mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
feat(router): add classifier circuit breaker
Prerequisite for #41615 on stable/1.100.x.
Cherry-picked from 510424c86c (main).
This commit is contained in:
parent
44b7b48e46
commit
d400ee2b7e
11 changed files with 374 additions and 8 deletions
|
|
@ -1,5 +1,14 @@
|
|||
# Complexity Router
|
||||
|
||||
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 rule-based routing strategy that classifies requests by complexity and routes them to appropriate models - with zero API calls and sub-millisecond latency.
|
||||
|
||||
## Overview
|
||||
|
|
|
|||
|
|
@ -18,11 +18,68 @@ 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
|
||||
|
||||
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._lock = Lock()
|
||||
|
||||
def allow_request(self) -> bool:
|
||||
"""Allow ordinary calls while closed and exactly one probe after cooldown."""
|
||||
with self._lock:
|
||||
if self._state == self.CLOSED:
|
||||
return True
|
||||
if self._state == self.HALF_OPEN:
|
||||
return False
|
||||
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 True
|
||||
return False
|
||||
|
||||
def record_success(self) -> None:
|
||||
with self._lock:
|
||||
self._state = self.CLOSED
|
||||
self._opened_at = None
|
||||
|
||||
def record_failure(self, *, is_timeout: bool) -> None:
|
||||
"""Open on a normal timeout, or reopen when the single recovery probe fails."""
|
||||
with self._lock:
|
||||
if not is_timeout and self._state != self.HALF_OPEN:
|
||||
return
|
||||
self._state = self.OPEN
|
||||
self._opened_at = self._clock()
|
||||
|
||||
|
||||
def _is_classifier_timeout(exc: BaseException) -> bool:
|
||||
if isinstance(exc, TimeoutError):
|
||||
return True
|
||||
from litellm.exceptions import Timeout as LiteLLMTimeout
|
||||
|
||||
return isinstance(exc, LiteLLMTimeout)
|
||||
|
||||
|
||||
from pydantic import BaseModel, create_model
|
||||
|
||||
from litellm._logging import verbose_router_logger
|
||||
|
|
@ -903,6 +960,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
|
||||
)
|
||||
|
||||
verbose_router_logger.debug("ComplexityRouter initialized for %s with tiers: %s", model_name, self.config.tiers)
|
||||
|
||||
|
|
@ -935,6 +1001,15 @@ class ComplexityRouter(CustomLogger):
|
|||
order, so every defined tier's models are candidates and resolve_baseline's
|
||||
cost ranking picks the counterfactual from the whole set.
|
||||
"""
|
||||
breaker: Final = self._classifier_circuit_breaker
|
||||
if breaker is not None and not breaker.allow_request():
|
||||
return self._classifier_failure_outcome(
|
||||
"LLM classifier circuit is open",
|
||||
prompt,
|
||||
system_prompt,
|
||||
scored,
|
||||
signal="classifier-circuit-open",
|
||||
)
|
||||
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):
|
||||
|
|
@ -1320,6 +1395,8 @@ class ComplexityRouter(CustomLogger):
|
|||
"""
|
||||
try:
|
||||
tier, classifier_cost = await self._classify_with_llm(prompt, system_prompt, request_kwargs, messages)
|
||||
if breaker is not None:
|
||||
breaker.record_success()
|
||||
return ClassificationOutcome(
|
||||
tier=tier,
|
||||
score=None,
|
||||
|
|
@ -1328,6 +1405,8 @@ class ComplexityRouter(CustomLogger):
|
|||
classifier_cost=classifier_cost,
|
||||
)
|
||||
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:
|
||||
breaker.record_failure(is_timeout=_is_classifier_timeout(e))
|
||||
return self._classifier_failure_outcome(f"LLM classifier failed ({e})", prompt, system_prompt, scored)
|
||||
|
||||
def _classifier_failure_outcome(
|
||||
|
|
@ -1336,6 +1415,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.
|
||||
|
|
@ -1345,21 +1425,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,
|
||||
|
|
|
|||
|
|
@ -434,6 +434,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=(
|
||||
|
|
|
|||
|
|
@ -26,9 +26,12 @@ 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,
|
||||
)
|
||||
from litellm.router_utils.auto_router_model_naming import count_heuristic_v2_routers
|
||||
from litellm.router_strategy.complexity_router.config import (
|
||||
DEFAULT_CLASSIFICATION_RUBRIC,
|
||||
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE,
|
||||
|
|
@ -46,6 +49,7 @@ from litellm.types.router import (
|
|||
)
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_router_instance():
|
||||
"""Create a mock LiteLLM Router instance."""
|
||||
|
|
@ -1534,6 +1538,13 @@ def llm_complexity_router(mock_router_instance, llm_classifier_config):
|
|||
class TestLLMClassifierConfig:
|
||||
"""Test config validation for the LLM classifier option."""
|
||||
|
||||
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)
|
||||
|
||||
def test_llm_classifier_type_requires_config(self):
|
||||
"""classifier_type='llm' without classifier_llm_config must raise."""
|
||||
with pytest.raises(ValidationError):
|
||||
|
|
@ -1757,6 +1768,7 @@ class TestLLMClassifier:
|
|||
complexity_router_config=llm_classifier_config,
|
||||
)
|
||||
outcome = await router.aclassify("hi")
|
||||
next_outcome = await router.aclassify("hi again")
|
||||
assert outcome.cause == "llm_classifier"
|
||||
assert outcome.classifier_cost == pytest.approx(1.35e-05)
|
||||
|
||||
|
|
@ -2093,6 +2105,8 @@ class TestLLMClassifier:
|
|||
outcome = await router.aclassify("Hello!")
|
||||
|
||||
assert outcome.cause == "heuristic_scorer"
|
||||
assert next_outcome.cause == "heuristic_scorer"
|
||||
assert "classifier-circuit-open" in next_outcome.signals
|
||||
assert outcome.tier == ComplexityTier.SIMPLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -9762,3 +9776,84 @@ class TestHeuristicFirst:
|
|||
)
|
||||
outcome = await router.aclassify(NO_SIGNAL_PROMPT)
|
||||
assert outcome.cause == "default_model_fallback"
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@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
|
||||
|
|
@ -13,6 +13,7 @@ import ClassifierPromptEditor from "./ClassifierPromptEditor";
|
|||
import CustomTierPromptEditor from "./CustomTierPromptEditor";
|
||||
import { RestrictedSection, restrictedBy } from "./TierRestrictions";
|
||||
import HeuristicScoringConfig from "./HeuristicScoringConfig";
|
||||
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
|
||||
import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults";
|
||||
import {
|
||||
ClassifierFallback,
|
||||
|
|
@ -410,6 +411,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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -132,10 +132,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,
|
||||
|
|
@ -244,6 +265,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) => {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,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;
|
||||
classification_rubric?: ClassificationRubric;
|
||||
system_prompt?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,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,
|
||||
|
|
|
|||
|
|
@ -53,12 +53,26 @@ import {
|
|||
export const normalizeClassifierLlmConfig = ({
|
||||
model,
|
||||
timeout_ms,
|
||||
circuit_breaker_enabled,
|
||||
circuit_breaker_cooldown_seconds,
|
||||
classification_rubric,
|
||||
system_prompt,
|
||||
}: ClassifierLLMConfig): ClassifierLLMConfig =>
|
||||
system_prompt?.trim()
|
||||
? { model, timeout_ms, system_prompt }
|
||||
: { model, timeout_ms, ...(classification_rubric && { classification_rubric }) };
|
||||
? {
|
||||
model,
|
||||
timeout_ms,
|
||||
...(circuit_breaker_enabled !== undefined && { circuit_breaker_enabled }),
|
||||
...(circuit_breaker_cooldown_seconds !== undefined && { circuit_breaker_cooldown_seconds }),
|
||||
system_prompt,
|
||||
}
|
||||
: {
|
||||
model,
|
||||
timeout_ms,
|
||||
...(circuit_breaker_enabled !== undefined && { circuit_breaker_enabled }),
|
||||
...(circuit_breaker_cooldown_seconds !== undefined && { circuit_breaker_cooldown_seconds }),
|
||||
...(classification_rubric && { classification_rubric }),
|
||||
};
|
||||
|
||||
interface ScorerKnobInputs {
|
||||
classifierType: ClassifierType;
|
||||
|
|
@ -285,7 +299,16 @@ export const customTierWireFields = (
|
|||
// classification_rubric beside tier_definitions, and both live inside this object rather than at
|
||||
// the top level the omit list covers. The opening instructions ride classification_prompt below.
|
||||
...(classifierLlmConfig && {
|
||||
classifier_llm_config: { model: classifierLlmConfig.model, timeout_ms: classifierLlmConfig.timeout_ms },
|
||||
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,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
session_affinity: false,
|
||||
...(classificationPrompt?.trim() && { classification_prompt: classificationPrompt.trim() }),
|
||||
|
|
|
|||
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -24902,6 +24902,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;
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue