Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/openai/_base_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import sys
import os
import json
import time
import uuid
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Import os before using it in sanitizer

With the default sync client, _DefaultHttpxClient.__init__ now always calls _sanitize_no_proxy(), and this line references os even though _base_client.py does not import it. Any OpenAI()/DefaultHttpxClient() construction therefore raises NameError before httpx initialization, even when NO_PROXY is unset.

Useful? React with 👍 / 👎.

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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sanitize NO_PROXY before async client init

This call only runs in _DefaultHttpxClient; AsyncOpenAI creates AsyncHttpxClientWrapper from _DefaultAsyncHttpxClient (src/openai/_base_client.py:1546, 1441-1446), whose constructor still enters httpx.AsyncClient.__init__ with the original environment. In async clients with NO_PROXY/no_proxy containing newlines, the same InvalidURL during httpx environment proxy parsing remains, so the fix only works for sync users.

Useful? React with 👍 / 👎.

super().__init__(**kwargs)


Expand Down Expand Up @@ -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)


Expand Down
8 changes: 7 additions & 1 deletion src/openai/lib/_parsing/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 28 additions & 5 deletions src/openai/lib/streaming/responses/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use output_item.done data for null-output fallback

When response.completed has output: null, this fallback copies snapshot.output, but the snapshot is populated from response.output_item.added and deltas and never replaced from response.output_item.done before this branch. For streams where the added item is still in_progress and the done event carries the completed item/final annotations, the final parsed response returned here preserves the stale in-progress item instead of the finalized output.

Useful? React with 👍 / 👎.

},
)
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

Expand Down