From 486fe992ef4e8fca8033c23bf500689a7db2c55a Mon Sep 17 00:00:00 2001 From: AidenGeunGeun Date: Fri, 10 Jul 2026 04:51:55 +0900 Subject: [PATCH] fix(opencode): support GPT-5.6 Responses Lite --- packages/opencode/src/plugin/openai/codex.ts | 90 +++++++- .../opencode/src/plugin/openai/ws-pool.ts | 13 +- packages/opencode/test/plugin/codex.test.ts | 194 ++++++++++++++++++ .../opencode/test/plugin/openai-ws.test.ts | 41 ++++ 4 files changed, 336 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 5a9cd6e8592b..a3ce7a4432ed 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -6,12 +6,15 @@ 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_MODELS = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "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"]) @@ -263,6 +266,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 +277,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", @@ -414,8 +424,18 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug ? new URL(codexApiEndpoint) : parsed + const liteRequest = parsed.pathname.endsWith("/responses") + ? parseResponsesLiteRequest(init?.body) + : undefined const requestInit = { ...init, + ...(liteRequest && { + body: prepareResponsesLiteRequest({ + request: liteRequest, + headers, + sessionIDs: codexSessionIDs, + }), + }), headers, } if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit) @@ -560,3 +580,71 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug }, } } + +function parseResponsesLiteRequest(body: BodyInit | null | undefined): Record | undefined { + if (typeof body !== "string") return undefined + const request: unknown = JSON.parse(body) + if (!isRecord(request)) return undefined + if (typeof request.model !== "string" || !RESPONSES_LITE_MODELS.has(request.model)) return undefined + return request +} + +function prepareResponsesLiteRequest(input: { + request: Record + headers: Headers + sessionIDs: Map +}) { + if (!Array.isArray(input.request.input)) throw new Error("Responses Lite requires an input array") + if (input.request.tools !== undefined && !Array.isArray(input.request.tools)) { + throw new Error("Responses Lite requires a tools array") + } + if (input.request.instructions !== undefined && typeof input.request.instructions !== "string") { + throw new Error("Responses Lite requires string instructions") + } + + const sourceSessionID = input.headers.get("session-id") + if (!sourceSessionID) throw new Error("Responses Lite requires a session-id header") + const sessionID = input.sessionIDs.get(sourceSessionID) ?? Bun.randomUUIDv7() + input.sessionIDs.set(sourceSessionID, sessionID) + + stripImageDetail(input.request.input) + input.request.input = [ + { type: "additional_tools", role: "developer", tools: input.request.tools ?? [] }, + ...(input.request.instructions + ? [ + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: input.request.instructions }], + }, + ] + : []), + ...input.request.input, + ] + delete input.request.tools + delete input.request.instructions + input.request.tool_choice = "auto" + input.request.parallel_tool_calls = false + input.request.prompt_cache_key = sessionID + input.request.reasoning = { + ...(isRecord(input.request.reasoning) ? input.request.reasoning : {}), + context: "all_turns", + } + + input.headers.set("session-id", sessionID) + input.headers.set("x-session-affinity", sessionID) + input.headers.set("version", CODEX_COMPATIBILITY_VERSION) + input.headers.set(OpenAIWebSocketPool.RESPONSES_LITE_HEADER, "true") + input.headers.delete("content-length") + return JSON.stringify(input.request) +} + +function stripImageDetail(input: unknown): void { + if (Array.isArray(input)) { + input.forEach(stripImageDetail) + return + } + if (!isRecord(input)) return + if (input.type === "input_image") delete input.detail + Object.values(input).forEach(stripImageDetail) +} diff --git a/packages/opencode/src/plugin/openai/ws-pool.ts b/packages/opencode/src/plugin/openai/ws-pool.ts index 3cbb29a3012a..3b1e363da089 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" +export const RESPONSES_LITE_CLIENT_METADATA = "ws_request_header_x_openai_internal_codex_responses_lite" export interface CreateWebSocketFetchOptions { httpFetch?: typeof globalThis.fetch @@ -98,7 +100,16 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { }) const response = OpenAIWebSocket.streamResponsesWebSocket({ socket: entry.socket, - body, + body: + internalHeaders[RESPONSES_LITE_HEADER] === "true" + ? { + ...body, + client_metadata: { + ...(isRecord(body.client_metadata) ? body.client_metadata : {}), + [RESPONSES_LITE_CLIENT_METADATA]: "true", + }, + } + : body, idleTimeout, signal: init?.signal ?? undefined, onFirstEvent: (error) => resolveFirstEvent(error ?? true), diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 0d789bb42b5a..77a02a40c1ae 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -7,6 +7,7 @@ import { renderOAuthError, type IdTokenClaims, } from "../../src/plugin/openai/codex" +import { isRecord } from "../../src/util/record" function createTestJwt(payload: object): string { const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url") @@ -173,6 +174,174 @@ describe("plugin.codex", () => { ) }) + test("rewrites GPT-5.6 OAuth requests for Responses Lite", async () => { + const requests: Array<{ headers: Headers; body: Record }> = [] + using server = Bun.serve({ + port: 0, + async fetch(request) { + requests.push({ + headers: new Headers(request.headers), + body: await readRequestBody(request), + }) + return Response.json({}) + }, + }) + const providerFetch = await loadCodexFetch(new URL("/backend-api/codex/responses", server.url).toString()) + const body = JSON.stringify({ + model: "gpt-5.6-luna", + input: [ + { + role: "user", + content: [{ type: "input_image", image_url: "data:image/png;base64,test", detail: "high" }], + }, + { + type: "function_call_output", + call_id: "call_123", + output: [{ type: "input_image", image_url: "data:image/png;base64,result", detail: "low" }], + }, + ], + instructions: "Be concise.", + tools: [ + { + type: "function", + name: "noop", + description: "No operation", + parameters: { type: "object", properties: {}, additionalProperties: false }, + strict: false, + }, + ], + parallel_tool_calls: true, + prompt_cache_key: "ses_luna", + reasoning: { effort: "high", summary: "auto" }, + stream: true, + }) + const init = { + method: "POST", + headers: { + "content-type": "application/json", + "session-id": "ses_luna", + "x-session-affinity": "ses_luna", + }, + body, + } + + await providerFetch("https://api.openai.com/v1/responses", init) + await Promise.all( + ["gpt-5.6-sol", "gpt-5.6-terra"].map((model) => + providerFetch("https://api.openai.com/v1/responses", { + ...init, + headers: { + ...init.headers, + "session-id": `ses_${model}`, + "x-session-affinity": `ses_${model}`, + }, + body: JSON.stringify({ model, input: [], stream: true }), + }), + ), + ) + await providerFetch("https://api.openai.com/v1/responses", { + ...init, + body: JSON.stringify({ model: "gpt-5.6-luna", input: [], stream: true }), + }) + + expect(requests).toHaveLength(4) + expect( + requests + .slice(0, 3) + .map((request) => request.body.model) + .sort(), + ).toEqual(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]) + expect(requests.slice(0, 3).map((request) => request.headers.get("version"))).toEqual([ + "0.144.0", + "0.144.0", + "0.144.0", + ]) + expect( + requests.slice(0, 3).map((request) => request.headers.get("x-openai-internal-codex-responses-lite")), + ).toEqual(["true", "true", "true"]) + 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[3]?.headers.get("session-id")).toBe(sessionID) + expect(requests[0]?.headers.get("x-session-affinity")).toBe(sessionID) + expect(requests[0]?.body.prompt_cache_key).toBe(sessionID) + expect(requests[0]?.body.tool_choice).toBe("auto") + expect(requests[0]?.body.parallel_tool_calls).toBe(false) + expect(requests[0]?.body.reasoning).toEqual({ effort: "high", summary: "auto", context: "all_turns" }) + expect(requests[0]?.body.tools).toBeUndefined() + expect(requests[0]?.body.instructions).toBeUndefined() + expect(requests[0]?.body.input).toEqual([ + { + type: "additional_tools", + role: "developer", + tools: [ + { + type: "function", + name: "noop", + description: "No operation", + parameters: { type: "object", properties: {}, additionalProperties: false }, + strict: false, + }, + ], + }, + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "Be concise." }], + }, + { + role: "user", + content: [{ type: "input_image", image_url: "data:image/png;base64,test" }], + }, + { + type: "function_call_output", + call_id: "call_123", + output: [{ type: "input_image", image_url: "data:image/png;base64,result" }], + }, + ]) + expect(requests.slice(1).map((request) => request.body.input)).toEqual([ + [{ type: "additional_tools", role: "developer", tools: [] }], + [{ type: "additional_tools", role: "developer", tools: [] }], + [{ type: "additional_tools", role: "developer", tools: [] }], + ]) + }) + + test("leaves non-Lite OAuth requests unchanged", async () => { + const requests: Array<{ headers: Headers; body: Record }> = [] + using server = Bun.serve({ + port: 0, + async fetch(request) { + requests.push({ + headers: new Headers(request.headers), + body: await readRequestBody(request), + }) + return Response.json({}) + }, + }) + const providerFetch = await loadCodexFetch(new URL("/backend-api/codex/responses", server.url).toString()) + const body = { + model: "gpt-5.5", + input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }], + instructions: "Be concise.", + tools: [], + parallel_tool_calls: true, + prompt_cache_key: "ses_legacy", + reasoning: { effort: "medium", summary: "auto" }, + stream: true, + } + + await providerFetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", "session-id": "ses_legacy" }, + body: JSON.stringify(body), + }) + + expect(requests).toHaveLength(1) + expect(requests[0]?.headers.get("session-id")).toBe("ses_legacy") + expect(requests[0]?.headers.get("version")).toBeNull() + expect(requests[0]?.headers.get("x-openai-internal-codex-responses-lite")).toBeNull() + expect(requests[0]?.body).toEqual(body) + }) + test("deduplicates concurrent Codex token refreshes", async () => { let auth = { type: "oauth" as const, @@ -278,3 +447,28 @@ async function waitFor(predicate: () => boolean) { await new Promise((resolve) => setTimeout(resolve, 1)) } } + +async function readRequestBody(request: Request) { + const body: unknown = await request.json() + if (!isRecord(body)) throw new Error("Expected a JSON object") + return body +} + +async function loadCodexFetch(endpoint: string) { + const hooks = await CodexAuthPlugin({} as never, { + codexApiEndpoint: endpoint, + }) + const loaded = await hooks.auth!.loader!( + async () => + ({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 60_000, + accountId: "account-id", + }) as never, + {} as never, + ) + if (!loaded.fetch) throw new Error("Expected a provider fetch implementation") + return loaded.fetch +} diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index 7a125824e0bf..2245d621822f 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -166,6 +166,47 @@ describe("plugin.openai.ws-pool", () => { fetch.close() }) + test("adds Responses Lite client metadata without splitting the session socket", async () => { + let connections = 0 + const requests: unknown[] = [] + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.on("message", (data) => { + requests.push(JSON.parse(data.toString())) + socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${requests.length}` } })) + }) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url }) + const sessionID = "019f4860-9ca3-7000-81e9-08939c58b0fa" + + const legacy = await fetch( + server.url, + streamRequest({ "session-id": sessionID, "x-session-affinity": sessionID }), + ) + expect(await legacy.text()).toContain("data: [DONE]") + const lite = await fetch( + server.url, + streamRequest({ + "session-id": sessionID, + "x-session-affinity": sessionID, + [OpenAIWebSocketPool.RESPONSES_LITE_HEADER]: "true", + }), + ) + + expect(await lite.text()).toContain("data: [DONE]") + expect(connections).toBe(1) + expect(requests[0]).not.toHaveProperty( + `client_metadata.${OpenAIWebSocketPool.RESPONSES_LITE_CLIENT_METADATA}`, + ) + expect(requests[1]).toMatchObject({ + type: "response.create", + client_metadata: { + [OpenAIWebSocketPool.RESPONSES_LITE_CLIENT_METADATA]: "true", + }, + }) + fetch.close() + }) + test("rotates a socket that exceeds max connection age", async () => { let connections = 0 await using server = await createWebSocketServer((socket) => {