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,2 @@
|
||||
export { recordProviderUsage, listProviderUsage, forgetProviderUsage } from './queries';
|
||||
export type { UsageSnapshotRow } from './queries';
|
||||
@@ -0,0 +1,84 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { aiProviderUsage } from './schema';
|
||||
|
||||
export type UsageSnapshotRow = {
|
||||
aiProvider: string;
|
||||
emailAccount: string;
|
||||
plan: string | null;
|
||||
payload: unknown;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type RecordParams = {
|
||||
userId: number;
|
||||
aiProvider: string;
|
||||
emailAccount: string;
|
||||
plan: string | null;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Store a reading. Upsert on the account — one row per account, last reading wins.
|
||||
*
|
||||
* `updatedAt` is set explicitly rather than left to the default, because on the UPDATE half a default only
|
||||
* applies to inserts: without it a refreshed row would keep saying it was measured whenever it was first
|
||||
* created, which is the one field this whole feature rests on.
|
||||
*/
|
||||
export async function recordProviderUsage({
|
||||
userId,
|
||||
aiProvider,
|
||||
emailAccount,
|
||||
plan,
|
||||
payload,
|
||||
}: RecordParams): Promise<void> {
|
||||
await db
|
||||
.insert(aiProviderUsage)
|
||||
.values({ userId, aiProvider, emailAccount, plan, payload })
|
||||
.onConflictDoUpdate({
|
||||
target: [aiProviderUsage.userId, aiProviderUsage.aiProvider, aiProviderUsage.emailAccount],
|
||||
set: { plan, payload, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every account this user has a reading for, newest first.
|
||||
*
|
||||
* Newest first because that is tab order: the account you used most recently is the one you are most likely
|
||||
* to be looking for, and it is usually the one signed in.
|
||||
*/
|
||||
export async function listProviderUsage(userId: number, aiProvider?: string): Promise<UsageSnapshotRow[]> {
|
||||
return db
|
||||
.select({
|
||||
aiProvider: aiProviderUsage.aiProvider,
|
||||
emailAccount: aiProviderUsage.emailAccount,
|
||||
plan: aiProviderUsage.plan,
|
||||
payload: aiProviderUsage.payload,
|
||||
updatedAt: aiProviderUsage.updatedAt,
|
||||
})
|
||||
.from(aiProviderUsage)
|
||||
.where(
|
||||
aiProvider
|
||||
? and(eq(aiProviderUsage.userId, userId), eq(aiProviderUsage.aiProvider, aiProvider))
|
||||
: eq(aiProviderUsage.userId, userId),
|
||||
)
|
||||
.orderBy(desc(aiProviderUsage.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget one account's reading.
|
||||
*
|
||||
* Exists because a tab for an account you no longer use is clutter, and the row is display data — deleting
|
||||
* it loses nothing that cannot be measured again by signing in.
|
||||
*/
|
||||
export async function forgetProviderUsage(userId: number, aiProvider: string, emailAccount: string): Promise<void> {
|
||||
await db
|
||||
.delete(aiProviderUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(aiProviderUsage.userId, userId),
|
||||
eq(aiProviderUsage.aiProvider, aiProvider),
|
||||
eq(aiProviderUsage.emailAccount, emailAccount),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
const AI_PROVIDERS = ['claude-code', 'opencode'] as const;
|
||||
|
||||
/**
|
||||
* The last usage reading for each AI subscription this machine has seen — one row per account, kept.
|
||||
*
|
||||
* ── Why store it, when the live reading takes half a second ──
|
||||
*
|
||||
* Because half of it cannot be read at all for an account you are not signed into. The limits come from
|
||||
* Anthropic's OAuth usage endpoint using the credential the CLI holds for the CURRENT login, and the token
|
||||
* counts come from that login's transcripts. Switch to another account and the previous one becomes
|
||||
* unreadable — not slow, unreadable.
|
||||
*
|
||||
* That is the whole feature. The owner switches between a personal account and a company one, and the
|
||||
* question worth answering at a glance is "which of my accounts has room right now". Only a stored reading
|
||||
* per account can answer it.
|
||||
*
|
||||
* ── Why the account is in the key ──
|
||||
*
|
||||
* `(user_id, ai_provider, email_account)`, so switching logins accumulates rather than overwrites. Keyed on
|
||||
* the OFFICER user as well as the provider account because they are different axes: one Officer account may
|
||||
* hold several Anthropic logins, and eventually several Officer accounts will each hold their own.
|
||||
*
|
||||
* ── Why the reading itself is jsonb ──
|
||||
*
|
||||
* The three sections — limits, tokens by day, tokens by model — are arrays whose shape is provider-specific.
|
||||
* A prepaid provider reports a credit balance and no windows at all; a subscription reports windows and no
|
||||
* balance. Flattening that into columns produces a table that is mostly null for every provider, and a
|
||||
* migration every time one is added.
|
||||
*
|
||||
* So the columns here are only what is true of EVERY provider and worth querying on — who, which, whose,
|
||||
* when — and the reading rides along as one document. It is display data with a single writer and a single
|
||||
* reader; nothing joins to it, and nothing needs to filter inside it.
|
||||
*
|
||||
* A stored reading is a SNAPSHOT and is never edited: a fresh one replaces it whole. There is no partial
|
||||
* update path, which is what makes a document safe here.
|
||||
*/
|
||||
export const aiProviderUsage = pgTable(
|
||||
'ai_provider_usage',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** Which harness. Constrained, so a provider the code stopped supporting cannot sit here unnoticed. */
|
||||
aiProvider: text('ai_provider').notNull(),
|
||||
/**
|
||||
* Which account WITHIN that provider — `jrg.conceicao@gmail.com`.
|
||||
*
|
||||
* From the CLI's own config (`~/.claude.json` → `oauthAccount.emailAddress`), not from the Officer
|
||||
* account: they are unrelated, and the whole point of this table is that one Officer user has several
|
||||
* of these.
|
||||
*/
|
||||
emailAccount: text('email_account').notNull(),
|
||||
/** `Max 20x`, `Pro`. Display only, and a column rather than payload because it labels the tab. */
|
||||
plan: text('plan'),
|
||||
/**
|
||||
* The reading. See `ClaudeUsageSnapshot` in `types.ts` for the Claude shape.
|
||||
*
|
||||
* Deliberately not validated by the database. It is written by one function and read by one component,
|
||||
* both in this repository, and a check constraint on a document would fail a push the day a provider
|
||||
* adds a field.
|
||||
*/
|
||||
payload: jsonb('payload').notNull(),
|
||||
/**
|
||||
* When the reading was taken — NOT when the row was touched.
|
||||
*
|
||||
* This is the field the panel renders as "as of 20 minutes ago", and it is the only thing standing
|
||||
* between a glanceable reminder and a confidently wrong number. Every write sets it explicitly.
|
||||
*/
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('uq_ai_provider_usage_account').on(table.userId, table.aiProvider, table.emailAccount),
|
||||
check(
|
||||
'ck_ai_provider_usage_provider',
|
||||
// `sql.raw`, never an interpolated string — a bound parameter is emitted as `IN ($1, $2)`, which
|
||||
// Postgres refuses, and that breaks push for the whole schema rather than for this table.
|
||||
sql`${table.aiProvider} IN (${sql.join(
|
||||
AI_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -34,6 +34,7 @@ export * from './auth';
|
||||
export * from './permissions';
|
||||
export * from './chat-events';
|
||||
export * from './chat-session-models';
|
||||
export * from './ai-provider-usage';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
export * from './pipeline-jobs';
|
||||
|
||||
@@ -32,6 +32,7 @@ export * from './server/schema'; // server_config (SMTP lives here), server_inte
|
||||
export * from './pipeline-jobs/schema'; // pipeline_jobs
|
||||
export * from './chat-events/schema'; // chat_session_events
|
||||
export * from './chat-session-models/schema'; // chat_session_models
|
||||
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
|
||||
export * from './agent-panels/schema'; // agent_panels
|
||||
|
||||
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
|
||||
|
||||
@@ -81,3 +81,8 @@ export type PushDeviceInsert = typeof NotifySchema.pushDevices.$inferInsert;
|
||||
|
||||
export type ApiKeySelect = typeof Schema.apiKeys.$inferSelect;
|
||||
export type ApiKeyInsert = typeof Schema.apiKeys.$inferInsert;
|
||||
|
||||
// ── AI provider usage ──
|
||||
|
||||
export type AiProviderUsageSelect = typeof Schema.aiProviderUsage.$inferSelect;
|
||||
export type AiProviderUsageInsert = typeof Schema.aiProviderUsage.$inferInsert;
|
||||
|
||||
Reference in New Issue
Block a user