| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624 |
- import type { Hooks, PluginInput } from "@opencode-ai/plugin"
- import { Log } from "../util/log"
- import { Installation } from "../installation"
- import { Auth, OAUTH_DUMMY_KEY } from "../auth"
- import os from "os"
- import { ProviderTransform } from "@/provider/transform"
- const log = Log.create({ service: "plugin.codex" })
- const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
- const ISSUER = "https://auth.openai.com"
- const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
- const OAUTH_PORT = 1455
- const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
- interface PkceCodes {
- verifier: string
- challenge: string
- }
- async function generatePKCE(): Promise<PkceCodes> {
- const verifier = generateRandomString(43)
- const encoder = new TextEncoder()
- const data = encoder.encode(verifier)
- const hash = await crypto.subtle.digest("SHA-256", data)
- const challenge = base64UrlEncode(hash)
- return { verifier, challenge }
- }
- function generateRandomString(length: number): string {
- const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
- const bytes = crypto.getRandomValues(new Uint8Array(length))
- return Array.from(bytes)
- .map((b) => chars[b % chars.length])
- .join("")
- }
- function base64UrlEncode(buffer: ArrayBuffer): string {
- const bytes = new Uint8Array(buffer)
- const binary = String.fromCharCode(...bytes)
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
- }
- function generateState(): string {
- return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
- }
- export interface IdTokenClaims {
- chatgpt_account_id?: string
- organizations?: Array<{ id: string }>
- email?: string
- "https://api.openai.com/auth"?: {
- chatgpt_account_id?: string
- }
- }
- export function parseJwtClaims(token: string): IdTokenClaims | undefined {
- const parts = token.split(".")
- if (parts.length !== 3) return undefined
- try {
- return JSON.parse(Buffer.from(parts[1], "base64url").toString())
- } catch {
- return undefined
- }
- }
- export function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
- return (
- claims.chatgpt_account_id ||
- claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
- claims.organizations?.[0]?.id
- )
- }
- export function extractAccountId(tokens: TokenResponse): string | undefined {
- if (tokens.id_token) {
- const claims = parseJwtClaims(tokens.id_token)
- const accountId = claims && extractAccountIdFromClaims(claims)
- if (accountId) return accountId
- }
- if (tokens.access_token) {
- const claims = parseJwtClaims(tokens.access_token)
- return claims ? extractAccountIdFromClaims(claims) : undefined
- }
- return undefined
- }
- function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
- const params = new URLSearchParams({
- response_type: "code",
- client_id: CLIENT_ID,
- redirect_uri: redirectUri,
- scope: "openid profile email offline_access",
- code_challenge: pkce.challenge,
- code_challenge_method: "S256",
- id_token_add_organizations: "true",
- codex_cli_simplified_flow: "true",
- state,
- originator: "opencode",
- })
- return `${ISSUER}/oauth/authorize?${params.toString()}`
- }
- interface TokenResponse {
- id_token: string
- access_token: string
- refresh_token: string
- expires_in?: number
- }
- async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
- const response = await fetch(`${ISSUER}/oauth/token`, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- grant_type: "authorization_code",
- code,
- redirect_uri: redirectUri,
- client_id: CLIENT_ID,
- code_verifier: pkce.verifier,
- }).toString(),
- })
- if (!response.ok) {
- throw new Error(`Token exchange failed: ${response.status}`)
- }
- return response.json()
- }
- async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
- const response = await fetch(`${ISSUER}/oauth/token`, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- grant_type: "refresh_token",
- refresh_token: refreshToken,
- client_id: CLIENT_ID,
- }).toString(),
- })
- if (!response.ok) {
- throw new Error(`Token refresh failed: ${response.status}`)
- }
- return response.json()
- }
- const HTML_SUCCESS = `<!doctype html>
- <html>
- <head>
- <title>OpenCode - Codex Authorization Successful</title>
- <style>
- body {
- font-family:
- system-ui,
- -apple-system,
- sans-serif;
- display: flex;
- justify-content: center;
- align-items: center;
- height: 100vh;
- margin: 0;
- background: #131010;
- color: #f1ecec;
- }
- .container {
- text-align: center;
- padding: 2rem;
- }
- h1 {
- color: #f1ecec;
- margin-bottom: 1rem;
- }
- p {
- color: #b7b1b1;
- }
- </style>
- </head>
- <body>
- <div class="container">
- <h1>Authorization Successful</h1>
- <p>You can close this window and return to OpenCode.</p>
- </div>
- <script>
- setTimeout(() => window.close(), 2000)
- </script>
- </body>
- </html>`
- const HTML_ERROR = (error: string) => `<!doctype html>
- <html>
- <head>
- <title>OpenCode - Codex Authorization Failed</title>
- <style>
- body {
- font-family:
- system-ui,
- -apple-system,
- sans-serif;
- display: flex;
- justify-content: center;
- align-items: center;
- height: 100vh;
- margin: 0;
- background: #131010;
- color: #f1ecec;
- }
- .container {
- text-align: center;
- padding: 2rem;
- }
- h1 {
- color: #fc533a;
- margin-bottom: 1rem;
- }
- p {
- color: #b7b1b1;
- }
- .error {
- color: #ff917b;
- font-family: monospace;
- margin-top: 1rem;
- padding: 1rem;
- background: #3c140d;
- border-radius: 0.5rem;
- }
- </style>
- </head>
- <body>
- <div class="container">
- <h1>Authorization Failed</h1>
- <p>An error occurred during authorization.</p>
- <div class="error">${error}</div>
- </div>
- </body>
- </html>`
- interface PendingOAuth {
- pkce: PkceCodes
- state: string
- resolve: (tokens: TokenResponse) => void
- reject: (error: Error) => void
- }
- let oauthServer: ReturnType<typeof Bun.serve> | undefined
- let pendingOAuth: PendingOAuth | undefined
- async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
- if (oauthServer) {
- return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
- }
- oauthServer = Bun.serve({
- port: OAUTH_PORT,
- fetch(req) {
- const url = new URL(req.url)
- if (url.pathname === "/auth/callback") {
- const code = url.searchParams.get("code")
- const state = url.searchParams.get("state")
- const error = url.searchParams.get("error")
- const errorDescription = url.searchParams.get("error_description")
- if (error) {
- const errorMsg = errorDescription || error
- pendingOAuth?.reject(new Error(errorMsg))
- pendingOAuth = undefined
- return new Response(HTML_ERROR(errorMsg), {
- headers: { "Content-Type": "text/html" },
- })
- }
- if (!code) {
- const errorMsg = "Missing authorization code"
- pendingOAuth?.reject(new Error(errorMsg))
- pendingOAuth = undefined
- return new Response(HTML_ERROR(errorMsg), {
- status: 400,
- headers: { "Content-Type": "text/html" },
- })
- }
- if (!pendingOAuth || state !== pendingOAuth.state) {
- const errorMsg = "Invalid state - potential CSRF attack"
- pendingOAuth?.reject(new Error(errorMsg))
- pendingOAuth = undefined
- return new Response(HTML_ERROR(errorMsg), {
- status: 400,
- headers: { "Content-Type": "text/html" },
- })
- }
- const current = pendingOAuth
- pendingOAuth = undefined
- exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
- .then((tokens) => current.resolve(tokens))
- .catch((err) => current.reject(err))
- return new Response(HTML_SUCCESS, {
- headers: { "Content-Type": "text/html" },
- })
- }
- if (url.pathname === "/cancel") {
- pendingOAuth?.reject(new Error("Login cancelled"))
- pendingOAuth = undefined
- return new Response("Login cancelled", { status: 200 })
- }
- return new Response("Not found", { status: 404 })
- },
- })
- log.info("codex oauth server started", { port: OAUTH_PORT })
- return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
- }
- function stopOAuthServer() {
- if (oauthServer) {
- oauthServer.stop()
- oauthServer = undefined
- log.info("codex oauth server stopped")
- }
- }
- function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
- return new Promise((resolve, reject) => {
- const timeout = setTimeout(
- () => {
- if (pendingOAuth) {
- pendingOAuth = undefined
- reject(new Error("OAuth callback timeout - authorization took too long"))
- }
- },
- 5 * 60 * 1000,
- ) // 5 minute timeout
- pendingOAuth = {
- pkce,
- state,
- resolve: (tokens) => {
- clearTimeout(timeout)
- resolve(tokens)
- },
- reject: (error) => {
- clearTimeout(timeout)
- reject(error)
- },
- }
- })
- }
- export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
- return {
- auth: {
- provider: "openai",
- async loader(getAuth, provider) {
- const auth = await getAuth()
- if (auth.type !== "oauth") return {}
- // Filter models to only allowed Codex models for OAuth
- const allowedModels = new Set([
- "gpt-5.1-codex-max",
- "gpt-5.1-codex-mini",
- "gpt-5.2",
- "gpt-5.2-codex",
- "gpt-5.3-codex",
- "gpt-5.1-codex",
- ])
- for (const modelId of Object.keys(provider.models)) {
- if (!allowedModels.has(modelId)) {
- delete provider.models[modelId]
- }
- }
- if (!provider.models["gpt-5.3-codex"] || true) {
- const model = {
- id: "gpt-5.3-codex",
- providerID: "openai",
- api: {
- id: "gpt-5.3-codex",
- url: "https://chatgpt.com/backend-api/codex",
- npm: "@ai-sdk/openai",
- },
- name: "GPT-5.3 Codex",
- capabilities: {
- temperature: false,
- reasoning: true,
- attachment: true,
- toolcall: true,
- input: { text: true, audio: false, image: true, video: false, pdf: false },
- output: { text: true, audio: false, image: false, video: false, pdf: false },
- interleaved: false,
- },
- cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
- limit: { context: 400_000, input: 272_000, output: 128_000 },
- status: "active" as const,
- options: {},
- headers: {},
- release_date: "2026-02-05",
- variants: {} as Record<string, Record<string, any>>,
- family: "gpt-codex",
- }
- model.variants = ProviderTransform.variants(model)
- provider.models["gpt-5.3-codex"] = model
- }
- // Zero out costs for Codex (included with ChatGPT subscription)
- for (const model of Object.values(provider.models)) {
- model.cost = {
- input: 0,
- output: 0,
- cache: { read: 0, write: 0 },
- }
- }
- return {
- apiKey: OAUTH_DUMMY_KEY,
- async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
- // Remove dummy API key authorization header
- if (init?.headers) {
- if (init.headers instanceof Headers) {
- init.headers.delete("authorization")
- init.headers.delete("Authorization")
- } else if (Array.isArray(init.headers)) {
- init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization")
- } else {
- delete init.headers["authorization"]
- delete init.headers["Authorization"]
- }
- }
- const currentAuth = await getAuth()
- if (currentAuth.type !== "oauth") return fetch(requestInput, init)
- // Cast to include accountId field
- const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
- // Check if token needs refresh
- if (!currentAuth.access || currentAuth.expires < Date.now()) {
- log.info("refreshing codex access token")
- const tokens = await refreshAccessToken(currentAuth.refresh)
- const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
- await input.client.auth.set({
- path: { id: "openai" },
- body: {
- type: "oauth",
- refresh: tokens.refresh_token,
- access: tokens.access_token,
- expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
- ...(newAccountId && { accountId: newAccountId }),
- },
- })
- currentAuth.access = tokens.access_token
- authWithAccount.accountId = newAccountId
- }
- // Build headers
- const headers = new Headers()
- if (init?.headers) {
- if (init.headers instanceof Headers) {
- init.headers.forEach((value, key) => headers.set(key, value))
- } else if (Array.isArray(init.headers)) {
- for (const [key, value] of init.headers) {
- if (value !== undefined) headers.set(key, String(value))
- }
- } else {
- for (const [key, value] of Object.entries(init.headers)) {
- if (value !== undefined) headers.set(key, String(value))
- }
- }
- }
- // Set authorization header with access token
- headers.set("authorization", `Bearer ${currentAuth.access}`)
- // Set ChatGPT-Account-Id header for organization subscriptions
- if (authWithAccount.accountId) {
- headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
- }
- // Rewrite URL to Codex endpoint
- const parsed =
- requestInput instanceof URL
- ? requestInput
- : new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
- const url =
- parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
- ? new URL(CODEX_API_ENDPOINT)
- : parsed
- return fetch(url, {
- ...init,
- headers,
- })
- },
- }
- },
- methods: [
- {
- label: "ChatGPT Pro/Plus (browser)",
- type: "oauth",
- authorize: async () => {
- const { redirectUri } = await startOAuthServer()
- const pkce = await generatePKCE()
- const state = generateState()
- const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
- const callbackPromise = waitForOAuthCallback(pkce, state)
- return {
- url: authUrl,
- instructions: "Complete authorization in your browser. This window will close automatically.",
- method: "auto" as const,
- callback: async () => {
- const tokens = await callbackPromise
- stopOAuthServer()
- const accountId = extractAccountId(tokens)
- return {
- type: "success" as const,
- refresh: tokens.refresh_token,
- access: tokens.access_token,
- expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
- accountId,
- }
- },
- }
- },
- },
- {
- label: "ChatGPT Pro/Plus (headless)",
- type: "oauth",
- authorize: async () => {
- const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "User-Agent": `opencode/${Installation.VERSION}`,
- },
- body: JSON.stringify({ client_id: CLIENT_ID }),
- })
- if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization")
- const deviceData = (await deviceResponse.json()) as {
- device_auth_id: string
- user_code: string
- interval: string
- }
- const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000
- return {
- url: `${ISSUER}/codex/device`,
- instructions: `Enter code: ${deviceData.user_code}`,
- method: "auto" as const,
- async callback() {
- while (true) {
- const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "User-Agent": `opencode/${Installation.VERSION}`,
- },
- body: JSON.stringify({
- device_auth_id: deviceData.device_auth_id,
- user_code: deviceData.user_code,
- }),
- })
- if (response.ok) {
- const data = (await response.json()) as {
- authorization_code: string
- code_verifier: string
- }
- const tokenResponse = await fetch(`${ISSUER}/oauth/token`, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- grant_type: "authorization_code",
- code: data.authorization_code,
- redirect_uri: `${ISSUER}/deviceauth/callback`,
- client_id: CLIENT_ID,
- code_verifier: data.code_verifier,
- }).toString(),
- })
- if (!tokenResponse.ok) {
- throw new Error(`Token exchange failed: ${tokenResponse.status}`)
- }
- const tokens: TokenResponse = await tokenResponse.json()
- return {
- type: "success" as const,
- refresh: tokens.refresh_token,
- access: tokens.access_token,
- expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
- accountId: extractAccountId(tokens),
- }
- }
- if (response.status !== 403 && response.status !== 404) {
- return { type: "failed" as const }
- }
- await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
- }
- },
- }
- },
- },
- {
- label: "Manually enter API Key",
- type: "api",
- },
- ],
- },
- "chat.headers": async (input, output) => {
- if (input.model.providerID !== "openai") return
- output.headers.originator = "opencode"
- output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`
- output.headers.session_id = input.sessionID
- },
- }
- }
|