opencode-codex-multi-account 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +173 -107
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -84,6 +84,23 @@ var PluginConfigSchema = v.object({
84
84
  quiet_mode: v.optional(v.boolean(), false),
85
85
  debug: v.optional(v.boolean(), false)
86
86
  });
87
+ var TokenRefreshError = class _TokenRefreshError extends Error {
88
+ status;
89
+ permanent;
90
+ constructor(permanent, status) {
91
+ super(status === void 0 ? "Token refresh failed" : `Token refresh failed: ${status}`);
92
+ this.name = "TokenRefreshError";
93
+ this.status = status;
94
+ this.permanent = permanent;
95
+ Object.setPrototypeOf(this, _TokenRefreshError.prototype);
96
+ }
97
+ };
98
+ function isTokenRefreshError(error) {
99
+ if (error instanceof TokenRefreshError) return true;
100
+ if (!(error instanceof Error)) return false;
101
+ const candidate = error;
102
+ return candidate.name === "TokenRefreshError" && typeof candidate.permanent === "boolean" && (candidate.status === void 0 || typeof candidate.status === "number");
103
+ }
87
104
 
88
105
  // ../multi-account-core/src/config.ts
89
106
  var DEFAULT_CONFIG_FILENAME = "multiauth-config.json";
@@ -224,6 +241,14 @@ function createMinimalClient() {
224
241
  }
225
242
  };
226
243
  }
244
+ function getClearedOAuthBody() {
245
+ return {
246
+ type: "oauth",
247
+ refresh: "",
248
+ access: "",
249
+ expires: 0
250
+ };
251
+ }
227
252
 
228
253
  // ../multi-account-core/src/claims.ts
229
254
  var CLAIMS_FILENAME = "multiauth-claims.json";
@@ -647,13 +672,7 @@ function createAccountManagerForProvider(dependencies) {
647
672
  });
648
673
  }
649
674
  async markRevoked(uuid) {
650
- await this.store.mutateAccount(uuid, (account) => {
651
- account.isAuthDisabled = true;
652
- account.authDisabledReason = "OAuth token revoked (403)";
653
- account.accessToken = void 0;
654
- account.expiresAt = void 0;
655
- });
656
- this.runtimeFactory?.invalidate(uuid);
675
+ await this.removeAccountByUuid(uuid);
657
676
  }
658
677
  async markSuccess(uuid) {
659
678
  this.last429Map.delete(uuid);
@@ -676,15 +695,32 @@ function createAccountManagerForProvider(dependencies) {
676
695
  }).catch(() => {
677
696
  });
678
697
  }
698
+ async clearOpenCodeAuthIfNoAccountsRemain() {
699
+ if (!this.client) return;
700
+ const storage = await this.store.load();
701
+ if (storage.accounts.length > 0) return;
702
+ await this.client.auth.set({
703
+ path: { id: providerAuthId },
704
+ body: getClearedOAuthBody()
705
+ }).catch(() => {
706
+ });
707
+ }
708
+ async removeAccountByUuid(uuid) {
709
+ const removed = await this.store.removeAccount(uuid);
710
+ if (!removed) return;
711
+ this.last429Map.delete(uuid);
712
+ this.runtimeFactory?.invalidate(uuid);
713
+ await this.refresh();
714
+ await this.clearOpenCodeAuthIfNoAccountsRemain();
715
+ }
679
716
  async markAuthFailure(uuid, result) {
717
+ if (!result.ok && result.permanent) {
718
+ await this.removeAccountByUuid(uuid);
719
+ return;
720
+ }
680
721
  await this.store.mutateStorage((storage) => {
681
722
  const account = storage.accounts.find((entry) => entry.uuid === uuid);
682
723
  if (!account) return;
683
- if (!result.ok && result.permanent) {
684
- account.isAuthDisabled = true;
685
- account.authDisabledReason = "Token permanently rejected (400/401/403)";
686
- return;
687
- }
688
724
  account.consecutiveAuthFailures = (account.consecutiveAuthFailures ?? 0) + 1;
689
725
  const maxFailures = getConfig().max_consecutive_auth_failures;
690
726
  const usableCount = storage.accounts.filter(
@@ -781,11 +817,21 @@ function createAccountManagerForProvider(dependencies) {
781
817
  this.cached = [];
782
818
  this.activeAccountUuid = void 0;
783
819
  }
784
- async addAccount(auth) {
820
+ async addAccount(auth, email) {
785
821
  if (!auth.refresh) return;
786
- const existing = this.cached.find((account) => account.refreshToken === auth.refresh);
787
- if (existing) return;
822
+ const existingByToken = this.cached.find((account) => account.refreshToken === auth.refresh);
823
+ if (existingByToken) return;
824
+ if (email) {
825
+ const existingByEmail = this.cached.find(
826
+ (account) => account.email && account.email === email
827
+ );
828
+ if (existingByEmail?.uuid) {
829
+ await this.replaceAccountCredentials(existingByEmail.uuid, auth);
830
+ return;
831
+ }
832
+ }
788
833
  const newAccount = this.createNewAccount(auth, Date.now());
834
+ if (email) newAccount.email = email;
789
835
  await this.store.addAccount(newAccount);
790
836
  this.activeAccountUuid = newAccount.uuid;
791
837
  await this.store.setActiveUuid(newAccount.uuid);
@@ -1111,7 +1157,6 @@ var MAX_SERVER_RETRIES_PER_ATTEMPT = 2;
1111
1157
  var MAX_RESOLVE_ATTEMPTS = 10;
1112
1158
  var SERVER_RETRY_BASE_MS = 1e3;
1113
1159
  var SERVER_RETRY_MAX_MS = 4e3;
1114
- var PERMANENT_AUTH_FAILURE_STATUSES = /* @__PURE__ */ new Set([400, 401, 403]);
1115
1160
  function isAbortError(error) {
1116
1161
  return error instanceof Error && error.name === "AbortError";
1117
1162
  }
@@ -1125,80 +1170,49 @@ function createExecutorForProvider(providerName, dependencies) {
1125
1170
  } = dependencies;
1126
1171
  async function executeWithAccountRotation2(manager, runtimeFactory, client, input, init) {
1127
1172
  const maxRetries = Math.max(MIN_MAX_RETRIES, manager.getAccountCount() * RETRIES_PER_ACCOUNT);
1128
- let retries = 0;
1129
1173
  let previousAccountUuid;
1130
- while (true) {
1131
- if (++retries > maxRetries) {
1132
- throw new Error(
1133
- `Exhausted ${maxRetries} retries across all accounts. All attempts failed due to auth errors, rate limits, or token issues.`
1134
- );
1135
- }
1136
- await manager.refresh();
1137
- const account = await resolveAccount(manager, client);
1138
- const accountUuid = account.uuid;
1139
- if (!accountUuid) continue;
1140
- if (previousAccountUuid && accountUuid !== previousAccountUuid && manager.getAccountCount() > 1) {
1141
- void showToast2(client, `Switched to ${getAccountLabel2(account)}`, "info");
1142
- }
1143
- previousAccountUuid = accountUuid;
1144
- let runtime;
1145
- let response;
1146
- try {
1147
- runtime = await runtimeFactory.getRuntime(accountUuid);
1148
- response = await runtime.fetch(input, init);
1149
- } catch (error) {
1150
- if (isAbortError(error)) throw error;
1151
- if (await handleRuntimeFetchFailure(manager, runtimeFactory, client, account, error)) {
1152
- continue;
1174
+ async function retryServerErrors(account, runtime) {
1175
+ for (let attempt = 0; attempt < MAX_SERVER_RETRIES_PER_ATTEMPT; attempt++) {
1176
+ const backoff = Math.min(SERVER_RETRY_BASE_MS * 2 ** attempt, SERVER_RETRY_MAX_MS);
1177
+ const jitteredBackoff = backoff * (0.5 + Math.random() * 0.5);
1178
+ await sleep2(jitteredBackoff);
1179
+ let retryResponse;
1180
+ try {
1181
+ retryResponse = await runtime.fetch(input, init);
1182
+ } catch (error) {
1183
+ if (isAbortError(error)) throw error;
1184
+ if (await handleRuntimeFetchFailure(manager, runtimeFactory, client, account, error)) {
1185
+ return null;
1186
+ }
1187
+ void showToast2(client, `${getAccountLabel2(account)} network error \u2014 switching`, "warning");
1188
+ return null;
1153
1189
  }
1154
- void showToast2(client, `${getAccountLabel2(account)} network error \u2014 switching`, "warning");
1155
- continue;
1190
+ if (retryResponse.status < 500) return retryResponse;
1156
1191
  }
1192
+ return null;
1193
+ }
1194
+ const dispatchResponseStatus = async (account, accountUuid, runtime, response, allow401Retry, from401RefreshRetry) => {
1157
1195
  if (response.status >= 500) {
1158
- let serverResponse = response;
1159
- let networkErrorDuringServerRetry = false;
1160
- let authFailureDuringServerRetry = false;
1161
- for (let attempt = 0; attempt < MAX_SERVER_RETRIES_PER_ATTEMPT; attempt++) {
1162
- const backoff = Math.min(SERVER_RETRY_BASE_MS * 2 ** attempt, SERVER_RETRY_MAX_MS);
1163
- const jitteredBackoff = backoff * (0.5 + Math.random() * 0.5);
1164
- await sleep2(jitteredBackoff);
1196
+ const recovered = await retryServerErrors(account, runtime);
1197
+ if (recovered === null) {
1198
+ return { type: "retryOuter" };
1199
+ }
1200
+ response = recovered;
1201
+ }
1202
+ if (response.status === 401) {
1203
+ if (allow401Retry) {
1204
+ runtimeFactory.invalidate(accountUuid);
1165
1205
  try {
1166
- serverResponse = await runtime.fetch(input, init);
1206
+ const retryRuntime = await runtimeFactory.getRuntime(accountUuid);
1207
+ const retryResponse = await retryRuntime.fetch(input, init);
1208
+ return dispatchResponseStatus(account, accountUuid, retryRuntime, retryResponse, false, true);
1167
1209
  } catch (error) {
1168
1210
  if (isAbortError(error)) throw error;
1169
1211
  if (await handleRuntimeFetchFailure(manager, runtimeFactory, client, account, error)) {
1170
- authFailureDuringServerRetry = true;
1171
- break;
1212
+ return { type: "retryOuter" };
1172
1213
  }
1173
- networkErrorDuringServerRetry = true;
1174
- void showToast2(client, `${getAccountLabel2(account)} network error \u2014 switching`, "warning");
1175
- break;
1214
+ return { type: "retryOuter" };
1176
1215
  }
1177
- if (serverResponse.status < 500) break;
1178
- }
1179
- if (authFailureDuringServerRetry) {
1180
- continue;
1181
- }
1182
- if (networkErrorDuringServerRetry || serverResponse.status >= 500) {
1183
- continue;
1184
- }
1185
- response = serverResponse;
1186
- }
1187
- if (response.status === 401) {
1188
- runtimeFactory.invalidate(accountUuid);
1189
- try {
1190
- const retryRuntime = await runtimeFactory.getRuntime(accountUuid);
1191
- const retryResponse = await retryRuntime.fetch(input, init);
1192
- if (retryResponse.status !== 401) {
1193
- await manager.markSuccess(accountUuid);
1194
- return retryResponse;
1195
- }
1196
- } catch (error) {
1197
- if (isAbortError(error)) throw error;
1198
- if (await handleRuntimeFetchFailure(manager, runtimeFactory, client, account, error)) {
1199
- continue;
1200
- }
1201
- continue;
1202
1216
  }
1203
1217
  await manager.markAuthFailure(accountUuid, { ok: false, permanent: false });
1204
1218
  await manager.refresh();
@@ -1209,7 +1223,7 @@ function createExecutorForProvider(providerName, dependencies) {
1209
1223
  );
1210
1224
  }
1211
1225
  void showToast2(client, `${getAccountLabel2(account)} auth failed \u2014 switching to next account.`, "warning");
1212
- continue;
1226
+ return { type: "retryOuter" };
1213
1227
  }
1214
1228
  if (response.status === 403) {
1215
1229
  const revoked = await isRevokedTokenResponse(response);
@@ -1226,26 +1240,62 @@ function createExecutorForProvider(providerName, dependencies) {
1226
1240
  `All ${providerName} accounts have been revoked or disabled. Re-authenticate with \`opencode auth login\`.`
1227
1241
  );
1228
1242
  }
1229
- continue;
1243
+ return { type: "retryOuter" };
1244
+ }
1245
+ if (from401RefreshRetry) {
1246
+ return { type: "handled", response };
1230
1247
  }
1231
1248
  }
1232
1249
  if (response.status === 429) {
1233
1250
  await handleRateLimitResponse2(manager, client, account, response);
1251
+ return { type: "handled" };
1252
+ }
1253
+ return { type: "success", response };
1254
+ };
1255
+ for (let retries = 1; retries <= maxRetries; retries++) {
1256
+ await manager.refresh();
1257
+ const account = await resolveAccount(manager, client);
1258
+ const accountUuid = account.uuid;
1259
+ if (!accountUuid) continue;
1260
+ if (previousAccountUuid && accountUuid !== previousAccountUuid && manager.getAccountCount() > 1) {
1261
+ void showToast2(client, `Switched to ${getAccountLabel2(account)}`, "info");
1262
+ }
1263
+ previousAccountUuid = accountUuid;
1264
+ let runtime;
1265
+ let response;
1266
+ try {
1267
+ runtime = await runtimeFactory.getRuntime(accountUuid);
1268
+ response = await runtime.fetch(input, init);
1269
+ } catch (error) {
1270
+ if (isAbortError(error)) throw error;
1271
+ if (await handleRuntimeFetchFailure(manager, runtimeFactory, client, account, error)) {
1272
+ continue;
1273
+ }
1274
+ void showToast2(client, `${getAccountLabel2(account)} network error \u2014 switching`, "warning");
1275
+ continue;
1276
+ }
1277
+ const transition = await dispatchResponseStatus(account, accountUuid, runtime, response, true, false);
1278
+ if (transition.type === "retryOuter" || transition.type === "handled") {
1279
+ if (transition.type === "handled" && transition.response) {
1280
+ return transition.response;
1281
+ }
1234
1282
  continue;
1235
1283
  }
1236
1284
  await manager.markSuccess(accountUuid);
1237
- return response;
1285
+ return transition.response;
1238
1286
  }
1287
+ throw new Error(
1288
+ `Exhausted ${maxRetries} retries across all accounts. All attempts failed due to auth errors, rate limits, or token issues.`
1289
+ );
1239
1290
  }
1240
1291
  async function handleRuntimeFetchFailure(manager, runtimeFactory, client, account, error) {
1241
- const refreshFailureStatus = getRefreshFailureStatus(error);
1242
- if (refreshFailureStatus === void 0) return false;
1292
+ if (!isTokenRefreshError(error)) return false;
1243
1293
  if (!account.uuid) return false;
1244
1294
  const accountUuid = account.uuid;
1245
1295
  runtimeFactory.invalidate(accountUuid);
1246
1296
  await manager.markAuthFailure(accountUuid, {
1247
1297
  ok: false,
1248
- permanent: PERMANENT_AUTH_FAILURE_STATUSES.has(refreshFailureStatus)
1298
+ permanent: error.permanent
1249
1299
  });
1250
1300
  await manager.refresh();
1251
1301
  if (!manager.hasAnyUsableAccount()) {
@@ -1290,13 +1340,6 @@ function createExecutorForProvider(providerName, dependencies) {
1290
1340
  executeWithAccountRotation: executeWithAccountRotation2
1291
1341
  };
1292
1342
  }
1293
- function getRefreshFailureStatus(error) {
1294
- if (!(error instanceof Error)) return void 0;
1295
- const matched = error.message.match(/Token refresh failed:\s*(\d{3})/);
1296
- if (!matched) return void 0;
1297
- const status = Number(matched[1]);
1298
- return Number.isFinite(status) ? status : void 0;
1299
- }
1300
1343
  async function isRevokedTokenResponse(response) {
1301
1344
  try {
1302
1345
  const cloned = response.clone();
@@ -1311,6 +1354,7 @@ async function isRevokedTokenResponse(response) {
1311
1354
  var INITIAL_DELAY_MS = 5e3;
1312
1355
  function createProactiveRefreshQueueForProvider(dependencies) {
1313
1356
  const {
1357
+ providerAuthId,
1314
1358
  getConfig: getConfig2,
1315
1359
  refreshToken: refreshToken2,
1316
1360
  isTokenExpired: isTokenExpired2,
@@ -1329,6 +1373,10 @@ function createProactiveRefreshQueueForProvider(dependencies) {
1329
1373
  const config = getConfig2();
1330
1374
  if (!config.proactive_refresh) return;
1331
1375
  this.runToken++;
1376
+ if (this.timeoutHandle) {
1377
+ clearTimeout(this.timeoutHandle);
1378
+ this.timeoutHandle = null;
1379
+ }
1332
1380
  this.scheduleNext(this.runToken, INITIAL_DELAY_MS);
1333
1381
  debugLog2(this.client, "Proactive refresh started", {
1334
1382
  intervalSeconds: config.proactive_refresh_interval_seconds,
@@ -1403,23 +1451,41 @@ function createProactiveRefreshQueueForProvider(dependencies) {
1403
1451
  }
1404
1452
  async persistFailure(account, permanent) {
1405
1453
  try {
1406
- await this.store.mutateAccount(account.uuid, (target) => {
1407
- if (permanent) {
1454
+ const accountUuid = account.uuid;
1455
+ if (!accountUuid) return;
1456
+ if (permanent) {
1457
+ const removed = await this.store.removeAccount(accountUuid);
1458
+ if (!removed) return;
1459
+ this.onInvalidate?.(accountUuid);
1460
+ await this.clearOpenCodeAuthIfNoAccountsRemain();
1461
+ return;
1462
+ }
1463
+ await this.store.mutateStorage((storage) => {
1464
+ const target = storage.accounts.find((entry) => entry.uuid === accountUuid);
1465
+ if (!target) return;
1466
+ target.consecutiveAuthFailures = (target.consecutiveAuthFailures ?? 0) + 1;
1467
+ const maxFailures = getConfig2().max_consecutive_auth_failures;
1468
+ const usableCount = storage.accounts.filter(
1469
+ (entry) => entry.enabled && !entry.isAuthDisabled && entry.uuid !== accountUuid
1470
+ ).length;
1471
+ if (target.consecutiveAuthFailures >= maxFailures && usableCount > 0) {
1408
1472
  target.isAuthDisabled = true;
1409
- target.authDisabledReason = "Token permanently rejected (proactive refresh)";
1410
- } else {
1411
- target.consecutiveAuthFailures = (target.consecutiveAuthFailures ?? 0) + 1;
1412
- const maxFailures = getConfig2().max_consecutive_auth_failures;
1413
- if (target.consecutiveAuthFailures >= maxFailures) {
1414
- target.isAuthDisabled = true;
1415
- target.authDisabledReason = `${maxFailures} consecutive auth failures (proactive refresh)`;
1416
- }
1473
+ target.authDisabledReason = `${maxFailures} consecutive auth failures (proactive refresh)`;
1417
1474
  }
1418
1475
  });
1419
1476
  } catch {
1420
1477
  debugLog2(this.client, `Failed to persist auth failure for ${account.uuid}`);
1421
1478
  }
1422
1479
  }
1480
+ async clearOpenCodeAuthIfNoAccountsRemain() {
1481
+ const storage = await this.store.load();
1482
+ if (storage.accounts.length > 0) return;
1483
+ await this.client.auth.set({
1484
+ path: { id: providerAuthId },
1485
+ body: getClearedOAuthBody()
1486
+ }).catch(() => {
1487
+ });
1488
+ }
1423
1489
  };
1424
1490
  }
1425
1491
 
@@ -1769,6 +1835,8 @@ var openAIOAuthAdapter = {
1769
1835
  oauthBetaHeader: "",
1770
1836
  requestBetaHeader: "",
1771
1837
  cliUserAgent: "opencode/1.1.53",
1838
+ cliVersion: "",
1839
+ billingSalt: "",
1772
1840
  toolPrefix: "mcp_",
1773
1841
  accountStorageFilename: "openai-multi-account-accounts.json",
1774
1842
  transform: {
@@ -2993,6 +3061,7 @@ Retrying authentication for ${label}...
2993
3061
 
2994
3062
  // src/proactive-refresh.ts
2995
3063
  var ProactiveRefreshQueue = createProactiveRefreshQueueForProvider({
3064
+ providerAuthId: "openai",
2996
3065
  getConfig,
2997
3066
  isTokenExpired,
2998
3067
  refreshToken,
@@ -3103,10 +3172,7 @@ var AccountRuntimeFactory = class {
3103
3172
  if (!accessToken || !expiresAt || isTokenExpired({ accessToken, expiresAt })) {
3104
3173
  const refreshed = await refreshToken(storedAccount.refreshToken, uuid, this.client);
3105
3174
  if (!refreshed.ok) {
3106
- if (typeof refreshed.status === "number") {
3107
- throw new Error(`Token refresh failed: ${refreshed.status}`);
3108
- }
3109
- throw new Error("Token refresh failed");
3175
+ throw new TokenRefreshError(refreshed.permanent, refreshed.status);
3110
3176
  }
3111
3177
  accessToken = refreshed.patch.accessToken;
3112
3178
  expiresAt = refreshed.patch.expiresAt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codex-multi-account",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "OpenCode plugin for Codex (OpenAI) multi-account management with automatic rate limit switching",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "directory": "packages/codex-multi-account"
41
41
  },
42
42
  "dependencies": {
43
- "opencode-multi-account-core": "^0.2.3",
43
+ "opencode-multi-account-core": "^0.2.4",
44
44
  "valibot": "^1.2.0"
45
45
  },
46
46
  "devDependencies": {