Skip to content

Commit 286c75f

Browse files
authored
Merge pull request #37729 from BerriAI/devin_ai_fal_gpt_image_2
feat(fal_ai): add gpt-image-2 image generation support
2 parents 43995bc + 6d66567 commit 286c75f

5 files changed

Lines changed: 353 additions & 1 deletion

File tree

litellm/llms/fal_ai/image_generation/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .flux_pro_v11_transformation import FalAIFluxProV11Config
1313
from .flux_pro_v11_ultra_transformation import FalAIFluxProV11UltraConfig
1414
from .flux_schnell_transformation import FalAIFluxSchnellConfig
15+
from .gpt_image_2_transformation import FalAIGPTImage2Config
1516
from .ideogram_v3_transformation import FalAIIdeogramV3Config
1617
from .imagen4_transformation import FalAIImagen4Config
1718
from .nano_banana_transformation import FalAINanoBananaConfig
@@ -27,6 +28,7 @@
2728
"FalAIFluxProV11Config",
2829
"FalAIFluxProV11UltraConfig",
2930
"FalAIFluxSchnellConfig",
31+
"FalAIGPTImage2Config",
3032
"FalAIIdeogramV3Config",
3133
"FalAIImageGenerationConfig",
3234
"FalAIImagen4Config",
@@ -49,7 +51,9 @@ def get_fal_ai_image_generation_config(model: str) -> BaseImageGenerationConfig:
4951
model_lower: Final = model.lower()
5052

