From 2d70e7ba82a7eb582c10137fb374b373d500efed Mon Sep 17 00:00:00 2001 From: Jason Cook Date: Thu, 23 Apr 2026 13:24:50 -0400 Subject: [PATCH] fix(chatgpt): fold response.output_item.done events into SSE parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported symptom from a working OAuth request to ``chatgpt.com/backend-api/codex/responses``: APIConnectionError: ChatgptException - Unknown items in responses API response: [] The ChatGPT / Codex backend ships the response body as SSE. Each output item arrives in its own ``response.output_item.done`` event, and the terminal ``response.completed`` frame carries an empty ``response.output`` array — it is effectively a "we're done" signal rather than the carrier for the items. The existing parser only read ``response.output`` off the completed frame, so it handed the downstream chat-completions translator a ``ResponsesAPIResponse`` with ``output=[]``, which blew up on ``_convert_response_output_to_choices``. Fix: accumulate items from ``response.output_item.done`` during the loop and, when ``response.completed`` arrives with an empty output, substitute the accumulated list. The canonical OpenAI shape (items already populated on ``response.completed``) still works — we only fill in when the completed frame itself is empty. Extracted the SSE loop into ``_parse_codex_sse_response`` to keep ``transform_response_api_response`` under the PLR0915 threshold. Added a regression test that reproduces the Codex wire shape: one ``output_item.done`` event followed by a ``response.completed`` with ``output=[]``. 174 tests pass; Black + Ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../llms/chatgpt/responses/transformation.py | 60 ++++++++++++++----- .../test_chatgpt_responses_transformation.py | 43 +++++++++++++ 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 0ef6c34f881..8561fc5047b 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -1,5 +1,5 @@ import json -from typing import Any, Optional +from typing import Any, Optional, Tuple from litellm.constants import STREAM_SSE_DONE_STRING from litellm.exceptions import AuthenticationError @@ -134,8 +134,39 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): additional_args={"complete_input_dict": {}}, ) - completed_response = None - error_message = None + completed_response, error_message = self._parse_codex_sse_response(body_text) + if completed_response is None: + raise OpenAIError( + message=error_message or raw_response.text, + status_code=raw_response.status_code, + ) + + raw_headers = dict(raw_response.headers) + processed_headers = process_response_headers(raw_headers) + if not hasattr(completed_response, "_hidden_params"): + setattr(completed_response, "_hidden_params", {}) + completed_response._hidden_params["additional_headers"] = processed_headers + completed_response._hidden_params["headers"] = raw_headers + return completed_response + + @staticmethod + def _parse_codex_sse_response( + body_text: str, + ) -> Tuple[Optional[ResponsesAPIResponse], Optional[str]]: + """ + Walk the Codex-backend SSE stream and reconstruct a + :class:`ResponsesAPIResponse` from a mix of + ``response.output_item.done`` events (per-item payloads) and the + terminal ``response.completed`` frame (which ChatGPT may ship + with an empty ``output`` array). + + Returns ``(completed_response, error_message)`` — at most one is + non-None. Caller raises if the response is None. + """ + completed_response: Optional[ResponsesAPIResponse] = None + error_message: Optional[str] = None + output_items: list = [] + for chunk in body_text.splitlines(): stripped_chunk = CustomStreamWrapper._strip_sse_data_from_chunk(chunk) if not stripped_chunk: @@ -152,6 +183,11 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): if not isinstance(parsed_chunk, dict): continue event_type = parsed_chunk.get("type") + if event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE: + item = parsed_chunk.get("item") + if isinstance(item, dict): + output_items.append(item) + continue if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED: response_payload = parsed_chunk.get("response") if isinstance(response_payload, dict): @@ -160,6 +196,10 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): response_payload["created_at"] = _safe_convert_created_field( response_payload["created_at"] ) + # Codex backend: fold per-item events in when the + # completed frame's ``output`` is empty. + if not response_payload.get("output") and output_items: + response_payload["output"] = output_items try: completed_response = ResponsesAPIResponse(**response_payload) except Exception: @@ -180,19 +220,7 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): else: error_message = str(error_obj) - if completed_response is None: - raise OpenAIError( - message=error_message or raw_response.text, - status_code=raw_response.status_code, - ) - - raw_headers = dict(raw_response.headers) - processed_headers = process_response_headers(raw_headers) - if not hasattr(completed_response, "_hidden_params"): - setattr(completed_response, "_hidden_params", {}) - completed_response._hidden_params["additional_headers"] = processed_headers - completed_response._hidden_params["headers"] = raw_headers - return completed_response + return completed_response, error_message def get_complete_url( self, diff --git a/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py b/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py index c0a0927b7d1..43577f54ce7 100644 --- a/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py +++ b/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py @@ -197,3 +197,46 @@ class TestChatGPTResponsesAPITransformation: ) assert parsed.output_text == "Hello!" + + def test_chatgpt_codex_output_items_from_stream_when_completed_has_empty_output( + self, + ): + """ + Regression: ChatGPT's Codex backend streams each output item via + ``response.output_item.done`` events and ships an *empty* + ``response.output`` on the final ``response.completed``. Without + accumulation, the downstream chat translator blows up with + "Unknown items in responses API response: []". + """ + config = ChatGPTResponsesAPIConfig() + item = { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hi there!"}], + } + completed_response = { + "id": "resp_test", + "object": "response", + "created_at": 1700000000, + "status": "completed", + "model": "gpt-5.4", + "output": [], # Codex backend delivers items via output_item.done + } + sse_body = "\n".join( + [ + f"data: {json.dumps({'type': 'response.output_item.done', 'item': item})}", + f"data: {json.dumps({'type': 'response.completed', 'response': completed_response})}", + "data: [DONE]", + "", + ] + ) + raw_response = httpx.Response( + 200, headers={"content-type": "text/event-stream"}, text=sse_body + ) + + parsed = config.transform_response_api_response( + model="chatgpt/gpt-5.4", + raw_response=raw_response, + logging_obj=MagicMock(), + ) + assert parsed.output_text == "Hi there!"