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>
This commit is contained in:
devin-ai-integration[bot] 2026-11-12 08:55:08 -08:00 • committed by GitHub
parent 00bd56760d
commit b60df93c46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 280 additions and 13 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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