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.
This commit is contained in:
Daniel Han 2026-05-22 16:50:24 +00:00 committed by danielhanchen
commit 1bee0924bb
2 changed files with 87 additions and 12 deletions

View file

@ -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

View file

@ -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)