diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 636f319ba5..946f96f062 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -4,6 +4,7 @@ import { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature, } from "../../services/geminiThoughtSignatureStore.ts"; +import { parseTextualToolCallCandidate } from "../../utils/textualToolCall.ts"; type GeminiToOpenAIState = { functionIndex: number; @@ -13,6 +14,7 @@ type GeminiToOpenAIState = { signatureNamespace?: string | null; toolCalls: Map; toolNameMap?: Map; + textualToolCallBuffer?: string; }; type GeminiFunctionCallPart = { @@ -34,36 +36,6 @@ function normalizeToolCallArgs(args: unknown): unknown { } } -function parseTextualToolCall(text: unknown): { name: string; args: unknown } | null { - if (typeof text !== "string") return null; - - // Gemini/Antigravity sometimes imitates the request-side fallback with small - // variations, e.g. a leading "(empty)" marker or zero-width chars inserted - // into argument strings. Normalize those variants before parsing so the - // response is still surfaced as a structured OpenAI tool call. - 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(); - if (!name || !rawArgs) return null; - try { - let args = JSON.parse(rawArgs); - if (typeof args === "string") { - const trimmed = args.trim(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - args = JSON.parse(trimmed); - } - } - if (args && typeof args === "object" && !Array.isArray(args)) { - return { name, args }; - } - } catch {} - return null; -} - function containsTextualToolCallMarker(text: unknown): boolean { return ( typeof text === "string" && text.replace(/[\u200B-\u200D\uFEFF]/g, "").includes("[Tool call:") @@ -253,26 +225,44 @@ export function geminiToOpenAIResponse(chunk, state) { // back to a structured OpenAI tool call so clients/tools do not see it as // assistant prose. if (part.text !== undefined && part.text !== "") { - const textualToolCall = parseTextualToolCall(part.text); - if (textualToolCall) { - emitFunctionCallPart( - { - functionCall: { - name: textualToolCall.name, - args: textualToolCall.args, + const accumulated = (state.textualToolCallBuffer || "") + part.text; + const candidate = parseTextualToolCallCandidate(accumulated); + + if (candidate) { + if (candidate.kind === "complete") { + emitFunctionCallPart( + { + functionCall: { + name: candidate.name, + args: candidate.args, + }, }, - }, - state, - results - ); + state, + results + ); + state.textualToolCallBuffer = ""; + } else { + state.textualToolCallBuffer = accumulated; + } continue; } - // Never leak a malformed textual pseudo tool-call to clients. If the - // model emits the marker but the arguments are not parseable yet/at all, - // suppress the text; the final finish reason remains `stop` unless a - // structured tool call was emitted elsewhere. - if (containsTextualToolCallMarker(part.text)) { + if (state.textualToolCallBuffer) { + const flushedText = state.textualToolCallBuffer + part.text; + state.textualToolCallBuffer = ""; + results.push({ + id: `chatcmpl-${state.messageId}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: state.model, + choices: [ + { + index: 0, + delta: { content: flushedText }, + finish_reason: null, + }, + ], + }); continue; } @@ -407,6 +397,38 @@ export function geminiToOpenAIResponse(chunk, state) { // Finish reason - include usage in final chunk if (candidate.finishReason) { + if (state.textualToolCallBuffer) { + const remainingText = state.textualToolCallBuffer; + state.textualToolCallBuffer = ""; + const textualToolCall = parseTextualToolCallCandidate(remainingText); + if (textualToolCall && textualToolCall.kind === "complete") { + emitFunctionCallPart( + { + functionCall: { + name: textualToolCall.name, + args: textualToolCall.args, + }, + }, + state, + results + ); + } else if (!containsTextualToolCallMarker(remainingText)) { + results.push({ + id: `chatcmpl-${state.messageId}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: state.model, + choices: [ + { + index: 0, + delta: { content: remainingText }, + finish_reason: null, + }, + ], + }); + } + } + let finishReason = candidate.finishReason.toLowerCase(); if (finishReason === "stop" && state.toolCalls.size > 0) { finishReason = "tool_calls"; diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 0cb878a3c9..c502c62c0a 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -30,6 +30,7 @@ import { extractThinkingFromContent, } from "../handlers/responseSanitizer.ts"; import { buildErrorBody } from "./error.ts"; +import { parseTextualToolCallCandidate, isValidToolCallHeaderPrefix } from "./textualToolCall.ts"; import { recordToolLatency } from "../services/toolLatencyTracker.ts"; import { generateSessionId, @@ -188,57 +189,6 @@ function appendBoundedText(current: string, next: string): string { return combined.slice(-STREAM_SUMMARY_TEXT_LIMIT); } -function stripZeroWidth(value: unknown): unknown { - if (typeof value === "string") { - return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); - } - if (Array.isArray(value)) { - return value.map((item) => stripZeroWidth(item)); - } - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value as Record).map(([key, item]) => [ - key, - stripZeroWidth(item), - ]) - ); - } - return value; -} - -function parseTextualToolCallCandidate( - text: unknown -): { kind: "complete"; name: string; args: unknown } | { kind: "partial" } | null { - if (typeof text !== "string") return null; - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); - const toolCallIndex = normalized.lastIndexOf("[Tool call:"); - if (toolCallIndex < 0) return null; - const candidate = normalized.slice(toolCallIndex); - const headerMatch = candidate.match(/^\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*/); - if (!headerMatch) return { kind: "partial" }; - const name = headerMatch[1]?.trim(); - const rawArgs = candidate.slice(headerMatch[0].length).trim(); - if (!name || !rawArgs) return { kind: "partial" }; - 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 { kind: "complete", name, args: stripZeroWidth(parsed) }; - } catch {} - } - return { kind: "partial" }; -} - function parseTextualToolCallFromContent(text: unknown): { name: string; args: unknown } | null { const candidate = parseTextualToolCallCandidate(text); return candidate?.kind === "complete" ? { name: candidate.name, args: candidate.args } : null; @@ -248,9 +198,33 @@ function containsTextualToolCallCandidate(text: unknown): boolean { return parseTextualToolCallCandidate(text) !== null; } -function containsMalformedTextualToolCall(text: unknown): boolean { +function containsMalformedTextualToolCall( + text: unknown, + allowedToolNames?: Set | null +): boolean { if (typeof text !== "string") return false; - return text.replace(/[\u200B-\u200D\uFEFF]/g, "").includes("[Tool call:"); + const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + + let searchIdx = 0; + while (true) { + const idx = normalized.indexOf("[Tool call:", searchIdx); + if (idx === -1) break; + + const candidate = normalized.slice(idx); + if (isValidToolCallHeaderPrefix(candidate)) { + const parsed = parseTextualToolCallFromContent(candidate); + if (parsed) { + if (allowedToolNames?.size && !allowedToolNames.has(parsed.name)) { + return true; + } + } else { + return true; + } + } + + searchIdx = idx + 1; + } + return false; } function extractAllowedToolNames(body: unknown): Set | null { @@ -903,6 +877,10 @@ export function createSSEStream(options: StreamOptions = {}) { textualToolCallConverted = true; delta.content = ""; } else { + if (passthroughBufferedTextualToolCallContent) { + delta.content = passthroughBufferedTextualToolCallContent + incomingContent; + textualToolCallConverted = true; + } passthroughAccumulatedContent = appendBoundedText( passthroughAccumulatedContent, passthroughBufferedTextualToolCallContent + incomingContent @@ -1399,6 +1377,11 @@ export function createSSEStream(options: StreamOptions = {}) { output = `data: ${JSON.stringify(parsed)}\n`; injectedUsage = true; } else { + if (passthroughBufferedTextualToolCallContent) { + parsed.delta = passthroughBufferedTextualToolCallContent + incomingDelta; + output = `data: ${JSON.stringify(parsed)}\n`; + injectedUsage = true; + } passthroughAccumulatedContent = appendBoundedText( passthroughAccumulatedContent, passthroughBufferedTextualToolCallContent + incomingDelta @@ -2088,6 +2071,54 @@ export function createSSEStream(options: StreamOptions = {}) { } clearPendingPassthroughEvent(); + if ( + passthroughBufferedTextualToolCallContent && + !passthroughBufferedTextualToolCallContent.includes("Arguments:") + ) { + let flushOutput = ""; + if (clientExpectsResponsesStream) { + const syntheticChunk = { + type: "response.output_text.delta", + delta: passthroughBufferedTextualToolCallContent, + }; + flushOutput = `data: ${JSON.stringify(syntheticChunk)}\n\n`; + } else if (clientExpectsClaudeStream) { + const syntheticChunk = { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: passthroughBufferedTextualToolCallContent, + }, + }; + flushOutput = `data: ${JSON.stringify(syntheticChunk)}\n\n`; + } else { + const syntheticChunk = { + id: passthroughResponsesId || `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: model || "unknown", + choices: [ + { + index: 0, + delta: { + content: passthroughBufferedTextualToolCallContent, + }, + finish_reason: null, + }, + ], + }; + flushOutput = `data: ${JSON.stringify(syntheticChunk)}\n\n`; + } + reqLogger?.appendConvertedChunk?.(flushOutput); + controller.enqueue(encoder.encode(flushOutput)); + passthroughAccumulatedContent = appendBoundedText( + passthroughAccumulatedContent, + passthroughBufferedTextualToolCallContent + ); + passthroughBufferedTextualToolCallContent = ""; + } + // Estimate usage if provider didn't return valid usage if (!hasValidUsage(usage) && totalContentLength > 0) { usage = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI); @@ -2141,7 +2172,7 @@ export function createSSEStream(options: StreamOptions = {}) { ) { passthroughHasToolCalls = true; content = ""; - } else if (containsMalformedTextualToolCall(content)) { + } else if (containsMalformedTextualToolCall(content, allowedToolNames)) { content = ""; } const message: Record = { @@ -2392,7 +2423,7 @@ export function createSSEStream(options: StreamOptions = {}) { }, }); content = ""; - } else if (containsMalformedTextualToolCall(content)) { + } else if (containsMalformedTextualToolCall(content, allowedToolNames)) { content = ""; } const message: Record = { diff --git a/open-sse/utils/textualToolCall.ts b/open-sse/utils/textualToolCall.ts new file mode 100644 index 0000000000..160f93e3d6 --- /dev/null +++ b/open-sse/utils/textualToolCall.ts @@ -0,0 +1,101 @@ +export function stripZeroWidth(value: unknown): unknown { + if (typeof value === "string") { + return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); + } + if (Array.isArray(value)) { + return value.map((item) => stripZeroWidth(item)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + key, + stripZeroWidth(item), + ]) + ); + } + return value; +} + +export function isValidToolCallHeaderPrefix(candidate: string): boolean { + if (!candidate.startsWith("[Tool call:")) return false; + + const bracketIndex = candidate.indexOf("]"); + if (bracketIndex === -1) { + const namePart = candidate.slice("[Tool call:".length); + if (namePart.includes("\n") || namePart.includes("[")) return false; + return true; + } + + const namePart = candidate.slice("[Tool call:".length, bracketIndex); + if (namePart.includes("\n") || namePart.trim().length === 0) return false; + + const afterBracket = candidate.slice(bracketIndex + 1); + const leadingWhitespaceMatch = afterBracket.match(/^[\s\r\n]*/); + const leadingWhitespace = leadingWhitespaceMatch ? leadingWhitespaceMatch[0] : ""; + const textAfterWhitespace = afterBracket.slice(leadingWhitespace.length); + + if (textAfterWhitespace.length === 0) { + return true; + } + + if (!leadingWhitespace.includes("\n")) { + return false; + } + + const expectedText = "Arguments:"; + if (expectedText.startsWith(textAfterWhitespace)) { + return true; + } + + if (textAfterWhitespace.startsWith(expectedText)) { + return true; + } + + return false; +} + +export function parseTextualToolCallCandidate( + text: unknown +): { kind: "complete"; name: string; args: unknown } | { kind: "partial" } | null { + if (typeof text !== "string") return null; + const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const toolCallIndex = normalized.lastIndexOf("[Tool call:"); + if (toolCallIndex < 0) { + const lastBracket = normalized.lastIndexOf("["); + if (lastBracket !== -1 && "[Tool call:".startsWith(normalized.slice(lastBracket))) { + return { kind: "partial" }; + } + const lastParen = normalized.lastIndexOf("("); + if (lastParen !== -1 && "(empty)[Tool call:".startsWith(normalized.slice(lastParen))) { + return { kind: "partial" }; + } + return null; + } + const candidate = normalized.slice(toolCallIndex); + if (!isValidToolCallHeaderPrefix(candidate)) { + return null; + } + const headerMatch = candidate.match(/^\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*/); + if (!headerMatch) return { kind: "partial" }; + const name = headerMatch[1]?.trim(); + const rawArgs = candidate.slice(headerMatch[0].length).trim(); + if (!name || !rawArgs) return { kind: "partial" }; + 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 { kind: "complete", name, args: stripZeroWidth(parsed) }; + } catch {} + } + return { kind: "partial" }; +} diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index b921808d95..ea36a217a2 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -69,6 +69,22 @@ export const MODEL_SPECS: Record = { aliases: ["openai/gpt-4o"], }, + // ── Gemini 2.5 and 3.5 Flash series ────────────────────────────── + "gemini-2.5-flash": { + maxOutputTokens: 65536, + contextWindow: 1048576, + supportsThinking: false, + supportsTools: true, + supportsVision: true, + }, + "gemini-3.5-flash-low": { + maxOutputTokens: 65536, + contextWindow: 1048576, + supportsThinking: false, + supportsTools: true, + supportsVision: true, + }, + // ── Gemini 3 Flash series ─────────────────────────────────────── "gemini-3-flash": { maxOutputTokens: 65536, diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index f8ab86f723..cddeab67b9 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -232,6 +232,69 @@ test("createSSEStream passthrough converts split textual tool-call content at co assert.doesNotMatch(text, /\[Tool call: terminal\]/); }); +test("createSSEStream passthrough handles textual tool-call content split inside the prefix [Tool call: across chunks", async () => { + let onCompletePayload = null; + const splitToolArgs = JSON.stringify({ + command: "whoami", + }); + const chunks = ["[Tool", " call: terminal]\n", `Arguments: ${splitToolArgs}`]; + + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_split_prefix_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_prefix_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_prefix_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "antigravity/gemini-3.5-flash-low", + choices: [{ index: 0, delta: { content: chunks[2] } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_split_prefix_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; + }, + } + ); + + assert.doesNotMatch(text, /"content":"\[Tool/); + assert.doesNotMatch(text, /"content":" call:/); + assert.match(text, /"tool_calls":\[/); + assert.match(text, /"name":"terminal"/); + 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: "whoami", + }); +}); + test("createSSEStream passthrough buffers fragmented textual tool-call JSON before emitting", async () => { let onCompletePayload = null; const text = await readTransformed( @@ -1658,3 +1721,91 @@ test("createSSEStream passthrough logs empty response after tool_calls completio // Content should be null (empty) since no text was generated assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); }); + +test("createSSEStream passthrough does not swallow false positive textual tool call", async () => { + let onCompletePayload = null; + const sentence = "Checking: [Tool call: terminal] was executed successfully."; + + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_false_positive_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "MainAgent", + choices: [{ index: 0, delta: { role: "assistant", content: sentence } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_false_positive_textual_tool", + object: "chat.completion.chunk", + created: 1, + model: "MainAgent", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "omniroute", + model: "MainAgent", + body: { messages: [{ role: "user", content: "inspect status" }] }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + const choice = onCompletePayload.responseBody.choices[0]; + assert.equal(choice.finish_reason, "stop"); + assert.equal(choice.message.content, sentence); + assert.equal(choice.message.tool_calls, undefined); + assert.match(text, /\[Tool call: terminal\] was executed successfully/); +}); + +test("createSSEStream passthrough does not swallow false positive textual tool call starting chunk", async () => { + let onCompletePayload = null; + const chunk1 = "[Tool call: terminal]"; + const chunk2 = " was skipped."; + + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_false_positive_textual_tool_start", + object: "chat.completion.chunk", + created: 1, + model: "MainAgent", + choices: [{ index: 0, delta: { role: "assistant", content: chunk1 } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_false_positive_textual_tool_start", + object: "chat.completion.chunk", + created: 1, + model: "MainAgent", + choices: [{ index: 0, delta: { content: chunk2 } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_false_positive_textual_tool_start", + object: "chat.completion.chunk", + created: 1, + model: "MainAgent", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "omniroute", + model: "MainAgent", + body: { messages: [{ role: "user", content: "inspect status" }] }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + const choice = onCompletePayload.responseBody.choices[0]; + assert.equal(choice.finish_reason, "stop"); + assert.equal(choice.message.content, chunk1 + chunk2); + assert.equal(choice.message.tool_calls, undefined); + assert.match(text, /\[Tool call: terminal\] was skipped/); +}); diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index 71898d7de9..ba968904bf 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -600,3 +600,103 @@ test("Gemini stream: suppresses malformed textual Tool call marker", () => { ); assert.equal(result.at(-1).choices[0].finish_reason, "stop"); }); + +test("Gemini stream: handles textual Tool call block split across chunks", () => { + const state = createStreamingState(); + const chunk1 = { + responseId: "resp-split", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "[Tool call: terminal]", + }, + ], + }, + }, + ], + }; + const chunk2 = { + responseId: "resp-split", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: '\nArguments: {"command":"whoami"}', + }, + ], + }, + finishReason: "STOP", + }, + ], + }; + + const res1 = geminiToOpenAIResponse(chunk1, state) || []; + const res2 = geminiToOpenAIResponse(chunk2, state) || []; + + const leakedContent = [...res1, ...res2] + .map((event) => event.choices?.[0]?.delta?.content || "") + .join(""); + + assert.equal(leakedContent, ""); + + const toolCalls = [...res1, ...res2].flatMap( + (event) => event.choices?.[0]?.delta?.tool_calls || [] + ); + assert.equal(toolCalls.length, 1); + assert.equal(toolCalls[0].function.name, "terminal"); + assert.equal(toolCalls[0].function.arguments, JSON.stringify({ command: "whoami" })); +}); + +test("Gemini stream: does not swallow false positive textual tool call in backticks", () => { + const state = createStreamingState(); + const chunk1 = { + responseId: "resp-false-positive", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "Как исправить: `[Tool call: ", + }, + ], + }, + }, + ], + }; + const chunk2 = { + responseId: "resp-false-positive", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "terminal]` не будут проходить.", + }, + ], + }, + finishReason: "STOP", + }, + ], + }; + + const res1 = geminiToOpenAIResponse(chunk1, state) || []; + const res2 = geminiToOpenAIResponse(chunk2, state) || []; + + const leakedContent = [...res1, ...res2] + .map((event) => event.choices?.[0]?.delta?.content || "") + .join(""); + + assert.equal(leakedContent, "Как исправить: `[Tool call: terminal]` не будут проходить."); + + const toolCalls = [...res1, ...res2].flatMap( + (event) => event.choices?.[0]?.delta?.tool_calls || [] + ); + assert.equal(toolCalls.length, 0); +});