mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
49affa7c01
commit
ec94a1f82a
8 changed files with 319 additions and 3 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue