openllmprovider 0.1.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 (91) hide show
  1. package/README.md +192 -0
  2. package/dist/auth/index.cjs +6 -0
  3. package/dist/auth/index.d.cts +3 -0
  4. package/dist/auth/index.d.mts +3 -0
  5. package/dist/auth/index.mjs +3 -0
  6. package/dist/auto-C2hXJY13.d.cts +33 -0
  7. package/dist/auto-C2hXJY13.d.cts.map +1 -0
  8. package/dist/auto-CBqNYBXs.mjs +48 -0
  9. package/dist/auto-CBqNYBXs.mjs.map +1 -0
  10. package/dist/auto-CInerwvs.d.mts +33 -0
  11. package/dist/auto-CInerwvs.d.mts.map +1 -0
  12. package/dist/auto-D77wgMqO.cjs +59 -0
  13. package/dist/auto-D77wgMqO.cjs.map +1 -0
  14. package/dist/file-DB-rxfzi.mjs +77 -0
  15. package/dist/file-DB-rxfzi.mjs.map +1 -0
  16. package/dist/file-DZ7FGcSW.cjs +73 -0
  17. package/dist/file-DZ7FGcSW.cjs.map +1 -0
  18. package/dist/index.cjs +1909 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.cts +1239 -0
  21. package/dist/index.d.cts.map +1 -0
  22. package/dist/index.d.mts +1241 -0
  23. package/dist/index.d.mts.map +1 -0
  24. package/dist/index.mjs +1891 -0
  25. package/dist/index.mjs.map +1 -0
  26. package/dist/logger-BsHpI_fH.mjs +11 -0
  27. package/dist/logger-BsHpI_fH.mjs.map +1 -0
  28. package/dist/logger-jRimlMFR.cjs +69 -0
  29. package/dist/logger-jRimlMFR.cjs.map +1 -0
  30. package/dist/plugin/index.cjs +29 -0
  31. package/dist/plugin/index.cjs.map +1 -0
  32. package/dist/plugin/index.d.cts +10 -0
  33. package/dist/plugin/index.d.cts.map +1 -0
  34. package/dist/plugin/index.d.mts +10 -0
  35. package/dist/plugin/index.d.mts.map +1 -0
  36. package/dist/plugin/index.mjs +25 -0
  37. package/dist/plugin/index.mjs.map +1 -0
  38. package/dist/plugin-BkeUu5LW.d.mts +46 -0
  39. package/dist/plugin-BkeUu5LW.d.mts.map +1 -0
  40. package/dist/plugin-wK7RmJhZ.d.cts +46 -0
  41. package/dist/plugin-wK7RmJhZ.d.cts.map +1 -0
  42. package/dist/resolver-BA7LWSJO.mjs +645 -0
  43. package/dist/resolver-BA7LWSJO.mjs.map +1 -0
  44. package/dist/resolver-BMTvzTt9.cjs +662 -0
  45. package/dist/resolver-BMTvzTt9.cjs.map +1 -0
  46. package/dist/resolver-MgJryMWG.d.cts +75 -0
  47. package/dist/resolver-MgJryMWG.d.cts.map +1 -0
  48. package/dist/resolver-_gfXzr_S.d.mts +76 -0
  49. package/dist/resolver-_gfXzr_S.d.mts.map +1 -0
  50. package/dist/storage/index.cjs +7 -0
  51. package/dist/storage/index.d.cts +12 -0
  52. package/dist/storage/index.d.cts.map +1 -0
  53. package/dist/storage/index.d.mts +12 -0
  54. package/dist/storage/index.d.mts.map +1 -0
  55. package/dist/storage/index.mjs +4 -0
  56. package/package.json +137 -0
  57. package/src/auth/.gitkeep +0 -0
  58. package/src/auth/index.ts +10 -0
  59. package/src/auth/resolver.ts +46 -0
  60. package/src/auth/scanners.ts +462 -0
  61. package/src/auth/store.ts +357 -0
  62. package/src/catalog/.gitkeep +0 -0
  63. package/src/catalog/catalog.ts +302 -0
  64. package/src/catalog/index.ts +17 -0
  65. package/src/catalog/mapper.ts +129 -0
  66. package/src/catalog/merger.ts +99 -0
  67. package/src/index.ts +37 -0
  68. package/src/logger.ts +7 -0
  69. package/src/plugin/.gitkeep +0 -0
  70. package/src/plugin/anthropic.test.ts +505 -0
  71. package/src/plugin/anthropic.ts +324 -0
  72. package/src/plugin/codex.ts +656 -0
  73. package/src/plugin/copilot.ts +161 -0
  74. package/src/plugin/google.ts +454 -0
  75. package/src/plugin/index.ts +30 -0
  76. package/src/provider/.gitkeep +0 -0
  77. package/src/provider/bundled.ts +59 -0
  78. package/src/provider/index.ts +249 -0
  79. package/src/provider/state.ts +163 -0
  80. package/src/storage/.gitkeep +0 -0
  81. package/src/storage/auto.ts +32 -0
  82. package/src/storage/file.ts +84 -0
  83. package/src/storage/index.ts +10 -0
  84. package/src/storage/memory.ts +23 -0
  85. package/src/types/.gitkeep +0 -0
  86. package/src/types/auth.ts +18 -0
  87. package/src/types/errors.ts +87 -0
  88. package/src/types/index.ts +26 -0
  89. package/src/types/model.ts +88 -0
  90. package/src/types/plugin.ts +49 -0
  91. package/src/types/provider.ts +48 -0
