mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
3337a0a01f
commit
0322107414
4 changed files with 79 additions and 4 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue