oc-chatgpt-multi-auth 4.12.1 → 4.12.3

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 (119) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +414 -303
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/accounts/rate-limits.d.ts +22 -0
  5. package/dist/lib/accounts/rate-limits.d.ts.map +1 -0
  6. package/dist/lib/accounts/rate-limits.js +63 -0
  7. package/dist/lib/accounts/rate-limits.js.map +1 -0
  8. package/dist/lib/accounts.d.ts +10 -66
  9. package/dist/lib/accounts.d.ts.map +1 -1
  10. package/dist/lib/accounts.js +39 -340
  11. package/dist/lib/accounts.js.map +1 -1
  12. package/dist/lib/audit.js +1 -1
  13. package/dist/lib/audit.js.map +1 -1
  14. package/dist/lib/auth/browser.d.ts.map +1 -1
  15. package/dist/lib/auth/browser.js +9 -2
  16. package/dist/lib/auth/browser.js.map +1 -1
  17. package/dist/lib/auth/server.d.ts.map +1 -1
  18. package/dist/lib/auth/server.js +13 -2
  19. package/dist/lib/auth/server.js.map +1 -1
  20. package/dist/lib/auth/token-utils.d.ts +42 -0
  21. package/dist/lib/auth/token-utils.d.ts.map +1 -0
  22. package/dist/lib/auth/token-utils.js +288 -0
  23. package/dist/lib/auth/token-utils.js.map +1 -0
  24. package/dist/lib/circuit-breaker.d.ts.map +1 -1
  25. package/dist/lib/circuit-breaker.js +7 -0
  26. package/dist/lib/circuit-breaker.js.map +1 -1
  27. package/dist/lib/cli.d.ts +6 -0
  28. package/dist/lib/cli.d.ts.map +1 -1
  29. package/dist/lib/cli.js +27 -1
  30. package/dist/lib/cli.js.map +1 -1
  31. package/dist/lib/config.d.ts +2 -0
  32. package/dist/lib/config.d.ts.map +1 -1
  33. package/dist/lib/config.js +9 -0
  34. package/dist/lib/config.js.map +1 -1
  35. package/dist/lib/errors.d.ts +113 -0
  36. package/dist/lib/errors.d.ts.map +1 -0
  37. package/dist/lib/errors.js +103 -0
  38. package/dist/lib/errors.js.map +1 -0
  39. package/dist/lib/health.d.ts +33 -0
  40. package/dist/lib/health.d.ts.map +1 -0
  41. package/dist/lib/health.js +70 -0
  42. package/dist/lib/health.js.map +1 -0
  43. package/dist/lib/index.d.ts +5 -0
  44. package/dist/lib/index.d.ts.map +1 -1
  45. package/dist/lib/index.js +5 -0
  46. package/dist/lib/index.js.map +1 -1
  47. package/dist/lib/logger.d.ts.map +1 -1
  48. package/dist/lib/logger.js +10 -3
  49. package/dist/lib/logger.js.map +1 -1
  50. package/dist/lib/parallel-probe.d.ts +28 -0
  51. package/dist/lib/parallel-probe.d.ts.map +1 -0
  52. package/dist/lib/parallel-probe.js +97 -0
  53. package/dist/lib/parallel-probe.js.map +1 -0
  54. package/dist/lib/prompts/codex.d.ts +5 -0
  55. package/dist/lib/prompts/codex.d.ts.map +1 -1
  56. package/dist/lib/prompts/codex.js +22 -5
  57. package/dist/lib/prompts/codex.js.map +1 -1
  58. package/dist/lib/recovery/storage.d.ts.map +1 -1
  59. package/dist/lib/recovery/storage.js +11 -3
  60. package/dist/lib/recovery/storage.js.map +1 -1
  61. package/dist/lib/recovery.js +4 -4
  62. package/dist/lib/recovery.js.map +1 -1
  63. package/dist/lib/refresh-queue.d.ts.map +1 -1
  64. package/dist/lib/refresh-queue.js +3 -1
  65. package/dist/lib/refresh-queue.js.map +1 -1
  66. package/dist/lib/request/fetch-helpers.d.ts +2 -1
  67. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  68. package/dist/lib/request/fetch-helpers.js +23 -9
  69. package/dist/lib/request/fetch-helpers.js.map +1 -1
  70. package/dist/lib/request/helpers/input-utils.d.ts.map +1 -1
  71. package/dist/lib/request/helpers/input-utils.js +1 -0
  72. package/dist/lib/request/helpers/input-utils.js.map +1 -1
  73. package/dist/lib/request/helpers/model-map.d.ts.map +1 -1
  74. package/dist/lib/request/helpers/model-map.js +1 -3
  75. package/dist/lib/request/helpers/model-map.js.map +1 -1
  76. package/dist/lib/request/helpers/tool-utils.js +5 -1
  77. package/dist/lib/request/helpers/tool-utils.js.map +1 -1
  78. package/dist/lib/request/rate-limit-backoff.d.ts.map +1 -1
  79. package/dist/lib/request/rate-limit-backoff.js +9 -0
  80. package/dist/lib/request/rate-limit-backoff.js.map +1 -1
  81. package/dist/lib/request/request-transformer.d.ts.map +1 -1
  82. package/dist/lib/request/request-transformer.js +2 -1
  83. package/dist/lib/request/request-transformer.js.map +1 -1
  84. package/dist/lib/request/response-handler.d.ts.map +1 -1
  85. package/dist/lib/request/response-handler.js +15 -4
  86. package/dist/lib/request/response-handler.js.map +1 -1
  87. package/dist/lib/rotation.d.ts +6 -0
  88. package/dist/lib/rotation.d.ts.map +1 -1
  89. package/dist/lib/rotation.js +48 -5
  90. package/dist/lib/rotation.js.map +1 -1
  91. package/dist/lib/schemas.d.ts +2 -0
  92. package/dist/lib/schemas.d.ts.map +1 -1
  93. package/dist/lib/schemas.js +2 -0
  94. package/dist/lib/schemas.js.map +1 -1
  95. package/dist/lib/shutdown.d.ts +7 -0
  96. package/dist/lib/shutdown.d.ts.map +1 -0
  97. package/dist/lib/shutdown.js +43 -0
  98. package/dist/lib/shutdown.js.map +1 -0
  99. package/dist/lib/storage/migrations.d.ts +49 -0
  100. package/dist/lib/storage/migrations.d.ts.map +1 -0
  101. package/dist/lib/storage/migrations.js +44 -0
  102. package/dist/lib/storage/migrations.js.map +1 -0
  103. package/dist/lib/storage/paths.d.ts +10 -0
  104. package/dist/lib/storage/paths.d.ts.map +1 -0
  105. package/dist/lib/storage/paths.js +49 -0
  106. package/dist/lib/storage/paths.js.map +1 -0
  107. package/dist/lib/storage.d.ts +4 -44
  108. package/dist/lib/storage.d.ts.map +1 -1
  109. package/dist/lib/storage.js +32 -85
  110. package/dist/lib/storage.js.map +1 -1
  111. package/dist/lib/table-formatter.d.ts +32 -0
  112. package/dist/lib/table-formatter.d.ts.map +1 -0
  113. package/dist/lib/table-formatter.js +44 -0
  114. package/dist/lib/table-formatter.js.map +1 -0
  115. package/dist/lib/utils.d.ts +29 -0
  116. package/dist/lib/utils.d.ts.map +1 -0
  117. package/dist/lib/utils.js +54 -0
  118. package/dist/lib/utils.js.map +1 -0
  119. package/package.json +4 -1
package/dist/index.js CHANGED
@@ -27,17 +27,18 @@ import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationI
27
27
  import { queuedRefresh } from "./lib/refresh-queue.js";
28
28
  import { openBrowserUrl } from "./lib/auth/browser.js";
29
29
  import { startLocalOAuthServer } from "./lib/auth/server.js";
30
- import { promptAccountSelection, promptLoginMode } from "./lib/cli.js";
30
+ import { promptLoginMode } from "./lib/cli.js";
31
31
  import { getCodexMode, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getToastDurationMs, getPerProjectAccounts, loadPluginConfig, } from "./lib/config.js";
32
32
  import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, ACCOUNT_LIMITS, } from "./lib/constants.js";
33
33
  import { initLogger, logRequest, logDebug, logInfo, logWarn, logError } from "./lib/logger.js";
34
34
  import { checkAndNotify } from "./lib/auto-update-checker.js";
35
35
  import { handleContextOverflow } from "./lib/context-overflow.js";
36
- import { AccountManager, getAccountIdCandidates, extractAccountEmail, extractAccountId, formatAccountLabel, formatCooldown, formatWaitTime, sanitizeEmail, shouldUpdateAccountIdFromToken, } from "./lib/accounts.js";
36
+ import { AccountManager, getAccountIdCandidates, extractAccountEmail, extractAccountId, formatAccountLabel, formatCooldown, formatWaitTime, sanitizeEmail, shouldUpdateAccountIdFromToken, parseRateLimitReason, } from "./lib/accounts.js";
37
37
  import { getStoragePath, loadAccounts, saveAccounts, setStoragePath, exportAccounts, importAccounts, StorageError, formatStorageErrorHint } from "./lib/storage.js";
38
38
  import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
39
39
  import { getRateLimitBackoff, RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS, resetRateLimitBackoff, } from "./lib/request/rate-limit-backoff.js";
40
40
  import { addJitter } from "./lib/rotation.js";
41
+ import { buildTableHeader, buildTableRow } from "./lib/table-formatter.js";
41
42
  import { getModelFamily, MODEL_FAMILIES } from "./lib/prompts/codex.js";
42
43
  import { createSessionRecoveryHook, isRecoverableError, detectErrorType, getRecoveryToastContent, } from "./lib/recovery.js";
43
44
  /**
@@ -56,10 +57,14 @@ import { createSessionRecoveryHook, isRecoverableError, detectErrorType, getReco
56
57
  * }
57
58
  * ```
58
59
  */
60
+ // eslint-disable-next-line @typescript-eslint/require-await
59
61
  export const OpenAIOAuthPlugin = async ({ client }) => {
60
62
  initLogger(client);
61
63
  let cachedAccountManager = null;
62
- const resolveAccountSelection = async (tokens) => {
64
+ let accountManagerPromise = null;
65
+ let loaderMutex = null;
66
+ const MIN_BACKOFF_MS = 100;
67
+ const resolveAccountSelection = (tokens) => {
63
68
  const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim();
64
69
  if (override) {
65
70
  const suffix = override.length > 6 ? override.slice(-6) : override;
@@ -76,28 +81,31 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
76
81
  return tokens;
77
82
  }
78
83
  if (candidates.length === 1) {
79
- const candidate = candidates[0];
80
- return {
81
- ...tokens,
82
- accountIdOverride: candidate.accountId,
83
- accountIdSource: candidate.source,
84
- accountLabel: candidate.label,
85
- };
84
+ const [candidate] = candidates;
85
+ if (candidate) {
86
+ return {
87
+ ...tokens,
88
+ accountIdOverride: candidate.accountId,
89
+ accountIdSource: candidate.source,
90
+ accountLabel: candidate.label,
91
+ };
92
+ }
86
93
  }
87
- const defaultIndex = (() => {
88
- const orgDefaultIndex = candidates.findIndex((candidate) => candidate.isDefault && candidate.source === "org");
89
- if (orgDefaultIndex >= 0)
90
- return orgDefaultIndex;
91
- const tokenIndex = candidates.findIndex((candidate) => candidate.source === "token");
92
- if (tokenIndex >= 0)
93
- return tokenIndex;
94
- return 0;
94
+ // Auto-select the default candidate without prompting
95
+ // The user already selected their workspace on OpenAI's auth page
96
+ // Prompting again breaks TUI mode and is redundant
97
+ const choice = (() => {
98
+ // Prefer org-level default
99
+ const orgDefault = candidates.find((candidate) => candidate.isDefault && candidate.source === "org");
100
+ if (orgDefault)
101
+ return orgDefault;
102
+ // Fall back to token-derived account
103
+ const tokenAccount = candidates.find((candidate) => candidate.source === "token");
104
+ if (tokenAccount)
105
+ return tokenAccount;
106
+ // Otherwise first candidate
107
+ return candidates[0];
95
108
  })();
96
- const selected = await promptAccountSelection(candidates, {
97
- defaultIndex,
98
- title: "Multiple workspaces detected for this account:",
99
- });
100
- const choice = selected ?? candidates[defaultIndex];
101
109
  if (!choice)
102
110
  return tokens;
103
111
  return {
@@ -111,6 +119,13 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
111
119
  url,
112
120
  method: "code",
113
121
  instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL,
122
+ validate: (input) => {
123
+ const parsed = parseAuthorizationInput(input);
124
+ if (!parsed.code) {
125
+ return "No authorization code found. Paste the full callback URL (e.g., http://localhost:1455/auth/callback?code=...)";
126
+ }
127
+ return undefined;
128
+ },
114
129
  callback: async (input) => {
115
130
  const parsed = parseAuthorizationInput(input);
116
131
  if (!parsed.code) {
@@ -118,7 +133,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
118
133
  }
119
134
  const tokens = await exchangeAuthorizationCode(parsed.code, pkce.verifier, REDIRECT_URI);
120
135
  if (tokens?.type === "success") {
121
- const resolved = await resolveAccountSelection(tokens);
136
+ const resolved = resolveAccountSelection(tokens);
122
137
  if (onSuccess) {
123
138
  await onSuccess(resolved);
124
139
  }
@@ -302,7 +317,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
302
317
  process.env.OPENCODE_SKIP_EMAIL_HYDRATE === "1";
303
318
  if (skipHydrate)
304
319
  return storage;
305
- const accountsToHydrate = storage.accounts.filter((account) => account && !account.email);
320
+ const accountsCopy = storage.accounts.map((account) => account ? { ...account } : account);
321
+ const accountsToHydrate = accountsCopy.filter((account) => account && !account.email);
306
322
  if (accountsToHydrate.length === 0)
307
323
  return storage;
308
324
  let changed = false;
@@ -330,10 +346,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
330
346
  }
331
347
  }
332
348
  catch {
333
- logDebug(`[${PLUGIN_NAME}] Failed to hydrate email for account`);
349
+ logWarn(`[${PLUGIN_NAME}] Failed to hydrate email for account`);
334
350
  }
335
351
  }));
336
352
  if (changed) {
353
+ storage.accounts = accountsCopy;
337
354
  await saveAccounts(storage);
338
355
  }
339
356
  return storage;
@@ -368,29 +385,34 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
368
385
  };
369
386
  // Event handler for session recovery and account selection
370
387
  const eventHandler = async (input) => {
371
- const { event } = input;
372
- // Handle TUI account selection events
373
- // Accepts generic selection events with an index property
374
- if (event.type === "account.select" ||
375
- event.type === "openai.account.select") {
376
- const props = event.properties;
377
- // Filter by provider if specified
378
- if (props.provider && props.provider !== "openai" && props.provider !== PROVIDER_ID) {
379
- return;
380
- }
381
- const index = props.index ?? props.accountIndex;
382
- if (typeof index === "number" && cachedAccountManager) {
383
- // Convert 1-based index (UI) to 0-based index (internal) if needed,
384
- // or handle 0-based directly. Usually UI lists are 0-based in code but 1-based in display.
385
- // AccountManager.setActiveIndex expects 0-based index.
386
- // Assuming the event passes the raw index from the list.
387
- const account = cachedAccountManager.setActiveIndex(index);
388
- if (account) {
389
- await cachedAccountManager.saveToDisk();
390
- await showToast(`Switched to account ${index + 1}`, "info");
388
+ try {
389
+ const { event } = input;
390
+ // Handle TUI account selection events
391
+ // Accepts generic selection events with an index property
392
+ if (event.type === "account.select" ||
393
+ event.type === "openai.account.select") {
394
+ const props = event.properties;
395
+ // Filter by provider if specified
396
+ if (props.provider && props.provider !== "openai" && props.provider !== PROVIDER_ID) {
397
+ return;
398
+ }
399
+ const index = props.index ?? props.accountIndex;
400
+ if (typeof index === "number" && cachedAccountManager) {
401
+ // Convert 1-based index (UI) to 0-based index (internal) if needed,
402
+ // or handle 0-based directly. Usually UI lists are 0-based in code but 1-based in display.
403
+ // AccountManager.setActiveIndex expects 0-based index.
404
+ // Assuming the event passes the raw index from the list.
405
+ const account = cachedAccountManager.setActiveIndex(index);
406
+ if (account) {
407
+ await cachedAccountManager.saveToDisk();
408
+ await showToast(`Switched to account ${index + 1}`, "info");
409
+ }
391
410
  }
392
411
  }
393
412
  }
413
+ catch (error) {
414
+ logDebug(`[${PLUGIN_NAME}] Event handler error: ${error instanceof Error ? error.message : String(error)}`);
415
+ }
394
416
  };
395
417
  return {
396
418
  event: eventHandler,
@@ -423,270 +445,332 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
423
445
  logDebug(`[${PLUGIN_NAME}] Auth is not multi-account, skipping loader`);
424
446
  return {};
425
447
  }
426
- const accountManager = await AccountManager.loadFromDisk(auth);
427
- cachedAccountManager = accountManager;
428
- const refreshToken = auth.type === "oauth" ? auth.refresh : "";
429
- const needsPersist = refreshToken &&
430
- !accountManager.hasRefreshToken(refreshToken);
431
- if (needsPersist) {
432
- await accountManager.saveToDisk();
448
+ // Acquire mutex for thread-safe initialization
449
+ // Use while loop to handle multiple concurrent waiters correctly
450
+ while (loaderMutex) {
451
+ await loaderMutex;
433
452
  }
434
- if (accountManager.getAccountCount() === 0) {
435
- logDebug(`[${PLUGIN_NAME}] No OAuth accounts available (run opencode auth login)`);
436
- return {};
437
- }
438
- // Extract user configuration (global + per-model options)
439
- const providerConfig = provider;
440
- const userConfig = {
441
- global: providerConfig?.options || {},
442
- models: providerConfig?.models || {},
443
- };
444
- // Load plugin configuration and determine CODEX_MODE
445
- // Priority: CODEX_MODE env var > config file > default (true)
446
- const pluginConfig = loadPluginConfig();
447
- const codexMode = getCodexMode(pluginConfig);
448
- const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
449
- const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
450
- const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig);
451
- const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
452
- const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
453
- const toastDurationMs = getToastDurationMs(pluginConfig);
454
- const perProjectAccounts = getPerProjectAccounts(pluginConfig);
455
- if (perProjectAccounts) {
456
- setStoragePath(process.cwd());
457
- }
458
- const sessionRecoveryEnabled = getSessionRecovery(pluginConfig);
459
- const autoResumeEnabled = getAutoResume(pluginConfig);
460
- const recoveryHook = sessionRecoveryEnabled
461
- ? createSessionRecoveryHook({ client, directory: process.cwd() }, { sessionRecovery: true, autoResume: autoResumeEnabled })
462
- : null;
463
- checkAndNotify(async (message, variant) => {
464
- await showToast(message, variant);
465
- }).catch(() => { });
466
- // Return SDK configuration
467
- return {
468
- apiKey: DUMMY_API_KEY,
469
- baseURL: CODEX_BASE_URL,
470
- /**
471
- * Custom fetch implementation for Codex API
472
- *
473
- * Handles:
474
- * - Token refresh when expired
475
- * - URL rewriting for Codex backend
476
- * - Request body transformation
477
- * - OAuth header injection
478
- * - SSE to JSON conversion for non-tool requests
479
- * - Error handling and logging
480
- *
481
- * @param input - Request URL or Request object
482
- * @param init - Request options
483
- * @returns Response from Codex API
484
- */
485
- async fetch(input, init) {
486
- // Step 1: Extract and rewrite URL for Codex backend
487
- const originalUrl = extractRequestUrl(input);
488
- const url = rewriteUrlForCodex(originalUrl);
489
- // Step 3: Transform request body with model-specific Codex instructions
490
- // Instructions are fetched per model family (codex-max, codex, gpt-5.1)
491
- // Capture original stream value before transformation
492
- // generateText() sends no stream field, streamText() sends stream=true
493
- const originalBody = init?.body ? JSON.parse(init.body) : {};
494
- const isStreaming = originalBody.stream === true;
495
- const transformation = await transformRequestForCodex(init, url, userConfig, codexMode);
496
- const requestInit = transformation?.updatedInit ?? init;
497
- const promptCacheKey = transformation?.body?.prompt_cache_key;
498
- const model = transformation?.body.model;
499
- const modelFamily = model ? getModelFamily(model) : "gpt-5.1";
500
- const quotaKey = model ? `${modelFamily}:${model}` : modelFamily;
501
- const abortSignal = requestInit?.signal ?? init?.signal ?? null;
502
- const sleep = (ms) => new Promise((resolve, reject) => {
503
- if (abortSignal?.aborted) {
504
- reject(new Error("Aborted"));
505
- return;
506
- }
507
- const timeout = setTimeout(() => {
508
- cleanup();
509
- resolve();
510
- }, ms);
511
- const onAbort = () => {
512
- cleanup();
513
- reject(new Error("Aborted"));
514
- };
515
- const cleanup = () => {
516
- clearTimeout(timeout);
517
- abortSignal?.removeEventListener("abort", onAbort);
518
- };
519
- abortSignal?.addEventListener("abort", onAbort, { once: true });
520
- });
521
- const sleepWithCountdown = async (totalMs, message, intervalMs = 5000) => {
522
- const startTime = Date.now();
523
- const endTime = startTime + totalMs;
524
- while (Date.now() < endTime) {
525
- if (abortSignal?.aborted) {
526
- throw new Error("Aborted");
527
- }
528
- const remaining = Math.max(0, endTime - Date.now());
529
- const waitLabel = formatWaitTime(remaining);
530
- await showToast(`${message} (${waitLabel} remaining)`, "warning", { duration: Math.min(intervalMs + 1000, toastDurationMs) });
531
- const sleepTime = Math.min(intervalMs, remaining);
532
- if (sleepTime > 0) {
533
- await sleep(sleepTime);
453
+ let resolveMutex;
454
+ loaderMutex = new Promise((resolve) => {
455
+ resolveMutex = resolve;
456
+ });
457
+ try {
458
+ if (!accountManagerPromise) {
459
+ accountManagerPromise = AccountManager.loadFromDisk(auth);
460
+ }
461
+ const accountManager = await accountManagerPromise;
462
+ cachedAccountManager = accountManager;
463
+ const refreshToken = auth.type === "oauth" ? auth.refresh : "";
464
+ const needsPersist = refreshToken &&
465
+ !accountManager.hasRefreshToken(refreshToken);
466
+ if (needsPersist) {
467
+ await accountManager.saveToDisk();
468
+ }
469
+ if (accountManager.getAccountCount() === 0) {
470
+ logDebug(`[${PLUGIN_NAME}] No OAuth accounts available (run opencode auth login)`);
471
+ return {};
472
+ }
473
+ // Extract user configuration (global + per-model options)
474
+ const providerConfig = provider;
475
+ const userConfig = {
476
+ global: providerConfig?.options || {},
477
+ models: providerConfig?.models || {},
478
+ };
479
+ // Load plugin configuration and determine CODEX_MODE
480
+ // Priority: CODEX_MODE env var > config file > default (true)
481
+ const pluginConfig = loadPluginConfig();
482
+ const codexMode = getCodexMode(pluginConfig);
483
+ const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
484
+ const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
485
+ const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig);
486
+ const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
487
+ const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
488
+ const toastDurationMs = getToastDurationMs(pluginConfig);
489
+ const perProjectAccounts = getPerProjectAccounts(pluginConfig);
490
+ if (perProjectAccounts) {
491
+ setStoragePath(process.cwd());
492
+ }
493
+ const sessionRecoveryEnabled = getSessionRecovery(pluginConfig);
494
+ const autoResumeEnabled = getAutoResume(pluginConfig);
495
+ const recoveryHook = sessionRecoveryEnabled
496
+ ? createSessionRecoveryHook({ client, directory: process.cwd() }, { sessionRecovery: true, autoResume: autoResumeEnabled })
497
+ : null;
498
+ checkAndNotify(async (message, variant) => {
499
+ await showToast(message, variant);
500
+ }).catch((err) => {
501
+ logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`);
502
+ });
503
+ // Return SDK configuration
504
+ return {
505
+ apiKey: DUMMY_API_KEY,
506
+ baseURL: CODEX_BASE_URL,
507
+ /**
508
+ * Custom fetch implementation for Codex API
509
+ *
510
+ * Handles:
511
+ * - Token refresh when expired
512
+ * - URL rewriting for Codex backend
513
+ * - Request body transformation
514
+ * - OAuth header injection
515
+ * - SSE to JSON conversion for non-tool requests
516
+ * - Error handling and logging
517
+ *
518
+ * @param input - Request URL or Request object
519
+ * @param init - Request options
520
+ * @returns Response from Codex API
521
+ */
522
+ async fetch(input, init) {
523
+ // Step 1: Extract and rewrite URL for Codex backend
524
+ const originalUrl = extractRequestUrl(input);
525
+ const url = rewriteUrlForCodex(originalUrl);
526
+ // Step 3: Transform request body with model-specific Codex instructions
527
+ // Instructions are fetched per model family (codex-max, codex, gpt-5.1)
528
+ // Capture original stream value before transformation
529
+ // generateText() sends no stream field, streamText() sends stream=true
530
+ let originalBody = {};
531
+ if (init?.body) {
532
+ try {
533
+ originalBody = JSON.parse(init.body);
534
534
  }
535
- else {
536
- break;
535
+ catch {
536
+ logWarn("Failed to parse request body, using empty object");
537
537
  }
538
538
  }
539
- };
540
- let allRateLimitedRetries = 0;
541
- while (true) {
542
- const accountCount = accountManager.getAccountCount();
543
- const attempted = new Set();
544
- while (attempted.size < Math.max(1, accountCount)) {
545
- const account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model);
546
- if (!account || attempted.has(account.index)) {
547
- break;
539
+ const isStreaming = originalBody.stream === true;
540
+ const transformation = await transformRequestForCodex(init, url, userConfig, codexMode, originalBody);
541
+ const requestInit = transformation?.updatedInit ?? init;
542
+ const promptCacheKey = transformation?.body?.prompt_cache_key;
543
+ const model = transformation?.body.model;
544
+ const modelFamily = model ? getModelFamily(model) : "gpt-5.1";
545
+ const quotaKey = model ? `${modelFamily}:${model}` : modelFamily;
546
+ const abortSignal = requestInit?.signal ?? init?.signal ?? null;
547
+ const sleep = (ms) => new Promise((resolve, reject) => {
548
+ if (abortSignal?.aborted) {
549
+ reject(new Error("Aborted"));
550
+ return;
548
551
  }
549
- attempted.add(account.index);
550
- // Log account selection for debugging rotation
551
- logDebug(`Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`);
552
- let accountAuth = accountManager.toAuthDetails(account);
553
- try {
554
- if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
555
- accountAuth = (await refreshAndUpdateToken(accountAuth, client));
556
- accountManager.updateFromAuth(account, accountAuth);
557
- accountManager.clearAuthFailures(account);
558
- accountManager.saveToDiskDebounced();
552
+ const timeout = setTimeout(() => {
553
+ cleanup();
554
+ resolve();
555
+ }, ms);
556
+ const onAbort = () => {
557
+ cleanup();
558
+ reject(new Error("Aborted"));
559
+ };
560
+ const cleanup = () => {
561
+ clearTimeout(timeout);
562
+ abortSignal?.removeEventListener("abort", onAbort);
563
+ };
564
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
565
+ });
566
+ const sleepWithCountdown = async (totalMs, message, intervalMs = 5000) => {
567
+ const startTime = Date.now();
568
+ const endTime = startTime + totalMs;
569
+ while (Date.now() < endTime) {
570
+ if (abortSignal?.aborted) {
571
+ throw new Error("Aborted");
572
+ }
573
+ const remaining = Math.max(0, endTime - Date.now());
574
+ const waitLabel = formatWaitTime(remaining);
575
+ await showToast(`${message} (${waitLabel} remaining)`, "warning", { duration: Math.min(intervalMs + 1000, toastDurationMs) });
576
+ const sleepTime = Math.min(intervalMs, remaining);
577
+ if (sleepTime > 0) {
578
+ await sleep(sleepTime);
579
+ }
580
+ else {
581
+ break;
559
582
  }
560
583
  }
561
- catch (err) {
562
- logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${err?.message ?? String(err)}`);
563
- const failures = accountManager.incrementAuthFailures(account);
564
- const accountLabel = formatAccountLabel(account, account.index);
565
- if (failures >= ACCOUNT_LIMITS.MAX_AUTH_FAILURES_BEFORE_REMOVAL) {
566
- accountManager.removeAccount(account);
584
+ };
585
+ let allRateLimitedRetries = 0;
586
+ while (true) {
587
+ const accountCount = accountManager.getAccountCount();
588
+ const attempted = new Set();
589
+ while (attempted.size < Math.max(1, accountCount)) {
590
+ const account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model);
591
+ if (!account || attempted.has(account.index)) {
592
+ break;
593
+ }
594
+ attempted.add(account.index);
595
+ // Log account selection for debugging rotation
596
+ logDebug(`Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`);
597
+ let accountAuth = accountManager.toAuthDetails(account);
598
+ try {
599
+ if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
600
+ accountAuth = (await refreshAndUpdateToken(accountAuth, client));
601
+ accountManager.updateFromAuth(account, accountAuth);
602
+ accountManager.clearAuthFailures(account);
603
+ accountManager.saveToDiskDebounced();
604
+ }
605
+ }
606
+ catch (err) {
607
+ logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${err?.message ?? String(err)}`);
608
+ const failures = accountManager.incrementAuthFailures(account);
609
+ const accountLabel = formatAccountLabel(account, account.index);
610
+ if (failures >= ACCOUNT_LIMITS.MAX_AUTH_FAILURES_BEFORE_REMOVAL) {
611
+ accountManager.removeAccount(account);
612
+ accountManager.saveToDiskDebounced();
613
+ await showToast(`Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'opencode auth login' to re-add.`, "error", { duration: toastDurationMs * 2 });
614
+ continue;
615
+ }
616
+ accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
567
617
  accountManager.saveToDiskDebounced();
568
- await showToast(`Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'opencode auth login' to re-add.`, "error", { duration: toastDurationMs * 2 });
569
618
  continue;
570
619
  }
571
- accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
572
- accountManager.saveToDiskDebounced();
573
- continue;
574
- }
575
- const hadAccountId = !!account.accountId;
576
- // Prefer fresh token-derived ID over stored ID (fixes Business plan workspace issues)
577
- const accountId = extractAccountId(accountAuth.access) ?? account.accountId;
578
- if (!accountId) {
579
- accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
580
- accountManager.saveToDiskDebounced();
581
- continue;
582
- }
583
- account.accountId = accountId;
584
- if (!hadAccountId) {
585
- account.accountIdSource = account.accountIdSource ?? "token";
586
- }
587
- account.email =
588
- extractAccountEmail(accountAuth.access) ?? account.email;
589
- if (accountCount > 1 &&
590
- accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
591
- const accountLabel = formatAccountLabel(account, account.index);
592
- await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
593
- accountManager.markToastShown(account.index);
594
- }
595
- const headers = createCodexHeaders(requestInit, accountId, accountAuth.access, {
596
- model,
597
- promptCacheKey,
598
- });
599
- while (true) {
600
- let response;
601
- const fetchStart = performance.now();
602
- try {
603
- response = await fetch(url, {
604
- ...requestInit,
605
- headers,
606
- });
620
+ const hadAccountId = !!account.accountId;
621
+ // Prefer fresh token-derived ID over stored ID (fixes Business plan workspace issues)
622
+ const accountId = extractAccountId(accountAuth.access) ?? account.accountId;
623
+ if (!accountId) {
624
+ accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
625
+ accountManager.saveToDiskDebounced();
626
+ continue;
607
627
  }
608
- catch (networkError) {
609
- const errorMsg = networkError instanceof Error ? networkError.message : String(networkError);
610
- logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`);
611
- accountManager.recordFailure(account, modelFamily, model);
612
- break;
628
+ account.accountId = accountId;
629
+ if (!hadAccountId) {
630
+ account.accountIdSource = account.accountIdSource ?? "token";
631
+ }
632
+ account.email =
633
+ extractAccountEmail(accountAuth.access) ?? account.email;
634
+ if (accountCount > 1 &&
635
+ accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
636
+ const accountLabel = formatAccountLabel(account, account.index);
637
+ await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
638
+ accountManager.markToastShown(account.index);
613
639
  }
614
- const fetchLatencyMs = Math.round(performance.now() - fetchStart);
615
- logRequest(LOG_STAGES.RESPONSE, {
616
- status: response.status,
617
- ok: response.ok,
618
- statusText: response.statusText,
619
- latencyMs: fetchLatencyMs,
620
- headers: Object.fromEntries(response.headers.entries()),
640
+ const headers = createCodexHeaders(requestInit, accountId, accountAuth.access, {
641
+ model,
642
+ promptCacheKey,
621
643
  });
622
- if (!response.ok) {
623
- const contextOverflowResult = await handleContextOverflow(response, model);
624
- if (contextOverflowResult.handled) {
625
- return contextOverflowResult.response;
644
+ // Consume a token before making the request for proactive rate limiting
645
+ accountManager.consumeToken(account, modelFamily, model);
646
+ while (true) {
647
+ let response;
648
+ const fetchStart = performance.now();
649
+ // Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any)
650
+ const fetchController = new AbortController();
651
+ const fetchTimeoutMs = 60000;
652
+ const fetchTimeoutId = setTimeout(() => fetchController.abort(new Error("Request timeout")), fetchTimeoutMs);
653
+ const onUserAbort = abortSignal
654
+ ? () => fetchController.abort(abortSignal.reason ?? new Error("Aborted by user"))
655
+ : null;
656
+ if (abortSignal?.aborted) {
657
+ clearTimeout(fetchTimeoutId);
658
+ fetchController.abort(abortSignal.reason ?? new Error("Aborted by user"));
659
+ }
660
+ else if (abortSignal && onUserAbort) {
661
+ abortSignal.addEventListener("abort", onUserAbort, { once: true });
626
662
  }
627
- const { response: errorResponse, rateLimit, errorBody } = await handleErrorResponse(response);
628
- if (recoveryHook && errorBody && isRecoverableError(errorBody)) {
629
- const errorType = detectErrorType(errorBody);
630
- const toastContent = getRecoveryToastContent(errorType);
631
- await showToast(`${toastContent.title}: ${toastContent.message}`, "warning", { duration: toastDurationMs });
632
- logDebug(`[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`);
663
+ try {
664
+ response = await fetch(url, {
665
+ ...requestInit,
666
+ headers,
667
+ signal: fetchController.signal,
668
+ });
633
669
  }
634
- if (rateLimit) {
635
- const { attempt, delayMs } = getRateLimitBackoff(account.index, quotaKey, rateLimit.retryAfterMs);
636
- const waitLabel = formatWaitTime(delayMs);
637
- if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) {
638
- if (accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
639
- await showToast(`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning", { duration: toastDurationMs });
670
+ catch (networkError) {
671
+ const errorMsg = networkError instanceof Error ? networkError.message : String(networkError);
672
+ logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`);
673
+ accountManager.refundToken(account, modelFamily, model);
674
+ accountManager.recordFailure(account, modelFamily, model);
675
+ break;
676
+ }
677
+ finally {
678
+ clearTimeout(fetchTimeoutId);
679
+ if (abortSignal && onUserAbort) {
680
+ abortSignal.removeEventListener("abort", onUserAbort);
681
+ }
682
+ }
683
+ const fetchLatencyMs = Math.round(performance.now() - fetchStart);
684
+ logRequest(LOG_STAGES.RESPONSE, {
685
+ status: response.status,
686
+ ok: response.ok,
687
+ statusText: response.statusText,
688
+ latencyMs: fetchLatencyMs,
689
+ headers: Object.fromEntries(response.headers.entries()),
690
+ });
691
+ if (!response.ok) {
692
+ const contextOverflowResult = await handleContextOverflow(response, model);
693
+ if (contextOverflowResult.handled) {
694
+ return contextOverflowResult.response;
695
+ }
696
+ const { response: errorResponse, rateLimit, errorBody } = await handleErrorResponse(response);
697
+ if (recoveryHook && errorBody && isRecoverableError(errorBody)) {
698
+ const errorType = detectErrorType(errorBody);
699
+ const toastContent = getRecoveryToastContent(errorType);
700
+ await showToast(`${toastContent.title}: ${toastContent.message}`, "warning", { duration: toastDurationMs });
701
+ logDebug(`[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`);
702
+ }
703
+ // Handle 5xx server errors by rotating to another account
704
+ if (response.status >= 500 && response.status < 600) {
705
+ logWarn(`Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`);
706
+ accountManager.refundToken(account, modelFamily, model);
707
+ accountManager.recordFailure(account, modelFamily, model);
708
+ break;
709
+ }
710
+ if (rateLimit) {
711
+ const { attempt, delayMs } = getRateLimitBackoff(account.index, quotaKey, rateLimit.retryAfterMs);
712
+ const waitLabel = formatWaitTime(delayMs);
713
+ if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) {
714
+ if (accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
715
+ await showToast(`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning", { duration: toastDurationMs });
716
+ accountManager.markToastShown(account.index);
717
+ }
718
+ await sleep(addJitter(Math.max(MIN_BACKOFF_MS, delayMs), 0.2));
719
+ continue;
720
+ }
721
+ accountManager.markRateLimitedWithReason(account, delayMs, modelFamily, parseRateLimitReason(rateLimit.code), model);
722
+ accountManager.recordRateLimit(account, modelFamily, model);
723
+ account.lastSwitchReason = "rate-limit";
724
+ accountManager.saveToDiskDebounced();
725
+ logWarn(`Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`);
726
+ if (accountManager.getAccountCount() > 1 &&
727
+ accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
728
+ await showToast(`Rate limited. Switching accounts (retry in ${waitLabel}).`, "warning", { duration: toastDurationMs });
640
729
  accountManager.markToastShown(account.index);
641
730
  }
642
- await sleep(addJitter(delayMs, 0.2));
643
- continue;
731
+ break;
644
732
  }
645
- accountManager.markRateLimited(account, delayMs, modelFamily, model);
646
- accountManager.recordRateLimit(account, modelFamily, model);
647
- account.lastSwitchReason = "rate-limit";
648
- accountManager.saveToDiskDebounced();
649
- logWarn(`Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`);
650
- if (accountManager.getAccountCount() > 1 &&
651
- accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
652
- await showToast(`Rate limited. Switching accounts (retry in ${waitLabel}).`, "warning", { duration: toastDurationMs });
653
- accountManager.markToastShown(account.index);
654
- }
655
- break;
733
+ return errorResponse;
656
734
  }
657
- return errorResponse;
735
+ resetRateLimitBackoff(account.index, quotaKey);
736
+ const successResponse = await handleSuccessResponse(response, isStreaming);
737
+ accountManager.recordSuccess(account, modelFamily, model);
738
+ return successResponse;
658
739
  }
659
- resetRateLimitBackoff(account.index, quotaKey);
660
- accountManager.recordSuccess(account, modelFamily, model);
661
- return await handleSuccessResponse(response, isStreaming);
662
740
  }
741
+ const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
742
+ const count = accountManager.getAccountCount();
743
+ if (retryAllAccountsRateLimited &&
744
+ count > 0 &&
745
+ waitMs > 0 &&
746
+ (retryAllAccountsMaxWaitMs === 0 ||
747
+ waitMs <= retryAllAccountsMaxWaitMs) &&
748
+ allRateLimitedRetries < retryAllAccountsMaxRetries) {
749
+ const countdownMessage = `All ${count} account(s) rate-limited. Waiting`;
750
+ await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage);
751
+ allRateLimitedRetries++;
752
+ continue;
753
+ }
754
+ const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
755
+ const message = count === 0
756
+ ? "No Codex accounts configured. Run `opencode auth login`."
757
+ : waitMs > 0
758
+ ? `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`opencode auth login\`.`
759
+ : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`;
760
+ return new Response(JSON.stringify({ error: { message } }), {
761
+ status: waitMs > 0 ? 429 : 503,
762
+ headers: {
763
+ "content-type": "application/json; charset=utf-8",
764
+ },
765
+ });
663
766
  }
664
- const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
665
- const count = accountManager.getAccountCount();
666
- if (retryAllAccountsRateLimited &&
667
- count > 0 &&
668
- waitMs > 0 &&
669
- (retryAllAccountsMaxWaitMs === 0 ||
670
- waitMs <= retryAllAccountsMaxWaitMs) &&
671
- allRateLimitedRetries < retryAllAccountsMaxRetries) {
672
- const countdownMessage = `All ${count} account(s) rate-limited. Waiting`;
673
- await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage);
674
- allRateLimitedRetries++;
675
- continue;
676
- }
677
- const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
678
- const message = count === 0
679
- ? "No Codex accounts configured. Run `opencode auth login`."
680
- : `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`opencode auth login\`.`;
681
- return new Response(JSON.stringify({ error: { message } }), {
682
- status: 429,
683
- headers: {
684
- "content-type": "application/json; charset=utf-8",
685
- },
686
- });
687
- }
688
- },
689
- };
767
+ },
768
+ };
769
+ }
770
+ finally {
771
+ resolveMutex?.();
772
+ loaderMutex = null;
773
+ }
690
774
  },
691
775
  methods: [
692
776
  {
@@ -740,6 +824,13 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
740
824
  * @returns Authorization flow configuration
741
825
  */
742
826
  authorize: async (inputs) => {
827
+ // Initialize storage path based on config BEFORE any account operations
828
+ // This ensures accounts are saved to the correct location (per-project or global)
829
+ const authPluginConfig = loadPluginConfig();
830
+ const authPerProjectAccounts = getPerProjectAccounts(authPluginConfig);
831
+ if (authPerProjectAccounts) {
832
+ setStoragePath(process.cwd());
833
+ }
743
834
  // Always use the multi-account flow regardless of inputs
744
835
  // The inputs parameter is only used for noBrowser flag, not for flow selection
745
836
  const accounts = [];
@@ -783,7 +874,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
783
874
  url: "",
784
875
  instructions: "Account limit reached. Remove an account or start fresh.",
785
876
  method: "auto",
786
- callback: async () => ({
877
+ callback: () => Promise.resolve({
787
878
  type: "failed",
788
879
  }),
789
880
  };
@@ -809,7 +900,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
809
900
  const result = await runOAuthFlow(forceNewLogin);
810
901
  let resolved = null;
811
902
  if (result.type === "success") {
812
- resolved = await resolveAccountSelection(result);
903
+ resolved = resolveAccountSelection(result);
813
904
  const email = extractAccountEmail(resolved.access, resolved.idToken);
814
905
  const accountId = resolved.accountIdOverride ?? extractAccountId(resolved.access);
815
906
  const label = resolved.accountLabel ?? email ?? accountId ?? "Unknown account";
@@ -829,7 +920,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
829
920
  url: "",
830
921
  instructions: "Authentication failed.",
831
922
  method: "auto",
832
- callback: async () => result,
923
+ callback: () => Promise.resolve(result),
833
924
  };
834
925
  }
835
926
  logWarn(`[${PLUGIN_NAME}] Skipping failed account ${accounts.length + 1}`);
@@ -861,7 +952,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
861
952
  url: "",
862
953
  instructions: "Authentication cancelled",
863
954
  method: "auto",
864
- callback: async () => ({
955
+ callback: () => Promise.resolve({
865
956
  type: "failed",
866
957
  }),
867
958
  };
@@ -874,13 +965,13 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
874
965
  }
875
966
  }
876
967
  catch (err) {
877
- logDebug(`[${PLUGIN_NAME}] Failed to load final account count: ${err?.message ?? String(err)}`);
968
+ logWarn(`[${PLUGIN_NAME}] Failed to load final account count: ${err?.message ?? String(err)}`);
878
969
  }
879
970
  return {
880
971
  url: "",
881
972
  instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
882
973
  method: "auto",
883
- callback: async () => primary,
974
+ callback: () => Promise.resolve(primary),
884
975
  };
885
976
  },
886
977
  },
@@ -888,6 +979,13 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
888
979
  label: AUTH_LABELS.OAUTH_MANUAL,
889
980
  type: "oauth",
890
981
  authorize: async () => {
982
+ // Initialize storage path for manual OAuth flow
983
+ // Must happen BEFORE persistAccountPool to ensure correct storage location
984
+ const manualPluginConfig = loadPluginConfig();
985
+ const manualPerProjectAccounts = getPerProjectAccounts(manualPluginConfig);
986
+ if (manualPerProjectAccounts) {
987
+ setStoragePath(process.cwd());
988
+ }
891
989
  const { pkce, url } = await createAuthorizationFlow();
892
990
  return buildManualOAuthFlow(pkce, url, async (tokens) => {
893
991
  try {
@@ -924,11 +1022,17 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
924
1022
  }
925
1023
  const now = Date.now();
926
1024
  const activeIndex = resolveActiveIndex(storage, "codex");
1025
+ const listTableOptions = {
1026
+ columns: [
1027
+ { header: "#", width: 3 },
1028
+ { header: "Label", width: 42 },
1029
+ { header: "Status", width: 20 },
1030
+ ],
1031
+ };
927
1032
  const lines = [
928
1033
  `Codex Accounts (${storage.accounts.length}):`,
929
1034
  "",
930
- " # Label Status",
931
- "----------------------------------------------- ---------------------",
1035
+ ...buildTableHeader(listTableOptions),
932
1036
  ];
933
1037
  storage.accounts.forEach((account, index) => {
934
1038
  const label = formatAccountLabel(account, index);
@@ -944,8 +1048,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
944
1048
  statuses.push("cooldown");
945
1049
  }
946
1050
  const statusText = statuses.length > 0 ? statuses.join(", ") : "ok";
947
- const row = `${String(index + 1).padEnd(3)} ${label.padEnd(40)} ${statusText}`;
948
- lines.push(row);
1051
+ lines.push(buildTableRow([String(index + 1), label, statusText], listTableOptions));
949
1052
  });
950
1053
  lines.push("");
951
1054
  lines.push(`Storage: ${storePath}`);
@@ -1009,22 +1112,30 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1009
1112
  }
1010
1113
  const now = Date.now();
1011
1114
  const activeIndex = resolveActiveIndex(storage, "codex");
1115
+ const statusTableOptions = {
1116
+ columns: [
1117
+ { header: "#", width: 3 },
1118
+ { header: "Label", width: 42 },
1119
+ { header: "Active", width: 6 },
1120
+ { header: "Rate Limit", width: 16 },
1121
+ { header: "Cooldown", width: 16 },
1122
+ { header: "Last Used", width: 16 },
1123
+ ],
1124
+ };
1012
1125
  const lines = [
1013
1126
  `Account Status (${storage.accounts.length} total):`,
1014
1127
  "",
1015
- " # Label Active Rate Limit Cooldown Last Used",
1016
- "----------------------------------------------- ------ ---------------- ---------------- ----------------",
1128
+ ...buildTableHeader(statusTableOptions),
1017
1129
  ];
1018
1130
  storage.accounts.forEach((account, index) => {
1019
- const label = formatAccountLabel(account, index).padEnd(42);
1131
+ const label = formatAccountLabel(account, index);
1020
1132
  const active = index === activeIndex ? "Yes" : "No";
1021
1133
  const rateLimit = formatRateLimitEntry(account, now) ?? "None";
1022
1134
  const cooldown = formatCooldown(account, now) ?? "No";
1023
1135
  const lastUsed = typeof account.lastUsed === "number" && account.lastUsed > 0
1024
1136
  ? `${formatWaitTime(now - account.lastUsed)} ago`
1025
1137
  : "-";
1026
- const row = `${String(index + 1).padEnd(3)} ${label} ${active.padEnd(6)} ${rateLimit.padEnd(16)} ${cooldown.padEnd(16)} ${lastUsed}`;
1027
- lines.push(row);
1138
+ lines.push(buildTableRow([String(index + 1), label, active, rateLimit, cooldown, lastUsed], statusTableOptions));
1028
1139
  });
1029
1140
  lines.push("");
1030
1141
  lines.push("Active index by model family:");