From 9879baeabf707c0a9cefd553c207aec8db77674e Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 30 Jul 2026 23:41:42 -0700 Subject: [PATCH] fix(complexity_router): stop default_model rescuing an unservable default_tier under plugins The validator accepted default_model as evidence that an explicitly configured default_tier could be served. The plugin path never consults it: candidates come from the tier pool alone, and a pool that is empty raises rather than falling through to a model the plugins never vetted. So a config with plugins, an explicit default_tier absent from tiers, and a default_model passed validation and then failed on its first no-signal request, which is exactly what validating at load is meant to prevent default_model now counts only when no plugins are configured, and the error names which of the two remedies applies --- .../complexity_router/config.py | 24 +++++++++++++++---- .../router_strategy/test_complexity_router.py | 21 ++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index 6064cbb0ce8..b1eb424b2d4 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -476,14 +476,28 @@ class ComplexityRouterConfig(BaseModel): def _validate_default_tier_is_servable(self) -> "ComplexityRouterConfig": if "default_tier" not in self.model_fields_set: return self - if self.tiers.get(self.default_tier.value) or self.default_model: + if self.tiers.get(self.default_tier.value): return self + # default_model rescues this only without plugins. The plugin path never falls back + # to it, since a model the plugins did not vet must not serve, so accepting it here + # would validate a config whose every no-signal request fails at routing time. + if self.default_model and not self.plugins: + return self + remedy = ( + "Add it to tiers, or name a tier that is configured" + if self.plugins + else "Add it to tiers, name a tier that is configured, or set default_model in complexity_router_config" + ) + because = ( + "routing plugins are configured, so default_model is not consulted: a model the plugins " + "never vetted must not serve" + if self.plugins + else "the deployment-level complexity_router_default_model does not count here, because " + "falling through to it would serve every no-signal request from a model this tier never names" + ) raise ValueError( f"default_tier {self.default_tier.value} is not a non-empty entry in tiers " - f"({sorted(self.tiers)}). Add it to tiers, name a tier that is configured, or set " - f"default_model in complexity_router_config; the deployment-level " - f"complexity_router_default_model does not count here, because falling through to it " - f"would serve every no-signal request from a model this tier never names" + f"({sorted(self.tiers)}). {remedy}; {because}" ) @model_validator(mode="after") diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 6c01bf88ded..d37eb5bdeb7 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -4893,6 +4893,27 @@ class TestNoSignalDefaultTier: }, ) + def test_default_model_does_not_rescue_the_default_tier_when_plugins_are_configured(self, mock_router_instance): + """The plugin path builds candidates from the tier pool alone and never consults + default_model, since a model the plugins did not vet must not serve. Accepting + default_model here would validate a config whose every no-signal request raises.""" + + class NoOpPlugin: + async def run(self, context): + return context + + with pytest.raises(ValidationError, match="routing plugins are configured"): + ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": "simple-model", "MEDIUM": "medium-model"}, + "default_tier": "COMPLEX", + "default_model": "fallback-model", + "plugins": [NoOpPlugin()], + }, + ) + def test_default_tier_outside_tiers_is_allowed_with_default_model(self, mock_router_instance): router = ComplexityRouter( model_name="test-router",