feat(router): add a hybrid classifier that defers near tier boundaries (#39403)

This commit is contained in:
tin-berri 2026-09-02 16:51:43 -07:00 • committed by GitHub
parent 95b511bc19
commit 632898b2a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 460 additions and 20 deletions

View file

@ -274,6 +274,49 @@ except that the heuristic outcome is the one already computed rather than a seco
Spend logs record `routing_decision.cause` as `heuristic_first_short_circuit` when the classifier
was skipped, and `llm_classifier` when it ran, so the two are told apart per request.
### Hybrid
`classifier_type: hybrid` also scores locally first, but it asks a different question than
`heuristic_first`. Where heuristic-first asks how CHEAP the scorer's tier is and pays for the
classifier on everything above a ceiling, hybrid asks how DECIDED the score is and pays for the
classifier only where the score lands near a tier boundary. A confident score keeps its tier at
every tier, the most expensive one included:
```yaml
model_list:
- model_name: smart-router
litellm_params:
model: auto_router/complexity_router
complexity_router_config:
classifier_type: hybrid
hybrid_boundary_margin: 0.03
classifier_llm_config:
model: gpt-4o-mini
tiers:
SIMPLE: gpt-4o-mini
MEDIUM: gpt-4o
COMPLEX: claude-sonnet-4
REASONING: o1-preview
```
A request routes on the scorer's own tier when its score is further than `hybrid_boundary_margin`
from every active boundary. Everything else goes to the classifier: a score inside the band, where a
hair's difference would have named the adjacent tier and its model pool, and a prompt where no
dimension fired at all, which has no opinion to be confident about. `hybrid_boundary_margin` is
required for this type and rejected on the others, the same way `heuristic_first_max_tier` is
required for heuristic-first, so the two modes are told apart by the knob each one takes rather than
by a shared field that means something different per type.
Pick the margin against the score distribution rather than by intuition. The scorer combines a small
set of discretely weighted dimensions, so achievable scores cluster on a lumpy grid instead of
spreading smoothly, and widening the margin admits whole clusters at once rather than a few more
requests. Spend logs record `routing_decision.cause` as `hybrid_short_circuit` when the classifier
was skipped and `llm_classifier` when it ran.
Operator-defined tier sets (`tier_definitions`) are not supported here, for the same reason they are
not supported under heuristic-first: the scorer only produces the built-in tiers. Classifier failure
behaves exactly as it does under `classifier_type: llm`.
### Reasoning Override
If 2+ reasoning markers are detected in the user message, the request is promoted to the REASONING tier even when the weighted score maps lower, so complex reasoning tasks get the appropriate model. The promotion requires the score to reach `reasoning_override_min_score`, which tracks `tier_boundaries.simple_medium` unless set, so stock phrases on an otherwise trivial prompt cannot buy the top tier. Set it to `0` to promote on the markers alone.

View file

@ -799,6 +799,7 @@ class ClassificationOutcome(NamedTuple):
"reasoning_override",
"llm_classifier",
"heuristic_first_short_circuit",
"hybrid_short_circuit",
"housekeeping",
"classifier_plugin",
"classifier_fallback",
@ -1241,6 +1242,15 @@ class ComplexityRouter(CustomLogger):
return tier, weighted_score, tuple(signals), "heuristic_scorer"
def _is_near_tier_boundary(self, score: float, margin: float) -> bool:
boundaries: Final = self._effective_tier_boundaries()
active_boundaries: Final = (
boundaries["simple_medium"],
boundaries["medium_complex"],
boundaries["complex_reasoning"],
)
return any(abs(score - boundary) <= margin for boundary in active_boundaries)
def _effective_reasoning_override_min_score(self) -> float:
"""The score a request must reach before the reasoning-marker override may promote it.
@ -1367,6 +1377,8 @@ class ComplexityRouter(CustomLogger):
return await self._classify_with_plugin(prompt, system_prompt, request_kwargs, raw_messages)
if self.config.classifier_type == "heuristic_first" and self.config.classifier_llm_config is not None:
return await self._classify_heuristic_first(prompt, system_prompt, request_kwargs, messages)
if self.config.classifier_type == "hybrid" and self.config.classifier_llm_config is not None:
return await self._classify_hybrid(prompt, system_prompt, request_kwargs, messages)
if self.config.classifier_type != "llm" or self.config.classifier_llm_config is None:
tier, score, signals, cause = self._score_and_classify(prompt, system_prompt)
return ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause)
@ -1418,6 +1430,29 @@ class ComplexityRouter(CustomLogger):
return ClassificationOutcome(tier=tier, score=score, signals=signals, cause="heuristic_first_short_circuit")
return await self._llm_classifier_outcome(prompt, system_prompt, request_kwargs, messages, scored=scored)
async def _classify_hybrid(
self,
prompt: str,
system_prompt: str | None,
request_kwargs: dict[str, Any] | None, # mutable-ok: handed to _classify_with_llm as-is
messages: Sequence[Mapping[str, object]] | None,
) -> ClassificationOutcome:
"""Score locally, and only pay for the classifier when the score sits near a tier boundary.
Where heuristic_first asks how CHEAP the scorer's tier is, this asks how DECIDED it is, so a
confident score keeps its tier at every tier including the most expensive one. Two things make
a score undecided: landing within hybrid_boundary_margin of an active boundary, where a
hair's difference in score would have named the adjacent tier and its model pool, and firing
no dimension at all, which scores 0.0 and lands SIMPLE by default rather than by evidence.
"""
tier, score, signals, cause = self._score_and_classify(prompt, system_prompt)
scored: Final = ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause)
margin: Final = self.config.hybrid_boundary_margin
decided: Final = margin is not None and bool(signals) and not self._is_near_tier_boundary(score, margin)
if decided:
return ClassificationOutcome(tier=tier, score=score, signals=signals, cause="hybrid_short_circuit")
return await self._llm_classifier_outcome(prompt, system_prompt, request_kwargs, messages, scored=scored)
async def _llm_classifier_outcome(
self,
prompt: str,

View file

@ -43,7 +43,7 @@ DEFAULT_CLASSIFICATION_RUBRIC: Final[ClassificationRubric] = ClassificationRubri
# The classifier_type values that can call classifier_llm_config.model. Every consumer asking
# "is the classifier model a real dependency of this router" resolves it here, including the ones
# that only hold the raw config mapping and cannot reach ComplexityRouterConfig.uses_llm_classifier.
LLM_CLASSIFIER_TYPES: Final[frozenset[str]] = frozenset({"llm", "heuristic_first"})
LLM_CLASSIFIER_TYPES: Final[frozenset[str]] = frozenset({"llm", "heuristic_first", "hybrid"})
TIER_SEVERITY_ORDER: Final[tuple[ComplexityTier, ...]] = (
@ -627,12 +627,13 @@ class ComplexityRouterConfig(BaseModel):
)
# Classifier strategy
classifier_type: Literal["heuristic", "heuristic_v2", "llm", "custom", "heuristic_first"] = Field(
classifier_type: Literal["heuristic", "heuristic_v2", "llm", "custom", "heuristic_first", "hybrid"] = Field(
default="heuristic",
description=(
"Classification strategy: local regex/keyword scoring, the bundled trained four-tier heuristic, "
"an LLM call, a custom classifier plugin, or 'heuristic_first', which scores locally and only pays "
"for the LLM classifier when the local scorer does not confidently land a cheap tier"
"an LLM call, a custom classifier plugin, 'heuristic_first', which scores locally and only pays "
"for the LLM classifier when the local scorer does not confidently land a cheap tier, or 'hybrid', "
"which trusts the local scorer everywhere except when its score lands near a tier boundary"
),
)
heuristic_v2_artifact: TrainedTierArtifact | Literal["ultrafeedback"] = Field(
@ -644,7 +645,10 @@ class ComplexityRouterConfig(BaseModel):
)
classifier_llm_config: ClassifierLLMConfig | None = Field(
default=None,
description="Configuration for the LLM classifier; required when classifier_type is 'llm' or 'heuristic_first'",
description=(
"Configuration for the LLM classifier; required when classifier_type is 'llm', "
"'heuristic_first' or 'hybrid'"
),
)
heuristic_first_max_tier: str | None = Field(
default=None,
@ -659,6 +663,19 @@ class ComplexityRouterConfig(BaseModel):
"may not name the highest one, since that would make the LLM classifier unreachable."
),
)
hybrid_boundary_margin: float | None = Field(
default=None,
ge=0,
le=1,
description=(
"How close to a tier boundary a heuristic score has to land before the LLM classifier breaks the "
"tie; required when classifier_type is 'hybrid' and rejected otherwise. Everything further than "
"this from every active boundary routes on the scorer's own tier with no classifier call, at any "
"tier, which is what separates 'hybrid' from 'heuristic_first' and its cheap-tier ceiling. A "
"prompt where no dimension fired still goes to the classifier, since the scorer has no opinion "
"to be near a boundary with. 0 escalates only scores sitting exactly on a boundary."
),
)
classifier_plugin: ClassifierPlugin | None = Field(
default=None,
description=(
@ -1135,6 +1152,23 @@ class ComplexityRouterConfig(BaseModel):
)
return self
@model_validator(mode="after")
def _validate_hybrid_boundary_margin(self) -> "ComplexityRouterConfig":
if self.classifier_type != "hybrid":
if self.hybrid_boundary_margin is not None:
raise ValueError(
f"hybrid_boundary_margin is set but classifier_type is {self.classifier_type!r}; "
"the scorer would never consult the classifier on a near-boundary score. Set "
"classifier_type 'hybrid' or remove hybrid_boundary_margin"
)
return self
if self.hybrid_boundary_margin is None:
raise ValueError(
"hybrid_boundary_margin is required when classifier_type is 'hybrid': without a margin no "
"score is ever near enough to a boundary to escalate, which is classifier_type 'heuristic'"
)
return self
@field_validator("fallback_tier")
@classmethod
def _reject_blank_optional_text(cls, value: str | None) -> str | None:
@ -1257,7 +1291,7 @@ class ComplexityRouterConfig(BaseModel):
)
if duplicated:
raise ValueError(f"tier_definitions names must be unique (case-insensitive): {', '.join(duplicated)}")
if self.classifier_type in ("heuristic", "heuristic_v2", "heuristic_first"):
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"

View file

@ -2852,6 +2852,7 @@ RoutingDecisionCause = Literal[
# scorer, and from "classifier_fallback", which is the scorer running because a call failed:
# only this cause means an LLM classifier was configured, reachable, and deliberately skipped.
"heuristic_first_short_circuit",
"hybrid_short_circuit",
# The operator's classifier plugin (classifier_type 'custom') decided the tier.
"classifier_plugin",
# The LLM classifier or classifier plugin failed on a router with an operator-defined

View file

@ -10089,6 +10089,207 @@ class TestHeuristicFirst:
assert outcome.cause == "default_model_fallback"
# Scores 0.175 with one signal, so it sits 0.025 from simple_medium: the pair of tiers either side of
# that boundary are different model pools, and a hair's difference in score picks the other one.
NEAR_BOUNDARY_PROMPT = (
"design a distributed cache with consistent hashing, then explain the failure modes step by step"
)
# Scores 0.075 with signals, the far side of any margin under 0.075: the scorer is decided here.
CLEAR_OF_BOUNDARY_PROMPT = "explain step by step how consistent hashing rebalances keys"
def _hybrid_router(mock_router_instance, **config_overrides):
config = {
"tiers": dict(HEURISTIC_FIRST_TIERS),
"tier_boundaries": dict(HEURISTIC_FIRST_BOUNDARIES),
"classifier_type": "hybrid",
"hybrid_boundary_margin": 0.03,
"classifier_llm_config": {"model": "haiku-classifier", "timeout_ms": 400},
**config_overrides,
}
return ComplexityRouter(
model_name="test-complexity-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
class TestHybridConfig:
"""Config validation for classifier_type='hybrid'."""
@pytest.mark.parametrize(
"overrides, expected",
[
({"classifier_llm_config": None}, "classifier_llm_config is required"),
({"hybrid_boundary_margin": None}, "hybrid_boundary_margin is required"),
({"hybrid_boundary_margin": -0.01}, "greater than or equal to 0"),
({"hybrid_boundary_margin": 1.01}, "less than or equal to 1"),
],
)
def test_rejects_incoherent_config(self, overrides, expected):
config = {
"tiers": dict(HEURISTIC_FIRST_TIERS),
"classifier_type": "hybrid",
"hybrid_boundary_margin": 0.03,
"classifier_llm_config": {"model": "haiku-classifier"},
**overrides,
}
with pytest.raises(ValidationError, match=expected):
ComplexityRouterConfig(**config)
@pytest.mark.parametrize("classifier_type", ["heuristic", "llm", "custom", "heuristic_first"])
def test_margin_rejected_on_every_other_classifier_type(self, classifier_type):
"""A margin on a router that never compares a score to a boundary is a silent no-op, so it is
refused rather than accepted and ignored. heuristic_first is in this list on purpose: its
ceiling is a different question from proximity, and accepting both on one router would make
two modes out of one classifier_type."""
config: dict[str, object] = {
"tiers": dict(HEURISTIC_FIRST_TIERS),
"classifier_type": classifier_type,
"hybrid_boundary_margin": 0.03,
}
if classifier_type in ("llm", "heuristic_first"):
config["classifier_llm_config"] = {"model": "haiku-classifier"}
if classifier_type == "heuristic_first":
config["heuristic_first_max_tier"] = "SIMPLE"
if classifier_type == "custom":
config["classifier_plugin"] = _FixedTierClassifier("SIMPLE")
with pytest.raises(ValidationError, match="hybrid_boundary_margin is set but classifier_type"):
ComplexityRouterConfig(**config)
def test_the_cheap_tier_ceiling_is_rejected_here(self):
"""The two modes are told apart by which knob they take, so the ceiling is refused on hybrid
exactly as the margin is refused on heuristic_first."""
with pytest.raises(ValidationError, match="heuristic_first_max_tier is set but classifier_type"):
ComplexityRouterConfig(
tiers=dict(HEURISTIC_FIRST_TIERS),
classifier_type="hybrid",
hybrid_boundary_margin=0.03,
heuristic_first_max_tier="SIMPLE",
classifier_llm_config={"model": "haiku-classifier"},
)
def test_custom_tier_set_is_rejected(self):
"""The scorer only emits the four built-in tiers, so it cannot judge proximity on a replaced set."""
with pytest.raises(ValidationError, match="tier_definitions requires classifier_type"):
ComplexityRouterConfig(
classifier_type="hybrid",
hybrid_boundary_margin=0.03,
classifier_llm_config={"model": "haiku-classifier"},
tier_definitions=[{"name": "lo", "description": "x"}, {"name": "hi", "description": "y"}],
tiers={"lo": "gpt-4o-mini", "hi": "gpt-4o"},
)
def test_classifier_model_is_a_dependency(self):
config = ComplexityRouterConfig(
tiers=dict(HEURISTIC_FIRST_TIERS),
classifier_type="hybrid",
hybrid_boundary_margin=0.03,
classifier_llm_config={"model": "haiku-classifier"},
)
assert config.uses_llm_classifier is True
class TestHybrid:
"""Behavior of the hybrid chain: the scorer keeps its tier unless the score is near a boundary."""
@pytest.mark.asyncio
async def test_near_boundary_prompt_escalates(self, mock_router_instance):
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "COMPLEX"}'))
router = _hybrid_router(mock_router_instance)
_tier, score, signals, _cause = router._score_and_classify(NEAR_BOUNDARY_PROMPT)
assert signals and abs(score - HEURISTIC_FIRST_BOUNDARIES["simple_medium"]) < 0.03
outcome = await router.aclassify(NEAR_BOUNDARY_PROMPT)
mock_router_instance.acompletion.assert_awaited_once()
assert outcome.tier == ComplexityTier.COMPLEX
assert outcome.cause == "llm_classifier"
@pytest.mark.asyncio
async def test_score_clear_of_every_boundary_keeps_the_heuristic_tier(self, mock_router_instance):
mock_router_instance.acompletion = AsyncMock()
router = _hybrid_router(mock_router_instance)
outcome = await router.aclassify(CLEAR_OF_BOUNDARY_PROMPT)
mock_router_instance.acompletion.assert_not_called()
assert outcome.tier == ComplexityTier.SIMPLE
assert outcome.cause == "hybrid_short_circuit"
@pytest.mark.asyncio
async def test_an_expensive_tier_short_circuits_too(self, mock_router_instance):
"""This is the whole difference from heuristic_first, which would have escalated this by tier
alone. Hybrid asks whether the score is DECIDED, not whether the tier is cheap."""
mock_router_instance.acompletion = AsyncMock()
router = _hybrid_router(
mock_router_instance,
tier_boundaries={"simple_medium": -0.9, "medium_complex": -0.8, "complex_reasoning": -0.7},
)
tier, _score, signals, _cause = router._score_and_classify(CLEAR_OF_BOUNDARY_PROMPT)
assert (tier, bool(signals)) == (ComplexityTier.REASONING, True)
outcome = await router.aclassify(CLEAR_OF_BOUNDARY_PROMPT)
mock_router_instance.acompletion.assert_not_called()
assert outcome.tier == ComplexityTier.REASONING
assert outcome.cause == "hybrid_short_circuit"
@pytest.mark.asyncio
async def test_widening_the_margin_escalates_what_a_narrow_one_kept(self, mock_router_instance):
"""The margin is the knob: the same prompt short-circuits at 0.03 and escalates at 0.08."""
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "MEDIUM"}'))
router = _hybrid_router(mock_router_instance, hybrid_boundary_margin=0.08)
outcome = await router.aclassify(CLEAR_OF_BOUNDARY_PROMPT)
mock_router_instance.acompletion.assert_awaited_once()
assert outcome.cause == "llm_classifier"
@pytest.mark.asyncio
async def test_a_zero_margin_escalates_only_an_exact_boundary_score(self, mock_router_instance):
"""0 is a real margin, not an off switch: a score sitting exactly on the line still escalates.
The boundary is spelled as the scorer's own accumulated float rather than the 0.075 it prints
as, because the comparison is on raw floats: a boundary written 0.075 sits 1.4e-17 away from
this score and a zero margin correctly declines to call that exact."""
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "MEDIUM"}'))
on_the_line = 0.07499999999999998
router = _hybrid_router(
mock_router_instance,
tier_boundaries={"simple_medium": on_the_line, "medium_complex": 0.35, "complex_reasoning": 0.60},
hybrid_boundary_margin=0,
)
_tier, score, _signals, _cause = router._score_and_classify(CLEAR_OF_BOUNDARY_PROMPT)
assert score == on_the_line
outcome = await router.aclassify(CLEAR_OF_BOUNDARY_PROMPT)
mock_router_instance.acompletion.assert_awaited_once()
assert outcome.cause == "llm_classifier"
@pytest.mark.asyncio
async def test_no_signal_prompt_escalates_however_far_from_a_boundary(self, mock_router_instance):
"""The scorer with no opinion has no tier to be confident about, so proximity cannot save it."""
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "COMPLEX"}'))
router = _hybrid_router(mock_router_instance)
tier, score, signals, _cause = router._score_and_classify(NO_SIGNAL_PROMPT)
assert (tier, score, signals) == (ComplexityTier.SIMPLE, 0.0, ())
outcome = await router.aclassify(NO_SIGNAL_PROMPT)
mock_router_instance.acompletion.assert_awaited_once()
assert outcome.cause == "llm_classifier"
@pytest.mark.asyncio
async def test_classifier_failure_falls_back_to_the_scorer(self, mock_router_instance):
mock_router_instance.acompletion = AsyncMock(side_effect=RuntimeError("classifier exploded"))
router = _hybrid_router(mock_router_instance)
expected_tier, expected_score, expected_signals, _cause = router._score_and_classify(NEAR_BOUNDARY_PROMPT)
outcome = await router.aclassify(NEAR_BOUNDARY_PROMPT)
assert (outcome.tier, outcome.score, outcome.signals) == (expected_tier, expected_score, expected_signals)
assert outcome.cause == "heuristic_scorer"
def _windowed_router(*deployments: tuple) -> Router:
"""Real Router; each deployment is (group, provider_model, declared window or None).
None means no declared override on a model the cost map does not know: unresolvable."""

View file

@ -58,6 +58,7 @@ const dedupe = (models: string[]): string[] => Array.from(new Set(models));
const COMPLEXITY_TYPE_LABELS: Record<string, string> = {
llm: "LLM Classifier",
heuristic_first: "Heuristic first",
hybrid: "Hybrid",
custom: "Custom classifier",
};

View file

@ -35,6 +35,7 @@ import {
heuristicScoringRole,
usesLlmClassifier,
DEFAULT_HEURISTIC_FIRST_MAX_TIER,
DEFAULT_HYBRID_BOUNDARY_MARGIN,
HEURISTIC_FIRST_MAX_TIER_KEYS,
effectiveClassifierType,
} from "./ComplexityRouterConfig";
@ -50,6 +51,7 @@ const HEURISTIC_V2_EXPLANATION =
const CLASSIFIER_TIMEOUT_ID = "classifier-timeout-ms";
const CLASSIFIER_CONTEXT_WINDOW_SIZE_ID = "classifier-context-window-size";
const CLASSIFIER_CONTEXT_BUDGET_CHARS_ID = "classifier-context-budget-chars";
const HYBRID_BOUNDARY_MARGIN_ID = "hybrid-boundary-margin";
const CUSTOM_PROMPT_WITH_HEURISTIC_FALLBACK =
"This router classifies with your own prompt, so the tier comes from whatever rubric it states. The four tier " +
@ -213,6 +215,18 @@ const ClassifierTypeRadios: React.FC<{
</span>
</Label>
</SimpleTooltip>
<SimpleTooltip content={scorerLockedReason}>
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
<RadioGroupItem value="hybrid" className="mt-0.5" disabled={scorerLocked} />
<span>
<strong className="font-semibold">Hybrid</strong>{" "}
<span className="text-muted-foreground">
keeps the local score at any tier, and only pays for the classifier when that score lands near a tier
boundary
</span>
</span>
</Label>
</SimpleTooltip>
</div>
</RadioGroup>
);
@ -263,6 +277,8 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
classifierType === "heuristic_first"
? value.heuristic_first_max_tier ?? DEFAULT_HEURISTIC_FIRST_MAX_TIER
: undefined,
hybrid_boundary_margin:
classifierType === "hybrid" ? value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN : undefined,
};
onChange(nextValue);
};
@ -271,6 +287,13 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
onChange({ ...value, heuristic_first_max_tier: tier });
};
const handleHybridBoundaryMarginChange = (raw: string) => {
setDraft({ id: HYBRID_BOUNDARY_MARGIN_ID, raw });
const parsed = Number(raw);
if (raw.trim() === "" || !Number.isFinite(parsed)) return;
onChange({ ...value, hybrid_boundary_margin: Math.min(1, Math.max(0, parsed)) });
};
const handleClassificationPromptChange = (classificationPrompt: string | undefined) => {
onChange({ ...value, classification_prompt: classificationPrompt });
};
@ -391,6 +414,30 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</div>
)}
{classifierType === "hybrid" && (
<div className="mt-4 space-y-2">
<strong className="block font-semibold">Boundary margin</strong>
<Input
id={HYBRID_BOUNDARY_MARGIN_ID}
type="text"
inputMode="decimal"
value={
draft?.id === HYBRID_BOUNDARY_MARGIN_ID
? draft.raw
: String(value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN)
}
onChange={(event) => handleHybridBoundaryMarginChange(event.target.value)}
onBlur={() => setDraft(null)}
className="w-full"
/>
<p className="text-sm text-muted-foreground">
A score further than this from every tier boundary routes on the scorer&apos;s own tier, however expensive
that tier is. A score closer than this, and anything the scorer found no signal for at all, goes to the
classifier to break the tie
</p>
</div>
)}
<div className="mt-4 space-y-2">
<strong className="block font-semibold">How often to classify</strong>
<RadioGroup

View file

@ -128,7 +128,7 @@ export interface ClassifierLLMConfig {
system_prompt?: string;
}
export type ClassifierType = "heuristic" | "heuristic_v2" | "llm" | "heuristic_first";
export type ClassifierType = "heuristic" | "heuristic_v2" | "llm" | "heuristic_first" | "hybrid";
/**
* Whether this router can call classifier_llm_config.model. Mirrors the backend's
@ -136,7 +136,7 @@ export type ClassifierType = "heuristic" | "heuristic_v2" | "llm" | "heuristic_f
* control and payload key, so a new chaining type cannot strip knobs the operator set.
*/
export const usesLlmClassifier = (classifierType: ClassifierType): boolean =>
classifierType === "llm" || classifierType === "heuristic_first";
classifierType === "llm" || classifierType === "heuristic_first" || classifierType === "hybrid";
export type ClassifierFallback = "heuristic" | "default_model";
@ -162,7 +162,8 @@ export const heuristicScoringRoleFor = (
classifierFallback: ClassifierFallback | undefined,
): HeuristicScoringRole => {
if (classifierType === "heuristic_v2") return "never";
if (classifierType === "heuristic" || classifierType === "heuristic_first") return "decides";
if (classifierType === "heuristic" || classifierType === "heuristic_first" || classifierType === "hybrid")
return "decides";
return (classifierFallback ?? DEFAULT_CLASSIFIER_FALLBACK) === "heuristic" ? "fallback_only" : "never";
};
@ -404,6 +405,8 @@ export interface ComplexityRouterConfigValue {
classification_prompt?: string;
/** Highest tier the scorer may decide alone under heuristic_first. Required by that type, rejected by the others. */
heuristic_first_max_tier?: string;
/** How near a tier boundary a score may land before hybrid defers to the classifier. Required by that type, rejected by the others. */
hybrid_boundary_margin?: number;
classification_mode?: ClassificationMode;
session_affinity?: boolean;
modality_routing?: boolean;
@ -516,6 +519,9 @@ export const effectiveTierLabel = (tier: keyof ComplexityTiers, tierLabels: Comp
export const DEFAULT_HEURISTIC_FIRST_MAX_TIER = "SIMPLE";
/** What the Hybrid radio starts at. Required by that type, so the form always has a value to send. */
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.

View file

@ -342,6 +342,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
planModeMinTier: complexityRouterConfig.plan_mode_min_tier,
classificationPrompt: complexityRouterConfig.classification_prompt,
heuristicFirstMaxTier: complexityRouterConfig.heuristic_first_max_tier,
hybridBoundaryMargin: complexityRouterConfig.hybrid_boundary_margin,
classificationMode: complexityRouterConfig.classification_mode,
tierLabels: complexityRouterConfig.tier_labels,
classifierType: complexityRouterConfig.classifier_type,

View file

@ -817,6 +817,39 @@ describe("heuristic_first", () => {
});
});
describe("hybrid", () => {
const hybridParams: BuildComplexityRouterConfigParams = {
...baseParams,
classifierType: "hybrid",
hybridBoundaryMargin: 0.03,
classifierLlmConfig: { model: "gpt-4o-mini", timeout_ms: 3000 },
classifierFallback: "default_model",
};
it("emits hybrid_boundary_margin, zero included since exactly-on-a-boundary is a real setting", () => {
expect(buildComplexityRouterConfig(hybridParams).hybrid_boundary_margin).toBe(0.03);
expect(buildComplexityRouterConfig({ ...hybridParams, hybridBoundaryMargin: 0 }).hybrid_boundary_margin).toBe(0);
});
it("keeps every classifier key the operator set, since hybrid still calls the classifier", () => {
const config = buildComplexityRouterConfig(hybridParams);
expect(config.classifier_type).toBe("hybrid");
expect(config.classifier_llm_config).toEqual({ model: "gpt-4o-mini", timeout_ms: 3000 });
expect(config.classifier_fallback).toBe("default_model");
});
it("omits hybrid_boundary_margin on every other classifier type, which the backend rejects it on", () => {
for (const classifierType of ["heuristic", "llm", "heuristic_first"] as const) {
const config = buildComplexityRouterConfig({
...hybridParams,
classifierType,
...(classifierType === "heuristic_first" && { heuristicFirstMaxTier: "SIMPLE" }),
});
expect(config.hybrid_boundary_margin).toBeUndefined();
}
});
});
describe("classification_mode", () => {
it("emits user_turn", () => {
const config = buildComplexityRouterConfig({ ...baseParams, classificationMode: "user_turn" });
@ -924,10 +957,12 @@ describe("buildComplexityRouterConfig with an edited tier set", () => {
dimensionWeights: { length: 1 },
reasoningOverrideMinScore: 0.5,
heuristicFirstMaxTier: "SIMPLE",
hybridBoundaryMargin: 0.03,
customTechnicalKeywords: ["kubernetes"],
};
const emittingType = key === "heuristic_first_max_tier" ? "heuristic_first" : "llm";
expect(buildComplexityRouterConfig({ ...baseParams, ...loaded, classifierType: emittingType })).toHaveProperty(key);
const typeForKey = key === "hybrid_boundary_margin" ? "hybrid" : emittingType;
expect(buildComplexityRouterConfig({ ...baseParams, ...loaded, classifierType: typeForKey })).toHaveProperty(key);
expect(build(loaded)).not.toHaveProperty(key);
});

View file

@ -107,6 +107,7 @@ export interface BuildComplexityRouterConfigParams {
classifierFallback: ClassifierFallback | undefined;
classificationPrompt: string | undefined;
heuristicFirstMaxTier: string | undefined;
hybridBoundaryMargin?: number;
classificationMode: ClassificationMode | undefined;
sessionAffinity: boolean;
modalityRouting?: boolean;
@ -163,6 +164,7 @@ export interface ComplexityRouterConfigPayload {
classifier_fallback?: ClassifierFallback;
classification_prompt?: string;
heuristic_first_max_tier?: string;
hybrid_boundary_margin?: number;
classification_mode: ClassificationMode;
session_affinity: boolean;
deployment_affinity: boolean;
@ -352,6 +354,7 @@ const classifierWireFields = (
classifierLlmConfig,
classifierFallback,
heuristicFirstMaxTier,
hybridBoundaryMargin,
classifierContextWindowSize,
classifierContextBudgetChars,
classifierContextIncludeAssistantTurns,
@ -360,6 +363,7 @@ const classifierWireFields = (
| "classifierLlmConfig"
| "classifierFallback"
| "heuristicFirstMaxTier"
| "hybridBoundaryMargin"
| "classifierContextWindowSize"
| "classifierContextBudgetChars"
| "classifierContextIncludeAssistantTurns"
@ -371,6 +375,8 @@ const classifierWireFields = (
classifierFallback !== undefined && { classifier_fallback: classifierFallback }),
...(effectiveType === "heuristic_first" &&
heuristicFirstMaxTier?.trim() && { heuristic_first_max_tier: heuristicFirstMaxTier }),
...(effectiveType === "hybrid" &&
hybridBoundaryMargin !== undefined && { hybrid_boundary_margin: hybridBoundaryMargin }),
...(usesLlmClassifier(effectiveType) &&
classifierContextWindowSize !== undefined && {
classifier_context_window_size: classifierContextWindowSize,
@ -399,6 +405,7 @@ export const buildComplexityRouterConfig = ({
classifierFallback,
classificationPrompt,
heuristicFirstMaxTier,
hybridBoundaryMargin,
classificationMode,
sessionAffinity,
modalityRouting,
@ -444,6 +451,7 @@ export const buildComplexityRouterConfig = ({
classifierLlmConfig,
classifierFallback,
heuristicFirstMaxTier,
hybridBoundaryMargin,
classifierContextWindowSize,
classifierContextBudgetChars,
classifierContextIncludeAssistantTurns,

View file

@ -122,10 +122,10 @@ export const CUSTOM_TIER_RESTRICTIONS = {
reason: "Session pinning escalates along the built-in tier ladder, which your tier set replaces",
},
heuristicClassifier: {
omit: ["heuristic_first_max_tier"],
omit: ["heuristic_first_max_tier", "hybrid_boundary_margin"],
reason:
"The heuristic scorer only produces the built-in tiers, so an edited set needs the LLM classifier. " +
"Heuristic first is out for the same reason: its local scorer decides the cheap traffic",
"Heuristic first and hybrid are out for the same reason: their local scorer decides the traffic it is sure of",
},
heuristicScoring: {
omit: [

View file

@ -520,16 +520,21 @@ describe("managed keys survive an untouched open-and-save", () => {
};
// tier_definitions, fallback_tier and classification_prompt cannot sit beside heuristic_first, which
// this fixture uses, so no single stored config can hold every managed key. They get their own round
// trip below.
const CUSTOM_TIER_ONLY_KEYS = new Set(["tier_definitions", "fallback_tier", "classification_prompt"]);
// this fixture uses, and hybrid_boundary_margin belongs to the sibling hybrid type, so no single
// stored config can hold every managed key. Each gets its own round trip below.
const KEYS_ANOTHER_CLASSIFIER_TYPE_OWNS = new Set([
"tier_definitions",
"fallback_tier",
"classification_prompt",
"hybrid_boundary_margin",
]);
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);
const dropped = [...MANAGED_COMPLEXITY_ROUTER_KEYS]
.filter((key) => !CUSTOM_TIER_ONLY_KEYS.has(key))
.filter((key) => !KEYS_ANOTHER_CLASSIFIER_TYPE_OWNS.has(key))
.filter((key) => saved[key] === undefined);
expect(dropped).toEqual([]);
});
@ -583,6 +588,18 @@ describe("managed keys survive an untouched open-and-save", () => {
);
});
it("round-trips a hybrid router's margin, which save requires and the backend rejects without", () => {
const storedHybrid: Record<string, unknown> = {
...STORED_ALL_MANAGED,
classifier_type: "hybrid",
hybrid_boundary_margin: 0.05,
};
delete storedHybrid.heuristic_first_max_tier;
const hydrated = hydrateComplexityRouterConfig(storedHybrid, undefined);
expect(hydrated.hybrid_boundary_margin).toBe(0.05);
expect(buildUpdatedComplexityRouterConfig(storedHybrid, hydrated).hybrid_boundary_margin).toBe(0.05);
});
it("round-trips the heuristic_first threshold, which save requires and the backend rejects without", () => {
const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined);
expect(hydrated.heuristic_first_max_tier).toBe("SIMPLE");

View file

@ -89,6 +89,7 @@ export interface StoredComplexityRouterConfig {
plan_mode_min_tier?: unknown;
classification_prompt?: unknown;
heuristic_first_max_tier?: unknown;
hybrid_boundary_margin?: unknown;
tier_labels?: unknown;
classifier_type?: ClassifierType;
classifier_llm_config?: ClassifierLLMConfig;
@ -167,6 +168,8 @@ export const hydrateComplexityRouterConfig = (
typeof parsedConfig.heuristic_first_max_tier === "string" && parsedConfig.heuristic_first_max_tier.trim() !== ""
? parsedConfig.heuristic_first_max_tier
: undefined,
hybrid_boundary_margin:
typeof parsedConfig.hybrid_boundary_margin === "number" ? parsedConfig.hybrid_boundary_margin : undefined,
classification_mode:
parsedConfig.classification_mode === "user_turn" || parsedConfig.classification_mode === "every_request"
? parsedConfig.classification_mode
@ -214,6 +217,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"classifier_fallback",
"classification_prompt",
"heuristic_first_max_tier",
"hybrid_boundary_margin",
"classification_mode",
"session_affinity",
"modality_routing",
@ -303,6 +307,7 @@ export const buildUpdatedComplexityRouterConfig = (
planModeMinTier: value.plan_mode_min_tier,
classificationPrompt: value.classification_prompt,
heuristicFirstMaxTier: value.heuristic_first_max_tier,
hybridBoundaryMargin: value.hybrid_boundary_margin,
classificationMode: value.classification_mode,
tierLabels: value.tier_labels,
classifierType: value.classifier_type,

View file

@ -86,6 +86,7 @@ const CONSTANT_CAUSE_LABELS: Record<string, string> = {
heuristic_scorer: "Heuristic scorer",
heuristic_v2: "Heuristic v2",
heuristic_first_short_circuit: "Heuristic scorer, classifier skipped",
hybrid_short_circuit: "Heuristic scorer, score clear of every boundary",
classifier_plugin: "Custom classifier plugin",
semantic_keyword_match: "Semantic keyword match",
session_affinity_pin: "Pinned to session",

View file

@ -34560,7 +34560,7 @@ export interface components {
* @enum {string}
*/
classifier_fallback: "heuristic" | "default_model";
/** @description Configuration for the LLM classifier; required when classifier_type is 'llm' or 'heuristic_first' */
/** @description Configuration for the LLM classifier; required when classifier_type is 'llm', 'heuristic_first' or 'hybrid' */
classifier_llm_config?: components["schemas"]["ClassifierLLMConfig"] | null;
/**
* Classifier Plugin
@ -34575,11 +34575,11 @@ export interface components {
classifier_plugin_timeout_ms: number;
/**
* Classifier Type
* @description Classification strategy: local regex/keyword scoring, the bundled trained four-tier heuristic, an LLM call, a custom classifier plugin, or 'heuristic_first', which scores locally and only pays for the LLM classifier when the local scorer does not confidently land a cheap tier
* @description Classification strategy: local regex/keyword scoring, the bundled trained four-tier heuristic, an LLM call, a custom classifier plugin, 'heuristic_first', which scores locally and only pays for the LLM classifier when the local scorer does not confidently land a cheap tier, or 'hybrid', which trusts the local scorer everywhere except when its score lands near a tier boundary
* @default heuristic
* @enum {string}
*/
classifier_type: "heuristic" | "heuristic_v2" | "llm" | "custom" | "heuristic_first";
classifier_type: "heuristic" | "heuristic_v2" | "llm" | "custom" | "heuristic_first" | "hybrid";
/**
* Code Keywords
* @description Keywords indicating code-related content
@ -34651,6 +34651,11 @@ export interface components {
* @description Additional case-sensitive literal sentinels that mark a request as client housekeeping, on top of the built-in conversation-title ones. For clients whose wording the built-ins don't cover, or after a client release changes its strings.
*/
housekeeping_patterns?: string[] | null;
/**
* Hybrid Boundary Margin
* @description How close to a tier boundary a heuristic score has to land before the LLM classifier breaks the tie; required when classifier_type is 'hybrid' and rejected otherwise. Everything further than this from every active boundary routes on the scorer's own tier with no classifier call, at any tier, which is what separates 'hybrid' from 'heuristic_first' and its cheap-tier ceiling. A prompt where no dimension fired still goes to the classifier, since the scorer has no opinion to be near a boundary with. 0 escalates only scores sitting exactly on a boundary.
*/
hybrid_boundary_margin?: number | null;
/**
* Keyword Tier Rules
* @description Rules that force a specific tier when their keywords match the prompt
@ -35867,7 +35872,7 @@ export interface components {
* Cause
* @enum {string}
*/
cause?: "heuristic_scorer" | "heuristic_v2" | "reasoning_override" | "llm_classifier" | "heuristic_first_short_circuit" | "classifier_plugin" | "classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "modality_escalation" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
cause?: "heuristic_scorer" | "heuristic_v2" | "reasoning_override" | "llm_classifier" | "heuristic_first_short_circuit" | "hybrid_short_circuit" | "classifier_plugin" | "classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "modality_escalation" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
/** Classifier Cost */
classifier_cost?: number;
/** Classifier Model */