SLOPSHOPPER

openrouter-balance

Your OpenRouter credit above the prompt: the balance with a gauge, today's spend and where the money mostly went. /openrouter-balance shows the breakdown.

newbandcommandprocessnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · openrouter-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 › /openrouter-balance ⎿ openrouter-balance: No OpenRouter key found. Needs an OpenRouter key. A **management key** (openrouter.ai → Settings → Management ◆ OpenRouter no key set, see /openrouter-balance ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ OpenRouter no key set, see /openrouter-balance ⟨Claude Code's own drawing⟩
README

Claude Code mods by krysmalskipl

Mods for Claude Code, built for SEO and content work. Install only the ones you want.

Install

Every mod installs on its own. The quick version:

claude plugin marketplace add krysmalskipl/claude-code-mods
claude plugin install <mod>@krysmalskipl

Then restart Claude Code. Each mod below has the full steps, including any key it needs.

Needs Claude Code 2.1.287+ in the terminal, or the Claude desktop app's Code tab. Plugins installed from the terminal show up in both.

Mods

ModWhat it doesCommand
🪙openrouter-balanceYour OpenRouter credit: the real balance with a gauge, today's spend and the model the money mostly went to. A regular API key shows its own spend and limit instead/openrouter-balance
📏meta-lintThe SEO basics of the page or post Claude just edited: title and meta description length, H1 count, images without alt, word count. Markdown, MDX, Astro and HTML/meta-lint [file]
🔗link-checkChecks the external links Claude adds. Broken ones go back to Claude so it can fix them, and show above the prompt/links [file]
🪧share-previewHow the page looks in Google (desktop and mobile, cut by pixel width) and as a shared link on LinkedIn, Facebook, Slack and X, with what would cost you clicks. Live while Claude edits`/preview [url \file]`
⏱️cache-statsYour prompt cache above the prompt: hit rate, how long it stays warm, what a cold restart re-caches, and how long the session has run. /cache lists every miss and why`/cache [hide\show]`

📏 meta-lint

meta-lint and link-check in Claude Code: /meta-lint details with a search snippet preview, /links, and the SEO line above the prompt

One line above the prompt after Claude edits a page or post:

◆ SEO  post.md  title 58/60 ✓ · description 186/160 ✗ · H1 1 ✓ · 2 images without alt · 2,726 words
Install
claude plugin marketplace add krysmalskipl/claude-code-mods   # once per machine
claude plugin install meta-lint@krysmalskipl

Restart Claude Code or type /reload-plugins. No key, no setup: it reads files on your machine and sends nothing anywhere. The line appears after Claude's first Write or Edit of a .md, .mdx, .markdown, .astro, .html or .htm file.

What it checks
CheckWhere it looksGreenYellowRed
TitleFrontmatter seo_title, meta_title or title (the first one set), else <title>, else the first # H130 to 60 charactersunder 30over 60, or missing
DescriptionFrontmatter seo_description, meta_description, description or excerpt, else <meta name="description">70 to 160 charactersunder 70over 160, or missing
H1# lines and <h1> tags in the body, outside code blocksone, or none when the frontmatter has a title (templates usually render it as the H1)more than one, or none at all
Images without alt and <img> without altany
Wordsthe body, without codeshown, not judged
Commands
  • /meta-lint shows the details of the last page: each value, where it came from, how far over or under it is, and a search snippet preview cut where Google would likely cut it
  • /meta-lint path/to/file.md checks any file, edited or not

Limits are in characters. Google cuts titles by pixel width (about 600 px), so a title of wide letters can be cut sooner: treat the numbers as a guide.

🔗 link-check

Checks every external link Claude adds to a page, post or template, while Claude is still working.

◆ Links  2 not working · https://gone.dev/page (404) in post.md and 1 more · /links
Install
claude plugin marketplace add krysmalskipl/claude-code-mods   # once per machine
claude plugin install link-check@krysmalskipl

Restart Claude Code or type /reload-plugins. No key, no setup.

How it works
  • After each Write or Edit of a .md, .mdx, .astro, .html, .tsx, .jsx, .vue, .svelte, .php, .njk, .liquid or .twig file, it finds the http(s) links new in that edit and asks each one (a HEAD, or a GET when the host refuses HEAD)
  • Claude is told: links that answer 4xx or 5xx, or don't answer at all, go back to Claude with the edit's result, so it can fix or remove them in the same turn
  • You see them above the prompt until they're gone from the file. /links lists them
  • An edit waits at most 6 seconds for its links. A slower host's answer comes later as a toast
  • 401, 403 and 429 count as working: they're usually bot walls or rate limits, and the page opens in a browser
  • Skipped: localhost, 127.0.0.1, example.com, .local, .test and links with template placeholders (${…}, {{…}})
  • Each URL is asked once per session, at most 25 per edit and 6 at a time. Requests go from your machine straight to each link's host, nowhere else
Commands
  • /links lists the links that don't work, and where
  • /links path/to/file.md checks every link in a file now (up to 100), with ✓ working, ↪ redirect, ~ blocks bots and ✗ broken

🪧 share-preview

See the page you're building the way people will meet it: as a Google result and as a link someone shares. A pane next to the chat, updated while Claude edits.

✗ og:image "/og.png" is relative: platforms need a full https:// URL
! Title is 668px wide, Google shows about 600px: the end is cut
! No twitter:card: X shows a small thumbnail, add summary_large_image for the big card
✓ Description fits desktop and mobile (512px)
Install
claude plugin marketplace add krysmalskipl/claude-code-mods   # once per machine
claude plugin install share-preview@krysmalskipl

Restart Claude Code or type /reload-plugins. No key, no setup.

Use it
  • /preview shows the page Claude edited last: HTML, Astro, Vue, Svelte, JSX with <Head>, a Next.js metadata export or Markdown frontmatter. The pane follows every later edit of that file
  • /preview localhost:3000/pricing reads the page your dev server renders, the most accurate view. It refreshes after each of Claude's turns
  • /preview https://example.com checks a live page
  • In the pane: g Google, s shared link, r refresh
What it shows
  • Google: the desktop and mobile result, the title cut at about 600 px and the description at about 920 px (desktop) and 680 px (mobile), measured in Arial glyph widths the way Google draws them, not in characters
  • Shared link: the LinkedIn and Facebook card, Slack's unfurl and X's card (large, small or none, from twitter:card), with the fallbacks each platform uses when a tag is missing
  • Checks, worst first: noindex, a missing title, a missing or relative or local og:image, an og:image that doesn't load (it asks the URL, or looks in public/ and static/ for a local file), a canonical pointing at localhost, titles and descriptions too long or too short, a missing twitter:card, og:image:alt or <html lang>
  • Tags set in code ({post.title}, generateMetadata) are marked as such: /preview <url> reads them rendered

The findings also go to Claude as context, so "fix these" just works. Pages and images are fetched from your machine; nothing is sent anywhere else.

Google publishes no snippet limits: the pixel widths are the ones SEO tools have measured for years. Treat them as a close guide, not a promise.

⏱️ cache-stats

Claude Code resends the whole conversation with every request, and the prompt cache is what keeps that cheap and fast. This shows what the cache is doing, live.

◆ Cache  █████████░ 96% hit · warm 58m · 133k cached · session 1h 12m (23m working) · avg 94% · 38 requests
◆ Cache  ░░░░░░░░░░ 0% hit · cold, next message re-caches 133k · session 2h 05m (31m working)
Install
claude plugin marketplace add krysmalskipl/claude-code-mods   # once per machine
claude plugin install cache-stats@krysmalskipl

Restart Claude Code or type /reload-plugins. The line appears after Claude's first reply. It reads Claude Code's own usage figures and sends nothing anywhere.

Set the cache lifetime

The hit rate is measured, but the warm countdown needs to know how long your cache lives: 1h on a Claude subscription (the default) or 5m on an API key. On an API key, set it in /config → cache-stats, or from a shell:

echo '{"ttl":"5m"}' | claude plugin configure cache-stats@krysmalskipl --values-stdin

Not sure which you have? Look at your session transcript: ephemeral_1h_input_tokens in the usage rows means a 1h cache.

What the line shows
  • Hit rate of the last request: how much of the prompt the cache served. Green from 80%, yellow from 50%, red under
  • Warm 3m: time left before the cache lapses, counted from the last reply. Once it has, the line says what the next message re-caches
  • Cached: tokens in the cache now, the size of the last prompt
  • Session time since the session started, and how much of it the model spent working
  • avg and requests: the hit rate over the whole session and the number of requests to the model

On a narrow terminal the right-hand parts drop out first.

Commands
  • /cache shows the details: state, session totals (read from cache, written, sent uncached, generated), every miss with the reason, and the last requests
  • /cache hide and /cache show take the line away and bring it back

A miss is a large prompt (5,000+ tokens) the cache mostly did not serve. The reason is told by the gap before it: idle longer than the cache lives means it simply expired; soon after the last request means something under the cache changed, such as /model, /compact, an edited CLAUDE.md or a new MCP tool. That second kind is the one worth hunting down, since each costs a full re-cache.

Subagents run on caches of their own and are not counted.

🪙 openrouter-balance

openrouter-balance: OpenRouter key limit and today's spend above the Claude Code prompt

Your OpenRouter credit in one line above the prompt, refreshed every 5 minutes and after each turn.

1. Check your Claude Code version
claude --version

You need 2.1.287 or newer. Older? Update with claude update (or npm install -g @anthropic-ai/claude-code). In the desktop app, type /status in a Code tab session.

2. Add this marketplace

Once per machine, in a terminal:

claude plugin marketplace add krysmalskipl/claude-code-mods

Or inside a Claude Code session: /plugin marketplace add krysmalskipl/claude-code-mods.

3. Install the mod
claude plugin install openrouter-balance@krysmalskipl

Or inside a session: /plugin install openrouter-balance@krysmalskipl.

It ends with "1 userConfig option not yet set". That's the optional key from step 4. If your key is already in your environment or Keychain, ignore it.

4. Give it an OpenRouter key

Pick the kind of key first:

KeyWhat the line showsWhere to make one
Management key (recommended)The account's real balance, today's spend and the model the money mostly went toopenrouter.ai/settings/management-keys. It can't call models, so it's safe to keep around
Regular API keyOnly that key's own spend today and this month, and what its limit leavesopenrouter.ai/settings/keys. The OPENROUTER_API_KEY you already use works

Then put it in one place. The mod looks in this order and takes the first it finds:

  1. /config → openrouter-balance in an interactive claude terminal session, or from a shell:
   echo '{"apiKey":"sk-or-v1-..."}' | claude plugin configure openrouter-balance@krysmalskipl --values-stdin

That line lands in your shell history; the Keychain (3) keeps the key out of it.

  1. An environment variable: OPENROUTER_MANAGEMENT_KEY for a management key, OPENROUTER_API_KEY for a regular one. Add it to ~/.zshrc (or your shell's startup file):
   export OPENROUTER_MANAGEMENT_KEY="sk-or-v1-..."
  1. The macOS Keychain (no key in any file). Paste the key when asked:
   security add-generic-password -a "$USER" -s openrouter-key -w
  1. Your login shell: when Claude Code doesn't see the variables itself (the desktop app started from the Dock never reads ~/.zshrc), the mod asks your shell for OPENROUTER_MANAGEMENT_KEY and OPENROUTER_API_KEY. So a key exported in ~/.zshrc works there too.

The key is kept in memory only and sent only to openrouter.ai.

5. Restart and check

Start a new Claude Code session (or type /reload-plugins in an open one). Within a few seconds a line appears above the prompt:

◆ OpenRouter  █████████▁ $2.67 of $3.00 key limit left · $0.33 today

Type /openrouter-balance for the breakdown: balance, today, yesterday, last 30 days and spend, requests and tokens by model. With a regular key: today, this week, this month, all time, its limit and budget.

Update or remove
claude plugin marketplace update krysmalskipl
claude plugin update openrouter-balance@krysmalskipl

claude plugin uninstall openrouter-balance@krysmalskipl
If something's off
The line saysWhyFix
Nothing at allThe mod isn't loaded yetStart a new session, or /reload-plugins. Check claude plugin list shows it enabled
no key setNo key in any of the four placesStep 4. In the desktop app, the Keychain is the surest place
key rejectedThe key was revoked or disabledMake a new one, put it where the old one was
can't reach OpenRouter yetNo network, or OpenRouter is downIt retries every minute by itself
$0.42 today · $12.30 this month and no balanceA regular key with no limit: it can't see the accountUse a management key for the balance, or set a limit on the key
… · offlineThe last refresh failedThe numbers are the last good ones; it retries every minute
How it counts
  • Green from $5, yellow under $5, red under $2
  • "Today" is the UTC day. With a management key it's the usage now minus where it stood when the mod first saw the day; a day first seen after 00:15 UTC shows "since HH:MM" (UTC)
  • Spend by model comes from OpenRouter's /activity, which holds completed UTC days only

Develop

claude plugin validate mods/<name>
CLAUDE_CONFIG_DIR=$(mktemp -d) claude plugin test mods/<name>

More mods: hamzafer/claude-code-mods.

License

MIT

Source 2 files
hooks/register.tsx 348 lines
1// OpenRouter Balance: your OpenRouter credit, in a band above the prompt.
2// A management key sees the account: the real balance from /credits, and spend by model from /activity.
3// A regular API key sees only itself: its spend today and this month, and what its limit leaves.
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 = 5 * 60 * 1000 // the balance is live, but nobody needs it by the second
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 ACTIVITY_MS = 60 * 60 * 1000 // /activity holds completed days only, so once an hour is plenty
14const GRACE_S = 15 * 60 // a day first seen this soon after midnight UTC counts as seen from its start
15const KEYCHAIN_SERVICE = 'openrouter-key'
16const API = 'https://openrouter.ai/api/v1'
17const DAY_S = 86_400
18const LOW = 2 // dollars: red below this
19const MID = 5 // dollars: yellow below this
20const BRAND = '#8b9cff' // periwinkle: apart from the green gauge and openai-balance's violet
21const GAUGE = 10
22const EMPTY: Line = { kind: null, left: null, start: null, today: null, month: null, since: null, top: null, error: null, isLoaded: false, hasData: false }
23const SETUP =
24  'Needs an OpenRouter key. A **management key** (openrouter.ai → Settings → Management keys) shows the account balance and spend by model; a regular API key shows its own spend and limit. Put it in `/config` → openrouter-balance, or in `OPENROUTER_MANAGEMENT_KEY` or `OPENROUTER_API_KEY`, or on macOS in Keychain: `security add-generic-password -a "$USER" -s openrouter-key -w`.'
25
26// The account's all-time usage when this UTC day was first seen, so today's spend is the difference.
27type Mark = { day: number; usage: number; at: number }
28type Activity = { date: string; model: string; usage: number; requests: number; prompt_tokens: number; completion_tokens: number }
29
30// Held by the host, so the line survives a hot reload of this file.
31const line = atom({ plugin: 'openrouter-balance', key: 'line' } as const, EMPTY)
32
33let timers: Timer[] = [] // restarted on each session.start
34let configKey = ''
35let key = '' // kept in memory only, never in state, the store or a message
36let nextTry = 0
37let generation = 0 // a newer refresh wins; an older one that ends later is dropped
38let running: { since: number } | null = null
39let activityCache: { at: number; items: Promise<Activity[]> } | null = null
40let lastKey = '' // the key the cached numbers came from, in memory only
41
42class NoKey extends Error {}
43class KeyRejected extends Error {}
44class HttpError extends Error {
45  constructor(readonly status: number) {
46    super(`OpenRouter answered ${status}`)
47  }
48}
49
50export const register: Register = (on, options) => {
51  configKey = typeof options.apiKey === 'string' ? options.apiKey.trim() : ''
52  key = '' // a key changed in /config is read again
53
54  on('session.start', async ($, e, next) => {
55    const result = await next(e)
56    await $.command
57      .register({ name: 'openrouter-balance', description: 'OpenRouter credit, spend today and spend by model' })
58      .catch(() => {}) // a name Claude Code already has is refused: start anyway
59    void refresh($, true).catch(() => {}) // in the background: a slow API never holds up the session
60    for (const t of timers) t.cancel()
61    timers = [$.clock.every(TICK_MS, () => void refresh($, false).catch(() => {}))]
62    return result
63  })
64
65  on('turn.complete', async ($, e, next) => {
66    const result = await next(e)
67    if (!e.agentId) void refresh($, false).catch(() => {}) // main-loop turns only, not subagents
68    return result
69  })
70
71  on('command.run', { command: 'openrouter-balance' }, async $ => {
72    try {
73      return { text: await detail($) }
74    } catch (err) {
75      return { text: problem(err) }
76    }
77  })
78
79  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
80    const rest = await next(e) // what other mods and Claude Code draw here stays
81    const now = { ...EMPTY, ...(await read($, line)) } // a value an older version saved may lack a field
82    if (e.props.hasSurvey || !now.isLoaded) return rest
83
84    const { Box, Text } = $.ui.resolve(e)
85    const isWide = e.props.bodyColumns >= 60
86    const sep = <Text dimColor>{' · '}</Text>
87    const head = <Text color={BRAND} bold>{'◆ OpenRouter  '}</Text>
88    const today = now.today !== null && (
89      <Text>
90        <Text bold>{usd(now.today)}</Text>
91        <Text dimColor>{now.since !== null ? ` since ${clock(now.since)}` : ' today'}</Text>
92      </Text>
93    )
94
95    let body
96    if (now.error === 'no-key') body = <Text color="red">no key set, see /openrouter-balance</Text>
97    else if (now.error === 'rejected') body = <Text color="red">key rejected, see /openrouter-balance</Text>
98    else if (!now.hasData) body = <Text dimColor>{"can't reach OpenRouter yet"}</Text>
99    else if (now.left === null) {
100      // A regular key with no limit: its spend is all there is to show.
101      body = (
102        <Text>
103          {today}
104          {now.month !== null && sep}
105          {now.month !== null && <Text bold>{usd(now.month)}</Text>}
106          {now.month !== null && <Text dimColor>{' this month'}</Text>}
107          {isWide && sep}
108          {isWide && <Text dimColor>{'a management key shows the balance'}</Text>}
109          {now.error === 'offline' && <Text dimColor>{' · offline'}</Text>}
110        </Text>
111      )
112    } else {
113      const tone = now.left < LOW ? 'red' : now.left < MID ? 'yellow' : 'green'
114      const filled = now.start !== null && now.start > 0 ? Math.max(0, Math.min(GAUGE, Math.round((now.left / now.start) * GAUGE))) : 0
115      const of = now.start !== null ? ` of ${usd(now.start)}${now.kind === 'key' ? ' key limit' : ''}` : ''
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>{of}{now.left < LOW ? ' left, top up soon' : ' left'}</Text>
122          {today && sep}
123          {today}
124          {isWide && now.top && <Text dimColor>{', mostly '}</Text>}
125          {isWide && now.top && <Text color="cyan">{short(now.top)}</Text>}
126          {now.error === 'offline' && <Text dimColor>{' · offline'}</Text>}
127        </Text>
128      )
129    }
130
131    return (
132      <Box flexDirection="column">
133        <Box paddingX={1}>
134          <Text wrap="truncate-end">
135            {head}
136            {body}
137          </Text>
138        </Box>
139        {rest}
140      </Box>
141    )
142  })
143}
144
145// Updates what the band draws. Every 5 minutes, a minute after a failure, or now when forced.
146async function refresh($: EngineInterface, isForced: boolean) {
147  const now = await $.clock.now()
148  if (!isForced && (now < nextTry || (running && now - running.since < STUCK_MS))) return
149  const mine = ++generation
150  running = { since: now }
151  try {
152    const next = await snapshot($, now)
153    if (mine !== generation) return
154    nextTry = now + EVERY_MS
155    await update($, line, () => next)
156  } catch (err) {
157    if (mine !== generation) return
158    nextTry = now + RETRY_MS // a fixed key or a network back shows within a minute
159    const error: Line['error'] = err instanceof NoKey ? 'no-key' : err instanceof KeyRejected ? 'rejected' : 'offline'
160    await update($, line, l => ({ ...EMPTY, ...l, error, isLoaded: true })) // offline keeps the last numbers, marked
161  } finally {
162    if (mine === generation) running = null
163  }
164}
165
166async function snapshot($: EngineInterface, now: number): Promise<Line> {
167  const info = await keyInfo($)
168  const loaded = { ...EMPTY, isLoaded: true, hasData: true }
169  if (!isManagement(info)) {
170    return { ...loaded, kind: 'key', left: num(info.limit_remaining), start: num(info.limit), today: num(info.usage_daily), month: num(info.usage_monthly) }
171  }
172  const credits = await account($)
173  const mark = await dayMark($, Math.floor(now / 1000), credits.total_usage)
174  const items = await activity($, now).catch(() => []) // a hiccup here never hides the balance
175  return {
176    ...loaded,
177    kind: 'account',
178    left: credits.total_credits - credits.total_usage,
179    start: credits.total_credits,
180    today: Math.max(0, credits.total_usage - mark.usage),
181    since: mark.at - mark.day > GRACE_S ? mark.at : null,
182    top: byModel(items)[0]?.model ?? null,
183  }
184}
185
186// The first all-time usage seen this UTC day. A day first seen late says "since" instead of "today".
187async function dayMark($: EngineInterface, nowS: number, usage: number): Promise<Mark> {
188  const day = dayStart(nowS)
189  const saved = (await $.store.get('mark')) as Mark | undefined
190  if (saved && saved.day === day && typeof saved.usage === 'number') return saved
191  const mark = { day, usage, at: nowS }
192  await $.store.set('mark', mark)
193  return mark
194}
195
196async function detail($: EngineInterface) {
197  const now = await $.clock.now()
198  const info = await keyInfo($)
199  const lines = ['## OpenRouter balance', '']
200
201  if (!isManagement(info)) {
202    lines.push(
203      `A regular API key: it sees only its own spend. A management key shows the account balance.`,
204      '',
205      `- **Today (UTC):** ${usd(num(info.usage_daily) ?? 0)}`,
206      `- **This week:** ${usd(num(info.usage_weekly) ?? 0)}`,
207      `- **This month:** ${usd(num(info.usage_monthly) ?? 0)}`,
208      `- **All time:** ${usd(num(info.usage) ?? 0)}`,
209    )
210    if (num(info.limit) !== null) {
211      const reset = info.limit_reset ? `, resets ${info.limit_reset}` : ''
212      lines.push(`- **Key limit:** ${usd(num(info.limit_remaining) ?? 0)} left of ${usd(info.limit)}${reset}`)
213    } else lines.push('- **Key limit:** none')
214    const b = info.effective_budget
215    if (b && typeof b.limit_usd === 'number') lines.push(`- **Budget:** ${usd(b.remaining_usd ?? 0)} left of ${usd(b.limit_usd)}${b.reset_interval ? ` ${b.reset_interval}` : ''}`)
216    const free = info.free_model_daily_requests
217    if (info.is_free_tier && free) lines.push(`- **Free model requests today:** ${free.remaining} of ${free.limit} left`)
218    return lines.join('\n')
219  }
220
221  const credits = await account($)
222  const mark = await dayMark($, Math.floor(now / 1000), credits.total_usage)
223  const items = await activity($, now)
224  const yesterday = new Date((dayStart(Math.floor(now / 1000)) - DAY_S) * 1000).toISOString().slice(0, 10)
225  const today = Math.max(0, credits.total_usage - mark.usage)
226  lines.push(
227    `- **Balance:** ${usd(credits.total_credits - credits.total_usage)} (${usd(credits.total_credits)} bought, ${usd(credits.total_usage)} used)`,
228    `- **Today (UTC):** ${usd(today)}${mark.at - mark.day > GRACE_S ? ` since ${clock(mark.at)} UTC, when this mod first saw the day` : ''}`,
229    `- **Yesterday:** ${usd(sum(items.filter(i => i.date === yesterday)))}`,
230    `- **Last 30 days:** ${usd(sum(items))}`,
231    '',
232    '**By model, last 30 days**',
233  )
234  const models = byModel(items)
235  if (models.length === 0) lines.push('- none')
236  for (const m of models) lines.push(`- ${m.model}: ${usd4(m.usage)}, ${int(m.requests)} requests, ${int(m.input)} in / ${int(m.output)} out`)
237  lines.push('', '_Spend by model counts completed UTC days only, as OpenRouter reports it. Today is the all-time usage now minus where it stood when the day began._')
238  return lines.join('\n')
239}
240
241// The last 30 days' spend per model, biggest first.
242function byModel(items: Activity[]) {
243  const map = new Map<string, { model: string; usage: number; requests: number; input: number; output: number }>()
244  for (const i of items) {
245    const m = map.get(i.model) ?? { model: i.model, usage: 0, requests: 0, input: 0, output: 0 }
246    m.usage += Number(i.usage) || 0
247    m.requests += Number(i.requests) || 0
248    m.input += Number(i.prompt_tokens) || 0
249    m.output += Number(i.completion_tokens) || 0
250    map.set(i.model, m)
251  }
252  return [...map.values()].filter(m => m.usage > 0).sort((a, b) => b.usage - a.usage)
253}
254
255const sum = (items: Activity[]) => items.reduce((s, i) => s + (Number(i.usage) || 0), 0)
256
257const keyInfo = async ($: EngineInterface) => (await call($, 'key')).data ?? {}
258const account = async ($: EngineInterface) => {
259  const d = (await call($, 'credits')).data ?? {}
260  const total_credits = num(d.total_credits)
261  const total_usage = num(d.total_usage)
262  if (total_credits === null || total_usage === null) throw new Error('OpenRouter sent no credit numbers')
263  return { total_credits, total_usage }
264}
265const isManagement = (info: any) => info.is_management_key === true || info.is_provisioning_key === true
266
267// One lookup shared by concurrent callers, kept an hour. A failed one is tried again next time.
268async function activity($: EngineInterface, now: number): Promise<Activity[]> {
269  if (!activityCache || now - activityCache.at > ACTIVITY_MS) {
270    const entry = {
271      at: now,
272      items: call($, 'activity').then(
273        d => (Array.isArray(d.data) ? d.data : []),
274        err => {
275          if (activityCache === entry) activityCache = null
276          throw err
277        },
278      ),
279    }
280    activityCache = entry
281  }
282  return activityCache.items
283}
284
285async function call($: EngineInterface, path: string) {
286  const res = await $.http.fetch(`${API}/${path}`, { headers: { authorization: `Bearer ${await apiKey($)}` } })
287  if (res.status === 401 || res.status === 403) {
288    key = '' // read it again next time, in case it was replaced
289    throw new KeyRejected()
290  }
291  if (!res.ok) throw new HttpError(res.status)
292  return JSON.parse(res.text)
293}
294
295// From /config first, then OPENROUTER_MANAGEMENT_KEY, OPENROUTER_API_KEY, then the macOS Keychain, and last the
296// same two variables as your login shell sets them: an app started from the Dock never read ~/.zshrc.
297async function apiKey($: EngineInterface) {
298  if (key) return key
299  const run = async (argv: string[], timeoutMs?: number) => {
300    const r = await $.process.run(argv, { timeoutMs }).catch(() => null)
301    return r && r.exitCode === 0 ? r.stdout.trim() : ''
302  }
303  const k =
304    configKey ||
305    (await run(['printenv', 'OPENROUTER_MANAGEMENT_KEY'])) ||
306    (await run(['printenv', 'OPENROUTER_API_KEY'])) ||
307    (await run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'])) ||
308    fromShell(await run([(await run(['printenv', 'SHELL'])) || '/bin/zsh', '-ilc', SHELL_PRINT], SHELL_MS))
309  if (!k.startsWith('sk-or-')) throw new NoKey()
310  if (lastKey && k !== lastKey) {
311    activityCache = null // another key may be another account: drop its numbers
312    await $.store.set('mark', null).catch(() => {}) // and its start-of-day mark
313  }
314  lastKey = k
315  return (key = k)
316}
317
318// Each variable on its own marked line, so whatever the shell's startup files print is skipped.
319const SHELL_PRINT = 'printf "\\n@or@%s\\n@or@%s\\n" "$OPENROUTER_MANAGEMENT_KEY" "$OPENROUTER_API_KEY"'
320const SHELL_MS = 10_000 // a slow ~/.zshrc must not hold the line up for long
321const fromShell = (out: string) =>
322  out
323    .split('\n')
324    .filter(l => l.startsWith('@or@sk-or-'))
325    .map(l => l.slice(4).trim())[0] ?? ''
326
327function problem(err: unknown) {
328  if (err instanceof NoKey) return `No OpenRouter key found. ${SETUP}`
329  if (err instanceof KeyRejected) return `OpenRouter refused the key (401/403): it may be revoked or disabled. ${SETUP}`
330  return `Couldn't reach OpenRouter: ${err instanceof Error ? err.message : String(err)}`
331}
332
333// A model id without its provider or variant: "anthropic/claude-sonnet-4.5" → "claude-sonnet-4.5",
334// "meta-llama/llama-3.3-70b-instruct:free" → "llama-3.3-70b-instruct".
335const short = (model: string) => model.split('/').pop()!.split(':')[0]
336const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
337const dayStart = (s: number) => s - (s % DAY_S)
338const usd = (n: number) => {
339  const cents = Math.round(n * 100)
340  return `${cents < 0 ? '-' : ''}$${(Math.abs(cents) / 100).toFixed(2)}`
341}
342const usd4 = (n: number) => `$${n.toFixed(4)}`
343const int = (n: number) => Math.round(n).toLocaleString('en-US')
344const clock = (s: number) => {
345  const d = new Date(s * 1000)
346  return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
347}
348
types/index.d.ts 20 lines
1// What the band above the prompt draws.
2export type Line = {
3  kind: 'account' | 'key' | null // a management key sees the account; a regular key only itself
4  left: number | null // account credit, or what the key's limit leaves; null when neither is known
5  start: number | null // credits bought, or the key's limit, for the gauge
6  today: number | null // spend in the current UTC day, null when it can't be told yet
7  month: number | null // the key's spend this UTC month (regular key only)
8  since: number | null // unix seconds: "today" counts from here when the mod first saw the day after it began
9  top: string | null // the model the last 30 days' money mostly went to (management key only)
10  error: 'no-key' | 'rejected' | 'offline' | null // offline keeps the last numbers, marked
11  isLoaded: boolean
12  hasData: boolean // a refresh has succeeded at least once
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'openrouter-balance': { line: Line }
18  }
19}
20