mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 20:22:21 +03:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: "" };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user