mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
feat(minimax): add MiniMax-H3 and H3-Max video generation via /v1/videos (#43705)
* feat(minimax): add MiniMax-H3 and H3-Max video generation via /v1/videos MinimaxVideoConfig maps litellm's OpenAI Videos API onto MiniMax's V2 task API. Create, status, list, content and delete hit /v2/video_generation and /v2/query/video_generation. Remix is not exposed: MiniMax's /v2/video_regeneration only upscales a finished 768P task to 2K and ignores any prompt, so it raises instead of returning a video that silently drops the edit OpenAI params translate to MiniMax's contract: seconds becomes duration, size reduces via gcd to a valid ratio, and text-to-video requests default to ratio 16:9, resolution 768P and duration 5 when unset. input_reference becomes a first_frame content item, base64 encoded into a data URI for file inputs, and multimodal reference media passes through a content array in extra_body. Caller content is validated strictly as image, video or audio items, and the scanned prompt is always the one text item sent, so text can't reach MiniMax past the guardrails; anything else in content returns a 400. api_base shares MINIMAX_API_BASE with the chat config (a trailing /v1 or /v2 is stripped because the video API carries its own version). The list endpoint pages by number with no cursor or sort, so after and an ascending order are rejected with a 400 rather than ignored, and page_num passes through extra_query. Responses validate into frozen pydantic models at the boundary, video ids are provider-encoded so status and content route back to MiniMax, and create reports duration_seconds and video_resolution in usage for cost tracking End-to-end tests drive the real avideo_generation() entrypoint through an httpx MockTransport and assert on the encoded request body, which is what catches a mapper that drops input_reference before the request is built Both model_prices maps gain minimax/MiniMax-H3 and minimax/MiniMax-H3-Max, priced per output second by resolution tier from MiniMax's pay-as-you-go list (H3 $0.08 at 768P and $0.13 at 2K, H3-Max $0.05 at 480P and $0.08 at 768P). MiniMax also bills input images beyond a per-model free allowance (5 for H3, 2 for H3-Max), so each entry carries input_cost_per_image and provider_specific_entry.minimax_free_input_images, and the create response reports the full charge as provider_reported_cost_usd. Reference video input is rejected with a 400, because MiniMax bills its duration only after the create call that litellm bills Co-authored-by: AaronHowell <237895480@qq.com> * fix(minimax): fail closed on invalid video params and malformed task responses Addresses review on #43705: - A create, status or delete response without a task id (or a task) is a 502 instead of a queued object with an empty id - An unmapped task status is a 502 instead of defaulting to queued - seconds/duration must be a positive whole number and size a positive WIDTHxHEIGHT or W:H, otherwise 400; a 0x0 size no longer divides by zero and an unparseable duration no longer falls back to the billed default - characters and image are rejected with UnsupportedParamsError unless drop_params is set, instead of being dropped silently - Downloading a task that is not ready or failed is a 400 that keeps its message through the handler, instead of a ValueError surfaced as a retried 500 - The video download goes through safe_get / async_safe_get Co-authored-by: AaronHowell <237895480@qq.com> * test(minimax): cover the video download path and invalid-param branches The download now goes through safe_get, so the happy path and the finished-task-without-a-url branch are asserted, alongside the non-object parameters rejection and the remix response stub. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering> * fix(minimax): return 400 for video remix and clear new lint gate counts * fix(minimax): send file-tuple images and keep input_reference next to content media * fix(minimax): answer 404 when an H3 video is downloaded before it finishes A download that lands while the task is still queued or running answered 400. That is a client error, which was the intent, but 404 is what the OpenAI videos API documents for content that is not ready, and it is the status the sibling DashScope and Volcengine video paths now answer, so a client polling all three reads one shape A failed task keeps its 400, since that is not a timing mistake the caller can retry away. The download test drives avideo_content and asserts the caller sees NotFoundError rather than an internal server error * fix(minimax): answer 400 for unsupported video params, early downloads and unknown ids A create param MiniMax cannot honor (seed, n, quality and the like) was dropped without a word. It now answers 400 unless drop_params is on, like other providers A download before the video is ready and a status call for an unknown task id now answer 400. The router cools a deployment down on a 404 and retries a 5xx, and neither is a deployment fault. DashScope on main answers 400 for the same case Input images past the free allowance are now billed through the shared video cost path, so a deployment with custom pricing bills them too. Status usage no longer carries MiniMax's token counts, list sets has_more to false since MiniMax pages by number, and the price map stops listing video as an input modality * fix(minimax): keep the polled video id, honor deployment drop settings, and match chat's key order Status re-encodes the id with the polled id's model when MiniMax omits it, drop_params and additional_drop_params never reach MiniMax and listed keys are dropped, MINIMAX_API_KEY wins over litellm.api_key, create sets created_at, and a content variant other than the video answers 400 * fix(minimax): list a task without a model under the routed group MiniMax leaves `model` off some listed tasks. The caller polls the ids a list hands back, and the proxy resolves a poll by the model in the id, so an id encoded with an empty model no longer routes back to the deployment that served the task. Fall back to the group the router was asked for. * fix(minimax): honor deployment drop keys inside parameters and bill images at the map rate when a deployment prices only seconds A field the deployment lists in additional_drop_params no longer reaches MiniMax when it arrives inside parameters, and a deployment that sets only its per-second price now bills reference images past the free allowance at the cost map's per-image rate instead of $0 * fix(minimax): keep deployment-dropped fields out of the body when they arrive through extra_body * fix(minimax): answer 400 for a video resolution with no price unless drop_params is on * fix(minimax): send the default resolution when a deployment drops resolution --------- Co-authored-by: AaronHowell <237895480@qq.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Happy <yesreply@happy.engineering> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
a2f792634e
commit
71e508b65d
8 changed files with 2316 additions and 10 deletions
|
|
@ -1403,7 +1403,7 @@ def completion_cost(
|
|||
total_time: float | None = 0.0, # used for replicate, sagemaker
|
||||
call_type: CallTypesLiteral | None = None,
|
||||
### REGION ###
|
||||
custom_llm_provider=None,
|
||||
custom_llm_provider: str | None = None,
|
||||
region_name=None, # used for bedrock pricing
|
||||
### IMAGE GEN ###
|
||||
size: str | None = None,
|
||||
|
|
@ -1549,6 +1549,7 @@ def completion_cost(
|
|||
region_name=region_name,
|
||||
)
|
||||
|
||||
requested_model: Final = model
|
||||
potential_model_names: Final = [
|
||||
selected_model,
|
||||
_get_response_model(completion_response),
|
||||
|
|
@ -1708,15 +1709,17 @@ def completion_cost(
|
|||
video_generation_cost,
|
||||
)
|
||||
|
||||
return (
|
||||
video_generation_cost(
|
||||
model=model,
|
||||
duration_seconds=duration_seconds,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
model_info=_video_model_info,
|
||||
video_resolution=video_resolution,
|
||||
)
|
||||
* video_count
|
||||
return video_generation_cost(
|
||||
model=model,
|
||||
duration_seconds=duration_seconds,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
model_info=_video_model_info,
|
||||
video_resolution=video_resolution,
|
||||
) * video_count + _video_input_image_cost(
|
||||
requested_model or model,
|
||||
custom_llm_provider,
|
||||
_video_model_info,
|
||||
getattr(completion_response, "usage", None),
|
||||
)
|
||||
# Fallback to default video cost calculation if no duration available
|
||||
return default_video_cost_calculator(
|
||||
|
|
@ -2168,6 +2171,27 @@ def response_cost_calculator(
|
|||
raise e
|
||||
|
||||
|
||||
def _video_input_image_cost(
|
||||
model: str,
|
||||
custom_llm_provider: str | None,
|
||||
model_info: ModelInfo | None,
|
||||
usage: object,
|
||||
) -> float:
|
||||
match usage:
|
||||
case {"billable_input_image_count": int(billable_input_images)} if billable_input_images > 0:
|
||||
deployment_rate: Final = model_info.get("input_cost_per_image") if model_info is not None else None
|
||||
image_rate: Final = (
|
||||
deployment_rate
|
||||
if deployment_rate is not None
|
||||
else litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider).get(
|
||||
"input_cost_per_image"
|
||||
)
|
||||
)
|
||||
return billable_input_images * image_rate if isinstance(image_rate, (int, float)) else 0.0
|
||||
case _:
|
||||
return 0.0
|
||||
|
||||
|
||||
_LOGGED_LITELLM_PARAMS: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
|
|
|
|||
0
litellm/llms/minimax/videos/__init__.py
Normal file
0
litellm/llms/minimax/videos/__init__.py
Normal file
856
litellm/llms/minimax/videos/transformation.py
Normal file
856
litellm/llms/minimax/videos/transformation.py
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
"""
|
||||
MiniMax V2 video task API: create, poll, list, download and delete. Regeneration (/v2/video_regeneration)
|
||||
only upscales a finished 768P task to 2K and ignores the prompt, so it is not exposed as an OpenAI remix.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from io import BufferedReader, BytesIO
|
||||
from math import gcd
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Literal, TypeVar
|
||||
|
||||
import httpx
|
||||
from httpx._types import RequestFiles
|
||||
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm.exceptions import BadRequestError, UnsupportedParamsError
|
||||
from litellm.images.utils import ImageEditRequestUtils
|
||||
from litellm.litellm_core_utils.url_utils import (
|
||||
async_safe_get,
|
||||
encode_url_path_segment,
|
||||
safe_get,
|
||||
)
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
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.secret_managers.main import get_secret_str
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
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.utils import get_model_info
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
|
||||
class MinimaxVideoError(BaseLLMException):
|
||||
pass
|
||||
|
||||
|
||||
class _TaskError(BaseModel, frozen=True):
|
||||
code: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class _TaskUsage(BaseModel, frozen=True):
|
||||
total_seconds: int | None = None
|
||||
input_seconds: int | None = None
|
||||
output_seconds: int | None = None
|
||||
input_image_count: int | None = None
|
||||
input_audio_seconds: int | None = None
|
||||
|
||||
|
||||
class _TaskContent(BaseModel, frozen=True):
|
||||
url: str | None = None
|
||||
prompt: str | None = None
|
||||
|
||||
|
||||
class _ContentItem(BaseModel, frozen=True):
|
||||
type: str = ""
|
||||
text: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
|
||||
class _MediaUrl(BaseModel, frozen=True, extra="forbid"):
|
||||
url: str
|
||||
|
||||
|
||||
class _CallerMediaItem(BaseModel, frozen=True, extra="forbid"):
|
||||
type: Literal["image_url", "video_url", "audio_url"]
|
||||
image_url: _MediaUrl | None = None
|
||||
video_url: _MediaUrl | None = None
|
||||
audio_url: _MediaUrl | None = None
|
||||
role: Literal["first_frame", "last_frame", "reference_image", "reference_video", "reference_audio"] | None = None
|
||||
|
||||
|
||||
class _MiniMaxTask(BaseModel, frozen=True):
|
||||
id: str = Field(min_length=1)
|
||||
model: str | None = None
|
||||
status: str
|
||||
error: _TaskError | None = None
|
||||
created_at: int | None = None
|
||||
updated_at: int | None = None
|
||||
content: _TaskContent | None = None
|
||||
resolution: str | None = None
|
||||
duration: int | None = None
|
||||
usage: _TaskUsage | None = None
|
||||
ratio: str | None = None
|
||||
task_type: str | None = None
|
||||
modality: str | None = None
|
||||
|
||||
|
||||
class _TaskIdResponse(BaseModel, frozen=True):
|
||||
task_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class _TaskResponse(BaseModel, frozen=True):
|
||||
task: _MiniMaxTask
|
||||
|
||||
|
||||
class _ListResponse(BaseModel, frozen=True):
|
||||
items: Sequence[_MiniMaxTask] | None = None
|
||||
total: int | None = None
|
||||
|
||||
|
||||
class _DeleteResponse(BaseModel, frozen=True):
|
||||
task_id: str = Field(min_length=1)
|
||||
status: str
|
||||
|
||||
|
||||
MINIMAX_VIDEO_DEFAULT_API_BASE: Final = "https://api.minimax.io"
|
||||
MINIMAX_VIDEO_DEFAULT_RESOLUTION: Final = "768P"
|
||||
MINIMAX_VIDEO_DEFAULT_DURATION_SECONDS: Final = 5
|
||||
MINIMAX_TEXT_TO_VIDEO_DEFAULT_RATIO: Final = "16:9"
|
||||
|
||||
_EMPTY_PARAMS: Final[dict[str, object]] = {} # mutable-ok: BaseVideoConfig contract empty params
|
||||
|
||||
_RESPONSE_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
_TASK_ID_RESPONSE_ADAPTER: Final = TypeAdapter(_TaskIdResponse)
|
||||
_TASK_RESPONSE_ADAPTER: Final = TypeAdapter(_TaskResponse)
|
||||
_LIST_RESPONSE_ADAPTER: Final = TypeAdapter(_ListResponse)
|
||||
_DELETE_RESPONSE_ADAPTER: Final = TypeAdapter(_DeleteResponse)
|
||||
_CONTENT_ITEMS_ADAPTER: Final = TypeAdapter(list[_ContentItem])
|
||||
_CALLER_MEDIA_ADAPTER: Final = TypeAdapter(list[_CallerMediaItem])
|
||||
_FILE_TUPLE_ADAPTER: Final = TypeAdapter(tuple[object, ...])
|
||||
_DROP_KEYS_ADAPTER: Final = TypeAdapter(frozenset[str])
|
||||
_PARAMETERS_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
_STATUS_MAP: Final = MappingProxyType(
|
||||
{
|
||||
"queued": "queued",
|
||||
"running": "in_progress",
|
||||
"succeeded": "completed",
|
||||
"failed": "failed",
|
||||
"cancelled": "cancelled",
|
||||
}
|
||||
)
|
||||
|
||||
_DROP_FROM_CREATE_BODY: Final = frozenset(
|
||||
{
|
||||
"model",
|
||||
"prompt",
|
||||
"user",
|
||||
"extra_headers",
|
||||
"extra_query",
|
||||
"extra_body",
|
||||
"drop_params",
|
||||
"additional_drop_params",
|
||||
}
|
||||
)
|
||||
|
||||
_CONTENT_VARIANT: Final = "video"
|
||||
|
||||
_CREATE_BODY_KEYS: Final = (
|
||||
"resolution",
|
||||
"duration",
|
||||
"ratio",
|
||||
"callback_url",
|
||||
"aigc_watermark",
|
||||
"extra",
|
||||
)
|
||||
|
||||
_CREATE_REQUEST_PARAMS: Final = frozenset({*_CREATE_BODY_KEYS, "content", "input_reference"})
|
||||
|
||||
_TASK_NOT_FOUND_MESSAGE: Final = "record not found"
|
||||
|
||||
_ParsedT: Final = TypeVar("_ParsedT")
|
||||
|
||||
|
||||
def _minimax_error(status_code: int, message: str) -> MinimaxVideoError:
|
||||
"""
|
||||
The video handlers rebuild provider errors from ``response.text``, so the message has to ride on the
|
||||
response as well or it reaches the caller empty.
|
||||
"""
|
||||
request: Final = httpx.Request(method="GET", url=MINIMAX_VIDEO_DEFAULT_API_BASE)
|
||||
return MinimaxVideoError(
|
||||
status_code=status_code,
|
||||
message=message,
|
||||
request=request,
|
||||
response=httpx.Response(status_code=status_code, text=message, request=request),
|
||||
)
|
||||
|
||||
|
||||
def _parse_response(adapter: TypeAdapter[_ParsedT], raw_response: httpx.Response) -> _ParsedT:
|
||||
try:
|
||||
return adapter.validate_python(raw_response.json())
|
||||
except ValueError as e:
|
||||
raise _minimax_error(502, f"Unexpected MiniMax video response: {raw_response.text}") from e
|
||||
|
||||
|
||||
def _parse_task_response(raw_response: httpx.Response) -> _MiniMaxTask:
|
||||
return _parse_response(_TASK_RESPONSE_ADAPTER, raw_response).task
|
||||
|
||||
|
||||
def _parse_task_id_response(raw_response: httpx.Response) -> str:
|
||||
return _parse_response(_TASK_ID_RESPONSE_ADAPTER, raw_response).task_id
|
||||
|
||||
|
||||
def _parse_delete_response(raw_response: httpx.Response) -> tuple[str, str]:
|
||||
deleted: Final = _parse_response(_DELETE_RESPONSE_ADAPTER, raw_response)
|
||||
return deleted.task_id, deleted.status
|
||||
|
||||
|
||||
MINIMAX_SURFACE_SUFFIXES: Final = ("/v1", "/v2", "/anthropic")
|
||||
|
||||
|
||||
def _normalized_api_base(api_base: str) -> str:
|
||||
"""
|
||||
A MiniMax key covers the chat surfaces (``/v1``, ``/anthropic``) and video (``/v2``), so strip whichever
|
||||
surface suffix a shared api_base carries back to the host.
|
||||
"""
|
||||
trimmed: Final = api_base.rstrip("/")
|
||||
matched: Final = next((suffix for suffix in MINIMAX_SURFACE_SUFFIXES if trimmed.endswith(suffix)), None)
|
||||
return trimmed[: -len(matched)] if matched else trimmed
|
||||
|
||||
|
||||
def _invalid_param(param: str, value: object, expected: str, model: str) -> BadRequestError:
|
||||
return BadRequestError(
|
||||
message=f"Invalid {param} {value!r} for MiniMax video generation: expected {expected}.",
|
||||
model=model,
|
||||
llm_provider="minimax",
|
||||
)
|
||||
|
||||
|
||||
_SIZE_OR_RATIO: Final = re.compile(r"(\d+)[x:](\d+)")
|
||||
|
||||
|
||||
def _ratio_from_size(size: object, model: str) -> str:
|
||||
"""OpenAI ``size`` is ``WIDTHxHEIGHT``; MiniMax takes the reduced ``W:H`` ratio, which is also accepted as is."""
|
||||
matched: Final = _SIZE_OR_RATIO.fullmatch(size) if isinstance(size, str) else None
|
||||
width: Final = int(matched.group(1)) if matched else 0
|
||||
height: Final = int(matched.group(2)) if matched else 0
|
||||
if width <= 0 or height <= 0:
|
||||
raise _invalid_param("size", size, "WIDTHxHEIGHT or W:H with positive integers, e.g. 1280x720", model)
|
||||
divisor: Final = gcd(width, height)
|
||||
return f"{width // divisor}:{height // divisor}"
|
||||
|
||||
|
||||
def _whole_seconds(seconds: object) -> int:
|
||||
if isinstance(seconds, int) and not isinstance(seconds, bool):
|
||||
return seconds
|
||||
if isinstance(seconds, str) and seconds.isdigit():
|
||||
return int(seconds)
|
||||
return 0
|
||||
|
||||
|
||||
def _duration_param(seconds: object, model: str) -> int:
|
||||
parsed: Final = _whole_seconds(seconds)
|
||||
if parsed <= 0:
|
||||
raise _invalid_param("seconds", seconds, "a positive whole number of seconds", model)
|
||||
return parsed
|
||||
|
||||
|
||||
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, a file object, or a (filename, file) tuple")
|
||||
|
||||
|
||||
def _file_tuple_parts(file_tuple: tuple[object, ...]) -> tuple[object, str | None]:
|
||||
match file_tuple:
|
||||
case (_, file_content, str() as declared_type, *_) if declared_type:
|
||||
return file_content, declared_type
|
||||
case (_, file_content, *_):
|
||||
return file_content, None
|
||||
case _:
|
||||
raise ValueError("input_reference tuples must be (filename, file) or (filename, file, content_type)")
|
||||
|
||||
|
||||
def _image_url(image: object) -> str:
|
||||
if isinstance(image, str):
|
||||
return image
|
||||
file_content, declared_type = (
|
||||
_file_tuple_parts(_FILE_TUPLE_ADAPTER.validate_python(image)) if isinstance(image, tuple) else (image, None)
|
||||
)
|
||||
content_type: Final = declared_type or ImageEditRequestUtils.get_image_content_type(file_content)
|
||||
encoded: Final = base64.b64encode(_read_all_bytes(file_content)).decode("utf-8")
|
||||
return f"data:{content_type};base64,{encoded}"
|
||||
|
||||
|
||||
def _first_frame_content_item(
|
||||
image: object,
|
||||
) -> dict[str, object]: # mutable-ok: content items are JSON request-body fragments
|
||||
return {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": _image_url(image)},
|
||||
"role": "first_frame",
|
||||
}
|
||||
|
||||
|
||||
def _content_items(content: object) -> tuple[_ContentItem, ...]:
|
||||
if not isinstance(content, Sequence) or isinstance(content, (str, bytes)):
|
||||
return ()
|
||||
try:
|
||||
return tuple(_CONTENT_ITEMS_ADAPTER.validate_python(content))
|
||||
except ValidationError:
|
||||
return ()
|
||||
|
||||
|
||||
def _is_text_only_content(content: object) -> bool:
|
||||
if not isinstance(content, Sequence) or isinstance(content, (str, bytes)) or not content:
|
||||
return False
|
||||
try:
|
||||
items: Final = _CONTENT_ITEMS_ADAPTER.validate_python(content)
|
||||
except ValidationError:
|
||||
return False
|
||||
return all(item.type == "text" for item in items)
|
||||
|
||||
|
||||
def _video_object_from_task(task: _MiniMaxTask) -> VideoObject:
|
||||
status: Final = _STATUS_MAP.get(task.status)
|
||||
if status is None:
|
||||
raise _minimax_error(502, f"MiniMax returned unknown video task status {task.status!r} for task {task.id}")
|
||||
usage_dump: Final = task.usage.model_dump(exclude_none=True) if task.usage is not None else None
|
||||
return VideoObject(
|
||||
id=task.id,
|
||||
object="video",
|
||||
status=status,
|
||||
created_at=task.created_at,
|
||||
completed_at=task.updated_at if status == "completed" else None,
|
||||
error=task.error.model_dump(exclude_none=True) if task.error is not None else None,
|
||||
seconds=str(task.duration) if task.duration is not None else None,
|
||||
model=task.model,
|
||||
usage=usage_dump or None,
|
||||
)
|
||||
|
||||
|
||||
def _polled_model_id(logging_obj: object) -> str | None:
|
||||
litellm_params: Final[object] = getattr(logging_obj, "litellm_params", None)
|
||||
match litellm_params:
|
||||
case {"video_id": str(video_id)}:
|
||||
return decode_video_id_with_provider(video_id).get("model_id") or None
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def _routed_model_id(logging_obj: object) -> str | None:
|
||||
"""
|
||||
A list has no id to read a model back from, so a task MiniMax leaves `model` off falls back to the group the
|
||||
router was asked for. The router stamps the group into litellm_metadata, and metadata carries a copy only when
|
||||
the caller sent none.
|
||||
"""
|
||||
litellm_params: Final[object] = getattr(logging_obj, "litellm_params", None)
|
||||
try:
|
||||
params: Final = _PARAMETERS_ADAPTER.validate_python(litellm_params)
|
||||
except ValidationError:
|
||||
return None
|
||||
stamped: Final = (_model_group_from_metadata(params.get(key)) for key in ("litellm_metadata", "metadata"))
|
||||
return next((group for group in stamped if group is not None), None)
|
||||
|
||||
|
||||
def _model_group_from_metadata(metadata: object) -> str | None:
|
||||
try:
|
||||
fields: Final = _PARAMETERS_ADAPTER.validate_python(metadata)
|
||||
except ValidationError:
|
||||
return None
|
||||
group: Final = fields.get("model_group")
|
||||
return group if isinstance(group, str) and group else None
|
||||
|
||||
|
||||
def _additional_drop_keys(params: Mapping[str, object]) -> frozenset[str]:
|
||||
try:
|
||||
return _DROP_KEYS_ADAPTER.validate_python(params.get("additional_drop_params") or ())
|
||||
except ValidationError:
|
||||
return frozenset()
|
||||
|
||||
|
||||
def _parameters_fields(value: object, model: str) -> Mapping[str, object]:
|
||||
try:
|
||||
return MappingProxyType(_RESPONSE_ADAPTER.validate_python(value))
|
||||
except ValidationError as e:
|
||||
raise _invalid_param("parameters", value, "an object of MiniMax request fields", model) from e
|
||||
|
||||
|
||||
def _billable_input_image_count(model: str, input_image_count: int) -> int:
|
||||
"""MiniMax bills input images beyond a per-model free allowance on top of output seconds."""
|
||||
try:
|
||||
info: Final = get_model_info(model=model, custom_llm_provider="minimax")
|
||||
except Exception: # noqa: BLE001 # get_model_info raises plain Exception for an unmapped model
|
||||
return 0
|
||||
provider_specific: Final = info.get("provider_specific_entry")
|
||||
free_images: Final = (
|
||||
provider_specific.get("minimax_free_input_images") if isinstance(provider_specific, Mapping) else None
|
||||
)
|
||||
if not isinstance(free_images, int) or isinstance(free_images, bool):
|
||||
return 0
|
||||
return max(0, input_image_count - free_images)
|
||||
|
||||
|
||||
_TIER_COST_PREFIX: Final = "output_cost_per_second_"
|
||||
|
||||
|
||||
def _priced_resolutions(model: str) -> frozenset[str]:
|
||||
try:
|
||||
info: Final = get_model_info(model=model, custom_llm_provider="minimax")
|
||||
except Exception: # noqa: BLE001 # get_model_info raises plain Exception for an unmapped model
|
||||
return frozenset()
|
||||
return frozenset(
|
||||
key.removeprefix(_TIER_COST_PREFIX)
|
||||
for key, cost in info.items()
|
||||
if key.startswith(_TIER_COST_PREFIX) and cost is not None
|
||||
)
|
||||
|
||||
|
||||
def _unpriced_resolution_drop(model: str, resolution: object, litellm_params: GenericLiteLLMParams) -> frozenset[str]:
|
||||
priced: Final = _priced_resolutions(model)
|
||||
if not priced or not isinstance(resolution, str) or resolution.strip().lower() in priced:
|
||||
return frozenset()
|
||||
if litellm_params.drop_params is True or litellm.drop_params is True:
|
||||
return frozenset({"resolution"})
|
||||
raise UnsupportedParamsError(
|
||||
message=(
|
||||
f"MiniMax {model} has no price for resolution {resolution!r} in litellm's cost map, so it would bill at "
|
||||
f"another tier's rate. Use one of {', '.join(sorted(tier.upper() for tier in priced))}, or set "
|
||||
"drop_params=True to send MiniMax's default resolution instead."
|
||||
),
|
||||
model=model,
|
||||
llm_provider="minimax",
|
||||
)
|
||||
|
||||
|
||||
def _video_url_from_task(task: _MiniMaxTask) -> str:
|
||||
"""A download before the video is ready is the caller polling too early, so it answers 400, which the
|
||||
router neither retries nor counts toward a deployment cooldown the way it counts a 404."""
|
||||
if task.content is not None and task.content.url:
|
||||
return task.content.url
|
||||
|
||||
if task.status in ("queued", "running"):
|
||||
raise _minimax_error(
|
||||
400, f"Video is still processing (status: {task.status}). Poll the video until it is completed."
|
||||
)
|
||||
if task.error is not None:
|
||||
raise _minimax_error(400, f"Video generation failed: {task.error.message or 'unknown error'}")
|
||||
raise _minimax_error(400, f"Video has no downloadable content (status: {task.status}).")
|
||||
|
||||
|
||||
def _remix_unsupported() -> UnsupportedParamsError:
|
||||
return UnsupportedParamsError(
|
||||
message=(
|
||||
"Video remix is not supported by MiniMax. Its regeneration endpoint only upscales a finished 768P "
|
||||
"MiniMax-H3 task to 2K and ignores the prompt; send a new video_generation() request with the edited "
|
||||
"prompt instead."
|
||||
),
|
||||
llm_provider="minimax",
|
||||
)
|
||||
|
||||
|
||||
class MinimaxVideoConfig(BaseVideoConfig):
|
||||
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: BaseVideoConfig contract returns list
|
||||
return [
|
||||
"model",
|
||||
"prompt",
|
||||
"input_reference",
|
||||
"seconds",
|
||||
"size",
|
||||
"resolution",
|
||||
"user",
|
||||
"extra_headers",
|
||||
"duration",
|
||||
"ratio",
|
||||
"content",
|
||||
"callback_url",
|
||||
"aigc_watermark",
|
||||
"extra",
|
||||
"parameters",
|
||||
]
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
video_create_optional_params: VideoCreateOptionalRequestParams,
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict[str, object]: # mutable-ok: BaseVideoConfig contract
|
||||
mapped_params: Final[dict[str, object]] = {} # mutable-ok: BaseVideoConfig contract; extra_body merges into it
|
||||
dropped_keys: Final = _DROP_FROM_CREATE_BODY | _additional_drop_keys(video_create_optional_params)
|
||||
for key, value in video_create_optional_params.items():
|
||||
if value is None or key in dropped_keys:
|
||||
continue
|
||||
if key == "seconds":
|
||||
mapped_params["duration"] = _duration_param(value, model)
|
||||
elif key == "size":
|
||||
mapped_params.setdefault("ratio", _ratio_from_size(value, model))
|
||||
elif key == "parameters":
|
||||
mapped_params.update(_parameters_fields(value, model))
|
||||
else:
|
||||
mapped_params[key] = value
|
||||
unsupported: Final = sorted(key for key in mapped_params if key not in _CREATE_REQUEST_PARAMS)
|
||||
if unsupported and not drop_params:
|
||||
raise UnsupportedParamsError(
|
||||
message=(
|
||||
f"MiniMax video generation does not support {', '.join(map(repr, unsupported))}. "
|
||||
"Set litellm.drop_params=True to drop it, or remove it from the request."
|
||||
),
|
||||
model=model,
|
||||
llm_provider="minimax",
|
||||
)
|
||||
return {key: value for key, value in mapped_params.items() if key in _CREATE_REQUEST_PARAMS}
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract; handler expects a mutable headers dict
|
||||
model: str,
|
||||
api_key: str | None = None,
|
||||
litellm_params: GenericLiteLLMParams | None = None,
|
||||
) -> dict[str, str]: # mutable-ok: BaseVideoConfig contract
|
||||
resolved_api_key: Final = (
|
||||
api_key
|
||||
or (litellm_params.api_key if litellm_params is not None and litellm_params.api_key else None)
|
||||
or get_secret_str("MINIMAX_API_KEY")
|
||||
or litellm.api_key
|
||||
)
|
||||
|
||||
if resolved_api_key is None:
|
||||
raise ValueError(
|
||||
"MiniMax API key is required. Set MINIMAX_API_KEY environment variable or pass api_key parameter."
|
||||
)
|
||||
|
||||
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)
|
||||
return headers
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
model: str,
|
||||
api_base: str | None,
|
||||
litellm_params: dict[str, object], # mutable-ok: BaseVideoConfig contract
|
||||
) -> str:
|
||||
resolved_api_base: Final = api_base or get_secret_str("MINIMAX_API_BASE") or MINIMAX_VIDEO_DEFAULT_API_BASE
|
||||
return _normalized_api_base(resolved_api_base)
|
||||
|
||||
def transform_video_create_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
api_base: str,
|
||||
video_create_optional_request_params: dict[str, object], # mutable-ok: BaseVideoConfig contract
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
) -> tuple[dict[str, object], RequestFiles, str]: # mutable-ok: BaseVideoConfig contract
|
||||
content: Final = self._content_param(video_create_optional_request_params, prompt)
|
||||
if any(item.type == "video_url" for item in _content_items(content)):
|
||||
raise UnsupportedParamsError(
|
||||
message=(
|
||||
"Reference video input is not supported through litellm: MiniMax bills its duration, which is only "
|
||||
"known after the task is created and billed. Use image or audio references instead."
|
||||
),
|
||||
model=model,
|
||||
llm_provider="minimax",
|
||||
)
|
||||
request_data: Final[dict[str, object]] = { # mutable-ok: request body dict, JSON-serialized by the handler
|
||||
"model": model,
|
||||
"content": content,
|
||||
}
|
||||
additional_drops: Final = _additional_drop_keys(litellm_params.model_dump())
|
||||
dropped_keys: Final = (
|
||||
additional_drops
|
||||
if "resolution" in additional_drops
|
||||
else additional_drops
|
||||
| _unpriced_resolution_drop(model, video_create_optional_request_params.get("resolution"), litellm_params)
|
||||
)
|
||||
for key in _CREATE_BODY_KEYS:
|
||||
if key not in dropped_keys and video_create_optional_request_params.get(key) is not None:
|
||||
request_data[key] = video_create_optional_request_params[key]
|
||||
|
||||
request_data.setdefault("resolution", MINIMAX_VIDEO_DEFAULT_RESOLUTION)
|
||||
request_data["duration"] = _duration_param(
|
||||
request_data.get("duration", MINIMAX_VIDEO_DEFAULT_DURATION_SECONDS), model
|
||||
)
|
||||
if "ratio" not in request_data and _is_text_only_content(content):
|
||||
request_data["ratio"] = MINIMAX_TEXT_TO_VIDEO_DEFAULT_RATIO
|
||||
|
||||
return request_data, (), f"{api_base}/v2/video_generation"
|
||||
|
||||
@staticmethod
|
||||
def _content_param(video_create_optional_request_params: Mapping[str, object], prompt: str) -> object:
|
||||
"""
|
||||
``prompt`` is what guardrails scanned, so it is always the one text item MiniMax takes; a caller's
|
||||
``content`` array only contributes media, never text that would bypass that check.
|
||||
"""
|
||||
input_reference: Final = video_create_optional_request_params.get("input_reference")
|
||||
first_frame_items: Final = () if input_reference is None else (_first_frame_content_item(input_reference),)
|
||||
explicit_content: Final = video_create_optional_request_params.get("content")
|
||||
if explicit_content is None:
|
||||
return [{"type": "text", "text": prompt}, *first_frame_items]
|
||||
try:
|
||||
media: Final = _CALLER_MEDIA_ADAPTER.validate_python(explicit_content)
|
||||
except ValidationError as e:
|
||||
raise UnsupportedParamsError(
|
||||
message=(
|
||||
"content must be a list of MiniMax image_url, video_url or audio_url items; pass the text of "
|
||||
f"the request as prompt. {e.error_count()} invalid item field(s)."
|
||||
),
|
||||
llm_provider="minimax",
|
||||
) from e
|
||||
return [
|
||||
{"type": "text", "text": prompt},
|
||||
*(item.model_dump(exclude_none=True) for item in media),
|
||||
*first_frame_items,
|
||||
]
|
||||
|
||||
def transform_video_create_response(
|
||||
self,
|
||||
model: str,
|
||||
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_id: Final = _parse_task_id_response(raw_response)
|
||||
request_mapping: Final[Mapping[str, object]] = request_data if request_data is not None else _EMPTY_PARAMS
|
||||
duration: Final = request_mapping.get("duration")
|
||||
resolution: Final = request_mapping.get("resolution")
|
||||
input_image_count: Final = sum(
|
||||
1 for item in _content_items(request_mapping.get("content")) if item.type == "image_url"
|
||||
)
|
||||
|
||||
video_obj: Final = VideoObject(
|
||||
id=task_id,
|
||||
object="video",
|
||||
status="queued",
|
||||
created_at=int(time.time()),
|
||||
model=model,
|
||||
seconds=str(duration) if duration is not None else None,
|
||||
)
|
||||
if custom_llm_provider and video_obj.id:
|
||||
video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, model)
|
||||
|
||||
usage: Final[dict[str, object]] = {} # mutable-ok: VideoObject.usage is a mutable dict field
|
||||
if isinstance(duration, (int, float)):
|
||||
usage["duration_seconds"] = float(duration)
|
||||
if isinstance(resolution, str):
|
||||
usage["video_resolution"] = resolution.strip().lower()
|
||||
if input_image_count:
|
||||
usage["input_image_count"] = input_image_count
|
||||
billable_input_images: Final = _billable_input_image_count(model, input_image_count)
|
||||
if billable_input_images:
|
||||
usage["billable_input_image_count"] = billable_input_images
|
||||
video_obj.usage = usage
|
||||
|
||||
return video_obj
|
||||
|
||||
def transform_video_status_retrieve_request(
|
||||
self,
|
||||
video_id: str,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract
|
||||
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}/v2/query/video_generation/{encoded_task_id}", _EMPTY_PARAMS
|
||||
|
||||
def transform_video_status_retrieve_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "Logging",
|
||||
custom_llm_provider: str | None = None,
|
||||
client: HTTPHandler | None = None,
|
||||
) -> VideoObject:
|
||||
task: Final = _parse_task_response(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, _polled_model_id(logging_obj) or task.model
|
||||
)
|
||||
return video_obj
|
||||
|
||||
def transform_video_content_request(
|
||||
self,
|
||||
video_id: str,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
variant: str | None = None,
|
||||
) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract
|
||||
if variant is not None and variant != _CONTENT_VARIANT:
|
||||
raise UnsupportedParamsError(
|
||||
message=f"MiniMax only serves the video itself; variant {variant!r} is not available.",
|
||||
llm_provider="minimax",
|
||||
)
|
||||
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}/v2/query/video_generation/{encoded_task_id}", _EMPTY_PARAMS
|
||||
|
||||
def transform_video_content_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "Logging",
|
||||
client: HTTPHandler | None = None,
|
||||
) -> bytes:
|
||||
task: Final = _parse_task_response(raw_response)
|
||||
video_url: Final = _video_url_from_task(task)
|
||||
|
||||
httpx_client: Final[HTTPHandler] = client if client is not None else _get_httpx_client()
|
||||
video_response: Final = safe_get(httpx_client, video_url)
|
||||
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:
|
||||
task: Final = _parse_task_response(raw_response)
|
||||
video_url: Final = _video_url_from_task(task)
|
||||
|
||||
async_httpx_client: Final[AsyncHTTPHandler] = (
|
||||
client if client is not None else get_async_httpx_client(llm_provider=litellm.LlmProviders.MINIMAX)
|
||||
)
|
||||
video_response: Final = await async_safe_get(async_httpx_client, video_url)
|
||||
video_response.raise_for_status()
|
||||
|
||||
return video_response.content
|
||||
|
||||
def transform_video_remix_request(
|
||||
self,
|
||||
video_id: str,
|
||||
prompt: str,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
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 _remix_unsupported()
|
||||
|
||||
def transform_video_remix_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "Logging",
|
||||
custom_llm_provider: str | None = None,
|
||||
) -> VideoObject:
|
||||
raise _remix_unsupported()
|
||||
|
||||
def transform_video_list_request(
|
||||
self,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
after: str | None = None,
|
||||
limit: int | None = None,
|
||||
order: str | None = None,
|
||||
extra_query: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract
|
||||
) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract
|
||||
if after is not None:
|
||||
raise UnsupportedParamsError(
|
||||
message=(
|
||||
"MiniMax video list does not support cursor pagination via 'after'. "
|
||||
"Pass extra_query={'page_num': N} to request a later page."
|
||||
),
|
||||
llm_provider="minimax",
|
||||
)
|
||||
if order is not None and order != "desc":
|
||||
raise UnsupportedParamsError(
|
||||
message="MiniMax video list only returns newest first; order must be 'desc' or omitted.",
|
||||
llm_provider="minimax",
|
||||
)
|
||||
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}/v2/query/video_generation", params
|
||||
|
||||
def transform_video_list_response( # pyright: ignore[reportIncompatibleMethodOverride] # base declares dict[str, str] but the payload is a heterogeneous list body
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
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 = _LIST_RESPONSE_ADAPTER.validate_python(raw_response.json())
|
||||
total: Final = list_payload.total
|
||||
|
||||
data: Final[list[dict[str, object]]] = [] # mutable-ok: OpenAI list body served as JSON by the proxy
|
||||
for task in list_payload.items or ():
|
||||
video_obj = _video_object_from_task(task)
|
||||
if custom_llm_provider and video_obj.id:
|
||||
model_id = video_obj.model or _routed_model_id(logging_obj)
|
||||
video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, model_id)
|
||||
data.append(video_obj.model_dump())
|
||||
|
||||
list_response: Final[dict[str, object]] = { # mutable-ok: OpenAI list body served as JSON by the proxy
|
||||
"object": "list",
|
||||
"data": data,
|
||||
"total": total if isinstance(total, int) and not isinstance(total, bool) else len(data),
|
||||
"has_more": False,
|
||||
}
|
||||
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,
|
||||
video_id: str,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract
|
||||
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}/v2/video_generation/{encoded_task_id}", _EMPTY_PARAMS
|
||||
|
||||
def transform_video_delete_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "Logging",
|
||||
) -> VideoObject:
|
||||
task_id, status = _parse_delete_response(raw_response)
|
||||
return VideoObject(
|
||||
id=task_id,
|
||||
object="video",
|
||||
status=status,
|
||||
)
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, str] | httpx.Headers, # mutable-ok: BaseVideoConfig contract
|
||||
) -> BaseLLMException:
|
||||
"""MiniMax answers an unknown task id with a 500, which would be retried and cool the deployment down."""
|
||||
return MinimaxVideoError(
|
||||
status_code=400 if _TASK_NOT_FOUND_MESSAGE in error_message else status_code,
|
||||
message=error_message,
|
||||
headers=headers,
|
||||
)
|
||||
|
|
@ -40273,6 +40273,58 @@
|
|||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000
|
||||
},
|
||||
"minimax/MiniMax-H3": {
|
||||
"litellm_provider": "minimax",
|
||||
"mode": "video_generation",
|
||||
"source": "https://platform.minimax.io/docs/guides/pricing-paygo",
|
||||
"output_cost_per_second": 0.08,
|
||||
"output_cost_per_second_768p": 0.08,
|
||||
"output_cost_per_second_2k": 0.13,
|
||||
"input_cost_per_image": 0.04,
|
||||
"provider_specific_entry": {
|
||||
"minimax_free_input_images": 5
|
||||
},
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"supported_endpoints": [
|
||||
"/v1/videos"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "V2 async task API. Pay-as-you-go list price $0.08/s at 768P and $0.13/s at 2K, plus $0.04 per input image beyond the first 5. Audio input is free. Reference video input is rejected, since MiniMax bills its duration only after the create call."
|
||||
}
|
||||
},
|
||||
"minimax/MiniMax-H3-Max": {
|
||||
"litellm_provider": "minimax",
|
||||
"mode": "video_generation",
|
||||
"source": "https://platform.minimax.io/docs/guides/pricing-paygo",
|
||||
"output_cost_per_second": 0.08,
|
||||
"output_cost_per_second_480p": 0.05,
|
||||
"output_cost_per_second_768p": 0.08,
|
||||
"input_cost_per_image": 0.074,
|
||||
"provider_specific_entry": {
|
||||
"minimax_free_input_images": 2
|
||||
},
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"supported_endpoints": [
|
||||
"/v1/videos"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "V2 async task API, fast tier. Pay-as-you-go list price $0.05/s at 480P and $0.08/s at 768P; no 2K. Plus $0.074 per input image beyond the first 2. Audio input is free. Reference video input is rejected, since MiniMax bills its duration only after the create call."
|
||||
}
|
||||
},
|
||||
"mistral.devstral-2-123b": {
|
||||
"deprecation_date": "2027-03-30",
|
||||
"input_cost_per_token": 4e-07,
|
||||
|
|
|
|||
|
|
@ -9908,6 +9908,10 @@ class ProviderConfigManager:
|
|||
from litellm.llms.hosted_vllm.videos import get_hosted_vllm_video_config
|
||||
|
||||
return get_hosted_vllm_video_config(model)
|
||||
elif LlmProviders.MINIMAX == provider:
|
||||
from litellm.llms.minimax.videos.transformation import MinimaxVideoConfig
|
||||
|
||||
return MinimaxVideoConfig()
|
||||
elif LlmProviders.EDENAI == provider:
|
||||
return litellm.EdenAIVideoConfig()
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -40273,6 +40273,58 @@
|
|||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000
|
||||
},
|
||||
"minimax/MiniMax-H3": {
|
||||
"litellm_provider": "minimax",
|
||||
"mode": "video_generation",
|
||||
"source": "https://platform.minimax.io/docs/guides/pricing-paygo",
|
||||
"output_cost_per_second": 0.08,
|
||||
"output_cost_per_second_768p": 0.08,
|
||||
"output_cost_per_second_2k": 0.13,
|
||||
"input_cost_per_image": 0.04,
|
||||
"provider_specific_entry": {
|
||||
"minimax_free_input_images": 5
|
||||
},
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"supported_endpoints": [
|
||||
"/v1/videos"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "V2 async task API. Pay-as-you-go list price $0.08/s at 768P and $0.13/s at 2K, plus $0.04 per input image beyond the first 5. Audio input is free. Reference video input is rejected, since MiniMax bills its duration only after the create call."
|
||||
}
|
||||
},
|
||||
"minimax/MiniMax-H3-Max": {
|
||||
"litellm_provider": "minimax",
|
||||
"mode": "video_generation",
|
||||
"source": "https://platform.minimax.io/docs/guides/pricing-paygo",
|
||||
"output_cost_per_second": 0.08,
|
||||
"output_cost_per_second_480p": 0.05,
|
||||
"output_cost_per_second_768p": 0.08,
|
||||
"input_cost_per_image": 0.074,
|
||||
"provider_specific_entry": {
|
||||
"minimax_free_input_images": 2
|
||||
},
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"supported_endpoints": [
|
||||
"/v1/videos"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "V2 async task API, fast tier. Pay-as-you-go list price $0.05/s at 480P and $0.08/s at 768P; no 2K. Plus $0.074 per input image beyond the first 2. Audio input is free. Reference video input is rejected, since MiniMax bills its duration only after the create call."
|
||||
}
|
||||
},
|
||||
"mistral.devstral-2-123b": {
|
||||
"deprecation_date": "2027-03-30",
|
||||
"input_cost_per_token": 4e-07,
|
||||
|
|
|
|||
0
tests/unit/llms/minimax/videos/__init__.py
Normal file
0
tests/unit/llms/minimax/videos/__init__.py
Normal file
1318
tests/unit/llms/minimax/videos/test_minimax_video_transformation.py
Normal file
1318
tests/unit/llms/minimax/videos/test_minimax_video_transformation.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue