mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 06:02:14 +03:00
feat(sse): x-omniroute-strip-reasoning header to drop reasoning_content (#4678)
Integrated into release/v3.8.37 — x-omniroute-strip-reasoning header. Cherry-picked onto release tip (resolved chatCore.ts/headers.ts adjacency conflict, kept resolveCompressionHeader + isStripReasoningRequested); tests 8/8 green.
This commit is contained in:
committed by
GitHub
parent
33ac3f6b7d
commit
5b1bb72483
@@ -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);
|
||||
|
||||
@@ -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<string, unknown> | Headers | null | undefined
|
||||
): boolean {
|
||||
const value = (getHeaderValueCaseInsensitive(headers, "x-omniroute-strip-reasoning") || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return value === "true" || value === "1" || value === "yes";
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
124
tests/unit/strip-reasoning-header.test.ts
Normal file
124
tests/unit/strip-reasoning-header.test.ts
Normal file
@@ -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<string, unknown> }> };
|
||||
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<string, unknown> }> };
|
||||
assert.equal(out.choices[0].message.reasoning_content, undefined);
|
||||
assert.equal(out.choices[0].message.content, "visible answer");
|
||||
});
|
||||
Reference in New Issue
Block a user