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 = (