diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 146e105c757..68f6307d675 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -72,6 +72,55 @@ function stripZeroWidthText(value: string): string { return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); } +/** + * Detect whether a string contains the internal reasoning placeholder that + * should only appear inside reasoning fields, not as visible content. + * + * The placeholder is "(prior reasoning summary unavailable)" but upstream + * providers sometimes emit it with wrappers/prefixes such as: + * - "Thinking: (prior reasoning summary unavailable)" + * - "(prior reasoning summary unavailable)" + * - "(Prior reasoning summary unavailable.)" + * + * Match case-insensitively on the core phrase. + */ +const REASONING_PLACEHOLDER_CORE = "prior reasoning summary unavailable"; +const REASONING_PLACEHOLDER_RE = new RegExp(REASONING_PLACEHOLDER_CORE.replace(/ /g, "\\s+"), "i"); + +function isReasoningPlaceholder(value: unknown): boolean { + return typeof value === "string" && REASONING_PLACEHOLDER_RE.test(value); +} + +/** + * Strip the internal reasoning placeholder from visible content. + * + * Returns the cleaned string. If the content is ONLY the placeholder (optionally + * wrapped in thinking tags / prefix), returns an empty string so the caller + * can drop it entirely. If the placeholder appears alongside real content, + * only the placeholder fragment is removed. + * + * Non-streaming path: applied in sanitizeMessageContent. + * Streaming path: applied in sanitizeStreamingChunk. + */ +function stripReasoningPlaceholderFromContent(content: string): string { + if (!REASONING_PLACEHOLDER_RE.test(content)) return content; + + // Try to remove …placeholder… wrapper first. + const thinkTagRe = + /\s*\(?\s*prior\s+reasoning\s+summary\s+unavailable\.?\s*\)?\s*<\/think>\s*/gi; + let cleaned = content.replace(thinkTagRe, ""); + + // Remove "Thinking: (prior reasoning summary unavailable)" prefix. + const prefixRe = /thinking\s*:\s*\(?\s*prior\s+reasoning\s+summary\s+unavailable\.?\s*\)?\s*/gi; + cleaned = cleaned.replace(prefixRe, ""); + + // Remove bare placeholder occurrences. + const bareRe = /\(?\s*prior\s+reasoning\s+summary\s+unavailable\.?\s*\)?\s*/gi; + cleaned = cleaned.replace(bareRe, ""); + + return cleaned.trim(); +} + function stripZeroWidthToolArgumentJson(value: unknown): string { return stripZeroWidthText(typeof value === "string" ? value : JSON.stringify(value || {})); } @@ -395,7 +444,9 @@ function sanitizeChoice( function sanitizeMessageContent(msgRecord: JsonRecord, options: ParseOptions = {}): JsonRecord { if (typeof msgRecord.content === "string") { - const strippedContent = stripInternalToolEnvelopeText(msgRecord.content); + const strippedContent = stripReasoningPlaceholderFromContent( + stripInternalToolEnvelopeText(msgRecord.content) + ); const nativeReasoning = getReadableReasoningValue(msgRecord); const { content, thinking } = options.parseTextualReasoningTags === true && !nativeReasoning @@ -1035,7 +1086,9 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { if (deltaRecord.content !== undefined) { delta.content = typeof deltaRecord.content === "string" - ? collapseExcessiveNewlines(stripZeroWidthText(deltaRecord.content)) + ? collapseExcessiveNewlines( + stripReasoningPlaceholderFromContent(stripZeroWidthText(deltaRecord.content)) + ) : deltaRecord.content; } copyOpenAICompatibleReasoningFields(deltaRecord, delta); diff --git a/tests/unit/reasoning-placeholder-leak.test.ts b/tests/unit/reasoning-placeholder-leak.test.ts new file mode 100644 index 00000000000..107e0059e6a --- /dev/null +++ b/tests/unit/reasoning-placeholder-leak.test.ts @@ -0,0 +1,218 @@ +/** + * Unit tests for reasoning placeholder leak fix (Issue #8081). + * + * The internal sentinel "(prior reasoning summary unavailable)" must never + * leak into visible message.content — it should only appear inside + * reasoning_content / reasoning fields. + */ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { + sanitizeOpenAIResponse, + sanitizeStreamingChunk, +} from "../../open-sse/handlers/responseSanitizer.ts"; + +describe("Reasoning placeholder leak (#8081)", () => { + // ── Non-streaming ────────────────────────────────────────────── + + test("strips bare placeholder from content (non-streaming)", () => { + const body = { + id: "chatcmpl-1", + object: "chat.completion", + created: 1, + model: "test", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "(prior reasoning summary unavailable)", + }, + finish_reason: "stop", + }, + ], + }; + const result = sanitizeOpenAIResponse(body) as Record; + assert.equal( + result.choices[0].message.content, + "", + "bare placeholder should be stripped to empty string" + ); + }); + + test("strips placeholder with trailing period and case variation", () => { + const body = { + id: "chatcmpl-2", + object: "chat.completion", + created: 1, + model: "test", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "(Prior reasoning summary unavailable.)", + }, + finish_reason: "stop", + }, + ], + }; + const result = sanitizeOpenAIResponse(body) as Record; + assert.equal(result.choices[0].message.content, ""); + }); + + test("strips -wrapped placeholder", () => { + const body = { + id: "chatcmpl-3", + object: "chat.completion", + created: 1, + model: "test", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "(prior reasoning summary unavailable)", + }, + finish_reason: "stop", + }, + ], + }; + const result = sanitizeOpenAIResponse(body) as Record; + assert.equal(result.choices[0].message.content, ""); + }); + + test("strips 'Thinking:' prefixed placeholder", () => { + const body = { + id: "chatcmpl-4", + object: "chat.completion", + created: 1, + model: "test", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "Thinking: (prior reasoning summary unavailable)", + }, + finish_reason: "stop", + }, + ], + }; + const result = sanitizeOpenAIResponse(body) as Record; + assert.equal(result.choices[0].message.content, ""); + }); + + test("preserves real content when placeholder is mixed in", () => { + const body = { + id: "chatcmpl-5", + object: "chat.completion", + created: 1, + model: "test", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "Hello! (prior reasoning summary unavailable) How are you?", + }, + finish_reason: "stop", + }, + ], + }; + const result = sanitizeOpenAIResponse(body) as Record; + assert.equal(result.choices[0].message.content, "Hello! How are you?"); + }); + + test("does NOT strip placeholder from reasoning_content", () => { + const body = { + id: "chatcmpl-6", + object: "chat.completion", + created: 1, + model: "test", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "The answer is 42.", + reasoning_content: "(prior reasoning summary unavailable)", + }, + finish_reason: "stop", + }, + ], + }; + const result = sanitizeOpenAIResponse(body) as Record; + assert.equal(result.choices[0].message.content, "The answer is 42."); + // reasoning_content is preserved (it's the correct field for the placeholder) + assert.equal( + result.choices[0].message.reasoning_content, + "(prior reasoning summary unavailable)" + ); + }); + + test("leaves unrelated content untouched", () => { + const body = { + id: "chatcmpl-7", + object: "chat.completion", + created: 1, + model: "test", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "Just a normal response without any placeholders.", + }, + finish_reason: "stop", + }, + ], + }; + const result = sanitizeOpenAIResponse(body) as Record; + assert.equal( + result.choices[0].message.content, + "Just a normal response without any placeholders." + ); + }); + + // ── Streaming ────────────────────────────────────────────────── + + test("strips placeholder from streaming delta content", () => { + const chunk = { + id: "chatcmpl-s1", + object: "chat.completion.chunk", + created: 1, + model: "test", + choices: [ + { + index: 0, + delta: { + content: "(prior reasoning summary unavailable)", + }, + }, + ], + }; + const result = sanitizeStreamingChunk(chunk) as Record; + assert.equal(result.choices[0].delta.content, ""); + }); + + test("preserves real content in streaming delta alongside placeholder", () => { + const chunk = { + id: "chatcmpl-s2", + object: "chat.completion.chunk", + created: 1, + model: "test", + choices: [ + { + index: 0, + delta: { + content: "Sure! (prior reasoning summary unavailable) Here is the answer.", + }, + }, + ], + }; + const result = sanitizeStreamingChunk(chunk) as Record; + assert.equal(result.choices[0].delta.content, "Sure! Here is the answer."); + }); +});