Files
fusion/packages/dashboard/src/usage.ts
gsxdsm fb6db73a89 fix(FN-1014): add fallback reset time for Claude session window when API omits resets_at
- When the Claude usage API omits resets_at for the 5-hour session window, fall back to using the full window duration as resetMs
- This enables pace calculation and reset text display even without an explicit reset timestamp
- Update existing test to verify fallback behavior (resetMs = 5h, resetText = 'resets in 5h')
- Add new test for pace calculation with fallback reset time
2026-04-05 19:45:17 -07:00

1449 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from "node:fs";
import * as path from "node:path";
import * as https from "node:https";
import * as child_process from "node:child_process";
/**
* Pace information for weekly usage windows
*/
export interface UsagePace {
status: "ahead" | "on-track" | "behind";
percentElapsed: number; // 0-100
message: string;
}
/**
* Usage window for a provider (e.g., "Session (5h)", "Weekly")
*/
export interface UsageWindow {
label: string;
percentUsed: number; // 0-100
percentLeft: number; // 0-100
resetText: string | null; // e.g., "resets in 2h"
resetMs?: number; // ms until reset
resetAt?: string; // ISO 8601 timestamp of when the window resets (machine-readable)
windowDurationMs?: number; // total window length
pace?: UsagePace; // pace indicator for weekly windows
}
/**
* Provider usage data
*/
export interface ProviderUsage {
name: string;
icon: string; // emoji
status: "ok" | "error" | "no-auth";
error?: string;
plan?: string | null;
email?: string | null;
windows: UsageWindow[];
}
/**
* Auth storage interface - minimal interface matching pi-coding-agent's AuthStorage
*/
export interface AuthStorageLike {
reload(): void;
hasAuth(provider: string): boolean;
}
// Cache for usage data with TTL
interface CacheEntry {
data: ProviderUsage[];
timestamp: number;
}
let usageCache: CacheEntry | null = null;
const CACHE_TTL_MS = 30_000; // 30 seconds
// Pace threshold - matches frontend UsageIndicator.tsx
const PACE_THRESHOLD = 5; // 5% threshold for "on pace"
/**
* Calculate pace information for a usage window.
* Returns undefined if pace cannot be calculated (e.g., missing timing data or window reset).
*/
export function calculatePace(
percentUsed: number,
resetMs: number | undefined,
windowDurationMs: number | undefined
): UsagePace | undefined {
// Validate inputs
if (resetMs === undefined || windowDurationMs === undefined) {
return undefined;
}
// Window already reset or invalid duration
if (resetMs <= 0 || windowDurationMs <= 0) {
return undefined;
}
// Clamp percentUsed to valid range
const clampedPercentUsed = Math.min(100, Math.max(0, percentUsed));
// Calculate percent of time elapsed in the window
// percentElapsed = 100 - (remainingTime / totalTime * 100)
const percentElapsed = 100 - (resetMs / windowDurationMs * 100);
// Calculate delta between usage and elapsed time
const paceDelta = clampedPercentUsed - percentElapsed;
// Determine status based on threshold
if (paceDelta > PACE_THRESHOLD) {
return {
status: "ahead",
percentElapsed: Math.round(percentElapsed),
message: `${Math.abs(Math.round(paceDelta))}% over pace`,
};
} else if (paceDelta < -PACE_THRESHOLD) {
return {
status: "behind",
percentElapsed: Math.round(percentElapsed),
message: `${Math.abs(Math.round(paceDelta))}% under pace`,
};
} else {
return {
status: "on-track",
percentElapsed: Math.round(percentElapsed),
message: "On pace with time elapsed",
};
}
}
/**
* Apply pace calculation to a usage window if applicable.
* Applies to any window with valid timing data (resetMs and windowDurationMs).
*/
function applyPaceToWindow(window: UsageWindow): UsageWindow {
// Apply pace to any window that has both resetMs and windowDurationMs
if (window.resetMs === undefined || window.windowDurationMs === undefined) {
return window;
}
const pace = calculatePace(window.percentUsed, window.resetMs, window.windowDurationMs);
if (pace) {
return { ...window, pace };
}
return window;
}
/**
* Format duration in milliseconds to human-readable string
*/
function formatDuration(ms: number): string {
if (ms <= 0) return "now";
const secs = Math.floor(ms / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const remSecs = secs % 60;
if (mins < 60) return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`;
const hours = Math.floor(mins / 60);
const remMins = mins % 60;
if (hours < 24) return remMins > 0 ? `${hours}h ${remMins}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours > 0 ? `${days}d ${remHours}h` : `${days}d`;
}
/**
* Make HTTPS request and return response
*/
function httpsRequest(
url: string,
options: {
method?: string;
headers?: Record<string, string>;
body?: string;
timeout?: number;
}
): Promise<{ status: number; headers: Record<string, string>; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const req = https.request(
{
hostname: parsed.hostname,
port: parsed.port || 443,
path: parsed.pathname + parsed.search,
method: options.method || "GET",
headers: options.headers || {},
timeout: options.timeout || 15000,
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
const hdrs: Record<string, string> = {};
for (const [k, v] of Object.entries(res.headers)) {
if (typeof v === "string") hdrs[k.toLowerCase()] = v;
else if (Array.isArray(v)) hdrs[k.toLowerCase()] = v.join(", ");
}
resolve({
status: res.statusCode || 0,
headers: hdrs,
body: Buffer.concat(chunks).toString("utf-8"),
});
});
}
);
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Request timed out"));
});
if (options.body) req.write(options.body);
req.end();
});
}
/**
* Decode JWT payload without verification
*/
function decodeJwtPayload(token: string): any {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const payload = Buffer.from(parts[1], "base64url").toString("utf-8");
return JSON.parse(payload);
} catch {
return null;
}
}
// ── Pi auth storage reader ──────────────────────────────────────────────────
/**
* Read an API key from pi's auth storage (~/.pi/agent/auth.json).
* Returns the API key string or null if not found.
*/
function readPiAuthKey(provider: string): string | null {
const authPath = path.join(process.env.HOME || "~", ".pi", "agent", "auth.json");
try {
const auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
const entry = auth?.[provider];
if (entry && (entry.type === "api_key" || entry.type === "key") && entry.key) {
return entry.key;
}
} catch {}
return null;
}
// ── Claude fetcher ─────────────────────────────────────────────────────────
/**
* Read Claude credentials from macOS keychain.
* Returns the parsed credentials object or null if not found/error.
*/
function readClaudeKeychainCredentials(): any | null {
try {
const result = child_process.execFileSync(
"security",
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
{ encoding: "utf-8", timeout: 5000 }
);
return JSON.parse(result.trim());
} catch {
return null;
}
}
/** Max number of retries for transient 429 responses */
const CLAUDE_MAX_RETRIES = 3;
/** Initial retry delay in ms (doubles each attempt) */
const CLAUDE_INITIAL_RETRY_MS = 1000;
/**
* In-memory cache for refreshed OAuth access tokens.
* Never written back to disk/keychain — only lives for the process lifetime.
*/
let refreshedAccessToken: string | null = null;
/**
* Anthropic OAuth token refresh endpoint on the Claude platform.
* The OAuth token endpoint lives on platform.claude.com (not console.anthropic.com)
* per the Anthropic OAuth 2.0 specification.
*/
const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token";
/**
* Public OAuth client ID for the Claude CLI / first-party OAuth flow.
* Required as `client_id` in token refresh requests per the OAuth 2.0 spec.
*/
const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
/**
* Check whether an OAuth access token is expired using the `expiresAt` timestamp
* from the credential store. Returns true if expired or expiring within 60 seconds.
*/
function isTokenExpired(expiresAt: number | undefined): boolean {
if (expiresAt === undefined) return false; // No expiry info — assume valid
const bufferMs = 60_000; // Treat tokens expiring within 60s as expired
return Date.now() >= expiresAt - bufferMs;
}
/**
* Attempt to refresh the OAuth access token using the refresh token.
* Returns the new access token on success, or null on failure.
* The refreshed token is cached in memory only (not written to disk/keychain).
*/
async function refreshClaudeAccessToken(refreshToken: string): Promise<string | null> {
try {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
}).toString();
const res = await httpsRequest(ANTHROPIC_TOKEN_ENDPOINT, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
timeout: 10_000, // 10s timeout for refresh
});
if (res.status !== 200) {
return null;
}
const data = JSON.parse(res.body);
const newToken = data.access_token || data.accessToken;
if (newToken) {
// Cache in memory only — never written back to disk/keychain
refreshedAccessToken = newToken;
return newToken;
}
return null;
} catch {
return null;
}
}
/** Clear the in-memory refreshed token cache (for testing) */
export function _clearRefreshedToken(): void {
refreshedAccessToken = null;
}
/**
* Sleep for the given duration. Exported for test mocking.
*/
export const _sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
// Allow tests to swap the sleep implementation
let sleepFn = _sleep;
export function _setSleepFn(fn: typeof _sleep): void {
sleepFn = fn;
}
export function _resetSleepFn(): void {
sleepFn = _sleep;
}
// ── Claude CLI fallback (parses `claude /usage` TUI output) ──────────────────
/**
* Strip ANSI escape codes from Claude CLI output.
* Handles cursor-forward (ESC[nC) by converting to spaces to preserve word
* boundaries — the Claude TUI uses these instead of real spaces.
*/
export function _stripClaudeAnsi(text: string): string {
let clean = text
// Cursor forward (CSI n C): replace with n spaces
.replace(/\x1B\[(\d+)C/g, (_m, n) => " ".repeat(parseInt(n, 10)))
// Cursor movement (up/down/back/position)
.replace(/\x1B\[\d*[ABD]/g, "")
.replace(/\x1B\[\d+;\d+[Hf]/g, "\n")
// Remaining CSI sequences (colors, modes, etc.)
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, "")
// OSC sequences
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, "")
// Other ESC sequences
.replace(/\x1B[A-Za-z]/g, "")
// Carriage returns
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
// Handle backspaces
while (clean.includes("\x08")) {
clean = clean.replace(/[^\x08]\x08/, "");
clean = clean.replace(/^\x08+/, "");
}
// Strip remaining non-printable control characters (except newline)
clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "");
return clean;
}
/**
* Parse a percentage line from Claude CLI usage output.
* Lines look like: "█████████████▌ 27% used" or "████████ 65% left"
* Returns the USED percentage (0-100).
*/
export function _parseClaudePercentLine(line: string): number | null {
const match = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i);
if (!match) return null;
const value = parseInt(match[1], 10);
const isUsed = match[2].toLowerCase() === "used";
return isUsed ? value : 100 - value;
}
/**
* Parse a reset line from Claude CLI usage output.
* Lines like: "Resets in 2h 15m", "Resets 11am", "Resets Feb 19 at 3pm"
*/
export function _parseClaudeResetLine(line: string): string | null {
const match = line.match(/(Resets?.*)$/i);
if (!match) return null;
let text = match[1];
// Clean up percentage info that might be on the same line
text = text.replace(/(\d{1,3})\s*%\s*(left|used|remaining)/i, "").trim();
// Ensure space after "Resets" if missing
text = text.replace(/(resets?)(\d)/i, "$1 $2");
// Strip timezone like "(America/Los_Angeles)"
text = text.replace(/\s*\([A-Za-z_/]+\)\s*$/, "").trim();
return text || null;
}
/**
* Parse a reset-time text into an approximate ISO date string.
*/
export function _parseClaudeResetText(text: string): string | null {
const now = Date.now();
// "Resets in 2h 15m" or "Resets in 30m"
const durationMatch = text.match(/(\d+)\s*h(?:ours?)?(?:\s+(\d+)\s*m(?:in)?)?|(\d+)\s*m(?:in)?/i);
if (durationMatch) {
let hours = 0;
let minutes = 0;
if (durationMatch[1]) {
hours = parseInt(durationMatch[1], 10);
minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0;
} else if (durationMatch[3]) {
minutes = parseInt(durationMatch[3], 10);
}
return new Date(now + (hours * 60 + minutes) * 60 * 1000).toISOString();
}
// "Resets 11am" or "Resets 3pm"
const simpleTimeMatch = text.match(/resets?\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
if (simpleTimeMatch) {
let hours = parseInt(simpleTimeMatch[1], 10);
const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0;
const ampm = simpleTimeMatch[3].toLowerCase();
if (ampm === "pm" && hours !== 12) hours += 12;
else if (ampm === "am" && hours === 12) hours = 0;
const resetDate = new Date(now);
resetDate.setHours(hours, minutes, 0, 0);
if (resetDate.getTime() <= now) resetDate.setDate(resetDate.getDate() + 1);
return resetDate.toISOString();
}
// "Resets Feb 19 at 3pm" or "Resets Jan 15, 3:30pm"
// Note: \s+at\s* (not \s+at\s+) to handle CLI output where "at" may be
// immediately followed by the time with no space (e.g. "at3pm" from TUI cursor-forward).
const dateMatch = text.match(
/(?:resets?\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s*|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
);
if (dateMatch) {
const months: Record<string, number> = {
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
};
const month = months[dateMatch[1].toLowerCase().substring(0, 3)];
const day = parseInt(dateMatch[2], 10);
let hours = parseInt(dateMatch[3], 10);
const minutes = dateMatch[4] ? parseInt(dateMatch[4], 10) : 0;
const ampm = dateMatch[5].toLowerCase();
if (ampm === "pm" && hours !== 12) hours += 12;
else if (ampm === "am" && hours === 12) hours = 0;
if (month !== undefined) {
const resetDate = new Date(new Date().getFullYear(), month, day, hours, minutes);
if (resetDate.getTime() < now) resetDate.setFullYear(resetDate.getFullYear() + 1);
return resetDate.toISOString();
}
}
return null;
}
/**
* Fetch Claude usage by spawning `claude /usage` via PTY and parsing the TUI output.
* Used as a fallback when the OAuth API returns 429 (rate limited).
*/
async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Claude",
icon: "🟠",
status: "error",
windows: [],
};
try {
// Dynamically import node-pty
const pty = await import("node-pty");
const isWindows = process.platform === "win32";
const shell = isWindows ? "cmd.exe" : "/bin/sh";
const cwd = process.cwd();
const args = isWindows
? ["/c", "claude", "--add-dir", cwd]
: ["-c", `claude --add-dir "${cwd}"`];
const ptyOptions: any = {
name: "xterm-256color",
cols: 120,
rows: 30,
cwd,
env: { ...process.env, TERM: "xterm-256color" },
};
if (isWindows) ptyOptions.useConpty = false;
const output = await new Promise<string>((resolve, reject) => {
let buf = "";
let settled = false;
let sentCommand = false;
let approvedTrust = false;
let seenUsageData = false;
const ptyProcess = pty.spawn(shell, args, ptyOptions);
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
try { ptyProcess.kill(); } catch {}
// Return whatever we have if it contains usage data
const clean = _stripClaudeAnsi(buf);
if (clean.includes("Current session") || clean.includes("% left") || clean.includes("% used")) {
resolve(buf);
} else {
reject(new Error("Claude CLI timed out after 60s — got output but no usage data. Try running `claude /usage` manually."));
}
}, 60000);
ptyProcess.onData((data: string) => {
if (settled) return;
buf += data;
const clean = _stripClaudeAnsi(buf);
// Check for auth errors
if (
clean.includes("OAuth token does not meet scope requirement") ||
clean.includes("token_expired") ||
clean.includes('"type":"authentication_error"') ||
clean.includes('"type": "authentication_error"')
) {
settled = true;
clearTimeout(timeout);
try { ptyProcess.kill(); } catch {}
reject(new Error("Claude CLI auth error"));
return;
}
// Auto-approve trust prompt
if (
!approvedTrust &&
(clean.includes("Do you want to work in this folder?") ||
clean.includes("Ready to code here") ||
clean.includes("permission to work with your files") ||
clean.includes("trust this folder"))
) {
approvedTrust = true;
setTimeout(() => {
if (!settled) ptyProcess.write("\r");
}, 1000);
}
// Detect REPL prompt and send /usage
const isReplReady =
clean.includes("") ||
clean.includes("? for shortcuts");
if (!sentCommand && isReplReady) {
sentCommand = true;
setTimeout(() => {
if (!settled) {
ptyProcess.write("/usage\r");
// Confirm if autocomplete menu appeared
setTimeout(() => {
if (!settled) ptyProcess.write("\r");
}, 1200);
}
}, 1500);
}
// Detect usage data, then exit after brief delay
const hasUsage =
clean.includes("Current session") ||
clean.includes("Current week") ||
/\d+%\s*(left|used|remaining)/i.test(clean);
if (!seenUsageData && hasUsage && sentCommand) {
seenUsageData = true;
setTimeout(() => {
if (!settled) {
ptyProcess.write("\x1b"); // ESC to exit
// Fallback kill after 2s
setTimeout(() => {
if (!settled) {
settled = true;
clearTimeout(timeout);
try { ptyProcess.kill(); } catch {}
resolve(buf);
}
}, 2000);
}
}, 3000);
}
});
ptyProcess.onExit(() => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(buf);
});
});
// Parse the output
const cleanOutput = _stripClaudeAnsi(output);
const lines = cleanOutput.split("\n").map((l) => l.trim()).filter(Boolean);
// Find sections by looking for known headers (use LAST occurrence since PTY output has redraws)
const sections: { label: string; windowMs: number }[] = [
{ label: "Current session", windowMs: 5 * 60 * 60 * 1000 },
{ label: "Current week (all models)", windowMs: 7 * 24 * 60 * 60 * 1000 },
{ label: "Current week (Sonnet", windowMs: 7 * 24 * 60 * 60 * 1000 },
{ label: "Current week (Opus", windowMs: 7 * 24 * 60 * 60 * 1000 },
];
usage.status = "ok";
for (const section of sections) {
// Find last occurrence
let sectionIdx = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].toLowerCase().includes(section.label.toLowerCase())) {
sectionIdx = i;
break;
}
}
if (sectionIdx === -1) continue;
const searchLines = lines.slice(sectionIdx, sectionIdx + 5);
let percentUsed: number | null = null;
let resetText: string | null = null;
for (const line of searchLines) {
if (percentUsed === null) {
percentUsed = _parseClaudePercentLine(line);
}
if (!resetText) {
resetText = _parseClaudeResetLine(line);
}
}
if (percentUsed !== null) {
const window: UsageWindow = {
label: section.label,
percentUsed: Math.min(100, Math.max(0, percentUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
resetText,
windowDurationMs: section.windowMs,
resetMs: undefined,
};
// Parse reset time to calculate resetMs
if (resetText) {
const iso = _parseClaudeResetText(resetText);
if (iso) {
const msLeft = new Date(iso).getTime() - Date.now();
window.resetMs = msLeft > 0 ? msLeft : 0;
window.resetAt = iso;
// Always replace raw CLI text (e.g. "Resets Apr 9 at 8pm") with
// a relative duration (e.g. "resets in 3d 8h") for better UX.
window.resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
}
}
usage.windows.push(window);
}
}
if (usage.windows.length === 0) {
usage.status = "error";
usage.error = "Could not parse usage from CLI output";
}
} catch (e: any) {
usage.status = "error";
usage.error = e.message || "CLI fallback failed";
}
return usage;
}
/**
* Fetch Claude usage data via the Anthropic OAuth usage API.
*
* Reads credentials from the Claude CLI's credential store (files or macOS
* keychain) and calls api.anthropic.com/api/oauth/usage directly.
* Includes retry logic with exponential backoff for transient 429 responses.
* Falls back to parsing `claude /usage` CLI output when rate limited.
*/
async function fetchClaudeUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Claude",
icon: "🟠",
status: "no-auth",
windows: [],
};
// ── Credential reading for plan detection & auth check ──────────────
const credPaths = [
path.join(process.env.HOME || "~", ".claude", ".credentials.json"),
path.join(process.env.HOME || "~", ".config", "claude", ".credentials.json"),
];
let creds: any = null;
for (const p of credPaths) {
try {
creds = JSON.parse(fs.readFileSync(p, "utf-8"));
break;
} catch {}
}
// Fallback to macOS keychain if file credentials not found
if (!creds) {
creds = readClaudeKeychainCredentials();
}
const oauthCreds = creds?.claudeAiOauth || creds;
if (!oauthCreds?.accessToken) {
usage.error = "No Claude CLI credentials — run 'claude' to login";
return usage;
}
// Check scopes
const scopes: string[] = oauthCreds.scopes || [];
if (!scopes.includes("user:profile")) {
usage.error = "Claude CLI token missing user:profile scope";
return usage;
}
// Infer plan from credential metadata
if (oauthCreds.subscriptionType) {
usage.plan = oauthCreds.subscriptionType.charAt(0).toUpperCase() + oauthCreds.subscriptionType.slice(1);
} else if (oauthCreds.rateLimitTier) {
const tier = oauthCreds.rateLimitTier.toLowerCase();
if (tier.includes("max")) usage.plan = "Max";
else if (tier.includes("pro")) usage.plan = "Pro";
else if (tier.includes("team")) usage.plan = "Team";
else usage.plan = oauthCreds.rateLimitTier;
}
// ── Resolve the best available access token ─────────────────────────
// If we have a previously refreshed token in memory, prefer it.
// Otherwise check if the stored token is expired and attempt refresh.
let activeToken: string = refreshedAccessToken || oauthCreds.accessToken;
const tokenExpired = isTokenExpired(oauthCreds.expiresAt);
if (tokenExpired && !refreshedAccessToken) {
// Token is expired — attempt refresh before calling the usage API
if (oauthCreds.refreshToken) {
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken);
if (newToken) {
activeToken = newToken;
} else {
// Refresh failed — fall back to CLI which has its own auth mechanism
return fetchClaudeUsageViaCli();
}
} else {
// No refresh token available — fall back to CLI which has its own auth
return fetchClaudeUsageViaCli();
}
}
// ── Fetch usage via direct API call with retry for 429 ─────────────
try {
let res: { status: number; headers: Record<string, string>; body: string } | undefined;
let lastStatus = 0;
for (let attempt = 0; attempt < CLAUDE_MAX_RETRIES; attempt++) {
res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
method: "GET",
headers: {
authorization: `Bearer ${activeToken}`,
},
});
lastStatus = res.status;
// Auth errors — attempt token refresh once before giving up
if (res.status === 401 || res.status === 403) {
if (oauthCreds.refreshToken && activeToken !== refreshedAccessToken) {
// Try refreshing the token as a recovery path
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken);
if (newToken) {
activeToken = newToken;
continue; // Retry with refreshed token
}
}
// All refresh attempts exhausted — fall back to CLI which has its own auth
return fetchClaudeUsageViaCli();
}
// 429 is potentially transient — retry with exponential backoff
if (res.status === 429) {
if (attempt < CLAUDE_MAX_RETRIES - 1) {
// Use retry-after header if available, otherwise exponential backoff
const retryAfter = res.headers["retry-after"];
let delayMs: number;
if (retryAfter && !isNaN(Number(retryAfter))) {
delayMs = Number(retryAfter) * 1000;
} else {
delayMs = CLAUDE_INITIAL_RETRY_MS * Math.pow(2, attempt);
}
await sleepFn(delayMs);
continue;
}
// All retries exhausted — fall back to CLI parsing
return fetchClaudeUsageViaCli();
}
// Any other non-200 status — fail immediately (not transient)
if (res.status !== 200) {
usage.status = "error";
const bodySnippet = res.body ? res.body.slice(0, 100).replace(/\n/g, " ") : "";
usage.error = bodySnippet ? `HTTP ${res.status}: ${bodySnippet}` : `HTTP ${res.status}`;
return usage;
}
// Success — break out of retry loop
break;
}
if (!res || lastStatus !== 200) {
usage.status = "error";
usage.error = `HTTP ${lastStatus}`;
return usage;
}
const data = JSON.parse(res.body);
usage.status = "ok";
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const parseWindow = (key: string, label: string, windowDurationMs: number): UsageWindow | null => {
const w = data[key];
if (!w || typeof w !== "object") return null;
const pctUsed: number = w.utilization ?? w.percent_used ?? w.percentUsed ?? 0;
let resetText: string | null = null;
let resetMs: number | undefined;
const resetAtValue = w.resets_at || w.reset_at || w.resetAt;
if (resetAtValue) {
const msLeft = new Date(resetAtValue).getTime() - Date.now();
resetMs = msLeft > 0 ? msLeft : 0;
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
} else if (windowDurationMs === FIVE_HOURS_MS) {
// Fallback for session window: when the API doesn't provide reset time,
// use the full window duration as a best-effort estimate. This enables
// pace calculation and reset text for the session (5h) window even when
// the API omits resets_at / reset_at / resetAt fields.
resetMs = windowDurationMs;
resetText = "resets in 5h";
}
return {
label,
percentUsed: Math.min(100, Math.max(0, pctUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)),
resetText,
windowDurationMs,
resetMs,
resetAt: resetAtValue || undefined,
};
};
const fiveHour = parseWindow("five_hour", "Session (5h)", FIVE_HOURS_MS);
const sevenDay = parseWindow("seven_day", "Weekly", SEVEN_DAYS_MS);
const sonnet = parseWindow("seven_day_sonnet", "Weekly (Sonnet)", SEVEN_DAYS_MS);
const opus = parseWindow("seven_day_opus", "Weekly (Opus)", SEVEN_DAYS_MS);
if (fiveHour) usage.windows.push(fiveHour);
if (sevenDay) usage.windows.push(sevenDay);
if (sonnet) usage.windows.push(sonnet);
if (opus) usage.windows.push(opus);
} catch (e: any) {
usage.status = "error";
usage.error = e.message || "Failed to fetch Claude usage";
}
return usage;
}
// ── Codex fetcher ──────────────────────────────────────────────────────────
async function fetchCodexUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Codex",
icon: "🟢",
status: "no-auth",
windows: [],
};
// Load Codex auth
const codexHome = process.env.CODEX_HOME || path.join(process.env.HOME || "~", ".codex");
const authPath = path.join(codexHome, "auth.json");
let auth: any = null;
try {
auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
} catch {
usage.error = "No Codex credentials — run 'codex' to login";
return usage;
}
const accessToken = auth?.tokens?.access_token;
if (!accessToken) {
usage.error = "No Codex access token found";
return usage;
}
// Extract plan and email from id_token
if (auth?.tokens?.id_token) {
const claims = decodeJwtPayload(auth.tokens.id_token);
if (claims) {
usage.email = claims.email || null;
const openaiAuth = claims["https://api.openai.com/auth"];
if (openaiAuth?.chatgpt_plan_type) {
usage.plan = openaiAuth.chatgpt_plan_type.charAt(0).toUpperCase() + openaiAuth.chatgpt_plan_type.slice(1);
}
}
}
try {
const res = await httpsRequest("https://chatgpt.com/backend-api/wham/usage", {
method: "GET",
headers: {
authorization: `Bearer ${accessToken}`,
},
});
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired — run 'codex' to re-login";
return usage;
}
if (res.status !== 200) {
usage.status = "error";
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
return usage;
}
const data = JSON.parse(res.body);
usage.status = "ok";
// Override email/plan from response if available
if (data.email) usage.email = data.email;
if (data.plan_type) usage.plan = data.plan_type.charAt(0).toUpperCase() + data.plan_type.slice(1);
const parseWindow = (win: any, label: string): UsageWindow | null => {
if (!win || typeof win !== "object") return null;
const pctUsed: number = win.used_percent ?? 0;
let resetText: string | null = null;
let resetMs: number | undefined;
const windowDurationMs: number | undefined = win.limit_window_seconds
? win.limit_window_seconds * 1000
: undefined;
let resetAt: string | undefined;
if (win.reset_at) {
const msLeft = win.reset_at * 1000 - Date.now();
resetMs = msLeft > 0 ? msLeft : 0;
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
resetAt = new Date(win.reset_at * 1000).toISOString();
} else if (win.reset_after_seconds) {
resetMs = win.reset_after_seconds * 1000;
resetText = `resets in ${formatDuration(resetMs)}`;
resetAt = new Date(Date.now() + resetMs).toISOString();
}
return {
label,
percentUsed: Math.min(100, Math.max(0, pctUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)),
resetText,
windowDurationMs,
resetMs,
resetAt,
};
};
// Main rate limits
if (data.rate_limit) {
const primary = parseWindow(data.rate_limit.primary_window, "Session (5h)");
const secondary = parseWindow(data.rate_limit.secondary_window, "Weekly");
if (primary) usage.windows.push(primary);
if (secondary) usage.windows.push(secondary);
}
} catch (e: any) {
usage.status = "error";
usage.error = e.message || "Failed to fetch";
}
return usage;
}
// ── Gemini fetcher ─────────────────────────────────────────────────────────
async function fetchGeminiUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Gemini",
icon: "🔵",
status: "no-auth",
windows: [],
};
// Load Gemini OAuth credentials
const oauthPath = path.join(process.env.HOME || "~", ".gemini", "oauth_creds.json");
let oauthCreds: any = null;
try {
oauthCreds = JSON.parse(fs.readFileSync(oauthPath, "utf-8"));
} catch {
usage.error = "No Gemini credentials — run 'gemini' to login";
return usage;
}
if (!oauthCreds?.access_token) {
usage.error = "No Gemini access token found";
return usage;
}
// Extract email from id_token
if (oauthCreds.id_token) {
const claims = decodeJwtPayload(oauthCreds.id_token);
if (claims?.email) usage.email = claims.email;
}
// Check auth type from settings
const settingsPath = path.join(process.env.HOME || "~", ".gemini", "settings.json");
try {
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
const authType = settings?.security?.auth?.selectedType;
if (authType === "api-key" || authType === "vertex-ai") {
usage.status = "error";
usage.error = `Unsupported auth type: ${authType} (need oauth-personal)`;
return usage;
}
} catch {}
try {
const res = await httpsRequest(
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${oauthCreds.access_token}`,
},
body: JSON.stringify({}),
}
);
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired — run 'gemini' to re-login";
return usage;
}
if (res.status !== 200) {
usage.status = "error";
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
return usage;
}
const data = JSON.parse(res.body);
usage.status = "ok";
// Parse buckets array
const buckets: any[] = data.buckets || [];
if (Array.isArray(buckets) && buckets.length > 0) {
// Group by model family, pick lowest remainingFraction per family
const modelGroups = new Map<string, { pctLeft: number; resetText: string | null; resetMs: number | undefined; resetAt: string | undefined; models: string[] }>();
for (const b of buckets) {
const modelId: string = b.modelId || "unknown";
const remainFrac: number = b.remainingFraction ?? 1;
const pctLeft = remainFrac * 100;
let resetText: string | null = null;
let resetMs: number | undefined;
let resetAt: string | undefined;
if (b.resetTime) {
const msLeft = new Date(b.resetTime).getTime() - Date.now();
resetMs = msLeft > 0 ? msLeft : 0;
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
resetAt = new Date(b.resetTime).toISOString();
}
// Skip _vertex duplicates, classify by family
if (modelId.endsWith("_vertex")) continue;
let family: string;
if (modelId.includes("pro")) family = "Pro models";
else if (modelId.includes("flash-lite")) family = "Flash Lite";
else if (modelId.includes("flash")) family = "Flash models";
else family = modelId;
const existing = modelGroups.get(family);
if (!existing || pctLeft < existing.pctLeft) {
modelGroups.set(family, {
pctLeft,
resetText,
resetMs,
resetAt,
models: existing ? [...existing.models, modelId] : [modelId],
});
} else {
existing.models.push(modelId);
}
}
// Gemini rate limits reset daily (24 hours)
const DAILY_WINDOW_MS = 24 * 60 * 60 * 1000;
for (const [family, info] of modelGroups) {
usage.windows.push({
label: family,
percentUsed: Math.min(100, Math.max(0, 100 - info.pctLeft)),
percentLeft: Math.min(100, Math.max(0, info.pctLeft)),
resetText: info.resetText,
resetMs: info.resetMs,
resetAt: info.resetAt,
windowDurationMs: DAILY_WINDOW_MS,
});
}
}
} catch (e: any) {
usage.status = "error";
usage.error = e.message || "Failed to fetch";
}
return usage;
}
// ── Minimax fetcher ─────────────────────────────────────────────────────────
async function fetchMinimaxUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Minimax",
icon: "🟣",
status: "no-auth",
windows: [],
};
// Load Minimax API key from pi's auth storage
const apiKey = readPiAuthKey("minimax");
if (!apiKey) {
usage.error = "No Minimax credentials — add API key to pi";
return usage;
}
try {
const res = await httpsRequest("https://api.minimax.io/v1/api/openplatform/coding_plan/remains", {
method: "GET",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
});
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired — check your Minimax API key";
return usage;
}
if (res.status !== 200) {
usage.status = "error";
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
return usage;
}
const data = JSON.parse(res.body);
usage.status = "ok";
// Parse model_remains array — group by model family
const modelRemains: any[] = data?.model_remains || [];
if (Array.isArray(modelRemains) && modelRemains.length > 0) {
for (const model of modelRemains) {
const modelName: string = model.model_name || "Unknown";
const total: number = model.current_interval_total_count ?? 0;
// Note: Minimax's current_interval_usage_count is actually REMAINING, not used
// (known API quirk per https://github.com/MiniMax-AI/MiniMax-M2/issues/99)
const remaining: number = model.current_interval_usage_count ?? 0;
const used: number = Math.max(0, total - remaining);
const percentUsed = total > 0 ? (used / total) * 100 : 0;
let resetText: string | null = null;
let resetMs: number | undefined;
let windowDurationMs: number | undefined;
const remainsTime: number = model.remains_time;
let resetAt: string | undefined;
if (remainsTime && remainsTime > 0) {
resetMs = remainsTime;
resetText = `resets in ${formatDuration(remainsTime)}`;
resetAt = new Date(Date.now() + remainsTime).toISOString();
}
const startTime: number = model.start_time;
const endTime: number = model.end_time;
if (startTime && endTime) {
windowDurationMs = endTime - startTime;
}
// Only show models that have a quota > 0 (skip unused model types)
if (total > 0) {
usage.windows.push({
label: modelName,
percentUsed: Math.min(100, Math.max(0, percentUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)),
resetText,
resetMs,
resetAt,
windowDurationMs,
});
}
}
}
} catch (e: any) {
usage.status = "error";
usage.error = e.message || "Failed to fetch";
}
return usage;
}
// ── Zai (Zhipu AI) fetcher ──────────────────────────────────────────────────
async function fetchZaiUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Zai",
icon: "🟡",
status: "no-auth",
windows: [],
};
// Load Zai API key from pi's auth storage
const apiKey = readPiAuthKey("zai");
if (!apiKey) {
usage.error = "No Zai credentials — add API key to pi";
return usage;
}
try {
// Z.ai quota endpoint — uses raw API key in Authorization header (not Bearer)
const res = await httpsRequest("https://api.z.ai/api/monitor/usage/quota/limit", {
method: "GET",
headers: {
authorization: apiKey,
"content-type": "application/json",
},
});
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired — check your Zai API key";
return usage;
}
if (res.status !== 200) {
usage.status = "error";
usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`;
return usage;
}
const data = JSON.parse(res.body);
if (!data?.success || data?.code !== 200) {
usage.status = "error";
usage.error = data?.msg || "API returned error";
return usage;
}
usage.status = "ok";
const limits: any[] = data?.data?.limits || [];
// Find TOKENS_LIMIT (5-hour rolling window)
const tokensLimit = limits.find((l: any) => l.type === "TOKENS_LIMIT");
if (tokensLimit) {
const percentage: number = tokensLimit.percentage ?? 0;
// The percentage field represents percentage USED
// But the API actually reports percentage as the utilization level
// remaining = 100 - percentage (if percentage is used%)
// However the opencode-mystatus source treats it differently:
// remainPercent = 100 - percentage (where percentage is used %)
// Actually from the response: percentage=1 means 1% used, so 99% remaining
let resetText: string | null = null;
let resetMs: number | undefined;
let windowDurationMs: number | undefined;
let resetAt: string | undefined;
const nextResetTime: number | undefined = tokensLimit.nextResetTime;
if (nextResetTime) {
resetMs = Math.max(0, nextResetTime - Date.now());
resetText = resetMs > 0 ? `resets in ${formatDuration(resetMs)}` : "resetting now";
resetAt = new Date(nextResetTime).toISOString();
// 5-hour window
windowDurationMs = 5 * 60 * 60 * 1000;
}
usage.windows.push({
label: "Session (5h)",
percentUsed: Math.min(100, Math.max(0, percentage)),
percentLeft: Math.min(100, Math.max(0, 100 - percentage)),
resetText,
resetMs,
resetAt,
windowDurationMs,
});
}
// Find TIME_LIMIT (MCP monthly search quota)
const timeLimit = limits.find((l: any) => l.type === "TIME_LIMIT");
if (timeLimit) {
const total: number = timeLimit.usage ?? 0;
const used: number = timeLimit.currentValue ?? 0;
const remaining: number = timeLimit.remaining ?? Math.max(0, total - used);
const percentage: number = timeLimit.percentage ?? 0;
let resetText: string | null = null;
let resetMs: number | undefined;
let resetAt2: string | undefined;
const nextResetTime: number | undefined = timeLimit.nextResetTime;
if (nextResetTime) {
resetMs = Math.max(0, nextResetTime - Date.now());
resetText = resetMs > 0 ? `resets in ${formatDuration(resetMs)}` : "resetting now";
resetAt2 = new Date(nextResetTime).toISOString();
}
usage.windows.push({
label: "MCP Monthly",
percentUsed: Math.min(100, Math.max(0, percentage)),
percentLeft: Math.min(100, Math.max(0, 100 - percentage)),
resetText,
resetMs,
resetAt: resetAt2,
windowDurationMs: 30 * 24 * 60 * 60 * 1000,
});
}
// Extract plan level if available
if (data?.data?.level) {
usage.plan = data.data.level.charAt(0).toUpperCase() + data.data.level.slice(1);
}
} catch (e: any) {
usage.status = "error";
usage.error = e.message || "Failed to fetch";
}
return usage;
}
// ── Main export ────────────────────────────────────────────────────────────
/**
* Fetch usage data from all configured providers with caching.
* Results are cached for 30 seconds to avoid hitting provider API rate limits.
*/
/** Max time to wait for any individual provider fetch (ms) */
const PROVIDER_FETCH_TIMEOUT_MS = 10_000; // 10 seconds
/**
* Extended timeout for Claude provider fetch (ms).
* Claude's flow can include up to 3 API retries with exponential backoff (~7s)
* plus a 60-second CLI fallback via PTY, so the default 10s is insufficient.
*/
export const CLAUDE_FETCH_TIMEOUT_MS = 75_000; // 75 seconds
/**
* Wrap a provider fetch with a timeout. Returns the provider result or an
* error provider if the fetch takes longer than PROVIDER_FETCH_TIMEOUT_MS.
*/
export function withTimeout(
providerPromise: Promise<ProviderUsage>,
providerName: string,
timeoutMs: number = PROVIDER_FETCH_TIMEOUT_MS,
): Promise<ProviderUsage> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
resolve({
name: providerName,
icon: "⏱️",
status: "error",
error: `Timed out after ${Math.round(timeoutMs / 1000)}s`,
windows: [],
});
}, timeoutMs);
providerPromise
.then((result) => {
clearTimeout(timer);
resolve(result);
})
.catch((err: any) => {
clearTimeout(timer);
resolve({
name: providerName,
icon: "⏱️",
status: "error",
error: err.message || "Failed",
windows: [],
});
});
});
}
export async function fetchAllProviderUsage(_authStorage?: AuthStorageLike): Promise<ProviderUsage[]> {
// Check cache
if (usageCache && Date.now() - usageCache.timestamp < CACHE_TTL_MS) {
return usageCache.data;
}
// Fetch all providers in parallel with per-provider timeout
const results = await Promise.allSettled([
withTimeout(fetchClaudeUsage(), "Claude", CLAUDE_FETCH_TIMEOUT_MS),
withTimeout(fetchCodexUsage(), "Codex"),
withTimeout(fetchGeminiUsage(), "Gemini"),
withTimeout(fetchMinimaxUsage(), "Minimax"),
withTimeout(fetchZaiUsage(), "Zai"),
]);
const providers: ProviderUsage[] = [];
for (const r of results) {
if (r.status === "fulfilled") {
// Apply pace calculation to all windows
const provider = r.value;
provider.windows = provider.windows.map(applyPaceToWindow);
providers.push(provider);
}
}
// Update cache
usageCache = {
data: providers,
timestamp: Date.now(),
};
return providers;
}
/**
* Clear the usage cache (useful for testing or manual refresh)
*/
export function clearUsageCache(): void {
usageCache = null;
}