Merge pull request #38104 from BerriAI/litellm_fix_36493_image_video_routes

fix: match OpenAI SDK wire format on image/video routes
This commit is contained in:
Mateo Wang 2026-08-24 13:17:03 -07:00 committed by GitHub
commit a91cac7f6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 553 additions and 14 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
@ -854,6 +855,18 @@ def image_edit(
additional_drop_params=kwargs.get("additional_drop_params"),
)
if (
custom_llm_provider == "openai"
or custom_llm_provider == "azure"
or custom_llm_provider in litellm.openai_compatible_providers
):
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(
kwargs=kwargs,
@ -1003,6 +1016,9 @@ async def aimage_edit(
response_format=response_format,
size=size,
user=user,
extra_headers=extra_headers,
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
custom_llm_provider=custom_llm_provider,
**kwargs,

View file

@ -1,8 +1,88 @@
from collections.abc import Mapping
from typing import Final
import litellm
def _form_field_value(value: object) -> str:
if value is True:
return "true"
if value is False:
return "false"
return str(value)
def _flatten_form_field(key: str, value: object) -> tuple[tuple[str, str], ...]:
if isinstance(value, Mapping):
return tuple(
item for subkey, subvalue in value.items() for item in _flatten_form_field(f"{key}[{subkey}]", subvalue)
)
if isinstance(value, (list, tuple)):
return tuple(item for entry in value for item in _flatten_form_field(f"{key}[]", entry))
if value is None:
return ()
serialized: Final = _form_field_value(value)
if not serialized:
return ()
return ((key, serialized),)
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 ``(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_data_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 OpenAI-SDK-style multipart file-tuples so a file-less
request is still sent as multipart/form-data, working around httpx downgrading a
file-less ``data=`` payload to application/x-www-form-urlencoded.
"""
return tuple(
(key, (None, serialized))
for top_key, top_value in data.items()
for key, serialized in _flatten_form_field(top_key, top_value)
)
def _ensure_extra_body_is_safe(extra_body: dict | None) -> dict | None:
"""
Ensure that the extra_body sent in the request is safe, otherwise users will see this error

View file

@ -91,6 +91,14 @@ class BaseVideoConfig(ABC):
raise ValueError("api_base is required")
return api_base
def use_multipart_form_data(self) -> bool:
"""
Whether video create requests without files must still be sent as
multipart/form-data (the encoding the OpenAI SDK always uses for
/videos), instead of falling back to JSON.
"""
return False
@abstractmethod
def transform_video_create_request(
self,

View file

@ -24,6 +24,7 @@ from litellm.litellm_core_utils.agentic_loop_settings import (
validated_max_agentic_loops,
)
from litellm.litellm_core_utils.asyncify import run_async_function
from litellm.litellm_core_utils.llm_request_utils import serialize_multipart_form_fields
from litellm.litellm_core_utils.realtime_errors import realtime_error_event, websocket_close_reason
from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
@ -7051,9 +7052,7 @@ class BaseLLMHTTPHandler:
)
try:
# Use JSON when no files, otherwise use form data with files
if files and len(files) > 0:
# Use multipart/form-data when files are present
response = sync_httpx_client.post(
url=api_base,
headers=headers,
@ -7061,9 +7060,14 @@ class BaseLLMHTTPHandler:
files=files,
timeout=timeout,
)
elif video_generation_provider_config.use_multipart_form_data():
response = sync_httpx_client.post( # rebind-ok: one of three mutually-exclusive branches
url=api_base,
headers=headers,
files=serialize_multipart_form_fields(data),
timeout=timeout,
)
else:
# Use JSON content type for POST requests without files
response = sync_httpx_client.post(
url=api_base,
headers=headers,
@ -7155,20 +7159,26 @@ class BaseLLMHTTPHandler:
)
try:
# Use JSON when no files, otherwise use form data with files
if files is None or len(files) == 0:
if files and len(files) > 0:
response = await async_httpx_client.post(
url=api_base,
headers=headers,
json=data,
data=data,
files=files,
timeout=timeout,
)
elif video_generation_provider_config.use_multipart_form_data():
response = await async_httpx_client.post( # rebind-ok: one of three mutually-exclusive branches
url=api_base,
headers=headers,
files=serialize_multipart_form_fields(data),
timeout=timeout,
)
else:
response = await async_httpx_client.post(
url=api_base,
headers=headers,
data=data,
files=files,
json=data,
timeout=timeout,
)

View file

@ -101,6 +101,9 @@ class OpenAIVideoConfig(BaseVideoConfig):
return f"{api_base.rstrip('/')}/videos"
def use_multipart_form_data(self) -> bool:
return True
def transform_video_create_request(
self,
model: str,

View file

@ -470,7 +470,10 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
``items()`` collapses duplicate keys to the last value. Files go out as a
list of ``(field_name, (filename, content, content_type))`` tuples and
repeated non-file fields are grouped into list values, both of which httpx
encodes as separate multipart parts.
encodes as separate multipart parts. A form with no file parts is sent
entirely through ``files`` as ``(field_name, (None, value))`` tuples,
because httpx downgrades a file-less ``data=`` payload to
application/x-www-form-urlencoded.
"""
form_items: Final = (await request.form()).multi_items()
@ -500,6 +503,11 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
)
}
multipart_files: Final = (
files if files else tuple((field_name, (None, field_value)) for field_name, field_value in non_file_items)
)
multipart_data: Final = form_data_dict if files else None
# Remove content-type header - httpx will set it correctly with the new boundary
# when it creates the multipart body from files/data parameters
headers_copy: Final = headers.copy()
@ -512,8 +520,8 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
url,
headers=headers_copy,
params=requested_query_params,
files=files,
data=form_data_dict,
files=multipart_files,
data=multipart_data,
)
return await async_client.send(req, stream=True)
@ -522,8 +530,8 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
url=url,
headers=headers_copy,
params=requested_query_params,
files=files,
data=form_data_dict,
files=multipart_files,
data=multipart_data,
)
@staticmethod

View file

@ -0,0 +1,147 @@
"""
Regression tests for https://github.com/BerriAI/litellm/issues/36493
/v1/images/edits on the openai path silently dropped unknown provider params
(e.g. seed) and the extra_body escape hatch, unlike /v1/images/generations.
"""
import httpx
import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
PNG_BYTES = b"\x89PNG\r\n\x1a\nfakepng"
def _capture_image_edit_request(captured):
def respond(request):
captured["content_type"] = request.headers.get("content-type")
captured["body"] = request.content
return httpx.Response(200, json={"created": 1712697600, "data": [{"b64_json": "aW1n"}]})
return respond
def _multipart_text_fields(content_type: str, body: bytes) -> dict:
boundary = content_type.split("boundary=")[1].encode()
return {
part.split(b'name="')[1].split(b'"')[0].decode(): part.partition(b"\r\n\r\n")[2].rstrip(b"\r\n-").decode()
for part in body.split(b"--" + boundary)
if b'name="' in part and b"filename=" not in part
}
def test_image_edit_forwards_provider_params_and_extra_body():
captured = {}
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_image_edit_request(captured))))
response = 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,
seed=42,
extra_body={"quality_level": "high"},
)
assert captured["content_type"].startswith("multipart/form-data")
fields = _multipart_text_fields(captured["content_type"], captured["body"])
assert fields["seed"] == "42"
assert fields["quality_level"] == "high"
assert "extra_body" not in fields
assert fields["model"] == "gpt-image-1"
assert fields["prompt"] == "add a hat"
assert b'name="image[]"' in captured["body"]
assert response.data
def test_image_edit_extra_body_takes_precedence_over_kwargs():
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,
seed=42,
extra_body={"seed": 7},
)
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
def test_image_edit_forwards_scalar_array_as_repeated_fields():
"""A list-valued provider param must reach the backend as one repeated part
per element, not collapse to its last element under dict.update."""
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,
loras=["style_a", "style_b", "style_c"],
)
body = captured["body"]
assert body.count(b'name="loras"') == 3
assert b"style_a" in body and b"style_b" in body and b"style_c" in body
@pytest.mark.asyncio
async def test_aimage_edit_forwards_extra_body():
"""aimage_edit used to drop extra_headers/extra_query/extra_body when
building its partial, so they never reached image_edit."""
captured = {}
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(_capture_image_edit_request(captured)))
response = await litellm.aimage_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,
seed=42,
extra_body={"quality_level": "high"},
)
fields = _multipart_text_fields(captured["content_type"], captured["body"])
assert fields["seed"] == "42"
assert fields["quality_level"] == "high"
assert "extra_body" not in fields
assert response.data

View file

@ -0,0 +1,99 @@
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(
{
"model": "sora-2",
"prompt": "a cat surfing",
"hd": True,
"watermark": False,
"seconds": 4,
"size": None,
"metadata": {"trace": {"id": "t1"}},
"characters": [{"id": "char_1", "name": "Mia"}, "solo"],
}
)
assert fields == (
("model", (None, "sora-2")),
("prompt", (None, "a cat surfing")),
("hd", (None, "true")),
("watermark", (None, "false")),
("seconds", (None, "4")),
("metadata[trace][id]", (None, "t1")),
("characters[][id]", (None, "char_1")),
("characters[][name]", (None, "Mia")),
("characters[]", (None, "solo")),
)
def test_serialize_multipart_form_fields_drops_empty_strings():
assert serialize_multipart_form_fields({"prompt": "", "model": "sora-2"}) == (("model", (None, "sora-2")),)
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"
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

View file

@ -26,6 +26,8 @@ from litellm.llms.custom_httpx.llm_http_handler import (
_has_pre_call_deployment_hook,
_rust_responses_websocket_enabled,
)
from litellm.llms.azure.videos.transformation import AzureVideoConfig
from litellm.llms.openai.videos.transformation import OpenAIVideoConfig
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import TranscriptionResponse
@ -2524,3 +2526,132 @@ def test_only_callbacks_that_can_charge_a_frame_are_collected_for_ws_quota(monke
monkeypatch.setattr(litellm, "callbacks", [plain, quota, decoy])
assert _collect_ws_project_quota_callbacks() == (quota,)
class _JSONBodyVideoConfig(OpenAIVideoConfig):
def use_multipart_form_data(self) -> bool:
return False
def _video_create_call_kwargs(config, **optional_params):
return {
"model": "sora-2",
"prompt": "a cat surfing",
"video_generation_provider_config": config,
"video_generation_optional_request_params": {"seconds": "4", **optional_params},
"custom_llm_provider": "openai",
"litellm_params": GenericLiteLLMParams(api_key="sk-test", api_base="https://video.example/v1"),
"logging_obj": Mock(),
"timeout": 10.0,
}
def _capture_video_create_request(captured):
def respond(request):
captured["content_type"] = request.headers.get("content-type")
captured["body"] = request.content
return httpx.Response(
200,
json={"id": "video_123", "object": "video", "status": "queued", "created_at": 1712697600, "model": "sora-2"},
)
return respond
def _multipart_text_fields(content_type: str, body: bytes) -> dict:
boundary = content_type.split("boundary=")[1].encode()
return {
part.split(b'name="')[1].split(b'"')[0].decode(): part.partition(b"\r\n\r\n")[2].rstrip(b"\r\n-").decode()
for part in body.split(b"--" + boundary)
if b'name="' in part and b"filename=" not in part
}
def test_video_generation_without_file_sends_multipart_form_data():
"""Regression for #36493: the OpenAI SDK always sends /videos requests as
multipart/form-data, so OpenAI-compatible backends (SGLang Diffusion,
vLLM-Omni) reject the JSON body LiteLLM used to send when no
input_reference file was attached."""
captured = {}
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(OpenAIVideoConfig()))
assert captured["content_type"].startswith("multipart/form-data")
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
"model": "sora-2",
"prompt": "a cat surfing",
"seconds": "4",
}
assert result.status == "queued"
@pytest.mark.asyncio
async def test_async_video_generation_without_file_sends_multipart_form_data():
captured = {}
client = AsyncHTTPHandler()
client.client = httpx.AsyncClient(transport=httpx.MockTransport(_capture_video_create_request(captured)))
result = await BaseLLMHTTPHandler().async_video_generation_handler(
client=client, **_video_create_call_kwargs(OpenAIVideoConfig())
)
assert captured["content_type"].startswith("multipart/form-data")
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
"model": "sora-2",
"prompt": "a cat surfing",
"seconds": "4",
}
assert result.status == "queued"
def test_azure_video_generation_without_file_sends_multipart_form_data():
"""AzureVideoConfig subclasses OpenAIVideoConfig, so it inherits the
file-less multipart behavior. Azure's /openai/v1/videos surface is
OpenAI-SDK-compatible (the SDK sends multipart there too), so this is
intentional; lock it so the inherited flip can't silently regress to JSON."""
assert AzureVideoConfig().use_multipart_form_data() is True
captured = {}
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(AzureVideoConfig()))
assert captured["content_type"].startswith("multipart/form-data")
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
"model": "sora-2",
"prompt": "a cat surfing",
"seconds": "4",
}
assert result.status == "queued"
def test_video_generation_json_provider_keeps_json_body():
captured = {}
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(_JSONBodyVideoConfig()))
assert captured["content_type"] == "application/json"
assert json.loads(captured["body"]) == {"model": "sora-2", "prompt": "a cat surfing", "seconds": "4"}
assert result.status == "queued"
def test_video_generation_with_input_reference_keeps_file_multipart():
captured = {}
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
result = BaseLLMHTTPHandler().video_generation_handler(
client=client,
**_video_create_call_kwargs(OpenAIVideoConfig(), input_reference=b"\x89PNG\r\n\x1a\nfakepng"),
)
assert captured["content_type"].startswith("multipart/form-data")
assert b'name="input_reference"' in captured["body"]
assert b'filename="input_reference.png"' in captured["body"]
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
"model": "sora-2",
"prompt": "a cat surfing",
"seconds": "4",
}
assert result.status == "queued"

