Token-Verbrauch der letzten 7 Tage im Vergleich zur Plan-Auslastung (5h- und 7-Tage-Fenster)

Eigene Claude-Code-Mods (Plugins mit Function-Hooks), privat geteilt ueber alle Geraete. Das Repo ist zugleich ein Plugin-Marketplace (.claude-plugin/marketplace.json).
git clone git@github.com:<dein-user>/claude-mods.git ~/claude-mods
~/claude-mods/install.sh
Aktualisieren: git -C ~/claude-mods pull, danach in einer laufenden Sitzung /reload-plugins.
Nur fuer eine Sitzung testen, ohne Installation: claude --plugin-dir ~/claude-mods/mods/token-usage. Fuer Desktop-App/SDK-Sitzungen alternativ CLAUDE_CODE_PLUGIN_DIRS im env-Block von ~/.claude/settings.json auf die Mod-Ordner setzen.
Zeigt den Token-Verbrauch der letzten 7 Tage im Vergleich zur Plan-Auslastung.
/tokens oeffnet ein Pane: Balken je Tag, Summe (ohne/mit Cache-Reads), Aufteilung Input/Output/Cache, Verbrauch je Modell.hooks/pricing.ts.Grenzen:
~/.claude/projects/**/*.jsonl, bzw. $CLAUDE_CONFIG_DIR) dieses Geraets. Der Plan-Prozentwert zaehlt dagegen alle Geraete und Oberflaechen (claude.ai, Cloud-Sitzungen). Die Hochrechnung ist daher eine Untergrenze/Schaetzung.grep gefiltert gelesen (unter Windows ohne grep uebersprungen).Pruefen: claude plugin validate mods/token-usage und claude plugin test mods/token-usage.
hooks/register.tsx 348 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PlanSnapshot, TokenTotals, UsageReport } from '../types'
5import {
6 DAY_MS,
7 aggregate,
8 all,
9 effective,
10 fmt,
11 parseTranscript,
12 projectCapacity,
13 type UsageRecord,
14} from './aggregate'
15import { fmtUsd } from './pricing'
16
17const PANE = 'token-usage'
18const TITLE = 'Token-Verbrauch (7 Tage)'
19const STORE_PLAN = 'plan'
20const RESCAN_MS = 10 * 60 * 1000
21
22const report = atom({ plugin: 'token-usage', key: 'report' } as const, null)
23const plan = atom({ plugin: 'token-usage', key: 'plan' } as const, null)
24const scanning = atom({ plugin: 'token-usage', key: 'isScanning' } as const, false)
25
26const join = (...parts: string[]) => parts.join('/').replace(/\/+/g, '/')
27
28async function configDir($: EngineInterface): Promise<string | undefined> {
29 const explicit = await $.env.get('CLAUDE_CONFIG_DIR')
30 if (explicit) return explicit
31 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
32 return home ? join(home, '.claude') : undefined
33}
34
35/** Transkripte (auch Subagent-Transkripte) mit Aenderung nach `since`. */
36async function transcriptFiles($: EngineInterface, root: string, since: number) {
37 const files: string[] = []
38 const walk = async (dir: string, depth: number) => {
39 const entries = await $.fs.list(dir).catch(() => [])
40 for (const entry of entries) {
41 const path = join(dir, entry.name)
42 if (entry.kind === 'dir' && depth < 3) await walk(path, depth + 1)
43 else if (entry.kind === 'file' && entry.name.endsWith('.jsonl') && entry.mtimeMs >= since)
44 files.push(path)
45 }
46 }
47 await walk(join(root, 'projects'), 0)
48 return files
49}
50
51/** Liest eine Datei; ueber 4 MiB nur die usage-Zeilen per grep. */
52async function readUsageLines($: EngineInterface, path: string): Promise<string> {
53 try {
54 return await $.fs.read(path)
55 } catch {
56 const r = await $.process
57 .run(['grep', '-F', '"usage"', path], { timeoutMs: 60_000 })
58 .catch(() => undefined)
59 return r?.stdout ?? ''
60 }
61}
62
63function planSince(snapshot: PlanSnapshot | null): number | undefined {
64 const week = snapshot?.windows.find(w => w.kind === 'seven_day')
65 const reset = week?.resetsAt ? Date.parse(week.resetsAt) : NaN
66 return Number.isFinite(reset) ? reset - 7 * DAY_MS : undefined
67}
68
69type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
70
71const samePlan = (a: PlanSnapshot | null, windows: RateLimit[]) =>
72 !!a &&
73 a.windows.length === windows.length &&
74 a.windows.every((w, i) => {
75 const o = windows[i]
76 return o && w.kind === o.kind && w.percentUsed === o.percentUsed && w.resetsAt === o.resetsAt
77 })
78
79/** Uebernimmt einen Plan-Stand; speichert nur bei Aenderung. */
80async function setPlan($: EngineInterface, limits: RateLimit[]): Promise<boolean> {
81 if (limits.length === 0) return false
82 const previous = await read($, plan)
83 if (samePlan(previous, limits)) return false
84 const snapshot: PlanSnapshot = {
85 measuredAt: await $.clock.now(),
86 windows: limits.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })),
87 }
88 await update($, plan, () => snapshot)
89 await $.store.set(STORE_PLAN, snapshot).catch(() => undefined)
90 return planSince(snapshot) !== planSince(previous)
91}
92
93/** Holt den Plan-Stand, den die Sitzung schon kennt (z.B. nach /reload-plugins). */
94async function refreshPlan($: EngineInterface) {
95 const usage = await $.session.usage().catch(() => undefined)
96 if (usage) await setPlan($, usage.rateLimits)
97}
98
99async function scan($: EngineInterface): Promise<UsageReport | undefined> {
100 if (await read($, scanning)) return undefined
101 await update($, scanning, () => true)
102 try {
103 await refreshPlan($)
104 const now = await $.clock.now()
105 const root = await configDir($)
106 if (!root) return undefined
107 const snapshot = await read($, plan)
108 const since = planSince(snapshot)
109 const oldest = Math.min(now - 8 * DAY_MS, since ?? Infinity)
110 const files = await transcriptFiles($, root, oldest)
111 const records: UsageRecord[] = []
112 for (const file of files) records.push(...parseTranscript(await readUsageLines($, file)))
113 const offsetMin = -new Date(now).getTimezoneOffset()
114 const result = aggregate(records, now, offsetMin, files.length, since)
115 await update($, report, () => result)
116 return result
117 } finally {
118 await update($, scanning, () => false)
119 }
120}
121
122
123const WINDOW_NAMES: Record<string, string> = {
124 five_hour: '5-Stunden-Fenster',
125 seven_day: '7-Tage-Fenster',
126 seven_day_opus: '7-Tage-Fenster (Opus)',
127 seven_day_sonnet: '7-Tage-Fenster (Sonnet)',
128 spend_limit: 'Ausgabenlimit',
129}
130
131const weekday = (day: string) =>
132 ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][new Date(`${day}T12:00:00Z`).getUTCDay()]
133
134const when = (iso?: string) => {
135 if (!iso) return ''
136 const d = new Date(iso)
137 if (Number.isNaN(d.getTime())) return ''
138 const pad = (n: number) => String(n).padStart(2, '0')
139 return `Reset ${pad(d.getDate())}.${pad(d.getMonth() + 1)}. ${pad(d.getHours())}:${pad(d.getMinutes())}`
140}
141
142/** Gefuellter und leerer Teil eines Balkens, getrennt faerbbar. */
143const bar = (value: number, max: number, width: number): [string, string] => {
144 const n = max > 0 ? Math.min(width, Math.round((value / max) * width)) : 0
145 return ['█'.repeat(n), '·'.repeat(Math.max(0, width - n))]
146}
147
148const tone = (pct: number) => (pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green')
149
150const modelColor = (model: string) =>
151 model.includes('opus') || model.includes('fable')
152 ? 'magenta'
153 : model.includes('sonnet')
154 ? 'blue'
155 : model.includes('haiku')
156 ? 'green'
157 : undefined
158
159const SPARK = '▁▂▃▄▅▆▇█'
160const spark = (values: number[]) => {
161 const max = Math.max(...values)
162 return values
163 .map(v => (v <= 0 || max <= 0 ? ' ' : SPARK[Math.min(7, Math.floor((v / max) * 7.999))]))
164 .join('')
165}
166
167const split = (t: TokenTotals) =>
168 `in ${fmt(t.input)} · out ${fmt(t.output)} · cache-w ${fmt(t.cacheWrite)} · cache-r ${fmt(t.cacheRead)}`
169
170export const register: Register = on => {
171 on('session.start', async ($, e, next) => {
172 const started = await next(e)
173 const stored = (await $.store.get(STORE_PLAN).catch(() => undefined)) as PlanSnapshot | undefined
174 if (stored && !(await read($, plan))) await update($, plan, () => stored)
175 void scan($)
176 $.clock.every(RESCAN_MS, () => void scan($))
177 await $.command
178 .register({
179 name: 'tokens',
180 description: 'Details zum Token-Verbrauch der letzten 7 Tage vs. Plan-Auslastung',
181 })
182 .catch(() => undefined)
183 return started
184 })
185
186 on('command.run', { command: 'tokens' }, async $ => {
187 await $.ui.open({ id: PANE, title: TITLE })
188 void scan($)
189 return { text: 'Token-Pane geoeffnet.' }
190 })
191
192 // Plan-Auslastung kommt mit jeder API-Antwort.
193 on('session.measure', async ($, e, next) => {
194 // Neues Plan-Fenster: Summe seit Fensterbeginn neu rechnen.
195 if (await setPlan($, e.rateLimits)) void scan($)
196 return next(e)
197 })
198
199 // Kompakte Leiste oberhalb der Eingabe: 7-Tage-Verlauf, Summe, Plan-Fenster.
200 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
201 if (e.props.hasSurvey) return next(e)
202 const r = await read($, report)
203 if (!r) return next(e)
204 const p = await read($, plan)
205 const { Box, Text, Button } = $.ui.resolve(e)
206 const cols = e.props.bodyColumns
207 const total = effective(r.total)
208 const week = p?.windows.find(w => w.kind === 'seven_day')
209 const five = p?.windows.find(w => w.kind === 'five_hour')
210 const windowTokens = r.planWindow ? effective(r.planWindow) : undefined
211 const perPercent =
212 week && windowTokens !== undefined && week.percentUsed >= 1
213 ? windowTokens / week.percentUsed
214 : undefined
215
216 return (
217 <Box>
218 <Text dimColor>Tokens 7T </Text>
219 {cols >= 70 && <Text color="cyan">{`${spark(r.days.map(effective))} `}</Text>}
220 <Text bold>{fmt(total)}</Text>
221 <Text dimColor> │ API </Text>
222 <Text bold color="magenta">{`≈${fmtUsd(r.total.cost)}`}</Text>
223 {p ? (
224 <Text>
225 <Text dimColor> │ Plan </Text>
226 {five && <Text color={tone(five.percentUsed)}>{`5h ${five.percentUsed}% `}</Text>}
227 {week && <Text color={tone(week.percentUsed)}>{`7T ${week.percentUsed}%`}</Text>}
228 {week && cols >= 100 && <Text dimColor>{` (${when(week.resetsAt)})`}</Text>}
229 {perPercent !== undefined && cols >= 85 && (
230 <Text dimColor>{` │ ≈${fmt(perPercent)} Tok/1%`}</Text>
231 )}
232 </Text>
233 ) : (
234 <Text dimColor> │ Plan: noch kein Stand</Text>
235 )}
236 <Text> </Text>
237 <Button key="details" label="Details" onPress={() => void $.ui.open({ id: PANE, title: TITLE })} />
238 </Box>
239 )
240 })
241
242 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
243 const { Box, Text, Button } = $.ui.resolve(e)
244 const r = await read($, report)
245 const p = await read($, plan)
246 const isBusy = await read($, scanning)
247 const width = Math.max(10, Math.min(40, (e.viewport?.columns ?? 60) - 30))
248
249 if (!r)
250 return <Text dimColor>{isBusy ? 'Lese Transkripte …' : 'Noch keine Daten.'}</Text>
251
252 const max = Math.max(...r.days.map(effective))
253 const week = p?.windows.find(w => w.kind === 'seven_day')
254 const windowTokens = r.planWindow ? effective(r.planWindow) : undefined
255 const capacity =
256 week && windowTokens !== undefined ? projectCapacity(windowTokens, week.percentUsed) : undefined
257
258 return (
259 <Box flexDirection="column">
260 <Text bold color="cyan">Letzte 7 Tage (dieses Geraet)</Text>
261 {r.days.map((d, i) => {
262 const [full, rest] = bar(effective(d), max, width)
263 const isToday = i === r.days.length - 1
264 return (
265 <Text>
266 <Text bold={isToday} dimColor={!isToday}>
267 {weekday(d.day)} {d.day.slice(8)}.{d.day.slice(5, 7)}.
268 </Text>{' '}
269 <Text color={effective(d) === max && max > 0 ? 'yellow' : 'cyan'}>{full}</Text>
270 <Text dimColor>{rest}</Text> <Text bold={isToday}>{fmt(effective(d)).padStart(8)}</Text>{' '}
271 <Text color="magenta">{fmtUsd(d.cost).padStart(9)}</Text>
272 </Text>
273 )
274 })}
275 <Text> </Text>
276 <Text>
277 Summe: <Text bold color="cyan">{fmt(effective(r.total))}</Text> Tokens ohne Cache-Reads,{' '}
278 {fmt(all(r.total))} gesamt, {r.total.requests} Anfragen
279 </Text>
280 <Text dimColor>{split(r.total)}</Text>
281 <Text>
282 API-Preis: <Text bold color="magenta">≈ {fmtUsd(r.total.cost)}</Text> nach Listenpreis (inkl. Cache)
283 {r.total.unpriced > 0 && (
284 <Text color="yellow">{` · ${r.total.unpriced} Anfragen ohne bekannten Preis`}</Text>
285 )}
286 </Text>
287 <Text> </Text>
288 <Text bold color="cyan">Nach Modell</Text>
289 {r.models.length === 0 && <Text dimColor>keine Anfragen</Text>}
290 {r.models.map(m => (
291 <Text>
292 <Text bold color={modelColor(m.model)}>{m.model}</Text> {fmt(effective(m))} ·{' '}
293 {m.unpriced === m.requests ? (
294 <Text color="yellow">Preis unbekannt</Text>
295 ) : (
296 <Text color="magenta">≈ {fmtUsd(m.cost)}</Text>
297 )}
298 <Text dimColor>{` · ${m.requests} Anfragen`}</Text>
299 </Text>
300 ))}
301 <Text> </Text>
302 <Text bold color="cyan">Plan-Auslastung</Text>
303 {!p && (
304 <Text dimColor>
305 Noch kein Plan-Stand: er kommt mit der naechsten Antwort (nur mit Abo-Login, nicht mit API-Key).
306 </Text>
307 )}
308 {p?.windows.map(w => {
309 const [full, rest] = bar(w.percentUsed, 100, width)
310 return (
311 <Text>
312 {(WINDOW_NAMES[w.kind] ?? w.kind).padEnd(24)} <Text color={tone(w.percentUsed)}>{full}</Text>
313 <Text dimColor>{rest}</Text>{' '}
314 <Text bold color={tone(w.percentUsed)}>{`${String(w.percentUsed).padStart(5)}%`}</Text>{' '}
315 <Text dimColor>{when(w.resetsAt)}</Text>
316 </Text>
317 )
318 })}
319 {week && r.planWindow && windowTokens !== undefined && (
320 <Box flexDirection="column">
321 <Text> </Text>
322 <Text>
323 Seit Fensterbeginn hier verbraucht: <Text bold color="cyan">{fmt(windowTokens)}</Text> Tokens ={' '}
324 <Text color={tone(week.percentUsed)}>{week.percentUsed}%</Text> des Plans · API-Preis{' '}
325 <Text color="magenta">≈ {fmtUsd(r.planWindow.cost)}</Text>
326 </Text>
327 {capacity !== undefined && (
328 <Text>
329 ≈ {fmt(windowTokens / week.percentUsed)} Tokens je 1 % · hochgerechnet ≈{' '}
330 <Text bold color="green">{fmt(capacity)}</Text> Tokens bis 100 %
331 </Text>
332 )}
333 <Text dimColor>
334 Grobe Schaetzung: der Plan gewichtet Modelle und Cache anders und zaehlt alle Geraete.
335 </Text>
336 </Box>
337 )}
338 <Text> </Text>
339 <Text dimColor>
340 {r.files} Transkripte gelesen · Plan-Stand{' '}
341 {p ? new Date(p.measuredAt).toLocaleString() : '–'}
342 </Text>
343 <Button key="rescan" label={isBusy ? 'Lese …' : 'Neu einlesen'} onPress={() => void scan($)} />
344 </Box>
345 )
346 })
347}
348hooks/aggregate.ts 164 lines1import type { DayTotals, TokenTotals, UsageReport } from '../types'
2import { costOf } from './pricing'
3
4export const DAY_MS = 24 * 60 * 60 * 1000
5
6export const emptyTotals = (): TokenTotals => ({
7 input: 0,
8 output: 0,
9 cacheWrite: 0,
10 cacheRead: 0,
11 requests: 0,
12 cost: 0,
13 unpriced: 0,
14})
15
16/** Eine API-Antwort aus einer Transkriptzeile. */
17export type UsageRecord = {
18 key: string
19 at: number
20 model: string
21 input: number
22 output: number
23 cacheWrite: number
24 cacheRead: number
25 /** API-Listenpreis in USD; fehlt bei unbekanntem Modell. */
26 cost?: number
27}
28
29const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
30
31/**
32 * Liest die Assistant-Zeilen eines JSONL-Transkripts. Eine Antwort steht
33 * mehrfach im Transkript (eine Zeile je Content-Block, gleiche usage);
34 * `key` = message.id + requestId dient der Deduplizierung.
35 */
36export function parseTranscript(text: string): UsageRecord[] {
37 const out: UsageRecord[] = []
38 for (const line of text.split('\n')) {
39 if (!line.includes('"usage"')) continue
40 let row: any
41 try {
42 row = JSON.parse(line)
43 } catch {
44 continue
45 }
46 const msg = row?.message
47 const usage = msg?.usage
48 if (row?.type !== 'assistant' || !usage) continue
49 const at = Date.parse(row.timestamp)
50 if (!Number.isFinite(at)) continue
51 const model = typeof msg.model === 'string' ? msg.model : 'unbekannt'
52 if (model === '<synthetic>') continue
53 const input = num(usage.input_tokens)
54 const output = num(usage.output_tokens)
55 const cacheWrite = num(usage.cache_creation_input_tokens)
56 const cacheRead = num(usage.cache_read_input_tokens)
57 // Ohne TTL-Aufteilung gilt der 5-Minuten-Preis.
58 const cacheWrite1h = Math.min(cacheWrite, num(usage.cache_creation?.ephemeral_1h_input_tokens))
59 out.push({
60 key: `${msg.id ?? row.uuid}:${row.requestId ?? ''}`,
61 at,
62 model,
63 input,
64 output,
65 cacheWrite,
66 cacheRead,
67 cost: costOf({
68 model,
69 input,
70 output,
71 cacheWrite5m: cacheWrite - cacheWrite1h,
72 cacheWrite1h,
73 cacheRead,
74 isFast: usage.speed === 'fast',
75 }),
76 })
77 }
78 return out
79}
80
81const add = (t: TokenTotals, r: UsageRecord) => {
82 t.input += r.input
83 t.output += r.output
84 t.cacheWrite += r.cacheWrite
85 t.cacheRead += r.cacheRead
86 t.requests += 1
87 if (r.cost === undefined) t.unpriced += 1
88 else t.cost += r.cost
89}
90
91/** Lokales Datum als YYYY-MM-DD, verschoben um `offsetMin` (Minuten oestlich von UTC). */
92export const dayKey = (at: number, offsetMin: number) =>
93 new Date(at + offsetMin * 60_000).toISOString().slice(0, 10)
94
95/**
96 * Fasst die Records der letzten 7 Tage (heute + 6 Vortage, lokale Tage)
97 * zusammen; `planSince` liefert zusaetzlich die Summe seit Beginn des
98 * Plan-Fensters.
99 */
100export function aggregate(
101 records: UsageRecord[],
102 now: number,
103 offsetMin: number,
104 files: number,
105 planSince?: number,
106): UsageReport {
107 const days = new Map<string, DayTotals>()
108 for (let i = 6; i >= 0; i--) {
109 const day = dayKey(now - i * DAY_MS, offsetMin)
110 days.set(day, { day, ...emptyTotals() })
111 }
112 const models = new Map<string, TokenTotals>()
113 const total = emptyTotals()
114 const plan = emptyTotals()
115 const seen = new Set<string>()
116
117 for (const r of records) {
118 if (seen.has(r.key)) continue
119 seen.add(r.key)
120 if (planSince !== undefined && r.at >= planSince && r.at <= now) add(plan, r)
121 const bucket = days.get(dayKey(r.at, offsetMin))
122 if (!bucket) continue
123 add(bucket, r)
124 add(total, r)
125 const m = models.get(r.model) ?? emptyTotals()
126 add(m, r)
127 models.set(r.model, m)
128 }
129
130 return {
131 scannedAt: now,
132 files,
133 days: [...days.values()],
134 models: [...models.entries()]
135 .map(([model, t]) => ({ model, ...t }))
136 .sort((a, b) => effective(b) - effective(a)),
137 total,
138 planWindow: planSince === undefined ? undefined : { since: planSince, ...plan },
139 }
140}
141
142/** Tokens ohne Cache-Reads: Input + Cache-Write + Output. */
143export const effective = (t: TokenTotals) => t.input + t.cacheWrite + t.output
144
145/** Alle Tokens inklusive Cache-Reads. */
146export const all = (t: TokenTotals) => effective(t) + t.cacheRead
147
148export function fmt(n: number): string {
149 if (n >= 1e9) return `${(n / 1e9).toFixed(2)} Mrd`
150 if (n >= 1e6) return `${(n / 1e6).toFixed(1)} M`
151 if (n >= 1e3) return `${(n / 1e3).toFixed(1)} k`
152 return String(Math.round(n))
153}
154
155/**
156 * Hochrechnung: wie viele effektive Tokens das 7-Tage-Fenster bei gleichem
157 * Mix fassen wuerde. Grob, denn der Plan zaehlt gewichtet und
158 * geraeteuebergreifend, die Tokens hier stammen nur von diesem Geraet.
159 */
160export function projectCapacity(tokens: number, percentUsed: number): number | undefined {
161 if (percentUsed < 1 || tokens <= 0) return undefined
162 return (tokens / percentUsed) * 100
163}
164hooks/pricing.ts 70 lines1/**
2 * API-Listenpreise in USD je 1 Mio. Tokens (Anthropic first-party, Stand 2026-09-25).
3 * Cache-Write kostet 1,25x (5 Minuten) bzw. 2x (1 Stunde) des Input-Preises.
4 */
5export type Price = { input: number; output: number; cacheRead: number }
6
7// Laengster Praefix gewinnt; Transkripte tragen teils Datums-Suffixe.
8const PRICES: [prefix: string, price: Price][] = [
9 ['claude-fable-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
10 ['claude-mythos-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
11 ['claude-fable-5', { input: 10, output: 50, cacheRead: 1 }],
12 ['claude-mythos-5', { input: 10, output: 50, cacheRead: 1 }],
13 ['claude-opus-5-5', { input: 4, output: 20, cacheRead: 0.2 }],
14 ['claude-opus-5', { input: 5, output: 25, cacheRead: 0.5 }],
15 ['claude-opus-4-8', { input: 5, output: 25, cacheRead: 0.5 }],
16 ['claude-opus-4-7', { input: 5, output: 25, cacheRead: 0.5 }],
17 ['claude-opus-4-6', { input: 5, output: 25, cacheRead: 0.5 }],
18 ['claude-sonnet-5-5', { input: 2, output: 10, cacheRead: 0.2 }],
19 ['claude-sonnet-5', { input: 2, output: 10, cacheRead: 0.2 }],
20 ['claude-sonnet-4-6', { input: 3, output: 15, cacheRead: 0.3 }],
21 ['claude-haiku-4-5', { input: 1, output: 5, cacheRead: 0.1 }],
22]
23
24// Kurznamen ohne Version meinen die aktuelle Generation.
25const ALIASES: Record<string, string> = {
26 fable: 'claude-fable-5-1',
27 opus: 'claude-opus-5-5',
28 sonnet: 'claude-sonnet-5-5',
29 haiku: 'claude-haiku-4-5',
30}
31
32export function priceFor(model: string): Price | undefined {
33 const id = ALIASES[model] ?? model
34 let best: [string, Price] | undefined
35 for (const entry of PRICES)
36 if (id.startsWith(entry[0]) && (!best || entry[0].length > best[0].length)) best = entry
37 return best?.[1]
38}
39
40export type Billable = {
41 model: string
42 input: number
43 output: number
44 cacheWrite5m: number
45 cacheWrite1h: number
46 cacheRead: number
47 isFast: boolean
48}
49
50/** API-Preis einer Antwort in USD; `undefined` bei unbekanntem Modell. */
51export function costOf(r: Billable): number | undefined {
52 const p = priceFor(r.model)
53 if (!p) return undefined
54 const usd =
55 (r.input * p.input +
56 r.cacheWrite5m * p.input * 1.25 +
57 r.cacheWrite1h * p.input * 2 +
58 r.cacheRead * p.cacheRead +
59 r.output * p.output) /
60 1e6
61 // Fast Mode: doppelter Preis.
62 return r.isFast ? usd * 2 : usd
63}
64
65export function fmtUsd(usd: number): string {
66 if (usd >= 1000) return `$${Math.round(usd).toLocaleString('en-US')}`
67 if (usd >= 100) return `$${usd.toFixed(0)}`
68 return `$${usd.toFixed(2)}`
69}
70types/index.d.ts 39 lines1/** Token-Summen eines Zeitraums oder einer Gruppe. */
2export type TokenTotals = {
3 input: number
4 output: number
5 cacheWrite: number
6 cacheRead: number
7 requests: number
8 /** API-Listenpreis in USD. */
9 cost: number
10 /** Anfragen ohne bekannten Preis (in `cost` nicht enthalten). */
11 unpriced: number
12}
13
14/** Ein Tag (lokales Datum YYYY-MM-DD) mit seinen Summen. */
15export type DayTotals = { day: string } & TokenTotals
16
17/** Ein Plan-Fenster, wie die letzte API-Antwort es meldete. */
18export type PlanWindow = { kind: string; percentUsed: number; resetsAt?: string }
19
20/** Letzter bekannter Plan-Stand mit Zeitpunkt der Messung. */
21export type PlanSnapshot = { measuredAt: number; windows: PlanWindow[] }
22
23/** Ergebnis eines Transkript-Scans. */
24export type UsageReport = {
25 scannedAt: number
26 files: number
27 days: DayTotals[]
28 models: ({ model: string } & TokenTotals)[]
29 total: TokenTotals
30 /** Summe seit Beginn des aktuellen 7-Tage-Plan-Fensters (resetsAt - 7 Tage). */
31 planWindow?: { since: number } & TokenTotals
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 'token-usage': { report: UsageReport | null; plan: PlanSnapshot | null; isScanning: boolean }
37 }
38}
39