Skip to content

feat: 支持上报模型返回的额外字段 - #328

Open
raychen911 wants to merge 1 commit into
mainfrom
feat/report_model_info
Open

feat: 支持上报模型返回的额外字段#328
raychen911 wants to merge 1 commit into
mainfrom
feat/report_model_info

Conversation

@raychen911

Copy link
Copy Markdown
Contributor

No description provided.

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

通过

审查范围:提交 c98a61d..e5ea854「feat: 支持上报模型返回的额外字段」,共 9 个文件(212 增 / 8 删),核心为 OpenAIModel 新增 response_metadata_extractor 提取器(四条生成路径接入)、_trace.py 上报 provider_response_metadata span 属性、示例与 2 个测试文件。计划符合性:功能主体(非流式/流式/Responses 流四路接入、JSON 可序列化校验、异常吞并、trace 上报、示例与测试)均已落地;_runner_manager.py 的 await 修复与 Runner.close 的 async 签名一致,正确。主要风险:1) 流式路径仅取最后一帧提取元数据,真实后端元数据帧位置不保证为末帧(尤其 Responses 流会被 terminal 事件覆盖),存在静默丢失;2) 非流式提取仅见顶层 dict,嵌套层厂商字段拿不到;3) 提取器返回关键信息缺失时仅 warn,无透出。测试充分性:新增 3 个测试覆盖非流式顶层提取、流式末帧提取、trace 属性上报,但未覆盖异常/非 dict 返回、元数据位于首/中帧、Responses 流路径、嵌套字段位置。门禁结论:无 SEVERE 级阻断性问题(无数据损坏、崩溃、安全漏洞;核心丢失风险与位置契约问题以 MODERATE 呈现),state 为 PASSED。

发现的问题

中等

trpc_agent_sdk/models/_openai_model.py:2693-2694

问题: 流式路径(Chat Completions _generate_stream 与 Responses API _generate_responses_stream)仅在 async for 结束后用最后一块 chunk 调用一次提取器。OpenAI 兼容后端在 SSE 流终止前往往还会发送额外的元数据事件(如无 choicesusage 收尾块、response.completed 等),这些事件在真实流中是最后一条;而测试 test_streaming_extracts_provider_metadata_from_usage_chunk 中最后一块恰好是携带 venusMarker 的 usage-only chunk——该字段在该块中才出现。即实测中只有当后端调度恰好把元数据放在最后一帧时才成功;若元数据早到(如首个非空 chunk 或中间 chunk 携带),或最后还有一个不含该字段的收尾 chunk,提取器见不到它,元数据被丢弃。Responses 流中 last_event_dict 也常被 terminal 的 response.completed 事件(response_data 中通常不含顶层元数据)覆盖。

触发条件: 响应头/首个 chunk 携带元数据、或 SSE 流有多个收尾事件(真实后端常见),末帧不含目标字段。

实际影响: 依赖该功能的可观测性场景(如 Venus 追踪链路)中元数据静默丢失,最终 LlmResponse.custom_metadata 及 trace span 中缺少本应上报的 provider_response_metadata

修正方向: 累积候选事件(如保留首个携带且提取器返回非空的 last_event_dict,即第一次提取成功后不再覆盖),或在每个 chunk 到达时调用提取器并合并非空结果,而不只在循环结束后用最后一帧。

中等

trpc_agent_sdk/models/_openai_model.py:1836-1837

问题: _generate_single(非流式)中提取器收到的是 response.model_dump() 的顶层 dict。多数 OpenAI 兼容后端把厂商字段放在响应体顶层(如 providerRequestIdsome_field),测试也如此构造;但部分后端(如 OpenAI Responses 结构或 gateway 实现)把元数据放在 choices[].messageresponse 嵌套对象里,提取器只拿到顶层 dict,看不到嵌套字段。已有测试 test_non_streaming_extracts_provider_metadata 只覆盖顶层场景。

触发条件: 后端把目标字段放在 choices[0].messageresponse 或其他嵌套层而非顶层时。

实际影响: 嵌套位置携带的厂商字段无法上报,链路观测数据缺失;且提取器失败仅产生一条 warning,用户难以定位。

修正方向: 向提取器传入完整响应 dict 的同时文档明确约定字段位置,或提取时对 choices[0].message 等常见嵌套位置做兜底查找;并补充非顶层字段的测试。

较低

tests/models/test_openai_model_ext.py:1528-1590

问题: 新测试 test_streaming_extracts_provider_metadata_from_usage_chunk 覆盖了 final usage-chunk 场景,但未覆盖提取器抛异常、返回非 dict、返回 None 及元数据在首个/中间 chunk(非末帧)的路径,未覆盖 _generate_responses_stream(Responses API)与 _generate_single 异常分支。这些分支的行为各不相同。

触发条件: 回归测试执行时,凡依赖上述未覆盖分支的用户场景(厂商字段早到、提取器异常、Responses API 流)都没有防回归保障。

实际影响: 异常/非 dict 返回被静默吞掉、末帧覆盖中间帧等缺陷无法被 CI 发现,且将来改动易引入回归。

修正方向: 补齐 except Exception 返回空 dict、非 dict 类型、首/中帧携带元数据、Responses 流提取等用例的断言。

较低

trpc_agent_sdk/models/_openai_model.py:489-495

问题: _attach_provider_response_metadata 直接原地 response.custom_metadata[const.PROVIDER_RESPONSE_METADATA] = metadata,没有走 model_copy。终态 LlmResponse 是非流式 _generate_singleyield 前的最后一步,流式路径也只作用于新建的 final response,属安全;但 _responses_error 与非流式 _generate_singlecustom_metadata 存在其他分支赋值(如 _retry.py 的 error response),若后续在此基类上复用该方法,原地修改可能污染同一实例。

触发条件: 后续扩展在 _attach_provider_response_metadata 被调用时引用同一 LlmResponsecustom_metadata 的其他字段。

实际影响: 当前无实害,custom_metadata 的赋值本就要求整体兼容;仅提示未来改动与 partial 响应路径共用一个实例时可能产生隐式共享。

修正方向: 改为 response.model_copy(update={'custom_metadata': {**(response.custom_metadata or {}), const.PROVIDER_RESPONSE_METADATA: metadata}}) 或先 dict() 再赋值,保持不变性。

Comment on lines +2693 to +2694
provider_response_metadata = self._extract_provider_response_metadata(last_event_dict)
yield self._attach_provider_response_metadata(final_response, provider_response_metadata)

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.

问题: 流式路径(Chat Completions _generate_stream 与 Responses API _generate_responses_stream)仅在 async for 结束后用最后一块 chunk 调用一次提取器。OpenAI 兼容后端在 SSE 流终止前往往还会发送额外的元数据事件(如无 choicesusage 收尾块、response.completed 等),这些事件在真实流中是最后一条;而测试 test_streaming_extracts_provider_metadata_from_usage_chunk 中最后一块恰好是携带 venusMarker 的 usage-only chunk——该字段在该块中才出现。即实测中只有当后端调度恰好把元数据放在最后一帧时才成功;若元数据早到(如首个非空 chunk 或中间 chunk 携带),或最后还有一个不含该字段的收尾 chunk,提取器见不到它,元数据被丢弃。Responses 流中 last_event_dict 也常被 terminal 的 response.completed 事件(response_data 中通常不含顶层元数据)覆盖。

触发条件: 响应头/首个 chunk 携带元数据、或 SSE 流有多个收尾事件(真实后端常见),末帧不含目标字段。

实际影响: 依赖该功能的可观测性场景(如 Venus 追踪链路)中元数据静默丢失,最终 LlmResponse.custom_metadata 及 trace span 中缺少本应上报的 provider_response_metadata

修正方向: 累积候选事件(如保留首个携带且提取器返回非空的 last_event_dict,即第一次提取成功后不再覆盖),或在每个 chunk 到达时调用提取器并合并非空结果,而不只在循环结束后用最后一帧。

Comment on lines +1836 to +1837
provider_response_metadata = self._extract_provider_response_metadata(response_dict)
return self._attach_provider_response_metadata(llm_response, provider_response_metadata)

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.

问题: _generate_single(非流式)中提取器收到的是 response.model_dump() 的顶层 dict。多数 OpenAI 兼容后端把厂商字段放在响应体顶层(如 providerRequestIdsome_field),测试也如此构造;但部分后端(如 OpenAI Responses 结构或 gateway 实现)把元数据放在 choices[].messageresponse 嵌套对象里,提取器只拿到顶层 dict,看不到嵌套字段。已有测试 test_non_streaming_extracts_provider_metadata 只覆盖顶层场景。

触发条件: 后端把目标字段放在 choices[0].messageresponse 或其他嵌套层而非顶层时。

实际影响: 嵌套位置携带的厂商字段无法上报,链路观测数据缺失;且提取器失败仅产生一条 warning,用户难以定位。

修正方向: 向提取器传入完整响应 dict 的同时文档明确约定字段位置,或提取时对 choices[0].message 等常见嵌套位置做兜底查找;并补充非顶层字段的测试。

Comment on lines +1528 to 1590
async def test_streaming_extracts_provider_metadata_from_usage_chunk(self):
"""Provider metadata survives a final usage-only chunk."""

def extract_metadata(response_data):
marker = response_data.get("venusMarker")
if not marker:
return None
return {"venus_marker": {"span_id": marker["spanId"]}}

model = _model(response_metadata_extractor=extract_metadata)
request = _request([Content(parts=[Part.from_text(text="hi")], role="user")])

content_chunk = Mock()
content_chunk.model_dump.return_value = {
"id": "resp_1",
"choices": [{
"delta": {
"content": "hello"
},
"finish_reason": "stop",
}],
"usage": None,
}
usage_chunk = Mock()
usage_chunk.model_dump.return_value = {
"id": "resp_1",
"choices": [],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
"venusMarker": {
"spanId": "9d3e43a402a76a5b"
},
}

async def mock_stream():
yield content_chunk
yield usage_chunk

with patch.object(model, "_create_async_client") as mock_factory:
mock_client = AsyncMock()
mock_client.chat.completions.create = AsyncMock(return_value=mock_stream())
mock_client.close = AsyncMock()
mock_factory.return_value = mock_client

responses = []
async for response in model.generate_async(request, stream=True):
responses.append(response)

final_response = next(response for response in responses if not response.partial)
assert final_response.custom_metadata == {
"stream_complete": True,
"provider_response_metadata": {
"venus_marker": {
"span_id": "9d3e43a402a76a5b"
}
},
}

@pytest.mark.asyncio
async def test_streaming_null_response_raises(self):

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.

问题: 新测试 test_streaming_extracts_provider_metadata_from_usage_chunk 覆盖了 final usage-chunk 场景,但未覆盖提取器抛异常、返回非 dict、返回 None 及元数据在首个/中间 chunk(非末帧)的路径,未覆盖 _generate_responses_stream(Responses API)与 _generate_single 异常分支。这些分支的行为各不相同。

触发条件: 回归测试执行时,凡依赖上述未覆盖分支的用户场景(厂商字段早到、提取器异常、Responses API 流)都没有防回归保障。

实际影响: 异常/非 dict 返回被静默吞掉、末帧覆盖中间帧等缺陷无法被 CI 发现,且将来改动易引入回归。

修正方向: 补齐 except Exception 返回空 dict、非 dict 类型、首/中帧携带元数据、Responses 流提取等用例的断言。

Comment on lines +489 to +495
"""Attach extracted metadata under a stable, provider-neutral namespace."""
if not metadata:
return response
custom_metadata = dict(response.custom_metadata or {})
custom_metadata[const.PROVIDER_RESPONSE_METADATA] = metadata
response.custom_metadata = custom_metadata
return response

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.

问题: _attach_provider_response_metadata 直接原地 response.custom_metadata[const.PROVIDER_RESPONSE_METADATA] = metadata,没有走 model_copy。终态 LlmResponse 是非流式 _generate_singleyield 前的最后一步,流式路径也只作用于新建的 final response,属安全;但 _responses_error 与非流式 _generate_singlecustom_metadata 存在其他分支赋值(如 _retry.py 的 error response),若后续在此基类上复用该方法,原地修改可能污染同一实例。

触发条件: 后续扩展在 _attach_provider_response_metadata 被调用时引用同一 LlmResponsecustom_metadata 的其他字段。

实际影响: 当前无实害,custom_metadata 的赋值本就要求整体兼容;仅提示未来改动与 partial 响应路径共用一个实例时可能产生隐式共享。

修正方向: 改为 response.model_copy(update={'custom_metadata': {**(response.custom_metadata or {}), const.PROVIDER_RESPONSE_METADATA: metadata}}) 或先 dict() 再赋值,保持不变性。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants