mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
fix(gemini): isolate textual reasoning wrappers (#3605)
Split-out PR C from #3584. Isolates textual reasoning wrappers (<think>/<thinking>/<thought>/<internal_thought>, including malformed/open tags) into reasoning_content across both the non-streaming sanitizer and the Gemini streaming translator, with split-chunk buffering. Additive to the existing textual tool-call pipeline; does not touch the #3569 native functionResponse path. Integrated into release/v3.8.21. Thanks @dhaern!
This commit is contained in:
@@ -200,8 +200,20 @@ function hasVisibleMessageContent(content: unknown): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
// Matches <think>...</think> blocks and <thinking>...</thinking> (greedy, dotAll)
|
||||
const THINK_TAG_REGEX = /<(?:think|thinking)>([\s\S]*?)<\/(?:think|thinking)>/gi;
|
||||
const REASONING_TAG_NAMES = ["think", "thinking", "thought", "internal_thought"];
|
||||
const REASONING_TAG_PATTERN = REASONING_TAG_NAMES.join("|");
|
||||
// Matches complete <think>/<thinking>/<thought>/<internal_thought> blocks.
|
||||
const THINK_TAG_REGEX = new RegExp(
|
||||
`<(${REASONING_TAG_PATTERN})\\b[^>]*>([\\s\\S]*?)<\\/\\1>`,
|
||||
"gi"
|
||||
);
|
||||
// Matches an unclosed reasoning tag at the end of a message. Some providers can
|
||||
// emit malformed/open reasoning wrappers (for example "<thought\n...") before a
|
||||
// tool call. Treat that tail as reasoning instead of visible assistant text.
|
||||
const UNCLOSED_REASONING_TAG_REGEX = new RegExp(
|
||||
`<(${REASONING_TAG_PATTERN})(?:\\s[^>]*)?(?:>|\\r?\\n)([\\s\\S]*)$`,
|
||||
"i"
|
||||
);
|
||||
|
||||
// #638, #727: Collapse runs of 2+ consecutive newlines into \n\n
|
||||
// Tool call responses from thinking models often accumulate excessive newlines
|
||||
@@ -225,7 +237,7 @@ export function extractThinkingFromContent(text: string): {
|
||||
const thinkingParts: string[] = [];
|
||||
let hasThinkTags = false;
|
||||
|
||||
const cleaned = text.replace(THINK_TAG_REGEX, (_, thinkContent) => {
|
||||
let cleaned = text.replace(THINK_TAG_REGEX, (_match, _tagName, thinkContent) => {
|
||||
hasThinkTags = true;
|
||||
const trimmed = thinkContent.trim();
|
||||
if (trimmed) {
|
||||
@@ -234,6 +246,15 @@ export function extractThinkingFromContent(text: string): {
|
||||
return "";
|
||||
});
|
||||
|
||||
const unclosedMatch = cleaned.match(UNCLOSED_REASONING_TAG_REGEX);
|
||||
if (unclosedMatch?.index !== undefined) {
|
||||
hasThinkTags = true;
|
||||
const reasoning = String(unclosedMatch[2] || "").trim();
|
||||
if (reasoning) thinkingParts.push(reasoning);
|
||||
const prefix = cleaned.slice(0, unclosedMatch.index);
|
||||
cleaned = /^(?:\s|§\d+§)*$/.test(prefix) ? "" : prefix;
|
||||
}
|
||||
|
||||
if (!hasThinkTags) {
|
||||
return { content: text, thinking: null };
|
||||
}
|
||||
@@ -987,7 +1008,9 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown {
|
||||
|
||||
// Keep only standard fields — normalize id to string to avoid AI_InvalidResponseDataError
|
||||
if (parsedRecord.id !== undefined && parsedRecord.id !== null) {
|
||||
sanitized.id = normalizeResponseId(typeof parsedRecord.id === "string" ? parsedRecord.id : String(parsedRecord.id));
|
||||
sanitized.id = normalizeResponseId(
|
||||
typeof parsedRecord.id === "string" ? parsedRecord.id : String(parsedRecord.id)
|
||||
);
|
||||
}
|
||||
sanitized.object = toString(parsedRecord.object) || "chat.completion.chunk";
|
||||
if (parsedRecord.created !== undefined) sanitized.created = parsedRecord.created;
|
||||
|
||||
@@ -77,7 +77,7 @@ type GeminiRequest = {
|
||||
|
||||
type CloudCodeEnvelope = {
|
||||
project: string;
|
||||
model: string;
|
||||
model?: string;
|
||||
user_prompt_id?: string;
|
||||
userAgent?: "antigravity" | "jetski" | string;
|
||||
requestId?: string;
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
|
||||
type GeminiToOpenAIState = {
|
||||
functionIndex: number;
|
||||
finishReason?: string;
|
||||
groundingProcessed?: boolean;
|
||||
hasEmittedContent?: boolean;
|
||||
messageId: string;
|
||||
model: string;
|
||||
pendingThoughtSignature?: string | null;
|
||||
@@ -18,7 +21,20 @@ type GeminiToOpenAIState = {
|
||||
toolCalls: Map<number, unknown>;
|
||||
toolNameMap?: Map<string, string>;
|
||||
textualToolCallBuffer?: string;
|
||||
hasEmittedContent?: boolean;
|
||||
textualReasoningTagBuffer?: string;
|
||||
activeTextualReasoningTag?: string;
|
||||
textualReasoningContentBuffer?: string;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
prompt_tokens_details?: {
|
||||
cached_tokens: number;
|
||||
};
|
||||
completion_tokens_details?: {
|
||||
reasoning_tokens: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type GeminiFunctionCallPart = {
|
||||
@@ -29,6 +45,162 @@ type GeminiFunctionCallPart = {
|
||||
};
|
||||
};
|
||||
|
||||
const REASONING_TAG_OPEN_REGEX =
|
||||
/<(think|thinking|thought|internal_thought)(?=\s|>|\r?\n)(?:\s[^>]*)?(?:>|\r?\n)/i;
|
||||
const REASONING_TAG_OPEN_PREFIXES = ["<think", "<thinking", "<thought", "<internal_thought"];
|
||||
|
||||
function isIgnorableReasoningTagPrefix(value: string): boolean {
|
||||
return /^(?:\s|§\d+§)*$/.test(value);
|
||||
}
|
||||
|
||||
function getTrailingReasoningTagPrefixStart(text: string): number {
|
||||
const lastOpen = text.lastIndexOf("<");
|
||||
if (lastOpen < 0) return -1;
|
||||
const suffix = text.slice(lastOpen).toLowerCase();
|
||||
if (!suffix || suffix.includes(">") || suffix.includes("\n") || suffix.includes("\r")) return -1;
|
||||
return REASONING_TAG_OPEN_PREFIXES.some((prefix) => prefix.startsWith(suffix)) ? lastOpen : -1;
|
||||
}
|
||||
|
||||
function getTrailingReasoningCloseTagPrefixStart(text: string, tagName: string): number {
|
||||
const lastClose = text.lastIndexOf("</");
|
||||
if (lastClose < 0) return -1;
|
||||
const suffix = text.slice(lastClose).toLowerCase();
|
||||
if (!suffix || suffix.includes(">") || suffix.includes("\n") || suffix.includes("\r")) return -1;
|
||||
return `</${tagName.toLowerCase()}>`.startsWith(suffix) ? lastClose : -1;
|
||||
}
|
||||
|
||||
function consumeTextualReasoningTags(
|
||||
text: string,
|
||||
state: GeminiToOpenAIState,
|
||||
results: Array<Record<string, unknown>>
|
||||
): string {
|
||||
const pendingTagBuffer = state.textualReasoningTagBuffer || "";
|
||||
|
||||
if (state.activeTextualReasoningTag && pendingTagBuffer.startsWith("</")) {
|
||||
const combinedClose = `${pendingTagBuffer}${text}`;
|
||||
const closeTag = `</${state.activeTextualReasoningTag}>`;
|
||||
const lowerCombinedClose = combinedClose.toLowerCase();
|
||||
const lowerCloseTag = closeTag.toLowerCase();
|
||||
|
||||
if (lowerCombinedClose.startsWith(lowerCloseTag)) {
|
||||
emitTextDelta(state.textualReasoningContentBuffer || "", state, results, "reasoning_content");
|
||||
state.activeTextualReasoningTag = undefined;
|
||||
state.textualReasoningContentBuffer = undefined;
|
||||
state.textualReasoningTagBuffer = undefined;
|
||||
return combinedClose.slice(closeTag.length);
|
||||
}
|
||||
|
||||
if (lowerCloseTag.startsWith(lowerCombinedClose)) {
|
||||
state.textualReasoningTagBuffer = combinedClose;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
let remaining = `${state.textualReasoningTagBuffer || ""}${text}`;
|
||||
state.textualReasoningTagBuffer = undefined;
|
||||
|
||||
while (remaining) {
|
||||
if (state.activeTextualReasoningTag) {
|
||||
const bufferedReasoning = `${state.textualReasoningContentBuffer || ""}${remaining}`;
|
||||
const closeRegex = new RegExp(`</${state.activeTextualReasoningTag}>`, "i");
|
||||
const closeMatch = closeRegex.exec(bufferedReasoning);
|
||||
if (!closeMatch || closeMatch.index < 0) {
|
||||
const partialCloseStart = getTrailingReasoningCloseTagPrefixStart(
|
||||
bufferedReasoning,
|
||||
state.activeTextualReasoningTag
|
||||
);
|
||||
if (partialCloseStart >= 0) {
|
||||
state.textualReasoningContentBuffer = bufferedReasoning.slice(0, partialCloseStart);
|
||||
state.textualReasoningTagBuffer = bufferedReasoning.slice(partialCloseStart);
|
||||
return "";
|
||||
}
|
||||
state.textualReasoningContentBuffer = bufferedReasoning;
|
||||
return "";
|
||||
}
|
||||
|
||||
emitTextDelta(
|
||||
bufferedReasoning.slice(0, closeMatch.index),
|
||||
state,
|
||||
results,
|
||||
"reasoning_content"
|
||||
);
|
||||
state.activeTextualReasoningTag = undefined;
|
||||
state.textualReasoningContentBuffer = undefined;
|
||||
const closeEnd = bufferedReasoning.indexOf(">", closeMatch.index);
|
||||
remaining = bufferedReasoning.slice(
|
||||
closeEnd >= 0 ? closeEnd + 1 : closeMatch.index + closeMatch[0].length
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const openMatch = REASONING_TAG_OPEN_REGEX.exec(remaining);
|
||||
if (!openMatch || openMatch.index < 0) {
|
||||
const partialStart = getTrailingReasoningTagPrefixStart(remaining);
|
||||
if (partialStart >= 0) {
|
||||
state.textualReasoningTagBuffer = remaining.slice(partialStart);
|
||||
const prefix = remaining.slice(0, partialStart);
|
||||
return isIgnorableReasoningTagPrefix(prefix) ? "" : prefix;
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
const before = remaining.slice(0, openMatch.index);
|
||||
if (before && !isIgnorableReasoningTagPrefix(before)) {
|
||||
emitTextDelta(before, state, results, "content");
|
||||
}
|
||||
|
||||
const tagName = openMatch[1];
|
||||
const bodyStart = openMatch.index + openMatch[0].length;
|
||||
const afterOpen = remaining.slice(bodyStart);
|
||||
const closeRegex = new RegExp(`</${tagName}>`, "i");
|
||||
const closeMatch = closeRegex.exec(afterOpen);
|
||||
if (!closeMatch || closeMatch.index < 0) {
|
||||
state.activeTextualReasoningTag = tagName;
|
||||
state.textualReasoningContentBuffer = afterOpen;
|
||||
return "";
|
||||
}
|
||||
|
||||
emitTextDelta(afterOpen.slice(0, closeMatch.index), state, results, "reasoning_content");
|
||||
remaining = afterOpen.slice(closeMatch.index + closeMatch[0].length);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function flushOpenTextualReasoning(
|
||||
state: GeminiToOpenAIState,
|
||||
results: Array<Record<string, unknown>>
|
||||
): void {
|
||||
if (!state.activeTextualReasoningTag && !state.textualReasoningContentBuffer) return;
|
||||
emitTextDelta(state.textualReasoningContentBuffer || "", state, results, "reasoning_content");
|
||||
state.activeTextualReasoningTag = undefined;
|
||||
state.textualReasoningContentBuffer = undefined;
|
||||
state.textualReasoningTagBuffer = undefined;
|
||||
}
|
||||
|
||||
function emitTextDelta(
|
||||
content: string,
|
||||
state: GeminiToOpenAIState,
|
||||
results: Array<Record<string, unknown>>,
|
||||
field: "content" | "reasoning_content" = "content"
|
||||
) {
|
||||
if (!content) return;
|
||||
if (field === "content") state.hasEmittedContent = true;
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { [field]: content },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeToolCallArgs(args: unknown): unknown {
|
||||
if (typeof args !== "string") return args;
|
||||
const trimmed = args.trim();
|
||||
@@ -215,6 +387,9 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
}
|
||||
|
||||
if (hasFunctionCall) {
|
||||
state.activeTextualReasoningTag = undefined;
|
||||
state.textualReasoningTagBuffer = undefined;
|
||||
state.textualReasoningContentBuffer = undefined;
|
||||
emitFunctionCallPart(part, state, results);
|
||||
}
|
||||
continue;
|
||||
@@ -226,7 +401,10 @@ 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 !== "") {
|
||||
let accumulated = (state.textualToolCallBuffer || "") + part.text;
|
||||
const afterReasoning = consumeTextualReasoningTags(part.text, state, results);
|
||||
if (!afterReasoning) continue;
|
||||
|
||||
let accumulated = (state.textualToolCallBuffer || "") + afterReasoning;
|
||||
|
||||
let candidate = parseTextualToolCallCandidate(accumulated);
|
||||
|
||||
@@ -238,10 +416,7 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
}
|
||||
if (toolCallIndex < 0) {
|
||||
const lastParen = accumulated.lastIndexOf("(");
|
||||
if (
|
||||
lastParen !== -1 &&
|
||||
"(empty)[Tool call:".startsWith(accumulated.slice(lastParen))
|
||||
) {
|
||||
if (lastParen !== -1 && "(empty)[Tool call:".startsWith(accumulated.slice(lastParen))) {
|
||||
toolCallIndex = lastParen;
|
||||
} else {
|
||||
const lastBracket = accumulated.lastIndexOf("[");
|
||||
@@ -293,7 +468,7 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
}
|
||||
|
||||
if (state.textualToolCallBuffer) {
|
||||
const flushedText = state.textualToolCallBuffer + part.text;
|
||||
const flushedText = state.textualToolCallBuffer + afterReasoning;
|
||||
state.textualToolCallBuffer = "";
|
||||
state.hasEmittedContent = true;
|
||||
results.push({
|
||||
@@ -321,7 +496,7 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: part.text },
|
||||
delta: { content: afterReasoning },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
@@ -444,6 +619,8 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
|
||||
// Finish reason - include usage in final chunk
|
||||
if (candidate.finishReason) {
|
||||
flushOpenTextualReasoning(state, results);
|
||||
|
||||
if (state.textualToolCallBuffer) {
|
||||
const remainingText = state.textualToolCallBuffer;
|
||||
state.textualToolCallBuffer = "";
|
||||
|
||||
@@ -63,6 +63,23 @@ test("sanitizeOpenAIResponse extracts thinking, collapses newlines, preserves re
|
||||
assert.deepEqual((sanitized as any).choices[0].message.function_call, { name: "legacy" });
|
||||
});
|
||||
|
||||
test("sanitizeOpenAIResponse extracts unclosed reasoning wrappers into reasoning_content", () => {
|
||||
const sanitized = sanitizeOpenAIResponse({
|
||||
model: "gpt-4.1",
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "§54§ <thought\ninternal planning\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal((sanitized as any).choices[0].message.content, "");
|
||||
assert.equal((sanitized as any).choices[0].message.reasoning_content, "internal planning");
|
||||
});
|
||||
|
||||
test("sanitizeOpenAIResponse preserves native reasoning_content when no visible content remains", () => {
|
||||
const sanitized = sanitizeOpenAIResponse({
|
||||
model: "gpt-4.1",
|
||||
|
||||
@@ -381,6 +381,118 @@ test("Gemini stream: converts textual Tool call block to structured tool_calls",
|
||||
assert.equal(result.at(-1).choices[0].finish_reason, "tool_calls");
|
||||
});
|
||||
|
||||
test("Gemini stream: routes textual reasoning tags to reasoning_content before tool calls", () => {
|
||||
const state = createStreamingState();
|
||||
const result = geminiToOpenAIResponse(
|
||||
{
|
||||
responseId: "resp-textual-thought-tool",
|
||||
modelVersion: "gemini-3.5-flash-high",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: "§54§ <thought\nNeed to inspect first.",
|
||||
},
|
||||
{
|
||||
functionCall: {
|
||||
id: "call_grep",
|
||||
name: "grep",
|
||||
args: { pattern: "Host", path: "/tmp/file" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result.some((event: any) => event.choices?.[0]?.delta?.content?.includes("<thought")),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
result.find((event: any) => event.choices?.[0]?.delta?.reasoning_content)?.choices[0].delta
|
||||
.reasoning_content,
|
||||
"Need to inspect first."
|
||||
);
|
||||
const toolCall = result.find((event: any) => event.choices?.[0]?.delta?.tool_calls)?.choices[0]
|
||||
.delta.tool_calls[0];
|
||||
assert.equal(toolCall.id, "call_grep");
|
||||
assert.equal(result.at(-1).choices[0].finish_reason, "tool_calls");
|
||||
});
|
||||
|
||||
test("Gemini stream: keeps textual reasoning hidden across split chunks", () => {
|
||||
const state = createStreamingState();
|
||||
|
||||
const first = geminiToOpenAIResponse(
|
||||
{
|
||||
responseId: "resp-split-thought",
|
||||
modelVersion: "gemini-3.5-flash-high",
|
||||
candidates: [{ content: { parts: [{ text: "§54§ <tho" }] } }],
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.equal(
|
||||
first.some((event: any) => event.choices?.[0]?.delta?.content),
|
||||
false
|
||||
);
|
||||
|
||||
const second = geminiToOpenAIResponse(
|
||||
{
|
||||
responseId: "resp-split-thought",
|
||||
modelVersion: "gemini-3.5-flash-high",
|
||||
candidates: [{ content: { parts: [{ text: "ught\nNeed to inspect" }] } }],
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.equal(
|
||||
(second ?? []).some((event: any) =>
|
||||
event.choices?.[0]?.delta?.content?.includes("Need to inspect")
|
||||
),
|
||||
false
|
||||
);
|
||||
|
||||
const third = geminiToOpenAIResponse(
|
||||
{
|
||||
responseId: "resp-split-thought",
|
||||
modelVersion: "gemini-3.5-flash-high",
|
||||
candidates: [{ content: { parts: [{ text: " more</tho" }] } }],
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.equal(
|
||||
(third ?? []).some((event: any) => event.choices?.[0]?.delta?.content?.includes("more")),
|
||||
false
|
||||
);
|
||||
|
||||
const fourth = geminiToOpenAIResponse(
|
||||
{
|
||||
responseId: "resp-split-thought",
|
||||
modelVersion: "gemini-3.5-flash-high",
|
||||
candidates: [{ content: { parts: [{ text: "ught>Visible answer" }] } }],
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.equal(
|
||||
fourth.some(
|
||||
(event: any) => event.choices?.[0]?.delta?.reasoning_content === "Need to inspect more"
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
fourth.some((event: any) => event.choices?.[0]?.delta?.content?.includes("ught>")),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
fourth.find((event: any) => event.choices?.[0]?.delta?.content)?.choices[0].delta.content,
|
||||
"Visible answer"
|
||||
);
|
||||
});
|
||||
|
||||
test("Gemini stream: converts prefixed textual Tool call block with zero-width chars", () => {
|
||||
const state = createStreamingState();
|
||||
const result = geminiToOpenAIResponse(
|
||||
@@ -826,7 +938,7 @@ test("Gemini stream: index mismatch regression test with zero-width characters i
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: "\u200BКак исправить: [Tool call: terminal]\nArguments: {\"command\":\"whoami\"}",
|
||||
text: '\u200BКак исправить: [Tool call: terminal]\nArguments: {"command":"whoami"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -837,7 +949,9 @@ test("Gemini stream: index mismatch regression test with zero-width characters i
|
||||
state
|
||||
);
|
||||
|
||||
const leakedContent = result.map((event: any) => event.choices?.[0]?.delta?.content || "").join("");
|
||||
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 || []);
|
||||
@@ -870,7 +984,7 @@ test("Gemini stream: partial tool call with (empty) prefix check at chunk end do
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: "ll: terminal]\nArguments: {\"command\":\"whoami\"}",
|
||||
text: 'll: terminal]\nArguments: {"command":"whoami"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -942,7 +1056,7 @@ test("Gemini stream: parses textual tool call that starts in a subsequent chunk
|
||||
|
||||
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",
|
||||
@@ -982,7 +1096,7 @@ test("Gemini stream: checks lastParen before lastBracket when identifying partia
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: ' call: my_tool]\nArguments: {}',
|
||||
text: " call: my_tool]\nArguments: {}",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1008,6 +1122,3 @@ test("Gemini stream: checks lastParen before lastBracket when identifying partia
|
||||
assert.equal(toolCall.function.name, "my_tool");
|
||||
assert.equal(toolCall.function.arguments, "{}");
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user