diff --git a/litellm/__init__.py b/litellm/__init__.py
index ccfbf80369f..97457f3e3cc 100644
--- a/litellm/__init__.py
+++ b/litellm/__init__.py
@@ -219,6 +219,7 @@ redact_user_api_key_info: Optional[bool] = False
# major release; opt in early with `litellm.expose_router_debug_in_errors
# = False`.
expose_router_debug_in_errors: bool = True
+model_access_denied_message: str | None = None
filter_invalid_headers: Optional[bool] = False
add_user_information_to_llm_headers: Optional[bool] = (
None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers
diff --git a/litellm/constants.py b/litellm/constants.py
index ba5ec73d435..9a9bb59d8b2 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -98,6 +98,7 @@ BASE64_TRUNCATION_OFFLOAD_THRESHOLD_CHARS: Final = 256 * 1024
REDACTED_BY_LITELLM: Final = "redacted-by-litellm"
# in-memory stand-in handed to provider converters for redacted arguments; never stored
REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER: Final = "{}"
+MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER: Final = "{model}"
MAX_STRING_LENGTH_STDOUT_LOG: Final = get_env_int("MAX_STRING_LENGTH_STDOUT_LOG", 4096)
@@ -1796,6 +1797,8 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES: Final = [
"max_ui_session_budget",
"budget_rollover",
"mcp_tool_search",
+ "model_access_denied_message",
+ "expose_router_debug_in_errors",
]
SPECIAL_LITELLM_AUTH_TOKEN: Final = ["ui-token"]
DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60))
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
index 355fc3f6a21..6435ce0d12f 100644
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -32,6 +32,7 @@ from litellm.constants import (
DEFAULT_MAX_RECURSE_DEPTH,
EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE,
END_USER_RESTRICTED_REGISTRY_MAX_SIZE,
+ MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER,
MODEL_ACCESS_GROUP_REGISTRY_MAX_SIZE,
REGISTRY_ERROR_NEGATIVE_CACHE_TTL,
TAG_REGISTRY_MAX_SIZE,
@@ -4033,6 +4034,14 @@ async def _get_agent_ids_from_access_groups(
)
+def _client_facing_model_access_denied_message(internal_message: str, model: str | list[str]) -> str:
+ template: Final = litellm.model_access_denied_message
+ if not template:
+ return internal_message
+ verbose_proxy_logger.warning(internal_message)
+ return template.replace(MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER, str(model))
+
+
def _resolve_all_team_model_sentinel_for_auth_check(
models: list[str],
llm_router: Router | None,
@@ -4155,7 +4164,10 @@ def _can_object_call_model(
return True
raise ProxyException(
- message=f"{object_type} not allowed to access model. This {object_type} can only access models={models}. Tried to access {model}",
+ message=_client_facing_model_access_denied_message(
+ internal_message=f"{object_type} not allowed to access model. This {object_type} can only access models={models}. Tried to access {model}",
+ model=model,
+ ),
type=ProxyErrorTypes.get_model_access_error_type_for_object(object_type=object_type),
param="model",
code=status.HTTP_403_FORBIDDEN,
@@ -4781,7 +4793,10 @@ async def can_user_call_model(
if SpecialModelNames.no_default_models.value in user_object.models:
raise ProxyException(
- message=f"User not allowed to access model. No default model access, only team models allowed. Tried to access {model}",
+ message=_client_facing_model_access_denied_message(
+ internal_message=f"User not allowed to access model. No default model access, only team models allowed. Tried to access {model}",
+ model=model,
+ ),
type=ProxyErrorTypes.key_model_access_denied,
param="model",
code=status.HTTP_403_FORBIDDEN,
@@ -5383,7 +5398,10 @@ async def _check_team_member_model_access(
)
except ProxyException:
raise ProxyException(
- message=f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, Model={model}. Allowed member models = {member_allowed_models}",
+ message=_client_facing_model_access_denied_message(
+ internal_message=f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, Model={model}. Allowed member models = {member_allowed_models}",
+ model=model,
+ ),
type=ProxyErrorTypes.team_model_access_denied,
param="model",
code=status.HTTP_403_FORBIDDEN,
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index f63e088ebf7..f758628c02a 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -17435,11 +17435,13 @@ GeneralSettingsUILiteLLMValue = float | bool | str | None
class GeneralSettingsUILiteLLMFieldSpec(TypedDict):
- type: Literal["Float", "Dollar", "Boolean", "Select"]
+ type: Literal["Float", "Dollar", "Boolean", "Select", "String"]
description: str
options: NotRequired[tuple[str, ...]]
tab: NotRequired[str] # Admin UI sub-tab this field renders under; None groups it with the rest
- default: NotRequired[float] # reset/clear restores this instead of None; fields whose None means fail-open set it
+ default: NotRequired[
+ float | bool
+ ] # reset/clear restores this instead of None; fields whose None means fail-open set it
_GENERAL_SETTINGS_UI_LITELLM_FIELDS: Final[dict[str, GeneralSettingsUILiteLLMFieldSpec]] = {
@@ -17481,6 +17483,24 @@ _GENERAL_SETTINGS_UI_LITELLM_FIELDS: Final[dict[str, GeneralSettingsUILiteLLMFie
"with this budget. Clearing restores the $1 default."
),
},
+ "model_access_denied_message": {
+ "type": "String",
+ "description": (
+ "Client-facing error message returned when a key, team, user, org or project is not allowed "
+ "to call the requested model. {model} is replaced with the requested model name. The full "
+ "denial reason (allowed models and access groups) is still written to the proxy logs. "
+ "Leave empty to return the detailed message to clients."
+ ),
+ },
+ "expose_router_debug_in_errors": {
+ "type": "Boolean",
+ "default": True,
+ "description": (
+ "Append router debug details (model group, configured fallbacks, fallback errors, cooldown "
+ "info) to error messages returned to clients. Turn off to keep those details in the proxy "
+ "logs only."
+ ),
+ },
}
@@ -17528,6 +17548,13 @@ def _validate_general_settings_ui_litellm_value(field_name: str, value: object)
detail={"error": f"{field_name} must be a positive dollar amount or empty"},
)
return float(value)
+ case "String":
+ if not isinstance(value, str):
+ raise HTTPException(
+ status_code=400,
+ detail={"error": f"{field_name} must be a string or empty"},
+ )
+ return value
case _:
assert_never(field_type)
diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py
index 8c8b755195f..e99e92fcd02 100644
--- a/tests/test_litellm/proxy/auth/test_auth_checks.py
+++ b/tests/test_litellm/proxy/auth/test_auth_checks.py
@@ -1679,6 +1679,100 @@ def test_can_object_call_model_no_access_to_alias_or_underlying():
assert "my-fake-gpt" in str(exc_info.value.message)
+_DENIED_MESSAGE_TEMPLATE: Final = "The model `{model}` is unavailable for this API key or does not exist."
+
+
+def test_can_object_call_model_denial_uses_configured_message_and_logs_detail(monkeypatch, caplog):
+ """LIT-5283: with model_access_denied_message set, the client sees only the template with
+ {model} filled in, while the allowed models / access groups stay in the proxy log."""
+ monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE)
+
+ with caplog.at_level("WARNING", logger="LiteLLM Proxy"):
+ with pytest.raises(ProxyException) as exc_info:
+ _can_object_call_model(
+ model="anthropic-sonnet-4-5",
+ llm_router=None,
+ models=["internal-models"],
+ object_type="key",
+ )
+
+ assert (
+ exc_info.value.message == "The model `anthropic-sonnet-4-5` is unavailable for this API key or does not exist."
+ )
+ assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied
+ assert exc_info.value.param == "model"
+ assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN
+ assert "internal-models" in caplog.text
+ assert "anthropic-sonnet-4-5" in caplog.text
+
+
+@pytest.mark.parametrize("unset_value", [None, ""])
+def test_can_object_call_model_denial_unchanged_when_message_not_configured(monkeypatch, unset_value):
+ monkeypatch.setattr(litellm, "model_access_denied_message", unset_value)
+
+ with pytest.raises(ProxyException) as exc_info:
+ _can_object_call_model(
+ model="anthropic-sonnet-4-5",
+ llm_router=None,
+ models=["internal-models"],
+ object_type="team",
+ )
+
+ assert exc_info.value.message == (
+ "team not allowed to access model. This team can only access models=['internal-models']. "
+ "Tried to access anthropic-sonnet-4-5"
+ )
+
+
+@pytest.mark.asyncio
+async def test_can_user_call_model_no_default_models_uses_configured_message(monkeypatch):
+ from litellm.proxy._types import SpecialModelNames
+ from litellm.proxy.auth.auth_checks import can_user_call_model
+
+ monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE)
+ user_object = LiteLLM_UserTable(user_id="test-user", models=[SpecialModelNames.no_default_models.value])
+
+ with pytest.raises(ProxyException) as exc_info:
+ await can_user_call_model(model="restricted-model", llm_router=None, user_object=user_object)
+
+ assert exc_info.value.message == "The model `restricted-model` is unavailable for this API key or does not exist."
+ assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN
+
+
+@pytest.mark.asyncio
+async def test_check_team_member_model_access_denied_uses_configured_message(monkeypatch):
+ from litellm.proxy._types import LiteLLM_TeamMembership
+ from litellm.proxy.auth.auth_checks import _check_team_member_model_access
+ from litellm.proxy.common_utils.user_api_key_cache import team_membership_reservation_cache_key
+
+ monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE)
+ membership = LiteLLM_TeamMembership(
+ user_id="alice",
+ team_id="team-a",
+ litellm_budget_table=LiteLLM_BudgetTable(allowed_models=["fast-models"]),
+ )
+ cache = UserApiKeyCache()
+ await cache.async_set_cache(
+ key=team_membership_reservation_cache_key(user_id="alice", team_id="team-a"),
+ value=membership,
+ model_type=LiteLLM_TeamMembership,
+ )
+
+ with pytest.raises(ProxyException) as exc_info:
+ await _check_team_member_model_access(
+ model="mock-vision",
+ team_object=LiteLLM_TeamTable(team_id="team-a"),
+ valid_token=UserAPIKeyAuth(token="sk-test", user_id="alice", team_id="team-a"),
+ llm_router=_make_team_scoped_router(),
+ prisma_client=None,
+ user_api_key_cache=cache,
+ proxy_logging_obj=MagicMock(),
+ )
+
+ assert exc_info.value.message == "The model `mock-vision` is unavailable for this API key or does not exist."
+ assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied
+
+
# -- Team-member access-group resolution with team-scoped DB models -----------
diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py
index 42af8e0af21..17847575de1 100644
--- a/tests/test_litellm/proxy/test_proxy_server.py
+++ b/tests/test_litellm/proxy/test_proxy_server.py
@@ -10860,6 +10860,72 @@ def test_validate_max_ui_session_budget_empty_restores_default(empty_value):
assert _validate_general_settings_ui_litellm_value("max_ui_session_budget", empty_value) == 1.0
+@pytest.mark.asyncio
+async def test_update_config_field_model_access_denied_message_sets_live_value(monkeypatch):
+ """LIT-5283: the client-facing model access denial message is editable from the Admin UI
+ General tab as a String field, applies live via setattr, and persists under litellm_settings."""
+ from unittest.mock import MagicMock
+
+ import litellm.proxy.proxy_server as ps
+ from litellm.proxy._types import ConfigFieldUpdate, LitellmUserRoles, UserAPIKeyAuth
+ from litellm.proxy.proxy_server import update_config_general_settings
+
+ saved: dict = {}
+
+ async def fake_get_config():
+ return {"litellm_settings": {}}
+
+ async def fake_save_config(new_config=None):
+ saved.update(new_config or {})
+
+ monkeypatch.setattr(ps.proxy_config, "get_config", fake_get_config)
+ monkeypatch.setattr(ps.proxy_config, "save_config", fake_save_config)
+ monkeypatch.setattr(ps, "prisma_client", MagicMock())
+ monkeypatch.setattr(litellm, "store_audit_logs", False)
+ monkeypatch.setattr(litellm, "model_access_denied_message", None)
+
+ admin = UserAPIKeyAuth(api_key="k", user_id="a", user_role=LitellmUserRoles.PROXY_ADMIN)
+ await update_config_general_settings(
+ data=ConfigFieldUpdate(
+ field_name="model_access_denied_message",
+ field_value="Model `{model}` is unavailable for this key.",
+ config_type="general_settings",
+ ),
+ user_api_key_dict=admin,
+ )
+
+ assert litellm.model_access_denied_message == "Model `{model}` is unavailable for this key."
+ assert saved["litellm_settings"]["model_access_denied_message"] == "Model `{model}` is unavailable for this key."
+
+
+@pytest.mark.parametrize("bad_value", [True, 3, 1.5, ["x"], {"a": "b"}])
+def test_validate_model_access_denied_message_rejects_non_strings(bad_value):
+ from fastapi import HTTPException
+
+ from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value
+
+ with pytest.raises(HTTPException) as exc_info:
+ _validate_general_settings_ui_litellm_value("model_access_denied_message", bad_value)
+ assert exc_info.value.status_code == 400
+
+
+@pytest.mark.parametrize("empty_value", [None, ""])
+def test_validate_model_access_denied_message_empty_restores_detailed_default(empty_value):
+ from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value
+
+ assert _validate_general_settings_ui_litellm_value("model_access_denied_message", empty_value) is None
+
+
+@pytest.mark.parametrize("empty_value", [None, ""])
+def test_validate_expose_router_debug_in_errors_empty_restores_true_default(empty_value):
+ """Clearing the field from the Admin UI must restore the historical default (debug details
+ exposed), not the generic Boolean fallback of False."""
+ from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value
+
+ assert _validate_general_settings_ui_litellm_value("expose_router_debug_in_errors", empty_value) is True
+ assert _validate_general_settings_ui_litellm_value("expose_router_debug_in_errors", False) is False
+
+
def test_general_settings_ui_defaults_unchanged_for_existing_fields():
"""The spec-default mechanism added for max_ui_session_budget must not change what
clearing the pre-existing fields restores (None for Float/Select, False for Boolean)."""
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx
index 9cd1444b0b9..1e29b181b78 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx
@@ -1,4 +1,4 @@
-import { renderWithProviders, screen, within } from "../../../../../tests/test-utils";
+import { fireEvent, renderWithProviders, screen, within } from "../../../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import GeneralSettings from "./general_settings";
@@ -53,6 +53,14 @@ const SETTINGS_FIXTURE = [
stored_in_db: true,
field_default_value: 1.0,
},
+ {
+ field_name: "model_access_denied_message",
+ field_type: "String",
+ field_value: null,
+ field_description: "client-facing denial message",
+ stored_in_db: null,
+ field_default_value: null,
+ },
];
const settingsRow = async (fieldName: string) => {
@@ -99,6 +107,29 @@ describe("GeneralSettings General tab", () => {
expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget");
expect(numericValueIn(row)).toBe(1);
});
+
+ it("saves a typed model_access_denied_message and resets it when cleared", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.click(screen.getByText("General"));
+ const row = await settingsRow("model_access_denied_message");
+ const input = within(row).getByRole("textbox") as HTMLInputElement;
+ expect(input.value).toBe("");
+
+ fireEvent.change(input, { target: { value: "Model `{model}` is unavailable for this key." } });
+ await user.click(within(row).getByRole("button", { name: /update/i }));
+ expect(updateConfigFieldSetting).toHaveBeenCalledWith(
+ "token",
+ "model_access_denied_message",
+ "Model `{model}` is unavailable for this key.",
+ );
+
+ fireEvent.change(input, { target: { value: "" } });
+ await user.click(within(row).getByRole("button", { name: /update/i }));
+ expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "model_access_denied_message");
+ expect(vi.mocked(updateConfigFieldSetting).mock.calls).toHaveLength(1);
+ });
});
// The five tabs here are proxy-wide settings. Auto-routers moved to Models + Endpoints.
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
index df9e328ec3b..040b1f3c375 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
@@ -39,6 +39,8 @@ export interface generalSettingsItem {
const NUMERIC_INPUT_WIDTH = "w-36";
const toNumericValue = (raw: string): number | null => (raw === "" ? null : Number(raw));
+const toStringValue = (raw: string): string | null => (raw === "" ? null : raw);
+const RESETS_WHEN_CLEARED = new Set(["Select", "String"]);
const SettingValueEditor: React.FC<{
setting: generalSettingsItem;
@@ -107,6 +109,16 @@ const SettingValueEditor: React.FC<{
);
}
+ if (setting.field_type === "String") {
+ return (
+ onChange(setting.field_name, toStringValue(event.target.value))}
+ />
+ );
+ }
return null;
};
@@ -210,7 +222,7 @@ const GeneralSettings: React.FC = ({ accessToken, user
const fieldValue = setting?.field_value;
if (fieldValue == null) {
- if (setting?.field_type === "Select") handleResetField(fieldName);
+ if (setting && RESETS_WHEN_CLEARED.has(setting.field_type)) handleResetField(fieldName);
return;
}
try {