mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
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:
parent
e58a561caa
commit
79d077d19c
10 changed files with 835 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
3
litellm/llms/fal_ai/videos/__init__.py
Normal file
3
litellm/llms/fal_ai/videos/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .transformation import FalAIVideoConfig
|
||||
|
||||
__all__ = ["FalAIVideoConfig"]
|
||||
405
litellm/llms/fal_ai/videos/transformation.py
Normal file
405
litellm/llms/fal_ai/videos/transformation.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
0
tests/test_litellm/llms/fal_ai/__init__.py
Normal file
0
tests/test_litellm/llms/fal_ai/__init__.py
Normal file
0
tests/test_litellm/llms/fal_ai/videos/__init__.py
Normal file
0
tests/test_litellm/llms/fal_ai/videos/__init__.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue