mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
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:
parent
9939e62b62
commit
316f9ef905
17 changed files with 405 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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 ##
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
12
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue