From 6b59e814daba91b76829788b6bd9e995087e5318 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Tue, 21 Jul 2026 03:22:24 +0200 Subject: [PATCH] Restore Responses API custom tool calls (#7905) * fix: restore Responses custom tool calls * fix: preserve nested Responses custom tool calls * fix: reset superseded tool call state * fix: preserve tool precedence and buffered Responses tool arguments * test: verify top-level tool descriptions take precedence * fix: preserve custom Responses tool streaming semantics * test: cover declared custom tool streaming round trips * test: cover Responses custom tool metadata collection * test: cover active Responses custom tool stream * test: isolate Responses active stream regression * fix: complete Responses custom tool round trips --- open-sse/handlers/chatCore.ts | 11 +- open-sse/handlers/responsesHandler.ts | 6 +- open-sse/transformer/responsesTransformer.ts | 165 +++++++++++++----- open-sse/translator/index.ts | 1 + .../translator/request/openai-responses.ts | 19 +- .../openai-responses/additionalTools.ts | 49 +++++- .../translator/response/openai-responses.ts | 63 ++++--- .../translator/response/responsesToolItem.ts | 15 ++ open-sse/utils/stream.ts | 8 +- ...esponses-active-stream-custom-tool.test.ts | 63 +++++++ tests/unit/responses-additional-tools.test.ts | 128 ++++++++++++++ tests/unit/responses-handler.test.ts | 101 +++++++++++ tests/unit/responses-transformer.test.ts | 107 +++++++++++- ...-openai-responses-custom-tool-1007.test.ts | 82 ++++++++- 14 files changed, 735 insertions(+), 83 deletions(-) create mode 100644 open-sse/translator/response/responsesToolItem.ts create mode 100644 tests/unit/responses-active-stream-custom-tool.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 4b0d4d5106..89a29d632f 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -74,6 +74,7 @@ import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts"; import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts"; import { translateRequest, needsTranslation } from "../translator/index.ts"; import { FORMATS } from "../translator/formats.ts"; +import { collectCustomToolNamesForSourceFormat } from "../translator/request/openai-responses/additionalTools.ts"; import { sanitizeKiroTools } from "../utils/kiroSanitizer.ts"; import { splitMisplacedToolResults } from "../translator/helpers/claudeHelper.ts"; import { @@ -614,6 +615,13 @@ export async function handleChatCore({ copilotCompatibleReasoning, clientResponseFormat, } = resolveChatCoreRequestFormat({ clientRawRequest, body, provider, userAgent }); + const responsesInputItems = Array.isArray(body?.input) ? body.input : []; + const customToolNames = collectCustomToolNamesForSourceFormat( + sourceFormat, + FORMATS.OPENAI_RESPONSES, + body?.tools, + responsesInputItems + ); // Check for bypass patterns (warmup, skip) - return fake response const bypassResponse = handleBypassRequest(body, model, userAgent); @@ -4618,7 +4626,8 @@ export async function handleChatCore({ userAgent: streamUserAgent, thinkingMarkerHeader, clientResponseFormat, - }) + }), + customToolNames ); } else { log?.debug?.("STREAM", `Standard passthrough mode`); diff --git a/open-sse/handlers/responsesHandler.ts b/open-sse/handlers/responsesHandler.ts index 65d18e6d1e..eb374f0ccb 100644 --- a/open-sse/handlers/responsesHandler.ts +++ b/open-sse/handlers/responsesHandler.ts @@ -6,6 +6,7 @@ import { CORS_HEADERS } from "../utils/cors.ts"; import { handleChatCore } from "./chatCore.ts"; import { convertResponsesApiFormat } from "../translator/helpers/responsesApiHelper.ts"; +import { collectResponsesCustomToolNames } from "../translator/request/openai-responses/additionalTools.ts"; import { createResponsesApiTransformStream } from "../transformer/responsesTransformer.ts"; import { createSseHeartbeatTransform, HEARTBEAT_SHAPES } from "../utils/sseHeartbeat.ts"; import { SSE_HEARTBEAT_INTERVAL_MS } from "../config/constants.ts"; @@ -35,6 +36,9 @@ export async function handleResponsesCore({ connectionId, signal, }) { + const inputItems = Array.isArray(body?.input) ? body.input : []; + const customToolNames = collectResponsesCustomToolNames(body?.tools, inputItems); + // Convert Responses API format to Chat Completions format const convertedBody = convertResponsesApiFormat(body, credentials); @@ -69,7 +73,7 @@ export async function handleResponsesCore({ } // Transform SSE stream to Responses API format (no logging in worker) - const transformStream = createResponsesApiTransformStream(null); + const transformStream = createResponsesApiTransformStream(null, undefined, { customToolNames }); const transformedBody = response.body.pipeThrough(transformStream).pipeThrough( createSseHeartbeatTransform({ signal, diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index ab8ce9596f..1255f5a82b 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -75,9 +75,16 @@ export function createResponsesLogger(model, logsDir = null) { /** * Create TransformStream that converts Chat Completions SSE to Responses API SSE * @param {Object} logger - Optional logger instance + * @param {number} keepaliveIntervalMs - Keepalive interval in milliseconds + * @param {{ customToolNames?: Iterable }} options - Original Responses tool metadata * @returns {TransformStream} */ -export function createResponsesApiTransformStream(logger = null, keepaliveIntervalMs = 3000) { +export function createResponsesApiTransformStream( + logger = null, + keepaliveIntervalMs = 3000, + options = {} +) { + const customToolNames = new Set(options.customToolNames || []); const state = { seq: 0, responseId: `resp_${Date.now()}`, @@ -97,6 +104,8 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv funcArgsBuf: {}, funcNames: {}, funcCallIds: {}, + funcItemAdded: {}, + funcItemTypes: {}, funcArgsDone: {}, funcItemDone: {}, completedOutputItems: [] as Array<{ @@ -270,46 +279,106 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv } }; + const emitToolCallAdded = (controller, idx) => { + if (state.funcItemAdded[idx] || !state.funcCallIds[idx]) return false; + + const customTool = customToolNames.has(state.funcNames[idx] || ""); + const itemType = customTool ? "custom_tool_call" : "function_call"; + state.funcItemTypes[idx] = itemType; + state.funcItemAdded[idx] = true; + + emit(controller, "response.output_item.added", { + type: "response.output_item.added", + output_index: idx, + item: { + id: `fc_${state.funcCallIds[idx]}`, + type: itemType, + ...(customTool ? { input: "" } : { arguments: "" }), + call_id: state.funcCallIds[idx], + name: state.funcNames[idx] || "", + ...(customTool ? { status: "in_progress" } : {}), + }, + }); + return true; + }; + const closeToolCall = (controller, idx, recordAsCompleted = true) => { const callId = state.funcCallIds[idx]; if (callId && !state.funcItemDone[idx]) { const normalizedIndex = normalizeOutputIndex(idx); let args = state.funcArgsBuf[idx] || "{}"; + const toolName = state.funcNames[idx] || ""; + emitToolCallAdded(controller, idx); + const isCustomTool = state.funcItemTypes[idx] === "custom_tool_call"; - // Fix #1674 & #1852: Final cleanup of empty string and empty array placeholders - try { - const parsed = JSON.parse(args); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - let modified = false; - for (const [k, v] of Object.entries(parsed)) { - if (v === "" || (Array.isArray(v) && v.length === 0)) { - delete parsed[k]; - modified = true; + // Fix #1674 & #1852: Final cleanup of empty string and empty array placeholders. + // Custom-tool input is intentionally allowed to be an empty string. + if (!isCustomTool) { + try { + const parsed = JSON.parse(args); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + let modified = false; + for (const [k, v] of Object.entries(parsed)) { + if (v === "" || (Array.isArray(v) && v.length === 0)) { + delete parsed[k]; + modified = true; + } + } + if (modified) { + args = JSON.stringify(parsed); + state.funcArgsBuf[idx] = args; } } - if (modified) { - args = JSON.stringify(parsed); - state.funcArgsBuf[idx] = args; - } + } catch (e) { + // Ignore malformed JSON } - } catch (e) { - // Ignore malformed JSON } - emit(controller, "response.function_call_arguments.done", { - type: "response.function_call_arguments.done", - item_id: `fc_${callId}`, - output_index: normalizedIndex, - arguments: args, - }); + let funcItem; + if (isCustomTool) { + let rawInput = args; + try { + const parsed = JSON.parse(args); + if (parsed && typeof parsed.input === "string") rawInput = parsed.input; + } catch { + // A non-JSON argument is already the raw custom-tool input. + } - const funcItem = { - id: `fc_${callId}`, - type: "function_call", - arguments: args, - call_id: callId, - name: state.funcNames[idx] || "", - }; + emit(controller, "response.custom_tool_call_input.delta", { + type: "response.custom_tool_call_input.delta", + item_id: `fc_${callId}`, + output_index: normalizedIndex, + delta: rawInput, + }); + emit(controller, "response.custom_tool_call_input.done", { + type: "response.custom_tool_call_input.done", + item_id: `fc_${callId}`, + output_index: normalizedIndex, + input: rawInput, + }); + funcItem = { + id: `fc_${callId}`, + type: "custom_tool_call", + input: rawInput, + call_id: callId, + name: toolName, + status: "completed", + }; + } else { + emit(controller, "response.function_call_arguments.done", { + type: "response.function_call_arguments.done", + item_id: `fc_${callId}`, + output_index: normalizedIndex, + arguments: args, + }); + funcItem = { + id: `fc_${callId}`, + type: "function_call", + arguments: args, + call_id: callId, + name: toolName, + }; + } emit(controller, "response.output_item.done", { type: "response.output_item.done", @@ -576,6 +645,8 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv delete state.funcCallIds[tcIdx]; delete state.funcNames[tcIdx]; delete state.funcArgsBuf[tcIdx]; + delete state.funcItemAdded[tcIdx]; + delete state.funcItemTypes[tcIdx]; delete state.funcArgsDone[tcIdx]; delete state.funcItemDone[tcIdx]; } @@ -584,18 +655,25 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv if (!state.funcCallIds[tcIdx] && newCallId) { state.funcCallIds[tcIdx] = newCallId; + } - emit(controller, "response.output_item.added", { - type: "response.output_item.added", - output_index: tcIdx, - item: { - id: `fc_${newCallId}`, - type: "function_call", - arguments: "", - call_id: newCallId, - name: state.funcNames[tcIdx] || "", - }, - }); + // The provider may send the call id before the function name. Defer the + // lifecycle item until the name is available so custom calls are not first + // announced as function calls. + if (state.funcCallIds[tcIdx] && state.funcNames[tcIdx]) { + const itemAdded = emitToolCallAdded(controller, tcIdx); + if ( + itemAdded && + state.funcItemTypes[tcIdx] !== "custom_tool_call" && + state.funcArgsBuf[tcIdx] + ) { + emit(controller, "response.function_call_arguments.delta", { + type: "response.function_call_arguments.delta", + item_id: `fc_${state.funcCallIds[tcIdx]}`, + output_index: tcIdx, + delta: state.funcArgsBuf[tcIdx], + }); + } } if (!state.funcArgsBuf[tcIdx]) state.funcArgsBuf[tcIdx] = ""; @@ -622,7 +700,12 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv const emittedDelta = nextArgs.slice(existingArgs.length); state.funcArgsBuf[tcIdx] = nextArgs; - if (refCallId && emittedDelta) { + if ( + refCallId && + emittedDelta && + state.funcItemAdded[tcIdx] && + state.funcItemTypes[tcIdx] !== "custom_tool_call" + ) { emit(controller, "response.function_call_arguments.delta", { type: "response.function_call_arguments.delta", item_id: `fc_${refCallId}`, diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 559afeccce..0e14c105ed 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -684,6 +684,7 @@ export function initState(sourceFormat) { funcNames: {}, funcCallIds: {}, funcArgsDone: {}, + funcItemAdded: {}, funcItemDone: {}, completedOutputItems: [], completedSent: false, diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index b6d7679cc6..0bc20d6f9b 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -403,11 +403,20 @@ export function openaiResponsesToOpenAIRequest( function: { name: toString(sub.name), description: toString(sub.description), - parameters: sub.parameters ?? - sub.input_schema ?? { - type: "object", - properties: {}, - }, + parameters: + toString(sub.type) === "custom" + ? { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, + } + : (sub.parameters ?? + sub.input_schema ?? { + type: "object", + properties: {}, + }), + strict: sub.strict, }, })); } diff --git a/open-sse/translator/request/openai-responses/additionalTools.ts b/open-sse/translator/request/openai-responses/additionalTools.ts index 50cb27004f..57e804adcb 100644 --- a/open-sse/translator/request/openai-responses/additionalTools.ts +++ b/open-sse/translator/request/openai-responses/additionalTools.ts @@ -56,7 +56,8 @@ function mergeNamespaceTools(first: unknown, second: unknown): unknown[] { * function names from reaching strict upstreams. */ export function collectResponsesTools(rootTools: unknown, inputItems: unknown[]): unknown[] { - const sources: unknown[][] = [Array.isArray(rootTools) ? rootTools : []]; + const rootToolList = Array.isArray(rootTools) ? rootTools : []; + const sources: unknown[][] = [rootToolList]; for (const itemValue of inputItems) { const item = toRecord(itemValue); @@ -64,6 +65,15 @@ export function collectResponsesTools(rootTools: unknown, inputItems: unknown[]) sources.push(item.tools); } } + const explicitNames = new Set( + sources + .flat() + .map((tool) => { + const record = toRecord(tool); + return record.type === "namespace" ? "" : toolName(tool); + }) + .filter(Boolean) + ); const merged: unknown[] = []; const seen = new Set(); @@ -74,16 +84,21 @@ export function collectResponsesTools(rootTools: unknown, inputItems: unknown[]) if (toolRecord.type === "namespace") { const namespaceName = toolName(tool); const existingIndex = namespaceName ? namespaceIndexes.get(namespaceName) : undefined; + const namespaceTools = Array.isArray(toolRecord.tools) + ? toolRecord.tools.filter((member) => !explicitNames.has(toolName(member))) + : toolRecord.tools; if (existingIndex !== undefined) { const existing = toRecord(merged[existingIndex]); merged[existingIndex] = { ...toolRecord, ...existing, - tools: mergeNamespaceTools(existing.tools, toolRecord.tools), + tools: mergeNamespaceTools(existing.tools, namespaceTools), }; continue; } if (namespaceName) namespaceIndexes.set(namespaceName, merged.length); + merged.push({ ...toolRecord, tools: namespaceTools }); + continue; } const identity = toolIdentity(tool); @@ -92,5 +107,35 @@ export function collectResponsesTools(rootTools: unknown, inputItems: unknown[]) merged.push(tool); } } + return merged; } + +/** Return the custom/freeform tool names after applying the same precedence rules as conversion. */ +export function collectResponsesCustomToolNames( + rootTools: unknown, + inputItems: unknown[] +): Set { + const names = new Set(); + const visit = (tools: unknown[]) => { + for (const toolValue of tools) { + const tool = toRecord(toolValue); + const name = toolName(toolValue); + if (tool.type === "custom" && name) names.add(name); + if (tool.type === "namespace" && Array.isArray(tool.tools)) visit(tool.tools); + } + }; + visit(collectResponsesTools(rootTools, inputItems)); + return names; +} + +export function collectCustomToolNamesForSourceFormat( + sourceFormat: string, + responsesFormat: string, + rootTools: unknown, + inputItems: unknown[] +): Set { + return sourceFormat === responsesFormat + ? collectResponsesCustomToolNames(rootTools, inputItems) + : new Set(); +} diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index cdc981cf92..0884d6d6d7 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -15,6 +15,7 @@ import { getVisibleResponsesReasoningSummaryText, } from "./openai-responses/pureHelpers.ts"; import { createEventEmitter } from "./openai-responses/eventEmitter.ts"; +import { buildResponsesToolCallItem } from "./responsesToolItem.ts"; import { synthesizeCompletedToolCalls, computeFinishReason, @@ -428,40 +429,38 @@ function emitToolCall(state, emit, tc) { delete state.funcNames[tcIdx]; delete state.funcArgsBuf[tcIdx]; delete state.funcArgsDone[tcIdx]; + delete state.funcItemAdded[tcIdx]; delete state.funcItemDone[tcIdx]; } if (funcName) state.funcNames[tcIdx] = funcName; - // Codex custom tools (apply_patch) are surfaced to the client as custom_tool_call items - // and stream their raw patch via custom_tool_call_input.* events instead of the + // Custom tools are surfaced as custom_tool_call items and stream raw input instead of the // function_call_arguments.* events used for regular function tools. (#1007) - const isCustomTool = (state.funcNames[tcIdx] || funcName) === "apply_patch"; + const toolName = state.funcNames[tcIdx] || funcName || ""; + const isCustomTool = + toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true; - if (!state.funcCallIds[tcIdx] && newCallId) { - state.funcCallIds[tcIdx] = newCallId; + if (!state.funcCallIds[tcIdx] && newCallId) state.funcCallIds[tcIdx] = newCallId; + const callId = state.funcCallIds[tcIdx]; + if (callId && toolName && !state.funcItemAdded[tcIdx]) { emit("response.output_item.added", { type: "response.output_item.added", output_index: outputIndex, - item: isCustomTool - ? { - id: `fc_${newCallId}`, - type: "custom_tool_call", - input: "", - call_id: newCallId, - name: state.funcNames[tcIdx] || "", - status: "in_progress", - } - : { - id: `fc_${newCallId}`, - type: "function_call", - arguments: "", - call_id: newCallId, - name: state.funcNames[tcIdx] || "", - status: "in_progress", - }, + item: buildResponsesToolCallItem({ callId, toolName, custom: isCustomTool }), }); + state.funcItemAdded[tcIdx] = true; + + const bufferedArgs = state.funcArgsBuf[tcIdx] || ""; + if (bufferedArgs && !isCustomTool) { + emit("response.function_call_arguments.delta", { + type: "response.function_call_arguments.delta", + item_id: `fc_${callId}`, + output_index: outputIndex, + delta: bufferedArgs, + }); + } } if (!state.funcArgsBuf[tcIdx]) state.funcArgsBuf[tcIdx] = ""; @@ -474,12 +473,9 @@ function emitToolCall(state, emit, tc) { const emittedDelta = nextArgs.slice(existingArgs.length); state.funcArgsBuf[tcIdx] = nextArgs; - if (refCallId && emittedDelta) { - const deltaEvent = isCustomTool - ? "response.custom_tool_call_input.delta" - : "response.function_call_arguments.delta"; - emit(deltaEvent, { - type: deltaEvent, + if (refCallId && emittedDelta && !isCustomTool && state.funcItemAdded[tcIdx]) { + emit("response.function_call_arguments.delta", { + type: "response.function_call_arguments.delta", item_id: `fc_${refCallId}`, output_index: outputIndex, delta: emittedDelta, @@ -495,7 +491,9 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) { ? normalizeOutputIndex(state.reasoningIndex) + 1 + normalizeOutputIndex(idx) : normalizeOutputIndex(idx); const args = state.funcArgsBuf[idx] || "{}"; - const isCustomTool = (state.funcNames[idx] || "") === "apply_patch"; + const toolName = state.funcNames[idx] || ""; + const isCustomTool = + toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true; let funcItem; if (isCustomTool) { @@ -509,6 +507,13 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) { // Not JSON — fall back to the raw buffered arguments. } + emit("response.custom_tool_call_input.delta", { + type: "response.custom_tool_call_input.delta", + item_id: `fc_${callId}`, + output_index: normalizedIndex, + delta: rawInput, + }); + emit("response.custom_tool_call_input.done", { type: "response.custom_tool_call_input.done", item_id: `fc_${callId}`, diff --git a/open-sse/translator/response/responsesToolItem.ts b/open-sse/translator/response/responsesToolItem.ts new file mode 100644 index 0000000000..a1bd062f43 --- /dev/null +++ b/open-sse/translator/response/responsesToolItem.ts @@ -0,0 +1,15 @@ +export function buildResponsesToolCallItem(options: { + callId: string; + toolName: string; + custom: boolean; +}) { + const { callId, toolName, custom } = options; + return { + id: `fc_${callId}`, + type: custom ? "custom_tool_call" : "function_call", + ...(custom ? { input: "" } : { arguments: "" }), + call_id: callId, + name: toolName, + status: "in_progress", + }; +} diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 6ff1e92099..421692c77b 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -133,6 +133,7 @@ type StreamOptions = { * `RESPONSES_PASSTHROUGH_DROP_COMMENTARY` feature flag (default on). */ dropResponsesCommentary?: boolean; + customToolNames?: ReadonlySet; provider?: string | null; reqLogger?: StreamLogger | null; toolNameMap?: unknown; @@ -159,6 +160,7 @@ type TranslateState = ReturnType & { accumulatedReasoning?: string; /** #6951 — per-tool JSON Schema (from request `tools[]`), keyed by tool name. */ toolSchemas?: Map> | null; + customToolNames?: ReadonlySet; upstreamError?: { status: number; type: string; @@ -631,6 +633,7 @@ export function createSSEStream(options: StreamOptions = {}) { onComplete = null, onFailure = null, dropResponsesCommentary, + customToolNames = new Set(), } = options; const signatureNamespace = connectionId; // Request-body-size metric (for monitoring payload size distribution & correlation with TTFT). @@ -705,6 +708,7 @@ export function createSSEStream(options: StreamOptions = {}) { accumulatedContent: "", accumulatedReasoning: "", toolSchemas: extractToolSchemaMap(body), + customToolNames, } : null; @@ -2739,7 +2743,8 @@ export function createSSETransformStreamWithLogger( apiKeyInfo: unknown = null, onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null, copilotCompatibleReasoning = false, - suppressThinkClose = false + suppressThinkClose = false, + customToolNames: ReadonlySet = new Set() ) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, @@ -2756,6 +2761,7 @@ export function createSSETransformStreamWithLogger( onFailure, copilotCompatibleReasoning, suppressThinkClose, + customToolNames, }); } diff --git a/tests/unit/responses-active-stream-custom-tool.test.ts b/tests/unit/responses-active-stream-custom-tool.test.ts new file mode 100644 index 0000000000..f1c78fa4cf --- /dev/null +++ b/tests/unit/responses-active-stream-custom-tool.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-responses-stream-")); +const { createSSETransformStreamWithLogger } = await import("../../open-sse/utils/stream.ts"); + +test("active Responses stream restores declared custom tool metadata", async () => { + const source = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify({ + id: "chatcmpl_custom", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_exec", + function: { name: "exec", arguments: '{"input":"text(\\"pong\\")"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + })}\n\n` + ) + ); + controller.close(); + }, + }); + const transform = createSSETransformStreamWithLogger( + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES, + "opencode-go", + null, + null, + "kimi-k3", + null, + { messages: [{ role: "user", content: "run it" }] }, + null, + null, + null, + false, + false, + new Set(["exec"]) + ); + + const text = await new Response(source.pipeThrough(transform)).text(); + + assert.match(text, /"type":"custom_tool_call"/); + assert.match(text, /"input":"text\(\\"pong\\"\)"/); + assert.doesNotMatch(text, /"type":"function_call"/); +}); diff --git a/tests/unit/responses-additional-tools.test.ts b/tests/unit/responses-additional-tools.test.ts index 517b1e94e0..5923b75e2c 100644 --- a/tests/unit/responses-additional-tools.test.ts +++ b/tests/unit/responses-additional-tools.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; const { openaiResponsesToOpenAIRequest } = await import("../../open-sse/translator/request/openai-responses.ts"); +const { collectCustomToolNamesForSourceFormat, collectResponsesCustomToolNames } = + await import("../../open-sse/translator/request/openai-responses/additionalTools.ts"); interface ChatTool { function: { @@ -198,6 +200,132 @@ test("Responses -> Chat merges members from same-named namespaces", () => { ); }); +test("Responses -> Chat gives top-level tools precedence over namespaced members", () => { + const rootTools = [ + { + type: "function", + name: "exec", + description: "Explicit function tool", + parameters: { type: "object" }, + }, + { + type: "namespace", + name: "commands", + tools: [{ type: "custom", name: "exec", description: "Shadowed custom tool" }], + }, + ]; + const result = openaiResponsesToOpenAIRequest( + "any-model", + { + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }], + tools: rootTools, + }, + false, + { provider: "another-provider" } + ) as ChatRequest; + + assert.deepEqual( + result.tools.map((tool) => tool.function.name), + ["exec"] + ); + assert.equal(result.tools[0].function.description, "Explicit function tool"); + assert.deepEqual([...collectResponsesCustomToolNames(rootTools, [])], []); +}); + +test("Responses custom metadata includes additional and namespaced custom tools", () => { + const input = [ + { + type: "additional_tools", + tools: [ + { type: "custom", name: "exec" }, + { + type: "namespace", + name: "server", + tools: [{ type: "custom", name: "apply_diff" }], + }, + ], + }, + ]; + assert.deepEqual([...collectResponsesCustomToolNames([], input)].sort(), ["apply_diff", "exec"]); +}); + +test("Responses source format enables custom metadata independently of model apiFormat", () => { + assert.deepEqual( + [ + ...collectCustomToolNamesForSourceFormat( + "openai-responses", + "openai-responses", + [{ type: "custom", name: "exec" }], + [] + ), + ], + ["exec"] + ); + assert.deepEqual( + [ + ...collectCustomToolNamesForSourceFormat( + "openai", + "openai-responses", + [{ type: "custom", name: "exec" }], + [] + ), + ], + [] + ); +}); + +test("Responses -> Chat normalizes custom tools nested in namespaces", () => { + const result = openaiResponsesToOpenAIRequest( + "any-model", + { + input: [{ type: "message", role: "user", content: "go" }], + tools: [ + { + type: "namespace", + name: "commands", + tools: [{ type: "custom", name: "exec", description: "Run code" }], + }, + ], + }, + false, + { provider: "another-provider" } + ) as ChatRequest; + + assert.deepEqual(result.tools[0].function.parameters, { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, + }); +}); + +test("Responses -> Chat enforces explicit precedence within additional_tools", () => { + const input = [ + { + type: "additional_tools", + tools: [ + { type: "function", name: "exec", description: "Explicit function" }, + { + type: "namespace", + name: "commands", + tools: [{ type: "custom", name: "exec", description: "Shadowed custom" }], + }, + ], + }, + { type: "message", role: "user", content: "go" }, + ]; + const result = openaiResponsesToOpenAIRequest("any-model", { input }, false, { + provider: "another-provider", + }) as ChatRequest; + + assert.deepEqual( + result.tools.map((tool) => tool.function.name), + ["exec"] + ); + assert.equal(result.tools[0].function.description, "Explicit function"); + assert.deepEqual([...collectResponsesCustomToolNames([], input)], []); +}); + test("Responses -> Chat validates tools supplied through additional_tools", () => { assert.throws( () => diff --git a/tests/unit/responses-handler.test.ts b/tests/unit/responses-handler.test.ts index 6ea4cb9bbb..2dd31b34c6 100644 --- a/tests/unit/responses-handler.test.ts +++ b/tests/unit/responses-handler.test.ts @@ -92,6 +92,36 @@ function buildOpenAISseResponse(text = "hello") { ); } +function buildToolCallSseResponse(name: string, argumentsJson: string) { + return new Response( + [ + `data: ${JSON.stringify({ + id: "chatcmpl-tool", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name, arguments: argumentsJson }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + })}`, + "", + "data: [DONE]", + "", + ].join("\n"), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); +} + function buildJsonResponse(status: number, payload: unknown) { return new Response(JSON.stringify(payload), { status, @@ -343,6 +373,77 @@ test("handleResponsesCore rejects invalid Responses API input that cannot be tra ); }); +test("handleResponsesCore restores custom tools declared through additional_tools", async () => { + const { result, call } = await invokeResponsesCore({ + body: { + model: "gpt-4o-mini", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "ping" }] }, + { + type: "additional_tools", + tools: [{ type: "custom", name: "exec", description: "Execute freeform code" }], + }, + ], + }, + responseFactory: () => buildToolCallSseResponse("exec", '{"input":"text(\\"pong\\")"}'), + }); + + assert.equal(call.body.tools[0].type, "function"); + const sse = await result.response.text(); + assert.match(sse, /"type":"custom_tool_call"/); + assert.match(sse, /"input":"text\(\\"pong\\"\)"/); + assert.doesNotMatch(sse, /"type":"function_call","arguments"/); +}); + +test("handleResponsesCore preserves top-level tool precedence for custom-name collisions", async () => { + const { result } = await invokeResponsesCore({ + body: { + model: "gpt-4o-mini", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "ping" }] }, + { + type: "additional_tools", + tools: [{ type: "custom", name: "exec", description: "Shadowed custom tool" }], + }, + ], + tools: [ + { + type: "function", + name: "exec", + description: "Explicit function tool", + parameters: { type: "object", properties: {} }, + }, + ], + }, + responseFactory: () => buildToolCallSseResponse("exec", "{}"), + }); + + const sse = await result.response.text(); + assert.match(sse, /"type":"function_call"/); + assert.doesNotMatch(sse, /"type":"custom_tool_call"/); +}); + +test("handleResponsesCore restores custom tools nested in namespaces", async () => { + const { result } = await invokeResponsesCore({ + body: { + model: "gpt-4o-mini", + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "ping" }] }], + tools: [ + { + type: "namespace", + name: "commands", + tools: [{ type: "custom", name: "exec", description: "Execute freeform code" }], + }, + ], + }, + responseFactory: () => buildToolCallSseResponse("exec", '{"input":"pong"}'), + }); + + const sse = await result.response.text(); + assert.match(sse, /"type":"custom_tool_call"/); + assert.doesNotMatch(sse, /"type":"function_call","arguments"/); +}); + test("handleResponsesCore injects SSE keepalive frames for Responses streams", async (t) => { // PR #2233 changed the Responses-API heartbeat shape from a SSE comment // (`: keepalive ...`) to a `data: {"type":"response.in_progress"}` frame, diff --git a/tests/unit/responses-transformer.test.ts b/tests/unit/responses-transformer.test.ts index fef0226f54..792d5f7300 100644 --- a/tests/unit/responses-transformer.test.ts +++ b/tests/unit/responses-transformer.test.ts @@ -10,8 +10,8 @@ const { createResponsesApiTransformStream, createResponsesLogger } = const encoder = new TextEncoder(); const decoder = new TextDecoder(); -async function runTransformStream(chunks, logger = null) { - const stream = createResponsesApiTransformStream(logger); +async function runTransformStream(chunks, logger = null, options = {}) { + const stream = createResponsesApiTransformStream(logger, 3000, options); const writer = stream.writable.getWriter(); const reader = stream.readable.getReader(); @@ -175,6 +175,109 @@ test("createResponsesApiTransformStream handles native reasoning content and too ); }); +test("createResponsesApiTransformStream restores declared custom tools without changing functions", async () => { + const output = await runTransformStream( + [ + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec","function":{"name":"exec","arguments":"{\\"input\\":\\"text(\\\\\\"pong\\\\\\")\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_search","function":{"name":"search","arguments":"{\\"q\\":\\"pong\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n', + ], + null, + { customToolNames: new Set(["exec"]) } + ); + + const events = parseSseOutput(output); + const added = events + .filter((event) => event.event === "response.output_item.added") + .map((event) => JSON.parse(event.data).item); + const completed = JSON.parse( + events.find((event) => event.event === "response.completed").data + ).response; + + assert.equal(added.find((item) => item.name === "exec").type, "custom_tool_call"); + assert.equal(added.find((item) => item.name === "search").type, "function_call"); + assert.ok(events.some((event) => event.event === "response.custom_tool_call_input.delta")); + assert.ok(events.some((event) => event.event === "response.custom_tool_call_input.done")); + assert.equal(completed.output.find((item) => item.name === "exec").input, 'text("pong")'); + assert.equal(completed.output.find((item) => item.name === "search").arguments, '{"q":"pong"}'); +}); + +test("createResponsesApiTransformStream preserves empty custom-tool input", async () => { + const output = await runTransformStream( + [ + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_empty","function":{"name":"exec","arguments":"{\\"input\\":\\"\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n', + ], + null, + { customToolNames: new Set(["exec"]) } + ); + const events = parseSseOutput(output); + const done = events + .filter((event) => event.event === "response.output_item.done") + .map((event) => JSON.parse(event.data).item) + .find((item) => item.name === "exec"); + assert.equal(done.type, "custom_tool_call"); + assert.equal(done.input, ""); +}); + +test("createResponsesApiTransformStream defers custom item creation until the tool name arrives", async () => { + const output = await runTransformStream( + [ + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec"}]}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"exec","arguments":"{\\"input\\":\\"pong\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n', + ], + null, + { customToolNames: new Set(["exec"]) } + ); + + const events = parseSseOutput(output); + const added = events + .filter((event) => event.event === "response.output_item.added") + .map((event) => JSON.parse(event.data).item) + .filter((item) => item.call_id === "call_exec"); + + assert.deepEqual(added, [ + { + id: "fc_call_exec", + type: "custom_tool_call", + input: "", + call_id: "call_exec", + name: "exec", + status: "in_progress", + }, + ]); + assert.equal(events.filter((event) => event.event === "response.output_item.done").length, 1); +}); + +test("createResponsesApiTransformStream replays buffered function arguments after the name arrives", async () => { + const output = await runTransformStream([ + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_search","function":{"arguments":"{\\"q\\":\\"pong\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"search"}}]},"finish_reason":"tool_calls"}]}\n\n', + ]); + + const events = parseSseOutput(output); + const argumentDeltas = events + .filter((event) => event.event === "response.function_call_arguments.delta") + .map((event) => JSON.parse(event.data).delta); + + assert.deepEqual(argumentDeltas, ['{"q":"pong"}']); +}); + +test("createResponsesApiTransformStream preserves empty custom-tool input", async () => { + const output = await runTransformStream( + [ + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec","function":{"name":"exec","arguments":"{\\"input\\":\\"\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n', + ], + null, + { customToolNames: new Set(["exec"]) } + ); + + const events = parseSseOutput(output); + const completed = JSON.parse( + events.find((event) => event.event === "response.completed").data + ).response; + + assert.equal(completed.output.find((item) => item.name === "exec").input, ""); +}); + test("createResponsesLogger persists input and output event logs on flush", async () => { const logsDir = mkdtempSync(join(tmpdir(), "responses-transformer-")); const logger = createResponsesLogger("gpt-4o", logsDir); diff --git a/tests/unit/translator-openai-responses-custom-tool-1007.test.ts b/tests/unit/translator-openai-responses-custom-tool-1007.test.ts index cfd47fe597..940b592144 100644 --- a/tests/unit/translator-openai-responses-custom-tool-1007.test.ts +++ b/tests/unit/translator-openai-responses-custom-tool-1007.test.ts @@ -8,8 +8,9 @@ const { openaiToOpenAIResponsesResponse } = const { initState } = await import("../../open-sse/translator/index.ts"); const { FORMATS } = await import("../../open-sse/translator/formats.ts"); -function collectEvents(chunks) { +function collectEvents(chunks, customToolNames = new Set()) { const state = initState(FORMATS.OPENAI_RESPONSES); + state.customToolNames = customToolNames; const events = []; for (const chunk of chunks) { const result = openaiToOpenAIResponsesResponse(chunk, state); @@ -164,3 +165,82 @@ test("OpenAI -> Responses: apply_patch streams as custom_tool_call with raw inpu assert.ok(customItem, "final snapshot should carry the custom_tool_call item"); assert.equal(customItem.input, "PATCH_BODY"); }); + +test("OpenAI -> Responses: declared custom tools round-trip through the active translator", () => { + const events = collectEvents( + [ + { + id: "chatcmpl-exec", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_exec", + function: { name: "exec", arguments: '{"input":"text(\\"pong\\")"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }, + null, + ], + new Set(["exec"]) + ); + + const added = events.find((event) => event.event === "response.output_item.added"); + const done = events.find( + (event) => event.event === "response.output_item.done" && event.data.item.name === "exec" + ); + assert.equal(added.data.item.type, "custom_tool_call"); + assert.equal(done.data.item.type, "custom_tool_call"); + assert.equal(done.data.item.input, 'text("pong")'); + assert.ok(events.some((event) => event.event === "response.custom_tool_call_input.delta")); + assert.ok(!events.some((event) => event.event === "response.function_call_arguments.delta")); +}); + +test("OpenAI -> Responses: active translator defers custom item until its name arrives", () => { + const events = collectEvents( + [ + { + id: "chatcmpl-late-name", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { index: 0, id: "call_exec", function: { arguments: '{"input":"pong"}' } }, + ], + }, + }, + ], + }, + { + id: "chatcmpl-late-name", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: 0, function: { name: "exec" } }] }, + finish_reason: "tool_calls", + }, + ], + }, + null, + ], + new Set(["exec"]) + ); + + const added = events.filter((event) => event.event === "response.output_item.added"); + assert.equal(added.length, 1); + assert.equal(added[0].data.item.type, "custom_tool_call"); + assert.equal(added[0].data.item.name, "exec"); + assert.ok(!events.some((event) => event.data?.item?.type === "function_call")); + const done = events.find( + (event) => event.event === "response.output_item.done" && event.data.item.name === "exec" + ); + assert.equal(done.data.item.input, "pong"); +});