From dbeb0c543a888d7bc54ed0e3ebfffa63334d1f75 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 9 Jun 2026 13:37:38 -0400 Subject: [PATCH] feat(bar): native Claude Code + Codex subscription quota (safe, server-side) Surface the logged-in Claude Code and Codex subscription quota as first-class summary rows so the existing gauge and alert engine render them with no new UI. Claude Code: read the native token (~/.claude/.credentials.json, macOS Keychain fallback) and reuse the existing Anthropic usage fetch+normalize via a new token-fed entry point. The /oauth/usage endpoint is hostile to polling, so the fetch is server-side only behind a 10-minute on-demand cache, in-flight coalescing, Retry-After + exponential backoff with jitter, a 3-strike circuit breaker with a 15-minute cooldown, and serve-stale-on-failure; logged-out or unsupported subscriptions never spend a token call. Codex: zero-network read of the latest rate_limits from local session rollouts, omitted when absent. Native rows side-load bounded so a slow or failed fetch degrades to CLIProxy rows, never an error. --- src/cliproxy/quota/quota-fetcher-claude.ts | 84 +++- src/web-server/routes/bar-routes.ts | 25 +- .../usage/claude-native-credentials.ts | 139 +++++++ .../usage/codex-local-quota-collector.ts | 221 ++++++++++ .../usage/native-quota-collector.ts | 376 ++++++++++++++++++ tests/unit/web-server/bar-routes.test.ts | 103 +++++ .../claude-native-credentials.test.ts | 126 ++++++ .../codex-local-quota-collector.test.ts | 100 +++++ .../web-server/native-quota-collector.test.ts | 328 +++++++++++++++ 9 files changed, 1481 insertions(+), 21 deletions(-) create mode 100644 src/web-server/usage/claude-native-credentials.ts create mode 100644 src/web-server/usage/codex-local-quota-collector.ts create mode 100644 src/web-server/usage/native-quota-collector.ts create mode 100644 tests/unit/web-server/claude-native-credentials.test.ts create mode 100644 tests/unit/web-server/codex-local-quota-collector.test.ts create mode 100644 tests/unit/web-server/native-quota-collector.test.ts diff --git a/src/cliproxy/quota/quota-fetcher-claude.ts b/src/cliproxy/quota/quota-fetcher-claude.ts index 4315af74..03113a40 100644 --- a/src/cliproxy/quota/quota-fetcher-claude.ts +++ b/src/cliproxy/quota/quota-fetcher-claude.ts @@ -218,25 +218,19 @@ function buildEmptyResult( } /** - * Fetch quota for a single Claude account. + * Run the Anthropic OAuth usage fetch loop for a known-good access token. + * + * This is the single Anthropic-call surface shared by both the CLIProxy-managed + * path (fetchClaudeQuota) and the native-login path (fetchClaudeQuotaWithToken). + * It owns the 401/403/404/429/5xx branch logic, the bounded retry loop, and the + * window normalization so neither caller re-implements the hostile-endpoint + * handling. The callers differ only in WHERE the token comes from. */ -export async function fetchClaudeQuota( +async function runClaudeUsageFetch( + accessToken: string, accountId: string, - verbose = false + verbose: boolean ): Promise { - const authData = await readClaudeAuthData(accountId); - if (!authData) { - return buildEmptyResult('Auth file not found for Claude account', accountId); - } - - if (authData.isExpired) { - return buildEmptyResult( - 'Token expired - re-authenticate with ccs cliproxy auth claude', - accountId, - true - ); - } - let lastError = 'Unknown error'; for (let attempt = 1; attempt <= CLAUDE_QUOTA_MAX_ATTEMPTS; attempt++) { @@ -248,7 +242,7 @@ export async function fetchClaudeQuota( method: 'GET', signal: controller.signal, headers: { - Authorization: `Bearer ${authData.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'Content-Type': 'application/json', 'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER, @@ -283,6 +277,9 @@ export async function fetchClaudeQuota( lastError = (await readResponseErrorMessage(response)) || `Claude OAuth usage API error: ${response.status}`; + // Surface the upstream status + Retry-After so an outer caller (the + // native collector's circuit-breaker) can honor backoff guidance. + const retryAfter = response.headers.get('retry-after'); if ( attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && (response.status === 429 || response.status >= 500) @@ -291,7 +288,12 @@ export async function fetchClaudeQuota( continue; } clearTimeout(timeoutId); - return buildEmptyResult(lastError, accountId); + return { + ...buildEmptyResult(lastError, accountId), + httpStatus: response.status, + retryable: response.status === 429 || response.status >= 500, + ...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}), + }; } let payload: unknown; @@ -338,12 +340,54 @@ export async function fetchClaudeQuota( if (attempt >= CLAUDE_QUOTA_MAX_ATTEMPTS) { clearTimeout(timeoutId); - return buildEmptyResult(lastError, accountId); + return { ...buildEmptyResult(lastError, accountId), retryable: true }; } } } - return buildEmptyResult(lastError, accountId); + return { ...buildEmptyResult(lastError, accountId), retryable: true }; +} + +/** + * Fetch quota using a directly-supplied native OAuth access token. + * + * Reuses the exact Anthropic call + normalization as fetchClaudeQuota; the only + * difference is the token source (the logged-in Claude Code credential rather + * than a CLIProxy-managed auth file). Lives in this file so it can share the + * file-private beta header, timeout, attempt count, and branch logic. + */ +export async function fetchClaudeQuotaWithToken( + accessToken: string, + accountId = 'claude-code', + verbose = false +): Promise { + if (!accessToken || accessToken.trim().length === 0) { + return buildEmptyResult('Missing native Claude access token', accountId, true); + } + return runClaudeUsageFetch(accessToken.trim(), accountId, verbose); +} + +/** + * Fetch quota for a single Claude account. + */ +export async function fetchClaudeQuota( + accountId: string, + verbose = false +): Promise { + const authData = await readClaudeAuthData(accountId); + if (!authData) { + return buildEmptyResult('Auth file not found for Claude account', accountId); + } + + if (authData.isExpired) { + return buildEmptyResult( + 'Token expired - re-authenticate with ccs cliproxy auth claude', + accountId, + true + ); + } + + return runClaudeUsageFetch(authData.accessToken, accountId, verbose); } /** diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index 82df9feb..948f852a 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -104,6 +104,13 @@ export interface BarRouterDeps { * audit shells out via a synchronous execSync that must never run here. */ runHealthChecks?: () => Promise; + /** + * Native subscription quota rows (Claude Code + Codex). Defaults to an empty + * async so older tests that build deps without it keep passing. The native + * collector owns its own long-TTL cache + safety controls, so this is cheap + * to call per request. + */ + getNativeAccountRows?: () => Promise; } // ============================================================================ @@ -122,6 +129,13 @@ const PER_ACCOUNT_TIMEOUT_MS = 5_000; /** Bound for the cost side-load so a slow snapshot read can't dominate the response. */ const SIDELOAD_TIMEOUT_MS = 1_500; +/** + * Bound for the native-subscription side-load. A slow or failed native fetch + * resolves to [] so the response paints CLIProxy rows only — never errors, never + * blocks. Native rows have their own 10-min cache, so the common path is instant. + */ +const NATIVE_SIDELOAD_TIMEOUT_MS = 1_500; + /** Timestamp of the last successful force-fresh pull (epoch ms, 0 = never) */ let lastForceFreshAt = 0; @@ -475,7 +489,14 @@ export function createBarRouter(deps: BarRouterDeps): Router { }); const rows = await Promise.race([gather, deadline]); - res.json(rows); + + // Native subscription rows (Claude Code + Codex) are side-loaded AFTER the + // CLIProxy rows resolve, bounded so a slow/failed native fetch degrades to + // [] rather than blocking or erroring the response. + const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]); + const nativeRows = (await withTimeout(getNative(), NATIVE_SIDELOAD_TIMEOUT_MS)) ?? []; + + res.json([...rows, ...nativeRows]); } catch (err) { console.error('[bar-routes] /summary error:', (err as Error).message); res.status(500).json({ error: 'Internal server error' }); @@ -523,6 +544,7 @@ import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher'; import { getTodayCostByAccount } from '../usage/data-aggregator'; import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader'; import { getCachedDailyData, getCachedHourlyData } from '../usage/aggregator'; +import { getNativeAccountRows } from '../usage/native-quota-collector'; /** Production bar router — wired to real dependencies */ const barRouter: Router = createBarRouter({ @@ -535,6 +557,7 @@ const barRouter: Router = createBarRouter({ loadCliproxyDetails: loadCliproxySnapshotDetails, loadDailyUsage: () => getCachedDailyData(), loadHourlyUsage: () => getCachedHourlyData(), + getNativeAccountRows: () => getNativeAccountRows(), }); export default barRouter; diff --git a/src/web-server/usage/claude-native-credentials.ts b/src/web-server/usage/claude-native-credentials.ts new file mode 100644 index 00000000..c4da718f --- /dev/null +++ b/src/web-server/usage/claude-native-credentials.ts @@ -0,0 +1,139 @@ +/** + * Native Claude Code credential reader. + * + * Reads the LOGGED-IN Claude Code OAuth token so the bar can show the user's + * own subscription quota (Max/Pro/Team) without going through CLIProxy-managed + * auth files. + * + * File-first, Keychain-fallback: we read ~/.claude/.credentials.json directly + * because hitting the macOS Keychain pops a permission dialog. The Keychain + * fallback is load-bearing on machines where Claude Code stores the token there + * instead of on disk, so it must be kept even though the file path is preferred. + * + * Only the user's own token is read here; the single read-only Anthropic usage + * endpoint is the only thing that ever sees it (see native-quota-collector). + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +/** Shape of the relevant slice of ~/.claude/.credentials.json */ +export interface ClaudeNativeCredentials { + claudeAiOauth?: { + accessToken?: string; + subscriptionType?: string; + rateLimitTier?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** Injectable seams so unit tests never touch the real fs/Keychain. */ +export interface CredentialReaderDeps { + platform?: NodeJS.Platform; + homedir?: string; + existsSyncImpl?: (p: string) => boolean; + readFileSyncImpl?: (p: string) => string; + execSyncImpl?: (cmd: string, opts: Record) => string | Buffer; +} + +const KEYCHAIN_SERVICE = 'Claude Code-credentials'; +const KEYCHAIN_TIMEOUT_MS = 5000; + +/** Subscription types that mean "no real subscription" -> skip the fetch. */ +const UNSUPPORTED_SUBSCRIPTION_TYPES = new Set(['', 'free', 'none']); + +/** rateLimitTier values that imply an entitled subscription. */ +const SUPPORTED_RATE_LIMIT_TIER = /claude|max|pro|team|enterprise/; + +function parseCredentials(raw: string): ClaudeNativeCredentials | null { + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as ClaudeNativeCredentials; + } + } catch { + // fall through + } + return null; +} + +/** + * Read the native Claude Code credentials. + * + * Order: the on-disk credentials file first (no prompt), then the macOS + * Keychain as a fallback. Returns null when neither source yields a parseable + * object. + */ +export function readClaudeCredentials( + deps: CredentialReaderDeps = {} +): ClaudeNativeCredentials | null { + const platform = deps.platform ?? os.platform(); + const homedir = deps.homedir ?? os.homedir(); + const existsImpl = deps.existsSyncImpl ?? existsSync; + const readImpl = deps.readFileSyncImpl ?? ((p: string) => readFileSync(p, 'utf8')); + const execImpl = deps.execSyncImpl ?? execSync; + + const credentialsPath = path.join(homedir, '.claude', '.credentials.json'); + if (existsImpl(credentialsPath)) { + try { + const parsed = parseCredentials(readImpl(credentialsPath)); + if (parsed) return parsed; + } catch { + // fall through to Keychain + } + } + + if (platform === 'darwin') { + try { + const out = execImpl(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`, { + timeout: KEYCHAIN_TIMEOUT_MS, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + const raw = (typeof out === 'string' ? out : out.toString('utf8')).trim(); + if (raw) { + const parsed = parseCredentials(raw); + if (parsed) return parsed; + } + } catch { + // no Keychain entry / access denied -> null + } + } + + return null; +} + +/** Pull the OAuth access token, or null when absent. */ +export function getAccessToken(creds: ClaudeNativeCredentials | null): string | null { + const token = creds?.claudeAiOauth?.accessToken; + return typeof token === 'string' && token.trim().length > 0 ? token : null; +} + +/** Pull the subscription tier (e.g. "max" / "pro"), or null. */ +export function getSubscriptionTier(creds: ClaudeNativeCredentials | null): string | null { + const tier = creds?.claudeAiOauth?.subscriptionType; + return typeof tier === 'string' && tier.trim().length > 0 ? tier.trim() : null; +} + +/** + * True when the credentials describe a real, entitled subscription. + * + * Gating on this BEFORE fetching means a free/logged-out user never spends a + * token call against the hostile usage endpoint and never gets a phantom row. + */ +export function hasSupportedSubscription(creds: ClaudeNativeCredentials | null): boolean { + const subscriptionType = String(creds?.claudeAiOauth?.subscriptionType ?? '') + .trim() + .toLowerCase(); + if (subscriptionType && !UNSUPPORTED_SUBSCRIPTION_TYPES.has(subscriptionType)) { + return true; + } + + const rateLimitTier = String(creds?.claudeAiOauth?.rateLimitTier ?? '') + .trim() + .toLowerCase(); + return SUPPORTED_RATE_LIMIT_TIER.test(rateLimitTier); +} diff --git a/src/web-server/usage/codex-local-quota-collector.ts b/src/web-server/usage/codex-local-quota-collector.ts new file mode 100644 index 00000000..1099c147 --- /dev/null +++ b/src/web-server/usage/codex-local-quota-collector.ts @@ -0,0 +1,221 @@ +/** + * Codex local quota collector (zero network). + * + * Codex writes a `rate_limits` object into its rollout session logs + * (~/.codex/sessions////rollout-*.jsonl). We read the most recent + * session's last non-null rate_limits to surface the user's Codex subscription + * quota WITHOUT any network call — pure local file read. + * + * Exec-mode sessions often never emit rate_limits (the field stays null); in + * that case we return null so the bar simply omits the Codex row rather than + * inventing a fake one. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { resolveCodexConfigPaths } from '../services/codex-dashboard-service'; + +/** Normalized Codex quota snapshot from a local session log. */ +export interface CodexLocalQuota { + /** Remaining percentage (0-100): min across primary/secondary windows. */ + quotaPercentage: number; + /** ISO timestamp of the soonest window reset, null if unknown. */ + nextReset: string | null; + /** plan_type from the session (e.g. "pro"/"plus"), null if absent. */ + tier: string | null; + /** True when the source file is older than the freshness window. */ + stale: boolean; +} + +/** Injectable seams for deterministic tests (no real fs / no tail subprocess). */ +export interface CodexLocalQuotaDeps { + env?: NodeJS.ProcessEnv; + homeDir?: string; + existsSyncImpl?: (p: string) => boolean; + readdirImpl?: (dir: string) => fs.Dirent[]; + statMtimeMsImpl?: (p: string) => number; + /** Returns the last N lines of a file (default: Bun tail). */ + tailLinesImpl?: (file: string, lines: number) => Promise; + now?: number; +} + +/** Lines to scan from the tail; rate_limits sits near the end of a session. */ +const TAIL_LINES = 200; + +/** A source older than this is reported stale (but still emitted). */ +const STALE_AFTER_MS = 5 * 60 * 1000; + +interface CodexRateWindow { + usedPercent: number; + resetsAtSeconds: number | null; +} + +interface CodexRateLimits { + primary: CodexRateWindow | null; + secondary: CodexRateWindow | null; + planType: string | null; +} + +function asObject(value: unknown): Record | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function parseWindow(value: unknown): CodexRateWindow | null { + const obj = asObject(value); + if (!obj) return null; + const usedPercent = asFiniteNumber(obj['used_percent']); + if (usedPercent === null) return null; + return { + usedPercent, + resetsAtSeconds: asFiniteNumber(obj['resets_at']), + }; +} + +/** + * Extract a non-null rate_limits object from a parsed JSONL line. + * rate_limits lives under `payload` in the token_count event. + */ +function extractRateLimits(line: string): CodexRateLimits | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + const root = asObject(parsed); + if (!root) return null; + + const payload = asObject(root['payload']) ?? root; + const rateLimits = asObject(payload['rate_limits']); + if (!rateLimits) return null; + + const primary = parseWindow(rateLimits['primary']); + const secondary = parseWindow(rateLimits['secondary']); + // A rate_limits object with neither window carries no usable signal. + if (!primary && !secondary) return null; + + const planType = + typeof rateLimits['plan_type'] === 'string' && rateLimits['plan_type'].trim().length > 0 + ? (rateLimits['plan_type'] as string).trim() + : null; + + return { primary, secondary, planType }; +} + +/** Recursive rollout-*.jsonl walker, lexicographically sorted (ISO ts in name). */ +function collectRolloutFiles( + dir: string, + existsImpl: (p: string) => boolean, + readdirImpl: (d: string) => fs.Dirent[] +): string[] { + if (!existsImpl(dir)) return []; + const files: string[] = []; + for (const entry of readdirImpl(dir)) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...collectRolloutFiles(entryPath, existsImpl, readdirImpl)); + continue; + } + if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) { + files.push(entryPath); + } + } + return files.sort(); +} + +/** + * Default tail using Bun.spawn (macOS-safe: no tac, no GNU timeout). + * Reads the last `lines` lines so we never load a huge session into memory. + */ +async function defaultTailLines(file: string, lines: number): Promise { + const proc = Bun.spawn(['tail', `-${lines}`, file], { + stdout: 'pipe', + stderr: 'ignore', + }); + const text = await new Response(proc.stdout).text(); + await proc.exited; + return text.split('\n').filter((l) => l.trim().length > 0); +} + +function computeQuotaPercentage(rate: CodexRateLimits): number { + const remaining: number[] = []; + if (rate.primary) remaining.push(100 - rate.primary.usedPercent); + if (rate.secondary) remaining.push(100 - rate.secondary.usedPercent); + // Clamp into [0,100] to guard against odd upstream values. + return Math.max(0, Math.min(100, Math.min(...remaining))); +} + +function computeNextReset(rate: CodexRateLimits): string | null { + const resets: number[] = []; + if (rate.primary?.resetsAtSeconds !== null && rate.primary?.resetsAtSeconds !== undefined) { + resets.push(rate.primary.resetsAtSeconds); + } + if (rate.secondary?.resetsAtSeconds !== null && rate.secondary?.resetsAtSeconds !== undefined) { + resets.push(rate.secondary.resetsAtSeconds); + } + if (resets.length === 0) return null; + return new Date(Math.min(...resets) * 1000).toISOString(); +} + +/** + * Read the latest Codex session's most recent rate_limits and normalize it. + * Returns null when no session carries a usable rate_limits object. + */ +export async function getCodexLocalQuota( + deps: CodexLocalQuotaDeps = {} +): Promise { + const existsImpl = deps.existsSyncImpl ?? fs.existsSync; + const readdirImpl = + deps.readdirImpl ?? ((dir: string) => fs.readdirSync(dir, { withFileTypes: true })); + const statMtimeMsImpl = deps.statMtimeMsImpl ?? ((p: string) => fs.statSync(p).mtimeMs); + const tailLinesImpl = deps.tailLinesImpl ?? defaultTailLines; + const now = deps.now ?? Date.now(); + + const { baseDir } = resolveCodexConfigPaths({ env: deps.env, homeDir: deps.homeDir }); + const sessionsDir = path.join(baseDir, 'sessions'); + + const rolloutFiles = collectRolloutFiles(sessionsDir, existsImpl, readdirImpl); + if (rolloutFiles.length === 0) return null; + + // Filename carries an ISO timestamp, so the lexicographically-last file is + // the most recent session. + const latest = rolloutFiles[rolloutFiles.length - 1]; + + let lines: string[]; + try { + lines = await tailLinesImpl(latest, TAIL_LINES); + } catch { + return null; + } + + // Iterate backwards for the most recent non-null rate_limits. + let rate: CodexRateLimits | null = null; + for (let i = lines.length - 1; i >= 0; i--) { + const found = extractRateLimits(lines[i]); + if (found) { + rate = found; + break; + } + } + if (!rate) return null; + + let stale = false; + try { + stale = now - statMtimeMsImpl(latest) > STALE_AFTER_MS; + } catch { + // Unknown mtime -> treat as fresh; the data itself is still valid. + stale = false; + } + + return { + quotaPercentage: computeQuotaPercentage(rate), + nextReset: computeNextReset(rate), + tier: rate.planType, + stale, + }; +} diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts new file mode 100644 index 00000000..1fac4721 --- /dev/null +++ b/src/web-server/usage/native-quota-collector.ts @@ -0,0 +1,376 @@ +/** + * Native subscription quota collector — the ONLY server-side fetch surface for + * the user's own Claude Code + Codex subscription quota. + * + * The macOS bar reads localhost /api/bar/summary and NEVER calls Anthropic. All + * Anthropic traffic originates here, under strict safety controls, because the + * OAuth usage endpoint is undocumented and hostile to polling (persistent 429s, + * no Retry-After, first-party-only policy). The controls below exist to protect + * the user's account: + * + * - long TTL (10 min) on-demand cache, never a tight timer loop + * - in-flight coalescing so concurrent /summary calls share one fetch + * - Retry-After honored; exponential backoff + jitter on 429/5xx + * - circuit breaker stops calling after repeated 429s for a cooldown + * - serve-stale-on-failure; only omit a row when there is genuinely no data + * + * Codex is a pure local file read (no network), so it skips the network guards. + */ + +import { + readClaudeCredentials, + getAccessToken, + getSubscriptionTier, + hasSupportedSubscription, + type ClaudeNativeCredentials, +} from './claude-native-credentials'; +import { fetchClaudeQuotaWithToken } from '../../cliproxy/quota/quota-fetcher-claude'; +import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-collector'; +import type { ClaudeQuotaResult } from '../../cliproxy/quota/quota-types'; +import type { BarSummaryRow } from '../routes/bar-routes'; + +// ============================================================================ +// Safety constants (concrete, named, module-level) +// ============================================================================ + +/** On-demand cache TTL. Floor is 5 min; we use 10 min because the bar polls + * /summary far more often than a hook fires. */ +const NATIVE_QUOTA_TTL_MS = 600_000; // 10 minutes + +/** Exponential backoff base; delay = min(base * 2^n, MAX) + jitter. */ +const RETRY_BASE_MS = 1_000; + +/** Ceiling for any single backoff / Retry-After cooldown derived from one call. */ +const MAX_BACKOFF_MS = 60_000; // 1 minute + +/** Jitter added to backoff to avoid synchronized retries. */ +const JITTER_MAX_MS = 500; + +/** Consecutive 429s that trip the breaker open. */ +const CB_TRIP_THRESHOLD = 3; + +/** How long the breaker stays open (zero network) once tripped. */ +const CB_COOLDOWN_MS = 900_000; // 15 minutes + +const CLAUDE_PROVIDER = 'claude-code'; +const CODEX_PROVIDER = 'codex'; + +// ============================================================================ +// Injectable dependencies (tests inject mocks; never live Anthropic in CI) +// ============================================================================ + +export interface NativeQuotaDeps { + /** Read the native Claude Code credentials. */ + readCredentials?: () => ClaudeNativeCredentials | null; + /** Fetch Claude quota with a directly-supplied native token. */ + fetchClaudeQuota?: (accessToken: string, accountId?: string) => Promise; + /** Read Codex quota from local session logs (zero network). */ + getCodexQuota?: () => Promise; + /** Clock seam for deterministic backoff/TTL/breaker tests. */ + now?: () => number; + /** Sleep seam (no real delay in tests). */ + sleep?: (ms: number) => Promise; +} + +// ============================================================================ +// Per-provider mutable state (module-scoped; reset() for tests) +// ============================================================================ + +interface ProviderState { + /** Last successfully-built row, kept for stale-on-fail and TTL serving. */ + cachedRow: BarSummaryRow | null; + /** Epoch ms when cachedRow was produced. */ + cachedAt: number; + /** Shared in-flight promise; concurrent callers await this, not a new fetch. */ + pending: Promise | null; + /** Consecutive 429 count toward the breaker threshold. */ + consecutive429: number; + /** Epoch ms until which the breaker is open (no network). */ + breakerOpenUntil: number; + /** Epoch ms until which a Retry-After / backoff cooldown holds. */ + cooldownUntil: number; + /** Attempt counter feeding exponential backoff. */ + backoffAttempt: number; +} + +function freshProviderState(): ProviderState { + return { + cachedRow: null, + cachedAt: 0, + pending: null, + consecutive429: 0, + breakerOpenUntil: 0, + cooldownUntil: 0, + backoffAttempt: 0, + }; +} + +const claudeState = freshProviderState(); + +/** Reset all module state. Tests call this to avoid cross-test pollution. */ +export function resetNativeQuotaState(): void { + Object.assign(claudeState, freshProviderState()); +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Parse Retry-After (seconds or HTTP-date) into ms, capped at MAX_BACKOFF_MS. */ +function parseRetryAfterMs(detail: string | undefined, now: number): number | null { + if (!detail) return null; + const match = /retry-after:(.+)$/.exec(detail); + if (!match) return null; + const raw = match[1].trim(); + + const asSeconds = Number(raw); + if (Number.isFinite(asSeconds)) { + return Math.min(Math.max(0, asSeconds) * 1000, MAX_BACKOFF_MS); + } + + const asDate = Date.parse(raw); + if (Number.isFinite(asDate)) { + return Math.min(Math.max(0, asDate - now), MAX_BACKOFF_MS); + } + return null; +} + +function computeBackoffMs(attempt: number): number { + const exp = Math.min(RETRY_BASE_MS * 2 ** attempt, MAX_BACKOFF_MS); + const jitter = Math.floor(Math.random() * JITTER_MAX_MS); + return exp + jitter; +} + +/** + * Best-guess remaining percentage across the 5h + weekly core windows, mirroring + * the CLIProxy quota-manager derivation: min of remaining across non-overage + * windows, falling back to all windows if core summary is absent. + */ +function deriveClaudeQuotaPercentage(quota: ClaudeQuotaResult): number | null { + const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly].filter( + (w): w is NonNullable => !!w + ); + if (coreWindows.length > 0) { + return Math.min(...coreWindows.map((w) => w.remainingPercent)); + } + const usageWindows = quota.windows.filter((w) => w.rateLimitType !== 'overage'); + if (usageWindows.length > 0) { + return Math.min(...usageWindows.map((w) => w.remainingPercent)); + } + return null; +} + +/** Soonest non-null reset ISO across the two core windows. */ +function deriveClaudeNextReset(quota: ClaudeQuotaResult): string | null { + const resets = [quota.coreUsage?.fiveHour?.resetAt, quota.coreUsage?.weekly?.resetAt] + .filter((r): r is string => typeof r === 'string') + .map((r) => ({ iso: r, ms: new Date(r).getTime() })) + .filter((r) => Number.isFinite(r.ms)) + .sort((a, b) => a.ms - b.ms); + return resets.length > 0 ? resets[0].iso : null; +} + +function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: number): BarSummaryRow { + return { + account_id: CLAUDE_PROVIDER, + provider: CLAUDE_PROVIDER, + displayName: 'Claude Code', + tier, + paused: false, + quota_percentage: deriveClaudeQuotaPercentage(quota), + quotaStatus: 'ok', + next_reset: deriveClaudeNextReset(quota), + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + }; +} + +function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: quota.tier, + paused: false, + quota_percentage: quota.quotaPercentage, + quotaStatus: 'ok', + next_reset: quota.nextReset, + is_default: false, + last_activity_at: null, + today_cost: null, + // Codex is a local read; a stale source still reflects real usage so we keep + // quotaStatus 'ok' but flag health 'warning' to hint freshness. + health: quota.stale ? 'warning' : 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + }; +} + +/** Return the cached row marked cached=true (used for TTL + stale serving). */ +function serveCached(state: ProviderState): BarSummaryRow | null { + if (!state.cachedRow) return null; + return { ...state.cachedRow, cached: true }; +} + +// ============================================================================ +// Claude path with full safety controls +// ============================================================================ + +async function collectClaudeRow(deps: NativeQuotaDeps): Promise { + const now = (deps.now ?? Date.now)(); + const state = claudeState; + + // Serve from cache while within TTL — on-demand only, NO network. + if (state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { + return serveCached(state); + } + + // Breaker open or cooldown active -> zero network, serve stale (may be null). + if (now < state.breakerOpenUntil || now < state.cooldownUntil) { + return serveCached(state); + } + + // Coalesce: concurrent callers past TTL share one in-flight fetch. + if (state.pending) { + return state.pending; + } + + const readCredentials = deps.readCredentials ?? readClaudeCredentials; + const fetchQuota = deps.fetchClaudeQuota ?? fetchClaudeQuotaWithToken; + const sleep = deps.sleep ?? defaultSleep; + + state.pending = (async (): Promise => { + try { + const creds = readCredentials(); + // No token / unsupported subscription -> never spend a call, omit the row. + if (!creds || !hasSupportedSubscription(creds)) { + return serveCached(state); + } + const token = getAccessToken(creds); + if (!token) { + return serveCached(state); + } + const tier = getSubscriptionTier(creds); + + const quota = await fetchQuota(token, CLAUDE_PROVIDER); + + if (quota.success) { + // Success closes the breaker and clears backoff. + state.consecutive429 = 0; + state.breakerOpenUntil = 0; + state.cooldownUntil = 0; + state.backoffAttempt = 0; + const row = buildClaudeRow(quota, tier, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + + // 401 -> token expired. Emit a reauth row so the bar can prompt; this is + // a real, actionable state distinct from a transient failure. + if (quota.needsReauth) { + const row: BarSummaryRow = { + account_id: CLAUDE_PROVIDER, + provider: CLAUDE_PROVIDER, + displayName: 'Claude Code', + tier, + paused: false, + quota_percentage: null, + quotaStatus: 'error', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'error', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + // Do not cache the reauth row as a good value; it should re-evaluate + // once the user re-auths. But return it now. + return row; + } + + // 429 / 5xx / transient. Apply backoff + breaker, then serve stale. + const is429 = quota.httpStatus === 429; + if (is429) { + state.consecutive429 += 1; + if (state.consecutive429 >= CB_TRIP_THRESHOLD) { + state.breakerOpenUntil = now + CB_COOLDOWN_MS; + } + const retryAfter = parseRetryAfterMs(quota.errorDetail, now); + const backoff = retryAfter ?? computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + // We do NOT sleep-then-retry inside the request path (that would burn + // the request budget). The cooldown gates the NEXT call instead. + void sleep; // retained as an injectable seam for future inline retry + } else if (quota.retryable) { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } + + // Serve last good row on failure; omit if we never succeeded. + return serveCached(state); + } catch { + // Network/parse rejection -> treat as transient, serve stale. + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + return serveCached(state); + } finally { + state.pending = null; + } + })(); + + return state.pending; +} + +// ============================================================================ +// Codex path (local read, no network guards needed) +// ============================================================================ + +async function collectCodexRow(deps: NativeQuotaDeps): Promise { + const now = (deps.now ?? Date.now)(); + const getCodex = deps.getCodexQuota ?? getCodexLocalQuota; + try { + const quota = await getCodex(); + if (!quota) return null; // exec-mode / no rate_limits -> omit the row + return buildCodexRow(quota, now); + } catch { + return null; + } +} + +// ============================================================================ +// Public entry point +// ============================================================================ + +/** + * Build the native subscription rows (Claude Code + Codex) for /summary. + * + * Each path is independently try/caught so one failing source never blocks the + * other or the response. Returns only rows that represent real data. + */ +export async function getNativeAccountRows(deps: NativeQuotaDeps = {}): Promise { + const [claude, codex] = await Promise.all([ + collectClaudeRow(deps).catch(() => null), + collectCodexRow(deps).catch(() => null), + ]); + + const rows: BarSummaryRow[] = []; + if (claude) rows.push(claude); + if (codex) rows.push(codex); + return rows; +} diff --git a/tests/unit/web-server/bar-routes.test.ts b/tests/unit/web-server/bar-routes.test.ts index 4629b951..196e2316 100644 --- a/tests/unit/web-server/bar-routes.test.ts +++ b/tests/unit/web-server/bar-routes.test.ts @@ -986,3 +986,106 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => { expect(body[0].today_cost).toBeCloseTo(3.75); }); }); + +// ============================================================================ +// Native subscription rows side-load into /summary +// ============================================================================ + +describe('/summary native subscription rows', () => { + function nativeRow(provider: 'claude-code' | 'codex'): BarSummaryRow { + return { + account_id: provider, + provider, + displayName: provider === 'codex' ? 'Codex' : 'Claude Code', + tier: provider === 'codex' ? 'pro' : 'max', + paused: false, + quota_percentage: 42, + quotaStatus: 'ok', + next_reset: '2026-06-09T20:00:00.000Z', + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: '2026-06-09T14:00:00.000Z', + needsReauth: false, + }; + } + + async function buildRouter(getNativeAccountRows: () => Promise) { + const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import( + '../../../src/web-server/routes/bar-routes' + ); + + const app = express(); + app.use(express.json()); + + const cliproxyAccount = makeAccountInfo({ id: 'pool@example.com', provider: 'agy' }); + + const router = createBarRouter({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAllAccountsSummary: () => ({ agy: [cliproxyAccount] }) as any, + getCachedQuota: () => null, + setCachedQuota: () => {}, + invalidateQuotaCache: () => {}, + fetchAccountQuota: async () => makeQuotaResult(), + getTodayCostByAccount: () => ({}), + loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], + runHealthChecks: async () => makeHealthReport(), + getNativeAccountRows, + }); + + app.use('/api/bar', router); + const srv = await new Promise((resolve, reject) => { + const instance = app.listen(0, '127.0.0.1'); + instance.once('error', reject); + instance.once('listening', () => resolve(instance)); + }); + const addr = srv.address(); + if (!addr || typeof addr === 'string') throw new Error('No server address'); + resetDebounce(); + return { srv, url: `http://127.0.0.1:${(addr as { port: number }).port}` }; + } + + it('appends native rows after the CLIProxy rows', async () => { + const { srv, url } = await buildRouter(async () => [ + nativeRow('claude-code'), + nativeRow('codex'), + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + expect(body.length).toBe(3); + expect(body[0].provider).toBe('agy'); // CLIProxy row first + expect(body[1].provider).toBe('claude-code'); + expect(body[2].provider).toBe('codex'); + }); + + it('degrades to CLIProxy-only rows when the native fetch never resolves (bounded side-load)', async () => { + const { srv, url } = await buildRouter(() => new Promise(() => {})); + const start = Date.now(); + const { status, body } = await getJson(url, '/api/bar/summary'); + const elapsed = Date.now() - start; + await new Promise((resolve) => srv.close(() => resolve())); + + expect(status).toBe(200); + expect(body.length).toBe(1); + expect(body[0].provider).toBe('agy'); + // Must not block longer than the native side-load budget (+ small slack). + expect(elapsed).toBeLessThan(2_500); + }); + + it('returns CLIProxy-only rows (never 500) when the native fetch rejects', async () => { + const { srv, url } = await buildRouter(async () => { + throw new Error('native blew up'); + }); + const { status, body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + expect(status).toBe(200); + expect(body.length).toBe(1); + expect(body[0].provider).toBe('agy'); + }); +}); diff --git a/tests/unit/web-server/claude-native-credentials.test.ts b/tests/unit/web-server/claude-native-credentials.test.ts new file mode 100644 index 00000000..4a4643eb --- /dev/null +++ b/tests/unit/web-server/claude-native-credentials.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for the native Claude Code credential reader. + * + * All fs / Keychain access is injected, so these tests never touch the real + * filesystem or pop a macOS Keychain prompt. + */ + +import { describe, expect, it } from 'bun:test'; +import { + readClaudeCredentials, + getAccessToken, + getSubscriptionTier, + hasSupportedSubscription, + type ClaudeNativeCredentials, +} from '../../../src/web-server/usage/claude-native-credentials'; + +function makeCreds(overrides: Record = {}): ClaudeNativeCredentials { + return { + claudeAiOauth: { + accessToken: 'tok-abc', + subscriptionType: 'max', + ...overrides, + }, + }; +} + +describe('readClaudeCredentials', () => { + it('parses the on-disk credentials file when present (file-first, no Keychain)', () => { + let keychainCalled = false; + const creds = readClaudeCredentials({ + platform: 'darwin', + homedir: '/home/test', + existsSyncImpl: () => true, + readFileSyncImpl: () => JSON.stringify(makeCreds()), + execSyncImpl: () => { + keychainCalled = true; + return ''; + }, + }); + expect(creds?.claudeAiOauth?.accessToken).toBe('tok-abc'); + // File present means the Keychain must NOT be consulted (avoids prompt). + expect(keychainCalled).toBe(false); + }); + + it('falls back to the macOS Keychain when the file is absent', () => { + const creds = readClaudeCredentials({ + platform: 'darwin', + homedir: '/home/test', + existsSyncImpl: () => false, + readFileSyncImpl: () => { + throw new Error('should not read file'); + }, + execSyncImpl: () => JSON.stringify(makeCreds({ subscriptionType: 'pro' })), + }); + expect(creds?.claudeAiOauth?.subscriptionType).toBe('pro'); + }); + + it('returns null when both file and Keychain are absent', () => { + const creds = readClaudeCredentials({ + platform: 'darwin', + homedir: '/home/test', + existsSyncImpl: () => false, + readFileSyncImpl: () => { + throw new Error('no file'); + }, + execSyncImpl: () => { + throw new Error('no keychain entry'); + }, + }); + expect(creds).toBeNull(); + }); + + it('does not consult the Keychain on non-darwin platforms', () => { + let keychainCalled = false; + const creds = readClaudeCredentials({ + platform: 'linux', + homedir: '/home/test', + existsSyncImpl: () => false, + readFileSyncImpl: () => { + throw new Error('no file'); + }, + execSyncImpl: () => { + keychainCalled = true; + return ''; + }, + }); + expect(creds).toBeNull(); + expect(keychainCalled).toBe(false); + }); +}); + +describe('hasSupportedSubscription', () => { + it.each(['', 'free', 'none'])('returns false for unsupported subscriptionType %p', (sub) => { + expect(hasSupportedSubscription(makeCreds({ subscriptionType: sub }))).toBe(false); + }); + + it.each(['max', 'pro', 'team', 'enterprise'])( + 'returns true for supported subscriptionType %p', + (sub) => { + expect(hasSupportedSubscription(makeCreds({ subscriptionType: sub }))).toBe(true); + } + ); + + it('returns true via rateLimitTier regex when subscriptionType is empty', () => { + const creds = makeCreds({ subscriptionType: '', rateLimitTier: 'claude_max_20x' }); + expect(hasSupportedSubscription(creds)).toBe(true); + }); + + it('returns false for null credentials', () => { + expect(hasSupportedSubscription(null)).toBe(false); + }); +}); + +describe('token + tier extraction', () => { + it('getAccessToken returns the token or null', () => { + expect(getAccessToken(makeCreds())).toBe('tok-abc'); + expect(getAccessToken(makeCreds({ accessToken: '' }))).toBeNull(); + expect(getAccessToken(null)).toBeNull(); + }); + + it('getSubscriptionTier returns the tier or null', () => { + expect(getSubscriptionTier(makeCreds({ subscriptionType: 'max' }))).toBe('max'); + expect(getSubscriptionTier(makeCreds({ subscriptionType: '' }))).toBeNull(); + expect(getSubscriptionTier(null)).toBeNull(); + }); +}); diff --git a/tests/unit/web-server/codex-local-quota-collector.test.ts b/tests/unit/web-server/codex-local-quota-collector.test.ts new file mode 100644 index 00000000..682202d8 --- /dev/null +++ b/tests/unit/web-server/codex-local-quota-collector.test.ts @@ -0,0 +1,100 @@ +/** + * Tests for the Codex local quota collector (zero network). + * + * Uses a temp fixture rollout-*.jsonl read via the real Bun.spawn(['tail', ...]) + * default impl (macOS-safe) plus injected fs seams for the directory walk. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { getCodexLocalQuota } from '../../../src/web-server/usage/codex-local-quota-collector'; + +let tmpDir: string; +let codexHome: string; +let sessionsDir: string; + +function tokenCountLine(rateLimits: unknown): string { + return JSON.stringify({ + timestamp: '2026-06-09T14:36:48.896Z', + type: 'event_msg', + payload: { type: 'token_count', info: {}, rate_limits: rateLimits }, + }); +} + +function writeRollout(name: string, lines: string[]): string { + const day = path.join(sessionsDir, '2026', '06', '09'); + fs.mkdirSync(day, { recursive: true }); + const file = path.join(day, name); + fs.writeFileSync(file, lines.join('\n') + '\n'); + return file; +} + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-local-quota-')); + codexHome = path.join(tmpDir, '.codex'); + sessionsDir = path.join(codexHome, 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('getCodexLocalQuota', () => { + it('parses the last non-null rate_limits into a normalized quota', async () => { + writeRollout('rollout-2026-06-09T10-00-00-aaaa.jsonl', [ + tokenCountLine(null), + tokenCountLine({ + primary: { used_percent: 0.0, window_minutes: 300, resets_at: 1781033803 }, + secondary: { used_percent: 48.0, window_minutes: 10080, resets_at: 1781192122 }, + plan_type: 'pro', + }), + ]); + + const quota = await getCodexLocalQuota({ env: { CODEX_HOME: codexHome }, now: Date.now() }); + expect(quota).not.toBeNull(); + // min(100-0, 100-48) = 52 + expect(quota?.quotaPercentage).toBe(52); + expect(quota?.tier).toBe('pro'); + // soonest reset = min(1781033803, 1781192122) -> primary + expect(quota?.nextReset).toBe(new Date(1781033803 * 1000).toISOString()); + expect(quota?.stale).toBe(false); + }); + + it('returns null when every rate_limits is null (exec-mode session)', async () => { + // A newer session (later filename) with only null rate_limits wins the sort. + writeRollout('rollout-2026-06-09T11-00-00-bbbb.jsonl', [ + tokenCountLine(null), + tokenCountLine(null), + ]); + const quota = await getCodexLocalQuota({ env: { CODEX_HOME: codexHome }, now: Date.now() }); + expect(quota).toBeNull(); + }); + + it('flags stale when the source file mtime is older than 5 minutes', async () => { + const file = writeRollout('rollout-2026-06-09T12-00-00-cccc.jsonl', [ + tokenCountLine({ + primary: { used_percent: 10, window_minutes: 300, resets_at: 1781033803 }, + secondary: { used_percent: 5, window_minutes: 10080, resets_at: 1781192122 }, + plan_type: 'plus', + }), + ]); + const mtime = fs.statSync(file).mtimeMs; + // now = mtime + 6 minutes -> stale + const quota = await getCodexLocalQuota({ + env: { CODEX_HOME: codexHome }, + now: mtime + 6 * 60 * 1000, + }); + expect(quota?.stale).toBe(true); + expect(quota?.tier).toBe('plus'); + }); + + it('returns null when there are no rollout files at all', async () => { + const emptyHome = path.join(tmpDir, '.codex-empty'); + fs.mkdirSync(path.join(emptyHome, 'sessions'), { recursive: true }); + const quota = await getCodexLocalQuota({ env: { CODEX_HOME: emptyHome }, now: Date.now() }); + expect(quota).toBeNull(); + }); +}); diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts new file mode 100644 index 00000000..13501fa3 --- /dev/null +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -0,0 +1,328 @@ +/** + * Tests for the native subscription quota collector. + * + * The Anthropic fetch is ALWAYS mocked — these tests NEVER hit the live usage + * endpoint. A controllable clock drives TTL / backoff / breaker assertions. + */ + +import { beforeEach, describe, expect, it } from 'bun:test'; +import { + getNativeAccountRows, + resetNativeQuotaState, + type NativeQuotaDeps, +} from '../../../src/web-server/usage/native-quota-collector'; +import type { ClaudeQuotaResult } from '../../../src/cliproxy/quota/quota-types'; +import type { ClaudeNativeCredentials } from '../../../src/web-server/usage/claude-native-credentials'; + +// A jump comfortably past any single-call backoff cooldown (<= 60s), used so a +// breaker-test fetch is not blocked by the prior 429's per-call cooldown. +const MAX_COOLDOWN_JUMP = 61_000; + +function maxCreds(): ClaudeNativeCredentials { + return { claudeAiOauth: { accessToken: 'native-tok', subscriptionType: 'max' } }; +} + +function successQuota(): ClaudeQuotaResult { + return { + success: true, + windows: [], + coreUsage: { + fiveHour: { + rateLimitType: 'five_hour', + label: 'Session limit', + remainingPercent: 42, + resetAt: '2026-06-09T20:00:00.000Z', + status: 'allowed', + }, + weekly: { + rateLimitType: 'seven_day', + label: 'Weekly limit', + remainingPercent: 70, + resetAt: '2026-06-15T00:00:00.000Z', + status: 'allowed', + }, + }, + lastUpdated: Date.now(), + accountId: 'claude-code', + }; +} + +function reauthQuota(): ClaudeQuotaResult { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + accountId: 'claude-code', + needsReauth: true, + error: 'Authentication required', + }; +} + +function rateLimitedQuota(retryAfter?: string): ClaudeQuotaResult { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + accountId: 'claude-code', + httpStatus: 429, + retryable: true, + ...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}), + error: 'rate limited', + }; +} + +/** Build a deps object with a controllable clock + counted fetch. */ +function makeDeps( + fetchImpl: (token: string) => Promise, + clock: { now: number }, + credsImpl: () => ClaudeNativeCredentials | null = maxCreds +): NativeQuotaDeps & { fetchCount: () => number } { + let count = 0; + return { + readCredentials: credsImpl, + fetchClaudeQuota: async (token: string) => { + count += 1; + return fetchImpl(token); + }, + getCodexQuota: async () => null, + now: () => clock.now, + sleep: async () => {}, + fetchCount: () => count, + }; +} + +beforeEach(() => { + resetNativeQuotaState(); +}); + +describe('Claude native row mapping', () => { + it('maps a successful fetch into a claude-code ok row with min remaining', async () => { + const clock = { now: 1_000_000 }; + const deps = makeDeps(async () => successQuota(), clock); + const rows = await getNativeAccountRows(deps); + + const row = rows.find((r) => r.provider === 'claude-code'); + expect(row).toBeDefined(); + expect(row?.quotaStatus).toBe('ok'); + // min(42, 70) + expect(row?.quota_percentage).toBe(42); + expect(row?.next_reset).toBe('2026-06-09T20:00:00.000Z'); + expect(row?.tier).toBe('max'); + expect(row?.displayName).toBe('Claude Code'); + expect(row?.account_id).toBe('claude-code'); + expect(row?.needsReauth).toBe(false); + }); + + it('emits a reauth error row on 401/needsReauth', async () => { + const clock = { now: 1_000_000 }; + const deps = makeDeps(async () => reauthQuota(), clock); + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'claude-code'); + expect(row?.quotaStatus).toBe('error'); + expect(row?.health).toBe('error'); + expect(row?.needsReauth).toBe(true); + }); + + it('omits the claude row when there is no token', async () => { + const clock = { now: 1_000_000 }; + const deps = makeDeps( + async () => successQuota(), + clock, + () => null + ); + const rows = await getNativeAccountRows(deps); + expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined(); + }); + + it('omits the claude row for an unsupported (free) subscription without spending a call', async () => { + const clock = { now: 1_000_000 }; + const deps = makeDeps( + async () => successQuota(), + clock, + () => ({ + claudeAiOauth: { accessToken: 'x', subscriptionType: 'free' }, + }) + ); + const rows = await getNativeAccountRows(deps); + expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined(); + expect(deps.fetchCount()).toBe(0); + }); +}); + +describe('cache / TTL', () => { + it('serves cache within TTL and does NOT re-fetch', async () => { + const clock = { now: 1_000_000 }; + const deps = makeDeps(async () => successQuota(), clock); + + await getNativeAccountRows(deps); + expect(deps.fetchCount()).toBe(1); + + // Advance < 10 min: still cached. + clock.now += 5 * 60 * 1000; + const rows = await getNativeAccountRows(deps); + expect(deps.fetchCount()).toBe(1); + expect(rows.find((r) => r.provider === 'claude-code')?.cached).toBe(true); + }); + + it('re-fetches after the TTL expires', async () => { + const clock = { now: 1_000_000 }; + const deps = makeDeps(async () => successQuota(), clock); + + await getNativeAccountRows(deps); + clock.now += 11 * 60 * 1000; // past 10-min TTL + await getNativeAccountRows(deps); + expect(deps.fetchCount()).toBe(2); + }); +}); + +describe('in-flight coalescing', () => { + it('shares ONE fetch across concurrent callers past TTL', async () => { + const clock = { now: 1_000_000 }; + let resolveFetch: (q: ClaudeQuotaResult) => void = () => {}; + const gate = new Promise((resolve) => { + resolveFetch = resolve; + }); + const deps = makeDeps(async () => gate, clock); + + const p1 = getNativeAccountRows(deps); + const p2 = getNativeAccountRows(deps); + resolveFetch(successQuota()); + await Promise.all([p1, p2]); + + expect(deps.fetchCount()).toBe(1); + }); +}); + +describe('Retry-After + backoff + circuit breaker', () => { + it('honors Retry-After: no fetch until the cooldown elapses', async () => { + const t0 = 1_000_000; + const clock = { now: t0 }; + const deps = makeDeps(async () => rateLimitedQuota('30'), clock); + + await getNativeAccountRows(deps); // fetch #1 -> 429, cooldown = t0 + 30s + expect(deps.fetchCount()).toBe(1); + + // Within the 30s Retry-After cooldown -> zero network even though there is + // no cached row yet. + clock.now = t0 + 10_000; + await getNativeAccountRows(deps); + expect(deps.fetchCount()).toBe(1); + + // Past the 30s cooldown -> a fetch is allowed again. + clock.now = t0 + 31_000; + await getNativeAccountRows(deps); + expect(deps.fetchCount()).toBe(2); + }); + + it('trips the breaker after 3 consecutive 429s, then a success closes it', async () => { + const clock = { now: 1_000_000 }; + let mode: 'fail' | 'ok' = 'fail'; + const deps = makeDeps( + async () => (mode === 'fail' ? rateLimitedQuota() : successQuota()), + clock + ); + + // Three 429s; each separated past the per-call cooldown so they actually fetch. + for (let i = 0; i < 3; i++) { + await getNativeAccountRows(deps); + // jump past TTL and any backoff cooldown + clock.now += 11 * 60 * 1000 + MAX_COOLDOWN_JUMP; + } + expect(deps.fetchCount()).toBe(3); + + // Breaker is open now -> zero network even past TTL. + await getNativeAccountRows(deps); + expect(deps.fetchCount()).toBe(3); + + // Advance past the 15-min breaker cooldown; allow a success which closes it. + clock.now += 16 * 60 * 1000; + mode = 'ok'; + await getNativeAccountRows(deps); + expect(deps.fetchCount()).toBe(4); + + // After success, breaker closed: another fetch past TTL proceeds. + clock.now += 11 * 60 * 1000; + await getNativeAccountRows(deps); + expect(deps.fetchCount()).toBe(5); + }); +}); + +describe('stale-on-fail', () => { + it('returns the last good row when a subsequent fetch rejects', async () => { + const clock = { now: 1_000_000 }; + let mode: 'ok' | 'throw' = 'ok'; + const deps = makeDeps(async () => { + if (mode === 'throw') throw new Error('network down'); + return successQuota(); + }, clock); + + await getNativeAccountRows(deps); // good row cached + mode = 'throw'; + clock.now += 11 * 60 * 1000; // force re-fetch + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'claude-code'); + expect(row).toBeDefined(); + expect(row?.quotaStatus).toBe('ok'); + expect(row?.cached).toBe(true); + }); + + it('omits the row when the first-ever fetch fails (no prior cache)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeDeps(async () => { + throw new Error('network down'); + }, clock); + const rows = await getNativeAccountRows(deps); + expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined(); + }); +}); + +describe('Codex path', () => { + it('maps a local Codex quota into a codex ok row', async () => { + const clock = { now: 1_000_000 }; + const deps: NativeQuotaDeps = { + readCredentials: () => null, // no claude row + getCodexQuota: async () => ({ + quotaPercentage: 52, + nextReset: '2026-06-09T19:00:00.000Z', + tier: 'pro', + stale: false, + }), + now: () => clock.now, + }; + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'codex'); + expect(row?.quotaStatus).toBe('ok'); + expect(row?.quota_percentage).toBe(52); + expect(row?.tier).toBe('pro'); + expect(row?.health).toBe('ok'); + }); + + it('flags health warning when the Codex source is stale', async () => { + const clock = { now: 1_000_000 }; + const deps: NativeQuotaDeps = { + readCredentials: () => null, + getCodexQuota: async () => ({ + quotaPercentage: 10, + nextReset: null, + tier: null, + stale: true, + }), + now: () => clock.now, + }; + const rows = await getNativeAccountRows(deps); + expect(rows.find((r) => r.provider === 'codex')?.health).toBe('warning'); + }); + + it('omits the codex row when there is no rate_limits (exec-mode)', async () => { + const clock = { now: 1_000_000 }; + const deps: NativeQuotaDeps = { + readCredentials: () => null, + getCodexQuota: async () => null, + now: () => clock.now, + }; + const rows = await getNativeAccountRows(deps); + expect(rows.find((r) => r.provider === 'codex')).toBeUndefined(); + }); +});