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.
- package/README.md +192 -0
- package/dist/auth/index.cjs +6 -0
- package/dist/auth/index.d.cts +3 -0
- package/dist/auth/index.d.mts +3 -0
- package/dist/auth/index.mjs +3 -0
- package/dist/auto-C2hXJY13.d.cts +33 -0
- package/dist/auto-C2hXJY13.d.cts.map +1 -0
- package/dist/auto-CBqNYBXs.mjs +48 -0
- package/dist/auto-CBqNYBXs.mjs.map +1 -0
- package/dist/auto-CInerwvs.d.mts +33 -0
- package/dist/auto-CInerwvs.d.mts.map +1 -0
- package/dist/auto-D77wgMqO.cjs +59 -0
- package/dist/auto-D77wgMqO.cjs.map +1 -0
- package/dist/file-DB-rxfzi.mjs +77 -0
- package/dist/file-DB-rxfzi.mjs.map +1 -0
- package/dist/file-DZ7FGcSW.cjs +73 -0
- package/dist/file-DZ7FGcSW.cjs.map +1 -0
- package/dist/index.cjs +1909 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1239 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +1241 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1891 -0
- package/dist/index.mjs.map +1 -0
- package/dist/logger-BsHpI_fH.mjs +11 -0
- package/dist/logger-BsHpI_fH.mjs.map +1 -0
- package/dist/logger-jRimlMFR.cjs +69 -0
- package/dist/logger-jRimlMFR.cjs.map +1 -0
- package/dist/plugin/index.cjs +29 -0
- package/dist/plugin/index.cjs.map +1 -0
- package/dist/plugin/index.d.cts +10 -0
- package/dist/plugin/index.d.cts.map +1 -0
- package/dist/plugin/index.d.mts +10 -0
- package/dist/plugin/index.d.mts.map +1 -0
- package/dist/plugin/index.mjs +25 -0
- package/dist/plugin/index.mjs.map +1 -0
- package/dist/plugin-BkeUu5LW.d.mts +46 -0
- package/dist/plugin-BkeUu5LW.d.mts.map +1 -0
- package/dist/plugin-wK7RmJhZ.d.cts +46 -0
- package/dist/plugin-wK7RmJhZ.d.cts.map +1 -0
- package/dist/resolver-BA7LWSJO.mjs +645 -0
- package/dist/resolver-BA7LWSJO.mjs.map +1 -0
- package/dist/resolver-BMTvzTt9.cjs +662 -0
- package/dist/resolver-BMTvzTt9.cjs.map +1 -0
- package/dist/resolver-MgJryMWG.d.cts +75 -0
- package/dist/resolver-MgJryMWG.d.cts.map +1 -0
- package/dist/resolver-_gfXzr_S.d.mts +76 -0
- package/dist/resolver-_gfXzr_S.d.mts.map +1 -0
- package/dist/storage/index.cjs +7 -0
- package/dist/storage/index.d.cts +12 -0
- package/dist/storage/index.d.cts.map +1 -0
- package/dist/storage/index.d.mts +12 -0
- package/dist/storage/index.d.mts.map +1 -0
- package/dist/storage/index.mjs +4 -0
- package/package.json +137 -0
- package/src/auth/.gitkeep +0 -0
- package/src/auth/index.ts +10 -0
- package/src/auth/resolver.ts +46 -0
- package/src/auth/scanners.ts +462 -0
- package/src/auth/store.ts +357 -0
- package/src/catalog/.gitkeep +0 -0
- package/src/catalog/catalog.ts +302 -0
- package/src/catalog/index.ts +17 -0
- package/src/catalog/mapper.ts +129 -0
- package/src/catalog/merger.ts +99 -0
- package/src/index.ts +37 -0
- package/src/logger.ts +7 -0
- package/src/plugin/.gitkeep +0 -0
- package/src/plugin/anthropic.test.ts +505 -0
- package/src/plugin/anthropic.ts +324 -0
- package/src/plugin/codex.ts +656 -0
- package/src/plugin/copilot.ts +161 -0
- package/src/plugin/google.ts +454 -0
- package/src/plugin/index.ts +30 -0
- package/src/provider/.gitkeep +0 -0
- package/src/provider/bundled.ts +59 -0
- package/src/provider/index.ts +249 -0
- package/src/provider/state.ts +163 -0
- package/src/storage/.gitkeep +0 -0
- package/src/storage/auto.ts +32 -0
- package/src/storage/file.ts +84 -0
- package/src/storage/index.ts +10 -0
- package/src/storage/memory.ts +23 -0
- package/src/types/.gitkeep +0 -0
- package/src/types/auth.ts +18 -0
- package/src/types/errors.ts +87 -0
- package/src/types/index.ts +26 -0
- package/src/types/model.ts +88 -0
- package/src/types/plugin.ts +49 -0
- 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
|
+
}
|