Diagnostic LLM APIs
output tokens, not input, are what the bill is made of
Every conversation about LLM cost starts with the prompt. The system prompt gets trimmed, the few-shot examples get cut, someone measures the context window. Then you group the cost report by token_type and find that three-quarters of the money is on the other side of the request, where none of the levers you just pulled reach.
Admin API key. GET /v1/organizations/cost_report?starting_at={T-30d}&limit=31&group_by[]=description and sum amount by token_type. Output is priced at five times input on every current model, and thinking tokens are billed as output tokens when they are generated, so the share tells you which lever is worth pulling.
The repairs are not interchangeable. An output-dominated bill responds to generating less — lower effort, tighter stop conditions, shorter formats. An input-dominated bill responds to prompt caching. Applying the wrong one produces a week of work and no change to the invoice.
The problem in plain words
The five-times ratio is the whole shape of the bill and almost nobody has it in their head. Twenty thousand tokens of context and four thousand tokens of answer feels like a request that is mostly input. Priced at 5:1 it is a request that is half output, and if adaptive thinking is running it is a request that is mostly output.
What makes it stick is that the cheap fix has already been applied. Prompt caching is well known, easy to enable, and genuinely effective, so teams turn it on, watch the input line fall, and conclude the problem is solved. The output line was always the larger number and it has not moved, because there is no caching discount on output. There is no discount on output at all. The only lever is generating fewer tokens.
Why it happens
Output costs five times input, per token, on every current model. That is a pricing fact, not a workload one, and it applies before anything about your traffic is considered. A request has to be five times more input-heavy than it looks before input is the bigger line.
Thinking tokens are output tokens. They are billed at the output rate when generated. Raising effort, or moving to a model that runs adaptive thinking when the parameter is omitted rather than treating omission as off, shifts spend onto the expensive side with no code change visible in a diff.
Caching moves only one line, and can move it the wrong way. Cache writes are billed at a premium over base input; cache reads are a fraction of it. Writes without enough reads to amortise them is a real state, and it looks like caching that is working right up until you compare the two numbers.
The cost report is the only place the split is visible. A per-request breakdown does not exist on either API. What exists is aggregate money grouped by token_type and aggregate tokens grouped by model, and the argument you can make from them is about proportion rather than about any individual call.
The fix, as a flow
The script sums money by token type rather than counting tokens, because output is priced at five times input and a token count is the wrong denominator for deciding which lever to pull.
How to fix it
Group the cost report by description
GET /v1/organizations/cost_report?starting_at={T-30d}&limit=31&group_by[]=description with an Admin API key. Each result carries amount, currency, token_type and a description. amount comes back as a decimal string, not a number; parsing it as if it were a float that already exists is the quiet way to sum nothing.
Bucket the token types into four, not fifteen
Input, output, cache read and cache write. New token type names appear as products ship — different cache durations, different tiers — so match on the shape of the name and put anything unrecognised in a visible "other" bucket rather than dropping it. A silently discarded token type is a share that adds up to less than a hundred percent and nobody notices.
Read the share, then pick the lever
Output above roughly seventy percent of spend means generating less is the only thing that will help. Input above sixty percent means caching is worth the work. In between, both help and neither is dramatic. This is a decision, not a metric, and it is worth writing down which one the numbers actually support before anyone opens a pull request.
Name the model carrying it
GET /v1/organizations/usage_report/messages?starting_at={T-30d}&bucket_width=1d&limit=31&group_by[]=model gives output_tokens and uncached_input_tokens per model per day. The model with the largest output share is where an effort change has the most effect; a step up in its output tokens with no matching input rise is a thinking or effort change rather than more traffic.
Print the change, do not make it
The suggestion is a lower effort setting on the model carrying the output spend, and a re-read of the same daily series a week later to see whether the share moved. Nothing here should be altering an inference setting from inside an audit script, and the Admin API cannot do it anyway.
How to check it worked
Re-run a week after the effort change. The output share should fall and total spend with it.
python3 anthropic_output_cost_audit.py --days 30
# balanced $1,204.55 over 30 day(s): output 52%, input 31%, cache read 14%, cache write 3%
# top model by output tokens: claude-sonnet-5 (61% of output)
# 0 finding(s)
The full code
Two GETs against the Claude Admin API, so ANTHROPIC_ADMIN_KEY has to be an Admin API key (sk-ant-admin...) — a workspace key is rejected by every /v1/organizations/* endpoint, and an Admin key cannot send a message even if it wanted to. The pure functions are the amount parser, which exists because the field is a string, the token-type bucketing, which has to survive names that do not exist yet, and the verdict that turns a share into a decision.
"""Report which side of a Claude request the bill is actually on.
Read only. Two GET requests and nothing else: ANTHROPIC_ADMIN_KEY must be an
Admin API key (sk-ant-admin...), because every /v1/organizations endpoint
rejects a workspace key. The repair is printed, never performed.
"""
import argparse
import datetime as dt
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("anthropic_output_cost_audit")
API = "https://api.anthropic.com/v1"
VERSION = "2023-06-01"
# Output is priced at five times input on every current model, so a request has
# to be markedly input-heavy before input is the larger line.
OUTPUT_MULTIPLE = 5
def amount(row):
"""Read a cost row's amount as a float. Pure.
The cost report returns amount as a decimal STRING, not a number. Summing
the raw values concatenates them in one language and throws in the other,
and the failure is silent enough to ship.
"""
raw = row.get("amount")
if raw is None or raw == "":
return 0.0
try:
return float(raw)
except (TypeError, ValueError):
return 0.0
def bucket_of(token_type):
"""Fold a token_type into one of five buckets. Pure.
Matched on the shape of the name rather than an exact list, because new
token types arrive with new cache durations and new tiers. Anything
unrecognised lands in "other" and stays visible; a silently dropped type is
a set of shares that quietly adds up to less than one.
"""
name = str(token_type or "").lower()
if not name:
return "other"
if "cache_creation" in name or "cache_write" in name:
return "cache_write"
if "cache_read" in name:
return "cache_read"
if "output" in name:
return "output"
if "input" in name:
return "input"
return "other"
def by_bucket(cost_buckets):
"""Sum spend per token bucket across the cost report. Pure."""
out = {"input": 0.0, "output": 0.0, "cache_read": 0.0,
"cache_write": 0.0, "other": 0.0}
for b in cost_buckets:
for r in b.get("results", []) or []:
out[bucket_of(r.get("token_type"))] += amount(r)
return out
def top_model(usage_buckets):
"""The model carrying the most output tokens, and its share. Pure.
Returns (model, share) or (None, 0.0). Answers the only actionable question
the usage report can answer here: where an effort change would land.
"""
per_model = {}
total = 0
for b in usage_buckets:
for r in b.get("results", []) or []:
model = r.get("model") or "unspecified"
out = int(r.get("output_tokens") or 0)
per_model[model] = per_model.get(model, 0) + out
total += out
if not total:
return (None, 0.0)
model = max(per_model, key=lambda m: per_model[m])
return (model, per_model[model] / total)
def verdict(buckets, min_spend=1.0):
"""Turn the spend split into the lever that will actually move it. Pure.
Returns (state, detail). The states are not degrees of the same finding:
each one names a different repair, and applying the wrong one costs a week
and changes nothing on the invoice.
"""
total = sum(buckets.values())
if total < min_spend:
return ("no-spend", "$%.2f over the window: nothing to act on" % total)
def pct(key):
return buckets[key] / total * 100
split = ("output %.0f%%, input %.0f%%, cache read %.0f%%, cache write %.0f%%"
% (pct("output"), pct("input"), pct("cache_read"), pct("cache_write")))
if buckets["other"] > 0:
split += ", unrecognised %.0f%%" % pct("other")
money = "$%.2f over the window: %s" % (total, split)
if buckets["cache_write"] > buckets["cache_read"] and pct("cache_write") >= 15:
return ("cache-write-heavy",
"%s. You are paying the cache write premium without the reads "
"to amortise it: the prefix is being rewritten more often than "
"it is hit." % money)
if pct("output") >= 70:
return ("output-dominated",
"%s. Output is priced at %dx input and there is no caching "
"discount on it, so the only lever is generating fewer tokens: "
"lower effort, tighter stop conditions, shorter output formats."
% (money, OUTPUT_MULTIPLE))
if pct("input") + pct("cache_read") + pct("cache_write") >= 60:
return ("input-dominated",
"%s. This is the shape prompt caching is for. Cache the stable "
"prefix and read it back; trimming output here buys very "
"little." % money)
if pct("output") >= 50:
return ("output-led",
"%s. Output is the larger half but not overwhelmingly. Both "
"levers help and neither is dramatic on its own." % money)
return ("balanced", money)
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 endpoints need an "
"Admin API key (sk-ant-admin...), not a workspace key"
% r.status_code)
r.raise_for_status()
return r.json()
def read_all(session, path, params):
"""Follow next_page until the report is exhausted."""
out = []
while True:
page = get(session, path, params)
out.extend(page.get("data", []))
if not page.get("has_more") or not page.get("next_page"):
break
params = [p for p in params if p[0] != "page"] + [("page", page["next_page"])]
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30,
help="how far back to read the cost and usage reports")
ap.add_argument("--min-spend", type=float, default=1.0,
help="below this total, report nothing rather than a noisy share")
args = ap.parse_args()
key = os.environ.get("ANTHROPIC_ADMIN_KEY")
if not key:
log.error("set ANTHROPIC_ADMIN_KEY (an Admin API key, sk-ant-admin...; "
"workspace keys are rejected by /v1/organizations/*)")
return 2
now = dt.datetime.now(dt.timezone.utc)
since = (now - dt.timedelta(days=args.days)).strftime("%Y-%m-%dT00:00:00Z")
s = requests.Session()
s.headers.update({"x-api-key": key, "anthropic-version": VERSION})
costs = read_all(s, "/organizations/cost_report",
[("starting_at", since), ("limit", 31),
("group_by[]", "description")])
usage = read_all(s, "/organizations/usage_report/messages",
[("starting_at", since), ("bucket_width", "1d"),
("limit", 31), ("group_by[]", "model")])
split = by_bucket(costs)
state, detail = verdict(split, args.min_spend)
line = "%-18s %s" % (state, detail)
bad = 0
if state in ("no-spend", "balanced", "input-dominated"):
log.info(line)
else:
bad = 1
log.warning(line)
model, share = top_model(usage)
if model:
log.info("top model by output tokens: %s (%.0f%% of output)",
model, share * 100)
if bad:
log.warning(" repair, to run yourself: lower output_config.effort on "
"%s (high to medium is the usual first step), then re-read "
"this same daily series a week later. Thinking tokens bill "
"as output, so effort is the setting that moves this share.",
model)
log.warning(" never change an effort setting from inside an audit; "
"the Admin API cannot do it and neither should this.")
else:
log.info("no output tokens in the usage report for this window")
log.info("%d cost bucket(s), %d usage bucket(s) over %d day(s), %d finding(s)",
len(costs), len(usage), args.days, bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report which side of a Claude request the bill is actually on.
*
* Read only. Two GET requests and nothing else: ANTHROPIC_ADMIN_KEY must be an
* Admin API key (sk-ant-admin...), because every /v1/organizations endpoint
* rejects a workspace key. The repair is printed, never performed.
*/
const API = 'https://api.anthropic.com/v1';
const VERSION = '2023-06-01';
// Output is priced at five times input on every current model.
export const OUTPUT_MULTIPLE = 5;
/**
* Read a cost row's amount as a number. Pure. The cost report returns amount as
* a decimal STRING; adding the raw values concatenates them instead of summing.
*/
export function amount(row) {
const raw = row.amount;
if (raw === null || raw === undefined || raw === '') return 0;
const n = Number(raw);
return Number.isFinite(n) ? n : 0;
}
/**
* Fold a token_type into one of five buckets. Pure. Matched on the shape of the
* name, because new token types arrive with new cache durations and tiers;
* anything unrecognised stays visible in "other" rather than being dropped.
*/
export function bucketOf(tokenType) {
const name = String(tokenType ?? '').toLowerCase();
if (!name) return 'other';
if (name.includes('cache_creation') || name.includes('cache_write')) return 'cache_write';
if (name.includes('cache_read')) return 'cache_read';
if (name.includes('output')) return 'output';
if (name.includes('input')) return 'input';
return 'other';
}
/** Sum spend per token bucket across the cost report. Pure. */
export function byBucket(costBuckets) {
const out = { input: 0, output: 0, cache_read: 0, cache_write: 0, other: 0 };
for (const b of costBuckets) {
for (const r of b.results ?? []) out[bucketOf(r.token_type)] += amount(r);
}
return out;
}
/**
* The model carrying the most output tokens, and its share. Pure.
* Returns [model, share] or [null, 0].
*/
export function topModel(usageBuckets) {
const perModel = new Map();
let total = 0;
for (const b of usageBuckets) {
for (const r of b.results ?? []) {
const model = r.model ?? 'unspecified';
const out = Number(r.output_tokens ?? 0);
perModel.set(model, (perModel.get(model) ?? 0) + out);
total += out;
}
}
if (!total) return [null, 0];
let best = null;
for (const [m, v] of perModel) if (best === null || v > perModel.get(best)) best = m;
return [best, perModel.get(best) / total];
}
/**
* Turn the spend split into the lever that will actually move it. Pure. Each
* state names a different repair; applying the wrong one changes nothing.
* Returns [state, detail].
*/
export function verdict(buckets, minSpend = 1) {
const total = Object.values(buckets).reduce((a, b) => a + b, 0);
if (total < minSpend) {
return ['no-spend', `$${total.toFixed(2)} over the window: nothing to act on`];
}
const pct = (k) => (buckets[k] / total) * 100;
let split = `output ${pct('output').toFixed(0)}%, input ${pct('input').toFixed(0)}%, ` +
`cache read ${pct('cache_read').toFixed(0)}%, cache write ${pct('cache_write').toFixed(0)}%`;
if (buckets.other > 0) split += `, unrecognised ${pct('other').toFixed(0)}%`;
const money = `$${total.toFixed(2)} over the window: ${split}`;
if (buckets.cache_write > buckets.cache_read && pct('cache_write') >= 15) {
return ['cache-write-heavy',
`${money}. You are paying the cache write premium without the reads to ` +
'amortise it: the prefix is being rewritten more often than it is hit.'];
}
if (pct('output') >= 70) {
return ['output-dominated',
`${money}. Output is priced at ${OUTPUT_MULTIPLE}x input and there is no ` +
'caching discount on it, so the only lever is generating fewer tokens: ' +
'lower effort, tighter stop conditions, shorter output formats.'];
}
if (pct('input') + pct('cache_read') + pct('cache_write') >= 60) {
return ['input-dominated',
`${money}. This is the shape prompt caching is for. Cache the stable ` +
'prefix and read it back; trimming output here buys very little.'];
}
if (pct('output') >= 50) {
return ['output-led',
`${money}. Output is the larger half but not overwhelmingly. Both levers ` +
'help and neither is dramatic on its own.'];
}
return ['balanced', money];
}
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 endpoints ` +
'need an Admin API key (sk-ant-admin...), not a workspace key');
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
async function readAll(key, path, params) {
const out = [];
let p = params;
for (;;) {
const page = await get(key, path, p);
out.push(...(page.data ?? []));
if (!page.has_more || !page.next_page) break;
p = p.filter((x) => x[0] !== 'page').concat([['page', page.next_page]]);
}
return out;
}
async function main() {
const key = process.env.ANTHROPIC_ADMIN_KEY;
if (!key) {
console.error('set ANTHROPIC_ADMIN_KEY (an Admin API key, sk-ant-admin...; ' +
'workspace keys are rejected by /v1/organizations/*)');
process.exitCode = 2;
return;
}
const argv = process.argv;
const days = Number(argv.includes('--days') ? argv[argv.indexOf('--days') + 1] : 30) || 30;
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10) +
'T00:00:00Z';
const costs = await readAll(key, '/organizations/cost_report',
[['starting_at', since], ['limit', '31'], ['group_by[]', 'description']]);
const usage = await readAll(key, '/organizations/usage_report/messages',
[['starting_at', since], ['bucket_width', '1d'], ['limit', '31'],
['group_by[]', 'model']]);
const split = byBucket(costs);
const [state, detail] = verdict(split);
const line = `${state.padEnd(18)} ${detail}`;
let bad = 0;
if (['no-spend', 'balanced', 'input-dominated'].includes(state)) console.log(line);
else { bad = 1; console.warn(line); }
const [model, share] = topModel(usage);
if (model) {
console.log(`top model by output tokens: ${model} ` +
`(${(share * 100).toFixed(0)}% of output)`);
if (bad) {
console.warn(` repair, to run yourself: lower output_config.effort on ${model} ` +
'(high to medium is the usual first step), then re-read this same ' +
'daily series a week later. Thinking tokens bill as output, so ' +
'effort is the setting that moves this share.');
console.warn(' never change an effort setting from inside an audit; the Admin ' +
'API cannot do it and neither should this.');
}
} else {
console.log('no output tokens in the usage report for this window');
}
console.log(`${costs.length} cost bucket(s), ${usage.length} usage bucket(s) ` +
`over ${days} day(s), ${bad} finding(s)`);
process.exitCode = bad ? 1 : 0;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The amount parser gets a test because the field is a string and the bug it prevents is a total of zero on an account spending thousands. The bucketing gets one because a token type nobody has seen before must land somewhere visible instead of vanishing out of the denominator. And the states are pinned against each other: the same total spend, split three different ways, has to produce three different recommendations, because a caching project shipped against an output-dominated bill is a month of work for nothing.
from anthropic_output_cost_audit import (amount, bucket_of, by_bucket,
top_model, verdict)
def cost(token_type, value, description="Claude Sonnet 5"):
# amount arrives as a decimal STRING on this endpoint, not a number.
return {"currency": "USD", "amount": str(value), "token_type": token_type,
"description": description, "cost_type": "tokens"}
def cost_day(*rows):
return {"starting_at": "2026-08-01T00:00:00Z", "results": list(rows)}
def usage_day(*rows):
return {"starting_at": "2026-08-01T00:00:00Z", "results": list(rows)}
def test_amount_is_a_string_on_this_endpoint():
assert amount({"amount": "12.34"}) == 12.34
assert amount({"amount": 12.34}) == 12.34
assert amount({"amount": ""}) == 0.0
assert amount({}) == 0.0
assert amount({"amount": "n/a"}) == 0.0
def test_token_types_fold_into_buckets_by_shape_not_by_exact_name():
assert bucket_of("output_tokens") == "output"
assert bucket_of("uncached_input_tokens") == "input"
assert bucket_of("cache_read_input_tokens") == "cache_read"
assert bucket_of("cache_creation_input_tokens") == "cache_write"
assert bucket_of("1h_cache_creation_input_tokens") == "cache_write"
# A type that does not exist yet must stay visible rather than vanish.
assert bucket_of("some_future_tier_tokens") == "other"
assert bucket_of(None) == "other"
def test_unrecognised_types_stay_in_the_denominator():
rows = by_bucket([cost_day(cost("output_tokens", "60"),
cost("some_future_tier_tokens", "40"))])
assert rows["other"] == 40.0
state, detail = verdict(rows)
assert "unrecognised 40%" in detail
assert state == "output-led"
def test_the_same_spend_split_three_ways_gives_three_different_repairs():
output_heavy = by_bucket([cost_day(cost("output_tokens", "800"),
cost("uncached_input_tokens", "200"))])
input_heavy = by_bucket([cost_day(cost("output_tokens", "300"),
cost("uncached_input_tokens", "500"),
cost("cache_read_input_tokens", "200"))])
even = by_bucket([cost_day(cost("output_tokens", "450"),
cost("uncached_input_tokens", "550"))])
assert verdict(output_heavy)[0] == "output-dominated"
assert verdict(input_heavy)[0] == "input-dominated"
assert verdict(even)[0] == "balanced"
def test_an_output_dominated_bill_names_the_only_lever_there_is():
rows = by_bucket([cost_day(cost("output_tokens", "800"),
cost("uncached_input_tokens", "200"))])
_, detail = verdict(rows)
assert "no caching discount" in detail
assert "5x input" in detail
def test_cache_writes_without_reads_is_its_own_finding():
# Writes cost more than base input; without reads to amortise them the
# caching is a premium being paid for nothing.
rows = by_bucket([cost_day(cost("cache_creation_input_tokens", "400"),
cost("cache_read_input_tokens", "50"),
cost("output_tokens", "300"),
cost("uncached_input_tokens", "250"))])
state, detail = verdict(rows)
assert state == "cache-write-heavy"
assert "amortise" in detail
def test_output_between_half_and_seventy_percent_is_not_an_emergency():
rows = by_bucket([cost_day(cost("output_tokens", "550"),
cost("uncached_input_tokens", "450"))])
assert verdict(rows)[0] == "output-led"
def test_a_quiet_window_reports_nothing_rather_than_a_noisy_share():
rows = by_bucket([cost_day(cost("output_tokens", "0.10"))])
assert verdict(rows)[0] == "no-spend"
assert verdict(by_bucket([]))[0] == "no-spend"
def test_top_model_names_where_an_effort_change_would_land():
model, share = top_model([
usage_day({"model": "claude-opus-5", "output_tokens": 900,
"uncached_input_tokens": 4000},
{"model": "claude-sonnet-5", "output_tokens": 100,
"uncached_input_tokens": 8000}),
])
assert model == "claude-opus-5"
assert round(share, 2) == 0.9
assert top_model([]) == (None, 0.0)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { amount, bucketOf, byBucket, topModel, verdict }
from './anthropic-output-cost-audit.mjs';
// amount arrives as a decimal STRING on this endpoint, not a number.
const cost = (tokenType, value, description = 'Claude Sonnet 5') => ({
currency: 'USD', amount: String(value), token_type: tokenType, description,
cost_type: 'tokens',
});
const costDay = (...rows) => ({ starting_at: '2026-08-01T00:00:00Z', results: rows });
const usageDay = (...rows) => ({ starting_at: '2026-08-01T00:00:00Z', results: rows });
test('amount is a string on this endpoint', () => {
assert.equal(amount({ amount: '12.34' }), 12.34);
assert.equal(amount({ amount: 12.34 }), 12.34);
assert.equal(amount({ amount: '' }), 0);
assert.equal(amount({}), 0);
assert.equal(amount({ amount: 'n/a' }), 0);
});
test('token types fold into buckets by shape, not by exact name', () => {
assert.equal(bucketOf('output_tokens'), 'output');
assert.equal(bucketOf('uncached_input_tokens'), 'input');
assert.equal(bucketOf('cache_read_input_tokens'), 'cache_read');
assert.equal(bucketOf('cache_creation_input_tokens'), 'cache_write');
assert.equal(bucketOf('1h_cache_creation_input_tokens'), 'cache_write');
assert.equal(bucketOf('some_future_tier_tokens'), 'other');
assert.equal(bucketOf(null), 'other');
});
test('unrecognised types stay in the denominator', () => {
const rows = byBucket([costDay(cost('output_tokens', '60'),
cost('some_future_tier_tokens', '40'))]);
assert.equal(rows.other, 40);
const [state, detail] = verdict(rows);
assert.match(detail, /unrecognised 40%/);
assert.equal(state, 'output-led');
});
test('the same spend split three ways gives three different repairs', () => {
const outputHeavy = byBucket([costDay(cost('output_tokens', '800'),
cost('uncached_input_tokens', '200'))]);
const inputHeavy = byBucket([costDay(cost('output_tokens', '300'),
cost('uncached_input_tokens', '500'), cost('cache_read_input_tokens', '200'))]);
const even = byBucket([costDay(cost('output_tokens', '450'),
cost('uncached_input_tokens', '550'))]);
assert.equal(verdict(outputHeavy)[0], 'output-dominated');
assert.equal(verdict(inputHeavy)[0], 'input-dominated');
assert.equal(verdict(even)[0], 'balanced');
});
test('an output dominated bill names the only lever there is', () => {
const rows = byBucket([costDay(cost('output_tokens', '800'),
cost('uncached_input_tokens', '200'))]);
const [, detail] = verdict(rows);
assert.match(detail, /no caching discount/);
assert.match(detail, /5x input/);
});
test('cache writes without reads is its own finding', () => {
const rows = byBucket([costDay(cost('cache_creation_input_tokens', '400'),
cost('cache_read_input_tokens', '50'), cost('output_tokens', '300'),
cost('uncached_input_tokens', '250'))]);
const [state, detail] = verdict(rows);
assert.equal(state, 'cache-write-heavy');
assert.match(detail, /amortise/);
});
test('output between half and seventy percent is not an emergency', () => {
const rows = byBucket([costDay(cost('output_tokens', '550'),
cost('uncached_input_tokens', '450'))]);
assert.equal(verdict(rows)[0], 'output-led');
});
test('a quiet window reports nothing rather than a noisy share', () => {
assert.equal(verdict(byBucket([costDay(cost('output_tokens', '0.10'))]))[0], 'no-spend');
assert.equal(verdict(byBucket([]))[0], 'no-spend');
});
test('top model names where an effort change would land', () => {
const [model, share] = topModel([
usageDay({ model: 'claude-opus-5', output_tokens: 900, uncached_input_tokens: 4000 },
{ model: 'claude-sonnet-5', output_tokens: 100, uncached_input_tokens: 8000 }),
]);
assert.equal(model, 'claude-opus-5');
assert.equal(Number(share.toFixed(2)), 0.9);
assert.deepEqual(topModel([]), [null, 0]);
});
FAQ
Is output really five times the price of input?
Per token, yes, on every current Claude model. That ratio is fixed before anything about your workload matters, which is why a request that feels input-heavy often is not: twenty thousand tokens of context against four thousand of answer is roughly an even split once the prices are applied.
Does prompt caching reduce output cost?
No. There is no caching discount on output tokens, and no discount on them of any kind. Caching reduces what you pay for the repeated part of the prompt. If output is already most of the bill, a caching project will produce a satisfying drop in one line of the cost report and almost no change to the total.
Where do thinking tokens show up?
As output tokens, billed at the output rate when they are generated. That is why an effort change moves this share with no visible change to the code that reads the response, and why a model that runs adaptive thinking when the parameter is omitted can shift the bill on a version bump alone.
Why does the script parse amount as a string?
Because that is how the cost report returns it. The values are decimal strings, so adding them without a conversion concatenates them in JavaScript and raises in Python. It is a small thing that silently produces either a nonsense total or none at all.
What does cache-write-heavy actually mean?
That you are paying the premium for writing a cache entry more often than you are collecting the discount for reading one. It usually means the cached prefix is not stable between requests, so every call rewrites it. Caching is not free, and this is the state where it is costing more than it saves.
Related field notes
- Reasoning tokens billed but never returned
- No spend limit means no ceiling
- A 429 that is a wall, not a throttle
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.
- Pricing — Claude Docs
- Get cost report — Claude Admin API
- Get messages usage report — Claude Admin API
- Extended thinking — Claude Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.