From f29840006085071fdd5ed0e169367c41464c0830 Mon Sep 17 00:00:00 2001 From: kenneth-berri Date: Thu, 12 Nov 2026 15:13:49 -0800 Subject: [PATCH] feat(fal_ai): bill flat per-video fal.ai models with output_cost_per_video (#51225) * fix(fal_ai): support flat per-video output pricing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fal_ai): handle flat-priced video catalog rows Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(rust): format flat video pricing parser test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/cost_calculator.py | 49 +- litellm/llms/fal_ai/cost_calculator.py | 3 + litellm/llms/openai/cost_calculation.py | 4 + ...odel_prices_and_context_window_backup.json | 432 ++++++++++++++++++ .../crates/model-catalog/src/model_info.rs | 1 + litellm/types/router.py | 1 + litellm/types/utils.py | 2 + .../src/lib/http/schema.d.ts | 4 + litellm/utils.py | 2 + model_prices_and_context_window.json | 432 ++++++++++++++++++ .../unit/llms/fal_ai/test_cost_calculator.py | 16 + .../test_fal_ai_video_transformation.py | 6 +- ...test_fal_ai_passthrough_logging_handler.py | 16 + .../test_llm_pass_through_endpoints.py | 14 + tests/unit/test_utils.py | 1 + tests/unit/test_video_generation.py | 45 ++ 16 files changed, 1009 insertions(+), 19 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index a37160b4528..91529ae9ff5 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -2070,25 +2070,21 @@ def completion_cost( litellm_logging_obj, custom_pricing, router_model_id ) - usage_obj = getattr(completion_response, "usage", None) + _video_usage_obj: object = attribute_or_none(completion_response, "usage") duration_seconds: float | None = None video_resolution: str | None = None provider_reported_cost: float | None = None - if completion_response is not None and usage_obj: - # Handle both dict and Pydantic Usage object - if isinstance(usage_obj, dict): - duration_seconds = usage_obj.get("duration_seconds", None) - _vr = usage_obj.get("video_resolution", None) - provider_reported_cost = usage_obj.get("provider_reported_cost_usd", None) - _vc = usage_obj.get("video_count", None) - else: - duration_seconds = getattr(usage_obj, "duration_seconds", None) - _vr = getattr(usage_obj, "video_resolution", None) - provider_reported_cost = getattr(usage_obj, "provider_reported_cost_usd", None) - _vc = getattr(usage_obj, "video_count", None) - if _vr is not None: - video_resolution = str(_vr).strip().lower() - video_count = _vc if isinstance(_vc, int) and not isinstance(_vc, bool) and _vc > 1 else 1 + if completion_response is not None and _video_usage_obj: + duration_seconds = _VIDEO_USAGE_NUMBER.validate_python( + _video_usage_value(_video_usage_obj, "duration_seconds") + ) + provider_reported_cost = _VIDEO_USAGE_NUMBER.validate_python( + _video_usage_value(_video_usage_obj, "provider_reported_cost_usd") + ) + if ( + video_resolution_value := _video_usage_value(_video_usage_obj, "video_resolution") + ) is not None: + video_resolution = str(video_resolution_value).strip().lower() if _video_model_info is None and provider_reported_cost is not None: return float(provider_reported_cost) @@ -2105,7 +2101,7 @@ def completion_cost( custom_llm_provider=custom_llm_provider, model_info=_video_model_info, video_resolution=video_resolution, - ) * video_count + _video_input_image_cost( + ) * _video_count(_video_usage_obj) + _video_input_image_cost( requested_model or model, custom_llm_provider, _video_model_info, @@ -2118,7 +2114,7 @@ def completion_cost( custom_llm_provider=custom_llm_provider, model_info=_video_model_info, video_resolution=video_resolution, - ) + ) * _video_count(_video_usage_obj) elif call_type in _SPEECH_CALL_TYPES: prompt_characters = _speech_billed_characters( completion_response if isinstance(completion_response, HttpxBinaryResponseContent) else None, @@ -2631,6 +2627,19 @@ def _speech_billed_characters(speech_response: HttpxBinaryResponseContent | None return reported if isinstance(reported, int) else litellm.utils.count_characters(text=prompt) +_VIDEO_USAGE_NUMBER: Final[TypeAdapter[float | None]] = TypeAdapter(float | None) + + +def _video_usage_value(usage: object, field: str) -> object: + fields: Final = as_str_mapping(usage) + return fields.get(field) if fields is not None else attribute_or_none(usage, field) + + +def _video_count(usage: object) -> int: + count: Final = _video_usage_value(usage, "video_count") + return count if type(count) is int and count > 1 else 1 + + def _video_input_image_cost( model: str, custom_llm_provider: str | None, @@ -3184,6 +3193,10 @@ def default_video_cost_calculator( if cost_info is None: raise Exception(f"Model not found in cost map for model={model}") + video_cost: Final = cost_info.get("output_cost_per_video") + if video_cost is not None: + return video_cost + # Check for video-specific cost per second first video_cost_per_second: Final = cost_info.get("output_cost_per_video_per_second") if video_cost_per_second is not None: diff --git a/litellm/llms/fal_ai/cost_calculator.py b/litellm/llms/fal_ai/cost_calculator.py index d2ad461a42f..d58cd80a6d8 100644 --- a/litellm/llms/fal_ai/cost_calculator.py +++ b/litellm/llms/fal_ai/cost_calculator.py @@ -189,6 +189,9 @@ def fal_ai_passthrough_cost(model: str, request_body: Mapping[str, object]) -> f entry: Final = _entry(f"{litellm.LlmProviders.FAL_AI.value}/{model}") if entry is None or "output_cost_per_billable_unit" in entry: return None + cost_per_video: Final = entry.get("output_cost_per_video") + if isinstance(cost_per_video, (int, float)): + return float(cost_per_video) cost_per_image: Final = _passthrough_cost_per_image(entry, request_body) if cost_per_image is None: return None diff --git a/litellm/llms/openai/cost_calculation.py b/litellm/llms/openai/cost_calculation.py index a6dbc79254e..af03c31d429 100644 --- a/litellm/llms/openai/cost_calculation.py +++ b/litellm/llms/openai/cost_calculation.py @@ -162,6 +162,10 @@ def video_generation_cost( if model_info is None: model_info = get_model_info(model=model, custom_llm_provider=custom_llm_provider or "openai") + video_cost: Final = model_info.get("output_cost_per_video") + if video_cost is not None: + return video_cost + # Check for video-specific cost per second video_cost_per_second: Final = model_info.get("output_cost_per_video_per_second") if video_cost_per_second is not None: diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 2962710bae0..4429fffe652 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -95168,6 +95168,438 @@ "video" ] }, + "fal_ai/fal-ai/auto-caption": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.1, + "source": "https://fal.ai/models/fal-ai/auto-caption", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/hunyuan-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.4, + "source": "https://fal.ai/models/fal-ai/hunyuan-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/hunyuan-video-image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.4, + "source": "https://fal.ai/models/fal-ai/hunyuan-video-image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/infinity-star/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.07, + "source": "https://fal.ai/models/fal-ai/infinity-star/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.02, + "source": "https://fal.ai/models/fal-ai/ltx-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-video-13b-distilled": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.04, + "source": "https://fal.ai/models/fal-ai/ltx-video-13b-distilled", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-video-13b-distilled/extend": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.04, + "source": "https://fal.ai/models/fal-ai/ltx-video-13b-distilled/extend", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-video-13b-distilled/multiconditioning": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.04, + "source": "https://fal.ai/models/fal-ai/ltx-video-13b-distilled/multiconditioning", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-video/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.02, + "source": "https://fal.ai/models/fal-ai/ltx-video/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/hailuo-2.3-fast/pro/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.33, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-2.3-fast/pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01-director": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01-director", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01-director/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01-director/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01-live": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01-live", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01-live/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01-live/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01-subject-reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01-subject-reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ovi": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.2, + "source": "https://fal.ai/models/fal-ai/ovi", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ovi/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.2, + "source": "https://fal.ai/models/fal-ai/ovi/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-i2v-lora": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.75, + "source": "https://fal.ai/models/fal-ai/wan-i2v-lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-pro/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.8, + "source": "https://fal.ai/models/fal-ai/wan-pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-t2v-lora": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.75, + "source": "https://fal.ai/models/fal-ai/wan-t2v-lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-5b/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.15, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-5b/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-5b/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.15, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-5b/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/moonvalley/marey/motion-transfer": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 2, + "source": "https://fal.ai/models/moonvalley/marey/motion-transfer", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/moonvalley/marey/pose-transfer": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 2, + "source": "https://fal.ai/models/moonvalley/marey/pose-transfer", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/mureka/api/generate/lyrics-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.1, + "source": "https://fal.ai/models/mureka/api/generate/lyrics-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, "fal_ai/fal-ai/wan-22-vace-fun-a14b/inpainting": { "litellm_provider": "fal_ai", "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, diff --git a/litellm/rust/crates/model-catalog/src/model_info.rs b/litellm/rust/crates/model-catalog/src/model_info.rs index cee4ebbf9bd..1b71774095f 100644 --- a/litellm/rust/crates/model-catalog/src/model_info.rs +++ b/litellm/rust/crates/model-catalog/src/model_info.rs @@ -120,6 +120,7 @@ pub struct ModelInfoFields { pub output_cost_per_second_768p: Option, pub output_cost_per_second_2k: Option, pub output_cost_per_second_4k: Option, + pub output_cost_per_video: Option, pub output_cost_per_video_per_second: Option, pub output_cost_per_image: Option, pub output_cost_per_billable_unit: Option, diff --git a/litellm/types/router.py b/litellm/types/router.py index bc715759c45..40b696887d7 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -869,6 +869,7 @@ class LiteLLMParamsTypedDict(TypedDict, total=False): cost_per_second: ReadOnly[float | None] input_cost_per_second: float | None input_cost_per_text_message: ReadOnly[float | None] + output_cost_per_video: ReadOnly[float | None] output_cost_per_second: float | None output_cost_per_second_480p: ReadOnly[float | None] output_cost_per_second_720p: ReadOnly[float | None] diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 56133a3f3e1..d1f2f343f1c 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -463,6 +463,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): output_cost_per_reasoning_token: float | None output_cost_per_reasoning_token_flex: float | None output_cost_per_reasoning_token_priority: float | None + output_cost_per_video: ReadOnly[float | None] output_cost_per_video_per_second: float | None # only for vertex ai models output_cost_per_audio_per_second: float | None # only for vertex ai models output_cost_per_second: float | None # for OpenAI Speech models @@ -4268,6 +4269,7 @@ class CustomPricingLiteLLMParams(MirroredPricingParams): output_cost_per_reasoning_token: float | None = None output_cost_per_reasoning_token_flex: float | None = None output_cost_per_reasoning_token_priority: float | None = None + output_cost_per_video: float | None = None output_cost_per_video_per_second: float | None = None output_cost_per_audio_per_second: float | None = None search_context_cost_per_query: dict[str, Any] | None = None diff --git a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts index c49a206f429..97f169bf9f5 100644 --- a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -37621,6 +37621,8 @@ export interface components { output_cost_per_token_priority?: number | null; /** Output Cost Per Token Ultrafast */ output_cost_per_token_ultrafast?: number | null; + /** Output Cost Per Video */ + output_cost_per_video?: number | null; /** Output Cost Per Video Per Second */ output_cost_per_video_per_second?: number | null; /** Output Cost Per Video Token */ @@ -54107,6 +54109,8 @@ export interface components { output_cost_per_token_priority?: number | null; /** Output Cost Per Token Ultrafast */ output_cost_per_token_ultrafast?: number | null; + /** Output Cost Per Video */ + output_cost_per_video?: number | null; /** Output Cost Per Video Per Second */ output_cost_per_video_per_second?: number | null; /** Output Cost Per Video Token */ diff --git a/litellm/utils.py b/litellm/utils.py index 71a29ee84b1..67b7b4f3d1e 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7485,6 +7485,7 @@ def get_model_info_helper( output_cost_per_second_768p=_model_info.get("output_cost_per_second_768p", None), output_cost_per_second_2k=_model_info.get("output_cost_per_second_2k", None), output_cost_per_second_4k=_model_info.get("output_cost_per_second_4k", None), + output_cost_per_video=_model_info.get("output_cost_per_video", None), output_cost_per_video_per_second=_model_info.get("output_cost_per_video_per_second", None), output_cost_per_image=_model_info.get("output_cost_per_image", None), output_cost_per_image_1K=_model_info.get("output_cost_per_image_1K", None), @@ -7667,6 +7668,7 @@ def get_model_info( ] # only for vertex ai models output_cost_per_image: Optional[float] output_vector_size: Optional[int] + output_cost_per_video: Optional[float] output_cost_per_video_per_second: Optional[float] # only for vertex ai models output_cost_per_audio_per_second: Optional[float] # only for vertex ai models litellm_provider: Required[str] diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 2962710bae0..4429fffe652 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -95168,6 +95168,438 @@ "video" ] }, + "fal_ai/fal-ai/auto-caption": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.1, + "source": "https://fal.ai/models/fal-ai/auto-caption", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/hunyuan-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.4, + "source": "https://fal.ai/models/fal-ai/hunyuan-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/hunyuan-video-image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.4, + "source": "https://fal.ai/models/fal-ai/hunyuan-video-image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/infinity-star/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.07, + "source": "https://fal.ai/models/fal-ai/infinity-star/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.02, + "source": "https://fal.ai/models/fal-ai/ltx-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-video-13b-distilled": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.04, + "source": "https://fal.ai/models/fal-ai/ltx-video-13b-distilled", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-video-13b-distilled/extend": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.04, + "source": "https://fal.ai/models/fal-ai/ltx-video-13b-distilled/extend", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-video-13b-distilled/multiconditioning": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.04, + "source": "https://fal.ai/models/fal-ai/ltx-video-13b-distilled/multiconditioning", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ltx-video/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.02, + "source": "https://fal.ai/models/fal-ai/ltx-video/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/hailuo-2.3-fast/pro/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.33, + "source": "https://fal.ai/models/fal-ai/minimax/hailuo-2.3-fast/pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01-director": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01-director", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01-director/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01-director/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01-live": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01-live", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01-live/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01-live/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01-subject-reference": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01-subject-reference", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/minimax/video-01/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.5, + "source": "https://fal.ai/models/fal-ai/minimax/video-01/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ovi": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.2, + "source": "https://fal.ai/models/fal-ai/ovi", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/ovi/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.2, + "source": "https://fal.ai/models/fal-ai/ovi/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-i2v-lora": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.75, + "source": "https://fal.ai/models/fal-ai/wan-i2v-lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-pro/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.8, + "source": "https://fal.ai/models/fal-ai/wan-pro/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan-t2v-lora": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.75, + "source": "https://fal.ai/models/fal-ai/wan-t2v-lora", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-5b/image-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.15, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-5b/image-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/fal-ai/wan/v2.2-5b/text-to-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.15, + "source": "https://fal.ai/models/fal-ai/wan/v2.2-5b/text-to-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/moonvalley/marey/motion-transfer": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 2, + "source": "https://fal.ai/models/moonvalley/marey/motion-transfer", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/moonvalley/marey/pose-transfer": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 2, + "source": "https://fal.ai/models/moonvalley/marey/pose-transfer", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, + "fal_ai/mureka/api/generate/lyrics-video": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.1, + "source": "https://fal.ai/models/mureka/api/generate/lyrics-video", + "supported_endpoints": [ + "/v1/videos" + ], + "supported_modalities": [ + "text", + "video" + ], + "supported_output_modalities": [ + "video" + ] + }, "fal_ai/fal-ai/wan-22-vace-fun-a14b/inpainting": { "litellm_provider": "fal_ai", "metadata": {"comment":"per second of generated video by resolution; the page's resolution default is auto or unstated, so output_cost_per_second is the highest tier (720p); tiers without a catalog field: 580p $0.075"}, diff --git a/tests/unit/llms/fal_ai/test_cost_calculator.py b/tests/unit/llms/fal_ai/test_cost_calculator.py index eaaf37b1d51..276c84b35ee 100644 --- a/tests/unit/llms/fal_ai/test_cost_calculator.py +++ b/tests/unit/llms/fal_ai/test_cost_calculator.py @@ -3,9 +3,11 @@ from typing import Final import pytest import litellm +from litellm.cost_calculator import default_video_cost_calculator from litellm.litellm_core_utils.core_helpers import set_provider_response_headers_in_hidden_params from litellm.litellm_core_utils.llm_cost_calc.utils import CostCalculatorUtils from litellm.llms.fal_ai.cost_calculator import FAL_BILLABLE_UNITS_HEADER, cost_calculator, fal_ai_passthrough_cost +from litellm.llms.openai.cost_calculation import video_generation_cost from litellm.types.utils import ImageObject, ImageResponse @@ -34,6 +36,20 @@ def _image_response_with_dimensions(dimensions: tuple[tuple[int, int], ...]) -> ) +@pytest.mark.parametrize("duration", [0.0, 8.0]) +def test_catalog_flat_video_price_reaches_both_video_calculators( + monkeypatch: pytest.MonkeyPatch, duration: float +) -> None: + monkeypatch.setitem( + litellm.model_cost, + "fal_ai/fal-ai/test-flat-video", + {"litellm_provider": "fal_ai", "mode": "video_generation", "output_cost_per_video": 0.37}, + ) + assert video_generation_cost("fal-ai/test-flat-video", duration, "fal_ai") == pytest.approx(0.37) + assert default_video_cost_calculator("fal-ai/test-flat-video", duration, "fal_ai") == pytest.approx(0.37) + assert fal_ai_passthrough_cost("fal-ai/test-flat-video", {}) == pytest.approx(0.37) + + GPT_IMAGE_25_MODELS = ( "openai/gpt-image-2.5/flare/text-to-image", "openai/gpt-image-2.5/flare/edit", diff --git a/tests/unit/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/unit/llms/fal_ai/videos/test_fal_ai_video_transformation.py index 44cc9add32d..b20c8069789 100644 --- a/tests/unit/llms/fal_ai/videos/test_fal_ai_video_transformation.py +++ b/tests/unit/llms/fal_ai/videos/test_fal_ai_video_transformation.py @@ -722,7 +722,8 @@ class TestFalAIVideoTransformation: assert isinstance(provider_config, FalAIVideoConfig) assert isinstance(self.config.get_error_class("bad key", 401, {}), FalAIVideoError) - def test_video_cost_uses_tiered_rows(self): + @pytest.mark.parametrize(("duration", "resolution"), [(0, None), (5, "720p"), (17, "9999p")]) + def test_video_cost_uses_tiered_rows(self, duration: float, resolution: str | None) -> None: rows = { model: row for model, row in litellm.model_cost.items() @@ -730,6 +731,9 @@ class TestFalAIVideoTransformation: } assert rows for model, row in rows.items(): + if (flat_cost := row.get("output_cost_per_video")) is not None: + assert default_video_cost_calculator(model, duration, "fal_ai", video_resolution=resolution) == flat_cost + continue for key, value in row.items(): if key.startswith("output_cost_per_second_") and value is not None: tier = key.removeprefix("output_cost_per_second_") diff --git a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_fal_ai_passthrough_logging_handler.py b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_fal_ai_passthrough_logging_handler.py index 1c945b9110b..f0f1f3bde68 100644 --- a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_fal_ai_passthrough_logging_handler.py +++ b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_fal_ai_passthrough_logging_handler.py @@ -87,6 +87,22 @@ def test_handler_charges_nothing_and_names_the_model_for_queue_status_and_result assert logging_obj.model_call_details["response_cost"] is None +def test_handler_charges_flat_video_queue_submit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem( + litellm.model_cost, + "fal_ai/fal-ai/test-flat-video", + {"litellm_provider": "fal_ai", "mode": "video_generation", "output_cost_per_video": 0.37}, + ) + result: Final = FalAIPassthroughLoggingHandler().fal_ai_passthrough_handler( + response_body={"request_id": "req-1", "status": "IN_QUEUE"}, + request_body={}, + logging_obj=_logging_obj(), + url_route="https://queue.fal.run/fal-ai/test-flat-video", + kwargs={}, + ) + assert result["kwargs"]["response_cost"] == pytest.approx(0.37) + + def test_handler_charges_for_queue_submit(): handler_result = FalAIPassthroughLoggingHandler().fal_ai_passthrough_handler( response_body={"request_id": "req-1", "status": "IN_QUEUE"}, diff --git a/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index e7556c06dfd..e95945cfd76 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -10663,6 +10663,20 @@ class TestFalAIPassthroughRoute: assert sent.headers["authorization"] == "Key fal-test-key" assert json.loads(sent.content or b"{}") == body + def test_flat_video_submit_is_accepted(self, client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem( + litellm.model_cost, + "fal_ai/fal-ai/test-flat-video", + {"litellm_provider": "fal_ai", "mode": "video_generation", "output_cost_per_video": 0.37}, + ) + with respx.mock(assert_all_called=True) as upstream: + upstream.post("https://queue.fal.run/fal-ai/test-flat-video").mock( + return_value=httpx.Response(200, json={"request_id": "req-flat", "status": "IN_QUEUE"}) + ) + response: Final = client.post("/fal_ai/fal-ai/test-flat-video", json={"prompt": "A lake"}) + assert response.status_code == 200, response.text + assert response.json() == {"request_id": "req-flat", "status": "IN_QUEUE"} + def test_status_get_forwards_to_queue_fal_run(self, client: TestClient) -> None: with respx.mock(assert_all_called=True) as upstream: route = upstream.get("https://queue.fal.run/fal-ai/trellis-2/requests/req-1/status").mock( diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index e6075488419..5c61b62b0da 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1183,6 +1183,7 @@ _COST_MAP_ROW_SCHEMA: Final = { "output_cost_per_token_above_512k_tokens": {"type": "number"}, "output_cost_per_token_batches": {"type": "number"}, "output_cost_per_reasoning_token": {"type": "number"}, + "output_cost_per_video": {"type": "number"}, "output_cost_per_video_per_second": {"type": "number"}, "output_db_cost_per_token": {"type": "number"}, "output_dbu_cost_per_token": {"type": "number"}, diff --git a/tests/unit/test_video_generation.py b/tests/unit/test_video_generation.py index 93243607b6f..0fc7dc467d6 100644 --- a/tests/unit/test_video_generation.py +++ b/tests/unit/test_video_generation.py @@ -2,6 +2,7 @@ import asyncio import io import json import os +from datetime import datetime from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -19,6 +20,8 @@ from litellm.llms.gemini.videos.transformation import GeminiVideoConfig from litellm.llms.openai.videos.transformation import OpenAIVideoConfig from litellm.proxy._types import UserAPIKeyAuth from litellm.types.videos.main import VideoObject, VideoResponse +from litellm.types.utils import ModelInfo +from litellm.llms.openai.cost_calculation import video_generation_cost from litellm.videos import main as videos_main from litellm.videos.main import ( avideo_generation, @@ -29,6 +32,48 @@ from litellm.videos.main import ( from tests.unit.proxy.router_entry_point_stubs import route_entry_points_to_stubs +@pytest.mark.parametrize("duration", [0.0, 5.0, 30.0]) +@pytest.mark.parametrize("price", [0.0, 0.37]) +def test_flat_video_price_takes_precedence_over_duration(duration: float, price: float) -> None: + pricing: Final[ModelInfo] = {"output_cost_per_video": price, "output_cost_per_video_per_second": 0.12} + assert video_generation_cost("test-flat-video", duration, model_info=pricing) == price + assert default_video_cost_calculator("test-flat-video", duration, model_info=pricing) == price + + +@pytest.mark.parametrize("duration", [None, 0.0, 12.0]) +@pytest.mark.parametrize("count", [1, 3]) +def test_completion_cost_flat_video_deployment_override_counts_videos( + monkeypatch: pytest.MonkeyPatch, duration: float | None, count: int +) -> None: + monkeypatch.setitem( + litellm.model_cost, + "openai/test-flat-video", + {"litellm_provider": "openai", "mode": "video_generation", "output_cost_per_video": 0.91}, + ) + response: Final = VideoObject( + id="vid-flat", object="video", status="completed", usage={"duration_seconds": duration, "video_count": count} + ) + logging_obj: Final = LitellmLogging( + model="openai/test-flat-video", + messages=[], + stream=False, + call_type="create_video", + start_time=datetime(2026, 1, 1), + litellm_call_id="flat-video", + function_id="flat-video", + ) + logging_obj.litellm_params = {"metadata": {"model_info": {"output_cost_per_video": 0.37}}} + cost: Final = litellm.completion_cost( + completion_response=response, + model="openai/test-flat-video", + call_type="create_video", + custom_llm_provider="openai", + custom_pricing=True, + litellm_logging_obj=logging_obj, + ) + assert cost == pytest.approx(0.37 * count) + + class TestVideoGeneration: """Test suite for video generation functionality."""