Skip to content

Commit 36679db

Browse files
committed
fix(codex): persist refreshed pool account plans
1 parent d12fb7d commit 36679db

2 files changed

Lines changed: 695 additions & 15 deletions

File tree

src/codex/auth-api.ts

Lines changed: 172 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { loadConfig, saveConfigPreservingClaudeCode } from "../config";
1+
import { loadConfig, readConfigDiagnostics, saveConfigPreservingClaudeCode } from "../config";
22
import { withCodexAccountLogLabel } from "./account-label";
33
import {
44
getCodexAccountCredential,
55
getValidCodexToken,
66
isCodexAccountGenerationLive,
77
markCodexAccountValidated,
8+
readCodexAccountRecord,
89
saveCodexAccountCredential,
910
CodexCredentialGenerationConflictError,
1011
CodexCredentialRefreshLockTimeoutError,
@@ -413,6 +414,8 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
413414
interface PoolQuotaResult {
414415
quota: StoredAccountQuota | null;
415416
needsReauth: boolean;
417+
/** Credential generation whose cache or network result this DTO state belongs to. */
418+
credentialGeneration?: number;
416419
/** Present only when this call freshly parsed a WHAM usage response. */
417420
freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
418421
/** Present only when this call's WHAM response included a non-empty `plan_type`. */
@@ -423,6 +426,16 @@ interface PoolQuotaResult {
423426
freshResetCredits?: number;
424427
}
425428

429+
interface PoolQuotaRefreshFlight {
430+
state: {
431+
startCredentialGeneration?: number;
432+
resolvedCredentialGeneration?: number;
433+
};
434+
promise: Promise<PoolQuotaResult>;
435+
}
436+
437+
const poolQuotaRefreshInFlight = new Map<string, Set<PoolQuotaRefreshFlight>>();
438+
426439
export interface CodexAuthAccountDto {
427440
id: string;
428441
alias?: string;
@@ -440,39 +453,144 @@ export interface CodexAuthAccountDto {
440453
healthAction?: string;
441454
}
442455

443-
async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
444-
const existing = getAccountQuota(accountId);
445-
if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
446-
return { quota: existing, needsReauth: false };
456+
interface FreshPoolPlanUpdate {
457+
accountId: string;
458+
plan: string;
459+
credentialGeneration: number;
460+
}
461+
462+
/**
463+
* Persist only validated plan leaves against the latest disk snapshot. A quota GET must not save
464+
* the long-lived runtime object wholesale: unrelated manual/provider writes may have landed while
465+
* WHAM requests were in flight. Missing or malformed files fail closed: a read path must not
466+
* recreate a deleted config from the server's older in-memory snapshot.
467+
*/
468+
function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void {
469+
if (updates.length === 0) return;
470+
const diagnostics = readConfigDiagnostics();
471+
if (diagnostics.source !== "file") return;
472+
const persistedConfig = diagnostics.config;
473+
const accepted: FreshPoolPlanUpdate[] = [];
474+
let persistedChanged = false;
475+
476+
for (const update of updates) {
477+
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
478+
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
479+
const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId);
480+
if (!liveAccount || !persistedAccount) continue;
481+
accepted.push(update);
482+
if (persistedAccount.plan !== update.plan) {
483+
persistedAccount.plan = update.plan;
484+
persistedChanged = true;
485+
}
447486
}
487+
488+
if (persistedChanged) saveConfigPreservingClaudeCode(persistedConfig);
489+
for (const update of accepted) {
490+
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
491+
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
492+
if (liveAccount) liveAccount.plan = update.plan;
493+
}
494+
}
495+
496+
async function fetchFreshPoolAccountQuota(
497+
accountId: string,
498+
existing: StoredAccountQuota | null,
499+
configuredPlan?: string,
500+
onCredentialGeneration?: (generation: number) => void,
501+
): Promise<PoolQuotaResult> {
502+
let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
448503
try {
449504
const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId);
505+
requestCredentialGeneration = generation;
506+
onCredentialGeneration?.(generation);
450507
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
451508
headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
452509
signal: AbortSignal.timeout(8000),
453510
});
454-
if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 };
511+
if (!resp.ok) {
512+
return {
513+
quota: existing ?? null,
514+
needsReauth: resp.status === 401,
515+
credentialGeneration: generation,
516+
};
517+
}
455518
const data = (await resp.json()) as WhamUsageResponse;
456519
const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
457520
const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
458521
const freshResetCredits = quota?.resetCredits;
459-
if (!quota) return { quota: existing ?? null, needsReauth: false };
522+
if (!quota) {
523+
return {
524+
quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
525+
needsReauth: false,
526+
credentialGeneration: generation,
527+
};
528+
}
460529
if (!isCodexAccountGenerationLive(accountId, generation)) {
461-
return { quota: getAccountQuota(accountId), needsReauth: false };
530+
return { quota: null, needsReauth: false, credentialGeneration: generation };
462531
}
463532
setAccountQuotaFromParsed(accountId, quota);
464533
return {
465534
quota: getAccountQuota(accountId),
466535
needsReauth: false,
536+
credentialGeneration: generation,
467537
freshQuota: quota,
468538
freshCredentialGeneration: generation,
469539
...(freshPlan !== undefined ? { freshPlan } : {}),
470540
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
471541
};
472542
} catch (e) {
473-
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) return { quota: existing ?? null, needsReauth: false };
474-
if (e instanceof TokenRefreshError) return { quota: existing ?? null, needsReauth: true };
475-
return { quota: existing ?? null, needsReauth: false };
543+
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) {
544+
return { quota: null, needsReauth: false, credentialGeneration: requestCredentialGeneration };
545+
}
546+
if (e instanceof TokenRefreshError) {
547+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration };
548+
}
549+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration };
550+
}
551+
}
552+
553+
async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
554+
const existing = getAccountQuota(accountId);
555+
if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
556+
return {
557+
quota: existing,
558+
needsReauth: false,
559+
credentialGeneration: readCodexAccountRecord(accountId)?.generation,
560+
};
561+
}
562+
// A token refresh may increment the generation (and rotate the refresh token) before WHAM
563+
// completes. Join a flight whose starting or resolved generation is still current, but let a
564+
// replacement credential with the same pool id start its own request.
565+
const record = readCodexAccountRecord(accountId);
566+
const flights = poolQuotaRefreshInFlight.get(accountId);
567+
const current = flights && [...flights].find(flight => {
568+
const generation = flight.state.resolvedCredentialGeneration
569+
?? flight.state.startCredentialGeneration;
570+
return generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
571+
});
572+
if (current) return current.promise;
573+
574+
const state: PoolQuotaRefreshFlight["state"] = {
575+
startCredentialGeneration: record?.generation,
576+
};
577+
const refresh = fetchFreshPoolAccountQuota(
578+
accountId,
579+
existing,
580+
configuredPlan,
581+
generation => { state.resolvedCredentialGeneration = generation; },
582+
);
583+
const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
584+
const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
585+
activeFlights.add(flight);
586+
if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
587+
try {
588+
return await refresh;
589+
} finally {
590+
activeFlights.delete(flight);
591+
if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
592+
poolQuotaRefreshInFlight.delete(accountId);
593+
}
476594
}
477595
}
478596

