diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index e49e1302ab..6667010447 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -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 diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 41fc417d97..92bd791515 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -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="/", ), ): diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 197572216d..63b61954cf 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -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="/", ), ): 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 89c57bcced..9b9d5e22a4 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 @@ -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(