mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-14 12:26:25 +00:00
fix(proxy): passthrough 404 when SERVER_ROOT_PATH is set (#29658)
* fix(proxy): match passthrough registry routes bare-to-bare with SERVER_ROOT_PATH After #28547, get_request_route strips the deployment prefix while registry lookup still re-inflated stored paths via SERVER_ROOT_PATH, causing 404s under paths like /llmproxy/ml. Compare normalized bare routes in both is_registered_pass_through_route and get_registered_pass_through_route. Co-authored-by: Cursor <cursoragent@cursor.com> * test(proxy): patch utils.get_server_root_path in passthrough auth tests After removing get_server_root_path from pass_through_endpoints, route and JWT tests must mock litellm.proxy.utils where normalization reads it. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -59,7 +59,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 get_server_root_path, normalize_route_for_root_path
|
||||
from litellm.proxy.utils import normalize_route_for_root_path
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
@@ -2499,20 +2499,16 @@ class InitPassThroughEndpointHelpers:
|
||||
return list(_registered_pass_through_routes.keys())
|
||||
|
||||
@staticmethod
|
||||
def _build_full_path_with_root(path: str) -> str:
|
||||
def _route_for_registry_lookup(route: str) -> str:
|
||||
"""
|
||||
Build full path by prepending server root path if needed.
|
||||
Normalize an incoming route to the bare path stored in the registry.
|
||||
|
||||
Args:
|
||||
path: The relative path to build
|
||||
|
||||
Returns:
|
||||
Full path with server root prepended (if root is not "/")
|
||||
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``.
|
||||
"""
|
||||
root_path = get_server_root_path()
|
||||
if root_path == "/":
|
||||
return path
|
||||
return f"{root_path}{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:
|
||||
@@ -2535,6 +2531,10 @@ class InitPassThroughEndpointHelpers:
|
||||
if normalized_route.startswith(mapped_route):
|
||||
return True
|
||||
|
||||
comparison_route = InitPassThroughEndpointHelpers._route_for_registry_lookup(
|
||||
route
|
||||
)
|
||||
|
||||
# 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}"
|
||||
# For backward compatibility, also support old format: "{endpoint_id}:exact:{path}" or "{endpoint_id}:subpath:{path}"
|
||||
@@ -2543,14 +2543,13 @@ class InitPassThroughEndpointHelpers:
|
||||
parts = key.split(":", 3) # Split into [endpoint_id, type, path, methods?]
|
||||
if len(parts) >= 3:
|
||||
route_type = parts[1]
|
||||
registered_path = (
|
||||
InitPassThroughEndpointHelpers._build_full_path_with_root(parts[2])
|
||||
)
|
||||
if route_type == "exact" and route == registered_path:
|
||||
registered_path = parts[2]
|
||||
if route_type == "exact" and comparison_route == registered_path:
|
||||
return True
|
||||
elif route_type == "subpath":
|
||||
if route == registered_path or route.startswith(
|
||||
registered_path + "/"
|
||||
if (
|
||||
comparison_route == registered_path
|
||||
or comparison_route.startswith(registered_path + "/")
|
||||
):
|
||||
return True
|
||||
|
||||
@@ -2561,13 +2560,14 @@ class InitPassThroughEndpointHelpers:
|
||||
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
|
||||
)
|
||||
for key in _registered_pass_through_routes.keys():
|
||||
parts = key.split(":", 3) # Split into [endpoint_id, type, path, methods?]
|
||||
if len(parts) >= 3:
|
||||
route_type = parts[1]
|
||||
registered_path = (
|
||||
InitPassThroughEndpointHelpers._build_full_path_with_root(parts[2])
|
||||
)
|
||||
registered_path = parts[2]
|
||||
|
||||
# Get the methods for this route. Prefer the registered metadata,
|
||||
# but keep supporting test fixtures / older registry entries that
|
||||
@@ -2581,11 +2581,12 @@ class InitPassThroughEndpointHelpers:
|
||||
|
||||
# Check if path matches
|
||||
path_matches = False
|
||||
if route_type == "exact" and route == registered_path:
|
||||
if route_type == "exact" and comparison_route == registered_path:
|
||||
path_matches = True
|
||||
elif route_type == "subpath":
|
||||
if route == registered_path or route.startswith(
|
||||
registered_path + "/"
|
||||
if (
|
||||
comparison_route == registered_path
|
||||
or comparison_route.startswith(registered_path + "/")
|
||||
):
|
||||
path_matches = True
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ async def test_find_team_with_model_access_uses_request_method_for_passthrough_a
|
||||
mock_registered_routes,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
|
||||
"litellm.proxy.utils.get_server_root_path",
|
||||
return_value="/",
|
||||
),
|
||||
):
|
||||
|
||||
@@ -733,7 +733,7 @@ def test_virtual_key_llm_api_routes_allows_registered_pass_through_endpoints():
|
||||
mock_registered_routes,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
|
||||
"litellm.proxy.utils.get_server_root_path",
|
||||
return_value="/",
|
||||
),
|
||||
):
|
||||
@@ -799,7 +799,7 @@ def test_virtual_key_llm_api_routes_allows_non_auth_enforced_pass_through_endpoi
|
||||
mock_registered_routes,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
|
||||
"litellm.proxy.utils.get_server_root_path",
|
||||
return_value="/",
|
||||
),
|
||||
):
|
||||
@@ -849,7 +849,7 @@ def test_virtual_key_llm_api_routes_denies_auth_pass_through_without_allowlist()
|
||||
mock_registered_routes,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
|
||||
"litellm.proxy.utils.get_server_root_path",
|
||||
return_value="/",
|
||||
),
|
||||
):
|
||||
@@ -893,7 +893,7 @@ def test_virtual_key_llm_api_routes_uses_method_specific_auth_setting():
|
||||
mock_registered_routes,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
|
||||
"litellm.proxy.utils.get_server_root_path",
|
||||
return_value="/",
|
||||
),
|
||||
):
|
||||
@@ -948,7 +948,7 @@ def test_non_proxy_admin_denies_auth_pass_through_without_allowlist():
|
||||
mock_registered_routes,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
|
||||
"litellm.proxy.utils.get_server_root_path",
|
||||
return_value="/",
|
||||
),
|
||||
):
|
||||
@@ -987,7 +987,7 @@ def test_non_proxy_admin_allows_auth_pass_through_with_team_allowlist():
|
||||
mock_registered_routes,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
|
||||
"litellm.proxy.utils.get_server_root_path",
|
||||
return_value="/",
|
||||
),
|
||||
):
|
||||
@@ -1021,7 +1021,7 @@ def test_virtual_key_without_llm_api_routes_cannot_access_pass_through():
|
||||
mock_registered_routes,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
|
||||
"litellm.proxy.utils.get_server_root_path",
|
||||
return_value="/",
|
||||
),
|
||||
):
|
||||
|
||||
@@ -1386,10 +1386,6 @@ async def test_create_pass_through_endpoint_auth_true_enforces_allowlist():
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
||||
registry,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
|
||||
return_value="/",
|
||||
),
|
||||
):
|
||||
mock_get_config.return_value = ConfigFieldInfo(
|
||||
field_name="pass_through_endpoints", field_value=[]
|
||||
@@ -1485,10 +1481,6 @@ async def test_update_pass_through_endpoint_auth_true_enforces_allowlist():
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
||||
registry,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
|
||||
return_value="/",
|
||||
),
|
||||
):
|
||||
mock_get_config.return_value = ConfigFieldInfo(
|
||||
field_name="pass_through_endpoints", field_value=existing_endpoints
|
||||
@@ -1570,10 +1562,6 @@ async def test_update_pass_through_endpoint_preserves_auth_false():
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
||||
registry,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
|
||||
return_value="/",
|
||||
),
|
||||
):
|
||||
mock_get_config.return_value = ConfigFieldInfo(
|
||||
field_name="pass_through_endpoints", field_value=existing_endpoints
|
||||
@@ -2866,70 +2854,10 @@ async def test_create_pass_through_route_no_custom_body_falls_back():
|
||||
assert call_kwargs["custom_body"] == request_parsed_body
|
||||
|
||||
|
||||
def test_build_full_path_with_root_default():
|
||||
"""
|
||||
Test _build_full_path_with_root with default root path (/)
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
InitPassThroughEndpointHelpers,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
|
||||
) as mock_get_root:
|
||||
# Test with default root path
|
||||
mock_get_root.return_value = "/"
|
||||
|
||||
result = InitPassThroughEndpointHelpers._build_full_path_with_root(
|
||||
"/api/v1/endpoint"
|
||||
)
|
||||
assert result == "/api/v1/endpoint"
|
||||
|
||||
|
||||
def test_build_full_path_with_root_custom():
|
||||
"""
|
||||
Test _build_full_path_with_root with custom root path
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
InitPassThroughEndpointHelpers,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
|
||||
) as mock_get_root:
|
||||
# Test with custom root path /proxy
|
||||
mock_get_root.return_value = "/proxy"
|
||||
|
||||
result = InitPassThroughEndpointHelpers._build_full_path_with_root(
|
||||
"/api/v1/endpoint"
|
||||
)
|
||||
assert result == "/proxy/api/v1/endpoint"
|
||||
|
||||
|
||||
def test_build_full_path_with_root_nested():
|
||||
"""
|
||||
Test _build_full_path_with_root with nested root path
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
InitPassThroughEndpointHelpers,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
|
||||
) as mock_get_root:
|
||||
# Test with nested root path /api/v2
|
||||
mock_get_root.return_value = "/api/v2"
|
||||
|
||||
result = InitPassThroughEndpointHelpers._build_full_path_with_root("/endpoint")
|
||||
assert result == "/api/v2/endpoint"
|
||||
|
||||
|
||||
def test_is_registered_pass_through_route_with_custom_root():
|
||||
"""
|
||||
Test is_registered_pass_through_route correctly handles server root path
|
||||
|
||||
When server has a custom root path like /proxy, the registered path
|
||||
should be constructed by prepending the root to match incoming routes.
|
||||
Registry stores bare paths; incoming routes may be bare (get_request_route)
|
||||
or prefixed (request.url.path). Both should resolve via normalization.
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
InitPassThroughEndpointHelpers,
|
||||
@@ -2948,32 +2876,13 @@ def test_is_registered_pass_through_route_with_custom_root():
|
||||
"headers": {},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
|
||||
) as mock_get_root:
|
||||
# Test with custom root path /proxy
|
||||
mock_get_root.return_value = "/proxy"
|
||||
|
||||
# Should match when request route includes the root path
|
||||
with patch("litellm.proxy.utils.get_server_root_path", return_value="/proxy"):
|
||||
assert (
|
||||
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
|
||||
"/proxy/api/endpoint"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# Should not match when request route doesn't include root path
|
||||
assert (
|
||||
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
|
||||
"/api/endpoint"
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
# Test with default root path
|
||||
mock_get_root.return_value = "/"
|
||||
|
||||
# Should match with default root
|
||||
assert (
|
||||
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
|
||||
"/api/endpoint"
|
||||
@@ -2981,7 +2890,13 @@ def test_is_registered_pass_through_route_with_custom_root():
|
||||
is True
|
||||
)
|
||||
|
||||
# Should not match with root prepended when root is /
|
||||
with patch("litellm.proxy.utils.get_server_root_path", return_value="/"):
|
||||
assert (
|
||||
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
|
||||
"/api/endpoint"
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
|
||||
"/proxy/api/endpoint"
|
||||
@@ -2995,10 +2910,8 @@ def test_is_registered_pass_through_route_with_custom_root():
|
||||
|
||||
def test_get_registered_pass_through_route_with_custom_root():
|
||||
"""
|
||||
Test get_registered_pass_through_route correctly handles server root path
|
||||
|
||||
When server has a custom root path, the method should return the correct
|
||||
endpoint configuration by matching the full path including the root.
|
||||
get_registered_pass_through_route matches bare registry paths against
|
||||
bare or SERVER_ROOT_PATH-prefixed incoming routes.
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
InitPassThroughEndpointHelpers,
|
||||
@@ -3019,13 +2932,8 @@ def test_get_registered_pass_through_route_with_custom_root():
|
||||
route_key = f"{endpoint_id}:exact:{path}"
|
||||
_registered_pass_through_routes[route_key] = target_config
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
|
||||
) as mock_get_root:
|
||||
# Test with custom root path /litellm
|
||||
mock_get_root.return_value = "/litellm"
|
||||
|
||||
# Should return config when request route includes root path
|
||||
with patch("litellm.proxy.utils.get_server_root_path", return_value="/litellm"):
|
||||
# Prefixed incoming route
|
||||
result = InitPassThroughEndpointHelpers.get_registered_pass_through_route(
|
||||
"/litellm/chat/completions"
|
||||
)
|
||||
@@ -3033,16 +2941,14 @@ def test_get_registered_pass_through_route_with_custom_root():
|
||||
assert result["target"] == "http://api.example.com/v1/chat/completions"
|
||||
assert result["headers"]["Authorization"] == "Bearer token123"
|
||||
|
||||
# Should return None when route doesn't match
|
||||
# Bare incoming route (get_request_route convention)
|
||||
result = InitPassThroughEndpointHelpers.get_registered_pass_through_route(
|
||||
"/chat/completions"
|
||||
)
|
||||
assert result is None
|
||||
assert result is not None
|
||||
assert result["target"] == "http://api.example.com/v1/chat/completions"
|
||||
|
||||
# Test with default root path
|
||||
mock_get_root.return_value = "/"
|
||||
|
||||
# Should return config with default root
|
||||
with patch("litellm.proxy.utils.get_server_root_path", return_value="/"):
|
||||
result = InitPassThroughEndpointHelpers.get_registered_pass_through_route(
|
||||
"/chat/completions"
|
||||
)
|
||||
@@ -3053,6 +2959,62 @@ def test_get_registered_pass_through_route_with_custom_root():
|
||||
_registered_pass_through_routes.clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"server_root_path,route_type,incoming_route,should_match",
|
||||
[
|
||||
("", "subpath", "/ml/api/v1/time-series-forecast/predict", True),
|
||||
("", "exact", "/ml", True),
|
||||
("", "exact", "/ml/extra", False),
|
||||
("/llmproxy", "subpath", "/ml/api/v1/time-series-forecast/predict", True),
|
||||
(
|
||||
"/llmproxy",
|
||||
"subpath",
|
||||
"/llmproxy/ml/api/v1/time-series-forecast/predict",
|
||||
True,
|
||||
),
|
||||
("/llmproxy", "exact", "/ml", True),
|
||||
("/llmproxy", "exact", "/llmproxy/ml", True),
|
||||
("/llmproxy", "subpath", "/other/api", False),
|
||||
],
|
||||
)
|
||||
def test_db_registered_pass_through_route_bare_path_convention(
|
||||
server_root_path, route_type, incoming_route, should_match
|
||||
):
|
||||
"""
|
||||
Regression: #28547 / SERVER_ROOT_PATH — registry stores bare /ml paths;
|
||||
get_request_route() supplies bare paths; prefixed url.path must still match.
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
InitPassThroughEndpointHelpers,
|
||||
_registered_pass_through_routes,
|
||||
)
|
||||
|
||||
_registered_pass_through_routes.clear()
|
||||
endpoint_id = "customer-ml"
|
||||
path = "/ml"
|
||||
route_key = f"{endpoint_id}:{route_type}:{path}:GET,POST"
|
||||
_registered_pass_through_routes[route_key] = {
|
||||
"endpoint_id": endpoint_id,
|
||||
"path": path,
|
||||
"type": route_type,
|
||||
"target": "https://example.com",
|
||||
"methods": ["GET", "POST"],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.utils.get_server_root_path",
|
||||
return_value=server_root_path,
|
||||
):
|
||||
assert (
|
||||
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
|
||||
incoming_route
|
||||
)
|
||||
is should_match
|
||||
)
|
||||
|
||||
_registered_pass_through_routes.clear()
|
||||
|
||||
|
||||
def test_mapped_pass_through_routes_with_server_root_path():
|
||||
"""
|
||||
Mapped passthrough routes (vertex_ai, bedrock, etc) should match
|
||||
@@ -3064,9 +3026,7 @@ def test_mapped_pass_through_routes_with_server_root_path():
|
||||
InitPassThroughEndpointHelpers,
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.utils.get_server_root_path") as mock_get_root:
|
||||
mock_get_root.return_value = "/litellm"
|
||||
|
||||
with patch("litellm.proxy.utils.get_server_root_path", return_value="/litellm"):
|
||||
# prefixed route should match mapped routes like /vertex_ai
|
||||
assert (
|
||||
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
|
||||
|
||||
Reference in New Issue
Block a user