feat(fal_ai): add Nano Banana / Gemini 2.5 Flash Image generation support (#29798)

* feat(fal_ai): add Nano Banana / Gemini 2.5 Flash Image generation support

Adds a FalAINanoBananaConfig for fal.ai's Nano Banana models, exposed under
both fal-ai/nano-banana and fal-ai/gemini-25-flash-image (identical schema).
This is the migration path for fal-ai/imagen4, which fal deprecates on
2026-06-30.

The config derives the request endpoint from the model name so both aliases
route correctly, maps OpenAI image params to the fal schema (n -> num_images,
size -> nearest supported aspect_ratio, response_format ignored since the model
returns URLs), and reuses the base fal response parser. Pricing is registered
at 0.039 per image in the cost map and backup.

* fix(fal_ai): tighten nano-banana routing and guard mapped params

Match the specific gemini-25-flash-image / gemini-2.5-flash-image
aliases instead of any model containing gemini so future fal.ai
Gemini-branded models aren't silently misrouted to the nano-banana
config. Guard the param mapping on the fal-side keys (num_images,
aspect_ratio) so a pre-set mapped value is respected and an OpenAI
key is never forwarded unmapped.

* fix(fal_ai): drop non-existent gemini-2.5-flash-image routing alias

fal.ai only serves the dotted-free fal-ai/gemini-25-flash-image and
fal-ai/nano-banana endpoints. Routing the dotted gemini-2.5-flash-image
alias built a https://fal.run/fal-ai/gemini-2.5-flash-image URL that
fal.ai 404s and had no pricing entry, so spend tracking silently fell to
zero. Match only the two real endpoint slugs.
This commit is contained in:
Mateo Wang 2026-06-06 11:16:44 -07:00 committed by GitHub
parent 21d2c3aa83
commit 51769a8ede
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 313 additions and 1 deletions

View File

@ -7,6 +7,7 @@ from .flux_pro_v11_transformation import FalAIFluxProV11Config
from .flux_pro_v11_ultra_transformation import FalAIFluxProV11UltraConfig
from .flux_schnell_transformation import FalAIFluxSchnellConfig
from .imagen4_transformation import FalAIImagen4Config
from .nano_banana_transformation import FalAINanoBananaConfig
from .recraft_v3_transformation import FalAIRecraftV3Config
from .ideogram_v3_transformation import FalAIIdeogramV3Config
from .stable_diffusion_transformation import FalAIStableDiffusionConfig
@ -20,6 +21,7 @@ __all__ = [
"FalAIBaseConfig",
"FalAIImageGenerationConfig",
"FalAIImagen4Config",
"FalAINanoBananaConfig",
"FalAIRecraftV3Config",
"FalAIBriaConfig",
"FalAIFluxProV11Config",
@ -45,7 +47,9 @@ def get_fal_ai_image_generation_config(model: str) -> BaseImageGenerationConfig:
model_lower = model.lower()
# Map model names to their corresponding configuration classes
if "imagen4" in model_lower or "imagen-4" in model_lower:
if "nano-banana" in model_lower or "gemini-25-flash-image" in model_lower:
return FalAINanoBananaConfig()
elif "imagen4" in model_lower or "imagen-4" in model_lower:
return FalAIImagen4Config()
elif "recraft" in model_lower:
return FalAIRecraftV3Config()

View File

@ -0,0 +1,105 @@
from typing import List, Optional
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import OpenAIImageGenerationOptionalParams
from .transformation import FalAIBaseConfig
class FalAINanoBananaConfig(FalAIBaseConfig):
"""
Configuration for Fal AI's Nano Banana / Gemini 2.5 Flash Image models.
Serves the imagen4 deprecation migration path. The same underlying model is
exposed under two endpoints that share an identical schema:
- fal-ai/nano-banana
- fal-ai/gemini-25-flash-image
Documentation: https://fal.ai/models/fal-ai/nano-banana
"""
SUPPORTED_ASPECT_RATIOS: List[str] = [
"21:9",
"16:9",
"3:2",
"4:3",
"5:4",
"1:1",
"4:5",
"3:4",
"2:3",
"9:16",
]
def get_complete_url(
self,
api_base: Optional[str],
api_key: Optional[str],
model: str,
optional_params: dict,
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
base_url: str = (
api_base or get_secret_str("FAL_AI_API_BASE") or self.DEFAULT_BASE_URL
).rstrip("/")
endpoint = model if model.startswith("fal-ai/") else f"fal-ai/{model}"
return f"{base_url}/{endpoint}"
def get_supported_openai_params(
self, model: str
) -> List[OpenAIImageGenerationOptionalParams]:
return ["n", "response_format", "size"]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
supported_params = self.get_supported_openai_params(model)
for key, value in non_default_params.items():
if key == "response_format":
continue
elif key == "n":
if "num_images" not in optional_params:
optional_params["num_images"] = value
elif key == "size":
if "aspect_ratio" not in optional_params:
optional_params["aspect_ratio"] = self._map_aspect_ratio(value)
elif key not in optional_params and not drop_params:
raise ValueError(
f"Parameter {key} is not supported for model {model}. "
f"Supported parameters are {supported_params}. "
"Set drop_params=True to drop unsupported parameters."
)
return optional_params
def _map_aspect_ratio(self, size: str) -> str:
if not isinstance(size, str) or "x" not in size:
return "1:1"
try:
width, height = (int(part) for part in size.split("x"))
target = width / height
except (ValueError, ZeroDivisionError):
return "1:1"
def ratio_of(aspect_ratio: str) -> float:
w, h = (int(part) for part in aspect_ratio.split(":"))
return w / h
return min(
self.SUPPORTED_ASPECT_RATIOS,
key=lambda aspect_ratio: abs(ratio_of(aspect_ratio) - target),
)
def transform_image_generation_request(
self,
model: str,
prompt: str,
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
return {"prompt": prompt, **optional_params}

View File

@ -14014,6 +14014,22 @@
"/v1/images/generations"
]
},
"fal_ai/fal-ai/nano-banana": {
"litellm_provider": "fal_ai",
"mode": "image_generation",
"output_cost_per_image": 0.039,
"supported_endpoints": [
"/v1/images/generations"
]
},
"fal_ai/fal-ai/gemini-25-flash-image": {
"litellm_provider": "fal_ai",
"mode": "image_generation",
"output_cost_per_image": 0.039,
"supported_endpoints": [
"/v1/images/generations"
]
},
"featherless_ai/featherless-ai/Qwerky-72B": {
"litellm_provider": "featherless_ai",
"max_input_tokens": 32768,

View File

@ -14014,6 +14014,22 @@
"/v1/images/generations"
]
},
"fal_ai/fal-ai/nano-banana": {
"litellm_provider": "fal_ai",
"mode": "image_generation",
"output_cost_per_image": 0.039,
"supported_endpoints": [
"/v1/images/generations"
]
},
"fal_ai/fal-ai/gemini-25-flash-image": {
"litellm_provider": "fal_ai",
"mode": "image_generation",
"output_cost_per_image": 0.039,
"supported_endpoints": [
"/v1/images/generations"
]
},
"featherless_ai/featherless-ai/Qwerky-72B": {
"litellm_provider": "featherless_ai",
"max_input_tokens": 32768,

View File

@ -19,6 +19,11 @@ from litellm import aimage_generation
"fal_ai/fal-ai/stable-diffusion-v35-medium",
"fal-ai/stable-diffusion-v35-medium",
),
("fal_ai/fal-ai/nano-banana", "fal-ai/nano-banana"),
(
"fal_ai/fal-ai/gemini-25-flash-image",
"fal-ai/gemini-25-flash-image",
),
],
)
@pytest.mark.asyncio

View File

@ -0,0 +1,166 @@
import os
import sys
import pytest
sys.path.insert(0, os.path.abspath("../../../../.."))
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
import litellm
litellm.model_cost = litellm.get_model_cost_map(url="")
from litellm.llms.fal_ai.cost_calculator import cost_calculator
from litellm.llms.fal_ai.image_generation import (
FalAIImagen4Config,
FalAINanoBananaConfig,
get_fal_ai_image_generation_config,
)
from litellm.types.utils import ImageObject, ImageResponse
@pytest.mark.parametrize(
"model",
[
"fal-ai/nano-banana",
"nano-banana",
"fal-ai/gemini-25-flash-image",
],
)
def test_nano_banana_config_selected(model):
assert isinstance(get_fal_ai_image_generation_config(model), FalAINanoBananaConfig)
def test_imagen4_still_routes_to_imagen4_config():
assert isinstance(
get_fal_ai_image_generation_config("fal-ai/imagen4/preview"),
FalAIImagen4Config,
)
@pytest.mark.parametrize(
"model,expected_url",
[
("fal-ai/nano-banana", "https://fal.run/fal-ai/nano-banana"),
(
"fal-ai/gemini-25-flash-image",
"https://fal.run/fal-ai/gemini-25-flash-image",
),
("nano-banana", "https://fal.run/fal-ai/nano-banana"),
],
)
def test_get_complete_url_derives_endpoint_from_model(model, expected_url):
url = FalAINanoBananaConfig().get_complete_url(
api_base=None,
api_key="test-key",
model=model,
optional_params={},
litellm_params={},
)
assert url == expected_url
def test_get_complete_url_respects_api_base_override():
url = FalAINanoBananaConfig().get_complete_url(
api_base="https://proxy.internal/",
api_key="test-key",
model="fal-ai/nano-banana",
optional_params={},
litellm_params={},
)
assert url == "https://proxy.internal/fal-ai/nano-banana"
def test_map_n_to_num_images():
optional_params = FalAINanoBananaConfig().map_openai_params(
non_default_params={"n": 3},
optional_params={},
model="fal-ai/nano-banana",
drop_params=False,
)
assert optional_params == {"num_images": 3}
@pytest.mark.parametrize(
"size,expected_aspect_ratio",
[
("1024x1024", "1:1"),
("512x512", "1:1"),
("1792x1024", "16:9"),
("1024x1792", "9:16"),
("1024x768", "4:3"),
("768x1024", "3:4"),
],
)
def test_map_size_to_aspect_ratio(size, expected_aspect_ratio):
optional_params = FalAINanoBananaConfig().map_openai_params(
non_default_params={"size": size},
optional_params={},
model="fal-ai/nano-banana",
drop_params=False,
)
assert optional_params == {"aspect_ratio": expected_aspect_ratio}
def test_response_format_is_ignored():
optional_params = FalAINanoBananaConfig().map_openai_params(
non_default_params={"response_format": "b64_json"},
optional_params={},
model="fal-ai/nano-banana",
drop_params=False,
)
assert optional_params == {}
def test_unsupported_param_raises_without_drop_params():
with pytest.raises(ValueError):
FalAINanoBananaConfig().map_openai_params(
non_default_params={"style": "vivid"},
optional_params={},
model="fal-ai/nano-banana",
drop_params=False,
)
def test_unsupported_param_dropped_with_drop_params():
optional_params = FalAINanoBananaConfig().map_openai_params(
non_default_params={"style": "vivid"},
optional_params={},
model="fal-ai/nano-banana",
drop_params=True,
)
assert optional_params == {}
def test_transform_request_includes_prompt_and_mapped_params():
request = FalAINanoBananaConfig().transform_image_generation_request(
model="fal-ai/nano-banana",
prompt="a cat",
optional_params={"num_images": 2, "aspect_ratio": "16:9"},
litellm_params={},
headers={},
)
assert request == {
"prompt": "a cat",
"num_images": 2,
"aspect_ratio": "16:9",
}
@pytest.mark.parametrize(
"model", ["fal-ai/nano-banana", "fal-ai/gemini-25-flash-image"]
)
def test_nano_banana_pricing_registered(model):
info = litellm.get_model_info(
model=model, custom_llm_provider=litellm.LlmProviders.FAL_AI.value
)
assert info["output_cost_per_image"] == 0.039
assert info["mode"] == "image_generation"
def test_cost_calculator_scales_with_image_count():
image_response = ImageResponse(
data=[ImageObject(url="https://x/1.png"), ImageObject(url="https://x/2.png")]
)
cost = cost_calculator(model="fal-ai/nano-banana", image_response=image_response)
assert cost == pytest.approx(0.078)