From cbba14682a4433f9ecf4859f2b512b1980585e0f Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:17:46 -0700 Subject: [PATCH] fix(fal_ai): price nano-banana-2 and nano-banana-pro image generations by resolution (#43101) * fix(fal_ai): price nano-banana-2 and nano-banana-pro image generations by resolution * fix(fal_ai): bill passthrough submits per requested image and register the resolution price keys --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/llms/fal_ai/cost_calculator.py | 34 ++++++++- .../nano_banana_transformation.py | 11 ++- ...odel_prices_and_context_window_backup.json | 31 ++++++++ model_prices_and_context_window.json | 31 ++++++++ model_prices_and_context_window.schema.json | 16 ++++ .../test_fal_ai_nano_banana_transformation.py | 21 ++++++ .../llms/fal_ai/test_cost_calculator.py | 74 +++++++++++++++++++ tests/test_litellm/test_utils.py | 8 ++ 8 files changed, 219 insertions(+), 7 deletions(-) diff --git a/litellm/llms/fal_ai/cost_calculator.py b/litellm/llms/fal_ai/cost_calculator.py index 519a11de13c..aa3d7942174 100644 --- a/litellm/llms/fal_ai/cost_calculator.py +++ b/litellm/llms/fal_ai/cost_calculator.py @@ -142,14 +142,35 @@ def _resolution_key(resolution: object) -> str | None: return str(resolution) +def _resolution_cost_per_image(entry: Mapping[str, object] | None, resolution: object) -> float | None: + resolution_key: Final = _resolution_key(resolution) + if entry is None or resolution_key is None: + return None + cost: Final = entry.get(f"output_cost_per_image_{resolution_key}") + return float(cost) if isinstance(cost, (int, float)) else None + + +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 + + +def _passthrough_cost_per_image(entry: Mapping[str, object], request_body: Mapping[str, object]) -> float | None: + resolution_cost: Final = _resolution_cost_per_image(entry, request_body.get("resolution")) + if resolution_cost is not None: + return resolution_cost + cost: Final = entry.get("output_cost_per_image") + return float(cost) if isinstance(cost, (int, float)) else None + + 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: return None - resolution: Final = _resolution_key(request_body.get("resolution")) - keyed_cost: Final = entry.get(f"output_cost_per_image_{resolution}") if resolution is not None else None - cost: Final = keyed_cost if isinstance(keyed_cost, (int, float)) else entry.get("output_cost_per_image") - return float(cost) if isinstance(cost, (int, float)) else None + cost_per_image: Final = _passthrough_cost_per_image(entry, request_body) + if cost_per_image is None: + return None + return cost_per_image * _requested_image_count(request_body) def cost_calculator( @@ -172,6 +193,11 @@ def cost_calculator( if deployment_cost_per_image is not None: return deployment_cost_per_image * len(images) 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") + ) + if resolution_cost_per_image is not None: + return resolution_cost_per_image * len(images) keyed_costs: Final = tuple( _keyed_cost_per_image( model=normalized_model, 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 bb104a0793f..35df7a72fe0 100644 --- a/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py +++ b/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py @@ -8,12 +8,17 @@ from .transformation import FalAIBaseConfig class FalAINanoBananaConfig(FalAIBaseConfig): """ - Configuration for Fal AI's Nano Banana / Gemini 2.5 Flash Image models. + Configuration for Fal AI's Nano Banana family (Gemini Flash / Pro Image models). - Serves the imagen4 deprecation migration path. The same underlying model is - exposed under two endpoints that share an identical schema: + Serves the imagen4 deprecation migration path. Every endpoint shares the same + request schema, so one config covers all of them: - fal-ai/nano-banana - fal-ai/gemini-25-flash-image + - fal-ai/nano-banana-2 + - fal-ai/nano-banana-pro + + 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. Documentation: https://fal.ai/models/fal-ai/nano-banana """ diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index dc4921b5b06..f3904f387be 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -22852,6 +22852,37 @@ "/v1/images/generations" ] }, + "fal_ai/fal-ai/nano-banana-2": { + "litellm_provider": "fal_ai", + "metadata": { + "comment": "priced by the request's resolution field (0.5K, 1K default, 2K, 4K); the web search and high thinking surcharges are not modeled" + }, + "mode": "image_generation", + "output_cost_per_image": 0.08, + "output_cost_per_image_0.5K": 0.06, + "output_cost_per_image_1K": 0.08, + "output_cost_per_image_2K": 0.12, + "output_cost_per_image_4K": 0.16, + "source": "https://fal.ai/models/fal-ai/nano-banana-2", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/nano-banana-pro": { + "litellm_provider": "fal_ai", + "metadata": { + "comment": "priced by the request's resolution field (1K default, 2K, 4K); the web search surcharge is not modeled" + }, + "mode": "image_generation", + "output_cost_per_image": 0.15, + "output_cost_per_image_1K": 0.15, + "output_cost_per_image_2K": 0.15, + "output_cost_per_image_4K": 0.3, + "source": "https://fal.ai/models/fal-ai/nano-banana-pro", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, "fal_ai/openai/gpt-image-2": { "litellm_provider": "fal_ai", "metadata": { diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index dc4921b5b06..f3904f387be 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -22852,6 +22852,37 @@ "/v1/images/generations" ] }, + "fal_ai/fal-ai/nano-banana-2": { + "litellm_provider": "fal_ai", + "metadata": { + "comment": "priced by the request's resolution field (0.5K, 1K default, 2K, 4K); the web search and high thinking surcharges are not modeled" + }, + "mode": "image_generation", + "output_cost_per_image": 0.08, + "output_cost_per_image_0.5K": 0.06, + "output_cost_per_image_1K": 0.08, + "output_cost_per_image_2K": 0.12, + "output_cost_per_image_4K": 0.16, + "source": "https://fal.ai/models/fal-ai/nano-banana-2", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/nano-banana-pro": { + "litellm_provider": "fal_ai", + "metadata": { + "comment": "priced by the request's resolution field (1K default, 2K, 4K); the web search surcharge is not modeled" + }, + "mode": "image_generation", + "output_cost_per_image": 0.15, + "output_cost_per_image_1K": 0.15, + "output_cost_per_image_2K": 0.15, + "output_cost_per_image_4K": 0.3, + "source": "https://fal.ai/models/fal-ai/nano-banana-pro", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, "fal_ai/openai/gpt-image-2": { "litellm_provider": "fal_ai", "metadata": { diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index 395b2db1137..35624045fdf 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -624,6 +624,10 @@ "type": "number", "minimum": 0 }, + "output_cost_per_image_0.5K": { + "type": "number", + "minimum": 0 + }, "output_cost_per_image_1024": { "type": "number", "minimum": 0 @@ -632,6 +636,18 @@ "type": "number", "minimum": 0 }, + "output_cost_per_image_1K": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_2K": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_4K": { + "type": "number", + "minimum": 0 + }, "output_cost_per_image_512": { "type": "number", "minimum": 0 diff --git a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_nano_banana_transformation.py b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_nano_banana_transformation.py index ac7cd24766d..6014e8a514e 100644 --- a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_nano_banana_transformation.py +++ b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_nano_banana_transformation.py @@ -15,6 +15,7 @@ from litellm.llms.fal_ai.image_generation import ( get_fal_ai_image_generation_config, ) from litellm.types.utils import ImageObject, ImageResponse +from litellm.utils import get_optional_params_image_gen @pytest.mark.parametrize( @@ -23,6 +24,8 @@ from litellm.types.utils import ImageObject, ImageResponse "fal-ai/nano-banana", "nano-banana", "fal-ai/gemini-25-flash-image", + "fal-ai/nano-banana-2", + "fal-ai/nano-banana-pro", ], ) def test_nano_banana_config_selected(model): @@ -145,3 +148,21 @@ def test_transform_request_includes_prompt_and_mapped_params(): } +@pytest.mark.parametrize("model", ["fal-ai/nano-banana-2", "fal-ai/nano-banana-pro"]) +def test_resolution_extra_param_is_forwarded_to_fal(model): + optional_params = get_optional_params_image_gen( + model=model, + n=1, + size="1024x1024", + custom_llm_provider="fal_ai", + provider_config=FalAINanoBananaConfig(), + resolution="4K", + ) + request = FalAINanoBananaConfig().transform_image_generation_request( + model=model, + prompt="a cat", + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + assert request == {"prompt": "a cat", "num_images": 1, "aspect_ratio": "1:1", "resolution": "4K"} diff --git a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py index 3c6e6aea090..35eec247f7e 100644 --- a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py +++ b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py @@ -242,3 +242,77 @@ def test_passthrough_cost_is_none_only_when_no_price_applies_to_the_request(monk assert fal_ai_passthrough_cost("fal-ai/keyed-only-model", {}) is None assert fal_ai_passthrough_cost("fal-ai/keyed-only-model", {"resolution": 1024}) is None assert fal_ai_passthrough_cost("fal-ai/keyed-only-model", {"resolution": "512"}) == 0.02 + + +NANO_BANANA_RESOLUTION_MODELS: Final = ("fal-ai/nano-banana-2", "fal-ai/nano-banana-pro") + + +@pytest.mark.parametrize("model", NANO_BANANA_RESOLUTION_MODELS) +def test_nano_banana_default_request_charges_the_1k_rate_per_image(model): + entry: Final = litellm.model_cost[f"fal_ai/{model}"] + cost: Final = cost_calculator( + model=f"fal_ai/{model}", + image_response=_image_response(num_images=2), + optional_params={"num_images": 2, "aspect_ratio": "1:1"}, + ) + assert cost == 2 * entry["output_cost_per_image"] == 2 * entry["output_cost_per_image_1K"] > 0 + + +@pytest.mark.parametrize("model", NANO_BANANA_RESOLUTION_MODELS) +def test_nano_banana_4k_request_charges_the_4k_rate_above_1k(model): + one_k: Final = cost_calculator( + model=f"fal_ai/{model}", image_response=_image_response(), optional_params={"resolution": "1K"} + ) + four_k: Final = cost_calculator( + model=f"fal_ai/{model}", image_response=_image_response(), optional_params={"resolution": "4K"} + ) + assert four_k == litellm.model_cost[f"fal_ai/{model}"]["output_cost_per_image_4K"] + assert four_k > one_k > 0 + + +@pytest.mark.parametrize("model", NANO_BANANA_RESOLUTION_MODELS) +@pytest.mark.parametrize("resolution", ("1K", "2K", "4K")) +def test_nano_banana_images_generations_and_passthrough_charge_the_same_tier(model, resolution): + images_generations_cost: Final = cost_calculator( + model=f"fal_ai/{model}", image_response=_image_response(), optional_params={"resolution": resolution} + ) + assert images_generations_cost == fal_ai_passthrough_cost(model, {"resolution": resolution}) > 0 + + +def test_nano_banana_2_resolution_tiers_are_monotonic(): + costs: Final = tuple( + cost_calculator( + model="fal_ai/fal-ai/nano-banana-2", + image_response=_image_response(), + optional_params={"resolution": resolution}, + ) + for resolution in ("0.5K", "1K", "2K", "4K") + ) + assert costs == tuple(sorted(costs)) and len(set(costs)) == len(costs) + + +@pytest.mark.parametrize("model", NANO_BANANA_RESOLUTION_MODELS) +def test_nano_banana_unpriced_resolution_falls_back_to_the_default_rate(model): + cost: Final = cost_calculator( + model=f"fal_ai/{model}", image_response=_image_response(), optional_params={"resolution": "8K"} + ) + assert cost == litellm.model_cost[f"fal_ai/{model}"]["output_cost_per_image"] > 0 + + +@pytest.mark.parametrize("model", NANO_BANANA_RESOLUTION_MODELS) +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 + ) + + +@pytest.mark.parametrize("num_images", (None, 0, -2, True, 2.0, "2")) +def test_passthrough_without_a_positive_integer_num_images_charges_one_image(num_images): + body: Final = {} if num_images is None else {"num_images": num_images} + assert ( + fal_ai_passthrough_cost("fal-ai/nano-banana-2", body) + == litellm.model_cost["fal_ai/fal-ai/nano-banana-2"]["output_cost_per_image"] + > 0 + ) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 5dc09db4535..285188c9c09 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -642,6 +642,10 @@ def validate_model_cost_values(model_data, exceptions=None): "output_cost_per_image_512", "output_cost_per_image_1024", "output_cost_per_image_1536", + "output_cost_per_image_0.5K", + "output_cost_per_image_1K", + "output_cost_per_image_2K", + "output_cost_per_image_4K", "input_cost_per_pixel", "output_cost_per_pixel", "input_cost_per_second", @@ -875,6 +879,10 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "output_cost_per_image_512": {"type": "number"}, "output_cost_per_image_1024": {"type": "number"}, "output_cost_per_image_1536": {"type": "number"}, + "output_cost_per_image_0.5K": {"type": "number"}, + "output_cost_per_image_1K": {"type": "number"}, + "output_cost_per_image_2K": {"type": "number"}, + "output_cost_per_image_4K": {"type": "number"}, "output_cost_per_image_token": {"type": "number"}, "output_cost_per_video_token": {"type": "number"}, "output_cost_per_pixel": {"type": "number"},