fix(opencode): support Luna with ChatGPT OAuth

This commit is contained in:
Aiden Cline
2026-07-12 23:37:28 -05:00
parent cf7503687a
commit 86ebd606df
4 changed files with 300 additions and 10 deletions
+99 -1
View File
@@ -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
@@ -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>> = []
@@ -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<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)
}
+14 -1
View File
@@ -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<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)
+156 -6
View File
@@ -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<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,
@@ -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) => {
@@ -773,7 +798,11 @@ 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: {
@@ -781,7 +810,7 @@ function streamRequest(headers?: Record<string, string>, signal?: AbortSignal):
authorization: "Bearer test",
...headers,
},
body: JSON.stringify({ stream: true, input: "hi" }),
body: JSON.stringify({ stream: true, input: "hi", ...body }),
signal,
}
}