oc-chatgpt-multi-auth 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/LICENSE +37 -0
  2. package/README.md +507 -0
  3. package/assets/opencode-logo-ornate-dark.svg +18 -0
  4. package/assets/readme-hero.svg +31 -0
  5. package/config/README.md +110 -0
  6. package/config/minimal-opencode.json +13 -0
  7. package/config/opencode-legacy.json +572 -0
  8. package/config/opencode-modern.json +240 -0
  9. package/dist/index.d.ts +45 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +971 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/accounts.d.ts +120 -0
  14. package/dist/lib/accounts.d.ts.map +1 -0
  15. package/dist/lib/accounts.js +579 -0
  16. package/dist/lib/accounts.js.map +1 -0
  17. package/dist/lib/auth/auth.d.ts +51 -0
  18. package/dist/lib/auth/auth.d.ts.map +1 -0
  19. package/dist/lib/auth/auth.js +180 -0
  20. package/dist/lib/auth/auth.js.map +1 -0
  21. package/dist/lib/auth/browser.d.ts +17 -0
  22. package/dist/lib/auth/browser.d.ts.map +1 -0
  23. package/dist/lib/auth/browser.js +83 -0
  24. package/dist/lib/auth/browser.js.map +1 -0
  25. package/dist/lib/auth/server.d.ts +10 -0
  26. package/dist/lib/auth/server.d.ts.map +1 -0
  27. package/dist/lib/auth/server.js +85 -0
  28. package/dist/lib/auth/server.js.map +1 -0
  29. package/dist/lib/auto-update-checker.d.ts +10 -0
  30. package/dist/lib/auto-update-checker.d.ts.map +1 -0
  31. package/dist/lib/auto-update-checker.js +129 -0
  32. package/dist/lib/auto-update-checker.js.map +1 -0
  33. package/dist/lib/cli.d.ts +9 -0
  34. package/dist/lib/cli.d.ts.map +1 -0
  35. package/dist/lib/cli.js +50 -0
  36. package/dist/lib/cli.js.map +1 -0
  37. package/dist/lib/config.d.ts +17 -0
  38. package/dist/lib/config.d.ts.map +1 -0
  39. package/dist/lib/config.js +102 -0
  40. package/dist/lib/config.js.map +1 -0
  41. package/dist/lib/constants.d.ts +74 -0
  42. package/dist/lib/constants.d.ts.map +1 -0
  43. package/dist/lib/constants.js +74 -0
  44. package/dist/lib/constants.js.map +1 -0
  45. package/dist/lib/context-overflow.d.ts +27 -0
  46. package/dist/lib/context-overflow.d.ts.map +1 -0
  47. package/dist/lib/context-overflow.js +124 -0
  48. package/dist/lib/context-overflow.js.map +1 -0
  49. package/dist/lib/index.d.ts +13 -0
  50. package/dist/lib/index.d.ts.map +1 -0
  51. package/dist/lib/index.js +13 -0
  52. package/dist/lib/index.js.map +1 -0
  53. package/dist/lib/logger.d.ts +22 -0
  54. package/dist/lib/logger.d.ts.map +1 -0
  55. package/dist/lib/logger.js +175 -0
  56. package/dist/lib/logger.js.map +1 -0
  57. package/dist/lib/oauth-success.html +712 -0
  58. package/dist/lib/prompts/codex-opencode-bridge.d.ts +19 -0
  59. package/dist/lib/prompts/codex-opencode-bridge.d.ts.map +1 -0
  60. package/dist/lib/prompts/codex-opencode-bridge.js +152 -0
  61. package/dist/lib/prompts/codex-opencode-bridge.js.map +1 -0
  62. package/dist/lib/prompts/codex.d.ts +32 -0
  63. package/dist/lib/prompts/codex.d.ts.map +1 -0
  64. package/dist/lib/prompts/codex.js +262 -0
  65. package/dist/lib/prompts/codex.js.map +1 -0
  66. package/dist/lib/prompts/opencode-codex.d.ts +21 -0
  67. package/dist/lib/prompts/opencode-codex.d.ts.map +1 -0
  68. package/dist/lib/prompts/opencode-codex.js +91 -0
  69. package/dist/lib/prompts/opencode-codex.js.map +1 -0
  70. package/dist/lib/recovery/constants.d.ts +12 -0
  71. package/dist/lib/recovery/constants.d.ts.map +1 -0
  72. package/dist/lib/recovery/constants.js +25 -0
  73. package/dist/lib/recovery/constants.js.map +1 -0
  74. package/dist/lib/recovery/index.d.ts +12 -0
  75. package/dist/lib/recovery/index.d.ts.map +1 -0
  76. package/dist/lib/recovery/index.js +12 -0
  77. package/dist/lib/recovery/index.js.map +1 -0
  78. package/dist/lib/recovery/storage.d.ts +24 -0
  79. package/dist/lib/recovery/storage.d.ts.map +1 -0
  80. package/dist/lib/recovery/storage.js +354 -0
  81. package/dist/lib/recovery/storage.js.map +1 -0
  82. package/dist/lib/recovery/types.d.ts +116 -0
  83. package/dist/lib/recovery/types.d.ts.map +1 -0
  84. package/dist/lib/recovery/types.js +7 -0
  85. package/dist/lib/recovery/types.js.map +1 -0
  86. package/dist/lib/recovery.d.ts +31 -0
  87. package/dist/lib/recovery.d.ts.map +1 -0
  88. package/dist/lib/recovery.js +308 -0
  89. package/dist/lib/recovery.js.map +1 -0
  90. package/dist/lib/refresh-queue.d.ts +100 -0
  91. package/dist/lib/refresh-queue.d.ts.map +1 -0
  92. package/dist/lib/refresh-queue.js +196 -0
  93. package/dist/lib/refresh-queue.js.map +1 -0
  94. package/dist/lib/request/fetch-helpers.d.ts +81 -0
  95. package/dist/lib/request/fetch-helpers.d.ts.map +1 -0
  96. package/dist/lib/request/fetch-helpers.js +325 -0
  97. package/dist/lib/request/fetch-helpers.js.map +1 -0
  98. package/dist/lib/request/helpers/input-utils.d.ts +7 -0
  99. package/dist/lib/request/helpers/input-utils.d.ts.map +1 -0
  100. package/dist/lib/request/helpers/input-utils.js +213 -0
  101. package/dist/lib/request/helpers/input-utils.js.map +1 -0
  102. package/dist/lib/request/helpers/model-map.d.ts +28 -0
  103. package/dist/lib/request/helpers/model-map.d.ts.map +1 -0
  104. package/dist/lib/request/helpers/model-map.js +109 -0
  105. package/dist/lib/request/helpers/model-map.js.map +1 -0
  106. package/dist/lib/request/rate-limit-backoff.d.ts +17 -0
  107. package/dist/lib/request/rate-limit-backoff.d.ts.map +1 -0
  108. package/dist/lib/request/rate-limit-backoff.js +74 -0
  109. package/dist/lib/request/rate-limit-backoff.js.map +1 -0
  110. package/dist/lib/request/request-transformer.d.ts +93 -0
  111. package/dist/lib/request/request-transformer.d.ts.map +1 -0
  112. package/dist/lib/request/request-transformer.js +405 -0
  113. package/dist/lib/request/request-transformer.js.map +1 -0
  114. package/dist/lib/request/response-handler.d.ts +14 -0
  115. package/dist/lib/request/response-handler.d.ts.map +1 -0
  116. package/dist/lib/request/response-handler.js +90 -0
  117. package/dist/lib/request/response-handler.js.map +1 -0
  118. package/dist/lib/rotation.d.ts +121 -0
  119. package/dist/lib/rotation.d.ts.map +1 -0
  120. package/dist/lib/rotation.js +248 -0
  121. package/dist/lib/rotation.js.map +1 -0
  122. package/dist/lib/storage.d.ts +91 -0
  123. package/dist/lib/storage.d.ts.map +1 -0
  124. package/dist/lib/storage.js +323 -0
  125. package/dist/lib/storage.js.map +1 -0
  126. package/dist/lib/types.d.ts +185 -0
  127. package/dist/lib/types.d.ts.map +1 -0
  128. package/dist/lib/types.js +2 -0
  129. package/dist/lib/types.js.map +1 -0
  130. package/package.json +86 -0
  131. package/scripts/copy-oauth-success.js +37 -0
  132. package/scripts/install-opencode-codex-auth.js +193 -0
  133. package/scripts/test-all-models.sh +260 -0
  134. package/scripts/validate-model-map.sh +97 -0
package/dist/index.js ADDED
@@ -0,0 +1,971 @@
1
+ /**
2
+ * OpenAI ChatGPT (Codex) OAuth Authentication Plugin for opencode
3
+ *
4
+ * COMPLIANCE NOTICE:
5
+ * This plugin uses OpenAI's official OAuth authentication flow (the same method
6
+ * used by OpenAI's official Codex CLI at https://github.com/openai/codex).
7
+ *
8
+ * INTENDED USE: Personal development and coding assistance with your own
9
+ * ChatGPT Plus/Pro subscription.
10
+ *
11
+ * NOT INTENDED FOR: Commercial resale, multi-user services, high-volume
12
+ * automated extraction, or any use that violates OpenAI's Terms of Service.
13
+ *
14
+ * Users are responsible for ensuring their usage complies with:
15
+ * - OpenAI Terms of Use: https://openai.com/policies/terms-of-use/
16
+ * - OpenAI Usage Policies: https://openai.com/policies/usage-policies/
17
+ *
18
+ * For production applications, use the OpenAI Platform API: https://platform.openai.com/
19
+ *
20
+ * @license MIT with Usage Disclaimer (see LICENSE file)
21
+ * @author numman-ali
22
+ * @repository https://github.com/ndycode/oc-chatgpt-multi-auth
23
+
24
+ */
25
+ import { tool } from "@opencode-ai/plugin/tool";
26
+ import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationInput, REDIRECT_URI, } from "./lib/auth/auth.js";
27
+ import { queuedRefresh } from "./lib/refresh-queue.js";
28
+ import { openBrowserUrl } from "./lib/auth/browser.js";
29
+ import { startLocalOAuthServer } from "./lib/auth/server.js";
30
+ import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
31
+ import { getCodexMode, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, loadPluginConfig, } from "./lib/config.js";
32
+ import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, ACCOUNT_LIMITS, } from "./lib/constants.js";
33
+ import { logRequest, logDebug } from "./lib/logger.js";
34
+ import { checkAndNotify } from "./lib/auto-update-checker.js";
35
+ import { handleContextOverflow } from "./lib/context-overflow.js";
36
+ import { AccountManager, extractAccountEmail, extractAccountId, formatAccountLabel, formatCooldown, formatWaitTime, sanitizeEmail, } from "./lib/accounts.js";
37
+ import { getStoragePath, loadAccounts, saveAccounts } from "./lib/storage.js";
38
+ import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
39
+ import { getRateLimitBackoff, RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS, resetRateLimitBackoff, } from "./lib/request/rate-limit-backoff.js";
40
+ import { getModelFamily, MODEL_FAMILIES } from "./lib/prompts/codex.js";
41
+ import { createSessionRecoveryHook, isRecoverableError, detectErrorType, getRecoveryToastContent, } from "./lib/recovery.js";
42
+ /**
43
+ * OpenAI Codex OAuth authentication plugin for opencode
44
+ *
45
+ * This plugin enables opencode to use OpenAI's Codex backend via ChatGPT Plus/Pro
46
+ * OAuth authentication, allowing users to leverage their ChatGPT subscription
47
+ * instead of OpenAI Platform API credits.
48
+ *
49
+ * @example
50
+ * ```json
51
+ * {
52
+ * "plugin": ["oc-chatgpt-multi-auth"],
53
+
54
+ * "model": "openai/gpt-5-codex"
55
+ * }
56
+ * ```
57
+ */
58
+ export const OpenAIOAuthPlugin = async ({ client }) => {
59
+ let cachedAccountManager = null;
60
+ const buildManualOAuthFlow = (pkce, url, onSuccess) => ({
61
+ url,
62
+ method: "code",
63
+ instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL,
64
+ callback: async (input) => {
65
+ const parsed = parseAuthorizationInput(input);
66
+ if (!parsed.code) {
67
+ return { type: "failed", reason: "invalid_response", message: "No authorization code provided" };
68
+ }
69
+ const tokens = await exchangeAuthorizationCode(parsed.code, pkce.verifier, REDIRECT_URI);
70
+ if (tokens?.type === "success" && onSuccess) {
71
+ await onSuccess(tokens);
72
+ }
73
+ return tokens?.type === "success"
74
+ ? tokens
75
+ : { type: "failed" };
76
+ },
77
+ });
78
+ const promptOAuthCallbackValue = async (message) => {
79
+ const { createInterface } = await import("node:readline/promises");
80
+ const { stdin, stdout } = await import("node:process");
81
+ const rl = createInterface({ input: stdin, output: stdout });
82
+ try {
83
+ return (await rl.question(message)).trim();
84
+ }
85
+ finally {
86
+ rl.close();
87
+ }
88
+ };
89
+ const runManualOAuthFlow = async (pkce, _url) => {
90
+ console.log("1. Open the URL above in your browser and sign in.");
91
+ console.log("2. After approving, copy the full redirect URL.");
92
+ console.log("3. Paste it back here.\n");
93
+ const callbackInput = await promptOAuthCallbackValue("Paste the redirect URL (or just the code) here: ");
94
+ const parsed = parseAuthorizationInput(callbackInput);
95
+ if (!parsed.code) {
96
+ return { type: "failed" };
97
+ }
98
+ return await exchangeAuthorizationCode(parsed.code, pkce.verifier, REDIRECT_URI);
99
+ };
100
+ const runOAuthFlow = async (useManualMode, forceNewLogin = false) => {
101
+ const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin });
102
+ console.log("\nOAuth URL:\n" + url + "\n");
103
+ if (useManualMode) {
104
+ openBrowserUrl(url);
105
+ return await runManualOAuthFlow(pkce, url);
106
+ }
107
+ let serverInfo = null;
108
+ try {
109
+ serverInfo = await startLocalOAuthServer({ state });
110
+ }
111
+ catch (err) {
112
+ logDebug(`[${PLUGIN_NAME}] Failed to start OAuth server: ${err?.message ?? String(err)}`);
113
+ serverInfo = null;
114
+ }
115
+ openBrowserUrl(url);
116
+ if (!serverInfo || !serverInfo.ready) {
117
+ serverInfo?.close();
118
+ return await runManualOAuthFlow(pkce, url);
119
+ }
120
+ const result = await serverInfo.waitForCode(state);
121
+ serverInfo.close();
122
+ if (!result) {
123
+ return { type: "failed", reason: "unknown", message: "OAuth callback timeout or cancelled" };
124
+ }
125
+ return await exchangeAuthorizationCode(result.code, pkce.verifier, REDIRECT_URI);
126
+ };
127
+ const persistAccountPool = async (results, replaceAll = false) => {
128
+ if (results.length === 0)
129
+ return;
130
+ const now = Date.now();
131
+ const stored = replaceAll ? null : await loadAccounts();
132
+ const accounts = stored?.accounts ? [...stored.accounts] : [];
133
+ const indexByRefreshToken = new Map();
134
+ const indexByAccountId = new Map();
135
+ const indexByEmail = new Map();
136
+ for (let i = 0; i < accounts.length; i += 1) {
137
+ const account = accounts[i];
138
+ if (!account)
139
+ continue;
140
+ if (account.refreshToken) {
141
+ indexByRefreshToken.set(account.refreshToken, i);
142
+ }
143
+ if (account.accountId) {
144
+ indexByAccountId.set(account.accountId, i);
145
+ }
146
+ if (account.email) {
147
+ indexByEmail.set(account.email, i);
148
+ }
149
+ }
150
+ for (const result of results) {
151
+ const accountId = extractAccountId(result.access);
152
+ const accountEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken));
153
+ const existingByEmail = accountEmail && indexByEmail.has(accountEmail)
154
+ ? indexByEmail.get(accountEmail)
155
+ : undefined;
156
+ const existingById = accountId && indexByAccountId.has(accountId)
157
+ ? indexByAccountId.get(accountId)
158
+ : undefined;
159
+ const existingByToken = indexByRefreshToken.get(result.refresh);
160
+ const existingIndex = existingById ?? existingByEmail ?? existingByToken;
161
+ if (existingIndex === undefined) {
162
+ const newIndex = accounts.length;
163
+ accounts.push({
164
+ accountId,
165
+ email: accountEmail,
166
+ refreshToken: result.refresh,
167
+ addedAt: now,
168
+ lastUsed: now,
169
+ });
170
+ indexByRefreshToken.set(result.refresh, newIndex);
171
+ if (accountId) {
172
+ indexByAccountId.set(accountId, newIndex);
173
+ }
174
+ if (accountEmail) {
175
+ indexByEmail.set(accountEmail, newIndex);
176
+ }
177
+ continue;
178
+ }
179
+ const existing = accounts[existingIndex];
180
+ if (!existing)
181
+ continue;
182
+ const oldToken = existing.refreshToken;
183
+ const oldEmail = existing.email;
184
+ const nextEmail = accountEmail ?? existing.email;
185
+ accounts[existingIndex] = {
186
+ ...existing,
187
+ accountId: accountId ?? existing.accountId,
188
+ email: nextEmail,
189
+ refreshToken: result.refresh,
190
+ lastUsed: now,
191
+ };
192
+ if (oldToken !== result.refresh) {
193
+ indexByRefreshToken.delete(oldToken);
194
+ indexByRefreshToken.set(result.refresh, existingIndex);
195
+ }
196
+ if (accountId) {
197
+ indexByAccountId.set(accountId, existingIndex);
198
+ }
199
+ if (oldEmail && oldEmail !== nextEmail) {
200
+ indexByEmail.delete(oldEmail);
201
+ }
202
+ if (nextEmail) {
203
+ indexByEmail.set(nextEmail, existingIndex);
204
+ }
205
+ }
206
+ if (accounts.length === 0)
207
+ return;
208
+ const activeIndex = replaceAll
209
+ ? 0
210
+ : typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex)
211
+ ? stored.activeIndex
212
+ : 0;
213
+ const clampedActiveIndex = Math.max(0, Math.min(activeIndex, accounts.length - 1));
214
+ const activeIndexByFamily = {};
215
+ for (const family of MODEL_FAMILIES) {
216
+ const storedFamilyIndex = stored?.activeIndexByFamily?.[family];
217
+ const rawFamilyIndex = replaceAll
218
+ ? 0
219
+ : typeof storedFamilyIndex === "number" && Number.isFinite(storedFamilyIndex)
220
+ ? storedFamilyIndex
221
+ : clampedActiveIndex;
222
+ activeIndexByFamily[family] = Math.max(0, Math.min(Math.floor(rawFamilyIndex), accounts.length - 1));
223
+ }
224
+ await saveAccounts({
225
+ version: 3,
226
+ accounts,
227
+ activeIndex: clampedActiveIndex,
228
+ activeIndexByFamily,
229
+ });
230
+ };
231
+ const showToast = async (message, variant = "success") => {
232
+ try {
233
+ await client.tui.showToast({
234
+ body: {
235
+ message,
236
+ variant,
237
+ },
238
+ });
239
+ }
240
+ catch {
241
+ // Ignore when TUI is not available.
242
+ }
243
+ };
244
+ const resolveActiveIndex = (storage, family = "codex") => {
245
+ const total = storage.accounts.length;
246
+ if (total === 0)
247
+ return 0;
248
+ const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex;
249
+ const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0;
250
+ return Math.max(0, Math.min(raw, total - 1));
251
+ };
252
+ const hydrateEmails = async (storage) => {
253
+ if (!storage)
254
+ return storage;
255
+ const skipHydrate = process.env.VITEST_WORKER_ID !== undefined ||
256
+ process.env.NODE_ENV === "test" ||
257
+ process.env.OPENCODE_SKIP_EMAIL_HYDRATE === "1";
258
+ if (skipHydrate)
259
+ return storage;
260
+ const accountsToHydrate = storage.accounts.filter((account) => account && !account.email);
261
+ if (accountsToHydrate.length === 0)
262
+ return storage;
263
+ let changed = false;
264
+ await Promise.all(accountsToHydrate.map(async (account) => {
265
+ try {
266
+ const refreshed = await queuedRefresh(account.refreshToken);
267
+ if (refreshed.type !== "success")
268
+ return;
269
+ const id = extractAccountId(refreshed.access);
270
+ const email = sanitizeEmail(extractAccountEmail(refreshed.access, refreshed.idToken));
271
+ if (id && id !== account.accountId) {
272
+ account.accountId = id;
273
+ changed = true;
274
+ }
275
+ if (email && email !== account.email) {
276
+ account.email = email;
277
+ changed = true;
278
+ }
279
+ if (refreshed.refresh && refreshed.refresh !== account.refreshToken) {
280
+ account.refreshToken = refreshed.refresh;
281
+ changed = true;
282
+ }
283
+ }
284
+ catch {
285
+ logDebug(`[${PLUGIN_NAME}] Failed to hydrate email for account`);
286
+ }
287
+ }));
288
+ if (changed) {
289
+ await saveAccounts(storage);
290
+ }
291
+ return storage;
292
+ };
293
+ const getRateLimitResetTimeForFamily = (account, now, family) => {
294
+ const times = account.rateLimitResetTimes;
295
+ if (!times)
296
+ return null;
297
+ let minReset = null;
298
+ const prefix = `${family}:`;
299
+ for (const [key, value] of Object.entries(times)) {
300
+ if (typeof value !== "number")
301
+ continue;
302
+ if (value <= now)
303
+ continue;
304
+ if (key !== family && !key.startsWith(prefix))
305
+ continue;
306
+ if (minReset === null || value < minReset) {
307
+ minReset = value;
308
+ }
309
+ }
310
+ return minReset;
311
+ };
312
+ const formatRateLimitEntry = (account, now, family = "codex") => {
313
+ const resetAt = getRateLimitResetTimeForFamily(account, now, family);
314
+ if (typeof resetAt !== "number")
315
+ return null;
316
+ const remaining = resetAt - now;
317
+ if (remaining <= 0)
318
+ return null;
319
+ return `resets in ${formatWaitTime(remaining)}`;
320
+ };
321
+ // Event handler for session recovery (matches antigravity plugin pattern)
322
+ const eventHandler = async (_input) => {
323
+ // Session recovery is handled inside the loader, but we need to expose the event handler
324
+ // to match the antigravity plugin structure that OpenCode expects
325
+ };
326
+ return {
327
+ event: eventHandler,
328
+ auth: {
329
+ provider: PROVIDER_ID,
330
+ /**
331
+ * Loader function that configures OAuth authentication and request handling
332
+ *
333
+ * This function:
334
+ * 1. Validates OAuth authentication
335
+ * 2. Loads multi-account pool from disk (fallback to current auth)
336
+ * 3. Loads user configuration from opencode.json
337
+ * 4. Fetches Codex system instructions from GitHub (cached)
338
+ * 5. Returns SDK configuration with custom fetch implementation
339
+ *
340
+ * @param getAuth - Function to retrieve current auth state
341
+ * @param provider - Provider configuration from opencode.json
342
+ * @returns SDK configuration object or empty object for non-OAuth auth
343
+ */
344
+ async loader(getAuth, provider) {
345
+ const auth = await getAuth();
346
+ // Only handle OAuth auth type, skip API key auth
347
+ if (auth.type !== "oauth") {
348
+ return {};
349
+ }
350
+ // Only handle multi-account auth (identified by multiAccount flag)
351
+ // If auth was created by built-in plugin, let built-in handle it
352
+ const authWithMulti = auth;
353
+ if (!authWithMulti.multiAccount) {
354
+ logDebug(`[${PLUGIN_NAME}] Auth is not multi-account, skipping loader`);
355
+ return {};
356
+ }
357
+ const accountManager = await AccountManager.loadFromDisk(auth);
358
+ cachedAccountManager = accountManager;
359
+ const refreshToken = auth.type === "oauth" ? auth.refresh : "";
360
+ const needsPersist = refreshToken &&
361
+ !accountManager.hasRefreshToken(refreshToken);
362
+ if (needsPersist) {
363
+ await accountManager.saveToDisk();
364
+ }
365
+ if (accountManager.getAccountCount() === 0) {
366
+ logDebug(`[${PLUGIN_NAME}] No OAuth accounts available (run opencode auth login)`);
367
+ return {};
368
+ }
369
+ // Extract user configuration (global + per-model options)
370
+ const providerConfig = provider;
371
+ const userConfig = {
372
+ global: providerConfig?.options || {},
373
+ models: providerConfig?.models || {},
374
+ };
375
+ // Load plugin configuration and determine CODEX_MODE
376
+ // Priority: CODEX_MODE env var > config file > default (true)
377
+ const pluginConfig = loadPluginConfig();
378
+ const codexMode = getCodexMode(pluginConfig);
379
+ const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
380
+ const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
381
+ const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig);
382
+ const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
383
+ const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
384
+ const sessionRecoveryEnabled = getSessionRecovery(pluginConfig);
385
+ const autoResumeEnabled = getAutoResume(pluginConfig);
386
+ const recoveryHook = sessionRecoveryEnabled
387
+ ? createSessionRecoveryHook({ client, directory: process.cwd() }, { sessionRecovery: true, autoResume: autoResumeEnabled })
388
+ : null;
389
+ checkAndNotify(async (message, variant) => {
390
+ await showToast(message, variant);
391
+ }).catch(() => { });
392
+ // Return SDK configuration
393
+ return {
394
+ apiKey: DUMMY_API_KEY,
395
+ baseURL: CODEX_BASE_URL,
396
+ /**
397
+ * Custom fetch implementation for Codex API
398
+ *
399
+ * Handles:
400
+ * - Token refresh when expired
401
+ * - URL rewriting for Codex backend
402
+ * - Request body transformation
403
+ * - OAuth header injection
404
+ * - SSE to JSON conversion for non-tool requests
405
+ * - Error handling and logging
406
+ *
407
+ * @param input - Request URL or Request object
408
+ * @param init - Request options
409
+ * @returns Response from Codex API
410
+ */
411
+ async fetch(input, init) {
412
+ // Step 1: Extract and rewrite URL for Codex backend
413
+ const originalUrl = extractRequestUrl(input);
414
+ const url = rewriteUrlForCodex(originalUrl);
415
+ // Step 3: Transform request body with model-specific Codex instructions
416
+ // Instructions are fetched per model family (codex-max, codex, gpt-5.1)
417
+ // Capture original stream value before transformation
418
+ // generateText() sends no stream field, streamText() sends stream=true
419
+ const originalBody = init?.body ? JSON.parse(init.body) : {};
420
+ const isStreaming = originalBody.stream === true;
421
+ const transformation = await transformRequestForCodex(init, url, userConfig, codexMode);
422
+ const requestInit = transformation?.updatedInit ?? init;
423
+ const promptCacheKey = transformation?.body?.prompt_cache_key;
424
+ const model = transformation?.body.model;
425
+ const modelFamily = model ? getModelFamily(model) : "gpt-5.1";
426
+ const quotaKey = model ? `${modelFamily}:${model}` : modelFamily;
427
+ const abortSignal = requestInit?.signal ?? init?.signal ?? null;
428
+ const sleep = (ms) => new Promise((resolve, reject) => {
429
+ if (abortSignal?.aborted) {
430
+ reject(new Error("Aborted"));
431
+ return;
432
+ }
433
+ const timeout = setTimeout(() => {
434
+ cleanup();
435
+ resolve();
436
+ }, ms);
437
+ const onAbort = () => {
438
+ cleanup();
439
+ reject(new Error("Aborted"));
440
+ };
441
+ const cleanup = () => {
442
+ clearTimeout(timeout);
443
+ abortSignal?.removeEventListener("abort", onAbort);
444
+ };
445
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
446
+ });
447
+ let allRateLimitedRetries = 0;
448
+ while (true) {
449
+ const accountCount = accountManager.getAccountCount();
450
+ const attempted = new Set();
451
+ while (attempted.size < Math.max(1, accountCount)) {
452
+ const account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model);
453
+ if (!account || attempted.has(account.index)) {
454
+ break;
455
+ }
456
+ attempted.add(account.index);
457
+ let accountAuth = accountManager.toAuthDetails(account);
458
+ try {
459
+ if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
460
+ accountAuth = (await refreshAndUpdateToken(accountAuth, client));
461
+ accountManager.updateFromAuth(account, accountAuth);
462
+ accountManager.saveToDiskDebounced();
463
+ }
464
+ }
465
+ catch (err) {
466
+ logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${err?.message ?? String(err)}`);
467
+ accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
468
+ accountManager.saveToDiskDebounced();
469
+ continue;
470
+ }
471
+ const accountId = account.accountId ?? extractAccountId(accountAuth.access);
472
+ if (!accountId) {
473
+ accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
474
+ accountManager.saveToDiskDebounced();
475
+ continue;
476
+ }
477
+ account.accountId = accountId;
478
+ account.email =
479
+ extractAccountEmail(accountAuth.access) ?? account.email;
480
+ if (accountCount > 1 &&
481
+ accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
482
+ const accountLabel = formatAccountLabel(account, account.index);
483
+ await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
484
+ accountManager.markToastShown(account.index);
485
+ }
486
+ const headers = createCodexHeaders(requestInit, accountId, accountAuth.access, {
487
+ model,
488
+ promptCacheKey,
489
+ });
490
+ while (true) {
491
+ const response = await fetch(url, {
492
+ ...requestInit,
493
+ headers,
494
+ });
495
+ logRequest(LOG_STAGES.RESPONSE, {
496
+ status: response.status,
497
+ ok: response.ok,
498
+ statusText: response.statusText,
499
+ headers: Object.fromEntries(response.headers.entries()),
500
+ });
501
+ if (!response.ok) {
502
+ const contextOverflowResult = await handleContextOverflow(response, model);
503
+ if (contextOverflowResult.handled) {
504
+ return contextOverflowResult.response;
505
+ }
506
+ const { response: errorResponse, rateLimit, errorBody } = await handleErrorResponse(response);
507
+ if (recoveryHook && errorBody && isRecoverableError(errorBody)) {
508
+ const errorType = detectErrorType(errorBody);
509
+ const toastContent = getRecoveryToastContent(errorType);
510
+ await showToast(`${toastContent.title}: ${toastContent.message}`, "warning");
511
+ logDebug(`[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`);
512
+ }
513
+ if (rateLimit) {
514
+ const { attempt, delayMs } = getRateLimitBackoff(account.index, quotaKey, rateLimit.retryAfterMs);
515
+ const waitLabel = formatWaitTime(delayMs);
516
+ if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) {
517
+ if (accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
518
+ await showToast(`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning");
519
+ accountManager.markToastShown(account.index);
520
+ }
521
+ await sleep(delayMs);
522
+ continue;
523
+ }
524
+ accountManager.markRateLimited(account, delayMs, modelFamily, model);
525
+ accountManager.recordRateLimit(account, modelFamily, model);
526
+ account.lastSwitchReason = "rate-limit";
527
+ accountManager.saveToDiskDebounced();
528
+ if (accountManager.getAccountCount() > 1 &&
529
+ accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
530
+ await showToast(`Rate limited. Switching accounts (retry in ${waitLabel}).`, "warning");
531
+ accountManager.markToastShown(account.index);
532
+ }
533
+ break;
534
+ }
535
+ return errorResponse;
536
+ }
537
+ resetRateLimitBackoff(account.index, quotaKey);
538
+ accountManager.recordSuccess(account, modelFamily, model);
539
+ return await handleSuccessResponse(response, isStreaming);
540
+ }
541
+ }
542
+ const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
543
+ const count = accountManager.getAccountCount();
544
+ if (retryAllAccountsRateLimited &&
545
+ count > 0 &&
546
+ waitMs > 0 &&
547
+ (retryAllAccountsMaxWaitMs === 0 ||
548
+ waitMs <= retryAllAccountsMaxWaitMs) &&
549
+ allRateLimitedRetries < retryAllAccountsMaxRetries) {
550
+ const waitLabel = formatWaitTime(waitMs);
551
+ await showToast(`All ${count} account(s) are rate-limited. Waiting ${waitLabel}...`, "warning");
552
+ allRateLimitedRetries++;
553
+ await sleep(waitMs);
554
+ continue;
555
+ }
556
+ const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
557
+ const message = count === 0
558
+ ? "No OpenAI accounts configured. Run `opencode auth login`."
559
+ : `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`opencode auth login\`.`;
560
+ return new Response(JSON.stringify({ error: { message } }), {
561
+ status: 429,
562
+ headers: {
563
+ "content-type": "application/json; charset=utf-8",
564
+ },
565
+ });
566
+ }
567
+ },
568
+ };
569
+ },
570
+ methods: [
571
+ {
572
+ label: AUTH_LABELS.OAUTH,
573
+ type: "oauth",
574
+ /**
575
+ * OAuth authorization flow
576
+ *
577
+ * Steps:
578
+ * 1. Generate PKCE challenge and state for security
579
+ * 2. Start local OAuth callback server on port 1455
580
+ * 3. Open browser to OpenAI authorization page
581
+ * 4. Wait for user to complete login
582
+ * 5. Exchange authorization code for tokens
583
+ *
584
+ * @returns Authorization flow configuration
585
+ */
586
+ authorize: async (inputs) => {
587
+ console.log(`[DEBUG] authorize called, inputs:`, JSON.stringify(inputs));
588
+ if (inputs && Object.keys(inputs).length > 0) {
589
+ const accounts = [];
590
+ const noBrowser = inputs.noBrowser === "true" ||
591
+ inputs["no-browser"] === "true";
592
+ const useManualMode = noBrowser;
593
+ let startFresh = true;
594
+ const existingStorage = await hydrateEmails(await loadAccounts());
595
+ if (existingStorage && existingStorage.accounts.length > 0) {
596
+ const existingAccounts = existingStorage.accounts.map((account, index) => ({
597
+ accountId: account.accountId,
598
+ email: account.email,
599
+ index,
600
+ }));
601
+ const loginMode = await promptLoginMode(existingAccounts);
602
+ startFresh = loginMode === "fresh";
603
+ if (startFresh) {
604
+ console.log("\nStarting fresh - existing accounts will be replaced.\n");
605
+ }
606
+ else {
607
+ console.log("\nAdding to existing accounts.\n");
608
+ }
609
+ }
610
+ while (accounts.length < ACCOUNT_LIMITS.MAX_ACCOUNTS) {
611
+ console.log(`\n=== OpenAI OAuth (Account ${accounts.length + 1}) ===`);
612
+ const forceNewLogin = accounts.length > 0;
613
+ const result = await runOAuthFlow(useManualMode, forceNewLogin);
614
+ if (result.type === "success") {
615
+ const email = extractAccountEmail(result.access, result.idToken);
616
+ const accountId = extractAccountId(result.access);
617
+ const label = email || accountId || "Unknown account";
618
+ console.log(`\n✓ Authenticated as: ${label}\n`);
619
+ const isDuplicate = accounts.some((acc) => (accountId && extractAccountId(acc.access) === accountId) ||
620
+ (email && extractAccountEmail(acc.access, acc.idToken) === email));
621
+ if (isDuplicate) {
622
+ console.warn(`\n⚠️ WARNING: You authenticated with an account that is already in the list (${label}).`);
623
+ console.warn("This usually happens if you didn't log out or use a different browser profile.");
624
+ console.warn("The duplicate will update the existing entry.\n");
625
+ }
626
+ }
627
+ if (result.type === "failed") {
628
+ if (accounts.length === 0) {
629
+ return {
630
+ url: "",
631
+ instructions: "Authentication failed.",
632
+ method: "auto",
633
+ callback: async () => result,
634
+ };
635
+ }
636
+ console.warn(`[${PLUGIN_NAME}] Skipping failed account ${accounts.length + 1}`);
637
+ break;
638
+ }
639
+ accounts.push(result);
640
+ await showToast(`Account ${accounts.length} authenticated`, "success");
641
+ try {
642
+ const isFirstAccount = accounts.length === 1;
643
+ await persistAccountPool([result], isFirstAccount && startFresh);
644
+ }
645
+ catch (err) {
646
+ logDebug(`[${PLUGIN_NAME}] Failed to persist account pool: ${err?.message ?? String(err)}`);
647
+ }
648
+ if (accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) {
649
+ break;
650
+ }
651
+ let currentAccountCount = accounts.length;
652
+ try {
653
+ const currentStorage = await loadAccounts();
654
+ if (currentStorage) {
655
+ currentAccountCount = currentStorage.accounts.length;
656
+ }
657
+ }
658
+ catch (err) {
659
+ logDebug(`[${PLUGIN_NAME}] Failed to load accounts for count: ${err?.message ?? String(err)}`);
660
+ }
661
+ const addAnother = await promptAddAnotherAccount(currentAccountCount);
662
+ if (!addAnother) {
663
+ break;
664
+ }
665
+ }
666
+ const primary = accounts[0];
667
+ if (!primary) {
668
+ return {
669
+ url: "",
670
+ instructions: "Authentication cancelled",
671
+ method: "auto",
672
+ callback: async () => ({
673
+ type: "failed",
674
+ }),
675
+ };
676
+ }
677
+ let actualAccountCount = accounts.length;
678
+ try {
679
+ const finalStorage = await loadAccounts();
680
+ if (finalStorage) {
681
+ actualAccountCount = finalStorage.accounts.length;
682
+ }
683
+ }
684
+ catch (err) {
685
+ logDebug(`[${PLUGIN_NAME}] Failed to load final account count: ${err?.message ?? String(err)}`);
686
+ }
687
+ return {
688
+ url: "",
689
+ instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
690
+ method: "auto",
691
+ callback: async () => primary,
692
+ };
693
+ }
694
+ let startFresh = true;
695
+ const existingStorage = await hydrateEmails(await loadAccounts());
696
+ if (existingStorage && existingStorage.accounts.length > 0) {
697
+ const existingAccounts = existingStorage.accounts.map((account, index) => ({
698
+ accountId: account.accountId,
699
+ email: account.email,
700
+ index,
701
+ }));
702
+ const loginMode = await promptLoginMode(existingAccounts);
703
+ startFresh = loginMode === "fresh";
704
+ if (startFresh) {
705
+ console.log("\nStarting fresh - existing accounts will be replaced.\n");
706
+ }
707
+ else {
708
+ console.log("\nAdding to existing accounts.\n");
709
+ }
710
+ }
711
+ const { pkce, state, url } = await createAuthorizationFlow();
712
+ let serverInfo = null;
713
+ try {
714
+ serverInfo = await startLocalOAuthServer({ state });
715
+ }
716
+ catch (err) {
717
+ logDebug(`[${PLUGIN_NAME}] Failed to start OAuth server for add flow: ${err?.message ?? String(err)}`);
718
+ serverInfo = null;
719
+ }
720
+ openBrowserUrl(url);
721
+ if (!serverInfo || !serverInfo.ready) {
722
+ serverInfo?.close();
723
+ return buildManualOAuthFlow(pkce, url, async (tokens) => {
724
+ await persistAccountPool([tokens], startFresh);
725
+ });
726
+ }
727
+ return {
728
+ url,
729
+ method: "auto",
730
+ instructions: AUTH_LABELS.INSTRUCTIONS,
731
+ callback: async () => {
732
+ const result = await serverInfo.waitForCode(state);
733
+ serverInfo.close();
734
+ if (!result) {
735
+ return { type: "failed" };
736
+ }
737
+ const tokens = await exchangeAuthorizationCode(result.code, pkce.verifier, REDIRECT_URI);
738
+ if (tokens?.type === "success") {
739
+ await persistAccountPool([tokens], startFresh);
740
+ }
741
+ return tokens?.type === "success"
742
+ ? tokens
743
+ : { type: "failed" };
744
+ },
745
+ };
746
+ },
747
+ },
748
+ {
749
+ label: AUTH_LABELS.OAUTH_MANUAL,
750
+ type: "oauth",
751
+ authorize: async () => {
752
+ const { pkce, url } = await createAuthorizationFlow();
753
+ return buildManualOAuthFlow(pkce, url, async (tokens) => {
754
+ await persistAccountPool([tokens], false);
755
+ });
756
+ },
757
+ },
758
+ {
759
+ label: AUTH_LABELS.API_KEY,
760
+ type: "api",
761
+ },
762
+ ],
763
+ },
764
+ tool: {
765
+ "openai-accounts": tool({
766
+ description: "List all OpenAI OAuth accounts and the current active index.",
767
+ args: {},
768
+ async execute() {
769
+ const storage = await loadAccounts();
770
+ const storePath = getStoragePath();
771
+ if (!storage || storage.accounts.length === 0) {
772
+ return [
773
+ "No OpenAI accounts configured.",
774
+ "",
775
+ "Add accounts:",
776
+ " opencode auth login",
777
+ "",
778
+ `Storage: ${storePath}`,
779
+ ].join("\n");
780
+ }
781
+ const now = Date.now();
782
+ const activeIndex = resolveActiveIndex(storage, "codex");
783
+ const lines = [
784
+ `OpenAI Accounts (${storage.accounts.length}):`,
785
+ "",
786
+ " # Label Status",
787
+ "----------------------------------------------- ---------------------",
788
+ ];
789
+ storage.accounts.forEach((account, index) => {
790
+ const label = formatAccountLabel(account, index);
791
+ const statuses = [];
792
+ const rateLimit = formatRateLimitEntry(account, now);
793
+ if (index === activeIndex)
794
+ statuses.push("active");
795
+ if (rateLimit)
796
+ statuses.push("rate-limited");
797
+ if (typeof account.coolingDownUntil ===
798
+ "number" &&
799
+ account.coolingDownUntil > now) {
800
+ statuses.push("cooldown");
801
+ }
802
+ const statusText = statuses.length > 0 ? statuses.join(", ") : "ok";
803
+ const row = `${String(index + 1).padEnd(3)} ${label.padEnd(40)} ${statusText}`;
804
+ lines.push(row);
805
+ });
806
+ lines.push("");
807
+ lines.push(`Storage: ${storePath}`);
808
+ lines.push("");
809
+ lines.push("Commands:");
810
+ lines.push(" - Add account: opencode auth login");
811
+ lines.push(" - Switch account: openai-accounts-switch");
812
+ lines.push(" - Status details: openai-accounts-status");
813
+ return lines.join("\n");
814
+ },
815
+ }),
816
+ "openai-accounts-switch": tool({
817
+ description: "Switch active OpenAI account by index (1-based).",
818
+ args: {
819
+ index: tool.schema.number().describe("Account number to switch to (1-based, e.g., 1 for first account)"),
820
+ },
821
+ async execute({ index }) {
822
+ const storage = await loadAccounts();
823
+ if (!storage || storage.accounts.length === 0) {
824
+ return "No OpenAI accounts configured. Run: opencode auth login";
825
+ }
826
+ const targetIndex = Math.floor((index ?? 0) - 1);
827
+ if (!Number.isFinite(targetIndex) ||
828
+ targetIndex < 0 ||
829
+ targetIndex >= storage.accounts.length) {
830
+ return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}`;
831
+ }
832
+ const now = Date.now();
833
+ const account = storage.accounts[targetIndex];
834
+ if (account) {
835
+ account.lastUsed = now;
836
+ account.lastSwitchReason = "rotation";
837
+ }
838
+ storage.activeIndex = targetIndex;
839
+ storage.activeIndexByFamily = storage.activeIndexByFamily ?? {};
840
+ for (const family of MODEL_FAMILIES) {
841
+ storage.activeIndexByFamily[family] = targetIndex;
842
+ }
843
+ await saveAccounts(storage);
844
+ if (cachedAccountManager) {
845
+ cachedAccountManager.setActiveIndex(targetIndex);
846
+ await cachedAccountManager.saveToDisk();
847
+ }
848
+ const label = formatAccountLabel(account, targetIndex);
849
+ return `Switched to account: ${label}`;
850
+ },
851
+ }),
852
+ "openai-accounts-status": tool({
853
+ description: "Show detailed status of OpenAI accounts and rate limits.",
854
+ args: {
855
+ json: tool.schema.boolean().optional().describe("Return JSON instead of text"),
856
+ },
857
+ async execute({ json }) {
858
+ const storage = await loadAccounts();
859
+ if (!storage || storage.accounts.length === 0) {
860
+ return "No OpenAI accounts configured. Run: opencode auth login";
861
+ }
862
+ const now = Date.now();
863
+ const activeIndex = resolveActiveIndex(storage, "codex");
864
+ if (json) {
865
+ return JSON.stringify({
866
+ total: storage.accounts.length,
867
+ activeIndex,
868
+ activeIndexByFamily: storage.activeIndexByFamily ?? null,
869
+ storagePath: getStoragePath(),
870
+ accounts: storage.accounts.map((account, index) => ({
871
+ index,
872
+ active: index === activeIndex,
873
+ label: formatAccountLabel(account, index),
874
+ accountId: account.accountId ?? null,
875
+ email: account.email ?? null,
876
+ rateLimitResetTimes: account.rateLimitResetTimes ?? null,
877
+ coolingDownUntil: typeof account.coolingDownUntil === "number"
878
+ ? account.coolingDownUntil
879
+ : null,
880
+ cooldownReason: account.cooldownReason ?? null,
881
+ lastUsed: typeof account.lastUsed === "number"
882
+ ? account.lastUsed
883
+ : null,
884
+ })),
885
+ }, null, 2);
886
+ }
887
+ const lines = [
888
+ `Account Status (${storage.accounts.length} total):`,
889
+ "",
890
+ " # Label Active Rate Limit Cooldown Last Used",
891
+ "----------------------------------------------- ------ ---------------- ---------------- ----------------",
892
+ ];
893
+ storage.accounts.forEach((account, index) => {
894
+ const label = formatAccountLabel(account, index).padEnd(42);
895
+ const active = index === activeIndex ? "Yes" : "No";
896
+ const rateLimit = formatRateLimitEntry(account, now) ?? "None";
897
+ const cooldown = formatCooldown(account, now) ?? "No";
898
+ const lastUsed = typeof account.lastUsed === "number" && account.lastUsed > 0
899
+ ? `${formatWaitTime(now - account.lastUsed)} ago`
900
+ : "-";
901
+ const row = `${String(index + 1).padEnd(3)} ${label} ${active.padEnd(6)} ${rateLimit.padEnd(16)} ${cooldown.padEnd(16)} ${lastUsed}`;
902
+ lines.push(row);
903
+ });
904
+ lines.push("");
905
+ lines.push("Active index by model family:");
906
+ for (const family of MODEL_FAMILIES) {
907
+ const idx = storage.activeIndexByFamily?.[family];
908
+ const familyIndexLabel = typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-";
909
+ lines.push(` ${family}: ${familyIndexLabel}`);
910
+ }
911
+ lines.push("");
912
+ lines.push("Rate limits by model family (per account):");
913
+ storage.accounts.forEach((account, index) => {
914
+ const statuses = MODEL_FAMILIES.map((family) => {
915
+ const resetAt = getRateLimitResetTimeForFamily(account, now, family);
916
+ if (typeof resetAt !== "number")
917
+ return `${family}=ok`;
918
+ return `${family}=${formatWaitTime(resetAt - now)}`;
919
+ });
920
+ lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`);
921
+ });
922
+ return lines.join("\n");
923
+ },
924
+ }),
925
+ "openai-accounts-health": tool({
926
+ description: "Check health of all OpenAI accounts by validating refresh tokens.",
927
+ args: {},
928
+ async execute() {
929
+ const storage = await loadAccounts();
930
+ if (!storage || storage.accounts.length === 0) {
931
+ return "No OpenAI accounts configured. Run: opencode auth login";
932
+ }
933
+ const results = [
934
+ `Health Check (${storage.accounts.length} accounts):`,
935
+ "",
936
+ ];
937
+ let healthyCount = 0;
938
+ let unhealthyCount = 0;
939
+ for (let i = 0; i < storage.accounts.length; i++) {
940
+ const account = storage.accounts[i];
941
+ if (!account)
942
+ continue;
943
+ const label = formatAccountLabel(account, i);
944
+ try {
945
+ const refreshResult = await queuedRefresh(account.refreshToken);
946
+ if (refreshResult.type === "success") {
947
+ results.push(` ✓ ${label}: Healthy`);
948
+ healthyCount++;
949
+ }
950
+ else {
951
+ results.push(` ✗ ${label}: Token refresh failed`);
952
+ unhealthyCount++;
953
+ }
954
+ }
955
+ catch (error) {
956
+ const errorMsg = error instanceof Error ? error.message : String(error);
957
+ results.push(` ✗ ${label}: Error - ${errorMsg.slice(0, 50)}`);
958
+ unhealthyCount++;
959
+ }
960
+ }
961
+ results.push("");
962
+ results.push(`Summary: ${healthyCount} healthy, ${unhealthyCount} unhealthy`);
963
+ return results.join("\n");
964
+ },
965
+ }),
966
+ },
967
+ };
968
+ };
969
+ export const OpenAIAuthPlugin = OpenAIOAuthPlugin;
970
+ export default OpenAIOAuthPlugin;
971
+ //# sourceMappingURL=index.js.map