diff --git a/litellm/llms/base_llm/videos/async_task_utils.py b/litellm/llms/base_llm/videos/async_task_utils.py index 507aefea6c6..ffc847127ea 100644 --- a/litellm/llms/base_llm/videos/async_task_utils.py +++ b/litellm/llms/base_llm/videos/async_task_utils.py @@ -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: diff --git a/litellm/llms/dashscope/videos/transformation.py b/litellm/llms/dashscope/videos/transformation.py index 59e42e7b926..c815e3790cf 100644 --- a/litellm/llms/dashscope/videos/transformation.py +++ b/litellm/llms/dashscope/videos/transformation.py @@ -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 diff --git a/litellm/llms/edenai/videos/transformation.py b/litellm/llms/edenai/videos/transformation.py index c9f53523acf..0cb9c5afaec 100644 --- a/litellm/llms/edenai/videos/transformation.py +++ b/litellm/llms/edenai/videos/transformation.py @@ -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 diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index 86aa6388ff0..c658dab4d3f 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -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 diff --git a/litellm/llms/gemini/videos/transformation.py b/litellm/llms/gemini/videos/transformation.py index 3f6b900fd01..65a81aeee0f 100644 --- a/litellm/llms/gemini/videos/transformation.py +++ b/litellm/llms/gemini/videos/transformation.py @@ -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 diff --git a/litellm/llms/minimax/videos/transformation.py b/litellm/llms/minimax/videos/transformation.py index 7d458cb8877..aff99542d9b 100644 --- a/litellm/llms/minimax/videos/transformation.py +++ b/litellm/llms/minimax/videos/transformation.py @@ -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)) diff --git a/litellm/llms/openai/videos/transformation.py b/litellm/llms/openai/videos/transformation.py index 0560e1c2e3a..abd157a9a08 100644 --- a/litellm/llms/openai/videos/transformation.py +++ b/litellm/llms/openai/videos/transformation.py @@ -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, diff --git a/litellm/llms/runwayml/videos/transformation.py b/litellm/llms/runwayml/videos/transformation.py index ad8a24c1535..7ad76e62a96 100644 --- a/litellm/llms/runwayml/videos/transformation.py +++ b/litellm/llms/runwayml/videos/transformation.py @@ -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 diff --git a/litellm/llms/vertex_ai/videos/transformation.py b/litellm/llms/vertex_ai/videos/transformation.py index 0dd3a395b54..2b88861b79b 100644 --- a/litellm/llms/vertex_ai/videos/transformation.py +++ b/litellm/llms/vertex_ai/videos/transformation.py @@ -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) diff --git a/litellm/llms/volcengine/videos/transformation.py b/litellm/llms/volcengine/videos/transformation.py index a75a4185186..5af1080b9a0 100644 --- a/litellm/llms/volcengine/videos/transformation.py +++ b/litellm/llms/volcengine/videos/transformation.py @@ -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), } diff --git a/litellm/types/videos/main.py b/litellm/types/videos/main.py index 4d6f8fc6a30..b02633accb1 100644 --- a/litellm/types/videos/main.py +++ b/litellm/types/videos/main.py @@ -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 diff --git a/litellm/videos/utils.py b/litellm/videos/utils.py index b7b663e1e00..fa6296739e7 100644 --- a/litellm/videos/utils.py +++ b/litellm/videos/utils.py @@ -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) diff --git a/tests/unit/llms/dashscope/test_dashscope_video_transformation.py b/tests/unit/llms/dashscope/test_dashscope_video_transformation.py index c1b4157c8ab..654722ad3e5 100644 --- a/tests/unit/llms/dashscope/test_dashscope_video_transformation.py +++ b/tests/unit/llms/dashscope/test_dashscope_video_transformation.py @@ -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) diff --git a/tests/unit/llms/edenai/videos/test_edenai_video_transformation.py b/tests/unit/llms/edenai/videos/test_edenai_video_transformation.py index 360e4d07f24..0dd8245ee92 100644 --- a/tests/unit/llms/edenai/videos/test_edenai_video_transformation.py +++ b/tests/unit/llms/edenai/videos/test_edenai_video_transformation.py @@ -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())) diff --git a/tests/unit/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/unit/llms/fal_ai/videos/test_fal_ai_video_transformation.py index d4ad22ca5a0..2505d635375 100644 --- a/tests/unit/llms/fal_ai/videos/test_fal_ai_video_transformation.py +++ b/tests/unit/llms/fal_ai/videos/test_fal_ai_video_transformation.py @@ -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) diff --git a/tests/unit/llms/gemini/videos/test_gemini_video_transformation.py b/tests/unit/llms/gemini/videos/test_gemini_video_transformation.py index 1ac451d17db..7177e8e9c68 100644 --- a/tests/unit/llms/gemini/videos/test_gemini_video_transformation.py +++ b/tests/unit/llms/gemini/videos/test_gemini_video_transformation.py @@ -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) diff --git a/tests/unit/llms/minimax/videos/test_minimax_video_transformation.py b/tests/unit/llms/minimax/videos/test_minimax_video_transformation.py index 1405964134c..b512ef13d57 100644 --- a/tests/unit/llms/minimax/videos/test_minimax_video_transformation.py +++ b/tests/unit/llms/minimax/videos/test_minimax_video_transformation.py @@ -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"} diff --git a/tests/unit/llms/openai/videos/test_openai_video_transformation.py b/tests/unit/llms/openai/videos/test_openai_video_transformation.py index 89f52261cba..ec4e2ac8471 100644 --- a/tests/unit/llms/openai/videos/test_openai_video_transformation.py +++ b/tests/unit/llms/openai/videos/test_openai_video_transformation.py @@ -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) diff --git a/tests/unit/llms/runwayml/videos/test_runway_video_transformation.py b/tests/unit/llms/runwayml/videos/test_runway_video_transformation.py index afc8e7ec4a2..6b18ef5b4b1 100644 --- a/tests/unit/llms/runwayml/videos/test_runway_video_transformation.py +++ b/tests/unit/llms/runwayml/videos/test_runway_video_transformation.py @@ -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) diff --git a/tests/unit/llms/vertex_ai/videos/test_vertex_video_transformation.py b/tests/unit/llms/vertex_ai/videos/test_vertex_video_transformation.py index 5c90d54ae90..ecf131f8215 100644 --- a/tests/unit/llms/vertex_ai/videos/test_vertex_video_transformation.py +++ b/tests/unit/llms/vertex_ai/videos/test_vertex_video_transformation.py @@ -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) diff --git a/tests/unit/llms/volcengine/test_volcengine_video_transformation.py b/tests/unit/llms/volcengine/test_volcengine_video_transformation.py index 1fcf49eeb13..ead17753b81 100644 --- a/tests/unit/llms/volcengine/test_volcengine_video_transformation.py +++ b/tests/unit/llms/volcengine/test_volcengine_video_transformation.py @@ -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"} diff --git a/tests/unit/proxy/video_endpoints/test_endpoints.py b/tests/unit/proxy/video_endpoints/test_endpoints.py index ecc7bccfa06..967f8089c70 100644 --- a/tests/unit/proxy/video_endpoints/test_endpoints.py +++ b/tests/unit/proxy/video_endpoints/test_endpoints.py @@ -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"} diff --git a/tests/unit/videos/test_main.py b/tests/unit/videos/test_main.py index 76e9b062476..d43141bdea3 100644 --- a/tests/unit/videos/test_main.py +++ b/tests/unit/videos/test_main.py @@ -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: diff --git a/tests/unit/videos/test_utils.py b/tests/unit/videos/test_utils.py index 728644cdda5..ffcbde7381d 100644 --- a/tests/unit/videos/test_utils.py +++ b/tests/unit/videos/test_utils.py @@ -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, + }