fix(chatgpt): fold response.output_item.done events into SSE parse

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) <noreply@anthropic.com>
This commit is contained in:
Jason Cook 2026-04-23 13:24:50 -04:00
parent 503df1478e
commit 2d70e7ba82
2 changed files with 87 additions and 16 deletions

View file

@ -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,

View file

@ -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!"