fix(proxy): preserve multipart/form-data boundary in passthrough endpoints

Fixes issue where multipart file uploads through passthrough endpoints failed with RequestValidationError. The proxy was consuming the request body stream and FastAPI was trying to parse multipart bodies as JSON dicts.

Changes:
- Try JSON parsing first for multipart content-type (handles misconfigured clients)
- Skip multipart parsing if JSON succeeds to avoid stream consumption
- Remove custom_body parameter from endpoint_func to prevent FastAPI auto-parsing
- Check for parsed body before using multipart handler
- Add regression test for multipart boundary preservation

Handles both actual multipart uploads and JSON bodies with incorrect multipart content-type headers.

Made-with: Cursor
This commit is contained in:
Sameer Kankute
2026-03-11 16:52:02 +05:30
parent 628510d1b5
commit c2fca1124b
2 changed files with 93 additions and 31 deletions
@@ -404,7 +404,9 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
headers=headers,
params=requested_query_params,
)
elif HttpPassThroughEndpointHelpers.is_multipart(request) is True:
elif HttpPassThroughEndpointHelpers.is_multipart(request) is True and not _parsed_body:
# Only use multipart handler if we don't have a parsed body
# (parsed body means it was JSON despite multipart content-type header)
return await HttpPassThroughEndpointHelpers.make_multipart_http_request(
request=request,
async_client=async_client,
@@ -677,8 +679,15 @@ async def pass_through_request( # noqa: PLR0915
str(url)
)
# Skip body parsing for multipart requests - make_multipart_http_request will handle it
# But if custom_body is provided (e.g., JSON parsed despite multipart content-type), use it
is_multipart = HttpPassThroughEndpointHelpers.is_multipart(request) and not custom_body
if custom_body:
_parsed_body = custom_body
elif is_multipart:
# Don't parse multipart body here - it will be handled by make_multipart_http_request
_parsed_body = {}
else:
_parsed_body = await _read_request_body(request)
verbose_proxy_logger.debug(
@@ -1043,30 +1052,22 @@ async def _parse_request_data_by_content_type(
# Handle requests with no body (e.g., DELETE requests)
pass
elif "multipart/form-data" in content_type:
# ✅ Handle multipart form-data
form = await request.form()
if "query_params" in form:
form_value = form["query_params"]
if isinstance(form_value, str):
try:
query_params_data = json.loads(form_value)
except Exception:
query_params_data = form_value
else:
query_params_data = form_value
if "custom_body" in form:
form_value = form["custom_body"]
if isinstance(form_value, str):
try:
custom_body_data = json.loads(form_value)
except Exception:
custom_body_data = form_value
else:
custom_body_data = form_value
if "file" in form:
file_data = form["file"] # this is a Starlette UploadFile object
# ✅ Try to parse as JSON first (handles misconfigured clients sending JSON with multipart content-type)
# If that fails, skip parsing - pass_through_request will handle actual multipart
try:
body = await request.json()
# Successfully parsed as JSON - treat as JSON body
query_params_data = body.get("query_params")
custom_body_data = body.get("custom_body")
stream = body.get("stream")
# If custom_body is not set, use the entire body
if custom_body_data is None and body:
custom_body_data = body
except (json.JSONDecodeError, Exception):
# Not JSON - this is actual multipart data
# Skip parsing here to avoid consuming the request body stream
# make_multipart_http_request will handle it
pass
elif "application/x-www-form-urlencoded" in content_type:
# ✅ Handle URL-encoded form data
@@ -1132,7 +1133,6 @@ def create_pass_through_route(
fastapi_response: Response,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
subpath: str = "", # captures sub-paths when include_subpath=True
custom_body: Optional[dict] = None,
):
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
@@ -1208,12 +1208,9 @@ def create_pass_through_route(
)
if query_params:
final_query_params.update(query_params)
# When a caller (e.g. bedrock_proxy_route) supplies a pre-built
# body, use it instead of the body parsed from the raw request.
# Use the body parsed from the raw request
final_custom_body: Optional[dict] = None
if custom_body is not None:
final_custom_body = custom_body
elif isinstance(custom_body_data, dict):
if isinstance(custom_body_data, dict):
final_custom_body = custom_body_data
return await pass_through_request( # type: ignore
@@ -2408,3 +2408,68 @@ def test_mapped_pass_through_routes_with_server_root_path():
)
is False
)
@pytest.mark.asyncio
async def test_multipart_passthrough_preserves_boundary():
"""
Test that multipart/form-data requests through passthrough preserve the boundary
and can be correctly parsed by the upstream server.
Regression test for multipart boundary stripping issue.
"""
from io import BytesIO
# Mock the httpx request to verify files are passed correctly
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = httpx.Headers({"content-type": "application/json"})
mock_response.aread = AsyncMock(return_value=b'{"filename": "test.txt", "size": 17}')
mock_response.text = '{"filename": "test.txt", "size": 17}'
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"
# Verify content-type is NOT in headers (httpx will set it with correct boundary)
headers = kwargs.get("headers", {})
assert "content-type" not in headers, "content-type should be removed for multipart"
filename, content, content_type = kwargs["files"]["file"]
assert filename == "test.txt"
assert content == b"test file content"
assert content_type == "text/plain"
return mock_response
async_client = MagicMock()
async_client.request = AsyncMock(side_effect=mock_httpx_request)
# Create mock request
request = MagicMock(spec=Request)
request.method = "POST"
request.headers = Headers({"content-type": "multipart/form-data; boundary=test123"})
# Mock form data
file_content = b"test file content"
file = BytesIO(file_content)
headers = Headers({"content-type": "text/plain"})
upload_file = UploadFile(file=file, filename="test.txt", headers=headers)
upload_file.read = AsyncMock(return_value=file_content)
form_data = {"file": upload_file}
request.form = AsyncMock(return_value=form_data)
# Test the multipart handler directly
response = await HttpPassThroughEndpointHelpers.make_multipart_http_request(
request=request,
async_client=async_client,
url=httpx.URL("http://test.com/upload"),
headers={},
requested_query_params=None,
)
# Verify the response
assert response.status_code == 200
async_client.request.assert_called_once()