mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
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:
parent
a11e76fe8f
commit
cbf4e40d38
21 changed files with 808 additions and 220 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"}}}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue