diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 5f7d646e150..d56e1156224 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -2070,6 +2070,7 @@ def completion_cost( call_type=call_type, model_info=_deployment_model_info(litellm_logging_obj, custom_pricing, router_model_id), vertex_location=vertex_location, + data_residency=data_residency, ) elif call_type in _VIDEO_CALL_TYPES: ### VIDEO GENERATION COST CALCULATION ### diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index ae7c4b47ece..0f47f055b96 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -2104,6 +2104,7 @@ def calculate_image_response_cost_from_usage( custom_llm_provider: str, model_info: ModelInfo | None = None, vertex_location: str | None = None, + data_residency: str | None = None, ) -> float | None: """ Calculate image generation cost from usage metadata when available. @@ -2188,6 +2189,7 @@ def calculate_image_response_cost_from_usage( custom_llm_provider=custom_llm_provider, model_info=model_info, vertex_location=vertex_location, + data_residency=data_residency, ) return prompt_cost + completion_cost @@ -2295,6 +2297,7 @@ class CostCalculatorUtils: call_type: str | None = None, model_info: ModelInfo | None = None, vertex_location: str | None = None, + data_residency: str | None = None, ) -> float: """ Route the image generation cost calculator based on the custom_llm_provider @@ -2420,6 +2423,7 @@ class CostCalculatorUtils: image_response=completion_response, custom_llm_provider=custom_llm_provider, model_info=pricing, + data_residency=data_residency, ) # Fall through to default for DALL-E models return default_image_cost_calculator( diff --git a/litellm/llms/openai/image_generation/cost_calculator.py b/litellm/llms/openai/image_generation/cost_calculator.py index 350fdbaee2d..ad51696a9ff 100644 --- a/litellm/llms/openai/image_generation/cost_calculator.py +++ b/litellm/llms/openai/image_generation/cost_calculator.py @@ -21,8 +21,13 @@ def cost_calculator( image_response: ImageResponse, custom_llm_provider: str | None = None, model_info: ModelInfo | None = None, + data_residency: str | None = None, ) -> float: - """Calculate cost for OpenAI gpt-image models (token-based pricing).""" + """Calculate cost for OpenAI gpt-image models (token-based pricing). + + ``data_residency`` carries the OpenAI regional-processing region (e.g. "eu", + "us") so the per-model uplift multiplier applies exactly like the chat path. + """ provider: Final = custom_llm_provider or "openai" price_table: Final = ( None @@ -38,7 +43,11 @@ def cost_calculator( # 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, model_info=price_table + model=model, + usage=usage, + custom_llm_provider=provider, + model_info=price_table, + data_residency=data_residency, ) return prompt_cost + completion_cost @@ -48,7 +57,11 @@ 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_info=price_table + model=model, + image_response=image_response, + custom_llm_provider=provider, + model_info=price_table, + data_residency=data_residency, ) if token_based_cost is not None: return token_based_cost @@ -57,7 +70,11 @@ def cost_calculator( # 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, model_info=price_table + model=model, + usage=usage, + custom_llm_provider=provider, + model_info=price_table, + data_residency=data_residency, ) return prompt_cost + completion_cost diff --git a/tests/unit/test_gpt_image_cost_calculator.py b/tests/unit/test_gpt_image_cost_calculator.py index d026285f9ae..fd13fa993cb 100644 --- a/tests/unit/test_gpt_image_cost_calculator.py +++ b/tests/unit/test_gpt_image_cost_calculator.py @@ -10,6 +10,8 @@ gpt-image-1 uses token-based pricing: - Image Output: $40.00/1M tokens """ +from typing import Final + import pytest import litellm @@ -18,6 +20,7 @@ from litellm.types.utils import ( ImageResponse, ImageUsage, ImageUsageInputTokensDetails, + ModelInfo, ) @@ -91,7 +94,6 @@ class TestGPTImageCostRouting: """Test that gpt-image models are properly routed to the token-based calculator""" - class TestGPTImage15OutputImageTokens: """ Test for GitHub issue #19508: @@ -120,3 +122,141 @@ class TestGPTImage2OutputImageTokensNoBreakdown: if __name__ == "__main__": pytest.main([__file__, "-v"]) + + +class TestGPTImageDataResidencyUplift: + """ + Test for GitHub issue #46407: + gpt-image cost ignored the OpenAI regional-processing (data residency) uplift. + + The chat path applies ``regional_processing_uplift_multiplier_`` from + the resolved price table inside ``generic_cost_per_token``; the image route + never received ``data_residency``, so the uplift could never reach gpt-image + calls. These tests pin the uplift on the image path using a price table that + carries the multiplier key (same mechanism the chat path uses with cost-map + entries). + """ + + _MODEL_INFO: Final[ModelInfo] = { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 1e-05, + "input_cost_per_image_token": 1e-05, + "output_cost_per_image_token": 4e-05, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + } + + @staticmethod + def _image_response() -> ImageResponse: + return ImageResponse( + created=1234567890, + data=[ImageObject(url="http://example.com/image.jpg")], + usage=ImageUsage( + input_tokens=1000, + input_tokens_details=ImageUsageInputTokensDetails(image_tokens=600, text_tokens=400), + output_tokens=500, + total_tokens=1500, + ), + ) + + def test_data_residency_uplift_applies_to_gpt_image_cost(self) -> None: + from litellm.llms.openai.image_generation.cost_calculator import cost_calculator + + base: Final = cost_calculator( + model="gpt-image-1", + image_response=self._image_response(), + custom_llm_provider="openai", + model_info=self._MODEL_INFO, + ) + eu: Final = cost_calculator( + model="gpt-image-1", + image_response=self._image_response(), + custom_llm_provider="openai", + model_info=self._MODEL_INFO, + data_residency="eu", + ) + assert base > 0 + assert abs(eu - base * 1.1) < 1e-12 + + def test_data_residency_uplift_via_calculate_image_response_cost_from_usage(self) -> None: + from litellm.litellm_core_utils.llm_cost_calc.utils import calculate_image_response_cost_from_usage + + base: Final = calculate_image_response_cost_from_usage( + model="gpt-image-1", + image_response=self._image_response(), + custom_llm_provider="openai", + model_info=self._MODEL_INFO, + ) + eu: Final = calculate_image_response_cost_from_usage( + model="gpt-image-1", + image_response=self._image_response(), + custom_llm_provider="openai", + model_info=self._MODEL_INFO, + data_residency="eu", + ) + assert base is not None and eu is not None + assert abs(eu - base * 1.1) < 1e-12 + + def test_no_uplift_when_model_carries_no_multiplier(self) -> None: + from litellm.llms.openai.image_generation.cost_calculator import cost_calculator + + model_info: Final[ModelInfo] = { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 1e-05, + "input_cost_per_image_token": 1e-05, + "output_cost_per_image_token": 4e-05, + } + base: Final = cost_calculator( + model="gpt-image-1", + image_response=self._image_response(), + custom_llm_provider="openai", + model_info=model_info, + ) + eu: Final = cost_calculator( + model="gpt-image-1", + image_response=self._image_response(), + custom_llm_provider="openai", + model_info=model_info, + data_residency="eu", + ) + assert eu == base + + def test_no_uplift_when_data_residency_absent(self) -> None: + from litellm.llms.openai.image_generation.cost_calculator import cost_calculator + + none_region: Final = cost_calculator( + model="gpt-image-1", + image_response=self._image_response(), + custom_llm_provider="openai", + model_info=self._MODEL_INFO, + data_residency=None, + ) + base: Final = cost_calculator( + model="gpt-image-1", + image_response=self._image_response(), + custom_llm_provider="openai", + model_info=self._MODEL_INFO, + ) + assert none_region == base + + def test_completion_cost_threads_data_residency_to_image_route(self, monkeypatch: pytest.MonkeyPatch) -> None: + """End-to-end: completion_cost's image branch must hand data_residency to the route.""" + from litellm.cost_calculator import completion_cost + + monkeypatch.setitem(litellm.model_cost["gpt-image-1"], "regional_processing_uplift_multiplier_eu", 1.1) + + base: Final = completion_cost( + completion_response=self._image_response(), + model="gpt-image-1", + custom_llm_provider="openai", + call_type="image_generation", + ) + eu: Final = completion_cost( + completion_response=self._image_response(), + model="gpt-image-1", + custom_llm_provider="openai", + call_type="image_generation", + data_residency="eu", + ) + assert base > 0 + assert abs(eu - base * 1.1) < 1e-12