From 60706d5f8970cee5bc6f120dd3600b4cc14f5617 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:18:34 +0000 Subject: [PATCH 1/4] feat(fal_ai): add gpt-image-2 image generation support Route fal.ai's openai/gpt-image-2 endpoints through a dedicated transformation that maps OpenAI image params (n, size, quality, output_format) into fal's schema, and register the model in the cost map. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/fal_ai/image_generation/__init__.py | 6 +- .../gpt_image_2_transformation.py | 124 +++++++++++++++ ...odel_prices_and_context_window_backup.json | 13 ++ model_prices_and_context_window.json | 13 ++ .../test_fal_ai_gpt_image_2_transformation.py | 146 ++++++++++++++++++ 5 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py create mode 100644 tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py diff --git a/litellm/llms/fal_ai/image_generation/__init__.py b/litellm/llms/fal_ai/image_generation/__init__.py index fb38855b35e..2b305c8f234 100644 --- a/litellm/llms/fal_ai/image_generation/__init__.py +++ b/litellm/llms/fal_ai/image_generation/__init__.py @@ -12,6 +12,7 @@ from .bytedance_transformation import ( from .flux_pro_v11_transformation import FalAIFluxProV11Config from .flux_pro_v11_ultra_transformation import FalAIFluxProV11UltraConfig from .flux_schnell_transformation import FalAIFluxSchnellConfig +from .gpt_image_2_transformation import FalAIGPTImage2Config from .ideogram_v3_transformation import FalAIIdeogramV3Config from .imagen4_transformation import FalAIImagen4Config from .nano_banana_transformation import FalAINanoBananaConfig @@ -27,6 +28,7 @@ __all__ = [ "FalAIFluxProV11Config", "FalAIFluxProV11UltraConfig", "FalAIFluxSchnellConfig", + "FalAIGPTImage2Config", "FalAIIdeogramV3Config", "FalAIImageGenerationConfig", "FalAIImagen4Config", @@ -49,7 +51,9 @@ def get_fal_ai_image_generation_config(model: str) -> BaseImageGenerationConfig: model_lower: Final = model.lower() # Map model names to their corresponding configuration classes - if "nano-banana" in model_lower or "gemini-25-flash-image" in model_lower: + if "gpt-image-2" in model_lower: + return FalAIGPTImage2Config() + elif "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() diff --git a/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py b/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py new file mode 100644 index 00000000000..b91ae8ce2b0 --- /dev/null +++ b/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py @@ -0,0 +1,124 @@ +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final + +from typing_extensions import ReadOnly, TypedDict + +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import OpenAIImageGenerationOptionalParams + +from .transformation import FalAIBaseConfig + + +class FalAIImageSize(TypedDict): + width: ReadOnly[int] + height: ReadOnly[int] + + +SUPPORTED_OPENAI_PARAMS: Final[tuple[OpenAIImageGenerationOptionalParams, ...]] = ( + "n", + "output_format", + "quality", + "response_format", + "size", +) + + +class FalAIGPTImage2Config(FalAIBaseConfig): + """ + Configuration for OpenAI's GPT Image 2 served through Fal AI. + + Model endpoints: + - openai/gpt-image-2 (text-to-image) + - openai/gpt-image-2/edit (editing, with optional mask) + + Documentation: https://fal.ai/models/openai/gpt-image-2/api + """ + + MODEL_PREFIX: Final[str] = "openai/" + SUPPORTED_QUALITIES: Final[frozenset[str]] = frozenset({"auto", "low", "medium", "high"}) + OPENAI_QUALITY_ALIASES: Final[Mapping[str, str]] = MappingProxyType({"hd": "high", "standard": "medium"}) + PARAM_TRANSLATION: Final[Mapping[str, str]] = MappingProxyType( + { + "n": "num_images", + "size": "image_size", + "quality": "quality", + "output_format": "output_format", + } + ) + + def get_complete_url( + self, + api_base: str | None, + api_key: str | None, + model: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + 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[str] = model if model.startswith(self.MODEL_PREFIX) else f"{self.MODEL_PREFIX}{model}" + return f"{base_url}/{endpoint}" + + def get_supported_openai_params( # mutable-ok: base class contract returns a list + self, model: str + ) -> list[OpenAIImageGenerationOptionalParams]: + return list(SUPPORTED_OPENAI_PARAMS) # mutable-ok: base class contract returns a list + + def map_openai_params( # mutable-ok: base class contract returns a dict + self, + non_default_params: Mapping[str, object], + optional_params: Mapping[str, object], + model: str, + drop_params: bool, + ) -> dict: + unsupported_params: Final = tuple( + key for key in non_default_params if key not in SUPPORTED_OPENAI_PARAMS and key not in optional_params + ) + if unsupported_params and not drop_params: + raise ValueError( + f"Parameters {unsupported_params} are not supported for model {model}. " + f"Supported parameters are {SUPPORTED_OPENAI_PARAMS}. " + "Set drop_params=True to drop unsupported parameters." + ) + translated_params: Final[Mapping[str, object]] = MappingProxyType( + { + self.PARAM_TRANSLATION[key]: self._translate_value(key, value) + for key, value in non_default_params.items() + if key in self.PARAM_TRANSLATION and self.PARAM_TRANSLATION[key] not in optional_params + } + ) + return {**optional_params, **translated_params} # mutable-ok: base class contract returns a dict + + def _translate_value(self, key: str, value: object) -> object: + if key == "size": + return self._map_image_size(value) + if key == "quality": + return self._map_quality(value) + return value + + def _map_image_size(self, size: object) -> object: + if not isinstance(size, str) or size == "auto": + return size + try: + width, height = (int(part) for part in size.lower().split("x")) + except ValueError: + return size + image_size: Final[FalAIImageSize] = {"width": width, "height": height} + return image_size + + def _map_quality(self, quality: object) -> object: + if not isinstance(quality, str): + return quality + normalized: Final[str] = self.OPENAI_QUALITY_ALIASES.get(quality, quality) + return normalized if normalized in self.SUPPORTED_QUALITIES else "auto" + + def transform_image_generation_request( # mutable-ok: base class contract returns a dict + self, + model: str, + prompt: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + headers: Mapping[str, str], + ) -> dict: + return {"prompt": prompt, **optional_params} # mutable-ok: base class contract returns a dict diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 858eab672e5..20d47bdb839 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -17397,6 +17397,19 @@ "/v1/images/generations" ] }, + "fal_ai/openai/gpt-image-2": { + "litellm_provider": "fal_ai", + "metadata": { + "notes": "OpenAI gpt-image-2 served through fal.ai. fal bills by token, so the flat output_cost_per_image here is fal's published per-image rate for a default request (quality=high, image_size=landscape_4_3 at 1024x768). Other canonical sizes at high quality: 1024x1024 $0.211, 1024x1536 $0.165, 1920x1080 $0.158, 2560x1440 $0.222, 3840x2160 $0.401" + }, + "mode": "image_generation", + "output_cost_per_image": 0.145, + "source": "https://fal.ai/models/openai/gpt-image-2", + "supported_endpoints": [ + "/v1/images/generations" + ], + "supports_vision": true + }, "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 858eab672e5..20d47bdb839 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -17397,6 +17397,19 @@ "/v1/images/generations" ] }, + "fal_ai/openai/gpt-image-2": { + "litellm_provider": "fal_ai", + "metadata": { + "notes": "OpenAI gpt-image-2 served through fal.ai. fal bills by token, so the flat output_cost_per_image here is fal's published per-image rate for a default request (quality=high, image_size=landscape_4_3 at 1024x768). Other canonical sizes at high quality: 1024x1024 $0.211, 1024x1536 $0.165, 1920x1080 $0.158, 2560x1440 $0.222, 3840x2160 $0.401" + }, + "mode": "image_generation", + "output_cost_per_image": 0.145, + "source": "https://fal.ai/models/openai/gpt-image-2", + "supported_endpoints": [ + "/v1/images/generations" + ], + "supports_vision": true + }, "featherless_ai/featherless-ai/Qwerky-72B": { "litellm_provider": "featherless_ai", "max_input_tokens": 32768, diff --git a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py new file mode 100644 index 00000000000..513e16dc4b6 --- /dev/null +++ b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py @@ -0,0 +1,146 @@ +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 ( + FalAIGPTImage2Config, + FalAINanoBananaConfig, + get_fal_ai_image_generation_config, +) +from litellm.types.utils import ImageObject, ImageResponse + + +@pytest.mark.parametrize( + "model", + [ + "openai/gpt-image-2", + "gpt-image-2", + "openai/gpt-image-2/edit", + ], +) +def test_gpt_image_2_config_selected(model): + assert isinstance(get_fal_ai_image_generation_config(model), FalAIGPTImage2Config) + + +def test_nano_banana_still_routes_to_nano_banana_config(): + assert isinstance( + get_fal_ai_image_generation_config("fal-ai/nano-banana"), + FalAINanoBananaConfig, + ) + + +@pytest.mark.parametrize( + "model,expected_url", + [ + ("openai/gpt-image-2", "https://fal.run/openai/gpt-image-2"), + ("gpt-image-2", "https://fal.run/openai/gpt-image-2"), + ("openai/gpt-image-2/edit", "https://fal.run/openai/gpt-image-2/edit"), + ], +) +def test_get_complete_url_derives_endpoint_from_model(model, expected_url): + url = FalAIGPTImage2Config().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 = FalAIGPTImage2Config().get_complete_url( + api_base="https://proxy.internal/", + api_key="test-key", + model="openai/gpt-image-2", + optional_params={}, + litellm_params={}, + ) + assert url == "https://proxy.internal/openai/gpt-image-2" + + +@pytest.mark.parametrize( + "non_default_params,expected", + [ + ({"n": 3}, {"num_images": 3}), + ({"size": "1024x1536"}, {"image_size": {"width": 1024, "height": 1536}}), + ({"size": "auto"}, {"image_size": "auto"}), + ({"quality": "medium"}, {"quality": "medium"}), + ({"quality": "hd"}, {"quality": "high"}), + ({"quality": "standard"}, {"quality": "medium"}), + ({"quality": "nonsense"}, {"quality": "auto"}), + ({"output_format": "webp"}, {"output_format": "webp"}), + ({"response_format": "url"}, {}), + ], +) +def test_map_openai_params(non_default_params, expected): + assert ( + FalAIGPTImage2Config().map_openai_params( + non_default_params=non_default_params, + optional_params={}, + model="openai/gpt-image-2", + drop_params=False, + ) + == expected + ) + + +def test_map_openai_params_keeps_explicit_provider_params(): + mapped = FalAIGPTImage2Config().map_openai_params( + non_default_params={"n": 4, "size": "1024x1024"}, + optional_params={"num_images": 1, "image_size": "square_hd"}, + model="openai/gpt-image-2", + drop_params=False, + ) + assert mapped == {"num_images": 1, "image_size": "square_hd"} + + +def test_map_openai_params_raises_on_unsupported_param(): + with pytest.raises(ValueError, match="style"): + FalAIGPTImage2Config().map_openai_params( + non_default_params={"style": "vivid"}, + optional_params={}, + model="openai/gpt-image-2", + drop_params=False, + ) + + +def test_map_openai_params_drops_unsupported_param(): + assert ( + FalAIGPTImage2Config().map_openai_params( + non_default_params={"style": "vivid"}, + optional_params={}, + model="openai/gpt-image-2", + drop_params=True, + ) + == {} + ) + + +def test_transform_image_generation_request(): + assert FalAIGPTImage2Config().transform_image_generation_request( + model="openai/gpt-image-2", + prompt="a red bicycle", + optional_params={"quality": "high", "num_images": 2}, + litellm_params={}, + headers={}, + ) == {"prompt": "a red bicycle", "quality": "high", "num_images": 2} + + +def test_cost_calculator_uses_registry_price(): + response = ImageResponse( + data=[ + ImageObject(url="https://v3b.fal.media/files/b/one.png"), + ImageObject(url="https://v3b.fal.media/files/b/two.png"), + ] + ) + assert cost_calculator(model="openai/gpt-image-2", image_response=response) == pytest.approx(0.29) From f3896c0527b62f896f519be72a25f028856a9806 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:27:01 +0000 Subject: [PATCH 2/4] test(fal_ai): use monkeypatch for the gpt-image-2 cost map fixture Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_fal_ai_gpt_image_2_transformation.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py index 513e16dc4b6..3baa39c758f 100644 --- a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py +++ b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py @@ -1,15 +1,6 @@ -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 ( FalAIGPTImage2Config, @@ -136,7 +127,8 @@ def test_transform_image_generation_request(): ) == {"prompt": "a red bicycle", "quality": "high", "num_images": 2} -def test_cost_calculator_uses_registry_price(): +def test_cost_calculator_uses_registry_price(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) response = ImageResponse( data=[ ImageObject(url="https://v3b.fal.media/files/b/one.png"), From 618d907d5ae539d1d39f4e8688d702bfd0a76754 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:04:37 +0000 Subject: [PATCH 3/4] fix(fal_ai): price gpt-image-2 unprefixed alias and edit endpoint Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 26 +++++++++++++++++++ model_prices_and_context_window.json | 26 +++++++++++++++++++ .../test_fal_ai_gpt_image_2_transformation.py | 12 +++++++-- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 20d47bdb839..429e859e242 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -17410,6 +17410,32 @@ ], "supports_vision": true }, + "fal_ai/gpt-image-2": { + "litellm_provider": "fal_ai", + "metadata": { + "notes": "Alias of fal_ai/openai/gpt-image-2, which litellm also accepts without the openai/ prefix. Same rate, see that entry for the size and quality caveat" + }, + "mode": "image_generation", + "output_cost_per_image": 0.145, + "source": "https://fal.ai/models/openai/gpt-image-2", + "supported_endpoints": [ + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/openai/gpt-image-2/edit": { + "litellm_provider": "fal_ai", + "metadata": { + "notes": "Editing endpoint of gpt-image-2 on fal.ai. Same rate as fal_ai/openai/gpt-image-2, see that entry for the size and quality caveat" + }, + "mode": "image_generation", + "output_cost_per_image": 0.145, + "source": "https://fal.ai/models/openai/gpt-image-2/edit", + "supported_endpoints": [ + "/v1/images/edits" + ], + "supports_vision": true + }, "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 20d47bdb839..429e859e242 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -17410,6 +17410,32 @@ ], "supports_vision": true }, + "fal_ai/gpt-image-2": { + "litellm_provider": "fal_ai", + "metadata": { + "notes": "Alias of fal_ai/openai/gpt-image-2, which litellm also accepts without the openai/ prefix. Same rate, see that entry for the size and quality caveat" + }, + "mode": "image_generation", + "output_cost_per_image": 0.145, + "source": "https://fal.ai/models/openai/gpt-image-2", + "supported_endpoints": [ + "/v1/images/generations" + ], + "supports_vision": true + }, + "fal_ai/openai/gpt-image-2/edit": { + "litellm_provider": "fal_ai", + "metadata": { + "notes": "Editing endpoint of gpt-image-2 on fal.ai. Same rate as fal_ai/openai/gpt-image-2, see that entry for the size and quality caveat" + }, + "mode": "image_generation", + "output_cost_per_image": 0.145, + "source": "https://fal.ai/models/openai/gpt-image-2/edit", + "supported_endpoints": [ + "/v1/images/edits" + ], + "supports_vision": true + }, "featherless_ai/featherless-ai/Qwerky-72B": { "litellm_provider": "featherless_ai", "max_input_tokens": 32768, diff --git a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py index 3baa39c758f..3c8cf9f9e0a 100644 --- a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py +++ b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py @@ -127,7 +127,15 @@ def test_transform_image_generation_request(): ) == {"prompt": "a red bicycle", "quality": "high", "num_images": 2} -def test_cost_calculator_uses_registry_price(monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize( + "model", + [ + "openai/gpt-image-2", + "gpt-image-2", + "openai/gpt-image-2/edit", + ], +) +def test_cost_calculator_uses_registry_price(model, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) response = ImageResponse( data=[ @@ -135,4 +143,4 @@ def test_cost_calculator_uses_registry_price(monkeypatch: pytest.MonkeyPatch): ImageObject(url="https://v3b.fal.media/files/b/two.png"), ] ) - assert cost_calculator(model="openai/gpt-image-2", image_response=response) == pytest.approx(0.29) + assert cost_calculator(model=model, image_response=response) == pytest.approx(0.29) From 6d665679156ac475f1ef6d8a47473a7bbfa36bf6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:01:48 -0700 Subject: [PATCH 4/4] fix(fal_ai): stop advertising /v1/images/edits for gpt-image-2 edit The edit model is reached through the image generation path with fal's image_urls param; /v1/images/edits is not wired for fal_ai and errors. Point supported_endpoints at /v1/images/generations and say so in the entry notes. --- litellm/model_prices_and_context_window_backup.json | 4 ++-- model_prices_and_context_window.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 429e859e242..53902e640d6 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -17426,13 +17426,13 @@ "fal_ai/openai/gpt-image-2/edit": { "litellm_provider": "fal_ai", "metadata": { - "notes": "Editing endpoint of gpt-image-2 on fal.ai. Same rate as fal_ai/openai/gpt-image-2, see that entry for the size and quality caveat" + "notes": "Editing endpoint of gpt-image-2 on fal.ai, reached through the image generation path with fal's image_urls param since /v1/images/edits is not wired for fal_ai. Same rate as fal_ai/openai/gpt-image-2, see that entry for the size and quality caveat" }, "mode": "image_generation", "output_cost_per_image": 0.145, "source": "https://fal.ai/models/openai/gpt-image-2/edit", "supported_endpoints": [ - "/v1/images/edits" + "/v1/images/generations" ], "supports_vision": true }, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 429e859e242..53902e640d6 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -17426,13 +17426,13 @@ "fal_ai/openai/gpt-image-2/edit": { "litellm_provider": "fal_ai", "metadata": { - "notes": "Editing endpoint of gpt-image-2 on fal.ai. Same rate as fal_ai/openai/gpt-image-2, see that entry for the size and quality caveat" + "notes": "Editing endpoint of gpt-image-2 on fal.ai, reached through the image generation path with fal's image_urls param since /v1/images/edits is not wired for fal_ai. Same rate as fal_ai/openai/gpt-image-2, see that entry for the size and quality caveat" }, "mode": "image_generation", "output_cost_per_image": 0.145, "source": "https://fal.ai/models/openai/gpt-image-2/edit", "supported_endpoints": [ - "/v1/images/edits" + "/v1/images/generations" ], "supports_vision": true },