litellm/tests/test_litellm/router_strategy/test_quality_router.py
Krrish Dholakia e7bc316db0
Litellm krrish staging 04 20 2026 (#26138)
* feat(router): add auto_router/quality_router for quality-tier routing (#25987)

* feat(router): add auto_router/quality_router for quality-tier routing

Adds a new auto-router type that routes a request to a model at a target
quality tier. The quality tier is inferred by re-using the existing
ComplexityRouter's classification, then mapped through an admin-configured
complexity_to_quality table. Each candidate model declares its own
quality_tier in model_info.litellm_routing_preferences.

Resolution strategy: exact tier match, else round up to the next higher
tier, else fall back to default_model.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* feat(quality_router): add capability-based filtering

Each deployment can declare a `capabilities: List[str]` field in
`model_info.litellm_routing_preferences` (e.g. ["vision",
"function_calling"]). Requests can pass `litellm_capabilities` in
`request_kwargs` to require specific capabilities — the router will only
route to deployments whose declared capabilities are a superset.

Resolution still walks tier (exact → round up), but at each tier filters
by capability before picking. Falls back to default_model only when it
also satisfies the required capabilities; otherwise raises rather than
silently routing to a model that lacks a required capability.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* feat(quality_router): expose routing decision in response headers

For transparency, expose the QualityRouter's routing decision in the
proxy response headers:

  x-litellm-quality-router-model       → picked model_name (e.g. "haiku-vision")
  x-litellm-quality-router-tier        → resolved quality tier (e.g. "1")
  x-litellm-quality-router-complexity  → ComplexityTier name (e.g. "SIMPLE")

Mechanism: the pre-routing hook stashes the decision in
request_kwargs["metadata"]["quality_router_decision"]. After the call
returns, Router.set_response_headers lifts the decision into
response._hidden_params["additional_headers"] alongside the existing
x-litellm-model-group / x-litellm-model-id headers. Existing metadata
keys (trace_id, user_id, etc.) are preserved.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* feat(quality_router): replace capabilities with keyword override

Drops the capability-based filtering in favor of a keyword-based override
for v0:

- RoutingPreferences.keywords: List[str] (replaces capabilities) — each
  deployment can declare substring keywords.
- If any declared keyword (case-insensitive) appears in the user message,
  the router short-circuits the complexity-classification flow and routes
  to the matching deployment.
- Tiebreaker for overlapping keyword matches: quality_tier DESC, then
  cheapest model_info.input_cost_per_token ASC. Unpriced models lose ties
  to priced ones.

Decision metadata + headers now expose the override:
  x-litellm-quality-router-via       → "keyword" | "quality_tier"
  x-litellm-quality-router-keyword   → matched keyword (only on keyword route)
  x-litellm-quality-router-complexity → complexity tier (only on tier route)

Removes:
- request_kwargs["litellm_capabilities"] reading
- _model_capabilities, _model_supports_capabilities,
  _first_capable_model_at_tier, capability filter in
  _resolve_model_for_quality_tier

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* feat(quality_router): add explicit `order` to RoutingPreferences

Adds an explicit priority field to RoutingPreferences for resolving
collisions deterministically:

  RoutingPreferences.order: Optional[int]   # lower wins; unset = +inf

Used as the PRIMARY tiebreaker in two places:

1. Keyword overlap: when multiple deployments declare the same matching
   keyword, sort by (order ASC, quality_tier DESC, input_cost_per_token
   ASC, model_name ASC). Explicit always beats implicit.

2. Tier resolution: when multiple deployments share a quality tier,
   `_resolve_model_for_quality_tier` picks the one with the lowest
   order. The tier list is now sorted at index-build time.

This lets admins make routing decisions explicit when the natural
quality-and-price ordering would pick the wrong model.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* feat(quality_router): reorder tiebreak to (quality, order, price)

Changes the tiebreak ordering so quality_tier always wins first, then
explicit `order` is used to break ties within the same tier, then price
breaks the rest:

  1. quality_tier DESC      ← best model wins first
  2. order ASC              ← explicit priority within a tier
  3. input_cost_per_token ASC
  4. model_name ASC

Previously `order` was the primary key — that meant a tier-2 model with
`order=1` would beat a tier-3 model with no `order`, which is the wrong
default. Now `order` only resolves collisions among same-tier candidates.

Tier resolution (within a single tier) keeps the same key minus quality:
(order ASC, cost ASC, name).

Test renames + flips:
  - test_explicit_order_overrides_quality_tier → test_quality_wins_over_explicit_order
  - new: test_order_breaks_tie_within_same_quality_tier

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* fix(quality_router): resolve Greptile review feedback

Addresses four P1 findings from PR review plus test coverage:

1. set_model_list missing quality_routers reset
   - Hot-reloading the Router would leave stale QualityRouter instances
     pointing at the old model_list. `set_model_list` now clears
     `self.quality_routers` alongside the other indices.

2. Round-down fallback before default_model
   - `_resolve_model_for_quality_tier` now rounds DOWN to the closest
     lower tier after round-up fails, before falling back to
     `default_model`. Degrades gracefully rather than jumping straight
     off-tier.

3. RoutingPreferences validation bypass
   - `_build_tier_index` now instantiates `RoutingPreferences(**prefs)`
     so invalid shapes (e.g. non-int quality_tier) raise a clear
     ValueError instead of silently succeeding.

4. Config-ordering dependency
   - `_tier_to_models` is now built lazily on first access. Previously,
     eager construction in `__init__` meant a QualityRouter deployment
     had to appear AFTER all its referenced models in config.yaml,
     because `Router._create_deployment` populates `model_list`
     incrementally. Any `available_models` defined after the router
     entry would silently be reported as missing.

Also adds 6 new tests covering each fix:
- test_invalid_quality_tier_type_raises_clear_error
- test_router_can_be_instantiated_before_its_targets_exist
- test_set_model_list_clears_quality_routers_registry
- test_rounds_down_when_no_higher_tier_exists
- test_rounds_down_prefers_closest_lower_tier
- test_prefers_round_up_over_round_down

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* style: apply black 24.10.0 formatting to pre-existing offenders

Unblocks the LiteLLM Linting check for this PR — these 12 files are already
failing `black --check` on main (the lint workflow only runs on PRs, so main
drifts). No behavior changes; formatting-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update litellm/router.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Support /v1/responses in complexity router (#26137)

* feat(proxy): add --reload flag for uvicorn hot reload (dev only)

Opt-in CLI flag, off by default, no env var. Only affects the uvicorn
run path; gunicorn/hypercorn paths and prod (which doesn't pass the
flag) are unaffected.

* Feature/add audio support for scaleway (#26110)

* feat(scaleway): add SCALEWAY to LlmProviders enum

* feat(scaleway): add audio transcription config and dispatch wiring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(scaleway): add behavior tests for audio transcription config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(scaleway): advertise audio_transcriptions in endpoint-support JSON

* docs(scaleway): document audio transcription support

* fix(scaleway): address PR review — plain-text response_format + missing-key fail-fast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(scaleway): cover new response paths, drop gettysburg.wav coupling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* Prompt Compression - add it to the proxy (#25729)

* refactor: new agentic loop event hook

simplifies how to create logic for tool based multi llm calls

* fix: compress - make it work on anthropic input as well

* fix(compress.py): working prompt compression for claude code

ensures claude code messages can run through proxy easily

* docs: add agentic loop hook guide

* docs: add agentic_loop_hook to sidebar

* fix: fix multiple arguments error

* fix: fix tool call loop for compression on streaming /v1/messages

* fix: fix linting errors

* fix: fix ci/cd errors

* feat(litellm_pre_call_utils.py): use claude code session for litellm session id

allows claude code logs to be stitched together, making it easy to know they were all part of the same conversation

* fix: suppress incorrect mypy warning rE: module

* revert: drop PR's changes to litellm/proxy/_experimental/out/

Restores the 34 HTML files under _experimental/out/ to their pre-PR
paths (X/index.html -> X.html). All renames are R100 (content
unchanged); no other files are touched.

* fix: address greptile review comments on PR #25729

- Skip ``kwargs["tools"] = []`` injection when compression is a no-op —
  Anthropic Messages rejects empty tool arrays on requests that did not
  originally declare tools.
- Move agentic-loop safety guards (fingerprint cycle / max depth) out of
  the per-callback try/except so they propagate instead of being swallowed
  by the generic exception handler. Extracted _check_agentic_loop_safety.
- Gate generic ``x-<vendor>-session-id`` capture behind the
  LITELLM_CAPTURE_VENDOR_SESSION_HEADERS env var (off by default) to
  preserve backwards compatibility; explicit x-litellm-* headers are
  unaffected.
- Fix monkeypatch target in pre-call-hook test to patch the actual
  module-level binding
  (litellm.integrations.compression_interception.handler.compress).
- Add regression tests for empty-tools skip and opt-in session capture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* revert: drop LITELLM_CAPTURE_VENDOR_SESSION_HEADERS flag

Generic x-<vendor>-session-id header capture is a new feature and only
runs *after* the explicit x-litellm-trace-id / x-litellm-session-id
checks, so it does not change behavior for any existing caller that was
already using the LiteLLM headers — no backwards-incompatibility to gate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(compress): replace input_type with CallTypes call_type

Drop the bespoke ``CompressionInputType`` literal and use the existing
``litellm.types.utils.CallTypes`` enum instead.  ``litellm.compress()``
now takes ``call_type: Union[CallTypes, str]`` (default
``CallTypes.completion``) — no new concept to learn, and the enum is
already the way the rest of the codebase talks about request shapes.

Supported values: ``completion`` / ``acompletion`` (OpenAI chat-completions
shape) and ``anthropic_messages`` (Anthropic structured content blocks).

Updated: compress(), the compression_interception handler, tests, docs,
and the two eval scripts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Support /v1/responses in complexity router

Adds cross-format support to the complexity router via the guardrail
translation handler dispatch. Adds get_structured_messages to base
translation plus OpenAI chat, Responses, and Anthropic handlers.
Auto-router helper _extract_text_from_messages handles tool-call and
multimodal messages. Widens async_pre_routing_hook messages type to
Dict[str, Any].

Fixes https://github.com/BerriAI/litellm/issues/25134

* chore: apply black formatting

* fix: fallback to trying each handler when route inference fails

---------

Co-authored-by: Ryan Crabbe <ryan@berri.ai>
Co-authored-by: nhyy244 <106547304+nhyy244@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: cover _is_quality_router_deployment and init_quality_router_deployment

* fix: reset auto_routers on set_model_list to prevent hot-reload ValueError

* style: apply black formatting to websearch_interception and agentic_streaming_iterator

---------

Co-authored-by: yuneng-jiang <yuneng@berri.ai>
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Ryan Crabbe <ryan@berri.ai>
Co-authored-by: nhyy244 <106547304+nhyy244@users.noreply.github.com>
2026-04-20 16:22:12 -07:00

1033 lines
36 KiB
Python

"""
Tests for the QualityRouter.
Covers:
- Tier index construction from `model_info.litellm_routing_preferences`.
- Quality-tier resolution (exact, round-up, default fallback).
- Keyword override (match, tiebreaking by quality + price).
- Pre-routing hook end-to-end.
- Decision metadata stash + Router.set_response_headers lift.
"""
import os
import sys
from typing import Any, Dict, List
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, os.path.abspath("../../.."))
from litellm.router_strategy.quality_router.config import (
DEFAULT_COMPLEXITY_TO_QUALITY,
)
from litellm.router_strategy.quality_router.quality_router import QualityRouter
def _make_model_list(spec: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Build a router model_list from a compact spec.
spec entry shape: {
"model_name": str,
"quality_tier": Optional[int],
"keywords": Optional[List[str]],
"order": Optional[int],
"input_cost_per_token": Optional[float],
}
If quality_tier is None, the deployment is created without
`litellm_routing_preferences`.
"""
out: List[Dict[str, Any]] = []
for entry in spec:
model_info: Dict[str, Any] = {"id": f"id-{entry['model_name']}"}
if entry.get("quality_tier") is not None:
prefs: Dict[str, Any] = {"quality_tier": entry["quality_tier"]}
if "keywords" in entry:
prefs["keywords"] = entry["keywords"]
if "order" in entry:
prefs["order"] = entry["order"]
model_info["litellm_routing_preferences"] = prefs
if "input_cost_per_token" in entry:
model_info["input_cost_per_token"] = entry["input_cost_per_token"]
out.append(
{
"model_name": entry["model_name"],
"litellm_params": {"model": f"openai/{entry['model_name']}"},
"model_info": model_info,
}
)
return out
@pytest.fixture
def four_tier_model_list() -> List[Dict[str, Any]]:
"""A standard haiku(1)/sonnet(2)/opus(3)/opus-next(4) model list."""
return _make_model_list(
[
{"model_name": "haiku", "quality_tier": 1},
{"model_name": "sonnet", "quality_tier": 2},
{"model_name": "opus", "quality_tier": 3},
{"model_name": "opus-next", "quality_tier": 4},
]
)
@pytest.fixture
def mock_router(four_tier_model_list):
"""A MagicMock router preloaded with the four-tier model list."""
router = MagicMock()
router.model_list = four_tier_model_list
return router
@pytest.fixture
def quality_router(mock_router) -> QualityRouter:
"""Default QualityRouter wired to all four tiers."""
config = {
"available_models": ["haiku", "sonnet", "opus", "opus-next"],
"complexity_to_quality": DEFAULT_COMPLEXITY_TO_QUALITY,
}
return QualityRouter(
model_name="quality-router-test",
litellm_router_instance=mock_router,
default_model="haiku",
quality_router_config=config,
)
# ─── Tier index ─────────────────────────────────────────────────────────────
class TestTierIndex:
def test_builds_correct_tier_to_models_map(self, quality_router):
assert quality_router._tier_to_models == {
1: ["haiku"],
2: ["sonnet"],
3: ["opus"],
4: ["opus-next"],
}
def test_ignores_models_not_in_available_models(self, four_tier_model_list):
# Add a model the config doesn't list — it should be ignored.
extra = _make_model_list([{"model_name": "ghost", "quality_tier": 5}])
router = MagicMock()
router.model_list = four_tier_model_list + extra
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="haiku",
quality_router_config={
"available_models": ["haiku", "sonnet", "opus", "opus-next"]
},
)
for models in qr._tier_to_models.values():
assert "ghost" not in models
def test_raises_when_routing_preferences_missing(self):
# `sonnet` is in available_models but has no preferences.
ml = _make_model_list(
[
{"model_name": "haiku", "quality_tier": 1},
{"model_name": "sonnet", "quality_tier": None},
]
)
router = MagicMock()
router.model_list = ml
# Construction succeeds (tier index is lazy); the error surfaces on
# first use so the router entry doesn't have to appear after all of
# its referenced models in config.yaml.
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="haiku",
quality_router_config={"available_models": ["haiku", "sonnet"]},
)
with pytest.raises(ValueError, match="sonnet"):
_ = qr._tier_to_models
# ─── Resolve model for quality tier ─────────────────────────────────────────
class TestResolveModelForQualityTier:
def test_exact_match(self, quality_router):
assert quality_router._resolve_model_for_quality_tier(2) == "sonnet"
assert quality_router._resolve_model_for_quality_tier(4) == "opus-next"
def test_rounds_up_when_tier_missing(self, mock_router):
# Available tiers: 1, 3, 4. Asking for 2 should round up to 3.
spec = [
{"model_name": "haiku", "quality_tier": 1},
{"model_name": "opus", "quality_tier": 3},
{"model_name": "opus-next", "quality_tier": 4},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="haiku",
quality_router_config={"available_models": ["haiku", "opus", "opus-next"]},
)
assert qr._resolve_model_for_quality_tier(2) == "opus"
def test_rounds_down_when_no_higher_tier_exists(self):
# Only tier 1 available. Asking for tier 4 rounds up (nothing), then
# rounds DOWN to the closest lower tier — tier 1.
spec = [{"model_name": "haiku", "quality_tier": 1}]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="emergency-default",
quality_router_config={"available_models": ["haiku"]},
)
assert qr._resolve_model_for_quality_tier(4) == "haiku"
def test_rounds_down_prefers_closest_lower_tier(self):
# Available: 1, 2. Asking for 4 rounds down to tier 2 (not tier 1).
spec = [
{"model_name": "haiku", "quality_tier": 1},
{"model_name": "sonnet", "quality_tier": 2},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="emergency-default",
quality_router_config={"available_models": ["haiku", "sonnet"]},
)
assert qr._resolve_model_for_quality_tier(4) == "sonnet"
def test_prefers_round_up_over_round_down(self):
# Available: 1, 3. Asking for 2 rounds UP to 3, not DOWN to 1.
spec = [
{"model_name": "haiku", "quality_tier": 1},
{"model_name": "opus", "quality_tier": 3},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="emergency-default",
quality_router_config={"available_models": ["haiku", "opus"]},
)
assert qr._resolve_model_for_quality_tier(2) == "opus"
# ─── RoutingPreferences validation ─────────────────────────────────────────
class TestRoutingPreferencesValidation:
def test_invalid_quality_tier_type_raises_clear_error(self):
# quality_tier must be an int — pass a non-coercible string.
ml = [
{
"model_name": "haiku",
"litellm_params": {"model": "openai/gpt-4o-mini"},
"model_info": {
"id": "id-haiku",
"litellm_routing_preferences": {"quality_tier": "not-an-int"},
},
}
]
router = MagicMock()
router.model_list = ml
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="haiku",
quality_router_config={"available_models": ["haiku"]},
)
with pytest.raises(ValueError, match="invalid litellm_routing_preferences"):
_ = qr._tier_to_models
# ─── Config-ordering independence (lazy index build) ───────────────────────
class TestConfigOrderingIndependence:
def test_router_can_be_instantiated_before_its_targets_exist(self):
# Build a router instance whose referenced model_list is EMPTY at
# construction time (simulating a config where the router entry
# appears before its target deployments). The tier index must not be
# built eagerly — it's deferred until first use.
router = MagicMock()
router.model_list = [] # <- targets haven't been added yet
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="haiku",
quality_router_config={"available_models": ["haiku", "sonnet", "opus"]},
)
# Now the targets come online. This mirrors the incremental add by
# `Router._create_deployment`.
router.model_list = _make_model_list(
[
{"model_name": "haiku", "quality_tier": 1},
{"model_name": "sonnet", "quality_tier": 2},
{"model_name": "opus", "quality_tier": 3},
]
)
# First access triggers the index build and sees the full list.
assert qr._tier_to_models == {
1: ["haiku"],
2: ["sonnet"],
3: ["opus"],
}
# ─── Router.set_model_list resets quality_routers (hot reload) ─────────────
class TestSetModelListResetsQualityRouters:
def test_set_model_list_clears_quality_routers_registry(self):
from litellm.router import Router
router = Router(
model_list=[
{
"model_name": "haiku",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-test",
},
"model_info": {"litellm_routing_preferences": {"quality_tier": 1}},
},
{
"model_name": "my-qr",
"litellm_params": {
"model": "auto_router/quality_router",
"quality_router_default_model": "haiku",
"quality_router_config": {"available_models": ["haiku"]},
},
},
]
)
assert "my-qr" in router.quality_routers
# Hot-reload with a new model_list that doesn't define the router.
router.set_model_list(
[
{
"model_name": "haiku",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-test",
},
}
]
)
# Stale router from before must be cleared.
assert "my-qr" not in router.quality_routers
# ─── Pre-routing hook ───────────────────────────────────────────────────────
class TestPreRoutingHook:
@pytest.mark.asyncio
async def test_simple_message_routes_to_tier_1(self, quality_router):
messages = [{"role": "user", "content": "hi"}]
resp = await quality_router.async_pre_routing_hook(
model="quality-router-test",
request_kwargs={},
messages=messages,
)
assert resp is not None
assert resp.model == "haiku"
@pytest.mark.asyncio
async def test_reasoning_message_routes_to_tier_4(self, quality_router):
# Two reasoning markers triggers ComplexityTier.REASONING → quality 4.
messages = [
{
"role": "user",
"content": (
"Think step by step and reason through this problem. "
"Analyze this carefully and break down each component."
),
}
]
resp = await quality_router.async_pre_routing_hook(
model="quality-router-test",
request_kwargs={},
messages=messages,
)
assert resp is not None
assert resp.model == "opus-next"
@pytest.mark.asyncio
async def test_empty_messages_returns_none(self, quality_router):
resp = await quality_router.async_pre_routing_hook(
model="quality-router-test",
request_kwargs={},
messages=[],
)
assert resp is None
@pytest.mark.asyncio
async def test_only_system_message_routes_to_default(self, quality_router):
messages = [{"role": "system", "content": "You are a helpful assistant."}]
resp = await quality_router.async_pre_routing_hook(
model="quality-router-test",
request_kwargs={},
messages=messages,
)
assert resp is not None
assert resp.model == "haiku" # the configured default_model
# ─── Keyword override ──────────────────────────────────────────────────────
@pytest.fixture
def keyword_router():
"""
Router where multiple deployments declare overlapping keywords so we can
exercise the (quality DESC, price ASC) tiebreak.
- cheap-coder tier 2, keywords [code, python], cost 0.000001
- smart-coder tier 3, keywords [code, python], cost 0.000010
- law-bot tier 2, keywords [legal, contract], cost 0.000005
- default-haiku tier 1, no keywords, cost 0.0000005
"""
spec = [
{
"model_name": "default-haiku",
"quality_tier": 1,
"keywords": [],
"input_cost_per_token": 0.0000005,
},
{
"model_name": "cheap-coder",
"quality_tier": 2,
"keywords": ["code", "python"],
"input_cost_per_token": 0.000001,
},
{
"model_name": "smart-coder",
"quality_tier": 3,
"keywords": ["code", "python"],
"input_cost_per_token": 0.000010,
},
{
"model_name": "law-bot",
"quality_tier": 2,
"keywords": ["legal", "contract"],
"input_cost_per_token": 0.000005,
},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
return QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="default-haiku",
quality_router_config={
"available_models": [
"default-haiku",
"cheap-coder",
"smart-coder",
"law-bot",
],
},
)
class TestKeywordOverride:
def test_no_keyword_in_message_returns_none(self, keyword_router):
assert keyword_router._keyword_override("hello there") is None
def test_single_match_returns_that_model(self, keyword_router):
# Only law-bot declares "legal".
assert keyword_router._keyword_override("review this legal doc") == (
"law-bot",
"legal",
)
def test_case_insensitive_match(self, keyword_router):
assert keyword_router._keyword_override("LEGAL question") == (
"law-bot",
"legal",
)
def test_overlap_picks_highest_quality_tier(self, keyword_router):
# Both cheap-coder (tier 2) and smart-coder (tier 3) declare "code".
# Quality wins over price → smart-coder.
assert keyword_router._keyword_override("write some code for me") == (
"smart-coder",
"code",
)
def test_same_tier_picks_cheapest(self):
# Two models at the same tier, both matching "data" — cheapest wins.
spec = [
{
"model_name": "expensive",
"quality_tier": 2,
"keywords": ["data"],
"input_cost_per_token": 0.000050,
},
{
"model_name": "cheap",
"quality_tier": 2,
"keywords": ["data"],
"input_cost_per_token": 0.000005,
},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="cheap",
quality_router_config={"available_models": ["expensive", "cheap"]},
)
match = qr._keyword_override("show me the data")
assert match == ("cheap", "data")
def test_unpriced_loses_to_priced_at_same_tier(self):
# Same quality tier, one has cost, one doesn't → priced wins.
spec = [
{
"model_name": "no-price",
"quality_tier": 2,
"keywords": ["data"],
# input_cost_per_token deliberately omitted
},
{
"model_name": "with-price",
"quality_tier": 2,
"keywords": ["data"],
"input_cost_per_token": 0.000005,
},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="no-price",
quality_router_config={"available_models": ["no-price", "with-price"]},
)
match = qr._keyword_override("show me the data")
assert match == ("with-price", "data")
@pytest.mark.asyncio
async def test_hook_short_circuits_complexity_on_keyword_match(
self, keyword_router
):
# A reasoning-style prompt would normally route to a high-quality model
# via the complexity flow — but the keyword "code" should short-circuit
# to smart-coder (highest tier among "code" models).
messages = [
{
"role": "user",
"content": (
"Think step by step and reason through this code problem. "
"Analyze this carefully and break down each component."
),
}
]
request_kwargs: Dict[str, Any] = {}
resp = await keyword_router.async_pre_routing_hook(
model="qr",
request_kwargs=request_kwargs,
messages=messages,
)
assert resp is not None
assert resp.model == "smart-coder"
decision = request_kwargs["metadata"]["quality_router_decision"]
assert decision["routed_via"] == "keyword"
assert decision["matched_keyword"] == "code"
assert decision["complexity_tier"] is None # short-circuited
def test_quality_wins_over_explicit_order(self):
# Quality always beats order. A tier-3 model with no `order` wins over
# a tier-2 model with `order=1`.
spec = [
{
"model_name": "ordered-tier2",
"quality_tier": 2,
"keywords": ["code"],
"order": 1,
"input_cost_per_token": 0.000010,
},
{
"model_name": "implicit-tier3",
"quality_tier": 3,
"keywords": ["code"],
"input_cost_per_token": 0.000005,
},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="ordered-tier2",
quality_router_config={
"available_models": ["ordered-tier2", "implicit-tier3"]
},
)
match = qr._keyword_override("write some code")
assert match == ("implicit-tier3", "code")
def test_order_breaks_tie_within_same_quality_tier(self):
# Two tier-3 models, both match "code". Lower `order` wins.
spec = [
{
"model_name": "preferred",
"quality_tier": 3,
"keywords": ["code"],
"order": 1,
"input_cost_per_token": 0.000050, # more expensive
},
{
"model_name": "default-tier3",
"quality_tier": 3,
"keywords": ["code"],
"input_cost_per_token": 0.000005, # cheaper
},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="default-tier3",
quality_router_config={"available_models": ["preferred", "default-tier3"]},
)
match = qr._keyword_override("write some code")
assert match == ("preferred", "code")
def test_explicit_order_overrides_price(self):
# Same tier, but the more expensive one has a lower `order` and wins.
spec = [
{
"model_name": "expensive-but-preferred",
"quality_tier": 2,
"keywords": ["data"],
"order": 1,
"input_cost_per_token": 0.000050,
},
{
"model_name": "cheap-default",
"quality_tier": 2,
"keywords": ["data"],
"input_cost_per_token": 0.000005,
},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="cheap-default",
quality_router_config={
"available_models": ["expensive-but-preferred", "cheap-default"]
},
)
match = qr._keyword_override("show me the data")
assert match == ("expensive-but-preferred", "data")
def test_lower_order_wins_between_two_explicitly_ordered(self):
spec = [
{
"model_name": "second",
"quality_tier": 2,
"keywords": ["data"],
"order": 5,
},
{
"model_name": "first",
"quality_tier": 2,
"keywords": ["data"],
"order": 1,
},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="first",
quality_router_config={"available_models": ["first", "second"]},
)
match = qr._keyword_override("show me the data")
assert match == ("first", "data")
def test_same_order_falls_through_to_quality_then_price(self):
# All three models share order=1 → tiebreak falls through to
# (quality DESC, cost ASC).
spec = [
{
"model_name": "low-tier",
"quality_tier": 1,
"keywords": ["data"],
"order": 1,
"input_cost_per_token": 0.000001,
},
{
"model_name": "high-tier-cheap",
"quality_tier": 3,
"keywords": ["data"],
"order": 1,
"input_cost_per_token": 0.000005,
},
{
"model_name": "high-tier-expensive",
"quality_tier": 3,
"keywords": ["data"],
"order": 1,
"input_cost_per_token": 0.000050,
},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="low-tier",
quality_router_config={
"available_models": [
"low-tier",
"high-tier-cheap",
"high-tier-expensive",
]
},
)
match = qr._keyword_override("show me the data")
assert match == ("high-tier-cheap", "data")
def test_order_is_used_in_tier_resolution_too(self):
# Two models at the same tier. Explicit `order=1` on the second one
# should make _resolve_model_for_quality_tier(2) pick it.
spec = [
{
"model_name": "default-pick",
"quality_tier": 2,
},
{
"model_name": "preferred-pick",
"quality_tier": 2,
"order": 1,
},
]
router = MagicMock()
router.model_list = _make_model_list(spec)
qr = QualityRouter(
model_name="qr",
litellm_router_instance=router,
default_model="default-pick",
quality_router_config={
"available_models": ["default-pick", "preferred-pick"]
},
)
assert qr._resolve_model_for_quality_tier(2) == "preferred-pick"
@pytest.mark.asyncio
async def test_hook_falls_back_to_complexity_when_no_keyword(self, keyword_router):
# No declared keyword in the message → complexity-based routing.
# "hi" is SIMPLE → quality 1 → default-haiku (the only tier-1 model).
messages = [{"role": "user", "content": "hi"}]
request_kwargs: Dict[str, Any] = {}
resp = await keyword_router.async_pre_routing_hook(
model="qr",
request_kwargs=request_kwargs,
messages=messages,
)
assert resp is not None
assert resp.model == "default-haiku"
decision = request_kwargs["metadata"]["quality_router_decision"]
assert decision["routed_via"] == "quality_tier"
assert decision["matched_keyword"] is None
assert decision["complexity_tier"] == "SIMPLE"
# ─── Routing-decision metadata (powers x-litellm-quality-router-* headers) ──
class TestDecisionMetadata:
@pytest.mark.asyncio
async def test_hook_stashes_decision_in_request_kwargs_metadata(
self, quality_router
):
# Reasoning prompt → REASONING → quality tier 4 → opus-next.
messages = [
{
"role": "user",
"content": (
"Think step by step and reason through this problem. "
"Analyze this carefully and break down each component."
),
}
]
request_kwargs: Dict[str, Any] = {}
resp = await quality_router.async_pre_routing_hook(
model="quality-router-test",
request_kwargs=request_kwargs,
messages=messages,
)
assert resp is not None and resp.model == "opus-next"
decision = request_kwargs["metadata"]["quality_router_decision"]
assert decision["routed_model"] == "opus-next"
assert decision["quality_tier"] == 4
assert decision["complexity_tier"] == "REASONING"
assert decision["router_model_name"] == "quality-router-test"
assert decision["routed_via"] == "quality_tier"
assert decision["matched_keyword"] is None
@pytest.mark.asyncio
async def test_decision_metadata_preserves_existing_metadata(self, quality_router):
request_kwargs: Dict[str, Any] = {
"metadata": {"trace_id": "abc-123", "user_id": "u-1"}
}
await quality_router.async_pre_routing_hook(
model="quality-router-test",
request_kwargs=request_kwargs,
messages=[{"role": "user", "content": "hi"}],
)
# Existing metadata keys are intact and the decision is added alongside.
assert request_kwargs["metadata"]["trace_id"] == "abc-123"
assert request_kwargs["metadata"]["user_id"] == "u-1"
assert "quality_router_decision" in request_kwargs["metadata"]
# ─── Router.set_response_headers lifts decision into x-litellm-quality-* ────
class TestSetResponseHeadersLiftsDecision:
"""
Verify the Router.set_response_headers helper turns a stashed quality-router
decision into x-litellm-quality-router-* headers on the response.
"""
@pytest.mark.asyncio
async def test_lifts_decision_into_additional_headers(self):
from pydantic import BaseModel
from litellm.router import Router
class FakeResponse(BaseModel):
model_config = {"arbitrary_types_allowed": True}
_hidden_params: Dict[str, Any] = {}
# Build a real Router with a tiny model_list — enough to satisfy
# set_response_headers without needing the rest of the router stack.
router = Router(
model_list=[
{
"model_name": "haiku",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-test",
},
}
]
)
response = FakeResponse()
response._hidden_params = {}
request_kwargs = {
"metadata": {
"quality_router_decision": {
"router_model_name": "qr",
"routed_model": "smart-coder",
"routed_via": "keyword",
"matched_keyword": "code",
"quality_tier": 3,
"complexity_tier": None,
}
}
}
await router.set_response_headers(
response=response,
model_group="qr",
request_kwargs=request_kwargs,
)
headers = response._hidden_params["additional_headers"]
assert headers["x-litellm-quality-router-model"] == "smart-coder"
assert headers["x-litellm-quality-router-tier"] == "3"
assert headers["x-litellm-quality-router-via"] == "keyword"
assert headers["x-litellm-quality-router-keyword"] == "code"
# Keyword route short-circuits classification → no complexity header.
assert "x-litellm-quality-router-complexity" not in headers
# Existing x-litellm-model-group behavior is unchanged.
assert headers["x-litellm-model-group"] == "qr"
@pytest.mark.asyncio
async def test_quality_tier_route_emits_complexity_not_keyword(self):
from pydantic import BaseModel
from litellm.router import Router
class FakeResponse(BaseModel):
model_config = {"arbitrary_types_allowed": True}
_hidden_params: Dict[str, Any] = {}
router = Router(
model_list=[
{
"model_name": "haiku",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-test",
},
}
]
)
response = FakeResponse()
response._hidden_params = {}
request_kwargs = {
"metadata": {
"quality_router_decision": {
"router_model_name": "qr",
"routed_model": "haiku",
"routed_via": "quality_tier",
"matched_keyword": None,
"quality_tier": 1,
"complexity_tier": "SIMPLE",
}
}
}
await router.set_response_headers(
response=response,
model_group="qr",
request_kwargs=request_kwargs,
)
headers = response._hidden_params["additional_headers"]
assert headers["x-litellm-quality-router-via"] == "quality_tier"
assert headers["x-litellm-quality-router-complexity"] == "SIMPLE"
# Quality-tier route → no keyword header.
assert "x-litellm-quality-router-keyword" not in headers
@pytest.mark.asyncio
async def test_no_decision_leaves_quality_router_headers_unset(self):
from pydantic import BaseModel
from litellm.router import Router
class FakeResponse(BaseModel):
model_config = {"arbitrary_types_allowed": True}
_hidden_params: Dict[str, Any] = {}
router = Router(
model_list=[
{
"model_name": "haiku",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-test",
},
}
]
)
response = FakeResponse()
response._hidden_params = {}
await router.set_response_headers(
response=response,
model_group="haiku",
request_kwargs={}, # no quality_router_decision
)
headers = response._hidden_params["additional_headers"]
assert "x-litellm-quality-router-model" not in headers
assert "x-litellm-quality-router-tier" not in headers
class TestRouterQualityDeploymentMethods:
"""Tests for Router._is_quality_router_deployment and Router.init_quality_router_deployment."""
def test_is_quality_router_deployment_true(self):
"""_is_quality_router_deployment returns True for quality router models."""
from litellm.router import Router
from litellm.types.router import LiteLLM_Params
router = Router(
model_list=[
{
"model_name": "gpt-4o-mini",
"litellm_params": {"model": "openai/gpt-4o-mini"},
}
]
)
params = LiteLLM_Params(model="auto_router/quality_router/my-router")
assert router._is_quality_router_deployment(params) is True
def test_is_quality_router_deployment_false(self):
"""_is_quality_router_deployment returns False for regular models."""
from litellm.router import Router
from litellm.types.router import LiteLLM_Params
router = Router(
model_list=[
{
"model_name": "gpt-4o-mini",
"litellm_params": {"model": "openai/gpt-4o-mini"},
}
]
)
params = LiteLLM_Params(model="openai/gpt-4o-mini")
assert router._is_quality_router_deployment(params) is False
def test_init_quality_router_deployment(self):
"""init_quality_router_deployment registers a QualityRouter."""
from litellm.router import Router
from litellm.types.router import Deployment, LiteLLM_Params
router = Router(
model_list=[
{
"model_name": "gpt-4o-mini",
"litellm_params": {"model": "openai/gpt-4o-mini"},
}
]
)
deployment = Deployment(
model_name="auto_router/quality_router/test-router",
litellm_params=LiteLLM_Params(
model="auto_router/quality_router/test-router",
quality_router_default_model="gpt-4o-mini",
),
model_info={"id": "test-id"},
)
router.init_quality_router_deployment(deployment)
assert "auto_router/quality_router/test-router" in router.quality_routers