Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions litellm/llms/chatgpt/responses/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ def transform_response_api_response(

completed_response = None
error_message = None
# The ChatGPT Codex backend streams output items via
# `response.output_item.done` events and emits a final
# `response.completed` event whose `response.output` is empty — it
# only carries metadata (id, status, usage). Accumulate the items
# while iterating so the assembled non-streaming response is not
# empty. See `codex-rs/core/src/client.rs` (OutputItemDone handler)
# in the upstream Codex CLI for the reference implementation.
accumulated_output_items: list = []
for chunk in body_text.splitlines():
stripped_chunk = CustomStreamWrapper._strip_sse_data_from_chunk(chunk)
if not stripped_chunk:
Expand All @@ -150,20 +158,17 @@ def transform_response_api_response(
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):
accumulated_output_items.append(item)
continue
if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED:
response_payload = parsed_chunk.get("response")
if isinstance(response_payload, dict):
response_payload = dict(response_payload)
if "created_at" in response_payload:
response_payload["created_at"] = _safe_convert_created_field(
response_payload["created_at"]
)
try:
completed_response = ResponsesAPIResponse(**response_payload)
except Exception:
completed_response = ResponsesAPIResponse.model_construct(
**response_payload
)
completed_response = self._build_completed_response(
response_payload, accumulated_output_items
)
break
if event_type in (
ResponsesAPIStreamEvents.RESPONSE_FAILED,
Expand Down Expand Up @@ -192,6 +197,22 @@ def transform_response_api_response(
completed_response._hidden_params["headers"] = raw_headers
return completed_response

@staticmethod
def _build_completed_response(
response_payload: dict, accumulated_output_items: list
) -> ResponsesAPIResponse:
response_payload = dict(response_payload)
if "created_at" in response_payload:
response_payload["created_at"] = _safe_convert_created_field(
response_payload["created_at"]
)
if not response_payload.get("output") and accumulated_output_items:
response_payload["output"] = accumulated_output_items
try:
return ResponsesAPIResponse(**response_payload)
except Exception:
return ResponsesAPIResponse.model_construct(**response_payload)

def get_complete_url(
self,
api_base: Optional[str],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,126 @@ def test_chatgpt_non_stream_sse_response_parsing(
)

assert parsed.output_text == "Hello!"

def test_chatgpt_accumulates_output_item_done_when_completed_output_empty(
self,
):
"""
The ChatGPT Codex backend streams output items via
`response.output_item.done` events and emits a terminal
`response.completed` event with an empty `response.output`
(only carrying id/status/usage). The transformation must
accumulate those items so the assembled non-streaming response
is not empty.
"""
config = ChatGPTResponsesAPIConfig()
reasoning_item = {
"id": "rs_test",
"type": "reasoning",
"summary": [],
"encrypted_content": "ENCRYPTED",
}
message_item = {
"id": "msg_test",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{
"type": "output_text",
"text": "hello world",
"annotations": [],
}
],
}
completed_payload_without_output = {
"id": "resp_test",
"object": "response",
"created_at": 1700000000,
"status": "completed",
"model": "gpt-5.3-codex",
"output": [],
"usage": {
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 15,
},
}
sse_body = "\n".join(
[
f"data: {json.dumps({'type': 'response.output_item.done', 'output_index': 0, 'item': reasoning_item})}",
f"data: {json.dumps({'type': 'response.output_item.done', 'output_index': 1, 'item': message_item})}",
f"data: {json.dumps({'type': 'response.completed', 'response': completed_payload_without_output})}",
"data: [DONE]",
"",
]
)
raw_response = httpx.Response(
200, headers={"content-type": "text/event-stream"}, text=sse_body
)
logging_obj = MagicMock()

parsed = config.transform_response_api_response(
model="chatgpt/gpt-5.3-codex",
raw_response=raw_response,
logging_obj=logging_obj,
)

assert len(parsed.output) == 2
assert parsed.output[0].type == "reasoning"
assert parsed.output[1].type == "message"
assert parsed.output_text == "hello world"

def test_chatgpt_prefers_nonempty_completed_output_over_accumulated(self):
"""
If a `response.completed` event already carries a populated
`response.output`, it should win over any accumulated
`output_item.done` items — the backend is the source of truth
when it chooses to populate the terminal event.
"""
config = ChatGPTResponsesAPIConfig()
stray_item = {
"id": "msg_stray",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "stray", "annotations": []}],
}
canonical_item = {
"id": "msg_canonical",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{"type": "output_text", "text": "canonical", "annotations": []}
],
}
completed_payload_with_output = {
"id": "resp_test",
"object": "response",
"created_at": 1700000000,
"status": "completed",
"model": "gpt-5.3-codex",
"output": [canonical_item],
}
sse_body = "\n".join(
[
f"data: {json.dumps({'type': 'response.output_item.done', 'output_index': 0, 'item': stray_item})}",
f"data: {json.dumps({'type': 'response.completed', 'response': completed_payload_with_output})}",
"data: [DONE]",
"",
]
)
raw_response = httpx.Response(
200, headers={"content-type": "text/event-stream"}, text=sse_body
)
logging_obj = MagicMock()

parsed = config.transform_response_api_response(
model="chatgpt/gpt-5.3-codex",
raw_response=raw_response,
logging_obj=logging_obj,
)

assert len(parsed.output) == 1
assert parsed.output_text == "canonical"
Loading