Fix: Handle multipart/form-data with binary files in pass-through endpoints

- Skip JSON parsing for multipart/form-data to avoid UnicodeDecodeError
- Add regression tests for binary file uploads, JSON, and form-urlencoded
- Ensure no regression for existing content types

Fixes issue where uploading PDF files via pass-through endpoints caused:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc4 in position X

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Iheb 2026-05-06 10:41:37 +02:00
parent bd1ea0252a
commit 6913383953
2 changed files with 121 additions and 21 deletions

View file

@ -60,8 +60,8 @@ from litellm.proxy.utils import get_server_root_path, normalize_route_for_root_p
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
EndpointType,
LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY,
EndpointType,
PassthroughStandardLoggingPayload,
)
@ -1192,22 +1192,18 @@ 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:
# ✅ 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
# ✅ Skip parsing for multipart/form-data - let make_multipart_http_request handle it
#
# CRITICAL: Do NOT call request.json() or request.body() here for multipart requests.
# Binary files (PDFs, images, etc.) cannot be decoded as UTF-8 and will cause:
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc4 in position X: invalid continuation byte
#
# The pass_through_request function checks is_multipart and calls make_multipart_http_request
# which properly handles multipart/form-data with files.
#
# Edge case: If a misconfigured client sends JSON with multipart content-type,
# that client needs to fix their Content-Type header.
pass
elif "application/x-www-form-urlencoded" in content_type:
# ✅ Handle URL-encoded form data
@ -2324,10 +2320,10 @@ async def _register_pass_through_endpoint(
dependencies = None
if auth is not None and str(auth).lower() == "true":
# Authentication on a pass-through endpoint used to be enterprise-only.
# That left OSS with no safe configuration: auth=True raised at startup
# unless the operator had a license. The safe option must always be free,
# and unauthenticated forwarding should require explicit opt-in.
# Authentication on a pass-through endpoint used to be enterprise-only.
# That left OSS with no safe configuration: auth=True raised at startup
# unless the operator had a license. The safe option must always be free,
# and unauthenticated forwarding should require explicit opt-in.
dependencies = [Depends(user_api_key_auth)]
if path not in LiteLLMRoutes.openai_routes.value:
LiteLLMRoutes.openai_routes.value.append(path)

View file

@ -18,6 +18,7 @@ sys.path.insert(
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
HttpPassThroughEndpointHelpers,
LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY,
_parse_request_data_by_content_type,
pass_through_request,
)
from litellm.proxy.pass_through_endpoints.success_handler import (
@ -2618,3 +2619,106 @@ def test_get_response_headers_strips_server_and_date():
assert lowered["content-type"] == "application/json"
assert lowered["x-request-id"] == "req_abc"
assert lowered["anthropic-ratelimit-requests-remaining"] == "100"
# Test _parse_request_data_by_content_type with multipart/form-data containing binary files
@pytest.mark.asyncio
async def test_parse_request_data_multipart_binary_file():
"""
Test that _parse_request_data_by_content_type correctly handles multipart/form-data
with binary files (e.g., PDF uploads) without raising UnicodeDecodeError.
This is a regression test for the issue where calling request.json() on multipart
form-data with binary content caused:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc4 in position X: invalid continuation byte
"""
# Create a mock request with multipart/form-data content type
request = MagicMock(spec=Request)
# Simulate a PDF file upload with multipart/form-data
# PDF files contain binary data that cannot be decoded as UTF-8
pdf_header = b"%PDF-1.4\n" # Standard PDF header
binary_content = pdf_header + bytes([0xC4, 0x80, 0x81, 0x82]) # Binary data that fails UTF-8
request.headers = Headers({
"content-type": "multipart/form-data; boundary=--------------------------7oKZJmK2xoAobYu7SoXyay"
})
# Mock the form data to simulate a file upload
file_content = binary_content
file = BytesIO(file_content)
headers = Headers({"content-type": "application/pdf"})
upload_file = UploadFile(file=file, filename="document.pdf", headers=headers)
upload_file.read = AsyncMock(return_value=file_content)
form_data = {"file": upload_file, "field1": "value1"}
request.form = AsyncMock(return_value=form_data)
# Mock query_params
request.query_params = QueryParams({})
# This should NOT raise UnicodeDecodeError
# Before the fix, calling request.json() on multipart data would fail
query_params_data, custom_body_data, file_data, stream = await _parse_request_data_by_content_type(
request
)
# For multipart/form-data, the function should skip parsing and return None for all fields
# The actual multipart handling is done by make_multipart_http_request
assert query_params_data is None
assert custom_body_data is None
assert file_data is None
assert stream is None
# Test _parse_request_data_by_content_type with JSON (should still work)
@pytest.mark.asyncio
async def test_parse_request_data_json():
"""
Test that _parse_request_data_by_content_type still correctly handles JSON requests.
"""
request = MagicMock(spec=Request)
request.headers = Headers({"content-type": "application/json"})
mock_body = {
"query_params": {"param1": "value1"},
"custom_body": {"key": "value"},
"stream": True
}
request.json = AsyncMock(return_value=mock_body)
request.query_params = QueryParams({})
query_params_data, custom_body_data, file_data, stream = await _parse_request_data_by_content_type(
request
)
assert query_params_data == {"param1": "value1"}
assert custom_body_data == {"key": "value"}
assert stream is True
assert file_data is None
# Test _parse_request_data_by_content_type with application/x-www-form-urlencoded
@pytest.mark.asyncio
async def test_parse_request_data_form_urlencoded():
"""
Test that _parse_request_data_by_content_type correctly handles URL-encoded form data.
"""
request = MagicMock(spec=Request)
request.headers = Headers({"content-type": "application/x-www-form-urlencoded"})
form_data = {
"query_params": '{"param1": "value1"}',
"custom_body": '{"key": "value"}'
}
request.form = AsyncMock(return_value=form_data)
request.query_params = QueryParams({})
query_params_data, custom_body_data, file_data, stream = await _parse_request_data_by_content_type(
request
)
assert query_params_data == '{"param1": "value1"}'
assert custom_body_data == '{"key": "value"}'
assert file_data is None
assert stream is None