From c5de32017b07a68d82e88da586ee483e93b55478 Mon Sep 17 00:00:00 2001 From: FU-max-boop Date: Fri, 29 May 2026 04:25:40 +0800 Subject: [PATCH 1/2] Handle null response output during parsing --- src/openai/lib/_parsing/_responses.py | 2 +- tests/lib/responses/test_responses.py | 44 +++++++++++++++++++++++++++ 2 files changed, 45 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..1020cba68a 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -7,7 +7,11 @@ from inline_snapshot import snapshot from openai import OpenAI, AsyncOpenAI +from openai._types import omit from openai._utils import assert_signatures_in_sync +from openai._models import construct_type_unchecked +from openai.types.responses import Response +from openai.lib._parsing._responses import parse_response from ...conftest import base_url from ..snapshots import make_snapshot_request @@ -41,6 +45,46 @@ def test_output_text(client: OpenAI, respx_mock: MockRouter) -> None: ) +def test_parse_response_handles_null_output() -> None: + response = construct_type_unchecked( + type_=Response, + value={ + "id": "resp_null_output", + "object": "response", + "created_at": 0, + "status": "completed", + "error": None, + "incomplete_details": None, + "instructions": None, + "max_output_tokens": None, + "max_tool_calls": None, + "model": "gpt-4o-mini", + "output": None, + "parallel_tool_calls": True, + "previous_response_id": None, + "prompt_cache_key": None, + "reasoning": {"effort": None, "summary": None}, + "safety_identifier": None, + "service_tier": "default", + "store": False, + "temperature": 1.0, + "text": {"format": {"type": "text"}, "verbosity": "medium"}, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": None, + "user": None, + "metadata": {}, + }, + ) + + parsed = parse_response(text_format=omit, input_tools=omit, response=response) + + assert parsed.output == [] + + @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) def test_stream_method_definition_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None: checking_client: OpenAI | AsyncOpenAI = client if sync else async_client From d9b78c4da89e3b090f704b3e7db4d677f98ffb9b Mon Sep 17 00:00:00 2001 From: FU-max-boop Date: Fri, 29 May 2026 12:00:36 +0800 Subject: [PATCH 2/2] Preserve streamed response output on null completion --- .../lib/streaming/responses/_responses.py | 10 +- tests/lib/responses/test_responses.py | 119 ++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 6975a9260d..e440eb3d92 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -357,9 +357,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 + response_dict: dict[str, object] = response.to_dict() + if response_dict.get("output") is None: + response_dict["output"] = [output.to_dict() for output in snapshot.output] + response = construct_type_unchecked( + type_=ParsedResponseSnapshot, + value=response_dict, + ) 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 1020cba68a..f605a1a7e2 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -12,6 +12,12 @@ from openai._models import construct_type_unchecked from openai.types.responses import Response from openai.lib._parsing._responses import parse_response +from openai.lib.streaming.responses._responses import ResponseStreamState +from openai.types.responses.response_created_event import ResponseCreatedEvent +from openai.types.responses.response_completed_event import ResponseCompletedEvent +from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent +from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent +from openai.types.responses.response_content_part_added_event import ResponseContentPartAddedEvent from ...conftest import base_url from ..snapshots import make_snapshot_request @@ -25,6 +31,39 @@ # `OPENAI_LIVE=1 pytest --inline-snapshot=fix -p no:xdist -o addopts=""` +def _response_payload(*, output: object) -> dict[str, object]: + return { + "id": "resp_null_output", + "object": "response", + "created_at": 0, + "status": "completed", + "error": None, + "incomplete_details": None, + "instructions": None, + "max_output_tokens": None, + "max_tool_calls": None, + "model": "gpt-4o-mini", + "output": output, + "parallel_tool_calls": True, + "previous_response_id": None, + "prompt_cache_key": None, + "reasoning": {"effort": None, "summary": None}, + "safety_identifier": None, + "service_tier": "default", + "store": False, + "temperature": 1.0, + "text": {"format": {"type": "text"}, "verbosity": "medium"}, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": None, + "user": None, + "metadata": {}, + } + + @pytest.mark.respx(base_url=base_url) def test_output_text(client: OpenAI, respx_mock: MockRouter) -> None: response = make_snapshot_request( @@ -85,6 +124,86 @@ def test_parse_response_handles_null_output() -> None: assert parsed.output == [] +def test_streaming_completed_null_output_preserves_accumulated_snapshot() -> None: + state = ResponseStreamState(text_format=omit, input_tools=omit) + state.handle_event( + construct_type_unchecked( + type_=ResponseCreatedEvent, + value={ + "type": "response.created", + "sequence_number": 0, + "response": _response_payload(output=[]), + }, + ) + ) + state.handle_event( + construct_type_unchecked( + type_=ResponseOutputItemAddedEvent, + value={ + "type": "response.output_item.added", + "sequence_number": 1, + "output_index": 0, + "item": { + "id": "msg_1", + "type": "message", + "status": "in_progress", + "role": "assistant", + "content": [], + }, + }, + ) + ) + state.handle_event( + construct_type_unchecked( + type_=ResponseContentPartAddedEvent, + value={ + "type": "response.content_part.added", + "sequence_number": 2, + "item_id": "msg_1", + "output_index": 0, + "content_index": 0, + "part": { + "id": "text_1", + "type": "output_text", + "text": "", + "annotations": [], + }, + }, + ) + ) + state.handle_event( + construct_type_unchecked( + type_=ResponseTextDeltaEvent, + value={ + "type": "response.output_text.delta", + "sequence_number": 3, + "item_id": "msg_1", + "output_index": 0, + "content_index": 0, + "delta": "hello", + "logprobs": [], + }, + ) + ) + + events = state.handle_event( + construct_type_unchecked( + type_=ResponseCompletedEvent, + value={ + "type": "response.completed", + "sequence_number": 4, + "response": _response_payload(output=None), + }, + ) + ) + + completed = events[0] + assert completed.type == "response.completed" + assert completed.response.output_text == "hello" + assert state._completed_response is not None + assert state._completed_response.output_text == "hello" + + @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) def test_stream_method_definition_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None: checking_client: OpenAI | AsyncOpenAI = client if sync else async_client