mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
feat(bar): per-window subscription quota detail + codex multi-session scan
Expose per-window detail (5h / weekly / Opus / Sonnet) on native subscription rows instead of collapsing to a single percentage, so the bar can show the binding window, resets, and a burn-rate pace line. Fix the Codex collector to scan recent session rollouts newest-first for the latest non-null rate_limits (today's exec-mode session is null) and mark the result stale with its source time, so Codex quota appears instead of silently vanishing.
This commit is contained in:
@@ -28,6 +28,32 @@ import type { DailyUsage, HourlyUsage } from '../usage/types';
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Per-window quota detail for native subscription rows (Claude/Codex).
|
||||
*
|
||||
* Carries BOTH used and remaining percent so the macOS bar never re-derives
|
||||
* them. CLIProxy rows omit the parent `quotaWindows` field entirely, keeping
|
||||
* the payload backward compatible.
|
||||
*
|
||||
* JSON shape (decode test pins these exact keys): the inner object keys stay
|
||||
* camelCase (usedPercent/remainingPercent/resetAt/windowMinutes); only the
|
||||
* parent field name serializes to snake_case ("quota_windows").
|
||||
*/
|
||||
export interface QuotaWindowDetail {
|
||||
/** Stable key: "five_hour" | "seven_day" | "seven_day_opus" | "seven_day_sonnet". */
|
||||
key: string;
|
||||
/** Display label: "5h" | "week" | "Opus · week" | "Sonnet · week". */
|
||||
label: string;
|
||||
/** Used percentage (0-100). */
|
||||
usedPercent: number;
|
||||
/** Remaining percentage (0-100). Carried explicitly, never derived in Swift. */
|
||||
remainingPercent: number;
|
||||
/** ISO timestamp when this window resets, null if unknown. */
|
||||
resetAt: string | null;
|
||||
/** Window length in minutes (300 | 10080), null if unknown. */
|
||||
windowMinutes: number | null;
|
||||
}
|
||||
|
||||
/** Single account glance row returned by /api/bar/summary */
|
||||
export interface BarSummaryRow {
|
||||
/** Account identifier (email or custom name) */
|
||||
@@ -67,6 +93,18 @@ export interface BarSummaryRow {
|
||||
fetchedAt: string;
|
||||
/** True if account token is expired and needs re-authentication */
|
||||
needsReauth: boolean;
|
||||
/**
|
||||
* Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
|
||||
* Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode
|
||||
* tests and the Swift legacy path stay unaffected. Serialized as
|
||||
* "quota_windows".
|
||||
*/
|
||||
quotaWindows?: QuotaWindowDetail[];
|
||||
/**
|
||||
* Native-only ISO mtime of the source session that supplied a stale Codex
|
||||
* reading. Present only when stale; serialized as "stale_as_of".
|
||||
*/
|
||||
staleAsOf?: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -177,6 +215,21 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a row to its wire shape. The native-only additions use snake_case parent
|
||||
* keys ("quota_windows" / "stale_as_of") to match the existing payload's mixed
|
||||
* casing; inner QuotaWindowDetail keys stay camelCase and pass through as-is.
|
||||
* Rows without the native fields serialize byte-identically to before, so
|
||||
* CLIProxy decode/encode tests are unaffected.
|
||||
*/
|
||||
function serializeBarRow(row: BarSummaryRow): Record<string, unknown> {
|
||||
const { quotaWindows, staleAsOf, ...rest } = row;
|
||||
const wire: Record<string, unknown> = { ...rest };
|
||||
if (quotaWindows !== undefined) wire.quota_windows = quotaWindows;
|
||||
if (staleAsOf !== undefined && staleAsOf !== null) wire.stale_as_of = staleAsOf;
|
||||
return wire;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-account health derivation
|
||||
// ============================================================================
|
||||
@@ -496,7 +549,7 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
||||
const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]);
|
||||
const nativeRows = (await withTimeout(getNative(), NATIVE_SIDELOAD_TIMEOUT_MS)) ?? [];
|
||||
|
||||
res.json([...rows, ...nativeRows]);
|
||||
res.json([...rows, ...nativeRows].map(serializeBarRow));
|
||||
} catch (err) {
|
||||
console.error('[bar-routes] /summary error:', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
||||
@@ -2,19 +2,41 @@
|
||||
* 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.
|
||||
* (~/.codex/sessions/<y>/<m>/<d>/rollout-*.jsonl). We 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.
|
||||
* Exec-mode sessions often never emit rate_limits (the field stays null). The
|
||||
* NEWEST session is frequently exec-mode, so reading only it would emit no row
|
||||
* even though an older interactive session still carries real quota. We instead
|
||||
* scan recent sessions newest-first and use the first one that yields a usable
|
||||
* rate_limits, marking the result stale when that source file is old. We only
|
||||
* return null when no scanned session has any rate_limits — then the bar omits
|
||||
* the 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';
|
||||
|
||||
/**
|
||||
* One Codex quota window normalized for the bar's per-window detail.
|
||||
* `key`/`label` map primary->five_hour/"5h", secondary->seven_day/"week".
|
||||
*/
|
||||
export interface CodexLocalQuotaWindow {
|
||||
/** Stable window key: "five_hour" | "seven_day". */
|
||||
key: string;
|
||||
/** Short display label: "5h" | "week". */
|
||||
label: string;
|
||||
/** Used percentage (0-100). */
|
||||
usedPercent: number;
|
||||
/** Remaining percentage (0-100) = 100 - usedPercent. */
|
||||
remainingPercent: number;
|
||||
/** ISO timestamp when this window resets, null if unknown. */
|
||||
resetAt: string | null;
|
||||
/** Window length in minutes (300 / 10080), null if absent in the raw log. */
|
||||
windowMinutes: number | null;
|
||||
}
|
||||
|
||||
/** Normalized Codex quota snapshot from a local session log. */
|
||||
export interface CodexLocalQuota {
|
||||
/** Remaining percentage (0-100): min across primary/secondary windows. */
|
||||
@@ -25,6 +47,13 @@ export interface CodexLocalQuota {
|
||||
tier: string | null;
|
||||
/** True when the source file is older than the freshness window. */
|
||||
stale: boolean;
|
||||
/**
|
||||
* ISO mtime of the session file that SUPPLIED this data, present only when
|
||||
* stale. Lets the bar render an "as of HH:mm (older session)" footnote.
|
||||
*/
|
||||
staleAsOf: string | null;
|
||||
/** Per-window detail (primary -> five_hour, secondary -> seven_day). */
|
||||
windows: CodexLocalQuotaWindow[];
|
||||
}
|
||||
|
||||
/** Injectable seams for deterministic tests (no real fs / no tail subprocess). */
|
||||
@@ -45,9 +74,17 @@ const TAIL_LINES = 200;
|
||||
/** A source older than this is reported stale (but still emitted). */
|
||||
const STALE_AFTER_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Cap on how many recent session files we scan newest-first before giving up.
|
||||
* Bounds the walk when Codex genuinely never reported (avoids touching the whole
|
||||
* history) while still reaching past several exec-mode sessions to a real one.
|
||||
*/
|
||||
const MAX_SESSIONS_SCANNED = 20;
|
||||
|
||||
interface CodexRateWindow {
|
||||
usedPercent: number;
|
||||
resetsAtSeconds: number | null;
|
||||
windowMinutes: number | null;
|
||||
}
|
||||
|
||||
interface CodexRateLimits {
|
||||
@@ -73,6 +110,7 @@ function parseWindow(value: unknown): CodexRateWindow | null {
|
||||
return {
|
||||
usedPercent,
|
||||
resetsAtSeconds: asFiniteNumber(obj['resets_at']),
|
||||
windowMinutes: asFiniteNumber(obj['window_minutes']),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,9 +200,52 @@ function computeNextReset(rate: CodexRateLimits): string | null {
|
||||
return new Date(Math.min(...resets) * 1000).toISOString();
|
||||
}
|
||||
|
||||
function windowDetail(window: CodexRateWindow, key: string, label: string): CodexLocalQuotaWindow {
|
||||
const usedPercent = Math.max(0, Math.min(100, window.usedPercent));
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
usedPercent,
|
||||
remainingPercent: 100 - usedPercent,
|
||||
resetAt:
|
||||
window.resetsAtSeconds !== null
|
||||
? new Date(window.resetsAtSeconds * 1000).toISOString()
|
||||
: null,
|
||||
windowMinutes: window.windowMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build per-window detail (primary -> 5h, secondary -> week), omitting absent. */
|
||||
function buildWindows(rate: CodexRateLimits): CodexLocalQuotaWindow[] {
|
||||
const windows: CodexLocalQuotaWindow[] = [];
|
||||
if (rate.primary) windows.push(windowDetail(rate.primary, 'five_hour', '5h'));
|
||||
if (rate.secondary) windows.push(windowDetail(rate.secondary, 'seven_day', 'week'));
|
||||
return windows;
|
||||
}
|
||||
|
||||
/** Scan one session file's tail backward for the latest non-null rate_limits. */
|
||||
async function readRateLimitsFromFile(
|
||||
file: string,
|
||||
tailLinesImpl: (file: string, lines: number) => Promise<string[]>
|
||||
): Promise<CodexRateLimits | null> {
|
||||
let lines: string[];
|
||||
try {
|
||||
lines = await tailLinesImpl(file, TAIL_LINES);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const found = extractRateLimits(lines[i]);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the latest Codex session's most recent rate_limits and normalize it.
|
||||
* Returns null when no session carries a usable rate_limits object.
|
||||
* Scan recent Codex sessions newest-first and normalize the first usable
|
||||
* rate_limits. Staleness is computed from the mtime of the file that SUPPLIED
|
||||
* the data (not the newest file), so a real-but-old session reads correctly as
|
||||
* stale. Returns null only when none of the scanned sessions carries quota.
|
||||
*/
|
||||
export async function getCodexLocalQuota(
|
||||
deps: CodexLocalQuotaDeps = {}
|
||||
@@ -182,31 +263,28 @@ export async function getCodexLocalQuota(
|
||||
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];
|
||||
// Filenames carry an ISO timestamp, so the lexicographic order is chronological.
|
||||
// Walk newest-first, bounded, until a session yields a usable rate_limits.
|
||||
const newestFirst = rolloutFiles.slice().reverse().slice(0, MAX_SESSIONS_SCANNED);
|
||||
|
||||
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]);
|
||||
let sourceFile: string | null = null;
|
||||
for (const file of newestFirst) {
|
||||
const found = await readRateLimitsFromFile(file, tailLinesImpl);
|
||||
if (found) {
|
||||
rate = found;
|
||||
sourceFile = file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rate) return null;
|
||||
if (!rate || !sourceFile) return null;
|
||||
|
||||
let stale = false;
|
||||
let staleAsOf: string | null = null;
|
||||
try {
|
||||
stale = now - statMtimeMsImpl(latest) > STALE_AFTER_MS;
|
||||
const mtimeMs = statMtimeMsImpl(sourceFile);
|
||||
stale = now - mtimeMs > STALE_AFTER_MS;
|
||||
if (stale) staleAsOf = new Date(mtimeMs).toISOString();
|
||||
} catch {
|
||||
// Unknown mtime -> treat as fresh; the data itself is still valid.
|
||||
stale = false;
|
||||
@@ -217,5 +295,7 @@ export async function getCodexLocalQuota(
|
||||
nextReset: computeNextReset(rate),
|
||||
tier: rate.planType,
|
||||
stale,
|
||||
staleAsOf,
|
||||
windows: buildWindows(rate),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
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';
|
||||
import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes';
|
||||
|
||||
// ============================================================================
|
||||
// Safety constants (concrete, named, module-level)
|
||||
@@ -174,7 +174,69 @@ function deriveClaudeNextReset(quota: ClaudeQuotaResult): string | null {
|
||||
return resets.length > 0 ? resets[0].iso : null;
|
||||
}
|
||||
|
||||
/** Window length in minutes by Claude rate-limit family. */
|
||||
const FIVE_HOUR_MINUTES = 300;
|
||||
const SEVEN_DAY_MINUTES = 10080;
|
||||
|
||||
/**
|
||||
* Build the per-window detail for a Claude subscription row.
|
||||
*
|
||||
* 5h + weekly come from coreUsage (the canonical core summary). Opus/Sonnet
|
||||
* weekly splits come from quota.windows[] and only exist on Max plans, so they
|
||||
* are omitted entirely when absent. Each window carries BOTH used and remaining
|
||||
* percent so the bar never re-derives them.
|
||||
*/
|
||||
function buildClaudeQuotaWindows(quota: ClaudeQuotaResult): QuotaWindowDetail[] {
|
||||
const windows: QuotaWindowDetail[] = [];
|
||||
|
||||
const fiveHour = quota.coreUsage?.fiveHour;
|
||||
if (fiveHour) {
|
||||
windows.push({
|
||||
key: 'five_hour',
|
||||
label: '5h',
|
||||
usedPercent: 100 - fiveHour.remainingPercent,
|
||||
remainingPercent: fiveHour.remainingPercent,
|
||||
resetAt: fiveHour.resetAt,
|
||||
windowMinutes: FIVE_HOUR_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
const weekly = quota.coreUsage?.weekly;
|
||||
if (weekly) {
|
||||
windows.push({
|
||||
key: 'seven_day',
|
||||
label: 'week',
|
||||
usedPercent: 100 - weekly.remainingPercent,
|
||||
remainingPercent: weekly.remainingPercent,
|
||||
resetAt: weekly.resetAt,
|
||||
windowMinutes: SEVEN_DAY_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
// Opus/Sonnet weekly splits are Max-only; surface them when the API carries
|
||||
// them, otherwise omit so non-Max plans get exactly the two core windows.
|
||||
const splitLabels: Record<string, string> = {
|
||||
seven_day_opus: 'Opus · week',
|
||||
seven_day_sonnet: 'Sonnet · week',
|
||||
};
|
||||
for (const w of quota.windows) {
|
||||
const label = splitLabels[w.rateLimitType];
|
||||
if (!label) continue;
|
||||
windows.push({
|
||||
key: w.rateLimitType,
|
||||
label,
|
||||
usedPercent: w.usedPercent,
|
||||
remainingPercent: w.remainingPercent,
|
||||
resetAt: w.resetAt,
|
||||
windowMinutes: SEVEN_DAY_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
return windows;
|
||||
}
|
||||
|
||||
function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: number): BarSummaryRow {
|
||||
const quotaWindows = buildClaudeQuotaWindows(quota);
|
||||
return {
|
||||
account_id: CLAUDE_PROVIDER,
|
||||
provider: CLAUDE_PROVIDER,
|
||||
@@ -191,10 +253,26 @@ function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: numb
|
||||
cached: false,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
needsReauth: false,
|
||||
// Omit the field entirely (rather than an empty array) when no windows
|
||||
// resolved, so the wire shape stays minimal.
|
||||
...(quotaWindows.length > 0 ? { quotaWindows } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Map the Codex local windows into the row's per-window detail shape. */
|
||||
function buildCodexQuotaWindows(quota: CodexLocalQuota): QuotaWindowDetail[] {
|
||||
return quota.windows.map((w) => ({
|
||||
key: w.key,
|
||||
label: w.label,
|
||||
usedPercent: w.usedPercent,
|
||||
remainingPercent: w.remainingPercent,
|
||||
resetAt: w.resetAt,
|
||||
windowMinutes: w.windowMinutes,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow {
|
||||
const quotaWindows = buildCodexQuotaWindows(quota);
|
||||
return {
|
||||
account_id: CODEX_PROVIDER,
|
||||
provider: CODEX_PROVIDER,
|
||||
@@ -213,6 +291,9 @@ function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow {
|
||||
cached: false,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
needsReauth: false,
|
||||
...(quotaWindows.length > 0 ? { quotaWindows } : {}),
|
||||
// staleAsOf is only present (and serialized) when the source session is old.
|
||||
...(quota.staleAsOf ? { staleAsOf: quota.staleAsOf } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,14 +23,25 @@ function tokenCountLine(rateLimits: unknown): string {
|
||||
});
|
||||
}
|
||||
|
||||
function writeRollout(name: string, lines: string[]): string {
|
||||
const day = path.join(sessionsDir, '2026', '06', '09');
|
||||
function writeRollout(sessions: string, name: string, lines: string[]): string {
|
||||
const day = path.join(sessions, '2026', '06', '09');
|
||||
fs.mkdirSync(day, { recursive: true });
|
||||
const file = path.join(day, name);
|
||||
fs.writeFileSync(file, lines.join('\n') + '\n');
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each test gets its OWN codex home so the multi-session scan never bleeds a
|
||||
* fixture from another test (the scan walks ALL recent sessions, not just one).
|
||||
*/
|
||||
function freshHome(slug: string): { env: NodeJS.ProcessEnv; sessions: string } {
|
||||
const home = path.join(tmpDir, `.codex-${slug}`);
|
||||
const sessions = path.join(home, 'sessions');
|
||||
fs.mkdirSync(sessions, { recursive: true });
|
||||
return { env: { CODEX_HOME: home }, sessions };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-local-quota-'));
|
||||
codexHome = path.join(tmpDir, '.codex');
|
||||
@@ -44,7 +55,8 @@ afterAll(() => {
|
||||
|
||||
describe('getCodexLocalQuota', () => {
|
||||
it('parses the last non-null rate_limits into a normalized quota', async () => {
|
||||
writeRollout('rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
const { env, sessions } = freshHome('parse');
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine(null),
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 0.0, window_minutes: 300, resets_at: 1781033803 },
|
||||
@@ -53,7 +65,7 @@ describe('getCodexLocalQuota', () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const quota = await getCodexLocalQuota({ env: { CODEX_HOME: codexHome }, now: Date.now() });
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
expect(quota).not.toBeNull();
|
||||
// min(100-0, 100-48) = 52
|
||||
expect(quota?.quotaPercentage).toBe(52);
|
||||
@@ -61,20 +73,94 @@ describe('getCodexLocalQuota', () => {
|
||||
// soonest reset = min(1781033803, 1781192122) -> primary
|
||||
expect(quota?.nextReset).toBe(new Date(1781033803 * 1000).toISOString());
|
||||
expect(quota?.stale).toBe(false);
|
||||
expect(quota?.staleAsOf).toBeNull();
|
||||
});
|
||||
|
||||
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', [
|
||||
it('surfaces per-window detail incl. window_minutes (300 / 10080)', async () => {
|
||||
const { env, sessions } = freshHome('windows');
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 19.0, window_minutes: 300, resets_at: 1781033803 },
|
||||
secondary: { used_percent: 30.0, window_minutes: 10080, resets_at: 1781192122 },
|
||||
plan_type: 'pro',
|
||||
}),
|
||||
]);
|
||||
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
expect(quota?.windows).toHaveLength(2);
|
||||
|
||||
const five = quota?.windows.find((w) => w.key === 'five_hour');
|
||||
expect(five?.label).toBe('5h');
|
||||
expect(five?.usedPercent).toBe(19);
|
||||
expect(five?.remainingPercent).toBe(81);
|
||||
expect(five?.windowMinutes).toBe(300);
|
||||
expect(five?.resetAt).toBe(new Date(1781033803 * 1000).toISOString());
|
||||
|
||||
const week = quota?.windows.find((w) => w.key === 'seven_day');
|
||||
expect(week?.label).toBe('week');
|
||||
expect(week?.usedPercent).toBe(30);
|
||||
expect(week?.remainingPercent).toBe(70);
|
||||
expect(week?.windowMinutes).toBe(10080);
|
||||
expect(week?.resetAt).toBe(new Date(1781192122 * 1000).toISOString());
|
||||
});
|
||||
|
||||
it('scans an OLDER session when the newest is exec-mode (rate_limits:null)', async () => {
|
||||
const { env, sessions } = freshHome('fallback');
|
||||
// Older interactive session carries real quota.
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
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',
|
||||
}),
|
||||
]);
|
||||
// Newest session is exec-mode: only null rate_limits.
|
||||
writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [
|
||||
tokenCountLine(null),
|
||||
tokenCountLine(null),
|
||||
]);
|
||||
const quota = await getCodexLocalQuota({ env: { CODEX_HOME: codexHome }, now: Date.now() });
|
||||
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
// Skips the null-newest file, reads the older file's rate_limits.
|
||||
expect(quota).not.toBeNull();
|
||||
expect(quota?.quotaPercentage).toBe(52);
|
||||
expect(quota?.tier).toBe('pro');
|
||||
});
|
||||
|
||||
it('returns null when NO scanned session carries rate_limits (no fake row)', async () => {
|
||||
const { env, sessions } = freshHome('all-null');
|
||||
writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [tokenCountLine(null)]);
|
||||
writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [
|
||||
tokenCountLine(null),
|
||||
tokenCountLine(null),
|
||||
]);
|
||||
const quota = await getCodexLocalQuota({ env, 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', [
|
||||
it('flags stale from the SOURCE file mtime and sets staleAsOf', async () => {
|
||||
const { env, sessions } = freshHome('stale');
|
||||
// Newest is exec-mode (null); the data comes from the older file, so stale
|
||||
// must reflect the OLDER file's mtime, not the newest's.
|
||||
const sourceFile = writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.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',
|
||||
}),
|
||||
]);
|
||||
writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [tokenCountLine(null)]);
|
||||
|
||||
const sourceMtime = fs.statSync(sourceFile).mtimeMs;
|
||||
const quota = await getCodexLocalQuota({ env, now: sourceMtime + 6 * 60 * 1000 });
|
||||
expect(quota?.stale).toBe(true);
|
||||
expect(quota?.staleAsOf).toBe(new Date(sourceMtime).toISOString());
|
||||
expect(quota?.tier).toBe('plus');
|
||||
});
|
||||
|
||||
it('is fresh (no staleAsOf) when the source file is recent', async () => {
|
||||
const { env, sessions } = freshHome('fresh');
|
||||
const file = writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [
|
||||
tokenCountLine({
|
||||
primary: { used_percent: 10, window_minutes: 300, resets_at: 1781033803 },
|
||||
secondary: { used_percent: 5, window_minutes: 10080, resets_at: 1781192122 },
|
||||
@@ -82,19 +168,14 @@ describe('getCodexLocalQuota', () => {
|
||||
}),
|
||||
]);
|
||||
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');
|
||||
const quota = await getCodexLocalQuota({ env, now: mtime + 60 * 1000 });
|
||||
expect(quota?.stale).toBe(false);
|
||||
expect(quota?.staleAsOf).toBeNull();
|
||||
});
|
||||
|
||||
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() });
|
||||
const { env } = freshHome('empty');
|
||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||
expect(quota).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,34 @@ function successQuota(): ClaudeQuotaResult {
|
||||
};
|
||||
}
|
||||
|
||||
/** A Max-plan quota carrying the Opus/Sonnet weekly splits in windows[]. */
|
||||
function maxQuotaWithSplits(): ClaudeQuotaResult {
|
||||
const base = successQuota();
|
||||
return {
|
||||
...base,
|
||||
windows: [
|
||||
{
|
||||
rateLimitType: 'seven_day_opus',
|
||||
label: 'Opus weekly',
|
||||
status: 'allowed',
|
||||
utilization: 0.25,
|
||||
usedPercent: 25,
|
||||
remainingPercent: 75,
|
||||
resetAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
rateLimitType: 'seven_day_sonnet',
|
||||
label: 'Sonnet weekly',
|
||||
status: 'allowed',
|
||||
utilization: 0.6,
|
||||
usedPercent: 60,
|
||||
remainingPercent: 40,
|
||||
resetAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function reauthQuota(): ClaudeQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
@@ -115,6 +143,51 @@ describe('Claude native row mapping', () => {
|
||||
expect(row?.needsReauth).toBe(false);
|
||||
});
|
||||
|
||||
it('emits quotaWindows with five_hour + seven_day from coreUsage (no opus/sonnet on non-Max)', 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?.quotaWindows).toHaveLength(2);
|
||||
const five = row?.quotaWindows?.find((w) => w.key === 'five_hour');
|
||||
expect(five?.label).toBe('5h');
|
||||
expect(five?.remainingPercent).toBe(42);
|
||||
expect(five?.usedPercent).toBe(58); // 100 - 42
|
||||
expect(five?.windowMinutes).toBe(300);
|
||||
expect(five?.resetAt).toBe('2026-06-09T20:00:00.000Z');
|
||||
|
||||
const week = row?.quotaWindows?.find((w) => w.key === 'seven_day');
|
||||
expect(week?.label).toBe('week');
|
||||
expect(week?.remainingPercent).toBe(70);
|
||||
expect(week?.usedPercent).toBe(30);
|
||||
expect(week?.windowMinutes).toBe(10080);
|
||||
|
||||
// Max-only splits absent here.
|
||||
expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_opus')).toBeUndefined();
|
||||
expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds seven_day_opus + seven_day_sonnet windows when present (Max plan)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => maxQuotaWithSplits(), clock);
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'claude-code');
|
||||
|
||||
// five_hour, seven_day, seven_day_opus, seven_day_sonnet
|
||||
expect(row?.quotaWindows).toHaveLength(4);
|
||||
const opus = row?.quotaWindows?.find((w) => w.key === 'seven_day_opus');
|
||||
expect(opus?.label).toBe('Opus · week');
|
||||
expect(opus?.usedPercent).toBe(25);
|
||||
expect(opus?.remainingPercent).toBe(75);
|
||||
expect(opus?.windowMinutes).toBe(10080);
|
||||
|
||||
const sonnet = row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet');
|
||||
expect(sonnet?.label).toBe('Sonnet · week');
|
||||
expect(sonnet?.usedPercent).toBe(60);
|
||||
expect(sonnet?.remainingPercent).toBe(40);
|
||||
});
|
||||
|
||||
it('emits a reauth error row on 401/needsReauth', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeDeps(async () => reauthQuota(), clock);
|
||||
@@ -288,6 +361,25 @@ describe('Codex path', () => {
|
||||
nextReset: '2026-06-09T19:00:00.000Z',
|
||||
tier: 'pro',
|
||||
stale: false,
|
||||
staleAsOf: null,
|
||||
windows: [
|
||||
{
|
||||
key: 'five_hour',
|
||||
label: '5h',
|
||||
usedPercent: 19,
|
||||
remainingPercent: 81,
|
||||
resetAt: '2026-06-09T19:00:00.000Z',
|
||||
windowMinutes: 300,
|
||||
},
|
||||
{
|
||||
key: 'seven_day',
|
||||
label: 'week',
|
||||
usedPercent: 48,
|
||||
remainingPercent: 52,
|
||||
resetAt: '2026-06-14T00:00:00.000Z',
|
||||
windowMinutes: 10080,
|
||||
},
|
||||
],
|
||||
}),
|
||||
now: () => clock.now,
|
||||
};
|
||||
@@ -297,6 +389,9 @@ describe('Codex path', () => {
|
||||
expect(row?.quota_percentage).toBe(52);
|
||||
expect(row?.tier).toBe('pro');
|
||||
expect(row?.health).toBe('ok');
|
||||
expect(row?.quotaWindows).toHaveLength(2);
|
||||
expect(row?.quotaWindows?.[0].windowMinutes).toBe(300);
|
||||
expect(row?.staleAsOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flags health warning when the Codex source is stale', async () => {
|
||||
@@ -308,11 +403,16 @@ describe('Codex path', () => {
|
||||
nextReset: null,
|
||||
tier: null,
|
||||
stale: true,
|
||||
staleAsOf: '2026-06-09T13:30:00.000Z',
|
||||
windows: [],
|
||||
}),
|
||||
now: () => clock.now,
|
||||
};
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.find((r) => r.provider === 'codex')?.health).toBe('warning');
|
||||
const row = rows.find((r) => r.provider === 'codex');
|
||||
expect(row?.health).toBe('warning');
|
||||
// staleAsOf flows through so the bar can render the freshness footnote.
|
||||
expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z');
|
||||
});
|
||||
|
||||
it('omits the codex row when there is no rate_limits (exec-mode)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user