codex.ts 15 KB

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