fix(gemini): resolve truncation/suppression of false positive textual tool call markers in backticks (#3358)

Integrated into release/v3.8.14 — Gemini/Antigravity textual tool-call marker normalization (no false-positive suppression + split-chunk buffering).
This commit is contained in:
Ardem2025
2026-06-07 13:10:13 +03:00
committed by GitHub
parent 95cdde5f90
commit cb46aeb96c
6 changed files with 522 additions and 101 deletions

View File

@@ -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<number, unknown>;
toolNameMap?: Map<string, string>;
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";

View File

@@ -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<string, unknown>).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<string> | 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<string> | 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<string, unknown> = {
@@ -2392,7 +2423,7 @@ export function createSSEStream(options: StreamOptions = {}) {
},
});
content = "";
} else if (containsMalformedTextualToolCall(content)) {
} else if (containsMalformedTextualToolCall(content, allowedToolNames)) {
content = "";
}
const message: Record<string, unknown> = {

View File

@@ -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<string, unknown>).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" };
}

View File

@@ -69,6 +69,22 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
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,

View File

@@ -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/);
});

View File

@@ -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);
});