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
6 changes: 6 additions & 0 deletions .changeset/gpt-56-responses-lite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---

Support GPT-5.6 Sol, Terra, and Luna through OpenAI Responses Lite with ChatGPT OAuth.
109 changes: 106 additions & 3 deletions packages/opencode/src/plugin/openai/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
import { refreshCodexAuth } from "@/kilocode/provider/codex-refresh" // kilocode_change
import { OpenAIWebSocketPool } from "./ws-pool"
import { isRecord } from "@/util/record" // kilocode_change

const log = Log.create({ service: "plugin.codex" })

Expand All @@ -15,6 +16,10 @@ 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
// kilocode_change start
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"])
// kilocode_change end
const ALLOWED_MODELS = new Set([
"gpt-5.5",
"gpt-5.2",
Expand All @@ -30,9 +35,7 @@ const ALLOWED_MODELS = new Set([
// kilocode_change end
])
// kilocode_change start
const DISALLOWED_MODELS = new Set([
"gpt-5.5-pro",
])
const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"])
// kilocode_change end

interface PkceCodes {
Expand Down Expand Up @@ -382,6 +385,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>() // kilocode_change
let websocketFetchInstalled = false
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []

Expand All @@ -390,6 +394,21 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
for (const websocketFetch of websocketFetches) websocketFetch.close()
websocketFetches.length = 0
},
// kilocode_change start
async event(input) {
if (input.event.type !== "session.deleted") return
const sessionID = input.event.properties.info.id
const sources = [sessionID, `title-${sessionID}`, `branch-name:${sessionID}`]
for (const source of sources) {
const target = codexSessionIDs.get(source)
for (const websocketFetch of websocketFetches) {
websocketFetch.remove(source)
if (target) websocketFetch.remove(target)
}
codexSessionIDs.delete(source)
}
},
// kilocode_change end
provider: {
id: "openai",
async models(provider, ctx) {
Expand Down Expand Up @@ -519,8 +538,22 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
? new URL(codexApiEndpoint)
: parsed

// kilocode_change start
const liteRequest = parsed.pathname.endsWith("/responses")
? parseResponsesLiteRequest(init?.body)
: undefined
// kilocode_change end
const requestInit = {
...init,
// kilocode_change start
...(liteRequest && {
body: prepareResponsesLiteRequest({
request: liteRequest,
headers,
sessionIDs: codexSessionIDs,
}),
}),
// kilocode_change end
headers,
}
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
Expand Down Expand Up @@ -665,3 +698,73 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
},
}
}

// kilocode_change start
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)
Comment thread
mcartmel marked this conversation as resolved.

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
if (input.request.tool_choice === undefined) 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)
}
// kilocode_change end
55 changes: 51 additions & 4 deletions packages/opencode/src/plugin/openai/ws-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { isRecord } from "@/util/record"
import { OpenAIWebSocket } from "./ws"

export const TITLE_HEADER = "x-kilo-title"
// kilocode_change start
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"
// kilocode_change end

const log = Log.create({ service: "plugin.openai.ws" })

Expand All @@ -24,6 +28,7 @@ interface PoolEntry {
busy: boolean
fallback: boolean
streamFailures: number
removed: boolean // kilocode_change
}

const DEFAULT_CONNECT_TIMEOUT = 15_000
Expand Down Expand Up @@ -74,7 +79,15 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
}
const key = `${sessionID}:conversation`

const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false, streamFailures: 0 }
// kilocode_change start
const entry = pool.get(key) ?? {
lastUsedAt: Date.now(),
busy: false,
fallback: false,
streamFailures: 0,
removed: false,
}
// kilocode_change end
pool.set(key, entry)

if (entry.fallback) {
Expand Down Expand Up @@ -105,7 +118,18 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
})
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket: entry.socket,
body,
// kilocode_change start
body:
internalHeaders[RESPONSES_LITE_HEADER] === "true"
? {
...body,
client_metadata: {
...(isRecord(body.client_metadata) ? body.client_metadata : {}),
[RESPONSES_LITE_CLIENT_METADATA]: "true",
},
}
: body,
// kilocode_change end
idleTimeout,
signal: init?.signal ?? undefined,
onFirstEvent: () => resolveFirstEvent(true),
Expand Down Expand Up @@ -190,11 +214,27 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
function close() {
log.debug("websocket pool close", { count: pool.size })
clearInterval(pruneTimer)
for (const entry of pool.values()) invalidate(entry)
// kilocode_change start
for (const entry of pool.values()) {
entry.removed = true
invalidate(entry)
}
// kilocode_change end
pool.clear()
}

return Object.assign(websocketFetch, { close })
// kilocode_change start
function remove(sessionID: string) {
const key = `${sessionID}:conversation`
const entry = pool.get(key)
if (!entry) return
entry.removed = true
invalidate(entry)
Comment thread
mcartmel marked this conversation as resolved.
pool.delete(key)
}
// kilocode_change end

return Object.assign(websocketFetch, { close, remove }) // kilocode_change
}

function connectionLimitError(event: Record<string, unknown>) {
Expand Down Expand Up @@ -247,6 +287,13 @@ async function socket(
timeout: connectTimeout,
signal: signal ?? undefined,
})
// kilocode_change start
if (entry.removed) {
next.on("error", () => {})
next.terminate()
throw new DOMException("WebSocket pool entry removed during connection", "AbortError")
}
// kilocode_change end
entry.connectedAt = Date.now()
return next
}
Expand Down
Loading
Loading