9router/open-sse/executors/codex.js
Rodrigo Rodrigues Costa 40a53fbd33
Fix: Codex image support - convert image_url to input_image format (#236)
Cursor sends images as Chat Completions format:
  { type: "image_url", image_url: { url: "data:...", detail: "auto" } }

But Codex Responses API requires:
  { type: "input_image", image_url: "data:..." }

- openai-responses.js: bidirectional conversion image_url <-> input_image
- responsesApiHelper.js: input_image -> image_url in Responses->Chat path
- codex.js: safety net conversion in executor before sending to Codex API

Note: Cursor has a known bug where images bypass the Override OpenAI Base URL
and are sent directly to api.openai.com. This fix is effective for other clients
(curl, Codex CLI, Claude Code) that route through the proxy correctly.

Made-with: Cursor
2026-03-05 10:31:50 +07:00

108 lines
4 KiB
JavaScript

import { BaseExecutor } from "./base.js";
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js";
import { PROVIDERS } from "../config/constants.js";
import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js";
/**
* Codex Executor - handles OpenAI Codex API (Responses API format)
* Automatically injects default instructions if missing
*/
export class CodexExecutor extends BaseExecutor {
constructor() {
super("codex", PROVIDERS.codex);
}
/**
* Override headers to add session_id per request
*/
buildHeaders(credentials, stream = true) {
const headers = super.buildHeaders(credentials, stream);
headers["session_id"] = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
return headers;
}
/**
* Transform request before sending - inject default instructions if missing
*/
transformRequest(model, body, stream, credentials) {
// Convert string input to array format (Codex API requires input as array)
const normalized = normalizeResponsesInput(body.input);
if (normalized) body.input = normalized;
// Ensure input is present and non-empty (Codex API rejects empty input)
if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) {
body.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }];
}
// Normalize image content: image_url → input_image (Responses API format)
if (Array.isArray(body.input)) {
for (const item of body.input) {
if (Array.isArray(item.content)) {
item.content = item.content.map(c => {
if (c.type === "image_url") {
const url = typeof c.image_url === "string" ? c.image_url : c.image_url?.url;
return { type: "input_image", image_url: url, detail: c.image_url?.detail || "auto" };
}
return c;
});
}
}
}
// Ensure streaming is enabled (Codex API requires it)
body.stream = true;
// If no instructions provided, inject default Codex instructions
if (!body.instructions || body.instructions.trim() === "") {
body.instructions = CODEX_DEFAULT_INSTRUCTIONS;
}
// Ensure store is false (Codex requirement)
body.store = false;
// Extract thinking level from model name suffix
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
const effortLevels = ['none', 'low', 'medium', 'high', 'xhigh'];
let modelEffort = null;
for (const level of effortLevels) {
if (model.endsWith(`-${level}`)) {
modelEffort = level;
// Strip suffix from model name for actual API call
body.model = body.model.replace(`-${level}`, '');
break;
}
}
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
if (!body.reasoning) {
const effort = body.reasoning_effort || modelEffort || 'medium';
body.reasoning = { effort, summary: "auto" };
} else if (!body.reasoning.summary) {
body.reasoning.summary = "auto";
}
delete body.reasoning_effort;
// Include reasoning encrypted content (required by Codex backend for reasoning models)
if (body.reasoning && body.reasoning.effort && body.reasoning.effort !== 'none') {
body.include = ["reasoning.encrypted_content"];
}
// Remove unsupported parameters for Codex API
delete body.temperature;
delete body.top_p;
delete body.frequency_penalty;
delete body.presence_penalty;
delete body.logprobs;
delete body.top_logprobs;
delete body.n;
delete body.seed;
delete body.max_tokens;
delete body.user; // Cursor sends this but Codex doesn't support it
delete body.prompt_cache_retention; // Cursor sends this but Codex doesn't support it
delete body.metadata; // Cursor sends this but Codex doesn't support it
delete body.stream_options; // Cursor sends this but Codex doesn't support it
delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it
return body;
}
}