@@ -542,12 +660,48 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
542660
const runtimeConfig = getRuntimeConfig(config);
543661
const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
544662
const mainInfo = await fetchMainAccountInfo(forceRefresh);
545-
const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
546-
const cred = getCodexAccountCredential(a.id);
663+
const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
664+
const cred = getCodexAccountCredential(account.id);
547665
const quotaResult = cred
548-
? await fetchPoolAccountQuota(a.id, forceRefresh, a.plan)
666+
? await fetchPoolAccountQuota(account.id, forceRefresh, account.plan)
549667
: { quota: null, needsReauth: true };
550-
return poolAccountDto(a, quotaResult, !!cred, isCodexAccountPaused(runtimeConfig, a.id));
668+
return { accountId: account.id, quotaResult };
669+
});
670+
671+
// WHAM plan_type is authoritative only for the credential generation that fetched it. Collect
672+
// changes after every parallel read settles, then apply one narrow disk patch for the batch.
673+
const planUpdates = refreshedPool.flatMap(({ accountId, quotaResult }): FreshPoolPlanUpdate[] => {
674+
const plan = quotaResult.freshPlan;
675+
const credentialGeneration = quotaResult.freshCredentialGeneration;
676+
return plan && credentialGeneration !== undefined
677+
? [{ accountId, plan, credentialGeneration }]
678+
: [];
679+
});
680+
reconcileFreshPoolAccountPlans(runtimeConfig, planUpdates);
681+
682+
const withQuota = refreshedPool.flatMap(({ accountId, quotaResult }) => {
683+
const currentAccount = configuredPoolAccount(runtimeConfig, accountId);
684+
if (!currentAccount) return [];
685+
const currentCredential = getCodexAccountCredential(accountId);
686+
if (!currentCredential) {
687+
return [poolAccountDto(
688+
currentAccount,
689+
{ quota: null, needsReauth: true },
690+
false,
691+
isCodexAccountPaused(runtimeConfig, accountId),
692+
)];
693+
}
694+
const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
695+
const effectiveQuotaResult = resultGeneration !== undefined
696+
&& !isCodexAccountGenerationLive(accountId, resultGeneration)
697+
? { quota: null, needsReauth: false }
698+
: quotaResult;
699+
return [poolAccountDto(
700+
currentAccount,
701+
effectiveQuotaResult,
702+
true,
703+
isCodexAccountPaused(runtimeConfig, accountId),
704+
)];
551705
});
552706
const hasMainCredential = readCodexTokens() !== null;
553707
const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
@@ -1128,6 +1282,9 @@ export async function handleCodexAuthAPI(
11281282
expiresAt: cred.expires,
11291283
chatgptAccountId: oauthAccountId,
11301284
});
1285+
// A successful reauthentication replaces the credential generation. Do not let a
1286+
// failed optional WHAM probe make the replacement inherit quota from the old record.
1287+
if (reauth) clearAccountQuota(accountId);
11311288
markCodexAccountValidated(accountId, warmup.validatedAt);
11321289
clearAccountNeedsReauth(accountId);
11331290
if (quota) {

0 commit comments

Comments
 (0)