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("");
+ 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>
+): 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>
+): 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, "{}");
});
-
-
-