SLOPSHOPPER

openai-balance

Your OpenAI API credit above the prompt: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. /openai-balance…

newbandcommandprocessnetworktimer
★ 167v0.1.1MITupdated 2026-10-07hamzafer/claude-code-mods/mods/openai-balance
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · openai-balance
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /openai-balance ⎿ openai-balance: No OpenAI Admin key found. Needs an OpenAI organization **Admin key** (read-only is enough), from platform.ope ◆ OpenAI no admin key set, see /openai-balance ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ OpenAI no admin key set, see /openai-balance ⟨Claude Code's own drawing⟩
README

<h1 align="center">Claude Code mods</h1>

<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>

<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>

<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>

🚀 Install

Add the marketplace once, then install any mod by name:

claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods

Or install the general-purpose set in one go:

for m in context-bar token-weather usage-meter where-am-i next-steps agent-radar review-watch replay-theater md-preview blast-radius mission-control; do
  claude plugin install "$m@claude-code-mods"
done

Restart Claude Code after installing. To try one without installing:

git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar

Needs Claude Code 2.1.287+. A few mods need more (Chrome, gh, a connector); the tables say which.

🧩 The mods

👀 See what's happening

ModWhat it doesCommand
🛰️mission-controlLive map of agents, tool calls and the code they touch/mission
📊context-barYour context window as one stacked bar, a color per category, with token counts and where it compacts/context-bar
🌦️token-weatherContext fill from Clear to Compact soon, plus a prompt-cache countdown
⏱️cache-clockA prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+/cache-clock setup
📍where-am-iGoal, doing now, waiting on you, next step/where
➡️next-steps2 or 3 likely next prompts after each turn, one key to draft one1 2 3, 0 hides
💰usage-meter5-hour and 7-day plan usage, the reset countdown and the session's cost
💳openai-balanceYour OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key/openai-balance
📡agent-radarOne live line per running subagent/radar
🔍review-watchOne live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends
🌐browser-lanesWhether this session has a browser, and who holds it/browser
🕌prayer-timesThe current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr/prayers
🎬replay-theaterSteps through the last turn's edits, one diff at a time/replay
📝md-previewRenders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images/md

🛡️ Guard your repo

ModWhat it doesCommand
💥blast-radiusHolds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer

💸 Spend less

ModWhat it doesCommand
🔀switchboardPicks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost/route

🔧 My setup (fork and adapt)

These are built around my own tools and rules. Fork them and change the rules to yours.

ModWhat it doesCommand
👀glanceOne line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors/glance
🚦merge-gateHolds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours/gate
📏rulebook-guardEnforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes
💾session-saverSaves where you left off, shows it on resume. Needs unpause/park [note]

🎮 For fun (opt-in)

ModWhat it doesCommand
📱reelsYouTube Shorts while Claude works, pauses when it's done/reels
🐍snakeSnake while Claude works/snake
🎵now-playingWhat Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app/music

<a id="mission-control"></a>

🛰️ Flagship: mission-control

Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

mission-control at 4x: two subagents and a logout feature landing across four files

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.

  • 🤖 w shows the agents and every tool call, live
  • 🗺️ c shows the code map, with import arrows
  • 🔵 Blue while the agent reads a file
  • 🟠 Orange while it writes
  • 🟢 Green when done, with one line on what changed

The Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.

📚 More

Source 2 files
hooks/register.tsx 426 lines
1// OpenAI Balance: your OpenAI API credit, in a band above the prompt.
2// OpenAI has no balance API, so the balance is an estimate: the last balance you set with
3// /openai-balance <amount>, minus the real spend the Costs API reports since then.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, Timer } from 'claude-code'
6
7import type { Line } from '../types'
8
9const EVERY_MS = 10 * 60 * 1000 // spend posts in daily buckets; no need to ask more often
10const TICK_MS = 60 * 1000 // the timer checks every minute; nextTry decides whether to ask
11const RETRY_MS = 60 * 1000 // after a failed refresh
12const STUCK_MS = 2 * 60 * 1000 // a refresh still running after this is given up on (fetch has no timeout)
13const KEYCHAIN_SERVICE = 'openai-admin-key'
14const API = 'https://api.openai.com/v1/organization'
15const DAY_S = 86_400
16const LOW = 2 // dollars: red below this
17const MID = 5 // dollars: yellow below this
18const BRAND = '#c792ea' // soft violet: apart from the green gauge and the other bands
19// Usage types to merge. `decisions` 404s while the Decisions API is in beta (Oct 2026); it is asked anyway, so keys
20// and times show up by themselves once OpenAI adds it. Until then its spend shows through cost line items.
21const USAGE = ['completions', 'decisions', 'embeddings', 'moderations']
22const GAUGE = 10
23const EMPTY: Line = { left: null, start: null, today: 0, last: null, top: null, error: null, isLoaded: false, hasData: false }
24const SETUP =
25  'Needs an OpenAI organization **Admin key** (read-only is enough), from platform.openai.com → Settings → Organization → Admin keys. Put it in `/config` → openai-balance, or in `OPENAI_ADMIN_KEY`, or on macOS in Keychain: `security add-generic-password -a "$USER" -s openai-admin-key -w`.'
26
27// The balance you set, and how much of that UTC day's spend it already included.
28type Anchor = { balance: number; dayStart: number; spentBefore: number }
29type Bucket = { start: number; dollars: number; items: Record<string, number> } // items: dollars per line item
30
31// Held by the host, so the line survives a hot reload of this file.
32const line = atom({ plugin: 'openai-balance', key: 'line' } as const, EMPTY)
33
34let timers: Timer[] = [] // restarted on each session.start
35let configKey = ''
36let key = '' // kept in memory only, never in state, the store or a message
37let nextTry = 0
38let generation = 0 // a newer refresh wins; an older one that ends later is dropped
39let running: { since: number } | null = null
40
41class NoKey extends Error {}
42class KeyRejected extends Error {}
43class HttpError extends Error {
44  constructor(readonly status: number) {
45    super(`OpenAI answered ${status}`)
46  }
47}
48
49export const register: Register = (on, options) => {
50  configKey = typeof options.adminKey === 'string' ? options.adminKey.trim() : ''
51
52  on('session.start', async ($, e, next) => {
53    const result = await next(e)
54    await $.command
55      .register({
56        name: 'openai-balance',
57        description: 'OpenAI API credit, spend and tokens. Add an amount to set the balance after a top-up',
58        argumentHint: '[amount]',
59      })
60      .catch(() => {}) // a name Claude Code already has is refused: start anyway
61    void refresh($, true).catch(() => {}) // in the background: a slow API never holds up the session
62    for (const t of timers) t.cancel()
63    timers = [$.clock.every(TICK_MS, () => void refresh($, false).catch(() => {}))]
64    return result
65  })
66
67  on('turn.complete', async ($, e, next) => {
68    const result = await next(e)
69    if (!e.agentId) void refresh($, false).catch(() => {}) // main-loop turns only, not subagents
70    return result
71  })
72
73  on('command.run', { command: 'openai-balance' }, async ($, e) => {
74    const arg = e.args.trim().replace(/^\$/, '')
75    try {
76      if (arg === '') return { text: await detail($) }
77      if (!/^\d+(\.\d+)?$/.test(arg) || Number(arg) <= 0) return { text: 'Usage: `/openai-balance` or `/openai-balance 25.00` (the balance from your Billing page)' }
78      const anchor = await setBalance($, Number(arg))
79      await refresh($, true)
80      return { text: `OpenAI balance set to ${usd(anchor.balance)}. From now on the band subtracts spend after this point.` }
81    } catch (err) {
82      return { text: problem(err) }
83    }
84  })
85
86  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
87    const rest = await next(e) // what other mods and Claude Code draw here stays
88    const now = { ...EMPTY, ...(await read($, line)) } // a value an older version saved may lack a field
89    if (e.props.hasSurvey || !now.isLoaded) return rest
90
91    const { Box, Text } = $.ui.resolve(e)
92    const isWide = e.props.bodyColumns >= 60
93    const sep = <Text dimColor>{' · '}</Text>
94    const head = <Text color={BRAND} bold>{'◆ OpenAI  '}</Text>
95
96    let body
97    if (now.error === 'no-key') body = <Text color="red">no admin key set, see /openai-balance</Text>
98    else if (now.error === 'rejected') body = <Text color="red">admin key rejected, see /openai-balance</Text>
99    else if (!now.hasData) body = <Text dimColor>{"can't reach OpenAI yet"}</Text>
100    else if (now.left === null) {
101      body = (
102        <Text>
103          <Text bold>{usd(now.today)}</Text>
104          <Text dimColor>{' today'}</Text>
105          {sep}
106          <Text dimColor>{'/openai-balance <amount> to show credit'}</Text>
107          {now.error === 'offline' && <Text dimColor>{' · offline'}</Text>}
108        </Text>
109      )
110    } else {
111      // What today's money mostly went to, when that's not the last call's model: spend the Usage API can't
112      // attribute to a call (Decisions) still shows.
113      const mostly = isWide && now.top && (!now.last || same(now.top) !== same(now.last.model)) ? short(now.top) : null
114      const tone = now.left < LOW ? 'red' : now.left < MID ? 'yellow' : 'green'
115      const filled = now.start !== null && now.start > 0 ? Math.max(0, Math.min(GAUGE, Math.round((now.left / now.start) * GAUGE))) : 0
116      body = (
117        <Text>
118          {isWide && <Text color={tone}>{'█'.repeat(filled)}</Text>}
119          {isWide && <Text dimColor>{'▁'.repeat(GAUGE - filled)}</Text>}
120          <Text color={tone} bold>{`${isWide ? ' ' : ''}~${usd(now.left)}`}</Text>
121          <Text dimColor>{now.start !== null ? ` of ${usd(now.start)}` : ''}{now.left < LOW ? ' left, top up soon' : ' left'}</Text>
122          {sep}
123          <Text bold>{usd(now.today)}</Text>
124          <Text dimColor>{' today'}</Text>
125          {mostly && <Text dimColor>{', mostly '}</Text>}
126          {mostly && <Text color="cyan">{mostly}</Text>}
127          {now.last && sep}
128          {now.last && <Text dimColor>{'last '}</Text>}
129          {now.last && <Text color="cyan">{short(now.last.model)}</Text>}
130          {now.last && <Text dimColor>{` ${clock(now.last.at)}`}</Text>}
131          {now.error === 'offline' && <Text dimColor>{' · offline'}</Text>}
132        </Text>
133      )
134    }
135
136    return (
137      <Box flexDirection="column">
138        <Box paddingX={1}>
139          <Text wrap="truncate-end">
140            {head}
141            {body}
142          </Text>
143        </Box>
144        {rest}
145      </Box>
146    )
147  })
148}
149
150// Updates what the band draws. Every 10 minutes, a minute after a failure, or now when forced.
151async function refresh($: EngineInterface, isForced: boolean) {
152  const now = await $.clock.now()
153  if (!isForced && (now < nextTry || (running && now - running.since < STUCK_MS))) return
154  const mine = ++generation
155  running = { since: now }
156  try {
157    const anchor = await getAnchor($)
158    const nowS = Math.floor(now / 1000)
159    const buckets = await costs($, Math.min(anchor?.dayStart ?? nowS, dayStart(nowS)))
160    const today = spentFrom(buckets, dayStart(nowS))
161    const left = anchor ? estimate(anchor, buckets) : null
162    const last = await lastCall($, dayStart(nowS)).catch(() => null) // a usage hiccup never hides the balance
163    const top = topModel(itemsFrom(buckets, dayStart(nowS)))
164    if (mine !== generation) return
165    nextTry = now + EVERY_MS
166    await update($, line, () => ({ left, start: anchor?.balance ?? null, today, last, top, error: null, isLoaded: true, hasData: true }))
167  } catch (err) {
168    if (mine !== generation) return
169    nextTry = now + RETRY_MS // a fixed key or a network back shows within a minute
170    const error: Line['error'] = err instanceof NoKey ? 'no-key' : err instanceof KeyRejected ? 'rejected' : 'offline'
171    await update($, line, l => ({ ...EMPTY, ...l, error, isLoaded: true })) // offline keeps the last numbers, marked
172  } finally {
173    if (mine === generation) running = null
174  }
175}
176
177async function detail($: EngineInterface) {
178  const now = await $.clock.now()
179  const nowS = Math.floor(now / 1000)
180  const anchor = await getAnchor($)
181  const monthStart = Math.floor(Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1) / 1000)
182  const thirtyAgo = dayStart(nowS) - 29 * DAY_S
183  const buckets = await costs($, Math.min(anchor?.dayStart ?? thirtyAgo, monthStart, thirtyAgo))
184  const [models, keys, last] = await Promise.all([tokens($, thirtyAgo, 'model'), tokens($, thirtyAgo, 'api_key_id'), lastCall($, dayStart(nowS)).catch(() => null)])
185
186  const lines = ['## OpenAI balance', '']
187  if (anchor) {
188    const left = estimate(anchor, buckets)
189    lines.push(`- **Estimated left:** ~${usd(left)} (set to ${usd(anchor.balance)} on ${date(anchor.dayStart)}, ${usd(anchor.balance - left)} spent since)`)
190  } else {
191    lines.push('- **Estimated left:** not set yet. Run `/openai-balance <amount>` with the balance from the Billing page.')
192  }
193  lines.push(
194    `- **Today (UTC):** ${usd(spentFrom(buckets, dayStart(nowS)))}`,
195    `- **This month:** ${usd(spentFrom(buckets, monthStart))}`,
196    `- **Last 30 days:** ${usd(spentFrom(buckets, thirtyAgo))}`,
197    '',
198    '**Spend by item, last 30 days**',
199  )
200  const items = itemsFrom(buckets, thirtyAgo)
201  if (items.length === 0) lines.push('- none')
202  for (const [k, v] of items) lines.push(`- ${k}: ${usd4(v)}`)
203  lines.push('', '**Tokens, last 30 days**')
204  if (models.length === 0) lines.push('- none')
205  for (const m of models) lines.push(`- ${m.model}: ${num(m.input)} in / ${num(m.output)} out, ${num(m.requests)} requests`)
206  lines.push('', '**By key, last 30 days**')
207  if (keys.length === 0) lines.push('- none')
208  for (const k of keys) lines.push(`- ${k.model}: ${num(k.input + k.output)} tokens, ${num(k.requests)} requests`)
209  lines.push('', last ? `**Last call today:** ${last.model} via ${last.key} at ${clock(last.at)}` : '**Last call today:** none')
210  lines.push('', '_The balance is an estimate (OpenAI has no balance API). After a top-up, run `/openai-balance <new amount>`. Decisions API calls show as spend only until OpenAI adds them to the Usage API._')
211  return lines.join('\n')
212}
213
214async function setBalance($: EngineInterface, balance: number): Promise<Anchor> {
215  const start = dayStart(Math.floor((await $.clock.now()) / 1000))
216  const anchor = { balance, dayStart: start, spentBefore: spentFrom(await costs($, start), start) }
217  await $.store.set('anchor', anchor)
218  return anchor
219}
220
221async function getAnchor($: EngineInterface): Promise<Anchor | undefined> {
222  const a = (await $.store.get('anchor')) as Anchor | undefined
223  return a && typeof a.balance === 'number' ? a : undefined
224}
225
226function estimate(anchor: Anchor, buckets: Bucket[]) {
227  return anchor.balance - (spentFrom(buckets, anchor.dayStart) - anchor.spentBefore)
228}
229
230// Where the money since `start` went, biggest first.
231function itemsFrom(buckets: Bucket[], start: number) {
232  const sum: Record<string, number> = {}
233  for (const b of buckets) if (b.start >= start) for (const [k, v] of Object.entries(b.items)) sum[k] = (sum[k] ?? 0) + v
234  return Object.entries(sum).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1])
235}
236
237// The model today's money mostly went to, its line items (input, output, ...) added up first.
238function topModel(items: [string, number][]) {
239  const byModel = new Map<string, number>()
240  for (const [name, dollars] of items) byModel.set(short(name), (byModel.get(short(name)) ?? 0) + dollars)
241  return [...byModel].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
242}
243
244function spentFrom(buckets: Bucket[], start: number) {
245  return buckets.filter(b => b.start >= start).reduce((sum, b) => sum + b.dollars, 0)
246}
247
248// Daily spend in dollars from `start` (unix seconds) until now, all pages.
249async function costs($: EngineInterface, start: number): Promise<Bucket[]> {
250  const out: Bucket[] = []
251  let page = ''
252  do {
253    const d = await call($, `costs?start_time=${start}&bucket_width=1d&limit=180&group_by=line_item${page && `&page=${encodeURIComponent(page)}`}`)
254    for (const b of d.data ?? []) {
255      const items: Record<string, number> = {}
256      for (const r of b.results ?? []) {
257        const name = r.line_item ?? 'other'
258        items[name] = (items[name] ?? 0) + Number(r.amount?.value ?? 0)
259      }
260      out.push({ start: b.start_time, dollars: Object.values(items).reduce((a, v) => a + v, 0), items })
261    }
262    page = d.has_more && d.next_page ? d.next_page : ''
263  } while (page)
264  return out
265}
266
267async function tokens($: EngineInterface, start: number, by: 'model' | 'api_key_id') {
268  const d = await usage($, `start_time=${start}&bucket_width=1d&limit=31&group_by=${by}`)
269  const names = by === 'api_key_id' ? await keyNames($) : null
270  const byName = new Map<string, { model: string; input: number; output: number; requests: number }>()
271  for (const b of d.data ?? []) {
272    for (const r of b.results ?? []) {
273      const name = names ? keyName(names, r.api_key_id) : (r.model ?? '?')
274      const m = byName.get(name) ?? { model: name, input: 0, output: 0, requests: 0 }
275      m.input += r.input_tokens ?? 0
276      m.output += r.output_tokens ?? 0
277      m.requests += r.num_model_requests ?? 0
278      byName.set(name, m)
279    }
280  }
281  return [...byName.values()].sort((a, b) => b.input + b.output - (a.input + a.output))
282}
283
284// The newest minute today with a request in it: which model, through which key. The hour first, then its minutes,
285// so no request needs more than 60 buckets. Model and key come from two groupings, so with two keys busy in the
286// same minute the pair is a best guess.
287async function lastCall($: EngineInterface, start: number): Promise<Line['last']> {
288  const hour = newest(await usage($, `start_time=${start}&bucket_width=1h&limit=24&group_by=model`))
289  if (!hour) return null
290  const window = `start_time=${hour.at}&end_time=${hour.at + 3600}&bucket_width=1m&limit=60`
291  const [byModel, byKey] = await Promise.all((['model', 'api_key_id'] as const).map(by => usage($, `${window}&group_by=${by}`)))
292  const m = newest(byModel)
293  if (!m) return null
294  const k = newest(byKey)
295  return { at: m.at, model: m.r.model ?? '?', key: k ? keyName(await keyNames($), k.r.api_key_id) : '?' }
296}
297
298// The newest bucket with a request in it, and its busiest row.
299function newest(d: { data: any[] }) {
300  let best: { at: number; r: any } | null = null
301  for (const b of d.data) {
302    for (const r of b.results ?? []) {
303      const n = r.num_model_requests ?? 0
304      if (n > 0 && (!best || b.start_time > best.at || (b.start_time === best.at && n > (best.r.num_model_requests ?? 0)))) best = { at: b.start_time, r }
305    }
306  }
307  return best
308}
309
310// The same query over every usage type, buckets merged. Only a type the API doesn't have (404) is skipped.
311async function usage($: EngineInterface, query: string) {
312  const answers = await Promise.all(
313    USAGE.map(type =>
314      pages($, `usage/${type}?${query}`).catch(err => {
315        if (err instanceof HttpError && err.status === 404) return []
316        throw err
317      }),
318    ),
319  )
320  return { data: answers.flat() }
321}
322
323// Every bucket of a usage query, following next_page.
324async function pages($: EngineInterface, path: string) {
325  const out: any[] = []
326  let page = ''
327  do {
328    const d = await call($, `${path}${page && `&page=${encodeURIComponent(page)}`}`)
329    out.push(...(d.data ?? []))
330    page = d.has_more && d.next_page ? d.next_page : ''
331  } while (page)
332  return out
333}
334
335// Key id → the name it was given, across every project. One lookup shared by concurrent callers, kept an hour.
336let keyCache: { at: number; names: Promise<Map<string, string>> } | null = null
337async function keyNames($: EngineInterface) {
338  const now = await $.clock.now()
339  if (!keyCache || now - keyCache.at > 60 * 60 * 1000) {
340    const entry = { at: now, names: loadKeyNames($).then(r => {
341      if (!r.isComplete && keyCache === entry) keyCache = null // a lookup that missed some keys is tried again next time
342      return r.names
343    }) }
344    keyCache = entry
345  }
346  return keyCache.names
347}
348
349// A key without access to the key lists still gets its usage, shown by id (key …abcd).
350async function loadKeyNames($: EngineInterface) {
351  const names = new Map<string, string>()
352  let isComplete = true
353  const projects = await list($, 'projects?limit=100').catch(() => ((isComplete = false), []))
354  for (const p of projects) {
355    const keys = await list($, `projects/${p.id}/api_keys?limit=100`).catch(() => ((isComplete = false), []))
356    for (const k of keys) names.set(k.id, k.name || k.redacted_value || k.id)
357  }
358  return { names, isComplete }
359}
360
361// Every item of a paged list endpoint.
362async function list($: EngineInterface, path: string) {
363  const out: any[] = []
364  let after = ''
365  do {
366    const d = await call($, `${path}${after && `&after=${encodeURIComponent(after)}`}`)
367    out.push(...(d.data ?? []))
368    after = d.has_more && d.last_id ? d.last_id : ''
369  } while (after)
370  return out
371}
372
373function keyName(map: Map<string, string>, id: string | null | undefined) {
374  if (!id) return 'no key'
375  return map.get(id) ?? `key …${id.slice(-4)}`
376}
377
378async function call($: EngineInterface, path: string) {
379  const res = await $.http.fetch(`${API}/${path}`, { headers: { authorization: `Bearer ${await adminKey($)}` } })
380  if (res.status === 401 || res.status === 403) {
381    key = '' // read it again next time, in case it was replaced
382    throw new KeyRejected()
383  }
384  if (!res.ok) throw new HttpError(res.status)
385  return JSON.parse(res.text)
386}
387
388// From /config first, then OPENAI_ADMIN_KEY, then the macOS Keychain.
389async function adminKey($: EngineInterface) {
390  if (key) return key
391  const run = async (argv: string[]) => {
392    const r = await $.process.run(argv).catch(() => null)
393    return r && r.exitCode === 0 ? r.stdout.trim() : ''
394  }
395  const k = configKey || (await run(['printenv', 'OPENAI_ADMIN_KEY'])) || (await run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']))
396  if (!k.startsWith('sk-admin-')) throw new NoKey()
397  return (key = k)
398}
399
400function problem(err: unknown) {
401  if (err instanceof NoKey) return `No OpenAI Admin key found. ${SETUP}`
402  if (err instanceof KeyRejected) return `OpenAI refused the Admin key (401/403): it may be revoked, or not an Admin key. ${SETUP}`
403  return `Couldn't reach OpenAI: ${err instanceof Error ? err.message : String(err)}`
404}
405
406// A model or line item without its billing part, fine-tune suffix or snapshot date:
407// "x, input" → "x", "ft:gpt-4o-mini-2024-07-18:org::id" → "gpt-4o-mini", "gpt-5-2025-08-07" → "gpt-5".
408const short = (name: string) => {
409  const base = name.split(',')[0].trim()
410  return (base.startsWith('ft:') ? base.split(':')[1] : base).replace(/-\d{4}-\d{2}-\d{2}$/, '')
411}
412// For comparing a Costs line item with a Usage model id: case, spaces and dashes don't count.
413const same = (name: string) => short(name).toLowerCase().replace(/[^a-z0-9]/g, '')
414const dayStart = (s: number) => s - (s % DAY_S)
415const usd = (n: number) => {
416  const cents = Math.round(n * 100)
417  return `${cents < 0 ? '-' : ''}$${(Math.abs(cents) / 100).toFixed(2)}`
418}
419const num = (n: number) => Math.round(n).toLocaleString('en-US')
420const clock = (s: number) => {
421  const d = new Date(s * 1000)
422  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
423}
424const usd4 = (n: number) => `$${n.toFixed(4)}`
425const date = (s: number) => new Date(s * 1000).toISOString().slice(0, 10)
426
types/index.d.ts 18 lines
1// What the band above the prompt draws.
2export type Line = {
3  left: number | null // estimated balance, null until one is set
4  start: number | null // the balance it was set to, for the gauge
5  today: number // spend in the current UTC day
6  last: { at: number; model: string; key: string } | null // the newest request today (UTC)
7  top: string | null // today's biggest cost line item, for spend with no usage row (Decisions)
8  error: 'no-key' | 'rejected' | 'offline' | null // offline keeps the last numbers, marked
9  isLoaded: boolean
10  hasData: boolean // a refresh has succeeded at least once
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'openai-balance': { line: Line }
16  }
17}
18