diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index b9b1f00093..470720a49e 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -18,6 +18,7 @@ import { getHeaderValueCaseInsensitive, isNoMemoryRequested, resolveCompressionHeader, + isStripReasoningRequested, } from "./chatCore/headers.ts"; import { markCodexScopeRateLimited } from "./chatCore/codexFailover.ts"; import { getCombosCached } from "./chatCore/comboContextCache.ts"; @@ -3493,7 +3494,13 @@ export async function handleChatCore({ if (clientResponseFormat === FORMATS.OPENAI_RESPONSES) { translatedResponse = sanitizeResponsesApiResponse(translatedResponse); } else if (clientResponseFormat === FORMATS.OPENAI) { - translatedResponse = sanitizeOpenAIResponse(translatedResponse); + // Port of decolua/9router#517: opt-in `x-omniroute-strip-reasoning` header + // unconditionally drops `reasoning_content` from the final non-streaming + // JSON for clients (e.g. Firecrawl AI SDK) whose JSON parsers break on + // 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 }); } applyClientUsageBuffer(translatedResponse, body, clientResponseFormat); diff --git a/open-sse/handlers/chatCore/headers.ts b/open-sse/handlers/chatCore/headers.ts index 913898ad3b..ba6d1c2712 100644 --- a/open-sse/handlers/chatCore/headers.ts +++ b/open-sse/handlers/chatCore/headers.ts @@ -44,3 +44,22 @@ export function resolveCompressionHeader( const value = (getHeaderValueCaseInsensitive(headers, "x-omniroute-compression") || "").trim(); return value || null; } + +/** + * Per-request opt-in to unconditionally strip `reasoning_content` from the + * non-streaming JSON response via the `x-omniroute-strip-reasoning` header. + * Some clients (e.g. Firecrawl AI SDK) have JSON parsers that break on this + * non-standard OpenAI extension even though it's syntactically valid, and even + * on reasoning-only messages that the default sanitizer keeps. Truthy values: + * `true` / `1` / `yes` (case-insensitive). Ported from upstream 9router#517 + * (closes upstream #509). Reasoning is still captured for the replay cache + * before this header is consulted, so the cache feature is unaffected. + */ +export function isStripReasoningRequested( + headers: Record | Headers | null | undefined +): boolean { + const value = (getHeaderValueCaseInsensitive(headers, "x-omniroute-strip-reasoning") || "") + .trim() + .toLowerCase(); + return value === "true" || value === "1" || value === "yes"; +} diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 112673127a..f852aedac0 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -269,10 +269,26 @@ export function extractThinkingFromContent(text: string): { * Sanitize a non-streaming OpenAI ChatCompletion response. * Strips non-standard fields and normalizes required fields. */ -export function sanitizeOpenAIResponse(body: unknown): unknown { +export interface SanitizeOpenAIResponseOptions { + /** + * When true, unconditionally remove `reasoning_content` from every choice + * message in the final payload — including reasoning-only messages and + * DeepSeek V4 — even though the default sanitizer keeps it in those cases. + * Wired to the `x-omniroute-strip-reasoning` request header for clients whose + * JSON parsers cannot tolerate the non-standard field (e.g. Firecrawl AI SDK). + * Ported from upstream 9router#517 (closes upstream #509). + */ + stripReasoning?: boolean; +} + +export function sanitizeOpenAIResponse( + body: unknown, + options: SanitizeOpenAIResponseOptions = {} +): 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 const sanitized: JsonRecord = {}; @@ -296,6 +312,9 @@ export function sanitizeOpenAIResponse(body: unknown): unknown { ) { sanitizedChoice.finish_reason = "tool_calls"; } + if (stripReasoning && message && "reasoning_content" in message) { + delete message.reasoning_content; + } return sanitizedChoice; }); } else { diff --git a/tests/unit/strip-reasoning-header.test.ts b/tests/unit/strip-reasoning-header.test.ts new file mode 100644 index 0000000000..7d5dea4e7c --- /dev/null +++ b/tests/unit/strip-reasoning-header.test.ts @@ -0,0 +1,124 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Port of decolua/9router#517: per-request opt-in via the +// `x-omniroute-strip-reasoning` header to unconditionally strip +// `reasoning_content` from non-streaming JSON responses. Some clients +// (Firecrawl AI SDK) have JSON parsers that break on this non-standard +// extension even when there is no visible content, so the default +// "keep reasoning-only messages" behavior is not sufficient. +const { isStripReasoningRequested, getHeaderValueCaseInsensitive } = await import( + "../../open-sse/handlers/chatCore/headers.ts" +); +const { sanitizeOpenAIResponse } = await import( + "../../open-sse/handlers/responseSanitizer.ts" +); + +test("isStripReasoningRequested is true for truthy header values", () => { + for (const v of ["true", "1", "yes", "TRUE", "Yes", " true "]) { + assert.equal( + isStripReasoningRequested({ "x-omniroute-strip-reasoning": v }), + true, + `expected true for ${JSON.stringify(v)}` + ); + } +}); + +test("isStripReasoningRequested is case-insensitive on the header NAME", () => { + assert.equal(isStripReasoningRequested({ "X-OmniRoute-Strip-Reasoning": "true" }), true); +}); + +test("isStripReasoningRequested works with a Headers instance", () => { + const h = new Headers(); + h.set("x-omniroute-strip-reasoning", "1"); + assert.equal(isStripReasoningRequested(h), true); +}); + +test("isStripReasoningRequested is false when absent / empty / falsy", () => { + assert.equal(isStripReasoningRequested(null), false); + assert.equal(isStripReasoningRequested(undefined), false); + assert.equal(isStripReasoningRequested({}), false); + assert.equal(isStripReasoningRequested({ "x-omniroute-strip-reasoning": "" }), false); + assert.equal(isStripReasoningRequested({ "x-omniroute-strip-reasoning": "false" }), false); + assert.equal(isStripReasoningRequested({ "x-omniroute-strip-reasoning": "0" }), false); + assert.equal(isStripReasoningRequested({ "x-omniroute-strip-reasoning": "no" }), false); +}); + +test("getHeaderValueCaseInsensitive still resolves the header (sanity)", () => { + assert.equal( + getHeaderValueCaseInsensitive( + { "x-omniroute-strip-reasoning": "true" }, + "x-omniroute-strip-reasoning" + ), + "true" + ); +}); + +// Default behavior (regression guard): reasoning-only messages keep +// reasoning_content. This matches existing logic — non-streaming responses +// only drop reasoning_content when there is ALSO visible content. +test("default: reasoning-only message keeps reasoning_content", () => { + const out = sanitizeOpenAIResponse({ + id: "chatcmpl-x", + object: "chat.completion", + created: 1, + model: "deepseek-reasoner", + choices: [ + { + index: 0, + message: { role: "assistant", content: "", reasoning_content: "internal thoughts" }, + finish_reason: "stop", + }, + ], + }) as { choices: Array<{ message: { reasoning_content?: string } }> }; + assert.equal(out.choices[0].message.reasoning_content, "internal thoughts"); +}); + +// Opt-in port of PR#517: when stripReasoning=true, reasoning_content is +// always removed from the final non-streaming JSON, even on reasoning-only +// messages. Firecrawl AI SDK and similar JSON parsers cannot tolerate it. +test("stripReasoning=true: reasoning-only message has reasoning_content removed", () => { + const out = sanitizeOpenAIResponse( + { + id: "chatcmpl-x", + object: "chat.completion", + created: 1, + model: "deepseek-reasoner", + choices: [ + { + index: 0, + message: { role: "assistant", content: "", reasoning_content: "internal thoughts" }, + finish_reason: "stop", + }, + ], + }, + { stripReasoning: true } + ) as { choices: Array<{ message: Record }> }; + assert.equal(out.choices[0].message.reasoning_content, undefined); + assert.equal("reasoning_content" in out.choices[0].message, false); +}); + +test("stripReasoning=true: message with both content and reasoning_content has reasoning stripped", () => { + const out = sanitizeOpenAIResponse( + { + id: "chatcmpl-x", + object: "chat.completion", + created: 1, + model: "deepseek-v4", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "visible answer", + reasoning_content: "internal", + }, + finish_reason: "stop", + }, + ], + }, + { stripReasoning: true } + ) as { choices: Array<{ message: Record }> }; + assert.equal(out.choices[0].message.reasoning_content, undefined); + assert.equal(out.choices[0].message.content, "visible answer"); +});