From cb666413b45f29ecd0ff650d068d95b316cb212f Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Sun, 19 Jul 2026 16:10:00 +0530 Subject: [PATCH 1/7] 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 1c050f6988326529c3c43ffaa2666fba94f91ee1 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Sun, 19 Jul 2026 17:49:16 +0530 Subject: [PATCH 2/7] 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 479179ed84cf5ae4599592e5cc8372c8e9c8df29 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Sun, 19 Jul 2026 19:27:20 +0530 Subject: [PATCH 3/7] 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 217dc74b35a13bd38619239ecc1a77f3e1aa10f5 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Mon, 20 Jul 2026 20:47:10 +0530 Subject: [PATCH 4/7] 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 4933c8e2fe..dc9de349da 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -831,11 +831,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 da9a9fbe7a1a538ccd4253d8c3e76bde5875e1e9 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Mon, 20 Jul 2026 20:49:08 +0530 Subject: [PATCH 5/7] 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 dc9de349da..4933c8e2fe 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -831,31 +831,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 b097d55f6a3729fb8b3cc631c558231585b746b8 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Mon, 20 Jul 2026 20:52:05 +0530 Subject: [PATCH 6/7] 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 103680f7388320b3026bd9e3a5fcc4cf2e445efd Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Tue, 21 Jul 2026 06:42:27 +0530 Subject: [PATCH 7/7] 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":