From fab911f233e781bd5e24160ccdf797be9f7875a9 Mon Sep 17 00:00:00 2001 From: kimsehwan96 Date: Thu, 30 Apr 2026 19:34:25 +0900 Subject: [PATCH] fix(image_cost): split cached_tokens text-first to avoid double-bill --- litellm/cost_calculator.py | 15 ++++++-------- .../test_default_image_cost_calculator.py | 20 +++++++++++-------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 12ad598f0e7..2717416d890 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1925,17 +1925,14 @@ def _image_cost_from_token_usage( image_in = _detail("prompt_tokens_details", "image_tokens") image_out = _detail("completion_tokens_details", "image_tokens") - if image_in > 0 and cost_info.get("cache_read_input_image_token_cost") is not None: - text_in_uncached = text_in - cache_rate_key = "cache_read_input_image_token_cost" - else: - text_in_uncached = max(text_in - cached_in, 0) - cache_rate_key = "cache_read_input_token_cost" + text_cached = min(text_in, cached_in) + image_cached = cached_in - text_cached rates: List[Tuple[str, int]] = [ - ("input_cost_per_token", text_in_uncached), - (cache_rate_key, cached_in), - ("input_cost_per_image_token", image_in), + ("input_cost_per_token", text_in - text_cached), + ("cache_read_input_token_cost", text_cached), + ("input_cost_per_image_token", image_in - image_cached), + ("cache_read_input_image_token_cost", image_cached), ("output_cost_per_image_token", image_out), ] if not any(cost_info.get(key) is not None for key, _ in rates): diff --git a/tests/test_litellm/test_default_image_cost_calculator.py b/tests/test_litellm/test_default_image_cost_calculator.py index 68a930533f1..6ee782bc989 100644 --- a/tests/test_litellm/test_default_image_cost_calculator.py +++ b/tests/test_litellm/test_default_image_cost_calculator.py @@ -206,11 +206,13 @@ class TestDefaultImageCostCalculator: expected = 40 * 5e-6 + 160 * 1.25e-6 + 600 * 3e-5 assert abs(cost - expected) < 1e-9 - def test_token_fallback_uses_image_cache_rate_when_declared(self, monkeypatch): - """Image-edit responses report a single ``cached_tokens`` count; - when the model declares ``cache_read_input_image_token_cost`` and - image input tokens are present, that rate is used (and text input - is billed in full). + def test_token_fallback_splits_cached_tokens_between_text_and_image( + self, monkeypatch + ): + """Image-edit responses report a single ``cached_tokens`` count. + Charge text input first against the standard cache rate; the + remainder is billed at the dedicated image cache rate. Avoids + double-billing the text portion at the image cache rate. """ monkeypatch.setitem( litellm.model_cost, @@ -226,6 +228,8 @@ class TestDefaultImageCostCalculator: }, ) + # cached_in (300) > text_in (10) so 10 are charged at the text cache + # rate and the remaining 290 at the image cache rate. cost = default_image_cost_calculator( model="openai/synthetic-image-cache-model", custom_llm_provider="openai", @@ -233,11 +237,11 @@ class TestDefaultImageCostCalculator: n=1, size="1280x720", image_response=_image_response( - text_in=510, image_in=1452, cached_in=300, image_out=5488 + text_in=10, image_in=1452, cached_in=300, image_out=5488 ), ) - # text fully uncached, cached_tokens charged at image cache rate - expected = 510 * 5e-6 + 300 * 2e-6 + 1452 * 8e-6 + 5488 * 3e-5 + # text uncached: 0, text cache: 10, image uncached: 1162, image cache: 290 + expected = 0 * 5e-6 + 10 * 1.25e-6 + 1162 * 8e-6 + 290 * 2e-6 + 5488 * 3e-5 assert abs(cost - expected) < 1e-9 def test_token_fallback_returns_zero_for_free_model(self, monkeypatch):