fix(proxy): restore provider pass-through routes under SERVER_ROOT_PATH

get_request_route() already strips scope["root_path"], so the route handed
to the mapped-pass-through allowlist checks never carries the SERVER_ROOT_PATH
prefix. normalize_route_for_root_path() stripped a second time and returned
None for those bare routes, which made both callers skip the whole
mapped_pass_through_routes allowlist: every provider pass-through
(/anthropic, /bedrock, /vertex_ai, /gemini, /cohere, ...) 404'd, and the
litellm_user_api_key header swap stopped firing.

Replace it with strip_server_root_path(), which removes the prefix when the
route carries it and returns the route untouched otherwise, so both the bare
and the prefixed shape resolve.
This commit is contained in:
Yuneng Jiang 2026-07-25 15:09:03 -07:00
parent 7c1d9fa9ab
commit a57a7ef0b1
No known key found for this signature in database
6 changed files with 139 additions and 46 deletions

View file

@ -90,7 +90,7 @@ from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.utils import (
PrismaClient,
ProxyLogging,
normalize_route_for_root_path,
strip_server_root_path,
)
from litellm.repositories.table_repositories import TeamMembershipRepository
from litellm.secret_managers.main import get_secret_bool
@ -630,12 +630,11 @@ async def check_api_key_for_custom_headers_or_pass_through_endpoints(
api_key: str,
) -> Union[UserAPIKeyAuth, str]:
is_mapped_pass_through_route: bool = False
normalized_route = normalize_route_for_root_path(route)
if normalized_route is not None:
for mapped_route in LiteLLMRoutes.mapped_pass_through_routes.value: # type: ignore
if normalized_route.startswith(mapped_route):
is_mapped_pass_through_route = True
break
normalized_route = strip_server_root_path(route)
for mapped_route in LiteLLMRoutes.mapped_pass_through_routes.value: # type: ignore
if normalized_route.startswith(mapped_route):
is_mapped_pass_through_route = True
break
if is_mapped_pass_through_route:
if request.headers.get("litellm_user_api_key") is not None:
api_key = request.headers.get("litellm_user_api_key") or ""

View file

