Diagnostic LLM APIs
Claude Code sessions billed with zero cache reads
Two developers on the same team, the same repository, the same model, and one of them costs four times what the other does. The expensive one is not doing more work — fewer commits, in fact — and the cheap one has not configured anything. The difference is a habit: one of them keeps a session open and talks to it, and the other opens a fresh session for every question, which feels tidier and means the project context, the tool definitions and the file contents are paid for again at full rate every single time.
This is a different report from the one the other caching notes read. GET /v1/organizations/usage_report/claude_code?starting_at=YYYY-MM-DD&limit=1000 with an Admin API key returns one record per actor per UTC day, and it cannot be joined to the messages usage report at all — different grain, different keys, no shared id. It is the only place per-developer Claude Code behaviour is visible.
Per record, read core_metrics.num_sessions and every model_breakdown[] entry's tokens.input, tokens.cache_read and tokens.cache_creation, plus estimated_cost.amount. The finding is num_sessions >= 2 with cache_read == 0: two or more sessions in a day and not one token ever read back from a cached prefix.
Split the zero. cache_creation == 0 as well means nothing is being cached at all. cache_creation > 0 with cache_read == 0 means entries are being written, at a premium, and never matched. The two have different causes and the second is the more expensive mistake.
It matters more here than almost anywhere else because Claude Code sends very large prefixes. The gap between a read at 0.1x and uncached input at 1.0x is most of the per-developer bill, and nothing about it is visible to the developer.
The problem in plain words
Everything Claude Code does well depends on a long, stable prefix: the project context file, the tool definitions, the contents of the files it has already read. That prefix is re-sent on every turn, because the API is stateless, and the only thing standing between you and paying full price for it every time is a cache entry that matches byte for byte and is still alive.
The failure is behavioural rather than technical, which is why no configuration audit finds it. A session that runs for forty turns writes the prefix once and reads it thirty-nine times. Forty separate one-turn sessions write it forty times and read it never, and there is no error, no warning and no difference in the answers. The two workflows are indistinguishable from the inside and differ by roughly an order of magnitude on the input half of the bill.
It also hides inside an average. Rolled up to the organization the cached share looks respectable, because the developers who keep long sessions carry it. The report is per actor precisely so that the four people whose share is zero can be seen at all, and they will not appear in any aggregate that anyone else is looking at.
Why it happens
This is not the messages usage report, and the distinction is the reason the note exists. Two published notes already read cache_creation and cache_read on /v1/organizations/usage_report/messages, one for a cached share of zero and one for writes that outnumber reads. Those read organization traffic grouped by model, key or workspace. This reads /v1/organizations/usage_report/claude_code, whose unit is a person and a day, whose records carry session counts and tool actions that exist nowhere else, and which cannot be joined to the other report by any field. Same two token names, different report, different subject.
One session is never evidence. A single session with a single turn has no earlier turn to read a prefix back from, so a zero there is arithmetic rather than a finding. The floor is num_sessions >= 2, and the script prints the session count beside every verdict so a reader can see which side of it a person fell on.
Writes with no reads is a different diagnosis from no cache at all. Zero writes and zero reads means the prefix is never being cached, which is usually very short sessions. Writes with no reads means something is invalidating the prefix between turns — a timestamp, a re-ordered tool list, per-turn context injected ahead of the stable block — and it is worse, because five-minute cache writes cost more than plain input. The write-without-read note covers that mechanism properly on the other report, and this script names it rather than restating it.
The savings cannot be computed and the script will not pretend otherwise. Cache reads bill at 0.1x, so it is tempting to multiply. But the report does not say how much of an actor's tokens.input was a reusable prefix and how much was genuinely new, and that ratio is the whole calculation. The output pairs the observed cost with the observed shares and refuses to produce a savings figure, because a plausible number here would be indistinguishable from a real one.
The report has real edges and the output says them out loud. It covers Claude Code on the Claude API only — usage through Bedrock, Google Cloud, Foundry or Claude Platform on AWS is not reported here at all. Records are daily UTC aggregates and only those older than one hour are returned, so today is always incomplete. And the actor is an email address, which is personal data walking into a terminal, so the script masks it unless you ask for it in full.
The fix, as a flow
This reads a report the other caching notes have never touched. Its unit is one person on one UTC day, it carries session counts that exist nowhere else, and it cannot be joined to the messages usage report by any field. The two workflows below are indistinguishable from inside the editor and differ by about an order of magnitude on the input half of the bill.
How to fix it
Use an Admin API key and request one UTC day at a time
GET /v1/organizations/usage_report/claude_code?starting_at=YYYY-MM-DD&limit=1000, paging on next_page until has_more is false, once per day in the window. The script starts at yesterday, because only records older than an hour are returned and today's would be a partial day masquerading as a light one.
Fold the model breakdown per actor
An actor's record carries a model_breakdown[] array, not a single total. Sum tokens.input, tokens.cache_read, tokens.cache_creation and estimated_cost.amount across every entry, and keep core_metrics.num_sessions from the record itself. Reading only the first breakdown entry is the easy mistake and it understates anyone who used two models.
Apply the session floor before you grade anything
Fewer than two sessions in the window is not a finding, whatever the cache numbers say. There was nothing to read back. The script reports those separately rather than padding the list with people who used the tool once.
Split the zero into never cached and never matched
Reads and writes both zero: the prefix is never being cached. Writes present, reads zero: it is being cached and never matched, which costs more than not caching it. Different repairs, so different labels.
Print the actors, masked, with the sessions and the cost beside them
Email addresses are personal data. The default output masks the local part; a flag prints them in full for the conversation where you actually need to reach someone. No savings estimate appears, because the report does not carry the ratio that would make one honest.
How to check it worked
Re-run on the same window a week after the habit changes. A developer who moves from fresh sessions to continued ones should show a real cache_read share and a lower cost per session on the same volume of work. The session count is the control: if it fell and the reads did not appear, the change was fewer sessions rather than longer ones.
python3 claude_code_cache_coverage.py --days 7
# no-cache-at-all a***@example.com 11 session(s), 0% of input read from cache, no cache writes either, $41.20
# repair: check whether these sessions are one prompt each. A prefix is only reusable across turns of the same session.
# writes-never-read b***@example.com 6 session(s), 0% read with 2.1M token(s) written, $58.90
# repair: entries are being written and never matched. Something ahead of the stable block is changing between turns.
# cached c***@example.com 4 session(s), 84% of input read from cache, $9.40
# 7 actor(s) over 7 day(s), 2 finding(s)
# no savings figure: the report does not say how much of tokens.input was reusable prefix
The full code
One GET per day, paged, and eight pure functions. The actor reader, which has to cope with a user actor, an api actor and neither; the mask, because an email address is personal data and the default should not print it; the token reader; the fold across model_breakdown[], which is the step everyone shortcuts; the cost parser, which treats estimated_cost.amount as a decimal string of cents rather than a float; the read share; the verdict with its session floor; and the repair lines. There is no savings estimator, deliberately.
"""Find Claude Code actors whose sessions never read a cached prefix.
Read only. One paged GET per UTC day against the Claude Code usage report with
an Admin API key. No message is sent and nothing is written.
This is a different report from the messages usage report. Its unit is an actor
and a day, it carries session counts and tool actions that exist nowhere else,
and it cannot be joined to the other report by any field. Two token names are
the same; nothing else is.
The report covers Claude Code on the Claude API only. Usage through Bedrock,
Google Cloud, Foundry or Claude Platform on AWS is not reported here, so a
finding of "no evidence" is not a finding of "no problem" for those paths.
No savings figure is printed. Cache reads bill at 0.1x, but the report does not
say how much of tokens.input was reusable prefix and how much was genuinely
new, and that ratio is the entire calculation.
"""
import argparse
import datetime as dt
import decimal
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("claude_code_cache_coverage")
API = "https://api.anthropic.com/v1"
VERSION = "2023-06-01"
FINDINGS = ("no-cache-at-all", "writes-never-read", "thin-cache")
def actor_name(record):
"""Who the record belongs to. Pure. Both actor shapes, plus neither.
A user actor carries an email address; an api actor carries a key name.
A reader that knows only the first drops every service account silently,
which is the half of the roster nobody is watching.
"""
actor = (record or {}).get("actor")
if not isinstance(actor, dict):
return "unattributed"
for field in ("email_address", "api_key_name"):
value = str(actor.get(field) or "").strip()
if value:
return value
return "unattributed"
def mask(name):
"""Hide the local part of an email address. Pure. Non-emails pass through.
The actor on this report is usually a person, and a per-developer cost
table is personal data. Masking by default costs nothing and makes the
output safe to paste into a channel.
"""
text = str(name or "").strip()
if "@" not in text:
return text or "unattributed"
local, _, domain = text.partition("@")
if not local:
return text
return local[0] + "***@" + domain
def tokens_of(entry):
"""The four token counts off one model_breakdown entry. Pure."""
tokens = (entry or {}).get("tokens")
tokens = tokens if isinstance(tokens, dict) else {}
out = {}
for field in ("input", "output", "cache_read", "cache_creation"):
try:
out[field] = max(0, int(tokens.get(field) or 0))
except (TypeError, ValueError):
out[field] = 0
return out
def cost_cents(entry):
"""estimated_cost.amount as a Decimal of cents. Pure. 0 when unreadable.
A decimal string, parsed as a decimal. Money through a float is how a
per-developer table ends up disagreeing with itself in the third place.
"""
cost = (entry or {}).get("estimated_cost")
cost = cost if isinstance(cost, dict) else {}
try:
return decimal.Decimal(str(cost.get("amount") or "0"))
except (decimal.InvalidOperation, ValueError):
return decimal.Decimal("0")
def fold(pages):
"""Fold every record into one row per actor. Pure.
Sums across model_breakdown[] rather than reading the first entry, and
sums sessions across days. An actor who used two models in one day has two
entries, and taking the first understates them by whatever the second held.
"""
rows = {}
for page in pages or []:
for record in (page or {}).get("data") or []:
if not isinstance(record, dict):
continue
who = actor_name(record)
row = rows.setdefault(who, {
"sessions": 0, "days": 0, "input": 0, "output": 0,
"cache_read": 0, "cache_creation": 0,
"cents": decimal.Decimal("0"), "models": set()})
row["days"] += 1
core = record.get("core_metrics")
core = core if isinstance(core, dict) else {}
try:
row["sessions"] += max(0, int(core.get("num_sessions") or 0))
except (TypeError, ValueError):
pass
for entry in record.get("model_breakdown") or []:
counts = tokens_of(entry)
for field, value in counts.items():
row[field] += value
row["cents"] += cost_cents(entry)
model = str((entry or {}).get("model") or "").strip()
if model:
row["models"].add(model)
return rows
def read_share(row):
"""Share of an actor's input that was read back from cache. Pure.
Reads over reads plus uncached input. Cache writes are deliberately not in
the denominator: they are a cost, not a hit, and putting them there makes a
prefix that is written and never matched look partly cached.
"""
data = row or {}
reads = max(0, int(data.get("cache_read") or 0))
fresh = max(0, int(data.get("input") or 0))
total = reads + fresh
if total <= 0:
return 0.0
return reads / float(total)
def cost_per_session(row):
"""Cents per session for one actor. Pure. None when there are no sessions."""
data = row or {}
sessions = max(0, int(data.get("sessions") or 0))
if sessions <= 0:
return None
return decimal.Decimal(data.get("cents") or 0) / decimal.Decimal(sessions)
def verdict(row, min_sessions=2, min_input=100_000, floor=0.10):
"""Classify one actor's cache behaviour. Pure. Returns (state, detail)."""
data = row or {}
sessions = max(0, int(data.get("sessions") or 0))
reads = max(0, int(data.get("cache_read") or 0))
writes = max(0, int(data.get("cache_creation") or 0))
fresh = max(0, int(data.get("input") or 0))
if sessions < min_sessions:
return ("too-few-sessions",
"%d session(s) in the window: there was no earlier turn for a "
"prefix to be read back from, so a zero here is arithmetic "
"rather than a finding" % sessions)
if reads + fresh < min_input:
return ("low-volume",
"%d session(s) and %d input token(s), too few to conclude "
"anything" % (sessions, reads + fresh))
share = read_share(data)
if reads == 0 and writes == 0:
return ("no-cache-at-all",
"%d session(s), 0%% of input read from cache, and no cache "
"writes either: the prefix is never being cached at all"
% sessions)
if reads == 0:
return ("writes-never-read",
"%d session(s), 0%% read with %.1fM token(s) written: entries "
"are being created at a premium and never matched"
% (sessions, writes / 1e6))
if share < floor:
return ("thin-cache",
"%d session(s), %.0f%% of input read from cache, under the "
"floor of %.0f%%" % (sessions, share * 100, floor * 100))
return ("cached",
"%d session(s), %.0f%% of input read from cache"
% (sessions, share * 100))
def repair_lines(state):
"""The repair for one classified actor. Pure. Printed, never performed."""
if state == "no-cache-at-all":
return [
"check whether these sessions are one prompt each. A prefix is "
"only reusable across turns of the same session, so a fresh "
"session per question pays full rate for the project context, the "
"tool definitions and every file already read.",
"continuing a session rather than starting one is the whole fix, "
"and it is a habit rather than a setting.",
]
if state == "writes-never-read":
return [
"entries are being written and never matched, so something ahead "
"of the stable block is changing between turns.",
"this is the more expensive of the two zeros: cache writes cost "
"more than plain input, so the current state is worse than not "
"caching at all.",
]
if state == "thin-cache":
return [
"some turns are matching and most are not. Look for a mix of long "
"sessions and one-shot invocations under the same actor before "
"concluding the prefix is unstable.",
]
return []
def get(session, path, params):
r = session.get(API + path, params=params, timeout=60)
if r.status_code in (401, 403):
raise SystemExit("%d from Anthropic: /v1/organizations/* needs an Admin "
"API key (sk-ant-admin...), not a workspace key"
% r.status_code)
r.raise_for_status()
return r.json()
def pages(session, path, params):
"""Walk one day of the paginated Claude Code usage report."""
params = dict(params)
while True:
page = get(session, path, params)
yield page
if not page.get("has_more") or not page.get("next_page"):
return
params["page"] = page["next_page"]
def day_strings(days, today=None):
"""The UTC dates to request, newest first. Pure. Today is excluded.
Only records older than an hour are returned, so today is always a partial
day, and a partial day reads as a quiet one.
"""
end = today or dt.datetime.now(dt.timezone.utc).date()
return [(end - dt.timedelta(days=n)).isoformat()
for n in range(1, max(1, int(days)) + 1)]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=7,
help="UTC days to read, ending yesterday (default 7)")
ap.add_argument("--min-sessions", type=int, default=2,
help="sessions below which no claim is made (default 2)")
ap.add_argument("--floor", type=float, default=0.10,
help="cache read share below which coverage is thin")
ap.add_argument("--show-actors", action="store_true",
help="print email addresses in full instead of masked")
ap.add_argument("--show-all", action="store_true",
help="also print actors whose caching is healthy")
args = ap.parse_args()
admin = os.environ.get("ANTHROPIC_ADMIN_KEY")
if not admin:
log.error("set ANTHROPIC_ADMIN_KEY to an Admin API key (sk-ant-admin...); "
"a workspace key cannot read /v1/organizations/*")
return 2
s = requests.Session()
s.headers.update({"x-api-key": admin, "anthropic-version": VERSION})
collected = []
days = day_strings(args.days)
for day in days:
collected.extend(pages(s, "/organizations/usage_report/claude_code",
{"starting_at": day, "limit": 1000}))
rows = fold(collected)
if not rows:
log.info("no Claude Code records over %d day(s). This report covers "
"Claude Code on the Claude API only: Bedrock, Google Cloud, "
"Foundry and Claude Platform on AWS usage is not here.",
len(days))
return 0
bad = 0
for who in sorted(rows, key=lambda a: -rows[a]["cents"]):
row = rows[who]
state, detail = verdict(row, args.min_sessions, floor=args.floor)
label = who if args.show_actors else mask(who)
line = "%-20s %-22s %s, $%.2f" % (state, label, detail,
float(row["cents"]) / 100.0)
if state in FINDINGS:
bad += 1
log.warning(line)
for repair in repair_lines(state):
log.warning(" repair: %s", repair)
elif args.show_all or state != "cached":
log.info(line)
log.info("%d actor(s) over %d day(s), %d finding(s)",
len(rows), len(days), bad)
log.info("no savings figure: the report does not say how much of "
"tokens.input was reusable prefix, and that ratio is the whole "
"calculation")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find Claude Code actors whose sessions never read a cached prefix.
*
* Read only. One paged GET per UTC day against the Claude Code usage report
* with an Admin API key.
*
* This is a different report from the messages usage report: its unit is an
* actor and a day, and it cannot be joined to the other by any field. It also
* covers Claude Code on the Claude API only, so Bedrock, Google Cloud, Foundry
* and Claude Platform on AWS usage is simply absent.
*
* No savings figure is printed. The report does not say how much of
* tokens.input was reusable prefix, and that ratio is the whole calculation.
*/
const API = 'https://api.anthropic.com/v1';
const VERSION = '2023-06-01';
const FINDINGS = new Set(['no-cache-at-all', 'writes-never-read', 'thin-cache']);
/** Who the record belongs to. Pure. Both actor shapes, plus neither. */
export function actorName(record) {
const actor = record?.actor;
if (!actor || typeof actor !== 'object') return 'unattributed';
for (const field of ['email_address', 'api_key_name']) {
const value = String(actor[field] ?? '').trim();
if (value) return value;
}
return 'unattributed';
}
/** Hide the local part of an email address. Pure. Non-emails pass through. */
export function mask(name) {
const text = String(name ?? '').trim();
if (!text.includes('@')) return text || 'unattributed';
const at = text.indexOf('@');
const local = text.slice(0, at);
if (!local) return text;
return `${local[0]}***${text.slice(at)}`;
}
/** The four token counts off one model_breakdown entry. Pure. */
export function tokensOf(entry) {
const tokens = entry?.tokens && typeof entry.tokens === 'object' ? entry.tokens : {};
const out = {};
for (const field of ['input', 'output', 'cache_read', 'cache_creation']) {
const n = Number(tokens[field] ?? 0);
out[field] = Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0;
}
return out;
}
/**
* estimated_cost.amount in cents. Pure. 0 when unreadable.
* Kept as a number of cents rather than dollars so the rounding happens once,
* at the point of printing, instead of on every addition.
*/
export function costCents(entry) {
const cost = entry?.estimated_cost && typeof entry.estimated_cost === 'object'
? entry.estimated_cost : {};
const n = Number(cost.amount ?? 0);
return Number.isFinite(n) ? n : 0;
}
/** Fold every record into one row per actor. Pure. Sums across model_breakdown. */
export function fold(pages) {
const rows = {};
for (const page of pages ?? []) {
for (const record of page?.data ?? []) {
if (!record || typeof record !== 'object') continue;
const who = actorName(record);
const row = rows[who] ?? { sessions: 0, days: 0, input: 0, output: 0,
cache_read: 0, cache_creation: 0, cents: 0,
models: new Set() };
rows[who] = row;
row.days += 1;
const core = record.core_metrics && typeof record.core_metrics === 'object'
? record.core_metrics : {};
const sessions = Number(core.num_sessions ?? 0);
if (Number.isFinite(sessions)) row.sessions += Math.max(0, Math.trunc(sessions));
for (const entry of record.model_breakdown ?? []) {
const counts = tokensOf(entry);
for (const [field, value] of Object.entries(counts)) row[field] += value;
row.cents += costCents(entry);
const model = String(entry?.model ?? '').trim();
if (model) row.models.add(model);
}
}
}
return rows;
}
/**
* Share of an actor's input that was read back from cache. Pure.
* Writes are not in the denominator: they are a cost, not a hit.
*/
export function readShare(row) {
const reads = Math.max(0, Number(row?.cache_read ?? 0));
const fresh = Math.max(0, Number(row?.input ?? 0));
const total = reads + fresh;
if (total <= 0) return 0;
return reads / total;
}
/** Cents per session for one actor. Pure. Null when there are no sessions. */
export function costPerSession(row) {
const sessions = Math.max(0, Number(row?.sessions ?? 0));
if (sessions <= 0) return null;
return Number(row?.cents ?? 0) / sessions;
}
/** Classify one actor's cache behaviour. Pure. Returns [state, detail]. */
export function verdict(row, minSessions = 2, minInput = 100_000, floor = 0.1) {
const data = row ?? {};
const sessions = Math.max(0, Number(data.sessions ?? 0));
const reads = Math.max(0, Number(data.cache_read ?? 0));
const writes = Math.max(0, Number(data.cache_creation ?? 0));
const fresh = Math.max(0, Number(data.input ?? 0));
if (sessions < minSessions) {
return ['too-few-sessions',
`${sessions} session(s) in the window: there was no earlier turn for a ` +
'prefix to be read back from, so a zero here is arithmetic rather than ' +
'a finding'];
}
if (reads + fresh < minInput) {
return ['low-volume',
`${sessions} session(s) and ${reads + fresh} input token(s), too few to ` +
'conclude anything'];
}
const share = readShare(data);
if (reads === 0 && writes === 0) {
return ['no-cache-at-all',
`${sessions} session(s), 0% of input read from cache, and no cache ` +
'writes either: the prefix is never being cached at all'];
}
if (reads === 0) {
return ['writes-never-read',
`${sessions} session(s), 0% read with ${(writes / 1e6).toFixed(1)}M ` +
'token(s) written: entries are being created at a premium and never matched'];
}
if (share < floor) {
return ['thin-cache',
`${sessions} session(s), ${(share * 100).toFixed(0)}% of input read ` +
`from cache, under the floor of ${(floor * 100).toFixed(0)}%`];
}
return ['cached',
`${sessions} session(s), ${(share * 100).toFixed(0)}% of input read from cache`];
}
/** The repair for one classified actor. Pure. Printed, never performed. */
export function repairLines(state) {
if (state === 'no-cache-at-all') {
return [
'check whether these sessions are one prompt each. A prefix is only ' +
'reusable across turns of the same session, so a fresh session per ' +
'question pays full rate for the project context, the tool definitions ' +
'and every file already read.',
'continuing a session rather than starting one is the whole fix, and it ' +
'is a habit rather than a setting.',
];
}
if (state === 'writes-never-read') {
return [
'entries are being written and never matched, so something ahead of the ' +
'stable block is changing between turns.',
'this is the more expensive of the two zeros: cache writes cost more ' +
'than plain input, so the current state is worse than not caching at all.',
];
}
if (state === 'thin-cache') {
return ['some turns are matching and most are not. Look for a mix of long ' +
'sessions and one-shot invocations under the same actor before ' +
'concluding the prefix is unstable.'];
}
return [];
}
/** The UTC dates to request, newest first. Pure. Today is excluded. */
export function dayStrings(days, today = new Date()) {
const out = [];
const count = Math.max(1, Math.trunc(Number(days) || 1));
for (let n = 1; n <= count; n += 1) {
const d = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(),
today.getUTCDate() - n));
out.push(d.toISOString().slice(0, 10));
}
return out;
}
async function get(key, path, params) {
const url = new URL(API + path);
for (const [k, v] of params) url.searchParams.append(k, v);
const res = await fetch(url, {
headers: { 'x-api-key': key, 'anthropic-version': VERSION },
});
if (res.status === 401 || res.status === 403) {
throw new Error(`${res.status} from Anthropic: /v1/organizations/* needs ` +
'an Admin API key (sk-ant-admin...), not a workspace key');
}
if (!res.ok) throw new Error(`${res.status} from ${path}`);
return res.json();
}
async function main() {
const admin = process.env.ANTHROPIC_ADMIN_KEY;
if (!admin) {
console.error('set ANTHROPIC_ADMIN_KEY to an Admin API key (sk-ant-admin...); ' +
'a workspace key cannot read /v1/organizations/*');
process.exitCode = 2;
return;
}
const days = Number(process.env.DAYS ?? 7);
const showActors = process.env.SHOW_ACTORS === '1';
const showAll = process.env.SHOW_ALL === '1';
const dates = dayStrings(days);
const collected = [];
for (const day of dates) {
const base = [['starting_at', day], ['limit', '1000']];
let params = base;
for (;;) {
const page = await get(admin, '/organizations/usage_report/claude_code', params);
collected.push(page);
if (!page?.has_more || !page?.next_page) break;
params = [...base, ['page', page.next_page]];
}
}
const rows = fold(collected);
const actors = Object.keys(rows);
if (actors.length === 0) {
console.log(`no Claude Code records over ${dates.length} day(s). This ` +
'report covers Claude Code on the Claude API only: Bedrock, ' +
'Google Cloud, Foundry and Claude Platform on AWS usage is not here.');
return;
}
let bad = 0;
for (const who of actors.sort((a, b) => rows[b].cents - rows[a].cents)) {
const row = rows[who];
const [state, detail] = verdict(row, 2);
const label = showActors ? who : mask(who);
const line = `${state.padEnd(20)} ${label.padEnd(22)} ${detail}, ` +
`$${(row.cents / 100).toFixed(2)}`;
if (FINDINGS.has(state)) {
bad += 1;
console.warn(line);
for (const repair of repairLines(state)) console.warn(` repair: ${repair}`);
} else if (showAll || state !== 'cached') {
console.log(line);
}
}
console.log(`${actors.length} actor(s) over ${dates.length} day(s), ${bad} finding(s)`);
console.log('no savings figure: the report does not say how much of ' +
'tokens.input was reusable prefix, and that ratio is the whole ' +
'calculation');
process.exitCode = bad ? 1 : 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The headline is an actor with eleven sessions in a week, two million input tokens and not one cache read, next to a colleague on the same data with a healthy share — the whole note is that those two people are indistinguishable from the inside. The test immediately after it is the guard: the same zero on a single session is arithmetic, not a finding, and has to come back as too-few-sessions. Then the split between never cached and never matched, the fold across a two-model breakdown that a first-entry reader would halve, both actor shapes plus neither, the mask that keeps an email out of a pasted terminal, and the day list that stops today's partial data being read as a quiet day.
import datetime as dt
from claude_code_cache_coverage import (actor_name, cost_cents,
cost_per_session, day_strings, fold,
mask, read_share, repair_lines,
tokens_of, verdict)
def breakdown(model, input_tokens, cache_read=0, cache_creation=0, cents="0"):
return {"model": model,
"tokens": {"input": input_tokens, "output": 12_000,
"cache_read": cache_read,
"cache_creation": cache_creation},
"estimated_cost": {"currency": "USD", "amount": cents}}
def record(email, sessions, entries):
return {"date": "2026-08-30",
"actor": {"type": "user_actor", "email_address": email},
"core_metrics": {"num_sessions": sessions,
"lines_of_code": {"added": 90, "removed": 12},
"commits_by_claude_code": 2},
"model_breakdown": entries}
def page(records):
return {"data": records, "has_more": False}
def test_two_developers_on_the_same_work_and_one_never_reads_a_prefix():
# The note in one assertion. Same repository, same model, same week; the
# difference is whether a session was continued or restarted.
rows = fold([page([
record("nobody@example.com", 11,
[breakdown("claude-opus-5", 2_000_000, cents="4120")]),
record("someone@example.com", 4,
[breakdown("claude-opus-5", 300_000, cache_read=1_600_000,
cache_creation=200_000, cents="940")]),
])])
state, detail = verdict(rows["nobody@example.com"])
assert state == "no-cache-at-all"
assert "11 session(s), 0%" in detail
assert any("turns of the same session" in line
for line in repair_lines(state))
good, good_detail = verdict(rows["someone@example.com"])
assert good == "cached"
assert "84% of input read from cache" in good_detail
def test_a_single_session_zero_is_arithmetic_not_a_finding():
rows = fold([page([
record("once@example.com", 1,
[breakdown("claude-opus-5", 2_000_000, cents="900")])])])
state, detail = verdict(rows["once@example.com"])
assert state == "too-few-sessions"
assert "no earlier turn" in detail
assert repair_lines(state) == []
def test_written_and_never_matched_is_the_more_expensive_zero():
rows = fold([page([
record("churn@example.com", 6,
[breakdown("claude-opus-5", 900_000, cache_creation=2_100_000,
cents="5890")])])])
state, detail = verdict(rows["churn@example.com"])
assert state == "writes-never-read"
assert "2.1M token(s) written" in detail
assert any("worse than not caching at all" in line
for line in repair_lines(state))
def test_the_whole_model_breakdown_is_summed_and_not_just_the_first_entry():
rows = fold([page([
record("two@example.com", 5, [
breakdown("claude-opus-5", 1_000_000, cache_read=500_000, cents="1000"),
breakdown("claude-haiku-4-5-20251001", 400_000, cache_read=100_000,
cents="250"),
])])])
row = rows["two@example.com"]
assert row["input"] == 1_400_000
assert row["cache_read"] == 600_000
assert row["cents"] == 1250
assert row["models"] == {"claude-opus-5", "claude-haiku-4-5-20251001"}
assert float(cost_per_session(row)) == 250.0
def test_sessions_and_cost_accumulate_across_days():
day = [record("daily@example.com", 3,
[breakdown("claude-opus-5", 500_000, cents="600")])]
rows = fold([page(day), page(day), page(day)])
assert rows["daily@example.com"]["sessions"] == 9
assert rows["daily@example.com"]["days"] == 3
assert rows["daily@example.com"]["cents"] == 1800
def test_both_actor_shapes_are_read_and_neither_is_handled():
assert actor_name({"actor": {"type": "user_actor",
"email_address": "a@example.com"}}) == \
"a@example.com"
assert actor_name({"actor": {"type": "api_actor",
"api_key_name": "ci-runner"}}) == "ci-runner"
assert actor_name({"actor": {}}) == "unattributed"
assert actor_name({}) == "unattributed"
assert actor_name(None) == "unattributed"
def test_an_email_address_is_masked_before_it_is_printed():
assert mask("someone@example.com") == "s***@example.com"
assert mask("ci-runner") == "ci-runner"
assert mask("") == "unattributed"
assert mask(None) == "unattributed"
def test_reads_over_reads_plus_input_and_writes_are_not_a_hit():
assert read_share({"cache_read": 900, "input": 100}) == 0.9
# A prefix written and never matched is not partly cached.
assert read_share({"cache_read": 0, "input": 100, "cache_creation": 900}) == 0.0
assert read_share({}) == 0.0
assert cost_per_session({"sessions": 0, "cents": 100}) is None
assert tokens_of(None) == {"input": 0, "output": 0, "cache_read": 0,
"cache_creation": 0}
assert tokens_of({"tokens": {"input": "x"}})["input"] == 0
assert cost_cents({"estimated_cost": {"amount": "12.50"}}) == 12.5
assert cost_cents({"estimated_cost": {"amount": "not money"}}) == 0
def test_today_is_never_requested_because_today_is_always_partial():
days = day_strings(3, dt.date(2026, 8, 31))
assert days == ["2026-08-30", "2026-08-29", "2026-08-28"]
assert day_strings(1, dt.date(2026, 1, 1)) == ["2025-12-31"]
assert fold([]) == {} and fold(None) == {}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { actorName, costCents, costPerSession, dayStrings, fold, mask,
readShare, repairLines, tokensOf, verdict }
from './claude-code-cache-coverage.mjs';
const breakdown = (model, input, cacheRead = 0, cacheCreation = 0, cents = 0) => ({
model,
tokens: { input, output: 12_000, cache_read: cacheRead,
cache_creation: cacheCreation },
estimated_cost: { currency: 'USD', amount: cents },
});
const record = (email, sessions, entries) => ({
date: '2026-08-30',
actor: { type: 'user_actor', email_address: email },
core_metrics: { num_sessions: sessions,
lines_of_code: { added: 90, removed: 12 },
commits_by_claude_code: 2 },
model_breakdown: entries,
});
const page = (records) => ({ data: records, has_more: false });
test('two developers on the same work and one never reads a prefix', () => {
const rows = fold([page([
record('nobody@example.com', 11, [breakdown('claude-opus-5', 2_000_000, 0, 0, 4120)]),
record('someone@example.com', 4,
[breakdown('claude-opus-5', 300_000, 1_600_000, 200_000, 940)]),
])]);
const [state, detail] = verdict(rows['nobody@example.com']);
assert.equal(state, 'no-cache-at-all');
assert.match(detail, /11 session/);
assert.ok(repairLines(state).some((l) => l.includes('turns of the same session')));
const [good, goodDetail] = verdict(rows['someone@example.com']);
assert.equal(good, 'cached');
assert.match(goodDetail, /84% of input read from cache/);
});
test('a single session zero is arithmetic not a finding', () => {
const rows = fold([page([
record('once@example.com', 1, [breakdown('claude-opus-5', 2_000_000, 0, 0, 900)]),
])]);
const [state, detail] = verdict(rows['once@example.com']);
assert.equal(state, 'too-few-sessions');
assert.match(detail, /no earlier turn/);
assert.deepEqual(repairLines(state), []);
});
test('written and never matched is the more expensive zero', () => {
const rows = fold([page([
record('churn@example.com', 6,
[breakdown('claude-opus-5', 900_000, 0, 2_100_000, 5890)]),
])]);
const [state, detail] = verdict(rows['churn@example.com']);
assert.equal(state, 'writes-never-read');
assert.match(detail, /2.1M token/);
assert.ok(repairLines(state).some((l) => l.includes('worse than not caching at all')));
});
test('the whole model breakdown is summed and not just the first entry', () => {
const rows = fold([page([
record('two@example.com', 5, [
breakdown('claude-opus-5', 1_000_000, 500_000, 0, 1000),
breakdown('claude-haiku-4-5-20251001', 400_000, 100_000, 0, 250),
]),
])]);
const row = rows['two@example.com'];
assert.equal(row.input, 1_400_000);
assert.equal(row.cache_read, 600_000);
assert.equal(row.cents, 1250);
assert.deepEqual([...row.models].sort(),
['claude-haiku-4-5-20251001', 'claude-opus-5']);
assert.equal(costPerSession(row), 250);
});
test('sessions and cost accumulate across days', () => {
const day = [record('daily@example.com', 3,
[breakdown('claude-opus-5', 500_000, 0, 0, 600)])];
const rows = fold([page(day), page(day), page(day)]);
assert.equal(rows['daily@example.com'].sessions, 9);
assert.equal(rows['daily@example.com'].days, 3);
assert.equal(rows['daily@example.com'].cents, 1800);
});
test('both actor shapes are read and neither is handled', () => {
assert.equal(actorName({ actor: { type: 'user_actor',
email_address: 'a@example.com' } }),
'a@example.com');
assert.equal(actorName({ actor: { type: 'api_actor', api_key_name: 'ci-runner' } }),
'ci-runner');
assert.equal(actorName({ actor: {} }), 'unattributed');
assert.equal(actorName({}), 'unattributed');
assert.equal(actorName(null), 'unattributed');
});
test('an email address is masked before it is printed', () => {
assert.equal(mask('someone@example.com'), 's***@example.com');
assert.equal(mask('ci-runner'), 'ci-runner');
assert.equal(mask(''), 'unattributed');
assert.equal(mask(null), 'unattributed');
});
test('reads over reads plus input and writes are not a hit', () => {
assert.equal(readShare({ cache_read: 900, input: 100 }), 0.9);
assert.equal(readShare({ cache_read: 0, input: 100, cache_creation: 900 }), 0);
assert.equal(readShare({}), 0);
assert.equal(costPerSession({ sessions: 0, cents: 100 }), null);
assert.deepEqual(tokensOf(null),
{ input: 0, output: 0, cache_read: 0, cache_creation: 0 });
assert.equal(tokensOf({ tokens: { input: 'x' } }).input, 0);
assert.equal(costCents({ estimated_cost: { amount: '12.50' } }), 12.5);
assert.equal(costCents({ estimated_cost: { amount: 'not money' } }), 0);
});
test('today is never requested because today is always partial', () => {
assert.deepEqual(dayStrings(3, new Date('2026-08-31T09:00:00Z')),
['2026-08-30', '2026-08-29', '2026-08-28']);
assert.deepEqual(dayStrings(1, new Date('2026-01-01T00:30:00Z')), ['2025-12-31']);
assert.deepEqual(fold([]), {});
assert.deepEqual(fold(null), {});
});
FAQ
Is this the same check as the prompt caching notes?
No, and the difference is the report rather than the wording. Those read /v1/organizations/usage_report/messages, which aggregates organization traffic by model, key or workspace. This reads /v1/organizations/usage_report/claude_code, whose unit is one actor on one UTC day and which carries session counts and tool-action counts that appear nowhere else. The two reports share two token field names and nothing else, and they cannot be joined by any field, so a healthy cached share on one tells you nothing about a person on the other.
Why is one session not enough to make a finding?
Because there was nothing to read back. Caching pays off when a later turn matches a prefix an earlier turn wrote, so a session with a single turn has no earlier turn and reports zero reads with nothing wrong. The script sets the floor at two sessions in the window and prints the session count next to every verdict, so a reader can see which side of the floor a person fell on rather than trusting the label.
What is the difference between no cache writes and writes with no reads?
Cause and cost. Both zero means nothing is being cached, which is usually very short sessions. Writes present with reads at zero means entries are being created and never matched, so something ahead of the stable block changes between turns, and it is the worse of the two because cache writes bill above plain input. That state is genuinely worse than not caching at all, and the script says so instead of grading them together.
Why does the output not tell me how much money this is costing?
Because the number is not derivable from what the report returns. Cache reads bill at a tenth of base input, so the arithmetic is easy, but it needs to know what fraction of an actor's tokens.input was reusable prefix and what fraction was genuinely new text, and the report does not carry that split. Any figure produced here would be a guess presented in the same font as a measurement, which is worse than no figure.
Does this cover Claude Code running through Bedrock or Vertex?
It does not. The Claude Code usage report covers Claude Code on the Claude API only; usage through Bedrock, Google Cloud, Foundry or Claude Platform on AWS is not reported on this endpoint at all. If part of your organization runs on one of those, the absence of records for those developers is a coverage gap rather than a clean bill of health, and the script prints that caveat with the empty result rather than reporting nothing to see.
Related field notes
- Prompt caching that was never switched on at all
- Cache entries written at a premium and never read back
- The same report read for the diffs nobody kept
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Claude Code Analytics API — Claude Docs
- Analytics API — Claude Docs
- Prompt caching — Claude Docs
- Pricing — Claude Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.