From eb3cb3b1922f6018df63119082732748e16f99e7 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:10:40 +0800 Subject: [PATCH] fix(volcengine): bill every Seedance video at create and keep its polls on the deployment that made it Ark bills a task when it finishes while litellm bills it at create, so a request whose cost is not known then is rejected: Ark-picked length (duration -1), frames, a video input, and with it edit and extension. An omitted length is sent as 5s, since the 2.5 family otherwise lets Ark pick it and logged $0. Video ids now carry the requested and the served model group, read from litellm_metadata, so polls and downloads go back to the deployment that created the task. The routing and input_reference helpers move out of the DashScope config into a shared module both providers use. A download before the render finishes answers 400, unreadable or unmapped task bodies answer 502, malformed seconds, size, parameters and Ark fields raise unless drop_params is set, an unpriced resolution tier is refused, and the rendered frame table moves into the cost map. Co-Authored-By: AaronHowell <111272993+AaronHowell@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) --- .../llms/base_llm/videos/async_task_utils.py | 106 ++ .../llms/dashscope/videos/transformation.py | 102 +- litellm/llms/volcengine/__init__.py | 2 - litellm/llms/volcengine/videos/__init__.py | 1 - .../llms/volcengine/videos/transformation.py | 1038 ++++------- ...odel_prices_and_context_window_backup.json | 12 +- model_prices_and_context_window.json | 12 +- provider_endpoints_support.json | 3 +- .../test_volcengine_video_transformation.py | 1616 ++++++++--------- 9 files changed, 1256 insertions(+), 1636 deletions(-) create mode 100644 litellm/llms/base_llm/videos/async_task_utils.py diff --git a/litellm/llms/base_llm/videos/async_task_utils.py b/litellm/llms/base_llm/videos/async_task_utils.py new file mode 100644 index 00000000000..2a88b1d5f1e --- /dev/null +++ b/litellm/llms/base_llm/videos/async_task_utils.py @@ -0,0 +1,106 @@ +import base64 +from collections.abc import Mapping +from io import BufferedReader, BytesIO +from pathlib import Path, PurePath +from typing import Final + +from pydantic import TypeAdapter, ValidationError + +from litellm.images.utils import ImageEditRequestUtils +from litellm.types.videos.utils import decode_video_id_with_provider + +_FIELDS_ADAPTER: Final = TypeAdapter(dict[str, object]) +_FILE_TUPLE_ADAPTER: Final = TypeAdapter(tuple[object, ...]) + + +def _fields(value: object) -> Mapping[str, object] | None: + try: + return _FIELDS_ADAPTER.validate_python(value) + except ValidationError: + return None + + +def _logged_litellm_params(logging_obj: object) -> Mapping[str, object]: + return _fields(getattr(logging_obj, "litellm_params", None)) or {} + + +def _id_safe_name(name: object) -> str | None: + return name if isinstance(name, str) and name and ";" not in name else None + + +def _stamped_models(metadata: object) -> tuple[str, str | None] | None: + fields: Final = _fields(metadata) + if fields is None: + return None + requested: Final = _id_safe_name(fields.get("model_group")) + served: Final = _id_safe_name(fields.get("deployment_model_name")) + if requested is None: + return None + return requested, None if served is None or served == requested or "*" in served else served + + +def routed_video_models(logging_obj: object) -> tuple[str, str | None] | None: + """ + A later status or content request checks the key's model access against the model encoded in the id, and + routes by it. One upstream model name can be served by several deployments (regions, accounts), so only the + group that served the task can send its polls back to the deployment that created it, while a key may be + scoped to the alias, routing group or wildcard name it asked for instead. The id carries both names: the one + the router was asked for on this hop, and the group that served it when that differs and is not a wildcard. + The router stamps both into litellm_metadata, and metadata carries a copy only when the caller sent none. + """ + params: Final = _logged_litellm_params(logging_obj) + stamped: Final = (_stamped_models(params.get(key)) for key in ("litellm_metadata", "metadata")) + return next((models for models in stamped if models), None) + + +def polled_video_models(logging_obj: object) -> tuple[str | None, str | None]: + """ + The served group survives into the returned id only when the router sent this poll to it, so an edited id + cannot write any name it likes back. + """ + video_id: Final = _logged_litellm_params(logging_obj).get("video_id") + if not isinstance(video_id, str): + return None, None + decoded: Final = decode_video_id_with_provider(video_id) + served: Final = decoded.get("served_model") + routed: Final = routed_video_models(logging_obj) + return decoded.get("model_id") or None, served if routed is None or served in routed else None + + +def _file_content(file_obj: object) -> object: + if not isinstance(file_obj, tuple): + return file_obj + file_tuple: Final = _FILE_TUPLE_ADAPTER.validate_python(file_obj) + return file_tuple[1] if len(file_tuple) >= 2 else None + + +def input_reference_bytes(file_obj: object) -> bytes: + content: Final = _file_content(file_obj) + if isinstance(content, PurePath): + return Path(content).read_bytes() + if isinstance(content, (BytesIO, BufferedReader)): + current_position: Final = content.tell() + _ = content.seek(0) + data: Final = content.read() + _ = content.seek(current_position) + return data + if isinstance(content, bytes): + return content + if isinstance(content, bytearray): + return bytes(content) + read: Final = getattr(content, "read", None) + if callable(read): + read_data: Final[object] = read() + if isinstance(read_data, bytes): + return read_data + raise ValueError("input_reference must be a URL string, bytes, a path, a file object, or a file tuple") + + +def input_reference_url(image: object) -> str: + """A string is a URL the provider fetches itself and is never opened as a local path.""" + if isinstance(image, str): + return image + content: Final = input_reference_bytes(image) + content_type: Final = ImageEditRequestUtils.get_image_content_type(content) + encoded: Final = base64.b64encode(content).decode("utf-8") + return f"data:{content_type};base64,{encoded}" diff --git a/litellm/llms/dashscope/videos/transformation.py b/litellm/llms/dashscope/videos/transformation.py index 17b685a0e1a..1db91cbb127 100644 --- a/litellm/llms/dashscope/videos/transformation.py +++ b/litellm/llms/dashscope/videos/transformation.py @@ -3,13 +3,10 @@ DashScope (Alibaba Model Studio) async video task API: create, poll and download delete or remix endpoint, so those raise. """ -import base64 import json from collections.abc import Mapping from datetime import datetime -from io import BufferedReader, BytesIO from math import log -from pathlib import Path, PurePath from types import MappingProxyType from typing import TYPE_CHECKING, Annotated, Final @@ -19,9 +16,13 @@ from pydantic import BaseModel, Field, StrictBool, StrictInt, StrictStr, TypeAda import litellm from litellm.exceptions import UnsupportedParamsError -from litellm.images.utils import ImageEditRequestUtils from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.videos.async_task_utils import ( + input_reference_url, + polled_video_models, + routed_video_models, +) from litellm.llms.base_llm.videos.transformation import BaseVideoConfig from litellm.llms.custom_httpx.http_handler import ( _get_httpx_client, # pyright: ignore[reportPrivateUsage, reportUnknownVariableType] # house cached-client factory has no public alias and its stub leaves params untyped @@ -32,7 +33,6 @@ from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import ModelInfo from litellm.types.videos.main import VideoCreateOptionalRequestParams, VideoObject from litellm.types.videos.utils import ( - decode_video_id_with_provider, encode_video_id_with_provider, extract_original_video_id, ) @@ -86,7 +86,6 @@ _EMPTY_PARAMS: Final[dict[str, object]] = {} # mutable-ok: BaseVideoConfig cont _TASK_RESPONSE_ADAPTER: Final = TypeAdapter(_TaskResponse) _PARAMETERS_ADAPTER: Final = TypeAdapter(dict[str, object]) -_FILE_TUPLE_ADAPTER: Final = TypeAdapter(tuple[object, ...]) _STATUS_MAP: Final = MappingProxyType( { @@ -383,39 +382,6 @@ def _is_priced_tier(resolution: object, priced_tiers: tuple[str, ...]) -> bool: return isinstance(resolution, str) and resolution.strip().upper() in priced_tiers -def _read_all_bytes(file_obj: object) -> bytes: - if isinstance(file_obj, tuple): - file_tuple: Final = _FILE_TUPLE_ADAPTER.validate_python(file_obj) - return _read_all_bytes(file_tuple[1] if len(file_tuple) >= 2 else None) - if isinstance(file_obj, PurePath): - return Path(file_obj).read_bytes() - if isinstance(file_obj, (BytesIO, BufferedReader)): - current_position: Final = file_obj.tell() - file_obj.seek(0) - content: Final = file_obj.read() - file_obj.seek(current_position) - return content - if isinstance(file_obj, bytes): - return file_obj - if isinstance(file_obj, bytearray): - return bytes(file_obj) - read: Final = getattr(file_obj, "read", None) - if callable(read): - data: Final = read() - if isinstance(data, bytes): - return data - raise ValueError("input_reference must be a URL string, bytes, a path, a file object, or a file tuple") - - -def _image_url(image: object) -> str: - if isinstance(image, str): - return image - content: Final = _read_all_bytes(image) - content_type: Final = ImageEditRequestUtils.get_image_content_type(content) - encoded: Final = base64.b64encode(content).decode("utf-8") - return f"data:{content_type};base64,{encoded}" - - def _media_reference_type(model: str) -> str | None: """Models without the field predate the media array and take the image on the flat ``img_url`` field.""" info: Final = _dashscope_model_info(model) @@ -558,58 +524,6 @@ def _video_url_from_task(task: _TaskResponse, raw_response: httpx.Response) -> s raise _video_error(raw_response, 502, "Video URL not found in task response. The task may not have succeeded yet.") -def _logged_litellm_params(logging_obj: object) -> Mapping[str, object]: - litellm_params: Final[object] = getattr(logging_obj, "litellm_params", None) - try: - return _PARAMETERS_ADAPTER.validate_python(litellm_params) - except ValidationError: - return _EMPTY_PARAMS - - -def _polled_models(logging_obj: object) -> tuple[str | None, str | None]: - """ - The proxy routes by the models encoded in the polled id, so they must survive into the returned id. The served - group survives only when the router sent this poll to it, so an edited id cannot write any name it likes back. - """ - video_id: Final = _logged_litellm_params(logging_obj).get("video_id") - if not isinstance(video_id, str): - return None, None - decoded: Final = decode_video_id_with_provider(video_id) - served: Final = decoded.get("served_model") - routed: Final = _poll_models(logging_obj) - return decoded.get("model_id") or None, served if routed is None or served in routed else None - - -def _poll_models(logging_obj: object) -> tuple[str, str | None] | None: - """ - A later status or content request checks the key's model access against the model encoded in the id, and - routes by it. DashScope serves the same model name from several regional hosts, so only the group that - served the task can send its polls back to the region that created it, while a key may be scoped to the - alias, routing group or wildcard name it asked for instead. The id carries both names: the one the router - was asked for on this hop, and the group that served it when that differs and is not a wildcard. The router - stamps both into litellm_metadata, and metadata carries a copy only when the caller sent none. - """ - params: Final = _logged_litellm_params(logging_obj) - stamped: Final = (_stamped_poll_models(params.get(key)) for key in ("litellm_metadata", "metadata")) - return next((models for models in stamped if models), None) - - -def _stamped_poll_models(metadata: object) -> tuple[str, str | None] | None: - try: - fields: Final = _PARAMETERS_ADAPTER.validate_python(metadata) - except ValidationError: - return None - requested: Final = _id_safe_name(fields.get("model_group")) - served: Final = _id_safe_name(fields.get("deployment_model_name")) - if requested is None: - return None - return requested, None if served is None or served == requested or "*" in served else served - - -def _id_safe_name(name: object) -> str | None: - return name if isinstance(name, str) and name and ";" not in name else None - - class DashScopeVideoConfig(BaseVideoConfig): def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: BaseVideoConfig contract returns list return [ @@ -804,7 +718,7 @@ class DashScopeVideoConfig(BaseVideoConfig): input_reference: Final = video_create_optional_request_params.get("input_reference") if input_reference is None: return None, None - image_url: Final = _image_url(input_reference) + image_url: Final = input_reference_url(input_reference) media_type: Final = _media_reference_type(model) if media_type is None: return "img_url", image_url @@ -823,7 +737,7 @@ class DashScopeVideoConfig(BaseVideoConfig): requested: Final = self._requested_parameters(request_data) video_obj: Final = _video_object_from_task(task, raw_response, model=model, requested=requested) if custom_llm_provider and video_obj.id: - requested_model, served_model = _poll_models(logging_obj) or (model, None) + requested_model, served_model = routed_video_models(logging_obj) or (model, None) video_obj.id = encode_video_id_with_provider( video_obj.id, custom_llm_provider, requested_model, served_model ) @@ -869,7 +783,7 @@ class DashScopeVideoConfig(BaseVideoConfig): client: "HTTPHandler | None" = None, ) -> VideoObject: task: Final = _parse_task_response(raw_response) - model, served_model = _polled_models(logging_obj) + model, served_model = polled_video_models(logging_obj) video_obj: Final = _video_object_from_task(task, raw_response, model=model) if custom_llm_provider and video_obj.id: video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, model, served_model) diff --git a/litellm/llms/volcengine/__init__.py b/litellm/llms/volcengine/__init__.py index 932b6b1405c..0cd90940615 100644 --- a/litellm/llms/volcengine/__init__.py +++ b/litellm/llms/volcengine/__init__.py @@ -13,7 +13,6 @@ from .common_utils import ( ) from .embedding import VolcEngineEmbeddingConfig from .responses.transformation import VolcEngineResponsesAPIConfig -from .videos.transformation import VolcEngineVideoConfig # For backward compatibility, keep the old class name VolcEngineConfig: Final = VolcEngineChatConfig @@ -24,7 +23,6 @@ __all__ = [ "VolcEngineEmbeddingConfig", "VolcEngineError", "VolcEngineResponsesAPIConfig", - "VolcEngineVideoConfig", "get_volcengine_base_url", "get_volcengine_headers", ] diff --git a/litellm/llms/volcengine/videos/__init__.py b/litellm/llms/volcengine/videos/__init__.py index c593bda84fb..e69de29bb2d 100644 --- a/litellm/llms/volcengine/videos/__init__.py +++ b/litellm/llms/volcengine/videos/__init__.py @@ -1 +0,0 @@ -# Volcengine Ark video generation (Doubao-Seedance family, async task API) diff --git a/litellm/llms/volcengine/videos/transformation.py b/litellm/llms/volcengine/videos/transformation.py index cdd4ad97752..5eeb1433399 100644 --- a/litellm/llms/volcengine/videos/transformation.py +++ b/litellm/llms/volcengine/videos/transformation.py @@ -1,61 +1,42 @@ -"""Volcengine Ark (方舟) video generation, async task API. - -One endpoint family serves every Doubao-Seedance model: - -- ``POST /api/v3/contents/generations/tasks`` creates a task and returns - ``{"id": "cgt-..."}`` -- ``GET /api/v3/contents/generations/tasks/{id}`` polls it; once ``succeeded`` - the artifact sits at ``content.video_url``, a 24 hour signed TOS link -- ``GET /api/v3/contents/generations/tasks`` lists tasks -- ``DELETE /api/v3/contents/generations/tasks/{id}`` cancels a queued task or - removes a finished one - -The body is flat apart from a typed ``content`` array: the prompt is a ``text`` -item and every image/video/audio input is a url item tagged with a ``role`` -(``first_frame`` / ``last_frame`` / ``reference_image`` / ``reference_video`` / -``reference_audio``). Ark derives the task type from the roles present and -refuses to mix the frame roles with the reference roles, so ``content`` is the -only way to express reference-to-video, video editing and video extension. - -Ark bills per output token, not per second: ``tokens = width * height * fps * -duration / 1024`` at 24fps, which makes a per-second rate per resolution tier -exact for the 16:9 geometry Ark publishes. 1280x720 is 21600 tokens/s, so the -70 CNY per million tokens list rate for Seedance 2.5 is 1.512 CNY/s. -""" - -import base64 +import json from collections.abc import Mapping, Sequence -from io import BufferedReader, BytesIO from math import log from types import MappingProxyType -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, TypeVar import httpx from httpx._types import RequestFiles -from pydantic import BaseModel, TypeAdapter, ValidationError +from pydantic import BaseModel, StrictBool, StrictInt, StrictStr, TypeAdapter, ValidationError import litellm -from litellm.images.utils import ImageEditRequestUtils +from litellm.exceptions import UnsupportedParamsError from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.videos.async_task_utils import ( + input_reference_url, + polled_video_models, + routed_video_models, +) from litellm.llms.base_llm.videos.transformation import BaseVideoConfig from litellm.llms.custom_httpx.http_handler import ( + AsyncHTTPHandler, + HTTPHandler, _get_httpx_client, # pyright: ignore[reportPrivateUsage, reportUnknownVariableType] # house cached-client factory has no public alias and its stub leaves params untyped get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # factory stub leaves params untyped ) from litellm.llms.volcengine.common_utils import get_volcengine_base_url from litellm.secret_managers.main import get_secret_str from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import ModelInfo from litellm.types.videos.main import VideoCreateOptionalRequestParams, VideoObject -from litellm.types.videos.utils import ( - decode_video_id_with_provider, - encode_video_id_with_provider, - extract_original_video_id, -) +from litellm.types.videos.utils import encode_video_id_with_provider, extract_original_video_id +from litellm.utils import get_model_info if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging - from litellm.llms.custom_httpx.http_handler import HTTPHandler + + +_ParsedT: Final = TypeVar("_ParsedT") class VolcEngineVideoError(BaseLLMException): @@ -69,51 +50,66 @@ class _TaskError(BaseModel, frozen=True): class _TaskContent(BaseModel, frozen=True): video_url: str | None = None - last_frame_url: str | None = None class _TaskUsage(BaseModel, frozen=True): - completion_tokens: int | None = None total_tokens: int | None = None class _Task(BaseModel, frozen=True): - id: str = "" + id: str + status: str model: str | None = None - status: str = "" error: _TaskError | None = None content: _TaskContent | None = None usage: _TaskUsage | None = None - created_at: int | str | None = None - updated_at: int | str | None = None + created_at: int | None = None + updated_at: int | None = None resolution: str | None = None ratio: str | None = None - duration: int | str | None = None - frames: int | None = None - framespersecond: int | None = None + duration: int | float | None = None class _TaskCreateResponse(BaseModel, frozen=True): - id: str = "" + id: str | None = None error: _TaskError | None = None class _TaskList(BaseModel, frozen=True): - items: Sequence[_Task] | None = None + items: Sequence[_Task] = () total: int | None = None +class _ContentItem(BaseModel, frozen=True): + type: str | None = None + role: str | None = None + + +class _KnownFields(BaseModel, frozen=True): + resolution: StrictStr | None = None + ratio: StrictStr | None = None + seed: StrictInt | None = None + camera_fixed: StrictBool | None = None + watermark: StrictBool | None = None + generate_audio: StrictBool | None = None + return_last_frame: StrictBool | None = None + + VOLCENGINE_VIDEO_TASKS_PATH: Final = "/api/v3/contents/generations/tasks" +VOLCENGINE_VIDEO_DEFAULT_DURATION_SECONDS: Final = 5 _VOLCENGINE_API_MARKER: Final = "/api/" _VOLCENGINE_COMPAT_SUFFIXES: Final = ("/api", "/v1") +_FRAMES_FIELD: Final = "volcengine_video_frames" _EMPTY_PARAMS: Final[dict[str, object]] = {} # mutable-ok: BaseVideoConfig contract empty params _TASK_ADAPTER: Final = TypeAdapter(_Task) _TASK_CREATE_ADAPTER: Final = TypeAdapter(_TaskCreateResponse) _TASK_LIST_ADAPTER: Final = TypeAdapter(_TaskList) -_PARAMETERS_ADAPTER: Final = TypeAdapter(dict[str, object]) +_FIELDS_ADAPTER: Final = TypeAdapter(dict[str, object]) _CONTENT_ITEMS_ADAPTER: Final = TypeAdapter(tuple[dict[str, object], ...]) +_FRAMES_ADAPTER: Final = TypeAdapter(dict[str, dict[str, str]]) +_EMPTY_FRAMES: Final[Mapping[str, Mapping[str, str]]] = MappingProxyType({}) _STATUS_MAP: Final = MappingProxyType( { @@ -125,14 +121,13 @@ _STATUS_MAP: Final = MappingProxyType( "expired": "failed", } ) - +_PENDING_STATUSES: Final = frozenset({"queued", "running"}) _TERMINAL_STATUSES: Final = frozenset({"completed", "failed", "cancelled"}) _CREATE_PASSTHROUGH_KEYS: Final = ( "resolution", "ratio", "duration", - "frames", "seed", "camera_fixed", "watermark", @@ -149,166 +144,88 @@ _CREATE_PASSTHROUGH_KEYS: Final = ( "omni_reference_task_type", ) -_DROP_FROM_REQUEST: Final = frozenset( - { - "model", - "prompt", - "user", - "characters", - "image", - "extra_headers", - "extra_query", - "extra_body", - "content", - } -) +_REQUEST_METADATA: Final = frozenset({"model", "prompt", "user", "extra_headers", "extra_query", "extra_body"}) +_UNSUPPORTED_BY_VOLCENGINE: Final = frozenset({"characters", "image"}) +_MAPPED_ELSEWHERE: Final = frozenset({*_REQUEST_METADATA, *_UNSUPPORTED_BY_VOLCENGINE, "seconds", "size", "parameters"}) -_EDIT_DURATION_PARAM: Final = "duration" +_VIDEO_INPUT_UNSUPPORTED: Final = ( + "{operation} is not supported through litellm: Ark bills a task with a video input at a different rate per " + "token, and litellm bills the video when the task is created, before that cost is known." +) +_INPUT_VIDEO_ROLE: Final = "reference_video" +_INPUT_VIDEO_TYPE: Final = "video_url" _RATIO_SET: Final = ("16:9", "9:16", "4:3", "3:4", "1:1", "21:9") +_RESOLUTION_SHORTEST_SIDE: Final = MappingProxyType({"480p": 480, "720p": 720, "1080p": 1080, "4k": 2160}) +_RESOLUTION_HEIGHT_TIERS: Final[tuple[tuple[int, str], ...]] = ((600, "480p"), (900, "720p"), (1500, "1080p")) -_RESOLUTION_SHORTEST_SIDE: Final = MappingProxyType( - { - "480p": 480, - "720p": 720, - "1080p": 1080, - "2160p": 2160, - "4k": 2160, - } -) -_RESOLUTION_HEIGHT_TIERS: Final[tuple[tuple[int, str], ...]] = ( - (600, "480p"), - (900, "720p"), - (1500, "1080p"), -) +def _video_error(raw_response: httpx.Response, status_code: int, message: str) -> VolcEngineVideoError: + """The provider's own error status wins, and a body that failed on a 2xx answers the caller's status instead, + since a 2xx error has the proxy retry a call the provider already rejected.""" + return VolcEngineVideoError( + status_code=raw_response.status_code if raw_response.status_code >= 400 else status_code, + message=message, + headers=raw_response.headers, + ) -_DEFAULT_DURATION_SECONDS: Final = MappingProxyType( - { - "doubao-seedance-2-0": 5.0, - } -) -# Ark echoes the delivered geometry only as a resolution tier plus a ratio, and -# the frame it renders does not follow the tier's nominal pixel size: the 2.0 -# family's 16:9 "480p" canvas is 864x496, not the 854x480 the tier name implies. -# Values are the published frames from the Seedance 2.0 Mini model card -# (replicate.com/bytedance/seedance-2.0-mini/readme, 2026-10-13) and were all -# re-verified against the dev gateway with ffprobe at 480p across every ratio -# and at 720p for 16:9 and 4:3; the remaining 720p ratios are the card's. -# A (tier, ratio) that is absent falls back to the tier's nominal shortest side -# scaled up the ratio. -_DELIVERED_FRAME: Final = MappingProxyType( - { - "doubao-seedance-2-0": MappingProxyType( - { - ("480p", "16:9"): (864, 496), - ("480p", "4:3"): (752, 560), - ("480p", "1:1"): (640, 640), - ("480p", "3:4"): (560, 752), - ("480p", "9:16"): (496, 864), - ("480p", "21:9"): (992, 432), - ("720p", "16:9"): (1280, 720), - ("720p", "4:3"): (1112, 834), - ("720p", "1:1"): (960, 960), - ("720p", "3:4"): (834, 1112), - ("720p", "9:16"): (720, 1280), - ("720p", "21:9"): (1470, 630), - } - ), - "doubao-seedance-2-5": MappingProxyType( - { - ("480p", "16:9"): (854, 480), - ("480p", "4:3"): (752, 560), - ("480p", "1:1"): (640, 640), - ("480p", "3:4"): (560, 752), - ("480p", "9:16"): (480, 854), - ("480p", "21:9"): (992, 432), - ("720p", "16:9"): (1280, 720), - ("720p", "4:3"): (1112, 834), - ("720p", "1:1"): (960, 960), - ("720p", "3:4"): (834, 1112), - ("720p", "9:16"): (720, 1280), - ("720p", "21:9"): (1470, 630), - } - ), - } -) +def _parse(adapter: TypeAdapter[_ParsedT], raw_response: httpx.Response) -> _ParsedT: + try: + return adapter.validate_python(raw_response.json()) + except (json.JSONDecodeError, ValidationError) as e: + raise _video_error(raw_response, 502, f"Unexpected Volcengine video response: {e}") from e + + +def _unsupported(model: str, message: str) -> UnsupportedParamsError: + return UnsupportedParamsError(message=message, model=model, llm_provider="volcengine") + + +def _droppable(model: str, message: str) -> UnsupportedParamsError: + return _unsupported(model, f"{message}, set drop_params=True to ignore it") def _normalized_api_base(api_base: str) -> str: - """ - Normalize a Volcengine api_base to the Ark host root. - - ``get_volcengine_base_url`` is shared with the chat, responses and - Anthropic-compatible configs, so an api_base carries whichever surface the - caller uses: ``/api/v3`` for the native one, ``/api/compatible`` for the - Anthropic one, a bare host otherwise. The video paths carry their own - version segment, so the surface is cut at the first ``/api/`` marker before - the transform methods append theirs. - """ + """The Ark credential is shared with the chat, responses and Anthropic surfaces, so its api_base may carry any of + their paths, and the video paths carry their own version segment.""" trimmed: Final = api_base.rstrip("/") marker_index: Final = trimmed.find(_VOLCENGINE_API_MARKER) if marker_index != -1: return trimmed[:marker_index] - for suffix in _VOLCENGINE_COMPAT_SUFFIXES: - if trimmed.endswith(suffix): - return trimmed[: -len(suffix)] - return trimmed + suffix: Final = next((suffix for suffix in _VOLCENGINE_COMPAT_SUFFIXES if trimmed.endswith(suffix)), None) + return trimmed[: -len(suffix)] if suffix is not None else trimmed def _size_dimensions(size: str) -> tuple[int, int] | None: width_str, separator, height_str = size.partition("x") if not separator or not (width_str.isdigit() and height_str.isdigit()): return None - width: Final = int(width_str) - height: Final = int(height_str) - if not width or not height: + if not int(width_str) or not int(height_str): return None - return width, height - - -def _ratio_from_size(size: str) -> str | None: - """ - Snap an OpenAI ``size`` (``WIDTHxHEIGHT``) onto one of Ark's named ratios. - - Ark only accepts a fixed ratio set, and its own tiers do not reduce onto it - (854x480 is 427:240), so the closest ratio by aspect wins rather than the - gcd-reduced form, which Ark would reject. - """ - if ":" in size: - return size - dimensions: Final = _size_dimensions(size) - if dimensions is None: - return None - width, height = dimensions - requested: Final = width / height - return min(_RATIO_SET, key=lambda candidate: abs(log(requested / _ratio_value(candidate)))) + return int(width_str), int(height_str) def _ratio_value(ratio: str) -> float: width_str, _, height_str = ratio.partition(":") - if not (width_str.isdigit() and height_str.isdigit()) or not int(height_str): - return 0.0 return int(width_str) / int(height_str) -def _resolution_from_size(size: str) -> str | None: - """ - Pick the Ark resolution tier an OpenAI ``size`` falls in. +def _ratio_from_size(size: str) -> str | None: + """Ark only takes its named ratios, and its own tiers do not reduce onto them (854x480 is 427:240), so the + closest ratio wins.""" + dimensions: Final = _size_dimensions(size) + if dimensions is None: + return None + width, height = dimensions + return min(_RATIO_SET, key=lambda candidate: abs(log(width / height / _ratio_value(candidate)))) - Ark takes a tier name (``720p``) rather than a pixel size and bills per - second by that tier, so ``1280x720`` has to become ``720p`` instead of - being dropped and silently billed at whatever the model defaults to. - """ + +def _resolution_from_size(size: str) -> str | None: dimensions: Final = _size_dimensions(size) if dimensions is None: return None shortest_side: Final = min(dimensions) - return next( - (label for threshold, label in _RESOLUTION_HEIGHT_TIERS if shortest_side < threshold), - "4k", - ) + return next((label for threshold, label in _RESOLUTION_HEIGHT_TIERS if shortest_side < threshold), "4k") def _duration_param(seconds: object) -> int | None: @@ -316,168 +233,158 @@ def _duration_param(seconds: object) -> int | None: return None if isinstance(seconds, int): return seconds - if not isinstance(seconds, str): + if not isinstance(seconds, (str, float)): return None try: - return int(float(seconds)) + parsed: Final = float(seconds) except ValueError: return None + return int(parsed) if parsed.is_integer() else None -def _read_all_bytes(file_obj: object) -> bytes: - if isinstance(file_obj, (BytesIO, BufferedReader)): - current_position: Final = file_obj.tell() - file_obj.seek(0) - content: Final = file_obj.read() - file_obj.seek(current_position) - return content - if isinstance(file_obj, bytes): - return file_obj - if isinstance(file_obj, bytearray): - return bytes(file_obj) - read: Final = getattr(file_obj, "read", None) - if callable(read): - data: Final = read() - if isinstance(data, bytes): - return data - raise ValueError("input_reference must be a URL string, bytes, or a file object") +def _duration_fields(seconds: object, model: str, drop_params: bool) -> Mapping[str, object]: + if seconds is None: + return _EMPTY_PARAMS + duration: Final = _duration_param(seconds) + if duration is not None: + return MappingProxyType({"duration": duration}) + if drop_params: + return _EMPTY_PARAMS + raise _droppable(model, f"seconds must be a whole number of seconds, got {seconds!r}") -def _image_url(image: object) -> str: - if isinstance(image, str): - return image - content_type: Final = ImageEditRequestUtils.get_image_content_type(image) - encoded: Final = base64.b64encode(_read_all_bytes(image)).decode("utf-8") - return f"data:{content_type};base64,{encoded}" +def _size_fields(size: object, model: str, drop_params: bool) -> Mapping[str, object]: + if size is None: + return _EMPTY_PARAMS + ratio: Final = _ratio_from_size(size) if isinstance(size, str) else None + resolution: Final = _resolution_from_size(size) if isinstance(size, str) else None + if ratio is not None and resolution is not None: + return MappingProxyType({"ratio": ratio, "resolution": resolution}) + if drop_params: + return _EMPTY_PARAMS + raise _droppable(model, f"size must be WIDTHxHEIGHT, got {size!r}") -def _content_items(prompt: str, params: Mapping[str, object]) -> tuple[Mapping[str, object], ...]: - """ - Build Ark's typed ``content`` array. - - A caller-supplied ``content`` wins outright, since the roles are the only - way to express reference-to-video, edit and extension jobs; the prompt is - still prepended unless they already passed a text item. Otherwise the - prompt is the only text item and ``input_reference`` becomes the first - frame, which is the image-to-video shape. - """ - supplied: Final = params.get("content") - if supplied is not None: - try: - items: Final = _CONTENT_ITEMS_ADAPTER.validate_python(supplied) - except ValidationError as e: - raise ValueError("content must be an array of Ark content items") from e - if any(item.get("type") == "text" for item in items): - return items - return ({"type": "text", "text": prompt}, *items) - - reference: Final = params.get("input_reference") - if reference is None: - return ({"type": "text", "text": prompt},) - return ( - {"type": "text", "text": prompt}, - {"type": "image_url", "image_url": {"url": _image_url(reference)}, "role": "first_frame"}, - ) - - -def _parse_task(raw_response: httpx.Response) -> _Task: - return _TASK_ADAPTER.validate_python(raw_response.json()) - - -def _coerce_timestamp(value: int | str | None) -> int | None: - if isinstance(value, bool) or value is None: - return None - if isinstance(value, int): - return value +def _parameters_fields(parameters: object, model: str, drop_params: bool) -> Mapping[str, object]: + if parameters is None: + return _EMPTY_PARAMS try: - return int(float(value)) - except ValueError: - return None + return _FIELDS_ADAPTER.validate_python(parameters) + except ValidationError: + if drop_params: + return _EMPTY_PARAMS + raise _droppable( + model, f"parameters must be an object of Ark request fields, got {type(parameters).__name__}" + ) from None -def _coerce_duration(value: int | str | None) -> float | None: - if isinstance(value, bool) or value is None: - return None - if isinstance(value, int): - return float(value) +def _malformed_fields(fields: Mapping[str, object]) -> tuple[tuple[str, str], ...]: try: - return float(value) - except ValueError: + _ = _KnownFields.model_validate(dict(fields)) + except ValidationError as e: + return tuple((str(error["loc"][0]), error["msg"]) for error in e.errors() if error["loc"]) + return () + + +def _drops_params(litellm_params: GenericLiteLLMParams) -> bool: + return litellm_params.drop_params is True or litellm.drop_params is True + + +def _model_info(model: str) -> ModelInfo | None: + try: + return get_model_info(model=model, custom_llm_provider="volcengine") + except Exception: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models return None -def _scaled_side(shortest_side: int, ratio_side: int, shortest_ratio_side: int) -> int: - """ - Scale the tier's shortest side up the ratio, rounding up. - - Ark publishes 480p as 854x480 while 480 * 16 / 9 is 853.33, so truncating - reports a width the model never produces. - """ - return -(-shortest_side * ratio_side // shortest_ratio_side) - - -def _delivered_frame(model: str | None, tier: str, ratio: str) -> tuple[int, int] | None: - if not model: +def _priced_tiers(model: str) -> tuple[str, ...] | None: + """A tier the model has no price for is one Ark does not offer it at, and litellm would bill it at the base rate. + A model missing from the cost map has nothing to check against.""" + info: Final = _model_info(model) + if info is None: return None - for prefix, frames in _DELIVERED_FRAME.items(): - if model.startswith(prefix): - return frames.get((tier, ratio)) - return None + return tuple(tier for tier in _RESOLUTION_SHORTEST_SIDE if info.get(f"output_cost_per_second_{tier}") is not None) + + +def _delivered_frames(model: str | None) -> Mapping[str, Mapping[str, str]]: + info: Final = _model_info(model) if model else None + provider_specific: Final[object] = info.get("provider_specific_entry") if info is not None else None + frames: Final[object] = provider_specific.get(_FRAMES_FIELD) if isinstance(provider_specific, Mapping) else None + try: + return _FRAMES_ADAPTER.validate_python(frames) if frames is not None else _EMPTY_FRAMES + except ValidationError: + return _EMPTY_FRAMES def _size_from_geometry(resolution: str | None, ratio: str | None, model: str | None) -> str | None: - """ - Reconstruct an OpenAI ``WIDTHxHEIGHT`` size from Ark's echoed geometry. - - Ark reports the delivered geometry as a resolution tier (``720p``, the - shortest side in pixels) plus a ratio, so the two are recombined rather - than reporting the bare tier as a pixel size. Models whose rendered frame - does not follow the tier's nominal pixel size (see ``_DELIVERED_FRAME``) - are served from that table instead. - """ + """Ark echoes a tier and a ratio rather than pixels, and its rendered frame does not always follow the tier's + nominal size, so the frame listed for the model wins over scaling the tier's shortest side up the ratio.""" if not resolution or not ratio or ":" not in ratio: return None tier: Final = resolution.strip().lower() normalized_ratio: Final = ratio.strip() - measured: Final = _delivered_frame(model, tier, normalized_ratio) - if measured is not None: - return f"{measured[0]}x{measured[1]}" + listed: Final = _delivered_frames(model).get(tier, MappingProxyType({})).get(normalized_ratio) + if listed is not None: + return listed shortest_side: Final = _RESOLUTION_SHORTEST_SIDE.get(tier) - if shortest_side is None: - return None width_str, _, height_str = normalized_ratio.partition(":") - if not (width_str.isdigit() and height_str.isdigit()): + if shortest_side is None or not (width_str.isdigit() and height_str.isdigit()): return None ratio_width: Final = int(width_str) ratio_height: Final = int(height_str) if not ratio_width or not ratio_height: return None shortest_ratio_side: Final = min(ratio_width, ratio_height) - width: Final = _scaled_side(shortest_side, ratio_width, shortest_ratio_side) - height: Final = _scaled_side(shortest_side, ratio_height, shortest_ratio_side) - return f"{width}x{height}" + return f"{-(-shortest_side * ratio_width // shortest_ratio_side)}x{-(-shortest_side * ratio_height // shortest_ratio_side)}" -def _size_from_task(task: _Task) -> str | None: - return _size_from_geometry(task.resolution, task.ratio, task.model) +def _content_items(prompt: str, params: Mapping[str, object]) -> tuple[Mapping[str, object], ...]: + """A caller-supplied ``content`` array wins, since its roles are the only way to express reference-to-video. The + prompt is prepended unless the array already has a text item.""" + supplied: Final = params.get("content") + text_item: Final = MappingProxyType({"type": "text", "text": prompt}) + if supplied is not None: + items: Final = _CONTENT_ITEMS_ADAPTER.validate_python(supplied) + return items if any(item.get("type") == "text" for item in items) else (text_item, *items) + reference: Final = params.get("input_reference") + if reference is None: + return (text_item,) + first_frame: Final = MappingProxyType( + {"type": "image_url", "image_url": {"url": input_reference_url(reference)}, "role": "first_frame"} + ) + return (text_item, first_frame) -def _video_usage(task: _Task) -> Mapping[str, object]: - """ - Build the usage block the video cost calculator reads. +def _has_input_video(items: tuple[Mapping[str, object], ...]) -> bool: + entries: Final = tuple(_ContentItem.model_validate(dict(item)) for item in items) + return any(entry.type == _INPUT_VIDEO_TYPE or entry.role == _INPUT_VIDEO_ROLE for entry in entries) - ``duration`` and ``resolution`` are Ark's own delivered values, which is - what the per-second tiers have to be applied to: ``duration: -1`` and - ``ratio: adaptive`` both let the service deliver something other than what - was asked for. ``total_tokens`` is carried through because Ark bills by - token, so it is the figure to reconcile an invoice against. - """ + +def _requested_usage(request_data: Mapping[str, object]) -> Mapping[str, object]: + """The create call is the only billed one and Ark's create response carries no usage, so the requested duration + and tier are what the video bills at.""" + duration: Final = _duration_param(request_data.get("duration")) + resolution: Final = request_data.get("resolution") return MappingProxyType( { key: value for key, value in ( - ("duration_seconds", _coerce_duration(task.duration)), - ("video_resolution", task.resolution.strip().lower() if task.resolution else None), + ("duration_seconds", float(duration) if duration is not None and duration > 0 else None), + ("video_resolution", resolution.strip().lower() if isinstance(resolution, str) else None), + ) + if value is not None + } + ) + + +def _task_usage(task: _Task) -> Mapping[str, object]: + resolution: Final = task.resolution.strip().lower() if task.resolution else None + return MappingProxyType( + { + key: value + for key, value in ( + ("duration_seconds", float(task.duration) if task.duration is not None else None), + ("video_resolution", resolution), ("total_tokens", task.usage.total_tokens if task.usage is not None else None), ) if value is not None @@ -485,9 +392,16 @@ def _video_usage(task: _Task) -> Mapping[str, object]: ) -def _video_object_from_task(task: _Task) -> VideoObject: - status: Final = _STATUS_MAP.get(task.status.strip().lower(), "failed") - usage: Final = _video_usage(task) +def _task_status(task: _Task, raw_response: httpx.Response) -> str: + status: Final = _STATUS_MAP.get(task.status.strip().lower()) + if status is None: # defaulting it would hand back a job the caller polls forever + raise _video_error(raw_response, 502, f"Unknown Volcengine task status: {task.status}") + return status + + +def _video_object_from_task(task: _Task, raw_response: httpx.Response, model: str | None) -> VideoObject: + status: Final = _task_status(task, raw_response) + usage: Final = _task_usage(task) seconds: Final = usage.get("duration_seconds") error: Final[dict[str, object] | None] = ( # mutable-ok: VideoObject.error is a dict field {key: value for key, value in (("code", task.error.code), ("message", task.error.message)) if value is not None} @@ -498,185 +412,39 @@ def _video_object_from_task(task: _Task) -> VideoObject: id=task.id, object="video", status=status, - created_at=_coerce_timestamp(task.created_at), - completed_at=_coerce_timestamp(task.updated_at) if status in _TERMINAL_STATUSES else None, + created_at=task.created_at, + completed_at=task.updated_at if status in _TERMINAL_STATUSES else None, error=error, seconds=str(seconds) if seconds is not None else None, - size=_size_from_task(task), - model=task.model, + size=_size_from_geometry(task.resolution, task.ratio, task.model), + model=model or task.model, usage=dict(usage) if usage else None, ) -def _positive_seconds(value: object) -> float | None: - if isinstance(value, bool) or value is None: - return None - if isinstance(value, (int, float)): - return float(value) if value > 0 else None - if isinstance(value, str): - try: - parsed: Final = float(value) - except ValueError: - return None - return parsed if parsed > 0 else None - return None - - -def _default_create_duration(model: str | None) -> float | None: - if not model: - return None - for prefix, seconds in _DEFAULT_DURATION_SECONDS.items(): - if model.startswith(prefix): - return seconds - return None - - -def _requested_usage(request_data: Mapping[str, object] | None, model: str | None = None) -> Mapping[str, object]: - """ - Build the usage block for the create response out of the requested values. - - Only the create, edit and remix calls are costed (see ``_VIDEO_CALL_TYPES``), - and Ark's create response carries no usage at all, so without this every - video would be logged at zero cost. The requested duration and resolution - are the best estimate available at create time; the delivered values on the - status response are what an invoice should be reconciled against. - - A caller that leaves ``seconds`` off gets Ark's own default length, which is - still billable, so a model whose default is a fixed number falls back to it - here. The 2.5 family defaults to ``-1`` (Ark picks the length), which has no - honest number to report, so it stays unbilled until the status response. - """ - params: Final = request_data or _EMPTY_PARAMS - resolution: Final = params.get("resolution") - duration: Final = params.get("duration") - requested_seconds: Final = _positive_seconds(duration) if duration is not None else _default_create_duration(model) - return MappingProxyType( - { - key: value - for key, value in ( - ("duration_seconds", requested_seconds), - ( - "video_resolution", - resolution.strip().lower() if isinstance(resolution, str) and resolution.strip() else None, - ), - ) - if value is not None - } - ) - - -def _encoded_task_dump(task: _Task, custom_llm_provider: str | None) -> Mapping[str, object]: - video_obj: Final = _video_object_from_task(task) - if custom_llm_provider and video_obj.id: - video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, video_obj.model) - return video_obj.model_dump() - - -def _video_error(status_code: int, message: str) -> VolcEngineVideoError: - return VolcEngineVideoError(status_code=status_code, message=message) - - -def _video_url_from_task(task: _Task) -> str: - """ - A task whose artifact cannot be served yet is the caller's own state to fix, not a - gateway fault, so every branch here answers 4xx. A 5xx would have the router retry a - job that is simply unfinished and count the caller's early download against the - model's error rate. - """ - content: Final = task.content - if content is not None and content.video_url: - return content.video_url - +def _video_url_from_task(task: _Task, raw_response: httpx.Response) -> str: + """A download before the video is ready is the caller polling too early, not a fault of the deployment, so it + answers 400, which litellm neither retries nor counts toward a deployment cooldown. A 404 would cool the + deployment down, and a 409 or a 5xx would be retried against a job that is simply not finished.""" + if task.content is not None and task.content.video_url: + return task.content.video_url status: Final = task.status.strip().lower() - if status in ("queued", "running"): - raise _video_error(404, f"Video is still processing (status: {task.status}). Please wait and try again.") + if status in _PENDING_STATUSES: + raise _video_error( + raw_response, 400, f"Video is still processing (status: {status}). Please wait and try again." + ) if task.error is not None and (task.error.message or task.error.code): - raise _video_error(400, f"Video generation failed: {task.error.message or task.error.code}") + raise _video_error(raw_response, 400, f"Video generation failed: {task.error.message or task.error.code}") if status == "expired": - raise _video_error(404, "Task expired before it finished. Ark task records expire 7 days after creation.") - raise _video_error(502, "Video URL not found in task response. The task may not have succeeded yet.") - - -def _string_param(value: object) -> str | None: - if isinstance(value, str) and value.strip(): - return value.strip() - return None - - -def _routed_model(litellm_params: GenericLiteLLMParams | Mapping[str, object]) -> str | None: - """ - The model the router already resolved for this request, if any. - - ``litellm_params`` is a dict-like pydantic model whose ``get`` is untyped, - so the value is read out and validated rather than trusted. - """ - dumped: Final = dict(litellm_params) if isinstance(litellm_params, Mapping) else litellm_params.model_dump() - return _string_param(_PARAMETERS_ADAPTER.validate_python(dumped).get("model")) - - -def _edit_model( - video_id: str, - extra_body: Mapping[str, object] | None, - litellm_params: GenericLiteLLMParams | Mapping[str, object], -) -> str: - """ - Resolve the model an edit or extension task should run on. - - litellm's edit and extension transforms are handed no model argument, so it - is recovered from, in order: an explicit ``extra_body.model``, the model - encoded in the video id litellm minted, or the model the router already - resolved for this request. The router's copy keeps litellm's provider - prefix, which Ark does not take. - """ - supplied: Final = _string_param((extra_body or _EMPTY_PARAMS).get("model")) - if supplied is not None: - return supplied - model_id: Final = decode_video_id_with_provider(video_id).get("model_id") - if model_id: - return model_id - routed: Final = _routed_model(litellm_params) - if routed is not None: - return routed.rsplit("/", 1)[-1] - raise ValueError( - "Editing and extending need the model that produced the source video. Pass the video id returned by " - "video_generation() so it can be decoded, or set model in extra_body." - ) - - -def _source_video_url(video_id: str, extra_body: Mapping[str, object] | None) -> str: - """ - Resolve the source video the edit or extension task should reference. - - Ark takes a video URL, not a task id. litellm's edit prefetch hook only - issues POSTs and Ark's task query is a GET, so an id cannot be resolved - here; the caller passes the URL itself, either as ``video_id`` or as - ``video_url`` in ``extra_body``. - """ - supplied: Final = (extra_body or _EMPTY_PARAMS).get("video_url") - if isinstance(supplied, str) and supplied.strip(): - return supplied.strip() - original: Final = extract_original_video_id(video_id) - if original.startswith(("http://", "https://")): - return original - raise ValueError( - "Volcengine video edit and extension need the source video URL, not a task id. Pass the URL as video_id or " - "as video_url in extra_body; Ark's task query is a GET, so litellm's edit prefetch hook cannot resolve an id." - ) - - -def _source_video_content_item(video_url: str) -> Mapping[str, object]: - return {"type": "video_url", "video_url": {"url": video_url}, "role": "reference_video"} + raise _video_error(raw_response, 400, "Task expired. Ark keeps task records for 7 days after creation.") + if status in ("failed", "cancelled"): + raise _video_error(raw_response, 400, f"Video generation ended as {status} with no video URL.") + raise _video_error(raw_response, 502, "Video URL not found in task response.") class VolcEngineVideoConfig(BaseVideoConfig): - """ - Configuration class for Volcengine Ark video generation (Doubao-Seedance). - - Ark uses an async task API where: - 1. POST /api/v3/contents/generations/tasks creates a task and returns its id - 2. The task is polled via GET /api/v3/contents/generations/tasks/{id} - 3. On success the artifact is downloaded from the 24h signed content.video_url - """ + """Volcengine Ark (Doubao-Seedance) on Ark's async task API: create, poll, download from the signed + ``content.video_url``, and list.""" def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: BaseVideoConfig contract returns list return [ @@ -698,45 +466,29 @@ class VolcEngineVideoConfig(BaseVideoConfig): model: str, drop_params: bool, ) -> dict[str, object]: # mutable-ok: BaseVideoConfig contract - """ - Map OpenAI-style video params onto Ark's flat request fields. - - Mappings: - - seconds -> duration (integer; -1 asks Ark to pick) - - size -> ratio ("1280x720" snaps to "16:9") and resolution ("720p") - - parameters -> merged in as a provider-specific block - - content, ratio, resolution, duration, frames, seed, watermark, - generate_audio and the rest pass through - - OpenAI-only fields Ark does not define (user, characters, image) are - dropped. ``input_reference`` is consumed by - transform_video_create_request, which turns it into a content item. - """ - mapped_params: Final[dict[str, object]] = {} # mutable-ok: BaseVideoConfig contract; extra_body merges into it - for key, value in video_create_optional_params.items(): - if value is None or key in _DROP_FROM_REQUEST: - continue - if key == "seconds": - duration = _duration_param(value) - if duration is not None: - mapped_params["duration"] = duration - elif key == "size": - if not isinstance(value, str): - continue - ratio = _ratio_from_size(value) - if ratio is not None: - mapped_params.setdefault("ratio", ratio) - resolution = _resolution_from_size(value) - if resolution is not None: - mapped_params.setdefault("resolution", resolution) - elif key == "parameters": - try: - mapped_params.update(_PARAMETERS_ADAPTER.validate_python(value)) - except ValidationError as e: - raise ValueError("parameters must be an object of Ark request fields") from e - else: - mapped_params[key] = value - return mapped_params + unsupported: Final = next( + ( + key + for key, value in video_create_optional_params.items() + if key in _UNSUPPORTED_BY_VOLCENGINE and value is not None + ), + None, + ) + 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 video_create_optional_params.items() + if value is not None and key not in _MAPPED_ELSEWHERE + } + ) + return { + **_size_fields(video_create_optional_params.get("size"), model, drop_params), + **_duration_fields(video_create_optional_params.get("seconds"), model, drop_params), + **passthrough, + **_parameters_fields(video_create_optional_params.get("parameters"), model, drop_params), + } def _resolve_api_key(self, api_key: str | None) -> str: resolved_api_key: Final = api_key or get_secret_str("ARK_API_KEY") or get_secret_str("VOLCENGINE_API_KEY") @@ -759,12 +511,7 @@ class VolcEngineVideoConfig(BaseVideoConfig): or (litellm_params.api_key if litellm_params is not None and litellm_params.api_key else None) or litellm.api_key ) - - auth_headers: Final[dict[str, str]] = { # mutable-ok: httpx request headers are a mutable dict - "Authorization": f"Bearer {resolved_api_key}", - "Content-Type": "application/json", - } - headers.update(auth_headers) + headers.update({"Authorization": f"Bearer {resolved_api_key}", "Content-Type": "application/json"}) return headers def get_complete_url( @@ -785,24 +532,58 @@ class VolcEngineVideoConfig(BaseVideoConfig): headers: dict[str, str], # mutable-ok: BaseVideoConfig contract ) -> tuple[dict[str, object], RequestFiles, str]: # mutable-ok: BaseVideoConfig contract """ - Build Ark's flat body around the typed ``content`` array. - - ``input_reference`` becomes a ``first_frame`` content item, base64 - encoded into a data URI when a file object is given rather than a URL. - A caller-supplied ``content`` array always wins, as the roles on it are - the only way to express the multi-modal reference, edit and extension - jobs Ark accepts. + Ark bills a video when the task finishes, while litellm bills it here at create, so every request has to + carry a length and a tier whose cost is known now. Ark-picked length (``duration: -1``) and a video input + (edits, extensions and reference videos, billed at a different rate per token) are rejected for that reason, + and an omitted length is sent explicitly, since the 2.5 family's own default is ``-1``. """ + params: Final = video_create_optional_request_params + try: + content: Final = _content_items(prompt, params) + except ValidationError: + raise _unsupported(model, "content must be an array of Ark content items") from None + if _has_input_video(content): + raise _unsupported(model, _VIDEO_INPUT_UNSUPPORTED.format(operation="Video input (a reference_video item)")) + if params.get("frames") is not None: + raise _unsupported(model, "frames is not supported through litellm, pass seconds instead") + requested_duration: Final = params.get("duration") + duration: Final = ( + VOLCENGINE_VIDEO_DEFAULT_DURATION_SECONDS + if requested_duration is None + else _duration_param(requested_duration) + ) + if duration is None or duration <= 0: + raise _unsupported( + model, + f"duration must be a positive whole number of seconds, got {requested_duration!r}. Ark-picked length " + "(-1) is not supported through litellm: the video is billed when the task is created, before Ark has " + "picked its length.", + ) + + drop_params: Final = _drops_params(litellm_params) + requested_fields: Final = MappingProxyType( + {key: params[key] for key in _CREATE_PASSTHROUGH_KEYS if params.get(key) is not None} + ) + malformed: Final = _malformed_fields(requested_fields) + if malformed and not drop_params: + raise _droppable(model, "; ".join(f"invalid {field}: {message}" for field, message in malformed)) + resolution: Final = requested_fields.get("resolution") + priced_tiers: Final = _priced_tiers(model) if isinstance(resolution, str) else None + drops_resolution: Final = ( + priced_tiers is not None and isinstance(resolution, str) and resolution.strip().lower() not in priced_tiers + ) + if drops_resolution and not drop_params: + raise _droppable( + model, f"{model} is not offered at resolution {resolution!r}, only at {', '.join(priced_tiers or ())}" + ) + dropped: Final = frozenset({*(field for field, _ in malformed), *(("resolution",) if drops_resolution else ())}) + request_data: Final[dict[str, object]] = { # mutable-ok: request body dict, JSON-serialized by the handler "model": model, - "content": list(_content_items(prompt, video_create_optional_request_params)), - **{ - key: video_create_optional_request_params[key] - for key in _CREATE_PASSTHROUGH_KEYS - if video_create_optional_request_params.get(key) is not None - }, + "content": [dict(item) for item in content], + **{key: value for key, value in requested_fields.items() if key not in dropped}, + "duration": duration, } - return request_data, (), f"{api_base}{VOLCENGINE_VIDEO_TASKS_PATH}" def transform_video_create_response( @@ -813,32 +594,32 @@ class VolcEngineVideoConfig(BaseVideoConfig): custom_llm_provider: str | None = None, request_data: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract ) -> VideoObject: - task: Final = _TASK_CREATE_ADAPTER.validate_python(raw_response.json()) + task: Final = _parse(_TASK_CREATE_ADAPTER, raw_response) if not task.id: message: Final = task.error.message if task.error is not None and task.error.message else "no task id" - raise self.get_error_class( - error_message=f"Volcengine did not return a video task id: {message}", - status_code=raw_response.status_code, - headers=raw_response.headers, - ) - video_obj: Final = VideoObject(id=task.id, object="video", status="queued", model=model) - requested_usage: Final = _requested_usage(request_data, model) - if requested_usage: - video_obj.usage = dict(requested_usage) - requested_seconds: Final = requested_usage.get("duration_seconds") - video_obj.seconds = str(requested_seconds) if requested_seconds is not None else None - size: Final = _size_from_geometry( - _string_param((request_data or _EMPTY_PARAMS).get("resolution")), - _string_param((request_data or _EMPTY_PARAMS).get("ratio")), - model, + raise _video_error(raw_response, 400, f"Volcengine did not return a video task id: {message}") + requested: Final = request_data or _EMPTY_PARAMS + usage: Final = _requested_usage(requested) + seconds: Final = usage.get("duration_seconds") + requested_model, served_model = routed_video_models(logging_obj) or (model, None) + resolution: Final = requested.get("resolution") + ratio: Final = requested.get("ratio") + return VideoObject( + id=encode_video_id_with_provider(task.id, custom_llm_provider, requested_model, served_model) + if custom_llm_provider + else task.id, + object="video", + status="queued", + model=model, + seconds=str(seconds) if seconds is not None else None, + size=_size_from_geometry( + resolution if isinstance(resolution, str) else None, ratio if isinstance(ratio, str) else None, model + ), + usage=dict(usage) if usage else None, ) - if size is not None: - video_obj.size = size - if custom_llm_provider: - video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, model) - return video_obj - def _task_url(self, video_id: str, api_base: str) -> str: + @staticmethod + def _task_url(video_id: str, api_base: str) -> str: original_task_id: Final = extract_original_video_id(video_id) encoded_task_id: Final = encode_url_path_segment(original_task_id, field_name="video_id") return f"{api_base}{VOLCENGINE_VIDEO_TASKS_PATH}/{encoded_task_id}" @@ -859,10 +640,11 @@ class VolcEngineVideoConfig(BaseVideoConfig): custom_llm_provider: str | None = None, client: "HTTPHandler | None" = None, ) -> VideoObject: - task: Final = _parse_task(raw_response) - video_obj: Final = _video_object_from_task(task) - if custom_llm_provider and video_obj.id: - video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, None) + task: Final = _parse(_TASK_ADAPTER, raw_response) + model, served_model = polled_video_models(logging_obj) + video_obj: Final = _video_object_from_task(task, raw_response, model) + if custom_llm_provider: + video_obj.id = encode_video_id_with_provider(task.id, custom_llm_provider, model, served_model) return video_obj def transform_video_content_request( @@ -879,28 +661,26 @@ class VolcEngineVideoConfig(BaseVideoConfig): self, raw_response: httpx.Response, logging_obj: "Logging", + client: "HTTPHandler | None" = None, ) -> bytes: - video_url: Final = _video_url_from_task(_parse_task(raw_response)) - - httpx_client: Final = _get_httpx_client() + video_url: Final = _video_url_from_task(_parse(_TASK_ADAPTER, raw_response), raw_response) + httpx_client: Final[HTTPHandler] = client if client is not None else _get_httpx_client() video_response: Final = httpx_client.get(video_url) # pyright: ignore[reportUnknownMemberType] # HTTPHandler.get stub leaves params/headers untyped video_response.raise_for_status() - return video_response.content async def async_transform_video_content_response( self, raw_response: httpx.Response, logging_obj: "Logging", + client: "AsyncHTTPHandler | None" = None, ) -> bytes: - video_url: Final = _video_url_from_task(_parse_task(raw_response)) - - async_httpx_client: Final = get_async_httpx_client( - llm_provider=litellm.LlmProviders.VOLCENGINE, + video_url: Final = _video_url_from_task(_parse(_TASK_ADAPTER, raw_response), raw_response) + async_httpx_client: Final[AsyncHTTPHandler] = ( + client if client is not None else get_async_httpx_client(llm_provider=litellm.LlmProviders.VOLCENGINE) ) video_response: Final = await async_httpx_client.get(video_url) # pyright: ignore[reportUnknownMemberType] # HTTPHandler.get stub leaves params/headers untyped video_response.raise_for_status() - return video_response.content def transform_video_remix_request( @@ -912,10 +692,7 @@ class VolcEngineVideoConfig(BaseVideoConfig): headers: dict[str, str], # mutable-ok: BaseVideoConfig contract extra_body: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract - raise NotImplementedError( - "Video remix is not supported by Volcengine. Edit or extend the video instead: /v1/videos/edits and " - "/v1/videos/extensions map onto Ark's editing and extension task types." - ) + raise NotImplementedError("Video remix is not supported by Volcengine.") def transform_video_remix_response( self, @@ -936,45 +713,7 @@ class VolcEngineVideoConfig(BaseVideoConfig): extra_body: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract prefetched_source_data: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract ) -> tuple[str, Mapping[str, object], RequestFiles | None]: - """ - Build Ark's video editing task. - - Editing is a create-task call that references the source video and asks - Ark to resolve the geometry itself, so ``ratio`` is pinned to - ``adaptive`` and ``duration`` to ``-1`` unless the caller overrides - them, both of which Ark rejects the request without. - """ - request_data: Final[dict[str, object]] = { # mutable-ok: request body dict, JSON-serialized by the handler - "model": _edit_model(video_id, extra_body, litellm_params), - "content": [ - {"type": "text", "text": prompt}, - _source_video_content_item(_source_video_url(video_id, extra_body)), - ], - "ratio": "adaptive", - _EDIT_DURATION_PARAM: -1, - } - if extra_body: - request_data.update(extra_body) - return f"{api_base}{VOLCENGINE_VIDEO_TASKS_PATH}", request_data, None - - def transform_video_edit_response( - self, - raw_response: httpx.Response, - logging_obj: "Logging", - custom_llm_provider: str | None = None, - request_data: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract - ) -> VideoObject: - task: Final = _TASK_CREATE_ADAPTER.validate_python(raw_response.json()) - edit_model: Final = request_data.get("model") if request_data is not None else None - video_obj: Final = VideoObject( - id=task.id, - object="video", - status="queued", - model=edit_model if isinstance(edit_model, str) else None, - ) - if custom_llm_provider and video_obj.id: - video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, video_obj.model) - return video_obj + raise _unsupported("", _VIDEO_INPUT_UNSUPPORTED.format(operation="Video edit")) def transform_video_extension_request( self, @@ -986,39 +725,7 @@ class VolcEngineVideoConfig(BaseVideoConfig): headers: dict[str, str], # mutable-ok: BaseVideoConfig contract extra_body: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract - """ - Build Ark's video extension task. - - Extension is the same create-task shape as editing minus the fixed - duration: Ark requires ``ratio: adaptive`` and takes the number of - seconds to add as ``duration``. - """ - request_data: Final[dict[str, object]] = { # mutable-ok: request body dict, JSON-serialized by the handler - "model": _edit_model(video_id, extra_body, litellm_params), - "content": [ - {"type": "text", "text": prompt}, - _source_video_content_item(_source_video_url(video_id, extra_body)), - ], - "ratio": "adaptive", - } - duration: Final = _duration_param(seconds) - if duration is not None: - request_data["duration"] = duration - if extra_body: - request_data.update(extra_body) - return f"{api_base}{VOLCENGINE_VIDEO_TASKS_PATH}", request_data - - def transform_video_extension_response( - self, - raw_response: httpx.Response, - logging_obj: "Logging", - custom_llm_provider: str | None = None, - ) -> VideoObject: - task: Final = _TASK_CREATE_ADAPTER.validate_python(raw_response.json()) - video_obj: Final = VideoObject(id=task.id, object="video", status="queued") - if custom_llm_provider and video_obj.id: - video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, None) - return video_obj + raise _unsupported("", _VIDEO_INPUT_UNSUPPORTED.format(operation="Video extension")) def transform_video_list_request( self, @@ -1030,20 +737,11 @@ class VolcEngineVideoConfig(BaseVideoConfig): order: str | None = None, extra_query: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract - """ - Map the video list request to GET /api/v3/contents/generations/tasks. - - Ark paginates with page_num/page_size and filters (``filter.status`` - etc.) instead of a cursor, so ``limit`` maps to page_size and the - Ark-specific filters pass through ``extra_query``; ``after`` and - ``order`` have no Ark equivalent. - """ - params: Final[dict[str, object]] = {} # mutable-ok: query params dict consumed by the http handler - if limit is not None: - params["page_size"] = str(limit) - if extra_query: - params.update(extra_query) - return f"{api_base}{VOLCENGINE_VIDEO_TASKS_PATH}", params + """Ark pages by page_num/page_size with filters and has no cursor or order.""" + return f"{api_base}{VOLCENGINE_VIDEO_TASKS_PATH}", { + **({"page_size": str(limit)} if limit is not None else {}), + **(extra_query or {}), + } def transform_video_list_response( # pyright: ignore[reportIncompatibleMethodOverride] # base declares dict[str, str] but the payload is a heterogeneous list body self, @@ -1051,19 +749,23 @@ class VolcEngineVideoConfig(BaseVideoConfig): logging_obj: "Logging", custom_llm_provider: str | None = None, ) -> dict[str, object]: # mutable-ok: OpenAI list body served as JSON by the proxy - list_payload: Final = _TASK_LIST_ADAPTER.validate_python(raw_response.json()) - - data: Final = [_encoded_task_dump(task, custom_llm_provider) for task in list_payload.items or ()] - - list_response: Final[dict[str, object]] = { # mutable-ok: OpenAI list body served as JSON by the proxy + list_payload: Final = _parse(_TASK_LIST_ADAPTER, raw_response) + videos: Final = tuple(_video_object_from_task(task, raw_response, None) for task in list_payload.items) + data: Final = [ + video.model_dump() + | ( + {"id": encode_video_id_with_provider(video.id, custom_llm_provider, video.model)} + if custom_llm_provider + else {} + ) + for video in videos + ] + return { "object": "list", "data": data, - "total": list_payload.total if isinstance(list_payload.total, int) else len(data), + "total": list_payload.total if list_payload.total is not None else len(data), + **({"first_id": data[0]["id"], "last_id": data[-1]["id"]} if data else {}), } - if data: - list_response["first_id"] = data[0]["id"] - list_response["last_id"] = data[-1]["id"] - return list_response def transform_video_delete_request( self, @@ -1072,18 +774,14 @@ class VolcEngineVideoConfig(BaseVideoConfig): litellm_params: GenericLiteLLMParams, headers: dict[str, str], # mutable-ok: BaseVideoConfig contract ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract - return self._task_url(video_id, api_base), _EMPTY_PARAMS + raise NotImplementedError("Video delete is not supported by Volcengine.") def transform_video_delete_response( self, raw_response: httpx.Response, logging_obj: "Logging", ) -> VideoObject: - try: - task: Final = _parse_task(raw_response) - except ValueError: - return VideoObject(id="", object="video", status="deleted") - return VideoObject(id=task.id, object="video", status="deleted", model=task.model) + raise NotImplementedError("Video delete is not supported by Volcengine.") def get_error_class( self, @@ -1091,8 +789,4 @@ class VolcEngineVideoConfig(BaseVideoConfig): status_code: int, headers: dict[str, str] | httpx.Headers, # mutable-ok: BaseVideoConfig contract ) -> BaseLLMException: - return VolcEngineVideoError( - status_code=status_code, - message=error_message, - headers=headers, - ) + return VolcEngineVideoError(status_code=status_code, message=error_message, headers=headers) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index e7b268279e5..b655ac41699 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -59477,7 +59477,8 @@ "supported_modalities": ["text", "image", "video", "audio"], "supported_output_modalities": ["video"], "supported_endpoints": ["/v1/videos"], - "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s). List 70 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. 42 CNY per million tokens with a video input. Accepts up to 50 reference assets (30 images, 10 videos, 10 audios) and clips of 4-30s; per-asset billing beyond the free allowance is not encoded. Requests that do carry a video input bill at the lower CNY rate instead, which litellm's per-second tiers cannot express, so edit, extension and reference-video jobs are overestimated."} + "provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "854x480", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "480x854", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}}, + "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s). List 70 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Accepts up to 50 reference assets (30 images, 10 videos, 10 audios) and clips of 4-30s; per-asset billing beyond the free allowance is not encoded. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."} }, "volcengine/doubao-seedance-2-0-260128": { "litellm_provider": "volcengine", @@ -59491,7 +59492,8 @@ "supported_modalities": ["text", "image", "video", "audio"], "supported_output_modalities": ["video"], "supported_endpoints": ["/v1/videos"], - "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 46 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. 28 CNY per million tokens with a video input. Clips of 4-15s; 4k is exclusive to this tier. Requests that do carry a video input bill at the lower CNY rate instead, which litellm's per-second tiers cannot express, so edit, extension and reference-video jobs are overestimated."} + "provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}}, + "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 46 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s; 4k is exclusive to this tier. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."} }, "volcengine/doubao-seedance-2-0-fast-260128": { "litellm_provider": "volcengine", @@ -59503,7 +59505,8 @@ "supported_modalities": ["text", "image", "video", "audio"], "supported_output_modalities": ["video"], "supported_endpoints": ["/v1/videos"], - "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 37 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. 22 CNY per million tokens with a video input. Clips of 4-15s at 480p or 720p. Requests that do carry a video input bill at the lower CNY rate instead, which litellm's per-second tiers cannot express, so edit, extension and reference-video jobs are overestimated."} + "provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}}, + "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 37 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s at 480p or 720p. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."} }, "volcengine/doubao-seedance-2-0-mini-260615": { "litellm_provider": "volcengine", @@ -59515,7 +59518,8 @@ "supported_modalities": ["text", "image"], "supported_output_modalities": ["video"], "supported_endpoints": ["/v1/videos"], - "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 23 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. 14 CNY per million tokens with a video input. Clips of 4-15s at 480p or 720p. Only text and image inputs are declared; the mini tier's reference video and audio caps were not verified. Requests that do carry a video input bill at the lower CNY rate instead, which litellm's per-second tiers cannot express, so edit, extension and reference-video jobs are overestimated."} + "provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}}, + "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 23 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s at 480p or 720p. Only text and image inputs are declared; the mini tier's reference video and audio caps were not verified. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."} }, "bedrock/us-east-1/zai.glm-5": { "input_cost_per_token": 1e-06, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e7b268279e5..b655ac41699 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -59477,7 +59477,8 @@ "supported_modalities": ["text", "image", "video", "audio"], "supported_output_modalities": ["video"], "supported_endpoints": ["/v1/videos"], - "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s). List 70 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. 42 CNY per million tokens with a video input. Accepts up to 50 reference assets (30 images, 10 videos, 10 audios) and clips of 4-30s; per-asset billing beyond the free allowance is not encoded. Requests that do carry a video input bill at the lower CNY rate instead, which litellm's per-second tiers cannot express, so edit, extension and reference-video jobs are overestimated."} + "provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "854x480", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "480x854", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}}, + "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s). List 70 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Accepts up to 50 reference assets (30 images, 10 videos, 10 audios) and clips of 4-30s; per-asset billing beyond the free allowance is not encoded. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."} }, "volcengine/doubao-seedance-2-0-260128": { "litellm_provider": "volcengine", @@ -59491,7 +59492,8 @@ "supported_modalities": ["text", "image", "video", "audio"], "supported_output_modalities": ["video"], "supported_endpoints": ["/v1/videos"], - "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 46 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. 28 CNY per million tokens with a video input. Clips of 4-15s; 4k is exclusive to this tier. Requests that do carry a video input bill at the lower CNY rate instead, which litellm's per-second tiers cannot express, so edit, extension and reference-video jobs are overestimated."} + "provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}}, + "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 46 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s; 4k is exclusive to this tier. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."} }, "volcengine/doubao-seedance-2-0-fast-260128": { "litellm_provider": "volcengine", @@ -59503,7 +59505,8 @@ "supported_modalities": ["text", "image", "video", "audio"], "supported_output_modalities": ["video"], "supported_endpoints": ["/v1/videos"], - "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 37 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. 22 CNY per million tokens with a video input. Clips of 4-15s at 480p or 720p. Requests that do carry a video input bill at the lower CNY rate instead, which litellm's per-second tiers cannot express, so edit, extension and reference-video jobs are overestimated."} + "provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}}, + "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 37 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s at 480p or 720p. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."} }, "volcengine/doubao-seedance-2-0-mini-260615": { "litellm_provider": "volcengine", @@ -59515,7 +59518,8 @@ "supported_modalities": ["text", "image"], "supported_output_modalities": ["video"], "supported_endpoints": ["/v1/videos"], - "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 23 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. 14 CNY per million tokens with a video input. Clips of 4-15s at 480p or 720p. Only text and image inputs are declared; the mini tier's reference video and audio caps were not verified. Requests that do carry a video input bill at the lower CNY rate instead, which litellm's per-second tiers cannot express, so edit, extension and reference-video jobs are overestimated."} + "provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}}, + "metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 23 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s at 480p or 720p. Only text and image inputs are declared; the mini tier's reference video and audio caps were not verified. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."} }, "bedrock/us-east-1/zai.glm-5": { "input_cost_per_token": 1e-06, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index b6c45fd4bb4..38a42afb4d5 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -2748,7 +2748,8 @@ "batches": false, "rerank": false, "a2a": true, - "interactions": true + "interactions": true, + "video_generations": true } }, "voyage": { diff --git a/tests/unit/llms/volcengine/test_volcengine_video_transformation.py b/tests/unit/llms/volcengine/test_volcengine_video_transformation.py index ccb1c5463b0..1bab1bf471f 100644 --- a/tests/unit/llms/volcengine/test_volcengine_video_transformation.py +++ b/tests/unit/llms/volcengine/test_volcengine_video_transformation.py @@ -1,920 +1,820 @@ -""" -Tests for Volcengine Ark (Doubao-Seedance) video generation transformation. -""" - import base64 import io import json +from pathlib import Path from typing import Final from unittest.mock import Mock import httpx import pytest +import respx import litellm +from litellm import Router +from litellm.exceptions import UnsupportedParamsError from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler -from litellm.llms.volcengine.videos.transformation import ( - VolcEngineVideoConfig, - VolcEngineVideoError, - _parse_task, - _video_url_from_task, -) +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.utils import ( - decode_video_id_with_provider, - encode_video_id_with_provider, -) +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 -PNG_BYTES = base64.b64decode( +PNG_BYTES: Final = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" ) +PNG_DATA_URL: Final = f"data:image/png;base64,{base64.b64encode(PNG_BYTES).decode()}" -API_BASE = "https://ark.cn-beijing.volces.com" -TASKS_URL = f"{API_BASE}/api/v3/contents/generations/tasks" -SEEDANCE_2_5 = "doubao-seedance-2-5-260628" -SEEDANCE_2_0 = "doubao-seedance-2-0-260128" +API_BASE: Final = "https://ark.cn-beijing.volces.com" +TASKS_URL: Final = f"{API_BASE}/api/v3/contents/generations/tasks" +SEEDANCE_2_5: Final = "doubao-seedance-2-5-260628" +SEEDANCE_2_0: Final = "doubao-seedance-2-0-260128" +SEEDANCE_2_0_FAST: Final = "doubao-seedance-2-0-fast-260128" +TASK_ID: Final = "cgt-2026-abc" -def _mock_response(payload: dict, status_code: int = 200) -> Mock: - mock_response = Mock(spec=httpx.Response) - mock_response.json.return_value = payload - mock_response.status_code = status_code - mock_response.headers = httpx.Headers() - return mock_response +def _response(payload: object, status_code: int = 200) -> httpx.Response: + return httpx.Response(status_code, json=payload, request=httpx.Request("GET", TASKS_URL)) -def _create(params: dict, model: str = SEEDANCE_2_5, prompt: str = "a cat on a roof"): - return VolcEngineVideoConfig().transform_video_create_request( +def _text_response(text: str, status_code: int = 200) -> httpx.Response: + return httpx.Response(status_code, text=text, request=httpx.Request("GET", TASKS_URL)) + + +def _create_body( + params: dict[str, object], model: str = SEEDANCE_2_5, drop_params: bool | None = None +) -> dict[str, object]: + body, files, url = VolcEngineVideoConfig().transform_video_create_request( model=model, - prompt=prompt, + prompt="a cat on a roof", api_base=API_BASE, video_create_optional_request_params=params, - litellm_params=GenericLiteLLMParams(), + litellm_params=GenericLiteLLMParams(drop_params=drop_params), headers={}, ) + assert (files, url) == ((), TASKS_URL) + return body -def _poll(payload: dict, provider: str | None = "volcengine"): +def _created(request_data: dict[str, object], model: str = SEEDANCE_2_5, logging_obj: object = None): + return VolcEngineVideoConfig().transform_video_create_response( + model=model, + raw_response=_response({"id": TASK_ID}), + logging_obj=logging_obj, # pyright: ignore[reportArgumentType] # the transform only reads litellm_params off it + custom_llm_provider="volcengine", + request_data=request_data, + ) + + +def _polled(payload: object, logging_obj: object = None): return VolcEngineVideoConfig().transform_video_status_retrieve_response( - raw_response=_mock_response(payload), - logging_obj=Mock(), - custom_llm_provider=provider, + raw_response=_response(payload), + logging_obj=logging_obj, # pyright: ignore[reportArgumentType] # the transform only reads litellm_params off it + custom_llm_provider="volcengine", ) -class TestVolcEngineVideoCreateRequest: - def test_prompt_becomes_the_text_content_item(self): - """Ark takes no top-level prompt: it only reads the typed content array.""" - body, files, url = _create({}) +def _task(**fields: object) -> dict[str, object]: + return {"id": TASK_ID, "model": SEEDANCE_2_5, "status": "succeeded", **fields} - assert body["content"] == [{"type": "text", "text": "a cat on a roof"}] - assert "prompt" not in body - assert files == () - assert url == TASKS_URL - def test_input_reference_becomes_a_first_frame_item(self): - body, _, _ = _create({"input_reference": "https://x/first.png"}) +@pytest.mark.parametrize( + "input_reference", + [ + PNG_BYTES, + io.BytesIO(PNG_BYTES), + ("frame.png", PNG_BYTES, "image/png"), + ("frame.png", io.BytesIO(PNG_BYTES)), + ], + ids=["bytes", "file", "tuple", "tuple-with-file"], +) +def test_a_file_input_reference_becomes_a_data_uri_first_frame(input_reference: object): + body = _create_body({"input_reference": input_reference, "duration": 4}) - assert body["content"] == [ + assert body == { + "model": SEEDANCE_2_5, + "content": [ {"type": "text", "text": "a cat on a roof"}, - {"type": "image_url", "image_url": {"url": "https://x/first.png"}, "role": "first_frame"}, - ] - - def test_file_input_reference_becomes_a_data_uri(self): - body, _, _ = _create({"input_reference": io.BytesIO(PNG_BYTES)}) - - item = body["content"][1] - assert item["role"] == "first_frame" - assert item["image_url"]["url"].startswith("data:image/png;base64,") - - def test_caller_content_array_wins_and_keeps_the_prompt(self): - """Roles on a caller-supplied array are the only way to express - reference-to-video and edit jobs, so it must not be rebuilt.""" - supplied = [ - {"type": "image_url", "image_url": {"url": "https://x/ref.png"}, "role": "reference_image"}, - {"type": "video_url", "video_url": {"url": "https://x/ref.mp4"}, "role": "reference_video"}, - ] - body, _, _ = _create({"content": supplied}) - - assert body["content"] == [{"type": "text", "text": "a cat on a roof"}, *supplied] - - def test_caller_content_with_its_own_text_item_is_not_duplicated(self): - supplied = [ - {"type": "text", "text": "custom"}, - {"type": "audio_url", "audio_url": {"url": "x"}, "role": "reference_audio"}, - ] - body, _, _ = _create({"content": supplied}) - - assert body["content"] == supplied - - def test_malformed_content_array_raises(self): - with pytest.raises(ValueError, match="content must be an array"): - _create({"content": ["not-an-item"]}) - - def test_provider_params_land_next_to_the_content_array(self): - body, _, _ = _create( - {"resolution": "1080p", "ratio": "9:16", "duration": 8, "generate_audio": True, "watermark": False} - ) - - assert body["resolution"] == "1080p" - assert body["ratio"] == "9:16" - assert body["duration"] == 8 - assert body["generate_audio"] is True - assert body["watermark"] is False - - def test_false_valued_flags_are_not_dropped(self): - """watermark=False and camera_fixed=False are meaningful; a truthiness - check instead of an is-None check would silently flip them.""" - body, _, _ = _create({"watermark": False, "camera_fixed": False}) - - assert body["watermark"] is False - assert body["camera_fixed"] is False - - -class TestVolcEngineVideoParamMapping: - def test_seconds_becomes_duration(self): - assert VolcEngineVideoConfig().map_openai_params({"seconds": "5"}, "m", True) == {"duration": 5} - - def test_size_yields_ratio_and_resolution_tier(self): - """Ark takes a named tier, not a pixel size; dropping it leaves the - request on the model default and bills at that default's rate.""" - mapped = VolcEngineVideoConfig().map_openai_params({"size": "1920x1080"}, "m", True) - - assert mapped == {"ratio": "16:9", "resolution": "1080p"} - - @pytest.mark.parametrize( - "size,expected_ratio,expected_resolution", - [ - ("1280x720", "16:9", "720p"), - ("854x480", "16:9", "480p"), - ("1080x1920", "9:16", "1080p"), - ("1024x1024", "1:1", "1080p"), - ("3840x2160", "16:9", "4k"), - ("1080x1440", "3:4", "1080p"), + {"type": "image_url", "image_url": {"url": PNG_DATA_URL}, "role": "first_frame"}, ], + "duration": 4, + } + + +def test_a_path_input_reference_is_read_from_disk(tmp_path: Path): + frame = tmp_path / "frame.png" + frame.write_bytes(PNG_BYTES) + + body = _create_body({"input_reference": frame, "duration": 4}) + + assert body["content"] == [ + {"type": "text", "text": "a cat on a roof"}, + {"type": "image_url", "image_url": {"url": PNG_DATA_URL}, "role": "first_frame"}, + ] + + +def test_a_url_input_reference_is_passed_as_is_and_never_opened_as_a_path(): + body = _create_body({"input_reference": "https://x/first.png", "duration": 4}) + + assert body["content"] == [ + {"type": "text", "text": "a cat on a roof"}, + {"type": "image_url", "image_url": {"url": "https://x/first.png"}, "role": "first_frame"}, + ] + + +@pytest.mark.parametrize( + "content,expected", + [ + ( + [{"type": "image_url", "image_url": {"url": "https://x/a.png"}, "role": "reference_image"}], + [ + {"type": "text", "text": "a cat on a roof"}, + {"type": "image_url", "image_url": {"url": "https://x/a.png"}, "role": "reference_image"}, + ], + ), + ( + [{"type": "text", "text": "own prompt"}, {"type": "audio_url", "audio_url": {"url": "https://x/a.mp3"}}], + [{"type": "text", "text": "own prompt"}, {"type": "audio_url", "audio_url": {"url": "https://x/a.mp3"}}], + ), + ], + ids=["prompt-prepended", "own-text-item-kept"], +) +def test_a_caller_content_array_wins_over_input_reference(content: list[object], expected: list[object]): + body = _create_body({"content": content, "input_reference": "https://x/ignored.png", "duration": 4}) + + assert body["content"] == expected + + +@pytest.mark.parametrize( + "item", + [ + {"type": "video_url", "video_url": {"url": "https://x/v.mp4"}, "role": "reference_video"}, + {"type": "video_url", "video_url": {"url": "https://x/v.mp4"}}, + {"type": "image_url", "image_url": {"url": "https://x/v.png"}, "role": "reference_video"}, + ], + ids=["reference-video", "untagged-video", "mistagged-role"], +) +def test_a_video_input_is_rejected_because_its_cost_is_not_known_at_create(item: dict[str, object]): + with pytest.raises(UnsupportedParamsError, match="Video input"): + _ = _create_body({"content": [item], "duration": 4}, drop_params=True) + + +def test_a_content_value_that_is_not_an_array_of_items_is_rejected(): + with pytest.raises(UnsupportedParamsError, match="content must be an array"): + _ = _create_body({"content": "a string", "duration": 4}) + + +def test_an_omitted_duration_is_sent_as_five_seconds_so_ark_cannot_pick_the_length(): + body = _create_body({}) + + assert body == {"model": SEEDANCE_2_5, "content": [{"type": "text", "text": "a cat on a roof"}], "duration": 5} + + +@pytest.mark.parametrize("duration", [-1, "-1", 0, "abc", 4.5]) +def test_a_duration_ark_would_pick_or_that_is_not_whole_seconds_is_rejected(duration: object): + with pytest.raises(UnsupportedParamsError, match="duration must be a positive whole number"): + _ = _create_body({"duration": duration}, drop_params=True) + + +def test_frames_is_rejected_because_it_bypasses_the_billed_duration(): + with pytest.raises(UnsupportedParamsError, match="frames"): + _ = _create_body({"frames": 121, "duration": 4}) + + +def test_ark_fields_reach_the_body_and_false_flags_are_kept(): + body = _create_body( + { + "duration": 4, + "resolution": "480p", + "ratio": "9:16", + "seed": 7, + "watermark": False, + "generate_audio": False, + "service_tier": "flex", + "litellm_internal": "not for ark", + } ) - def test_size_snaps_onto_the_ratios_ark_accepts(self, size, expected_ratio, expected_resolution): - """854x480 reduces to 427:240 by gcd, which Ark rejects, so the closest - named ratio has to win instead.""" - mapped = VolcEngineVideoConfig().map_openai_params({"size": size}, "m", True) - assert mapped["ratio"] == expected_ratio - assert mapped["resolution"] == expected_resolution - - def test_explicit_ratio_and_resolution_beat_the_size_derived_ones(self): - mapped = VolcEngineVideoConfig().map_openai_params( - {"size": "1280x720", "ratio": "adaptive", "resolution": "1080p"}, "m", True - ) - - assert mapped == {"ratio": "adaptive", "resolution": "1080p"} - - def test_parameters_block_is_merged_in(self): - mapped = VolcEngineVideoConfig().map_openai_params({"parameters": {"priority": 5}}, "m", True) - - assert mapped == {"priority": 5} - - def test_openai_only_fields_ark_does_not_define_are_dropped(self): - mapped = VolcEngineVideoConfig().map_openai_params({"user": "u", "characters": [{"id": "c"}]}, "m", True) - - assert mapped == {} - - -class TestVolcEngineVideoCreateResponse: - def test_task_id_becomes_a_queued_video(self): - config = VolcEngineVideoConfig() - video = config.transform_video_create_response( - model=SEEDANCE_2_5, - raw_response=_mock_response({"id": "cgt-2026-abc", "safety_identifier": None}), - logging_obj=Mock(), - custom_llm_provider="volcengine", - ) - - assert video.status == "queued" - assert video.model == SEEDANCE_2_5 - assert decode_video_id_with_provider(video.id)["video_id"] == "cgt-2026-abc" - assert decode_video_id_with_provider(video.id)["model_id"] == SEEDANCE_2_5 - - def test_missing_task_id_raises_instead_of_returning_an_empty_video(self): - """A rejected create must not surface as a queued video with an empty id.""" - config = VolcEngineVideoConfig() - - with pytest.raises(VolcEngineVideoError, match="did not return a video task id"): - config.transform_video_create_response( - model=SEEDANCE_2_5, - raw_response=_mock_response({"error": {"code": "ModelNotOpen", "message": "model not activated"}}), - logging_obj=Mock(), - ) - - def _create_response(self, request_data: dict | None, payload: dict | None = None, model: str = SEEDANCE_2_5): - return VolcEngineVideoConfig().transform_video_create_response( - model=model, - raw_response=_mock_response(payload or {"id": "cgt-2026-abc"}), - logging_obj=Mock(), - request_data=request_data, - ) - - def test_create_response_carries_the_requested_usage(self): - """Only the create call is costed, and Ark's create response has no usage - block, so the requested values have to be reported here or every video - is logged at zero cost.""" - video = self._create_response({"duration": 4, "resolution": "480p"}) - - assert video.usage == {"duration_seconds": 4.0, "video_resolution": "480p"} - - def test_model_chosen_duration_is_not_reported_as_billable_seconds(self): - """duration -1 means Ark picks the length, so there is no honest number - to bill at create time.""" - video = self._create_response({"duration": -1, "resolution": "720p"}) - - assert "duration_seconds" not in video.usage - assert video.usage["video_resolution"] == "720p" - - def test_create_response_without_a_request_body_has_no_usage(self): - assert self._create_response(None).usage is None - - def test_an_omitted_duration_bills_the_2_0_default_length(self): - """Leaving seconds off is not free: Ark runs the model's own default - length, which for the 2.0 family is 5s, so the create response has to - report it or the video is logged at zero spend.""" - video = self._create_response({"resolution": "480p"}, model=SEEDANCE_2_0) - - assert video.usage == {"duration_seconds": 5.0, "video_resolution": "480p"} - - def test_an_omitted_duration_is_not_invented_for_2_5(self): - """The 2.5 family defaults duration to -1, so Ark picks the length and - there is no honest number to bill at create time.""" - video = self._create_response({"resolution": "480p"}, model=SEEDANCE_2_5) - - assert "duration_seconds" not in video.usage - - def test_the_2_0_default_does_not_override_an_explicit_minus_one(self): - """A caller asking Ark to pick the length must not be billed the model - default instead.""" - video = self._create_response({"duration": -1, "resolution": "480p"}, model=SEEDANCE_2_0) - - assert "duration_seconds" not in video.usage - - def test_create_response_echoes_the_requested_seconds_and_size(self): - video = self._create_response({"duration": 4, "resolution": "480p", "ratio": "16:9"}, model=SEEDANCE_2_0) - - assert video.seconds == "4.0" - assert video.size == "864x496" - - @pytest.mark.usefixtures("local_model_cost_map") - def test_the_create_call_is_not_billed_at_zero(self): - """Regression guard for the whole point of the usage block: feed what - the create response reports back through the cost calculator, which is - exactly what the proxy does when it logs spend.""" - from litellm.llms.openai.cost_calculation import video_generation_cost - from litellm.utils import get_model_info - - video = self._create_response({"duration": 4, "resolution": "1080p"}) - cost = video_generation_cost( - model=SEEDANCE_2_5, - duration_seconds=video.usage["duration_seconds"], - custom_llm_provider="volcengine", - video_resolution=video.usage["video_resolution"], - ) - info = get_model_info(model=f"volcengine/{SEEDANCE_2_5}", custom_llm_provider="volcengine") - - assert cost == pytest.approx(info["output_cost_per_second_1080p"] * 4.0) - assert cost > 0 - - -class TestVolcEngineVideoStatus: - @pytest.mark.parametrize( - "ark_status,expected", - [ - ("queued", "queued"), - ("running", "in_progress"), - ("succeeded", "completed"), - ("failed", "failed"), - ("cancelled", "cancelled"), - ("expired", "failed"), - ("Queued", "queued"), - ], - ) - def test_status_is_mapped_onto_the_openai_vocabulary(self, ark_status, expected): - video = _poll({"id": "cgt-1", "status": ark_status}) - - assert video.status == expected - - def test_completed_task_reports_delivered_geometry_and_duration(self): - """Billing reads Ark's own duration and resolution, not the requested - ones: duration=-1 and ratio=adaptive both let Ark deliver something - else, and the tier is what the per-second rate is keyed on.""" - video = _poll( - { - "id": "cgt-1", - "status": "succeeded", - "model": SEEDANCE_2_5, - "created_at": 1718049470, - "updated_at": 1718049700, - "resolution": "1080p", - "ratio": "16:9", - "duration": "8", - "content": {"video_url": "https://tos/v.mp4"}, - "usage": {"completion_tokens": 388800, "total_tokens": 388800}, - } - ) - - assert video.usage["duration_seconds"] == 8.0 - assert video.usage["video_resolution"] == "1080p" - assert video.usage["total_tokens"] == 388800 - assert video.size == "1920x1080" - assert video.seconds == "8.0" - assert video.created_at == 1718049470 - assert video.completed_at == 1718049700 - - def test_adaptive_ratio_does_not_produce_a_bogus_pixel_size(self): - video = _poll({"id": "cgt-1", "status": "succeeded", "resolution": "720p", "ratio": "adaptive"}) - - assert video.size is None - assert video.usage["video_resolution"] == "720p" - - def test_portrait_delivery_reports_portrait_pixels(self): - video = _poll({"id": "cgt-1", "status": "succeeded", "resolution": "720p", "ratio": "9:16"}) - - assert video.size == "720x1280" - - def test_480p_reports_the_width_ark_actually_produces(self): - """480 * 16 / 9 is 853.33 and the 2.5 family delivers 854x480, so - truncating would report a width the model never produces.""" - video = _poll( - {"id": "cgt-1", "status": "succeeded", "model": SEEDANCE_2_5, "resolution": "480p", "ratio": "16:9"} - ) - - assert video.size == "854x480" - - @pytest.mark.parametrize("ratio,expected", [("16:9", "864x496"), ("4:3", "752x560")]) - def test_480p_size_follows_the_2_0_familys_own_frame(self, ratio, expected): - """The 2.0 family renders a 480p canvas the tier name does not imply - (16:9 is 864x496, not 854x480), so scaling the tier's nominal shortest - side up the ratio reports pixels the model never produces.""" - video = _poll( - { - "id": "cgt-1", - "status": "succeeded", - "model": "doubao-seedance-2-0-260128", - "resolution": "480p", - "ratio": ratio, - } - ) - - assert video.size == expected - - def test_720p_size_is_the_same_for_both_families(self): - """Only the 480p canvas differs; both families deliver 1280x720.""" - video = _poll( - { - "id": "cgt-1", - "status": "succeeded", - "model": "doubao-seedance-2-0-260128", - "resolution": "720p", - "ratio": "16:9", - } - ) - - assert video.size == "1280x720" - - @pytest.mark.parametrize("model,expected", [("doubao-seedance-2-0-260128", "496x864"), (SEEDANCE_2_5, "480x854")]) - def test_portrait_480p_follows_the_same_family_frame(self, model, expected): - """The portrait frame is not just the landscape one transposed: the 2.5 - family's 480p short side is 480, the 2.0 family's is 496.""" - video = _poll({"id": "cgt-1", "status": "succeeded", "model": model, "resolution": "480p", "ratio": "9:16"}) - - assert video.size == expected - - def test_a_ratio_off_the_table_still_reports_a_pixel_size(self): - """A (tier, ratio) the model has no measured frame for falls back to the - tier's nominal shortest side rather than dropping the size.""" - video = _poll( - {"id": "cgt-1", "status": "succeeded", "model": SEEDANCE_2_5, "resolution": "1080p", "ratio": "4:3"} - ) - - assert video.size == "1440x1080" - - def test_queued_task_has_no_completion_time(self): - video = _poll({"id": "cgt-1", "status": "queued", "updated_at": 1718049700}) - - assert video.completed_at is None - - def test_failed_task_carries_the_error_block(self): - video = _poll( - { - "id": "cgt-1", - "status": "failed", - "error": {"code": "OutputVideoSensitiveContentDetected", "message": "flagged"}, - } - ) - - assert video.status == "failed" - assert video.error == {"code": "OutputVideoSensitiveContentDetected", "message": "flagged"} - - -class TestVolcEngineVideoContent: - def test_content_url_is_read_from_the_task(self): - task = _parse_task( - _mock_response({"id": "cgt-1", "status": "succeeded", "content": {"video_url": "https://tos/v.mp4"}}) - ) - - assert _video_url_from_task(task) == "https://tos/v.mp4" - - def test_pending_task_answers_not_found_so_the_router_does_not_retry(self): - task = _parse_task(_mock_response({"id": "cgt-1", "status": "running"})) - - with pytest.raises(VolcEngineVideoError, match="still processing") as raised: - _video_url_from_task(task) - - assert raised.value.status_code == 404 - - def test_failed_task_surfaces_the_upstream_message(self): - task = _parse_task(_mock_response({"id": "cgt-1", "status": "failed", "error": {"message": "quota exceeded"}})) - - with pytest.raises(VolcEngineVideoError, match="quota exceeded") as raised: - _video_url_from_task(task) - - assert raised.value.status_code == 400 - - def test_expired_task_points_at_the_retention_window(self): - task = _parse_task(_mock_response({"id": "cgt-1", "status": "expired"})) - - with pytest.raises(VolcEngineVideoError, match="expire 7 days") as raised: - _video_url_from_task(task) - - assert raised.value.status_code == 404 - - def test_content_request_polls_the_task(self): - url, params = VolcEngineVideoConfig().transform_video_content_request( - video_id="cgt-1", api_base=API_BASE, litellm_params=GenericLiteLLMParams(), headers={} - ) - - assert url == f"{TASKS_URL}/cgt-1" - assert params == {} - - -class TestVolcEngineVideoDownload: - """Downloading a task that is not finished is the caller's mistake, so it has to reach them as a - client error. A synthesized 500 had the proxy retry the same poll twice and then count the - caller's own early download as a gateway fault.""" - - @staticmethod - async def _download(task_payload: dict): - def respond(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json=task_payload) - - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: - handler: Final = AsyncHTTPHandler() - await handler.close() - handler.client = http_client - return await avideo_content( - video_id="cgt-2026-abc", - custom_llm_provider="volcengine", - model=f"volcengine/{SEEDANCE_2_5}", - api_key="sk-test", - api_base=API_BASE, - client=handler, - ) - - @pytest.mark.asyncio - async def test_running_task_is_a_not_found_error(self): - with pytest.raises(litellm.NotFoundError) as raised: - await self._download({"id": "cgt-2026-abc", "status": "running"}) - - assert raised.value.status_code == 404 - assert "still processing" in str(raised.value) - - @pytest.mark.asyncio - async def test_failed_task_is_a_bad_request(self): - with pytest.raises(litellm.BadRequestError) as raised: - await self._download({"id": "cgt-2026-abc", "status": "failed", "error": {"message": "flagged"}}) - - assert raised.value.status_code == 400 - assert "flagged" in str(raised.value) - - -class TestVolcEngineVideoList: - def test_list_hits_the_task_collection_with_ark_paging(self): - """Ark paginates with page_num/page_size and has no cursor or order, so - limit maps to page_size and the filters ride on extra_query.""" - url, params = VolcEngineVideoConfig().transform_video_list_request( - api_base=API_BASE, - litellm_params=GenericLiteLLMParams(), - headers={}, - limit=20, - extra_query={"filter.status": "succeeded"}, - ) - - assert url == TASKS_URL - assert params == {"page_size": "20", "filter.status": "succeeded"} - - def test_list_response_is_an_openai_list_body(self): - response = VolcEngineVideoConfig().transform_video_list_response( - raw_response=_mock_response( - { - "total": 1, - "items": [ - { - "id": "cgt-1", - "status": "succeeded", - "model": SEEDANCE_2_5, - "resolution": "720p", - "ratio": "16:9", - "duration": 5, - } - ], - } - ), - logging_obj=Mock(), - custom_llm_provider="volcengine", - ) - - assert response["object"] == "list" - assert response["total"] == 1 - assert response["first_id"] == response["last_id"] - (item,) = response["data"] - assert item["status"] == "completed" - assert decode_video_id_with_provider(item["id"])["video_id"] == "cgt-1" - - def test_empty_list_body_still_carries_the_object_type(self): - response = VolcEngineVideoConfig().transform_video_list_response( - raw_response=_mock_response({"total": 0, "items": []}), - logging_obj=Mock(), - ) - - assert response == {"object": "list", "data": [], "total": 0} - - -class TestVolcEngineVideoDelete: - def test_delete_targets_the_task_url(self): - url, params = VolcEngineVideoConfig().transform_video_delete_request( - video_id=encode_video_id_with_provider("cgt-1", "volcengine", SEEDANCE_2_5), - api_base=API_BASE, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) - - assert url == f"{TASKS_URL}/cgt-1" - assert params == {} - - def test_delete_response_reports_the_deleted_task(self): - video = VolcEngineVideoConfig().transform_video_delete_response( - raw_response=_mock_response({"id": "cgt-1", "model": SEEDANCE_2_5}), logging_obj=Mock() - ) - - assert video.id == "cgt-1" - assert video.status == "deleted" - - def test_delete_response_tolerates_an_empty_body(self): - """Ark's delete has no documented body, so an empty 200 must not crash.""" - raw_response = Mock(spec=httpx.Response) - raw_response.json.side_effect = ValueError("no body") - - video = VolcEngineVideoConfig().transform_video_delete_response(raw_response=raw_response, logging_obj=Mock()) - - assert video.status == "deleted" - - -class TestVolcEngineVideoEditAndExtension: - @staticmethod - def _video_id(source: str = "cgt-1") -> str: - return encode_video_id_with_provider(source, "volcengine", SEEDANCE_2_5) - - def test_edit_pins_the_adaptive_geometry_ark_requires(self): - """Ark rejects an edit task that does not ask it to resolve the source - geometry itself, so ratio and duration cannot be left to the caller.""" - url, body, files = VolcEngineVideoConfig().transform_video_edit_request( - prompt="make it brighter", - video_id=self._video_id(), - api_base=API_BASE, - litellm_params=GenericLiteLLMParams(), - headers={}, - extra_body={"video_url": "https://tos/source.mp4"}, - ) - - assert url == TASKS_URL - assert files is None - assert body["ratio"] == "adaptive" - assert body["duration"] == -1 - assert body["content"] == [ - {"type": "text", "text": "make it brighter"}, - {"type": "video_url", "video_url": {"url": "https://tos/source.mp4"}, "role": "reference_video"}, - ] - - def test_edit_recovers_the_model_and_the_source_url_from_the_video_id(self): - """The edit transform gets no model argument, so both the model and, when - the id wraps a URL, the source video have to come out of the video id.""" - _, body, _ = VolcEngineVideoConfig().transform_video_edit_request( - prompt="p", - video_id=self._video_id("https://tos/source.mp4"), - api_base=API_BASE, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) - - assert body["model"] == SEEDANCE_2_5 - assert body["content"][1]["video_url"]["url"] == "https://tos/source.mp4" - - def test_edit_model_can_be_named_in_extra_body(self): - _, body, _ = VolcEngineVideoConfig().transform_video_edit_request( - prompt="p", - video_id="https://tos/source.mp4", - api_base=API_BASE, - litellm_params=GenericLiteLLMParams(), - headers={}, - extra_body={"model": SEEDANCE_2_5}, - ) - - assert body["model"] == SEEDANCE_2_5 - - def test_edit_model_falls_back_to_the_routed_model(self): - """A caller editing an arbitrary video URL passes the model once, for - routing; making them repeat it in extra_body is a trap. The router's - copy carries litellm's provider prefix, which Ark does not take.""" - _, body, _ = VolcEngineVideoConfig().transform_video_edit_request( - prompt="p", - video_id="https://tos/source.mp4", - api_base=API_BASE, - litellm_params={"model": f"volcengine/{SEEDANCE_2_5}"}, # pyright: ignore[reportArgumentType] # the handler hands the transform a mapping, not the GenericLiteLLMParams the base declares - headers={}, - ) - - assert body["model"] == SEEDANCE_2_5 - - def test_edit_model_falls_back_to_the_routed_model_object(self): - """litellm's own entrypoint builds a GenericLiteLLMParams, so the - fallback has to read both shapes.""" - _, body, _ = VolcEngineVideoConfig().transform_video_edit_request( - prompt="p", - video_id="https://tos/source.mp4", - api_base=API_BASE, - litellm_params=GenericLiteLLMParams(model=f"volcengine/{SEEDANCE_2_5}"), - headers={}, - ) - - assert body["model"] == SEEDANCE_2_5 - - def test_edit_without_a_model_raises(self): - with pytest.raises(ValueError, match="need the model that produced the source video"): - VolcEngineVideoConfig().transform_video_edit_request( - prompt="p", - video_id="https://tos/source.mp4", - api_base=API_BASE, - litellm_params={}, - headers={}, - ) - - def test_edit_without_a_resolvable_source_url_raises(self): - """Ark's task query is a GET while litellm's edit prefetch hook only - POSTs, so a bare task id cannot be turned into a video URL.""" - with pytest.raises(ValueError, match="need the source video URL"): - VolcEngineVideoConfig().transform_video_edit_request( - prompt="p", - video_id=self._video_id(), - api_base=API_BASE, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) - - def test_extension_carries_the_seconds_to_add(self): - url, body = VolcEngineVideoConfig().transform_video_extension_request( - prompt="keep going", - video_id=self._video_id("https://tos/source.mp4"), - seconds="5", - api_base=API_BASE, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) - - assert url == TASKS_URL - assert body["duration"] == 5 - assert body["ratio"] == "adaptive" - assert body["content"][1]["role"] == "reference_video" - - def test_extension_without_seconds_leaves_the_duration_to_ark(self): - _, body = VolcEngineVideoConfig().transform_video_extension_request( - prompt="keep going", - video_id=self._video_id("https://tos/source.mp4"), - seconds="", - api_base=API_BASE, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) - - assert "duration" not in body - - def test_remix_raises_rather_than_guessing_an_endpoint(self): - with pytest.raises(NotImplementedError, match="not supported by Volcengine"): - VolcEngineVideoConfig().transform_video_remix_request( - video_id="v", prompt="p", api_base=API_BASE, litellm_params=GenericLiteLLMParams(), headers={} - ) - - -class TestVolcEngineVideoEnvironment: - @pytest.mark.parametrize( - "api_base", - [ - None, - API_BASE, - f"{API_BASE}/", - f"{API_BASE}/api/v3", - f"{API_BASE}/api/compatible", - f"{API_BASE}/api/v3/responses", - f"{API_BASE}/v1", - ], - ) - def test_api_base_is_normalized_to_the_host_root(self, api_base): - """Every Ark surface shares one credential, so the api_base may carry - whichever one the caller uses. Leaving the surface on the base produces - /api/compatible/api/v3/... and a 404 from Ark.""" - assert VolcEngineVideoConfig().get_complete_url("m", api_base, {}) == API_BASE - - def test_a_path_prefix_before_the_api_marker_survives(self): - """A self-hosted Ark-compatible gateway may sit under a path prefix.""" - url = VolcEngineVideoConfig().get_complete_url("m", "https://gw.internal/ark/api/v3", {}) - - assert url == "https://gw.internal/ark" - - def test_bearer_token_reaches_the_headers(self): - headers = VolcEngineVideoConfig().validate_environment({}, SEEDANCE_2_5, api_key="sk-ark") - - assert headers["Authorization"] == "Bearer sk-ark" - - def test_missing_api_key_raises(self, monkeypatch): - monkeypatch.delenv("ARK_API_KEY", raising=False) - monkeypatch.delenv("VOLCENGINE_API_KEY", raising=False) - - with pytest.raises(ValueError, match="Volcengine API key is required"): - VolcEngineVideoConfig().validate_environment({}, SEEDANCE_2_5) - - -class TestVolcEngineVideoProviderWiring: - def test_provider_resolves_to_the_video_config(self): - """Without this registration litellm answers 'video generation is not - supported for volcengine' before any transform runs.""" - import litellm - from litellm.utils import ProviderConfigManager - - config = ProviderConfigManager.get_provider_video_config( - model=SEEDANCE_2_5, provider=litellm.LlmProviders("volcengine") - ) - - assert type(config) is VolcEngineVideoConfig + assert body == { + "model": SEEDANCE_2_5, + "content": [{"type": "text", "text": "a cat on a roof"}], + "resolution": "480p", + "ratio": "9:16", + "seed": 7, + "watermark": False, + "generate_audio": False, + "service_tier": "flex", + "duration": 4, + } @pytest.mark.usefixtures("local_model_cost_map") -class TestVolcEngineVideoPricing: - """ - Ark bills per output token, so the per-second tiers are derived rather than - published as such. These assert the derivation holds rather than pinning a - list price that a vendor price change would break. - """ +def test_a_resolution_the_model_has_no_price_for_is_rejected(): + with pytest.raises(UnsupportedParamsError, match="not offered at resolution '1080p', only at 480p, 720p"): + _ = _create_body({"duration": 4, "resolution": "1080p"}, model=SEEDANCE_2_0_FAST) - @pytest.mark.parametrize( - "model,tiers", - [ - ("doubao-seedance-2-5-260628", {"480p": (854, 480), "720p": (1280, 720), "1080p": (1920, 1080)}), - ( - "doubao-seedance-2-0-260128", - {"480p": (864, 496), "720p": (1280, 720), "1080p": (1920, 1080), "4k": (3840, 2160)}, - ), - ("doubao-seedance-2-0-fast-260128", {"480p": (864, 496), "720p": (1280, 720)}), - ("doubao-seedance-2-0-mini-260615", {"480p": (864, 496), "720p": (1280, 720)}), - ], + +@pytest.mark.usefixtures("local_model_cost_map") +def test_an_unpriced_resolution_is_dropped_under_drop_params(): + body = _create_body( + {"duration": 4, "resolution": "1080p", "ratio": "16:9"}, model=SEEDANCE_2_0_FAST, drop_params=True ) - def test_each_tier_rate_sits_at_its_pixel_ratio_to_the_base_rate(self, model, tiers): - """Ark bills tokens = w*h*24*seconds/1024, so a tier's per-second rate - must be the base rate scaled by its pixel count. The 2.0 family's 16:9 - 480p frame is 864x496, not the 854x480 the tier name implies, which is - what its 480p rate has to be scaled against. A tier whose key is - missing silently falls back to the base rate instead.""" - from litellm.utils import get_model_info - info = get_model_info(model=f"volcengine/{model}", custom_llm_provider="volcengine") - base_rate: Final = info["output_cost_per_second"] - base_pixels: Final = 1280 * 720 + assert body == { + "model": SEEDANCE_2_0_FAST, + "content": [{"type": "text", "text": "a cat on a roof"}], + "ratio": "16:9", + "duration": 4, + } - for tier, (width, height) in tiers.items(): - rate = info[f"output_cost_per_second_{tier}"] - assert rate == pytest.approx(base_rate * width * height / base_pixels, rel=1e-5), tier - def test_cost_calculator_picks_the_reported_tier(self): - """The transform reports Ark's delivered resolution; if it did not, every - video would bill at the base tier regardless of what was generated.""" - from litellm.llms.openai.cost_calculation import video_generation_cost - from litellm.utils import get_model_info +def test_a_mistyped_ark_field_is_rejected(): + with pytest.raises(UnsupportedParamsError, match="invalid seed"): + _ = _create_body({"duration": 4, "seed": "abc", "watermark": False}) - info = get_model_info(model="volcengine/doubao-seedance-2-5-260628", custom_llm_provider="volcengine") - cost = video_generation_cost( - model="doubao-seedance-2-5-260628", - duration_seconds=4.0, + +def test_a_mistyped_ark_field_is_dropped_under_drop_params(): + body = _create_body({"duration": 4, "seed": "abc", "watermark": False}, drop_params=True) + + assert body == { + "model": SEEDANCE_2_5, + "content": [{"type": "text", "text": "a cat on a roof"}], + "watermark": False, + "duration": 4, + } + + +@pytest.mark.parametrize( + "size,expected", + [ + ("1920x1080", {"ratio": "16:9", "resolution": "1080p"}), + ("854x480", {"ratio": "16:9", "resolution": "480p"}), + ("864x496", {"ratio": "16:9", "resolution": "480p"}), + ("720x1280", {"ratio": "9:16", "resolution": "720p"}), + ("1024x768", {"ratio": "4:3", "resolution": "720p"}), + ("960x960", {"ratio": "1:1", "resolution": "1080p"}), + ("1470x630", {"ratio": "21:9", "resolution": "720p"}), + ("3840x2160", {"ratio": "16:9", "resolution": "4k"}), + ], +) +def test_size_maps_onto_an_ark_ratio_and_tier(size: str, expected: dict[str, str]): + assert VolcEngineVideoConfig().map_openai_params({"size": size}, SEEDANCE_2_5, False) == expected + + +def test_explicit_ratio_and_resolution_beat_the_size_derived_ones(): + mapped = VolcEngineVideoConfig().map_openai_params( + {"size": "1280x720", "seconds": "4", "ratio": "1:1", "resolution": "480p", "parameters": {"seed": 3}}, + SEEDANCE_2_5, + False, + ) + + assert mapped == {"ratio": "1:1", "resolution": "480p", "duration": 4, "seed": 3} + + +@pytest.mark.parametrize( + "params,message", + [ + ({"seconds": "4.5"}, "seconds must be a whole number"), + ({"size": "big"}, "size must be WIDTHxHEIGHT"), + ({"size": "16:9"}, "size must be WIDTHxHEIGHT"), + ({"parameters": "seed=3"}, "parameters must be an object"), + ({"characters": [{"id": "c"}]}, "'characters' is not supported"), + ({"image": "https://x/a.png"}, "'image' is not supported"), + ], +) +def test_a_value_ark_cannot_take_raises_unless_drop_params(params: dict[str, object], message: str): + with pytest.raises(UnsupportedParamsError, match=message): + _ = VolcEngineVideoConfig().map_openai_params(params, SEEDANCE_2_5, False) + + assert VolcEngineVideoConfig().map_openai_params(params, SEEDANCE_2_5, True) == {} + + +def test_request_metadata_is_not_mapped_into_the_body(): + mapped = VolcEngineVideoConfig().map_openai_params( + {"user": "u", "extra_headers": {"x": "y"}, "seconds": 4}, SEEDANCE_2_5, False + ) + + assert mapped == {"duration": 4} + + +@pytest.mark.usefixtures("local_model_cost_map") +@pytest.mark.parametrize( + "model,request_data,seconds,size,usage", + [ + ( + SEEDANCE_2_5, + {"duration": 4, "resolution": "480p", "ratio": "16:9"}, + "4.0", + "854x480", + {"duration_seconds": 4.0, "video_resolution": "480p"}, + ), + ( + SEEDANCE_2_0, + {"duration": 5, "resolution": "480p", "ratio": "16:9"}, + "5.0", + "864x496", + {"duration_seconds": 5.0, "video_resolution": "480p"}, + ), + (SEEDANCE_2_5, {"duration": 5}, "5.0", None, {"duration_seconds": 5.0}), + ], + ids=["2.5-480p", "2.0-480p-renders-its-own-frame", "no-geometry"], +) +def test_the_create_response_reports_what_the_task_bills( + model: str, request_data: dict[str, object], seconds: str, size: str | None, usage: dict[str, object] +): + video = _created(request_data, model=model) + + assert video.model_dump(include={"status", "seconds", "size", "usage", "model"}) == { + "status": "queued", + "seconds": seconds, + "size": size, + "usage": usage, + "model": model, + } + + +@pytest.mark.parametrize( + "usage,expected_cost", + [ + ({"duration_seconds": 4.0, "video_resolution": "480p"}, 4 * 0.1), + ({"duration_seconds": 5.0, "video_resolution": "720p"}, 5 * 0.2), + ({"duration_seconds": 5.0}, 5 * 0.2), + ], +) +def test_the_create_usage_bills_its_duration_at_its_tier_rate(usage: dict[str, object], expected_cost: float): + model_info = { + "output_cost_per_second": 0.2, + "output_cost_per_second_480p": 0.1, + "output_cost_per_second_720p": 0.2, + } + video = _created({"duration": int(usage["duration_seconds"]), "resolution": usage.get("video_resolution")}) + + cost = video_generation_cost( + model=SEEDANCE_2_5, + duration_seconds=video.usage["duration_seconds"], + model_info=model_info, # pyright: ignore[reportArgumentType] # a hand-written rate card stands in for ModelInfo + video_resolution=video.usage.get("video_resolution"), + ) + + assert cost == pytest.approx(expected_cost) + + +def test_the_created_id_carries_the_requested_and_served_model_groups(): + logging_obj = Mock( + litellm_params={"litellm_metadata": {"model_group": "seedance", "deployment_model_name": "seedance-account-b"}} + ) + + video = _created({"duration": 5}, logging_obj=logging_obj) + + assert decode_video_id_with_provider(video.id) == { + "custom_llm_provider": "volcengine", + "model_id": "seedance", + "video_id": TASK_ID, + "served_model": "seedance-account-b", + } + + +def test_a_create_without_router_metadata_carries_the_model(): + video = _created({"duration": 5}, logging_obj=Mock(litellm_params={})) + + assert decode_video_id_with_provider(video.id) == { + "custom_llm_provider": "volcengine", + "model_id": SEEDANCE_2_5, + "video_id": TASK_ID, + } + + +@pytest.mark.parametrize( + "raw_response,status_code,message", + [ + (_response({"error": {"code": "InvalidParameter", "message": "bad ratio"}}), 400, "bad ratio"), + (_response({"error": {"code": "Forbidden", "message": "no access"}}, 403), 403, "no access"), + (_text_response("bad gateway"), 502, "Unexpected Volcengine video response"), + ], + ids=["error-on-200", "error-status-kept", "not-json"], +) +def test_a_create_response_without_a_task_id_raises_instead_of_queueing_an_empty_video( + raw_response: httpx.Response, status_code: int, message: str +): + with pytest.raises(VolcEngineVideoError, match=message) as raised: + _ = VolcEngineVideoConfig().transform_video_create_response( + model=SEEDANCE_2_5, + raw_response=raw_response, + logging_obj=None, # pyright: ignore[reportArgumentType] # the transform only reads litellm_params off it custom_llm_provider="volcengine", - video_resolution="1080p", + request_data={"duration": 5}, ) - assert cost == pytest.approx(info["output_cost_per_second_1080p"] * 4.0) - assert cost > info["output_cost_per_second_720p"] * 4.0 + assert raised.value.status_code == status_code - def test_unlabelled_resolution_falls_back_to_the_base_tier(self): - """An unknown tier must not bill at zero.""" - from litellm.llms.openai.cost_calculation import video_generation_cost - from litellm.utils import get_model_info - info = get_model_info(model="volcengine/doubao-seedance-2-5-260628", custom_llm_provider="volcengine") - cost = video_generation_cost( - model="doubao-seedance-2-5-260628", - duration_seconds=3.0, +@pytest.mark.parametrize( + "ark_status,expected", + [ + ("queued", "queued"), + ("running", "in_progress"), + ("succeeded", "completed"), + ("failed", "failed"), + ("cancelled", "cancelled"), + ("expired", "failed"), + ], +) +def test_the_ark_status_maps_onto_the_openai_vocabulary(ark_status: str, expected: str): + assert _polled(_task(status=ark_status)).status == expected + + +@pytest.mark.usefixtures("local_model_cost_map") +def test_a_completed_task_reports_its_delivered_geometry_duration_and_tokens(): + video = _polled( + _task( + created_at=1791989938, + updated_at=1791990031, + resolution="480p", + ratio="16:9", + duration=4, + usage={"completion_tokens": 40594, "total_tokens": 40594}, + model=SEEDANCE_2_0, + content={"video_url": "https://tos/v.mp4"}, + ) + ) + + assert video.model_dump(exclude={"id"}) | {"id": decode_video_id_with_provider(video.id)["video_id"]} == { + "id": TASK_ID, + "object": "video", + "status": "completed", + "created_at": 1791989938, + "completed_at": 1791990031, + "expires_at": None, + "error": None, + "progress": None, + "remixed_from_video_id": None, + "seconds": "4.0", + "size": "864x496", + "model": SEEDANCE_2_0, + "usage": {"duration_seconds": 4.0, "video_resolution": "480p", "total_tokens": 40594}, + } + + +@pytest.mark.parametrize( + "resolution,ratio,expected", + [ + ("720p", "9:16", "720x1280"), + ("1080p", "16:9", "1920x1080"), + ("480p", "adaptive", None), + ], +) +def test_a_frame_the_cost_map_does_not_list_is_scaled_from_the_tier(resolution: str, ratio: str, expected: str | None): + assert _polled(_task(resolution=resolution, ratio=ratio, model="doubao-seedance-unlisted")).size == expected + + +def test_a_failed_task_carries_its_error_block(): + video = _polled(_task(status="failed", error={"code": "OutputVideoSensitiveContentDetected", "message": "flagged"})) + + assert (video.status, video.error, video.completed_at) == ( + "failed", + {"code": "OutputVideoSensitiveContentDetected", "message": "flagged"}, + None, + ) + + +@pytest.mark.parametrize( + "payload,message", + [ + (_task(status="paused"), "Unknown Volcengine task status: paused"), + ({"status": "running"}, "Unexpected Volcengine video response"), + ({"id": TASK_ID}, "Unexpected Volcengine video response"), + ], + ids=["unmapped-status", "no-id", "no-status"], +) +def test_a_poll_litellm_cannot_read_raises_instead_of_returning_a_job_to_poll_forever(payload: object, message: str): + with pytest.raises(VolcEngineVideoError, match=message) as raised: + _ = _polled(payload) + + assert raised.value.status_code == 502 + + +def test_a_poll_keeps_the_ids_requested_and_served_model_when_the_router_served_it(): + video_id = encode_video_id_with_provider(TASK_ID, "volcengine", "seedance", served_model="seedance-account-b") + logging_obj = Mock( + litellm_params={ + "video_id": video_id, + "litellm_metadata": {"model_group": "seedance", "deployment_model_name": "seedance-account-b"}, + } + ) + + video = _polled(_task(status="running"), logging_obj=logging_obj) + + assert video.id == video_id + assert video.model == "seedance" + + +def test_a_poll_drops_a_served_model_the_router_did_not_route_to(): + edited = encode_video_id_with_provider( + TASK_ID, "volcengine", "seedance-account-a", served_model="seedance-account-b" + ) + logging_obj = Mock(litellm_params={"video_id": edited, "litellm_metadata": {"model_group": "seedance-account-a"}}) + + video = _polled(_task(status="running"), logging_obj=logging_obj) + + assert video.id == encode_video_id_with_provider(TASK_ID, "volcengine", "seedance-account-a") + + +async def _download(task_payload: dict[str, object], video_bytes: bytes = b"mp4") -> bytes: + def respond(request: httpx.Request) -> httpx.Response: + if request.url.host == "tos.example": + return httpx.Response(200, content=video_bytes) + return httpx.Response(200, json=task_payload) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + handler: Final = AsyncHTTPHandler() + await handler.close() + handler.client = http_client + return await avideo_content( + video_id=TASK_ID, custom_llm_provider="volcengine", - video_resolution=None, - ) - - assert cost == pytest.approx(info["output_cost_per_second"] * 3.0) - assert cost > 0 - - -class TestVolcEngineVideoEndToEndRequest: - """ - Drive the real litellm entrypoint rather than calling the transform directly. - - ``video_generation()`` runs map_openai_params first and passes only the - *mapped* params to transform_video_create_request, so anything the mapper - drops never reaches the request builder. Asserting on the JSON the handler - actually serialized also catches body fragments that aren't JSON-encodable. - """ - - @staticmethod - async def _wire_request(**kwargs) -> dict: - sent: Final[list[httpx.Request]] = [] - - def respond(request: httpx.Request) -> httpx.Response: - sent.append(request) - return httpx.Response(200, json={"id": "cgt-2026-abc", "safety_identifier": None}) - - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: - handler: Final = AsyncHTTPHandler() - await handler.close() - handler.client = http_client - await avideo_generation(api_key="sk-test", api_base=API_BASE, client=handler, **kwargs) - - (request,) = sent - return {"serialized": json.loads(request.content), "headers": dict(request.headers), "url": str(request.url)} - - @pytest.mark.asyncio - async def test_seconds_and_size_reach_the_body_as_duration_and_tier(self): - captured = await self._wire_request( - model=f"volcengine/{SEEDANCE_2_5}", prompt="a train", seconds="5", size="1920x1080" - ) - - assert captured["serialized"] == { - "model": SEEDANCE_2_5, - "content": [{"type": "text", "text": "a train"}], - "resolution": "1080p", - "ratio": "16:9", - "duration": 5, - } - assert captured["url"] == TASKS_URL - - @pytest.mark.asyncio - async def test_input_reference_survives_param_mapping_into_the_body(self): - """Regression: if the mapper drops input_reference an image-to-video call - silently degrades to text-to-video.""" - captured = await self._wire_request( - model=f"volcengine/{SEEDANCE_2_5}", prompt="make it move", input_reference="https://x/first.png" - ) - - assert captured["serialized"]["content"][1] == { - "type": "image_url", - "image_url": {"url": "https://x/first.png"}, - "role": "first_frame", - } - assert "input_reference" not in captured["serialized"] - - @pytest.mark.asyncio - async def test_extra_body_content_array_reaches_the_wire(self): - """Reference-to-video has no OpenAI-shaped parameter, so extra_body is - the only way to express it.""" - captured = await self._wire_request( model=f"volcengine/{SEEDANCE_2_5}", - prompt="use these", - extra_body={ - "content": [ - {"type": "image_url", "image_url": {"url": "https://x/a.png"}, "role": "reference_image"}, - {"type": "audio_url", "audio_url": {"url": "https://x/a.mp3"}, "role": "reference_audio"}, - ] - }, + api_key="sk-test", + api_base=API_BASE, + client=handler, ) - assert captured["serialized"]["content"] == [ + +@pytest.mark.asyncio +async def test_the_download_fetches_the_signed_url_through_the_callers_client(): + content = await _download(_task(content={"video_url": "https://tos.example/v.mp4"}), video_bytes=b"the-video") + + assert content == b"the-video" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload,message", + [ + (_task(status="queued"), "still processing"), + (_task(status="running"), "still processing"), + (_task(status="failed", error={"message": "flagged"}), "flagged"), + (_task(status="failed"), "ended as failed"), + (_task(status="cancelled"), "ended as cancelled"), + (_task(status="expired"), "Task expired"), + ], +) +async def test_a_download_with_no_video_yet_is_a_bad_request_so_it_is_neither_retried_nor_cools_the_deployment( + payload: dict[str, object], message: str +): + with pytest.raises(litellm.BadRequestError, match=message) as raised: + _ = await _download(payload) + + assert raised.value.status_code == 400 + + +def test_the_list_maps_ark_paging_and_filters(): + url, params = VolcEngineVideoConfig().transform_video_list_request( + api_base=API_BASE, + litellm_params=GenericLiteLLMParams(), + headers={}, + limit=20, + extra_query={"filter.status": "succeeded"}, + ) + + assert (url, params) == (TASKS_URL, {"page_size": "20", "filter.status": "succeeded"}) + + +def test_the_list_response_is_an_openai_list_body(): + response = VolcEngineVideoConfig().transform_video_list_response( + raw_response=_response({"total": 1, "items": [_task(status="running")]}), + logging_obj=Mock(), # pyright: ignore[reportArgumentType] # unused by the transform + custom_llm_provider="volcengine", + ) + + encoded = encode_video_id_with_provider(TASK_ID, "volcengine", SEEDANCE_2_5) + assert {key: value for key, value in response.items() if key != "data"} == { + "object": "list", + "total": 1, + "first_id": encoded, + "last_id": encoded, + } + assert [(item["id"], item["status"]) for item in response["data"]] == [(encoded, "in_progress")] + + +def test_an_empty_list_still_carries_the_object_type(): + response = VolcEngineVideoConfig().transform_video_list_response( + raw_response=_response({"total": 0, "items": []}), + logging_obj=Mock(), # pyright: ignore[reportArgumentType] # unused by the transform + ) + + assert response == {"object": "list", "data": [], "total": 0} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("call", ["avideo_edit", "avideo_extension"]) +async def test_edit_and_extension_are_a_bad_request_because_a_video_input_cannot_be_billed_at_create(call: str): + with pytest.raises(litellm.BadRequestError, match="Ark bills a task with a video input") as raised: + _ = await getattr(litellm, call)( + video_id="https://x/source.mp4", + prompt="make it brighter", + seconds="5", + model=f"volcengine/{SEEDANCE_2_5}", + custom_llm_provider="volcengine", + api_key="sk-test", + api_base=API_BASE, + ) + + assert raised.value.status_code == 400 + + +@pytest.mark.parametrize( + "api_base", + [ + None, + API_BASE, + f"{API_BASE}/", + f"{API_BASE}/api/v3", + f"{API_BASE}/api/compatible", + f"{API_BASE}/api/v3/responses", + f"{API_BASE}/v1", + ], +) +def test_any_ark_surface_on_the_shared_credential_normalizes_to_the_host_root(api_base: str | None): + assert VolcEngineVideoConfig().get_complete_url("m", api_base, {}) == API_BASE + + +def test_a_path_prefix_before_the_api_marker_survives(): + assert ( + VolcEngineVideoConfig().get_complete_url("m", "https://gw.internal/ark/api/v3", {}) == "https://gw.internal/ark" + ) + + +def test_a_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("ARK_API_KEY", raising=False) + monkeypatch.delenv("VOLCENGINE_API_KEY", raising=False) + + with pytest.raises(ValueError, match="Volcengine API key is required"): + _ = VolcEngineVideoConfig().validate_environment({}, SEEDANCE_2_5) + + +def test_volcengine_resolves_to_the_video_config(): + config = ProviderConfigManager.get_provider_video_config( + model=SEEDANCE_2_5, provider=litellm.LlmProviders.VOLCENGINE + ) + + assert type(config) is VolcEngineVideoConfig + + +@pytest.mark.usefixtures("local_model_cost_map") +@pytest.mark.parametrize( + "model,tiers", + [ + (SEEDANCE_2_5, {"480p": (854, 480), "720p": (1280, 720), "1080p": (1920, 1080)}), + (SEEDANCE_2_0, {"480p": (864, 496), "720p": (1280, 720), "1080p": (1920, 1080), "4k": (3840, 2160)}), + (SEEDANCE_2_0_FAST, {"480p": (864, 496), "720p": (1280, 720)}), + ("doubao-seedance-2-0-mini-260615", {"480p": (864, 496), "720p": (1280, 720)}), + ], +) +def test_each_tier_rate_is_the_base_rate_scaled_by_its_rendered_pixels(model: str, tiers: dict[str, tuple[int, int]]): + info = litellm.get_model_info(model=model, custom_llm_provider="volcengine") + base_rate: Final = info["output_cost_per_second"] + + assert {tier: info[f"output_cost_per_second_{tier}"] for tier in tiers} == { + tier: pytest.approx(base_rate * width * height / (1280 * 720), rel=1e-5) + for tier, (width, height) in tiers.items() + } + + +@pytest.mark.usefixtures("local_model_cost_map") +@pytest.mark.parametrize("model", [SEEDANCE_2_5, SEEDANCE_2_0, SEEDANCE_2_0_FAST, "doubao-seedance-2-0-mini-260615"]) +def test_the_16_9_frame_listed_for_each_tier_is_the_one_its_rate_was_derived_from(model: str): + info = litellm.get_model_info(model=model, custom_llm_provider="volcengine") + frames = info["provider_specific_entry"]["volcengine_video_frames"] + base_rate: Final = info["output_cost_per_second"] + + for tier, by_ratio in frames.items(): + width, height = (int(side) for side in by_ratio["16:9"].split("x")) + assert info[f"output_cost_per_second_{tier}"] == pytest.approx( + base_rate * width * height / (1280 * 720), rel=1e-5 + ), tier + + +async def _wire_body(**kwargs: object) -> dict[str, object]: + sent: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + sent.append(request) + return httpx.Response(200, json={"id": TASK_ID}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + handler: Final = AsyncHTTPHandler() + await handler.close() + handler.client = http_client + _ = await avideo_generation(api_key="sk-test", api_base=API_BASE, client=handler, **kwargs) + + (request,) = sent + assert (str(request.url), request.headers["authorization"]) == (TASKS_URL, "Bearer sk-test") + return json.loads(request.content) + + +@pytest.mark.asyncio +async def test_seconds_size_and_extra_body_content_reach_the_wire(): + body = await _wire_body( + model=f"volcengine/{SEEDANCE_2_5}", + prompt="use these", + seconds="4", + size="854x480", + extra_body={ + "content": [{"type": "image_url", "image_url": {"url": "https://x/a.png"}, "role": "reference_image"}] + }, + ) + + assert body == { + "model": SEEDANCE_2_5, + "content": [ {"type": "text", "text": "use these"}, {"type": "image_url", "image_url": {"url": "https://x/a.png"}, "role": "reference_image"}, - {"type": "audio_url", "audio_url": {"url": "https://x/a.mp3"}, "role": "reference_audio"}, + ], + "ratio": "16:9", + "resolution": "480p", + "duration": 4, + } + + +@pytest.mark.asyncio +async def test_an_extra_body_duration_of_minus_one_is_rejected_before_anything_is_sent(): + with pytest.raises(UnsupportedParamsError, match="Ark-picked length"): + _ = await _wire_body(model=f"volcengine/{SEEDANCE_2_5}", prompt="p", extra_body={"duration": -1}) + + +ACCOUNT_A_HOST: Final = "https://ark-a.example" +ACCOUNT_B_HOST: Final = "https://ark-b.example" + + +def _two_account_router(**router_kwargs: object) -> Router: + return Router( + model_list=[ + { + "model_name": "seedance-a", + "litellm_params": { + "model": f"volcengine/{SEEDANCE_2_5}", + "api_key": "key-a", + "api_base": ACCOUNT_A_HOST, + }, + }, + { + "model_name": "seedance-b", + "litellm_params": { + "model": f"volcengine/{SEEDANCE_2_5}", + "api_key": "key-b", + "api_base": ACCOUNT_B_HOST, + }, + }, + ], + **router_kwargs, # pyright: ignore[reportArgumentType] # forwarded Router options + ) + + +def _proxy_poll_model(router: Router, video_id: str) -> str | None: + decoded: Final = decode_video_id_with_provider(video_id) + requested: Final = router.resolve_model_name_from_model_id( + decoded.get("model_id"), decoded.get("custom_llm_provider") + ) + served: Final = decoded.get("served_model") + return router.resolve_served_model_name(requested, served) if requested and served else requested + + +@pytest.mark.parametrize("caller_metadata", [None, {"tags": ["proxy-request"]}]) +@respx.mock +def test_a_video_is_polled_on_the_ark_account_that_created_it(caller_metadata: dict[str, object] | None): + router = _two_account_router() + _ = respx.post(f"{ACCOUNT_B_HOST}/api/v3/contents/generations/tasks").mock( + return_value=httpx.Response(200, json={"id": TASK_ID}) + ) + task_get = respx.get(f"{ACCOUNT_B_HOST}/api/v3/contents/generations/tasks/{TASK_ID}").mock( + return_value=httpx.Response(200, json=_task(status="running")) + ) + + created = router.video_generation(model="seedance-b", prompt="a cat", seconds="4", metadata=caller_metadata) + _ = router.video_status(model=_proxy_poll_model(router, created.id), video_id=created.id) + + assert [call.request.headers["authorization"] for call in task_get.calls] == ["Bearer key-b"] + + +@respx.mock +def test_a_video_created_through_a_routing_group_is_polled_on_the_member_that_created_it(): + router = _two_account_router( + routing_groups=[ + {"group_name": "seedance", "models": ["seedance-a", "seedance-b"], "routing_strategy": "simple-shuffle"} ] + ) + for host in (ACCOUNT_A_HOST, ACCOUNT_B_HOST): + _ = respx.post(f"{host}/api/v3/contents/generations/tasks").mock( + return_value=httpx.Response(200, json={"id": TASK_ID}) + ) + _ = respx.get(f"{host}/api/v3/contents/generations/tasks/{TASK_ID}").mock( + return_value=httpx.Response(200, json=_task(status="running")) + ) - @pytest.mark.asyncio - async def test_bearer_token_reaches_the_wire(self): - captured = await self._wire_request(model=f"volcengine/{SEEDANCE_2_5}", prompt="p") + for _ in range(8): + created = router.video_generation(model="seedance", prompt="a cat", seconds="4") + polled = router.video_status(model=_proxy_poll_model(router, created.id), video_id=created.id) + assert polled.id == created.id - headers = {k.lower(): v for k, v in captured["headers"].items()} - assert headers["authorization"] == "Bearer sk-test" - assert headers["content-type"] == "application/json" + hosts = [call.request.url.host for call in respx.calls] + assert hosts[1::2] == hosts[0::2]