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:
Rick 2026-10-15 20:48:36 +08:00 • committed by GitHub
parent a2f792634e
commit 71e508b65d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 2316 additions and 10 deletions

View file

@ -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])

View file

View 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,
)

View file

@ -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,

View file

@ -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

View file

@ -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,

File diff suppressed because it is too large Load diff