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
This commit is contained in:
Tin Chi Lo 2026-07-30 23:41:42 -07:00
parent 2d25d23566
commit 9879baeabf
2 changed files with 40 additions and 5 deletions

View file

@ -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")

View file

@ -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",