diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index babb19d1cf..592d9c2565 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -3,6 +3,7 @@ import { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature, } from "../services/geminiThoughtSignatureStore.ts"; +import { containsTextualToolCallMarker } from "../utils/textualToolCall.ts"; type JsonRecord = Record; @@ -75,17 +76,6 @@ function parseTextualToolCall(text: unknown): { name: string; args: unknown } | return null; } -function containsTextualToolCallMarker(text: unknown): boolean { - if (typeof text !== "string") return false; - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); - - if (!normalized.includes("[Tool call:")) return false; - if (normalized.includes("Arguments:")) return true; - - const trimmed = normalized.trim(); - return trimmed.startsWith("[Tool call:") || trimmed.startsWith("(empty)[Tool call:"); -} - function extractMessageOutputText(item: JsonRecord): string { if (!Array.isArray(item.content)) return ""; let text = ""; diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 43b681ec7c..ac3e3d00ed 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -4,7 +4,10 @@ import { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature, } from "../../services/geminiThoughtSignatureStore.ts"; -import { parseTextualToolCallCandidate } from "../../utils/textualToolCall.ts"; +import { + parseTextualToolCallCandidate, + containsTextualToolCallMarker, +} from "../../utils/textualToolCall.ts"; type GeminiToOpenAIState = { functionIndex: number; @@ -37,17 +40,6 @@ function normalizeToolCallArgs(args: unknown): unknown { } } -function containsTextualToolCallMarker(text: unknown): boolean { - if (typeof text !== "string") return false; - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); - - if (!normalized.includes("[Tool call:")) return false; - if (normalized.includes("Arguments:")) return true; - - const trimmed = normalized.trim(); - return trimmed.startsWith("[Tool call:") || trimmed.startsWith("(empty)[Tool call:"); -} - function buildToolCallId( functionCall: GeminiFunctionCallPart["functionCall"], toolName: string, @@ -236,28 +228,25 @@ export function geminiToOpenAIResponse(chunk, state) { if (part.text !== undefined && part.text !== "") { let accumulated = (state.textualToolCallBuffer || "") + part.text; - let candidate = null; - if (!state.hasEmittedContent || state.textualToolCallBuffer) { - candidate = parseTextualToolCallCandidate(accumulated); - } + let candidate = parseTextualToolCallCandidate(accumulated); if (candidate) { - const normalized = accumulated.replace(/[​-‍]/g, ""); - let toolCallIndex = normalized.lastIndexOf("[Tool call:"); + accumulated = accumulated.replace(/[\u200B-\u200D\uFEFF]/g, ""); + let toolCallIndex = accumulated.lastIndexOf("(empty)[Tool call:"); if (toolCallIndex < 0) { - toolCallIndex = normalized.lastIndexOf("(empty)[Tool call:"); + toolCallIndex = accumulated.lastIndexOf("[Tool call:"); } if (toolCallIndex < 0) { - const lastBracket = normalized.lastIndexOf("["); - if (lastBracket !== -1 && "[Tool call:".startsWith(normalized.slice(lastBracket))) { - toolCallIndex = lastBracket; + const lastParen = accumulated.lastIndexOf("("); + if ( + lastParen !== -1 && + "(empty)[Tool call:".startsWith(accumulated.slice(lastParen)) + ) { + toolCallIndex = lastParen; } else { - const lastParen = normalized.lastIndexOf("("); - if ( - lastParen !== -1 && - "(empty)[Tool call:".startsWith(normalized.slice(lastParen)) - ) { - toolCallIndex = lastParen; + const lastBracket = accumulated.lastIndexOf("["); + if (lastBracket !== -1 && "[Tool call:".startsWith(accumulated.slice(lastBracket))) { + toolCallIndex = lastBracket; } } } diff --git a/open-sse/utils/textualToolCall.ts b/open-sse/utils/textualToolCall.ts index 160f93e3d6..67c7fde1a2 100644 --- a/open-sse/utils/textualToolCall.ts +++ b/open-sse/utils/textualToolCall.ts @@ -61,14 +61,14 @@ export function parseTextualToolCallCandidate( 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" }; } + const lastBracket = normalized.lastIndexOf("["); + if (lastBracket !== -1 && "[Tool call:".startsWith(normalized.slice(lastBracket))) { + return { kind: "partial" }; + } return null; } const candidate = normalized.slice(toolCallIndex); @@ -99,3 +99,14 @@ export function parseTextualToolCallCandidate( } return { kind: "partial" }; } + +export function containsTextualToolCallMarker(text: unknown): boolean { + if (typeof text !== "string") return false; + const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + + if (!normalized.includes("[Tool call:")) return false; + if (normalized.includes("Arguments:")) return true; + + const trimmed = normalized.trim(); + return trimmed.startsWith("[Tool call:") || trimmed.startsWith("(empty)[Tool call:"); +} diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index 094390807e..8398903ce5 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -814,3 +814,200 @@ test("Gemini stream: splits mid-stream partial candidate but preserves tool call assert.equal(toolCall.function.name, "read_file"); assert.equal(toolCall.function.arguments, '{"path":"/tmp/a"}'); }); + +test("Gemini stream: index mismatch regression test with zero-width characters in prefix", () => { + const state = createStreamingState(); + const result = geminiToOpenAIResponse( + { + responseId: "resp-textual-tool-index-mismatch", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "\u200BКак исправить: [Tool call: terminal]\nArguments: {\"command\":\"whoami\"}", + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + const leakedContent = result.map((event: any) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(leakedContent, "Как исправить: "); + + const toolCalls = result.flatMap((event: any) => event.choices?.[0]?.delta?.tool_calls || []); + assert.equal(toolCalls.length, 1); + assert.equal(toolCalls[0].function.name, "terminal"); + assert.equal(toolCalls[0].function.arguments, '{"command":"whoami"}'); +}); + +test("Gemini stream: partial tool call with (empty) prefix check at chunk end does not leak (empty)", () => { + const state = createStreamingState(); + const chunk1 = { + responseId: "resp-empty-leak", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "Результат: (empty)[Tool ca", + }, + ], + }, + }, + ], + }; + + const chunk2 = { + candidates: [ + { + content: { + parts: [ + { + text: "ll: terminal]\nArguments: {\"command\":\"whoami\"}", + }, + ], + }, + finishReason: "STOP", + }, + ], + }; + + const res1 = geminiToOpenAIResponse(chunk1, state) || []; + const content1 = res1.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content1, "Результат: "); // (empty) must be buffered, not leaked! + + const res2 = geminiToOpenAIResponse(chunk2, state) || []; + const content2 = res2.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content2, ""); + + assert.equal(state.toolCalls.size, 1); + const toolCall: any = Array.from(state.toolCalls.values())[0]; + assert.equal(toolCall.function.name, "terminal"); + assert.equal(toolCall.function.arguments, '{"command":"whoami"}'); +}); + +test("Gemini stream: parses textual tool call that starts in a subsequent chunk after prose has been emitted", () => { + const state = createStreamingState() as any; + const chunk1 = { + responseId: "resp-test-after-prose", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "Generating response now... ", + }, + ], + }, + }, + ], + }; + + const chunk2 = { + candidates: [ + { + content: { + parts: [ + { + text: '[Tool call: web_search]\nArguments: {"query": "AI news"}', + }, + ], + }, + finishReason: "STOP", + }, + ], + }; + + const res1 = geminiToOpenAIResponse(chunk1, state) || []; + const content1 = res1.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content1, "Generating response now... "); + + const res2 = geminiToOpenAIResponse(chunk2, state) || []; + const content2 = res2.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content2, ""); + + assert.equal(state.toolCalls.size, 1); + const toolCall: any = Array.from(state.toolCalls.values())[0]; + assert.equal(toolCall.function.name, "web_search"); + assert.equal(toolCall.function.arguments, '{"query":"AI news"}'); +}); + +test("Gemini stream: checks lastParen before lastBracket when identifying partial (empty) markers with distinct chuncks", () => { + const state = createStreamingState() as any; + + // Имитируем чанк, который кончается на частичный "(empty)[Tool call:" маркер, например "(em" + const chunk1 = { + responseId: "resp-test-empty-partial", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { + content: { + parts: [ + { + text: "Result is here: (em", + }, + ], + }, + }, + ], + }; + + // Имитируем чанк, который содержит и "(", и "[", кончаясь на "(empty)[Tool" + // Если бы мы проверяли lastBracket первым, мы бы отрезали по "[", оставив "(empty)" утекать пользователю. + const chunk2 = { + candidates: [ + { + content: { + parts: [ + { + text: "pty)[Tool", + }, + ], + }, + }, + ], + }; + + const chunk3 = { + candidates: [ + { + content: { + parts: [ + { + text: ' call: my_tool]\nArguments: {}', + }, + ], + }, + finishReason: "STOP", + }, + ], + }; + + const res1 = geminiToOpenAIResponse(chunk1, state) || []; + const content1 = res1.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content1, "Result is here: "); // (em задерживается в буфере + + const res2 = geminiToOpenAIResponse(chunk2, state) || []; + const content2 = res2.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content2, ""); // (empty)[Tool задерживается в буфере полностью, (empty) не утекает + + const res3 = geminiToOpenAIResponse(chunk3, state) || []; + const content3 = res3.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(content3, ""); + + assert.equal(state.toolCalls.size, 1); + const toolCall: any = Array.from(state.toolCalls.values())[0]; + assert.equal(toolCall.function.name, "my_tool"); + assert.equal(toolCall.function.arguments, "{}"); +}); + + +