fix(router): reject complexity-router settings written outside complexity_router_config (#38570)

A complexity-router setting placed beside complexity_router_config, or inside a
tier entry's litellm_params, is read by nobody: the router loads its settings only
from litellm_params.complexity_router_config. It does not stay inert. The
alias-marker forwarding and the per-tier param spread carry every unrecognized key
onto the outbound request, and all_litellm_params only knows the outer names, so
the key reaches the provider as an unknown body field and every call through that
model group fails with an error naming an internal config key.

Guard the whole set, derived from ComplexityRouterConfig.model_fields so a field
added later is covered, and scoped to complexity-router deployments because the
names only mean this there (embedding_model is a legitimate flat param on an
s3_vectors vector store). Scope is read from the same merged field view the naming
check is judged on, so a router named only by its default model is in scope and a
field added to the required-field table is covered without another edit. The write
endpoints reject with a 400 naming the keys and where they belong, config.yaml
refuses to start for the same reason max_agentic_loops does, and a tier entry is
judged by the config model itself.

An already-stored deployment keeps loading, so an upgrade cannot take a running
gateway down over a row that was written before the gate existed.
This commit is contained in:
tin-berri 2026-08-27 17:04:49 -07:00 committed by GitHub
parent 49affa7c01
commit ec94a1f82a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 319 additions and 3 deletions

View file

@ -85,6 +85,8 @@ from litellm.router_strategy.complexity_router import (
)
from litellm.router_utils.auto_router_model_naming import (
STRATEGY_ROUTER_PARAM_FIELDS,
carries_complexity_router_settings,
validate_complexity_router_config_placement,
validate_complexity_router_config_write,
validate_strategy_router_model_write,
)
@ -226,14 +228,19 @@ def _strategy_router_write_violation(
)
if config_violation is not None:
return config_violation
if incoming_params.model is None:
return None
present_fields: Final = frozenset(
field
for field in STRATEGY_ROUTER_PARAM_FIELDS
for source in (incoming_params, existing_params)
if source is not None and getattr(source, field, None) is not None
)
# Scope reads the incoming model because the stored one is encrypted at rest.
if carries_complexity_router_settings(incoming_params.model, present_fields):
placement_violation: Final = validate_complexity_router_config_placement(incoming_params.model_extra)
if placement_violation is not None:
return placement_violation
if incoming_params.model is None:
return None
return validate_strategy_router_model_write(model=incoming_params.model, present_fields=present_fields)

View file

@ -116,6 +116,11 @@ from litellm.router_utils.add_retry_fallback_headers import (
get_fallback_errors_from_headers,
get_hidden_params_dict,
)
from litellm.router_utils.auto_router_model_naming import (
STRATEGY_ROUTER_PARAM_FIELDS,
carries_complexity_router_settings,
validate_complexity_router_config_placement,
)
from litellm.types.utils import (
ModelResponse,
ModelResponseStream,
@ -4151,6 +4156,28 @@ def validate_deployment_max_agentic_loops(model: Mapping[str, object]) -> None:
)
def validate_deployment_complexity_router_placement(model: Mapping[str, object]) -> None:
"""
Reject a complexity-router setting written one level above `complexity_router_config`.
Checked here rather than on `LiteLLM_Params` for the same reason as
`max_agentic_loops`: the proxy builds its router with
`ignore_invalid_deployments=True`, so a rejection further down turns a bad
deployment into a silently missing model instead of a refusal to start.
"""
litellm_params: Final = model.get("litellm_params")
if not isinstance(litellm_params, Mapping):
return
present_fields: Final = frozenset(
field for field in STRATEGY_ROUTER_PARAM_FIELDS if litellm_params.get(field) is not None
)
if not carries_complexity_router_settings(str(litellm_params.get("model") or ""), present_fields):
return
violation: Final = validate_complexity_router_config_placement(litellm_params)
if violation is not None:
raise ValueError(f"model {model.get('model_name', '')!r}: {violation}")
def pin_complexity_router_model_id(model: dict) -> None: # mutable-ok: out-param, model_info is stamped in place
"""
Stamps `model_info.id` from the raw litellm_params before plugin resolution swaps
@ -5499,6 +5526,7 @@ class ProxyConfig:
if isinstance(v, str) and v.startswith("os.environ/"):
model["litellm_params"][k] = get_secret(v)
validate_deployment_max_agentic_loops(model)
validate_deployment_complexity_router_placement(model)
pin_complexity_router_model_id(model)
complexity_router_config = model["litellm_params"].get("complexity_router_config")
if isinstance(complexity_router_config, dict):

View file

@ -1246,6 +1246,28 @@ class ComplexityRouterConfig(BaseModel):
)
return self
@model_validator(mode="after")
def _validate_tier_param_placement(self) -> "ComplexityRouterConfig":
"""Reject a router setting written into a tier entry's request params.
A tier entry's ``litellm_params`` are request params for that deployment: the
pre-routing hook spreads them onto the outbound call, so a config key placed
there configures nothing and reaches the provider as an unknown body field.
"""
misplaced: Final = tuple(
f"{tier}.{key}"
for tier, entries in self.tier_model_configs.items()
for entry in entries
for key in sorted(frozenset(entry.litellm_params) & COMPLEXITY_ROUTER_CONFIG_KEYS)
)
if misplaced:
raise ValueError(
"tier entries carry complexity_router_config settings in their litellm_params, where the "
"router never reads them and the outbound request forwards them to the provider as unknown "
f"body fields: {', '.join(misplaced)}. Set these on complexity_router_config itself"
)
return self
def tier_label(self, tier: ComplexityTier) -> str:
"""Operator-facing display name for a tier, falling back to its canonical name."""
return self.tier_labels.get(tier, "").strip() or tier.value
@ -1264,5 +1286,14 @@ class ComplexityRouterConfig(BaseModel):
)
COMPLEXITY_ROUTER_CONFIG_KEYS: Final[frozenset[str]] = frozenset(ComplexityRouterConfig.model_fields)
"""Every setting name this config owns, derived from the model so a field added later is covered.
These names are disjoint from the OpenAI request params, from ``all_litellm_params``, and from the
``LiteLLM_Params`` fields, so one of them appearing where a request param belongs is always a
misplaced setting rather than a parameter the caller meant to send.
"""
# Combined default config
DEFAULT_COMPLEXITY_CONFIG: Final = ComplexityRouterConfig()

View file

@ -15,7 +15,10 @@ from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
from litellm.router_strategy.complexity_router.config import LLM_CLASSIFIER_TYPES
from litellm.router_strategy.complexity_router.config import (
COMPLEXITY_ROUTER_CONFIG_KEYS,
LLM_CLASSIFIER_TYPES,
)
AUTO_ROUTER_MODEL_PREFIX: Final = "auto_router/"
@ -188,6 +191,47 @@ def validate_complexity_router_config_write(complexity_router_config: Mapping[st
return None
_COMPLEXITY_ROUTER_FIELDS: Final[frozenset[str]] = frozenset(
field for group in _REQUIRED_FIELD_GROUPS["complexity"] for field in group
)
def carries_complexity_router_settings(model: str | None, present_fields: frozenset[str]) -> bool:
"""Whether this deployment configures a complexity router, so is judged on its key set.
Scoped rather than applied to every deployment because the setting names are only
unambiguous in this context: ``embedding_model``, for one, is a legitimate flat param
on an s3_vectors vector store. ``present_fields`` carries the same merged view
``validate_strategy_router_model_write`` is judged on, so a router named only by its
default model is in scope, and a field added to the table above is covered here for free.
"""
return classify_strategy_router_model(model or "") == "complexity" or bool(
present_fields & _COMPLEXITY_ROUTER_FIELDS
)
def validate_complexity_router_config_placement(litellm_params: Mapping[str, object] | None) -> str | None:
"""Reject a complexity-router setting written beside ``complexity_router_config``.
The router reads its settings only from ``litellm_params.complexity_router_config``, so a
key one level too high configures nothing. It does not stay inert: the alias-marker
forwarding carries every unrecognized ``litellm_params`` key onto the outbound request,
where the provider rejects it as an unknown body field, and the deployment then fails
every call with an error naming an internal config key. Caller scopes; this judges.
"""
if litellm_params is None:
return None
misplaced: Final = tuple(sorted(frozenset(litellm_params) & COMPLEXITY_ROUTER_CONFIG_KEYS))
if not misplaced:
return None
return (
f"litellm_params sets complexity_router_config settings directly: {', '.join(misplaced)}. "
"The router reads these only from complexity_router_config, so there they configure nothing "
"and are forwarded to the provider as unknown request params, which rejects the call. "
"Move them under complexity_router_config."
)
def validate_strategy_router_model_write(model: str, present_fields: frozenset[str]) -> str | None:
"""Check that writing ``model`` leaves a deployment the router can load.

View file

@ -4106,6 +4106,72 @@ class TestStrategyRouterWriteValidation:
assert "requires" in str(exc_info.value.message)
mock_prisma.db.litellm_proxymodeltable.create.assert_not_called()
def test_settings_written_beside_the_config_rejected(self):
"""A setting one level above complexity_router_config configures nothing, and the alias
marker forwards it onto every outbound call, so the provider rejects the request with an
error naming an internal config key. The write is the last boundary that can refuse it."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
_strategy_router_write_violation,
)
violation = _strategy_router_write_violation(
incoming_params=LiteLLM_Params(
model="auto_router/complexity_router",
complexity_router_config={"tiers": {"SIMPLE": ["gpt-4o-mini"]}},
tier_boundaries={"simple_medium": 0.1},
token_thresholds={"medium": 100},
),
existing_params=None,
)
assert violation is not None
assert "tier_boundaries" in violation
assert "token_thresholds" in violation
@pytest.mark.parametrize(
"stored_field",
["complexity_router_config", "complexity_router_default_model"],
)
def test_settings_beside_the_config_rejected_on_a_patch_of_a_stored_router(self, stored_field):
"""The patch carries only the stray key, so scope has to come from the stored deployment:
the stored model is encrypted at rest and cannot be classified here. Either field names a
complexity router on its own, which is what the load requires, so either has to be scope."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
_strategy_router_write_violation,
)
from litellm.types.router import updateLiteLLMParams
stored = {
"complexity_router_config": {"tiers": {"SIMPLE": "gpt-4o-mini"}},
"complexity_router_default_model": "gpt-4o-mini",
}[stored_field]
violation = _strategy_router_write_violation(
incoming_params=updateLiteLLMParams(tier_boundaries={"simple_medium": 0.1}),
existing_params=LiteLLM_Params(model="auto_router/complexity_router", **{stored_field: stored}),
)
assert violation is not None
assert "tier_boundaries" in violation
def test_documented_nesting_still_accepted(self):
from litellm.proxy.management_endpoints.model_management_endpoints import (
_strategy_router_write_violation,
)
assert (
_strategy_router_write_violation(
incoming_params=LiteLLM_Params(
model="auto_router/complexity_router",
complexity_router_default_model="gpt-4o-mini",
complexity_router_config={
"tiers": {"SIMPLE": ["gpt-4o-mini"]},
"tier_boundaries": {"simple_medium": 0.1},
},
),
existing_params=None,
)
is None
)
@pytest.mark.asyncio
async def test_update_model_rejects_prefix_strip(self):
from litellm.proxy._types import ProxyException

View file

@ -26,6 +26,7 @@ from litellm.proxy.proxy_server import (
_scrub_guardrail_inner,
resolve_complexity_router_plugins,
resolve_routing_plugins,
validate_deployment_complexity_router_placement,
validate_deployment_max_agentic_loops,
)
@ -154,6 +155,44 @@ def test_resolve_complexity_router_plugins_resolves_dotted_path_to_live_instance
assert type(config["plugins"][0]).__name__ == "_Plugin"
def test_validate_deployment_complexity_router_placement_refuses_to_start():
"""Rejected here rather than at router build for the same reason as max_agentic_loops: the
proxy builds its router with ignore_invalid_deployments=True, so a rejection further down
turns the bad deployment into a silently missing model instead of a refusal to start."""
model = {
"model_name": "smart-router",
"litellm_params": {
"model": "auto_router/complexity_router",
"complexity_router_config": {"tiers": {"SIMPLE": "gpt-4o-mini"}},
"tier_boundaries": {"simple_medium": 0.1},
},
}
with pytest.raises(ValueError, match="tier_boundaries"):
validate_deployment_complexity_router_placement(model)
@pytest.mark.parametrize(
"litellm_params",
[
{"model": "gpt-4o"},
{"model": "openai/gpt-4o", "embedding_model": "text-embedding-3-small"},
{
"model": "auto_router/complexity_router",
"complexity_router_config": {"tiers": {"SIMPLE": "gpt-4o-mini"}, "tier_boundaries": {"simple_medium": 0.1}},
},
],
)
def test_validate_deployment_complexity_router_placement_leaves_valid_deployments_alone(litellm_params):
"""`embedding_model` is a legitimate flat param on an s3_vectors vector store, so the gate is
scoped to complexity routers rather than applied to every deployment."""
model = {"model_name": "m", "litellm_params": dict(litellm_params)}
validate_deployment_complexity_router_placement(model)
assert model["litellm_params"] == litellm_params
def test_validate_deployment_max_agentic_loops_allows_a_deployment_without_the_key():
model = {"model_name": "gpt-4o", "litellm_params": {"model": "gpt-4o"}}

