From ef0785881a476b39ae20479f2c80cb184f1f8a66 Mon Sep 17 00:00:00 2001 From: Shivam Rawat Date: Thu, 25 Jun 2026 19:14:13 -0700 Subject: [PATCH 1/2] fix(passthrough): forward all multipart files with repeated field names Passthrough multipart uploads used form.items() and a files dict, so only the last file under a repeated field name reached the upstream. Read multi_items() and send httpx a list of file tuples instead. Co-authored-by: Cursor --- .../pass_through_endpoints.py | 48 ++++++++--- .../test_pass_through_endpoints.py | 80 +++++++++++++++---- 2 files changed, 102 insertions(+), 26 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index a7c7786aa91..becf1e0abc2 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -477,20 +477,44 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): requested_query_params: Optional[dict] = None, stream: bool = False, ) -> httpx.Response: - """Process multipart/form-data requests, handling both files and form fields""" - form_data = await request.form() - files = {} - form_data_dict = {} + """Process multipart/form-data requests, handling both files and form fields. - for field_name, field_value in form_data.items(): - if isinstance(field_value, (StarletteUploadFile, UploadFile)): - files[ - field_name - ] = await HttpPassThroughEndpointHelpers._build_request_files_from_upload_file( + Iterates ``form.multi_items()`` rather than ``form.items()`` so repeated + field names (e.g. several ``-F file=@...`` parts) are all forwarded; + ``items()`` collapses duplicate keys to the last value. Files go out as a + list of ``(field_name, (filename, content, content_type))`` tuples and + repeated non-file fields are grouped into list values, both of which httpx + encodes as separate multipart parts. + """ + form_items = (await request.form()).multi_items() + + files = [ + ( + field_name, + await HttpPassThroughEndpointHelpers._build_request_files_from_upload_file( upload_file=field_value - ) - else: - form_data_dict[field_name] = field_value + ), + ) + for field_name, field_value in form_items + if isinstance(field_value, (StarletteUploadFile, UploadFile)) + ] + + field_names = tuple( + dict.fromkeys( + field_name + for field_name, field_value in form_items + if not isinstance(field_value, (StarletteUploadFile, UploadFile)) + ) + ) + form_data_dict = { + name: [ + field_value + for field_name, field_value in form_items + if field_name == name + and not isinstance(field_value, (StarletteUploadFile, UploadFile)) + ] + for name in field_names + } # Remove content-type header - httpx will set it correctly with the new boundary # when it creates the multipart body from files/data parameters diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 379e219b29b..42e90c823c2 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from fastapi import Request, UploadFile -from starlette.datastructures import Headers, QueryParams +from starlette.datastructures import FormData, Headers, QueryParams from starlette.datastructures import UploadFile as StarletteUploadFile sys.path.insert( @@ -94,7 +94,7 @@ async def test_make_multipart_http_request(): upload_file = UploadFile(file=file, filename="test.txt", headers=headers) upload_file.read = AsyncMock(return_value=file_content) - form_data = {"file": upload_file, "text_field": "test value"} + form_data = FormData([("file", upload_file), ("text_field", "test value")]) request.form = AsyncMock(return_value=form_data) # Mock httpx client @@ -123,9 +123,61 @@ async def test_make_multipart_http_request(): assert call_args["method"] == "POST" assert str(call_args["url"]) == "http://test.com" - assert isinstance(call_args["files"], dict) - assert isinstance(call_args["data"], dict) - assert call_args["data"]["text_field"] == "test value" + assert call_args["files"] == [("file", ("test.txt", file_content, "text/plain"))] + assert call_args["data"] == {"text_field": ["test value"]} + + +@pytest.mark.asyncio +async def test_make_multipart_http_request_forwards_repeated_fields(): + """ + Regression: a client sending several parts under the same field name + (e.g. ``-F file=@a.pdf -F file=@b.pdf``) must have every part forwarded. + Starlette's ``FormData.items()`` collapses duplicate keys to the last value, + so the handler must read ``multi_items()`` and emit one httpx files tuple per + file plus a list value per repeated non-file field. + """ + request = MagicMock(spec=Request) + request.method = "POST" + + def _upload(filename: str, content: bytes) -> UploadFile: + f = UploadFile( + file=BytesIO(content), + filename=filename, + headers=Headers({"content-type": "application/pdf"}), + ) + f.read = AsyncMock(return_value=content) + return f + + form_data = FormData( + [ + ("file", _upload("a.pdf", b"PDF-ONE")), + ("file", _upload("b.pdf", b"PDF-TWO")), + ("other_parameter", "xxx"), + ("other_parameter", "yyy"), + ] + ) + request.form = AsyncMock(return_value=form_data) + + mock_response = MagicMock() + mock_response.status_code = 200 + async_client = MagicMock() + async_client.request = AsyncMock(return_value=mock_response) + + await HttpPassThroughEndpointHelpers.make_multipart_http_request( + request=request, + async_client=async_client, + url=httpx.URL("http://test.com"), + headers={}, + requested_query_params=None, + ) + + call_args = async_client.request.call_args[1] + + assert call_args["files"] == [ + ("file", ("a.pdf", b"PDF-ONE", "application/pdf")), + ("file", ("b.pdf", b"PDF-TWO", "application/pdf")), + ] + assert call_args["data"] == {"other_parameter": ["xxx", "yyy"]} @pytest.mark.asyncio @@ -149,7 +201,7 @@ async def test_make_multipart_http_request_removes_content_type_header(): upload_file = UploadFile(file=file, filename="test.txt", headers=headers) upload_file.read = AsyncMock(return_value=file_content) - form_data = {"file": upload_file, "key": "value"} + form_data = FormData([("file", upload_file), ("key", "value")]) request.form = AsyncMock(return_value=form_data) # Mock httpx client @@ -193,9 +245,8 @@ async def test_make_multipart_http_request_removes_content_type_header(): # Verify other parameters are correct assert call_args["method"] == "POST" assert str(call_args["url"]) == "http://test.com" - assert isinstance(call_args["files"], dict) - assert isinstance(call_args["data"], dict) - assert call_args["data"]["key"] == "value" + assert call_args["files"] == [("file", ("test.txt", file_content, "text/plain"))] + assert call_args["data"] == {"key": ["value"]} assert call_args["params"] == {"param": "value"} # Verify the original headers dict was not modified (copy was used) @@ -219,7 +270,7 @@ async def test_non_streaming_http_request_handler_multipart_with_non_empty_parse upload_headers = Headers({"content-type": "text/plain"}) upload_file = UploadFile(file=file, filename="test.txt", headers=upload_headers) upload_file.read = AsyncMock(return_value=file_content) - request.form = AsyncMock(return_value={"file": upload_file}) + request.form = AsyncMock(return_value=FormData([("file", upload_file)])) mock_response = MagicMock() mock_response.status_code = 200 @@ -240,7 +291,7 @@ async def test_non_streaming_http_request_handler_multipart_with_non_empty_parse call_args = async_client.request.call_args[1] assert "files" in call_args assert "json" not in call_args - assert call_args["files"]["file"][0] == "test.txt" + assert call_args["files"] == [("file", ("test.txt", file_content, "text/plain"))] @pytest.mark.asyncio @@ -3225,7 +3276,8 @@ async def test_multipart_passthrough_preserves_boundary(): async def mock_httpx_request(method, url, **kwargs): # Verify that files parameter is passed (not json) assert "files" in kwargs, "Files should be passed for multipart requests" - assert "file" in kwargs["files"], "File field should be in files dict" + files = dict(kwargs["files"]) + assert "file" in files, "File field should be in files" # Verify content-type is NOT in headers (httpx will set it with correct boundary) headers = kwargs.get("headers", {}) @@ -3233,7 +3285,7 @@ async def test_multipart_passthrough_preserves_boundary(): "content-type" not in headers ), "content-type should be removed for multipart" - filename, content, content_type = kwargs["files"]["file"] + filename, content, content_type = files["file"] assert filename == "test.txt" assert content == b"test file content" assert content_type == "text/plain" @@ -3255,7 +3307,7 @@ async def test_multipart_passthrough_preserves_boundary(): upload_file = UploadFile(file=file, filename="test.txt", headers=headers) upload_file.read = AsyncMock(return_value=file_content) - form_data = {"file": upload_file} + form_data = FormData([("file", upload_file)]) request.form = AsyncMock(return_value=form_data) # Test the multipart handler directly From eb15fe667d12e86cd7553ef8201793352aa655e3 Mon Sep 17 00:00:00 2001 From: Shivam Rawat Date: Thu, 25 Jun 2026 19:28:59 -0700 Subject: [PATCH 2/2] refactor(passthrough): address multipart review feedback Build form_data_dict in one pass with groupby instead of rescanning form_items per field name, and assert on the files list directly in the boundary regression test so repeated field names are not collapsed by dict(). Co-authored-by: Cursor --- .../pass_through_endpoints.py | 29 ++++++++++--------- .../test_pass_through_endpoints.py | 8 +++-- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index becf1e0abc2..1b6e360b062 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -6,6 +6,7 @@ import posixpath import traceback from base64 import b64encode from datetime import datetime +from itertools import groupby from typing import Any, Dict, List, Mapping, Optional, Tuple, Union, cast from urllib.parse import urlencode, urlparse @@ -499,21 +500,23 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): if isinstance(field_value, (StarletteUploadFile, UploadFile)) ] - field_names = tuple( - dict.fromkeys( - field_name - for field_name, field_value in form_items - if not isinstance(field_value, (StarletteUploadFile, UploadFile)) - ) + non_file_items = tuple( + (field_name, field_value) + for field_name, field_value in form_items + if not isinstance(field_value, (StarletteUploadFile, UploadFile)) ) + field_order = { + field_name: index + for index, field_name in enumerate( + dict.fromkeys(field_name for field_name, _ in non_file_items) + ) + } form_data_dict = { - name: [ - field_value - for field_name, field_value in form_items - if field_name == name - and not isinstance(field_value, (StarletteUploadFile, UploadFile)) - ] - for name in field_names + field_name: [value for _, value in group] + for field_name, group in groupby( + sorted(non_file_items, key=lambda item: field_order[item[0]]), + key=lambda item: item[0], + ) } # Remove content-type header - httpx will set it correctly with the new boundary diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 42e90c823c2..f97e75f7ce3 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -3276,8 +3276,10 @@ async def test_multipart_passthrough_preserves_boundary(): async def mock_httpx_request(method, url, **kwargs): # Verify that files parameter is passed (not json) assert "files" in kwargs, "Files should be passed for multipart requests" - files = dict(kwargs["files"]) - assert "file" in files, "File field should be in files" + file_parts = [ + value for name, value in kwargs["files"] if name == "file" + ] + assert len(file_parts) == 1, "File field should be in files" # Verify content-type is NOT in headers (httpx will set it with correct boundary) headers = kwargs.get("headers", {}) @@ -3285,7 +3287,7 @@ async def test_multipart_passthrough_preserves_boundary(): "content-type" not in headers ), "content-type should be removed for multipart" - filename, content, content_type = files["file"] + filename, content, content_type = file_parts[0] assert filename == "test.txt" assert content == b"test file content" assert content_type == "text/plain"