From 5f4cd53b6e6266eb30a1eac92a98dd3d4c4ba342 Mon Sep 17 00:00:00 2001
From: Randi <55005611+rdself@users.noreply.github.com>
Date: Sun, 28 Jun 2026 11:54:33 -0400
Subject: [PATCH] Scope textual thinking tag extraction (#5224)
Scope textual thinking-tag extraction to tag-native model families; preserve GEMINI_CLI registration. Resubmit of #5216 without the regression. Integrated into release/v3.8.40.
---
open-sse/handlers/chatCore.ts | 11 +-
open-sse/handlers/responseSanitizer.ts | 112 ++++++++++++------
open-sse/services/contextManager.ts | 30 +----
open-sse/transformer/responsesTransformer.ts | 57 +++++----
open-sse/translator/index.ts | 4 +-
.../translator/response/gemini-to-openai.ts | 33 ++++--
.../translator/response/kiro-to-openai.ts | 3 +-
.../translator/response/openai-responses.ts | 58 ++++-----
tests/unit/context-manager.test.ts | 32 +++++
tests/unit/response-sanitizer.test.ts | 108 ++++++++++++-----
tests/unit/responses-transformer.test.ts | 31 +++--
.../unit/responses-translation-fixes.test.ts | 36 +++++-
...resp-antigravity-thinking-boundary.test.ts | 75 ++++++++++++
.../translator-resp-kiro-to-openai.test.ts | 5 +-
.../translator-resp-openai-responses.test.ts | 30 ++++-
15 files changed, 451 insertions(+), 174 deletions(-)
create mode 100644 tests/unit/translator-resp-antigravity-thinking-boundary.test.ts
diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts
index d9b46a6d8d..15d34b6b60 100644
--- a/open-sse/handlers/chatCore.ts
+++ b/open-sse/handlers/chatCore.ts
@@ -234,7 +234,11 @@ import { buildCodexQuotaPersistence } from "./chatCore/codexQuota.ts";
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
import { translateNonStreamingResponse } from "./responseTranslator.ts";
import { extractUsageFromResponse } from "./usageExtractor.ts";
-import { sanitizeOpenAIResponse, sanitizeResponsesApiResponse } from "./responseSanitizer.ts";
+import {
+ sanitizeOpenAIResponse,
+ sanitizeResponsesApiResponse,
+ shouldParseTextualReasoningTags,
+} from "./responseSanitizer.ts";
import {
withRateLimit,
updateFromHeaders,
@@ -3613,7 +3617,10 @@ export async function handleChatCore({
// that non-standard field. Reasoning replay cache is captured above this
// sanitize step, so the cache feature is unaffected.
const stripReasoning = isStripReasoningRequested(clientRawRequest?.headers ?? null);
- translatedResponse = sanitizeOpenAIResponse(translatedResponse, { stripReasoning });
+ translatedResponse = sanitizeOpenAIResponse(translatedResponse, {
+ stripReasoning,
+ parseTextualReasoningTags: shouldParseTextualReasoningTags(provider, model),
+ });
}
applyClientUsageBuffer(translatedResponse, body, clientResponseFormat);
diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts
index 62e165f40c..aaa216569b 100644
--- a/open-sse/handlers/responseSanitizer.ts
+++ b/open-sse/handlers/responseSanitizer.ts
@@ -10,7 +10,7 @@ import { normalizeOpenAICompatibleFinishReason } from "../utils/finishReason.ts"
* Fixes Issues:
* 1. Strips non-standard fields (x_groq, usage_breakdown, service_tier) that
* break OpenAI Python SDK v1.83+ Pydantic validation (returns str instead of object)
- * 2. Extracts tags from thinking models into reasoning_content
+ * 2. Optionally extracts native textual reasoning tags from known tag-style models
* 3. Normalizes response id, object, and usage fields
* 4. Converts developer role → system for non-OpenAI providers
*/
@@ -32,6 +32,7 @@ const ALLOWED_RESPONSES_USAGE_FIELDS = new Set([
]);
type JsonRecord = Record;
+type ParseOptions = { parseTextualReasoningTags?: boolean };
export const OMIT_STREAMING_CHUNK_MARKER = "__omniroute_omit_streaming_chunk";
@@ -297,6 +298,37 @@ export function extractThinkingFromContent(text: string): {
};
}
+function normalizeReasoningRouteId(value: unknown): string {
+ return typeof value === "string" ? value.toLowerCase() : "";
+}
+
+function isAntigravityReasoningRoute(providerId: string, modelId: string): boolean {
+ return (
+ providerId.includes("antigravity") ||
+ providerId === "agy" ||
+ modelId.includes("antigravity/") ||
+ modelId.startsWith("agy/")
+ );
+}
+
+function isTextualReasoningTagNativeRoute(providerId: string, modelId: string): boolean {
+ const routeId = `${providerId}/${modelId}`;
+ return (
+ /deepseek[-_/]?r1\b/.test(routeId) ||
+ /r1[-_/]?distill\b/.test(routeId) ||
+ /(?:^|[/:_-])qwq(?:[/._:-]|$)/.test(routeId)
+ );
+}
+
+export function shouldParseTextualReasoningTags(provider?: unknown, model?: unknown): boolean {
+ const providerId = normalizeReasoningRouteId(provider);
+ const modelId = normalizeReasoningRouteId(model);
+ return (
+ !isAntigravityReasoningRoute(providerId, modelId) &&
+ isTextualReasoningTagNativeRoute(providerId, modelId)
+ );
+}
+
/**
* Sanitize a non-streaming OpenAI ChatCompletion response.
* Strips non-standard fields and normalizes required fields.
@@ -311,6 +343,12 @@ export interface SanitizeOpenAIResponseOptions {
* Ported from upstream 9router#517 (closes upstream #509).
*/
stripReasoning?: boolean;
+ /**
+ * Keep disabled for generic OpenAI-compatible responses: prompt-format tags
+ * can be user-requested visible content. Enable only for routes/models whose
+ * upstream contract uses textual tags as the native reasoning channel.
+ */
+ parseTextualReasoningTags?: boolean;
}
export function sanitizeOpenAIResponse(
@@ -320,6 +358,7 @@ export function sanitizeOpenAIResponse(
const bodyRecord = toRecord(body);
if (!bodyRecord) return body;
const stripReasoning = options.stripReasoning === true;
+ const parseTextualReasoningTags = options.parseTextualReasoningTags === true;
// Build sanitized response with only allowed top-level fields
const sanitized: JsonRecord = {};
@@ -333,7 +372,7 @@ export function sanitizeOpenAIResponse(
// Sanitize choices
if (Array.isArray(bodyRecord.choices)) {
sanitized.choices = bodyRecord.choices.map((choice, idx) => {
- const sanitizedChoice = sanitizeChoice(choice, idx);
+ const sanitizedChoice = sanitizeChoice(choice, idx, { parseTextualReasoningTags });
const message = toRecord(sanitizedChoice.message);
if (
message &&
@@ -426,7 +465,11 @@ export function sanitizeResponsesApiResponse(body: unknown): unknown {
/**
* Sanitize a single choice object.
*/
-function sanitizeChoice(choice: unknown, defaultIndex: number): JsonRecord {
+function sanitizeChoice(
+ choice: unknown,
+ defaultIndex: number,
+ options: ParseOptions = {}
+): JsonRecord {
const choiceRecord = toRecord(choice);
const sanitized: JsonRecord = {
index: defaultIndex,
@@ -441,15 +484,13 @@ function sanitizeChoice(choice: unknown, defaultIndex: number): JsonRecord {
sanitized.finish_reason = normalizeOpenAICompatibleFinishReason(choiceRecord.finish_reason);
}
- // Sanitize message (non-streaming) or delta (streaming)
if (choiceRecord?.message !== undefined) {
- sanitized.message = sanitizeMessage(choiceRecord.message);
+ sanitized.message = sanitizeMessage(choiceRecord.message, options);
}
if (choiceRecord?.delta !== undefined) {
- sanitized.delta = sanitizeMessage(choiceRecord.delta);
+ sanitized.delta = sanitizeMessage(choiceRecord.delta, options);
}
- // Keep logprobs if present
if (choiceRecord?.logprobs !== undefined) {
sanitized.logprobs = choiceRecord.logprobs;
}
@@ -457,37 +498,23 @@ function sanitizeChoice(choice: unknown, defaultIndex: number): JsonRecord {
return sanitized;
}
-/**
- * Sanitize a message object, extracting tags if present.
- */
-function sanitizeMessage(msg: unknown): unknown {
- const msgRecord = toRecord(msg);
- if (!msgRecord) return msg;
-
- const sanitized: JsonRecord = {};
-
- // Copy only allowed fields
- if (msgRecord.role) sanitized.role = msgRecord.role;
- if (msgRecord.refusal !== undefined) sanitized.refusal = msgRecord.refusal;
-
- // Handle content — extract tags
+function sanitizeMessageContent(msgRecord: JsonRecord, options: ParseOptions = {}): JsonRecord {
if (typeof msgRecord.content === "string") {
- const { content, thinking } = extractThinkingFromContent(
- stripInternalToolEnvelopeText(msgRecord.content)
- );
- sanitized.content = collapseExcessiveNewlines(content);
-
- // Set reasoning_content from prompt-format tags only when the provider did
- // not also return a native OpenAI-compatible reasoning field.
- if (thinking && !getReadableReasoningValue(msgRecord)) {
- sanitized.reasoning_content = thinking;
- }
- } else if (msgRecord.content !== undefined) {
- sanitized.content = msgRecord.content;
+ const strippedContent = stripInternalToolEnvelopeText(msgRecord.content);
+ const nativeReasoning = getReadableReasoningValue(msgRecord);
+ const { content, thinking } =
+ options.parseTextualReasoningTags === true && !nativeReasoning
+ ? extractThinkingFromContent(strippedContent)
+ : { content: strippedContent, thinking: null };
+ const sanitized: JsonRecord = { content: collapseExcessiveNewlines(content) };
+ if (thinking) sanitized.reasoning_content = thinking;
+ return sanitized;
}
- copyOpenAICompatibleReasoningFields(msgRecord, sanitized);
+ return msgRecord.content !== undefined ? { content: msgRecord.content } : {};
+}
+function applyTextualToolCallSanitization(sanitized: JsonRecord, msgRecord: JsonRecord): void {
const textualToolCall = parseTextualToolCallContent(sanitized.content);
if (textualToolCall && !msgRecord.tool_calls) {
sanitized.content = null;
@@ -504,13 +531,26 @@ function sanitizeMessage(msg: unknown): unknown {
} else if (containsTextualToolCallContent(sanitized.content) && !msgRecord.tool_calls) {
sanitized.content = null;
}
+}
+
+function sanitizeMessage(msg: unknown, options: ParseOptions = {}): unknown {
+ const msgRecord = toRecord(msg);
+ if (!msgRecord) return msg;
+
+ const sanitized: JsonRecord = {};
+
+ if (msgRecord.role) sanitized.role = msgRecord.role;
+ if (msgRecord.refusal !== undefined) sanitized.refusal = msgRecord.refusal;
+
+ Object.assign(sanitized, sanitizeMessageContent(msgRecord, options));
+
+ copyOpenAICompatibleReasoningFields(msgRecord, sanitized);
+ applyTextualToolCallSanitization(sanitized, msgRecord);
- // Preserve tool_calls
if (msgRecord.tool_calls) {
sanitized.tool_calls = msgRecord.tool_calls;
}
- // Preserve function_call (legacy)
if (msgRecord.function_call) {
sanitized.function_call = msgRecord.function_call;
}
diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts
index e2c4917132..bbe844a3ac 100644
--- a/open-sse/services/contextManager.ts
+++ b/open-sse/services/contextManager.ts
@@ -2,7 +2,7 @@
* Context Manager — Phase 4
*
* Pre-flight context compression to prevent "prompt too long" errors.
- * 3 layers: trim tool messages, compress thinking, aggressive purification.
+ * 3 layers: trim tool messages, compress structured thinking, aggressive purification.
*/
import { REGISTRY } from "../config/providerRegistry.ts";
@@ -147,7 +147,7 @@ export function resolveComboContextLimit(options: {
* Operates in 3 layers of increasing aggressiveness:
*
* Layer 1: Trim tool_result messages (truncate long outputs)
- * Layer 2: Compress thinking blocks (remove from history, keep last)
+ * Layer 2: Compress structured thinking blocks (remove from history, keep last)
* Layer 3: Aggressive purification (drop old messages until fitting)
*
* @param {object} body - Request body with messages[]
@@ -194,7 +194,7 @@ export function compressContext(
};
}
- // Layer 2: Compress thinking blocks (remove from non-last assistant messages)
+ // Layer 2: Compress structured thinking blocks (remove from non-last assistant messages)
messages = compressThinking(messages);
currentTokens = estimateTokens(JSON.stringify(messages));
stats.layers.push({ name: "compress_thinking", tokens: currentTokens });
@@ -249,7 +249,7 @@ function trimToolMessages(messages: Record[], maxChars: number)
});
}
-// ─── Layer 2: Compress Thinking Blocks ──────────────────────────────────────
+// ─── Layer 2: Compress Structured Thinking Blocks ───────────────────────────
function compressThinking(messages: Record[]) {
// Find last assistant message index
@@ -274,28 +274,6 @@ function compressThinking(messages: Record[]) {
return { ...msg, content: filtered };
}
- // Remove thinking XML tags from string content
- if (typeof msg.content === "string") {
- let cleaned = msg.content;
- for (const [start, end] of [
- ["", ""],
- ["", ""],
- ]) {
- while (true) {
- const s = cleaned.indexOf(start);
- if (s === -1) break;
- const e = cleaned.indexOf(end, s + start.length);
- if (e === -1) {
- cleaned = cleaned.slice(0, s);
- break;
- }
- cleaned = cleaned.slice(0, s) + cleaned.slice(e + end.length);
- }
- }
- cleaned = cleaned.trim();
- return { ...msg, content: cleaned || "[thinking compressed]" };
- }
-
return msg;
});
}
diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts
index 0bb9156a73..afe245a019 100644
--- a/open-sse/transformer/responsesTransformer.ts
+++ b/open-sse/transformer/responsesTransformer.ts
@@ -1,4 +1,5 @@
import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts";
+import { shouldParseTextualReasoningTags } from "../handlers/responseSanitizer.ts";
import * as fs from "fs";
import * as path from "path";
/**
@@ -92,6 +93,7 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv
reasoningPartAdded: false,
reasoningDone: false,
inThinking: false,
+ parseTextualReasoningTags: false,
funcArgsBuf: {},
funcNames: {},
funcCallIds: {},
@@ -407,6 +409,13 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv
const choice = parsed.choices[0];
const idx = choice.index || 0;
const delta = choice.delta || {};
+ if (state.parseTextualReasoningTags !== true && typeof parsed.model === "string") {
+ state.parseTextualReasoningTags = shouldParseTextualReasoningTags(
+ undefined,
+ parsed.model
+ );
+ }
+ const parseTextualReasoningTags = state.parseTextualReasoningTags === true;
// Emit initial events
if (!state.started) {
@@ -443,41 +452,45 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv
emitReasoningDelta(controller, delta.reasoning_content);
}
- // Handle text content (may contain tags)
+ // Handle text content. Generic prompt-format tags are visible text;
+ // only tag-native models opt into textual reasoning extraction.
if (delta.content) {
// Close reasoning if it was opened via native reasoning_content
// and is still open, before emitting message content. Without this
// the reasoning item is never closed and the message reuses the
// reasoning output_index, producing a protocol-invalid stream.
- // Guard on !inThinking: reasoning opened via tags is closed by
- // its matching below — force-closing it here would snapshot a
- // partial buffer (dense output records the item at close time). (#4848 + #4906)
- if (state.reasoningId && !state.reasoningDone && !state.inThinking) {
+ if (
+ state.reasoningId &&
+ !state.reasoningDone &&
+ (!parseTextualReasoningTags || !state.inThinking)
+ ) {
closeReasoning(controller);
}
let content = delta.content;
- if (content.includes("")) {
- state.inThinking = true;
- content = content.replaceAll("", "");
- startReasoning(controller, idx);
- }
+ if (parseTextualReasoningTags) {
+ if (content.includes("")) {
+ state.inThinking = true;
+ content = content.replaceAll("", "");
+ startReasoning(controller, idx);
+ }
- if (content.includes("")) {
- const parts = content.split("");
- const thinkPart = parts[0];
- const textPart = parts.slice(1).join("");
+ if (content.includes("")) {
+ const parts = content.split("");
+ const thinkPart = parts[0];
+ const textPart = parts.slice(1).join("");
- if (thinkPart) emitReasoningDelta(controller, thinkPart);
- closeReasoning(controller);
- state.inThinking = false;
- content = textPart;
- }
+ if (thinkPart) emitReasoningDelta(controller, thinkPart);
+ closeReasoning(controller);
+ state.inThinking = false;
+ content = textPart;
+ }
- if (state.inThinking && content) {
- emitReasoningDelta(controller, content);
- continue;
+ if (state.inThinking && content) {
+ emitReasoningDelta(controller, content);
+ continue;
+ }
}
// Regular text content
diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts
index 16b6fdf1a3..6933741489 100644
--- a/open-sse/translator/index.ts
+++ b/open-sse/translator/index.ts
@@ -289,8 +289,7 @@ export function translateRequest(
// requested upstream; generic/implicit-cache OpenAI providers stay stripped.
result = filterToOpenAIFormat(result, {
preserveCacheControl:
- options?.preserveCacheControl === true &&
- providerHonorsOpenAIFormatCacheControl(provider),
+ options?.preserveCacheControl === true && providerHonorsOpenAIFormatCacheControl(provider),
// #4849 regression guard: keep client reasoning_content for replay providers.
preserveReasoningContent: isReasoner,
});
@@ -587,6 +586,7 @@ export function initState(sourceFormat) {
reasoningPartAdded: false,
reasoningDone: false,
inThinking: false,
+ parseTextualReasoningTags: false,
funcArgsBuf: {},
funcNames: {},
funcCallIds: {},
diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts
index ba23d68819..5db0c4507f 100644
--- a/open-sse/translator/response/gemini-to-openai.ts
+++ b/open-sse/translator/response/gemini-to-openai.ts
@@ -297,6 +297,9 @@ export function geminiToOpenAIResponse(chunk, state) {
const response = chunk.response || chunk;
if (!response) return null;
+ const modelVersion =
+ typeof response.modelVersion === "string" ? response.modelVersion.toLowerCase() : "";
+ const parseTextualReasoningTags = !chunk.response && !modelVersion.startsWith("antigravity/");
const results = [];
const candidate = response.candidates?.[0];
@@ -438,16 +441,18 @@ export function geminiToOpenAIResponse(chunk, state) {
}
if (hasFunctionCall) {
- // Flush any still-open textual reasoning wrapper as reasoning_content BEFORE
- // the tool call. A signed native functionCall arriving while a ``
- // (etc.) tag opened in an earlier chunk is still buffered must not silently
- // drop that buffered reasoning — flushOpenTextualReasoning emits it and clears
- // the active-tag/content buffers. (LEDGER-4 / #3821-review)
- flushOpenTextualReasoning(state, results);
- // Also drop any partial open-tag fragment buffered at a chunk boundary
- // (flushOpenTextualReasoning early-returns when only this is set), matching the
- // pre-fix branch which cleared all three buffers. (#3821-review convergence)
- state.textualReasoningTagBuffer = undefined;
+ if (parseTextualReasoningTags) {
+ // Flush any still-open textual reasoning wrapper as reasoning_content BEFORE
+ // the tool call. A signed native functionCall arriving while a ``
+ // (etc.) tag opened in an earlier chunk is still buffered must not silently
+ // drop that buffered reasoning — flushOpenTextualReasoning emits it and clears
+ // the active-tag/content buffers. (LEDGER-4 / #3821-review)
+ flushOpenTextualReasoning(state, results);
+ // Also drop any partial open-tag fragment buffered at a chunk boundary
+ // (flushOpenTextualReasoning early-returns when only this is set), matching the
+ // pre-fix branch which cleared all three buffers. (#3821-review convergence)
+ state.textualReasoningTagBuffer = undefined;
+ }
emitFunctionCallPart(part, state, results);
}
continue;
@@ -459,7 +464,9 @@ 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 !== "") {
- const afterReasoning = consumeTextualReasoningTags(part.text, state, results);
+ const afterReasoning = parseTextualReasoningTags
+ ? consumeTextualReasoningTags(part.text, state, results)
+ : part.text;
if (!afterReasoning) continue;
let accumulated = (state.textualToolCallBuffer || "") + afterReasoning;
@@ -677,7 +684,9 @@ export function geminiToOpenAIResponse(chunk, state) {
// Finish reason - include usage in final chunk
if (candidate.finishReason) {
- flushOpenTextualReasoning(state, results);
+ if (parseTextualReasoningTags) {
+ flushOpenTextualReasoning(state, results);
+ }
if (state.textualToolCallBuffer) {
const remainingText = state.textualToolCallBuffer;
diff --git a/open-sse/translator/response/kiro-to-openai.ts b/open-sse/translator/response/kiro-to-openai.ts
index f58a2aaeaa..694881ade8 100644
--- a/open-sse/translator/response/kiro-to-openai.ts
+++ b/open-sse/translator/response/kiro-to-openai.ts
@@ -92,7 +92,6 @@ export function convertKiroToOpenAI(chunk, state) {
const content = data.reasoningContentEvent?.content || data.content || "";
if (!content) return null;
- // Convert to thinking block format (Claude-style)
const openaiChunk = {
id: state.responseId,
object: "chat.completion.chunk",
@@ -103,7 +102,7 @@ export function convertKiroToOpenAI(chunk, state) {
index: 0,
delta: {
...(state.chunkIndex === 0 ? { role: "assistant" } : {}),
- content: `${content}`,
+ reasoning_content: content,
},
finish_reason: null,
},
diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts
index 90a6b74d87..d6c397fbad 100644
--- a/open-sse/translator/response/openai-responses.ts
+++ b/open-sse/translator/response/openai-responses.ts
@@ -6,6 +6,7 @@ import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
import { fallbackToolCallId } from "../helpers/toolCallHelper.ts";
+import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts";
function normalizeToolName(value) {
return typeof value === "string" ? value.trim() : "";
@@ -87,6 +88,10 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
const choice = chunk.choices[0];
const idx = choice.index || 0;
const delta = choice.delta || {};
+ if (state.parseTextualReasoningTags !== true && typeof chunk.model === "string") {
+ state.parseTextualReasoningTags = shouldParseTextualReasoningTags(undefined, chunk.model);
+ }
+ const parseTextualReasoningTags = state.parseTextualReasoningTags === true;
// Emit initial events
if (!state.started) {
@@ -117,50 +122,45 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
});
}
- // Handle reasoning_content
if (delta.reasoning_content) {
startReasoning(state, emit, idx);
emitReasoningDelta(state, emit, delta.reasoning_content);
}
-
- // Handle text content
if (delta.content) {
- // Close reasoning if it was opened via native reasoning_content and is
- // still open, before emitting message content. Otherwise the reasoning
- // item is never closed and the message reuses its output_index.
- // Guard on !inThinking: reasoning opened via tags is closed by its
- // matching below — force-closing it here would snapshot a partial
- // buffer (dense output records the item at close time). (#4848 + #4906)
- if (state.reasoningId && !state.reasoningDone && !state.inThinking) {
+ if (
+ state.reasoningId &&
+ !state.reasoningDone &&
+ (!parseTextualReasoningTags || !state.inThinking)
+ ) {
closeReasoning(state, emit);
}
let content = delta.content;
- if (content.includes("")) {
- state.inThinking = true;
- content = content.replaceAll("", "");
- startReasoning(state, emit, idx);
- }
+ if (parseTextualReasoningTags) {
+ if (content.includes("")) {
+ state.inThinking = true;
+ content = content.replaceAll("", "");
+ startReasoning(state, emit, idx);
+ }
- if (content.includes("")) {
- const parts = content.split("");
- const thinkPart = parts[0];
- const textPart = parts.slice(1).join("");
- if (thinkPart) emitReasoningDelta(state, emit, thinkPart);
- closeReasoning(state, emit);
- state.inThinking = false;
- content = textPart;
- }
+ if (content.includes("")) {
+ const parts = content.split("");
+ const thinkPart = parts[0];
+ const textPart = parts.slice(1).join("");
+ if (thinkPart) emitReasoningDelta(state, emit, thinkPart);
+ closeReasoning(state, emit);
+ state.inThinking = false;
+ content = textPart;
+ }
- if (state.inThinking && content) {
- emitReasoningDelta(state, emit, content);
- return events;
+ if (state.inThinking && content) {
+ emitReasoningDelta(state, emit, content);
+ return events;
+ }
}
if (content) {
- // Use a distinct output_index for the message when reasoning was
- // emitted, so the message item does not collide with the reasoning item.
const msgIdx = state.reasoningId ? state.reasoningIndex + 1 : idx;
emitTextContent(state, emit, msgIdx, content);
}
diff --git a/tests/unit/context-manager.test.ts b/tests/unit/context-manager.test.ts
index 7950930fa5..e16a16584e 100644
--- a/tests/unit/context-manager.test.ts
+++ b/tests/unit/context-manager.test.ts
@@ -122,6 +122,38 @@ test("compressContext: Layer 2 — compresses thinking in old messages", () => {
}
});
+test("compressContext: Layer 2 preserves prompt-format thinking tags in string content", () => {
+ const body = {
+ model: "test",
+ messages: [
+ { role: "user", content: "q1" },
+ {
+ role: "assistant",
+ content: "visible prompt protocolanswer1",
+ },
+ { role: "user", content: "q2" },
+ {
+ role: "assistant",
+ content: [
+ { type: "thinking", thinking: "lots of structured thinking here ".repeat(500) },
+ { type: "text", text: "answer2" },
+ ],
+ },
+ { role: "user", content: "q3" },
+ { role: "assistant", content: "answer3" },
+ ],
+ };
+ const result = compressContext(body, { maxTokens: 2000, reserveTokens: 500 });
+ const firstAssistant = (result.body as any).messages.find(
+ (m: any) => m.role === "assistant" && typeof m.content === "string"
+ );
+
+ assert.equal(
+ firstAssistant.content,
+ "visible prompt protocolanswer1"
+ );
+});
+
test("compressContext: Layer 3 — drops old messages to fit", () => {
const messages = [
{ role: "system", content: "You are helpful" },
diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts
index 5458d9cb4d..d46bc14f1d 100644
--- a/tests/unit/response-sanitizer.test.ts
+++ b/tests/unit/response-sanitizer.test.ts
@@ -6,6 +6,7 @@ const {
sanitizeOpenAIResponse,
sanitizeResponsesApiResponse,
sanitizeStreamingChunk,
+ shouldParseTextualReasoningTags,
} = await import("../../open-sse/handlers/responseSanitizer.ts");
test("extractThinkingFromContent separates think blocks from visible content", () => {
@@ -65,7 +66,7 @@ test("sanitizeOpenAIResponse strips non-standard fields and preserves required t
});
});
-test("sanitizeOpenAIResponse extracts thinking, collapses newlines, preserves reasoning_content with tool_calls, and preserves tool calls", () => {
+test("sanitizeOpenAIResponse preserves prompt-format thinking tags by default", () => {
const sanitized = sanitizeOpenAIResponse({
id: "chatcmpl_test",
model: "gpt-4.1",
@@ -75,7 +76,7 @@ test("sanitizeOpenAIResponse extracts thinking, collapses newlines, preserves re
finish_reason: "tool_calls",
message: {
role: "assistant",
- content: "Hello\n\n\ninternal chain\n\nworld",
+ content: "Hello\n\n\nvisible protocol\n\nworld",
tool_calls: [{ id: "call_1" }],
function_call: { name: "legacy" },
},
@@ -85,44 +86,76 @@ test("sanitizeOpenAIResponse extracts thinking, collapses newlines, preserves re
assert.equal((sanitized as any).choices[0].index, 2);
assert.equal((sanitized as any).choices[0].finish_reason, "tool_calls");
- (assert as any).equal((sanitized as any).choices[0].message.content, "Hello\n\nworld");
- assert.equal((sanitized as any).choices[0].message.reasoning_content, "internal chain");
+ (assert as any).equal(
+ (sanitized as any).choices[0].message.content,
+ "Hello\n\nvisible protocol\n\nworld"
+ );
+ assert.equal((sanitized as any).choices[0].message.reasoning_content, undefined);
(assert as any).deepEqual((sanitized as any).choices[0].message.tool_calls, [{ id: "call_1" }]);
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: "deepseek-r1",
+ choices: [
+ {
+ message: {
+ role: "assistant",
+ content: "Hello\n\n\ninternal chain\n\nworld",
+ },
},
- },
- ],
- });
+ ],
+ },
+ { parseTextualReasoningTags: true }
+ );
- assert.equal((sanitized as any).choices[0].message.content, "");
+ assert.equal((sanitized as any).choices[0].message.content, "Hello\n\nworld");
+ assert.equal((sanitized as any).choices[0].message.reasoning_content, "internal chain");
+});
+
+test("sanitizeOpenAIResponse extracts unclosed reasoning wrappers only when enabled", () => {
+ const sanitized = sanitizeOpenAIResponse(
+ {
+ model: "deepseek-r1",
+ choices: [
+ {
+ message: {
+ role: "assistant",
+ content: "§54§ {
- const sanitized = sanitizeOpenAIResponse({
- model: "gpt-4.1",
- choices: [
- {
- message: {
- role: "assistant",
- content: "discard me",
- reasoning_content: "provider reasoning",
+test("sanitizeOpenAIResponse preserves native reasoning_content without stripping content tags", () => {
+ const sanitized = sanitizeOpenAIResponse(
+ {
+ model: "gpt-4.1",
+ choices: [
+ {
+ message: {
+ role: "assistant",
+ content: "visible protocol",
+ reasoning_content: "provider reasoning",
+ },
},
- },
- ],
- });
+ ],
+ },
+ { parseTextualReasoningTags: true }
+ );
- assert.equal(((sanitized as any).choices[0].message as any).content, "");
+ assert.equal(
+ ((sanitized as any).choices[0].message as any).content,
+ "visible protocol"
+ );
assert.equal((sanitized as any).choices[0].message.reasoning_content, "provider reasoning");
});
@@ -246,7 +279,10 @@ test("sanitizeOpenAIResponse preserves OpenRouter native reasoning and signature
assert.deepEqual((sanitized as any).choices[0].message.reasoning_details, [
{ type: "reasoning.encrypted", data: "sig" },
]);
- assert.equal((sanitized as any).choices[0].message.content, "Visible answer");
+ assert.equal(
+ (sanitized as any).choices[0].message.content,
+ "tag-derivedVisible answer"
+ );
});
test("sanitizeOpenAIResponse keeps reasoning_details-derived reasoning_content for reasoning-only messages", () => {
@@ -618,6 +654,18 @@ test("sanitize functions return non-object inputs unchanged", () => {
assert.equal(sanitizeStreamingChunk("raw text"), "raw text");
});
+test("shouldParseTextualReasoningTags is limited to tag-native model families", () => {
+ assert.equal(shouldParseTextualReasoningTags("together", "deepseek-ai/DeepSeek-R1"), true);
+ assert.equal(shouldParseTextualReasoningTags("cloudflare-ai", "@cf/qwen/qwq-32b"), true);
+ assert.equal(shouldParseTextualReasoningTags("openrouter", "deepseek/deepseek-v4-pro"), false);
+ assert.equal(shouldParseTextualReasoningTags("antigravity", "deepseek-r1"), false);
+ assert.equal(shouldParseTextualReasoningTags(undefined, "antigravity/deepseek-r1"), false);
+ assert.equal(
+ shouldParseTextualReasoningTags("openai-compatible-custom", "claude-opus-4.7"),
+ false
+ );
+});
+
test("sanitizeOpenAIResponse converts textual pseudo tool-call content into structured tool_calls", () => {
const sanitized = sanitizeOpenAIResponse({
id: "chatcmpl_textual_tool_call",
diff --git a/tests/unit/responses-transformer.test.ts b/tests/unit/responses-transformer.test.ts
index 9d2ab0ad24..fef0226f54 100644
--- a/tests/unit/responses-transformer.test.ts
+++ b/tests/unit/responses-transformer.test.ts
@@ -83,9 +83,30 @@ test("createResponsesApiTransformStream converts plain chat deltas into Response
assert.equal(doneMarker.data, "[DONE]");
});
-test("createResponsesApiTransformStream converts think tags into reasoning summaries", async () => {
+test("createResponsesApiTransformStream preserves prompt-format think tags by default", async () => {
const output = await runTransformStream([
- 'data: {"choices":[{"index":0,"delta":{"content":"plan"}}]}\n\n',
+ 'data: {"id":"chatcmpl_1","model":"gpt-4.1","choices":[{"index":0,"delta":{"content":"plan"}}]}\n\n',
+ 'data: {"choices":[{"index":0,"delta":{"content":"ninganswer"}}]}\n\n',
+ 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
+ ]);
+
+ const events = parseSseOutput(output);
+ const reasoningDeltas = events
+ .filter((event) => event.event === "response.reasoning_summary_text.delta")
+ .map((event) => JSON.parse(event.data).delta);
+ const completed = JSON.parse(
+ events.find((event) => event.event === "response.completed").data
+ ).response;
+
+ assert.deepEqual(reasoningDeltas, []);
+ assert.deepEqual(completed.output[0].content, [
+ { type: "output_text", annotations: [], logprobs: [], text: "planninganswer" },
+ ]);
+});
+
+test("createResponsesApiTransformStream extracts think tags for tag-native models", async () => {
+ const output = await runTransformStream([
+ 'data: {"id":"chatcmpl_1","model":"deepseek-ai/DeepSeek-R1","choices":[{"index":0,"delta":{"content":"plan"}}]}\n\n',
'data: {"choices":[{"index":0,"delta":{"content":"ninganswer"}}]}\n\n',
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
]);
@@ -99,11 +120,7 @@ test("createResponsesApiTransformStream converts think tags into reasoning summa
).response;
assert.deepEqual(reasoningDeltas, ["plan", "ning"]);
- assert.deepEqual(completed.output[0], {
- id: completed.output[0].id,
- type: "reasoning",
- summary: [{ type: "summary_text", text: "planning" }],
- });
+ assert.equal(completed.output[0].type, "reasoning");
assert.deepEqual(completed.output[1].content, [
{ type: "output_text", annotations: [], logprobs: [], text: "answer" },
]);
diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts
index a155d39d63..61cf4b23f1 100644
--- a/tests/unit/responses-translation-fixes.test.ts
+++ b/tests/unit/responses-translation-fixes.test.ts
@@ -510,7 +510,7 @@ test("Responses→Chat streaming: Copilot mode emits reasoning_text for summary
assert.equal(result.choices[0].delta.reasoning, undefined);
});
-test("Chat→Responses streaming: multiple tags in one chunk handled", () => {
+test("Chat→Responses streaming: generic prompt-format tags remain text", () => {
const state = initState(FORMATS.OPENAI_RESPONSES);
// Chunk with multiple think tags
@@ -523,14 +523,44 @@ test("Chat→Responses streaming: multiple tags in one chunk handled", (
},
],
id: "c1",
+ model: "gpt-4.1",
};
const events = openaiToOpenAIResponsesResponse(chunk, state);
- // Should not have literal in any text delta
const textDeltas = events
.filter((e) => e.event === "response.output_text.delta")
.map((e) => e.data.delta);
const combined = textDeltas.join("");
- assert.ok(!combined.includes(""), `text should not contain tag, got: ${combined}`);
+ assert.equal(combined, "firstmiddlesecondend");
+ assert.equal(
+ events.some((e) => e.event === "response.reasoning_summary_text.delta"),
+ false
+ );
+});
+
+test("Chat→Responses streaming: tag-native models still split tags", () => {
+ const state = initState(FORMATS.OPENAI_RESPONSES);
+
+ const chunk = {
+ choices: [
+ {
+ index: 0,
+ delta: { content: "firstend" },
+ finish_reason: null,
+ },
+ ],
+ id: "c1",
+ model: "deepseek-r1",
+ };
+ const events = openaiToOpenAIResponsesResponse(chunk, state);
+ const textDeltas = events
+ .filter((e) => e.event === "response.output_text.delta")
+ .map((e) => e.data.delta);
+ const reasoningDeltas = events
+ .filter((e) => e.event === "response.reasoning_summary_text.delta")
+ .map((e) => e.data.delta);
+
+ assert.deepEqual(reasoningDeltas, ["first"]);
+ assert.equal(textDeltas.join(""), "end");
});
// Regression: a tool call was announced (response.output_item.added set currentToolCallId)
diff --git a/tests/unit/translator-resp-antigravity-thinking-boundary.test.ts b/tests/unit/translator-resp-antigravity-thinking-boundary.test.ts
new file mode 100644
index 0000000000..395d1f3d6e
--- /dev/null
+++ b/tests/unit/translator-resp-antigravity-thinking-boundary.test.ts
@@ -0,0 +1,75 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const { geminiToOpenAIResponse } =
+ await import("../../open-sse/translator/response/gemini-to-openai.ts");
+
+function createStreamingState() {
+ return {
+ toolCalls: new Map(),
+ };
+}
+
+test("Antigravity stream preserves prompt-format thinking tags as content", () => {
+ const state = createStreamingState();
+ const first = geminiToOpenAIResponse(
+ {
+ response: {
+ responseId: "resp-ag-visible-thinking",
+ modelVersion: "antigravity/gemini-3-pro",
+ candidates: [{ content: { parts: [{ text: "\n[metacognition" }] } }],
+ },
+ },
+ state
+ );
+ const second = geminiToOpenAIResponse(
+ {
+ response: {
+ responseId: "resp-ag-visible-thinking",
+ modelVersion: "antigravity/gemini-3-pro",
+ candidates: [{ content: { parts: [{ text: "]\n\nVisible answer" }] } }],
+ },
+ },
+ state
+ );
+
+ const deltas = [...first, ...second].map((event: any) => event.choices?.[0]?.delta || {});
+ assert.deepEqual(
+ deltas.filter((delta: any) => delta.content).map((delta: any) => delta.content),
+ ["\n[metacognition", "]\n\nVisible answer"]
+ );
+ assert.equal(
+ deltas.some((delta: any) => delta.reasoning_content !== undefined),
+ false
+ );
+});
+
+test("Antigravity stream keeps native Gemini thought parts as reasoning_content", () => {
+ const result = geminiToOpenAIResponse(
+ {
+ response: {
+ responseId: "resp-ag-native-thought",
+ modelVersion: "antigravity/gemini-3-pro",
+ candidates: [
+ {
+ content: {
+ parts: [{ thought: true, text: "Native plan" }, { text: "Visible answer" }],
+ },
+ finishReason: "STOP",
+ },
+ ],
+ },
+ },
+ createStreamingState()
+ );
+
+ assert.equal(
+ result.find((event: any) => event.choices?.[0]?.delta?.reasoning_content)?.choices[0].delta
+ .reasoning_content,
+ "Native plan"
+ );
+ assert.equal(
+ result.find((event: any) => event.choices?.[0]?.delta?.content)?.choices[0].delta.content,
+ "Visible answer"
+ );
+});
diff --git a/tests/unit/translator-resp-kiro-to-openai.test.ts b/tests/unit/translator-resp-kiro-to-openai.test.ts
index 38be7173e2..f99f2c666e 100644
--- a/tests/unit/translator-resp-kiro-to-openai.test.ts
+++ b/tests/unit/translator-resp-kiro-to-openai.test.ts
@@ -26,13 +26,14 @@ test("Kiro -> OpenAI: subsequent assistantResponseEvent omits role", () => {
assert.equal(result.choices[0].delta.content, "lo");
});
-test("Kiro -> OpenAI: reasoningContentEvent is wrapped as thinking tags", () => {
+test("Kiro -> OpenAI: reasoningContentEvent emits native reasoning_content", () => {
const result = convertKiroToOpenAI(
'event:reasoningContentEvent\ndata:{"content":"Need to inspect first"}\n\n',
{}
);
- assert.equal(result.choices[0].delta.content, "Need to inspect first");
+ assert.equal(result.choices[0].delta.reasoning_content, "Need to inspect first");
+ assert.equal(result.choices[0].delta.content, undefined);
});
test("Kiro -> OpenAI: toolUseEvent becomes OpenAI tool_calls", () => {
diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts
index 3401fbb684..40dd6a0598 100644
--- a/tests/unit/translator-resp-openai-responses.test.ts
+++ b/tests/unit/translator-resp-openai-responses.test.ts
@@ -112,7 +112,7 @@ test("OpenAI -> Responses: flush on null closes text content and emits response.
assert.ok(events.some((event) => event.event === "response.completed"));
});
-test("OpenAI -> Responses: tags become reasoning events and normal text still streams", () => {
+test("OpenAI -> Responses: prompt-format tags remain text by default", () => {
const events = collectEvents([
{
id: "chatcmpl-3",
@@ -127,6 +127,34 @@ test("OpenAI -> Responses: tags become reasoning events and normal text
},
]);
+ assert.equal(
+ events.some((event) => event.event === "response.reasoning_summary_text.delta"),
+ false
+ );
+ assert.ok(
+ events.some(
+ (event) =>
+ event.event === "response.output_text.delta" &&
+ event.data.delta === "Plan itDone."
+ )
+ );
+});
+
+test("OpenAI -> Responses: tag-native models still emit text as reasoning", () => {
+ const events = collectEvents([
+ {
+ id: "chatcmpl-3b",
+ model: "Qwen/QwQ-32B",
+ choices: [
+ {
+ index: 0,
+ delta: { content: "Plan itDone." },
+ finish_reason: "stop",
+ },
+ ],
+ },
+ ]);
+
assert.ok(
events.some(
(event) =>