What your Claude Code session would cost on the API, as a live band and a receipt.

A Claude Code cost tracker: what your Pro or Max subscription would cost at API prices, live above your prompt.

One line, always on, updated after every model request while Claude works:
| ◉ $4.46 session | this session at API prices. The dot turns orange while Claude is working |
| ▮▮▮▮▮▮ | this session's model mix: orange Opus, purple Fable, blue Sonnet, green Haiku |
| $16.45 today | every Claude Code session on this machine today |
| $315 7d | your current weekly limit window, from Claude Code's own reset time |
| $438 since Sep 28 | your billing cycle so far, from the day your plan renews |
| 2.2x Max 20x | how many times over the cycle has paid for your plan |
| ▂▅█▃▁▆ | the last 14 days, colored from quiet to busy |
| Anthropic is picking up the tab… | a running commentary, with a new line every ten minutes |
It reads your history on install, so the numbers start with your past month instead of $0.
Click ≡ invoice on the bar for the full bill. Tabs switch it between this session, your 7-day limit window, your billing cycle and all time, each broken down by model and token type, with a 60-day chart.
<img src="docs/invoice-cycle.jpg" alt="Claude Code cost invoice for the billing cycle, by model" width="45%"> <img src="docs/invoice-7d.jpg" alt="Claude Code cost invoice for the weekly limit window" width="45%">
In a Claude Code terminal session:
/plugin install what-would-it-cost --marketplace Faouzielbakri/what-would-it-cost
Answer y to add the marketplace, then pick the user scope so it runs in every session. On first run it prices every transcript already on your disk (~/.claude/projects) and tells you the total when it's done.
/cost total next to the mod's.Everything stays on your machine.
What it sends: nothing. It makes no network requests and has no telemetry. The numbers are kept in Claude Code's own plugin storage as per-day token totals per model.
What it reads:
| What | Why |
|---|---|
Claude Code's transcripts in ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects) | To price your history on first run. It keeps only each response's model, timestamp and token counts; message text is never stored. |
~/.claude.json, two fields only: oauthAccount.*RateLimitTier and oauthAccount.subscriptionCreatedAt | To know your plan (Pro, Max 5x, Max 20x) and the day it renews. Skipped entirely when you set Your plan and Billing day yourself in /config. |
The HOME and CLAUDE_CONFIG_DIR environment variables | To find the two locations above. |
| Claude Code's own usage figures | For the /cost cross-check and your weekly limit's reset time. |
Programs it runs:
| Command | When and why |
|---|---|
dd if=<transcript> bs=1048576 skip=<n> count=3 | Only for transcripts over 4 MB, which is the most a mod can read in one go. It reads them in 3 MB chunks, read-only. |
claude auth status | Only when the plan can't be read from ~/.claude.json, to tell Pro, Max and API-key accounts apart. |
It reads your exact plan (Pro, Max 5x or Max 20x) from the rate-limit tier in Claude Code's own config (~/.claude.json), falling back to claude auth status. To set it yourself, go to /config → what-would-it-cost › Your plan and pick pro, max-5x, max-20x or api.
The multiplier runs over your billing cycle, not the calendar month: if you subscribed on the 15th, the cycle runs from the 15th to the 15th. The renewal day is read from when your subscription began; set Billing day in /config if yours differs (say, after a plan change).
If you're on an API key, the bill is real money. The band shows anyway.
List prices per million tokens, from Anthropic's pricing page (October 2026):
| Model | Input | Output | Cache read | Cache write (5m / 1h) |
|---|---|---|---|---|
| Fable 5.1 | $10 | $50 | $0.25 | $12.50 / $20 |
| Fable 5 | $10 | $50 | $1.00 | $12.50 / $20 |
| Opus 5.5 | $4 | $20 | $0.20 | $5 / $8 |
| Opus 5 / 4.5–4.8 | $5 | $25 | $0.50 | $6.25 / $10 |
| Sonnet 5 / 5.5 | $2 | $10 | $0.20 | $2.50 / $4 |
| Sonnet 4.x | $3 | $15 | $0.30 | $3.75 / $6 |
| Haiku 4.5 | $1 | $5 | $0.10 | $1.25 / $2 |
Caveats:
cleanupPeriodDays), so history only goes back as far as your transcripts do.?. PRs that add new models to hooks/pricing.ts are welcome.claude --plugin-dir ./what-would-it-cost # run it from a checkout; saving a file hot-reloads it
claude plugin validate ./what-would-it-cost
claude plugin test ./what-would-it-cost
MIT. Fork it, remix it, ship it. If you build on it, a link back to Faouzi El Bakri is appreciated.
hooks/register.tsx 589 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { History, Plan, Tab, Tally } from '../types'
4import { addTranscript, emptyTotals } from './history'
5import type { DayModels } from './history'
6import {
7 BARS,
8 EMPTY,
9 PLANS,
10 addDayModels,
11 addUsage,
12 billOf,
13 billingDayFromConfig,
14 colorOf,
15 cycleEnd,
16 cycleStart,
17 dayKey,
18 dayTotals,
19 heat,
20 modelsBetween,
21 planFromAuth,
22 planFromConfig,
23 priceOf,
24 sound,
25 sparkline,
26 sumOf,
27 sumSince,
28 usd,
29} from './pricing'
30import type { Usage } from './pricing'
31import { cacheQuip, emptyQuip, historyToast, readingQuip, verdict } from './quips'
32
33const PANE = 'what-would-it-cost'
34const DAYS_KEPT = 400
35const DAY = 86_400_000
36const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
37const STALE_KEYS = ['cutoff', 'history', 'days', 'liveDays', 'historyDays', 'liveModels', 'historyModels']
38const READ_LIMIT = 4_000_000
39const CHUNK_MB = 3
40
41const ORANGE = '#ff8c5a'
42const GOLD = '#fcc419'
43const TEAL = '#3bc9db'
44const AMBER = '#ffa94d'
45const GREEN = '#51cf66'
46const GREY = '#868e96'
47
48// The session's drawn values (see types/index.d.ts).
49const MODELS = { plugin: 'what-would-it-cost', key: 'models' } as const
50const DAY_MODELS = { plugin: 'what-would-it-cost', key: 'dayModels' } as const
51const PLAN = { plugin: 'what-would-it-cost', key: 'plan' } as const
52const BILLING_DAY = { plugin: 'what-would-it-cost', key: 'billingDay' } as const
53const WEEK_START = { plugin: 'what-would-it-cost', key: 'weekStart' } as const
54const LEDGER = { plugin: 'what-would-it-cost', key: 'ledgerUsd' } as const
55const HISTORY = { plugin: 'what-would-it-cost', key: 'history' } as const
56const TAB = { plugin: 'what-would-it-cost', key: 'tab' } as const
57
58type Models = Record<string, Tally>
59
60const lastDays = (byDay: Record<string, number>, now: number, count: number) =>
61 Array.from({ length: count }, (_, i) => byDay[dayKey(now - (count - 1 - i) * DAY)] ?? 0)
62
63const short = (ms: number) => `${MONTHS[new Date(ms).getMonth()]} ${new Date(ms).getDate()}`
64
65/** A bar chart `rows` tall, one column per value, colored by how busy the day was (a Raster's cells). */
66const chartCells = (values: readonly number[], rows: number): string => {
67 const top = Math.max(...values, 0)
68 const words = new Uint32Array(values.length * rows * 3)
69 for (let row = 0; row < rows; row++) {
70 const level = rows - 1 - row
71 values.forEach((v, col) => {
72 const eighths = top > 0 ? Math.round((v / top) * rows * 8) : 0
73 const fill = Math.max(0, Math.min(8, eighths - level * 8))
74 const isFloor = level === 0 && v <= 0
75 const at = (row * values.length + col) * 3
76 words[at] = isFloor ? 0x00b7 : fill === 0 ? 0x20 : BARS.charCodeAt(fill - 1)
77 words[at + 1] = isFloor ? 0x495057 : heat(top > 0 ? v / top : 0)
78 words[at + 2] = 0x01000000
79 })
80 }
81 return (new Uint8Array(words.buffer) as unknown as { toBase64: () => string }).toBase64()
82}
83
84/** Each model's share of `width` cells, the biggest first, every model with a cost getting at least one. */
85const shares = (byModel: Models, width: number) => {
86 const rows = Object.entries(byModel)
87 .map(([model, t]) => ({ model, cost: billOf(model, t).total, t: sound(t) }))
88 .filter(row => row.cost > 0)
89 .sort((a, b) => b.cost - a.cost)
90 const total = rows.reduce((sum, row) => sum + row.cost, 0)
91 let left = width
92 return rows.map((row, i) => {
93 const cells =
94 i === rows.length - 1
95 ? left
96 : Math.max(1, Math.min(left - (rows.length - 1 - i), Math.round((row.cost / total) * width)))
97 left -= cells
98 return { ...row, cells: Math.max(0, cells), share: total > 0 ? row.cost / total : 0 }
99 })
100}
101
102/** Every value the band and the invoice draw from; read while drawing, so a change redraws them. */
103async function snapshot($: EngineInterface) {
104 const [models, dayModels, plan, billingDay, weekStart, ledger, history, tab] = await Promise.all([
105 $.state.get(MODELS),
106 $.state.get(DAY_MODELS),
107 $.state.get(PLAN),
108 $.state.get(BILLING_DAY),
109 $.state.get(WEEK_START),
110 $.state.get(LEDGER),
111 $.state.get(HISTORY),
112 $.state.get(TAB),
113 ])
114 return {
115 models: models.value ?? {},
116 dayModels: dayModels.value ?? {},
117 plan: plan.value ?? null,
118 billingDay: billingDay.value ?? null,
119 weekStart: weekStart.value ?? null,
120 ledger: ledger.value ?? null,
121 history: history.value ?? null,
122 tab: tab.value ?? 'cycle',
123 }
124}
125
126/** A day-by-model map kept in the store under `key`, empty when absent. */
127async function storedDays($: EngineInterface, key: string): Promise<DayModels> {
128 const value = await $.store.get(key)
129 return (value as DayModels | undefined) ?? {}
130}
131
132/** Where Claude Code keeps its config: $CLAUDE_CONFIG_DIR, else ~/.claude. */
133async function configDir($: EngineInterface): Promise<string> {
134 const custom = await $.env.get('CLAUDE_CONFIG_DIR')
135 const home = await $.env.get('HOME')
136 return custom ?? `${home ?? '~'}/.claude`
137}
138
139/** Re-reads history and live tallies from the store into the drawn state. */
140async function refresh($: EngineInterface) {
141 const past = await storedDays($, 'v2.historyDayModels')
142 const live = await storedDays($, 'v2.liveDayModels')
143 await $.state.set(DAY_MODELS, addDayModels(past, live))
144}
145
146/** The engine's own /cost total (a cross-check) and the 7-day limit window, from the status line's figures. */
147async function readUsage($: EngineInterface) {
148 let usage
149 try {
150 usage = await $.session.usage()
151 } catch {
152 return
153 }
154 await $.state.set(LEDGER, usage.cost?.usd ?? null)
155 const resets = Date.parse(usage.rateLimits.find(limit => limit.kind === 'seven_day')?.resetsAt ?? '')
156 if (!Number.isNaN(resets)) await $.state.set(WEEK_START, resets - 7 * DAY)
157}
158
159/** One model request's usage onto the session and the day, and the engine's figures re-read. */
160async function account($: EngineInterface, usage: Usage & { model: string }) {
161 const model = usage.model || 'unknown'
162 const today = dayKey(await $.clock.now())
163
164 const session = (await $.state.get(MODELS)).value ?? {}
165 await $.state.set(MODELS, { ...session, [model]: addUsage(session[model] ?? EMPTY, usage) })
166
167 // Re-read the store so sessions running side by side add up instead of overwriting.
168 const live = await storedDays($, 'v2.liveDayModels')
169 const kept: DayModels = Object.fromEntries(
170 Object.keys(live)
171 .sort()
172 .slice(-DAYS_KEPT)
173 .map(day => [day, live[day] ?? {}]),
174 )
175 const byModel = (kept[today] ??= {})
176 byModel[model] = addUsage(byModel[model] ?? EMPTY, usage)
177 await $.store.set('v2.liveDayModels', kept)
178 await refresh($)
179 await readUsage($)
180}
181
182/** Reads one transcript into `text` pieces that end on whole lines; past the read limit, in `dd` chunks. */
183async function* linesOf($: EngineInterface, path: string, size: number): AsyncGenerator<string> {
184 if (size <= READ_LIMIT) {
185 yield String(await $.fs.read(path))
186 return
187 }
188 let carry = ''
189 for (let skip = 0; skip * 1_048_576 < size; skip += CHUNK_MB) {
190 const ran = await $.process.run(['dd', `if=${path}`, 'bs=1048576', `skip=${skip}`, `count=${CHUNK_MB}`], {
191 timeoutMs: 30_000,
192 })
193 if (ran.exitCode !== 0) return
194 const text = carry + ran.stdout
195 const end = text.lastIndexOf('\n')
196 carry = end === -1 ? text : text.slice(end + 1)
197 if (end !== -1) yield text.slice(0, end)
198 }
199 if (carry) yield carry
200}
201
202/** Every transcript under `dir`: each project's sessions and their subagents. */
203async function transcriptsIn($: EngineInterface, dir: string, depth: number): Promise<{ path: string; size: number }[]> {
204 let entries
205 try {
206 entries = await $.fs.list(dir)
207 } catch {
208 return []
209 }
210 const files: { path: string; size: number }[] = []
211 for (const entry of entries) {
212 const path = `${dir}/${entry.name}`
213 if (entry.kind === 'file' && entry.name.endsWith('.jsonl')) files.push({ path, size: entry.size })
214 else if (entry.kind === 'dir' && depth < 3) files.push(...(await transcriptsIn($, path, depth + 1)))
215 }
216 return files
217}
218
219/** First run: price every transcript already on disk, so the band has a past from the start. */
220async function readPast($: EngineInterface, cutoff: number) {
221 const reading = (filesDone: number, files: number): History => ({ status: 'reading', filesDone, files, requests: 0 })
222 await $.state.set(HISTORY, reading(0, 0))
223 try {
224 const files = await transcriptsIn($, `${await configDir($)}/projects`, 0)
225 const totals = emptyTotals()
226 const seen = new Set<string>()
227 let done = 0
228 for (const file of files) {
229 try {
230 for await (const text of linesOf($, file.path, file.size)) addTranscript(text, totals, seen, cutoff)
231 } catch {
232 // One unreadable transcript costs only itself.
233 }
234 done += 1
235 if (done % 5 === 0 || done === files.length) await $.state.set(HISTORY, reading(done, files.length))
236 }
237
238 const finished: History = { status: 'done', filesDone: 0, files: 0, requests: totals.requests }
239 await $.store.set('v2.historyDayModels', totals.dayModels)
240 await $.store.set('v2.history', finished)
241 await $.state.set(HISTORY, finished)
242 await refresh($)
243 const total = Object.values(dayTotals(totals.dayModels)).reduce((a, b) => a + b, 0)
244 $.ui.toast(historyToast(totals.requests, total))
245 } catch {
246 await $.state.set(HISTORY, { status: 'failed', filesDone: 0, files: 0, requests: 0 })
247 }
248}
249
250/** The plan and its renewal day: Claude Code's own config names both; else `claude auth status` names the plan. */
251async function findAccount($: EngineInterface): Promise<{ plan: Plan | null; day: number | null }> {
252 const custom = await $.env.get('CLAUDE_CONFIG_DIR')
253 const home = await $.env.get('HOME')
254 const path = custom ? `${custom}/.claude.json` : `${home ?? '~'}/.claude.json`
255 let text = ''
256 try {
257 text = String(await $.fs.read(path))
258 } catch {
259 // No config file: the plan comes from `claude auth status` below.
260 }
261 const day = billingDayFromConfig(text)
262 const fromConfig = planFromConfig(text)
263 if (fromConfig) return { plan: fromConfig, day }
264 try {
265 const ran = await $.process.run(['claude', 'auth', 'status'], { timeoutMs: 15_000 })
266 return { plan: ran.exitCode === 0 ? planFromAuth(ran.stdout) : null, day }
267 } catch {
268 return { plan: null, day }
269 }
270}
271
272/** Fills in the plan and renewal day the person did not set themselves. */
273async function settleAccount($: EngineInterface, isPlanAuto: boolean, isDayAuto: boolean) {
274 await readUsage($)
275 // Both set in /config: nothing to look up, and ~/.claude.json is never opened.
276 if (!isPlanAuto && !isDayAuto) return
277 const found = await findAccount($)
278 if (isPlanAuto) await $.state.set(PLAN, found.plan)
279 if (isDayAuto) await $.state.set(BILLING_DAY, found.day ?? 1)
280}
281
282async function openInvoice($: EngineInterface) {
283 await $.ui.open({ id: PANE, title: 'Invoice' })
284}
285
286const TABS: ReadonlyArray<readonly [Tab, string]> = [
287 ['session', 'Session'],
288 ['week', '7 days'],
289 ['cycle', 'Cycle'],
290 ['all', 'All time'],
291]
292
293export const register: Register = (on, options) => {
294 on('session.start', async ($, e, next) => {
295 // Responses before the first run belong to history; from then on, to the live counter.
296 let cutoff = (await $.store.get('v2.cutoff')) as number | undefined
297 if (cutoff === undefined) {
298 cutoff = await $.clock.now()
299 await $.store.set('v2.cutoff', cutoff)
300 for (const key of STALE_KEYS) await $.store.delete(key)
301 }
302 await refresh($)
303
304 const past = (await $.store.get('v2.history')) as History | undefined
305 const from = cutoff
306 if (past?.status === 'done') await $.state.set(HISTORY, past)
307 else $.clock.after(100, () => void readPast($, from))
308
309 // Other Claude Code sessions add to the same store: pick their requests up as they land.
310 $.clock.every(30_000, () => void refresh($))
311
312 const chosen = String(options.plan ?? 'auto')
313 const day = Math.round(Number(options.billingDay ?? 0))
314 const isDayAuto = !(day >= 1 && day <= 31)
315 if (chosen !== 'auto') await $.state.set(PLAN, PLANS[chosen] ?? null)
316 if (!isDayAuto) await $.state.set(BILLING_DAY, day)
317 $.clock.after(100, () => void settleAccount($, chosen === 'auto', isDayAuto))
318
319 return next(e)
320 })
321
322 // Every model request, subagents' included, the moment it ends: the band moves while Claude works.
323 on('turn.step', async function* ($, e, next) {
324 const step = yield* next(e)
325 if (step.usage) await account($, step.usage)
326
327 return step
328 })
329
330 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
331 if (e.props.hasSurvey) return next(e)
332
333 const { Box, Button, Text } = $.ui.resolve(e)
334 const now = await $.clock.now()
335 const drawn = await snapshot($)
336 const sessionModels = drawn.models
337 const byDay = dayTotals(drawn.dayModels)
338 const p = drawn.plan
339 const past = drawn.history
340 const start = cycleStart(now, drawn.billingDay ?? 1)
341 const cycle = sumSince(byDay, start, now)
342 const week = sumSince(byDay, drawn.weekStart ?? now - 6 * DAY, now)
343 const ratio = p?.usd ? cycle / p.usd : null
344 const fortnight = lastDays(byDay, now, 14)
345 const mix = shares(sessionModels, 6)
346
347 let trend
348 if (e.surface === 'terminal') {
349 const { Raster } = $.ui.resolve(e)
350 trend = <Raster key="trend" columns={fortnight.length} rows={1} cells={chartCells(fortnight, 1)} />
351 } else {
352 trend = <Text color={TEAL}>{sparkline(fortnight)}</Text>
353 }
354
355 return (
356 <Box key="band" flexDirection="row">
357 <Text wrap="truncate-end">
358 <Text color={e.props.isWorking ? ORANGE : GREY} bold>
359 {e.props.isWorking ? '◉ ' : '◎ '}
360 </Text>
361 <Text color={ORANGE} bold>
362 {usd(sumOf(sessionModels))}
363 </Text>
364 <Text dimColor> session </Text>
365 {mix.map(row => (
366 <Text color={colorOf(row.model)}>{'▮'.repeat(row.cells)}</Text>
367 ))}
368 <Text dimColor>{mix.length > 0 ? ' │ ' : '│ '}</Text>
369 <Text color={GOLD} bold>
370 {usd(byDay[dayKey(now)] ?? 0)}
371 </Text>
372 <Text dimColor> today │ </Text>
373 <Text color={AMBER} bold>
374 {usd(week)}
375 </Text>
376 <Text dimColor> 7d │ </Text>
377 <Text color={TEAL} bold>
378 {usd(cycle)}
379 </Text>
380 <Text dimColor> since {short(start)} </Text>
381 {ratio !== null && (
382 <Text color={ratio >= 1 ? GREEN : GOLD} bold>
383 {ratio.toFixed(ratio < 10 ? 1 : 0)}x{' '}
384 </Text>
385 )}
386 {p?.usd != null && <Text dimColor>{p.label} </Text>}
387 <Text dimColor>│ </Text>
388 </Text>
389 {trend}
390 <Text> </Text>
391 <Button key="invoice" label="≡ invoice" plain hover={{ color: ORANGE }} onPress={() => void openInvoice($)} />
392 <Box flexShrink={1}>
393 {past?.status === 'reading' ? (
394 <Text color={TEAL} wrap="truncate-end">
395 {' '}⟳ {readingQuip(now)} {past.filesDone}/{past.files || '…'}
396 </Text>
397 ) : (
398 <Text dimColor italic wrap="truncate-end">
399 {' '}
400 {verdict(cycle, p, now)}
401 </Text>
402 )}
403 </Box>
404 </Box>
405 )
406 })
407
408 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
409 const { Box, Button, Text } = $.ui.resolve(e)
410 const outer = Math.max(40, Math.min(72, (e.props.bodyColumns ?? 72) - 1))
411 const inner = outer - 4
412 const now = await $.clock.now()
413 const today = dayKey(now)
414 const drawn = await snapshot($)
415 const sessionModels = drawn.models
416 const allDays = drawn.dayModels
417 const byDay = dayTotals(allDays)
418 const p = drawn.plan
419 const ledger = drawn.ledger
420 const past = drawn.history
421 const chosen = drawn.tab
422 const renews = drawn.billingDay ?? 1
423 const start = cycleStart(now, renews)
424 const end = cycleEnd(start, renews)
425 const limitWeek = drawn.weekStart
426 const firstDay = Object.keys(allDays).sort()[0] ?? today
427
428 const scope: Models =
429 chosen === 'session'
430 ? sessionModels
431 : chosen === 'week'
432 ? modelsBetween(allDays, dayKey(limitWeek ?? now - 6 * DAY), today)
433 : chosen === 'cycle'
434 ? modelsBetween(allDays, dayKey(start), today)
435 : modelsBetween(allDays, firstDay, today)
436 const rows = shares(scope, inner)
437 const total = rows.reduce((sum, row) => sum + row.cost, 0)
438 const requests = rows.reduce((sum, row) => sum + row.t.turns, 0)
439 const lines = rows.reduce(
440 (sum, row) => {
441 const bill = billOf(row.model, row.t)
442 return {
443 input: sum.input + bill.input,
444 output: sum.output + bill.output,
445 write: sum.write + bill.cacheWrite,
446 read: sum.read + bill.cacheRead,
447 saved: sum.saved + bill.saved,
448 }
449 },
450 { input: 0, output: 0, write: 0, read: 0, saved: 0 },
451 )
452
453 const elapsed = Math.max(1, Math.ceil((now - start) / DAY))
454 const length = Math.round((end - start) / DAY)
455 const ratio = chosen === 'cycle' && p?.usd ? total / p.usd : null
456 const subtitle =
457 chosen === 'session'
458 ? `this session · ${requests.toLocaleString('en-US')} requests`
459 : chosen === 'week'
460 ? limitWeek === null
461 ? `last 7 days · ${requests.toLocaleString('en-US')} requests`
462 : `weekly limit window · ${short(limitWeek)} → ${short(limitWeek + 7 * DAY)}`
463 : chosen === 'cycle'
464 ? `${short(start)} → ${short(end)} · day ${elapsed} of ${length} · on pace for ${usd((total / elapsed) * length)}`
465 : `since ${short(new Date(`${firstDay}T12:00`).getTime())} · ${requests.toLocaleString('en-US')} requests`
466
467 const span = Math.min(inner, 60)
468 const chartDays = lastDays(byDay, now, span)
469 let chart
470 if (e.surface === 'terminal') {
471 const { Raster } = $.ui.resolve(e)
472 chart = <Raster key="chart" columns={span} rows={4} cells={chartCells(chartDays, 4)} />
473 } else {
474 chart = <Text color={TEAL}>{sparkline(chartDays)}</Text>
475 }
476
477 const split = (label: string, value: number) => (
478 <Box width={Math.floor(inner / 2)} justifyContent="space-between" paddingRight={2}>
479 <Text dimColor>{label}</Text>
480 <Text>{usd(value)}</Text>
481 </Box>
482 )
483
484 return (
485 <Box flexDirection="column">
486 <Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1} width={outer}>
487 <Box justifyContent="space-between">
488 <Text>
489 <Text color={ORANGE} bold>
490 ≡ INVOICE
491 </Text>
492 <Text dimColor> at API prices</Text>
493 </Text>
494 <Text dimColor>{p ? (p.usd ? `${p.label} · $${p.usd}/mo` : p.label) : ''}</Text>
495 </Box>
496 <Text> </Text>
497
498 <Box flexDirection="row" gap={2}>
499 {TABS.map(([id, label]) =>
500 id === chosen ? (
501 <Button key={`tab-${id}`} label={label} variant="primary" onPress={() => void $.state.set(TAB, id)} />
502 ) : (
503 <Button key={`tab-${id}`} label={label} plain dimColor onPress={() => void $.state.set(TAB, id)} />
504 ),
505 )}
506 </Box>
507 <Text> </Text>
508
509 <Box justifyContent="space-between">
510 <Text color={ORANGE} bold>
511 {usd(total)}
512 </Text>
513 {ratio !== null && (
514 <Text color={ratio >= 1 ? GREEN : GOLD} bold>
515 {ratio.toFixed(2)}x your plan
516 </Text>
517 )}
518 </Box>
519 <Text dimColor>{subtitle}</Text>
520 {past?.status === 'reading' && chosen !== 'session' && (
521 <Text color={TEAL}>
522 ⟳ {readingQuip(now)} {past.filesDone}/{past.files || '…'}
523 </Text>
524 )}
525 <Text> </Text>
526
527 {rows.length === 0 ? (
528 <Text dimColor>{emptyQuip(now)}</Text>
529 ) : (
530 <Text>
531 {rows.map(row => (
532 <Text color={colorOf(row.model)}>{'█'.repeat(row.cells)}</Text>
533 ))}
534 </Text>
535 )}
536 {rows.map(row => (
537 <Box key={row.model} justifyContent="space-between">
538 <Text>
539 <Text color={colorOf(row.model)}>● </Text>
540 <Text bold>{priceOf(row.model).label}</Text>
541 <Text dimColor>
542 {' '}
543 {row.t.turns.toLocaleString('en-US')} req
544 </Text>
545 </Text>
546 <Text>
547 <Text dimColor>{Math.round(row.share * 100)}% </Text>
548 <Text bold>{usd(row.cost)}</Text>
549 </Text>
550 </Box>
551 ))}
552 <Text> </Text>
553
554 <Box flexDirection="row">
555 {split('input', lines.input)}
556 {split('output', lines.output)}
557 </Box>
558 <Box flexDirection="row">
559 {split('cache write', lines.write)}
560 {split('cache read', lines.read)}
561 </Box>
562 {lines.saved > 0 && <Text color={GREEN}>{cacheQuip(lines.saved, now)}</Text>}
563 {chosen === 'session' && ledger !== null && (
564 <Text dimColor>Claude Code's own /cost says {usd(ledger)}. We checked.</Text>
565 )}
566 <Text> </Text>
567
568 <Box justifyContent="space-between" width={span}>
569 <Text dimColor>last {span} days</Text>
570 <Text dimColor>peak {usd(Math.max(...chartDays, 0))}</Text>
571 </Box>
572 {chart}
573 <Box justifyContent="space-between" width={span}>
574 <Text dimColor>{short(now - (span - 1) * DAY)}</Text>
575 <Text dimColor>today</Text>
576 </Box>
577 <Text> </Text>
578 <Text italic color={GOLD}>
579 {verdict(sumSince(byDay, start, now), p, now)}
580 </Text>
581 </Box>
582 <Box justifyContent="flex-end" width={outer}>
583 <Button key="close" label="close" role="dismiss" plain dimColor onPress={() => $.ui.close({ id: PANE })} />
584 </Box>
585 </Box>
586 )
587 })
588}
589hooks/history.ts 57 lines1import type { Tally } from '../types'
2import { EMPTY, addUsage, dayKey } from './pricing'
3import type { Usage } from './pricing'
4
5/** Tallies by local day, then by model. */
6export type DayModels = Record<string, Record<string, Tally>>
7
8/** What one pass over the transcripts adds up to. */
9export type Totals = { dayModels: DayModels; requests: number }
10
11export const emptyTotals = (): Totals => ({ dayModels: {}, requests: 0 })
12
13type Row = {
14 type?: string
15 timestamp?: string
16 requestId?: string
17 message?: { id?: string; model?: string; usage?: Partial<Usage> }
18}
19
20/**
21 * Adds every billed model response in `text` (JSONL, whole lines) to `totals`.
22 * A response is written once per content block, so `seen` keeps each one to a single count.
23 * Only responses before `cutoffMs` count: from then on the live counter has them.
24 */
25export const addTranscript = (text: string, totals: Totals, seen: Set<string>, cutoffMs: number) => {
26 for (const line of text.split('\n')) {
27 if (!line.includes('"usage"') || !line.includes('"assistant"')) continue
28 let row: Row
29 try {
30 row = JSON.parse(line) as Row
31 } catch {
32 continue
33 }
34 const message = row.message
35 const u = message?.usage
36 if (row.type !== 'assistant' || !u || !message?.id || !message.model || message.model === '<synthetic>') continue
37
38 const key = `${message.id}:${row.requestId ?? ''}`
39 if (seen.has(key)) continue
40 seen.add(key)
41
42 const ms = Date.parse(row.timestamp ?? '')
43 if (!(ms < cutoffMs)) continue
44
45 const usage: Usage = {
46 input_tokens: u.input_tokens ?? 0,
47 output_tokens: u.output_tokens ?? 0,
48 cache_read_input_tokens: u.cache_read_input_tokens ?? 0,
49 cache_creation_input_tokens: u.cache_creation_input_tokens ?? 0,
50 ...(u.cache_creation ? { cache_creation: u.cache_creation } : {}),
51 }
52 const day = (totals.dayModels[dayKey(ms)] ??= {})
53 day[message.model] = addUsage(day[message.model] ?? EMPTY, usage)
54 totals.requests += 1
55 }
56}
57hooks/pricing.ts 248 lines1import type { Plan, Tally } from '../types'
2
3/** US dollars per million tokens. Cache writes bill at 1.25x input (5-minute) or 2x (1-hour). */
4export type Price = { input: number; output: number; cacheRead: number }
5
6// Anthropic first-party list prices, checked 2026-10. Most specific id first.
7export const PRICES: ReadonlyArray<readonly [RegExp, string, Price]> = [
8 [/fable-5-1|mythos-5-1/, 'Fable 5.1', { input: 10, output: 50, cacheRead: 0.25 }],
9 [/fable-5|mythos-5/, 'Fable 5', { input: 10, output: 50, cacheRead: 1 }],
10 [/opus-5-5/, 'Opus 5.5', { input: 4, output: 20, cacheRead: 0.2 }],
11 [/opus-5/, 'Opus 5', { input: 5, output: 25, cacheRead: 0.5 }],
12 [/opus-4-[5-8]/, 'Opus 4.x', { input: 5, output: 25, cacheRead: 0.5 }],
13 [/opus-4/, 'Opus 4', { input: 15, output: 75, cacheRead: 1.5 }],
14 [/sonnet-5/, 'Sonnet 5.x', { input: 2, output: 10, cacheRead: 0.2 }],
15 [/sonnet-4|sonnet-3-7/, 'Sonnet 4.x', { input: 3, output: 15, cacheRead: 0.3 }],
16 [/haiku-4/, 'Haiku 4.5', { input: 1, output: 5, cacheRead: 0.1 }],
17 [/haiku-3-5/, 'Haiku 3.5', { input: 0.8, output: 4, cacheRead: 0.08 }],
18]
19
20const OPUS_5_5: Price = { input: 4, output: 20, cacheRead: 0.2 }
21
22export const WRITE_5M = 1.25
23export const WRITE_1H = 2
24
25/** The price row for a model id as the API reports it (`claude-opus-5-5[1m]`, a Bedrock id, a dated id). */
26export const priceOf = (model: string): { label: string; price: Price; isKnown: boolean } => {
27 const row = PRICES.find(([pattern]) => pattern.test(model))
28 // An id this table does not know yet is billed as the current Opus, and marked.
29 return row ? { label: row[1], price: row[2], isKnown: true } : { label: model, price: OPUS_5_5, isKnown: false }
30}
31
32/** The model family's color, for bars and labels. */
33export const colorOf = (model: string): string =>
34 /fable|mythos/.test(model)
35 ? '#b197fc'
36 : /opus/.test(model)
37 ? '#ff8c5a'
38 : /sonnet/.test(model)
39 ? '#4dabf7'
40 : /haiku/.test(model)
41 ? '#69db7c'
42 : '#adb5bd'
43
44export type Usage = {
45 input_tokens: number
46 output_tokens: number
47 cache_read_input_tokens: number
48 cache_creation_input_tokens: number
49 /** The write split by cache lifetime, where the record has it (transcripts do; live usage does not). */
50 cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
51}
52
53export const EMPTY: Tally = { input: 0, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0, turns: 0 }
54
55/** A tally as stored, every count a number: one saved by an older version reads as zeros where it lacks a field. */
56export const sound = (t: Partial<Tally> | undefined): Tally => ({
57 input: Number(t?.input) || 0,
58 output: Number(t?.output) || 0,
59 cacheRead: Number(t?.cacheRead) || 0,
60 cacheWrite5m: Number(t?.cacheWrite5m) || 0,
61 cacheWrite1h: (Number(t?.cacheWrite1h) || 0) + (Number((t as { cacheWrite?: number } | undefined)?.cacheWrite) || 0),
62 turns: Number(t?.turns) || 0,
63})
64
65export const addUsage = (stored: Tally, usage: Usage): Tally => {
66 const tally = sound(stored)
67 const split = usage.cache_creation
68 // Claude Code writes its prompt cache with the 1-hour lifetime, so an unsplit write counts as one.
69 const write5m = split ? (split.ephemeral_5m_input_tokens ?? 0) : 0
70 const write1h = split ? (split.ephemeral_1h_input_tokens ?? 0) : usage.cache_creation_input_tokens
71 return {
72 input: tally.input + usage.input_tokens,
73 output: tally.output + usage.output_tokens,
74 cacheRead: tally.cacheRead + usage.cache_read_input_tokens,
75 cacheWrite5m: tally.cacheWrite5m + write5m,
76 cacheWrite1h: tally.cacheWrite1h + write1h,
77 turns: tally.turns + 1,
78 }
79}
80
81export const addTallies = (x: Tally, y: Tally): Tally => {
82 const [a, b] = [sound(x), sound(y)]
83 return {
84 input: a.input + b.input,
85 output: a.output + b.output,
86 cacheRead: a.cacheRead + b.cacheRead,
87 cacheWrite5m: a.cacheWrite5m + b.cacheWrite5m,
88 cacheWrite1h: a.cacheWrite1h + b.cacheWrite1h,
89 turns: a.turns + b.turns,
90 }
91}
92
93export const mergeModels = (...maps: Record<string, Tally>[]): Record<string, Tally> => {
94 const merged: Record<string, Tally> = {}
95 for (const map of maps) {
96 for (const [model, t] of Object.entries(map)) merged[model] = addTallies(merged[model] ?? EMPTY, t)
97 }
98 return merged
99}
100
101/** Each line of the bill for one model, in dollars. */
102export const billOf = (model: string, stored: Tally) => {
103 const { price } = priceOf(model)
104 const t = sound(stored)
105 const input = (t.input * price.input) / 1e6
106 const output = (t.output * price.output) / 1e6
107 const cacheWrite = ((t.cacheWrite5m * WRITE_5M + t.cacheWrite1h * WRITE_1H) * price.input) / 1e6
108 const cacheRead = (t.cacheRead * price.cacheRead) / 1e6
109 const saved = (t.cacheRead * (price.input - price.cacheRead)) / 1e6
110 return { input, output, cacheWrite, cacheRead, total: input + output + cacheWrite + cacheRead, saved }
111}
112
113export const costOf = (model: string, usage: Usage) => billOf(model, addUsage(EMPTY, usage)).total
114
115export const sumOf = (byModel: Record<string, Tally>) =>
116 Object.entries(byModel).reduce((sum, [model, t]) => sum + billOf(model, t).total, 0)
117
118export const PLANS: Record<string, Plan> = {
119 pro: { label: 'Pro', usd: 20 },
120 'max-5x': { label: 'Max 5x', usd: 100 },
121 'max-20x': { label: 'Max 20x', usd: 200 },
122 api: { label: 'API', usd: null },
123}
124
125/** The plan from Claude Code's own config (`~/.claude.json`): its rate-limit tier names it exactly. */
126export const planFromConfig = (json: string): Plan | null => {
127 try {
128 const account = (JSON.parse(json) as { oauthAccount?: Record<string, unknown> }).oauthAccount
129 if (!account) return null
130 const tier = String(account.userRateLimitTier ?? account.organizationRateLimitTier ?? '').toLowerCase()
131 if (tier.includes('max_20x')) return PLANS['max-20x'] ?? null
132 if (tier.includes('max_5x')) return PLANS['max-5x'] ?? null
133 if (tier.includes('pro')) return PLANS.pro ?? null
134 return null
135 } catch {
136 return null
137 }
138}
139
140/** The day of the month the subscription renews, from when it began (`oauthAccount.subscriptionCreatedAt`). */
141export const billingDayFromConfig = (json: string): number | null => {
142 try {
143 const began = (JSON.parse(json) as { oauthAccount?: { subscriptionCreatedAt?: string } }).oauthAccount
144 ?.subscriptionCreatedAt
145 const ms = Date.parse(began ?? '')
146 return Number.isNaN(ms) ? null : new Date(ms).getDate()
147 } catch {
148 return null
149 }
150}
151
152/** When the billing cycle holding `now` began: the last renewal on `day`, a short month renewing on its last day. */
153export const cycleStart = (now: number, day: number): number => {
154 const at = (year: number, month: number) =>
155 new Date(year, month, Math.min(day, new Date(year, month + 1, 0).getDate())).getTime()
156 const d = new Date(now)
157 const thisMonth = at(d.getFullYear(), d.getMonth())
158 return thisMonth <= now ? thisMonth : at(d.getFullYear(), d.getMonth() - 1)
159}
160
161/** When that cycle ends: the next renewal. */
162export const cycleEnd = (start: number, day: number): number => {
163 const d = new Date(start)
164 return new Date(d.getFullYear(), d.getMonth() + 1, Math.min(day, new Date(d.getFullYear(), d.getMonth() + 2, 0).getDate())).getTime()
165}
166
167/** Dollars from the day holding `startMs` through the day holding `now`. */
168export const sumSince = (byDay: Record<string, number>, startMs: number, now: number): number => {
169 const [from, to] = [dayKey(startMs), dayKey(now)]
170 return Object.entries(byDay).reduce((sum, [day, v]) => (day >= from && day <= to ? sum + v : sum), 0)
171}
172
173/** The plan `claude auth status` reports, from its JSON (it says `max`, not which Max). */
174export const planFromAuth = (json: string): Plan | null => {
175 try {
176 const auth = JSON.parse(json) as { authMethod?: string; subscriptionType?: string | null }
177 if (auth.authMethod !== 'claude.ai') return PLANS.api ?? null
178 const kind = (auth.subscriptionType ?? '').toLowerCase()
179 if (kind === 'max') return PLANS['max-20x'] ?? null
180 return PLANS[kind] ?? (kind === '' ? null : { label: kind.charAt(0).toUpperCase() + kind.slice(1), usd: null })
181 } catch {
182 return null
183 }
184}
185
186export const usd = (n: number): string =>
187 n >= 10_000
188 ? `$${(n / 1000).toFixed(1)}k`
189 : n >= 100
190 ? `$${Math.round(n).toLocaleString('en-US')}`
191 : `$${n.toFixed(2)}`
192
193export const tokens = (n: number): string =>
194 n >= 1e9
195 ? `${(n / 1e9).toFixed(1)}B`
196 : n >= 1e6
197 ? `${(n / 1e6).toFixed(1)}M`
198 : n >= 1e3
199 ? `${(n / 1e3).toFixed(1)}k`
200 : String(n)
201
202export const dayKey = (ms: number): string => {
203 const d = new Date(ms)
204 const pad = (n: number) => String(n).padStart(2, '0')
205 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
206}
207
208export const BARS = '▁▂▃▄▅▆▇█'
209
210/** One width-1 glyph per day, oldest first, scaled to the busiest day. */
211export const sparkline = (values: readonly number[]): string => {
212 const top = Math.max(...values, 0)
213 return values.map(v => (v <= 0 ? '·' : BARS.charAt(Math.min(7, Math.floor((v / top) * 7.999))))).join('')
214}
215
216/** Teal for a quiet day through gold to hot orange for the busiest, as 0xRRGGBB. */
217export const heat = (share: number): number => {
218 const stops = [
219 [0x3b, 0xc9, 0xdb],
220 [0xfc, 0xc4, 0x19],
221 [0xff, 0x6b, 0x2c],
222 ] as const
223 const t = Math.max(0, Math.min(1, share)) * 2
224 const [a, b] = t < 1 ? [stops[0], stops[1]] : [stops[1], stops[2]]
225 const f = t < 1 ? t : t - 1
226 const mix = (i: 0 | 1 | 2) => Math.round(a[i] + (b[i] - a[i]) * f)
227 return (mix(0) << 16) | (mix(1) << 8) | mix(2)
228}
229
230export const hex = (rgb: number): string => `#${rgb.toString(16).padStart(6, '0')}`
231
232type DayModels = Record<string, Record<string, Tally>>
233
234/** Each model's tally over the days from `from` through `to` (day keys, inclusive). */
235export const modelsBetween = (dayModels: DayModels, from: string, to: string): Record<string, Tally> =>
236 mergeModels(...Object.entries(dayModels).flatMap(([day, byModel]) => (day >= from && day <= to ? [byModel] : [])))
237
238/** Dollars per day. */
239export const dayTotals = (dayModels: DayModels): Record<string, number> =>
240 Object.fromEntries(Object.entries(dayModels).map(([day, byModel]) => [day, sumOf(byModel)]))
241
242/** Two day-by-model maps added together. */
243export const addDayModels = (a: DayModels, b: DayModels): DayModels => {
244 const sum: DayModels = { ...a }
245 for (const [day, byModel] of Object.entries(b)) sum[day] = mergeModels(sum[day] ?? {}, byModel)
246 return sum
247}
248hooks/quips.ts 107 lines1import type { Plan } from '../types'
2import { usd } from './pricing'
3
4// `{over}` what Anthropic covers past the plan, `{left}` what is left to break even, `{x}` the multiple.
5const VERDICTS = {
6 realMoney: [
7 'this one is real money. Breathe.',
8 'every token here is a tiny invoice.',
9 'your accountant just felt a chill.',
10 'no plan, no mercy. Pay-as-you-go hits different.',
11 ],
12 idle: [
13 'paying for a gym you never visit.',
14 'the buffet is open. You ordered water.',
15 'Anthropic thanks you for your generous donation.',
16 'your plan is bored. Give it something to do.',
17 ],
18 warming: [
19 'warming up. {left} until the plan pays for itself.',
20 'Anthropic is winning this round. For now.',
21 '{left} of plan left on the table. Eat up.',
22 'still in the shallow end of the pool.',
23 ],
24 close: [
25 '{left} away from free food.',
26 'almost broke even. The plan is sweating.',
27 'so close you can smell the free tokens.',
28 'one big refactor from break-even.',
29 ],
30 paidOff: [
31 'plan paid off. Anthropic is now covering {over}.',
32 'break-even achieved. Everything else is on the house.',
33 "{x}x what you paid. The chef is getting nervous.",
34 'Anthropic is picking up the tab: {over} so far.',
35 "you've made your money back. Now it's just showing off.",
36 ],
37 heavy: [
38 'Anthropic is quietly funding your startup ({over} and counting).',
39 'a VC would call this a {x}x return.',
40 'somewhere, a GPU is filing a complaint.',
41 '{over} on the house. Someone in finance just sighed.',
42 'this plan is the best deal since free refills.',
43 ],
44 absurd: [
45 'you are why they have weekly limits.',
46 "{x}x. Anthropic's finance team knows your name.",
47 "this isn't a subscription, it's a heist.",
48 '{over} of compute, $200 of guilt. Fair trade.',
49 'the datacenter dims the lights when you log in.',
50 ],
51} as const
52
53const CACHE = [
54 '♻ caching spared you {s} of déjà vu',
55 '♻ {s} of tokens Claude remembered instead of rereading',
56 '♻ the cache knocked {s} off. Rereading is cheap, apparently.',
57 '♻ {s} saved by not reading the same files again',
58]
59
60const EMPTY = ['Nothing billed yet. Suspiciously frugal.', 'Nothing here yet. Ask Claude something expensive.']
61
62const READING = ['digging through your receipts', 'adding up your past sins', 'pricing your history']
63
64/** A stable pick from `options`: the same for ten minutes, then the next, so a redraw never flickers. */
65export const pick = (options: readonly string[], now: number, salt = 0): string => {
66 let seed = Math.floor(now / 600_000) * 31 + salt
67 seed = (seed ^ (seed >>> 13)) * 1_274_126_177
68 return options[Math.abs(seed ^ (seed >>> 16)) % options.length] ?? options[0] ?? ''
69}
70
71const fill = (line: string, values: Record<string, string>) =>
72 line.replace(/\{(\w+)\}/g, (all, name: string) => values[name] ?? all)
73
74/** How the billing cycle compares with the plan, in one line of opinion. */
75export const verdict = (cycleUsd: number, plan: Plan | null, now: number): string => {
76 if (plan === null || plan.usd === null) {
77 return plan?.label === 'API' ? pick(VERDICTS.realMoney, now) : 'the meter is running.'
78 }
79 const ratio = cycleUsd / plan.usd
80 const tier =
81 ratio < 0.1
82 ? VERDICTS.idle
83 : ratio < 0.5
84 ? VERDICTS.warming
85 : ratio < 1
86 ? VERDICTS.close
87 : ratio < 3
88 ? VERDICTS.paidOff
89 : ratio < 10
90 ? VERDICTS.heavy
91 : VERDICTS.absurd
92 return fill(pick(tier, now, 1), {
93 over: usd(Math.max(0, cycleUsd - plan.usd)),
94 left: usd(Math.max(0, plan.usd - cycleUsd)),
95 x: ratio.toFixed(ratio < 10 ? 1 : 0),
96 }).replace('$200', usd(plan.usd))
97}
98
99export const cacheQuip = (saved: number, now: number) => fill(pick(CACHE, now, 2), { s: usd(saved) })
100
101export const emptyQuip = (now: number) => pick(EMPTY, now, 3)
102
103export const readingQuip = (now: number) => pick(READING, now, 4)
104
105export const historyToast = (requests: number, total: number) =>
106 `what-would-it-cost: found ${requests.toLocaleString('en-US')} past requests. At API prices you'd owe ${usd(total)}. You don't. Enjoy.`
107types/index.d.ts 49 lines1/** Tokens one model used, as the API bills them. */
2export type Tally = {
3 input: number
4 output: number
5 cacheRead: number
6 /** Cache writes at the 5-minute lifetime (1.25x input). */
7 cacheWrite5m: number
8 /** Cache writes at the 1-hour lifetime (2x input), what Claude Code uses. */
9 cacheWrite1h: number
10 /** Model requests billed (one turn makes several). */
11 turns: number
12}
13
14/** What the person pays per month; `usd` null on pay-as-you-go (the bill is real). */
15export type Plan = { label: string; usd: number | null }
16
17/** The first-run read of the transcripts already on disk. */
18export type History = {
19 status: 'reading' | 'done' | 'failed'
20 filesDone: number
21 files: number
22 requests: number
23}
24
25/** Tallies by local day, then model. */
26export type DayTallies = Record<string, Record<string, Tally>>
27
28/** Which period the invoice shows. */
29export type Tab = 'session' | 'week' | 'cycle' | 'all'
30
31declare module 'claude-code' {
32 interface PluginState {
33 'what-would-it-cost': {
34 /** This session's models, live. */
35 models: Record<string, Tally>
36 /** Tallies by local day, then model: history and live together. */
37 dayModels: DayTallies
38 plan: Plan | null
39 /** Day of the month the plan renews (1-31); null until known. */
40 billingDay: number | null
41 /** When the current 7-day rate-limit window began, from its reset time; null off a subscription. */
42 weekStart: number | null
43 ledgerUsd: number | null
44 history: History | null
45 tab: Tab
46 }
47 }
48}
49