import { discoverOmp, piAuthPath, readHarness, type StoreLookup } from "./stores.js"; import type { UsageInput } from "../shared/input.js"; import { execFile } from "node:child_process"; import { existsSync, promises as fs } from "node:fs"; import { homedir, userInfo } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { z } from "zod"; import { hashAccountKey, toneFromUsedPct, unavailable, type UsageAccount, type UsageScope, windowFromUsedPct, type UsageReport, type UsageWindow, type UsageDetail, } from "@getpaseo/plugin/server/usage"; const ApiNumberSchema = z.coerce.number().finite(); const execFileAsync = promisify(execFile); const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000; const CLAUDE_OAUTH_BETA = "oauth-2025-04-20"; const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"; const ClaudeCredentialsSchema = z.object({ claudeAiOauth: z .object({ accessToken: z.string().optional(), expiresAt: z.number().optional(), refreshToken: z.string().optional(), subscriptionType: z.string().optional(), rateLimitTier: z.string().optional(), }) .optional(), }); const ClaudeUsageWindowSchema = z.object({ utilization: ApiNumberSchema, resets_at: z.string().nullish(), }); // Model- and surface-scoped weekly limits live in a `limits[]` array rather than a // top-level `seven_day_` key. Entries are validated one at a time (see // scopedLimitsFromResponse) so a single malformed or newly-shaped entry cannot take down // the windows that already parsed from the top-level keys. const ClaudeScopeLabelSchema = z .object({ id: z.string().nullish(), display_name: z.string().nullish() }) .nullish(); const ClaudeLimitSchema = z.object({ kind: z.string(), percent: ApiNumberSchema.nullish(), resets_at: z.string().nullish(), scope: z.object({ model: ClaudeScopeLabelSchema, surface: ClaudeScopeLabelSchema }).nullish(), }); const ClaudeUsageResponseSchema = z.object({ five_hour: ClaudeUsageWindowSchema.nullish(), seven_day: ClaudeUsageWindowSchema.nullish(), seven_day_opus: ClaudeUsageWindowSchema.nullish(), seven_day_omelette: ClaudeUsageWindowSchema.nullish(), // Deliberately permissive: an additive section must never regress the top-level // windows, so shape validation happens per entry rather than here. limits: z.array(z.unknown()).nullish(), extra_usage: z .object({ is_enabled: z.boolean().optional(), }) .nullish(), }); type ClaudeCredentials = z.infer; type ClaudeUsageResponse = z.infer; type ClaudeLimit = z.infer; const SCOPED_WEEKLY_KIND = "weekly_scoped"; interface ClaudeCredentialRecord { expires?: number; oauth: { accessToken: string } & NonNullable; } function buildClaudePlan( subscriptionType: string | undefined, rateLimitTier: string | undefined, ): string | null { if (!subscriptionType) return null; const label = subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1); const tier = rateLimitTier?.split("_").pop(); return tier ? `${label} ${tier}` : label; } /** * A weekly limit scoped to one model or one surface, normalized away from whichever * shape of the response described it. * * The API describes the same limit two ways during the migration: a legacy top-level * `seven_day_` key, and an entry in `limits[]`. Everything downstream works on * this one representation so the two shapes are reconciled exactly once, in * `reconcileScopedLimits`, rather than at each place a window is built. */ interface ScopedLimit { dimension: "model" | "surface"; /** The API's own identifier. Null on every response observed so far. */ id: string | null; /** Display name, or the id when the API sends no display name. */ name: string; usedPct: number | null; resetsAt: string | null; } // Windows that describe no particular model or surface. const UNSCOPED_WINDOWS: ReadonlyArray<{ field: "five_hour" | "seven_day"; id: string; label: string; shortLabel: string; summary: boolean; }> = [ { field: "five_hour", id: "five_hour", label: "Session", shortLabel: "5h", summary: true }, { field: "seven_day", id: "weekly", label: "Weekly", shortLabel: "wk", summary: true }, ]; // Scoped windows from before `limits[]` existed. Declaring the dimension here is what // stops a *surface* named "Omelette" from being mistaken for the legacy Omelette *model* // window: these keys are model-scoped by definition. const LEGACY_SCOPED_WINDOWS: ReadonlyArray<{ field: "seven_day_opus" | "seven_day_omelette"; name: string; }> = [ { field: "seven_day_opus", name: "Opus" }, { field: "seven_day_omelette", name: "Omelette" }, ]; /** Fold a name down to the characters an id is allowed to carry. */ function normalizeName(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); } /** * Whether two descriptions denote the same limit. This is the single definition of * identity for scoped limits; nothing else may compare them, and in particular nothing * may compare display labels, which are presentation rather than identity. * * - Different dimensions are never the same limit, so a surface and a model sharing a * name stay apart. * - When both sides carry the API's own id, that id decides, so `fable-pro` and * `fable_pro` stay apart. * - Otherwise fall back to the normalized name, which is the only link available between * a legacy key (never has an id) and its `limits[]` counterpart. */ function isSameLimit(a: ScopedLimit, b: ScopedLimit): boolean { if (a.dimension !== b.dimension) return false; if (a.id && b.id) return a.id === b.id; return normalizeName(a.name) === normalizeName(b.name); } /** * Merge the legacy and `limits[]` descriptions into one limit per identity. * * A `limits[]` entry wins on identity because that is the representation the API is * migrating towards, so a limit keeps the same window id whichever shape carried it. * Its values are nullable though, so each field falls back to the legacy twin instead of * discarding a number the response did contain. */ function reconcileScopedLimits( legacy: ScopedLimit[], fromLimitsArray: ScopedLimit[], ): ScopedLimit[] { const reconciled = [...legacy]; for (const limit of fromLimitsArray) { const index = reconciled.findIndex((candidate) => isSameLimit(candidate, limit)); if (index === -1) { reconciled.push(limit); continue; } const twin = reconciled[index]; reconciled[index] = { ...limit, usedPct: limit.usedPct ?? twin?.usedPct ?? null, resetsAt: limit.resetsAt ?? twin?.resetsAt ?? null, }; } return reconciled; } function scopedLimitFromLegacy( spec: (typeof LEGACY_SCOPED_WINDOWS)[number], window: z.infer, ): ScopedLimit { return { dimension: "model", id: null, name: spec.name, usedPct: window.utilization, resetsAt: window.resets_at ?? null, }; } /** The scope of a `limits[]` entry, or null when it names nothing renderable. */ function scopedLimitFromEntry(limit: ClaudeLimit): ScopedLimit | null { for (const dimension of ["model", "surface"] as const) { const entry = limit.scope?.[dimension]; const id = entry?.id?.trim() || null; const name = entry?.display_name?.trim() || id; if (name) { return { dimension, id, name, usedPct: limit.percent ?? null, resetsAt: limit.resets_at ?? null, }; } } return null; } // The client uses window ids as React keys, so they must be stable across refreshes and // unique within a response. An API-supplied id is already an identifier and is used // verbatim (ids elsewhere carry punctuation too, e.g. MiniMax's `interval_MiniMax-M2.7`); // only a name fallback is normalized. Normalizing an id would collapse `fable-pro` and // `fable_pro` into one window. function scopedWindowId(limit: ScopedLimit): string { return `weekly_${limit.dimension}_${limit.id ?? normalizeName(limit.name)}`; } // Backstop for the one residual case identity cannot rule out: an entry whose verbatim id // equals another entry's normalized name. Suffix rather than drop, because a missing bar // is the bug this change exists to fix. function uniqueWindowId(candidate: string, taken: Set): string { if (!taken.has(candidate)) return candidate; for (let suffix = 2; ; suffix += 1) { const next = `${candidate}_${suffix}`; if (!taken.has(next)) return next; } } function legacyScopedLimits(resp: ClaudeUsageResponse): ScopedLimit[] { const limits: ScopedLimit[] = []; for (const spec of LEGACY_SCOPED_WINDOWS) { const window = resp[spec.field]; if (window) limits.push(scopedLimitFromLegacy(spec, window)); } return limits; } function unscopedWindows(resp: ClaudeUsageResponse): UsageWindow[] { const windows: UsageWindow[] = []; for (const spec of UNSCOPED_WINDOWS) { const window = resp[spec.field]; if (!window) continue; windows.push( windowFromUsedPct({ id: spec.id, label: spec.label, shortLabel: spec.shortLabel, summary: spec.summary, utilizationPct: window.utilization, resetsAt: window.resets_at ?? null, tone: toneFromUsedPct(window.utilization), }), ); } return windows; } function scopedWindows(limits: ScopedLimit[]): UsageWindow[] { const taken = new Set(); return limits.map((limit) => { const id = uniqueWindowId(scopedWindowId(limit), taken); taken.add(id); // Emitted even at 0% and inactive: a zero bar answers "how much of this model have I // used", and the bar must not come and go between refreshes. return windowFromUsedPct({ id, label: `Weekly \u00b7 ${limit.name}`, shortLabel: `wk ${limit.name}`, utilizationPct: limit.usedPct, resetsAt: limit.resetsAt, tone: toneFromUsedPct(limit.usedPct), }); }); } type ClaudeKeychainCommandRunner = (args: string[]) => Promise; // Keep this in sync with Claude Code's Keychain account derivation. const CLAUDE_KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/; const CLAUDE_KEYCHAIN_FALLBACK_ACCOUNT = "claude-code-user"; export function claudeKeychainAccount( user: string = process.env["USER"] || userInfo().username, ): string { return CLAUDE_KEYCHAIN_ACCOUNT_PATTERN.test(user) ? user : CLAUDE_KEYCHAIN_FALLBACK_ACCOUNT; } async function runSecurityCommand(args: string[]): Promise { try { const { stdout } = await execFileAsync("security", args, { timeout: CLAUDE_KEYCHAIN_TIMEOUT_MS, }); return stdout.trim() || null; } catch { return null; } } /** Read Claude Code's account-specific Keychain item, then try the legacy lookup. */ export async function readClaudeKeychainCredentials( run: ClaudeKeychainCommandRunner = runSecurityCommand, account: string = claudeKeychainAccount(), service: string = CLAUDE_KEYCHAIN_SERVICE, ): Promise { const lookups = [ ["find-generic-password", "-a", account, "-w", "-s", service], ["find-generic-password", "-w", "-s", service], ]; for (const args of lookups) { const raw = await run(args); if (!raw) continue; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { continue; } const creds = ClaudeCredentialsSchema.safeParse(parsed); if (creds.success && creds.data.claudeAiOauth?.accessToken) return parsed; } return null; } interface ClaudeCredentialLookup extends StoreLookup { readKeychainCredentials?: (service: string, account: string) => Promise; claudeHome?: string; } function claudeCredentialPath(lookup: ClaudeCredentialLookup): string { const home = lookup.home ?? homedir(); const env = lookup.env ?? process.env; const claudeHome = lookup.claudeHome ?? env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"); return join(claudeHome, ".credentials.json"); } async function keychainCredentialRecord( lookup: ClaudeCredentialLookup, route = claudeKeychainRoute(lookup), ): Promise { if ((lookup.platform ?? process.platform) !== "darwin") return null; const parsed = ClaudeCredentialsSchema.safeParse( await (lookup.readKeychainCredentials ? lookup.readKeychainCredentials(route.service, route.account) : readClaudeKeychainCredentials(undefined, route.account, route.service)), ); return parsed.success ? toCredentialRecord(parsed.data) : null; } export async function discover( scope: UsageScope, lookup: ClaudeCredentialLookup = {}, fetchApi: typeof fetch = fetch, ): Promise { if (scope.kind === "session") lookup = { ...lookup, env: scope.env, home: scope.env.HOME || scope.env.USERPROFILE || homedir(), }; const candidates = scope.kind === "global" ? await globalRoutes(lookup) : await sessionRoutes(scope, lookup); const accounts: UsageAccount[] = []; for (const input of candidates) { const credentials = await resolveClaudeCredentials(input, lookup); if (!credentials) continue; const harness = { pi: "Pi", omp: "OMP", claude: "Claude", keychain: "Claude" }[ input.route.store ]; const fallback = { key: hashAccountKey(JSON.stringify(input.route)), harness, input }; if (credentials.expires !== undefined && credentials.expires <= (lookup.now ?? Date.now)()) { accounts.push(fallback); continue; } try { const profile = await readProfile( credentials.oauth.accessToken, fetchApi, lookup.now ?? Date.now, ); accounts.push("status" in profile ? fallback : { ...profile, harness, input }); } catch { // Keep the login visible; fetching usage reports the vendor failure. accounts.push(fallback); } } return accounts; } function claudeKeychainRoute(lookup: ClaudeCredentialLookup) { const env = lookup.env ?? process.env; const configDir = env.CLAUDE_CONFIG_DIR; // macOS still uses Keychain with CLAUDE_CONFIG_DIR, with an entry keyed to that directory: // https://code.claude.com/docs/en/authentication#credential-management // Verified in @anthropic-ai/claude-code 2.1.59, package/cli.js: _c() appends the first // 8 SHA-256 hex characters of HA() (the NFC-normalized config directory). const suffix = configDir ? `-${hashAccountKey(configDir.normalize("NFC")).slice(0, 8)}` : ""; return { store: "keychain" as const, service: `${CLAUDE_KEYCHAIN_SERVICE}${suffix}`, account: claudeKeychainAccount(env.USER), }; } async function claudeRoute(lookup: ClaudeCredentialLookup): Promise { const route = claudeKeychainRoute(lookup); return (await keychainCredentialRecord(lookup, route)) ? { route } : { route: { store: "claude", path: claudeCredentialPath(lookup) } }; } async function globalRoutes(lookup: ClaudeCredentialLookup): Promise { return [ await claudeRoute(lookup), { route: { store: "pi", path: piAuthPath(lookup) } }, ...discoverOmp(lookup).map((route) => ({ route })), ]; } async function sessionRoutes( scope: Extract, lookup: ClaudeCredentialLookup, ): Promise { if (scope.provider === "claude") { const env = scope.env; const foreign = env.ANTHROPIC_BASE_URL && env.ANTHROPIC_BASE_URL.replace(/\/+$/, "") !== "https://api.anthropic.com"; if (enabled(env.CLAUDE_CODE_USE_BEDROCK) || enabled(env.CLAUDE_CODE_USE_VERTEX) || foreign) return []; return [await claudeRoute(lookup)]; } if (!scope.model?.startsWith("anthropic/")) return []; if (scope.provider === "pi") return [{ route: { store: "pi", path: piAuthPath(lookup) } }]; if (scope.provider === "omp") return discoverOmp(lookup).map((route) => ({ route })); return []; } function enabled(value: string | undefined): boolean { return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes"; } /** Re-read the selected login; the harness owns token refresh. */ export async function resolveClaudeCredentials( input: UsageInput, lookup: ClaudeCredentialLookup = {}, ): Promise { const route = input.route; if (route.store === "claude") return readCredentialFile(route.path); if (route.store === "keychain") return keychainCredentialRecord(lookup, { store: "keychain", service: route.service ?? CLAUDE_KEYCHAIN_SERVICE, account: route.account ?? claudeKeychainAccount(), }); const oauth = await readHarness(route, lookup); return oauth ? { oauth: { accessToken: oauth.access }, expires: oauth.expires } : null; } async function readCredentialFile(path: string): Promise { if (!existsSync(path)) return null; try { return toCredentialRecord( ClaudeCredentialsSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))), ); } catch { return null; } } function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRecord | null { const oauth = credentials.claudeAiOauth; return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken }, expires: oauth.expiresAt } : null; } export async function fetchUsage( input: UsageInput, fetchApi: typeof fetch = fetch, credentialLookup: ClaudeCredentialLookup = {}, ): Promise { /** * Scoped limits carried by `limits[]`. * * Entries are validated one at a time so a single malformed or newly-shaped entry * cannot fail the whole response and take the windows that already parsed with it. */ function scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] { if (!limits) return []; const parsed: ScopedLimit[] = []; for (const entry of limits) { const result = ClaudeLimitSchema.safeParse(entry); if (!result.success) { console.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry"); continue; } if (result.data.kind !== SCOPED_WEEKLY_KIND) continue; const limit = scopedLimitFromEntry(result.data); if (!limit) { console.warn("Skipping scoped Claude usage limit with no resolvable scope name"); continue; } parsed.push(limit); } return parsed; } async function callClaudeApi(token: string, now: number): Promise { for (const [key, until] of rateLimitedUntil) { if (until <= now) rateLimitedUntil.delete(key); } const tokenKey = hashAccountKey(token); const limitedUntil = rateLimitedUntil.get(tokenKey); if (limitedUntil !== undefined && limitedUntil > now) throw rateLimitedError(limitedUntil - now); rateLimitedUntil.delete(tokenKey); const res = await fetchApi("https://api.anthropic.com/api/oauth/usage", { signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "anthropic-beta": CLAUDE_OAUTH_BETA, }, }); if (res.status === 401 || res.status === 403) return res.status; if (res.status === 429) { const waitMs = retryAfterMs(res.headers.get("retry-after"), now); if (waitMs !== null) rateLimitedUntil.set(tokenKey, now + waitMs); throw rateLimitedError(waitMs); } if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`); return ClaudeUsageResponseSchema.parse(await res.json()); } const credentials = await resolveClaudeCredentials(input, credentialLookup); if (!credentials) throw new Error("Claude login store no longer exists"); const refreshedBy = input.route.store === "claude" || input.route.store === "keychain" ? "claude" : input.route.store; if ( credentials.expires !== undefined && credentials.expires <= (credentialLookup.now ?? Date.now)() ) return unavailable({ kind: "expired", expiresAt: new Date(credentials.expires).toISOString(), refreshedBy, }); const profile = await cachedProfile( credentials.oauth.accessToken, (credentialLookup.now ?? Date.now)(), ); if (profile && "status" in profile) return unavailable({ kind: "rejected", status: profile.status, refreshedBy }); const { oauth } = credentials; const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier); const resp = await callClaudeApi(oauth.accessToken, (credentialLookup.now ?? Date.now)()); if (typeof resp === "number") return unavailable({ kind: "rejected", status: resp, refreshedBy }); const scoped = reconcileScopedLimits( legacyScopedLimits(resp), scopedLimitsFromResponse(resp.limits), ); const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)]; if (windows.length === 0) { // The response parsed but described nothing. That silence is how the previous // shape change went unnoticed, so make it greppable. `warn` and not `debug` // because file logging defaults to `info`. console.warn("Claude usage response parsed but produced no windows"); } const details: UsageDetail[] = []; const extraUsageEnabled = resp.extra_usage?.is_enabled; if (extraUsageEnabled !== undefined) { details.push({ id: "extra_usage", label: "Extra usage", value: extraUsageEnabled ? "Enabled" : "Disabled", }); } return { status: "available", planLabel: plan ?? undefined, windows, balances: [], details, }; } // A 429 from the usage endpoint carries Retry-After, observed in the tens of minutes. Until it // passes, a fetch (including a forced Refresh) answers from here instead of calling again. const rateLimitedUntil = new Map(); function retryAfterMs(header: string | null, now: number): number | null { if (!header) return null; const seconds = Number(header); if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); const at = Date.parse(header); return Number.isNaN(at) ? null : Math.max(0, at - now); } function rateLimitedError(waitMs: number | null): Error { const when = waitMs === null ? "later" : `in ${formatWait(waitMs)}`; return new Error(`Rate limited by Claude. Try again ${when}.`); } function formatWait(ms: number): string { const minutes = Math.max(1, Math.ceil(ms / 60_000)); return minutes < 60 ? `${minutes}m` : `${Math.ceil(minutes / 60)}h`; } // The OAuth usage endpoint meters the active organization selected by the token. type ClaudeIdentity = { key: string; label?: string } | { status: number }; const PROFILE_TTL_MS = 300_000; const PROFILE_CACHE_LIMIT = 128; const profileCache = new Map }>(); function cachedProfile(token: string, now: number): Promise | undefined { pruneProfileCache(now); return profileCache.get(hashAccountKey(token))?.result; } function pruneProfileCache(now: number): void { for (const [key, entry] of profileCache) { if (now - entry.at >= PROFILE_TTL_MS) profileCache.delete(key); } } async function readProfile( token: string, fetchApi: typeof fetch, now: () => number, ): Promise { const tokenHash = hashAccountKey(token); pruneProfileCache(now()); const pending = profileCache.get(tokenHash); if (pending) return pending.result; while (profileCache.size >= PROFILE_CACHE_LIMIT) { const oldest = profileCache.keys().next().value; if (oldest === undefined) break; profileCache.delete(oldest); } const request = (async () => { const response = await fetchApi("https://api.anthropic.com/api/oauth/profile", { signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "anthropic-beta": CLAUDE_OAUTH_BETA, }, }); if (response.status === 401 || response.status === 403) return { status: response.status }; if (!response.ok) throw new Error(`Claude profile API returned ${response.status}`); const profile = z .object({ account: z.object({ uuid: z.string().min(1), email: z.string().optional() }), organization: z.object({ uuid: z.string().min(1) }), }) .parse(await response.json()); const account = profile.account; return { key: `${account.uuid}.${profile.organization.uuid}`, ...(account.email ? { label: account.email } : {}), }; })(); profileCache.set(tokenHash, { at: now(), result: request }); try { return await request; } catch (error) { if (profileCache.get(tokenHash)?.result === request) profileCache.delete(tokenHash); throw error; } }