refactor(proxy): replace configurable model access denied message with a fixed clean client message

Drop the model_access_denied_message setting, its {model} template, the DB
override entry and the Admin UI field. Model access denials now always return
the fixed client message while the allowlist diagnostic is logged at the final
HTTP, realtime and MCP boundaries

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-09-16 01:44:36 +00:00
parent 14d239fc84
commit 15f2e25e8a
19 changed files with 114 additions and 377 deletions

View file

@ -219,7 +219,6 @@ 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

View file

@ -98,7 +98,6 @@ 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)
@ -1801,8 +1800,6 @@ 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))

View file

@ -72,7 +72,7 @@ from litellm.proxy.auth.budget_throttle import (
budget_throttle_percentage,
should_throttle_budget_exceeded,
)
from litellm.proxy.auth.model_access_denied import client_facing_model_access_denied_message
from litellm.proxy.auth.model_access_denied import model_access_denied_client_message
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import publish_auth_cache_invalidation
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
@ -4177,7 +4177,7 @@ def _can_object_call_model(
f"Tried to access {model}"
)
raise ModelAccessDeniedProxyException(
message=client_facing_model_access_denied_message(internal_message=internal_message, model=model),
message=model_access_denied_client_message(model=model),
internal_message=internal_message,
type=ProxyErrorTypes.get_model_access_error_type_for_object(object_type=object_type),
param="model",
@ -4808,7 +4808,7 @@ async def can_user_call_model(
f"Tried to access {model}"
)
raise ModelAccessDeniedProxyException(
message=client_facing_model_access_denied_message(internal_message=internal_message, model=model),
message=model_access_denied_client_message(model=model),
internal_message=internal_message,
type=ProxyErrorTypes.key_model_access_denied,
param="model",
@ -5415,7 +5415,7 @@ async def _check_team_member_model_access(
f"Model={model}. Allowed member models = {member_allowed_models}"
)
raise ModelAccessDeniedProxyException(
message=client_facing_model_access_denied_message(internal_message=internal_message, model=model),
message=model_access_denied_client_message(model=model),
internal_message=internal_message,
type=ProxyErrorTypes.team_model_access_denied,
param="model",

View file

@ -54,7 +54,7 @@ from litellm.proxy._types import (
from litellm.proxy.auth.auth_checks import can_team_access_model
from litellm.proxy.auth.model_access_denied import (
ModelAccessDeniedHTTPException,
client_facing_model_access_denied_message,
model_access_denied_client_message,
)
from litellm.proxy.auth.resolvers.grants import GrantResolver, UserLookup, canonical_user_id
from litellm.proxy.auth.route_checks import RouteChecks
@ -1347,7 +1347,7 @@ class JWTAuthManager:
raise ModelAccessDeniedHTTPException(
internal_message=internal_message,
status_code=403,
detail=client_facing_model_access_denied_message(internal_message=internal_message, model=model),
detail=model_access_denied_client_message(model=model),
)
return True
@ -1380,11 +1380,7 @@ class JWTAuthManager:
raise ModelAccessDeniedHTTPException(
internal_message=internal_message,
status_code=403,
detail={
"error": client_facing_model_access_denied_message(
internal_message=internal_message, model=requested_model
)
},
detail={"error": model_access_denied_client_message(model=requested_model)},
)
return

View file

@ -2,15 +2,14 @@ from typing import Final
from fastapi import HTTPException
import litellm
from litellm.constants import MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER
MODEL_ACCESS_DENIED_CLIENT_MESSAGE: Final = (
"The requested model '{model}' is not available for this API key, or the model name is invalid. "
"Check the models available to you and try again."
)
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
return template.replace(MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER, str(model))
def model_access_denied_client_message(model: str | list[str]) -> str:
return MODEL_ACCESS_DENIED_CLIENT_MESSAGE.format(model=model)
class ModelAccessDeniedHTTPException(HTTPException):

View file

@ -1682,7 +1682,7 @@ async def openai_exception_handler(request: Request, exc: ProxyException):
def _log_model_access_denial(exc: ProxyException) -> None:
if not litellm.model_access_denied_message or not isinstance(exc, ModelAccessDeniedProxyException):
if not isinstance(exc, ModelAccessDeniedProxyException):
return
verbose_proxy_logger.warning(exc.sanitized_internal_message())
@ -17455,13 +17455,11 @@ GeneralSettingsUILiteLLMValue = float | bool | str | None
class GeneralSettingsUILiteLLMFieldSpec(TypedDict):
type: ReadOnly[Literal["Float", "Dollar", "Boolean", "Select", "String"]]
description: ReadOnly[str]
options: ReadOnly[NotRequired[tuple[str, ...]]]
tab: ReadOnly[NotRequired[str]] # Admin UI sub-tab this field renders under; None groups it with the rest
default: ReadOnly[
NotRequired[float | bool]
] # reset/clear restores this instead of None; fields whose None means fail-open set it
type: Literal["Float", "Dollar", "Boolean", "Select"]
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
_GENERAL_SETTINGS_UI_LITELLM_FIELDS: Final[dict[str, GeneralSettingsUILiteLLMFieldSpec]] = {
@ -17513,24 +17511,6 @@ _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."
),
},
}
@ -17578,13 +17558,6 @@ 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)

View file

@ -101,7 +101,7 @@ async def test_model_access_patterns(key_models, test_model, expect_success):
assert _error_body["type"] == "key_model_access_denied"
assert _error_body["param"] == "model"
assert _error_body["code"] == "403"
assert "key not allowed to access model" in _error_body["message"]
assert "is not available for this API key" in _error_body["message"]
@pytest.mark.asyncio
@ -299,7 +299,5 @@ def _validate_model_access_exception(
assert _error_body["type"] == expected_type
assert _error_body["param"] == "model"
assert _error_body["code"] == "403"
if expected_type == "key_model_access_denied":
assert "key not allowed to access model" in _error_body["message"]
elif expected_type == "team_model_access_denied":
assert "eam not allowed to access model" in _error_body["message"]
assert "is not available for this API key" in _error_body["message"]
assert "not allowed to access model" not in _error_body["message"]

View file

@ -163,7 +163,7 @@ async def test_can_key_call_model(model, expect_to_work):
if expect_to_work:
await can_key_call_model(**args)
else:
with pytest.raises(Exception, match='key not allowed to access model\\. This key can only access') as e:
with pytest.raises(Exception, match='is not available for this API key') as e:
await can_key_call_model(**args)
print(e)
@ -943,7 +943,7 @@ async def test_can_key_call_model_with_aliases(model, alias_map, expect_to_work)
llm_router=router,
)
else:
with pytest.raises(Exception, match='key not allowed to access model\\. This key can only access') as e:
with pytest.raises(Exception, match='is not available for this API key') as e:
await can_key_call_model(
model=model,
llm_model_list=llm_model_list,

View file

@ -138,11 +138,9 @@ class TestCheckModelAccess:
assert "claude-3-opus-20240229" in result.message
@pytest.mark.asyncio
async def test_should_log_internal_denial_reason_when_client_message_is_configured(self, monkeypatch, caplog):
import litellm
async def test_should_log_internal_denial_reason_and_hide_allowlist_from_client(self, caplog):
from litellm.proxy._types import UserAPIKeyAuth
monkeypatch.setattr(litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key.")
auth = UserAPIKeyAuth(api_key="sk-test-key", models=["gpt-3.5-turbo"])
with caplog.at_level("WARNING", logger="LiteLLM"):

View file

@ -531,12 +531,14 @@ async def test_can_team_access_model_error_lists_direct_and_access_group_models(
assert await can_team_access_model("direct-model", team_object, None) is True
assert await can_team_access_model("group-model", team_object, None) is True
with pytest.raises(ProxyException) as exc_info:
with pytest.raises(ModelAccessDeniedProxyException) as exc_info:
await can_team_access_model("blocked-model", team_object, None)
assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied
assert "direct-model" in exc_info.value.message
assert "group-model" in exc_info.value.message
assert "direct-model" in exc_info.value.internal_message
assert "group-model" in exc_info.value.internal_message
assert "direct-model" not in exc_info.value.message
assert "group-model" not in exc_info.value.message
@pytest.mark.asyncio
@ -1676,16 +1678,17 @@ def test_can_object_call_model_no_access_to_alias_or_underlying():
# Should raise ProxyException with appropriate error type
assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied
assert "key not allowed to access model" in str(exc_info.value.message)
assert "is not available for this API key" in str(exc_info.value.message)
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."
_DENIED_MESSAGE_TEMPLATE: Final = (
"The requested model '{model}' is not available for this API key, or the model name is invalid. "
"Check the models available to you and try again."
)
def test_can_object_call_model_denial_uses_configured_message_and_keeps_detail_on_exception(monkeypatch, caplog):
monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE)
def test_can_object_call_model_denial_hides_allowlist_and_keeps_detail_on_exception(caplog):
with caplog.at_level("DEBUG", logger="LiteLLM Proxy"):
with pytest.raises(ModelAccessDeniedProxyException) as exc_info:
_can_object_call_model(
@ -1695,9 +1698,8 @@ def test_can_object_call_model_denial_uses_configured_message_and_keeps_detail_o
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.message == _DENIED_MESSAGE_TEMPLATE.format(model="anthropic-sonnet-4-5")
assert "internal-models" not in exc_info.value.message
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
@ -1709,10 +1711,9 @@ def test_can_object_call_model_denial_uses_configured_message_and_keeps_detail_o
@pytest.mark.asyncio
async def test_access_group_fallback_grant_does_not_log_a_denial(monkeypatch, caplog):
async def test_access_group_fallback_grant_does_not_log_a_denial(caplog):
from litellm.proxy.auth.auth_checks import can_team_access_model
monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE)
team_object = LiteLLM_TeamTable(team_id="team-123", models=["direct-model"], access_group_ids=["ag-1"])
with (
@ -1727,46 +1728,49 @@ async def test_access_group_fallback_grant_does_not_log_a_denial(monkeypatch, ca
assert "not allowed to access model" not 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:
@pytest.mark.parametrize(
"object_type, expected_type",
[
("team", ProxyErrorTypes.team_model_access_denied),
("user", ProxyErrorTypes.user_model_access_denied),
("org", ProxyErrorTypes.org_model_access_denied),
],
)
def test_can_object_call_model_denial_same_client_message_for_every_object_type(object_type, expected_type):
with pytest.raises(ModelAccessDeniedProxyException) as exc_info:
_can_object_call_model(
model="anthropic-sonnet-4-5",
llm_router=None,
models=["internal-models"],
object_type="team",
object_type=object_type,
)
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"
)
assert exc_info.value.message == _DENIED_MESSAGE_TEMPLATE.format(model="anthropic-sonnet-4-5")
assert exc_info.value.type == expected_type
assert f"{object_type} not allowed to access model" in exc_info.value.internal_message
@pytest.mark.asyncio
async def test_can_user_call_model_no_default_models_uses_configured_message(monkeypatch):
async def test_can_user_call_model_no_default_models_hides_policy_detail():
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:
with pytest.raises(ModelAccessDeniedProxyException) 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 exc_info.value.message == _DENIED_MESSAGE_TEMPLATE.format(model="restricted-model")
assert "only team models allowed" in exc_info.value.internal_message
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):
async def test_check_team_member_model_access_denied_hides_member_allowlist():
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",
@ -1779,7 +1783,7 @@ async def test_check_team_member_model_access_denied_uses_configured_message(mon
model_type=LiteLLM_TeamMembership,
)
with pytest.raises(ProxyException) as exc_info:
with pytest.raises(ModelAccessDeniedProxyException) as exc_info:
await _check_team_member_model_access(
model="mock-vision",
team_object=LiteLLM_TeamTable(team_id="team-a"),
@ -1790,7 +1794,9 @@ async def test_check_team_member_model_access_denied_uses_configured_message(mon
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.message == _DENIED_MESSAGE_TEMPLATE.format(model="mock-vision")
assert "fast-models" not in exc_info.value.message
assert "Allowed member models = ['fast-models']" in exc_info.value.internal_message
assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied

View file

@ -26,7 +26,6 @@ from prisma.errors import (
)
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import INVALID_VIRTUAL_KEY_ERROR_MARKER
from litellm.exceptions import BudgetExceededError
@ -991,12 +990,15 @@ async def test_handle_authentication_error_traceback_only_for_unexpected_errors(
assert records[0].name == expected_logger_name
_DENIED_MESSAGE_TEMPLATE = "The model `{model}` is unavailable for this API key or does not exist."
_DENIED_CLIENT_MESSAGE = (
"The requested model 'gpt-5.6' is not available for this API key, or the model name is invalid. "
"Check the models available to you and try again."
)
def _denied_proxy_exception() -> ModelAccessDeniedProxyException:
return ModelAccessDeniedProxyException(
message="The model `gpt-5.6\r\nWARNING forged log line` is unavailable for this API key or does not exist.",
message=_DENIED_CLIENT_MESSAGE,
internal_message="key not allowed to access model. This key can only access models=['internal-models']. "
"Tried to access gpt-5.6\r\nWARNING forged log line",
type=ProxyErrorTypes.key_model_access_denied,
@ -1010,7 +1012,7 @@ def _denied_jwt_exception() -> ModelAccessDeniedHTTPException:
internal_message="Role=engineer not allowed to call model=gpt-5.6\r\nWARNING forged log line. "
"Allowed models=['internal-models']",
status_code=status.HTTP_403_FORBIDDEN,
detail="The model `gpt-5.6` is unavailable for this API key or does not exist.",
detail=_DENIED_CLIENT_MESSAGE,
)
@ -1022,10 +1024,7 @@ def _denied_jwt_exception() -> ModelAccessDeniedHTTPException:
pytest.param(_denied_jwt_exception, id="jwt_http_exception"),
],
)
async def test_handle_authentication_error_keeps_internal_message_on_model_access_denial(
monkeypatch, make_denial, caplog
):
monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE)
async def test_handle_authentication_error_keeps_internal_message_on_model_access_denial(make_denial, caplog):
handler = UserAPIKeyAuthExceptionHandler()
denial = make_denial()
@ -1054,7 +1053,7 @@ async def test_handle_authentication_error_keeps_internal_message_on_model_acces
def test_as_proxy_exception_keeps_jwt_scope_denial_message_shape():
detail = {"error": "The model `gpt-5.6` is unavailable for this API key or does not exist."}
detail = {"error": _DENIED_CLIENT_MESSAGE}
denial = ModelAccessDeniedHTTPException(
internal_message="model=gpt-5.6 not allowed. Allowed_models=['internal-models']",
status_code=status.HTTP_403_FORBIDDEN,
@ -1066,42 +1065,3 @@ def test_as_proxy_exception_keeps_jwt_scope_denial_message_shape():
assert converted.to_dict() == plain.to_dict()
assert converted.internal_message == denial.internal_message
@pytest.mark.asyncio
@pytest.mark.parametrize("unset_value", [None, ""])
async def test_handle_authentication_error_no_extra_denial_log_when_message_not_configured(
monkeypatch, unset_value, caplog
):
monkeypatch.setattr(litellm, "model_access_denied_message", unset_value)
handler = UserAPIKeyAuthExceptionHandler()
denial = ModelAccessDeniedProxyException(
message="key not allowed to access model. This key can only access models=['internal-models']. "
"Tried to access gpt-5.6",
internal_message="key not allowed to access model. This key can only access models=['internal-models']. "
"Tried to access gpt-5.6",
type=ProxyErrorTypes.key_model_access_denied,
param="model",
code=status.HTTP_403_FORBIDDEN,
)
with (
patch( # test-quality-ok: handler reads proxy_server globals at call time
"litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook",
new_callable=AsyncMock,
return_value=None,
),
patch( # test-quality-ok: handler reads proxy_server globals at call time
"litellm.proxy.auth.auth_exception_handler.seed_request_identity",
),
patch( # test-quality-ok: handler reads proxy_server globals at call time
"litellm.proxy.proxy_server.general_settings",
{"allow_requests_on_db_unavailable": False},
),
caplog.at_level("WARNING", logger="LiteLLM Proxy"),
pytest.raises(ProxyException) as exc_info,
):
await handler._handle_authentication_error(denial, MagicMock(), {}, "/v1/chat/completions", None, "sk-bad-key")
assert "internal-models" in str(exc_info.value.message)
assert [r for r in caplog.records if r.levelname == "WARNING" and "internal-models" in r.getMessage()] == []

View file

@ -1053,7 +1053,7 @@ async def test_managed_batch_routes_pass_team_model_access_check(route, request_
is True
)
with pytest.raises(Exception, match="team not allowed to access model"):
with pytest.raises(Exception, match="is not available for this API key"):
await can_team_access_model(
model=model,
team_object=LiteLLM_TeamTable(team_id="team-other", models=["some-other-model"]),

View file

@ -6972,19 +6972,13 @@ async def test_auth_builder_denies_jwt_naming_unregistered_agent_before_admin_ch
assert exc_info.value.status_code == 403
_JWT_DENIED_MESSAGE_TEMPLATE = "The model `{model}` is unavailable for this identity."
@pytest.mark.parametrize(
"configured_message, expected_detail",
[
(None, "Role=internal_user not allowed to call model=gpt-5.6. Allowed models=['gpt-5.6-mini']"),
("", "Role=internal_user not allowed to call model=gpt-5.6. Allowed models=['gpt-5.6-mini']"),
(_JWT_DENIED_MESSAGE_TEMPLATE, "The model `gpt-5.6` is unavailable for this identity."),
],
_JWT_DENIED_CLIENT_MESSAGE = (
"The requested model 'gpt-5.6' is not available for this API key, or the model name is invalid. "
"Check the models available to you and try again."
)
def test_can_rbac_role_call_model_denial_honors_configured_message(monkeypatch, configured_message, expected_detail):
monkeypatch.setattr(litellm, "model_access_denied_message", configured_message)
def test_can_rbac_role_call_model_denial_hides_role_allowlist_from_client():
general_settings = {
"role_permissions": [
RoleBasedPermissions(role=LitellmUserRoles.INTERNAL_USER, models=["gpt-5.6-mini"]),
@ -6999,23 +6993,13 @@ def test_can_rbac_role_call_model_denial_honors_configured_message(monkeypatch,
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == expected_detail
assert exc_info.value.detail == _JWT_DENIED_CLIENT_MESSAGE
assert exc_info.value.internal_message == (
"Role=internal_user not allowed to call model=gpt-5.6. Allowed models=['gpt-5.6-mini']"
)
@pytest.mark.parametrize(
"configured_message, expected_error",
[
(None, "model=gpt-5.6 not allowed. Allowed_models=['gpt-5.6-mini']"),
("", "model=gpt-5.6 not allowed. Allowed_models=['gpt-5.6-mini']"),
(_JWT_DENIED_MESSAGE_TEMPLATE, "The model `gpt-5.6` is unavailable for this identity."),
],
)
def test_check_scope_based_access_denial_honors_configured_message(monkeypatch, configured_message, expected_error):
monkeypatch.setattr(litellm, "model_access_denied_message", configured_message)
def test_check_scope_based_access_denial_hides_scope_allowlist_from_client():
with pytest.raises(ModelAccessDeniedHTTPException) as exc_info:
JWTAuthManager.check_scope_based_access(
scope_mappings=[ScopeMapping(scope="litellm.api.consumer", models=["gpt-5.6-mini"])],
@ -7025,5 +7009,5 @@ def test_check_scope_based_access_denial_honors_configured_message(monkeypatch,
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == {"error": expected_error}
assert exc_info.value.detail == {"error": _JWT_DENIED_CLIENT_MESSAGE}
assert exc_info.value.internal_message == "model=gpt-5.6 not allowed. Allowed_models=['gpt-5.6-mini']"

View file

@ -287,7 +287,7 @@ async def test_client_secrets_transcription_rejects_disallowed_nested_model(
)
assert response.status_code == 403
assert "Tried to access gpt-realtime-whisper" in response.text
assert "The requested model 'gpt-realtime-whisper' is not available for this API key" in response.text
mock_route_request.assert_not_called()
finally:
proxy_app.dependency_overrides.pop(user_api_key_auth, None)
@ -611,7 +611,7 @@ async def test_transcription_sessions_rejects_disallowed_resolved_model(
)
assert response.status_code == 403
assert "Tried to access gpt-realtime-whisper" in response.text
assert "The requested model 'gpt-realtime-whisper' is not available for this API key" in response.text
mock_route_request.assert_not_called()
finally:
proxy_app.dependency_overrides.pop(user_api_key_auth, None)
@ -658,7 +658,7 @@ async def test_transcription_sessions_rejects_disallowed_team_model_scope(
assert response.status_code == 403
assert "team" in response.text.lower()
assert "Tried to access gpt-realtime-whisper" in response.text
assert "The requested model 'gpt-realtime-whisper' is not available for this API key" in response.text
mock_route_request.assert_not_called()
finally:
proxy_app.dependency_overrides.pop(user_api_key_auth, None)
@ -703,7 +703,7 @@ async def test_transcription_sessions_rejects_disallowed_project_model_scope(
assert response.status_code == 403
assert "project" in response.text.lower()
assert "Tried to access gpt-realtime-whisper" in response.text
assert "The requested model 'gpt-realtime-whisper' is not available for this API key" in response.text
mock_route_request.assert_not_called()
finally:
proxy_app.dependency_overrides.pop(user_api_key_auth, None)
@ -757,7 +757,7 @@ async def test_transcription_sessions_rejects_disallowed_team_member_model_scope
)
assert response.status_code == 403
assert "Team member not allowed to access model" in response.text
assert "is not available for this API key" in response.text
mock_route_request.assert_not_called()
finally:
proxy_app.dependency_overrides.pop(user_api_key_auth, None)
@ -783,7 +783,7 @@ async def test_realtime_transcription_websocket_default_model_checks_key_scope()
websocket.close.assert_awaited_once()
_, close_kwargs = websocket.close.call_args
assert close_kwargs["code"] == 1008
assert "not allowed to access model" in close_kwargs["reason"]
assert "is not available for this API key" in close_kwargs["reason"]
@pytest.mark.asyncio
@ -825,7 +825,7 @@ async def test_realtime_transcription_websocket_default_model_checks_team_scope(
websocket.close.assert_awaited_once()
_, close_kwargs = websocket.close.call_args
assert close_kwargs["code"] == 1008
assert "not allowed to access model" in close_kwargs["reason"]
assert "is not available for this API key" in close_kwargs["reason"]
@pytest.mark.asyncio

View file

@ -10955,60 +10955,10 @@ 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):
from unittest.mock import AsyncMock, 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
save_config = AsyncMock()
monkeypatch.setattr(ps.proxy_config, "get_config", AsyncMock(return_value={"litellm_settings": {}}))
monkeypatch.setattr(ps.proxy_config, "save_config", 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."
save_config.assert_awaited_once()
saved_config = save_config.await_args.kwargs["new_config"]
assert saved_config["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
def _model_access_denied_proxy_exception():
return ModelAccessDeniedProxyException(
message="The model `gpt-5.6\r\nWARNING forged log line` is unavailable for this API key or does not exist.",
message="The requested model 'gpt-5.6\r\nWARNING forged log line' is not available for this API key, "
"or the model name is invalid. Check the models available to you and try again.",
internal_message="key not allowed to access model. This key can only access models=['internal-models']. "
"Tried to access gpt-5.6\r\nWARNING forged log line",
type=ProxyErrorTypes.key_model_access_denied,
@ -11022,11 +10972,7 @@ def _http_request_scope():
@pytest.mark.asyncio
async def test_openai_exception_handler_logs_sanitized_model_access_denial(monkeypatch, caplog):
monkeypatch.setattr(
litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key or does not exist."
)
async def test_openai_exception_handler_logs_sanitized_model_access_denial(caplog):
with caplog.at_level("WARNING", logger="LiteLLM Proxy"):
response = await openai_exception_handler(_http_request_scope(), _model_access_denied_proxy_exception())
@ -11042,22 +10988,7 @@ async def test_openai_exception_handler_logs_sanitized_model_access_denial(monke
@pytest.mark.asyncio
@pytest.mark.parametrize("unset_value", [None, ""])
async def test_openai_exception_handler_no_denial_log_when_message_not_configured(monkeypatch, unset_value, caplog):
monkeypatch.setattr(litellm, "model_access_denied_message", unset_value)
with caplog.at_level("WARNING", logger="LiteLLM Proxy"):
response = await openai_exception_handler(_http_request_scope(), _model_access_denied_proxy_exception())
assert response.status_code == 403
assert [r for r in caplog.records if r.levelname == "WARNING" and "internal-models" in r.getMessage()] == []
@pytest.mark.asyncio
async def test_openai_exception_handler_no_denial_log_for_plain_proxy_exception(monkeypatch, caplog):
monkeypatch.setattr(
litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key or does not exist."
)
async def test_openai_exception_handler_no_denial_log_for_plain_proxy_exception(caplog):
denial = ProxyException(
message="Authentication Error, Invalid proxy server token passed",
type=ProxyErrorTypes.auth_error,
@ -11073,10 +11004,7 @@ async def test_openai_exception_handler_no_denial_log_for_plain_proxy_exception(
@pytest.mark.asyncio
async def test_realtime_model_access_denial_logs_sanitized_internal_message(monkeypatch, caplog):
monkeypatch.setattr(
litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key or does not exist."
)
async def test_realtime_model_access_denial_logs_sanitized_internal_message(caplog):
reservation = {"reserved_cost": 0.0, "input_cost": 0.0, "finalized": False, "entries": []}
with caplog.at_level("WARNING", logger="LiteLLM Proxy"):
@ -11095,43 +11023,6 @@ async def test_realtime_model_access_denial_logs_sanitized_internal_message(monk
assert "gpt-5.6WARNING forged log line" in denial_records[0].getMessage()
@pytest.mark.parametrize("empty_value", [None, ""])
def test_validate_expose_router_debug_in_errors_empty_restores_true_default(empty_value):
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
@pytest.mark.parametrize(
"field_name, booted_value, db_value, read_setting",
[
(
"model_access_denied_message",
None,
"Model `{model}` is unavailable for this key.",
lambda: litellm.model_access_denied_message,
),
("expose_router_debug_in_errors", True, False, lambda: litellm.expose_router_debug_in_errors),
],
)
def test_model_access_denied_settings_propagate_on_config_reload(
monkeypatch, field_name, booted_value, db_value, read_setting
):
import litellm.proxy.proxy_server as ps
monkeypatch.setattr(litellm, field_name, booted_value)
assert read_setting() == booted_value
ps.ProxyConfig()._update_config_fields(
current_config={"litellm_settings": {}},
param_name="litellm_settings",
db_param_value={field_name: db_value},
)
assert read_setting() == db_value
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)."""

View file

@ -16424,7 +16424,7 @@ class TestMemberAutoRouterInference:
project_id="router-project", team_id="router-team", models=["restricted-model"],
), model_type=LiteLLM_ProjectTableCachedObj,
)
with pytest.raises(ProxyException, match="not allowed to access model"):
with pytest.raises(ProxyException, match="is not available for this API key"):
await self._route(self._router(), self._request(actor=self.actor.model_copy(update={
"models": ["member-router"] if ceiling == "key" else self.actor.models,
"project_id": "router-project" if ceiling == "project" else None,
@ -16453,7 +16453,7 @@ class TestMemberAutoRouterInference:
assert self.database.db.litellm_accessgrouptable.find_unique.await_count == 1
self.database.db.litellm_accessgrouptable.find_unique.return_value = group.model_copy(update={"access_model_names": []})
await evict_and_broadcast(cache_keys=("access_group_id:router-group",), user_api_key_cache=self.cache)
with pytest.raises(ProxyException, match="not allowed to access model"):
with pytest.raises(ProxyException, match="is not available for this API key"):
await self._route(router, request)
assert self.database.db.litellm_accessgrouptable.find_unique.await_count == 2
@ -16471,7 +16471,7 @@ class TestMemberAutoRouterInference:
key="team_id:router-team", model_type=LiteLLM_TeamTable,
value=self.team.model_copy(update={"models": ["member-router"]}),
)
with pytest.raises(ProxyException, match="not allowed to access model"):
with pytest.raises(ProxyException, match="is not available for this API key"):
await self._route(router, self._request())
self.database.db.litellm_teamtable.find_unique.reset_mock()
admin: Final = self._request(tag="admin")

View file

@ -307,7 +307,7 @@ async def test_chat_completion():
model="gpt-4",
messages=[{"role": "user", "content": "Hello!"}],
)
assert "key not allowed to access model." in str(e)
assert "is not available for this API key" in str(e)
@pytest.mark.asyncio

View file

@ -1,4 +1,4 @@
import { fireEvent, renderWithProviders, screen, within } from "../../../../../tests/test-utils";
import { 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";
@ -62,14 +62,6 @@ 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) => {
@ -116,54 +108,6 @@ 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);
});
it("keeps the stored value visible when the reset request fails", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue(
SETTINGS_FIXTURE.map((s) =>
s.field_name === "model_access_denied_message"
? { ...s, field_value: "Model `{model}` is unavailable.", stored_in_db: true }
: { ...s },
),
);
vi.mocked(deleteConfigFieldSetting).mockRejectedValueOnce(new Error("proxy unreachable"));
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(within(row).getByText("In DB")).toBeInTheDocument();
fireEvent.change(input, { target: { value: "" } });
await user.click(within(row).getByRole("button", { name: /update/i }));
expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "model_access_denied_message");
expect(within(row).getByText("In DB")).toBeInTheDocument();
expect(within(row).queryByText("Not Set")).not.toBeInTheDocument();
});
});
describe("GeneralSettings Prompt Caching tab", () => {

View file

@ -42,8 +42,6 @@ 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: ReadonlySet<string> = new Set(["Select", "String"]);
const SettingValueEditor: React.FC<{
setting: generalSettingsItem;
@ -112,16 +110,6 @@ 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;
};
@ -231,7 +219,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
setGeneralSettings(updatedSettings);
};
const handleUpdateField = async (fieldName: string) => {
const handleUpdateField = (fieldName: string) => {
if (!accessToken) {
return;
}
@ -240,33 +228,37 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
const fieldValue = setting?.field_value;
if (fieldValue == null) {
if (setting && RESETS_WHEN_CLEARED.has(setting.field_type)) await handleResetField(fieldName);
if (setting?.field_type === "Select") handleResetField(fieldName);
return;
}
try {
await updateConfigFieldSetting(accessToken, fieldName, fieldValue);
setGeneralSettings((current) =>
current.map((setting) => (setting.field_name === fieldName ? { ...setting, stored_in_db: true } : setting)),
updateConfigFieldSetting(accessToken, fieldName, fieldValue);
// update value in state
const updatedSettings = generalSettings.map((setting) =>
setting.field_name === fieldName ? { ...setting, stored_in_db: true } : setting,
);
setGeneralSettings(updatedSettings);
} catch (error) {
// do something
}
};
const handleResetField = async (fieldName: string) => {
const handleResetField = (fieldName: string) => {
if (!accessToken) {
return;
}
try {
await deleteConfigFieldSetting(accessToken, fieldName);
setGeneralSettings((current) =>
current.map((setting) =>
setting.field_name === fieldName
? { ...setting, stored_in_db: null, field_value: setting.field_default_value ?? null }
: setting,
),
deleteConfigFieldSetting(accessToken, fieldName);
// update value in state
const updatedSettings = generalSettings.map((setting) =>
setting.field_name === fieldName
? { ...setting, stored_in_db: null, field_value: setting.field_default_value ?? null }
: setting,
);
setGeneralSettings(updatedSettings);
} catch (error) {
// do something
}