diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index ceb9fe560f..ae1ef920bb 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -202,18 +202,31 @@ function stripZeroWidth(value: unknown): unknown { function parseTextualToolCallFromContent(text: unknown): { name: string; args: unknown } | null { if (typeof text !== "string") return null; const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); - const match = normalized.match( - /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/ - ); - if (!match) return null; - const name = match[1]?.trim(); - const rawArgs = match[2]?.trim(); + const toolCallIndex = normalized.lastIndexOf("[Tool call:"); + const candidate = toolCallIndex >= 0 ? normalized.slice(toolCallIndex) : normalized; + const headerMatch = candidate.match(/^\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*/); + if (!headerMatch) return null; + const name = headerMatch[1]?.trim(); + const rawArgs = candidate.slice(headerMatch[0].length).trim(); if (!name || !rawArgs) return null; - try { - return { name, args: stripZeroWidth(JSON.parse(rawArgs)) }; - } catch { - return null; + const decoders = [ + (value: string) => value, + (value: string) => { + if (value.startsWith('"') && value.endsWith('"')) { + const decoded = JSON.parse(value); + return typeof decoded === "string" ? decoded : value; + } + return value; + }, + ]; + for (const decode of decoders) { + try { + const decoded = decode(rawArgs); + const parsed = JSON.parse(decoded); + return { name, args: stripZeroWidth(parsed) }; + } catch {} } + return null; } function collectPassthroughTextualToolCall( @@ -1679,7 +1692,11 @@ export function createSSEStream(options: StreamOptions = {}) { const u = usage as Record | null; const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0); const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0); - const content = passthroughAccumulatedContent.trim() || ""; + let content = passthroughAccumulatedContent.trim() || ""; + if (content && collectPassthroughTextualToolCall(content, passthroughToolCalls)) { + passthroughHasToolCalls = true; + content = ""; + } const message: Record = { role: "assistant", content: content || null, @@ -1892,27 +1909,42 @@ export function createSSEStream(options: StreamOptions = {}) { const u = state?.usage as Record | null | undefined; const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0); const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0); - const content = (state?.accumulatedContent ?? "").trim() || ""; + let content = (state?.accumulatedContent ?? "").trim() || ""; + const normalizedToolCalls: ToolCall[] = state?.toolCalls?.size + ? [...state.toolCalls.values()] + .map( + (tc: Record): ToolCall => ({ + id: (tc.id as string) ?? null, + index: (tc.index as number) ?? (tc.blockIndex as number) ?? 0, + type: (tc.type as string) ?? "function", + function: (tc.function as ToolCall["function"]) ?? { + name: (tc.name as string) ?? "", + arguments: "", + }, + }) + ) + .sort((a, b) => a.index - b.index) + : []; + const textualToolCall = parseTextualToolCallFromContent(content); + if (textualToolCall) { + normalizedToolCalls.push({ + id: `call_${Date.now()}_${normalizedToolCalls.length}`, + index: normalizedToolCalls.length, + type: "function", + function: { + name: textualToolCall.name, + arguments: JSON.stringify(textualToolCall.args || {}), + }, + }); + content = ""; + } const message: Record = { role: "assistant", content: content || null, }; - const hasToolCalls = state?.toolCalls?.size > 0; + const hasToolCalls = normalizedToolCalls.length > 0; if (hasToolCalls) { - // Normalize shape — translators may store different structures - message.tool_calls = [...state.toolCalls.values()] - .map( - (tc: Record): ToolCall => ({ - id: (tc.id as string) ?? null, - index: (tc.index as number) ?? (tc.blockIndex as number) ?? 0, - type: (tc.type as string) ?? "function", - function: (tc.function as ToolCall["function"]) ?? { - name: (tc.name as string) ?? "", - arguments: "", - }, - }) - ) - .sort((a, b) => a.index - b.index); + message.tool_calls = normalizedToolCalls; } const responseBody = { choices: [ diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index 418668fab9..e5893be1f1 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -173,6 +173,59 @@ test("createSSEStream passthrough converts textual tool-call content into struct assert.doesNotMatch(text, /\[Tool call: terminal\]/); }); +test("createSSEStream passthrough converts split textual tool-call content at completion", async () => { + let onCompletePayload = null; + const splitToolArgs = JSON.stringify({ + command: 'sqlite3 ~/.o\u200dmniroute/o\u200dmniroute.db ".tables"', + }); + const chunks = ["[Tool call: terminal]\n", `Arguments: ${splitToolArgs}`]; + + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_split_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "antigravity/gemini-3.5-flash-low", + choices: [{ index: 0, delta: { role: "assistant", content: chunks[0] } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_split_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "antigravity/gemini-3.5-flash-low", + choices: [{ index: 0, delta: { content: chunks[1] } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_split_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 db" }] }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + const choice = onCompletePayload.responseBody.choices[0]; + assert.equal(choice.finish_reason, "tool_calls"); + assert.equal(choice.message.content, null); + assert.equal(choice.message.tool_calls[0].function.name, "terminal"); + assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), { + command: 'sqlite3 ~/.omniroute/omniroute.db ".tables"', + }); + assert.match(text, /\[Tool call: terminal\]/); +}); + test("createSSEStream passthrough flushes a buffered final line without a trailing newline", async () => { const text = await readTransformed( [