feat(fal_ai): add video generation provider

Wires fal.ai into BaseVideoConfig with the queue API: submit at
/{model_id}, poll /{model_id}/requests/{id}/status, fetch result at
/{model_id}/requests/{id}. Adds entries for Kling 2.5 Turbo Pro and
Veo 3.1 Fast.

Refs #16073.
This commit is contained in:
bhartimadhav10 2026-05-16 19:18:03 +05:30
parent e58a561caa
commit 79d077d19c
10 changed files with 835 additions and 0 deletions

View file

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

View file

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

View file

@ -0,0 +1,3 @@
from .transformation import FalAIVideoConfig
__all__ = ["FalAIVideoConfig"]

View file

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

View file

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

View file

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

View file

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

View file

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