Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions litellm/router_strategy/complexity_router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,73 @@ Spend logs record `routing_decision.cause: heuristic_v2`, the detected request
type, and all four predicted probabilities. Existing `classifier_type: heuristic`
configurations keep the original weighted scorer unchanged

### Custom classifier plugins

`classifier_type: custom` hands the tier decision to a plugin of your own. The plugin is an
object with one `async classify(context) -> str | None` method, named in the config by the
dotted path to an instance. It receives a `RoutingContext` (the request messages, both raw
and normalized to chat-completions shape, the candidate models, and the request metadata
including caller identity) and returns the name of the tier to route to, or `None` to
decline. A decline, an exception, or a call that overruns `classifier_plugin_timeout_ms`
(default 3000) all fall through to `classifier_fallback`, so a classifier that is down
cannot fail a completion.

Everything downstream of the tier is unchanged: the tier's model pool, the provider call,
your provider keys, fallbacks, and spend tracking all stay on this proxy.

#### Nadir

`litellm.router_strategy.complexity_router.nadir_classifier.nadir_classifier` is a bundled
plugin that asks [Nadir](https://getnadir.com)'s `/v1/bucket` endpoint which of `simple` /
`medium` / `complex` a request is. The endpoint grades the request without generating an
answer, and the three buckets map to the SIMPLE / MEDIUM / COMPLEX tiers:

```yaml
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
classifier_fallback: heuristic
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 instead of the hosted API.
`NADIR_API_KEY` is only sent to that base, or to the hosted API when it is unset.

Renaming the tiers with `tier_labels` needs no change here: the router still accepts the
default tier names the plugin returns. A router built on `tier_definitions` names its own
tiers, so point `classifier_plugin` at an instance of your own that maps Nadir's buckets
onto them:

```python
# my_classifiers.py, named in the config as my_classifiers.nadir
from litellm.router_strategy.complexity_router.nadir_classifier import NadirComplexityClassifier

nadir = NadirComplexityClassifier(tier_map={"simple": "cheap", "medium": "standard", "complex": "premium"})
```

Two properties to weigh against the local scorers. The classification is a network call, so
it costs a round trip per request where `heuristic` and `heuristic_v2` cost under a
millisecond (a laptop against the hosted API measured about 160ms warm, and the first call
in a fresh process also pays connection setup; anything that overruns
`classifier_plugin_timeout_ms` routes on the fallback instead of waiting). And the messages
are sent to the configured Nadir host, which is a third party unless that host is yours, the
same disclosure `classifier_type: llm` carries when the classifier model is a hosted one. A
REASONING tier is never returned, since Nadir grades three buckets; keyword rules and the
reasoning override still place requests there.

### Renaming the tiers

`tier_labels` puts your own vocabulary on the four tiers:
Expand Down
152 changes: 152 additions & 0 deletions litellm/router_strategy/complexity_router/nadir_classifier.py
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:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
"""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()
Comment thread
doramirdor marked this conversation as resolved.
"""Module-level instance, so the proxy config can name it by dotted path."""
1 change: 1 addition & 0 deletions litellm/types/llms/custom_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class httpxSpecialProvider(str, Enum):
PasswordBreachCheck = "password_breach_check"
ASGI = "asgi"
AgentHarness = "agent_harness"
ComplexityClassifier = "complexity_classifier"


VerifyTypes = str | bool | ssl.SSLContext
Loading
Loading