opencode-openai-codex-auth-multi 4.3.0-multiaccount.1 → 4.5.0

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 (87) hide show
  1. package/LICENSE +37 -37
  2. package/README.md +463 -80
  3. package/assets/opencode-logo-ornate-dark.svg +18 -18
  4. package/assets/readme-hero.svg +31 -31
  5. package/config/README.md +98 -98
  6. package/config/minimal-opencode.json +11 -11
  7. package/config/opencode-legacy.json +568 -568
  8. package/config/opencode-modern.json +236 -236
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +391 -135
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/accounts.d.ts +85 -11
  14. package/dist/lib/accounts.d.ts.map +1 -1
  15. package/dist/lib/accounts.js +352 -62
  16. package/dist/lib/accounts.js.map +1 -1
  17. package/dist/lib/auth/auth.d.ts +9 -1
  18. package/dist/lib/auth/auth.d.ts.map +1 -1
  19. package/dist/lib/auth/auth.js +26 -13
  20. package/dist/lib/auth/auth.js.map +1 -1
  21. package/dist/lib/auth/browser.d.ts.map +1 -1
  22. package/dist/lib/auth/browser.js +9 -2
  23. package/dist/lib/auth/browser.js.map +1 -1
  24. package/dist/lib/auth/server.d.ts.map +1 -1
  25. package/dist/lib/auth/server.js +11 -4
  26. package/dist/lib/auth/server.js.map +1 -1
  27. package/dist/lib/auto-update-checker.d.ts +10 -0
  28. package/dist/lib/auto-update-checker.d.ts.map +1 -0
  29. package/dist/lib/auto-update-checker.js +129 -0
  30. package/dist/lib/auto-update-checker.js.map +1 -0
  31. package/dist/lib/cli.d.ts +1 -0
  32. package/dist/lib/cli.d.ts.map +1 -1
  33. package/dist/lib/cli.js +11 -6
  34. package/dist/lib/cli.js.map +1 -1
  35. package/dist/lib/config.d.ts +5 -7
  36. package/dist/lib/config.d.ts.map +1 -1
  37. package/dist/lib/config.js +49 -6
  38. package/dist/lib/config.js.map +1 -1
  39. package/dist/lib/constants.d.ts +7 -0
  40. package/dist/lib/constants.d.ts.map +1 -1
  41. package/dist/lib/constants.js +7 -0
  42. package/dist/lib/constants.js.map +1 -1
  43. package/dist/lib/index.d.ts +13 -0
  44. package/dist/lib/index.d.ts.map +1 -0
  45. package/dist/lib/index.js +13 -0
  46. package/dist/lib/index.js.map +1 -0
  47. package/dist/lib/logger.d.ts +13 -17
  48. package/dist/lib/logger.d.ts.map +1 -1
  49. package/dist/lib/logger.js +89 -24
  50. package/dist/lib/logger.js.map +1 -1
  51. package/dist/lib/oauth-success.html +712 -712
  52. package/dist/lib/prompts/codex-opencode-bridge.js +121 -121
  53. package/dist/lib/prompts/codex.d.ts +5 -0
  54. package/dist/lib/prompts/codex.d.ts.map +1 -1
  55. package/dist/lib/prompts/codex.js +114 -93
  56. package/dist/lib/prompts/codex.js.map +1 -1
  57. package/dist/lib/refresh-queue.d.ts +100 -0
  58. package/dist/lib/refresh-queue.d.ts.map +1 -0
  59. package/dist/lib/refresh-queue.js +196 -0
  60. package/dist/lib/refresh-queue.js.map +1 -0
  61. package/dist/lib/request/fetch-helpers.d.ts +2 -3
  62. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  63. package/dist/lib/request/fetch-helpers.js +26 -29
  64. package/dist/lib/request/fetch-helpers.js.map +1 -1
  65. package/dist/lib/request/rate-limit-backoff.d.ts +17 -0
  66. package/dist/lib/request/rate-limit-backoff.d.ts.map +1 -0
  67. package/dist/lib/request/rate-limit-backoff.js +74 -0
  68. package/dist/lib/request/rate-limit-backoff.js.map +1 -0
  69. package/dist/lib/request/request-transformer.d.ts.map +1 -1
  70. package/dist/lib/request/request-transformer.js +3 -2
  71. package/dist/lib/request/request-transformer.js.map +1 -1
  72. package/dist/lib/request/response-handler.js +1 -1
  73. package/dist/lib/request/response-handler.js.map +1 -1
  74. package/dist/lib/rotation.d.ts +121 -0
  75. package/dist/lib/rotation.d.ts.map +1 -0
  76. package/dist/lib/rotation.js +248 -0
  77. package/dist/lib/rotation.js.map +1 -0
  78. package/dist/lib/storage.d.ts +72 -4
  79. package/dist/lib/storage.d.ts.map +1 -1
  80. package/dist/lib/storage.js +189 -19
  81. package/dist/lib/storage.js.map +1 -1
  82. package/dist/lib/types.d.ts +37 -1
  83. package/dist/lib/types.d.ts.map +1 -1
  84. package/package.json +85 -71
  85. package/scripts/install-opencode-codex-auth.js +191 -191
  86. package/scripts/test-all-models.sh +258 -258
  87. package/scripts/validate-model-map.sh +97 -97
package/dist/index.js CHANGED
@@ -19,22 +19,24 @@
19
19
  *
20
20
  * @license MIT with Usage Disclaimer (see LICENSE file)
21
21
  * @author numman-ali
22
- * @repository https://github.com/ndycode/opencode-openai-codex-auth-multiaccount
22
+ * @repository https://github.com/ndycode/opencode-openai-codex-auth-multi
23
23
 
24
24
  */
25
25
  import { tool } from "@opencode-ai/plugin";
26
26
  import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationInput, REDIRECT_URI, } from "./lib/auth/auth.js";
27
+ import { queuedRefresh } from "./lib/refresh-queue.js";
27
28
  import { openBrowserUrl } from "./lib/auth/browser.js";
28
29
  import { startLocalOAuthServer } from "./lib/auth/server.js";
29
30
  import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
30
- import { getCodexMode, loadPluginConfig } from "./lib/config.js";
31
- import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, } from "./lib/constants.js";
31
+ import { getCodexMode, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getTokenRefreshSkewMs, loadPluginConfig, } from "./lib/config.js";
32
+ import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, ACCOUNT_LIMITS, } from "./lib/constants.js";
32
33
  import { logRequest, logDebug } from "./lib/logger.js";
33
- import { AccountManager, extractAccountId, formatAccountLabel, formatCooldown, formatWaitTime, } from "./lib/accounts.js";
34
+ import { checkAndNotify } from "./lib/auto-update-checker.js";
35
+ import { AccountManager, extractAccountEmail, extractAccountId, formatAccountLabel, formatCooldown, formatWaitTime, sanitizeEmail, } from "./lib/accounts.js";
34
36
  import { getStoragePath, loadAccounts, saveAccounts } from "./lib/storage.js";
35
37
  import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
36
- const MAX_OAUTH_ACCOUNTS = 10;
37
- const AUTH_FAILURE_COOLDOWN_MS = 30_000;
38
+ import { getRateLimitBackoff, RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS, resetRateLimitBackoff, } from "./lib/request/rate-limit-backoff.js";
39
+ import { getModelFamily, MODEL_FAMILIES } from "./lib/prompts/codex.js";
38
40
  /**
39
41
  * OpenAI Codex OAuth authentication plugin for opencode
40
42
  *
@@ -45,7 +47,7 @@ const AUTH_FAILURE_COOLDOWN_MS = 30_000;
45
47
  * @example
46
48
  * ```json
47
49
  * {
48
- * "plugin": ["opencode-openai-codex-auth-multiaccount"],
50
+ * "plugin": ["opencode-openai-codex-auth-multi"],
49
51
 
50
52
  * "model": "openai/gpt-5-codex"
51
53
  * }
@@ -60,7 +62,7 @@ export const OpenAIAuthPlugin = async ({ client }) => {
60
62
  callback: async (input) => {
61
63
  const parsed = parseAuthorizationInput(input);
62
64
  if (!parsed.code) {
63
- return { type: "failed" };
65
+ return { type: "failed", reason: "invalid_response", message: "No authorization code provided" };
64
66
  }
65
67
  const tokens = await exchangeAuthorizationCode(parsed.code, pkce.verifier, REDIRECT_URI);
66
68
  if (tokens?.type === "success" && onSuccess) {
@@ -82,7 +84,7 @@ export const OpenAIAuthPlugin = async ({ client }) => {
82
84
  rl.close();
83
85
  }
84
86
  };
85
- const runManualOAuthFlow = async (pkce, url) => {
87
+ const runManualOAuthFlow = async (pkce, _url) => {
86
88
  console.log("1. Open the URL above in your browser and sign in.");
87
89
  console.log("2. After approving, copy the full redirect URL.");
88
90
  console.log("3. Paste it back here.\n");
@@ -93,8 +95,8 @@ export const OpenAIAuthPlugin = async ({ client }) => {
93
95
  }
94
96
  return await exchangeAuthorizationCode(parsed.code, pkce.verifier, REDIRECT_URI);
95
97
  };
96
- const runOAuthFlow = async (useManualMode) => {
97
- const { pkce, state, url } = await createAuthorizationFlow();
98
+ const runOAuthFlow = async (useManualMode, forceNewLogin = false) => {
99
+ const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin });
98
100
  console.log("\nOAuth URL:\n" + url + "\n");
99
101
  if (useManualMode) {
100
102
  openBrowserUrl(url);
@@ -104,7 +106,8 @@ export const OpenAIAuthPlugin = async ({ client }) => {
104
106
  try {
105
107
  serverInfo = await startLocalOAuthServer({ state });
106
108
  }
107
- catch {
109
+ catch (err) {
110
+ logDebug(`[${PLUGIN_NAME}] Failed to start OAuth server: ${err?.message ?? String(err)}`);
108
111
  serverInfo = null;
109
112
  }
110
113
  openBrowserUrl(url);
@@ -115,7 +118,7 @@ export const OpenAIAuthPlugin = async ({ client }) => {
115
118
  const result = await serverInfo.waitForCode(state);
116
119
  serverInfo.close();
117
120
  if (!result) {
118
- return { type: "failed" };
121
+ return { type: "failed", reason: "unknown", message: "OAuth callback timeout or cancelled" };
119
122
  }
120
123
  return await exchangeAuthorizationCode(result.code, pkce.verifier, REDIRECT_URI);
121
124
  };
@@ -127,6 +130,7 @@ export const OpenAIAuthPlugin = async ({ client }) => {
127
130
  const accounts = stored?.accounts ? [...stored.accounts] : [];
128
131
  const indexByRefreshToken = new Map();
129
132
  const indexByAccountId = new Map();
133
+ const indexByEmail = new Map();
130
134
  for (let i = 0; i < accounts.length; i += 1) {
131
135
  const account = accounts[i];
132
136
  if (!account)
@@ -137,18 +141,26 @@ export const OpenAIAuthPlugin = async ({ client }) => {
137
141
  if (account.accountId) {
138
142
  indexByAccountId.set(account.accountId, i);
139
143
  }
144
+ if (account.email) {
145
+ indexByEmail.set(account.email, i);
146
+ }
140
147
  }
141
148
  for (const result of results) {
142
149
  const accountId = extractAccountId(result.access);
150
+ const accountEmail = sanitizeEmail(extractAccountEmail(result.access));
151
+ const existingByEmail = accountEmail && indexByEmail.has(accountEmail)
152
+ ? indexByEmail.get(accountEmail)
153
+ : undefined;
143
154
  const existingById = accountId && indexByAccountId.has(accountId)
144
155
  ? indexByAccountId.get(accountId)
145
156
  : undefined;
146
157
  const existingByToken = indexByRefreshToken.get(result.refresh);
147
- const existingIndex = existingById ?? existingByToken;
158
+ const existingIndex = existingById ?? existingByEmail ?? existingByToken;
148
159
  if (existingIndex === undefined) {
149
160
  const newIndex = accounts.length;
150
161
  accounts.push({
151
162
  accountId,
163
+ email: accountEmail,
152
164
  refreshToken: result.refresh,
153
165
  addedAt: now,
154
166
  lastUsed: now,
@@ -157,15 +169,21 @@ export const OpenAIAuthPlugin = async ({ client }) => {
157
169
  if (accountId) {
158
170
  indexByAccountId.set(accountId, newIndex);
159
171
  }
172
+ if (accountEmail) {
173
+ indexByEmail.set(accountEmail, newIndex);
174
+ }
160
175
  continue;
161
176
  }
162
177
  const existing = accounts[existingIndex];
163
178
  if (!existing)
164
179
  continue;
165
180
  const oldToken = existing.refreshToken;
181
+ const oldEmail = existing.email;
182
+ const nextEmail = accountEmail ?? existing.email;
166
183
  accounts[existingIndex] = {
167
184
  ...existing,
168
185
  accountId: accountId ?? existing.accountId,
186
+ email: nextEmail,
169
187
  refreshToken: result.refresh,
170
188
  lastUsed: now,
171
189
  };
@@ -176,6 +194,12 @@ export const OpenAIAuthPlugin = async ({ client }) => {
176
194
  if (accountId) {
177
195
  indexByAccountId.set(accountId, existingIndex);
178
196
  }
197
+ if (oldEmail && oldEmail !== nextEmail) {
198
+ indexByEmail.delete(oldEmail);
199
+ }
200
+ if (nextEmail) {
201
+ indexByEmail.set(nextEmail, existingIndex);
202
+ }
179
203
  }
180
204
  if (accounts.length === 0)
181
205
  return;
@@ -184,10 +208,22 @@ export const OpenAIAuthPlugin = async ({ client }) => {
184
208
  : typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex)
185
209
  ? stored.activeIndex
186
210
  : 0;
211
+ const clampedActiveIndex = Math.max(0, Math.min(activeIndex, accounts.length - 1));
212
+ const activeIndexByFamily = {};
213
+ for (const family of MODEL_FAMILIES) {
214
+ const storedFamilyIndex = stored?.activeIndexByFamily?.[family];
215
+ const rawFamilyIndex = replaceAll
216
+ ? 0
217
+ : typeof storedFamilyIndex === "number" && Number.isFinite(storedFamilyIndex)
218
+ ? storedFamilyIndex
219
+ : clampedActiveIndex;
220
+ activeIndexByFamily[family] = Math.max(0, Math.min(Math.floor(rawFamilyIndex), accounts.length - 1));
221
+ }
187
222
  await saveAccounts({
188
- version: 1,
223
+ version: 3,
189
224
  accounts,
190
- activeIndex: Math.max(0, Math.min(activeIndex, accounts.length - 1)),
225
+ activeIndex: clampedActiveIndex,
226
+ activeIndexByFamily,
191
227
  });
192
228
  };
193
229
  const showToast = async (message, variant = "success") => {
@@ -203,17 +239,79 @@ export const OpenAIAuthPlugin = async ({ client }) => {
203
239
  // Ignore when TUI is not available.
204
240
  }
205
241
  };
206
- const resolveActiveIndex = (storage) => {
242
+ const resolveActiveIndex = (storage, family = "codex") => {
207
243
  const total = storage.accounts.length;
208
244
  if (total === 0)
209
245
  return 0;
210
- const raw = Number.isFinite(storage.activeIndex) ? storage.activeIndex : 0;
246
+ const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex;
247
+ const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0;
211
248
  return Math.max(0, Math.min(raw, total - 1));
212
249
  };
213
- const formatRateLimitEntry = (account, now) => {
214
- if (typeof account.rateLimitResetTime !== "number")
250
+ const hydrateEmails = async (storage) => {
251
+ if (!storage)
252
+ return storage;
253
+ const skipHydrate = process.env.VITEST_WORKER_ID !== undefined ||
254
+ process.env.NODE_ENV === "test" ||
255
+ process.env.OPENCODE_SKIP_EMAIL_HYDRATE === "1";
256
+ if (skipHydrate)
257
+ return storage;
258
+ const accountsToHydrate = storage.accounts.filter((account) => account && !account.email);
259
+ if (accountsToHydrate.length === 0)
260
+ return storage;
261
+ let changed = false;
262
+ await Promise.all(accountsToHydrate.map(async (account) => {
263
+ try {
264
+ const refreshed = await queuedRefresh(account.refreshToken);
265
+ if (refreshed.type !== "success")
266
+ return;
267
+ const id = extractAccountId(refreshed.access);
268
+ const email = sanitizeEmail(extractAccountEmail(refreshed.access));
269
+ if (id && id !== account.accountId) {
270
+ account.accountId = id;
271
+ changed = true;
272
+ }
273
+ if (email && email !== account.email) {
274
+ account.email = email;
275
+ changed = true;
276
+ }
277
+ if (refreshed.refresh && refreshed.refresh !== account.refreshToken) {
278
+ account.refreshToken = refreshed.refresh;
279
+ changed = true;
280
+ }
281
+ }
282
+ catch {
283
+ logDebug(`[${PLUGIN_NAME}] Failed to hydrate email for account`);
284
+ }
285
+ }));
286
+ if (changed) {
287
+ await saveAccounts(storage);
288
+ }
289
+ return storage;
290
+ };
291
+ const getRateLimitResetTimeForFamily = (account, now, family) => {
292
+ const times = account.rateLimitResetTimes;
293
+ if (!times)
215
294
  return null;
216
- const remaining = account.rateLimitResetTime - now;
295
+ let minReset = null;
296
+ const prefix = `${family}:`;
297
+ for (const [key, value] of Object.entries(times)) {
298
+ if (typeof value !== "number")
299
+ continue;
300
+ if (value <= now)
301
+ continue;
302
+ if (key !== family && !key.startsWith(prefix))
303
+ continue;
304
+ if (minReset === null || value < minReset) {
305
+ minReset = value;
306
+ }
307
+ }
308
+ return minReset;
309
+ };
310
+ const formatRateLimitEntry = (account, now, family = "codex") => {
311
+ const resetAt = getRateLimitResetTimeForFamily(account, now, family);
312
+ if (typeof resetAt !== "number")
313
+ return null;
314
+ const remaining = resetAt - now;
217
315
  if (remaining <= 0)
218
316
  return null;
219
317
  return `resets in ${formatWaitTime(remaining)}`;
@@ -243,13 +341,9 @@ export const OpenAIAuthPlugin = async ({ client }) => {
243
341
  }
244
342
  const accountManager = await AccountManager.loadFromDisk(auth);
245
343
  cachedAccountManager = accountManager;
246
- const storedSnapshot = await loadAccounts();
247
344
  const refreshToken = auth.type === "oauth" ? auth.refresh : "";
248
- const needsPersist = !storedSnapshot ||
249
- storedSnapshot.accounts.length !==
250
- accountManager.getAccountCount() ||
251
- (refreshToken &&
252
- !storedSnapshot.accounts.some((account) => account.refreshToken === refreshToken));
345
+ const needsPersist = refreshToken &&
346
+ !accountManager.hasRefreshToken(refreshToken);
253
347
  if (needsPersist) {
254
348
  await accountManager.saveToDisk();
255
349
  }
@@ -267,6 +361,14 @@ export const OpenAIAuthPlugin = async ({ client }) => {
267
361
  // Priority: CODEX_MODE env var > config file > default (true)
268
362
  const pluginConfig = loadPluginConfig();
269
363
  const codexMode = getCodexMode(pluginConfig);
364
+ const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
365
+ const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
366
+ const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig);
367
+ const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
368
+ const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
369
+ checkAndNotify(async (message, variant) => {
370
+ await showToast(message, variant);
371
+ }).catch(() => { });
270
372
  // Return SDK configuration
271
373
  return {
272
374
  apiKey: DUMMY_API_KEY,
@@ -300,83 +402,138 @@ export const OpenAIAuthPlugin = async ({ client }) => {
300
402
  const requestInit = transformation?.updatedInit ?? init;
301
403
  const promptCacheKey = transformation?.body?.prompt_cache_key;
302
404
  const model = transformation?.body.model;
303
- const accountCount = accountManager.getAccountCount();
304
- const attempted = new Set();
305
- while (attempted.size < Math.max(1, accountCount)) {
306
- const account = accountManager.getCurrentOrNext();
307
- if (!account || attempted.has(account.index)) {
308
- break;
405
+ const modelFamily = model ? getModelFamily(model) : "gpt-5.1";
406
+ const quotaKey = model ? `${modelFamily}:${model}` : modelFamily;
407
+ const abortSignal = requestInit?.signal ?? init?.signal ?? null;
408
+ const sleep = (ms) => new Promise((resolve, reject) => {
409
+ if (abortSignal?.aborted) {
410
+ reject(new Error("Aborted"));
411
+ return;
309
412
  }
310
- attempted.add(account.index);
311
- let accountAuth = accountManager.toAuthDetails(account);
312
- try {
313
- if (shouldRefreshToken(accountAuth)) {
314
- accountAuth = (await refreshAndUpdateToken(accountAuth, client));
315
- accountManager.updateFromAuth(account, accountAuth);
316
- await accountManager.saveToDisk();
413
+ const timeout = setTimeout(() => {
414
+ cleanup();
415
+ resolve();
416
+ }, ms);
417
+ const onAbort = () => {
418
+ cleanup();
419
+ reject(new Error("Aborted"));
420
+ };
421
+ const cleanup = () => {
422
+ clearTimeout(timeout);
423
+ abortSignal?.removeEventListener("abort", onAbort);
424
+ };
425
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
426
+ });
427
+ let allRateLimitedRetries = 0;
428
+ while (true) {
429
+ const accountCount = accountManager.getAccountCount();
430
+ const attempted = new Set();
431
+ while (attempted.size < Math.max(1, accountCount)) {
432
+ const account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model);
433
+ if (!account || attempted.has(account.index)) {
434
+ break;
317
435
  }
318
- }
319
- catch (error) {
320
- accountManager.markAccountCoolingDown(account, AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
321
- await accountManager.saveToDisk();
322
- continue;
323
- }
324
- const accountId = account.accountId ?? extractAccountId(accountAuth.access);
325
- if (!accountId) {
326
- accountManager.markAccountCoolingDown(account, AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
327
- await accountManager.saveToDisk();
328
- continue;
329
- }
330
- account.accountId = accountId;
331
- if (accountCount > 1 &&
332
- accountManager.shouldShowAccountToast(account.index)) {
333
- const accountLabel = formatAccountLabel(account.accountId, account.index);
334
- await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
335
- accountManager.markToastShown(account.index);
336
- }
337
- const headers = createCodexHeaders(requestInit, accountId, accountAuth.access, {
338
- model,
339
- promptCacheKey,
340
- });
341
- const response = await fetch(url, {
342
- ...requestInit,
343
- headers,
344
- });
345
- logRequest(LOG_STAGES.RESPONSE, {
346
- status: response.status,
347
- ok: response.ok,
348
- statusText: response.statusText,
349
- headers: Object.fromEntries(response.headers.entries()),
350
- });
351
- if (!response.ok) {
352
- const { response: errorResponse, rateLimit } = await handleErrorResponse(response);
353
- if (rateLimit) {
354
- accountManager.markRateLimited(account, rateLimit.retryAfterMs);
355
- accountManager.markSwitched(account, "rate-limit");
356
- await accountManager.saveToDisk();
357
- if (accountManager.getAccountCount() > 1 &&
358
- accountManager.shouldShowAccountToast(account.index)) {
359
- await showToast("Rate limit reached. Switching accounts.", "warning");
360
- accountManager.markToastShown(account.index);
436
+ attempted.add(account.index);
437
+ let accountAuth = accountManager.toAuthDetails(account);
438
+ try {
439
+ if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
440
+ accountAuth = (await refreshAndUpdateToken(accountAuth, client));
441
+ accountManager.updateFromAuth(account, accountAuth);
442
+ accountManager.saveToDiskDebounced();
361
443
  }
444
+ }
445
+ catch (err) {
446
+ logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${err?.message ?? String(err)}`);
447
+ accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
448
+ accountManager.saveToDiskDebounced();
362
449
  continue;
363
450
  }
364
- return errorResponse;
451
+ const accountId = account.accountId ?? extractAccountId(accountAuth.access);
452
+ if (!accountId) {
453
+ accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
454
+ accountManager.saveToDiskDebounced();
455
+ continue;
456
+ }
457
+ account.accountId = accountId;
458
+ account.email =
459
+ extractAccountEmail(accountAuth.access) ?? account.email;
460
+ if (accountCount > 1 &&
461
+ accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
462
+ const accountLabel = formatAccountLabel(account, account.index);
463
+ await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
464
+ accountManager.markToastShown(account.index);
465
+ }
466
+ const headers = createCodexHeaders(requestInit, accountId, accountAuth.access, {
467
+ model,
468
+ promptCacheKey,
469
+ });
470
+ while (true) {
471
+ const response = await fetch(url, {
472
+ ...requestInit,
473
+ headers,
474
+ });
475
+ logRequest(LOG_STAGES.RESPONSE, {
476
+ status: response.status,
477
+ ok: response.ok,
478
+ statusText: response.statusText,
479
+ headers: Object.fromEntries(response.headers.entries()),
480
+ });
481
+ if (!response.ok) {
482
+ const { response: errorResponse, rateLimit } = await handleErrorResponse(response);
483
+ if (rateLimit) {
484
+ const { attempt, delayMs } = getRateLimitBackoff(account.index, quotaKey, rateLimit.retryAfterMs);
485
+ const waitLabel = formatWaitTime(delayMs);
486
+ if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) {
487
+ if (accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
488
+ await showToast(`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning");
489
+ accountManager.markToastShown(account.index);
490
+ }
491
+ await sleep(delayMs);
492
+ continue;
493
+ }
494
+ accountManager.markRateLimited(account, delayMs, modelFamily, model);
495
+ accountManager.recordRateLimit(account, modelFamily, model);
496
+ account.lastSwitchReason = "rate-limit";
497
+ accountManager.saveToDiskDebounced();
498
+ if (accountManager.getAccountCount() > 1 &&
499
+ accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
500
+ await showToast(`Rate limited. Switching accounts (retry in ${waitLabel}).`, "warning");
501
+ accountManager.markToastShown(account.index);
502
+ }
503
+ break;
504
+ }
505
+ return errorResponse;
506
+ }
507
+ resetRateLimitBackoff(account.index, quotaKey);
508
+ accountManager.recordSuccess(account, modelFamily, model);
509
+ return await handleSuccessResponse(response, isStreaming);
510
+ }
511
+ }
512
+ const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
513
+ const count = accountManager.getAccountCount();
514
+ if (retryAllAccountsRateLimited &&
515
+ count > 0 &&
516
+ waitMs > 0 &&
517
+ (retryAllAccountsMaxWaitMs === 0 ||
518
+ waitMs <= retryAllAccountsMaxWaitMs) &&
519
+ allRateLimitedRetries < retryAllAccountsMaxRetries) {
520
+ const waitLabel = formatWaitTime(waitMs);
521
+ await showToast(`All ${count} account(s) are rate-limited. Waiting ${waitLabel}...`, "warning");
522
+ allRateLimitedRetries++;
523
+ await sleep(waitMs);
524
+ continue;
365
525
  }
366
- return await handleSuccessResponse(response, isStreaming);
526
+ const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
527
+ const message = count === 0
528
+ ? "No OpenAI accounts configured. Run `opencode auth login`."
529
+ : `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`opencode auth login\`.`;
530
+ return new Response(JSON.stringify({ error: { message } }), {
531
+ status: 429,
532
+ headers: {
533
+ "content-type": "application/json; charset=utf-8",
534
+ },
535
+ });
367
536
  }
368
- const waitMs = accountManager.getMinWaitTime();
369
- const count = accountManager.getAccountCount();
370
- const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
371
- const message = count === 0
372
- ? "No OpenAI accounts configured. Run `opencode auth login`."
373
- : `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`opencode auth login\`.`;
374
- return new Response(JSON.stringify({ error: { message } }), {
375
- status: 429,
376
- headers: {
377
- "content-type": "application/json; charset=utf-8",
378
- },
379
- });
380
537
  },
381
538
  };
382
539
  },
@@ -403,10 +560,11 @@ export const OpenAIAuthPlugin = async ({ client }) => {
403
560
  inputs["no-browser"] === "true";
404
561
  const useManualMode = noBrowser;
405
562
  let startFresh = true;
406
- const existingStorage = await loadAccounts();
563
+ const existingStorage = await hydrateEmails(await loadAccounts());
407
564
  if (existingStorage && existingStorage.accounts.length > 0) {
408
565
  const existingAccounts = existingStorage.accounts.map((account, index) => ({
409
566
  accountId: account.accountId,
567
+ email: account.email,
410
568
  index,
411
569
  }));
412
570
  const loginMode = await promptLoginMode(existingAccounts);
@@ -418,9 +576,23 @@ export const OpenAIAuthPlugin = async ({ client }) => {
418
576
  console.log("\nAdding to existing accounts.\n");
419
577
  }
420
578
  }
421
- while (accounts.length < MAX_OAUTH_ACCOUNTS) {
579
+ while (accounts.length < ACCOUNT_LIMITS.MAX_ACCOUNTS) {
422
580
  console.log(`\n=== OpenAI OAuth (Account ${accounts.length + 1}) ===`);
423
- const result = await runOAuthFlow(useManualMode);
581
+ const forceNewLogin = accounts.length > 0;
582
+ const result = await runOAuthFlow(useManualMode, forceNewLogin);
583
+ if (result.type === "success") {
584
+ const email = extractAccountEmail(result.access);
585
+ const accountId = extractAccountId(result.access);
586
+ const label = email || accountId || "Unknown account";
587
+ console.log(`\n✓ Authenticated as: ${label}\n`);
588
+ const isDuplicate = accounts.some((acc) => (accountId && extractAccountId(acc.access) === accountId) ||
589
+ (email && extractAccountEmail(acc.access) === email));
590
+ if (isDuplicate) {
591
+ console.warn(`\n⚠️ WARNING: You authenticated with an account that is already in the list (${label}).`);
592
+ console.warn("This usually happens if you didn't log out or use a different browser profile.");
593
+ console.warn("The duplicate will update the existing entry.\n");
594
+ }
595
+ }
424
596
  if (result.type === "failed") {
425
597
  if (accounts.length === 0) {
426
598
  return {
@@ -439,10 +611,10 @@ export const OpenAIAuthPlugin = async ({ client }) => {
439
611
  const isFirstAccount = accounts.length === 1;
440
612
  await persistAccountPool([result], isFirstAccount && startFresh);
441
613
  }
442
- catch {
443
- // Ignore storage failures
614
+ catch (err) {
615
+ logDebug(`[${PLUGIN_NAME}] Failed to persist account pool: ${err?.message ?? String(err)}`);
444
616
  }
445
- if (accounts.length >= MAX_OAUTH_ACCOUNTS) {
617
+ if (accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) {
446
618
  break;
447
619
  }
448
620
  let currentAccountCount = accounts.length;
@@ -452,8 +624,8 @@ export const OpenAIAuthPlugin = async ({ client }) => {
452
624
  currentAccountCount = currentStorage.accounts.length;
453
625
  }
454
626
  }
455
- catch {
456
- // Ignore storage read failures
627
+ catch (err) {
628
+ logDebug(`[${PLUGIN_NAME}] Failed to load accounts for count: ${err?.message ?? String(err)}`);
457
629
  }
458
630
  const addAnother = await promptAddAnotherAccount(currentAccountCount);
459
631
  if (!addAnother) {
@@ -478,8 +650,8 @@ export const OpenAIAuthPlugin = async ({ client }) => {
478
650
  actualAccountCount = finalStorage.accounts.length;
479
651
  }
480
652
  }
481
- catch {
482
- // Ignore storage read failures
653
+ catch (err) {
654
+ logDebug(`[${PLUGIN_NAME}] Failed to load final account count: ${err?.message ?? String(err)}`);
483
655
  }
484
656
  return {
485
657
  url: "",
@@ -493,7 +665,8 @@ export const OpenAIAuthPlugin = async ({ client }) => {
493
665
  try {
494
666
  serverInfo = await startLocalOAuthServer({ state });
495
667
  }
496
- catch {
668
+ catch (err) {
669
+ logDebug(`[${PLUGIN_NAME}] Failed to start OAuth server for add flow: ${err?.message ?? String(err)}`);
497
670
  serverInfo = null;
498
671
  }
499
672
  openBrowserUrl(url);
@@ -558,13 +731,15 @@ export const OpenAIAuthPlugin = async ({ client }) => {
558
731
  ].join("\n");
559
732
  }
560
733
  const now = Date.now();
561
- const activeIndex = resolveActiveIndex(storage);
734
+ const activeIndex = resolveActiveIndex(storage, "codex");
562
735
  const lines = [
563
736
  `OpenAI Accounts (${storage.accounts.length}):`,
564
737
  "",
738
+ " # Label Status",
739
+ "----------------------------------------------- ---------------------",
565
740
  ];
566
741
  storage.accounts.forEach((account, index) => {
567
- const label = formatAccountLabel(account.accountId, index);
742
+ const label = formatAccountLabel(account, index);
568
743
  const statuses = [];
569
744
  const rateLimit = formatRateLimitEntry(account, now);
570
745
  if (index === activeIndex)
@@ -576,10 +751,9 @@ export const OpenAIAuthPlugin = async ({ client }) => {
576
751
  account.coolingDownUntil > now) {
577
752
  statuses.push("cooldown");
578
753
  }
579
- const suffix = statuses.length > 0
580
- ? ` (${statuses.join(", ")})`
581
- : "";
582
- lines.push(` ${index + 1}. ${label}${suffix}`);
754
+ const statusText = statuses.length > 0 ? statuses.join(", ") : "ok";
755
+ const row = `${String(index + 1).padEnd(3)} ${label.padEnd(40)} ${statusText}`;
756
+ lines.push(row);
583
757
  });
584
758
  lines.push("");
585
759
  lines.push(`Storage: ${storePath}`);
@@ -614,51 +788,133 @@ export const OpenAIAuthPlugin = async ({ client }) => {
614
788
  account.lastSwitchReason = "rotation";
615
789
  }
616
790
  storage.activeIndex = targetIndex;
791
+ storage.activeIndexByFamily = storage.activeIndexByFamily ?? {};
792
+ for (const family of MODEL_FAMILIES) {
793
+ storage.activeIndexByFamily[family] = targetIndex;
794
+ }
617
795
  await saveAccounts(storage);
618
796
  if (cachedAccountManager) {
619
797
  cachedAccountManager.setActiveIndex(targetIndex);
620
798
  await cachedAccountManager.saveToDisk();
621
799
  }
622
- const label = formatAccountLabel(account?.accountId, targetIndex);
800
+ const label = formatAccountLabel(account, targetIndex);
623
801
  return `Switched to account: ${label}`;
624
802
  },
625
803
  }),
626
804
  "openai-accounts-status": tool({
627
805
  description: "Show detailed status of OpenAI accounts and rate limits.",
628
- args: {},
629
- async execute() {
806
+ args: {
807
+ json: tool.schema.boolean().optional().describe("Return JSON instead of text"),
808
+ },
809
+ async execute({ json }) {
630
810
  const storage = await loadAccounts();
631
811
  if (!storage || storage.accounts.length === 0) {
632
812
  return "No OpenAI accounts configured. Run: opencode auth login";
633
813
  }
634
814
  const now = Date.now();
635
- const activeIndex = resolveActiveIndex(storage);
815
+ const activeIndex = resolveActiveIndex(storage, "codex");
816
+ if (json) {
817
+ return JSON.stringify({
818
+ total: storage.accounts.length,
819
+ activeIndex,
820
+ activeIndexByFamily: storage.activeIndexByFamily ?? null,
821
+ storagePath: getStoragePath(),
822
+ accounts: storage.accounts.map((account, index) => ({
823
+ index,
824
+ active: index === activeIndex,
825
+ label: formatAccountLabel(account, index),
826
+ accountId: account.accountId ?? null,
827
+ email: account.email ?? null,
828
+ rateLimitResetTimes: account.rateLimitResetTimes ?? null,
829
+ coolingDownUntil: typeof account.coolingDownUntil === "number"
830
+ ? account.coolingDownUntil
831
+ : null,
832
+ cooldownReason: account.cooldownReason ?? null,
833
+ lastUsed: typeof account.lastUsed === "number"
834
+ ? account.lastUsed
835
+ : null,
836
+ })),
837
+ }, null, 2);
838
+ }
636
839
  const lines = [
637
840
  `Account Status (${storage.accounts.length} total):`,
638
841
  "",
842
+ " # Label Active Rate Limit Cooldown Last Used",
843
+ "----------------------------------------------- ------ ---------------- ---------------- ----------------",
639
844
  ];
640
845
  storage.accounts.forEach((account, index) => {
641
- const label = formatAccountLabel(account.accountId, index);
642
- lines.push(`${index + 1}. ${label}`);
643
- lines.push(` Active: ${index === activeIndex ? "Yes" : "No"}`);
644
- const rateLimit = formatRateLimitEntry(account, now);
645
- lines.push(` Rate Limit: ${rateLimit ?? "None"}`);
646
- const cooldown = formatCooldown(account, now);
647
- if (cooldown) {
648
- lines.push(` Cooldown: Yes (${cooldown})`);
649
- }
650
- else {
651
- lines.push(" Cooldown: No");
652
- }
653
- if (typeof account.lastUsed === "number" &&
654
- account.lastUsed > 0) {
655
- lines.push(` Last Used: ${formatWaitTime(now - account.lastUsed)} ago`);
656
- }
657
- lines.push("");
846
+ const label = formatAccountLabel(account, index).padEnd(42);
847
+ const active = index === activeIndex ? "Yes" : "No";
848
+ const rateLimit = formatRateLimitEntry(account, now) ?? "None";
849
+ const cooldown = formatCooldown(account, now) ?? "No";
850
+ const lastUsed = typeof account.lastUsed === "number" && account.lastUsed > 0
851
+ ? `${formatWaitTime(now - account.lastUsed)} ago`
852
+ : "-";
853
+ const row = `${String(index + 1).padEnd(3)} ${label} ${active.padEnd(6)} ${rateLimit.padEnd(16)} ${cooldown.padEnd(16)} ${lastUsed}`;
854
+ lines.push(row);
855
+ });
856
+ lines.push("");
857
+ lines.push("Active index by model family:");
858
+ for (const family of MODEL_FAMILIES) {
859
+ const idx = storage.activeIndexByFamily?.[family];
860
+ const familyIndexLabel = typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-";
861
+ lines.push(` ${family}: ${familyIndexLabel}`);
862
+ }
863
+ lines.push("");
864
+ lines.push("Rate limits by model family (per account):");
865
+ storage.accounts.forEach((account, index) => {
866
+ const statuses = MODEL_FAMILIES.map((family) => {
867
+ const resetAt = getRateLimitResetTimeForFamily(account, now, family);
868
+ if (typeof resetAt !== "number")
869
+ return `${family}=ok`;
870
+ return `${family}=${formatWaitTime(resetAt - now)}`;
871
+ });
872
+ lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`);
658
873
  });
659
874
  return lines.join("\n");
660
875
  },
661
876
  }),
877
+ "openai-accounts-health": tool({
878
+ description: "Check health of all OpenAI accounts by validating refresh tokens.",
879
+ args: {},
880
+ async execute() {
881
+ const storage = await loadAccounts();
882
+ if (!storage || storage.accounts.length === 0) {
883
+ return "No OpenAI accounts configured. Run: opencode auth login";
884
+ }
885
+ const results = [
886
+ `Health Check (${storage.accounts.length} accounts):`,
887
+ "",
888
+ ];
889
+ let healthyCount = 0;
890
+ let unhealthyCount = 0;
891
+ for (let i = 0; i < storage.accounts.length; i++) {
892
+ const account = storage.accounts[i];
893
+ if (!account)
894
+ continue;
895
+ const label = formatAccountLabel(account, i);
896
+ try {
897
+ const refreshResult = await queuedRefresh(account.refreshToken);
898
+ if (refreshResult.type === "success") {
899
+ results.push(` ✓ ${label}: Healthy`);
900
+ healthyCount++;
901
+ }
902
+ else {
903
+ results.push(` ✗ ${label}: Token refresh failed`);
904
+ unhealthyCount++;
905
+ }
906
+ }
907
+ catch (error) {
908
+ const errorMsg = error instanceof Error ? error.message : String(error);
909
+ results.push(` ✗ ${label}: Error - ${errorMsg.slice(0, 50)}`);
910
+ unhealthyCount++;
911
+ }
912
+ }
913
+ results.push("");
914
+ results.push(`Summary: ${healthyCount} healthy, ${unhealthyCount} unhealthy`);
915
+ return results.join("\n");
916
+ },
917
+ }),
662
918
  },
663
919
  };
664
920
  };