litellm/tests/unit/proxy/test_fallback_management_endpoints.py
devin-ai-integration[bot] 0070ce23b3
fix(router)!: apply content_policy_fallbacks when router settings update at runtime (#45428)
* fix(router): apply content_policy_fallbacks when router settings update at runtime

* test(router): annotate the fallback regression test's locals as Final

* fix(proxy): validate router_settings.content_policy_fallbacks on /config/update like its siblings

* test(proxy): annotate the content_policy_fallbacks config update test locals as Final

* fix(proxy): keep litellm_settings content policy fallbacks, accept dict targets, skip invalid stored rules

A runtime content_policy_fallbacks rule now layers over the litellm_settings rules instead of replacing
them, so models the runtime rules do not name keep their file fallbacks. Fallback targets may be
deployment dicts as well as model names. A stored rule that fails validation is logged and skipped at
sync, so the other router settings still apply. Per-user token discovery now reads every fallback key
from key and team router_settings.

* fix(proxy): validate content policy fallback rules one by one and keep a config-owned list alone

An invalid stored rule is now skipped on its own instead of dropping every runtime rule, a router_settings list from the config file stays the router's only rules across DB syncs, and GET /fallback names the model of a dict target.

* fix(proxy): answer an error for a string-valued fallback rule on GET /fallback instead of its characters

* fix(proxy): keep config-file content-policy rules as Router init accepted them and log a skipped rule once per change

* fix(proxy): log a skipped content-policy rule whenever the saved rules change

* fix(proxy): keep general fallbacks when no content-policy rule is saved, name a model-less target's model, and take only model names in key rules

* fix(proxy): reject an empty dict target in a key's content-policy rule

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-30 04:27:11 -07:00

1177 lines
48 KiB
Python

"""
Tests for fallback management endpoints
Tests:
1. Create fallback configuration
2. Get fallback configuration
3. Delete fallback configuration
4. Validation tests (invalid models, duplicate fallbacks, etc.)
"""
import copy
import json
from collections.abc import AsyncIterator, Mapping
from types import MappingProxyType, SimpleNamespace
from typing import Final, Literal, get_args
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from pydantic import JsonValue, TypeAdapter
from redis.exceptions import NoPermissionError
import litellm
from litellm import Router
from litellm.proxy.config_resolvers.settings_rules import Section
from litellm.proxy.management_endpoints.fallback_management_endpoints import (
FallbackCreateRequest,
FallbackDeleteResponse,
FallbackResponse,
create_fallback,
delete_fallback,
get_fallback,
)
from litellm.proxy.proxy_server import ProxyConfig
from litellm.proxy.utils import evict_config_param, get_config_param, litellm_config_cache
from litellm.router_api import ChangeSet
from litellm.router_api import RouterSettings as RouterSettingsSnapshot
from litellm.types.router import DeploymentTypedDict, LiteLLMParamsTypedDict
@pytest.fixture(autouse=True)
async def empty_config_cache() -> AsyncIterator[None]:
for section in get_args(Section):
await evict_config_param(section)
yield
for section in get_args(Section):
await evict_config_param(section)
class TestFallbackCreateRequest:
"""Test the FallbackCreateRequest validation"""
def test_valid_request(self):
"""Test valid fallback request"""
request = FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=["gpt-4", "claude-3-haiku"],
fallback_type="general",
)
assert request.model == "gpt-3.5-turbo"
assert request.fallback_models == ["gpt-4", "claude-3-haiku"]
assert request.fallback_type == "general"
def test_default_fallback_type(self):
"""Test default fallback type is 'general'"""
request = FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=["gpt-4"],
)
assert request.fallback_type == "general"
def test_empty_fallback_models(self):
"""Test that empty fallback_models raises validation error"""
with pytest.raises(ValueError, match="at least 1 item"):
FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=[],
)
def test_duplicate_fallback_models(self):
"""Test that duplicate fallback models raise validation error"""
with pytest.raises(
ValueError, match="fallback_models must not contain duplicates"
):
FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=["gpt-4", "gpt-4"],
)
def test_empty_model_name(self):
"""Test that empty model name raises validation error"""
with pytest.raises(ValueError, match="model must be a non-empty string"):
FallbackCreateRequest(
model="",
fallback_models=["gpt-4"],
)
def test_whitespace_model_name(self):
"""Test that whitespace-only model name raises validation error"""
with pytest.raises(ValueError, match="model must be a non-empty string"):
FallbackCreateRequest(
model=" ",
fallback_models=["gpt-4"],
)
def test_model_name_trimmed(self):
"""Test that model name is trimmed"""
request = FallbackCreateRequest(
model=" gpt-3.5-turbo ",
fallback_models=["gpt-4"],
)
assert request.model == "gpt-3.5-turbo"
def test_context_window_fallback_type(self):
"""Test context_window fallback type"""
request = FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=["gpt-4-32k"],
fallback_type="context_window",
)
assert request.fallback_type == "context_window"
def test_content_policy_fallback_type(self):
"""Test content_policy fallback type"""
request = FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=["gpt-4"],
fallback_type="content_policy",
)
assert request.fallback_type == "content_policy"
TEAM_ID: Final = "team-a"
PRIMARY_INTERNAL_NAME: Final = f"model_name_{TEAM_ID}_1a6437cb-4cab-432c-8099-1d7411731a8b"
FALLBACK_INTERNAL_NAME: Final = f"model_name_{TEAM_ID}_83151607-5556-4bbf-ac65-c474dbc64eba"
PRIMARY_DEPLOYMENT_ID: Final = "team-primary-id"
FALLBACK_DEPLOYMENT_ID: Final = "team-fallback-id"
FallbackRules = list[dict[str, list[str]] | dict[str, object] | str]
RouterSettings = dict[str, FallbackRules | str | None]
_ROUTER_SETTINGS_JSON: Final = TypeAdapter(dict[str, JsonValue])
def _litellm_params(mock_response: str | None) -> LiteLLMParamsTypedDict:
if mock_response is None:
return {"model": "openai/gpt-5.4-mini", "api_key": "fake"}
return {"model": "openai/gpt-5.4-mini", "api_key": "fake", "mock_response": mock_response}
def _team_scoped_deployment(
internal_name: str, public_name: str, deployment_id: str, mock_response: str | None = None
) -> DeploymentTypedDict:
return {
"model_name": internal_name,
"litellm_params": _litellm_params(mock_response),
"model_info": {"id": deployment_id, "team_id": TEAM_ID, "team_public_model_name": public_name},
}
def _team_router(primary_mock_response: str | None = None, fallback_mock_response: str | None = None) -> Router:
return Router(
model_list=[
{"model_name": "gpt-5.4-mini", "litellm_params": _litellm_params(None)},
_team_scoped_deployment(
PRIMARY_INTERNAL_NAME, "team-primary", PRIMARY_DEPLOYMENT_ID, primary_mock_response
),
_team_scoped_deployment(
FALLBACK_INTERNAL_NAME, "team-fallback", FALLBACK_DEPLOYMENT_ID, fallback_mock_response
),
],
num_retries=0,
)
class TestCreateFallbackForTeamScopedModels:
"""POST /fallback takes the public name a caller invokes a team-scoped model by, not only the stored internal one"""
@pytest.fixture
def router(self) -> Router:
return _team_router()
@pytest.fixture
def prisma_client(self) -> MagicMock:
client: Final = MagicMock()
client.writer_db = client.db
client.db.litellm_config.find_unique = AsyncMock(return_value=None)
client.db.litellm_config.upsert = AsyncMock()
client.get_generic_data = AsyncMock(return_value=None)
return client
@pytest.fixture
def proxy_config(self) -> ProxyConfig:
return _ConfigFileProxyConfig()
async def _create(
self, request: FallbackCreateRequest, router: Router, prisma_client: MagicMock, proxy_config: ProxyConfig
) -> FallbackResponse:
with (
patch("litellm.proxy.proxy_server.llm_router", router),
patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
patch("litellm.proxy.proxy_server.proxy_config", proxy_config),
patch("litellm.proxy.proxy_server.store_model_in_db", True),
):
return await create_fallback(request, MagicMock())
async def test_public_names_create_a_rule_keyed_on_the_public_name(
self, router: Router, prisma_client: MagicMock, proxy_config: MagicMock
) -> None:
request: Final = FallbackCreateRequest(model="team-primary", fallback_models=["team-fallback"])
response: Final = await self._create(request, router, prisma_client, proxy_config)
assert response.model == "team-primary"
assert response.fallback_models == ["team-fallback"]
assert router.settings().fallbacks == [{"team-primary": ["team-fallback"]}]
persisted: Final = json.loads(
prisma_client.db.litellm_config.upsert.call_args.kwargs["data"]["create"]["param_value"]
)
assert persisted["fallbacks"] == [{"team-primary": ["team-fallback"]}]
async def test_internal_names_keep_working(
self, router: Router, prisma_client: MagicMock, proxy_config: MagicMock
) -> None:
request: Final = FallbackCreateRequest(model=PRIMARY_INTERNAL_NAME, fallback_models=[FALLBACK_INTERNAL_NAME])
response: Final = await self._create(request, router, prisma_client, proxy_config)
assert router.settings().fallbacks == [{PRIMARY_INTERNAL_NAME: [FALLBACK_INTERNAL_NAME]}]
assert response.model == PRIMARY_INTERNAL_NAME
async def test_public_fallback_target_behind_a_gateway_primary(
self, router: Router, prisma_client: MagicMock, proxy_config: MagicMock
) -> None:
request: Final = FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"])
await self._create(request, router, prisma_client, proxy_config)
assert router.settings().fallbacks == [{"gpt-5.4-mini": ["team-fallback"]}]
async def test_unknown_name_is_rejected_and_the_error_names_the_public_names(
self, router: Router, prisma_client: MagicMock, proxy_config: MagicMock
) -> None:
request: Final = FallbackCreateRequest(model="team-missing", fallback_models=["team-fallback"])
with pytest.raises(HTTPException) as exc_info:
await self._create(request, router, prisma_client, proxy_config)
assert exc_info.value.status_code == 404
assert {"team-primary", "team-fallback", "gpt-5.4-mini"} <= set(exc_info.value.detail["available_models"])
async def test_a_team_request_fails_over_to_the_public_name_fallback(
self, prisma_client: MagicMock, proxy_config: MagicMock
) -> None:
router: Final = _team_router(primary_mock_response="litellm.RateLimitError", fallback_mock_response="pong")
request: Final = FallbackCreateRequest(model="team-primary", fallback_models=["team-fallback"])
await self._create(request, router, prisma_client, proxy_config)
response: Final = await router.acompletion(
model="team-primary",
messages=[{"role": "user", "content": "ping"}],
metadata={"user_api_key_team_id": TEAM_ID},
)
assert response.choices[0].message.content == "pong"
assert response._hidden_params["model_id"] == FALLBACK_DEPLOYMENT_ID
def _config_row(router_settings: RouterSettings) -> SimpleNamespace:
return SimpleNamespace(param_name="router_settings", param_value=router_settings)
class _ConfigFileProxyConfig(ProxyConfig):
def __init__(self, config_file: RouterSettings | None = None) -> None:
super().__init__()
self.config_file: RouterSettings = config_file or {}
self.router_settings.load_yaml(_ROUTER_SETTINGS_JSON.validate_python(self.config_file))
async def _get_config_from_file(self, config_file_path: str | None = None) -> dict[str, RouterSettings]:
return {"router_settings": copy.deepcopy(self.config_file)}
class _StoredRouterSettings:
"""The LiteLLM_Config router_settings row, with the proxy objects that read it through the config cache"""
def __init__(
self,
router_settings: RouterSettings | None,
config_file: RouterSettings | None = None,
*,
lagging_replica: bool = False,
) -> None:
self.row: SimpleNamespace | None = None if router_settings is None else _config_row(router_settings)
self.prisma_client: Final = MagicMock()
self.prisma_client.get_generic_data = AsyncMock(
side_effect=lambda value, **_: self.row if value == "router_settings" else None
)
self.prisma_client.writer_db.litellm_config.find_unique = AsyncMock(
side_effect=lambda **_: copy.deepcopy(self.row)
)
self.prisma_client.db.litellm_config.find_unique = AsyncMock(
side_effect=lambda **_: None if lagging_replica else copy.deepcopy(self.row)
)
self.prisma_client.db.litellm_config.upsert = AsyncMock(side_effect=self._upsert)
self.proxy_config: Final = _ConfigFileProxyConfig(config_file)
async def _upsert(self, where: dict[str, str], data: dict[str, dict[str, str]]) -> None:
self.row = _config_row(json.loads(data["update"]["param_value"]))
def written_by_another_instance(self, router_settings: RouterSettings) -> None:
self.row = _config_row(router_settings)
def stored_fallbacks(self) -> FallbackRules:
assert self.row is not None
return self.row.param_value["fallbacks"]
async def cached_fallbacks(self) -> FallbackRules:
row: Final = await get_config_param(self.prisma_client, "router_settings")
return row.param_value["fallbacks"]
TEAM_RULE: Final = {"team-primary": ["team-fallback"]}
GATEWAY_RULE: Final = {"gpt-5.4-mini": ["team-fallback"]}
MALFORMED_GATEWAY_RULE: Final = {"gpt-5.4-mini": "team-fallback"}
NON_STANDARD_RULES: Final = [
"claude-3-haiku",
{"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "retry"}]},
]
class _RedisThatRefusesDel:
def __init__(self) -> None:
self.values: Mapping[str, object] = MappingProxyType({})
async def async_get_cache(self, key: str, parent_otel_span: object = None) -> object:
return self.values.get(key)
async def async_set_cache(self, key: str, value: object, **_: object) -> None:
self.values = MappingProxyType({**self.values, key: value})
async def async_delete_cache(self, key: str) -> None:
raise NoPermissionError("this user has no permissions to run the 'del' command")
class TestFallbackWritesSeeTheLatestStoredRules:
"""A write reads the rules the database holds now and leaves no stale copy in the config cache behind"""
async def _create(self, request: FallbackCreateRequest, stored: _StoredRouterSettings) -> FallbackResponse:
with (
patch("litellm.proxy.proxy_server.llm_router", _team_router()),
patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client),
patch("litellm.proxy.proxy_server.proxy_config", stored.proxy_config),
patch("litellm.proxy.proxy_server.store_model_in_db", True),
):
return await create_fallback(request, MagicMock())
async def _delete(self, model: str, stored: _StoredRouterSettings) -> FallbackDeleteResponse:
with (
patch("litellm.proxy.proxy_server.llm_router", _team_router()),
patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client),
patch("litellm.proxy.proxy_server.proxy_config", stored.proxy_config),
patch("litellm.proxy.proxy_server.store_model_in_db", True),
):
return await delete_fallback(model, "general", MagicMock())
async def test_a_create_reads_the_rules_from_the_writer_while_the_replica_lags(self) -> None:
stored: Final = _StoredRouterSettings({"fallbacks": [TEAM_RULE]}, lagging_replica=True)
await self._create(FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"]), stored)
assert stored.stored_fallbacks() == [TEAM_RULE, GATEWAY_RULE]
async def test_a_second_create_keeps_the_first_rule(self) -> None:
stored: Final = _StoredRouterSettings(None)
await self._create(FallbackCreateRequest(model="team-primary", fallback_models=["team-fallback"]), stored)
await self._create(FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"]), stored)
assert stored.stored_fallbacks() == [TEAM_RULE, GATEWAY_RULE]
assert await stored.cached_fallbacks() == [TEAM_RULE, GATEWAY_RULE]
async def test_a_second_create_keeps_the_first_rule_when_redis_refuses_to_evict_the_cached_settings(self) -> None:
stored: Final = _StoredRouterSettings(None)
with patch.object(litellm_config_cache, "redis_cache", _RedisThatRefusesDel()):
await self._create(FallbackCreateRequest(model="team-primary", fallback_models=["team-fallback"]), stored)
await self._create(FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"]), stored)
await self._delete("team-primary", stored)
await self._create(FallbackCreateRequest(model="team-primary", fallback_models=["team-fallback"]), stored)
assert stored.stored_fallbacks() == [GATEWAY_RULE, TEAM_RULE]
async def test_content_policy_writes_keep_the_litellm_settings_rules_for_other_models(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "content_policy_fallbacks", [TEAM_RULE])
router: Final = _team_router()
stored: Final = _StoredRouterSettings(None)
with (
patch("litellm.proxy.proxy_server.llm_router", router),
patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client),
patch("litellm.proxy.proxy_server.proxy_config", stored.proxy_config),
patch("litellm.proxy.proxy_server.store_model_in_db", True),
):
await create_fallback(
FallbackCreateRequest(
model="gpt-5.4-mini", fallback_models=["team-fallback"], fallback_type="content_policy"
),
MagicMock(),
)
after_create: Final = await get_fallback("team-primary", "content_policy", MagicMock())
await delete_fallback("gpt-5.4-mini", "content_policy", MagicMock())
after_delete: Final = await get_fallback("team-primary", "content_policy", MagicMock())
assert after_create.fallback_models == ["team-fallback"]
assert after_delete.fallback_models == ["team-fallback"]
async def test_a_content_policy_write_applies_beside_an_invalid_stored_rule(self) -> None:
router: Final = _team_router()
stored: Final = _StoredRouterSettings({"content_policy_fallbacks": [MALFORMED_GATEWAY_RULE]})
with (
patch("litellm.proxy.proxy_server.llm_router", router),
patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client),
patch("litellm.proxy.proxy_server.proxy_config", stored.proxy_config),
patch("litellm.proxy.proxy_server.store_model_in_db", True),
):
await create_fallback(
FallbackCreateRequest(
model="team-primary", fallback_models=["team-fallback"], fallback_type="content_policy"
),
MagicMock(),
)
after_create: Final = await get_fallback("team-primary", "content_policy", MagicMock())
assert after_create.fallback_models == ["team-fallback"]
@pytest.mark.parametrize(
("fallback_type", "router_setting"),
[("general", "fallbacks"), ("content_policy", "content_policy_fallbacks")],
)
async def test_get_names_the_model_of_a_dict_target(
self, fallback_type: Literal["general", "content_policy"], router_setting: str
) -> None:
router: Final = _team_router()
router.update_settings(
**{router_setting: [{"team-primary": [{"model": "gpt-5.4-mini", "api_key": "sk-test"}, "team-fallback"]}]}
)
with patch("litellm.proxy.proxy_server.llm_router", router):
response: Final = await get_fallback("team-primary", fallback_type, MagicMock())
assert response.fallback_models == ["gpt-5.4-mini", "team-fallback"]
@pytest.mark.parametrize(
("fallback_type", "router_setting"),
[("general", "fallbacks"), ("content_policy", "content_policy_fallbacks")],
)
async def test_get_names_the_rules_own_model_for_a_dict_target_that_names_none(
self, fallback_type: Literal["general", "content_policy"], router_setting: str
) -> None:
router: Final = _team_router()
router.update_settings(**{router_setting: [{"team-primary": [{"temperature": 0}, "team-fallback"]}]})
with patch("litellm.proxy.proxy_server.llm_router", router):
response: Final = await get_fallback("team-primary", fallback_type, MagicMock())
assert response.fallback_models == ["team-primary", "team-fallback"]
async def test_get_answers_an_error_for_a_string_valued_rule_instead_of_its_characters(self) -> None:
router: Final = _team_router()
router.update_settings(fallbacks=[{"team-primary": "team-fallback"}])
with patch("litellm.proxy.proxy_server.llm_router", router), pytest.raises(HTTPException) as raised:
await get_fallback("team-primary", "general", MagicMock())
assert raised.value.status_code == 500
async def test_a_create_keeps_a_rule_another_instance_stored_since_this_one_last_read(self) -> None:
stored: Final = _StoredRouterSettings({})
await get_config_param(stored.prisma_client, "router_settings")
stored.written_by_another_instance({"fallbacks": [TEAM_RULE]})
await self._create(FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"]), stored)
assert stored.stored_fallbacks() == [TEAM_RULE, GATEWAY_RULE]
async def test_a_delete_keeps_a_rule_another_instance_stored_since_this_one_last_read(self) -> None:
stored: Final = _StoredRouterSettings({"fallbacks": [TEAM_RULE]})
await get_config_param(stored.prisma_client, "router_settings")
stored.written_by_another_instance({"fallbacks": [TEAM_RULE, GATEWAY_RULE]})
await self._delete("team-primary", stored)
assert stored.stored_fallbacks() == [GATEWAY_RULE]
assert await stored.cached_fallbacks() == [GATEWAY_RULE]
async def test_writes_keep_the_non_standard_rules_the_router_accepts(self) -> None:
stored: Final = _StoredRouterSettings({"fallbacks": [*NON_STANDARD_RULES, TEAM_RULE]})
await self._create(FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"]), stored)
assert stored.stored_fallbacks() == [*NON_STANDARD_RULES, TEAM_RULE, GATEWAY_RULE]
await self._delete("team-primary", stored)
assert stored.stored_fallbacks() == [*NON_STANDARD_RULES, GATEWAY_RULE]
async def test_a_create_replaces_a_same_key_rule_whatever_its_targets_shape(self) -> None:
stored: Final = _StoredRouterSettings({"fallbacks": [MALFORMED_GATEWAY_RULE, TEAM_RULE]})
await self._create(FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"]), stored)
assert stored.stored_fallbacks() == [GATEWAY_RULE, TEAM_RULE]
async def test_a_delete_removes_a_same_key_rule_whatever_its_targets_shape(self) -> None:
stored: Final = _StoredRouterSettings({"fallbacks": [MALFORMED_GATEWAY_RULE, TEAM_RULE]})
await self._delete("gpt-5.4-mini", stored)
assert stored.stored_fallbacks() == [TEAM_RULE]
async def test_a_create_treats_a_null_rule_list_as_empty(self) -> None:
stored: Final = _StoredRouterSettings({"fallbacks": None})
await self._create(FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"]), stored)
assert stored.stored_fallbacks() == [GATEWAY_RULE]
@pytest.mark.parametrize("param_value", [None, ["team-primary"], 7, "not json"])
async def test_a_create_replaces_a_stored_row_that_is_not_an_object(self, param_value: JsonValue) -> None:
stored: Final = _StoredRouterSettings(None)
stored.row = SimpleNamespace(param_name="router_settings", param_value=param_value)
await self._create(FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"]), stored)
assert stored.row.param_value == {"fallbacks": [GATEWAY_RULE]}
CONFIG_RULE: Final = {"gpt-5.4-mini": ["team-fallback"]}
CONFIG_WINDOW_RULE: Final = {"team-primary": ["gpt-5.4-mini"]}
class TestFallbackWritesHonourTheConfigFile:
"""A fallbacks list set in config.yaml owns the key: /fallback writes that would change it are refused and
the router keeps serving the file's rules"""
@pytest.fixture(autouse=True)
async def clean_config_cache(self) -> AsyncIterator[None]:
await evict_config_param("router_settings")
yield
await evict_config_param("router_settings")
@pytest.fixture
def router(self) -> Router:
return _team_router()
@pytest.fixture
def stored(self) -> _StoredRouterSettings:
return _StoredRouterSettings({"fallbacks": [CONFIG_RULE]}, config_file={"fallbacks": [CONFIG_RULE]})
async def _create(
self, request: FallbackCreateRequest, router: Router, stored: _StoredRouterSettings
) -> FallbackResponse:
with (
patch("litellm.proxy.proxy_server.llm_router", router),
patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client),
patch("litellm.proxy.proxy_server.proxy_config", stored.proxy_config),
patch("litellm.proxy.proxy_server.store_model_in_db", True),
):
return await create_fallback(request, MagicMock())
async def _delete(self, model: str, router: Router, stored: _StoredRouterSettings) -> FallbackDeleteResponse:
with (
patch("litellm.proxy.proxy_server.llm_router", router),
patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client),
patch("litellm.proxy.proxy_server.proxy_config", stored.proxy_config),
patch("litellm.proxy.proxy_server.store_model_in_db", True),
):
return await delete_fallback(model, "general", MagicMock())
async def test_a_create_that_adds_a_rule_to_a_config_owned_list_is_refused(
self, router: Router, stored: _StoredRouterSettings
) -> None:
router.apply(ChangeSet(assign={"fallbacks": [CONFIG_RULE]}))
request: Final = FallbackCreateRequest(model="team-primary", fallback_models=["team-fallback"])
with pytest.raises(HTTPException) as refused:
await self._create(request, router, stored)
assert refused.value.status_code == 400
assert "key 'fallbacks' is set in the config file" in str(refused.value.detail)
assert stored.stored_fallbacks() == [CONFIG_RULE]
stored.prisma_client.db.litellm_config.upsert.assert_not_awaited()
assert router.settings().fallbacks == [CONFIG_RULE]
async def test_a_create_after_the_config_file_takes_over_the_key_is_refused(self, router: Router) -> None:
stored: Final = _StoredRouterSettings({"fallbacks": [TEAM_RULE]})
stored.proxy_config.config_file = {"fallbacks": [CONFIG_RULE]}
request: Final = FallbackCreateRequest(model="team-primary", fallback_models=["gpt-5.4-mini"])
with pytest.raises(HTTPException) as refused:
await self._create(request, router, stored)
assert refused.value.status_code == 400
assert "key 'fallbacks' is set in the config file" in str(refused.value.detail)
assert stored.stored_fallbacks() == [TEAM_RULE]
async def test_a_delete_of_a_config_owned_rule_is_refused(
self, router: Router, stored: _StoredRouterSettings
) -> None:
router.apply(ChangeSet(assign={"fallbacks": [CONFIG_RULE]}))
with pytest.raises(HTTPException) as refused:
await self._delete("gpt-5.4-mini", router, stored)
assert refused.value.status_code == 400
assert "key 'fallbacks' is set in the config file" in str(refused.value.detail)
assert stored.stored_fallbacks() == [CONFIG_RULE]
stored.prisma_client.db.litellm_config.upsert.assert_not_awaited()
assert router.settings().fallbacks == [CONFIG_RULE]
async def test_a_delete_of_a_rule_only_the_config_file_holds_is_refused(self, router: Router) -> None:
stored: Final = _StoredRouterSettings(None, config_file={"fallbacks": [CONFIG_RULE]})
router.fallbacks = [CONFIG_RULE]
with pytest.raises(HTTPException) as refused:
await self._delete("gpt-5.4-mini", router, stored)
assert refused.value.status_code == 400
assert "key 'fallbacks' is set in the config file" in str(refused.value.detail)
assert stored.row is None
assert router.fallbacks == [CONFIG_RULE]
async def test_writes_to_a_config_file_env_reference_that_resolves_to_nothing_are_refused(
self, router: Router, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("LITELLM_TEST_UNSET_FALLBACKS", raising=False)
stored: Final = _StoredRouterSettings(None, config_file={"fallbacks": "os.environ/LITELLM_TEST_UNSET_FALLBACKS"})
with pytest.raises(HTTPException) as refused:
await self._create(FallbackCreateRequest(model="team-primary", fallback_models=["team-fallback"]), router, stored)
with pytest.raises(HTTPException) as not_found:
await self._delete("team-primary", router, stored)
assert refused.value.status_code == 400
assert "key 'fallbacks' is set in the config file" in str(refused.value.detail)
assert not_found.value.status_code == 404
assert stored.row is None
async def test_a_create_that_restates_one_of_several_config_rules_writes_nothing(self, router: Router) -> None:
stored: Final = _StoredRouterSettings(None, config_file={"fallbacks": [CONFIG_RULE, CONFIG_WINDOW_RULE]})
request: Final = FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"])
response: Final = await self._create(request, router, stored)
assert response.fallback_models == ["team-fallback"]
assert stored.row is None
assert router.fallbacks == [CONFIG_RULE, CONFIG_WINDOW_RULE]
async def test_a_content_policy_restate_keeps_the_config_list_without_the_litellm_settings_rules(
self, router: Router, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "content_policy_fallbacks", [TEAM_RULE])
stored: Final = _StoredRouterSettings(None, config_file={"content_policy_fallbacks": [CONFIG_RULE]})
request: Final = FallbackCreateRequest(
model="gpt-5.4-mini", fallback_models=["team-fallback"], fallback_type="content_policy"
)
response: Final = await self._create(request, router, stored)
assert response.fallback_models == ["team-fallback"]
assert stored.row is None
assert router.content_policy_fallbacks == [CONFIG_RULE]
async def test_a_create_that_restates_a_config_rule_next_to_an_env_reference_writes_nothing(
self, router: Router, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_TEST_FALLBACK_MODEL", "team-fallback")
env_rule: Final = {"team-primary": "os.environ/LITELLM_TEST_FALLBACK_MODEL"}
stored: Final = _StoredRouterSettings(None, config_file={"fallbacks": [CONFIG_RULE, env_rule]})
request: Final = FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"])
response: Final = await self._create(request, router, stored)
assert response.fallback_models == ["team-fallback"]
assert stored.row is None
async def test_a_create_that_restates_the_config_rule_is_accepted(
self, router: Router, stored: _StoredRouterSettings
) -> None:
request: Final = FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"])
response: Final = await self._create(request, router, stored)
assert response.fallback_models == ["team-fallback"]
assert stored.stored_fallbacks() == [CONFIG_RULE]
async def test_a_write_stores_only_the_database_rules_never_the_config_files_router_settings(
self, router: Router
) -> None:
stored: Final = _StoredRouterSettings(
{"fallbacks": [TEAM_RULE]}, config_file={"routing_strategy": "latency-based-routing"}
)
await self._create(FallbackCreateRequest(model="gpt-5.4-mini", fallback_models=["team-fallback"]), router, stored)
assert stored.row is not None
assert stored.row.param_value == {"fallbacks": [TEAM_RULE, GATEWAY_RULE]}
await self._delete("team-primary", router, stored)
assert stored.row.param_value == {"fallbacks": [GATEWAY_RULE]}
async def test_a_sibling_list_the_config_file_does_not_set_stays_writable(
self, router: Router, stored: _StoredRouterSettings
) -> None:
request: Final = FallbackCreateRequest(
model="team-primary", fallback_models=["gpt-5.4-mini"], fallback_type="context_window"
)
response: Final = await self._create(request, router, stored)
assert response.fallback_type == "context_window"
assert stored.row is not None
assert stored.row.param_value["context_window_fallbacks"] == [CONFIG_WINDOW_RULE]
assert router.settings().context_window_fallbacks == [CONFIG_WINDOW_RULE]
@pytest.mark.asyncio
class TestCreateFallback:
"""Test the create_fallback endpoint"""
@pytest.fixture
def mock_router(self):
"""Create a mock router"""
router = MagicMock()
names: Final = {
"group_set": {"gpt-3.5-turbo", "gpt-4", "claude-3-haiku"},
"team_public": frozenset(),
}
router.registry.return_value.names.side_effect = lambda model_name=None, *, kind="groups", **_: names[kind]
router.settings.return_value = RouterSettingsSnapshot(
fallbacks=[], context_window_fallbacks=[], content_policy_fallbacks=[]
)
return router
@pytest.fixture
def mock_prisma_client(self):
"""Create a mock prisma client"""
client = MagicMock()
client.writer_db = client.db
client.db.litellm_config.find_unique = AsyncMock(return_value=None)
client.db.litellm_config.upsert = AsyncMock()
client.get_generic_data = AsyncMock(return_value=None)
client.jsonify_object = lambda x: x
return client
@pytest.fixture
def mock_proxy_config(self):
"""Create a mock proxy config"""
return _ConfigFileProxyConfig()
@pytest.fixture
def mock_user_api_key_dict(self):
"""Create a mock user API key dict"""
return MagicMock()
async def test_create_fallback_success(
self, mock_router, mock_prisma_client, mock_proxy_config, mock_user_api_key_dict
):
"""Test successful fallback creation"""
request = FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=["gpt-4", "claude-3-haiku"],
fallback_type="general",
)
with (
patch(
"litellm.proxy.proxy_server.llm_router",
mock_router,
),
patch(
"litellm.proxy.proxy_server.prisma_client",
mock_prisma_client,
),
patch(
"litellm.proxy.proxy_server.proxy_config",
mock_proxy_config,
),
patch(
"litellm.proxy.proxy_server.store_model_in_db",
True,
),
):
response = await create_fallback(request, mock_user_api_key_dict)
assert response.model == "gpt-3.5-turbo"
assert response.fallback_models == ["gpt-4", "claude-3-haiku"]
assert response.fallback_type == "general"
assert (
"created" in response.message.lower()
or "updated" in response.message.lower()
)
# Verify database was updated
mock_prisma_client.db.litellm_config.upsert.assert_called_once()
async def test_create_fallback_router_not_initialized(
self, mock_prisma_client, mock_proxy_config, mock_user_api_key_dict
):
"""Test error when router is not initialized"""
request = FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=["gpt-4"],
)
with (
patch(
"litellm.proxy.proxy_server.llm_router",
None,
),
pytest.raises(HTTPException) as exc_info,
):
await create_fallback(request, mock_user_api_key_dict)
assert exc_info.value.status_code == 500
assert "Router not initialized" in str(exc_info.value.detail)
async def test_create_fallback_model_not_found(
self, mock_router, mock_prisma_client, mock_proxy_config, mock_user_api_key_dict
):
"""Test error when model is not found in router"""
request = FallbackCreateRequest(
model="invalid-model",
fallback_models=["gpt-4"],
)
with (
patch(
"litellm.proxy.proxy_server.llm_router",
mock_router,
),
patch(
"litellm.proxy.proxy_server.prisma_client",
mock_prisma_client,
),
patch(
"litellm.proxy.proxy_server.store_model_in_db",
True,
),
pytest.raises(HTTPException) as exc_info,
):
await create_fallback(request, mock_user_api_key_dict)
assert exc_info.value.status_code == 404
assert "not found in router" in str(exc_info.value.detail)
async def test_create_fallback_invalid_fallback_model(
self, mock_router, mock_prisma_client, mock_proxy_config, mock_user_api_key_dict
):
"""Test error when fallback model is not found in router"""
request = FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=["invalid-fallback-model"],
)
with (
patch(
"litellm.proxy.proxy_server.llm_router",
mock_router,
),
patch(
"litellm.proxy.proxy_server.prisma_client",
mock_prisma_client,
),
patch(
"litellm.proxy.proxy_server.store_model_in_db",
True,
),
pytest.raises(HTTPException) as exc_info,
):
await create_fallback(request, mock_user_api_key_dict)
assert exc_info.value.status_code == 400
assert "Invalid fallback models" in str(exc_info.value.detail)
async def test_create_fallback_model_is_own_fallback(
self, mock_router, mock_prisma_client, mock_proxy_config, mock_user_api_key_dict
):
"""Test error when model is its own fallback"""
request = FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=["gpt-3.5-turbo", "gpt-4"],
)
with (
patch(
"litellm.proxy.proxy_server.llm_router",
mock_router,
),
patch(
"litellm.proxy.proxy_server.prisma_client",
mock_prisma_client,
),
patch(
"litellm.proxy.proxy_server.store_model_in_db",
True,
),
pytest.raises(HTTPException) as exc_info,
):
await create_fallback(request, mock_user_api_key_dict)
assert exc_info.value.status_code == 400
assert "cannot be its own fallback" in str(exc_info.value.detail)
async def test_create_fallback_db_not_enabled(
self, mock_router, mock_user_api_key_dict
):
"""Test error when database storage is not enabled"""
request = FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=["gpt-4"],
)
with (
patch(
"litellm.proxy.proxy_server.llm_router",
mock_router,
),
patch(
"litellm.proxy.proxy_server.store_model_in_db",
False,
),
pytest.raises(HTTPException) as exc_info,
):
await create_fallback(request, mock_user_api_key_dict)
assert exc_info.value.status_code == 400
assert "Database storage not enabled" in str(exc_info.value.detail)
async def test_create_fallback_context_window_type(
self, mock_router, mock_prisma_client, mock_proxy_config, mock_user_api_key_dict
):
"""Test creating context_window fallback"""
request = FallbackCreateRequest(
model="gpt-3.5-turbo",
fallback_models=["gpt-4"],
fallback_type="context_window",
)
with (
patch(
"litellm.proxy.proxy_server.llm_router",
mock_router,
),
patch(
"litellm.proxy.proxy_server.prisma_client",
mock_prisma_client,
),
patch(
"litellm.proxy.proxy_server.proxy_config",
mock_proxy_config,
),
patch(
"litellm.proxy.proxy_server.store_model_in_db",
True,
),
):
response = await create_fallback(request, mock_user_api_key_dict)
assert response.fallback_type == "context_window"
# Verify the correct setting was updated
mock_router.apply.assert_called_once_with(
ChangeSet(assign={"context_window_fallbacks": [{"gpt-3.5-turbo": ["gpt-4"]}]})
)
@pytest.mark.asyncio
class TestGetFallback:
"""Test the get_fallback endpoint"""
@pytest.fixture
def mock_router_with_fallbacks(self):
"""Create a mock router with fallbacks configured"""
router = MagicMock()
router.settings.return_value = RouterSettingsSnapshot(
fallbacks=[{"gpt-3.5-turbo": ["gpt-4", "claude-3-haiku"]}],
context_window_fallbacks=[],
content_policy_fallbacks=[],
)
return router
@pytest.fixture
def mock_user_api_key_dict(self):
"""Create a mock user API key dict"""
return MagicMock()
async def test_get_fallback_success(
self, mock_router_with_fallbacks, mock_user_api_key_dict
):
"""Test successful fallback retrieval"""
with patch(
"litellm.proxy.proxy_server.llm_router",
mock_router_with_fallbacks,
):
response = await get_fallback(
"gpt-3.5-turbo", "general", mock_user_api_key_dict
)
assert response.model == "gpt-3.5-turbo"
assert response.fallback_models == ["gpt-4", "claude-3-haiku"]
assert response.fallback_type == "general"
async def test_get_fallback_not_found(
self, mock_router_with_fallbacks, mock_user_api_key_dict
):
"""Test error when fallback is not found"""
with (
patch(
"litellm.proxy.proxy_server.llm_router",
mock_router_with_fallbacks,
),
pytest.raises(HTTPException) as exc_info,
):
await get_fallback("gpt-4", "general", mock_user_api_key_dict)
assert exc_info.value.status_code == 404
assert "No general fallbacks configured" in str(exc_info.value.detail)
async def test_get_fallback_router_not_initialized(self, mock_user_api_key_dict):
"""Test error when router is not initialized"""
with (
patch(
"litellm.proxy.proxy_server.llm_router",
None,
),
pytest.raises(HTTPException) as exc_info,
):
await get_fallback("gpt-3.5-turbo", "general", mock_user_api_key_dict)
assert exc_info.value.status_code == 500
assert "Router not initialized" in str(exc_info.value.detail)
@pytest.mark.asyncio
class TestDeleteFallback:
"""Test the delete_fallback endpoint"""
@pytest.fixture
def mock_router_with_fallbacks(self):
"""Create a mock router with fallbacks configured"""
router = MagicMock()
router.settings.return_value = RouterSettingsSnapshot(
fallbacks=[{"gpt-3.5-turbo": ["gpt-4", "claude-3-haiku"]}],
context_window_fallbacks=[],
content_policy_fallbacks=[],
)
return router
@pytest.fixture
def mock_prisma_client(self):
"""Create a mock prisma client"""
client = MagicMock()
client.writer_db = client.db
client.db.litellm_config.find_unique = AsyncMock(
return_value=_config_row({"fallbacks": [{"gpt-3.5-turbo": ["gpt-4", "claude-3-haiku"]}]})
)
client.db.litellm_config.upsert = AsyncMock()
client.get_generic_data = AsyncMock(return_value=None)
client.jsonify_object = lambda x: x
return client
@pytest.fixture
def mock_proxy_config(self):
"""Create a mock proxy config"""
return _ConfigFileProxyConfig()
@pytest.fixture
def mock_user_api_key_dict(self):
"""Create a mock user API key dict"""
return MagicMock()
async def test_delete_fallback_success(
self,
mock_router_with_fallbacks,
mock_prisma_client,
mock_proxy_config,
mock_user_api_key_dict,
):
"""Test successful fallback deletion"""
with (
patch(
"litellm.proxy.proxy_server.llm_router",
mock_router_with_fallbacks,
),
patch(
"litellm.proxy.proxy_server.prisma_client",
mock_prisma_client,
),
patch(
"litellm.proxy.proxy_server.proxy_config",
mock_proxy_config,
),
patch(
"litellm.proxy.proxy_server.store_model_in_db",
True,
),
):
response = await delete_fallback(
"gpt-3.5-turbo", "general", mock_user_api_key_dict
)
assert response.model == "gpt-3.5-turbo"
assert response.fallback_type == "general"
assert "deleted" in response.message.lower()
# Verify database was updated
mock_prisma_client.db.litellm_config.upsert.assert_called_once()
async def test_delete_fallback_not_found(
self,
mock_router_with_fallbacks,
mock_prisma_client,
mock_proxy_config,
mock_user_api_key_dict,
):
"""Test error when fallback to delete is not found"""
with (
patch(
"litellm.proxy.proxy_server.llm_router",
mock_router_with_fallbacks,
),
patch(
"litellm.proxy.proxy_server.prisma_client",
mock_prisma_client,
),
patch(
"litellm.proxy.proxy_server.proxy_config",
mock_proxy_config,
),
patch(
"litellm.proxy.proxy_server.store_model_in_db",
True,
),
pytest.raises(HTTPException) as exc_info,
):
await delete_fallback("gpt-4", "general", mock_user_api_key_dict)
assert exc_info.value.status_code == 404
assert "No general fallbacks configured" in str(exc_info.value.detail)
async def test_delete_fallback_router_not_initialized(self, mock_user_api_key_dict):
"""Test error when router is not initialized"""
with (
patch(
"litellm.proxy.proxy_server.llm_router",
None,
),
pytest.raises(HTTPException) as exc_info,
):
await delete_fallback("gpt-3.5-turbo", "general", mock_user_api_key_dict)
assert exc_info.value.status_code == 500
assert "Router not initialized" in str(exc_info.value.detail)
async def test_delete_fallback_db_not_enabled(
self, mock_router_with_fallbacks, mock_user_api_key_dict
):
"""Test error when database storage is not enabled"""
with (
patch(
"litellm.proxy.proxy_server.llm_router",
mock_router_with_fallbacks,
),
patch(
"litellm.proxy.proxy_server.store_model_in_db",
False,
),
pytest.raises(HTTPException) as exc_info,
):
await delete_fallback("gpt-3.5-turbo", "general", mock_user_api_key_dict)
assert exc_info.value.status_code == 400
assert "Database storage not enabled" in str(exc_info.value.detail)