mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 4819f686f9 into ab3b12b951
This commit is contained in:
commit
518e733bf2
2 changed files with 59 additions and 7 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import json
|
||||
import re
|
||||
from collections.abc import Collection, Mapping
|
||||
from collections.abc import Collection, Mapping, Sequence
|
||||
from types import MappingProxyType, UnionType
|
||||
from typing import Annotated, Any, Final, Literal, Protocol, Union, get_args, get_origin
|
||||
|
||||
|
|
@ -32,10 +32,14 @@ _FORM_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-www-form-
|
|||
# Binary bodies (e.g. OTLP trace exports on POST /v1/traces) are not JSON: arbitrary bytes used to
|
||||
# hit the JSON surrogate-repair path and fail auth with a 400. JSON under these types still parses.
|
||||
_BINARY_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-protobuf", "application/protobuf"})
|
||||
_FORM_FALLBACK_FIELDS: Final[frozenset[str]] = frozenset(
|
||||
{"fallbacks", "context_window_fallbacks", "content_policy_fallbacks"}
|
||||
)
|
||||
|
||||
_ANNOTATION_QUALIFIERS: Final[frozenset[object]] = frozenset({Annotated, NotRequired, ReadOnly, Required})
|
||||
_DECODED_JSON: Final = TypeAdapter(object)
|
||||
_JSON_REQUEST_BODY: Final = TypeAdapter(Mapping[str, object])
|
||||
_FORM_FALLBACK_LIST: Final = TypeAdapter(list[object])
|
||||
|
||||
|
||||
class _ReadableUpload(Protocol):
|
||||
|
|
@ -233,6 +237,28 @@ def _parse_form_metadata(raw_metadata: str) -> object:
|
|||
return parsed
|
||||
|
||||
|
||||
def _parse_form_fallbacks(field: str, raw_fallbacks: str) -> Sequence[object]:
|
||||
try:
|
||||
return _FORM_FALLBACK_LIST.validate_json(raw_fallbacks)
|
||||
except ValidationError as e:
|
||||
raise ProxyException(
|
||||
message=f"Invalid form payload: {field} must be a JSON array",
|
||||
type="invalid_request_error",
|
||||
param=field,
|
||||
code=status.HTTP_400_BAD_REQUEST,
|
||||
) from e
|
||||
|
||||
|
||||
def _parse_form_field(field: str, value: object) -> object:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if field == "metadata":
|
||||
return _parse_form_metadata(value)
|
||||
if field in _FORM_FALLBACK_FIELDS:
|
||||
return _parse_form_fallbacks(field, value)
|
||||
return value
|
||||
|
||||
|
||||
def _reraise_request_size_error(error: Exception) -> None:
|
||||
if isinstance(error, HTTPException) and error.status_code == status.HTTP_413_CONTENT_TOO_LARGE:
|
||||
raise error
|
||||
|
|
@ -292,12 +318,7 @@ async def read_request_body(request: Request | None) -> dict:
|
|||
parsed_body = _parse_binary_body(binary_body)
|
||||
elif _is_form_content_type(content_type):
|
||||
form_body: Final = dict(await _read_form_data(request))
|
||||
raw_metadata: Final = form_body.get("metadata")
|
||||
parsed_body = (
|
||||
{**form_body, "metadata": _parse_form_metadata(raw_metadata)}
|
||||
if isinstance(raw_metadata, str)
|
||||
else form_body
|
||||
)
|
||||
parsed_body = {field: _parse_form_field(field, value) for field, value in form_body.items()}
|
||||
else:
|
||||
# Read the request body
|
||||
body: Final = await request.body()
|
||||
|
|
|
|||
|
|
@ -423,6 +423,37 @@ async def test_form_data_with_invalid_json_metadata() -> None:
|
|||
assert exc_info.value.param == "metadata"
|
||||
|
||||
|
||||
def _form_request(fields: Mapping[str, str]) -> MagicMock:
|
||||
request: Final = MagicMock()
|
||||
request.form = AsyncMock(return_value=FormData(fields))
|
||||
request.headers = {"content-type": "multipart/form-data"}
|
||||
request.scope = {}
|
||||
request.state._cached_headers = None
|
||||
return request
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("field", "raw", "expected"),
|
||||
[
|
||||
("fallbacks", '["backup"]', ["backup"]),
|
||||
("context_window_fallbacks", '[{"primary": ["backup"]}]', [{"primary": ["backup"]}]),
|
||||
("content_policy_fallbacks", '[{"primary": ["backup"]}]', [{"primary": ["backup"]}]),
|
||||
],
|
||||
)
|
||||
async def test_form_data_fallback_fields_are_decoded_to_lists(field: str, raw: str, expected: list[object]) -> None:
|
||||
result: Final = await read_request_body(_form_request({"model": "primary", field: raw}))
|
||||
assert result == {"model": "primary", field: expected}, result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("raw", ['"backup"', "backup", '{"primary": ["backup"]}'])
|
||||
async def test_form_data_fallbacks_that_are_not_a_json_array_are_rejected(raw: str) -> None:
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await read_request_body(_form_request({"model": "primary", "fallbacks": raw}))
|
||||
assert (exc_info.value.code, exc_info.value.param) == ("400", "fallbacks"), exc_info.value.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_form_data_without_metadata():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue