mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
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:
commit
92dfbdbb21
15 changed files with 2039 additions and 293 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 "invoice, refund,
|
||||
billing" 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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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">✓ <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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue