mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
723 lines
25 KiB
TypeScript
723 lines
25 KiB
TypeScript
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_<model>` 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(),
|
|
monthly_limit: ApiNumberSchema.nullish(),
|
|
used_credits: ApiNumberSchema.nullish(),
|
|
currency: z.string().nullish(),
|
|
})
|
|
.nullish(),
|
|
});
|
|
|
|
type ClaudeCredentials = z.infer<typeof ClaudeCredentialsSchema>;
|
|
type ClaudeUsageResponse = z.infer<typeof ClaudeUsageResponseSchema>;
|
|
type ClaudeLimit = z.infer<typeof ClaudeLimitSchema>;
|
|
|
|
const SCOPED_WEEKLY_KIND = "weekly_scoped";
|
|
|
|
interface ClaudeCredentialRecord {
|
|
expires?: number;
|
|
oauth: { accessToken: string } & NonNullable<ClaudeCredentials["claudeAiOauth"]>;
|
|
}
|
|
|
|
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_<model>` 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<typeof ClaudeUsageWindowSchema>,
|
|
): 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>): 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<string>();
|
|
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<string | null>;
|
|
|
|
// 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<string | null> {
|
|
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<unknown | null> {
|
|
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<unknown | null>;
|
|
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<ClaudeCredentialRecord | null> {
|
|
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<UsageAccount[]> {
|
|
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<UsageInput> {
|
|
const route = claudeKeychainRoute(lookup);
|
|
return (await keychainCredentialRecord(lookup, route))
|
|
? { route }
|
|
: { route: { store: "claude", path: claudeCredentialPath(lookup) } };
|
|
}
|
|
|
|
async function globalRoutes(lookup: ClaudeCredentialLookup): Promise<UsageInput[]> {
|
|
return [
|
|
await claudeRoute(lookup),
|
|
{ route: { store: "pi", path: piAuthPath(lookup) } },
|
|
...discoverOmp(lookup).map((route) => ({ route })),
|
|
];
|
|
}
|
|
|
|
async function sessionRoutes(
|
|
scope: Extract<UsageScope, { kind: "session" }>,
|
|
lookup: ClaudeCredentialLookup,
|
|
): Promise<UsageInput[]> {
|
|
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<ClaudeCredentialRecord | null> {
|
|
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<ClaudeCredentialRecord | null> {
|
|
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;
|
|
}
|
|
|
|
function extraUsageBalances(extraUsage: ClaudeUsageResponse["extra_usage"]) {
|
|
if (extraUsage?.currency?.toUpperCase() !== "USD") return [];
|
|
const used = extraUsage.used_credits == null ? undefined : extraUsage.used_credits / 100;
|
|
const limit = extraUsage.monthly_limit == null ? undefined : extraUsage.monthly_limit / 100;
|
|
if (used == null && limit == null) return [];
|
|
return [
|
|
{
|
|
id: "extra_usage_monthly",
|
|
label: "Extra usage",
|
|
used,
|
|
remaining: used != null && limit != null ? Math.max(0, limit - used) : undefined,
|
|
limit,
|
|
unit: "usd" as const,
|
|
tone: toneFromUsedPct(
|
|
used != null && limit != null && limit > 0 ? (used / limit) * 100 : null,
|
|
),
|
|
},
|
|
];
|
|
}
|
|
|
|
export async function fetchUsage(
|
|
input: UsageInput,
|
|
fetchApi: typeof fetch = fetch,
|
|
credentialLookup: ClaudeCredentialLookup = {},
|
|
): Promise<UsageReport> {
|
|
/**
|
|
* 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<ClaudeUsageResponse | number> {
|
|
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 extraUsage = resp.extra_usage;
|
|
if (extraUsage?.is_enabled !== undefined) {
|
|
details.push({
|
|
id: "extra_usage",
|
|
label: "Extra usage",
|
|
value: extraUsage.is_enabled ? "Enabled" : "Disabled",
|
|
});
|
|
}
|
|
const balances = extraUsageBalances(extraUsage);
|
|
|
|
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<string, number>();
|
|
|
|
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<string, { at: number; result: Promise<ClaudeIdentity> }>();
|
|
|
|
function cachedProfile(token: string, now: number): Promise<ClaudeIdentity> | 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<ClaudeIdentity> {
|
|
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;
|
|
}
|
|
}
|