From 26bcafccb0110412a6a4a0c22e62328eb7f53fb0 Mon Sep 17 00:00:00 2001 From: IdoPort Date: Sun, 23 Aug 2026 13:02:37 +0300 Subject: [PATCH 1/7] 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 @@ class SafeRouteAdder: 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 @@ class SafeRouteAdder: 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/7] 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 @@ class SafeRouteAdder: 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/7] 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/7] 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 @@ class SafeRouteAdder: 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 From 2aa7fc934a2bf294e2675cd4d524dc2b39e4d045 Mon Sep 17 00:00:00 2001 From: IdoPort Date: Mon, 14 Sep 2026 11:22:05 +0300 Subject: [PATCH 5/7] fix(ci): resolve post-merge test isolation leak and stale generated schema - test_custom_pass_through_endpoint_prefix_wins_over_native_provider_routes registered routes directly on the shared litellm.proxy.proxy_server.app singleton with no teardown, leaking /claude-aws/v1/files and /claude-aws/v1/batches into app.router.routes for the rest of the test session. A newer upstream test, test_gateway_plus_backend_covers_full_app, then flags those as routes uncovered by either component's allowlist. Snapshot app.router.routes before the test and restore it in a finally block. - Regenerate ui/litellm-dashboard/src/lib/http/schema.d.ts (npm run gen:api) against the post-merge backend; the prior merge left it stale by two lines (a soft_budget docstring entry removed upstream). --- .../test_llm_pass_through_endpoints.py | 47 +++++++++++-------- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 - 2 files changed, 27 insertions(+), 22 deletions(-) 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 879c776a5bd..a451fad244a 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 @@ -3413,28 +3413,35 @@ def test_custom_pass_through_endpoint_prefix_wins_over_native_provider_routes(): ) 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}", - ) + # app is the real, process-wide proxy app -- registering routes on it leaks + # into every other test (e.g. test_component_allowlists.py's full-route-coverage + # check) unless restored, so snapshot and restore app.router.routes afterward. + original_routes = list(app.router.routes) + try: + 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" + 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" + # 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" + finally: + app.router.routes = original_routes def test_move_before_generic_provider_routes_is_a_no_op_without_a_generic_route(): diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7eadaa6c991..839aa52fa84 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16781,7 +16781,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) @@ -16887,7 +16886,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) From d38ec59f5eda04e775649b528161d8ec617b0d8e Mon Sep 17 00:00:00 2001 From: IdoPort Date: Mon, 14 Sep 2026 11:43:14 +0300 Subject: [PATCH 6/7] fix(test): remove only the routes this test adds instead of restoring a full snapshot The previous fix (2aa7fc934a) snapshotted app.router.routes before the test and restored the whole snapshot in a finally block. Under pytest-xdist, another test on the same worker can legitimately register a route on the same process-wide app between this test's start and its cleanup; wholesale restore silently dropped that too, breaking test_native_provider_routes_are_unchanged[POST-/openai/v1/chat/completions-openai_proxy_route] in CI. Track only the two paths this test adds and filter exactly those back out of app.router.routes afterward, leaving every other route (and whatever order/objects exist at cleanup time) untouched. --- .../test_llm_pass_through_endpoints.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 a451fad244a..4296314fdf9 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 @@ -3415,8 +3415,12 @@ def test_custom_pass_through_endpoint_prefix_wins_over_native_provider_routes(): # app is the real, process-wide proxy app -- registering routes on it leaks # into every other test (e.g. test_component_allowlists.py's full-route-coverage - # check) unless restored, so snapshot and restore app.router.routes afterward. - original_routes = list(app.router.routes) + # check) unless removed again. Track exactly the paths this test adds and + # filter only those back out afterward, rather than restoring a full + # snapshot -- under pytest-xdist, another test on the same worker can + # legitimately register routes between this test's start and its cleanup, + # and a wholesale snapshot restore would silently drop those too. + added_paths = {f"/claude-aws/v1/{suffix}" for suffix in ("files", "batches")} try: for suffix in ("files", "batches"): InitPassThroughEndpointHelpers.add_exact_path_route( @@ -3441,7 +3445,10 @@ def test_custom_pass_through_endpoint_prefix_wins_over_native_provider_routes(): assert _resolve_route_name("POST", "/v1/files") == "create_file" assert _resolve_route_name("POST", "/v1/batches") == "create_batch" finally: - app.router.routes = original_routes + app.router.routes = [ + route for route in app.router.routes + if getattr(route, "path", None) not in added_paths + ] def test_move_before_generic_provider_routes_is_a_no_op_without_a_generic_route(): From 76f78727ee26b4b366c3e75abb1f589b50930002 Mon Sep 17 00:00:00 2001 From: IdoPort Date: Mon, 14 Sep 2026 12:18:31 +0300 Subject: [PATCH 7/7] fix(security): only reorder pass-through routes that actually overlap a generic provider route Addresses the veria-ai automated security review on PR #38017 (https://github.com/BerriAI/litellm/pull/38017#discussion_r4003591579). _move_before_generic_provider_routes previously moved ANY newly-appended route ahead of the first route containing "{provider}" in its path, unconditionally. Since insertion is positional, this also promoted the new route ahead of every other route registered afterward -- including unrelated, authenticated built-in routes. A wildcard pass-through configured with an overlapping prefix (e.g. "/key/{subpath:path}") would shadow "/key/generate" and receive request bodies meant for the management API. Now the candidate generic route's template must actually match the new route's own path (via the same route.matches() check the test file's _resolve_route_name helper already uses) before any reordering happens. A route whose own path never structurally resembles a "/{provider}/..." template -- any wildcard/parameterized path -- is left exactly where it was appended, same as when no generic route exists at all. Adds test_wildcard_pass_through_does_not_shadow_unrelated_built_in_routes covering the "/key/{subpath:path}" vs "/key/generate" scenario from the review. --- .../pass_through_endpoints.py | 41 ++++++++++++++----- .../test_llm_pass_through_endpoints.py | 38 +++++++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 8e43b0f52d3..0b0733be4a7 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -26,6 +26,7 @@ from fastapi import ( ) from fastapi.responses import StreamingResponse from starlette.datastructures import UploadFile as StarletteUploadFile +from starlette.routing import Match from starlette.websockets import WebSocketState from websockets.asyncio.client import connect from websockets.exceptions import ( @@ -2772,24 +2773,44 @@ class SafeRouteAdder: 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. + before the first generic route whose template would actually capture this + route's own path, so it is matched first instead. A route is moved only when + that genuine overlap exists: reordering unconditionally on any "{provider}" + sighting would also promote unrelated wildcard pass-throughs (e.g. a + "/key/{subpath:path}" entry) ahead of every route registered afterward, + including unrelated, authenticated built-in routes like "/key/generate" -- + a real route.matches() check on this route's own path rules that out, since + a route path containing its own "{...}" placeholder never structurally + matches a "/{provider}/..." template. If no generic provider route captures + this path (including when none is registered at all, 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: Final = app.routes new_route: Final = routes[-1] + scope: Final = { + "type": "http", + "method": "GET", + "path": new_route.path, + "headers": [], + "query_string": b"", + "root_path": "", + } 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 = [ # mutable-ok: framework's list # rebind-ok: reordering is the fix - *routes[:index], - new_route, - *routes[index:-1], - ] - return + if not (route_path and SafeRouteAdder._GENERIC_PROVIDER_PATH_MARKER in route_path): + continue + if route.matches(scope)[0] == Match.NONE: + continue + app.router.routes = [ # mutable-ok: framework's list # rebind-ok: reordering is the fix + *routes[:index], + new_route, + *routes[index:-1], + ] + return @staticmethod def add_api_route_if_not_exists( 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 4296314fdf9..c650a93d42a 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 @@ -3451,6 +3451,44 @@ def test_custom_pass_through_endpoint_prefix_wins_over_native_provider_routes(): ] +def test_wildcard_pass_through_does_not_shadow_unrelated_built_in_routes(): + """ + A pass_through_endpoints entry with a wildcard subpath (e.g. "/key/{subpath:path}") + must not be promoted ahead of unrelated, authenticated built-in routes like + "/key/generate" just because it lands after the first "/{provider}/..." route in + app.routes once appended. Its own path never structurally matches a + "/{provider}/..." template, so it must be left exactly where it was appended. + """ + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + InitPassThroughEndpointHelpers, + ) + from litellm.proxy.proxy_server import app + + key_generate_route_name_before = _resolve_route_name("POST", "/key/generate") + assert key_generate_route_name_before is not None + + added_paths = {"/key/{subpath:path}"} + try: + InitPassThroughEndpointHelpers.add_exact_path_route( + app=app, + path="/key/{subpath:path}", + target="https://example.com/", + custom_headers=None, + forward_headers=False, + merge_query_params=False, + dependencies=None, + cost_per_request=None, + endpoint_id="test-key-wildcard", + ) + + assert _resolve_route_name("POST", "/key/generate") == key_generate_route_name_before + finally: + app.router.routes = [ + route for route in app.router.routes + if getattr(route, "path", None) not in added_paths + ] + + 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