Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 98 additions & 1 deletion packages/opencode/src/plugin/openai/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> & {
input: unknown[]
tools?: unknown[]
instructions?: string
}

interface PkceCodes {
verifier: string
challenge: string
Expand Down Expand Up @@ -263,6 +272,7 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
const issuer = options.issuer ?? ISSUER
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
const codexSessionIDs = new Map<string, string>()
let websocketFetchInstalled = false
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []

Expand All @@ -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",
Expand Down Expand Up @@ -416,6 +432,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)
Expand Down Expand Up @@ -560,3 +579,81 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
},
}
}

function prepareResponsesLiteRequest(
body: BodyInit | null | undefined,
headers: Headers,
sessionIDs: Map<string, string>,
) {
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)
}
15 changes: 14 additions & 1 deletion packages/opencode/src/plugin/openai/ws-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -194,6 +196,17 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
return Object.assign(websocketFetch, { close, remove })
}

function withResponsesLiteMetadata(body: Record<string, unknown>, headers: Record<string, string>) {
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<string, unknown>) {
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)
Expand Down
142 changes: 142 additions & 0 deletions packages/opencode/test/plugin/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,148 @@ describe("plugin.codex", () => {
)
})

test("applies Responses Lite only to exact Luna requests and keeps its session UUID stable", async () => {
const requests: Array<{ body: Record<string, unknown>; 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,
Expand Down
33 changes: 31 additions & 2 deletions packages/opencode/test/plugin/openai-ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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) => {
Expand Down Expand Up @@ -773,15 +798,19 @@ describe("plugin.openai.ws-pool", () => {
})
})

function streamRequest(headers?: Record<string, string>, signal?: AbortSignal): RequestInit {
function streamRequest(
headers?: Record<string, string>,
signal?: AbortSignal,
body?: Record<string, unknown>,
): RequestInit {
return {
method: "POST",
headers: {
"session-id": "session-1",
authorization: "Bearer test",
...headers,
},
body: JSON.stringify({ stream: true, input: "hi" }),
body: JSON.stringify({ stream: true, input: "hi", ...body }),
signal,
}
}
Expand Down
Loading