Repository navigation
feat(complexity-router): add a bundled Nadir classifier plugin #41797
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
doramirdor
wants to merge
5
commits into
BerriAI:main
Choose a base branch
from
doramirdor:nadir-classifier-plugin
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+464
−0
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e460907
feat(complexity-router): add a bundled Nadir classifier plugin
doramirdor 6789b2c
fix(complexity-router): scope the Nadir key, inject the client, corre…
doramirdor 51492b3
fix(complexity-router): read the Nadir verdict through a TypeAdapter
doramirdor 651bd04
chore(complexity-router): drop two suppressions LIT002's removal orph…
doramirdor df0714e
fix(complexity-router): route on Nadir's routing_tier, not the raw bu…
doramirdor File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
152 changes: 152 additions & 0 deletions
152
litellm/router_strategy/complexity_router/nadir_classifier.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| """ | ||
| Nadir as the Complexity Router's classifier. | ||
|
|
||
| A decision-only integration: the tier comes from a call to Nadir's ``/v1/bucket`` | ||
| endpoint, and everything else stays here. The tier's model pool, the provider call, the | ||
| 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 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:: | ||
|
|
||
| model_list: | ||
| - model_name: smart-router | ||
| litellm_params: | ||
| model: auto_router/complexity_router | ||
| complexity_router_config: | ||
| classifier_type: custom | ||
| classifier_plugin: litellm.router_strategy.complexity_router.nadir_classifier.nadir_classifier | ||
| tiers: | ||
| SIMPLE: gpt-5-nano | ||
| MEDIUM: gpt-5-mini | ||
| COMPLEX: gpt-5 | ||
|
|
||
| ``NADIR_API_KEY`` attributes decisions to a Nadir account and lifts the anonymous rate | ||
| limit. That account's request log then keeps the user text of each classified request, or | ||
| only a hash of it when the account has prompt storage turned off. Without a key the | ||
| endpoint still answers and keeps no prompt, but its per-IP rate limit is sized for trying | ||
| the plugin out, and requests over it route on ``classifier_fallback``. | ||
|
|
||
| ``NADIR_API_BASE`` points at a self-hosted or on-prem Nadir. ``NADIR_API_KEY`` is only | ||
| ever sent to that base, or to the hosted API when it is unset: an ``api_base`` passed in | ||
| code brings its own ``api_key``. | ||
|
|
||
| Every failure mode is the router's existing one: this returns None to decline, and a | ||
| network error, an error status, a timeout past ``classifier_plugin_timeout_ms`` or an | ||
| unknown bucket all hand the request to ``classifier_fallback``. Nothing here can fail a | ||
| completion. | ||
|
|
||
| Privacy note for operators: the messages are sent to the configured Nadir host, which is | ||
| a third party unless that host is your own. That is the same disclosure a remote LLM | ||
| classifier carries, and it is the reason the local heuristic scorers exist. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Mapping | ||
| from types import MappingProxyType | ||
| from typing import Final | ||
|
|
||
| from pydantic import TypeAdapter, ValidationError | ||
|
|
||
| from litellm.constants import NADIR_DEFAULT_API_BASE | ||
| from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client | ||
| from litellm.secret_managers.main import get_secret_str | ||
| from litellm.types.llms.custom_http import httpxSpecialProvider | ||
| from litellm.types.router import RoutingContext | ||
|
|
||
| DEFAULT_TIER_MAP: Final[Mapping[str, str]] = MappingProxyType( | ||
| {"simple": "SIMPLE", "medium": "MEDIUM", "complex": "COMPLEX"} | ||
| ) | ||
|
|
||
| _API_PATH: Final = "/v1/bucket" | ||
| _VERDICT: Final = TypeAdapter(Mapping[str, object]) | ||
|
|
||
|
|
||
| def _bucket_url(api_base: str) -> str: | ||
| """Join the endpoint path to a host, tolerating a base that already ends in ``/v1``. | ||
|
|
||
| Nadir's own docs advertise ``https://api.getnadir.com/v1`` as the base URL, because the | ||
| OpenAI-compatible clients that consume it append ``/chat/completions``. An operator who | ||
| copies that value into ``NADIR_API_BASE`` would otherwise send ``/v1/v1/bucket`` and get a | ||
| 404 that reads like the endpoint does not exist. | ||
| """ | ||
| base: Final = api_base.rstrip("/").removesuffix("/v1") | ||
| return f"{base}{_API_PATH}" | ||
|
|
||
|
|
||
| def _configured_bucket_url() -> str: | ||
| """The endpoint the environment names: ``NADIR_API_BASE``, else the hosted API.""" | ||
| return _bucket_url(get_secret_str("NADIR_API_BASE") or NADIR_DEFAULT_API_BASE) | ||
|
|
||
|
|
||
| class NadirComplexityClassifier: | ||
| """Classifier plugin that asks Nadir's decision API which tier a request belongs to. | ||
|
|
||
| Args: | ||
| api_base: Nadir host. Defaults to ``NADIR_API_BASE``, then to the hosted API. | ||
| api_key: Nadir API key. Defaults to ``NADIR_API_KEY`` only when the request goes to the | ||
| host the environment names, so the environment key never follows an ``api_base`` | ||
| set in code; anonymous otherwise. | ||
| tier_map: Nadir bucket name -> the tier name this router routes on. Defaults to the | ||
| router's built-in tier names. | ||
| client: HTTP client the request goes out on. Defaults to litellm's shared async client. | ||
| Leave it unset on an instance a proxy config names, since the proxy deepcopies it. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| api_base: str | None = None, | ||
| api_key: str | None = None, | ||
| tier_map: Mapping[str, str] | None = None, | ||
| client: AsyncHTTPHandler | None = None, | ||
| ) -> None: | ||
| self._api_base: Final = api_base | ||
| self._api_key: Final = api_key | ||
| self._client: Final = client | ||
| # A plain dict, not a MappingProxyType: the proxy deepcopies a deployment's | ||
| # litellm_params, this instance travels inside them, and a mappingproxy cannot be | ||
| # deepcopied. Router construction would fail before the first request. | ||
| self._tier_map: Final[dict[str, str]] = dict( # mutable-ok: read-only after __init__, deepcopy-safe | ||
| DEFAULT_TIER_MAP if tier_map is None else tier_map | ||
| ) | ||
|
|
||
| def _headers(self, url: str) -> dict[str, str]: | ||
| env_key: Final = get_secret_str("NADIR_API_KEY") if url == _configured_bucket_url() else None | ||
| api_key: Final = self._api_key or env_key | ||
| return {"X-API-Key": api_key} if api_key else {} | ||
|
|
||
| async def classify(self, context: RoutingContext) -> str | None: | ||
| """Return the tier Nadir places this request in, or None to decline. | ||
|
|
||
| Declines rather than guesses on anything the endpoint cannot grade: a request with no | ||
| messages, and a bucket name outside ``tier_map`` (which is what a ``tier_definitions`` | ||
| router looks like before ``tier_map`` is configured). Both leave the decision to | ||
| ``classifier_fallback``, where a local scorer still routes the request. | ||
| """ | ||
| messages: Final = context.structured_messages or context.raw_messages | ||
| if not messages: | ||
| return None | ||
| url: Final = _bucket_url(self._api_base) if self._api_base else _configured_bucket_url() | ||
| client: Final = self._client or get_async_httpx_client(llm_provider=httpxSpecialProvider.ComplexityClassifier) | ||
| body: Final = {"messages": list(messages), "source": "litellm"} | ||
| response: Final = await client.post(url=url, json=body, headers=self._headers(url)) | ||
| try: | ||
| verdict: Final = _VERDICT.validate_python(response.json()) | ||
| except ValidationError: | ||
| return 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() | ||
|
doramirdor marked this conversation as resolved.
|
||
| """Module-level instance, so the proxy config can name it by dotted path.""" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.