mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
Merge pull request #31391 from BerriAI/litellm_multipart_file_upload
fix(passthrough): forward all multipart files with repeated field names
This commit is contained in:
commit
e4aedb0342
2 changed files with 107 additions and 26 deletions
|
|
@ -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
|
||||
|
||||
|
|
@ -478,20 +479,46 @@ 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))
|
||||
]
|
||||
|
||||
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 = {
|
||||
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
|
||||
# when it creates the multipart body from files/data parameters
|
||||
|
|
|
|||
|
|
@ -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,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"
|
||||
assert "file" in kwargs["files"], "File field should be in files dict"
|
||||
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", {})
|
||||
|
|
@ -3233,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 = kwargs["files"]["file"]
|
||||
filename, content, content_type = file_parts[0]
|
||||
assert filename == "test.txt"
|
||||
assert content == b"test file content"
|
||||
assert content_type == "text/plain"
|
||||
|
|
@ -3255,7 +3309,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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue