fix(fal_ai): price images from the dimensions fal returns

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kerry 2026-09-21 19:00:12 +00:00
parent 9a90adad32
commit adc4e6a132
19 changed files with 304 additions and 83 deletions

View file

@ -181,7 +181,7 @@ class AmazonNovaCanvasConfig:
for _img in nova_response.get("images", []):
openai_images.append(Image(b64_json=_img))
model_response.data = openai_images
model_response.data = openai_images # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type
return model_response
@classmethod

View file

@ -125,7 +125,7 @@ class AmazonStabilityConfig:
_image = Image(b64_json=artifact["base64"])
image_list.append(_image)
model_response.data = image_list
model_response.data = image_list # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type
return model_response

View file

@ -101,7 +101,7 @@ class AmazonStability3Config:
for _img in stability_3_response.get("images", []):
openai_images.append(Image(b64_json=_img))
model_response.data = openai_images
model_response.data = openai_images # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type
return model_response
@classmethod

View file

@ -134,7 +134,7 @@ class AmazonTitanImageGenerationConfig:
_image = Image(b64_json=image)
image_list.append(_image)
model_response.data = image_list
model_response.data = image_list # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type
return model_response

View file

@ -1,9 +1,12 @@
from collections.abc import Mapping
from math import ceil
from types import MappingProxyType
from typing import Final
from pydantic import TypeAdapter
import litellm
from litellm.types.utils import ImageResponse
from litellm.types.utils import ImageObject, ImageResponse
FAL_KEYED_PRICING_DEFAULT_QUALITY: Final[str] = "high"
FAL_TEXT_TO_IMAGE_DEFAULT_SIZE: Final[str] = "1024-x-768"
@ -18,14 +21,20 @@ FAL_NAMED_IMAGE_SIZES: Final[Mapping[str, str]] = MappingProxyType(
}
)
_MODEL_COST_MAP: Final[TypeAdapter[Mapping[str, Mapping[str, object]]]] = TypeAdapter(
Mapping[str, Mapping[str, object]]
)
_OBJECT_MAP: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
def _keyed_size(optional_params: Mapping[str, object]) -> str | None:
image_size: Final = optional_params.get("image_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")
image_size_map: Final = _OBJECT_MAP.validate_python(image_size)
width: Final = image_size_map.get("width")
height: Final = image_size_map.get("height")
if isinstance(width, int) and isinstance(height, int):
return f"{width}-x-{height}"
return None
@ -34,21 +43,71 @@ def _keyed_size(optional_params: Mapping[str, object]) -> str | None:
return None
def _keyed_cost_per_image(model: str, optional_params: Mapping[str, object] | None) -> float | None:
if optional_params is None:
def _response_size(image: object) -> str | None:
if not isinstance(image, ImageObject):
return None
size: Final = _keyed_size(optional_params)
if size is None:
raw_provider_specific_fields: Final = image.provider_specific_fields
if not isinstance(raw_provider_specific_fields, Mapping):
return None
provider_specific_fields: Final = _OBJECT_MAP.validate_python(raw_provider_specific_fields)
width: Final = provider_specific_fields.get("width")
height: Final = provider_specific_fields.get("height")
if not isinstance(width, int) or not isinstance(height, int):
return None
return f"{width}-x-{height}"
def _keyed_quality(optional_params: Mapping[str, object]) -> str:
raw_quality: Final = optional_params.get("quality")
quality: Final = (
raw_quality if isinstance(raw_quality, str) and raw_quality != "auto" else FAL_KEYED_PRICING_DEFAULT_QUALITY
)
keyed_entry: Final = litellm.model_cost.get(f"fal_ai/{quality}/{size}/{model}")
if keyed_entry is None:
return raw_quality if isinstance(raw_quality, str) and raw_quality != "auto" else FAL_KEYED_PRICING_DEFAULT_QUALITY
def _keyed_cost_per_image(
model: str,
image: object,
optional_params: Mapping[str, object],
model_cost_map: Mapping[str, Mapping[str, object]],
) -> float | None:
quality: Final = _keyed_quality(optional_params)
request_size: Final = _keyed_size(optional_params) or FAL_TEXT_TO_IMAGE_DEFAULT_SIZE
sizes: Final = (_response_size(image), request_size, FAL_TEXT_TO_IMAGE_DEFAULT_SIZE)
for size in sizes:
if size is None:
continue
keyed_entry = model_cost_map.get(f"fal_ai/{quality}/{size}/{model}")
if keyed_entry is None:
continue
keyed_cost = keyed_entry.get("output_cost_per_image")
if isinstance(keyed_cost, (int, float)):
return float(keyed_cost)
return None
def _image_dimensions(image: object) -> tuple[int, int] | None:
if not isinstance(image, ImageObject):
return None
keyed_cost: Final = keyed_entry.get("output_cost_per_image")
return float(keyed_cost) if isinstance(keyed_cost, (int, float)) else None
raw_provider_specific_fields: Final = image.provider_specific_fields
if not isinstance(raw_provider_specific_fields, Mapping):
return None
provider_specific_fields: Final = _OBJECT_MAP.validate_python(raw_provider_specific_fields)
width: Final = provider_specific_fields.get("width")
height: Final = provider_specific_fields.get("height")
if not isinstance(width, int) or not isinstance(height, int):
return None
return width, height
def _flat_cost_per_image(
image: object,
output_cost_per_image: float,
output_cost_per_pixel: float | None,
) -> float:
dimensions: Final = _image_dimensions(image)
if dimensions is None or output_cost_per_pixel is None:
return output_cost_per_image
width, height = dimensions
megapixels: Final = 1 if (width, height) == (1024, 1024) else ceil(width * height / 1_000_000)
return output_cost_per_pixel * 1_000_000 * megapixels
def cost_calculator(
@ -61,15 +120,44 @@ def cost_calculator(
"""
if not isinstance(image_response, ImageResponse):
raise ValueError(f"image_response must be of type ImageResponse got type={type(image_response)}")
# the proxy cost path passes the provider-prefixed model name
model = model.removeprefix(f"{litellm.LlmProviders.FAL_AI.value}/")
num_images: Final[int] = len(image_response.data) if image_response.data else 0
keyed_cost_per_image: Final = _keyed_cost_per_image(model=model, optional_params=optional_params)
if keyed_cost_per_image is not None:
return keyed_cost_per_image * num_images
_model_info: Final = litellm.get_model_info(
model=model,
custom_llm_provider=litellm.LlmProviders.FAL_AI.value,
normalized_model: Final = model.removeprefix(f"{litellm.LlmProviders.FAL_AI.value}/")
params: Final[Mapping[str, object]] = optional_params or MappingProxyType({})
images: Final = tuple(image_response.data or ())
raw_model_cost: Final[object] = litellm.model_cost # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # global catalog is untyped
model_cost_map: Final = _MODEL_COST_MAP.validate_python(raw_model_cost)
keyed_costs: Final = tuple(
_keyed_cost_per_image(
model=normalized_model,
image=image,
optional_params=params,
model_cost_map=model_cost_map,
)
for image in images
)
if all(cost is not None for cost in keyed_costs):
return sum(cost for cost in keyed_costs if cost is not None)
model_info_entry: Final = next(
(
entry
for key in (f"{litellm.LlmProviders.FAL_AI.value}/{normalized_model}", normalized_model)
if (entry := model_cost_map.get(key)) is not None
),
None,
)
model_info: Final = _OBJECT_MAP.validate_python(model_info_entry or MappingProxyType({}))
raw_output_cost_per_image: Final = model_info.get("output_cost_per_image")
output_cost_per_image: Final = (
float(raw_output_cost_per_image) if isinstance(raw_output_cost_per_image, (int, float)) else 0.0
)
raw_output_cost_per_pixel: Final = model_info.get("output_cost_per_pixel")
output_cost_per_pixel: Final = (
float(raw_output_cost_per_pixel) if isinstance(raw_output_cost_per_pixel, (int, float)) else None
)
return sum(
_flat_cost_per_image(
image=image,
output_cost_per_image=output_cost_per_image,
output_cost_per_pixel=output_cost_per_pixel,
)
for image in images
)
output_cost_per_image: Final[float] = _model_info.get("output_cost_per_image") or 0.0
return output_cost_per_image * num_images

View file

@ -3,9 +3,9 @@ from typing import TYPE_CHECKING, Any, Final
import httpx
from litellm.types.llms.openai import OpenAIImageGenerationOptionalParams
from litellm.types.utils import ImageObject, ImageResponse
from litellm.types.utils import ImageResponse
from .transformation import FalAIBaseConfig
from .transformation import FalAIBaseConfig, fal_images_to_image_objects
if TYPE_CHECKING:
import tiktoken
@ -229,25 +229,8 @@ class FalAIFluxProV11UltraConfig(FalAIBaseConfig):
if not model_response.data:
model_response.data = []
# Handle Flux Pro v1.1-ultra 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=None, # Flux Pro returns URLs only
)
)
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(images))
# Add additional metadata from Flux Pro response
if hasattr(model_response, "_hidden_params"):

View file

@ -51,7 +51,7 @@ def supported_gpt_image_qualities(
and "-x-" in parts[2]
and "/".join(parts[3:]) == qualified_endpoint
)
return qualities | {"auto"} if qualities else frozenset()
return qualities | frozenset({"auto"}) if qualities else frozenset()
def map_gpt_image_quality(

View file

@ -1,6 +1,9 @@
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Final
import httpx
from pydantic import TypeAdapter
from typing_extensions import ReadOnly, TypedDict
from litellm.llms.base_llm.image_generation.transformation import (
BaseImageGenerationConfig,
@ -22,16 +25,40 @@ else:
LiteLLMLoggingObj = Any
class FalImageProviderSpecificFields(TypedDict, total=False):
width: ReadOnly[int]
height: ReadOnly[int]
content_type: ReadOnly[str]
_FAL_IMAGE_DATA: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
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))
)
def to_image_object(image_data: object) -> ImageObject:
if isinstance(image_data, Mapping):
image_map: Final = _FAL_IMAGE_DATA.validate_python(image_data)
url: Final = image_map.get("url")
b64_json: Final = image_map.get("b64_json")
width: Final = image_map.get("width")
height: Final = image_map.get("height")
content_type: Final = image_map.get("content_type")
provider_specific_fields: Final[FalImageProviderSpecificFields] = {
**({"width": width} if isinstance(width, int) else {}),
**({"height": height} if isinstance(height, int) else {}),
**({"content_type": content_type} if isinstance(content_type, str) else {}),
}
return ImageObject(
url=url if isinstance(url, str) else None,
b64_json=b64_json if isinstance(b64_json, str) else None,
provider_specific_fields=provider_specific_fields or None,
)
return ImageObject(url=image_data if isinstance(image_data, str) else None, b64_json=None)
return tuple(to_image_object(image_data) for image_data in images if isinstance(image_data, (Mapping, str)))
class FalAIBaseConfig(BaseImageGenerationConfig):

View file

@ -148,7 +148,7 @@ class GeminiImageEditConfig(BaseImageEditConfig):
)
)
model_response.data = cast(list[OpenAIImage], data_list)
model_response.data = cast(list[OpenAIImage], data_list) # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type
if "usageMetadata" in response_json:
model_response.usage = transform_gemini_image_usage(response_json["usageMetadata"])
return model_response

View file

@ -219,7 +219,7 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM):
if (inline_data := part.get("inlineData")) and (b64_json := inline_data.get("data"))
]
model_response.data = cast(list[OpenAIImage], data_list)
model_response.data = cast(list[OpenAIImage], data_list) # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type
return model_response
def _map_size_to_aspect_ratio(self, size: str) -> str:

View file

@ -220,7 +220,7 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM):
)
)
model_response.data = cast(list[OpenAIImage], data_list)
model_response.data = cast(list[OpenAIImage], data_list) # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type
return model_response
def _map_size_to_aspect_ratio(self, size: str) -> str:

View file

@ -40,7 +40,7 @@ class VertexImageGeneration(VertexLLM):
image_object = Image(b64_json=bytes_base64_encoded)
response_data.append(image_object)
model_response.data = response_data
model_response.data = response_data # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type
return model_response
def transform_optional_params(self, optional_params: dict | None) -> dict:

View file

@ -24917,10 +24917,11 @@
"fal_ai/fal-ai/flux/dev": {
"litellm_provider": "fal_ai",
"metadata": {
"notes": "fal bills FLUX.1 [dev] at $0.025 per megapixel, rounding each image up to the nearest megapixel. Every named fal image_size (including the landscape_4_3 default) rounds up to 1 megapixel, so this flat per-image price is exact for them"
"notes": "fal bills FLUX.1 [dev] at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price is the fallback when dimensions are unavailable"
},
"mode": "image_generation",
"output_cost_per_image": 0.025,
"output_cost_per_pixel": 2.5e-08,
"source": "https://fal.ai/models/fal-ai/flux/dev",
"supported_endpoints": [
"/v1/images/generations"

View file

@ -2543,6 +2543,7 @@ from openai.types.images_response import ImagesResponse as OpenAIImageResponse
class ImageResponse(OpenAIImageResponse, BaseLiteLLMOpenAIResponseObject):
_hidden_params: dict = {}
data: list[ImageObject]
usage: ImageUsage | None = None
"""
Users might use litellm with older python versions, we don't want this to break for them.

View file

@ -24917,10 +24917,11 @@
"fal_ai/fal-ai/flux/dev": {
"litellm_provider": "fal_ai",
"metadata": {
"notes": "fal bills FLUX.1 [dev] at $0.025 per megapixel, rounding each image up to the nearest megapixel. Every named fal image_size (including the landscape_4_3 default) rounds up to 1 megapixel, so this flat per-image price is exact for them"
"notes": "fal bills FLUX.1 [dev] at $0.025 per megapixel, rounding each image up to the nearest megapixel. The per-pixel rate is used when Fal reports the output size, and the flat per-image price is the fallback when dimensions are unavailable"
},
"mode": "image_generation",
"output_cost_per_image": 0.025,
"output_cost_per_pixel": 2.5e-08,
"source": "https://fal.ai/models/fal-ai/flux/dev",
"supported_endpoints": [
"/v1/images/generations"

View file

@ -23,14 +23,14 @@ _JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_COST_MAP: Final = TypeAdapter(dict[str, dict[str, object]])
def _catalog_cost(key: str) -> float:
def _catalog_cost(key: str, field: str = "output_cost_per_image") -> float:
cost_map: Final = _COST_MAP.validate_json(_COST_MAP_PATH.read_bytes())
cost_value: Final = cost_map[key]["output_cost_per_image"]
cost_value: Final = cost_map[key][field]
assert isinstance(cost_value, (int, float))
return float(cost_value)
def _image_response(urls: tuple[str, ...], prompt: str) -> bytes:
def _image_response(images: tuple[tuple[str, int, int], ...], prompt: str) -> bytes:
return json.dumps(
{
"images": [
@ -39,10 +39,10 @@ def _image_response(urls: tuple[str, ...], prompt: str) -> bytes:
"content_type": "image/png",
"file_name": url.rsplit("/", 1)[-1],
"file_size": 123456,
"width": 1024,
"height": 768,
"width": width,
"height": height,
}
for url in urls
for url, width, height in images
],
"timings": {"inference": 2.1},
"seed": 1234567,
@ -69,9 +69,9 @@ def test_fal_gpt_image_25_generation_sends_quality_and_size_and_charges_keyed_ro
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))
return Reply(body=_image_response(((f"{wire_url}/files/high.png", 1024, 1536),), _PROMPT))
assert body == {"prompt": _PROMPT, "quality": "low"}
return Reply(body=_image_response((f"{wire_url}/files/low.png",), _PROMPT))
return Reply(body=_image_response(((f"{wire_url}/files/low.png", 1024, 1536),), _PROMPT))
with wire_server(respond) as wire, gateway.scenario() as scenario:
wire_url: Final = wire.url
@ -85,7 +85,14 @@ def test_fal_gpt_image_25_generation_sends_quality_and_size_and_charges_keyed_ro
)
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}]
assert high_payload["data"] == [
{
"url": f"{wire.url}/files/high.png",
"b64_json": None,
"revised_prompt": None,
"provider_specific_fields": {"width": 1024, "height": 1536, "content_type": "image/png"},
}
]
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"))
@ -96,9 +103,16 @@ def test_fal_gpt_image_25_generation_sends_quality_and_size_and_charges_keyed_ro
)
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}]
assert low_payload["data"] == [
{
"url": f"{wire.url}/files/low.png",
"b64_json": None,
"revised_prompt": None,
"provider_specific_fields": {"width": 1024, "height": 1536, "content_type": "image/png"},
}
]
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 low_cost == _approx(_catalog_cost("fal_ai/low/1024-x-1536/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"),
@ -119,7 +133,7 @@ def test_fal_flux_dev_generation_targets_dev_endpoint_and_charges_per_image(gate
}
return Reply(
body=_image_response(
(f"{wire_url}/files/flux-1.png", f"{wire_url}/files/flux-2.png"),
((f"{wire_url}/files/flux-1.png", 1024, 1024), (f"{wire_url}/files/flux-2.png", 1920, 1080)),
_PROMPT,
)
)
@ -135,11 +149,21 @@ def test_fal_flux_dev_generation_targets_dev_endpoint_and_charges_per_image(gate
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},
{
"url": f"{wire.url}/files/flux-1.png",
"b64_json": None,
"revised_prompt": None,
"provider_specific_fields": {"width": 1024, "height": 1024, "content_type": "image/png"},
},
{
"url": f"{wire.url}/files/flux-2.png",
"b64_json": None,
"revised_prompt": None,
"provider_specific_fields": {"width": 1920, "height": 1080, "content_type": "image/png"},
},
]
cost: Final = _response_cost(response)
assert cost == _approx(2 * _catalog_cost("fal_ai/fal-ai/flux/dev"))
assert cost == _approx(4 * _catalog_cost("fal_ai/fal-ai/flux/dev", "output_cost_per_pixel") * 1_000_000)
assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/fal-ai/flux/dev")]
@ -155,7 +179,7 @@ def test_fal_gpt_image_25_edit_inlines_upload_as_data_url_and_charges_keyed_row(
"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))
return Reply(body=_image_response(((f"{wire_url}/files/edit.png", 1024, 1536),), _PROMPT))
with wire_server(respond) as wire, gateway.scenario() as scenario:
wire_url: Final = wire.url
@ -168,9 +192,16 @@ def test_fal_gpt_image_25_edit_inlines_upload_as_data_url_and_charges_keyed_row(
)
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}]
assert payload["data"] == [
{
"url": f"{wire.url}/files/edit.png",
"b64_json": None,
"revised_prompt": None,
"provider_specific_fields": {"width": 1024, "height": 1536, "content_type": "image/png"},
}
]
cost: Final = _response_cost(response)
assert cost == _approx(_catalog_cost("fal_ai/low/1024-x-768/openai/gpt-image-2.5/flare/edit"))
assert cost == _approx(_catalog_cost("fal_ai/low/1024-x-1536/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

@ -120,12 +120,29 @@ def test_transform_request_reads_every_file_types_input(tmp_path, image_factory)
def test_transform_response_maps_fal_images():
raw = httpx.Response(200, json={"images": [{"url": "https://fal.media/out.png"}]})
raw = httpx.Response(
200,
json={
"images": [
{
"url": "https://fal.media/out.png",
"width": 1024,
"height": 1536,
"content_type": "image/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"]
assert response.data[0].provider_specific_fields == {
"width": 1024,
"height": 1536,
"content_type": "image/png",
}
@pytest.mark.parametrize("image", [None, []])

View file

@ -47,7 +47,15 @@ def test_flux_dev_maps_openai_params_and_builds_request():
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"}]})
raw = httpx.Response(
200,
json={
"images": [
{"url": "https://fal.media/a.png", "width": 1024, "height": 768, "content_type": "image/png"},
{"url": "https://fal.media/b.png", "width": 512, "height": 512, "content_type": "image/webp"},
]
},
)
response = FalAIFluxDevConfig().transform_image_generation_response(
model="fal-ai/flux/dev",
raw_response=raw,
@ -59,3 +67,22 @@ def test_flux_dev_response_yields_one_image_object_per_fal_image():
encoding=None,
)
assert [image.url for image in response.data] == ["https://fal.media/a.png", "https://fal.media/b.png"]
assert [image.provider_specific_fields for image in response.data] == [
{"width": 1024, "height": 768, "content_type": "image/png"},
{"width": 512, "height": 512, "content_type": "image/webp"},
]
def test_flux_dev_response_omits_provider_specific_fields_when_fal_omits_metadata():
raw = httpx.Response(200, json={"images": [{"url": "https://fal.media/a.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 response.data[0].provider_specific_fields is None

View file

@ -19,6 +19,18 @@ def _image_response(num_images: int = 1) -> ImageResponse:
return ImageResponse(data=[ImageObject(url="https://example.com/img.png") for _ in range(num_images)])
def _image_response_with_dimensions(dimensions: tuple[tuple[int, int], ...]) -> ImageResponse:
return ImageResponse(
data=[
ImageObject(
url=f"https://example.com/img-{index}.png",
provider_specific_fields={"width": width, "height": height},
)
for index, (width, height) in enumerate(dimensions)
]
)
GPT_IMAGE_25_MODELS = (
"openai/gpt-image-2.5/flare/text-to-image",
"openai/gpt-image-2.5/flare/edit",
@ -55,6 +67,28 @@ def test_gpt_image_25_edit_auto_size_still_honors_quality():
assert 0 < low < high
def test_gpt_image_response_dimensions_override_request_size():
model = "fal_ai/openai/gpt-image-2.5/flare/text-to-image"
cost = cost_calculator(
model=model,
image_response=_image_response_with_dimensions(((1024, 1536),)),
optional_params={"quality": "low", "image_size": {"width": 1024, "height": 768}},
)
expected = litellm.model_cost[f"fal_ai/low/1024-x-1536/{model.removeprefix('fal_ai/')}"]["output_cost_per_image"]
assert cost == expected
def test_gpt_image_response_dimensions_fall_back_to_request_size_when_unpriced():
model = "fal_ai/openai/gpt-image-2.5/flare/text-to-image"
cost = cost_calculator(
model=model,
image_response=_image_response_with_dimensions(((777, 888),)),
optional_params={"quality": "low", "image_size": {"width": 1024, "height": 1536}},
)
expected = litellm.model_cost[f"fal_ai/low/1024-x-1536/{model.removeprefix('fal_ai/')}"]["output_cost_per_image"]
assert cost == expected
def test_gpt_image_25_quality_tiers_are_monotonic():
costs = tuple(
cost_calculator(
@ -78,6 +112,17 @@ def test_flux_dev_cost_is_nonzero_and_distinct_from_schnell():
assert dev == 3 * litellm.model_cost["fal_ai/fal-ai/flux/dev"]["output_cost_per_image"]
def test_flux_dev_cost_uses_response_megapixels_per_image():
model = "fal_ai/fal-ai/flux/dev"
cost = cost_calculator(
model=model,
image_response=_image_response_with_dimensions(((1024, 1024), (1920, 1080), (512, 512))),
optional_params={},
)
output_cost_per_pixel = litellm.model_cost[model]["output_cost_per_pixel"]
assert cost == pytest.approx(output_cost_per_pixel * 1_000_000 * (1 + 3 + 1))
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(