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:
Raxxoor
2026-06-11 04:01:31 +01:00
committed by GitHub
parent 43c312abc9
commit e4675924cb
5 changed files with 349 additions and 21 deletions

View File

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

View File

@@ -77,7 +77,7 @@ type GeminiRequest = {
type CloudCodeEnvelope = {
project: string;
model: string;
model?: string;
user_prompt_id?: string;
userAgent?: "antigravity" | "jetski" | string;
requestId?: string;

View File

@@ -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 = "";

View File

@@ -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",

View File

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