mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
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:
parent
527c094abe
commit
98acc01e0d
24 changed files with 638 additions and 18 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue