From 4819f686f948bc90a00c7795b05d620036885ae7 Mon Sep 17 00:00:00 2001 From: Prince <203696297+Prince-5785@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:09:04 +0530 Subject: [PATCH] fix(proxy): decode multipart fallbacks into lists Multipart requests send every field as a string, so a per-request fallbacks list on /v1/images/edits reached the router as '["backup"]' and the fallback never ran read_request_body now JSON-decodes fallbacks, context_window_fallbacks and content_policy_fallbacks in form bodies, the same way it already decodes metadata, and returns a 400 when they are not a JSON array Fixes #45948 --- .../proxy/common_utils/http_parsing_utils.py | 35 +++++++++++++++---- .../common_utils/test_http_parsing_utils.py | 31 ++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index e210c337922..dc760fbd60e 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -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, Union, get_args, get_origin @@ -31,10 +31,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]) def resolve_realtime_route_model(model: str | None, intent: str | None, is_translation: bool) -> str | None: @@ -220,6 +224,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 @@ -279,12 +305,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() diff --git a/tests/unit/proxy/common_utils/test_http_parsing_utils.py b/tests/unit/proxy/common_utils/test_http_parsing_utils.py index 0fc7bb15a32..6ecc67768cc 100644 --- a/tests/unit/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/unit/proxy/common_utils/test_http_parsing_utils.py @@ -422,6 +422,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(): """