From 02ce5414114583512ac21a36d5944e665b2e43ea Mon Sep 17 00:00:00 2001 From: Hamza Zafar Date: Tue, 13 Oct 2026 08:36:13 +0200 Subject: [PATCH 1/2] openrouter-balance shows the balance with a regular key too OpenRouter answers /credits for regular keys as well, so the line shows the account balance whenever it can. A refusal falls back to the key's own spend and limit without calling the key rejected. The Budget line no longer repeats the key limit, and any credit left shows at least one gauge block. openrouter-balance 0.1.1. --- .claude-plugin/marketplace.json | 2 +- README.md | 2 +- docs/mods.md | 4 +-- .../.claude-plugin/plugin.json | 2 +- mods/openrouter-balance/hooks/register.tsx | 32 ++++++++++++------- .../tests/openrouter-balance.test.tsx | 21 +++++++++--- site/mods/openrouter-balance/index.html | 10 +++--- 7 files changed, 47 insertions(+), 26 deletions(-) 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..802a3a7 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -139,8 +139,8 @@ 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 - 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" 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..39740d7 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,7 +111,7 @@ 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 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' : ''}` : '' body = ( @@ -166,12 +166,13 @@ 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 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 +200,10 @@ async function detail($: EngineInterface) { const lines = ['## OpenRouter balance', ''] if (!isManagement(info)) { + const credits = await account($, true) + if (credits) lines.push(`- **Account balance:** ${usd(credits.total_credits - credits.total_usage)} (${usd(credits.total_credits)} bought, ${usd(credits.total_usage)} used)`, '') 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 +215,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') @@ -255,11 +258,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,8 +291,9 @@ async function activity($: EngineInterface, now: number): Promise { return activityCache.items } -async function call($: EngineInterface, path: string) { +async function call($: EngineInterface, path: string, isOptional = false) { const res = await $.http.fetch(`${API}/${path}`, { headers: { authorization: `Bearer ${await apiKey($)}` } }) + if (isOptional && (res.status === 401 || res.status === 403 || res.status === 404)) return null // not for this key: not a rejected key if (res.status === 401 || res.status === 403) { key = '' // read it again next time, in case it was replaced throw new KeyRejected() diff --git a/mods/openrouter-balance/tests/openrouter-balance.test.tsx b/mods/openrouter-balance/tests/openrouter-balance.test.tsx index 92ce788..79605aa 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 }) { const urls: string[] = [] const auths: string[] = [] const from = opts.keyFrom ?? 'keychain' @@ -43,6 +43,7 @@ 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')) return ok({ data: opts.credits?.() ?? { total_credits: 100, total_usage: 25 } }) if (e.url.endsWith('/activity')) return ok({ data: activity }) return fail(404) @@ -103,12 +104,22 @@ describe('openrouter-balance', () => { expect((await run($)).text).toMatch(/since 09:05 UTC/) }) - 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 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(/Today \(UTC\):\*\* \$0\.42/) + }) + + 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 +133,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/) }) diff --git a/site/mods/openrouter-balance/index.html b/site/mods/openrouter-balance/index.html index 90c32ba..349dbaf 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,8 +67,8 @@

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".
  • The key comes from /config, else OPENROUTER_MANAGEMENT_KEY, else OPENROUTER_API_KEY, else the macOS Keychain item openrouter-key, and stays in memory.
  • From bbef4d1d8a878cfee9ee2fccd0b2324f608f5bc8 Mon Sep 17 00:00:00 2001 From: Hamza Zafar Date: Tue, 13 Oct 2026 08:38:52 +0200 Subject: [PATCH 2/2] openrouter-balance: review fixes, local time, clearer labels - a /credits outage never hides a regular key's own numbers - a key limit tighter than the account's credit is what the line shows - /openrouter-balance shows the account's spend today, the same as the line - the line says "credit" or "key limit", so $5.00 isn't mistaken for the key's limit - "since HH:MM" is in local time; the day itself stays UTC --- docs/mods.md | 4 +-- mods/openrouter-balance/hooks/register.tsx | 32 +++++++++++++---- .../tests/openrouter-balance.test.tsx | 34 ++++++++++++++----- site/mods/openrouter-balance/index.html | 2 +- 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/docs/mods.md b/docs/mods.md index 802a3a7..f4d9d42 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -140,10 +140,10 @@ Shows your OpenRouter credit above the Claude Code prompt. - 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. 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 +- **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/hooks/register.tsx b/mods/openrouter-balance/hooks/register.tsx index 39740d7..b1a1702 100644 --- a/mods/openrouter-balance/hooks/register.tsx +++ b/mods/openrouter-balance/hooks/register.tsx @@ -112,7 +112,7 @@ 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(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' : ''}` : '' + 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)}} @@ -171,6 +171,10 @@ async function snapshot($: EngineInterface, now: number): Promise { 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 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 = isMgmt ? await activity($, now).catch(() => []) : [] // a hiccup here never hides the balance; a regular key is refused return { @@ -201,7 +205,14 @@ async function detail($: EngineInterface) { if (!isManagement(info)) { const credits = await account($, true) - if (credits) lines.push(`- **Account balance:** ${usd(credits.total_credits - credits.total_usage)} (${usd(credits.total_credits)} bought, ${usd(credits.total_usage)} used)`, '') + 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( `This key's own spend (a regular API key; a management key shows spend by model):`, '', @@ -228,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))}`, '', @@ -292,14 +303,21 @@ async function activity($: EngineInterface, now: number): Promise { } async function call($: EngineInterface, path: string, isOptional = false) { - const res = await $.http.fetch(`${API}/${path}`, { headers: { authorization: `Bearer ${await apiKey($)}` } }) - if (isOptional && (res.status === 401 || res.status === 403 || res.status === 404)) return null // not for this key: not a rejected key + 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. @@ -342,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 79605aa..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; creditsRefused?: boolean }) { +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' @@ -44,6 +44,7 @@ function engine(on: any, opts: { info?: object; credits?: () => { total_credits: 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) @@ -81,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\)/) @@ -100,18 +101,35 @@ 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 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 left, top up soon ยท \$0\.00 today/) + 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(/Today \(UTC\):\*\* \$0\.42/) + 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) => { @@ -166,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 349dbaf..9555974 100644 --- a/site/mods/openrouter-balance/index.html +++ b/site/mods/openrouter-balance/index.html @@ -70,7 +70,7 @@

    openrouter-balance

  • 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.