mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
Merge pull request #40273 from BerriAI/litellm_non_reasoning_tier
feat(auto_router): opt-in NON_REASONING tier below SIMPLE
This commit is contained in:
commit
6112274350
22 changed files with 637 additions and 67 deletions
|
|
@ -108,6 +108,11 @@ _CALIBRATION_EXAMPLES: Final[Mapping[ClassificationRubric, str]] = MappingProxyT
|
|||
|
||||
BUSINESS_TIER_CRITERIA: Final[Mapping[ComplexityTier, str]] = MappingProxyType(
|
||||
{
|
||||
ComplexityTier.NON_REASONING: (
|
||||
"operational requests whose whole job is to pass information along or put it in a requested "
|
||||
"shape: relaying or reformatting tool or system output, acknowledging a completed action, or "
|
||||
"extracting a stated field. Use it only when no judgment about the content is asked for."
|
||||
),
|
||||
ComplexityTier.SIMPLE: (
|
||||
"greetings, chitchat, or lookups of a fact, policy, price, or date with a short known answer. "
|
||||
"Never for analysis, strategy, or non-trivial work, even if the request is only one sentence."
|
||||
|
|
|
|||
|
|
@ -118,8 +118,20 @@ def _tier_name(tier: ComplexityTier | str) -> str:
|
|||
return tier.value if isinstance(tier, ComplexityTier) else tier
|
||||
|
||||
|
||||
def _built_in_tier_or_none(tier_name: str) -> ComplexityTier | None:
|
||||
"""The built-in tier a `tiers` key names, or None when the key is an operator-defined name."""
|
||||
return ComplexityTier.__members__.get(tier_name)
|
||||
|
||||
|
||||
_CLASSIFICATION_TIER_CRITERIA: Final[Mapping[ComplexityTier, str]] = MappingProxyType(
|
||||
{
|
||||
ComplexityTier.NON_REASONING: (
|
||||
"operational requests whose whole job is to pass information along or put it in a "
|
||||
"requested shape: relaying or reformatting tool output, acknowledging a completed action, "
|
||||
"or extracting a stated value. Use it only when no judgment about the content is asked for; "
|
||||
"the moment the request is to summarize, compare, explain, debug, or decide, it belongs "
|
||||
"in a higher tier however short it is."
|
||||
),
|
||||
ComplexityTier.SIMPLE: (
|
||||
"greetings, chitchat, or factual lookups with a short known answer. Do not use this tier for "
|
||||
"unsolved problems, proofs, deep theory, multi-step analysis, or non-trivial code, even if the "
|
||||
|
|
@ -1244,7 +1256,7 @@ class ComplexityRouter(CustomLogger):
|
|||
"""
|
||||
if self.config.has_custom_tiers:
|
||||
return tuple(dict.fromkeys(model for models in self._tier_pools().values() for model in models))
|
||||
for tier in reversed(TIER_SEVERITY_ORDER):
|
||||
for tier in reversed(self.config.active_tier_severity_order()):
|
||||
models = self.config.tiers.get(tier.value)
|
||||
if models:
|
||||
return tuple(models) if isinstance(models, list) else (models,)
|
||||
|
|
@ -1890,7 +1902,11 @@ class ComplexityRouter(CustomLogger):
|
|||
default_model: Final = self.config.default_model
|
||||
pools: Final = self._tier_pools()
|
||||
tier: Final = next(
|
||||
(candidate for candidate in TIER_SEVERITY_ORDER if default_model in pools.get(candidate.value, ())),
|
||||
(
|
||||
candidate
|
||||
for candidate in self.config.active_tier_severity_order()
|
||||
if default_model in pools.get(candidate.value, ())
|
||||
),
|
||||
ComplexityTier.MEDIUM,
|
||||
)
|
||||
return ClassificationOutcome(
|
||||
|
|
@ -2279,7 +2295,8 @@ class ComplexityRouter(CustomLogger):
|
|||
return self._fitting_tier_fallback(classified_tier, fit_filter)
|
||||
|
||||
request_type: Final = classify_prompt(user_message)
|
||||
classified_idx: Final = TIER_SEVERITY_ORDER.index(classified_tier)
|
||||
severity_order: Final = self.config.active_tier_severity_order()
|
||||
classified_idx: Final = severity_order.index(classified_tier)
|
||||
pools: Final = self._tier_pools()
|
||||
classified_candidates: Final = _allowed(tuple(pools.get(_tier_name(classified_tier), ())), fit_filter)
|
||||
cold_start_candidates: Final = tuple(
|
||||
|
|
@ -2343,9 +2360,7 @@ class ComplexityRouter(CustomLogger):
|
|||
distance = 0
|
||||
else:
|
||||
model_tiers = self._model_tiers.get(model, (classified_tier,))
|
||||
distance = min(
|
||||
abs(TIER_SEVERITY_ORDER.index(model_tier) - classified_idx) for model_tier in model_tiers
|
||||
)
|
||||
distance = min(abs(severity_order.index(model_tier) - classified_idx) for model_tier in model_tiers)
|
||||
score = quality_weight * quality_sample + cost_weight * cost_score - penalty_weight * distance
|
||||
candidate_scores.append(
|
||||
{
|
||||
|
|
@ -2660,10 +2675,15 @@ class ComplexityRouter(CustomLogger):
|
|||
def _tier_for_model(self, model: str) -> ComplexityTier | None:
|
||||
"""Return the most-severe configured tier whose pool contains this model."""
|
||||
pools: Final = self._tier_pools()
|
||||
matched: Final = tuple(ComplexityTier(tier_name) for tier_name, models in pools.items() if model in models)
|
||||
order: Final = self.config.active_tier_severity_order()
|
||||
matched: Final = tuple(
|
||||
tier
|
||||
for tier_name, models in pools.items()
|
||||
if model in models and (tier := _built_in_tier_or_none(tier_name)) is not None and tier in order
|
||||
)
|
||||
if not matched:
|
||||
return None
|
||||
return max(matched, key=TIER_SEVERITY_ORDER.index)
|
||||
return max(matched, key=order.index)
|
||||
|
||||
def _escalate_tier(self, tier: ComplexityTier | str) -> ComplexityTier | str:
|
||||
"""Bump a tier one step up to the next-higher configured tier.
|
||||
|
|
@ -2678,9 +2698,10 @@ class ComplexityRouter(CustomLogger):
|
|||
if self.config.has_custom_tiers:
|
||||
return tier
|
||||
configured: Final = frozenset(self.config.tiers)
|
||||
current_index: Final = TIER_SEVERITY_ORDER.index(tier)
|
||||
order: Final = self.config.active_tier_severity_order()
|
||||
current_index: Final = order.index(tier)
|
||||
higher_tiers: Final = tuple(
|
||||
candidate for candidate in TIER_SEVERITY_ORDER[current_index + 1 :] if candidate.value in configured
|
||||
candidate for candidate in order[current_index + 1 :] if candidate.value in configured
|
||||
)
|
||||
return higher_tiers[0] if higher_tiers else tier
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from .tier_predictor import TrainedTierArtifact
|
|||
class ComplexityTier(str, Enum):
|
||||
"""Complexity tiers for routing decisions."""
|
||||
|
||||
NON_REASONING = "NON_REASONING"
|
||||
SIMPLE = "SIMPLE"
|
||||
MEDIUM = "MEDIUM"
|
||||
COMPLEX = "COMPLEX"
|
||||
|
|
@ -62,6 +63,16 @@ TIER_SEVERITY_ORDER: Final[tuple[ComplexityTier, ...]] = (
|
|||
ComplexityTier.REASONING,
|
||||
)
|
||||
|
||||
NON_REASONING_TIER_SEVERITY_ORDER: Final[tuple[ComplexityTier, ...]] = (
|
||||
ComplexityTier.NON_REASONING,
|
||||
*TIER_SEVERITY_ORDER,
|
||||
)
|
||||
|
||||
|
||||
def tier_severity_order(non_reasoning_enabled: bool) -> tuple[ComplexityTier, ...]:
|
||||
return NON_REASONING_TIER_SEVERITY_ORDER if non_reasoning_enabled else TIER_SEVERITY_ORDER
|
||||
|
||||
|
||||
DEFAULT_TIER_DISTANCE_PENALTY: Final[float] = 0.5
|
||||
|
||||
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE: Final[int] = 3
|
||||
|
|
@ -142,6 +153,9 @@ def normalize_classification_examples(value: str | None) -> str | None:
|
|||
return _normalize_operator_section(value, "classification_examples", MAX_CLASSIFICATION_EXAMPLES_CHARS)
|
||||
|
||||
|
||||
_BUILT_IN_TIER_NAMES: Final[str] = ", ".join(ComplexityTier.__members__)
|
||||
|
||||
|
||||
class TierDefinition(BaseModel):
|
||||
"""An operator-defined tier: the name the LLM classifier must return and its rubric description."""
|
||||
|
||||
|
|
@ -152,7 +166,7 @@ class TierDefinition(BaseModel):
|
|||
default=None,
|
||||
description=(
|
||||
"What belongs in this tier; rendered as this tier's bullet in the classifier rubric. "
|
||||
"Required unless the name is a built-in tier (SIMPLE/MEDIUM/COMPLEX/REASONING), which "
|
||||
f"Required unless the name is a built-in tier ({_BUILT_IN_TIER_NAMES}), which "
|
||||
"inherits the built-in criteria when omitted"
|
||||
),
|
||||
)
|
||||
|
|
@ -174,7 +188,7 @@ class TierDefinition(BaseModel):
|
|||
if description is None and name.upper() not in ComplexityTier.__members__:
|
||||
raise ValueError(
|
||||
f"tier_definitions entry {name!r} must have a description: only the built-in tiers "
|
||||
"(SIMPLE, MEDIUM, COMPLEX, REASONING) carry one the rubric can inherit"
|
||||
f"({_BUILT_IN_TIER_NAMES}) carry one the rubric can inherit"
|
||||
)
|
||||
rendered_on_one_line: Final = (name, description or "")
|
||||
if any("\n" in part or "\r" in part for part in rendered_on_one_line):
|
||||
|
|
@ -711,6 +725,20 @@ class ComplexityRouterConfig(BaseModel):
|
|||
default_factory=dict,
|
||||
)
|
||||
|
||||
enable_non_reasoning_tier: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Add NON_REASONING as a fifth built-in tier below SIMPLE, for operational agent traffic "
|
||||
"that relays or reformats information rather than reasoning about it. Off by default: "
|
||||
"turning it on adds a rung to this router's ladder, a bullet to the LLM classifier's "
|
||||
"rubric, and a value the classifier may return, all of which move tier decisions and "
|
||||
"spend on an already-deployed router. Requires an LLM classifier or a custom classifier "
|
||||
"plugin, since the heuristic scorers cannot produce the tier, and a model in `tiers` "
|
||||
"under the NON_REASONING key. Escalation still walks up from it, and it is never the "
|
||||
"savings baseline or a `heuristic_v2` prediction."
|
||||
),
|
||||
)
|
||||
|
||||
tier_definitions: tuple[TierDefinition, ...] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
|
|
@ -1514,11 +1542,15 @@ class ComplexityRouterConfig(BaseModel):
|
|||
which still makes it a dependency on every one of those requests."""
|
||||
return self.classifier_type in LLM_CLASSIFIER_TYPES
|
||||
|
||||
def active_tier_severity_order(self) -> tuple[ComplexityTier, ...]:
|
||||
"""This router's built-in ladder, ascending; not meaningful for a custom tier set."""
|
||||
return tier_severity_order(self.enable_non_reasoning_tier)
|
||||
|
||||
def tier_names(self) -> tuple[str, ...]:
|
||||
"""The active tier names: the defined names, or the built-in set in severity order."""
|
||||
if self.tier_definitions is not None:
|
||||
return tuple(definition.name for definition in self.tier_definitions)
|
||||
return tuple(tier.value for tier in TIER_SEVERITY_ORDER)
|
||||
return tuple(tier.value for tier in self.active_tier_severity_order())
|
||||
|
||||
def classifier_wire_labels(self) -> tuple[str, ...]:
|
||||
"""The tier names the classifier is told to emit: defined names, or the display labels."""
|
||||
|
|
@ -1610,6 +1642,36 @@ class ComplexityRouterConfig(BaseModel):
|
|||
if present
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_non_reasoning_tier(self) -> "ComplexityRouterConfig":
|
||||
"""Require a classifier that can emit the opt-in tier and a model to route it to."""
|
||||
non_reasoning_key: Final = ComplexityTier.NON_REASONING.value
|
||||
if not self.enable_non_reasoning_tier:
|
||||
if not self.has_custom_tiers and non_reasoning_key in self.tiers:
|
||||
raise ValueError(
|
||||
f"tiers names {non_reasoning_key} but enable_non_reasoning_tier is False, so no request "
|
||||
"can route there; set enable_non_reasoning_tier: true or drop the tier"
|
||||
)
|
||||
return self
|
||||
if self.has_custom_tiers:
|
||||
raise ValueError(
|
||||
"enable_non_reasoning_tier cannot be combined with tier_definitions: a custom tier set "
|
||||
f"replaces the built-in ladder, so name a tier {non_reasoning_key} in tier_definitions instead"
|
||||
)
|
||||
if self.classifier_type not in ("llm", "custom"):
|
||||
raise ValueError(
|
||||
f"enable_non_reasoning_tier requires classifier_type 'llm' or 'custom', got "
|
||||
f"{self.classifier_type!r}: the heuristic scorers only produce the four tiers from SIMPLE up, "
|
||||
f"so nothing would ever classify as {non_reasoning_key}"
|
||||
)
|
||||
if not self.tiers.get(non_reasoning_key):
|
||||
raise ValueError(
|
||||
f"enable_non_reasoning_tier requires tiers to map {non_reasoning_key} to at least one model: "
|
||||
"the tier exists to send operational traffic somewhere cheaper, and an unconfigured tier "
|
||||
"would fall through to the default model"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_tier_definitions(self) -> "ComplexityRouterConfig":
|
||||
if self.tier_definitions is None:
|
||||
|
|
@ -1632,7 +1694,7 @@ class ComplexityRouterConfig(BaseModel):
|
|||
if self.classifier_type in ("heuristic", "heuristic_v2", "heuristic_first", "hybrid"):
|
||||
raise ValueError(
|
||||
"tier_definitions requires classifier_type 'llm' or 'custom': the heuristic scorer only "
|
||||
"produces the four built-in tiers, as does heuristic_v2"
|
||||
"produces the built-in tiers from SIMPLE up, as does heuristic_v2"
|
||||
)
|
||||
conflicts: Final = self._tier_definition_conflicts()
|
||||
if conflicts:
|
||||
|
|
@ -1786,7 +1848,7 @@ class ComplexityRouterConfig(BaseModel):
|
|||
|
||||
def labeled_tiers(self) -> tuple[tuple[ComplexityTier, str], ...]:
|
||||
"""Every tier paired with its display name, in ascending severity order."""
|
||||
return tuple((tier, self.tier_label(tier)) for tier in TIER_SEVERITY_ORDER)
|
||||
return tuple((tier, self.tier_label(tier)) for tier in self.active_tier_severity_order())
|
||||
|
||||
def tier_for_label(self, label: str) -> ComplexityTier | None:
|
||||
"""Resolve a display name back to its tier, case-insensitively, then canonical names."""
|
||||
|
|
@ -1794,7 +1856,7 @@ class ComplexityRouterConfig(BaseModel):
|
|||
labeled: Final = self.labeled_tiers()
|
||||
return next(
|
||||
(tier for tier, tier_label in labeled if tier_label.casefold() == folded),
|
||||
next((tier for tier in TIER_SEVERITY_ORDER if tier.value.casefold() == folded), None),
|
||||
next((tier for tier, _ in labeled if tier.value.casefold() == folded), None),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from litellm.router_strategy.complexity_router.config import (
|
|||
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE,
|
||||
DEFAULT_COMPLEXITY_CONFIG,
|
||||
DEFAULT_TECHNICAL_KEYWORDS,
|
||||
TIER_SEVERITY_ORDER,
|
||||
ClassificationRubric,
|
||||
ClassifierLLMConfig,
|
||||
ComplexityRouterConfig,
|
||||
|
|
@ -11132,7 +11133,7 @@ async def test_tier_model_params_reach_the_hook_response_and_override_client_val
|
|||
async def test_tier_params_mask_credentials_in_routing_decision(route, mock_router_instance):
|
||||
params = {"reasoning_effort": "xhigh", "api_key": "secret-tier-key"}
|
||||
config = {
|
||||
"tiers": {tier.value: {"model_name": "opus", "litellm_params": params} for tier in ComplexityTier},
|
||||
"tiers": {tier.value: {"model_name": "opus", "litellm_params": params} for tier in TIER_SEVERITY_ORDER},
|
||||
"keyword_tier_rules": [{"keywords": ["reason carefully"], "tier": "REASONING"}] if route == "keyword" else None,
|
||||
"session_affinity": route == "session",
|
||||
}
|
||||
|
|
@ -13660,3 +13661,181 @@ class _OutputCeilingRecorder(CustomLogger):
|
|||
|
||||
def log_pre_api_call(self, model, messages, kwargs):
|
||||
self.seen.append((model, kwargs.get("optional_params", {}).get("max_tokens")))
|
||||
|
||||
NON_REASONING_TIERS: Final = {
|
||||
"NON_REASONING": "gpt-4o-mini",
|
||||
"SIMPLE": "gpt-4o-mini",
|
||||
"MEDIUM": "gpt-4o",
|
||||
"COMPLEX": "claude-sonnet-4-20250514",
|
||||
"REASONING": "o1-preview",
|
||||
}
|
||||
|
||||
|
||||
class TestNonReasoningTier:
|
||||
"""The opt-in fifth built-in tier below SIMPLE: inert unless enabled, reachable when it is."""
|
||||
|
||||
@staticmethod
|
||||
def _router(mock_router_instance, **overrides) -> ComplexityRouter:
|
||||
config: Final = {
|
||||
"tiers": dict(NON_REASONING_TIERS),
|
||||
"enable_non_reasoning_tier": True,
|
||||
"classifier_type": "llm",
|
||||
"classifier_llm_config": {"model": "haiku-classifier"},
|
||||
**overrides,
|
||||
}
|
||||
return ComplexityRouter(
|
||||
model_name="test-non-reasoning-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=config,
|
||||
)
|
||||
|
||||
def test_ladder_gains_a_rung_below_simple_only_when_enabled(self):
|
||||
"""Tier 0 sits at the bottom; anywhere else and escalation and the baseline shift."""
|
||||
enabled: Final = ComplexityRouterConfig(
|
||||
tiers=dict(NON_REASONING_TIERS),
|
||||
enable_non_reasoning_tier=True,
|
||||
classifier_type="llm",
|
||||
classifier_llm_config={"model": "clf"},
|
||||
)
|
||||
assert enabled.tier_names() == ("NON_REASONING", "SIMPLE", "MEDIUM", "COMPLEX", "REASONING")
|
||||
assert ComplexityRouterConfig().tier_names() == ("SIMPLE", "MEDIUM", "COMPLEX", "REASONING")
|
||||
|
||||
def test_default_router_is_unchanged_by_the_tier_existing(self):
|
||||
"""The enum grew a member, and nothing a four-tier router sends or resolves may change."""
|
||||
default: Final = ComplexityRouterConfig()
|
||||
assert default.enable_non_reasoning_tier is False
|
||||
assert "NON_REASONING" not in DEFAULT_COMPLEXITY_CONFIG.tiers
|
||||
assert default.classifier_wire_labels() == ("SIMPLE", "MEDIUM", "COMPLEX", "REASONING")
|
||||
assert default.labeled_tiers() == TIER_SEVERITY_ORDER_LABELED
|
||||
assert default.resolve_classified_tier("NON_REASONING") is None
|
||||
|
||||
@pytest.mark.parametrize("preset", tuple(ClassificationRubric))
|
||||
def test_rubric_gains_the_bullet_only_when_enabled(self, preset):
|
||||
"""An unset toggle leaves every shipped rubric byte-identical; an enabled one adds a bullet."""
|
||||
enabled: Final = ComplexityRouterConfig(
|
||||
tiers=dict(NON_REASONING_TIERS),
|
||||
enable_non_reasoning_tier=True,
|
||||
classifier_type="llm",
|
||||
classifier_llm_config={"model": "clf"},
|
||||
)
|
||||
on: Final = classification_system_prompt(3, None, enabled.labeled_tiers(), preset)
|
||||
off: Final = classification_system_prompt(3, None, ComplexityRouterConfig().labeled_tiers(), preset)
|
||||
assert "- NON_REASONING:" in on
|
||||
assert "- NON_REASONING" not in off
|
||||
|
||||
def test_enabled_router_puts_the_tier_on_the_classifier_wire(self, mock_router_instance):
|
||||
"""The schema enum bounds what the classifier may return, whatever the rubric says."""
|
||||
router: Final = self._router(mock_router_instance)
|
||||
enum: Final = router._classifier_response_format["json_schema"]["schema"]["properties"]["tier"]["enum"]
|
||||
assert enum == ["NON_REASONING", "SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classifier_verdict_routes_to_the_tier_model(self, mock_router_instance):
|
||||
"""The classifier names the tier and the request lands on that tier's model."""
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "NON_REASONING"}'))
|
||||
router: Final = self._router(
|
||||
mock_router_instance, tiers={**NON_REASONING_TIERS, "NON_REASONING": "cheap-relay"}
|
||||
)
|
||||
response = await router.async_pre_routing_hook(
|
||||
model="test-non-reasoning-router",
|
||||
request_kwargs={},
|
||||
messages=[{"role": "user", "content": "here is the file, pass it along"}],
|
||||
)
|
||||
assert response.model == "cheap-relay"
|
||||
assert response.routing_decision["tier"] == "NON_REASONING"
|
||||
assert response.routing_decision["cause"] == "llm_classifier"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_four_tier_router_ignores_a_non_reasoning_verdict(
|
||||
self, llm_complexity_router, mock_router_instance
|
||||
):
|
||||
"""Naming the tier at a router that never opted in falls back instead of routing there."""
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "NON_REASONING"}'))
|
||||
outcome = await llm_complexity_router.aclassify("relay this")
|
||||
assert outcome.tier != ComplexityTier.NON_REASONING
|
||||
assert outcome.cause != "llm_classifier"
|
||||
|
||||
def test_escalation_walks_up_off_the_tier(self, mock_router_instance):
|
||||
"""Escalation is a built-in-ladder feature and the issue asks for it from the new tier."""
|
||||
router: Final = self._router(mock_router_instance)
|
||||
assert router._escalate_tier(ComplexityTier.NON_REASONING) == ComplexityTier.SIMPLE
|
||||
assert router._escalate_tier(ComplexityTier.REASONING) == ComplexityTier.REASONING
|
||||
|
||||
def test_escalation_skips_the_tier_when_unconfigured(self, mock_router_instance):
|
||||
"""SIMPLE still escalates to MEDIUM, so escalation never routes below the caller's model."""
|
||||
router: Final = self._router(
|
||||
mock_router_instance,
|
||||
tiers={"NON_REASONING": "cheap-relay", "SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o"},
|
||||
)
|
||||
assert router._escalate_tier(ComplexityTier.SIMPLE) == ComplexityTier.MEDIUM
|
||||
|
||||
def test_tier_zero_is_never_the_savings_baseline(self, mock_router_instance):
|
||||
"""Savings use the hardest configured tier; tier 0 winning would invert every figure."""
|
||||
assert self._router(mock_router_instance)._hardest_tier_models() == ("o1-preview",)
|
||||
cheap_only: Final = self._router(
|
||||
mock_router_instance, tiers={"NON_REASONING": "cheap-relay", "SIMPLE": "gpt-4o-mini"}
|
||||
)
|
||||
assert cheap_only._hardest_tier_models() == ("gpt-4o-mini",)
|
||||
|
||||
def test_the_tier_gets_its_own_display_label(self, mock_router_instance):
|
||||
"""tier_labels covers the built-in tiers, so the new rung must be renameable like the rest."""
|
||||
router: Final = self._router(mock_router_instance, tier_labels={"NON_REASONING": "Relay"})
|
||||
assert router.config.classifier_wire_labels()[0] == "Relay"
|
||||
assert router.config.resolve_classified_tier("relay") == ComplexityTier.NON_REASONING
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides, expected",
|
||||
(
|
||||
({"classifier_type": "heuristic", "classifier_llm_config": None}, "requires classifier_type"),
|
||||
({"classifier_type": "heuristic_v2", "classifier_llm_config": None}, "requires classifier_type"),
|
||||
({"tiers": {"SIMPLE": "a", "MEDIUM": "b"}}, "at least one model"),
|
||||
),
|
||||
ids=["heuristic", "heuristic_v2", "no_model"],
|
||||
)
|
||||
def test_unreachable_or_unroutable_configs_are_rejected(self, overrides, expected):
|
||||
"""Refused where it could do nothing: no scorer emits the tier, no pool routes it."""
|
||||
config: Final = {
|
||||
"tiers": dict(NON_REASONING_TIERS),
|
||||
"enable_non_reasoning_tier": True,
|
||||
"classifier_type": "llm",
|
||||
"classifier_llm_config": {"model": "clf"},
|
||||
**overrides,
|
||||
}
|
||||
with pytest.raises(ValidationError, match=expected):
|
||||
ComplexityRouterConfig.model_validate(config)
|
||||
|
||||
def test_the_tier_cannot_be_configured_without_the_toggle(self):
|
||||
"""Silently ignoring the key would leave an operator paying for a pool nothing routes to."""
|
||||
with pytest.raises(ValidationError, match="no request can route there"):
|
||||
ComplexityRouterConfig(tiers={"NON_REASONING": "cheap", "SIMPLE": "a"})
|
||||
|
||||
def test_the_toggle_is_refused_alongside_a_custom_tier_set(self):
|
||||
"""A custom tier set replaces the built-in ladder, so both at once has no meaning."""
|
||||
with pytest.raises(ValidationError, match="cannot be combined with tier_definitions"):
|
||||
ComplexityRouterConfig(
|
||||
enable_non_reasoning_tier=True,
|
||||
classifier_type="llm",
|
||||
classifier_llm_config={"model": "clf"},
|
||||
tier_definitions=({"name": "lo", "description": "d"}, {"name": "hi", "description": "d"}),
|
||||
tiers={"lo": "a", "hi": "b"},
|
||||
fallback_tier="lo",
|
||||
)
|
||||
|
||||
def test_heuristic_v2_predictions_never_reach_the_new_tier(self, mock_router_instance):
|
||||
"""The four-class artifact's 1-based index must keep mapping onto SIMPLE..REASONING."""
|
||||
router: Final = ComplexityRouter(
|
||||
model_name="v2-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config={
|
||||
"tiers": {k: v for k, v in NON_REASONING_TIERS.items() if k != "NON_REASONING"},
|
||||
"classifier_type": "heuristic_v2",
|
||||
},
|
||||
)
|
||||
outcome = router._classify_with_heuristic_v2("implement a distributed rate limiter under concurrency")
|
||||
assert outcome.tier in TIER_SEVERITY_ORDER
|
||||
assert tuple(signal.split(":")[1].split("=")[0] for signal in outcome.signals[1:]) == (
|
||||
"simple",
|
||||
"medium",
|
||||
"complex",
|
||||
"reasoning",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect";
|
|||
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
|
||||
import ClassifierVisionConfig from "./ClassifierVisionConfig";
|
||||
import type { ReasoningEffort } from "./complexity_router_tiers";
|
||||
import { nonReasoningTierFields } from "./nonReasoningTierFields";
|
||||
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
|
||||
import {
|
||||
ClassificationFrequency,
|
||||
|
|
@ -288,6 +289,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
: undefined,
|
||||
hybrid_boundary_margin:
|
||||
classifierType === "hybrid" ? value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN : undefined,
|
||||
...nonReasoningTierFields(classifierType, value),
|
||||
};
|
||||
onChange(nextValue);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ describe("ComplexityRouterConfig", () => {
|
|||
renderWithProviders(<ComplexityRouterConfig {...baseProps} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByText("Advanced: Response Format"));
|
||||
await user.click(screen.getByRole("switch"));
|
||||
await user.click(screen.getByRole("switch", { name: "Return raw model name" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...defaultValue,
|
||||
|
|
@ -495,7 +495,7 @@ describe("ComplexityRouterConfig", () => {
|
|||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
|
||||
await user.click(screen.getByRole("switch"));
|
||||
await user.click(screen.getByRole("switch", { name: "Semantic keyword matching" }));
|
||||
expect(onSemanticMatchingEnabledChange).toHaveBeenCalledWith(true, expect.anything());
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
import { AffinityControls } from "./AffinityControls";
|
||||
import NonReasoningTierToggle from "./NonReasoningTierToggle";
|
||||
import TierConfigIntro from "./TierConfigIntro";
|
||||
import TierRowSelect from "./TierRowSelect";
|
||||
import { ModalityRoutingControls } from "./ModalityRoutingControls";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
|
@ -20,6 +22,7 @@ import {
|
|||
MAX_TIER_COUNT,
|
||||
MAX_TIER_DEFINITION_CHARS,
|
||||
MAX_TIER_NAME_CHARS,
|
||||
ALL_BUILT_IN_TIERS,
|
||||
MIN_TIER_COUNT,
|
||||
TIER_ORDER,
|
||||
activeTierName,
|
||||
|
|
@ -76,11 +79,13 @@ export const DEFAULT_CLASSIFICATION_MODE: ClassificationMode = "every_request";
|
|||
*/
|
||||
export type ClassificationFrequency = ClassificationMode | "session";
|
||||
|
||||
/** NON_REASONING is optional: a router that never enabled it stores no such key. */
|
||||
export type ComplexityTiers = {
|
||||
SIMPLE: string[];
|
||||
MEDIUM: string[];
|
||||
COMPLEX: string[];
|
||||
REASONING: string[];
|
||||
NON_REASONING?: string[];
|
||||
};
|
||||
|
||||
export type ClassificationRubric = "legacy" | "agentic" | "chat" | "business";
|
||||
|
|
@ -196,34 +201,10 @@ const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isC
|
|||
};
|
||||
|
||||
const builtInTierInfo = (rowId: string): { label: string; description: string; examples: string } | undefined => {
|
||||
const builtIn = TIER_ORDER.find((tier) => tier === rowId);
|
||||
const builtIn = ALL_BUILT_IN_TIERS.find((tier) => tier === rowId);
|
||||
return builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined;
|
||||
};
|
||||
|
||||
const tierConfigIntroText = (value: ComplexityRouterConfigValue): string => {
|
||||
if (value.classifier_type === "heuristic_v2") {
|
||||
return "The complexity router classifies each request with a calibrated local four-tier model (no API calls). Configure which model(s) handle each tier.";
|
||||
}
|
||||
if (heuristicScoringRole(value) === "never") {
|
||||
return "The complexity router classifies each request with your classifier model and routes it to that tier. Configure which model(s) handle each tier.";
|
||||
}
|
||||
return "The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls, <1ms latency). Configure which model(s) handle each tier.";
|
||||
};
|
||||
|
||||
const TierConfigIntro: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => (
|
||||
<>
|
||||
<span className="block mb-6 text-muted-foreground">{tierConfigIntroText(value)}</span>
|
||||
|
||||
<span className="block mb-4 text-xs text-muted-foreground">
|
||||
{restrictedBy(value, "displayNames")?.reason ??
|
||||
"Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how requests are classified, and callers never see these names."}
|
||||
{!value.custom_tier_set &&
|
||||
usesLlmClassifier(value.classifier_type) &&
|
||||
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const TierSetToolbar: React.FC<{
|
||||
editing: boolean;
|
||||
isCustomSet: boolean;
|
||||
|
|
@ -379,6 +360,8 @@ export type ComplexityTierLabels = Partial<Record<keyof ComplexityTiers, string>
|
|||
|
||||
export interface ComplexityRouterConfigValue {
|
||||
tiers: ComplexityTiers;
|
||||
/** Opt into the NON_REASONING tier below SIMPLE; off keeps the four-tier ladder. */
|
||||
enable_non_reasoning_tier?: boolean;
|
||||
custom_tier_set?: CustomTierSet;
|
||||
tier_labels?: ComplexityTierLabels;
|
||||
/** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */
|
||||
|
|
@ -500,6 +483,11 @@ export const TIER_DESCRIPTIONS: Record<
|
|||
keyof ComplexityTiers,
|
||||
{ label: string; description: string; examples: string }
|
||||
> = {
|
||||
NON_REASONING: {
|
||||
label: "Non-reasoning",
|
||||
description: "Operational relay work: passing information along with no judgment about it",
|
||||
examples: '"Reformat this tool output", "Acknowledge the write succeeded"',
|
||||
},
|
||||
SIMPLE: {
|
||||
label: "Simple",
|
||||
description: "Basic questions, greetings, simple factual queries",
|
||||
|
|
@ -536,7 +524,7 @@ export const DEFAULT_HYBRID_BOUNDARY_MARGIN = 0.03;
|
|||
* Tiers the heuristic_first threshold may name. The top tier is excluded because it would short
|
||||
* circuit every request and leave the classifier unreachable, which the backend rejects.
|
||||
*/
|
||||
export const HEURISTIC_FIRST_MAX_TIER_KEYS = TIER_KEYS.slice(0, -1);
|
||||
export const HEURISTIC_FIRST_MAX_TIER_KEYS = TIER_ORDER.slice(0, -1);
|
||||
|
||||
const PlanModeOverrideControls: React.FC<{
|
||||
value: ComplexityRouterConfigValue;
|
||||
|
|
@ -668,6 +656,10 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
|
||||
<Card>
|
||||
<CardContent>
|
||||
{!customTierSet && (
|
||||
<NonReasoningTierToggle value={value} onChange={onChange} available={value.classifier_type === "llm"} />
|
||||
)}
|
||||
|
||||
{tierRows.map((row, index) => {
|
||||
const tierInfo = builtInTierInfo(row.id);
|
||||
const label = tierRowLabel(row, value.tier_labels);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import React from "react";
|
|||
import { emptyKeywordTierRuleIndexes } from "./complexity_router_keywords";
|
||||
import { tierOptions } from "./complexity_router_tiers";
|
||||
|
||||
export type ComplexityTier = "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
|
||||
export type ComplexityTier = "NON_REASONING" | "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
|
||||
|
||||
export interface KeywordTierRule {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import React from "react";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
const NonReasoningTierToggle: React.FC<{
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
available: boolean;
|
||||
}> = ({ value, onChange, available }) => {
|
||||
const handleToggle = (enabled: boolean): void => {
|
||||
const { NON_REASONING: existingPool, ...keptTiers } = value.tiers;
|
||||
// Turning it off must also release the plan-mode floor, which the backend rejects while it
|
||||
// names an inactive tier. An orphaned keyword rule is left for the save gate to name.
|
||||
const next: ComplexityRouterConfigValue = enabled
|
||||
? { ...value, enable_non_reasoning_tier: true, tiers: { ...keptTiers, NON_REASONING: existingPool ?? [] } }
|
||||
: {
|
||||
...value,
|
||||
enable_non_reasoning_tier: undefined,
|
||||
tiers: keptTiers,
|
||||
plan_mode_min_tier: value.plan_mode_min_tier === "NON_REASONING" ? undefined : value.plan_mode_min_tier,
|
||||
};
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.enable_non_reasoning_tier === true}
|
||||
disabled={!available}
|
||||
onCheckedChange={handleToggle}
|
||||
aria-label="Add a non-reasoning tier"
|
||||
/>
|
||||
<strong className="font-semibold">Add a non-reasoning tier</strong>
|
||||
</div>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Adds NON_REASONING below Simple, for operational agent traffic that relays or reformats information rather than
|
||||
reasoning about it. Escalation still moves up out of it when a request needs more.
|
||||
{!available && " Requires the LLM classification method."}
|
||||
</span>
|
||||
<Separator className="my-4" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NonReasoningTierToggle;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import React from "react";
|
||||
|
||||
import { type ComplexityRouterConfigValue, heuristicScoringRole, usesLlmClassifier } from "./ComplexityRouterConfig";
|
||||
import { restrictedBy } from "./TierRestrictions";
|
||||
|
||||
const tierConfigIntroText = (value: ComplexityRouterConfigValue): string => {
|
||||
if (value.classifier_type === "heuristic_v2") {
|
||||
return "The complexity router classifies each request with a calibrated local four-tier model (no API calls). Configure which model(s) handle each tier.";
|
||||
}
|
||||
if (heuristicScoringRole(value) === "never") {
|
||||
return "The complexity router classifies each request with your classifier model and routes it to that tier. Configure which model(s) handle each tier.";
|
||||
}
|
||||
return "The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls, <1ms latency). Configure which model(s) handle each tier.";
|
||||
};
|
||||
|
||||
const TierConfigIntro: React.FC<{ value: ComplexityRouterConfigValue }> = ({ value }) => (
|
||||
<>
|
||||
<span className="block mb-6 text-muted-foreground">{tierConfigIntroText(value)}</span>
|
||||
|
||||
<span className="block mb-4 text-xs text-muted-foreground">
|
||||
{restrictedBy(value, "displayNames")?.reason ??
|
||||
"Rename a tier to use your own vocabulary in the dashboard and your spend logs. Renaming doesn't change how requests are classified, and callers never see these names."}
|
||||
{!value.custom_tier_set &&
|
||||
usesLlmClassifier(value.classifier_type) &&
|
||||
" Your classifier model reads these names, so clearer ones can sharpen its choices."}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
export default TierConfigIntro;
|
||||
|
|
@ -377,6 +377,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
|
||||
const complexityRouterConfigParams: BuildComplexityRouterConfigParams = {
|
||||
tiers: complexityRouterConfig.tiers,
|
||||
enableNonReasoningTier: complexityRouterConfig.enable_non_reasoning_tier,
|
||||
customTierSet: complexityRouterConfig.custom_tier_set,
|
||||
defaultModel: complexityRouterConfig.default_model,
|
||||
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ const scorerKnobPayload = ({
|
|||
|
||||
export interface BuildComplexityRouterConfigParams {
|
||||
tiers: ComplexityTiers;
|
||||
enableNonReasoningTier?: boolean;
|
||||
customTierSet?: CustomTierSet;
|
||||
defaultModel: string | undefined;
|
||||
planModeMinTier: string | undefined;
|
||||
|
|
@ -190,6 +191,7 @@ export interface TierDefinitionPayload {
|
|||
|
||||
export interface ComplexityRouterConfigPayload {
|
||||
tiers: ComplexityTiers | Record<string, string[]>;
|
||||
enable_non_reasoning_tier?: boolean;
|
||||
tier_definitions?: TierDefinitionPayload[];
|
||||
fallback_tier?: string;
|
||||
default_model?: string;
|
||||
|
|
@ -383,6 +385,26 @@ export const customTierWireFields = (
|
|||
};
|
||||
};
|
||||
|
||||
/** The built-in tier pools and the opt-in flag, read back from a stored config. `tiers` is
|
||||
* rewritten wholesale on save, so a stored tier this misses is deleted by any unrelated edit. */
|
||||
export const hydrateBuiltInTiers = (
|
||||
storedTiers: Partial<Record<keyof ComplexityTiers, unknown>> | undefined,
|
||||
storedFlag: boolean | undefined,
|
||||
): { tiers: ComplexityTiers; enable_non_reasoning_tier: boolean } => {
|
||||
const nonReasoning: string[] = normalizeTierModels(storedTiers?.NON_REASONING);
|
||||
const enable_non_reasoning_tier: boolean = storedFlag === true || nonReasoning.length > 0;
|
||||
return {
|
||||
enable_non_reasoning_tier,
|
||||
tiers: {
|
||||
SIMPLE: normalizeTierModels(storedTiers?.SIMPLE),
|
||||
MEDIUM: normalizeTierModels(storedTiers?.MEDIUM),
|
||||
COMPLEX: normalizeTierModels(storedTiers?.COMPLEX),
|
||||
REASONING: normalizeTierModels(storedTiers?.REASONING),
|
||||
...(enable_non_reasoning_tier && { NON_REASONING: nonReasoning }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// plan_mode_min_tier rides the strip list because the base payload carries it as a row id;
|
||||
// customTierWireFields re-emits it as the row's name, and an unresolvable floor stays off.
|
||||
const CUSTOM_TIER_STRIPPED_KEYS: readonly string[] = [...CUSTOM_TIER_OMITTED_KEYS, "plan_mode_min_tier"];
|
||||
|
|
@ -471,6 +493,7 @@ const classifierWireFields = (
|
|||
|
||||
export const buildComplexityRouterConfig = ({
|
||||
tiers,
|
||||
enableNonReasoningTier,
|
||||
customTierSet,
|
||||
defaultModel,
|
||||
planModeMinTier,
|
||||
|
|
@ -548,6 +571,8 @@ export const buildComplexityRouterConfig = ({
|
|||
|
||||
const payload: ComplexityRouterConfigPayload = {
|
||||
tiers,
|
||||
// The backend rejects the flag beside a custom tier set.
|
||||
...(!customTierSet && enableNonReasoningTier && { enable_non_reasoning_tier: true }),
|
||||
...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }),
|
||||
...(defaultModel?.trim() && { default_model: defaultModel }),
|
||||
...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ComplexityTier } from "./KeywordTierRules";
|
||||
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { TIER_ORDER } from "./tier_rows";
|
||||
import { ALL_BUILT_IN_TIERS, TIER_ORDER } from "./tier_rows";
|
||||
|
||||
export type TierModelParams = Record<string, unknown>;
|
||||
|
||||
|
|
@ -145,13 +145,14 @@ export const pruneTierModelParams = (
|
|||
};
|
||||
|
||||
export const DEFAULT_TIER_LABELS: Record<ComplexityTier, string> = {
|
||||
NON_REASONING: "Non-reasoning",
|
||||
SIMPLE: "Simple",
|
||||
MEDIUM: "Medium",
|
||||
COMPLEX: "Complex",
|
||||
REASONING: "Reasoning",
|
||||
};
|
||||
|
||||
const isBuiltInTier = (tier: string): tier is ComplexityTier => (TIER_ORDER as string[]).includes(tier);
|
||||
const isBuiltInTier = (tier: string): tier is ComplexityTier => (ALL_BUILT_IN_TIERS as string[]).includes(tier);
|
||||
|
||||
const builtInTierLabel = (
|
||||
tierLabels: Partial<Record<ComplexityTier, string>> | undefined,
|
||||
|
|
@ -164,7 +165,7 @@ export const tierRowLabel = (
|
|||
row: { id: string; name: string },
|
||||
tierLabels?: Partial<Record<ComplexityTier, string>>,
|
||||
): string => {
|
||||
const builtIn = TIER_ORDER.find((tier) => tier === row.id);
|
||||
const builtIn = ALL_BUILT_IN_TIERS.find((tier) => tier === row.id);
|
||||
const named = row.name.trim();
|
||||
if (!builtIn || named !== builtIn) return named || "New";
|
||||
return builtInTierLabel(tierLabels, builtIn);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
import { nonReasoningTierFields } from "./nonReasoningTierFields";
|
||||
|
||||
const enabledValue: ComplexityRouterConfigValue = {
|
||||
classifier_type: "llm",
|
||||
enable_non_reasoning_tier: true,
|
||||
tiers: {
|
||||
NON_REASONING: ["relay-cheap"],
|
||||
SIMPLE: ["gpt-4o-mini"],
|
||||
MEDIUM: ["gpt-4o"],
|
||||
COMPLEX: ["sonnet"],
|
||||
REASONING: ["opus"],
|
||||
},
|
||||
};
|
||||
|
||||
describe("nonReasoningTierFields", () => {
|
||||
it("keeps the tier and its pool while the classifier stays LLM", () => {
|
||||
expect(nonReasoningTierFields("llm", enabledValue)).toEqual({
|
||||
enable_non_reasoning_tier: true,
|
||||
tiers: enabledValue.tiers,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["heuristic", "heuristic_v2", "heuristic_first", "hybrid"] as const)(
|
||||
"clears the flag and the tier when the classifier becomes %s",
|
||||
(classifierType) => {
|
||||
// Leaving the flag set under a classifier that cannot emit the tier is a config the backend
|
||||
// refuses, and the switch is disabled there, so the operator could never undo it.
|
||||
const cleared = nonReasoningTierFields(classifierType, enabledValue);
|
||||
expect(cleared.enable_non_reasoning_tier).toBeUndefined();
|
||||
expect(cleared.tiers).not.toHaveProperty("NON_REASONING");
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves the other tiers untouched when it clears", () => {
|
||||
const { NON_REASONING: _dropped, ...expectedTiers } = enabledValue.tiers;
|
||||
expect(nonReasoningTierFields("heuristic", enabledValue).tiers).toEqual(expectedTiers);
|
||||
});
|
||||
|
||||
it("is a no-op for a router that never enabled the tier", () => {
|
||||
const fourTier: ComplexityRouterConfigValue = {
|
||||
classifier_type: "heuristic",
|
||||
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: ["gpt-4o"], COMPLEX: ["sonnet"], REASONING: ["opus"] },
|
||||
};
|
||||
expect(nonReasoningTierFields("heuristic", fourTier)).toEqual({
|
||||
enable_non_reasoning_tier: undefined,
|
||||
tiers: fourTier.tiers,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stale references to the cleared tier", () => {
|
||||
const withFloorOnTierZero: ComplexityRouterConfigValue = { ...enabledValue, plan_mode_min_tier: "NON_REASONING" };
|
||||
|
||||
it("releases a plan-mode floor pointing at the tier it just cleared", () => {
|
||||
// The backend rejects a floor naming an inactive tier, and the switch is disabled once the
|
||||
// classifier changes, so a floor left behind is a config the operator cannot save or undo.
|
||||
expect(nonReasoningTierFields("heuristic", withFloorOnTierZero).plan_mode_min_tier).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves a floor on another tier alone", () => {
|
||||
const floorOnComplex: ComplexityRouterConfigValue = { ...enabledValue, plan_mode_min_tier: "COMPLEX" };
|
||||
expect(nonReasoningTierFields("heuristic", floorOnComplex).plan_mode_min_tier).toBe("COMPLEX");
|
||||
});
|
||||
|
||||
it("keeps the floor while the classifier can still emit the tier", () => {
|
||||
expect(nonReasoningTierFields("llm", withFloorOnTierZero).plan_mode_min_tier).toBe("NON_REASONING");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { ClassifierType, ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
const NON_REASONING = "NON_REASONING";
|
||||
|
||||
/** The NON_REASONING keys a classifier switch carries forward, or clears for a classifier that
|
||||
* cannot emit the tier. Leaving them set there is a config the backend refuses on save. The floor
|
||||
* goes with them: it is rejected on save while it names an inactive tier, and the switch is
|
||||
* disabled once the classifier changes, so the operator could not clear it themselves.
|
||||
* An orphaned keyword rule is left for getKeywordTierRulesError to name, matching how a removed
|
||||
* custom tier already behaves. */
|
||||
export const nonReasoningTierFields = (
|
||||
classifierType: ClassifierType,
|
||||
value: ComplexityRouterConfigValue,
|
||||
): Pick<ComplexityRouterConfigValue, "enable_non_reasoning_tier" | "tiers" | "plan_mode_min_tier"> => {
|
||||
if (classifierType === "llm") {
|
||||
return {
|
||||
enable_non_reasoning_tier: value.enable_non_reasoning_tier,
|
||||
tiers: value.tiers,
|
||||
plan_mode_min_tier: value.plan_mode_min_tier,
|
||||
};
|
||||
}
|
||||
const { [NON_REASONING]: _cleared, ...tiers } = value.tiers;
|
||||
return {
|
||||
enable_non_reasoning_tier: undefined,
|
||||
tiers,
|
||||
plan_mode_min_tier: value.plan_mode_min_tier === NON_REASONING ? undefined : value.plan_mode_min_tier,
|
||||
};
|
||||
};
|
||||
|
|
@ -168,3 +168,41 @@ describe("tierParamsByRowId", () => {
|
|||
expect(tierParamsByRowId(undefined, rows)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the opt-in non-reasoning tier", () => {
|
||||
const withTierZero = { SIMPLE: ["a"], MEDIUM: ["b"], COMPLEX: ["c"], REASONING: ["d"], NON_REASONING: ["cheap"] };
|
||||
|
||||
it("renders no fifth row while the toggle is off", () => {
|
||||
// The regression for every existing router: the tier exists in the type, and the form must
|
||||
// still show the four rows it always showed.
|
||||
expect(activeTierRows({ tiers: withTierZero }).map((row) => row.id)).toEqual([
|
||||
"SIMPLE",
|
||||
"MEDIUM",
|
||||
"COMPLEX",
|
||||
"REASONING",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders it first, as tier 0, when enabled", () => {
|
||||
const rows = activeTierRows({ tiers: withTierZero, enable_non_reasoning_tier: true });
|
||||
expect(rows.map((row) => row.id)).toEqual(["NON_REASONING", "SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
|
||||
expect(rows[0].models).toEqual(["cheap"]);
|
||||
});
|
||||
|
||||
it("renders an enabled tier with no models as an empty row rather than crashing", () => {
|
||||
const emptyTierZeroRow: ActiveTierRow = {
|
||||
id: "NON_REASONING",
|
||||
name: "NON_REASONING",
|
||||
definition: "",
|
||||
models: [],
|
||||
params: {},
|
||||
};
|
||||
const rows = activeTierRows({ tiers, enable_non_reasoning_tier: true });
|
||||
expect(rows[0]).toEqual(emptyTierZeroRow);
|
||||
});
|
||||
|
||||
it("counts as a built-in name either way, so a custom set cannot claim the name", () => {
|
||||
expect(isBuiltInTierName("NON_REASONING")).toBe(true);
|
||||
expect(isBuiltInTierName("non_reasoning")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ import type { TierModelParams, TierModelParamsByTier } from "./complexity_router
|
|||
|
||||
export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
|
||||
|
||||
export const ALL_BUILT_IN_TIERS: ComplexityTier[] = ["NON_REASONING", ...TIER_ORDER];
|
||||
|
||||
/** The ladder one router renders, ascending; NON_REASONING appears only when enabled. */
|
||||
export const tierOrderFor = (enableNonReasoningTier: boolean | undefined): ComplexityTier[] =>
|
||||
enableNonReasoningTier ? ALL_BUILT_IN_TIERS : TIER_ORDER;
|
||||
|
||||
export interface TierRow {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -27,6 +33,7 @@ export const MAX_TIER_DEFINITION_CHARS = 500;
|
|||
|
||||
export interface ActiveTierSet {
|
||||
tiers: ComplexityTiers;
|
||||
enable_non_reasoning_tier?: boolean;
|
||||
custom_tier_set?: CustomTierSet;
|
||||
tier_model_params?: TierModelParamsByTier;
|
||||
}
|
||||
|
|
@ -39,7 +46,8 @@ export const activeTierName = (row: TierRow): string => row.name.trim();
|
|||
export const sameTierIdentity = (left: string, right: string): boolean =>
|
||||
left.trim().toLowerCase() === right.trim().toLowerCase();
|
||||
|
||||
export const isBuiltInTierName = (name: string): boolean => TIER_ORDER.some((tier) => sameTierIdentity(tier, name));
|
||||
export const isBuiltInTierName = (name: string): boolean =>
|
||||
ALL_BUILT_IN_TIERS.some((tier) => sameTierIdentity(tier, name));
|
||||
|
||||
const builtInRow = (tier: keyof ComplexityTiers, tiers: ComplexityTiers): TierRow => ({
|
||||
id: tier,
|
||||
|
|
@ -51,7 +59,9 @@ const builtInRow = (tier: keyof ComplexityTiers, tiers: ComplexityTiers): TierRo
|
|||
// The only reader of the tier set. Built-in rows carry the canonical tier key as their id, so every
|
||||
// pointer into the set is a row id in both modes and nothing downstream branches on the mode.
|
||||
export const activeTierRows = (value: ActiveTierSet): ActiveTierRow[] => {
|
||||
const rows = value.custom_tier_set?.tiers ?? TIER_ORDER.map((tier) => builtInRow(tier, value.tiers));
|
||||
const rows =
|
||||
value.custom_tier_set?.tiers ??
|
||||
tierOrderFor(value.enable_non_reasoning_tier).map((tier) => builtInRow(tier, value.tiers));
|
||||
return rows.map((row) => ({ ...row, params: value.tier_model_params?.[row.id] ?? {} }));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ import { pruneTierModelParams } from "./complexity_router_tiers";
|
|||
import {
|
||||
type ActiveTierRow,
|
||||
type TierRow,
|
||||
TIER_ORDER,
|
||||
ALL_BUILT_IN_TIERS,
|
||||
activeTierName,
|
||||
activeTierRows,
|
||||
rowParamsByTier,
|
||||
sameTierIdentity,
|
||||
tierOrderFor,
|
||||
tierRowById,
|
||||
tierRowByName,
|
||||
} from "./tier_rows";
|
||||
|
|
@ -74,13 +75,13 @@ const rulesFollowingRows = (
|
|||
// Models and params both come from these rows, so the two cannot be keyed differently.
|
||||
const exitToBuiltInTiers = (value: ComplexityRouterConfigValue, rows: readonly ActiveTierRow[]) => {
|
||||
const { custom_tier_set: _dropped, ...rest } = value;
|
||||
const builtInRows: ActiveTierRow[] = TIER_ORDER.map(
|
||||
const builtInRows: ActiveTierRow[] = tierOrderFor(value.enable_non_reasoning_tier).map(
|
||||
(tier) =>
|
||||
tierRowById(rows, tier) ?? {
|
||||
id: tier,
|
||||
name: tier,
|
||||
definition: "",
|
||||
models: value.tiers[tier],
|
||||
models: value.tiers[tier] ?? [],
|
||||
params: value.tier_model_params?.[tier] ?? {},
|
||||
},
|
||||
);
|
||||
|
|
@ -121,7 +122,7 @@ const nextTierSetValue = (
|
|||
case "remove": {
|
||||
const removed = tierRowById(rows, action.id);
|
||||
const snapshot =
|
||||
removed && (TIER_ORDER as string[]).includes(action.id)
|
||||
removed && (ALL_BUILT_IN_TIERS as string[]).includes(action.id)
|
||||
? { ...value, tiers: { ...value.tiers, [action.id]: removed.models } }
|
||||
: value;
|
||||
return commitTierRows(
|
||||
|
|
|
|||
|
|
@ -647,6 +647,10 @@ describe("managed keys survive an untouched open-and-save", () => {
|
|||
"stall_escalation_repeat_threshold",
|
||||
]);
|
||||
|
||||
// The opt-in fifth tier requires the LLM classifier, which this heuristic_first fixture is not,
|
||||
// so it gets its own round trip below.
|
||||
const KEYS_ANOTHER_TIER_LADDER_OWNS = new Set(["enable_non_reasoning_tier"]);
|
||||
|
||||
it("carries every managed key a built-in router can hold through hydrate then save", () => {
|
||||
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
|
||||
const saved = buildUpdatedComplexityRouterConfig(STORED_ALL_MANAGED, hydrated);
|
||||
|
|
@ -654,10 +658,55 @@ describe("managed keys survive an untouched open-and-save", () => {
|
|||
const dropped = [...MANAGED_COMPLEXITY_ROUTER_KEYS]
|
||||
.filter((key) => !KEYS_ANOTHER_CLASSIFIER_TYPE_OWNS.has(key))
|
||||
.filter((key) => !KEYS_ANOTHER_CLASSIFICATION_FREQUENCY_OWNS.has(key))
|
||||
.filter((key) => !KEYS_ANOTHER_TIER_LADDER_OWNS.has(key))
|
||||
.filter((key) => saved[key] === undefined);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries an enabled non-reasoning tier and its models through their own round trip", () => {
|
||||
// `tiers` is rewritten wholesale on save, so this is the regression that matters: opening an
|
||||
// enabled router and saving an unrelated edit must not delete the tier or its pool.
|
||||
const stored: Record<string, unknown> = {
|
||||
...STORED_ALL_MANAGED,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "haiku-classifier" },
|
||||
heuristic_first_max_tier: undefined,
|
||||
enable_non_reasoning_tier: true,
|
||||
tiers: { ...(STORED_ALL_MANAGED.tiers as object), NON_REASONING: ["gpt-4o-mini"] },
|
||||
};
|
||||
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
|
||||
const saved = buildUpdatedComplexityRouterConfig(stored, hydrated);
|
||||
|
||||
expect(saved.enable_non_reasoning_tier).toBe(true);
|
||||
expect((saved.tiers as Record<string, string[]>).NON_REASONING).toEqual(["gpt-4o-mini"]);
|
||||
});
|
||||
|
||||
it("keeps a stored non-reasoning tier when the stored config never wrote the flag", () => {
|
||||
// A hand-written config that names the tier: the flag is inferred from the stored pool, so an
|
||||
// edit made for an unrelated reason cannot silently turn the tier off.
|
||||
const stored: Record<string, unknown> = {
|
||||
...STORED_ALL_MANAGED,
|
||||
classifier_type: "llm",
|
||||
classifier_llm_config: { model: "haiku-classifier" },
|
||||
heuristic_first_max_tier: undefined,
|
||||
tiers: { ...(STORED_ALL_MANAGED.tiers as object), NON_REASONING: ["gpt-4o-mini"] },
|
||||
};
|
||||
const saved = buildUpdatedComplexityRouterConfig(stored, hydrateComplexityRouterConfig(stored, undefined));
|
||||
|
||||
expect(saved.enable_non_reasoning_tier).toBe(true);
|
||||
expect((saved.tiers as Record<string, string[]>).NON_REASONING).toEqual(["gpt-4o-mini"]);
|
||||
});
|
||||
|
||||
it("leaves the tier and its flag out of a saved config that never had it on", () => {
|
||||
const saved = buildUpdatedComplexityRouterConfig(
|
||||
STORED_ALL_MANAGED,
|
||||
hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined),
|
||||
);
|
||||
|
||||
expect(saved).not.toHaveProperty("enable_non_reasoning_tier");
|
||||
expect(saved.tiers).not.toHaveProperty("NON_REASONING");
|
||||
});
|
||||
|
||||
it("carries the stall-escalation keys through their own round trip", () => {
|
||||
const stored: Record<string, unknown> = {
|
||||
...STORED_ALL_MANAGED,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import ModelChoiceCombobox, { type ModelChoice } from "../add_model/ModelChoiceC
|
|||
import { modelAvailableCall, modelPatchUpdateCall, validateAutoRouterConfig } from "../networking";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
|
||||
import { hydrateTierModelParams, normalizeTierModels } from "../add_model/complexity_router_tiers";
|
||||
import { hydrateTierModelParams } from "../add_model/complexity_router_tiers";
|
||||
import {
|
||||
type ActiveTierSet,
|
||||
CUSTOM_TIER_OMITTED_KEYS,
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
getSemanticConfigError,
|
||||
getPlanModeTierError,
|
||||
getTierLabelsError,
|
||||
hydrateBuiltInTiers,
|
||||
hydrateCustomTierSet,
|
||||
hydratePlanModeMinTier,
|
||||
hydrateTierLabels,
|
||||
|
|
@ -93,6 +94,7 @@ interface EditAutoRouterModalProps {
|
|||
* hydrators validate themselves stay `unknown`; the ones assigned straight through carry their type. */
|
||||
export interface StoredComplexityRouterConfig {
|
||||
tiers?: Partial<Record<keyof ComplexityTiers, unknown>>;
|
||||
enable_non_reasoning_tier?: boolean;
|
||||
tier_model_configs?: unknown;
|
||||
default_model?: string | null;
|
||||
plan_mode_min_tier?: unknown;
|
||||
|
|
@ -138,18 +140,14 @@ export const hydrateComplexityRouterConfig = (
|
|||
parsedConfig: StoredComplexityRouterConfig,
|
||||
complexityRouterDefaultModel: string | null | undefined,
|
||||
): ComplexityRouterConfigValue => {
|
||||
const hydratedTiers: ComplexityTiers = {
|
||||
SIMPLE: normalizeTierModels(parsedConfig.tiers?.SIMPLE),
|
||||
MEDIUM: normalizeTierModels(parsedConfig.tiers?.MEDIUM),
|
||||
COMPLEX: normalizeTierModels(parsedConfig.tiers?.COMPLEX),
|
||||
REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING),
|
||||
};
|
||||
|
||||
const builtIn = hydrateBuiltInTiers(parsedConfig.tiers, parsedConfig.enable_non_reasoning_tier);
|
||||
const { tiers: hydratedTiers, enable_non_reasoning_tier } = builtIn;
|
||||
const custom_tier_set = hydrateCustomTierSet(parsedConfig);
|
||||
const activeTiers = { tiers: hydratedTiers, custom_tier_set };
|
||||
const activeTiers = { ...builtIn, custom_tier_set };
|
||||
|
||||
return {
|
||||
tiers: hydratedTiers,
|
||||
enable_non_reasoning_tier,
|
||||
custom_tier_set,
|
||||
tier_model_params: tierParamsByRowId(
|
||||
hydrateTierModelParams(parsedConfig.tiers, parsedConfig.tier_model_configs),
|
||||
|
|
@ -238,6 +236,7 @@ export const hydrateComplexityRouterConfig = (
|
|||
|
||||
export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
|
||||
"tiers",
|
||||
"enable_non_reasoning_tier",
|
||||
"tier_definitions",
|
||||
"fallback_tier",
|
||||
"tier_model_configs",
|
||||
|
|
@ -344,6 +343,7 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
|
||||
const builderParams: BuildComplexityRouterConfigParams = {
|
||||
tiers: value.tiers,
|
||||
enableNonReasoningTier: value.enable_non_reasoning_tier,
|
||||
customTierSet: value.custom_tier_set,
|
||||
defaultModel: value.default_model,
|
||||
planModeMinTier: value.plan_mode_min_tier,
|
||||
|
|
|
|||
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -25579,7 +25579,7 @@ export interface components {
|
|||
* @description Complexity tiers for routing decisions.
|
||||
* @enum {string}
|
||||
*/
|
||||
ComplexityTier: "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
|
||||
ComplexityTier: "NON_REASONING" | "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
|
||||
/** ComplexityTierModel */
|
||||
ComplexityTierModel: {
|
||||
/** Litellm Params */
|
||||
|
|
@ -34956,6 +34956,12 @@ export interface components {
|
|||
* @default true
|
||||
*/
|
||||
enable_context_window_escalation: boolean;
|
||||
/**
|
||||
* Enable Non Reasoning Tier
|
||||
* @description Add NON_REASONING as a fifth built-in tier below SIMPLE, for operational agent traffic that relays or reformats information rather than reasoning about it. Off by default: turning it on adds a rung to this router's ladder, a bullet to the LLM classifier's rubric, and a value the classifier may return, all of which move tier decisions and spend on an already-deployed router. Requires an LLM classifier or a custom classifier plugin, since the heuristic scorers cannot produce the tier, and a model in `tiers` under the NON_REASONING key. Escalation still walks up from it, and it is never the savings baseline or a `heuristic_v2` prediction.
|
||||
* @default false
|
||||
*/
|
||||
enable_non_reasoning_tier: boolean;
|
||||
/**
|
||||
* Escalation Keywords
|
||||
* @description Case-sensitive phrases a user can include to force a bump to the next-higher complexity tier when they aren't satisfied with results (they can force a stronger model, but not choose which one). Defaults to ['LITELLM ESCALATE'] when unset; set to an empty list to disable.
|
||||
|
|
@ -37227,7 +37233,7 @@ export interface components {
|
|||
TierDefinition: {
|
||||
/**
|
||||
* Description
|
||||
* @description What belongs in this tier; rendered as this tier's bullet in the classifier rubric. Required unless the name is a built-in tier (SIMPLE/MEDIUM/COMPLEX/REASONING), which inherits the built-in criteria when omitted
|
||||
* @description What belongs in this tier; rendered as this tier's bullet in the classifier rubric. Required unless the name is a built-in tier (NON_REASONING, SIMPLE, MEDIUM, COMPLEX, REASONING), which inherits the built-in criteria when omitted
|
||||
*/
|
||||
description?: string | null;
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue