feat(codex): add GPT-6 Sol and Luna support
- Add GPT-6 Sol and Luna to the Codex model registry. - Send both models using the Codex 0.155 Responses Lite request shape, including `reasoning.context: "all_turns"`.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
105
tests/unit/codex-gpt6-lite.test.js
Normal file
105
tests/unit/codex-gpt6-lite.test.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user