Repository navigation
feat(complexity-router): add a bundled Nadir classifier plugin - #41797
doramirdor wants to merge 5 commits into
Conversation
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
4ebf716 to
3e4a0e4
Compare
3e4a0e4 to
adc6887
Compare
|
CI status, so the two red checks are not mistaken for code problems: 82 passing, 2 failing, 1 skipped, and both failures are the same cross-repo docs check.
The docs half is open at BerriAI/litellm-docs#1543 and is ready: two rows in the environment-variable reference plus a subsection under the existing custom classifier plugin docs, with an availability admonition naming this PR so the page reads correctly whichever merges first. With that branch checked out at Nothing else is outstanding on this side. |
adc6887 to
84d907e
Compare
|
@greptileai please re-review. The three findings from the first review (credential scoping, |
|
@greptileai please re-review. Two of the three first-review threads are fixed in 84d907e and resolved. The tier_definitions one is answered in its thread: it is a documented boundary, and a misconfigured router logs a warning and routes on its fallback. |
|
@krrish-berri-2 while you are in the Nadir code: this one is ready for review as well. It adds a bundled Nadir classifier plugin to the complexity router (decision only, no generation call). Mergeable against |
af2f2fe to
7b11bc5
Compare
classifier_type: custom already accepts any classifier that answers a tier. This ships one for Nadir's /v1/bucket endpoint, a trained complexity classifier rather than an LLM call, whose simple/medium/complex verdict maps to the SIMPLE/MEDIUM/COMPLEX tiers. A router names the module-level instance by dotted path; a renamed or custom tier set passes its own names as tier_map. Decision-only, so nothing moves off the proxy: the tier's model pool, the provider call, the operator's keys, fallbacks and spend tracking are unchanged, and Nadir sees only the messages it classifies. Every failure is the existing one, since a decline, an exception or a call past classifier_plugin_timeout_ms falls through to classifier_fallback. NADIR_API_KEY attributes decisions to an account and lifts the anonymous rate limit; NADIR_API_BASE points at a self-hosted deployment. The URL builder tolerates a base that already ends in /v1, which is how Nadir's own docs advertise it for OpenAI-compatible clients, so the common paste does not produce /v1/v1/bucket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ct the docs Addresses the Greptile review and aligns the plugin with the nadir/ provider that landed in BerriAI#33227. - NADIR_API_KEY only goes to the base the environment names (NADIR_API_BASE, else the hosted API), the same rule the provider applies. An api_base set in code brings its own api_key. - Reads NADIR_API_KEY / NADIR_API_BASE through get_secret_str and reuses NADIR_DEFAULT_API_BASE from litellm.constants instead of a second default. - The HTTP client is injected (defaults to the shared async client), so the tests pass a fake instead of patching get_async_httpx_client. - The verdict is read with a match on the parsed body. - Docs: tier_labels routers need no tier_map, since the router still accepts the default names; only tier_definitions routers build their own instance. States what a keyed account keeps and that the anonymous limit is sized for evaluation, drops "stored nowhere" and the "not an LLM call" wording, and uses current example models. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeQL flagged classify() for mixing explicit and implicit returns: it does not treat the trailing `case _` arm of the verdict match as exhaustive. Dropping that arm is not an option either, since basedpyright then reports reportMatchNotExhaustive, a rule budgeted at zero. The body is now validated as a Mapping[str, object] and the bucket read from it, the boundary pattern the repo already uses, so every path returns explicitly and the Any on the match subject is gone. Behavior is unchanged: a non-mapping body, a missing bucket or a non-string bucket still declines, which the existing unusable-verdict cases cover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7b11bc5 to
51492b3
Compare
|
@tin-berri @krrish-berri-2 rebased onto current @tin-berri this plugs in through the existing |
…aned BerriAI#43971 removed the LIT002 mutable-construction rule, so the `# mutable-ok` comments on the request headers and the request body no longer suppress anything, and type_discipline_gate reports each as an unused suppression (LIT013). The one on the tier_map dict still applies and stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cket /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>
|
Two small follow-ups on top of the rebase:
Locally: 214/214 across |
TLDR
Problem this solves:
classifier_type: customships no classifier, so every operator writes oneHow it solves it:
/v1/bucketfor the tierUser Flow
Before: a proxy admin who wants a trained complexity classifier has to write and host the plugin themselves
classifier_type: customincomplexity_router_configand have nothing to pointclassifier_pluginatlitellm.router_strategy.complexity_router.nadir_classifier.nadir_classifierfails the proxy at startup:ImportError: Could not import nadir_classifier from litellm.router_strategy.complexity_router.nadir_classifierAfter: the same admin names the bundled plugin and gets trained classification with no classifier model
classifier_type: customandclassifier_plugin: litellm.router_strategy.complexity_router.nadir_classifier.nadir_classifiertocomplexity_router_config, setNADIR_API_KEY, and restart the proxy"model": "smart-router"and"what is 2+2?", and it is answered by the SIMPLE tier modelrouting_decision.cause: classifier_pluginand the tier each request landed incause: heuristic_scorerinstead and the completion still succeedsRelevant issues
Docs companion: BerriAI/litellm-docs#1543 documents the plugin. CI no longer depends on it: the Nadir provider page merged in BerriAI/litellm-docs#861 already mentions
NADIR_API_KEYandNADIR_API_BASE, sotest_env_keys.pypasses here.Pre-Submission checklist
uv run pytest tests/test_litellm/<your_test_file>.py -vStatus: CI on af2f2fe: 97 passed, 1 skipped, 0 failed, and CodeQL reports no new alerts in the changed code. Greptile: 4/5 on af2f2fe. Its three first-review findings are resolved: credential scoping and the patched client are fixed in 84d907e, and it withdrew the
tier_definitionsone as a documented boundary. Its one open P2 asks for the Nadir HTTP handling to move underllms/; the reply in that thread points atjev_classifier.py, the router's other external classifier, which keeps the same layout. af2f2fe reads the verdict through aTypeAdapter, which clears the CodeQL mixed-returns note onclassify()without tripping basedpyright'sreportMatchNotExhaustive. Locally:test_nadir_classifier.py28 passed, andruff_strict_gate,type_discipline_gateandtest_quality_gatepass with--base c19ce71bcd.Screenshots / Proof of Fix
A real proxy, started from a checkout of each commit with the config below, with
NADIR_API_KEYset and the tier models on OpenRouter. The routing decision is a live call to Nadir's/v1/bucket, and the completions are live, billed calls to the tier models. Nothing is mocked.Before (c19ce71)
case 1: proxy startup
litellm --config config.yamlImportError: Could not import nadir_classifier from litellm.router_strategy.complexity_router.nadir_classifiercase 2: routed completions
After (af2f2fe)
case 1: proxy startup
litellm --config config.yaml --detailed_debugComplexityRouter initialized for smart-router with tiers: {'SIMPLE': 'openai/gpt-4o-mini', 'MEDIUM': 'openai/gpt-4.1', 'COMPLEX': 'anthropic/claude-sonnet-4.5'}. Startup builds the Router from this config, which deepcopies the plugin instance, andGET /health/livelinessanswers"I'm alive!"case 2: routed completions
curl -X POST http://localhost:4000/v1/chat/completions -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" -d '{"model": "smart-router", "messages": [{"role": "user", "content": "what is 2+2?"}], "max_tokens": 64}'HTTP 200, answer "2 + 2 = 4.", 14 in / 8 out,x-litellm-response-cost: 6.9e-06. Proxy log:ComplexityRouter: routing decision cause=classifier_plugin, tier=SIMPLE, score=n/a, signals=('classifier-plugin:SIMPLE',), routed_model=openai/gpt-4o-miniHTTP 200, a long design answer, 23 in / 2834 out,x-litellm-response-cost: 0.042579. Proxy log:ComplexityRouter: routing decision cause=classifier_plugin, tier=COMPLEX, score=n/a, signals=('classifier-plugin:COMPLEX',), routed_model=anthropic/claude-sonnet-4.5max_tokens: 64because the complexity router replaces the caller's value with the tier model's output ceiling by default (max_tokens_from_tier_model); the plugin does not touch itcase 3: unit tests
pytest tests/test_litellm/router_strategy/test_nadir_classifier.py -q28 passedType
🆕 New Feature
Caveats (if any)
heuristic/heuristic_v2. Anything pastclassifier_plugin_timeout_ms(default 3000) routes onclassifier_fallbackrather than waiting, so the cost of a slow or dead classifier is a fallback, never a failed completion.classifier_type: llmcarries with a hosted classifier model. Stated in the README section and the module docstring.tier_labelsrouter uses the module instance unchanged, since the router still accepts the default names it returns. Only atier_definitionsrouter needs its ownNadirComplexityClassifier(tier_map=...), defined in a module of its own and named by dotted path. An unmapped verdict declines rather than guesses.NADIR_API_KEYthe endpoint still answers, under a per-IP rate limit sized for trying the plugin out, and requests over it route onclassifier_fallback. With a key, that account's request log keeps the user text of each classified request, or only a hash of it when the account has prompt storage turned off.httpxSpecialProvidermember, so the call reuses litellm's cached async client instead of opening a private pool.classifier_pluginnames it.🤖 Generated with Claude Code