diff --git a/open-sse/utils/reasoningFields.ts b/open-sse/utils/reasoningFields.ts index a8b858e25e..21fc22cab1 100644 --- a/open-sse/utils/reasoningFields.ts +++ b/open-sse/utils/reasoningFields.ts @@ -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; } } diff --git a/tests/unit/reasoning-fields-placeholder-strip.test.ts b/tests/unit/reasoning-fields-placeholder-strip.test.ts new file mode 100644 index 0000000000..566d1dee12 --- /dev/null +++ b/tests/unit/reasoning-fields-placeholder-strip.test.ts @@ -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): Record { + const target: Record = {}; + 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" }]); +});