codex.ts 20 KB

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