mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
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:
parent
14d239fc84
commit
15f2e25e8a
19 changed files with 114 additions and 377 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()] == []
|
|
||||||
|
|
|
||||||
|
|
@ -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"]),
|
||||||
|
|
|
||||||
|
|
@ -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']"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)."""
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue