From 952a477a82ce81b375bc9138021ba55997aae029 Mon Sep 17 00:00:00 2001 From: zerafachris Date: Thu, 16 Jul 2026 10:49:54 +0200 Subject: [PATCH 1/2] fix: sanitize NO_PROXY env var to handle newline-separated values When NO_PROXY (or other proxy env vars) contain newline characters, httpx raises InvalidURL because it only splits by comma. This is common in Docker/.env files where values span multiple lines. Fixes #3303 --- src/openai/_client.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/openai/_client.py b/src/openai/_client.py index 66d03b23dd..2f28911181 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -92,6 +92,17 @@ WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER = "workload-identity-auth" +def _sanitize_proxy_env() -> None: + for env_var in ("NO_PROXY", "no_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"): + value = os.environ.get(env_var) + if value is not None and ("\n" in value or "\r" in value): + os.environ[env_var] = ",".join( + part.strip() + for part in value.replace("\r\n", "\n").replace("\r", "\n").split("\n") + if part.strip() + ) + + def _has_header(headers: Headers, header: str) -> bool: header = header.lower() return any(key.lower() == header for key in headers) @@ -164,6 +175,7 @@ def __init__( When `provider` is supplied, authentication and the base URL are configured by that provider instead. """ + _sanitize_proxy_env() provider_runtime: _ProviderRuntime | None = None if provider is not None: provider_name = _provider_name(provider) @@ -760,6 +772,7 @@ def __init__( When `provider` is supplied, authentication and the base URL are configured by that provider instead. """ + _sanitize_proxy_env() provider_runtime: _ProviderRuntime | None = None if provider is not None: provider_name = _provider_name(provider) From aa7ae3b9af98744ee2518c70667ec413bb8fbc6d Mon Sep 17 00:00:00 2001 From: zerafachris Date: Sat, 18 Jul 2026 18:47:58 +0200 Subject: [PATCH 2/2] fix: only sanitize proxy env when constructing the default http client Addresses Codex review (P2): _sanitize_proxy_env() mutated the process-wide proxy environment unconditionally, including when the caller supplies their own http_client. Such a client may deliberately ignore the environment (trust_env=False), and the SDK never reads it in that case, so the mutation was both unnecessary and observable by unrelated code in the process. Gate the call on http_client is None, and add sync + async coverage for both branches. Also applies ruff format to the helper, which was unformatted. --- src/openai/_client.py | 29 +++++++++++++++++++++------ tests/test_client.py | 46 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/openai/_client.py b/src/openai/_client.py index 2f28911181..6c34005ce4 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -93,13 +93,28 @@ def _sanitize_proxy_env() -> None: - for env_var in ("NO_PROXY", "no_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"): + """Normalize newline-separated values in the proxy environment variables. + + Only called when we are about to construct the default httpx client, which reads + these variables via ``trust_env``. When the caller supplies their own ``http_client`` + we must not touch the process-wide environment, as that client may deliberately + ignore it (e.g. ``trust_env=False``) and other code in the process can observe + the mutation. + """ + for env_var in ( + "NO_PROXY", + "no_proxy", + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ): value = os.environ.get(env_var) if value is not None and ("\n" in value or "\r" in value): os.environ[env_var] = ",".join( - part.strip() - for part in value.replace("\r\n", "\n").replace("\r", "\n").split("\n") - if part.strip() + part.strip() for part in value.replace("\r\n", "\n").replace("\r", "\n").split("\n") if part.strip() ) @@ -175,7 +190,8 @@ def __init__( When `provider` is supplied, authentication and the base URL are configured by that provider instead. """ - _sanitize_proxy_env() + if http_client is None: + _sanitize_proxy_env() provider_runtime: _ProviderRuntime | None = None if provider is not None: provider_name = _provider_name(provider) @@ -772,7 +788,8 @@ def __init__( When `provider` is supplied, authentication and the base URL are configured by that provider instead. """ - _sanitize_proxy_env() + if http_client is None: + _sanitize_proxy_env() provider_runtime: _ProviderRuntime | None = None if provider is not None: provider_name = _provider_name(provider) diff --git a/tests/test_client.py b/tests/test_client.py index 2d8955a58e..b41e44a7bb 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1292,6 +1292,29 @@ def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> N assert len(mounts) == 1 assert mounts[0][0].pattern == "https://" + def test_proxy_env_sanitized_for_default_client(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NO_PROXY", "127.0.0.1\nlocalhost\r\nexample.com\n") + + OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + assert os.environ["NO_PROXY"] == "127.0.0.1,localhost,example.com" + + def test_proxy_env_untouched_when_http_client_supplied(self, monkeypatch: pytest.MonkeyPatch) -> None: + # A caller-supplied client may deliberately ignore the proxy environment + # (trust_env=False), so we must not mutate it process-wide on their behalf. + raw = "127.0.0.1\nlocalhost\r\nexample.com\n" + monkeypatch.setenv("NO_PROXY", raw) + + with httpx.Client(trust_env=False) as http_client: + OpenAI( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=http_client, + ) + + assert os.environ["NO_PROXY"] == raw + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") def test_default_client_creation(self) -> None: # Ensure that the client can be initialized without any exceptions @@ -2552,6 +2575,29 @@ async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch assert len(mounts) == 1 assert mounts[0][0].pattern == "https://" + async def test_proxy_env_sanitized_for_default_client(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NO_PROXY", "127.0.0.1\nlocalhost\r\nexample.com\n") + + AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + assert os.environ["NO_PROXY"] == "127.0.0.1,localhost,example.com" + + async def test_proxy_env_untouched_when_http_client_supplied(self, monkeypatch: pytest.MonkeyPatch) -> None: + # A caller-supplied client may deliberately ignore the proxy environment + # (trust_env=False), so we must not mutate it process-wide on their behalf. + raw = "127.0.0.1\nlocalhost\r\nexample.com\n" + monkeypatch.setenv("NO_PROXY", raw) + + async with httpx.AsyncClient(trust_env=False) as http_client: + AsyncOpenAI( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=http_client, + ) + + assert os.environ["NO_PROXY"] == raw + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") async def test_default_client_creation(self) -> None: # Ensure that the client can be initialized without any exceptions