mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
7c1d9fa9ab
commit
a57a7ef0b1
6 changed files with 139 additions and 46 deletions
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue