diff --git a/litellm/router.py b/litellm/router.py index bc755ad4374..7059dc0fac0 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9991,9 +9991,11 @@ class Router: True when `fallbacks` routes `model_name` to a model group with at least one unblocked deployment. `fallbacks` must already reflect the precedence the router applies at call time, so a request-supplied list replaces the router-level chain - rather than merging with it. Visited groups are skipped so a cycle terminates. + rather than merging with it. Traversal stops on a repeat group and after + `max_fallbacks` hops, matching the runtime limit and bounding recursion so a long + client-supplied chain cannot exhaust the stack. """ - if model_name in visited: + if model_name in visited or len(visited) >= self.max_fallbacks: return False fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group=model_name) if not fallback_model_group: diff --git a/tests/test_litellm/test_router_block_helpers.py b/tests/test_litellm/test_router_block_helpers.py index f210371d2dd..a924fe60d06 100644 --- a/tests/test_litellm/test_router_block_helpers.py +++ b/tests/test_litellm/test_router_block_helpers.py @@ -146,6 +146,17 @@ class TestHasReachableFallback: ) assert router._has_reachable_fallback("primary", fallbacks=["fallback"]) is True + def test_chain_longer_than_max_fallbacks_fails_closed(self): + hops = 6 + router = Router( + model_list=[_deployment("m0", "m0", blocked=True)] + + [_deployment(f"m{i}", f"m{i}", blocked=True) for i in range(1, hops)] + + [_deployment("healthy", "h0", blocked=False)], + max_fallbacks=hops - 2, + ) + chain = [{f"m{i}": [f"m{i + 1}"]} for i in range(hops - 1)] + [{f"m{hops - 1}": ["healthy"]}] + assert router._has_reachable_fallback("m0", fallbacks=chain) is False + class TestIsBlockedWithoutReachableFallback: def test_blocked_and_no_fallback_returns_true(self):