From d91d0d75c2846f56c73fcec66923ea5f26974991 Mon Sep 17 00:00:00 2001 From: C1-BA-B1-F3 Date: Fri, 26 Jun 2026 03:15:19 +0800 Subject: [PATCH 1/2] fix: handle null response.output in parse_response When a backend sends 'response.output: null' in a response.completed event (e.g., chatgpt.com Codex backend), parse_response would crash with TypeError: 'NoneType' object is not iterable. This one-line fix coerces None to an empty list, allowing the stream to complete gracefully. Consumers that track output_item.done events can still backfill from their collected items. Fixes #3325 --- src/openai/lib/_parsing/_responses.py | 2 +- tests/lib/test_parsing_responses.py | 87 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/lib/test_parsing_responses.py diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 232718cef6..ad2297e249 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/test_parsing_responses.py b/tests/lib/test_parsing_responses.py new file mode 100644 index 0000000000..aab8d66567 --- /dev/null +++ b/tests/lib/test_parsing_responses.py @@ -0,0 +1,87 @@ +"""Tests for parse_response handling of null/None output fields.""" + +from __future__ import annotations + +from openai._models import construct_type_unchecked +from openai._types import Omit +from openai.lib._parsing._responses import parse_response +from openai.types.responses import Response, ParsedResponse + + +def _make_response(output=None, **kwargs): + """Helper to construct a Response with a given output field.""" + base = { + "id": "resp_test123", + "created_at": 1234567890.0, + "model": "gpt-4o", + "object": "response", + "status": "completed", + "output": output, + "parallel_tool_calls": True, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + } + base.update(kwargs) + return construct_type_unchecked(type_=Response, value=base) + + +def test_parse_response_with_none_output(): + """Test that parse_response handles null output without crashing.""" + response = _make_response(output=None) + assert response.output is None + + result = parse_response( + text_format=None, + input_tools=None, + response=response, + ) + + assert isinstance(result, ParsedResponse) + assert result.output == [] + + +def test_parse_response_with_empty_list_output(): + """Test that parse_response handles empty list output correctly.""" + response = _make_response(output=[]) + assert response.output == [] + + result = parse_response( + text_format=None, + input_tools=None, + response=response, + ) + + assert isinstance(result, ParsedResponse) + assert result.output == [] + + +def test_parse_response_with_message_output(): + """Test that parse_response still works correctly with actual output items.""" + output_data = [ + { + "id": "msg_test123", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello, world!", + "annotations": [], + } + ], + } + ] + response = _make_response(output=output_data) + + result = parse_response( + text_format=Omit(), + input_tools=None, + response=response, + ) + + assert isinstance(result, ParsedResponse) + assert len(result.output) == 1 + assert result.output[0].type == "message" \ No newline at end of file From 4ea9c571ceeb27c8561535cdb2ca782131bffd7f Mon Sep 17 00:00:00 2001 From: C1-BA-B1-F3 Date: Fri, 26 Jun 2026 05:16:12 +0800 Subject: [PATCH 2/2] fix: preserve accumulated stream output when response.completed has null output When response.completed arrives with null output, items accumulated from prior streaming events are now preserved by reconstructing the response with the snapshot output. --- .../lib/streaming/responses/_responses.py | 10 +- tests/lib/test_parsing_responses.py | 100 +++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 6975a9260d..853bdfa175 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -17,7 +17,7 @@ from ...._utils import is_given, consume_sync_iterator, consume_async_iterator from ...._models import build, construct_type_unchecked from ...._streaming import Stream, AsyncStream -from ....types.responses import ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent +from ....types.responses import Response, ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent from ..._parsing._responses import TextFormatT, parse_text, parse_response from ....types.responses.tool_param import ToolParam from ....types.responses.parsed_response import ( @@ -357,9 +357,15 @@ 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 response.output is None and snapshot.output: + response = construct_type_unchecked( + type_=cast(Any, Response), + value={**response.to_dict(), "output": [item.to_dict() for item in 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/test_parsing_responses.py b/tests/lib/test_parsing_responses.py index aab8d66567..b0d1b23bc6 100644 --- a/tests/lib/test_parsing_responses.py +++ b/tests/lib/test_parsing_responses.py @@ -5,7 +5,13 @@ from openai._models import construct_type_unchecked from openai._types import Omit from openai.lib._parsing._responses import parse_response +from openai.lib.streaming.responses._responses import ResponseStreamState from openai.types.responses import Response, ParsedResponse +from openai.types.responses.response_created_event import ResponseCreatedEvent +from openai.types.responses.response_completed_event import ResponseCompletedEvent +from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent +from openai.types.responses.response_content_part_added_event import ResponseContentPartAddedEvent +from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent def _make_response(output=None, **kwargs): @@ -84,4 +90,96 @@ def test_parse_response_with_message_output(): assert isinstance(result, ParsedResponse) assert len(result.output) == 1 - assert result.output[0].type == "message" \ No newline at end of file + assert result.output[0].type == "message" + + +def test_streaming_accumulated_items_preserved_on_null_output(): + """When response.completed arrives with null output, items accumulated + from prior streaming events should be preserved in get_final_response().""" + state = ResponseStreamState(text_format=Omit(), input_tools=Omit()) + + response_created = _make_response(output=[], status="in_progress") + created_event = construct_type_unchecked( + type_=ResponseCreatedEvent, + value={ + "type": "response.created", + "response": response_created.to_dict(), + "sequence_number": 0, + }, + ) + state.handle_event(created_event) + + item_added_event = construct_type_unchecked( + type_=ResponseOutputItemAddedEvent, + value={ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "msg_abc", + "type": "message", + "status": "in_progress", + "role": "assistant", + "content": [], + }, + "sequence_number": 1, + }, + ) + state.handle_event(item_added_event) + + part_added_event = construct_type_unchecked( + type_=ResponseContentPartAddedEvent, + value={ + "type": "response.content_part.added", + "output_index": 0, + "content_index": 0, + "item_id": "msg_abc", + "part": {"type": "output_text", "text": "", "annotations": []}, + "sequence_number": 2, + }, + ) + state.handle_event(part_added_event) + + delta_event_1 = construct_type_unchecked( + type_=ResponseTextDeltaEvent, + value={ + "type": "response.output_text.delta", + "output_index": 0, + "content_index": 0, + "item_id": "msg_abc", + "delta": "Hello, ", + "logprobs": [], + "sequence_number": 3, + }, + ) + state.handle_event(delta_event_1) + + delta_event_2 = construct_type_unchecked( + type_=ResponseTextDeltaEvent, + value={ + "type": "response.output_text.delta", + "output_index": 0, + "content_index": 0, + "item_id": "msg_abc", + "delta": "world!", + "logprobs": [], + "sequence_number": 4, + }, + ) + state.handle_event(delta_event_2) + + response_completed = _make_response(output=None, status="completed") + completed_event = construct_type_unchecked( + type_=ResponseCompletedEvent, + value={ + "type": "response.completed", + "response": response_completed.to_dict(), + "sequence_number": 5, + }, + ) + state.handle_event(completed_event) + + assert state._completed_response is not None + assert len(state._completed_response.output) == 1 + assert state._completed_response.output[0].type == "message" + msg = state._completed_response.output[0] + assert msg.content[0].text == "Hello, world!" \ No newline at end of file