fix(codex): improve Cursor responses compatibility (#1002)

Fixes Cursor/Codex Responses compatibility: hoists system messages to instructions, normalizes tools, and translates responses-style traffic. Integrated into release/v3.5.4.
This commit is contained in:
mercs2910
2026-04-07 23:28:08 +03:00
committed by GitHub
parent dbcc1f4535
commit 9d3986e06e
3 changed files with 146 additions and 12 deletions

View File

@@ -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<string, unknown>;
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<string, unknown>): 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<string, unknown>;
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<string, unknown>): void {
if (!Array.isArray(body.tools)) return;
const validToolNames = new Set<string>();
body.tools = body.tools.filter((toolValue) => {
if (!toolValue || typeof toolValue !== "object" || Array.isArray(toolValue)) {
return false;
}
const tool = toolValue as Record<string, unknown>;
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<string, unknown>).name === "string"
? ((tool.function as Record<string, unknown>).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<string, unknown>;
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;

View File

@@ -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<string, string> | null
)
: responseBody;
@@ -2200,22 +2206,25 @@ export async function handleChatCore({
// Strips non-standard fields (x_groq, usage_breakdown, service_tier, etc.)
// Extracts <think> and <thinking> 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,

View File

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