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:
Sameer Kankute
2026-06-04 07:44:51 -07:00
committed by GitHub
co-authored by Cursor
parent 216c68db04
commit 20dc6dffa4
4 changed files with 108 additions and 147 deletions
@@ -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(