mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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:
@@ -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";
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
101
open-sse/utils/textualToolCall.ts
Normal file
101
open-sse/utils/textualToolCall.ts
Normal 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" };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user