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:
@@ -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 }))];
|
||||
}
|
||||
Reference in New Issue
Block a user