From be7b23ce0ff16cc146c58c06fdd918ea6b1830ab Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Fri, 31 Jul 2026 10:33:21 -0700 Subject: [PATCH] fix(complexity_router): name the missing tier when a plugin config has no models for it With routing plugins configured, a tier absent from `tiers` produced "No candidate models left for tier MEDIUM after routing-plugin filtering". Nothing was filtered: the pool was empty before any plugin ran, so the message sent an operator to read plugin code for what is a gap in `tiers` The two cases now raise separately. An empty pool names the tier and says why default_model is not consulted; a plugin narrowing a real pool to zero keeps its own message, since that one really is a policy decision This is the path no-signal traffic now takes, because the implicit MEDIUM default is deliberately not validated at load: doing so would turn every partial tiers map into a startup failure. The tier ladder in #35331 resolves the same shape at runtime by climbing to the next configured tier, so this is the standalone behavior --- .../complexity_router/complexity_router.py | 11 +++++++- .../router_strategy/test_complexity_router.py | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index c4296f029b2..41552ee3745 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -871,11 +871,20 @@ class ComplexityRouter(CustomLogger): from litellm.types.router import RoutingContext tier_key = tier.value + candidates = list(self._tier_pools().get(tier_key, [])) + if not candidates: + # Distinct from the plugin denial below: nothing was filtered out, the tier was + # never given models. Saying "after routing-plugin filtering" would send an + # operator to read plugin code for what is a gap in `tiers`. + raise ValueError( + f"Tier {tier_key} has no models configured. Routing plugins are configured, so " + f"default_model is not consulted: a model the plugins never vetted must not serve" + ) metadata_key = "litellm_metadata" if "litellm_metadata" in request_kwargs else "metadata" context = RoutingContext( raw_messages=raw_messages or [], structured_messages=resolved_messages or [], - candidate_models=list(self._tier_pools().get(tier_key, [])), + candidate_models=candidates, metadata=request_kwargs.get(metadata_key) or {}, ) for plugin in self.config.plugins: diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index d37eb5bdeb7..54a7b8c2b52 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -4914,6 +4914,33 @@ class TestNoSignalDefaultTier: }, ) + @pytest.mark.asyncio + async def test_an_unconfigured_default_tier_under_plugins_names_the_real_gap(self, mock_router_instance): + """The implicit MEDIUM default is deliberately not validated at load, so a partial + tiers map keeps loading. With plugins configured it has no fallback, so the request + fails; the error has to name the missing tier rather than blaming the plugins for a + filter they never applied.""" + + class NoOpPlugin: + async def run(self, context): + return context + + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": "simple-model", "COMPLEX": "complex-model"}, + "plugins": [NoOpPlugin()], + "session_affinity": False, + }, + ) + with pytest.raises(ValueError, match="Tier MEDIUM has no models configured"): + await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": RIVER_CROSSING}], + ) + def test_default_tier_outside_tiers_is_allowed_with_default_model(self, mock_router_instance): router = ComplexityRouter( model_name="test-router",