From 6ede2a27647f4159437c12515e6ea536e3f0a7e6 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Fri, 21 Aug 2026 19:54:08 -0700 Subject: [PATCH] fix(complexity-router): reject tier_boundaries that do not ascend The score-to-tier mapping is a sequential comparison chain, so a boundary sitting below the one under it makes the tier between them unreachable and silently routes its traffic to a costlier tier. Nothing validated that, on either the config.yaml or the API path. Omitting a boundary is the easy way to arrive there, since the omitted key is filled from a shipped default that knows nothing about the boundary the operator did set, so the validator checks the resolved set rather than the raw one. Both it and the scorer now fill through a single resolve_tier_boundaries(), so they cannot disagree about what a partial config means. --- .../complexity_router/README.md | 2 + .../complexity_router/complexity_router.py | 10 ++--- .../complexity_router/config.py | 38 ++++++++++++++++ .../router_strategy/test_complexity_router.py | 45 +++++++++++++++++-- 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/litellm/router_strategy/complexity_router/README.md b/litellm/router_strategy/complexity_router/README.md index 3aea7a6e482..943479cb357 100644 --- a/litellm/router_strategy/complexity_router/README.md +++ b/litellm/router_strategy/complexity_router/README.md @@ -34,6 +34,8 @@ The weighted sum is mapped to tiers using configurable boundaries: | COMPLEX | 0.25 - 0.50 | `medium_complex` | Technical, multi-part requests | | REASONING | > 0.50 | `complex_reasoning` | Chain-of-thought, analysis | +The three boundaries must ascend, and any key you leave out is filled from the shipped default, so setting one boundary without the others can put them out of order. A set that decreases would strand the tier between the inverted pair and silently route its traffic to a costlier one, so it is rejected at config load with a message naming the resolved values. Set every boundary you need to move, not just one. + Tier names are defaults you can rename with [`tier_labels`](#renaming-the-tiers). The three `tier_boundaries` keys are named after those defaults but they are scorer knobs, not tiers: each one names the gap between two rungs and is persisted by name on every routing decision, so they stay `simple_medium` / `medium_complex` / `complex_reasoning` no matter what you call the tiers. The column above tells a renamed deployment which knob it is turning. ## Configuration diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 7420c3afb7c..19107fb5ad2 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -48,7 +48,6 @@ from .config import ( DEFAULT_REASONING_KEYWORDS, DEFAULT_SIMPLE_KEYWORDS, DEFAULT_TECHNICAL_KEYWORDS, - DEFAULT_TIER_BOUNDARIES, PLAN_MODE_SYSTEM_SENTINELS, PLAN_MODE_TAIL_SENTINELS, PLAN_MODE_TOOL_NAME, @@ -56,6 +55,7 @@ from .config import ( ClassificationRubric, ComplexityRouterConfig, ComplexityTier, + resolve_tier_boundaries, ) if TYPE_CHECKING: @@ -1096,11 +1096,11 @@ class ComplexityRouter(CustomLogger): Shared by score-to-tier mapping and the per-request routing decision snapshot, so a logged decision always reflects the boundaries that actually applied. """ - boundaries: Final = self.config.tier_boundaries + resolved: Final = resolve_tier_boundaries(self.config.tier_boundaries) return StandardLoggingRoutingDecisionTierBoundaries( - simple_medium=boundaries.get("simple_medium", DEFAULT_TIER_BOUNDARIES["simple_medium"]), - medium_complex=boundaries.get("medium_complex", DEFAULT_TIER_BOUNDARIES["medium_complex"]), - complex_reasoning=boundaries.get("complex_reasoning", DEFAULT_TIER_BOUNDARIES["complex_reasoning"]), + simple_medium=resolved["simple_medium"], + medium_complex=resolved["medium_complex"], + complex_reasoning=resolved["complex_reasoning"], ) def _build_routing_decision( diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index 10906f14bda..6caa9e519f8 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -372,6 +372,15 @@ DEFAULT_TIER_BOUNDARIES: Final[dict[str, float]] = { } +def resolve_tier_boundaries(boundaries: Mapping[str, float]) -> Mapping[str, float]: + """The three boundaries in effect, with the shipped default filling in each key the config omits. + + The single place omitted keys are filled, so a validator and the scorer cannot disagree about + what a partially specified tier_boundaries actually means. + """ + return MappingProxyType({key: boundaries.get(key, default) for key, default in DEFAULT_TIER_BOUNDARIES.items()}) + + # ─── Default Token Thresholds ─── DEFAULT_TOKEN_THRESHOLDS: Final[dict[str, int]] = { @@ -1104,6 +1113,35 @@ class ComplexityRouterConfig(BaseModel): self.tiers = normalized return self + @model_validator(mode="after") + def _validate_tier_boundaries_ascend(self) -> "ComplexityRouterConfig": + # The score-to-tier mapping is a sequential comparison chain, so a boundary that sits below the one + # under it makes the tier between them unreachable and silently sends its traffic to a costlier tier. + # Resolved, not raw: omitting a key is the common way to arrive here, since the omitted key is filled + # from a shipped default that knows nothing about the boundary the operator did set. + resolved: Final = resolve_tier_boundaries(self.tier_boundaries) + simple_medium, medium_complex, complex_reasoning = ( + resolved["simple_medium"], + resolved["medium_complex"], + resolved["complex_reasoning"], + ) + if simple_medium <= medium_complex <= complex_reasoning: + return self + stranded: Final = tuple( + tier + for tier, inverted in ( + ("MEDIUM", simple_medium > medium_complex), + ("COMPLEX", medium_complex > complex_reasoning), + ) + if inverted + ) + raise ValueError( + f"tier_boundaries must ascend, but resolve to simple_medium={simple_medium}, " + f"medium_complex={medium_complex}, complex_reasoning={complex_reasoning}, leaving " + f"{' and '.join(stranded)} unreachable. Boundaries you omit are filled from the shipped " + f"defaults {DEFAULT_TIER_BOUNDARIES}, so set every boundary you need to move, not just one." + ) + @model_validator(mode="after") def _validate_semantic_matching(self) -> "ComplexityRouterConfig": if not self.semantic_keyword_matching: diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 3e14c462f9e..16545be5491 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -309,7 +309,10 @@ class TestReasoningMarkerScoring: high = ComplexityRouter( model_name="test-complexity-router", litellm_router_instance=mock_router_instance, - complexity_router_config={**basic_config, "tier_boundaries": {"simple_medium": 0.30}}, + complexity_router_config={ + **basic_config, + "tier_boundaries": {"simple_medium": 0.30, "medium_complex": 0.35, "complex_reasoning": 0.50}, + }, ) assert low._effective_reasoning_override_min_score() == 0.20 assert high._effective_reasoning_override_min_score() == 0.30 @@ -622,15 +625,51 @@ class TestEffectiveTierBoundaries: litellm_router_instance=mock_router_instance, complexity_router_config={ "tiers": {"SIMPLE": "a", "MEDIUM": "b", "COMPLEX": "c", "REASONING": "d"}, - "tier_boundaries": {"simple_medium": 0.42}, + "tier_boundaries": {"simple_medium": 0.2}, }, ) assert dict(router._effective_tier_boundaries()) == { - "simple_medium": 0.42, + "simple_medium": 0.2, "medium_complex": DEFAULT_TIER_BOUNDARIES["medium_complex"], "complex_reasoning": DEFAULT_TIER_BOUNDARIES["complex_reasoning"], } + def test_omitting_a_boundary_below_one_that_is_set_is_rejected(self, mock_router_instance): + """The trap this guards: one boundary set high, the rest filled from lower shipped defaults.""" + with pytest.raises(ValidationError, match="MEDIUM unreachable"): + ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": "a", "MEDIUM": "b", "COMPLEX": "c", "REASONING": "d"}, + "tier_boundaries": {"simple_medium": 0.30}, + }, + ) + + def test_fully_specified_decreasing_boundaries_are_rejected(self, mock_router_instance): + """An operator can also strand a tier without omitting anything.""" + with pytest.raises(ValidationError, match="COMPLEX unreachable"): + ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": "a", "MEDIUM": "b", "COMPLEX": "c", "REASONING": "d"}, + "tier_boundaries": {"simple_medium": 0.10, "medium_complex": 0.60, "complex_reasoning": 0.50}, + }, + ) + + def test_equal_boundaries_are_allowed(self, mock_router_instance): + """Collapsing a tier to an empty band is a deliberate way to take it out of rotation.""" + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": "a", "MEDIUM": "b", "COMPLEX": "c", "REASONING": "d"}, + "tier_boundaries": {"simple_medium": 0.25, "medium_complex": 0.25, "complex_reasoning": 0.50}, + }, + ) + assert router._effective_tier_boundaries()["medium_complex"] == 0.25 + def test_defaults_stay_ordered_and_within_the_scoring_range(self): """The tiers only all remain reachable while the boundaries ascend.""" simple_medium, medium_complex, complex_reasoning = (