mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-10 17:22:17 +03:00
fix(response): strip internal reasoning placeholder from all reasoning fields
copyOpenAICompatibleReasoningFields only stripped the sentinel (NON_ANTHROPIC_THINKING_PLACEHOLDER = "(prior reasoning summary unavailable)") from reasoning_content and reasoning. Non-standard reasoning fields (reasoning_text, thinking, thought) and reasoning_details items passed through raw, leaking the internal replay sentinel to clients on providers that use those fields (e.g. Venice), where the model echo surfaces as a bogus thought block and can degrade into empty turns. Strip the sentinel from every forwarded reasoning field, including per-item text/content inside reasoning_details; drop items/fields that strip to nothing while preserving non-text details such as reasoning.encrypted. Fixes #9765 Refs #8081, #9606
This commit is contained in:
@@ -62,10 +62,38 @@ export function hasAnyReasoningSignal(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const STRIPPABLE_REASONING_FIELDS = [
|
||||
"reasoning_content",
|
||||
"reasoning",
|
||||
"reasoning_text",
|
||||
"thinking",
|
||||
"thought",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Strip the internal replay placeholder from a single string reasoning field,
|
||||
* deleting the field when nothing meaningful remains. Returns true only when a
|
||||
* present string field was fully stripped to empty (absent/non-string fields
|
||||
* return false so callers can distinguish "removed" from "never had text").
|
||||
*/
|
||||
function stripPlaceholderFromField(target: JsonRecord, field: string): boolean {
|
||||
const value = target[field];
|
||||
if (typeof value !== "string") return false;
|
||||
const stripped = stripInternalReasoningPlaceholder(value);
|
||||
if (stripped === "") {
|
||||
delete target[field];
|
||||
return true;
|
||||
}
|
||||
if (stripped !== value) target[field] = stripped;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function copyOpenAICompatibleReasoningFields(source: JsonRecord, target: JsonRecord) {
|
||||
if (source.reasoning_content !== undefined) target.reasoning_content = source.reasoning_content;
|
||||
if (source.reasoning !== undefined) target.reasoning = source.reasoning;
|
||||
if (source.reasoning_text !== undefined) target.reasoning_text = source.reasoning_text;
|
||||
if (source.thinking !== undefined) target.thinking = source.thinking;
|
||||
if (source.thought !== undefined) target.thought = source.thought;
|
||||
if (Array.isArray(source.reasoning_details)) target.reasoning_details = source.reasoning_details;
|
||||
if (!getReadableReasoningValue(target)) {
|
||||
const mirrored = getUnsupportedReasoningValue(source);
|
||||
@@ -73,15 +101,31 @@ export function copyOpenAICompatibleReasoningFields(source: JsonRecord, target:
|
||||
}
|
||||
// ponytail: the internal replay placeholder is request scaffolding, never
|
||||
// real reasoning — models echo it and it poisons client history + the cache
|
||||
// (#8081 echo). Strip it from anything we forward to the client.
|
||||
if (typeof target.reasoning_content === "string") {
|
||||
const stripped = stripInternalReasoningPlaceholder(target.reasoning_content);
|
||||
if (stripped === "") delete target.reasoning_content;
|
||||
else if (stripped !== target.reasoning_content) target.reasoning_content = stripped;
|
||||
// (#8081 echo). Strip it from anything we forward to the client, including
|
||||
// non-standard reasoning fields (reasoning_text / thinking / thought) and
|
||||
// reasoning_details items that non-OpenAI-compatible upstreams (e.g.
|
||||
// Venice) use (#9765 uncovered path).
|
||||
for (const field of STRIPPABLE_REASONING_FIELDS) {
|
||||
stripPlaceholderFromField(target, field);
|
||||
}
|
||||
if (typeof target.reasoning === "string") {
|
||||
const stripped = stripInternalReasoningPlaceholder(target.reasoning);
|
||||
if (stripped === "") delete target.reasoning;
|
||||
else if (stripped !== target.reasoning) target.reasoning = stripped;
|
||||
if (Array.isArray(target.reasoning_details)) {
|
||||
const cleaned: unknown[] = [];
|
||||
for (const detail of target.reasoning_details) {
|
||||
const record = asReasoningRecord(detail);
|
||||
const next: JsonRecord = { ...record };
|
||||
// Track whether the item originally carried text/content at all so
|
||||
// non-text details (e.g. `reasoning.encrypted` carrying only `data`)
|
||||
// survive untouched.
|
||||
const hadText = typeof next.text === "string";
|
||||
const hadContent = typeof next.content === "string";
|
||||
stripPlaceholderFromField(next, "text");
|
||||
stripPlaceholderFromField(next, "content");
|
||||
const textGone = next.text === undefined;
|
||||
const contentGone = next.content === undefined;
|
||||
if ((hadText || hadContent) && textGone && contentGone) continue;
|
||||
cleaned.push(next);
|
||||
}
|
||||
if (cleaned.length === 0) delete target.reasoning_details;
|
||||
else target.reasoning_details = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
116
tests/unit/reasoning-fields-placeholder-strip.test.ts
Normal file
116
tests/unit/reasoning-fields-placeholder-strip.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* tests/unit/reasoning-fields-placeholder-strip.test.ts
|
||||
*
|
||||
* copyOpenAICompatibleReasoningFields() must never forward the internal
|
||||
* reasoning-replay placeholder (NON_ANTHROPIC_THINKING_PLACEHOLDER =
|
||||
* "(prior reasoning summary unavailable)") to clients — it is request
|
||||
* scaffolding, and models echo it as their own reasoning (#8081, #9765).
|
||||
* Previously only reasoning_content / reasoning were stripped; non-standard
|
||||
* fields (reasoning_text, thinking, thought) and reasoning_details items
|
||||
* passed through raw, leaking the sentinel on providers that use them
|
||||
* (e.g. Venice).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { NON_ANTHROPIC_THINKING_PLACEHOLDER } from "../../open-sse/utils/reasoningPlaceholder.ts";
|
||||
import { copyOpenAICompatibleReasoningFields } from "../../open-sse/utils/reasoningFields.ts";
|
||||
|
||||
function copy(source: Record<string, unknown>): Record<string, unknown> {
|
||||
const target: Record<string, unknown> = {};
|
||||
copyOpenAICompatibleReasoningFields(source, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
test("real reasoning_content is preserved verbatim", () => {
|
||||
const target = copy({ reasoning_content: "Let me think carefully." });
|
||||
assert.equal(target.reasoning_content, "Let me think carefully.");
|
||||
});
|
||||
|
||||
test("reasoning_content that is exactly the placeholder is dropped", () => {
|
||||
const target = copy({ reasoning_content: NON_ANTHROPIC_THINKING_PLACEHOLDER });
|
||||
assert.equal("reasoning_content" in target, false);
|
||||
});
|
||||
|
||||
test("reasoning alias that is exactly the placeholder is dropped", () => {
|
||||
const target = copy({ reasoning: NON_ANTHROPIC_THINKING_PLACEHOLDER });
|
||||
assert.equal("reasoning" in target, false);
|
||||
});
|
||||
|
||||
test("reasoning_text that is exactly the placeholder is dropped (Venice path, #9765)", () => {
|
||||
const target = copy({ reasoning_text: NON_ANTHROPIC_THINKING_PLACEHOLDER });
|
||||
assert.equal("reasoning_text" in target, false);
|
||||
});
|
||||
|
||||
test("thinking that is exactly the placeholder is dropped", () => {
|
||||
const target = copy({ thinking: NON_ANTHROPIC_THINKING_PLACEHOLDER });
|
||||
assert.equal("thinking" in target, false);
|
||||
});
|
||||
|
||||
test("thought that is exactly the placeholder is dropped", () => {
|
||||
const target = copy({ thought: NON_ANTHROPIC_THINKING_PLACEHOLDER });
|
||||
assert.equal("thought" in target, false);
|
||||
});
|
||||
|
||||
test("placeholder embedded in otherwise real reasoning_text is stripped in place", () => {
|
||||
const target = copy({
|
||||
reasoning_text: `First thought. ${NON_ANTHROPIC_THINKING_PLACEHOLDER} Second thought.`,
|
||||
});
|
||||
assert.equal(target.reasoning_text, "First thought. Second thought.");
|
||||
});
|
||||
|
||||
test("no mirrored reasoning_content is emitted when the only signal is the placeholder", () => {
|
||||
const target = copy({ reasoning_text: NON_ANTHROPIC_THINKING_PLACEHOLDER });
|
||||
assert.equal("reasoning_content" in target, false);
|
||||
assert.equal("reasoning_text" in target, false);
|
||||
});
|
||||
|
||||
test("all-placeholder reasoning_details are dropped entirely", () => {
|
||||
const target = copy({
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.text", text: NON_ANTHROPIC_THINKING_PLACEHOLDER },
|
||||
{ type: "thinking", content: ` ${NON_ANTHROPIC_THINKING_PLACEHOLDER} ` },
|
||||
],
|
||||
});
|
||||
assert.equal("reasoning_details" in target, false);
|
||||
assert.equal("reasoning_content" in target, false);
|
||||
});
|
||||
|
||||
test("mixed reasoning_details keep real text and drop only placeholder items", () => {
|
||||
const target = copy({
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.text", text: "real first step " },
|
||||
{ type: "thinking", content: NON_ANTHROPIC_THINKING_PLACEHOLDER },
|
||||
{ type: "reasoning.text", text: "real second step" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(target.reasoning_details, [
|
||||
{ type: "reasoning.text", text: "real first step " },
|
||||
{ type: "reasoning.text", text: "real second step" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("placeholder inside a reasoning_details text item is stripped in place", () => {
|
||||
const target = copy({
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.text", text: `real ${NON_ANTHROPIC_THINKING_PLACEHOLDER} tail` },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(target.reasoning_details, [{ type: "reasoning.text", text: "real tail" }]);
|
||||
});
|
||||
|
||||
test("real reasoning_details still mirror into reasoning_content for readable clients", () => {
|
||||
const target = copy({
|
||||
reasoning_details: [{ type: "reasoning.text", text: "real reasoning here" }],
|
||||
});
|
||||
assert.equal(target.reasoning_content, "real reasoning here");
|
||||
assert.deepEqual(target.reasoning_details, [
|
||||
{ type: "reasoning.text", text: "real reasoning here" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("non-text reasoning_details (e.g. reasoning.encrypted) survive untouched", () => {
|
||||
const target = copy({
|
||||
reasoning_details: [{ type: "reasoning.encrypted", data: "sig" }],
|
||||
});
|
||||
assert.deepEqual(target.reasoning_details, [{ type: "reasoning.encrypted", data: "sig" }]);
|
||||
});
|
||||
Reference in New Issue
Block a user