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
3 changes: 2 additions & 1 deletion src/openai/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down
42 changes: 40 additions & 2 deletions src/openai/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ 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 == ""

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 Preserve empty api_key outside direct construction

This new explicit-empty state is only captured when the constructor receives api_key="", but other supported configuration paths do not preserve that intent: copy()/with_options() still uses api_key or self._api_key_provider or self.api_key, so client.with_options(api_key="", base_url=local_url) inherits and sends the previous non-empty key instead of disabling auth, and an already-loaded module client whose openai.api_key is later set to "" never updates this flag and still fails bearer-auth request validation. Please thread the explicit-empty state through these reconfiguration paths so local auth-less servers work consistently.

Useful? React with 👍 / 👎.

if provider_runtime is not None:
self.api_key = ""
self._api_key_provider = None
Expand Down Expand Up @@ -224,6 +232,7 @@ def __init__(
provider_runtime is None
and _enforce_credentials
and not self.api_key
and not _api_key_explicitly_set

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 Carry the explicit empty api_key through request auth

When a caller explicitly passes api_key="" for an auth-less local server, this constructor check now allows the client to be created, but the first normal bearer-auth request still fails: _bearer_auth/auth_headers return no Authorization header for an empty key, and _build_headers() then calls _validate_headers(), which raises because no auth header was resolved. The same pattern exists in AsyncOpenAI, so the new path only passes construction tests and still cannot make typical SDK calls unless users manually omit or provide an Authorization header.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — confirmed the empty api_key was still rejected at request time. Fixed in 3214527: added _api_key_explicitly_empty tracking (limited to a literal explicitly-passed api_key="", not key-providers or workload identity) and skip the auth-header validation for that case in _validate_headers, unless the request specifically requires admin_api_key_auth (which an empty api_key still can't satisfy). Had to thread security into _validate_headers (and update the AzureOpenAI overrides) to make that distinction precisely. Added regression tests that build actual requests (not just construct the client) for both the bearer_auth-succeeds and admin_api_key_auth-still-raises cases.

and self._api_key_provider is None
and workload_identity is None
and self.admin_api_key is None
Expand Down Expand Up @@ -544,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):

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 Allow empty api_key on raw client requests

When api_key="" is used with the public raw helpers (client.get("/foo", ...), post, etc.) and no options["security"] override is supplied, FinalRequestOptions still has its default admin_api_key_auth=True. This check therefore treats the request as admin-only and raises before it reaches an auth-less local/custom endpoint, even though generated endpoints pass security={"bearer_auth": True}. The fresh evidence beyond the earlier auth-header finding is that the raw helper path constructs options without a security override, so the default admin flag still blocks the new empty-key mode.

Useful? React with 👍 / 👎.

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"'
)
Expand Down Expand Up @@ -803,6 +822,14 @@ 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
Expand Down Expand Up @@ -830,6 +857,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
Expand Down Expand Up @@ -1153,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"'
)
Expand Down
14 changes: 12 additions & 2 deletions src/openai/lib/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down
90 changes: 90 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,51 @@ 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 == ""

# 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(
**{
"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}))
Expand Down Expand Up @@ -1837,6 +1882,51 @@ 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 == ""

# 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(
**{
"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}))
Expand Down