From 901021ad6c2516d91f42f67cdf73e7485a9709c9 Mon Sep 17 00:00:00 2001 From: BW Date: Mon, 5 Oct 2026 08:20:23 -0600 Subject: [PATCH] fix(usage): show Claude credit usage when a login has no rate-limit windows Logins billed per use get no five-hour or weekly window from the Claude usage endpoint, only an extra_usage block, so the parser returned nothing and the status bar stayed hidden. Parse extra_usage (and the newer spend block) into creditUsage and show it as "$used / $limit": inline in the bar when there are no windows, and in the popover. Co-Authored-By: Claude Opus 5.5 --- electron/ipc/claude-usage.test.ts | 131 +++++++++++++++++- electron/ipc/claude-usage.ts | 90 +++++++++++- electron/ipc/shared-types.ts | 12 ++ src/components/UsageStatusBar.client.test.tsx | 18 +++ src/components/UsageStatusBar.tsx | 55 +++++++- src/components/usage-format.test.ts | 49 ++++++- src/components/usage-format.ts | 15 +- src/ipc/types.ts | 1 + src/store/core.ts | 1 + src/store/types.ts | 2 + src/store/usage.test.ts | 2 + src/store/usage.ts | 2 + 12 files changed, 371 insertions(+), 7 deletions(-) diff --git a/electron/ipc/claude-usage.test.ts b/electron/ipc/claude-usage.test.ts index 648eb72ed..dc0c12092 100644 --- a/electron/ipc/claude-usage.test.ts +++ b/electron/ipc/claude-usage.test.ts @@ -7,6 +7,7 @@ import { fetchClaudeUsage, parseAccessToken, parseClaudeUsageResponse, + parseCreditUsage, readKeychainCredentials, type KeychainExec, } from './claude-usage.js'; @@ -40,7 +41,61 @@ describe('parseClaudeUsageResponse', () => { }); }); - it('returns null when no window carries a percentage', () => { + it('includes creditUsage when extra_usage is present', () => { + const result = parseClaudeUsageResponse( + { + five_hour: { utilization: 10 }, + extra_usage: { + is_enabled: true, + monthly_limit: 3000, + used_credits: 212, + utilization: 7.07, + currency: 'USD', + decimal_places: 2, + }, + }, + NOW, + ); + expect(result).toEqual({ + status: 'ok', + fiveHour: { usedPercent: 10, resetsAt: null }, + sevenDay: null, + creditUsage: { + used: 2.12, + limit: 30, + currency: 'USD', + usedPercent: 7.07, + }, + fetchedAt: NOW, + }); + }); + + it('returns ok if only creditUsage is present without rate limit windows', () => { + const result = parseClaudeUsageResponse( + { + extra_usage: { + is_enabled: true, + monthly_limit: 1000, + used_credits: 500, + }, + }, + NOW, + ); + expect(result).toEqual({ + status: 'ok', + fiveHour: null, + sevenDay: null, + creditUsage: { + used: 5, + limit: 10, + currency: 'USD', + usedPercent: 50, + }, + fetchedAt: NOW, + }); + }); + + it('returns null when neither windows nor credit usage are present', () => { expect(parseClaudeUsageResponse({ five_hour: { resets_at: 1 } })).toBeNull(); expect(parseClaudeUsageResponse({})).toBeNull(); expect(parseClaudeUsageResponse(null)).toBeNull(); @@ -48,6 +103,80 @@ describe('parseClaudeUsageResponse', () => { }); }); +describe('parseCreditUsage', () => { + it('parses extra_usage with custom decimal places and currency', () => { + expect( + parseCreditUsage({ + extra_usage: { + is_enabled: true, + monthly_limit: 5000, + used_credits: 1250, + currency: 'EUR', + decimal_places: 2, + }, + }), + ).toEqual({ + used: 12.5, + limit: 50, + currency: 'EUR', + usedPercent: 25, + }); + }); + + it('falls back to spend object when extra_usage is absent', () => { + expect( + parseCreditUsage({ + spend: { + enabled: true, + used: { amount_minor: 350, currency: 'USD', exponent: 2 }, + limit: { amount_minor: 1000, exponent: 2 }, + percent: 35, + }, + }), + ).toEqual({ + used: 3.5, + limit: 10, + currency: 'USD', + usedPercent: 35, + }); + }); + + it('returns null when extra_usage is disabled and 0 credits used', () => { + expect( + parseCreditUsage({ + extra_usage: { + is_enabled: false, + monthly_limit: 3000, + used_credits: 0, + }, + }), + ).toBeNull(); + }); + + it('returns usage if credits have been used even if is_enabled is false', () => { + expect( + parseCreditUsage({ + extra_usage: { + is_enabled: false, + monthly_limit: 3000, + used_credits: 150, + }, + }), + ).toEqual({ + used: 1.5, + limit: 30, + currency: 'USD', + usedPercent: 5, + }); + }); + + it('returns null for missing, null, or malformed data', () => { + expect(parseCreditUsage(null)).toBeNull(); + expect(parseCreditUsage({})).toBeNull(); + expect(parseCreditUsage('hello')).toBeNull(); + }); +}); + describe('parseAccessToken', () => { it('extracts the OAuth access token', () => { expect(parseAccessToken('{"claudeAiOauth":{"accessToken":"tok"}}')).toBe('tok'); diff --git a/electron/ipc/claude-usage.ts b/electron/ipc/claude-usage.ts index 69b0693ec..d5ee01293 100644 --- a/electron/ipc/claude-usage.ts +++ b/electron/ipc/claude-usage.ts @@ -4,7 +4,7 @@ import path from 'path'; import crypto from 'crypto'; import { execFile } from 'child_process'; import { promisify } from 'util'; -import type { UsageResult, UsageWindow } from './shared-types.js'; +import type { CreditUsage, UsageResult, UsageWindow } from './shared-types.js'; import { warn as logWarn, errMessage } from '../log.js'; import { clampPercent, finite, parseResetsAt, requestUsage } from './usage-shared.js'; @@ -36,6 +36,83 @@ interface UsageWindowJson { resets_at?: unknown; } +interface RawSpendMinor { + amount_minor?: unknown; + currency?: unknown; + exponent?: unknown; +} + +interface RawSpend { + used?: RawSpendMinor | null; + limit?: RawSpendMinor | null; + percent?: unknown; + enabled?: unknown; +} + +interface RawExtraUsage { + is_enabled?: unknown; + monthly_limit?: unknown; + used_credits?: unknown; + utilization?: unknown; + currency?: unknown; + decimal_places?: unknown; +} + +export function parseCreditUsage(body: unknown): CreditUsage | null { + if (typeof body !== 'object' || body === null) return null; + const raw = body as { extra_usage?: unknown; spend?: unknown }; + + if (typeof raw.extra_usage === 'object' && raw.extra_usage !== null) { + const eu = raw.extra_usage as RawExtraUsage; + const isEnabled = eu.is_enabled === true; + const decimals = typeof eu.decimal_places === 'number' ? eu.decimal_places : 2; + const divisor = 10 ** decimals; + const rawUsed = finite(eu.used_credits); + const rawLimit = finite(eu.monthly_limit); + const used = rawUsed !== null ? rawUsed / divisor : null; + const limit = rawLimit !== null ? rawLimit / divisor : null; + const currency = typeof eu.currency === 'string' && eu.currency ? eu.currency : 'USD'; + const usedPercent = finite(eu.utilization); + + if (used !== null && (isEnabled || used > 0)) { + const calcPercent = limit !== null && limit > 0 ? clampPercent((used / limit) * 100) : null; + return { + used, + limit, + currency, + usedPercent: usedPercent !== null ? clampPercent(usedPercent) : calcPercent, + }; + } + } + + if (typeof raw.spend === 'object' && raw.spend !== null) { + const sp = raw.spend as RawSpend; + const enabled = sp.enabled === true; + const usedMinor = finite(sp.used?.amount_minor); + const exp = typeof sp.used?.exponent === 'number' ? sp.used.exponent : 2; + const divisor = 10 ** exp; + const used = usedMinor !== null ? usedMinor / divisor : null; + const limitMinor = finite(sp.limit?.amount_minor); + const limitExp = typeof sp.limit?.exponent === 'number' ? sp.limit.exponent : exp; + const limit = limitMinor !== null ? limitMinor / 10 ** limitExp : null; + const currency = + typeof sp.used?.currency === 'string' && sp.used.currency ? sp.used.currency : 'USD'; + const percent = finite(sp.percent); + + if (used !== null && (enabled || used > 0)) { + const calcPercent = limit !== null && limit > 0 ? clampPercent((used / limit) * 100) : null; + return { + used, + limit, + currency, + usedPercent: percent !== null ? clampPercent(percent) : calcPercent, + }; + } + } + + return null; +} + function parseWindow(value: unknown): UsageWindow | null { if (typeof value !== 'object' || value === null) return null; const raw = value as UsageWindowJson; @@ -50,8 +127,15 @@ export function parseClaudeUsageResponse(body: unknown, now = Date.now()): Usage const raw = body as { five_hour?: unknown; seven_day?: unknown }; const fiveHour = parseWindow(raw.five_hour); const sevenDay = parseWindow(raw.seven_day); - if (!fiveHour && !sevenDay) return null; - return { status: 'ok', fiveHour, sevenDay, fetchedAt: now }; + const creditUsage = parseCreditUsage(body); + if (!fiveHour && !sevenDay && !creditUsage) return null; + return { + status: 'ok', + fiveHour, + sevenDay, + ...(creditUsage ? { creditUsage } : {}), + fetchedAt: now, + }; } /** Extracts the OAuth access token from a Claude credentials JSON document. */ diff --git a/electron/ipc/shared-types.ts b/electron/ipc/shared-types.ts index 715fd8fff..acd5076d5 100644 --- a/electron/ipc/shared-types.ts +++ b/electron/ipc/shared-types.ts @@ -210,11 +210,23 @@ export interface UsageWindow { resetsAt: number | null; } +export interface CreditUsage { + /** Amount used in standard currency units (e.g. 2.12 for $2.12). */ + used: number; + /** Spending limit in standard currency units, null if unlimited or not set. */ + limit: number | null; + /** Currency code, e.g. "USD". */ + currency: string; + /** Percent of limit used (0โ€“100), null if limit is not set. */ + usedPercent: number | null; +} + export type UsageResult = | { status: 'ok'; fiveHour: UsageWindow | null; sevenDay: UsageWindow | null; + creditUsage?: CreditUsage | null; fetchedAt: number; } /** No subscription login to read โ€” the status bar hides itself. */ diff --git a/src/components/UsageStatusBar.client.test.tsx b/src/components/UsageStatusBar.client.test.tsx index 739dee86a..c530e0256 100644 --- a/src/components/UsageStatusBar.client.test.tsx +++ b/src/components/UsageStatusBar.client.test.tsx @@ -91,4 +91,22 @@ describe('UsageStatusBar', () => { container.querySelector('[role="status"]')?.click(); expect(mockRefreshUsage).toHaveBeenCalledWith('claude', { force: true }); }); + + it('shows credit spend inline for a login with no rate-limit windows', () => { + const saved = { ...usage.claude }; + Object.assign(usage.claude, { + fiveHour: null, + sevenDay: null, + creditUsage: { used: 12.34, limit: 50, currency: 'USD', usedPercent: 24.68 }, + }); + try { + const container = mount(); + const entry = container.querySelector('[role="status"]'); + expect(entry?.textContent).toContain('Credits'); + expect(entry?.textContent).toContain('$12.34 / $50.00'); + expect(entry?.textContent).not.toContain('unavailable'); + } finally { + Object.assign(usage.claude, saved); + } + }); }); diff --git a/src/components/UsageStatusBar.tsx b/src/components/UsageStatusBar.tsx index ee7d34685..5c97e4a02 100644 --- a/src/components/UsageStatusBar.tsx +++ b/src/components/UsageStatusBar.tsx @@ -3,10 +3,11 @@ import { Portal } from 'solid-js/web'; import { store, refreshUsage, USAGE_PROVIDERS } from '../store/store'; import { theme } from '../lib/theme'; import { sf } from '../lib/fontScale'; -import type { UsageProvider, UsageWindow } from '../ipc/types'; +import type { CreditUsage, UsageProvider, UsageWindow } from '../ipc/types'; import type { UsageState } from '../store/types'; import { USAGE_WARN_PERCENT, + formatCurrency, formatFetchedAt, formatReset, hasUsageSnapshot, @@ -61,6 +62,55 @@ function UsageMeter(props: { label: string; window: UsageWindow; width?: number ); } +function CreditMeter(props: { credit: CreditUsage; width?: number }) { + const usedText = () => formatCurrency(props.credit.used, props.credit.currency); + const limitText = () => + props.credit.limit !== null ? formatCurrency(props.credit.limit, props.credit.currency) : null; + const percent = () => (props.credit.limit !== null ? props.credit.usedPercent : null); + const warn = () => (props.credit.usedPercent ?? 0) >= USAGE_WARN_PERCENT; + const color = () => (warn() ? theme.warning : theme.accent); + + return ( + + Credits + + {(pct) => ( + + + + )} + + + {limitText() ? `${usedText()} / ${limitText()}` : `${usedText()} used`} + + + {(pct) => ({Math.round(pct())}%)} + + + ); +} + /** Glance-only detail card above a provider's bar entry: both windows, last refresh, any error. */ function UsagePopover(props: { provider: UsageProvider; @@ -115,6 +165,7 @@ function UsagePopover(props: { {(w) => } + {(c) => }
{(h) => } + {/* Pay-per-use logins have no rate-limit windows; their spend is the headline. */} + {(c) => } usage unavailable ยท {usage().error} diff --git a/src/components/usage-format.test.ts b/src/components/usage-format.test.ts index 3337a9473..a86c73c9d 100644 --- a/src/components/usage-format.test.ts +++ b/src/components/usage-format.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; import type { UsageState } from '../store/types'; -import { formatFetchedAt, formatReset, remainingPercent, usageVisible } from './usage-format'; +import { + formatCurrency, + formatFetchedAt, + formatReset, + hasUsageSnapshot, + remainingPercent, + usageVisible, +} from './usage-format'; const NOON = new Date(2026, 8, 2, 12, 0, 0).getTime(); @@ -64,3 +71,43 @@ describe('usageVisible', () => { expect(usageVisible({ ...idle, status: 'error', error: 'HTTP 401' })).toBe(true); }); }); + +describe('formatCurrency', () => { + it('formats amounts in USD by default', () => { + expect(formatCurrency(2.12)).toBe('$2.12'); + expect(formatCurrency(30)).toBe('$30.00'); + expect(formatCurrency(0)).toBe('$0.00'); + }); + + it('formats amounts with custom currency code', () => { + expect(formatCurrency(15.5, 'EUR')).toMatch(/15[.,]50/); + }); +}); + +describe('hasUsageSnapshot', () => { + const idle: UsageState = { + fiveHour: null, + sevenDay: null, + fetchedAt: null, + status: 'idle', + error: null, + }; + + it('is false when no windows and no credit usage exist', () => { + expect(hasUsageSnapshot(idle)).toBe(false); + }); + + it('is true when fiveHour or sevenDay window exists', () => { + expect(hasUsageSnapshot({ ...idle, fiveHour: { usedPercent: 10, resetsAt: null } })).toBe(true); + expect(hasUsageSnapshot({ ...idle, sevenDay: { usedPercent: 50, resetsAt: null } })).toBe(true); + }); + + it('is true when creditUsage exists', () => { + expect( + hasUsageSnapshot({ + ...idle, + creditUsage: { used: 1, limit: 10, currency: 'USD', usedPercent: 10 }, + }), + ).toBe(true); + }); +}); diff --git a/src/components/usage-format.ts b/src/components/usage-format.ts index d36646615..fcc54ce94 100644 --- a/src/components/usage-format.ts +++ b/src/components/usage-format.ts @@ -29,8 +29,21 @@ export function formatFetchedAt(fetchedAt: number, now = Date.now()): string { return `${date.toLocaleDateString(undefined, { weekday: 'short' })} ${time}`; } +export function formatCurrency(amount: number, currency = 'USD'): string { + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + } catch { + return `$${amount.toFixed(2)}`; + } +} + export function hasUsageSnapshot(state: UsageState): boolean { - return state.fiveHour !== null || state.sevenDay !== null; + return state.fiveHour !== null || state.sevenDay !== null || Boolean(state.creditUsage); } /** A provider shows once it has a snapshot, and stays up through refresh errors diff --git a/src/ipc/types.ts b/src/ipc/types.ts index 9257a558d..19c4931e0 100644 --- a/src/ipc/types.ts +++ b/src/ipc/types.ts @@ -21,6 +21,7 @@ export type { PrReviewDecision, PtyOutput, StepEntry, + CreditUsage, UsageProvider, UsageResult, UsageWindow, diff --git a/src/store/core.ts b/src/store/core.ts index 956b65977..789c917fe 100644 --- a/src/store/core.ts +++ b/src/store/core.ts @@ -7,6 +7,7 @@ import type { AppStore, UsageState } from './types'; const EMPTY_USAGE: UsageState = { fiveHour: null, sevenDay: null, + creditUsage: null, fetchedAt: null, status: 'idle', error: null, diff --git a/src/store/types.ts b/src/store/types.ts index bda1861c8..95141ca81 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -15,6 +15,7 @@ import type { CanvasTaskLink, CanvasTaskSource } from '../lib/canvas-task-links' import type { AgentDef, StepEntry, + CreditUsage, UsageProvider, UsageWindow, VerificationRun, @@ -500,6 +501,7 @@ export interface MCPStatus { export interface UsageState { fiveHour: UsageWindow | null; sevenDay: UsageWindow | null; + creditUsage?: CreditUsage | null; /** When the current windows were fetched; null until the first success. */ fetchedAt: number | null; /** `unavailable` means no subscription login โ€” the bar hides. `error` keeps the last snapshot. */ diff --git a/src/store/usage.test.ts b/src/store/usage.test.ts index 78ceb06d8..385f958cc 100644 --- a/src/store/usage.test.ts +++ b/src/store/usage.test.ts @@ -64,6 +64,7 @@ describe('usage store slice', () => { expect(state('codex')).toEqual({ fiveHour: OK.fiveHour, sevenDay: OK.sevenDay, + creditUsage: null, fetchedAt: 500, status: 'ok', error: null, @@ -79,6 +80,7 @@ describe('usage store slice', () => { expect(state()).toEqual({ fiveHour: null, sevenDay: null, + creditUsage: null, fetchedAt: null, status: 'unavailable', error: 'logged out', diff --git a/src/store/usage.ts b/src/store/usage.ts index 800bac830..992e8c7a0 100644 --- a/src/store/usage.ts +++ b/src/store/usage.ts @@ -33,6 +33,7 @@ function applyResult(provider: UsageProvider, result: UsageResult): void { setStore('usage', provider, { fiveHour: result.fiveHour, sevenDay: result.sevenDay, + creditUsage: result.creditUsage ?? null, fetchedAt: result.fetchedAt, status: 'ok', error: null, @@ -42,6 +43,7 @@ function applyResult(provider: UsageProvider, result: UsageResult): void { setStore('usage', provider, { fiveHour: null, sevenDay: null, + creditUsage: null, fetchedAt: null, status: 'unavailable', error: result.reason,