View file

@ -186,6 +186,43 @@ async def test_make_multipart_http_request_forwards_repeated_fields():
assert call_args["data"] == {"other_parameter": ["xxx", "yyy"]}
@pytest.mark.asyncio
async def test_make_multipart_http_request_fileless_form_stays_multipart():
"""
Regression for #36493: a multipart form with no file parts was forwarded
through httpx's ``data=`` alone, which downgrades the request to
application/x-www-form-urlencoded. Every field must go through ``files``
as a ``(field_name, (None, value))`` tuple so httpx keeps the
multipart/form-data encoding the client sent.
"""
request = MagicMock(spec=Request)
request.method = "POST"
form_data = FormData([("prompt", "a cat surfing"), ("model", "sora-2"), ("seconds", "4")])
request.form = AsyncMock(return_value=form_data)
mock_response = MagicMock()
mock_response.status_code = 200
async_client = MagicMock()
async_client.request = AsyncMock(return_value=mock_response)
await HttpPassThroughEndpointHelpers.make_multipart_http_request(
request=request,
async_client=async_client,
url=httpx.URL("http://test.com"),
headers={},
requested_query_params=None,
)
call_args = async_client.request.call_args[1]
assert call_args["files"] == (
("prompt", (None, "a cat surfing")),
("model", (None, "sora-2")),
("seconds", (None, "4")),
)
assert call_args["data"] is None
@pytest.mark.asyncio
async def test_make_multipart_http_request_removes_content_type_header():
"""