mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
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:
parent
bd1ea0252a
commit
6913383953
2 changed files with 121 additions and 21 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue