mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-17 23:51:30 +00:00
feat(proxy): configurable client-facing model access denied message
Add litellm_settings.model_access_denied_message, a template ({model} placeholder) returned to clients instead of the detailed "can only access models=[...]" text on key/team/user/org/project and team-member model access denials. The full denial reason is still written to the proxy logs at WARNING. Unset keeps the existing detailed message, status codes and error types are unchanged.
Expose the new setting and the existing expose_router_debug_in_errors flag in the Admin UI general settings (String editor, Boolean toggle with an explicit True default) and allow both as safe DB overrides so they persist and propagate across workers.
Resolves LIT-5283
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
1b8daf20e0
commit
168b5bc4fb
8 changed files with 259 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -----------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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(<GeneralSettings accessToken="token" userRole="Admin" userID="user" />);
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<{
|
|||
</Select>
|
||||
);
|
||||
}
|
||||
if (setting.field_type === "String") {
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
className="w-96"
|
||||
value={setting.field_value ?? ""}
|
||||
onChange={(event) => onChange(setting.field_name, toStringValue(event.target.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -210,7 +222,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ 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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue