diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1da902d..d27e4d7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -146,7 +146,7 @@ { "name": "openrouter-balance", "description": "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.", - "version": "0.1.0", + "version": "0.1.1", "source": "./mods/openrouter-balance" } ] diff --git a/README.md b/README.md index 743295a..bf08aca 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ claude --plugin-dir mods/context-bar | โžก๏ธ | **next-steps** | 2 or 3 likely next prompts after each turn, one key to draft one. `language` sets their language | `1` `2` `3`, `0` hides | | ๐Ÿ’ฐ | **usage-meter** | 5-hour and 7-day plan usage, the reset countdown and the session's cost | | | ๐Ÿ’ณ | **openai-balance** | Your 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` | -| ๐Ÿช™ | **openrouter-balance** | Your 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` | +| ๐Ÿช™ | **openrouter-balance** | Your OpenRouter credit: the real balance with a gauge, today's spend and the model the money mostly went to. A regular API key shows the balance too when OpenRouter allows it, plus its own spend and limit | `/openrouter-balance` | | ๐Ÿ“ก | **agent-radar** | One live line per running subagent | `/radar` | | ๐Ÿ” | **review-watch** | One 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-lanes** | Whether this session has a browser, and who holds it | `/browser` | diff --git a/docs/mods.md b/docs/mods.md index 10d3045..f4d9d42 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -139,11 +139,11 @@ Shows your OpenRouter credit above the Claude Code prompt. ![openrouter-balance: OpenRouter credit with a gauge and today's spend above the Claude Code prompt](../images/openrouter-balance.png) - One line: the balance with a gauge, today's spend, and the model the last 30 days' money mostly went to -- **The balance is real**, not an estimate: OpenRouter's `/credits` reports credits bought and used. That needs a **management key** (openrouter.ai โ†’ Settings โ†’ Management keys). It can't make model calls, so it's safe to keep around -- **A regular API key** works too, but sees only itself: its spend today and this month, and what its limit leaves when it has one +- **The balance is real**, not an estimate: OpenRouter's `/credits` reports credits bought and used. OpenRouter documents it for a **management key** (openrouter.ai โ†’ Settings โ†’ Management keys), which can't make model calls, so it's safe to keep around +- **A regular API key** works too. It shows the account balance when OpenRouter answers `/credits` for it, and else its own spend today and this month and what its limit leaves. Spend by model needs a management key. When the key's own limit would run out before the account's credit, the line shows the key limit - Green from $5, yellow under $5, red under $2 - `/openrouter-balance` shows 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 -- "Today" is the UTC day. OpenRouter reports spend by model for completed days only, so today with a management key is the usage now minus where it stood when the mod first saw the day. A day first seen later than 00:15 UTC shows "since HH:MM" instead of "today" +- "Today" is the UTC day. OpenRouter reports spend by model for completed days only, so today with a management key is the usage now minus where it stood when the mod first saw the day. A day first seen later than 00:15 UTC shows "since HH:MM" (your local time) instead of "today" - **Key:** from the key in `/config`, else `OPENROUTER_MANAGEMENT_KEY`, else `OPENROUTER_API_KEY`, else the macOS Keychain item `openrouter-key`. Kept in memory only - Refreshes every 5 minutes and after each turn; a minute after a failure. A failed refresh keeps the last numbers, marked offline diff --git a/mods/openrouter-balance/.claude-plugin/plugin.json b/mods/openrouter-balance/.claude-plugin/plugin.json index 42e143a..87198a0 100644 --- a/mods/openrouter-balance/.claude-plugin/plugin.json +++ b/mods/openrouter-balance/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "openrouter-balance", - "version": "0.1.0", + "version": "0.1.1", "description": "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.", "author": { "name": "Jakub Krysmalski", diff --git a/mods/openrouter-balance/hooks/register.tsx b/mods/openrouter-balance/hooks/register.tsx index 8c4d872..b1a1702 100644 --- a/mods/openrouter-balance/hooks/register.tsx +++ b/mods/openrouter-balance/hooks/register.tsx @@ -1,6 +1,6 @@ // OpenRouter Balance: your OpenRouter credit, in a band above the prompt. // A management key sees the account: the real balance from /credits, and spend by model from /activity. -// A regular API key sees only itself: its spend today and this month, and what its limit leaves. +// A regular API key sees the balance too, when OpenRouter lets it, and its own spend and limit. import { atom, read, update } from 'claude-code' import type { EngineInterface, Register, Timer } from 'claude-code' @@ -111,8 +111,8 @@ export const register: Register = (on, options) => { ) } else { const tone = now.left < LOW ? 'red' : now.left < MID ? 'yellow' : 'green' - const filled = now.start !== null && now.start > 0 ? Math.max(0, Math.min(GAUGE, Math.round((now.left / now.start) * GAUGE))) : 0 - const of = now.start !== null ? ` of ${usd(now.start)}${now.kind === 'key' ? ' key limit' : ''}` : '' + const filled = now.start !== null && now.start > 0 ? Math.max(now.left > 0 ? 1 : 0, Math.min(GAUGE, Math.round((now.left / now.start) * GAUGE))) : 0 // any credit left shows a block + const of = now.start !== null ? ` of ${usd(now.start)}${now.kind === 'key' ? ' key limit' : ' credit'}` : '' // which one binds: the key's limit or the account's credit body = ( {isWide && {'โ–ˆ'.repeat(filled)}} @@ -166,12 +166,17 @@ async function refresh($: EngineInterface, isForced: boolean) { async function snapshot($: EngineInterface, now: number): Promise { const info = await keyInfo($) const loaded = { ...EMPTY, isLoaded: true, hasData: true } - if (!isManagement(info)) { + const isMgmt = isManagement(info) + const credits = isMgmt ? await account($) : await account($, true) // a regular key often sees the balance too + if (!credits) { return { ...loaded, kind: 'key', left: num(info.limit_remaining), start: num(info.limit), today: num(info.usage_daily), month: num(info.usage_monthly) } } - const credits = await account($) + const keyLeft = num(info.limit_remaining) + if (!isMgmt && keyLeft !== null && keyLeft < credits.total_credits - credits.total_usage) { + return { ...loaded, kind: 'key', left: keyLeft, start: num(info.limit), today: num(info.usage_daily), month: num(info.usage_monthly) } // the key's limit runs out first + } const mark = await dayMark($, Math.floor(now / 1000), credits.total_usage) - const items = await activity($, now).catch(() => []) // a hiccup here never hides the balance + const items = isMgmt ? await activity($, now).catch(() => []) : [] // a hiccup here never hides the balance; a regular key is refused return { ...loaded, kind: 'account', @@ -199,8 +204,17 @@ async function detail($: EngineInterface) { const lines = ['## OpenRouter balance', ''] if (!isManagement(info)) { + const credits = await account($, true) + if (credits) { + const mark = await dayMark($, Math.floor(now / 1000), credits.total_usage) // the same "today" the band shows + lines.push( + `- **Account balance:** ${usd(credits.total_credits - credits.total_usage)} (${usd(credits.total_credits)} bought, ${usd(credits.total_usage)} used)`, + `- **Account spend today (UTC):** ${usd(Math.max(0, credits.total_usage - mark.usage))}${mark.at - mark.day > GRACE_S ? ` since ${clock(mark.at)} your time, when this mod first saw the day` : ''}`, + '', + ) + } lines.push( - `A regular API key: it sees only its own spend. A management key shows the account balance.`, + `This key's own spend (a regular API key; a management key shows spend by model):`, '', `- **Today (UTC):** ${usd(num(info.usage_daily) ?? 0)}`, `- **This week:** ${usd(num(info.usage_weekly) ?? 0)}`, @@ -212,7 +226,7 @@ async function detail($: EngineInterface) { lines.push(`- **Key limit:** ${usd(num(info.limit_remaining) ?? 0)} left of ${usd(info.limit)}${reset}`) } else lines.push('- **Key limit:** none') const b = info.effective_budget - 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}` : ''}`) + if (b && typeof b.limit_usd === 'number' && b.scope !== 'key_limit') lines.push(`- **Budget:** ${usd(b.remaining_usd ?? 0)} left of ${usd(b.limit_usd)}${b.reset_interval ? ` ${b.reset_interval}` : ''}`) const free = info.free_model_daily_requests if (info.is_free_tier && free) lines.push(`- **Free model requests today:** ${free.remaining} of ${free.limit} left`) return lines.join('\n') @@ -225,7 +239,7 @@ async function detail($: EngineInterface) { const today = Math.max(0, credits.total_usage - mark.usage) lines.push( `- **Balance:** ${usd(credits.total_credits - credits.total_usage)} (${usd(credits.total_credits)} bought, ${usd(credits.total_usage)} used)`, - `- **Today (UTC):** ${usd(today)}${mark.at - mark.day > GRACE_S ? ` since ${clock(mark.at)} UTC, when this mod first saw the day` : ''}`, + `- **Today (UTC):** ${usd(today)}${mark.at - mark.day > GRACE_S ? ` since ${clock(mark.at)} your time, when this mod first saw the day` : ''}`, `- **Yesterday:** ${usd(sum(items.filter(i => i.date === yesterday)))}`, `- **Last 30 days:** ${usd(sum(items))}`, '', @@ -255,11 +269,17 @@ function byModel(items: Activity[]) { const sum = (items: Activity[]) => items.reduce((s, i) => s + (Number(i.usage) || 0), 0) const keyInfo = async ($: EngineInterface) => (await call($, 'key')).data ?? {} -const account = async ($: EngineInterface) => { - const d = (await call($, 'credits')).data ?? {} +// With isOptional, a refusal or an answer without numbers is null, not an error: the key's own numbers still show. +async function account($: EngineInterface, isOptional?: false): Promise<{ total_credits: number; total_usage: number }> +async function account($: EngineInterface, isOptional: true): Promise<{ total_credits: number; total_usage: number } | null> +async function account($: EngineInterface, isOptional = false) { + const d = (await call($, 'credits', isOptional))?.data ?? {} const total_credits = num(d.total_credits) const total_usage = num(d.total_usage) - if (total_credits === null || total_usage === null) throw new Error('OpenRouter sent no credit numbers') + if (total_credits === null || total_usage === null) { + if (isOptional) return null + throw new Error('OpenRouter sent no credit numbers') + } return { total_credits, total_usage } } const isManagement = (info: any) => info.is_management_key === true || info.is_provisioning_key === true @@ -282,14 +302,22 @@ async function activity($: EngineInterface, now: number): Promise { return activityCache.items } -async function call($: EngineInterface, path: string) { - const res = await $.http.fetch(`${API}/${path}`, { headers: { authorization: `Bearer ${await apiKey($)}` } }) +async function call($: EngineInterface, path: string, isOptional = false) { + const auth = { authorization: `Bearer ${await apiKey($)}` } + const res = isOptional ? await $.http.fetch(`${API}/${path}`, { headers: auth }).catch(() => null) : await $.http.fetch(`${API}/${path}`, { headers: auth }) + if (isOptional && (!res || !res.ok)) return null // refused or down: the key's own numbers still show, and the key is not rejected + if (!res) return null // for the type: only an optional fetch is ever null if (res.status === 401 || res.status === 403) { key = '' // read it again next time, in case it was replaced throw new KeyRejected() } if (!res.ok) throw new HttpError(res.status) - return JSON.parse(res.text) + try { + return JSON.parse(res.text) + } catch (err) { + if (isOptional) return null + throw err + } } // From /config first, then OPENROUTER_MANAGEMENT_KEY, OPENROUTER_API_KEY, then the macOS Keychain. @@ -332,5 +360,5 @@ const usd4 = (n: number) => `$${n.toFixed(4)}` const int = (n: number) => Math.round(n).toLocaleString('en-US') const clock = (s: number) => { const d = new Date(s * 1000) - return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}` + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` // your local time: the day itself is UTC } diff --git a/mods/openrouter-balance/tests/openrouter-balance.test.tsx b/mods/openrouter-balance/tests/openrouter-balance.test.tsx index 92ce788..5db8d17 100644 --- a/mods/openrouter-balance/tests/openrouter-balance.test.tsx +++ b/mods/openrouter-balance/tests/openrouter-balance.test.tsx @@ -15,7 +15,7 @@ const activity = [ ] // Stands for the engine, the key's sources and OpenRouter beneath the mod. -function engine(on: any, opts: { info?: object; credits?: () => { total_credits: number; total_usage: number }; keyFrom?: 'env' | 'keychain' | 'none'; status?: () => number }) { +function engine(on: any, opts: { info?: object; credits?: () => { total_credits: number; total_usage: number }; keyFrom?: 'env' | 'keychain' | 'none'; status?: () => number; creditsRefused?: boolean; creditsStatus?: number }) { const urls: string[] = [] const auths: string[] = [] const from = opts.keyFrom ?? 'keychain' @@ -43,6 +43,8 @@ function engine(on: any, opts: { info?: object; credits?: () => { total_credits: const status = opts.status?.() ?? 200 if (status !== 200) return fail(status) if (e.url.endsWith('/key')) return ok({ data: opts.info ?? regular }) + if (e.url.endsWith('/credits') && opts.creditsRefused) return fail(403) + if (e.url.endsWith('/credits') && opts.creditsStatus) return fail(opts.creditsStatus) if (e.url.endsWith('/credits')) return ok({ data: opts.credits?.() ?? { total_credits: 100, total_usage: 25 } }) if (e.url.endsWith('/activity')) return ok({ data: activity }) return fail(404) @@ -80,12 +82,12 @@ describe('openrouter-balance', () => { const h = engine(on, { info: management, credits: () => ({ total_credits: 100, total_usage: usage }) }) await start($) expect(h.auths[0]).toBe(`Bearer ${KEY}`) - expect(await band($)).toMatch(/โ–ˆ{8}โ–{2} \$75\.00 of \$100\.00 left ยท \$0\.00 today, mostly claude-sonnet-4\.5/) + expect(await band($)).toMatch(/โ–ˆ{8}โ–{2} \$75\.00 of \$100\.00 credit left ยท \$0\.00 today, mostly claude-sonnet-4\.5/) usage = 25.6 h.tick(6 * 60 * 1000) // past the 5-minute refresh await turn($) - expect(await band($)).toMatch(/\$74\.40 of \$100\.00 left ยท \$0\.60 today/) + expect(await band($)).toMatch(/\$74\.40 of \$100\.00 credit left ยท \$0\.60 today/) const out = await run($) expect(out.text).toMatch(/Balance:\*\* \$74\.40 \(\$100\.00 bought, \$25\.60 used\)/) @@ -99,16 +101,43 @@ describe('openrouter-balance', () => { const h = engine(on, { info: management }) h.tick(9 * 60 * 60 * 1000) // 09:05 UTC await start($) - expect(await band($)).toMatch(/\$0\.00 since 09:05/) // UTC, as the docs say, wherever the machine is - expect((await run($)).text).toMatch(/since 09:05 UTC/) + const d = new Date(NOW + 9 * 60 * 60 * 1000) // 09:05 UTC, shown in the machine's own time + const local = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + expect(await band($)).toContain(`$0.00 since ${local}`) + expect((await run($)).text).toContain(`since ${local} your time`) }) - test('a regular key without a limit shows its own spend and never asks for the balance', async ($, on) => { - const h = engine(on, { keyFrom: 'env' }) + test('a regular key that OpenRouter shows the balance to shows the account balance', async ($, on) => { + const h = engine(on, { keyFrom: 'env', credits: () => ({ total_credits: 5, total_usage: 4.87 }) }) + await start($) + expect(await band($)).toMatch(/\$0\.13 of \$5\.00 credit left, top up soon ยท \$0\.00 today/) + expect(h.urls.some(u => u.endsWith('/activity'))).toBe(false) // refused to a regular key: never asked + const out = await run($) + expect(out.text).toMatch(/Account balance:\*\* \$0\.13 \(\$5\.00 bought, \$4\.87 used\)/) + expect(out.text).toMatch(/Account spend today \(UTC\):\*\* \$0\.00/) // the band's today + expect(out.text).toMatch(/Today \(UTC\):\*\* \$0\.42/) // the key's own + }) + + test("a /credits outage never hides a regular key's own numbers", async ($, on) => { + engine(on, { keyFrom: 'env', creditsStatus: 500 }) + await start($) + expect(await band($)).toMatch(/\$0\.42 today ยท \$12\.30 this month/) + const out = await run($) + expect(out.text).toMatch(/This month:\*\* \$12\.30/) + }) + + test('a key limit tighter than the account balance is what the band shows', async ($, on) => { + engine(on, { info: { ...regular, limit: 10, limit_remaining: 1.5 }, credits: () => ({ total_credits: 100, total_usage: 20 }) }) + await start($) + expect(await band($)).toMatch(/\$1\.50 of \$10\.00 key limit left, top up soon/) + }) + + test('a regular key refused the balance shows its own spend, and the key still counts as good', async ($, on) => { + engine(on, { keyFrom: 'env', creditsRefused: true }) await start($) expect(await band($)).toMatch(/\$0\.42 today ยท \$12\.30 this month ยท a management key shows the balance/) - expect(h.urls.some(u => u.endsWith('/credits'))).toBe(false) const out = await run($) + expect(out.text).not.toMatch(/Account balance/) expect(out.text).toMatch(/This week:\*\* \$3\.10/) expect(out.text).toMatch(/Key limit:\*\* none/) expect(out.text).not.toContain('sk-or-') // the label is the key's start and end @@ -122,7 +151,7 @@ describe('openrouter-balance', () => { }) test('a regular key with a limit shows what the limit leaves, red when low', async ($, on) => { - engine(on, { info: { ...regular, limit: 10, limit_remaining: 1.5, limit_reset: 'monthly' } }) + engine(on, { info: { ...regular, limit: 10, limit_remaining: 1.5, limit_reset: 'monthly' }, creditsRefused: true }) await start($) expect(await band($)).toMatch(/\$1\.50 of \$10\.00 key limit left, top up soon ยท \$0\.42 today/) }) @@ -155,7 +184,7 @@ describe('openrouter-balance', () => { status = 500 h.tick(6 * 60 * 1000) await turn($) - expect(await band($)).toMatch(/\$75\.00 of \$100\.00 left.*offline/) + expect(await band($)).toMatch(/\$75\.00 of \$100\.00 credit left.*offline/) status = 200 h.tick(61 * 1000) // a failure retries after a minute, not five await turn($) diff --git a/site/mods/openrouter-balance/index.html b/site/mods/openrouter-balance/index.html index 90c32ba..9555974 100644 --- a/site/mods/openrouter-balance/index.html +++ b/site/mods/openrouter-balance/index.html @@ -25,7 +25,7 @@ - + @@ -43,7 +43,7 @@

