From 28440867528d19e5160914c6d9eb06e220efc937 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 9 Jan 2026 16:27:44 -0600 Subject: [PATCH] wip: codex --- packages/opencode/src/auth/index.ts | 2 + packages/opencode/src/codex/auth.ts | 109 +++++ packages/opencode/src/plugin/codex.ts | 416 ++++++++++++++++++ packages/opencode/src/plugin/index.ts | 16 +- packages/opencode/src/server/codex.ts | 180 ++++++++ packages/opencode/src/server/server.ts | 4 + packages/opencode/src/session/llm.ts | 42 +- .../src/session/prompt/codex_header.txt | 1 + packages/opencode/src/session/system.ts | 5 + 9 files changed, 767 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/codex/auth.ts create mode 100644 packages/opencode/src/plugin/codex.ts create mode 100644 packages/opencode/src/server/codex.ts create mode 100644 packages/opencode/src/session/prompt/codex_header.txt diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index b9c8a78caf..6642a07429 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -3,6 +3,8 @@ import { Global } from "../global" import fs from "fs/promises" import z from "zod" +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + export namespace Auth { export const Oauth = z .object({ diff --git a/packages/opencode/src/codex/auth.ts b/packages/opencode/src/codex/auth.ts new file mode 100644 index 0000000000..2c62a4288c --- /dev/null +++ b/packages/opencode/src/codex/auth.ts @@ -0,0 +1,109 @@ +import crypto from "crypto" + +export namespace CodexAuth { + const ISSUER = "https://auth.openai.com" + const CLIENT_ID = "openai-codex-cli" + + // Pending OAuth sessions: state -> { verifier, redirectUri } + const pending = new Map() + + function generatePkce() { + const verifier = crypto.randomBytes(64).toString("base64url") + const challenge = crypto.createHash("sha256").update(verifier).digest("base64url") + return { verifier, challenge } + } + + function generateState() { + return crypto.randomBytes(32).toString("base64url") + } + + export function authorize(redirectUri: string) { + const pkce = generatePkce() + const state = generateState() + + pending.set(state, { verifier: pkce.verifier, redirectUri }) + + // Clean up after 15 minutes + setTimeout(() => pending.delete(state), 15 * 60 * 1000) + + 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 { url: `${ISSUER}/oauth/authorize?${params}`, state } + } + + export async function callback(code: string, state: string) { + const session = pending.get(state) + if (!session) throw new Error("Invalid or expired OAuth state") + pending.delete(state) + + const resp = 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: session.redirectUri, + client_id: CLIENT_ID, + code_verifier: session.verifier, + }), + }) + + if (!resp.ok) { + const text = await resp.text() + throw new Error(`Token exchange failed: ${resp.status} ${text}`) + } + + const tokens = (await resp.json()) as { + id_token: string + access_token: string + refresh_token: string + expires_in?: number + } + + return { + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + } + } + + export async function refresh(refreshToken: string) { + const resp = await fetch(`${ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: refreshToken, + }), + }) + + if (!resp.ok) { + const text = await resp.text() + throw new Error(`Token refresh failed: ${resp.status} ${text}`) + } + + const tokens = (await resp.json()) as { + access_token: string + refresh_token?: string + expires_in?: number + } + + return { + access: tokens.access_token, + refresh: tokens.refresh_token ?? refreshToken, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + } + } +} diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts new file mode 100644 index 0000000000..9077c12966 --- /dev/null +++ b/packages/opencode/src/plugin/codex.ts @@ -0,0 +1,416 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { Log } from "../util/log" +import { Installation } from "../installation" +import { OAUTH_DUMMY_KEY } from "../auth" + +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 + +interface PkceCodes { + verifier: string + challenge: string +} + +async function generatePKCE(): Promise { + 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) +} + +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 { + 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 { + 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 = ` + + + OpenCode - Codex Authorization Successful + + + +
+

Authorization Successful

+

You can close this window and return to OpenCode.

+
+ + +` + +const HTML_ERROR = (error: string) => ` + + + OpenCode - Codex Authorization Failed + + + +
+

Authorization Failed

+

An error occurred during authorization.

+
${error}
+
+ +` + +interface PendingOAuth { + pkce: PkceCodes + state: string + resolve: (tokens: TokenResponse) => void + reject: (error: Error) => void +} + +let oauthServer: ReturnType | 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 { + 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) + }, + } + }) +} + +function getUserAgent(): string { + const version = Installation.VERSION + const platform = process.platform + const arch = process.arch + return `opencode/${version} (${platform}; ${arch})` +} + +export async function CodexAuthPlugin(input: PluginInput): Promise { + return { + auth: { + provider: "openai", + async loader(getAuth, provider) { + const auth = await getAuth() + if (auth.type !== "oauth") return {} + + // 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) + + // Check if token needs refresh + if (!currentAuth.access || currentAuth.expires < Date.now()) { + log.info("refreshing codex access token") + const tokens = await refreshAccessToken(currentAuth.refresh) + await input.client.auth.set({ + path: { id: "codex" }, + body: { + type: "oauth", + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + }, + }) + currentAuth.access = tokens.access_token + } + + // 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 required Codex headers + headers.set("authorization", `Bearer ${currentAuth.access}`) + headers.set("originator", "opencode") + headers.set("user-agent", getUserAgent()) + + // Extract session_id from request body if present + let body = init?.body + if (body && typeof body === "string") { + const parsed = JSON.parse(body) + // The session ID should be passed in the request - we'll extract it from context + // For now, generate a UUIDv7-like ID based on timestamp + const sessionId = parsed.metadata?.sessionID || generateSessionId() + headers.set("session_id", sessionId) + } + + // Rewrite URL to Codex endpoint + let url: URL + if (typeof requestInput === "string") { + url = new URL(requestInput) + } else if (requestInput instanceof URL) { + url = requestInput + } else { + url = new URL(requestInput.url) + } + + // If this is a messages/responses request, redirect to Codex endpoint + if (url.pathname.includes("/v1/responses") || url.pathname.includes("/chat/completions")) { + url = new URL(CODEX_API_ENDPOINT) + } + + return fetch(url, { + ...init, + body, + headers, + }) + }, + } + }, + methods: [ + { + label: "ChatGPT Pro/Plus", + 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() + return { + type: "success" as const, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + } + }, + } + }, + }, + ], + }, + } +} + +// Generate a UUIDv7-like session ID (timestamp-prefixed) +function generateSessionId(): string { + const timestamp = Date.now() + const timestampHex = timestamp.toString(16).padStart(12, "0") + const randomHex = Array.from(crypto.getRandomValues(new Uint8Array(10))) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + // UUIDv7 format: xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx + // First 48 bits are timestamp, version is 7 + return `${timestampHex.slice(0, 8)}-${timestampHex.slice(8, 12)}-7${randomHex.slice(0, 3)}-${(0x80 | (parseInt(randomHex.slice(3, 4), 16) & 0x3f)).toString(16)}${randomHex.slice(4, 7)}-${randomHex.slice(7, 19)}` +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index f2ee91122a..dbdc857243 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -7,12 +7,16 @@ import { Server } from "../server/server" import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" +import { CodexAuthPlugin } from "./codex" export namespace Plugin { const log = Log.create({ service: "plugin" }) const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.8"] + // Built-in plugins that are directly imported (not installed from npm) + const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin] + const state = Instance.state(async () => { const client = createOpencodeClient({ baseUrl: "http://localhost:4096", @@ -20,7 +24,7 @@ export namespace Plugin { fetch: async (...args) => Server.App().fetch(...args), }) const config = await Config.get() - const hooks = [] + const hooks: Hooks[] = [] const input: PluginInput = { client, project: Instance.project, @@ -29,6 +33,16 @@ export namespace Plugin { serverUrl: Server.url(), $: Bun.$, } + + // Load internal plugins first + if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { + for (const plugin of INTERNAL_PLUGINS) { + log.info("loading internal plugin", { name: plugin.name }) + const init = await plugin(input) + hooks.push(init) + } + } + const plugins = [...(config.plugin ?? [])] if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { plugins.push(...BUILTIN) diff --git a/packages/opencode/src/server/codex.ts b/packages/opencode/src/server/codex.ts new file mode 100644 index 0000000000..c11d96f5a6 --- /dev/null +++ b/packages/opencode/src/server/codex.ts @@ -0,0 +1,180 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { errors } from "./error" +import { Auth } from "@/auth" +import { CodexAuth } from "@/codex/auth" + +export const CodexRoute = new Hono() + .post( + "/auth/authorize", + describeRoute({ + summary: "Start Codex OAuth", + description: "Initiate OAuth flow for Codex/ChatGPT authentication. Returns URL to open in browser.", + operationId: "codex.auth.authorize", + responses: { + 200: { + description: "Authorization URL and state", + content: { + "application/json": { + schema: resolver( + z.object({ + url: z.string(), + state: z.string(), + }), + ), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + port: z.coerce.number().optional(), + }), + ), + async (c) => { + // Get port from request URL since we can't import Server (circular dep) + const url = new URL(c.req.url) + const port = c.req.valid("query").port ?? url.port ?? 4096 + const redirectUri = `http://localhost:${port}/codex/auth/callback` + const result = CodexAuth.authorize(redirectUri) + return c.json(result) + }, + ) + .get( + "/auth/callback", + describeRoute({ + summary: "Codex OAuth callback", + description: "Handle OAuth callback from ChatGPT auth. Called by browser after user authenticates.", + operationId: "codex.auth.callback", + responses: { + 200: { + description: "Success page", + content: { + "text/html": {}, + }, + }, + ...errors(400), + }, + }), + validator( + "query", + z.object({ + code: z.string(), + state: z.string(), + }), + ), + async (c) => { + const query = c.req.valid("query") + const tokens = await CodexAuth.callback(query.code, query.state) + + await Auth.set("codex", { + type: "oauth", + access: tokens.access, + refresh: tokens.refresh, + expires: tokens.expires, + }) + + return c.html(` + +Login Successful + +

Login Successful

+

You can close this window and return to OpenCode.

+ +`) + }, + ) + .post( + "/auth/refresh", + describeRoute({ + summary: "Refresh Codex tokens", + description: "Refresh the Codex access token using the stored refresh token.", + operationId: "codex.auth.refresh", + responses: { + 200: { + description: "Tokens refreshed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => { + const existing = await Auth.get("codex") + if (!existing || existing.type !== "oauth") { + throw new Error("No Codex OAuth credentials found") + } + + const tokens = await CodexAuth.refresh(existing.refresh) + + await Auth.set("codex", { + type: "oauth", + access: tokens.access, + refresh: tokens.refresh, + expires: tokens.expires, + }) + + return c.json(true) + }, + ) + .get( + "/auth/status", + describeRoute({ + summary: "Get Codex auth status", + description: "Check if Codex OAuth credentials exist and whether they're expired.", + operationId: "codex.auth.status", + responses: { + 200: { + description: "Auth status", + content: { + "application/json": { + schema: resolver( + z.object({ + authenticated: z.boolean(), + expired: z.boolean().optional(), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const existing = await Auth.get("codex") + if (!existing || existing.type !== "oauth") { + return c.json({ authenticated: false }) + } + return c.json({ + authenticated: true, + expired: existing.expires < Date.now(), + }) + }, + ) + .delete( + "/auth", + describeRoute({ + summary: "Remove Codex auth", + description: "Remove stored Codex OAuth credentials.", + operationId: "codex.auth.remove", + responses: { + 200: { + description: "Auth removed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Auth.remove("codex") + return c.json(true) + }, + ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c7baec778c..482f6d223b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -52,6 +52,7 @@ import { QuestionRoute } from "./question" import { Installation } from "@/installation" import { MDNS } from "./mdns" import { Worktree } from "../worktree" +import { CodexRoute } from "./codex" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -74,6 +75,8 @@ export namespace Server { const app = new Hono() export const App: () => Hono = lazy( () => + // TODO: Break server.ts into smaller route files to fix type inference + // @ts-expect-error - Hono route chain is too deep for TypeScript's type inference app .onError((err, c) => { log.error("failed", { @@ -1898,6 +1901,7 @@ export namespace Server { return c.json(true) }, ) + .route("/codex", CodexRoute) .get( "/find", describeRoute({ diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 0db453a222..ebf30c35ef 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,3 +1,5 @@ +import os from "os" +import { Installation } from "@/installation" import { Provider } from "@/provider/provider" import { Log } from "@/util/log" import { @@ -19,6 +21,7 @@ import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" import { Flag } from "@/flag/flag" import { PermissionNext } from "@/permission/next" +import { Auth } from "@/auth" export namespace LLM { const log = Log.create({ service: "llm" }) @@ -82,12 +85,23 @@ export namespace LLM { } const provider = await Provider.getProvider(input.model.providerID) + const auth = await Auth.get(input.model.providerID) + const isCodex = provider.id === "openai" && auth?.type === "oauth" + const variant = !input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {} const base = input.small ? ProviderTransform.smallOptions(input.model) : ProviderTransform.options(input.model, input.sessionID, provider.options) - const options = pipe(base, mergeDeep(input.model.options), mergeDeep(input.agent.options), mergeDeep(variant)) + const options: Record = pipe( + base, + mergeDeep(input.model.options), + mergeDeep(input.agent.options), + mergeDeep(variant), + ) + if (isCodex) { + options.instructions = SystemPrompt.instructions() + } const params = await Plugin.trigger( "chat.params", @@ -157,6 +171,13 @@ export namespace LLM { maxOutputTokens, abortSignal: input.abort, headers: { + ...(isCodex + ? { + originator: "opencode", + "User-Agent": `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, + session_id: input.sessionID, + } + : undefined), ...(input.model.providerID.startsWith("opencode") ? { "x-opencode-project": Instance.project.id, @@ -169,12 +190,19 @@ export namespace LLM { }, maxRetries: input.retries ?? 0, messages: [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), + ...(isCodex + ? [ + { + role: "user", + content: system.join("\n\n"), + } as ModelMessage, + ] + : system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + )), ...input.messages, ], model: wrapLanguageModel({ diff --git a/packages/opencode/src/session/prompt/codex_header.txt b/packages/opencode/src/session/prompt/codex_header.txt new file mode 100644 index 0000000000..70c2c6555f --- /dev/null +++ b/packages/opencode/src/session/prompt/codex_header.txt @@ -0,0 +1 @@ +You are a coding agent running in the opencode, a terminal-based coding assistant. opencode is an open source project. You are expected to be precise, safe, and helpful. diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index fe8c32f032..fff9080886 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -14,6 +14,7 @@ import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" import PROMPT_CODEX from "./prompt/codex.txt" +import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt" import type { Provider } from "@/provider/provider" import { Flag } from "@/flag/flag" @@ -23,6 +24,10 @@ export namespace SystemPrompt { return [] } + export function instructions() { + return PROMPT_CODEX_INSTRUCTIONS.trim() + } + export function provider(model: Provider.Model) { if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX] if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))