Tracks what Claude Code costs — session, calendar-week and calendar-month totals with the month's pace and week-over-week change, a /costs pane with a spend…

A Claude Code mod that keeps a running ledger of what Claude Code costs: this session, this calendar week (Monday to Sunday) and this calendar month, in local time. It also makes prompt-cache spend visible, since on long agentic sessions cache_read and cache_write are usually most of the bill.
Unlike the other plugins in this marketplace, cost-ledger is code: a TypeScript hooks module that Claude Code loads and runs inside the session, not skills or commands for Claude to read.
/plugin marketplace add bobtat/claude-plugins
/plugin install cost-ledger@bobtat-plugins
| Where | What |
|---|---|
| Status line and a band above the prompt | $1.84 session · $23.10 week · $96.40 month · ctx 150k ≈$0.03/step · cache warm 3m |
/costs pane | Totals with budget progress, the change against the same point last week and the month's pace (with the day a monthly budget would be reached), a heat-map calendar per month (shade by total spend or by cache writes, p/n to change month, s to switch shading), the month's prompt-cache breakdown, the subagent share, the most expensive prompts, what filled the context, spend by project, and daily bars |
/costs export | Writes ~/claude-costs/claude-costs-<date>.csv: one row per day and project with the total, tokens and estimated cost per category, and subagent cost |
/costs doctor | Checks the assumptions the totals rest on (see below) |
| Toasts | Before a prompt is sent once the cache has likely expired; before a model switch that drops a warm cache; after one request writes a large amount to the cache; after a prompt whose cost passes a threshold; at 80% and 100% of a budget |
All warnings are informational: nothing is ever blocked.
session.measure ── cost.usd (Claude Code's running total) ──► spend per session and day
turn.step ──────── usage per request (tokens, model) ───────► priced per category
│
$.store: one entry per session, ◄─────────────┘
archived per project after 60 days, kept 400 days
│
▼
status line · band · /costs pane · CSV export
/cost shows. It is list price unless an administrator has set the managed modelPricing setting./clear or /compact drops it. A large file read early in a long turn shows up here as the re-reads it caused. The tokens are approximate, so the dollars are a guide to where the cache spend comes from rather than a bill./costs doctor reports, each line marked ✓, ⚠ or ℹ:
Get-Date on Windows, date elsewhere), since days, weeks and months are split by it.--plugin-dir, a session's mods folder) has its own store, so history recorded by one is not in another's totals.It cannot check the /resume accounting: for that, compare /costs before and after resuming a session whose spend you know.
Set in /config under cost-ledger.
| Setting | Default | Meaning |
|---|---|---|
billing | api | subscription labels figures as API-equivalent and adds the 5-hour and 7-day rate-limit percentages |
statusLine, band | on | Show the summary in the status line and in the band |
rates | empty | JSON of USD per million tokens by model family, overriding list prices for some or all fields, for example {"opus-5-5": {"input": 4, "output": 20, "cacheRead": 0.2, "cacheWrite5m": 5, "cacheWrite1h": 8}}. A family matches any model id that contains it and does not continue with another version number, so Bedrock ids such as us.anthropic.claude-opus-5-5-v1:0 resolve. A family with no list price needs input, output and cacheRead |
cacheTtl | auto | Prompt-cache lifetime used for the countdown, the idle warning and write pricing. auto uses what Claude Code reports around a model switch or resume, otherwise 5 minutes. Set 1h if you know your setup uses the one-hour cache |
largeWriteTokens | 50000 | Toast when one request writes at least this many tokens to the cache; 0 turns it off |
contextNudgeTokens | 150000 | Suggest /compact once the context reaches this size; 0 turns it off |
dailyBudget, weeklyBudget, monthlyBudget | 0 | USD; toast at 80% and 100%; 0 turns each off |
expensiveTurnUsd | 2 | Toast when one prompt, its subagents included, costs at least this much (estimated); 0 turns it off |
Models with no list price in the bundled reference (Opus 4.5, Opus 4.1, Sonnet 4.5) are not guessed at: their tokens are reported as unpriced in the pane until rates names them.
/resume, the mod takes Claude Code's running cost at that moment as the new baseline. If Claude Code restores the resumed session's earlier cost only after that point, the earlier cost would be counted again. This has been reasoned from the API documentation and covered by tests, not yet observed in a live resume.The module is hooks/register.tsx. Date and summary logic is in hooks/ledger.ts, pricing in hooks/pricing.ts, and the per-prompt, context and pace arithmetic in hooks/insights.ts; all three are free of engine calls so they can be tested directly.
claude plugin validate plugins/cost-ledger
claude plugin test plugins/cost-ledger
tsc -p plugins/cost-ledger type-checks it once Claude Code has loaded the mod from the folder (for example with claude --plugin-dir plugins/cost-ledger), which writes the API declarations to .claude-plugin/types/. That folder is regenerated on every load and is not committed.
MIT
hooks/register.tsx 953 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Breakdown, Context, RateLimit, ToolUse, Totals } from '../types'
5import {
6 addStepToTurn,
7 addToolResult,
8 budgetReachDay,
9 chargesAsUse,
10 chargeStep,
11 emptyLoop,
12 estimateTokens,
13 finishTurn,
14 monthPace,
15 startTurn,
16 toolLabel,
17 topTools,
18 turnsIn,
19 turnUsd,
20 weekOverWeek,
21} from './insights'
22import {
23 addSpend,
24 addTools,
25 addTurn,
26 addUsage,
27 applyTools,
28 applyTurn,
29 applyUsage,
30 budgetAlert,
31 cacheMinutesLeft,
32 dayKey,
33 daysIn,
34 exportCsv,
35 formatTokens,
36 formatUsd,
37 heatLevel,
38 mergeEntries,
39 monthView,
40 OLDEST_MONTH_OFFSET,
41 projectName,
42 pruneDays,
43 shouldArchive,
44 spendSince,
45 summarize,
46 sumMonth,
47 UNKNOWN_PROJECT,
48 weekStartKey,
49} from './ledger'
50import type { SessionEntry } from './ledger'
51import {
52 addBreakdown,
53 CATEGORIES,
54 hitRate,
55 priceUsage,
56 rateTable,
57 ratesFor,
58 totalUsd,
59 writeRate,
60} from './pricing'
61import type { CacheTtl, RateTable, Usage } from './pricing'
62
63const PANE = 'cost-ledger'
64const SESSION_PREFIX = 'session:'
65const ARCHIVE_PREFIX = 'archive:'
66const LEARNED_TTL_KEY = 'learnedCacheTtl'
67const LEARNED_PRICING_KEY = 'learnedPricing'
68const UNPRICED_MODELS_KEY = 'unpricedModels'
69const PROBE_KEY = 'doctorProbe'
70const STORE_LIMIT_BYTES = 4 * 1024 * 1024
71const SHOWN_TURNS = 10
72const SHOWN_TOOLS = 8
73const BUDGET_PREFIX = 'budget:'
74const TICK_MS = 30_000
75const SHOWN_PROJECTS = 8
76const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
77const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
78const HEAT = ['', '#0e4429', '#006d32', '#26a641', '#39d353'] as const
79const RATE_LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
80const CATEGORY_NAMES = { input: 'Input', output: 'Output', cacheWrite: 'Cache write', cacheRead: 'Cache read' }
81
82const totals = atom({ plugin: 'cost-ledger', key: 'totals' } as const, null)
83const lastUsd = atom({ plugin: 'cost-ledger', key: 'lastUsd' } as const, null)
84const monthOffset = atom({ plugin: 'cost-ledger', key: 'monthOffset' } as const, 0)
85const context = atom({ plugin: 'cost-ledger', key: 'context' } as const, null)
86const shadeBy = atom({ plugin: 'cost-ledger', key: 'shadeBy' } as const, 'total')
87const tick = atom({ plugin: 'cost-ledger', key: 'tick' } as const, 0)
88const activeTurn = atom({ plugin: 'cost-ledger', key: 'activeTurn' } as const, null)
89const turnPrompts = atom({ plugin: 'cost-ledger', key: 'turnPrompts' } as const, {})
90const loops = atom({ plugin: 'cost-ledger', key: 'loops' } as const, {})
91
92type Settings = {
93 isSubscription: boolean
94 hasStatusLine: boolean
95 table: RateTable
96 hasCustomRates: boolean
97 ttlSetting: string
98 largeWriteTokens: number
99 contextNudgeTokens: number
100 budgets: { day: number; week: number; month: number }
101 expensiveTurnUsd: number
102}
103
104function readSettings(options: PluginOptions): Settings {
105 const rates = typeof options.rates === 'string' ? options.rates : ''
106 return {
107 isSubscription: options.billing === 'subscription',
108 hasStatusLine: options.statusLine === true,
109 table: rateTable(rates),
110 hasCustomRates: rates.trim() !== '',
111 ttlSetting: typeof options.cacheTtl === 'string' ? options.cacheTtl : 'auto',
112 largeWriteTokens: typeof options.largeWriteTokens === 'number' ? options.largeWriteTokens : 0,
113 contextNudgeTokens: typeof options.contextNudgeTokens === 'number' ? options.contextNudgeTokens : 0,
114 budgets: {
115 day: typeof options.dailyBudget === 'number' ? options.dailyBudget : 0,
116 week: typeof options.weeklyBudget === 'number' ? options.weeklyBudget : 0,
117 month: typeof options.monthlyBudget === 'number' ? options.monthlyBudget : 0,
118 },
119 expensiveTurnUsd: typeof options.expensiveTurnUsd === 'number' ? options.expensiveTurnUsd : 0,
120 }
121}
122
123async function loadEntries($: EngineInterface): Promise<[string, SessionEntry][]> {
124 const keys = (await $.store.keys()).filter(key => key.startsWith(SESSION_PREFIX) || key.startsWith(ARCHIVE_PREFIX))
125 return Promise.all(
126 keys.map(async key => [key, (await $.store.get(key)) as SessionEntry] as [string, SessionEntry]),
127 )
128}
129
130async function cacheTtl($: EngineInterface, settings: Settings): Promise<CacheTtl> {
131 if (settings.ttlSetting === '5m' || settings.ttlSetting === '1h') return settings.ttlSetting
132 const learned = await $.store.get(LEARNED_TTL_KEY)
133 return learned === '1h' ? '1h' : '5m'
134}
135
136function rateLimitText(limits: readonly RateLimit[]): string {
137 return limits.map(l => `${RATE_LIMIT_NAMES[l.kind] ?? l.kind} ${Math.round(l.percentUsed)}%`).join(' · ')
138}
139
140function perStepText(ctx: Context, settings: Settings): string {
141 const rates = ratesFor(settings.table, ctx.model)
142 return rates ? ` ≈${formatUsd((ctx.tokens * rates.cacheRead) / 1_000_000)}/step` : ''
143}
144
145function nudgeText(ctx: Context, settings: Settings): string {
146 return settings.contextNudgeTokens > 0 && ctx.tokens >= settings.contextNudgeTokens ? ', consider /compact' : ''
147}
148
149function cacheText(ctx: Context, ttl: CacheTtl, now: number): string {
150 const left = cacheMinutesLeft(ctx.at, TTL_MS[ttl], now)
151 return left > 0 ? ` · cache warm ${left}m` : ' · cache cold'
152}
153
154// The context segment of the status line and band; empty before the first
155// main-thread response of the session.
156async function contextLine($: EngineInterface, settings: Settings, ctx: Context | null): Promise<string> {
157 if (ctx === null || ctx.tokens === 0) return ''
158 const ttl = await cacheTtl($, settings)
159 const cache = cacheText(ctx, ttl, await $.clock.now())
160 return ` · ctx ${formatTokens(ctx.tokens)}${perStepText(ctx, settings)}${cache}${nudgeText(ctx, settings)}`
161}
162
163function summaryLine(t: Totals, settings: Settings, contextPart: string): string {
164 const qualifier = settings.isSubscription ? ' (API-equiv.)' : ''
165 const costs = `${formatUsd(t.session)} session · ${formatUsd(t.week)} week · ${formatUsd(t.month)} month${qualifier}`
166 const limits = settings.isSubscription && t.rateLimits.length > 0 ? ` · ${rateLimitText(t.rateLimits)}` : ''
167 return costs + limits + contextPart
168}
169
170async function updateStatus($: EngineInterface, settings: Settings) {
171 if (!settings.hasStatusLine) {
172 $.ui.status(undefined)
173 return
174 }
175 const t = await read($, totals)
176 if (t === null) return
177 const ctx = await read($, context)
178 $.ui.status(summaryLine(t, settings, await contextLine($, settings, ctx)))
179}
180
181async function onTick($: EngineInterface, settings: Settings) {
182 const now = await $.clock.now()
183 await update($, tick, () => now)
184 await updateStatus($, settings)
185}
186
187async function checkBudgets($: EngineInterface, settings: Settings, t: Totals) {
188 const today = dayKey(await $.clock.now())
189 const periods = [
190 { name: 'Daily', key: `day:${today}`, spent: t.today, limit: settings.budgets.day },
191 { name: 'Weekly', key: `week:${t.weekStart}`, spent: t.week, limit: settings.budgets.week },
192 { name: 'Monthly', key: `month:${today.slice(0, 7)}`, spent: t.month, limit: settings.budgets.month },
193 ]
194 for (const period of periods) {
195 const storeKey = `${BUDGET_PREFIX}${period.key}:${period.limit}`
196 const announced = Number((await $.store.get(storeKey)) ?? 0)
197 const reached = budgetAlert(period.spent, period.limit, announced)
198 if (reached === null) continue
199
200 await $.store.set(storeKey, reached)
201 const verb = reached >= 1 ? 'reached' : `at ${Math.round(reached * 100)}%`
202 $.ui.toast(`${period.name} budget ${verb}: ${formatUsd(period.spent)} of ${formatUsd(period.limit)}.`, {
203 timeoutMs: 10_000,
204 })
205 }
206}
207
208async function pruneBudgetKeys($: EngineInterface, now: number) {
209 const today = dayKey(now)
210 const current = [`day:${today}:`, `week:${weekStartKey(now)}:`, `month:${today.slice(0, 7)}:`]
211 const isCurrent = (period: string) => current.some(prefix => period.startsWith(prefix))
212 for (const key of await $.store.keys()) {
213 if (key.startsWith(BUDGET_PREFIX) && !isCurrent(key.slice(BUDGET_PREFIX.length))) await $.store.delete(key)
214 }
215}
216
217async function writeExport($: EngineInterface): Promise<string> {
218 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
219 if (home === undefined) return 'Could not find a home folder to write the export to.'
220
221 const csv = exportCsv((await loadEntries($)).map(([, entry]) => entry))
222 const path = `${home}/claude-costs/claude-costs-${dayKey(await $.clock.now())}.csv`
223 await $.fs.write(path, csv)
224 return `Wrote ${csv.trimEnd().split('\n').length - 1} rows to ${path}`
225}
226
227// Module scope on purpose: one notice per load of the mod is enough.
228let hasWarnedSaveFailure = false
229
230async function saveEntry($: EngineInterface, key: string, entry: SessionEntry): Promise<boolean> {
231 try {
232 await $.store.set(key, entry)
233 return true
234 } catch (error) {
235 if (!hasWarnedSaveFailure) {
236 hasWarnedSaveFailure = true
237 $.ui.toast(`cost-ledger could not save spend, so totals will fall behind: ${String(error)}`, {
238 timeoutMs: 15_000,
239 })
240 }
241 return false
242 }
243}
244
245// Sessions untouched for 60 days are folded into one archive entry per
246// project, and days past retention are dropped, which keeps the store far
247// below its 4 MiB limit however many sessions run.
248async function compactStore($: EngineInterface, now: number) {
249 for (const [key, entry] of await loadEntries($)) {
250 if (!key.startsWith(SESSION_PREFIX) || !shouldArchive(entry, now)) continue
251 const archiveKey = ARCHIVE_PREFIX + (entry.project ?? UNKNOWN_PROJECT)
252 const archive = (await $.store.get(archiveKey)) as SessionEntry | undefined
253 if (await saveEntry($, archiveKey, mergeEntries(archive, entry))) await $.store.delete(key)
254 }
255 for (const [key, entry] of await loadEntries($)) {
256 const pruned = pruneDays(entry, now)
257 const dayCount = (e: SessionEntry) =>
258 [e.days, e.cache, e.subagentCache].reduce((sum, days) => sum + Object.keys(days ?? {}).length, 0)
259 if (pruned === undefined) await $.store.delete(key)
260 else if (dayCount(pruned) !== dayCount(entry)) await saveEntry($, key, pruned)
261 }
262}
263
264async function rebaseline($: EngineInterface) {
265 const usage = await $.session.usage()
266 const usd = usage.cost?.usd ?? 0
267 await update($, lastUsd, () => usd)
268}
269
270async function refresh(
271 $: EngineInterface,
272 settings: Settings,
273 sessionUsd?: number,
274 rateLimits?: readonly RateLimit[],
275) {
276 const now = await $.clock.now()
277 const previous = await read($, totals)
278 const entries = (await loadEntries($)).map(([, entry]) => entry)
279 const next: Totals = {
280 session: sessionUsd ?? previous?.session ?? 0,
281 ...summarize(entries, now),
282 rateLimits: [...(rateLimits ?? previous?.rateLimits ?? [])],
283 }
284 await update($, totals, () => next)
285 await updateStatus($, settings)
286 await checkBudgets($, settings, next)
287}
288
289async function recordSpend($: EngineInterface, usd: number) {
290 const spent = spendSince(await read($, lastUsd), usd)
291 if (spent > 0) {
292 const key = SESSION_PREFIX + (await $.session.id())
293 const entry = (await $.store.get(key)) as SessionEntry | undefined
294 // The baseline moves only once the spend is saved, so a failed save is
295 // counted again by the next measurement instead of being lost.
296 if (!(await saveEntry($, key, addSpend(entry, await $.clock.now(), spent, await $.session.root())))) return
297 }
298 await update($, lastUsd, () => usd)
299}
300
301type Step = { turnId: string; agentId?: string; startedAt: number }
302
303async function recordStep($: EngineInterface, settings: Settings, usage: Usage & { model: string }, step: Step) {
304 const isMainThread = step.agentId === undefined
305 const ttl = await cacheTtl($, settings)
306 const rates = ratesFor(settings.table, usage.model)
307 const priced = priceUsage(usage, rates, ttl)
308 if (rates === undefined) await noteUnpricedModel($, usage.model)
309
310 const loopId = step.agentId ?? 'main'
311 const loopsNow = await read($, loops)
312 const charged = chargeStep(
313 loopsNow[loopId] ?? emptyLoop(),
314 (rates?.cacheRead ?? 0) / 1_000_000,
315 (rates ? writeRate(rates, ttl) : 0) / 1_000_000,
316 )
317 await update($, loops, all => ({ ...all, [loopId]: charged.loop }))
318 const toolCosts = chargesAsUse(charged.charges)
319
320 const now = await $.clock.now()
321 const project = await $.session.root()
322 const key = SESSION_PREFIX + (await $.session.id())
323 const entry = (await $.store.get(key)) as SessionEntry | undefined
324 await saveEntry($, key, addTools(addUsage(entry, now, priced, project, !isMainThread), now, toolCosts))
325 await update($, totals, t => (t === null ? t : applyTools(t, now, toolCosts)))
326 await addToActiveTurn($, step, priced, isMainThread, now)
327 await update($, totals, t => (t === null ? t : applyUsage(t, now, priced, project, !isMainThread)))
328
329 if (isMainThread) {
330 const tokens =
331 usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens + usage.output_tokens
332 await update($, context, () => ({ tokens, model: usage.model, at: step.startedAt }))
333 }
334
335 const written = usage.cache_creation_input_tokens
336 if (settings.largeWriteTokens > 0 && written >= settings.largeWriteTokens) {
337 const cost = rates ? `≈ ${formatUsd(priced.usd.cacheWrite)}` : '(no rate for this model)'
338 $.ui.toast(`One request wrote ${formatTokens(written)} tokens to the prompt cache ${cost} on ${usage.model}.`, {
339 timeoutMs: 8000,
340 })
341 }
342
343 await updateStatus($, settings)
344}
345
346async function noteUnpricedModel($: EngineInterface, model: string) {
347 const seen = ((await $.store.get(UNPRICED_MODELS_KEY)) as string[] | undefined) ?? []
348 if (!seen.includes(model)) await $.store.set(UNPRICED_MODELS_KEY, [...seen, model].slice(-20))
349}
350
351// A turn becomes the prompt's record at its first main-thread request, since
352// turn.start does not say which loop it belongs to. Subagent requests made
353// while it runs are charged to it.
354async function addToActiveTurn(
355 $: EngineInterface,
356 step: Step,
357 priced: Breakdown,
358 isMainThread: boolean,
359 now: number,
360) {
361 const current = await read($, activeTurn)
362 if (isMainThread && current?.turnId !== step.turnId) {
363 const prompt = (await read($, turnPrompts))[step.turnId]
364 const started = startTurn(step.turnId, prompt?.text || '(continuation)', prompt?.at ?? now)
365 await update($, activeTurn, () => addStepToTurn(started, priced, false))
366 return
367 }
368 if (current !== null) await update($, activeTurn, turn => (turn === null ? turn : addStepToTurn(turn, priced, !isMainThread)))
369}
370
371async function finishActiveTurn($: EngineInterface, settings: Settings, turnId: string) {
372 const turn = await read($, activeTurn)
373 await update($, turnPrompts, prompts => {
374 const { [turnId]: _done, ...rest } = prompts
375 return rest
376 })
377 if (turn === null || turn.turnId !== turnId) return
378 await update($, activeTurn, () => null)
379
380 const now = await $.clock.now()
381 const record = finishTurn(turn, await $.session.root())
382 const key = SESSION_PREFIX + (await $.session.id())
383 const entry = (await $.store.get(key)) as SessionEntry | undefined
384 await saveEntry($, key, addTurn(entry, now, record))
385 await update($, totals, t => (t === null ? t : applyTurn(t, record)))
386
387 const usd = turnUsd(record)
388 if (settings.expensiveTurnUsd > 0 && usd >= settings.expensiveTurnUsd) {
389 const detail = `${record.steps} requests${record.subagentSteps > 0 ? ` + ${record.subagentSteps} by subagents` : ''}`
390 $.ui.toast(`That prompt cost ≈ ${formatUsd(usd)} (${detail}): “${record.prompt}”`, { timeoutMs: 10_000 })
391 }
392}
393
394async function recordToolResult($: EngineInterface, tool: string, agentId: string | undefined, text: string) {
395 const tokens = estimateTokens(text)
396 const loopId = agentId ?? 'main'
397 await update($, loops, all => ({ ...all, [loopId]: addToolResult(all[loopId], tool, tokens) }))
398
399 const now = await $.clock.now()
400 const use: Record<string, ToolUse> = { [tool]: { calls: 1, tokens, writeUsd: 0, rereadUsd: 0 } }
401 const key = SESSION_PREFIX + (await $.session.id())
402 const entry = (await $.store.get(key)) as SessionEntry | undefined
403 await saveEntry($, key, addTools(entry, now, use))
404 await update($, totals, t => (t === null ? t : applyTools(t, now, use)))
405}
406
407const localStamp = (ms: number) => {
408 const d = new Date(ms)
409 const pad = (n: number) => String(n).padStart(2, '0')
410 const offset = -d.getTimezoneOffset()
411 const sign = offset >= 0 ? '+' : '-'
412 const hhmm = `${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`
413 return `${dayKey(ms)} ${pad(d.getHours())}:${pad(d.getMinutes())} ${sign}${hhmm}`
414}
415
416async function hostLocalTime($: EngineInterface): Promise<string | undefined> {
417 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
418 const argv = isWindows
419 ? ['powershell', '-NoProfile', '-Command', "Get-Date -Format 'yyyy-MM-dd HH:mm zzz'"]
420 : ['date', '+%Y-%m-%d %H:%M %z']
421 try {
422 const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 10_000 })
423 return exitCode === 0 ? stdout.trim().replace(/([+-]\d{2})(\d{2})$/, '$1:$2') : undefined
424 } catch {
425 return undefined
426 }
427}
428
429async function readOwnStoreFiles($: EngineInterface): Promise<{ path: string; text: string }[] | undefined> {
430 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
431 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home && `${home}/.claude`)
432 if (!configDir) return undefined
433 const dir = `${configDir}/plugins/store`
434 try {
435 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.startsWith(`${$.plugin.name}_`))
436 return Promise.all(files.map(async f => ({ path: `${dir}/${f.name}`, text: (await $.fs.read(`${dir}/${f.name}`)) as string })))
437 } catch {
438 return undefined
439 }
440}
441
442function storeKeysOf(text: string): string[] | undefined {
443 try {
444 const parsed: unknown = JSON.parse(text)
445 return typeof parsed === 'object' && parsed !== null ? Object.keys(parsed).sort() : undefined
446 } catch {
447 return undefined
448 }
449}
450
451// Each check reports what it saw; nothing here changes the ledger except the
452// probe key, which is removed again.
453async function runDoctor($: EngineInterface, settings: Settings): Promise<string> {
454 const lines = ['cost-ledger self-check', '']
455 const say = (isOk: boolean | null, label: string, detail: string) =>
456 lines.push(`${isOk === null ? 'ℹ' : isOk ? '✓' : '⚠'} ${label}: ${detail}`)
457 const now = await $.clock.now()
458
459 const mine = localStamp(now)
460 const host = await hostLocalTime($)
461 if (host === undefined) say(null, 'Time zone', `the mod sees ${mine}; the host's clock could not be read to compare`)
462 else {
463 const isSame = host.slice(0, 13) === mine.slice(0, 13) && host.slice(-6) === mine.slice(-6)
464 say(isSame, 'Time zone', isSame ? `day boundaries follow local time (${mine})` : `the mod sees ${mine} but the host says ${host}, so days are split at the wrong hour`)
465 }
466
467 const probe = `${now}-${Math.random().toString(36).slice(2)}`
468 await $.store.set(PROBE_KEY, probe)
469 try {
470 const files = await readOwnStoreFiles($)
471 const own = files?.find(f => f.text.includes(probe))
472 const ownKeys = own && storeKeysOf(own.text)
473 if (own === undefined || ownKeys === undefined) {
474 say(false, 'Store file', 'a write could not be found on disk, so the checks that compare the file were skipped')
475 } else {
476 say(true, 'Store file', `writes reach disk at once (${own.path})`)
477 const inView = [...(await $.store.keys())].sort()
478 const isSameView = ownKeys.join('\n') === inView.join('\n')
479 say(
480 isSameView,
481 'Other sessions',
482 isSameView
483 ? 'this session sees exactly what is on disk, so writes from other sessions are not being overwritten'
484 : `this session sees ${inView.length} keys and the file has ${ownKeys.length}; another session's writes may be lost`,
485 )
486 const percent = Math.round((own.text.length / STORE_LIMIT_BYTES) * 100)
487 say(percent < 75, 'Store size', `${Math.round(own.text.length / 1024)} KiB, ${percent}% of the 4 MiB limit`)
488 }
489 for (const other of files?.filter(f => f !== own) ?? []) {
490 const sessions = storeKeysOf(other.text)?.filter(k => k.startsWith(SESSION_PREFIX)).length
491 say(
492 null,
493 'Another install',
494 sessions === undefined
495 ? `${other.path} could not be read`
496 : `${other.path} holds ${sessions} sessions recorded by a differently installed copy; they are not in these totals`,
497 )
498 }
499 } finally {
500 await $.store.delete(PROBE_KEY)
501 }
502
503 const pricing = await $.store.get(LEARNED_PRICING_KEY)
504 say(
505 null,
506 'Claude Code pricing',
507 pricing === 'configured'
508 ? "your organisation's managed modelPricing, so the totals follow your rates"
509 : pricing === 'catalog'
510 ? 'Anthropic list prices; on Bedrock the totals may differ from your bill'
511 : 'not reported yet; Claude Code reports it when you switch models with /model',
512 )
513
514 const learnedTtl = await $.store.get(LEARNED_TTL_KEY)
515 say(
516 null,
517 'Cache lifetime',
518 `using ${await cacheTtl($, settings)} (setting: ${settings.ttlSetting}; reported by Claude Code: ${typeof learnedTtl === 'string' ? learnedTtl : 'not yet'})`,
519 )
520
521 const usd = (await $.session.usage()).cost?.usd ?? 0
522 const recorded = await read($, lastUsd)
523 if (recorded === null) say(false, 'Baseline', 'not set; spend is recorded from the next measurement')
524 else if (recorded > usd + 0.005) say(false, 'Baseline', `recorded up to ${formatUsd(recorded)}, above the session's ${formatUsd(usd)}`)
525 else say(true, 'Baseline', `session at ${formatUsd(usd)}, recorded up to ${formatUsd(recorded)}${usd - recorded >= 0.005 ? '; the rest is recorded after the next turn' : ''}`)
526
527 if (settings.table.error) say(false, 'Rates', settings.table.error)
528 const unpriced = ((await $.store.get(UNPRICED_MODELS_KEY)) as string[] | undefined) ?? []
529 say(unpriced.length === 0, 'Models', unpriced.length === 0 ? 'every model seen has a rate' : `no rate for ${unpriced.join(', ')}; add them to the rates setting`)
530
531 say(null, 'Resume', 'cannot be checked from here: compare /costs before and after a /resume of a session with known spend')
532 return lines.join('\n')
533}
534
535async function warnIfCacheExpired($: EngineInterface, settings: Settings) {
536 const ctx = await read($, context)
537 if (ctx === null || ctx.tokens === 0) return
538
539 const ttl = await cacheTtl($, settings)
540 const idleMs = (await $.clock.now()) - ctx.at
541 if (idleMs <= TTL_MS[ttl]) return
542
543 const rates = ratesFor(settings.table, ctx.model)
544 const cost = rates ? ` ≈ ${formatUsd((ctx.tokens * writeRate(rates, ttl)) / 1_000_000)}` : ''
545 $.ui.toast(
546 `Prompt cache has likely expired (idle ${Math.round(idleMs / 60_000)} min, ${ttl} cache): ` +
547 `this prompt re-writes ~${formatTokens(ctx.tokens)} tokens${cost}.`,
548 { timeoutMs: 10_000 },
549 )
550}
551
552export const register: Register = (on, options) => {
553 const settings = readSettings(options)
554 const { isSubscription } = settings
555
556 // Steps of parallel subagents finish concurrently; each read-modify-write of
557 // this session's store entry must see the previous one's result.
558 let queue: Promise<void> = Promise.resolve()
559 const serially = (task: () => Promise<void>) => {
560 const run = queue.then(task)
561 queue = run.catch(() => undefined)
562 return run
563 }
564
565 on('session.start', async ($, e, next) => {
566 await $.command.register({
567 name: 'costs',
568 description: 'Show session, weekly and monthly Claude Code costs; "export" writes a CSV, "doctor" checks the setup',
569 argumentHint: '[export|doctor]',
570 })
571 if (settings.table.error) $.ui.toast(`cost-ledger: ${settings.table.error}`)
572
573 const now = await $.clock.now()
574 await compactStore($, now)
575 await pruneBudgetKeys($, now)
576 $.clock.every(TICK_MS, () => void onTick($, settings))
577
578 const usage = await $.session.usage()
579 const usd = usage.cost?.usd ?? 0
580 // A resumed session can start with spend that an earlier run already
581 // recorded; only growth from here on is new. A reload keeps the baseline.
582 if ((await read($, lastUsd)) === null) await update($, lastUsd, () => usd)
583 await refresh($, settings, usd, usage.rateLimits)
584
585 return next(e)
586 })
587
588 on('classic.SessionStart', async ($, e, next) => {
589 // session.start fires once per process, but /clear, /resume and a fork
590 // switch sessions inside it: the new session's running total starts from
591 // whatever it carries, and none of that is new spend.
592 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') await serially(() => rebaseline($))
593 if (e.source === 'clear' || e.source === 'compact') {
594 await update($, context, () => null)
595 await update($, loops, () => ({}))
596 }
597 if (e.source === 'clear') {
598 await update($, activeTurn, () => null)
599 await update($, turnPrompts, () => ({}))
600 }
601
602 // A resumed transcript's cache is as old as its last response, so the
603 // idle warning on the first prompt covers resumes too.
604 if (e.context_tokens && e.seconds_since_last_response !== undefined && e.model) {
605 const at = (await $.clock.now()) - e.seconds_since_last_response * 1000
606 await update($, context, () => ({ tokens: e.context_tokens ?? 0, model: e.model ?? '', at }))
607 }
608 return next(e)
609 })
610
611 on('classic.PreModelSwitch', async ($, e, next) => {
612 await $.store.set(LEARNED_TTL_KEY, e.cache_ttl)
613 await $.store.set(LEARNED_PRICING_KEY, e.pricing)
614 if (e.prompt_cache_warm && e.context_tokens > 0) {
615 const rates = ratesFor(settings.table, e.to_model)
616 const usd = rates
617 ? (e.context_tokens * writeRate(rates, e.cache_ttl)) / 1_000_000
618 : e.estimated_cache_write_usd
619 $.ui.toast(
620 `Switching to ${e.to_model} gives up a warm prompt cache: ` +
621 `the next request re-writes ~${formatTokens(e.context_tokens)} tokens ≈ ${formatUsd(usd)}.`,
622 { timeoutMs: 10_000 },
623 )
624 }
625 return next(e)
626 })
627
628 on('classic.PostModelSwitch', async ($, e, next) => {
629 await $.store.set(LEARNED_TTL_KEY, e.cache_ttl)
630 await $.store.set(LEARNED_PRICING_KEY, e.pricing)
631 return next(e)
632 })
633
634 // Spend after the last measurement (a background subagent, an exit right
635 // after a turn) is otherwise never recorded; a /clear or /resume ends the
636 // old session here before its successor's baseline is taken.
637 on('session.end', async ($, e, next) => {
638 const usage = await $.session.usage()
639 const usd = usage.cost?.usd
640 if (usd !== undefined) await serially(() => recordSpend($, usd))
641 return next(e)
642 })
643
644 on('prompt.submit', async ($, e, next) => {
645 await warnIfCacheExpired($, settings)
646 return next(e)
647 })
648
649 on('turn.start', async ($, e, next) => {
650 const at = await $.clock.now()
651 await update($, turnPrompts, prompts => ({ ...prompts, [e.turnId]: { text: e.text, at } }))
652 return next(e)
653 })
654
655 on('turn.step', async function* ($, e, next) {
656 const step = { turnId: e.turnId, agentId: e.agentId, startedAt: await $.clock.now() }
657 const result = yield* next(e)
658 const usage = result.usage
659 if (usage) await serially(() => recordStep($, settings, usage, step))
660 return result
661 })
662
663 on('turn.complete', async ($, e, next) => {
664 const result = await next(e)
665 const agentId = e.agentId
666 if (agentId === undefined) await serially(() => finishActiveTurn($, settings, e.turnId))
667 else
668 await update($, loops, all => {
669 const { [agentId]: _done, ...rest } = all
670 return rest
671 })
672 return result
673 })
674
675 on('tool.call', async ($, e, next) => {
676 const result = await next(e)
677 const text = result.text
678 if (typeof text === 'string' && text.length > 0) {
679 await serially(() => recordToolResult($, String(e.tool), e.agentId, text))
680 }
681 return result
682 })
683
684 on('session.measure', async ($, e, next) => {
685 const cost = e.cost
686 if (cost) await serially(() => recordSpend($, cost.usd))
687 await refresh($, settings, e.cost?.usd ?? 0, e.rateLimits)
688
689 return next(e)
690 })
691
692 on('command.run', { command: 'costs' }, async ($, e) => {
693 const args = e.args.trim()
694 if (args === 'export') return { text: await writeExport($) }
695 if (args === 'doctor') return { text: await runDoctor($, settings) }
696
697 const usage = await $.session.usage()
698 await refresh($, settings, usage.cost?.usd ?? 0, usage.rateLimits)
699 await update($, monthOffset, () => 0)
700 await $.ui.open({ id: PANE, title: 'Costs' })
701
702 return { text: 'Costs pane opened.' }
703 })
704
705 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
706 const t = await read($, totals)
707 if (!options.band || e.props.hasSurvey || t === null) return next(e)
708
709 await read($, tick)
710 const { Text } = $.ui.resolve(e)
711 const contextPart = await contextLine($, settings, await read($, context))
712 return <Text dimColor>{summaryLine(t, settings, contextPart)}</Text>
713 })
714
715 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
716 const { Box, Button, Text } = $.ui.resolve(e)
717 const t = await read($, totals)
718 if (t === null) return <Text dimColor>No cost recorded yet.</Text>
719
720 const now = await $.clock.now()
721 const today = dayKey(now)
722 const offset = await read($, monthOffset)
723 const shading = await read($, shadeBy)
724 const ctx = await read($, context)
725 await read($, tick)
726 const shown = monthView(now, offset)
727 const days = daysIn(t.days, shown.key)
728 const shownTotal = days.reduce((sum, [, usd]) => sum + usd, 0)
729 const cache = sumMonth<Breakdown>(t.cacheDays, shown.key, addBreakdown)
730 const cacheSum = cache ? totalUsd(cache) : 0
731 const subagents = sumMonth<Breakdown>(t.subagentDays, shown.key, addBreakdown)
732 const subagentSum = subagents ? totalUsd(subagents) : 0
733 const projects = Object.entries(t.projectDays)
734 .map(([root, projectDays]) => {
735 const month = Object.entries(projectDays).filter(([day]) => day.startsWith(shown.key))
736 return {
737 name: projectName(root),
738 usd: month.reduce((sum, [, d]) => sum + d.usd, 0),
739 cacheWriteUsd: month.reduce((sum, [, d]) => sum + d.cacheWriteUsd, 0),
740 }
741 })
742 .filter(p => p.usd > 0 || p.cacheWriteUsd > 0)
743 .sort((a, b) => b.usd - a.usd)
744 const ttl = await cacheTtl($, settings)
745 const pace = monthPace(t.month, now)
746 const reachDay = budgetReachDay(t.month, settings.budgets.month, now)
747 const wow = weekOverWeek(t.days, now)
748 const monthFrom = `${shown.key}-01`
749 const monthTo = `${shown.key}-31`
750 const expensive = turnsIn(t.turns, monthFrom, monthTo).slice(0, SHOWN_TURNS)
751 const tools = topTools(t.toolDays, monthFrom, monthTo).slice(0, SHOWN_TOOLS)
752
753 const shadeValue = (day: string) =>
754 shading === 'cacheWrite' ? (t.cacheDays[day]?.usd.cacheWrite ?? 0) : (t.days[day] ?? 0)
755 const monthKeys = Object.keys({ ...t.days, ...t.cacheDays }).filter(day => day.startsWith(shown.key))
756 const shadePeak = Math.max(0, ...monthKeys.map(shadeValue))
757 const barPeak = Math.max(0, ...days.map(([, usd]) => usd))
758 const barRoom = Math.max(4, (e.props.bodyColumns ?? 40) - 24)
759
760 const row = (label: string, usd: number, note = '', budget = 0) => (
761 <Text>
762 {label.padEnd(12)}
763 {formatUsd(usd).padStart(10)}
764 {budget > 0 && (
765 <Text color={usd >= budget ? 'error' : usd >= budget * 0.8 ? 'warning' : undefined}>
766 {` of ${formatUsd(budget)} (${Math.round((usd / budget) * 100)}%)`}
767 </Text>
768 )}
769 <Text dimColor>{note}</Text>
770 </Text>
771 )
772 const cell = (day: number | null) => {
773 if (day === null) return <Text>{' '}</Text>
774 const key = `${shown.key}-${String(day).padStart(2, '0')}`
775 const level = heatLevel(shadeValue(key), shadePeak)
776 return (
777 <Text>
778 {' '}
779 <Text
780 backgroundColor={level > 0 ? HEAT[level] : undefined}
781 color={level > 0 ? '#ffffff' : undefined}
782 dimColor={level === 0}
783 bold={key === today}
784 underline={key === today}
785 >
786 {String(day).padStart(2)}
787 </Text>
788 </Text>
789 )
790 }
791
792 return (
793 <Box flexDirection="column">
794 {isSubscription && <Text dimColor>Figures are API-equivalent, not billed amounts.</Text>}
795 {row('Session', t.session)}
796 {row('Today', t.today, '', settings.budgets.day)}
797 {row(
798 'This week',
799 t.week,
800 wow.change === null
801 ? ` since ${t.weekStart}`
802 : ` ${wow.change >= 0 ? '+' : ''}${Math.round(wow.change * 100)}% vs this point last week`,
803 settings.budgets.week,
804 )}
805 {row(
806 'This month',
807 t.month,
808 ` on pace for ${formatUsd(pace)}${reachDay ? `, budget reached ~${reachDay}` : ''}`,
809 settings.budgets.month,
810 )}
811 {isSubscription && t.rateLimits.length > 0 && <Text>Rate limits: {rateLimitText(t.rateLimits)}</Text>}
812 <Text> </Text>
813 <Box flexDirection="row">
814 {offset > OLDEST_MONTH_OFFSET ? (
815 <Button key="prev" label="◀" plain hotkey="p" onPress={() => update($, monthOffset, n => n - 1)} />
816 ) : (
817 <Text> </Text>
818 )}
819 <Text bold> {shown.name.padEnd(15)}</Text>
820 {offset < 0 ? (
821 <Button key="next" label="▶" plain hotkey="n" onPress={() => update($, monthOffset, n => n + 1)} />
822 ) : (
823 <Text> </Text>
824 )}
825 </Box>
826 <Text dimColor>{WEEKDAYS.map(d => ` ${d}`).join('')}</Text>
827 {shown.weeks.map(week => (
828 <Text>{week.map(cell)}</Text>
829 ))}
830 <Text>
831 <Text dimColor>{'less '}</Text>
832 {HEAT.slice(1).map(color => (
833 <Text backgroundColor={color}>{' '}</Text>
834 ))}
835 <Text dimColor>{' more'}</Text>
836 {' '}
837 {formatUsd(shownTotal)}
838 <Text dimColor> total</Text>
839 </Text>
840 <Box flexDirection="row">
841 <Text dimColor>Shade by </Text>
842 <Button
843 key="shade"
844 label={shading === 'total' ? 'total spend' : 'cache writes'}
845 plain
846 hotkey="s"
847 onPress={() => update($, shadeBy, s => (s === 'total' ? 'cacheWrite' : 'total'))}
848 />
849 </Box>
850 <Text> </Text>
851 <Text bold>
852 Prompt cache, {shown.name}
853 <Text dimColor> (estimated at {settings.hasCustomRates ? 'your rates' : 'list prices'})</Text>
854 </Text>
855 {cache === undefined ? (
856 <Text dimColor>No requests recorded.</Text>
857 ) : (
858 <Box flexDirection="column">
859 <Text dimColor>{' '}{'tokens'.padStart(8)}{'cost'.padStart(11)}{'share'.padStart(7)}</Text>
860 {CATEGORIES.map(c => (
861 <Text>
862 {CATEGORY_NAMES[c].padEnd(12)}
863 {formatTokens(cache.tokens[c]).padStart(8)}
864 {formatUsd(cache.usd[c]).padStart(11)}
865 <Text dimColor>{`${cacheSum > 0 ? Math.round((cache.usd[c] / cacheSum) * 100) : 0}%`.padStart(7)}</Text>
866 </Text>
867 ))}
868 {(cache.unpricedTokens ?? 0) > 0 && (
869 <Text color="warning">
870 {formatTokens(cache.unpricedTokens ?? 0)} tokens from models with no rate are not priced; add them to
871 the rates setting
872 </Text>
873 )}
874 <Text dimColor>
875 Cache hit {Math.round(hitRate(cache) * 100)}% of prompt tokens
876 {settings.hasCustomRates ? '; totals above use Claude Code’s own pricing' : ''}
877 </Text>
878 <Text>
879 {'Subagents'.padEnd(12)}
880 {formatUsd(subagentSum).padStart(19)}
881 <Text dimColor>
882 {`${cacheSum > 0 ? Math.round((subagentSum / cacheSum) * 100) : 0}%`.padStart(7)}
883 {subagents ? ` incl. ${formatUsd(subagents.usd.cacheWrite)} cache writes` : ''}
884 </Text>
885 </Text>
886 </Box>
887 )}
888 {ctx !== null && ctx.tokens > 0 && (
889 <Text dimColor>
890 Current context {formatTokens(ctx.tokens)} on {ctx.model}
891 {perStepText(ctx, settings)}
892 {cacheText(ctx, ttl, now)}
893 {nudgeText(ctx, settings)}
894 </Text>
895 )}
896 <Text> </Text>
897 <Text bold>
898 Most expensive prompts, {shown.name}
899 <Text dimColor> (estimated, subagents included)</Text>
900 </Text>
901 {expensive.length === 0 && <Text dimColor>None recorded.</Text>}
902 {expensive.map(turn => (
903 <Text>
904 {formatUsd(turnUsd(turn)).padStart(9)}{' '}
905 <Text dimColor>{`${turn.steps}${turn.subagentSteps > 0 ? `+${turn.subagentSteps}` : ''} req `.padStart(10)}</Text>
906 {turn.prompt.slice(0, Math.max(10, (e.props.bodyColumns ?? 60) - 22))}
907 </Text>
908 ))}
909 <Text> </Text>
910 <Text bold>
911 What filled the context, {shown.name}
912 <Text dimColor> (tool results, ≈ tokens)</Text>
913 </Text>
914 {tools.length === 0 && <Text dimColor>No tool results recorded.</Text>}
915 {tools.length > 0 && (
916 <Text dimColor>{'tool'.padEnd(22)}{'calls'.padStart(6)}{'tokens'.padStart(8)}{'write'.padStart(9)}{'re-read'.padStart(10)}</Text>
917 )}
918 {tools.map(([tool, use]) => (
919 <Text>
920 {toolLabel(tool).slice(0, 21).padEnd(22)}
921 {String(use.calls).padStart(6)}
922 {formatTokens(use.tokens).padStart(8)}
923 {formatUsd(use.writeUsd).padStart(9)}
924 {formatUsd(use.rereadUsd).padStart(10)}
925 </Text>
926 ))}
927 <Text> </Text>
928 <Text bold>By project, {shown.name}</Text>
929 {projects.length === 0 && <Text dimColor>No spend recorded.</Text>}
930 {projects.slice(0, SHOWN_PROJECTS).map(p => (
931 <Text>
932 {p.name.slice(0, 20).padEnd(21)}
933 {formatUsd(p.usd).padStart(10)}
934 <Text dimColor>{` ${formatUsd(p.cacheWriteUsd)} cache writes`}</Text>
935 </Text>
936 ))}
937 {projects.length > SHOWN_PROJECTS && (
938 <Text dimColor>and {projects.length - SHOWN_PROJECTS} more; /costs export has them all</Text>
939 )}
940 <Text> </Text>
941 <Text bold>Daily, {shown.name}</Text>
942 {days.length === 0 && <Text dimColor>No spend recorded.</Text>}
943 {days.map(([day, usd]) => (
944 <Text>
945 {day} {formatUsd(usd).padStart(9)}{' '}
946 <Text color="cyan">{'█'.repeat(Math.max(1, Math.round((usd / barPeak) * barRoom)))}</Text>
947 </Text>
948 ))}
949 </Box>
950 )
951 })
952}
953hooks/insights.ts 195 lines1import { addBreakdown, emptyBreakdown, totalUsd } from './pricing'
2import type { ActiveTurn, Breakdown, LoopContext, ToolUse, TurnRecord } from '../types'
3
4const DAY_MS = 24 * 60 * 60 * 1000
5const PROMPT_CHARS = 80
6export const TURNS_PER_MONTH = 10
7
8const pad = (n: number) => String(n).padStart(2, '0')
9const localDay = (ms: number) => {
10 const d = new Date(ms)
11 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
12}
13
14// ---- Cost per prompt -------------------------------------------------------
15
16export function startTurn(turnId: string, text: string, now: number): ActiveTurn {
17 const prompt = text.replace(/\s+/g, ' ').trim()
18 return {
19 turnId,
20 prompt: prompt.length > PROMPT_CHARS ? `${prompt.slice(0, PROMPT_CHARS - 1)}…` : prompt,
21 at: now,
22 steps: 0,
23 subagentSteps: 0,
24 usage: emptyBreakdown(),
25 subagentUsage: emptyBreakdown(),
26 }
27}
28
29export function addStepToTurn(turn: ActiveTurn, usage: Breakdown, isSubagent: boolean): ActiveTurn {
30 return {
31 ...turn,
32 steps: turn.steps + (isSubagent ? 0 : 1),
33 subagentSteps: turn.subagentSteps + (isSubagent ? 1 : 0),
34 usage: addBreakdown(turn.usage, usage),
35 subagentUsage: isSubagent ? addBreakdown(turn.subagentUsage, usage) : turn.subagentUsage,
36 }
37}
38
39export function finishTurn(turn: ActiveTurn, project: string): TurnRecord {
40 const { turnId: _turnId, ...record } = turn
41 return { ...record, project }
42}
43
44export function turnUsd(turn: TurnRecord): number {
45 return totalUsd(turn.usage)
46}
47
48// Keeps the most expensive prompts of each month, so a month's top list
49// survives however many cheap prompts follow it.
50export function keepTopTurns(turns: readonly TurnRecord[]): TurnRecord[] {
51 const byMonth = new Map<string, TurnRecord[]>()
52 for (const turn of turns) {
53 const month = localDay(turn.at).slice(0, 7)
54 byMonth.set(month, [...(byMonth.get(month) ?? []), turn])
55 }
56 return [...byMonth.values()].flatMap(month =>
57 [...month].sort((a, b) => turnUsd(b) - turnUsd(a)).slice(0, TURNS_PER_MONTH),
58 )
59}
60
61export function turnsIn(turns: readonly TurnRecord[], fromDay: string, toDay: string): TurnRecord[] {
62 return turns
63 .filter(turn => {
64 const day = localDay(turn.at)
65 return day >= fromDay && day <= toDay
66 })
67 .sort((a, b) => turnUsd(b) - turnUsd(a))
68}
69
70// ---- What fills the context --------------------------------------------------
71
72// Tool results are measured in characters; four per token is the usual rough
73// ratio for English and code, so these figures are approximate by design.
74export function estimateTokens(text: string): number {
75 return Math.ceil(text.length / 4)
76}
77
78export function toolLabel(tool: string): string {
79 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
80 return mcp ? `${mcp[1]}/${mcp[2]}` : tool
81}
82
83export function emptyLoop(): LoopContext {
84 return { pending: {}, resident: {} }
85}
86
87export function addToolResult(loop: LoopContext | undefined, tool: string, tokens: number): LoopContext {
88 const current = loop ?? emptyLoop()
89 return { ...current, pending: { ...current.pending, [tool]: (current.pending[tool] ?? 0) + tokens } }
90}
91
92export type ToolCharges = Record<string, { writeUsd: number; rereadUsd: number }>
93
94// One model request of a loop: results already cached are read again at the
95// read rate, and results added since the last request are written at the write
96// rate, after which they are cached too.
97export function chargeStep(
98 loop: LoopContext,
99 readPerToken: number,
100 writePerToken: number,
101): { loop: LoopContext; charges: ToolCharges } {
102 const charges: ToolCharges = {}
103 const charge = (tool: string) => (charges[tool] ??= { writeUsd: 0, rereadUsd: 0 })
104 for (const [tool, tokens] of Object.entries(loop.resident)) charge(tool).rereadUsd += tokens * readPerToken
105 const resident = { ...loop.resident }
106 for (const [tool, tokens] of Object.entries(loop.pending)) {
107 charge(tool).writeUsd += tokens * writePerToken
108 resident[tool] = (resident[tool] ?? 0) + tokens
109 }
110 return { loop: { pending: {}, resident }, charges }
111}
112
113const emptyToolUse = (): ToolUse => ({ calls: 0, tokens: 0, writeUsd: 0, rereadUsd: 0 })
114
115export function addToolUse(a: ToolUse | undefined, b: ToolUse): ToolUse {
116 const sum = a ?? emptyToolUse()
117 return {
118 calls: sum.calls + b.calls,
119 tokens: sum.tokens + b.tokens,
120 writeUsd: sum.writeUsd + b.writeUsd,
121 rereadUsd: sum.rereadUsd + b.rereadUsd,
122 }
123}
124
125export function mergeToolMaps(
126 a: Record<string, ToolUse> | undefined,
127 b: Record<string, ToolUse> | undefined,
128): Record<string, ToolUse> {
129 const merged = { ...a }
130 for (const [tool, use] of Object.entries(b ?? {})) merged[tool] = addToolUse(merged[tool], use)
131 return merged
132}
133
134export function chargesAsUse(charges: ToolCharges): Record<string, ToolUse> {
135 return Object.fromEntries(
136 Object.entries(charges).map(([tool, c]) => [tool, { ...emptyToolUse(), writeUsd: c.writeUsd, rereadUsd: c.rereadUsd }]),
137 )
138}
139
140export function topTools(
141 toolDays: Record<string, Record<string, ToolUse>>,
142 fromDay: string,
143 toDay: string,
144): [string, ToolUse][] {
145 let merged: Record<string, ToolUse> = {}
146 for (const [day, tools] of Object.entries(toolDays)) {
147 if (day >= fromDay && day <= toDay) merged = mergeToolMaps(merged, tools)
148 }
149 return Object.entries(merged).sort(([, a], [, b]) => b.writeUsd + b.rereadUsd - (a.writeUsd + a.rereadUsd))
150}
151
152// ---- Pace and week over week ------------------------------------------------
153
154export function monthBounds(now: number): { start: number; end: number; days: number } {
155 const d = new Date(now)
156 const start = new Date(d.getFullYear(), d.getMonth(), 1).getTime()
157 const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
158 return { start, end, days: Math.round((end - start) / DAY_MS) }
159}
160
161// Month-to-date spend extrapolated at its average daily rate; at least one
162// day has elapsed so a busy first morning does not project a fortune.
163export function monthPace(monthUsd: number, now: number): number {
164 const { start, days } = monthBounds(now)
165 const elapsed = Math.max(1, (now - start) / DAY_MS)
166 return (monthUsd / elapsed) * days
167}
168
169// The local day on which spend at the month's average daily rate reaches the
170// budget, or null when it is already reached or will not be this month.
171export function budgetReachDay(monthUsd: number, budget: number, now: number): string | null {
172 if (budget <= 0 || monthUsd >= budget || monthUsd <= 0) return null
173 const { start, end } = monthBounds(now)
174 const rate = monthUsd / Math.max(1, (now - start) / DAY_MS)
175 const reachAt = now + ((budget - monthUsd) / rate) * DAY_MS
176 return reachAt < end ? localDay(reachAt) : null
177}
178
179// This week so far against last week up to the same weekday, by local day.
180export function weekOverWeek(
181 days: Record<string, number>,
182 now: number,
183): { thisWeek: number; lastWeek: number; change: number | null } {
184 const d = new Date(now)
185 const sinceMonday = (d.getDay() + 6) % 7
186 const dayAt = (offset: number) => localDay(new Date(d.getFullYear(), d.getMonth(), d.getDate() + offset).getTime())
187 let thisWeek = 0
188 let lastWeek = 0
189 for (let i = -sinceMonday; i <= 0; i++) {
190 thisWeek += days[dayAt(i)] ?? 0
191 lastWeek += days[dayAt(i - 7)] ?? 0
192 }
193 return { thisWeek, lastWeek, change: lastWeek > 0 ? (thisWeek - lastWeek) / lastWeek : null }
194}
195hooks/ledger.ts 334 lines1import { keepTopTurns, mergeToolMaps } from './insights'
2import { addBreakdown, CATEGORIES, emptyBreakdown } from './pricing'
3import type { Breakdown, ProjectDay, ToolUse, Totals, TurnRecord } from '../types'
4
5export type SessionEntry = {
6 project?: string
7 days: Record<string, number>
8 cache?: Record<string, Breakdown>
9 subagentCache?: Record<string, Breakdown>
10 turns?: TurnRecord[]
11 tools?: Record<string, Record<string, ToolUse>>
12 updatedAt: number
13}
14
15export type Summary = {
16 today: number
17 week: number
18 month: number
19 weekStart: string
20 days: Record<string, number>
21 cacheDays: Record<string, Breakdown>
22 subagentDays: Record<string, Breakdown>
23 projectDays: Record<string, Record<string, ProjectDay>>
24 turns: TurnRecord[]
25 toolDays: Record<string, Record<string, ToolUse>>
26}
27
28export type MonthView = { key: string; name: string; weeks: (number | null)[][] }
29
30const DAY_MS = 24 * 60 * 60 * 1000
31const RETENTION_MS = 400 * DAY_MS
32const ARCHIVE_AFTER_MS = 60 * DAY_MS
33export const OLDEST_MONTH_OFFSET = -13
34export const UNKNOWN_PROJECT = '(unknown)'
35export const BUDGET_THRESHOLDS = [0.8, 1] as const
36
37const MONTH_NAMES = [
38 'January', 'February', 'March', 'April', 'May', 'June',
39 'July', 'August', 'September', 'October', 'November', 'December',
40]
41
42const pad = (n: number) => String(n).padStart(2, '0')
43
44export function dayKey(ms: number): string {
45 const d = new Date(ms)
46 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
47}
48
49export function weekStartKey(ms: number): string {
50 const d = new Date(ms)
51 const sinceMonday = (d.getDay() + 6) % 7
52 return dayKey(new Date(d.getFullYear(), d.getMonth(), d.getDate() - sinceMonday).getTime())
53}
54
55// The engine's figure is a running total that a /clear may reset to zero, so a
56// drop means a fresh total rather than a refund.
57export function spendSince(previous: number | null, usd: number): number {
58 if (previous === null || usd < previous) return usd
59 return usd - previous
60}
61
62export function addSpend(
63 entry: SessionEntry | undefined,
64 now: number,
65 usd: number,
66 project: string,
67): SessionEntry {
68 const day = dayKey(now)
69 const days = { ...entry?.days }
70 days[day] = (days[day] ?? 0) + usd
71 return { ...entry, project, days, updatedAt: now }
72}
73
74export function addUsage(
75 entry: SessionEntry | undefined,
76 now: number,
77 usage: Breakdown,
78 project: string,
79 isSubagent: boolean,
80): SessionEntry {
81 const day = dayKey(now)
82 const cache = { ...entry?.cache }
83 cache[day] = addBreakdown(cache[day], usage)
84 const subagentCache = { ...entry?.subagentCache }
85 if (isSubagent) subagentCache[day] = addBreakdown(subagentCache[day], usage)
86 return { days: {}, ...entry, project, cache, subagentCache, updatedAt: now }
87}
88
89export function addTurn(entry: SessionEntry | undefined, now: number, turn: TurnRecord): SessionEntry {
90 return { days: {}, ...entry, turns: keepTopTurns([...(entry?.turns ?? []), turn]), updatedAt: now }
91}
92
93export function addTools(entry: SessionEntry | undefined, now: number, tools: Record<string, ToolUse>): SessionEntry {
94 const day = dayKey(now)
95 const all = { ...entry?.tools }
96 all[day] = mergeToolMaps(all[day], tools)
97 return { days: {}, ...entry, tools: all, updatedAt: now }
98}
99
100export function shouldArchive(entry: SessionEntry, now: number): boolean {
101 return now - entry.updatedAt > ARCHIVE_AFTER_MS
102}
103
104function mergeDays<T>(a: Record<string, T> | undefined, b: Record<string, T> | undefined, add: (x: T, y: T) => T) {
105 const merged = { ...a }
106 for (const [day, value] of Object.entries(b ?? {})) {
107 const existing = merged[day]
108 merged[day] = existing === undefined ? value : add(existing, value)
109 }
110 return merged
111}
112
113// Folds a finished session into its project's archive entry, so the store
114// holds one entry per project for old history instead of one per session.
115export function mergeEntries(archive: SessionEntry | undefined, entry: SessionEntry): SessionEntry {
116 return {
117 project: entry.project ?? archive?.project,
118 days: mergeDays(archive?.days, entry.days, (x, y) => x + y),
119 cache: mergeDays(archive?.cache, entry.cache, addBreakdown),
120 subagentCache: mergeDays(archive?.subagentCache, entry.subagentCache, addBreakdown),
121 turns: keepTopTurns([...(archive?.turns ?? []), ...(entry.turns ?? [])]),
122 tools: mergeDays(archive?.tools, entry.tools, mergeToolMaps),
123 updatedAt: Math.max(archive?.updatedAt ?? 0, entry.updatedAt),
124 }
125}
126
127// Drops the days past retention; undefined once nothing is left.
128export function pruneDays(entry: SessionEntry, now: number): SessionEntry | undefined {
129 const cutoff = dayKey(now - RETENTION_MS)
130 const keep = <T>(days: Record<string, T> | undefined) =>
131 Object.fromEntries(Object.entries(days ?? {}).filter(([day]) => day >= cutoff))
132 const pruned = {
133 ...entry,
134 days: keep(entry.days),
135 cache: keep(entry.cache),
136 subagentCache: keep(entry.subagentCache),
137 tools: keep(entry.tools),
138 turns: (entry.turns ?? []).filter(turn => dayKey(turn.at) >= cutoff),
139 }
140 const isEmpty =
141 [pruned.days, pruned.cache, pruned.subagentCache, pruned.tools].every(d => Object.keys(d).length === 0) &&
142 pruned.turns.length === 0
143 return isEmpty ? undefined : pruned
144}
145
146// Adds one request's usage to the drawn totals in place of a full store scan,
147// which would run on every model response.
148export function applyUsage(t: Totals, now: number, usage: Breakdown, project: string, isSubagent: boolean): Totals {
149 const day = dayKey(now)
150 const projectDays = { ...t.projectDays[project] }
151 const projectDay = projectDays[day] ?? { usd: 0, cacheWriteUsd: 0 }
152 projectDays[day] = { ...projectDay, cacheWriteUsd: projectDay.cacheWriteUsd + usage.usd.cacheWrite }
153 return {
154 ...t,
155 cacheDays: { ...t.cacheDays, [day]: addBreakdown(t.cacheDays[day], usage) },
156 subagentDays: isSubagent ? { ...t.subagentDays, [day]: addBreakdown(t.subagentDays[day], usage) } : t.subagentDays,
157 projectDays: { ...t.projectDays, [project]: projectDays },
158 }
159}
160
161export function applyTurn(t: Totals, turn: TurnRecord): Totals {
162 return { ...t, turns: keepTopTurns([...t.turns, turn]) }
163}
164
165export function applyTools(t: Totals, now: number, tools: Record<string, ToolUse>): Totals {
166 const day = dayKey(now)
167 return { ...t, toolDays: { ...t.toolDays, [day]: mergeToolMaps(t.toolDays[day], tools) } }
168}
169
170export function summarize(entries: readonly SessionEntry[], now: number): Summary {
171 const today = dayKey(now)
172 const weekStart = weekStartKey(now)
173 const month = today.slice(0, 7)
174 const summary: Summary = {
175 today: 0,
176 week: 0,
177 month: 0,
178 weekStart,
179 days: {},
180 cacheDays: {},
181 subagentDays: {},
182 projectDays: {},
183 turns: [],
184 toolDays: {},
185 }
186 const turns: TurnRecord[] = []
187
188 for (const entry of entries) {
189 const projectDays = (summary.projectDays[entry.project ?? UNKNOWN_PROJECT] ??= {})
190 const projectDay = (day: string) => (projectDays[day] ??= { usd: 0, cacheWriteUsd: 0 })
191 for (const [day, usd] of Object.entries(entry.days)) {
192 if (day > today) continue
193 if (day === today) summary.today += usd
194 if (day >= weekStart) summary.week += usd
195 if (day.startsWith(month)) summary.month += usd
196 summary.days[day] = (summary.days[day] ?? 0) + usd
197 projectDay(day).usd += usd
198 }
199 for (const [day, usage] of Object.entries(entry.cache ?? {})) {
200 summary.cacheDays[day] = addBreakdown(summary.cacheDays[day], usage)
201 projectDay(day).cacheWriteUsd += usage.usd.cacheWrite
202 }
203 for (const [day, usage] of Object.entries(entry.subagentCache ?? {})) {
204 summary.subagentDays[day] = addBreakdown(summary.subagentDays[day], usage)
205 }
206 for (const [day, tools] of Object.entries(entry.tools ?? {})) {
207 summary.toolDays[day] = mergeToolMaps(summary.toolDays[day], tools)
208 }
209 turns.push(...(entry.turns ?? []))
210 }
211 summary.turns = keepTopTurns(turns)
212 return summary
213}
214
215export function formatUsd(usd: number): string {
216 return `$${usd.toFixed(2)}`
217}
218
219export function monthView(now: number, offset: number): MonthView {
220 const today = new Date(now)
221 const first = new Date(today.getFullYear(), today.getMonth() + offset, 1)
222 const year = first.getFullYear()
223 const month = first.getMonth()
224 const length = new Date(year, month + 1, 0).getDate()
225 const cells: (number | null)[] = Array((first.getDay() + 6) % 7).fill(null)
226 for (let day = 1; day <= length; day++) cells.push(day)
227 while (cells.length % 7 !== 0) cells.push(null)
228
229 const weeks: (number | null)[][] = []
230 for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7))
231
232 const name = `${MONTH_NAMES[month]} ${year}`
233 return { key: `${year}-${pad(month + 1)}`, name, weeks }
234}
235
236export function daysIn(days: Record<string, number>, monthKey: string): [string, number][] {
237 return Object.entries(days)
238 .filter(([day]) => day.startsWith(monthKey))
239 .sort(([a], [b]) => a.localeCompare(b))
240}
241
242export function heatLevel(usd: number, peak: number): 0 | 1 | 2 | 3 | 4 {
243 if (usd <= 0 || peak <= 0) return 0
244 return Math.min(4, Math.max(1, Math.ceil((usd / peak) * 4))) as 1 | 2 | 3 | 4
245}
246
247export function formatTokens(tokens: number): string {
248 if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
249 if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
250 return String(tokens)
251}
252
253export function projectName(root: string): string {
254 return root.split(/[\\/]/).filter(Boolean).at(-1) ?? root
255}
256
257export function sumMonth<T>(days: Record<string, T>, monthKey: string, add: (sum: T | undefined, day: T) => T) {
258 return Object.entries(days)
259 .filter(([day]) => day.startsWith(monthKey))
260 .reduce<T | undefined>((sum, [, value]) => add(sum, value), undefined)
261}
262
263// The highest threshold the spend has reached that has not been announced yet,
264// so 80% and 100% are each said once per period.
265export function budgetAlert(spent: number, limit: number, announced: number): number | null {
266 if (limit <= 0) return null
267 const reached = BUDGET_THRESHOLDS.filter(t => spent >= limit * t).at(-1)
268 return reached !== undefined && reached > announced ? reached : null
269}
270
271export function cacheMinutesLeft(lastRequestAt: number, ttlMs: number, now: number): number {
272 return Math.max(0, Math.ceil((lastRequestAt + ttlMs - now) / 60_000))
273}
274
275const CSV_HEADER = [
276 'date',
277 'project',
278 'total_usd',
279 ...CATEGORIES.map(c => `${c}_tokens`),
280 ...CATEGORIES.map(c => `${c}_usd_estimated`),
281 'subagent_usd_estimated',
282]
283
284const csvField = (value: string | number) => {
285 const text = String(value)
286 return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
287}
288
289type CsvRow = { usd: number; usage: Breakdown; subagent: Breakdown }
290
291export function exportCsv(entries: readonly SessionEntry[]): string {
292 const byDay = new Map<string, Map<string, CsvRow>>()
293 const row = (day: string, project: string) => {
294 const projects = byDay.get(day) ?? new Map<string, CsvRow>()
295 byDay.set(day, projects)
296 const found = projects.get(project) ?? { usd: 0, usage: emptyBreakdown(), subagent: emptyBreakdown() }
297 projects.set(project, found)
298 return found
299 }
300
301 for (const entry of entries) {
302 const project = entry.project ?? UNKNOWN_PROJECT
303 for (const [day, usd] of Object.entries(entry.days)) row(day, project).usd += usd
304 for (const [day, usage] of Object.entries(entry.cache ?? {})) {
305 const r = row(day, project)
306 r.usage = addBreakdown(r.usage, usage)
307 }
308 for (const [day, usage] of Object.entries(entry.subagentCache ?? {})) {
309 const r = row(day, project)
310 r.subagent = addBreakdown(r.subagent, usage)
311 }
312 }
313
314 const lines: string[] = [CSV_HEADER.join(',')]
315 for (const day of [...byDay.keys()].sort()) {
316 const projects = byDay.get(day) ?? new Map<string, CsvRow>()
317 for (const project of [...projects.keys()].sort()) {
318 const r = projects.get(project)
319 if (!r) continue
320 const subagentUsd = CATEGORIES.reduce((sum, c) => sum + r.subagent.usd[c], 0)
321 const fields = [
322 day,
323 project,
324 r.usd.toFixed(4),
325 ...CATEGORIES.map(c => r.usage.tokens[c]),
326 ...CATEGORIES.map(c => r.usage.usd[c].toFixed(4)),
327 subagentUsd.toFixed(4),
328 ]
329 lines.push(fields.map(csvField).join(','))
330 }
331 }
332 return lines.join('\n') + '\n'
333}
334hooks/pricing.ts 175 lines1import type { Breakdown, Category } from '../types'
2
3export type Rates = {
4 input: number
5 output: number
6 cacheRead: number
7 cacheWrite5m: number
8 cacheWrite1h: number
9}
10
11export type CacheTtl = '5m' | '1h'
12
13export type Usage = {
14 input_tokens: number
15 output_tokens: number
16 cache_read_input_tokens: number
17 cache_creation_input_tokens: number
18}
19
20export const CATEGORIES: readonly Category[] = ['input', 'output', 'cacheRead', 'cacheWrite']
21
22const listRates = (input: number, output: number, cacheRead: number): Rates => ({
23 input,
24 output,
25 cacheRead,
26 cacheWrite5m: input * 1.25,
27 cacheWrite1h: input * 2,
28})
29
30// Anthropic list prices in USD per million tokens. Models without a price in
31// the bundled reference (Opus 4.5, Opus 4.1, Sonnet 4.5) are left out rather
32// than guessed: their tokens are counted as unpriced until the rates setting
33// names them.
34export const LIST_RATES: readonly [string, Rates][] = [
35 ['mythos-5-1', listRates(10, 50, 0.25)],
36 ['mythos-5', listRates(10, 50, 1)],
37 ['fable-5-1', listRates(10, 50, 0.25)],
38 ['fable-5', listRates(10, 50, 1)],
39 ['opus-5-5', listRates(4, 20, 0.2)],
40 ['opus-5', listRates(5, 25, 0.5)],
41 ['opus-4-8', listRates(5, 25, 0.5)],
42 ['opus-4-7', listRates(5, 25, 0.5)],
43 ['opus-4-6', listRates(5, 25, 0.5)],
44 ['sonnet-5-5', listRates(2, 10, 0.2)],
45 ['sonnet-5', listRates(2, 10, 0.2)],
46 ['sonnet-4-6', listRates(3, 15, 0.3)],
47 ['haiku-4-5', listRates(1, 5, 0.1)],
48]
49
50const RATE_FIELDS: readonly (keyof Rates)[] = ['input', 'output', 'cacheRead', 'cacheWrite5m', 'cacheWrite1h']
51
52export type RateTable = { rates: readonly [string, Rates][]; error?: string }
53
54const isRecord = (value: unknown): value is Record<string, unknown> =>
55 typeof value === 'object' && value !== null && !Array.isArray(value)
56
57const isRate = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0
58
59// Overrides are per family and may name only some fields, so an AWS price list
60// can be pasted in for the models actually used. A family that has no list
61// price must give input, output and cacheRead; anything malformed is skipped
62// and named in the error rather than failing the mod.
63export function rateTable(overridesJson: string): RateTable {
64 if (overridesJson.trim() === '') return { rates: LIST_RATES }
65
66 let overrides: unknown
67 try {
68 overrides = JSON.parse(overridesJson)
69 } catch {
70 return { rates: LIST_RATES, error: 'the rates setting is not valid JSON; list prices are used' }
71 }
72 if (!isRecord(overrides)) {
73 return { rates: LIST_RATES, error: 'the rates setting must be a JSON object; list prices are used' }
74 }
75
76 const merged = new Map(LIST_RATES)
77 const skipped: string[] = []
78 for (const [family, value] of Object.entries(overrides)) {
79 if (!isRecord(value)) {
80 skipped.push(family)
81 continue
82 }
83 const partial: Partial<Rates> = {}
84 for (const field of RATE_FIELDS) {
85 if (isRate(value[field])) partial[field] = value[field]
86 }
87 const base = merged.get(family)
88 if (base) {
89 merged.set(family, { ...base, ...partial })
90 } else if (partial.input !== undefined && partial.output !== undefined && partial.cacheRead !== undefined) {
91 merged.set(family, { ...listRates(partial.input, partial.output, partial.cacheRead), ...partial })
92 } else {
93 skipped.push(family)
94 }
95 }
96 const error = skipped.length > 0 ? `rates for ${skipped.join(', ')} were skipped as incomplete or malformed` : undefined
97 return { rates: [...merged.entries()], error }
98}
99
100// A family matches where the id continues with something other than another
101// version number, so "claude-opus-5" does not claim "claude-opus-5-5" while a
102// date ("-20250929") or a Bedrock suffix ("-v1:0") still matches.
103function matchesFamily(model: string, family: string): boolean {
104 let at = model.indexOf(family)
105 while (at !== -1) {
106 const rest = model.slice(at + family.length)
107 if (!/^(\d|-\d{1,2}(?!\d))/.test(rest)) return true
108 at = model.indexOf(family, at + 1)
109 }
110 return false
111}
112
113export function ratesFor(table: RateTable, model: string): Rates | undefined {
114 let best: [string, Rates] | undefined
115 for (const entry of table.rates) {
116 if (matchesFamily(model, entry[0]) && (best === undefined || entry[0].length > best[0].length)) best = entry
117 }
118 return best?.[1]
119}
120
121export function writeRate(rates: Rates, ttl: CacheTtl): number {
122 return ttl === '1h' ? rates.cacheWrite1h : rates.cacheWrite5m
123}
124
125export function emptyBreakdown(): Breakdown {
126 return {
127 tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
128 usd: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
129 unpricedTokens: 0,
130 }
131}
132
133export function priceUsage(usage: Usage, rates: Rates | undefined, ttl: CacheTtl): Breakdown {
134 const tokens = {
135 input: usage.input_tokens,
136 output: usage.output_tokens,
137 cacheRead: usage.cache_read_input_tokens,
138 cacheWrite: usage.cache_creation_input_tokens,
139 }
140 if (rates === undefined) {
141 const unpricedTokens = CATEGORIES.reduce((sum, c) => sum + tokens[c], 0)
142 return { tokens, usd: emptyBreakdown().usd, unpricedTokens }
143 }
144 const perToken = (rate: number) => rate / 1_000_000
145 return {
146 tokens,
147 usd: {
148 input: tokens.input * perToken(rates.input),
149 output: tokens.output * perToken(rates.output),
150 cacheRead: tokens.cacheRead * perToken(rates.cacheRead),
151 cacheWrite: tokens.cacheWrite * perToken(writeRate(rates, ttl)),
152 },
153 unpricedTokens: 0,
154 }
155}
156
157export function addBreakdown(a: Breakdown | undefined, b: Breakdown): Breakdown {
158 const sum = emptyBreakdown()
159 for (const c of CATEGORIES) {
160 sum.tokens[c] = (a?.tokens[c] ?? 0) + b.tokens[c]
161 sum.usd[c] = (a?.usd[c] ?? 0) + b.usd[c]
162 }
163 sum.unpricedTokens = (a?.unpricedTokens ?? 0) + (b.unpricedTokens ?? 0)
164 return sum
165}
166
167export function hitRate(b: Breakdown): number {
168 const prompt = b.tokens.input + b.tokens.cacheRead + b.tokens.cacheWrite
169 return prompt === 0 ? 0 : b.tokens.cacheRead / prompt
170}
171
172export function totalUsd(b: Breakdown): number {
173 return CATEGORIES.reduce((sum, c) => sum + b.usd[c], 0)
174}
175types/index.d.ts 69 lines1export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Category = 'input' | 'output' | 'cacheRead' | 'cacheWrite'
4
5export type Breakdown = {
6 tokens: Record<Category, number>
7 usd: Record<Category, number>
8 // Tokens from models with no rate; absent in entries written before 0.4.0.
9 unpricedTokens?: number
10}
11
12export type ProjectDay = { usd: number; cacheWriteUsd: number }
13
14// One prompt's cost, its subagents included, estimated from its requests.
15export type TurnRecord = {
16 at: number
17 prompt: string
18 project: string
19 steps: number
20 subagentSteps: number
21 usage: Breakdown
22 subagentUsage: Breakdown
23}
24
25export type ActiveTurn = Omit<TurnRecord, 'project'> & { turnId: string }
26
27// What one tool's results cost while they sat in context: approximate tokens,
28// the cache write when first sent, and every cached re-read after that.
29export type ToolUse = { calls: number; tokens: number; writeUsd: number; rereadUsd: number }
30
31// A loop's tool results by tool, in approximate tokens: pending until the next
32// request sends them, resident once cached.
33export type LoopContext = { pending: Record<string, number>; resident: Record<string, number> }
34
35export type Totals = {
36 session: number
37 today: number
38 week: number
39 month: number
40 weekStart: string
41 days: Record<string, number>
42 cacheDays: Record<string, Breakdown>
43 subagentDays: Record<string, Breakdown>
44 projectDays: Record<string, Record<string, ProjectDay>>
45 turns: TurnRecord[]
46 toolDays: Record<string, Record<string, ToolUse>>
47 rateLimits: RateLimit[]
48}
49
50export type Context = { tokens: number; model: string; at: number }
51
52export type Shading = 'total' | 'cacheWrite'
53
54declare module 'claude-code' {
55 interface PluginState {
56 'cost-ledger': {
57 totals: Totals | null
58 lastUsd: number | null
59 monthOffset: number
60 context: Context | null
61 shadeBy: Shading
62 tick: number
63 activeTurn: ActiveTurn | null
64 turnPrompts: Record<string, { text: string; at: number }>
65 loops: Record<string, LoopContext>
66 }
67 }
68}
69