Merge pull request #32859 from BerriAI/litellm_complexity_router_keyword_tiers

feat(auto_router): keyword tier overrides and semantic keyword matching for the complexity router
This commit is contained in:
Abhimanyu Kapur 2026-07-11 14:18:21 -07:00 committed by GitHub
commit 92dfbdbb21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2039 additions and 293 deletions

View file

@ -33,6 +33,12 @@ model_list:
model: anthropic/claude-sonnet-5
api_key: os.environ/ANTHROPIC_API_KEY
# ---------- Embeddings (for complexity router semantic keyword matching) ----------
- model_name: voyage-4-large
litellm_params:
model: voyage/voyage-4-large
api_key: os.environ/VOYAGE_API_KEY
# ---------- Bedrock Invoke ----------
- model_name: bedrock-invoke-haiku-4-5
litellm_params:

View file

@ -1040,6 +1040,22 @@ class ModelManagementAuthChecks:
return True
def _deployment_name_and_model(deployment: Optional[Union[Deployment, Dict[str, object]]]) -> Tuple[Optional[str], str]:
"""Return (model_name, litellm_params.model) for a deployment.
delete_deployment is annotated to return a Deployment but hands back the raw
model_list dict at runtime, so both shapes are handled; the model defaults to "".
"""
if deployment is None:
return None, ""
if isinstance(deployment, dict):
name = deployment.get("model_name")
params = deployment.get("litellm_params")
model = params.get("model") if isinstance(params, dict) else None
return (name if isinstance(name, str) else None), (model if isinstance(model, str) else "")
return deployment.model_name, str(getattr(deployment.litellm_params, "model", "") or "")
#### [BETA] - This is a beta endpoint, format might change based on user feedback. - https://github.com/BerriAI/litellm/issues/964
@router.post(
"/model/delete",
@ -1111,7 +1127,19 @@ async def delete_model(
## DELETE FROM ROUTER ##
if llm_router is not None:
llm_router.delete_deployment(id=model_info.id)
deleted_deployment = llm_router.delete_deployment(id=model_info.id)
# delete_deployment only drops the deployment from model_list; the auto/
# complexity router registries are keyed by model_name and would otherwise
# retain a stale (now unbacked) entry, so evict it here too. Guard on the
# auto_router/ prefix (as clear_cache does): a regular DB model that merely
# shares a model_name with a config-defined router must not evict that router,
# since add_deployment never restores config-defined routers.
deleted_name, deleted_model = _deployment_name_and_model(deleted_deployment)
if deleted_name is not None and deleted_model.startswith("auto_router/"):
llm_router.auto_routers.pop(deleted_name, None)
llm_router.complexity_routers.pop(deleted_name, None)
llm_router.adaptive_routers.pop(deleted_name, None)
llm_router.quality_routers.pop(deleted_name, None)
# Runs after the row delete so the sibling check sees post-delete state.
if model_params.model_info.team_id is not None:
@ -1715,8 +1743,27 @@ async def clear_cache():
for model_id in db_model_ids:
llm_router.delete_deployment(id=model_id)
# Clear auto routers
llm_router.auto_routers.clear()
# Clear only DB-backed auto-router-family entries, keyed by model_name, so the
# reload below rebuilds them fresh. A blanket .clear() would also drop config-defined
# routers, which are never re-added below (add_deployment only reloads DB models),
# leaving them permanently unroutable until a full proxy restart for every tenant.
# Restrict to deployments whose model is actually an auto_router/* so a config
# router that merely shares a model_name with a regular DB model isn't evicted. The
# auto_router/ prefix also covers quality_router/ and adaptive_router/, so pop the
# name from every router registry (no-op where absent); missing quality/adaptive
# entries would otherwise make init raise "already exists" on reload and abort it.
db_router_names = {
model.get("model_name")
for model in current_models
if model.get("model_name") is not None
and model.get("model_info", {}).get("db_model", False)
and str(model.get("litellm_params", {}).get("model", "")).startswith("auto_router/")
}
for model_name in db_router_names:
llm_router.auto_routers.pop(model_name, None)
llm_router.complexity_routers.pop(model_name, None)
llm_router.adaptive_routers.pop(model_name, None)
llm_router.quality_routers.pop(model_name, None)
# Reload only DB models
await proxy_config.add_deployment(prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj)

View file

@ -7,12 +7,15 @@ to classify requests by complexity and route them to appropriate models.
By default, scoring is local (regex/keyword-based) with no external API calls and <1ms
latency. Optionally, classifier_type="llm" routes classification through a configured
model instead, trading that latency/cost guarantee for potentially better accuracy.
keyword_tier_rules (lexical or, with semantic_keyword_matching, embedding-based) are
evaluated before either classification strategy and force a tier outright when matched.
Inspired by ClawRouter: https://github.com/BlockRunAI/ClawRouter
"""
import asyncio
import re
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast
from pydantic import BaseModel
@ -25,16 +28,20 @@ from .config import (
DEFAULT_REASONING_KEYWORDS,
DEFAULT_SIMPLE_KEYWORDS,
DEFAULT_TECHNICAL_KEYWORDS,
TIER_SEVERITY_ORDER,
ComplexityRouterConfig,
ComplexityTier,
)
if TYPE_CHECKING:
from semantic_router.routers import SemanticRouter
from litellm.router import Router
from litellm.types.router import PreRoutingHookResponse
else:
Router = Any
PreRoutingHookResponse = Any
SemanticRouter = Any
class TierClassification(BaseModel):
@ -63,18 +70,38 @@ def _append_custom_keywords(base_keywords: list[str], custom_keywords: Optional[
return [*base_keywords, *deduped_custom.values()]
# Metadata keys that carry the parent request's budget reservation. These must not
# reach the classifier's internal acompletion call: the reservation belongs to the
# routed completion that the classifier is deciding on, not to the classifier call
# itself, and forwarding it would let the classifier's cost-tracking reconcile
# against a reservation it isn't responsible for.
_BUDGET_RESERVATION_METADATA_KEYS = frozenset({"user_api_key_budget_reservation", "user_api_key_auth"})
# Metadata keys that carry only the parent request's budget reservation state. These
# must not reach internal sub-calls (classifier, embedding): the reservation belongs to
# the routed completion being decided on, not to the sub-call itself, and forwarding it
# would let the sub-call's cost callback finalize the reservation, causing the routed
# completion's callback to skip incrementing key/team budget counters.
#
# Note: user_api_key_auth itself is intentionally kept; it is required by
# _filter_deployments_by_model_access_groups to scope embedding/classifier model
# selection to the caller's authorized access groups. It is forwarded as a sanitized
# copy with its budget_reservation sub-field removed, because the proxy cost callback
# (_get_budget_reservation_from_metadata) falls back to reading the reservation from
# inside the auth object when the top-level key is absent; forwarding it unsanitized
# would re-create the exact double-finalization this stripping exists to prevent.
_BUDGET_RESERVATION_METADATA_KEYS = frozenset({"user_api_key_budget_reservation"})
def _sanitize_user_api_key_auth(auth: Any) -> Any:
if isinstance(auth, dict):
return {k: v for k, v in auth.items() if k != "budget_reservation"}
if getattr(auth, "budget_reservation", None) is not None and hasattr(auth, "model_copy"):
return auth.model_copy(update={"budget_reservation": None})
return auth
def _classifier_call_metadata(metadata: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
if not metadata:
return metadata
return {k: v for k, v in metadata.items() if k not in _BUDGET_RESERVATION_METADATA_KEYS}
return {
k: _sanitize_user_api_key_auth(v) if k == "user_api_key_auth" else v
for k, v in metadata.items()
if k not in _BUDGET_RESERVATION_METADATA_KEYS
}
class DimensionScore:
@ -141,6 +168,13 @@ class ComplexityRouter(CustomLogger):
)
self.simple_keywords = self.config.simple_keywords or DEFAULT_SIMPLE_KEYWORDS
# Lazily built on first semantic request and cached for reuse (route
# embeddings are static, only the prompt is embedded per request). The lock
# serializes the one-time build so concurrent cold-start requests don't each
# construct the index and fire duplicate embedding calls.
self._semantic_routelayer: Optional[SemanticRouter] = None
self._semantic_routelayer_lock = asyncio.Lock()
# Pre-compile regex patterns for efficiency
# Use non-greedy .*? to prevent ReDoS on pathological inputs
self._multi_step_patterns = [
@ -419,6 +453,138 @@ class ComplexityRouter(CustomLogger):
raise ValueError(f"No model configured for tier {tier_key} and no default_model set")
def _lexical_tier_override(self, user_message: str) -> Optional[ComplexityTier]:
"""When keyword_tier_rules match literally, the most-severe matched tier wins.
Escalating to the highest tier (rather than the first rule in the list) keeps
routing independent of the order rules were authored in: a prompt hitting both a
SIMPLE and a REASONING keyword routes to REASONING.
"""
rules = self.config.keyword_tier_rules
if not rules:
return None
text = user_message.lower()
matched_tiers = [
rule.tier for rule in rules if any(self._keyword_matches(text, keyword) for keyword in rule.keywords)
]
if not matched_tiers:
return None
return max(matched_tiers, key=TIER_SEVERITY_ORDER.index)
def _get_or_create_semantic_routelayer(self) -> "SemanticRouter":
"""Build (once) a SemanticRouter with one route per tier, utterances = that tier's keywords."""
if self._semantic_routelayer is not None:
return self._semantic_routelayer
from semantic_router.routers import SemanticRouter
from semantic_router.routers.base import Route
from litellm.router_strategy.auto_router.litellm_encoder import (
LiteLLMRouterEncoder,
)
embedding_model = self.config.embedding_model
if embedding_model is None:
raise ValueError("embedding_model is required for semantic keyword matching")
rules = self.config.keyword_tier_rules or []
ordered_tiers = tuple(dict.fromkeys(rule.tier.value for rule in rules))
routes = [
Route(
name=tier,
utterances=[keyword for rule in rules if rule.tier.value == tier for keyword in rule.keywords],
score_threshold=self.config.match_threshold,
)
for tier in ordered_tiers
]
routelayer = SemanticRouter(
routes=routes,
encoder=LiteLLMRouterEncoder(
litellm_router_instance=self.litellm_router_instance,
model_name=embedding_model,
score_threshold=self.config.match_threshold,
),
auto_sync="local",
aggregation="max",
)
self._semantic_routelayer = routelayer
return routelayer
async def _ensure_semantic_routelayer(self) -> "SemanticRouter":
"""Return the cached route layer, building it once under a lock if needed.
The build embeds the static route utterances via the encoder's synchronous path,
so it runs in a worker thread to avoid blocking the event loop. A double-checked
asyncio lock ensures concurrent cold-start requests build it exactly once rather
than each firing duplicate embedding calls.
"""
if self._semantic_routelayer is not None:
return self._semantic_routelayer
async with self._semantic_routelayer_lock:
routelayer = self._semantic_routelayer
if routelayer is None:
routelayer = await asyncio.to_thread(self._get_or_create_semantic_routelayer)
return routelayer
async def _semantic_tier_override(self, user_message: str, request_kwargs: Dict) -> Optional[ComplexityTier]:
"""Match the prompt against keyword_tier_rules by embedding similarity.
Embeds the query ourselves (instead of letting SemanticRouter.acall embed it
internally) so the caller's metadata/litellm_metadata flows into aembedding()
and this spend is attributed and budget-checked against the originating key/team,
the same as any other litellm call. SemanticRouter.acall() has no parameter to
pass such kwargs through to the encoder, so it's bypassed for the query embedding;
the route index itself (static utterances, embedded once at build time with no
caller context) is unaffected and still reused via the precomputed `vector=` path.
"""
from semantic_router.schema import RouteChoice
from litellm.router_strategy.auto_router.litellm_encoder import (
LiteLLMRouterEncoder,
)
routelayer = await self._ensure_semantic_routelayer()
encoder = cast(LiteLLMRouterEncoder, routelayer.encoder) # cast-ok: always the encoder we built above
# Strip the parent request's budget reservation before forwarding: the reservation
# belongs to the routed completion this embedding is helping select, not to the
# embedding call. Forwarding it would let the embedding's cost callback finalize the
# reservation, so the routed completion's own callback then skips incrementing the
# key/team budget. Key/team attribution fields are preserved for spend logging.
metadata = _classifier_call_metadata(request_kwargs.get("metadata")) or {}
litellm_metadata = _classifier_call_metadata(request_kwargs.get("litellm_metadata")) or {}
query_vector = (
await encoder.aencode_queries([user_message], metadata=metadata, litellm_metadata=litellm_metadata)
)[0]
route_choice = await routelayer.acall(vector=query_vector)
if isinstance(route_choice, list):
route_choice = route_choice[0] if route_choice else None
if not isinstance(route_choice, RouteChoice) or not route_choice.name:
return None
try:
return ComplexityTier(route_choice.name)
except ValueError:
return None
async def _resolve_keyword_tier_override(self, user_message: str, request_kwargs: Dict) -> Optional[ComplexityTier]:
"""Resolve a keyword_tier_rule override, semantically or lexically per config.
Returns None (no override -> fall through to the scorer) not only when no rule
matches, but also when the semantic path fails: the embedding call can error or
time out, and a routing helper must never turn that into a failed user request.
"""
if not self.config.keyword_tier_rules:
return None
if not self.config.semantic_keyword_matching:
return self._lexical_tier_override(user_message)
try:
return await self._semantic_tier_override(user_message, request_kwargs)
except Exception as e: # noqa: BLE001 -- embedding call can fail many ways (timeout, provider/network/parse error); any failure must fall back to scoring, never fail the request
verbose_router_logger.warning(
f"ComplexityRouter: semantic keyword matching failed ({e}), falling back to complexity scoring"
)
return None
def _resolve_messages(
self,
messages: Optional[List[Dict[str, Any]]],
@ -539,6 +705,17 @@ class ComplexityRouter(CustomLogger):
messages=messages if has_original_messages else None,
)
override_tier = await self._resolve_keyword_tier_override(user_message, request_kwargs)
if override_tier is not None:
routed_model = self.get_model_for_tier(override_tier)
verbose_router_logger.info(
f"ComplexityRouter: keyword rule fired, tier={override_tier.value}, routed_model={routed_model}"
)
return PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
)
tier, score, signals = await self.aclassify(user_message, system_prompt, request_kwargs)
routed_model = self.get_model_for_tier(tier)

View file

@ -20,6 +20,38 @@ class ComplexityTier(str, Enum):
REASONING = "REASONING"
TIER_SEVERITY_ORDER: tuple[ComplexityTier, ...] = (
ComplexityTier.SIMPLE,
ComplexityTier.MEDIUM,
ComplexityTier.COMPLEX,
ComplexityTier.REASONING,
)
class KeywordTierRule(BaseModel):
"""A deterministic override: if any keyword matches, route to this tier."""
keywords: List[str] = Field(
min_length=1,
description="Keywords/phrases that trigger this rule (lexical or semantic match)",
)
tier: ComplexityTier = Field(
description="Tier to route to when this rule matches",
)
@model_validator(mode="after")
def _normalize_keywords(self) -> "KeywordTierRule":
# Strip and drop blank keywords. An empty/whitespace keyword is a routing foot-gun:
# _keyword_matches treats "" / " " as a substring that matches essentially every
# prompt, so a single stray blank would silently force this rule's tier for all
# traffic. Require at least one real keyword to remain.
cleaned = [stripped for keyword in self.keywords if (stripped := keyword.strip())]
if not cleaned:
raise ValueError("keyword_tier_rules entries must contain at least one non-empty keyword")
self.keywords = cleaned
return self
# ─── Default Keyword Lists ───
# Note: Keywords should be full words/phrases to avoid substring false positives.
# The matching logic uses word boundary detection for single-word keywords.
@ -279,6 +311,28 @@ class ComplexityRouterConfig(BaseModel):
description="Configuration for the LLM classifier; required when classifier_type is 'llm'",
)
# Deterministic keyword -> tier overrides, evaluated before weighted scoring
keyword_tier_rules: Optional[List[KeywordTierRule]] = Field(
default=None,
description="Rules that force a specific tier when their keywords match the prompt",
)
# Semantic (embedding) matching for keyword_tier_rules instead of literal text matching
semantic_keyword_matching: bool = Field(
default=False,
description="Match keyword_tier_rules by embedding similarity instead of literal text",
)
embedding_model: Optional[str] = Field(
default=None,
description="Embedding model (LiteLLM model name) used when semantic_keyword_matching is enabled",
)
match_threshold: float = Field(
default=0.5,
ge=0.0,
le=1.0,
description="Minimum cosine similarity for a semantic keyword match",
)
model_config = ConfigDict(extra="allow") # Allow additional fields
@model_validator(mode="after")
@ -287,6 +341,16 @@ class ComplexityRouterConfig(BaseModel):
raise ValueError("classifier_llm_config is required when classifier_type is 'llm'")
return self
@model_validator(mode="after")
def _validate_semantic_matching(self) -> "ComplexityRouterConfig":
if not self.semantic_keyword_matching:
return self
if not self.embedding_model:
raise ValueError("embedding_model is required when semantic_keyword_matching is enabled")
if not self.keyword_tier_rules:
raise ValueError("keyword_tier_rules must be non-empty when semantic_keyword_matching is enabled")
return self
# Combined default config
DEFAULT_COMPLEXITY_CONFIG = ComplexityRouterConfig()

View file

@ -436,28 +436,31 @@ class TestClearCache:
clear_cache,
)
# Create mock router with mixed DB and config models
# Create mock router with mixed DB and config router deployments. The two DB
# entries are auto_router/* deployments (so their router-map entries should be
# cleared for reload); the config-defined router is preserved.
mock_router = MagicMock()
mock_router.model_list = [
{
"model_name": "gpt-4",
"model_name": "db-auto-router",
"model_info": {"id": "db-model-1", "db_model": True},
"litellm_params": {"model": "gpt-4"},
"litellm_params": {"model": "auto_router/db-auto-router"},
},
{
"model_name": "gpt-3.5-turbo",
"model_name": "config-router",
"model_info": {"id": "config-model-1", "db_model": False},
"litellm_params": {"model": "gpt-3.5-turbo"},
"litellm_params": {"model": "auto_router/complexity_router"},
},
{
"model_name": "claude-3",
"model_name": "db-complexity-router",
"model_info": {"id": "db-model-2", "db_model": True},
"litellm_params": {"model": "claude-3"},
"litellm_params": {"model": "auto_router/complexity_router"},
},
]
mock_router.delete_deployment = MagicMock(return_value=True)
mock_router.auto_routers = MagicMock()
mock_router.auto_routers.clear = MagicMock()
# Real dicts (not MagicMock) so we can assert on their actual contents below.
mock_router.auto_routers = {"db-auto-router": MagicMock(), "config-router": MagicMock()}
mock_router.complexity_routers = {"db-complexity-router": MagicMock(), "config-router": MagicMock()}
mock_config = MagicMock()
mock_config.add_deployment = AsyncMock(return_value=True)
@ -479,8 +482,14 @@ class TestClearCache:
mock_router.delete_deployment.assert_any_call(id="db-model-1")
mock_router.delete_deployment.assert_any_call(id="db-model-2")
# Should have cleared auto routers
mock_router.auto_routers.clear.assert_called_once()
# DB-backed router entries are cleared so they can be re-populated by the
# reload below; the config-backed router must survive, since add_deployment()
# only reloads DB models and would otherwise leave it permanently unroutable
# (see TestClearCachePreservesConfigRouters).
assert "db-auto-router" not in mock_router.auto_routers
assert "db-complexity-router" not in mock_router.complexity_routers
assert "config-router" in mock_router.auto_routers
assert "config-router" in mock_router.complexity_routers
# Should have called add_deployment to reload DB models
mock_config.add_deployment.assert_called_once_with(
@ -488,6 +497,260 @@ class TestClearCache:
)
class TestClearCachePreservesConfigRouters:
"""
Regression test: clear_cache() must not wipe config-defined auto/complexity
routers.
clear_cache() runs after any DB model write (e.g. a team admin patching a
team-owned model via PATCH /model/{id}/update). Before this fix, it called
auto_routers.clear() / complexity_routers.clear() unconditionally, which also
dropped routers defined in config.yaml belonging to *other* tenants. Those
entries are never restored, because the reload below only re-adds DB models
(proxy_config.add_deployment), so a config-defined router would stay
permanently unroutable until a full proxy restart - a cross-tenant
denial-of-service triggerable by any team admin's unrelated model update.
"""
@pytest.mark.asyncio
async def test_config_backed_routers_survive_unrelated_db_model_update(self):
from litellm.proxy.management_endpoints.model_management_endpoints import (
clear_cache,
)
mock_router = MagicMock()
mock_router.model_list = [
{
"model_name": "team-a-db-router",
"model_info": {"id": "db-model-1", "db_model": True},
"litellm_params": {"model": "auto_router/complexity_router"},
},
]
mock_router.delete_deployment = MagicMock(return_value=True)
mock_router.auto_routers = {"config-semantic-router": MagicMock()}
mock_router.complexity_routers = {
"team-a-db-router": MagicMock(),
"config-defined-complexity-router": MagicMock(),
}
mock_config = MagicMock()
mock_config.add_deployment = AsyncMock(return_value=True)
with (
patch("litellm.proxy.proxy_server.llm_router", mock_router),
patch("litellm.proxy.proxy_server.proxy_config", mock_config),
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
patch("litellm.proxy.proxy_server.verbose_proxy_logger"),
):
await clear_cache()
# The DB-backed router for the model that was actually updated is cleared
# so the reload below can re-populate it.
assert "team-a-db-router" not in mock_router.complexity_routers
# Config-defined routers for unrelated tenants must survive untouched.
assert "config-defined-complexity-router" in mock_router.complexity_routers
assert "config-semantic-router" in mock_router.auto_routers
@pytest.mark.asyncio
async def test_config_router_sharing_name_with_regular_db_model_is_preserved(self):
"""A config router must not be evicted just because a regular (non-router) DB
model happens to share its model_name; only DB deployments that are themselves
auto_router/* deployments should have their router entry cleared.
"""
from litellm.proxy.management_endpoints.model_management_endpoints import clear_cache
mock_router = MagicMock()
mock_router.model_list = [
{
"model_name": "shared-name",
"model_info": {"id": "db-model-1", "db_model": True},
"litellm_params": {"model": "openai/gpt-4o"}, # a regular model, NOT a router
},
]
mock_router.delete_deployment = MagicMock(return_value=True)
# A config-defined complexity router registered under the same name as the DB model.
mock_router.auto_routers = {}
mock_router.complexity_routers = {"shared-name": MagicMock()}
mock_config = MagicMock()
mock_config.add_deployment = AsyncMock(return_value=True)
with (
patch("litellm.proxy.proxy_server.llm_router", mock_router),
patch("litellm.proxy.proxy_server.proxy_config", mock_config),
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
patch("litellm.proxy.proxy_server.verbose_proxy_logger"),
):
await clear_cache()
# The DB model isn't a router, so the same-named config router must be left intact.
assert "shared-name" in mock_router.complexity_routers
@pytest.mark.asyncio
async def test_db_quality_and_adaptive_routers_are_evicted(self):
"""The auto_router/ prefix also covers quality_router/ and adaptive_router/. Their
registry entries must be popped too, or reload's init raises 'already exists'
(quality) or leaves a stale entry (adaptive).
"""
from litellm.proxy.management_endpoints.model_management_endpoints import clear_cache
mock_router = MagicMock()
mock_router.model_list = [
{
"model_name": "q1",
"model_info": {"id": "db-q", "db_model": True},
"litellm_params": {"model": "auto_router/quality_router/q1"},
},
{
"model_name": "a1",
"model_info": {"id": "db-a", "db_model": True},
"litellm_params": {"model": "auto_router/adaptive_router/a1"},
},
]
mock_router.delete_deployment = MagicMock(return_value=True)
mock_router.auto_routers = {}
mock_router.complexity_routers = {}
mock_router.quality_routers = {"q1": MagicMock()}
mock_router.adaptive_routers = {"a1": MagicMock()}
mock_config = MagicMock()
mock_config.add_deployment = AsyncMock(return_value=True)
with (
patch("litellm.proxy.proxy_server.llm_router", mock_router),
patch("litellm.proxy.proxy_server.proxy_config", mock_config),
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
patch("litellm.proxy.proxy_server.verbose_proxy_logger"),
):
await clear_cache()
assert "q1" not in mock_router.quality_routers
assert "a1" not in mock_router.adaptive_routers
class TestDeleteModelClearsRouterRegistry:
"""delete_model must evict the deleted deployment from the auto/complexity router maps,
not just from model_list, or a stale (now unbacked) router entry lingers until restart.
"""
@pytest.mark.asyncio
async def test_delete_model_pops_router_registries(self):
from litellm.proxy.management_endpoints.model_management_endpoints import (
delete_model as delete_model_endpoint,
)
from litellm.proxy.management_endpoints.model_management_endpoints import ModelInfoDelete
model_id = "router-del-1"
admin_user = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
db_row = LiteLLM_ProxyModelTable(
model_id=model_id,
model_name="smart-router",
litellm_params={"model": "auto_router/complexity_router"},
model_info={"id": model_id},
created_by="admin",
updated_by="admin",
)
mock_prisma = MagicMock()
mock_prisma.db = MagicMock()
mock_prisma.db.litellm_proxymodeltable = AsyncMock()
mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=db_row)
mock_prisma.db.litellm_proxymodeltable.delete = AsyncMock(return_value=db_row)
mock_router = MagicMock()
mock_router.delete_deployment = MagicMock(
return_value={
"model_name": "smart-router",
"litellm_params": {"model": "auto_router/complexity_router"},
"model_info": {"id": model_id},
}
)
mock_router.auto_routers = {"smart-router": MagicMock()}
mock_router.complexity_routers = {"smart-router": MagicMock()}
_PS = "litellm.proxy.proxy_server"
with (
patch(f"{_PS}.prisma_client", mock_prisma),
patch(f"{_PS}.store_model_in_db", True),
patch(f"{_PS}.proxy_config", MagicMock()),
patch(f"{_PS}.proxy_logging_obj", MagicMock()),
patch(f"{_PS}.general_settings", {}),
patch(f"{_PS}.premium_user", True),
patch(f"{_PS}.llm_router", mock_router),
):
await delete_model_endpoint(
model_info=ModelInfoDelete(id=model_id),
user_api_key_dict=admin_user,
)
mock_router.delete_deployment.assert_called_once_with(id=model_id)
assert "smart-router" not in mock_router.auto_routers
assert "smart-router" not in mock_router.complexity_routers
@pytest.mark.asyncio
async def test_delete_regular_model_preserves_config_router_sharing_name(self):
"""Deleting a regular (non-router) DB model must not evict a config-defined router
that merely shares its model_name. delete_deployment pops the DB model, but the
auto/complexity registries hold a config router under the same name that
add_deployment never restores, so an unguarded pop would make it permanently
unroutable (the same cross-tenant DoS clear_cache was hardened against).
"""
from litellm.proxy.management_endpoints.model_management_endpoints import (
delete_model as delete_model_endpoint,
)
from litellm.proxy.management_endpoints.model_management_endpoints import ModelInfoDelete
model_id = "regular-del-1"
admin_user = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
db_row = LiteLLM_ProxyModelTable(
model_id=model_id,
model_name="shared-name",
litellm_params={"model": "openai/gpt-4o"},
model_info={"id": model_id},
created_by="admin",
updated_by="admin",
)
mock_prisma = MagicMock()
mock_prisma.db = MagicMock()
mock_prisma.db.litellm_proxymodeltable = AsyncMock()
mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=db_row)
mock_prisma.db.litellm_proxymodeltable.delete = AsyncMock(return_value=db_row)
mock_router = MagicMock()
mock_router.delete_deployment = MagicMock(
return_value={
"model_name": "shared-name",
"litellm_params": {"model": "openai/gpt-4o"},
"model_info": {"id": model_id},
}
)
config_router = MagicMock()
mock_router.auto_routers = {}
mock_router.complexity_routers = {"shared-name": config_router}
_PS = "litellm.proxy.proxy_server"
with (
patch(f"{_PS}.prisma_client", mock_prisma),
patch(f"{_PS}.store_model_in_db", True),
patch(f"{_PS}.proxy_config", MagicMock()),
patch(f"{_PS}.proxy_logging_obj", MagicMock()),
patch(f"{_PS}.general_settings", {}),
patch(f"{_PS}.premium_user", True),
patch(f"{_PS}.llm_router", mock_router),
):
await delete_model_endpoint(
model_info=ModelInfoDelete(id=model_id),
user_api_key_dict=admin_user,
)
mock_router.delete_deployment.assert_called_once_with(id=model_id)
assert mock_router.complexity_routers.get("shared-name") is config_router
class TestUpdateModel:
"""
Tests for the update_model (POST /model/update) handler.

View file

@ -4,9 +4,10 @@ Tests for the ComplexityRouter.
Tests the rule-based complexity scoring and tier assignment logic.
"""
import asyncio
import os
import sys
from typing import Dict
from typing import Dict, List
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -16,6 +17,7 @@ sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
import litellm
from litellm import Router
from litellm.router_strategy.complexity_router.complexity_router import (
ComplexityRouter,
@ -1252,15 +1254,26 @@ class TestLLMClassifier:
"user_api_key": "sk-abc",
"user_api_key_team_id": "team-1",
"user_api_key_budget_reservation": {"reserved_cost": 1.0},
"user_api_key_auth": {"budget_reservation": {"reserved_cost": 1.0}},
"user_api_key_auth": {"models": ["gpt-4o"], "budget_reservation": {"reserved_cost": 1.0}},
}
await llm_complexity_router.aclassify(
"hi", request_kwargs={"litellm_metadata": request_metadata}
)
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
# user_api_key_budget_reservation is stripped (budget enforcement) while
# user_api_key_auth is kept so _filter_deployments_by_model_access_groups
# can scope the classifier's model selection to the caller's access groups,
# but only as a sanitized copy without its budget_reservation sub-field:
# the cost callback falls back to reading the reservation from inside the
# auth object when the top-level key is absent.
assert call_kwargs["metadata"] == {
"user_api_key": "sk-abc",
"user_api_key_team_id": "team-1",
"user_api_key_auth": {"models": ["gpt-4o"]},
}
assert request_metadata["user_api_key_auth"] == {
"models": ["gpt-4o"],
"budget_reservation": {"reserved_cost": 1.0},
}
@pytest.mark.asyncio
@ -1309,3 +1322,700 @@ class TestLLMClassifier:
assert result.model == "o1-preview" # REASONING tier model
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
assert call_kwargs["metadata"] == request_metadata
class TestLexicalKeywordTierRules:
"""Test deterministic (literal) keyword_tier_rules overrides."""
@pytest.fixture
def rule_config(self, basic_config) -> Dict:
return {
**basic_config,
"keyword_tier_rules": [
{"keywords": ["deploy to k8s"], "tier": "REASONING"},
],
}
@pytest.mark.asyncio
async def test_matching_rule_overrides_scoring(
self, mock_router_instance, rule_config
):
"""A prompt hitting a rule keyword routes to that tier, not the scored tier."""
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=rule_config,
)
prompt = "please deploy to k8s now"
# Without the rule this short prompt would not score into REASONING.
scored_tier, _, _ = router.classify(prompt)
assert scored_tier != ComplexityTier.REASONING
result = await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[{"role": "user", "content": prompt}],
)
assert result is not None
assert result.model == "o1-preview" # REASONING tier model
@pytest.mark.asyncio
async def test_most_severe_tier_wins_regardless_of_rule_order(self, mock_router_instance, basic_config):
"""When several rules match, the highest-severity tier wins, independent of list order."""
config = {
**basic_config,
"keyword_tier_rules": [
{"keywords": ["database"], "tier": "SIMPLE"}, # listed first, lower tier
{"keywords": ["database"], "tier": "REASONING"}, # listed later, higher tier
],
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
result = await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[{"role": "user", "content": "tell me about the database"}],
)
assert result is not None
assert result.model == "o1-preview" # REASONING wins over the earlier SIMPLE rule
@pytest.mark.asyncio
async def test_distinct_keywords_escalate_to_highest_tier(self, mock_router_instance, basic_config):
"""A prompt hitting keywords across tiers routes to the most complex one."""
config = {
**basic_config,
"keyword_tier_rules": [
{"keywords": ["hi"], "tier": "SIMPLE"},
{"keywords": ["advise"], "tier": "COMPLEX"},
{"keywords": ["kubernetes"], "tier": "REASONING"},
],
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
result = await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[{"role": "user", "content": "hi, advise me on kubernetes"}],
)
assert result is not None
assert result.model == "o1-preview" # REASONING, the highest of SIMPLE/COMPLEX/REASONING
def test_lexical_override_returns_most_severe_matched_tier(self, mock_router_instance, basic_config):
"""Unit-level check of the escalation helper across mixed matches."""
config = {
**basic_config,
"keyword_tier_rules": [
{"keywords": ["hi"], "tier": "SIMPLE"},
{"keywords": ["advise"], "tier": "COMPLEX"},
],
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
assert router._lexical_tier_override("hi there, please advise") == ComplexityTier.COMPLEX
assert router._lexical_tier_override("just saying hi") == ComplexityTier.SIMPLE
assert router._lexical_tier_override("nothing relevant here") is None
@pytest.mark.asyncio
async def test_no_rule_match_falls_back_to_scoring(
self, mock_router_instance, basic_config
):
"""A prompt that matches no rule is classified by the scorer as usual."""
config = {
**basic_config,
"keyword_tier_rules": [
{"keywords": ["zzznomatch"], "tier": "REASONING"},
],
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
result = await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[{"role": "user", "content": "Hello!"}],
)
assert result is not None
assert result.model == "gpt-4o-mini" # SIMPLE via scoring, rule did not fire
def test_word_boundary_avoids_substring_false_positive(
self, mock_router_instance, basic_config
):
"""A single-word rule keyword must not match inside a larger word."""
config = {
**basic_config,
"keyword_tier_rules": [{"keywords": ["k8s"], "tier": "REASONING"}],
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
assert router._lexical_tier_override("running my k8s cluster") == ComplexityTier.REASONING
assert router._lexical_tier_override("what is a k8scluster thing") is None
def _make_embedding_response(vectors: List[List[float]]) -> "litellm.EmbeddingResponse":
return litellm.EmbeddingResponse(
model="fake-embed",
data=[
{"embedding": vec, "index": idx, "object": "embedding"}
for idx, vec in enumerate(vectors)
],
object="list",
)
class FakeEmbeddingRouter:
"""A stand-in router whose embeddings are deterministic 2D unit vectors.
Any text mentioning a cluster/container concept maps to [1, 0]; everything
else maps to [0, 1]. This lets the real SemanticRouter compute exact cosine
similarities (1.0 or 0.0) so threshold behavior is testable without a network call.
"""
_CLUSTER_MARKERS = ("k8s", "kube", "container", "cluster", "orchestrat")
def __init__(self):
self.async_embedding_calls: List[List[str]] = []
self.async_embedding_kwargs: List[Dict] = []
# Every embedded batch (sync route-index build AND async query), so tests can count
# builds independently of which embedding path the library happens to use.
self.embedded_batches: List[List[str]] = []
# Thread ids of the synchronous (route-index build) embedding calls, so a test can
# assert the build is offloaded off the event-loop thread.
self.sync_embedding_thread_ids: List[int] = []
def _vectors(self, docs: List[str]) -> List[List[float]]:
return [
[1.0, 0.0] if any(marker in doc.lower() for marker in self._CLUSTER_MARKERS) else [0.0, 1.0]
for doc in docs
]
@staticmethod
def _as_list(text) -> List[str]:
return text if isinstance(text, list) else [text]
def embedding(self, input, model, **kwargs):
import threading
docs = self._as_list(input)
self.embedded_batches.append(docs)
self.sync_embedding_thread_ids.append(threading.get_ident())
return _make_embedding_response(self._vectors(docs))
async def aembedding(self, input, model, **kwargs):
docs = self._as_list(input)
self.embedded_batches.append(docs)
self.async_embedding_calls.append(docs)
self.async_embedding_kwargs.append(kwargs)
return _make_embedding_response(self._vectors(docs))
def utterance_embedding_count(self, utterance: str) -> int:
"""How many times the given route utterance was embedded == number of route-index builds."""
return sum(1 for batch in self.embedded_batches if utterance in batch)
class TestSemanticKeywordTierRules:
"""Test embedding-based keyword_tier_rules matching."""
@pytest.mark.asyncio
async def test_semantic_match_routes_to_rule_tier(self, basic_config):
"""A paraphrase (no literal keyword) still routes via embedding similarity."""
fake_router = FakeEmbeddingRouter()
config = {
**basic_config,
"keyword_tier_rules": [
{"keywords": ["kubernetes deployment", "container orchestration"], "tier": "REASONING"},
{"keywords": ["hello", "thanks"], "tier": "SIMPLE"},
],
"semantic_keyword_matching": True,
"embedding_model": "fake-embed",
"match_threshold": 0.5,
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=fake_router,
complexity_router_config=config,
)
result = await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[{"role": "user", "content": "help me roll out my k8s cluster today"}],
)
assert result is not None
assert result.model == "o1-preview" # REASONING via semantic match
assert fake_router.async_embedding_calls, "expected an embedding call for the prompt"
@pytest.mark.asyncio
async def test_tier_matches_on_best_utterance_not_diluted_by_others(self, basic_config):
"""A tier with several keywords must match if the query is close to ANY of them,
not the average across all of them. A tier's route holds one utterance per keyword;
mean aggregation (the semantic_router library default) scores the query against the
*average* similarity across every utterance in the route, so a real match on one
keyword gets dragged below threshold by the tier's other, unrelated keywords.
"""
fake_router = FakeEmbeddingRouter()
config = {
**basic_config,
"keyword_tier_rules": [
{"keywords": ["kubernetes deployment", "thanks", "goodbye"], "tier": "REASONING"},
],
"semantic_keyword_matching": True,
"embedding_model": "fake-embed",
"match_threshold": 0.5,
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=fake_router,
complexity_router_config=config,
)
# Only "kubernetes deployment" is close to this query (cos 1.0); "thanks" and
# "goodbye" are orthogonal (cos 0.0). Mean over the three would be ~0.33, below the
# 0.5 threshold; the best (max) utterance alone clears it.
result = await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[{"role": "user", "content": "help me roll out my k8s cluster today"}],
)
assert result is not None
assert result.model == "o1-preview" # REASONING via best-utterance semantic match
@pytest.mark.asyncio
async def test_semantic_embedding_call_carries_caller_metadata(self, basic_config):
"""The query embedding call must carry the caller's metadata/litellm_metadata
so embedding spend is attributed and budget-checked against the originating
key/team, instead of being logged as an untracked, unattributed cost.
"""
fake_router = FakeEmbeddingRouter()
config = {
**basic_config,
"keyword_tier_rules": [{"keywords": ["kubernetes deployment"], "tier": "REASONING"}],
"semantic_keyword_matching": True,
"embedding_model": "fake-embed",
"match_threshold": 0.5,
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=fake_router,
complexity_router_config=config,
)
caller_metadata = {"user_api_key_hash": "hash-abc", "user_api_key_team_id": "team-1"}
caller_litellm_metadata = {"user_api_key": "hash-abc"}
result = await router.async_pre_routing_hook(
model="test-model",
request_kwargs={"metadata": caller_metadata, "litellm_metadata": caller_litellm_metadata},
messages=[{"role": "user", "content": "roll out my k8s cluster"}],
)
assert result is not None
assert fake_router.async_embedding_kwargs, "expected an embedding call for the prompt"
assert fake_router.async_embedding_kwargs[0]["metadata"] == caller_metadata
assert fake_router.async_embedding_kwargs[0]["litellm_metadata"] == caller_litellm_metadata
@pytest.mark.asyncio
async def test_semantic_embedding_call_strips_budget_reservation(self, basic_config):
"""The embedding call must not carry the parent request's budget reservation.
The reservation belongs to the routed completion this embedding helps select, not
to the embedding call. Forwarding it would let the embedding's cost callback
finalize the reservation, so the routed completion's callback then skips
incrementing the key/team budget - letting a caller run completions while only the
embedding cost is enforced. Key/team attribution fields must still be forwarded.
"""
fake_router = FakeEmbeddingRouter()
config = {
**basic_config,
"keyword_tier_rules": [{"keywords": ["kubernetes deployment"], "tier": "REASONING"}],
"semantic_keyword_matching": True,
"embedding_model": "fake-embed",
"match_threshold": 0.5,
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=fake_router,
complexity_router_config=config,
)
caller_metadata = {
"user_api_key_hash": "hash-abc",
"user_api_key_team_id": "team-1",
"user_api_key_budget_reservation": {"reserved_cost": 1.0},
"user_api_key_auth": {"models": ["voyage-3-5"], "budget_reservation": {"reserved_cost": 1.0}},
}
await router.async_pre_routing_hook(
model="test-model",
request_kwargs={"metadata": caller_metadata, "litellm_metadata": dict(caller_metadata)},
messages=[{"role": "user", "content": "roll out my k8s cluster"}],
)
assert fake_router.async_embedding_kwargs, "expected an embedding call for the prompt"
# user_api_key_budget_reservation is stripped to prevent budget-bypass.
# user_api_key_auth is kept so _filter_deployments_by_model_access_groups
# scopes the embedding model selection to the caller's authorized groups,
# but its budget_reservation sub-field is removed because the cost callback
# falls back to reading the reservation from inside the auth object.
expected = {
"user_api_key_hash": "hash-abc",
"user_api_key_team_id": "team-1",
"user_api_key_auth": {"models": ["voyage-3-5"]},
}
assert fake_router.async_embedding_kwargs[0]["metadata"] == expected
assert fake_router.async_embedding_kwargs[0]["litellm_metadata"] == expected
assert caller_metadata["user_api_key_auth"] == {
"models": ["voyage-3-5"],
"budget_reservation": {"reserved_cost": 1.0},
}
@pytest.mark.asyncio
async def test_semantic_routelayer_build_runs_off_event_loop(self, basic_config):
"""Building the SemanticRouter embeds route utterances via a synchronous provider
call; it must run in a worker thread, not block the async event loop.
"""
import threading
fake_router = FakeEmbeddingRouter()
config = {
**basic_config,
"keyword_tier_rules": [{"keywords": ["kubernetes deployment"], "tier": "REASONING"}],
"semantic_keyword_matching": True,
"embedding_model": "fake-embed",
"match_threshold": 0.5,
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=fake_router,
complexity_router_config=config,
)
loop_thread_id = threading.get_ident()
await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[{"role": "user", "content": "roll out my k8s cluster"}],
)
# The route-index build did a synchronous embedding call...
assert fake_router.sync_embedding_thread_ids, "expected the route-index build to embed utterances"
# ...and none of it ran on the event-loop thread.
assert all(tid != loop_thread_id for tid in fake_router.sync_embedding_thread_ids)
@pytest.mark.asyncio
async def test_concurrent_cold_start_builds_routelayer_once(self, basic_config):
"""Concurrent first requests must not each construct the route index (which would
fire duplicate embedding calls); the lazy build happens exactly once.
"""
config = {
**basic_config,
"keyword_tier_rules": [{"keywords": ["kubernetes deployment"], "tier": "REASONING"}],
"semantic_keyword_matching": True,
"embedding_model": "fake-embed",
"match_threshold": 0.5,
}
def _make_router(fake):
return ComplexityRouter(
model_name="test-router",
litellm_router_instance=fake,
complexity_router_config=config,
)
# Baseline: a single cold request's route-index build embeds the route utterance once.
route_utterance = "kubernetes deployment"
baseline_fake = FakeEmbeddingRouter()
await _make_router(baseline_fake)._semantic_tier_override("roll out my k8s cluster", {})
baseline_builds = baseline_fake.utterance_embedding_count(route_utterance)
assert baseline_builds >= 1
# Ten simultaneous cold-start requests must build the index the same number of
# times as one request - i.e. exactly once, not once per concurrent caller.
concurrent_fake = FakeEmbeddingRouter()
concurrent_router = _make_router(concurrent_fake)
await asyncio.gather(
*(concurrent_router._semantic_tier_override("roll out my k8s cluster", {}) for _ in range(10))
)
assert concurrent_fake.utterance_embedding_count(route_utterance) == baseline_builds
@pytest.mark.asyncio
async def test_below_threshold_falls_back_to_scoring(self, basic_config):
"""When no route clears the threshold, scoring decides the tier."""
fake_router = FakeEmbeddingRouter()
config = {
**basic_config,
"keyword_tier_rules": [
{"keywords": ["kubernetes deployment"], "tier": "REASONING"},
],
"semantic_keyword_matching": True,
"embedding_model": "fake-embed",
"match_threshold": 0.9,
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=fake_router,
complexity_router_config=config,
)
# "hello there friend" embeds orthogonal to the REASONING route (cos 0 < 0.9).
result = await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[{"role": "user", "content": "hello there friend"}],
)
assert result is not None
assert result.model == "gpt-4o-mini" # SIMPLE via scoring fallback
@pytest.mark.asyncio
async def test_route_embeddings_cached_across_requests(self, basic_config):
"""The route layer is built once and reused on subsequent requests."""
fake_router = FakeEmbeddingRouter()
config = {
**basic_config,
"keyword_tier_rules": [
{"keywords": ["kubernetes deployment"], "tier": "REASONING"},
],
"semantic_keyword_matching": True,
"embedding_model": "fake-embed",
"match_threshold": 0.5,
}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=fake_router,
complexity_router_config=config,
)
assert router._semantic_routelayer is None
await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[{"role": "user", "content": "roll out my k8s cluster"}],
)
first_layer = router._semantic_routelayer
assert first_layer is not None
await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[{"role": "user", "content": "scale my container cluster"}],
)
assert router._semantic_routelayer is first_layer
class TestSemanticConfigValidation:
"""Test config validation for semantic_keyword_matching."""
def test_semantic_without_embedding_model_raises(self):
with pytest.raises(ValidationError):
ComplexityRouterConfig(
semantic_keyword_matching=True,
keyword_tier_rules=[{"keywords": ["k8s"], "tier": "REASONING"}],
)
def test_semantic_without_rules_raises(self):
with pytest.raises(ValidationError):
ComplexityRouterConfig(
semantic_keyword_matching=True,
embedding_model="fake-embed",
)
def test_semantic_disabled_needs_no_embedding_model(self):
config = ComplexityRouterConfig(
keyword_tier_rules=[{"keywords": ["k8s"], "tier": "REASONING"}],
)
assert config.semantic_keyword_matching is False
assert config.match_threshold == 0.5
def test_keyword_tier_rule_rejects_empty_keywords(self):
"""A rule with no keywords is meaningless (and yields a zero-utterance semantic route)."""
with pytest.raises(ValidationError):
ComplexityRouterConfig(keyword_tier_rules=[{"keywords": [], "tier": "SIMPLE"}])
def test_keyword_tier_rule_rejects_blank_only_keywords(self):
"""Whitespace-only keywords don't count as content."""
with pytest.raises(ValidationError):
ComplexityRouterConfig(keyword_tier_rules=[{"keywords": [" ", ""], "tier": "SIMPLE"}])
def test_keyword_tier_rule_strips_and_drops_blank_keywords(self):
"""Blank keywords mixed with real ones are dropped (not kept), and survivors trimmed.
A stray "" would otherwise match-all in _keyword_matches and silently force this
tier for every request.
"""
config = ComplexityRouterConfig(
keyword_tier_rules=[{"keywords": ["", " deploy to k8s ", " ", "kubernetes"], "tier": "REASONING"}]
)
assert config.keyword_tier_rules is not None
assert config.keyword_tier_rules[0].keywords == ["deploy to k8s", "kubernetes"]
class _StubEncoder:
"""Minimal stand-in for LiteLLMRouterEncoder.aencode_queries, capturing the kwargs it was called with."""
def __init__(self):
self.aencode_queries_calls: List[Dict] = []
async def aencode_queries(self, docs, **kwargs):
self.aencode_queries_calls.append(kwargs)
return [[0.0]]
class _StubRouteLayer:
"""Returns a fixed acall result so _semantic_tier_override branches can be exercised."""
def __init__(self, result):
self._result = result
self.encoder = _StubEncoder()
async def acall(self, text=None, vector=None):
return self._result
class _RaisingEncoder:
"""Simulates an embedding-provider failure during semantic matching."""
async def aencode_queries(self, docs, **kwargs):
raise RuntimeError("embedding provider unavailable")
class _RaisingRouteLayer:
def __init__(self):
self.encoder = _RaisingEncoder()
async def acall(self, text=None, vector=None):
raise AssertionError("acall should not be reached when the encoder fails")
class TestKeywordOverrideEdgeCases:
"""Cover the defensive branches of the lexical and semantic override helpers."""
def _semantic_router(self, mock_router_instance, basic_config):
config = {
**basic_config,
"keyword_tier_rules": [{"keywords": ["kubernetes"], "tier": "REASONING"}],
"semantic_keyword_matching": True,
"embedding_model": "fake-embed",
"match_threshold": 0.5,
}
return ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
def test_lexical_override_none_when_no_rules(self, mock_router_instance, basic_config):
"""No keyword_tier_rules configured -> lexical override is a no-op."""
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=basic_config,
)
assert router._lexical_tier_override("deploy to k8s and reason step by step") is None
def test_semantic_routelayer_requires_embedding_model(self, mock_router_instance, basic_config):
"""Building the route layer without an embedding model raises (defensive invariant)."""
config = {**basic_config, "keyword_tier_rules": [{"keywords": ["k8s"], "tier": "REASONING"}]}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
assert router.config.embedding_model is None
with pytest.raises(ValueError, match="embedding_model is required"):
router._get_or_create_semantic_routelayer()
@pytest.mark.asyncio
async def test_semantic_override_maps_first_of_list(self, mock_router_instance, basic_config):
"""A list RouteChoice result maps to the first entry's tier."""
from semantic_router.schema import RouteChoice
router = self._semantic_router(mock_router_instance, basic_config)
router._semantic_routelayer = _StubRouteLayer([RouteChoice(name="COMPLEX"), RouteChoice(name="SIMPLE")])
assert await router._semantic_tier_override("anything", {}) == ComplexityTier.COMPLEX
@pytest.mark.asyncio
async def test_semantic_override_empty_list_returns_none(self, mock_router_instance, basic_config):
"""An empty list result falls through to scoring."""
router = self._semantic_router(mock_router_instance, basic_config)
router._semantic_routelayer = _StubRouteLayer([])
assert await router._semantic_tier_override("anything", {}) is None
@pytest.mark.asyncio
async def test_semantic_override_unknown_route_name_returns_none(self, mock_router_instance, basic_config):
"""A matched route whose name is not a ComplexityTier is ignored."""
from semantic_router.schema import RouteChoice
router = self._semantic_router(mock_router_instance, basic_config)
router._semantic_routelayer = _StubRouteLayer(RouteChoice(name="NOT_A_TIER"))
assert await router._semantic_tier_override("anything", {}) is None
@pytest.mark.asyncio
async def test_semantic_embedding_error_falls_back_to_scoring(self, mock_router_instance, basic_config):
"""An embedding failure must not fail the request: the override yields None so
async_pre_routing_hook falls through to the complexity scorer.
"""
router = self._semantic_router(mock_router_instance, basic_config)
router._semantic_routelayer = _RaisingRouteLayer()
# _resolve_keyword_tier_override swallows the error and returns None (no override).
assert await router._resolve_keyword_tier_override("roll out my k8s cluster", {}) is None
# End-to-end, the hook still returns a routed model (from scoring) rather than raising.
result = await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[{"role": "user", "content": "roll out my k8s cluster"}],
)
assert result is not None
assert result.model in {"gpt-4o-mini", "gpt-4o", "claude-sonnet-4-20250514", "o1-preview"}
class TestSubCallMetadataSanitization:
"""The proxy cost callback must not be able to recover the parent budget reservation
from sub-call metadata, in either of the shapes it knows how to read."""
def test_cost_callback_cannot_recover_reservation_from_sanitized_metadata(self):
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.proxy_track_cost_callback import (
_get_budget_reservation_from_metadata,
)
from litellm.router_strategy.complexity_router.complexity_router import (
_classifier_call_metadata,
)
reservation = {"reserved_cost": 1.0}
auth_shapes = (
{"models": ["gpt-4o"], "budget_reservation": dict(reservation)},
UserAPIKeyAuth(api_key="sk-abc", budget_reservation=dict(reservation)),
)
for auth in auth_shapes:
metadata = {
"user_api_key_hash": "hash-abc",
"user_api_key_budget_reservation": dict(reservation),
"user_api_key_auth": auth,
}
assert _get_budget_reservation_from_metadata(metadata) == reservation
sanitized = _classifier_call_metadata(metadata)
assert sanitized is not None
assert sanitized["user_api_key_auth"] is not None
assert _get_budget_reservation_from_metadata(sanitized) is None
def test_sanitized_auth_keeps_access_group_fields_and_leaves_original_untouched(self):
from litellm.proxy._types import UserAPIKeyAuth
from litellm.router_strategy.complexity_router.complexity_router import (
_classifier_call_metadata,
)
auth = UserAPIKeyAuth(
api_key="sk-abc",
team_id="team-1",
budget_reservation={"reserved_cost": 1.0},
)
sanitized = _classifier_call_metadata({"user_api_key_auth": auth})
assert sanitized is not None
sanitized_auth = sanitized["user_api_key_auth"]
assert sanitized_auth.budget_reservation is None
assert sanitized_auth.team_id == "team-1"
assert sanitized_auth.api_key == auth.api_key
assert auth.budget_reservation == {"reserved_cost": 1.0}

