fix(router): fall back from unhealthy auto-router tier
Some checks are pending
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
Terraform Modules / fmt, validate, test (aws) (push) Waiting to run
Terraform Modules / fmt, validate, test (gcp) (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Waiting to run
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Waiting to run

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Tin Chi Lo 2026-09-09 16:12:27 -07:00
parent ef1a37795c
commit 00c7fd8376
9 changed files with 788 additions and 92 deletions

View file

@ -12669,6 +12669,7 @@ class Router:
input: str | list | None = None,
specific_deployment: bool | None = False,
parent_otel_span: Span | None = None,
health_check_probe: bool = False,
) -> list[dict] | dict:
"""
Get the healthy deployments for a model.
@ -12718,6 +12719,7 @@ class Router:
healthy_deployments = await self._async_filter_health_check_unhealthy_deployments(
healthy_deployments=healthy_deployments,
parent_otel_span=parent_otel_span,
health_check_probe=health_check_probe,
)
cooldown_deployments: Final = await _async_get_cooldown_deployments(
@ -14100,6 +14102,7 @@ class Router:
self,
healthy_deployments: list[dict],
parent_otel_span: Span | None = None,
health_check_probe: bool = False,
) -> list[dict]:
"""
Filter out deployments marked unhealthy by background health checks.
@ -14136,8 +14139,7 @@ class Router:
]
if not filtered:
verbose_router_logger.warning("All deployments marked unhealthy by health checks, bypassing health filter")
return healthy_deployments
return [] if health_check_probe else healthy_deployments # mutable-ok: empty list signals unavailable probe
return filtered

View file

