mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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:
commit
f0474bb70e
18 changed files with 470 additions and 46 deletions
|
|
@ -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}"),
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
18
litellm/proxy/auth/model_access_denied.py
Normal file
18
litellm/proxy/auth/model_access_denied.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 -----------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue