From b60df93c4657f93a3aa9baa23bb86b576d356093 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:55:08 -0800 Subject: [PATCH] feat(fal_ai): add Nano Banana 2.1, bill it from fal billable units, fix google/ routing (#51410) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fal_ai/cost_calculator.py | 37 +++++- .../nano_banana_transformation.py | 7 +- litellm/llms/fal_ai/videos/transformation.py | 10 +- ...odel_prices_and_context_window_backup.json | 40 +++++++ litellm/types/utils.py | 2 + .../src/lib/http/schema.d.ts | 4 + litellm/utils.py | 1 + model_prices_and_context_window.json | 40 +++++++ .../test_fal_ai_nano_banana_transformation.py | 5 + .../unit/llms/fal_ai/test_cost_calculator.py | 109 +++++++++++++++++- .../test_fal_ai_video_transformation.py | 13 +++ .../test_llm_pass_through_endpoints.py | 23 ++++ tests/unit/test_utils.py | 2 + 13 files changed, 280 insertions(+), 13 deletions(-) diff --git a/litellm/llms/fal_ai/cost_calculator.py b/litellm/llms/fal_ai/cost_calculator.py index 2acf1c69594..d2ad461a42f 100644 --- a/litellm/llms/fal_ai/cost_calculator.py +++ b/litellm/llms/fal_ai/cost_calculator.py @@ -1,12 +1,13 @@ import os from collections.abc import Mapping -from math import ceil +from math import ceil, isfinite from types import MappingProxyType from typing import Final 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.types.utils import ImageObject, ImageResponse, ModelInfo @@ -28,6 +29,7 @@ FAL_NAMED_IMAGE_SIZES: Final[Mapping[str, str]] = MappingProxyType( _OBJECT_MAP: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object]) FAL_AI_QUEUE_DEFAULT_BASE: Final[str] = "https://queue.fal.run" +FAL_BILLABLE_UNITS_HEADER: Final[str] = "x-fal-billable-units" def fal_ai_queue_base() -> str: @@ -150,6 +152,26 @@ def _resolution_cost_per_image(entry: Mapping[str, object] | None, resolution: o return float(cost) if isinstance(cost, (int, float)) else None +def _billable_units(response: object) -> float | None: + headers: Final = get_provider_response_headers_from_hidden_params(response) + raw_units: Final = None if headers is None else headers.get(FAL_BILLABLE_UNITS_HEADER) + if raw_units is None: + return None + try: + units: Final = float(raw_units) + except ValueError: + return None + return units if isfinite(units) and units >= 0 else None + + +def _billable_units_cost(prices: Mapping[str, object] | None, response: object) -> float | None: + unit_cost: Final = None if prices is None else prices.get("output_cost_per_billable_unit") + units: Final = _billable_units(response) + if isinstance(unit_cost, bool) or not isinstance(unit_cost, (int, float)) or units is None: + return None + return unit_cost * units + + def _requested_image_count(request_body: Mapping[str, object]) -> int: num_images: Final = request_body.get("num_images") return num_images if type(num_images) is int and num_images > 0 else 1 @@ -165,7 +187,7 @@ 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: + if entry is None or "output_cost_per_billable_unit" in entry: return None cost_per_image: Final = _passthrough_cost_per_image(entry, request_body) if cost_per_image is None: @@ -187,15 +209,20 @@ def cost_calculator( normalized_model: Final = model.removeprefix(f"{litellm.LlmProviders.FAL_AI.value}/") images: Final = tuple(image_response.data or ()) deployment_prices: Final = deployment_pricing(model_info) + deployment_billable_units_cost: Final = _billable_units_cost(deployment_prices, image_response) + if deployment_billable_units_cost is not None: + return deployment_billable_units_cost deployment_cost_per_image: Final = ( None if deployment_prices is None else deployment_prices.get("output_cost_per_image") ) if deployment_cost_per_image is not None: return deployment_cost_per_image * len(images) + entry: Final = _entry(f"{litellm.LlmProviders.FAL_AI.value}/{normalized_model}") + billable_units_cost: Final = _billable_units_cost(entry, image_response) + if billable_units_cost is not None: + return billable_units_cost params: Final[Mapping[str, object]] = optional_params or MappingProxyType({}) - resolution_cost_per_image: Final = _resolution_cost_per_image( - _entry(f"{litellm.LlmProviders.FAL_AI.value}/{normalized_model}"), params.get("resolution") - ) + resolution_cost_per_image: Final = _resolution_cost_per_image(entry, params.get("resolution")) if resolution_cost_per_image is not None: return resolution_cost_per_image * len(images) keyed_costs: Final = tuple( diff --git a/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py b/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py index 35df7a72fe0..f848b86246f 100644 --- a/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py +++ b/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py @@ -5,6 +5,8 @@ from litellm.types.llms.openai import OpenAIImageGenerationOptionalParams from .transformation import FalAIBaseConfig +_BARE_APP_MARKERS: Final = ("nano-banana", "gemini-25-flash-image") + class FalAINanoBananaConfig(FalAIBaseConfig): """ @@ -16,6 +18,7 @@ class FalAINanoBananaConfig(FalAIBaseConfig): - fal-ai/gemini-25-flash-image - fal-ai/nano-banana-2 - fal-ai/nano-banana-pro + - google/nano-banana-2.1 Provider-specific params such as ``resolution`` ("0.5K", "1K", "2K", "4K") are forwarded as-is and drive the per-resolution price in the cost map. @@ -46,7 +49,9 @@ class FalAINanoBananaConfig(FalAIBaseConfig): stream: bool | None = None, ) -> str: base_url: Final[str] = (api_base or get_secret_str("FAL_AI_API_BASE") or self.DEFAULT_BASE_URL).rstrip("/") - endpoint: Final = model if model.startswith("fal-ai/") else f"fal-ai/{model}" + app: Final = model.partition("/")[0].lower() + is_bare_app: Final = any(marker in app for marker in _BARE_APP_MARKERS) + endpoint: Final = f"fal-ai/{model}" if is_bare_app else model return f"{base_url}/{endpoint}" def get_supported_openai_params(self, model: str) -> list[OpenAIImageGenerationOptionalParams]: diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index 9974f72c025..838e7065ef7 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -2,7 +2,7 @@ import math import sys import time from collections.abc import Callable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from types import MappingProxyType from typing import Final, TypeAlias @@ -75,6 +75,11 @@ _H3_MAX_PROFILE: Final[_ModelProfile] = _ModelProfile( reference_key="image_url", reference_as_list=False, ) +_SEEDANCE_REFERENCE_PROFILE: Final[_ModelProfile] = replace( + _SEEDANCE_PROFILE, + reference_key="image_urls", + reference_as_list=True, +) _QUEUE_NAMESPACES: Final[frozenset[str]] = frozenset(("workflows", "comfy")) _STATUS_MAP: Final[Mapping[str, str]] = MappingProxyType( { @@ -113,7 +118,8 @@ def _profile_for_model(model: str) -> _ModelProfile: return _H3_PROFILE if model.startswith("minimax/h3-max"): return _H3_MAX_PROFILE - return _SEEDANCE_PROFILE + is_seedance_reference: Final = model.startswith("bytedance/seedance-") and model.endswith("/reference-to-video") + return _SEEDANCE_REFERENCE_PROFILE if is_seedance_reference else _SEEDANCE_PROFILE def _resolution_for_short_side(short_side: int, profile: _ModelProfile) -> str: diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bd13d3be6df..d437241cc29 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -26484,6 +26484,46 @@ "/v1/images/generations" ] }, + "fal_ai/google/nano-banana-2.1": { + "litellm_provider": "fal_ai", + "metadata": { + "comment": "fal bills this endpoint by tokens and reports the charge in the x-fal-billable-units response header, priced at output_cost_per_billable_unit dollars per unit. The per-image prices are fal's representative costs by the request's resolution field (1K default, 2K, 4K) for a short prompt, used when the header is absent" + }, + "mode": "image_generation", + "output_cost_per_billable_unit": 1.0, + "output_cost_per_image": 0.04, + "output_cost_per_image_1K": 0.04, + "output_cost_per_image_2K": 0.059, + "output_cost_per_image_4K": 0.134, + "source": "https://fal.ai/models/google/nano-banana-2.1", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/google/nano-banana-2.1/edit": { + "litellm_provider": "fal_ai", + "metadata": { + "comment": "fal bills this endpoint by tokens and reports the charge in the x-fal-billable-units response header, priced at output_cost_per_billable_unit dollars per unit. The per-image prices are fal's representative costs by the request's resolution field (1K default, 2K, 4K) for a short prompt and two 1K input images, used when the header is absent" + }, + "mode": "image_generation", + "output_cost_per_billable_unit": 1.0, + "output_cost_per_image": 0.044, + "output_cost_per_image_1K": 0.044, + "output_cost_per_image_2K": 0.063, + "output_cost_per_image_4K": 0.138, + "source": "https://fal.ai/models/google/nano-banana-2.1/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/google/nano-banana-latest": { + "alias_of": "fal_ai/google/nano-banana-2.1" + }, + "fal_ai/google/nano-banana-latest/edit": { + "alias_of": "fal_ai/google/nano-banana-2.1/edit" + }, "fal_ai/openai/gpt-image-2": { "litellm_provider": "fal_ai", "metadata": { diff --git a/litellm/types/utils.py b/litellm/types/utils.py index beac372d882..56133a3f3e1 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -479,6 +479,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): output_cost_per_image_1536: ReadOnly[float | None] output_cost_per_image_1K: ReadOnly[float | None] output_cost_per_image_2K: ReadOnly[float | None] + output_cost_per_billable_unit: ReadOnly[float | None] ocr_cost_per_page: float | None # for OCR models ocr_cost_per_page_batches: ReadOnly[float | None] ocr_cost_per_credit: float | None # for OCR models priced by credit @@ -4163,6 +4164,7 @@ class CustomPricingLiteLLMParams(MirroredPricingParams): output_cost_per_image_1536: float | None = None output_cost_per_image_1K: float | None = None output_cost_per_image_2K: float | None = None + output_cost_per_billable_unit: float | None = None input_cost_per_pixel: float | None = None output_cost_per_pixel: 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 26bec687f86..4b20a49a627 100644 --- a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -37220,6 +37220,8 @@ export interface components { output_cost_per_audio_per_second?: number | null; /** Output Cost Per Audio Token */ output_cost_per_audio_token?: number | null; + /** Output Cost Per Billable Unit */ + output_cost_per_billable_unit?: number | null; /** Output Cost Per Character */ output_cost_per_character?: number | null; /** Output Cost Per Character Above 128K Tokens */ @@ -53508,6 +53510,8 @@ export interface components { output_cost_per_audio_per_second?: number | null; /** Output Cost Per Audio Token */ output_cost_per_audio_token?: number | null; + /** Output Cost Per Billable Unit */ + output_cost_per_billable_unit?: number | null; /** Output Cost Per Character */ output_cost_per_character?: number | null; /** Output Cost Per Character Above 128K Tokens */ diff --git a/litellm/utils.py b/litellm/utils.py index c0eec2d55fb..71a29ee84b1 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7489,6 +7489,7 @@ def get_model_info_helper( 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), output_cost_per_image_2K=_model_info.get("output_cost_per_image_2K", None), + output_cost_per_billable_unit=_model_info.get("output_cost_per_billable_unit", None), output_cost_per_pixel=_model_info.get("output_cost_per_pixel", None), output_cost_per_image_token=_model_info.get("output_cost_per_image_token", None), output_cost_per_image_token_batches=_model_info.get("output_cost_per_image_token_batches", None), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index bd13d3be6df..d437241cc29 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -26484,6 +26484,46 @@ "/v1/images/generations" ] }, + "fal_ai/google/nano-banana-2.1": { + "litellm_provider": "fal_ai", + "metadata": { + "comment": "fal bills this endpoint by tokens and reports the charge in the x-fal-billable-units response header, priced at output_cost_per_billable_unit dollars per unit. The per-image prices are fal's representative costs by the request's resolution field (1K default, 2K, 4K) for a short prompt, used when the header is absent" + }, + "mode": "image_generation", + "output_cost_per_billable_unit": 1.0, + "output_cost_per_image": 0.04, + "output_cost_per_image_1K": 0.04, + "output_cost_per_image_2K": 0.059, + "output_cost_per_image_4K": 0.134, + "source": "https://fal.ai/models/google/nano-banana-2.1", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/google/nano-banana-2.1/edit": { + "litellm_provider": "fal_ai", + "metadata": { + "comment": "fal bills this endpoint by tokens and reports the charge in the x-fal-billable-units response header, priced at output_cost_per_billable_unit dollars per unit. The per-image prices are fal's representative costs by the request's resolution field (1K default, 2K, 4K) for a short prompt and two 1K input images, used when the header is absent" + }, + "mode": "image_generation", + "output_cost_per_billable_unit": 1.0, + "output_cost_per_image": 0.044, + "output_cost_per_image_1K": 0.044, + "output_cost_per_image_2K": 0.063, + "output_cost_per_image_4K": 0.138, + "source": "https://fal.ai/models/google/nano-banana-2.1/edit", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/google/nano-banana-latest": { + "alias_of": "fal_ai/google/nano-banana-2.1" + }, + "fal_ai/google/nano-banana-latest/edit": { + "alias_of": "fal_ai/google/nano-banana-2.1/edit" + }, "fal_ai/openai/gpt-image-2": { "litellm_provider": "fal_ai", "metadata": { diff --git a/tests/unit/llms/fal_ai/image_generation/test_fal_ai_nano_banana_transformation.py b/tests/unit/llms/fal_ai/image_generation/test_fal_ai_nano_banana_transformation.py index 6014e8a514e..2f340cdfaaf 100644 --- a/tests/unit/llms/fal_ai/image_generation/test_fal_ai_nano_banana_transformation.py +++ b/tests/unit/llms/fal_ai/image_generation/test_fal_ai_nano_banana_transformation.py @@ -26,6 +26,8 @@ from litellm.utils import get_optional_params_image_gen "fal-ai/gemini-25-flash-image", "fal-ai/nano-banana-2", "fal-ai/nano-banana-pro", + "google/nano-banana-2.1", + "google/nano-banana-2.1/edit", ], ) def test_nano_banana_config_selected(model): @@ -48,6 +50,9 @@ def test_imagen4_still_routes_to_imagen4_config(): "https://fal.run/fal-ai/gemini-25-flash-image", ), ("nano-banana", "https://fal.run/fal-ai/nano-banana"), + ("nano-banana-2/edit", "https://fal.run/fal-ai/nano-banana-2/edit"), + ("google/nano-banana-2.1", "https://fal.run/google/nano-banana-2.1"), + ("google/nano-banana-2.1/edit", "https://fal.run/google/nano-banana-2.1/edit"), ], ) def test_get_complete_url_derives_endpoint_from_model(model, expected_url): diff --git a/tests/unit/llms/fal_ai/test_cost_calculator.py b/tests/unit/llms/fal_ai/test_cost_calculator.py index 35eec247f7e..eaaf37b1d51 100644 --- a/tests/unit/llms/fal_ai/test_cost_calculator.py +++ b/tests/unit/llms/fal_ai/test_cost_calculator.py @@ -3,8 +3,9 @@ from typing import Final import pytest import litellm +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 cost_calculator, fal_ai_passthrough_cost +from litellm.llms.fal_ai.cost_calculator import FAL_BILLABLE_UNITS_HEADER, cost_calculator, fal_ai_passthrough_cost from litellm.types.utils import ImageObject, ImageResponse @@ -98,9 +99,9 @@ def test_gpt_image_25_noncanonical_response_uses_nearest_keyed_row(): image_response=_image_response_with_dimensions(((1536, 1024),)), optional_params={"quality": "low", "image_size": {"width": 1536, "height": 1024}}, ) - expected: Final = litellm.model_cost[ - "fal_ai/low/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image" - ]["output_cost_per_image"] + expected: Final = litellm.model_cost["fal_ai/low/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image"][ + "output_cost_per_image" + ] assert cost == expected @@ -304,7 +305,9 @@ def test_passthrough_num_images_multiplies_the_per_image_rate(model): entry: Final = litellm.model_cost[f"fal_ai/{model}"] assert fal_ai_passthrough_cost(model, {"num_images": 3}) == 3 * entry["output_cost_per_image"] > 0 assert ( - fal_ai_passthrough_cost(model, {"resolution": "4K", "num_images": 2}) == 2 * entry["output_cost_per_image_4K"] > 0 + fal_ai_passthrough_cost(model, {"resolution": "4K", "num_images": 2}) + == 2 * entry["output_cost_per_image_4K"] + > 0 ) @@ -316,3 +319,99 @@ def test_passthrough_without_a_positive_integer_num_images_charges_one_image(num == litellm.model_cost["fal_ai/fal-ai/nano-banana-2"]["output_cost_per_image"] > 0 ) + + +BILLABLE_UNIT_MODELS: Final = ("google/nano-banana-2.1", "google/nano-banana-2.1/edit") + + +def _image_response_with_headers(headers: dict[str, str], num_images: int = 1) -> ImageResponse: + response: Final = _image_response(num_images=num_images) + set_provider_response_headers_in_hidden_params(response, headers) + return response + + +@pytest.mark.parametrize("model", BILLABLE_UNIT_MODELS) +def test_reported_billable_units_set_the_cost_over_the_per_image_price(model: str) -> None: + unit_price: Final = litellm.model_cost[f"fal_ai/{model}"]["output_cost_per_billable_unit"] + cost: Final = CostCalculatorUtils.route_image_generation_cost_calculator( + model=model, + custom_llm_provider="fal_ai", + completion_response=_image_response_with_headers({FAL_BILLABLE_UNITS_HEADER: "0.062173746"}, num_images=2), + optional_params={"resolution": "4K", "num_images": 2}, + ) + assert cost == pytest.approx(0.062173746 * unit_price) + + +@pytest.mark.parametrize("headers", [{}, {FAL_BILLABLE_UNITS_HEADER: "nan"}, {FAL_BILLABLE_UNITS_HEADER: "-1"}]) +@pytest.mark.parametrize("model", BILLABLE_UNIT_MODELS) +def test_unusable_billable_units_fall_back_to_the_resolution_price(model: str, headers: dict[str, str]) -> None: + entry: Final = litellm.model_cost[f"fal_ai/{model}"] + cost: Final = cost_calculator( + model=f"fal_ai/{model}", + image_response=_image_response_with_headers(headers, num_images=2), + optional_params={"resolution": "2K", "num_images": 2}, + ) + assert cost == 2 * entry["output_cost_per_image_2K"] > 0 + + +def test_billable_units_are_ignored_without_a_unit_price() -> None: + entry: Final = litellm.model_cost["fal_ai/fal-ai/nano-banana-2"] + assert "output_cost_per_billable_unit" not in entry + cost: Final = cost_calculator( + model="fal_ai/fal-ai/nano-banana-2", + image_response=_image_response_with_headers({FAL_BILLABLE_UNITS_HEADER: "7"}), + optional_params={}, + ) + assert cost == entry["output_cost_per_image"] + + +def test_billable_unit_price_scales_the_reported_units(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem( + litellm.model_cost, + "fal_ai/fal-ai/megapixel-billed-model", + {"litellm_provider": "fal_ai", "mode": "image_generation", "output_cost_per_billable_unit": 0.003}, + ) + cost: Final = cost_calculator( + model="fal_ai/fal-ai/megapixel-billed-model", + image_response=_image_response_with_headers({FAL_BILLABLE_UNITS_HEADER: "2"}, num_images=2), + ) + assert cost == pytest.approx(0.006) + + +def test_deployment_price_overrides_reported_billable_units() -> None: + cost: Final = cost_calculator( + model="fal_ai/google/nano-banana-2.1", + image_response=_image_response_with_headers({FAL_BILLABLE_UNITS_HEADER: "0.5"}, num_images=2), + model_info={"output_cost_per_image": 0.01}, + ) + assert cost == pytest.approx(0.02) + + +@pytest.mark.parametrize( + ("deployment_prices", "headers", "expected"), + [ + ({"output_cost_per_billable_unit": 2.0}, {FAL_BILLABLE_UNITS_HEADER: "0.5"}, 1.0), + ( + {"output_cost_per_billable_unit": 2.0, "output_cost_per_image": 0.01}, + {FAL_BILLABLE_UNITS_HEADER: "0.5"}, + 1.0, + ), + ({"output_cost_per_billable_unit": 2.0, "output_cost_per_image": 0.01}, {}, 0.02), + ], +) +def test_deployment_unit_price_replaces_the_catalog_unit_price( + deployment_prices: dict[str, float], headers: dict[str, str], expected: float +) -> None: + cost: Final = cost_calculator( + model="fal_ai/google/nano-banana-2.1", + image_response=_image_response_with_headers(headers, num_images=2), + model_info=deployment_prices, + ) + assert cost == pytest.approx(expected) + + +@pytest.mark.parametrize("model", BILLABLE_UNIT_MODELS) +@pytest.mark.parametrize("body", ({}, {"resolution": "4K", "num_images": 2})) +def test_passthrough_does_not_price_a_submission_billed_by_reported_units(model: str, body: dict[str, object]) -> None: + assert litellm.model_cost[f"fal_ai/{model}"]["output_cost_per_image"] > 0 + assert fal_ai_passthrough_cost(model, body) is None 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 8f7c794b157..44cc9add32d 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 @@ -117,6 +117,19 @@ class TestFalAIVideoTransformation: assert "reference_image_urls" not in mapped assert mapped["duration"] == "5" + @pytest.mark.parametrize( + ("model", "expected"), + [ + ("bytedance/seedance-2.5/reference-to-video", {"image_urls": ["https://example.com/image.png"]}), + ("bytedance/seedance-2.0/fast/reference-to-video", {"image_urls": ["https://example.com/image.png"]}), + ("bytedance/seedance-2.5/image-to-video", {"image_url": "https://example.com/image.png"}), + ], + ) + def test_map_openai_params_seedance_input_reference_follows_task(self, model: str, expected: dict[str, object]): + mapped = self.config.map_openai_params({"input_reference": "https://example.com/image.png"}, model, False) + + assert mapped == expected + def test_map_openai_params_h3_omits_auto_duration(self): assert self.config.map_openai_params({"seconds": "auto"}, H3_TEXT_MODEL, False) == {} assert self.config.map_openai_params({"seconds": "auto"}, MODEL, False) == {"duration": "auto"} 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 ca04787e592..e7556c06dfd 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 @@ -10735,6 +10735,29 @@ class TestFalAIPassthroughRoute: assert "no pricing entry" in response.text assert not route.calls + def test_submit_to_endpoint_billed_by_reported_units_returns_400_without_upstream_call( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setitem( + litellm.model_cost, + "fal_ai/acme/unit-billed-model", + { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_billable_unit": 1.0, + "output_cost_per_image": 0.04, + }, + ) + with respx.mock(assert_all_called=False) as upstream: + route = upstream.post("https://queue.fal.run/acme/unit-billed-model").mock( + return_value=httpx.Response(200, json={"request_id": "req-1"}) + ) + response = client.post("/fal_ai/acme/unit-billed-model", json={"prompt": "a red boat"}) + + assert response.status_code == 400, response.text + assert "no pricing entry" in response.text + assert not route.calls + def test_submit_gate_prices_the_request_body_not_an_empty_one( self, client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index b5a5c609dae..e6075488419 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -862,6 +862,7 @@ def validate_model_cost_values(model_data, exceptions=None): "output_cost_per_image_1K", "output_cost_per_image_2K", "output_cost_per_image_4K", + "output_cost_per_billable_unit", "input_cost_per_pixel", "output_cost_per_pixel", "cost_per_second", @@ -1156,6 +1157,7 @@ _COST_MAP_ROW_SCHEMA: Final = { "output_cost_per_image_1K": {"type": "number"}, "output_cost_per_image_2K": {"type": "number"}, "output_cost_per_image_4K": {"type": "number"}, + "output_cost_per_billable_unit": {"type": "number"}, "output_cost_per_image_token": {"type": "number"}, "output_cost_per_image_token_batches": {"type": "number"}, "output_cost_per_image_token_priority": {"type": "number"},