opencode-openai-codex-multi-auth 4.5.5 → 4.5.9

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 (47) hide show
  1. package/README.md +10 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +356 -48
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/account-matching.d.ts.map +1 -1
  6. package/dist/lib/account-matching.js +3 -37
  7. package/dist/lib/account-matching.js.map +1 -1
  8. package/dist/lib/accounts.d.ts +22 -1
  9. package/dist/lib/accounts.d.ts.map +1 -1
  10. package/dist/lib/accounts.js +291 -56
  11. package/dist/lib/accounts.js.map +1 -1
  12. package/dist/lib/auth/auth.d.ts +5 -0
  13. package/dist/lib/auth/auth.d.ts.map +1 -1
  14. package/dist/lib/auth/auth.js +19 -1
  15. package/dist/lib/auth/auth.js.map +1 -1
  16. package/dist/lib/cli.d.ts +7 -1
  17. package/dist/lib/cli.d.ts.map +1 -1
  18. package/dist/lib/cli.js +57 -3
  19. package/dist/lib/cli.js.map +1 -1
  20. package/dist/lib/config.d.ts +8 -0
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/config.js +35 -0
  23. package/dist/lib/config.js.map +1 -1
  24. package/dist/lib/formatting.d.ts +9 -0
  25. package/dist/lib/formatting.d.ts.map +1 -0
  26. package/dist/lib/formatting.js +71 -0
  27. package/dist/lib/formatting.js.map +1 -0
  28. package/dist/lib/oauth-success.html +1 -1
  29. package/dist/lib/rate-limit.d.ts +36 -0
  30. package/dist/lib/rate-limit.d.ts.map +1 -0
  31. package/dist/lib/rate-limit.js +78 -0
  32. package/dist/lib/rate-limit.js.map +1 -0
  33. package/dist/lib/refresh-queue.d.ts +37 -0
  34. package/dist/lib/refresh-queue.d.ts.map +1 -0
  35. package/dist/lib/refresh-queue.js +83 -0
  36. package/dist/lib/refresh-queue.js.map +1 -0
  37. package/dist/lib/rotation.d.ts +1 -1
  38. package/dist/lib/rotation.d.ts.map +1 -1
  39. package/dist/lib/rotation.js +15 -3
  40. package/dist/lib/rotation.js.map +1 -1
  41. package/dist/lib/storage.d.ts +20 -0
  42. package/dist/lib/storage.d.ts.map +1 -1
  43. package/dist/lib/storage.js +502 -108
  44. package/dist/lib/storage.js.map +1 -1
  45. package/dist/lib/types.d.ts +45 -0
  46. package/dist/lib/types.d.ts.map +1 -1
  47. package/package.json +11 -5
package/dist/index.js CHANGED
@@ -22,20 +22,23 @@
22
22
  * @repository https://github.com/numman-ali/opencode-openai-codex-auth
23
23
  */
24
24
  import { tool } from "@opencode-ai/plugin";
25
- import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationInput, REDIRECT_URI, refreshAccessToken, } from "./lib/auth/auth.js";
25
+ import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationInputForFlow, REDIRECT_URI, } from "./lib/auth/auth.js";
26
26
  import { openBrowserUrl } from "./lib/auth/browser.js";
27
27
  import { startLocalOAuthServer } from "./lib/auth/server.js";
28
- import { getAccountSelectionStrategy, getCodexMode, getPidOffsetEnabled, getQuietMode, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getTokenRefreshSkewMs, loadPluginConfig, } from "./lib/config.js";
28
+ import { getAccountSelectionStrategy, getCodexMode, getDefaultRetryAfterMs, getMaxBackoffMs, getMaxCacheFirstWaitSeconds, getPidOffsetEnabled, getQuietMode, getRateLimitDedupWindowMs, getRateLimitStateResetMs, getRateLimitToastDebounceMs, getRequestJitterMaxMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getSchedulingMode, getSwitchOnFirstRateLimit, getTokenRefreshSkewMs, loadPluginConfig, } from "./lib/config.js";
29
29
  import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, HTTP_STATUS, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, } from "./lib/constants.js";
30
30
  import { logRequest, logDebug } from "./lib/logger.js";
31
31
  import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, rewriteUrlForCodex, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
