From ddc5d8dc37ad4b3a2c4f14c10c9839b3d94e092c Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 3 Sep 2026 19:10:53 -0700 Subject: [PATCH 1/7] fix(router): bound auto-router classifier latency --- .../complexity_router/complexity_router.py | 27 ++++--- .../router_strategy/test_complexity_router.py | 71 +++++++++++++++++++ 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 17e3d1256d0..9bdcb45a789 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -1694,16 +1694,23 @@ class ComplexityRouter(CustomLogger): } } - response: Final[ModelResponse] = await self.litellm_router_instance.acompletion( - model=llm_config.model, - messages=messages_for_call, - response_format=response_format, - timeout=llm_config.timeout_ms / 1000, - metadata=metadata, - proxy_server_request=proxy_server_request, - turn_off_message_logging=turn_off_message_logging, - **classifier_call_params, - **_parent_session_kwargs(request_kwargs), + classifier_timeout_s: Final[float] = llm_config.timeout_ms / 1000 + response: Final[ModelResponse] = await asyncio.wait_for( + self.litellm_router_instance.acompletion( + model=llm_config.model, + messages=messages_for_call, + stream=False, + response_format=response_format, + timeout=classifier_timeout_s, + num_retries=0, + disable_fallbacks=True, + metadata=metadata, + proxy_server_request=proxy_server_request, + turn_off_message_logging=turn_off_message_logging, + **classifier_call_params, + **_parent_session_kwargs(request_kwargs), + ), + timeout=classifier_timeout_s, ) content: Final = response.choices[0].message.content if not content: diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index da3791da39a..17594f7d444 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -1994,6 +1994,77 @@ class TestLLMClassifier: assert outcome.cause == "llm_classifier" assert outcome.classifier_cost == pytest.approx(1.35e-05) + @pytest.mark.asyncio + async def test_aclassify_timeout_does_not_inherit_router_retries_or_fallbacks( + self, llm_classifier_config + ): + real_router = Router( + model_list=[ + { + "model_name": "haiku-classifier", + "litellm_params": { + "model": "openai/mock-classifier", + "api_key": "mock-key", + "mock_timeout": True, + }, + }, + { + "model_name": "backup-classifier", + "litellm_params": { + "model": "openai/mock-backup-classifier", + "api_key": "mock-key", + "mock_response": '{"tier": "COMPLEX"}', + }, + }, + ], + num_retries=2, + fallbacks=[{"haiku-classifier": ["backup-classifier"]}], + ) + config = { + **llm_classifier_config, + "classifier_llm_config": {"model": "haiku-classifier", "timeout_ms": 10}, + } + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=real_router, + complexity_router_config=config, + ) + + outcome = await router.aclassify("hi") + + assert outcome.cause == "heuristic_scorer" + assert real_router.total_calls["openai/mock-classifier"] == 1 + assert real_router.total_calls["openai/mock-backup-classifier"] == 0 + + @pytest.mark.asyncio + async def test_aclassify_enforces_total_classifier_deadline( + self, mock_router_instance, llm_classifier_config + ): + cancelled = asyncio.Event() + + async def slow_classifier(**_kwargs: object) -> None: + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + cancelled.set() + raise + + mock_router_instance.acompletion = AsyncMock(side_effect=slow_classifier) + config = { + **llm_classifier_config, + "classifier_llm_config": {"model": "haiku-classifier", "timeout_ms": 10}, + } + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config=config, + ) + + outcome = await router.aclassify("hi") + + assert outcome.cause == "heuristic_scorer" + assert cancelled.is_set() + @pytest.mark.asyncio async def test_aclassify_classifier_cost_is_none_when_call_is_unpriced( self, llm_complexity_router, mock_router_instance From d671e0ea5de884a6ef17b7e925ae8d10e408cb8f Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 3 Sep 2026 19:20:47 -0700 Subject: [PATCH 2/7] fix(router): honor explicit retry opt-out --- litellm/router.py | 2 +- .../test_router_per_deployment_num_retries.py | 23 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 6da201725b6..6c7611c6236 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -7513,7 +7513,7 @@ class Router: # Check retry policy FIRST, before should_retry_this_error # This allows retry policies to override the healthy deployments check _retry_policy_applies = False - if self.retry_policy is not None or model_group_retry_policy is not None: + if request_num_retries != 0 and (self.retry_policy is not None or model_group_retry_policy is not None): # get num_retries from retry policy # Use the model_group captured at the start of the function, or get it from metadata # kwargs.get("model") at this point is the deployment model, not the model_group diff --git a/tests/test_litellm/test_router_per_deployment_num_retries.py b/tests/test_litellm/test_router_per_deployment_num_retries.py index 1bf5781c2d0..44f0ca319be 100644 --- a/tests/test_litellm/test_router_per_deployment_num_retries.py +++ b/tests/test_litellm/test_router_per_deployment_num_retries.py @@ -490,7 +490,7 @@ class TestRequestNumRetriesBeatsGlobal: litellm.callbacks = prev_callbacks @staticmethod - def _router(global_num_retries): + def _router(global_num_retries, retry_policy=None): return Router( model_list=[ { @@ -503,6 +503,7 @@ class TestRequestNumRetriesBeatsGlobal: } ], num_retries=global_num_retries, + retry_policy=retry_policy, ) async def _count_attempts(self, *, global_num_retries, request_num_retries): @@ -530,6 +531,26 @@ class TestRequestNumRetriesBeatsGlobal: attempts = await self._count_attempts(global_num_retries=3, request_num_retries=0) assert attempts == 1 + @pytest.mark.asyncio + async def test_request_num_retries_zero_disables_retry_policy(self): + """An explicit zero remains a single attempt when a retry policy matches the error.""" + counter = _AttemptCounter() + litellm.callbacks = [counter] + router = self._router( + global_num_retries=3, + retry_policy=RetryPolicy(InternalServerErrorRetries=2), + ) + + with patch("asyncio.sleep", return_value=None): + with pytest.raises(litellm.InternalServerError): + await router.acompletion( + model="mock", + messages=[{"role": "user", "content": "hi"}], + num_retries=0, + ) + + assert counter.attempts == 1 + @pytest.mark.asyncio async def test_global_num_retries_applies_when_request_omits_it(self): """No request num_retries -> the global still applies: 1 initial + 3 retries = 4.""" From c0a401947a835ce88f7f4ffb91976d58add91c21 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 3 Sep 2026 19:29:54 -0700 Subject: [PATCH 3/7] test(router): cover retry policy opt-out --- .../test_router_per_deployment_num_retries.py | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/test_litellm/test_router_per_deployment_num_retries.py b/tests/test_litellm/test_router_per_deployment_num_retries.py index 44f0ca319be..d75e32a1821 100644 --- a/tests/test_litellm/test_router_per_deployment_num_retries.py +++ b/tests/test_litellm/test_router_per_deployment_num_retries.py @@ -490,7 +490,7 @@ class TestRequestNumRetriesBeatsGlobal: litellm.callbacks = prev_callbacks @staticmethod - def _router(global_num_retries, retry_policy=None): + def _router(global_num_retries): return Router( model_list=[ { @@ -503,7 +503,6 @@ class TestRequestNumRetriesBeatsGlobal: } ], num_retries=global_num_retries, - retry_policy=retry_policy, ) async def _count_attempts(self, *, global_num_retries, request_num_retries): @@ -534,22 +533,32 @@ class TestRequestNumRetriesBeatsGlobal: @pytest.mark.asyncio async def test_request_num_retries_zero_disables_retry_policy(self): """An explicit zero remains a single attempt when a retry policy matches the error.""" - counter = _AttemptCounter() - litellm.callbacks = [counter] - router = self._router( - global_num_retries=3, - retry_policy=RetryPolicy(InternalServerErrorRetries=2), + router = Router( + model_list=[ + { + "model_name": "mock", + "litellm_params": { + "model": "openai/mock-timeout", + "api_key": "sk-fake", + "mock_timeout": True, + }, + } + ], + num_retries=3, + retry_after=0, + retry_policy=RetryPolicy(TimeoutErrorRetries=2), ) with patch("asyncio.sleep", return_value=None): - with pytest.raises(litellm.InternalServerError): + with pytest.raises(litellm.Timeout): await router.acompletion( model="mock", messages=[{"role": "user", "content": "hi"}], + timeout=0.001, num_retries=0, ) - assert counter.attempts == 1 + assert router.total_calls["openai/mock-timeout"] == 1 @pytest.mark.asyncio async def test_global_num_retries_applies_when_request_omits_it(self): From 510424c86c80b24b418ca852304de6a27c5a396d Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 3 Sep 2026 19:59:14 -0700 Subject: [PATCH 4/7] feat(router): add classifier circuit breaker --- .../complexity_router/README.md | 9 ++ .../complexity_router/complexity_router.py | 97 ++++++++++++++++++- .../complexity_router/config.py | 17 ++++ .../router_strategy/test_complexity_router.py | 95 +++++++++++++++++- .../add_model/ClassificationMethodConfig.tsx | 5 + .../ClassifierCircuitBreakerConfig.tsx | 69 +++++++++++++ .../add_model/ComplexityRouterConfig.test.tsx | 32 ++++++ .../add_model/ComplexityRouterConfig.tsx | 2 + .../build_complexity_router_config.test.ts | 15 +++ .../build_complexity_router_config.ts | 19 +++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 12 +++ 11 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/ClassifierCircuitBreakerConfig.tsx diff --git a/litellm/router_strategy/complexity_router/README.md b/litellm/router_strategy/complexity_router/README.md index afa27719064..2d66d28a93e 100644 --- a/litellm/router_strategy/complexity_router/README.md +++ b/litellm/router_strategy/complexity_router/README.md @@ -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. diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 9bdcb45a789..9176b3da02a 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -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,61 @@ 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._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) + + 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 +1050,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 +1540,19 @@ 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 + 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", + ) 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, @@ -1484,6 +1561,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( @@ -1492,6 +1571,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 +1581,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, diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index 70c1b281e31..fa086c57687 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -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=( diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 17594f7d444..d55cabd6806 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -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,7 @@ from litellm.router_strategy.complexity_router.complexity_router import ( DimensionScore, KeywordOverride, _built_in_prompt, + _ClassifierCircuitBreaker, _matched_plan_mode_sentinel, classification_system_prompt, ) @@ -43,6 +43,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 +1719,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 +2039,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 +2076,79 @@ 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) + + assert breaker.allow_request() is True + breaker.record_failure(is_timeout=True) + assert breaker.allow_request() is False + + now = 130.0 + assert breaker.allow_request() is True + assert breaker.allow_request() is False + + breaker.record_success() + assert breaker.allow_request() is True + + def test_failed_classifier_probe_restarts_cooldown(self): + now = 100.0 + breaker = _ClassifierCircuitBreaker(30.0, clock=lambda: now) + breaker.record_failure(is_timeout=True) + + now = 130.0 + assert breaker.allow_request() is True + breaker.record_failure(is_timeout=False) + assert breaker.allow_request() is False + + now = 160.0 + assert breaker.allow_request() is True + + @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) + breaker.record_failure(is_timeout=False) + assert breaker.allow_request() 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 +8611,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 +8624,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, diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index 00ee7bd7d6e..e596c406799 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -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 = ({ How long the classifier call has before it fails and the fallback below takes over. + onChange({ ...value, classifier_llm_config })} + />
Classification Rubric diff --git a/ui/litellm-dashboard/src/components/add_model/ClassifierCircuitBreakerConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassifierCircuitBreakerConfig.tsx new file mode 100644 index 00000000000..40c6efca4af --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/ClassifierCircuitBreakerConfig.tsx @@ -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 = ({ value, onChange }) => { + const [draftCooldown, setDraftCooldown] = React.useState(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 ( +
+
+ onChange({ ...value, circuit_breaker_enabled })} + aria-label="Classifier circuit breaker" + /> + Classifier circuit breaker +
+ + After one classifier timeout, use the fallback immediately for every session until a recovery probe succeeds. + Enabled by default. + + {enabled && ( +
+ + handleCooldownChange(event.target.value)} + onBlur={() => setDraftCooldown(null)} + className="w-full" + /> +
+ )} +
+ ); +}; + +export default ClassifierCircuitBreakerConfig; diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx index bdca5205b2e..588f9e777c5 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -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(); + 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) => { diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 06363830d64..2a024ab7fdf 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts index 9ee555f5dd2..87e82ef3c4b 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts @@ -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, diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts index 956e593a234..32633a809a2 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts @@ -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 }), }, }), diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 3dcfeb64866..c7c324b533c 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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; /** From 5a2845d183d588fa892c7409b2fed077fd568c3d Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 3 Sep 2026 20:10:02 -0700 Subject: [PATCH 5/7] fix(router): preserve classifier breaker state under concurrency --- .../complexity_router/complexity_router.py | 47 ++++++++---- .../router_strategy/test_complexity_router.py | 75 +++++++++++++++---- 2 files changed, 94 insertions(+), 28 deletions(-) diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 9176b3da02a..d7bbf85d4fa 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -836,31 +836,45 @@ class _ClassifierCircuitBreaker: self._clock = clock self._state = self.CLOSED self._opened_at: float | None = None + self._generation = 0 self._lock = Lock() - def allow_request(self) -> bool: - """Allow ordinary calls while closed and exactly one probe after cooldown.""" + 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 True + return self._generation if self._state == self.HALF_OPEN: - return False + 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 True - return False + return self._generation + return None - def record_success(self) -> 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, *, is_timeout: bool) -> 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 not is_timeout and self._state != self.HALF_OPEN: + 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() @@ -1541,7 +1555,8 @@ class ComplexityRouter(CustomLogger): has. It is handed to the failure path so a classifier error does not re-run the scorer. """ breaker: Final = self._classifier_circuit_breaker - if breaker is not None and not breaker.allow_request(): + 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, @@ -1551,8 +1566,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() + if breaker is not None and permit is not None: + breaker.record_success(permit) return ClassificationOutcome( tier=tier, score=None, @@ -1560,9 +1575,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: - breaker.record_failure(is_timeout=_is_classifier_timeout(e)) + 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( diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index d55cabd6806..137fb128a57 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -2100,29 +2100,74 @@ class TestLLMClassifier: now = 100.0 breaker = _ClassifierCircuitBreaker(30.0, clock=lambda: now) - assert breaker.allow_request() is True - breaker.record_failure(is_timeout=True) - assert breaker.allow_request() is False + 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 - assert breaker.allow_request() is True - assert breaker.allow_request() is False + probe_permit = breaker.acquire_permit() + assert probe_permit is not None + assert breaker.acquire_permit() is None - breaker.record_success() - assert breaker.allow_request() is True + 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) - breaker.record_failure(is_timeout=True) + initial_permit = breaker.acquire_permit() + assert initial_permit is not None + breaker.record_failure(initial_permit, is_timeout=True) now = 130.0 - assert breaker.allow_request() is True - breaker.record_failure(is_timeout=False) - assert breaker.allow_request() is False + 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.allow_request() is True + 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): @@ -2146,8 +2191,10 @@ class TestLLMClassifier: def test_non_timeout_failure_does_not_open_closed_classifier_circuit(self): breaker = _ClassifierCircuitBreaker(30.0) - breaker.record_failure(is_timeout=False) - assert breaker.allow_request() is True + permit = breaker.acquire_permit() + assert permit is not None + breaker.record_failure(permit, is_timeout=False) + assert breaker.acquire_permit() is not None @pytest.mark.asyncio async def test_aclassify_classifier_cost_is_none_when_call_is_unpriced( From 81dd911bdc20db3a5a8a3d60837ffcc56287ed44 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 3 Sep 2026 20:19:13 -0700 Subject: [PATCH 6/7] fix(router): recognize asyncio classifier timeouts --- .../router_strategy/complexity_router/complexity_router.py | 4 +++- tests/test_litellm/router_strategy/test_complexity_router.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index d7bbf85d4fa..2c1097b7af3 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -880,7 +880,9 @@ class _ClassifierCircuitBreaker: def _is_classifier_timeout(exc: BaseException) -> bool: - if isinstance(exc, TimeoutError): + # 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 diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 137fb128a57..130a6f5a488 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -26,6 +26,7 @@ from litellm.router_strategy.complexity_router.complexity_router import ( KeywordOverride, _built_in_prompt, _ClassifierCircuitBreaker, + _is_classifier_timeout, _matched_plan_mode_sentinel, classification_system_prompt, ) @@ -2196,6 +2197,9 @@ class TestLLMClassifier: 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 From 6234399f9e7b0e28eec0edb7269c5537f249f3c8 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 13:16:34 -0700 Subject: [PATCH 7/7] fix(router): keep circuit-open fallbacks out of session pins An open classifier circuit routed through the ordinary heuristic or classifier_fallback path, and both causes are pin-worthy, so a session whose turn landed on the cooldown fallback held that model for the whole session_affinity TTL and never reclassified after the breaker closed. The circuit-open signal now blocks the pin, and _classifier_failure_outcome tags its outcomes through one helper instead of reassigning a Final. --- .../complexity_router/complexity_router.py | 41 +++++++++++-------- .../router_strategy/test_complexity_router.py | 39 ++++++++++++++++++ 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 2c1097b7af3..7dbb2ddc544 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -313,6 +313,8 @@ _TRUNCATION_MARKER: Final = "..." _TRUNCATION_HEAD_FRACTION: Final = 0.3 _MIN_QUOTED_TURN_CHARS: Final = 120 +_CLASSIFIER_CIRCUIT_OPEN_SIGNAL: Final = "classifier-circuit-open" + _CJK_CHARACTER: Final = re.compile("[぀-ヿㇰ-ㇿ㐀-䶿一-鿿豈-﫿ヲ-ン\U00020000-\U0003ffff]") @@ -757,6 +759,12 @@ def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bo image), not what the session's traffic looks like, and pinning it would hold every following text turn on the vision-capable model the image forced. A modality pin override is the same fact on a session that already holds a pin, so it must not overwrite the pin it displaced. + + An open classifier circuit is the shortest-lived state of all: the fallback ran because the + breaker skipped the classifier, not because the request got classified, and the cooldown is + seconds against a TTL of an hour that every later turn refreshes. Its cause is whatever the + fallback path reports, so the circuit signal is what marks the decision, and leaving it + unpinned lets the session classify again as soon as the breaker closes. """ return decision is None or ( decision.get("cause") @@ -768,6 +776,7 @@ def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bo "modality_pin_override", ) and not decision.get("context_escalated") + and _CLASSIFIER_CIRCUIT_OPEN_SIGNAL not in (decision.get("signals") or ()) ) @@ -818,6 +827,10 @@ class ClassificationOutcome(NamedTuple): classifier_cost: float | None = None +def _with_signal(outcome: ClassificationOutcome, signal: str | None) -> ClassificationOutcome: + return outcome if signal is None else outcome._replace(signals=(*outcome.signals, signal)) + + class _ClassifierCircuitBreaker: """Process-local timeout breaker for one complexity-router classifier. @@ -1564,7 +1577,7 @@ class ComplexityRouter(CustomLogger): prompt, system_prompt, scored, - signal="classifier-circuit-open", + signal=_CLASSIFIER_CIRCUIT_OPEN_SIGNAL, ) try: tier, classifier_cost = await self._classify_with_llm(prompt, system_prompt, request_kwargs, messages) @@ -1602,28 +1615,24 @@ 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) - outcome: Final = ClassificationOutcome( - tier=fallback_tier, - score=None, - signals=(f"classifier-fallback:{fallback_tier}",), - cause="classifier_fallback", + return _with_signal( + ClassificationOutcome( + tier=fallback_tier, + score=None, + signals=(f"classifier-fallback:{fallback_tier}",), + cause="classifier_fallback", + ), + signal, ) - 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": - outcome = self._default_model_fallback_outcome() - return outcome if signal is None else outcome._replace(signals=(*outcome.signals, signal)) + return _with_signal(self._default_model_fallback_outcome(), signal) if scored is not None: - return scored if signal is None else scored._replace(signals=(*scored.signals, signal)) + return _with_signal(scored, signal) tier, score, signals, cause = self._score_and_classify(prompt, system_prompt) - return ClassificationOutcome( - tier=tier, - score=score, - signals=signals if signal is None else (*signals, signal), - cause=cause, - ) + return _with_signal(ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause), signal) async def _classify_with_plugin( self, diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 130a6f5a488..b4c48b53376 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -4750,6 +4750,45 @@ class TestSessionAffinity: # Pinned to the first turn's model, not re-classified down to SIMPLE. assert second.model == "o1-preview" + @pytest.mark.asyncio + async def test_circuit_open_fallback_does_not_pin_the_session(self, mock_router_instance, session_affinity_config): + """Regression: the classifier circuit cools down in seconds while a pin lasts for the whole + TTL, so a session whose only turn landed on the cooldown fallback must classify again once + the breaker closes instead of holding that fallback's model.""" + now = 100.0 + mock_router_instance.cache = DualCache() + mock_router_instance.acompletion = AsyncMock( + side_effect=[TimeoutError("classifier timed out"), _llm_response('{"tier": "REASONING"}')] + ) + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + **session_affinity_config, + "classifier_type": "llm", + "classifier_llm_config": {"model": "haiku-classifier", "timeout_ms": 400}, + }, + ) + router._classifier_circuit_breaker = _ClassifierCircuitBreaker(30.0, clock=lambda: now) + + await router.async_pre_routing_hook( + model="test-model", + request_kwargs=self._request_kwargs("outage-session"), + messages=self.SIMPLE_MESSAGE, + ) + cooled_down_kwargs = self._request_kwargs("cooldown-session") + during_cooldown = await router.async_pre_routing_hook( + model="test-model", request_kwargs=cooled_down_kwargs, messages=self.SIMPLE_MESSAGE + ) + now = 130.0 + after_cooldown = await router.async_pre_routing_hook( + model="test-model", request_kwargs=cooled_down_kwargs, messages=self.SIMPLE_MESSAGE + ) + + assert during_cooldown.model == "gpt-4o-mini" + assert after_cooldown.model == "o1-preview" + assert mock_router_instance.acompletion.await_count == 2 + @pytest.mark.asyncio async def test_a_pinned_turn_reports_the_tier_that_serves_it(self, mock_router_instance, session_affinity_config): mock_router_instance.cache = DualCache()