mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
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:
parent
5d5e3685c1
commit
d18efdfe13
5 changed files with 289 additions and 6 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue