cache usage per ai account, so a switched-away account still answers
half a usage reading cannot be taken for an account you are not signed into. the limits come from anthropic's oauth endpoint using the credential the cli holds for the CURRENT login, and the tokens come from that login's transcripts — switch accounts and the previous one is not slow to read, it is unreadable. so: `ai_provider_usage`, keyed (user_id, ai_provider, email_account), with a snapshot taken at the end of every turn. fire-and-forget off the `result` message, owner-gated, capped by the reader's own 60s cache so a burst of short turns is not a burst of 150MB scans. the panel gains tabs, one per account. GET /chat/usage now answers from postgres alone — instant, and it carries the accounts that cannot be measured — and ?refresh=1 re-measures the signed-in one and writes it back. the client fires both and swaps. the reason a stale row is worth showing rather than apologising for is that `resetsAt` is an ABSOLUTE instant. a row from six hours ago has an as-of percentage and an exactly correct "resets in 14h 20m", ticked client-side with no network. and where a window has rolled over since, `currentLimit` reads it as 0% — the stored figure is not merely stale there, it is wrong in the direction that matters, since the account is actually free. weekly rolls forward to its next boundary (the length is in the kind, so that is arithmetic); session does not, because the five-hour window starts on first use rather than on a grid and there is no clock to count down. fetchedAt is now the older of the two halves rather than a stamp at the end, so a cached reading does not report itself as current. needs `bun db:push`. degrades to no tabs without it rather than a 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,8 @@ import { logger } from './logger';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import type { ChatIdentity } from './claude-sessions';
|
||||
import { officerModelForApiModel } from './model-aliases';
|
||||
import { readClaudeUsage } from './claude-usage';
|
||||
import { readAccountEmail } from './claude-usage';
|
||||
import { listStoredUsage, mergeUsage, snapshotClaudeUsage } from './usage-store';
|
||||
import { readSttConfig } from '../server-settings/stt';
|
||||
|
||||
/**
|
||||
@@ -408,8 +409,27 @@ chatRouter.post('/tasks/:id/pause', async (ctx) => {
|
||||
*/
|
||||
chatRouter.get('/usage', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const user = ctx.get('user');
|
||||
if (!who.isOwner) throw errors.FORBIDDEN('Usage belongs to the account that owns this machine');
|
||||
return ctx.json(await readClaudeUsage());
|
||||
|
||||
const currentEmail = await readAccountEmail();
|
||||
const stored = await listStoredUsage(user.id, currentEmail);
|
||||
|
||||
/*
|
||||
Two modes, and the split is the whole point of the cache.
|
||||
|
||||
Without `?refresh=1` this answers from Postgres alone — no transcript scan, no network — so the panel
|
||||
paints instantly with every account, including the ones that CANNOT be read live because the CLI is not
|
||||
signed in as them. The client then asks again with the flag and swaps in the fresh tab.
|
||||
|
||||
The exception is a first open, where answering instantly with nothing would be a worse kind of fast: if
|
||||
the signed-in account has no stored row, measure it now rather than show an empty panel.
|
||||
*/
|
||||
const refresh = ctx.req.query('refresh') === '1';
|
||||
const missing = !!currentEmail && !stored.some((a) => a.current);
|
||||
if (!refresh && !missing) return ctx.json({ accounts: stored });
|
||||
|
||||
return ctx.json({ accounts: mergeUsage(stored, await snapshotClaudeUsage(user.id)) });
|
||||
});
|
||||
|
||||
chatRouter.get('/models', async (ctx: Context) => {
|
||||
|
||||
@@ -127,7 +127,7 @@ function planLabel(login: Record<string, unknown>): string | null {
|
||||
* account and the limits belong to exactly one of them; a panel of percentages that does not say whose is
|
||||
* a panel you cannot act on.
|
||||
*/
|
||||
async function readAccountEmail(): Promise<string | null> {
|
||||
export async function readAccountEmail(): Promise<string | null> {
|
||||
try {
|
||||
const raw = await readFile(join(getOwnerHomeDir(''), '.claude.json'), 'utf-8');
|
||||
const account = (JSON.parse(raw) as Record<string, unknown>).oauthAccount as Record<string, unknown> | undefined;
|
||||
@@ -289,26 +289,34 @@ const windowStillOpen = (limit: UsageLimit, now: number): boolean => {
|
||||
return Number.isNaN(at) || at > now;
|
||||
};
|
||||
|
||||
async function collectLimits(): Promise<LimitsResult & { plan: string | null }> {
|
||||
/**
|
||||
* `measuredAt` travels with the reading rather than being stamped at the end.
|
||||
*
|
||||
* The panel shows "as of N minutes ago", and a reading served from a cache was measured when the CACHE was
|
||||
* filled, not when it was handed out. Stamping on the way out would report a minute-old figure as current —
|
||||
* which is the one thing this whole feature exists to avoid.
|
||||
*/
|
||||
async function collectLimits(): Promise<LimitsResult & { plan: string | null; measuredAt: number }> {
|
||||
const login = await readOauthLogin();
|
||||
const now = Date.now();
|
||||
if (!login) {
|
||||
return { limits: [], plan: null, problem: 'Claude Code is not signed in on this machine.' };
|
||||
return { limits: [], plan: null, measuredAt: now, problem: 'Claude Code is not signed in on this machine.' };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cached = limitsCache && now - limitsCache.at < LIMITS_TTL_MS ? limitsCache.result : null;
|
||||
if (cached) return { ...cached, plan: login.plan };
|
||||
if (limitsCache && now - limitsCache.at < LIMITS_TTL_MS) {
|
||||
return { ...limitsCache.result, plan: login.plan, measuredAt: limitsCache.at };
|
||||
}
|
||||
|
||||
const fresh = await probeLimits(login.accessToken);
|
||||
if (fresh.limits.length) {
|
||||
limitsCache = { at: now, result: fresh };
|
||||
return { ...fresh, plan: login.plan };
|
||||
return { ...fresh, plan: login.plan, measuredAt: now };
|
||||
}
|
||||
|
||||
// A failed probe falls back to the last good reading rather than to nothing — the numbers are minutes old
|
||||
// at worst, and a panel that goes blank on one flaky request is worse than one that is slightly behind.
|
||||
const stale = limitsCache?.result.limits.filter((l) => windowStillOpen(l, now)) ?? [];
|
||||
return { limits: stale, plan: login.plan, problem: fresh.problem };
|
||||
return { limits: stale, plan: login.plan, measuredAt: limitsCache?.at ?? now, problem: fresh.problem };
|
||||
}
|
||||
|
||||
// ── Local transcript scan ──
|
||||
@@ -468,12 +476,12 @@ async function scanTranscripts(): Promise<ScanResult> {
|
||||
return { days: dates.map((date) => ({ date, tokens: byDay.get(date) ?? 0 })), models };
|
||||
}
|
||||
|
||||
async function collectScan(): Promise<ScanResult> {
|
||||
async function collectScan(): Promise<ScanResult & { measuredAt: number }> {
|
||||
const now = Date.now();
|
||||
if (scanCache && now - scanCache.at < SCAN_TTL_MS) return scanCache.result;
|
||||
if (scanCache && now - scanCache.at < SCAN_TTL_MS) return { ...scanCache.result, measuredAt: scanCache.at };
|
||||
const result = await scanTranscripts();
|
||||
scanCache = { at: now, result };
|
||||
return result;
|
||||
return { ...result, measuredAt: now };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -487,7 +495,7 @@ export async function readClaudeUsage(): Promise<ClaudeUsage> {
|
||||
collectLimits(),
|
||||
collectScan().catch((err: unknown) => {
|
||||
logger.warn('Could not scan Claude transcripts for usage', { error: String(err) });
|
||||
return { days: recentDates().map((date) => ({ date, tokens: 0 })), models: [] };
|
||||
return { days: recentDates().map((date) => ({ date, tokens: 0 })), models: [], measuredAt: Date.now() };
|
||||
}),
|
||||
readAccountEmail(),
|
||||
]);
|
||||
@@ -498,6 +506,8 @@ export async function readClaudeUsage(): Promise<ClaudeUsage> {
|
||||
...(limits.problem ? { limitsProblem: limits.problem } : {}),
|
||||
days: scan.days,
|
||||
models: scan.models,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
// The OLDER of the two halves, because that is how old the reading is. Claiming the newer one would
|
||||
// describe a panel as fresher than its stalest number.
|
||||
fetchedAt: new Date(Math.min(limits.measuredAt, scan.measuredAt)).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mergeUsage, type UsageAccount } from './usage-store';
|
||||
|
||||
const account = (email: string, over: Partial<UsageAccount> = {}): UsageAccount => ({
|
||||
provider: 'claude-code',
|
||||
email,
|
||||
plan: 'Max 20x',
|
||||
current: false,
|
||||
measuredAt: '2026-08-18T04:00:00.000Z',
|
||||
limits: [],
|
||||
days: [],
|
||||
models: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('mergeUsage', () => {
|
||||
test('a fresh reading replaces its own account and leaves the others alone', () => {
|
||||
// The contract the panel rests on: one tab is live, the rest are as-of. A refresh must not touch a
|
||||
// stored account it could not have measured — the CLI is not signed in as it.
|
||||
const stored = [account('personal@x.com', { current: true }), account('work@y.com')];
|
||||
const fresh = account('personal@x.com', { current: true, measuredAt: '2026-08-18T05:00:00.000Z' });
|
||||
|
||||
const merged = mergeUsage(stored, fresh);
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged[0]!.measuredAt).toBe('2026-08-18T05:00:00.000Z');
|
||||
expect(merged[1]).toEqual(stored[1]!);
|
||||
});
|
||||
|
||||
test('signing into a new account adds it rather than replacing the list', () => {
|
||||
const merged = mergeUsage([account('personal@x.com', { current: true })], account('work@y.com', { current: true }));
|
||||
expect(merged.map((a) => a.email)).toEqual(['work@y.com', 'personal@x.com']);
|
||||
});
|
||||
|
||||
test('only one account is ever current', () => {
|
||||
// `current` drives the "not signed in · as of" line. Two of them would claim two live accounts.
|
||||
const stored = [account('personal@x.com', { current: true }), account('work@y.com', { current: true })];
|
||||
const merged = mergeUsage(stored, account('third@z.com', { current: true }));
|
||||
expect(merged.filter((a) => a.current)).toHaveLength(1);
|
||||
expect(merged[0]!.email).toBe('third@z.com');
|
||||
});
|
||||
|
||||
test('a failed reading leaves the stored list untouched', () => {
|
||||
// A signed-out CLI, or a scan that threw. The cached accounts are still the best answer available.
|
||||
const stored = [account('personal@x.com', { current: true })];
|
||||
expect(mergeUsage(stored, null)).toEqual(stored);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { listProviderUsage, recordProviderUsage } from 'officerdb';
|
||||
import { readClaudeUsage, type UsageDay, type UsageLimit, type UsageModel } from './claude-usage';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Usage readings across every AI account this machine has seen — not just the one signed in.
|
||||
*
|
||||
* ── The problem this exists for ──
|
||||
*
|
||||
* Half a usage reading cannot be taken at all for an account you are not signed into. The limits come from
|
||||
* Anthropic's OAuth endpoint using the credential the CLI holds for the CURRENT login, and the token counts
|
||||
* come from that login's transcripts. Switch accounts and the previous one is not slow to read — it is
|
||||
* unreadable.
|
||||
*
|
||||
* The owner switches between a personal account and a company one, and the question worth answering is
|
||||
* "which of my accounts has room right now". Only a stored reading per account can answer it, so a snapshot
|
||||
* is taken at the end of every turn and kept in `ai_provider_usage`.
|
||||
*
|
||||
* ── Why the countdowns still work on a stale row ──
|
||||
*
|
||||
* Because `resetsAt` is an ABSOLUTE instant, not a duration. A row measured six hours ago has a percentage
|
||||
* that is as-of, and a "resets in 14h 20m" that is exactly right — the client ticks it off the stored
|
||||
* timestamp with no network and no signed-in session. That asymmetry is the whole reason a cached row is
|
||||
* worth showing rather than apologising for.
|
||||
*/
|
||||
|
||||
export const CLAUDE_PROVIDER = 'claude-code';
|
||||
|
||||
/** The reading as stored — the three display sections, without the account identity wrapped around them. */
|
||||
export type UsageSnapshot = {
|
||||
limits: UsageLimit[];
|
||||
days: UsageDay[];
|
||||
models: UsageModel[];
|
||||
limitsProblem?: string;
|
||||
};
|
||||
|
||||
export type UsageAccount = {
|
||||
provider: string;
|
||||
email: string;
|
||||
plan: string | null;
|
||||
/** Whether this is the account the CLI is signed in as right now. Exactly one row is, or none. */
|
||||
current: boolean;
|
||||
/** When the reading was MEASURED. Drives "as of N minutes ago" and nothing else. */
|
||||
measuredAt: string;
|
||||
} & UsageSnapshot;
|
||||
|
||||
/**
|
||||
* Take a reading for whoever is signed in, and store it.
|
||||
*
|
||||
* Best-effort by design and it never throws: the caller is a turn ending, and a chat turn must not fail
|
||||
* because a display row would not write. A missed snapshot costs nothing — the next turn takes another.
|
||||
*
|
||||
* Returns the account it stored, or null when there was nothing to store (no login, so no account to key a
|
||||
* row on).
|
||||
*/
|
||||
export async function snapshotClaudeUsage(userId: number): Promise<UsageAccount | null> {
|
||||
try {
|
||||
const usage = await readClaudeUsage();
|
||||
const email = usage.account.email;
|
||||
// No account means no key. A reading that cannot be attributed is worse than no reading: it would land
|
||||
// under whichever account happened to be signed in last.
|
||||
if (!email) return null;
|
||||
|
||||
const snapshot: UsageSnapshot = {
|
||||
limits: usage.limits,
|
||||
days: usage.days,
|
||||
models: usage.models,
|
||||
...(usage.limitsProblem ? { limitsProblem: usage.limitsProblem } : {}),
|
||||
};
|
||||
|
||||
await recordProviderUsage({
|
||||
userId,
|
||||
aiProvider: CLAUDE_PROVIDER,
|
||||
emailAccount: email,
|
||||
plan: usage.account.plan,
|
||||
payload: snapshot,
|
||||
});
|
||||
|
||||
return {
|
||||
provider: CLAUDE_PROVIDER,
|
||||
email,
|
||||
plan: usage.account.plan,
|
||||
current: true,
|
||||
measuredAt: usage.fetchedAt,
|
||||
...snapshot,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn('Could not snapshot Claude usage', { userId, error: String(err) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every account with a stored reading, current one first.
|
||||
*
|
||||
* Reads only Postgres — no transcript scan, no network — so this is what the panel renders the instant it
|
||||
* opens. The fresh reading arrives separately and replaces one tab's worth.
|
||||
*
|
||||
* A missing table degrades to an empty list rather than a 500. `ai_provider_usage` arrived after this route
|
||||
* did, and a machine that has not run `db:push` should lose the account tabs, not the panel.
|
||||
*/
|
||||
export async function listStoredUsage(userId: number, currentEmail: string | null): Promise<UsageAccount[]> {
|
||||
let rows: Awaited<ReturnType<typeof listProviderUsage>>;
|
||||
try {
|
||||
rows = await listProviderUsage(userId, CLAUDE_PROVIDER);
|
||||
} catch (err) {
|
||||
logger.warn('Could not read stored usage', { userId, error: String(err) });
|
||||
return [];
|
||||
}
|
||||
|
||||
const accounts = rows.map((row) => {
|
||||
// The payload is a document written by an earlier version of this code, so its shape is whatever was
|
||||
// current the day the row was written. Defaulted rather than trusted: the three sections are mapped over
|
||||
// directly in the panel, and one missing array is a crash in the composer rather than a blank section.
|
||||
const payload = (row.payload ?? {}) as Partial<UsageSnapshot>;
|
||||
return {
|
||||
provider: row.aiProvider,
|
||||
email: row.emailAccount,
|
||||
plan: row.plan,
|
||||
current: row.emailAccount === currentEmail,
|
||||
measuredAt: row.updatedAt.toISOString(),
|
||||
limits: payload.limits ?? [],
|
||||
days: payload.days ?? [],
|
||||
models: payload.models ?? [],
|
||||
...(payload.limitsProblem ? { limitsProblem: payload.limitsProblem } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
// Current account first — it is the one the panel opens on, and the only one whose numbers are live.
|
||||
return accounts.sort((a, b) => Number(b.current) - Number(a.current));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a fresh reading into the stored list.
|
||||
*
|
||||
* The fresh one wins for its own account and the others are left exactly as they were, which is the whole
|
||||
* contract of the panel: one tab is live, the rest are as-of.
|
||||
*/
|
||||
export function mergeUsage(stored: UsageAccount[], fresh: UsageAccount | null): UsageAccount[] {
|
||||
if (!fresh) return stored;
|
||||
const rest = stored.filter((a) => !(a.provider === fresh.provider && a.email === fresh.email));
|
||||
return [fresh, ...rest.map((a) => ({ ...a, current: false }))];
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import { resolveHomeDir } from '@@/user-home';
|
||||
import { claudeLoginState } from '@@/os-user-claude';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { logger } from './logger';
|
||||
import { isSuperAdmin } from '@@/super-admin';
|
||||
import { snapshotClaudeUsage } from './usage-store';
|
||||
|
||||
// Default model when no user preference is set
|
||||
const DEFAULT_MODEL = 'claude-code';
|
||||
@@ -456,6 +458,26 @@ function createMessageHandler(sessionId: string, model: string, resumeSessionId?
|
||||
record();
|
||||
}
|
||||
|
||||
/*
|
||||
A turn ended — take a usage snapshot while the person is reading the answer.
|
||||
|
||||
Here rather than on a timer because this is the moment the numbers have just moved AND nobody is
|
||||
waiting: the turn is over, the composer is idle, and whatever this costs lands in the gap before the
|
||||
next prompt. `void`, so nothing about the turn depends on it.
|
||||
|
||||
Best-effort in every direction. It is capped by the reader's own 60s cache, so a burst of short turns
|
||||
does not become a burst of transcript scans; it stores nothing when the CLI is signed out; and it
|
||||
swallows its own failures. A missed snapshot costs one stale row until the next turn.
|
||||
|
||||
Owner-gated because the reading is structurally the owner's — `getOwnerHomeDir` ignores the email it
|
||||
is passed, so a member's turn would snapshot the OWNER'S usage under the MEMBER'S user id. That is not
|
||||
a permissions slip, it is a wrong row, and it would be invisible.
|
||||
*/
|
||||
if (msg.type === 'result' && session.userId) {
|
||||
const userId = session.userId;
|
||||
void isSuperAdmin({ id: userId }).then((owner) => (owner ? snapshotClaudeUsage(userId) : null));
|
||||
}
|
||||
|
||||
foldIntoSession(session, msg, effective);
|
||||
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user