Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
6 changes: 3 additions & 3 deletions docs/mods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion mods/openrouter-balance/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
60 changes: 44 additions & 16 deletions mods/openrouter-balance/hooks/register.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 = (
<Text>
{isWide && <Text color={tone}>{'█'.repeat(filled)}</Text>}
Expand Down Expand Up @@ -166,12 +166,17 @@ async function refresh($: EngineInterface, isForced: boolean) {
async function snapshot($: EngineInterface, now: number): Promise<Line> {
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',
Expand Down Expand Up @@ -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)}`,
Expand All @@ -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')
Expand All @@ -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))}`,
'',
Expand Down Expand Up @@ -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
Expand All @@ -282,14 +302,22 @@ async function activity($: EngineInterface, now: number): Promise<Activity[]> {
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.
Expand Down Expand Up @@ -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
}
49 changes: 39 additions & 10 deletions mods/openrouter-balance/tests/openrouter-balance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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\)/)
Expand All @@ -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
Expand All @@ -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/)
})
Expand Down Expand Up @@ -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($)
Expand Down
Loading
Loading