diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index 2eb25cc6105..ed743764fce 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -24,6 +24,7 @@ from litellm.proxy.auth.auth_utils import ( # noqa: F401 # legacy module expor _get_request_ip_address, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export get_request_ip_address, is_invalid_virtual_key_error, + is_unauthorized_error, mark_invalid_virtual_key_error, normalize_request_route, ) @@ -200,11 +201,11 @@ class UserAPIKeyAuthExceptionHandler: user_agent: Final = _get_user_agent(request) # Log authentication failures before identity seeding and callbacks, so the log - # survives a raising callback pipeline. Classify and route malformed virtual-key - # rejections to WARNING on stdout (suppressible via LITELLM_LOG=ERROR). + # survives a raising callback pipeline. Route 401 rejections to WARNING on stdout + # (suppressible via LITELLM_LOG=ERROR); anything else is unexpected and logs at ERROR. log_extra: Final = {"requester_ip": requester_ip} is_invalid_virtual_key: Final = is_invalid_virtual_key_error(e) - is_quiet_log: Final = is_invalid_virtual_key and not litellm.log_client_error_tracebacks + is_quiet_log: Final = is_unauthorized_error(e) and not litellm.log_client_error_tracebacks logger: Final = verbose_proxy_stdout_logger if is_quiet_log else verbose_proxy_logger logger.log( logging.WARNING if is_quiet_log else logging.ERROR, diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 30ede935cfa..aa6ddae438d 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -48,6 +48,16 @@ from litellm.types.utils import CustomPricingLiteLLMParams, oauth_token_exchange from litellm.types.videos.main import DecodedVideoId +def is_unauthorized_error(exception: BaseException | None) -> bool: + """True when the proxy itself rejected the caller's credentials with a 401.""" + if not isinstance(exception, (HTTPException, ProxyException)): + return False + + code: Final[object] = getattr(exception, "code", None) + status_code: Final[object] = code if code is not None else getattr(exception, "status_code", None) + return str(status_code) == str(status.HTTP_401_UNAUTHORIZED) + + def is_invalid_virtual_key_error(exception: BaseException | None) -> bool: """True when an authentication error rejects a malformed virtual key. @@ -57,15 +67,7 @@ def is_invalid_virtual_key_error(exception: BaseException | None) -> bool: match would let a request body demote an authorization failure to the quiet log path. """ - if not isinstance(exception, (HTTPException, ProxyException)): - return False - - code: Final[object] = getattr(exception, "code", None) - status_code: Final[object] = code if code is not None else getattr(exception, "status_code", None) - if str(status_code) != str(status.HTTP_401_UNAUTHORIZED): - return False - - return getattr(exception, INVALID_VIRTUAL_KEY_ERROR_MARKER, False) is True + return is_unauthorized_error(exception) and getattr(exception, INVALID_VIRTUAL_KEY_ERROR_MARKER, False) is True def log_model_access_denial(exc: BaseException) -> None: diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index c402f111563..74e4c4c6d11 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -14,6 +14,8 @@ from litellm.proxy._types import ( LiteLLM_UserTable, LiteLLMRoutes, LitellmUserRoles, + ProxyErrorTypes, + ProxyException, UserAPIKeyAuth, ) @@ -284,7 +286,7 @@ class RouteChecks: route (str): The route being accessed Raises: - Exception: With user role and masked user_id information + ProxyException: 401 with user role and masked user_id information """ user_role = "unknown" user_id = "unknown" @@ -293,8 +295,14 @@ class RouteChecks: user_id = user_obj.user_id or "unknown" masked_user_id: Final = RouteChecks._mask_user_id(user_id) - raise Exception( - f"Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}" + raise ProxyException( + message=( + "Authentication Error, Only proxy admin can be used to generate, delete, update info for new " + f"keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}" + ), + type=ProxyErrorTypes.auth_error, + param="None", + code=status.HTTP_401_UNAUTHORIZED, ) @staticmethod diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index b1e8bdbe787..cb490b55352 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2513,10 +2513,20 @@ async def user_api_key_auth_builder( parent_otel_span=parent_otel_span, ) elif api_key is None: # only require api key if master key is set - raise Exception("No api key passed in.") + raise ProxyException( + message="Authentication Error, No api key passed in.", + type=ProxyErrorTypes.auth_error, + param="None", + code=status.HTTP_401_UNAUTHORIZED, + ) elif api_key == "": # missing 'Bearer ' prefix - raise Exception("Malformed API Key passed in. Ensure Key has `Bearer ` prefix.") + raise ProxyException( + message="Authentication Error, Malformed API Key passed in. Ensure Key has `Bearer ` prefix.", + type=ProxyErrorTypes.auth_error, + param="None", + code=status.HTTP_401_UNAUTHORIZED, + ) if route == "/user/auth": if general_settings.get("allow_user_auth", False) is True: @@ -2752,7 +2762,12 @@ async def user_api_key_auth_builder( ## IF it's not a master key ## Route should not be in master_key_only_routes if route in LiteLLMRoutes.master_key_only_routes.value: - raise Exception(f"Tried to access route={route}, which is only for MASTER KEY") + raise ProxyException( + message=f"Authentication Error, Tried to access route={route}, which is only for MASTER KEY", + type=ProxyErrorTypes.auth_error, + param="None", + code=status.HTTP_401_UNAUTHORIZED, + ) ## Check DB @@ -2880,7 +2895,12 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e ## base case ## key is disabled if valid_token.blocked is True: - raise Exception("Key is blocked. Update via `/key/unblock` if you're an admin.") + raise ProxyException( + message="Authentication Error, Key is blocked. Update via `/key/unblock` if you're an admin.", + type=ProxyErrorTypes.auth_error, + param="None", + code=status.HTTP_401_UNAUTHORIZED, + ) await enforce_key_and_fallback_model_access( valid_token=valid_token, request_data=request_data, @@ -2923,8 +2943,14 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e and isinstance(user_obj.metadata, dict) and user_obj.metadata.get("scim_active") is False ): - raise Exception( - f"User={valid_token.user_id} has been deactivated via SCIM. Keys owned by this user cannot be used." + raise ProxyException( + message=( + f"Authentication Error, User={valid_token.user_id} has been deactivated via SCIM. " + "Keys owned by this user cannot be used." + ), + type=ProxyErrorTypes.auth_error, + param="None", + code=status.HTTP_401_UNAUTHORIZED, ) # Check 2a. Check if model has zero cost - if so, skip all budget checks @@ -3241,7 +3267,12 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e if valid_token is None: # No token was found when looking up in the DB - raise Exception("Invalid proxy server token passed") + raise ProxyException( + message="Authentication Error, Invalid proxy server token passed", + type=ProxyErrorTypes.auth_error, + param="None", + code=status.HTTP_401_UNAUTHORIZED, + ) if valid_token_dict is not None: virtual_key_auth_obj: Final = await _return_user_api_key_auth_obj( user_obj=user_obj, diff --git a/tests/unit/proxy/auth/test_auth_exception_handler.py b/tests/unit/proxy/auth/test_auth_exception_handler.py index e21491fd996..92b4c93118f 100644 --- a/tests/unit/proxy/auth/test_auth_exception_handler.py +++ b/tests/unit/proxy/auth/test_auth_exception_handler.py @@ -668,7 +668,7 @@ async def test_expired_key_error_log_names_the_key_owner( ): verbose_proxy_logger.propagate = True try: - with caplog.at_level("ERROR", logger="LiteLLM Proxy"), pytest.raises(ProxyException): + with caplog.at_level("WARNING", logger="LiteLLM Proxy"), pytest.raises(ProxyException): await handler.handle_authentication_error( expired_key_error, MagicMock(), @@ -714,8 +714,8 @@ async def test_expired_key_error_log_names_the_key_owner( async def test_unknown_key_error_log_keeps_the_operator_wording_the_401_hides( looked_up, logged_fragment, absent_fragment, caplog ): - """A wrong key gets a generic 401, while the one auth failure log line keeps the - operator wording and the hash that was looked up, next to the requester IP.""" + """A wrong key gets a generic 401, while the one auth failure log line, a WARNING with no + traceback, keeps the operator wording and the hash that was looked up, next to the requester IP.""" from litellm.constants import INVALID_API_KEY_ERROR_MESSAGE from litellm.proxy.auth.resolvers.exceptions import KeyNotFoundError @@ -733,7 +733,7 @@ async def test_unknown_key_error_log_keeps_the_operator_wording_the_401_hides( ): verbose_proxy_logger.propagate = True try: - with caplog.at_level("ERROR", logger="LiteLLM Proxy"), pytest.raises(ProxyException) as exc_info: + with caplog.at_level("WARNING", logger="LiteLLM Proxy"), pytest.raises(ProxyException) as exc_info: await UserAPIKeyAuthExceptionHandler().handle_authentication_error( KeyNotFoundError(looked_up), MagicMock(), @@ -748,6 +748,7 @@ async def test_unknown_key_error_log_keeps_the_operator_wording_the_401_hides( assert exc_info.value.message == INVALID_API_KEY_ERROR_MESSAGE records = [r for r in caplog.records if "user_api_key_auth(): Exception occured" in r.getMessage()] assert len(records) == 1, [r.getMessage() for r in caplog.records] + assert records[0].levelname == "WARNING" and records[0].exc_info is None logged = records[0].getMessage() assert "Requester IP Address:" in logged and logged_fragment in logged, logged if absent_fragment is not None: @@ -1148,10 +1149,16 @@ def _marked_malformed_key_error() -> HTTPException: message="Authentication Error", type=ProxyErrorTypes.auth_error, param=None, code=401 ), False, - "ERROR", - id="expected_401_no_traceback", + "WARNING", + id="proxy_exception_401_warning_no_traceback", ), pytest.param(ValueError("unexpected internal error"), True, "ERROR", id="unexpected_error_keeps_traceback"), + pytest.param( + Exception("No api key passed in."), + True, + "ERROR", + id="bare_exception_coerced_to_401_still_keeps_traceback", + ), pytest.param( _marked_malformed_key_error(), False, @@ -1161,8 +1168,14 @@ def _marked_malformed_key_error() -> HTTPException: pytest.param( HTTPException(status_code=401, detail="LiteLLM Virtual Key expected. Received=test"), False, + "WARNING", + id="http_exception_401_warning_no_traceback", + ), + pytest.param( + ProxyException(message="Forbidden", type=ProxyErrorTypes.auth_error, param=None, code=403), + False, "ERROR", - id="phrase_without_marker_stays_loud", + id="403_stays_error_without_traceback", ), ], ) @@ -1189,7 +1202,7 @@ async def test_handle_authentication_error_traceback_only_for_unexpected_errors( try: try: raise auth_error - except (ProxyException, ValueError, HTTPException) as caught: + except Exception as caught: with caplog.at_level(expect_level, logger="LiteLLM Proxy"), pytest.raises((ProxyException, HTTPException)): await handler.handle_authentication_error( caught, diff --git a/tests/unit/proxy/auth/test_route_checks.py b/tests/unit/proxy/auth/test_route_checks.py index 6de54aa4af6..b2c67b4e009 100644 --- a/tests/unit/proxy/auth/test_route_checks.py +++ b/tests/unit/proxy/auth/test_route_checks.py @@ -3742,7 +3742,7 @@ def test_team_spend_by_user_reachable_by_non_admin(user_role): return "allowed" assert outcome("/team/spend/by_user") == "allowed" - assert outcome("/team/spend/by_key").startswith("denied: Only proxy admin") + assert outcome("/team/spend/by_key").startswith("denied: Authentication Error, Only proxy admin") def test_user_daily_activity_aggregated_not_covered_by_prefix_match(): diff --git a/tests/unit/proxy/auth/test_user_api_key_auth.py b/tests/unit/proxy/auth/test_user_api_key_auth.py index 60a8dd73a34..092805a9cac 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth.py @@ -2031,9 +2031,9 @@ async def test_user_api_key_auth_websocket_rejection_adds_no_traceback_for_other assert rejection.value.status_code == 403 assert "expired key" in str(rejection.value.detail).lower() assert sent == [{"type": "websocket.close", "code": status.WS_1008_POLICY_VIOLATION, "reason": ""}] - proxy_records: Final = [record for record in caplog.records if record.name == verbose_proxy_logger.name] + proxy_records: Final = [record for record in caplog.records if record.name.startswith(verbose_proxy_logger.name)] assert [record.getMessage() for record in proxy_records if record.exc_info is not None] == [] - assert [record.levelno for record in proxy_records if record.levelno >= logging.WARNING] == [logging.ERROR] + assert [record.levelno for record in proxy_records if record.levelno >= logging.WARNING] == [logging.WARNING] @pytest.mark.asyncio