fix(vllm): send the pass-through key as a bearer on both /vllm routes (#46861)

* fix(vllm): send the deployment api_key on /vllm passthrough calls

VLLMModelInfo.get_api_key always returned None, so /vllm/{endpoint} calls to a router
model reached the vLLM server with no auth header even when the deployment had an
api_key, and a server started with --api-key answered 401 (#23443).

Return the deployment key (or VLLM_API_KEY) and send it as Authorization: Bearer, the
header vllm serve --api-key checks. x-api-key was never actually sent before, since the
key was always None.

* fix(vllm): send the pass-through key as a bearer on both /vllm routes

vllm serve --api-key only reads Authorization: Bearer, so a keyed vLLM
answered 401 on both pass-through routes. Send the key as a bearer and
keep x-api-key for gateways that read it. Router models send their own
deployment key and never fall back to VLLM_API_KEY, which belongs to
the /vllm route's own server.

* fix(vllm): send only the bearer on router models and stop doubling /v1 on /vllm

Router models now send the same Authorization: Bearer header /v1/chat/completions sends for that deployment, without the x-api-key copy that route never sent. The VLLM_API_BASE route keeps x-api-key next to the bearer, as it sent it before. A router model whose api_base already ends in /v1 no longer gets /v1/v1 on /vllm/v1 paths.

* fix(vllm): send no placeholder key from keyless router models and stop doubling /v1 on the VLLM_API_BASE route

* fix(vllm): send VLLM_API_KEY when a router model with no api_base is forwarded to VLLM_API_BASE

* fix(vllm): keep a router model's own key on the VLLM_API_BASE server and resolve an oidc VLLM_API_KEY

* fix(vllm): treat an empty key as no key so pass-through sends no empty bearer

* fix(vllm): send no bearer for a Google oidc key and match VLLM_API_BASE across a trailing slash

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
alvcol 2026-10-30 16:57:48 +01:00 • committed by GitHub
parent 5d5e3685c1
commit d18efdfe13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 289 additions and 6 deletions

View file

@ -1,3 +1,4 @@
from collections.abc import Mapping
from typing import Final
import httpx
@ -10,6 +11,14 @@ from litellm.types.llms.openai import AllMessageValues
from litellm.utils import add_path_to_api_base
def endpoint_under_api_base(endpoint: str, api_base: str | None) -> str:
return (
endpoint.lstrip("/").removeprefix("v1/")
if api_base is not None and api_base.rstrip("/").endswith("/v1")
else endpoint
)
class VLLMError(BaseLLMException):
def __init__(
self,
@ -39,9 +48,14 @@ class VLLMModelInfo(BaseLLMModelInfo):
api_key: str | None = None,
api_base: str | None = None,
) -> dict:
if api_key is not None:
headers["x-api-key"] = api_key
return headers
upstream_key: Final = self.upstream_api_key(api_key, api_base)
return {**headers, **self.upstream_auth_headers(upstream_key)} if upstream_key else headers
def upstream_api_key(self, api_key: str | None, api_base: str | None) -> str | None:
return api_key
def upstream_auth_headers(self, api_key: str) -> Mapping[str, str]:
return {"Authorization": f"Bearer {api_key}", "x-api-key": api_key}
@staticmethod
def get_api_base(api_base: str | None = None) -> str | None:

View file

@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Final
from httpx import Response
from litellm.constants import PLACEHOLDER_API_KEY
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig
from litellm.llms.openai_like.passthrough.transformation import (
@ -12,8 +13,14 @@ from litellm.llms.openai_like.passthrough.transformation import (
logged_text_completion_stream,
relays_text_completion,
)
from litellm.secret_managers.main import (
GOOGLE_OIDC_SECRET_PREFIX,
UnresolvableSecretReference,
get_secret_str,
resolve_local_oidc_reference,
)
from ..common_utils import VLLMModelInfo
from ..common_utils import VLLMModelInfo, endpoint_under_api_base
if TYPE_CHECKING:
from httpx import URL
@ -22,6 +29,30 @@ if TYPE_CHECKING:
class VLLMPassthroughConfig(VLLMModelInfo, BasePassthroughConfig):
@staticmethod
def get_api_key(api_key: str | None = None) -> str | None:
return None if api_key == PLACEHOLDER_API_KEY else api_key
def upstream_api_key(self, api_key: str | None, api_base: str | None) -> str | None:
if api_key is not None or not self._is_vllm_api_base(api_base):
return api_key
vllm_api_key: Final = get_secret_str("VLLM_API_KEY")
if vllm_api_key is None or vllm_api_key.startswith(GOOGLE_OIDC_SECRET_PREFIX):
return None
match resolve_local_oidc_reference(vllm_api_key):
case UnresolvableSecretReference():
return None
case str() as resolved:
return resolved
@staticmethod
def _is_vllm_api_base(api_base: str | None) -> bool:
vllm_api_base: Final = get_secret_str("VLLM_API_BASE")
return api_base is not None and vllm_api_base is not None and api_base.rstrip("/") == vllm_api_base.rstrip("/")
def upstream_auth_headers(self, api_key: str) -> Mapping[str, str]:
return {"Authorization": f"Bearer {api_key}"}
def is_streaming_request(self, endpoint: str, request_data: dict) -> bool:
return "stream" in request_data
@ -40,7 +71,7 @@ class VLLMPassthroughConfig(VLLMModelInfo, BasePassthroughConfig):
raise Exception("VLLM api base not found")
return (
self.format_url(endpoint, base_target_url, request_query_params),
self.format_url(endpoint_under_api_base(endpoint, base_target_url), base_target_url, request_query_params),
base_target_url,
)

View file

@ -72,6 +72,7 @@ from litellm.llms.openai.common_utils import is_openai_agents_api_path, openai_a
from litellm.llms.openai.workload_identity import get_workload_identity_bearer_token_for_api_base
from litellm.llms.oss_decision import OssDecisionProvider, oss_connection, validate_oss_request
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
from litellm.llms.vllm.common_utils import endpoint_under_api_base
from litellm.passthrough.main import AsyncPassthroughStreamingResponse, is_provider_error
from litellm.proxy._types import *
from litellm.proxy.auth.auth_checks import enforced_model_allowlists
@ -600,7 +601,7 @@ async def vllm_proxy_route(
)
return await llm_passthrough_factory_proxy_route(
endpoint=endpoint,
endpoint=endpoint_under_api_base(endpoint, get_secret_str("VLLM_API_BASE")),
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,

View file

@ -51,3 +51,178 @@ async def test_allm_passthrough_route_hosted_vllm_sends_normalized_model(respx_m
outbound: Final = json.loads(route.calls[0].request.content)
assert outbound["model"] == "my-deployment"
assert outbound["messages"] == [{"role": "user", "content": "Hello"}]
@pytest.mark.asyncio
async def test_allm_passthrough_route_hosted_vllm_sends_deployment_api_key(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(f"{_API_BASE}/v1/chat/completions").mock(
return_value=httpx.Response(200, json={"ok": True})
)
response: Final = await allm_passthrough_route(
method="POST",
endpoint="v1/chat/completions",
model="hosted_vllm/my-deployment",
api_base=_API_BASE,
api_key="sk-upstream",
json={"model": "anything", "messages": [{"role": "user", "content": "Hello"}]},
client=AsyncHTTPHandler(),
)
assert response.status_code == 200
assert route.calls[0].request.headers["authorization"] == "Bearer sk-upstream"
assert "x-api-key" not in route.calls[0].request.headers
@pytest.mark.parametrize("endpoint", ["v1/chat/completions", "chat/completions"])
@pytest.mark.asyncio
async def test_allm_passthrough_route_hosted_vllm_reaches_v1_paths_under_an_api_base_ending_in_v1(
respx_mock: respx.MockRouter, endpoint: str
) -> None:
route: Final = respx_mock.post(f"{_API_BASE}/v1/chat/completions").mock(
return_value=httpx.Response(200, json={"ok": True})
)
response: Final = await allm_passthrough_route(
method="POST",
endpoint=endpoint,
model="hosted_vllm/my-deployment",
api_base=f"{_API_BASE}/v1",
api_key="sk-upstream",
json={"model": "anything", "messages": [{"role": "user", "content": "Hello"}]},
client=AsyncHTTPHandler(),
)
assert response.status_code == 200
assert route.call_count == 1
@pytest.mark.asyncio
async def test_allm_passthrough_route_hosted_vllm_sends_no_auth_for_a_keyless_deployment(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VLLM_API_KEY", "sk-vllm-route-only")
monkeypatch.delenv("HOSTED_VLLM_API_KEY", raising=False)
route: Final = respx_mock.post(f"{_API_BASE}/v1/chat/completions").mock(
return_value=httpx.Response(200, json={"ok": True})
)
response: Final = await allm_passthrough_route(
method="POST",
endpoint="v1/chat/completions",
model="hosted_vllm/my-deployment",
api_base=_API_BASE,
json={"model": "anything", "messages": [{"role": "user", "content": "Hello"}]},
client=AsyncHTTPHandler(),
)
assert response.status_code == 200
assert "authorization" not in route.calls[0].request.headers
assert "x-api-key" not in route.calls[0].request.headers
@pytest.mark.parametrize(
("vllm_api_key", "expected_authorization"),
[("sk-vllm-env", "Bearer sk-vllm-env"), ("oidc/env/VLLM_OIDC_TOKEN", "Bearer sk-vllm-oidc")],
)
@pytest.mark.asyncio
async def test_allm_passthrough_route_keyless_hosted_vllm_without_api_base_sends_the_vllm_api_base_key(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, vllm_api_key: str, expected_authorization: str
) -> None:
monkeypatch.setenv("VLLM_API_BASE", _API_BASE)
monkeypatch.setenv("VLLM_API_KEY", vllm_api_key)
monkeypatch.setenv("VLLM_OIDC_TOKEN", "sk-vllm-oidc")
monkeypatch.delenv("HOSTED_VLLM_API_BASE", raising=False)
monkeypatch.delenv("HOSTED_VLLM_API_KEY", raising=False)
route: Final = respx_mock.post(f"{_API_BASE}/v1/chat/completions").mock(
return_value=httpx.Response(200, json={"ok": True})
)
response: Final = await allm_passthrough_route(
method="POST",
endpoint="v1/chat/completions",
model="hosted_vllm/my-deployment",
json={"model": "anything", "messages": [{"role": "user", "content": "Hello"}]},
client=AsyncHTTPHandler(),
)
assert response.status_code == 200
assert route.calls[0].request.headers["authorization"] == expected_authorization
@pytest.mark.parametrize(
("model_api_base", "vllm_api_base"), [(f"{_API_BASE}/", _API_BASE), (_API_BASE, f"{_API_BASE}/")]
)
@pytest.mark.asyncio
async def test_allm_passthrough_route_keyless_hosted_vllm_matches_the_vllm_api_base_across_a_trailing_slash(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, model_api_base: str, vllm_api_base: str
) -> None:
monkeypatch.setenv("VLLM_API_BASE", vllm_api_base)
monkeypatch.setenv("VLLM_API_KEY", "sk-vllm-env")
monkeypatch.delenv("HOSTED_VLLM_API_BASE", raising=False)
monkeypatch.delenv("HOSTED_VLLM_API_KEY", raising=False)
route: Final = respx_mock.post(url__regex=rf"^{_API_BASE}/+v1/chat/completions$").mock(
return_value=httpx.Response(200, json={"ok": True})
)
response: Final = await allm_passthrough_route(
method="POST",
endpoint="v1/chat/completions",
model="hosted_vllm/my-deployment",
api_base=model_api_base,
json={"model": "anything", "messages": [{"role": "user", "content": "Hello"}]},
client=AsyncHTTPHandler(),
)
assert response.status_code == 200
assert route.calls[0].request.headers["authorization"] == "Bearer sk-vllm-env"
@pytest.mark.parametrize("vllm_api_key", ["", "oidc/google/audience"])
@pytest.mark.asyncio
async def test_allm_passthrough_route_unusable_vllm_api_key_sends_no_authorization_header(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, vllm_api_key: str
) -> None:
monkeypatch.setenv("VLLM_API_BASE", _API_BASE)
monkeypatch.setenv("VLLM_API_KEY", vllm_api_key)
monkeypatch.delenv("HOSTED_VLLM_API_BASE", raising=False)
monkeypatch.delenv("HOSTED_VLLM_API_KEY", raising=False)
route: Final = respx_mock.post(f"{_API_BASE}/v1/chat/completions").mock(
return_value=httpx.Response(200, json={"ok": True})
)
response: Final = await allm_passthrough_route(
method="POST",
endpoint="v1/chat/completions",
model="hosted_vllm/my-deployment",
json={"model": "anything", "messages": [{"role": "user", "content": "Hello"}]},
client=AsyncHTTPHandler(),
)
assert response.status_code == 200
assert "authorization" not in route.calls[0].request.headers
@pytest.mark.parametrize(
("api_base", "api_key", "expected_authorization"),
[
(None, "sk-upstream", "Bearer sk-upstream"),
(None, "sk-hosted-env", "Bearer sk-hosted-env"),
(_API_BASE, "sk-hosted-env", "Bearer sk-hosted-env"),
(None, None, "Bearer sk-hosted-env"),
],
)
@pytest.mark.asyncio
async def test_allm_passthrough_route_hosted_vllm_on_the_vllm_api_base_sends_its_own_resolved_key(
respx_mock: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
api_base: str | None,
api_key: str | None,
expected_authorization: str,
) -> None:
monkeypatch.setenv("VLLM_API_BASE", _API_BASE)
monkeypatch.setenv("VLLM_API_KEY", "sk-vllm-env")
monkeypatch.delenv("HOSTED_VLLM_API_BASE", raising=False)
monkeypatch.setenv("HOSTED_VLLM_API_KEY", "sk-hosted-env")
route: Final = respx_mock.post(f"{_API_BASE}/v1/chat/completions").mock(
return_value=httpx.Response(200, json={"ok": True})
)
response: Final = await allm_passthrough_route(
method="POST",
endpoint="v1/chat/completions",
model="hosted_vllm/my-deployment",
api_base=api_base,
api_key=api_key,
json={"model": "anything", "messages": [{"role": "user", "content": "Hello"}]},
client=AsyncHTTPHandler(),
)
assert response.status_code == 200
assert route.calls[0].request.headers["authorization"] == expected_authorization

View file

@ -2759,6 +2759,68 @@ class TestVLLMProxyRoute:
mock_factory_route.assert_awaited_once()
@pytest.mark.parametrize(
("vllm_api_base", "request_path"),
[
("http://vllm-upstream.test:8090", "/vllm/v1/chat/completions"),
("http://vllm-upstream.test:8090/v1", "/vllm/v1/chat/completions"),
("http://vllm-upstream.test:8090/v1", "/vllm/chat/completions"),
],
)
def test_vllm_provider_route_reaches_v1_paths_with_its_key_as_a_bearer(
monkeypatch: pytest.MonkeyPatch, vllm_api_base: str, request_path: str
) -> None:
from litellm.proxy import proxy_server
monkeypatch.setenv("VLLM_API_BASE", vllm_api_base)
monkeypatch.setenv("VLLM_API_KEY", "sk-vllm-upstream")
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.in_memory_llm_clients_cache.flush_cache()
monkeypatch.setitem(
proxy_server.app.dependency_overrides, user_api_key_auth, lambda: UserAPIKeyAuth(api_key="sk-virtual")
)
with respx.mock(assert_all_called=True) as upstream:
route: Final = upstream.post("http://vllm-upstream.test:8090/v1/chat/completions").mock(
return_value=httpx.Response(200, json={"id": "chatcmpl-1"})
)
response: Final = TestClient(proxy_server.app).post(
request_path,
json={"model": "Qwen/Qwen3-0.6B", "messages": [{"role": "user", "content": "hi"}]},
)
assert (response.status_code, response.json()) == (200, {"id": "chatcmpl-1"})
assert route.calls.last.request.headers["authorization"] == "Bearer sk-vllm-upstream"
assert route.calls.last.request.headers["x-api-key"] == "sk-vllm-upstream"
def test_vllm_provider_route_with_an_empty_key_sends_no_credential_headers(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy import proxy_server
monkeypatch.setenv("VLLM_API_BASE", "http://vllm-upstream.test:8090")
monkeypatch.setenv("VLLM_API_KEY", "")
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.in_memory_llm_clients_cache.flush_cache()
monkeypatch.setitem(
proxy_server.app.dependency_overrides, user_api_key_auth, lambda: UserAPIKeyAuth(api_key="sk-virtual")
)
with respx.mock(assert_all_called=True) as upstream:
route: Final = upstream.post("http://vllm-upstream.test:8090/v1/chat/completions").mock(
return_value=httpx.Response(200, json={"id": "chatcmpl-1"})
)
response: Final = TestClient(proxy_server.app).post(
"/vllm/v1/chat/completions",
json={"model": "Qwen/Qwen3-0.6B", "messages": [{"role": "user", "content": "hi"}]},
)
assert (response.status_code, response.json()) == (200, {"id": "chatcmpl-1"})
assert "authorization" not in route.calls.last.request.headers
assert "x-api-key" not in route.calls.last.request.headers
class TestGigachatProxyRoute:
@pytest.mark.asyncio
@patch( # test-quality-ok: patching litellm internal for unit test isolation