fix(usage): render codex usage when rate-limit response has null secondary window

Some Codex plans (e.g. Plus) return only a weekly window in `primary` with
`secondary: null`. applyCodexUsageToAccount dereferenced `secondary.usedPercent`
unconditionally, throwing a TypeError that refreshActiveCodexUsage swallowed at
debug level, so usage never applied and the dashboard row stayed blank (-1).

Guard the null secondary, and when a lone window is weekly (windowDurationMins
> 1440) route it into the 7d slot leaving 5h unknown. Accounts that report both
windows are unchanged. Adds a regression test for the secondary:null case.
This commit is contained in:
Codex
2026-07-24 23:15:12 +09:00
parent a9095d95a8
commit 6ab38ca461
2 changed files with 68 additions and 8 deletions

View File

@@ -157,6 +157,48 @@ describe('codex-usage-collector fallback account usage', () => {
]);
});
it('handles a lone weekly window (secondary: null) without throwing and routes it to 7d', async () => {
createDefaultCodexAuth(tempHome);
const childProcess = await import('child_process');
vi.mocked(childProcess.spawn).mockImplementation(((
_cmd: string,
_args: readonly string[] | undefined,
_opts?: { env?: Record<string, string> },
) => {
// Some plans (e.g. Plus) report only a weekly window in `primary` with
// `secondary: null`. This previously threw on `secondary.usedPercent`.
return createFakeChildProcess({
codex: {
limitName: 'Codex',
primary: {
usedPercent: 10,
windowDurationMins: 10080, // 7 days
resetsAt: new Date(Date.now() + 4 * 86_400_000).toISOString(),
},
secondary: null,
},
}) as never;
}) as unknown as typeof childProcess.spawn);
const rotation = await import('./codex-token-rotation.js');
const usage = await import('./codex-usage-collector.js');
rotation.initCodexTokenRotation();
const result = await usage.refreshActiveCodexUsage();
// Usage must be applied (not swallowed by a TypeError), and the lone weekly
// window is shown as 7d while the missing 5h window renders as unknown.
expect(result.fetchedAt).toEqual(expect.any(String));
expect(result.rows).toEqual([
expect.objectContaining({
name: 'Codex',
h5pct: -1,
d7pct: 10,
}),
]);
});
it('finds codex via ~/.hermes/node/bin when running under bun', async () => {
createDefaultCodexAuth(tempHome);
const childProcess = await import('child_process');

View File

@@ -14,8 +14,8 @@ import { logger } from './logger.js';
export interface CodexRateLimit {
limitId?: string;
limitName: string | null;
primary: { usedPercent: number; resetsAt: string | number };
secondary: { usedPercent: number; resetsAt: string | number };
primary: { usedPercent: number; resetsAt: string | number; windowDurationMins?: number };
secondary: { usedPercent: number; resetsAt: string | number; windowDurationMins?: number } | null;
}
/**
@@ -291,8 +291,8 @@ export function applyCodexUsageToAccount(
account: accountIndex + 1,
buckets: usage.map((l) => ({
id: l.limitId,
h5: l.primary.usedPercent,
d7: l.secondary.usedPercent,
h5: l.primary?.usedPercent ?? null,
d7: l.secondary?.usedPercent ?? null,
})),
},
`Codex account #${accountIndex + 1}: ${usage.length} rate-limit bucket(s)`,
@@ -312,13 +312,31 @@ export function applyCodexUsageToAccount(
return;
}
const pct = Math.round(effective.primary.usedPercent);
const d7Pct = Math.round(effective.secondary.usedPercent);
// Historical shape: primary = 5h window, secondary = 7d window. Some plans
// (e.g. Plus) return only ONE window in `primary` (a weekly/7d window) with
// `secondary: null`. Guard the null so we don't throw, and route a lone
// weekly window into the 7d slot so usage still renders.
// Store raw ISO timestamps (not pre-formatted strings) so the exhaustion
// gate can compute "minutes until reset" later. The dashboard render path
// formats these at display time via `formatResetRemaining`.
const resetIso = toIsoMaybe(effective.primary.resetsAt);
const resetD7Iso = toIsoMaybe(effective.secondary.resetsAt);
let pct = Math.round(effective.primary.usedPercent);
let resetIso = toIsoMaybe(effective.primary.resetsAt);
let d7Pct = effective.secondary
? Math.round(effective.secondary.usedPercent)
: -1;
let resetD7Iso = effective.secondary
? toIsoMaybe(effective.secondary.resetsAt)
: undefined;
if (
!effective.secondary &&
(effective.primary.windowDurationMins ?? 0) > 1440
) {
// Lone window is weekly (> 1 day) — show it as 7d, leave 5h unknown.
d7Pct = pct;
resetD7Iso = resetIso;
pct = -1;
resetIso = undefined;
}
updateCodexAccountUsage(pct, resetIso, accountIndex, d7Pct, resetD7Iso);
logger.info(
{