From 486a8f5ea6e1f368f37fd4a54f5cf4a5f9c13041 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Sun, 19 Jul 2026 16:10:00 +0530 Subject: [PATCH 01/11] fix(parsing): guard against None response.output in parse_response The chatgpt.com Codex backend sometimes sends response.output: null in the consolidated response.completed event, even when valid output_item.done events were streamed earlier. The SDK then raises TypeError: 'NoneType' object is not iterable inside the stream accumulator, killing the entire stream before the consumer can read the deltas. Fix: iterate over response.output or [] instead of response.output directly. Closes #3325 --- src/openai/lib/_parsing/_responses.py | 2 +- 1 file changed, 1 insertion(+), 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: From 8a3d39a9a2b3d9f6bd262e57395b6c4f87fcd409 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Sun, 19 Jul 2026 17:49:16 +0530 Subject: [PATCH 02/11] fix(streaming): preserve accumulated output when response.completed has null output The chatgpt.com Codex backend sometimes sends response.output: null in the consolidated response.completed event even when valid output_item.done events were streamed earlier (issue #3325). The previous fix (response.output or []) prevented the TypeError but discarded the already-streamed snapshot.output, causing the final ParsedResponse to have empty output/output_text. Move the guard into ResponseStreamState.accumulate_event: when event.response.output is None and the snapshot has accumulated output items, build the completed response from the snapshot instead of calling parse_response with an empty output list. This preserves streamed text and tool calls in get_final_response() and ResponseCompletedEvent. Addresses Codex review feedback on #3517. --- src/openai/lib/_parsing/_responses.py | 2 +- .../lib/streaming/responses/_responses.py | 25 +++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 81e6b2b983..c607587ec1 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 or []: + for output in response.output: 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..5a45937a90 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -357,11 +357,26 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps if output.type == "function_call": output.arguments += event.delta elif event.type == "response.completed": - self._completed_response = parse_response( - text_format=self._text_format, - response=event.response, - input_tools=self._input_tools, - ) + # The chatgpt.com Codex backend sometimes sends `response.output: null` + # in the consolidated `response.completed` event even when valid + # `output_item.done` events were streamed earlier (see issue #3325). + # Calling `parse_response` with a null `output` would discard the + # already-accumulated `snapshot.output` and emit an empty final + # response, so we fall back to the streamed snapshot in that case. + if event.response.output is None and snapshot.output: + self._completed_response = construct_type_unchecked( + type_=ParsedResponse[TextFormatT], + value={ + **event.response.to_dict(), + "output": [item.to_dict() for item in snapshot.output], + }, + ) + else: + self._completed_response = parse_response( + text_format=self._text_format, + response=event.response, + input_tools=self._input_tools, + ) return snapshot From 8e55bf95fd4168eb9205e31715eed87e367c8741 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Sun, 19 Jul 2026 19:27:20 +0530 Subject: [PATCH 03/11] fix(streaming): route null-output fallback through parse_response Address Codex P2 review feedback on the previous commit (1c050f69): 1. Run response parsing on the streamed fallback: instead of building ParsedResponse directly from snapshot.output via construct_type_unchecked (which left output_parsed/parsed_arguments as None), inject the streamed items into a shallow copy of the response and pass it through parse_response so text_format and parsed_arguments logic still runs. 2. Handle null completed output without streamed items: re-add the 'response.output or []' guard in parse_response() so a null-output response.completed with no prior output_item.added events returns a parsed response with an empty output list instead of raising TypeError. Together these cover both branches: null output WITH accumulated snapshot items (parse_response runs on the injected items) and null output WITHOUT items (parse_response returns empty output gracefully). --- src/openai/lib/_parsing/_responses.py | 8 +++++++- .../lib/streaming/responses/_responses.py | 18 +++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index c607587ec1..abf3eb2217 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -58,7 +58,13 @@ def parse_response( ) -> ParsedResponse[TextFormatT]: output_list: List[ParsedResponseOutputItem[TextFormatT]] = [] - for output in response.output: + # Guard against `response.output` being `None` (observed in the chatgpt.com + # Codex backend's consolidated `response.completed` event — see issue #3325). + # When the streaming accumulator has already collected output items, it + # injects them into the response before calling this function, so reaching + # here with `None` means the stream genuinely had no output items and an + # empty list is the correct result. + 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 5a45937a90..617e5e00c6 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -360,17 +360,25 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps # The chatgpt.com Codex backend sometimes sends `response.output: null` # in the consolidated `response.completed` event even when valid # `output_item.done` events were streamed earlier (see issue #3325). - # Calling `parse_response` with a null `output` would discard the - # already-accumulated `snapshot.output` and emit an empty final - # response, so we fall back to the streamed snapshot in that case. + # `parse_response()` guards against `None` with `response.output or []`, + # but that would discard the already-accumulated `snapshot.output` and + # emit an empty final response. When the completed event has no + # output but the snapshot has accumulated items, inject the streamed + # items into a shallow copy of the response so `parse_response()` can + # still run its text_format / parsed_arguments logic on them. if event.response.output is None and snapshot.output: - self._completed_response = construct_type_unchecked( - type_=ParsedResponse[TextFormatT], + response_with_output = construct_type_unchecked( + type_=type(event.response), value={ **event.response.to_dict(), "output": [item.to_dict() for item in snapshot.output], }, ) + self._completed_response = parse_response( + text_format=self._text_format, + response=response_with_output, + input_tools=self._input_tools, + ) else: self._completed_response = parse_response( text_format=self._text_format, From 650fc645e2c1ef680dc412726be954c04c6dd8b7 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Mon, 20 Jul 2026 20:47:10 +0530 Subject: [PATCH 04/11] fix(client): sanitize newlines in NO_PROXY env var before httpx client init httpx's get_environment_proxies() only splits NO_PROXY by comma, not by newline. When NO_PROXY contains newline characters (common in Docker environments, .env files, or shell scripts), the newline becomes part of the hostname and httpx raises InvalidURL. Add _sanitize_no_proxy() which replaces newlines with commas and strips whitespace, called from _DefaultHttpxClient.__init__() before the httpx client is constructed. Closes #3303. --- src/openai/_base_client.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 10d7b9f7ca..9f732ff5a2 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -866,11 +866,31 @@ def _idempotency_key(self) -> str: return f"stainless-python-retry-{uuid.uuid4()}" +def _sanitize_no_proxy() -> None: + """Sanitize NO_PROXY/no_proxy env vars that contain newline characters. + + httpx's ``get_environment_proxies()`` only splits by comma, not by newline. + When NO_PROXY contains newlines (common in Docker/.env files), the newline + becomes part of the hostname and httpx raises ``InvalidURL`` (issue #3303). + """ + for key in ("NO_PROXY", "no_proxy"): + val = os.environ.get(key) + if val and "\n" in val: + os.environ[key] = ",".join( + part.strip() for part in val.replace("\n", ",").split(",") if part.strip() + ) + + class _DefaultHttpxClient(httpx.Client): def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("timeout", DEFAULT_TIMEOUT) kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) + # Sanitize NO_PROXY environment variable: httpx's get_environment_proxies() + # only splits by comma, not by newline. When NO_PROXY contains newlines + # (common in Docker/.env files), the newline becomes part of the hostname + # and httpx raises InvalidURL. See issue #3303. + _sanitize_no_proxy() super().__init__(**kwargs) From 860247ebb36afb69c1b58fb16bcf4f31f59fa332 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Mon, 20 Jul 2026 20:49:08 +0530 Subject: [PATCH 05/11] Revert "fix(client): sanitize newlines in NO_PROXY env var before httpx client init" This reverts commit 217dc74b35a13bd38619239ecc1a77f3e1aa10f5. --- src/openai/_base_client.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 9f732ff5a2..10d7b9f7ca 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -866,31 +866,11 @@ def _idempotency_key(self) -> str: return f"stainless-python-retry-{uuid.uuid4()}" -def _sanitize_no_proxy() -> None: - """Sanitize NO_PROXY/no_proxy env vars that contain newline characters. - - httpx's ``get_environment_proxies()`` only splits by comma, not by newline. - When NO_PROXY contains newlines (common in Docker/.env files), the newline - becomes part of the hostname and httpx raises ``InvalidURL`` (issue #3303). - """ - for key in ("NO_PROXY", "no_proxy"): - val = os.environ.get(key) - if val and "\n" in val: - os.environ[key] = ",".join( - part.strip() for part in val.replace("\n", ",").split(",") if part.strip() - ) - - class _DefaultHttpxClient(httpx.Client): def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("timeout", DEFAULT_TIMEOUT) kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) - # Sanitize NO_PROXY environment variable: httpx's get_environment_proxies() - # only splits by comma, not by newline. When NO_PROXY contains newlines - # (common in Docker/.env files), the newline becomes part of the hostname - # and httpx raises InvalidURL. See issue #3303. - _sanitize_no_proxy() super().__init__(**kwargs) From b042e5a6e7138d9ae98ffdda07dc1e09297e93c8 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Mon, 20 Jul 2026 20:52:05 +0530 Subject: [PATCH 06/11] fix(streaming): apply done events to snapshot for null-output fallback When response.completed has output: null, the fallback serializes snapshot.output, but accumulate_event never applied done events (response.output_text.done, response.output_item.done, response.content_part.done, response.function_call_arguments.done) to the snapshot. This meant get_final_response() could expose stale in_progress statuses and miss finalized text in the null-output case. Add handlers in accumulate_event for all four done event types so the snapshot reflects the finalized state before the fallback serializes it. Addresses Codex P2 review feedback on commit 479179ed. --- .../lib/streaming/responses/_responses.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 617e5e00c6..c95a62f13b 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -356,6 +356,30 @@ 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.output_text.done": + output = snapshot.output[event.output_index] + if output.type == "message": + content = output.content[event.content_index] + assert content.type == "output_text" + content.text = event.text + elif event.type == "response.output_item.done": + # Mark the item as done in the snapshot so the null-output fallback + # captures the finalized status rather than the in-progress state. + if event.output_index < len(snapshot.output): + item = snapshot.output[event.output_index] + if hasattr(item, "status"): + item.status = "completed" + elif event.type == "response.content_part.done": + output = snapshot.output[event.output_index] + if output.type == "message" and event.content_index < len(output.content): + part = output.content[event.content_index] + if hasattr(part, "status"): + part.status = "completed" + elif event.type == "response.function_call_arguments.done": + output = snapshot.output[event.output_index] + if output.type == "function_call": + if hasattr(output, "status"): + output.status = "completed" elif event.type == "response.completed": # The chatgpt.com Codex backend sometimes sends `response.output: null` # in the consolidated `response.completed` event even when valid From 63f903570f3ce603290d50513a5f1cb5b5dc35aa Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Tue, 21 Jul 2026 06:42:27 +0530 Subject: [PATCH 07/11] fix: apply authoritative payloads from done events to snapshot (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three P2 fixes for the null-output fallback path: 1. response.output_item.done — Replace the entire item in the snapshot with event.item (not just set status=completed). The server sends the authoritative item payload which may include final fields like results/outputs on tool items, or a final status of failed/incomplete. 2. response.content_part.done — Replace the entire content part with event.part (not just set status=completed). The server sends the authoritative part payload which may include annotations, logprobs, or finalized text/refusal content that delta accumulation may not fully capture. 3. response.function_call_arguments.done — Apply event.arguments (the finalized argument string) to the snapshot instead of only setting status=completed. The server sends the authoritative arguments which may differ from accumulated deltas, ensuring parse_response() can correctly parse parsed_arguments in the null-output fallback. --- .../lib/streaming/responses/_responses.py | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index c95a62f13b..764d54e6a9 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -363,21 +363,38 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps assert content.type == "output_text" content.text = event.text elif event.type == "response.output_item.done": - # Mark the item as done in the snapshot so the null-output fallback - # captures the finalized status rather than the in-progress state. + # Replace the item in the snapshot with the finalized item from the + # done event. The server sends the authoritative item payload here, + # which may include final fields like `results`/`outputs` on tool + # items, or a final `status` of `failed`/`incomplete`. Simply + # setting `status = "completed"` would discard those fields and + # produce a stale final response in the null-output fallback path. if event.output_index < len(snapshot.output): - item = snapshot.output[event.output_index] - if hasattr(item, "status"): - item.status = "completed" + snapshot.output[event.output_index] = construct_type_unchecked( + type_=type(snapshot.output[event.output_index]), + value=event.item.to_dict(), + ) elif event.type == "response.content_part.done": + # Replace the content part in the snapshot with the finalized part + # from the done event. The server sends the authoritative part + # payload here, which may include metadata like annotations, + # logprobs, or finalized text/refusal content that the delta + # accumulation may not fully capture. output = snapshot.output[event.output_index] if output.type == "message" and event.content_index < len(output.content): - part = output.content[event.content_index] - if hasattr(part, "status"): - part.status = "completed" + output.content[event.content_index] = construct_type_unchecked( + type_=type(output.content[event.content_index]), + value=event.part.to_dict(), + ) elif event.type == "response.function_call_arguments.done": + # Apply the finalized arguments string from the done event. + # The server sends the authoritative arguments payload here, which + # may differ from the accumulated deltas. Using the finalized + # arguments ensures `parse_response()` can correctly parse + # `parsed_arguments` in the null-output fallback path. output = snapshot.output[event.output_index] if output.type == "function_call": + output.arguments = event.arguments if hasattr(output, "status"): output.status = "completed" elif event.type == "response.completed": From 1a208f4d7c2ae052c0d8eb7e2aa48f979a3eea07 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Fri, 7 Aug 2026 12:24:10 +0530 Subject: [PATCH 08/11] fix: resolve type-check failures and add null-output regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback from @jbeckwith-oai on #3517: 1. Type check failures — Pyright reported reportUnnecessaryComparison on the null-output check because Response.output is declared non-nullable. Added pyright: ignore comments with explanatory context, and a cast(Any, ...) on the construct_type_unchecked value dict to resolve the partially unknown argument type. Both Pyright and Mypy now pass. 2. Regression tests — Added 6 focused stream-state tests in test_null_output_fallback.py covering: - Accumulated text survives null output - Done-event status survives null output - No-prior-items returns empty output - Normal completed-with-output path still works - Empty output list path still works - Function call arguments survive null output --- src/openai/lib/_parsing/_responses.py | 14 +- .../lib/streaming/responses/_responses.py | 29 +- .../responses/test_null_output_fallback.py | 358 ++++++++++++++++++ 3 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 tests/lib/responses/test_null_output_fallback.py diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index abf3eb2217..6361522a38 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -60,11 +60,15 @@ def parse_response( # Guard against `response.output` being `None` (observed in the chatgpt.com # Codex backend's consolidated `response.completed` event — see issue #3325). - # When the streaming accumulator has already collected output items, it - # injects them into the response before calling this function, so reaching - # here with `None` means the stream genuinely had no output items and an - # empty list is the correct result. - for output in response.output or []: + # The type model declares `output` as non-nullable, but the wire value can + # violate that contract. We normalize at the boundary so both Pyright and + # Mypy accept the check without weakening the model contract. When the + # streaming accumulator has already collected output items, it injects + # them into the response before calling this function, so reaching here + # with `None` means the stream genuinely had no output items and an empty + # list is the correct result. + output_items = response.output or [] # pyright: ignore[reportUnnecessaryComparison] + for output in output_items: 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 764d54e6a9..4afcc2fe2a 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -15,6 +15,7 @@ ) from ...._types import Omit, omit from ...._utils import is_given, consume_sync_iterator, consume_async_iterator +from ...._compat import PYDANTIC_V1 from ...._models import build, construct_type_unchecked from ...._streaming import Stream, AsyncStream from ....types.responses import ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent @@ -407,13 +408,31 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps # output but the snapshot has accumulated items, inject the streamed # items into a shallow copy of the response so `parse_response()` can # still run its text_format / parsed_arguments logic on them. - if event.response.output is None and snapshot.output: + # + # `output` is typed as non-nullable but the wire value can violate + # that contract; the `pyright: ignore` makes the check explicit + # without weakening the model contract. + if event.response.output is None and snapshot.output: # pyright: ignore[reportUnnecessaryComparison] + # Build a copy of the response with the accumulated output + # items injected. Use warnings=False on Pydantic v2 to suppress + # the serializer warning from dumping the invalid null output + # field (the repo's pytest config treats warnings as errors). + # On Pydantic v1, warnings=False is not supported, so we build + # the dict without dumping the invalid field — exclude_unset + # skips the null output entirely. + if PYDANTIC_V1: + base_dict = event.response.to_dict() + else: + base_dict = event.response.to_dict(warnings=False) # type: ignore[call-arg] response_with_output = construct_type_unchecked( type_=type(event.response), - value={ - **event.response.to_dict(), - "output": [item.to_dict() for item in snapshot.output], - }, + value=cast( + Any, + { + **base_dict, + "output": [item.to_dict() for item in snapshot.output], + }, + ), ) self._completed_response = parse_response( text_format=self._text_format, diff --git a/tests/lib/responses/test_null_output_fallback.py b/tests/lib/responses/test_null_output_fallback.py new file mode 100644 index 0000000000..16d7cc195a --- /dev/null +++ b/tests/lib/responses/test_null_output_fallback.py @@ -0,0 +1,358 @@ +"""Regression tests for ResponseStreamState null-output handling (issue #3325). + +The chatgpt.com Codex backend sometimes sends `response.output: null` in the +consolidated `response.completed` event even when valid `output_item.done` events +were streamed earlier. These tests verify that: + +1. Accumulated text/items survive when `response.completed.output` is `None`. +2. Authoritative done-event fields (status, text, arguments) are applied. +3. Parsed function arguments/text formats still run in the fallback path. +4. The no-prior-items case returns an empty output. +""" + +from __future__ import annotations + +from openai._types import omit +from openai._models import construct_type_unchecked +from openai.types.responses import ( + Response, + ResponseStreamEvent as RawResponseStreamEvent, +) +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_output_item_done_event import ( + ResponseOutputItemDoneEvent, +) +from openai.types.responses.response_output_item_added_event import ( + ResponseOutputItemAddedEvent, +) +from openai.types.responses.response_function_call_arguments_done_event import ( + ResponseFunctionCallArgumentsDoneEvent, +) + + +def _make_created_event() -> RawResponseStreamEvent: + """Create a minimal `response.created` event to seed the stream state.""" + response = construct_type_unchecked( + type_=Response, + value={ + "id": "resp_test", + "object": "response", + "created_at": 1754925861, + "status": "in_progress", + "model": "gpt-4o", + "output": [], + }, + ) + return construct_type_unchecked( + type_=ResponseCreatedEvent, + value={ + "type": "response.created", + "sequence_number": 0, + "response": response.to_dict(), + }, + ) + + +def _make_output_item_added_message() -> RawResponseStreamEvent: + """Create a `response.output_item.added` event for a message item.""" + return construct_type_unchecked( + type_=ResponseOutputItemAddedEvent, + value={ + "type": "response.output_item.added", + "sequence_number": 1, + "output_index": 0, + "item": { + "type": "message", + "id": "msg_001", + "status": "in_progress", + "role": "assistant", + "content": [], + }, + }, + ) + + +def _make_output_item_done_message(text: str = "Hello world") -> RawResponseStreamEvent: + """Create a `response.output_item.done` event for a message.""" + return construct_type_unchecked( + type_=ResponseOutputItemDoneEvent, + value={ + "type": "response.output_item.done", + "sequence_number": 4, + "output_index": 0, + "item": { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": text, + "annotations": [], + "logprobs": [], + } + ], + }, + }, + ) + + +def _make_completed_event_null_output() -> RawResponseStreamEvent: + """Create a `response.completed` event with `output: null`. + + Build the event from a raw dict to avoid calling ``to_dict()`` on a + ``Response(output=None)``, which would trigger a Pydantic serializer + warning (and the repo's pytest config treats warnings as errors). + """ + return construct_type_unchecked( + type_=ResponseCompletedEvent, + value={ + "type": "response.completed", + "sequence_number": 5, + "response": { + "id": "resp_test", + "object": "response", + "created_at": 1754925861, + "status": "completed", + "model": "gpt-4o", + "output": None, # The bug: output is null + }, + }, + ) + + +def _make_completed_event_with_output() -> RawResponseStreamEvent: + """Create a `response.completed` event with normal output.""" + response = construct_type_unchecked( + type_=Response, + value={ + "id": "resp_test", + "object": "response", + "created_at": 1754925861, + "status": "completed", + "model": "gpt-4o", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello world", + "annotations": [], + "logprobs": [], + } + ], + } + ], + }, + ) + return construct_type_unchecked( + type_=ResponseCompletedEvent, + value={ + "type": "response.completed", + "sequence_number": 5, + "response": response.to_dict(), + }, + ) + + +def _make_completed_event_empty_output() -> RawResponseStreamEvent: + """Create a `response.completed` event with empty output list.""" + response = construct_type_unchecked( + type_=Response, + value={ + "id": "resp_test", + "object": "response", + "created_at": 1754925861, + "status": "completed", + "model": "gpt-4o", + "output": [], + }, + ) + return construct_type_unchecked( + type_=ResponseCompletedEvent, + value={ + "type": "response.completed", + "sequence_number": 5, + "response": response.to_dict(), + }, + ) + + +def _make_state() -> ResponseStreamState: + """Create a ResponseStreamState with no text format or tools.""" + return ResponseStreamState( + input_tools=omit, + text_format=omit, + ) + + +class TestNullOutputFallback: + """Tests for the null-output fallback path in ResponseStreamState.""" + + def test_accumulated_text_survives_null_output(self): + """When response.completed has output=None, the accumulated text + from done events must survive in the final parsed response.""" + state = _make_state() + state.handle_event(_make_created_event()) + state.handle_event(_make_output_item_added_message()) + state.handle_event(_make_output_item_done_message("Hello world")) + + events = state.handle_event(_make_completed_event_null_output()) + + # The completed event should produce a ResponseCompletedEvent + assert len(events) == 1 + assert events[0].type == "response.completed" + + response = events[0].response + # The output should contain the accumulated message, not be empty + assert len(response.output) == 1 + assert response.output[0].type == "message" + assert response.output[0].content[0].text == "Hello world" + + def test_done_event_status_survives_null_output(self): + """The authoritative status from output_item.done must survive + in the null-output fallback path.""" + state = _make_state() + state.handle_event(_make_created_event()) + state.handle_event(_make_output_item_added_message()) + state.handle_event(_make_output_item_done_message("Hello world")) + + events = state.handle_event(_make_completed_event_null_output()) + + response = events[0].response + # The status should be "completed" from the done event, not "in_progress" + assert response.output[0].status == "completed" + + def test_no_prior_items_returns_empty_output(self): + """When response.completed has output=None and no items were + accumulated, the result should be an empty output list.""" + state = _make_state() + state.handle_event(_make_created_event()) + + events = state.handle_event(_make_completed_event_null_output()) + + assert len(events) == 1 + assert events[0].type == "response.completed" + # No items were accumulated, so output should be empty + assert len(events[0].response.output) == 0 + + def test_normal_completed_with_output_still_works(self): + """The normal path (output is not None) should still work correctly.""" + state = _make_state() + state.handle_event(_make_created_event()) + state.handle_event(_make_output_item_added_message()) + state.handle_event(_make_output_item_done_message("Hello world")) + + events = state.handle_event(_make_completed_event_with_output()) + + assert len(events) == 1 + assert events[0].type == "response.completed" + response = events[0].response + assert len(response.output) == 1 + assert response.output[0].type == "message" + assert response.output[0].content[0].text == "Hello world" + + def test_empty_output_completed_still_works(self): + """An empty output list (not None) should also produce empty output.""" + state = _make_state() + state.handle_event(_make_created_event()) + + events = state.handle_event(_make_completed_event_empty_output()) + + assert len(events) == 1 + assert events[0].type == "response.completed" + assert len(events[0].response.output) == 0 + + +class TestFunctionCallArgumentsDone: + """Tests for the function_call_arguments.done event handling.""" + + def _make_function_call_added(self) -> RawResponseStreamEvent: + return construct_type_unchecked( + type_=ResponseOutputItemAddedEvent, + value={ + "type": "response.output_item.added", + "sequence_number": 1, + "output_index": 0, + "item": { + "type": "function_call", + "id": "fc_001", + "call_id": "call_001", + "name": "get_weather", + "arguments": "", + "status": "in_progress", + }, + }, + ) + + def _make_function_call_arguments_done(self, args: str = '{"city": "SF"}') -> RawResponseStreamEvent: + return construct_type_unchecked( + type_=ResponseFunctionCallArgumentsDoneEvent, + value={ + "type": "response.function_call_arguments.done", + "sequence_number": 2, + "output_index": 0, + "item_id": "fc_001", + "arguments": args, + }, + ) + + def _make_function_call_item_done(self, args: str = '{"city": "SF"}') -> RawResponseStreamEvent: + return construct_type_unchecked( + type_=ResponseOutputItemDoneEvent, + value={ + "type": "response.output_item.done", + "sequence_number": 3, + "output_index": 0, + "item": { + "type": "function_call", + "id": "fc_001", + "call_id": "call_001", + "name": "get_weather", + "arguments": args, + "status": "completed", + }, + }, + ) + + def _make_completed_null_output(self) -> RawResponseStreamEvent: + """Build from a raw dict to avoid serializing the invalid null output.""" + return construct_type_unchecked( + type_=ResponseCompletedEvent, + value={ + "type": "response.completed", + "sequence_number": 4, + "response": { + "id": "resp_test", + "object": "response", + "created_at": 1754925861, + "status": "completed", + "model": "gpt-4o", + "output": None, + }, + }, + ) + + def test_function_call_arguments_survive_null_output(self): + """Finalized function call arguments from the done event must + survive in the null-output fallback path.""" + state = _make_state() + state.handle_event(_make_created_event()) + state.handle_event(self._make_function_call_added()) + state.handle_event(self._make_function_call_arguments_done('{"city": "SF"}')) + state.handle_event(self._make_function_call_item_done('{"city": "SF"}')) + + events = state.handle_event(self._make_completed_null_output()) + + response = events[0].response + assert len(response.output) == 1 + assert response.output[0].type == "function_call" + assert response.output[0].arguments == '{"city": "SF"}' + assert response.output[0].name == "get_weather" From b5c9a6c6fe0280d9cf1d9fa82cda5d132f820a6f Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Fri, 7 Aug 2026 17:59:57 +0530 Subject: [PATCH 09/11] fix: preserve model objects in null-output fallback and test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two Codex P2 comments: 1. Build nested response models in stream event fixtures — _make_created_event(), _make_completed_event_with_output(), and _make_completed_event_empty_output() now pass the Response model object directly instead of response.to_dict(). construct_type_unchecked is shallow, so passing a dict left event.response as a plain dict, and _create_initial_response() calling event.response.to_dict() would raise AttributeError. 2. Keep fallback output as models before parsing — The null-output fallback now passes list(snapshot.output) (model objects) directly instead of [item.to_dict() for item in snapshot.output]. Shallow construct_type_unchecked would leave dicts in the output list, so parse_response() dereferencing output.type would crash. All 6 null-output tests pass, ruff and pyright clean. --- src/openai/lib/streaming/responses/_responses.py | 6 +++++- tests/lib/responses/test_null_output_fallback.py | 15 +++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 4afcc2fe2a..ded15ad967 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -430,7 +430,11 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps Any, { **base_dict, - "output": [item.to_dict() for item in snapshot.output], + # Preserve the accumulated model objects directly + # instead of converting to dicts — construct_type_unchecked + # is shallow, so dicts would stay dicts and + # parse_response() would crash on `output.type`. + "output": list(snapshot.output), }, ), ) diff --git a/tests/lib/responses/test_null_output_fallback.py b/tests/lib/responses/test_null_output_fallback.py index 16d7cc195a..21bba16d13 100644 --- a/tests/lib/responses/test_null_output_fallback.py +++ b/tests/lib/responses/test_null_output_fallback.py @@ -33,7 +33,14 @@ def _make_created_event() -> RawResponseStreamEvent: - """Create a minimal `response.created` event to seed the stream state.""" + """Create a minimal `response.created` event to seed the stream state. + + The ``response`` field is passed as the *model object* (not a dict) so that + ``construct_type_unchecked`` preserves it as a ``Response`` instance. + ``ResponseStreamState._create_initial_response`` calls + ``event.response.to_dict()``, which would raise ``AttributeError`` on a + plain dict. + """ response = construct_type_unchecked( type_=Response, value={ @@ -50,7 +57,7 @@ def _make_created_event() -> RawResponseStreamEvent: value={ "type": "response.created", "sequence_number": 0, - "response": response.to_dict(), + "response": response, }, ) @@ -157,7 +164,7 @@ def _make_completed_event_with_output() -> RawResponseStreamEvent: value={ "type": "response.completed", "sequence_number": 5, - "response": response.to_dict(), + "response": response, }, ) @@ -180,7 +187,7 @@ def _make_completed_event_empty_output() -> RawResponseStreamEvent: value={ "type": "response.completed", "sequence_number": 5, - "response": response.to_dict(), + "response": response, }, ) From 05071e1bb5f3c54e9756bca3c0c4f923bca6f9e4 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Fri, 7 Aug 2026 18:32:03 +0530 Subject: [PATCH 10/11] fix: preserve nested content models and coerce dict responses in null-output fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two Codex P2 comments: 1. Preserve nested content models from done items — response.output_item.done and response.content_part.done were round-tripping through event.item.to_dict() / event.part.to_dict() before construct_type_unchecked, which is shallow. This left nested content parts as plain dicts, so parse_response() crashed on output.content[].type. Now passes event.item / event.part directly (already model objects). Also applied the same fix to response.output_item.added and response.content_part.added for consistency. 2. Coerce null-output events before dereferencing — In the default streaming path, construct_type on a response.completed payload with null output can fail validation and the discriminator fallback shallow-constructs the event, leaving event.response as a raw dict. The guard event.response.output would then raise AttributeError before the fallback could run. Now coerces event.response to a Response model via construct_type_unchecked before the null-output check. Added test_dict_response_coerced_before_null_output_check. All 7 tests pass, ruff and pyright clean. --- .../lib/streaming/responses/_responses.py | 46 +++++++++++++------ .../responses/test_null_output_fallback.py | 37 +++++++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index ded15ad967..4f4f157511 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -18,7 +18,7 @@ from ...._compat import PYDANTIC_V1 from ...._models import build, construct_type_unchecked from ...._streaming import Stream, AsyncStream -from ....types.responses import ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent +from ....types.responses import Response, ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent from ..._parsing._responses import TextFormatT, parse_text, parse_response from ....types.responses.tool_param import ToolParam from ....types.responses.parsed_response import ( @@ -331,22 +331,18 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps if event.type == "response.output_item.added": if event.item.type == "function_call": snapshot.output.append( - construct_type_unchecked( - type_=cast(Any, ParsedResponseFunctionToolCall), value=event.item.to_dict() - ) + construct_type_unchecked(type_=cast(Any, ParsedResponseFunctionToolCall), value=event.item) ) elif event.item.type == "message": snapshot.output.append( - construct_type_unchecked(type_=cast(Any, ParsedResponseOutputMessage), value=event.item.to_dict()) + construct_type_unchecked(type_=cast(Any, ParsedResponseOutputMessage), value=event.item) ) else: snapshot.output.append(event.item) elif event.type == "response.content_part.added": output = snapshot.output[event.output_index] if output.type == "message": - output.content.append( - construct_type_unchecked(type_=cast(Any, ParsedContent), value=event.part.to_dict()) - ) + output.content.append(construct_type_unchecked(type_=cast(Any, ParsedContent), value=event.part)) elif event.type == "response.output_text.delta": output = snapshot.output[event.output_index] if output.type == "message": @@ -370,10 +366,15 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps # items, or a final `status` of `failed`/`incomplete`. Simply # setting `status = "completed"` would discard those fields and # produce a stale final response in the null-output fallback path. + # + # Use event.item directly instead of event.item.to_dict() — + # construct_type_unchecked is shallow, so round-tripping through + # to_dict() would leave nested content parts as plain dicts, + # causing parse_response() to crash on output.content[].type. if event.output_index < len(snapshot.output): snapshot.output[event.output_index] = construct_type_unchecked( type_=type(snapshot.output[event.output_index]), - value=event.item.to_dict(), + value=event.item, ) elif event.type == "response.content_part.done": # Replace the content part in the snapshot with the finalized part @@ -381,11 +382,14 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps # payload here, which may include metadata like annotations, # logprobs, or finalized text/refusal content that the delta # accumulation may not fully capture. + # + # Use event.part directly instead of event.part.to_dict() for the + # same shallow-construction reason as output_item.done above. output = snapshot.output[event.output_index] if output.type == "message" and event.content_index < len(output.content): output.content[event.content_index] = construct_type_unchecked( type_=type(output.content[event.content_index]), - value=event.part.to_dict(), + value=event.part, ) elif event.type == "response.function_call_arguments.done": # Apply the finalized arguments string from the done event. @@ -412,7 +416,19 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps # `output` is typed as non-nullable but the wire value can violate # that contract; the `pyright: ignore` makes the check explicit # without weakening the model contract. - if event.response.output is None and snapshot.output: # pyright: ignore[reportUnnecessaryComparison] + # + # In the default streaming path, SSE data is converted through + # `construct_type(...)`. For a `response.completed` payload whose + # nested `response.output` is `null`, validation of the nested + # `Response` can fail and the discriminator fallback + # shallow-constructs the event, leaving `event.response` as the + # raw dict. Normalize it to a `Response` model before + # dereferencing `.output` so the guard doesn't raise + # `AttributeError` before the fallback can run. + response = event.response + if not isinstance(response, Response): # pyright: ignore[reportUnnecessaryIsInstance] + response = construct_type_unchecked(type_=Response, value=response) + if response.output is None and snapshot.output: # pyright: ignore[reportUnnecessaryComparison] # Build a copy of the response with the accumulated output # items injected. Use warnings=False on Pydantic v2 to suppress # the serializer warning from dumping the invalid null output @@ -421,11 +437,11 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps # the dict without dumping the invalid field — exclude_unset # skips the null output entirely. if PYDANTIC_V1: - base_dict = event.response.to_dict() + base_dict = response.to_dict() else: - base_dict = event.response.to_dict(warnings=False) # type: ignore[call-arg] + base_dict = response.to_dict(warnings=False) # type: ignore[call-arg] response_with_output = construct_type_unchecked( - type_=type(event.response), + type_=type(response), value=cast( Any, { @@ -446,7 +462,7 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps else: 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_null_output_fallback.py b/tests/lib/responses/test_null_output_fallback.py index 21bba16d13..8011fe0f7a 100644 --- a/tests/lib/responses/test_null_output_fallback.py +++ b/tests/lib/responses/test_null_output_fallback.py @@ -277,6 +277,43 @@ def test_empty_output_completed_still_works(self): assert events[0].type == "response.completed" assert len(events[0].response.output) == 0 + def test_dict_response_coerced_before_null_output_check(self): + """When the discriminator fallback leaves event.response as a raw dict + (because validation of Response(output=None) failed), the null-output + guard must coerce it to a Response model before dereferencing .output + instead of raising AttributeError.""" + state = _make_state() + state.handle_event(_make_created_event()) + state.handle_event(_make_output_item_added_message()) + state.handle_event(_make_output_item_done_message("Hello world")) + + # Build a completed event where `response` is a plain dict (simulating + # the discriminator fallback from construct_type on invalid null output) + completed_with_dict_response = construct_type_unchecked( + type_=ResponseCompletedEvent, + value={ + "type": "response.completed", + "sequence_number": 5, + "response": { + "id": "resp_test", + "object": "response", + "created_at": 1754925861, + "status": "completed", + "model": "gpt-4o", + "output": None, + }, + }, + ) + + events = state.handle_event(completed_with_dict_response) + + assert len(events) == 1 + assert events[0].type == "response.completed" + response = events[0].response + assert len(response.output) == 1 + assert response.output[0].type == "message" + assert response.output[0].content[0].text == "Hello world" + class TestFunctionCallArgumentsDone: """Tests for the function_call_arguments.done event handling.""" From 7636d0e368295c93556524dd8af5d174180cc9ff Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Tue, 11 Aug 2026 08:08:33 +0530 Subject: [PATCH 11/11] fix: use typed cast instead of pyright: ignore for nullable output check Replace `pyright: ignore[reportUnnecessaryComparison]` and `pyright: ignore[reportUnnecessaryIsInstance]` with narrowly scoped `cast(Optional[List[ResponseOutputItem]], ...)` so both Pyright and Mypy accept the None check without weakening the model contract. The `isinstance` check is now a plain runtime guard (no ignore needed) since `event.response` is typed as `Response` but can be a raw dict at runtime when validation falls back to shallow construction. --- src/openai/lib/_parsing/_responses.py | 17 +++++++++-------- .../lib/streaming/responses/_responses.py | 19 +++++++++++++------ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 6361522a38..bbaa061716 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING, List, Iterable, cast +from typing import TYPE_CHECKING, List, Iterable, Optional, cast from typing_extensions import TypeVar, assert_never import pydantic @@ -19,6 +19,7 @@ ParsedContent, ParsedResponse, FunctionToolParam, + ResponseOutputItem, ParsedResponseOutputItem, ParsedResponseOutputText, ResponseFunctionToolCall, @@ -61,13 +62,13 @@ def parse_response( # Guard against `response.output` being `None` (observed in the chatgpt.com # Codex backend's consolidated `response.completed` event — see issue #3325). # The type model declares `output` as non-nullable, but the wire value can - # violate that contract. We normalize at the boundary so both Pyright and - # Mypy accept the check without weakening the model contract. When the - # streaming accumulator has already collected output items, it injects - # them into the response before calling this function, so reaching here - # with `None` means the stream genuinely had no output items and an empty - # list is the correct result. - output_items = response.output or [] # pyright: ignore[reportUnnecessaryComparison] + # violate that contract. We cast to `Optional[List[...]]` at the boundary + # so both Pyright and Mypy accept the None check without weakening the + # model contract. When the streaming accumulator has already collected + # output items, it injects them into the response before calling this + # function, so reaching here with `None` means the stream genuinely had + # no output items and an empty list is the correct result. + output_items = cast(Optional[List[ResponseOutputItem]], response.output) or [] for output in output_items: if output.type == "message": content_list: List[ParsedContent[TextFormatT]] = [] diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 4f4f157511..4f24d1edf1 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -2,7 +2,7 @@ import inspect from types import TracebackType -from typing import Any, List, Generic, Iterable, Awaitable, cast +from typing import Any, List, Generic, Iterable, Optional, Awaitable, cast from typing_extensions import Self, Callable, Iterator, AsyncIterator from ._types import ParsedResponseSnapshot @@ -18,7 +18,12 @@ from ...._compat import PYDANTIC_V1 from ...._models import build, construct_type_unchecked from ...._streaming import Stream, AsyncStream -from ....types.responses import Response, ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent +from ....types.responses import ( + Response, + ParsedResponse, + ResponseOutputItem, + ResponseStreamEvent as RawResponseStreamEvent, +) from ..._parsing._responses import TextFormatT, parse_text, parse_response from ....types.responses.tool_param import ToolParam from ....types.responses.parsed_response import ( @@ -414,8 +419,9 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps # still run its text_format / parsed_arguments logic on them. # # `output` is typed as non-nullable but the wire value can violate - # that contract; the `pyright: ignore` makes the check explicit - # without weakening the model contract. + # that contract; cast to `Optional[List[...]]` at the boundary so + # both Pyright and Mypy accept the None check without weakening + # the model contract. # # In the default streaming path, SSE data is converted through # `construct_type(...)`. For a `response.completed` payload whose @@ -426,9 +432,10 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps # dereferencing `.output` so the guard doesn't raise # `AttributeError` before the fallback can run. response = event.response - if not isinstance(response, Response): # pyright: ignore[reportUnnecessaryIsInstance] + if not isinstance(response, Response): response = construct_type_unchecked(type_=Response, value=response) - if response.output is None and snapshot.output: # pyright: ignore[reportUnnecessaryComparison] + output = cast(Optional[List[ResponseOutputItem]], response.output) + if output is None and snapshot.output: # Build a copy of the response with the accumulated output # items injected. Use warnings=False on Pydantic v2 to suppress # the serializer warning from dumping the invalid null output