@@ -117,6 +117,7 @@ import {
117117 parseRateLimitReason ,
118118 lookupCodexCliTokensByEmail ,
119119 isCodexCliSyncEnabled ,
120+ type Workspace ,
120121} from "./lib/accounts.js" ;
121122import {
122123 getStoragePath ,
@@ -149,6 +150,7 @@ import {
149150 rewriteUrlForCodex ,
150151 shouldRefreshToken ,
151152 transformRequestForCodex ,
153+ isWorkspaceDisabledError ,
152154} from "./lib/request/fetch-helpers.js" ;
153155import { applyFastSessionDefaults } from "./lib/request/request-transformer.js" ;
154156import {
@@ -377,6 +379,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
377379 accountIdOverride ?: string ;
378380 accountIdSource ?: AccountIdSource ;
379381 accountLabel ?: string ;
382+ workspaces ?: Workspace [ ] ;
380383 } ;
381384
382385 const resolveAccountSelection = (
@@ -399,6 +402,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
399402 return tokens ;
400403 }
401404
405+ // Convert candidates to workspaces
406+ const workspaces : Workspace [ ] = candidates . map ( ( c ) => ( {
407+ id : c . accountId ,
408+ name : c . label ,
409+ enabled : true ,
410+ isDefault : c . isDefault ,
411+ } ) ) ;
412+
402413 if ( candidates . length === 1 ) {
403414 const [ candidate ] = candidates ;
404415 if ( candidate ) {
@@ -407,6 +418,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
407418 accountIdOverride : candidate . accountId ,
408419 accountIdSource : candidate . source ,
409420 accountLabel : candidate . label ,
421+ workspaces,
410422 } ;
411423 }
412424 }
@@ -421,6 +433,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
421433 accountIdOverride : choice . accountId ,
422434 accountIdSource : choice . source ?? "token" ,
423435 accountLabel : choice . label ,
436+ workspaces,
424437 } ;
425438 } ;
426439
@@ -546,6 +559,23 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
546559 } ) ;
547560
548561 if ( existingIndex === undefined ) {
562+ const initialWorkspaceIndex =
563+ result . workspaces && result . workspaces . length > 0
564+ ? ( ( ) => {
565+ if ( accountId ) {
566+ const matchingWorkspaceIndex = result . workspaces . findIndex (
567+ ( workspace ) => workspace . id === accountId ,
568+ ) ;
569+ if ( matchingWorkspaceIndex >= 0 ) {
570+ return matchingWorkspaceIndex ;
571+ }
572+ }
573+ const firstEnabledWorkspaceIndex = result . workspaces . findIndex (
574+ ( workspace ) => workspace . enabled !== false ,
575+ ) ;
576+ return firstEnabledWorkspaceIndex >= 0 ? firstEnabledWorkspaceIndex : 0 ;
577+ } ) ( )
578+ : undefined ;
549579 accounts . push ( {
550580 accountId,
551581 accountIdSource,
@@ -556,6 +586,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
556586 expiresAt : result . expires ,
557587 addedAt : now ,
558588 lastUsed : now ,
589+ workspaces : result . workspaces ,
590+ currentWorkspaceIndex : initialWorkspaceIndex ,
559591 } ) ;
560592 continue ;
561593 }
@@ -568,6 +600,48 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
568600 const nextAccountIdSource =
569601 accountId ? accountIdSource ?? existing . accountIdSource : existing . accountIdSource ;
570602 const nextAccountLabel = accountLabel ?? existing . accountLabel ;
603+ // Preserve tracked workspace state when auth refreshes do not return workspace metadata.
604+ const mergedWorkspaces = result . workspaces
605+ ? result . workspaces . map ( ( newWs ) => {
606+ const existingWs = existing . workspaces ?. find ( ( w ) => w . id === newWs . id ) ;
607+ return existingWs
608+ ? {
609+ ...newWs ,
610+ enabled : existingWs . enabled ,
611+ disabledAt : existingWs . disabledAt ,
612+ }
613+ : newWs ;
614+ } )
615+ : existing . workspaces ;
616+ const currentWorkspaceId =
617+ existing . workspaces ?. [
618+ typeof existing . currentWorkspaceIndex === "number"
619+ ? existing . currentWorkspaceIndex
620+ : 0
621+ ] ?. id ;
622+ const nextCurrentWorkspaceIndex =
623+ mergedWorkspaces && mergedWorkspaces . length > 0
624+ ? ( ( ) => {
625+ if ( currentWorkspaceId ) {
626+ const matchingWorkspaceIndex = mergedWorkspaces . findIndex (
627+ ( workspace ) => workspace . id === currentWorkspaceId ,
628+ ) ;
629+ if ( matchingWorkspaceIndex >= 0 ) {
630+ return matchingWorkspaceIndex ;
631+ }
632+ }
633+ const defaultWorkspaceIndex = mergedWorkspaces . findIndex (
634+ ( workspace ) => workspace . isDefault === true ,
635+ ) ;
636+ if ( defaultWorkspaceIndex >= 0 ) {
637+ return defaultWorkspaceIndex ;
638+ }
639+ const firstEnabledWorkspaceIndex = mergedWorkspaces . findIndex (
640+ ( workspace ) => workspace . enabled !== false ,
641+ ) ;
642+ return firstEnabledWorkspaceIndex >= 0 ? firstEnabledWorkspaceIndex : 0 ;
643+ } ) ( )
644+ : existing . currentWorkspaceIndex ;
571645 accounts [ existingIndex ] = {
572646 ...existing ,
573647 accountId : nextAccountId ,
@@ -578,6 +652,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
578652 accessToken : result . access ,
579653 expiresAt : result . expires ,
580654 lastUsed : now ,
655+ workspaces : mergedWorkspaces ,
656+ currentWorkspaceIndex : nextCurrentWorkspaceIndex ,
581657 } ;
582658 }
583659
@@ -1404,7 +1480,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
14041480 ) ;
14051481 }
14061482
1407- while ( attempted . size < Math . max ( 1 , accountCount ) ) {
1483+ accountAttemptLoop: while ( attempted . size < Math . max ( 1 , accountCount ) ) {
14081484 let account = null ;
14091485 if (
14101486 ! usedPreferredSessionAccount &&
@@ -1497,8 +1573,11 @@ while (attempted.size < Math.max(1, accountCount)) {
14971573 continue ;
14981574 }
14991575
1500- const storedAccountId = account . accountId ;
1501- const storedAccountIdSource = account . accountIdSource ;
1576+ const currentWorkspace = accountManager . getCurrentWorkspace ( account ) ;
1577+ const storedAccountId = currentWorkspace ?. id ?? account . accountId ;
1578+ const storedAccountIdSource = currentWorkspace
1579+ ? "manual"
1580+ : account . accountIdSource ;
15021581 const storedEmail = account . email ;
15031582 const hadAccountId = ! ! storedAccountId ;
15041583 const runtimeIdentity = resolveRuntimeRequestIdentity ( {
@@ -1899,19 +1978,99 @@ while (attempted.size < Math.max(1, accountCount)) {
18991978 blockedModel ,
19001979 ) ;
19011980 }
1902- if ( errorResponse . status === 403 && ! unsupportedModelInfo . isUnsupported ) {
1903- entitlementCache . markBlocked (
1904- entitlementAccountKey ,
1905- model ?? modelFamily ,
1906- "plan-entitlement" ,
1981+ const workspaceErrorCode =
1982+ ( errorBody as { error ?: { code ?: string } } | undefined ) ?. error ?. code ?? "" ;
1983+ const workspaceErrorMessage =
1984+ ( errorBody as { error ?: { message ?: string } } | undefined ) ?. error ?. message ?? "" ;
1985+ const isDisabledWorkspaceError =
1986+ isWorkspaceDisabledError (
1987+ errorResponse . status ,
1988+ workspaceErrorCode ,
1989+ workspaceErrorMessage ,
1990+ ) ;
1991+
1992+ // Handle workspace disabled/expired errors by rotating to the next workspace
1993+ // within the same account before falling back to another account.
1994+ if ( isDisabledWorkspaceError ) {
1995+ runtimeMetrics . failedRequests ++ ;
1996+ runtimeMetrics . lastError = `Workspace disabled for account ${ account . index + 1 } ` ;
1997+
1998+ if ( ! account . workspaces || account . workspaces . length === 0 ) {
1999+ logWarn (
2000+ `Workspace disabled/expired for account ${ account . index + 1 } without tracked workspaces. Leaving account enabled.` ,
2001+ { errorCode : workspaceErrorCode } ,
19072002 ) ;
1908- capabilityPolicyStore . recordFailure (
1909- entitlementAccountKey ,
1910- capabilityModelKey ,
2003+ if ( hasRemainingAccounts ) {
2004+ continue accountAttemptLoop;
2005+ }
2006+ return errorResponse ;
2007+ } else {
2008+ const currentWorkspace = accountManager . getCurrentWorkspace ( account ) ;
2009+ const workspaceName = currentWorkspace ?. name ?? currentWorkspace ?. id ?? "unknown" ;
2010+
2011+ logWarn (
2012+ `Workspace disabled/expired for account ${ account . index + 1 } - workspace: ${ workspaceName } . Rotating to next workspace.` ,
2013+ { errorCode : workspaceErrorCode } ,
2014+ ) ;
2015+
2016+ const disabledWorkspace = currentWorkspace
2017+ ? accountManager . disableCurrentWorkspace ( account , currentWorkspace . id )
2018+ : false ;
2019+ let nextWorkspace = disabledWorkspace
2020+ ? accountManager . rotateToNextWorkspace ( account )
2021+ : accountManager . getCurrentWorkspace ( account ) ;
2022+ if ( ! disabledWorkspace && ( ! nextWorkspace || nextWorkspace . enabled === false ) ) {
2023+ nextWorkspace = accountManager . rotateToNextWorkspace ( account ) ;
2024+ }
2025+
2026+ if ( nextWorkspace ) {
2027+ accountManager . saveToDiskDebounced ( ) ;
2028+
2029+ const newWorkspaceName = nextWorkspace . name ?? nextWorkspace . id ;
2030+ await showToast (
2031+ `Workspace ${ workspaceName } disabled. Switched to ${ newWorkspaceName } .` ,
2032+ "warning" ,
2033+ { duration : toastDurationMs } ,
2034+ ) ;
2035+
2036+ logInfo ( `Rotated to workspace ${ newWorkspaceName } for account ${ account . index + 1 } ` ) ;
2037+
2038+ // Allow the same account to be selected again with fresh request state.
2039+ attempted . delete ( account . index ) ;
2040+ continue accountAttemptLoop;
2041+ }
2042+
2043+ logWarn ( `All workspaces disabled for account ${ account . index + 1 } . Disabling account.` ) ;
2044+
2045+ accountManager . setAccountEnabled ( account . index , false ) ;
2046+ accountManager . saveToDiskDebounced ( ) ;
2047+
2048+ await showToast (
2049+ `All workspaces disabled for account ${ account . index + 1 } . Switching to another account.` ,
2050+ "warning" ,
2051+ { duration : toastDurationMs } ,
19112052 ) ;
2053+
2054+ // Forget session affinity and continue the outer loop so another
2055+ // enabled account can service the request.
2056+ sessionAffinityStore ?. forgetSession ( sessionAffinityKey ) ;
2057+ continue accountAttemptLoop;
19122058 }
2059+ }
2060+
2061+ if ( errorResponse . status === 403 && ! unsupportedModelInfo . isUnsupported && ! isDisabledWorkspaceError ) {
2062+ entitlementCache . markBlocked (
2063+ entitlementAccountKey ,
2064+ model ?? modelFamily ,
2065+ "plan-entitlement" ,
2066+ ) ;
2067+ capabilityPolicyStore . recordFailure (
2068+ entitlementAccountKey ,
2069+ capabilityModelKey ,
2070+ ) ;
2071+ }
19132072
1914- if ( recoveryHook && errorBody && isRecoverableError ( errorBody ) ) {
2073+ if ( recoveryHook && errorBody && isRecoverableError ( errorBody ) ) {
19152074 const errorType = detectErrorType ( errorBody ) ;
19162075 const toastContent = getRecoveryToastContent ( errorType ) ;
19172076 await showToast (
0 commit comments