codex.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. import type { Hooks, PluginInput } from "@opencode-ai/plugin"
  2. import { Log } from "../util"
  3. import { Installation } from "../installation"
  4. import { OAUTH_DUMMY_KEY } from "../auth"
  5. import os from "os"
  6. import { setTimeout as sleep } from "node:timers/promises"
  7. import { createServer } from "http"
  8. const log = Log.create({ service: "plugin.codex" })
  9. const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
  10. const ISSUER = "https://auth.openai.com"
  11. const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
  12. const OAUTH_PORT = 1455
  13. const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
  14. interface PkceCodes {
  15. verifier: string
  16. challenge: string
  17. }
  18. async function generatePKCE(): Promise<PkceCodes> {
  19. const verifier = generateRandomString(43)
  20. const encoder = new TextEncoder()
  21. const data = encoder.encode(verifier)
  22. const hash = await crypto.subtle.digest("SHA-256", data)
  23. const challenge = base64UrlEncode(hash)
  24. return { verifier, challenge }
  25. }
  26. function generateRandomString(length: number): string {
  27. const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
  28. const bytes = crypto.getRandomValues(new Uint8Array(length))
  29. return Array.from(bytes)
  30. .map((b) => chars[b % chars.length])
  31. .join("")
  32. }
  33. function base64UrlEncode(buffer: ArrayBuffer): string {
  34. const bytes = new Uint8Array(buffer)
  35. const binary = String.fromCharCode(...bytes)
  36. return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
  37. }
  38. function generateState(): string {
  39. return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
  40. }
  41. export interface IdTokenClaims {
  42. chatgpt_account_id?: string
  43. organizations?: Array<{ id: string }>
  44. email?: string
  45. "https://api.openai.com/auth"?: {
  46. chatgpt_account_id?: string
  47. }
  48. }
  49. export function parseJwtClaims(token: string): IdTokenClaims | undefined {
  50. const parts = token.split(".")
  51. if (parts.length !== 3) return undefined
  52. try {
  53. return JSON.parse(Buffer.from(parts[1], "base64url").toString())
  54. } catch {
  55. return undefined
  56. }
  57. }
  58. export function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
  59. return (
  60. claims.chatgpt_account_id ||
  61. claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
  62. claims.organizations?.[0]?.id
  63. )
  64. }
  65. export function extractAccountId(tokens: TokenResponse): string | undefined {
  66. if (tokens.id_token) {
  67. const claims = parseJwtClaims(tokens.id_token)
  68. const accountId = claims && extractAccountIdFromClaims(claims)
  69. if (accountId) return accountId
  70. }
  71. if (tokens.access_token) {
  72. const claims = parseJwtClaims(tokens.access_token)
  73. return claims ? extractAccountIdFromClaims(claims) : undefined
  74. }
  75. return undefined
  76. }
  77. function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
  78. const params = new URLSearchParams({
  79. response_type: "code",
  80. client_id: CLIENT_ID,
  81. redirect_uri: redirectUri,
  82. scope: "openid profile email offline_access",
  83. code_challenge: pkce.challenge,
  84. code_challenge_method: "S256",
  85. id_token_add_organizations: "true",
  86. codex_cli_simplified_flow: "true",
  87. state,
  88. originator: "opencode",
  89. })
  90. return `${ISSUER}/oauth/authorize?${params.toString()}`
  91. }
  92. interface TokenResponse {
  93. id_token: string
  94. access_token: string
  95. refresh_token: string
  96. expires_in?: number
  97. }
  98. async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
  99. const response = await fetch(`${ISSUER}/oauth/token`, {
  100. method: "POST",
  101. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  102. body: new URLSearchParams({
  103. grant_type: "authorization_code",
  104. code,
  105. redirect_uri: redirectUri,
  106. client_id: CLIENT_ID,
  107. code_verifier: pkce.verifier,
  108. }).toString(),
  109. })
  110. if (!response.ok) {
  111. throw new Error(`Token exchange failed: ${response.status}`)
  112. }
  113. return response.json()
  114. }
  115. async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
  116. const response = await fetch(`${ISSUER}/oauth/token`, {
  117. method: "POST",
  118. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  119. body: new URLSearchParams({
  120. grant_type: "refresh_token",
  121. refresh_token: refreshToken,
  122. client_id: CLIENT_ID,
  123. }).toString(),
  124. })
  125. if (!response.ok) {
  126. throw new Error(`Token refresh failed: ${response.status}`)
  127. }
  128. return response.json()
  129. }
  130. const HTML_SUCCESS = `<!doctype html>
  131. <html>
  132. <head>
  133. <title>OpenCode - Codex Authorization Successful</title>
  134. <style>
  135. body {
  136. font-family:
  137. system-ui,
  138. -apple-system,
  139. sans-serif;
  140. display: flex;
  141. justify-content: center;
  142. align-items: center;
  143. height: 100vh;
  144. margin: 0;
  145. background: #131010;
  146. color: #f1ecec;
  147. }
  148. .container {
  149. text-align: center;
  150. padding: 2rem;
  151. }
  152. h1 {
  153. color: #f1ecec;
  154. margin-bottom: 1rem;
  155. }
  156. p {
  157. color: #b7b1b1;
  158. }
  159. </style>
  160. </head>
  161. <body>
  162. <div class="container">
  163. <h1>Authorization Successful</h1>
  164. <p>You can close this window and return to OpenCode.</p>
  165. </div>
  166. <script>
  167. setTimeout(() => window.close(), 2000)
  168. </script>
  169. </body>
  170. </html>`
  171. const HTML_ERROR = (error: string) => `<!doctype html>
  172. <html>
  173. <head>
  174. <title>OpenCode - Codex Authorization Failed</title>
  175. <style>
  176. body {
  177. font-family:
  178. system-ui,
  179. -apple-system,
  180. sans-serif;
  181. display: flex;
  182. justify-content: center;
  183. align-items: center;
  184. height: 100vh;
  185. margin: 0;
  186. background: #131010;
  187. color: #f1ecec;
  188. }
  189. .container {
  190. text-align: center;
  191. padding: 2rem;
  192. }
  193. h1 {
  194. color: #fc533a;
  195. margin-bottom: 1rem;
  196. }
  197. p {
  198. color: #b7b1b1;
  199. }
  200. .error {
  201. color: #ff917b;
  202. font-family: monospace;
  203. margin-top: 1rem;
  204. padding: 1rem;
  205. background: #3c140d;
  206. border-radius: 0.5rem;
  207. }
  208. </style>
  209. </head>
  210. <body>
  211. <div class="container">
  212. <h1>Authorization Failed</h1>
  213. <p>An error occurred during authorization.</p>
  214. <div class="error">${error}</div>
  215. </div>
  216. </body>
  217. </html>`
  218. interface PendingOAuth {
  219. pkce: PkceCodes
  220. state: string
  221. resolve: (tokens: TokenResponse) => void
  222. reject: (error: Error) => void
  223. }
  224. let oauthServer: ReturnType<typeof createServer> | undefined
  225. let pendingOAuth: PendingOAuth | undefined
  226. async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
  227. if (oauthServer) {
  228. return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
  229. }
  230. oauthServer = createServer((req, res) => {
  231. const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
  232. if (url.pathname === "/auth/callback") {
  233. const code = url.searchParams.get("code")
  234. const state = url.searchParams.get("state")
  235. const error = url.searchParams.get("error")
  236. const errorDescription = url.searchParams.get("error_description")
  237. if (error) {
  238. const errorMsg = errorDescription || error
  239. pendingOAuth?.reject(new Error(errorMsg))
  240. pendingOAuth = undefined
  241. res.writeHead(200, { "Content-Type": "text/html" })
  242. res.end(HTML_ERROR(errorMsg))
  243. return
  244. }
  245. if (!code) {
  246. const errorMsg = "Missing authorization code"
  247. pendingOAuth?.reject(new Error(errorMsg))
  248. pendingOAuth = undefined
  249. res.writeHead(400, { "Content-Type": "text/html" })
  250. res.end(HTML_ERROR(errorMsg))
  251. return
  252. }
  253. if (!pendingOAuth || state !== pendingOAuth.state) {
  254. const errorMsg = "Invalid state - potential CSRF attack"
  255. pendingOAuth?.reject(new Error(errorMsg))
  256. pendingOAuth = undefined
  257. res.writeHead(400, { "Content-Type": "text/html" })
  258. res.end(HTML_ERROR(errorMsg))
  259. return
  260. }
  261. const current = pendingOAuth
  262. pendingOAuth = undefined
  263. exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
  264. .then((tokens) => current.resolve(tokens))
  265. .catch((err) => current.reject(err))
  266. res.writeHead(200, { "Content-Type": "text/html" })
  267. res.end(HTML_SUCCESS)
  268. return
  269. }
  270. if (url.pathname === "/cancel") {
  271. pendingOAuth?.reject(new Error("Login cancelled"))
  272. pendingOAuth = undefined
  273. res.writeHead(200)
  274. res.end("Login cancelled")
  275. return
  276. }
  277. res.writeHead(404)
  278. res.end("Not found")
  279. })
  280. await new Promise<void>((resolve, reject) => {
  281. oauthServer!.listen(OAUTH_PORT, () => {
  282. log.info("codex oauth server started", { port: OAUTH_PORT })
  283. resolve()
  284. })
  285. oauthServer!.on("error", reject)
  286. })
  287. return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
  288. }
  289. function stopOAuthServer() {
  290. if (oauthServer) {
  291. oauthServer.close(() => {
  292. log.info("codex oauth server stopped")
  293. })
  294. oauthServer = undefined
  295. }
  296. }
  297. function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
  298. return new Promise((resolve, reject) => {
  299. const timeout = setTimeout(
  300. () => {
  301. if (pendingOAuth) {
  302. pendingOAuth = undefined
  303. reject(new Error("OAuth callback timeout - authorization took too long"))
  304. }
  305. },
  306. 5 * 60 * 1000,
  307. ) // 5 minute timeout
  308. pendingOAuth = {
  309. pkce,
  310. state,
  311. resolve: (tokens) => {
  312. clearTimeout(timeout)
  313. resolve(tokens)
  314. },
  315. reject: (error) => {
  316. clearTimeout(timeout)
  317. reject(error)
  318. },
  319. }
  320. })
  321. }
  322. export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
  323. return {
  324. auth: {
  325. provider: "openai",
  326. async loader(getAuth, provider) {
  327. const auth = await getAuth()
  328. if (auth.type !== "oauth") return {}
  329. // Filter models to only allowed Codex models for OAuth
  330. const allowedModels = new Set([
  331. "gpt-5.1-codex",
  332. "gpt-5.1-codex-max",
  333. "gpt-5.1-codex-mini",
  334. "gpt-5.2",
  335. "gpt-5.2-codex",
  336. "gpt-5.3-codex",
  337. "gpt-5.4",
  338. "gpt-5.4-mini",
  339. ])
  340. for (const [modelId, model] of Object.entries(provider.models)) {
  341. if (modelId.includes("codex")) continue
  342. if (allowedModels.has(model.api.id)) continue
  343. delete provider.models[modelId]
  344. }
  345. // Zero out costs for Codex (included with ChatGPT subscription)
  346. for (const model of Object.values(provider.models)) {
  347. model.cost = {
  348. input: 0,
  349. output: 0,
  350. cache: { read: 0, write: 0 },
  351. }
  352. }
  353. return {
  354. apiKey: OAUTH_DUMMY_KEY,
  355. async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
  356. // Remove dummy API key authorization header
  357. if (init?.headers) {
  358. if (init.headers instanceof Headers) {
  359. init.headers.delete("authorization")
  360. init.headers.delete("Authorization")
  361. } else if (Array.isArray(init.headers)) {
  362. init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization")
  363. } else {
  364. delete init.headers["authorization"]
  365. delete init.headers["Authorization"]
  366. }
  367. }
  368. const currentAuth = await getAuth()
  369. if (currentAuth.type !== "oauth") return fetch(requestInput, init)
  370. // Cast to include accountId field
  371. const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
  372. // Check if token needs refresh
  373. if (!currentAuth.access || currentAuth.expires < Date.now()) {
  374. log.info("refreshing codex access token")
  375. const tokens = await refreshAccessToken(currentAuth.refresh)
  376. const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
  377. await input.client.auth.set({
  378. path: { id: "openai" },
  379. body: {
  380. type: "oauth",
  381. refresh: tokens.refresh_token,
  382. access: tokens.access_token,
  383. expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
  384. ...(newAccountId && { accountId: newAccountId }),
  385. },
  386. })
  387. currentAuth.access = tokens.access_token
  388. authWithAccount.accountId = newAccountId
  389. }
  390. // Build headers
  391. const headers = new Headers()
  392. if (init?.headers) {
  393. if (init.headers instanceof Headers) {
  394. init.headers.forEach((value, key) => headers.set(key, value))
  395. } else if (Array.isArray(init.headers)) {
  396. for (const [key, value] of init.headers) {
  397. if (value !== undefined) headers.set(key, String(value))
  398. }
  399. } else {
  400. for (const [key, value] of Object.entries(init.headers)) {
  401. if (value !== undefined) headers.set(key, String(value))
  402. }
  403. }
  404. }
  405. // Set authorization header with access token
  406. headers.set("authorization", `Bearer ${currentAuth.access}`)
  407. // Set ChatGPT-Account-Id header for organization subscriptions
  408. if (authWithAccount.accountId) {
  409. headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
  410. }
  411. // Rewrite URL to Codex endpoint
  412. const parsed =
  413. requestInput instanceof URL
  414. ? requestInput
  415. : new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
  416. const url =
  417. parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
  418. ? new URL(CODEX_API_ENDPOINT)
  419. : parsed
  420. return fetch(url, {
  421. ...init,
  422. headers,
  423. })
  424. },
  425. }
  426. },
  427. methods: [
  428. {
  429. label: "ChatGPT Pro/Plus (browser)",
  430. type: "oauth",
  431. authorize: async () => {
  432. const { redirectUri } = await startOAuthServer()
  433. const pkce = await generatePKCE()
  434. const state = generateState()
  435. const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
  436. const callbackPromise = waitForOAuthCallback(pkce, state)
  437. return {
  438. url: authUrl,
  439. instructions: "Complete authorization in your browser. This window will close automatically.",
  440. method: "auto" as const,
  441. callback: async () => {
  442. const tokens = await callbackPromise
  443. stopOAuthServer()
  444. const accountId = extractAccountId(tokens)
  445. return {
  446. type: "success" as const,
  447. refresh: tokens.refresh_token,
  448. access: tokens.access_token,
  449. expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
  450. accountId,
  451. }
  452. },
  453. }
  454. },
  455. },
  456. {
  457. label: "ChatGPT Pro/Plus (headless)",
  458. type: "oauth",
  459. authorize: async () => {
  460. const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
  461. method: "POST",
  462. headers: {
  463. "Content-Type": "application/json",
  464. "User-Agent": `opencode/${Installation.VERSION}`,
  465. },
  466. body: JSON.stringify({ client_id: CLIENT_ID }),
  467. })
  468. if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization")
  469. const deviceData = (await deviceResponse.json()) as {
  470. device_auth_id: string
  471. user_code: string
  472. interval: string
  473. }
  474. const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000
  475. return {
  476. url: `${ISSUER}/codex/device`,
  477. instructions: `Enter code: ${deviceData.user_code}`,
  478. method: "auto" as const,
  479. async callback() {
  480. while (true) {
  481. const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
  482. method: "POST",
  483. headers: {
  484. "Content-Type": "application/json",
  485. "User-Agent": `opencode/${Installation.VERSION}`,
  486. },
  487. body: JSON.stringify({
  488. device_auth_id: deviceData.device_auth_id,
  489. user_code: deviceData.user_code,
  490. }),
  491. })
  492. if (response.ok) {
  493. const data = (await response.json()) as {
  494. authorization_code: string
  495. code_verifier: string
  496. }
  497. const tokenResponse = await fetch(`${ISSUER}/oauth/token`, {
  498. method: "POST",
  499. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  500. body: new URLSearchParams({
  501. grant_type: "authorization_code",
  502. code: data.authorization_code,
  503. redirect_uri: `${ISSUER}/deviceauth/callback`,
  504. client_id: CLIENT_ID,
  505. code_verifier: data.code_verifier,
  506. }).toString(),
  507. })
  508. if (!tokenResponse.ok) {
  509. throw new Error(`Token exchange failed: ${tokenResponse.status}`)
  510. }
  511. const tokens: TokenResponse = await tokenResponse.json()
  512. return {
  513. type: "success" as const,
  514. refresh: tokens.refresh_token,
  515. access: tokens.access_token,
  516. expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
  517. accountId: extractAccountId(tokens),
  518. }
  519. }
  520. if (response.status !== 403 && response.status !== 404) {
  521. return { type: "failed" as const }
  522. }
  523. await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
  524. }
  525. },
  526. }
  527. },
  528. },
  529. {
  530. label: "Manually enter API Key",
  531. type: "api",
  532. },
  533. ],
  534. },
  535. "chat.headers": async (input, output) => {
  536. if (input.model.providerID !== "openai") return
  537. output.headers.originator = "opencode"
  538. output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`
  539. output.headers.session_id = input.sessionID
  540. },
  541. "chat.params": async (input, output) => {
  542. if (input.model.providerID !== "openai") return
  543. // Match codex cli
  544. output.maxOutputTokens = undefined
  545. },
  546. }
  547. }