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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
- Used by: Plugin loader (per-instance wiring); `/openai-cachekeep` command.

**Request transformation:**
- Purpose: Convert OpenAI Responses calls into Codex-shaped wire requests (UUIDv7 thread/turn ids, Codex turn-metadata header, OAuth/ChatGPT account headers, client_metadata, tool normalization, cache-stabilizer injection, key-reordering via `orderCodexBody` to match Codex wire serialization). Maps the `-pro` moniker (e.g. `gpt-5.6-sol-pro`) to `reasoning.effort: "max"` and strips the header. Resolves and preserves model/variant context for synthetic command replies to prevent model regression.
- Purpose: Convert OpenAI Responses calls into Codex-shaped wire requests (UUIDv7 thread/turn ids, Codex turn-metadata header, OAuth/ChatGPT account headers, client_metadata, tool normalization, cache-stabilizer injection, key-reordering via `orderCodexBody` to match Codex wire serialization), including Codex's Responses Lite request shape for eligible models. Maps the `-pro` moniker (e.g. `gpt-5.6-sol-pro`) to `reasoning.effort: "max"` and strips the header. Resolves and preserves model/variant context for synthetic command replies to prevent model regression.
- Location: `packages/opencode/src/index.ts` (`prepareCodexRequest`, `maybeInjectCacheStabilizerTool`, `normalizeCodexTool`, `getCodexSessionMetadata`, `loadCodexSessions`/`saveCodexSessions`), `packages/opencode/src/hosted-web-search.ts` (provider-hosted web-search tool + replay rewrite + SSE translation), `packages/opencode/src/prompt-context.ts` (`resolvePromptContext`), `packages/opencode/src/response-stream-error.ts`.
- Depends on: `util/uuid-v7.ts`, `util/stable-json.ts`, `util/record.ts`, `config.ts`.
- Used by: Plugin loader `sendWithAccessToken`, `fetch` override.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ Config file: `~/.config/opencode/openai-auth.json` (the directory follows `OPENC
"webSearch": true,
"webSockets": false,
"rawWebSocket": false,
"responsesLite": false,
"dump": false,
"codexApiEndpoint": "https://chatgpt.com/backend-api/codex/responses"
}
Expand All @@ -156,6 +157,7 @@ Config file: `~/.config/opencode/openai-auth.json` (the directory follows `OPENC
| Prompt-cache fix | `webSearch` | `CORTEXKIT_OPENAI_AUTH_NO_WEB_SEARCH` (set to disable) | `true` | Appends a native `web_search` tool to the wire request so Codex keeps tool-continuation requests on the stable prompt cache. See [Why `web_search`](#why-web_search). |
| WebSocket transport | `webSockets` | `CORTEXKIT_OPENAI_AUTH_WEBSOCKETS` | `false` | Use the Codex Responses WebSocket transport instead of plain HTTP. See [Transports](#transports). |
| Hand-rolled WS client | `rawWebSocket` | `CORTEXKIT_OPENAI_AUTH_RAW_WS` | `false` | When WebSockets are enabled, use the hand-rolled raw TCP/TLS client that surfaces Codex-style incremental streaming. Bun uses `Bun.connect`; Node/OpenCode Desktop uses `node:net`/`node:tls`. |
| Responses Lite | `responsesLite` | `CORTEXKIT_OPENAI_AUTH_RESPONSES_LITE` | `false` | Send `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` requests in Codex's Responses Lite shape — the wire format the Codex CLI itself uses for these models. Lite carries tools inline in the input, so it bypasses the `web_search` prompt-cache stabilizer for these models (see [Why `web_search`](#why-web_search)). |
| Request dumps | `dump` | `CORTEXKIT_OPENAI_AUTH_DUMP` | `false` | Write final Codex request bodies and redacted request metadata for cache debugging. Bodies may contain prompt/session content. |
| Dump directory | `dumpDir` | `OPENCODE_OPENAI_AUTH_DUMP_DIR` | OS temp dir: `opencode-openai-auth-dumps` | Destination for `.body.json`, `.meta.json`, and `.request.json` dump files. |
| Codex endpoint | `codexApiEndpoint` | `CORTEXKIT_OPENAI_AUTH_CODEX_ENDPOINT` | `https://chatgpt.com/backend-api/codex/responses` | Send rewritten Codex requests to a compatible proxy/relay instead of ChatGPT's backend endpoint. |
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Settings resolve as environment variable → config file (`~/.config/opencode/op
| `webSearch` | `CORTEXKIT_OPENAI_AUTH_NO_WEB_SEARCH` (set to disable) | `true` | Inject the `web_search` prompt-cache stabilizer. |
| `webSockets` | `CORTEXKIT_OPENAI_AUTH_WEBSOCKETS` | `false` | Use the Codex Responses WebSocket transport instead of HTTP. |
| `rawWebSocket` | `CORTEXKIT_OPENAI_AUTH_RAW_WS` | `false` | Use the hand-rolled raw TCP/TLS client with Codex-style incremental streaming. Bun uses `Bun.connect`; Node/OpenCode Desktop uses `node:net`/`node:tls`. |
| `responsesLite` | `CORTEXKIT_OPENAI_AUTH_RESPONSES_LITE` | `false` | Send `gpt-5.6-sol`/`-terra`/`-luna` requests in Codex's Responses Lite shape, matching the Codex CLI. Bypasses the `web_search` stabilizer for these models. |
| `dump` | `CORTEXKIT_OPENAI_AUTH_DUMP` | `false` | Dump final Codex request bodies for cache debugging. |
| `dumpDir` | `OPENCODE_OPENAI_AUTH_DUMP_DIR` | OS temp dir: `opencode-openai-auth-dumps` | Directory for request dump files. |
| `codexApiEndpoint` | `CORTEXKIT_OPENAI_AUTH_CODEX_ENDPOINT` | `https://chatgpt.com/backend-api/codex/responses` | Send rewritten Codex requests to a compatible proxy/relay instead of ChatGPT's backend endpoint. |
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface OpenAIAuthConfig {
webSockets?: boolean
/** Use the hand-rolled raw TCP/TLS WebSocket client (incremental streaming). Default false. */
rawWebSocket?: boolean
/** Use Codex's Responses Lite request shape. Default false. */
responsesLite?: boolean
/** Dump final Codex request bodies for cache debugging. Default false. */
dump?: boolean | { enabled?: boolean }
/** Directory for request dumps. Defaults to the OS temp directory. */
Expand All @@ -37,6 +39,7 @@ export interface ResolvedSettings {
webSearch: boolean
webSockets: boolean
rawWebSocket: boolean
responsesLite: boolean
dump: boolean
dumpDir: string
codexApiEndpoint: string
Expand All @@ -49,6 +52,7 @@ const ENV = {
noWebSearch: 'CORTEXKIT_OPENAI_AUTH_NO_WEB_SEARCH',
webSockets: 'CORTEXKIT_OPENAI_AUTH_WEBSOCKETS',
rawWebSocket: 'CORTEXKIT_OPENAI_AUTH_RAW_WS',
responsesLite: 'CORTEXKIT_OPENAI_AUTH_RESPONSES_LITE',
dump: 'CORTEXKIT_OPENAI_AUTH_DUMP',
dumpDir: 'OPENCODE_OPENAI_AUTH_DUMP_DIR',
codexApiEndpoint: 'CORTEXKIT_OPENAI_AUTH_CODEX_ENDPOINT',
Expand Down Expand Up @@ -138,6 +142,7 @@ function resolve(): ResolvedSettings {
webSearch,
webSockets: resolveBool(ENV.webSockets, config.webSockets, false),
rawWebSocket: resolveBool(ENV.rawWebSocket, config.rawWebSocket, false),
responsesLite: resolveBool(ENV.responsesLite, config.responsesLite, false),
dump: resolveBool(ENV.dump, dumpConfig, false),
dumpDir:
process.env[ENV.dumpDir]?.trim() ||
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/core/cachekeep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export function buildKeepwarmCapture(input: {
'x-codex-window-id',
'x-client-request-id',
'thread-id',
'x-openai-internal-codex-responses-lite',
]) {
const value = input.headers.get(name)
if (value) replayHeaders[name] = value
Expand Down
94 changes: 92 additions & 2 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ const ALLOWED_MODELS = new Set([
// api.id ("gpt-5.6"), so filtering on api.id drops them all at once while
// keeping the working variants (api.id gpt-5.6-luna, etc.).
const DISALLOWED_MODELS = new Set(['gpt-5.6'])
// Wire models Codex's catalog marks use_responses_lite. The set is exact on
// purpose: upstream resolves models against its catalog and defaults unknown
// names to the normal Responses shape, so no prefix matching here.
const RESPONSES_LITE_MODELS = new Set([
'gpt-5.6-sol',
'gpt-5.6-terra',
'gpt-5.6-luna',
])
const OAUTH_DUMMY_KEY = 'opencode-oauth-dummy-key'
const CODEX_BETA_FEATURES = 'terminal_resize_reflow'
// gpt-5.6 requires Codex client >= 0.144.0 (older versions 400 with "requires a
Expand Down Expand Up @@ -173,6 +181,7 @@ interface CodexAuthPluginOptions {
issuer?: string
codexApiEndpoint?: string
experimentalWebSockets?: boolean
responsesLite?: boolean
}

interface CodexSessionMetadata {
Expand Down Expand Up @@ -329,6 +338,7 @@ function prepareCodexRequest(input: {
metadata: CodexSessionMetadata | undefined
installationID: string
websocket: boolean
responsesLite: boolean
dumpSessionID?: string
}): PreparedCodexRequest {
// Capture + strip the internal effort signal up front so it never leaks to the
Expand Down Expand Up @@ -380,6 +390,13 @@ function prepareCodexRequest(input: {

const parsed = body
if (!parsed) return { init: input.init }
const useResponsesLite =
input.responsesLite &&
typeof parsed.model === 'string' &&
RESPONSES_LITE_MODELS.has(parsed.model)
if (useResponsesLite && !input.websocket) {
input.headers.set('x-openai-internal-codex-responses-lite', 'true')
}
parsed.prompt_cache_key = input.metadata.threadID
parsed.parallel_tool_calls ??= true
// OpenCode's `-pro` moniker means max reasoning effort, but the pinned
Expand All @@ -403,6 +420,7 @@ function prepareCodexRequest(input: {
removeExaWebSearchFunctionTool(parsed)
rewriteHostedWebSearchReplay(parsed)
maybeInjectCacheStabilizerTool(parsed)
if (useResponsesLite) rewriteResponsesLiteBody(parsed)
const clientMetadata: Record<string, unknown> = {
...(typeof parsed.client_metadata === 'object' &&
parsed.client_metadata !== null
Expand All @@ -414,6 +432,12 @@ function prepareCodexRequest(input: {
if (input.websocket) {
clientMetadata['x-codex-turn-metadata'] = turnMetadata
clientMetadata['x-codex-ws-stream-request-start-ms'] = String(Date.now())
if (useResponsesLite) {
// The WS transport marks Lite per request via client_metadata; the
// upgrade itself carries no Lite header.
clientMetadata.ws_request_header_x_openai_internal_codex_responses_lite =
'true'
}
}
parsed.client_metadata = clientMetadata
input.headers.delete('content-length')
Expand Down Expand Up @@ -461,6 +485,56 @@ function maybeInjectCacheStabilizerTool(parsed: Record<string, unknown>) {
]
}

// Codex strips `detail` from every input_image in a Lite request, including
// images nested in function/custom-tool outputs — hence the recursion.
function stripResponsesLiteImageDetails(value: unknown) {
if (Array.isArray(value)) {
for (const item of value) stripResponsesLiteImageDetails(item)
return
}
if (!isRecord(value)) return
if (value.type === 'input_image') delete value.detail
for (const nested of Object.values(value))
stripResponsesLiteImageDetails(nested)
}

// Codex's Responses Lite request shape (client_common.rs): reasoning over all
// turns, no parallel tool calls, and tools/instructions moved into the input —
// an always-present `additional_tools` developer item (client-executed tools
// only; hosted tools like web_search are excluded, so the cache stabilizer is
// dropped here) followed by a developer message for non-empty instructions
// (Codex omits empty instructions from the wire).
function rewriteResponsesLiteBody(parsed: Record<string, unknown>) {
const reasoning = isRecord(parsed.reasoning) ? { ...parsed.reasoning } : {}
reasoning.context = 'all_turns'
parsed.reasoning = reasoning
parsed.parallel_tool_calls = false

const input = Array.isArray(parsed.input) ? parsed.input : []
stripResponsesLiteImageDetails(input)
const tools = Array.isArray(parsed.tools)
? parsed.tools.filter(
(tool) => !(isRecord(tool) && tool.type === 'web_search'),
)
: []
const prefix: unknown[] = [
{ type: 'additional_tools', role: 'developer', tools },
]
if (
typeof parsed.instructions === 'string' &&
parsed.instructions.length > 0
) {
prefix.push({
type: 'message',
role: 'developer',
content: [{ type: 'input_text', text: parsed.instructions }],
})
}
parsed.input = [...prefix, ...input]
delete parsed.tools
delete parsed.instructions
}

function removeHostedWebSearchFunctionTool(parsed: Record<string, unknown>) {
if (!Array.isArray(parsed.tools)) return
parsed.tools = parsed.tools.filter(
Expand Down Expand Up @@ -1368,6 +1442,7 @@ export async function CodexAuthPlugin(
websocket: Boolean(
websocketFetch && parsed.pathname.endsWith('/responses'),
),
responsesLite: options.responsesLite ?? false,
dumpSessionID: sessionID,
})
const requestInit = prepared.init
Expand Down Expand Up @@ -1396,12 +1471,26 @@ export async function CodexAuthPlugin(
pathname: parsed.pathname,
})
if (keepwarmCapture) {
// Keepwarm replays over HTTP; convert the WS-shaped body like fallback does.
const replayBody = OpenAIWebSocketPool.sanitizeHttpFallbackBody(
keepwarmCapture.bodyText,
)
const replayHeaders = { ...keepwarmCapture.replayHeaders }
if (
OpenAIWebSocketPool.hasWebSocketResponsesLiteMetadata(
keepwarmCapture.bodyText,
)
) {
replayHeaders['x-openai-internal-codex-responses-lite'] = 'true'
}
cacheKeepManager.track(
keepwarmCapture.sessionKey,
keepwarmCapture.bodyText,
typeof replayBody === 'string'
? replayBody
: keepwarmCapture.bodyText,
keepwarmAccountKey,
accountId,
keepwarmCapture.replayHeaders,
replayHeaders,
keepwarmCapture.isSubagent,
)
}
Expand Down Expand Up @@ -2257,6 +2346,7 @@ export const OpenAIAuthPlugin: Plugin = async (input) => {
return CodexAuthPlugin(input, {
codexApiEndpoint: settings.codexApiEndpoint,
experimentalWebSockets: settings.webSockets,
responsesLite: settings.responsesLite,
})
}

Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/tests/cachekeep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ describe('buildKeepwarmCapture', () => {
'x-codex-beta-features': 'terminal_resize_reflow',
'x-codex-turn-metadata': '{"turn_id":"turn-1"}',
'x-codex-window-id': 'window-1',
'x-openai-internal-codex-responses-lite': 'true',
}),
body,
})
Expand All @@ -745,6 +746,7 @@ describe('buildKeepwarmCapture', () => {
'x-codex-beta-features': 'terminal_resize_reflow',
'x-codex-turn-metadata': '{"turn_id":"turn-1"}',
'x-codex-window-id': 'window-1',
'x-openai-internal-codex-responses-lite': 'true',
},
isSubagent: false,
})
Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/src/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('config resolution', () => {
'OPENCODE_OPENAI_AUTH_FILE',
'CORTEXKIT_OPENAI_AUTH_DUMP',
'CORTEXKIT_OPENAI_AUTH_NO_WEB_SEARCH',
'CORTEXKIT_OPENAI_AUTH_RESPONSES_LITE',
]

beforeEach(async () => {
Expand Down Expand Up @@ -103,6 +104,31 @@ describe('config resolution', () => {
})
})

describe('Responses Lite setting resolution', () => {
test('defaults to false', () => {
resetSettingsForTest()
expect(getSettings().responsesLite).toBe(false)
})

test('reads the config value', async () => {
await writeFile(configPath, JSON.stringify({ responsesLite: true }))
resetSettingsForTest()
expect(getSettings().responsesLite).toBe(true)
})

test('env overrides config', async () => {
await writeFile(configPath, JSON.stringify({ responsesLite: true }))
process.env.CORTEXKIT_OPENAI_AUTH_RESPONSES_LITE = 'false'
resetSettingsForTest()
expect(getSettings().responsesLite).toBe(false)

await writeFile(configPath, JSON.stringify({ responsesLite: false }))
process.env.CORTEXKIT_OPENAI_AUTH_RESPONSES_LITE = 'true'
resetSettingsForTest()
expect(getSettings().responsesLite).toBe(true)
})
})

describe('envBool parser', () => {
test('accepts documented truthy values (case-insensitive, trimmed)', () => {
const truthyValues = ['1', 'true', 'yes', 'on', ' TRUE ', 'Yes', 'ON']
Expand Down
Loading