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>
This commit is contained in:
kenneth-berri 2026-11-13 12:55:55 -08:00 • committed by GitHub
parent 9939e62b62
commit 316f9ef905
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 405 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -115,6 +115,7 @@ pub struct ModelInfoFields {
pub output_cost_per_token_above_512k_tokens: Option<f64>,
pub output_cost_per_second: Option<f64>,
pub output_cost_per_second_1080p: Option<f64>,
pub output_cost_per_video_clip: Option<IndexMap<String, IndexMap<String, f64>>>,
pub output_cost_per_second_480p: Option<f64>,
pub output_cost_per_second_720p: Option<f64>,
pub output_cost_per_second_768p: Option<f64>,

View file

@ -19,6 +19,7 @@ struct ModelEntry {
#[derive(JsonSchema)]
#[allow(dead_code)]
struct PriceBlocks {
output_cost_per_video_clip: Option<BTreeMap<String, BTreeMap<String, UnitPrice>>>,
/// USD cost per billable guardrail unit, keyed by the provider's usage counter name (e.g. Bedrock's contentPolicyUnits).
guardrail_cost_per_unit: Option<BTreeMap<String, UnitPrice>>,
/// Rates that replace the same-named base fields while the request falls inside the stated UTC windows.

View file

@ -21,6 +21,7 @@ pub struct EntryWire {
pub off_peak: Option<OffPeakBlock>,
pub search_context: Option<SearchContextCostPerQuery>,
pub guardrail: Option<BTreeMap<String, UnitPrice>>,
pub output_cost_per_video_clip: Option<BTreeMap<String, BTreeMap<String, UnitPrice>>>,
pub regional_endpoint_uplift: Option<Multiplier>,
pub residency_uplift_eu: Option<Multiplier>,
pub residency_uplift_us: Option<Multiplier>,
@ -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!({

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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