fix: match OpenAI SDK wire format on image/video routes (#36493)

POST /v1/videos without an input_reference file now goes out as
multipart/form-data the way the OpenAI SDK always sends it, instead of a
JSON body that OpenAI-compatible backends (SGLang Diffusion, vLLM-Omni)
reject; gemini, vertex, and runwayml keep their JSON bodies

/v1/images/edits on the openai/azure/openai-compatible path now forwards
unknown provider params (e.g. seed) and honors extra_body, matching
/v1/images/generations, and aimage_edit forwards
extra_headers/extra_query/extra_body instead of dropping them

Generic pass-through no longer downgrades a file-less multipart form to
application/x-www-form-urlencoded
This commit is contained in:
mateo-berri 2026-08-24 11:08:21 -07:00
parent 4913b2a3ca
commit 3337a0a01f
10 changed files with 378 additions and 14 deletions

View file

@ -846,6 +846,15 @@ 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(non_default_params)
if isinstance(extra_body, dict):
image_edit_request_params.update(extra_body)
# Pre Call logging
litellm_logging_obj.update_from_kwargs(
kwargs=kwargs,
@ -995,6 +1004,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,47 @@
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 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
parts is still sent as multipart/form-data (httpx downgrades a file-less
``data=`` payload to application/x-www-form-urlencoded). Nested values are
flattened the way the OpenAI SDK serializes multipart bodies: dicts as
``key[subkey]``, lists as ``key[]``, booleans lowercased, None dropped.
"""
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
@ -7050,9 +7051,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,
@ -7060,9 +7059,14 @@ class BaseLLMHTTPHandler:
files=files,
timeout=timeout,
)
elif video_generation_provider_config.use_multipart_form_data():
response = sync_httpx_client.post(
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,
@ -7154,20 +7158,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(
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,102 @@
"""
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"
@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,36 @@
from litellm.litellm_core_utils.llm_request_utils import serialize_multipart_form_fields
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({}) == ()

View file

@ -26,6 +26,7 @@ from litellm.llms.custom_httpx.llm_http_handler import (
_has_pre_call_deployment_hook,
_rust_responses_websocket_enabled,
)
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 +2525,111 @@ 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_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():
"""