Tutorial: Build an LLM Cost Report and a Per-User Budget Guard
Turn a month of usage logs into unit economics, spend jumps, and what-ifs at current prices, then enforce per-user daily budgets that downgrade the model, then block.
On this page· What you will build
Why this matters
Cost engineering plans costs with a model of your workloads. Once you are in production you have something better: a log of every call you actually made. This tutorial reads a month of usage from a small SaaS product and answers the questions that matter: which feature costs what, whether each plan pays for itself, which users and which days stand out, and how much each fix would have saved, computed by re-pricing the real traffic. The log hides two incidents for you to find. Then you build the control that would have contained one of them: a per-user daily budget that steps down to a cheaper model before it says no.
What you will build#
19,031 calls, $325.81 over 30 days
spend jumps (a day above 1.3x the previous 7-day average)
2026-10-09 $14.49, the day before $6.73
2026-10-14 $21.50, the day before $13.95
what-ifs on this month's traffic
tagging on Haiku 5.5 saves $24.49 (8%)
fix the cache regression saves $119.15 (37%)
summaries through Batches saves $9.92 (3%)
cap free users at 20 chats/day saves $39.89 (12%)
all of the above saves $153.56 (47%) -> $172.25- A usage log: 30 days and 19,000 calls across three features and two plans, generated with two incidents in it.
- A price function with cache reads and writes, batch discounts, and Haiku 5.5's long-prompt tier.
- A report: spend by feature, unit economics per plan, top users, spend jumps, cache efficiency by week, and what-ifs.
- A budget guard that reserves each request's worst-case cost, downgrades to Haiku when Opus would not fit, and blocks at the plan's limits.
Hover or tap a component to see what it does.
Show text descriptionHide text description
Find what cost too much last month, then make sure it cannot happen again.
- Usage log: Feature, plan, user, model, and four token counts per call.
- Price function: One table of current prices, with cache reads and writes, batch discounts, and Haiku's long-prompt tier.
- Report: Unit economics, top users, spend jumps, cache ratio by week, and savings from re-pricing the log.
- Budget guard: Per user per day: Opus while it fits, Haiku after, a refusal at the limit.
- Claude: countTokens before the call gives the exact input size for the worst case.
You need Node.js 22.18 or later. The report needs no API key; the budget demo makes about 30 small calls.
1. Set up the project#
mkdir cost-report && cd cost-report
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk
npm install -D typescript @types/node
mkdir src test scripts
npm pkg set scripts.make-usage="node scripts/make-usage.ts" scripts.report="node src/report.ts" \
scripts.budget-demo="node --env-file=.env src/budget-demo.ts" scripts.test="node --test" scripts.typecheck="tsc"Copy the tsconfig.json from Foundations Tutorial 1, add "scripts" to its include list, and add your key to .env for the budget demo.
2. Usage records and prices#
Each record is one API call: who, which feature, which model, and the four token counts that determine its price. Your app can log these directly, or you can export them from the SDK's trace spans, as in the trace report tutorial.
/** One API call, as your app logs it (or as you export it from the SDK's trace spans). */
export interface UsageRecord {
day: string; // YYYY-MM-DD
feature: string;
plan: "free" | "pro";
user: string;
model: string;
input: number; // uncached input tokens
cacheRead: number;
cacheWrite: number;
output: number;
batch?: boolean; // sent through the Batches API (50% off)
}
// USD per million tokens. Keep the table in one place, and check the pricing page when models change.
export const PRICES: Record<string, { input: number; output: number; cacheRead: number; cacheWrite: number }> = {
"claude-opus-5-5": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 },
"claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
"claude-haiku-5-5": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
};
// Haiku 5.5 costs more for prompts above 100K tokens.
const HAIKU_LONG = { input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 };
export function cost(r: UsageRecord): number {
const prompt = r.input + r.cacheRead + r.cacheWrite;
const p = r.model === "claude-haiku-5-5" && prompt > 100_000 ? HAIKU_LONG : PRICES[r.model];
if (!p) throw new Error(`No price for ${r.model}`);
const usd = (r.input * p.input + r.cacheRead * p.cacheRead + r.cacheWrite * p.cacheWrite + r.output * p.output) / 1_000_000;
return r.batch ? usd / 2 : usd;
}
export const sum = (records: UsageRecord[]) => records.reduce((s, r) => s + cost(r), 0);Note the spread between models. On Opus 5.5, a million input tokens cost $4; on Haiku 5.5, $0.10 for prompts up to 100K tokens. A feature on the wrong model can cost forty times what it should.
3. A month of traffic, with two incidents#
// Generates data/usage.jsonl: 30 days of calls from a small SaaS product, with two incidents hidden in it.
import { mkdir, writeFile } from "node:fs/promises";
import type { UsageRecord } from "../src/usage.ts";
let seed = 7;
const rand = () => ((seed = (seed * 1_103_515_245 + 12_345) % 2 ** 31) / 2 ** 31);
const around = (n: number) => Math.round(n * (0.7 + rand() * 0.6));
const PRO = Array.from({ length: 200 }, (_, i) => `u-pro-${String(i).padStart(3, "0")}`);
const FREE = Array.from({ length: 300 }, (_, i) => `u-free-${String(i).padStart(3, "0")}`);
const lines: string[] = [];
const add = (r: UsageRecord) => lines.push(JSON.stringify(r));
for (let d = 0; d < 30; d++) {
const day = new Date(Date.UTC(2026, 8, 20 + d)).toISOString().slice(0, 10);
// Incident 1: a deploy on 2026-10-09 put the current time in the system prompt. Every request now
// writes a new cache entry instead of reading the old one.
const cacheBroken = day >= "2026-10-09";
const chat = (user: string, plan: UsageRecord["plan"]) => {
const warm = !cacheBroken && rand() < 0.9;
add({ day, feature: "support-chat", plan, user, model: "claude-opus-5-5", input: around(2_000), cacheRead: warm ? 7_000 : 0, cacheWrite: warm ? 0 : 7_000, output: around(400) });
};
for (const u of PRO) if (rand() < 0.9) chat(u, "pro");
for (const u of FREE) if (rand() < 0.2) chat(u, "free");
// Incident 2: from 2026-10-14, one free account scripts the chat endpoint.
if (day >= "2026-10-14") for (let i = 0; i < 150; i++) chat("u-free-042", "free");
for (let i = 0; i < 60; i++) {
const user = PRO[Math.floor(rand() * PRO.length)]!;
add({ day, feature: "summaries", plan: "pro", user, model: "claude-sonnet-5-5", input: around(4_000), cacheRead: 0, cacheWrite: 0, output: around(300) });
}
// Tagging runs on the most capable model, although it only picks one of five labels.
for (let i = 0; i < 300; i++) {
const pro = rand() < 0.5;
const user = pro ? PRO[Math.floor(rand() * PRO.length)]! : FREE[Math.floor(rand() * FREE.length)]!;
add({ day, feature: "tagging", plan: pro ? "pro" : "free", user, model: "claude-opus-5-5", input: around(600), cacheRead: 0, cacheWrite: 0, output: around(20) });
}
}
await mkdir("data", { recursive: true });
await writeFile("data/usage.jsonl", `${lines.join("\n")}\n`);
console.log(`wrote data/usage.jsonl (${lines.length} calls over 30 days)`);npm run make-usage # wrote data/usage.jsonl (19031 calls over 30 days)The comments tell you what happened. Pretend you have not read them; the report should find both incidents on its own.
4. The analyses#
import { cost, sum, type UsageRecord } from "./usage.ts";
export const REVENUE_PER_USER = { free: 0, pro: 12 }; // USD per month
export function byFeature(records: UsageRecord[]) {
const total = sum(records);
return [...Map.groupBy(records, (r) => r.feature)]
.map(([feature, rs]) => ({ feature, calls: rs.length, usd: sum(rs), perCall: sum(rs) / rs.length, share: sum(rs) / total }))
.sort((a, b) => b.usd - a.usd);
}
/** Cost per active user against revenue per user, per plan: the numbers that decide pricing and limits. */
export function unitEconomics(records: UsageRecord[]) {
return [...Map.groupBy(records, (r) => r.plan)].map(([plan, rs]) => {
const users = new Set(rs.map((r) => r.user)).size;
const perUser = sum(rs) / users;
const revenue = REVENUE_PER_USER[plan];
return { plan, users, usd: sum(rs), perUser, revenue, margin: revenue ? (revenue - perUser) / revenue : null };
});
}
export function topUsers(records: UsageRecord[], n = 3) {
return [...Map.groupBy(records, (r) => r.user)]
.map(([user, rs]) => ({ user, plan: rs[0]!.plan, calls: rs.length, usd: sum(rs) }))
.sort((a, b) => b.usd - a.usd)
.slice(0, n);
}
/** Days when spend jumps above `factor` times the previous seven days' average: the start of each incident. */
export function spendJumps(records: UsageRecord[], factor = 1.3) {
const days = [...Map.groupBy(records, (r) => r.day)].sort(([a], [b]) => a.localeCompare(b)).map(([day, rs]) => ({ day, usd: sum(rs) }));
const high = days.map((d, i) => i >= 7 && d.usd > (days.slice(i - 7, i).reduce((s, x) => s + x.usd, 0) / 7) * factor);
// Report the first day of each run of high days, with the day before it for comparison.
return days.flatMap((d, i) => (high[i] && !high[i - 1] ? [{ day: d.day, usd: d.usd, before: days[i - 1]!.usd }] : []));
}
/** Share of prompt tokens read from cache, for one feature, per week. */
export function cacheRatioByWeek(records: UsageRecord[], feature: string) {
const rs = records.filter((r) => r.feature === feature);
const first = rs[0]?.day ?? "";
const week = (day: string) => Math.floor((Date.parse(day) - Date.parse(first)) / (7 * 86_400_000));
return [...Map.groupBy(rs, (r) => week(r.day))].map(([w, xs]) => {
const prompt = xs.reduce((s, r) => s + r.input + r.cacheRead + r.cacheWrite, 0);
return { week: w + 1, from: xs[0]!.day, ratio: xs.reduce((s, r) => s + r.cacheRead, 0) / prompt };
});
}
/** Re-price the actual log under a change. Cheaper than any estimate, because the traffic is real. */
export function whatIf(records: UsageRecord[], change: (r: UsageRecord) => UsageRecord): number {
return records.reduce((s, r) => s + cost(change(r)), 0);
}whatIf is the most useful function here. A planning model needs assumptions about traffic; re-pricing the log uses the traffic you actually had, so the only assumption left is the change itself.
5. The report#
import { readFile } from "node:fs/promises";
import { byFeature, cacheRatioByWeek, spendJumps, topUsers, unitEconomics, whatIf } from "./analysis.ts";
import { sum, type UsageRecord } from "./usage.ts";
const records = (await readFile("data/usage.jsonl", "utf8")).trim().split("\n").map((l) => JSON.parse(l) as UsageRecord);
const $ = (n: number) => `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const pct = (n: number) => `${Math.round(n * 100)}%`;
const total = sum(records);
console.log(`${records.length.toLocaleString("en-US")} calls, ${$(total)} over 30 days\n`);
console.log("by feature");
for (const f of byFeature(records)) console.log(` ${f.feature.padEnd(14)}${String(f.calls).padStart(7)} calls ${$(f.usd).padStart(11)} ${`$${f.perCall.toFixed(4)}/call`.padStart(15)} ${pct(f.share).padStart(5)}`);
console.log("\nunit economics (per active user, per month)");
for (const p of unitEconomics(records)) {
console.log(` ${p.plan.padEnd(6)}${String(p.users).padStart(5)} users cost ${$(p.perUser).padStart(7)} revenue ${$(p.revenue).padStart(7)} ${p.margin === null ? "no revenue" : `margin ${pct(p.margin)}`}`);
}
console.log("\ntop users");
for (const u of topUsers(records)) console.log(` ${u.user.padEnd(12)}${u.plan.padEnd(6)}${String(u.calls).padStart(6)} calls ${$(u.usd).padStart(10)}`);
console.log("\nspend jumps (a day above 1.3x the previous 7-day average)");
for (const j of spendJumps(records)) console.log(` ${j.day} ${$(j.usd)}, the day before ${$(j.before)}`);
console.log("\nsupport-chat cache read ratio by week");
for (const w of cacheRatioByWeek(records, "support-chat")) console.log(` week ${w.week} (from ${w.from}) ${pct(w.ratio)}`);
// What-ifs: re-price the real traffic under each change.
const levers: [string, (r: UsageRecord) => UsageRecord][] = [
["tagging on Haiku 5.5", (r) => (r.feature === "tagging" ? { ...r, model: "claude-haiku-5-5" } : r)],
["fix the cache regression", (r) => (r.feature === "support-chat" && r.cacheWrite > 0 && r.day >= "2026-10-09" ? { ...r, cacheRead: r.cacheWrite, cacheWrite: 0 } : r)],
["summaries through Batches", (r) => (r.feature === "summaries" ? { ...r, batch: true } : r)],
["cap free users at 20 chats/day", capFreeChats(records, 20)],
];
console.log("\nwhat-ifs on this month's traffic");
let all = (r: UsageRecord) => r;
for (const [label, change] of levers) {
const after = whatIf(records, change);
console.log(` ${label.padEnd(32)} saves ${$(total - after).padStart(10)} (${pct((total - after) / total)})`);
const prev = all;
all = (r) => change(prev(r));
}
const combined = whatIf(records, all);
console.log(` ${"all of the above".padEnd(32)} saves ${$(total - combined).padStart(10)} (${pct((total - combined) / total)}) -> ${$(combined)}`);
/** Drop a free user's chats beyond `limit` per day: what a budget would have prevented. */
function capFreeChats(rs: UsageRecord[], limit: number) {
const seen = new Map<string, number>();
const dropped = new Set<UsageRecord>();
for (const r of rs) {
if (r.plan !== "free" || r.feature !== "support-chat") continue;
const key = `${r.user} ${r.day}`;
const n = (seen.get(key) ?? 0) + 1;
seen.set(key, n);
if (n > limit) dropped.add(r);
}
return (r: UsageRecord): UsageRecord => (dropped.has(r) ? { ...r, input: 0, cacheRead: 0, cacheWrite: 0, output: 0 } : r);
}6. Read the month#
npm run report19,031 calls, $325.81 over 30 days
by feature
support-chat 8231 calls $280.85 $0.0341/call 86%
tagging 9000 calls $25.12 $0.0028/call 8%
summaries 1800 calls $19.85 $0.0110/call 6%
unit economics (per active user, per month)
pro 200 users cost $1.04 revenue $12.00 margin 91%
free 300 users cost $0.39 revenue $0.00 no revenue
top users
u-free-042 free 925 calls $46.21
u-pro-087 pro 77 calls $1.26
u-pro-024 pro 81 calls $1.26
spend jumps (a day above 1.3x the previous 7-day average)
2026-10-09 $14.49, the day before $6.73
2026-10-14 $21.50, the day before $13.95
support-chat cache read ratio by week
week 1 (from 2026-09-20) 69%
week 2 (from 2026-09-27) 68%
week 3 (from 2026-10-04) 49%
week 4 (from 2026-10-11) 0%
week 5 (from 2026-10-18) 0%
what-ifs on this month's traffic
tagging on Haiku 5.5 saves $24.49 (8%)
fix the cache regression saves $119.15 (37%)
summaries through Batches saves $9.92 (3%)
cap free users at 20 chats/day saves $39.89 (12%)
all of the above saves $153.56 (47%) -> $172.25Follow the clues:
- Spend more than doubled on 2026-10-09. The cache table explains it: the read ratio fell from about 70% to zero, with no change in traffic. Something made every prompt different, so every request wrote the long prefix to the cache at 1.25 times the input price instead of reading it at a twentieth of it. On a real system, the deploy log for that day would show the cause; here, a timestamp in the system prompt. This one incident is the largest saving available: 37% of the month.
- A second jump on 2026-10-14, and one free user who cost $46.21 alone, more than the next 36 pro users combined, on a plan that pays nothing. It is not a bug in your code; it is the absence of a limit.
- Tagging is 47% of all calls on Opus, choosing one of five labels. On Haiku 5.5 it would cost about 3% as much. That is a routing decision, so check quality on your evals before you switch.
- Unit economics are healthy for Pro: $1.04 of cost against $12 of revenue. Free costs $0.39 per user on average, but the average hides one user, which is why you look at the top users too.
A few lines of analysis over your own logs give you the incident, its start date, its cause category, and its price. A tracing backend can do the same; what matters is that someone looks.
7. A budget guard that degrades before it refuses#
The cap in the what-if is the policy you want in code: each user gets a daily allowance per plan. A good guard does not simply say no. It first moves the user to a cheaper model, so they keep getting answers, and only refuses at the hard limit.
export const PLANS = {
free: { usdPerDay: 0.03, requestsPerDay: 25 },
pro: { usdPerDay: 1, requestsPerDay: 500 },
} as const;
export type PlanName = keyof typeof PLANS;
// Best model first; the guard steps down when the better one would not fit the budget.
export const MODELS = ["claude-opus-5-5", "claude-haiku-5-5"] as const;
export type Model = (typeof MODELS)[number];
export type Decision =
| { action: "allow"; model: Model; reserved: number; key: string; downgraded: boolean }
| { action: "block"; reason: string };
/**
* Per-user daily budgets in dollars and requests. Each request reserves its worst-case cost before
* the call and settles the actual cost after it, so concurrent requests cannot overspend together.
* In-memory here; use Redis or your database when you run more than one process.
*/
export class BudgetGuard {
private readonly usd = new Map<string, number>(); // settled + reserved
private readonly requests = new Map<string, number>();
private readonly now: () => Date;
constructor(now: () => Date = () => new Date()) {
this.now = now;
}
private key(user: string) {
return `${user}:${this.now().toISOString().slice(0, 10)}`; // resets at midnight UTC
}
spent(user: string) {
return this.usd.get(this.key(user)) ?? 0;
}
/** `worstCase(model)` is the most the request could cost on that model. */
reserve(user: string, plan: PlanName, worstCase: (model: Model) => number): Decision {
const limits = PLANS[plan];
const key = this.key(user);
const spent = this.usd.get(key) ?? 0;
if ((this.requests.get(key) ?? 0) >= limits.requestsPerDay) return { action: "block", reason: `daily limit of ${limits.requestsPerDay} requests reached` };
const model = MODELS.find((m) => spent + worstCase(m) <= limits.usdPerDay);
if (!model) return { action: "block", reason: `daily budget of $${limits.usdPerDay} used` };
const reserved = worstCase(model);
this.usd.set(key, spent + reserved);
this.requests.set(key, (this.requests.get(key) ?? 0) + 1);
return { action: "allow", model, reserved, key, downgraded: model !== MODELS[0] };
}
/** Replace the reservation with what the call really cost (0 if it failed before any tokens). */
settle(decision: Extract<Decision, { action: "allow" }>, actualUSD: number) {
this.usd.set(decision.key, (this.usd.get(decision.key) ?? 0) - decision.reserved + actualUSD);
}
}Reserve, then settle is the part that is easy to get wrong. If the guard only checked the amount already spent, ten requests arriving at once would all see the same balance and all pass. Reserving the worst case before the call makes each request count immediately; settling afterwards gives back what was not used.
The worst case needs the input size before the call. countTokens gives it exactly, for free:
import type Anthropic from "@anthropic-ai/sdk";
import { type BudgetGuard, type PlanName } from "./budget.ts";
import { cost, PRICES } from "./usage.ts";
const SYSTEM = "You are the support assistant for Acme Billing. Answer in at most three sentences.";
const MAX_TOKENS = 512;
export type ChatResult = { ok: true; text: string; model: string; usd: number; downgraded: boolean } | { ok: false; message: string };
export async function guardedChat(client: Anthropic, guard: BudgetGuard, user: string, plan: PlanName, question: string): Promise<ChatResult> {
const messages: Anthropic.MessageParam[] = [{ role: "user", content: question }];
// The exact input size, before spending anything. The worst case adds a full max_tokens of output.
const { input_tokens } = await client.messages.countTokens({ model: "claude-opus-5-5", system: SYSTEM, messages });
const decision = guard.reserve(user, plan, (model) => (input_tokens * PRICES[model]!.input + MAX_TOKENS * PRICES[model]!.output) / 1_000_000);
if (decision.action === "block") {
return { ok: false, message: `You've reached today's AI limit on the ${plan} plan (${decision.reason}). It resets at midnight UTC.` };
}
let usd = 0;
try {
const message = await client.messages.create({ model: decision.model, max_tokens: MAX_TOKENS, system: SYSTEM, messages });
const u = message.usage;
usd = cost({
day: "",
feature: "support-chat",
plan,
user,
model: decision.model,
input: u.input_tokens,
cacheRead: u.cache_read_input_tokens ?? 0,
cacheWrite: u.cache_creation_input_tokens ?? 0,
output: u.output_tokens,
});
const text = message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
return { ok: true, text, model: decision.model, usd, downgraded: decision.downgraded };
} finally {
guard.settle(decision, usd); // always settle, even when the call throws
}
}max_tokens is what makes the worst case finite. Without it, a single request has no upper bound, and no budget can be enforced before the call.
8. Watch it step down, then stop#
import Anthropic from "@anthropic-ai/sdk";
import { BudgetGuard, PLANS } from "./budget.ts";
import { guardedChat } from "./guarded-chat.ts";
const client = new Anthropic();
const guard = new BudgetGuard();
// One free user who keeps asking: watch the guard step down, then stop.
const runs: { from: number; to: number; label: string }[] = [];
for (let i = 1; i <= 30; i++) {
const r = await guardedChat(client, guard, "u-free-042", "free", `Question ${i}: how do I export my invoices?`);
const label = r.ok ? `answered by ${r.model}${r.downgraded ? " (downgraded)" : ""}` : r.message;
const last = runs.at(-1);
if (last?.label === label) last.to = i;
else runs.push({ from: i, to: i, label });
}
for (const r of runs) console.log(`requests ${String(r.from).padStart(2)}-${String(r.to).padEnd(2)} ${r.label}`);
console.log(`spent today: $${guard.spent("u-free-042").toFixed(4)} of $${PLANS.free.usdPerDay}`);npm run budget-demorequests 1-7 answered by claude-opus-5-5
requests 8-25 answered by claude-haiku-5-5 (downgraded)
requests 26-30 You've reached today's AI limit on the free plan (daily limit of 25 requests reached). It resets at midnight UTC.
spent today: $0.0216 of $0.03With this guard in place, the account that cost $46.21 last month would have cost at most $0.03 a day, under a dollar for the month, and every one of its first 25 daily requests would still have been answered.
9. Test the money#
import assert from "node:assert/strict";
import { test } from "node:test";
import Anthropic from "@anthropic-ai/sdk";
import { spendJumps, unitEconomics, whatIf } from "../src/analysis.ts";
import { BudgetGuard } from "../src/budget.ts";
import { guardedChat } from "../src/guarded-chat.ts";
import { cost, type UsageRecord } from "../src/usage.ts";
const rec = (r: Partial<UsageRecord>): UsageRecord => ({ day: "2026-10-01", feature: "f", plan: "pro", user: "u", model: "claude-opus-5-5", input: 0, cacheRead: 0, cacheWrite: 0, output: 0, ...r });
test("pricing: cache reads, cache writes, batches, and Haiku's long-prompt tier", () => {
assert.equal(cost(rec({ input: 1_000_000 })), 4);
assert.equal(cost(rec({ cacheRead: 1_000_000 })), 0.2);
assert.equal(cost(rec({ cacheWrite: 1_000_000 })), 5);
assert.equal(cost(rec({ output: 1_000_000, batch: true })), 10);
assert.equal(cost(rec({ model: "claude-haiku-5-5", input: 100_000 })), 0.01);
assert.equal(cost(rec({ model: "claude-haiku-5-5", input: 100_001 })), 0.0500005); // above 100K tokens
});
test("what-ifs re-price the same records", () => {
const rs = [rec({ feature: "tagging", input: 100_000 }), rec({ feature: "chat", input: 100_000 })];
const after = whatIf(rs, (r) => (r.feature === "tagging" ? { ...r, model: "claude-haiku-5-5" } : r));
assert.equal(after.toFixed(2), "0.41"); // tagging drops from $0.40 to $0.01
});
test("spend jumps report the first day of each incident only", () => {
const days = Array.from({ length: 12 }, (_, i) => `2026-10-${String(i + 1).padStart(2, "0")}`);
const rs = days.map((day, i) => rec({ day, input: i >= 8 ? 2_000_000 : 1_000_000 })); // doubles from day 9
assert.deepEqual(spendJumps(rs).map((j) => j.day), ["2026-10-09"]);
});
test("unit economics divide by active users per plan", () => {
const rs = [rec({ user: "a", input: 1_000_000 }), rec({ user: "b", input: 1_000_000 }), rec({ plan: "free", user: "c", input: 500_000 })];
assert.deepEqual(unitEconomics(rs).map((p) => [p.plan, p.users, p.perUser]), [["pro", 2, 4], ["free", 1, 2]]);
});
test("the guard reserves the worst case, so concurrent requests cannot overspend", () => {
const guard = new BudgetGuard(() => new Date("2026-10-20T10:00:00Z"));
const worst = (m: string) => (m === "claude-opus-5-5" ? 0.012 : 0.0005);
const a = guard.reserve("u", "free", worst); // $0.012 reserved
const b = guard.reserve("u", "free", worst); // $0.024 reserved
const c = guard.reserve("u", "free", worst); // another Opus call would reach $0.036: downgrade
assert.deepEqual([a.action, b.action, c.action], ["allow", "allow", "allow"]);
assert.equal(c.action === "allow" && c.model, "claude-haiku-5-5");
if (a.action === "allow") guard.settle(a, 0.004); // the real cost was lower: the difference is released
assert.ok(Math.abs(guard.spent("u") - (0.004 + 0.012 + 0.0005)) < 1e-12);
});
test("the guard blocks when nothing fits, and resets the next day", () => {
let now = new Date("2026-10-20T23:00:00Z");
const guard = new BudgetGuard(() => now);
const decision = guard.reserve("u", "free", () => 0.05); // more than the whole free budget
assert.equal(decision.action, "block");
now = new Date("2026-10-21T00:01:00Z");
assert.equal(guard.reserve("u", "free", () => 0.001).action, "allow");
});
test("a failed call is settled at zero, not left reserved", async () => {
const client = new Anthropic({
apiKey: "test-key",
maxRetries: 0,
fetch: async (url) =>
String(url).includes("count_tokens")
? Response.json({ input_tokens: 100 })
: Response.json({ type: "error", error: { type: "overloaded_error", message: "busy" } }, { status: 529 }),
});
const guard = new BudgetGuard();
await assert.rejects(guardedChat(client, guard, "u", "free", "hi"));
assert.equal(guard.spent("u"), 0);
});npm test # 7 passing, no API callsCommon mistakes#
- Looking only at the monthly total. It hides when a change started and who caused it. Break cost down by feature, plan, user, and day.
- Averages per user. One heavy account can carry a plan's whole cost. Look at the top users every week.
- Checking a budget without reserving. Concurrent requests all see the same balance. Reserve the worst case, settle the actual.
- No
max_tokens. Without it, a single call's cost has no upper bound and no budget can be enforced before it. - A guard that only blocks. Stepping down to a cheaper model keeps users served while costs stay bounded; refuse only at the hard limit, with a clear message.
- Prices copied into many places. Keep one table, check it when models change, and test the price function.
Try it
Extend the report and the guard
- Add a "cost per feature per day" table and flag a feature whose daily cost rises 50% above its own 7-day average, as in the cost engineering article.
- Move the guard's state to Redis with an atomic
INCRBYFLOAT, and run two processes against the same user. - Add a middle step to
MODELS,claude-sonnet-5-5, and a pro plan rule that keeps pro users on Opus until 90% of their budget. - Export the report as JSON and chart daily spend per feature in a small Next.js page.
Check your understanding
Key takeaways
- Log every call's feature, plan, user, model, and token counts, and price them with one tested function.
- Read cost by feature, plan, user, and day; spend jumps and cache ratios point to incidents and their causes.
- Estimate every lever by re-pricing the real log, and check quality with evals before changing models.
- Enforce per-user budgets by reserving the worst case and settling the actual cost, stepping down to a cheaper model before refusing.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Cost Engineering for LLM Apps: Unit Economics, Routing, and BudgetsModel and cut LLM costs in TypeScript: unit economics, a cost model for levers like caching and batching, model routing with escalation, and per-feature budgets.
- Tutorial: Trace an LLM Feature with OpenTelemetry and Build an SLO ReportTrace a support feature with OpenTelemetry and the Anthropic SDK's built-in spans, redact personal data, and turn the spans into latency, cost, and alert reports.
- Tutorial: Measure Prompt Caching and Batch Savings in a Cache LabAsk questions about one long document, watch cache writes turn into cheap reads, see a timestamp silently break the cache, and run the work as a half-price batch.