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:
mxlanparty@dp14
2026-09-23 12:10:03 +07:00
parent 69cc1aa987
commit 95600db17b
4 changed files with 161 additions and 13 deletions

View File

@@ -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)

View File

@@ -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" },

View File

@@ -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;
}

View 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();
});
});