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 <noreply@anthropic.com>
This commit is contained in:
dor 2026-10-06 17:09:24 -04:00
parent 651bd044e3
commit df0714e1f7
2 changed files with 34 additions and 7 deletions

View file

@ -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()

View file

@ -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 "}))