diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index e7c12448435..c16c837e8e1 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -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 "" diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 9c5c545fe76..50a1516f489 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -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: diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index e85ccf150d2..bd30cadd7b5 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -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 diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index ca2e282a119..fab3ad2f7f6 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -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" diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 9bddeda0723..9450ff0d782 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -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 ) diff --git a/tests/test_litellm/proxy/utils/helpers/test_url_helpers.py b/tests/test_litellm/proxy/utils/helpers/test_url_helpers.py index 31ea1bdce74..a3dbdb80e33 100644 --- a/tests/test_litellm/proxy/utils/helpers/test_url_helpers.py +++ b/tests/test_litellm/proxy/utils/helpers/test_url_helpers.py @@ -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"