diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index de2af822..8b8c5f79 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -7,7 +7,7 @@ import { } from "../services/oauthCredentialManager.js"; import { normalizeResponsesInput } from "../translator/formats/responsesApi.js"; import { fetchImageAsBase64 } from "../translator/concerns/image.js"; -import { getModelUpstreamId } from "../config/providerModels.js"; +import { getModelUpstreamId, getProviderModels } from "../config/providerModels.js"; import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js"; import { dbg } from "../utils/debugLog.js"; @@ -25,6 +25,10 @@ const CODEX_SSE_USER_OUTPUT_PATTERNS = [ ]; const CODEX_SSE_PEEK_BYTES = 256 * 1024; const CODEX_MODEL_CAPACITY_MESSAGE = "Selected model is at capacity. Please try a different model."; +function isCodexResponsesLiteModel(model) { + const baseId = String(model || "").replace(/\([^()]+\)\s*$/, ""); + return getProviderModels("cx").some((entry) => entry.id === baseId && entry.responsesLite === true); +} // Server-generated item id prefixes that Codex /responses cannot resolve when store=false const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; @@ -43,7 +47,7 @@ const CODEX_PASSTHROUGH_TOOL_TYPES = new Set(["custom"]); const RESPONSES_API_ALLOWLIST = new Set([ "model", "input", "instructions", "tools", "tool_choice", "stream", "store", "reasoning", "service_tier", "include", "prompt_cache_key", "client_metadata", - "text" + "text", "parallel_tool_calls" ]); // Convert role=system → role=developer in body.input (keeps content in cacheable prefix) @@ -57,13 +61,14 @@ function convertSystemToDeveloperRole(body) { } // Strip server-generated item IDs (rs_/fc_/resp_/msg_) from input — avoids 404 with store=false -function stripStoredItemReferences(body) { +function stripStoredItemReferences(body, preserveLitePrefix = false) { if (!Array.isArray(body.input)) return; body.input = body.input.filter((item) => { if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false; if (item && typeof item === "object" && !Array.isArray(item)) { if (item.type === "item_reference") return false; - if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id)) delete item.id; + if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id) + && !(preserveLitePrefix && item.role === "developer" && item.id.startsWith("msg_"))) delete item.id; } return true; }); @@ -138,6 +143,7 @@ function resolveCacheSessionId(body, credentials) { function normalizeReasoningEffort(model, value) { const supportedLevels = getThinkingLevels("codex", model); if (supportedLevels?.includes(value)) return value; + if (isCodexResponsesLiteModel(model) && (value === "none" || value === "minimal")) return "low"; if (value === "ultra" && supportedLevels?.includes("max")) return "max"; if (value === "max" || value === "ultra") return "xhigh"; return value; @@ -209,8 +215,11 @@ export class CodexExecutor extends BaseExecutor { * Override headers to add codex-specific identity headers. * transformRequest runs BEFORE buildHeaders, sets this._currentSessionId. */ - buildHeaders(credentials, stream = true) { + buildHeaders(credentials, stream = true, _url = null, model = null) { const headers = super.buildHeaders(credentials, stream); + if (isCodexResponsesLiteModel(model && getModelUpstreamId("cx", model))) { + headers["x-openai-internal-codex-responses-lite"] = "true"; + } headers["session_id"] = this._currentSessionId || credentials?.connectionId || "default"; // Identify client type to Codex backend (matches official codex CLI) if (!headers["originator"]) headers["originator"] = "codex_cli_rs"; @@ -408,6 +417,8 @@ export class CodexExecutor extends BaseExecutor { // Convert string input to array format (Codex API requires input as array) const normalized = normalizeResponsesInput(body.input); if (normalized) body.input = normalized; + const upstreamModel = getModelUpstreamId("cx", body.model || model); + const responsesLite = isCodexResponsesLiteModel(upstreamModel); // Ensure input is present and non-empty (Codex API rejects empty input) if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) { @@ -417,7 +428,7 @@ export class CodexExecutor extends BaseExecutor { // Keep system prompts in body.input as role=developer so they stay in the cacheable prefix convertSystemToDeveloperRole(body); // Strip server-generated item IDs (rs_/fc_/resp_/msg_) — Codex /responses can't resolve when store=false - stripStoredItemReferences(body); + stripStoredItemReferences(body, responsesLite); // Flatten function tools + drop unsupported types normalizeCodexTools(body); @@ -425,7 +436,7 @@ export class CodexExecutor extends BaseExecutor { body.stream = true; // If no instructions provided, inject default Codex instructions - if (!body.instructions || body.instructions.trim() === "") { + if (!responsesLite && (!body.instructions || body.instructions.trim() === "")) { body.instructions = CODEX_DEFAULT_INSTRUCTIONS; } @@ -438,7 +449,29 @@ export class CodexExecutor extends BaseExecutor { } // Map virtual Codex review models to the upstream Codex model before suffix parsing. - body.model = getModelUpstreamId("cx", body.model || model); + body.model = upstreamModel; + + if (responsesLite) { + // Codex 0.155 carries tools and instructions as input prefix items. + const input = Array.isArray(body.input) ? body.input : [body.input]; + const hasLitePrefix = input.some((item) => item?.type === "additional_tools"); + if (!hasLitePrefix) { + const instructions = typeof body.instructions === "string" && body.instructions.trim() + ? body.instructions : CODEX_DEFAULT_INSTRUCTIONS; + const prefix = [{ type: "additional_tools", role: "developer", tools: Array.isArray(body.tools) ? body.tools : [] }]; + if (instructions) { + prefix.push({ type: "message", role: "developer", content: [{ type: "input_text", text: instructions }] }); + } + input.unshift(...prefix); + } + body.input = input; + body.instructions = ""; + body.tools = null; + body.tool_choice ||= "auto"; + body.parallel_tool_calls = false; + } else { + delete body.parallel_tool_calls; + } // Extract thinking level from model name suffix // e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default) @@ -455,12 +488,13 @@ export class CodexExecutor extends BaseExecutor { // Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium) if (!body.reasoning) { - const effort = normalizeReasoningEffort(body.model, body.reasoning_effort || modelEffort || 'low'); - body.reasoning = { effort, summary: "auto" }; + const effort = normalizeReasoningEffort(body.model, body.reasoning_effort || modelEffort || (responsesLite ? 'medium' : 'low')); + body.reasoning = responsesLite ? { effort } : { effort, summary: "auto" }; } else { body.reasoning.effort = normalizeReasoningEffort(body.model, body.reasoning.effort); - if (!body.reasoning.summary) body.reasoning.summary = "auto"; + if (!responsesLite && !body.reasoning.summary) body.reasoning.summary = "auto"; } + if (responsesLite) body.reasoning.context = "all_turns"; delete body.reasoning_effort; // Include reasoning encrypted content (required by Codex backend for reasoning models) diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index 710cbc27..c7e192e9 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -2,7 +2,8 @@ import { withCodexReviewModels } from "../models/helpers.js"; // Codex CLI version seen by OpenAI's backend — single source for the Version / // User-Agent identity headers. Bump when the installed codex CLI is upgraded. -const CODEX_CLI_VERSION = "0.154.0"; +const CODEX_CLI_VERSION = "0.155.0"; +const GPT_6_LITE_THINKING_LEVELS = ["low", "medium", "high", "xhigh", "max"]; export default { id: "codex", @@ -42,6 +43,7 @@ export default { headers: { originator: "codex_cli_rs", "User-Agent": `codex_cli_rs/${CODEX_CLI_VERSION}`, + version: CODEX_CLI_VERSION, }, usage: { url: "https://chatgpt.com/backend-api/wham/usage", @@ -51,6 +53,8 @@ export default { }, models: [ { id: "gpt-6-astra", name: "GPT 6.0 Astra" }, + { id: "gpt-6-sol", name: "GPT 6.0 Sol", responsesLite: true, thinkingLevels: GPT_6_LITE_THINKING_LEVELS }, + { id: "gpt-6-luna", name: "GPT 6.0 Luna", responsesLite: true, thinkingLevels: GPT_6_LITE_THINKING_LEVELS }, { id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }, { id: "gpt-5.6-sol-review", name: "GPT 5.6 Sol Review", upstreamModelId: "gpt-5.6-sol", quotaFamily: "review" }, { id: "gpt-5.6-terra", name: "GPT 5.6 Terra" }, diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index 89865593..0b020100 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -3,6 +3,7 @@ import { getCapabilitiesForModel } from "./capabilities.js"; import { matchPattern } from "./pricing.js"; import { resolveKiroEffortPath } from "../config/kiroConstants.js"; +import { getProviderModels } from "../config/providerModels.js"; // Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat. const L = { @@ -69,10 +70,14 @@ export function getThinkingLevels(provider, model) { if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null; const caps = getCapabilitiesForModel(provider, model); if (!caps.reasoning) return null; + const baseId = String(model || "").replace(/\([^()]+\)\s*$/, ""); + const modelLevels = provider === "codex" + ? getProviderModels("cx").find((entry) => entry.id === baseId)?.thinkingLevels + : null; const hit = PATTERN_THINKING.find((entry) => (!entry.provider || entry.provider === provider) && matchPattern(entry.pattern, model) ); - let levels = hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base; + let levels = modelLevels || hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base; if (caps.thinkingCanDisable === false) levels = levels.filter((l) => l !== "none"); return levels; } diff --git a/tests/unit/codex-gpt6-lite.test.js b/tests/unit/codex-gpt6-lite.test.js new file mode 100644 index 00000000..035c8329 --- /dev/null +++ b/tests/unit/codex-gpt6-lite.test.js @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CodexExecutor } from "../../open-sse/executors/codex.js"; +import { getModelsByProviderId } from "../../open-sse/config/providerModels.js"; +import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; +import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js"; +import * as proxyFetchModule from "../../open-sse/utils/proxyFetch.js"; + +const credentials = { connectionId: "fixture", accessToken: "fixture-token" }; +afterEach(() => vi.restoreAllMocks()); + +describe("Codex GPT-6 Sol/Luna transport", () => { + it.each(["gpt-6-sol", "gpt-6-luna"])("lists %s with Codex capabilities", (model) => { + const entry = getModelsByProviderId("codex").find((item) => item.id === model); + expect(entry?.responsesLite).toBe(true); + expect(entry?.thinkingLevels).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(getCapabilitiesForModel("codex", model)).toMatchObject({ + vision: true, + reasoning: true, + thinkingFormat: "openai", + }); + expect(getThinkingLevels("codex", model)).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(getThinkingLevels("codex", `${model}(high)`)).toEqual(entry.thinkingLevels); + }); + + it("keeps a native Responses Lite request intact", () => { + const executor = new CodexExecutor(); + const input = [ + { type: "additional_tools", role: "developer", tools: [{ type: "function", name: "run", parameters: { type: "object", properties: {} } }] }, + { type: "message", id: "msg_native", role: "developer", content: [{ type: "input_text", text: "Native instructions" }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + ]; + const body = executor.transformRequest("gpt-6-luna", { + model: "gpt-6-luna", input: structuredClone(input), instructions: "", tools: null, parallel_tool_calls: false, + reasoning: { effort: "high", context: "all_turns" }, + }, true, credentials); + const headers = executor.buildHeaders(credentials, true, null, "gpt-6-luna"); + + expect(headers["x-openai-internal-codex-responses-lite"]).toBe("true"); + expect(body.instructions).toBe(""); + expect(body.tools).toBeNull(); + expect(body.parallel_tool_calls).toBe(false); + expect(body.input).toEqual(input); + expect(body.reasoning).toEqual({ effort: "high", context: "all_turns" }); + }); + + it("converts an ordinary Responses request to the Lite shape", () => { + const executor = new CodexExecutor(); + const tool = { type: "function", name: "run", parameters: { type: "object", properties: {} } }; + const body = executor.transformRequest("gpt-6-sol", { + model: "gpt-6-sol", input: "hello", instructions: "Do the task", tools: [tool], + }, true, credentials); + + expect(body.instructions).toBe(""); + expect(body.tools).toBeNull(); + expect(body.parallel_tool_calls).toBe(false); + expect(body.reasoning).toEqual({ effort: "medium", context: "all_turns" }); + expect(body.input[0]).toEqual({ type: "additional_tools", role: "developer", tools: [tool] }); + expect(body.input[1]).toEqual({ type: "message", role: "developer", content: [{ type: "input_text", text: "Do the task" }] }); + expect(executor.buildHeaders(credentials, true, null, "gpt-6-sol")["x-openai-internal-codex-responses-lite"]).toBe("true"); + }); + + it("clamps unsupported GPT-6 reasoning values to Codex's lowest supported level", () => { + const body = new CodexExecutor().transformRequest("gpt-6-luna", { + model: "gpt-6-luna", input: "hello", reasoning: { effort: "none" }, + }, true, credentials); + + expect(body.reasoning.effort).toBe("low"); + expect(body.reasoning.context).toBe("all_turns"); + }); + + it("sends the Lite shape and header in the actual outbound request", async () => { + const fetchMock = vi.spyOn(proxyFetchModule, "proxyAwareFetch").mockResolvedValue({ + ok: true, status: 200, headers: new Map(), + }); + await new CodexExecutor().execute({ + model: "gpt-6-luna", + body: { model: "gpt-6-luna", input: "hello", instructions: "Do the task" }, + stream: true, + credentials, + }); + + const [url, options] = fetchMock.mock.calls[0]; + const body = JSON.parse(options.body); + expect(url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(options.headers["x-openai-internal-codex-responses-lite"]).toBe("true"); + expect(options.headers.version).toBe("0.155.0"); + expect(body.model).toBe("gpt-6-luna"); + expect(body.instructions).toBe(""); + expect(body.input[0].type).toBe("additional_tools"); + expect(body.reasoning.context).toBe("all_turns"); + }); + + it("keeps the legacy transport for other models", () => { + const executor = new CodexExecutor(); + const body = executor.transformRequest("gpt-5.5", { model: "gpt-5.5", input: "hello" }, true, credentials); + + expect(body.instructions).toBeTruthy(); + expect(body.input[0].type).not.toBe("additional_tools"); + expect(body.reasoning.context).toBeUndefined(); + expect(executor.buildHeaders(credentials, true, null, "gpt-5.5")["x-openai-internal-codex-responses-lite"]).toBeUndefined(); + expect(getThinkingLevels("codex", "gpt-6-astra")).toContain("none"); + expect(executor.buildHeaders(credentials, true, null, "gpt-6-astra")["x-openai-internal-codex-responses-lite"]).toBeUndefined(); + }); +});