Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3570,7 +3570,44 @@ class ProxyErrorTypes(str, enum.Enum):

auth_error = "auth_error"
"""
General authentication error
General authentication error. Use only when the failure does not fit
one of the more specific auth_* types below — UI clients (litellm
dashboard) fall back to a heuristic redirect decision when they see
this type, so prefer a precise type whenever the cause is known.
"""

auth_session_expired = "auth_session_expired"
"""
The caller's session/token was once valid but is no longer (expired,
revoked, deleted, key rotated). UI clients should clear local auth
state and redirect to the login page. Distinguish from
`auth_invalid_credentials` — that one means the credential format was
bad from the start (never authenticated).
"""

auth_invalid_credentials = "auth_invalid_credentials"
"""
The supplied credential is malformed, never existed, or does not
parse — e.g. missing Authorization header, garbled bearer token, key
not found in DB. UI clients should treat this the same as
`auth_session_expired` (clear state + login) because there is no
valid session to recover.
"""

auth_permission_denied = "auth_permission_denied"
"""
The caller is authenticated but lacks the role / scope / model
access needed for this specific endpoint or resource (admin-only,
team_id mismatch, model not in allowed list). UI clients must NOT
redirect to login — the session is still valid, only this one
operation is forbidden. Surface as a toast.

Note: `key_model_access_denied`, `team_model_access_denied`,
`team_member_permission_error`, and the other granular
*_access_denied / *_permission_error types are already specific
enough; this catch-all is for the cases where the cause is "you're
not allowed" but doesn't fit those buckets (e.g. master-key-required
on a non-master-key request).
"""

internal_server_error = "internal_server_error"
Expand Down
123 changes: 121 additions & 2 deletions litellm/proxy/auth/auth_exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,112 @@
# error monitoring stream with them buries genuine issues.
_KNOWN_AUTH_ERROR_TYPES = (ProxyException, HTTPException)

# Substrings (lowercase) in an upstream HTTPException's `detail` that
# indicate the caller's session/token was once valid but is no longer.
# Used by `_classify_auth_failure` to pick the right ProxyErrorTypes value
# when wrapping HTTPException — so the UI can route on a structured type
# instead of regex-matching free-text again.
_SESSION_EXPIRED_DETAIL_MARKERS = (
"expired",
"expir", # covers expired / expiration
"revoked",
"key has been deleted",
"key has expired",
)

# Substrings indicating the supplied credential was never valid (bad
# format, missing entirely, not in DB).
_INVALID_CREDENTIALS_DETAIL_MARKERS = (
"no auth header",
"no authentication",
"no api key passed", # bare `Exception("No api key passed in.")` from user_api_key_auth.py
"no api key", # broader form
"invalid api key",
"invalid token",
"invalid bearer",
"token not found",
"key not found",
"malformed",
"malformed api key", # bare `Exception("Malformed API Key passed in. ...")`
"virtual key expected", # bare `Exception("LiteLLM Virtual Key expected. ...")`
"expected to start with 'sk-'", # tail of the same exception
)

# Substrings indicating the caller IS authenticated but lacks the
# privilege for this specific operation. The role/scope/admin language
# is the giveaway. Distinct from "expired" or "invalid" — the session is
# fine, just this endpoint isn't allowed.
_PERMISSION_DENIED_DETAIL_MARKERS = (
"not allowed",
"not authorized",
"admin only",
"admin-only",
"proxy admin", # "Only proxy admin can be used to generate ..."
"your role", # "Your role=unknown" / "Your role is not allowed ..."
"master key",
"requires", # "requires admin role", "requires master key", etc.
"permission",
"forbidden",
"access denied",
)


def _classify_auth_failure(e: Exception) -> "ProxyErrorTypes":
"""Pick a specific ProxyErrorTypes for an auth-pipeline exception
based on its status code (if any) and message text.

Rationale: the wrapper used to collapse every wrapped auth failure
into the generic `auth_error` type, leaving UI clients no way to
tell "your session is gone, redirect to login" apart from "you're
logged in but not authorized for THIS endpoint" — both arrived as
401 with `type=auth_error`. This function makes that decision once,
centrally, so the wire-format `type` field carries the action.

Works for BOTH:
- HTTPException — uses status_code + detail text
- bare Exception — uses str(e). The auth pipeline raises plenty of
these as final messages, e.g.
Exception("No api key passed in.")
Exception("LiteLLM Virtual Key expected. Received=... start with 'sk-'")
Exception("Malformed API Key passed in. Ensure Key has `Bearer` prefix.")
so the classifier MUST inspect them or the bare-Exception
catch-all in the wrapper keeps emitting plain `auth_error` and
defeats the whole point of D1.

Returns the most specific type we can confidently determine. Falls
back to `auth_error` only when truly ambiguous.

Logic (in priority order):
- HTTP 403 -> `auth_permission_denied` (semantics of 403)
- text contains an expired/revoked marker -> `auth_session_expired`
- text contains an invalid/missing-credential marker -> `auth_invalid_credentials`
- text contains a permission/role marker -> `auth_permission_denied`
(LiteLLM uses 401 for role mismatch too)
- Otherwise -> `auth_error` (UI falls back to its heuristic)
"""
status_code = getattr(e, "status_code", None)
# Prefer HTTPException.detail; fall back to str(e) so the same
# function classifies bare Exceptions from the auth pipeline.
detail_attr = str(getattr(e, "detail", "") or "")
text = (detail_attr or str(e)).lower()

if status_code == 403:
return ProxyErrorTypes.auth_permission_denied

# Order matters: check expired first because revoked keys often
# surface as "invalid" too, and we want to label them as
# session-expired (the recovery action — re-login — is the same and
# more accurate semantically).
if any(marker in text for marker in _SESSION_EXPIRED_DETAIL_MARKERS):
return ProxyErrorTypes.auth_session_expired
if any(marker in text for marker in _INVALID_CREDENTIALS_DETAIL_MARKERS):
return ProxyErrorTypes.auth_invalid_credentials
if any(marker in text for marker in _PERMISSION_DENIED_DETAIL_MARKERS):
return ProxyErrorTypes.auth_permission_denied

return ProxyErrorTypes.auth_error


if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span

Expand Down Expand Up @@ -141,17 +247,30 @@ async def _handle_authentication_error(
code=400,
)
if isinstance(e, HTTPException):
# Classify into the specific auth_* type so UI clients can
# route on `type` instead of regex-matching the free-text
# message. Centralized here rather than at each raise site
# because most HTTPExceptions in the auth pipeline come
# from FastAPI / upstream code we don't own.
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({str(e)})"),
type=ProxyErrorTypes.auth_error,
type=_classify_auth_failure(e),
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_401_UNAUTHORIZED),
)
elif isinstance(e, ProxyException):
# Inner exception already carries a specific type
# (e.g. token_not_found_in_db, expired_key, *_access_denied);
# passing it through preserves that signal end-to-end.
raise e
# Catch-all for bare Exception. Classify by message content
# so the wire-format type still carries an action signal —
# the auth pipeline raises plenty of these (e.g. missing/
# malformed/wrong-prefix key checks). _classify_auth_failure
# transparently inspects str(e) when there's no `detail`.
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
type=_classify_auth_failure(e),
param=getattr(e, "param", "None"),
code=status.HTTP_401_UNAUTHORIZED,
)
166 changes: 166 additions & 0 deletions tests/test_litellm/proxy/auth/test_auth_exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,172 @@ async def test_empty_exception_message_falls_back_to_type_name(caplog):
assert "ProxyException" in auth_records[0].getMessage()


class TestClassifyAuthFailure:
"""Pure unit tests for `_classify_auth_failure` — guards the
contract that wrapped HTTPExceptions surface as a specific auth_*
type, so UI clients can route on `error.type` instead of regex-
matching free-text messages."""

def test_403_maps_to_permission_denied_regardless_of_detail(self):
from litellm.proxy.auth.auth_exception_handler import _classify_auth_failure

e = HTTPException(status_code=403, detail="anything at all")
assert _classify_auth_failure(e) == ProxyErrorTypes.auth_permission_denied

@pytest.mark.parametrize(
"detail",
[
"Key has expired",
"Authentication Error - Expired Key",
"Your API key has been revoked",
"Key has been deleted",
],
)
def test_401_with_expired_marker_maps_to_session_expired(self, detail):
from litellm.proxy.auth.auth_exception_handler import _classify_auth_failure

e = HTTPException(status_code=401, detail=detail)
assert _classify_auth_failure(e) == ProxyErrorTypes.auth_session_expired

@pytest.mark.parametrize(
"detail",
[
"No auth header passed in",
"No authentication credentials supplied",
"Invalid API Key",
"Invalid token format",
"Invalid bearer credentials",
"Token not found in database",
"Key not found in database",
"Malformed authorization header",
],
)
def test_401_with_invalid_credential_marker_maps_to_invalid_credentials(
self, detail
):
from litellm.proxy.auth.auth_exception_handler import _classify_auth_failure

e = HTTPException(status_code=401, detail=detail)
assert _classify_auth_failure(e) == ProxyErrorTypes.auth_invalid_credentials

@pytest.mark.parametrize(
"detail",
[
"Not allowed to access this endpoint",
"Not authorized for this resource",
"Admin only endpoint",
"Admin-only operation",
"Master Key required",
"Requires admin role to access",
"Insufficient permission",
"Forbidden",
"Access denied",
],
)
def test_401_with_permission_marker_maps_to_permission_denied(self, detail):
from litellm.proxy.auth.auth_exception_handler import _classify_auth_failure

e = HTTPException(status_code=401, detail=detail)
assert _classify_auth_failure(e) == ProxyErrorTypes.auth_permission_denied

def test_401_with_unknown_detail_falls_back_to_auth_error(self):
# Critical fallback: ambiguous messages must NOT silently map to
# a wrong specific type. The UI's heuristic handles auth_error.
from litellm.proxy.auth.auth_exception_handler import _classify_auth_failure

e = HTTPException(status_code=401, detail="something we have never seen")
assert _classify_auth_failure(e) == ProxyErrorTypes.auth_error

def test_401_with_empty_detail_falls_back_to_auth_error(self):
from litellm.proxy.auth.auth_exception_handler import _classify_auth_failure

e = HTTPException(status_code=401, detail="")
assert _classify_auth_failure(e) == ProxyErrorTypes.auth_error

def test_expired_markers_take_priority_over_invalid_markers(self):
# A revoked key can surface as both "invalid" and "revoked" — we
# prefer session_expired (the recovery flow is the same and the
# label is semantically more accurate).
from litellm.proxy.auth.auth_exception_handler import _classify_auth_failure

e = HTTPException(status_code=401, detail="Invalid API Key - has been revoked")
assert _classify_auth_failure(e) == ProxyErrorTypes.auth_session_expired


@pytest.mark.asyncio
async def test_wrapped_httpexception_carries_classified_type():
"""Wire-format contract: a wrapped HTTPException emerges as a
ProxyException with the classified specific type, not the generic
`auth_error`. UI's PR D2 handleErrorResponse keys off this."""
handler = UserAPIKeyAuthExceptionHandler()
mock_request = MagicMock()
mock_request.headers = {}

with (
patch(
"litellm.proxy.proxy_server.general_settings",
{"allow_requests_on_db_unavailable": False},
),
patch(
"litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook",
# AsyncMock's default return is a MagicMock — the wrapper at
# auth_exception_handler.py treats any truthy return as
# `transformed_exception` and replaces the original `e`,
# which destroys the type we want to assert. Pin
# return_value=None so the HTTPException flows through.
new=AsyncMock(return_value=None),
),
):
expired_http_exc = HTTPException(
status_code=401, detail="Authentication Error - Expired Key"
)
with pytest.raises(ProxyException) as exc_info:
await handler._handle_authentication_error(
expired_http_exc,
mock_request,
{},
"/v1/chat/completions",
None,
"test-key",
)

assert exc_info.value.type == ProxyErrorTypes.auth_session_expired


@pytest.mark.asyncio
async def test_wrapped_httpexception_permission_denied_carries_specific_type():
handler = UserAPIKeyAuthExceptionHandler()
mock_request = MagicMock()
mock_request.headers = {}

with (
patch(
"litellm.proxy.proxy_server.general_settings",
{"allow_requests_on_db_unavailable": False},
),
patch(
"litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook",
# Pin return None — see comment in
# test_wrapped_httpexception_carries_classified_type.
new=AsyncMock(return_value=None),
),
):
perm_http_exc = HTTPException(
status_code=401, detail="Master Key required to access this endpoint"
)
with pytest.raises(ProxyException) as exc_info:
await handler._handle_authentication_error(
perm_http_exc,
mock_request,
{},
"/key/new",
None,
"test-key",
)

assert exc_info.value.type == ProxyErrorTypes.auth_permission_denied


@pytest.mark.asyncio
async def test_route_passed_to_post_call_failure_hook():
"""
Expand Down