opencode-codex-multi-account 0.2.2 → 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 +179 -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,9 @@ 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]);
1160
+ function isAbortError(error) {
1161
+ return error instanceof Error && error.name === "AbortError";
1162
+ }
1115
1163
  function createExecutorForProvider(providerName, dependencies) {
1116
1164
  const {
1117
1165
  handleRateLimitResponse: handleRateLimitResponse2,
@@ -1122,77 +1170,49 @@ function createExecutorForProvider(providerName, dependencies) {
1122
1170
  } = dependencies;
1123
1171
  async function executeWithAccountRotation2(manager, runtimeFactory, client, input, init) {
1124
1172
  const maxRetries = Math.max(MIN_MAX_RETRIES, manager.getAccountCount() * RETRIES_PER_ACCOUNT);
1125
- let retries = 0;
1126
1173
  let previousAccountUuid;
1127
- while (true) {
1128
- if (++retries > maxRetries) {
1129
- throw new Error(
1130
- `Exhausted ${maxRetries} retries across all accounts. All attempts failed due to auth errors, rate limits, or token issues.`
1131
- );
1132
- }
1133
- await manager.refresh();
1134
- const account = await resolveAccount(manager, client);
1135
- const accountUuid = account.uuid;
1136
- if (!accountUuid) continue;
1137
- if (previousAccountUuid && accountUuid !== previousAccountUuid && manager.getAccountCount() > 1) {
1138
- void showToast2(client, `Switched to ${getAccountLabel2(account)}`, "info");
1139
- }
1140
- previousAccountUuid = accountUuid;
1141
- let runtime;
1142
- let response;
1143
- try {
1144
- runtime = await runtimeFactory.getRuntime(accountUuid);
1145
- response = await runtime.fetch(input, init);
1146
- } catch (error) {
1147
- if (await handleRuntimeFetchFailure(manager, runtimeFactory, client, account, error)) {
1148
- 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;
1149
1189
  }
1150
- void showToast2(client, `${getAccountLabel2(account)} network error \u2014 switching`, "warning");
1151
- continue;
1190
+ if (retryResponse.status < 500) return retryResponse;
1152
1191
  }
1192
+ return null;
1193
+ }
1194
+ const dispatchResponseStatus = async (account, accountUuid, runtime, response, allow401Retry, from401RefreshRetry) => {
1153
1195
  if (response.status >= 500) {
1154
- let serverResponse = response;
1155
- let networkErrorDuringServerRetry = false;
1156
- let authFailureDuringServerRetry = false;
1157
- for (let attempt = 0; attempt < MAX_SERVER_RETRIES_PER_ATTEMPT; attempt++) {
1158
- const backoff = Math.min(SERVER_RETRY_BASE_MS * 2 ** attempt, SERVER_RETRY_MAX_MS);
1159
- const jitteredBackoff = backoff * (0.5 + Math.random() * 0.5);
1160
- 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);
1161
1205
  try {
1162
- 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);
1163
1209
  } catch (error) {
1210
+ if (isAbortError(error)) throw error;
1164
1211
  if (await handleRuntimeFetchFailure(manager, runtimeFactory, client, account, error)) {
1165
- authFailureDuringServerRetry = true;
1166
- break;
1212
+ return { type: "retryOuter" };
1167
1213
  }
1168
- networkErrorDuringServerRetry = true;
1169
- void showToast2(client, `${getAccountLabel2(account)} network error \u2014 switching`, "warning");
1170
- break;
1214
+ return { type: "retryOuter" };
1171
1215
  }
1172
- if (serverResponse.status < 500) break;
1173
- }
1174
- if (authFailureDuringServerRetry) {
1175
- continue;
1176
- }
1177
- if (networkErrorDuringServerRetry || serverResponse.status >= 500) {
1178
- continue;
1179
- }
1180
- response = serverResponse;
1181
- }
1182
- if (response.status === 401) {
1183
- runtimeFactory.invalidate(accountUuid);
1184
- try {
1185
- const retryRuntime = await runtimeFactory.getRuntime(accountUuid);
1186
- const retryResponse = await retryRuntime.fetch(input, init);
1187
- if (retryResponse.status !== 401) {
1188
- await manager.markSuccess(accountUuid);
1189
- return retryResponse;
1190
- }
1191
- } catch (error) {
1192
- if (await handleRuntimeFetchFailure(manager, runtimeFactory, client, account, error)) {
1193
- continue;
1194
- }
1195
- continue;
1196
1216
  }
1197
1217
  await manager.markAuthFailure(accountUuid, { ok: false, permanent: false });
1198
1218
  await manager.refresh();
@@ -1203,7 +1223,7 @@ function createExecutorForProvider(providerName, dependencies) {
1203
1223
  );
1204
1224
  }
1205
1225
  void showToast2(client, `${getAccountLabel2(account)} auth failed \u2014 switching to next account.`, "warning");
1206
- continue;
1226
+ return { type: "retryOuter" };
1207
1227
  }
1208
1228
  if (response.status === 403) {
1209
1229
  const revoked = await isRevokedTokenResponse(response);
@@ -1220,26 +1240,62 @@ function createExecutorForProvider(providerName, dependencies) {
1220
1240
  `All ${providerName} accounts have been revoked or disabled. Re-authenticate with \`opencode auth login\`.`
1221
1241
  );
1222
1242
  }
1223
- continue;
1243
+ return { type: "retryOuter" };
1244
+ }
1245
+ if (from401RefreshRetry) {
1246
+ return { type: "handled", response };
1224
1247
  }
1225
1248
  }
1226
1249
  if (response.status === 429) {
1227
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
+ }
1228
1282
  continue;
1229
1283
  }
1230
1284
  await manager.markSuccess(accountUuid);
1231
- return response;
1285
+ return transition.response;
1232
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
+ );
1233
1290
  }
1234
1291
  async function handleRuntimeFetchFailure(manager, runtimeFactory, client, account, error) {
1235
- const refreshFailureStatus = getRefreshFailureStatus(error);
1236
- if (refreshFailureStatus === void 0) return false;
1292
+ if (!isTokenRefreshError(error)) return false;
1237
1293
  if (!account.uuid) return false;
1238
1294
  const accountUuid = account.uuid;
1239
1295
  runtimeFactory.invalidate(accountUuid);
1240
1296
  await manager.markAuthFailure(accountUuid, {
1241
1297
  ok: false,
1242
- permanent: PERMANENT_AUTH_FAILURE_STATUSES.has(refreshFailureStatus)
1298
+ permanent: error.permanent
1243
1299
  });
1244
1300
  await manager.refresh();
1245
1301
  if (!manager.hasAnyUsableAccount()) {
@@ -1284,13 +1340,6 @@ function createExecutorForProvider(providerName, dependencies) {
1284
1340
  executeWithAccountRotation: executeWithAccountRotation2
1285
1341
  };
1286
1342
  }
1287
- function getRefreshFailureStatus(error) {
1288
- if (!(error instanceof Error)) return void 0;
1289
- const matched = error.message.match(/Token refresh failed:\s*(\d{3})/);
1290
- if (!matched) return void 0;
1291
- const status = Number(matched[1]);
1292
- return Number.isFinite(status) ? status : void 0;
1293
- }
1294
1343
  async function isRevokedTokenResponse(response) {
1295
1344
  try {
1296
1345
  const cloned = response.clone();
@@ -1305,6 +1354,7 @@ async function isRevokedTokenResponse(response) {
1305
1354
  var INITIAL_DELAY_MS = 5e3;
1306
1355
  function createProactiveRefreshQueueForProvider(dependencies) {
1307
1356
  const {
1357
+ providerAuthId,
1308
1358
  getConfig: getConfig2,
1309
1359
  refreshToken: refreshToken2,
1310
1360
  isTokenExpired: isTokenExpired2,
@@ -1323,6 +1373,10 @@ function createProactiveRefreshQueueForProvider(dependencies) {
1323
1373
  const config = getConfig2();
1324
1374
  if (!config.proactive_refresh) return;
1325
1375
  this.runToken++;
1376
+ if (this.timeoutHandle) {
1377
+ clearTimeout(this.timeoutHandle);
1378
+ this.timeoutHandle = null;
1379
+ }
1326
1380
  this.scheduleNext(this.runToken, INITIAL_DELAY_MS);
1327
1381
  debugLog2(this.client, "Proactive refresh started", {
1328
1382
  intervalSeconds: config.proactive_refresh_interval_seconds,
@@ -1397,23 +1451,41 @@ function createProactiveRefreshQueueForProvider(dependencies) {
1397
1451
  }
1398
1452
  async persistFailure(account, permanent) {
1399
1453
  try {
1400
- await this.store.mutateAccount(account.uuid, (target) => {
1401
- 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) {
1402
1472
  target.isAuthDisabled = true;
1403
- target.authDisabledReason = "Token permanently rejected (proactive refresh)";
1404
- } else {
1405
- target.consecutiveAuthFailures = (target.consecutiveAuthFailures ?? 0) + 1;
1406
- const maxFailures = getConfig2().max_consecutive_auth_failures;
1407
- if (target.consecutiveAuthFailures >= maxFailures) {
1408
- target.isAuthDisabled = true;
1409
- target.authDisabledReason = `${maxFailures} consecutive auth failures (proactive refresh)`;
1410
- }
1473
+ target.authDisabledReason = `${maxFailures} consecutive auth failures (proactive refresh)`;
1411
1474
  }
1412
1475
  });
1413
1476
  } catch {
1414
1477
  debugLog2(this.client, `Failed to persist auth failure for ${account.uuid}`);
1415
1478
  }
1416
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
+ }
1417
1489
  };
1418
1490
  }
1419
1491
 
@@ -1763,6 +1835,8 @@ var openAIOAuthAdapter = {
1763
1835
  oauthBetaHeader: "",
1764
1836
  requestBetaHeader: "",
1765
1837
  cliUserAgent: "opencode/1.1.53",
1838
+ cliVersion: "",
1839
+ billingSalt: "",
1766
1840
  toolPrefix: "mcp_",
1767
1841
  accountStorageFilename: "openai-multi-account-accounts.json",
1768
1842
  transform: {
@@ -2353,11 +2427,11 @@ function getUsageSummary(account) {
2353
2427
  const parts = [];
2354
2428
  const { five_hour, seven_day } = parsed.output;
2355
2429
  if (five_hour) {
2356
- const reset = five_hour.utilization >= 100 && five_hour.resets_at ? ` (resets ${formatTimeRemaining(five_hour.resets_at)})` : "";
2430
+ const reset = five_hour.resets_at ? ` (resets ${formatTimeRemaining(five_hour.resets_at)})` : "";
2357
2431
  parts.push(`5h: ${five_hour.utilization.toFixed(0)}%${reset}`);
2358
2432
  }
2359
2433
  if (seven_day) {
2360
- const reset = seven_day.utilization >= 100 && seven_day.resets_at ? ` (resets ${formatTimeRemaining(seven_day.resets_at)})` : "";
2434
+ const reset = seven_day.resets_at ? ` (resets ${formatTimeRemaining(seven_day.resets_at)})` : "";
2361
2435
  parts.push(`7d: ${seven_day.utilization.toFixed(0)}%${reset}`);
2362
2436
  }
2363
2437
  return parts.length > 0 ? parts.join(", ") : "no usage data";
@@ -2987,6 +3061,7 @@ Retrying authentication for ${label}...
2987
3061
 
2988
3062
  // src/proactive-refresh.ts
2989
3063
  var ProactiveRefreshQueue = createProactiveRefreshQueueForProvider({
3064
+ providerAuthId: "openai",
2990
3065
  getConfig,
2991
3066
  isTokenExpired,
2992
3067
  refreshToken,
@@ -3097,10 +3172,7 @@ var AccountRuntimeFactory = class {
3097
3172
  if (!accessToken || !expiresAt || isTokenExpired({ accessToken, expiresAt })) {
3098
3173
  const refreshed = await refreshToken(storedAccount.refreshToken, uuid, this.client);
3099
3174
  if (!refreshed.ok) {
3100
- if (typeof refreshed.status === "number") {
3101
- throw new Error(`Token refresh failed: ${refreshed.status}`);
3102
- }
3103
- throw new Error("Token refresh failed");
3175
+ throw new TokenRefreshError(refreshed.permanent, refreshed.status);
3104
3176
  }
3105
3177
  accessToken = refreshed.patch.accessToken;
3106
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.2",
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.2",
43
+ "opencode-multi-account-core": "^0.2.4",
44
44
  "valibot": "^1.2.0"
45
45
  },
46
46
  "devDependencies": {