mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
Merge pull request #39536 from BerriAI/litellm_openai_error_payload_non_llm_routes
fix(proxy): stop shipping the literal string "None" as error type and param
This commit is contained in:
commit
2b9a69d783
17 changed files with 747 additions and 167 deletions
|
|
@ -25,6 +25,11 @@ from litellm.proxy.common_request_processing import (
|
|||
proxy_exception_from_http_exception,
|
||||
)
|
||||
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
)
|
||||
from litellm.types.utils import TokenCountResponse
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
|
@ -243,9 +248,9 @@ async def anthropic_response(
|
|||
return _anthropic_error_json_response(
|
||||
ProxyException(
|
||||
message=getattr(e, "message", error_msg),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
headers=headers,
|
||||
),
|
||||
request,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ from litellm.proxy.common_utils.callback_utils import (
|
|||
get_logging_caching_headers,
|
||||
get_remaining_tokens_and_requests_from_request_data,
|
||||
)
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
attribute_of,
|
||||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
)
|
||||
from litellm.proxy.common_utils.sse_keepalive import (
|
||||
SSE_COMMENT_PING_BYTES,
|
||||
coerce_keepalive_interval,
|
||||
|
|
@ -464,46 +470,6 @@ def _stream_usage_tracking_updates(
|
|||
}
|
||||
|
||||
|
||||
def _getattr_object(value: object, name: str, default: object = None) -> object:
|
||||
return getattr(value, name, default)
|
||||
|
||||
|
||||
_OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType(
|
||||
{
|
||||
status.HTTP_401_UNAUTHORIZED: "authentication_error",
|
||||
status.HTTP_403_FORBIDDEN: "permission_error",
|
||||
status.HTTP_429_TOO_MANY_REQUESTS: "rate_limit_error",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _error_status_code(exc: object, default: int) -> int:
|
||||
"""The HTTP status an exception carries, or ``default`` when it carries none."""
|
||||
carried: Final = _getattr_object(exc, "status_code")
|
||||
return carried if isinstance(carried, int) and not isinstance(carried, bool) else default
|
||||
|
||||
|
||||
def _openai_error_type(exc: object, status_code: int) -> str:
|
||||
"""OpenAI types ``error.type`` as a required string, so an exception carrying none
|
||||
falls back to the type its status code stands for."""
|
||||
carried: Final = _getattr_object(exc, "type")
|
||||
if isinstance(carried, str):
|
||||
return carried
|
||||
mapped: Final = _OPENAI_ERROR_TYPE_BY_STATUS.get(status_code)
|
||||
if mapped is not None:
|
||||
return mapped
|
||||
if status_code < status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||
return "invalid_request_error"
|
||||
return "internal_server_error"
|
||||
|
||||
|
||||
def _openai_error_param(exc: object) -> str | None:
|
||||
"""OpenAI types ``error.param`` as nullable, so an exception carrying none
|
||||
serializes as JSON ``null``."""
|
||||
carried: Final = _getattr_object(exc, "param")
|
||||
return carried if isinstance(carried, str) else None
|
||||
|
||||
|
||||
class _UpstreamHttpResponse(Protocol):
|
||||
@property
|
||||
def status_code(self) -> int: ...
|
||||
|
|
@ -573,15 +539,15 @@ def serialize_http_exception_detail(
|
|||
|
||||
|
||||
def proxy_exception_from_http_exception(exc: HTTPException, headers: dict[str, str]) -> ProxyException:
|
||||
raw_detail: Final = _getattr_object(exc, "detail", str(exc))
|
||||
raw_detail: Final = attribute_of(exc, "detail", str(exc))
|
||||
message, structured_fields = serialize_http_exception_detail(raw_detail)
|
||||
existing_fields: Final = getattr(exc, "provider_specific_fields", None) or {}
|
||||
merged_fields: Final = {**existing_fields, **structured_fields} if structured_fields else (existing_fields or None)
|
||||
error_status: Final = _error_status_code(exc, status.HTTP_400_BAD_REQUEST)
|
||||
error_status: Final = error_status_code(exc, status.HTTP_400_BAD_REQUEST)
|
||||
return ProxyException(
|
||||
message=message,
|
||||
type=_openai_error_type(exc, error_status),
|
||||
param=_openai_error_param(exc),
|
||||
type=openai_error_type(exc, error_status),
|
||||
param=openai_error_param(exc),
|
||||
code=error_status,
|
||||
provider_specific_fields=merged_fields,
|
||||
headers=headers,
|
||||
|
|
@ -865,8 +831,8 @@ def sse_error_payload(exc: BaseException) -> tuple[int, Mapping[str, object]]:
|
|||
are byte-identical.
|
||||
"""
|
||||
# Preserve status code from HTTPException (e.g. guardrail blocks)
|
||||
error_status: Final = _error_status_code(exc, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
raw_detail: Final = _getattr_object(exc, "detail", "Error processing stream start")
|
||||
error_status: Final = error_status_code(exc, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
raw_detail: Final = attribute_of(exc, "detail", "Error processing stream start")
|
||||
message, structured_fields = serialize_http_exception_detail(raw_detail)
|
||||
|
||||
existing_fields: Final = getattr(exc, "provider_specific_fields", None) or {}
|
||||
|
|
@ -874,8 +840,8 @@ def sse_error_payload(exc: BaseException) -> tuple[int, Mapping[str, object]]:
|
|||
|
||||
error_obj: Final = {
|
||||
"message": message,
|
||||
"type": _openai_error_type(exc, error_status),
|
||||
"param": _openai_error_param(exc),
|
||||
"type": openai_error_type(exc, error_status),
|
||||
"param": openai_error_param(exc),
|
||||
"code": str(error_status),
|
||||
}
|
||||
if not merged_fields:
|
||||
|
|
@ -2815,10 +2781,10 @@ class ProxyBaseLLMRequestProcessing:
|
|||
``ResponsesAPIResponse`` directly. Handle both shapes so the
|
||||
container-ownership recording path can walk ``.output`` either way.
|
||||
"""
|
||||
completed: Final = _getattr_object(stream_response, "completed_response")
|
||||
completed: Final = attribute_of(stream_response, "completed_response")
|
||||
if completed is None:
|
||||
return None
|
||||
response_obj: Final = _getattr_object(completed, "response")
|
||||
response_obj: Final = attribute_of(completed, "response")
|
||||
if response_obj is not None:
|
||||
return response_obj
|
||||
return completed
|
||||
|
|
@ -3468,7 +3434,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
headers = getattr(e, "headers", None) or {}
|
||||
if not headers:
|
||||
# Try to get headers from e.response.headers (httpx.Response)
|
||||
_response: Final = _getattr_object(e, "response")
|
||||
_response: Final = attribute_of(e, "response")
|
||||
if _response is not None:
|
||||
_response_headers: Final = getattr(_response, "headers", None)
|
||||
if _response_headers:
|
||||
|
|
@ -3543,8 +3509,8 @@ class ProxyBaseLLMRequestProcessing:
|
|||
_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
raise ProxyException(
|
||||
message=redact_internal_details_from_client_message(getattr(e, "message", error_msg)),
|
||||
type=_openai_error_type(e, _code),
|
||||
param=_openai_error_param(e),
|
||||
type=openai_error_type(e, _code),
|
||||
param=openai_error_param(e),
|
||||
openai_code=getattr(e, "code", None),
|
||||
code=_code,
|
||||
provider_specific_fields=getattr(e, "provider_specific_fields", None),
|
||||
|
|
@ -3754,11 +3720,11 @@ class ProxyBaseLLMRequestProcessing:
|
|||
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
stream_error_status: Final = _error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
stream_error_status: Final = error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
proxy_exception: Final = ProxyException(
|
||||
message=redact_internal_details_from_client_message(getattr(e, "message", str(e))),
|
||||
type=_openai_error_type(e, stream_error_status),
|
||||
param=_openai_error_param(e),
|
||||
type=openai_error_type(e, stream_error_status),
|
||||
param=openai_error_param(e),
|
||||
code=stream_error_status,
|
||||
)
|
||||
stream_completed = True
|
||||
|
|
|
|||
52
litellm/proxy/common_utils/openai_error_payload.py
Normal file
52
litellm/proxy/common_utils/openai_error_payload.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Shapes the ``error`` object the proxy answers with so it matches OpenAI's contract:
|
||||
``type`` is a required string and ``param`` is nullable, neither of which the literal
|
||||
string ``"None"`` satisfies."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from fastapi import status
|
||||
|
||||
_OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType(
|
||||
{
|
||||
status.HTTP_401_UNAUTHORIZED: "authentication_error",
|
||||
status.HTTP_403_FORBIDDEN: "permission_error",
|
||||
status.HTTP_429_TOO_MANY_REQUESTS: "rate_limit_error",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def attribute_of(value: object, name: str, default: object = None) -> object:
|
||||
return getattr(value, name, default)
|
||||
|
||||
|
||||
def error_status_code(exc: object, default: int) -> int:
|
||||
"""The HTTP status an exception carries as ``status_code`` or, the way ``ProxyException``
|
||||
stores it, as a stringified ``code``; ``default`` when it carries neither."""
|
||||
carried: Final = attribute_of(exc, "status_code")
|
||||
if isinstance(carried, int) and not isinstance(carried, bool):
|
||||
return carried
|
||||
stringified: Final = attribute_of(exc, "code")
|
||||
return int(stringified) if isinstance(stringified, str) and stringified.isdecimal() else default
|
||||
|
||||
|
||||
def openai_error_type(exc: object, status_code: int) -> str:
|
||||
"""OpenAI types ``error.type`` as a required string, so an exception carrying none
|
||||
falls back to the type its status code stands for."""
|
||||
carried: Final = attribute_of(exc, "type")
|
||||
if isinstance(carried, str):
|
||||
return carried
|
||||
mapped: Final = _OPENAI_ERROR_TYPE_BY_STATUS.get(status_code)
|
||||
if mapped is not None:
|
||||
return mapped
|
||||
if status_code < status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||
return "invalid_request_error"
|
||||
return "internal_server_error"
|
||||
|
||||
|
||||
def openai_error_param(exc: object) -> str | None:
|
||||
"""OpenAI types ``error.param`` as nullable, so an exception carrying none
|
||||
serializes as JSON ``null``."""
|
||||
carried: Final = attribute_of(exc, "param")
|
||||
return carried if isinstance(carried, str) else None
|
||||
|
|
@ -20,6 +20,11 @@ from litellm.proxy.common_utils.http_parsing_utils import (
|
|||
coerce_numeric_form_fields,
|
||||
numeric_form_fields,
|
||||
)
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
)
|
||||
from litellm.proxy.route_llm_request import route_request
|
||||
from litellm.types.images.main import ImageEditRequestParams
|
||||
from litellm.types.llms.openai import ChatCompletionUserMessage
|
||||
|
|
@ -200,18 +205,18 @@ async def image_generation(
|
|||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
|
||||
type=openai_error_type(e, error_status_code(e, status.HTTP_400_BAD_REQUEST)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, status.HTTP_400_BAD_REQUEST),
|
||||
)
|
||||
else:
|
||||
error_msg: Final = f"{e}"
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", error_msg),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
openai_code=getattr(e, "code", None),
|
||||
code=getattr(e, "status_code", 500),
|
||||
code=error_status_code(e, 500),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ from litellm.proxy.common_utils.openai_endpoint_utils import (
|
|||
get_custom_llm_provider_from_request_headers,
|
||||
get_custom_llm_provider_from_request_query,
|
||||
)
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
)
|
||||
from litellm.proxy.openai_files_endpoints.batch_file_validation import (
|
||||
check_batch_file_upload,
|
||||
raise_batch_file_validation_failure,
|
||||
|
|
@ -296,22 +301,22 @@ async def route_create_file(
|
|||
if managed_files_obj is None:
|
||||
raise ProxyException(
|
||||
message="Managed files hook not found",
|
||||
type="None",
|
||||
param="None",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param=None,
|
||||
code=500,
|
||||
)
|
||||
if llm_router is None:
|
||||
raise ProxyException(
|
||||
message="LLM Router not found",
|
||||
type="None",
|
||||
param="None",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param=None,
|
||||
code=500,
|
||||
)
|
||||
if not isinstance(managed_files_obj, BaseFileEndpoints):
|
||||
raise ProxyException(
|
||||
message="Managed files hook is not a BaseFileEndpoints",
|
||||
type="None",
|
||||
param="None",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param=None,
|
||||
code=500,
|
||||
)
|
||||
# Managed files internally calls llm_router.acreate_file() which includes loadbalancing
|
||||
|
|
@ -713,17 +718,17 @@ async def create_file(
|
|||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e.detail)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
|
||||
type=openai_error_type(e, error_status_code(e, status.HTTP_400_BAD_REQUEST)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, status.HTTP_400_BAD_REQUEST),
|
||||
)
|
||||
else:
|
||||
error_msg: Final = f"{e}"
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", error_msg),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
)
|
||||
finally:
|
||||
for spool in spools:
|
||||
|
|
@ -812,22 +817,22 @@ async def get_file_content(
|
|||
if managed_files_obj is None:
|
||||
raise ProxyException(
|
||||
message="Managed files hook not found",
|
||||
type="None",
|
||||
param="None",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param=None,
|
||||
code=500,
|
||||
)
|
||||
if llm_router is None:
|
||||
raise ProxyException(
|
||||
message="LLM Router not found",
|
||||
type="None",
|
||||
param="None",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param=None,
|
||||
code=500,
|
||||
)
|
||||
if not isinstance(managed_files_obj, BaseFileEndpoints):
|
||||
raise ProxyException(
|
||||
message="Managed files hook is not a BaseFileEndpoints",
|
||||
type="None",
|
||||
param="None",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param=None,
|
||||
code=500,
|
||||
)
|
||||
|
||||
|
|
@ -1021,17 +1026,17 @@ async def get_file_content(
|
|||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e.detail)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
|
||||
type=openai_error_type(e, error_status_code(e, status.HTTP_400_BAD_REQUEST)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, status.HTTP_400_BAD_REQUEST),
|
||||
)
|
||||
else:
|
||||
error_msg: Final = f"{e}"
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", error_msg),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1151,15 +1156,15 @@ async def get_file(
|
|||
if managed_files_obj is None:
|
||||
raise ProxyException(
|
||||
message="Managed files hook not found",
|
||||
type="None",
|
||||
param="None",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param=None,
|
||||
code=500,
|
||||
)
|
||||
if not isinstance(managed_files_obj, BaseFileEndpoints):
|
||||
raise ProxyException(
|
||||
message="Managed files hook is not a BaseFileEndpoints",
|
||||
type="None",
|
||||
param="None",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param=None,
|
||||
code=500,
|
||||
)
|
||||
response = await managed_files_obj.afile_retrieve(
|
||||
|
|
@ -1215,17 +1220,17 @@ async def get_file(
|
|||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e.detail)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
|
||||
type=openai_error_type(e, error_status_code(e, status.HTTP_400_BAD_REQUEST)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, status.HTTP_400_BAD_REQUEST),
|
||||
)
|
||||
else:
|
||||
error_msg: Final = f"{e}"
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", error_msg),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1355,22 +1360,22 @@ async def delete_file(
|
|||
if managed_files_obj is None:
|
||||
raise ProxyException(
|
||||
message="Managed files hook not found",
|
||||
type="None",
|
||||
param="None",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param=None,
|
||||
code=500,
|
||||
)
|
||||
if llm_router is None:
|
||||
raise ProxyException(
|
||||
message="LLM Router not found",
|
||||
type="None",
|
||||
param="None",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param=None,
|
||||
code=500,
|
||||
)
|
||||
if not isinstance(managed_files_obj, BaseFileEndpoints):
|
||||
raise ProxyException(
|
||||
message="Managed files hook is not a BaseFileEndpoints",
|
||||
type="None",
|
||||
param="None",
|
||||
type=ProxyErrorTypes.internal_server_error.value,
|
||||
param=None,
|
||||
code=500,
|
||||
)
|
||||
|
||||
|
|
@ -1427,17 +1432,17 @@ async def delete_file(
|
|||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e.detail)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
|
||||
type=openai_error_type(e, error_status_code(e, status.HTTP_400_BAD_REQUEST)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, status.HTTP_400_BAD_REQUEST),
|
||||
)
|
||||
else:
|
||||
error_msg: Final = f"{e}"
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", error_msg),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1629,15 +1634,15 @@ async def list_files(
|
|||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e.detail)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
|
||||
type=openai_error_type(e, error_status_code(e, status.HTTP_400_BAD_REQUEST)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, status.HTTP_400_BAD_REQUEST),
|
||||
)
|
||||
else:
|
||||
error_msg: Final = f"{e}"
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", error_msg),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -78,6 +78,11 @@ from litellm.proxy.common_utils.http_parsing_utils import (
|
|||
_read_request_body,
|
||||
_safe_get_request_headers,
|
||||
)
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
)
|
||||
from litellm.proxy.common_utils.sse_keepalive import (
|
||||
wrap_passthrough_sse_bytes_with_keepalive_pings,
|
||||
)
|
||||
|
|
@ -311,9 +316,9 @@ async def chat_completion_pass_through_endpoint(
|
|||
error_msg: Final = f"{e}"
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", error_msg),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1728,18 +1733,18 @@ async def pass_through_request(
|
|||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(getattr(e, "detail", str(e)))),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
|
||||
type=openai_error_type(e, error_status_code(e, status.HTTP_400_BAD_REQUEST)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, status.HTTP_400_BAD_REQUEST),
|
||||
headers=custom_headers,
|
||||
)
|
||||
else:
|
||||
error_msg: Final = f"{e}"
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", error_msg),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
headers=custom_headers,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
|||
encrypt_value_helper,
|
||||
)
|
||||
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
)
|
||||
from litellm.types.realtime import (
|
||||
RealtimeClientSecretRequest,
|
||||
RealtimeClientSecretResponse,
|
||||
|
|
@ -304,15 +309,15 @@ async def create_realtime_client_secret(
|
|||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", http_status.HTTP_400_BAD_REQUEST),
|
||||
type=openai_error_type(e, error_status_code(e, http_status.HTTP_400_BAD_REQUEST)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, http_status.HTTP_400_BAD_REQUEST),
|
||||
)
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
)
|
||||
|
||||
if upstream_resp.status_code != 200:
|
||||
|
|
@ -495,15 +500,15 @@ async def proxy_realtime_calls(
|
|||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", http_status.HTTP_400_BAD_REQUEST),
|
||||
type=openai_error_type(e, error_status_code(e, http_status.HTTP_400_BAD_REQUEST)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, http_status.HTTP_400_BAD_REQUEST),
|
||||
)
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
)
|
||||
|
||||
return Response(
|
||||
|
|
@ -608,15 +613,15 @@ async def create_realtime_transcription_session(
|
|||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "detail", getattr(e, "message", str(e))),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", http_status.HTTP_400_BAD_REQUEST),
|
||||
type=openai_error_type(e, error_status_code(e, http_status.HTTP_400_BAD_REQUEST)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, http_status.HTTP_400_BAD_REQUEST),
|
||||
)
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
)
|
||||
|
||||
if upstream_resp.status_code != 200:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ from litellm._logging import verbose_proxy_logger
|
|||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
)
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
||||
|
|
@ -112,15 +117,15 @@ async def rerank(
|
|||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e)),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
|
||||
type=openai_error_type(e, error_status_code(e, status.HTTP_400_BAD_REQUEST)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, status.HTTP_400_BAD_REQUEST),
|
||||
)
|
||||
else:
|
||||
error_msg: Final = f"{e}"
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", error_msg),
|
||||
type=getattr(e, "type", "None"),
|
||||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
type=openai_error_type(e, error_status_code(e, 500)),
|
||||
param=openai_error_param(e),
|
||||
code=error_status_code(e, 500),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from litellm.proxy._types import (
|
|||
SpendLogsMetadata,
|
||||
SpendLogsPayload,
|
||||
)
|
||||
from litellm.proxy.common_utils.openai_error_payload import openai_error_param
|
||||
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.proxy.model_listing import ModelInfoResponse
|
||||
|
|
@ -7409,7 +7410,7 @@ def handle_exception_on_proxy(e: Exception) -> ProxyException:
|
|||
return ProxyException(
|
||||
message=getattr(e, "detail", f"error({e})"),
|
||||
type=ProxyErrorTypes.internal_server_error,
|
||||
param=getattr(e, "param", "None"),
|
||||
param=openai_error_param(e),
|
||||
code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR),
|
||||
)
|
||||
elif isinstance(e, ProxyException):
|
||||
|
|
@ -7418,7 +7419,7 @@ def handle_exception_on_proxy(e: Exception) -> ProxyException:
|
|||
return ProxyException(
|
||||
message=str(e),
|
||||
type=ProxyErrorTypes.internal_server_error,
|
||||
param=getattr(e, "param", "None"),
|
||||
param=openai_error_param(e),
|
||||
code=_status_code,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected_type",
|
||||
[
|
||||
(400, "invalid_request_error"),
|
||||
(401, "authentication_error"),
|
||||
(403, "permission_error"),
|
||||
(404, "invalid_request_error"),
|
||||
(408, "invalid_request_error"),
|
||||
(422, "invalid_request_error"),
|
||||
(429, "rate_limit_error"),
|
||||
(499, "invalid_request_error"),
|
||||
(500, "internal_server_error"),
|
||||
(502, "internal_server_error"),
|
||||
(503, "internal_server_error"),
|
||||
],
|
||||
)
|
||||
def test_status_code_decides_the_type_when_the_exception_carries_none(status_code: int, expected_type: str):
|
||||
"""A route that raises a bare HTTPException carries no error type, so the status it
|
||||
answered with is the only thing left to name the OpenAI type from."""
|
||||
assert openai_error_type(HTTPException(status_code=status_code, detail="boom"), status_code) == expected_type
|
||||
|
||||
|
||||
def test_a_carried_type_wins_over_the_one_the_status_would_imply():
|
||||
"""A ProxyException raised mid-request already names its own type, and relabelling a
|
||||
402 budget_exceeded as the status map's guess would lose what the client branches on."""
|
||||
carried = ProxyException(
|
||||
message="Budget has been exceeded",
|
||||
type=ProxyErrorTypes.budget_exceeded.value,
|
||||
param=None,
|
||||
code=400,
|
||||
)
|
||||
|
||||
assert openai_error_type(carried, 400) == ProxyErrorTypes.budget_exceeded.value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("carried_type", [None, 400, {"type": "invalid_request_error"}, ["invalid_request_error"]])
|
||||
def test_a_non_string_carried_type_falls_back_to_the_status(carried_type: object):
|
||||
"""OpenAI types error.type as a string, so anything else on the exception is not one and
|
||||
must not reach the wire the way the literal "None" used to."""
|
||||
|
||||
class _Carrier(Exception):
|
||||
type = carried_type
|
||||
|
||||
assert openai_error_type(_Carrier("boom"), 401) == "authentication_error"
|
||||
|
||||
|
||||
def test_the_type_is_never_the_string_none_after_a_json_round_trip():
|
||||
"""The bug this module exists for: json.dumps of a "None" default is indistinguishable
|
||||
from a real type to a client's error handler."""
|
||||
payload = json.loads(
|
||||
json.dumps(
|
||||
{
|
||||
"type": openai_error_type(HTTPException(status_code=400, detail="boom"), 400),
|
||||
"param": openai_error_param(HTTPException(status_code=400, detail="boom")),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert payload == {"type": "invalid_request_error", "param": None}
|
||||
|
||||
|
||||
def test_a_carried_param_names_the_offending_field():
|
||||
carried = ProxyException(message="Invalid purpose", type="invalid_request_error", param="purpose", code=400)
|
||||
|
||||
assert openai_error_param(carried) == "purpose"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc", [HTTPException(status_code=400, detail="boom"), ValueError("boom"), None])
|
||||
def test_param_is_json_null_when_the_exception_names_no_field(exc: Exception | None):
|
||||
assert openai_error_param(exc) is None
|
||||
|
||||
|
||||
def test_a_non_string_carried_param_is_json_null():
|
||||
class _Carrier(Exception):
|
||||
param = 42
|
||||
|
||||
assert openai_error_param(_Carrier("boom")) is None
|
||||
|
||||
|
||||
def test_a_carried_status_code_wins_over_the_default():
|
||||
assert error_status_code(HTTPException(status_code=429, detail="slow down"), 400) == 429
|
||||
|
||||
|
||||
@pytest.mark.parametrize("default", [400, 500])
|
||||
def test_the_default_status_stands_when_the_exception_carries_none(default: int):
|
||||
assert error_status_code(ValueError("boom"), default) == default
|
||||
|
||||
|
||||
@pytest.mark.parametrize("carried_status", [True, False, "429", None, 429.0])
|
||||
def test_a_non_int_carried_status_falls_back_to_the_default(carried_status: object):
|
||||
"""True is an int in Python but not an HTTP status, and a stringified one would break
|
||||
every caller that compares the code numerically."""
|
||||
|
||||
class _Carrier(Exception):
|
||||
status_code = carried_status
|
||||
|
||||
assert error_status_code(_Carrier("boom"), 500) == 500
|
||||
|
||||
|
||||
def test_a_proxy_exception_keeps_the_status_it_was_raised_with():
|
||||
"""ProxyException stores its status as the string ``code`` rather than ``status_code``,
|
||||
so a route tail that rewraps one used to answer a 4xx rejection as a 500."""
|
||||
rejection = ProxyException(message="session_id is required", type="bad_request_error", param="session_id", code=400)
|
||||
|
||||
assert error_status_code(rejection, 500) == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("carried_code", [None, "None", "", "rate_limited", "4xx", 404])
|
||||
def test_a_code_that_is_not_a_decimal_string_falls_back_to_the_default(carried_code: object):
|
||||
"""Only ProxyException's stringified status is a status; ``code`` on anything else
|
||||
(OpenAI's ``invalid_api_key``, a stray int) says nothing about the HTTP answer."""
|
||||
|
||||
class _Carrier(Exception):
|
||||
code = carried_code
|
||||
|
||||
assert error_status_code(_Carrier("boom"), 500) == 500
|
||||
|
||||
|
||||
def test_a_status_code_wins_over_a_stringified_code():
|
||||
class _Carrier(Exception):
|
||||
status_code = 429
|
||||
code = "400"
|
||||
|
||||
assert error_status_code(_Carrier("boom"), 500) == 429
|
||||
|
||||
|
||||
def test_a_status_carried_by_an_exception_drives_the_type_it_reports():
|
||||
"""The two helpers compose at every call site: the status the exception carries is what
|
||||
names its type, not the default the route would have used."""
|
||||
exc = HTTPException(status_code=403, detail="blocked by policy")
|
||||
|
||||
assert openai_error_type(exc, error_status_code(exc, 400)) == "permission_error"
|
||||
|
|
@ -5,12 +5,12 @@ from typing import Any, Dict
|
|||
|
||||
import orjson
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.image_endpoints import endpoints
|
||||
|
||||
|
|
@ -167,3 +167,47 @@ def test_image_edit_multipart_n_that_is_not_a_number_is_left_alone(monkeypatch):
|
|||
|
||||
assert response.status_code == 200
|
||||
assert captured["n"] == "two"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_model_the_router_cannot_serve_answers_an_openai_typed_error(monkeypatch: pytest.MonkeyPatch):
|
||||
"""A bare HTTPException carries no type or param, so the tail used to ship the
|
||||
literal string "None" in both fields."""
|
||||
|
||||
async def fake_add_litellm_data_to_request(**kwargs: object) -> object:
|
||||
return kwargs["data"]
|
||||
|
||||
async def fake_pre_call_hook(*, user_api_key_dict: UserAPIKeyAuth, data: dict[str, object], call_type: str) -> dict[str, object]:
|
||||
return data
|
||||
|
||||
async def fake_post_call_failure_hook(**_: object) -> None:
|
||||
return None
|
||||
|
||||
async def failing_route_request(**_: object) -> None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail={"error": "image_generation: Invalid model name passed in model=dall-e-3"}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.add_litellm_data_to_request", fake_add_litellm_data_to_request)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", {})
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.proxy_logging_obj",
|
||||
SimpleNamespace(pre_call_hook=fake_pre_call_hook, post_call_failure_hook=fake_post_call_failure_hook),
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.version", "test-version")
|
||||
monkeypatch.setattr("litellm.proxy.image_endpoints.endpoints.route_request", failing_route_request)
|
||||
|
||||
body = orjson.dumps({"model": "dall-e-3", "prompt": "a lighthouse at dusk"})
|
||||
|
||||
async def receive() -> dict[str, object]:
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
request = Request({"type": "http", "method": "POST", "path": "/v1/images/generations", "headers": []}, receive)
|
||||
|
||||
with pytest.raises(ProxyException) as raised:
|
||||
await endpoints.image_generation(request=request, fastapi_response=Response(), user_api_key_dict=UserAPIKeyAuth())
|
||||
|
||||
assert (raised.value.type, raised.value.param, raised.value.code) == ("invalid_request_error", None, "404")
|
||||
|
|
|
|||
|
|
@ -2920,9 +2920,9 @@ def test_unscoped_list_files_accepts_every_documented_purpose(
|
|||
def test_list_files_reports_a_bad_target_model_names_as_a_400(
|
||||
mocker: MockerFixture, monkeypatch, llm_router: Router
|
||||
):
|
||||
"""The exception tail reports an HTTPException with its own status and error
|
||||
type rather than relabelling it, so a client that branches on either keeps
|
||||
reading the same thing off a bad request."""
|
||||
"""The exception tail answers with the OpenAI error object a client can branch on:
|
||||
the type its 400 status stands for, and a JSON null param rather than the literal
|
||||
string "None" no OpenAI SDK has a case for."""
|
||||
_setup_unscoped_list_files_route(mocker, monkeypatch, llm_router, _permissive_afile_list)
|
||||
|
||||
response = _get_list_files("/v1/files?target_model_names=gpt-3.5-turbo,gpt-4o")
|
||||
|
|
@ -2931,8 +2931,8 @@ def test_list_files_reports_a_bad_target_model_names_as_a_400(
|
|||
assert response.json() == {
|
||||
"error": {
|
||||
"message": "target_model_names on list files must be a list of one model name. Example: ['gpt-4o']",
|
||||
"type": "None",
|
||||
"param": "None",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "400",
|
||||
}
|
||||
}
|
||||
|
|
@ -4666,3 +4666,156 @@ def test_create_file_path_traversal_filename_rejected_before_forwarding(monkeypa
|
|||
assert error["param"] == "file"
|
||||
assert "traversal" in error["message"].lower()
|
||||
assert forwarded_calls == []
|
||||
|
||||
|
||||
def _setup_managed_file_route_answering_404(
|
||||
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router
|
||||
) -> None:
|
||||
"""Wire the single-file routes to a managed file store that knows no file, the way the
|
||||
managed files hook answers once a file has been deleted or was never the caller's."""
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from fastapi import HTTPException
|
||||
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
async def _file_not_found(file_id: str, **kwargs: object) -> None:
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {file_id}")
|
||||
|
||||
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router)
|
||||
managed_files = mocker.MagicMock(spec=BaseFileEndpoints)
|
||||
managed_files.afile_retrieve = mocker.AsyncMock(side_effect=_file_not_found)
|
||||
managed_files.afile_delete = mocker.AsyncMock(side_effect=_file_not_found)
|
||||
managed_files.afile_content = mocker.AsyncMock(side_effect=_file_not_found)
|
||||
proxy_logging_obj.proxy_hook_mapping["managed_files"] = managed_files
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router)
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id="test-user",
|
||||
)
|
||||
|
||||
|
||||
def _call_managed_file_route(method: str, path: str) -> httpx.Response:
|
||||
try:
|
||||
return client.request(method, path, headers={"Authorization": "Bearer test-key"})
|
||||
finally:
|
||||
import litellm.proxy.proxy_server as ps
|
||||
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
def _missing_managed_file_error(file_id: str) -> dict[str, dict[str, str | None]]:
|
||||
return {
|
||||
"error": {
|
||||
"message": f"File not found: {file_id}",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "404",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_create_file_reports_a_half_specified_expires_after_as_a_400(
|
||||
monkeypatch: pytest.MonkeyPatch, llm_router: Router
|
||||
):
|
||||
"""A 400 raised inside the route answers with the type a 400 stands for and a JSON null
|
||||
param, not the literal string "None" in both fields, so a client can classify it."""
|
||||
setup_proxy_logging_object(monkeypatch, llm_router)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router)
|
||||
|
||||
response = client.post(
|
||||
"/v1/files",
|
||||
files={"file": ("mydata.jsonl", VALID_BATCH_LINE, "application/jsonl")},
|
||||
data={"purpose": "batch", "target_model_names": "gpt-3.5-turbo", "expires_after[anchor]": "created_at"},
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
error = response.json()["error"]
|
||||
assert "expires_after[seconds]" in error["message"]
|
||||
assert error["type"] == "invalid_request_error"
|
||||
assert error["param"] is None
|
||||
assert error["code"] == "400"
|
||||
|
||||
|
||||
def test_get_file_reports_a_missing_managed_file_as_a_404(
|
||||
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router
|
||||
):
|
||||
_setup_managed_file_route_answering_404(mocker, monkeypatch, llm_router)
|
||||
file_id = _unified_managed_file_id()
|
||||
|
||||
response = _call_managed_file_route("GET", f"/v1/files/{file_id}")
|
||||
|
||||
assert response.status_code == 404, response.text
|
||||
assert response.json() == _missing_managed_file_error(file_id)
|
||||
|
||||
|
||||
def test_delete_file_reports_a_missing_managed_file_as_a_404(
|
||||
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router
|
||||
):
|
||||
_setup_managed_file_route_answering_404(mocker, monkeypatch, llm_router)
|
||||
file_id = _unified_managed_file_id()
|
||||
|
||||
response = _call_managed_file_route("DELETE", f"/v1/files/{file_id}")
|
||||
|
||||
assert response.status_code == 404, response.text
|
||||
assert response.json() == _missing_managed_file_error(file_id)
|
||||
|
||||
|
||||
def test_get_file_content_reports_a_missing_managed_file_as_a_404(
|
||||
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router
|
||||
):
|
||||
_setup_managed_file_route_answering_404(mocker, monkeypatch, llm_router)
|
||||
file_id = _unified_managed_file_id()
|
||||
|
||||
response = _call_managed_file_route("GET", f"/v1/files/{file_id}/content")
|
||||
|
||||
assert response.status_code == 404, response.text
|
||||
assert response.json() == _missing_managed_file_error(file_id)
|
||||
|
||||
|
||||
def _setup_managed_file_stored_in_an_unknown_storage_backend(
|
||||
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router
|
||||
) -> None:
|
||||
"""Wire the content route to a managed file whose row names a storage backend the
|
||||
factory does not know, which is the one in-route ProxyException on these routes."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, llm_router)
|
||||
managed_files = mocker.MagicMock(spec=BaseFileEndpoints)
|
||||
managed_files.prisma_client = mocker.MagicMock()
|
||||
proxy_logging_obj.proxy_hook_mapping["managed_files"] = managed_files
|
||||
repository = mocker.MagicMock()
|
||||
repository.table.find_first = mocker.AsyncMock(
|
||||
return_value=SimpleNamespace(storage_backend="ftp", storage_url="ftp://bucket/file")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.openai_files_endpoints.files_endpoints.ManagedFileRepository", lambda _prisma: repository
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router)
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id="test-user",
|
||||
)
|
||||
|
||||
|
||||
def test_get_file_content_keeps_the_status_of_a_rejection_raised_inside_the_route(
|
||||
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, llm_router: Router
|
||||
):
|
||||
"""A ProxyException raised inside the route carries its status as the string ``code``,
|
||||
and the tail used to rebuild it as a 500 because it only read ``status_code``."""
|
||||
_setup_managed_file_stored_in_an_unknown_storage_backend(mocker, monkeypatch, llm_router)
|
||||
|
||||
response = _call_managed_file_route("GET", f"/v1/files/{_unified_managed_file_id()}/content")
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
error = response.json()["error"]
|
||||
assert error["message"].startswith("Storage backend error")
|
||||
assert (error["type"], error["param"], error["code"]) == ("invalid_request_error", "file_id", "400")
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import Request, UploadFile
|
||||
from fastapi import Request, Response, UploadFile
|
||||
from starlette.datastructures import FormData, Headers, QueryParams
|
||||
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||
|
||||
|
|
@ -22,6 +22,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
|||
InitPassThroughEndpointHelpers,
|
||||
LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY,
|
||||
_registered_pass_through_routes,
|
||||
chat_completion_pass_through_endpoint,
|
||||
create_pass_through_route,
|
||||
initialize_pass_through_endpoints,
|
||||
pass_through_request,
|
||||
|
|
@ -5837,3 +5838,38 @@ def test_passthrough_client_cannot_forge_session_id_omission(client_metadata_key
|
|||
)
|
||||
== "per-call-random-trace-id"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_completion_pass_through_endpoint_answers_an_openai_typed_error_for_an_unknown_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""A bare HTTPException carries no type or param, so the tail used to ship the
|
||||
literal string "None" in both fields."""
|
||||
proxy_logging = MagicMock()
|
||||
proxy_logging.pre_call_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["data"])
|
||||
proxy_logging.post_call_failure_hook = AsyncMock()
|
||||
|
||||
async def fake_add_litellm_data_to_request(**kwargs: object) -> object:
|
||||
return kwargs["data"]
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.add_litellm_data_to_request", fake_add_litellm_data_to_request)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.body = AsyncMock(
|
||||
return_value=json.dumps({"model": "unknown-model", "messages": [{"role": "user", "content": "hi"}]}).encode()
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException) as raised:
|
||||
await chat_completion_pass_through_endpoint(
|
||||
fastapi_response=Response(),
|
||||
request=request,
|
||||
adapter_id="anthropic",
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
|
||||
)
|
||||
|
||||
assert (raised.value.type, raised.value.param, raised.value.code) == ("invalid_request_error", None, "400")
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ Tests for LiteLLM proxy realtime WebRTC HTTP endpoints:
|
|||
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Awaitable
|
||||
from typing import Protocol
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
|
|
@ -159,17 +162,27 @@ def mock_route_request_realtime_calls():
|
|||
return _mock_route
|
||||
|
||||
|
||||
class AddLitellmDataToRequest(Protocol):
|
||||
def __call__(self, data: dict[str, object], **kwargs: object) -> Awaitable[dict[str, object]]: ...
|
||||
|
||||
|
||||
class PreCallHook(Protocol):
|
||||
def __call__(
|
||||
self, user_api_key_dict: UserAPIKeyAuth, data: dict[str, object], call_type: str
|
||||
) -> Awaitable[dict[str, object]]: ...
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_add_litellm_data():
|
||||
async def _mock(data, **kwargs):
|
||||
def mock_add_litellm_data() -> AddLitellmDataToRequest:
|
||||
async def _mock(data: dict[str, object], **kwargs: object) -> dict[str, object]:
|
||||
return data
|
||||
|
||||
return _mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pre_call_hook():
|
||||
async def _mock(user_api_key_dict, data, call_type):
|
||||
def mock_pre_call_hook() -> PreCallHook:
|
||||
async def _mock(user_api_key_dict: UserAPIKeyAuth, data: dict[str, object], call_type: str) -> dict[str, object]:
|
||||
return data
|
||||
|
||||
return _mock
|
||||
|
|
@ -1199,3 +1212,78 @@ async def test_transcription_sessions_wraps_route_exception(
|
|||
assert "Model not allowed" in response.text
|
||||
finally:
|
||||
proxy_app.dependency_overrides.pop(user_api_key_auth, None)
|
||||
|
||||
|
||||
def test_realtime_calls_upstream_rejection_answers_an_openai_typed_error(
|
||||
proxy_app: FastAPI,
|
||||
mock_add_litellm_data: AddLitellmDataToRequest,
|
||||
mock_pre_call_hook: PreCallHook,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""A bare HTTPException carries no type or param, so the tail used to ship the
|
||||
literal string "None" in both fields of the error the browser client reads."""
|
||||
token_payload = _encode_realtime_token_payload(
|
||||
ephemeral_key="fake_upstream_epk",
|
||||
model_id="gpt-4o-realtime-preview",
|
||||
user_id=None,
|
||||
team_id=None,
|
||||
expires_at=int(time.time()) + 3600,
|
||||
)
|
||||
encrypted_token = encrypt_value_helper(token_payload)
|
||||
|
||||
async def failing_route_request(*args: object, **kwargs: object) -> None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": "realtime: Invalid model name passed in model=gpt-4o-realtime-preview"},
|
||||
)
|
||||
|
||||
proxy_logging = MagicMock()
|
||||
proxy_logging.pre_call_hook = AsyncMock(side_effect=mock_pre_call_hook)
|
||||
proxy_logging.post_call_failure_hook = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.route_request", failing_route_request)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.add_litellm_data_to_request", mock_add_litellm_data)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging)
|
||||
|
||||
response = TestClient(proxy_app).post(
|
||||
"/v1/realtime/calls",
|
||||
headers={"Authorization": f"Bearer {encrypted_token}"},
|
||||
content=b"v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\ns=-\r\n",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert (response.json()["error"]["type"], response.json()["error"]["param"]) == ("invalid_request_error", None)
|
||||
|
||||
|
||||
def test_transcription_sessions_rejection_answers_an_openai_typed_error(
|
||||
proxy_app: FastAPI,
|
||||
mock_add_litellm_data: AddLitellmDataToRequest,
|
||||
mock_pre_call_hook: PreCallHook,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""A model the router cannot serve surfaces as a bare HTTPException, which this tail
|
||||
used to relabel with the literal string "None" for both type and param."""
|
||||
|
||||
async def failing_route_request(*args: object, **kwargs: object) -> None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "realtime: Invalid model name passed in model=no-such-transcribe"},
|
||||
)
|
||||
|
||||
proxy_logging = MagicMock()
|
||||
proxy_logging.pre_call_hook = AsyncMock(side_effect=mock_pre_call_hook)
|
||||
proxy_logging.post_call_failure_hook = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.route_request", failing_route_request)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.add_litellm_data_to_request", mock_add_litellm_data)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging)
|
||||
proxy_app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="test-user")
|
||||
try:
|
||||
response = TestClient(proxy_app, raise_server_exceptions=False).post(
|
||||
"/v1/realtime/transcription_sessions",
|
||||
headers={"Authorization": "Bearer sk-test-master-key"},
|
||||
json={"input_audio_transcription": {"model": "no-such-transcribe"}},
|
||||
)
|
||||
finally:
|
||||
proxy_app.dependency_overrides.pop(user_api_key_auth, None)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert (response.json()["error"]["type"], response.json()["error"]["param"]) == ("invalid_request_error", None)
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import json
|
|||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Request, Response
|
||||
from fastapi import HTTPException, Request, Response
|
||||
|
||||
import litellm.proxy.common_request_processing as common_request_processing_mod
|
||||
import litellm.proxy.proxy_server as proxy_server_mod
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.rerank_endpoints.endpoints import rerank
|
||||
from litellm.types.utils import RerankResponse
|
||||
|
||||
|
|
@ -118,3 +118,55 @@ async def test_rerank_omits_detailed_timing_headers_when_disabled():
|
|||
fastapi_response = await _call_rerank()
|
||||
|
||||
assert "x-litellm-timing-llm-api-ms" not in fastapi_response.headers
|
||||
|
||||
|
||||
async def _rerank_failure(
|
||||
failure: Exception, *, raised_before_routing: bool, monkeypatch: pytest.MonkeyPatch
|
||||
) -> ProxyException:
|
||||
proxy_logging_obj = MagicMock()
|
||||
proxy_logging_obj.pre_call_hook = AsyncMock(
|
||||
side_effect=failure if raised_before_routing else lambda **kwargs: kwargs["data"]
|
||||
)
|
||||
proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||||
|
||||
async def fake_add_litellm_data_to_request(**kwargs: object) -> object:
|
||||
return kwargs["data"]
|
||||
|
||||
async def failing_route_request(**kwargs: object) -> None:
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(proxy_server_mod, "add_litellm_data_to_request", fake_add_litellm_data_to_request)
|
||||
monkeypatch.setattr(proxy_server_mod, "route_request", failing_route_request)
|
||||
monkeypatch.setattr(proxy_server_mod, "proxy_logging_obj", proxy_logging_obj)
|
||||
monkeypatch.setattr(proxy_server_mod, "llm_router", MagicMock())
|
||||
monkeypatch.setattr(proxy_server_mod, "version", "1.2.3")
|
||||
|
||||
with pytest.raises(ProxyException) as raised:
|
||||
await rerank(
|
||||
request=_build_request(),
|
||||
fastapi_response=Response(),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
|
||||
)
|
||||
return raised.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_model_the_router_cannot_serve_answers_an_openai_typed_error(monkeypatch: pytest.MonkeyPatch):
|
||||
"""A bare HTTPException carries no type or param, so the tail used to ship the
|
||||
literal string "None" in both fields."""
|
||||
failure = HTTPException(status_code=404, detail={"error": "rerank: Invalid model name passed in model=rerank-model"})
|
||||
|
||||
error = await _rerank_failure(failure, raised_before_routing=False, monkeypatch=monkeypatch)
|
||||
|
||||
assert (error.type, error.param, error.code) == ("invalid_request_error", None, "404")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rejection_raised_before_routing_keeps_its_own_status(monkeypatch: pytest.MonkeyPatch):
|
||||
"""A ProxyException stores its status as the string ``code``, which the tail used to
|
||||
miss and rewrap as a 500 while keeping the 4xx type and param."""
|
||||
rejection = ProxyException(message="session_id is required", type="bad_request_error", param="session_id", code=400)
|
||||
|
||||
error = await _rerank_failure(rejection, raised_before_routing=True, monkeypatch=monkeypatch)
|
||||
|
||||
assert (error.type, error.param, error.code) == ("bad_request_error", "session_id", "400")
|
||||
|
|
|
|||
|
|
@ -2202,6 +2202,19 @@ class TestGuardrailBlockErrorPayloadNeverStringifiesNone:
|
|||
assert frame["error"]["param"] is None
|
||||
assert frame["error"]["code"] == "400"
|
||||
|
||||
def test_a_streaming_frame_keeps_the_status_a_proxy_exception_was_raised_with(self):
|
||||
"""ProxyException stores its status as the string ``code``, so a 429 raised before the
|
||||
first chunk used to reach the SSE frame as a 500."""
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.common_request_processing import sse_error_payload
|
||||
|
||||
error_status, error_obj = sse_error_payload(
|
||||
ProxyException(message="Rate limit reached", type="rate_limit_error", param=None, code=429)
|
||||
)
|
||||
|
||||
assert error_status == 429
|
||||
assert (error_obj["type"], error_obj["code"]) == ("rate_limit_error", "429")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected_type",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ def test_handle_exception_on_proxy_happy_path_generic_exception_defaults_to_500(
|
|||
"message": "kaboom",
|
||||
"type": ProxyErrorTypes.internal_server_error.value,
|
||||
"code": "500",
|
||||
"param": "None",
|
||||
"param": None,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue