From 1592842473e83267232d8ba4000ee239e2b3b4f6 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Wed, 5 Aug 2026 12:05:16 -0700 Subject: [PATCH] fix(complexity_router): resolve custom-tier classifier replies case-insensitively An exact membership check meant a reply like "research" or " RESEARCH " for a defined RESEARCH tier was treated as unknown and rerouted to the fallback tier, while the built-in path already resolves labels case-insensitively. Custom tier names are validated unique case-insensitively at config write, so folding is unambiguous; the decision records the canonical configured name, never the model's spelling. --- .../complexity_router/complexity_router.py | 6 +++-- .../router_strategy/test_complexity_router.py | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index fdb5f19266b..1d1a0618ade 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -999,9 +999,11 @@ class ComplexityRouter(CustomLogger): raise ValueError("LLM classifier returned empty content") raw_tier: Final = _TierReply.model_validate_json(content).tier if self.config.has_custom_tiers: - if raw_tier not in self.config.tier_names(): + folded: Final = raw_tier.strip().casefold() + matched: Final = next((name for name in self.config.tier_names() if name.casefold() == folded), None) + if matched is None: raise ValueError(f"LLM classifier returned an unknown tier: {raw_tier!r}") - return raw_tier + return matched tier: Final = self.config.tier_for_label(raw_tier) if tier is None: raise ValueError(f"LLM classifier returned an unrecognized tier: {raw_tier!r}") diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 1ce52e18991..c6d705a03bb 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -5940,6 +5940,31 @@ class TestTierDefinitionsClassifier: assert "score" not in result.routing_decision assert "tier_boundaries" not in result.routing_decision + @pytest.mark.asyncio + @pytest.mark.parametrize( + "reply", + [ + pytest.param("research", id="different-case"), + pytest.param(" RESEARCH ", id="surrounding-whitespace"), + ], + ) + async def test_custom_tier_reply_resolves_case_insensitively_to_the_canonical_name( + self, custom_tier_router, mock_router_instance, reply + ): + """A model that answers in the wrong case or with stray whitespace still names a + defined tier, matching the built-in path's label resolution; rejecting it would + silently reroute the request to the fallback tier. The decision records the + canonical configured name, never the model's spelling.""" + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response(f'{{"tier": "{reply}"}}')) + result = await custom_tier_router.async_pre_routing_hook( + model="custom-tier-router", + request_kwargs={}, + messages=[{"role": "user", "content": "compare these two consensus protocols"}], + ) + assert result.model == "deep-model" + assert result.routing_decision["tier"] == "RESEARCH" + assert result.routing_decision["cause"] == "llm_classifier" + @pytest.mark.asyncio async def test_classifier_failure_routes_to_the_fallback_tier(self, custom_tier_router, mock_router_instance): """The heuristic scorer cannot produce a custom tier, so it must never be the