Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions open-sse/handlers/responseSanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
* - "<think>(prior reasoning summary unavailable)</think>"
* - "(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 <think>…placeholder…</think> wrapper first.
const thinkTagRe =
/<think>\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 || {}));
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
218 changes: 218 additions & 0 deletions tests/unit/reasoning-placeholder-leak.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
assert.equal(result.choices[0].message.content, "");
});

test("strips <think>-wrapped placeholder", () => {
const body = {
id: "chatcmpl-3",
object: "chat.completion",
created: 1,
model: "test",
choices: [
{
index: 0,
message: {
role: "assistant",
content: "<think>(prior reasoning summary unavailable)</think>",
},
finish_reason: "stop",
},
],
};
const result = sanitizeOpenAIResponse(body) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
assert.equal(result.choices[0].delta.content, "Sure! Here is the answer.");
});
});