litellm - ✅(Solved) Fix [Bug]: user_config not JSON-decoded for multipart endpoints (e.g. /v1/images/edits) [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
BerriAI/litellm#26707Fetched 2026-04-29 06:12:35
View on GitHub
Comments
0
Participants
1
Timeline
4
Reactions
0
Participants
Timeline (top)
labeled ×3cross-referenced ×1

Error Message

ERROR: common_request_processing.py:912 - litellm.proxy.proxy_server._handle_llm_api_exception(): Exception occured - litellm.router.Router() argument after ** must be a mapping, not str

Traceback (most recent call last): File "/usr/lib/python3.13/site-packages/litellm/proxy/image_endpoints/endpoints.py", line 288, in image_edit_api return await processor.base_process_llm_request(...) File "/usr/lib/python3.13/site-packages/litellm/proxy/common_request_processing.py", line 667, in base_process_llm_request llm_call = await route_request(...) File "/usr/lib/python3.13/site-packages/litellm/proxy/route_llm_request.py", line 200, in route_request user_router = litellm.Router(**router_config) TypeError: litellm.router.Router() argument after ** must be a mapping, not str

The router_config at line 200 is the literal string '{"model_list":[...]}' — _read_request_body returned the form field as a string and route_request splatted it into Router().

Drop-in for the bug body

Use this as the "Actual behavior / log output" section:

Actual response (500):

{"error":{"message":"litellm.router.Router() argument after ** must be a mapping, not str","type":"None","param":"None","code":"500"}}

Proxy traceback:

TypeError: litellm.router.Router() argument after ** must be a mapping, not str

Traceback (most recent call last): File "litellm/proxy/image_endpoints/endpoints.py", line 288, in image_edit_api return await processor.base_process_llm_request(...) File "litellm/proxy/common_request_processing.py", line 667, in base_process_llm_request llm_call = await route_request(...) File "litellm/proxy/route_llm_request.py", line 200, in route_request user_router = litellm.Router(**router_config)

The crash happens because _read_request_body (litellm/proxy/common_utils/http_parsing_utils.py:40-43) only JSON-decodes the metadata field for multipart bodies. user_config arrives as a JSON-shaped str, and route_request splats it directly into Router().

Root Cause

The same user_config payload works correctly when sent on a JSON-bodied endpoint (e.g. POST /v1/images/generations), because _read_request_body parses the entire JSON body and user_config arrives as a dict.

Fix Action

Fixed

PR fix notes

PR #26723: fix: decode multipart user_config JSON

Description (problem / solution / changelog)

Fixes #26707

Summary

  • JSON-decode multipart user_config form fields before routing sees them
  • preserve existing metadata behavior and also handle litellm_metadata
  • decode top-level JSON-list tags while preserving plain string tag fields

Tests

  • /tmp/litellm-pr-venv/bin/python -m pytest tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py -k "form_data_with_json_user_config_and_tags or form_data_with_string_tag_is_preserved or form_data_with_invalid_json_user_config or form_data_with_json_metadata or form_data_with_invalid_json_metadata"
  • /tmp/litellm-pr-venv/bin/ruff check litellm/proxy/common_utils/http_parsing_utils.py
  • git diff --check

Changed files

  • litellm/proxy/common_utils/http_parsing_utils.py (modified, +13/-2)
  • tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py (modified, +81/-0)

Code Example

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"])

---

litellm_settings:
  drop_params: true

# intentionally empty — per-request user_config provides the model_list
model_list: []

---

litellm --config config.yaml --port 4000
# (or via Docker: ghcr.io/berriai/litellm:v1.81.0)

---

python -c "import base64,sys; sys.stdout.buffer.write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII='))" > tiny.png

---

curl -X POST http://localhost:4000/v1/images/edits \
  -H "Authorization: Bearer sk-1234" \
  -F "model=openai/gpt-image-1" \
  -F "prompt=test" \
  -F "[email protected]" \
  -F 'user_config={"model_list":[{"model_name":"openai/gpt-image-1","litellm_params":{"model":"openai/gpt-image-1","api_key":"sk-fake"}}]}'

---

curl -X POST http://localhost:4000/v1/images/generations \
  -H "Authorization: Bearer sk-1234" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-image-1",
    "prompt": "test",
    "user_config": {"model_list":[{"model_name":"openai/gpt-image-1","litellm_params":{"model":"openai/gpt-image-1","api_key":"sk-fake"}}]}
  }'

---

ERROR: common_request_processing.py:912 - litellm.proxy.proxy_server._handle_llm_api_exception():
Exception occured - litellm.router.Router() argument after ** must be a mapping, not str

Traceback (most recent call last):
  File "/usr/lib/python3.13/site-packages/litellm/proxy/image_endpoints/endpoints.py", line 288, in image_edit_api
    return await processor.base_process_llm_request(...)
  File "/usr/lib/python3.13/site-packages/litellm/proxy/common_request_processing.py", line 667, in base_process_llm_request
    llm_call = await route_request(...)
  File "/usr/lib/python3.13/site-packages/litellm/proxy/route_llm_request.py", line 200, in route_request
    user_router = litellm.Router(**router_config)
TypeError: litellm.router.Router() argument after ** must be a mapping, not str

The router_config at line 200 is the literal string '{"model_list":[...]}' — _read_request_body returned the form field as a string and route_request splatted it into Router().

Drop-in for the bug body

Use this as the "Actual behavior / log output" section:

**Actual response (500):**


{"error":{"message":"litellm.router.Router() argument after ** must be a mapping, not str","type":"None","param":"None","code":"500"}}


**Proxy traceback:**


TypeError: litellm.router.Router() argument after ** must be a mapping, not str

Traceback (most recent call last):
  File "litellm/proxy/image_endpoints/endpoints.py", line 288, in image_edit_api
    return await processor.base_process_llm_request(...)
  File "litellm/proxy/common_request_processing.py", line 667, in base_process_llm_request
    llm_call = await route_request(...)
  File "litellm/proxy/route_llm_request.py", line 200, in route_request
    user_router = litellm.Router(**router_config)


The crash happens because `_read_request_body`
(`litellm/proxy/common_utils/http_parsing_utils.py:40-43`) only JSON-decodes
the `metadata` field for multipart bodies. `user_config` arrives as a
JSON-shaped `str`, and `route_request` splats it directly into `Router()`.
RAW_BUFFERClick to expand / collapse

Check for existing issues

  • I have searched the existing issues and checked that my issue is not a duplicate.

What happened?

When sending a request to a multipart/form-data endpoint (e.g. POST /v1/images/edits) with a user_config form field whose value is a JSON string, the proxy does not JSON-decode the field. As a result user_config arrives downstream as a str rather than a dict, which then either:

  • crashes with litellm.router.Router() argument after ** must be a mapping, not str at route_llm_request.py:200 (Router(**user_config)), or
  • gets silently dropped during request setup, after which route_request falls through to the catch-all and raises ProxyModelNotFoundError("Invalid model name passed in model=...") at route_llm_request.py:324,

depending on the exact request path.

The same user_config payload works correctly when sent on a JSON-bodied endpoint (e.g. POST /v1/images/generations), because _read_request_body parses the entire JSON body and user_config arrives as a dict.

Root causelitellm/proxy/common_utils/http_parsing_utils.py, in the multipart branch of _read_request_body:

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"])

Only metadata gets JSON-decoded. user_config (and a top-level tags array, if sent) stay as strings.

Impact — proxies that rely on per-request user_config for routing (no static model_list) cannot use multipart endpoints at all. Image edits, audio transcriptions, and any future multipart endpoint inherit this gap.

Steps to Reproduce

1. Minimal proxy config (config.yaml) — empty model_list, so routing depends entirely on user_config:

litellm_settings:
  drop_params: true

# intentionally empty — per-request user_config provides the model_list
model_list: []

2. Start the proxy:

litellm --config config.yaml --port 4000
# (or via Docker: ghcr.io/berriai/litellm:v1.81.0)

3. Create a 1×1 PNG so the request is well-formed:

python -c "import base64,sys; sys.stdout.buffer.write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII='))" > tiny.png

4. Send a multipart POST /v1/images/edits with user_config as a JSON-string form field — same shape that works on /v1/images/generations over JSON:

curl -X POST http://localhost:4000/v1/images/edits \
  -H "Authorization: Bearer sk-1234" \
  -F "model=openai/gpt-image-1" \
  -F "prompt=test" \
  -F "[email protected]" \
  -F 'user_config={"model_list":[{"model_name":"openai/gpt-image-1","litellm_params":{"model":"openai/gpt-image-1","api_key":"sk-fake"}}]}'

5. Observe the failure. Depending on the path, one of:

  • 500 Internal Server Error with litellm.router.Router() argument after ** must be a mapping, not str, or
  • 400 Bad Request with /images/edits: Invalid model name passed in model=openai/gpt-image-1. Call /v1/models to view available models for your key.

6. (Sanity check that the same user_config works on a JSON endpoint) — send the equivalent to /v1/images/generations as JSON and it routes correctly:

curl -X POST http://localhost:4000/v1/images/generations \
  -H "Authorization: Bearer sk-1234" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-image-1",
    "prompt": "test",
    "user_config": {"model_list":[{"model_name":"openai/gpt-image-1","litellm_params":{"model":"openai/gpt-image-1","api_key":"sk-fake"}}]}
  }'

This call gets past routing (and would only fail later at the OpenAI call due to the fake key), proving the same user_config payload is valid — it just doesn't survive the multipart body parser.

Relevant log output

ERROR: common_request_processing.py:912 - litellm.proxy.proxy_server._handle_llm_api_exception():
Exception occured - litellm.router.Router() argument after ** must be a mapping, not str

Traceback (most recent call last):
  File "/usr/lib/python3.13/site-packages/litellm/proxy/image_endpoints/endpoints.py", line 288, in image_edit_api
    return await processor.base_process_llm_request(...)
  File "/usr/lib/python3.13/site-packages/litellm/proxy/common_request_processing.py", line 667, in base_process_llm_request
    llm_call = await route_request(...)
  File "/usr/lib/python3.13/site-packages/litellm/proxy/route_llm_request.py", line 200, in route_request
    user_router = litellm.Router(**router_config)
TypeError: litellm.router.Router() argument after ** must be a mapping, not str

The router_config at line 200 is the literal string '{"model_list":[...]}' — _read_request_body returned the form field as a string and route_request splatted it into Router().

Drop-in for the bug body

Use this as the "Actual behavior / log output" section:

**Actual response (500):**


{"error":{"message":"litellm.router.Router() argument after ** must be a mapping, not str","type":"None","param":"None","code":"500"}}


**Proxy traceback:**


TypeError: litellm.router.Router() argument after ** must be a mapping, not str

Traceback (most recent call last):
  File "litellm/proxy/image_endpoints/endpoints.py", line 288, in image_edit_api
    return await processor.base_process_llm_request(...)
  File "litellm/proxy/common_request_processing.py", line 667, in base_process_llm_request
    llm_call = await route_request(...)
  File "litellm/proxy/route_llm_request.py", line 200, in route_request
    user_router = litellm.Router(**router_config)


The crash happens because `_read_request_body`
(`litellm/proxy/common_utils/http_parsing_utils.py:40-43`) only JSON-decodes
the `metadata` field for multipart bodies. `user_config` arrives as a
JSON-shaped `str`, and `route_request` splats it directly into `Router()`.

What part of LiteLLM is this about?

Proxy

What LiteLLM version are you on ?

v1.81.0

Twitter / LinkedIn details

No response

extent analysis

TL;DR

The issue can be fixed by modifying the _read_request_body function in litellm/proxy/common_utils/http_parsing_utils.py to JSON-decode the user_config field for multipart bodies.

Guidance

  • Identify the _read_request_body function in litellm/proxy/common_utils/http_parsing_utils.py and modify it to decode the user_config field.
  • Add a condition to check if user_config is a string and JSON-decode it if necessary.
  • Verify that the user_config field is correctly decoded and passed to the route_request function.
  • Test the modified code with the provided example to ensure it works as expected.

Example

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"])
    if "user_config" in parsed_body and isinstance(parsed_body["user_config"], str):
        parsed_body["user_config"] = json.loads(parsed_body["user_config"])

Notes

This fix assumes that the user_config field is always a JSON string when sent in a multipart body. If this is not the case, additional error handling may be necessary.

Recommendation

Apply the workaround by modifying the _read_request_body function to decode the user_config field, as this will fix the issue without requiring an upgrade to a new version of LiteLLM.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

litellm - ✅(Solved) Fix [Bug]: user_config not JSON-decoded for multipart endpoints (e.g. /v1/images/edits) [1 pull requests, 1 participants]