From 14d0136f4e278307891ea497be3bac3070dd3819 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:03:54 -0700 Subject: [PATCH 1/4] fix(router): apply content_policy_fallbacks when router settings update at runtime --- litellm/constants.py | 1 + tests/unit/test_router/test_router.py | 33 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/litellm/constants.py b/litellm/constants.py index c4adc0de22f..6c0111c07c2 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -24,6 +24,7 @@ RUNTIME_UPDATABLE_ROUTER_SETTINGS: Final[frozenset[str]] = frozenset( "retry_after", "fallbacks", "context_window_fallbacks", + "content_policy_fallbacks", "retry_policy", "model_group_retry_policy", "model_group_alias", diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index cd490e127cf..d4166231579 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -2361,6 +2361,39 @@ def test_update_settings_model_group_alias_drops_cached_group_info(): assert after.input_cost_per_token is not None and after.input_cost_per_token > 0 +@pytest.mark.parametrize( + "fallback_kind, mock_testing_param", + [ + ("fallbacks", "mock_testing_fallbacks"), + ("context_window_fallbacks", "mock_testing_context_fallbacks"), + ("content_policy_fallbacks", "mock_testing_content_policy_fallbacks"), + ], +) +async def test_update_settings_fallback_rule_of_every_kind_is_used_on_its_error( + fallback_kind: str, mock_testing_param: str +): + router = Router( + model_list=[ + { + "model_name": "primary", + "litellm_params": {"model": "gpt-5.4-mini", "api_key": "fake", "mock_response": "primary answered"}, + }, + { + "model_name": "backup", + "litellm_params": {"model": "gpt-5.4-nano", "api_key": "fake", "mock_response": "backup answered"}, + }, + ], + num_retries=0, + ) + + router.update_settings(**{fallback_kind: [{"primary": ["backup"]}]}) + + response = await router.acompletion( + model="primary", messages=[{"role": "user", "content": "hi"}], **{mock_testing_param: True} + ) + assert response.choices[0].message.content == "backup answered" + + _PAID_INPUT_COST_PER_TOKEN: Final = 3e-06 _PAID_OUTPUT_COST_PER_TOKEN: Final = 1.5e-05 From 9c1919a3278151051aca9d2b9b5e9b5486eefb5b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:45:21 -0700 Subject: [PATCH 2/4] test(router): annotate the fallback regression test's locals as Final --- tests/unit/test_router/test_router.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index d4166231579..c14ddb9e734 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -2372,7 +2372,7 @@ def test_update_settings_model_group_alias_drops_cached_group_info(): async def test_update_settings_fallback_rule_of_every_kind_is_used_on_its_error( fallback_kind: str, mock_testing_param: str ): - router = Router( + router: Final = Router( model_list=[ { "model_name": "primary", @@ -2388,7 +2388,7 @@ async def test_update_settings_fallback_rule_of_every_kind_is_used_on_its_error( router.update_settings(**{fallback_kind: [{"primary": ["backup"]}]}) - response = await router.acompletion( + response: Final = await router.acompletion( model="primary", messages=[{"role": "user", "content": "hi"}], **{mock_testing_param: True} ) assert response.choices[0].message.content == "backup answered" From 9c88a086a636bbae2e6e58e27544b66c8c986f0f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:45:10 -0700 Subject: [PATCH 3/4] fix(proxy): validate router_settings.content_policy_fallbacks on /config/update like its siblings --- litellm/types/router.py | 1 + .../test_proxy_server_endpoints_and_startup.py | 15 +++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/litellm/types/router.py b/litellm/types/router.py index a66c4571b39..756e36d2fc7 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -181,6 +181,7 @@ class UpdateRouterConfig(LiteLLMBaseModel): retry_after: float | None = None fallbacks: list[dict] | None = None context_window_fallbacks: list[dict] | None = None + content_policy_fallbacks: Sequence[Mapping[str, Sequence[str]]] | None = None model_group_alias: dict[str, str | dict] | None = Field(default={}) enable_tag_filtering: bool | None = None weights: RouterWeights | None = None diff --git a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py index 920f9d90bb8..8c6822f85bb 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -10115,6 +10115,21 @@ def test_update_config_accepts_disjoint_routing_groups(_update_config_setup): restore() +def test_update_config_validates_content_policy_fallbacks_like_fallbacks(_update_config_setup): + client, prisma, restore = _update_config_setup(initial_rows={"router_settings": {"num_retries": 2}}) + rule: Final = [{"gpt-5.4-mini": ["gpt-5.4-nano"]}] + try: + for malformed in ("oops", 5, [1, 2], [{"gpt-5.4-mini": "gpt-5.4-nano"}], [{"gpt-5.4-mini": ["gpt-5.4-nano", 7]}]): + rejected = client.post("/config/update", json={"router_settings": {"content_policy_fallbacks": malformed}}) + assert rejected.status_code == 422, malformed + assert prisma.db.litellm_config.rows["router_settings"] == {"num_retries": 2} + accepted = client.post("/config/update", json={"router_settings": {"content_policy_fallbacks": rule}}) + assert accepted.status_code == 200 + assert prisma.db.litellm_config.rows["router_settings"] == {"num_retries": 2, "content_policy_fallbacks": rule} + finally: + restore() + + def test_update_config_env_var_round_trip_not_double_encrypted(_update_config_setup, monkeypatch): """Endpoint-level regression for the /config/update double-encryption bug. diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 769798b5134..ccefbd7901b 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -49206,6 +49206,10 @@ export interface components { UpdateRouterConfig: { /** Allowed Fails */ allowed_fails?: number | null; + /** Content Policy Fallbacks */ + content_policy_fallbacks?: { + [key: string]: string[]; + }[] | null; /** Context Window Fallbacks */ context_window_fallbacks?: { [key: string]: unknown; From 5f9aa12b71ed2a8d899d4867a2cd9f954b8cb92e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:58:32 -0700 Subject: [PATCH 4/4] test(proxy): annotate the content_policy_fallbacks config update test locals as Final --- .../proxy/test_proxy_server_endpoints_and_startup.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py index 8c6822f85bb..5a6f4c957aa 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -10118,12 +10118,15 @@ def test_update_config_accepts_disjoint_routing_groups(_update_config_setup): def test_update_config_validates_content_policy_fallbacks_like_fallbacks(_update_config_setup): client, prisma, restore = _update_config_setup(initial_rows={"router_settings": {"num_retries": 2}}) rule: Final = [{"gpt-5.4-mini": ["gpt-5.4-nano"]}] + malformed: Final = ("oops", 5, [1, 2], [{"gpt-5.4-mini": "gpt-5.4-nano"}], [{"gpt-5.4-mini": ["gpt-5.4-nano", 7]}]) try: - for malformed in ("oops", 5, [1, 2], [{"gpt-5.4-mini": "gpt-5.4-nano"}], [{"gpt-5.4-mini": ["gpt-5.4-nano", 7]}]): - rejected = client.post("/config/update", json={"router_settings": {"content_policy_fallbacks": malformed}}) - assert rejected.status_code == 422, malformed + rejected: Final = tuple( + client.post("/config/update", json={"router_settings": {"content_policy_fallbacks": value}}).status_code + for value in malformed + ) + assert rejected == (422,) * len(malformed) assert prisma.db.litellm_config.rows["router_settings"] == {"num_retries": 2} - accepted = client.post("/config/update", json={"router_settings": {"content_policy_fallbacks": rule}}) + accepted: Final = client.post("/config/update", json={"router_settings": {"content_policy_fallbacks": rule}}) assert accepted.status_code == 200 assert prisma.db.litellm_config.rows["router_settings"] == {"num_retries": 2, "content_policy_fallbacks": rule} finally: