From eed893cdc98e450fb6a15f26a450061d26700250 Mon Sep 17 00:00:00 2001 From: iqdoctor <35931201+iqdoctor@users.noreply.github.com> Date: Wed, 27 May 2026 02:08:45 +0000 Subject: [PATCH] Tolerate missing Responses output when aggregating text Responses objects can be constructed from malformed or partial payloads where output is None. Avoid leaking a raw Python TypeError from parser and output_text convenience paths; treat a missing output list as empty so callers receive stable SDK behavior.\n\nConstraint: observed downstream Hermes/Codex integrations received terminal Responses snapshots with output=None.\nRejected: Leaving applications to catch TypeError | the SDK owns the Responses object invariant and can handle the missing list at the iteration boundary.\nConfidence: medium\nScope-risk: narrow\nDirective: If maintainers prefer a typed validation error, preserve the invariant that raw 'NoneType' iteration never escapes.\nTested: uv run pytest -q tests/lib/responses/test_responses.py -> 7 passed\nNot-tested: Full repository test suite --- src/openai/lib/_parsing/_responses.py | 2 +- .../lib/streaming/responses/_responses.py | 15 ++- src/openai/types/responses/response.py | 2 +- tests/lib/responses/test_responses.py | 117 ++++++++++++++++++ 4 files changed, 133 insertions(+), 3 deletions(-) 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/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 6975a9260d..446b56a16b 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -357,9 +357,22 @@ 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 getattr(response, "output", None) and snapshot.output: + response = construct_type_unchecked( + type_=cast(Any, ParsedResponse[object]), + value={ + **response.to_dict(), + "output": [ + item.to_dict() if hasattr(item, "to_dict") else item + 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/src/openai/types/responses/response.py b/src/openai/types/responses/response.py index dac3e09a89..4c90b029f9 100644 --- a/src/openai/types/responses/response.py +++ b/src/openai/types/responses/response.py @@ -313,7 +313,7 @@ def output_text(self) -> str: If no `output_text` content blocks exist, then an empty string is returned. """ texts: List[str] = [] - for output in self.output: + for output in self.output or []: if output.type == "message": for content in output.content: if content.type == "output_text": diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 8e5f16df95..6ec3f43837 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -7,6 +7,11 @@ from inline_snapshot import snapshot from openai import OpenAI, AsyncOpenAI +from openai._types import NOT_GIVEN +from openai.types.responses.response import Response +from openai.types.responses.response_output_text import ResponseOutputText +from openai.types.responses.response_output_message import ResponseOutputMessage +from openai.lib.streaming.responses._responses import ResponseStreamState from openai._utils import assert_signatures_in_sync from ...conftest import base_url @@ -61,3 +66,115 @@ def test_parse_method_definition_in_sync(sync: bool, client: OpenAI, async_clien checking_client.responses.parse, exclude_params={"tools"}, ) + + +def test_output_text_tolerates_none_output() -> None: + response = Response.construct( + id="resp_test", + object="response", + created_at=0, + model="gpt-test", + output=None, + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ) + + assert response.output_text == "" + + +def test_parse_response_tolerates_none_output() -> None: + from openai.lib._parsing._responses import parse_response + response = Response.construct( + id="resp_test", + object="response", + created_at=0, + model="gpt-test", + output=None, + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ) + + parsed = parse_response(text_format=NOT_GIVEN, input_tools=NOT_GIVEN, response=response) + + assert parsed.output == [] + + +@pytest.mark.parametrize("terminal_output", [None, []]) +def test_response_stream_preserves_snapshot_when_terminal_output_is_missing(terminal_output: object) -> None: + state = ResponseStreamState(input_tools=[], text_format=NOT_GIVEN) + + state.handle_event( + _Event( + type="response.created", + response=Response.construct( + id="resp_test", + object="response", + created_at=0, + model="gpt-test", + output=[], + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ), + ) + ) + state.handle_event( + _Event( + type="response.output_item.added", + output_index=0, + item=ResponseOutputMessage.construct( + id="msg_test", + type="message", + role="assistant", + status="in_progress", + content=[], + ), + ) + ) + state.handle_event( + _Event( + type="response.content_part.added", + output_index=0, + content_index=0, + part=ResponseOutputText.construct(type="output_text", text="", annotations=[]), + ) + ) + state.handle_event( + _Event( + type="response.output_text.delta", + output_index=0, + content_index=0, + item_id="msg_test", + delta="streamed text", + sequence_number=1, + logprobs=[], + ) + ) + + events = state.handle_event( + _Event( + type="response.completed", + sequence_number=2, + response=Response.construct( + id="resp_test", + object="response", + created_at=0, + model="gpt-test", + output=terminal_output, + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ), + ) + ) + + completed = events[0].response + assert completed.output_text == "streamed text" + assert completed.output[0].content[0].text == "streamed text" + + +class _Event: + def __init__(self, **kwargs: object) -> None: + self.__dict__.update(kwargs)