mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +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:
@@ -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;
|
||||
}
|
||||
@@ -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<BarSummaryRow[]>) {
|
||||
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<Server>((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<BarSummaryRow[]>(url, '/api/bar/summary');
|
||||
await new Promise<void>((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<BarSummaryRow[]>(() => {}));
|
||||
const start = Date.now();
|
||||
const { status, body } = await getJson<BarSummaryRow[]>(url, '/api/bar/summary');
|
||||
const elapsed = Date.now() - start;
|
||||
await new Promise<void>((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<BarSummaryRow[]>(url, '/api/bar/summary');
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.length).toBe(1);
|
||||
expect(body[0].provider).toBe('agy');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<ClaudeQuotaResult>,
|
||||
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<ClaudeQuotaResult>((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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user