From 62c215be19a7b26cd8646c55f658fcadde724150 Mon Sep 17 00:00:00 2001 From: kerry Date: Sun, 20 Sep 2026 05:31:27 +0000 Subject: [PATCH] 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> --- .../llms/fal_ai/image_edit/transformation.py | 38 ++++++++++++++---- .../gpt_image_2_transformation.py | 13 +++++-- .../test_fal_ai_image_edit_transformation.py | 39 +++++++++++++++++++ .../test_fal_ai_gpt_image_2_transformation.py | 8 ++++ 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/litellm/llms/fal_ai/image_edit/transformation.py b/litellm/llms/fal_ai/image_edit/transformation.py index f0bb2820d7e..274e166e9f6 100644 --- a/litellm/llms/fal_ai/image_edit/transformation.py +++ b/litellm/llms/fal_ai/image_edit/transformation.py @@ -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: 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 index ce016b350d8..cfa36a65a6f 100644 --- a/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py +++ b/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py @@ -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: diff --git a/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py index d8df467f0f4..ce23f54a48e 100644 --- a/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py +++ b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py @@ -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( 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 5445c0cc1b4..262b9de7631 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 @@ -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"