diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 4933c8e2fe..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 @@ -831,11 +832,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) @@ -1423,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) 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 6975a9260d..617e5e00c6 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -357,11 +357,34 @@ 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). + # `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: + 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, + response=event.response, + input_tools=self._input_tools, + ) return snapshot