9router/open-sse/translator/request/openai-responses.js
nguyenha935 d06e0d26c6 fix(translator): preserve Responses Lite tools across Chat providers
Codex Responses Lite clients routed to a chat-native OpenAI-compatible
provider lost tool use in three places: non-streaming Chat responses
leaked the raw chat.completion envelope instead of Responses output
items, internal reasoning continuity fields leaked into the outbound
Chat body causing some upstreams to reject the request, and the
Responses to Chat request translator ignored additional_tools,
custom_tool_call, and custom_tool_call_output items entirely.

Also fixes apiType (chat vs responses) for openai-compatible nodes
being resolved from the immutable provider ID instead of the stored
node config, so editing a node's API Type had no runtime effect.
2026-08-05 13:27:25 +07:00

430 lines
17 KiB
JavaScript

/**
* Translator: OpenAI Responses API → OpenAI Chat Completions
*
* Responses API uses: { input: [...], instructions: "..." }
* Chat API uses: { messages: [...] }
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { normalizeResponsesInput } from "../formats/responsesApi.js";
import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM } from "../schema/index.js";
// Responses API enforces max 64 chars on call_id (#393)
const MAX_CALL_ID_LEN = 64;
const clampCallId = (id) => (typeof id === "string" && id.length > MAX_CALL_ID_LEN ? id.substring(0, MAX_CALL_ID_LEN) : id);
/**
* Convert OpenAI Responses API request to OpenAI Chat Completions format
*/
export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) {
if (!body.input) return body;
const result = { ...body };
result.messages = [];
// Convert instructions to system message
if (body.instructions) {
result.messages.push({ role: ROLE.SYSTEM, content: body.instructions });
}
// Group items by conversation turn
let currentAssistantMsg = null;
let pendingToolResults = [];
let pendingReasoning = "";
let pendingReasoningEncrypted = "";
const additionalTools = [];
const customToolNames = new Set();
const inputItems = normalizeResponsesInput(body.input);
if (!inputItems) return body;
// Extract reasoning text from summary[].text (encrypted_content is continuity-only)
const extractReasoningText = (item) => {
if (Array.isArray(item.summary)) {
const txt = item.summary.map(s => s?.text || "").filter(Boolean).join("\n");
if (txt) return txt;
}
if (Array.isArray(item.content)) {
const txt = item.content.map(c => c?.text || "").filter(Boolean).join("\n");
if (txt) return txt;
}
return "";
};
const attachPendingReasoning = (msg) => {
if (pendingReasoning) msg.reasoning_content = pendingReasoning;
if (pendingReasoningEncrypted) msg.encrypted_content = pendingReasoningEncrypted;
pendingReasoning = "";
pendingReasoningEncrypted = "";
};
for (const item of inputItems) {
// Determine item type - Droid CLI sends role-based items without 'type' field
// Fallback: if no type but has role property, treat as message
const itemType = item.type || (item.role ? RESPONSES_ITEM.MESSAGE : null);
if (itemType === RESPONSES_ITEM.MESSAGE) {
// Flush any pending assistant message with tool calls
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
// Flush pending tool results
if (pendingToolResults.length > 0) {
for (const tr of pendingToolResults) {
result.messages.push(tr);
}
pendingToolResults = [];
}
// Convert content: input_text → text, output_text → text, input_image → image_url
const content = Array.isArray(item.content)
? item.content.map(c => {
if (c.type === RESPONSES_ITEM.INPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text };
if (c.type === RESPONSES_ITEM.OUTPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text };
if (c.type === RESPONSES_ITEM.INPUT_IMAGE) {
const url = c.image_url || c.file_id || "";
return { type: OPENAI_BLOCK.IMAGE_URL, image_url: { url, detail: c.detail || "auto" } };
}
return c;
})
: item.content;
const msg = { role: item.role, content };
// Attach buffered reasoning to assistant turn (required by xiaomi-mimo + store=false continuity)
if (item.role === ROLE.ASSISTANT) attachPendingReasoning(msg);
else {
pendingReasoning = "";
pendingReasoningEncrypted = "";
}
result.messages.push(msg);
}
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL || itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL) {
// Start or append to assistant message with tool_calls
if (!currentAssistantMsg) {
currentAssistantMsg = {
role: ROLE.ASSISTANT,
content: null,
tool_calls: []
};
attachPendingReasoning(currentAssistantMsg);
}
// Skip items with empty/missing name — Codex/OpenAI reject nameless tool calls (#444)
if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue;
if (itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL) customToolNames.add(item.name);
const toolInput = itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL
? { input: typeof item.input === "string" ? item.input : JSON.stringify(item.input ?? "") }
: item.arguments;
currentAssistantMsg.tool_calls.push({
id: item.call_id,
type: OPENAI_BLOCK.FUNCTION,
function: {
name: item.name,
arguments: typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput ?? {})
}
});
}
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT || itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL_OUTPUT) {
// Flush assistant message first if exists
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
// Flush any pending tool results first
if (pendingToolResults.length > 0) {
for (const tr of pendingToolResults) {
result.messages.push(tr);
}
pendingToolResults = [];
}
// Add tool result immediately
result.messages.push({
role: ROLE.TOOL,
tool_call_id: item.call_id,
content: typeof item.output === "string" ? item.output : JSON.stringify(item.output)
});
}
else if (itemType === RESPONSES_ITEM.ADDITIONAL_TOOLS) {
if (Array.isArray(item.tools)) additionalTools.push(...item.tools);
}
else if (itemType === RESPONSES_ITEM.REASONING) {
// Buffer reasoning text; attached to next assistant message/function_call.
// Also stash encrypted_content so a later openai→responses hop can restore
// the store=false continuity blob (Grok CLI / Codex multi-turn).
const txt = extractReasoningText(item);
if (txt) pendingReasoning = pendingReasoning ? `${pendingReasoning}\n${txt}` : txt;
if (typeof item.encrypted_content === "string" && item.encrypted_content) {
// Prefer attaching to the next assistant message we create
pendingReasoningEncrypted = item.encrypted_content;
}
continue;
}
}
// Flush remaining
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
}
if (pendingToolResults.length > 0) {
for (const tr of pendingToolResults) {
result.messages.push(tr);
}
}
// Convert tools format.
// Responses API supports "hosted" tools (e.g. { type: "request_user_input" }) that carry no
// explicit `name` field and cannot be represented as Chat Completions function declarations.
// Filter them out to avoid sending nameless functionDeclarations to downstream providers
// such as Gemini, which strictly validates function names.
const responseTools = [
...(Array.isArray(body.tools) ? body.tools : []),
...additionalTools,
];
if (responseTools.length > 0) {
result.tools = responseTools
.map(tool => {
// Already in Chat Completions format: { type: "function", function: { name, ... } }
if (tool.function) return tool;
// Responses API function/custom tool: { type, name, description, parameters|format }.
// Chat Completions has no freeform custom-tool declaration, so expose custom
// tools as functions with one raw `input` string while retaining their names
// in translator-only metadata for the response conversion.
const name = tool.name;
if (!name || typeof name !== "string" || name.trim() === "") return null;
if (tool.type === "custom") {
customToolNames.add(name);
const formatHint = [tool.format?.syntax, tool.format?.definition].filter(Boolean).join("\n");
return {
type: OPENAI_BLOCK.FUNCTION,
function: {
name,
description: [String(tool.description || ""), formatHint].filter(Boolean).join("\n\n"),
parameters: {
type: "object",
properties: {
input: {
type: "string",
description: "Raw freeform input for this custom tool"
}
},
required: ["input"],
additionalProperties: false
}
}
};
}
// Responses API function tool: { type: "function", name, description, parameters }
// Only convert when a non-empty name is present; skip hosted tools without one.
return {
type: OPENAI_BLOCK.FUNCTION,
function: {
name,
description: String(tool.description || ""),
parameters: normalizeToolParameters(tool.parameters),
strict: tool.strict
}
};
})
.filter(Boolean);
}
if (customToolNames.size > 0) result._customToolNames = [...customToolNames];
// Cleanup Responses API specific fields
// Map Responses-only max_output_tokens to Chat max_tokens (avoid leaking unknown field upstream)
if (result.max_output_tokens !== undefined) {
if (result.max_tokens === undefined) result.max_tokens = result.max_output_tokens;
delete result.max_output_tokens;
}
delete result.input;
delete result.instructions;
delete result.include;
delete result.prompt_cache_key;
delete result.store;
if (typeof result.reasoning?.effort === "string") {
result.reasoning_effort = result.reasoning.effort;
}
delete result.reasoning;
delete result.client_metadata;
return result;
}
/**
* Ensure object schema always has properties field (required by Codex Responses API)
*/
function normalizeToolParameters(params) {
if (!params) return { type: "object", properties: {} };
if (params.type === "object" && !params.properties) return { ...params, properties: {} };
return params;
}
/**
* Build a Responses `reasoning` input item from Chat Completions assistant fields.
* Preserves encrypted blobs needed by store=false multi-turn (Grok CLI / Codex).
* Returns null when the message has nothing useful to re-send.
*/
function buildReasoningInputItem(msg) {
if (!msg || typeof msg !== "object") return null;
const encrypted =
(typeof msg.encrypted_content === "string" && msg.encrypted_content) ||
(typeof msg.reasoning_encrypted_content === "string" && msg.reasoning_encrypted_content) ||
(typeof msg.reasoning?.encrypted_content === "string" && msg.reasoning.encrypted_content) ||
"";
let summaryText = "";
if (typeof msg.reasoning_content === "string" && msg.reasoning_content.trim()) {
summaryText = msg.reasoning_content;
} else if (typeof msg.reasoning === "string" && msg.reasoning.trim()) {
summaryText = msg.reasoning;
} else if (Array.isArray(msg.reasoning_details)) {
summaryText = msg.reasoning_details
.map((d) => (typeof d?.text === "string" ? d.text : typeof d?.content === "string" ? d.content : ""))
.filter(Boolean)
.join("\n");
}
if (!encrypted && !summaryText) return null;
const item = { type: RESPONSES_ITEM.REASONING };
if (summaryText) {
item.summary = [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: summaryText }];
}
// encrypted_content is the continuity token for store=false backends
if (encrypted) item.encrypted_content = encrypted;
return item;
}
/**
* Convert OpenAI Chat Completions to OpenAI Responses API format
*/
export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) {
// Body already in Responses API format (e.g. Cursor CLI calling /chat/completions with input[])
if (body.input) return { ...body, model, stream: true };
const result = {
model,
input: [],
stream: true,
store: false
};
// Extract system message as instructions
let hasSystemMessage = false;
const messages = body.messages || [];
for (const msg of messages) {
if (msg.role === ROLE.SYSTEM || msg.role === ROLE.DEVELOPER) {
// Use the first instruction-bearing message as instructions.
// OpenAI recommends role="developer" for GPT-5/Codex as the system-level prompt.
if (!hasSystemMessage) {
result.instructions = typeof msg.content === "string" ? msg.content : "";
hasSystemMessage = true;
}
continue; // Skip instruction messages in input
}
// Convert user/assistant messages to input items
if (msg.role === ROLE.USER || msg.role === ROLE.ASSISTANT) {
// Multi-turn continuity for store=false Responses backends (Codex / Grok CLI):
// re-emit a reasoning item before the assistant message when the chat-format
// history carried reasoning text and/or encrypted_content from a prior turn.
if (msg.role === ROLE.ASSISTANT) {
const reasoningItem = buildReasoningInputItem(msg);
if (reasoningItem) result.input.push(reasoningItem);
}
const contentType = msg.role === ROLE.USER ? RESPONSES_ITEM.INPUT_TEXT : RESPONSES_ITEM.OUTPUT_TEXT;
const content = typeof msg.content === "string"
? [{ type: contentType, text: msg.content }]
: Array.isArray(msg.content)
? msg.content.map(c => {
if (c.type === OPENAI_BLOCK.TEXT) return { type: contentType, text: c.text };
// Convert Chat Completions image_url → Responses API input_image
// Responses API expects: { type: "input_image", image_url: "<url string>" }
// Chat Completions sends: { type: "image_url", image_url: { url: "...", detail: "..." } }
if (c.type === OPENAI_BLOCK.IMAGE_URL) {
const url = typeof c.image_url === "string" ? c.image_url : c.image_url?.url;
return { type: RESPONSES_ITEM.INPUT_IMAGE, image_url: url, detail: c.image_url?.detail || "auto" };
}
if (c.type === RESPONSES_ITEM.INPUT_IMAGE) return c;
// Serialize any unknown type (tool_use, tool_result, thinking, etc.) as text
const text = c.text || c.content || JSON.stringify(c);
return { type: contentType, text: typeof text === "string" ? text : JSON.stringify(text) };
})
: [];
// Only push a message block if content is non-empty.
// Assistant messages with only tool_calls have content: null — skip the
// message block in that case; the tool_calls are pushed separately below.
if (content.length > 0) {
result.input.push({
type: RESPONSES_ITEM.MESSAGE,
role: msg.role,
content
});
}
}
// Convert tool calls
if (msg.role === ROLE.ASSISTANT && msg.tool_calls) {
for (const tc of msg.tool_calls) {
result.input.push({
type: RESPONSES_ITEM.FUNCTION_CALL,
call_id: clampCallId(tc.id),
name: tc.function?.name || "_unknown",
arguments: tc.function?.arguments || "{}"
});
}
}
// Convert tool results - output must be a string for Responses API
if (msg.role === ROLE.TOOL) {
const output = typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.map(c => c.text || JSON.stringify(c)).join("")
: JSON.stringify(msg.content);
result.input.push({
type: RESPONSES_ITEM.FUNCTION_CALL_OUTPUT,
call_id: clampCallId(msg.tool_call_id),
output
});
}
}
// If no system message, leave instructions empty (will be filled by executor)
if (!hasSystemMessage) {
result.instructions = "";
}
// Convert tools format
if (body.tools && Array.isArray(body.tools)) {
result.tools = body.tools.map(tool => {
if (tool.type === OPENAI_BLOCK.FUNCTION) {
return {
type: OPENAI_BLOCK.FUNCTION,
name: tool.function.name,
description: String(tool.function.description || ""),
parameters: normalizeToolParameters(tool.function.parameters),
strict: tool.function.strict
};
}
return tool;
});
}
// Pass through other relevant fields
if (body.temperature !== undefined) result.temperature = body.temperature;
if (body.max_tokens !== undefined) result.max_tokens = body.max_tokens;
if (body.top_p !== undefined) result.top_p = body.top_p;
if (body.reasoning !== undefined) result.reasoning = body.reasoning;
if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" };
if (body.service_tier !== undefined) result.service_tier = body.service_tier;
return result;
}
// Register both directions
register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, openaiResponsesToOpenAIRequest, null);
register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, openaiToOpenAIResponsesRequest, null);