From c2fca1124bb5f80af16b94f175a4dc5d977c90dd Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 11 Mar 2026 16:41:58 +0530 Subject: [PATCH] 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 --- .../pass_through_endpoints.py | 59 ++++++++--------- .../test_pass_through_endpoints.py | 65 +++++++++++++++++++ 2 files changed, 93 insertions(+), 31 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 4d95fda0a4..338a198d00 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -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 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 71420c23ad..5af24f9612 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 @@ -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()