View file

@ -19,14 +19,28 @@ const defaultValue: ComplexityRouterConfigValue = {
classifier_type: "heuristic",
};
const baseProps = {
modelInfo: mockModelInfo,
value: defaultValue,
onChange: vi.fn(),
keywordTierRules: [],
onKeywordTierRulesChange: vi.fn(),
semanticMatchingEnabled: false,
onSemanticMatchingEnabledChange: vi.fn(),
embeddingModel: undefined,
onEmbeddingModelChange: vi.fn(),
matchThreshold: 0.5,
onMatchThresholdChange: vi.fn(),
};
describe("ComplexityRouterConfig", () => {
it("should render", () => {
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={defaultValue} onChange={vi.fn()} />);
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument();
});
it("should display all four tier labels", () => {
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={defaultValue} onChange={vi.fn()} />);
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
expect(screen.getByText("Simple Tier")).toBeInTheDocument();
expect(screen.getByText("Medium Tier")).toBeInTheDocument();
expect(screen.getByText("Complex Tier")).toBeInTheDocument();
@ -34,7 +48,7 @@ describe("ComplexityRouterConfig", () => {
});
it("should show example queries for each tier", () => {
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={defaultValue} onChange={vi.fn()} />);
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
expect(screen.getByText(/Hello!/)).toBeInTheDocument();
expect(screen.getByText(/Explain how REST APIs work/)).toBeInTheDocument();
expect(screen.getByText(/Design a microservices architecture/)).toBeInTheDocument();
@ -42,12 +56,12 @@ describe("ComplexityRouterConfig", () => {
});
it("should display the how classification works section", () => {
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={defaultValue} onChange={vi.fn()} />);
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
expect(screen.getByText("How Classification Works")).toBeInTheDocument();
});
it("should show score thresholds in the classification section", () => {
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={defaultValue} onChange={vi.fn()} />);
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
expect(screen.getByText(/Score < 0.15/)).toBeInTheDocument();
expect(screen.getByText(/Score 0.15 - 0.35/)).toBeInTheDocument();
expect(screen.getByText(/Score 0.35 - 0.60/)).toBeInTheDocument();
@ -91,16 +105,14 @@ describe("ComplexityRouterConfig", () => {
});
it("should render the custom technical keywords field", () => {
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={defaultValue} onChange={vi.fn()} />);
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
expect(screen.getByText("Custom Technical Keywords")).toBeInTheDocument();
});
it("should display existing custom technical keywords as tags", () => {
renderWithProviders(
<ComplexityRouterConfig
modelInfo={mockModelInfo}
value={defaultValue}
onChange={vi.fn()}
{...baseProps}
customTechnicalKeywords={["udp", "kafka"]}
onCustomTechnicalKeywordsChange={vi.fn()}
/>,
@ -114,9 +126,7 @@ describe("ComplexityRouterConfig", () => {
const onCustomTechnicalKeywordsChange = vi.fn();
renderWithProviders(
<ComplexityRouterConfig
modelInfo={mockModelInfo}
value={defaultValue}
onChange={vi.fn()}
{...baseProps}
customTechnicalKeywords={[]}
onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange}
/>,
@ -126,4 +136,75 @@ describe("ComplexityRouterConfig", () => {
await user.type(input, "udp,");
expect(onCustomTechnicalKeywordsChange).toHaveBeenCalledWith(["udp"]);
});
it("should render an empty state when no keyword tier rules exist", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} />);
expect(screen.getByText("Keyword Tier Overrides")).toBeInTheDocument();
expect(screen.getByText("No keyword tier overrides configured")).toBeInTheDocument();
});
it("hides the keyword-tier and semantic sections when their change handlers are absent (edit modal)", () => {
// The edit-auto-router modal renders ComplexityRouterConfig without these handlers;
// the sections must stay hidden rather than render interactive-but-dead controls.
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={defaultValue} onChange={vi.fn()} />);
expect(screen.queryByText("Keyword Tier Overrides")).not.toBeInTheDocument();
expect(screen.queryByText("Semantic keyword matching")).not.toBeInTheDocument();
// Core tier config still renders.
expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument();
});
it("should call onKeywordTierRulesChange with a new rule when 'Add keyword rule' is clicked", async () => {
const user = userEvent.setup();
const onKeywordTierRulesChange = vi.fn();
renderWithProviders(<ComplexityRouterConfig {...baseProps} onKeywordTierRulesChange={onKeywordTierRulesChange} />);
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
expect(onKeywordTierRulesChange).toHaveBeenCalledTimes(1);
const newRules = onKeywordTierRulesChange.mock.calls[0][0];
expect(newRules).toHaveLength(1);
expect(newRules[0]).toMatchObject({ keywords: [], tier: "COMPLEX" });
});
it("should render an existing keyword tier rule and remove it when the delete button is clicked", async () => {
const user = userEvent.setup();
const onKeywordTierRulesChange = vi.fn();
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
keywordTierRules={[{ id: "rule-1", keywords: ["invoice", "refund"], tier: "MEDIUM" }]}
onKeywordTierRulesChange={onKeywordTierRulesChange}
/>,
);
expect(screen.getByText("invoice")).toBeInTheDocument();
expect(screen.getByText("refund")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /remove keyword rule 1/i }));
expect(onKeywordTierRulesChange).toHaveBeenCalledWith([]);
});
it("should not show embedding model or match score fields when semantic matching is disabled", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} semanticMatchingEnabled={false} />);
expect(screen.getByText("Semantic keyword matching")).toBeInTheDocument();
expect(screen.queryByText("Embedding model")).not.toBeInTheDocument();
expect(screen.queryByText("Minimum match score")).not.toBeInTheDocument();
});
it("should show embedding model and match score fields when semantic matching is enabled", () => {
renderWithProviders(<ComplexityRouterConfig {...baseProps} semanticMatchingEnabled={true} />);
expect(screen.getByText("Embedding model")).toBeInTheDocument();
expect(screen.getByText("Minimum match score")).toBeInTheDocument();
});
it("should call onSemanticMatchingEnabledChange when the semantic matching switch is toggled", async () => {
const user = userEvent.setup();
const onSemanticMatchingEnabledChange = vi.fn();
renderWithProviders(
<ComplexityRouterConfig
{...baseProps}
semanticMatchingEnabled={false}
onSemanticMatchingEnabledChange={onSemanticMatchingEnabledChange}
/>,
);
await user.click(screen.getByRole("switch"));
expect(onSemanticMatchingEnabledChange).toHaveBeenCalledWith(true, expect.anything());
});
});

View file

@ -2,6 +2,8 @@ import { InfoCircleOutlined } from "@ant-design/icons";
import { Select as AntdSelect, Card, Collapse, Divider, InputNumber, Radio, Space, Tooltip, Typography } from "antd";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules";
import SemanticKeywordMatching from "./SemanticKeywordMatching";
const { Text } = Typography;
@ -33,6 +35,16 @@ interface ComplexityRouterConfigProps {
onChange: (value: ComplexityRouterConfigValue) => void;
customTechnicalKeywords?: string[];
onCustomTechnicalKeywordsChange?: (keywords: string[]) => void;
// Optional: the edit-auto-router modal doesn't yet support editing keyword tier
// rules or semantic matching, so it renders this component without them.
keywordTierRules?: KeywordTierRule[];
onKeywordTierRulesChange?: (rules: KeywordTierRule[]) => void;
semanticMatchingEnabled?: boolean;
onSemanticMatchingEnabledChange?: (enabled: boolean) => void;
embeddingModel?: string;
onEmbeddingModelChange?: (model: string) => void;
matchThreshold?: number;
onMatchThresholdChange?: (threshold: number) => void;
}
const TIER_DESCRIPTIONS: Record<keyof ComplexityTiers, { label: string; description: string; examples: string }> = {
@ -64,6 +76,14 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
onChange,
customTechnicalKeywords,
onCustomTechnicalKeywordsChange,
keywordTierRules = [],
onKeywordTierRulesChange,
semanticMatchingEnabled = false,
onSemanticMatchingEnabledChange,
embeddingModel,
onEmbeddingModelChange = () => {},
matchThreshold = 0.5,
onMatchThresholdChange = () => {},
}) => {
// Prepare model options for dropdowns
const modelOptions = modelInfo.map((model) => ({
@ -239,7 +259,8 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</Tooltip>
</div>
<Text type="secondary" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
Optional: add terms the built-in list misses (e.g., udp, kafka, terraform)
Optional: Add terms to the built-in list to improve classification accuracy on the technical dimension. (e.g.,
udp, kafka, terraform).
</Text>
<AntdSelect
mode="tags"
@ -280,6 +301,31 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
</li>
</ul>
</Card>
{/* Keyword-tier and semantic sections only render when their change handlers are
wired (the add-router flow). The edit-auto-router modal doesn't pass them yet, so
they stay hidden there rather than rendering interactive-but-dead controls. */}
{onKeywordTierRulesChange && (
<>
<Divider />
<KeywordTierRules rules={keywordTierRules} onChange={onKeywordTierRulesChange} />
</>
)}
{onSemanticMatchingEnabledChange && (
<>
<Divider />
<SemanticKeywordMatching
enabled={semanticMatchingEnabled}
onEnabledChange={onSemanticMatchingEnabledChange}
embeddingModel={embeddingModel}
onEmbeddingModelChange={onEmbeddingModelChange}
matchThreshold={matchThreshold}
onMatchThresholdChange={onMatchThresholdChange}
modelInfo={modelInfo}
/>
</>
)}
</div>
);
};

View file

@ -0,0 +1,112 @@
import { DeleteOutlined, InfoCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, Card, Empty, Select as AntdSelect, Tooltip, Typography } from "antd";
import React from "react";
const { Text } = Typography;
export type ComplexityTier = "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
export interface KeywordTierRule {
id: string;
keywords: string[];
tier: ComplexityTier;
}
interface KeywordTierRulesProps {
rules: KeywordTierRule[];
onChange: (rules: KeywordTierRule[]) => void;
}
const TIER_OPTIONS: { value: ComplexityTier; label: string }[] = [
{ value: "SIMPLE", label: "Simple" },
{ value: "MEDIUM", label: "Medium" },
{ value: "COMPLEX", label: "Complex" },
{ value: "REASONING", label: "Reasoning" },
];
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange }) => {
const addRule = () => {
onChange([...rules, { id: `${Date.now()}`, keywords: [], tier: "COMPLEX" }]);
};
const updateRule = (id: string, updates: Partial<Omit<KeywordTierRule, "id">>) => {
onChange(rules.map((rule) => (rule.id === id ? { ...rule, ...updates } : rule)));
};
const removeRule = (id: string) => {
onChange(rules.filter((rule) => rule.id !== id));
};
return (
<div className="w-full max-w-none">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Typography.Title level={4} style={{ margin: 0 }}>
Keyword Tier Overrides
</Typography.Title>
<Tooltip title="Match known terms and force the request straight to a chosen complexity tier, bypassing rule-based scoring.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</div>
<Button icon={<PlusOutlined />} onClick={addRule}>
Add keyword rule
</Button>
</div>
<Text type="secondary" style={{ display: "block", marginBottom: 16 }}>
Optional: route requests containing specific keywords directly to a tier, e.g. route &quot;invoice, refund,
billing&quot; to the medium tier.
</Text>
{rules.length === 0 ? (
<Card className="bg-gray-50">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No keyword tier overrides configured" />
</Card>
) : (
<div className="flex flex-col gap-3">
{rules.map((rule, index) => (
<Card key={rule.id} size="small">
<div className="flex items-end gap-3">
<div className="flex-1">
<Text strong style={{ display: "block", marginBottom: 8 }}>
Keywords {index + 1}
</Text>
<AntdSelect
mode="tags"
value={rule.keywords}
onChange={(keywords: string[]) => updateRule(rule.id, { keywords })}
placeholder="e.g., invoice, refund, billing"
tokenSeparators={[","]}
open={false}
suffixIcon={null}
style={{ width: "100%" }}
allowClear
/>
</div>
<div style={{ width: 220 }}>
<Text strong style={{ display: "block", marginBottom: 8 }}>
Route to tier
</Text>
<AntdSelect
value={rule.tier}
onChange={(tier: ComplexityTier) => updateRule(rule.id, { tier })}
options={TIER_OPTIONS}
style={{ width: "100%" }}
/>
</div>
<Button
danger
type="text"
icon={<DeleteOutlined />}
aria-label={`Remove keyword rule ${index + 1}`}
onClick={() => removeRule(rule.id)}
/>
</div>
</Card>
))}
</div>
)}
</div>
);
};
export default KeywordTierRules;

View file

@ -0,0 +1,84 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Card, InputNumber, Select as AntdSelect, Switch, Tooltip, Typography } from "antd";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
const { Text } = Typography;
const DEFAULT_MATCH_THRESHOLD = 0.5;
interface SemanticKeywordMatchingProps {
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
embeddingModel: string | undefined;
onEmbeddingModelChange: (model: string) => void;
matchThreshold: number;
onMatchThresholdChange: (threshold: number) => void;
modelInfo: ModelGroup[];
}
const SemanticKeywordMatching: React.FC<SemanticKeywordMatchingProps> = ({
enabled,
onEnabledChange,
embeddingModel,
onEmbeddingModelChange,
matchThreshold,
onMatchThresholdChange,
modelInfo,
}) => {
const modelOptions = Array.from(new Set(modelInfo.map((model) => model.model_group))).map((model_group) => ({
value: model_group,
label: model_group,
}));
return (
<Card className="mb-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<Text className="font-medium">Semantic keyword matching</Text>
<Tooltip title="Recognize related phrasing beyond exact keyword matches by comparing embeddings instead of plain text. Overrides direct keyword matching">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</div>
<Text className="text-gray-500 text-sm">
Uses same keyword-tier pairs as above and overrides direct keyword matching. Adds latency based on embedding
model network request.
</Text>
</div>
<Switch checked={enabled} onChange={onEnabledChange} aria-label="Semantic keyword matching" />
</div>
{enabled && (
<div className="grid gap-4 md:grid-cols-2 mt-4 pt-4 border-t border-gray-200">
<div>
<Text className="text-sm font-medium mb-1 block">Embedding model</Text>
<AntdSelect
value={embeddingModel}
onChange={onEmbeddingModelChange}
placeholder="Select an embedding model"
showSearch
style={{ width: "100%" }}
options={modelOptions}
/>
</div>
<div>
<Text className="text-sm font-medium mb-1 block">Minimum match score</Text>
<InputNumber
value={matchThreshold}
onChange={(value) => onMatchThresholdChange(value ?? DEFAULT_MATCH_THRESHOLD)}
min={0}
max={1}
step={0.05}
style={{ width: "100%" }}
/>
<Text className="text-gray-500 text-xs mt-1 block">Match only at or above this similarity score.</Text>
</div>
</div>
)}
</Card>
);
};
export default SemanticKeywordMatching;
export { DEFAULT_MATCH_THRESHOLD };

View file

@ -1,16 +1,18 @@
import React, { useEffect, useState } from "react";
import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Modal, Radio, Badge, Space } from "antd";
import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Radio, Badge, Space } from "antd";
import type { FormInstance } from "antd";
import { ThunderboltOutlined, BranchesOutlined } from "@ant-design/icons";
import { Text, TextInput } from "@tremor/react";
import { modelAvailableCall } from "../networking";
import ConnectionErrorDisplay from "./model_connection_test";
import { all_admin_roles } from "@/utils/roles";
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import RouterConfigBuilder from "./RouterConfigBuilder";
import ComplexityRouterConfig, { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { KeywordTierRule } from "./KeywordTierRules";
import { DEFAULT_MATCH_THRESHOLD } from "./SemanticKeywordMatching";
import { buildComplexityRouterConfig, getSemanticConfigError } from "./build_complexity_router_config";
import NotificationManager from "../molecules/notifications_manager";
import { ThunderboltOutlined, BranchesOutlined } from "@ant-design/icons";
interface AddAutoRouterTabProps {
form: FormInstance;
@ -19,34 +21,29 @@ interface AddAutoRouterTabProps {
userRole: string;
}
type RouterType = "complexity" | "semantic";
type RouterType = "recommended" | "semantic";
const { Title, Link } = Typography;
const { Title } = Typography;
const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, accessToken, userRole }) => {
// State for connection testing
const [isResultModalVisible, setIsResultModalVisible] = useState<boolean>(false);
const [isTestingConnection, setIsTestingConnection] = useState<boolean>(false);
const [connectionTestId, setConnectionTestId] = useState<string>("");
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const [showCustomDefaultModel, setShowCustomDefaultModel] = useState<boolean>(false);
const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState<boolean>(false);
// Router type state - default to complexity router
const [routerType, setRouterType] = useState<RouterType>("complexity");
const [routerType, setRouterType] = useState<RouterType>("recommended");
// Semantic router config (existing)
const [routerConfig, setRouterConfig] = useState<any>(null);
// Complexity router config (new)
const [complexityRouterConfig, setComplexityRouterConfig] = useState<ComplexityRouterConfigValue>({
tiers: { SIMPLE: "", MEDIUM: "", COMPLEX: "", REASONING: "" },
classifier_type: "heuristic",
});
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState<string[]>([]);
const [keywordTierRules, setKeywordTierRules] = useState<KeywordTierRule[]>([]);
const [semanticMatchingEnabled, setSemanticMatchingEnabled] = useState<boolean>(false);
const [embeddingModel, setEmbeddingModel] = useState<string | undefined>(undefined);
const [matchThreshold, setMatchThreshold] = useState<number>(DEFAULT_MATCH_THRESHOLD);
// Semantic router config (existing)
const [routerConfig, setRouterConfig] = useState<any>(null);
useEffect(() => {
const fetchModelAccessGroups = async () => {
@ -70,135 +67,130 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
const isAdmin = all_admin_roles.includes(userRole);
// Test connection when button is clicked
const handleTestConnection = async () => {
setIsTestingConnection(true);
setConnectionTestId(`test-${Date.now()}`);
setIsResultModalVisible(true);
const modelGroupOptions = Array.from(new Set(modelInfo.map((option) => option.model_group))).map((model_group) => ({
value: model_group,
label: model_group,
}));
const submitRecommendedRouter = (name: string) => {
const {
tiers,
classifier_type: classifierType,
classifier_llm_config: classifierLlmConfig,
} = complexityRouterConfig;
const filledTiers = Object.values(tiers).filter(Boolean);
if (filledTiers.length === 0) {
NotificationManager.fromBackend("Please select at least one model for a complexity tier");
return;
}
if (classifierType === "llm" && !classifierLlmConfig?.model) {
NotificationManager.fromBackend("Please select a classifier model, or switch back to Heuristic");
return;
}
const semanticError = getSemanticConfigError({ semanticMatchingEnabled, embeddingModel, keywordTierRules });
if (semanticError) {
NotificationManager.fromBackend(semanticError);
return;
}
const defaultModel = tiers.MEDIUM || tiers.SIMPLE || tiers.COMPLEX || tiers.REASONING;
form.setFieldsValue({
custom_llm_provider: "auto_router",
model: name,
api_key: "not_required_for_auto_router",
auto_router_default_model: defaultModel,
});
form
.validateFields(["auto_router_name"])
.then((values) => {
const complexityRouterConfigParams = {
tiers,
classifierType,
classifierLlmConfig,
customTechnicalKeywords,
keywordTierRules,
semanticMatchingEnabled,
embeddingModel,
matchThreshold,
};
const submitValues = {
...values,
auto_router_name: name,
auto_router_default_model: defaultModel,
model_type: "complexity_router",
complexity_router_config: buildComplexityRouterConfig(complexityRouterConfigParams),
model_access_group: form.getFieldValue("model_access_group"),
};
handleAddAutoRouterSubmit(submitValues, accessToken, form, handleOk);
})
.catch((error) => {
console.error("Validation failed:", error);
NotificationManager.fromBackend("Please fill in all required fields");
});
};
// Auto router specific form submit handler
const handleAutoRouterSubmit = () => {
const currentFormValues = form.getFieldsValue();
const submitSemanticRouter = (name: string) => {
if (!form.getFieldValue("auto_router_default_model")) {
NotificationManager.fromBackend("Please select a Default Model");
return;
}
// Check basic required fields first
if (!currentFormValues.auto_router_name) {
if (!routerConfig || !routerConfig.routes || routerConfig.routes.length === 0) {
NotificationManager.fromBackend("Please configure at least one route for the auto router");
return;
}
const invalidRoutes = routerConfig.routes.filter(
(route: any) => !route.name || !route.description || route.utterances.length === 0,
);
if (invalidRoutes.length > 0) {
NotificationManager.fromBackend(
"Please ensure all routes have a target model, description, and at least one utterance",
);
return;
}
form.setFieldsValue({
custom_llm_provider: "auto_router",
model: name,
api_key: "not_required_for_auto_router",
});
form
.validateFields()
.then((values) => {
const submitValues = {
...values,
auto_router_name: name,
auto_router_config: routerConfig,
model_type: "semantic_router",
};
handleAddAutoRouterSubmit(submitValues, accessToken, form, handleOk);
})
.catch((error) => {
console.error("Validation failed:", error);
NotificationManager.fromBackend("Please fill in all required fields");
});
};
const handleAutoRouterSubmit = () => {
const name = form.getFieldValue("auto_router_name");
if (!name) {
NotificationManager.fromBackend("Please enter an Auto Router Name");
return;
}
// Validation differs based on router type
if (routerType === "complexity") {
// Complexity Router validation
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
const filledTiers = Object.values(tiers).filter(Boolean);
if (filledTiers.length === 0) {
NotificationManager.fromBackend("Please select at least one model for a complexity tier");
return;
}
if (classifier_type === "llm" && !classifier_llm_config?.model) {
NotificationManager.fromBackend("Please select a classifier model, or switch back to Heuristic");
return;
}
// For complexity router, use the first non-empty tier as default
const defaultModel = tiers.MEDIUM || tiers.SIMPLE || tiers.COMPLEX || tiers.REASONING;
// Set form values for complexity router
form.setFieldsValue({
custom_llm_provider: "auto_router",
model: currentFormValues.auto_router_name,
api_key: "not_required_for_auto_router",
auto_router_default_model: defaultModel,
});
form
.validateFields(["auto_router_name"])
.then((values) => {
// Build the complexity router config
const submitValues = {
...values,
auto_router_name: currentFormValues.auto_router_name,
auto_router_default_model: defaultModel,
// Use special model prefix for complexity router
model_type: "complexity_router",
complexity_router_config: {
tiers,
classifier_type,
...(classifier_type === "llm" ? { classifier_llm_config } : {}),
...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),
},
model_access_group: currentFormValues.model_access_group,
};
handleAddAutoRouterSubmit(submitValues, accessToken, form, handleOk);
})
.catch((error) => {
console.error("Validation failed:", error);
NotificationManager.fromBackend("Please fill in all required fields");
});
if (routerType === "recommended") {
submitRecommendedRouter(name);
} else {
// Semantic Router validation (existing logic)
if (!currentFormValues.auto_router_default_model) {
NotificationManager.fromBackend("Please select a Default Model");
return;
}
form.setFieldsValue({
custom_llm_provider: "auto_router",
model: currentFormValues.auto_router_name,
api_key: "not_required_for_auto_router",
});
// Custom validation for router config
if (!routerConfig || !routerConfig.routes || routerConfig.routes.length === 0) {
NotificationManager.fromBackend("Please configure at least one route for the auto router");
return;
}
// Check if all routes have required fields
const invalidRoutes = routerConfig.routes.filter(
(route: any) => !route.name || !route.description || route.utterances.length === 0,
);
if (invalidRoutes.length > 0) {
NotificationManager.fromBackend(
"Please ensure all routes have a target model, description, and at least one utterance",
);
return;
}
form
.validateFields()
.then((values) => {
const submitValues = {
...values,
auto_router_config: routerConfig,
model_type: "semantic_router",
};
handleAddAutoRouterSubmit(submitValues, accessToken, form, handleOk);
})
.catch((error) => {
console.error("Validation failed:", error);
const fieldErrors = error.errorFields || [];
if (fieldErrors.length > 0) {
const missingFields = fieldErrors.map((field: any) => {
const fieldName = field.name[0];
const friendlyNames: { [key: string]: string } = {
auto_router_name: "Auto Router Name",
auto_router_default_model: "Default Model",
auto_router_embedding_model: "Embedding Model",
};
return friendlyNames[fieldName] || fieldName;
});
NotificationManager.fromBackend(
`Please fill in the following required fields: ${missingFields.join(", ")}`,
);
} else {
NotificationManager.fromBackend("Please fill in all required fields");
}
});
submitSemanticRouter(name);
}
};
@ -207,7 +199,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
<Title level={2}>Add Auto Router</Title>
<Text className="text-gray-600 mb-6">
Create an auto router that automatically selects the best model based on request complexity or semantic
matching.
matching. Use in place of a single default model.
</Text>
<Card className="mb-4">
@ -215,35 +207,28 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
<Text className="text-sm font-medium mb-2 block">Router Type</Text>
<Radio.Group value={routerType} onChange={(e) => setRouterType(e.target.value)} className="w-full">
<Space direction="vertical" className="w-full">
<Radio value="complexity" className="w-full">
<Radio value="recommended" className="w-full">
<div className="flex items-center gap-2">
<ThunderboltOutlined className="text-yellow-500" />
<span className="font-medium">Complexity Router</span>
<span className="font-medium">Auto-Router v2</span>
<Badge
count="Recommended"
style={{
backgroundColor: "#52c41a",
fontSize: "10px",
padding: "0 6px",
}}
style={{ backgroundColor: "#52c41a", fontSize: "10px", padding: "0 6px" }}
/>
</div>
<div className="text-xs text-gray-500 ml-6 mt-1">
Automatically routes based on request complexity. No training data needed just pick 4 models and go.
<br />
<span className="text-green-600"> Zero API calls</span> ·{" "}
<span className="text-green-600"> &lt;1ms latency</span> ·{" "}
<span className="text-green-600"> No cost</span>
Routes by request complexity across four tiers, with optional keyword-to-tier overrides and semantic
keyword matching. No training data needed.
</div>
</Radio>
<Radio value="semantic" className="w-full mt-2">
<div className="flex items-center gap-2">
<BranchesOutlined className="text-blue-500" />
<span className="font-medium">Semantic Router</span>
<span className="font-medium">Semantic Router [to be deprecated]</span>
</div>
<div className="text-xs text-gray-500 ml-6 mt-1">
Routes based on semantic similarity to example utterances. Requires embedding model and training
examples.
Routes based on semantic similarity to example utterances. Requires an embedding model and example
utterances.
</div>
</Radio>
</Space>
@ -259,7 +244,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
wrapperCol={{ span: 16 }}
labelAlign="left"
>
{/* Auto Router Name */}
<Form.Item
rules={[{ required: true, message: "Auto router name is required" }]}
label="Auto Router Name"
@ -271,24 +255,26 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
<TextInput placeholder="e.g., smart_router, auto_router_1" />
</Form.Item>
{/* Conditional rendering based on router type */}
{routerType === "complexity" ? (
/* Complexity Router Configuration */
{routerType === "recommended" ? (
<div className="w-full mb-4">
<ComplexityRouterConfig
modelInfo={modelInfo}
value={complexityRouterConfig}
onChange={(config) => {
setComplexityRouterConfig(config);
}}
onChange={setComplexityRouterConfig}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
keywordTierRules={keywordTierRules}
onKeywordTierRulesChange={setKeywordTierRules}
semanticMatchingEnabled={semanticMatchingEnabled}
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
embeddingModel={embeddingModel}
onEmbeddingModelChange={setEmbeddingModel}
matchThreshold={matchThreshold}
onMatchThresholdChange={setMatchThreshold}
/>
</div>
) : (
/* Semantic Router Configuration (existing) */
<>
{/* Router Configuration Builder */}
<div className="w-full mb-4">
<RouterConfigBuilder
modelInfo={modelInfo}
@ -300,9 +286,8 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
/>
</div>
{/* Auto Router Default Model */}
<Form.Item
rules={[{ required: routerType === "semantic", message: "Default model is required" }]}
rules={[{ required: true, message: "Default model is required" }]}
label="Default Model"
name="auto_router_default_model"
tooltip="Fallback model to use when auto routing logic cannot determine the best model"
@ -311,45 +296,24 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
>
<AntdSelect
placeholder="Select a default model"
onChange={(value) => {
setShowCustomDefaultModel(value === "custom");
}}
options={[
...Array.from(new Set(modelInfo.map((option) => option.model_group))).map((model_group) => ({
value: model_group,
label: model_group,
})),
{ value: "custom", label: "Enter custom model name" },
]}
options={modelGroupOptions}
style={{ width: "100%" }}
showSearch={true}
showSearch
/>
</Form.Item>
{/* Auto Router Embedding Model */}
<Form.Item
label="Embedding Model"
name="auto_router_embedding_model"
tooltip="Optional: Embedding model to use for semantic routing decisions"
tooltip="Optional: embedding model to use for semantic routing decisions"
labelCol={{ span: 10 }}
labelAlign="left"
>
<AntdSelect
value={form.getFieldValue("auto_router_embedding_model")}
placeholder="Select an embedding model (optional)"
onChange={(value) => {
setShowCustomEmbeddingModel(value === "custom");
form.setFieldValue("auto_router_embedding_model", value);
}}
options={[
...Array.from(new Set(modelInfo.map((option) => option.model_group))).map((model_group) => ({
value: model_group,
label: model_group,
})),
{ value: "custom", label: "Enter custom model name" },
]}
options={modelGroupOptions}
style={{ width: "100%" }}
showSearch={true}
showSearch
allowClear
/>
</Form.Item>
@ -391,9 +355,10 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
<Typography.Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Typography.Link>
</Tooltip>
<div className="space-x-2">
<Button onClick={handleTestConnection} loading={isTestingConnection}>
Test Connection
</Button>
{/* TODO: add back a Test Connection or JSON preview action here. Test Connection was removed
because prepareModelAddRequest can't build a valid pre-save payload for an auto router
(tiers are model-group references, not litellm_params); a JSON preview of the
complexity_router_config would be a good alternative. */}
<Button
type="primary"
onClick={() => {
@ -406,44 +371,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
</div>
</Form>
</Card>
{/* Test Connection Results Modal */}
<Modal
title="Connection Test Results"
open={isResultModalVisible}
onCancel={() => {
setIsResultModalVisible(false);
setIsTestingConnection(false);
}}
footer={[
<Button
key="close"
onClick={() => {
setIsResultModalVisible(false);
setIsTestingConnection(false);
}}
>
Close
</Button>,
]}
width={700}
>
{/* Only render the ConnectionErrorDisplay when modal is visible and we have a test ID */}
{isResultModalVisible && (
<ConnectionErrorDisplay
key={connectionTestId}
formValues={form.getFieldsValue()}
accessToken={accessToken}
testMode="chat"
modelName={form.getFieldValue("auto_router_name")}
onClose={() => {
setIsResultModalVisible(false);
setIsTestingConnection(false);
}}
onTestComplete={() => setIsTestingConnection(false)}
/>
)}
</Modal>
</>
);
};

View file

@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, renderHook, screen, waitFor } from "@testing-library/react";
import { render, renderHook, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Form } from "antd";
import type { UploadProps } from "antd/es/upload";
@ -299,8 +299,11 @@ describe("Add Model Tab", () => {
// Wait for component to load
await screen.findByText("Provider");
// Find the team-BYOK switch by its role
const teamSwitch = screen.getByRole("switch");
// Scope to the Team-BYOK Model Form.Item: the Add Auto Router tab, mounted alongside
// this one, also renders a "Semantic keyword matching" switch, so a bare
// getByRole("switch") would match more than one element.
const teamByokFormItem = screen.getByText("Team-BYOK Model").closest(".ant-form-item") as HTMLElement;
const teamSwitch = within(teamByokFormItem).getByRole("switch");
expect(teamSwitch).toBeInTheDocument();
// Initially, team selection should not be visible

View file

@ -0,0 +1,164 @@
import {
buildComplexityRouterConfig,
getSemanticConfigError,
BuildComplexityRouterConfigParams,
} from "./build_complexity_router_config";
const tiers = {
SIMPLE: "gpt-4o-mini",
MEDIUM: "gpt-4o",
COMPLEX: "claude-sonnet-4",
REASONING: "o1-preview",
};
const baseParams: BuildComplexityRouterConfigParams = {
tiers,
classifierType: "heuristic",
classifierLlmConfig: undefined,
customTechnicalKeywords: [],
keywordTierRules: [],
semanticMatchingEnabled: false,
embeddingModel: undefined,
matchThreshold: 0.5,
};
describe("buildComplexityRouterConfig", () => {
it("emits only tiers and classifier_type when nothing else is configured", () => {
const config = buildComplexityRouterConfig(baseParams);
expect(config).toEqual({ tiers, classifier_type: "heuristic" });
});
it("includes classifier_llm_config only when classifier_type is llm", () => {
const config = buildComplexityRouterConfig({
...baseParams,
classifierType: "llm",
classifierLlmConfig: { model: "gpt-4o-mini", timeout_ms: 3000 },
});
expect(config.classifier_type).toBe("llm");
expect(config.classifier_llm_config).toEqual({ model: "gpt-4o-mini", timeout_ms: 3000 });
});
it("omits classifier_llm_config when classifier_type is heuristic even if config lingers in state", () => {
const config = buildComplexityRouterConfig({
...baseParams,
classifierType: "heuristic",
classifierLlmConfig: { model: "gpt-4o-mini", timeout_ms: 3000 },
});
expect(config.classifier_llm_config).toBeUndefined();
});
it("sends keyword_tier_rules with their per-tier targeting preserved (not flattened)", () => {
const params: BuildComplexityRouterConfigParams = {
...baseParams,
customTechnicalKeywords: ["udp"],
keywordTierRules: [
{ id: "r1", keywords: ["deploy to k8s"], tier: "REASONING" },
{ id: "r2", keywords: ["invoice", "refund"], tier: "SIMPLE" },
],
};
const config = buildComplexityRouterConfig(params);
expect(config.keyword_tier_rules).toEqual([
{ keywords: ["deploy to k8s"], tier: "REASONING" },
{ keywords: ["invoice", "refund"], tier: "SIMPLE" },
]);
// custom technical keywords stay their own list, not merged with rule keywords
expect(config.custom_technical_keywords).toEqual(["udp"]);
expect(config.semantic_keyword_matching).toBeUndefined();
});
it("includes semantic fields only when semantic matching is enabled", () => {
const params: BuildComplexityRouterConfigParams = {
...baseParams,
keywordTierRules: [{ id: "r1", keywords: ["k8s"], tier: "REASONING" }],
semanticMatchingEnabled: true,
embeddingModel: "openai/text-embedding-3-small",
matchThreshold: 0.42,
};
const config = buildComplexityRouterConfig(params);
expect(config.semantic_keyword_matching).toBe(true);
expect(config.embedding_model).toBe("openai/text-embedding-3-small");
expect(config.match_threshold).toBe(0.42);
});
it("omits semantic fields when the toggle is off even if an embedding model lingers in state", () => {
const params: BuildComplexityRouterConfigParams = {
...baseParams,
keywordTierRules: [{ id: "r1", keywords: ["k8s"], tier: "REASONING" }],
semanticMatchingEnabled: false,
embeddingModel: "openai/text-embedding-3-small",
matchThreshold: 0.42,
};
const config = buildComplexityRouterConfig(params);
expect(config.semantic_keyword_matching).toBeUndefined();
expect(config.embedding_model).toBeUndefined();
expect(config.match_threshold).toBeUndefined();
});
it("omits empty optional lists", () => {
const config = buildComplexityRouterConfig(baseParams);
expect(config.custom_technical_keywords).toBeUndefined();
expect(config.keyword_tier_rules).toBeUndefined();
});
it("trims keywords and drops rules left empty, so unfilled rows never 400 the backend", () => {
const params: BuildComplexityRouterConfigParams = {
...baseParams,
keywordTierRules: [
{ id: "r1", keywords: [" deploy to k8s ", "", " "], tier: "REASONING" },
{ id: "r2", keywords: [], tier: "COMPLEX" }, // seeded by "Add keyword rule", never filled
{ id: "r3", keywords: [" "], tier: "SIMPLE" }, // whitespace only
],
};
const config = buildComplexityRouterConfig(params);
// r1 keeps only its real keyword (trimmed); r2 and r3 are dropped entirely.
expect(config.keyword_tier_rules).toEqual([{ keywords: ["deploy to k8s"], tier: "REASONING" }]);
});
it("omits keyword_tier_rules entirely when every rule is empty", () => {
const params: BuildComplexityRouterConfigParams = {
...baseParams,
keywordTierRules: [{ id: "r1", keywords: ["", " "], tier: "COMPLEX" }],
};
const config = buildComplexityRouterConfig(params);
expect(config.keyword_tier_rules).toBeUndefined();
});
});
describe("getSemanticConfigError", () => {
const rule = { id: "r1", keywords: ["k8s"], tier: "REASONING" as const };
it("returns null when semantic matching is disabled (even with gaps)", () => {
expect(
getSemanticConfigError({ semanticMatchingEnabled: false, embeddingModel: undefined, keywordTierRules: [] }),
).toBeNull();
});
it("errors when enabled without an embedding model", () => {
expect(
getSemanticConfigError({ semanticMatchingEnabled: true, embeddingModel: undefined, keywordTierRules: [rule] }),
).toMatch(/embedding model/i);
});
it("errors when enabled with an embedding model but no keyword tier rules", () => {
expect(
getSemanticConfigError({ semanticMatchingEnabled: true, embeddingModel: "voyage-3-5", keywordTierRules: [] }),
).toMatch(/keyword tier rule/i);
});
it("errors when a rule has no non-empty keywords", () => {
const emptyRule = { id: "r2", keywords: ["", " "], tier: "SIMPLE" as const };
expect(
getSemanticConfigError({
semanticMatchingEnabled: true,
embeddingModel: "voyage-3-5",
keywordTierRules: [emptyRule],
}),
).toMatch(/at least one keyword/i);
});
it("returns null when enabled with both an embedding model and rules", () => {
expect(
getSemanticConfigError({ semanticMatchingEnabled: true, embeddingModel: "voyage-3-5", keywordTierRules: [rule] }),
).toBeNull();
});
});

View file

@ -0,0 +1,78 @@
import { KeywordTierRule } from "./KeywordTierRules";
import { ClassifierLLMConfig, ClassifierType } from "./ComplexityRouterConfig";
export interface ComplexityTiers {
SIMPLE: string;
MEDIUM: string;
COMPLEX: string;
REASONING: string;
}
export interface BuildComplexityRouterConfigParams {
tiers: ComplexityTiers;
classifierType: ClassifierType;
classifierLlmConfig: ClassifierLLMConfig | undefined;
customTechnicalKeywords: string[];
keywordTierRules: KeywordTierRule[];
semanticMatchingEnabled: boolean;
embeddingModel: string | undefined;
matchThreshold: number;
}
export interface ComplexityRouterConfigPayload {
tiers: ComplexityTiers;
classifier_type: ClassifierType;
classifier_llm_config?: ClassifierLLMConfig;
custom_technical_keywords?: string[];
keyword_tier_rules?: { keywords: string[]; tier: KeywordTierRule["tier"] }[];
semantic_keyword_matching?: boolean;
embedding_model?: string;
match_threshold?: number;
}
export const getSemanticConfigError = ({
semanticMatchingEnabled,
embeddingModel,
keywordTierRules,
}: Pick<BuildComplexityRouterConfigParams, "semanticMatchingEnabled" | "embeddingModel" | "keywordTierRules">):
| string
| null => {
if (!semanticMatchingEnabled) return null;
if (!embeddingModel) return "Select an embedding model to use semantic keyword matching";
if (keywordTierRules.length === 0) return "Add at least one keyword tier rule to use semantic keyword matching";
if (keywordTierRules.some((rule) => !rule.keywords.some((keyword) => keyword.trim())))
return "Every keyword tier rule needs at least one keyword";
return null;
};
export const buildComplexityRouterConfig = ({
tiers,
classifierType,
classifierLlmConfig,
customTechnicalKeywords,
keywordTierRules,
semanticMatchingEnabled,
embeddingModel,
matchThreshold,
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
// Trim keywords and drop empty ones; drop any rule left with no keywords. Clicking
// "Add keyword rule" seeds a rule with an empty keywords list, so without this an
// unfilled row (common in the heuristic flow, where getSemanticConfigError doesn't run)
// would ship keyword_tier_rules the backend validator rejects with a 400.
const cleanedKeywordTierRules = keywordTierRules
.map((rule) => ({ keywords: rule.keywords.map((k) => k.trim()).filter(Boolean), tier: rule.tier }))
.filter((rule) => rule.keywords.length > 0);
return {
tiers,
classifier_type: classifierType,
...(classifierType === "llm" && classifierLlmConfig && { classifier_llm_config: classifierLlmConfig }),
...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),
...(cleanedKeywordTierRules.length > 0 && { keyword_tier_rules: cleanedKeywordTierRules }),
...(semanticMatchingEnabled && {
semantic_keyword_matching: true,
embedding_model: embeddingModel,
match_threshold: matchThreshold,
}),
};
};

View file

@ -6,62 +6,46 @@ export const handleAddAutoRouterSubmit = async (values: any, accessToken: string
let autoRouterConfig: any;
if (values.model_type === "complexity_router") {
// Complexity Router configuration
autoRouterConfig = {
model_name: values.auto_router_name,
litellm_params: {
// Use special prefix for complexity router
model: `auto_router/complexity_router`,
// Pass the complexity router config as a JSON object (not stringified)
complexity_router_config: values.complexity_router_config,
// Default model for fallback (use MEDIUM or first available tier)
complexity_router_default_model: values.auto_router_default_model,
},
model_info: {},
};
} else {
// Semantic Router configuration (existing behavior)
autoRouterConfig = {
model_name: values.auto_router_name,
litellm_params: {
model: `auto_router/${values.auto_router_name}`,
auto_router_config: JSON.stringify(values.auto_router_config), // Convert JSON object to string as expected by backend
auto_router_config: JSON.stringify(values.auto_router_config),
auto_router_default_model: values.auto_router_default_model,
},
model_info: {},
};
// Add optional embedding model if provided
if (values.auto_router_embedding_model && values.auto_router_embedding_model !== "custom") {
if (values.auto_router_embedding_model) {
autoRouterConfig.litellm_params.auto_router_embedding_model = values.auto_router_embedding_model;
} else if (values.custom_embedding_model) {
autoRouterConfig.litellm_params.auto_router_embedding_model = values.custom_embedding_model;
}
}
// Add team information if provided
if (values.team_id) {
autoRouterConfig.model_info.team_id = values.team_id;
}
// Add model access groups if provided
if (values.model_access_group && values.model_access_group.length > 0) {
autoRouterConfig.model_info.access_groups = values.model_access_group;
}
// Create the auto router using the same model creation endpoint
const response: any = await modelCreateCall(accessToken, autoRouterConfig as Model);
await modelCreateCall(accessToken, autoRouterConfig as Model);
// Show success notification
const routerTypeName = values.model_type === "complexity_router" ? "Complexity Router" : "Semantic Router";
const routerTypeName = values.model_type === "complexity_router" ? "Auto Router" : "Semantic Router";
NotificationManager.success(`Successfully created ${routerTypeName}: ${values.auto_router_name}`);
// Reset the form
form.resetFields();
// Call the callback if provided (e.g., to close modal)
if (callback) {
callback();
}