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/5] 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/5] 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/5] 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/5] 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 fb55f961d7561c01451d42dc7c2d8afe2acd0382 Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Thu, 23 Jul 2026 23:20:54 +0530 Subject: [PATCH 5/5] fix: import os and sanitize NO_PROXY in async client init Add missing 'import os' that caused NameError on every client construction. Also call _sanitize_no_proxy() in _DefaultAsyncHttpxClient so async clients get the same newline sanitization as sync clients. Addresses Codex P1 (missing os import) and P2 (async client not sanitized) review feedback. --- src/openai/_base_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index dc9de349da..a6fc653f67 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +import os import json import time import uuid @@ -1443,6 +1444,8 @@ 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 for async clients too — see _sanitize_no_proxy. + _sanitize_no_proxy() super().__init__(**kwargs)