From df0714e1f767296f03b99ea3a54de336251c40f8 Mon Sep 17 00:00:00 2001 From: dor Date: Tue, 6 Oct 2026 17:09:24 -0400 Subject: [PATCH] fix(complexity-router): route on Nadir's routing_tier, not the raw bucket /v1/bucket returns two tiers. `bucket` is the classifier's prediction; `routing_tier` is the tier Nadir itself routes on, after its deterministic adjustments: a one-tier bump when a low-confidence prediction carries real probability mass above it, the step-down for a complex-gate over-catch, and an on-prem calibrated confidence floor. The API documents `routing_tier` as the authoritative tier, so reading `bucket` skipped exactly the rules that guard against under-routing. The plugin now reads `routing_tier` and falls back to `bucket` only for a server that predates it, such as an older self-hosted image. An unknown `routing_tier` still declines rather than falling through to `bucket`. Co-Authored-By: Claude Opus 5.5 --- .../complexity_router/nadir_classifier.py | 18 +++++++++------ .../test_nadir_classifier.py | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/litellm/router_strategy/complexity_router/nadir_classifier.py b/litellm/router_strategy/complexity_router/nadir_classifier.py index 67a4bfe8c75..cd69c0a605b 100644 --- a/litellm/router_strategy/complexity_router/nadir_classifier.py +++ b/litellm/router_strategy/complexity_router/nadir_classifier.py @@ -6,11 +6,12 @@ endpoint, and everything else stays here. The tier's model pool, the provider ca operator's own provider keys, fallbacks, spend tracking and the response path are untouched, so Nadir sees the messages it is asked to classify and never the response. -``/v1/bucket`` grades the request without generating an answer and replies ``simple`` / -``medium`` / ``complex``, which map to the router's default SIMPLE / MEDIUM / COMPLEX -tiers. Renaming the tiers with ``tier_labels`` needs nothing here, since the router still -accepts the default names. A router built on ``tier_definitions`` names its own tiers, so -it constructs its own instance with a ``tier_map`` onto them. +``/v1/bucket`` grades the request without generating an answer and replies with a +``routing_tier`` of ``simple`` / ``medium`` / ``complex``, which map to the router's +default SIMPLE / MEDIUM / COMPLEX tiers. Renaming the tiers with ``tier_labels`` needs +nothing here, since the router still accepts the default names. A router built on +``tier_definitions`` names its own tiers, so it constructs its own instance with a +``tier_map`` onto them. Configure it in the proxy by pointing ``classifier_plugin`` at the module-level instance:: @@ -138,10 +139,13 @@ class NadirComplexityClassifier: body: Final = {"messages": list(messages), "source": "litellm"} response: Final = await client.post(url=url, json=body, headers=self._headers(url)) try: - bucket: Final = _VERDICT.validate_python(response.json()).get("bucket") + verdict: Final = _VERDICT.validate_python(response.json()) except ValidationError: return None - return self._tier_map.get(bucket.strip().lower()) if isinstance(bucket, str) else None + # ``routing_tier`` is the tier Nadir itself routes on: the classifier's bucket after its + # deterministic confidence and gate adjustments. A server that predates it sends only ``bucket``. + tier: Final = verdict.get("routing_tier") or verdict.get("bucket") + return self._tier_map.get(tier.strip().lower()) if isinstance(tier, str) else None nadir_classifier: Final = NadirComplexityClassifier() diff --git a/tests/unit/router_strategy/complexity_router/test_nadir_classifier.py b/tests/unit/router_strategy/complexity_router/test_nadir_classifier.py index 2af0824d4f4..b2e6fb427f2 100644 --- a/tests/unit/router_strategy/complexity_router/test_nadir_classifier.py +++ b/tests/unit/router_strategy/complexity_router/test_nadir_classifier.py @@ -154,6 +154,29 @@ class TestClassify: """Anything outside tier_map is declined, so classifier_fallback decides rather than this guessing.""" assert await NadirComplexityClassifier(client=_FakeClient(payload=payload)).classify(_context()) is None + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("payload", "expected_tier"), + [ + ({"bucket": "simple", "routing_tier": "medium"}, "MEDIUM"), + ({"bucket": "complex", "routing_tier": "medium"}, "MEDIUM"), + ({"bucket": "complex"}, "COMPLEX"), + ], + ) + async def test_routing_tier_wins_over_the_raw_bucket(self, payload, expected_tier): + """routing_tier carries Nadir's own confidence and gate adjustments; bucket is the pre-adjustment + prediction, read only from a server that predates routing_tier.""" + assert ( + await NadirComplexityClassifier(client=_FakeClient(payload=payload)).classify(_context()) == expected_tier + ) + + @pytest.mark.asyncio + async def test_unknown_routing_tier_declines_rather_than_reading_the_bucket(self): + classifier = NadirComplexityClassifier( + client=_FakeClient(payload={"bucket": "simple", "routing_tier": "reasoning"}) + ) + assert await classifier.classify(_context()) is None + @pytest.mark.asyncio async def test_bucket_name_is_matched_case_insensitively(self): classifier = NadirComplexityClassifier(client=_FakeClient(payload={"bucket": " Complex "}))