From 5af9b0136c98a14b6c68f8301e8dce71c96f437f Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:51:30 +0000 Subject: [PATCH] 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 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fal_ai/videos/transformation.py | 7 ++- tests/integration/contracts.json | 6 ++ .../providers/test_fal_ai_video_wire.py | 63 +++++++++++++++++++ .../test_fal_ai_video_transformation.py | 12 ++++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index 51082a6773b..44b33a8f58f 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -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( diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json index e1b5940935f..8c9f33094da 100644 --- a/tests/integration/contracts.json +++ b/tests/integration/contracts.json @@ -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" ], diff --git a/tests/integration/providers/test_fal_ai_video_wire.py b/tests/integration/providers/test_fal_ai_video_wire.py index 276ea2868a7..1f3113bb840 100644 --- a/tests/integration/providers/test_fal_ai_video_wire.py +++ b/tests/integration/providers/test_fal_ai_video_wire.py @@ -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}")] diff --git a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py index 86ecbf6701b..4b8bfd264a6 100644 --- a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py +++ b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py @@ -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,