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 # major release; opt in early with `litellm.expose_router_debug_in_errors
# = False`. # = False`.
expose_router_debug_in_errors: bool = True expose_router_debug_in_errors: bool = True
model_access_denied_message: str | None = None
filter_invalid_headers: Optional[bool] = False filter_invalid_headers: Optional[bool] = False
add_user_information_to_llm_headers: Optional[bool] = ( add_user_information_to_llm_headers: Optional[bool] = (
None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers 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" REDACTED_BY_LITELLM: Final = "redacted-by-litellm"
# in-memory stand-in handed to provider converters for redacted arguments; never stored # in-memory stand-in handed to provider converters for redacted arguments; never stored
REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER: Final = "{}" 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) 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", "max_ui_session_budget",
"budget_rollover", "budget_rollover",
"mcp_tool_search", "mcp_tool_search",
"model_access_denied_message",
"expose_router_debug_in_errors",
] ]
SPECIAL_LITELLM_AUTH_TOKEN: Final = ["ui-token"] 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)) 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, budget_throttle_percentage,
should_throttle_budget_exceeded, 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.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import publish_auth_cache_invalidation from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import publish_auth_cache_invalidation
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
@ -4177,7 +4177,7 @@ def _can_object_call_model(
f"Tried to access {model}" f"Tried to access {model}"
) )
raise ModelAccessDeniedProxyException( 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, internal_message=internal_message,
type=ProxyErrorTypes.get_model_access_error_type_for_object(object_type=object_type), type=ProxyErrorTypes.get_model_access_error_type_for_object(object_type=object_type),
param="model", param="model",
@ -4808,7 +4808,7 @@ async def can_user_call_model(
f"Tried to access {model}" f"Tried to access {model}"
) )
raise ModelAccessDeniedProxyException( 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, internal_message=internal_message,
type=ProxyErrorTypes.key_model_access_denied, type=ProxyErrorTypes.key_model_access_denied,
param="model", param="model",
@ -5415,7 +5415,7 @@ async def _check_team_member_model_access(
f"Model={model}. Allowed member models = {member_allowed_models}" f"Model={model}. Allowed member models = {member_allowed_models}"
) )
raise ModelAccessDeniedProxyException( 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, internal_message=internal_message,
type=ProxyErrorTypes.team_model_access_denied, type=ProxyErrorTypes.team_model_access_denied,
param="model", 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.auth_checks import can_team_access_model
from litellm.proxy.auth.model_access_denied import ( from litellm.proxy.auth.model_access_denied import (
ModelAccessDeniedHTTPException, 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.resolvers.grants import GrantResolver, UserLookup, canonical_user_id
from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.auth.route_checks import RouteChecks
@ -1347,7 +1347,7 @@ class JWTAuthManager:
raise ModelAccessDeniedHTTPException( raise ModelAccessDeniedHTTPException(
internal_message=internal_message, internal_message=internal_message,
status_code=403, 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 return True
@ -1380,11 +1380,7 @@ class JWTAuthManager:
raise ModelAccessDeniedHTTPException( raise ModelAccessDeniedHTTPException(
internal_message=internal_message, internal_message=internal_message,
status_code=403, status_code=403,
detail={ detail={"error": model_access_denied_client_message(model=requested_model)},
"error": client_facing_model_access_denied_message(
internal_message=internal_message, model=requested_model
)
},
) )
return return

View file

@ -2,15 +2,14 @@ from typing import Final
from fastapi import HTTPException from fastapi import HTTPException
import litellm MODEL_ACCESS_DENIED_CLIENT_MESSAGE: Final = (
from litellm.constants import MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER "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: def model_access_denied_client_message(model: str | list[str]) -> str:
template: Final = litellm.model_access_denied_message return MODEL_ACCESS_DENIED_CLIENT_MESSAGE.format(model=model)
if not template:
return internal_message
return template.replace(MODEL_ACCESS_DENIED_MESSAGE_MODEL_PLACEHOLDER, str(model))
class ModelAccessDeniedHTTPException(HTTPException): 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: 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 return
verbose_proxy_logger.warning(exc.sanitized_internal_message()) verbose_proxy_logger.warning(exc.sanitized_internal_message())
@ -17455,13 +17455,11 @@ GeneralSettingsUILiteLLMValue = float | bool | str | None
class GeneralSettingsUILiteLLMFieldSpec(TypedDict): class GeneralSettingsUILiteLLMFieldSpec(TypedDict):
type: ReadOnly[Literal["Float", "Dollar", "Boolean", "Select", "String"]] type: Literal["Float", "Dollar", "Boolean", "Select"]
description: ReadOnly[str] description: str
options: ReadOnly[NotRequired[tuple[str, ...]]] options: NotRequired[tuple[str, ...]]
tab: ReadOnly[NotRequired[str]] # Admin UI sub-tab this field renders under; None groups it with the rest tab: NotRequired[str] # Admin UI sub-tab this field renders under; None groups it with the rest
default: ReadOnly[ default: NotRequired[float] # reset/clear restores this instead of None; fields whose None means fail-open set it
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]] = { _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." "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"}, detail={"error": f"{field_name} must be a positive dollar amount or empty"},
) )
return float(value) 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 _: case _:
assert_never(field_type) 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["type"] == "key_model_access_denied"
assert _error_body["param"] == "model" assert _error_body["param"] == "model"
assert _error_body["code"] == "403" 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 @pytest.mark.asyncio
@ -299,7 +299,5 @@ def _validate_model_access_exception(
assert _error_body["type"] == expected_type assert _error_body["type"] == expected_type
assert _error_body["param"] == "model" assert _error_body["param"] == "model"
assert _error_body["code"] == "403" assert _error_body["code"] == "403"
if expected_type == "key_model_access_denied": assert "is not available for this API key" in _error_body["message"]
assert "key not allowed to access model" in _error_body["message"] assert "not allowed to access model" not in _error_body["message"]
elif expected_type == "team_model_access_denied":
assert "eam not allowed to access model" 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: if expect_to_work:
await can_key_call_model(**args) await can_key_call_model(**args)
else: 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) await can_key_call_model(**args)
print(e) print(e)
@ -943,7 +943,7 @@ async def test_can_key_call_model_with_aliases(model, alias_map, expect_to_work)
llm_router=router, llm_router=router,
) )
else: 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( await can_key_call_model(
model=model, model=model,
llm_model_list=llm_model_list, llm_model_list=llm_model_list,

View file

@ -138,11 +138,9 @@ class TestCheckModelAccess:
assert "claude-3-opus-20240229" in result.message assert "claude-3-opus-20240229" in result.message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_should_log_internal_denial_reason_when_client_message_is_configured(self, monkeypatch, caplog): async def test_should_log_internal_denial_reason_and_hide_allowlist_from_client(self, caplog):
import litellm
from litellm.proxy._types import UserAPIKeyAuth 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"]) auth = UserAPIKeyAuth(api_key="sk-test-key", models=["gpt-3.5-turbo"])
with caplog.at_level("WARNING", logger="LiteLLM"): 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("direct-model", team_object, None) is True
assert await can_team_access_model("group-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) await can_team_access_model("blocked-model", team_object, None)
assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied
assert "direct-model" in exc_info.value.message assert "direct-model" in exc_info.value.internal_message
assert "group-model" in exc_info.value.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 @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 # Should raise ProxyException with appropriate error type
assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied 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) 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): def test_can_object_call_model_denial_hides_allowlist_and_keeps_detail_on_exception(caplog):
monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE)
with caplog.at_level("DEBUG", logger="LiteLLM Proxy"): with caplog.at_level("DEBUG", logger="LiteLLM Proxy"):
with pytest.raises(ModelAccessDeniedProxyException) as exc_info: with pytest.raises(ModelAccessDeniedProxyException) as exc_info:
_can_object_call_model( _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", object_type="key",
) )
assert ( assert exc_info.value.message == _DENIED_MESSAGE_TEMPLATE.format(model="anthropic-sonnet-4-5")
exc_info.value.message == "The model `anthropic-sonnet-4-5` is unavailable for this API key or does not exist." assert "internal-models" not in exc_info.value.message
)
assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied
assert exc_info.value.param == "model" assert exc_info.value.param == "model"
assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN 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 @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 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"]) team_object = LiteLLM_TeamTable(team_id="team-123", models=["direct-model"], access_group_ids=["ag-1"])
with ( 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 assert "not allowed to access model" not in caplog.text
@pytest.mark.parametrize("unset_value", [None, ""]) @pytest.mark.parametrize(
def test_can_object_call_model_denial_unchanged_when_message_not_configured(monkeypatch, unset_value): "object_type, expected_type",
monkeypatch.setattr(litellm, "model_access_denied_message", unset_value) [
("team", ProxyErrorTypes.team_model_access_denied),
with pytest.raises(ProxyException) as exc_info: ("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( _can_object_call_model(
model="anthropic-sonnet-4-5", model="anthropic-sonnet-4-5",
llm_router=None, llm_router=None,
models=["internal-models"], models=["internal-models"],
object_type="team", object_type=object_type,
) )
assert exc_info.value.message == ( assert exc_info.value.message == _DENIED_MESSAGE_TEMPLATE.format(model="anthropic-sonnet-4-5")
"team not allowed to access model. This team can only access models=['internal-models']. " assert exc_info.value.type == expected_type
"Tried to access anthropic-sonnet-4-5" assert f"{object_type} not allowed to access model" in exc_info.value.internal_message
)
@pytest.mark.asyncio @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._types import SpecialModelNames
from litellm.proxy.auth.auth_checks import can_user_call_model 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]) 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) 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 assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio @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._types import LiteLLM_TeamMembership
from litellm.proxy.auth.auth_checks import _check_team_member_model_access 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 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( membership = LiteLLM_TeamMembership(
user_id="alice", user_id="alice",
team_id="team-a", 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, model_type=LiteLLM_TeamMembership,
) )
with pytest.raises(ProxyException) as exc_info: with pytest.raises(ModelAccessDeniedProxyException) as exc_info:
await _check_team_member_model_access( await _check_team_member_model_access(
model="mock-vision", model="mock-vision",
team_object=LiteLLM_TeamTable(team_id="team-a"), 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(), 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 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._logging import verbose_proxy_logger
from litellm.constants import INVALID_VIRTUAL_KEY_ERROR_MARKER from litellm.constants import INVALID_VIRTUAL_KEY_ERROR_MARKER
from litellm.exceptions import BudgetExceededError 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 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: def _denied_proxy_exception() -> ModelAccessDeniedProxyException:
return 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']. " 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", "Tried to access gpt-5.6\r\nWARNING forged log line",
type=ProxyErrorTypes.key_model_access_denied, 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. " internal_message="Role=engineer not allowed to call model=gpt-5.6\r\nWARNING forged log line. "
"Allowed models=['internal-models']", "Allowed models=['internal-models']",
status_code=status.HTTP_403_FORBIDDEN, 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"), pytest.param(_denied_jwt_exception, id="jwt_http_exception"),
], ],
) )
async def test_handle_authentication_error_keeps_internal_message_on_model_access_denial( async def test_handle_authentication_error_keeps_internal_message_on_model_access_denial(make_denial, caplog):
monkeypatch, make_denial, caplog
):
monkeypatch.setattr(litellm, "model_access_denied_message", _DENIED_MESSAGE_TEMPLATE)
handler = UserAPIKeyAuthExceptionHandler() handler = UserAPIKeyAuthExceptionHandler()
denial = make_denial() 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(): 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( denial = ModelAccessDeniedHTTPException(
internal_message="model=gpt-5.6 not allowed. Allowed_models=['internal-models']", internal_message="model=gpt-5.6 not allowed. Allowed_models=['internal-models']",
status_code=status.HTTP_403_FORBIDDEN, 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.to_dict() == plain.to_dict()
assert converted.internal_message == denial.internal_message 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 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( await can_team_access_model(
model=model, model=model,
team_object=LiteLLM_TeamTable(team_id="team-other", models=["some-other-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 assert exc_info.value.status_code == 403
_JWT_DENIED_MESSAGE_TEMPLATE = "The model `{model}` 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."
@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."),
],
) )
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 = { general_settings = {
"role_permissions": [ "role_permissions": [
RoleBasedPermissions(role=LitellmUserRoles.INTERNAL_USER, models=["gpt-5.6-mini"]), 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.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 == ( assert exc_info.value.internal_message == (
"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']"
) )
@pytest.mark.parametrize( def test_check_scope_based_access_denial_hides_scope_allowlist_from_client():
"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)
with pytest.raises(ModelAccessDeniedHTTPException) as exc_info: with pytest.raises(ModelAccessDeniedHTTPException) as exc_info:
JWTAuthManager.check_scope_based_access( JWTAuthManager.check_scope_based_access(
scope_mappings=[ScopeMapping(scope="litellm.api.consumer", models=["gpt-5.6-mini"])], 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.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']" 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 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() mock_route_request.assert_not_called()
finally: finally:
proxy_app.dependency_overrides.pop(user_api_key_auth, None) 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 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() mock_route_request.assert_not_called()
finally: finally:
proxy_app.dependency_overrides.pop(user_api_key_auth, None) 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 response.status_code == 403
assert "team" in response.text.lower() 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() mock_route_request.assert_not_called()
finally: finally:
proxy_app.dependency_overrides.pop(user_api_key_auth, None) 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 response.status_code == 403
assert "project" in response.text.lower() 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() mock_route_request.assert_not_called()
finally: finally:
proxy_app.dependency_overrides.pop(user_api_key_auth, None) 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 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() mock_route_request.assert_not_called()
finally: finally:
proxy_app.dependency_overrides.pop(user_api_key_auth, None) 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() websocket.close.assert_awaited_once()
_, close_kwargs = websocket.close.call_args _, close_kwargs = websocket.close.call_args
assert close_kwargs["code"] == 1008 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 @pytest.mark.asyncio
@ -825,7 +825,7 @@ async def test_realtime_transcription_websocket_default_model_checks_team_scope(
websocket.close.assert_awaited_once() websocket.close.assert_awaited_once()
_, close_kwargs = websocket.close.call_args _, close_kwargs = websocket.close.call_args
assert close_kwargs["code"] == 1008 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 @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 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(): def _model_access_denied_proxy_exception():
return 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="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']. " 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", "Tried to access gpt-5.6\r\nWARNING forged log line",
type=ProxyErrorTypes.key_model_access_denied, type=ProxyErrorTypes.key_model_access_denied,
@ -11022,11 +10972,7 @@ def _http_request_scope():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_openai_exception_handler_logs_sanitized_model_access_denial(monkeypatch, caplog): async def test_openai_exception_handler_logs_sanitized_model_access_denial(caplog):
monkeypatch.setattr(
litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key or does not exist."
)
with caplog.at_level("WARNING", logger="LiteLLM Proxy"): with caplog.at_level("WARNING", logger="LiteLLM Proxy"):
response = await openai_exception_handler(_http_request_scope(), _model_access_denied_proxy_exception()) 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.asyncio
@pytest.mark.parametrize("unset_value", [None, ""]) async def test_openai_exception_handler_no_denial_log_for_plain_proxy_exception(caplog):
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."
)
denial = ProxyException( denial = ProxyException(
message="Authentication Error, Invalid proxy server token passed", message="Authentication Error, Invalid proxy server token passed",
type=ProxyErrorTypes.auth_error, type=ProxyErrorTypes.auth_error,
@ -11073,10 +11004,7 @@ async def test_openai_exception_handler_no_denial_log_for_plain_proxy_exception(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_realtime_model_access_denial_logs_sanitized_internal_message(monkeypatch, caplog): async def test_realtime_model_access_denial_logs_sanitized_internal_message(caplog):
monkeypatch.setattr(
litellm, "model_access_denied_message", "The model `{model}` is unavailable for this API key or does not exist."
)
reservation = {"reserved_cost": 0.0, "input_cost": 0.0, "finalized": False, "entries": []} reservation = {"reserved_cost": 0.0, "input_cost": 0.0, "finalized": False, "entries": []}
with caplog.at_level("WARNING", logger="LiteLLM Proxy"): 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() 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(): def test_general_settings_ui_defaults_unchanged_for_existing_fields():
"""The spec-default mechanism added for max_ui_session_budget must not change what """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).""" 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"], project_id="router-project", team_id="router-team", models=["restricted-model"],
), model_type=LiteLLM_ProjectTableCachedObj, ), 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={ await self._route(self._router(), self._request(actor=self.actor.model_copy(update={
"models": ["member-router"] if ceiling == "key" else self.actor.models, "models": ["member-router"] if ceiling == "key" else self.actor.models,
"project_id": "router-project" if ceiling == "project" else None, "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 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": []}) 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) 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) await self._route(router, request)
assert self.database.db.litellm_accessgrouptable.find_unique.await_count == 2 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, key="team_id:router-team", model_type=LiteLLM_TeamTable,
value=self.team.model_copy(update={"models": ["member-router"]}), 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()) await self._route(router, self._request())
self.database.db.litellm_teamtable.find_unique.reset_mock() self.database.db.litellm_teamtable.find_unique.reset_mock()
admin: Final = self._request(tag="admin") admin: Final = self._request(tag="admin")

View file

@ -307,7 +307,7 @@ async def test_chat_completion():
model="gpt-4", model="gpt-4",
messages=[{"role": "user", "content": "Hello!"}], 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 @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 userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import GeneralSettings from "./general_settings"; import GeneralSettings from "./general_settings";
@ -62,14 +62,6 @@ const SETTINGS_FIXTURE = [
stored_in_db: true, stored_in_db: true,
field_default_value: 1.0, 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) => { const settingsRow = async (fieldName: string) => {
@ -116,54 +108,6 @@ describe("GeneralSettings General tab", () => {
expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget"); expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget");
expect(numericValueIn(row)).toBe(1); 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", () => { describe("GeneralSettings Prompt Caching tab", () => {

View file

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