5153
# Map model names to their corresponding configuration classes
52-
if "nano-banana" in model_lower or "gemini-25-flash-image" in model_lower:
54+
if "gpt-image-2" in model_lower:
55+
return FalAIGPTImage2Config()
56+
elif "nano-banana" in model_lower or "gemini-25-flash-image" in model_lower:
5357
return FalAINanoBananaConfig()
5458
elif "imagen4" in model_lower or "imagen-4" in model_lower:
5559
return FalAIImagen4Config()
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from collections.abc import Mapping
2+
from types import MappingProxyType
3+
from typing import Final
4+
5+
from typing_extensions import ReadOnly, TypedDict
6+
7+
from litellm.secret_managers.main import get_secret_str
8+
from litellm.types.llms.openai import OpenAIImageGenerationOptionalParams
9+
10+
from .transformation import FalAIBaseConfig
11+
12+
13+
class FalAIImageSize(TypedDict):
14+
width: ReadOnly[int]
15+
height: ReadOnly[int]
16+
17+
18+
SUPPORTED_OPENAI_PARAMS: Final[tuple[OpenAIImageGenerationOptionalParams, ...]] = (
19+
"n",
20+
"output_format",
21+
"quality",
22+
"response_format",
23+
"size",
24+
)
25+
26+
27+
class FalAIGPTImage2Config(FalAIBaseConfig):
28+
"""
29+
Configuration for OpenAI's GPT Image 2 served through Fal AI.
30+
31+
Model endpoints:
32+
- openai/gpt-image-2 (text-to-image)
33+
- openai/gpt-image-2/edit (editing, with optional mask)
34+
35+
Documentation: https://fal.ai/models/openai/gpt-image-2/api
36+
"""
37+
38+
MODEL_PREFIX: Final[str] = "openai/"
39+
SUPPORTED_QUALITIES: Final[frozenset[str]] = frozenset({"auto", "low", "medium", "high"})
40+
OPENAI_QUALITY_ALIASES: Final[Mapping[str, str]] = MappingProxyType({"hd": "high", "standard": "medium"})
41+
PARAM_TRANSLATION: Final[Mapping[str, str]] = MappingProxyType(
42+
{
43+
"n": "num_images",
44+
"size": "image_size",
45+
"quality": "quality",
46+
"output_format": "output_format",
47+
}
48+
)
49+
50+
def get_complete_url(
51+
self,
52+
api_base: str | None,
53+
api_key: str | None,
54+
model: str,
55+
optional_params: Mapping[str, object],
56+
litellm_params: Mapping[str, object],
57+
stream: bool | None = None,
58+
) -> str:
59+
base_url: Final[str] = (api_base or get_secret_str("FAL_AI_API_BASE") or self.DEFAULT_BASE_URL).rstrip("/")
60+
endpoint: Final[str] = model if model.startswith(self.MODEL_PREFIX) else f"{self.MODEL_PREFIX}{model}"
61+
return f"{base_url}/{endpoint}"
62+
63+
def get_supported_openai_params( # mutable-ok: base class contract returns a list
64+
self, model: str
65+
) -> list[OpenAIImageGenerationOptionalParams]:
66+
return list(SUPPORTED_OPENAI_PARAMS) # mutable-ok: base class contract returns a list
67+
68+
def map_openai_params( # mutable-ok: base class contract returns a dict
69+
self,
70+
non_default_params: Mapping[str, object],
71+
optional_params: Mapping[str, object],
72+
model: str,
73+
drop_params: bool,
74+
) -> dict:
75+
unsupported_params: Final = tuple(
76+
key for key in non_default_params if key not in SUPPORTED_OPENAI_PARAMS and key not in optional_params
77+
)
78+
if unsupported_params and not drop_params:
79+
raise ValueError(
80+
f"Parameters {unsupported_params} are not supported for model {model}. "
81+
f"Supported parameters are {SUPPORTED_OPENAI_PARAMS}. "
82+
"Set drop_params=True to drop unsupported parameters."
83+
)
84+
translated_params: Final[Mapping[str, object]] = MappingProxyType(
85+
{
86+
self.PARAM_TRANSLATION[key]: self._translate_value(key, value)
87+
for key, value in non_default_params.items()
88+
if key in self.PARAM_TRANSLATION and self.PARAM_TRANSLATION[key] not in optional_params
89+
}
90+
)
91+
return {**optional_params, **translated_params} # mutable-ok: base class contract returns a dict
92+
93+
def _translate_value(self, key: str, value: object) -> object:
94+
if key == "size":
95+
return self._map_image_size(value)
96+
if key == "quality":
97+
return self._map_quality(value)
98+
return value
99+
100+
def _map_image_size(self, size: object) -> object:
101+
if not isinstance(size, str) or size == "auto":
102+
return size
103+
try:
104+
width, height = (int(part) for part in size.lower().split("x"))
105+
except ValueError:
106+
return size
107+
image_size: Final[FalAIImageSize] = {"width": width, "height": height}
108+
return image_size
109+
110+
def _map_quality(self, quality: object) -> object:
111+
if not isinstance(quality, str):
112+
return quality
113+
normalized: Final[str] = self.OPENAI_QUALITY_ALIASES.get(quality, quality)
114+
return normalized if normalized in self.SUPPORTED_QUALITIES else "auto"
115+
116+
def transform_image_generation_request( # mutable-ok: base class contract returns a dict
117+
self,
118+
model: str,
119+
prompt: str,
120+
optional_params: Mapping[str, object],
121+
litellm_params: Mapping[str, object],
122+
headers: Mapping[str, str],
123+
) -> dict:
124+
return {"prompt": prompt, **optional_params} # mutable-ok: base class contract returns a dict

litellm/model_prices_and_context_window_backup.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17397,6 +17397,45 @@
1739717397
"/v1/images/generations"
1739817398
]
1739917399
},
17400+
"fal_ai/openai/gpt-image-2": {
17401+
"litellm_provider": "fal_ai",
17402+
"metadata": {
17403+
"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"
17404+
},
17405+
"mode": "image_generation",
17406+
"output_cost_per_image": 0.145,
17407+
"source": "https://fal.ai/models/openai/gpt-image-2",
17408+
"supported_endpoints": [
17409+
"/v1/images/generations"
17410+
],
17411+
"supports_vision": true
17412+
},
17413+
"fal_ai/gpt-image-2": {
17414+
"litellm_provider": "fal_ai",
17415+
"metadata": {
17416+
"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"
17417+
},
17418+
"mode": "image_generation",
17419+
"output_cost_per_image": 0.145,
17420+
"source": "https://fal.ai/models/openai/gpt-image-2",
17421+
"supported_endpoints": [
17422+
"/v1/images/generations"
17423+
],
17424+
"supports_vision": true
17425+
},
17426+
"fal_ai/openai/gpt-image-2/edit": {
17427+
"litellm_provider": "fal_ai",
17428+
"metadata": {
17429+
"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"
17430+
},
17431+
"mode": "image_generation",
17432+
"output_cost_per_image": 0.145,
17433+
"source": "https://fal.ai/models/openai/gpt-image-2/edit",
17434+
"supported_endpoints": [
17435+
"/v1/images/generations"
17436+
],
17437+
"supports_vision": true
17438+
},
1740017439
"featherless_ai/featherless-ai/Qwerky-72B": {
1740117440
"litellm_provider": "featherless_ai",
1740217441
"max_input_tokens": 32768,

model_prices_and_context_window.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17397,6 +17397,45 @@
1739717397
"/v1/images/generations"
1739817398
]
1739917399
},
17400+
"fal_ai/openai/gpt-image-2": {
17401+
"litellm_provider": "fal_ai",
17402+
"metadata": {
17403+
"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"
17404+
},
17405+
"mode": "image_generation",
17406+
"output_cost_per_image": 0.145,
17407+
"source": "https://fal.ai/models/openai/gpt-image-2",
17408+
"supported_endpoints": [
17409+
"/v1/images/generations"
17410+
],
17411+
"supports_vision": true
17412+
},
17413+
"fal_ai/gpt-image-2": {
17414+
"litellm_provider": "fal_ai",
17415+
"metadata": {
17416+
"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"
17417+
},
17418+
"mode": "image_generation",
17419+
"output_cost_per_image": 0.145,
17420+
"source": "https://fal.ai/models/openai/gpt-image-2",
17421+
"supported_endpoints": [
17422+
"/v1/images/generations"
17423+
],
17424+
"supports_vision": true
17425+
},
17426+
"fal_ai/openai/gpt-image-2/edit": {
17427+
"litellm_provider": "fal_ai",
17428+
"metadata": {
17429+
"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"
17430+
},
17431+
"mode": "image_generation",
17432+
"output_cost_per_image": 0.145,
17433+
"source": "https://fal.ai/models/openai/gpt-image-2/edit",
17434+
"supported_endpoints": [
17435+
"/v1/images/generations"
17436+
],
17437+
"supports_vision": true
17438+
},
1740017439
"featherless_ai/featherless-ai/Qwerky-72B": {
1740117440
"litellm_provider": "featherless_ai",
1740217441
"max_input_tokens": 32768,
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import pytest
2+
3+
import litellm
4+
from litellm.llms.fal_ai.cost_calculator import cost_calculator
5+
from litellm.llms.fal_ai.image_generation import (
6+
FalAIGPTImage2Config,
7+
FalAINanoBananaConfig,
8+
get_fal_ai_image_generation_config,
9+
)
10+
from litellm.types.utils import ImageObject, ImageResponse
11+
12+
13+
@pytest.mark.parametrize(
14+
"model",
15+
[
16+
"openai/gpt-image-2",
17+
"gpt-image-2",
18+
"openai/gpt-image-2/edit",
19+
],
20+
)
21+
def test_gpt_image_2_config_selected(model):
22+
assert isinstance(get_fal_ai_image_generation_config(model), FalAIGPTImage2Config)
23+
24+
25+
def test_nano_banana_still_routes_to_nano_banana_config():
26+
assert isinstance(
27+
get_fal_ai_image_generation_config("fal-ai/nano-banana"),
28+
FalAINanoBananaConfig,
29+
)
30+
31+
32+
@pytest.mark.parametrize(
33+
"model,expected_url",
34+
[
35+
("openai/gpt-image-2", "https://fal.run/openai/gpt-image-2"),
36+
("gpt-image-2", "https://fal.run/openai/gpt-image-2"),
37+
("openai/gpt-image-2/edit", "https://fal.run/openai/gpt-image-2/edit"),
38+
],
39+
)
40+
def test_get_complete_url_derives_endpoint_from_model(model, expected_url):
41+
url = FalAIGPTImage2Config().get_complete_url(
42+
api_base=None,
43+
api_key="test-key",
44+
model=model,
45+
optional_params={},
46+
litellm_params={},
47+
)
48+
assert url == expected_url
49+
50+
51+
def test_get_complete_url_respects_api_base_override():
52+
url = FalAIGPTImage2Config().get_complete_url(
53+
api_base="https://proxy.internal/",
54+
api_key="test-key",
55+
model="openai/gpt-image-2",
56+
optional_params={},
57+
litellm_params={},
58+
)
59+
assert url == "https://proxy.internal/openai/gpt-image-2"
60+
61+
62+
@pytest.mark.parametrize(
63+
"non_default_params,expected",
64+
[
65+
({"n": 3}, {"num_images": 3}),
66+
({"size": "1024x1536"}, {"image_size": {"width": 1024, "height": 1536}}),
67+
({"size": "auto"}, {"image_size": "auto"}),
68+
({"quality": "medium"}, {"quality": "medium"}),
69+
({"quality": "hd"}, {"quality": "high"}),
70+
({"quality": "standard"}, {"quality": "medium"}),
71+
({"quality": "nonsense"}, {"quality": "auto"}),
72+
({"output_format": "webp"}, {"output_format": "webp"}),
73+
({"response_format": "url"}, {}),
74+
],
75+
)
76+
def test_map_openai_params(non_default_params, expected):
77+
assert (
78+
FalAIGPTImage2Config().map_openai_params(
79+
non_default_params=non_default_params,
80+
optional_params={},
81+
model="openai/gpt-image-2",
82+
drop_params=False,
83+
)
84+
== expected
85+
)
86+
87+
88+
def test_map_openai_params_keeps_explicit_provider_params():
89+
mapped = FalAIGPTImage2Config().map_openai_params(
90+
non_default_params={"n": 4, "size": "1024x1024"},
91+
optional_params={"num_images": 1, "image_size": "square_hd"},
92+
model="openai/gpt-image-2",
93+
drop_params=False,
94+
)
95+
assert mapped == {"num_images": 1, "image_size": "square_hd"}
96+
97+
98+
def test_map_openai_params_raises_on_unsupported_param():
99+
with pytest.raises(ValueError, match="style"):
100+
FalAIGPTImage2Config().map_openai_params(
101+
non_default_params={"style": "vivid"},
102+
optional_params={},
103+
model="openai/gpt-image-2",
104+
drop_params=False,
105+
)
106+
107+
108+
def test_map_openai_params_drops_unsupported_param():
109+
assert (
110+
FalAIGPTImage2Config().map_openai_params(
111+
non_default_params={"style": "vivid"},
112+
optional_params={},
113+
model="openai/gpt-image-2",
114+
drop_params=True,
115+
)
116+
== {}
117+
)
118+
119+
120+
def test_transform_image_generation_request():
121+
assert FalAIGPTImage2Config().transform_image_generation_request(
122+
model="openai/gpt-image-2",
123+
prompt="a red bicycle",
124+
optional_params={"quality": "high", "num_images": 2},
125+
litellm_params={},
126+
headers={},
127+
) == {"prompt": "a red bicycle", "quality": "high", "num_images": 2}
128+
129+
130+
@pytest.mark.parametrize(
131+
"model",
132+
[
133+
"openai/gpt-image-2",
134+
"gpt-image-2",
135+
"openai/gpt-image-2/edit",
136+
],
137+
)
138+
def test_cost_calculator_uses_registry_price(model, monkeypatch: pytest.MonkeyPatch):
139+
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
140+
response = ImageResponse(
141+
data=[
142+
ImageObject(url="https://v3b.fal.media/files/b/one.png"),
143+
ImageObject(url="https://v3b.fal.media/files/b/two.png"),
144+
]
145+
)
146+
assert cost_calculator(model=model, image_response=response) == pytest.approx(0.29)

0 commit comments

Comments
 (0)