feat(videos): add first-class OpenRouter video request fields (#50319)

* feat(videos): add first-class OpenRouter video request fields

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(videos): remove recursive image parameter fallback

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(videos): preserve EdenAI native parameter passthrough

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(videos): deduplicate frame roles in linear time

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(videos): preserve image selection and dropped parameter semantics

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(videos): preserve provider scalar parameters and raw extra body

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(videos): retain accepted scalar inputs for Vertex and DashScope

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Mateo Wang 2026-11-06 16:50:48 -08:00 • committed by GitHub
parent 527c094abe
commit 98acc01e0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 638 additions and 18 deletions

View file

@ -7,11 +7,104 @@ from typing import Final
from pydantic import TypeAdapter, ValidationError
from litellm.exceptions import UnsupportedParamsError
from litellm.images.utils import ImageEditRequestUtils
from litellm.types.videos.main import VideoCreateOptionalRequestParams, VideoFrameImage, VideoInputReference
from litellm.types.videos.utils import decode_video_id_with_provider, id_safe_name
_FIELDS_ADAPTER: Final = TypeAdapter(dict[str, object])
_FILE_TUPLE_ADAPTER: Final = TypeAdapter(tuple[object, ...])
_FRAME_IMAGES_ADAPTER: Final = TypeAdapter(tuple[VideoFrameImage, ...])
_INPUT_REFERENCES_ADAPTER: Final = TypeAdapter(tuple[VideoInputReference, ...])
OPENROUTER_VIDEO_FIELDS: Final = frozenset(
{"duration", "aspect_ratio", "frame_images", "input_references", "generate_audio", "seed"}
)
def unsupported_video_fields(
params: VideoCreateOptionalRequestParams,
model: str,
provider: str,
drop_params: bool,
supported_native: frozenset[str] = frozenset(),
) -> frozenset[str]:
unsupported: Final = tuple(
key for key, value in params.items() if key in OPENROUTER_VIDEO_FIELDS - supported_native and value is not None
)
if unsupported and not drop_params:
raise UnsupportedParamsError(
message=f"{provider} video generation does not support {', '.join(unsupported)}, set drop_params=True to ignore them",
model=model,
llm_provider=provider,
)
return frozenset(unsupported)
def video_image_params(
params: Mapping[str, object], model: str, provider: str, drop_params: bool
) -> Mapping[str, object]:
frames: Final = params.get("frame_images")
references: Final = params.get("input_references")
conflict: Final = (
"content cannot be combined with frame_images or input_references"
if params.get("content") is not None and (frames is not None or references is not None)
else "frame_images cannot be combined with input_references or input_reference"
if frames is not None and (references is not None or params.get("input_reference") is not None)
else "input_references cannot be combined with input_reference"
if references is not None and params.get("input_reference") is not None
else None
)
if conflict is not None and not drop_params:
raise UnsupportedParamsError(
message=f"{conflict}, set drop_params=True to ignore it", model=model, llm_provider=provider
)
try:
parsed_frames: Final = _FRAME_IMAGES_ADAPTER.validate_python(() if frames is None else frames)
parsed_references: Final = _INPUT_REFERENCES_ADAPTER.validate_python(
() if references is None or frames is not None else references
)
except ValidationError as e:
if not drop_params:
raise UnsupportedParamsError(
message=f"Invalid video image items, set drop_params=True to ignore them. {e}",
model=model,
llm_provider=provider,
) from e
return _mapped_video_image_params(
{k: v for k, v in params.items() if k not in {"frame_images", "input_references"}},
(),
)
roles: Final = tuple(item["frame_type"] for item in parsed_frames)
first_indices: Final = {role: roles.index(role) for role in frozenset(roles)}
if len(roles) != len(frozenset(roles)) and not drop_params:
raise UnsupportedParamsError(
message="Duplicate frame_type, set drop_params=True to ignore it", model=model, llm_provider=provider
)
frame_items: Final = tuple(
{"type": item["type"], "image_url": item["image_url"], "role": item["frame_type"]}
for index, item in enumerate(parsed_frames)
if index == first_indices[item["frame_type"]]
)
reference_items: Final = tuple(
{"type": item["type"], "image_url": item["image_url"], "role": "reference_image"} for item in parsed_references
)
media: Final = frame_items if frames is not None else reference_items
return _mapped_video_image_params(params, media)
def _mapped_video_image_params(
params: Mapping[str, object], media: tuple[Mapping[str, object], ...]
) -> Mapping[str, object]:
frames: Final = params.get("frame_images")
references: Final = params.get("input_references")
excluded: Final[frozenset[str]] = frozenset({"frame_images", "input_references", "aspect_ratio"}) | (
frozenset({"content", "input_reference"}) if frames is not None or references is not None else frozenset[str]()
)
return {
**{key: value for key, value in params.items() if key not in excluded},
**({"ratio": params["aspect_ratio"]} if params.get("aspect_ratio") is not None else {}),
**({"content": list(media)} if frames is not None or references is not None else {}),
}
def _fields(value: object) -> Mapping[str, object] | None:

View file

@ -22,6 +22,7 @@ from litellm.llms.base_llm.videos.async_task_utils import (
input_reference_url,
polled_video_models,
routed_video_models,
unsupported_video_fields,
)
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
from litellm.llms.custom_httpx.http_handler import (
@ -558,6 +559,13 @@ class DashScopeVideoConfig(BaseVideoConfig):
model: str,
drop_params: bool,
) -> dict[str, object]: # mutable-ok: BaseVideoConfig contract
excluded_openrouter: Final = unsupported_video_fields(
video_create_optional_params,
model,
"dashscope",
drop_params,
frozenset({"duration", "seed", "aspect_ratio", "generate_audio"}),
)
unsupported: Final = next(
(
key
@ -578,7 +586,7 @@ class DashScopeVideoConfig(BaseVideoConfig):
{
key: value
for key, value in video_create_optional_params.items()
if value is not None and key not in _MAPPED_ELSEWHERE
if value is not None and key not in _MAPPED_ELSEWHERE and key not in excluded_openrouter
}
)
mapped_params: Final[dict[str, object]] = { # mutable-ok: BaseVideoConfig contract; extra_body merges into it

View file

@ -15,7 +15,7 @@ from httpx._types import RequestFiles
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.openai.videos.transformation import OpenAIVideoConfig
from litellm.types.router import GenericLiteLLMParams
from litellm.types.videos.main import VideoObject
from litellm.types.videos.main import VideoCreateOptionalRequestParams, VideoObject
from ..common_utils import EdenAIException, authorized_headers, endpoint_url, reported_cost
@ -36,6 +36,14 @@ def _usage_with_reported_cost(
class EdenAIVideoConfig(OpenAIVideoConfig):
def map_openai_params(
self,
video_create_optional_params: VideoCreateOptionalRequestParams,
model: str,
drop_params: bool,
) -> dict[str, object]: # mutable-ok: inherited BaseVideoConfig return contract
return dict(video_create_optional_params)
def validate_environment(
self,
headers: dict[str, object], # mutable-ok: inherited contract

View file

@ -12,6 +12,7 @@ from pydantic import TypeAdapter
from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.videos.async_task_utils import unsupported_video_fields
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
@ -304,6 +305,13 @@ class FalAIVideoConfig(BaseVideoConfig):
model: str,
drop_params: bool,
) -> _VideoParams:
unsupported: Final = unsupported_video_fields(
video_create_optional_params,
model,
"fal_ai",
drop_params,
frozenset({"generate_audio", "aspect_ratio", "seed", "duration"}),
)
supported_params: Final[frozenset[str]] = frozenset(self.get_supported_openai_params(model))
input_reference: Final[object] = video_create_optional_params.get("input_reference")
if "input_reference" in video_create_optional_params and not isinstance(input_reference, str):
@ -338,7 +346,11 @@ class FalAIVideoConfig(BaseVideoConfig):
**duration_params,
**size_params,
**user_params,
**{key: value for key, value in video_create_optional_params.items() if key not in supported_params},
**{
key: value
for key, value in video_create_optional_params.items()
if key not in supported_params and key not in unsupported
},
}
return mapped_params

View file

@ -10,6 +10,7 @@ import litellm
from litellm.constants import DEFAULT_GOOGLE_VIDEO_DURATION_SECONDS
from litellm.images.utils import ImageEditRequestUtils
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.base_llm.videos.async_task_utils import unsupported_video_fields
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
from litellm.llms.vertex_ai.videos.transformation import veo_video_count_from_parameters
from litellm.secret_managers.main import get_secret_str
@ -123,6 +124,13 @@ class GeminiVideoConfig(BaseVideoConfig):
All other params are passed through as-is to support Gemini-specific parameters.
"""
unsupported: Final = unsupported_video_fields(
video_create_optional_params,
model,
"gemini",
drop_params,
frozenset({"seed", "duration", "aspect_ratio", "generate_audio"}),
)
mapped_params: Final[dict[str, object]] = {}
# Get supported OpenAI params (exclude "model" and "prompt" which are handled separately)
@ -158,7 +166,7 @@ class GeminiVideoConfig(BaseVideoConfig):
# Pass through any other params that weren't mapped (Gemini-specific params)
for key, value in video_create_optional_params.items():
if key not in openai_params_to_map and key not in mapped_params:
if key not in openai_params_to_map and key not in mapped_params and key not in unsupported:
mapped_params[key] = value
return mapped_params

View file

@ -25,6 +25,7 @@ from litellm.litellm_core_utils.url_utils import (
safe_get,
)
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.videos.async_task_utils import video_image_params
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
@ -488,6 +489,9 @@ class MinimaxVideoConfig(BaseVideoConfig):
"aigc_watermark",
"extra",
"parameters",
"frame_images",
"input_references",
"aspect_ratio",
]
def map_openai_params(
@ -496,12 +500,20 @@ class MinimaxVideoConfig(BaseVideoConfig):
model: str,
drop_params: bool,
) -> dict[str, object]: # mutable-ok: BaseVideoConfig contract
requested: Final = video_image_params(video_create_optional_params, model, "minimax", drop_params)
mapped_params: Final[dict[str, object]] = {} # mutable-ok: BaseVideoConfig contract; extra_body merges into it
dropped_keys: Final = _DROP_FROM_CREATE_BODY | _additional_drop_keys(video_create_optional_params)
for key, value in video_create_optional_params.items():
for key, value in requested.items():
if value is None or key in dropped_keys:
continue
if key == "seconds":
if key == "seconds" and requested.get("duration") is not None:
if not drop_params and _duration_param(value, model) != _duration_param(requested["duration"], model):
raise UnsupportedParamsError(
message="duration and seconds must agree, set drop_params=True to ignore seconds",
model=model,
llm_provider="minimax",
)
elif key == "seconds":
mapped_params["duration"] = _duration_param(value, model)
elif key == "size":
mapped_params.setdefault("ratio", _ratio_from_size(value, model))

View file

@ -9,6 +9,7 @@ import httpx
from httpx._types import FileContent, FileTypes, RequestFiles
from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base
from litellm.llms.base_llm.videos.async_task_utils import unsupported_video_fields
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
from litellm.llms.openai.image_edit.transformation import ImageEditRequestUtils
from litellm.llms.openai.workload_identity import resolve_openai_bearer_token
@ -66,9 +67,16 @@ class OpenAIVideoConfig(BaseVideoConfig):
video_create_optional_params: VideoCreateOptionalRequestParams,
model: str,
drop_params: bool,
) -> dict:
) -> dict[str, object]:
"""No mapping applied since inputs are in OpenAI spec already"""
return dict(video_create_optional_params)
unsupported: Final = unsupported_video_fields(
video_create_optional_params,
model,
"openai",
drop_params,
frozenset({"seed", "duration", "aspect_ratio", "generate_audio"}),
)
return {key: value for key, value in video_create_optional_params.items() if key not in unsupported}
def validate_environment(
self,

View file

@ -11,6 +11,7 @@ import litellm
from litellm.constants import RUNWAYML_DEFAULT_API_VERSION
from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.videos.async_task_utils import unsupported_video_fields
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
@ -155,12 +156,23 @@ class RunwayMLVideoConfig(BaseVideoConfig):
- size -> ratio (convert "WIDTHxHEIGHT" to "WIDTH:HEIGHT")
- seconds -> duration (convert to integer)
"""
unsupported: Final = unsupported_video_fields(
video_create_optional_params,
model,
"runwayml",
drop_params,
frozenset({"seed", "duration", "aspect_ratio", "generate_audio"}),
)
supported_openai_params: Final = self.get_supported_openai_params(model)
return {
**self._prompt_image_param(video_create_optional_params),
**self._ratio_param(video_create_optional_params),
**self._duration_param(video_create_optional_params),
**{key: value for key, value in video_create_optional_params.items() if key not in supported_openai_params},
**{
key: value
for key, value in video_create_optional_params.items()
if key not in supported_openai_params and key not in unsupported
},
}
@staticmethod

View file

@ -20,6 +20,7 @@ from litellm.constants import DEFAULT_GOOGLE_VIDEO_DURATION_SECONDS
from litellm.images.utils import ImageEditRequestUtils
from litellm.litellm_core_utils.core_helpers import decoded_json_object
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.base_llm.videos.async_task_utils import unsupported_video_fields
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
from litellm.llms.vertex_ai.common_utils import (
convert_vertex_datetime_to_openai_datetime,
@ -198,6 +199,13 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase):
skipped if ``resolution`` is already set
- seconds → durationSeconds (defaults to 4 seconds if not provided)
"""
_ = unsupported_video_fields(
video_create_optional_params,
model,
"vertex_ai",
drop_params,
frozenset({"duration", "seed", "aspect_ratio", "generate_audio"}),
)
mapped_params: Final[dict[str, object]] = {}
# Map input_reference to image (will be processed in transform_video_create_request)

View file

@ -16,6 +16,7 @@ from litellm.llms.base_llm.videos.async_task_utils import (
input_reference_url,
polled_video_models,
routed_video_models,
video_image_params,
)
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
from litellm.llms.custom_httpx.http_handler import (
@ -546,6 +547,9 @@ class VolcEngineVideoConfig(BaseVideoConfig):
"extra_headers",
"content",
"parameters",
"frame_images",
"input_references",
"aspect_ratio",
*_CREATE_PASSTHROUGH_KEYS,
]
@ -555,7 +559,19 @@ class VolcEngineVideoConfig(BaseVideoConfig):
model: str,
drop_params: bool,
) -> dict[str, object]: # mutable-ok: BaseVideoConfig contract
requested: Final = _without(video_create_optional_params, _additional_drop_keys(video_create_optional_params))
requested: Final = video_image_params(
_without(video_create_optional_params, _additional_drop_keys(video_create_optional_params)),
model,
"volcengine",
drop_params,
)
if (
not drop_params
and requested.get("duration") is not None
and requested.get("seconds") is not None
and _duration_param(requested["duration"]) != _duration_param(requested["seconds"])
):
raise _droppable(model, "duration and seconds must agree")
unsupported: Final = next(
(key for key, value in requested.items() if key in _UNSUPPORTED_BY_VOLCENGINE and value is not None),
None,
@ -563,13 +579,17 @@ class VolcEngineVideoConfig(BaseVideoConfig):
if unsupported is not None and not drop_params:
raise _droppable(model, f"'{unsupported}' is not supported by Volcengine video generation")
passthrough: Final = MappingProxyType(
{key: value for key, value in requested.items() if value is not None and key not in _MAPPED_ELSEWHERE}
{
key: value
for key, value in requested.items()
if value is not None and key not in _MAPPED_ELSEWHERE and key != "duration"
}
)
return {
**_user_fields(requested.get("user"), model, drop_params),
**_size_fields(requested.get("size"), model, drop_params),
**_duration_fields(requested.get("seconds"), model, drop_params),
**passthrough,
**_duration_fields(requested.get("duration", requested.get("seconds")), model, drop_params),
**_parameters_fields(requested.get("parameters"), model, drop_params),
}

View file

@ -1,4 +1,5 @@
import builtins
from collections.abc import Sequence
from typing import Any, Literal
from openai.types.audio.transcription_create_params import FileTypes
@ -76,6 +77,19 @@ class VideoResponse(LiteLLMBaseModel):
return self.dict()
class VideoImageURL(TypedDict):
url: ReadOnly[str]
class VideoInputReference(TypedDict):
type: ReadOnly[Literal["image_url"]]
image_url: ReadOnly[VideoImageURL]
class VideoFrameImage(VideoInputReference):
frame_type: ReadOnly[Literal["first_frame", "last_frame"]]
class VideoCreateOptionalRequestParams(TypedDict, total=False):
"""
TypedDict for Optional parameters supported by OpenAI's video creation API.
@ -88,6 +102,12 @@ class VideoCreateOptionalRequestParams(TypedDict, total=False):
parameters: dict[str, object] | None # Provider-specific parameters block passed directly to the API
model: str | None
resolution: ReadOnly[str | None]
duration: ReadOnly[int]
aspect_ratio: ReadOnly[str]
frame_images: ReadOnly[Sequence[VideoFrameImage]]
input_references: ReadOnly[Sequence[VideoInputReference]]
generate_audio: ReadOnly[bool]
seed: ReadOnly[int]
seconds: str | None
size: str | None
characters: list[dict[str, str]] | None

View file

@ -2,6 +2,7 @@ from collections.abc import Mapping
from typing import Final, cast
import litellm
from litellm.llms.base_llm.videos.async_task_utils import OPENROUTER_VIDEO_FIELDS
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
from litellm.types.videos.main import VideoCreateOptionalRequestParams
from litellm.utils import filter_out_litellm_params
@ -92,7 +93,9 @@ class VideoGenerationRequestUtils:
merged_extra_body = filter_out_litellm_params(kwargs=merged_extra_body)
if merged_extra_body:
optional_params["extra_body"] = merged_extra_body
optional_params.update(merged_extra_body)
optional_params.update(
{key: value for key, value in merged_extra_body.items() if key not in OPENROUTER_VIDEO_FIELDS}
)
optional_params.pop("timeout", None)

View file

@ -1434,3 +1434,17 @@ class TestDashScopeVideoEndToEndRequest:
async def test_a_size_in_a_tier_the_model_lacks_never_reaches_dashscope(self):
with pytest.raises(UnsupportedParamsError, match="480P"):
await self._wire_request(model="dashscope/wan2.7-t2v", prompt="p", size="854x480")
@pytest.mark.parametrize("drop_params", [False, True])
def test_existing_scalar_parameters_remain_accepted(drop_params: bool) -> None:
params: Final = {"seed": 7, "duration": 6, "aspect_ratio": "9:16", "generate_audio": False}
assert DashScopeVideoConfig().map_openai_params(params, "wan3.0-video", drop_params) == params
@pytest.mark.parametrize("field", ["frame_images", "input_references"])
def test_first_class_video_fields_are_rejected_or_dropped(field: str) -> None:
config: Final = DashScopeVideoConfig()
with pytest.raises(UnsupportedParamsError, match=field):
config.map_openai_params({field: 1}, "video-model", False)
assert config.map_openai_params({field: 1}, "video-model", True) == config.map_openai_params({}, "video-model", True)

View file

@ -117,6 +117,9 @@ class TestCreate:
assert b'name="model"\r\n\r\n' + SELLER_MODEL.encode() in request.content
assert b'name="seconds"\r\n\r\n4' in request.content
def test_seed_zero_survives_drop_params(self):
assert EdenAIVideoConfig().map_openai_params({"seed": 0}, SELLER_MODEL, True) == {"seed": 0}
def test_a_reference_image_url_stays_in_the_json_body(self, eden_key, respx_mock):
respx_mock.post(EDEN_VIDEOS_URL).mock(return_value=httpx.Response(200, json=_eden_video()))

View file

@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from litellm.exceptions import UnsupportedParamsError
import litellm
import litellm.llms.fal_ai.videos.transformation as fal_video_module
@ -24,6 +25,14 @@ H3_TEXT_MODEL = "minimax/h3/text-to-video"
H3_REFERENCE_MODEL = "minimax/h3/reference-to-video"
@pytest.mark.parametrize("drop_params", [False, True])
def test_native_video_parameters_keep_precedence(drop_params: bool) -> None:
params: Final = {"aspect_ratio": "1:1", "seed": 0, "duration": 6}
assert FalAIVideoConfig().map_openai_params({"seconds": "5", "size": "1280x720", **params}, MODEL, drop_params) == {
"resolution": "720p", **params,
}
class TestFalAIVideoTransformation:
def setup_method(self):
self.config = FalAIVideoConfig()
@ -695,3 +704,11 @@ class TestFalAIVideoTransformation:
)
== 5 * row["output_cost_per_second_768p"]
)
@pytest.mark.parametrize("field", ["frame_images", "input_references"])
def test_first_class_video_fields_are_rejected_or_dropped(field: str) -> None:
config: Final = FalAIVideoConfig()
with pytest.raises(UnsupportedParamsError, match=field):
config.map_openai_params({field: 1}, "video-model", False)
assert config.map_openai_params({field: 1}, "video-model", True) == config.map_openai_params({}, "video-model", True)

View file

@ -6,9 +6,11 @@ import io
import json
import os
from unittest.mock import MagicMock, Mock, patch
from typing import Final
import httpx
import pytest
from litellm.exceptions import UnsupportedParamsError
from litellm.llms.gemini.videos.transformation import GeminiVideoConfig
from litellm.llms.openai.cost_calculation import video_generation_cost
@ -842,3 +844,17 @@ class TestGeminiVideoCostTracking:
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@pytest.mark.parametrize("drop_params", [False, True])
def test_scalar_provider_parameters_are_preserved(drop_params: bool) -> None:
params: Final = {"seed": 0, "duration": 6, "aspect_ratio": "1:1", "generate_audio": False}
assert GeminiVideoConfig().map_openai_params(params, "veo-3.1-generate-preview", drop_params) == params
@pytest.mark.parametrize("field", ["frame_images", "input_references"])
def test_first_class_video_fields_are_rejected_or_dropped(field: str) -> None:
config: Final = GeminiVideoConfig()
with pytest.raises(UnsupportedParamsError, match=field):
config.map_openai_params({field: 1}, "video-model", False)
assert config.map_openai_params({field: 1}, "video-model", True) == config.map_openai_params({}, "video-model", True)

View file

@ -6,7 +6,8 @@ import base64
import io
import json
import time
from typing import Final
from collections.abc import Mapping
from typing import Final, cast
from unittest.mock import Mock
import httpx
@ -24,6 +25,7 @@ from litellm.llms.minimax.videos.transformation import (
_video_url_from_task,
)
from litellm.types.router import GenericLiteLLMParams
from litellm.types.videos.main import VideoCreateOptionalRequestParams
from litellm.types.videos.utils import (
decode_video_id_with_provider,
encode_video_id_with_provider,
@ -327,6 +329,7 @@ class TestMinimaxVideoCreateRequest:
assert raised.value.status_code == 400
def test_text_inside_content_is_rejected_so_guardrails_cannot_be_bypassed(self):
"""Guardrails scan prompt; a text item smuggled into content would reach
MiniMax unscanned while a harmless prompt passed the check."""
@ -1316,3 +1319,106 @@ class TestMinimaxVideoInputBilling:
)
assert raised.value.status_code == 400
@pytest.mark.parametrize("role", ["first_frame", "last_frame", "reference_image"])
def test_openrouter_video_images_become_provider_content(role: str) -> None:
params: Final = cast(VideoCreateOptionalRequestParams, {
"duration": 8, "aspect_ratio": "9:16", "resolution": "768P",
**(
{"input_references": [{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}}]}
if role == "reference_image"
else {"frame_images": [{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}, "frame_type": role}]}
),
})
config: Final = MinimaxVideoConfig()
mapped: Final = config.map_openai_params(params, "MiniMax-H3", False)
body, _, _ = config.transform_video_create_request(
model="MiniMax-H3", prompt="a cat", api_base=API_BASE,
video_create_optional_request_params=mapped, litellm_params=GenericLiteLLMParams(), headers={},
)
assert body["content"] == [
{"type": "text", "text": "a cat"},
{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}, "role": role},
]
assert (body["duration"], body["ratio"]) == (8, "9:16")
assert not ({"frame_images", "input_references", "aspect_ratio"} & body.keys())
@pytest.mark.parametrize("other", [
{"content": [{"type": "image_url", "image_url": {"url": "https://example.com/raw.png"}, "role": "reference_image"}]},
{"input_references": [{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}}]},
{"input_reference": "https://example.com/old.png"},
{"frame_images": [
{"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "frame_type": "first_frame"},
{"type": "image_url", "image_url": {"url": "https://example.com/duplicate.png"}, "frame_type": "first_frame"},
]},
])
def test_openrouter_image_conflicts_raise_or_keep_first_frame(other: object) -> None:
params: Final = cast(VideoCreateOptionalRequestParams, {
"frame_images": [{"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "frame_type": "first_frame"}],
**cast(Mapping[str, object], other),
})
config: Final = MinimaxVideoConfig()
with pytest.raises(UnsupportedParamsError, match="drop_params"):
config.map_openai_params(params, "MiniMax-H3", False)
assert config.map_openai_params(params, "MiniMax-H3", True) == {
"content": [{"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "role": "first_frame"}],
}
@pytest.mark.parametrize("params", [{"seed": 0}, {"generate_audio": False}])
def test_openrouter_unsupported_audio_and_seed_raise_or_drop(params: VideoCreateOptionalRequestParams) -> None:
config: Final = MinimaxVideoConfig()
with pytest.raises(UnsupportedParamsError, match="does not support"):
config.map_openai_params(params, "MiniMax-H3", False)
assert config.map_openai_params(params, "MiniMax-H3", True) == {}
def test_openrouter_duration_conflict_raises_or_prefers_duration() -> None:
config: Final = MinimaxVideoConfig()
with pytest.raises(UnsupportedParamsError, match="duration and seconds"):
config.map_openai_params({"duration": 8, "seconds": "5"}, "MiniMax-H3", False)
assert config.map_openai_params({"duration": 8, "seconds": "5"}, "MiniMax-H3", True) == {"duration": 8}
assert config.map_openai_params({"duration": 8, "seconds": "8"}, "MiniMax-H3", False) == {"duration": 8}
@pytest.mark.parametrize("images", [[], [{"type": "image_url", "image_url": {}}]])
def test_openrouter_empty_and_invalid_frames(images: object) -> None:
params: Final = cast(VideoCreateOptionalRequestParams, {"frame_images": images})
config: Final = MinimaxVideoConfig()
if images:
with pytest.raises(UnsupportedParamsError, match="Invalid video image"):
config.map_openai_params(params, "MiniMax-H3", False)
assert config.map_openai_params(params, "MiniMax-H3", True) == {}
else:
assert config.map_openai_params(params, "MiniMax-H3", False) == {"content": []}
@pytest.mark.parametrize("references", [[], [{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}}]])
def test_plural_and_singular_reference_conflict_raises_or_prefers_plural(references: object) -> None:
config: Final = MinimaxVideoConfig()
params: Final = cast(VideoCreateOptionalRequestParams, {
"input_references": references, "input_reference": "https://example.com/first.png",
})
with pytest.raises(UnsupportedParamsError, match="input_references cannot be combined"):
config.map_openai_params(params, "MiniMax-H3", False)
assert config.map_openai_params(params, "MiniMax-H3", True) == {
"content": [
{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}, "role": "reference_image"}
] if references else [],
}
def test_dropping_seconds_does_not_parse_the_discarded_value() -> None:
assert MinimaxVideoConfig().map_openai_params({"duration": 8, "seconds": "abc"}, "MiniMax-H3", True) == {"duration": 8}
@pytest.mark.parametrize("drop_params", [False, True])
def test_aspect_ratio_preserves_native_ratio_precedence_over_size(drop_params: bool) -> None:
config: Final = MinimaxVideoConfig()
assert config.map_openai_params(
{"size": "1280x720", "aspect_ratio": "9:16"}, "MiniMax-H3", drop_params,
) == {"ratio": "9:16"}
assert config.map_openai_params(
cast(VideoCreateOptionalRequestParams, {"size": "1280x720", "ratio": "9:16"}), "MiniMax-H3", drop_params,
) == {"ratio": "9:16"}

View file

@ -1,4 +1,9 @@
import io
from typing import Final
import pytest
from litellm.exceptions import UnsupportedParamsError
from litellm.llms.openai.videos.transformation import OpenAIVideoConfig
from litellm.types.router import GenericLiteLLMParams
@ -115,3 +120,17 @@ def test_video_edit_request_without_file_sends_json_id_reference():
assert url == "https://api.openai.com/v1/videos/edits"
assert data == {"prompt": "brighter", "video": {"id": "video_abc123"}}
assert files is None
@pytest.mark.parametrize("drop_params", [False, True])
def test_scalar_provider_parameters_are_preserved(drop_params: bool) -> None:
params: Final = {"seed": 0, "duration": 6, "aspect_ratio": "1:1", "generate_audio": False}
assert OpenAIVideoConfig().map_openai_params(params, "sora-2", drop_params) == params
@pytest.mark.parametrize("field", ["frame_images", "input_references"])
def test_first_class_video_fields_are_rejected_or_dropped(field: str) -> None:
config: Final = OpenAIVideoConfig()
with pytest.raises(UnsupportedParamsError, match=field):
config.map_openai_params({field: 1}, "video-model", False)
assert config.map_openai_params({field: 1}, "video-model", True) == config.map_openai_params({}, "video-model", True)

View file

@ -3,9 +3,11 @@ Tests for RunwayML video generation transformation.
"""
from unittest.mock import Mock
from typing import Final
import httpx
import pytest
from litellm.exceptions import UnsupportedParamsError
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.runwayml.videos.transformation import (
@ -17,6 +19,12 @@ from litellm.types.router import GenericLiteLLMParams
from litellm.types.videos.main import VideoObject
@pytest.mark.parametrize("drop_params", [False, True])
def test_native_video_parameters_keep_precedence(drop_params: bool) -> None:
params: Final = {"seed": 0, "duration": 6, "aspect_ratio": "1:1", "generate_audio": False}
assert RunwayMLVideoConfig().map_openai_params({"seconds": "5", **params}, "gen4_turbo", drop_params) == params
class TestRunwayMLVideoTransformation:
"""Test RunwayMLVideoConfig transformation class."""
@ -372,3 +380,11 @@ class TestRunwayMLVideoTransformation:
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@pytest.mark.parametrize("field", ["frame_images", "input_references"])
def test_first_class_video_fields_are_rejected_or_dropped(field: str) -> None:
config: Final = RunwayMLVideoConfig()
with pytest.raises(UnsupportedParamsError, match=field):
config.map_openai_params({field: 1}, "video-model", False)
assert config.map_openai_params({field: 1}, "video-model", True) == config.map_openai_params({}, "video-model", True)

View file

@ -6,11 +6,12 @@ import base64
import json
from collections.abc import Mapping
from pathlib import Path
from typing import cast
from typing import Final, cast
from unittest.mock import Mock, patch
import httpx
import pytest
from litellm.exceptions import UnsupportedParamsError
import litellm
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
@ -970,3 +971,17 @@ class TestImageAndParametersPassthrough:
assert "parameters" not in data["parameters"]
assert url.endswith(":predictLongRunning")
@pytest.mark.parametrize("drop_params", [False, True])
def test_existing_scalar_parameters_remain_accepted(drop_params: bool) -> None:
params: Final = {"seed": 7, "duration": 6, "aspect_ratio": "9:16", "generate_audio": False}
assert VertexAIVideoConfig().map_openai_params(params, "veo-3.1-generate-001", drop_params) == {}
@pytest.mark.parametrize("field", ["frame_images", "input_references"])
def test_first_class_video_fields_are_rejected_or_dropped(field: str) -> None:
config: Final = VertexAIVideoConfig()
with pytest.raises(UnsupportedParamsError, match=field):
config.map_openai_params({field: 1}, "video-model", False)
assert config.map_openai_params({field: 1}, "video-model", True) == config.map_openai_params({}, "video-model", True)

View file

@ -2,7 +2,8 @@ import base64
import io
import json
from pathlib import Path
from typing import Final
from collections.abc import Mapping
from typing import Final, cast
from unittest.mock import Mock
import httpx
@ -16,6 +17,7 @@ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.openai.cost_calculation import video_generation_cost
from litellm.llms.volcengine.videos.transformation import VolcEngineVideoConfig, VolcEngineVideoError
from litellm.types.router import GenericLiteLLMParams
from litellm.types.videos.main import VideoCreateOptionalRequestParams
from litellm.types.videos.utils import decode_video_id_with_provider, encode_video_id_with_provider
from litellm.utils import ProviderConfigManager
from litellm.videos.main import avideo_content, avideo_generation
@ -1129,3 +1131,129 @@ def test_a_video_created_through_a_routing_group_is_polled_on_the_member_that_cr
hosts = [call.request.url.host for call in respx.calls]
assert hosts[1::2] == hosts[0::2]
@pytest.mark.parametrize("role", ["first_frame", "last_frame", "reference_image"])
def test_openrouter_video_images_become_provider_content(role: str) -> None:
params: Final = cast(VideoCreateOptionalRequestParams, {
"duration": 8, "aspect_ratio": "9:16", "resolution": "720p", "seed": 0, "generate_audio": False,
**(
{"input_references": [{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}}]}
if role == "reference_image"
else {"frame_images": [{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}, "frame_type": role}]}
),
})
config: Final = VolcEngineVideoConfig()
mapped: Final = config.map_openai_params(params, SEEDANCE_2_5, False)
body, _, _ = config.transform_video_create_request(
model=SEEDANCE_2_5, prompt="a cat", api_base=API_BASE,
video_create_optional_request_params=mapped, litellm_params=GenericLiteLLMParams(), headers={},
)
assert body["content"] == [
{"type": "text", "text": "a cat"},
{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}, "role": role},
]
assert (body["duration"], body["ratio"]) == (8, "9:16")
assert not ({"frame_images", "input_references", "aspect_ratio"} & body.keys())
@pytest.mark.parametrize("other", [
{"content": [{"type": "image_url", "image_url": {"url": "https://example.com/raw.png"}, "role": "reference_image"}]},
{"input_references": [{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}}]},
{"input_reference": "https://example.com/old.png"},
{"frame_images": [
{"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "frame_type": "first_frame"},
{"type": "image_url", "image_url": {"url": "https://example.com/duplicate.png"}, "frame_type": "first_frame"},
]},
])
def test_openrouter_image_conflicts_raise_or_keep_first_frame(other: object) -> None:
params: Final = cast(VideoCreateOptionalRequestParams, {
"frame_images": [{"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "frame_type": "first_frame"}],
**cast(Mapping[str, object], other),
})
config: Final = VolcEngineVideoConfig()
with pytest.raises(UnsupportedParamsError, match="drop_params"):
config.map_openai_params(params, SEEDANCE_2_5, False)
assert config.map_openai_params(params, SEEDANCE_2_5, True) == {
"content": [{"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "role": "first_frame"}],
}
@pytest.mark.parametrize("roles", [("first_frame", "last_frame"), ("last_frame", "first_frame")])
def test_large_duplicate_frame_arrays_keep_first_images_in_input_order(roles: tuple[str, str]) -> None:
params: Final = cast(VideoCreateOptionalRequestParams, {
"frame_images": [
{"type": "image_url", "image_url": {"url": f"https://example.com/{index}.png"}, "frame_type": role}
for index, role in enumerate(roles * 5000)
],
})
assert VolcEngineVideoConfig().map_openai_params(params, SEEDANCE_2_5, True) == {
"content": [
{"type": "image_url", "image_url": {"url": f"https://example.com/{index}.png"}, "role": role}
for index, role in enumerate(roles)
],
}
def test_openrouter_duration_conflict_raises_or_prefers_duration() -> None:
config: Final = VolcEngineVideoConfig()
with pytest.raises(UnsupportedParamsError, match="duration and seconds"):
config.map_openai_params({"duration": 8, "seconds": "5"}, SEEDANCE_2_5, False)
assert config.map_openai_params({"duration": 8, "seconds": "5"}, SEEDANCE_2_5, True) == {"duration": 8}
assert config.map_openai_params({"duration": 8, "seconds": "8"}, SEEDANCE_2_5, False) == {"duration": 8}
@pytest.mark.parametrize("images", [[], [{"type": "image_url", "image_url": {}}]])
def test_openrouter_empty_and_invalid_frames(images: object) -> None:
params: Final = cast(VideoCreateOptionalRequestParams, {"frame_images": images})
config: Final = VolcEngineVideoConfig()
if images:
with pytest.raises(UnsupportedParamsError, match="Invalid video image"):
config.map_openai_params(params, SEEDANCE_2_5, False)
assert config.map_openai_params(params, SEEDANCE_2_5, True) == {}
else:
assert config.map_openai_params(params, SEEDANCE_2_5, False) == {"content": []}
def test_dropping_invalid_frames_preserves_raw_content_and_other_mappings() -> None:
content: Final = [{"type": "image_url", "image_url": {"url": "https://example.com/raw.png"}, "role": "first_frame"}]
params: Final = cast(VideoCreateOptionalRequestParams, {
"frame_images": [{"type": "image_url", "image_url": {}}],
"content": content,
"aspect_ratio": "16:9",
"duration": 8,
})
assert VolcEngineVideoConfig().map_openai_params(params, SEEDANCE_2_5, True) == {
"content": content,
"ratio": "16:9",
"duration": 8,
}
@pytest.mark.parametrize("references", [[], [{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}}]])
def test_plural_and_singular_reference_conflict_raises_or_prefers_plural(references: object) -> None:
config: Final = VolcEngineVideoConfig()
params: Final = cast(VideoCreateOptionalRequestParams, {
"input_references": references, "input_reference": "https://example.com/first.png",
})
with pytest.raises(UnsupportedParamsError, match="input_references cannot be combined"):
config.map_openai_params(params, SEEDANCE_2_5, False)
assert config.map_openai_params(params, SEEDANCE_2_5, True) == {
"content": [
{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}, "role": "reference_image"}
] if references else [],
}
def test_dropping_seconds_does_not_parse_the_discarded_value() -> None:
assert VolcEngineVideoConfig().map_openai_params({"duration": 8, "seconds": "abc"}, SEEDANCE_2_5, True) == {"duration": 8}
@pytest.mark.parametrize("drop_params", [False, True])
def test_aspect_ratio_preserves_native_ratio_precedence_over_size(drop_params: bool) -> None:
config: Final = VolcEngineVideoConfig()
assert config.map_openai_params(
{"size": "1280x720", "aspect_ratio": "9:16"}, SEEDANCE_2_5, drop_params,
) == {"ratio": "9:16", "resolution": "720p"}
assert config.map_openai_params(
cast(VideoCreateOptionalRequestParams, {"size": "1280x720", "ratio": "9:16"}), SEEDANCE_2_5, drop_params,
) == {"ratio": "9:16", "resolution": "720p"}

View file

@ -260,6 +260,23 @@ async def test_generation__route_type_data_and_no_provider_default(harness):
harness.batch_to_bytesio.assert_not_called()
@pytest.mark.asyncio
async def test_generation_preserves_first_class_video_fields(harness: Harness) -> None:
body: Final = {
"model": "queue-video/model",
"prompt": "a cat",
"duration": 8,
"aspect_ratio": "16:9",
"frame_images": [{"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "frame_type": "first_frame"}],
"input_references": [{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}}],
"generate_audio": False,
"seed": 0,
}
response: Final = await call_generation(harness, body=body)
assert response is SENTINEL
assert harness.processor_data() == body
@pytest.mark.asyncio
async def test_generation__input_reference_attached(harness):
body = {"model": "sora-2", "prompt": "a sunset"}

View file

@ -35,6 +35,8 @@ from dataclasses import dataclass
from typing import Any, Dict, Final
from unittest.mock import MagicMock, patch
from litellm.types.videos.main import VideoCreateOptionalRequestParams
import pytest
@ -439,18 +441,39 @@ def test_video_generation__custom_provider_map_base_video_config_is_dispatched(m
from litellm.llms.openai.videos.transformation import OpenAIVideoConfig
class QueueVideoConfig(OpenAIVideoConfig):
pass
def map_openai_params(
self, video_create_optional_params: VideoCreateOptionalRequestParams, model: str, drop_params: bool
) -> dict[str, object]:
return dict(video_create_optional_params)
video_config: Final = QueueVideoConfig()
monkeypatch.setattr(litellm, "custom_provider_map", [{"provider": "queue-video", "custom_handler": video_config}])
monkeypatch.setattr(litellm, "_custom_providers", ["queue-video"])
handler: Final = MagicMock(spec=BaseLLMHTTPHandler)
with patch.object(videos_main, "base_llm_http_handler", handler):
videos_main.video_generation(model="queue-video/seedance-1", prompt="a cat", api_base="http://backend.test")
videos_main.video_generation(
model="queue-video/seedance-1",
prompt="a cat",
api_base="http://backend.test",
duration=8,
aspect_ratio="16:9",
frame_images=[{"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "frame_type": "first_frame"}],
input_references=[{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}}],
generate_audio=False,
seed=0,
)
kw: Final = handler.video_generation_handler.call_args.kwargs
assert kw["video_generation_provider_config"] is video_config
assert kw["custom_llm_provider"] == "queue-video"
assert kw["model"] == "seedance-1"
assert kw["video_generation_optional_request_params"] == {
"duration": 8,
"aspect_ratio": "16:9",
"frame_images": [{"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "frame_type": "first_frame"}],
"input_references": [{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}}],
"generate_audio": False,
"seed": 0,
}
def test_video_generation__custom_provider_without_a_video_config_is_a_400(monkeypatch: pytest.MonkeyPatch) -> None:

View file

@ -10,15 +10,29 @@ production. Every test asserts the exact resulting dict, never "ran without erro
"""
from unittest.mock import MagicMock
from typing import Final
import pytest
import litellm
from litellm.exceptions import UnsupportedParamsError
from litellm.llms.volcengine.videos.transformation import VolcEngineVideoConfig
from litellm.llms.vertex_ai.videos.transformation import VertexAIVideoConfig
from litellm.videos.utils import VideoGenerationRequestUtils
get_requested = (
VideoGenerationRequestUtils.get_requested_video_generation_optional_param
)
@pytest.mark.parametrize("drop_params", [False, True])
def test_extra_body_video_fields_remain_raw(drop_params: bool) -> None:
extra: Final = {"seed": 0, "duration": 6, "aspect_ratio": "1:1", "generate_audio": False,
"frame_images": [], "input_references": []}
params: Final = get_requested({"extra_body": extra})
assert get_optional("veo-3.1-generate-001", VertexAIVideoConfig(), params, drop_params=drop_params) == extra
get_optional = VideoGenerationRequestUtils.get_optional_params_video_generation
@ -179,3 +193,23 @@ def test_optional__non_dict_extra_body_ignored():
)
assert result == {"seconds": "8"}
@pytest.mark.parametrize("extra_body", [
{"content": [{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}, "role": "reference_image"}]},
{"input_reference": "https://example.com/ref.png"},
])
def test_extra_body_overrides_mapped_images_after_dropping_conflicts(extra_body: dict[str, object]) -> None:
params: Final = get_requested({
"kwargs": {
"frame_images": [{"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "frame_type": "first_frame"}],
"extra_body": extra_body,
},
})
config: Final = VolcEngineVideoConfig()
with pytest.raises(UnsupportedParamsError, match="cannot be combined"):
get_optional("model", config, params)
assert get_optional("model", config, params, drop_params=True) == {
"content": [{"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "role": "first_frame"}],
**extra_body,
}