@@ -0,0 +1,656 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createHash, randomBytes } from 'node:crypto'
3
+ import { createServer } from 'node:http'
4
+ import { createLogger } from '../logger.js'
5
+ import type { AuthCredential, AuthHook, AuthMethod, ProviderInfo } from '../types/plugin.js'
6
+
7
+ const log = createLogger('plugin:codex')
8
+
9
+ const OAUTH_DUMMY_KEY = 'codex-oauth-placeholder'
10
+ const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
11
+ const ISSUER = 'https://auth.openai.com'
12
+ const OPENAI_AUTHORIZE_URL = `${ISSUER}/oauth/authorize`
13
+ const OPENAI_TOKEN_URL = `${ISSUER}/oauth/token`
14
+ const OPENAI_DEVICE_USERCODE_URL = `${ISSUER}/api/accounts/deviceauth/usercode`
15
+ const OPENAI_DEVICE_TOKEN_URL = `${ISSUER}/api/accounts/deviceauth/token`
16
+ const OPENAI_DEVICE_VERIFY_URL = `${ISSUER}/codex/device`
17
+ const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses'
18
+ const DEVICE_USER_AGENT = 'openllmprovider/codex-auth'
19
+ const OAUTH_CALLBACK_PORT = 1455
20
+ const OAUTH_CALLBACK_PATH = '/auth/callback'
21
+ const OAUTH_CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
22
+
23
+ interface TokenResponse {
24
+ access_token: string
25
+ refresh_token?: string
26
+ expires_in: number
27
+ token_type: string
28
+ }
29
+
30
+ function parseTokenResponse(raw: Record<string, unknown>): TokenResponse {
31
+ return {
32
+ access_token: String(raw.access_token ?? ''),
33
+ refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
34
+ expires_in: Number(raw.expires_in ?? 3600),
35
+ token_type: String(raw.token_type ?? 'Bearer'),
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Decode a JWT payload (base64url) and return claims as a plain object.
41
+ * Returns undefined if the token is not a valid JWT.
42
+ */
43
+ function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
44
+ const parts = token.split('.')
45
+ if (parts.length !== 3) return undefined
46
+ try {
47
+ const payload = parts[1]
48
+ // base64url → base64 → decode
49
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/')
50
+ const padded = base64 + '==='.slice((base64.length + 3) % 4)
51
+ const decoded = atob(padded)
52
+ const parsed: unknown = JSON.parse(decoded)
53
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
54
+ return parsed as Record<string, unknown>
55
+ }
56
+ } catch {
57
+ // malformed JWT — ignore
58
+ }
59
+ return undefined
60
+ }
61
+
62
+ /**
63
+ * Extract the ChatGPT account ID from an OpenAI OAuth JWT access token.
64
+ * The claim is nested: token["https://api.openai.com/auth"]["chatgpt_account_id"]
65
+ */
66
+ function extractAccountIdFromJwt(token: string): string | undefined {
67
+ const claims = decodeJwtPayload(token)
68
+ if (claims === undefined) return undefined
69
+ const authClaim = claims['https://api.openai.com/auth']
70
+ if (authClaim !== null && typeof authClaim === 'object' && !Array.isArray(authClaim)) {
71
+ const id = (authClaim as Record<string, unknown>).chatgpt_account_id
72
+ if (typeof id === 'string' && id.length > 0) return id
73
+ }
74
+ return undefined
75
+ }
76
+
77
+ interface DeviceAuthStartResponse {
78
+ device_auth_id: string
79
+ user_code: string
80
+ interval?: string | number
81
+ }
82
+
83
+ interface DeviceAuthTokenResponse {
84
+ authorization_code: string
85
+ code_verifier: string
86
+ }
87
+
88
+ interface BrowserOauthStart {
89
+ authorizationUrl: string
90
+ callbackPromise: Promise<{ code: string; state?: string }>
91
+ expectedState: string
92
+ codeVerifier: string
93
+ stop: () => Promise<void>
94
+ }
95
+
96
+ function toBase64Url(input: Buffer): string {
97
+ return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
98
+ }
99
+
100
+ function createPkcePair(): { verifier: string; challenge: string } {
101
+ const verifier = toBase64Url(randomBytes(32))
102
+ const challenge = toBase64Url(createHash('sha256').update(verifier).digest())
103
+ return { verifier, challenge }
104
+ }
105
+
106
+ function openUrlInBrowser(url: string): void {
107
+ const platform = process.platform
108
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32' : 'xdg-open'
109
+ const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]
110
+ try {
111
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' })
112
+ child.unref()
113
+ } catch {
114
+ log('failed to open browser automatically')
115
+ }
116
+ }
117
+
118
+ async function startBrowserOAuthFlow(): Promise<BrowserOauthStart> {
119
+ const { verifier, challenge } = createPkcePair()
120
+ const state = toBase64Url(randomBytes(24))
121
+ const redirectUri = `http://localhost:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
122
+
123
+ const authUrl = new URL(OPENAI_AUTHORIZE_URL)
124
+ authUrl.searchParams.set('response_type', 'code')
125
+ authUrl.searchParams.set('client_id', CLIENT_ID)
126
+ authUrl.searchParams.set('redirect_uri', redirectUri)
127
+ authUrl.searchParams.set('scope', 'openid profile email offline_access')
128
+ authUrl.searchParams.set('code_challenge', challenge)
129
+ authUrl.searchParams.set('code_challenge_method', 'S256')
130
+ authUrl.searchParams.set('state', state)
131
+ authUrl.searchParams.set('id_token_add_organizations', 'true')
132
+ authUrl.searchParams.set('codex_cli_simplified_flow', 'true')
133
+ authUrl.searchParams.set('originator', 'openllmprovider')
134
+
135
+ const successPage =
136
+ '<!doctype html><html><body style="font-family:system-ui;padding:24px">Authentication complete. You can close this tab.</body></html>'
137
+ const errorPage =
138
+ '<!doctype html><html><body style="font-family:system-ui;padding:24px">Authentication failed. Return to terminal.</body></html>'
139
+
140
+ let resolved = false
141
+ let rejectAuth: (reason?: unknown) => void = () => {}
142
+ let closeServer: () => Promise<void> = async () => {}
143
+
144
+ const callbackPromise = new Promise<{ code: string; state?: string }>((resolve, reject) => {
145
+ rejectAuth = reject
146
+ const server = createServer((req, res) => {
147
+ if (!req.url) {
148
+ res.statusCode = 400
149
+ res.end(errorPage)
150
+ if (!resolved) {
151
+ resolved = true
152
+ reject(new Error('OAuth callback request missing URL'))
153
+ }
154
+ return
155
+ }
156
+
157
+ const callbackUrl = new URL(req.url, `http://localhost:${OAUTH_CALLBACK_PORT}`)
158
+ if (callbackUrl.pathname !== OAUTH_CALLBACK_PATH) {
159
+ res.statusCode = 404
160
+ res.end('Not Found')
161
+ return
162
+ }
163
+
164
+ const error = callbackUrl.searchParams.get('error')
165
+ const errorDescription = callbackUrl.searchParams.get('error_description')
166
+ const code = callbackUrl.searchParams.get('code') ?? undefined
167
+ const responseState = callbackUrl.searchParams.get('state') ?? undefined
168
+
169
+ if (error) {
170
+ res.statusCode = 400
171
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
172
+ res.end(errorPage)
173
+ if (!resolved) {
174
+ resolved = true
175
+ reject(new Error(errorDescription ?? error))
176
+ }
177
+ return
178
+ }
179
+
180
+ if (!code) {
181
+ res.statusCode = 400
182
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
183
+ res.end(errorPage)
184
+ if (!resolved) {
185
+ resolved = true
186
+ reject(new Error('OAuth callback missing code'))
187
+ }
188
+ return
189
+ }
190
+
191
+ res.statusCode = 200
192
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
193
+ res.end(successPage)
194
+ if (!resolved) {
195
+ resolved = true
196
+ resolve({ code, state: responseState })
197
+ }
198
+ })
199
+
200
+ server.once('error', (error) => {
201
+ if (!resolved) {
202
+ resolved = true
203
+ reject(error)
204
+ }
205
+ })
206
+
207
+ server.listen(OAUTH_CALLBACK_PORT, '127.0.0.1')
208
+
209
+ closeServer = async () => {
210
+ await new Promise<void>((closeResolve) => {
211
+ server.close(() => closeResolve())
212
+ })
213
+ }
214
+ })
215
+
216
+ const timeout = setTimeout(() => {
217
+ if (!resolved) {
218
+ resolved = true
219
+ rejectAuth(new Error('Browser OAuth timed out waiting for callback'))
220
+ }
221
+ }, OAUTH_CALLBACK_TIMEOUT_MS)
222
+
223
+ const stop = async (): Promise<void> => {
224
+ clearTimeout(timeout)
225
+ await closeServer()
226
+ }
227
+
228
+ return {
229
+ authorizationUrl: authUrl.toString(),
230
+ callbackPromise,
231
+ expectedState: state,
232
+ codeVerifier: verifier,
233
+ stop,
234
+ }
235
+ }
236
+
237
+ function extractApiErrorMessage(rawBody: string): string {
238
+ try {
239
+ const parsed = JSON.parse(rawBody) as Record<string, unknown>
240
+ const direct = parsed.message
241
+ if (typeof direct === 'string' && direct.length > 0) {
242
+ return direct
243
+ }
244
+ const nested = parsed.error
245
+ if (nested !== null && typeof nested === 'object' && !Array.isArray(nested)) {
246
+ const msg = (nested as Record<string, unknown>).message
247
+ if (typeof msg === 'string' && msg.length > 0) {
248
+ return msg
249
+ }
250
+ }
251
+ } catch {
252
+ return rawBody
253
+ }
254
+ return rawBody
255
+ }
256
+
257
+ function isDeviceAuthSecurityGate(message: string): boolean {
258
+ const text = message.toLowerCase()
259
+ return (
260
+ text.includes('enable device code authorization for codex') ||
261
+ text.includes('chatgpt security settings') ||
262
+ text.includes('device code authorization')
263
+ )
264
+ }
265
+
266
+ async function startDeviceAuth(): Promise<DeviceAuthStartResponse> {
267
+ const res = await globalThis.fetch(OPENAI_DEVICE_USERCODE_URL, {
268
+ method: 'POST',
269
+ headers: {
270
+ 'Content-Type': 'application/json',
271
+ 'User-Agent': DEVICE_USER_AGENT,
272
+ },
273
+ body: JSON.stringify({ client_id: CLIENT_ID }),
274
+ })
275
+
276
+ if (!res.ok) {
277
+ const body = await res.text()
278
+ throw new Error(`Device auth start failed: ${res.status} ${res.statusText} ${body}`)
279
+ }
280
+
281
+ const raw = (await res.json()) as Record<string, unknown>
282
+ return {
283
+ device_auth_id: String(raw.device_auth_id ?? ''),
284
+ user_code: String(raw.user_code ?? ''),
285
+ interval: typeof raw.interval === 'string' || typeof raw.interval === 'number' ? raw.interval : undefined,
286
+ }
287
+ }
288
+
289
+ async function pollDeviceAuthorizationCode(
290
+ deviceAuthId: string,
291
+ userCode: string,
292
+ intervalSeconds: number
293
+ ): Promise<DeviceAuthTokenResponse> {
294
+ const intervalMs = Math.max(intervalSeconds, 1) * 1000
295
+ const maxPolls = 120
296
+
297
+ for (let i = 0; i < maxPolls; i++) {
298
+ await new Promise<void>((resolve) => setTimeout(resolve, intervalMs + 3000))
299
+
300
+ const res = await globalThis.fetch(OPENAI_DEVICE_TOKEN_URL, {
301
+ method: 'POST',
302
+ headers: {
303
+ 'Content-Type': 'application/json',
304
+ 'User-Agent': DEVICE_USER_AGENT,
305
+ },
306
+ body: JSON.stringify({
307
+ device_auth_id: deviceAuthId,
308
+ user_code: userCode,
309
+ }),
310
+ })
311
+
312
+ if (res.ok) {
313
+ const raw = (await res.json()) as Record<string, unknown>
314
+ const authorizationCode = String(raw.authorization_code ?? '')
315
+ const codeVerifier = String(raw.code_verifier ?? '')
316
+ if (!authorizationCode || !codeVerifier) {
317
+ throw new Error('Device auth token response missing authorization_code/code_verifier')
318
+ }
319
+ log('poll[%d]: authorization code acquired', i + 1)
320
+ return { authorization_code: authorizationCode, code_verifier: codeVerifier }
321
+ }
322
+
323
+ if (res.status === 403 || res.status === 404) {
324
+ if (res.status === 403) {
325
+ const body = await res.text()
326
+ const message = extractApiErrorMessage(body)
327
+ if (isDeviceAuthSecurityGate(message)) {
328
+ throw new Error(message)
329
+ }
330
+ }
331
+ log('poll[%d]: pending (%d)', i + 1, res.status)
332
+ continue
333
+ }
334
+
335
+ const body = await res.text()
336
+ throw new Error(`Device auth poll failed: ${res.status} ${res.statusText} ${body}`)
337
+ }
338
+
339
+ throw new Error('Device flow timed out after polling limit reached')
340
+ }
341
+
342
+ async function exchangeAuthorizationCodeForTokens(
343
+ authorizationCode: string,
344
+ codeVerifier: string
345
+ ): Promise<TokenResponse> {
346
+ const body = new URLSearchParams({
347
+ grant_type: 'authorization_code',
348
+ code: authorizationCode,
349
+ redirect_uri: `${ISSUER}/deviceauth/callback`,
350
+ client_id: CLIENT_ID,
351
+ code_verifier: codeVerifier,
352
+ })
353
+
354
+ const res = await globalThis.fetch(OPENAI_TOKEN_URL, {
355
+ method: 'POST',
356
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
357
+ body: body.toString(),
358
+ })
359
+
360
+ if (!res.ok) {
361
+ const rawBody = await res.text()
362
+ throw new Error(`Token exchange failed: ${res.status} ${res.statusText} ${rawBody}`)
363
+ }
364
+
365
+ const raw = (await res.json()) as Record<string, unknown>
366
+ return parseTokenResponse(raw)
367
+ }
368
+
369
+ async function exchangeBrowserAuthorizationCode(code: string, codeVerifier: string): Promise<TokenResponse> {
370
+ const redirectUri = `http://localhost:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
371
+ const body = new URLSearchParams({
372
+ grant_type: 'authorization_code',
373
+ code,
374
+ redirect_uri: redirectUri,
375
+ client_id: CLIENT_ID,
376
+ code_verifier: codeVerifier,
377
+ })
378
+
379
+ const res = await globalThis.fetch(OPENAI_TOKEN_URL, {
380
+ method: 'POST',
381
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
382
+ body: body.toString(),
383
+ })
384
+
385
+ if (!res.ok) {
386
+ const rawBody = await res.text()
387
+ throw new Error(`Browser token exchange failed: ${res.status} ${res.statusText} ${rawBody}`)
388
+ }
389
+
390
+ const raw = (await res.json()) as Record<string, unknown>
391
+ return parseTokenResponse(raw)
392
+ }
393
+
394
+ /**
395
+ * Exchange a refresh_token for a new access_token using form-urlencoded POST.
396
+ */
397
+ async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
398
+ log('refreshing access token')
399
+
400
+ const body = new URLSearchParams({
401
+ grant_type: 'refresh_token',
402
+ refresh_token: refreshToken,
403
+ client_id: CLIENT_ID,
404
+ })
405
+
406
+ const res = await globalThis.fetch(OPENAI_TOKEN_URL, {
407
+ method: 'POST',
408
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
409
+ body: body.toString(),
410
+ })
411
+
412
+ if (!res.ok) {
413
+ throw new Error(`Token refresh failed: ${res.status} ${res.statusText}`)
414
+ }
415
+
416
+ const raw = (await res.json()) as Record<string, unknown>
417
+ return parseTokenResponse(raw)
418
+ }
419
+
420
+ /**
421
+ * Check if a request URL should be rewritten to the Codex endpoint.
422
+ */
423
+ function shouldRewriteUrl(input: Parameters<typeof globalThis.fetch>[0]): boolean {
424
+ let url: URL
425
+ try {
426
+ if (input instanceof Request) {
427
+ url = new URL(input.url)
428
+ } else {
429
+ url = new URL(String(input))
430
+ }
431
+ } catch {
432
+ return false
433
+ }
434
+ const pathname = url.pathname
435
+ return pathname.includes('/v1/responses') || pathname.includes('/chat/completions')
436
+ }
437
+
438
+ /**
439
+ * Buffer a streaming SSE response from the Codex endpoint and extract the
440
+ * final response.completed event into a synthetic Responses API JSON object.
441
+ */
442
+ async function bufferCodexStream(response: Response): Promise<Record<string, unknown>> {
443
+ const text = await response.text()
444
+ const lines = text.split('\n')
445
+ let lastResponseData: Record<string, unknown> | undefined
446
+
447
+ for (const line of lines) {
448
+ if (!line.startsWith('data: ')) continue
449
+ const jsonStr = line.slice(6)
450
+ if (jsonStr === '[DONE]') break
451
+ try {
452
+ const event: unknown = JSON.parse(jsonStr)
453
+ if (event !== null && typeof event === 'object' && !Array.isArray(event)) {
454
+ const e = event as Record<string, unknown>
455
+ // The response.completed or response.done event contains the full response
456
+ if (e.type === 'response.completed' || e.type === 'response.done') {
457
+ const resp = e.response
458
+ if (resp !== null && typeof resp === 'object' && !Array.isArray(resp)) {
459
+ lastResponseData = resp as Record<string, unknown>
460
+ }
461
+ }
462
+ }
463
+ } catch {
464
+ // Malformed SSE line — skip
465
+ }
466
+ }
467
+
468
+ if (lastResponseData !== undefined) {
469
+ return lastResponseData
470
+ }
471
+
472
+ // Fallback: return a minimal error response
473
+ log('bufferCodexStream: no response.completed event found in SSE stream')
474
+ return { error: { message: 'Failed to parse streaming response from Codex endpoint' } }
475
+ }
476
+
477
+ /**
478
+ * Built-in auth plugin for OpenAI Codex (OAuth).
479
+ *
480
+ * Only activates for OAuth credentials (`auth.type === 'oauth'`). API key
481
+ * credentials fall through with an empty options object so the standard
482
+ * OpenAI SDK path handles them.
483
+ *
484
+ * When active the plugin:
485
+ * 1. Sets a dummy apiKey so the SDK doesn't reject construction
486
+ * 2. Wraps fetch to auto-refresh expired tokens via refresh_token
487
+ * 3. Injects `Authorization: Bearer <access_token>`
488
+ * 4. Sets `ChatGPT-Account-Id` from stored accountId or JWT claims
489
+ * 5. Rewrites `/v1/responses` and `/chat/completions` URLs to the Codex endpoint
490
+ */
491
+ export const codexPlugin: AuthHook = {
492
+ provider: 'openai',
493
+
494
+ async loader(getAuth: () => Promise<AuthCredential>, _provider: ProviderInfo, setAuth: (credential: AuthCredential) => Promise<void>): Promise<Record<string, unknown>> {
495
+ const auth = await getAuth()
496
+
497
+ // Only intercept OAuth credentials — API keys use the standard SDK path
498
+ if (auth.type !== 'oauth') {
499
+ log('codex loader: skipping (type=%s)', auth.type)
500
+ return {}
501
+ }
502
+
503
+ log('codex loader: activating OAuth fetch wrapper')
504
+ return {
505
+ apiKey: OAUTH_DUMMY_KEY,
506
+ async fetch(
507
+ request: Parameters<typeof globalThis.fetch>[0],
508
+ init?: Parameters<typeof globalThis.fetch>[1]
509
+ ): Promise<Response> {
510
+ let currentAuth = await getAuth()
511
+ if (
512
+ currentAuth.expires !== undefined &&
513
+ currentAuth.expires < Date.now() &&
514
+ typeof currentAuth.refresh === 'string'
515
+ ) {
516
+ log('token expired, attempting refresh...')
517
+ try {
518
+ const tokens = await refreshAccessToken(currentAuth.refresh)
519
+ currentAuth = {
520
+ ...currentAuth,
521
+ key: tokens.access_token,
522
+ refresh: tokens.refresh_token ?? currentAuth.refresh,
523
+ expires: Date.now() + tokens.expires_in * 1000,
524
+ }
525
+ await setAuth(currentAuth).catch((err) =>
526
+ log('failed to persist refreshed credential: %s', err instanceof Error ? err.message : String(err)),
527
+ )
528
+ log('token refreshed successfully')
529
+ } catch (err: unknown) {
530
+ log('token refresh failed: %s', err instanceof Error ? err.message : String(err))
531
+ }
532
+ }
533
+
534
+ const headers = new Headers(init?.headers)
535
+ headers.delete('Authorization')
536
+
537
+ const token = currentAuth.key ?? ''
538
+ headers.set('Authorization', `Bearer ${token}`)
539
+
540
+ let accountId = typeof currentAuth.accountId === 'string' ? currentAuth.accountId : undefined
541
+ if (accountId === undefined && token.length > 0) {
542
+ accountId = extractAccountIdFromJwt(token)
543
+ }
544
+ if (typeof accountId === 'string' && accountId.length > 0) {
545
+ headers.set('ChatGPT-Account-Id', accountId)
546
+ }
547
+
548
+ const isCodexRewrite = shouldRewriteUrl(request)
549
+ const rewritten = isCodexRewrite ? CODEX_API_ENDPOINT : request
550
+
551
+ // The Codex backend requires store=false, instructions, and stream=true.
552
+ // Patch the request body when routing to the codex endpoint.
553
+ let patchedInit = init
554
+ let needsStreamBuffer = false
555
+ if (isCodexRewrite && init?.body) {
556
+ try {
557
+ const bodyStr =
558
+ typeof init.body === 'string' ? init.body : new TextDecoder().decode(init.body as ArrayBuffer)
559
+ const parsed: unknown = JSON.parse(bodyStr)
560
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
561
+ const body = parsed as Record<string, unknown>
562
+ if (body.store !== false) body.store = false
563
+ if (typeof body.instructions !== 'string' || body.instructions.length === 0) {
564
+ body.instructions = 'You are a helpful assistant.'
565
+ }
566
+ // Codex endpoint only supports streaming. If the SDK requested
567
+ // non-streaming, force stream=true and buffer the SSE response.
568
+ if (body.stream !== true) {
569
+ body.stream = true
570
+ needsStreamBuffer = true
571
+ }
572
+ patchedInit = { ...init, body: JSON.stringify(body) }
573
+ }
574
+ } catch {
575
+ // If we can't parse the body, send it as-is
576
+ }
577
+ }
578
+
579
+ log('codex fetch: rewritten=%s, needsStreamBuffer=%s', isCodexRewrite, needsStreamBuffer)
580
+ const response = await globalThis.fetch(rewritten, { ...patchedInit, headers })
581
+
582
+ // If we forced streaming, buffer the SSE response and return a
583
+ // synthetic JSON response that matches the Responses API format.
584
+ if (needsStreamBuffer && response.ok && response.body) {
585
+ const syntheticBody = await bufferCodexStream(response)
586
+ return new Response(JSON.stringify(syntheticBody), {
587
+ status: 200,
588
+ headers: { 'Content-Type': 'application/json' },
589
+ })
590
+ }
591
+
592
+ return response
593
+ },
594
+ }
595
+ },
596
+
597
+ methods: [
598
+ {
599
+ type: 'oauth',
600
+ label: 'ChatGPT Pro/Plus (browser)',
601
+
602
+ async handler(): Promise<AuthCredential> {
603
+ log('starting browser OAuth flow')
604
+ const oauth = await startBrowserOAuthFlow()
605
+
606
+ try {
607
+ console.log('Open this URL to continue Codex login:')
608
+ console.log(oauth.authorizationUrl)
609
+ openUrlInBrowser(oauth.authorizationUrl)
610
+
611
+ const callback = await oauth.callbackPromise
612
+ if (callback.state !== undefined && callback.state !== oauth.expectedState) {
613
+ throw new Error('OAuth state mismatch')
614
+ }
615
+
616
+ const tokens = await exchangeBrowserAuthorizationCode(callback.code, oauth.codeVerifier)
617
+
618
+ return {
619
+ type: 'oauth',
620
+ key: tokens.access_token,
621
+ refresh: tokens.refresh_token,
622
+ expires: Date.now() + tokens.expires_in * 1000,
623
+ }
624
+ } finally {
625
+ await oauth.stop()
626
+ }
627
+ },
628
+ },
629
+ {
630
+ type: 'device-flow',
631
+ label: 'ChatGPT Pro/Plus (headless)',
632
+
633
+ async handler(): Promise<AuthCredential> {
634
+ log('starting OpenAI device flow')
635
+
636
+ const deviceData = await startDeviceAuth()
637
+ const interval = Number(deviceData.interval ?? 5)
638
+ const verificationUri = OPENAI_DEVICE_VERIFY_URL
639
+ const userCode = deviceData.user_code
640
+
641
+ log('device flow: user_code=%s', userCode)
642
+ log('device flow: verify at %s', verificationUri)
643
+
644
+ const authCode = await pollDeviceAuthorizationCode(deviceData.device_auth_id, userCode, interval)
645
+ const tokens = await exchangeAuthorizationCodeForTokens(authCode.authorization_code, authCode.code_verifier)
646
+
647
+ return {
648
+ type: 'oauth',
649
+ key: tokens.access_token,
650
+ refresh: tokens.refresh_token,
651
+ expires: Date.now() + tokens.expires_in * 1000,
652
+ }
653
+ },
654
+ },
655
+ ] satisfies AuthMethod[],
656
+ }