fix(fal_ai): accept every FileTypes image input and derive gpt-image qualities from pricing metadata

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kerry 2026-09-20 05:31:27 +00:00
parent 365dc9a3b5
commit 62c215be19
4 changed files with 88 additions and 10 deletions

View file

@ -1,8 +1,9 @@
import base64
import os
from collections.abc import Mapping
from io import BufferedReader, BytesIO
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
import httpx
from httpx._types import RequestFiles
@ -35,16 +36,39 @@ PARAM_TRANSLATION: Final[Mapping[str, str]] = MappingProxyType(
)
@runtime_checkable
class _Readable(Protocol):
def read(self) -> bytes: ...
@runtime_checkable
class _Tellable(Protocol):
def tell(self) -> int: ...
@runtime_checkable
class _Seekable(Protocol):
def seek(self, position: int) -> int: ...
def _read_image_bytes(image: object) -> bytes:
if isinstance(image, bytes):
return image
if isinstance(image, (BytesIO, BufferedReader)):
position: Final = image.tell()
if isinstance(image, tuple) and len(image) >= 2:
return _read_image_bytes(image[1])
if isinstance(image, os.PathLike):
return Path(image).read_bytes()
if isinstance(image, str):
raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
if not hasattr(image, "read") or not isinstance(image, _Readable):
raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
position: Final = image.tell() if hasattr(image, "tell") and isinstance(image, _Tellable) else 0
if hasattr(image, "seek") and isinstance(image, _Seekable):
image.seek(0)
data: Final = image.read()
data: Final = image.read()
if hasattr(image, "seek") and isinstance(image, _Seekable):
image.seek(position)
return data
raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
return data
def _to_data_url(image: object) -> str:

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
@ -23,8 +24,6 @@ SUPPORTED_OPENAI_PARAMS: Final[tuple[OpenAIImageGenerationOptionalParams, ...]]
"size",
)
SUPPORTED_QUALITIES: Final[frozenset[str]] = frozenset({"auto", "low", "medium", "high"})
GPT_IMAGE_25_QUALITIES: Final[frozenset[str]] = SUPPORTED_QUALITIES | frozenset(("xhigh", "max"))
GPT_IMAGE_25_MARKER: Final[str] = "gpt-image-2.5"
OPENAI_QUALITY_ALIASES: Final[Mapping[str, str]] = MappingProxyType({"hd": "high", "standard": "medium"})
@ -40,7 +39,15 @@ def map_gpt_image_size(size: object) -> object:
def supported_gpt_image_qualities(model: str) -> frozenset[str]:
return GPT_IMAGE_25_QUALITIES if GPT_IMAGE_25_MARKER in model.lower() else SUPPORTED_QUALITIES
suffix: Final = f"/{model}"
keyed: Final = frozenset(
key.removeprefix("fal_ai/").split("/")[0]
for key in litellm.model_cost
if key.startswith("fal_ai/")
and key.endswith(suffix)
and key.removeprefix("fal_ai/").removesuffix(suffix).count("/") == 1
)
return keyed | frozenset(("auto",)) if keyed else SUPPORTED_QUALITIES
def map_gpt_image_quality(quality: object, model: str) -> object:

View file

@ -1,6 +1,8 @@
import base64
import io
import json
from pathlib import Path
from typing import Final
import httpx
import pytest
@ -14,6 +16,20 @@ from litellm.utils import ProviderConfigManager
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
class GenericFileLike:
def __init__(self, data: bytes):
self._buffer = io.BytesIO(data)
def read(self) -> bytes:
return self._buffer.read()
def seek(self, position: int) -> int:
return self._buffer.seek(position)
def tell(self) -> int:
return self._buffer.tell()
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
@ -85,6 +101,29 @@ def test_transform_request_inlines_local_images_as_data_urls_and_keeps_remote_ur
assert "mask" not in body
@pytest.mark.parametrize("input_kind", ("path", "tuple_bytes", "tuple_file_like", "file_like"))
def test_transform_request_accepts_openai_file_types(tmp_path, input_kind):
image_path: Final[Path] = tmp_path / "in.png"
image_path.write_bytes(PNG_BYTES)
image: Final[object] = {
"path": image_path,
"tuple_bytes": ("in.png", PNG_BYTES),
"tuple_file_like": ("in.png", io.BytesIO(PNG_BYTES), "image/png"),
"file_like": GenericFileLike(PNG_BYTES),
}[input_kind]
expected_data_url: Final = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode()
body, files = FalAIImageEditConfig().transform_image_edit_request(
model="openai/gpt-image-2",
prompt="make it blue",
image=image,
image_edit_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert files == ()
assert body["image_urls"] == (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(

View file

@ -7,6 +7,7 @@ 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
from litellm.types.utils import ImageObject, ImageResponse
@ -160,3 +161,10 @@ def test_map_openai_params_quality_tiers_follow_model(model, quality, expected):
model=model,
drop_params=False,
) == {"quality": expected}
def test_map_gpt_image_quality_derives_supported_tiers_from_pricing_metadata():
assert map_gpt_image_quality("xhigh", "openai/gpt-image-2.5/flare/text-to-image") == "xhigh"
assert map_gpt_image_quality("xhigh", "openai/gpt-image-2") == "auto"
assert map_gpt_image_quality("xhigh", "openai/unknown-model") == "auto"
assert map_gpt_image_quality("high", "openai/unknown-model") == "high"