-
Notifications
You must be signed in to change notification settings - Fork 99
feat: 支持上报模型返回的额外字段 #328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: 支持上报模型返回的额外字段 #328
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| from enum import Enum | ||
| from typing import Any | ||
| from typing import AsyncGenerator | ||
| from typing import Callable | ||
| from typing import Dict | ||
| from typing import List | ||
| from typing import Optional | ||
|
|
@@ -56,6 +57,7 @@ | |
|
|
||
| _HTTPCORE2_ATHROW_ERROR = "generator didn't stop after athrow" | ||
| _HTTP_BODY_DRAIN_TIMEOUT_S = 2.0 | ||
| ResponseMetadataExtractor = Callable[[dict[str, Any]], Optional[dict[str, Any]]] | ||
|
|
||
|
|
||
| def _is_httpx2_response(http_response: Any) -> bool: | ||
|
|
@@ -358,6 +360,10 @@ class OpenAIModel(LLMModel): | |
| the openai SDK's ``ResponseCreateParams`` and passed | ||
| through verbatim to ``responses.create``. The model, | ||
| input, and stream parameters remain managed by this class. | ||
| response_metadata_extractor: Optional callback that extracts a small, | ||
| JSON-serializable metadata dictionary from each | ||
| provider response or stream event. Extracted values | ||
| are attached to the final ``LlmResponse`` and trace. | ||
| **kwargs: Additional arguments passed to parent LLMModel class | ||
| (e.g., api_key, base_url, etc.) | ||
|
|
||
|
|
@@ -397,6 +403,7 @@ def __init__( | |
| http_client_provider_factory: HttpClientProviderFactory = temporary_http_client_provider_factory, | ||
| use_responses_api: bool = False, | ||
| responses_api_params: Optional[ResponseCreateParams] = None, | ||
| response_metadata_extractor: Optional[ResponseMetadataExtractor] = None, | ||
| **kwargs, | ||
| ): | ||
| super().__init__(model_name, filters_name, **kwargs) | ||
|
|
@@ -407,6 +414,7 @@ def __init__( | |
| self.client_args = kwargs.get(const.CLIENT_ARGS, {}) | ||
| self.use_responses_api = use_responses_api | ||
| self.responses_api_params = dict(responses_api_params or {}) | ||
| self._response_metadata_extractor = response_metadata_extractor | ||
| reserved_response_params = {"model", "input", "stream"}.intersection(self.responses_api_params) | ||
| if reserved_response_params: | ||
| names = ", ".join(sorted(reserved_response_params)) | ||
|
|
@@ -452,6 +460,40 @@ def _refresh_adapter(self) -> None: | |
| def is_retriable_status_code(self, status_code: int) -> Optional[bool]: | ||
| return status_code in {408, 409, 429} or status_code >= 500 | ||
|
|
||
| def _extract_provider_response_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: | ||
| """Extract allowlisted provider metadata without affecting model calls.""" | ||
| if self._response_metadata_extractor is None: | ||
| return {} | ||
| try: | ||
| metadata = self._response_metadata_extractor(response_data) | ||
| if metadata is None: | ||
| return {} | ||
| if not isinstance(metadata, dict): | ||
| logger.warning( | ||
| "response_metadata_extractor returned %s instead of dict; ignoring it", | ||
| type(metadata).__name__, | ||
| ) | ||
| return {} | ||
| # LlmResponse.custom_metadata must remain JSON serializable. | ||
| json.dumps(metadata) | ||
| return metadata | ||
| except Exception: # pylint: disable=broad-except | ||
| logger.warning("Failed to extract provider response metadata", exc_info=True) | ||
| return {} | ||
|
|
||
| @staticmethod | ||
| def _attach_provider_response_metadata( | ||
| response: LlmResponse, | ||
| metadata: dict[str, Any], | ||
| ) -> LlmResponse: | ||
| """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 | ||
|
Comment on lines
+489
to
+495
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 问题: 触发条件: 后续扩展在 实际影响: 当前无实害, 修正方向: 改为 |
||
|
|
||
| def is_retriable_exception(self, ex: Exception) -> bool: | ||
| if isinstance(ex, httpx.TimeoutException): | ||
| return True | ||
|
|
@@ -1753,7 +1795,12 @@ async def _generate_responses_single( | |
| **self._prepare_responses_api_params(client, api_params), | ||
| **(http_options or {}), | ||
| ) | ||
| return self._create_responses_response(self._model_dump(response)) | ||
| response_dict = self._model_dump(response) | ||
| llm_response = self._create_responses_response(response_dict) | ||
| return self._attach_provider_response_metadata( | ||
| llm_response, | ||
| self._extract_provider_response_metadata(response_dict), | ||
| ) | ||
| finally: | ||
| await self._http_client_provider.close_http_client(client) | ||
|
|
||
|
|
@@ -1783,8 +1830,11 @@ async def _generate_single( | |
|
|
||
| # Create response with content if we have text or tool calls | ||
| if has_text_content or has_tool_calls: | ||
| return self._create_response_with_content(response_dict) | ||
| return self._create_response_without_content(response_dict) | ||
| llm_response = self._create_response_with_content(response_dict) | ||
| else: | ||
| llm_response = self._create_response_without_content(response_dict) | ||
| provider_response_metadata = self._extract_provider_response_metadata(response_dict) | ||
| return self._attach_provider_response_metadata(llm_response, provider_response_metadata) | ||
|
Comment on lines
+1836
to
+1837
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 问题: 触发条件: 后端把目标字段放在 实际影响: 嵌套位置携带的厂商字段无法上报,链路观测数据缺失;且提取器失败仅产生一条 warning,用户难以定位。 修正方向: 向提取器传入完整响应 dict 的同时文档明确约定字段位置,或提取时对 |
||
| finally: | ||
| await self._http_client_provider.close_http_client(client) | ||
|
|
||
|
|
@@ -2257,9 +2307,10 @@ def upsert_function(item: dict) -> tuple[str, Dict[str, Any]]: | |
| if response is None: | ||
| raise ValueError("Empty response from Responses API") | ||
| _patch_stream_response_to_drain_http_body(response) | ||
|
|
||
| last_event_dict: dict[str, Any] = {} | ||
| async for event in response: | ||
| event_dict = self._model_dump(event) | ||
| last_event_dict = event_dict | ||
| event_type = event_dict.get("type", "") | ||
| logger.debug("OpenAI Responses event: %s", json.dumps(event_dict, ensure_ascii=False)) | ||
|
|
||
|
|
@@ -2374,7 +2425,9 @@ def upsert_function(item: dict) -> tuple[str, Dict[str, Any]]: | |
| final_response = self._create_responses_response(completed_response) | ||
| final_response.partial = False | ||
| final_response.custom_metadata = {"stream_complete": True} | ||
| yield final_response | ||
|
|
||
| provider_response_metadata = self._extract_provider_response_metadata(last_event_dict) | ||
| yield self._attach_provider_response_metadata(final_response, provider_response_metadata) | ||
| finally: | ||
| await _aclose_openai_stream(response) | ||
| try: | ||
|
|
@@ -2424,12 +2477,14 @@ async def _generate_stream( | |
| raise ValueError("Empty response from API") | ||
| _patch_stream_response_to_drain_http_body(response) | ||
|
|
||
| last_event_dict: dict[str, Any] = {} | ||
| async for chunk in response: | ||
| if chunk is None: | ||
| continue | ||
|
|
||
| chunk_dict: dict = chunk.model_dump() | ||
| logger.debug("🔥 RAW LLM CHUNK: %s", json.dumps(chunk_dict, ensure_ascii=False)) | ||
| last_event_dict = chunk_dict | ||
|
|
||
| # Capture response ID from chunk (only set once from first chunk that has it) | ||
| if response_id is None and chunk_dict.get("id"): | ||
|
|
@@ -2628,14 +2683,15 @@ async def _generate_stream( | |
| if last_usage: | ||
| # Create a compatible usage metadata object | ||
| final_usage = last_usage # Use the existing usage object for now | ||
|
|
||
| yield LlmResponse( | ||
| final_response = LlmResponse( | ||
| content=final_content, | ||
| usage_metadata=final_usage, | ||
| partial=False, | ||
| response_id=response_id, | ||
| custom_metadata={"stream_complete": True}, | ||
| ) | ||
| provider_response_metadata = self._extract_provider_response_metadata(last_event_dict) | ||
| yield self._attach_provider_response_metadata(final_response, provider_response_metadata) | ||
|
Comment on lines
+2693
to
+2694
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 问题: 流式路径(Chat Completions 触发条件: 响应头/首个 chunk 携带元数据、或 SSE 流有多个收尾事件(真实后端常见),末帧不含目标字段。 实际影响: 依赖该功能的可观测性场景(如 Venus 追踪链路)中元数据静默丢失,最终 修正方向: 累积候选事件(如保留首个携带且提取器返回非空的 |
||
| finally: | ||
| await _aclose_openai_stream(response) | ||
| await self._http_client_provider.close_http_client(client) | ||
There was a problem hiding this comment.
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 流提取等用例的断言。