mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
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:
parent
9a90adad32
commit
adc4e6a132
19 changed files with 304 additions and 83 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) 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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue