Merge pull request #41310 from BerriAI/litellm_model_access_denied_message

fix(proxy): hide model allowlist from client-facing model access denied errors
This commit is contained in:
Yassin Kortam 2026-09-16 09:38:29 -07:00 committed by GitHub
commit f0474bb70e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 470 additions and 46 deletions

View file

@ -771,6 +771,7 @@ async def _check_model_access(model: str, user_api_key_auth: "UserAPIKeyAuth | N
try:
import litellm
from litellm.proxy._types import ModelAccessDeniedProxyException
from litellm.proxy.auth.auth_checks import (
_check_team_member_model_access,
can_key_call_model,
@ -884,11 +885,14 @@ async def _check_model_access(model: str, user_api_key_auth: "UserAPIKeyAuth | N
)
return None
except Exception as access_err:
verbose_logger.warning(
"MCP sampling: model access denied for model=%s: %s",
model,
access_err,
)
if isinstance(access_err, ModelAccessDeniedProxyException):
verbose_logger.warning(
"MCP sampling: model access denied for model=%s: %s",
model,
access_err.sanitized_internal_message(),
)
return ErrorData(code=-1, message=access_err.message)
verbose_logger.warning("MCP sampling: model access denied for model=%s: %s", model, access_err)
return ErrorData(
code=-1,
message=(f"Model access denied: the API key is not authorized to use model '{model}'. {access_err}"),

View file

@ -4032,6 +4032,22 @@ class ProxyException(Exception):
return error_dict
class ModelAccessDeniedProxyException(ProxyException):
def __init__(
self,
message: str,
internal_message: str,
type: str,
param: str | None,
code: int | str | None,
) -> None:
super().__init__(message=message, type=type, param=param, code=code)
self.internal_message: Final = internal_message
def sanitized_internal_message(self) -> str:
return self.internal_message.replace("\r", "").replace("\n", "")
class CommonProxyErrors(str, enum.Enum):
db_not_connected_error = (
"DB not connected. This endpoint needs a database; set DATABASE_URL to a "

View file

@ -60,6 +60,7 @@ from litellm.proxy._types import (
LiteLLM_UserTable,
LiteLLMRoutes,
LitellmUserRoles,
ModelAccessDeniedProxyException,
NewTeamRequest,
ProxyErrorTypes,
ProxyException,
@ -71,6 +72,7 @@ from litellm.proxy.auth.budget_throttle import (
budget_throttle_percentage,
should_throttle_budget_exceeded,
)
from litellm.proxy.auth.model_access_denied import model_access_denied_client_message
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import publish_auth_cache_invalidation
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
@ -4170,8 +4172,13 @@ def _can_object_call_model(
):
return True
raise ProxyException(
message=f"{object_type} not allowed to access model. This {object_type} can only access models={models}. Tried to access {model}",
internal_message: Final = (
f"{object_type} not allowed to access model. This {object_type} can only access models={models}. "
f"Tried to access {model}"
)
raise ModelAccessDeniedProxyException(
message=model_access_denied_client_message(model=model),
internal_message=internal_message,
type=ProxyErrorTypes.get_model_access_error_type_for_object(object_type=object_type),
param="model",
code=status.HTTP_403_FORBIDDEN,
@ -4796,8 +4803,13 @@ async def can_user_call_model(
return True
if SpecialModelNames.no_default_models.value in user_object.models:
raise ProxyException(
message=f"User not allowed to access model. No default model access, only team models allowed. Tried to access {model}",
internal_message: Final = (
f"User not allowed to access model. No default model access, only team models allowed. "
f"Tried to access {model}"
)
raise ModelAccessDeniedProxyException(
message=model_access_denied_client_message(model=model),
internal_message=internal_message,
type=ProxyErrorTypes.key_model_access_denied,
param="model",
code=status.HTTP_403_FORBIDDEN,
@ -5398,8 +5410,13 @@ async def _check_team_member_model_access(
team_id=team_object.team_id,
)
except ProxyException:
raise ProxyException(
message=f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, Model={model}. Allowed member models = {member_allowed_models}",
internal_message: Final = (
f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, "
f"Model={model}. Allowed member models = {member_allowed_models}"
)
raise ModelAccessDeniedProxyException(
message=model_access_denied_client_message(model=model),
internal_message=internal_message,
type=ProxyErrorTypes.team_model_access_denied,
param="model",
code=status.HTTP_403_FORBIDDEN,

View file

@ -15,6 +15,7 @@ from litellm.integrations.otel.runtime import seed_request_identity
from litellm.litellm_core_utils.core_helpers import is_expected_client_error
from litellm.proxy._types import (
LitellmUserRoles,
ModelAccessDeniedProxyException,
ProxyErrorTypes,
ProxyException,
UserAPIKeyAuth,
@ -25,6 +26,7 @@ from litellm.proxy.auth.auth_utils import (
mark_invalid_virtual_key_error,
normalize_request_route,
)
from litellm.proxy.auth.model_access_denied import ModelAccessDeniedHTTPException
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.types.services import ServiceTypes
@ -51,6 +53,14 @@ def _as_proxy_exception(e: Exception) -> ProxyException:
param=None,
code=getattr(e, "status_code", status.HTTP_429_TOO_MANY_REQUESTS),
)
if isinstance(e, ModelAccessDeniedHTTPException):
return ModelAccessDeniedProxyException(
message=str(e.detail),
internal_message=e.internal_message,
type=ProxyErrorTypes.auth_error,
param="None",
code=e.status_code,
)
if isinstance(e, HTTPException):
return ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),

View file

@ -53,6 +53,10 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_checks import can_team_access_model
from litellm.proxy.auth.model_access_denied import (
ModelAccessDeniedHTTPException,
model_access_denied_client_message,
)
from litellm.proxy.auth.resolvers.grants import GrantResolver, UserLookup, canonical_user_id
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.auth.team_grants import team_grants, team_model_aliases
@ -1352,9 +1356,13 @@ class JWTAuthManager:
return True
if model not in role_based_models:
raise HTTPException(
internal_message: Final = (
f"Role={rbac_role} not allowed to call model={model}. Allowed models={role_based_models}"
)
raise ModelAccessDeniedHTTPException(
internal_message=internal_message,
status_code=403,
detail=f"Role={rbac_role} not allowed to call model={model}. Allowed models={role_based_models}",
detail=model_access_denied_client_message(model=model),
)
return True
@ -1383,9 +1391,11 @@ class JWTAuthManager:
return
if requested_model not in allowed_models:
raise HTTPException(
internal_message: Final = f"model={requested_model} not allowed. Allowed_models={allowed_models}"
raise ModelAccessDeniedHTTPException(
internal_message=internal_message,
status_code=403,
detail={"error": f"model={requested_model} not allowed. Allowed_models={allowed_models}"},
detail={"error": model_access_denied_client_message(model=requested_model)},
)
return

View file

@ -0,0 +1,18 @@
from typing import Final
from fastapi import HTTPException
MODEL_ACCESS_DENIED_CLIENT_MESSAGE: Final = (
"The requested model '{model}' is not available for this API key, or the model name is invalid. "
"Check the models available to you and try again."
)
def model_access_denied_client_message(model: str | list[str]) -> str:
return MODEL_ACCESS_DENIED_CLIENT_MESSAGE.format(model=model)
class ModelAccessDeniedHTTPException(HTTPException):
def __init__(self, internal_message: str, status_code: int, detail: str | dict[str, str]) -> None:
super().__init__(status_code=status_code, detail=detail)
self.internal_message: Final = internal_message

View file

@ -106,6 +106,7 @@ from litellm.proxy._types import (
LiteLLM_TeamTableCachedObj,
LiteLLM_UserTable,
LitellmUserRoles,
ModelAccessDeniedProxyException,
PassThroughGenericEndpoint,
ProxyErrorTypes,
ProxyException,
@ -1668,6 +1669,7 @@ class UserAPIKeyCacheTTLEnum(enum.Enum):
@app.exception_handler(ProxyException)
async def openai_exception_handler(request: Request, exc: ProxyException):
# NOTE: DO NOT MODIFY THIS, its crucial to map to Openai exceptions
_log_model_access_denial(exc)
headers: Final = exc.headers
error_dict: Final = exc.to_dict()
status_code: Final = int(exc.code) if exc.code else status.HTTP_500_INTERNAL_SERVER_ERROR
@ -1679,6 +1681,12 @@ async def openai_exception_handler(request: Request, exc: ProxyException):
)
def _log_model_access_denial(exc: ProxyException) -> None:
if not isinstance(exc, ModelAccessDeniedProxyException):
return
verbose_proxy_logger.warning(exc.sanitized_internal_message())
def _close_dangling_otel_server_span(request: Request, status_code: int, exc: Exception | None = None) -> None:
parent_otel_span: Final[_Span | None] = getattr(request.state, "parent_otel_span", None)
if parent_otel_span is None:
@ -11978,6 +11986,7 @@ async def realtime_websocket_endpoint(
llm_router=llm_router,
)
except ProxyException as e:
_log_model_access_denial(e)
await _reject_realtime_session(websocket, user_api_key_dict, code=1008, reason=e.message[:120])
return
await websocket.accept(**accept_kwargs)

View file

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

View file

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

View file

@ -137,6 +137,22 @@ class TestCheckModelAccess:
assert result.code == -1
assert "claude-3-opus-20240229" in result.message
@pytest.mark.asyncio
async def test_should_log_internal_denial_reason_and_hide_allowlist_from_client(self, caplog):
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.model_access_denied import model_access_denied_client_message
auth = UserAPIKeyAuth(api_key="sk-test-key", models=["gpt-3.5-turbo"])
with caplog.at_level("WARNING", logger="LiteLLM"):
result = await _check_model_access("gpt-4o\r\nforged", user_api_key_auth=auth)
assert result is not None
assert result.message == model_access_denied_client_message(model="gpt-4o\r\nforged")
denial_records = [r for r in caplog.records if "gpt-3.5-turbo" in r.getMessage()]
assert len(denial_records) == 1
assert "Tried to access gpt-4oforged" in denial_records[0].getMessage()
@pytest.mark.asyncio
async def test_should_deny_empty_oauth_passthrough_placeholder(self):
"""Regression: process_mcp_request() returns an empty UserAPIKeyAuth()

View file

@ -25,6 +25,7 @@ from litellm.proxy._types import (
LiteLLM_TeamTable,
LiteLLM_UserTable,
LitellmUserRoles,
ModelAccessDeniedProxyException,
ProxyErrorTypes,
ProxyException,
SSOUserDefinedValues,
@ -530,12 +531,14 @@ async def test_can_team_access_model_error_lists_direct_and_access_group_models(
assert await can_team_access_model("direct-model", team_object, None) is True
assert await can_team_access_model("group-model", team_object, None) is True
with pytest.raises(ProxyException) as exc_info:
with pytest.raises(ModelAccessDeniedProxyException) as exc_info:
await can_team_access_model("blocked-model", team_object, None)
assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied
assert "direct-model" in exc_info.value.message
assert "group-model" in exc_info.value.message
assert "direct-model" in exc_info.value.internal_message
assert "group-model" in exc_info.value.internal_message
assert "direct-model" not in exc_info.value.message
assert "group-model" not in exc_info.value.message
@pytest.mark.asyncio
@ -1675,10 +1678,128 @@ def test_can_object_call_model_no_access_to_alias_or_underlying():
# Should raise ProxyException with appropriate error type
assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied
assert "key not allowed to access model" in str(exc_info.value.message)
assert "is not available for this API key" in str(exc_info.value.message)
assert "my-fake-gpt" in str(exc_info.value.message)
_DENIED_MESSAGE_TEMPLATE: Final = (
"The 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_hides_allowlist_and_keeps_detail_on_exception(caplog):
with caplog.at_level("DEBUG", logger="LiteLLM Proxy"):
with pytest.raises(ModelAccessDeniedProxyException) as exc_info:
_can_object_call_model(
model="anthropic-sonnet-4-5",
llm_router=None,
models=["internal-models"],
object_type="key",
)
assert exc_info.value.message == _DENIED_MESSAGE_TEMPLATE.format(model="anthropic-sonnet-4-5")
assert "internal-models" not in exc_info.value.message
assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied
assert exc_info.value.param == "model"
assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN
assert exc_info.value.internal_message == (
"key not allowed to access model. This key can only access models=['internal-models']. "
"Tried to access anthropic-sonnet-4-5"
)
assert "internal-models" not in caplog.text
@pytest.mark.asyncio
async def test_access_group_fallback_grant_does_not_log_a_denial(caplog):
from litellm.proxy.auth.auth_checks import can_team_access_model
team_object = LiteLLM_TeamTable(team_id="team-123", models=["direct-model"], access_group_ids=["ag-1"])
with (
patch( # test-quality-ok: access-group lookup has no dependency-injection seam
"litellm.proxy.auth.auth_checks._get_models_from_access_groups",
new=AsyncMock(return_value=["group-model"]),
),
caplog.at_level("DEBUG", logger="LiteLLM Proxy"),
):
assert await can_team_access_model("group-model", team_object, None) is True
assert "not allowed to access model" not in caplog.text
@pytest.mark.parametrize(
"object_type, expected_type",
[
("team", ProxyErrorTypes.team_model_access_denied),
("user", ProxyErrorTypes.user_model_access_denied),
("org", ProxyErrorTypes.org_model_access_denied),
],
)
def test_can_object_call_model_denial_same_client_message_for_every_object_type(object_type, expected_type):
with pytest.raises(ModelAccessDeniedProxyException) as exc_info:
_can_object_call_model(
model="anthropic-sonnet-4-5",
llm_router=None,
models=["internal-models"],
object_type=object_type,
)
assert exc_info.value.message == _DENIED_MESSAGE_TEMPLATE.format(model="anthropic-sonnet-4-5")
assert exc_info.value.type == expected_type
assert f"{object_type} not allowed to access model" in exc_info.value.internal_message
@pytest.mark.asyncio
async def test_can_user_call_model_no_default_models_hides_policy_detail():
from litellm.proxy._types import SpecialModelNames
from litellm.proxy.auth.auth_checks import can_user_call_model
user_object = LiteLLM_UserTable(user_id="test-user", models=[SpecialModelNames.no_default_models.value])
with pytest.raises(ModelAccessDeniedProxyException) as exc_info:
await can_user_call_model(model="restricted-model", llm_router=None, user_object=user_object)
assert exc_info.value.message == _DENIED_MESSAGE_TEMPLATE.format(model="restricted-model")
assert "only team models allowed" in exc_info.value.internal_message
assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
async def test_check_team_member_model_access_denied_hides_member_allowlist():
from litellm.proxy._types import LiteLLM_TeamMembership
from litellm.proxy.auth.auth_checks import _check_team_member_model_access
from litellm.proxy.common_utils.user_api_key_cache import team_membership_reservation_cache_key
membership = LiteLLM_TeamMembership(
user_id="alice",
team_id="team-a",
litellm_budget_table=LiteLLM_BudgetTable(allowed_models=["fast-models"]),
)
cache = UserApiKeyCache()
await cache.async_set_cache(
key=team_membership_reservation_cache_key(user_id="alice", team_id="team-a"),
value=membership,
model_type=LiteLLM_TeamMembership,
)
with pytest.raises(ModelAccessDeniedProxyException) as exc_info:
await _check_team_member_model_access(
model="mock-vision",
team_object=LiteLLM_TeamTable(team_id="team-a"),
valid_token=UserAPIKeyAuth(token="sk-test", user_id="alice", team_id="team-a"),
llm_router=_make_team_scoped_router(),
prisma_client=None,
user_api_key_cache=cache,
proxy_logging_obj=MagicMock(),
)
assert exc_info.value.message == _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
# -- Team-member access-group resolution with team-scoped DB models -----------

View file

@ -29,8 +29,14 @@ from prisma.errors import (
from litellm._logging import verbose_proxy_logger
from litellm.constants import INVALID_VIRTUAL_KEY_ERROR_MARKER
from litellm.exceptions import BudgetExceededError
from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth
from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler
from litellm.proxy._types import (
ModelAccessDeniedProxyException,
ProxyErrorTypes,
ProxyException,
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler, _as_proxy_exception
from litellm.proxy.auth.model_access_denied import ModelAccessDeniedHTTPException
class _EngineHttp500:
@ -982,3 +988,80 @@ async def test_handle_authentication_error_traceback_only_for_unexpected_errors(
assert records[0].levelname == expect_level
expected_logger_name = "LiteLLM Proxy.stdout" if expect_level == "WARNING" else "LiteLLM Proxy"
assert records[0].name == expected_logger_name
_DENIED_CLIENT_MESSAGE = (
"The requested model 'gpt-5.6' is not available for this API key, or the model name is invalid. "
"Check the models available to you and try again."
)
def _denied_proxy_exception() -> ModelAccessDeniedProxyException:
return ModelAccessDeniedProxyException(
message=_DENIED_CLIENT_MESSAGE,
internal_message="key not allowed to access model. This key can only access models=['internal-models']. "
"Tried to access gpt-5.6\r\nWARNING forged log line",
type=ProxyErrorTypes.key_model_access_denied,
param="model",
code=status.HTTP_403_FORBIDDEN,
)
def _denied_jwt_exception() -> ModelAccessDeniedHTTPException:
return ModelAccessDeniedHTTPException(
internal_message="Role=engineer not allowed to call model=gpt-5.6\r\nWARNING forged log line. "
"Allowed models=['internal-models']",
status_code=status.HTTP_403_FORBIDDEN,
detail=_DENIED_CLIENT_MESSAGE,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"make_denial",
[
pytest.param(_denied_proxy_exception, id="proxy_exception"),
pytest.param(_denied_jwt_exception, id="jwt_http_exception"),
],
)
async def test_handle_authentication_error_keeps_internal_message_on_model_access_denial(make_denial, caplog):
handler = UserAPIKeyAuthExceptionHandler()
denial = make_denial()
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(ModelAccessDeniedProxyException) as exc_info,
):
await handler._handle_authentication_error(denial, MagicMock(), {}, "/v1/chat/completions", None, "sk-bad-key")
assert exc_info.value.code == str(status.HTTP_403_FORBIDDEN)
assert "internal-models" not in str(exc_info.value.message)
assert exc_info.value.internal_message == denial.internal_message
assert [r for r in caplog.records if r.levelname == "WARNING" and "internal-models" in r.getMessage()] == []
def test_as_proxy_exception_keeps_jwt_scope_denial_message_shape():
detail = {"error": _DENIED_CLIENT_MESSAGE}
denial = ModelAccessDeniedHTTPException(
internal_message="model=gpt-5.6 not allowed. Allowed_models=['internal-models']",
status_code=status.HTTP_403_FORBIDDEN,
detail=detail,
)
plain = _as_proxy_exception(HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail))
converted = _as_proxy_exception(denial)
assert converted.to_dict() == plain.to_dict()
assert converted.internal_message == denial.internal_message

View file

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

View file

@ -9,6 +9,8 @@ from fastapi import HTTPException
import httpx
import pytest
import litellm
from litellm.proxy._types import (
DEFAULT_JWKS_STALE_TTL,
JWTLiteLLMRoleMap,
@ -21,6 +23,8 @@ from litellm.proxy._types import (
Member,
ProxyErrorTypes,
ProxyException,
RoleBasedPermissions,
ScopeMapping,
)
from litellm.caching.dual_cache import DualCache
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
@ -33,6 +37,7 @@ from litellm.proxy.auth.handle_jwt import (
JWTHandler,
NoMatchingJWTPublicKeyError,
)
from litellm.proxy.auth.model_access_denied import ModelAccessDeniedHTTPException
from litellm.types.agents import AgentResponse
@ -7067,6 +7072,47 @@ async def test_auth_builder_denies_jwt_naming_unregistered_agent_before_admin_ch
assert exc_info.value.status_code == 403
_JWT_DENIED_CLIENT_MESSAGE = (
"The requested model 'gpt-5.6' is not available for this API key, or the model name is invalid. "
"Check the models available to you and try again."
)
def test_can_rbac_role_call_model_denial_hides_role_allowlist_from_client():
general_settings = {
"role_permissions": [
RoleBasedPermissions(role=LitellmUserRoles.INTERNAL_USER, models=["gpt-5.6-mini"]),
]
}
with pytest.raises(ModelAccessDeniedHTTPException) as exc_info:
JWTAuthManager.can_rbac_role_call_model(
rbac_role=LitellmUserRoles.INTERNAL_USER,
general_settings=general_settings,
model="gpt-5.6",
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == _JWT_DENIED_CLIENT_MESSAGE
assert exc_info.value.internal_message == (
"Role=internal_user not allowed to call model=gpt-5.6. Allowed models=['gpt-5.6-mini']"
)
def test_check_scope_based_access_denial_hides_scope_allowlist_from_client():
with pytest.raises(ModelAccessDeniedHTTPException) as exc_info:
JWTAuthManager.check_scope_based_access(
scope_mappings=[ScopeMapping(scope="litellm.api.consumer", models=["gpt-5.6-mini"])],
scopes=["litellm.api.consumer"],
request_data={"model": "gpt-5.6"},
general_settings={},
)
assert exc_info.value.status_code == 403
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']"
@pytest.mark.asyncio
@pytest.mark.parametrize("admission", [False, True])
async def test_admin_jwt_team_header_only_provisions_during_admission(monkeypatch, admission: bool):

View file

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

View file

@ -19,7 +19,7 @@ import fastapi.routing
import httpx
import pytest
import yaml
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.staticfiles import StaticFiles
from fastapi.testclient import TestClient
@ -31,10 +31,17 @@ from litellm.caching.caching import RedisCache
from litellm.caching.redis_cluster_cache import RedisClusterCache
from litellm.litellm_core_utils.get_model_cost_map import ModelCostMapReloaded
from litellm.caching.dual_cache import DualCache
from litellm.proxy._types import LitellmUserRoles, TokenCountRequest, UserAPIKeyAuth
from litellm.proxy._types import (
LitellmUserRoles,
ModelAccessDeniedProxyException,
ProxyErrorTypes,
ProxyException,
TokenCountRequest,
UserAPIKeyAuth,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.hooks.parallel_request_limiter_v3 import RequestRateLimiterStash
from litellm.proxy.proxy_server import app, initialize
from litellm.proxy.proxy_server import app, initialize, openai_exception_handler
from litellm.utils import _invalidate_model_cost_lowercase_map
example_embedding_result = {
@ -10085,6 +10092,7 @@ async def _lit6973_drive_realtime_session(
backend_logged_failure: bool = False,
phase_one_exit: str | None = None,
websocket: MagicMock | None = None,
model_access_exception: ProxyException | None = None,
) -> MagicMock:
"""Drive realtime_websocket_endpoint through one of its reservation-settling exits.
@ -10117,10 +10125,10 @@ async def _lit6973_drive_realtime_session(
if backend_logged_failure:
logging_obj.model_call_details[REALTIME_SESSION_FAILURE_LOGGED_KEY] = True
from litellm.proxy._types import ProxyException
model_access_error: Final = (
ProxyException(message="key cannot access model", type="auth_error", param="model", code=401)
model_access_exception
if model_access_exception is not None
else ProxyException(message="key cannot access model", type="auth_error", param="model", code=401)
if phase_one_exit == "model_access"
else None
)
@ -10947,6 +10955,74 @@ 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
def _model_access_denied_proxy_exception():
return ModelAccessDeniedProxyException(
message="The requested model 'gpt-5.6\r\nWARNING forged log line' is not available for this API key, "
"or the model name is invalid. Check the models available to you and try again.",
internal_message="key not allowed to access model. This key can only access models=['internal-models']. "
"Tried to access gpt-5.6\r\nWARNING forged log line",
type=ProxyErrorTypes.key_model_access_denied,
param="model",
code=403,
)
def _http_request_scope():
return Request({"type": "http", "method": "POST", "path": "/v1/chat/completions", "headers": []})
@pytest.mark.asyncio
async def test_openai_exception_handler_logs_sanitized_model_access_denial(caplog):
with caplog.at_level("WARNING", logger="LiteLLM Proxy"):
response = await openai_exception_handler(_http_request_scope(), _model_access_denied_proxy_exception())
assert response.status_code == 403
body = json.loads(response.body)
assert "internal-models" not in body["error"]["message"]
denial_records = [r for r in caplog.records if "internal-models" in r.getMessage()]
assert len(denial_records) == 1
assert denial_records[0].levelname == "WARNING"
assert "\n" not in denial_records[0].getMessage()
assert "\r" not in denial_records[0].getMessage()
assert "gpt-5.6WARNING forged log line" in denial_records[0].getMessage()
@pytest.mark.asyncio
async def test_openai_exception_handler_no_denial_log_for_plain_proxy_exception(caplog):
denial = ProxyException(
message="Authentication Error, Invalid proxy server token passed",
type=ProxyErrorTypes.auth_error,
param="None",
code=401,
)
with caplog.at_level("WARNING", logger="LiteLLM Proxy"):
response = await openai_exception_handler(_http_request_scope(), denial)
assert response.status_code == 401
assert [r for r in caplog.records if r.levelname == "WARNING"] == []
@pytest.mark.asyncio
async def test_realtime_model_access_denial_logs_sanitized_internal_message(caplog):
reservation = {"reserved_cost": 0.0, "input_cost": 0.0, "finalized": False, "entries": []}
with caplog.at_level("WARNING", logger="LiteLLM Proxy"):
ws = await _lit6973_drive_realtime_session(
reservation,
backend_logged_success=False,
phase_one_exit="model_access",
model_access_exception=_model_access_denied_proxy_exception(),
)
ws.close.assert_awaited_once()
assert "internal-models" not in ws.close.await_args.kwargs["reason"]
denial_records = [r for r in caplog.records if "internal-models" in r.getMessage()]
assert len(denial_records) == 1
assert "\n" not in denial_records[0].getMessage()
assert "gpt-5.6WARNING forged log line" in denial_records[0].getMessage()
def test_general_settings_ui_defaults_unchanged_for_existing_fields():
"""The spec-default mechanism added for max_ui_session_budget must not change what
clearing the pre-existing fields restores (None for Float/Select, False for Boolean)."""

View file

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

View file

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