32
- import { AccountManager, extractAccountEmail, extractAccountId, extractAccountPlan, formatAccountLabel, formatWaitTime, isOAuthAuth, sanitizeEmail, } from "./lib/accounts.js";
33
- import { promptAddAnotherAccount, promptLoginMode, promptOAuthCallbackValue } from "./lib/cli.js";
32
+ import { AccountManager, extractAccountEmail, extractAccountId, extractAccountPlan, formatAccountLabel, formatWaitTime, isOAuthAuth, needsIdentityHydration, sanitizeEmail, } from "./lib/accounts.js";
33
+ import { promptAddAnotherAccount, promptLoginMode, promptManageAccounts, promptOAuthCallbackValue, promptRepairAccounts, } from "./lib/cli.js";
34
34
  import { withTerminalModeRestored } from "./lib/terminal.js";
35
- import { getStoragePath, loadAccounts, saveAccounts } from "./lib/storage.js";
35
+ import { getStoragePath, autoQuarantineCorruptAccountsFile, inspectAccountsFile, loadAccounts, quarantineAccounts, quarantineCorruptFile, replaceAccountsFile, saveAccounts, toggleAccountEnabled, writeQuarantineFile, } from "./lib/storage.js";
36
36
  import { findAccountMatchIndex } from "./lib/account-matching.js";
37
37
  import { getModelFamily, MODEL_FAMILIES } from "./lib/prompts/codex.js";
38
38
  import { getHealthTracker, getTokenTracker } from "./lib/rotation.js";
39
+ import { RateLimitTracker, decideRateLimitAction, parseRateLimitReason } from "./lib/rate-limit.js";
40
+ import { ProactiveRefreshQueue, createRefreshScheduler, } from "./lib/refresh-queue.js";
41
+ import { formatRateLimitStatusMessage, formatToastMessage } from "./lib/formatting.js";
39
42
  const RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS = 5_000;
40
43
  const AUTH_FAILURE_COOLDOWN_MS = 60_000;
41
44
  const MAX_ACCOUNTS = 10;
