Skip to content

Commit c2dca9e

Browse files
committed
fix: surface persisted rate limits in quota views
Persisted account rate-limit state and persisted quota-cache views were tracked separately, so the CLI menu could lose rate-limit context between live probes. Merge stored account rate-limit resets into the display-side quota view so persisted 429 pressure stays visible even when the cache is otherwise empty.
1 parent f235057 commit c2dca9e

2 files changed

Lines changed: 87 additions & 8 deletions

File tree

lib/codex-manager.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import {
8080
} from "./request/helpers/model-map.js";
8181
import {
8282
formatRateLimitEntry as formatAccountRateLimitEntry,
83+
getRateLimitResetTimeForFamily,
8384
resolveActiveIndex,
8485
} from "./runtime/account-status.js";
8586
import {
@@ -645,6 +646,41 @@ function getQuotaCacheEntryForAccount(
645646
return null;
646647
}
647648

649+
function getPersistedQuotaViewForAccount(
650+
cache: QuotaCacheData | null,
651+
account: Pick<AccountMetadataV3, "accountId" | "email" | "rateLimitResetTimes">,
652+
accounts: readonly Pick<AccountMetadataV3, "accountId" | "email">[],
653+
now: number,
654+
emailFallbackState = buildQuotaEmailFallbackState(accounts),
655+
): QuotaCacheEntry | null {
656+
const cachedEntry = cache
657+
? getQuotaCacheEntryForAccount(cache, account, accounts, emailFallbackState)
658+
: null;
659+
const persistedResetAt = getRateLimitResetTimeForFamily(account, now, "codex");
660+
if (typeof persistedResetAt !== "number") {
661+
return cachedEntry;
662+
}
663+
const cachedPrimaryResetAt = cachedEntry?.primary.resetAtMs ?? 0;
664+
const cachedSecondaryResetAt = cachedEntry?.secondary.resetAtMs ?? 0;
665+
if (
666+
cachedEntry?.status === 429 &&
667+
Math.max(cachedPrimaryResetAt, cachedSecondaryResetAt) >= persistedResetAt
668+
) {
669+
return cachedEntry;
670+
}
671+
return {
672+
updatedAt: cachedEntry?.updatedAt ?? now,
673+
status: 429,
674+
model: cachedEntry?.model ?? "gpt-5-codex",
675+
planType: cachedEntry?.planType,
676+
primary: {
677+
...cachedEntry?.primary,
678+
resetAtMs: Math.max(cachedPrimaryResetAt, persistedResetAt),
679+
},
680+
secondary: cachedEntry?.secondary ?? {},
681+
};
682+
}
683+
648684
function updateQuotaCacheForAccount(
649685
cache: QuotaCacheData,
650686
account: Pick<AccountMetadataV3, "accountId" | "email">,
@@ -1062,14 +1098,13 @@ function toExistingAccountInfo(
10621098
const layoutMode = resolveMenuLayoutMode(displaySettings);
10631099
const emailFallbackState = buildQuotaEmailFallbackState(storage.accounts);
10641100
const baseAccounts = storage.accounts.map((account, index) => {
1065-
const entry = quotaCache
1066-
? getQuotaCacheEntryForAccount(
1067-
quotaCache,
1068-
account,
1069-
storage.accounts,
1070-
emailFallbackState,
1071-
)
1072-
: null;
1101+
const entry = getPersistedQuotaViewForAccount(
1102+
quotaCache,
1103+
account,
1104+
storage.accounts,
1105+
now,
1106+
emailFallbackState,
1107+
);
10731108
return {
10741109
index,
10751110
sourceIndex: index,

test/codex-manager-cli.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7152,6 +7152,50 @@ describe("codex manager cli commands", () => {
71527152
expect(firstCallAccounts[1]?.quotaSummary).toBe("5h 90%");
71537153
});
71547154

7155+
it("surfaces persisted account rate limits when quota cache is empty", async () => {
7156+
const now = Date.now();
7157+
loadAccountsMock.mockResolvedValue({
7158+
version: 3,
7159+
activeIndex: 0,
7160+
activeIndexByFamily: { codex: 0 },
7161+
accounts: [
7162+
{
7163+
email: "rate-limited@example.com",
7164+
accountId: "acc_rate_limited",
7165+
refreshToken: "refresh-rate-limited",
7166+
accessToken: "access-rate-limited",
7167+
expiresAt: now + 3_600_000,
7168+
addedAt: now - 1_000,
7169+
lastUsed: now - 1_000,
7170+
enabled: true,
7171+
rateLimitResetTimes: {
7172+
codex: now + 60_000,
7173+
},
7174+
},
7175+
],
7176+
});
7177+
loadDashboardDisplaySettingsMock.mockResolvedValue(
7178+
createReadyFirstMenuSettings({ menuAutoFetchLimits: false }),
7179+
);
7180+
loadQuotaCacheMock.mockResolvedValue({ byAccountId: {}, byEmail: {} });
7181+
promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" });
7182+
7183+
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
7184+
const exitCode = await runCodexMultiAuthCli(["auth", "login"]);
7185+
7186+
expect(exitCode).toBe(0);
7187+
const firstCallAccounts = promptLoginModeMock.mock.calls[0]?.[0] as Array<{
7188+
email?: string;
7189+
quotaRateLimited?: boolean;
7190+
quota5hResetAtMs?: number;
7191+
quotaSummary?: string;
7192+
}>;
7193+
expect(firstCallAccounts[0]?.email).toBe("rate-limited@example.com");
7194+
expect(firstCallAccounts[0]?.quotaRateLimited).toBe(true);
7195+
expect(firstCallAccounts[0]?.quota5hResetAtMs).toBe(now + 60_000);
7196+
expect(firstCallAccounts[0]?.quotaSummary).toBe("rate-limited");
7197+
});
7198+
71557199
it("treats accounts with no quota windows as the lowest ready-first floor", async () => {
71567200
const now = Date.now();
71577201
loadAccountsMock.mockResolvedValue({

0 commit comments

Comments
 (0)