From 636e70708984527aa2a4b89fdeb16886f7cff278 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:20:52 -0700 Subject: [PATCH] fix(proxy)!: stop answering with the literal string "None" as error type and param on the proxy-server, auth, spend, and key health routes (#39540) * fix(proxy): stop answering with the literal string "None" on the proxy-server, auth, and health routes The error objects these routes return defaulted `type` and `param` to the four-character string "None", so a client's error handler matched no known OpenAI type and fell into its generic branch. Route them through the shared openai_error_payload helpers instead, so `type` comes from the carried type or the HTTP status and `param` serializes as JSON null. * fix(proxy): stop answering with the literal string "None" on the spend and management routes The error objects these routes return defaulted `type` and `param` to the four-character string "None", so a client's error handler matched no known OpenAI type and fell into its generic branch. Route them through the shared openai_error_payload helpers instead, so `type` comes from the carried type or the HTTP status and `param` serializes as JSON null. * test(proxy): pin the mid-stream failure frame and the DB outage body to a JSON null param * fix(proxy): type upstream timeouts as timeout_error and update integration asserts for the null param A 408 with no carried type now answers timeout_error instead of invalid_request_error, so a client branching on type keeps retrying it. Three integration suites still compared auth rejections against param="None". * fix(proxy): keep a 408 on main's invalid_request_error type and pin the transcription file cap's 400 * fix(proxy): type a route failure by the status it answers with after the tag-routing and no-deployment remap * fix(proxy): keep auth_error on proxy-raised 401/403 route failures and ban the string None as an error type or param * fix(proxy): type a hook's 401 or 403 on completions, moderations and transcription by its status, as chat does Only a status the proxy remapped into 401 or 403, such as a tag-routing denial, keeps auth_error. * test(proxy): build the transcription file-cap test on a real router so it holds after the router refactor --- .github/workflows/test-linting.yml | 6 + litellm/proxy/auth/auth_exception_handler.py | 7 +- litellm/proxy/auth/handle_jwt.py | 2 +- litellm/proxy/auth/route_checks.py | 2 +- litellm/proxy/auth/user_api_key_auth.py | 12 +- .../common_utils/openai_error_payload.py | 31 ++++- litellm/proxy/db/exception_handler.py | 2 +- .../key_management_endpoints.py | 6 +- .../team_callback_endpoints.py | 11 +- litellm/proxy/proxy_server.py | 106 ++++++++--------- .../spend_management_endpoints.py | 49 ++++---- .../check_no_none_string_error_fields.py | 71 ++++++++++++ .../test_check_no_none_string_error_fields.py | 38 +++++++ .../test_guardrail_submissions.py | 2 +- .../test_jwt_key_mapping_lifecycle.py | 4 +- .../authorization/test_memory_ownership.py | 2 +- .../authorization/test_team_admin_gate.py | 2 +- .../test_unknown_key_generic_401.py | 8 +- .../test_user_self_update_guard.py | 2 +- .../authorization/test_warmed_policy.py | 2 +- .../authorization/test_workflow_ownership.py | 2 +- .../management/test_health_routing.py | 2 +- .../management/test_scim_admin_group.py | 2 +- .../test_user_delete_revokes_keys.py | 2 +- ...st_arize_otel_v2_openinference_sad_edge.py | 2 +- .../test_presidio_entity_masking.py | 2 +- .../security/test_config_overrides.py | 6 +- .../spend/test_global_spend_reset.py | 2 +- .../proxy/auth/test_auth_exception_handler.py | 14 +++ tests/unit/proxy/auth/test_handle_jwt.py | 1 + .../unit/proxy/auth/test_user_api_key_auth.py | 27 +++++ .../test_internal_user_endpoints.py | 2 +- .../test_key_management_endpoints.py | 17 +++ .../test_team_endpoints.py | 2 +- .../proxy_server/test_exception_handlers.py | 2 +- .../proxy/proxy_server/test_routes_audio.py | 107 +++++++++++++++++- .../proxy_server/test_routes_completions.py | 38 +++++++ .../proxy_server/test_streaming_helpers.py | 35 +++++- .../test_spend_management_endpoints.py | 16 +++ ...test_proxy_server_endpoints_and_startup.py | 90 +++++++++++++++ 40 files changed, 606 insertions(+), 130 deletions(-) create mode 100644 tests/code_coverage_tests/check_no_none_string_error_fields.py create mode 100644 tests/code_coverage_tests/test_check_no_none_string_error_fields.py diff --git a/.github/workflows/test-linting.yml b/.github/workflows/test-linting.yml index bd7343ea222..88c66f0011d 100644 --- a/.github/workflows/test-linting.yml +++ b/.github/workflows/test-linting.yml @@ -630,6 +630,12 @@ jobs: - name: check_migrations_lock_timeout run: uv run --no-sync python ./tests/code_coverage_tests/check_migrations_lock_timeout.py + - name: check_no_none_string_error_fields + run: uv run --no-sync python ./tests/code_coverage_tests/check_no_none_string_error_fields.py + + - name: test_check_no_none_string_error_fields + run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_check_no_none_string_error_fields.py + - name: check_no_publicly_known_master_key run: uv run --no-sync python ./tests/code_coverage_tests/check_no_publicly_known_master_key.py diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index ed743764fce..2ec2274db2b 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -30,6 +30,7 @@ from litellm.proxy.auth.auth_utils import ( # noqa: F401 # legacy module expor ) from litellm.proxy.auth.model_access_denied import ModelAccessDeniedHTTPException from litellm.proxy.auth.resolvers.exceptions import KeyNotFoundError +from litellm.proxy.common_utils.openai_error_payload import openai_error_param from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.types.services import ServiceTypes @@ -61,14 +62,14 @@ def _as_proxy_exception(e: Exception) -> ProxyException: message=str(e.detail), internal_message=e.internal_message, type=ProxyErrorTypes.auth_error, - param="None", + param=None, code=e.status_code, ) if isinstance(e, HTTPException): return ProxyException( message=getattr(e, "detail", f"Authentication Error({e})"), type=ProxyErrorTypes.auth_error, - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=getattr(e, "status_code", status.HTTP_401_UNAUTHORIZED), ) if isinstance(e, ProxyException): @@ -78,7 +79,7 @@ def _as_proxy_exception(e: Exception) -> ProxyException: return ProxyException( message="Authentication Error, " + str(e), type=ProxyErrorTypes.auth_error, - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_401_UNAUTHORIZED, ) diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 9d800a1fd63..7bd7b1226b1 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -225,7 +225,7 @@ def jwks_unavailable_exception(error: JWKSUnreachableError) -> ProxyException: f"unreachable, so the JWT signature could not be verified. Please retry shortly. Error: {error}" ), type=ProxyErrorTypes.auth_provider_unavailable, - param="None", + param=None, code=status.HTTP_503_SERVICE_UNAVAILABLE, ) diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 56be44600fd..105acb237af 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -302,7 +302,7 @@ class RouteChecks: f"keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}" ), type=ProxyErrorTypes.auth_error, - param="None", + param=None, code=status.HTTP_401_UNAUTHORIZED, ) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 2b6471952fd..67410e867d6 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2586,7 +2586,7 @@ async def user_api_key_auth_builder( raise ProxyException( message="Authentication Error, No api key passed in.", type=ProxyErrorTypes.auth_error, - param="None", + param=None, code=status.HTTP_401_UNAUTHORIZED, ) elif api_key == "": @@ -2594,7 +2594,7 @@ async def user_api_key_auth_builder( raise ProxyException( message="Authentication Error, Malformed API Key passed in. Ensure Key has `Bearer ` prefix.", type=ProxyErrorTypes.auth_error, - param="None", + param=None, code=status.HTTP_401_UNAUTHORIZED, ) @@ -2835,7 +2835,7 @@ async def user_api_key_auth_builder( raise ProxyException( message=f"Authentication Error, Tried to access route={route}, which is only for MASTER KEY", type=ProxyErrorTypes.auth_error, - param="None", + param=None, code=status.HTTP_401_UNAUTHORIZED, ) @@ -2968,7 +2968,7 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e raise ProxyException( message="Authentication Error, Key is blocked. Update via `/key/unblock` if you're an admin.", type=ProxyErrorTypes.auth_error, - param="None", + param=None, code=status.HTTP_401_UNAUTHORIZED, ) await enforce_key_and_fallback_model_access( @@ -3019,7 +3019,7 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e "Keys owned by this user cannot be used." ), type=ProxyErrorTypes.auth_error, - param="None", + param=None, code=status.HTTP_401_UNAUTHORIZED, ) @@ -3325,7 +3325,7 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e raise ProxyException( message="Authentication Error, Invalid proxy server token passed", type=ProxyErrorTypes.auth_error, - param="None", + param=None, code=status.HTTP_401_UNAUTHORIZED, ) if valid_token_dict is not None: diff --git a/litellm/proxy/common_utils/openai_error_payload.py b/litellm/proxy/common_utils/openai_error_payload.py index f92fbeef9a1..53b2a05d40f 100644 --- a/litellm/proxy/common_utils/openai_error_payload.py +++ b/litellm/proxy/common_utils/openai_error_payload.py @@ -147,8 +147,37 @@ def _is_failover_refusal(exc: Exception) -> bool: return any(refusal in message for refusal in _FAILOVER_REFUSALS) +def answered_status_code(message: str, status_code: int) -> int: + return int(proxy_exception_status_code(message, str(status_code))) + + +def route_failure_proxy_exception( + exc: Exception, + message: str, + default_status_code: int, + headers: dict[str, str] | None = None, # mutable-ok: ProxyException.headers + openai_code: str | None = None, +) -> ProxyException: + raised_status: Final = error_status_code(exc, default_status_code) + answered_status: Final = answered_status_code(message, raised_status) + return ProxyException( + message=message, + type=_route_failure_error_type(exc, raised_status, answered_status), + param=openai_error_param(exc), + headers=headers, + openai_code=openai_code, + code=answered_status, + ) + + +def _route_failure_error_type(exc: Exception, raised_status: int, answered_status: int) -> str: + if answered_status != raised_status and answered_status in _AUTH_FAILURE_STATUS_CODES: + return ProxyErrorTypes.auth_error.value + return openai_error_type(exc, answered_status) + + def _openai_shaped_proxy_exception(exc: Exception, message: str, status_code: int) -> ProxyException: - answered_status: Final = int(proxy_exception_status_code(message, str(status_code))) + answered_status: Final = answered_status_code(message, status_code) return ProxyException( message=message, type=_catch_all_error_type(exc, answered_status), diff --git a/litellm/proxy/db/exception_handler.py b/litellm/proxy/db/exception_handler.py index 3f7ea334a99..baf53604ea5 100644 --- a/litellm/proxy/db/exception_handler.py +++ b/litellm/proxy/db/exception_handler.py @@ -435,7 +435,7 @@ class PrismaDBExceptionHandler: return ProxyException( message=PrismaDBExceptionHandler.database_unavailable_message(e), type=ProxyErrorTypes.no_db_connection, - param="None", + param=None, code=HTTPStatus.SERVICE_UNAVAILABLE.value, ) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 69f28b6d388..a6814b593ca 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -75,7 +75,7 @@ from litellm.proxy.common_utils.config_sync_pubsub import ( coordination_redis_cache, publish_config_change, ) -from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for +from litellm.proxy.common_utils.openai_error_payload import openai_error_param, proxy_exception_for from litellm.proxy.common_utils.rbac_utils import check_org_admin_can_generate_keys from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time from litellm.proxy.common_utils.user_api_key_cache import AUTH_OBJECTS_TARGET, UserApiKeyCache @@ -4959,7 +4959,7 @@ async def info_key_fn( raise ProxyException( message="Internal server error from /key/info. Details are in the proxy server logs.", type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -8156,7 +8156,7 @@ async def key_health( raise ProxyException( message=f"Key health check failed: {e}", type=ProxyErrorTypes.internal_server_error, - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index d1e1c4bd25e..5b778392664 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -46,6 +46,7 @@ from litellm.proxy.common_utils.callback_utils import ( # noqa: F401 # legacy encrypt_callback_vars, is_sensitive_callback_key, ) +from litellm.proxy.common_utils.openai_error_payload import openai_error_param from litellm.proxy.litellm_pre_call_utils import ( # noqa: F401 # legacy module exports _get_validated_callback_metadata, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export convert_key_logging_metadata_to_callback, @@ -463,7 +464,7 @@ async def add_team_callbacks( raise ProxyException( message="Internal Server Error, " + str(e), type=ProxyErrorTypes.internal_server_error.value, - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -606,7 +607,7 @@ async def delete_team_callback( raise ProxyException( message="Internal Server Error, " + str(e), type=ProxyErrorTypes.internal_server_error.value, - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) else: @@ -747,7 +748,7 @@ async def disable_team_logging( raise ProxyException( message="Internal Server Error, " + str(e), type=ProxyErrorTypes.internal_server_error.value, - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -838,7 +839,7 @@ async def get_team_callbacks( raise ProxyException( message=getattr(e, "detail", f"Internal Server Error({e})"), type=ProxyErrorTypes.internal_server_error.value, - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), ) elif isinstance(e, ProxyException): @@ -846,6 +847,6 @@ async def get_team_callbacks( raise ProxyException( message="Internal Server Error, " + str(e), type=ProxyErrorTypes.internal_server_error.value, - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 1bd221bca98..b0ccf74c63b 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -529,9 +529,12 @@ from litellm.proxy.common_utils.openai_endpoint_utils import ( ) from litellm.proxy.common_utils.openai_error_payload import ( LITELLM_CALL_ID_HEADER, + attribute_of, headers_with_litellm_call_id, litellm_call_id_headers, + openai_error_param, proxy_exception_for, + route_failure_proxy_exception, with_litellm_call_id, ) from litellm.proxy.common_utils.periodic_reload_schedule import ( @@ -9898,11 +9901,8 @@ async def async_assistants_data_generator(response, user_api_key_dict: UserAPIKe # Including it in the SSE response leaks internal details to clients. error_msg: Final = str(e) - proxy_exception: Final = ProxyException( - message=getattr(e, "message", error_msg), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), - code=getattr(e, "status_code", 500), + proxy_exception: Final = route_failure_proxy_exception( + e, str(attribute_of(e, "message", error_msg)), status.HTTP_500_INTERNAL_SERVER_ERROR ) error_returned: Final = json.dumps({"error": proxy_exception.to_dict()}) yield f"data: {error_returned}\n\n" @@ -10712,11 +10712,10 @@ async def async_data_generator( { "error": sse_error_payload(client_error)[1] if isinstance(client_error, HTTPException) - else ProxyException( - message=getattr(client_error, "message", str(client_error)), - type=getattr(client_error, "type", "None"), - param=getattr(client_error, "param", "None"), - code=getattr(client_error, "status_code", 500), + else route_failure_proxy_exception( + client_error, + str(attribute_of(client_error, "message", client_error)), + status.HTTP_500_INTERNAL_SERVER_ERROR, ).to_dict() }, default=str, @@ -13096,14 +13095,12 @@ async def completion( log_llm_api_exception(e, litellm_call_id) if isinstance(e, ProxyException): raise with_litellm_call_id(e, litellm_call_id) - error_msg: Final = f"{e}" - raise ProxyException( - message=getattr(e, "message", error_msg), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), + raise route_failure_proxy_exception( + e, + str(attribute_of(e, "message", e)), + status.HTTP_500_INTERNAL_SERVER_ERROR, headers=litellm_call_id_headers(litellm_call_id), openai_code=getattr(e, "code", None), - code=getattr(e, "status_code", 500), ) @@ -13324,22 +13321,18 @@ async def moderations( if isinstance(e, ProxyException): raise with_litellm_call_id(e, litellm_call_id) if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "message", str(e)), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), + raise route_failure_proxy_exception( + e, + str(attribute_of(e, "message", e)), + status.HTTP_400_BAD_REQUEST, headers=litellm_call_id_headers(litellm_call_id), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - ) - else: - error_msg: Final = f"{e}" - raise ProxyException( - message=getattr(e, "message", error_msg), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), - headers=litellm_call_id_headers(litellm_call_id), - code=getattr(e, "status_code", 500), ) + raise route_failure_proxy_exception( + e, + str(attribute_of(e, "message", e)), + status.HTTP_500_INTERNAL_SERVER_ERROR, + headers=litellm_call_id_headers(litellm_call_id), + ) async def _audio_speech_chunk_generator( @@ -13477,13 +13470,12 @@ async def audio_speech( detail=e.detail, headers=headers_with_litellm_call_id(e.headers, litellm_call_id), ) - raise ProxyException( - message=getattr(e, "message", f"{e}"), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), + raise route_failure_proxy_exception( + e, + str(attribute_of(e, "message", e)), + status.HTTP_500_INTERNAL_SERVER_ERROR, headers=litellm_call_id_headers(litellm_call_id), openai_code=getattr(e, "code", None), - code=getattr(e, "status_code", 500), ) @@ -13623,23 +13615,19 @@ async def audio_transcriptions( ) log_llm_api_exception(e, litellm_call_id) if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "message", str(e.detail)), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), + raise route_failure_proxy_exception( + e, + str(attribute_of(e, "message", e.detail)), + status.HTTP_400_BAD_REQUEST, headers=litellm_call_id_headers(litellm_call_id), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - ) - else: - error_msg: Final = f"{e}" - raise ProxyException( - message=getattr(e, "message", error_msg), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), - headers=litellm_call_id_headers(litellm_call_id), - openai_code=getattr(e, "code", None), - code=getattr(e, "status_code", 500), ) + raise route_failure_proxy_exception( + e, + str(attribute_of(e, "message", e)), + status.HTTP_500_INTERNAL_SERVER_ERROR, + headers=litellm_call_id_headers(litellm_call_id), + openai_code=getattr(e, "code", None), + ) ###################################################################### @@ -17239,7 +17227,7 @@ async def model_streaming_metrics( raise ProxyException( message=CommonProxyErrors.db_not_connected_error.value, type="internal_error", - param="None", + param=None, code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -17373,7 +17361,7 @@ async def model_metrics( raise ProxyException( message="Prisma Client is not initialized", type="internal_error", - param="None", + param=None, code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) startTime = startTime or datetime.now() - timedelta(days=DAYS_IN_A_MONTH) @@ -17488,7 +17476,7 @@ async def model_metrics_slow_responses( raise ProxyException( message="Prisma Client is not initialized", type="internal_error", - param="None", + param=None, code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) if api_key is None or api_key == "undefined": @@ -17577,7 +17565,7 @@ async def model_metrics_exceptions( raise ProxyException( message="Prisma Client is not initialized", type="internal_error", - param="None", + param=None, code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -18791,7 +18779,7 @@ async def login_v2(request: Request): raise ProxyException( message=getattr(e, "detail", str(e)), type=ProxyErrorTypes.auth_error, - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), ) else: @@ -18799,7 +18787,7 @@ async def login_v2(request: Request): raise ProxyException( message=error_msg, type=ProxyErrorTypes.auth_error, - param="None", + param=None, code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -18867,7 +18855,7 @@ async def login_v3(request: Request): raise ProxyException( message=getattr(e, "detail", str(e)), type=ProxyErrorTypes.auth_error, - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), ) else: @@ -18875,7 +18863,7 @@ async def login_v3(request: Request): raise ProxyException( message=error_msg, type=ProxyErrorTypes.auth_error, - param="None", + param=None, code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -18940,7 +18928,7 @@ async def login_v3_exchange(request: Request): raise ProxyException( message=str(e), type=ProxyErrorTypes.auth_error, - param="None", + param=None, code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 38dcc3b06df..89200792c0b 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -50,6 +50,7 @@ from litellm.proxy.auth.authorization_dependencies import ( LogTeamLookupDependency, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_utils.openai_error_payload import error_status_code, openai_error_param, openai_error_type from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup from litellm.proxy.spend_tracking.spend_capture_rate import ( ProviderBillingCredentialMissing, @@ -636,7 +637,7 @@ async def view_spend_tags( raise ProxyException( message=getattr(e, "detail", f"/spend/tags Error({e})"), type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), ) elif isinstance(e, ProxyException): @@ -644,7 +645,7 @@ async def view_spend_tags( raise ProxyException( message="/spend/tags Error" + str(e), type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -2142,7 +2143,7 @@ async def global_get_all_tag_names(): raise ProxyException( message=getattr(e, "detail", f"/spend/all_tag_names Error({e})"), type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), ) elif isinstance(e, ProxyException): @@ -2150,7 +2151,7 @@ async def global_get_all_tag_names(): raise ProxyException( message="/spend/all_tag_names Error" + str(e), type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -2224,7 +2225,7 @@ async def global_view_spend_tags( raise ProxyException( message=getattr(e, "detail", f"/spend/tags Error({error_str})"), type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), ) elif isinstance(e, ProxyException): @@ -2232,7 +2233,7 @@ async def global_view_spend_tags( raise ProxyException( message="/spend/tags Error" + error_str, type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -2447,9 +2448,9 @@ async def calculate_spend(request: SpendCalculateRequest): if isinstance(e, HTTPException): raise ProxyException( message=getattr(e, "detail", str(e)), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), + type=openai_error_type(e, error_status_code(e, status.HTTP_400_BAD_REQUEST)), + param=openai_error_param(e), + code=error_status_code(e, status.HTTP_400_BAD_REQUEST), ) if isinstance(e, litellm.exceptions.ModelNotMappedError): raise ProxyException( @@ -2461,9 +2462,9 @@ async def calculate_spend(request: SpendCalculateRequest): error_msg: Final = f"{e}" raise ProxyException( message=getattr(e, "message", error_msg), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), - code=getattr(e, "status_code", 500), + type=openai_error_type(e, error_status_code(e, 500)), + param=openai_error_param(e), + code=error_status_code(e, 500), ) @@ -2633,7 +2634,7 @@ async def ui_view_spend_logs( raise ProxyException( message="Prisma Client is not initialized", type="internal_error", - param="None", + param=None, code=status.HTTP_401_UNAUTHORIZED, ) @@ -2700,7 +2701,7 @@ async def ui_view_spend_logs( raise ProxyException( message="Start date and end date are required", type="bad_request", - param="None", + param=None, code=status.HTTP_400_BAD_REQUEST, ) formats: Final = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] if is_v2 else ["%Y-%m-%d %H:%M:%S"] @@ -3681,7 +3682,7 @@ async def view_spend_logs( raise ProxyException( message=getattr(e, "detail", f"/spend/logs Error({e})"), type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), ) elif isinstance(e, ProxyException): @@ -3689,7 +3690,7 @@ async def view_spend_logs( raise ProxyException( message="/spend/logs Error" + str(e), type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -3716,7 +3717,7 @@ async def global_spend_reset(): raise ProxyException( message="Prisma Client is not initialized", type="internal_error", - param="None", + param=None, code=status.HTTP_401_UNAUTHORIZED, ) @@ -3747,7 +3748,7 @@ async def global_spend_refresh(): raise ProxyException( message="Prisma Client is not initialized", type="internal_error", - param="None", + param=None, code=status.HTTP_401_UNAUTHORIZED, ) @@ -3818,7 +3819,7 @@ async def global_spend_for_internal_user( raise ProxyException( message="Prisma Client is not initialized", type="internal_error", - param="None", + param=None, code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) try: @@ -3879,7 +3880,7 @@ async def global_spend_logs( raise ProxyException( message="Prisma Client is not initialized", type="internal_error", - param="None", + param=None, code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -3923,7 +3924,7 @@ async def global_spend_logs( raise ProxyException( message=getattr(e, "detail", f"/global/spend/logs Error({error_str})"), type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), ) elif isinstance(e, ProxyException): @@ -3931,7 +3932,7 @@ async def global_spend_logs( raise ProxyException( message="/global/spend/logs Error" + error_str, type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -3971,7 +3972,7 @@ async def global_spend(): raise ProxyException( message=getattr(e, "detail", f"/global/spend Error({error_str})"), type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), ) elif isinstance(e, ProxyException): @@ -3979,7 +3980,7 @@ async def global_spend(): raise ProxyException( message="/global/spend Error" + error_str, type="internal_error", - param=getattr(e, "param", "None"), + param=openai_error_param(e), code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) diff --git a/tests/code_coverage_tests/check_no_none_string_error_fields.py b/tests/code_coverage_tests/check_no_none_string_error_fields.py new file mode 100644 index 00000000000..3a75b56fc8d --- /dev/null +++ b/tests/code_coverage_tests/check_no_none_string_error_fields.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import ast +import sys +from collections.abc import Iterator +from itertools import chain +from pathlib import Path +from typing import Final + +ERROR_FIELDS: Final = frozenset({"type", "param"}) +SOURCE_ROOT: Final = Path(__file__).resolve().parents[2] / "litellm" + + +def _is_none_string(node: ast.expr | None) -> bool: + return isinstance(node, ast.Constant) and node.value == "None" + + +def _names_an_error_field(node: ast.expr | None) -> bool: + return isinstance(node, ast.Constant) and node.value in ERROR_FIELDS + + +def _keyword_defaults_to_none_string(call: ast.Call) -> bool: + return any(keyword.arg in ERROR_FIELDS and _is_none_string(keyword.value) for keyword in call.keywords) + + +def _getattr_defaults_to_none_string(call: ast.Call) -> bool: + return ( + isinstance(call.func, ast.Name) + and call.func.id == "getattr" + and len(call.args) == 3 + and _names_an_error_field(call.args[1]) + and _is_none_string(call.args[2]) + ) + + +def _dict_holds_none_string(node: ast.Dict) -> bool: + return any(_names_an_error_field(key) and _is_none_string(value) for key, value in zip(node.keys, node.values)) + + +def _is_violation(node: ast.AST) -> bool: + match node: + case ast.Call(): + return _keyword_defaults_to_none_string(node) or _getattr_defaults_to_none_string(node) + case ast.Dict(): + return _dict_holds_none_string(node) + case _: + return False + + +def violations(source: str, filename: str) -> Iterator[str]: + return (f"{filename}:{node.lineno}" for node in ast.walk(ast.parse(source, filename=filename)) if _is_violation(node)) + + +def _file_violations(path: Path) -> Iterator[str]: + return violations(path.read_text(encoding="utf-8"), str(path.relative_to(SOURCE_ROOT.parent))) + + +def main() -> int: + found: Final = tuple(chain.from_iterable(_file_violations(path) for path in sorted(SOURCE_ROOT.rglob("*.py")))) + if not found: + return 0 + print( + 'An error\'s "type" or "param" is set to the string "None", which OpenAI clients read as a real ' + "error class or field name. Use a named error type and a real None for param instead:" + ) + print("\n".join(found)) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/code_coverage_tests/test_check_no_none_string_error_fields.py b/tests/code_coverage_tests/test_check_no_none_string_error_fields.py new file mode 100644 index 00000000000..5d2f04b0942 --- /dev/null +++ b/tests/code_coverage_tests/test_check_no_none_string_error_fields.py @@ -0,0 +1,38 @@ +import importlib.util +from pathlib import Path +from typing import Final + +import pytest + +GUARD_PATH: Final = Path(__file__).resolve().parent / "check_no_none_string_error_fields.py" +_spec: Final = importlib.util.spec_from_file_location("check_no_none_string_error_fields", GUARD_PATH) +assert _spec is not None and _spec.loader is not None +guard: Final = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(guard) + + +@pytest.mark.parametrize( + "source", + [ + pytest.param('raise ProxyException(message="m", type="None", param=None, code=500)\n', id="type_keyword"), + pytest.param('raise ProxyException(message="m", type="x", param="None", code=500)\n', id="param_keyword"), + pytest.param('ProxyException(message="m", type=getattr(e, "type", "None"), param=None)\n', id="type_getattr"), + pytest.param('ProxyException(message="m", type="x", param=getattr(e, "param", "None"))\n', id="param_getattr"), + pytest.param('error = {"message": "m", "type": "x", "param": "None"}\n', id="dict_literal"), + ], +) +def test_a_none_string_error_field_is_reported_with_its_line(source: str) -> None: + assert tuple(guard.violations("x = 1\n" + source, "proxy.py")) == ("proxy.py:2",) + + +@pytest.mark.parametrize( + "source", + [ + pytest.param('ProxyException(message="m", type="auth_error", param=None, code=401)\n', id="real_none_param"), + pytest.param('ProxyException(message="None", type="x", param=getattr(e, "param", None))\n', id="none_message"), + pytest.param('label = getattr(e, "provider", "None")\n', id="other_attribute"), + pytest.param('row = {"model": "None"}\n', id="other_dict_key"), + ], +) +def test_a_real_none_or_an_unrelated_none_string_passes(source: str) -> None: + assert tuple(guard.violations(source, "proxy.py")) == () diff --git a/tests/integration/authorization/test_guardrail_submissions.py b/tests/integration/authorization/test_guardrail_submissions.py index f120028f46d..16b9cbf31dc 100644 --- a/tests/integration/authorization/test_guardrail_submissions.py +++ b/tests/integration/authorization/test_guardrail_submissions.py @@ -310,7 +310,7 @@ def test_non_admin_keys_scoped_to_the_submission_routes_get_403_from_the_admin_c f"Your user_id={_mask_user_id(user_id)}" ), type="auth_error", - param="None", + param=None, code="401", ) ) diff --git a/tests/integration/authorization/test_jwt_key_mapping_lifecycle.py b/tests/integration/authorization/test_jwt_key_mapping_lifecycle.py index 715f3731175..da830293145 100644 --- a/tests/integration/authorization/test_jwt_key_mapping_lifecycle.py +++ b/tests/integration/authorization/test_jwt_key_mapping_lifecycle.py @@ -114,7 +114,7 @@ class _ChatRequest(BaseModel): class _ProxyError(BaseModel): message: str type: str - param: str + param: str | None code: str @@ -398,7 +398,7 @@ def _assert_unmapped(response: httpx.Response, client_id: str) -> None: assert response.status_code == 403, response.text error: Final = _ErrorResponse.model_validate_json(response.content) expected: Final = f"JWT Key Mapping: No registered mapping for {_CLIENT_CLAIM}='{client_id}'. Access denied." - assert error.error == _ProxyError(message=expected, type="auth_error", param="None", code="403"), response.text + assert error.error == _ProxyError(message=expected, type="auth_error", param=None, code="403"), response.text def _mapping_info(gateway: Gateway, identity: str) -> _JWTMappingResponse: diff --git a/tests/integration/authorization/test_memory_ownership.py b/tests/integration/authorization/test_memory_ownership.py index bad7c3ccb37..249b74363a2 100644 --- a/tests/integration/authorization/test_memory_ownership.py +++ b/tests/integration/authorization/test_memory_ownership.py @@ -24,7 +24,7 @@ def _admin_only_error(route: str, user_id: str) -> dict[str, JsonValue]: f"keys/users/teams. Route={route}. Your role=internal_user. Your user_id={masked_user_id}" ), "type": "auth_error", - "param": "None", + "param": None, "code": "401", } } diff --git a/tests/integration/authorization/test_team_admin_gate.py b/tests/integration/authorization/test_team_admin_gate.py index eff4060631e..adc374a57d7 100644 --- a/tests/integration/authorization/test_team_admin_gate.py +++ b/tests/integration/authorization/test_team_admin_gate.py @@ -600,7 +600,7 @@ def _admin_only_route(route: str) -> tuple[int, JsonValue]: "message": "Authentication Error, Only proxy admin can be used to generate, delete, update info for new " f"keys/users/teams. Route={route}. Your role=internal_user. Your user_id=", "type": "auth_error", - "param": "None", + "param": None, "code": "401", } } diff --git a/tests/integration/authorization/test_unknown_key_generic_401.py b/tests/integration/authorization/test_unknown_key_generic_401.py index 8e6ff3fa3ab..025f11d3973 100644 --- a/tests/integration/authorization/test_unknown_key_generic_401.py +++ b/tests/integration/authorization/test_unknown_key_generic_401.py @@ -40,7 +40,7 @@ class _ProxyError(BaseModel): message: str type: str - param: str + param: str | None code: str @@ -260,12 +260,12 @@ def _control_error(response: httpx.Response) -> _ProxyError: def test_requests_without_an_sk_key_keep_their_own_401(gateway: Gateway, peer: Gateway, side: Side) -> None: client: Final = _side(gateway, peer, side).client assert _control_error(client.get("/v1/models")) == _ProxyError( - message="Authentication Error, No api key passed in.", type="auth_error", param="None", code="401" + message="Authentication Error, No api key passed in.", type="auth_error", param=None, code="401" ) assert _control_error(client.get("/v1/models", headers={"Authorization": "Bearer not-a-real-key"})) == _ProxyError( message="LiteLLM Virtual Key expected. Received=not-****-key, expected to start with 'sk-'.", type="auth_error", - param="None", + param=None, code="401", ) @@ -277,7 +277,7 @@ def test_a_blocked_key_keeps_its_own_401_on_both_proxies(gateway: Gateway, peer: expected: Final = _ProxyError( message="Authentication Error, Key is blocked. Update via `/key/unblock` if you're an admin.", type="auth_error", - param="None", + param=None, code="401", ) for candidate in (gateway, peer): diff --git a/tests/integration/authorization/test_user_self_update_guard.py b/tests/integration/authorization/test_user_self_update_guard.py index d058b3c6998..2c495beb77e 100644 --- a/tests/integration/authorization/test_user_self_update_guard.py +++ b/tests/integration/authorization/test_user_self_update_guard.py @@ -65,7 +65,7 @@ def _ui_session_route_error(user_id: str) -> dict[str, JsonValue]: f"Your user_id={masked_user_id}" ), "type": "auth_error", - "param": "None", + "param": None, "code": "401", } } diff --git a/tests/integration/authorization/test_warmed_policy.py b/tests/integration/authorization/test_warmed_policy.py index b4ffc10c3c4..0de4141143e 100644 --- a/tests/integration/authorization/test_warmed_policy.py +++ b/tests/integration/authorization/test_warmed_policy.py @@ -679,7 +679,7 @@ def _expected_scim_write_denial(route: str, user_id: str, user_role: str) -> dic f"keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}" ), "type": "auth_error", - "param": "None", + "param": None, "code": "401", } } diff --git a/tests/integration/authorization/test_workflow_ownership.py b/tests/integration/authorization/test_workflow_ownership.py index 386d4f1ed27..70919760874 100644 --- a/tests/integration/authorization/test_workflow_ownership.py +++ b/tests/integration/authorization/test_workflow_ownership.py @@ -57,7 +57,7 @@ def _route_refusal(response: httpx.Response, user_id: str) -> None: f"keys/users/teams. Route=/v1/workflows/runs. Your role=internal_user. Your user_id={masked_user_id}" ), "type": "auth_error", - "param": "None", + "param": None, "code": "401", } }, response.text diff --git a/tests/integration/management/test_health_routing.py b/tests/integration/management/test_health_routing.py index 3a60efe4342..b74d32380d0 100644 --- a/tests/integration/management/test_health_routing.py +++ b/tests/integration/management/test_health_routing.py @@ -151,7 +151,7 @@ def test_public_readiness_reports_stalled_database_lookups(gateway: Gateway, tmp "Please retry shortly." ), "type": "no_db_connection", - "param": "None", + "param": None, "code": "503", } } diff --git a/tests/integration/management/test_scim_admin_group.py b/tests/integration/management/test_scim_admin_group.py index ffbec93d4a8..92016af1ebb 100644 --- a/tests/integration/management/test_scim_admin_group.py +++ b/tests/integration/management/test_scim_admin_group.py @@ -133,7 +133,7 @@ def _expected_scim_write_denial(route: str, user_id: str, user_role: str) -> dic f"keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}" ), "type": "auth_error", - "param": "None", + "param": None, "code": "401", } } diff --git a/tests/integration/management/test_user_delete_revokes_keys.py b/tests/integration/management/test_user_delete_revokes_keys.py index 324963a0da9..513a80b7319 100644 --- a/tests/integration/management/test_user_delete_revokes_keys.py +++ b/tests/integration/management/test_user_delete_revokes_keys.py @@ -64,7 +64,7 @@ def _blocked_key_error() -> dict[str, JsonValue]: "error": { "message": "Authentication Error, Key is blocked. Update via `/key/unblock` if you're an admin.", "type": "auth_error", - "param": "None", + "param": None, "code": "401", } } diff --git a/tests/integration/observability/test_arize_otel_v2_openinference_sad_edge.py b/tests/integration/observability/test_arize_otel_v2_openinference_sad_edge.py index a05b3f0f045..94e9d46ec8f 100644 --- a/tests/integration/observability/test_arize_otel_v2_openinference_sad_edge.py +++ b/tests/integration/observability/test_arize_otel_v2_openinference_sad_edge.py @@ -177,7 +177,7 @@ def test_arize_otel_v2_d4_unauthenticated_request_has_no_span(gateway: Gateway, "error": { "message": "Authentication Error, No api key passed in.", "type": "auth_error", - "param": "None", + "param": None, "code": "401", } }, body diff --git a/tests/integration/observability/test_presidio_entity_masking.py b/tests/integration/observability/test_presidio_entity_masking.py index ab966873389..ff36e4623f7 100644 --- a/tests/integration/observability/test_presidio_entity_masking.py +++ b/tests/integration/observability/test_presidio_entity_masking.py @@ -325,7 +325,7 @@ def test_long_apply_guardrail_alias_accepts_an_llm_api_scoped_key(gateway: Gatew "error": { "message": "Authentication Error, No api key passed in.", "type": "auth_error", - "param": "None", + "param": None, "code": "401", } }, unauthenticated.text diff --git a/tests/integration/security/test_config_overrides.py b/tests/integration/security/test_config_overrides.py index 72546c54222..70f9226ea79 100644 --- a/tests/integration/security/test_config_overrides.py +++ b/tests/integration/security/test_config_overrides.py @@ -100,7 +100,7 @@ def _assert_non_admin_refusal(response: httpx.Response, route: str, user_id: str f"new keys/users/teams. Route={route}. Your role=internal_user. Your user_id={masked_user_id}" ), "type": "auth_error", - "param": "None", + "param": None, "code": "401", } }, response.text @@ -244,7 +244,7 @@ def test_saved_vault_config_reads_back_masked_and_refuses_non_admins( "message": "user not allowed to access this route, role= proxy_admin_viewer. " "Trying to access: /config_overrides/hashicorp_vault", "type": "auth_error", - "param": "None", + "param": None, "code": "403", } }, viewer_post.text @@ -386,7 +386,7 @@ def test_saved_cyberark_config_reads_back_masked_and_refuses_non_admins( "message": "user not allowed to access this route, role= proxy_admin_viewer. " "Trying to access: /config_overrides/cyberark", "type": "auth_error", - "param": "None", + "param": None, "code": "403", } }, viewer_post.text diff --git a/tests/integration/spend/test_global_spend_reset.py b/tests/integration/spend/test_global_spend_reset.py index c400d90e184..cac66471dea 100644 --- a/tests/integration/spend/test_global_spend_reset.py +++ b/tests/integration/spend/test_global_spend_reset.py @@ -104,7 +104,7 @@ def test_global_spend_reset_zeroes_token_and_team_spend_but_keeps_logs(gateway: "error": { "message": "Authentication Error, Tried to access route=/global/spend/reset, which is only for MASTER KEY", "type": "auth_error", - "param": "None", + "param": None, "code": "401", } }, refused.text diff --git a/tests/unit/proxy/auth/test_auth_exception_handler.py b/tests/unit/proxy/auth/test_auth_exception_handler.py index 92b4c93118f..2523284ec2c 100644 --- a/tests/unit/proxy/auth/test_auth_exception_handler.py +++ b/tests/unit/proxy/auth/test_auth_exception_handler.py @@ -1298,3 +1298,17 @@ def test_as_proxy_exception_keeps_jwt_scope_denial_message_shape(): assert converted.to_dict() == plain.to_dict() assert converted.internal_message == denial.internal_message + + +@pytest.mark.parametrize( + "auth_failure", + [ + pytest.param(HTTPException(status_code=401, detail="Authentication Error, invalid key"), id="http_exception"), + pytest.param(Exception("Invalid proxy server token passed"), id="bare_exception"), + ], +) +def test_auth_failure_error_body_carries_a_json_null_param(auth_failure: Exception) -> None: + body = json.loads(json.dumps({"error": _as_proxy_exception(auth_failure).to_dict()})) + + assert body["error"]["type"] == ProxyErrorTypes.auth_error.value + assert body["error"]["param"] is None diff --git a/tests/unit/proxy/auth/test_handle_jwt.py b/tests/unit/proxy/auth/test_handle_jwt.py index a0ea6ce0403..51cf15996cd 100644 --- a/tests/unit/proxy/auth/test_handle_jwt.py +++ b/tests/unit/proxy/auth/test_handle_jwt.py @@ -4368,6 +4368,7 @@ async def test_removed_signing_key_stops_being_trusted_once_the_stale_window_exp assert exc_info.value.code == "503" assert exc_info.value.type == ProxyErrorTypes.auth_provider_unavailable + assert exc_info.value.param is None @pytest.mark.asyncio 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 117428ee27b..81ea0e3f60b 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth.py @@ -1310,6 +1310,33 @@ async def test_auth_rejects_wrong_or_empty_present_custom_header( assert exc_info.value.code == "401" +@pytest.mark.asyncio +@pytest.mark.parametrize("api_key", [None, ""], ids=["no_key", "no_bearer_prefix"]) +async def test_auth_rejection_without_a_usable_key_has_a_json_null_param( + api_key: str | None, monkeypatch: pytest.MonkeyPatch +): + from fastapi import Request + + from litellm.proxy import proxy_server + from litellm.proxy._types import ProxyErrorTypes, ProxyException + + monkeypatch.setattr(proxy_server, "master_key", "sk-test-master") + monkeypatch.setattr(proxy_server, "general_settings", {}) + request = Request( + scope={"type": "http", "method": "POST", "path": "/chat/completions", "headers": []}, + receive=AsyncMock(return_value={"type": "http.request", "body": b"", "more_body": False}), + ) + request._url = URL(url="/chat/completions") + + with pytest.raises(ProxyException) as exc_info: + await user_api_key_auth(request=request, api_key=api_key) + + body = json.loads(json.dumps({"error": exc_info.value.to_dict()})) + assert body["error"]["code"] == "401" + assert body["error"]["type"] == ProxyErrorTypes.auth_error.value + assert body["error"]["param"] is None + + @pytest.mark.asyncio async def test_user_api_key_from_query_param(): """Ensure user_api_key_auth reads API key from `key` query parameter.""" diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index d1bbf444d2c..78a0a765fe6 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -4970,7 +4970,7 @@ _DB_OUTAGE_503_BODY: Final = { "error": { "message": "Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly.", "type": "no_db_connection", - "param": "None", + "param": None, "code": "503", } } diff --git a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py index 859824e4a72..b0ef7e64e60 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -23379,6 +23379,23 @@ def test_key_info_database_not_connected_sanitized_500( proxy_server.app.dependency_overrides.pop(user_api_key_auth, None) +def test_key_health_failure_body_carries_a_json_null_param(): + import litellm.proxy.proxy_server as ps + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="hashed-key", metadata={"logging": ["not-a-callback-object"]} + ) + try: + response = client.post("/key/health", headers={"Authorization": "Bearer sk-test"}) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 500 + error = response.json()["error"] + assert error["type"] == "internal_server_error" + assert error["param"] is None + + @pytest.mark.asyncio async def test_update_key_reports_a_bad_budget_duration_as_a_request_error_not_an_auth_error(monkeypatch: pytest.MonkeyPatch) -> None: from fastapi import Request diff --git a/tests/unit/proxy/management_endpoints/test_team_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_endpoints.py index c6d12ade2a0..e33743bb149 100644 --- a/tests/unit/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_team_endpoints.py @@ -19095,7 +19095,7 @@ _DB_OUTAGE_503_BODY: Final = { "error": { "message": "Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly.", "type": "no_db_connection", - "param": "None", + "param": None, "code": "503", } } diff --git a/tests/unit/proxy/proxy_server/test_exception_handlers.py b/tests/unit/proxy/proxy_server/test_exception_handlers.py index b34c2d2fb08..0f9d379afa1 100644 --- a/tests/unit/proxy/proxy_server/test_exception_handlers.py +++ b/tests/unit/proxy/proxy_server/test_exception_handlers.py @@ -438,7 +438,7 @@ _DB_OUTAGE_503_BODY: Final = { "error": { "message": "Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly.", "type": "no_db_connection", - "param": "None", + "param": None, "code": "503", } } diff --git a/tests/unit/proxy/proxy_server/test_routes_audio.py b/tests/unit/proxy/proxy_server/test_routes_audio.py index de76c7257cf..59d9490f786 100644 --- a/tests/unit/proxy/proxy_server/test_routes_audio.py +++ b/tests/unit/proxy/proxy_server/test_routes_audio.py @@ -14,9 +14,15 @@ from unittest.mock import AsyncMock, MagicMock import httpx import pytest +from fastapi import HTTPException +from litellm import Router from litellm.proxy import proxy_server +from litellm.proxy._types import ProxyErrorTypes, ProxyException from litellm.types.llms.openai import HttpxBinaryResponseContent +from litellm.types.router import RouterErrors + +_TAG_ROUTING_DENIAL = f"{RouterErrors.no_deployments_with_tag_routing.value}. Passed model=tts-1 and tags=['x']" @pytest.fixture @@ -208,7 +214,8 @@ def test_audio_speech_error(client, auth_as, patched_speech_error, path): with auth_as(): response = client.post(path, json=payload) assert response.status_code == 500 - assert len(response.content) > 0 + error = response.json()["error"] + assert (error["type"], error["param"]) == ("internal_server_error", None) def test_audio_speech_bad_request_maps_to_400(client, auth_as, patched_speech_provider_rejection): @@ -245,6 +252,104 @@ def test_audio_transcription_happy_path(client, auth_as, patched_transcription, } +@pytest.mark.parametrize("path", ["/v1/audio/transcriptions", "/audio/transcriptions"]) +def test_audio_transcription_over_the_file_size_cap_is_rejected_as_a_bad_request( + client, auth_as, patched_transcription, monkeypatch, path +): + from litellm.proxy.common_utils.http_parsing_utils import check_file_size_under_limit + + capped_router = Router( + model_list=[ + {"model_name": "whisper-1", "litellm_params": {"model": "openai/whisper-1", "max_file_size_mb": 0.000001}} + ] + ) + monkeypatch.setattr(proxy_server, "llm_router", capped_router) + monkeypatch.setattr(proxy_server, "premium_user", True) + monkeypatch.setattr(proxy_server, "check_file_size_under_limit", check_file_size_under_limit) + files = {"file": ("audio.mp3", b"\x00\x01\x02", "audio/mpeg")} + with auth_as(): + response = client.post(path, files=files, data={"model": "whisper-1"}) + assert response.status_code == 400 + error = response.json()["error"] + assert "File size is too large" in error["message"] + assert error["param"] == "file" + + +def test_audio_speech_tag_routing_denial_is_typed_as_a_proxy_auth_error( + client, auth_as, patched_speech_error, monkeypatch +): + async def _deny(*args, **kwargs): + raise ValueError(_TAG_ROUTING_DENIAL) + + monkeypatch.setattr(proxy_server, "route_request", _deny) + with auth_as(): + response = client.post("/v1/audio/speech", json={"model": "tts-1", "input": "Hi", "voice": "alloy"}) + assert response.status_code == 401 + error = response.json()["error"] + assert (error["type"], error["param"]) == ("auth_error", None) + + +@pytest.mark.parametrize( + ("failure", "expected_status", "expected_type"), + [ + pytest.param(ValueError(_TAG_ROUTING_DENIAL), 401, "auth_error", id="tag_routing_denial"), + pytest.param( + HTTPException(status_code=403, detail="Blocked by the content policy"), + 403, + "permission_error", + id="hook_policy_block", + ), + ], +) +def test_audio_transcription_failure_is_typed_by_who_rejected_it( + client, auth_as, patched_transcription, monkeypatch, failure, expected_status, expected_type +): + async def _deny(*args, **kwargs): + raise failure + + monkeypatch.setattr(proxy_server, "route_request", _deny) + files = {"file": ("audio.mp3", b"\x00\x01\x02", "audio/mpeg")} + with auth_as(): + response = client.post("/v1/audio/transcriptions", files=files, data={"model": "whisper-1"}) + assert response.status_code == expected_status + error = response.json()["error"] + assert (error["type"], error["param"]) == (expected_type, None) + + +@pytest.mark.parametrize( + "rejection", + [ + pytest.param( + ProxyException( + message="Invalid type for 'metadata': expected an object, but got an integer instead.", + type=ProxyErrorTypes.bad_request_error, + param="metadata", + code=400, + ), + id="bad_metadata", + ), + pytest.param( + ProxyException( + message="tag budgets are unavailable", type=ProxyErrorTypes.no_db_connection, param=None, code=503 + ), + id="tag_budget_store_down", + ), + ], +) +def test_audio_transcription_keeps_the_status_of_a_proxy_rejection( + client, auth_as, patched_transcription, monkeypatch, rejection +): + async def _reject(data, **kwargs): + raise rejection + + monkeypatch.setattr(proxy_server, "add_litellm_data_to_request", _reject) + files = {"file": ("audio.mp3", b"\x00\x01\x02", "audio/mpeg")} + with auth_as(): + response = client.post("/v1/audio/transcriptions", files=files, data={"model": "whisper-1"}) + assert response.status_code == int(rejection.code) + assert response.json()["error"] == rejection.to_dict() + + @pytest.mark.parametrize("path", ["/v1/audio/transcriptions", "/audio/transcriptions"]) def test_audio_transcription_error(client, auth_as, patched_transcription_error, path): """Pins ``POST /v1/audio/transcriptions`` / ``POST /audio/transcriptions`` (error).""" diff --git a/tests/unit/proxy/proxy_server/test_routes_completions.py b/tests/unit/proxy/proxy_server/test_routes_completions.py index b5c60c23c02..2b585357549 100644 --- a/tests/unit/proxy/proxy_server/test_routes_completions.py +++ b/tests/unit/proxy/proxy_server/test_routes_completions.py @@ -12,8 +12,10 @@ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock import pytest +from fastapi import HTTPException from litellm.proxy import common_request_processing, proxy_server +from litellm.types.router import RouterErrors from .conftest import normalize # type: ignore[import-not-found] @@ -124,3 +126,39 @@ def test_completion_pipeline_error(client, auth_as, completion_pipeline_raises, response = client.post(path, json=payload) assert response.status_code == 500 assert response.headers.get("content-type", "").startswith("application/json") + + +@pytest.mark.parametrize( + ("failure", "expected_status", "expected_type"), + [ + pytest.param(ValueError("boom"), 500, "internal_server_error", id="unexpected_failure"), + pytest.param( + HTTPException(status_code=403, detail="Blocked by the content policy"), + 403, + "permission_error", + id="hook_policy_block", + ), + pytest.param(HTTPException(status_code=401, detail="key expired"), 401, "authentication_error", id="hook_401"), + pytest.param( + ValueError(f"{RouterErrors.no_deployments_with_tag_routing.value}. Passed model=gpt-4o and tags=['x']"), + 401, + "auth_error", + id="tag_routing_denial", + ), + ], +) +def test_completion_failure_body_types_the_status_it_answers_with( + client, auth_as, monkeypatch, failure, expected_status, expected_type +): + monkeypatch.setattr(proxy_server, "llm_router", MagicMock()) + monkeypatch.setattr(proxy_server, "proxy_logging_obj", MagicMock(post_call_failure_hook=AsyncMock())) + + async def _raise(self, *args, **kwargs): + raise failure + + monkeypatch.setattr(common_request_processing.ProxyBaseLLMRequestProcessing, "base_process_llm_request", _raise) + with auth_as(): + response = client.post("/v1/completions", json={"model": "gpt-4o", "prompt": "hi"}) + assert response.status_code == expected_status + error = response.json()["error"] + assert (error["type"], error["param"], error["code"]) == (expected_type, None, str(expected_status)) diff --git a/tests/unit/proxy/proxy_server/test_streaming_helpers.py b/tests/unit/proxy/proxy_server/test_streaming_helpers.py index 22ada990fe6..c37475bd539 100644 --- a/tests/unit/proxy/proxy_server/test_streaming_helpers.py +++ b/tests/unit/proxy/proxy_server/test_streaming_helpers.py @@ -32,7 +32,7 @@ import litellm import litellm.proxy.proxy_server as ps from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY from litellm.integrations.custom_logger import CustomLogger -from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy._types import ProxyException, UserAPIKeyAuth from litellm.proxy.common_request_processing import create_response from litellm.proxy.proxy_server import ( _apply_streaming_chunk_hooks, @@ -946,6 +946,39 @@ def _error_of(frame: str) -> dict[str, object]: return json.loads(frame.removeprefix("data: "))["error"] +@pytest.mark.asyncio +@pytest.mark.parametrize( + "raised, expected_error", + [ + pytest.param( + ProxyException(message="Budget has been exceeded", type="budget_exceeded", param=None, code=429), + {"message": "Budget has been exceeded", "type": "budget_exceeded", "param": None, "code": "429"}, + id="proxy_exception_keeps_its_status", + ), + pytest.param( + RuntimeError("upstream blew up"), + {"message": "upstream blew up", "type": "internal_server_error", "param": None, "code": "500"}, + id="unexpected_failure", + ), + ], +) +async def test_a_mid_stream_failure_frame_carries_the_status_the_error_was_raised_with( + monkeypatch, raised: Exception, expected_error: dict[str, object] +): + _patch_logging_flags(monkeypatch) + monkeypatch.setattr(ps.proxy_logging_obj, "post_call_failure_hook", AsyncMock(return_value=None)) + + frames: Final = [ + frame + async for frame in async_data_generator( + response=_async_iter_raises(raised), user_api_key_dict=_user_auth(), request_data={} + ) + ] + + error_frames: Final = [frame for frame in frames if isinstance(frame, str) and frame.startswith('data: {"error":')] + assert [_error_of(frame.removesuffix("\n\n")) for frame in error_frames] == [expected_error] + + @pytest.mark.asyncio @pytest.mark.parametrize( "detail,expected_message,expected_fields", diff --git a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py index 8d53817f51e..6598b94e60f 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -8268,3 +8268,19 @@ async def test_global_spend_refresh_runs_the_refresh_on_the_writer_when_a_reader assert [statement for statement in journal.statements if statement[1].startswith("REFRESH")] == [ ("writer", 'REFRESH MATERIALIZED VIEW "MonthlyGlobalSpend";') ] + + +def test_spend_calculate_rejection_body_is_openai_shaped(client): + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user", api_key="hashed-k" + ) + try: + response = client.post("/spend/calculate", json={}, headers={"Authorization": "Bearer sk-test"}) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + assert response.status_code == 400 + error = response.json()["error"] + assert error["type"] == "invalid_request_error" + assert error["param"] is None + assert error["code"] == "400" diff --git a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py index 023ef0b4606..62a90109d26 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -7255,6 +7255,39 @@ async def test_async_data_generator_closes_the_guardrail_chain_before_returning_ assert cleanup_ran == [True] +@pytest.mark.asyncio +async def test_async_data_generator_midstream_failure_frame_is_openai_shaped_with_a_json_null_param(): + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.proxy_server import async_data_generator + + async def chain_failing_after_first_chunk(**_kwargs): + yield {"choices": [{"delta": {"content": "Hello"}}]} + raise ValueError("audit hook could not reach its sink") + + proxy_logging = _guarded_chain_logging(chain_failing_after_first_chunk) + proxy_logging.post_call_failure_hook = AsyncMock(return_value=None) + + with patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging): + frames = [ + frame + async for frame in async_data_generator( + MagicMock(), MagicMock(spec=UserAPIKeyAuth), {"model": "gpt-4o-mini"} + ) + ] + + error_bodies = [json.loads(frame.removeprefix("data: ")) for frame in frames if '"error"' in frame] + assert error_bodies == [ + { + "error": { + "message": "audit hook could not reach its sink", + "type": "internal_server_error", + "param": None, + "code": "500", + } + } + ] + + @pytest.mark.asyncio async def test_async_data_generator_closes_the_guardrail_chain_while_a_keepalive_read_is_pending(): from litellm.proxy._types import UserAPIKeyAuth @@ -14222,6 +14255,63 @@ async def test_moderations_reraises_proxy_exception_unwrapped(): mock_logging.post_call_failure_hook.assert_awaited_once() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("failure", "expected_type", "expected_code"), + [ + pytest.param(HTTPException(status_code=400, detail="bad input"), "invalid_request_error", "400", id="http_400"), + pytest.param(HTTPException(status_code=429, detail="slow down"), "rate_limit_error", "429", id="http_429"), + pytest.param( + HTTPException(status_code=403, detail="Blocked by the content policy"), + "permission_error", + "403", + id="hook_policy_block", + ), + pytest.param(Exception("provider down"), "internal_server_error", "500", id="bare_exception"), + pytest.param( + ValueError("Not allowed to access model due to tags configuration. Passed model=m and tags=['x']"), + "auth_error", + "401", + id="tag_routing_denial", + ), + ], +) +async def test_moderations_failure_body_is_openai_shaped(failure, expected_type, expected_code): + from litellm.proxy._types import ProxyException + + async def passthrough_add_litellm_data(data, **kwargs): + return data + + request = MagicMock() + request.body = AsyncMock(return_value=b'{"input": "hi"}') + fake_logging = MagicMock() + fake_logging.pre_call_hook = AsyncMock(side_effect=lambda user_api_key_dict, data, call_type: data) + fake_logging.post_call_failure_hook = AsyncMock() + + with ( + patch.object( + proxy_server_module, "add_litellm_data_to_request", new=passthrough_add_litellm_data + ), # test-quality-ok: the route reads this module global, no injection point + patch.object( + proxy_server_module, "route_request", new=AsyncMock(side_effect=failure) + ), # test-quality-ok: fakes the provider failure so the real route's error shaping is observable + patch.object( + proxy_server_module, "proxy_logging_obj", new=fake_logging + ), # test-quality-ok: module global, no injection point + pytest.raises(ProxyException) as raised, + ): + await proxy_server_module.moderations( + request=request, + fastapi_response=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + ) + + body = json.loads(json.dumps({"error": raised.value.to_dict()})) + assert body["error"]["type"] == expected_type + assert body["error"]["param"] is None + assert body["error"]["code"] == expected_code + + @pytest.mark.asyncio async def test_moderations_response_carries_litellm_call_id_header(): from fastapi import Response