@@ -601,15 +601,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
601601 return accountId && accountId . length > 0 ? accountId : undefined ;
602602 } ;
603603
604- const hasDistinctNonEmptyAccountIds = (
605- left : { accountId ?: string } | undefined ,
606- right : { accountId ?: string } | undefined ,
607- ) : boolean => {
608- const leftId = normalizeStoredAccountId ( left ) ;
609- const rightId = normalizeStoredAccountId ( right ) ;
610- return ! ! leftId && ! ! rightId && leftId !== rightId ;
611- } ;
612-
613604 const canCollapseWithCandidateAccountId = (
614605 existing : { accountId ?: string } | undefined ,
615606 candidateAccountId : string | undefined ,
@@ -715,19 +706,41 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
715706 return newestExactAccountId ?? newestNoAccountId ;
716707 } ;
717708
718- const resolveUniqueOrgScopedMatch = (
719- indexes : IdentityIndexes ,
720- accountId : string | undefined ,
721- refreshToken : string ,
722- ) : number | undefined => {
723- const byAccountId = accountId
724- ? asUniqueIndex ( indexes . byAccountIdOrgScoped . get ( accountId ) )
725- : undefined ;
726- if ( byAccountId !== undefined ) return byAccountId ;
709+ const resolveUniqueOrgScopedMatch = (
710+ indexes : IdentityIndexes ,
711+ accountId : string | undefined ,
712+ refreshToken : string ,
713+ ) : number | undefined => {
714+ const byAccountId = accountId
715+ ? asUniqueIndex ( indexes . byAccountIdOrgScoped . get ( accountId ) )
716+ : undefined ;
717+ if ( byAccountId !== undefined ) return byAccountId ;
718+
719+ if ( accountId ) {
720+ const accountMatches = indexes . byAccountIdOrgScoped . get ( accountId ) ;
721+ if ( accountMatches && accountMatches . length > 1 ) {
722+ let newestRefreshMatch : number | undefined ;
723+ for ( const index of accountMatches ) {
724+ const existing = accounts [ index ] ;
725+ if ( ! existing ) continue ;
726+ const existingRefresh = existing . refreshToken ?. trim ( ) ;
727+ if ( ! existingRefresh || existingRefresh !== refreshToken ) {
728+ continue ;
729+ }
730+ newestRefreshMatch =
731+ typeof newestRefreshMatch === "number"
732+ ? pickNewestAccountIndex ( newestRefreshMatch , index )
733+ : index ;
734+ }
735+ if ( typeof newestRefreshMatch === "number" ) {
736+ return newestRefreshMatch ;
737+ }
738+ }
739+ }
727740
728- // Refresh-token-only fallback is allowed only when accountId is absent.
729- // This avoids collapsing distinct workspace variants that share refresh token.
730- if ( accountId ) return undefined ;
741+ // Refresh-token-only fallback is allowed only when accountId is absent.
742+ // This avoids collapsing distinct workspace variants that share refresh token.
743+ if ( accountId ) return undefined ;
731744
732745 return asUniqueIndex ( indexes . byRefreshTokenOrgScoped . get ( refreshToken ) ) ;
733746 } ;
@@ -910,157 +923,48 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
910923
911924 const pruneRefreshTokenCollisions = ( ) : void => {
912925 const indicesToRemove = new Set < number > ( ) ;
913- const refreshMap = new Map <
914- string ,
915- {
916- byOrg : Map < string , number [ ] > ;
917- preferredOrgIndex ?: number ;
918- fallbackNoAccountIdIndex ?: number ;
919- fallbackByAccountId : Map < string , number > ;
920- }
921- > ( ) ;
922-
923- const pickPreferredOrgIndex = (
924- existingIndex : number | undefined ,
925- candidateIndex : number ,
926- ) : number => {
927- if ( existingIndex === undefined ) return candidateIndex ;
928- return pickNewestAccountIndex ( existingIndex , candidateIndex ) ;
929- } ;
930-
931- const collapseFallbackIntoPreferredOrg = ( entry : {
932- byOrg : Map < string , number [ ] > ;
933- preferredOrgIndex ?: number ;
934- fallbackNoAccountIdIndex ?: number ;
935- fallbackByAccountId : Map < string , number > ;
936- } ) : void => {
937- if ( entry . preferredOrgIndex === undefined ) {
938- return ;
939- }
940-
941- const preferredOrgIndex = entry . preferredOrgIndex ;
942- const collapseFallbackIndex = ( fallbackIndex : number ) : boolean => {
943- if ( preferredOrgIndex === fallbackIndex ) return true ;
944- const target = accounts [ preferredOrgIndex ] ;
945- const source = accounts [ fallbackIndex ] ;
946- if ( ! target || ! source ) return true ;
947- const targetAccountId = normalizeStoredAccountId ( target ) ;
948- const sourceAccountId = normalizeStoredAccountId ( source ) ;
949- if ( ! targetAccountId && sourceAccountId ) {
950- return false ;
951- }
952- if ( hasDistinctNonEmptyAccountIds ( target , source ) ) {
953- return false ;
954- }
955- mergeAccountRecords ( preferredOrgIndex , fallbackIndex ) ;
956- indicesToRemove . add ( fallbackIndex ) ;
957- return true ;
958- } ;
959-
960- if ( typeof entry . fallbackNoAccountIdIndex === "number" ) {
961- if ( collapseFallbackIndex ( entry . fallbackNoAccountIdIndex ) ) {
962- entry . fallbackNoAccountIdIndex = undefined ;
963- }
964- }
965-
966- const fallbackAccountIdsToDelete : string [ ] = [ ] ;
967- for ( const [ accountId , fallbackIndex ] of entry . fallbackByAccountId ) {
968- if ( collapseFallbackIndex ( fallbackIndex ) ) {
969- fallbackAccountIdsToDelete . push ( accountId ) ;
970- }
971- }
972- for ( const accountId of fallbackAccountIdsToDelete ) {
973- entry . fallbackByAccountId . delete ( accountId ) ;
926+ const exactIdentityToIndex = new Map < string , number > ( ) ;
927+
928+ const getExactIdentityKey = (
929+ account : {
930+ organizationId ?: string ;
931+ accountId ?: string ;
932+ email ?: string ;
933+ refreshToken ?: string ;
934+ } | undefined ,
935+ ) : string => {
936+ const organizationId = account ?. organizationId ?. trim ( ) ?? "" ;
937+ const accountId = normalizeStoredAccountId ( account ) ?? "" ;
938+ const email = account ?. email ?. trim ( ) . toLowerCase ( ) ?? "" ;
939+ const refreshToken = account ?. refreshToken ?. trim ( ) ?? "" ;
940+ if ( organizationId || accountId ) {
941+ return `org:${ organizationId } |account:${ accountId } |refresh:${ refreshToken } ` ;
974942 }
943+ return `email:${ email } |refresh:${ refreshToken } ` ;
975944 } ;
976945
977946 for ( let i = 0 ; i < accounts . length ; i += 1 ) {
978947 const account = accounts [ i ] ;
979948 if ( ! account ) continue ;
980- const refreshToken = account . refreshToken ?. trim ( ) ;
981- if ( ! refreshToken ) continue ;
982- const orgKey = account . organizationId ?. trim ( ) ?? "" ;
983- let entry = refreshMap . get ( refreshToken ) ;
984- if ( ! entry ) {
985- entry = {
986- byOrg : new Map < string , number [ ] > ( ) ,
987- preferredOrgIndex : undefined ,
988- fallbackNoAccountIdIndex : undefined ,
989- fallbackByAccountId : new Map < string , number > ( ) ,
990- } ;
991- refreshMap . set ( refreshToken , entry ) ;
992- }
993-
994- if ( orgKey ) {
995- const orgMatches = entry . byOrg . get ( orgKey ) ?? [ ] ;
996- const existingIndex = resolveOrganizationMatch (
997- {
998- byOrganizationId : new Map ( [ [ orgKey , orgMatches ] ] ) ,
999- byAccountIdNoOrg : new Map ( ) ,
1000- byRefreshTokenNoOrg : new Map ( ) ,
1001- byEmailNoOrg : new Map ( ) ,
1002- byAccountIdOrgScoped : new Map ( ) ,
1003- byRefreshTokenOrgScoped : new Map ( ) ,
1004- byRefreshTokenGlobal : new Map ( ) ,
1005- } ,
1006- orgKey ,
1007- normalizeStoredAccountId ( account ) ,
1008- ) ;
1009- if ( existingIndex !== undefined ) {
1010- const newestIndex = pickNewestAccountIndex ( existingIndex , i ) ;
1011- const obsoleteIndex = newestIndex === existingIndex ? i : existingIndex ;
1012- mergeAccountRecords ( newestIndex , obsoleteIndex ) ;
1013- indicesToRemove . add ( obsoleteIndex ) ;
1014- const nextOrgMatches = orgMatches . filter (
1015- ( index ) => index !== obsoleteIndex && index !== newestIndex ,
1016- ) ;
1017- nextOrgMatches . push ( newestIndex ) ;
1018- entry . byOrg . set ( orgKey , nextOrgMatches ) ;
1019- entry . preferredOrgIndex = pickPreferredOrgIndex ( entry . preferredOrgIndex , newestIndex ) ;
1020- collapseFallbackIntoPreferredOrg ( entry ) ;
1021- continue ;
1022- }
1023- entry . byOrg . set ( orgKey , [ ...orgMatches , i ] ) ;
1024- entry . preferredOrgIndex = pickPreferredOrgIndex ( entry . preferredOrgIndex , i ) ;
1025- collapseFallbackIntoPreferredOrg ( entry ) ;
1026- continue ;
1027- }
1028949
1029- const fallbackAccountId = normalizeStoredAccountId ( account ) ;
1030- if ( fallbackAccountId ) {
1031- const existingFallback = entry . fallbackByAccountId . get ( fallbackAccountId ) ;
1032- if ( typeof existingFallback === "number" ) {
1033- const newestIndex = pickNewestAccountIndex ( existingFallback , i ) ;
1034- const obsoleteIndex = newestIndex === existingFallback ? i : existingFallback ;
1035- mergeAccountRecords ( newestIndex , obsoleteIndex ) ;
1036- indicesToRemove . add ( obsoleteIndex ) ;
1037- entry . fallbackByAccountId . set ( fallbackAccountId , newestIndex ) ;
1038- collapseFallbackIntoPreferredOrg ( entry ) ;
1039- continue ;
1040- }
1041- entry . fallbackByAccountId . set ( fallbackAccountId , i ) ;
1042- collapseFallbackIntoPreferredOrg ( entry ) ;
950+ const identityKey = getExactIdentityKey ( account ) ;
951+ const existingIndex = exactIdentityToIndex . get ( identityKey ) ;
952+ if ( existingIndex === undefined ) {
953+ exactIdentityToIndex . set ( identityKey , i ) ;
1043954 continue ;
1044955 }
1045956
1046- const existingFallback = entry . fallbackNoAccountIdIndex ;
1047- if ( typeof existingFallback === "number" ) {
1048- const newestIndex = pickNewestAccountIndex ( existingFallback , i ) ;
1049- const obsoleteIndex = newestIndex === existingFallback ? i : existingFallback ;
1050- mergeAccountRecords ( newestIndex , obsoleteIndex ) ;
1051- indicesToRemove . add ( obsoleteIndex ) ;
1052- entry . fallbackNoAccountIdIndex = newestIndex ;
1053- collapseFallbackIntoPreferredOrg ( entry ) ;
1054- continue ;
1055- }
1056- entry . fallbackNoAccountIdIndex = i ;
1057- collapseFallbackIntoPreferredOrg ( entry ) ;
957+ const newestIndex = pickNewestAccountIndex ( existingIndex , i ) ;
958+ const obsoleteIndex = newestIndex === existingIndex ? i : existingIndex ;
959+ mergeAccountRecords ( newestIndex , obsoleteIndex ) ;
960+ indicesToRemove . add ( obsoleteIndex ) ;
961+ exactIdentityToIndex . set ( identityKey , newestIndex ) ;
1058962 }
1059963
1060- if ( indicesToRemove . size > 0 ) {
1061- accounts = accounts . filter ( ( _ , index ) => ! indicesToRemove . has ( index ) ) ;
1062- }
1063- } ;
964+ if ( indicesToRemove . size > 0 ) {
965+ accounts = accounts . filter ( ( _ , index ) => ! indicesToRemove . has ( index ) ) ;
966+ }
967+ } ;
1064968
1065969 const collectIdentityKeys = (
1066970 account : { organizationId ?: string ; accountId ?: string ; refreshToken ?: string } | undefined ,
@@ -2179,7 +2083,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
21792083 }
21802084
21812085 while ( true ) {
2182- const accountCount = accountManager . getAccountCount ( ) ;
2086+ let accountCount = accountManager . getAccountCount ( ) ;
21832087 const attempted = new Set < number > ( ) ;
21842088 let restartAccountTraversalWithFallback = false ;
21852089
@@ -2258,13 +2162,36 @@ while (attempted.size < Math.max(1, accountCount)) {
22582162 const accountLabel = formatAccountLabel ( account , account . index ) ;
22592163
22602164 if ( failures >= ACCOUNT_LIMITS . MAX_AUTH_FAILURES_BEFORE_REMOVAL ) {
2261- accountManager . removeAccount ( account ) ;
2165+ const removedCount = accountManager . removeAccountsWithSameRefreshToken ( account ) ;
2166+ if ( removedCount <= 0 ) {
2167+ logWarn (
2168+ `[${ PLUGIN_NAME } ] Expected grouped account removal after auth failures, but removed ${ removedCount } .` ,
2169+ ) ;
2170+ const cooledCount = accountManager . markAccountsWithRefreshTokenCoolingDown (
2171+ account . refreshToken ,
2172+ ACCOUNT_LIMITS . AUTH_FAILURE_COOLDOWN_MS ,
2173+ "auth-failure" ,
2174+ ) ;
2175+ if ( cooledCount <= 0 ) {
2176+ logWarn (
2177+ `[${ PLUGIN_NAME } ] Unable to apply auth-failure cooldown; no live account found for refresh token.` ,
2178+ ) ;
2179+ }
2180+ accountManager . saveToDiskDebounced ( ) ;
2181+ continue ;
2182+ }
22622183 accountManager . saveToDiskDebounced ( ) ;
2184+ const removalMessage = removedCount > 1
2185+ ? `Removed ${ removedCount } accounts (same refresh token) after ${ failures } consecutive auth failures. Run 'opencode auth login' to re-add.`
2186+ : `Removed ${ accountLabel } after ${ failures } consecutive auth failures. Run 'opencode auth login' to re-add.` ;
22632187 await showToast (
2264- `Removed ${ accountLabel } after ${ failures } consecutive auth failures. Run 'opencode auth login' to re-add.` ,
2188+ removalMessage ,
22652189 "error" ,
22662190 { duration : toastDurationMs * 2 } ,
22672191 ) ;
2192+ // Restart traversal: clear attempted and refresh accountCount to avoid skipping healthy accounts
2193+ attempted . clear ( ) ;
2194+ accountCount = accountManager . getAccountCount ( ) ;
22682195 continue ;
22692196 }
22702197
@@ -2322,6 +2249,7 @@ while (attempted.size < Math.max(1, accountCount)) {
23222249 {
23232250 model,
23242251 promptCacheKey,
2252+ organizationId : account . organizationId ,
23252253 } ,
23262254 ) ;
23272255
@@ -2959,6 +2887,7 @@ while (attempted.size < Math.max(1, accountCount)) {
29592887 const fetchCodexQuotaSnapshot = async ( params : {
29602888 accountId : string ;
29612889 accessToken : string ;
2890+ organizationId : string | undefined ;
29622891 } ) : Promise < CodexQuotaSnapshot > => {
29632892 const QUOTA_PROBE_MODELS = [ "gpt-5-codex" , "gpt-5.3-codex" , "gpt-5.2-codex" ] ;
29642893 let lastError : Error | null = null ;
@@ -2985,6 +2914,7 @@ while (attempted.size < Math.max(1, accountCount)) {
29852914
29862915 const headers = createCodexHeaders ( undefined , params . accountId , params . accessToken , {
29872916 model,
2917+ organizationId : params . organizationId ,
29882918 } ) ;
29892919 headers . set ( "content-type" , "application/json" ) ;
29902920
@@ -3258,6 +3188,7 @@ while (attempted.size < Math.max(1, accountCount)) {
32583188 const snapshot = await fetchCodexQuotaSnapshot ( {
32593189 accountId : requestAccountId ,
32603190 accessToken,
3191+ organizationId : account . organizationId ,
32613192 } ) ;
32623193 ok += 1 ;
32633194 console . log (
0 commit comments