litellm/tests/unit/router_utils/test_fallback_event_handlers.py
Yassin Kortam 6f5f9ce665
fix(router): report fallbacks skipped for access or budget instead of calling them failed (#46291)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 15:08:47 -07:00

2570 lines
98 KiB
Python

import asyncio
import importlib
import json
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from types import MappingProxyType
from typing import Any, Final, Literal, NoReturn
from unittest.mock import MagicMock, patch
import httpx
import pytest
import respx
import litellm
from litellm import Router
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils import get_llm_provider_logic
from litellm.router_utils.cooldown_handlers import mark_advisor_orchestration_failure
from litellm.router_utils.fallback_event_handlers import (
MID_STREAM_FALLBACK_CONTROLS_KEY,
PRE_ROUTING_SELECTED_MODEL_KEY,
AttemptedFallbackTargets,
MidStreamFallbackControls,
_trigger_cooldown_for_failed_deployment,
attempted_retries_for_request,
carry_over_routed_deployment,
clear_pre_routing_selection,
committed_retry_budget_for_request,
fallback_attempt_key,
get_fallback_model_group,
get_pre_routing_selection,
is_bad_request_without_fallback,
log_failure_fallback_event,
log_success_fallback_event,
mid_stream_fallback_snapshot_kwargs,
mid_stream_retry_kwargs,
record_pre_routing_selection,
record_retry_attempt,
routed_deployment_id,
run_async_fallback,
)
from litellm.types.router import FallbackSkipReason, SkippedFallbackTarget
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
from tests.fake_openai_endpoint import FAKE_OPENAI_API_BASE
from typing import Dict
import os
class StreamingWrapper:
def __init__(self):
self._hidden_params = {"additional_headers": {}}
class FakeRouter:
fallback_access_check = None
fallback_budget_check = None
def log_retry(self, kwargs, e):
return kwargs
async def async_function_with_fallbacks(self, *args, **kwargs):
return StreamingWrapper()
class AlwaysFailRouter:
fallback_access_check = None
fallback_budget_check = None
def log_retry(self, kwargs, e):
return kwargs
async def async_function_with_fallbacks(self, *args, **kwargs):
raise RuntimeError("fallback model also failed")
@pytest.mark.asyncio
async def test_run_async_fallback_adds_errors_when_opted_in():
response = await run_async_fallback(
litellm_router=FakeRouter(),
fallback_model_group=["fallback-model"],
original_model_group="primary-model",
original_exception=RuntimeError("upstream limited request"),
max_fallbacks=3,
fallback_depth=0,
include_fallback_errors=True,
)
additional_headers = response._hidden_params["additional_headers"]
assert additional_headers["x-litellm-attempted-fallbacks"] == 1
assert json.loads(additional_headers["x-litellm-fallback-errors"]) == [
{
"message": "upstream limited request",
"type": "RuntimeError",
"param": None,
"code": None,
}
]
@pytest.mark.asyncio
async def test_run_async_fallback_omits_errors_without_opt_in():
response = await run_async_fallback(
litellm_router=FakeRouter(),
fallback_model_group=["fallback-model"],
original_model_group="primary-model",
original_exception=RuntimeError("upstream limited request"),
max_fallbacks=3,
fallback_depth=0,
)
additional_headers = response._hidden_params["additional_headers"]
assert additional_headers["x-litellm-attempted-fallbacks"] == 1
assert "x-litellm-fallback-errors" not in additional_headers
@pytest.mark.asyncio
async def test_run_async_fallback_raises_when_all_fallbacks_fail():
with pytest.raises(RuntimeError, match="fallback model also failed"):
await run_async_fallback(
litellm_router=AlwaysFailRouter(),
fallback_model_group=["fallback-model"],
original_model_group="primary-model",
original_exception=RuntimeError("original request failed"),
max_fallbacks=3,
fallback_depth=0,
include_fallback_errors=True,
)
class RecordingRouter:
fallback_access_check = None
fallback_budget_check = None
def __init__(self):
self.received_kwargs = None
def log_retry(self, kwargs, e):
return kwargs
async def async_function_with_fallbacks(self, *args, **kwargs):
self.received_kwargs = kwargs
return StreamingWrapper()
@pytest.mark.asyncio
async def test_run_async_fallback_forwards_include_fallback_errors_to_nested_call():
"""A nested fallback (multi-hop) must keep collecting errors, so the opt-in
flag has to reach the nested async_function_with_fallbacks call."""
router = RecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=["fallback-model"],
original_model_group="primary-model",
original_exception=RuntimeError("upstream limited request"),
max_fallbacks=3,
fallback_depth=0,
include_fallback_errors=True,
)
assert router.received_kwargs.get("include_fallback_errors") is True
@pytest.mark.asyncio
async def test_run_async_fallback_does_not_forward_flag_without_opt_in():
router = RecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=["fallback-model"],
original_model_group="primary-model",
original_exception=RuntimeError("upstream limited request"),
max_fallbacks=3,
fallback_depth=0,
)
assert "include_fallback_errors" not in router.received_kwargs
@pytest.mark.asyncio
async def test_run_async_fallback_skips_original_model_group():
response = await run_async_fallback(
litellm_router=FakeRouter(),
fallback_model_group=["primary-model", "fallback-model"],
original_model_group="primary-model",
original_exception=RuntimeError("original failed"),
max_fallbacks=3,
fallback_depth=0,
)
assert response._hidden_params["additional_headers"]["x-litellm-attempted-fallbacks"] == 1
class AttemptRecordingRouter:
fallback_access_check = None
fallback_budget_check = None
def __init__(self):
self.attempted_model_groups = []
self.received_kwargs = None
def log_retry(self, kwargs, e):
return kwargs
async def async_function_with_fallbacks(self, *args, **kwargs):
self.attempted_model_groups.append(kwargs.get("model"))
self.received_kwargs = kwargs
return StreamingWrapper()
async def _acreate_batch(*args, **kwargs):
raise AssertionError("only used for its __name__")
async def _acreate_file(*args: object, **kwargs: object) -> NoReturn:
raise AssertionError("only used for its __name__")
async def _acancel_batch(*args: object, **kwargs: object) -> NoReturn:
raise AssertionError("only used for its __name__")
async def _acompletion(*args: object, **kwargs: object) -> NoReturn:
raise AssertionError("only used for its __name__")
async def _ageneric_api_call_with_fallbacks_helper(*args: object, **kwargs: object) -> NoReturn:
raise AssertionError("only used for its __name__")
async def acreate_fine_tuning_job(*args: object, **kwargs: object) -> NoReturn:
raise AssertionError("only used for its __name__")
async def aretrieve_fine_tuning_job(*args: object, **kwargs: object) -> NoReturn:
raise AssertionError("only used for its __name__")
async def afile_content(*args: object, **kwargs: object) -> NoReturn:
raise AssertionError("only used for its __name__")
@pytest.mark.asyncio
async def test_run_async_fallback_keeps_uploaded_file_requests_in_their_model_group():
"""An input_file_id only exists under the credentials of the group it was uploaded
to, so a cross-group fallback can only fail with the wrong provider's error."""
router = AttemptRecordingRouter()
owning_provider_error = RuntimeError("openai connection error")
with pytest.raises(RuntimeError, match="openai connection error"):
await run_async_fallback(
litellm_router=router,
fallback_model_group=["azure-group"],
original_model_group="openai-group",
original_exception=owning_provider_error,
max_fallbacks=3,
fallback_depth=0,
model="openai-group",
input_file_id="file-owned-by-openai",
original_function=_acreate_batch,
)
assert router.attempted_model_groups == []
@pytest.mark.asyncio
async def test_run_async_fallback_keeps_fine_tuning_requests_in_their_model_group():
router = AttemptRecordingRouter()
with pytest.raises(RuntimeError, match="openai connection error"):
await run_async_fallback(
litellm_router=router,
fallback_model_group=["azure-group"],
original_model_group="openai-group",
original_exception=RuntimeError("openai connection error"),
max_fallbacks=3,
fallback_depth=0,
model="openai-group",
training_file="file-owned-by-openai",
original_function=_ageneric_api_call_with_fallbacks_helper,
original_generic_function=acreate_fine_tuning_job,
)
assert router.attempted_model_groups == []
@pytest.mark.asyncio
async def test_run_async_fallback_allows_same_model_group_retry_for_uploaded_file_requests():
"""Order-based fallbacks stay inside the owning group, so they must still run."""
router = AttemptRecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=[{"model": "openai-group", "_target_order": 2}],
original_model_group="openai-group",
original_exception=RuntimeError("first deployment failed"),
max_fallbacks=3,
fallback_depth=0,
model="openai-group",
input_file_id="file-owned-by-openai",
original_function=_acreate_batch,
)
assert router.attempted_model_groups == ["openai-group"]
@pytest.mark.asyncio
async def test_run_async_fallback_keeps_file_creation_in_its_model_group():
"""A file created for batches lands in the account of the deployment that stored it,
and its id is only usable against the model group the caller named. A cross-group
fallback silently stores the file with the wrong provider."""
router = AttemptRecordingRouter()
with pytest.raises(RuntimeError, match="azure connection error"):
await run_async_fallback(
litellm_router=router,
fallback_model_group=["openai-group"],
original_model_group="azure-group",
original_exception=RuntimeError("azure connection error"),
max_fallbacks=3,
fallback_depth=0,
model="azure-group",
original_function=_acreate_file,
)
assert router.attempted_model_groups == []
@pytest.mark.asyncio
async def test_run_async_fallback_allows_same_model_group_retry_for_file_creation():
router = AttemptRecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=[{"model": "azure-group", "_target_order": 2}],
original_model_group="azure-group",
original_exception=RuntimeError("first deployment failed"),
max_fallbacks=3,
fallback_depth=0,
model="azure-group",
original_function=_acreate_file,
)
assert router.attempted_model_groups == ["azure-group"]
@pytest.mark.asyncio
async def test_run_async_fallback_still_crosses_model_groups_without_an_uploaded_file():
router = AttemptRecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=["azure-group"],
original_model_group="openai-group",
original_exception=RuntimeError("openai connection error"),
max_fallbacks=3,
fallback_depth=0,
model="openai-group",
)
assert router.attempted_model_groups == ["azure-group"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("resource_key", "handler_kwargs"),
[
("batch_id", {"original_function": _acancel_batch}),
(
"file_id",
{
"original_function": _ageneric_api_call_with_fallbacks_helper,
"original_generic_function": afile_content,
},
),
(
"fine_tuning_job_id",
{
"original_function": _ageneric_api_call_with_fallbacks_helper,
"original_generic_function": aretrieve_fine_tuning_job,
},
),
],
)
async def test_run_async_fallback_keeps_provider_scoped_ids_in_their_model_group(
resource_key: str, handler_kwargs: dict
):
"""A batch, file, or fine-tuning job id only exists under the credentials of the group
that issued it, so a cross-group fallback asks a provider about an id it never saw.
Generic API calls carry the real handler in original_generic_function, so the pin
must recognize it there too."""
router = AttemptRecordingRouter()
with pytest.raises(RuntimeError, match="openai connection error"):
await run_async_fallback(
litellm_router=router,
fallback_model_group=["azure-group"],
original_model_group="openai-group",
original_exception=RuntimeError("openai connection error"),
max_fallbacks=3,
fallback_depth=0,
model="openai-group",
**{resource_key: "owned-by-openai"},
**handler_kwargs,
)
assert router.attempted_model_groups == []
@pytest.mark.asyncio
@pytest.mark.parametrize("resource_key", ["batch_id", "file_id", "fine_tuning_job_id"])
async def test_run_async_fallback_ignores_stray_resource_ids_on_completion_calls(resource_key: str):
"""A caller-supplied top-level field like file_id on a chat completion is application
data, never a provider resource reference, so it must not cost the request its
cross-group fallbacks."""
router = AttemptRecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=["azure-group"],
original_model_group="openai-group",
original_exception=RuntimeError("openai connection error"),
max_fallbacks=3,
fallback_depth=0,
model="openai-group",
original_function=_acompletion,
**{resource_key: "caller-app-data"},
)
assert router.attempted_model_groups == ["azure-group"]
@pytest.mark.asyncio
async def test_run_async_fallback_allows_same_model_group_retry_for_batch_cancel():
router = AttemptRecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=[{"model": "openai-group", "_target_order": 2}],
original_model_group="openai-group",
original_exception=RuntimeError("first deployment failed"),
max_fallbacks=3,
fallback_depth=0,
model="openai-group",
batch_id="owned-by-openai",
original_function=_acancel_batch,
)
assert router.attempted_model_groups == ["openai-group"]
@pytest.mark.asyncio
async def test_run_async_fallback_handles_explicitly_none_metadata():
"""/v1/batches always sets `metadata`, and sets it to None when the caller sent
none, so setdefault() on it hands back None instead of a dict."""
router = AttemptRecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=["azure-group"],
original_model_group="openai-group",
original_exception=RuntimeError("openai connection error"),
max_fallbacks=3,
fallback_depth=0,
model="openai-group",
metadata=None,
)
assert router.received_kwargs["metadata"] == {
"model_group": "azure-group",
"attempted_fallbacks": 1,
"original_model_group": "openai-group",
}
@pytest.mark.asyncio
async def test_run_async_fallback_records_batch_model_group_outside_provider_metadata():
"""`metadata` on a batch request is forwarded to the provider and stored on the
batch, so the router's own model_group belongs in litellm_metadata."""
router = AttemptRecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=[{"model": "openai-group", "_target_order": 2}],
original_model_group="openai-group",
original_exception=RuntimeError("first deployment failed"),
max_fallbacks=3,
fallback_depth=0,
model="openai-group",
input_file_id="file-owned-by-openai",
metadata={"caller": "nightly-job"},
litellm_metadata={"model_group": "openai-group"},
original_function=_acreate_batch,
)
assert router.received_kwargs["metadata"] == {"caller": "nightly-job"}
assert router.received_kwargs["litellm_metadata"]["model_group"] == "openai-group"
class AccessCheckedRouter(AttemptRecordingRouter):
def __init__(self, allowed_models: frozenset[str]):
super().__init__()
self.allowed_models = allowed_models
self.access_checks = []
fallback_budget_check = None
async def fallback_access_check(self, *, model, request_kwargs, llm_router):
self.access_checks.append((model, request_kwargs["metadata"]["user_api_key"], llm_router is self))
return model in self.allowed_models
@pytest.mark.asyncio
async def test_run_async_fallback_skips_targets_the_access_check_rejects():
router = AccessCheckedRouter(allowed_models=frozenset({"allowed-model"}))
await run_async_fallback(
litellm_router=router,
fallback_model_group=[
{"model": "secret-model", "messages": [{"role": "user", "content": "hi"}]},
"allowed-model",
],
original_model_group="primary-model",
original_exception=RuntimeError("primary failed"),
max_fallbacks=3,
fallback_depth=0,
model="primary-model",
metadata={"user_api_key": "hashed"},
)
assert router.attempted_model_groups == ["allowed-model"]
assert router.access_checks == [
("secret-model", "hashed", True),
("allowed-model", "hashed", True),
]
@pytest.mark.asyncio
async def test_run_async_fallback_raises_original_error_when_no_target_is_authorized():
router = AccessCheckedRouter(allowed_models=frozenset())
with pytest.raises(RuntimeError, match="primary failed"):
await run_async_fallback(
litellm_router=router,
fallback_model_group=["secret-model", "other-secret-model"],
original_model_group="primary-model",
original_exception=RuntimeError("primary failed"),
max_fallbacks=3,
fallback_depth=0,
model="primary-model",
metadata={"user_api_key": "hashed"},
)
assert router.attempted_model_groups == []
assert [model for model, _, _ in router.access_checks] == ["secret-model", "other-secret-model"]
@pytest.mark.asyncio
async def test_run_async_fallback_does_not_consult_access_check_for_same_model_group_retries():
router = AccessCheckedRouter(allowed_models=frozenset())
await run_async_fallback(
litellm_router=router,
fallback_model_group=[{"model": "primary-model", "_target_order": 2}],
original_model_group="primary-model",
original_exception=RuntimeError("first order level failed"),
max_fallbacks=3,
fallback_depth=0,
model="primary-model",
metadata={"user_api_key": "hashed"},
)
assert router.attempted_model_groups == ["primary-model"]
assert router.access_checks == []
class RecordingFailRouter:
fallback_access_check = None
fallback_budget_check = None
def __init__(self):
self.attempted_models = []
def log_retry(self, kwargs, e):
return kwargs
async def async_function_with_fallbacks(self, *args, **kwargs):
self.attempted_models.append(kwargs.get("model"))
raise RuntimeError("fallback model also failed")
@pytest.mark.asyncio
async def test_run_async_fallback_skips_model_group_already_attempted():
"""A fallback graph that loops back on itself must not re-attempt a model group that
already failed for this request. Every group in a cycle fails identically, so
revisiting one multiplies the work and the error output without any chance of
succeeding."""
router = RecordingFailRouter()
with pytest.raises(RuntimeError, match="original failed"):
await run_async_fallback(
litellm_router=router,
fallback_model_group=["already-attempted"],
original_model_group="primary-model",
original_exception=RuntimeError("original failed"),
max_fallbacks=3,
fallback_depth=0,
attempted_targets=AttemptedFallbackTargets(frozenset({"already-attempted"})),
)
assert router.attempted_models == []
@pytest.mark.asyncio
async def test_run_async_fallback_attempts_a_repeated_target_once():
router = RecordingFailRouter()
with pytest.raises(RuntimeError, match="fallback model also failed"):
await run_async_fallback(
litellm_router=router,
fallback_model_group=["fallback-model", "fallback-model", "other-model"],
original_model_group="primary-model",
original_exception=RuntimeError("original failed"),
max_fallbacks=5,
fallback_depth=0,
)
assert router.attempted_models == ["fallback-model", "other-model"]
@pytest.mark.asyncio
async def test_run_async_fallback_forwards_attempted_model_groups_to_nested_call():
"""The nested call is where the next hop of the walk decides what to skip, so the
accumulated set has to reach it, carrying both the group that just failed and the
target being attempted."""
router = RecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=["fallback-model"],
original_model_group="primary-model",
original_exception=RuntimeError("original failed"),
max_fallbacks=3,
fallback_depth=0,
attempted_targets=AttemptedFallbackTargets(frozenset({"earlier-model"})),
)
assert router.received_kwargs["attempted_targets"].keys == frozenset(
{"earlier-model", "primary-model", "fallback-model"}
)
@pytest.mark.asyncio
async def test_run_async_fallback_can_target_the_requested_group_when_a_pre_router_replaced_it():
"""The requested group was never called when a pre-router selected a tier, so a
tier fallback may legitimately target that originally requested group."""
router = RecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=["requested-model"],
original_model_group="requested-model",
original_exception=RuntimeError("selected tier failed"),
max_fallbacks=3,
fallback_depth=0,
model="requested-model",
metadata={"pre_routing_selected_model": "selected-tier"},
)
assert router.received_kwargs["model"] == "requested-model"
assert router.received_kwargs["attempted_targets"].keys == frozenset({"selected-tier", "requested-model"})
@pytest.mark.asyncio
@pytest.mark.parametrize(
"entry",
[
{"model": "primary-model", "_target_order": 2},
{"model": "primary-model", "_excluded_deployment_ids": ["dep-1"]},
],
)
async def test_run_async_fallback_still_retargets_the_same_group_via_dict_entry(entry):
"""Order-based fallback and weighted intra-group failover both re-target the group that
just failed, selecting a different set of deployments inside it. Those entries are dicts
rather than plain names and must survive a guard that skips already-attempted names."""
router = RecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=[entry],
original_model_group="primary-model",
original_exception=RuntimeError("original failed"),
max_fallbacks=3,
fallback_depth=0,
attempted_targets=AttemptedFallbackTargets(frozenset({"primary-model"})),
)
assert router.received_kwargs["model"] == "primary-model"
@pytest.mark.asyncio
async def test_run_async_fallback_skips_a_repeated_dict_target():
"""A client-side fallback list names its targets with dicts, and that list is re-walked
at every level of the recursion, so an entry that carries no request override has to be
recognised as the same attempt as the bare name."""
router = RecordingFailRouter()
with pytest.raises(RuntimeError, match="original failed"):
await run_async_fallback(
litellm_router=router,
fallback_model_group=[{"model": "already-attempted"}],
original_model_group="primary-model",
original_exception=RuntimeError("original failed"),
max_fallbacks=3,
fallback_depth=0,
attempted_targets=AttemptedFallbackTargets(frozenset({"already-attempted"})),
)
assert router.attempted_models == []
@pytest.mark.asyncio
async def test_run_async_fallback_attempts_a_repeated_dict_target_once():
router = RecordingFailRouter()
entry = {"model": "fallback-model", "messages": [{"role": "user", "content": "shorter"}]}
with pytest.raises(RuntimeError, match="fallback model also failed"):
await run_async_fallback(
litellm_router=router,
fallback_model_group=[entry, entry, {"model": "other-model"}],
original_model_group="primary-model",
original_exception=RuntimeError("original failed"),
max_fallbacks=5,
fallback_depth=0,
)
assert router.attempted_models == ["fallback-model", "other-model"]
@pytest.mark.asyncio
async def test_run_async_fallback_keeps_a_request_override_distinct_from_the_bare_name():
"""The documented use of the client-side form is to retry a group with different request
params, so an entry carrying an override must survive even when the bare name of that
same group has already been attempted."""
router = RecordingFailRouter()
with pytest.raises(RuntimeError, match="fallback model also failed"):
await run_async_fallback(
litellm_router=router,
fallback_model_group=[{"model": "already-attempted", "messages": [{"role": "user", "content": "shorter"}]}],
original_model_group="primary-model",
original_exception=RuntimeError("original failed"),
max_fallbacks=3,
fallback_depth=0,
attempted_targets=AttemptedFallbackTargets(frozenset({"already-attempted"})),
)
assert router.attempted_models == ["already-attempted"]
@pytest.mark.parametrize(
"target, expected",
[
("group-a", "group-a"),
({"model": "group-a"}, "group-a"),
(None, None),
(["group-a"], None),
],
)
def test_fallback_attempt_key_identity(target, expected):
"""A bare name and a `{"model": name}` entry are the same attempt. A shape with no
usable identity returns None and is never skipped, so an unrecognised entry keeps
today's behaviour rather than being silently dropped."""
assert fallback_attempt_key(target) == expected
def test_fallback_attempt_key_gives_a_param_only_entry_its_own_identity():
"""An entry with no `model` re-targets the group currently being attempted with
different request params, so it is a distinct attempt and still needs an identity."""
key = fallback_attempt_key({"messages": [{"role": "user", "content": "shorter"}]})
assert key is not None
assert key != fallback_attempt_key({"messages": [{"role": "user", "content": "other"}]})
def test_fallback_attempt_key_separates_overrides_from_the_bare_name():
bare = fallback_attempt_key("group-a")
override = fallback_attempt_key({"model": "group-a", "messages": [{"role": "user", "content": "x"}]})
other_override = fallback_attempt_key({"model": "group-a", "messages": [{"role": "user", "content": "y"}]})
order_retarget = fallback_attempt_key({"model": "group-a", "_target_order": 2})
assert len({bare, override, other_override, order_retarget}) == 4
def test_fallback_attempt_key_is_stable_across_key_order():
assert fallback_attempt_key({"model": "group-a", "_target_order": 2}) == fallback_attempt_key(
{"_target_order": 2, "model": "group-a"}
)
def test_get_fallback_model_group_does_not_mutate_fallbacks():
"""A string fallback must be resolved without mutating the caller's
fallbacks list, which is the live router config shared across requests."""
fallbacks = [{"gpt-3.5-turbo": ["claude-3-haiku"]}, "gpt-4o-mini"]
fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group="unmatched-model")
assert fallback_model_group == ["gpt-4o-mini"]
assert fallbacks == [{"gpt-3.5-turbo": ["claude-3-haiku"]}, "gpt-4o-mini"]
class TestTriggerCooldownForFailedDeployment:
def test_calls_set_cooldown_deployments_with_stamped_deployment_id(self):
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(litellm_router=mock_router, kwargs={}, exception=exc)
mock_set_cooldown.assert_called_once()
call_kwargs = mock_set_cooldown.call_args[1]
assert call_kwargs["deployment"] == "fallback-deployment"
assert call_kwargs["original_exception"] is exc
def test_does_not_trust_caller_supplied_metadata_bucket(self):
"""A metadata bucket can't reliably be told apart from a caller-supplied
one without knowing this call's function_name, so a client with
permission to set metadata must not be able to get an arbitrary
deployment cooled down by forging a deployment_model_name marker."""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
kwargs = {
"metadata": {
"model_info": {"id": "attacker-chosen-deployment"},
"deployment_model_name": "gpt-4",
}
}
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(litellm_router=mock_router, kwargs=kwargs, exception=exc)
mock_set_cooldown.assert_not_called()
def test_increments_failure_counter_before_cooldown_check(self):
"""The fallback path must feed the same per-minute failure counter the
primary path uses, or repeated fallback failures never accumulate
toward the default percent-fail-rate cooldown threshold."""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
with (
patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown,
patch(
"litellm.router_utils.fallback_event_handlers.increment_deployment_failures_for_current_minute"
) as mock_increment,
):
_trigger_cooldown_for_failed_deployment(litellm_router=mock_router, kwargs={}, exception=exc)
mock_increment.assert_called_once_with(
litellm_router_instance=mock_router, deployment_id="fallback-deployment"
)
mock_set_cooldown.assert_called_once()
def test_no_op_when_deployment_id_missing(self):
mock_router = MagicMock()
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(
litellm_router=mock_router, kwargs={}, exception=RuntimeError("no metadata")
)
mock_set_cooldown.assert_not_called()
def test_skipped_for_advisor_orchestration_failure(self):
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
mark_advisor_orchestration_failure(exc)
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(litellm_router=mock_router, kwargs={}, exception=exc)
mock_set_cooldown.assert_not_called()
def test_uses_deployment_litellm_params_cooldown_time_override(self):
mock_router = MagicMock()
mock_router.cooldown_time = 300.0
mock_router.get_model_info.return_value = {"litellm_params": {"cooldown_time": 30.0}}
exc = litellm.RateLimitError("Rate limit", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(litellm_router=mock_router, kwargs={}, exception=exc)
call_kwargs = mock_set_cooldown.call_args[1]
assert call_kwargs["time_to_cooldown"] == 30.0
def test_uses_response_header_when_no_deployment_config(self):
"""Precedence must match Router.deployment_callback_on_failure's primary
path: deployment config, then the response's Retry-After header, then the
router default."""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = {"litellm_params": {}}
exc = RuntimeError("upstream error")
exc.failed_deployment_id = "fallback-deployment"
exc.litellm_response_headers = httpx.Headers({"retry-after": "45"})
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(litellm_router=mock_router, kwargs={}, exception=exc)
call_kwargs = mock_set_cooldown.call_args[1]
assert call_kwargs["time_to_cooldown"] == 45
def test_silently_catches_exceptions(self):
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = RuntimeError("upstream error")
exc.failed_deployment_id = "fallback-deployment"
with patch(
"litellm.router_utils.fallback_event_handlers.set_cooldown_deployments",
side_effect=RuntimeError("cooldown error"),
):
_trigger_cooldown_for_failed_deployment(litellm_router=mock_router, kwargs={}, exception=exc)
def test_skips_request_scoped_404_on_generic_api_call(self):
"""A generic API call (files/batches/threads/rerank/...) forwards a caller-supplied
resource id, so a 404 there means "that id doesn't exist", not "this deployment is
unhealthy". Without this guard, a single bad id would 404 every deployment in the
fallback chain and cool all of them down from one request."""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.NotFoundError("not found", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
with (
patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown,
patch(
"litellm.router_utils.fallback_event_handlers.increment_deployment_failures_for_current_minute"
) as mock_increment,
):
_trigger_cooldown_for_failed_deployment(
litellm_router=mock_router,
kwargs={"original_generic_function": MagicMock()},
exception=exc,
)
mock_set_cooldown.assert_not_called()
mock_increment.assert_not_called()
def test_still_cools_down_404_outside_generic_api_call(self):
"""The request-scoped-404 guard is scoped to generic API calls only: a 404 on a
regular completion fallback (no original_generic_function in kwargs) must still
cool down the deployment as before."""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.NotFoundError("not found", "openai", "gpt-4")
exc.failed_deployment_id = "fallback-deployment"
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(litellm_router=mock_router, kwargs={}, exception=exc)
mock_set_cooldown.assert_called_once()
def test_skips_client_side_timeout_408(self):
"""The proxy's x-litellm-timeout header lets a caller set an arbitrarily short
timeout, which litellm.Timeout reports as status 408 regardless of the
deployment's actual health. Without this guard, a caller could force a 408 on
every deployment in the fallback chain from a single request.
The failure logger never stamps end_time for a fallback hop (has_logged_async_failure
is already set), so model_call_details still carries the previous hop's end_time, which
predates this hop's api_call_start_time. The guard must not trust it."""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.Timeout(message="timeout", model="gpt-4", llm_provider="openai")
exc.failed_deployment_id = "fallback-deployment"
with (
patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown,
patch(
"litellm.router_utils.fallback_event_handlers.increment_deployment_failures_for_current_minute"
) as mock_increment,
):
_trigger_cooldown_for_failed_deployment(
litellm_router=mock_router,
kwargs={"client_side_timeout": True},
exception=exc,
model_call_details={
"litellm_params": {"client_side_timeout": True, "timeout": 0.5},
"api_call_start_time": datetime.now() - timedelta(seconds=1),
"end_time": datetime.now() - timedelta(seconds=5),
},
)
mock_set_cooldown.assert_not_called()
mock_increment.assert_not_called()
@pytest.mark.asyncio
async def test_still_cools_down_provider_408_before_caller_deadline(self):
"""client_side_timeout only records that the caller configured a timeout. A 408
that comes back before that deadline was raised by the provider itself, so it is
a real health signal and must still cool the deployment down."""
from litellm.router_utils.router_callbacks.track_deployment_metrics import (
get_deployment_failures_for_current_minute,
)
router = litellm.Router(
model_list=[
{
"model_name": "fallback-model",
"litellm_params": {"model": "openai/gpt-5.6", "api_key": "sk-fake"},
"model_info": {"id": "fallback-deployment"},
}
],
allowed_fails=0,
cooldown_time=60,
num_retries=0,
)
exc = litellm.Timeout(message="timeout", model="gpt-5.6", llm_provider="openai")
exc.failed_deployment_id = "fallback-deployment"
started = datetime.now()
_trigger_cooldown_for_failed_deployment(
litellm_router=router,
kwargs={"client_side_timeout": True},
exception=exc,
model_call_details={
"litellm_params": {"client_side_timeout": True, "timeout": 30},
"api_call_start_time": started,
"end_time": started + timedelta(seconds=1),
},
)
assert (
get_deployment_failures_for_current_minute(
litellm_router_instance=router, deployment_id="fallback-deployment"
)
== 1
)
active = router.cooldown_cache.get_active_cooldowns(model_ids=["fallback-deployment"], parent_otel_span=None)
assert [entry[0] for entry in active] == ["fallback-deployment"]
def test_still_cools_down_408_without_client_side_timeout_flag(self):
"""The client-side-timeout guard is scoped to caller-supplied timeouts only: a
408 that did not come from x-litellm-timeout (no client_side_timeout in kwargs)
must still cool down the deployment as before."""
mock_router = MagicMock()
mock_router.cooldown_time = 60.0
mock_router.get_model_info.return_value = None
exc = litellm.Timeout(message="timeout", model="gpt-4", llm_provider="openai")
exc.failed_deployment_id = "fallback-deployment"
with patch("litellm.router_utils.fallback_event_handlers.set_cooldown_deployments") as mock_set_cooldown:
_trigger_cooldown_for_failed_deployment(litellm_router=mock_router, kwargs={}, exception=exc)
mock_set_cooldown.assert_called_once()
class TestRunAsyncFallbackTriggersCooldown:
class RouterWithLoggingKwarg:
fallback_access_check = None
fallback_budget_check = None
def __init__(self):
self.cooldown_time = 60.0
def log_retry(self, kwargs, e):
return kwargs
def get_model_info(self, id):
return None
async def async_function_with_fallbacks(self, *args, **kwargs):
raise RuntimeError("fallback model also failed")
def _logging_obj(self, has_logged_async_failure: bool) -> MagicMock:
logging_obj = MagicMock()
logging_obj.model_call_details = {"has_logged_async_failure": has_logged_async_failure}
return logging_obj
@pytest.mark.asyncio
async def test_triggers_cooldown_when_has_logged_async_failure_is_true(self):
with patch(
"litellm.router_utils.fallback_event_handlers._trigger_cooldown_for_failed_deployment"
) as mock_trigger:
with pytest.raises(RuntimeError, match="fallback model also failed"):
await run_async_fallback(
litellm_router=self.RouterWithLoggingKwarg(),
fallback_model_group=["fallback-model"],
original_model_group="primary-model",
original_exception=RuntimeError("original request failed"),
max_fallbacks=3,
fallback_depth=0,
litellm_logging_obj=self._logging_obj(has_logged_async_failure=True),
)
mock_trigger.assert_called_once()
@pytest.mark.asyncio
async def test_does_not_trigger_cooldown_when_has_logged_async_failure_is_false(self):
"""This is the exact dead-code scenario the bug fix addresses: before it,
the normal failure callback runs for the first attempt in a fallback chain
(has_logged_async_failure is still False at that point), so no explicit
trigger is needed there."""
with patch(
"litellm.router_utils.fallback_event_handlers._trigger_cooldown_for_failed_deployment"
) as mock_trigger:
with pytest.raises(RuntimeError, match="fallback model also failed"):
await run_async_fallback(
litellm_router=self.RouterWithLoggingKwarg(),
fallback_model_group=["fallback-model"],
original_model_group="primary-model",
original_exception=RuntimeError("original request failed"),
max_fallbacks=3,
fallback_depth=0,
litellm_logging_obj=self._logging_obj(has_logged_async_failure=False),
)
mock_trigger.assert_not_called()
@pytest.mark.asyncio
async def test_does_not_trigger_cooldown_when_no_logging_obj_present(self):
with patch(
"litellm.router_utils.fallback_event_handlers._trigger_cooldown_for_failed_deployment"
) as mock_trigger:
with pytest.raises(RuntimeError, match="fallback model also failed"):
await run_async_fallback(
litellm_router=self.RouterWithLoggingKwarg(),
fallback_model_group=["fallback-model"],
original_model_group="primary-model",
original_exception=RuntimeError("original request failed"),
max_fallbacks=3,
fallback_depth=0,
)
mock_trigger.assert_not_called()
@pytest.mark.asyncio
async def test_a_stored_fallback_target_cannot_carry_a_federation_field():
"""A dict fallback target is merged into kwargs, and kwargs beat the deployment's own params,
so a stored key/team/global fallback could otherwise set the workspace a federation token is
minted for. The request itself is already forbidden to carry these, and a stored setting is
not a more trusted source than the request."""
with pytest.raises(
ValueError,
match="server-owned workload identity federation or OAuth token exchange parameter",
):
await run_async_fallback(
litellm_router=FakeRouter(),
fallback_model_group=[{"model": "anthropic-backup", "anthropic_federation_workspace_id": "wrkspc_other"}],
original_model_group="primary-model",
original_exception=RuntimeError("upstream limited request"),
max_fallbacks=3,
fallback_depth=0,
)
@pytest.mark.asyncio
async def test_a_stored_fallback_target_cannot_carry_an_openai_federation_field():
"""The OpenAI identity trio is server-owned for the same reason: a stored fallback target
naming a token file would pick which workload assertion is exchanged for the bearer."""
with pytest.raises(ValueError, match="openai_identity_token_file"):
await run_async_fallback(
litellm_router=FakeRouter(),
fallback_model_group=[
{"model": "openai-backup", "openai_identity_token_file": "/var/run/secrets/tokens/other"}
],
original_model_group="primary-model",
original_exception=RuntimeError("upstream limited request"),
max_fallbacks=3,
fallback_depth=0,
)
@pytest.mark.asyncio
async def test_the_refusal_is_not_swallowed_as_a_fallback_error():
"""Checked before the per-target loop on purpose: inside it, the refusal would be caught as
that target's failure and the run would quietly continue to the next one."""
with pytest.raises(ValueError, match="anthropic_issuer_signing_key_ref"):
await run_async_fallback(
litellm_router=FakeRouter(),
fallback_model_group=[
{"model": "anthropic-backup", "anthropic_issuer_signing_key_ref": "os.environ/ADMIN_KEY"},
"a-perfectly-fine-model",
],
original_model_group="primary-model",
original_exception=RuntimeError("upstream limited request"),
max_fallbacks=3,
fallback_depth=0,
include_fallback_errors=True,
)
async def test_run_async_fallback_stamps_fallback_info_into_metadata():
"""Spend logs are built from the request metadata of the nested call, so the
fallback signal has to be stamped there before recursing."""
router = RecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=["fallback-model"],
original_model_group="primary-model",
original_exception=RuntimeError("original failed"),
max_fallbacks=3,
fallback_depth=0,
)
metadata = router.received_kwargs["metadata"]
assert metadata["attempted_fallbacks"] == 1
assert metadata["original_model_group"] == "primary-model"
assert metadata["model_group"] == "fallback-model"
@pytest.mark.asyncio
async def test_run_async_fallback_preserves_original_model_group_on_nested_fallback():
"""A second-level fallback receives the first fallback target as its
original_model_group argument, so the first-stamped value must survive the hop."""
router = RecordingRouter()
await run_async_fallback(
litellm_router=router,
fallback_model_group=["second-fallback"],
original_model_group="first-fallback",
original_exception=RuntimeError("first fallback failed"),
max_fallbacks=3,
fallback_depth=1,
metadata={"attempted_fallbacks": 1, "original_model_group": "primary-model"},
)
metadata = router.received_kwargs["metadata"]
assert metadata["attempted_fallbacks"] == 2
assert metadata["original_model_group"] == "primary-model"
class TestPreRoutingSelectionCarriesToFallbacks:
"""#38832: a complexity/auto router picks a tier behind the router name, but fallback
lookup kept using the router name, so the tier's configured chain never ran."""
def test_selection_is_recorded_in_the_metadata_bucket(self):
kwargs = {"model": "smart-router", "metadata": {}}
record_pre_routing_selection(kwargs, "tier1")
assert kwargs["metadata"]["pre_routing_selected_model"] == "tier1"
assert get_pre_routing_selection(kwargs) == "tier1"
def test_selection_is_recorded_in_the_litellm_metadata_bucket(self):
kwargs = {"model": "smart-router", "litellm_metadata": {}}
record_pre_routing_selection(kwargs, "tier2")
assert get_pre_routing_selection(kwargs) == "tier2"
def test_a_bucket_survives_the_kwargs_copy_that_fallbacks_run_on(self):
"""The bucket is shared by reference, which is the whole reason this works."""
outer = {"model": "smart-router", "metadata": {}}
inner = {**outer}
record_pre_routing_selection(inner, "tier1")
assert get_pre_routing_selection(outer) == "tier1"
def test_no_selection_reads_as_none(self):
assert get_pre_routing_selection({"model": "smart-router", "metadata": {}}) is None
assert get_pre_routing_selection({"model": "smart-router"}) is None
def test_missing_kwargs_is_a_no_op(self):
"""A caller with no kwargs must not raise, and must not leak the selection anywhere."""
record_pre_routing_selection(None, "tier1")
assert get_pre_routing_selection({}) is None
def test_a_non_dict_bucket_is_ignored(self):
kwargs = {"model": "smart-router", "metadata": "not-a-dict"}
record_pre_routing_selection(kwargs, "tier1")
assert get_pre_routing_selection(kwargs) is None
def test_fallbacks_resolve_against_the_selected_tier(self):
"""The lookup the router performs, keyed on the tier rather than the router name."""
fallbacks = [{"tier1": ["backup-a", "backup-b"]}, {"tier2": ["backup-c"]}]
assert get_fallback_model_group(fallbacks=fallbacks, model_group="tier1")[0] == ["backup-a", "backup-b"]
assert get_fallback_model_group(fallbacks=fallbacks, model_group="smart-router")[0] is None
class TestPreRoutingSelectionIsPerHop:
"""#38832 review: the buckets also carry whatever the caller sent, and a fallback hop
inherits the previous hop's tier, so a hop must start without a selection."""
def test_a_caller_supplied_selection_is_dropped(self):
kwargs = {"model": "plain", "metadata": {"pre_routing_selected_model": "tier1"}}
clear_pre_routing_selection(kwargs)
assert get_pre_routing_selection(kwargs) is None
assert "pre_routing_selected_model" not in kwargs["metadata"]
def test_both_buckets_are_cleared(self):
kwargs = {
"metadata": {"pre_routing_selected_model": "tier1"},
"litellm_metadata": {"pre_routing_selected_model": "tier2"},
}
clear_pre_routing_selection(kwargs)
assert get_pre_routing_selection(kwargs) is None
def test_the_rest_of_the_bucket_is_left_alone(self):
kwargs = {"metadata": {"pre_routing_selected_model": "tier1", "tags": ["a"]}}
clear_pre_routing_selection(kwargs)
assert kwargs["metadata"] == {"tags": ["a"]}
def test_clearing_is_a_no_op_without_a_usable_bucket(self):
kwargs = {"model": "plain", "metadata": "not-a-dict"}
clear_pre_routing_selection(None)
clear_pre_routing_selection(kwargs)
assert kwargs == {"model": "plain", "metadata": "not-a-dict"}
def test_a_selection_recorded_after_clearing_is_kept(self):
"""Clearing runs before routing, so the hook's own write must survive it."""
kwargs = {"model": "smart-router", "metadata": {"pre_routing_selected_model": "stale"}}
clear_pre_routing_selection(kwargs)
record_pre_routing_selection(kwargs, "tier1")
assert get_pre_routing_selection(kwargs) == "tier1"
class TestOrderedFallbackLookupGroups:
def test_tier_first_then_requested_group_deduped(self):
from litellm.router_utils.fallback_event_handlers import (
PRE_ROUTING_SELECTED_MODEL_KEY,
fallback_lookup_groups,
)
kwargs = {"litellm_metadata": {PRE_ROUTING_SELECTED_MODEL_KEY: "tier1"}}
assert fallback_lookup_groups(kwargs, "smart-router") == ("tier1", "smart-router")
assert fallback_lookup_groups(kwargs, "tier1") == ("tier1",)
assert fallback_lookup_groups({}, "smart-router") == ("smart-router",)
assert fallback_lookup_groups({}, None) == ()
def test_session_remap_keeps_the_bound_router_between_tier_and_requested_group(self):
from litellm.router_utils.fallback_event_handlers import (
PRE_ROUTING_SELECTED_MODEL_KEY,
fallback_lookup_groups,
)
kwargs = {
"litellm_metadata": {
PRE_ROUTING_SELECTED_MODEL_KEY: "tier1",
"model_group": "smart-router",
}
}
assert fallback_lookup_groups(kwargs, "requested-model") == (
"tier1",
"smart-router",
"requested-model",
)
assert fallback_lookup_groups({"metadata": {"model_group": []}}, "requested-model") == ("requested-model",)
def test_fallback_hop_resumes_the_original_groups_chain_last(self):
from litellm.router_utils.fallback_event_handlers import fallback_lookup_groups
kwargs = {"metadata": {"model_group": "fb1", "original_model_group": "primary"}}
assert fallback_lookup_groups(kwargs, "fb1") == ("fb1", "primary")
assert fallback_lookup_groups({"metadata": {"original_model_group": 42}}, "fb1") == ("fb1",)
def test_first_resolving_group_wins_and_generic_idx_survives_a_miss(self):
from litellm.router_utils.fallback_event_handlers import (
get_fallback_model_group_for_lookup_groups,
)
fallbacks = [{"tier1": ["backup-a"]}, {"smart-router": ["backup-b"]}, {"*": ["backup-c"]}]
assert get_fallback_model_group_for_lookup_groups(fallbacks, ("tier1", "smart-router")) == (["backup-a"], None)
assert get_fallback_model_group_for_lookup_groups(fallbacks, ("tier9", "smart-router")) == (["backup-b"], None)
assert get_fallback_model_group_for_lookup_groups(fallbacks, ("tier9", "no-such")) == (["backup-c"], 2)
assert get_fallback_model_group_for_lookup_groups([{"tier1": ["backup-a"]}], ("no", "nope")) == (None, None)
class TestHasUnattemptedFallbackTarget:
def test_exhausted_chain_is_not_recoverable_but_a_fresh_entry_is(self):
from litellm.router_utils.fallback_event_handlers import (
has_unattempted_fallback_target,
)
attempted: Final = AttemptedFallbackTargets()
attempted.record("primary")
attempted.record("fb1")
attempted.record("fb2")
assert has_unattempted_fallback_target(["fb1", "fb2"], {"attempted_targets": attempted}) is False
assert has_unattempted_fallback_target(["fb1", "fb3"], {"attempted_targets": attempted}) is True
assert has_unattempted_fallback_target(["fb1"], {}) is True
assert has_unattempted_fallback_target(None, {}) is False
def test_attempted_fallback_targets_deduplicates_skips_in_order_and_keeps_first_reason() -> None:
attempted: Final = AttemptedFallbackTargets()
attempted.record_skip("a", FallbackSkipReason.NOT_AUTHORIZED)
attempted.record_skip("a", FallbackSkipReason.NOT_AUTHORIZED)
attempted.record_skip("b", FallbackSkipReason.OVER_BUDGET)
attempted.record_skip("a", FallbackSkipReason.OVER_BUDGET)
assert attempted.skipped == (
SkippedFallbackTarget("a", FallbackSkipReason.NOT_AUTHORIZED),
SkippedFallbackTarget("b", FallbackSkipReason.OVER_BUDGET),
)
def test_get_fallback_model_group_matches_provider_prefixed_key():
"""A bare model group routed via a wildcard (e.g. "gpt-4o" through
"openai/*") must match a fallback keyed on the provider-prefixed name,
which is the form the Admin UI offers for wildcard routes."""
fallbacks = [{"openai/gpt-4o": ["claude-3-haiku"]}]
fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group="gpt-4o")
assert fallback_model_group == ["claude-3-haiku"]
def test_get_fallback_model_group_exact_match_beats_prefixed_match():
fallbacks = [
{"openai/gpt-4o": ["claude-3-haiku"]},
{"gpt-4o": ["gemini-1.5-flash"]},
]
fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group="gpt-4o")
assert fallback_model_group == ["gemini-1.5-flash"]
def test_get_fallback_model_group_prefixed_match_ignores_unknown_models():
"""Provider inference fails for unknown bare names - the lookup must not
raise and must fall through to the generic fallback."""
fallbacks = [
{"openai/some-model": ["claude-3-haiku"]},
{"*": ["gemini-1.5-flash"]},
]
fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group="some-unknown-model-xyz")
assert fallback_model_group == ["gemini-1.5-flash"]
def test_get_fallback_model_group_prefixed_match_skips_prefixed_model_group():
"""An already-prefixed model group must not double-prefix."""
fallbacks = [{"openai/openai/gpt-4o": ["claude-3-haiku"]}]
fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group="openai/gpt-4o")
assert fallback_model_group is None
def test_get_fallback_model_group_never_resolves_a_provider_without_a_prefixed_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An alias-style group name has no provider, and resolving it prints the SDK's provider-list banner,
so the lookup only infers a provider when some key is spelled <provider>/<group>."""
resolver: Final = MagicMock(return_value=("my-alias", "openai", None, None))
monkeypatch.setattr(get_llm_provider_logic, "get_llm_provider", resolver)
fallbacks: Final = [{"gpt-5.5-pro": ["claude-sonnet-4-6"]}, {"*": ["gpt-5.5-mini"]}]
assert get_fallback_model_group(fallbacks=fallbacks, model_group="my-alias") == (["gpt-5.5-mini"], 1)
resolver.assert_not_called()
def test_mid_stream_fallback_snapshot_kwargs_restores_the_popped_lists_and_shares_the_buckets():
controls: Final = MidStreamFallbackControls(
MappingProxyType({"fallbacks": [{"primary": ["backup"]}], "context_window_fallbacks": None})
)
metadata: Final = {"model_group": "primary"}
kwargs: Final = {"messages": [{"role": "user", "content": "hi"}], "stream": True, "metadata": metadata}
snapshot: Final = mid_stream_fallback_snapshot_kwargs(model="primary", controls=controls, kwargs=kwargs)
assert snapshot == {
**kwargs,
"fallbacks": [{"primary": ["backup"]}],
"context_window_fallbacks": None,
MID_STREAM_FALLBACK_CONTROLS_KEY: controls,
"model": "primary",
}
assert snapshot["metadata"] is metadata
assert "fallbacks" not in kwargs
bare: Final = mid_stream_fallback_snapshot_kwargs(model="primary", controls=None, kwargs=kwargs)
assert "fallbacks" not in bare
assert bare[MID_STREAM_FALLBACK_CONTROLS_KEY] == MidStreamFallbackControls(MappingProxyType({}))
def test_mid_stream_retry_kwargs_strips_what_the_retry_wrapper_pops_and_keeps_the_controls_carrier():
def generic_function(**kwargs) -> None:
return None
def attempt(**kwargs) -> None:
return None
controls = MidStreamFallbackControls(MappingProxyType({"num_retries": 3}))
litellm_metadata = {"model_group": "glm"}
hop_kwargs = {
"model": "glm",
"original_generic_function": generic_function,
"original_function": attempt,
"fallbacks": [{"glm": ["fb"]}],
"context_window_fallbacks": [],
"content_policy_fallbacks": [],
"num_retries": 3,
"model_group_retry_policy": {},
"stream": True,
"litellm_metadata": litellm_metadata,
MID_STREAM_FALLBACK_CONTROLS_KEY: controls,
}
retry_kwargs = mid_stream_retry_kwargs(hop_kwargs)
assert retry_kwargs == {
"model": "glm",
"original_generic_function": generic_function,
"stream": True,
"litellm_metadata": litellm_metadata,
MID_STREAM_FALLBACK_CONTROLS_KEY: controls,
}
assert retry_kwargs["litellm_metadata"] is litellm_metadata
@pytest.mark.parametrize(
"kwargs,expected",
[
pytest.param(
{"litellm_metadata": {"attempted_retries": 2}, "metadata": {"attempted_retries": 5}},
2,
id="litellm_metadata-wins",
),
pytest.param({"metadata": {"attempted_retries": 1}}, 1, id="metadata-bucket"),
pytest.param({"litellm_metadata": {"attempted_retries": "2"}}, 0, id="string-is-not-a-count"),
pytest.param({"litellm_metadata": {"attempted_retries": -1}}, 0, id="negative-is-not-a-count"),
pytest.param({"litellm_metadata": {}}, 0, id="unstamped"),
pytest.param({}, 0, id="no-bucket"),
],
)
def test_attempted_retries_for_request_reads_the_request_bucket(kwargs, expected):
assert attempted_retries_for_request(kwargs) == expected
def test_record_retry_attempt_stamps_the_bucket_the_retry_wrapper_reads():
kwargs = {"litellm_metadata": {"attempted_retries": 0, "max_retries": 2}, "metadata": {}}
record_retry_attempt(kwargs, attempted_retries=1, max_retries=2)
assert kwargs["litellm_metadata"] == {"attempted_retries": 1, "max_retries": 2}
assert kwargs["metadata"] == {}
assert attempted_retries_for_request(kwargs) == 1
assert committed_retry_budget_for_request(kwargs) == 2
@pytest.mark.parametrize(
"kwargs,expected",
[
pytest.param({"litellm_metadata": {"attempted_retries": 1, "max_retries": 3}}, 3, id="committed-by-a-retry"),
pytest.param(
{"litellm_metadata": {"attempted_retries": 0, "max_retries": 3}}, None, id="stamped-before-any-retry"
),
pytest.param(
{"litellm_metadata": {"attempted_retries": 1, "max_retries": "3"}}, None, id="string-is-not-a-budget"
),
pytest.param({"litellm_metadata": {"attempted_retries": 1}}, None, id="no-budget"),
pytest.param({}, None, id="no-bucket"),
],
)
def test_committed_retry_budget_for_request_is_the_budget_a_retry_stamped(kwargs, expected):
assert committed_retry_budget_for_request(kwargs) == expected
def test_carry_over_routed_deployment_copies_model_info_into_the_snapshot():
live_kwargs = {"litellm_metadata": {"model_info": {"id": "dep-1"}, "deployment": "anthropic/glm-a"}}
snapshot = {"litellm_metadata": {"model_group": "glm"}}
carry_over_routed_deployment(live_kwargs=live_kwargs, snapshot=snapshot)
assert snapshot["litellm_metadata"] == {"model_group": "glm", "model_info": {"id": "dep-1"}}
assert snapshot["litellm_metadata"]["model_info"] is not live_kwargs["litellm_metadata"]["model_info"]
assert routed_deployment_id(snapshot) == "dep-1"
def test_carry_over_routed_deployment_leaves_a_snapshot_without_a_bucket_alone():
snapshot = {"model": "glm"}
carry_over_routed_deployment(live_kwargs={"litellm_metadata": {"model_info": {"id": "dep-1"}}}, snapshot=snapshot)
assert snapshot == {"model": "glm"}
assert routed_deployment_id(snapshot) is None
@pytest.fixture()
def _vcr_outcome_gate(request, vcr):
install_live_call_probe(request, vcr)
yield
record_vcr_outcome(request, vcr)
@pytest.fixture(scope="function")
def setup_and_teardown():
"""
This fixture reloads litellm before every function. To speed up testing by removing callbacks being chained.
"""
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
asyncio.run(GLOBAL_LOGGING_WORKER.clear_queue())
importlib.reload(litellm)
try:
if hasattr(litellm, "proxy") and hasattr(litellm.proxy, "proxy_server"):
importlib.reload(litellm.proxy.proxy_server)
except Exception:
pass
loop = asyncio.get_event_loop_policy().new_event_loop()
asyncio.set_event_loop(loop)
yield
loop.close()
asyncio.set_event_loop(None)
REFUSAL_RESPONSE: dict[str, Any] = {
"id": "msg_refusal",
"type": "message",
"role": "assistant",
"model": "claude-fable-5",
"content": [],
"stop_reason": "refusal",
"stop_sequence": None,
"stop_details": {"category": "cyber", "explanation": "flagged"},
"usage": {"input_tokens": 25, "output_tokens": 1},
}
PLAIN_REFUSAL_RESPONSE: dict[str, Any] = {k: v for k, v in REFUSAL_RESPONSE.items() if k != "stop_details"}
OK_RESPONSE: dict[str, Any] = {
"id": "msg_ok",
"type": "message",
"role": "assistant",
"model": "claude-opus-5",
"content": [{"type": "text", "text": "hello"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 25, "output_tokens": 2},
}
def _sse(event: str, data: dict[str, Any]) -> bytes:
return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()
REFUSAL_STREAM_FRAMES: tuple[bytes, ...] = (
_sse("message_start", {"type": "message_start", "message": {**REFUSAL_RESPONSE, "stop_reason": None}}),
_sse(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": "refusal", "stop_details": {"category": "cyber"}},
"usage": {"output_tokens": 1},
},
),
_sse("message_stop", {"type": "message_stop"}),
)
OK_STREAM_FRAMES: tuple[bytes, ...] = (
_sse("message_start", {"type": "message_start", "message": {**OK_RESPONSE, "stop_reason": None}}),
_sse(
"content_block_delta",
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello"}},
),
_sse("message_stop", {"type": "message_stop"}),
)
def _split_frames_mid_data_line(frames: tuple[bytes, ...]) -> tuple[bytes, ...]:
"""Split each frame's data line in half, modeling a transport chunk boundary."""
return tuple(part for frame in frames for part in (frame[: len(frame) // 2], frame[len(frame) // 2 :]))
class _FrameStream(httpx.AsyncByteStream):
def __init__(self, frames: tuple[bytes, ...]) -> None:
self._frames = frames
async def __aiter__(self) -> AsyncIterator[bytes]:
for frame in self._frames:
yield frame
async def aclose(self) -> None:
return None
class FakeAnthropicUpstream:
"""Intercepts the third-party transport (httpx.AsyncClient.send): refuses on fable
models, answers on others. The router deliberately does not forward caller-injected
clients, so the transport is the seam that exercises the real litellm pipeline."""
def __init__(
self,
refusal_body: dict[str, Any] = REFUSAL_RESPONSE,
refusal_frames: tuple[bytes, ...] = REFUSAL_STREAM_FRAMES,
) -> None:
self.refusal_body = refusal_body
self.refusal_frames = refusal_frames
self.calls: list[str] = []
self.bodies: list[dict[str, Any]] = []
async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
body = json.loads(request.content or b"{}")
model = body.get("model", "")
self.calls.append(model)
self.bodies.append(body)
refuses = "fable" in model
if body.get("stream"):
frames = self.refusal_frames if refuses else OK_STREAM_FRAMES
return httpx.Response(
200,
stream=_FrameStream(frames),
headers={"content-type": "text/event-stream"},
request=request,
)
return httpx.Response(200, json=self.refusal_body if refuses else OK_RESPONSE, request=request)
def install(self):
async def _send(_client: httpx.AsyncClient, request: httpx.Request, **kwargs: Any) -> httpx.Response:
return await self.send(request, **kwargs)
return patch("httpx.AsyncClient.send", new=_send)
FABLE_TIER = {
"model_name": "fable-tier",
"litellm_params": {"model": "anthropic/claude-fable-5", "api_key": "sk-test"},
}
OPUS_TARGET = {
"model_name": "opus-target",
"litellm_params": {"model": "anthropic/claude-opus-5", "api_key": "sk-test"},
}
def _router(content_policy_fallbacks: list | None) -> Router:
return Router(model_list=[FABLE_TIER, OPUS_TARGET], content_policy_fallbacks=content_policy_fallbacks)
async def _collect(stream: AsyncIterator[bytes]) -> bytes:
return b"".join([chunk async for chunk in stream])
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_non_streaming_refusal_with_fallback_row_returns_fallback_response():
fake = FakeAnthropicUpstream()
router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}])
with fake.install():
response = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, messages=[{"role": "user", "content": "hi"}]
)
assert response["stop_reason"] == "end_turn"
assert response["id"] == "msg_ok"
assert len(fake.calls) == 2
assert "claude-opus-5" in fake.calls[1]
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"content_policy_fallbacks, upstream_body",
[
(None, REFUSAL_RESPONSE),
([{"unrelated-group": ["opus-target"]}], REFUSAL_RESPONSE),
([{"fable-tier": ["opus-target"]}], PLAIN_REFUSAL_RESPONSE),
],
ids=["nothing-configured", "row-for-other-group", "refusal-without-stop-details"],
)
async def test_non_streaming_refusal_passes_through_untouched(content_policy_fallbacks, upstream_body):
fake = FakeAnthropicUpstream(refusal_body=upstream_body)
router = _router(content_policy_fallbacks=content_policy_fallbacks)
with fake.install():
response = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, messages=[{"role": "user", "content": "hi"}]
)
assert response["stop_reason"] == "refusal"
assert response.get("stop_details") == upstream_body.get("stop_details")
assert len(fake.calls) == 1
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_streaming_refusal_with_fallback_row_streams_fallback_frames():
fake = FakeAnthropicUpstream()
router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}])
with fake.install():
stream = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}]
)
body = await _collect(stream)
assert b'"refusal"' not in body
assert b"text_delta" in body
assert len(fake.calls) == 2
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_streaming_refusal_split_across_chunks_still_falls_back():
fake = FakeAnthropicUpstream(refusal_frames=_split_frames_mid_data_line(REFUSAL_STREAM_FRAMES))
router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}])
with fake.install():
stream = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}]
)
body = await _collect(stream)
assert b'"refusal"' not in body
assert b"text_delta" in body
assert len(fake.calls) == 2
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_streaming_refusal_without_fallback_row_passes_frames_through():
fake = FakeAnthropicUpstream()
router = _router(content_policy_fallbacks=None)
with fake.install():
stream = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}]
)
body = await _collect(stream)
assert b'"stop_reason": "refusal"' in body
assert b"stop_details" in body
assert len(fake.calls) == 1
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_streaming_refusal_on_routed_tier_matches_tier_keyed_row_without_inbound_metadata():
"""The pre-routing hook's tier stamp must reach the mid-stream fallback lookup even when the
request carries no metadata bucket at all (the snapshot is taken before the request runs)."""
fake = FakeAnthropicUpstream()
smart_router = {
"model_name": "smart-router",
"litellm_params": {
"model": "auto_router/complexity_router",
"complexity_router_config": {
"tiers": {"SIMPLE": "fable-tier", "MEDIUM": "fable-tier", "COMPLEX": "fable-tier"}
},
"complexity_router_default_model": "fable-tier",
},
"model_info": {"id": "router-1", "db_model": True},
}
router = Router(
model_list=[FABLE_TIER, OPUS_TARGET, smart_router],
content_policy_fallbacks=[{"fable-tier": ["opus-target"]}],
ignore_invalid_deployments=True,
)
with fake.install():
stream = await router.aanthropic_messages(
model="smart-router", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}]
)
body = await _collect(stream)
assert b'"refusal"' not in body
assert b"text_delta" in body
assert len(fake.calls) == 2
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_caller_forged_tier_stamp_cannot_pick_the_streaming_fallback_chain():
fake = FakeAnthropicUpstream()
router = _router(content_policy_fallbacks=[{"forged-tier": ["opus-target"]}])
with fake.install():
stream = await router.aanthropic_messages(
model="fable-tier",
max_tokens=16,
stream=True,
messages=[{"role": "user", "content": "hi"}],
litellm_metadata={PRE_ROUTING_SELECTED_MODEL_KEY: "forged-tier"},
)
body = await _collect(stream)
assert b'"stop_reason": "refusal"' in body
assert len(fake.calls) == 1
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_tier_stamp_never_reaches_provider_bound_metadata():
"""On /v1/messages the top-level metadata dict is Anthropic's own request field, so the
routed-tier stamp must never appear in any upstream body even when the client sends one."""
fake = FakeAnthropicUpstream()
smart_router = {
"model_name": "smart-router",
"litellm_params": {
"model": "auto_router/complexity_router",
"complexity_router_config": {
"tiers": {"SIMPLE": "fable-tier", "MEDIUM": "fable-tier", "COMPLEX": "fable-tier"}
},
"complexity_router_default_model": "fable-tier",
},
"model_info": {"id": "router-1", "db_model": True},
}
router = Router(
model_list=[FABLE_TIER, OPUS_TARGET, smart_router],
content_policy_fallbacks=[{"fable-tier": ["opus-target"]}],
ignore_invalid_deployments=True,
)
with fake.install():
response = await router.aanthropic_messages(
model="smart-router",
max_tokens=16,
messages=[{"role": "user", "content": "hi"}],
metadata={"user_id": "u1"},
)
assert response["stop_reason"] == "end_turn"
assert len(fake.bodies) == 2
for body in fake.bodies:
assert body.get("metadata") == {"user_id": "u1"}
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_record_pre_routing_selection_writes_only_the_internal_bucket():
"""The Anthropic request's own metadata field must never carry the tier stamp."""
kwargs = {"metadata": {"user_id": "u1"}, "litellm_metadata": {}}
record_pre_routing_selection(kwargs, "tier-x")
assert kwargs["litellm_metadata"] == {PRE_ROUTING_SELECTED_MODEL_KEY: "tier-x"}
assert kwargs["metadata"] == {"user_id": "u1"}
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True], ids=["non-streaming", "streaming"])
async def test_generic_only_row_recovers_safeguard_refusal(stream):
"""With no content-policy list configured, a generic fallback row covers safeguard refusals,
so the dashboard's generic fallbacks work without config-only content_policy rows."""
fake = FakeAnthropicUpstream()
router = Router(model_list=[FABLE_TIER, OPUS_TARGET], fallbacks=[{"fable-tier": ["opus-target"]}])
with fake.install():
response = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, stream=stream, messages=[{"role": "user", "content": "hi"}]
)
body = await _collect(response) if stream else response
if stream:
assert b'"refusal"' not in body
assert b"text_delta" in body
else:
assert body["stop_reason"] == "end_turn"
assert len(fake.calls) == 2
assert "claude-opus-5" in fake.calls[1]
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_configured_content_policy_list_stays_authoritative_over_generic_rows():
fake = FakeAnthropicUpstream()
router = Router(
model_list=[FABLE_TIER, OPUS_TARGET],
fallbacks=[{"fable-tier": ["opus-target"]}],
content_policy_fallbacks=[{"unrelated-group": ["opus-target"]}],
)
with fake.install():
response = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, messages=[{"role": "user", "content": "hi"}]
)
assert response["stop_reason"] == "refusal"
assert len(fake.calls) == 1
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_refusal_fallback_available_arms_on_generic_rows_only_without_content_policy():
router = Router(model_list=[FABLE_TIER, OPUS_TARGET], fallbacks=[{"tier-group": ["opus-target"]}])
stamped = {"litellm_metadata": {PRE_ROUTING_SELECTED_MODEL_KEY: "tier-group"}}
assert router._refusal_fallback_available("router-group", stamped) is True
assert router._refusal_fallback_available("router-group", {}) is False
assert router._refusal_fallback_available("router-group", {"content_policy_fallbacks": [{"other": ["x"]}]}) is False
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_chat_content_filter_gate_unchanged_by_generic_rows():
"""The generic-row arming is scoped to /v1/messages safeguard refusals; the chat surface's
content_filter gate keeps its long-standing content-policy-only semantics."""
from litellm.types.utils import Choices, ModelResponse
router = Router(model_list=[FABLE_TIER, OPUS_TARGET], fallbacks=[{"fable-tier": ["opus-target"]}])
response = ModelResponse(choices=[Choices(finish_reason="content_filter")])
assert router._should_raise_content_policy_error(model="fable-tier", response=response, kwargs={}) is False
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True], ids=["non-streaming", "streaming"])
async def test_disable_fallbacks_returns_the_refusal_instead_of_raising(stream):
"""A request that opted out of fallbacks must receive the provider's refusal response,
never a ContentPolicyViolationError the dispatcher refuses to recover."""
fake = FakeAnthropicUpstream()
router = Router(model_list=[FABLE_TIER, OPUS_TARGET], fallbacks=[{"fable-tier": ["opus-target"]}])
with fake.install():
response = await router.aanthropic_messages(
model="fable-tier",
max_tokens=16,
stream=stream,
disable_fallbacks=True,
messages=[{"role": "user", "content": "hi"}],
)
body = await _collect(response) if stream else response
if stream:
assert b'"stop_reason": "refusal"' in body
else:
assert body["stop_reason"] == "refusal"
assert len(fake.calls) == 1
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_disable_fallbacks_beats_a_content_policy_row_too():
fake = FakeAnthropicUpstream()
router = Router(
model_list=[FABLE_TIER, OPUS_TARGET],
content_policy_fallbacks=[{"fable-tier": ["opus-target"]}],
)
with fake.install():
response = await router.aanthropic_messages(
model="fable-tier",
max_tokens=16,
disable_fallbacks=True,
messages=[{"role": "user", "content": "hi"}],
)
assert response["stop_reason"] == "refusal"
assert len(fake.calls) == 1
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_refusal_gate_keys_on_pre_routing_tier_stamp():
router = _router(content_policy_fallbacks=[{"tier-group": ["opus-target"]}])
def anthropic_messages(**kwargs: Any) -> None:
return None
refusal_kwargs = {"litellm_metadata": {PRE_ROUTING_SELECTED_MODEL_KEY: "tier-group"}}
assert (
router._should_raise_anthropic_refusal_error(
model="router-group",
original_generic_function=anthropic_messages,
response=dict(REFUSAL_RESPONSE),
kwargs=refusal_kwargs,
)
is True
)
assert (
router._should_raise_anthropic_refusal_error(
model="router-group",
original_generic_function=anthropic_messages,
response=dict(REFUSAL_RESPONSE),
kwargs={},
)
is False
)
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_has_content_policy_fallback_default_fallbacks_arm():
router = Router(model_list=[OPUS_TARGET], fallbacks=[{"*": ["opus-target"]}])
assert router._has_content_policy_fallback("any-group", {}) is True
assert router._has_content_policy_fallback("any-group", {"content_policy_fallbacks": [{"other": ["x"]}]}) is False
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_get_fallback_model_group_for_lookup_groups_orders_tier_before_requested():
router = _router(content_policy_fallbacks=None)
fallbacks = [{"tier1": ["backup-a"]}, {"smart-router": ["backup-b"]}]
assert router._get_fallback_model_group_for_lookup_groups(
fallbacks=fallbacks, lookup_groups=("tier1", "smart-router")
) == ["backup-a"]
assert router._get_fallback_model_group_for_lookup_groups(
fallbacks=fallbacks, lookup_groups=("tier9", "smart-router")
) == ["backup-b"]
assert router._get_fallback_model_group_for_lookup_groups(fallbacks=fallbacks, lookup_groups=()) is None
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
def test_refusal_gate_ignores_other_generic_call_types():
router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}])
def aresponses(**kwargs: Any) -> None:
return None
assert (
router._should_raise_anthropic_refusal_error(
model="fable-tier",
original_generic_function=aresponses,
response=dict(REFUSAL_RESPONSE),
kwargs={},
)
is False
)
class FallbackEventLogger(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.success_fallback_events: list[tuple[str, dict[str, object], Exception]] = []
self.failure_fallback_events: list[tuple[str, dict[str, object], Exception]] = []
async def log_success_fallback_event(
self,
original_model_group: str,
kwargs: dict[str, object],
original_exception: Exception,
) -> None:
self.success_fallback_events.append((original_model_group, kwargs, original_exception))
async def log_failure_fallback_event(
self,
original_model_group: str,
kwargs: dict[str, object],
original_exception: Exception,
) -> None:
self.failure_fallback_events.append((original_model_group, kwargs, original_exception))
def _create_fallback_event_router() -> Router:
return Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "gpt-3.5-turbo", "api_key": "test-key"},
},
{
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4", "api_key": "test-key"},
},
],
fallbacks=[{"gpt-3.5-turbo": ["gpt-4"]}],
)
@pytest.mark.parametrize(
"function_name",
["_acompletion", "_atext_completion", "_aembedding"],
)
@pytest.mark.asyncio
async def test_run_async_fallback(function_name):
"""
Basic test - given a list of fallback models, run the original function with the fallback models
"""
router = create_test_router()
original_function = getattr(router, function_name)
litellm.set_verbose = True
fallback_model_group = ["gpt-4"]
original_model_group = "gpt-3.5-turbo"
original_exception = litellm.exceptions.InternalServerError(
message="Simulated error",
llm_provider="openai",
model="gpt-3.5-turbo",
)
request_kwargs = {
"mock_response": "hello this is a test for run_async_fallback",
"metadata": {"previous_models": ["gpt-3.5-turbo"]},
}
if function_name == "_aembedding":
request_kwargs["input"] = "hello this is a test for run_async_fallback"
elif function_name == "_atext_completion":
request_kwargs["prompt"] = "hello this is a test for run_async_fallback"
elif function_name == "_acompletion":
request_kwargs["messages"] = [{"role": "user", "content": "Hello, world!"}]
result = await run_async_fallback(
litellm_router=router,
original_function=original_function,
num_retries=1,
fallback_model_group=fallback_model_group,
original_model_group=original_model_group,
original_exception=original_exception,
max_fallbacks=5,
fallback_depth=0,
**request_kwargs,
)
assert result is not None
if function_name == "_acompletion":
assert isinstance(result, litellm.ModelResponse)
elif function_name == "_atext_completion":
assert isinstance(result, litellm.TextCompletionResponse)
elif function_name == "_aembedding":
assert isinstance(result, litellm.EmbeddingResponse)
@pytest.mark.asyncio
async def test_log_success_fallback_event():
"""
Tests that successful fallback events are logged correctly
"""
original_model_group = "gpt-3.5-turbo"
kwargs = {"messages": [{"role": "user", "content": "Hello, world!"}]}
original_exception = litellm.exceptions.InternalServerError(
message="Simulated error",
llm_provider="openai",
model="gpt-3.5-turbo",
)
logger = CustomTestLogger()
litellm.callbacks = [logger]
# This test mainly checks if the function runs without errors
await log_success_fallback_event(original_model_group, kwargs, original_exception)
await asyncio.sleep(0.5)
assert len(logger.success_fallback_events) == 1
assert len(logger.failure_fallback_events) == 0
assert logger.success_fallback_events[0] == (
original_model_group,
kwargs,
original_exception,
)
@pytest.mark.asyncio
async def test_log_failure_fallback_event():
"""
Tests that failed fallback events are logged correctly
"""
original_model_group = "gpt-3.5-turbo"
kwargs = {"messages": [{"role": "user", "content": "Hello, world!"}]}
original_exception = litellm.exceptions.InternalServerError(
message="Simulated error",
llm_provider="openai",
model="gpt-3.5-turbo",
)
logger = CustomTestLogger()
litellm.callbacks = [logger]
# This test mainly checks if the function runs without errors
await log_failure_fallback_event(original_model_group, kwargs, original_exception)
await asyncio.sleep(0.5)
assert len(logger.failure_fallback_events) == 1
assert len(logger.success_fallback_events) == 0
assert logger.failure_fallback_events[0] == (
original_model_group,
kwargs,
original_exception,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("function_name", ["_acompletion", "_atext_completion"])
async def test_failed_fallbacks_raise_most_recent_exception(function_name):
"""
Tests that if all fallbacks fail, the most recent occuring exception is raised
meaning the exception from the last fallback model is raised
"""
router = create_test_router()
original_function = getattr(router, function_name)
fallback_model_group = ["gpt-4"]
original_model_group = "gpt-3.5-turbo"
original_exception = litellm.exceptions.InternalServerError(
message="Simulated error",
llm_provider="openai",
model="gpt-3.5-turbo",
)
request_kwargs: Dict[str, Any] = {"metadata": {"previous_models": ["gpt-3.5-turbo"]}}
if function_name == "_aembedding":
request_kwargs["input"] = "hello this is a test for run_async_fallback"
elif function_name == "_atext_completion":
request_kwargs["prompt"] = "hello this is a test for run_async_fallback"
elif function_name == "_acompletion":
request_kwargs["messages"] = [{"role": "user", "content": "Hello, world!"}]
with pytest.raises(litellm.exceptions.RateLimitError):
await run_async_fallback(
litellm_router=router,
original_function=original_function,
num_retries=1,
fallback_model_group=fallback_model_group,
original_model_group=original_model_group,
original_exception=original_exception,
mock_response="litellm.RateLimitError",
max_fallbacks=5,
fallback_depth=0,
**request_kwargs,
)
def create_test_router():
return Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_key": os.getenv("OPENAI_API_KEY"),
},
},
{
"model_name": "gpt-4",
"litellm_params": {
"model": "gpt-4",
"api_key": os.getenv("OPENAI_API_KEY"),
},
},
],
fallbacks=[{"gpt-3.5-turbo": ["gpt-4"]}],
)
class CustomTestLogger(CustomLogger):
def __init__(self):
super().__init__()
self.success_fallback_events = []
self.failure_fallback_events = []
async def log_success_fallback_event(self, original_model_group, kwargs, original_exception):
print(
"in log_success_fallback_event for original_model_group: ",
original_model_group,
)
self.success_fallback_events.append((original_model_group, kwargs, original_exception))
async def log_failure_fallback_event(self, original_model_group, kwargs, original_exception):
print(
"in log_failure_fallback_event for original_model_group: ",
original_model_group,
)
self.failure_fallback_events.append((original_model_group, kwargs, original_exception))
def create_test_router_2():
return Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"api_key": os.getenv("OPENAI_API_KEY"),
},
},
{
"model_name": "gpt-4",
"litellm_params": {
"model": "gpt-4",
"api_key": "very-fake-key",
},
},
{
"model_name": "fake-openai-endpoint-2",
"litellm_params": {
"model": "openai/fake-openai-endpoint-2",
"api_key": "working-key-since-this-is-fake-endpoint",
"api_base": FAKE_OPENAI_API_BASE,
},
},
],
)
@pytest.mark.asyncio
@pytest.mark.parametrize("function_name", ["_acompletion", "_atext_completion"])
async def test_multiple_fallbacks(function_name, respx_mock: respx.MockRouter, monkeypatch):
"""
Tests that if multiple fallbacks passed:
- fallback 1 = bad configured deployment / failing endpoint
- fallback 2 = working deployment / working endpoint
Assert that:
- a success response is received from the working endpoint (fallback 2)
"""
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
invalid_key = httpx.Response(
401,
json={
"error": {
"message": "Incorrect API key provided",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
},
)
respx_mock.post("https://api.openai.com/v1/chat/completions").mock(return_value=invalid_key)
respx_mock.post("https://api.openai.com/v1/completions").mock(return_value=invalid_key)
respx_mock.post(f"{FAKE_OPENAI_API_BASE}/chat/completions").mock(
return_value=httpx.Response(
200,
json={
"id": "chatcmpl-fake",
"object": "chat.completion",
"created": 1700000000,
"model": "fake-openai-endpoint-2",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6},
},
)
)
respx_mock.post(f"{FAKE_OPENAI_API_BASE}/completions").mock(
return_value=httpx.Response(
200,
json={
"id": "cmpl-fake",
"object": "text_completion",
"created": 1700000000,
"model": "fake-openai-endpoint-2",
"choices": [{"index": 0, "text": "hi", "logprobs": None, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6},
},
)
)
router_2 = create_test_router_2()
original_function = getattr(router_2, function_name)
fallback_model_group = ["gpt-4", "fake-openai-endpoint-2"]
original_model_group = "gpt-3.5-turbo"
original_exception = Exception("Simulated error")
request_kwargs: dict[str, object] = {"metadata": {"previous_models": ["gpt-3.5-turbo"]}}
if function_name == "_aembedding":
request_kwargs["input"] = "hello this is a test for run_async_fallback"
elif function_name == "_atext_completion":
request_kwargs["prompt"] = "hello this is a test for run_async_fallback"
elif function_name == "_acompletion":
request_kwargs["messages"] = [{"role": "user", "content": "Hello, world!"}]
result = await run_async_fallback(
litellm_router=router_2,
original_function=original_function,
num_retries=1,
fallback_model_group=fallback_model_group,
original_model_group=original_model_group,
original_exception=original_exception,
max_fallbacks=5,
fallback_depth=0,
**request_kwargs,
)
print(result)
print(result._hidden_params)
assert result._hidden_params["api_base"] == FAKE_OPENAI_API_BASE
def _provider_error(status_code: int) -> httpx.Response:
return httpx.Response(status_code=status_code, request=httpx.Request(method="POST", url="https://provider.test"))
@pytest.mark.parametrize(
("exception", "stops_fallbacks"),
[
pytest.param(
litellm.BadRequestError(message="Unsupported value: temperature", model="m", llm_provider="openai"),
True,
id="provider 400",
),
pytest.param(
litellm.UnsupportedParamsError(
message="reasoning_effort is not supported", model="m", llm_provider="openai"
),
True,
id="client-side rejection",
),
pytest.param(
litellm.UnsupportedParamsError(message="stop is not supported"),
True,
id="client-side rejection without a provider",
),
pytest.param(
litellm.BadRequestError(
message="Deployment ptu is reserved for another team", model="ptu", llm_provider=""
),
False,
id="router-raised 400 keeps falling back",
),
pytest.param(
litellm.BadRequestError(
message="VertexAIException BadRequestError - 403",
model="m",
llm_provider="vertex_ai",
response=_provider_error(403),
),
False,
id="vertex 403 wrapped as BadRequestError",
),
pytest.param(
litellm.PaymentRequiredError(message="insufficient credits", model="m", llm_provider="openrouter"),
False,
id="402",
),
pytest.param(
litellm.ContextWindowExceededError(message="maximum context length", model="m", llm_provider="openai"),
False,
id="context window keeps its own fallbacks",
),
pytest.param(
litellm.ContentPolicyViolationError(message="content_policy_violation", model="m", llm_provider="openai"),
False,
id="content policy keeps its own fallbacks",
),
pytest.param(
litellm.InternalServerError(message="overloaded", model="m", llm_provider="openai"),
False,
id="500 still falls back",
),
],
)
def test_is_bad_request_without_fallback_only_stops_on_a_real_400_when_the_flag_is_off(
exception: Exception, stops_fallbacks: bool
) -> None:
assert is_bad_request_without_fallback(exception, fallback_on_unsupported_params=False) is stops_fallbacks
assert is_bad_request_without_fallback(exception, fallback_on_unsupported_params=True) is False