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
This commit is contained in:
Mateo Wang 2026-10-29 18:20:52 -07:00 • committed by GitHub
parent dacaaad72e
commit 636e707089
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 606 additions and 130 deletions

View file

@ -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

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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:

View file

@ -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),

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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())

View file

@ -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")) == ()

View file

@ -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",
)
)

View file

@ -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:

View file

@ -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",
}
}

View file

@ -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=<caller>",
"type": "auth_error",
"param": "None",
"param": None,
"code": "401",
}
}

View file

@ -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):

View file

@ -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",
}
}

View file

@ -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",
}
}

View file

@ -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

View file

@ -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",
}
}

View file

@ -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",
}
}

View file

@ -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",
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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."""

View file

@ -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",
}
}

View file

@ -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

View file

@ -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",
}
}

View file

@ -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",
}
}

View file

@ -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)."""

View file

@ -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))

View file

@ -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",

View file

@ -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"

View file

@ -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