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:
2026-08-18 04:46:31 +00:00
co-authored by Claude Opus 5
parent dc68a289ff
commit 9745f54fa1
14 changed files with 704 additions and 63 deletions
+22
View File
@@ -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);
};