From e4675924cb2d73ebfd188d4f98a27c766161e5c2 Mon Sep 17 00:00:00 2001 From: Raxxoor Date: Thu, 11 Jun 2026 04:01:31 +0100 Subject: [PATCH] fix(gemini): isolate textual reasoning wrappers (#3605) Split-out PR C from #3584. Isolates textual reasoning wrappers (///, 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! --- open-sse/handlers/responseSanitizer.ts | 31 ++- .../translator/request/openai-to-gemini.ts | 2 +- .../translator/response/gemini-to-openai.ts | 193 +++++++++++++++++- tests/unit/response-sanitizer.test.ts | 17 ++ .../translator-resp-gemini-to-openai.test.ts | 127 +++++++++++- 5 files changed, 349 insertions(+), 21 deletions(-) diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 2c26a06480..112673127a 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -200,8 +200,20 @@ function hasVisibleMessageContent(content: unknown): boolean { }); } -// Matches ... blocks and ... (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 /// 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 "]*)?(?:>|\\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; diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 3a9dc8a09d..8cf9954602 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -77,7 +77,7 @@ type GeminiRequest = { type CloudCodeEnvelope = { project: string; - model: string; + model?: string; user_prompt_id?: string; userAgent?: "antigravity" | "jetski" | string; requestId?: string; diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index ac3e3d00ed..a99c3d671b 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -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; toolNameMap?: Map; 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 = ["") || 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("") || suffix.includes("\n") || suffix.includes("\r")) return -1; + return ``.startsWith(suffix) ? lastClose : -1; +} + +function consumeTextualReasoningTags( + text: string, + state: GeminiToOpenAIState, + results: Array> +): string { + const pendingTagBuffer = state.textualReasoningTagBuffer || ""; + + if (state.activeTextualReasoningTag && pendingTagBuffer.startsWith("`; + 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(``, "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(``, "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> +): 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>, + 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 = ""; diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 5c716aebd5..64e0e5ab27 100644 --- a/tests/unit/response-sanitizer.test.ts +++ b/tests/unit/response-sanitizer.test.ts @@ -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§ { const sanitized = sanitizeOpenAIResponse({ model: "gpt-4.1", diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index 8398903ce5..d1946672af 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -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§ event.choices?.[0]?.delta?.content?.includes(" 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§ 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 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, "{}"); }); - - -