From adc4e6a13284678ce322359e09aa88b3fe473a42 Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 19:00:12 +0000 Subject: [PATCH 1/5] fix(fal_ai): price images from the dimensions fal returns Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../amazon_nova_canvas_transformation.py | 2 +- .../amazon_stability1_transformation.py | 2 +- .../amazon_stability3_transformation.py | 2 +- .../amazon_titan_transformation.py | 2 +- litellm/llms/fal_ai/cost_calculator.py | 138 ++++++++++++++---- .../flux_pro_v11_ultra_transformation.py | 23 +-- .../gpt_image_2_transformation.py | 2 +- .../fal_ai/image_generation/transformation.py | 41 +++++- .../llms/gemini/image_edit/transformation.py | 2 +- .../vertex_gemini_transformation.py | 2 +- .../vertex_imagen_transformation.py | 2 +- .../image_generation_handler.py | 2 +- ...odel_prices_and_context_window_backup.json | 3 +- litellm/types/utils.py | 1 + model_prices_and_context_window.json | 3 +- .../providers/test_fal_ai_image_wire.py | 67 ++++++--- .../test_fal_ai_image_edit_transformation.py | 19 ++- .../test_fal_ai_flux_dev_transformation.py | 29 +++- .../llms/fal_ai/test_cost_calculator.py | 45 ++++++ 19 files changed, 304 insertions(+), 83 deletions(-) diff --git a/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py b/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py index ce61a6253f6..32f069be6c3 100644 --- a/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py +++ b/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py @@ -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 diff --git a/litellm/llms/bedrock/image_generation/amazon_stability1_transformation.py b/litellm/llms/bedrock/image_generation/amazon_stability1_transformation.py index df91e736239..a8ae5f6e1eb 100644 --- a/litellm/llms/bedrock/image_generation/amazon_stability1_transformation.py +++ b/litellm/llms/bedrock/image_generation/amazon_stability1_transformation.py @@ -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 diff --git a/litellm/llms/bedrock/image_generation/amazon_stability3_transformation.py b/litellm/llms/bedrock/image_generation/amazon_stability3_transformation.py index 98e4cbbfd4d..489440a7430 100644 --- a/litellm/llms/bedrock/image_generation/amazon_stability3_transformation.py +++ b/litellm/llms/bedrock/image_generation/amazon_stability3_transformation.py @@ -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 diff --git a/litellm/llms/bedrock/image_generation/amazon_titan_transformation.py b/litellm/llms/bedrock/image_generation/amazon_titan_transformation.py index e1b06791c9d..93b23672377 100644 --- a/litellm/llms/bedrock/image_generation/amazon_titan_transformation.py +++ b/litellm/llms/bedrock/image_generation/amazon_titan_transformation.py @@ -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 diff --git a/litellm/llms/fal_ai/cost_calculator.py b/litellm/llms/fal_ai/cost_calculator.py index f23bd1b46bc..497a792fd8a 100644 --- a/litellm/llms/fal_ai/cost_calculator.py +++ b/litellm/llms/fal_ai/cost_calculator.py @@ -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 diff --git a/litellm/llms/fal_ai/image_generation/flux_pro_v11_ultra_transformation.py b/litellm/llms/fal_ai/image_generation/flux_pro_v11_ultra_transformation.py index 228dd9257ce..6b8558b8124 100644 --- a/litellm/llms/fal_ai/image_generation/flux_pro_v11_ultra_transformation.py +++ b/litellm/llms/fal_ai/image_generation/flux_pro_v11_ultra_transformation.py @@ -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"): diff --git a/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py b/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py index 3dfc26f8f46..ca301662cf8 100644 --- a/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py +++ b/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py @@ -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( diff --git a/litellm/llms/fal_ai/image_generation/transformation.py b/litellm/llms/fal_ai/image_generation/transformation.py index 7f6a417e8a1..a5afdc41e9a 100644 --- a/litellm/llms/fal_ai/image_generation/transformation.py +++ b/litellm/llms/fal_ai/image_generation/transformation.py @@ -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): diff --git a/litellm/llms/gemini/image_edit/transformation.py b/litellm/llms/gemini/image_edit/transformation.py index e6c22dc60b4..594b4dfd4ce 100644 --- a/litellm/llms/gemini/image_edit/transformation.py +++ b/litellm/llms/gemini/image_edit/transformation.py @@ -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 diff --git a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py index 725a7f39917..6cf32733550 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py @@ -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: diff --git a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py index c6ad5928b74..d0485275bf3 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py @@ -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: diff --git a/litellm/llms/vertex_ai/image_generation/image_generation_handler.py b/litellm/llms/vertex_ai/image_generation/image_generation_handler.py index 6a5bb484540..d0fb6a2f937 100644 --- a/litellm/llms/vertex_ai/image_generation/image_generation_handler.py +++ b/litellm/llms/vertex_ai/image_generation/image_generation_handler.py @@ -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: diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 87edd1544ca..855ef1fb04d 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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" diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 5a80644347e..7c33579be5a 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -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. diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 87edd1544ca..855ef1fb04d 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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" diff --git a/tests/integration/providers/test_fal_ai_image_wire.py b/tests/integration/providers/test_fal_ai_image_wire.py index 23ab7e08c16..e0c05e82b33 100644 --- a/tests/integration/providers/test_fal_ai_image_wire.py +++ b/tests/integration/providers/test_fal_ai_image_wire.py @@ -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") ] diff --git a/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py index 65b04e1f1b8..6c55760b625 100644 --- a/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py +++ b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py @@ -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, []]) diff --git a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py index 09c9bc4b5f7..273b9151a0a 100644 --- a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py +++ b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py @@ -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 diff --git a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py index 989b5855803..05b6014c8e2 100644 --- a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py +++ b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py @@ -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( From 9b54c4b0779a83584f8c38fe002e8d8c9ffd5259 Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 19:08:16 +0000 Subject: [PATCH 2/5] refactor(fal_ai): bill flux dev per 1024x1024 megapixel and drop ImageResponse retyping Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../amazon_nova_canvas_transformation.py | 2 +- .../amazon_stability1_transformation.py | 2 +- .../amazon_stability3_transformation.py | 2 +- .../amazon_titan_transformation.py | 2 +- litellm/llms/fal_ai/cost_calculator.py | 99 +++++++++---------- .../llms/gemini/image_edit/transformation.py | 2 +- .../vertex_gemini_transformation.py | 2 +- .../vertex_imagen_transformation.py | 2 +- .../image_generation_handler.py | 2 +- ...odel_prices_and_context_window_backup.json | 2 +- litellm/types/utils.py | 5 +- model_prices_and_context_window.json | 2 +- .../providers/test_fal_ai_image_wire.py | 2 +- .../llms/fal_ai/test_cost_calculator.py | 2 +- 14 files changed, 63 insertions(+), 65 deletions(-) diff --git a/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py b/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py index 32f069be6c3..ce61a6253f6 100644 --- a/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py +++ b/litellm/llms/bedrock/image_generation/amazon_nova_canvas_transformation.py @@ -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 # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type + model_response.data = openai_images return model_response @classmethod diff --git a/litellm/llms/bedrock/image_generation/amazon_stability1_transformation.py b/litellm/llms/bedrock/image_generation/amazon_stability1_transformation.py index a8ae5f6e1eb..df91e736239 100644 --- a/litellm/llms/bedrock/image_generation/amazon_stability1_transformation.py +++ b/litellm/llms/bedrock/image_generation/amazon_stability1_transformation.py @@ -125,7 +125,7 @@ class AmazonStabilityConfig: _image = Image(b64_json=artifact["base64"]) image_list.append(_image) - model_response.data = image_list # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type + model_response.data = image_list return model_response diff --git a/litellm/llms/bedrock/image_generation/amazon_stability3_transformation.py b/litellm/llms/bedrock/image_generation/amazon_stability3_transformation.py index 489440a7430..98e4cbbfd4d 100644 --- a/litellm/llms/bedrock/image_generation/amazon_stability3_transformation.py +++ b/litellm/llms/bedrock/image_generation/amazon_stability3_transformation.py @@ -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 # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type + model_response.data = openai_images return model_response @classmethod diff --git a/litellm/llms/bedrock/image_generation/amazon_titan_transformation.py b/litellm/llms/bedrock/image_generation/amazon_titan_transformation.py index 93b23672377..e1b06791c9d 100644 --- a/litellm/llms/bedrock/image_generation/amazon_titan_transformation.py +++ b/litellm/llms/bedrock/image_generation/amazon_titan_transformation.py @@ -134,7 +134,7 @@ class AmazonTitanImageGenerationConfig: _image = Image(b64_json=image) image_list.append(_image) - model_response.data = image_list # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type + model_response.data = image_list return model_response diff --git a/litellm/llms/fal_ai/cost_calculator.py b/litellm/llms/fal_ai/cost_calculator.py index 497a792fd8a..114bbabe4e6 100644 --- a/litellm/llms/fal_ai/cost_calculator.py +++ b/litellm/llms/fal_ai/cost_calculator.py @@ -10,6 +10,7 @@ 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", @@ -21,10 +22,8 @@ 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]) +_EMPTY_ENTRY: Final[Mapping[str, object]] = MappingProxyType({}) def _keyed_size(optional_params: Mapping[str, object]) -> str | None: @@ -43,46 +42,6 @@ def _keyed_size(optional_params: Mapping[str, object]) -> str | None: return None -def _response_size(image: object) -> str | None: - if not isinstance(image, ImageObject): - return 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") - 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 @@ -97,6 +56,39 @@ def _image_dimensions(image: object) -> tuple[int, int] | 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") + 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, @@ -106,8 +98,15 @@ def _flat_cost_per_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 + 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 + return _OBJECT_MAP.validate_python(raw_entry) def cost_calculator( @@ -123,28 +122,24 @@ def cost_calculator( 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( + model_info: 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 + if (entry := _entry(key)) is not None ), - None, + _EMPTY_ENTRY, ) - 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 diff --git a/litellm/llms/gemini/image_edit/transformation.py b/litellm/llms/gemini/image_edit/transformation.py index 594b4dfd4ce..e6c22dc60b4 100644 --- a/litellm/llms/gemini/image_edit/transformation.py +++ b/litellm/llms/gemini/image_edit/transformation.py @@ -148,7 +148,7 @@ class GeminiImageEditConfig(BaseImageEditConfig): ) ) - model_response.data = cast(list[OpenAIImage], data_list) # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type + model_response.data = cast(list[OpenAIImage], data_list) if "usageMetadata" in response_json: model_response.usage = transform_gemini_image_usage(response_json["usageMetadata"]) return model_response diff --git a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py index 6cf32733550..725a7f39917 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py @@ -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) # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type + model_response.data = cast(list[OpenAIImage], data_list) return model_response def _map_size_to_aspect_ratio(self, size: str) -> str: diff --git a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py index d0485275bf3..c6ad5928b74 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py @@ -220,7 +220,7 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): ) ) - model_response.data = cast(list[OpenAIImage], data_list) # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type + model_response.data = cast(list[OpenAIImage], data_list) return model_response def _map_size_to_aspect_ratio(self, size: str) -> str: diff --git a/litellm/llms/vertex_ai/image_generation/image_generation_handler.py b/litellm/llms/vertex_ai/image_generation/image_generation_handler.py index d0fb6a2f937..6a5bb484540 100644 --- a/litellm/llms/vertex_ai/image_generation/image_generation_handler.py +++ b/litellm/llms/vertex_ai/image_generation/image_generation_handler.py @@ -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 # pyright: ignore[reportAttributeAccessIssue] # legacy OpenAI image response type + model_response.data = response_data return model_response def transform_optional_params(self, optional_params: dict | None) -> dict: diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 855ef1fb04d..570fa3485ee 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -24921,7 +24921,7 @@ }, "mode": "image_generation", "output_cost_per_image": 0.025, - "output_cost_per_pixel": 2.5e-08, + "output_cost_per_pixel": 2.384185791015625e-08, "source": "https://fal.ai/models/fal-ai/flux/dev", "supported_endpoints": [ "/v1/images/generations" diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 7c33579be5a..a2d92491188 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2543,7 +2543,6 @@ 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. @@ -2552,6 +2551,10 @@ class ImageResponse(OpenAIImageResponse, BaseLiteLLMOpenAIResponseObject): model_config = ConfigDict(extra="allow", protected_namespaces=()) + @field_serializer("data") + def _serialize_image_data(self, data: list[OpenAIImage]) -> list[dict[str, object]]: + return [image.model_dump() for image in data] + def __init__( self, created: int | None = None, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 855ef1fb04d..570fa3485ee 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -24921,7 +24921,7 @@ }, "mode": "image_generation", "output_cost_per_image": 0.025, - "output_cost_per_pixel": 2.5e-08, + "output_cost_per_pixel": 2.384185791015625e-08, "source": "https://fal.ai/models/fal-ai/flux/dev", "supported_endpoints": [ "/v1/images/generations" diff --git a/tests/integration/providers/test_fal_ai_image_wire.py b/tests/integration/providers/test_fal_ai_image_wire.py index e0c05e82b33..f9ceac0b037 100644 --- a/tests/integration/providers/test_fal_ai_image_wire.py +++ b/tests/integration/providers/test_fal_ai_image_wire.py @@ -163,7 +163,7 @@ def test_fal_flux_dev_generation_targets_dev_endpoint_and_charges_per_image(gate }, ] cost: Final = _response_cost(response) - assert cost == _approx(4 * _catalog_cost("fal_ai/fal-ai/flux/dev", "output_cost_per_pixel") * 1_000_000) + 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")] diff --git a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py index 05b6014c8e2..e387586e417 100644 --- a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py +++ b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py @@ -120,7 +120,7 @@ def test_flux_dev_cost_uses_response_megapixels_per_image(): 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)) + assert cost == pytest.approx(output_cost_per_pixel * 1_048_576 * (1 + 2 + 1)) def test_image_edit_call_type_routes_to_fal_keyed_pricing(): From c12c20083fbc4cc3f0107766d033a171981b65f7 Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 19:09:10 +0000 Subject: [PATCH 3/5] fix(fal_ai): keep image data serializer none safe Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/types/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index a2d92491188..8d327b337a7 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2552,8 +2552,8 @@ class ImageResponse(OpenAIImageResponse, BaseLiteLLMOpenAIResponseObject): model_config = ConfigDict(extra="allow", protected_namespaces=()) @field_serializer("data") - def _serialize_image_data(self, data: list[OpenAIImage]) -> list[dict[str, object]]: - return [image.model_dump() for image in data] + def _serialize_image_data(self, data: list[OpenAIImage] | None) -> list[dict[str, object]] | None: + return None if data is None else [image.model_dump() for image in data] def __init__( self, From 34293483059d74af7138b62aa55865454237c165 Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 19:21:53 +0000 Subject: [PATCH 4/5] fix(types): avoid mutable image serializer annotations Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/types/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 8d327b337a7..c661aa0839a 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2552,7 +2552,7 @@ class ImageResponse(OpenAIImageResponse, BaseLiteLLMOpenAIResponseObject): model_config = ConfigDict(extra="allow", protected_namespaces=()) @field_serializer("data") - def _serialize_image_data(self, data: list[OpenAIImage] | None) -> list[dict[str, object]] | None: + 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__( From b53f9ad65827eac2004b73d9f61eaeda5bb5981c Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 19:58:59 +0000 Subject: [PATCH 5/5] fix(fal_ai): validate returned image dimensions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fal_ai/cost_calculator.py | 13 ++----- .../fal_ai/image_generation/transformation.py | 4 +- litellm/types/utils.py | 1 + litellm/utils.py | 1 + .../test_fal_ai_flux_dev_transformation.py | 38 +++++++++++++++++++ .../llms/fal_ai/test_cost_calculator.py | 27 +++++++++++++ 6 files changed, 73 insertions(+), 11 deletions(-) diff --git a/litellm/llms/fal_ai/cost_calculator.py b/litellm/llms/fal_ai/cost_calculator.py index 114bbabe4e6..fd7d82d314f 100644 --- a/litellm/llms/fal_ai/cost_calculator.py +++ b/litellm/llms/fal_ai/cost_calculator.py @@ -23,7 +23,6 @@ FAL_NAMED_IMAGE_SIZES: Final[Mapping[str, str]] = MappingProxyType( ) _OBJECT_MAP: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object]) -_EMPTY_ENTRY: Final[Mapping[str, object]] = MappingProxyType({}) def _keyed_size(optional_params: Mapping[str, object]) -> str | None: @@ -51,7 +50,7 @@ def _image_dimensions(image: object) -> tuple[int, int] | 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): + if type(width) is not int or width <= 0 or type(height) is not int or height <= 0: return None return width, height @@ -132,13 +131,9 @@ def cost_calculator( ) 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 = next( - ( - entry - for key in (f"{litellm.LlmProviders.FAL_AI.value}/{normalized_model}", normalized_model) - if (entry := _entry(key)) is not None - ), - _EMPTY_ENTRY, + model_info: Final = litellm.get_model_info( + model=normalized_model, + custom_llm_provider=litellm.LlmProviders.FAL_AI.value, ) raw_output_cost_per_image: Final = model_info.get("output_cost_per_image") output_cost_per_image: Final = ( diff --git a/litellm/llms/fal_ai/image_generation/transformation.py b/litellm/llms/fal_ai/image_generation/transformation.py index a5afdc41e9a..fd8e280da1c 100644 --- a/litellm/llms/fal_ai/image_generation/transformation.py +++ b/litellm/llms/fal_ai/image_generation/transformation.py @@ -47,8 +47,8 @@ def fal_images_to_image_objects(images: object) -> tuple[ImageObject, ...]: 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 {}), + **({"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( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index c661aa0839a..db9678eab43 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -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 diff --git a/litellm/utils.py b/litellm/utils.py index 9a80b115d4b..b54f700c67d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -6087,6 +6087,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), diff --git a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py index 273b9151a0a..675d502240e 100644 --- a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py +++ b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py @@ -86,3 +86,41 @@ def test_flux_dev_response_omits_provider_specific_fields_when_fal_omits_metadat 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 diff --git a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py index e387586e417..6fb34d9f88e 100644 --- a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py +++ b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py @@ -123,6 +123,33 @@ def test_flux_dev_cost_uses_response_megapixels_per_image(): 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(