View file

@ -8690,6 +8690,38 @@ def test_tier_model_params_reject_malformed_entries(tiers):
ComplexityRouterConfig(tiers=tiers)
@pytest.mark.parametrize(
"misplaced",
[
{"tier_boundaries": {"simple_medium": 0.1}},
{"token_thresholds": {"medium": 100}},
{"classifier_type": "llm"},
],
)
def test_tier_model_params_reject_router_settings(misplaced):
"""A tier entry's litellm_params are request params for that deployment: the pre-routing hook
spreads them onto the outbound call, so a router setting placed there configures nothing and
reaches the provider as an unknown body field, failing every call through that tier."""
with pytest.raises(ValidationError, match="complexity_router_config settings"):
ComplexityRouterConfig(tiers={"REASONING": [{"model_name": "opus", "litellm_params": misplaced}]})
@pytest.mark.parametrize(
"params",
[
{"reasoning_effort": "xhigh"},
{"thinking": {"type": "enabled"}},
{"max_tokens": 512, "temperature": 0.2},
],
)
def test_tier_model_params_still_accept_real_request_params(params):
"""The negative class for the gate above: per-tier request-param overrides are a shipped
feature, so the check must reject only names the config itself owns."""
config = ComplexityRouterConfig(tiers={"REASONING": [{"model_name": "opus", "litellm_params": params}]})
assert config.tier_model_configs["REASONING"][0].litellm_params == params
def test_tier_model_params_reject_duplicate_models():
with pytest.raises(ValidationError, match="duplicate model_name"):
ComplexityRouterConfig(

View file

@ -1,8 +1,10 @@
import pytest
from litellm.router_utils.auto_router_model_naming import (
carries_complexity_router_settings,
classify_strategy_router_model,
strategy_router_dependencies,
validate_complexity_router_config_placement,
validate_complexity_router_config_write,
validate_strategy_router_model_write,
)
@ -300,3 +302,70 @@ def test_complexity_embedding_model_is_a_dependency_only_when_semantic_matching_
)
assert tuple(d.model_name for d in found) == expected
@pytest.mark.parametrize(
"misplaced",
[
("tier_boundaries",),
("token_thresholds", "dimension_weights"),
("reasoning_override_min_score",),
("tiers",),
],
)
def test_placement_rejects_settings_written_beside_the_config(misplaced):
"""A setting one level above complexity_router_config configures nothing and is forwarded to
the provider as an unknown body field, so the deployment fails every call with an error naming
an internal config key. The whole key set leaks the same way, not just the one first reported."""
violation = validate_complexity_router_config_placement(
{
"model": "auto_router/complexity_router",
"complexity_router_config": {"tiers": VALID_TIERS},
**{key: {"anything": 1} for key in misplaced},
}
)
assert violation is not None
for key in misplaced:
assert key in violation
assert "Move them under complexity_router_config" in violation
def test_placement_accepts_the_documented_nesting():
assert (
validate_complexity_router_config_placement(
{
"model": "auto_router/complexity_router",
"complexity_router_config": {"tiers": VALID_TIERS, "tier_boundaries": {"simple_medium": 0.1}},
}
)
is None
)
def test_placement_guards_every_setting_the_config_owns():
"""Derived from the model rather than listed here, so a field added to ComplexityRouterConfig
later is covered without editing this gate. Pinned so a rename cannot silently shrink it."""
from litellm.router_strategy.complexity_router.config import (
COMPLEXITY_ROUTER_CONFIG_KEYS,
ComplexityRouterConfig,
)
assert COMPLEXITY_ROUTER_CONFIG_KEYS == frozenset(ComplexityRouterConfig.model_fields)
assert {"tier_boundaries", "token_thresholds", "dimension_weights"} <= COMPLEXITY_ROUTER_CONFIG_KEYS
@pytest.mark.parametrize(
"model,present_fields,scoped",
[
("auto_router/complexity_router", frozenset(), True),
("openai/gpt-4o", frozenset({"complexity_router_config"}), True),
(None, frozenset({"complexity_router_default_model"}), True),
("auto_router/semantic_router", frozenset({"auto_router_default_model"}), False),
("openai/gpt-4o", frozenset(), False),
],
)
def test_placement_is_scoped_to_complexity_router_deployments(model, present_fields, scoped):
"""The setting names only mean this on a complexity router: `embedding_model` is a legitimate
flat param on an s3_vectors vector store, so an unscoped gate would reject a valid deployment.
Either complexity field names one on its own, which is what the load itself requires."""
assert carries_complexity_router_settings(model, present_fields) is scoped