mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-25 01:02:15 +00:00
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:
parent
365dc9a3b5
commit
62c215be19
4 changed files with 88 additions and 10 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue