Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 63 additions & 88 deletions open-sse/handlers/responseSanitizer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { copyOpenAICompatibleReasoningFields } from "../utils/reasoningFields.ts";
import {
copyOpenAICompatibleReasoningFields,
getReadableReasoningValue,
} from "../utils/reasoningFields.ts";

/**
* Response Sanitizer — Normalizes LLM responses to strict OpenAI SDK format.
Expand Down Expand Up @@ -31,12 +34,6 @@ type JsonRecord = Record<string, unknown>;

export const OMIT_STREAMING_CHUNK_MARKER = "__omniroute_omit_streaming_chunk";

const DEEPSEEK_V4_SANITIZER_MODEL_PATTERN = /deepseek[-/]v4/i;

function isDeepSeekV4Model(model: unknown): boolean {
return typeof model === "string" && DEEPSEEK_V4_SANITIZER_MODEL_PATTERN.test(model);
}

function toRecord(value: unknown): JsonRecord | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as JsonRecord;
Expand All @@ -50,6 +47,13 @@ function toNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

function deleteOpenAICompatibleReasoningFields(record: JsonRecord): void {
delete record.reasoning_content;
delete record.reasoning;
delete record.reasoning_text;
delete record.reasoning_details;
}

function stripZeroWidthText(value: string): string {
return value.replace(/[\u200B-\u200D\uFEFF]/g, "");
}
Expand Down Expand Up @@ -185,30 +189,16 @@ function containsTextualToolCallContent(content: unknown): boolean {
);
}

function hasVisibleMessageContent(content: unknown): boolean {
if (typeof content === "string") {
return content.trim().length > 0;
}

if (!Array.isArray(content)) return false;

return content.some((contentPart) => {
const part = toRecord(contentPart);
if (!part) return false;
if (typeof part.text === "string" && part.text.trim().length > 0) return true;
if (typeof part.content === "string" && part.content.trim().length > 0) return true;
const partType = toString(part.type);
return Boolean(partType && partType !== "thinking" && partType !== "reasoning");
});
}

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"
);
const REASONING_CLOSE_TAG_REGEX = new RegExp(`</(${REASONING_TAG_PATTERN})>`, "i");
const REASONING_TAG_FRAGMENT_REGEX = new RegExp(`</?(${REASONING_TAG_PATTERN})\\b[^>]*>`, "gi");
const CONTENT_OPEN_TAG_REGEX = /<content\b[^>]*>/i;
// 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.
Expand All @@ -224,6 +214,36 @@ function collapseExcessiveNewlines(text: string): string {
return text.replace(EXCESSIVE_NEWLINES, "\n\n");
}

function cleanReasoningFragment(text: string): string {
return text.replace(REASONING_TAG_FRAGMENT_REGEX, "").trim();
}

function splitClosingOnlyReasoningPrefix(text: string): {
content: string;
thinking: string | null;
} | null {
const closeMatch = text.match(REASONING_CLOSE_TAG_REGEX);
if (!closeMatch || closeMatch.index === undefined || closeMatch.index === 0) return null;
const closeIndex = closeMatch.index;

const suffix = text.slice(closeIndex + closeMatch[0].length);
if (!CONTENT_OPEN_TAG_REGEX.test(suffix)) return null;

const thinking = cleanReasoningFragment(text.slice(0, closeIndex));
if (!thinking) return null;
return { content: suffix.trim(), thinking };
}

function movePrefixBeforeContentTagToThinking(cleaned: string, thinkingParts: string[]): string {
const contentMatch = cleaned.match(CONTENT_OPEN_TAG_REGEX);
if (!contentMatch || contentMatch.index === undefined || contentMatch.index <= 0) return cleaned;
const contentIndex = contentMatch.index;

const prefix = cleanReasoningFragment(cleaned.slice(0, contentIndex));
if (prefix) thinkingParts.unshift(prefix);
return cleaned.slice(contentIndex);
}

/**
* Extract <think> blocks from text content and return separated parts.
* @returns {{ content: string, thinking: string | null }}
Expand All @@ -248,6 +268,13 @@ export function extractThinkingFromContent(text: string): {
return "";
});

if (!hasThinkTags) {
const closingOnly = splitClosingOnlyReasoningPrefix(cleaned);
if (closingOnly) {
return closingOnly;
}
}

const unclosedMatch = cleaned.match(UNCLOSED_REASONING_TAG_REGEX);
if (unclosedMatch?.index !== undefined) {
hasThinkTags = true;
Expand All @@ -261,6 +288,8 @@ export function extractThinkingFromContent(text: string): {
return { content: text, thinking: null };
}

cleaned = movePrefixBeforeContentTagToThinking(cleaned, thinkingParts);

return {
content: cleaned.trim(),
thinking: thinkingParts.length > 0 ? thinkingParts.join("\n\n") : null,
Expand Down Expand Up @@ -289,7 +318,6 @@ export function sanitizeOpenAIResponse(
): unknown {
const bodyRecord = toRecord(body);
if (!bodyRecord) return body;
const isDeepSeekV4 = isDeepSeekV4Model(bodyRecord.model);
const stripReasoning = options.stripReasoning === true;

// Build sanitized response with only allowed top-level fields
Expand All @@ -304,7 +332,7 @@ export function sanitizeOpenAIResponse(
// Sanitize choices
if (Array.isArray(bodyRecord.choices)) {
sanitized.choices = bodyRecord.choices.map((choice, idx) => {
const sanitizedChoice = sanitizeChoice(choice, idx, isDeepSeekV4);
const sanitizedChoice = sanitizeChoice(choice, idx);
const message = toRecord(sanitizedChoice.message);
if (
message &&
Expand All @@ -314,8 +342,8 @@ export function sanitizeOpenAIResponse(
) {
sanitizedChoice.finish_reason = "tool_calls";
}
if (stripReasoning && message && "reasoning_content" in message) {
delete message.reasoning_content;
if (stripReasoning && message) {
deleteOpenAICompatibleReasoningFields(message);
}
return sanitizedChoice;
});
Expand Down Expand Up @@ -397,7 +425,7 @@ export function sanitizeResponsesApiResponse(body: unknown): unknown {
/**
* Sanitize a single choice object.
*/
function sanitizeChoice(choice: unknown, defaultIndex: number, isDeepSeekV4 = false): JsonRecord {
function sanitizeChoice(choice: unknown, defaultIndex: number): JsonRecord {
const choiceRecord = toRecord(choice);
const sanitized: JsonRecord = {
index: defaultIndex,
Expand All @@ -414,7 +442,7 @@ function sanitizeChoice(choice: unknown, defaultIndex: number, isDeepSeekV4 = fa

// Sanitize message (non-streaming) or delta (streaming)
if (choiceRecord?.message !== undefined) {
sanitized.message = sanitizeMessage(choiceRecord.message, isDeepSeekV4);
sanitized.message = sanitizeMessage(choiceRecord.message);
}
if (choiceRecord?.delta !== undefined) {
sanitized.delta = sanitizeMessage(choiceRecord.delta);
Expand All @@ -431,7 +459,7 @@ function sanitizeChoice(choice: unknown, defaultIndex: number, isDeepSeekV4 = fa
/**
* Sanitize a message object, extracting <think> tags if present.
*/
function sanitizeMessage(msg: unknown, isDeepSeekV4 = false): unknown {
function sanitizeMessage(msg: unknown): unknown {
const msgRecord = toRecord(msg);
if (!msgRecord) return msg;

Expand All @@ -448,69 +476,16 @@ function sanitizeMessage(msg: unknown, isDeepSeekV4 = false): unknown {
);
sanitized.content = collapseExcessiveNewlines(content);

// Set reasoning_content from <think> tags (if not already set)
if (thinking && !msgRecord.reasoning_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;
}

// Preserve existing reasoning_content (from providers that natively support it)
if (msgRecord.reasoning_content && !sanitized.reasoning_content) {
sanitized.reasoning_content = msgRecord.reasoning_content;
}

// Handle 'reasoning' field alias (some providers use this instead of reasoning_content)
if (
msgRecord.reasoning &&
typeof msgRecord.reasoning === "string" &&
!sanitized.reasoning_content
) {
sanitized.reasoning_content = msgRecord.reasoning;
}

// Handle reasoning_details[] array (StepFun/OpenRouter format)
// Structure: [{ type: "reasoning.text", text: "...", format: "unknown", index: 0 }]
if (Array.isArray(msgRecord.reasoning_details) && !sanitized.reasoning_content) {
const reasoningParts: string[] = [];
for (const detail of msgRecord.reasoning_details) {
const detailObj = detail && typeof detail === "object" ? (detail as JsonRecord) : null;
if (!detailObj) continue;
const detailType = typeof detailObj.type === "string" ? detailObj.type : "";
const detailText =
typeof detailObj.text === "string"
? detailObj.text
: typeof detailObj.content === "string"
? detailObj.content
: "";
if (
detailText &&
(detailType === "reasoning" ||
detailType === "reasoning.text" ||
detailType === "thinking" ||
detailType === "")
) {
reasoningParts.push(detailText);
}
}
if (reasoningParts.length > 0) {
sanitized.reasoning_content = reasoningParts.join("");
}
}

// Non-streaming responses should not expose both visible content and reasoning_content.
// Some clients drop the visible assistant text or render duplicated panels when both fields
// are present in the final payload. Keep reasoning_content only for reasoning-only messages.
if (
sanitized.reasoning_content !== undefined &&
hasVisibleMessageContent(sanitized.content) &&
!msgRecord.tool_calls &&
!msgRecord.function_call &&
!isDeepSeekV4
) {
delete sanitized.reasoning_content;
}
copyOpenAICompatibleReasoningFields(msgRecord, sanitized);

const textualToolCall = parseTextualToolCallContent(sanitized.content);
if (textualToolCall && !msgRecord.tool_calls) {
Expand Down
48 changes: 40 additions & 8 deletions tests/unit/response-sanitizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ test("extractThinkingFromContent does NOT treat <thoughtful> as a reasoning tag"
assert.equal(parsed.thinking, null);
});

test("extractThinkingFromContent handles closing-only reasoning before content tag", () => {
const parsed = extractThinkingFromContent("planning\n</thinking>\n<content>visible</content>");
assert.equal(parsed.content, "<content>visible</content>");
assert.equal(parsed.thinking, "planning");
});

test("sanitizeOpenAIResponse strips non-standard fields and preserves required top-level fields", () => {
const sanitized = sanitizeOpenAIResponse({
id: "chatcmpl_existing",
Expand Down Expand Up @@ -139,7 +145,7 @@ test("sanitizeOpenAIResponse maps Claude-style usage fields and strips extras",
});
});

test("sanitizeOpenAIResponse strips reasoning_details-derived reasoning_content when visible text exists", () => {
test("sanitizeOpenAIResponse preserves reasoning_details-derived reasoning_content with visible text", () => {
const sanitized = sanitizeOpenAIResponse({
model: "openrouter/model",
choices: [
Expand All @@ -150,14 +156,18 @@ test("sanitizeOpenAIResponse strips reasoning_details-derived reasoning_content
reasoning_details: [
{ type: "reasoning.text", text: "first " },
{ type: "thinking", content: "second" },
{ type: "other", text: "ignored" },
],
},
},
],
});

assert.equal((sanitized as any).choices[0].message.reasoning_content, undefined);
assert.equal((sanitized as any).choices[0].message.content, "Visible");
assert.equal((sanitized as any).choices[0].message.reasoning_content, "first second");
assert.deepEqual((sanitized as any).choices[0].message.reasoning_details, [
{ type: "reasoning.text", text: "first " },
{ type: "thinking", content: "second" },
]);
});

test("sanitizeOpenAIResponse preserves DeepSeek V4 reasoning_content with visible text", () => {
Expand Down Expand Up @@ -198,7 +208,7 @@ test("sanitizeOpenAIResponse preserves DeepSeek V4 reasoning_details with visibl
assert.equal((sanitized as any).choices[0].message.reasoning_content, "first second");
});

test("sanitizeOpenAIResponse still strips non-DeepSeek reasoning_content with visible text", () => {
test("sanitizeOpenAIResponse preserves non-DeepSeek reasoning_content with visible text", () => {
const sanitized = sanitizeOpenAIResponse({
model: "o3-mini",
choices: [
Expand All @@ -212,7 +222,31 @@ test("sanitizeOpenAIResponse still strips non-DeepSeek reasoning_content with vi
],
});

assert.equal((sanitized as any).choices[0].message.content, "Visible answer");
assert.equal((sanitized as any).choices[0].message.reasoning_content, "OpenAI reasoning");
});

test("sanitizeOpenAIResponse preserves OpenRouter native reasoning and signatures", () => {
const sanitized = sanitizeOpenAIResponse({
model: "moonshotai/kimi-k2.6",
choices: [
{
message: {
role: "assistant",
content: "<thinking>tag-derived</thinking><content>Visible answer</content>",
reasoning: "provider native reasoning",
reasoning_details: [{ type: "reasoning.encrypted", data: "sig" }],
},
},
],
});

assert.equal((sanitized as any).choices[0].message.reasoning_content, undefined);
assert.equal((sanitized as any).choices[0].message.reasoning, "provider native reasoning");
assert.deepEqual((sanitized as any).choices[0].message.reasoning_details, [
{ type: "reasoning.encrypted", data: "sig" },
]);
assert.equal((sanitized as any).choices[0].message.content, "<content>Visible answer</content>");
});

test("sanitizeOpenAIResponse keeps reasoning_details-derived reasoning_content for reasoning-only messages", () => {
Expand Down Expand Up @@ -535,9 +569,7 @@ test("sanitizeOpenAIResponse preserves reasoning_content when tool_calls are pre
assert.equal(message.tool_calls[0].id, "call_search_1");
});

test("sanitizeOpenAIResponse still strips reasoning_content when no tool_calls exist", () => {
// When there are no tool_calls, the original behavior should remain:
// reasoning_content is stripped to avoid client rendering issues.
test("sanitizeOpenAIResponse preserves reasoning_content when no tool_calls exist", () => {
const sanitized = sanitizeOpenAIResponse({
model: "gpt-4.1",
choices: [
Expand All @@ -553,7 +585,7 @@ test("sanitizeOpenAIResponse still strips reasoning_content when no tool_calls e

const message = (sanitized as any).choices[0].message;
assert.equal(message.content, "Hello world");
assert.equal(message.reasoning_content, undefined);
assert.equal(message.reasoning_content, "Some internal reasoning");
});

test("sanitizeOpenAIResponse preserves reasoning_content when legacy function_call is present", () => {
Expand Down
Loading
Loading