From b63721c45f2801c9ec4bb92a89b45e7e8c413fd6 Mon Sep 17 00:00:00 2001 From: Kcstring Date: Wed, 29 Apr 2026 17:11:03 +0800 Subject: [PATCH] fix(proxy): decode multipart user_config JSON --- .../proxy/common_utils/http_parsing_utils.py | 15 ++++- .../test_multipart_json_form_fields.py | 56 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/test_litellm/proxy/common_utils/test_multipart_json_form_fields.py diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 71abdfa5e9e..826581ec375 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -13,6 +13,16 @@ from litellm.proxy.common_utils.callback_utils import ( from litellm.types.router import Deployment +def _parse_form_json_field( + parsed_body: Dict[str, Any], field_name: str, *, require_json: bool = False +) -> None: + value = parsed_body.get(field_name) + if not isinstance(value, str): + return + if require_json or value.lstrip().startswith(("{", "[")): + parsed_body[field_name] = json.loads(value) + + async def _read_request_body(request: Optional[Request]) -> Dict: """ Safely read the request body and parse it as JSON. @@ -39,8 +49,9 @@ async def _read_request_body(request: Optional[Request]) -> Dict: if "form" in content_type: parsed_body = dict(await request.form()) - if "metadata" in parsed_body and isinstance(parsed_body["metadata"], str): - parsed_body["metadata"] = json.loads(parsed_body["metadata"]) + _parse_form_json_field(parsed_body, "metadata", require_json=True) + _parse_form_json_field(parsed_body, "user_config") + _parse_form_json_field(parsed_body, "tags") else: # Read the request body body = await request.body() diff --git a/tests/test_litellm/proxy/common_utils/test_multipart_json_form_fields.py b/tests/test_litellm/proxy/common_utils/test_multipart_json_form_fields.py new file mode 100644 index 00000000000..58ef62c5795 --- /dev/null +++ b/tests/test_litellm/proxy/common_utils/test_multipart_json_form_fields.py @@ -0,0 +1,56 @@ +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from litellm.proxy.common_utils.http_parsing_utils import _read_request_body + + +@pytest.mark.asyncio +async def test_form_data_with_json_user_config_and_tags(): + mock_request = MagicMock() + user_config = { + "model_list": [ + { + "model_name": "openai/gpt-image-1", + "litellm_params": { + "model": "openai/gpt-image-1", + "api_key": "sk-fake", + }, + } + ] + } + test_data = { + "model": "openai/gpt-image-1", + "prompt": "test", + "user_config": json.dumps(user_config), + "tags": json.dumps(["image-edit", "multipart"]), + } + + mock_request.form = AsyncMock(return_value=test_data) + mock_request.headers = {"content-type": "multipart/form-data"} + mock_request.scope = {} + mock_request.state._cached_headers = None + + result = await _read_request_body(mock_request) + + assert result["user_config"] == user_config + assert result["tags"] == ["image-edit", "multipart"] + assert result["model"] == "openai/gpt-image-1" + assert result["prompt"] == "test" + mock_request.form.assert_called_once() + + +@pytest.mark.asyncio +async def test_form_data_with_plain_string_tags_is_left_unchanged(): + mock_request = MagicMock() + test_data = {"model": "whisper-1", "tags": "production"} + + mock_request.form = AsyncMock(return_value=test_data) + mock_request.headers = {"content-type": "multipart/form-data"} + mock_request.scope = {} + mock_request.state._cached_headers = None + + result = await _read_request_body(mock_request) + + assert result["tags"] == "production"