fix(images): flatten nested image-edit params to SDK multipart form

The openai/azure/compat image-edit funnel merged non_default_params and
extra_body straight into the multipart body, so a nested value (e.g.
extra_body={"metadata": {...}}) reached the httpx encoder and 500'd with
"Invalid type for value. Expected primitive type". Route the funnel through
a shared flattener that serializes nested values as OpenAI-SDK bracket fields
(key[subkey], lists as key[], bools lowercased, None/empty dropped), matching
the wire format of the rest of this fix.
This commit is contained in:
mateo-berri 2026-08-24 11:51:28 -07:00
parent 3337a0a01f
commit 0322107414
4 changed files with 79 additions and 4 deletions

View file

@ -19,6 +19,7 @@ from litellm.constants import request_timeout as DEFAULT_REQUEST_TIMEOUT
from litellm.exceptions import LiteLLMUnknownProvider
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.llm_request_utils import flatten_form_field_values
from litellm.litellm_core_utils.mock_functions import mock_image_generation
from litellm.llms.base_llm import BaseImageEditConfig, BaseImageGenerationConfig
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
@ -851,9 +852,12 @@ def image_edit(
or custom_llm_provider == "azure"
or custom_llm_provider in litellm.openai_compatible_providers
):
image_edit_request_params.update(non_default_params)
if isinstance(extra_body, dict):
image_edit_request_params.update(extra_body)
image_edit_request_params.update(
flatten_form_field_values(
non_default_params,
extra_body if isinstance(extra_body, dict) else None,
)
)
# Pre Call logging
litellm_logging_obj.update_from_kwargs(

View file

@ -27,6 +27,25 @@ def _flatten_form_field(key: str, value: object) -> tuple[tuple[str, str], ...]:
return ((key, serialized),)
def flatten_form_field_values(*sources: Mapping[str, object] | None) -> tuple[tuple[str, str], ...]:
"""
Flatten JSON-shaped bodies into primitive ``(name, value)`` form fields the
way the OpenAI SDK serializes multipart bodies: dicts as ``key[subkey]``,
lists as ``key[]``, booleans lowercased, None and empty values dropped.
Sources are applied in order, so a later source wins on a key collision when
fed to ``dict.update``. Used to funnel provider-specific params into a
multipart request without handing the httpx encoder a nested value it
rejects with ``Invalid type for value``.
"""
return tuple(
pair
for source in sources
if source is not None
for top_key, top_value in source.items()
for pair in _flatten_form_field(top_key, top_value)
)
def serialize_multipart_form_fields(data: Mapping[str, object]) -> tuple[tuple[str, tuple[None, str]], ...]:
"""
Encode a JSON-shaped body as httpx file-tuples so a request with no file

View file

@ -76,6 +76,30 @@ def test_image_edit_extra_body_takes_precedence_over_kwargs():
assert _multipart_text_fields(captured["content_type"], captured["body"])["seed"] == "7"
def test_image_edit_flattens_nested_provider_params():
"""A nested value in extra_body (or a nested unknown kwarg) must be
serialized as OpenAI-SDK bracket form fields (key[subkey]) rather than
handed to the httpx multipart encoder, which raises 'Invalid type for
value. Expected primitive type' on a dict and 500s the request."""
captured = {}
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_image_edit_request(captured))))
litellm.image_edit(
model="openai/gpt-image-1",
image=PNG_BYTES,
prompt="add a hat",
api_key="sk-test",
api_base="https://edit.example/v1",
client=client,
extra_body={"generation_config": {"steps": 30, "guidance": True}},
)
fields = _multipart_text_fields(captured["content_type"], captured["body"])
assert fields["generation_config[steps]"] == "30"
assert fields["generation_config[guidance]"] == "true"
assert "generation_config" not in fields
@pytest.mark.asyncio
async def test_aimage_edit_forwards_extra_body():
"""aimage_edit used to drop extra_headers/extra_query/extra_body when

View file

@ -1,4 +1,7 @@
from litellm.litellm_core_utils.llm_request_utils import serialize_multipart_form_fields
from litellm.litellm_core_utils.llm_request_utils import (
flatten_form_field_values,
serialize_multipart_form_fields,
)
def test_serialize_multipart_form_fields_flattens_like_the_openai_sdk():
@ -34,3 +37,28 @@ def test_serialize_multipart_form_fields_drops_empty_strings():
def test_serialize_multipart_form_fields_empty_body():
assert serialize_multipart_form_fields({}) == ()
def test_flatten_form_field_values_flattens_nested_and_drops_empty():
assert flatten_form_field_values(
{
"seed": 42,
"hd": True,
"size": None,
"prompt": "",
"generation_config": {"steps": 30, "guidance": True},
}
) == (
("seed", "42"),
("hd", "true"),
("generation_config[steps]", "30"),
("generation_config[guidance]", "true"),
)
def test_flatten_form_field_values_later_source_wins_on_collision():
assert flatten_form_field_values({"seed": 1}, None, {"seed": 2}) == (
("seed", "1"),
("seed", "2"),
)
assert dict(flatten_form_field_values({"seed": 1}, {"seed": 2}))["seed"] == "2"