diff --git a/litellm/router.py b/litellm/router.py
index 6e8127110cc..159e8a7d55e 100644
--- a/litellm/router.py
+++ b/litellm/router.py
@@ -683,6 +683,7 @@ class Router:
self.alerting_config: Optional[AlertingConfig] = alerting_config
+ self.optional_pre_call_checks: OptionalPreCallChecks = []
if optional_pre_call_checks is not None:
self.add_optional_pre_call_checks(optional_pre_call_checks)
@@ -1504,6 +1505,8 @@ class Router:
if optional_pre_call_checks is None:
return
+ self.optional_pre_call_checks = list(dict.fromkeys([*self.optional_pre_call_checks, *optional_pre_call_checks]))
+
# ---------------------------------------------------------------------
# Unified deployment affinity (session stickiness)
# ---------------------------------------------------------------------
@@ -9686,6 +9689,8 @@ class Router:
"retry_policy",
"model_group_alias",
"enable_weighted_failover",
+ "default_litellm_params",
+ "optional_pre_call_checks",
]
for var in vars_to_include:
@@ -9722,6 +9727,8 @@ class Router:
"model_group_retry_policy",
"model_group_alias",
"enable_weighted_failover",
+ "default_litellm_params",
+ "optional_pre_call_checks",
]
_int_settings = [
@@ -9749,6 +9756,12 @@ class Router:
value = RetryPolicy(**value)
if value is None or isinstance(value, RetryPolicy):
setattr(self, var, value)
+ elif var == "default_litellm_params":
+ self.default_litellm_params = {**self.default_litellm_params, **kwargs[var]}
+ elif var == "optional_pre_call_checks":
+ new_checks = [check for check in kwargs[var] if check not in self.optional_pre_call_checks]
+ if new_checks:
+ self.add_optional_pre_call_checks(new_checks)
else:
value = kwargs[var]
# only run routing strategy init if it has changed
diff --git a/litellm/types/management_endpoints/router_settings_endpoints.py b/litellm/types/management_endpoints/router_settings_endpoints.py
index 929f2fde132..dfb513cdd5f 100644
--- a/litellm/types/management_endpoints/router_settings_endpoints.py
+++ b/litellm/types/management_endpoints/router_settings_endpoints.py
@@ -224,9 +224,37 @@ ROUTER_SETTINGS_FIELDS: List[RouterSettingsField] = [
field_name="default_litellm_params",
field_type="Dictionary",
field_value=None,
- field_description="Default parameters for Router.chat.completion.create",
+ field_description=(
+ "Default parameters for Router.chat.completion.create. E.g. set "
+ "cache_control_injection_points here to enable Anthropic/Bedrock "
+ "prompt caching for every model on this proxy."
+ ),
field_default=None,
ui_field_name="Default LiteLLM Params",
+ link="https://docs.litellm.ai/docs/tutorials/claude_code_prompt_cache_routing",
+ ),
+ RouterSettingsField(
+ field_name="optional_pre_call_checks",
+ field_type="List",
+ field_value=None,
+ field_description=(
+ "Extra checks the router runs before picking a deployment. Add "
+ "'prompt_caching' to route repeat requests back to the deployment "
+ "that cached the prompt."
+ ),
+ field_default=[],
+ options=[
+ "prompt_caching",
+ "router_budget_limiting",
+ "responses_api_deployment_check",
+ "deployment_affinity",
+ "session_affinity",
+ "forward_client_headers_by_model_group",
+ "enforce_model_rate_limits",
+ "encrypted_content_affinity",
+ ],
+ ui_field_name="Optional Pre-call Checks",
+ link="https://docs.litellm.ai/docs/tutorials/claude_code_prompt_cache_routing",
),
RouterSettingsField(
field_name="set_verbose",
diff --git a/litellm/types/router.py b/litellm/types/router.py
index 3bedd97c20c..2eff564fca3 100644
--- a/litellm/types/router.py
+++ b/litellm/types/router.py
@@ -26,6 +26,19 @@ class ConfigurableClientsideParamsCustomAuth(TypedDict):
CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = Optional[List[Union[str, ConfigurableClientsideParamsCustomAuth]]]
+OptionalPreCallChecks = List[
+ Literal[
+ "prompt_caching",
+ "router_budget_limiting",
+ "responses_api_deployment_check",
+ "deployment_affinity",
+ "session_affinity",
+ "forward_client_headers_by_model_group",
+ "enforce_model_rate_limits",
+ "encrypted_content_affinity",
+ ]
+]
+
class ModelConfig(BaseModel):
model_name: str
@@ -117,6 +130,8 @@ class UpdateRouterConfig(BaseModel):
fallbacks: Optional[List[dict]] = None
context_window_fallbacks: Optional[List[dict]] = None
model_group_alias: Optional[Dict[str, Union[str, Dict]]] = {}
+ default_litellm_params: Optional[Dict[str, Any]] = None
+ optional_pre_call_checks: Optional[OptionalPreCallChecks] = None
model_config = ConfigDict(protected_namespaces=())
@@ -768,20 +783,6 @@ class GenericBudgetWindowDetails(BaseModel):
ttl_seconds: int
-OptionalPreCallChecks = List[
- Literal[
- "prompt_caching",
- "router_budget_limiting",
- "responses_api_deployment_check",
- "deployment_affinity",
- "session_affinity",
- "forward_client_headers_by_model_group",
- "enforce_model_rate_limits",
- "encrypted_content_affinity",
- ]
-]
-
-
class LiteLLM_RouterFileObject(TypedDict, total=False):
"""
Tracking the litellm params hash, used for mapping the file id to the right model
diff --git a/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py
index b62f077a62e..348d74a36fd 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py
@@ -78,6 +78,25 @@ class TestRouterSettingsEndpoints:
assert isinstance(routing_strategy_field["options"], list)
assert len(routing_strategy_field["options"]) > 0
+ @pytest.mark.asyncio
+ async def test_get_router_fields_includes_optional_pre_call_checks(self):
+ """
+ Regression test: `optional_pre_call_checks` (e.g. "prompt_caching", used for
+ Claude Code prompt cache routing) must be exposed as a configurable field so
+ the Admin UI can render and save it, not just `default_litellm_params`.
+ """
+ response = client.get(
+ "/router/fields", headers={"Authorization": "Bearer sk-1234"}
+ )
+ assert response.status_code == 200
+
+ fields = response.json()["fields"]
+ field = next(
+ (f for f in fields if f["field_name"] == "optional_pre_call_checks"), None
+ )
+ assert field is not None
+ assert "prompt_caching" in field["options"]
+
@pytest.mark.asyncio
async def test_get_router_settings_includes_routing_groups_from_live_router(
self, monkeypatch
diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py
index 9c4d83ff7ea..f1e85e6723a 100644
--- a/tests/test_litellm/test_router.py
+++ b/tests/test_litellm/test_router.py
@@ -5304,3 +5304,100 @@ class TestRouterRequestTimeoutPropagation:
)
== 60
)
+
+
+def _make_router_for_settings_tests(**kwargs):
+ return litellm.Router(
+ model_list=[
+ {
+ "model_name": "gpt-3.5-turbo",
+ "litellm_params": {
+ "model": "azure/gpt-4.1-mini",
+ "api_key": "fake-key",
+ "api_base": "https://fake.openai.azure.com",
+ },
+ }
+ ],
+ **kwargs,
+ )
+
+
+def test_update_settings_merges_default_litellm_params_without_dropping_existing_keys():
+ """
+ Regression test: `update_settings(default_litellm_params=...)` must merge into
+ the existing dict, not replace it wholesale. A naive `setattr` replace would
+ silently drop keys the Router set at init (e.g. `timeout`, `max_retries`,
+ `metadata`) whenever an admin edits `default_litellm_params` from the UI to
+ add something like `cache_control_injection_points`.
+ """
+ router = _make_router_for_settings_tests(timeout=42)
+ assert router.default_litellm_params.get("timeout") == 42
+
+ router.update_settings(
+ default_litellm_params={
+ "cache_control_injection_points": [
+ {"location": "message", "role": "system"}
+ ]
+ }
+ )
+
+ assert router.default_litellm_params["timeout"] == 42
+ assert router.default_litellm_params["cache_control_injection_points"] == [
+ {"location": "message", "role": "system"}
+ ]
+
+
+def test_update_settings_optional_pre_call_checks_is_idempotent():
+ """
+ Regression test: `add_optional_pre_call_checks` has no built-in guard against
+ registering the same check twice (unlike `router_budget_limiting`, which
+ checks for an existing budget limiter). `_add_router_settings_from_db_config`
+ re-applies the full `optional_pre_call_checks` list on every config sync, so
+ without diffing against already-applied checks in `update_settings`, saving
+ the same setting twice from the UI would register a second
+ `PromptCachingDeploymentCheck` callback on every save.
+ """
+ from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import (
+ PromptCachingDeploymentCheck,
+ )
+
+ router = _make_router_for_settings_tests()
+ assert router.optional_pre_call_checks == []
+
+ router.update_settings(optional_pre_call_checks=["prompt_caching"])
+ assert router.optional_pre_call_checks == ["prompt_caching"]
+ prompt_caching_callbacks = [
+ cb
+ for cb in (router.optional_callbacks or [])
+ if isinstance(cb, PromptCachingDeploymentCheck)
+ ]
+ assert len(prompt_caching_callbacks) == 1
+
+ # Re-applying the same setting (e.g. a second Save click, or the periodic
+ # config-sync re-running update_settings with the combined config) must not
+ # register a duplicate callback.
+ router.update_settings(optional_pre_call_checks=["prompt_caching"])
+ assert router.optional_pre_call_checks == ["prompt_caching"]
+ prompt_caching_callbacks = [
+ cb
+ for cb in (router.optional_callbacks or [])
+ if isinstance(cb, PromptCachingDeploymentCheck)
+ ]
+ assert len(prompt_caching_callbacks) == 1
+
+
+def test_get_settings_includes_default_litellm_params_and_optional_pre_call_checks():
+ """
+ Regression test: the Admin UI's Router Settings page reads its current
+ values from `Router.get_settings()` (via `GET /get/config/callbacks`). If a
+ setting isn't in `get_settings()`'s `vars_to_include`, it can never be
+ displayed or edited from the UI even though the Router attribute exists.
+ """
+ router = _make_router_for_settings_tests()
+ router.update_settings(optional_pre_call_checks=["prompt_caching"])
+
+ settings = router.get_settings()
+
+ assert settings["optional_pre_call_checks"] == ["prompt_caching"]
+ assert "default_litellm_params" in settings
+ assert isinstance(settings["default_litellm_params"], dict)
diff --git a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
index 94cbb94d164..67f4ada046b 100644
--- a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
+++ b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx
@@ -187,4 +187,38 @@ describe("RouterSettings", () => {
});
expect(NotificationsManager.success).not.toHaveBeenCalled();
});
+
+ it("should round-trip default_litellm_params and optional_pre_call_checks as JSON on save", async () => {
+ // Regression test: these two fields hold dicts/lists (e.g. cache_control_injection_points,
+ // ["prompt_caching"]), not plain strings. Without listing them in the save handler's
+ // jsonKeys set, they'd be persisted as raw stringified text instead of parsed JSON,
+ // silently corrupting the setting the next time the router reads it.
+ vi.mocked(getCallbacksCall).mockResolvedValue({
+ router_settings: {
+ ...mockCallbacksResponse.router_settings,
+ default_litellm_params: { cache_control_injection_points: [{ location: "message", role: "system" }] },
+ optional_pre_call_checks: ["prompt_caching"],
+ },
+ });
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("strategy-select")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() =>
+ expect(setCallbacksCall).toHaveBeenCalledWith(
+ "test-token",
+ expect.objectContaining({
+ router_settings: expect.objectContaining({
+ default_litellm_params: { cache_control_injection_points: [{ location: "message", role: "system" }] },
+ optional_pre_call_checks: ["prompt_caching"],
+ }),
+ }),
+ ),
+ );
+ });
});
diff --git a/ui/litellm-dashboard/src/components/router_settings/index.tsx b/ui/litellm-dashboard/src/components/router_settings/index.tsx
index aea08425388..c229b3e65ee 100644
--- a/ui/litellm-dashboard/src/components/router_settings/index.tsx
+++ b/ui/litellm-dashboard/src/components/router_settings/index.tsx
@@ -50,6 +50,7 @@ const RouterSettings: React.FC = ({ accessToken, userRole,
fieldsMap[field.field_name] = {
ui_field_name: field.ui_field_name,
field_description: field.field_description,
+ field_type: field.field_type,
options: field.options,
link: field.link,
};
@@ -87,7 +88,7 @@ const RouterSettings: React.FC = ({ accessToken, userRole,
const router_settings = formValue.routerSettings;
const numberKeys = new Set(["allowed_fails", "cooldown_time", "num_retries", "timeout", "retry_after"]);
- const jsonKeys = new Set(["model_group_alias"]);
+ const jsonKeys = new Set(["model_group_alias", "default_litellm_params", "optional_pre_call_checks"]);
// retry_policy and model_group_retry_policy are owned by the Model Retry Settings tab;
// routing_groups is owned by the Routing Groups tab. This page must not read or write them.
const tabOwnedKeys = new Set(["retry_policy", "model_group_retry_policy", "routing_groups"]);