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.
This commit is contained in:
@@ -258,8 +258,10 @@ export function initState(sourceFormat) {
|
||||
funcArgsBuf: {},
|
||||
funcNames: {},
|
||||
funcCallIds: {},
|
||||
funcItemAdded: {},
|
||||
funcArgsDone: {},
|
||||
funcItemDone: {},
|
||||
customToolNames: new Set(),
|
||||
completedSent: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
|
||||
let pendingToolResults = [];
|
||||
let pendingReasoning = "";
|
||||
let pendingReasoningEncrypted = "";
|
||||
const additionalTools = [];
|
||||
const customToolNames = new Set();
|
||||
|
||||
const inputItems = normalizeResponsesInput(body.input);
|
||||
if (!inputItems) return body;
|
||||
@@ -96,7 +98,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
|
||||
}
|
||||
result.messages.push(msg);
|
||||
}
|
||||
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) {
|
||||
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 = {
|
||||
@@ -108,16 +110,20 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
|
||||
}
|
||||
// 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: item.arguments
|
||||
arguments: typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput ?? {})
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) {
|
||||
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);
|
||||
@@ -137,6 +143,9 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
|
||||
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
|
||||
@@ -166,15 +175,45 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
|
||||
// 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.
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
result.tools = body.tools
|
||||
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 tool: { type: "function", name, description, parameters }
|
||||
// Only convert when a non-empty name is present; skip hosted tools without one.
|
||||
// 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: {
|
||||
@@ -187,6 +226,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
|
||||
})
|
||||
.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)
|
||||
|
||||
@@ -258,24 +258,43 @@ function closeMessage(state, emit, idx) {
|
||||
}
|
||||
}
|
||||
|
||||
function isCustomTool(state, name) {
|
||||
return !!name && state.customToolNames?.has(name);
|
||||
}
|
||||
|
||||
function extractCustomToolInput(argumentsText) {
|
||||
if (typeof argumentsText !== "string") return "";
|
||||
try {
|
||||
const parsed = JSON.parse(argumentsText);
|
||||
if (parsed && typeof parsed === "object" && typeof parsed.input === "string") return parsed.input;
|
||||
} catch { /* incomplete or raw freeform input */ }
|
||||
return argumentsText;
|
||||
}
|
||||
|
||||
function emitToolCall(state, emit, tc) {
|
||||
const tcIdx = tc.index ?? 0;
|
||||
const newCallId = tc.id;
|
||||
const funcName = tc.function?.name;
|
||||
|
||||
if (funcName) state.funcNames[tcIdx] = funcName;
|
||||
if (newCallId) state.funcCallIds[tcIdx] = newCallId;
|
||||
|
||||
// Some compatible providers split the call id and function name across
|
||||
// chunks. Wait for both before deciding whether this is a custom tool;
|
||||
// otherwise an `exec` call can be irreversibly announced as function_call.
|
||||
const callId = state.funcCallIds[tcIdx];
|
||||
if (!state.funcItemAdded[tcIdx] && callId && state.funcNames[tcIdx]) {
|
||||
state.funcItemAdded[tcIdx] = true;
|
||||
const custom = isCustomTool(state, state.funcNames[tcIdx]);
|
||||
|
||||
if (!state.funcCallIds[tcIdx] && newCallId) {
|
||||
state.funcCallIds[tcIdx] = newCallId;
|
||||
|
||||
emit("response.output_item.added", {
|
||||
type: "response.output_item.added",
|
||||
output_index: tcIdx,
|
||||
item: {
|
||||
id: `fc_${newCallId}`,
|
||||
type: RESPONSES_ITEM.FUNCTION_CALL,
|
||||
arguments: "",
|
||||
call_id: newCallId,
|
||||
id: `${custom ? "ctc" : "fc"}_${callId}`,
|
||||
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
|
||||
...(custom ? { input: "" } : { arguments: "" }),
|
||||
call_id: callId,
|
||||
name: state.funcNames[tcIdx] || ""
|
||||
}
|
||||
});
|
||||
@@ -285,7 +304,7 @@ function emitToolCall(state, emit, tc) {
|
||||
|
||||
if (tc.function?.arguments) {
|
||||
const refCallId = state.funcCallIds[tcIdx] || newCallId;
|
||||
if (refCallId) {
|
||||
if (state.funcItemAdded[tcIdx] && refCallId && !isCustomTool(state, state.funcNames[tcIdx])) {
|
||||
emit("response.function_call_arguments.delta", {
|
||||
type: "response.function_call_arguments.delta",
|
||||
item_id: `fc_${refCallId}`,
|
||||
@@ -293,6 +312,9 @@ function emitToolCall(state, emit, tc) {
|
||||
delta: tc.function.arguments
|
||||
});
|
||||
}
|
||||
// Custom input is emitted once at close, after the Chat JSON wrapper can be
|
||||
// parsed and unwrapped. Streaming the raw JSON fragments would expose
|
||||
// {"input":"..."} instead of the freeform program Codex expects.
|
||||
state.funcArgsBuf[tcIdx] += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
@@ -301,21 +323,38 @@ function closeToolCall(state, emit, idx) {
|
||||
const callId = state.funcCallIds[idx];
|
||||
if (callId && !state.funcItemDone[idx]) {
|
||||
const args = state.funcArgsBuf[idx] || "{}";
|
||||
|
||||
emit("response.function_call_arguments.done", {
|
||||
type: "response.function_call_arguments.done",
|
||||
item_id: `fc_${callId}`,
|
||||
output_index: parseInt(idx),
|
||||
arguments: args
|
||||
});
|
||||
const custom = isCustomTool(state, state.funcNames[idx]);
|
||||
|
||||
if (custom) {
|
||||
const input = extractCustomToolInput(args);
|
||||
emit("response.custom_tool_call_input.delta", {
|
||||
type: "response.custom_tool_call_input.delta",
|
||||
item_id: `ctc_${callId}`,
|
||||
output_index: parseInt(idx),
|
||||
delta: input
|
||||
});
|
||||
emit("response.custom_tool_call_input.done", {
|
||||
type: "response.custom_tool_call_input.done",
|
||||
item_id: `ctc_${callId}`,
|
||||
output_index: parseInt(idx),
|
||||
input
|
||||
});
|
||||
} else {
|
||||
emit("response.function_call_arguments.done", {
|
||||
type: "response.function_call_arguments.done",
|
||||
item_id: `fc_${callId}`,
|
||||
output_index: parseInt(idx),
|
||||
arguments: args
|
||||
});
|
||||
}
|
||||
|
||||
emit("response.output_item.done", {
|
||||
type: "response.output_item.done",
|
||||
output_index: parseInt(idx),
|
||||
item: {
|
||||
id: `fc_${callId}`,
|
||||
type: RESPONSES_ITEM.FUNCTION_CALL,
|
||||
arguments: args,
|
||||
id: `${custom ? "ctc" : "fc"}_${callId}`,
|
||||
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
|
||||
...(custom ? { input: extractCustomToolInput(args) } : { arguments: args }),
|
||||
call_id: callId,
|
||||
name: state.funcNames[idx] || ""
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ export const RESPONSES_ITEM = {
|
||||
MESSAGE: "message",
|
||||
FUNCTION_CALL: "function_call",
|
||||
FUNCTION_CALL_OUTPUT: "function_call_output",
|
||||
CUSTOM_TOOL_CALL: "custom_tool_call",
|
||||
CUSTOM_TOOL_CALL_OUTPUT: "custom_tool_call_output",
|
||||
ADDITIONAL_TOOLS: "additional_tools",
|
||||
REASONING: "reasoning",
|
||||
OUTPUT_TEXT: "output_text",
|
||||
INPUT_TEXT: "input_text",
|
||||
|
||||
Reference in New Issue
Block a user