Skip to content

Commit 247e22f

Browse files
committed
feat: workspace rotation within accounts
1 parent 7146102 commit 247e22f

4 files changed

Lines changed: 168 additions & 16 deletions

File tree

index.ts

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ import {
116116
parseRateLimitReason,
117117
lookupCodexCliTokensByEmail,
118118
isCodexCliSyncEnabled,
119+
type Workspace,
119120
} from "./lib/accounts.js";
120121
import {
121122
getStoragePath,
@@ -376,6 +377,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
376377
accountIdOverride?: string;
377378
accountIdSource?: AccountIdSource;
378379
accountLabel?: string;
380+
workspaces?: Workspace[];
379381
};
380382

381383
const resolveAccountSelection = (
@@ -398,6 +400,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
398400
return tokens;
399401
}
400402

403+
// Convert candidates to workspaces
404+
const workspaces: Workspace[] = candidates.map((c) => ({
405+
id: c.accountId,
406+
name: c.label,
407+
enabled: true,
408+
isDefault: c.isDefault,
409+
}));
410+
401411
if (candidates.length === 1) {
402412
const [candidate] = candidates;
403413
if (candidate) {
@@ -406,6 +416,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
406416
accountIdOverride: candidate.accountId,
407417
accountIdSource: candidate.source,
408418
accountLabel: candidate.label,
419+
workspaces,
409420
};
410421
}
411422
}
@@ -420,6 +431,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
420431
accountIdOverride: choice.accountId,
421432
accountIdSource: choice.source ?? "token",
422433
accountLabel: choice.label,
434+
workspaces,
423435
};
424436
};
425437

@@ -588,6 +600,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
588600
expiresAt: result.expires,
589601
addedAt: now,
590602
lastUsed: now,
603+
workspaces: result.workspaces,
591604
});
592605
indexByRefreshToken.set(result.refresh, newIndex);
593606
if (accountId) {
@@ -609,6 +622,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
609622
const nextAccountIdSource =
610623
accountId ? accountIdSource ?? existing.accountIdSource : existing.accountIdSource;
611624
const nextAccountLabel = accountLabel ?? existing.accountLabel;
625+
// Merge workspaces: preserve enabled status from existing, add new ones
626+
const mergedWorkspaces = result.workspaces?.map((newWs) => {
627+
const existingWs = existing.workspaces?.find((w) => w.id === newWs.id);
628+
return existingWs ? { ...newWs, enabled: existingWs.enabled } : newWs;
629+
}) ?? result.workspaces;
612630
accounts[existingIndex] = {
613631
...existing,
614632
accountId: nextAccountId,
@@ -619,6 +637,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
619637
accessToken: result.access,
620638
expiresAt: result.expires,
621639
lastUsed: now,
640+
workspaces: mergedWorkspaces,
622641
};
623642
if (oldToken !== result.refresh) {
624643
indexByRefreshToken.delete(oldToken);
@@ -1554,8 +1573,13 @@ while (attempted.size < Math.max(1, accountCount)) {
15541573

15551574
const hadAccountId = !!account.accountId;
15561575
const tokenAccountId = extractAccountId(accountAuth.access);
1576+
1577+
// Use workspace ID if workspaces are configured, otherwise use account ID
1578+
const currentWorkspace = accountManager.getCurrentWorkspace(account);
1579+
const workspaceId = currentWorkspace?.id;
1580+
15571581
const accountId = resolveRequestAccountId(
1558-
account.accountId,
1582+
workspaceId ?? account.accountId,
15591583
account.accountIdSource,
15601584
tokenAccountId,
15611585
);
@@ -1955,35 +1979,62 @@ while (attempted.size < Math.max(1, accountCount)) {
19551979
);
19561980
}
19571981

1958-
// Handle workspace disabled/expired errors by marking account as disabled and rotating
1982+
// Handle workspace disabled/expired errors by rotating to next workspace within account
19591983
if (errorResponse.status === 403 && errorBody) {
19601984
const errorCode = (errorBody as { error?: { code?: string } })?.error?.code ?? "";
19611985
const errorMessage = (errorBody as { error?: { message?: string } })?.error?.message ?? "";
19621986

19631987
if (isWorkspaceDisabledError(errorResponse.status, errorCode, errorMessage)) {
19641988
runtimeMetrics.failedRequests++;
1965-
runtimeMetrics.accountRotations++;
19661989
runtimeMetrics.lastError = `Workspace disabled for account ${account.index + 1}`;
19671990

1991+
// Get current workspace info for logging
1992+
const currentWorkspace = accountManager.getCurrentWorkspace(account);
1993+
const workspaceName = currentWorkspace?.name ?? currentWorkspace?.id ?? "unknown";
1994+
19681995
logWarn(
1969-
`Workspace disabled/expired for account ${account.index + 1} (${account.email ?? "unknown"}). Disabling account and rotating.`,
1996+
`Workspace disabled/expired for account ${account.index + 1} (${account.email ?? "unknown"}) - workspace: ${workspaceName}. Rotating to next workspace.`,
19701997
{ errorCode, errorMessage },
19711998
);
19721999

1973-
// Mark the account as disabled
1974-
accountManager.setAccountEnabled(account.index, false);
1975-
accountManager.saveToDiskDebounced();
2000+
// Disable the current workspace
2001+
accountManager.disableCurrentWorkspace(account);
19762002

1977-
// Show a toast notification
1978-
await showToast(
1979-
`Workspace disabled for account ${account.index + 1}. Switching to another account.`,
1980-
"warning",
1981-
{ duration: toastDurationMs },
1982-
);
2003+
// Try to rotate to next enabled workspace
2004+
const nextWorkspace = accountManager.rotateToNextWorkspace(account);
19832005

1984-
// Forget session affinity and rotate to next account
1985-
sessionAffinityStore?.forgetSession(sessionAffinityKey);
1986-
break;
2006+
if (nextWorkspace) {
2007+
// Found another enabled workspace, retry with it
2008+
accountManager.saveToDiskDebounced();
2009+
2010+
const newWorkspaceName = nextWorkspace.name ?? nextWorkspace.id;
2011+
await showToast(
2012+
`Workspace ${workspaceName} disabled. Switched to ${newWorkspaceName}.`,
2013+
"warning",
2014+
{ duration: toastDurationMs },
2015+
);
2016+
2017+
logInfo(`Rotated to workspace ${newWorkspaceName} for account ${account.index + 1}`);
2018+
2019+
// Retry the request with the new workspace (continue the loop)
2020+
continue;
2021+
} else {
2022+
// No more enabled workspaces, disable the entire account
2023+
logWarn(`All workspaces disabled for account ${account.index + 1}. Disabling account.`);
2024+
2025+
accountManager.setAccountEnabled(account.index, false);
2026+
accountManager.saveToDiskDebounced();
2027+
2028+
await showToast(
2029+
`All workspaces disabled for account ${account.index + 1}. Switching to another account.`,
2030+
"warning",
2031+
{ duration: toastDurationMs },
2032+
);
2033+
2034+
// Forget session affinity and rotate to next account
2035+
sessionAffinityStore?.forgetSession(sessionAffinityKey);
2036+
break;
2037+
}
19872038
}
19882039
}
19892040

lib/accounts.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ function initFamilyState(defaultValue: number): Record<ModelFamily, number> {
7979
) as Record<ModelFamily, number>;
8080
}
8181

82+
export interface Workspace {
83+
id: string;
84+
name?: string;
85+
enabled: boolean;
86+
disabledAt?: number;
87+
isDefault?: boolean;
88+
}
89+
8290
export interface ManagedAccount {
8391
index: number;
8492
accountId?: string;
@@ -97,6 +105,8 @@ export interface ManagedAccount {
97105
coolingDownUntil?: number;
98106
cooldownReason?: CooldownReason;
99107
consecutiveAuthFailures?: number;
108+
workspaces?: Workspace[];
109+
currentWorkspaceIndex?: number;
100110
}
101111

102112
export class AccountManager {
@@ -245,6 +255,8 @@ export class AccountManager {
245255
rateLimitResetTimes: account.rateLimitResetTimes ?? {},
246256
coolingDownUntil: account.coolingDownUntil,
247257
cooldownReason: account.cooldownReason,
258+
workspaces: account.workspaces,
259+
currentWorkspaceIndex: account.currentWorkspaceIndex,
248260
};
249261
})
250262
.filter((account): account is ManagedAccount => account !== null);
@@ -764,6 +776,8 @@ export class AccountManager {
764776
Object.keys(account.rateLimitResetTimes).length > 0 ? account.rateLimitResetTimes : undefined,
765777
coolingDownUntil: account.coolingDownUntil,
766778
cooldownReason: account.cooldownReason,
779+
workspaces: account.workspaces,
780+
currentWorkspaceIndex: account.currentWorkspaceIndex,
767781
})),
768782
activeIndex,
769783
activeIndexByFamily,
@@ -805,6 +819,75 @@ export class AccountManager {
805819
await this.pendingSave;
806820
}
807821
}
822+
823+
// Workspace management methods
824+
setWorkspaces(account: ManagedAccount, workspaces: Workspace[]): void {
825+
account.workspaces = workspaces;
826+
if (workspaces.length > 0 && account.currentWorkspaceIndex === undefined) {
827+
// Find first enabled workspace or default to 0
828+
const firstEnabled = workspaces.findIndex((w) => w.enabled !== false);
829+
account.currentWorkspaceIndex = firstEnabled >= 0 ? firstEnabled : 0;
830+
}
831+
}
832+
833+
getCurrentWorkspace(account: ManagedAccount): Workspace | null {
834+
if (!account.workspaces || account.workspaces.length === 0) {
835+
return null;
836+
}
837+
const idx = account.currentWorkspaceIndex ?? 0;
838+
return account.workspaces[idx] ?? null;
839+
}
840+
841+
disableCurrentWorkspace(account: ManagedAccount): boolean {
842+
if (!account.workspaces || account.workspaces.length === 0) {
843+
return false;
844+
}
845+
const idx = account.currentWorkspaceIndex ?? 0;
846+
if (idx < 0 || idx >= account.workspaces.length) {
847+
return false;
848+
}
849+
const workspace = account.workspaces[idx];
850+
if (!workspace) return false;
851+
workspace.enabled = false;
852+
workspace.disabledAt = Date.now();
853+
return true;
854+
}
855+
856+
rotateToNextWorkspace(account: ManagedAccount): Workspace | null {
857+
if (!account.workspaces || account.workspaces.length === 0) {
858+
return null;
859+
}
860+
const currentIdx = account.currentWorkspaceIndex ?? 0;
861+
const totalWorkspaces = account.workspaces.length;
862+
863+
// Find next enabled workspace
864+
for (let i = 1; i <= totalWorkspaces; i++) {
865+
const nextIdx = (currentIdx + i) % totalWorkspaces;
866+
const workspace = account.workspaces[nextIdx];
867+
if (workspace && workspace.enabled !== false) {
868+
account.currentWorkspaceIndex = nextIdx;
869+
return workspace;
870+
}
871+
}
872+
873+
return null; // No enabled workspaces found
874+
}
875+
876+
hasEnabledWorkspaces(account: ManagedAccount): boolean {
877+
if (!account.workspaces || account.workspaces.length === 0) {
878+
return true; // No workspaces tracked yet, assume single workspace
879+
}
880+
return account.workspaces.some((w) => w.enabled !== false);
881+
}
882+
883+
getWorkspaceCount(account: ManagedAccount): number {
884+
return account.workspaces?.length ?? 0;
885+
}
886+
887+
getEnabledWorkspaceCount(account: ManagedAccount): number {
888+
if (!account.workspaces) return 0;
889+
return account.workspaces.filter((w) => w.enabled !== false).length;
890+
}
808891
}
809892

810893
export function formatAccountLabel(

lib/storage/migrations.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ import type { AccountIdSource } from "../types.js";
88

99
export type CooldownReason = "auth-failure" | "network-error" | "rate-limit";
1010

11+
export interface Workspace {
12+
id: string;
13+
name?: string;
14+
enabled: boolean;
15+
disabledAt?: number;
16+
isDefault?: boolean;
17+
}
18+
1119
export interface RateLimitStateV3 {
1220
[key: string]: number | undefined;
1321
}
@@ -54,6 +62,8 @@ export interface AccountMetadataV3 {
5462
rateLimitResetTimes?: RateLimitStateV3;
5563
coolingDownUntil?: number;
5664
cooldownReason?: CooldownReason;
65+
workspaces?: Workspace[];
66+
currentWorkspaceIndex?: number;
5767
}
5868

5969
export interface AccountStorageV3 {

test/index-retry.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,14 @@ vi.mock("../lib/accounts.js", () => {
107107
}
108108

109109
markToastShown() {}
110+
111+
getCurrentWorkspace() { return null; }
112+
113+
disableCurrentWorkspace() { return false; }
114+
115+
rotateToNextWorkspace() { return null; }
116+
117+
hasEnabledWorkspaces() { return true; }
110118
}
111119

112120
return {

0 commit comments

Comments
 (0)