From 1bee0924bbc5833a35ce9d79d49e8110b4fa34ef Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 16:50:24 +0000 Subject: [PATCH] Studio: dedupe cache buckets when costing chat-style Anthropic usage When the caller hands in Studio's chat-style envelope (``prompt_tokens`` emitted by ``_build_usage_chunk``) for Anthropic, that value already folds ``cache_creation_input_tokens`` + ``cache_read_input_tokens`` into the total. The previous follow-up accepted the chat-style key but then re-added both cache buckets in ``billable_input_tokens`` and ``input_usd``, double-counting cache tokens on every Anthropic chat-style call. Detect which envelope landed (``input_tokens`` present = raw upstream; absent + ``prompt_tokens`` present = Studio chat-style) and peel the cache buckets off for Anthropic before the downstream math so both envelopes produce identical costs. OpenAI: ``input_tokens`` and Studio's ``prompt_tokens`` both already include ``cache_read`` and exclude any notional ``cache_creation``, so the OpenAI path stays a straight passthrough. Tests (2 new): both envelopes match for Anthropic on a triple (uncached + cache_creation + cache_read); OpenAI envelopes match on a cached-tokens fixture. --- studio/backend/core/inference/pricing.py | 38 ++++++++++----- studio/backend/tests/test_pricing.py | 61 ++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/studio/backend/core/inference/pricing.py b/studio/backend/core/inference/pricing.py index 6627ac614a..dc021dc020 100644 --- a/studio/backend/core/inference/pricing.py +++ b/studio/backend/core/inference/pricing.py @@ -197,20 +197,33 @@ def calculate_cost( # Accept both shapes: raw Anthropic / OpenAI Responses usage # carries ``input_tokens`` / ``output_tokens``; the OpenAI-Chat- - # style usage chunk Studio re-emits (``_build_usage_chunk``) uses - # ``prompt_tokens`` / ``completion_tokens``. Either is fine here - # so a caller can hand whichever envelope landed first. - input_tokens = int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0) + # style envelope Studio re-emits (``_build_usage_chunk``) uses + # ``prompt_tokens`` / ``completion_tokens``. Normalise to a single + # ``uncached_input`` view because the two envelopes treat the + # cache buckets differently: + # + # raw Anthropic: input_tokens EXCLUDES cache_creation + cache_read + # raw OpenAI: input_tokens INCLUDES cache_read (no cache_create) + # Studio Anthropic: prompt_tokens INCLUDES cache_creation + cache_read + # Studio OpenAI: prompt_tokens == raw input_tokens (includes cache_read) + cache_creation = int(usage.get("cache_creation_input_tokens") or 0) + cache_read = int(usage.get("cache_read_input_tokens") or 0) + has_input_tokens = "input_tokens" in usage and usage.get("input_tokens") is not None + if has_input_tokens: + # Raw upstream envelope. + input_tokens = int(usage.get("input_tokens") or 0) + else: + # Studio chat-style envelope: prompt_tokens already folds the + # cache buckets for Anthropic, so peel them off to recover the + # raw uncached prompt count and keep downstream math symmetric. + prompt_tokens = int(usage.get("prompt_tokens") or 0) + if provider == "anthropic": + input_tokens = max(0, prompt_tokens - cache_creation - cache_read) + else: + input_tokens = prompt_tokens output_tokens = int( usage.get("output_tokens") or usage.get("completion_tokens") or 0 ) - cache_creation = int(usage.get("cache_creation_input_tokens") or 0) - cache_read = int(usage.get("cache_read_input_tokens") or 0) - # OpenAI Responses reports cached tokens under input_tokens_details - # but ALSO folds them into the top-level input_tokens, so we don't - # add cache_read into the billable total again below (Anthropic - # excludes cache buckets from input_tokens, OpenAI includes them -- - # the two providers differ here and the calculator must match). if provider == "openai": details = usage.get("input_tokens_details") or {} if isinstance(details, dict): @@ -218,7 +231,8 @@ def calculate_cost( # OpenAI: cache_read already counted inside input_tokens. out["billable_input_tokens"] = input_tokens + cache_creation else: - # Anthropic: input_tokens excludes cache_* buckets, add them all. + # Anthropic: input_tokens (post-normalisation) excludes cache + # buckets, so add them all back. out["billable_input_tokens"] = input_tokens + cache_creation + cache_read out["billable_output_tokens"] = output_tokens diff --git a/studio/backend/tests/test_pricing.py b/studio/backend/tests/test_pricing.py index 9c9c9f7fa3..bc1bce579c 100644 --- a/studio/backend/tests/test_pricing.py +++ b/studio/backend/tests/test_pricing.py @@ -493,3 +493,64 @@ def test_input_tokens_preferred_when_both_keys_present(): ) # input_tokens=2M wins -> 2 * 0.75 = 1.50. assert _isclose(out["input_usd"], 1.50), out + + +def test_anthropic_chat_style_prompt_tokens_dedupes_cache_buckets(): + """Anthropic prompt_tokens (Studio's chat-style envelope) already + folds cache_creation + cache_read into the total. The calculator + must NOT add them again or it double-counts billable input. + Regression for the Codex P1 on the pricing follow-up PR.""" + # 1M uncached + 200K cache_creation + 500K cache_read. + # Studio envelope: prompt_tokens = 1.7M (everything folded). + raw = calculate_cost( + "anthropic", + "claude-opus-4-7", + { + "input_tokens": 1_000_000, + "cache_creation_input_tokens": 200_000, + "cache_read_input_tokens": 500_000, + "output_tokens": 0, + }, + ) + chat = calculate_cost( + "anthropic", + "claude-opus-4-7", + { + "prompt_tokens": 1_700_000, + "cache_creation_input_tokens": 200_000, + "cache_read_input_tokens": 500_000, + "completion_tokens": 0, + }, + ) + # Both envelopes must price the same -- no double-count. + assert _isclose(chat["input_usd"], raw["input_usd"]), (chat, raw) + assert _isclose(chat["cache_write_usd"], raw["cache_write_usd"]), (chat, raw) + assert _isclose(chat["cache_read_usd"], raw["cache_read_usd"]), (chat, raw) + assert _isclose(chat["total_usd"], raw["total_usd"]), (chat, raw) + assert ( + chat["billable_input_tokens"] == raw["billable_input_tokens"] + ), (chat, raw) + + +def test_openai_chat_style_prompt_tokens_keeps_cache_read_semantics(): + """For OpenAI, prompt_tokens already includes cache_read just like + raw input_tokens, so both envelopes should price identically.""" + raw = calculate_cost( + "openai", + "gpt-5.5", + { + "input_tokens": 1_000_000, + "input_tokens_details": {"cached_tokens": 200_000}, + "output_tokens": 100_000, + }, + ) + chat = calculate_cost( + "openai", + "gpt-5.5", + { + "prompt_tokens": 1_000_000, + "cache_read_input_tokens": 200_000, + "completion_tokens": 100_000, + }, + ) + assert _isclose(chat["total_usd"], raw["total_usd"]), (chat, raw)