From 4bcdaf3d4bcb54d47052f2e72c0c687c927c429e Mon Sep 17 00:00:00 2001 From: Meet Patel <93856438+patel-26meet@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:10:39 +0530 Subject: [PATCH] fix(cost): honor deployment pricing for image generation (#39311) * fix image cost: honor deployment pricing * fix types: coerce fal deployment price, drop private import * fix: forward every custom pricing field through get_litellm_params * test: assert optional keys are absent, not merely None, in get_litellm_params * test: type the deployment image pricing test parameters * fix: bill deployment per-image and per-pixel prices on unlisted image models * test: type the remaining image cost test parameters --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/cost_calculator.py | 55 +++---- .../litellm_core_utils/get_litellm_params.py | 2 + .../litellm_core_utils/llm_cost_calc/utils.py | 81 +++++++++++ .../aiml/image_generation/cost_calculator.py | 7 +- .../image_generation/cost_calculator.py | 27 +++- .../image_generation/cost_calculator.py | 7 +- litellm/llms/fal_ai/cost_calculator.py | 19 ++- .../llms/gemini/image_edit/cost_calculator.py | 3 + .../image_generation/cost_calculator.py | 9 +- .../image_generation/cost_calculator.py | 28 ++-- litellm/llms/recraft/cost_calculator.py | 7 +- litellm/llms/runwayml/cost_calculator.py | 7 +- .../image_generation/cost_calculator.py | 9 +- .../llm_cost_calc/test_llm_cost_calc_utils.py | 135 +++++++++++++++++- .../test_get_litellm_params.py | 27 +++- .../test_azure_ai_flux2_image_generation.py | 30 ++++ tests/test_litellm/test_cost_calculator.py | 92 +++++++++++- .../test_gpt_image_cost_calculator.py | 34 +++++ 18 files changed, 518 insertions(+), 61 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index ae54b91c5a0..d66d80a564b 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1532,6 +1532,7 @@ def completion_cost( size=size, optional_params=optional_params, call_type=call_type, + model_info=_deployment_model_info(litellm_logging_obj, custom_pricing, router_model_id), ) elif call_type in _VIDEO_CALL_TYPES: ### VIDEO GENERATION COST CALCULATION ### @@ -2011,13 +2012,9 @@ def _deployment_model_info( ) -> ModelInfo | None: if not custom_pricing: return None - registered_deployment_info: Final = ( - _cost_map_model_info(router_model_id, None) - if router_model_id is not None and router_model_id in litellm.model_cost - else None - ) + registered_deployment_info: Final = _raw_cost_map_entry(router_model_id) if router_model_id is not None else None if registered_deployment_info is not None: - return registered_deployment_info + return cast(ModelInfo, registered_deployment_info) # cast-ok: router registers deployment prices under its id if litellm_logging_obj is None: return None litellm_params: Final = getattr(litellm_logging_obj, "litellm_params", None) @@ -2085,8 +2082,7 @@ def pricing_entry_for_cost_calc( deployment_entry: Final = _deployment_model_info(litellm_logging_obj, custom_pricing, router_model_id) deployment_key: Final = router_model_id or model if deployment_entry is not None and deployment_key is not None: - registered_entry: Final = _raw_cost_map_entry(router_model_id) if router_model_id is not None else None - return deployment_key, registered_entry or deployment_entry + return deployment_key, deployment_entry selected_model: Final = _select_model_name_for_cost_calc( model=model, completion_response=completion_response, @@ -2346,6 +2342,7 @@ def default_image_cost_calculator( n: int | None = 1, # Default to 1 image size: str | None = "1024-x-1024", # OpenAI default optional_params: dict | None = None, + model_info: ModelInfo | None = None, ) -> float: """ Default image cost calculator for image generation @@ -2356,6 +2353,7 @@ def default_image_cost_calculator( quality (Optional[str]): Image quality setting n (Optional[int]): Number of images generated size (Optional[str]): Image size (e.g. "1024x1024" or "1024-x-1024") + model_info (Optional[ModelInfo]): The deployment's own prices, consulted before the cost map Returns: float: Cost in USD for the image generation @@ -2386,9 +2384,7 @@ def default_image_cost_calculator( model_without_provider: Final = f"{size_str}/{model.split('/')[-1]}" model_with_quality_without_provider = f"{quality}/{model_without_provider}" if quality else model_without_provider - # Try model with quality first, fall back to base model name - cost_info: dict | None = None - models_to_check: Final[list[str | None]] = [ + models_to_check: Final = ( model_name_with_quality, base_model_name, model_name_with_v2_quality, @@ -2396,22 +2392,33 @@ def default_image_cost_calculator( model_without_provider, model, model_name_without_custom_llm_provider, - ] - for _model in models_to_check: - if _model is not None and _model in litellm.model_cost: - cost_info = litellm.model_cost[_model] - break - if cost_info is None: + ) + matched_model: Final = next( + (_model for _model in models_to_check if _model is not None and _model in litellm.model_cost), None + ) + if matched_model is None and model_info is None: raise Exception(f"Model not found in cost map. Tried checking {models_to_check}") - # Priority 1: Use per-image pricing if available (for gpt-image-1 and similar models) - if "input_cost_per_image" in cost_info and cost_info["input_cost_per_image"] is not None: - return cost_info["input_cost_per_image"] * n - # Priority 2: Fall back to per-pixel pricing for backward compatibility - elif "input_cost_per_pixel" in cost_info and cost_info["input_cost_per_pixel"] is not None: - return cost_info["input_cost_per_pixel"] * height * width * n - else: + shared_cost_info: Final = litellm.model_cost[matched_model] if matched_model is not None else None + price_tables: Final = tuple(table for table in (model_info, shared_cost_info) if table is not None) + image_count: Final = n if n is not None else 1 + unit_counts: Final = ( + ("input_cost_per_image", image_count), + ("output_cost_per_image", image_count), + ("input_cost_per_pixel", height * width * image_count), + ) + cost: Final = next( + ( + price * units + for price_table in price_tables + for cost_key, units in unit_counts + if (price := price_table.get(cost_key)) is not None + ), + None, + ) + if cost is None: raise Exception(f"No pricing information found for model {model}. Tried checking {models_to_check}") + return cost def default_video_cost_calculator( diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index 36fd7fa4e61..7f373569b21 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -4,6 +4,7 @@ from typing import Final from litellm.litellm_core_utils.core_helpers import normalize_drop_params from litellm.llms.openai.data_residency import infer_openai_data_residency +from litellm.types.router import CustomPricingLiteLLMParams AWS_CREDENTIAL_KWARGS_KEYS: Final = frozenset( { @@ -65,6 +66,7 @@ OPTIONAL_KWARGS_KEYS: Final = ( } ) | AWS_CREDENTIAL_KWARGS_KEYS + | frozenset(CustomPricingLiteLLMParams.model_fields) ) # Backward-compatible alias for existing imports/tests. diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 3845f50af79..46bf2ec2960 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -24,6 +24,7 @@ from litellm.types.utils import ( CallTypes, CompletionTokensDetailsWrapper, CostPerToken, + CustomPricingLiteLLMParams, DataResidency, ImageResponse, ModelInfo, @@ -49,6 +50,15 @@ _IMAGE_RESPONSE_CALL_TYPES: Final = frozenset( # Pre-resolved DataResidency enum values for fast membership checks _VALID_DATA_RESIDENCIES: Final = frozenset(r.value for r in DataResidency) +_DEPLOYMENT_PRICING_KEYS: Final[frozenset[str]] = frozenset(CustomPricingLiteLLMParams.model_fields) + +_IMAGE_TOKEN_RATE_KEYS: Final[tuple[str, ...]] = ( + "input_cost_per_token", + "output_cost_per_token", + "input_cost_per_image_token", + "output_cost_per_image_token", +) + # Pre-resolved service-tier cost-key suffixes (e.g. "_priority"). Used per # request in the cost-calc path, so the f-strings are built once here instead # of being rebuilt for every model_info key on every call. Longest-first so a @@ -826,6 +836,53 @@ def _get_cost_per_unit(model_info: ModelInfo, cost_key: str, default_value: floa return default_value +def deployment_pricing(model_info: ModelInfo | None) -> ModelInfo | None: + """The prices a deployment sets itself, as floats; None when it sets none that parse.""" + if model_info is None: + return None + priced_keys: Final = tuple(key for key in _DEPLOYMENT_PRICING_KEYS if model_info.get(key) is not None) + pricing: Final = MappingProxyType( + { + key: price + for key in priced_keys + if (price := _get_cost_per_unit(model_info, key, default_value=None)) is not None + } + ) + if not pricing: + return None + return cast(ModelInfo, pricing) # cast-ok: a read-only subset of ModelInfo pricing keys, values validated above + + +def prices_tokens(model_info: ModelInfo) -> bool: + """Whether the price table carries any token rate, so a token-priced calculator can bill from usage.""" + return any(model_info.get(key) is not None for key in _IMAGE_TOKEN_RATE_KEYS) + + +def flat_image_cost(model_info: ModelInfo | None, image_response: ImageResponse) -> float: + """The per-image price times the images returned; 0.0 when the table sets no per-image price.""" + if model_info is None: + return 0.0 + output_cost_per_image: Final = _get_cost_per_unit(model_info, "output_cost_per_image", default_value=None) or 0.0 + num_images: Final = len(image_response.data) if image_response.data else 0 + return output_cost_per_image * num_images + + +def resolve_image_model_info(model: str, custom_llm_provider: str, model_info: ModelInfo | None) -> ModelInfo: + """The price table an image cost calculator consults for ``model``. + + ``shared_backend_model_info`` keeps deployment prices off the shared ``{provider}/{model}`` key, so + a name lookup alone reads the public rate, and a model only the deployment prices has no entry at all. + """ + if model_info is None: + return get_model_info(model=model, custom_llm_provider=custom_llm_provider) + try: + shared_model_info: Final = get_model_info(model=model, custom_llm_provider=custom_llm_provider) + except Exception: # noqa: BLE001 # get_model_info raises a bare Exception for an unmapped model + return model_info + resolved: Final[ModelInfo] = {**shared_model_info, **model_info} + return resolved + + def calculate_cache_writing_cost( cache_creation_tokens: int, cache_creation_token_details: CacheCreationTokenDetails | None, @@ -1711,6 +1768,7 @@ def calculate_image_response_cost_from_usage( model: str, image_response: ImageResponse, custom_llm_provider: str, + model_info: ModelInfo | None = None, ) -> float | None: """ Calculate image generation cost from usage metadata when available. @@ -1735,6 +1793,9 @@ def calculate_image_response_cost_from_usage( if prompt_tokens == 0 and completion_tokens == 0 and total_tokens == 0: return None + if model_info is not None and not prices_tokens(model_info): + return None + input_tokens_details: Final[object] = getattr(usage, "input_tokens_details", None) prompt_tokens_details: PromptTokensDetailsWrapper | None = None if input_tokens_details is not None: @@ -1790,6 +1851,7 @@ def calculate_image_response_cost_from_usage( model=model, usage=normalized_usage, custom_llm_provider=custom_llm_provider, + model_info=model_info, ) return prompt_cost + completion_cost @@ -1850,9 +1912,15 @@ class CostCalculatorUtils: size: str | None = None, optional_params: dict | None = None, call_type: str | None = None, + model_info: ModelInfo | None = None, ) -> float: """ Route the image generation cost calculator based on the custom_llm_provider + + ``model_info`` is the deployment's own price table. Its valid prices are laid over the shared + cost-map entry and handed to the provider calculator, so per-image, per-pixel and per-token + deployment prices all apply while provider logic (token-first billing, grounding surcharges, + image counting) stays in one place. An unparseable price is logged and ignored. """ from litellm.cost_calculator import default_image_cost_calculator from litellm.llms.azure_ai.image_generation.cost_calculator import ( @@ -1878,12 +1946,14 @@ class CostCalculatorUtils: quality or completion_response.quality or _requested_image_param(optional_params, "quality") or "standard" ) resolved_n: Final = n if n is not None else (len(completion_response.data) if completion_response.data else 0) + pricing: Final = deployment_pricing(model_info) if custom_llm_provider == litellm.LlmProviders.VERTEX_AI.value: if isinstance(completion_response, ImageResponse): return vertex_ai_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.BEDROCK.value: if isinstance(completion_response, ImageResponse): @@ -1902,6 +1972,7 @@ class CostCalculatorUtils: return recraft_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.AIML.value: from litellm.llms.aiml.image_generation.cost_calculator import ( @@ -1911,6 +1982,7 @@ class CostCalculatorUtils: return aiml_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.COMETAPI.value: from litellm.llms.cometapi.image_generation.cost_calculator import ( @@ -1920,6 +1992,7 @@ class CostCalculatorUtils: return cometapi_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.GEMINI.value: if call_type in ( @@ -1933,6 +2006,7 @@ class CostCalculatorUtils: return gemini_image_edit_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) from litellm.llms.gemini.image_generation.cost_calculator import ( cost_calculator as gemini_image_cost_calculator, @@ -1941,6 +2015,7 @@ class CostCalculatorUtils: return gemini_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.AZURE_AI.value: return azure_ai_image_cost_calculator( @@ -1949,6 +2024,7 @@ class CostCalculatorUtils: size=resolved_size, n=resolved_n, optional_params=optional_params, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.FAL_AI.value: from litellm.llms.fal_ai.cost_calculator import ( @@ -1959,6 +2035,7 @@ class CostCalculatorUtils: model=model, image_response=completion_response, optional_params=optional_params, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.RUNWAYML.value: from litellm.llms.runwayml.cost_calculator import ( @@ -1968,6 +2045,7 @@ class CostCalculatorUtils: return runwayml_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif ( custom_llm_provider == litellm.LlmProviders.OPENAI.value @@ -1984,6 +2062,7 @@ class CostCalculatorUtils: model=model, image_response=completion_response, custom_llm_provider=custom_llm_provider, + model_info=pricing, ) # Fall through to default for DALL-E models return default_image_cost_calculator( @@ -1993,6 +2072,7 @@ class CostCalculatorUtils: n=resolved_n, size=resolved_size, optional_params=optional_params, + model_info=pricing, ) else: return default_image_cost_calculator( @@ -2002,5 +2082,6 @@ class CostCalculatorUtils: n=resolved_n, size=resolved_size, optional_params=optional_params, + model_info=pricing, ) return 0.0 diff --git a/litellm/llms/aiml/image_generation/cost_calculator.py b/litellm/llms/aiml/image_generation/cost_calculator.py index 13427dcafc2..abf4216807a 100644 --- a/litellm/llms/aiml/image_generation/cost_calculator.py +++ b/litellm/llms/aiml/image_generation/cost_calculator.py @@ -1,19 +1,22 @@ from typing import Any, Final import litellm -from litellm.types.utils import ImageResponse +from litellm.litellm_core_utils.llm_cost_calc.utils import resolve_image_model_info +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ AI/ML flux image generation cost calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider=litellm.LlmProviders.AIML.value, + model_info=model_info, ) output_cost_per_image: Final[float] = _model_info.get("output_cost_per_image") or 0.0 num_images: int = 0 diff --git a/litellm/llms/azure_ai/image_generation/cost_calculator.py b/litellm/llms/azure_ai/image_generation/cost_calculator.py index 35d0f4fb6c3..08b732197f3 100644 --- a/litellm/llms/azure_ai/image_generation/cost_calculator.py +++ b/litellm/llms/azure_ai/image_generation/cost_calculator.py @@ -3,9 +3,22 @@ from typing import Any, Final import litellm from litellm.litellm_core_utils.llm_cost_calc.utils import ( + _get_cost_per_unit, calculate_image_response_cost_from_usage, + resolve_image_model_info, ) -from litellm.types.utils import ImageResponse +from litellm.types.utils import ImageResponse, ModelInfo + + +def _input_cost_per_pixel(resolved: ModelInfo) -> float: + deployment_price: Final = _get_cost_per_unit(resolved, "input_cost_per_pixel", default_value=None) + if deployment_price is not None: + return deployment_price + model_cost_key: Final = resolved.get("key") + shared_entry: Final = litellm.model_cost.get(model_cost_key) if model_cost_key is not None else None + if shared_entry is None: + return 0.0 + return shared_entry.get("input_cost_per_pixel") or 0.0 def cost_calculator( @@ -14,13 +27,15 @@ def cost_calculator( size: str | None = None, n: int | None = None, optional_params: Mapping[str, object] | None = None, + model_info: ModelInfo | None = None, ) -> float: """ Azure AI image generation cost calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider=litellm.LlmProviders.AZURE_AI.value, + model_info=model_info, ) if isinstance(image_response, ImageResponse): @@ -28,6 +43,7 @@ def cost_calculator( model=model, image_response=image_response, custom_llm_provider=litellm.LlmProviders.AZURE_AI.value, + model_info=_model_info, ) if token_based_cost is not None: return token_based_cost @@ -37,9 +53,7 @@ def cost_calculator( if output_cost_per_image: return output_cost_per_image * num_images - model_cost: Final = litellm.model_cost[_model_info["key"]] - input_cost_per_pixel: Final[float] = model_cost.get("input_cost_per_pixel") or 0.0 - if input_cost_per_pixel: + if _input_cost_per_pixel(_model_info): from litellm.cost_calculator import default_image_cost_calculator width: Final = optional_params.get("width") if optional_params else None @@ -50,10 +64,11 @@ def cost_calculator( else size or image_response.size ) return default_image_cost_calculator( - model=_model_info["key"], + model=_model_info.get("key", model), custom_llm_provider=litellm.LlmProviders.AZURE_AI.value, size=pixel_size, n=num_images, + model_info=model_info, ) return 0.0 diff --git a/litellm/llms/cometapi/image_generation/cost_calculator.py b/litellm/llms/cometapi/image_generation/cost_calculator.py index 0ad9f75c45f..8f767cf311e 100644 --- a/litellm/llms/cometapi/image_generation/cost_calculator.py +++ b/litellm/llms/cometapi/image_generation/cost_calculator.py @@ -1,19 +1,22 @@ from typing import Any, Final import litellm -from litellm.types.utils import ImageResponse +from litellm.litellm_core_utils.llm_cost_calc.utils import resolve_image_model_info +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ CometAPI image generation cost calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider=litellm.LlmProviders.COMETAPI.value, + model_info=model_info, ) output_cost_per_image: Final[float] = _model_info.get("output_cost_per_image") or 0.0 num_images: int = 0 diff --git a/litellm/llms/fal_ai/cost_calculator.py b/litellm/llms/fal_ai/cost_calculator.py index 31f0995bf9f..3036590e58d 100644 --- a/litellm/llms/fal_ai/cost_calculator.py +++ b/litellm/llms/fal_ai/cost_calculator.py @@ -7,7 +7,8 @@ from typing import Final from pydantic import TypeAdapter import litellm -from litellm.types.utils import ImageObject, ImageResponse +from litellm.litellm_core_utils.llm_cost_calc.utils import deployment_pricing, resolve_image_model_info +from litellm.types.utils import ImageObject, ImageResponse, ModelInfo FAL_KEYED_PRICING_DEFAULT_QUALITY: Final[str] = "high" _DEFAULT_KEYED_DIMENSIONS: Final[tuple[int, int]] = (1024, 768) @@ -149,6 +150,7 @@ def cost_calculator( model: str, image_response: object, optional_params: Mapping[str, object] | None = None, + model_info: ModelInfo | None = None, ) -> float: """ fal.ai image generation cost calculator @@ -156,8 +158,14 @@ def cost_calculator( if not isinstance(image_response, ImageResponse): raise ValueError(f"image_response must be of type ImageResponse got type={type(image_response)}") 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 ()) + deployment_prices: Final = deployment_pricing(model_info) + deployment_cost_per_image: Final = ( + None if deployment_prices is None else deployment_prices.get("output_cost_per_image") + ) + if deployment_cost_per_image is not None: + return deployment_cost_per_image * len(images) + params: Final[Mapping[str, object]] = optional_params or MappingProxyType({}) keyed_costs: Final = tuple( _keyed_cost_per_image( model=normalized_model, @@ -168,15 +176,16 @@ def cost_calculator( ) if not any(cost is 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( + resolved_model_info: Final = resolve_image_model_info( model=normalized_model, custom_llm_provider=litellm.LlmProviders.FAL_AI.value, + model_info=deployment_prices, ) - raw_output_cost_per_image: Final = model_info.get("output_cost_per_image") + raw_output_cost_per_image: Final = resolved_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") + raw_output_cost_per_pixel: Final = resolved_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 ) diff --git a/litellm/llms/gemini/image_edit/cost_calculator.py b/litellm/llms/gemini/image_edit/cost_calculator.py index 956edb849a0..321fbbaeb37 100644 --- a/litellm/llms/gemini/image_edit/cost_calculator.py +++ b/litellm/llms/gemini/image_edit/cost_calculator.py @@ -7,11 +7,13 @@ from typing import Any from litellm.llms.gemini.image_generation.cost_calculator import ( cost_calculator as image_generation_cost_calculator, ) +from litellm.types.utils import ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ Gemini image edit cost calculator. @@ -22,4 +24,5 @@ def cost_calculator( return image_generation_cost_calculator( model=model, image_response=image_response, + model_info=model_info, ) diff --git a/litellm/llms/gemini/image_generation/cost_calculator.py b/litellm/llms/gemini/image_generation/cost_calculator.py index ea0e77e1b81..e3232f8fb7b 100644 --- a/litellm/llms/gemini/image_generation/cost_calculator.py +++ b/litellm/llms/gemini/image_generation/cost_calculator.py @@ -4,24 +4,26 @@ Google AI Image Generation Cost Calculator from typing import Any, Final -import litellm from litellm.litellm_core_utils.llm_cost_calc.utils import ( calculate_image_response_cost_from_usage, calculate_image_response_web_search_cost, + resolve_image_model_info, ) -from litellm.types.utils import ImageResponse +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ Google AI Image Generation Cost Calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider="gemini", + model_info=model_info, ) if not isinstance(image_response, ImageResponse): @@ -37,6 +39,7 @@ def cost_calculator( model=model, image_response=image_response, custom_llm_provider="gemini", + model_info=_model_info, ) if token_based_cost is not None: return token_based_cost + web_search_cost diff --git a/litellm/llms/openai/image_generation/cost_calculator.py b/litellm/llms/openai/image_generation/cost_calculator.py index 938a0a57f2a..350fdbaee2d 100644 --- a/litellm/llms/openai/image_generation/cost_calculator.py +++ b/litellm/llms/openai/image_generation/cost_calculator.py @@ -9,27 +9,37 @@ from typing import Final from litellm import verbose_logger from litellm.litellm_core_utils.llm_cost_calc.utils import ( calculate_image_response_cost_from_usage, + flat_image_cost, generic_cost_per_token, + resolve_image_model_info, ) -from litellm.types.utils import ImageResponse, Usage +from litellm.types.utils import ImageResponse, ModelInfo, Usage def cost_calculator( model: str, image_response: ImageResponse, custom_llm_provider: str | None = None, + model_info: ModelInfo | None = None, ) -> float: """Calculate cost for OpenAI gpt-image models (token-based pricing).""" + provider: Final = custom_llm_provider or "openai" + price_table: Final = ( + None + if model_info is None + else resolve_image_model_info(model=model, custom_llm_provider=provider, model_info=model_info) + ) + usage: Final = getattr(image_response, "usage", None) if usage is None: verbose_logger.debug("No usage data available for %s, cannot calculate token-based cost", model) - return 0.0 - - provider: Final = custom_llm_provider or "openai" + return flat_image_cost(price_table, image_response) # A chat Usage with an explicit output breakdown: cost via generic_cost_per_token. if isinstance(usage, Usage) and usage.completion_tokens_details is not None: - prompt_cost, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider=provider) + prompt_cost, completion_cost = generic_cost_per_token( + model=model, usage=usage, custom_llm_provider=provider, model_info=price_table + ) return prompt_cost + completion_cost # ImageUsage / ResponseAPIUsage: reuse the shared helper (same path as @@ -38,7 +48,7 @@ def cost_calculator( # does not itemize output and splitting text/image when it does. if getattr(usage, "input_tokens", None) is not None: token_based_cost: Final = calculate_image_response_cost_from_usage( - model=model, image_response=image_response, custom_llm_provider=provider + model=model, image_response=image_response, custom_llm_provider=provider, model_info=price_table ) if token_based_cost is not None: return token_based_cost @@ -46,7 +56,9 @@ def cost_calculator( # Fallback: a Usage with no output breakdown that the image helper can't read — # cost via generic_cost_per_token (text rate) instead of returning 0.0. if isinstance(usage, Usage): - prompt_cost, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider=provider) + prompt_cost, completion_cost = generic_cost_per_token( + model=model, usage=usage, custom_llm_provider=provider, model_info=price_table + ) return prompt_cost + completion_cost - return 0.0 + return flat_image_cost(price_table, image_response) diff --git a/litellm/llms/recraft/cost_calculator.py b/litellm/llms/recraft/cost_calculator.py index 2866d8e1d96..221d4a35913 100644 --- a/litellm/llms/recraft/cost_calculator.py +++ b/litellm/llms/recraft/cost_calculator.py @@ -1,19 +1,22 @@ from typing import Any, Final import litellm -from litellm.types.utils import ImageResponse +from litellm.litellm_core_utils.llm_cost_calc.utils import resolve_image_model_info +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ Recraft image generation cost calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider=litellm.LlmProviders.RECRAFT.value, + model_info=model_info, ) output_cost_per_image: Final[float] = _model_info.get("output_cost_per_image") or 0.0 num_images: int = 0 diff --git a/litellm/llms/runwayml/cost_calculator.py b/litellm/llms/runwayml/cost_calculator.py index fdd4b904b60..07f7d564ac2 100644 --- a/litellm/llms/runwayml/cost_calculator.py +++ b/litellm/llms/runwayml/cost_calculator.py @@ -1,12 +1,14 @@ from typing import Any, Final import litellm -from litellm.types.utils import ImageResponse +from litellm.litellm_core_utils.llm_cost_calc.utils import resolve_image_model_info +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ RunwayML image generation cost calculator. @@ -14,9 +16,10 @@ def cost_calculator( RunwayML charges per image generated, not per pixel. Pricing is stored in model_prices_and_context_window.json with output_cost_per_image. """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider=litellm.LlmProviders.RUNWAYML.value, + model_info=model_info, ) output_cost_per_image: Final[float] = _model_info.get("output_cost_per_image") or 0.0 num_images: int = 0 diff --git a/litellm/llms/vertex_ai/image_generation/cost_calculator.py b/litellm/llms/vertex_ai/image_generation/cost_calculator.py index f3117e65681..6db4f02ee5a 100644 --- a/litellm/llms/vertex_ai/image_generation/cost_calculator.py +++ b/litellm/llms/vertex_ai/image_generation/cost_calculator.py @@ -4,24 +4,26 @@ Vertex AI Image Generation Cost Calculator from typing import Final -import litellm from litellm.litellm_core_utils.llm_cost_calc.utils import ( calculate_image_response_cost_from_usage, calculate_image_response_web_search_cost, + resolve_image_model_info, ) -from litellm.types.utils import ImageResponse +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: ImageResponse, + model_info: ModelInfo | None = None, ) -> float: """ Vertex AI Image Generation Cost Calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider="vertex_ai", + model_info=model_info, ) web_search_cost: Final = calculate_image_response_web_search_cost( @@ -34,6 +36,7 @@ def cost_calculator( model=model, image_response=image_response, custom_llm_provider="vertex_ai", + model_info=_model_info, ) if token_based_cost is not None: return token_based_cost + web_search_cost diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 4b25c87d70f..5b21684ca9c 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -1,6 +1,7 @@ +import json from collections.abc import Mapping from datetime import datetime, timezone -from typing import cast +from typing import Final, cast import pytest @@ -3637,6 +3638,138 @@ def test_get_token_base_cost_resolves_missing_cache_write_rates_like_the_tiered_ assert creation_1h == pytest.approx(expected_creation_1h) +def _image_response(num_images: int = 1, usage: ImageUsage | None = None) -> ImageResponse: + return ImageResponse( + data=[ImageObject(url="https://example.com/img.png") for _ in range(num_images)], + usage=usage, + ) + + +_GPT_IMAGE_2_HIGH_1024: Final = {"quality": "high", "image_size": {"width": 1024, "height": 1024}} + + +@pytest.mark.parametrize( + ("model", "optional_params", "model_info", "num_images", "expected_cost"), + [ + ("fal-ai/unlisted-image-model", None, {"output_cost_per_image": 0.08}, 1, 0.08), + ("fal-ai/unlisted-image-model", None, {"output_cost_per_image": 0.08}, 2, 0.16), + ("fal-ai/unlisted-image-model", None, {"output_cost_per_image": "0.08"}, 1, 0.08), + ("openai/gpt-image-2", _GPT_IMAGE_2_HIGH_1024, {"output_cost_per_image": 0.5}, 1, 0.5), + ("openai/gpt-image-2", _GPT_IMAGE_2_HIGH_1024, {"mode": "image_generation"}, 1, 0.211), + ("openai/gpt-image-2", _GPT_IMAGE_2_HIGH_1024, {"output_cost_per_image": "0.08 USD"}, 1, 0.211), + ], +) +def test_route_image_generation_cost_honors_deployment_model_info( + _local_model_cost_map: None, + model: str, + optional_params: dict[str, object] | None, + model_info: ModelInfo, + num_images: int, + expected_cost: float, +) -> None: + cost = CostCalculatorUtils.route_image_generation_cost_calculator( + model=model, + completion_response=_image_response(num_images), + custom_llm_provider="fal_ai", + optional_params=optional_params, + call_type="image_generation", + model_info=model_info, + ) + + assert cost == pytest.approx(expected_cost) + + +def test_route_image_generation_cost_openai_honors_deployment_input_cost_per_image( + _local_model_cost_map: None, +) -> None: + cost = CostCalculatorUtils.route_image_generation_cost_calculator( + model="dall-e-3", + completion_response=_image_response(), + custom_llm_provider="openai", + quality="standard", + size="1024-x-1024", + call_type="image_generation", + model_info={"input_cost_per_image": 0.07}, + ) + + assert cost == pytest.approx(0.07) + + +def test_route_image_generation_cost_gemini_adds_grounding_to_deployment_image_price( + _local_model_cost_map: None, +) -> None: + usage = ImageUsage( + input_tokens=0, + input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=0), + output_tokens=0, + total_tokens=0, + web_search_requests=3, + ) + + cost = CostCalculatorUtils.route_image_generation_cost_calculator( + model="gemini/gemini-3.1-flash-image-preview", + completion_response=_image_response(usage=usage), + custom_llm_provider="gemini", + call_type="image_generation", + model_info={"output_cost_per_image": 0.1}, + ) + + assert cost == pytest.approx(0.1 + 3 * 0.014) + + +def test_route_image_generation_cost_gemini_bills_tokens_when_no_image_returned( + _local_model_cost_map: None, +) -> None: + usage = ImageUsage( + input_tokens=10, + input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=10), + output_tokens=1290, + total_tokens=1300, + ) + + cost = CostCalculatorUtils.route_image_generation_cost_calculator( + model="gemini/gemini-3.1-flash-image-preview", + completion_response=ImageResponse(data=[], usage=usage), + custom_llm_provider="gemini", + call_type="image_generation", + model_info={"output_cost_per_image": 0.08}, + ) + + assert cost == pytest.approx(10 * 5e-07 + 1290 * 6e-05) + + +@pytest.mark.parametrize( + ("custom_llm_provider", "model"), + [ + ("gemini", "gemini/unlisted-image-model"), + ("vertex_ai", "vertex_ai/unlisted-image-model"), + ("azure_ai", "unlisted-image-model"), + ("openai", "gpt-image-unlisted"), + ], +) +def test_route_image_generation_cost_bills_deployment_image_price_when_unlisted_model_reports_tokens( + _local_model_cost_map: None, + custom_llm_provider: str, + model: str, +) -> None: + usage = ImageUsage( + input_tokens=10, + input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=10), + output_tokens=1290, + total_tokens=1300, + ) + + cost = CostCalculatorUtils.route_image_generation_cost_calculator( + model=model, + completion_response=_image_response(num_images=2, usage=usage), + custom_llm_provider=custom_llm_provider, + call_type="image_generation", + model_info={"output_cost_per_image": 0.05}, + ) + + assert cost == pytest.approx(0.10) + + def _batch_rates_model_info(**rates: object) -> ModelInfo: return cast(ModelInfo, dict(rates)) diff --git a/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py b/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py index 4c963d14ada..7fb45e1b092 100644 --- a/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py +++ b/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py @@ -4,6 +4,8 @@ Tests for get_litellm_params and related helpers. Ensures backward compatibility after sparse kwargs extraction optimization. """ +from typing import Final + import pytest from litellm.litellm_core_utils.get_litellm_params import ( @@ -12,6 +14,10 @@ from litellm.litellm_core_utils.get_litellm_params import ( get_litellm_params, ) +NAMED_PRICE_PARAMS: Final = frozenset( + {"input_cost_per_token", "output_cost_per_token", "input_cost_per_second", "output_cost_per_second"} +) + class TestGetBaseModelFromLitellmCallMetadata: def test_none_metadata_returns_none(self): @@ -40,10 +46,27 @@ class TestGetLitellmParamsKwargsExtraction: """Verify that optional kwargs are correctly extracted via sparse extraction.""" def test_no_kwargs_omits_optional_keys(self): - """When no kwargs passed, optional keys should not be in result.""" + """When no kwargs passed, optional keys are absent; the named price params are present as None.""" result = get_litellm_params(api_key="test-key") - for key in _OPTIONAL_KWARGS_KEYS: + for key in _OPTIONAL_KWARGS_KEYS - NAMED_PRICE_PARAMS: assert key not in result + for key in NAMED_PRICE_PARAMS: + assert result[key] is None + + def test_custom_pricing_kwargs_are_extracted(self) -> None: + from litellm.litellm_core_utils.litellm_logging import use_custom_pricing_for_model + from litellm.types.router import CustomPricingLiteLLMParams + + assert set(CustomPricingLiteLLMParams.model_fields) <= _OPTIONAL_KWARGS_KEYS + + result = get_litellm_params(output_cost_per_image=0.08, input_cost_per_audio_token=1e-6) + assert result["output_cost_per_image"] == 0.08 + assert result["input_cost_per_audio_token"] == 1e-6 + assert use_custom_pricing_for_model(result) is True + + result_without_prices = get_litellm_params() + assert "output_cost_per_image" not in result_without_prices + assert use_custom_pricing_for_model(result_without_prices) is False def test_present_kwargs_are_extracted(self): result = get_litellm_params( diff --git a/tests/test_litellm/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py b/tests/test_litellm/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py index 512e98b4151..fdd21c87732 100644 --- a/tests/test_litellm/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py +++ b/tests/test_litellm/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py @@ -205,6 +205,36 @@ def test_flux2_flex_cost_accepts_lowercase_model_spelling(): assert cost == pytest.approx(5e-08 * 1536 * 1024 * 2) +def test_flux2_flex_cost_prefers_deployment_input_cost_per_pixel() -> None: + response: Final = ImageResponse(data=[ImageObject(b64_json="aW1n"), ImageObject(b64_json="aW1n")]) + + cost: Final = CostCalculatorUtils.route_image_generation_cost_calculator( + model="FLUX.2-flex", + completion_response=response, + custom_llm_provider="azure_ai", + size="2048x1024", + call_type="image_generation", + model_info={"input_cost_per_pixel": 2e-07}, + ) + + assert cost == pytest.approx(2e-07 * 2048 * 1024 * 2) + + +def test_unlisted_azure_ai_model_bills_deployment_input_cost_per_pixel() -> None: + response: Final = ImageResponse(data=[ImageObject(b64_json="aW1n"), ImageObject(b64_json="aW1n")]) + + cost: Final = CostCalculatorUtils.route_image_generation_cost_calculator( + model="unlisted-flux-deployment", + completion_response=response, + custom_llm_provider="azure_ai", + size="1024x1024", + call_type="image_generation", + model_info={"input_cost_per_pixel": 1e-07}, + ) + + assert cost == pytest.approx(1e-07 * 1024 * 1024 * 2) + + def test_flux2_response_preserves_mapped_dimensions(): config = AzureFoundryFluxImageGenerationConfig() params = config.map_openai_params( diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index a33701cd1bf..7d2a04c500f 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -1,6 +1,7 @@ import datetime import time -from types import MappingProxyType +from pathlib import Path +from types import MappingProxyType, SimpleNamespace from typing import Final, cast import pytest @@ -22,8 +23,13 @@ from litellm.types.llms.base import CachedTokensDetails from litellm.types.llms.openai import OpenAIRealtimeStreamList, ResponseAPIUsage, ResponsesAPIResponse from litellm.types.rerank import RerankResponse from litellm.types.utils import ( + CacheCreationTokenDetails, CallTypes, Choices, + ImageObject, + ImageResponse, + ImageUsage, + ImageUsageInputTokensDetails, LiteLLMRealtimeStreamLoggingObject, Message, ModelInfo, @@ -686,6 +692,90 @@ def test_tiered_pricing_only_deployment_selects_router_model_id(): assert router_model_id in selected +@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) +def test_completion_cost_image_generation_reads_deployment_model_info_price_from_logging_metadata( + _local_model_cost_map: None, metadata_key: str +) -> None: + cost = completion_cost( + completion_response=ImageResponse(data=[ImageObject(url="https://example.com/img.png")]), + model="fal_ai/fal-ai/unlisted-image-model", + call_type="image_generation", + custom_pricing=True, + litellm_logging_obj=SimpleNamespace( + litellm_params={metadata_key: {"model_info": {"output_cost_per_image": 0.08}}} + ), + ) + + assert cost == pytest.approx(0.08) + + +def test_completion_cost_image_generation_registered_deployment_price_keeps_map_token_rates( + _local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch +) -> None: + deployment_id: Final = "gemini-image-deployment-priced-per-image" + monkeypatch.setitem( + litellm.model_cost, + deployment_id, + {"mode": "image_generation", "litellm_provider": "gemini", "output_cost_per_image": 0.1}, + ) + usage: Final = ImageUsage( + input_tokens=10, + input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=10), + output_tokens=1290, + total_tokens=1300, + ) + + cost = completion_cost( + completion_response=ImageResponse(data=[ImageObject(url="https://example.com/img.png")], usage=usage), + model="gemini/gemini-3.1-flash-image-preview", + custom_llm_provider="gemini", + call_type="image_generation", + custom_pricing=True, + router_model_id=deployment_id, + litellm_logging_obj=SimpleNamespace(litellm_params={"metadata": {"model_info": {"id": deployment_id}}}), + ) + + assert cost == pytest.approx(10 * 5e-07 + 1290 * 6e-05) + + +def test_completion_cost_image_generation_ignores_deployment_model_info_without_custom_pricing( + _local_model_cost_map: None, +) -> None: + cost = completion_cost( + completion_response=ImageResponse(data=[ImageObject(url="https://example.com/img.png")]), + model="fal_ai/openai/gpt-image-2", + call_type="image_generation", + custom_pricing=False, + optional_params={"quality": "high", "image_size": {"width": 1024, "height": 1024}}, + litellm_logging_obj=SimpleNamespace( + litellm_params={"litellm_metadata": {"model_info": {"output_cost_per_image": 0.5}}} + ), + ) + + assert cost == pytest.approx(0.211) + + +async def test_router_image_generation_bills_litellm_params_output_cost_per_image() -> None: + from litellm import Router + + router = Router( + model_list=[ + { + "model_name": "img", + "litellm_params": { + "model": "fal_ai/fal-ai/unlisted-image-model", + "api_key": "sk-fake", + "output_cost_per_image": 0.08, + }, + } + ] + ) + + response = await router.aimage_generation(model="img", prompt="x", mock_response="https://example.com/img.png") + + assert response._hidden_params["response_cost"] == pytest.approx(0.08) + + def test_tiered_pricing_only_deployment_completion_cost_is_nonzero(): """End-to-end: a tier-only deployment must produce the tiered cost, not $0. Mirrors the reported dashscope/qwen3.7-plus trace (12 prompt + 377 diff --git a/tests/test_litellm/test_gpt_image_cost_calculator.py b/tests/test_litellm/test_gpt_image_cost_calculator.py index 42d4c699200..0f471430daf 100644 --- a/tests/test_litellm/test_gpt_image_cost_calculator.py +++ b/tests/test_litellm/test_gpt_image_cost_calculator.py @@ -16,6 +16,8 @@ import litellm from litellm.types.utils import ( ImageObject, ImageResponse, + ImageUsage, + ImageUsageInputTokensDetails, ) @@ -52,6 +54,38 @@ class TestGPTImageCostCalculator: assert cost == 0.0 + @pytest.mark.parametrize( + "usage", + [ + None, + ImageUsage( + input_tokens=0, + input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=0), + output_tokens=0, + total_tokens=0, + ), + ], + ) + def test_gpt_image_1_bills_deployment_output_cost_per_image_without_usage_tokens( + self, usage: ImageUsage | None + ) -> None: + from litellm.llms.openai.image_generation.cost_calculator import cost_calculator + + image_response = ImageResponse( + created=1234567890, + data=[ImageObject(url="http://example.com/one.jpg"), ImageObject(url="http://example.com/two.jpg")], + usage=usage, + ) + + cost = cost_calculator( + model="gpt-image-1", + image_response=image_response, + custom_llm_provider="openai", + model_info={"output_cost_per_image": 0.05}, + ) + + assert cost == pytest.approx(0.10) + class TestGPTImageCostRouting: """Test that gpt-image models are properly routed to the token-based calculator"""