feat: 支持上报模型返回的额外字段 - #328
Conversation
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。 发现的问题中等
问题: 流式路径(Chat Completions 触发条件: 响应头/首个 chunk 携带元数据、或 SSE 流有多个收尾事件(真实后端常见),末帧不含目标字段。 实际影响: 依赖该功能的可观测性场景(如 Venus 追踪链路)中元数据静默丢失,最终 修正方向: 累积候选事件(如保留首个携带且提取器返回非空的 中等
问题: 触发条件: 后端把目标字段放在 实际影响: 嵌套位置携带的厂商字段无法上报,链路观测数据缺失;且提取器失败仅产生一条 warning,用户难以定位。 修正方向: 向提取器传入完整响应 dict 的同时文档明确约定字段位置,或提取时对 较低
问题: 新测试 触发条件: 回归测试执行时,凡依赖上述未覆盖分支的用户场景(厂商字段早到、提取器异常、Responses API 流)都没有防回归保障。 实际影响: 异常/非 dict 返回被静默吞掉、末帧覆盖中间帧等缺陷无法被 CI 发现,且将来改动易引入回归。 修正方向: 补齐 较低
问题: 触发条件: 后续扩展在 实际影响: 当前无实害, 修正方向: 改为 |
| provider_response_metadata = self._extract_provider_response_metadata(last_event_dict) | ||
| yield self._attach_provider_response_metadata(final_response, provider_response_metadata) |
There was a problem hiding this comment.
问题: 流式路径(Chat Completions _generate_stream 与 Responses API _generate_responses_stream)仅在 async for 结束后用最后一块 chunk 调用一次提取器。OpenAI 兼容后端在 SSE 流终止前往往还会发送额外的元数据事件(如无 choices 的 usage 收尾块、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 到达时调用提取器并合并非空结果,而不只在循环结束后用最后一帧。
| provider_response_metadata = self._extract_provider_response_metadata(response_dict) | ||
| return self._attach_provider_response_metadata(llm_response, provider_response_metadata) |
There was a problem hiding this comment.
问题: _generate_single(非流式)中提取器收到的是 response.model_dump() 的顶层 dict。多数 OpenAI 兼容后端把厂商字段放在响应体顶层(如 providerRequestId、some_field),测试也如此构造;但部分后端(如 OpenAI Responses 结构或 gateway 实现)把元数据放在 choices[].message 或 response 嵌套对象里,提取器只拿到顶层 dict,看不到嵌套字段。已有测试 test_non_streaming_extracts_provider_metadata 只覆盖顶层场景。
触发条件: 后端把目标字段放在 choices[0].message、response 或其他嵌套层而非顶层时。
实际影响: 嵌套位置携带的厂商字段无法上报,链路观测数据缺失;且提取器失败仅产生一条 warning,用户难以定位。
修正方向: 向提取器传入完整响应 dict 的同时文档明确约定字段位置,或提取时对 choices[0].message 等常见嵌套位置做兜底查找;并补充非顶层字段的测试。
| 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): |
There was a problem hiding this comment.
问题: 新测试 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 流提取等用例的断言。
| """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 |
There was a problem hiding this comment.
问题: _attach_provider_response_metadata 直接原地 response.custom_metadata[const.PROVIDER_RESPONSE_METADATA] = metadata,没有走 model_copy。终态 LlmResponse 是非流式 _generate_single 中 yield 前的最后一步,流式路径也只作用于新建的 final response,属安全;但 _responses_error 与非流式 _generate_single 中 custom_metadata 存在其他分支赋值(如 _retry.py 的 error response),若后续在此基类上复用该方法,原地修改可能污染同一实例。
触发条件: 后续扩展在 _attach_provider_response_metadata 被调用时引用同一 LlmResponse 的 custom_metadata 的其他字段。
实际影响: 当前无实害,custom_metadata 的赋值本就要求整体兼容;仅提示未来改动与 partial 响应路径共用一个实例时可能产生隐式共享。
修正方向: 改为 response.model_copy(update={'custom_metadata': {**(response.custom_metadata or {}), const.PROVIDER_RESPONSE_METADATA: metadata}}) 或先 dict() 再赋值,保持不变性。
No description provided.