mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 18:15:23 +00:00
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.
This commit is contained in:
1 parent
0fed2982f5
commit
dbeb0c543a
9 files changed
+1481
-21
No files matched your search
@@ -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<ClaudeQuotaResult> {
|
||||
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<ClaudeQuotaResult> {
|
||||
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<ClaudeQuotaResult> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -104,6 +104,13 @@ export interface BarRouterDeps {
|
||||
* audit shells out via a synchronous execSync that must never run here.
|
||||
*/
|
||||
runHealthChecks?: () => Promise<HealthReport>;
|
||||
/**
|
||||
* 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<BarSummaryRow[]>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -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;
|
||||
@@ -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, unknown>) => 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);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Codex local quota collector (zero network).
|
||||
*
|
||||
* Codex writes a `rate_limits` object into its rollout session logs
|
||||
* (~/.codex/sessions/<y>/<m>/<d>/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<string[]>;
|
||||
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<string, unknown> | null {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string[]> {
|
||||
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<CodexLocalQuota | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<ClaudeQuotaResult>;
|
||||
/** Read Codex quota from local session logs (zero network). */
|
||||
getCodexQuota?: () => Promise<CodexLocalQuota | null>;
|
||||
/** Clock seam for deterministic backoff/TTL/breaker tests. */
|
||||
now?: () => number;
|
||||
/** Sleep seam (no real delay in tests). */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<BarSummaryRow | null> | 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<void> {
|
||||
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<typeof w> => !!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<BarSummaryRow | null> {
|
||||
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<BarSummaryRow | null> => {
|
||||
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<BarSummaryRow | null> {
|
||||
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<BarSummaryRow[]> {
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user