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 {