@ -461,6 +461,8 @@ class AdaptiveRouter:
if d_alpha == 0 and d_beta == 0:
continue
cell_key = (attribution_type, target_model)
if cell_key not in self._cells:
continue
self._cells[cell_key] = apply_delta(
self._cells[cell_key],
d_alpha,

View file

@ -270,6 +270,18 @@ change or default takeover records `cause: modality_escalation` with the displac
pinned by session affinity, and by default a KEPT session pin bypasses the gate: a session pinned
to a text-only model keeps it even when an image arrives.
Context-window and modality recovery take priority over the default model. If a compatible tier
cannot serve, the router checks the remaining compatible recovery tiers before using `default_model`.
A capacity failure without those constraints tries the selected tier's peers, then the default
The default must fit the context and accept the request's modality. It cannot bypass routing plugins
or a plan-mode floor. Context fit uses the auto-router's existing buffer even when Router-wide pre-call
checks are off. Missing context metadata retains the existing unknown-window behavior
Health fallback records `cause: health_default_fallback` and `health_displaced:<MODEL>` in `signals`.
It does not replace the session's tier pin. Adaptive feedback retains the model that actually served,
but a default outside the adaptive candidate pool does not become a normal candidate
Add `modality_pin_override: true` to lift that last exemption. The image turn is then re-placed
the same way every other decision is, and records `cause: modality_pin_override` whether or not
the tier moved, since the model left the pin either way. The pin itself is untouched: the session

View file

@ -936,6 +936,7 @@ def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bo
"modality_escalation",
"modality_pin_override",
"health_failover",
"health_default_fallback",
)
and not decision.get("context_escalated")
and _CLASSIFIER_CIRCUIT_OPEN_SIGNAL not in (decision.get("signals") or ())
@ -1097,6 +1098,15 @@ def _group_provably_fits(facts: tuple[int | None, bool], needed: int, buffer: fl
return window is not None and not has_unknown and needed <= int(window * buffer)
class _RequestContextFit(NamedTuple):
facts: Mapping[str, tuple[int | None, bool]]
needed: int | None
buffer: float
def accepts(self, model: str) -> bool:
return self.needed is None or _window_can_hold(self.facts.get(model, (None, True))[0], self.needed, self.buffer)
class _ContextWindowPlacement(NamedTuple):
"""Where the context-window gate placed the request: the placement tier, the subset of its
pool the pick may use, and every configured group not provably misfit (the adaptive filter)."""
@ -2672,12 +2682,32 @@ class ComplexityRouter(CustomLogger):
verbose_router_logger.debug("ComplexityRouter: context-window token count failed. Got - %s", e)
return None
async def _request_context_fit(
self,
resolved_messages: Sequence[Mapping[str, object]] | None,
request_kwargs: Mapping[str, object],
) -> _RequestContextFit:
if not self.config.enable_context_window_escalation or not resolved_messages:
return _RequestContextFit(EMPTY_MAPPING, None, self.config.context_window_escalation_buffer)
names: Final = frozenset(model for pool in self._tier_pools().values() for model in pool) | frozenset(
(self.config.default_model,) if self.config.default_model else ()
)
facts: Final = MappingProxyType({name: self._group_window_facts(name) for name in names})
known: Final = tuple(window for window, _ in facts.values() if window is not None)
buffer: Final = self.config.context_window_escalation_buffer
needs_count: Final = known and self._request_byte_upper_bound(resolved_messages, request_kwargs) > int(
min(known) * buffer
)
needed: Final = await self._counted_request_tokens(resolved_messages, request_kwargs) if needs_count else None
return _RequestContextFit(facts=facts, needed=needed, buffer=buffer)
async def _context_window_placement(
self,
tier: ComplexityTier | str,
resolved_messages: Sequence[Mapping[str, object]] | None,
request_kwargs: Mapping[str, object],
pool_override: tuple[str, ...] | None = None,
context_fit: _RequestContextFit | None = None,
) -> _ContextWindowPlacement | None:
"""Correct a decided placement whose models provably cannot hold the prompt, or None
(the placement stands). Only a real tokenizer count ever moves a request, escalation
@ -2689,17 +2719,10 @@ class ComplexityRouter(CustomLogger):
pool: Final = pool_override if pool_override is not None else tuple(pools.get(_tier_name(tier), ()))
if not pool:
return None
facts: Final = MappingProxyType({group: self._group_window_facts(group) for group in pool})
known_windows: Final = tuple(window for window, _ in facts.values() if window is not None)
if not known_windows:
fit: Final = context_fit or await self._request_context_fit(resolved_messages, request_kwargs)
if fit.needed is None:
return None
buffer: Final = self.config.context_window_escalation_buffer
if self._request_byte_upper_bound(resolved_messages, request_kwargs) <= int(min(known_windows) * buffer):
return None
needed: Final = await self._counted_request_tokens(resolved_messages, request_kwargs)
if needed is None:
return None
return self._placement_for_tokens(tier=tier, pool=pool, pools=pools, facts=facts, needed=needed)
return self._placement_for_tokens(tier=tier, pool=pool, pools=pools, facts=fit.facts, needed=fit.needed)
def _placement_for_tokens(
self,
@ -2711,14 +2734,16 @@ class ComplexityRouter(CustomLogger):
needed: int,
) -> _ContextWindowPlacement | None:
buffer: Final = self.config.context_window_escalation_buffer
in_tier: Final = tuple(group for group in pool if _window_can_hold(facts[group][0], needed, buffer))
in_tier: Final = tuple(
group for group in pool if _window_can_hold(facts.get(group, (None, True))[0], needed, buffer)
)
if in_tier and len(in_tier) == len(pool):
return None
holdable: Final = frozenset(
group
for tier_pool in pools.values()
for group in tier_pool
if _window_can_hold(self._group_window_facts(group)[0], needed, buffer)
if _window_can_hold(facts.get(group, (None, True))[0], needed, buffer)
)
if in_tier:
return _ContextWindowPlacement(tier=tier, allowed_models=in_tier, holdable_models=holdable)
@ -2726,7 +2751,7 @@ class ComplexityRouter(CustomLogger):
proven = tuple(
group
for group in pools.get(name, ())
if _group_provably_fits(self._group_window_facts(group), needed, buffer)
if _group_provably_fits(facts.get(group, (None, True)), needed, buffer)
)
if proven:
return _ContextWindowPlacement(
@ -2872,6 +2897,7 @@ class ComplexityRouter(CustomLogger):
messages: list[dict[str, Any]] | None, # mutable-ok: forwarded verbatim to the list-typed re-pick
resolved_messages: Sequence[Mapping[str, object]] | None,
request_kwargs: dict, # mutable-ok: same shape the hook receives
context_fit: _RequestContextFit | None = None,
) -> PreRoutingHookResponse:
"""Replace a routed model that cannot accept this request's image input.
@ -2902,7 +2928,8 @@ class ComplexityRouter(CustomLogger):
or self._model_accepts_image_input(response.model)
):
return response
eligible: Final = self._modality_eligible_models()
fit: Final = context_fit or await self._request_context_fit(resolved_messages, request_kwargs)
eligible: Final = frozenset(name for name in self._modality_eligible_models() if fit.accepts(name))
names: Final = self.config.tier_names()
pools: Final = self._tier_pools()
decided: Final = decision.get("tier") if decision is not None else None
@ -3036,8 +3063,10 @@ class ComplexityRouter(CustomLogger):
messages=messages,
input=input,
parent_otel_span=_get_parent_otel_span_from_kwargs(request_kwargs),
health_check_probe=True,
)
except (RouterRateLimitError, RouterRateLimitErrorBasic, BadRequestError):
except (RouterRateLimitError, RouterRateLimitErrorBasic, BadRequestError) as exc:
verbose_router_logger.debug("health probe unavailable model=%s error=%s", model_name, type(exc).__name__)
return False
except Exception as exc: # noqa: BLE001 # a speculative eligibility read must fail open on unknown faults
verbose_router_logger.debug(
@ -3053,76 +3082,124 @@ class ComplexityRouter(CustomLogger):
input: str | list | None, # mutable-ok: mirrors the owner's own input parameter, which this forwards verbatim
resolved_messages: Sequence[Mapping[str, object]] | None,
request_kwargs: dict, # mutable-ok: same shape the hook receives
context_fit: _RequestContextFit | None = None,
) -> PreRoutingHookResponse:
"""Replace a decided model group that has no serving capacity with a live peer in the same tier.
Applied to the decided response at the hook's exits, so every arm that can place a request
is covered by one owner: a fresh classification, a replayed or escalated session pin, a
plan-mode floor, a context-window escalation, an adaptive pick, and whatever arm is added
next. Peers come from the DECIDED tier only; climbing to another tier is deliberately not
done here, since a higher tier costs more than the classifier asked for.
Serving capacity is one question asked of one owner (`_model_group_can_serve`), so the
substitute is only ever a group the pipeline would actually accept for this request. The
pick then runs through `_pick_model_for_tier`, so routing plugins decide the substitute
exactly as they decided the original.
Fails open everywhere it cannot be sure: an unreadable eligibility view, a decision
carrying no tier (default_model), or a tier whose every peer is unusable too. It fails
CLOSED on a plugin that empties the pool, leaving the original decision to fail rather
than serving a model the plugin excluded.
"""
"""Try compatible tier recovery before the default, preserving request policy and fit."""
decision: Final = response.routing_decision
decided_tier: Final = decision.get("tier") if decision is not None else None
if decision is None or not isinstance(decided_tier, str):
return response
peers: Final = tuple(self._tier_pools().get(decided_tier, ()))
if len(peers) < 2:
return response
if await self._model_group_can_serve(response.model, messages, input, request_kwargs):
fit: Final = context_fit or await self._request_context_fit(resolved_messages, request_kwargs)
if fit.accepts(response.model) and await self._model_group_can_serve(
response.model, messages, input, request_kwargs
):
return response
eligible: Final = (
self._modality_eligible_models()
if self.config.modality_routing and resolved_messages and request_contains_image_content(resolved_messages)
else None
)
candidates: Final = tuple(
peer for peer in peers if peer != response.model and (eligible is None or peer in eligible)
pools: Final = self._tier_pools()
context_recovery: Final = bool(decision.get("context_escalated")) or any(
not fit.accepts(model) for model in pools.get(decided_tier, ())
)
if not candidates:
return response
servable: Final = await asyncio.gather(
*(self._model_group_can_serve(peer, messages, input, request_kwargs) for peer in candidates)
modality_recovery: Final = eligible is not None
names: Final = self.config.tier_names()
tiers: Final = (
tuple(names[names.index(decided_tier) :])
if (context_recovery or modality_recovery) and decided_tier in names
else (decided_tier,)
)
live: Final = tuple(peer for peer, can_serve in zip(candidates, servable) if can_serve)
if not live:
return response
repick_messages: Final = (
list(resolved_messages) if resolved_messages else None # mutable-ok: the pick's param is list-typed
)
try:
new_model: Final = await self._pick_model_for_tier(
decided_tier if self.config.has_custom_tiers else ComplexityTier(decided_tier),
messages,
repick_messages, # pyright: ignore[reportArgumentType] # hook-resolved message dicts; the pick only reads them
request_kwargs,
allowed_models=live,
async def recover_tier(candidate_tier: str) -> PreRoutingHookResponse | None:
peers: Final = tuple(
model
for model in pools.get(candidate_tier, ())
if not context_recovery
or candidate_tier == decided_tier
or fit.needed is None
or _group_provably_fits(fit.facts.get(model, (None, True)), fit.needed, fit.buffer)
)
except ValueError as exc:
verbose_router_logger.debug(
"ComplexityRouter: health failover found no candidate the routing plugins allow: %s", exc
candidates: Final = tuple(
peer
for peer in peers
if peer != response.model and fit.accepts(peer) and (eligible is None or peer in eligible)
)
servable: Final = await asyncio.gather(
*(self._model_group_can_serve(peer, messages, input, request_kwargs) for peer in candidates)
)
live: Final = tuple(peer for peer, can_serve in zip(candidates, servable) if can_serve)
if live:
repick_messages: Final = (
list(resolved_messages) if resolved_messages else None # mutable-ok: the pick's param is list-typed
)
try:
new_model: Final = await self._pick_model_for_tier(
candidate_tier if self.config.has_custom_tiers else ComplexityTier(candidate_tier),
messages,
repick_messages, # pyright: ignore[reportArgumentType] # hook-resolved message dicts; the pick only reads them
request_kwargs,
allowed_models=live,
)
except ValueError as exc:
verbose_router_logger.debug(
"ComplexityRouter: health failover found no candidate the routing plugins allow: %s", exc
)
else:
self._restamp_adaptive_choice(request_kwargs, response.model, new_model)
verbose_router_logger.info(
"ComplexityRouter: routing decision cause=health_failover, routed_model=%s, displaced=%s",
new_model,
response.model,
)
new_decision: Final = self._build_routing_decision(
routed_model=new_model,
cause="health_failover",
tier=candidate_tier,
score=decision.get("score"),
signals=(*(decision.get("signals") or ()), f"health_displaced:{response.model}"),
matched_keyword=decision.get("matched_keyword"),
escalation_keyword=decision.get("escalation_keyword"),
escalated=bool(decision.get("escalated", False)),
classifier_model=decision.get("classifier_model"),
classifier_cost=decision.get("classifier_cost"),
conversation_continuing=bool(decision.get("conversation_continuing", True)),
tier_litellm_params=self._litellm_params_for_model(candidate_tier, new_model),
context_escalation_original_tier=decision.get("context_escalation_original_tier"),
)
return response.model_copy(
update={ # mutable-ok: model_copy types update as a plain dict
"model": new_model,
"litellm_params": self._litellm_params_for_model(candidate_tier, new_model),
"routing_decision": new_decision,
}
)
return None
for candidate_tier in tiers:
if (recovered := await recover_tier(candidate_tier)) is not None:
return recovered
default_model: Final = self.config.default_model
plan_mode_active: Final = self._matched_plan_mode_signal(request_kwargs, resolved_messages) is not None
if (
plan_mode_active
or self.config.plugins
or not default_model
or default_model == response.model
or not fit.accepts(default_model)
or (eligible is not None and default_model not in eligible)
or not await self._model_group_can_serve(default_model, messages, input, request_kwargs)
):
return response
self._restamp_adaptive_choice(request_kwargs, response.model, new_model)
self._restamp_adaptive_choice(request_kwargs, response.model, default_model)
verbose_router_logger.info(
"ComplexityRouter: routing decision cause=health_failover, routed_model=%s, displaced=%s",
new_model,
"ComplexityRouter: routing decision cause=health_default_fallback, routed_model=%s, displaced=%s",
default_model,
response.model,
)
new_decision: Final = self._build_routing_decision(
routed_model=new_model,
cause="health_failover",
tier=decision.get("tier"),
default_decision: Final = self._build_routing_decision(
routed_model=default_model,
cause="health_default_fallback",
score=decision.get("score"),
signals=(*(decision.get("signals") or ()), f"health_displaced:{response.model}"),
matched_keyword=decision.get("matched_keyword"),
@ -3131,14 +3208,14 @@ class ComplexityRouter(CustomLogger):
classifier_model=decision.get("classifier_model"),
classifier_cost=decision.get("classifier_cost"),
conversation_continuing=bool(decision.get("conversation_continuing", True)),
tier_litellm_params=self._litellm_params_for_model(decided_tier, new_model),
tier_litellm_params=self._litellm_params_for_model(None, default_model),
context_escalation_original_tier=decision.get("context_escalation_original_tier"),
)
return response.model_copy(
update={ # mutable-ok: model_copy types update as a plain dict
"model": new_model,
"litellm_params": self._litellm_params_for_model(decided_tier, new_model),
"routing_decision": new_decision,
"model": default_model,
"litellm_params": self._litellm_params_for_model(None, default_model),
"routing_decision": default_decision,
}
)
@ -3453,6 +3530,7 @@ class ComplexityRouter(CustomLogger):
# chat-completions messages, so it is real work on every non-chat surface, and
# both the conversation shape and the classifier read the same list.
resolved_messages: Final = self._resolve_messages(messages, request_kwargs)
context_fit: Final = await self._request_context_fit(resolved_messages, request_kwargs)
marker_pairs: Final = self._reminder_markers_for_request(request_kwargs)
conversation_continuing: Final = _conversation_is_continuing(resolved_messages)
@ -3503,7 +3581,11 @@ class ComplexityRouter(CustomLogger):
pin_source_tier: Final = self._tier_for_model(routed_model)
pin_placement: Final = (
await self._context_window_placement(
pin_source_tier, resolved_messages, request_kwargs, pool_override=(routed_model,)
pin_source_tier,
resolved_messages,
request_kwargs,
pool_override=(routed_model,),
context_fit=context_fit,
)
if pin_source_tier is not None
else None
@ -3573,11 +3655,13 @@ class ComplexityRouter(CustomLogger):
messages,
resolved_messages,
request_kwargs,
context_fit,
),
messages,
input,
resolved_messages,
request_kwargs,
context_fit,
)
)
@ -3589,14 +3673,18 @@ class ComplexityRouter(CustomLogger):
specific_deployment=specific_deployment,
conversation_continuing=conversation_continuing,
resolved_messages=resolved_messages,
context_fit=context_fit,
)
response: Final = (
await self._gate_response_health(
await self._gate_response_modality(routed_response, messages, resolved_messages, request_kwargs),
await self._gate_response_modality(
routed_response, messages, resolved_messages, request_kwargs, context_fit
),
messages,
input,
resolved_messages,
request_kwargs,
context_fit,
)
if routed_response is not None
else None
@ -3631,6 +3719,7 @@ class ComplexityRouter(CustomLogger):
specific_deployment: bool | None = False,
conversation_continuing: bool = True,
resolved_messages: Sequence[Mapping[str, object]] | None = None,
context_fit: _RequestContextFit | None = None,
) -> PreRoutingHookResponse | None:
"""
Classifies the request by complexity and returns the appropriate model.
@ -3802,7 +3891,9 @@ class ComplexityRouter(CustomLogger):
plan_floored: Final = tier != pre_floor_tier
if plan_floored:
signals = (*signals, "plan_mode_floor")
context_placement: Final = await self._context_window_placement(tier, resolved_messages, request_kwargs)
context_placement: Final = await self._context_window_placement(
tier, resolved_messages, request_kwargs, context_fit=context_fit
)
tier, signals, context_original_tier = _apply_context_placement(tier, signals, context_placement)
score_repr: Final = f"{score:.3f}" if score is not None else "n/a"
fallback_model: Final = self.config.default_model if not self.config.plugins else None

View file

@ -2920,6 +2920,7 @@ RoutingDecisionCause = Literal[
# same tier served instead. The displaced group rides in signals. Reported even on a kept
# session pin, since the pinned model did not serve the request.
"health_failover",
"health_default_fallback",
"session_affinity_pin",
"session_affinity_escalation",
# classification_mode 'user_turn': the request is an agent loop's continuation turn (no new

View file

@ -236,6 +236,38 @@ async def test_record_turn_attributes_satisfaction_to_previous_response_model():
assert smart_after.alpha == pytest.approx(smart_before.alpha)
@pytest.mark.asyncio
async def test_external_default_keeps_feedback_history_without_entering_bandit_pool():
r = _make_router()
before = r._cells[(RequestType.GENERAL, "fast")]
await r.record_turn(
session_id="fallback",
model_name="fast",
request_type=RequestType.GENERAL,
turn=Turn(user_content="fix this retry bug", assistant_content="clear the cache"),
)
await r.record_turn(
session_id="fallback",
model_name="external-default",
request_type=RequestType.GENERAL,
turn=Turn(user_content="the fix is still broken", assistant_content="keep cache entries"),
)
assert r._cells[(RequestType.GENERAL, "fast")].beta > before.beta
await r.record_turn(
session_id="fallback",
model_name="smart",
request_type=RequestType.GENERAL,
turn=Turn(
user_content="the fix is still broken",
assistant_content="use the corrected entry",
tool_results=[{"is_error": True, "content": "failure"}],
),
)
assert r._feedback_contexts["fallback"].model_name == "smart"
assert all(model != "external-default" for _, model in r._cells)
assert r.config.available_models == ["fast", "smart"]
@pytest.mark.asyncio
async def test_record_turn_bounds_feedback_contexts_and_evicts_least_recent_session():
r = _make_router()

View file

@ -5,18 +5,19 @@ Tests the rule-based complexity scoring and tier assignment logic.
"""
import asyncio
from collections.abc import AsyncIterator
import json
from copy import deepcopy
from functools import partial
import logging
import sys
import time
from typing import Dict, Final, List
from collections.abc import AsyncIterator, Mapping
from copy import deepcopy
from functools import partial
from typing import Dict, Final, List, Literal
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import httpx
import pytest
import respx
from pydantic import ValidationError
import litellm
@ -2671,7 +2672,8 @@ class TestEncryptedTaskClassifier:
assert call["metadata"]["user_api_key_hash"] == "caller-key-hash"
assert call["proxy_server_request"]["body"]["input"] == call["input"]
assert call["proxy_server_request"]["originating_request_masked"] == {
"input": [task], "metadata": {"authorization": "REDACTED"},
"input": [task],
"metadata": {"authorization": "REDACTED"},
}
assert "source-secret" not in json.dumps(call)
assert "originating_request_masked" not in call["proxy_server_request"]["body"]
@ -3295,19 +3297,23 @@ class TestLLMClassifier:
]
@pytest.mark.asyncio
@pytest.mark.parametrize("source_body", [
{"model": "router", "messages": [{"role": "user", "content": "source-only"}]},
{"model": "router", "system": "source-only", "messages": [{"role": "user", "content": "ask"}]},
{"model": "router", "instructions": "source-only", "input": "ask"},
])
@pytest.mark.parametrize(
"source_body",
[
{"model": "router", "messages": [{"role": "user", "content": "source-only"}]},
{"model": "router", "system": "source-only", "messages": [{"role": "user", "content": "ask"}]},
{"model": "router", "instructions": "source-only", "input": "ask"},
],
)
async def test_classifier_source_is_masked_and_separate_from_provider_input(
self, llm_complexity_router, mock_router_instance, source_body
):
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}'))
outcome = await llm_complexity_router.aclassify(
"classify-this-ask", request_kwargs={"proxy_server_request": {
"body": {**source_body, "metadata": {"authorization": "source-secret"}}
}}
"classify-this-ask",
request_kwargs={
"proxy_server_request": {"body": {**source_body, "metadata": {"authorization": "source-secret"}}}
},
)
assert outcome.cause == "llm_classifier"
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
@ -8011,7 +8017,9 @@ class TestContextAwareClassifier:
),
),
)
def test_only_text_reminder_tails_are_ignored_for_new_asks(self, tail: list[dict[str, object]], expected: bool) -> None:
def test_only_text_reminder_tails_are_ignored_for_new_asks(
self, tail: list[dict[str, object]], expected: bool
) -> None:
from litellm.router_strategy.complexity_router.complexity_router import (
_CODEX_REMINDER_MARKERS,
_newest_turn_is_human_ask,
@ -13095,6 +13103,497 @@ class TestModalityRouting:
assert cache.async_set_cache.await_args.kwargs["value"] == {"model": "text-cheap", "tier": "SIMPLE"}
@pytest.mark.usefixtures("local_model_cost_map")
class TestHealthFallbackDispatch:
@pytest.fixture(autouse=True)
def httpx_transport(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
@staticmethod
def _router(
surface: str = "chat",
*,
peer: bool = False,
session: bool = False,
tagged: bool = False,
config: Mapping[str, object] | None = None,
) -> Router:
provider: Final = "anthropic/claude-sonnet-5" if surface == "messages" else "openai/gpt-5.6"
base_suffix: Final = "" if surface == "messages" else "/v1"
return Router(
model_list=[
{
"model_name": "health-router",
"litellm_params": {
"model": "auto_router/complexity_router",
"complexity_router_default_model": (config or {}).get("default_model", "fallback"),
"complexity_router_config": {
"tiers": {"SIMPLE": ["primary", "peer"] if peer else "primary", "MEDIUM": "primary"},
"session_affinity": session,
"deployment_affinity": False,
"max_tokens_from_tier_model": False,
**(config or {}),
},
},
},
*[
{
"model_name": name,
"litellm_params": {
"model": provider,
"api_key": "test-only",
"api_base": f"https://{name}.test{base_suffix}",
**({"tags": [name]} if tagged else {}),
},
"model_info": {"id": f"{name}-id"},
}
for name in ("primary", "peer", "fallback")
],
],
num_retries=0,
enable_health_check_routing=True,
enable_tag_filtering=tagged,
)
@staticmethod
def _unavailable(router: Router, model_id: str, source: Literal["health", "cooldown"]) -> None:
if source == "health":
router.health_state_cache.set_deployment_health_states(
{model_id: {"is_healthy": False, "timestamp": time.time()}}
)
else:
router.cooldown_cache.add_deployment_to_cooldown(
model_id=model_id,
original_exception=RuntimeError("unavailable"),
exception_status=503,
cooldown_time=60,
)
@staticmethod
def _http_response(request: httpx.Request) -> httpx.Response:
body: Final = json.loads(request.content)
text: Final = request.url.host.split(".")[0]
payload: Final[Mapping[str, object]]
events: Final[tuple[Mapping[str, object], ...]]
if request.url.path.endswith("/responses"):
from litellm.responses.main import mock_responses_api_response
payload = mock_responses_api_response(text).model_dump()
events = (
{"type": "response.created", "response": {**payload, "status": "in_progress"}, "sequence_number": 0},
{
"type": "response.output_text.delta",
"delta": text,
"item_id": "msg_test",
"output_index": 0,
"content_index": 0,
"sequence_number": 1,
},
{"type": "response.completed", "response": payload, "sequence_number": 2},
)
elif request.url.path.endswith("/messages"):
payload = {
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": body["model"],
"content": [{"type": "text", "text": text}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 1},
}
events = (
{"type": "message_start", "message": {**payload, "content": [], "stop_reason": None}},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}},
{"type": "content_block_stop", "index": 0},
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 1}},
{"type": "message_stop"},
)
else:
payload = {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1,
"model": body["model"],
"choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11},
}
events = (
{
**payload,
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}],
},
{
**payload,
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
},
)
if not body.get("stream"):
return httpx.Response(200, json=payload)
wire: Final = "".join(
(f"event: {event['type']}\n" if "type" in event else "") + f"data: {json.dumps(event)}\n\n"
for event in events
)
return httpx.Response(
200,
text=wire + ("data: [DONE]\n\n" if "type" not in events[0] else ""),
headers={"content-type": "text/event-stream"},
)
@staticmethod
async def _request(router: Router, surface: str, stream: bool, metadata: dict[str, object]) -> str:
if surface == "responses":
result = await router.aresponses(
model="health-router", input="Hello!", stream=stream, litellm_metadata=metadata
)
elif surface == "messages":
result = await router.aanthropic_messages(
model="health-router",
messages=[{"role": "user", "content": "Hello!"}],
max_tokens=32,
stream=stream,
litellm_metadata=metadata,
)
else:
result = await router.acompletion(
model="health-router",
messages=[{"role": "user", "content": "Hello!"}],
stream=stream,
metadata=metadata,
)
if not stream:
payload = result if isinstance(result, dict) else result.model_dump()
if surface == "responses":
return payload["output"][0]["content"][0]["text"]
if surface == "messages":
return payload["content"][0]["text"]
return payload["choices"][0]["message"]["content"]
if surface == "messages":
wire: Final = b"".join([chunk async for chunk in result]).decode()
events = tuple(json.loads(line[6:]) for line in wire.splitlines() if line.startswith("data: "))
assert events[-1]["type"] == "message_stop"
return "".join(c["delta"]["text"] for c in events if c["type"] == "content_block_delta")
chunks: Final = [chunk.model_dump() async for chunk in result]
if surface == "responses":
assert chunks[-1]["type"] == "response.completed"
return "".join(c["delta"] for c in chunks if c["type"] == "response.output_text.delta")
assert chunks[-1]["choices"][0]["finish_reason"] == "stop"
return "".join(c["choices"][0]["delta"].get("content") or "" for c in chunks if c["choices"])
@pytest.mark.asyncio
@pytest.mark.parametrize("surface", ["chat", "responses", "messages"])
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize("source", ["health", "cooldown"])
async def test_public_call_falls_back_and_recovers(
self, surface: str, stream: bool, source: Literal["health", "cooldown"]
) -> None:
router: Final = self._router(surface, session=True)
self._unavailable(router, "primary-id", source)
metadata: Final[dict[str, object]] = {"session_id": "outage"}
with respx.mock(assert_all_mocked=True) as upstream:
upstream.post(host__regex=r"^(primary|peer|fallback)\.test$").mock(side_effect=self._http_response)
assert await self._request(router, surface, stream, metadata) == "fallback"
assert metadata["routing_decision"]["cause"] == "health_default_fallback"
assert "tier" not in metadata["routing_decision"]
assert "health_displaced:primary" in metadata["routing_decision"]["signals"]
assert [c.request.url.host for c in upstream.calls] == ["fallback.test"]
strategy: Final = router.complexity_routers["health-router"][0].strategy
key: Final = strategy._get_session_affinity_cache_key("outage", {})
assert await router.cache.async_get_cache(key=key) is None
if source == "health":
router.health_state_cache.set_deployment_health_states(
{"primary-id": {"is_healthy": True, "timestamp": time.time()}}
)
else:
router.cooldown_cache.cooldown_store.delete_cache(
router.cooldown_cache.get_cooldown_cache_key("primary-id")
)
recovered: Final[dict[str, object]] = {"session_id": "outage"}
assert await self._request(router, surface, stream, recovered) == "primary"
assert recovered["routing_decision"]["routed_model"] == "primary"
assert [c.request.url.host for c in upstream.calls] == ["fallback.test", "primary.test"]
@pytest.mark.asyncio
@pytest.mark.parametrize("source", ["health", "cooldown"])
async def test_partial_group_then_peer_then_default(self, source: Literal["health", "cooldown"]) -> None:
router: Final = self._router(peer=True, session=True)
router.add_deployment(
Deployment(
model_name="primary",
litellm_params=LiteLLM_Params(
model="openai/gpt-5.6", api_key="test-only", api_base="https://primary.test/v1"
),
model_info={"id": "primary-sibling-id"},
)
)
strategy: Final = router.complexity_routers["health-router"][0].strategy
key: Final = strategy._get_session_affinity_cache_key("precedence", {})
await router.cache.async_set_cache(key=key, value={"model": "primary", "tier": "SIMPLE"}, ttl=600)
with respx.mock(assert_all_mocked=True) as upstream:
upstream.post(host__regex=r"^(primary|peer|fallback)\.test$").mock(side_effect=self._http_response)
for model_id, expected, cause in (
("primary-id", "primary", "session_affinity_pin"),
("primary-sibling-id", "peer", "health_failover"),
("peer-id", "fallback", "health_default_fallback"),
):
self._unavailable(router, model_id, source)
metadata: Final[dict[str, object]] = {"session_id": "precedence"}
assert await self._request(router, "chat", False, metadata) == expected
assert metadata["routing_decision"]["cause"] == cause
assert await router.cache.async_get_cache(key=key) == {"model": "primary", "tier": "SIMPLE"}
assert [c.request.url.host for c in upstream.calls] == ["primary.test", "peer.test", "fallback.test"]
@pytest.mark.asyncio
async def test_concurrent_tag_scopes_keep_fallbacks_request_local(self) -> None:
router: Final = self._router(tagged=True)
router.add_deployment(
Deployment(
model_name="fallback",
litellm_params=LiteLLM_Params(
model="openai/gpt-5.6", api_key="test-only", api_base="https://peer.test/v1", tags=["peer"]
),
model_info={"id": "fallback-peer-id"},
)
)
self._unavailable(router, "primary-id", "cooldown")
with respx.mock(assert_all_mocked=True) as upstream:
upstream.post(host__regex=r"^(peer|fallback)\.test$").mock(side_effect=self._http_response)
scopes: Final = tuple({"tags": [name], "session_id": name} for name in ("peer", "fallback"))
results: Final = await asyncio.gather(
*(self._request(router, "chat", False, metadata) for metadata in scopes)
)
assert results == ["peer", "fallback"]
assert [m["tags"] for m in scopes] == [["peer"], ["fallback"]]
assert [m["routing_decision"]["routed_model"] for m in scopes] == ["fallback", "fallback"]
assert sorted(c.request.url.host for c in upstream.calls) == ["fallback.test", "peer.test"]
@pytest.mark.asyncio
async def test_probe_preserves_consumed_request_exclusions(self) -> None:
router: Final = self._router()
self._unavailable(router, "primary-id", "cooldown")
kwargs: Final = {"_excluded_deployment_ids": ["fallback-id"], "_target_order": 1}
strategy: Final = router.complexity_routers["health-router"][0].strategy
response: Final = await strategy.async_pre_routing_hook(
model="health-router", messages=[{"role": "user", "content": "Hello!"}], request_kwargs=kwargs
)
assert response.model == "primary"
assert kwargs == {"_excluded_deployment_ids": ["fallback-id"], "_target_order": 1}
@pytest.mark.asyncio
@pytest.mark.parametrize("default_state", ["cooldown", "unconfigured", "same-model"])
async def test_unavailable_default_preserves_no_deployment_error(self, default_state: str) -> None:
from litellm.types.router import RouterRateLimitError
router: Final = self._router(config={"default_model": "primary"} if default_state == "same-model" else None)
self._unavailable(router, "primary-id", "cooldown")
if default_state == "unconfigured":
router.delete_deployment(id="fallback-id")
elif default_state == "cooldown":
self._unavailable(router, "fallback-id", "cooldown")
with respx.mock(assert_all_mocked=True) as upstream:
with pytest.raises(RouterRateLimitError, match="No deployments available"):
await self._request(router, "chat", False, {})
assert not upstream.calls
@pytest.mark.asyncio
@pytest.mark.parametrize("plan_active", [False, True])
async def test_plan_floor_outage_cannot_use_untiered_default(self, plan_active: bool) -> None:
from litellm.types.router import RouterRateLimitError
router: Final = self._router(
config={"tiers": {"SIMPLE": "primary", "MEDIUM": "peer"}, "plan_mode_min_tier": "MEDIUM"}
)
self._unavailable(router, "primary-id", "cooldown")
self._unavailable(router, "peer-id", "cooldown")
metadata: Final = {}
with respx.mock(assert_all_mocked=True, assert_all_called=False) as upstream:
upstream.post(host="fallback.test").mock(side_effect=self._http_response)
if plan_active:
with pytest.raises(RouterRateLimitError, match="No deployments available"):
await router.acompletion(
model="health-router",
messages=[
{"role": "system", "content": "Plan mode is active"},
{"role": "user", "content": "Hello!"},
],
metadata=metadata,
)
assert not upstream.calls
assert metadata["routing_decision"]["routed_model"] == "peer"
assert metadata["routing_decision"]["tier"] == "MEDIUM"
else:
assert await self._request(router, "chat", False, metadata) == "fallback"
@pytest.mark.asyncio
async def test_default_dispatch_drops_displaced_tier_params(self) -> None:
router: Final = self._router(
config={"tiers": {"SIMPLE": {"model_name": "primary", "litellm_params": {"max_tokens": 9}}}}
)
with respx.mock(assert_all_mocked=True) as upstream:
upstream.post(host__regex=r"^(primary|fallback)\.test$").mock(side_effect=self._http_response)
await router.acompletion(
model="health-router", messages=[{"role": "user", "content": "Hello!"}], max_tokens=32
)
assert json.loads(upstream.calls[-1].request.content)["max_completion_tokens"] == 9
self._unavailable(router, "primary-id", "cooldown")
await router.acompletion(
model="health-router", messages=[{"role": "user", "content": "Hello!"}], max_tokens=32
)
assert json.loads(upstream.calls[-1].request.content)["max_completion_tokens"] == 32
assert upstream.calls[-1].request.url.host == "fallback.test"
@pytest.mark.asyncio
@pytest.mark.parametrize("source", ["health", "cooldown"])
async def test_pinned_session_returns_to_primary_after_outage(self, source: Literal["health", "cooldown"]) -> None:
router: Final = self._router(session=True)
with respx.mock(assert_all_mocked=True) as upstream:
upstream.post(host__regex=r"^(primary|fallback)\.test$").mock(side_effect=self._http_response)
assert await self._request(router, "chat", False, {"session_id": "pinned"}) == "primary"
self._unavailable(router, "primary-id", source)
outage: Final[dict[str, object]] = {"session_id": "pinned"}
assert await self._request(router, "chat", False, outage) == "fallback"
assert outage["routing_decision"]["cause"] == "health_default_fallback"
if source == "health":
router.health_state_cache.set_deployment_health_states(
{"primary-id": {"is_healthy": True, "timestamp": time.time()}}
)
else:
router.cooldown_cache.cooldown_store.delete_cache(
router.cooldown_cache.get_cooldown_cache_key("primary-id")
)
recovered: Final[dict[str, object]] = {"session_id": "pinned"}
assert await self._request(router, "chat", False, recovered) == "primary"
assert recovered["routing_decision"]["cause"] == "session_affinity_pin"
assert [c.request.url.host for c in upstream.calls] == ["primary.test", "fallback.test", "primary.test"]
@pytest.mark.asyncio
async def test_policy_plugin_does_not_escape_to_live_default(self) -> None:
from litellm.types.router import RouterRateLimitError, RoutingContext
class PrimaryOnly:
async def run(self, context: RoutingContext) -> RoutingContext:
context.candidate_models = [name for name in context.candidate_models if name == "primary"]
return context
router: Final = self._router(peer=True, config={"plugins": [PrimaryOnly()]})
self._unavailable(router, "primary-id", "cooldown")
with respx.mock(assert_all_mocked=True) as upstream:
with pytest.raises(RouterRateLimitError, match="No deployments available"):
await self._request(router, "chat", False, {})
assert not upstream.calls
@pytest.mark.asyncio
@pytest.mark.parametrize("live_tier", [True, False])
@pytest.mark.parametrize("default_fits", [True, False])
async def test_context_recovery_precedes_default_with_prechecks_off(
self, live_tier: bool, default_fits: bool
) -> None:
from litellm.types.router import RouterRateLimitError
router: Final = self._router(config={"tiers": {"SIMPLE": "primary", "MEDIUM": "peer", "COMPLEX": "large"}})
router.add_deployment(
Deployment(
model_name="large",
litellm_params=LiteLLM_Params(
model="openai/gpt-5.6", api_key="test-only", api_base="https://large.test/v1"
),
model_info={"id": "large-id", "max_input_tokens": 10000},
)
)
for deployment in router.model_list:
deployment["model_info"]["max_input_tokens"] = (
10
if deployment["model_name"] == "primary"
or (deployment["model_name"] == "fallback" and not default_fits)
else 10000
)
self._unavailable(router, "peer-id", "cooldown")
if not live_tier:
self._unavailable(router, "large-id", "cooldown")
assert router.enable_pre_call_checks is False
metadata: Final = {}
messages: Final = [{"role": "user", "content": "hello " * 100}]
with respx.mock(assert_all_mocked=True, assert_all_called=False) as upstream:
upstream.post(host__regex=r"^(large|fallback)\.test$").mock(side_effect=self._http_response)
if not live_tier and not default_fits:
with pytest.raises(RouterRateLimitError, match="No deployments available"):
await router.acompletion(model="health-router", messages=messages, metadata=metadata)
assert not upstream.calls
else:
result: Final = await router.acompletion(model="health-router", messages=messages, metadata=metadata)
expected: Final = "large" if live_tier else "fallback"
assert result.choices[0].message.content == expected
assert upstream.calls[-1].request.url.host == f"{expected}.test"
assert metadata["routing_decision"].get("tier") == ("COMPLEX" if live_tier else None)
@pytest.mark.asyncio
@pytest.mark.parametrize("live_tier", [True, False])
async def test_modality_recovery_precedes_default(self, live_tier: bool) -> None:
router: Final = self._router(
config={"modality_routing": True, "tiers": {"SIMPLE": "primary", "MEDIUM": "peer", "COMPLEX": "vision"}}
)
router.add_deployment(
Deployment(
model_name="vision",
litellm_params=LiteLLM_Params(
model="openai/gpt-5.6", api_key="test-only", api_base="https://vision.test/v1"
),
model_info={"id": "vision-id", "supports_vision": True},
)
)
for deployment in router.model_list:
deployment["model_info"]["supports_vision"] = deployment["model_name"] != "primary"
self._unavailable(router, "peer-id", "cooldown")
if not live_tier:
self._unavailable(router, "vision-id", "cooldown")
with respx.mock(assert_all_mocked=True) as upstream:
upstream.post(host__regex=r"^(vision|fallback)\.test$").mock(side_effect=self._http_response)
result: Final = await router.acompletion(
model="health-router",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Hello!"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,aGk="}},
],
}
],
)
expected: Final = "vision" if live_tier else "fallback"
assert result.choices[0].message.content == expected
assert upstream.calls[-1].request.url.host == f"{expected}.test"
@pytest.mark.asyncio
@pytest.mark.parametrize("default_fits", [True, False])
async def test_modality_default_must_also_fit_context(self, default_fits: bool) -> None:
router: Final = self._router(config={"modality_routing": True, "tiers": {"SIMPLE": "primary"}})
for deployment in router.model_list:
deployment["model_info"]["supports_vision"] = deployment["model_name"] == "fallback"
deployment["model_info"]["max_input_tokens"] = 10000 if default_fits else 10
with respx.mock(assert_all_mocked=True, assert_all_called=False) as upstream:
upstream.post(host="fallback.test").mock(side_effect=self._http_response)
messages: Final = [
{
"role": "user",
"content": [
{"type": "text", "text": "hello " * 100},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,aGk="}},
],
}
]
if default_fits:
result: Final = await router.acompletion(model="health-router", messages=messages)
assert result.choices[0].message.content == "fallback"
else:
with pytest.raises(litellm.BadRequestError, match="modality_routing is enabled"):
await router.acompletion(model="health-router", messages=messages)
assert not upstream.calls
class TestTierHealthFailover:
"""A tier whose decided model group is entirely in cooldown falls back to a live peer."""
@ -13129,7 +13628,7 @@ class TestTierHealthFailover:
probed_prompts = []
async def get_healthy_deployments(
model, request_kwargs, messages=None, input=None, parent_otel_span=None, **kwargs
model, request_kwargs, messages=None, input=None, parent_otel_span=None, health_check_probe=False
):
probed_kwargs.append(request_kwargs)
probed_prompts.append((messages, input))

View file

@ -7381,6 +7381,63 @@ async def test_async_get_fully_unhealthy_model_names_marks_name_when_all_unhealt
assert await router.async_get_fully_unhealthy_model_names() == {"gpt-4o"}
@pytest.mark.asyncio
@pytest.mark.parametrize("health_check_probe", [False, True])
@pytest.mark.parametrize(
"state, health_routing, fails_policy, scoped, strict_ids",
[
("absent", True, False, False, ("dep-0", "dep-1")),
("partial", True, False, False, ("dep-1",)),
("all", True, False, False, ()),
("stale", True, False, False, ("dep-0", "dep-1")),
("all", False, False, False, ("dep-0", "dep-1")),
("all", True, True, False, ("dep-0", "dep-1")),
("all", True, True, True, ()),
],
)
async def test_health_probe_preserves_normal_caller_policy(
health_check_probe: bool,
state: str,
health_routing: bool,
fails_policy: bool,
scoped: bool,
strict_ids: tuple[str, ...],
) -> None:
import time
from litellm.types.router import AllowedFailsPolicy, RouterRateLimitError
router: Final = Router(
model_list=[
{
"model_name": "health-group",
"litellm_params": {"model": "openai/gpt-5.6", "api_key": "test-only"},
"model_info": {"id": model_id},
}
for model_id in ("dep-0", "dep-1")
],
enable_health_check_routing=health_routing,
allowed_fails_policy=AllowedFailsPolicy(ServiceUnavailableErrorAllowedFails=2) if fails_policy else None,
background_health_check_model_groups=["health-group"] if scoped else None,
)
if state != "absent":
_seed_unhealthy_states(
router,
("dep-0",) if state == "partial" else ("dep-0", "dep-1"),
time.time() - router.health_state_cache.staleness_threshold - 10 if state == "stale" else None,
)
expected: Final = strict_ids if strict_ids or health_check_probe else ("dep-0", "dep-1")
if not expected:
with pytest.raises(RouterRateLimitError, match="No deployments available"):
await router.async_get_healthy_deployments(model="health-group", request_kwargs={}, health_check_probe=True)
else:
deployments: Final = await router.async_get_healthy_deployments(
model="health-group", request_kwargs={}, health_check_probe=health_check_probe
)
assert {d["model_info"]["id"] for d in deployments} == set(expected)
assert await router.cooldown_cache.async_get_active_cooldowns(["dep-0", "dep-1"], parent_otel_span=None) == []
@pytest.mark.asyncio
async def test_async_get_fully_unhealthy_model_names_keeps_name_when_partial():
router = _router_with_two_deployments([False, False])

View file

@ -36460,7 +36460,7 @@ export interface components {
* Cause
* @enum {string}
*/
cause?: "heuristic_scorer" | "heuristic_v2" | "reasoning_override" | "llm_classifier" | "heuristic_first_short_circuit" | "hybrid_short_circuit" | "classifier_plugin" | "classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "modality_escalation" | "modality_pin_override" | "health_failover" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
cause?: "heuristic_scorer" | "heuristic_v2" | "reasoning_override" | "llm_classifier" | "heuristic_first_short_circuit" | "hybrid_short_circuit" | "classifier_plugin" | "classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "modality_escalation" | "modality_pin_override" | "health_failover" | "health_default_fallback" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
/** Classifier Cost */
classifier_cost?: number;
/** Classifier Model */