mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
503df1478e
commit
2d70e7ba82
2 changed files with 87 additions and 16 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue