diff --git a/litellm/images/main.py b/litellm/images/main.py index fd18edc66fb..e45adda2526 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -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( diff --git a/litellm/litellm_core_utils/llm_request_utils.py b/litellm/litellm_core_utils/llm_request_utils.py index 33b402789b3..5e822971e8f 100644 --- a/litellm/litellm_core_utils/llm_request_utils.py +++ b/litellm/litellm_core_utils/llm_request_utils.py @@ -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 diff --git a/tests/test_litellm/images/test_image_edit_extra_params.py b/tests/test_litellm/images/test_image_edit_extra_params.py index 46a5feb08a0..01490cdd988 100644 --- a/tests/test_litellm/images/test_image_edit_extra_params.py +++ b/tests/test_litellm/images/test_image_edit_extra_params.py @@ -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 diff --git a/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py b/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py index bd4f8943b47..0140d4ff232 100644 --- a/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py @@ -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"