From 1f53041a2708f5b52e4ca6bbfbccaa4b4eb5bfaf Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Sun, 31 May 2026 22:51:52 -0700 Subject: [PATCH 1/2] Handle null output in parse_response parse_response iterates response.output directly. Response.output is typed as a required list, but the SDK constructs models leniently, so when a backend emits a terminal response.completed event with output null (seen with the Codex backend for incomplete responses) the field becomes None and the loop raises TypeError: 'NoneType' object is not iterable. This is the streaming responses.stream() / responses.parse() crash path. Guard the loop with `response.output or []` so a response with no output items parses to an empty output list. --- src/openai/lib/_parsing/_responses.py | 2 +- tests/lib/responses/test_responses.py | 30 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 8853a0749f..36346177b8 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -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 []: if output.type == "message": content_list: List[ParsedContent[TextFormatT]] = [] for item in output.content: diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 8e5f16df95..f9cd8657af 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -61,3 +61,33 @@ 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.types.responses.response import Response + from openai.lib._parsing._responses import parse_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 == [] From 5225266b5ac3c2ce3f5ae22304511555f4a9797a Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Mon, 1 Jun 2026 08:40:18 -0700 Subject: [PATCH 2/2] Preserve streamed output when completed event omits output When a backend sends the terminal response.completed event with a null or empty output after streaming output items, the streaming path passed that response straight to parse_response, so the final response dropped the text and tool calls already accumulated in the snapshot. Fall back to the accumulated snapshot output in that case. Addresses the streaming review note on this PR. --- .../lib/streaming/responses/_responses.py | 11 ++- tests/lib/responses/test_responses.py | 83 ++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 6975a9260d..46a6f76b0c 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -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 @@ -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, ) diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index f9cd8657af..6f8ab36b82 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -69,8 +69,8 @@ def test_parse_response_with_null_output() -> None: # 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.types.responses.response import Response from openai.lib._parsing._responses import parse_response + from openai.types.responses.response import Response response = construct_type( type_=Response, @@ -91,3 +91,84 @@ def test_parse_response_with_null_output() -> 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"