1- import { loadConfig , saveConfigPreservingClaudeCode } from "../config" ;
1+ import { loadConfig , readConfigDiagnostics , saveConfigPreservingClaudeCode } from "../config" ;
22import { withCodexAccountLogLabel } from "./account-label" ;
33import {
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
413414interface 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+
426439export 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