Merge pull request #42282 from BerriAI/litellm_fal_price_from_response_dims

fix(fal_ai): price images from the dimensions fal returns
This commit is contained in:
kerry-berri 2026-09-21 13:08:49 -07:00 • committed by GitHub
commit 246a6ea54a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 355 additions and 74 deletions

View file

@ -1,12 +1,16 @@
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"
FAL_PIXELS_PER_MEGAPIXEL: Final[int] = 1_048_576
FAL_NAMED_IMAGE_SIZES: Final[Mapping[str, str]] = MappingProxyType(
{
"square_hd": "1024-x-1024",
@ -18,14 +22,17 @@ FAL_NAMED_IMAGE_SIZES: Final[Mapping[str, str]] = MappingProxyType(
}
)
_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 +41,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 _image_dimensions(image: object) -> tuple[int, int] | 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 type(width) is not int or width <= 0 or type(height) is not int or height <= 0:
return None
return width, height
def _response_size(image: object) -> str | None:
dimensions: Final = _image_dimensions(image)
if dimensions is None:
return None
width, height = dimensions
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],
) -> 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 = _entry(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 _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 = ceil(width * height / FAL_PIXELS_PER_MEGAPIXEL)
return output_cost_per_pixel * FAL_PIXELS_PER_MEGAPIXEL * megapixels
def _entry(key: str) -> Mapping[str, object] | None:
raw_entry: Final[object] = litellm.model_cost.get(key) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # global catalog is untyped
if not isinstance(raw_entry, Mapping):
return None
keyed_cost: Final = keyed_entry.get("output_cost_per_image")
return float(keyed_cost) if isinstance(keyed_cost, (int, float)) else None
return _OBJECT_MAP.validate_python(raw_entry)
def cost_calculator(
@ -61,15 +118,36 @@ 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,
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 ())
keyed_costs: Final = tuple(
_keyed_cost_per_image(
model=normalized_model,
image=image,
optional_params=params,
)
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: Final = litellm.get_model_info(
model=normalized_model,
custom_llm_provider=litellm.LlmProviders.FAL_AI.value,
)
output_cost_per_image: Final[float] = _model_info.get("output_cost_per_image") or 0.0
return output_cost_per_image * num_images
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
)

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) and type(width) is int and width > 0 else {}),
**({"height": height} if isinstance(height, int) and type(height) is int and height > 0 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

@ -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.384185791015625e-08,
"source": "https://fal.ai/models/fal-ai/flux/dev",
"supported_endpoints": [
"/v1/images/generations"

View file

@ -315,6 +315,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
output_cost_per_token_above_512k_tokens: float | None # MiniMax-M3: prompts >512K priced at 2x output
output_cost_per_character_above_128k_tokens: float | None # only for vertex ai models
output_cost_per_image: float | None
output_cost_per_pixel: ReadOnly[float | None]
output_cost_per_image_token: float | None
output_cost_per_video_token: float | None # for gemini omni models with video output
output_vector_size: int | None
@ -2551,6 +2552,10 @@ class ImageResponse(OpenAIImageResponse, BaseLiteLLMOpenAIResponseObject):
model_config = ConfigDict(extra="allow", protected_namespaces=())
@field_serializer("data")
def _serialize_image_data(self, data: Sequence[OpenAIImage] | None) -> Sequence[Mapping[str, object]] | None:
return None if data is None else [image.model_dump() for image in data]
def __init__(
self,
created: int | None = None,

View file

@ -6103,6 +6103,7 @@ def _get_model_info_helper(
output_cost_per_second_4k=_model_info.get("output_cost_per_second_4k", None),
output_cost_per_video_per_second=_model_info.get("output_cost_per_video_per_second", None),
output_cost_per_image=_model_info.get("output_cost_per_image", None),
output_cost_per_pixel=_model_info.get("output_cost_per_pixel", None),
output_cost_per_image_token=_model_info.get("output_cost_per_image_token", None),
output_cost_per_video_token=_model_info.get("output_cost_per_video_token", None),
output_vector_size=_model_info.get("output_vector_size", None),

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.384185791015625e-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(3 * _catalog_cost("fal_ai/fal-ai/flux/dev", "output_cost_per_pixel") * 1_048_576)
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,60 @@ 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
@pytest.mark.parametrize(
"invalid_field, invalid_value, expected_fields",
(
("width", True, {"height": 768, "content_type": "image/png"}),
("width", 0, {"height": 768, "content_type": "image/png"}),
("width", -1, {"height": 768, "content_type": "image/png"}),
("height", True, {"width": 1024, "content_type": "image/png"}),
("height", 0, {"width": 1024, "content_type": "image/png"}),
("height", -1, {"width": 1024, "content_type": "image/png"}),
),
)
def test_flux_dev_response_drops_invalid_dimension_metadata(invalid_field, invalid_value, expected_fields):
metadata = {"width": 1024, "height": 768, "content_type": "image/png"}
metadata[invalid_field] = invalid_value
raw = httpx.Response(
200,
json={
"images": [
{
"url": "https://fal.media/a.png",
**metadata,
}
]
},
)
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 == expected_fields

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,44 @@ 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_048_576 * (1 + 2 + 1))
@pytest.mark.parametrize(
"dimensions",
(
((True, 1024),),
((1024, 0),),
((-1, 1024),),
),
)
def test_flux_dev_invalid_response_dimensions_use_flat_price(dimensions):
model = "fal_ai/fal-ai/flux/dev"
cost = cost_calculator(
model=model,
image_response=_image_response_with_dimensions(dimensions),
optional_params={},
)
assert cost == litellm.model_cost[model]["output_cost_per_image"] * len(dimensions)
def test_unknown_fal_model_raises_when_flat_pricing_is_needed():
with pytest.raises(Exception, match="isn't mapped yet"):
cost_calculator(
model="fal_ai/fal-ai/unknown-model",
image_response=_image_response(),
optional_params={},
)
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(