@ -66,7 +66,7 @@ from litellm.proxy.common_utils.http_parsing_utils import (
_safe_get_request_headers,
)
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.utils import normalize_route_for_root_path
from litellm.proxy.utils import strip_server_root_path
from litellm.repositories.team_repository import TeamRepository
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.custom_http import httpxSpecialProvider
@ -2647,18 +2647,6 @@ class InitPassThroughEndpointHelpers:
"""Get all registered pass-through endpoints from the registry"""
return list(_registered_pass_through_routes.keys())
@staticmethod
def _route_for_registry_lookup(route: str) -> str:
"""
Normalize an incoming route to the bare path stored in the registry.
Registry keys store root-stripped paths. Callers should pass routes from
``get_request_route()`` (already stripped); prefixed ``request.url.path``
values are stripped via ``normalize_route_for_root_path``.
"""
normalized_route = normalize_route_for_root_path(route)
return normalized_route if normalized_route is not None else route
@staticmethod
def is_registered_pass_through_route(route: str) -> bool:
"""
@ -2673,14 +2661,12 @@ class InitPassThroughEndpointHelpers:
Returns:
bool: True if route is a registered pass-through endpoint, False otherwise
"""
## CHECK IF MAPPED PASS THROUGH ENDPOINT
normalized_route = normalize_route_for_root_path(route)
if normalized_route is not None:
for mapped_route in LiteLLMRoutes.mapped_pass_through_routes.value:
if normalized_route.startswith(mapped_route):
return True
comparison_route = strip_server_root_path(route)
comparison_route = InitPassThroughEndpointHelpers._route_for_registry_lookup(route)
## CHECK IF MAPPED PASS THROUGH ENDPOINT
for mapped_route in LiteLLMRoutes.mapped_pass_through_routes.value:
if comparison_route.startswith(mapped_route):
return True
# Fast path: check if any registered route key contains this path
# Keys are in format: "{endpoint_id}:exact:{path}:{methods}" or "{endpoint_id}:subpath:{path}:{methods}"
@ -2702,7 +2688,7 @@ class InitPassThroughEndpointHelpers:
@staticmethod
def get_registered_pass_through_route(route: str, method: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get passthrough params for a given route and optionally filter by HTTP method"""
comparison_route = InitPassThroughEndpointHelpers._route_for_registry_lookup(route)
comparison_route = strip_server_root_path(route)
for key in _registered_pass_through_routes.keys():
parts = key.split(":", 3) # Split into [endpoint_id, type, path, methods?]
if len(parts) >= 3:

View file

@ -6143,13 +6143,16 @@ def get_server_root_path() -> str:
return os.getenv("SERVER_ROOT_PATH", "")
def normalize_route_for_root_path(route: str) -> Optional[str]:
"""Strip SERVER_ROOT_PATH prefix. Returns de-prefixed route, or None if route is not under root path."""
root_path = get_server_root_path()
if root_path and root_path != "/":
if route.startswith(root_path + "/"):
return route[len(root_path) :]
return None
def strip_server_root_path(route: str) -> str:
"""
Return ``route`` with the SERVER_ROOT_PATH prefix removed.
Routes that do not carry the prefix are returned unchanged: ``get_request_route()``
already strips ``scope["root_path"]``, so most callers hand over a bare route.
"""
root_path = get_server_root_path().rstrip("/")
if root_path and route.startswith(root_path + "/"):
return route[len(root_path) :]
return route

View file

@ -41,6 +41,7 @@ from litellm.proxy.auth.user_api_key_auth import (
_run_centralized_common_checks,
_run_post_custom_auth_checks,
_user_api_key_auth_builder,
check_api_key_for_custom_headers_or_pass_through_endpoints,
get_api_key,
user_api_key_auth,
)
@ -5192,3 +5193,55 @@ async def test_temp_budget_increase_applied_for_cached_key():
cached_after = await user_api_key_cache.async_get_cache(key=hashed_token)
assert cached_after.max_budget == 2.0
@pytest.mark.asyncio
@pytest.mark.parametrize(
"server_root_path,route",
[
("", "/anthropic/v1/messages"),
("/", "/anthropic/v1/messages"),
("/api/v1", "/anthropic/v1/messages"),
("/api/v1", "/api/v1/anthropic/v1/messages"),
("", "/vertex_ai/v1/projects/foo"),
("/api/v1", "/vertex_ai/v1/projects/foo"),
("/api/v1", "/api/v1/vertex_ai/v1/projects/foo"),
],
)
async def test_mapped_pass_through_route_honors_litellm_user_api_key_header(
monkeypatch, server_root_path, route
):
"""
Mapped pass-through routes swap in the ``litellm_user_api_key`` header. The
route arrives from ``get_request_route()`` with SERVER_ROOT_PATH already
stripped, so the allowlist has to match the bare route too.
"""
monkeypatch.setenv("SERVER_ROOT_PATH", server_root_path)
mock_request = MagicMock()
mock_request.headers = {"litellm_user_api_key": "sk-from-header"}
result = await check_api_key_for_custom_headers_or_pass_through_endpoints(
request=mock_request,
route=route,
pass_through_endpoints=None,
api_key="sk-original",
)
assert result == "sk-from-header"
@pytest.mark.asyncio
@pytest.mark.parametrize("server_root_path", ["", "/", "/api/v1"])
async def test_non_mapped_route_keeps_original_api_key(monkeypatch, server_root_path):
monkeypatch.setenv("SERVER_ROOT_PATH", server_root_path)
mock_request = MagicMock()
mock_request.headers = {"litellm_user_api_key": "sk-from-header"}
result = await check_api_key_for_custom_headers_or_pass_through_endpoints(
request=mock_request,
route="/not_a_provider/v1/messages",
pass_through_endpoints=None,
api_key="sk-original",
)
assert result == "sk-original"

View file

@ -3533,10 +3533,56 @@ def test_mapped_pass_through_routes_with_server_root_path():
is True
)
# bare route without prefix should not match when root is set
@pytest.mark.parametrize(
"server_root_path",
["", "/", "/api/v1"],
)
@pytest.mark.parametrize(
"bare_route",
[
"/anthropic/v1/messages",
"/bedrock/model/anthropic.claude-3-5-sonnet-20240620-v1:0/invoke",
"/vertex_ai/v1/projects/foo/locations/us-central1/publishers/google/models/gemini-2.5-pro:generateContent",
"/gemini/v1beta/models/gemini-2.5-pro:generateContent",
"/cohere/v1/chat",
],
)
def test_mapped_pass_through_routes_match_bare_route_under_root_path(
server_root_path, bare_route
):
"""
``create_pass_through_route``'s handler resolves the route with
``get_request_route()``, which has already stripped ``scope["root_path"]``.
The mapped-route allowlist has to match that bare route whether or not
SERVER_ROOT_PATH is set, otherwise every provider pass-through 404s.
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
_registered_pass_through_routes,
)
_registered_pass_through_routes.clear()
with patch(
"litellm.proxy.utils.get_server_root_path", return_value=server_root_path
):
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(bare_route)
is True
)
prefixed_route = (
f"{server_root_path}{bare_route}" if server_root_path not in ("", "/") else bare_route
)
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/vertex_ai/v1/projects/foo"
prefixed_route
)
is True
)
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/not_a_provider/v1/messages"
)
is False
)

View file

@ -8,7 +8,7 @@ from litellm.proxy.utils import (
get_proxy_base_url,
get_server_root_path,
join_paths,
normalize_route_for_root_path,
strip_server_root_path,
)
@ -279,11 +279,11 @@ def test_get_custom_url_error_path_invalid_base_raises(monkeypatch):
get_custom_url(None, "/v1/chat")
def test_normalize_route_for_root_path_strips_prefix(monkeypatch):
def test_strip_server_root_path_strips_prefix(monkeypatch):
_clear_url_env(monkeypatch)
monkeypatch.setenv("SERVER_ROOT_PATH", "/proxy")
summary = {
"result": normalize_route_for_root_path("/proxy/v1/chat"),
"result": strip_server_root_path("/proxy/v1/chat"),
"root_path": "/proxy",
"input": "/proxy/v1/chat",
}
@ -294,10 +294,10 @@ def test_normalize_route_for_root_path_strips_prefix(monkeypatch):
}
def test_normalize_route_for_root_path_returns_route_when_no_root(monkeypatch):
def test_strip_server_root_path_returns_route_when_no_root(monkeypatch):
_clear_url_env(monkeypatch)
summary = {
"result": normalize_route_for_root_path("/v1/chat"),
"result": strip_server_root_path("/v1/chat"),
"root_path": "",
"input": "/v1/chat",
}
@ -308,9 +308,15 @@ def test_normalize_route_for_root_path_returns_route_when_no_root(monkeypatch):
}
def test_normalize_route_for_root_path_error_path_when_route_not_under_root(
monkeypatch,
@pytest.mark.parametrize("server_root_path", ["/proxy", "/proxy/"])
def test_strip_server_root_path_returns_route_when_already_stripped(
monkeypatch, server_root_path
):
"""
``get_request_route()`` already strips ``scope["root_path"]``, so the common
input here carries no prefix and must be handed back untouched.
"""
_clear_url_env(monkeypatch)
monkeypatch.setenv("SERVER_ROOT_PATH", "/proxy")
assert normalize_route_for_root_path("/other/v1/chat") is None
monkeypatch.setenv("SERVER_ROOT_PATH", server_root_path)
assert strip_server_root_path("/other/v1/chat") == "/other/v1/chat"
assert strip_server_root_path("/proxy/v1/chat") == "/v1/chat"