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:
parent
72211d18b7
commit
1bee0924bb
2 changed files with 87 additions and 12 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue