Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 266 additions & 0 deletions tests/models/test_openai_mixed_stream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
#
# Copyright (C) 2026 Tencent. All rights reserved.
#
# tRPC-Agent-Python is licensed under Apache-2.0.

import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch

import pytest
from trpc_agent_sdk.models import LlmRequest, OpenAIModel
from trpc_agent_sdk.models._openai_model import const


async def collect(deltas, streaming_names=None, usage_only=True):
chunks = [{"id": "synthetic-response", "choices": [{"delta": delta, "finish_reason": None}]} for delta in deltas]
usage = {"prompt_tokens": 4, "completion_tokens": 6, "total_tokens": 10}
chunks.append({"choices": [{"delta": {}, "finish_reason": "tool_calls"}]})
if usage_only:
chunks.append({"choices": [], "usage": usage})
else:
chunks[0]["usage"] = usage

async def stream():
for chunk in chunks:
yield Mock(model_dump=lambda value=chunk: value)

client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=AsyncMock(return_value=stream()))),
close=AsyncMock())
model = OpenAIModel(model_name="synthetic", api_key="synthetic")
request = LlmRequest()
request.streaming_tool_names = streaming_names
with patch.object(OpenAIModel, "_create_async_client", return_value=client):
return [response async for response in model._generate_stream({}, request)]


def tool_delta(index, arguments, first=True):
result = {"index": index, "function": {"arguments": arguments}}
if first:
result.update(id=f"call_{index}", type="function")
result["function"]["name"] = f"tool_{index}"
return result


@pytest.mark.parametrize("text", [{}, {
"content": "text"
}, {
"reasoning_content": "synthetic"
}, {
"content": "text",
"reasoning_content": "synthetic"
}, {
"content": "",
"reasoning_content": None
}])
@pytest.mark.parametrize("fragmented", [False, True])
@pytest.mark.parametrize("tool_count", [1, 2])
@pytest.mark.parametrize("streaming", [False, True])
@pytest.mark.asyncio
async def test_mixed_stream_preserves_calls_and_text(text, fragmented, tool_count, streaming):
arguments = json.dumps({"value": "中文", "count": 12}, ensure_ascii=False)
fragments = [arguments[:12], arguments[12:]] if fragmented else [arguments]
deltas = [
dict(text, tool_calls=[tool_delta(index, fragment, first=part == 0) for index in range(tool_count)])
for part, fragment in enumerate(fragments)
]
responses = await collect(deltas, {"tool_0"} if streaming else set(), usage_only=fragmented)
final = responses[-1]
assert not final.partial
assert final.error_code is None
assert final.response_id == "synthetic-response"
assert final.usage_metadata.total_token_count == 10
calls = [part.function_call for part in final.content.parts if part.function_call]
assert [(call.id, call.name, call.args) for call in calls] == [(f"call_{index}", f"tool_{index}", {
"value": "中文",
"count": 12
}) for index in range(tool_count)]
for thought, field in [(False, "content"), (True, "reasoning_content")]:
expected = (text.get(field) or "") * len(fragments)
assert "".join(part.text for part in final.content.parts
if part.text and bool(part.thought) == thought) == expected
assert "".join(part.text for response in responses[:-1] if response.content for part in response.content.parts
if part.text and bool(part.thought) == thought) == expected
partial_calls = [
part.function_call for response in responses[:-1] if response.content for part in response.content.parts
if part.function_call
]
assert [(call.id, call.name, call.args) for call in partial_calls] == ([("call_0", "tool_0", {
const.TOOL_STREAMING_ARGS: fragment
}) for fragment in fragments] if streaming else [])
Comment on lines +79 to +91

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 新增测试未覆盖本次变更最关键的对外契约——流式工具事件与文本 partial 的相对顺序(及二者在同一 chunk 内的交错)。test_mixed_stream_preserves_calls_and_text(本文件 79-91 行)分别断言文本聚合与工具 delta 序列,test_one_mixed_fragment...(123-132 行)仅断言工具 delta 序列;text={} 参数化用例(46 行第一个参数)使 deltas 退化为纯工具 chunk,断言为空;混合 chunk + 分片 + 非法 JSON 修复路径组合也未覆盖。

