mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user