fix(openai): never send OPENAI_ADMIN_KEY as the bearer token for a deployment without an api_key (#50218)

* fix(openai): authenticate raw chat requests with the deployment api_key only, never OPENAI_ADMIN_KEY

openai 3.x request options default to bearer_auth plus admin_api_key_auth, so a deployment whose api_key is empty sent Authorization: Bearer <OPENAI_ADMIN_KEY> to its api_base. Pass security={"bearer_auth": True} when the installed SDK's make_request_options accepts it (openai 2.x has no admin key fallback and no security kwarg)

* test(openai): assert the admin key never reaches the wire whether the SDK sends unsigned or refuses

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Kerry Lu 2026-11-05 09:58:42 -08:00 • committed by GitHub
parent 88ea40e69c
commit f25fff42a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 55 additions and 0 deletions

View file

@ -1,3 +1,4 @@
import inspect
import time
import types
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator, Mapping, Sequence
@ -379,6 +380,11 @@ _EXTRA_HEADERS_ADAPTER: Final = TypeAdapter(dict[str, str] | None)
_EXTRA_QUERY_ADAPTER: Final = TypeAdapter(dict[str, object] | None)
_NO_EXTRA_HEADERS: Final[Mapping[str, str]] = types.MappingProxyType({})
_SDK_OPTION_KEYS: Final = frozenset(("extra_headers", "extra_query", "extra_body"))
_CLIENT_API_KEY_ONLY_AUTH: Final[Mapping[str, Mapping[str, bool]]] = (
types.MappingProxyType({"security": types.MappingProxyType({"bearer_auth": True})})
if "security" in inspect.signature(make_request_options).parameters
else types.MappingProxyType({})
)
def _raw_chat_response(
@ -398,6 +404,7 @@ def _request_without_sdk_transform(
extra_query=_EXTRA_QUERY_ADAPTER.validate_python(data.get("extra_query")),
extra_body=data.get("extra_body"),
timeout=timeout,
**_CLIENT_API_KEY_ONLY_AUTH, # pyright: ignore[reportArgumentType] # openai<3 make_request_options has no security kwarg
)
return body, options

View file

@ -1,6 +1,8 @@
import asyncio
import contextlib
import gzip
import importlib
import inspect
import json
import os
from collections.abc import Awaitable, Callable, Mapping, Sequence
@ -227,6 +229,52 @@ async def test_acompletion_returns_prompt_cache_details_over_injected_transport(
assert prompt_tokens_details.cached_tokens == 7
_SDK_READS_OPENAI_ADMIN_KEY: Final = "admin_api_key" in inspect.signature(AsyncOpenAI.__init__).parameters
@pytest.mark.skipif(not _SDK_READS_OPENAI_ADMIN_KEY, reason="openai<3 has no OPENAI_ADMIN_KEY fallback to leak")
@pytest.mark.asyncio
async def test_acompletion_never_sends_the_openai_admin_key_for_a_deployment_without_a_key(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_ADMIN_KEY", "sk-admin-only")
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.setattr(litellm, "openai_key", None)
outbound: Final = asyncio.Queue()
def respond(request: httpx.Request) -> httpx.Response:
outbound.put_nowait(request.headers.get("authorization"))
return httpx.Response(
200,
json={
"id": "chatcmpl-no-admin-key",
"object": "chat.completion",
"created": 0,
"model": "gpt-5.4-mini",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client:
monkeypatch.setattr(litellm, "aclient_session", http_client)
with contextlib.suppress(litellm.AuthenticationError, litellm.InternalServerError):
await asyncio.wait_for(
litellm.acompletion(
model="openai/gpt-5.4-mini",
api_key="",
messages=[{"role": "user", "content": "who signs this request"}],
num_retries=0,
max_retries=0,
),
timeout=10,
)
sent: Final = tuple(outbound.get_nowait() for _ in range(outbound.qsize()))
assert all(authorization is None for authorization in sent), sent
class _SearchingChatGate(CustomLogger):
def __init__(self) -> None: