diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index b9eda7e997..230e5b4f29 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -246,12 +246,34 @@ function containsMalformedTextualToolCall(text: unknown): boolean { return text.replace(/[\u200B-\u200D\uFEFF]/g, "").includes("[Tool call:"); } +function extractAllowedToolNames(body: unknown): Set | null { + const record = asRecord(body); + const tools = record.tools; + if (!Array.isArray(tools)) return null; + const names = new Set(); + for (const tool of tools) { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) continue; + const item = tool as JsonRecord; + const directName = typeof item.name === "string" ? item.name.trim() : ""; + const fn = + item.function && typeof item.function === "object" && !Array.isArray(item.function) + ? (item.function as JsonRecord) + : null; + const functionName = typeof fn?.name === "string" ? fn.name.trim() : ""; + const name = functionName || directName; + if (name) names.add(name); + } + return names.size > 0 ? names : null; +} + function collectPassthroughTextualToolCall( text: string, - toolCalls: Map + toolCalls: Map, + allowedToolNames?: Set | null ): ToolCall | null { const parsed = parseTextualToolCallFromContent(text); if (!parsed) return null; + if (allowedToolNames?.size && !allowedToolNames.has(parsed.name)) return null; const key = `textual:${toolCalls.size}`; const toolCall: ToolCall = { id: `call_${Date.now()}_${toolCalls.size}`, @@ -669,6 +691,7 @@ export function createSSEStream(options: StreamOptions = {}) { /** Passthrough: accumulate tool_calls deltas for call log responseBody */ const passthroughToolCalls = new Map(); let passthroughToolCallSeq = 0; + const allowedToolNames = extractAllowedToolNames(body); let skipPassthroughEvent = false; // State for translate mode (accumulatedContent for call log response body) @@ -1399,12 +1422,13 @@ export function createSSEStream(options: StreamOptions = {}) { if (parsedCandidate?.kind === "complete") { const collectedToolCall = collectPassthroughTextualToolCall( bufferedCandidate, - passthroughToolCalls + passthroughToolCalls, + allowedToolNames ); if (collectedToolCall) { delta.tool_calls = [toStreamingToolCallDelta(collectedToolCall)]; + passthroughHasToolCalls = true; } - passthroughHasToolCalls = true; textualToolCallConverted = true; passthroughBufferedTextualToolCallContent = ""; delete delta.content; @@ -1760,14 +1784,18 @@ export function createSSEStream(options: StreamOptions = {}) { if ( collectPassthroughTextualToolCall( finalBufferedTextualToolCall, - passthroughToolCalls + passthroughToolCalls, + allowedToolNames ) ) { passthroughHasToolCalls = true; } passthroughBufferedTextualToolCallContent = ""; } - if (content && collectPassthroughTextualToolCall(content, passthroughToolCalls)) { + if ( + content && + collectPassthroughTextualToolCall(content, passthroughToolCalls, allowedToolNames) + ) { passthroughHasToolCalls = true; content = ""; } else if (containsMalformedTextualToolCall(content)) { diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index 1a35f19975..66fc476d92 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -232,6 +232,53 @@ test("createSSEStream passthrough converts split textual tool-call content at co assert.doesNotMatch(text, /\[Tool call: terminal\]/); }); +test("createSSEStream passthrough suppresses textual tool calls for unknown tools", async () => { + let onCompletePayload = null; + const toolText = `[Tool call: search_files_ide] +Arguments: {"path":"/opt/OmniRoute/src","target":"files"}`; + + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_unknown_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "antigravity/gemini-3.5-flash-low", + choices: [{ index: 0, delta: { role: "assistant", content: toolText } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_unknown_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "antigravity/gemini-3.5-flash-low", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "antigravity", + model: "antigravity/gemini-3.5-flash-low", + body: { + messages: [{ role: "user", content: "inspect files" }], + tools: [ + { type: "function", function: { name: "search_files", parameters: { type: "object" } } }, + ], + }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + const choice = onCompletePayload.responseBody.choices[0]; + assert.equal(choice.finish_reason, "stop"); + assert.equal(choice.message.content, null); + assert.equal(choice.message.tool_calls, undefined); + assert.doesNotMatch(text, /search_files_ide/); + assert.doesNotMatch(JSON.stringify(onCompletePayload.responseBody), /search_files_ide/); +}); + test("createSSEStream passthrough suppresses malformed textual tool-call content", async () => { let onCompletePayload = null; const malformedToolText = `(empty)[Tool call: terminal]\nArguments: {"command":"sqlite3 /opt/O\u200dmniRoute/data/o\u200dmniroute.`;