openrouter-balance

Your OpenRouter credit above the prompt: the real balance with a gauge, today's spend and the model the money mostly went to. By @krysmalskipl.

-

See what's happening Version 0.1.0

+

See what's happening Version 0.1.1

openrouter-balance: $2.67 of a $3.00 key limit left with a gauge, $0.33 spent today
A regular API key: what its limit leaves and today's spend.
@@ -58,7 +58,7 @@

openrouter-balance

Restart Claude Code after installing. To try it without installing, clone the repo and run claude --plugin-dir mods/openrouter-balance.

How to use

-

Give it an OpenRouter key in /config. 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.

+

Give it an OpenRouter key in /config. A management key (openrouter.ai โ†’ Settings โ†’ Management keys) shows the account balance and spend by model. A regular API key shows the balance too when OpenRouter allows it, plus its own spend and limit.

Run /openrouter-balance for the full breakdown.

/openrouter-balance
@@ -67,10 +67,10 @@

openrouter-balance

How it behaves

    -
  • The balance is real, not an estimate: OpenRouter's /credits reports credits bought and used. That needs a management key, which can't make model calls.
  • -
  • A regular API key sees only itself: its spend today and this month, and what its limit leaves when it has one.
  • +
  • The balance is real, not an estimate: OpenRouter's /credits reports credits bought and used. OpenRouter documents it for a management key, which can't make model calls.
  • +
  • A regular API key shows the account balance when OpenRouter answers /credits for it, and else its own spend today and this month and what its limit leaves. Spend by model needs a management key.
  • Green from $5, yellow under $5, red under $2.
  • -
  • "Today" is the UTC day. A day first seen later than 00:15 UTC shows "since HH:MM" instead of "today".
  • +
  • "Today" is the UTC day. A day first seen later than 00:15 UTC shows "since HH:MM" (your local time) instead of "today".
  • The key comes from /config, else OPENROUTER_MANAGEMENT_KEY, else OPENROUTER_API_KEY, else the macOS Keychain item openrouter-key, and stays in memory.
  • Refreshes every 5 minutes and after each turn. A failed refresh keeps the last numbers, marked offline.