mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 08:42:39 +00:00
* feat(server): run internal plugins in-process through the plugin runtime
* fix(server): harden internal plugin startup and boundary checks
* feat(usage): add plugin usage sources and Codex discovery
* fix(usage): share SDK helpers and bound source cache
* feat(usage): migrate usage fetchers to plugin sources
Serve the legacy usage RPC from discovered source reports so older apps retain the same host usage list while new clients use the source registry. Keep credential reads and normalization inside each source.
* refactor(usage): shape sources as fetch functions
Keep the plugin usage API limited to stable report and presentation helpers. Source-local parsers and timeout handling preserve credential and response parity while the cases move alongside each source.
* feat(usage): resolve reports from active session accounts
Resolve source inputs at fetch time so model and credential changes select the account each session spends. Keep the legacy discovered-account RPC for existing clients. Sync OpenCode model events outside foreground turns so usage follows model switches.
* fix usage source discovery and session report parity
* test adapter usage references through daemon RPC
* name usage reference contract across server
* refactor(usage): own usage RPCs and validate session auth
* feat(omp): resolve session usage from active OAuth credential
* fix(omp): match usage source input and valid credential fallback
* test(omp): use upstream credential database schema
* test(omp): verify live session usage references
* fix(usage): declare YAML parser and consolidate credential validation
* feat(app): show usage sources in the composer and a Usage screen
Replace the provider-matched usage in the context ring tooltip with a
composer pill driven by agent.get_usage_report, add a sidebar Usage
screen over usage.list_reports grouped by host, and move the host
settings Usage page to usage.list_reports. Hosts without
features.usageSources show an update message. Source icons come from
the report's catalog SVG. The app no longer calls provider.usage.list.
* fix(usage): declare the Paseo requirement on internal usage-source plugins
A manifest without requirements.paseo targets Paseo before 0.8, so the daemon
refused to start eight of the nine usage sources.
* fix(app): keep host connection statuses live under the React Compiler
The hook read a store version only as a memo trigger (void version); the
compiler dropped it, so pages that mounted before a host came online kept
showing it as connecting.
* fix(app): place the context ring slot before the usage pill
The ring reserves its slot before it has data. With the pill first, the empty
slot sat between the pill and the mic; now it falls in the flexible space.
* refactor(app): extract the settings page layout into PageLayout
Desktop puts the title in the page above a centered column; compact moves it
into a back header. Settings renders the same pixels through it.
* feat(app): lay out the Usage screen like a settings page
The title moves from the header bar into the page on desktop; compact gets the
settings back header, which returns home when the screen was opened by URL.
* refactor(app): drop the unused actions slot from PageLayout
* test(plugin): classify the usage source SDK entry
* fix(usage): preserve platform test contracts
* fix(settings): preserve page title test contract
* feat(usage): identify and cache reports by account ID
Keep report identity stable across input routes and token rotation, and make selective refresh operate on known account reports.
* feat(app): read usage reports through report IDs
Share report data between agent pills and host views while preserving the existing popover interaction for the next UI phase.
* fix(usage): keep account identity aligned with credentials
Read labels from local token claims and use one credential lookup for usage fetches and account identification. Bound cached profile identities across token rotation.
* fix(usage): isolate failure IDs and prune expired reports
Failure reports live outside the account-key namespace, and cache writes discard entries past the five-minute TTL.
* refactor(app): share the compact time-ago prose formatter
Move the tab tooltip's "3m" -> "3m ago" wording into utils/time so the
usage card can reuse it.
* feat(usage): show when each report was fetched
The usage card reads fetchedAt as "Updated 3m ago": a tooltip on the
card header where hover exists (web, desktop), printed in the card
footer on native and compact. Shared by the pill popover and the Usage
screen.
* feat(usage): open the pill popover from cache and refresh on demand
Opening the popover no longer forces a vendor fetch; it shows the cached
report. A Refresh control force-refreshes only this report
(usage.list_reports with its single ID and forceRefresh), shows
Refreshing... while it runs, keeps the previous report visible, and says
"Unable to refresh usage" if the request fails.
* feat(app): let ToolbarButton show a tooltip other than its label
An icon action can now say a status in its tooltip ("Updated 3m ago")
while keeping its label for accessibility.
* feat(usage): refresh a report from its card header
Refresh is an icon button at the right edge of every usage card header,
in the pill popover and on the Usage screen. It force-refreshes only that
report, writes the result to the report's cache and its host list, shows
a spinner and disables itself while pending, and prints "Unable to
refresh usage" in the card on failure. Its tooltip carries the
freshness; without hover the freshness stays in the card footer. The
status indicator sits just left of the button, and the popover's
separate refresh row is gone.
* fix(server): include internal plugin assets in built output
* feat(server): built-in plugins in /plugins (#5544)
* feat(server): load compiled built-in Codex plugin
* test(server): guard built-in plugin loading and installs
* test(server): verify built-in plugins from dist in CI
* refactor(server): migrate remaining usage sources to built-in plugins
* test(server): guard all packaged usage source registrations
* docs: describe usage validation across plugin hosts
* test(ci): include built-in plugins in routing contract
* test(server): import moved Codex usage source
* fix(server): retain ACP updates received with session creation
546 lines
19 KiB
TypeScript
546 lines
19 KiB
TypeScript
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,
|
|
unavailableUsage,
|
|
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(),
|
|
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 {
|
|
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;
|
|
}> = [
|
|
{ field: "five_hour", id: "five_hour", label: "Session" },
|
|
{ field: "seven_day", id: "weekly", label: "Weekly" },
|
|
];
|
|
|
|
// 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,
|
|
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}`,
|
|
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(),
|
|
): Promise<unknown | null> {
|
|
const lookups = [
|
|
["find-generic-password", "-a", account, "-w", "-s", CLAUDE_KEYCHAIN_SERVICE],
|
|
["find-generic-password", "-w", "-s", CLAUDE_KEYCHAIN_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 {
|
|
platform?: NodeJS.Platform;
|
|
readKeychainCredentials?: () => Promise<unknown | null>;
|
|
claudeHome?: string;
|
|
accountHome?: string;
|
|
}
|
|
|
|
/** Shared credential lookup for usage fetches and account identification. */
|
|
export async function resolveClaudeCredentials(
|
|
input: UsageInput,
|
|
lookup: ClaudeCredentialLookup = {},
|
|
): Promise<ClaudeCredentialRecord | null> {
|
|
if ("accessToken" in input) return { oauth: { accessToken: input.accessToken } };
|
|
if ("configDir" in input) return readCredentialFile(join(input.configDir, ".credentials.json"));
|
|
const claudeHome = lookup.claudeHome ?? process.env["CLAUDE_HOME"] ?? join(homedir(), ".claude");
|
|
const fileCredentials = await readCredentialFile(join(claudeHome, ".credentials.json"));
|
|
if (fileCredentials) return fileCredentials;
|
|
if ((lookup.platform ?? process.platform) !== "darwin") return null;
|
|
const parsed = ClaudeCredentialsSchema.safeParse(
|
|
await (lookup.readKeychainCredentials ?? readClaudeKeychainCredentials)(),
|
|
);
|
|
return parsed.success ? toCredentialRecord(parsed.data) : 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 } } : 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 | "NEEDS_AUTH"> {
|
|
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 "NEEDS_AUTH";
|
|
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) {
|
|
return unavailableUsage();
|
|
}
|
|
|
|
const { oauth } = credentials;
|
|
const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier);
|
|
const resp = await callClaudeApi(oauth.accessToken);
|
|
|
|
if (resp === "NEEDS_AUTH") {
|
|
// Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md.
|
|
return unavailableUsage();
|
|
}
|
|
|
|
const scoped = reconcileScopedLimits(
|
|
legacyScopedLimits(resp),
|
|
scopedLimitsFromResponse(resp.limits),
|
|
);
|
|
const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)];
|
|
if (windows[0]) windows[0].headline = true;
|
|
|
|
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.
|
|
const ClaudeAccountSchema = z.object({
|
|
accountUuid: z.string().min(1),
|
|
organizationUuid: z.string().min(1),
|
|
emailAddress: z.string().optional(),
|
|
});
|
|
type ClaudeIdentity = { key: string; label?: string } | null;
|
|
const PROFILE_TTL_MS = 300_000;
|
|
const PROFILE_CACHE_LIMIT = 128;
|
|
const profileCache = new Map<string, { at: number; result: Promise<ClaudeIdentity> }>();
|
|
|
|
function pruneProfileCache(now: number): void {
|
|
for (const [key, entry] of profileCache) {
|
|
if (now - entry.at >= PROFILE_TTL_MS) profileCache.delete(key);
|
|
}
|
|
}
|
|
|
|
export async function identify(
|
|
input: UsageInput,
|
|
fetchApi: typeof fetch = fetch,
|
|
now: () => number = Date.now,
|
|
credentialLookup: ClaudeCredentialLookup = {},
|
|
) {
|
|
const directory =
|
|
"configDir" in input ? input.configDir : (credentialLookup.accountHome ?? homedir());
|
|
if (!("accessToken" in input)) {
|
|
try {
|
|
const config = z
|
|
.object({ oauthAccount: ClaudeAccountSchema })
|
|
.parse(JSON.parse(await fs.readFile(join(directory, ".claude.json"), "utf8")));
|
|
const account = config.oauthAccount;
|
|
return {
|
|
key: `${account.accountUuid}.${account.organizationUuid}`,
|
|
...(account.emailAddress ? { label: account.emailAddress } : {}),
|
|
};
|
|
} catch {
|
|
// Credentials may still be present even when account metadata is absent.
|
|
}
|
|
}
|
|
const credentials = await resolveClaudeCredentials(input, credentialLookup);
|
|
if (!credentials) return null;
|
|
const token = credentials.oauth.accessToken;
|
|
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 null;
|
|
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;
|
|
}
|
|
}
|