@@ -490,23 +490,92 @@ export async function ensureCodexUsageAccessToken(params: {
490490 return { accessToken, refreshed : true , persisted } ;
491491}
492492
493+ /**
494+ * Normalize an account identity field to a trimmed string.
495+ *
496+ * Non-string values collapse to an empty string so callers can treat
497+ * "missing" and "blank" identity parts uniformly when building dedupe keys.
498+ */
499+ function normalizeUsageIdentityPart ( value : string | undefined ) : string {
500+ return typeof value === "string" ? value . trim ( ) : "" ;
501+ }
502+
503+ /**
504+ * Derive a stable usage-quota dedupe key for an account.
505+ *
506+ * A single OAuth credential can authorize multiple ChatGPT workspaces, each
507+ * exposing distinct `accountId` / `organizationId` values while sharing one
508+ * refresh token. Quota deduplication must therefore key on workspace identity
509+ * first so separate workspaces are not collapsed into one row, falling back to
510+ * the refresh token only when no workspace identity is available.
511+ *
512+ * Keys are emitted as `JSON.stringify` arrays (tagged `"workspace"` or
513+ * `"refresh"`) so values containing delimiter characters cannot collide.
514+ *
515+ * @param account - Stored account metadata to derive the key from.
516+ * @returns A unique identity key, or `undefined` when the account carries no
517+ * workspace identity and no refresh token.
518+ */
519+ export function getUsageAccountDedupeKey (
520+ account : AccountMetadataV3 ,
521+ ) : string | undefined {
522+ const accountId = normalizeUsageIdentityPart ( account . accountId ) ;
523+ const organizationId = normalizeUsageIdentityPart ( account . organizationId ) ;
524+ if ( accountId || organizationId ) {
525+ return JSON . stringify ( [ "workspace" , accountId , organizationId ] ) ;
526+ }
527+
528+ const refreshToken = normalizeUsageIdentityPart ( account . refreshToken ) ;
529+ return refreshToken ? JSON . stringify ( [ "refresh" , refreshToken ] ) : undefined ;
530+ }
531+
532+ /**
533+ * Collect the indices of accounts that represent distinct usage quotas.
534+ *
535+ * Disabled accounts are skipped. Accounts with no usable identity — no
536+ * `accountId`, no `organizationId`, and no `refreshToken`, i.e. those for which
537+ * {@link getUsageAccountDedupeKey} returns `undefined` — are also dropped, since
538+ * they cannot be attributed to a quota and have no token to query. Entries
539+ * sharing the same dedupe key are collapsed to a single index.
540+ *
541+ * When a workspace key appears more than once (e.g. an account re-added after a
542+ * token re-issue), the *last* (most recently added) occurrence is kept so the
543+ * freshest credential is queried — keeping the first occurrence could surface
544+ * an invalidated refresh token after re-auth. First-appearance order is still
545+ * used for display stability.
546+ *
547+ * @param storage - The account storage to scan.
548+ * @returns Storage indices of unique, enabled, identifiable usage accounts in
549+ * first-appearance order, each pointing at its freshest occurrence.
550+ */
493551export function deduplicateUsageAccountIndices ( storage : AccountStorageV3 ) : number [ ] {
494- const seenTokens = new Set < string > ( ) ;
495- const uniqueIndices : number [ ] = [ ] ;
552+ const indexByIdentity = new Map < string , number > ( ) ;
496553 for ( let i = 0 ; i < storage . accounts . length ; i += 1 ) {
497554 const account = storage . accounts [ i ] ;
498555 if ( ! account ) continue ;
499- const refreshToken =
500- typeof account . refreshToken === "string"
501- ? account . refreshToken . trim ( )
502- : "" ;
503- if ( refreshToken && seenTokens . has ( refreshToken ) ) continue ;
504- if ( refreshToken ) seenTokens . add ( refreshToken ) ;
505- uniqueIndices . push ( i ) ;
556+ if ( account . enabled === false ) continue ;
557+ const key = getUsageAccountDedupeKey ( account ) ;
558+ if ( ! key ) continue ;
559+ // Map keeps first-insertion key order (stable display) while overwriting
560+ // the value so the latest occurrence's index wins (freshest credential).
561+ indexByIdentity . set ( key , i ) ;
506562 }
507- return uniqueIndices ;
563+ return [ ... indexByIdentity . values ( ) ] ;
508564}
509565
566+ /**
567+ * Resolve which account's usage quota should be shown as active.
568+ *
569+ * Starts from the persisted active index (preferring the Codex family index)
570+ * and then prefers the most-recently-used enabled account by `lastUsed`, so the
571+ * displayed quota tracks the credential actually serving requests. Disabled
572+ * accounts are ignored, and invalid/missing `lastUsed` values are treated as
573+ * oldest.
574+ *
575+ * @param storage - The account storage to inspect.
576+ * @returns The selected account and its index, or `null` when no enabled
577+ * account is available.
578+ */
510579export function resolveCodexUsageActiveAccount (
511580 storage : AccountStorageV3 ,
512581) : UsageAccountSelection | null {
@@ -519,25 +588,41 @@ export function resolveCodexUsageActiveAccount(
519588 Math . min ( storage . accounts . length - 1 , Math . trunc ( numericIndex ) ) ,
520589 ) ;
521590 const activeAccount = storage . accounts [ index ] ;
522- if ( ! activeAccount ) return null ;
591+ if (
592+ ! activeAccount &&
593+ storage . accounts . every ( ( account ) => ! account || account . enabled === false )
594+ ) {
595+ return null ;
596+ }
523597
598+ // An enabled active account with a missing/invalid `lastUsed` must fall back
599+ // to 0 (same as every other enabled account), not -1. Using -1 would let a
600+ // lower-index enabled account with `lastUsed` 0 win the `0 > -1` comparison
601+ // and steal the active marker before the active account's own iteration.
602+ const activeEnabled = ! ! activeAccount && activeAccount . enabled !== false ;
524603 const activeLastUsed =
525- typeof activeAccount . lastUsed === "number" && Number . isFinite ( activeAccount . lastUsed )
604+ activeEnabled &&
605+ typeof activeAccount ?. lastUsed === "number" &&
606+ Number . isFinite ( activeAccount . lastUsed )
526607 ? activeAccount . lastUsed
527- : 0 ;
528- let newestIndex = index ;
608+ : activeEnabled
609+ ? 0
610+ : - 1 ;
611+ let newestIndex = activeEnabled ? index : - 1 ;
529612 let newestLastUsed = activeLastUsed ;
530613 for ( let i = 0 ; i < storage . accounts . length ; i += 1 ) {
531614 const account = storage . accounts [ i ] ;
615+ if ( ! account || account . enabled === false ) continue ;
532616 const lastUsed =
533- typeof account ? .lastUsed === "number" && Number . isFinite ( account . lastUsed )
617+ typeof account . lastUsed === "number" && Number . isFinite ( account . lastUsed )
534618 ? account . lastUsed
535619 : 0 ;
536- if ( account && lastUsed > newestLastUsed ) {
620+ if ( lastUsed > newestLastUsed ) {
537621 newestIndex = i ;
538622 newestLastUsed = lastUsed ;
539623 }
540624 }
625+ if ( newestIndex < 0 ) return null ;
541626
542627 const account = storage . accounts [ newestIndex ] ;
543628 return account ? { index : newestIndex , account } : null ;
0 commit comments