Merge pull request #39701 from BerriAI/litellm_classifier_circuit_breaker

feat(router): add classifier circuit breaker
This commit is contained in:
moe-berri 2026-09-04 12:10:11 -07:00 • committed by GitHub
commit 9f3aa8f36e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 435 additions and 9 deletions

View file

@ -275,6 +275,15 @@ model_list:
keep the classifier deployment or provider default, or set a supported value such as `none` or
`low` to override that call.
Classifier calls have a one-attempt hard deadline. After a timeout, the router opens a process-local
circuit for that classifier and sends every session through `classifier_fallback` for
`classifier_llm_config.circuit_breaker_cooldown_seconds` (30 seconds by default). When the cooldown
expires, one request probes the classifier while concurrent requests continue through the fallback.
A successful probe closes the circuit; a failed probe restarts the cooldown. The circuit breaker is
on by default; set `classifier_llm_config.circuit_breaker_enabled: false` to disable it. The default
fallback is the local heuristic scorer, so a classifier outage does not repeat its timeout across
every turn or session handled by the router process.
A request short-circuits, meaning it routes on the scorer's own tier with no classifier call, when
two things hold: the scorer landed at or below `heuristic_first_max_tier`, and it produced at least
one signal. Everything else goes to the classifier, which then decides as it normally would.

View file

@ -18,8 +18,10 @@ from __future__ import annotations
import asyncio
import random
import re
from collections.abc import Iterator, Mapping, Sequence
import time
from collections.abc import Callable, Iterator, Mapping, Sequence
from itertools import accumulate, islice, takewhile
from threading import Lock
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple, cast
@ -816,6 +818,77 @@ class ClassificationOutcome(NamedTuple):
classifier_cost: float | None = None
class _ClassifierCircuitBreaker:
"""Process-local timeout breaker for one complexity-router classifier.
The router instance serves every session assigned to that auto-router deployment, so the
breaker prevents one unhealthy classifier from charging the same timeout to each session.
Exactly one request becomes the recovery probe after the cooldown; the lock makes that state
transition atomic even when several request tasks arrive together.
"""
CLOSED: Final = "closed"
OPEN: Final = "open"
HALF_OPEN: Final = "half_open"
def __init__(self, cooldown_seconds: float, clock: Callable[[], float] = time.monotonic) -> None:
self._cooldown_seconds = cooldown_seconds
self._clock = clock
self._state = self.CLOSED
self._opened_at: float | None = None
self._generation = 0
self._lock = Lock()
def acquire_permit(self) -> int | None:
"""Return a generation-scoped permit, or deny the call while the circuit is open.
Calls admitted together while closed share a generation. The first timeout advances it,
making every other in-flight completion stale so it cannot erase the new cooldown.
"""
with self._lock:
if self._state == self.CLOSED:
return self._generation
if self._state == self.HALF_OPEN:
return None
opened_at: Final = self._opened_at
if opened_at is not None and self._clock() - opened_at >= self._cooldown_seconds:
self._state = self.HALF_OPEN
return self._generation
return None
def record_success(self, permit: int) -> None:
"""Close only when the current half-open recovery probe succeeds."""
with self._lock:
if self._state != self.HALF_OPEN or permit != self._generation:
return
self._state = self.CLOSED
self._opened_at = None
def record_failure(self, permit: int, *, is_timeout: bool) -> None:
"""Open on a normal timeout, or reopen when the single recovery probe fails."""
with self._lock:
if permit != self._generation:
return
if self._state == self.CLOSED:
if not is_timeout:
return
elif self._state != self.HALF_OPEN:
return
self._generation += 1
self._state = self.OPEN
self._opened_at = self._clock()
def _is_classifier_timeout(exc: BaseException) -> bool:
# asyncio.TimeoutError became an alias of the built-in TimeoutError in Python 3.11.
# LiteLLM still supports 3.10, where they are distinct exception classes.
if isinstance(exc, (TimeoutError, asyncio.TimeoutError)):
return True
from litellm.exceptions import Timeout as LiteLLMTimeout
return isinstance(exc, LiteLLMTimeout)
def _allowed(models: tuple[str, ...], fit_filter: frozenset[str] | None) -> tuple[str, ...]:
return models if fit_filter is None else tuple(model for model in models if model in fit_filter)
@ -993,6 +1066,15 @@ class ComplexityRouter(CustomLogger):
if llm_classifier_configured
else None
)
self._classifier_circuit_breaker: _ClassifierCircuitBreaker | None = (
_ClassifierCircuitBreaker(self.config.classifier_llm_config.circuit_breaker_cooldown_seconds)
if (
llm_classifier_configured
and self.config.classifier_llm_config is not None
and self.config.classifier_llm_config.circuit_breaker_enabled
)
else None
)
self._tier_success_predictor: TierSuccessPredictor | None = (
TierSuccessPredictor(resolve_tier_artifact(self.config.heuristic_v2_artifact))
if self.config.classifier_type == "heuristic_v2"
@ -1474,8 +1556,20 @@ class ComplexityRouter(CustomLogger):
`scored` is the heuristic outcome the caller already computed, which only "heuristic_first"
has. It is handed to the failure path so a classifier error does not re-run the scorer.
"""
breaker: Final = self._classifier_circuit_breaker
permit: Final = breaker.acquire_permit() if breaker is not None else None
if breaker is not None and permit is None:
return self._classifier_failure_outcome(
"LLM classifier circuit is open",
prompt,
system_prompt,
scored,
signal="classifier-circuit-open",
)
try:
tier, classifier_cost = await self._classify_with_llm(prompt, system_prompt, request_kwargs, messages)
if breaker is not None and permit is not None:
breaker.record_success(permit)
return ClassificationOutcome(
tier=tier,
score=None,
@ -1483,7 +1577,13 @@ class ComplexityRouter(CustomLogger):
cause="llm_classifier",
classifier_cost=classifier_cost,
)
except asyncio.CancelledError:
if breaker is not None and permit is not None:
breaker.record_failure(permit, is_timeout=False)
raise
except Exception as e: # noqa: BLE001 -- external LLM call can fail in many distinct ways (timeout, provider error, validation, parse error); any failure must fall back to the configured fallback path
if breaker is not None and permit is not None:
breaker.record_failure(permit, is_timeout=_is_classifier_timeout(e))
return self._classifier_failure_outcome(f"LLM classifier failed ({e})", prompt, system_prompt, scored)
def _classifier_failure_outcome(
@ -1492,6 +1592,7 @@ class ComplexityRouter(CustomLogger):
prompt: str,
system_prompt: str | None,
scored: ClassificationOutcome | None = None,
signal: str | None = None,
) -> ClassificationOutcome:
"""The outcome when the LLM classifier or classifier plugin produced no usable tier:
fallback_tier on a custom tier set, classifier_fallback otherwise.
@ -1501,21 +1602,28 @@ class ComplexityRouter(CustomLogger):
fallback_tier: Final = self.config.fallback_tier
if fallback_tier is not None:
verbose_router_logger.warning("ComplexityRouter: %s, routing to fallback_tier %s", reason, fallback_tier)
return ClassificationOutcome(
outcome: Final = ClassificationOutcome(
tier=fallback_tier,
score=None,
signals=(f"classifier-fallback:{fallback_tier}",),
cause="classifier_fallback",
)
return outcome if signal is None else outcome._replace(signals=(*outcome.signals, signal))
verbose_router_logger.warning(
"ComplexityRouter: %s, falling back to %s", reason, self.config.classifier_fallback
)
if self.config.classifier_fallback == "default_model":
return self._default_model_fallback_outcome()
outcome = self._default_model_fallback_outcome()
return outcome if signal is None else outcome._replace(signals=(*outcome.signals, signal))
if scored is not None:
return scored
return scored if signal is None else scored._replace(signals=(*scored.signals, signal))
tier, score, signals, cause = self._score_and_classify(prompt, system_prompt)
return ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause)
return ClassificationOutcome(
tier=tier,
score=score,
signals=signals if signal is None else (*signals, signal),
cause=cause,
)
async def _classify_with_plugin(
self,

View file

@ -444,6 +444,23 @@ class ClassifierLLMConfig(BaseModel):
default=3000,
description="Timeout budget for the classification call, in milliseconds",
)
circuit_breaker_enabled: bool = Field(
default=True,
description=(
"Whether one classifier timeout temporarily sends requests through classifier_fallback. "
"Enabled by default so an unhealthy classifier cannot repeat its timeout across sessions."
),
)
circuit_breaker_cooldown_seconds: float = Field(
default=30.0,
gt=0.0,
description=(
"How long to skip this router's LLM classifier after a classification call times out. "
"Requests use classifier_fallback during the cooldown. When it expires, one request "
"probes the classifier while concurrent requests keep using the fallback; a successful "
"probe closes the circuit and a failed probe restarts the cooldown."
),
)
classification_rubric: ClassificationRubric | None = Field(
default=None,
description=(

View file

@ -14,7 +14,6 @@ from pydantic import ValidationError
import litellm
from litellm import Router
from litellm.router_utils.auto_router_model_naming import count_heuristic_v2_routers
from litellm._logging import verbose_router_logger
from litellm.caching.dual_cache import DualCache
from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY, SESSION_ID_GENERATED_METADATA_KEY
@ -26,6 +25,8 @@ from litellm.router_strategy.complexity_router.complexity_router import (
DimensionScore,
KeywordOverride,
_built_in_prompt,
_ClassifierCircuitBreaker,
_is_classifier_timeout,
_matched_plan_mode_sentinel,
classification_system_prompt,
)
@ -43,6 +44,7 @@ from litellm.router_strategy.complexity_router.tier_predictor import (
TierGlobalStatistic,
TrainedTierArtifact,
)
from litellm.router_utils.auto_router_model_naming import count_heuristic_v2_routers
from litellm.types.router import (
Deployment,
LiteLLM_Params,
@ -1718,6 +1720,13 @@ class TestLLMClassifierConfig:
assert config.classifier_type == "heuristic"
assert config.classifier_llm_config is None
def test_classifier_circuit_breaker_defaults_on_and_requires_positive_cooldown(self):
config = ClassifierLLMConfig(model="haiku-classifier")
assert config.circuit_breaker_enabled is True
assert config.circuit_breaker_cooldown_seconds == 30.0
with pytest.raises(ValidationError):
ClassifierLLMConfig(model="haiku-classifier", circuit_breaker_cooldown_seconds=0)
@pytest.mark.parametrize("reasoning_effort", ["", "ultra"])
def test_classifier_reasoning_effort_rejects_unsupported_values(self, reasoning_effort):
with pytest.raises(ValidationError):
@ -2031,8 +2040,11 @@ class TestLLMClassifier:
)
outcome = await router.aclassify("hi")
next_outcome = await router.aclassify("hi again")
assert outcome.cause == "heuristic_scorer"
assert next_outcome.cause == "heuristic_scorer"
assert "classifier-circuit-open" in next_outcome.signals
assert real_router.total_calls["openai/mock-classifier"] == 1
assert real_router.total_calls["openai/mock-backup-classifier"] == 0
@ -2065,6 +2077,129 @@ class TestLLMClassifier:
assert outcome.cause == "heuristic_scorer"
assert cancelled.is_set()
@pytest.mark.asyncio
async def test_timeout_opens_classifier_circuit_for_other_sessions(
self, mock_router_instance, llm_classifier_config
):
"""One classifier outage is deployment-wide, so a second session must not pay the timeout."""
mock_router_instance.acompletion = AsyncMock(side_effect=TimeoutError("classifier timed out"))
router = ComplexityRouter(
model_name="test-complexity-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=llm_classifier_config,
)
first = await router.aclassify("first ask", request_kwargs={"metadata": {"session_id": "session-a"}})
second = await router.aclassify("second ask", request_kwargs={"metadata": {"session_id": "session-b"}})
assert first.cause == "heuristic_scorer"
assert second.cause == "heuristic_scorer"
assert "classifier-circuit-open" in second.signals
mock_router_instance.acompletion.assert_awaited_once()
def test_classifier_circuit_allows_one_probe_and_closes_on_success(self):
now = 100.0
breaker = _ClassifierCircuitBreaker(30.0, clock=lambda: now)
initial_permit = breaker.acquire_permit()
assert initial_permit is not None
breaker.record_failure(initial_permit, is_timeout=True)
assert breaker.acquire_permit() is None
now = 130.0
probe_permit = breaker.acquire_permit()
assert probe_permit is not None
assert breaker.acquire_permit() is None
breaker.record_success(probe_permit)
assert breaker.acquire_permit() is not None
def test_failed_classifier_probe_restarts_cooldown(self):
now = 100.0
breaker = _ClassifierCircuitBreaker(30.0, clock=lambda: now)
initial_permit = breaker.acquire_permit()
assert initial_permit is not None
breaker.record_failure(initial_permit, is_timeout=True)
now = 130.0
probe_permit = breaker.acquire_permit()
assert probe_permit is not None
breaker.record_failure(probe_permit, is_timeout=False)
assert breaker.acquire_permit() is None
now = 160.0
assert breaker.acquire_permit() is not None
def test_stale_success_cannot_close_circuit_opened_by_overlapping_timeout(self):
breaker = _ClassifierCircuitBreaker(30.0)
timeout_permit = breaker.acquire_permit()
stale_success_permit = breaker.acquire_permit()
assert timeout_permit is not None
assert stale_success_permit is not None
breaker.record_failure(timeout_permit, is_timeout=True)
breaker.record_success(stale_success_permit)
assert breaker.acquire_permit() is None
@pytest.mark.asyncio
async def test_cancelled_classifier_probe_restarts_cooldown(self, mock_router_instance, llm_classifier_config):
now = 100.0
mock_router_instance.acompletion = AsyncMock(
side_effect=[
TimeoutError("classifier timed out"),
asyncio.CancelledError(),
_llm_response('{"tier": "SIMPLE"}'),
]
)
router = ComplexityRouter(
model_name="test-complexity-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=llm_classifier_config,
)
router._classifier_circuit_breaker = _ClassifierCircuitBreaker(30.0, clock=lambda: now)
await router.aclassify("open the circuit")
now = 130.0
with pytest.raises(asyncio.CancelledError):
await router.aclassify("cancel the recovery probe")
outcome = await router.aclassify("stay in cooldown")
assert outcome.cause == "heuristic_scorer"
assert "classifier-circuit-open" in outcome.signals
assert mock_router_instance.acompletion.await_count == 2
@pytest.mark.asyncio
async def test_classifier_circuit_can_be_disabled(self, mock_router_instance, llm_classifier_config):
mock_router_instance.acompletion = AsyncMock(side_effect=TimeoutError("classifier timed out"))
router = ComplexityRouter(
model_name="test-complexity-router",
litellm_router_instance=mock_router_instance,
complexity_router_config={
**llm_classifier_config,
"classifier_llm_config": {
**llm_classifier_config["classifier_llm_config"],
"circuit_breaker_enabled": False,
},
},
)
await router.aclassify("first ask")
await router.aclassify("second ask")
assert mock_router_instance.acompletion.await_count == 2
def test_non_timeout_failure_does_not_open_closed_classifier_circuit(self):
breaker = _ClassifierCircuitBreaker(30.0)
permit = breaker.acquire_permit()
assert permit is not None
breaker.record_failure(permit, is_timeout=False)
assert breaker.acquire_permit() is not None
def test_asyncio_timeout_is_a_classifier_timeout_on_python_310(self):
assert _is_classifier_timeout(asyncio.TimeoutError()) is True
@pytest.mark.asyncio
async def test_aclassify_classifier_cost_is_none_when_call_is_unpriced(
self, llm_complexity_router, mock_router_instance
@ -8527,7 +8662,8 @@ class TestClassifierFallbackChoice:
@pytest.mark.asyncio
async def test_a_classifier_failure_does_not_pin_the_session_to_the_default_model(self, mock_router_instance):
"""One transient timeout must not hold a session on default_model for the whole affinity TTL:
that turn was never classified, so there is nothing worth pinning and the next turn retries."""
that turn was never classified, so there is nothing worth pinning. The circuit breaker is
disabled here so the next turn isolates and verifies the affinity contract."""
router = ComplexityRouter(
model_name="test-complexity-router",
litellm_router_instance=mock_router_instance,
@ -8539,7 +8675,11 @@ class TestClassifierFallbackChoice:
"REASONING": "o1-preview",
},
"classifier_type": "llm",
"classifier_llm_config": {"model": "haiku-classifier", "timeout_ms": 400},
"classifier_llm_config": {
"model": "haiku-classifier",
"timeout_ms": 400,
"circuit_breaker_enabled": False,
},
"classifier_fallback": "default_model",
"default_model": "gpt-4o",
"session_affinity": True,

View file

@ -14,6 +14,7 @@ import CustomTierPromptEditor from "./CustomTierPromptEditor";
import { RestrictedSection, restrictedBy } from "./TierRestrictions";
import HeuristicScoringConfig from "./HeuristicScoringConfig";
import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect";
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
import type { ReasoningEffort } from "./complexity_router_tiers";
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
import {
@ -555,6 +556,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
How long the classifier call has before it fails and the fallback below takes over.
</span>
</div>
<ClassifierCircuitBreakerConfig
value={value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS }}
onChange={(classifier_llm_config) => onChange({ ...value, classifier_llm_config })}
/>
<div>
<div className="flex items-center gap-2 mb-1">
<strong className="font-semibold">Classification Rubric</strong>

View file

@ -0,0 +1,69 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import React from "react";
import type { ClassifierLLMConfig } from "./ComplexityRouterConfig";
export const DEFAULT_CLASSIFIER_CIRCUIT_BREAKER_ENABLED = true;
export const DEFAULT_CLASSIFIER_CIRCUIT_BREAKER_COOLDOWN_SECONDS = 30;
const COOLDOWN_ID = "classifier-circuit-breaker-cooldown-seconds";
interface ClassifierCircuitBreakerConfigProps {
value: ClassifierLLMConfig;
onChange: (value: ClassifierLLMConfig) => void;
}
const ClassifierCircuitBreakerConfig: React.FC<ClassifierCircuitBreakerConfigProps> = ({ value, onChange }) => {
const [draftCooldown, setDraftCooldown] = React.useState<string | null>(null);
const enabled = value.circuit_breaker_enabled ?? DEFAULT_CLASSIFIER_CIRCUIT_BREAKER_ENABLED;
const handleCooldownChange = (raw: string) => {
setDraftCooldown(raw);
const parsed = Number(raw);
if (raw.trim() === "" || !Number.isFinite(parsed)) return;
onChange({
...value,
circuit_breaker_cooldown_seconds: Math.max(1, Math.round(parsed)),
});
};
return (
<div className="space-y-2 rounded-md border border-border p-3">
<div className="flex items-center gap-2">
<Switch
checked={enabled}
onCheckedChange={(circuit_breaker_enabled) => onChange({ ...value, circuit_breaker_enabled })}
aria-label="Classifier circuit breaker"
/>
<strong className="font-semibold">Classifier circuit breaker</strong>
</div>
<span className="block text-xs text-muted-foreground">
After one classifier timeout, use the fallback immediately for every session until a recovery probe succeeds.
Enabled by default.
</span>
{enabled && (
<div>
<Label htmlFor={COOLDOWN_ID} className="block mb-1 font-semibold">
Circuit breaker cooldown (seconds)
</Label>
<Input
id={COOLDOWN_ID}
type="text"
inputMode="numeric"
value={
draftCooldown ??
String(value.circuit_breaker_cooldown_seconds ?? DEFAULT_CLASSIFIER_CIRCUIT_BREAKER_COOLDOWN_SECONDS)
}
onChange={(event) => handleCooldownChange(event.target.value)}
onBlur={() => setDraftCooldown(null)}
className="w-full"
/>
</div>
)}
</div>
);
};
export default ClassifierCircuitBreakerConfig;

View file

@ -166,10 +166,31 @@ describe("ComplexityRouterConfig", () => {
expect(screen.getByText("Classifier Model")).toBeInTheDocument();
expect(screen.getByLabelText("Timeout (ms)")).toHaveValue("750");
expect(screen.getByRole("switch", { name: "Classifier circuit breaker" })).toBeChecked();
expect(screen.getByLabelText("Circuit breaker cooldown (seconds)")).toHaveValue("30");
expect(screen.getByLabelText("Context Window Size")).toHaveValue("5");
expect(screen.queryByText("Context Per-Turn Character Limit")).not.toBeInTheDocument();
});
it("should allow the default-on classifier circuit breaker to be disabled", () => {
const onChange = vi.fn();
const llmValue: ComplexityRouterConfigValue = {
...defaultValue,
classifier_type: "llm",
classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 },
};
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={llmValue} onChange={onChange} />);
fireEvent.click(screen.getByText("Advanced: Classification Method"));
fireEvent.click(screen.getByRole("switch", { name: "Classifier circuit breaker" }));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
classifier_llm_config: expect.objectContaining({ circuit_breaker_enabled: false }),
}),
);
});
it("should default the context window and budget when llm is selected", () => {
const llmValue: ComplexityRouterConfigValue = {
...defaultValue,
@ -278,6 +299,17 @@ describe("ComplexityRouterConfig", () => {
it.each([
["Timeout (ms)", "7", { classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 7 } }],
[
"Circuit breaker cooldown (seconds)",
"45",
{
classifier_llm_config: {
model: "gpt-3.5-turbo",
timeout_ms: 3000,
circuit_breaker_cooldown_seconds: 45,
},
},
],
["Context Window Size", "0", { classifier_context_window_size: 0 }],
["Context Character Budget", "7", { classifier_context_budget_chars: 7 }],
])("keeps %s empty while it is being edited, then commits %s", (label, replacement, expected) => {

View file

@ -126,6 +126,8 @@ export const CLASSIFICATION_RUBRIC_KEYS = Object.keys(CLASSIFICATION_RUBRIC_DESC
export interface ClassifierLLMConfig {
model: string;
timeout_ms: number;
circuit_breaker_enabled?: boolean;
circuit_breaker_cooldown_seconds?: number;
reasoning_effort?: ReasoningEffort;
classification_rubric?: ClassificationRubric;
system_prompt?: string;

View file

@ -113,6 +113,21 @@ describe("buildComplexityRouterConfig", () => {
expect(config.classifier_llm_config).toEqual({ model: "gpt-4o-mini", timeout_ms: 3000 });
});
it("preserves explicit classifier circuit-breaker settings, including disabled", () => {
const classifierLlmConfig = {
model: "gpt-4o-mini",
timeout_ms: 3000,
circuit_breaker_enabled: false,
circuit_breaker_cooldown_seconds: 45,
};
const config = buildComplexityRouterConfig({
...baseParams,
classifierType: "llm",
classifierLlmConfig,
});
expect(config.classifier_llm_config).toEqual(classifierLlmConfig);
});
it("omits classifier_llm_config when classifier_type is heuristic even if config lingers in state", () => {
const config = buildComplexityRouterConfig({
...baseParams,

View file

@ -56,15 +56,26 @@ import {
export const normalizeClassifierLlmConfig = ({
model,
timeout_ms,
circuit_breaker_enabled,
circuit_breaker_cooldown_seconds,
reasoning_effort,
classification_rubric,
system_prompt,
}: ClassifierLLMConfig): ClassifierLLMConfig =>
system_prompt?.trim()
? { model, timeout_ms, ...(reasoning_effort && { reasoning_effort }), system_prompt }
? {
model,
timeout_ms,
...(circuit_breaker_enabled !== undefined && { circuit_breaker_enabled }),
...(circuit_breaker_cooldown_seconds !== undefined && { circuit_breaker_cooldown_seconds }),
...(reasoning_effort && { reasoning_effort }),
system_prompt,
}
: {
model,
timeout_ms,
...(circuit_breaker_enabled !== undefined && { circuit_breaker_enabled }),
...(circuit_breaker_cooldown_seconds !== undefined && { circuit_breaker_cooldown_seconds }),
...(reasoning_effort && { reasoning_effort }),
...(classification_rubric && { classification_rubric }),
};
@ -325,6 +336,12 @@ export const customTierWireFields = (
classifier_llm_config: {
model: classifierLlmConfig.model,
timeout_ms: classifierLlmConfig.timeout_ms,
...(classifierLlmConfig.circuit_breaker_enabled !== undefined && {
circuit_breaker_enabled: classifierLlmConfig.circuit_breaker_enabled,
}),
...(classifierLlmConfig.circuit_breaker_cooldown_seconds !== undefined && {
circuit_breaker_cooldown_seconds: classifierLlmConfig.circuit_breaker_cooldown_seconds,
}),
...(classifierLlmConfig.reasoning_effort && { reasoning_effort: classifierLlmConfig.reasoning_effort }),
},
}),

View file

@ -25206,6 +25206,18 @@ export interface components {
* @description Configuration for the LLM-based complexity classifier.
*/
ClassifierLLMConfig: {
/**
* Circuit Breaker Cooldown Seconds
* @description How long to skip this router's LLM classifier after a classification call times out. Requests use classifier_fallback during the cooldown. When it expires, one request probes the classifier while concurrent requests keep using the fallback; a successful probe closes the circuit and a failed probe restarts the cooldown.
* @default 30
*/
circuit_breaker_cooldown_seconds: number;
/**
* Circuit Breaker Enabled
* @description Whether one classifier timeout temporarily sends requests through classifier_fallback. Enabled by default so an unhealthy classifier cannot repeat its timeout across sessions.
* @default true
*/
circuit_breaker_enabled: boolean;
/** @description Which calibration examples the built-in rubric carries. 'agentic' anchors routine installs, builds, multi-file edits, and standard debugging at MEDIUM, so ordinary engineering does not route to the most expensive tier; it suits agent, terminal, and coding-assistant traffic as well as mixed traffic. 'chat' omits those engineering anchors, for a deployment serving only conversational traffic. 'business' carries business/sales anchors and business-flavored tier criteria that keep routine drafting and summarizing off the expensive tiers and reserve the top tier for committing to decisions under tradeoffs; it suits sales, support, and go-to-market traffic. Every preset keeps the same four tiers, so this moves where the boundary sits without changing the taxonomy. Leave unset for 'legacy', the rubric as it shipped before calibration examples existed, so an existing router's tier decisions and spend do not move on upgrade. Mutually exclusive with system_prompt, which replaces the rubric this would select. Only applies when classifier_type is 'llm'. */
classification_rubric?: components["schemas"]["ClassificationRubric"] | null;
/**