From e8c5dfebf59e8eb05aba65f5fb2d175785baf35a Mon Sep 17 00:00:00 2001 From: "Thomas (toto) Bille" Date: Mon, 11 May 2026 21:32:08 +0200 Subject: [PATCH 1/3] 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 d7ca675b4d..b6457d61d4 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -197,6 +197,7 @@ def __init__( self.workload_identity = workload_identity if provider_runtime is None else None + _api_key_explicitly_set = False if provider_runtime is not None: self.api_key = "" self._api_key_provider = None @@ -211,9 +212,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 and provider_runtime is None: @@ -224,6 +227,7 @@ def __init__( provider_runtime is None and _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 @@ -803,6 +807,7 @@ def __init__( self.workload_identity = workload_identity if provider_runtime is None else None + _api_key_explicitly_set = False if provider_runtime is not None: self.api_key = "" self._api_key_provider = None @@ -817,9 +822,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 and provider_runtime is None: @@ -830,6 +837,7 @@ def __init__( provider_runtime is None and _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 33a5b1c224..f35864c307 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})) @@ -1837,6 +1853,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 c8c66949e167f98c02e554912d02161b8f1e8c63 Mon Sep 17 00:00:00 2001 From: "Thomas (toto) Bille" Date: Mon, 11 May 2026 21:36:31 +0200 Subject: [PATCH 2/3] 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 b6457d61d4..b64bee8b7f 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -197,7 +197,7 @@ def __init__( self.workload_identity = workload_identity if provider_runtime is None else None - _api_key_explicitly_set = False + _api_key_explicitly_set = api_key is not None if provider_runtime is not None: self.api_key = "" self._api_key_provider = None @@ -212,11 +212,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 and provider_runtime is None: @@ -807,7 +805,7 @@ def __init__( self.workload_identity = workload_identity if provider_runtime is None else None - _api_key_explicitly_set = False + _api_key_explicitly_set = api_key is not None if provider_runtime is not None: self.api_key = "" self._api_key_provider = None @@ -822,11 +820,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 and provider_runtime is None: diff --git a/tests/test_client.py b/tests/test_client.py index f35864c307..9005cedfe8 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})) @@ -1869,6 +1880,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})) From 32145273056c647c2b4e47e69c9ff5bc6fedea6d Mon Sep 17 00:00:00 2001 From: "Thomas (toto) Bille" Date: Wed, 5 Aug 2026 13:26:47 +0200 Subject: [PATCH 3/3] fix: allow explicit empty api_key to bypass request-time auth validation Client construction with api_key="" was fixed to not raise "Missing credentials", but _validate_headers still rejected the first actual request for bearer_auth endpoints since no Authorization header could be built from an empty key. This left the local-server use case (llama.cpp, LM Studio, vLLM, etc.) broken end-to-end. Track whether the caller passed a literal empty string (as opposed to a key provider, workload identity, or unset value) and skip the 'Could not resolve authentication method' error for such clients, except when the request specifically requires admin credentials that an empty api_key cannot satisfy. Thread the request's security requirements into _validate_headers to make that distinction, updating the AzureOpenAI overrides to match the new signature. Addresses review feedback from PR #3225. --- src/openai/_base_client.py | 3 ++- src/openai/_client.py | 38 ++++++++++++++++++++++++++++++++++++-- src/openai/lib/azure.py | 14 ++++++++++++-- tests/test_client.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 10d7b9f7ca..2008ce3c0a 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -468,7 +468,7 @@ def _custom_auth( def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers: custom_headers = options.headers or {} headers_dict = _merge_mappings({**self._auth_headers(options.security), **self.default_headers}, custom_headers) - self._validate_headers(headers_dict, custom_headers) + self._validate_headers(headers_dict, custom_headers, options.security) # headers are case-insensitive while dictionaries are not. headers = httpx.Headers(headers_dict) @@ -731,6 +731,7 @@ def _validate_headers( self, headers: Headers, # noqa: ARG002 custom_headers: Headers, # noqa: ARG002 + security: SecurityOptions | None = None, # noqa: ARG002 ) -> None: """Validate the given default headers and custom headers. diff --git a/src/openai/_client.py b/src/openai/_client.py index b64bee8b7f..28aa62a19d 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -198,6 +198,13 @@ def __init__( self.workload_identity = workload_identity if provider_runtime is None else None _api_key_explicitly_set = api_key is not None + # Tracks whether the caller explicitly passed a literal `api_key=""`, as opposed + # to it defaulting to an empty string because no credentials were configured, or + # a key provider/workload identity being used (which resolve the real key later). + # This is needed so that requests don't fail header validation below when the + # caller intentionally disabled authentication (e.g. for local, auth-less + # OpenAI-compatible servers). + self._api_key_explicitly_empty = api_key == "" if provider_runtime is not None: self.api_key = "" self._api_key_provider = None @@ -546,13 +553,23 @@ def default_headers(self) -> dict[str, str | Omit]: } @override - def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: + def _validate_headers( + self, headers: Headers, custom_headers: Headers, security: SecurityOptions | None = None + ) -> None: if self._provider_runtime is not None: return if _has_header(headers, "Authorization") or _has_omitted_header(custom_headers, "Authorization"): return + # An explicitly-passed `api_key=""` means the caller intentionally disabled + # authentication (e.g. for a local, auth-less OpenAI-compatible server), so + # don't fail requests just because no `Authorization` header could be built — + # unless the request specifically requires admin credentials, which an empty + # `api_key` cannot satisfy. + if self._api_key_explicitly_empty and not (security or {}).get("admin_api_key_auth", False): + return + raise TypeError( '"Could not resolve authentication method. Expected either api_key or admin_api_key to be set. Or for one of the `Authorization` or `Authorization` headers to be explicitly omitted"' ) @@ -806,6 +823,13 @@ def __init__( self.workload_identity = workload_identity if provider_runtime is None else None _api_key_explicitly_set = api_key is not None + # Tracks whether the caller explicitly passed a literal `api_key=""`, as opposed + # to it defaulting to an empty string because no credentials were configured, or + # a key provider/workload identity being used (which resolve the real key later). + # This is needed so that requests don't fail header validation below when the + # caller intentionally disabled authentication (e.g. for local, auth-less + # OpenAI-compatible servers). + self._api_key_explicitly_empty = api_key == "" if provider_runtime is not None: self.api_key = "" self._api_key_provider = None @@ -1157,13 +1181,23 @@ def default_headers(self) -> dict[str, str | Omit]: } @override - def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: + def _validate_headers( + self, headers: Headers, custom_headers: Headers, security: SecurityOptions | None = None + ) -> None: if self._provider_runtime is not None: return if _has_header(headers, "Authorization") or _has_omitted_header(custom_headers, "Authorization"): return + # An explicitly-passed `api_key=""` means the caller intentionally disabled + # authentication (e.g. for a local, auth-less OpenAI-compatible server), so + # don't fail requests just because no `Authorization` header could be built — + # unless the request specifically requires admin credentials, which an empty + # `api_key` cannot satisfy. + if self._api_key_explicitly_empty and not (security or {}).get("admin_api_key_auth", False): + return + raise TypeError( '"Could not resolve authentication method. Expected either api_key or admin_api_key to be set. Or for one of the `Authorization` or `Authorization` headers to be explicitly omitted"' ) diff --git a/src/openai/lib/azure.py b/src/openai/lib/azure.py index 4ebe0a98aa..e0cd9c1e54 100644 --- a/src/openai/lib/azure.py +++ b/src/openai/lib/azure.py @@ -363,7 +363,12 @@ def _auth_headers(self, security: SecurityOptions) -> dict[str, str]: # noqa: A return {} @override - def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: + def _validate_headers( + self, + headers: Headers, + custom_headers: Headers, + security: SecurityOptions | None = None, # noqa: ARG002 + ) -> None: if _has_auth_header(headers) or _has_auth_header(custom_headers): return @@ -689,7 +694,12 @@ def _auth_headers(self, security: SecurityOptions) -> dict[str, str]: # noqa: A return {} @override - def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None: + def _validate_headers( + self, + headers: Headers, + custom_headers: Headers, + security: SecurityOptions | None = None, # noqa: ARG002 + ) -> None: if _has_auth_header(headers) or _has_auth_header(custom_headers): return diff --git a/tests/test_client.py b/tests/test_client.py index 9005cedfe8..5543870bd5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -540,6 +540,24 @@ def test_validate_headers(self) -> None: ) assert client.api_key == "" + # Requests should also succeed, not just client construction: no `Authorization` + # header should be required or added when api_key was explicitly set to "". + request = client._build_request( + FinalRequestOptions(method="get", url="/foo", security={"bearer_auth": True}) + ) + assert "Authorization" not in request.headers + + # An explicit empty api_key should not bypass validation for endpoints that + # require credentials the client doesn't have (e.g. admin-only endpoints). + with pytest.raises(TypeError, match="Could not resolve authentication method"): + client._build_request( + FinalRequestOptions( + method="get", + url="/organization/projects", + security={"admin_api_key_auth": True}, + ) + ) + # 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( @@ -1880,6 +1898,24 @@ async def test_validate_headers(self) -> None: ) assert client.api_key == "" + # Requests should also succeed, not just client construction: no `Authorization` + # header should be required or added when api_key was explicitly set to "". + request = client._build_request( + FinalRequestOptions(method="get", url="/foo", security={"bearer_auth": True}) + ) + assert "Authorization" not in request.headers + + # An explicit empty api_key should not bypass validation for endpoints that + # require credentials the client doesn't have (e.g. admin-only endpoints). + with pytest.raises(TypeError, match="Could not resolve authentication method"): + client._build_request( + FinalRequestOptions( + method="get", + url="/organization/projects", + security={"admin_api_key_auth": True}, + ) + ) + # 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(