mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 01:32:40 +00:00
* feat(usage): stream reports for live agent accounts Resolve account scope from the running harness environment without invoking lifecycle hooks. Keep the client aggregate result while delivering report updates as each fetch settles; the app presentation follows separately. * fix(usage): retain account scope for open agent sessions Capture the launch environment once per session, independently of lazy child-process startup or exit. Use a session UUID for registry identity. Update remaining global discovery fixtures to the scoped SDK contract. * test(usage): update browser fixtures for scoped report streams * feat(usage): let a usage card show window rows without pins UsageCard takes a required pinnable prop. When false, a window row is a plain bar: no Pressable, hover highlight, or pin glyph. The settings Usage list passes true. * feat(usage): show the agent's usage in the context window popover Reports stream into the query cache as each update arrives, for the host list and for a new per-agent query keyed by agent. The context window popover fetches its agent's reports while open and renders one unpinnable card per report as it lands, a loading sentence before the first, the request error as a sentence, and nothing on hosts without usage reports. * feat(usage): hide the Refresh button on popover usage cards UsageCard takes a required refreshable prop. The popover passes false because its content takes no pointer events, so the button could not be pressed; the card then shows its freshness inline. The settings Usage list keeps the button. * feat(usage): make the sidebar Usage summary opt-in The sidebar Usage item starts hidden on every layout, not only on compact ones, so resolveSidebarNavItems drops its compact input. While the summary is off, the Usage list renders window rows without pins; saved pins stay in preferences and return when it is turned on. Specs that read the summary now turn it on first. * docs(glossary): describe the popover usage card and the opt-in sidebar summary * fix(usage): show a popover request error without retrying A daemon error for an agent's usage, such as an unknown agent, does not heal on retry, and reopening the popover fetches again. Retrying held the loading sentence for about seven seconds before the error. * test(usage): cover agent usage in the context window popover The usage fixture records agentId, streams reports held behind promises, and can switch host support between reloads. The meter spec walks loading, streaming, a report problem, a request error and a host without usage reports, on desktop and compact in both themes. * fix(usage): keep the plain context window tooltip on hosts without usage The popover widened to fit usage cards on every host. Hosts that cannot report usage now keep the tooltip's default padding and width, as before, and mount no usage section. * fix(usage): let a report's Refresh replace it in the agent's list too Refresh wrote the refreshed report only into the host's list, so a card rendered from an agent's list kept the old report. Both lists now sit under one host key and Refresh replaces the report in each that holds it. * feat(usage): open the context window details in a sheet on compact screens Wide screens keep the tooltip. On compact screens pressing the meter opens a content-sized bottom sheet titled Context window. Both render one ContextWindowDetails body; only the sheet's usage cards have Refresh, since the tooltip cannot be pressed. Pins stay off in both. * test(usage): cover the compact context window sheet On the compact viewport the meter opens the sheet; its cards have Refresh, a Refresh replaces the card, and Close dismisses the sheet. The glossary names the sheet. * feat(ui): give tooltips and the context window sheet accessible roles Tooltip content is role=tooltip, and the context window sheet is a dialog named Context window, so assistive tech and tests find them by role. The agent usage message drops its test ID; it is found by its text. * test(usage): reshape the context window and Usage switch specs as journeys The context window spec runs one tooltip journey and one sheet journey per theme, with no layout branches. Its fixture script, meter actions and report builders move to a context-window helper, and it finds the meter, tooltip, sheet and Refresh buttons by role. The theme comes from the system colour scheme. The Usage switch test uses named helpers for the Usage icon and the Summary in sidebar switch. * fix(usage): say an agent's usage request failed instead of showing older reports A failed request keeps whatever reports the query held: those from an earlier open, or those that streamed in before the failure. The context window details showed them as if they were the agent's current usage. The request error now wins over held reports. * test(usage): let a fixture clean up the context window spec's agent The tooltip and sheet tests no longer wrap their steps in try/finally; a spec-local fixture seeds the agent and removes it after the test. * fix(usage): keep a refreshed report when the list it is in finishes Pressing Refresh on one card while another source was still streaming put the pre-refresh report back: the finished list replaced the query's data with the copies the request had fetched when it started. A report now keeps whichever copy was fetched later, both when a streamed copy arrives and when the finished list replaces the streamed one. * fix(usage): show only the login a resumed agent runs under now An agent resumed under another login keeps its ID, so its usage query kept the previous login's reports: reopening the context window showed them while the new login's reports loaded, then both side by side until the request finished. The agent's usage is now dropped as soon as its tooltip or sheet closes, so each open loads the login the agent runs under at that moment. * fix(usage): stop a closed agent usage request from writing reports Closing the context window details cancels the agent's usage request and drops reports still on their way, so reopening after a resume under another login sends a new request instead of joining the old one.
660 lines
23 KiB
TypeScript
660 lines
23 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(),
|
|
})
|
|
.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 fallback = { key: hashAccountKey(JSON.stringify(input.route)), 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, 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;
|
|
}
|
|
|
|
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): Promise<ClaudeUsageResponse | number> {
|
|
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.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);
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|