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:
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user