From 168a416518cd92f12b1f568736899a0013eb9d1f Mon Sep 17 00:00:00 2001 From: "Thomas (toto) Bille" Date: Mon, 11 May 2026 21:32:08 +0200 Subject: [PATCH 1/2] fix: allow api_key="" to bypass credential validation for local servers In v2.34.0, the credential validation changed from an identity check (api_key is None) to a truthiness check (not self.api_key), which caused api_key="" to be rejected as missing credentials. This broke OpenAI-compatible local servers (llama.cpp, llamafile, LM Studio, vLLM) that don't require authentication. Track whether api_key was explicitly provided by the caller and skip the credential error when it was, even if the value is an empty string. Fixes #3224 --- src/openai/_client.py | 8 ++++++++ tests/test_client.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/openai/_client.py b/src/openai/_client.py index 499a62dfe5..ce154ad526 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -163,6 +163,7 @@ def __init__( self.workload_identity = workload_identity + _api_key_explicitly_set = False if workload_identity is not None: self.api_key = WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER self._api_key_provider = None @@ -175,9 +176,11 @@ def __init__( if callable(api_key): self.api_key = "" self._api_key_provider: Callable[[], str] | None = api_key # type: ignore[no-redef] + _api_key_explicitly_set = True else: self.api_key = api_key or "" self._api_key_provider = None + _api_key_explicitly_set = api_key is not None self._workload_identity_auth = None if admin_api_key is None: @@ -187,6 +190,7 @@ def __init__( if ( _enforce_credentials and not self.api_key + and not _api_key_explicitly_set and self._api_key_provider is None and workload_identity is None and self.admin_api_key is None @@ -669,6 +673,7 @@ def __init__( self.workload_identity = workload_identity + _api_key_explicitly_set = False if workload_identity is not None: self.api_key = WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER self._api_key_provider = None @@ -681,9 +686,11 @@ def __init__( if callable(api_key): self.api_key = "" self._api_key_provider: Callable[[], Awaitable[str]] | None = api_key # type: ignore[no-redef] + _api_key_explicitly_set = True else: self.api_key = api_key or "" self._api_key_provider = None + _api_key_explicitly_set = api_key is not None self._workload_identity_auth = None if admin_api_key is None: @@ -693,6 +700,7 @@ def __init__( if ( _enforce_credentials and not self.api_key + and not _api_key_explicitly_set and self._api_key_provider is None and workload_identity is None and self.admin_api_key is None diff --git a/tests/test_client.py b/tests/test_client.py index e2bb6ea966..3cee1b0242 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -524,6 +524,22 @@ def test_validate_headers(self) -> None: with pytest.raises(OpenAIError, match="Missing credentials"): OpenAI(base_url=base_url, api_key=None, admin_api_key=None, _strict_response_validation=True) + # Explicitly passing api_key="" should not raise, even with _enforce_credentials=True. + # This is important for OpenAI-compatible local servers that don't require authentication. + with update_env( + **{ + "OPENAI_API_KEY": Omit(), + "OPENAI_ADMIN_KEY": Omit(), + } + ): + client = OpenAI( + base_url=base_url, + api_key="", + admin_api_key=None, + _strict_response_validation=True, + ) + assert client.api_key == "" + @pytest.mark.respx(base_url=base_url) def test_api_key_provider_preserves_admin_auth(self, respx_mock: MockRouter) -> None: respx_mock.get("/organization/projects").mock(return_value=httpx.Response(200, json={"ok": True})) @@ -1788,6 +1804,22 @@ async def test_validate_headers(self) -> None: with pytest.raises(OpenAIError, match="Missing credentials"): AsyncOpenAI(base_url=base_url, api_key=None, admin_api_key=None, _strict_response_validation=True) + # Explicitly passing api_key="" should not raise, even with _enforce_credentials=True. + # This is important for OpenAI-compatible local servers that don't require authentication. + with update_env( + **{ + "OPENAI_API_KEY": Omit(), + "OPENAI_ADMIN_KEY": Omit(), + } + ): + client = AsyncOpenAI( + base_url=base_url, + api_key="", + admin_api_key=None, + _strict_response_validation=True, + ) + assert client.api_key == "" + @pytest.mark.respx(base_url=base_url) async def test_api_key_provider_preserves_admin_auth(self, respx_mock: MockRouter) -> None: respx_mock.get("/organization/projects").mock(return_value=httpx.Response(200, json={"ok": True})) From 089169b2692065c9ad1459ea669a9c33ba71c856 Mon Sep 17 00:00:00 2001 From: "Thomas (toto) Bille" Date: Mon, 11 May 2026 21:36:31 +0200 Subject: [PATCH 2/2] fix: track api_key explicit-set before env var fallback Move _api_key_explicitly_set check before the OPENAI_API_KEY env var lookup so that only caller-provided api_key="" bypasses validation. OPENAI_API_KEY="" in the environment (likely misconfiguration) still raises the Missing credentials error. Add tests for the OPENAI_API_KEY="" env var scenario. --- src/openai/_client.py | 8 ++------ tests/test_client.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/openai/_client.py b/src/openai/_client.py index ce154ad526..19184f7fd6 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -163,7 +163,7 @@ def __init__( self.workload_identity = workload_identity - _api_key_explicitly_set = False + _api_key_explicitly_set = api_key is not None if workload_identity is not None: self.api_key = WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER self._api_key_provider = None @@ -176,11 +176,9 @@ def __init__( if callable(api_key): self.api_key = "" self._api_key_provider: Callable[[], str] | None = api_key # type: ignore[no-redef] - _api_key_explicitly_set = True else: self.api_key = api_key or "" self._api_key_provider = None - _api_key_explicitly_set = api_key is not None self._workload_identity_auth = None if admin_api_key is None: @@ -673,7 +671,7 @@ def __init__( self.workload_identity = workload_identity - _api_key_explicitly_set = False + _api_key_explicitly_set = api_key is not None if workload_identity is not None: self.api_key = WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER self._api_key_provider = None @@ -686,11 +684,9 @@ def __init__( if callable(api_key): self.api_key = "" self._api_key_provider: Callable[[], Awaitable[str]] | None = api_key # type: ignore[no-redef] - _api_key_explicitly_set = True else: self.api_key = api_key or "" self._api_key_provider = None - _api_key_explicitly_set = api_key is not None self._workload_identity_auth = None if admin_api_key is None: diff --git a/tests/test_client.py b/tests/test_client.py index 3cee1b0242..b2e57437fe 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -540,6 +540,17 @@ def test_validate_headers(self) -> None: ) assert client.api_key == "" + # OPENAI_API_KEY="" in the environment (without explicit api_key arg) should still raise, + # as an empty env var likely indicates misconfiguration rather than intentional use. + with update_env( + **{ + "OPENAI_API_KEY": "", + "OPENAI_ADMIN_KEY": Omit(), + } + ): + with pytest.raises(OpenAIError, match="Missing credentials"): + OpenAI(base_url=base_url, admin_api_key=None, _strict_response_validation=True) + @pytest.mark.respx(base_url=base_url) def test_api_key_provider_preserves_admin_auth(self, respx_mock: MockRouter) -> None: respx_mock.get("/organization/projects").mock(return_value=httpx.Response(200, json={"ok": True})) @@ -1820,6 +1831,17 @@ async def test_validate_headers(self) -> None: ) assert client.api_key == "" + # OPENAI_API_KEY="" in the environment (without explicit api_key arg) should still raise, + # as an empty env var likely indicates misconfiguration rather than intentional use. + with update_env( + **{ + "OPENAI_API_KEY": "", + "OPENAI_ADMIN_KEY": Omit(), + } + ): + with pytest.raises(OpenAIError, match="Missing credentials"): + AsyncOpenAI(base_url=base_url, admin_api_key=None, _strict_response_validation=True) + @pytest.mark.respx(base_url=base_url) async def test_api_key_provider_preserves_admin_auth(self, respx_mock: MockRouter) -> None: respx_mock.get("/organization/projects").mock(return_value=httpx.Response(200, json={"ok": True}))