mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
commit
246a6ea54a
12 changed files with 355 additions and 74 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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, []])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue