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

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