mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
commit
12379aa1e3
16 changed files with 3453 additions and 46 deletions
|
|
@ -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")
|
||||
|
|
|
|||
3
litellm/llms/fal_ai/image_edit/__init__.py
Normal file
3
litellm/llms/fal_ai/image_edit/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .transformation import FalAIImageEditConfig
|
||||
|
||||
__all__ = ("FalAIImageEditConfig",)
|
||||
179
litellm/llms/fal_ai/image_edit/transformation.py
Normal file
179
litellm/llms/fal_ai/image_edit/transformation.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
176
tests/integration/providers/test_fal_ai_image_wire.py
Normal file
176
tests/integration/providers/test_fal_ai_image_wire.py
Normal 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")
|
||||
]
|
||||
|
|
@ -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={},
|
||||
)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
90
tests/test_litellm/llms/fal_ai/test_cost_calculator.py
Normal file
90
tests/test_litellm/llms/fal_ai/test_cost_calculator.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue