From a703378915ccedeb49324c0c63fbf6d384cc17c6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:48:44 -0700 Subject: [PATCH] fix(images): forward scalar-array edit params as repeated multipart fields Flatten dict-backed multipart bodies so a scalar list becomes one field with a tuple value, which httpx emits as a repeated part per element, instead of collapsing to the last element under dict.update. Nested objects still flatten to key[subkey] like the OpenAI SDK, and the file-tuple video path is untouched. --- .../litellm_core_utils/llm_request_utils.py | 40 +++++++++++++++---- .../test_llm_request_utils.py | 35 ++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/llm_request_utils.py b/litellm/litellm_core_utils/llm_request_utils.py index 0575af3d6f7..c833d57b6a9 100644 --- a/litellm/litellm_core_utils/llm_request_utils.py +++ b/litellm/litellm_core_utils/llm_request_utils.py @@ -27,20 +27,46 @@ 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], ...]: +def _is_form_scalar(value: object) -> bool: + return value is not None and not isinstance(value, (Mapping, list, tuple)) + + +def _flatten_form_data_field(key: str, value: object) -> tuple[tuple[str, str | tuple[str, ...]], ...]: + if isinstance(value, Mapping): + return tuple( + item + for subkey, subvalue in value.items() + for item in _flatten_form_data_field(f"{key}[{subkey}]", subvalue) + ) + if isinstance(value, (list, tuple)): + if all(_is_form_scalar(entry) for entry in value): + serialized_fields: Final = tuple(field for entry in value if (field := _form_field_value(entry))) + return ((key, serialized_fields),) if serialized_fields else () + return tuple(item for entry in value for item in _flatten_form_data_field(f"{key}[]", entry)) + if value is None: + return () + serialized: Final = _form_field_value(value) + if not serialized: + return () + return ((key, serialized),) + + +def flatten_form_field_values(*sources: Mapping[str, object] | None) -> tuple[tuple[str, str | tuple[str, ...]], ...]: """ - Flatten JSON-shaped bodies into primitive ``(name, value)`` form fields the way the - OpenAI SDK serializes multipart bodies, applying ``sources`` in order so a later source - wins on a key collision under ``dict.update``. Lets provider params reach a multipart - request without handing the httpx encoder a nested value it rejects with - ``Invalid type for value``. + Flatten JSON-shaped bodies into ``(name, value)`` form fields for a ``dict``-backed + multipart body, applying ``sources`` in order so a later source wins on a key collision + under ``dict.update``. Nested objects become ``key[subkey]`` fields the way the OpenAI SDK + serializes them, so provider params reach a multipart request without handing the httpx + encoder a nested value it rejects with ``Invalid type for value``. A scalar list becomes a + single field carrying a tuple value, which httpx emits as one repeated part per element, so + every element survives instead of collapsing to the last under ``dict.update``. """ 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) + for pair in _flatten_form_data_field(top_key, top_value) ) 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 0140d4ff232..3a09702de45 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,9 +1,24 @@ +import httpx + from litellm.litellm_core_utils.llm_request_utils import ( flatten_form_field_values, serialize_multipart_form_fields, ) +def _multipart_field_names(data: dict) -> list[str]: + request = httpx.Request( + "POST", + "http://backend/v1/images/edits", + data=data, + files=[("image[]", ("in.png", b"stub", "image/png"))], + ) + request.read() + body = request.content.decode("utf-8", "replace") + prefix = 'Content-Disposition: form-data; name="' + return [line[len(prefix) : line.index('"', len(prefix))] for line in body.splitlines() if line.startswith(prefix)] + + def test_serialize_multipart_form_fields_flattens_like_the_openai_sdk(): fields = serialize_multipart_form_fields( { @@ -62,3 +77,23 @@ def test_flatten_form_field_values_later_source_wins_on_collision(): ("seed", "2"), ) assert dict(flatten_form_field_values({"seed": 1}, {"seed": 2}))["seed"] == "2" + + +def test_flatten_form_field_values_keeps_scalar_lists_as_repeated_fields(): + assert flatten_form_field_values( + {"loras": ["a", "b", "c"], "generation_config": {"tags": [1, 2]}, "seed": 42} + ) == ( + ("loras", ("a", "b", "c")), + ("generation_config[tags]", ("1", "2")), + ("seed", "42"), + ) + + +def test_flatten_form_field_values_scalar_list_survives_update_into_multipart(): + request_params: dict = {"model": "my-edit-model"} + request_params.update(flatten_form_field_values({"loras": ["style_a", "style_b"]})) + + names = _multipart_field_names(request_params) + + assert names.count("loras") == 2 + assert names.count("model") == 1