diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 4347d7d651..5e08855074 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -162,6 +162,110 @@ type EffortLevel = (typeof EFFORT_ORDER)[number]; const CODEX_FAST_WIRE_VALUE = "priority"; let defaultFastServiceTierEnabled = false; +function stringifyCodexInstructionContent(content: unknown): string { + if (typeof content === "string") { + return content.trim(); + } + + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part.trim(); + if (!part || typeof part !== "object") return ""; + const record = part as Record; + if (typeof record.text === "string") return record.text.trim(); + if (typeof record.content === "string") return record.content.trim(); + return ""; + }) + .filter(Boolean) + .join("\n") + .trim(); + } + + return ""; +} + +function hoistSystemMessagesToInstructions(body: Record): void { + if (!Array.isArray(body.input)) return; + + const systemChunks: string[] = []; + const filteredInput = body.input.filter((itemValue) => { + if (!itemValue || typeof itemValue !== "object" || Array.isArray(itemValue)) { + return true; + } + + const item = itemValue as Record; + const role = typeof item.role === "string" ? item.role : ""; + const type = typeof item.type === "string" ? item.type : ""; + const isSystemMessage = role === "system" && (!type || type === "message"); + if (!isSystemMessage) { + return true; + } + + const text = stringifyCodexInstructionContent(item.content); + if (text) { + systemChunks.push(text); + } + return false; + }); + + if (systemChunks.length === 0) return; + + const existingInstructions = + typeof body.instructions === "string" ? body.instructions.trim() : ""; + body.instructions = existingInstructions + ? `${systemChunks.join("\n\n")}\n\n${existingInstructions}` + : systemChunks.join("\n\n"); + body.input = filteredInput; +} + +function normalizeCodexTools(body: Record): void { + if (!Array.isArray(body.tools)) return; + + const validToolNames = new Set(); + body.tools = body.tools.filter((toolValue) => { + if (!toolValue || typeof toolValue !== "object" || Array.isArray(toolValue)) { + return false; + } + + const tool = toolValue as Record; + if (tool.type !== "function") { + return false; + } + + const rawName = + typeof tool.name === "string" + ? tool.name + : tool.function && + typeof tool.function === "object" && + !Array.isArray(tool.function) && + typeof (tool.function as Record).name === "string" + ? ((tool.function as Record).name as string) + : ""; + const name = rawName.trim(); + if (!name) { + return false; + } + + validToolNames.add(name); + return true; + }); + + if ( + body.tool_choice && + typeof body.tool_choice === "object" && + !Array.isArray(body.tool_choice) + ) { + const toolChoice = body.tool_choice as Record; + if (toolChoice.type === "function") { + const rawName = typeof toolChoice.name === "string" ? toolChoice.name.trim() : ""; + if (!rawName || !validToolNames.has(rawName)) { + delete body.tool_choice; + } + } + } +} + function getResponsesSubpath(endpointPath: unknown): string | null { const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, ""); const match = normalizedEndpoint.match(/(?:^|\/)responses(?:(\/.*))?$/i); @@ -317,6 +421,15 @@ export class CodexExecutor extends BaseExecutor { // Ensure store is false (Codex requirement) body.store = false; + // Cursor can send native Responses payloads with role=system items inside `input`. + // Codex rejects system messages there; they must be folded into `instructions`. + hoistSystemMessagesToInstructions(body); + + // Codex Responses only supports function tools with non-empty names. + // Cursor may include custom tools (e.g. ApplyPatch) that work locally but are + // invalid upstream, and translation bugs can leave orphaned/empty tool_choice names. + normalizeCodexTools(body); + // Issue #806: Even for native passthrough, some clients (purist completions) might indiscriminately inject // a `messages` or `prompt` array which the strict Codex Responses schema rejects. delete body.messages; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index fc78182c61..21288bff8a 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -611,6 +611,12 @@ export async function handleChatCore({ sourceFormat, endpointPath, }); + const isDroidCLI = + userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); + const clientResponseFormat = + sourceFormat === FORMATS.OPENAI_RESPONSES && !isResponsesEndpoint && !isDroidCLI + ? FORMATS.OPENAI + : sourceFormat; // Check for bypass patterns (warmup, skip) - return fake response const bypassResponse = handleBypassRequest(body, model, userAgent); @@ -2164,11 +2170,11 @@ export async function handleChatCore({ // Translate response to client's expected format (usually OpenAI) // Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605) - let translatedResponse = needsTranslation(targetFormat, sourceFormat) + let translatedResponse = needsTranslation(targetFormat, clientResponseFormat) ? translateNonStreamingResponse( responseBody, targetFormat, - sourceFormat, + clientResponseFormat, toolNameMap as Map | null ) : responseBody; @@ -2200,22 +2206,25 @@ export async function handleChatCore({ // Strips non-standard fields (x_groq, usage_breakdown, service_tier, etc.) // Extracts and tags into reasoning_content // Source format determines output shape. If we are outputting OpenAI shape or pseudo-OpenAI shape, sanitize. - if (sourceFormat === FORMATS.OPENAI || sourceFormat === FORMATS.OPENAI_RESPONSES) { + if ( + clientResponseFormat === FORMATS.OPENAI || + clientResponseFormat === FORMATS.OPENAI_RESPONSES + ) { translatedResponse = sanitizeOpenAIResponse(translatedResponse); } // Add buffer and filter usage for client (to prevent CLI context errors) if (translatedResponse?.usage) { const buffered = addBufferToUsage(translatedResponse.usage); - translatedResponse.usage = filterUsageForFormat(buffered, sourceFormat); + translatedResponse.usage = filterUsageForFormat(buffered, clientResponseFormat); } else { // Fallback: estimate usage when provider returned no usage block const contentLength = JSON.stringify( translatedResponse?.choices?.[0]?.message?.content || "" ).length; if (contentLength > 0) { - const estimated = estimateUsage(body, contentLength, sourceFormat); - translatedResponse.usage = filterUsageForFormat(estimated, sourceFormat); + const estimated = estimateUsage(body, contentLength, clientResponseFormat); + translatedResponse.usage = filterUsageForFormat(estimated, clientResponseFormat); } } @@ -2377,11 +2386,9 @@ export async function handleChatCore({ // For providers using Responses API format, translate stream back to openai (Chat Completions) format // UNLESS client is Droid CLI which expects openai-responses format back - const isDroidCLI = - userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); const needsResponsesTranslation = targetFormat === FORMATS.OPENAI_RESPONSES && - sourceFormat === FORMATS.OPENAI && + clientResponseFormat === FORMATS.OPENAI && !isResponsesEndpoint && !isDroidCLI; @@ -2400,12 +2407,12 @@ export async function handleChatCore({ onStreamComplete, apiKeyInfo ); - } else if (needsTranslation(targetFormat, sourceFormat)) { + } else if (needsTranslation(targetFormat, clientResponseFormat)) { // Standard translation for other providers - log?.debug?.("STREAM", `Translation mode: ${targetFormat} → ${sourceFormat}`); + log?.debug?.("STREAM", `Translation mode: ${targetFormat} → ${clientResponseFormat}`); transformStream = createSSETransformStreamWithLogger( targetFormat, - sourceFormat, + clientResponseFormat, provider, reqLogger, toolNameMap, diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index 584ea713f3..1317a28a53 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -50,6 +50,17 @@ function buildAnthropicCompatibleUrl(baseUrl) { // contain max_tokens or Claude model names. export function detectFormatFromEndpoint(body, endpointPath = "") { const path = String(endpointPath || ""); + const hasInputField = + body && + typeof body === "object" && + Object.prototype.hasOwnProperty.call(body, "input") && + body.input !== undefined; + const hasResponsesSpecificFields = + body && + typeof body === "object" && + (body.max_output_tokens !== undefined || + body.previous_response_id !== undefined || + body.reasoning !== undefined); if (/\/responses(?=\/|$)/i.test(path) || /^responses(?=\/|$)/i.test(path)) { return "openai-responses"; @@ -63,6 +74,9 @@ export function detectFormatFromEndpoint(body, endpointPath = "") { /\/(?:chat\/completions|completions)(?=\/|$)/i.test(path) || /^(?:chat\/completions|completions)(?=\/|$)/i.test(path) ) { + if (hasInputField || hasResponsesSpecificFields) { + return "openai-responses"; + } return "openai"; }