Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/openai/lib/_parsing/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def parse_response(
) -> ParsedResponse[TextFormatT]:
output_list: List[ParsedResponseOutputItem[TextFormatT]] = []

for output in response.output:
for output in response.output or []:

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 output when completed event omits output

In the streaming path, ResponseStreamState.accumulate_event builds up a snapshot from response.output_item.added and delta events, but on response.completed it calls parse_response(response=event.response). When the backend sends the terminal event with output: null after earlier streamed output items—the case described by this test—this coercion turns the final parsed response into output == [], so get_final_response() and the emitted response.completed event silently lose the text/tool calls already accumulated in the stream instead of parsing from the snapshot.

Useful? React with 👍 / 👎.

if output.type == "message":
content_list: List[ParsedContent[TextFormatT]] = []
for item in output.content:
Expand Down
11 changes: 10 additions & 1 deletion src/openai/lib/streaming/responses/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
)
from ...._types import Omit, omit
from ...._utils import is_given, consume_sync_iterator, consume_async_iterator
from ...._compat import model_copy
from ...._models import build, construct_type_unchecked
from ...._streaming import Stream, AsyncStream
from ....types.responses import ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent
Expand Down Expand Up @@ -357,9 +358,17 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps
if output.type == "function_call":
output.arguments += event.delta
elif event.type == "response.completed":
response = event.response
if not response.output and snapshot.output:
# Some backends send the terminal completed event with a null or
# empty output even though items were streamed earlier. Fall back
# to the accumulated snapshot so the final response keeps that
# content instead of dropping it.
response = model_copy(response)
response.output = snapshot.output
self._completed_response = parse_response(
text_format=self._text_format,
response=event.response,
response=response,
input_tools=self._input_tools,
)

Expand Down
111 changes: 111 additions & 0 deletions tests/lib/responses/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,114 @@ def test_parse_method_definition_in_sync(sync: bool, client: OpenAI, async_clien
checking_client.responses.parse,
exclude_params={"tools"},
)


def test_parse_response_with_null_output() -> None:
# some backends (e.g. Codex) emit a terminal `response.completed` event where
# `output` is null; the SDK constructs the model leniently so `.output` ends
# up as None and parsing must not crash, see #3312/#3313/#3314/#3321/#3325
from openai._types import omit
from openai._models import construct_type
from openai.lib._parsing._responses import parse_response
from openai.types.responses.response import Response

response = construct_type(
type_=Response,
value={
"id": "resp_123",
"created_at": 0,
"model": "gpt-5",
"object": "response",
"output": None,
"parallel_tool_calls": True,
"tool_choice": "auto",
"tools": [],
"status": "incomplete",
},
)
assert isinstance(response, Response)
assert response.output is None

parsed = parse_response(text_format=omit, input_tools=omit, response=response)
assert parsed.output == []


def test_streaming_completed_with_null_output_keeps_snapshot() -> None:
# if the terminal `response.completed` event omits output but items were
# streamed earlier, the final response should keep the accumulated content
# rather than collapsing to an empty list
from openai._types import omit
from openai._models import construct_type_unchecked
from openai.types.responses import ResponseStreamEvent
from openai.lib.streaming.responses._responses import ResponseStreamState

def ev(value: dict) -> ResponseStreamEvent:
return construct_type_unchecked(type_=ResponseStreamEvent, value=value)

base_resp = {
"id": "resp_1",
"created_at": 0,
"model": "gpt-5",
"object": "response",
"parallel_tool_calls": True,
"tool_choice": "auto",
"tools": [],
"status": "in_progress",
"output": [],
}

state: ResponseStreamState[object] = ResponseStreamState(input_tools=omit, text_format=omit)
state.handle_event(ev({"type": "response.created", "sequence_number": 0, "response": base_resp}))
state.handle_event(
ev(
{
"type": "response.output_item.added",
"sequence_number": 1,
"output_index": 0,
"item": {
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "in_progress",
"content": [],
},
}
)
)
state.handle_event(
ev(
{
"type": "response.content_part.added",
"sequence_number": 2,
"output_index": 0,
"content_index": 0,
"item_id": "msg_1",
"part": {"type": "output_text", "text": "", "annotations": []},
}
)
)
state.handle_event(
ev(
{
"type": "response.output_text.delta",
"sequence_number": 3,
"output_index": 0,
"content_index": 0,
"item_id": "msg_1",
"delta": "Hello world",
"logprobs": [],
}
)
)

completed_resp = dict(base_resp)
completed_resp["status"] = "completed"
completed_resp["output"] = None
state.handle_event(ev({"type": "response.completed", "sequence_number": 4, "response": completed_resp}))

final = state._completed_response
assert final is not None
assert len(final.output) == 1
message = final.output[0]
assert message.type == "message"
assert message.content[0].text == "Hello world"