From 49ab38b6243f5ce3ccc511997c325f047d3b2e3e Mon Sep 17 00:00:00 2001 From: adhavan Date: Mon, 15 Jun 2026 13:12:05 +0530 Subject: [PATCH 1/5] 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/5] 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, ) From de0ac7bb2924adf00cadee7309b58016420328ad Mon Sep 17 00:00:00 2001 From: adhavan Date: Sun, 2 Aug 2026 16:20:34 +0530 Subject: [PATCH 3/5] Drop unused pytest import and sort imports in the null-output tests ruff was reporting F401 for the unused pytest import plus three I001 import-order errors in this file, which fails scripts/lint before the regression tests get a chance to run. ruff check . is clean now. --- tests/lib/responses/test_null_output.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/lib/responses/test_null_output.py b/tests/lib/responses/test_null_output.py index 7bc13dd7b5..1536d9d36f 100644 --- a/tests/lib/responses/test_null_output.py +++ b/tests/lib/responses/test_null_output.py @@ -1,10 +1,8 @@ """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 +from openai.types.responses.response import Response def _make_response(output): @@ -49,8 +47,8 @@ def test_output_text_property_null_output(): 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 + from openai.types.responses.response_output_message import ResponseOutputMessage content = [ ResponseOutputText.model_construct(type="output_text", text=None, annotations=[], logprobs=[]), @@ -81,8 +79,8 @@ def test_parse_response_null_output_does_not_crash(): 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 + from openai.types.responses.response_output_message import ResponseOutputMessage content = [ ResponseOutputText.model_construct(type="output_text", text=None, annotations=[], logprobs=[]), From 1e7d216bf6640510c80a5ede3a14f086c02c7a96 Mon Sep 17 00:00:00 2001 From: adhavan Date: Sun, 2 Aug 2026 19:19:45 +0530 Subject: [PATCH 4/5] Make the null-output guards type-check The lint job was failing on pyright with 12 errors across the four files. Three of them are the defensive guards themselves: output and text are generated as non-optional, so pyright reads `is not None` as a comparison that can never be false. That is exactly the backend behaviour this PR exists to tolerate, so each guard now carries a targeted `pyright: ignore[reportUnnecessaryComparison]` with a comment saying why, rather than being removed. build() returns an untyped value, which made completed_response Unknown and leaked into the parse_response call. It is now annotated as Response and the build() result cast back to it. The rest were in the test: parse_response takes Omit rather than NotGiven since the merge with main, and the fixture helper had no parameter annotation. pyright is clean on all four files, ruff check and ruff format pass, and the null-output tests still pass. --- src/openai/lib/_parsing/_responses.py | 7 ++++++- .../lib/streaming/responses/_responses.py | 17 ++++++++++++----- src/openai/types/responses/response.py | 2 +- tests/lib/responses/test_null_output.py | 12 +++++++----- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 5f6f8dcafc..4f0b539d2f 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -71,7 +71,12 @@ def parse_response( type_=ParsedResponseOutputText[TextFormatT], value={ **item.to_dict(), - "parsed": parse_text(item.text, text_format=text_format) if item.text is not None else None, + # item.text is typed str, but some backends send null; guard anyway. + "parsed": ( + parse_text(item.text, text_format=text_format) + if item.text is not None # pyright: ignore[reportUnnecessaryComparison] + else None + ), }, ) ) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 62ac76f27c..c68411a436 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -19,6 +19,7 @@ from ...._streaming import Stream, AsyncStream from ....types.responses import ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent from ..._parsing._responses import TextFormatT, parse_text, parse_response +from ....types.responses.response import Response from ....types.responses.tool_param import ToolParam from ....types.responses.parsed_response import ( ParsedContent, @@ -365,11 +366,17 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps # 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]}, + completed_response: Response = event.response + # output is typed as a list, but the null-completed backend case is + # exactly what this branch exists for. + if completed_response.output is None and snapshot is not None: # pyright: ignore[reportUnnecessaryComparison] + # build() is untyped, so cast to keep the annotation meaningful. + completed_response = cast( + 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, diff --git a/src/openai/types/responses/response.py b/src/openai/types/responses/response.py index 974033145e..f5550db378 100644 --- a/src/openai/types/responses/response.py +++ b/src/openai/types/responses/response.py @@ -482,7 +482,7 @@ def output_text(self) -> str: for output in self.output or []: if output.type == "message": for content in output.content: - if content.type == "output_text" and content.text is not None: + if content.type == "output_text" and content.text is not None: # pyright: ignore[reportUnnecessaryComparison] 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 index 1536d9d36f..a30ce545f8 100644 --- a/tests/lib/responses/test_null_output.py +++ b/tests/lib/responses/test_null_output.py @@ -1,11 +1,15 @@ """Regression tests for null-output edge cases in the Responses API.""" + from __future__ import annotations +from typing import Any + +from openai import omit from openai.lib._parsing._responses import parse_response from openai.types.responses.response import Response -def _make_response(output): +def _make_response(output: Any) -> Response: """Build a minimal Response fixture with the given output value.""" return Response.model_construct( id="resp_test", @@ -68,17 +72,15 @@ def test_output_text_property_null_text_in_content(): 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) + parsed = parse_response(text_format=omit, input_tools=omit, 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_text import ResponseOutputText from openai.types.responses.response_output_message import ResponseOutputMessage @@ -95,5 +97,5 @@ def test_parse_response_null_text_skips_structured_parse(): ) 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) + parsed = parse_response(text_format=omit, input_tools=omit, response=resp) assert len(parsed.output) == 1 From 0794f572664a1c0d95b9ebfac10b2b68f166e879 Mon Sep 17 00:00:00 2001 From: adhavan Date: Sun, 2 Aug 2026 19:47:21 +0530 Subject: [PATCH 5/5] Satisfy mypy as well as pyright on the null-output branch The previous commit fixed pyright but broke mypy, which reported the branch as unreachable for the same reason: output is generated as non-optional, so `is None` looks impossible to both checkers. Rather than silencing them separately, the value is now read through getattr, which neither narrows. The `snapshot is not None` half of the condition was genuinely redundant, since accumulate_event returns early when snapshot is None, so it is gone. to_dict() returns dict[str, object], so the ** spread could not be checked field by field against Response; it is now built as an explicit dict[str, Any]. That also made build() resolve to Response, so the cast is no longer needed. Test functions gained return annotations, which mypy requires. mypy and pyright are both clean on the four changed files, ruff check and ruff format pass, and the null-output tests pass. --- .../lib/streaming/responses/_responses.py | 25 +++++++++++-------- tests/lib/responses/test_null_output.py | 8 +++--- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index c68411a436..8df8a1da9f 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -367,17 +367,20 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps # the accumulated snapshot output before parsing so that the final # ParsedResponse contains the real content. completed_response: Response = event.response - # output is typed as a list, but the null-completed backend case is - # exactly what this branch exists for. - if completed_response.output is None and snapshot is not None: # pyright: ignore[reportUnnecessaryComparison] - # build() is untyped, so cast to keep the annotation meaningful. - completed_response = cast( - Response, - build( - type(completed_response), - **{**completed_response.to_dict(), "output": [item.to_dict() for item in snapshot.output]}, - ), - ) + # `output` is generated as non-optional, so both type checkers treat + # `is None` as impossible. Some backends do send null here, which is the + # case this branch exists for, so read it through getattr rather than + # silencing mypy and pyright separately. `snapshot` is already known to + # be non-None by this point. + completed_output = getattr(completed_response, "output", None) + if completed_output is None: + # to_dict() gives dict[str, object], so the ** spread cannot be + # checked field by field against Response. + patched: dict[str, Any] = { + **completed_response.to_dict(), + "output": [item.to_dict() for item in snapshot.output], + } + completed_response = build(type(completed_response), **patched) self._completed_response = parse_response( text_format=self._text_format, response=completed_response, diff --git a/tests/lib/responses/test_null_output.py b/tests/lib/responses/test_null_output.py index a30ce545f8..0be0ffb8bf 100644 --- a/tests/lib/responses/test_null_output.py +++ b/tests/lib/responses/test_null_output.py @@ -43,13 +43,13 @@ def _make_response(output: Any) -> Response: ) -def test_output_text_property_null_output(): +def test_output_text_property_null_output() -> None: """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(): +def test_output_text_property_null_text_in_content() -> None: """Response.output_text must skip output_text items with text=None (issue #3063).""" from openai.types.responses.response_output_text import ResponseOutputText from openai.types.responses.response_output_message import ResponseOutputMessage @@ -70,7 +70,7 @@ def test_output_text_property_null_text_in_content(): assert resp.output_text == '{"ok": true}' -def test_parse_response_null_output_does_not_crash(): +def test_parse_response_null_output_does_not_crash() -> None: """parse_response must not raise TypeError when response.output is None (issue #3325).""" resp = _make_response(output=None) @@ -79,7 +79,7 @@ def test_parse_response_null_output_does_not_crash(): assert parsed.output == [] -def test_parse_response_null_text_skips_structured_parse(): +def test_parse_response_null_text_skips_structured_parse() -> None: """parse_response must not crash when an output_text item has text=None (issue #3063).""" from openai.types.responses.response_output_text import ResponseOutputText from openai.types.responses.response_output_message import ResponseOutputMessage