Merge pull request #42095 from BerriAI/litellm_fal_gpt_image_25_flux_dev_edits

feat(fal_ai): add gpt-image-2.5 flare/sunburst, flux/dev and image edits
This commit is contained in:
kerry-berri 2026-09-21 11:45:45 -07:00 committed by GitHub
commit 12379aa1e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 3453 additions and 46 deletions

View file

@ -19,10 +19,10 @@ FAL_NAMED_IMAGE_SIZES: Final[Mapping[str, str]] = MappingProxyType(
)
def _keyed_size(model: str, optional_params: Mapping[str, object]) -> str | None:
def _keyed_size(optional_params: Mapping[str, object]) -> str | None:
image_size: Final = optional_params.get("image_size")
if image_size is None:
return None if model.endswith("/edit") else FAL_TEXT_TO_IMAGE_DEFAULT_SIZE
if image_size is None or image_size == "auto":
return FAL_TEXT_TO_IMAGE_DEFAULT_SIZE
if isinstance(image_size, Mapping):
width: Final = image_size.get("width")
height: Final = image_size.get("height")
@ -37,7 +37,7 @@ def _keyed_size(model: str, optional_params: Mapping[str, object]) -> str | None
def _keyed_cost_per_image(model: str, optional_params: Mapping[str, object] | None) -> float | None:
if optional_params is None:
return None
size: Final = _keyed_size(model=model, optional_params=optional_params)
size: Final = _keyed_size(optional_params)
if size is None:
return None
raw_quality: Final = optional_params.get("quality")

View file

@ -0,0 +1,3 @@
from .transformation import FalAIImageEditConfig
__all__ = ("FalAIImageEditConfig",)

View file

@ -0,0 +1,179 @@
import base64
import os
from collections.abc import Mapping
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
import httpx
from httpx._types import RequestFiles
from litellm.images.utils import ImageEditRequestUtils
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
from litellm.llms.fal_ai.image_generation.gpt_image_2_transformation import (
map_gpt_image_quality,
map_gpt_image_size,
)
from litellm.llms.fal_ai.image_generation.transformation import fal_images_to_image_objects
from litellm.secret_managers.main import get_secret_str
from litellm.types.images.main import ImageEditOptionalRequestParams
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import FileTypes, ImageResponse
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
DEFAULT_BASE_URL: Final[str] = "https://fal.run"
EDIT_SUFFIX: Final[str] = "/edit"
SUPPORTED_OPENAI_PARAMS: Final[tuple[str, ...]] = ("background", "mask", "n", "quality", "size")
PARAM_TRANSLATION: Final[Mapping[str, str]] = MappingProxyType(
{
"background": "background",
"n": "num_images",
"quality": "quality",
"size": "image_size",
}
)
@runtime_checkable
class _SeekableBinaryReader(Protocol):
def tell(self) -> int: ...
def seek(self, offset: int) -> int: ...
def read(self) -> bytes: ...
def _read_image_bytes(image: object) -> bytes:
if isinstance(image, bytes):
return image
if isinstance(image, tuple):
return _read_image_bytes(image[1])
if isinstance(image, os.PathLike):
return Path(image).read_bytes()
if isinstance(image, _SeekableBinaryReader):
position: Final = image.tell()
image.seek(0)
data: Final = image.read()
image.seek(position)
return data
raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
def _to_data_url(image: object) -> str:
if isinstance(image, str):
return image
image_bytes: Final = _read_image_bytes(image)
mime_type: Final = ImageEditRequestUtils.get_image_content_type(image_bytes)
return f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}"
def _first(value: object) -> object:
return value[0] if isinstance(value, list) and value else value
class FalAIImageEditConfig(BaseImageEditConfig):
"""
Image edits served through Fal AI's ``/edit`` endpoints, e.g. openai/gpt-image-2.5/flare/edit.
Fal expects a JSON body with ``image_urls`` (and an optional ``mask_url``) rather than multipart
uploads, so local files are sent inline as base64 data URLs.
"""
def get_supported_openai_params(self, model: str) -> list: # mutable-ok: base class contract returns a list
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,
image_edit_optional_params: ImageEditOptionalRequestParams,
model: str,
drop_params: bool,
) -> dict:
return { # mutable-ok: base class contract returns a dict
PARAM_TRANSLATION.get(key, key): self._translate_value(key, value, model)
for key, value in image_edit_optional_params.items()
if value is not None
}
def _translate_value(self, key: str, value: object, model: str) -> object:
if key == "size":
return map_gpt_image_size(value)
if key == "quality":
return map_gpt_image_quality(value, model)
return value
def validate_environment(
self,
headers: dict,
model: str,
api_key: str | None = None,
litellm_params: dict | None = None,
api_base: str | None = None,
) -> dict:
final_api_key: Final = api_key or get_secret_str("FAL_AI_API_KEY")
if not final_api_key:
raise ValueError("FAL_AI_API_KEY is not set")
return {**headers, "Authorization": f"Key {final_api_key}"} # mutable-ok: base class contract returns a dict
def use_multipart_form_data(self) -> bool:
return False
def get_complete_url(
self,
model: str,
api_base: str | None,
litellm_params: dict,
) -> str:
base_url: Final = (api_base or get_secret_str("FAL_AI_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
endpoint: Final = model if model.endswith(EDIT_SUFFIX) else f"{model}{EDIT_SUFFIX}"
return f"{base_url}/{endpoint}"
def transform_image_edit_request(
self,
model: str,
prompt: str | None,
image: FileTypes | None,
image_edit_optional_request_params: dict,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> tuple[dict, RequestFiles]:
images: Final = tuple(img for img in (image if isinstance(image, list) else (image,)) if img is not None)
if not images:
raise ValueError("Fal AI image edit requires at least one input image")
mask: Final = _first(image_edit_optional_request_params.get("mask"))
mask_field: Final[Mapping[str, str]] = (
MappingProxyType({"mask_url": _to_data_url(mask)}) if mask is not None else MappingProxyType({})
)
provider_params: Final[Mapping[str, object]] = MappingProxyType(
{
key: value for key, value in image_edit_optional_request_params.items() if key != "mask"
} # mutable-ok: frozen by MappingProxyType
)
request_body: Final[dict[str, object]] = { # mutable-ok: base class contract returns a dict
"prompt": prompt,
"image_urls": tuple(_to_data_url(img) for img in images),
**mask_field,
**provider_params,
}
return request_body, ()
def transform_image_edit_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: "LiteLLMLoggingObj",
) -> ImageResponse:
try:
response_json: Final = raw_response.json()
except Exception as e:
raise self.get_error_class(
error_message=f"Error parsing Fal AI image edit response: {e}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
model_response: Final = ImageResponse()
model_response.data = list( # mutable-ok: ImageResponse.data is typed as a list
fal_images_to_image_objects(response_json.get("images", ()))
)
return model_response

View file

@ -9,6 +9,7 @@ from .bytedance_transformation import (
FalAIBytedanceDreaminaV31Config,
FalAIBytedanceSeedreamV3Config,
)
from .flux_dev_transformation import FalAIFluxDevConfig
from .flux_pro_v11_transformation import FalAIFluxProV11Config
from .flux_pro_v11_ultra_transformation import FalAIFluxProV11UltraConfig
from .flux_schnell_transformation import FalAIFluxSchnellConfig
@ -25,6 +26,7 @@ __all__ = [
"FalAIBriaConfig",
"FalAIBytedanceDreaminaV31Config",
"FalAIBytedanceSeedreamV3Config",
"FalAIFluxDevConfig",
"FalAIFluxProV11Config",
"FalAIFluxProV11UltraConfig",
"FalAIFluxSchnellConfig",
@ -65,6 +67,8 @@ def get_fal_ai_image_generation_config(model: str) -> BaseImageGenerationConfig:
if "ultra" in model_lower:
return FalAIFluxProV11UltraConfig()
return FalAIFluxProV11Config()
elif "flux/dev" in model_lower or "flux-dev" in model_lower:
return FalAIFluxDevConfig()
elif "flux/schnell" in model_lower or "flux-schnell" in model_lower or "schnell" in model_lower:
return FalAIFluxSchnellConfig()
elif "bytedance/seedream" in model_lower:

View file

@ -0,0 +1,12 @@
from .flux_schnell_transformation import FalAIFluxSchnellConfig
class FalAIFluxDevConfig(FalAIFluxSchnellConfig):
"""
Configuration for Fal AI Flux Dev model.
Model endpoint: fal-ai/flux/dev
Documentation: https://fal.ai/models/fal-ai/flux/dev
"""
IMAGE_GENERATION_ENDPOINT: str = "fal-ai/flux/dev"

View file

@ -4,6 +4,7 @@ from typing import Final
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import OpenAIImageGenerationOptionalParams
@ -22,6 +23,47 @@ SUPPORTED_OPENAI_PARAMS: Final[tuple[OpenAIImageGenerationOptionalParams, ...]]
"response_format",
"size",
)
OPENAI_QUALITY_ALIASES: Final[Mapping[str, str]] = MappingProxyType({"hd": "high", "standard": "medium"})
def map_gpt_image_size(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 supported_gpt_image_qualities(
model: str, model_cost: Mapping[str, Mapping[str, object]] | None = None
) -> frozenset[str]:
costs: Final = litellm.model_cost if model_cost is None else model_cost
endpoint: Final[str] = model.removeprefix("fal_ai/")
qualified_endpoint: Final[str] = endpoint if endpoint.startswith("openai/") else f"openai/{endpoint}"
qualities: Final[frozenset[str]] = frozenset(
parts[1]
for key in costs
if (parts := key.split("/"))[0] == "fal_ai"
and len(parts) > 3
and "-x-" in parts[2]
and "/".join(parts[3:]) == qualified_endpoint
)
return qualities | {"auto"} if qualities else frozenset()
def map_gpt_image_quality(
quality: object, model: str, model_cost: Mapping[str, Mapping[str, object]] | None = None
) -> object:
if not isinstance(quality, str):
return quality
normalized: Final[str] = OPENAI_QUALITY_ALIASES.get(quality, quality)
supported: Final[frozenset[str]] = supported_gpt_image_qualities(model, model_cost)
if not supported:
return normalized
return normalized if normalized in supported else "auto"
class FalAIGPTImage2Config(FalAIBaseConfig):
@ -31,13 +73,12 @@ class FalAIGPTImage2Config(FalAIBaseConfig):
Model endpoints:
- openai/gpt-image-2 (text-to-image)
- openai/gpt-image-2/edit (editing, with optional mask)
- openai/gpt-image-2.5/flare/text-to-image, openai/gpt-image-2.5/sunburst/text-to-image
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",
@ -83,36 +124,20 @@ class FalAIGPTImage2Config(FalAIBaseConfig):
)
translated_params: Final[Mapping[str, object]] = MappingProxyType(
{
self.PARAM_TRANSLATION[key]: self._translate_value(key, value)
self.PARAM_TRANSLATION[key]: self._translate_value(key, value, model)
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:
def _translate_value(self, key: str, value: object, model: str) -> object:
if key == "size":
return self._map_image_size(value)
return map_gpt_image_size(value)
if key == "quality":
return self._map_quality(value)
return map_gpt_image_quality(value, model)
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,

View file

@ -22,6 +22,18 @@ else:
LiteLLMLoggingObj = Any
def fal_images_to_image_objects(images: object) -> tuple[ImageObject, ...]:
if not isinstance(images, list):
return ()
return tuple(
ImageObject(url=image_data.get("url", None), b64_json=image_data.get("b64_json", None))
if isinstance(image_data, dict)
else ImageObject(url=image_data, b64_json=None)
for image_data in images
if isinstance(image_data, (dict, str))
)
class FalAIBaseConfig(BaseImageGenerationConfig):
"""
Base configuration for Fal AI image generation models.
@ -96,26 +108,7 @@ class FalAIBaseConfig(BaseImageGenerationConfig):
if not model_response.data:
model_response.data = []
# Handle fal.ai response format
images: Final = response_data.get("images", [])
if isinstance(images, list):
for image_data in images:
if isinstance(image_data, dict):
model_response.data.append(
ImageObject(
url=image_data.get("url", None),
b64_json=image_data.get("b64_json", None),
)
)
elif isinstance(image_data, str):
# If images is just a list of URLs
model_response.data.append(
ImageObject(
url=image_data,
b64_json=None,
)
)
model_response.data.extend(fal_images_to_image_objects(response_data.get("images", ())))
return model_response

File diff suppressed because it is too large Load diff

View file

@ -9512,6 +9512,10 @@ class ProviderConfigManager:
)
return BlackForestLabsImageEditConfig()
elif LlmProviders.FAL_AI == provider:
from litellm.llms.fal_ai.image_edit import FalAIImageEditConfig
return FalAIImageEditConfig()
elif LlmProviders.AZURE_AI == provider:
from litellm.llms.azure_ai.image_edit import get_azure_ai_image_edit_config

File diff suppressed because it is too large Load diff

View file

@ -166,6 +166,15 @@
"tests/integration/providers/test_fal_ai_video_wire.py::test_fal_video_create_status_and_content_follow_queue_wire_contract": [
"other.provider_wire.fal_ai.video_queue_create_status_and_content_download"
],
"tests/integration/providers/test_fal_ai_image_wire.py::test_fal_gpt_image_25_generation_sends_quality_and_size_and_charges_keyed_row": [
"other.provider_wire.fal_ai.gpt_image_generation_quality_size_wire_and_keyed_pricing"
],
"tests/integration/providers/test_fal_ai_image_wire.py::test_fal_flux_dev_generation_targets_dev_endpoint_and_charges_per_image": [
"other.provider_wire.fal_ai.flux_dev_endpoint_and_per_image_pricing"
],
"tests/integration/providers/test_fal_ai_image_wire.py::test_fal_gpt_image_25_edit_inlines_upload_as_data_url_and_charges_keyed_row": [
"other.provider_wire.fal_ai.image_edit_json_data_urls_and_keyed_pricing"
],
"tests/integration/mcp/test_mcp_lifecycle.py::test_saved_headers_reach_real_mcp_tool_and_survive_unrelated_edit": [
"mcp.call_tool.saved_headers.reach_actual_transport"
],

View file

@ -0,0 +1,176 @@
import base64
import json
from pathlib import Path
from typing import Final
import httpx
import pytest
from integration._support.client import Gateway
from integration._support.wire import Reply, Request, wire_server
from pydantic import JsonValue, TypeAdapter
_GPT_IMAGE_MODEL: Final = "openai/gpt-image-2.5/flare/text-to-image"
_FLUX_MODEL: Final = "fal-ai/flux/dev"
_EDIT_MODEL: Final = "openai/gpt-image-2.5/flare/edit"
_PNG_BYTES: Final = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
b"\x1f\x15\xc4\x89\x00\x00\x00\rIDAT\x08\xd7c\xf8\xcf\xc0\xf0\x1f\x00\x05\x00\x01\xff"
b"\x89\x99=\x1d\x00\x00\x00\x00IEND\xaeB`\x82"
)
_PROMPT: Final = "a red circle on a blue background"
_COST_MAP_PATH: Final = Path(__file__).resolve().parents[3] / "model_prices_and_context_window.json"
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_COST_MAP: Final = TypeAdapter(dict[str, dict[str, object]])
def _catalog_cost(key: str) -> float:
cost_map: Final = _COST_MAP.validate_json(_COST_MAP_PATH.read_bytes())
cost_value: Final = cost_map[key]["output_cost_per_image"]
assert isinstance(cost_value, (int, float))
return float(cost_value)
def _image_response(urls: tuple[str, ...], prompt: str) -> bytes:
return json.dumps(
{
"images": [
{
"url": url,
"content_type": "image/png",
"file_name": url.rsplit("/", 1)[-1],
"file_size": 123456,
"width": 1024,
"height": 768,
}
for url in urls
],
"timings": {"inference": 2.1},
"seed": 1234567,
"has_nsfw_concepts": [False],
"prompt": prompt,
}
).encode()
def _response_cost(response: httpx.Response) -> float:
return float(response.headers["x-litellm-response-cost"])
def _approx(value: float) -> object:
return pytest.approx(value, rel=1e-6) # pyright: ignore[reportUnknownMemberType] # pytest lacks typed approx stubs
@pytest.mark.covers("other.provider_wire.fal_ai.gpt_image_generation_quality_size_wire_and_keyed_pricing")
def test_fal_gpt_image_25_generation_sends_quality_and_size_and_charges_keyed_row(gateway: Gateway) -> None:
def respond(request: Request) -> Reply:
assert request.method == "POST"
assert request.headers["authorization"] == "Key synthetic-fal-key"
assert request.target == "/openai/gpt-image-2.5/flare/text-to-image"
body: Final = _JSON_OBJECT.validate_json(request.body)
if body.get("quality") == "high":
assert body == {"prompt": _PROMPT, "quality": "high", "image_size": {"width": 1024, "height": 1536}}
return Reply(body=_image_response((f"{wire_url}/files/high.png",), _PROMPT))
assert body == {"prompt": _PROMPT, "quality": "low"}
return Reply(body=_image_response((f"{wire_url}/files/low.png",), _PROMPT))
with wire_server(respond) as wire, gateway.scenario() as scenario:
wire_url: Final = wire.url
model: Final = scenario.model(
model=f"fal_ai/{_GPT_IMAGE_MODEL}", api_base=wire.url, api_key="synthetic-fal-key"
)
high_response: Final = gateway.request(
"POST",
"/v1/images/generations",
{"model": model, "prompt": _PROMPT, "quality": "high", "size": "1024x1536"},
)
assert high_response.status_code == 200, high_response.text
high_payload: Final = _JSON_OBJECT.validate_json(high_response.content)
assert high_payload["data"] == [{"url": f"{wire.url}/files/high.png", "b64_json": None, "revised_prompt": None}]
high_cost: Final = _response_cost(high_response)
assert high_cost == _approx(_catalog_cost("fal_ai/high/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image"))
low_response: Final = gateway.request(
"POST",
"/v1/images/generations",
{"model": model, "prompt": _PROMPT, "quality": "low"},
)
assert low_response.status_code == 200, low_response.text
low_payload: Final = _JSON_OBJECT.validate_json(low_response.content)
assert low_payload["data"] == [{"url": f"{wire.url}/files/low.png", "b64_json": None, "revised_prompt": None}]
low_cost: Final = _response_cost(low_response)
assert low_cost == _approx(_catalog_cost("fal_ai/low/1024-x-768/openai/gpt-image-2.5/flare/text-to-image"))
assert high_cost != low_cost
assert [(request.method, request.target) for request in wire.drain()] == [
("POST", "/openai/gpt-image-2.5/flare/text-to-image"),
("POST", "/openai/gpt-image-2.5/flare/text-to-image"),
]
@pytest.mark.covers("other.provider_wire.fal_ai.flux_dev_endpoint_and_per_image_pricing")
def test_fal_flux_dev_generation_targets_dev_endpoint_and_charges_per_image(gateway: Gateway) -> None:
def respond(request: Request) -> Reply:
assert request.method == "POST"
assert request.headers["authorization"] == "Key synthetic-fal-key"
assert request.target == "/fal-ai/flux/dev"
assert _JSON_OBJECT.validate_json(request.body) == {
"prompt": _PROMPT,
"num_images": 2,
"image_size": "square_hd",
}
return Reply(
body=_image_response(
(f"{wire_url}/files/flux-1.png", f"{wire_url}/files/flux-2.png"),
_PROMPT,
)
)
with wire_server(respond) as wire, gateway.scenario() as scenario:
wire_url: Final = wire.url
model: Final = scenario.model(model=f"fal_ai/{_FLUX_MODEL}", api_base=wire.url, api_key="synthetic-fal-key")
response: Final = gateway.request(
"POST",
"/v1/images/generations",
{"model": model, "prompt": _PROMPT, "n": 2, "size": "1024x1024"},
)
assert response.status_code == 200, response.text
payload: Final = _JSON_OBJECT.validate_json(response.content)
assert payload["data"] == [
{"url": f"{wire.url}/files/flux-1.png", "b64_json": None, "revised_prompt": None},
{"url": f"{wire.url}/files/flux-2.png", "b64_json": None, "revised_prompt": None},
]
cost: Final = _response_cost(response)
assert cost == _approx(2 * _catalog_cost("fal_ai/fal-ai/flux/dev"))
assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/fal-ai/flux/dev")]
@pytest.mark.covers("other.provider_wire.fal_ai.image_edit_json_data_urls_and_keyed_pricing")
def test_fal_gpt_image_25_edit_inlines_upload_as_data_url_and_charges_keyed_row(gateway: Gateway) -> None:
def respond(request: Request) -> Reply:
assert request.method == "POST"
assert request.headers["authorization"] == "Key synthetic-fal-key"
assert request.target == "/openai/gpt-image-2.5/flare/edit"
assert request.headers["content-type"] == "application/json"
assert _JSON_OBJECT.validate_json(request.body) == {
"prompt": _PROMPT,
"image_urls": ["data:image/png;base64," + base64.b64encode(_PNG_BYTES).decode()],
"quality": "low",
}
return Reply(body=_image_response((f"{wire_url}/files/edit.png",), _PROMPT))
with wire_server(respond) as wire, gateway.scenario() as scenario:
wire_url: Final = wire.url
model: Final = scenario.model(model=f"fal_ai/{_EDIT_MODEL}", api_base=wire.url, api_key="synthetic-fal-key")
response: Final = gateway.client.post(
"/v1/images/edits",
data={"model": model, "prompt": _PROMPT, "quality": "low"},
files={"image": ("red_circle.png", _PNG_BYTES, "image/png")},
headers={"Authorization": f"Bearer {gateway.key}"},
)
assert response.status_code == 200, response.text
payload: Final = _JSON_OBJECT.validate_json(response.content)
assert payload["data"] == [{"url": f"{wire.url}/files/edit.png", "b64_json": None, "revised_prompt": None}]
cost: Final = _response_cost(response)
assert cost == _approx(_catalog_cost("fal_ai/low/1024-x-768/openai/gpt-image-2.5/flare/edit"))
assert [(request.method, request.target) for request in wire.drain()] == [
("POST", "/openai/gpt-image-2.5/flare/edit")
]

View file

@ -0,0 +1,141 @@
import base64
import io
import json
import tempfile
from pathlib import Path
import httpx
import pytest
from litellm.llms.fal_ai.image_edit import FalAIImageEditConfig
from litellm.types.images.main import ImageEditOptionalRequestParams
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import ImageResponse, LlmProviders
from litellm.utils import ProviderConfigManager
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
def test_fal_ai_resolves_to_image_edit_config():
config = ProviderConfigManager.get_provider_image_edit_config(
model="openai/gpt-image-2.5/flare/edit", provider=LlmProviders.FAL_AI
)
assert isinstance(config, FalAIImageEditConfig)
@pytest.mark.parametrize(
"model,expected",
[
("openai/gpt-image-2.5/flare", "https://fal.run/openai/gpt-image-2.5/flare/edit"),
("openai/gpt-image-2.5/sunburst/edit", "https://fal.run/openai/gpt-image-2.5/sunburst/edit"),
("openai/gpt-image-2", "https://fal.run/openai/gpt-image-2/edit"),
],
)
def test_get_complete_url_appends_edit_suffix_once(model, expected):
assert FalAIImageEditConfig().get_complete_url(model=model, api_base=None, litellm_params={}) == expected
def test_get_complete_url_respects_api_base():
url = FalAIImageEditConfig().get_complete_url(
model="openai/gpt-image-2.5/flare", api_base="https://proxy.internal/", litellm_params={}
)
assert url == "https://proxy.internal/openai/gpt-image-2.5/flare/edit"
def test_validate_environment_uses_fal_key_scheme():
headers = FalAIImageEditConfig().validate_environment(headers={}, model="m", api_key="secret")
assert headers["Authorization"] == "Key secret"
def test_validate_environment_requires_key(monkeypatch):
monkeypatch.delenv("FAL_AI_API_KEY", raising=False)
with pytest.raises(ValueError, match="FAL_AI_API_KEY"):
FalAIImageEditConfig().validate_environment(headers={}, model="m", api_key=None)
def test_map_openai_params_translates_to_fal_names():
mapped = FalAIImageEditConfig().map_openai_params(
image_edit_optional_params=ImageEditOptionalRequestParams(
n=2, size="1024x1536", quality="xhigh", background="transparent"
),
model="openai/gpt-image-2.5/flare/edit",
drop_params=False,
)
assert mapped == {
"num_images": 2,
"image_size": {"width": 1024, "height": 1536},
"quality": "xhigh",
"background": "transparent",
}
def test_transform_request_inlines_local_images_as_data_urls_and_keeps_remote_urls():
body, files = FalAIImageEditConfig().transform_image_edit_request(
model="openai/gpt-image-2.5/flare/edit",
prompt="make it blue",
image=[io.BytesIO(PNG_BYTES), "https://example.com/in.png"],
image_edit_optional_request_params={"num_images": 1, "mask": io.BytesIO(PNG_BYTES)},
litellm_params=GenericLiteLLMParams(),
headers={},
)
expected_data_url = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode()
assert files == ()
assert body["prompt"] == "make it blue"
assert json.loads(json.dumps(body))["image_urls"] == [expected_data_url, "https://example.com/in.png"]
assert body["mask_url"] == expected_data_url
assert body["num_images"] == 1
assert "mask" not in body
@pytest.mark.parametrize(
"image_factory",
[
pytest.param(lambda path: ("red.png", PNG_BYTES), id="filename-bytes-tuple"),
pytest.param(lambda path: ("red.png", PNG_BYTES, "image/png"), id="three-tuple-with-content-type"),
pytest.param(lambda path: path, id="path"),
pytest.param(lambda path: io.FileIO(str(path), "rb"), id="file-io"),
pytest.param(
lambda path: tempfile.SpooledTemporaryFile(suffix=".png"),
id="spooled-temp-file",
),
],
)
def test_transform_request_reads_every_file_types_input(tmp_path, image_factory):
path = Path(tmp_path) / "red.png"
path.write_bytes(PNG_BYTES)
image = image_factory(path)
if isinstance(image, tempfile.SpooledTemporaryFile):
image.write(PNG_BYTES)
image.seek(3)
body, _ = FalAIImageEditConfig().transform_image_edit_request(
model="openai/gpt-image-2.5/flare/edit",
prompt="make it blue",
image=image,
image_edit_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
expected_data_url = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode()
assert body["image_urls"][0] == expected_data_url
def test_transform_response_maps_fal_images():
raw = httpx.Response(200, json={"images": [{"url": "https://fal.media/out.png"}]})
response = FalAIImageEditConfig().transform_image_edit_response(
model="openai/gpt-image-2.5/flare/edit", raw_response=raw, logging_obj=None
)
assert isinstance(response, ImageResponse)
assert [image.url for image in response.data] == ["https://fal.media/out.png"]
@pytest.mark.parametrize("image", [None, []])
def test_transform_request_requires_an_image(image):
with pytest.raises(ValueError, match="input image"):
FalAIImageEditConfig().transform_image_edit_request(
model="openai/gpt-image-2.5/flare/edit",
prompt="make it blue",
image=image,
image_edit_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)

View file

@ -0,0 +1,61 @@
import httpx
import pytest
from litellm.llms.fal_ai.image_generation import (
FalAIFluxDevConfig,
FalAIFluxSchnellConfig,
FalAIImageGenerationConfig,
get_fal_ai_image_generation_config,
)
from litellm.types.utils import ImageResponse
@pytest.mark.parametrize("model", ["fal-ai/flux/dev", "flux/dev", "flux-dev"])
def test_flux_dev_config_selected(model):
config = get_fal_ai_image_generation_config(model)
assert isinstance(config, FalAIFluxDevConfig)
assert not isinstance(config, FalAIImageGenerationConfig)
def test_flux_schnell_still_routes_to_schnell():
config = get_fal_ai_image_generation_config("fal-ai/flux/schnell")
assert isinstance(config, FalAIFluxSchnellConfig)
assert not isinstance(config, FalAIFluxDevConfig)
def test_flux_dev_url_targets_dev_endpoint():
url = FalAIFluxDevConfig().get_complete_url(
api_base=None, api_key="k", model="fal-ai/flux/dev", optional_params={}, litellm_params={}
)
assert url == "https://fal.run/fal-ai/flux/dev"
def test_flux_dev_maps_openai_params_and_builds_request():
config = FalAIFluxDevConfig()
optional_params = config.map_openai_params(
non_default_params={"n": 2, "size": "1024x1024", "response_format": "b64_json"},
optional_params={},
model="fal-ai/flux/dev",
drop_params=False,
)
body = config.transform_image_generation_request(
model="fal-ai/flux/dev", prompt="a cat", optional_params=optional_params, litellm_params={}, headers={}
)
assert body["prompt"] == "a cat"
assert body["num_images"] == 2
assert body["image_size"] == "square_hd"
def test_flux_dev_response_yields_one_image_object_per_fal_image():
raw = httpx.Response(200, json={"images": [{"url": "https://fal.media/a.png"}, {"url": "https://fal.media/b.png"}]})
response = FalAIFluxDevConfig().transform_image_generation_response(
model="fal-ai/flux/dev",
raw_response=raw,
model_response=ImageResponse(),
logging_obj=None,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert [image.url for image in response.data] == ["https://fal.media/a.png", "https://fal.media/b.png"]

View file

@ -7,6 +7,10 @@ from litellm.llms.fal_ai.image_generation import (
FalAINanoBananaConfig,
get_fal_ai_image_generation_config,
)
from litellm.llms.fal_ai.image_generation.gpt_image_2_transformation import (
map_gpt_image_quality,
supported_gpt_image_qualities,
)
from litellm.types.utils import ImageObject, ImageResponse
@ -127,3 +131,57 @@ def test_transform_image_generation_request():
) == {"prompt": "a red bicycle", "quality": "high", "num_images": 2}
@pytest.mark.parametrize(
"model",
[
"openai/gpt-image-2.5/flare/text-to-image",
"openai/gpt-image-2.5/sunburst/text-to-image",
],
)
def test_gpt_image_25_routes_to_its_own_fal_endpoint(model):
config = get_fal_ai_image_generation_config(model)
assert isinstance(config, FalAIGPTImage2Config)
assert (
config.get_complete_url(api_base=None, api_key="k", model=model, optional_params={}, litellm_params={})
== f"https://fal.run/{model}"
)
@pytest.mark.parametrize(
"model,quality,expected",
[
("openai/gpt-image-2.5/flare/text-to-image", "xhigh", "xhigh"),
("openai/gpt-image-2.5/sunburst/text-to-image", "max", "max"),
("openai/gpt-image-2.5/flare/text-to-image", "hd", "high"),
("openai/gpt-image-2", "xhigh", "auto"),
("openai/gpt-image-2", "max", "auto"),
],
)
def test_map_openai_params_quality_tiers_follow_model(model, quality, expected):
assert FalAIGPTImage2Config().map_openai_params(
non_default_params={"quality": quality},
optional_params={},
model=model,
drop_params=False,
) == {"quality": expected}
@pytest.mark.parametrize(
"model",
[
"some-new-model",
"openai/some-new-model",
"fal_ai/openai/some-new-model",
],
)
def test_supported_qualities_derived_from_pricing_rows(model):
model_cost = {
"fal_ai/xhigh/1024-x-1024/openai/some-new-model": {},
"fal_ai/low/1024-x-1024/openai/some-new-model": {},
"fal_ai/max/1024-x-1024/openai/other-model": {},
}
assert supported_gpt_image_qualities(model, model_cost) == {"xhigh", "low", "auto"}
def test_map_gpt_image_quality_passes_through_when_no_pricing_rows():
assert map_gpt_image_quality("xhigh", "some-new-model", {}) == "xhigh"

View file

@ -0,0 +1,90 @@
import pytest
import litellm
from litellm.litellm_core_utils.llm_cost_calc.utils import CostCalculatorUtils
from litellm.llms.fal_ai.cost_calculator import cost_calculator
from litellm.types.utils import ImageObject, ImageResponse
@pytest.fixture(autouse=True)
def _use_local_model_cost_map(monkeypatch):
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
litellm.get_model_info.cache_clear()
yield
litellm.get_model_info.cache_clear()
def _image_response(num_images: int = 1) -> ImageResponse:
return ImageResponse(data=[ImageObject(url="https://example.com/img.png") for _ in range(num_images)])
GPT_IMAGE_25_MODELS = (
"openai/gpt-image-2.5/flare/text-to-image",
"openai/gpt-image-2.5/flare/edit",
"openai/gpt-image-2.5/sunburst/text-to-image",
"openai/gpt-image-2.5/sunburst/edit",
)
@pytest.mark.parametrize("model", GPT_IMAGE_25_MODELS)
def test_gpt_image_25_default_request_matches_high_1024x768_keyed_row(model):
default_cost = cost_calculator(model=f"fal_ai/{model}", image_response=_image_response(), optional_params={})
keyed_cost = litellm.model_cost[f"fal_ai/high/1024-x-768/{model}"]["output_cost_per_image"]
assert default_cost == keyed_cost > 0
@pytest.mark.parametrize("model", GPT_IMAGE_25_MODELS)
def test_gpt_image_25_quality_and_size_pick_keyed_row(model):
cost = cost_calculator(
model=f"fal_ai/{model}",
image_response=_image_response(num_images=2),
optional_params={"quality": "max", "image_size": {"width": 3840, "height": 2160}},
)
assert cost == 2 * litellm.model_cost[f"fal_ai/max/3840-x-2160/{model}"]["output_cost_per_image"] > 0
def test_gpt_image_25_edit_auto_size_still_honors_quality():
model = "fal_ai/openai/gpt-image-2.5/flare/edit"
low = cost_calculator(
model=model, image_response=_image_response(), optional_params={"quality": "low", "image_size": "auto"}
)
high = cost_calculator(
model=model, image_response=_image_response(), optional_params={"quality": "high", "image_size": "auto"}
)
assert 0 < low < high
def test_gpt_image_25_quality_tiers_are_monotonic():
costs = tuple(
cost_calculator(
model="fal_ai/openai/gpt-image-2.5/sunburst/text-to-image",
image_response=_image_response(),
optional_params={"quality": quality, "image_size": "square_hd"},
)
for quality in ("low", "medium", "high", "xhigh", "max")
)
assert costs == tuple(sorted(costs)) and len(set(costs)) == len(costs)
def test_flux_dev_cost_is_nonzero_and_distinct_from_schnell():
dev = cost_calculator(
model="fal_ai/fal-ai/flux/dev", image_response=_image_response(num_images=3), optional_params={}
)
schnell = cost_calculator(
model="fal_ai/fal-ai/flux/schnell", image_response=_image_response(num_images=3), optional_params={}
)
assert dev > schnell > 0
assert dev == 3 * litellm.model_cost["fal_ai/fal-ai/flux/dev"]["output_cost_per_image"]
def test_image_edit_call_type_routes_to_fal_keyed_pricing():
model = "openai/gpt-image-2.5/flare/edit"
cost = CostCalculatorUtils.route_image_generation_cost_calculator(
model=model,
completion_response=_image_response(),
custom_llm_provider="fal_ai",
optional_params={"quality": "medium", "image_size": {"width": 1024, "height": 1024}},
call_type="aimage_edit",
)
assert cost == litellm.model_cost[f"fal_ai/medium/1024-x-1024/{model}"]["output_cost_per_image"] > 0