@@ -80,22 +83,26 @@ function parseRetryAfterMs(headers) {
80
83
  */
81
84
  export const OpenAIAuthPlugin = async ({ client }) => {
82
85
  let cachedAccountManager = null;
86
+ let proactiveRefreshScheduler = null;
83
87
  const showToast = async (message, variant = "info", quietMode = false) => {
84
88
  if (quietMode)
85
89
  return;
86
90
  try {
87
- await client.tui.showToast({ body: { message, variant } });
91
+ await client.tui.showToast({ body: { message: formatToastMessage(message), variant } });
88
92
  }
89
93
  catch {
90
94
  // ignore (non-TUI contexts)
91
95
  }
92
96
  };
93
- const buildManualOAuthFlow = (pkce, url, onSuccess) => ({
97
+ const buildManualOAuthFlow = (pkce, expectedState, url, onSuccess) => ({
94
98
  url,
95
99
  method: "code",
96
100
  instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL,
97
101
  callback: async (input) => {
98
- const parsed = parseAuthorizationInput(input);
102
+ const parsed = parseAuthorizationInputForFlow(input, expectedState);
103
+ if (parsed.stateStatus === "mismatch") {
104
+ return { type: "failed" };
105
+ }
99
106
  if (!parsed.code) {
100
107
  return { type: "failed" };
101
108
  }
@@ -114,6 +121,9 @@ export const OpenAIAuthPlugin = async ({ client }) => {
114
121
  const accountId = extractAccountId(token.access);
115
122
  const email = sanitizeEmail(extractAccountEmail(token.idToken ?? token.access));
116
123
  const plan = extractAccountPlan(token.idToken ?? token.access);
124
+ if (!accountId || !email || !plan) {
125
+ debugAuth("[PersistAccount] Missing account identity fields; persisting legacy entry");
126
+ }
117
127
  debugAuth(`[PersistAccount] Account details - accountId: ${accountId}, email: ${email}, plan: ${plan}, existing accounts: ${accounts.length}`);
118
128
  const existingIndex = findAccountMatchIndex(accounts, { accountId, plan, email });
119
129
  debugAuth(`[PersistAccount] Match index: ${existingIndex}`);
@@ -124,6 +134,7 @@ export const OpenAIAuthPlugin = async ({ client }) => {
124
134
  accountId,
125
135
  email,
126
136
  plan,
137
+ enabled: true,
127
138
  addedAt: now,
128
139
  lastUsed: now,
129
140
  });
@@ -136,6 +147,8 @@ export const OpenAIAuthPlugin = async ({ client }) => {
136
147
  existing.accountId = accountId ?? existing.accountId;
137
148
  existing.email = email ?? existing.email;
138
149
  existing.plan = plan ?? existing.plan;
150
+ if (typeof existing.enabled !== "boolean")
151
+ existing.enabled = true;
139
152
  existing.lastUsed = now;
140
153
  }
141
154
  }
@@ -172,9 +185,15 @@ export const OpenAIAuthPlugin = async ({ client }) => {
172
185
  if (!isOAuthAuth(auth)) {
173
186
  return {};
174
187
  }
188
+ const pluginConfig = loadPluginConfig();
189
+ const quietMode = getQuietMode(pluginConfig);
175
190
  const accountManager = await AccountManager.loadFromDisk(auth);
176
191
  cachedAccountManager = accountManager;
177
192
  if (accountManager.getAccountCount() === 0) {
193
+ const quarantinePath = await autoQuarantineCorruptAccountsFile();
194
+ if (quarantinePath) {
195
+ await showToast("Accounts file was corrupted and has been quarantined. Run `opencode auth login`.", "warning", quietMode);
196
+ }
178
197
  logDebug(`[${PLUGIN_NAME}] No OAuth accounts available (run opencode auth login)`);
179
198
  return {};
180
199
  }
@@ -184,16 +203,85 @@ export const OpenAIAuthPlugin = async ({ client }) => {
184
203
  global: providerConfig?.options || {},
185
204
  models: providerConfig?.models || {},
186
205
  };
187
- const pluginConfig = loadPluginConfig();
188
206
  const codexMode = getCodexMode(pluginConfig);
189
207
  const accountSelectionStrategy = getAccountSelectionStrategy(pluginConfig);
190
208
  const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
191
- const quietMode = getQuietMode(pluginConfig);
192
209
  const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
210
+ const proactiveRefreshEnabled = (() => {
211
+ const rawConfig = pluginConfig;
212
+ const configFlag = rawConfig["proactive_token_refresh"] ?? rawConfig["proactiveTokenRefresh"];
213
+ const envFlag = process.env.CODEX_AUTH_PROACTIVE_TOKEN_REFRESH;
214
+ if (envFlag === "1" || envFlag === "true")
215
+ return true;
216
+ if (envFlag === "0" || envFlag === "false")
217
+ return false;
218
+ return Boolean(configFlag);
219
+ })();
193
220
  const toastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
194
221
  const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig);
195
222
  const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
196
223
  const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
224
+ const schedulingMode = getSchedulingMode(pluginConfig);
225
+ const maxCacheFirstWaitSeconds = getMaxCacheFirstWaitSeconds(pluginConfig);
226
+ const switchOnFirstRateLimit = getSwitchOnFirstRateLimit(pluginConfig);
227
+ const rateLimitDedupWindowMs = getRateLimitDedupWindowMs(pluginConfig);
228
+ const rateLimitStateResetMs = getRateLimitStateResetMs(pluginConfig);
229
+ const defaultRetryAfterMs = getDefaultRetryAfterMs(pluginConfig);
230
+ const maxBackoffMs = getMaxBackoffMs(pluginConfig);
231
+ const requestJitterMaxMs = getRequestJitterMaxMs(pluginConfig);
232
+ const maxCacheFirstWaitMs = Math.max(0, Math.floor(maxCacheFirstWaitSeconds * 1000));
233
+ const proactiveRefreshQueue = proactiveRefreshEnabled
234
+ ? new ProactiveRefreshQueue({ bufferMs: tokenRefreshSkewMs, intervalMs: 250 })
235
+ : null;
236
+ if (proactiveRefreshScheduler) {
237
+ proactiveRefreshScheduler.stop();
238
+ proactiveRefreshScheduler = null;
239
+ }
240
+ if (proactiveRefreshQueue) {
241
+ proactiveRefreshScheduler = createRefreshScheduler({
242
+ intervalMs: 1000,
243
+ queue: proactiveRefreshQueue,
244
+ getTasks: () => {
245
+ const tasks = [];
246
+ for (const account of accountManager.getAccountsSnapshot()) {
247
+ if (account.enabled === false)
248
+ continue;
249
+ if (!Number.isFinite(account.expires))
250
+ continue;
251
+ tasks.push({
252
+ key: `account-${account.index}`,
253
+ expires: account.expires ?? 0,
254
+ refresh: async () => {
255
+ const live = accountManager.getAccountByIndex(account.index);
256
+ if (!live || live.enabled === false)
257
+ return { type: "failed" };
258
+ const refreshed = await accountManager.refreshAccountWithFallback(live);
259
+ if (refreshed.type !== "success")
260
+ return refreshed;
261
+ const refreshedAuth = {
262
+ type: "oauth",
263
+ access: refreshed.access,
264
+ refresh: refreshed.refresh,
265
+ expires: refreshed.expires,
266
+ };
267
+ accountManager.updateFromAuth(live, refreshedAuth);
268
+ await accountManager.saveToDisk();
269
+ return refreshed;
270
+ },
271
+ });
272
+ }
273
+ return tasks;
274
+ },
275
+ });
276
+ proactiveRefreshScheduler.start();
277
+ }
278
+ const rateLimitTracker = new RateLimitTracker({
279
+ dedupWindowMs: rateLimitDedupWindowMs,
280
+ resetMs: rateLimitStateResetMs,
281
+ defaultRetryMs: defaultRetryAfterMs,
282
+ maxBackoffMs,
283
+ jitterMaxMs: requestJitterMaxMs,
284
+ });
197
285
  // Return SDK configuration
198
286
  return {
199
287
  apiKey: DUMMY_API_KEY,
@@ -249,8 +337,35 @@ export const OpenAIAuthPlugin = async ({ client }) => {
249
337
  abortSignal?.addEventListener("abort", onAbort, { once: true });
250
338
  });
251
339
  let allRateLimitedRetries = 0;
340
+ let autoRepairAttempted = false;
252
341
  while (true) {
253
342
  const accountCount = accountManager.getAccountCount();
343
+ if (!autoRepairAttempted && accountCount === 0) {
344
+ const legacyAccounts = accountManager.getLegacyAccounts();
345
+ if (legacyAccounts.length > 0) {
346
+ autoRepairAttempted = true;
347
+ const repair = await accountManager.repairLegacyAccounts();
348
+ const snapshot = accountManager.getStorageSnapshot();
349
+ let quarantinePath = null;
350
+ if (repair.quarantined.length > 0) {
351
+ const quarantinedTokens = new Set(repair.quarantined.map((account) => account.refreshToken));
352
+ const quarantineEntries = snapshot.accounts.filter((account) => quarantinedTokens.has(account.refreshToken));
353
+ const quarantineResult = await quarantineAccounts(snapshot, quarantineEntries, "legacy-auto-repair-failed");
354
+ quarantinePath = quarantineResult.quarantinePath;
355
+ accountManager.removeAccountsByRefreshToken(quarantinedTokens);
356
+ }
357
+ else {
358
+ await replaceAccountsFile(snapshot);
359
+ }
360
+ if (repair.quarantined.length > 0 && quarantinePath) {
361
+ await showToast(`Auto-repair failed for ${repair.quarantined.length} account(s). Quarantined: ${quarantinePath}`, "warning", quietMode);
362
+ }
363
+ else if (repair.repaired.length > 0) {
364
+ await showToast(`Auto-repaired ${repair.repaired.length} account(s).`, "success", quietMode);
365
+ }
366
+ continue;
367
+ }
368
+ }
254
369
  const attempted = new Set();
255
370
  while (attempted.size < Math.max(1, accountCount)) {
256
371
  const account = accountManager.getCurrentOrNextForFamily(modelFamily, model, accountSelectionStrategy, usePidOffset);
@@ -258,27 +373,48 @@ export const OpenAIAuthPlugin = async ({ client }) => {
258
373
  break;
259
374
  attempted.add(account.index);
260
375
  let accountAuth = accountManager.toAuthDetails(account);
261
- if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
262
- const refreshed = await refreshAccessToken(account.refreshToken);
263
- if (refreshed.type !== "success") {
264
- accountManager.markAccountCoolingDown(account, AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
265
- await accountManager.saveToDisk();
266
- await showToast(`Auth refresh failed. Cooling down ${formatAccountLabel(account, account.index)}.`, "warning", quietMode);
267
- continue;
268
- }
269
- accountAuth = {
376
+ const tokenExpired = !accountAuth.access || accountAuth.expires <= Date.now();
377
+ const runRefresh = async () => {
378
+ const refreshed = await accountManager.refreshAccountWithFallback(account);
379
+ if (refreshed.type !== "success")
380
+ return refreshed;
381
+ const refreshedAuth = {
270
382
  type: "oauth",
271
383
  access: refreshed.access,
272
384
  refresh: refreshed.refresh,
273
385
  expires: refreshed.expires,
274
386
  };
275
- accountManager.updateFromAuth(account, accountAuth);
387
+ accountManager.updateFromAuth(account, refreshedAuth);
276
388
  await accountManager.saveToDisk();
277
- // Keep OpenCode's stored auth aligned with the last-used account.
278
389
  await client.auth.set({
279
390
  path: { id: PROVIDER_ID },
280
- body: accountAuth,
391
+ body: refreshedAuth,
281
392
  });
393
+ return refreshed;
394
+ };
395
+ if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
396
+ if (proactiveRefreshQueue && !tokenExpired) {
397
+ void proactiveRefreshQueue.enqueue({
398
+ key: `account-${account.index}`,
399
+ expires: accountAuth.expires,
400
+ refresh: runRefresh,
401
+ });
402
+ }
403
+ else {
404
+ const refreshed = await runRefresh();
405
+ if (refreshed.type !== "success") {
406
+ accountManager.markAccountCoolingDown(account, AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
407
+ await accountManager.saveToDisk();
408
+ await showToast(`Auth refresh failed. Cooling down ${formatAccountLabel(account, account.index)}.`, "warning", quietMode);
409
+ continue;
410
+ }
411
+ accountAuth = {
412
+ type: "oauth",
413
+ access: refreshed.access,
414
+ refresh: refreshed.refresh,
415
+ expires: refreshed.expires,
416
+ };
417
+ }
282
418
  }
283
419
  const accountId = account.accountId ?? extractAccountId(accountAuth.access);
284
420
  if (!accountId) {
@@ -331,6 +467,7 @@ export const OpenAIAuthPlugin = async ({ client }) => {
331
467
  if (accountSelectionStrategy === "hybrid") {
332
468
  getHealthTracker().recordSuccess(account.index);
333
469
  }
470
+ accountManager.markAccountUsed(account.index);
334
471
  return await handleSuccessResponse(res, isStreaming);
335
472
  }
336
473
  const handled = await handleErrorResponse(res);
@@ -340,7 +477,25 @@ export const OpenAIAuthPlugin = async ({ client }) => {
340
477
  }
341
478
  return handled;
342
479
  }
343
- const retryAfterMs = parseRetryAfterMs(handled.headers) ?? 60_000;
480
+ const retryAfterMs = parseRetryAfterMs(handled.headers);
481
+ let responseText = "";
482
+ try {
483
+ responseText = await handled.clone().text();
484
+ }
485
+ catch {
486
+ responseText = "";
487
+ }
488
+ const reason = parseRateLimitReason(handled.status, responseText);
489
+ const trackerKey = `${account.index}:${modelFamily}:${model ?? ""}`;
490
+ const backoff = rateLimitTracker.getBackoff(trackerKey, reason, retryAfterMs);
491
+ const decision = decideRateLimitAction({
492
+ schedulingMode,
493
+ accountCount,
494
+ maxCacheFirstWaitMs,
495
+ switchOnFirstRateLimit,
496
+ shortRetryThresholdMs: RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS,
497
+ backoff,
498
+ });
344
499
  if (tokenConsumed) {
345
500
  getTokenTracker().refund(account.index);
346
501
  tokenConsumed = false;
@@ -348,19 +503,27 @@ export const OpenAIAuthPlugin = async ({ client }) => {
348
503
  if (accountSelectionStrategy === "hybrid") {
349
504
  getHealthTracker().recordRateLimit(account.index);
350
505
  }
351
- if (retryAfterMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) {
352
- await showToast(`Rate limited. Retrying in ${formatWaitTime(retryAfterMs)}...`, "warning", quietMode);
353
- await sleep(retryAfterMs);
506
+ accountManager.markRateLimited(account, backoff.delayMs, modelFamily, model);
507
+ const shouldPersistRateLimit = !backoff.isDuplicate;
508
+ if (decision.action === "wait") {
509
+ if (shouldPersistRateLimit) {
510
+ await accountManager.saveToDisk();
511
+ await showToast(`Rate limited. Retrying in ${formatWaitTime(decision.delayMs)}...`, "warning", quietMode);
512
+ }
513
+ if (decision.delayMs > 0) {
514
+ await sleep(decision.delayMs);
515
+ }
354
516
  continue;
355
517
  }
356
- accountManager.markRateLimited(account, retryAfterMs, modelFamily, model);
357
518
  accountManager.markSwitched(account, "rate-limit", modelFamily);
358
- await accountManager.saveToDisk();
359
- await showToast(`Rate limited. Switching accounts (retry in ${formatWaitTime(retryAfterMs)}).`, "warning", quietMode);
519
+ if (shouldPersistRateLimit) {
520
+ await accountManager.saveToDisk();
521
+ await showToast(`Rate limited. Switching accounts (retry in ${formatWaitTime(decision.delayMs)}).`, "warning", quietMode);
522
+ }
360
523
  break;
361
524
  }
362
525
  }
363
- const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
526
+ const waitMs = await accountManager.getMinWaitTimeForFamilyWithHydration(modelFamily, model);
364
527
  if (retryAllAccountsRateLimited &&
365
528
  accountManager.getAccountCount() > 0 &&
366
529
  waitMs > 0 &&
@@ -371,11 +534,11 @@ export const OpenAIAuthPlugin = async ({ client }) => {
371
534
  await sleep(waitMs);
372
535
  continue;
373
536
  }
374
- const storePath = getStoragePath();
375
- const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
376
- const message = accountManager.getAccountCount() === 0
377
- ? "No OpenAI accounts configured. Run `opencode auth login`."
378
- : `All ${accountManager.getAccountCount()} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`opencode auth login\`. (Storage: ${storePath})`;
537
+ const message = formatRateLimitStatusMessage({
538
+ accountCount: accountManager.getAccountCount(),
539
+ waitMs,
540
+ storagePath: getStoragePath(),
541
+ });
379
542
  return new Response(JSON.stringify({ error: { message } }), {
380
543
  status: 429,
381
544
  headers: { "content-type": "application/json; charset=utf-8" },
@@ -404,6 +567,71 @@ export const OpenAIAuthPlugin = async ({ client }) => {
404
567
  const pluginConfig = loadPluginConfig();
405
568
  const quietMode = getQuietMode(pluginConfig);
406
569
  const isCliFlow = Boolean(inputs);
570
+ const notifyRepairResult = async (message) => {
571
+ if (isCliFlow) {
572
+ console.log(`\n${message}\n`);
573
+ return;
574
+ }
575
+ await showToast(message, "info", quietMode);
576
+ };
577
+ const maybeRepairAccounts = async () => {
578
+ const inspection = await inspectAccountsFile();
579
+ if (inspection.status === "missing" || inspection.status === "ok") {
580
+ return null;
581
+ }
582
+ const corruptCount = inspection.status === "corrupt-file"
583
+ ? 1
584
+ : inspection.corruptEntries.length;
585
+ const legacyCount = inspection.status === "needs-repair" ? inspection.legacyEntries.length : 0;
586
+ const shouldRepair = await promptRepairAccounts({
587
+ legacyCount,
588
+ corruptCount,
589
+ });
590
+ if (!shouldRepair)
591
+ return null;
592
+ const quarantinePaths = [];
593
+ if (inspection.status === "corrupt-file") {
594
+ const quarantinePath = await quarantineCorruptFile();
595
+ if (quarantinePath) {
596
+ quarantinePaths.push(quarantinePath);
597
+ await notifyRepairResult(`Accounts file was corrupted. Quarantined to ${quarantinePath}.`);
598
+ }
599
+ return await loadAccounts();
600
+ }
601
+ if (inspection.corruptEntries.length > 0) {
602
+ const quarantinePath = await writeQuarantineFile(inspection.corruptEntries, "corrupt-entry");
603
+ quarantinePaths.push(quarantinePath);
604
+ }
605
+ const storage = await loadAccounts();
606
+ if (!storage) {
607
+ await notifyRepairResult("Repair skipped: no valid accounts found.");
608
+ return null;
609
+ }
610
+ const manager = new AccountManager(undefined, storage);
611
+ const repair = await manager.repairLegacyAccounts();
612
+ const snapshot = manager.getStorageSnapshot();
613
+ let updatedStorage = snapshot;
614
+ if (repair.quarantined.length > 0) {
615
+ const quarantinedTokens = new Set(repair.quarantined.map((account) => account.refreshToken));
616
+ const quarantineEntries = snapshot.accounts.filter((account) => quarantinedTokens.has(account.refreshToken));
617
+ const quarantineResult = await quarantineAccounts(snapshot, quarantineEntries, "legacy-repair-failed");
618
+ updatedStorage = quarantineResult.storage;
619
+ quarantinePaths.push(quarantineResult.quarantinePath);
620
+ }
621
+ else {
622
+ await replaceAccountsFile(snapshot);
623
+ }
624
+ const summaryParts = [
625
+ `Repaired ${repair.repaired.length}`,
626
+ `quarantined ${repair.quarantined.length}`,
627
+ ];
628
+ const detail = quarantinePaths.length
629
+ ? ` Quarantine: ${quarantinePaths.join(", ")}.`
630
+ : "";
631
+ await notifyRepairResult(`Account repair complete. ${summaryParts.join(", ")}.${detail}`);
632
+ return updatedStorage;
633
+ };
634
+ const repairedStorage = await maybeRepairAccounts();
407
635
  // CLI flow (`opencode auth login`) passes inputs; TUI does not.
408
636
  if (isCliFlow) {
409
637
  debugAuth("[OAuthAuthorize] Starting OAuth flow in CLI mode");
@@ -415,8 +643,15 @@ export const OpenAIAuthPlugin = async ({ client }) => {
415
643
  const { pkce, state, url } = await createAuthorizationFlow();
416
644
  console.log("\nOAuth URL:\n" + url + "\n");
417
645
  if (noBrowser) {
418
- const callbackInput = await promptOAuthCallbackValue("Paste the redirect URL (or just the code) here: ");
419
- const parsed = parseAuthorizationInput(callbackInput);
646
+ const callbackInput = await promptOAuthCallbackValue("Paste the full redirect URL (recommended). You can also paste code#state or just the code: ");
647
+ const parsed = parseAuthorizationInputForFlow(callbackInput, state);
648
+ if (parsed.stateStatus === "mismatch") {
649
+ console.log("\nOAuth state mismatch. Paste the redirect URL from this login session (the one shown above).\n");
650
+ return { type: "failed" };
651
+ }
652
+ if (parsed.stateStatus === "missing") {
653
+ console.log("\nWarning: redirect state not provided. For best security, paste the full redirect URL.\n");
654
+ }
420
655
  if (!parsed.code)
421
656
  return { type: "failed" };
422
657
  return await exchangeAuthorizationCode(parsed.code, pkce.verifier, REDIRECT_URI);
@@ -431,8 +666,15 @@ export const OpenAIAuthPlugin = async ({ client }) => {
431
666
  openBrowserUrl(url);
432
667
  if (!serverInfo || !serverInfo.ready) {
433
668
  serverInfo?.close();
434
- const callbackInput = await promptOAuthCallbackValue("Paste the redirect URL (or just the code) here: ");
435
- const parsed = parseAuthorizationInput(callbackInput);
669
+ const callbackInput = await promptOAuthCallbackValue("Paste the full redirect URL (recommended). You can also paste code#state or just the code: ");
670
+ const parsed = parseAuthorizationInputForFlow(callbackInput, state);
671
+ if (parsed.stateStatus === "mismatch") {
672
+ console.log("\nOAuth state mismatch. Paste the redirect URL from this login session (the one shown above).\n");
673
+ return { type: "failed" };
674
+ }
675
+ if (parsed.stateStatus === "missing") {
676
+ console.log("\nWarning: redirect state not provided. For best security, paste the full redirect URL.\n");
677
+ }
436
678
  if (!parsed.code)
437
679
  return { type: "failed" };
438
680
  return await exchangeAuthorizationCode(parsed.code, pkce.verifier, REDIRECT_URI);
@@ -445,9 +687,9 @@ export const OpenAIAuthPlugin = async ({ client }) => {
445
687
  };
446
688
  const authenticated = [];
447
689
  let startFresh = true;
448
- let existingStorage = await loadAccounts();
690
+ let existingStorage = repairedStorage ?? (await loadAccounts());
449
691
  if (existingStorage && existingStorage.accounts.length > 0) {
450
- const needsHydration = existingStorage.accounts.some((a) => !a.email);
692
+ const needsHydration = needsIdentityHydration(existingStorage.accounts);
451
693
  if (needsHydration) {
452
694
  try {
453
695
  console.log("\nRefreshing saved accounts to fill missing emails...\n");
@@ -460,13 +702,43 @@ export const OpenAIAuthPlugin = async ({ client }) => {
460
702
  // Best-effort; ignore.
461
703
  }
462
704
  }
463
- const existingLabels = (existingStorage?.accounts ?? []).map((a, index) => ({
705
+ let existingLabels = (existingStorage?.accounts ?? []).map((a, index) => ({
464
706
  index,
465
707
  email: a.email,
466
708
  plan: a.plan,
467
709
  accountId: a.accountId,
710
+ enabled: a.enabled,
468
711
  }));
469
- const mode = await promptLoginMode(existingLabels);
712
+ let mode = await promptLoginMode(existingLabels);
713
+ while (mode === "manage") {
714
+ let updatedStorage = existingStorage;
715
+ while (updatedStorage) {
716
+ const labels = (updatedStorage?.accounts ?? []).map((a, index) => ({
717
+ index,
718
+ email: a.email,
719
+ plan: a.plan,
720
+ accountId: a.accountId,
721
+ enabled: a.enabled,
722
+ }));
723
+ const toggleIndex = await promptManageAccounts(labels);
724
+ if (toggleIndex === null)
725
+ break;
726
+ const toggled = toggleAccountEnabled(updatedStorage, toggleIndex);
727
+ if (toggled) {
728
+ await saveAccounts(toggled);
729
+ updatedStorage = toggled;
730
+ }
731
+ }
732
+ existingStorage = await loadAccounts();
733
+ existingLabels = (existingStorage?.accounts ?? []).map((a, index) => ({
734
+ index,
735
+ email: a.email,
736
+ plan: a.plan,
737
+ accountId: a.accountId,
738
+ enabled: a.enabled,
739
+ }));
740
+ mode = await promptLoginMode(existingLabels);
741
+ }
470
742
  startFresh = mode === "fresh";
471
743
  }
472
744
  if (startFresh) {
@@ -528,7 +800,7 @@ export const OpenAIAuthPlugin = async ({ client }) => {
528
800
  process.env.SSH_TTY ||
529
801
  process.env.OPENCODE_HEADLESS);
530
802
  const useManualFlow = isHeadless || process.env.OPENCODE_NO_BROWSER === "1";
531
- const existingStorage = await loadAccounts();
803
+ const existingStorage = repairedStorage ?? (await loadAccounts());
532
804
  const existingCount = existingStorage?.accounts.length ?? 0;
533
805
  const { pkce, state, url } = await createAuthorizationFlow();
534
806
  let serverInfo = null;
@@ -574,10 +846,14 @@ export const OpenAIAuthPlugin = async ({ client }) => {
574
846
  serverInfo?.close();
575
847
  return {
576
848
  url,
577
- instructions: "Visit the URL above, complete OAuth, then paste either the full redirect URL or the authorization code.",
849
+ instructions: "Visit the URL above, complete OAuth, then paste the full redirect URL (recommended) or the authorization code.",
578
850
  method: "code",
579
851
  callback: async (input) => {
580
- const parsed = parseAuthorizationInput(input);
852
+ const parsed = parseAuthorizationInputForFlow(input, state);
853
+ if (parsed.stateStatus === "mismatch") {
854
+ await showToast("OAuth state mismatch. Paste the redirect URL from this login session.", "error", quietMode);
855
+ return { type: "failed" };
856
+ }
581
857
  if (!parsed.code)
582
858
  return { type: "failed" };
583
859
  const tokens = await exchangeAuthorizationCode(parsed.code, pkce.verifier, REDIRECT_URI);
@@ -603,8 +879,8 @@ export const OpenAIAuthPlugin = async ({ client }) => {
603
879
  label: AUTH_LABELS.OAUTH_MANUAL,
604
880
  type: "oauth",
605
881
  authorize: async () => {
606
- const { pkce, url } = await createAuthorizationFlow();
607
- return buildManualOAuthFlow(pkce, url, async (tokens) => {
882
+ const { pkce, state, url } = await createAuthorizationFlow();
883
+ return buildManualOAuthFlow(pkce, state, url, async (tokens) => {
608
884
  await persistAccount(tokens);
609
885
  });
610
886
  },
@@ -647,6 +923,8 @@ export const OpenAIAuthPlugin = async ({ client }) => {
647
923
  const statuses = [];
648
924
  if (index === activeIndex)
649
925
  statuses.push("active");
926
+ if (account.enabled === false)
927
+ statuses.push("disabled");
650
928
  const rateLimited = account.rateLimitResetTimes &&
651
929
  Object.values(account.rateLimitResetTimes).some((t) => typeof t === "number" && t > now);
652
930
  if (rateLimited)
@@ -693,6 +971,36 @@ export const OpenAIAuthPlugin = async ({ client }) => {
693
971
  return `Switched to ${formatAccountLabel(account, targetIndex)}`;
694
972
  },
695
973
  }),
974
+ "openai-accounts-toggle": tool({
975
+ description: "Enable or disable an OpenAI account by index (1-based).",
976
+ args: {
977
+ index: tool.schema.number().describe("Account number (1-based)"),
978
+ },
979
+ async execute({ index }) {
980
+ const storage = await loadAccounts();
981
+ if (!storage || storage.accounts.length === 0) {
982
+ return "No OpenAI accounts configured. Run: opencode auth login";
983
+ }
984
+ const targetIndex = Math.floor((index ?? 0) - 1);
985
+ if (targetIndex < 0 || targetIndex >= storage.accounts.length) {
986
+ return `Invalid account number: ${index}\nValid range: 1-${storage.accounts.length}`;
987
+ }
988
+ const updated = toggleAccountEnabled(storage, targetIndex);
989
+ if (!updated) {
990
+ return `Failed to toggle account number: ${index}`;
991
+ }
992
+ await saveAccounts(updated);
993
+ const account = updated.accounts[targetIndex];
994
+ if (cachedAccountManager) {
995
+ const live = cachedAccountManager.getAccountByIndex(targetIndex);
996
+ if (live)
997
+ live.enabled = account?.enabled !== false;
998
+ }
999
+ const enabled = account?.enabled !== false;
1000
+ const verb = enabled ? "Enabled" : "Disabled";
1001
+ return `${verb} ${formatAccountLabel(account, targetIndex)} (${targetIndex + 1}/${updated.accounts.length})`;
1002
+ },
1003
+ }),
696
1004
  },
697
1005
  };
698
1006
  };