fix(fal_ai): handle seconds=auto and oversized sizes for minimax h3 videos (#42504)

* test(fal_ai): e2e for minimax h3 auto duration and oversized size

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(fal_ai): keep auto duration literal and make h3 tier lookup total

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(fal_ai): omit duration for h3 when seconds is auto

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(fal_ai): move the minimax h3 auto duration and oversized size repro to tests/integration

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-22 18:51:30 +00:00 • committed by GitHub
parent ee3f5a8bdf
commit 5af9b0136c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 87 additions and 1 deletions

View file

@ -104,7 +104,10 @@ def _profile_for_model(model: str) -> _ModelProfile:
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)
return next(
(resolution for threshold, resolution in profile.resolution_tiers if short_side <= threshold),
profile.resolution_tiers[-1][1],
)
def _model_path_from_request_url(raw_response: httpx.Response) -> str | None:
@ -351,6 +354,8 @@ class FalAIVideoConfig(BaseVideoConfig):
duration: Final[str | None] = _duration_value(seconds)
if duration is None:
raise ValueError("fal.ai seconds must be a numeric value")
if duration == "auto":
return MappingProxyType({}) if profile.integer_duration else MappingProxyType({"duration": duration})
return MappingProxyType({"duration": int(duration) if profile.integer_duration else duration})
def validate_environment(

View file

@ -169,6 +169,12 @@
"tests/integration/providers/test_fal_ai_video_wire.py::test_fal_video_failed_result_reports_failed_status_and_fal_error": [
"other.provider_wire.fal_ai.video_failed_result_surfaces_fal_error"
],
"tests/integration/providers/test_fal_ai_video_wire.py::test_fal_h3_auto_duration_omits_duration_and_queues": [
"other.provider_wire.fal_ai.h3_auto_duration_omits_duration_and_queues"
],
"tests/integration/providers/test_fal_ai_video_wire.py::test_fal_h3_oversized_size_uses_top_resolution_tier_and_queues": [
"other.provider_wire.fal_ai.h3_oversized_size_uses_top_resolution_tier"
],
"tests/integration/providers/test_fal_ai_image_wire.py::test_fal_gpt_image_25_generation_sends_quality_and_size_and_charges_keyed_row": [
"other.provider_wire.fal_ai.gpt_image_generation_quality_size_wire_and_keyed_pricing"
],

View file

@ -9,6 +9,11 @@ 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
_OVERSIZED_SIDE: Final = "9" * 30
def _h3_queue_reply(request_id: str) -> Reply:
return Reply(body=json.dumps({"status": "IN_QUEUE", "request_id": request_id, "queue_position": 0}).encode())
@pytest.mark.covers("other.provider_wire.fal_ai.video_queue_create_status_and_content_download")
@ -177,3 +182,61 @@ def test_fal_video_failed_result_reports_failed_status_and_fal_error(gateway: Ga
content: Final = gateway.request("GET", f"/v1/videos/{video_id}/content")
assert content.status_code == 422, content.text
assert "Failed to download the file" in content.text
@pytest.mark.covers("other.provider_wire.fal_ai.h3_auto_duration_omits_duration_and_queues")
def test_fal_h3_auto_duration_omits_duration_and_queues(gateway: Gateway) -> None:
request_id: Final = "fal-h3-auto-" + uuid.uuid4().hex
def respond(request: Request) -> Reply:
assert request.method == "POST"
assert request.target == f"/{_H3_MODEL}"
assert json.loads(request.body) == {
"prompt": "a cat playing volleyball on a beach",
"resolution": "768P",
"aspect_ratio": "16:9",
}
return _h3_queue_reply(request_id)
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"fal_ai/{_H3_MODEL}", api_base=wire.url, api_key="synthetic-fal-key")
response: Final = gateway.request(
"POST",
"/v1/videos",
{"model": model, "prompt": "a cat playing volleyball on a beach", "seconds": "auto", "size": "1280x720"},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "queued"
assert [(request.method, request.target) for request in wire.drain()] == [("POST", f"/{_H3_MODEL}")]
@pytest.mark.covers("other.provider_wire.fal_ai.h3_oversized_size_uses_top_resolution_tier")
def test_fal_h3_oversized_size_uses_top_resolution_tier_and_queues(gateway: Gateway) -> None:
request_id: Final = "fal-h3-oversized-" + uuid.uuid4().hex
def respond(request: Request) -> Reply:
assert request.method == "POST"
assert request.target == f"/{_H3_MODEL}"
assert json.loads(request.body) == {
"prompt": "a cat playing volleyball on a beach",
"duration": 5,
"resolution": "4K",
"aspect_ratio": "1:1",
}
return _h3_queue_reply(request_id)
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"fal_ai/{_H3_MODEL}", api_base=wire.url, api_key="synthetic-fal-key")
response: Final = gateway.request(
"POST",
"/v1/videos",
{
"model": model,
"prompt": "a cat playing volleyball on a beach",
"seconds": "5",
"size": f"{_OVERSIZED_SIDE}x{_OVERSIZED_SIDE}",
},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "queued"
assert [(request.method, request.target) for request in wire.drain()] == [("POST", f"/{_H3_MODEL}")]

View file

@ -1,3 +1,4 @@
import sys
from typing import Final
from unittest.mock import AsyncMock, Mock
@ -86,6 +87,17 @@ class TestFalAIVideoTransformation:
assert mapped["reference_image_urls"] == [url]
assert "image_url" not in mapped
def test_map_openai_params_h3_omits_auto_duration(self):
assert self.config.map_openai_params({"seconds": "auto"}, H3_TEXT_MODEL, False) == {}
assert self.config.map_openai_params({"seconds": "auto"}, MODEL, False) == {"duration": "auto"}
def test_map_openai_params_h3_size_beyond_tiers_uses_top_resolution(self):
side = str(sys.maxsize + 1)
assert self.config.map_openai_params({"size": f"{side}x{side}"}, H3_TEXT_MODEL, False) == {
"resolution": "4K",
"aspect_ratio": "1:1",
}
def test_transform_video_create_request(self):
body, files, url = self.config.transform_video_create_request(
model=MODEL,