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.
430 lines
17 KiB
JavaScript
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);
|