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:
@@ -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