From bb4a92ad53e9510fe64416b37ec9229e4a54cfd9 Mon Sep 17 00:00:00 2001 From: FU-max-boop Date: Wed, 12 Aug 2026 15:33:45 +0800 Subject: [PATCH 1/3] Handle null response output during parsing --- src/openai/lib/_parsing/_responses.py | 2 +- tests/lib/responses/test_responses.py | 40 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index c607587ec1..81e6b2b983 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 43879942b8..05773510f4 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -72,6 +72,46 @@ def test_parse_response_preserves_program_items(item: dict[str, object]) -> None assert parsed.output[0].to_dict() == item +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 a6aa9c2a0aba0fa78402bea85cad84397deccf88 Mon Sep 17 00:00:00 2001 From: FU-max-boop Date: Wed, 12 Aug 2026 15:34:09 +0800 Subject: [PATCH 2/3] Preserve streamed response output on null completion --- .../lib/streaming/responses/_responses.py | 10 +- tests/lib/responses/test_responses.py | 150 ++++++++++++++---- 2 files changed, 129 insertions(+), 31 deletions(-) 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 05773510f4..0053d37f31 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.respx2(base_url=base_url) def test_output_text(client: OpenAI, respx2_mock: MockRouter) -> None: response = make_snapshot_request( @@ -75,36 +114,7 @@ def test_parse_response_preserves_program_items(item: dict[str, object]) -> 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": {}, - }, + value=_response_payload(output=None), ) parsed = parse_response(text_format=omit, input_tools=omit, response=response) @@ -112,6 +122,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 From 359b4d65af9c52d551b19e473151c859005a5d3b Mon Sep 17 00:00:00 2001 From: FU-max-boop Date: Wed, 12 Aug 2026 16:14:52 +0800 Subject: [PATCH 3/3] Preserve finalized streamed output on null completion --- .../lib/streaming/responses/_responses.py | 32 +++ tests/lib/responses/test_responses.py | 220 +++++++++++++----- 2 files changed, 200 insertions(+), 52 deletions(-) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index e440eb3d92..d780c18967 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -356,6 +356,38 @@ 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.refusal.delta": + output = snapshot.output[event.output_index] + if output.type == "message": + content = output.content[event.content_index] + assert content.type == "refusal" + content.refusal += event.delta + elif event.type == "response.refusal.done": + output = snapshot.output[event.output_index] + if output.type == "message": + content = output.content[event.content_index] + assert content.type == "refusal" + content.refusal = event.refusal + elif event.type == "response.content_part.done": + output = snapshot.output[event.output_index] + if output.type == "message": + output.content[event.content_index] = construct_type_unchecked( + type_=cast(Any, ParsedContent), + value=event.part.to_dict(), + ) + elif event.type == "response.output_item.done": + if event.item.type == "function_call": + snapshot.output[event.output_index] = construct_type_unchecked( + type_=cast(Any, ParsedResponseFunctionToolCall), + value=event.item.to_dict(), + ) + elif event.item.type == "message": + snapshot.output[event.output_index] = construct_type_unchecked( + type_=cast(Any, ParsedResponseOutputMessage), + value=event.item.to_dict(), + ) + else: + snapshot.output[event.output_index] = event.item elif event.type == "response.completed": response = event.response response_dict: dict[str, object] = response.to_dict() diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 0053d37f31..612278f8af 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Any from typing_extensions import TypeVar import pytest @@ -16,6 +17,10 @@ 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_refusal_done_event import ResponseRefusalDoneEvent +from openai.types.responses.response_refusal_delta_event import ResponseRefusalDeltaEvent +from openai.types.responses.response_output_item_done_event import ResponseOutputItemDoneEvent +from openai.types.responses.response_content_part_done_event import ResponseContentPartDoneEvent from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent from openai.types.responses.response_content_part_added_event import ResponseContentPartAddedEvent @@ -64,6 +69,46 @@ def _response_payload(*, output: object) -> dict[str, object]: } +def _handle_stream_event(state: ResponseStreamState[Any], event_type: Any, **value: object) -> None: + state.handle_event(construct_type_unchecked(type_=event_type, value=value)) + + +def _message_stream_state(content_part: dict[str, object]) -> ResponseStreamState[Any]: + state = ResponseStreamState(text_format=omit, input_tools=omit) + _handle_stream_event( + state, + ResponseCreatedEvent, + type="response.created", + sequence_number=0, + response=_response_payload(output=[]), + ) + _handle_stream_event( + state, + ResponseOutputItemAddedEvent, + type="response.output_item.added", + sequence_number=1, + output_index=0, + item={ + "id": "msg_1", + "type": "message", + "status": "in_progress", + "role": "assistant", + "content": [], + }, + ) + _handle_stream_event( + state, + ResponseContentPartAddedEvent, + type="response.content_part.added", + sequence_number=2, + item_id="msg_1", + output_index=0, + content_index=0, + part=content_part, + ) + return state + + @pytest.mark.respx2(base_url=base_url) def test_output_text(client: OpenAI, respx2_mock: MockRouter) -> None: response = make_snapshot_request( @@ -123,73 +168,140 @@ def test_parse_response_handles_null_output() -> None: 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 = _message_stream_state({"type": "output_text", "text": "", "annotations": []}) + _handle_stream_event( + state, + ResponseTextDeltaEvent, + type="response.output_text.delta", + sequence_number=3, + item_id="msg_1", + output_index=0, + content_index=0, + delta="hello", + logprobs=[], ) - state.handle_event( + + events = state.handle_event( construct_type_unchecked( - type_=ResponseOutputItemAddedEvent, + type_=ResponseCompletedEvent, value={ - "type": "response.output_item.added", - "sequence_number": 1, - "output_index": 0, - "item": { - "id": "msg_1", - "type": "message", - "status": "in_progress", - "role": "assistant", - "content": [], - }, + "type": "response.completed", + "sequence_number": 4, + "response": _response_payload(output=None), }, ) ) - 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": [], - }, - }, - ) + + 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" + + +def test_streaming_completed_null_output_applies_finalization_events() -> None: + state = _message_stream_state({"type": "output_text", "text": "", "annotations": []}) + _handle_stream_event( + state, + ResponseTextDeltaEvent, + type="response.output_text.delta", + sequence_number=3, + item_id="msg_1", + output_index=0, + content_index=0, + delta="draft", + logprobs=[], + ) + final_part = { + "type": "output_text", + "text": "final", + "annotations": [ + { + "type": "url_citation", + "url": "https://example.com", + "title": "Example", + "start_index": 0, + "end_index": 5, + } + ], + "logprobs": [], + } + _handle_stream_event( + state, + ResponseContentPartDoneEvent, + type="response.content_part.done", + sequence_number=4, + item_id="msg_1", + output_index=0, + content_index=0, + part=final_part, + ) + _handle_stream_event( + state, + ResponseOutputItemDoneEvent, + type="response.output_item.done", + sequence_number=5, + output_index=0, + item={ + "id": "msg_1", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [final_part], + }, ) - state.handle_event( + + events = state.handle_event( construct_type_unchecked( - type_=ResponseTextDeltaEvent, + type_=ResponseCompletedEvent, value={ - "type": "response.output_text.delta", - "sequence_number": 3, - "item_id": "msg_1", - "output_index": 0, - "content_index": 0, - "delta": "hello", - "logprobs": [], + "type": "response.completed", + "sequence_number": 6, + "response": _response_payload(output=None), }, ) ) + completed = events[0] + assert completed.type == "response.completed" + assert completed.response.output[0].to_dict() == { + "id": "msg_1", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{**final_part, "parsed": None}], + } + + +def test_streaming_completed_null_output_preserves_refusal_done() -> None: + state = _message_stream_state({"type": "refusal", "refusal": ""}) + _handle_stream_event( + state, + ResponseRefusalDeltaEvent, + type="response.refusal.delta", + sequence_number=3, + item_id="msg_1", + output_index=0, + content_index=0, + delta="draft", + ) + _handle_stream_event( + state, + ResponseRefusalDoneEvent, + type="response.refusal.done", + sequence_number=4, + item_id="msg_1", + output_index=0, + content_index=0, + refusal="final refusal", + ) + events = state.handle_event( construct_type_unchecked( type_=ResponseCompletedEvent, value={ "type": "response.completed", - "sequence_number": 4, + "sequence_number": 5, "response": _response_payload(output=None), }, ) @@ -197,9 +309,13 @@ def test_streaming_completed_null_output_preserves_accumulated_snapshot() -> Non 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" + assert completed.response.output[0].to_dict() == { + "id": "msg_1", + "type": "message", + "status": "in_progress", + "role": "assistant", + "content": [{"type": "refusal", "refusal": "final refusal"}], + } @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])