feat(fal_ai): add MiniMax H3 text-to-video and reference-to-video

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kerry 2026-09-21 19:01:54 +00:00
parent 9a90adad32
commit 8d73ce756a
12 changed files with 289 additions and 25 deletions

View file

@ -1,6 +1,8 @@
import math
import sys
import time
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, TypeAlias
@ -36,11 +38,33 @@ class FalAIVideoError(BaseLLMException):
_ALLOWED_ASPECT_RATIOS: Final[frozenset[str]] = frozenset({"auto", "16:9", "9:16", "1:1", "4:3", "3:4", "21:9"})
_ALLOWED_RESOLUTIONS: Final[frozenset[str]] = frozenset({"480p", "720p", "1080p", "4k"})
_RESOLUTION_TIERS: Final[tuple[tuple[int, str], ...]] = (
(480, "480p"),
(720, "720p"),
(1080, "1080p"),
@dataclass(frozen=True, slots=True)
class _ModelProfile:
resolutions: frozenset[str]
resolution_tiers: tuple[tuple[int, str], ...]
default_resolution: str
integer_duration: bool
reference_key: str
reference_as_list: bool
_SEEDANCE_PROFILE: Final[_ModelProfile] = _ModelProfile(
resolutions=frozenset({"480p", "720p", "1080p", "4k"}),
resolution_tiers=((480, "480p"), (720, "720p"), (1080, "1080p"), (sys.maxsize, "4k")),
default_resolution="720p",
integer_duration=False,
reference_key="image_url",
reference_as_list=False,
)
_H3_PROFILE: Final[_ModelProfile] = _ModelProfile(
resolutions=frozenset({"480P", "768P", "2K", "4K"}),
resolution_tiers=((480, "480P"), (768, "768P"), (1440, "2K"), (sys.maxsize, "4K")),
default_resolution="2K",
integer_duration=True,
reference_key="reference_image_urls",
reference_as_list=True,
)
_QUEUE_NAMESPACES: Final[frozenset[str]] = frozenset(("workflows", "comfy"))
_STATUS_MAP: Final[Mapping[str, str]] = MappingProxyType(
@ -75,8 +99,12 @@ def _duration_value(value: object) -> str | None:
return None
def _resolution_for_short_side(short_side: int) -> str:
return next((resolution for threshold, resolution in _RESOLUTION_TIERS if short_side <= threshold), "4k")
def _profile_for_model(model: str) -> _ModelProfile:
return _H3_PROFILE if model.startswith("minimax/h3/") else _SEEDANCE_PROFILE
def _resolution_for_short_side(short_side: int, profile: _ModelProfile) -> str:
return next(resolution for threshold, resolution in profile.resolution_tiers if short_side <= threshold)
def _model_path_from_request_url(raw_response: httpx.Response) -> str | None:
@ -97,14 +125,19 @@ def _request_id_from_request_url(raw_response: httpx.Response) -> str | None:
return segments[request_id_index] if len(segments) > request_id_index else None
def _size_params(size: object) -> Mapping[str, str]:
def _size_params(size: object, profile: _ModelProfile) -> Mapping[str, str]:
if not isinstance(size, str):
return MappingProxyType({})
if size in _ALLOWED_RESOLUTIONS:
return MappingProxyType({"resolution": size})
if size.count("x") != 1:
normalized_size: Final[str] = size.lower()
canonical_resolution: Final[str | None] = next(
(resolution for resolution in profile.resolutions if resolution.lower() == normalized_size),
None,
)
if canonical_resolution is not None:
return MappingProxyType({"resolution": canonical_resolution})
if normalized_size.count("x") != 1:
return MappingProxyType({})
width_text, height_text = size.split("x")
width_text, height_text = normalized_size.split("x")
if not (width_text.isdigit() and height_text.isdigit()):
return MappingProxyType({})
width: Final[int] = int(width_text)
@ -113,7 +146,7 @@ def _size_params(size: object) -> Mapping[str, str]:
return MappingProxyType({})
reduced_gcd: Final[int] = math.gcd(width, height)
aspect_ratio: Final[str] = f"{width // reduced_gcd}:{height // reduced_gcd}"
resolution: Final[str] = _resolution_for_short_side(min(width, height))
resolution: Final[str] = _resolution_for_short_side(min(width, height), profile)
if aspect_ratio in _ALLOWED_ASPECT_RATIOS:
return MappingProxyType({"resolution": resolution, "aspect_ratio": aspect_ratio})
return MappingProxyType({"resolution": resolution})
@ -158,18 +191,27 @@ class FalAIVideoConfig(BaseVideoConfig):
input_reference: Final[object] = video_create_optional_params.get("input_reference")
if "input_reference" in video_create_optional_params and not isinstance(input_reference, str):
raise ValueError("fal.ai needs a public image URL for input_reference")
input_reference_params: Final[Mapping[str, str]] = (
profile: Final[_ModelProfile] = _profile_for_model(model)
input_reference_params: Final[Mapping[str, object]] = (
MappingProxyType({})
if not isinstance(input_reference, str)
else MappingProxyType({"image_url": input_reference})
else MappingProxyType(
{
profile.reference_key: (
[input_reference] # mutable-ok: fal.ai expects a list for H3 references
if profile.reference_as_list
else input_reference
),
}
)
)
duration_params: Final[Mapping[str, str]] = (
duration_params: Final[Mapping[str, object]] = (
MappingProxyType({})
if "seconds" not in video_create_optional_params
else self._duration_params(video_create_optional_params["seconds"])
else self._duration_params(video_create_optional_params["seconds"], profile)
)
size_params: Final[Mapping[str, str]] = (
_size_params(video_create_optional_params["size"])
_size_params(video_create_optional_params["size"], profile)
if "size" in video_create_optional_params
else MappingProxyType({})
)
@ -190,11 +232,11 @@ class FalAIVideoConfig(BaseVideoConfig):
return mapped_params
@staticmethod
def _duration_params(seconds: object) -> Mapping[str, str]:
def _duration_params(seconds: object, profile: _ModelProfile) -> Mapping[str, object]:
duration: Final[str | None] = _duration_value(seconds)
if duration is None:
raise ValueError("fal.ai seconds must be a numeric value")
return MappingProxyType({"duration": duration})
return MappingProxyType({"duration": int(duration) if profile.integer_duration else duration})
def validate_environment(
self,
@ -251,6 +293,7 @@ class FalAIVideoConfig(BaseVideoConfig):
request_data: Mapping[str, object] | None = None,
) -> VideoObject:
response_data: Final[Mapping[str, object]] = _response_data(raw_response)
profile: Final[_ModelProfile] = _profile_for_model(model)
request_params: Final[Mapping[str, object]] = request_data or MappingProxyType({})
request_id: Final[str] = _response_string(response_data, "request_id")
provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER
@ -262,7 +305,10 @@ class FalAIVideoConfig(BaseVideoConfig):
key: value
for key, value in (
("duration_seconds", duration),
("video_resolution", resolution if isinstance(resolution, str) else "720p"),
(
"video_resolution",
resolution if isinstance(resolution, str) else profile.default_resolution,
),
)
if value is not None
}

View file

@ -22889,6 +22889,45 @@
"video"
]
},
"fal_ai/minimax/h3/text-to-video": {
"litellm_provider": "fal_ai",
"mode": "video_generation",
"output_cost_per_second": 0.13,
"output_cost_per_second_480p": 0.05,
"output_cost_per_second_768p": 0.06,
"output_cost_per_second_2k": 0.13,
"output_cost_per_second_4k": 0.16,
"source": "https://fal.ai/models/minimax/h3/text-to-video",
"supported_endpoints": [
"/v1/videos"
],
"supported_modalities": [
"text"
],
"supported_output_modalities": [
"video"
]
},
"fal_ai/minimax/h3/reference-to-video": {
"litellm_provider": "fal_ai",
"mode": "video_generation",
"output_cost_per_second": 0.13,
"output_cost_per_second_480p": 0.05,
"output_cost_per_second_768p": 0.06,
"output_cost_per_second_2k": 0.13,
"output_cost_per_second_4k": 0.16,
"source": "https://fal.ai/models/minimax/h3/reference-to-video",
"supported_endpoints": [
"/v1/videos"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"video"
]
},
"fal_ai/bytedance/seedance-2.0/text-to-video": {
"litellm_provider": "fal_ai",
"mode": "video_generation",

View file

@ -539,6 +539,8 @@ class LiteLLMParamsTypedDict(TypedDict, total=False):
output_cost_per_second: float | None
output_cost_per_second_480p: ReadOnly[float | None]
output_cost_per_second_720p: ReadOnly[float | None]
output_cost_per_second_768p: ReadOnly[float | None]
output_cost_per_second_2k: ReadOnly[float | None]
output_cost_per_second_1080p: float | None
output_cost_per_second_4k: ReadOnly[float | None]
num_retries: int | None

View file

@ -329,6 +329,8 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
) # video_generation tier: key output_cost_per_second_<resolution> (e.g. 1080p, 720p)
output_cost_per_second_480p: ReadOnly[float | None]
output_cost_per_second_720p: ReadOnly[float | None]
output_cost_per_second_768p: ReadOnly[float | None]
output_cost_per_second_2k: ReadOnly[float | None]
output_cost_per_second_4k: ReadOnly[float | None]
ocr_cost_per_page: float | None # for OCR models
ocr_cost_per_page_batches: ReadOnly[float | None]
@ -3610,6 +3612,8 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
output_cost_per_second_1080p: float | None = None
output_cost_per_second_480p: float | None = None
output_cost_per_second_720p: float | None = None
output_cost_per_second_768p: float | None = None
output_cost_per_second_2k: float | None = None
output_cost_per_second_4k: float | None = None
input_cost_per_pixel: float | None = None
output_cost_per_pixel: float | None = None

View file

@ -6084,6 +6084,8 @@ def _get_model_info_helper(
output_cost_per_second_1080p=_model_info.get("output_cost_per_second_1080p", None),
output_cost_per_second_480p=_model_info.get("output_cost_per_second_480p", None),
output_cost_per_second_720p=_model_info.get("output_cost_per_second_720p", None),
output_cost_per_second_768p=_model_info.get("output_cost_per_second_768p", None),
output_cost_per_second_2k=_model_info.get("output_cost_per_second_2k", None),
output_cost_per_second_4k=_model_info.get("output_cost_per_second_4k", None),
output_cost_per_video_per_second=_model_info.get("output_cost_per_video_per_second", None),
output_cost_per_image=_model_info.get("output_cost_per_image", None),

View file

@ -22889,6 +22889,45 @@
"video"
]
},
"fal_ai/minimax/h3/text-to-video": {
"litellm_provider": "fal_ai",
"mode": "video_generation",
"output_cost_per_second": 0.13,
"output_cost_per_second_480p": 0.05,
"output_cost_per_second_768p": 0.06,
"output_cost_per_second_2k": 0.13,
"output_cost_per_second_4k": 0.16,
"source": "https://fal.ai/models/minimax/h3/text-to-video",
"supported_endpoints": [
"/v1/videos"
],
"supported_modalities": [
"text"
],
"supported_output_modalities": [
"video"
]
},
"fal_ai/minimax/h3/reference-to-video": {
"litellm_provider": "fal_ai",
"mode": "video_generation",
"output_cost_per_second": 0.13,
"output_cost_per_second_480p": 0.05,
"output_cost_per_second_768p": 0.06,
"output_cost_per_second_2k": 0.13,
"output_cost_per_second_4k": 0.16,
"source": "https://fal.ai/models/minimax/h3/reference-to-video",
"supported_endpoints": [
"/v1/videos"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"video"
]
},
"fal_ai/bytedance/seedance-2.0/text-to-video": {
"litellm_provider": "fal_ai",
"mode": "video_generation",

View file

@ -607,6 +607,10 @@
"type": "number",
"minimum": 0
},
"output_cost_per_second_2k": {
"type": "number",
"minimum": 0
},
"output_cost_per_second_480p": {
"type": "number",
"minimum": 0
@ -619,6 +623,10 @@
"type": "number",
"minimum": 0
},
"output_cost_per_second_768p": {
"type": "number",
"minimum": 0
},
"output_cost_per_token": {
"type": "number",
"minimum": 0,

View file

@ -175,6 +175,9 @@
"tests/integration/providers/test_fal_ai_image_wire.py::test_fal_gpt_image_25_edit_inlines_upload_as_data_url_and_charges_keyed_row": [
"other.provider_wire.fal_ai.image_edit_json_data_urls_and_keyed_pricing"
],
"tests/integration/providers/test_fal_ai_video_wire.py::test_fal_h3_video_create_uses_canonical_body_and_status_path": [
"other.provider_wire.fal_ai.video_queue_create_status_and_content_download"
],
"tests/integration/mcp/test_mcp_lifecycle.py::test_saved_headers_reach_real_mcp_tool_and_survive_unrelated_edit": [
"mcp.call_tool.saved_headers.reach_actual_transport"
],

View file

@ -7,6 +7,7 @@ from integration._support.client import Gateway
from integration._support.wire import Reply, Request, wire_server
_MODEL: Final = "bytedance/seedance-2.5/text-to-video"
_H3_MODEL: Final = "minimax/h3/text-to-video"
_MP4: Final = b"\x00\x00\x00\x18ftypmp42" + uuid.uuid4().bytes * 4
@ -67,3 +68,56 @@ def test_fal_video_create_status_and_content_follow_queue_wire_contract(gateway:
("GET", f"/bytedance/seedance-2.5/requests/{request_id}"),
("GET", f"/files/{request_id}.mp4"),
]
@pytest.mark.covers("other.provider_wire.fal_ai.video_queue_create_status_and_content_download")
def test_fal_h3_video_create_uses_canonical_body_and_status_path(gateway: Gateway) -> None:
request_id: Final = "fal-h3-req-" + uuid.uuid4().hex
def respond(request: Request) -> Reply:
assert request.headers["authorization"] == "Key synthetic-fal-key"
if request.method == "POST":
assert request.target == f"/{_H3_MODEL}"
assert json.loads(request.body) == {
"prompt": "a cat playing volleyball on a beach",
"duration": 6,
"resolution": "2K",
}
return Reply(
body=json.dumps({"status": "IN_QUEUE", "request_id": request_id, "queue_position": 0}).encode()
)
assert request.method == "GET"
if request.target == f"/minimax/h3/requests/{request_id}/status":
return Reply(body=json.dumps({"status": "COMPLETED", "request_id": request_id}).encode())
assert request.target == f"/minimax/h3/requests/{request_id}"
return Reply(body=json.dumps({"video": {"url": f"{wire_url}/files/{request_id}.mp4"}}).encode())
with wire_server(respond) as wire, gateway.scenario() as scenario:
wire_url: Final = wire.url
model: Final = scenario.model(
model=f"fal_ai/{_H3_MODEL}",
api_base=wire.url,
api_key="synthetic-fal-key",
)
created: Final = gateway.post(
"/v1/videos",
{
"model": model,
"prompt": "a cat playing volleyball on a beach",
"seconds": 6,
"size": "2k",
},
)
assert created["status"] == "queued"
video_id: Final = created["id"]
status: Final = gateway.get(f"/v1/videos/{video_id}")
assert status["status"] == "completed"
content: Final = gateway.request("GET", f"/v1/videos/{video_id}/content")
assert content.status_code == 200, content.text
assert content.content == _MP4
assert [(request.method, request.target) for request in wire.drain()] == [
("POST", f"/{_H3_MODEL}"),
("GET", f"/minimax/h3/requests/{request_id}/status"),
("GET", f"/minimax/h3/requests/{request_id}"),
("GET", f"/files/{request_id}.mp4"),
]

View file

@ -11,12 +11,15 @@ from litellm.llms.fal_ai.videos.transformation import (
FalAIVideoError,
_queue_request_base_path,
)
from litellm.llms.openai.cost_calculation import video_generation_cost
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from litellm.types.videos.utils import decode_video_id_with_provider
from litellm.utils import ProviderConfigManager
MODEL = "bytedance/seedance-2.5/text-to-video"
H3_TEXT_MODEL = "minimax/h3/text-to-video"
H3_REFERENCE_MODEL = "minimax/h3/reference-to-video"
class TestFalAIVideoTransformation:
@ -64,6 +67,24 @@ class TestFalAIVideoTransformation:
with pytest.raises(ValueError, match="public image URL"):
self.config.map_openai_params({"input_reference": b"image"}, MODEL, False)
def test_map_openai_params_supports_h3_profiles(self):
url = "https://example.com/image.png"
assert self.config.map_openai_params({"size": "2k"}, H3_TEXT_MODEL, False) == {"resolution": "2K"}
assert self.config.map_openai_params({"size": "1024x768"}, H3_TEXT_MODEL, False) == {
"resolution": "768P",
"aspect_ratio": "4:3",
}
mapped = self.config.map_openai_params(
{"seconds": 6, "input_reference": url},
H3_REFERENCE_MODEL,
False,
)
assert mapped["duration"] == 6
assert isinstance(mapped["duration"], int)
assert mapped["reference_image_urls"] == [url]
assert "image_url" not in mapped
def test_transform_video_create_request(self):
body, files, url = self.config.transform_video_create_request(
model=MODEL,
@ -141,6 +162,20 @@ class TestFalAIVideoTransformation:
assert auto_video.seconds is None
assert auto_video.size is None
def test_transform_video_create_response_uses_h3_default_resolution(self):
response = Mock(spec=httpx.Response)
response.json.return_value = {"request_id": "abc"}
video = self.config.transform_video_create_response(
model=H3_TEXT_MODEL,
raw_response=response,
logging_obj=self.logging_obj,
custom_llm_provider="fal_ai",
request_data={"duration": 5},
)
assert video.usage == {"duration_seconds": 5.0, "video_resolution": "2K"}
def test_status_request_uses_queue_base_path(self):
response = Mock(spec=httpx.Response)
response.json.return_value = {"request_id": "abc"}
@ -290,9 +325,29 @@ class TestFalAIVideoTransformation:
}
assert rows
for model, row in rows.items():
assert default_video_cost_calculator(model, 5, "fal_ai", video_resolution="480p") == (
5 * row["output_cost_per_second_480p"]
)
assert default_video_cost_calculator(model, 5, "fal_ai", video_resolution="720p") == (
for key, value in row.items():
if key.startswith("output_cost_per_second_") and value is not None:
tier = key.removeprefix("output_cost_per_second_")
assert default_video_cost_calculator(model, 5, "fal_ai", video_resolution=tier) == 5 * value
assert default_video_cost_calculator(model, 5, "fal_ai", video_resolution="9999p") == (
5 * row["output_cost_per_second"]
)
def test_h3_video_cost_uses_model_info_tiers(self, local_model_cost_map):
row = litellm.model_cost[f"fal_ai/{H3_TEXT_MODEL}"]
model_info = litellm.get_model_info(model=H3_TEXT_MODEL, custom_llm_provider="fal_ai")
assert video_generation_cost(
model=H3_TEXT_MODEL,
duration_seconds=5,
custom_llm_provider="fal_ai",
model_info=model_info,
video_resolution="2K",
) == 5 * row["output_cost_per_second_2k"]
assert video_generation_cost(
model=H3_TEXT_MODEL,
duration_seconds=5,
custom_llm_provider="fal_ai",
model_info=model_info,
video_resolution="768p",
) == 5 * row["output_cost_per_second_768p"]

View file

@ -619,6 +619,8 @@ def validate_model_cost_values(model_data, exceptions=None):
"output_cost_per_second",
"output_cost_per_second_480p",
"output_cost_per_second_720p",
"output_cost_per_second_768p",
"output_cost_per_second_2k",
"output_cost_per_second_1080p",
"output_cost_per_second_4k",
"input_cost_per_query",
@ -838,6 +840,8 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"output_cost_per_second": {"type": "number"},
"output_cost_per_second_480p": {"type": "number"},
"output_cost_per_second_720p": {"type": "number"},
"output_cost_per_second_768p": {"type": "number"},
"output_cost_per_second_2k": {"type": "number"},
"output_cost_per_second_1080p": {"type": "number"},
"output_cost_per_second_4k": {"type": "number"},
"output_cost_per_token": {"type": "number"},

View file

@ -31138,12 +31138,16 @@ export interface components {
output_cost_per_second?: number | null;
/** Output Cost Per Second 1080P */
output_cost_per_second_1080p?: number | null;
/** Output Cost Per Second 2K */
output_cost_per_second_2k?: number | null;
/** Output Cost Per Second 480P */
output_cost_per_second_480p?: number | null;
/** Output Cost Per Second 4K */
output_cost_per_second_4k?: number | null;
/** Output Cost Per Second 720P */
output_cost_per_second_720p?: number | null;
/** Output Cost Per Second 768P */
output_cost_per_second_768p?: number | null;
/** Output Cost Per Token */
output_cost_per_token?: number | null;
/** Output Cost Per Token Above 128K Tokens */
@ -41878,12 +41882,16 @@ export interface components {
output_cost_per_second?: number | null;
/** Output Cost Per Second 1080P */
output_cost_per_second_1080p?: number | null;
/** Output Cost Per Second 2K */
output_cost_per_second_2k?: number | null;
/** Output Cost Per Second 480P */
output_cost_per_second_480p?: number | null;
/** Output Cost Per Second 4K */
output_cost_per_second_4k?: number | null;
/** Output Cost Per Second 720P */
output_cost_per_second_720p?: number | null;
/** Output Cost Per Second 768P */
output_cost_per_second_768p?: number | null;
/** Output Cost Per Token */
output_cost_per_token?: number | null;
/** Output Cost Per Token Above 128K Tokens */