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:
moe-berri 2026-09-08 17:27:35 -07:00 committed by GitHub
commit 6112274350
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 637 additions and 67 deletions

View file

@ -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."

View file

@ -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

View file

@ -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),
)

View file

@ -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",
)

View file

@ -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);
};

View file

@ -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());
});

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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 }),

View file

@ -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);

View file

@ -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");
});
});

View file

@ -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,
};
};

View file

@ -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);
});
});

View file

@ -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] ?? {} }));
};

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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