diff --git a/litellm/llms/fal_ai/image_generation/__init__.py b/litellm/llms/fal_ai/image_generation/__init__.py index 9deeb403c4..7f3358934a 100644 --- a/litellm/llms/fal_ai/image_generation/__init__.py +++ b/litellm/llms/fal_ai/image_generation/__init__.py @@ -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() diff --git a/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py b/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py new file mode 100644 index 0000000000..dd4758055a --- /dev/null +++ b/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py @@ -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} diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bccbce8ef1..397f96fdb1 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 2a39a09971..b2836a096b 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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, diff --git a/tests/image_gen_tests/test_fal_ai_image_generation.py b/tests/image_gen_tests/test_fal_ai_image_generation.py index 105ae499c9..23032e44de 100644 --- a/tests/image_gen_tests/test_fal_ai_image_generation.py +++ b/tests/image_gen_tests/test_fal_ai_image_generation.py @@ -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 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 new file mode 100644 index 0000000000..593593bfa7 --- /dev/null +++ b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_nano_banana_transformation.py @@ -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)