diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 5a9cd6e859..74574f11c3 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -6,15 +6,24 @@ import { setTimeout as sleep } from "node:timers/promises" import { createServer } from "http" import { OpenAIWebSocketPool } from "./ws-pool" import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" +import { isRecord } from "@/util/record" 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 +const CODEX_COMPATIBILITY_VERSION = "0.144.0" +const RESPONSES_LITE_MODEL = "gpt-5.6-luna" const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"]) +type ResponsesLiteRequest = Record & { + input: unknown[] + tools?: unknown[] + instructions?: string +} + interface PkceCodes { verifier: string challenge: string @@ -263,6 +272,7 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise { const issuer = options.issuer ?? ISSUER const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT + const codexSessionIDs = new Map() let websocketFetchInstalled = false const websocketFetches: Array> = [] @@ -273,7 +283,13 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug }, async event(input) { if (input.event.type !== "session.deleted") return - for (const websocketFetch of websocketFetches) websocketFetch.remove(input.event.properties.info.id) + const sessionID = input.event.properties.info.id + const codexSessionID = codexSessionIDs.get(sessionID) + for (const websocketFetch of websocketFetches) { + websocketFetch.remove(sessionID) + if (codexSessionID) websocketFetch.remove(codexSessionID) + } + codexSessionIDs.delete(sessionID) }, provider: { id: "openai", @@ -285,6 +301,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug .filter(([, model]) => { if (ALLOWED_MODELS.has(model.api.id)) return true if (DISALLOWED_MODELS.has(model.api.id)) return false + if (model.api.id === "gpt-5.6") return false const match = model.api.id.match(/^gpt-(\d+\.\d+)/) return match ? parseFloat(match[1]) > 5.4 : false }) @@ -416,6 +433,9 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug const requestInit = { ...init, + body: parsed.pathname.endsWith("/responses") + ? prepareResponsesLiteRequest(init?.body, headers, codexSessionIDs) + : init?.body, headers, } if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit) @@ -560,3 +580,81 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug }, } } + +function prepareResponsesLiteRequest( + body: BodyInit | null | undefined, + headers: Headers, + sessionIDs: Map, +) { + if (typeof body !== "string") return body + const request = parseResponsesLiteRequest(body) + if (!request) return body + const sourceSessionID = requireSessionID(headers) + const sessionID = sessionIDs.get(sourceSessionID) ?? Bun.randomUUIDv7() + sessionIDs.set(sourceSessionID, sessionID) + + stripImageDetail(request.input) + request.input = [ + { type: "additional_tools", role: "developer", tools: request.tools ?? [] }, + ...(request.instructions + ? [ + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: request.instructions }], + }, + ] + : []), + ...request.input, + ] + delete request.tools + delete request.instructions + request.tool_choice = "auto" + request.parallel_tool_calls = false + request.prompt_cache_key = sessionID + request.reasoning = { + ...(isRecord(request.reasoning) ? request.reasoning : {}), + context: "all_turns", + } + + headers.set("session-id", sessionID) + headers.set("x-session-affinity", sessionID) + headers.set("version", CODEX_COMPATIBILITY_VERSION) + headers.set(OpenAIWebSocketPool.RESPONSES_LITE_HEADER, "true") + headers.delete("content-length") + return JSON.stringify(request) +} + +function parseResponsesLiteRequest(body: string): ResponsesLiteRequest | undefined { + const request: unknown = JSON.parse(body) + if (!isRecord(request) || request.model !== RESPONSES_LITE_MODEL) return undefined + if (!Array.isArray(request.input)) throw new Error("Responses Lite requires an input array") + if (request.tools !== undefined && !Array.isArray(request.tools)) { + throw new Error("Responses Lite requires a tools array") + } + if (request.instructions !== undefined && typeof request.instructions !== "string") { + throw new Error("Responses Lite requires string instructions") + } + return { + ...request, + input: request.input, + tools: request.tools, + instructions: request.instructions, + } +} + +function requireSessionID(headers: Headers) { + const sessionID = headers.get("session-id") + if (!sessionID) throw new Error("Responses Lite requires a session-id header") + return sessionID +} + +function stripImageDetail(value: unknown) { + if (Array.isArray(value)) { + value.forEach(stripImageDetail) + return + } + if (!isRecord(value)) return + if (value.type === "input_image") delete value.detail + Object.values(value).forEach(stripImageDetail) +} diff --git a/packages/opencode/src/plugin/openai/ws-pool.ts b/packages/opencode/src/plugin/openai/ws-pool.ts index 3cbb29a301..d907238a29 100644 --- a/packages/opencode/src/plugin/openai/ws-pool.ts +++ b/packages/opencode/src/plugin/openai/ws-pool.ts @@ -4,6 +4,8 @@ import { isRecord } from "@/util/record" import { OpenAIWebSocket } from "./ws" export const TITLE_HEADER = "x-opencode-title" +export const RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite" +const RESPONSES_LITE_CLIENT_METADATA = "ws_request_header_x_openai_internal_codex_responses_lite" export interface CreateWebSocketFetchOptions { httpFetch?: typeof globalThis.fetch @@ -98,7 +100,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { }) const response = OpenAIWebSocket.streamResponsesWebSocket({ socket: entry.socket, - body, + body: withResponsesLiteMetadata(body, internalHeaders), idleTimeout, signal: init?.signal ?? undefined, onFirstEvent: (error) => resolveFirstEvent(error ?? true), @@ -194,6 +196,17 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { return Object.assign(websocketFetch, { close, remove }) } +function withResponsesLiteMetadata(body: Record, headers: Record) { + if (headers[RESPONSES_LITE_HEADER] !== "true") return body + return { + ...body, + client_metadata: { + ...(isRecord(body.client_metadata) ? body.client_metadata : {}), + [RESPONSES_LITE_CLIENT_METADATA]: "true", + }, + } +} + function connectionLimitError(event: Record) { if (event.type !== "error" || !isRecord(event.error) || event.error.code !== CONNECTION_LIMIT_REACHED_CODE) return return new Error(typeof event.error.message === "string" ? event.error.message : CONNECTION_LIMIT_REACHED_CODE) diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 0d789bb42b..683d1312ee 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -152,13 +152,19 @@ describe("plugin.codex", () => { test("uses Codex context limits for OAuth GPT models", async () => { const hooks = await CodexAuthPlugin({} as never) const limit = { context: 1_050_000, input: 922_000, output: 128_000 } + const ids = [ + "gpt-5.4", + "gpt-5.5", + "gpt-5.6", + "gpt-5.6-luna", + "gpt-5.6-luna-pro", + "gpt-5.6-sol", + "gpt-5.6-sol-pro", + "gpt-5.6-terra", + "gpt-5.6-terra-pro", + ] const provider = { - models: Object.fromEntries( - ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].map((id) => [ - id, - { id, api: { id }, limit, cost: {} }, - ]), - ), + models: Object.fromEntries(ids.map((id) => [id, { id, api: { id }, limit, cost: {} }])), } const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never) @@ -168,11 +174,155 @@ describe("plugin.codex", () => { expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 }) expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 }) expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 }) + expect(models["gpt-5.6"]).toBeUndefined() + expect(Object.keys(models)).toEqual(ids.filter((id) => id !== "gpt-5.6")) expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe( provider.models as never, ) }) + test("applies Responses Lite only to exact Luna requests and keeps its session UUID stable", async () => { + const requests: Array<{ body: Record; headers: Headers }> = [] + using server = Bun.serve({ + port: 0, + async fetch(request) { + requests.push({ body: await request.json(), headers: request.headers }) + return Response.json({}) + }, + }) + const hooks = await CodexAuthPlugin({} as never, { + codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(), + }) + const loaded = await hooks.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + {} as never, + ) + const first = { + model: "gpt-5.6-luna", + stream: true, + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_image", image_url: "data:image/png;base64,a", detail: "high" }], + }, + ], + tools: [], + instructions: "Be precise", + tool_choice: "required", + parallel_tool_calls: true, + reasoning: { effort: "high" }, + } + const continuation = { + model: "gpt-5.6-luna", + stream: true, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] }], + tools: [{ type: "function", name: "search" }], + instructions: "", + } + const headers = { + "session-id": "opencode-session", + originator: "opencode", + "user-agent": "opencode/test", + } + + await loaded.fetch!("https://api.openai.com/v1/responses", { + method: "POST", + headers, + body: JSON.stringify(first), + }) + await loaded.fetch!("https://api.openai.com/v1/responses", { + method: "POST", + headers, + body: JSON.stringify(continuation), + }) + + expect(requests).toHaveLength(2) + const sessionID = requests[0].headers.get("session-id") + expect(sessionID).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + expect(requests[1].headers.get("session-id")).toBe(sessionID) + for (const request of requests) { + expect(request.headers.get("x-session-affinity")).toBe(sessionID) + expect(request.headers.get("version")).toBe("0.144.0") + expect(request.headers.get("x-openai-internal-codex-responses-lite")).toBe("true") + expect(request.headers.get("originator")).toBe("opencode") + expect(request.headers.get("user-agent")).toBe("opencode/test") + expect(request.body.prompt_cache_key).toBe(sessionID) + expect(request.body.tool_choice).toBe("auto") + expect(request.body.parallel_tool_calls).toBe(false) + expect(request.body.tools).toBeUndefined() + expect(request.body.instructions).toBeUndefined() + } + expect(requests[0].body.reasoning).toEqual({ effort: "high", context: "all_turns" }) + expect(requests[1].body.reasoning).toEqual({ context: "all_turns" }) + expect(requests[0].body.input).toEqual([ + { type: "additional_tools", role: "developer", tools: [] }, + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "Be precise" }], + }, + { + type: "message", + role: "user", + content: [{ type: "input_image", image_url: "data:image/png;base64,a" }], + }, + ]) + expect(requests[1].body.input).toEqual([ + { type: "additional_tools", role: "developer", tools: [{ type: "function", name: "search" }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] }, + ]) + }) + + test("leaves Sol, Terra, and other Responses requests unchanged", async () => { + const requests: Array<{ body: string; headers: Headers }> = [] + using server = Bun.serve({ + port: 0, + async fetch(request) { + requests.push({ body: await request.text(), headers: request.headers }) + return Response.json({}) + }, + }) + const hooks = await CodexAuthPlugin({} as never, { + codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(), + }) + const loaded = await hooks.auth!.loader!( + async () => ({ + type: "oauth", + refresh: "refresh", + access: "access", + expires: Date.now() + 60_000, + }), + {} as never, + ) + const bodies = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna-pro", "gpt-5.5"].map((model) => + JSON.stringify({ model, stream: true, input: [], tools: [], instructions: "Keep me" }), + ) + + await Promise.all( + bodies.map((body) => + loaded.fetch!("https://api.openai.com/v1/responses", { + method: "POST", + headers: { "session-id": "opencode-session" }, + body, + }), + ), + ) + + expect(requests.map((request) => request.body).sort()).toEqual(bodies.sort()) + for (const request of requests) { + expect(request.headers.get("session-id")).toBe("opencode-session") + expect(request.headers.get("x-session-affinity")).toBeNull() + expect(request.headers.get("version")).toBeNull() + expect(request.headers.get("x-openai-internal-codex-responses-lite")).toBeNull() + } + }) + test("deduplicates concurrent Codex token refreshes", async () => { let auth = { type: "oauth" as const, diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index 7a125824e0..8a0dcb4594 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -166,6 +166,31 @@ describe("plugin.openai.ws-pool", () => { fetch.close() }) + test("adds the Responses Lite marker to websocket client metadata", async () => { + let requestBody: Record | undefined + await using server = await createWebSocketServer((socket) => { + socket.once("message", (data) => { + requestBody = JSON.parse(data.toString()) + socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_luna" } })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url }) + + const response = await fetch( + server.url, + streamRequest({ [OpenAIWebSocketPool.RESPONSES_LITE_HEADER]: "true" }, undefined, { + client_metadata: { existing: "value" }, + }), + ) + + expect(await response.text()).toContain("data: [DONE]") + expect(requestBody?.client_metadata).toEqual({ + existing: "value", + ws_request_header_x_openai_internal_codex_responses_lite: "true", + }) + fetch.close() + }) + test("rotates a socket that exceeds max connection age", async () => { let connections = 0 await using server = await createWebSocketServer((socket) => { @@ -773,7 +798,11 @@ describe("plugin.openai.ws-pool", () => { }) }) -function streamRequest(headers?: Record, signal?: AbortSignal): RequestInit { +function streamRequest( + headers?: Record, + signal?: AbortSignal, + body?: Record, +): RequestInit { return { method: "POST", headers: { @@ -781,7 +810,7 @@ function streamRequest(headers?: Record, signal?: AbortSignal): authorization: "Bearer test", ...headers, }, - body: JSON.stringify({ stream: true, input: "hi" }), + body: JSON.stringify({ stream: true, input: "hi", ...body }), signal, } }