fix(sse): buffer '<think' partial so a split open tag cannot leak into content (#10441)

containsOrMayEndWithThinkOpenTag missed the 6-char partial '<think', so an
open tag arriving as '<think' + '>' across SSE deltas leaked into content
instead of being parsed as reasoning. Derive every proper prefix from
THINK_OPEN itself so the lookahead list can never drift out of sync with
the tag again. Covered by new unit tests for the partial-suffix lookahead
and the split-delta buffering path.
This commit is contained in:
SHANMUGAPRIYAN
2026-08-16 08:46:36 +05:30
committed by GitHub
parent df226e55f4
commit 579cae32b1
2 changed files with 45 additions and 6 deletions

View File

@@ -21,6 +21,15 @@ import { appendBoundedText, buildSyntheticChatChunk } from "./streamHelpers.ts";
const THINK_OPEN = "<think>";
const THINK_CLOSE = "</think>";
/**
* Every proper prefix of `<think>` ("<", "<t", ... "<think"), derived from the
* tag itself so the list cannot drift out of sync with it.
*/
const THINK_OPEN_PARTIALS: readonly string[] = Array.from(
{ length: THINK_OPEN.length - 1 },
(_, i) => THINK_OPEN.slice(0, i + 1)
);
/**
* Create the mutable streaming-parse context for one SSE stream.
* `enabled` decides whether the caller should attempt think-tag parsing at
@@ -52,10 +61,7 @@ export function initThinkState(isPassthroughMode: boolean, provider?: unknown, m
* @returns {boolean}
*/
export function containsOrMayEndWithThinkOpenTag(value: string): boolean {
return (
value.includes(THINK_OPEN) ||
["<", "<t", "<th", "<thi", "<thin"].some((suffix) => value.endsWith(suffix))
);
return value.includes(THINK_OPEN) || THINK_OPEN_PARTIALS.some((suffix) => value.endsWith(suffix));
}
/**

View File

@@ -1,8 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
const { hasThinkTags, extractThinkTags, processStreamingThinkDelta, flushThinkBuffer } =
await import("../../open-sse/utils/thinkTagParser.ts");
const {
hasThinkTags,
extractThinkTags,
processStreamingThinkDelta,
flushThinkBuffer,
containsOrMayEndWithThinkOpenTag,
applyThinkTag,
} = await import("../../open-sse/utils/thinkTagParser.ts");
test("hasThinkTags detects opening tags and ignores empty input", () => {
assert.equal(hasThinkTags("before <think>plan</think> after"), true);
@@ -60,6 +66,33 @@ test("processStreamingThinkDelta extracts content and reasoning across split tag
});
});
test("containsOrMayEndWithThinkOpenTag flags a chunk ending on any partial open tag", () => {
for (const partial of ["<", "<t", "<th", "<thi", "<thin", "<think"]) {
assert.equal(containsOrMayEndWithThinkOpenTag(`answer ${partial}`), true, partial);
}
assert.equal(containsOrMayEndWithThinkOpenTag("plain answer"), false);
});
test("applyThinkTag buffers an open tag split as '<think' + '>' across deltas", () => {
const ctx = { enabled: true, active: false, insideThink: false, buffer: "" };
const opening: { content: unknown; reasoning_content?: string } = { content: "<think" };
assert.equal(applyThinkTag(ctx, opening), true);
assert.equal(opening.content, "");
const rest: { content: unknown; reasoning_content?: string } = {
content: ">plan</think>answer",
};
assert.equal(applyThinkTag(ctx, rest), true);
assert.equal(rest.reasoning_content, "plan");
assert.equal(rest.content, "");
assert.deepEqual(flushThinkBuffer(ctx), {
reasoningDelta: null,
contentDelta: "answer",
});
});
test("processStreamingThinkDelta keeps partial closing tags buffered while inside think", () => {
const ctx = { insideThink: true, buffer: "" };