触发条件: 任何未来改动如翻转事件产出顺序(先文本后工具、或合并事件)都会通过这些测试但改变消费端可见契约。

实际影响: 本次已确认的代理文本丢失与 AG-UI 消息拆分回归(见前面评论)在测试中不可见,回归防护缺失,测试对消费端协议顺序给出虚假的安全感。

修正方向: 增加对完整事件序列(含顺序)的断言,例如按序收集所有 responses 的事件类型列表并校验混合 chunk 的事件顺序为 [streaming-tool, text],并补充混合 + 分片 + 非法 JSON 的修复路径用例。



@pytest.mark.asyncio
async def test_separate_text_and_tool_chunks():
responses = await collect([{"content": "text"}, {"tool_calls": [tool_delta(0, '{"ok": true}')]}])
parts = responses[-1].content.parts
assert parts[0].text == "text"
assert parts[1].function_call.args == {"ok": True}


@pytest.mark.parametrize("mixed", [False, True])
@pytest.mark.asyncio
async def test_invalid_json_preserves_error_envelope(mixed):
delta = {"tool_calls": [tool_delta(0, 'not-json'), tool_delta(1, '{"ok": true}')]}
if mixed:
delta["content"] = "text"
responses = await collect([delta])
final = responses[-1]
calls = [part.function_call for part in final.content.parts if part.function_call]
assert [call.name for call in calls] == ["tool_0", "tool_1"]
assert const.TOOL_CALL_ARGUMENT_ERRORS in calls[0].args
assert calls[1].args == {"ok": True}
assert final.error_code is None


@pytest.mark.parametrize("mixed_index", [0, 1, 2])
@pytest.mark.asyncio
async def test_one_mixed_fragment_cannot_silently_change_arguments(mixed_index):
fragments = ['{"count":1', '2', '}']
deltas = [{"tool_calls": [tool_delta(0, fragment, first=index == 0)]} for index, fragment in enumerate(fragments)]
deltas[mixed_index]["content"] = "text"
responses = await collect(deltas, {"tool_0"})
calls = [part.function_call for part in responses[-1].content.parts if part.function_call]
assert len(calls) == 1
assert calls[0].id == "call_0"
assert calls[0].args == {"count": 12}
streamed = [
part.function_call.args[const.TOOL_STREAMING_ARGS] for response in responses[:-1] if response.content
for part in response.content.parts if part.function_call
]
assert streamed == fragments


@pytest.mark.asyncio
async def test_mixed_chunk_text_precedes_streaming_tool_event():
responses = await collect([{
"content": "Recording now.",
"tool_calls": [tool_delta(0, '{"count":12}')]
}], {"tool_0"})
assert responses[0].partial
assert responses[0].content.parts[0].text == "Recording now."
assert responses[1].partial
assert responses[1].content.parts[0].function_call.name == "tool_0"


@pytest.mark.asyncio
async def test_mixed_chunks_keep_text_through_anthropic_proxy():
pytest.importorskip("claude_agent_sdk")
from trpc_agent_sdk.server.agents.claude._proxy import AnthropicMessagesRequest, AnthropicProxyApp

responses = await collect([
{
"content": "Recording ",
"tool_calls": [tool_delta(0, '{"count":')]
},
{
"content": "now.",
"tool_calls": [tool_delta(0, '12}', first=False)]
},
], {"tool_0"})

async def generate(request, stream=True):
for response in responses:
yield response

model = SimpleNamespace(generate_async=generate)
request = AnthropicMessagesRequest(model="synthetic", messages=[{"role": "user", "content": "record"}], stream=True)
events = [event async for event in AnthropicProxyApp()._handle_streaming(LlmRequest(), request, model)]
payloads = [
json.loads(line[6:]) for event in events for line in event.splitlines()
if line.startswith("data: ") and line != "data: [DONE]"
]
deltas = [payload["delta"] for payload in payloads if payload["type"] == "content_block_delta"]
assert "".join(delta["text"] for delta in deltas if delta["type"] == "text_delta") == "Recording now."
assert "".join(delta["partial_json"] for delta in deltas if delta["type"] == "input_json_delta") == '{"count":12}'

starts = [payload["index"] for payload in payloads if payload["type"] == "content_block_start"]
stops = [payload["index"] for payload in payloads if payload["type"] == "content_block_stop"]
assert len(starts) == len(set(starts))
assert sorted(starts) == sorted(stops)
Comment on lines +156 to +181

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 新测试存在两处盲区,无法覆盖本次变更引入的若干新代码行与回归面:1) 测试模型均未设置 add_tools_to_prompt(tool_prompt=None),因此 2563-2566 与 2594-2599 新增的 yield streaming_event; continue 过滤路径从未被执行;2) 代理测试断言 sorted(starts) == sorted(stops) 对索引顺序不敏感,无法发现 stop 非递增、missing stop、stop_reason 错误等新逻辑回归;3) 代理测试直接调用 _handle_streaming(LlmRequest(), ...),未经过 _convert_anthropic_to_llm_request 转换链路(生产路径在此设置 streaming_tool_names 与系统提示词)。

触发条件: 后续修改若破坏了过滤抑制路径下的工具事件产出、stop 顺序或转换链路上的 streaming_tool_names 设置,整套测试仍保持绿色。

实际影响: 本次变更新增的最复杂状态机(文本块重开、事件经多个 continue 分支产出)缺少有效回归防护,回归只能靠生产事故暴露。

修正方向: 为工具提示过滤路径补充用例(构造 add_tools_to_prompt=True、ToolPromptTextFilterMixin 抑制状态 + 混合 chunk);代理测试改为断言 stop 事件与 start 事件按索引成对且严格有序、message_delta.stop_reason 正确,并通过 AnthropicProxyApp 的真实路由(/v1/messages)或 _convert_anthropic_to_llm_request 后的 request 走一遍端到端路径。



@pytest.mark.parametrize("mixed", [False, True])
@pytest.mark.asyncio
async def test_streaming_tool_events_keep_response_identity_and_chunk_metadata(mixed):
deltas = [
{
"tool_calls": [tool_delta(0, '{"count":')]
},
{
"tool_calls": [tool_delta(0, '12}', first=False)]
},
]
if mixed:
for delta in deltas:
delta["content"] = "text"
responses = await collect(deltas, {"tool_0"})
tool_events = [
response for response in responses[:-1] if any(part.function_call for part in response.content.parts)
]
assert len(tool_events) == 2
for event, delta in zip(tool_events, deltas):
assert event.response_id == responses[-1].response_id == "synthetic-response"
assert event.custom_metadata[const.TOOL_STREAMING] is True
assert event.custom_metadata[const.CHUNK]["choices"][0]["delta"] == delta


@pytest.mark.asyncio
async def test_fragmented_invalid_json_in_mixed_chunks_keeps_error_envelope():
responses = await collect([
{
"content": "Recording ",
"tool_calls": [tool_delta(0, 'not-')]
},
{
"content": "now.",
"tool_calls": [tool_delta(0, 'json', first=False)]
},
], {"tool_0"})
assert [bool(response.content.parts[0].text) for response in responses[:-1]] == [True, False, True, False]
call = next(part.function_call for part in responses[-1].content.parts if part.function_call)
assert call.name == "tool_0"
assert const.TOOL_CALL_ARGUMENT_ERRORS in call.args
assert responses[-1].error_code is None


@pytest.mark.asyncio
async def test_proxy_closes_open_blocks_when_interleaved_stream_fails():
pytest.importorskip("claude_agent_sdk")
from trpc_agent_sdk.server.agents.claude._proxy import AnthropicMessagesRequest, AnthropicProxyApp

responses = await collect([
{
"content": "Recording ",
"tool_calls": [tool_delta(0, '{"count":')]
},
{
"content": "now."
},
], {"tool_0"})

async def generate(request, stream=True):
for response in responses[:-1]:
yield response
raise RuntimeError("test stream interrupted")

request = AnthropicMessagesRequest(model="synthetic", messages=[{"role": "user", "content": "record"}], stream=True)
model = SimpleNamespace(generate_async=generate)
events = [event async for event in AnthropicProxyApp()._handle_streaming(LlmRequest(), request, model)]
payloads = [
json.loads(line[6:]) for event in events for line in event.splitlines()
if line.startswith("data: ") and line != "data: [DONE]"
]
open_blocks = set()
for payload in payloads:
if payload["type"] == "content_block_start":
assert payload["index"] not in open_blocks
open_blocks.add(payload["index"])
elif payload["type"] == "content_block_stop":
assert payload["index"] in open_blocks
open_blocks.remove(payload["index"])
elif payload["type"] in {"message_delta", "message_stop"}:
assert not open_blocks
assert not open_blocks
assert next(p for p in payloads if p["type"] == "message_delta")["delta"]["stop_reason"] == "error"
57 changes: 32 additions & 25 deletions trpc_agent_sdk/models/_openai_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1118,7 +1118,7 @@ def _process_responses_usage(self, response_dict: dict) -> Optional[GenerateCont
def _process_chunk_without_content(
self, chunk_dict: dict, accumulated_tool_calls: list[dict]
) -> tuple[Optional[FinishReason], Optional[GenerateContentResponseUsageMetadata], dict[int, str]]:
"""Process a chunk that doesn't contain content.
"""Process tool calls and usage, including chunks that also contain text.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 本提交将 _process_chunk_without_content 改为对每个 chunk 无条件调用,其方法名("without content")与新语义(含文本 chunk 也调用)相矛盾——此处 docstring 已更新但名称未改;同时每个 chunk 现在对同一嵌套结构做多次重复解析(_process_chunk_without_content 解析 choices[0].delta、_verify_text_content_in_delta_response 再次解析、内容区第三次取 choice[const.DELTA]),finish_reason 返回值在 2525-2526 行仍被 _ 丢弃。

触发条件: 代码维护期;高 token 速率流式场景下每个文本 chunk 的多余 dict 遍历。

实际影响: 命名与调用语义背离易诱导后续维护者把调用移回旧的 "if not verify" 分支内(重新引入本提交修复的 bug);重复解析是热点循环中的冗余开销(数值上很小)。

修正方向: 将方法更名为 _process_chunk(或 _process_tool_calls_and_usage)并合并单次 delta 解析:让该方法同时返回文本存在性,消除重复遍历;顺带删除已无消费者的 finish_reason 返回值。


Args:
chunk_dict (`dict`): The chunk dictionary to process
Expand Down Expand Up @@ -1149,7 +1149,7 @@ def _process_chunk_without_content(
# Handle usage
usage = self._process_usage(chunk_dict)

# Handle tool calls in chunks without content (this is where streaming tool calls happen)
# Handle tool calls independently of text content.
tool_calls_data = delta.get(const.TOOL_CALLS)

# Track delta arguments from this chunk
Expand Down Expand Up @@ -2519,30 +2519,36 @@ async def _generate_stream(
# We can log them but don't need to yield them as content
continue

# Verify if the chunk contains valid text content
if not self._verify_text_content_in_delta_response(chunk_dict):
# Process chunk without content (this is where tool calls are streamed)
_, usage, delta_arguments = self._process_chunk_without_content(chunk_dict, accumulated_tool_calls)

if usage:
last_usage = usage

# If streaming tool call arguments is enabled, yield partial tool call events
# delta_arguments being non-empty means tool calls were processed
if streaming_tool_names and delta_arguments and accumulated_tool_calls:
# Yield streaming tool call event with delta arguments
# Only for tools in streaming_tool_names
streaming_event = self._create_streaming_tool_call_response(accumulated_tool_calls,
delta_arguments,
streaming_tool_names)
if streaming_event:
yield streaming_event
# Tool deltas and text can coexist in the same chunk.
_, usage, delta_arguments = self._process_chunk_without_content(chunk_dict, accumulated_tool_calls)

if usage:
last_usage = usage

# Emit text before tool events from the same chunk for streaming consumers.
streaming_event = None
# If streaming tool call arguments is enabled, prepare partial tool call events
# delta_arguments being non-empty means tool calls were processed
if streaming_tool_names and delta_arguments and accumulated_tool_calls:
# Yield streaming tool call event with delta arguments
# Only for tools in streaming_tool_names
streaming_event = self._create_streaming_tool_call_response(accumulated_tool_calls,
delta_arguments,
streaming_tool_names)
Comment on lines +2535 to +2537

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 此处调用的 _create_streaming_tool_call_response(1348-1352 行)构造的流式工具事件 LlmResponse 未携带 response_id,而同 chunk 的文本事件(2608-2613 行)携带了 response_id=response_id。本次变更使流式工具事件在更多 chunk 上产出(混合 chunk 也会产出),放大了该缺口。

触发条件: 任何启用流式工具且模型产出工具增量的回合;LlmProcessor._create_event_from_response 将缺失的 response_id 透传为 None,a2a _event_converter._effective_response_id(257-263 行)回退为每事件随机 UUID,_sql_session_service(290 行)按事件存储 response_id。

实际影响: 同一回合的流式工具事件与文本/终态事件无法按 response_id 关联,会话持久化与 a2a 消息聚合把同回合事件当作不同消息处理,影响消息分组、压缩与检索的完整性。

修正方向: 在 _create_streaming_tool_call_response 返回的 LlmResponse 上补充 response_id(由调用方把当前 response_id 传入并设置到返回对象),并同步检查 _anthropic_model.py 中同类实现(430-433 行)。

if streaming_event:
streaming_event.response_id = response_id
streaming_event.custom_metadata = {const.CHUNK: chunk_dict, const.TOOL_STREAMING: True}
Comment on lines +2538 to +2540

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 流式工具调用事件新增 custom_metadata = {const.CHUNK: chunk_dict, const.TOOL_STREAMING: True},把包含原始 tool_calls/function.arguments 增量的完整 provider chunk 塞进每条事件;此前流式工具事件的 custom_metadata 为空。

触发条件: 启用 streaming_tool_names 且 provider 逐片段下发工具参数时,每个片段都会产生一条带此元数据的事件;AG-UI 翻译器对任何带 custom_metadata 的事件都产出 CustomEvent(name="trpc_metadata") 下发客户端(_event_translator.py:232-239),同一混合 chunk 的文本事件({CHUNK: chunk_dict})与工具事件各发一条,内容几乎重复。

实际影响: 客户端每收到一个工具参数片段就多收到一条携带原始 provider JSON 的 trpc_metadata CustomEvent,负载随片段数线性增长且与文本事件元数据重复;将 provider 内部字段(含参数增量原文)暴露给前端,属新增的信息面。

修正方向: 若该元数据仅用于响应关联/调试,可仅在首片段或末片段附加,或复用与文本事件一致的轻量元数据(如仅 const.TOOL_STREAMING: True),避免逐片段透传完整 chunk_dict。


if not self._verify_text_content_in_delta_response(chunk_dict):
if streaming_event:
yield streaming_event
continue
Comment on lines +2522 to 2545

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 本变更将流式工具调用事件(_create_streaming_tool_call_response 生成的 partial LlmResponse)从"仅无文本块"提前到对所有块(含文本+工具调用混合块)在文本处理之前 yield。下游 Anthropic 兼容 SSE 代理 server/agents/claude/_proxy.py 在收到第一个流式工具事件时即置 text_block_closed=True 并关闭 index 0 文本块(_proxy.py:779-780),其后的文本部分事件全部被 if part.text and not text_block_closed(_proxy.py:825)丢弃;而该代理的最终响应分支从不发送最终累积文本。变更前混合块只产生文本部分事件(文本保留、工具调用被 _generate_stream 静默丢弃),变更后文本被代理丢弃——文本丢失是本变更新引入的可达回归。

触发条件: AnthropicProxyApp 配置 OpenAI 兼容模型,request.stream=True 且请求携带 tools(此时代理总是设置 streaming_tool_names={所有工具名}),模型在某一 chunk 中同时返回 content 文本与 tool_calls,或在首个工具 delta 之后继续输出文本。

实际影响: 通过该代理接入的客户端(如 Claude Code 风格的 Anthropic 流式消费方)拿到的 SSE 流中助手文本整段缺失,表现为"只见工具调用、不见任何前言文本";而变更前该场景文本可正常送达。工具调用增量 JSON 仍可通过 input_json_delta 送达,故工具执行不受影响,但用户可见输出数据丢失。

修正方向: 代理层(_proxy.py)在收到流式工具事件时不要立即关闭文本块,改为在最终响应到达(或流结束)时统一关闭并补发文本增量;或在 _generate_stream 中为混合块保持"先文本、后工具事件"的 yield 顺序并在代理层对工具事件后的文本做透传。同时建议新增代理层消费方的回归测试,覆盖"文本+工具调用混合 chunk"流经 _handle_streaming 时文本不丢失。


# Process chunk with valid content
choices = chunk_dict.get(const.CHOICES, [])
if not choices:
if streaming_event:
yield streaming_event
continue # Skip if no choices available
choice: dict[str, dict] = choices[0]
delta = choice[const.DELTA]
Expand All @@ -2558,6 +2564,8 @@ async def _generate_stream(
partial_text = self._adapter.filter_streaming_text(reasoning_content,
reasoning_filter_state)
if not partial_text:
if streaming_event:
yield streaming_event
continue

# Reasoning content is always thinking content
Expand Down Expand Up @@ -2589,6 +2597,8 @@ async def _generate_stream(
content_filter_state = streaming_text_filter_state["content"]
partial_text = self._adapter.filter_streaming_text(content, content_filter_state)
if not partial_text:
if streaming_event:
yield streaming_event
continue
if not is_thinking:
visible_content += partial_text
Expand All @@ -2604,11 +2614,8 @@ async def _generate_stream(
response_id=response_id,
custom_metadata={const.CHUNK: chunk_dict},
)

# Handle usage
usage = self._process_usage(chunk_dict)
if usage:
last_usage = usage
if streaming_event:
yield streaming_event
Comment on lines +2617 to +2618

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 本次变更使 OpenAIModel 对"文本+tool_calls 共存"的同一 chunk 拆分为两个事件产出(先文本 partial,再于末尾 yield 流式工具事件),这是下游 AG-UI 翻译器回合割裂的根因所在:_translate_streaming_tool_call 在首个流式工具事件时调用 force_close_streaming_message() 结束当前文本消息;当模型在工具增量之后继续输出文本(本变更新增支持的场景)时,后续文本事件在 _translate_text_content 中生成新的 TEXT_MESSAGE_START(新的随机 message_id)。

触发条件: 启用流式工具(StreamingFunctionTool)+ 模型在工具增量之间穿插输出文本的任一回合;变更前同 chunk 混合文本+工具只产出文本事件(工具增量被丢弃),因此该拆分与后续消息割裂是本次变更引入的。

实际影响: 同一回合被切分为多个 AG-UI 消息(如"Recording"与"now."分属两个 message_id),按 message_id 聚合、去重或渲染为独立气泡的前端会看到回合被割裂,消息时序与原文顺序错乱。

修正方向: 在事件翻译层对同一回合内工具事件之后的文本延续复用原消息(如记录"工具间文本"状态,不重新 START),或将本行末尾的独立事件 yield 与文本 partial 合并为同一 LlmResponse(同 chunk 的文本与工具增量合入同一 Content),从源头保证一轮内文本与工具事件的消息连续性。


if tool_prompt and streaming_text_filter_state is not None:
if self._adapter.should_filter_reasoning_text():
Expand Down
Loading
Loading