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 () => {
|
it('finds codex via ~/.hermes/node/bin when running under bun', async () => {
|
||||||
createDefaultCodexAuth(tempHome);
|
createDefaultCodexAuth(tempHome);
|
||||||
const childProcess = await import('child_process');
|
const childProcess = await import('child_process');
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { logger } from './logger.js';
|
|||||||
export interface CodexRateLimit {
|
export interface CodexRateLimit {
|
||||||
limitId?: string;
|
limitId?: string;
|
||||||
limitName: string | null;
|
limitName: string | null;
|
||||||
primary: { usedPercent: number; resetsAt: string | number };
|
primary: { usedPercent: number; resetsAt: string | number; windowDurationMins?: number };
|
||||||
secondary: { usedPercent: number; resetsAt: string | number };
|
secondary: { usedPercent: number; resetsAt: string | number; windowDurationMins?: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -291,8 +291,8 @@ export function applyCodexUsageToAccount(
|
|||||||
account: accountIndex + 1,
|
account: accountIndex + 1,
|
||||||
buckets: usage.map((l) => ({
|
buckets: usage.map((l) => ({
|
||||||
id: l.limitId,
|
id: l.limitId,
|
||||||
h5: l.primary.usedPercent,
|
h5: l.primary?.usedPercent ?? null,
|
||||||
d7: l.secondary.usedPercent,
|
d7: l.secondary?.usedPercent ?? null,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
`Codex account #${accountIndex + 1}: ${usage.length} rate-limit bucket(s)`,
|
`Codex account #${accountIndex + 1}: ${usage.length} rate-limit bucket(s)`,
|
||||||
@@ -312,13 +312,31 @@ export function applyCodexUsageToAccount(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pct = Math.round(effective.primary.usedPercent);
|
// Historical shape: primary = 5h window, secondary = 7d window. Some plans
|
||||||
const d7Pct = Math.round(effective.secondary.usedPercent);
|
// (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
|
// Store raw ISO timestamps (not pre-formatted strings) so the exhaustion
|
||||||
// gate can compute "minutes until reset" later. The dashboard render path
|
// gate can compute "minutes until reset" later. The dashboard render path
|
||||||
// formats these at display time via `formatResetRemaining`.
|
// formats these at display time via `formatResetRemaining`.
|
||||||
const resetIso = toIsoMaybe(effective.primary.resetsAt);
|
let pct = Math.round(effective.primary.usedPercent);
|
||||||
const resetD7Iso = toIsoMaybe(effective.secondary.resetsAt);
|
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);
|
updateCodexAccountUsage(pct, resetIso, accountIndex, d7Pct, resetD7Iso);
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user