From 49ab38b6243f5ce3ccc511997c325f047d3b2e3e Mon Sep 17 00:00:00 2001 From: adhavan Date: Mon, 15 Jun 2026 13:12:05 +0530 Subject: [PATCH 1/2] fix: guard against null output in parse_response and output_text property When the Responses API returns `output: null` in a `response.completed` event (observed with the chatgpt.com Codex backend), iterating `response.output` raised `TypeError: 'NoneType' object is not iterable`. A second related case is `output_text` content items whose `text` field is `null`, which caused `parse_response` to pass `None` to structured text parsing and `output_text` to concatenate `None` into the result. Fixes: - `parse_response` in `lib/_parsing/_responses.py`: iterate `response.output or []` so a null list is treated as empty. - Same function: skip structured parsing for `output_text` items where `item.text is None`; set `parsed=None` for those items instead. - `Response.output_text` property in `types/responses/response.py`: iterate `self.output or []` and skip content items with `text is None`. Adds four regression tests covering all three cases. Closes #3325, #3063 --- src/openai/lib/_parsing/_responses.py | 4 +- src/openai/types/responses/response.py | 4 +- tests/lib/responses/test_null_output.py | 101 ++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 tests/lib/responses/test_null_output.py diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 232718cef6..7886b9b7b9 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: @@ -71,7 +71,7 @@ def parse_response( type_=ParsedResponseOutputText[TextFormatT], value={ **item.to_dict(), - "parsed": parse_text(item.text, text_format=text_format), + "parsed": parse_text(item.text, text_format=text_format) if item.text is not None else None, }, ) ) diff --git a/src/openai/types/responses/response.py b/src/openai/types/responses/response.py index 67102d2628..9f639eec24 100644 --- a/src/openai/types/responses/response.py +++ b/src/openai/types/responses/response.py @@ -444,10 +444,10 @@ 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": + if content.type == "output_text" and content.text is not None: texts.append(content.text) return "".join(texts) diff --git a/tests/lib/responses/test_null_output.py b/tests/lib/responses/test_null_output.py new file mode 100644 index 0000000000..7bc13dd7b5 --- /dev/null +++ b/tests/lib/responses/test_null_output.py @@ -0,0 +1,101 @@ +"""Regression tests for null-output edge cases in the Responses API.""" +from __future__ import annotations + +import pytest + +from openai.types.responses.response import Response +from openai.lib._parsing._responses import parse_response + + +def _make_response(output): + """Build a minimal Response fixture with the given output value.""" + return Response.model_construct( + id="resp_test", + object="response", + created_at=0, + status="completed", + background=False, + 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=None, + safety_identifier=None, + service_tier="default", + store=True, + temperature=1.0, + text=None, + tool_choice="auto", + tools=[], + top_p=1.0, + truncation="disabled", + usage=None, + user=None, + metadata={}, + ) + + +def test_output_text_property_null_output(): + """Response.output_text must return '' when output is None (issue #3325 / #3063).""" + resp = _make_response(output=None) + assert resp.output_text == "" + + +def test_output_text_property_null_text_in_content(): + """Response.output_text must skip output_text items with text=None (issue #3063).""" + from openai.types.responses.response_output_message import ResponseOutputMessage + from openai.types.responses.response_output_text import ResponseOutputText + + content = [ + ResponseOutputText.model_construct(type="output_text", text=None, annotations=[], logprobs=[]), + ResponseOutputText.model_construct(type="output_text", text='{"ok": true}', annotations=[], logprobs=[]), + ] + msg = ResponseOutputMessage.model_construct( + id="msg_test", + type="message", + status="completed", + role="assistant", + content=content, + ) + resp = _make_response(output=[msg]) + # only the non-null text should be concatenated + assert resp.output_text == '{"ok": true}' + + +def test_parse_response_null_output_does_not_crash(): + """parse_response must not raise TypeError when response.output is None (issue #3325).""" + from openai import NOT_GIVEN + + resp = _make_response(output=None) + # Should not raise + parsed = parse_response(text_format=NOT_GIVEN, input_tools=NOT_GIVEN, response=resp) + assert parsed.output == [] + + +def test_parse_response_null_text_skips_structured_parse(): + """parse_response must not crash when an output_text item has text=None (issue #3063).""" + from openai import NOT_GIVEN + from openai.types.responses.response_output_message import ResponseOutputMessage + from openai.types.responses.response_output_text import ResponseOutputText + + content = [ + ResponseOutputText.model_construct(type="output_text", text=None, annotations=[], logprobs=[]), + ResponseOutputText.model_construct(type="output_text", text="hello", annotations=[], logprobs=[]), + ] + msg = ResponseOutputMessage.model_construct( + id="msg_test", + type="message", + status="completed", + role="assistant", + content=content, + ) + resp = _make_response(output=[msg]) + # Should not raise; null-text item gets parsed=None, non-null item gets parsed normally. + parsed = parse_response(text_format=NOT_GIVEN, input_tools=NOT_GIVEN, response=resp) + assert len(parsed.output) == 1 From bbd183079478a4aa1e3f4414093e8da4682f2b24 Mon Sep 17 00:00:00 2001 From: adhavan Date: Mon, 15 Jun 2026 14:58:20 +0530 Subject: [PATCH 2/2] fix: fall back to accumulated snapshot output when response.completed has null output --- src/openai/lib/streaming/responses/_responses.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 6975a9260d..62ac76f27c 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -357,9 +357,23 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps if output.type == "function_call": output.arguments += event.delta elif event.type == "response.completed": + # Some backends (e.g. the chatgpt.com Codex backend) send + # `output: null` in the final `response.completed` event even when + # valid output items were already delivered via `output_item.done` + # events and accumulated into `snapshot.output`. In that case we + # must not let parse_response iterate over null and produce an empty + # output list; instead we patch the completed event's response with + # the accumulated snapshot output before parsing so that the final + # ParsedResponse contains the real content. + completed_response = event.response + if completed_response.output is None and snapshot is not None: + completed_response = build( + type(completed_response), + **{**completed_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=completed_response, input_tools=self._input_tools, )