mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 12:16:38 +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
@@ -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