mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
commit
a91cac7f6c
10 changed files with 553 additions and 14 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
147
tests/test_litellm/images/test_image_edit_extra_params.py
Normal file
147
tests/test_litellm/images/test_image_edit_extra_params.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue