fix(complexity_router): fall back to a live peer when the decided tier model is fully cooled down (#39675)

A complexity tier can name several model groups, but the pool pick and the session-pin
replay both returned a group without consulting deployment health, so a group whose every
deployment was in cooldown was still routed to and the request died at the router's
zero-deployment check while a healthy peer sat in the same tier.

Gate the decided response at the pre-routing hook's exits, the seam the modality gate
already occupies, so every arm that can place a request is covered by one owner: a fresh
classification, a replayed or escalated 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 a higher tier costs more than the
classifier asked for and is left to a follow-up. The gate fails open on every uncertainty:
an unreadable cooldown view, a decision carrying no tier, a group the router knows no
deployments for, or a tier whose peers are all cooling.
This commit is contained in:
tin-berri 2026-09-04 15:11:50 -07:00 committed by GitHub
parent df68edca76
commit 6dff3a5f72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 762 additions and 18 deletions

View file

@ -35,7 +35,10 @@ from litellm.constants import (
SESSION_ID_GENERATED_METADATA_KEY,
)
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs
from litellm.litellm_core_utils.core_helpers import (
_get_parent_otel_span_from_kwargs,
get_metadata_variable_name_from_kwargs,
)
from litellm.litellm_core_utils.internal_call_metadata import forwarded_internal_call_metadata
from litellm.litellm_core_utils.prompt_templates.common_utils import request_contains_image_content
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
@ -765,6 +768,11 @@ def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bo
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.
A health failover describes the fleet's state right now, not the session's traffic, and it can
displace decisions that were themselves unpinnable (a housekeeping call, a modality escalation).
Pinning it would hold the session on the substitute long after the displaced group recovers; the
gate re-fires per request, so leaving it unpinned costs nothing but the classifier call.
"""
return decision is None or (
decision.get("cause")
@ -774,6 +782,7 @@ def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bo
"housekeeping",
"modality_escalation",
"modality_pin_override",
"health_failover",
)
and not decision.get("context_escalated")
and _CLASSIFIER_CIRCUIT_OPEN_SIGNAL not in (decision.get("signals") or ())
@ -2650,6 +2659,150 @@ class ComplexityRouter(CustomLogger):
and self._matched_plan_mode_signal(request_kwargs, resolved_messages) is None
)
async def _model_group_can_serve(
self,
model_name: str,
messages: list[dict[str, Any]] | None, # mutable-ok: forwarded verbatim to the router's own probe
input: str | list | None, # mutable-ok: mirrors the owner's own input parameter, which this forwards verbatim
request_kwargs: dict, # mutable-ok: same shape the hook receives
) -> bool:
"""Whether the router would find a deployment for this group ON THIS REQUEST.
Asks the same owner the routing path itself will ask, with the same prompt arguments it
will pass, so every filter that decides a deployment's eligibility applies here exactly
as it applies downstream: cooldowns, admin pause, team scoping, model access groups, tag
routing, routing plugins, RPM limits, and the context-window pre-call check. Re-deriving
any subset of that list is how a substitute gets chosen that the pipeline then rejects,
and dropping `input` would silently skip the window check on the Responses API surface,
where the prompt never arrives as messages.
Probed on a COPY of request_kwargs because the owner pops routing bookkeeping off the
dict it is handed (`_target_order`, `_excluded_deployment_ids`), and this is a
speculative question about a model that may never be picked.
Every way the owner says "nothing here can serve this" is a negative verdict: no healthy
deployment for the group at all (BadRequestError, which ContextWindowExceededError
subclasses), every deployment filtered out (RouterRateLimitError), and every deployment
over its RPM (RouterRateLimitErrorBasic). Anything else is unknown rather than negative,
so it reads as capacity: absent information must never decide the verdict.
"""
from litellm.exceptions import BadRequestError
from litellm.types.router import RouterRateLimitError, RouterRateLimitErrorBasic
probe_kwargs: Final = dict(request_kwargs) # mutable-ok: the owner pops routing keys off the dict it is handed
try:
deployments: Final = await self.litellm_router_instance.async_get_healthy_deployments(
model=model_name,
request_kwargs=probe_kwargs,
messages=messages,
input=input,
parent_otel_span=_get_parent_otel_span_from_kwargs(request_kwargs),
)
except (RouterRateLimitError, RouterRateLimitErrorBasic, BadRequestError):
return False
except Exception as exc: # noqa: BLE001 # a speculative eligibility read must fail open on unknown faults
verbose_router_logger.debug(
"ComplexityRouter: eligibility probe for %s failed, treating the group as live: %s", model_name, exc
)
return True
return bool(deployments)
async def _gate_response_health(
self,
response: PreRoutingHookResponse,
messages: list[dict[str, Any]] | None, # mutable-ok: forwarded verbatim to the list-typed re-pick
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
) -> 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.
"""
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):
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)
)
if not candidates:
return response
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 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,
)
except ValueError as exc:
verbose_router_logger.debug(
"ComplexityRouter: health failover found no candidate the routing plugins allow: %s", exc
)
return response
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=decision.get("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(decided_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(decided_tier, new_model),
"routing_decision": new_decision,
}
)
def _placed_default_model(self) -> str:
"""The default_model behind a usable-default verdict; the raise is the type-level
proof, not a reachable path."""
@ -3047,24 +3200,30 @@ class ComplexityRouter(CustomLogger):
session_tier_litellm_params: Final = self._litellm_params_for_model(routed_pin_tier, routed_model)
has_original_messages: Final = messages is not None and len(messages) > 0
return self._with_session_deployment_affinity(
await self._gate_response_modality(
PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
litellm_params=session_tier_litellm_params,
routing_decision=self._build_routing_decision(
routed_model=routed_model,
cause=cause,
tier=routed_pin_tier,
matched_keyword=pin_plan_sentinel if plan_floored else None,
escalation_keyword=pin_escalation_keyword,
escalated=escalated,
conversation_continuing=conversation_continuing,
tier_litellm_params=session_tier_litellm_params,
context_escalation_original_tier=pin_context_original_tier,
await self._gate_response_health(
await self._gate_response_modality(
PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
litellm_params=session_tier_litellm_params,
routing_decision=self._build_routing_decision(
routed_model=routed_model,
cause=cause,
tier=routed_pin_tier,
matched_keyword=pin_plan_sentinel if plan_floored else None,
escalation_keyword=pin_escalation_keyword,
escalated=escalated,
conversation_continuing=conversation_continuing,
tier_litellm_params=session_tier_litellm_params,
context_escalation_original_tier=pin_context_original_tier,
),
),
messages,
resolved_messages,
request_kwargs,
),
messages,
input,
resolved_messages,
request_kwargs,
)
@ -3080,7 +3239,13 @@ class ComplexityRouter(CustomLogger):
resolved_messages=resolved_messages,
)
response: Final = (
await self._gate_response_modality(routed_response, messages, resolved_messages, request_kwargs)
await self._gate_response_health(
await self._gate_response_modality(routed_response, messages, resolved_messages, request_kwargs),
messages,
input,
resolved_messages,
request_kwargs,
)
if routed_response is not None
else None
)

View file

@ -2886,6 +2886,10 @@ RoutingDecisionCause = Literal[
# carries an image the pinned model cannot accept. The stored pin is untouched, so the next
# text turn replays it. Distinct from "modality_escalation", which never displaces a pin.
"modality_pin_override",
# Every deployment behind the decided model group was in cooldown, so a healthy peer in the
# 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",
"session_affinity_pin",
"session_affinity_escalation",
# classification_mode 'user_turn': the request is an agent loop's continuation turn (no new

View file

@ -11571,3 +11571,578 @@ class TestModalityRouting:
model="m", request_kwargs={"metadata": {"session_id": "s1"}}, messages=self.IMAGE_MESSAGE
)
assert cache.async_set_cache.await_args.kwargs["value"] == {"model": "text-cheap", "tier": "SIMPLE"}
class TestTierHealthFailover:
"""A tier whose decided model group is entirely in cooldown falls back to a live peer."""
SIMPLE_MESSAGE = [{"role": "user", "content": "Hello!"}]
TIERS = {"SIMPLE": ["dead-a", "live-b"], "MEDIUM": "mid", "COMPLEX": "big", "REASONING": "top"}
@staticmethod
def _router(
mock_router_instance,
config,
ids_by_model,
cooling=(),
blocked=(),
excluded=(),
raises_for=None,
health_error=None,
):
"""ids_by_model: model group -> deployment ids the router knows.
The fake mirrors the real async_get_healthy_deployments contract, including how it says
no: BadRequestError for a group with no deployment at all, RouterRateLimitError when every
deployment is filtered out (cooling, admin-paused, or excluded by a request-scoped policy
such as tags, team scoping or access groups), a per-model exception via raises_for (the
RPM verdict), and an unrelated failure via health_error. It records what it was handed so
tests can prove the probe passes a kwargs copy and forwards the prompt arguments.
"""
import litellm as litellm_module
from litellm.types.router import RouterRateLimitError
probed_kwargs = []
probed_prompts = []
async def get_healthy_deployments(
model, request_kwargs, messages=None, input=None, parent_otel_span=None, **kwargs
):
probed_kwargs.append(request_kwargs)
probed_prompts.append((messages, input))
if health_error is not None:
raise health_error
if raises_for and model in raises_for:
raise raises_for[model]
if not ids_by_model.get(model):
raise litellm_module.BadRequestError(
message=f"You passed in model={model}. There are no healthy deployments.",
model=model,
llm_provider="",
)
filtered = (*cooling, *blocked, *excluded)
healthy = [
{"model_name": model, "model_info": {"id": i}} for i in ids_by_model[model] if i not in filtered
]
if not healthy:
raise RouterRateLimitError(
model=model, cooldown_time=60.0, enable_pre_call_checks=False, cooldown_list=[]
)
return healthy
mock_router_instance.async_get_healthy_deployments = get_healthy_deployments
mock_router_instance.probed_kwargs = probed_kwargs
mock_router_instance.probed_prompts = probed_prompts
mock_router_instance.cache = DualCache()
return ComplexityRouter(
model_name="health-test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
async def _pinned_hook(self, router, session_id="sess-1", messages=None):
"""Drive the hook twice so the second call replays a pin, which makes the decided
model deterministic instead of a coin flip over the tier pool."""
kwargs = {"metadata": {"session_id": session_id}}
await router.async_pre_routing_hook(model="m", request_kwargs=kwargs, messages=messages or self.SIMPLE_MESSAGE)
return await router.async_pre_routing_hook(
model="m", request_kwargs=kwargs, messages=messages or self.SIMPLE_MESSAGE
)
@pytest.mark.asyncio
async def test_dead_pinned_group_fails_over_to_live_peer_and_reports_the_displacement(self, mock_router_instance):
"""The core regression: a session pinned to a group whose every deployment is cooling
serves from the live peer, and the row says so rather than naming the pinned model."""
router = self._router(
mock_router_instance,
{"tiers": dict(self.TIERS), "session_affinity": True},
{"dead-a": ["id-a1", "id-a2"], "live-b": ["id-b1"]},
cooling=("id-a1", "id-a2"),
)
# Seed the pin onto the dead group directly so the replay path is exercised.
key = router._get_session_affinity_cache_key("sess-dead", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": "dead-a", "tier": "SIMPLE"}, ttl=600
)
result = await router.async_pre_routing_hook(
model="m", request_kwargs={"metadata": {"session_id": "sess-dead"}}, messages=self.SIMPLE_MESSAGE
)
assert result.model == "live-b"
assert result.routing_decision["cause"] == "health_failover"
assert "health_displaced:dead-a" in result.routing_decision["signals"]
assert result.routing_decision["tier"] == "SIMPLE"
@pytest.mark.asyncio
async def test_fresh_classification_never_serves_a_fully_cooled_group(self, mock_router_instance):
"""The pool pick is a uniform draw, so the invariant is asserted over repeated turns:
no turn may land on the dead group while a live peer sits in the same tier."""
router = self._router(
mock_router_instance,
{"tiers": dict(self.TIERS)},
{"dead-a": ["id-a1"], "live-b": ["id-b1"]},
cooling=("id-a1",),
)
results = [
await router.async_pre_routing_hook(model="m", request_kwargs={}, messages=self.SIMPLE_MESSAGE)
for _ in range(20)
]
assert {r.model for r in results} == {"live-b"}
assert all(r.routing_decision["cause"] in ("heuristic_scorer", "health_failover") for r in results)
assert any(r.routing_decision["cause"] == "health_failover" for r in results)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"ids_by_model, cooling, health_error, tiers, reason",
[
({"dead-a": ["id-a1"], "live-b": ["id-b1"]}, (), None, None, "nothing_cooling"),
({"dead-a": ["id-a1"], "live-b": ["id-b1"]}, ("id-a1", "id-b1"), None, None, "every_peer_dead"),
(
{"dead-a": ["id-a1"], "live-b": ["id-b1"]},
("id-a1",),
RuntimeError("redis down"),
None,
"health_view_unreadable",
),
(
{"only": ["id-1"]},
("id-1",),
None,
{"SIMPLE": "only", "MEDIUM": "mid", "COMPLEX": "big", "REASONING": "top"},
"single_model_tier_has_no_peer",
),
],
)
async def test_gate_fails_open_and_leaves_the_decision_untouched(
self, mock_router_instance, ids_by_model, cooling, health_error, tiers, reason
):
"""Every uncertainty leaves the decided model in place, so the request fails exactly
as it does today rather than being rerouted on a guess."""
router = self._router(
mock_router_instance,
{"tiers": dict(tiers or self.TIERS), "session_affinity": True},
ids_by_model,
cooling=cooling,
health_error=health_error,
)
pinned = "only" if tiers else "dead-a"
key = router._get_session_affinity_cache_key("sess-open", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": pinned, "tier": "SIMPLE"}, ttl=600
)
result = await router.async_pre_routing_hook(
model="m", request_kwargs={"metadata": {"session_id": "sess-open"}}, messages=self.SIMPLE_MESSAGE
)
assert result.model == pinned, reason
assert result.routing_decision["cause"] == "session_affinity_pin", reason
@pytest.mark.asyncio
async def test_a_failed_over_turn_is_never_pinned(self, mock_router_instance):
"""A failover describes the fleet's state, not the session's traffic, so it must not
become the pin: the substitute would outlive the outage that caused it.
Asserted over many sessions because the underlying pool pick is a uniform draw.
"""
router = self._router(
mock_router_instance,
{"tiers": dict(self.TIERS), "session_affinity": True},
{"dead-a": ["id-a1"], "live-b": ["id-b1"]},
cooling=("id-a1",),
)
async def pin_after_session(turn: int):
session_id = f"sess-write-{turn}"
await router.async_pre_routing_hook(
model="m",
request_kwargs={"metadata": {"session_id": session_id}},
messages=self.SIMPLE_MESSAGE,
)
return await router.litellm_router_instance.cache.async_get_cache(
key=router._get_session_affinity_cache_key(session_id, {})
)
stored = [await pin_after_session(turn) for turn in range(20)]
assert all(entry in (None, {"model": "live-b", "tier": "SIMPLE"}) for entry in stored)
assert any(entry is None for entry in stored), "a failed-over turn must leave the pin unwritten"
@pytest.mark.asyncio
async def test_an_unpinnable_displaced_cause_stays_unpinnable_after_failover(self, mock_router_instance):
"""A housekeeping turn is deliberately never pinned. Rewriting its cause to health_failover
must not smuggle it past that guard and lock the session onto the cheapest tier."""
router = self._router(
mock_router_instance,
{"tiers": dict(self.TIERS), "session_affinity": True},
{"dead-a": ["id-a1"], "live-b": ["id-b1"]},
cooling=("id-a1",),
)
session_id = "sess-housekeeping"
result = await router.async_pre_routing_hook(
model="m",
request_kwargs={"metadata": {"session_id": session_id}},
messages=[{"role": "user", "content": TITLE_ASK}],
)
assert result.routing_decision["cause"] in ("housekeeping", "health_failover")
stored = await router.litellm_router_instance.cache.async_get_cache(
key=router._get_session_affinity_cache_key(session_id, {})
)
assert stored is None
@pytest.mark.asyncio
async def test_a_peer_whose_deployments_are_admin_paused_is_not_a_failover_target(self, mock_router_instance):
"""Capacity is the router's own verdict, not just cooldown: a paused peer would be
rejected downstream and the request would fail with a live third peer available."""
router = self._router(
mock_router_instance,
{
"tiers": {
"SIMPLE": ["dead-a", "paused-b", "live-c"],
"MEDIUM": "mid",
"COMPLEX": "big",
"REASONING": "top",
},
"session_affinity": True,
},
{"dead-a": ["id-a1"], "paused-b": ["id-b1"], "live-c": ["id-c1"]},
cooling=("id-a1",),
blocked=("id-b1",),
)
key = router._get_session_affinity_cache_key("sess-paused", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": "dead-a", "tier": "SIMPLE"}, ttl=600
)
results = [
await router.async_pre_routing_hook(
model="m", request_kwargs={"metadata": {"session_id": "sess-paused"}}, messages=self.SIMPLE_MESSAGE
)
for _ in range(20)
]
assert {r.model for r in results} == {"live-c"}
@pytest.mark.asyncio
async def test_failover_fails_closed_when_a_routing_plugin_excludes_every_peer(self, mock_router_instance):
"""A plugin's exclusion is policy, so a peer it removed must not be served just because
the plugin's own choice went into cooldown."""
class ExcludeEverythingButDead:
async def run(self, context):
context.candidate_models = [m for m in context.candidate_models if m == "dead-a"]
return context
router = self._router(
mock_router_instance,
{"tiers": dict(self.TIERS), "plugins": [ExcludeEverythingButDead()]},
{"dead-a": ["id-a1"], "live-b": ["id-b1"]},
cooling=("id-a1",),
)
result = await router.async_pre_routing_hook(model="m", request_kwargs={}, messages=self.SIMPLE_MESSAGE)
assert result.model == "dead-a"
assert result.routing_decision["cause"] != "health_failover"
@pytest.mark.asyncio
async def test_failover_moves_the_adaptive_chosen_model_marker(self, mock_router_instance):
"""The adaptive feedback loop scores the marker, so leaving it on the displaced group
would credit a model that never ran."""
router = self._router(
mock_router_instance,
{"tiers": dict(self.TIERS), "session_affinity": True},
{"dead-a": ["id-a1"], "live-b": ["id-b1"]},
cooling=("id-a1",),
)
key = router._get_session_affinity_cache_key("sess-adaptive", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": "dead-a", "tier": "SIMPLE"}, ttl=600
)
request_kwargs = {"metadata": {"session_id": "sess-adaptive", "adaptive_router_chosen_model": "dead-a"}}
result = await router.async_pre_routing_hook(
model="m", request_kwargs=request_kwargs, messages=self.SIMPLE_MESSAGE
)
assert result.model == "live-b"
assert request_kwargs["metadata"]["adaptive_router_chosen_model"] == "live-b"
@pytest.mark.asyncio
async def test_health_failover_never_undoes_the_modality_gate(self, mock_router_instance):
"""An image turn whose only live peer cannot take images keeps the vision model the
modality gate chose: serving a cooling vision model beats a hard 400."""
vision_by_model = {"dead-vision": True, "live-text": False}
def get_model_list(model_name=None):
if model_name not in vision_by_model:
return []
return [
{
"model_name": model_name,
"litellm_params": {"model": f"openai/unmapped-{model_name}"},
"model_info": {"supports_vision": vision_by_model[model_name]},
}
]
mock_router_instance.get_model_list = get_model_list
router = self._router(
mock_router_instance,
{
"tiers": {
"SIMPLE": ["dead-vision", "live-text"],
"MEDIUM": "mid",
"COMPLEX": "big",
"REASONING": "top",
},
"session_affinity": True,
"modality_routing": True,
},
{"dead-vision": ["id-v1"], "live-text": ["id-t1"]},
cooling=("id-v1",),
)
key = router._get_session_affinity_cache_key("sess-image", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": "dead-vision", "tier": "SIMPLE"}, ttl=600
)
image_message = [
{
"role": "user",
"content": [
{"type": "text", "text": "What color is this?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,aGk="}},
],
}
]
result = await router.async_pre_routing_hook(
model="m", request_kwargs={"metadata": {"session_id": "sess-image"}}, messages=image_message
)
assert result.model == "dead-vision"
@pytest.mark.asyncio
async def test_failover_will_not_pick_a_peer_that_cannot_hold_the_prompt(self):
"""The context-window filter is a pre-call check inside the eligibility owner, so this
drives the REAL owner on a real Router and injects only the cooldown. A substitute the
prompt overflows must never be chosen while a peer that holds it exists."""
pool = ["dead-big", "live-small", "live-big"]
router_instance = _windowed_router(
("dead-big", "openai/gpt-4o-mini", 200000),
("live-small", "openai/gpt-3.5-turbo", 16385),
("live-big", "openai/gpt-4o-mini", 200000),
)
router_instance.enable_pre_call_checks = True
dead_ids = {d["model_info"]["id"] for d in router_instance.model_list if d["model_name"] == "dead-big"}
async def active_cooldowns(model_ids, parent_otel_span):
return [(i, {"exception_received": "boom"}) for i in model_ids if i in dead_ids]
router_instance.cooldown_cache.async_get_active_cooldowns = active_cooldowns
router_instance.cache = DualCache()
router = ComplexityRouter(
model_name="health-window-router",
litellm_router_instance=router_instance,
complexity_router_config={
"tiers": {name: list(pool) for name in ("SIMPLE", "MEDIUM", "COMPLEX", "REASONING")},
"session_affinity": True,
"enable_context_window_escalation": True,
},
)
key = router._get_session_affinity_cache_key("sess-window", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": "dead-big", "tier": "SIMPLE"}, ttl=600
)
results = [
await router.async_pre_routing_hook(
model="m",
request_kwargs={"metadata": {"session_id": "sess-window"}},
messages=list(_OVERSIZED_TURNS),
)
for _ in range(20)
]
assert "live-small" not in {r.model for r in results}
assert {r.model for r in results} == {"live-big"}
@pytest.mark.asyncio
async def test_a_decision_with_no_tier_is_left_alone(self, mock_router_instance):
"""default_model placements carry no tier, so there is no pool to draw a peer from.
The gate leaves them exactly as they are rather than inventing a tier."""
router = self._router(
mock_router_instance,
{
"tiers": dict(self.TIERS),
"default_model": "fallback-model",
"classifier_type": "llm",
"classifier_llm_config": {"model": "gpt-4o-mini"},
"classifier_fallback": "default_model",
},
{"fallback-model": ["id-f1"], "dead-a": ["id-a1"], "live-b": ["id-b1"]},
cooling=("id-f1", "id-a1"),
)
mock_router_instance.acompletion = AsyncMock(side_effect=RuntimeError("classifier down"))
result = await router.async_pre_routing_hook(model="m", request_kwargs={}, messages=self.SIMPLE_MESSAGE)
assert result.model == "fallback-model"
assert result.routing_decision.get("tier") is None
assert result.routing_decision["cause"] != "health_failover"
@pytest.mark.asyncio
async def test_a_tier_entry_the_router_cannot_serve_fails_over_instead_of_erroring(self, mock_router_instance):
"""A tier naming a model this proxy has no deployment for is unservable, and the
eligibility owner says so, so the peer serves rather than the request 429ing."""
router = self._router(
mock_router_instance,
{"tiers": dict(self.TIERS), "session_affinity": True},
{"live-b": ["id-b1"]},
)
key = router._get_session_affinity_cache_key("sess-unknown", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": "dead-a", "tier": "SIMPLE"}, ttl=600
)
result = await router.async_pre_routing_hook(
model="m", request_kwargs={"metadata": {"session_id": "sess-unknown"}}, messages=self.SIMPLE_MESSAGE
)
assert result.model == "live-b"
assert result.routing_decision["cause"] == "health_failover"
@pytest.mark.asyncio
async def test_a_peer_excluded_by_a_request_scoped_policy_is_not_a_failover_target(self, mock_router_instance):
"""Tag, team and access-group filters are request-scoped and live inside the eligibility
owner. A peer they exclude would be rejected downstream, so it must not be chosen."""
router = self._router(
mock_router_instance,
{
"tiers": {
"SIMPLE": ["dead-a", "tagged-out-b", "live-c"],
"MEDIUM": "mid",
"COMPLEX": "big",
"REASONING": "top",
},
"session_affinity": True,
},
{"dead-a": ["id-a1"], "tagged-out-b": ["id-b1"], "live-c": ["id-c1"]},
cooling=("id-a1",),
excluded=("id-b1",),
)
key = router._get_session_affinity_cache_key("sess-tagged", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": "dead-a", "tier": "SIMPLE"}, ttl=600
)
results = [
await router.async_pre_routing_hook(
model="m", request_kwargs={"metadata": {"session_id": "sess-tagged"}}, messages=self.SIMPLE_MESSAGE
)
for _ in range(20)
]
assert {r.model for r in results} == {"live-c"}
@pytest.mark.asyncio
async def test_the_eligibility_probe_never_mutates_the_caller_request_kwargs(self, mock_router_instance):
"""The owner pops routing bookkeeping off the dict it is handed, so a probe that passed
the real kwargs would strip them before the request is ever placed."""
router = self._router(
mock_router_instance,
{"tiers": dict(self.TIERS), "session_affinity": True},
{"dead-a": ["id-a1"], "live-b": ["id-b1"]},
cooling=("id-a1",),
)
key = router._get_session_affinity_cache_key("sess-kwargs", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": "dead-a", "tier": "SIMPLE"}, ttl=600
)
request_kwargs = {
"metadata": {"session_id": "sess-kwargs"},
"_target_order": 1,
"_excluded_deployment_ids": ["id-x"],
}
result = await router.async_pre_routing_hook(
model="m", request_kwargs=request_kwargs, messages=self.SIMPLE_MESSAGE
)
assert result.model == "live-b"
assert request_kwargs["_target_order"] == 1
assert request_kwargs["_excluded_deployment_ids"] == ["id-x"]
assert all(probed is not request_kwargs for probed in router.litellm_router_instance.probed_kwargs)
@pytest.mark.asyncio
async def test_a_peer_whose_every_deployment_is_over_its_rpm_is_not_a_failover_target(
self, mock_router_instance
):
"""RPM exhaustion is its own verdict from the owner (RouterRateLimitErrorBasic). A peer
in that state would be rejected downstream, so it cannot be the substitute."""
from litellm.types.router import RouterRateLimitErrorBasic
router = self._router(
mock_router_instance,
{
"tiers": {
"SIMPLE": ["dead-a", "rpm-full-b", "live-c"],
"MEDIUM": "mid",
"COMPLEX": "big",
"REASONING": "top",
},
"session_affinity": True,
},
{"dead-a": ["id-a1"], "rpm-full-b": ["id-b1"], "live-c": ["id-c1"]},
cooling=("id-a1",),
raises_for={"rpm-full-b": RouterRateLimitErrorBasic(model="rpm-full-b")},
)
key = router._get_session_affinity_cache_key("sess-rpm", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": "dead-a", "tier": "SIMPLE"}, ttl=600
)
results = [
await router.async_pre_routing_hook(
model="m", request_kwargs={"metadata": {"session_id": "sess-rpm"}}, messages=self.SIMPLE_MESSAGE
)
for _ in range(20)
]
assert {r.model for r in results} == {"live-c"}
@pytest.mark.asyncio
async def test_the_probe_forwards_input_so_window_checks_run_on_input_only_surfaces(
self, mock_router_instance
):
"""The Responses API carries its prompt as `input`, never as messages. The owner only
runs its context-window pre-call check when one of them is present, so dropping `input`
would silently skip window filtering on that whole surface."""
router = self._router(
mock_router_instance,
{"tiers": dict(self.TIERS), "session_affinity": True},
{"dead-a": ["id-a1"], "live-b": ["id-b1"]},
cooling=("id-a1",),
)
key = router._get_session_affinity_cache_key("sess-input", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": "dead-a", "tier": "SIMPLE"}, ttl=600
)
result = await router.async_pre_routing_hook(
model="m",
request_kwargs={"metadata": {"session_id": "sess-input"}},
input="summarize this document for me",
)
assert result.model == "live-b"
assert any(
probed_input == "summarize this document for me"
for _, probed_input in router.litellm_router_instance.probed_prompts
), "the eligibility probe must forward `input` to the owner"
@pytest.mark.asyncio
async def test_a_group_the_router_has_no_deployment_for_is_not_a_failover_target(
self, mock_router_instance
):
"""The owner answers an unconfigured group with BadRequestError. Reading that as live
would both skip failover off it and let it be chosen as a substitute."""
router = self._router(
mock_router_instance,
{
"tiers": {
"SIMPLE": ["dead-a", "unconfigured-b", "live-c"],
"MEDIUM": "mid",
"COMPLEX": "big",
"REASONING": "top",
},
"session_affinity": True,
},
{"dead-a": ["id-a1"], "live-c": ["id-c1"]},
cooling=("id-a1",),
)
key = router._get_session_affinity_cache_key("sess-missing", {})
await router.litellm_router_instance.cache.async_set_cache(
key=key, value={"model": "dead-a", "tier": "SIMPLE"}, ttl=600
)
results = [
await router.async_pre_routing_hook(
model="m", request_kwargs={"metadata": {"session_id": "sess-missing"}}, messages=self.SIMPLE_MESSAGE
)
for _ in range(20)
]
assert {r.model for r in results} == {"live-c"}

View file

@ -36045,7 +36045,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" | "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" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
/** Classifier Cost */
classifier_cost?: number;
/** Classifier Model */