fix(proxy)!: stop labelling management and health rejections as auth failures (#48739)

* test(proxy): cover management and health routes labelling rejections as auth failures

* fix(proxy): name management and health route errors by their status instead of auth_error

* fix(proxy): keep auth_error for 401 and 403 and answer database outages with 503 in route catch-alls

/key/list and /key/aliases now share the catch-all helper too, so they stop
adding the Authentication Error prefix and typing a 400 as internal_server_error

* fix(proxy): answer 503 when the database drops mid-query and prisma fails to name the error

A real outage surfaces from prisma's query engine as a builtin AttributeError, which the
prisma-module check skipped, so the route catch-alls still answered 500 or 400

* fix(proxy): report wrapped and stalled database outages as 503 and label upstream failures by their own status

* fix(proxy): answer the upstream status on the catch-all, keep auth_error on tag denials, report model insert outages as 503

* fix(proxy): answer 503 for database failovers and label provider auth errors like chat

A database that refuses connections while starting up, shutting down, in
recovery, or read-only after a failover now answers 503 no_db_connection
instead of 400. A provider's 401 or 403 keeps the type /chat/completions
gives it, while a 401 or 403 the proxy raises keeps auth_error. An
exception with no message is named by its type.

* fix(proxy): scope failover 503s to management route errors and keep a re-raised error's cause

* test(proxy): expect the OpenAI error shape from management route refusals

Team-not-found 404s now answer invalid_request_error, and every handler-raised refusal answers param null instead of the string None.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
tavren27 2026-10-28 21:49:03 -07:00 • committed by GitHub
parent a11e76fe8f
commit cbf4e40d38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 808 additions and 220 deletions

View file

@ -3816,6 +3816,16 @@ class ManagementEndpointLoggingPayload(LiteLLMPydanticObjectBase):
end_time: datetime | None = None
def proxy_exception_status_code(message: str, code: str) -> str:
# Litellm router.py returns "No healthy deployment available" when there are no deployments available
# Should map to 429 errors https://github.com/BerriAI/litellm/issues/2487
if "No healthy deployment available" in message or "No deployments available" in message:
return "429"
if RouterErrors.no_deployments_with_tag_routing.value in message:
return "401"
return code
class ProxyException(Exception):
# NOTE: DO NOT MODIFY THIS
# This is used to map exactly to OPENAI Exceptions
@ -3844,13 +3854,7 @@ class ProxyException(Exception):
headers[k] = str(v)
self.headers = headers or {}
self.provider_specific_fields = provider_specific_fields
# rules for proxyExceptions
# Litellm router.py returns "No healthy deployment available" when there are no deployments available
# Should map to 429 errors https://github.com/BerriAI/litellm/issues/2487
if "No healthy deployment available" in self.message or "No deployments available" in self.message:
self.code = "429"
elif RouterErrors.no_deployments_with_tag_routing.value in self.message:
self.code = "401"
self.code = proxy_exception_status_code(self.message, self.code)
def to_dict(self) -> dict:
"""Converts the ProxyException instance to a dictionary."""

View file

@ -2,14 +2,17 @@
``type`` is a required string and ``param`` is nullable, neither of which the literal
string ``"None"`` satisfies."""
import sys
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final
from fastapi import status
from fastapi import HTTPException, status
from litellm.constants import STRINGIFIED_NONE
from litellm.proxy._types import ProxyException
from litellm.proxy._types import ProxyErrorTypes, ProxyException, proxy_exception_status_code
from litellm.proxy.db.db_lookup_gate import DBLookupDeadlineExceeded
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler, exception_chain
LITELLM_CALL_ID_HEADER: Final = "x-litellm-call-id"
@ -21,6 +24,19 @@ _OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType(
}
)
_AUTH_FAILURE_STATUS_CODES: Final = frozenset({status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN})
_FAILOVER_SQLSTATES: Final = frozenset({"25006", "57P01", "57P02", "57P03"})
_FAILOVER_REFUSALS: Final = (
"is not currently accepting connections",
"the database system is starting up",
"the database system is shutting down",
"the database system is in recovery mode",
"the database system is not yet accepting connections",
"terminating connection due to administrator command",
)
def attribute_of(value: object, name: str, default: object = None) -> object:
return getattr(value, name, default)
@ -61,6 +77,82 @@ def openai_error_param(exc: object) -> str | None:
return carried if isinstance(carried, str) and carried != STRINGIFIED_NONE else None
def proxy_exception_for(exc: Exception, default_status_code: int) -> ProxyException:
match exc:
case ProxyException():
return exc
case _ if _is_database_outage(exc):
return ProxyException(
message=PrismaDBExceptionHandler.database_unavailable_message(exc),
type=ProxyErrorTypes.no_db_connection.value,
param=None,
code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
case HTTPException(status_code=status_code, detail=detail):
return _openai_shaped_proxy_exception(exc, str(detail), status_code)
case _:
return _openai_shaped_proxy_exception(
exc, str(exc) or type(exc).__name__, error_status_code(exc, default_status_code)
)
def _is_database_outage(exc: Exception) -> bool:
return any(map(_is_database_outage_link, exception_chain(exc)))
def _is_database_outage_link(exc: BaseException) -> bool:
match exc:
case DBLookupDeadlineExceeded():
return True
case Exception() if _prisma_is_loaded():
return _is_prisma_outage(exc)
case _:
return False
def _prisma_is_loaded() -> bool:
return sys.modules.get("prisma") is not None
def _is_prisma_outage(exc: Exception) -> bool:
if PrismaDBExceptionHandler.is_prisma_engine_internal_error(exc):
return True
if type(exc).__module__.partition(".")[0] != "prisma":
return False
return PrismaDBExceptionHandler.is_database_service_unavailable_error(exc) or _is_failover_refusal(exc)
def _is_failover_refusal(exc: Exception) -> bool:
if PrismaDBExceptionHandler.is_read_only_transaction_error(exc):
return True
if PrismaDBExceptionHandler.postgres_sqlstate(exc) in _FAILOVER_SQLSTATES:
return True
message: Final = str(exc).lower()
return any(refusal in message for refusal in _FAILOVER_REFUSALS)
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)))
return ProxyException(
message=message,
type=_catch_all_error_type(exc, answered_status),
param=openai_error_param(exc),
code=answered_status,
)
def _catch_all_error_type(exc: Exception, status_code: int) -> str:
if _rejects_the_caller(exc, status_code):
return ProxyErrorTypes.auth_error.value
return openai_error_type(exc, status_code)
def _rejects_the_caller(exc: Exception, status_code: int) -> bool:
raised_as_http_answer: Final = isinstance(exc, HTTPException)
status_from_upstream: Final = error_status_code(exc, 0) == status_code
return status_code in _AUTH_FAILURE_STATUS_CODES and (raised_as_http_answer or not status_from_upstream)
def litellm_call_id_headers(litellm_call_id: str | None) -> dict[str, str] | None: # mutable-ok: ProxyException.headers
if litellm_call_id is None:
return None

View file

@ -36,7 +36,7 @@ _LOCK_TIMEOUT_SQLSTATE: Final = "55P03"
_LOCK_TIMEOUT_PHRASE: Final = "canceling statement due to lock timeout"
def _exception_chain(e: BaseException) -> Iterator[BaseException]:
def exception_chain(e: BaseException) -> Iterator[BaseException]:
current = e # rebind-ok: advances one link per iteration of the bounded walk
for _ in range(_MAX_EXCEPTION_CHAIN_DEPTH):
yield current
@ -49,7 +49,7 @@ def _exception_chain(e: BaseException) -> Iterator[BaseException]:
def _database_service_unavailable_errors(e: BaseException) -> tuple[Exception, ...]:
return tuple(
link
for link in _exception_chain(e)
for link in exception_chain(e)
if isinstance(link, Exception) and PrismaDBExceptionHandler.is_database_service_unavailable_error(link)
)

View file

@ -32,7 +32,6 @@ from litellm.proxy._types import (
EnterpriseLicenseData,
Litellm_EntityType,
LitellmUserRoles,
ProxyErrorTypes,
ProxyException,
SpecialModelNames,
UserAPIKeyAuth,
@ -47,6 +46,7 @@ from litellm.proxy.auth.auth_utils import (
from litellm.proxy.auth.model_checks import get_key_models
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils import http_parsing_utils
from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for
from litellm.proxy.db.db_lookup_gate import db_lookup_stall_tracker
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.db.health_check_latest import (
@ -683,21 +683,9 @@ async def health_services_endpoint(
except Exception as e:
verbose_proxy_logger.error("litellm.proxy.proxy_server.health_services_endpoint(): Exception occured - %s", e)
verbose_proxy_logger.debug(traceback.format_exc())
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from e
def convert_health_check_to_dict(check) -> dict:

View file

@ -46,6 +46,7 @@ from litellm.proxy.auth.password_policy import (
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for
from litellm.proxy.common_utils.user_api_key_cache import (
AUTH_OBJECTS_TARGET,
object_permission_cache_key,
@ -1729,21 +1730,9 @@ async def user_update(
except Exception as e:
verbose_proxy_logger.exception("litellm.proxy.proxy_server.user_update(): Exception occured - %s", e)
verbose_proxy_logger.debug(traceback.format_exc())
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e
async def bulk_update_processed_users(

View file

@ -75,6 +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.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
@ -4087,21 +4088,9 @@ async def update_key_fn(
# update based on remaining passed in values
except Exception as e:
verbose_proxy_logger.exception("litellm.proxy.proxy_server.update_key_fn(): Exception occured - %s", e)
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e
@router.post(
@ -7244,21 +7233,9 @@ async def list_keys(
except Exception as e:
verbose_proxy_logger.exception("Error in list_keys: %s", e)
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"error({e})"),
type=ProxyErrorTypes.internal_server_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", fastapi.status.HTTP_500_INTERNAL_SERVER_ERROR),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.internal_server_error,
param=getattr(e, "param", "None"),
code=fastapi.status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=fastapi.status.HTTP_500_INTERNAL_SERVER_ERROR) from e
async def _apply_non_admin_alias_scope(
@ -7402,21 +7379,9 @@ async def key_aliases(
except Exception as e:
verbose_proxy_logger.exception("Error in key_aliases: %s", e)
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"error({e})"),
type=ProxyErrorTypes.internal_server_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.internal_server_error,
param=getattr(e, "param", "None"),
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from e
def _validate_sort_params(

View file

@ -77,6 +77,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient
from litellm.proxy.management.teams.authz import TEAM_ADMIN_ONLY, is_team_admin
@ -2845,21 +2846,9 @@ async def delete_model(
except Exception as e:
verbose_proxy_logger.exception("Failed to delete model. Due to error - %s", e)
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e
async def delete_team_model_alias(
@ -2899,6 +2888,13 @@ async def delete_team_model_alias(
return removed_model_aliases
def _model_not_added_to_db() -> HTTPException:
return HTTPException(
status_code=500,
detail={"error": "Failed to add model to db. Check your server logs for more details."},
)
@router.post(
"/model/new",
description="Allows adding new models to the model list in the config.yaml",
@ -3080,6 +3076,8 @@ async def add_new_model(
if isinstance(e, HTTPException):
raise
verbose_proxy_logger.exception("Exception in add_new_model: %s", e)
if model_response is None:
raise _model_not_added_to_db() from e
else:
raise HTTPException(
@ -3088,10 +3086,7 @@ async def add_new_model(
)
if model_response is None:
raise HTTPException(
status_code=500,
detail={"error": "Failed to add model to db. Check your server logs for more details."},
)
raise _model_not_added_to_db()
## CREATE AUDIT LOG ##
asyncio.create_task(
@ -3121,21 +3116,9 @@ async def add_new_model(
except Exception as e:
verbose_proxy_logger.exception("litellm.proxy.proxy_server.add_new_model(): Exception occured - %s", e)
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e
#### MODEL MANAGEMENT ####
@ -3364,21 +3347,9 @@ async def update_model(
return None if model_response is None else _model_write_response(model_response, member_write)
except Exception as e:
verbose_proxy_logger.exception("litellm.proxy.proxy_server.update_model(): Exception occured - %s", e)
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e
@router.post(

View file

@ -41,6 +41,7 @@ from litellm.proxy.auth.auth_checks import (
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints.budget_management_endpoints import (
@ -1396,21 +1397,9 @@ async def organization_member_add(
)
except Exception as e:
verbose_proxy_logger.exception("Error adding member to organization: %s", e)
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from e
async def find_member_if_email(user_email: str, prisma_client: PrismaClient) -> LiteLLM_UserTable:

View file

@ -132,6 +132,7 @@ from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
)
from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch
from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
team_membership_auth_cache_key,
@ -5526,21 +5527,9 @@ async def team_info(
e,
traceback.format_exc(),
)
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e
@router.get(

View file

@ -524,6 +524,7 @@ from litellm.proxy.common_utils.openai_error_payload import (
LITELLM_CALL_ID_HEADER,
headers_with_litellm_call_id,
litellm_call_id_headers,
proxy_exception_for,
with_litellm_call_id,
)
from litellm.proxy.common_utils.periodic_reload_schedule import (
@ -18536,25 +18537,13 @@ async def async_queue_request(
await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict, original_exception=e, request_data=data
)
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
)
elif isinstance(e, ProxyException):
raise e
elif isinstance(e, litellm.BadRequestError):
if isinstance(e, litellm.BadRequestError):
raise ProxyException(
message=e.message, type="invalid_request_error", param=getattr(e, "param", None), code=e.status_code
)
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=status.HTTP_400_BAD_REQUEST,
)
) from e
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e
@app.get("/fallback/login", tags=["experimental"], include_in_schema=False)
@ -19833,21 +19822,9 @@ async def update_config(
except Exception as e:
verbose_proxy_logger.error("litellm.proxy.proxy_server.update_config(): Exception occured - %s", e)
verbose_proxy_logger.debug(traceback.format_exc())
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_400_BAD_REQUEST) from e
### CONFIG GENERAL SETTINGS
@ -21030,21 +21007,9 @@ async def get_config(
}
except Exception as e:
verbose_proxy_logger.exception("litellm.proxy.proxy_server.get_config(): Exception occured - %s", e)
if isinstance(e, HTTPException):
raise ProxyException(
message=getattr(e, "detail", f"Authentication Error({e})"),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
)
elif isinstance(e, ProxyException):
raise e
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
param=getattr(e, "param", "None"),
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(e, ProxyException):
raise
raise proxy_exception_for(e, default_status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from e
@router.get(

View file

@ -746,7 +746,7 @@ def test_internal_user_model_creation_prohibition_refuses_team_admin_but_not_pro
"error": {
"message": "Model creation is disabled for internal users by disable_model_add_for_internal_users.",
"type": "auth_error",
"param": "None",
"param": None,
"code": "403",
}
},

View file

@ -38,7 +38,7 @@ def _escalation_error(field: str) -> dict[str, JsonValue]:
"error": {
"message": f"{{'error': \"Non-admin users cannot modify '{field}' on their own record. Contact your proxy admin.\"}}",
"type": "auth_error",
"param": "None",
"param": None,
"code": "403",
}
}
@ -49,7 +49,7 @@ def _role_change_error() -> dict[str, JsonValue]:
"error": {
"message": "Only proxy admins can modify user roles.",
"type": "auth_error",
"param": "None",
"param": None,
"code": "403",
}
}

View file

@ -255,8 +255,8 @@ def _team_not_found_body(team_id: str) -> dict[str, JsonValue]:
return {
"error": {
"message": f"{{'message': 'Team not found, passed team id: {team_id}.'}}",
"type": "auth_error",
"param": "None",
"type": "invalid_request_error",
"param": None,
"code": "404",
}
}

View file

@ -1,16 +1,26 @@
import json
import sys
from typing import Final
import httpx
import prisma.errors
import pytest
from fastapi import HTTPException
from prisma._async_http import Response
from prisma.engine.utils import handle_response_errors
import litellm
from litellm.proxy._types import ProxyErrorTypes, ProxyException
from litellm.proxy.db.db_lookup_gate import DBLookupDeadlineExceeded
from litellm.proxy.common_utils.openai_error_payload import (
error_status_code,
litellm_call_id_headers,
openai_error_param,
openai_error_type,
proxy_exception_for,
with_litellm_call_id,
)
from litellm.types.router import RouterErrors
@pytest.mark.parametrize(
@ -203,3 +213,287 @@ def test_a_proxy_error_keeps_the_call_id_it_was_raised_with():
assert with_litellm_call_id(raised_with_id, "second").headers == {"x-litellm-call-id": "first"}
assert with_litellm_call_id(ProxyException(message="nope", type="None", param=None, code=400), None).headers == {}
@pytest.mark.parametrize(
"raised_status, expected_type",
[
(400, "invalid_request_error"),
(404, "invalid_request_error"),
(401, "auth_error"),
(403, "auth_error"),
(422, "invalid_request_error"),
(500, "internal_server_error"),
],
)
def test_a_catch_all_handler_keeps_the_status_its_route_answered_with(raised_status: int, expected_type: str) -> None:
wrapped: Final = proxy_exception_for(
HTTPException(status_code=raised_status, detail={"message": "boom"}),
default_status_code=400,
)
assert (wrapped.code, wrapped.type) == (str(raised_status), expected_type)
@pytest.mark.parametrize(
"default_status_code, expected_type", [(400, "invalid_request_error"), (500, "internal_server_error")]
)
def test_an_exception_carrying_no_status_falls_back_to_the_routes_default(default_status_code: int, expected_type: str) -> None:
wrapped: Final = proxy_exception_for(ValueError("boom"), default_status_code=default_status_code)
assert (wrapped.code, wrapped.type) == (str(default_status_code), expected_type)
def test_a_proxy_exception_passes_through_with_the_type_it_already_named() -> None:
raised: Final = ProxyException(
message="Budget has been exceeded",
type=ProxyErrorTypes.budget_exceeded.value,
param="max_budget",
code=400,
)
assert proxy_exception_for(raised, default_status_code=500) is raised
def test_the_message_is_what_the_route_raised_without_an_invented_prefix() -> None:
assert proxy_exception_for(ValueError("boom"), default_status_code=400).message == "boom"
def test_an_http_exceptions_detail_survives_the_wrapping() -> None:
wrapped: Final = proxy_exception_for(
HTTPException(status_code=400, detail={"error": "Invalid budget_duration 'not-a-duration'."}),
default_status_code=400,
)
assert "Invalid budget_duration" in wrapped.message
def test_a_carried_type_survives_the_wrapping() -> None:
class _Carrier(Exception):
type: str = "context_window_exceeded"
status_code: int = 400
assert proxy_exception_for(_Carrier("boom"), default_status_code=500).type == "context_window_exceeded"
@pytest.mark.parametrize("exc", [HTTPException(status_code=404, detail="nope"), ValueError("boom")])
def test_the_wrapped_error_serializes_as_an_openai_error_object(exc: Exception) -> None:
body: Final = json.loads(json.dumps({"error": proxy_exception_for(exc, default_status_code=400).to_dict()}))
assert body["error"]["param"] is None
assert body["error"]["type"] == "invalid_request_error"
@pytest.mark.parametrize(
"provider_failure, expected",
[
(
litellm.AuthenticationError(message="invalid x-api-key", llm_provider="anthropic", model="claude-haiku-5-5"),
("401", "authentication_error"),
),
(
litellm.PermissionDeniedError(
message="model not allowed",
llm_provider="anthropic",
model="claude-haiku-5-5",
response=httpx.Response(403, request=httpx.Request("POST", "https://api.anthropic.com/v1/messages")),
),
("403", "permission_error"),
),
],
)
def test_a_provider_credential_failure_is_labelled_the_way_chat_completions_labels_it(
provider_failure: Exception, expected: tuple[str, str]
) -> None:
wrapped: Final = proxy_exception_for(provider_failure, default_status_code=400)
assert (wrapped.code, wrapped.type) == expected
@pytest.mark.parametrize("status_code", [401, 403])
def test_a_rejection_the_route_raised_keeps_the_auth_label(status_code: int) -> None:
wrapped: Final = proxy_exception_for(HTTPException(status_code=status_code, detail="no"), default_status_code=400)
assert (wrapped.code, wrapped.type) == (str(status_code), "auth_error")
def test_an_exception_without_a_message_is_named_by_its_type() -> None:
wrapped: Final = proxy_exception_for(TimeoutError(), default_status_code=500)
assert wrapped.message == "TimeoutError"
def test_a_database_outage_answers_503_so_callers_retry_instead_of_fixing_their_request() -> None:
wrapped: Final = proxy_exception_for(
prisma.errors.PrismaError("Can't reach database server at `db:5432`"), default_status_code=400
)
assert (wrapped.code, wrapped.type) == ("503", "no_db_connection")
@pytest.mark.parametrize(
"postgres_refusal",
[
'FATAL: database "litellm" is not currently accepting connections',
"FATAL: the database system is starting up",
"FATAL: the database system is shutting down",
"FATAL: the database system is in recovery mode",
"FATAL: the database system is not yet accepting connections",
"FATAL: terminating connection due to administrator command",
"ERROR: cannot execute UPDATE in a read-only transaction",
],
)
def test_a_database_refusing_work_during_a_failover_answers_503(postgres_refusal: str) -> None:
refused: Final = prisma.errors.DataError(
{"user_facing_error": {"message": f"Error in connector: Error querying the database: {postgres_refusal}"}}
)
wrapped: Final = proxy_exception_for(refused, default_status_code=400)
assert (wrapped.code, wrapped.type) == ("503", "no_db_connection")
@pytest.mark.parametrize(
"localized_refusal",
[
{
"error_code": "P2010",
"message": "Raw query failed. Message: `FEHLER: kann UPDATE nicht in einer Nur-Lese-Transaktion ausführen`",
"meta": {"code": "25006"},
},
{
"message": 'PostgresError { code: "57P01", message: "Verbindung wird abgebrochen", severity: "FATAL" }',
},
],
)
def test_a_failover_refusal_in_a_localized_postgres_answers_503(localized_refusal: dict[str, object]) -> None:
refused: Final = prisma.errors.DataError({"user_facing_error": localized_refusal})
wrapped: Final = proxy_exception_for(refused, default_status_code=400)
assert (wrapped.code, wrapped.type) == ("503", "no_db_connection")
def test_a_failover_phrase_outside_the_database_is_not_reported_as_a_database_outage() -> None:
callback_down: Final = ValueError("FATAL: the database system is starting up")
wrapped: Final = proxy_exception_for(callback_down, default_status_code=400)
assert (wrapped.code, wrapped.type) == ("400", "invalid_request_error")
def test_a_database_dropped_mid_query_answers_503_though_prisma_fails_to_name_the_error() -> None:
engine_reply_with_null_meta: Final = [{"user_facing_error": {"error_code": "P1001", "message": "", "meta": None}}]
with pytest.raises(AttributeError) as raised:
handle_response_errors(Response(httpx.Response(500)), engine_reply_with_null_meta)
wrapped: Final = proxy_exception_for(raised.value, default_status_code=500)
assert (wrapped.code, wrapped.type) == ("503", "no_db_connection")
def test_a_network_failure_outside_the_database_is_not_reported_as_a_database_outage() -> None:
callback_unreachable: Final = httpx.ConnectError("[Errno 111] Connection refused")
wrapped: Final = proxy_exception_for(callback_unreachable, default_status_code=500)
assert (wrapped.code, wrapped.type) == ("500", "internal_server_error")
def _add_member_failing_with(db_error: Exception) -> None:
try:
raise db_error
except Exception as e:
raise ValueError(f"Error adding member to organization: {e}")
@pytest.mark.parametrize(
"underlying_error, expected",
[
(prisma.errors.PrismaError("Can't reach database server at `db:5432`"), ("503", "no_db_connection")),
(httpx.ConnectError("[Errno 111] Connection refused"), ("500", "internal_server_error")),
],
)
def test_a_route_that_rewraps_its_error_keeps_the_database_outage_it_wrapped(
underlying_error: Exception, expected: tuple[str, str]
) -> None:
with pytest.raises(ValueError, match="Error adding member") as raised:
_add_member_failing_with(underlying_error)
wrapped: Final = proxy_exception_for(raised.value, default_status_code=500)
assert (wrapped.code, wrapped.type) == expected
def test_a_proxy_without_prisma_installed_still_answers_with_an_error_object(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(sys.modules, "prisma", None)
wrapped: Final = proxy_exception_for(httpx.ConnectError("[Errno 111] Connection refused"), default_status_code=500)
assert (wrapped.code, wrapped.type) == ("500", "internal_server_error")
def _look_up_team_failing_with(lookup_error: Exception) -> None:
try:
raise lookup_error
except Exception:
raise HTTPException(status_code=404, detail="Team doesn't exist in db")
@pytest.mark.parametrize(
"lookup_error, expected",
[
(DBLookupDeadlineExceeded("team", 10.0), ("503", "no_db_connection")),
(prisma.errors.PrismaError("Can't reach database server at `db:5432`"), ("503", "no_db_connection")),
(KeyError("team_id"), ("404", "invalid_request_error")),
],
)
def test_a_not_found_answer_raised_while_the_database_was_down_reports_the_outage(
lookup_error: Exception, expected: tuple[str, str]
) -> None:
with pytest.raises(HTTPException) as raised:
_look_up_team_failing_with(lookup_error)
wrapped: Final = proxy_exception_for(raised.value, default_status_code=400)
assert (wrapped.code, wrapped.type) == expected
def test_a_lookup_that_missed_its_deadline_answers_503() -> None:
wrapped: Final = proxy_exception_for(DBLookupDeadlineExceeded("team", 10.0), default_status_code=400)
assert (wrapped.code, wrapped.type) == ("503", "no_db_connection")
def test_a_database_outage_answer_does_not_reveal_where_the_database_lives() -> None:
wrapped: Final = proxy_exception_for(
prisma.errors.PrismaError("Can't reach database server at `prod-db.internal:5432`"), default_status_code=400
)
assert wrapped.code == "503"
assert "prod-db.internal" not in wrapped.message
def test_an_upstream_failure_answers_with_its_own_status_not_the_routes_default() -> None:
upstream_overloaded: Final = litellm.ServiceUnavailableError(
message="upstream overloaded", llm_provider="openai", model="gpt-5.4-mini"
)
wrapped: Final = proxy_exception_for(upstream_overloaded, default_status_code=400)
assert (wrapped.code, wrapped.type) == ("503", "internal_server_error")
@pytest.mark.parametrize(
"router_rejection, expected",
[
(f"{RouterErrors.no_deployments_with_tag_routing.value}. Passed model=gpt-5.4-mini", ("401", "auth_error")),
(f"{RouterErrors.no_deployments_available.value}. Passed model=gpt-5.4-mini", ("429", "rate_limit_error")),
],
)
def test_a_router_rejection_is_labelled_by_the_status_it_answers_with(
router_rejection: str, expected: tuple[str, str]
) -> None:
wrapped: Final = proxy_exception_for(ValueError(router_rejection), default_status_code=400)
assert (wrapped.code, wrapped.type) == expected

View file

@ -5480,6 +5480,15 @@ async def test_health_services_endpoint_langfuse_missing_keys_errors(monkeypatch
assert "connection refused by lf.internal.example" in str(raised.value.message)
@pytest.mark.asyncio
async def test_health_services_reports_an_unknown_service_as_a_request_error_not_an_auth_error() -> None:
with pytest.raises(ProxyException) as exc_info:
await health_services_endpoint(service="totally_unknown_service_xyz")
assert (exc_info.value.code, exc_info.value.type) == ("400", "invalid_request_error")
assert "Authentication Error" not in exc_info.value.message
@pytest.mark.asyncio
@pytest.mark.parametrize("admin", [False, True])
async def test_unsaved_chatgpt_account_probe_requires_proxy_admin(

View file

@ -5211,3 +5211,18 @@ async def test_user_update_without_password_revokes_nothing(_admin_prisma, mocke
await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller)
revoke_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_user_update_reports_a_bad_budget_duration_as_a_request_error_not_an_auth_error(_admin_prisma: MagicMock) -> None:
from litellm.proxy.management_endpoints.internal_user_endpoints import user_update
with pytest.raises(ProxyException) as exc_info:
await user_update(
data=UpdateUserRequest(user_id="target-user", budget_duration="not-a-duration"),
user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert (exc_info.value.code, exc_info.value.type) == ("400", "invalid_request_error")
assert "Invalid budget_duration" in exc_info.value.message
assert "Authentication Error" not in exc_info.value.message

View file

@ -6538,7 +6538,7 @@ async def test_list_keys_with_invalid_status():
)
# Verify ProxyException properties
assert exc_info.value.code == "400"
assert (exc_info.value.code, exc_info.value.type) == ("400", "invalid_request_error")
assert "Invalid status value" in str(exc_info.value.message)
assert "deleted" in str(exc_info.value.message)
@ -23377,3 +23377,73 @@ def test_key_info_database_not_connected_sanitized_500(
assert "Database not connected" in caplog.text
finally:
proxy_server.app.dependency_overrides.pop(user_api_key_auth, 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
from litellm.proxy.management_endpoints.key_management_endpoints import update_key_fn
monkeypatch.setattr( # test-quality-ok: the endpoint reads proxy_server.prisma_client itself, no parameter to inject
"litellm.proxy.proxy_server.prisma_client", MagicMock()
)
with pytest.raises(ProxyException) as exc_info:
await update_key_fn(
request=MagicMock(spec=Request),
data=UpdateKeyRequest(key="sk-whatever", budget_duration="not-a-duration"),
user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert (exc_info.value.code, exc_info.value.type) == ("400", "invalid_request_error")
assert "Authentication Error" not in exc_info.value.message
@pytest.mark.asyncio
async def test_update_key_passes_its_own_rejection_through_without_making_it_its_own_cause(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from fastapi import Request
from litellm.proxy.management_endpoints.key_management_endpoints import update_key_fn
prisma_client: Final = MagicMock()
prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr( # test-quality-ok: the endpoint reads proxy_server.prisma_client itself, no parameter to inject
"litellm.proxy.proxy_server.prisma_client", prisma_client
)
with pytest.raises(ProxyException) as exc_info:
await update_key_fn(
request=MagicMock(spec=Request),
data=UpdateKeyRequest(key="sk-missing", max_budget=5),
user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert (exc_info.value.code, exc_info.value.type) == ("404", "not_found_error")
assert exc_info.value.__cause__ is None
@pytest.mark.asyncio
async def test_key_aliases_answers_503_when_the_database_is_unreachable(monkeypatch: pytest.MonkeyPatch) -> None:
import prisma.errors
from litellm.proxy.management_endpoints.key_management_endpoints import key_aliases
unreachable_db: Final = MagicMock()
unreachable_db.db.query_raw = AsyncMock(
side_effect=prisma.errors.PrismaError("Can't reach database server at `db:5432`")
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", unreachable_db)
with pytest.raises(ProxyException) as exc_info:
await key_aliases(
user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
page=1,
size=50,
search=None,
team_id=None,
)
assert (exc_info.value.code, exc_info.value.type) == ("503", "no_db_connection")

View file

@ -311,7 +311,7 @@ async def test_model_creation_fails_closed_when_authoritative_policy_is_unavaila
Deployment(model_name="cannot-create", litellm_params={"model": "openai/test-model"}),
UserAPIKeyAuth(user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER),
)
assert error.value.code == "503"
assert (error.value.code, error.value.type) == ("503", "internal_server_error")
prisma.db.litellm_proxymodeltable.create.assert_not_called()
prisma.db.litellm_teamtable.find_unique.assert_not_called()
@ -10913,3 +10913,76 @@ class TestNonAdminCannotSetPerUserOauthOnModel:
user_api_key_dict=admin,
)
assert result is created_row
@pytest.mark.asyncio
async def test_delete_model_reports_an_unknown_model_id_as_a_request_error_not_an_auth_error(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy import proxy_server
from litellm.proxy._types import ModelInfoDelete
from litellm.proxy.management_endpoints.model_management_endpoints import delete_model
prisma: Final = MagicMock()
prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
with pytest.raises(ProxyException) as exc_info:
await delete_model(
model_info=ModelInfoDelete(id="no-such-model"),
user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert (exc_info.value.code, exc_info.value.type) == ("400", "invalid_request_error")
assert "Authentication Error" not in exc_info.value.message
@pytest.mark.asyncio
async def test_update_model_reports_an_unknown_model_id_as_a_request_error_not_an_auth_error(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy import proxy_server
from litellm.proxy.management_endpoints.model_management_endpoints import update_model
prisma: Final = MagicMock()
prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
monkeypatch.setattr(proxy_server, "llm_router", None)
with pytest.raises(ProxyException) as exc_info:
await update_model(
model_params=updateDeployment(model_info=ModelInfo(id="no-such-model")),
user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert (exc_info.value.code, exc_info.value.type) == ("400", "invalid_request_error")
assert exc_info.value.message == "model not found"
@pytest.mark.asyncio
async def test_add_new_model_reports_a_database_outage_during_the_insert_as_503(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import prisma.errors
from litellm.proxy import proxy_server
from litellm.proxy.management_endpoints.model_management_endpoints import add_new_model
unreachable_db: Final = MagicMock()
unreachable_db.db.query_raw = AsyncMock(return_value=[])
unreachable_db.db.litellm_proxymodeltable.create = AsyncMock(
side_effect=prisma.errors.PrismaError("Can't reach database server at `db:5432`")
)
monkeypatch.setattr(proxy_server, "prisma_client", unreachable_db)
monkeypatch.setattr(proxy_server, "store_model_in_db", True)
monkeypatch.setattr(proxy_server, "general_settings", {})
monkeypatch.setattr(proxy_server, "premium_user", True)
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-outage-salt")
with pytest.raises(ProxyException) as exc_info:
await add_new_model(
model_params=Deployment(
model_name="outage-model",
litellm_params=LiteLLM_Params(model="openai/gpt-5.4-mini", api_key="fake-key"),
model_info={"id": "outage-model-1"},
),
user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert (exc_info.value.code, exc_info.value.type) == ("503", "no_db_connection")

View file

@ -1617,6 +1617,37 @@ async def test_delete_organization_is_refused_while_one_of_its_teams_holds_a_ptu
prisma_client.db.litellm_organizationtable.delete.assert_not_called()
@pytest.mark.asyncio
async def test_organization_member_add_reports_an_unknown_org_as_a_request_error_not_an_auth_error(monkeypatch: pytest.MonkeyPatch) -> None:
from fastapi import Request
from litellm.proxy._types import (
LitellmUserRoles,
OrganizationMemberAddRequest,
OrgMember,
ProxyException,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.organization_endpoints import organization_member_add
mock_prisma_client: Final = MagicMock()
mock_prisma_client.db.litellm_organizationtable.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
with pytest.raises(ProxyException) as exc_info:
await organization_member_add(
data=OrganizationMemberAddRequest(
organization_id="no-such-org",
member=OrgMember(role="internal_user", user_id="someone"),
),
http_request=MagicMock(spec=Request),
user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert (exc_info.value.code, exc_info.value.type) == ("404", "invalid_request_error")
assert "Authentication Error" not in exc_info.value.message
@pytest.mark.asyncio
@pytest.mark.parametrize("budget_duration", ["30d", None])
async def test_new_organization_schedules_budget_reset_from_duration(monkeypatch, budget_duration):

View file

@ -19372,3 +19372,48 @@ async def test_default_budget_cache_eviction_does_not_fail_after_redis_error() -
)
user_api_key_cache.async_delete_cache.assert_awaited_once_with(key="team_member_default_budget:budget_456")
@pytest.mark.asyncio
async def test_team_info_reports_a_missing_team_as_a_request_error_not_an_auth_error(monkeypatch: pytest.MonkeyPatch) -> None:
from fastapi import Request
from litellm.proxy._types import ProxyException
from litellm.proxy.management_endpoints import team_endpoints
mock_prisma_client: Final = MagicMock()
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
with pytest.raises(ProxyException) as exc_info:
await team_endpoints.team_info(
http_request=MagicMock(spec=Request),
team_id="no-such-team",
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert (exc_info.value.code, exc_info.value.type) == ("404", "invalid_request_error")
assert "Authentication Error" not in exc_info.value.message
@pytest.mark.asyncio
async def test_team_info_keeps_the_auth_label_for_a_caller_outside_the_team(monkeypatch: pytest.MonkeyPatch) -> None:
from fastapi import Request
from litellm.proxy._types import ProxyException
from litellm.proxy.management_endpoints import team_endpoints
other_team: Final = MagicMock()
other_team.model_dump.return_value = {"team_id": "team-a", "members_with_roles": []}
mock_prisma_client: Final = MagicMock()
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=other_team)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
with pytest.raises(ProxyException) as exc_info:
await team_endpoints.team_info(
http_request=MagicMock(spec=Request),
team_id="team-a",
user_api_key_dict=UserAPIKeyAuth(user_id="outsider", user_role=LitellmUserRoles.INTERNAL_USER),
)
assert (exc_info.value.code, exc_info.value.type) == ("403", "auth_error")

View file

@ -3,6 +3,7 @@ import sys
import traceback
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine, Mapping
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Final, Literal, NoReturn, cast
from unittest import mock
@ -3817,6 +3818,48 @@ def test_live_session_preserves_pretty_printed_start_frame(
assert routed_data["live_session_start"] == expected_start
@pytest.mark.asyncio
async def test_update_config_reports_an_unsupported_router_setting_as_a_request_error_not_an_auth_error() -> None:
from fastapi import Request
from litellm.proxy._types import ConfigYAML, ProxyException
from litellm.proxy.proxy_server import update_config
body: Final = {"router_settings": {"not_a_router_setting": True}}
request: Final = MagicMock(spec=Request)
request.json = AsyncMock(return_value=body)
with pytest.raises(ProxyException) as exc_info:
await update_config(
config_info=ConfigYAML(**body),
request=request,
user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert (exc_info.value.code, exc_info.value.type) == ("400", "invalid_request_error")
assert "Authentication Error" not in exc_info.value.message
@pytest.mark.asyncio
async def test_get_config_callbacks_reports_an_unreadable_config_as_a_server_error_not_an_auth_error(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
from litellm.proxy import proxy_server
from litellm.proxy._types import ProxyException
from litellm.proxy.proxy_server import get_config
empty_config: Final = tmp_path / "config.yaml"
empty_config.write_text("")
monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False)
monkeypatch.setattr(proxy_server, "user_config_file_path", str(empty_config))
with pytest.raises(ProxyException) as exc_info:
await get_config(user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN))
assert (exc_info.value.code, exc_info.value.type) == ("500", "internal_server_error")
assert exc_info.value.message == "Config cannot be None or Empty."
@pytest.mark.parametrize("scope", [None, "expand"])
@pytest.mark.parametrize(
"general_settings, wildcard_query, wildcard_listed",
@ -3861,6 +3904,62 @@ async def test_model_list_return_wildcard_routes_defaults_to_general_setting(
assert ("openai/*" in listed) is wildcard_listed, listed
@pytest.mark.asyncio
async def test_queue_request_labels_an_upstream_failure_a_server_error_not_an_auth_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from starlette.requests import Request
from starlette.responses import Response
from litellm.proxy import proxy_server
from litellm.proxy._types import ProxyException
from litellm.proxy.proxy_server import async_queue_request
body: Final = json.dumps(
{"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "hi"}], "priority": 0}
).encode()
async def receive() -> dict[str, object]:
return {"type": "http.request", "body": body, "more_body": False}
request: Final = Request(
{
"type": "http",
"method": "POST",
"path": "/queue/chat/completions",
"headers": [(b"content-type", b"application/json")],
"query_string": b"",
"scheme": "http",
"server": ("testserver", 80),
"root_path": "",
},
receive,
)
router: Final = litellm.Router(
model_list=[
{
"model_name": "gpt-5.4-mini",
"litellm_params": {
"model": "openai/gpt-5.4-mini",
"api_key": "sk-test",
"mock_response": "litellm.InternalServerError",
},
}
],
num_retries=0,
)
monkeypatch.setattr(proxy_server, "llm_router", router)
with pytest.raises(ProxyException) as exc_info:
await async_queue_request(
request=request,
fastapi_response=Response(),
user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert (exc_info.value.code, exc_info.value.type) == ("500", "internal_server_error")
_DEBUG_LOG_SENTINEL: Final = "sentinel-request-body-7f3a"
_LONG_PROMPT: Final = f"{_DEBUG_LOG_SENTINEL} " + "Keep edits minimal and explain them briefly. " * 20
_TOOL_PARAMETERS: Final = {"type": "object", "properties": {"cmd": {"type": "string"}}}