diff --git a/litellm/constants.py b/litellm/constants.py index e36746326cc..a9c65b01a8b 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -224,6 +224,13 @@ RUNWAYML_POLLING_TIMEOUT = int( os.getenv("RUNWAYML_POLLING_TIMEOUT", 600) ) # 10 minutes default for image generation +FAL_AI_DEFAULT_API_BASE = str( + os.getenv("FAL_AI_DEFAULT_API_BASE", "https://queue.fal.run") +) +FAL_AI_POLLING_TIMEOUT = int( + os.getenv("FAL_AI_POLLING_TIMEOUT", 900) +) # 15 minutes default for video generation + ########## Networking constants ############################################################## _DEFAULT_TTL_FOR_HTTPX_CLIENTS = 3600 # 1 hour, re-use the same httpx client for 1 hour diff --git a/litellm/llms/fal_ai/__init__.py b/litellm/llms/fal_ai/__init__.py index 0de526a8eb7..f924397a45d 100644 --- a/litellm/llms/fal_ai/__init__.py +++ b/litellm/llms/fal_ai/__init__.py @@ -11,6 +11,7 @@ from .image_generation import ( FalAIStableDiffusionConfig, get_fal_ai_image_generation_config, ) +from .videos import FalAIVideoConfig __all__ = [ "cost_calculator", @@ -23,5 +24,6 @@ __all__ = [ "FalAIFluxProV11UltraConfig", "FalAIFluxSchnellConfig", "FalAIStableDiffusionConfig", + "FalAIVideoConfig", "get_fal_ai_image_generation_config", ] diff --git a/litellm/llms/fal_ai/videos/__init__.py b/litellm/llms/fal_ai/videos/__init__.py new file mode 100644 index 00000000000..894176775de --- /dev/null +++ b/litellm/llms/fal_ai/videos/__init__.py @@ -0,0 +1,3 @@ +from .transformation import FalAIVideoConfig + +__all__ = ["FalAIVideoConfig"] diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py new file mode 100644 index 00000000000..7250e323055 --- /dev/null +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -0,0 +1,405 @@ +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union + +import httpx +from httpx._types import RequestFiles + +import litellm +from litellm.constants import FAL_AI_DEFAULT_API_BASE +from litellm.litellm_core_utils.url_utils import encode_url_path_segment +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, + get_async_httpx_client, +) +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, +) + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + + +_FAL_AI_STATUS_MAP = { + "IN_QUEUE": "queued", + "IN_PROGRESS": "in_progress", + "COMPLETED": "completed", + "FAILED": "failed", + "CANCELLED": "failed", +} + +_SIZE_TO_ASPECT_RATIO = { + "1280x720": "16:9", + "1920x1080": "16:9", + "720x1280": "9:16", + "1080x1920": "9:16", + "1024x1024": "1:1", + "1280x1280": "1:1", +} + + +def _normalize_fal_model_id(model: str) -> str: + stripped = model + if stripped.startswith("fal_ai/"): + stripped = stripped[len("fal_ai/") :] + stripped = stripped.strip("/") + if not stripped: + raise ValueError("fal.ai model id is empty after stripping provider prefix") + return stripped + + +class FalAIVideoConfig(BaseVideoConfig): + """ + fal.ai uses a queue API: POST to /{model_id}, then poll + /{model_id}/requests/{id}/status and GET /{model_id}/requests/{id} for the + result. Video models return {"video": {"url": ...}}. + """ + + def get_supported_openai_params(self, model: str) -> list: + return [ + "model", + "prompt", + "seconds", + "size", + "user", + "extra_headers", + "extra_body", + ] + + def map_openai_params( + self, + video_create_optional_params: VideoCreateOptionalRequestParams, + model: str, + drop_params: bool, + ) -> Dict: + mapped: Dict[str, Any] = {} + + seconds = video_create_optional_params.get("seconds") + if seconds is not None: + mapped["duration"] = str(seconds) + + size = video_create_optional_params.get("size") + if isinstance(size, str): + aspect = _SIZE_TO_ASPECT_RATIO.get(size) + if aspect is not None: + mapped["aspect_ratio"] = aspect + elif "x" in size: + mapped["aspect_ratio"] = size.replace("x", ":") + + supported = self.get_supported_openai_params(model) + for key, value in video_create_optional_params.items(): + if key not in supported: + mapped[key] = value + + extra_body = video_create_optional_params.get("extra_body") + if isinstance(extra_body, dict): + mapped.update(extra_body) + mapped.pop("extra_body", None) + + return mapped + + def validate_environment( + self, + headers: dict, + model: str, + api_key: Optional[str] = None, + litellm_params: Optional[GenericLiteLLMParams] = None, + ) -> dict: + if litellm_params and litellm_params.api_key: + api_key = api_key or litellm_params.api_key + + resolved_key = ( + api_key + or litellm.api_key + or get_secret_str("FAL_AI_API_KEY") + or get_secret_str("FAL_KEY") + ) + + if not resolved_key: + raise ValueError( + "fal.ai API key is required. Set FAL_AI_API_KEY (or FAL_KEY) " + "environment variable or pass api_key parameter." + ) + + headers.update( + { + "Authorization": f"Key {resolved_key}", + "Content-Type": "application/json", + } + ) + return headers + + def get_complete_url( + self, + model: str, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + base = api_base or get_secret_str("FAL_AI_API_BASE") or FAL_AI_DEFAULT_API_BASE + return base.rstrip("/") + + def transform_video_create_request( + self, + model: str, + prompt: str, + api_base: str, + video_create_optional_request_params: Dict, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> Tuple[Dict, RequestFiles, str]: + model_id = _normalize_fal_model_id(model) + + request_data: Dict[str, Any] = {"prompt": prompt} + request_data.update(video_create_optional_request_params) + request_data.pop("model", None) + + return request_data, [], f"{api_base}/{model_id}" + + def transform_video_create_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + custom_llm_provider: Optional[str] = None, + request_data: Optional[Dict] = None, + ) -> VideoObject: + response_data = raw_response.json() + model_id = _normalize_fal_model_id(model) + + video_data: Dict[str, Any] = { + "id": response_data.get("request_id", ""), + "object": "video", + "status": _FAL_AI_STATUS_MAP.get( + response_data.get("status", "IN_QUEUE").upper(), "queued" + ), + "model": model, + } + + if request_data: + if "duration" in request_data: + video_data["seconds"] = str(request_data["duration"]) + if "aspect_ratio" in request_data: + video_data["size"] = str(request_data["aspect_ratio"]).replace(":", "x") + + video_obj = VideoObject(**video_data) # type: ignore[arg-type] + + if custom_llm_provider and video_obj.id: + video_obj.id = encode_video_id_with_provider( + video_obj.id, custom_llm_provider, model_id + ) + + usage: Dict[str, Any] = {} + if video_obj.seconds: + try: + usage["duration_seconds"] = float(video_obj.seconds) + except (ValueError, TypeError): + pass + 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, + ) -> Tuple[str, Dict]: + original_id, model_id = self._extract_request_and_model_id(video_id) + encoded = encode_url_path_segment(original_id, field_name="video_id") + return f"{api_base}/{model_id}/requests/{encoded}/status", {} + + def transform_video_status_retrieve_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + custom_llm_provider: Optional[str] = None, + ) -> VideoObject: + response_data = raw_response.json() + status_raw = response_data.get("status", "IN_QUEUE") + + video_data: Dict[str, Any] = { + "id": response_data.get("request_id", ""), + "object": "video", + "status": _FAL_AI_STATUS_MAP.get(status_raw.upper(), "queued"), + } + + if "queue_position" in response_data: + video_data["progress"] = response_data["queue_position"] + + if status_raw.upper() == "FAILED": + video_data["error"] = { + "code": "failed", + "message": str(response_data.get("error") or "Video generation failed"), + } + + video_obj = VideoObject(**video_data) # type: ignore[arg-type] + + if custom_llm_provider and video_obj.id: + video_obj.id = encode_video_id_with_provider( + video_obj.id, custom_llm_provider, None + ) + + return video_obj + + def transform_video_content_request( + self, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + variant: Optional[str] = None, + ) -> Tuple[str, Dict]: + original_id, model_id = self._extract_request_and_model_id(video_id) + encoded = encode_url_path_segment(original_id, field_name="video_id") + return f"{api_base}/{model_id}/requests/{encoded}", {} + + def transform_video_content_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + ) -> bytes: + video_url = self._extract_video_url(raw_response.json()) + httpx_client: HTTPHandler = _get_httpx_client() + video_response = httpx_client.get(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: LiteLLMLoggingObj, + ) -> bytes: + video_url = self._extract_video_url(raw_response.json()) + async_client: AsyncHTTPHandler = get_async_httpx_client( + llm_provider=litellm.LlmProviders.FAL_AI, + ) + video_response = await async_client.get(video_url) + video_response.raise_for_status() + return video_response.content + + @staticmethod + def _extract_video_url(response_data: Dict[str, Any]) -> str: + video = response_data.get("video") + if isinstance(video, dict): + url = video.get("url") + if isinstance(url, str) and url: + return url + + top_level = response_data.get("url") + if isinstance(top_level, str) and top_level: + return top_level + + raise ValueError( + "Video URL not found in fal.ai response. The job may still be processing." + ) + + @staticmethod + def _extract_request_and_model_id(video_id: str) -> Tuple[str, str]: + # fal.ai queue URLs embed the model id, so we need it back at lookup time. + decoded = decode_video_id_with_provider(video_id) + original_id = decoded.get("video_id") or extract_original_video_id(video_id) + model_id = decoded.get("model_id") + + if not model_id: + raise ValueError( + "fal.ai video status/content lookup requires a model id encoded " + "in the video_id. Use the id returned by video creation." + ) + + return original_id, model_id + + def transform_video_remix_request( + self, + video_id: str, + prompt: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + extra_body: Optional[Dict[str, Any]] = None, + ) -> Tuple[str, Dict]: + raise NotImplementedError( + "Video remix is not supported by the fal.ai queue API" + ) + + def transform_video_remix_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + custom_llm_provider: Optional[str] = None, + ) -> VideoObject: + raise NotImplementedError( + "Video remix is not supported by the fal.ai queue API" + ) + + def transform_video_list_request( + self, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + after: Optional[str] = None, + limit: Optional[int] = None, + order: Optional[str] = None, + extra_query: Optional[Dict[str, Any]] = None, + ) -> Tuple[str, Dict]: + raise NotImplementedError( + "Video listing is not supported by the fal.ai queue API" + ) + + def transform_video_list_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + custom_llm_provider: Optional[str] = None, + ) -> Dict[str, str]: + raise NotImplementedError( + "Video listing is not supported by the fal.ai queue API" + ) + + def transform_video_delete_request( + self, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> Tuple[str, Dict]: + original_id, model_id = self._extract_request_and_model_id(video_id) + encoded = encode_url_path_segment(original_id, field_name="video_id") + return f"{api_base}/{model_id}/requests/{encoded}/cancel", {} + + def transform_video_delete_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + ) -> VideoObject: + response_data: Dict[str, Any] = {} + try: + response_data = raw_response.json() + except Exception: + pass + + return VideoObject( + id=response_data.get("request_id", ""), + object="video", + status="cancelled", + ) # type: ignore[arg-type] + + def get_error_class( + self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] + ) -> BaseLLMException: + raise BaseLLMException( + status_code=status_code, + message=error_message, + headers=headers, + ) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 543172b8381..650e52f2f47 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1448,6 +1448,35 @@ "supports_native_structured_output": true, "supports_minimal_reasoning_effort": true }, + "jp.anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 4.125e-06, + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_token": 3.3e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_max_reasoning_effort": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true + }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, @@ -9228,6 +9257,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -9421,6 +9451,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -9454,6 +9485,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -9487,6 +9519,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -9521,6 +9554,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -13328,6 +13362,39 @@ "/v1/images/generations" ] }, + "fal_ai/fal-ai/kling-video/v2.5-turbo/pro/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video_per_second": 0.07, + "source": "https://fal.ai/pricing", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/veo3.1/fast/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/veo3.1/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ], + "metadata": { + "comment": "Listed price is for 720p/1080p video without audio" + } + }, "featherless_ai/featherless-ai/Qwerky-72B": { "litellm_provider": "featherless_ai", "max_input_tokens": 32768, diff --git a/litellm/utils.py b/litellm/utils.py index da80e4ae164..eaaa3732c52 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9052,6 +9052,10 @@ class ProviderConfigManager: from litellm.llms.runwayml.videos.transformation import RunwayMLVideoConfig return RunwayMLVideoConfig() + elif LlmProviders.FAL_AI == provider: + from litellm.llms.fal_ai.videos.transformation import FalAIVideoConfig + + return FalAIVideoConfig() return None @staticmethod diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 269e7daecc1..d8c2b4efbff 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -13333,6 +13333,39 @@ "/v1/images/generations" ] }, + "fal_ai/fal-ai/kling-video/v2.5-turbo/pro/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video_per_second": 0.07, + "source": "https://fal.ai/pricing", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/veo3.1/fast/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video_per_second": 0.1, + "source": "https://fal.ai/models/fal-ai/veo3.1/fast", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ], + "metadata": { + "comment": "Listed price is for 720p/1080p video without audio" + } + }, "featherless_ai/featherless-ai/Qwerky-72B": { "litellm_provider": "featherless_ai", "max_input_tokens": 32768, diff --git a/tests/test_litellm/llms/fal_ai/__init__.py b/tests/test_litellm/llms/fal_ai/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/fal_ai/videos/__init__.py b/tests/test_litellm/llms/fal_ai/videos/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/fal_ai/videos/test_falai_video_transformation.py b/tests/test_litellm/llms/fal_ai/videos/test_falai_video_transformation.py new file mode 100644 index 00000000000..34802ed29f9 --- /dev/null +++ b/tests/test_litellm/llms/fal_ai/videos/test_falai_video_transformation.py @@ -0,0 +1,314 @@ +from unittest.mock import Mock + +import httpx +import pytest + +from litellm.llms.fal_ai.videos.transformation import FalAIVideoConfig +from litellm.types.router import GenericLiteLLMParams +from litellm.types.videos.main import VideoObject +from litellm.types.videos.utils import ( + decode_video_id_with_provider, + encode_video_id_with_provider, +) + +SORA_2_MODEL = "fal_ai/fal-ai/sora-2/text-to-video" +KLING_MODEL = "fal_ai/fal-ai/kling-video/v2.5-turbo/pro/text-to-video" +KLING_MODEL_ID = "fal-ai/kling-video/v2.5-turbo/pro/text-to-video" +FAL_API_BASE = "https://queue.fal.run" + + +class TestFalAIVideoTransformation: + def setup_method(self): + self.config = FalAIVideoConfig() + self.mock_logging_obj = Mock() + + def test_validate_environment_uses_fal_ai_api_key(self, monkeypatch): + monkeypatch.setenv("FAL_AI_API_KEY", "test-key-123") + headers = self.config.validate_environment( + headers={}, + model=SORA_2_MODEL, + ) + assert headers["Authorization"] == "Key test-key-123" + assert headers["Content-Type"] == "application/json" + + def test_validate_environment_falls_back_to_fal_key(self, monkeypatch): + monkeypatch.delenv("FAL_AI_API_KEY", raising=False) + monkeypatch.setenv("FAL_KEY", "fallback-key") + headers = self.config.validate_environment(headers={}, model=SORA_2_MODEL) + assert headers["Authorization"] == "Key fallback-key" + + def test_validate_environment_raises_when_missing(self, monkeypatch): + monkeypatch.delenv("FAL_AI_API_KEY", raising=False) + monkeypatch.delenv("FAL_KEY", raising=False) + with pytest.raises(ValueError, match="fal.ai API key is required"): + self.config.validate_environment(headers={}, model=SORA_2_MODEL) + + def test_get_complete_url_uses_default_base(self, monkeypatch): + monkeypatch.delenv("FAL_AI_API_BASE", raising=False) + url = self.config.get_complete_url( + model=SORA_2_MODEL, api_base=None, litellm_params={} + ) + assert url == FAL_API_BASE + + def test_get_complete_url_strips_trailing_slash(self): + url = self.config.get_complete_url( + model=SORA_2_MODEL, + api_base="https://custom.example.com/", + litellm_params={}, + ) + assert url == "https://custom.example.com" + + def test_map_openai_params_converts_seconds_and_size(self): + params = self.config.map_openai_params( + video_create_optional_params={"seconds": 5, "size": "1280x720"}, + model=KLING_MODEL, + drop_params=False, + ) + assert params["duration"] == "5" + assert params["aspect_ratio"] == "16:9" + + def test_map_openai_params_falls_back_to_colon_replacement(self): + params = self.config.map_openai_params( + video_create_optional_params={"size": "640x480"}, + model=KLING_MODEL, + drop_params=False, + ) + assert params["aspect_ratio"] == "640:480" + + def test_map_openai_params_unpacks_extra_body(self): + params = self.config.map_openai_params( + video_create_optional_params={ + "extra_body": {"negative_prompt": "blurry", "cfg_scale": 0.5} + }, + model=KLING_MODEL, + drop_params=False, + ) + assert params["negative_prompt"] == "blurry" + assert params["cfg_scale"] == 0.5 + assert "extra_body" not in params + + def test_transform_video_create_request_builds_queue_url(self): + data, files, url = self.config.transform_video_create_request( + model=KLING_MODEL, + prompt="A demo video", + api_base=FAL_API_BASE, + video_create_optional_request_params={ + "duration": "5", + "aspect_ratio": "16:9", + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert url == f"{FAL_API_BASE}/{KLING_MODEL_ID}" + assert data["prompt"] == "A demo video" + assert data["duration"] == "5" + assert data["aspect_ratio"] == "16:9" + assert "model" not in data + assert files == [] + + def test_transform_video_create_response_encodes_model_into_video_id(self): + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "request_id": "abc-123", + "status": "IN_QUEUE", + } + + video_obj = self.config.transform_video_create_response( + model=KLING_MODEL, + raw_response=mock_response, + logging_obj=self.mock_logging_obj, + custom_llm_provider="fal_ai", + request_data={"duration": "5", "aspect_ratio": "16:9"}, + ) + + assert isinstance(video_obj, VideoObject) + assert video_obj.status == "queued" + assert video_obj.id.startswith("video_") + + decoded = decode_video_id_with_provider(video_obj.id) + assert decoded["video_id"] == "abc-123" + assert decoded["custom_llm_provider"] == "fal_ai" + assert decoded["model_id"] == KLING_MODEL_ID + + assert video_obj.seconds == "5" + assert video_obj.size == "16x9" + + def test_transform_video_status_retrieve_request_builds_status_url(self): + encoded_id = encode_video_id_with_provider("abc-123", "fal_ai", KLING_MODEL_ID) + url, params = self.config.transform_video_status_retrieve_request( + video_id=encoded_id, + api_base=FAL_API_BASE, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert url == f"{FAL_API_BASE}/{KLING_MODEL_ID}/requests/abc-123/status" + assert params == {} + + def test_transform_video_status_request_url_path_segment_is_encoded(self): + encoded_id = encode_video_id_with_provider( + "../../../etc/passwd", "fal_ai", KLING_MODEL_ID + ) + url, _ = self.config.transform_video_status_retrieve_request( + video_id=encoded_id, + api_base=FAL_API_BASE, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert "/requests/..%2F..%2F..%2Fetc%2Fpasswd/status" in url + + def test_transform_video_status_response_maps_in_progress(self): + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "request_id": "abc-123", + "status": "IN_PROGRESS", + "queue_position": 2, + } + status_obj = self.config.transform_video_status_retrieve_response( + raw_response=mock_response, + logging_obj=self.mock_logging_obj, + custom_llm_provider="fal_ai", + ) + assert status_obj.status == "in_progress" + assert status_obj.progress == 2 + + def test_transform_video_status_response_maps_failed_with_error(self): + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "request_id": "abc-123", + "status": "FAILED", + "error": "model timed out", + } + status_obj = self.config.transform_video_status_retrieve_response( + raw_response=mock_response, + logging_obj=self.mock_logging_obj, + custom_llm_provider="fal_ai", + ) + assert status_obj.status == "failed" + assert status_obj.error is not None + assert status_obj.error["message"] == "model timed out" + + def test_transform_video_content_request_builds_result_url(self): + encoded_id = encode_video_id_with_provider("abc-123", "fal_ai", KLING_MODEL_ID) + url, params = self.config.transform_video_content_request( + video_id=encoded_id, + api_base=FAL_API_BASE, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert url == f"{FAL_API_BASE}/{KLING_MODEL_ID}/requests/abc-123" + assert params == {} + + def test_extract_video_url_handles_video_object(self): + url = self.config._extract_video_url( + {"video": {"url": "https://cdn.example.com/v.mp4"}} + ) + assert url == "https://cdn.example.com/v.mp4" + + def test_extract_video_url_handles_top_level_url(self): + url = self.config._extract_video_url({"url": "https://cdn.example.com/v.mp4"}) + assert url == "https://cdn.example.com/v.mp4" + + def test_extract_video_url_raises_when_missing(self): + with pytest.raises(ValueError, match="Video URL not found"): + self.config._extract_video_url({"status": "IN_PROGRESS"}) + + def test_status_request_requires_model_id_in_video_id(self): + plain_id = encode_video_id_with_provider("abc-123", "fal_ai", None) + with pytest.raises(ValueError, match="model id encoded"): + self.config.transform_video_status_retrieve_request( + video_id=plain_id, + api_base=FAL_API_BASE, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + def test_transform_video_delete_request_builds_cancel_url(self): + encoded_id = encode_video_id_with_provider("abc-123", "fal_ai", KLING_MODEL_ID) + url, data = self.config.transform_video_delete_request( + video_id=encoded_id, + api_base=FAL_API_BASE, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert url == f"{FAL_API_BASE}/{KLING_MODEL_ID}/requests/abc-123/cancel" + assert data == {} + + def test_remix_and_list_raise_not_implemented(self): + with pytest.raises(NotImplementedError): + self.config.transform_video_remix_request( + video_id="x", + prompt="p", + api_base=FAL_API_BASE, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + with pytest.raises(NotImplementedError): + self.config.transform_video_list_request( + api_base=FAL_API_BASE, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + def test_full_video_workflow(self): + config = FalAIVideoConfig() + mock_logging_obj = Mock() + + data, _, url = config.transform_video_create_request( + model=KLING_MODEL, + prompt="A high quality demo of LiteLLM video gateway", + api_base=FAL_API_BASE, + video_create_optional_request_params={ + "duration": "5", + "aspect_ratio": "16:9", + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert url.endswith(KLING_MODEL_ID) + + create_response = Mock(spec=httpx.Response) + create_response.json.return_value = { + "request_id": "queued-id-1", + "status": "IN_QUEUE", + } + video_obj = config.transform_video_create_response( + model=KLING_MODEL, + raw_response=create_response, + logging_obj=mock_logging_obj, + custom_llm_provider="fal_ai", + request_data=data, + ) + assert video_obj.status == "queued" + assert video_obj.id.startswith("video_") + + status_url, _ = config.transform_video_status_retrieve_request( + video_id=video_obj.id, + api_base=FAL_API_BASE, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert status_url.endswith("/requests/queued-id-1/status") + + completed_response = Mock(spec=httpx.Response) + completed_response.json.return_value = { + "request_id": "queued-id-1", + "status": "COMPLETED", + } + completed_obj = config.transform_video_status_retrieve_response( + raw_response=completed_response, + logging_obj=mock_logging_obj, + custom_llm_provider="fal_ai", + ) + assert completed_obj.status == "completed" + + +def test_provider_config_manager_returns_fal_ai_video_config(): + from litellm.types.utils import LlmProviders + from litellm.utils import ProviderConfigManager + + config = ProviderConfigManager.get_provider_video_config( + model=SORA_2_MODEL, provider=LlmProviders.FAL_AI + ) + assert isinstance(config, FalAIVideoConfig)