-
-
Notifications
You must be signed in to change notification settings - Fork 11.6k
fix(chatgpt): preserve responses routing and recover empty output #26219
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
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 |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ | |
| import litellm | ||
| from litellm import ModelResponse | ||
| from litellm._logging import verbose_logger | ||
| from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper | ||
| from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator | ||
| from litellm.llms.base_llm.bridges.completion_transformation import ( | ||
| CompletionTransformationBridge, | ||
|
|
@@ -97,7 +98,7 @@ def _build_reasoning_item( | |
|
|
||
|
|
||
| def _reasoning_item_to_response_input( | ||
| r_item: Union[ChatCompletionReasoningItem, Dict[str, Any]] | ||
| r_item: Union[ChatCompletionReasoningItem, Dict[str, Any]], | ||
| ) -> Dict[str, Any]: | ||
| """Convert a stored ChatCompletionReasoningItem back to a Responses API input item.""" | ||
| r_input: Dict[str, Any] = { | ||
|
|
@@ -583,6 +584,125 @@ def _convert_response_output_to_choices( | |
|
|
||
| return choices | ||
|
|
||
| @classmethod | ||
| def _recover_output_items_from_raw_sse( | ||
| cls, raw_sse: Optional[str] | ||
| ) -> List[Dict[str, Any]]: | ||
| if not raw_sse or not isinstance(raw_sse, str): | ||
| return [] | ||
|
|
||
| recovered_output_items: Dict[int, Dict[str, Any]] = {} | ||
| recovered_text_only_items: Dict[int, Dict[str, Any]] = {} | ||
|
|
||
| for chunk in raw_sse.splitlines(): | ||
| stripped_chunk = ( | ||
| CustomStreamWrapper._strip_sse_data_from_chunk(chunk.strip()) or "" | ||
| ).strip() | ||
| if ( | ||
| not stripped_chunk | ||
| or stripped_chunk == "[DONE]" | ||
| or stripped_chunk.startswith("event:") | ||
| ): | ||
| continue | ||
|
|
||
| try: | ||
| parsed_chunk = json.loads(stripped_chunk) | ||
| except json.JSONDecodeError: | ||
| continue | ||
|
|
||
| if not isinstance(parsed_chunk, dict): | ||
| continue | ||
|
|
||
| event_type = parsed_chunk.get("type") | ||
|
|
||
| if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED: | ||
| response_payload = parsed_chunk.get("response") | ||
| if isinstance(response_payload, dict): | ||
| response_output = response_payload.get("output") | ||
| if isinstance(response_output, list) and len(response_output) > 0: | ||
| return cast(List[Dict[str, Any]], response_output) | ||
| continue | ||
|
|
||
| if event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE: | ||
| item = parsed_chunk.get("item") | ||
| if not isinstance(item, dict): | ||
| continue | ||
| try: | ||
| output_index = int(parsed_chunk.get("output_index")) | ||
| except (TypeError, ValueError): | ||
| output_index = len(recovered_output_items) | ||
| recovered_output_items[output_index] = item | ||
| continue | ||
|
|
||
| if event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE: | ||
| text = parsed_chunk.get("text") | ||
| if not isinstance(text, str): | ||
| continue | ||
|
|
||
| try: | ||
| output_index = int(parsed_chunk.get("output_index")) | ||
| except (TypeError, ValueError): | ||
| output_index = len(recovered_text_only_items) | ||
|
|
||
| item = recovered_output_items.get( | ||
| output_index | ||
| ) or recovered_text_only_items.get(output_index) | ||
| if item is None: | ||
| item = { | ||
| "type": "message", | ||
| "id": parsed_chunk.get("item_id") or f"msg_{output_index}", | ||
| "role": "assistant", | ||
| "status": "completed", | ||
| "content": [], | ||
| } | ||
| recovered_text_only_items[output_index] = item | ||
|
|
||
| content = item.setdefault("content", []) | ||
| if not isinstance(content, list): | ||
| continue | ||
|
|
||
| try: | ||
| content_index = int(parsed_chunk.get("content_index")) | ||
| except (TypeError, ValueError): | ||
| content_index = len(content) | ||
|
|
||
| while len(content) <= content_index: | ||
| content.append( | ||
| { | ||
| "type": "output_text", | ||
| "text": "", | ||
| "annotations": [], | ||
| } | ||
| ) | ||
|
|
||
| content_item = content[content_index] | ||
| if not isinstance(content_item, dict): | ||
| content_item = {} | ||
| content[content_index] = content_item | ||
|
|
||
| content_item["type"] = "output_text" | ||
| content_item["text"] = text | ||
| if parsed_chunk.get("annotations") is not None: | ||
| content_item["annotations"] = parsed_chunk["annotations"] | ||
| else: | ||
| content_item.setdefault("annotations", []) | ||
|
|
||
| if recovered_output_items: | ||
| return [item for _, item in sorted(recovered_output_items.items())] | ||
|
|
||
| if recovered_text_only_items: | ||
| return [item for _, item in sorted(recovered_text_only_items.items())] | ||
|
|
||
| return [] | ||
|
|
||
| @classmethod | ||
| def _recover_output_items_from_logging( | ||
| cls, logging_obj: "LiteLLMLoggingObj" | ||
| ) -> List[Dict[str, Any]]: | ||
| model_call_details = getattr(logging_obj, "model_call_details", {}) or {} | ||
| original_response = model_call_details.get("original_response") | ||
| return cls._recover_output_items_from_raw_sse(original_response) | ||
|
|
||
| def transform_response( # noqa: PLR0915 | ||
| self, | ||
| model: str, | ||
|
|
@@ -607,9 +727,22 @@ def transform_response( # noqa: PLR0915 | |
| if raw_response.error is not None: | ||
| raise ValueError(f"Error in response: {raw_response.error}") | ||
|
|
||
| output_items = raw_response.output | ||
| if len(output_items) == 0: | ||
| recovered_output_items = self._recover_output_items_from_logging( | ||
| logging_obj | ||
| ) | ||
| if recovered_output_items: | ||
| output_items = recovered_output_items | ||
| raw_response.output = recovered_output_items | ||
|
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.
|
||
| verbose_logger.warning( | ||
| "Recovered empty Responses API output from raw SSE for model=%s", | ||
| model, | ||
| ) | ||
|
|
||
| # Convert response output to choices using the static helper | ||
| choices = self._convert_response_output_to_choices( | ||
| output_items=raw_response.output, | ||
| output_items=output_items, | ||
| handle_raw_dict_callback=self._handle_raw_dict_response_item, | ||
| ) | ||
|
|
||
|
|
@@ -623,7 +756,7 @@ def transform_response( # noqa: PLR0915 | |
| ) | ||
| else: | ||
| raise ValueError( | ||
| f"Unknown items in responses API response: {raw_response.output}" | ||
| f"Unknown items in responses API response: {output_items}" | ||
| ) | ||
|
|
||
| setattr(model_response, "choices", choices) | ||
|
|
@@ -1211,7 +1344,7 @@ def translate_responses_chunk_to_openai_stream( # noqa: PLR0915 | |
| raise ValueError( | ||
| f"Chat provider: Invalid function argument delta {parsed_chunk}" | ||
| ) | ||
| elif event_type == "response.output_item.done": | ||
| elif event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE: | ||
| # New output item added | ||
| output_item = parsed_chunk.get("item", {}) | ||
| if output_item.get("type") == "function_call": | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6760,6 +6760,18 @@ def _create_deployment( | |
| _shared_model_info = { | ||
| k: v for k, v in _model_info.items() if k not in _custom_pricing_fields | ||
| } | ||
| _existing_shared_mode = ( | ||
| cast(Optional[dict], litellm.model_cost.get(_model_name, {})) or {} | ||
| ).get("mode") | ||
| if ( | ||
| _existing_shared_mode is not None | ||
| and _shared_model_info.get("mode") != _existing_shared_mode | ||
| ): | ||
| # Keep the built-in bridge mode stable for shared backend keys. | ||
| # Multiple aliases can point at the same provider/model backend, | ||
| # but their deployment-level overrides should not downgrade the | ||
| # backend from responses -> chat via last-write-wins registration. | ||
| _shared_model_info.pop("mode", None) | ||
|
Comment on lines
+6766
to
+6774
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.
The guard pops For the ChatGPT use-case (built-in key already has |
||
| litellm.register_model( | ||
| model_cost={ | ||
| _model_name: _shared_model_info, | ||
|
|
||
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.
_recover_output_items_from_raw_ssere-implements much of the SSE-scanning logic that was just added toChatGPTResponsesAPIConfiginlitellm/llms/chatgpt/responses/transformation.py. When the ChatGPT layer successfully recovers items,raw_response.outputis already populated and this fallback is never reached—making thecompletion_extras/copy purely belt-and-suspenders for non-ChatGPT Responses-API providers. If that's the intent, a brief comment explaining which providers need this second-pass fallback and why would help future readers avoid silently removing one layer thinking it is dead code.