Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2291,7 +2291,11 @@ async def initialize_pass_through_endpoints(
# remove the ones that are not visited from the list
for endpoint_key in registered_pass_through_endpoints:
if endpoint_key not in visited_endpoints:
InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_key)
_registered_pass_through_routes.pop(endpoint_key, None)
verbose_proxy_logger.debug(
"Removed stale pass-through route from registry: %s",
endpoint_key,
)
Comment on lines 2292 to +2298

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing LiteLLMRoutes.openai_routes cleanup

The PR description explicitly states the fix "also properly cleans up LiteLLMRoutes.openai_routes entries (both exact paths and /* wildcard paths for subpath routes), matching the behavior of the existing remove_endpoint_routes." However, the actual implementation only pops from _registered_pass_through_routes without touching LiteLLMRoutes.openai_routes.value.

For auth-enabled endpoints registered with include_subpath=True, each reload cycle appends the path and its /* wildcard to openai_routes (lines 2180–2216), but the stale entries are never removed in this loop. This creates the same class of unbounded growth β€” just in openai_routes instead of _registered_pass_through_routes.

route_checks.py iterates LiteLLMRoutes.openai_routes.value on every request for fuzzy matching (line 317), so the accumulation will cause the same O(n) per-request degradation the PR aims to fix for _registered_pass_through_routes.

The existing remove_endpoint_routes (used for explicit delete/update flows) shows the intended cleanup pattern. The stale-route removal loop should also inspect the popped route_info and mirror that cleanup:

for endpoint_key in registered_pass_through_endpoints:
    if endpoint_key not in visited_endpoints:
        route_info = _registered_pass_through_routes.pop(endpoint_key, None)
        if route_info is not None:
            path = route_info.get("path")
            if isinstance(path, str):
                openai_routes_list = LiteLLMRoutes.openai_routes.value
                if path in openai_routes_list:
                    openai_routes_list.remove(path)
                if route_info.get("type") == "subpath":
                    wildcard_path = path.rstrip("/") + "/*"
                    if wildcard_path in openai_routes_list:
                        openai_routes_list.remove(wildcard_path)
        verbose_proxy_logger.debug(
            "Removed stale pass-through route from registry: %s",
            endpoint_key,
        )



def _get_pass_through_endpoints_from_config() -> List[PassThroughGenericEndpoint]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
HttpPassThroughEndpointHelpers,
_registered_pass_through_routes,
pass_through_request,
)
from litellm.proxy.pass_through_endpoints.success_handler import (
Expand Down Expand Up @@ -2474,3 +2475,82 @@ async def mock_httpx_request(method, url, **kwargs):
# Verify the response
assert response.status_code == 200
async_client.request.assert_called_once()


class TestRemoveStaleEndpointRoute:
"""Regression tests for https://github.com/BerriAI/litellm/issues/24833:
When pass-through endpoints stored in the DB have no ``id`` field, each
periodic reload generates a new UUID, causing ``_registered_pass_through_routes``
to grow without bound because the old cleanup path
(``remove_endpoint_routes``) scanned by ``endpoint_id`` and never matched
the stale entries. The fix uses dict.pop() to remove by route key in O(1).
"""

def setup_method(self):
_registered_pass_through_routes.clear()

def teardown_method(self):
_registered_pass_through_routes.clear()

def test_pop_removes_stale_route_by_key(self):
"""Stale route is removed in O(1) by its route key via dict.pop()."""
route_key = "old-uuid:exact:/my-endpoint:GET,POST"
_registered_pass_through_routes[route_key] = {
"endpoint_id": "old-uuid",
"path": "/my-endpoint",
"type": "exact",
}

_registered_pass_through_routes.pop(route_key, None)

assert route_key not in _registered_pass_through_routes

def test_pop_noop_for_unknown_key(self):
"""pop() with default None does not raise for nonexistent keys."""
_registered_pass_through_routes["keep-me:exact:/a:GET"] = {
"endpoint_id": "keep-me",
"path": "/a",
"type": "exact",
}

_registered_pass_through_routes.pop("nonexistent:exact:/b:GET", None)

assert len(_registered_pass_through_routes) == 1

def test_registry_does_not_grow_across_reload_cycles(self):
"""Simulate multiple reload cycles with changing UUIDs.

Core regression test: without the fix the registry grows by N entries
every cycle; with the fix it stays constant.
"""
path = "/vertex-passthrough"
methods_str = "GET,POST"
num_cycles = 50

for cycle in range(num_cycles):
old_keys = list(_registered_pass_through_routes.keys())

new_uuid = f"uuid-{cycle}"
new_exact_key = f"{new_uuid}:exact:{path}:{methods_str}"
new_subpath_key = f"{new_uuid}:subpath:{path}:{methods_str}"

_registered_pass_through_routes[new_exact_key] = {
"endpoint_id": new_uuid,
"path": path,
"type": "exact",
}
_registered_pass_through_routes[new_subpath_key] = {
"endpoint_id": new_uuid,
"path": path,
"type": "subpath",
}

visited = {new_exact_key, new_subpath_key}

# Clean up stale routes β€” the fix: pop by key in O(1)
for key in old_keys:
if key not in visited:
_registered_pass_through_routes.pop(key, None)

# After 50 cycles, only the last cycle's 2 keys should remain
Comment on lines +2495 to +2555

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests exercise dict operations, not the production code path

All three new tests directly manipulate _registered_pass_through_routes and call dict.pop() on it themselves. They do not call initialize_pass_through_endpoints or any production function. For example, test_pop_removes_stale_route_by_key invokes _registered_pass_through_routes.pop(route_key, None) directly β€” this only verifies that Python's dict works, not that the production code actually uses it.

The core regression test test_registry_does_not_grow_across_reload_cycles re-implements the cleanup loop inline rather than invoking the real initialize_pass_through_endpoints. If someone later refactors that function (e.g., accidentally re-introduces the old O(n) scan), the tests will continue to pass.

Consider mocking out the DB + FastAPI app interactions and calling initialize_pass_through_endpoints (or at minimum the cleanup slice of it) so the tests actually cover the production code path that was broken.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in 7b2a7e0 β€” result_rows = [] was moved to the top of Section 5 (before the message-building loop) and the duplicate initialization in Section 6 was removed.

assert len(_registered_pass_through_routes) == 2
Loading