oc-codex-multi-account 1.0.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 (70) hide show
  1. package/README.md +321 -0
  2. package/dist/auth-sync.d.ts +3 -0
  3. package/dist/auth-sync.d.ts.map +1 -0
  4. package/dist/auth-sync.js +105 -0
  5. package/dist/auth-sync.js.map +1 -0
  6. package/dist/auth.d.ts +15 -0
  7. package/dist/auth.d.ts.map +1 -0
  8. package/dist/auth.js +236 -0
  9. package/dist/auth.js.map +1 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +160 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/codex-auth.d.ts +28 -0
  15. package/dist/codex-auth.d.ts.map +1 -0
  16. package/dist/codex-auth.js +174 -0
  17. package/dist/codex-auth.js.map +1 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +730 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/limits-refresh.d.ts +9 -0
  23. package/dist/limits-refresh.d.ts.map +1 -0
  24. package/dist/limits-refresh.js +48 -0
  25. package/dist/limits-refresh.js.map +1 -0
  26. package/dist/logger.d.ts +6 -0
  27. package/dist/logger.d.ts.map +1 -0
  28. package/dist/logger.js +52 -0
  29. package/dist/logger.js.map +1 -0
  30. package/dist/models.d.ts +7 -0
  31. package/dist/models.d.ts.map +1 -0
  32. package/dist/models.js +121 -0
  33. package/dist/models.js.map +1 -0
  34. package/dist/probe-limits.d.ts +10 -0
  35. package/dist/probe-limits.d.ts.map +1 -0
  36. package/dist/probe-limits.js +160 -0
  37. package/dist/probe-limits.js.map +1 -0
  38. package/dist/rate-limits.d.ts +6 -0
  39. package/dist/rate-limits.d.ts.map +1 -0
  40. package/dist/rate-limits.js +117 -0
  41. package/dist/rate-limits.js.map +1 -0
  42. package/dist/refresh-queue.d.ts +18 -0
  43. package/dist/refresh-queue.d.ts.map +1 -0
  44. package/dist/refresh-queue.js +78 -0
  45. package/dist/refresh-queue.js.map +1 -0
  46. package/dist/rotation.d.ts +20 -0
  47. package/dist/rotation.d.ts.map +1 -0
  48. package/dist/rotation.js +273 -0
  49. package/dist/rotation.js.map +1 -0
  50. package/dist/sessions-limits.d.ts +11 -0
  51. package/dist/sessions-limits.d.ts.map +1 -0
  52. package/dist/sessions-limits.js +123 -0
  53. package/dist/sessions-limits.js.map +1 -0
  54. package/dist/store.d.ts +23 -0
  55. package/dist/store.d.ts.map +1 -0
  56. package/dist/store.js +339 -0
  57. package/dist/store.js.map +1 -0
  58. package/dist/systemd.d.ts +10 -0
  59. package/dist/systemd.d.ts.map +1 -0
  60. package/dist/systemd.js +53 -0
  61. package/dist/systemd.js.map +1 -0
  62. package/dist/types.d.ts +98 -0
  63. package/dist/types.d.ts.map +1 -0
  64. package/dist/types.js +12 -0
  65. package/dist/types.js.map +1 -0
  66. package/dist/web.d.ts +6 -0
  67. package/dist/web.d.ts.map +1 -0
  68. package/dist/web.js +1857 -0
  69. package/dist/web.js.map +1 -0
  70. package/package.json +53 -0
package/dist/index.js ADDED
@@ -0,0 +1,730 @@
1
+ import fs from 'node:fs';
2
+ import { syncAuthFromOpenCode } from './auth-sync.js';
3
+ import { createAuthorizationFlow, loginAccount } from './auth.js';
4
+ import { extractRateLimitUpdate, mergeRateLimits } from './rate-limits.js';
5
+ import { getNextAccount, markAuthInvalid, markModelUnsupported, markRateLimited, markWorkspaceDeactivated } from './rotation.js';
6
+ import { listAccounts, updateAccount } from './store.js';
7
+ import { DEFAULT_CONFIG } from './types.js';
8
+ const PROVIDER_ID = 'openai';
9
+ const CODEX_BASE_URL = 'https://chatgpt.com/backend-api';
10
+ const REDIRECT_PORT = 1455;
11
+ const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/auth/callback`;
12
+ const URL_PATHS = {
13
+ RESPONSES: '/responses',
14
+ CODEX_RESPONSES: '/codex/responses'
15
+ };
16
+ const OPENAI_HEADERS = {
17
+ BETA: 'OpenAI-Beta',
18
+ ACCOUNT_ID: 'chatgpt-account-id',
19
+ ORIGINATOR: 'originator',
20
+ SESSION_ID: 'session_id',
21
+ CONVERSATION_ID: 'conversation_id'
22
+ };
23
+ const OPENAI_HEADER_VALUES = {
24
+ BETA_RESPONSES: 'responses=experimental',
25
+ ORIGINATOR_CODEX: 'codex_cli_rs'
26
+ };
27
+ const JWT_CLAIM_PATH = 'https://api.openai.com/auth';
28
+ let pluginConfig = { ...DEFAULT_CONFIG };
29
+ function configure(config) {
30
+ pluginConfig = { ...pluginConfig, ...config };
31
+ }
32
+ function decodeJWT(token) {
33
+ try {
34
+ const parts = token.split('.');
35
+ if (parts.length !== 3)
36
+ return null;
37
+ const payload = parts[1];
38
+ const decoded = Buffer.from(payload, 'base64').toString('utf-8');
39
+ return JSON.parse(decoded);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function extractRequestUrl(input) {
46
+ if (typeof input === 'string')
47
+ return input;
48
+ if (input instanceof URL)
49
+ return input.toString();
50
+ return input.url;
51
+ }
52
+ function rewriteUrlForCodex(url) {
53
+ return url.replace(URL_PATHS.RESPONSES, URL_PATHS.CODEX_RESPONSES);
54
+ }
55
+ function extractPathAndSearch(url) {
56
+ // OpenCode sometimes passes relative paths (e.g. "/chat/completions") or even
57
+ // malformed strings when provider base_url is missing (e.g. "undefined/...").
58
+ // We only need the path+query and then we force the ChatGPT backend base URL.
59
+ try {
60
+ const u = new URL(url);
61
+ return `${u.pathname}${u.search}`;
62
+ }
63
+ catch {
64
+ // best-effort fallback
65
+ }
66
+ const trimmed = String(url || '').trim();
67
+ if (trimmed.startsWith('/'))
68
+ return trimmed;
69
+ const firstSlash = trimmed.indexOf('/');
70
+ if (firstSlash >= 0)
71
+ return trimmed.slice(firstSlash);
72
+ return trimmed;
73
+ }
74
+ function toCodexBackendUrl(originalUrl) {
75
+ const pathAndSearch = extractPathAndSearch(originalUrl);
76
+ // Map OpenAI v1 endpoints to ChatGPT Codex endpoints.
77
+ let mapped = pathAndSearch;
78
+ if (mapped.includes(URL_PATHS.RESPONSES)) {
79
+ mapped = mapped.replace(URL_PATHS.RESPONSES, URL_PATHS.CODEX_RESPONSES);
80
+ }
81
+ else if (mapped.includes('/chat/completions')) {
82
+ mapped = mapped.replace('/chat/completions', '/codex/chat/completions');
83
+ }
84
+ return new URL(mapped, CODEX_BASE_URL).toString();
85
+ }
86
+ function filterInput(input) {
87
+ if (!Array.isArray(input))
88
+ return input;
89
+ return input
90
+ .filter((item) => item?.type !== 'item_reference')
91
+ .map((item) => {
92
+ if (item && typeof item === 'object' && 'id' in item) {
93
+ const { id, ...rest } = item;
94
+ return rest;
95
+ }
96
+ return item;
97
+ });
98
+ }
99
+ function normalizeModel(model) {
100
+ if (!model)
101
+ return 'gpt-5.1';
102
+ const modelId = model.includes('/') ? model.split('/').pop() : model;
103
+ const baseModel = modelId.replace(/-(?:none|low|medium|high|xhigh)$/, '');
104
+ // OpenCode currently allowlists gpt-5.2-codex, but we can route it to the latest
105
+ // Codex model on the ChatGPT backend for users who want the newest model without
106
+ // waiting for upstream registry updates.
107
+ const preferLatestRaw = process.env.OPENCODE_MULTI_AUTH_PREFER_CODEX_LATEST;
108
+ const preferLatest = preferLatestRaw !== '0' && preferLatestRaw !== 'false';
109
+ if (preferLatest && (baseModel === 'gpt-5.2-codex' || baseModel === 'gpt-5-codex')) {
110
+ const latestModel = (process.env.OPENCODE_MULTI_AUTH_CODEX_LATEST_MODEL || 'gpt-5.3-codex').trim();
111
+ if (process.env.OPENCODE_MULTI_AUTH_DEBUG === '1') {
112
+ console.log(`[multi-auth] model map: ${baseModel} -> ${latestModel}`);
113
+ }
114
+ return latestModel;
115
+ }
116
+ return baseModel;
117
+ }
118
+ function ensureContentType(headers) {
119
+ const responseHeaders = new Headers(headers);
120
+ if (!responseHeaders.has('content-type')) {
121
+ responseHeaders.set('content-type', 'text/event-stream; charset=utf-8');
122
+ }
123
+ return responseHeaders;
124
+ }
125
+ function parseSseStream(sseText) {
126
+ const lines = sseText.split('\n');
127
+ for (const line of lines) {
128
+ if (!line.startsWith('data: '))
129
+ continue;
130
+ try {
131
+ const data = JSON.parse(line.substring(6));
132
+ if (data?.type === 'response.done' || data?.type === 'response.completed') {
133
+ return data.response;
134
+ }
135
+ }
136
+ catch {
137
+ // ignore malformed chunks
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+ async function convertSseToJson(response, headers) {
143
+ if (!response.body) {
144
+ throw new Error('[multi-auth] Response has no body');
145
+ }
146
+ const reader = response.body.getReader();
147
+ const decoder = new TextDecoder();
148
+ let fullText = '';
149
+ while (true) {
150
+ const { done, value } = await reader.read();
151
+ if (done)
152
+ break;
153
+ fullText += decoder.decode(value, { stream: true });
154
+ }
155
+ const finalResponse = parseSseStream(fullText);
156
+ if (!finalResponse) {
157
+ return new Response(fullText, {
158
+ status: response.status,
159
+ statusText: response.statusText,
160
+ headers
161
+ });
162
+ }
163
+ const jsonHeaders = new Headers(headers);
164
+ jsonHeaders.set('content-type', 'application/json; charset=utf-8');
165
+ return new Response(JSON.stringify(finalResponse), {
166
+ status: response.status,
167
+ statusText: response.statusText,
168
+ headers: jsonHeaders
169
+ });
170
+ }
171
+ /**
172
+ * Multi-account OAuth plugin for OpenCode
173
+ *
174
+ * Rotates between multiple ChatGPT Plus/Pro accounts for rate limit resilience.
175
+ */
176
+ const MultiAuthPlugin = async ({ client, $, project, directory }) => {
177
+ const terminalNotifierPath = (() => {
178
+ const candidates = [
179
+ '/opt/homebrew/bin/terminal-notifier',
180
+ '/usr/local/bin/terminal-notifier'
181
+ ];
182
+ for (const c of candidates) {
183
+ try {
184
+ if (fs.existsSync(c))
185
+ return c;
186
+ }
187
+ catch {
188
+ // ignore
189
+ }
190
+ }
191
+ return null;
192
+ })();
193
+ const notifyEnabledRaw = process.env.OPENCODE_MULTI_AUTH_NOTIFY;
194
+ const notifyEnabled = notifyEnabledRaw !== '0' && notifyEnabledRaw !== 'false';
195
+ const notifySound = (process.env.OPENCODE_MULTI_AUTH_NOTIFY_SOUND || '/System/Library/Sounds/Glass.aiff').trim();
196
+ const lastStatusBySession = new Map();
197
+ const lastNotifiedAtByKey = new Map();
198
+ const lastRetryAttemptBySession = new Map();
199
+ const escapeAppleScriptString = (value) => {
200
+ return String(value)
201
+ .replaceAll('\\', '\\\\')
202
+ .replaceAll('"', '\"')
203
+ .replaceAll(String.fromCharCode(10), '\n');
204
+ };
205
+ let didWarnTerminalNotifier = false;
206
+ const notifyMac = (title, message, clickUrl) => {
207
+ if (!notifyEnabled)
208
+ return;
209
+ if (process.platform !== 'darwin')
210
+ return;
211
+ const macOpenRaw = process.env.OPENCODE_MULTI_AUTH_NOTIFY_MAC_OPEN;
212
+ const macOpenEnabled = macOpenRaw !== '0' && macOpenRaw !== 'false';
213
+ // Best effort: clickable notifications require terminal-notifier.
214
+ if (macOpenEnabled && clickUrl && terminalNotifierPath) {
215
+ try {
216
+ $ `${terminalNotifierPath} -title ${title} -message ${message} -open ${clickUrl}`
217
+ .nothrow()
218
+ .catch(() => { });
219
+ }
220
+ catch {
221
+ // ignore
222
+ }
223
+ }
224
+ else {
225
+ if (macOpenEnabled && clickUrl && !terminalNotifierPath && !didWarnTerminalNotifier) {
226
+ didWarnTerminalNotifier = true;
227
+ if (process.env.OPENCODE_MULTI_AUTH_DEBUG === '1') {
228
+ console.log('[multi-auth] mac click-to-open requires terminal-notifier (brew install terminal-notifier)');
229
+ }
230
+ }
231
+ try {
232
+ const osascript = '/usr/bin/osascript';
233
+ const safeTitle = escapeAppleScriptString(title);
234
+ const safeMessage = escapeAppleScriptString(message);
235
+ const script = `display notification "${safeMessage}" with title "${safeTitle}"`;
236
+ // Fire-and-forget: never block OpenCode event processing.
237
+ $ `${osascript} -e ${script}`.nothrow().catch(() => { });
238
+ }
239
+ catch {
240
+ // ignore
241
+ }
242
+ }
243
+ if (!notifySound)
244
+ return;
245
+ try {
246
+ const afplay = '/usr/bin/afplay';
247
+ $ `${afplay} ${notifySound}`.nothrow().catch(() => { });
248
+ }
249
+ catch {
250
+ // ignore
251
+ }
252
+ };
253
+ const ntfyUrl = (process.env.OPENCODE_MULTI_AUTH_NOTIFY_NTFY_URL || '').trim();
254
+ const ntfyToken = (process.env.OPENCODE_MULTI_AUTH_NOTIFY_NTFY_TOKEN || '').trim();
255
+ const notifyUiBaseUrl = (process.env.OPENCODE_MULTI_AUTH_NOTIFY_UI_BASE_URL || '').trim();
256
+ const getSessionUrl = (sessionID) => {
257
+ const base = (notifyUiBaseUrl || '').replace(/\/$/, '');
258
+ if (!base)
259
+ return '';
260
+ return `${base}/session/${sessionID}`;
261
+ };
262
+ const projectLabel = (project?.name || project?.id || '').trim() || 'OpenCode';
263
+ const sessionMetaCache = new Map();
264
+ const getSessionMeta = async (sessionID) => {
265
+ const cached = sessionMetaCache.get(sessionID);
266
+ if (cached?.title)
267
+ return cached;
268
+ try {
269
+ const res = await client.session.get({
270
+ path: { id: sessionID },
271
+ query: { directory }
272
+ });
273
+ // @opencode-ai/sdk returns { data } shape.
274
+ const data = res?.data;
275
+ const meta = { title: data?.title };
276
+ sessionMetaCache.set(sessionID, meta);
277
+ return meta;
278
+ }
279
+ catch {
280
+ const meta = cached || {};
281
+ sessionMetaCache.set(sessionID, meta);
282
+ return meta;
283
+ }
284
+ };
285
+ const formatTitle = (kind) => {
286
+ if (kind === 'error')
287
+ return `OpenCode - ${projectLabel} - Error`;
288
+ if (kind === 'retry')
289
+ return `OpenCode - ${projectLabel} - Retrying`;
290
+ return `OpenCode - ${projectLabel}`;
291
+ };
292
+ const formatBody = async (kind, sessionID, detail) => {
293
+ const meta = await getSessionMeta(sessionID);
294
+ const titleLine = meta.title ? `Task: ${meta.title}` : '';
295
+ const url = getSessionUrl(sessionID);
296
+ if (kind === 'idle') {
297
+ return [titleLine, `Session finished: ${sessionID}`, detail || '', url].filter(Boolean).join('\n');
298
+ }
299
+ if (kind === 'retry') {
300
+ return [titleLine, `Retrying: ${sessionID}`, detail || '', url].filter(Boolean).join('\n');
301
+ }
302
+ return [titleLine, `Error: ${sessionID}`, detail || '', url].filter(Boolean).join('\n');
303
+ };
304
+ const notifyMacRich = async (kind, sessionID, detail) => {
305
+ const body = await formatBody(kind, sessionID, detail);
306
+ notifyMac(formatTitle(kind), body, getSessionUrl(sessionID) || undefined);
307
+ };
308
+ const notifyNtfyRich = async (kind, sessionID, detail) => {
309
+ if (!notifyEnabled)
310
+ return;
311
+ if (!ntfyUrl)
312
+ return;
313
+ const sessionUrl = getSessionUrl(sessionID);
314
+ const title = formatTitle(kind);
315
+ const body = await formatBody(kind, sessionID, detail);
316
+ // ntfy priority: 1=min, 3=default, 5=max
317
+ const priority = kind === 'error' ? '5' : kind === 'retry' ? '4' : '3';
318
+ const headers = {
319
+ 'Content-Type': 'text/plain; charset=utf-8',
320
+ 'Title': title,
321
+ 'Priority': priority
322
+ };
323
+ if (sessionUrl)
324
+ headers['Click'] = sessionUrl;
325
+ if (ntfyToken)
326
+ headers['Authorization'] = `Bearer ${ntfyToken}`;
327
+ try {
328
+ await fetch(ntfyUrl, { method: 'POST', headers, body });
329
+ }
330
+ catch {
331
+ // ignore
332
+ }
333
+ };
334
+ const shouldThrottle = (key, minMs) => {
335
+ const last = lastNotifiedAtByKey.get(key) || 0;
336
+ const now = Date.now();
337
+ if (now - last < minMs)
338
+ return true;
339
+ lastNotifiedAtByKey.set(key, now);
340
+ return false;
341
+ };
342
+ const formatRetryDetail = (status) => {
343
+ const attempt = typeof status?.attempt === 'number' ? status.attempt : undefined;
344
+ const message = typeof status?.message === 'string' ? status.message : '';
345
+ const next = typeof status?.next === 'number' ? status.next : undefined;
346
+ const parts = [];
347
+ if (typeof attempt === 'number')
348
+ parts.push(`Attempt: ${attempt}`);
349
+ // OpenCode has emitted both "seconds-until-next" and "epoch ms" variants over time.
350
+ if (typeof next === 'number') {
351
+ const seconds = next > 1e12 ? Math.max(0, Math.round((next - Date.now()) / 1000)) : Math.max(0, Math.round(next));
352
+ parts.push(`Next in: ${seconds}s`);
353
+ }
354
+ if (message)
355
+ parts.push(message);
356
+ return parts.join(' | ');
357
+ };
358
+ const formatErrorDetail = (err) => {
359
+ if (!err || typeof err !== 'object')
360
+ return '';
361
+ const name = typeof err.name === 'string' ? err.name : '';
362
+ const code = typeof err.code === 'string' ? err.code : '';
363
+ const message = (typeof err.message === 'string' && err.message) ||
364
+ (typeof err.error?.message === 'string' && err.error.message) ||
365
+ '';
366
+ return [name, code, message].filter(Boolean).join(': ');
367
+ };
368
+ const notifyRich = async (kind, sessionID, detail) => {
369
+ try {
370
+ await notifyMacRich(kind, sessionID, detail);
371
+ }
372
+ catch {
373
+ // ignore
374
+ }
375
+ try {
376
+ await notifyNtfyRich(kind, sessionID, detail);
377
+ }
378
+ catch {
379
+ // ignore
380
+ }
381
+ };
382
+ return {
383
+ event: async ({ event }) => {
384
+ if (!notifyEnabled)
385
+ return;
386
+ if (!event || !('type' in event))
387
+ return;
388
+ if (event.type === 'session.created' || event.type === 'session.updated') {
389
+ const info = event.properties?.info;
390
+ const id = info?.id;
391
+ if (id) {
392
+ sessionMetaCache.set(id, { title: info?.title });
393
+ }
394
+ return;
395
+ }
396
+ if (event.type === 'session.status') {
397
+ const sessionID = event.properties?.sessionID;
398
+ const status = event.properties?.status;
399
+ const statusType = status?.type;
400
+ if (!sessionID || !statusType)
401
+ return;
402
+ lastStatusBySession.set(sessionID, statusType);
403
+ if (statusType === 'retry') {
404
+ const attempt = typeof status?.attempt === 'number' ? status.attempt : undefined;
405
+ const prevAttempt = lastRetryAttemptBySession.get(sessionID);
406
+ if (typeof attempt === 'number') {
407
+ if (prevAttempt === attempt && shouldThrottle(`retry:${sessionID}:${attempt}`, 5000)) {
408
+ return;
409
+ }
410
+ lastRetryAttemptBySession.set(sessionID, attempt);
411
+ }
412
+ const key = `retry:${sessionID}:${typeof attempt === 'number' ? attempt : 'na'}`;
413
+ if (shouldThrottle(key, 2000))
414
+ return;
415
+ await notifyRich('retry', sessionID, formatRetryDetail(status));
416
+ }
417
+ return;
418
+ }
419
+ if (event.type === 'session.error') {
420
+ const sessionID = event.properties?.sessionID;
421
+ const id = sessionID || 'unknown';
422
+ const err = event.properties?.error;
423
+ const detail = formatErrorDetail(err);
424
+ const key = `error:${id}:${detail}`;
425
+ if (shouldThrottle(key, 2000))
426
+ return;
427
+ await notifyRich('error', id, detail);
428
+ return;
429
+ }
430
+ if (event.type === 'session.idle') {
431
+ const sessionID = event.properties?.sessionID;
432
+ if (!sessionID)
433
+ return;
434
+ const prev = lastStatusBySession.get(sessionID);
435
+ if (prev === 'busy' || prev === 'retry') {
436
+ if (shouldThrottle(`idle:${sessionID}`, 2000))
437
+ return;
438
+ await notifyRich('idle', sessionID);
439
+ }
440
+ lastStatusBySession.set(sessionID, 'idle');
441
+ }
442
+ },
443
+ config: async (config) => {
444
+ const injectModelsRaw = process.env.OPENCODE_MULTI_AUTH_INJECT_MODELS;
445
+ const injectModels = injectModelsRaw === '1' || injectModelsRaw === 'true';
446
+ if (!injectModels)
447
+ return;
448
+ const latestModel = (process.env.OPENCODE_MULTI_AUTH_CODEX_LATEST_MODEL || 'gpt-5.3-codex').trim();
449
+ try {
450
+ const openai = config.provider?.[PROVIDER_ID] || null;
451
+ if (!openai || typeof openai !== 'object')
452
+ return;
453
+ openai.models ||= {};
454
+ if (!openai.models[latestModel]) {
455
+ openai.models[latestModel] = {
456
+ id: latestModel,
457
+ name: 'GPT-5.3 Codex',
458
+ reasoning: true,
459
+ tool_call: true,
460
+ temperature: true,
461
+ limit: {
462
+ // Be conservative: upstream model metadata changes over time and
463
+ // incorrect limits prevent OpenCode's compaction from triggering.
464
+ context: 200000,
465
+ output: 8192
466
+ }
467
+ };
468
+ }
469
+ if (process.env.OPENCODE_MULTI_AUTH_DEBUG === '1') {
470
+ console.log(`[multi-auth] injected ${latestModel} into runtime config`);
471
+ }
472
+ }
473
+ catch (err) {
474
+ if (process.env.OPENCODE_MULTI_AUTH_DEBUG === '1') {
475
+ console.log('[multi-auth] config injection failed:', err);
476
+ }
477
+ }
478
+ },
479
+ auth: {
480
+ provider: PROVIDER_ID,
481
+ /**
482
+ * Loader configures the SDK with multi-account rotation
483
+ */
484
+ async loader(getAuth, provider) {
485
+ await syncAuthFromOpenCode(getAuth);
486
+ const accounts = listAccounts();
487
+ if (accounts.length === 0) {
488
+ console.log('[multi-auth] No accounts configured. Run: opencode-multi-auth add <alias>');
489
+ return {};
490
+ }
491
+ // Custom fetch with multi-account rotation
492
+ const customFetch = async (input, init) => {
493
+ await syncAuthFromOpenCode(getAuth);
494
+ const rotation = await getNextAccount(pluginConfig);
495
+ if (!rotation) {
496
+ return new Response(JSON.stringify({ error: { message: 'No available accounts' } }), { status: 503, headers: { 'Content-Type': 'application/json' } });
497
+ }
498
+ const { account, token } = rotation;
499
+ const decoded = decodeJWT(token);
500
+ const accountId = decoded?.[JWT_CLAIM_PATH]?.chatgpt_account_id;
501
+ if (!accountId) {
502
+ return new Response(JSON.stringify({ error: { message: '[multi-auth] Failed to extract accountId from token' } }), { status: 401, headers: { 'Content-Type': 'application/json' } });
503
+ }
504
+ const originalUrl = extractRequestUrl(input);
505
+ const url = toCodexBackendUrl(originalUrl);
506
+ let body = {};
507
+ try {
508
+ body = init?.body ? JSON.parse(init.body) : {};
509
+ }
510
+ catch {
511
+ body = {};
512
+ }
513
+ const isStreaming = body?.stream === true;
514
+ const normalizedModel = normalizeModel(body.model);
515
+ const reasoningMatch = body.model?.match(/-(none|low|medium|high|xhigh)$/);
516
+ const payload = {
517
+ ...body,
518
+ model: normalizedModel,
519
+ store: false
520
+ };
521
+ // Note: The ChatGPT Codex backend does not currently accept
522
+ // `truncation`. Keep this opt-in and default off.
523
+ if (payload.truncation === undefined) {
524
+ const truncationRaw = (process.env.OPENCODE_MULTI_AUTH_TRUNCATION || '').trim();
525
+ if (truncationRaw && truncationRaw !== 'disabled' && truncationRaw !== 'false' && truncationRaw !== '0') {
526
+ payload.truncation = truncationRaw;
527
+ }
528
+ }
529
+ if (payload.input) {
530
+ payload.input = filterInput(payload.input);
531
+ }
532
+ if (reasoningMatch?.[1]) {
533
+ payload.reasoning = {
534
+ ...(payload.reasoning || {}),
535
+ effort: reasoningMatch[1],
536
+ summary: payload.reasoning?.summary || 'auto'
537
+ };
538
+ }
539
+ delete payload.reasoning_effort;
540
+ try {
541
+ const headers = new Headers(init?.headers || {});
542
+ headers.delete('x-api-key');
543
+ headers.set('Content-Type', 'application/json');
544
+ headers.set('Authorization', `Bearer ${token}`);
545
+ headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
546
+ headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
547
+ headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
548
+ const cacheKey = payload?.prompt_cache_key;
549
+ if (cacheKey) {
550
+ headers.set(OPENAI_HEADERS.CONVERSATION_ID, cacheKey);
551
+ headers.set(OPENAI_HEADERS.SESSION_ID, cacheKey);
552
+ }
553
+ else {
554
+ headers.delete(OPENAI_HEADERS.CONVERSATION_ID);
555
+ headers.delete(OPENAI_HEADERS.SESSION_ID);
556
+ }
557
+ headers.set('accept', 'text/event-stream');
558
+ const res = await fetch(url, {
559
+ method: init?.method || 'POST',
560
+ headers,
561
+ body: JSON.stringify(payload)
562
+ });
563
+ const limitUpdate = extractRateLimitUpdate(res.headers);
564
+ if (limitUpdate) {
565
+ updateAccount(account.alias, {
566
+ rateLimits: mergeRateLimits(account.rateLimits, limitUpdate)
567
+ });
568
+ }
569
+ // Handle rate limiting with automatic rotation
570
+ if (res.status === 401 || res.status === 403) {
571
+ const errorData = await res.clone().json().catch(() => ({}));
572
+ const message = errorData?.error?.message || '';
573
+ if (message.toLowerCase().includes('invalidated') || res.status === 401) {
574
+ markAuthInvalid(account.alias);
575
+ }
576
+ const retryRotation = await getNextAccount(pluginConfig);
577
+ if (retryRotation && retryRotation.account.alias !== account.alias) {
578
+ return customFetch(input, init);
579
+ }
580
+ return new Response(JSON.stringify({
581
+ error: {
582
+ message: `[multi-auth][acc=${account.alias}] Unauthorized on all accounts. ${message}`.trim()
583
+ }
584
+ }), { status: res.status, headers: { 'Content-Type': 'application/json' } });
585
+ }
586
+ if (res.status === 429) {
587
+ markRateLimited(account.alias, pluginConfig.rateLimitCooldownMs);
588
+ // Try another account
589
+ const retryRotation = await getNextAccount(pluginConfig);
590
+ if (retryRotation && retryRotation.account.alias !== account.alias) {
591
+ return customFetch(input, init);
592
+ }
593
+ // All accounts exhausted
594
+ const errorData = await res.json().catch(() => ({}));
595
+ return new Response(JSON.stringify({
596
+ error: {
597
+ message: `[multi-auth][acc=${account.alias}] Rate limited on all accounts. ${errorData.error?.message || ''}`
598
+ }
599
+ }), { status: 429, headers: { 'Content-Type': 'application/json' } });
600
+ }
601
+ if (res.status === 402) {
602
+ // Some accounts can temporarily be in a deactivated workspace state.
603
+ // Rotate to the next account instead of hard-failing the request.
604
+ const errorData = await res.clone().json().catch(() => null);
605
+ const errorText = await res.clone().text().catch(() => '');
606
+ const code = (typeof errorData?.detail?.code === 'string' && errorData.detail.code) ||
607
+ (typeof errorData?.error?.code === 'string' && errorData.error.code) ||
608
+ '';
609
+ const message = (typeof errorData?.detail?.message === 'string' && errorData.detail.message) ||
610
+ (typeof errorData?.detail === 'string' && errorData.detail) ||
611
+ (typeof errorData?.error?.message === 'string' && errorData.error.message) ||
612
+ (typeof errorData?.message === 'string' && errorData.message) ||
613
+ errorText ||
614
+ '';
615
+ const isDeactivatedWorkspace = code === 'deactivated_workspace' ||
616
+ message.toLowerCase().includes('deactivated_workspace') ||
617
+ message.toLowerCase().includes('deactivated workspace');
618
+ if (isDeactivatedWorkspace) {
619
+ markWorkspaceDeactivated(account.alias, pluginConfig.workspaceDeactivatedCooldownMs, {
620
+ error: message || code
621
+ });
622
+ const retryRotation = await getNextAccount(pluginConfig);
623
+ if (retryRotation && retryRotation.account.alias !== account.alias) {
624
+ return customFetch(input, init);
625
+ }
626
+ return new Response(JSON.stringify({
627
+ error: {
628
+ message: `[multi-auth][acc=${account.alias}] Workspace deactivated on all accounts. ${message || code}`.trim()
629
+ }
630
+ }), { status: 402, headers: { 'Content-Type': 'application/json' } });
631
+ }
632
+ }
633
+ if (res.status === 400) {
634
+ // Some accounts get staged access to newer Codex models (e.g. gpt-5.3-codex).
635
+ // If the backend says the model isn't supported for this account, temporarily
636
+ // skip it instead of trapping the whole rotation on a permanent 400 loop.
637
+ const errorData = await res.clone().json().catch(() => ({}));
638
+ const message = (typeof errorData?.detail === 'string' && errorData.detail) ||
639
+ (typeof errorData?.error?.message === 'string' && errorData.error.message) ||
640
+ (typeof errorData?.message === 'string' && errorData.message) ||
641
+ '';
642
+ const isModelUnsupported = typeof message === 'string' &&
643
+ message.toLowerCase().includes('model is not supported') &&
644
+ message.toLowerCase().includes('chatgpt account');
645
+ if (isModelUnsupported) {
646
+ markModelUnsupported(account.alias, pluginConfig.modelUnsupportedCooldownMs, {
647
+ model: normalizedModel,
648
+ error: message
649
+ });
650
+ const retryRotation = await getNextAccount(pluginConfig);
651
+ if (retryRotation && retryRotation.account.alias !== account.alias) {
652
+ return customFetch(input, init);
653
+ }
654
+ return new Response(JSON.stringify({
655
+ error: {
656
+ message: `[multi-auth] Model not supported on all accounts. ${message}`.trim()
657
+ }
658
+ }), { status: 400, headers: { 'Content-Type': 'application/json' } });
659
+ }
660
+ }
661
+ if (!res.ok) {
662
+ return res;
663
+ }
664
+ const responseHeaders = ensureContentType(res.headers);
665
+ if (!isStreaming && responseHeaders.get('content-type')?.includes('text/event-stream')) {
666
+ return await convertSseToJson(res, responseHeaders);
667
+ }
668
+ return res;
669
+ }
670
+ catch (err) {
671
+ return new Response(JSON.stringify({ error: { message: `[multi-auth] Request failed: ${err}` } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
672
+ }
673
+ };
674
+ // Return SDK configuration with custom fetch for rotation
675
+ return {
676
+ apiKey: 'chatgpt-oauth',
677
+ baseURL: CODEX_BASE_URL,
678
+ fetch: customFetch
679
+ };
680
+ },
681
+ methods: [
682
+ {
683
+ label: 'ChatGPT OAuth (Multi-Account)',
684
+ type: 'oauth',
685
+ prompts: [
686
+ {
687
+ type: 'text',
688
+ key: 'alias',
689
+ message: 'Account alias (e.g., personal, work)',
690
+ placeholder: 'personal'
691
+ }
692
+ ],
693
+ /**
694
+ * OAuth flow - opens browser for ChatGPT login
695
+ */
696
+ authorize: async (inputs) => {
697
+ const alias = inputs?.alias || `account-${Date.now()}`;
698
+ const flow = await createAuthorizationFlow();
699
+ return {
700
+ url: flow.url,
701
+ method: 'auto',
702
+ instructions: `Login with your ChatGPT Plus/Pro account for "${alias}"`,
703
+ callback: async () => {
704
+ try {
705
+ const account = await loginAccount(alias, flow);
706
+ return {
707
+ type: 'success',
708
+ provider: PROVIDER_ID,
709
+ refresh: account.refreshToken,
710
+ access: account.accessToken,
711
+ expires: account.expiresAt
712
+ };
713
+ }
714
+ catch {
715
+ return { type: 'failed' };
716
+ }
717
+ }
718
+ };
719
+ }
720
+ },
721
+ {
722
+ label: 'Skip (use existing accounts)',
723
+ type: 'api'
724
+ }
725
+ ]
726
+ }
727
+ };
728
+ };
729
+ export default MultiAuthPlugin;
730
+ //# sourceMappingURL=index.js.map