codex.ts 15 KB

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