import * as os from "node:os"; import * as path from "node:path"; import { readFile } from "node:fs/promises"; import * as https from "node:https"; import * as child_process from "node:child_process"; import { choosePreferredStoredCredential, readStoredCredentialsFromAuthFile } from "@fusion/core"; import { getAuthFileCandidates } from "./auth-paths.js"; function getHomeDir(): string { return process.env.HOME || process.env.USERPROFILE || os.homedir(); } function execFileAsync( file: string, args: string[], options: child_process.ExecFileOptionsWithStringEncoding, ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { child_process.execFile(file, args, options, (error, stdout, stderr) => { if (error) { reject(error); return; } resolve({ stdout: String(stdout), stderr: String(stderr) }); }); }); } /** * 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; get?(provider: string): AuthCredentialEntry | null | undefined; getApiKey?(provider: string): string | null | undefined | Promise; } /** * Credential entry returned by AuthStorage.get(). * Covers both API-key entries (`{ type: "api_key", key }`) and OAuth entries * (`{ type: "oauth", access, refresh, expires }`) stored by Fusion's auth * subsystem. The `[key: string]: unknown` index signature allows additional * provider-specific fields (e.g. `scopes`, `subscriptionType`) without * widening the entire interface. */ export interface AuthCredentialEntry { type?: string; key?: string; access?: string; refresh?: string; expires?: number; [key: string]: unknown; } // 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; body?: string; timeout?: number; } ): Promise<{ status: number; headers: Record; 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 = {}; 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 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- JWT payloads are untyped 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; } } // ── Auth storage reader ────────────────────────────────────────────────── async function readAuthKeyFromFile(authPath: string, provider: string): Promise { try { const auth = JSON.parse(await readFile(authPath, "utf-8")); const entry = auth?.[provider]; if (entry && (entry.type === "api_key" || entry.type === "key") && entry.key) { return entry.key; } } catch { // No auth file or invalid format - fall through to return null } return null; } /** * Read an API key from the same AuthStorage object used by the dashboard when * available, then fall back to conventional pi/fusion auth files. */ async function readConfiguredApiKey(provider: string, authStorage?: AuthStorageLike): Promise { try { authStorage?.reload(); } catch { // Reload may fail if no storage - ignore } try { const apiKey = await authStorage?.getApiKey?.(provider); if (apiKey) return apiKey; } catch { // getApiKey may not be implemented - ignore } try { const entry = authStorage?.get?.(provider); if (entry && (entry.type === "api_key" || entry.type === "key") && entry.key) { return entry.key; } } catch { // get() may not be implemented - ignore } for (const authPath of getAuthFileCandidates()) { const apiKey = await readAuthKeyFromFile(authPath, provider); if (apiKey) return apiKey; } return null; } // ── Claude fetcher ───────────────────────────────────────────────────────── /** * Read Claude credentials from macOS keychain. * Returns the parsed credentials object or null if not found/error. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped keychain data async function readClaudeKeychainCredentials(): Promise { try { const { stdout } = await execFileAsync( "security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 5000 } ); return JSON.parse(stdout.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"; /** * `anthropic-beta` header value required by the Anthropic API to authorize * OAuth-scoped access to `/api/oauth/usage`. Without this header the endpoint * returns 401 "OAuth authentication is currently not supported". Value mirrors * what the Claude CLI (`claude /usage`) sends — bump when the CLI does. */ const ANTHROPIC_OAUTH_BETA = "oauth-2025-04-20"; /** * User-Agent sent alongside OAuth usage requests. Matches the format used by * the Claude CLI so the call is recognizable to Anthropic. */ const CLAUDE_USAGE_USER_AGENT = "claude-code-fusion-dashboard"; /** * 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). * * Request shape mirrors what the Claude CLI sends: JSON body, includes a * `scope` field, and posts to platform.claude.com. Sending the body as * form-urlencoded or omitting `scope` causes Anthropic to respond with 4xx * errors (or silently rate-limit) even when the refresh token is valid. */ async function refreshClaudeAccessToken( refreshToken: string, scopes?: string[], ): Promise { try { const payload: Record = { grant_type: "refresh_token", refresh_token: refreshToken, client_id: ANTHROPIC_OAUTH_CLIENT_ID, }; if (scopes && scopes.length > 0) { payload.scope = scopes.join(" "); } const res = await httpsRequest(ANTHROPIC_TOKEN_ENDPOINT, { method: "POST", headers: { "content-type": "application/json", "user-agent": CLAUDE_USAGE_USER_AGENT, }, body: JSON.stringify(payload), 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 => 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 // eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence .replace(/\x1B\[(\d+)C/g, (_m, n) => " ".repeat(parseInt(n, 10))) // Cursor movement (up/down/back/position) // eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence .replace(/\x1B\[\d*[ABD]/g, "") // eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence .replace(/\x1B\[\d+;\d+[Hf]/g, "\n") // Remaining CSI sequences (colors, modes, etc.) // eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, "") // OSC sequences // eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, "") // Other ESC sequences // eslint-disable-next-line no-control-regex -- terminal ANSI escape sequence .replace(/\x1B[A-Za-z]/g, "") // Carriage returns .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); // Handle backspaces /* eslint-disable no-control-regex -- backspace control character */ while (clean.includes("\x08")) { clean = clean.replace(/[^\x08]\x08/, ""); clean = clean.replace(/^\x08+/, ""); } /* eslint-enable no-control-regex */ // Strip remaining non-printable control characters (except newline) // eslint-disable-next-line no-control-regex -- control character cleanup 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 timestamp value from the API into milliseconds until reset. * Handles multiple formats: ISO strings, Unix timestamps (seconds or milliseconds), * and numeric strings. Returns null if the value is unparseable or in the past. */ export function _parseResetTimestamp(value: unknown): { msLeft: number; resetAt: string } | null { if (value === null || value === undefined || value === "") { return null; } let timestampMs: number; // Handle numeric values (Unix timestamps in seconds or milliseconds) if (typeof value === "number") { // Detect format: if >= 1e12, assume milliseconds; otherwise seconds timestampMs = value >= 1e12 ? value : value * 1000; } else if (typeof value === "string") { // Try parsing as number first (for numeric strings) const numericValue = Number(value); if (!isNaN(numericValue)) { // Detect format: if >= 1e12, assume milliseconds; otherwise seconds timestampMs = numericValue >= 1e12 ? numericValue : numericValue * 1000; } else { // Try parsing as ISO string const parsed = new Date(value); if (isNaN(parsed.getTime())) { return null; // Invalid date string } timestampMs = parsed.getTime(); } } else { return null; } const msLeft = timestampMs - Date.now(); if (msLeft <= 0) { return null; // Already past } return { msLeft, resetAt: new Date(timestampMs).toISOString(), }; } /** * 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 = { 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 { 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: Record = { name: "xterm-256color", cols: 120, rows: 30, cwd, env: { ...process.env, TERM: "xterm-256color" }, }; if (isWindows) ptyOptions.useConpty = false; const output = await new Promise((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 { // Kill may fail if process already exited - ignore } // 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 { // Kill may fail if process already exited - ignore } 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 { // Kill may fail if process already exited - ignore } 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"; } } // Fallback for session window: when CLI output doesn't include reset info // but we have usage percentage data, 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 CLI output is incomplete. if (!window.resetText && section.windowMs === 5 * 60 * 60 * 1000) { window.resetMs = section.windowMs; window.resetText = "resets in 5h"; window.resetAt = new Date(Date.now() + section.windowMs).toISOString(); } usage.windows.push(window); } } if (usage.windows.length === 0) { usage.status = "error"; usage.error = "Could not parse usage from CLI output"; } } catch (e: unknown) { usage.status = "error"; usage.error = e instanceof Error ? e.message : "CLI fallback failed"; } return usage; } /** * Fetch Claude usage data via the Anthropic OAuth usage API. * * Reads credentials from (in order of precedence): * 1. Fusion auth storage (`authStorage.get("anthropic")`) — OAuth credentials * stored by the `fn auth login anthropic` flow. * 2. Claude CLI credential files (`~/.claude/.credentials.json`, * `~/.config/claude/.credentials.json`). * 3. macOS keychain (`Claude Code-credentials`). * * Then 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(authStorage?: AuthStorageLike): Promise { const usage: ProviderUsage = { name: "Claude", icon: "🟠", status: "no-auth", windows: [], }; // ── Credential reading for plan detection & auth check ────────────── // Try Fusion auth storage first (OAuth credentials from `fn auth login anthropic`). // Normalize to the same shape as Claude CLI credentials so the rest of the // fetcher works unchanged. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped credentials JSON let creds: any = null; try { authStorage?.reload(); } catch { // Reload may fail if no storage - ignore } try { const fusionCreds = authStorage?.get?.("anthropic"); if (fusionCreds?.type === "oauth" && fusionCreds.access) { creds = { accessToken: fusionCreds.access, refreshToken: fusionCreds.refresh || undefined, expiresAt: typeof fusionCreds.expires === "number" ? fusionCreds.expires : undefined, scopes: Array.isArray(fusionCreds.scopes) ? fusionCreds.scopes : ["user:profile"], ...(fusionCreds.subscriptionType ? { subscriptionType: fusionCreds.subscriptionType } : {}), ...(fusionCreds.rateLimitTier ? { rateLimitTier: fusionCreds.rateLimitTier } : {}), }; } } catch { // get() may not be implemented or throw - ignore } // Legacy: Claude CLI credential files if (!creds) { const credPaths = [ path.join(getHomeDir(), ".claude", ".credentials.json"), path.join(getHomeDir(), ".config", "claude", ".credentials.json"), ]; for (const p of credPaths) { try { creds = JSON.parse(await readFile(p, "utf-8")); break; } catch { // File doesn't exist or invalid JSON - continue to next path } } // Fallback to macOS keychain if file credentials not found if (!creds) { creds = await readClaudeKeychainCredentials(); } } const oauthCreds = creds?.claudeAiOauth || creds; if (!oauthCreds?.accessToken) { usage.error = "No Claude credentials — run 'claude' to login or 'fn auth login anthropic'"; 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, scopes); 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; 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}`, "anthropic-beta": ANTHROPIC_OAUTH_BETA, "user-agent": CLAUDE_USAGE_USER_AGENT, }, }); 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, scopes); 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; /** * Get a window data object from the API response, checking multiple possible keys * for backward compatibility (API may use `session` instead of `five_hour`). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response const getWindowData = (primaryKey: string, fallbackKeys: string[] = []): any => { if (data[primaryKey] && typeof data[primaryKey] === "object") { return data[primaryKey]; } for (const key of fallbackKeys) { if (data[key] && typeof data[key] === "object") { return data[key]; } } return null; }; const parseWindow = (key: string, label: string, windowDurationMs: number, fallbackKeys: string[] = []): UsageWindow | null => { const w = getWindowData(key, fallbackKeys); if (!w) return null; const pctUsed: number = w.utilization ?? w.percent_used ?? w.percentUsed ?? w.used_percent ?? w.usage_percent ?? 0; let resetText: string | null = null; let resetMs: number | undefined; let resetAt: string | undefined; const resetAtValue = w.resets_at ?? w.reset_at ?? w.resetAt ?? w.resetsAt ?? w.reset_time ?? w.resetsAtTime; const parsedReset = _parseResetTimestamp(resetAtValue); if (parsedReset) { resetMs = parsedReset.msLeft; resetAt = parsedReset.resetAt; resetText = `resets in ${formatDuration(parsedReset.msLeft)}`; } else if (windowDurationMs === FIVE_HOURS_MS) { // Fallback for session window: when the API doesn't provide a valid 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, or provides invalid values. resetMs = windowDurationMs; resetText = "resets in 5h"; resetAt = new Date(Date.now() + windowDurationMs).toISOString(); } return { label, percentUsed: Math.min(100, Math.max(0, pctUsed)), percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)), resetText, windowDurationMs, resetMs, resetAt, }; }; const fiveHour = parseWindow("five_hour", "Session (5h)", FIVE_HOURS_MS, ["session"]); 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: unknown) { usage.status = "error"; usage.error = e instanceof Error ? e.message : "Failed to fetch Claude usage"; } return usage; } // ── Codex fetcher ────────────────────────────────────────────────────────── type CodexCredential = { accessToken: string; idToken?: string; source: "codex-cli" | "fusion-auth"; }; async function loadCodexCredential(): Promise { // Prefer Fusion-stored openai-codex OAuth credentials; fall back to Codex CLI auth.json. let preferredCredential: ReturnType; for (const candidatePath of getAuthFileCandidates()) { const authEntries = readStoredCredentialsFromAuthFile(candidatePath); preferredCredential = choosePreferredStoredCredential(preferredCredential, authEntries["openai-codex"]); } if ( preferredCredential?.type === "oauth" && typeof preferredCredential.access === "string" && preferredCredential.access.length > 0 && typeof preferredCredential.expires === "number" && Number.isFinite(preferredCredential.expires) && preferredCredential.expires > Date.now() ) { return { accessToken: preferredCredential.access, source: "fusion-auth", }; } const codexHome = process.env.CODEX_HOME || path.join(getHomeDir(), ".codex"); const authPath = path.join(codexHome, "auth.json"); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped auth JSON let auth: any = null; try { auth = JSON.parse(await readFile(authPath, "utf-8")); } catch { auth = null; } const codexCliAccessToken = auth?.tokens?.access_token; if (typeof codexCliAccessToken === "string" && codexCliAccessToken.length > 0) { return { accessToken: codexCliAccessToken, idToken: typeof auth?.tokens?.id_token === "string" ? auth.tokens.id_token : undefined, source: "codex-cli", }; } return null; } async function fetchCodexUsage(): Promise { const usage: ProviderUsage = { name: "Codex", icon: "🟢", status: "no-auth", windows: [], }; const credential = await loadCodexCredential(); if (!credential) { usage.error = "No Codex credentials — run 'codex' to login"; return usage; } const accessToken = credential.accessToken; if (!accessToken) { usage.error = "No Codex access token found"; return usage; } // Extract plan and email from id_token when sourced from Codex CLI auth if (credential.source === "codex-cli" && credential.idToken) { const claims = decodeJwtPayload(credential.idToken); 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 = credential.source === "fusion-auth" ? "Auth expired — re-login from Fusion Settings or run 'codex'" : "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); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response 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; const parsedReset = _parseResetTimestamp(win.reset_at); if (parsedReset) { resetMs = parsedReset.msLeft; resetText = `resets in ${formatDuration(parsedReset.msLeft)}`; resetAt = parsedReset.resetAt; } 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: unknown) { usage.status = "error"; usage.error = e instanceof Error ? e.message : "Failed to fetch"; } return usage; } // ── Gemini fetcher ───────────────────────────────────────────────────────── async function fetchGeminiUsage(): Promise { const usage: ProviderUsage = { name: "Gemini", icon: "🔵", status: "no-auth", windows: [], }; // Load Gemini OAuth credentials const oauthPath = path.join(getHomeDir(), ".gemini", "oauth_creds.json"); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped OAuth JSON let oauthCreds: any = null; try { oauthCreds = JSON.parse(await readFile(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(getHomeDir(), ".gemini", "settings.json"); try { const settings = JSON.parse(await readFile(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 { // Settings file doesn't exist or invalid JSON - continue } 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response 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(); 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: unknown) { usage.status = "error"; usage.error = e instanceof Error ? e.message : "Failed to fetch"; } return usage; } // ── Minimax fetcher ───────────────────────────────────────────────────────── async function fetchMinimaxUsage(authStorage?: AuthStorageLike): Promise { const usage: ProviderUsage = { name: "Minimax", icon: "🟣", status: "no-auth", windows: [], }; // Load Minimax API key from the same auth storage the dashboard uses. const apiKey = await readConfiguredApiKey("minimax", authStorage); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response 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: unknown) { usage.status = "error"; usage.error = e instanceof Error ? e.message : "Failed to fetch"; } return usage; } // ── Zai (Zhipu AI) fetcher ────────────────────────────────────────────────── async function fetchZaiUsage(authStorage?: AuthStorageLike): Promise { const usage: ProviderUsage = { name: "Zai", icon: "🟡", status: "no-auth", windows: [], }; // Load Zai API key from the same auth storage the dashboard uses. const apiKey = await readConfiguredApiKey("zai", authStorage); 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"; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response const limits: any[] = data?.data?.limits || []; // Find TOKENS_LIMIT (5-hour rolling window) const tokensLimit = limits.find((l) => 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) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped API response 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: unknown) { usage.status = "error"; usage.error = e instanceof Error ? e.message : "Failed to fetch"; } return usage; } // ── GitHub Copilot fetcher ────────────────────────────────────────────────── type CopilotCredential = { accessToken: string; source: "fusion-auth"; }; async function loadGitHubCopilotCredential(): Promise { let preferredCredential: ReturnType; for (const candidatePath of getAuthFileCandidates()) { const authEntries = readStoredCredentialsFromAuthFile(candidatePath); preferredCredential = choosePreferredStoredCredential(preferredCredential, authEntries["github-copilot"]); } if ( preferredCredential?.type === "oauth" && typeof preferredCredential.access === "string" && preferredCredential.access.length > 0 && typeof preferredCredential.expires === "number" && Number.isFinite(preferredCredential.expires) && preferredCredential.expires > Date.now() ) { return { accessToken: preferredCredential.access, source: "fusion-auth", }; } return null; } function applyGitHubCopilotUsagePayload(usage: ProviderUsage, data: Record): void { usage.status = "ok"; if (typeof data.seat_management_setting === "string" && data.seat_management_setting.length > 0) { usage.plan = data.seat_management_setting; } const planType = typeof data.copilot_plan_type === "string" ? data.copilot_plan_type : typeof data.plan_type === "string" ? data.plan_type : undefined; if (planType) { usage.plan = planType.charAt(0).toUpperCase() + planType.slice(1); } if (data.copilot_plan_type === "free" || data.plan_type === "free") { if (typeof data.chat_messages_used === "number" && typeof data.chat_messages_limit === "number") { const chatPct = data.chat_messages_limit > 0 ? (data.chat_messages_used / data.chat_messages_limit) * 100 : 0; usage.windows.push({ label: "Chat (Monthly)", percentUsed: Math.min(100, Math.max(0, chatPct)), percentLeft: Math.min(100, Math.max(0, 100 - chatPct)), resetText: null, windowDurationMs: 30 * 24 * 60 * 60 * 1000, }); } if (typeof data.completions_used === "number" && typeof data.completions_limit === "number") { const completionPct = data.completions_limit > 0 ? (data.completions_used / data.completions_limit) * 100 : 0; usage.windows.push({ label: "Completions (Monthly)", percentUsed: Math.min(100, Math.max(0, completionPct)), percentLeft: Math.min(100, Math.max(0, 100 - completionPct)), resetText: null, windowDurationMs: 30 * 24 * 60 * 60 * 1000, }); } } } async function fetchGitHubCopilotUsage(): Promise { const usage: ProviderUsage = { name: "GitHub Copilot", icon: "⚫", status: "no-auth", windows: [], }; const fusionCredential = await loadGitHubCopilotCredential(); if (fusionCredential) { try { const res = await httpsRequest("https://api.github.com/user/copilot", { method: "GET", headers: { authorization: `Bearer ${fusionCredential.accessToken}`, accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", "user-agent": "fusion-dashboard", }, }); if (res.status === 200) { applyGitHubCopilotUsagePayload(usage, JSON.parse(res.body)); return usage; } usage.status = "error"; if (res.status === 404) { usage.error = "No Copilot subscription found"; } else if (res.status === 401 || res.status === 403) { usage.error = "Auth expired — re-login from Fusion Settings → Authentication"; } else { usage.error = `HTTP ${res.status}: ${res.body.slice(0, 200)}`; } return usage; } catch (e: unknown) { usage.status = "error"; usage.error = e instanceof Error ? e.message : "Failed to fetch"; return usage; } } try { await execFileAsync("gh", ["auth", "status"], { encoding: "utf-8", timeout: 5000 }); } catch { usage.error = "GitHub CLI not authenticated — run 'gh auth login'"; return usage; } try { const { stdout } = await execFileAsync("gh", ["api", "/user/copilot", "--jq", "."], { encoding: "utf-8", timeout: 10000, }); applyGitHubCopilotUsagePayload(usage, JSON.parse(stdout.trim())); } catch (e: unknown) { const errMsg = e instanceof Error ? e.message : "Failed to fetch"; if (errMsg.includes("404") || errMsg.includes("Not Found")) { usage.status = "error"; usage.error = "No Copilot subscription found"; } else if (errMsg.includes("401") || errMsg.includes("403")) { usage.status = "error"; usage.error = "GitHub auth expired — run 'gh auth login'"; } else { usage.status = "error"; usage.error = errMsg; } } 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, providerName: string, timeoutMs: number = PROVIDER_FETCH_TIMEOUT_MS, ): Promise { 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: unknown) => { clearTimeout(timer); resolve({ name: providerName, icon: "⏱️", status: "error", error: err instanceof Error ? err.message : "Failed", windows: [], }); }); }); } export async function fetchAllProviderUsage(authStorage?: AuthStorageLike): Promise { // Check cache if (usageCache && Date.now() - usageCache.timestamp < CACHE_TTL_MS) { return usageCache.data; } // Fetch all providers in parallel with per-provider timeout // Currently includes: Claude, Codex, Gemini, Minimax, Zai, GitHub Copilot const results = await Promise.allSettled([ withTimeout(fetchClaudeUsage(authStorage), "Claude", CLAUDE_FETCH_TIMEOUT_MS), withTimeout(fetchCodexUsage(), "Codex"), withTimeout(fetchGeminiUsage(), "Gemini"), withTimeout(fetchMinimaxUsage(authStorage), "Minimax"), withTimeout(fetchZaiUsage(authStorage), "Zai"), withTimeout(fetchGitHubCopilotUsage(), "GitHub Copilot"), ]); 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); } } // Only return providers that have valid auth configured. const authenticatedProviders = providers.filter((provider) => provider.status !== "no-auth"); // Update cache usageCache = { data: authenticatedProviders, timestamp: Date.now(), }; return authenticatedProviders; } /** * Clear the usage cache (useful for testing or manual refresh) */ export function clearUsageCache(): void { usageCache = null; }