SLOPSHOPPER

token-usage

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

newpanebandcommandprocesstimer
v0.1.0no licenseupdated 2026-10-05Benjamin410/claude-mods/mods/token-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-usage
│ ┃ Token-Verbrauch (7 Tage) ✕ › fix the failing auth test and add an audit log call │ ┃ Letzte 7 Tage (dieses Geraet) │ ┃ Fr 03.10. ⏺ Read(src/auth.ts) │ ┃ ········································ ⎿ Read 6 lines │ ┃ 0 $0.00 ⏺ Update(src/auth.ts) │ ┃ Sa 04.10. ⎿ Added 2 lines, removed 1 line │ ┃ ········································ ⏺ Bash(bun test) │ ┃ 0 $0.00 ⎿ 3 pass, 1 fail │ ┃ So 05.10. │ ┃ ········································ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 0 $0.00 │ ┃ Mo 06.10. ✻ Worked for 42s · done 4:20 PM │ ┃ ········································ │ ┃ 0 $0.00 › /tokens │ ┃ Di 07.10. ⎿ token-usage: Token-Pane geoeffnet. │ ┃ ········································ │ ┃ 0 $0.00 │ ┃ Mi 08.10. │ ┃ ········································ │ ┃ 0 $0.00 │ ┃ Do 09.10. │ ┃ ········································ │ ┃ 0 $0.00 │ ┃ │ ┃ Summe: 0 Tokens ohne Cache-Reads, 0 gesamt, │ ┃ 0 Anfragen Tokens 7T 0 │ API ≈$0.00 │ Plan 5h 31% [ Details ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Tokens 7T 0 │ API ≈$0.00 │ Plan 5h 31% [ Details ]
Pane · Token-Verbrauch (7 Tage)
Letzte 7 Tage (dieses Geraet) Fr 03.10. ········································ 0 $0.00 Sa 04.10. ········································ 0 $0.00 So 05.10. ········································ 0 $0.00 Mo 06.10. ········································ 0 $0.00 Di 07.10. ········································ 0 $0.00 Mi 08.10. ········································ 0 $0.00 Do 09.10. ········································ 0 $0.00 Summe: 0 Tokens ohne Cache-Reads, 0 gesamt, 0 Anfragen in 0 · out 0 · cache-w 0 · cache-r 0 API-Preis: ≈ $0.00 nach Listenpreis (inkl. Cache) Nach Modell keine Anfragen Plan-Auslastung 5-Stunden-Fenster ████████████···························· 31% Reset 09.10. 02:53 0 Transkripte gelesen · Plan-Stand 10/9/2025, 1:53:20 AM [ Lese … ]
README

claude-mods

Eigene Claude-Code-Mods (Plugins mit Function-Hooks), privat geteilt ueber alle Geraete. Das Repo ist zugleich ein Plugin-Marketplace (.claude-plugin/marketplace.json).

Einrichten auf einem Geraet

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.

Mods

token-usage

Zeigt den Token-Verbrauch der letzten 7 Tage im Vergleich zur Plan-Auslastung.

  • Leiste oberhalb der Eingabe: Verlauf der 7 Tage als Sparkline, Summe, Plan 5h/7T in Prozent (gruen/gelb/rot ab 70/90 %), Reset-Zeitpunkt und Tokens je 1 % Plan. Knopf „Details“.
  • „Details“ bzw. /tokens oeffnet ein Pane: Balken je Tag, Summe (ohne/mit Cache-Reads), Aufteilung Input/Output/Cache, Verbrauch je Modell.
  • API-Preis: was derselbe Verbrauch nach API-Listenpreis in USD gekostet haette (je Modell, inkl. Cache-Write 5 min/1 h, Cache-Read und Fast Mode), in der Leiste als Summe der 7 Tage, im Pane je Tag, je Modell und seit Beginn des Plan-Fensters. Preistabelle: hooks/pricing.ts.
  • Plan-Auslastung: 5-Stunden- und 7-Tage-Fenster in Prozent mit Reset-Zeitpunkt, so wie die letzte API-Antwort sie meldet (nur mit Abo-Login, nicht mit API-Key). Der letzte Stand wird sitzungsuebergreifend gespeichert.
  • Vergleich: Tokens seit Beginn des aktuellen 7-Tage-Plan-Fensters gegen dessen Prozentwert, daraus Tokens je 1 % und eine Hochrechnung bis 100 %.
  • Transkripte werden beim Start und alle 10 Minuten neu eingelesen.

Grenzen:

  • Tokens stammen aus den lokalen Transkripten (~/.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.
  • Der Plan gewichtet Modelle und Cache-Tokens anders als eine reine Token-Summe; als Vergleichsbasis dienen Tokens ohne Cache-Reads.
  • Transkripte ueber 4 MiB werden per grep gefiltert gelesen (unter Windows ohne grep uebersprungen).

Pruefen: claude plugin validate mods/token-usage und claude plugin test mods/token-usage.

Source 4 files
hooks/register.tsx 348 lines
1import { 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}
348
hooks/aggregate.ts 164 lines
1import 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}
164
hooks/pricing.ts 70 lines
1/**
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}
70
types/index.d.ts 39 lines
1/** 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