From 26bcafccb0110412a6a4a0c22e62328eb7f53fb0 Mon Sep 17 00:00:00 2001 From: IdoPort Date: Sun, 23 Aug 2026 13:02:37 +0300 Subject: [PATCH 1/4] fix(proxy): stop /{provider}/v1/files and /v1/batches from shadowing custom pass_through_endpoints Custom pass_through_endpoints entries from config.yaml are registered during proxy startup, strictly after every built-in router (including the generic /{provider}/v1/files and /v1/batches routes) is mounted at module-import time. Since they're always appended to app.routes, the generic native-provider routes always match first regardless of the configured prefix, misinterpreting it as a provider name. SafeRouteAdder now repositions a newly-added route immediately before the first route whose path template contains "{provider}" -- the shared marker for every such generic route, present and future -- so a custom pass-through path always wins the match instead. Fixes #37925 --- .../pass_through_endpoints.py | 32 ++++++++++++++++ .../test_llm_pass_through_endpoints.py | 38 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 1915a853983..25a5f64f24e 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -2561,6 +2561,37 @@ def _is_path_registered(app: FastAPI, path: str, methods: list[str]) -> bool: return True return False + # Every generic native-provider route (files, batches, and any future ones) is + # registered as "/{provider}/v1/...", so this literal path-parameter name is a + # reliable, future-proof marker -- no need to enumerate specific provider routes. + _GENERIC_PROVIDER_PATH_MARKER: Final = "{provider}" + + @staticmethod + def _move_before_generic_provider_routes(app: FastAPI) -> None: + """ + Custom pass-through routes registered from config.yaml are always appended to + app.routes, since they're added during proxy startup, strictly after every + built-in router (including the generic "/{provider}/v1/files" and + "/{provider}/v1/batches" routes) is mounted at module-import time. Starlette + resolves overlapping path templates by registration order, so an appended + custom route can never win against those generic routes -- they always match + first and misinterpret the custom prefix as a provider name (see + https://github.com/BerriAI/litellm/issues/37925). + + Move the just-appended route (the last item in app.routes) to sit immediately + before the first such generic route, so it is matched first instead. If no + generic provider route is registered (e.g. a minimal deployment), leave the + route appended -- current behavior is preserved as a safe fallback. + """ + routes = app.routes + new_route = routes[-1] + for index, route in enumerate(routes[:-1]): + route_path = getattr(route, "path", None) + if route_path and SafeRouteAdder._GENERIC_PROVIDER_PATH_MARKER in route_path: + routes.pop() + routes.insert(index, new_route) + return + @staticmethod def add_api_route_if_not_exists( app: FastAPI, @@ -2596,6 +2627,7 @@ def add_api_route_if_not_exists( methods=methods, dependencies=dependencies, ) + SafeRouteAdder._move_before_generic_provider_routes(app=app) verbose_proxy_logger.debug( "Successfully added route: %s with methods %s", path, diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index ac140abe31f..761f0218fce 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3066,6 +3066,44 @@ def test_native_provider_routes_are_unchanged(method, path, expected_name): assert _resolve_route_name(method, path) == expected_name +def test_custom_pass_through_endpoint_prefix_wins_over_native_provider_routes(): + """ + A pass_through_endpoints entry registered under an arbitrary, non-built-in + prefix (e.g. a self-hosted Anthropic-compatible endpoint reached via a + "/claude-aws" prefix) must win over the native /{provider}/v1/files and + /{provider}/v1/batches routes, which would otherwise misinterpret the + custom prefix as a provider name and 422/500 instead of forwarding + (see https://github.com/BerriAI/litellm/issues/37925). + """ + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + InitPassThroughEndpointHelpers, + ) + from litellm.proxy.proxy_server import app + + for suffix in ("files", "batches"): + InitPassThroughEndpointHelpers.add_exact_path_route( + app=app, + path=f"/claude-aws/v1/{suffix}", + target=f"https://example.com/v1/{suffix}", + custom_headers=None, + forward_headers=False, + merge_query_params=False, + dependencies=None, + cost_per_request=None, + endpoint_id=f"test-claude-aws-{suffix}", + ) + + assert _resolve_route_name("POST", "/claude-aws/v1/files") == "endpoint_func" + assert _resolve_route_name("POST", "/claude-aws/v1/batches") == "endpoint_func" + + # registering a custom prefix must not disturb resolution of unrelated, + # already-registered native-provider routes + assert _resolve_route_name("POST", "/openai/v1/files") == "create_file" + assert _resolve_route_name("GET", "/azure/v1/files") == "list_files" + assert _resolve_route_name("POST", "/v1/files") == "create_file" + assert _resolve_route_name("POST", "/v1/batches") == "create_batch" + + class TestCursorProxyRoute: """Tests for the Cursor Cloud Agents pass-through route.""" From a099e8061830969e28e84a0c50d34cb638ad0848 Mon Sep 17 00:00:00 2001 From: IdoPort Date: Sun, 23 Aug 2026 13:12:40 +0300 Subject: [PATCH 2/4] refactor(proxy): rebuild app.routes in one expression instead of pop()/insert() Addresses Greptile review feedback on #38017: avoid in-place mutation of the shared FastAPI route list. Builds the reordered route list via unpacking and reassigns app.router.routes wholesale, rather than popping the appended route and inserting it back in place. --- .../proxy/pass_through_endpoints/pass_through_endpoints.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 25a5f64f24e..dccc67ce47d 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -2582,14 +2582,16 @@ def _move_before_generic_provider_routes(app: FastAPI) -> None: before the first such generic route, so it is matched first instead. If no generic provider route is registered (e.g. a minimal deployment), leave the route appended -- current behavior is preserved as a safe fallback. + + Builds the reordered list in one expression and reassigns app.router.routes + wholesale, rather than mutating the existing list in place with pop()/insert(). """ routes = app.routes new_route = routes[-1] for index, route in enumerate(routes[:-1]): route_path = getattr(route, "path", None) if route_path and SafeRouteAdder._GENERIC_PROVIDER_PATH_MARKER in route_path: - routes.pop() - routes.insert(index, new_route) + app.router.routes = [*routes[:index], new_route, *routes[index:-1]] return @staticmethod From 6f2b7bd887f7bdda2b2f005d29ec49e31d8c1164 Mon Sep 17 00:00:00 2001 From: IdoPort Date: Sun, 23 Aug 2026 13:16:04 +0300 Subject: [PATCH 3/4] test(proxy): cover the no-generic-route fallback in _move_before_generic_provider_routes Addresses Codecov patch-coverage gap on #38017: the documented safe no-op when no "/{provider}/..." route exists (e.g. a minimal deployment) was previously untested, since the real production app always has one registered. --- .../test_llm_pass_through_endpoints.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 761f0218fce..113f2061ca9 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3104,6 +3104,37 @@ def test_custom_pass_through_endpoint_prefix_wins_over_native_provider_routes(): assert _resolve_route_name("POST", "/v1/batches") == "create_batch" +def test_move_before_generic_provider_routes_is_a_no_op_without_a_generic_route(): + """ + If no generic "/{provider}/..." route is registered on the app (e.g. a minimal + deployment without the files/batches routers mounted), the newly-appended custom + route is left exactly where it was appended -- a safe no-op fallback. + """ + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + SafeRouteAdder, + ) + + class _FakeRoute: + def __init__(self, path): + self.path = path + + class _FakeRouter: + def __init__(self, routes): + self.routes = routes + + class _FakeApp: + def __init__(self, routes): + self.routes = routes + self.router = _FakeRouter(routes) + + routes = [_FakeRoute("/health"), _FakeRoute("/claude-aws/v1/files")] + app = _FakeApp(routes) + + SafeRouteAdder._move_before_generic_provider_routes(app=app) + + assert app.router.routes == routes + + class TestCursorProxyRoute: """Tests for the Cursor Cloud Agents pass-through route.""" From 726a343bc4bd3e42e2424e7dd61cd6735ebfec7d Mon Sep 17 00:00:00 2001 From: IdoPort Date: Sun, 23 Aug 2026 13:27:46 +0300 Subject: [PATCH 4/4] fix(proxy): satisfy the type-discipline gate for the route-reordering fix - routes/new_route: Final, closing the LIT010 rebind-openness gap - # mutable-ok / # rebind-ok on the app.router.routes reassignment: it necessarily constructs a list literal and mutates state reachable from the app parameter, since Starlette's own Router.routes must stay a real, appendable list for the framework's own route registration to keep working -- an immutable rewrite is not possible here, per CLAUDE.md's own last-resort carve-out for this case CI failure: LIT002 total exceeded budget by the 1 new violation this PR added (https://github.com/BerriAI/litellm/pull/38017/checks). --- .../pass_through_endpoints/pass_through_endpoints.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index dccc67ce47d..b1fd6492286 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -2586,12 +2586,16 @@ def _move_before_generic_provider_routes(app: FastAPI) -> None: Builds the reordered list in one expression and reassigns app.router.routes wholesale, rather than mutating the existing list in place with pop()/insert(). """ - routes = app.routes - new_route = routes[-1] + routes: Final = app.routes + new_route: Final = routes[-1] for index, route in enumerate(routes[:-1]): route_path = getattr(route, "path", None) if route_path and SafeRouteAdder._GENERIC_PROVIDER_PATH_MARKER in route_path: - app.router.routes = [*routes[:index], new_route, *routes[index:-1]] + app.router.routes = [ # mutable-ok: framework's list # rebind-ok: reordering is the fix + *routes[:index], + new_route, + *routes[index:-1], + ] return @staticmethod