From 316f9ef905ad845eb20fb2ba5236456ecf7ec9ff Mon Sep 17 00:00:00 2001 From: kenneth-berri Date: Fri, 13 Nov 2026 12:55:55 -0800 Subject: [PATCH] feat(fal_ai): bill fal video clips by duration and resolution (#51679) * feat: add per-clip video billing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: preserve Pixverse resolution and explicit clip duration Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(fal): allow clip pricing with a null billable-unit rate 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 | 8 + litellm/llms/fal_ai/cost_calculator.py | 6 +- litellm/llms/fal_ai/videos/transformation.py | 16 +- litellm/llms/openai/cost_calculation.py | 27 ++++ ...odel_prices_and_context_window_backup.json | 5 + .../crates/model-catalog/src/model_info.rs | 1 + .../rust/crates/model-catalog/src/schema.rs | 1 + .../crates/model-catalog/src/wire/entry.rs | 15 ++ litellm/types/router.py | 1 + litellm/types/utils.py | 2 + .../src/lib/http/schema.d.ts | 12 ++ litellm/utils.py | 1 + model_prices_and_context_window.json | 5 + .../unit/llms/fal_ai/test_cost_calculator.py | 90 +++++++++++- .../test_fal_ai_video_transformation.py | 139 +++++++++++++++++- .../unit/llms/openai/test_cost_calculation.py | 75 +++++++++- tests/unit/test_utils.py | 10 ++ 17 files changed, 405 insertions(+), 9 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 927f37791fb..171b731fac8 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -101,6 +101,7 @@ from litellm.llms.openai.cost_calculation import ( cost_per_token as openai_cost_per_token, ) from litellm.llms.openai.cost_calculation import ( + video_clip_cost, video_output_cost_per_second, ) from litellm.llms.perplexity.cost_calculator import ( @@ -3152,6 +3153,9 @@ def default_image_cost_calculator( return cost +_VIDEO_COST_INFO: Final = TypeAdapter(Mapping[str, object]) + + def default_video_cost_calculator( model: str, duration_seconds: float, @@ -3214,6 +3218,10 @@ def default_video_cost_calculator( if cost_info is None: raise Exception(f"Model not found in cost map for model={model}") + clip_cost: Final = video_clip_cost(_VIDEO_COST_INFO.validate_python(cost_info), duration_seconds, video_resolution) + if clip_cost is not None: + return clip_cost + video_cost: Final = cost_info.get("output_cost_per_video") if video_cost is not None: return video_cost diff --git a/litellm/llms/fal_ai/cost_calculator.py b/litellm/llms/fal_ai/cost_calculator.py index d58cd80a6d8..b68e9234c09 100644 --- a/litellm/llms/fal_ai/cost_calculator.py +++ b/litellm/llms/fal_ai/cost_calculator.py @@ -9,6 +9,7 @@ from pydantic import TypeAdapter import litellm from litellm.litellm_core_utils.core_helpers import get_provider_response_headers_from_hidden_params from litellm.litellm_core_utils.llm_cost_calc.utils import deployment_pricing, resolve_image_model_info +from litellm.llms.openai.cost_calculation import video_clip_cost from litellm.types.utils import ImageObject, ImageResponse, ModelInfo FAL_KEYED_PRICING_DEFAULT_QUALITY: Final[str] = "high" @@ -187,8 +188,11 @@ def _passthrough_cost_per_image(entry: Mapping[str, object], request_body: Mappi def fal_ai_passthrough_cost(model: str, request_body: Mapping[str, object]) -> float | None: entry: Final = _entry(f"{litellm.LlmProviders.FAL_AI.value}/{model}") - if entry is None or "output_cost_per_billable_unit" in entry: + if entry is None or entry.get("output_cost_per_billable_unit") is not None: return None + clip_cost: Final = video_clip_cost(entry, request_body.get("duration"), request_body.get("resolution")) + if clip_cost is not None: + return clip_cost cost_per_video: Final = entry.get("output_cost_per_video") if isinstance(cost_per_video, (int, float)): return float(cost_per_video) diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index d6cd7f0d5ce..bea3de75f71 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -49,6 +49,7 @@ class _ModelProfile: integer_duration: bool reference_key: str reference_as_list: bool + default_duration: str | None = None _SEEDANCE_PROFILE: Final[_ModelProfile] = _ModelProfile( @@ -59,6 +60,15 @@ _SEEDANCE_PROFILE: Final[_ModelProfile] = _ModelProfile( reference_key="image_url", reference_as_list=False, ) +_PIXVERSE_PROFILE: Final[_ModelProfile] = _ModelProfile( + resolutions=frozenset({"360p", "540p", "720p", "1080p"}), + resolution_tiers=((360, "360p"), (540, "540p"), (720, "720p"), (sys.maxsize, "1080p")), + default_resolution="720p", + integer_duration=False, + reference_key="image_url", + reference_as_list=False, + default_duration="5", # https://fal.ai/models/pixverse/v6/text-to-video/api (2026-11-13) +) _H3_PROFILE: Final[_ModelProfile] = _ModelProfile( resolutions=frozenset({"480P", "768P", "2K", "4K"}), resolution_tiers=((480, "480P"), (768, "768P"), (1440, "2K"), (sys.maxsize, "4K")), @@ -122,6 +132,8 @@ def _duration_value(value: object) -> str | None: def _profile_for_model(model: str) -> _ModelProfile: + if model.removeprefix("fal_ai/").removeprefix("fal-ai/").startswith("pixverse/"): + return _PIXVERSE_PROFILE if model.startswith("minimax/h3/"): return _H3_PROFILE if model.startswith("minimax/h3-max"): @@ -356,8 +368,8 @@ class FalAIVideoConfig(BaseVideoConfig): ) duration_params: Final[Mapping[str, object]] = ( MappingProxyType({}) - if "seconds" not in video_create_optional_params - else self._duration_params(video_create_optional_params["seconds"], profile) + if "seconds" not in video_create_optional_params and profile.default_duration is None + else self._duration_params(video_create_optional_params.get("seconds", profile.default_duration), profile) ) size_params: Final[Mapping[str, str]] = ( _size_params(video_create_optional_params["size"], profile) diff --git a/litellm/llms/openai/cost_calculation.py b/litellm/llms/openai/cost_calculation.py index af03c31d429..10f23b02afb 100644 --- a/litellm/llms/openai/cost_calculation.py +++ b/litellm/llms/openai/cost_calculation.py @@ -6,6 +6,7 @@ Helper util for handling openai-specific cost calculation from collections.abc import Mapping from typing import Final, Literal, SupportsFloat, SupportsIndex +from pydantic import TypeAdapter, ValidationError from typing_extensions import Buffer from litellm._logging import verbose_logger @@ -136,6 +137,28 @@ def video_output_cost_per_second( _video_output_cost_per_second = video_output_cost_per_second +_VIDEO_CLIP_PRICES: Final = TypeAdapter(Mapping[str, Mapping[str, float]]) + + +def video_clip_cost(model_info: Mapping[str, object], duration: object, resolution: object) -> float | None: + if not isinstance(resolution, str): + return None + try: + prices: Final = _VIDEO_CLIP_PRICES.validate_python(model_info.get("output_cost_per_video_clip")) + except ValidationError: + return None + if isinstance(duration, bool) or not isinstance(duration, (str, int, float)): + return None + try: + duration_seconds: Final = float(duration) + if duration_seconds <= 0: + return None + requested_key: Final = f"{round(duration_seconds)}s" + except (ValueError, OverflowError): + return None + return prices.get(requested_key, {}).get(resolution.strip().lower()) + + def video_generation_cost( model: str, duration_seconds: float, @@ -162,6 +185,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") + clip_cost: Final = video_clip_cost(model_info, duration_seconds, video_resolution) + if clip_cost is not None: + return clip_cost + video_cost: Final = model_info.get("output_cost_per_video") if video_cost is not None: return video_cost diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 0ea945ba115..632ee7f627a 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -168,6 +168,11 @@ "max_output_tokens": 4096 }, "sample_spec": { + "output_cost_per_video_clip": { + "5s": { + "720p": 0.0 + } + }, "code_interpreter_cost_per_session": 0.0, "computer_use_input_cost_per_1k_tokens": 0.0, "computer_use_output_cost_per_1k_tokens": 0.0, diff --git a/litellm/rust/crates/model-catalog/src/model_info.rs b/litellm/rust/crates/model-catalog/src/model_info.rs index 1b71774095f..d0fd74e2cb7 100644 --- a/litellm/rust/crates/model-catalog/src/model_info.rs +++ b/litellm/rust/crates/model-catalog/src/model_info.rs @@ -115,6 +115,7 @@ pub struct ModelInfoFields { pub output_cost_per_token_above_512k_tokens: Option, pub output_cost_per_second: Option, pub output_cost_per_second_1080p: Option, + pub output_cost_per_video_clip: Option>>, pub output_cost_per_second_480p: Option, pub output_cost_per_second_720p: Option, pub output_cost_per_second_768p: Option, diff --git a/litellm/rust/crates/model-catalog/src/schema.rs b/litellm/rust/crates/model-catalog/src/schema.rs index 408863062b0..bd25b6bcd79 100644 --- a/litellm/rust/crates/model-catalog/src/schema.rs +++ b/litellm/rust/crates/model-catalog/src/schema.rs @@ -19,6 +19,7 @@ struct ModelEntry { #[derive(JsonSchema)] #[allow(dead_code)] struct PriceBlocks { + output_cost_per_video_clip: Option>>, /// USD cost per billable guardrail unit, keyed by the provider's usage counter name (e.g. Bedrock's contentPolicyUnits). guardrail_cost_per_unit: Option>, /// Rates that replace the same-named base fields while the request falls inside the stated UTC windows. diff --git a/litellm/rust/crates/model-catalog/src/wire/entry.rs b/litellm/rust/crates/model-catalog/src/wire/entry.rs index d0bdb394720..c8c704c2d1a 100644 --- a/litellm/rust/crates/model-catalog/src/wire/entry.rs +++ b/litellm/rust/crates/model-catalog/src/wire/entry.rs @@ -21,6 +21,7 @@ pub struct EntryWire { pub off_peak: Option, pub search_context: Option, pub guardrail: Option>, + pub output_cost_per_video_clip: Option>>, pub regional_endpoint_uplift: Option, pub residency_uplift_eu: Option, pub residency_uplift_us: Option, @@ -49,6 +50,9 @@ impl EntryWire { "off_peak_pricing" => self.off_peak = reader.off_peak(value), "search_context_cost_per_query" => self.search_context = reader.value(path(), value), "guardrail_cost_per_unit" => self.guardrail = reader.value(path(), value), + "output_cost_per_video_clip" => { + self.output_cost_per_video_clip = reader.value(path(), value) + } "regional_endpoint_uplift_multiplier" => { self.regional_endpoint_uplift = reader.value(path(), value) } @@ -108,6 +112,17 @@ mod tests { .collect() } + #[rstest] + #[case::clip_table(json!({"5s": {"720p": 0.15}}), false)] + #[case::empty(json!({}), false)] + #[case::invalid_price(json!({"5s": {"720p": "invalid"}}), true)] + fn video_clip_table_is_a_known_price_block(#[case] prices: Value, #[case] invalid: bool) { + let wire = read(json!({"output_cost_per_video_clip": prices})); + assert!(wire.unknown.is_empty()); + assert_eq!(!wire.problems.is_empty(), invalid); + assert_eq!(wire.output_cost_per_video_clip.is_some(), !invalid); + } + #[rstest] fn every_key_lands_in_exactly_one_place() { let wire = read(json!({ diff --git a/litellm/types/router.py b/litellm/types/router.py index 868e0022871..90431543b8c 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -877,6 +877,7 @@ class LiteLLMParamsTypedDict(TypedDict, total=False): output_cost_per_second_768p: ReadOnly[float | None] output_cost_per_second_2k: ReadOnly[float | None] output_cost_per_second_1080p: float | None + output_cost_per_video_clip: ReadOnly[Mapping[str, Mapping[str, float]] | None] output_cost_per_second_4k: ReadOnly[float | None] num_retries: int | None ## MOCK RESPONSES ## diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 035114a099c..708181dd87a 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -465,6 +465,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): 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_video_clip: ReadOnly[Mapping[str, Mapping[str, float]] | None] output_cost_per_audio_per_second: float | None # only for vertex ai models output_cost_per_second: float | None # for OpenAI Speech models output_cost_per_second_1080p: ( @@ -4169,6 +4170,7 @@ class CustomPricingLiteLLMParams(MirroredPricingParams): input_cost_per_text_message: float | None = None output_cost_per_second: float | None = None output_cost_per_second_1080p: float | None = None + output_cost_per_video_clip: Mapping[str, Mapping[str, float]] | None = None output_cost_per_second_480p: float | None = None output_cost_per_second_720p: float | None = None output_cost_per_second_768p: float | 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 26b57d62e7b..809a7f616fe 100644 --- a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -38132,6 +38132,12 @@ export interface components { output_cost_per_token_ultrafast?: number | null; /** Output Cost Per Video */ output_cost_per_video?: number | null; + /** Output Cost Per Video Clip */ + output_cost_per_video_clip?: { + [key: string]: { + [key: string]: number; + }; + } | null; /** Output Cost Per Video Per Second */ output_cost_per_video_per_second?: number | null; /** Output Cost Per Video Token */ @@ -54818,6 +54824,12 @@ export interface components { output_cost_per_token_ultrafast?: number | null; /** Output Cost Per Video */ output_cost_per_video?: number | null; + /** Output Cost Per Video Clip */ + output_cost_per_video_clip?: { + [key: string]: { + [key: string]: 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 4c4ce3bb559..b7610bcfaae 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7474,6 +7474,7 @@ def get_model_info_helper( ), output_cost_per_second=_model_info.get("output_cost_per_second", None), output_cost_per_second_1080p=_model_info.get("output_cost_per_second_1080p", None), + output_cost_per_video_clip=_model_info.get("output_cost_per_video_clip", None), output_cost_per_second_480p=_model_info.get("output_cost_per_second_480p", None), output_cost_per_second_720p=_model_info.get("output_cost_per_second_720p", None), output_cost_per_second_768p=_model_info.get("output_cost_per_second_768p", None), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 0ea945ba115..632ee7f627a 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -168,6 +168,11 @@ "max_output_tokens": 4096 }, "sample_spec": { + "output_cost_per_video_clip": { + "5s": { + "720p": 0.0 + } + }, "code_interpreter_cost_per_session": 0.0, "computer_use_input_cost_per_1k_tokens": 0.0, "computer_use_output_cost_per_1k_tokens": 0.0, diff --git a/tests/unit/llms/fal_ai/test_cost_calculator.py b/tests/unit/llms/fal_ai/test_cost_calculator.py index 276c84b35ee..ebda0719795 100644 --- a/tests/unit/llms/fal_ai/test_cost_calculator.py +++ b/tests/unit/llms/fal_ai/test_cost_calculator.py @@ -11,6 +11,86 @@ from litellm.llms.openai.cost_calculation import video_generation_cost from litellm.types.utils import ImageObject, ImageResponse +@pytest.mark.parametrize( + ("body", "expected"), + [ + ({"duration": "8", "resolution": "720p"}, 0.6), + ({"duration": 5, "resolution": "540p"}, 0.1), + ({"resolution": "720p"}, None), + ({"duration": 8}, None), + ({"duration": 8, "resolution": "1080p"}, None), + ({"duration": "invalid", "resolution": "720p"}, None), + ], +) +def test_passthrough_video_clip_pricing(body: dict[str, object], expected: float | None) -> None: + litellm.register_model( + { + "fal_ai/test-clip": { + "output_cost_per_video_clip": { + "8s": {"720p": 0.6}, + "5s": {"720p": 0.3, "540p": 0.1}, + } + } + }, + persist_across_reloads=False, + ) + assert fal_ai_passthrough_cost("test-clip", body) == expected + + +@pytest.mark.parametrize( + ("body", "expected"), + [ + ({"duration": "5", "resolution": "360p"}, 0.17), + ({"resolution": "360p"}, None), + ({"duration": "8", "resolution": "360p"}, None), + ], +) +def test_router_preserves_passthrough_clip_pricing_without_a_billable_unit_rate( + body: dict[str, object], expected: float | None +) -> None: + prices: Final = {"5s": {"360p": 0.17}} + litellm.register_model( + { + "fal_ai/fal-ai/router-clip": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video_clip": prices, + } + }, + persist_across_reloads=False, + ) + _router: Final = litellm.Router( + model_list=[ + { + "model_name": "configured-clip", + "litellm_params": {"model": "fal_ai/fal-ai/router-clip", "api_key": "test-key"}, + "model_info": {"mode": "video_generation", "output_cost_per_video_clip": prices}, + } + ], + num_retries=0, + ) + assert fal_ai_passthrough_cost("fal-ai/router-clip", body) == expected + + +@pytest.mark.parametrize( + ("fallback", "expected"), + [ + ({}, None), + ({"output_cost_per_video": 0.0}, 0.0), + ({"output_cost_per_video": 0.37}, 0.37), + ({"output_cost_per_image": 0.11}, 0.11), + ], +) +def test_passthrough_missing_duration_uses_existing_fallback( + fallback: dict[str, float], expected: float | None +) -> None: + litellm.register_model( + {"fal_ai/test-clip-fallback": {"output_cost_per_video_clip": {"5s": {"720p": 0.3}}, **fallback}}, + persist_across_reloads=False, + ) + assert fal_ai_passthrough_cost("test-clip-fallback", {"resolution": "720p"}) == expected + + @pytest.fixture(autouse=True) def _use_local_model_cost_map(monkeypatch): monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") @@ -37,13 +117,19 @@ def _image_response_with_dimensions(dimensions: tuple[tuple[int, int], ...]) -> @pytest.mark.parametrize("duration", [0.0, 8.0]) +@pytest.mark.parametrize("clip_prices", [None, {"5s": {"720p": 0.19}}]) def test_catalog_flat_video_price_reaches_both_video_calculators( - monkeypatch: pytest.MonkeyPatch, duration: float + monkeypatch: pytest.MonkeyPatch, duration: float, clip_prices: dict[str, dict[str, float]] | None ) -> 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}, + { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video": 0.37, + "output_cost_per_video_clip": clip_prices, + }, ) 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) 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 3498663775f..eaf353d2210 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 @@ -1,9 +1,11 @@ +import json import sys from typing import Final from unittest.mock import AsyncMock, Mock import httpx import pytest +import respx from litellm.exceptions import UnsupportedParamsError import litellm @@ -28,11 +30,138 @@ H3_MAX_TURBO_IMAGE_MODEL = "minimax/h3-max-turbo/image-to-video" H3_MAX_RECAST_MODEL = "minimax/h3-max/recast" +@pytest.mark.asyncio +@pytest.mark.parametrize("pricing_source", ["litellm_params", "model_info"]) +@pytest.mark.parametrize( + ("pricing", "expected_cost"), + [ + ({"output_cost_per_second": 0.017}, 0.085), + ({"output_cost_per_video_clip": {"5s": {"480p": 0.19}}}, 0.19), + ], +) +async def test_router_video_uses_configured_deployment_price( + monkeypatch: pytest.MonkeyPatch, pricing_source: str, pricing: dict[str, object], expected_cost: float +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "model_cost", {**litellm.model_cost}) + router: Final = litellm.Router( + num_retries=0, + model_list=[ + { + "model_name": "configured-video", + "litellm_params": { + "model": "fal_ai/fal-ai/longcat-video/distilled/text-to-video/480p", + "api_key": "synthetic-fal-key", + **(pricing if pricing_source == "litellm_params" else {}), + }, + "model_info": pricing if pricing_source == "model_info" else {}, + } + ], + ) + with respx.mock: + upstream: Final = respx.post("https://queue.fal.run/fal-ai/longcat-video/distilled/text-to-video/480p").mock( + return_value=httpx.Response(200, json={"request_id": "configured-priced-clip"}) + ) + video: Final = await router.avideo_generation(model="configured-video", prompt="clip", seconds="5", size="480p") + assert video._hidden_params["response_cost"] == pytest.approx(expected_cost) + assert upstream.call_count == 1 + + +@pytest.mark.parametrize("drop_params", [False, True]) +@pytest.mark.parametrize( + "model", + [ + "fal-ai/pixverse/v5/text-to-video", + "pixverse/v6/text-to-video", + "fal_ai/pixverse/v6/text-to-video", + "fal_ai/fal-ai/pixverse/v5/text-to-video", + ], +) +@pytest.mark.parametrize( + ("size", "resolution"), + [ + ("360p", "360p"), + ("540p", "540p"), + ("720p", "720p"), + ("1080p", "1080p"), + ("640x360", "360p"), + ("960x540", "540p"), + ("720x1280", "720p"), + ], +) +def test_pixverse_resolution_maps_to_request_and_billing( + size: str, resolution: str, drop_params: bool, model: str +) -> None: + config: Final = FalAIVideoConfig() + params: Final = config.map_openai_params({"seconds": "5", "size": size}, model, drop_params) + assert params["resolution"] == resolution + response: Final = config.transform_video_create_response( + model=model, + raw_response=httpx.Response( + 200, json={"request_id": "pixverse-clip"}, request=httpx.Request("POST", "https://queue.fal.run") + ), + logging_obj=Mock(), + request_data=params, + ) + assert response.usage is not None + assert response.usage["video_resolution"] == resolution + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model", ["fal-ai/pixverse/v5/text-to-video", "pixverse/v6/text-to-video"]) +@pytest.mark.parametrize( + ("seconds", "native_duration", "expected_duration", "expected_cost"), + [(None, None, "5", 0.19), ("8", None, "8", 0.31), (None, "8", "8", 0.31)], +) +async def test_pixverse_duration_reaches_request_usage_and_clip_billing( + monkeypatch: pytest.MonkeyPatch, + model: str, + seconds: str | None, + native_duration: str | None, + expected_duration: str, + expected_cost: float, +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "model_cost", {**litellm.model_cost}) + router: Final = litellm.Router( + model_list=[ + { + "model_name": "pixverse-clip", + "litellm_params": {"model": f"fal_ai/{model}", "api_key": "synthetic-fal-key"}, + "model_info": { + "output_cost_per_video_clip": { + "4s": {"540p": 0.04}, + "5s": {"540p": 0.19, "720p": 0.61}, + "8s": {"540p": 0.31}, + } + }, + } + ] + ) + with respx.mock: + upstream: Final = respx.post(f"https://queue.fal.run/{model}").respond( + 200, json={"request_id": "pixverse-duration"} + ) + video: Final = await router.avideo_generation( + model="pixverse-clip", + prompt="clip", + size="540p", + seconds=seconds, + extra_body={"duration": native_duration} if native_duration is not None else None, + ) + assert upstream.call_count == 1 + assert json.loads(upstream.calls.last.request.content)["duration"] == expected_duration + assert video.seconds == expected_duration + assert video.usage == {"duration_seconds": float(expected_duration), "video_resolution": "540p"} + assert video._hidden_params["response_cost"] == pytest.approx(expected_cost) + + @pytest.mark.parametrize("drop_params", [False, True]) def test_native_video_parameters_keep_precedence(drop_params: bool) -> None: params: Final = {"aspect_ratio": "1:1", "seed": 0, "duration": 6} assert FalAIVideoConfig().map_openai_params({"seconds": "5", "size": "1280x720", **params}, MODEL, drop_params) == { - "resolution": "720p", **params, + "resolution": "720p", + **params, } @@ -755,7 +884,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 + 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: @@ -796,4 +927,6 @@ def test_first_class_video_fields_are_rejected_or_dropped(field: str) -> None: config: Final = FalAIVideoConfig() with pytest.raises(UnsupportedParamsError, match=field): config.map_openai_params({field: 1}, "video-model", False) - assert config.map_openai_params({field: 1}, "video-model", True) == config.map_openai_params({}, "video-model", True) + assert config.map_openai_params({field: 1}, "video-model", True) == config.map_openai_params( + {}, "video-model", True + ) diff --git a/tests/unit/llms/openai/test_cost_calculation.py b/tests/unit/llms/openai/test_cost_calculation.py index 6c168e61dfc..768b5dc4ece 100644 --- a/tests/unit/llms/openai/test_cost_calculation.py +++ b/tests/unit/llms/openai/test_cost_calculation.py @@ -1,9 +1,82 @@ """Tests for per-second transcription cost calculation.""" +from collections.abc import Callable +from typing import Final + import pytest import litellm -from litellm.llms.openai.cost_calculation import cost_per_second +from litellm.cost_calculator import default_video_cost_calculator +from litellm.llms.openai.cost_calculation import cost_per_second, video_clip_cost, video_generation_cost +from litellm.types.utils import ModelInfo +from litellm.types.videos.main import VideoObject + + +@pytest.mark.parametrize("calculator", [video_generation_cost, default_video_cost_calculator]) +@pytest.mark.parametrize( + ("duration", "resolution", "expected"), + [ + (8.0, "720p", 0.6), + (5.0, "540p", 0.1), + (0.0, "720p", 0.0), + (7.7, "720p", 0.6), + (8.0, "1080p", 0.08), + (3.0, "720p", 0.03), + ], +) +def test_video_clip_price_precedes_per_second( + calculator: Callable[..., float], duration: float, resolution: str, expected: float +) -> None: + info: Final[ModelInfo] = { + "output_cost_per_video_clip": {"8s": {"720p": 0.6}, "5s": {"720p": 0.3, "540p": 0.1}}, + "output_cost_per_second": 0.01, + } + assert calculator("test-clip", duration, model_info=info, video_resolution=resolution) == pytest.approx(expected) + + +@pytest.mark.parametrize("duration", [5.0, None]) +@pytest.mark.parametrize("flat_price", [None, 0.0, 0.37]) +def test_completion_cost_multiplies_applicable_video_price( + duration: float | None, flat_price: float | None, local_model_cost_map: None +) -> None: + litellm.register_model( + { + "fal_ai/test-video-clip": { + "litellm_provider": "fal_ai", + "mode": "video_generation", + "output_cost_per_video_clip": {"5s": {"720p": 0.3}}, + "output_cost_per_video": flat_price, + } + }, + persist_across_reloads=False, + ) + response: Final = VideoObject( + id="clip", + object="video", + status="queued", + model="test-video-clip", + usage={ + "video_resolution": "720p", + "video_count": 3, + **({"duration_seconds": duration} if duration is not None else {}), + }, + ) + assert litellm.completion_cost( + completion_response=response, model="test-video-clip", custom_llm_provider="fal_ai", call_type="create_video" + ) == pytest.approx(0.9 if duration is not None else 3 * (flat_price or 0.0)) + + +@pytest.mark.parametrize( + "duration", [None, 0, 0.0, "0", "invalid", "auto", False, True, [], -1, float("nan"), float("inf")] +) +def test_video_clip_cost_does_not_guess_unknown_duration(duration: object) -> None: + info: Final = {"output_cost_per_video_clip": {"0s": {"720p": 0.01}, "5s": {"720p": 0.3}, "8s": {"720p": 0.6}}} + assert video_clip_cost(info, duration, "720p") is None + + +@pytest.mark.parametrize("table", [None, {}, {"5s": {"720p": "invalid"}}, {"invalid": {"720p": 0.3}}]) +def test_video_clip_cost_ignores_empty_or_invalid_catalog_table(table: object) -> None: + assert video_clip_cost({"output_cost_per_video_clip": table}, 5, "720p") is None def _register_stt(name: str, **pricing: float) -> None: diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 5c61b62b0da..8519f5c97ce 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1169,6 +1169,16 @@ _COST_MAP_ROW_SCHEMA: Final = { "output_cost_per_second_768p": {"type": "number"}, "output_cost_per_second_2k": {"type": "number"}, "output_cost_per_second_1080p": {"type": "number"}, + "output_cost_per_video_clip": { + "type": "object", + "patternProperties": { + "^[1-9][0-9]*s$": { + "type": "object", + "additionalProperties": {"type": "number", "exclusiveMinimum": 0}, + } + }, + "additionalProperties": False, + }, "output_cost_per_second_4k": {"type": "number"}, "output_cost_per_token": {"type": "number"}, "output_cost_per_token_above_32k_tokens": {"type": "number"},