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:
parent
21d2c3aa83
commit
51769a8ede
@ -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()
|
||||
|
||||
@ -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}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user