Skip to content
8 changes: 7 additions & 1 deletion src/openai/lib/_parsing/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ def parse_response(
) -> ParsedResponse[TextFormatT]:
output_list: List[ParsedResponseOutputItem[TextFormatT]] = []

for output in response.output:
# Guard against `response.output` being `None` (observed in the chatgpt.com
# Codex backend's consolidated `response.completed` event — see issue #3325).
# When the streaming accumulator has already collected output items, it
# injects them into the response before calling this function, so reaching
# here with `None` means the stream genuinely had no output items and an
# empty list is the correct result.
for output in response.output or []:
if output.type == "message":
content_list: List[ParsedContent[TextFormatT]] = []
for item in output.content:
Expand Down
74 changes: 69 additions & 5 deletions src/openai/lib/streaming/responses/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,12 +356,76 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps
output = snapshot.output[event.output_index]
if output.type == "function_call":
output.arguments += event.delta
elif event.type == "response.output_text.done":
output = snapshot.output[event.output_index]
if output.type == "message":
content = output.content[event.content_index]
assert content.type == "output_text"
content.text = event.text
elif event.type == "response.output_item.done":
# Replace the item in the snapshot with the finalized item from the
# done event. The server sends the authoritative item payload here,
# which may include final fields like `results`/`outputs` on tool
# items, or a final `status` of `failed`/`incomplete`. Simply
# setting `status = "completed"` would discard those fields and
# produce a stale final response in the null-output fallback path.
if event.output_index < len(snapshot.output):
snapshot.output[event.output_index] = construct_type_unchecked(
type_=type(snapshot.output[event.output_index]),
value=event.item.to_dict(),
)
elif event.type == "response.content_part.done":
# Replace the content part in the snapshot with the finalized part
# from the done event. The server sends the authoritative part
# payload here, which may include metadata like annotations,
# logprobs, or finalized text/refusal content that the delta
# accumulation may not fully capture.
output = snapshot.output[event.output_index]
if output.type == "message" and event.content_index < len(output.content):
output.content[event.content_index] = construct_type_unchecked(
type_=type(output.content[event.content_index]),
value=event.part.to_dict(),
)
elif event.type == "response.function_call_arguments.done":
# Apply the finalized arguments string from the done event.
# The server sends the authoritative arguments payload here, which
# may differ from the accumulated deltas. Using the finalized
# arguments ensures `parse_response()` can correctly parse
# `parsed_arguments` in the null-output fallback path.
output = snapshot.output[event.output_index]
if output.type == "function_call":
output.arguments = event.arguments
if hasattr(output, "status"):
output.status = "completed"
Comment on lines +395 to +399

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply finalized function-call arguments

In the null-output fallback path, parse_response() parses tool arguments from snapshot.output, but this done handler ignores event.arguments, the finalized argument string sent by response.function_call_arguments.done. If the accumulated deltas are incomplete or differ from the final payload, the final response can expose stale arguments and fail or mis-parse parsed_arguments even though the stream included the authoritative arguments.

Useful? React with 👍 / 👎.

elif event.type == "response.completed":
self._completed_response = parse_response(
text_format=self._text_format,
response=event.response,
input_tools=self._input_tools,
)
# The chatgpt.com Codex backend sometimes sends `response.output: null`
# in the consolidated `response.completed` event even when valid
# `output_item.done` events were streamed earlier (see issue #3325).
# `parse_response()` guards against `None` with `response.output or []`,
# but that would discard the already-accumulated `snapshot.output` and
# emit an empty final response. When the completed event has no
# output but the snapshot has accumulated items, inject the streamed
# items into a shallow copy of the response so `parse_response()` can
# still run its text_format / parsed_arguments logic on them.
if event.response.output is None and snapshot.output:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle null completed output without streamed items

If the backend sends response.output: null on response.completed before any output item has been accumulated, this condition is false and the else path still calls parse_response with event.response.output is None, reintroducing the same TypeError this fix is meant to avoid. This affects legitimately empty completed responses (or any null-output stream with no prior response.output_item.added) instead of returning a parsed response with an empty output list.

Useful? React with 👍 / 👎.

response_with_output = construct_type_unchecked(
type_=type(event.response),
value={
**event.response.to_dict(),
"output": [item.to_dict() for item in snapshot.output],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply done events before serializing the fallback snapshot

When response.completed has a null output after normal streamed *.done events, this fallback serializes snapshot.output, but the accumulator above never applies response.output_item.done, response.content_part.done, or response.function_call_arguments.done to that snapshot. As a result, get_final_response() can expose stale in_progress item statuses or miss final content metadata carried by the done events even though those finalized items were already streamed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve streamed text annotations in the fallback

For streams that emit response.output_text.annotation.added events, such as citation-bearing responses, the accumulator never adds those annotations to snapshot.output; with a null completed output this fallback serializes that incomplete snapshot instead of a final API output. In that scenario get_final_response().output loses annotations that were already streamed, leaving citation/file annotation arrays empty even though consumers saw the annotation events.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve finalized stream items when completed output is null

When response.completed has output: null, this fallback serializes snapshot.output, but that snapshot is only built from response.output_item.added, response.content_part.added, and deltas; accumulate_event never replaces it with the finalized response.output_item.done item or applies annotation/done events. In the exact backend scenario described here, any fields that are only finalized on those earlier events (for example message status, annotations/citations, or logprobs) are dropped from ResponseCompletedEvent.response / get_final_response(), even though the stream did provide them.

Useful? React with 👍 / 👎.

},
)
self._completed_response = parse_response(
text_format=self._text_format,
response=response_with_output,
input_tools=self._input_tools,
)
else:
self._completed_response = parse_response(
text_format=self._text_format,
response=event.response,
input_tools=self._input_tools,
)

return snapshot

Expand Down