mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(stream): resolve index mismatch in textual tool-call slicing and deduplicate containsTextualToolCallMarker (#3413)
Integrated into release/v3.8.17
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
buildGeminiThoughtSignatureKey,
|
||||
storeGeminiThoughtSignature,
|
||||
} from "../services/geminiThoughtSignatureStore.ts";
|
||||
import { containsTextualToolCallMarker } from "../utils/textualToolCall.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:");
|
||||
}
|
||||
|
||||
@@ -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, "{}");
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user