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
90 changes: 89 additions & 1 deletion packages/opencode/src/plugin/openai/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down Expand Up @@ -263,6 +266,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 +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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -560,3 +580,71 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
},
}
}

function parseResponsesLiteRequest(body: BodyInit | null | undefined): Record<string, unknown> | 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<string, unknown>
headers: Headers
sessionIDs: Map<string, string>
}) {
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)
}
13 changes: 12 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"
export 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,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),
Expand Down
194 changes: 194 additions & 0 deletions packages/opencode/test/plugin/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<string, unknown> }> = []
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<string, unknown> }> = []
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,
Expand Down Expand Up @@ -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
}
Loading
Loading