mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix(sse): strip Composer <|final|> sentinel markers leaking after Composer reasoning (#4842)
Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green.
This commit is contained in:
committed by
GitHub
parent
604e0041bd
commit
7d2501b286
@@ -325,11 +325,32 @@ export function isComposerModel(model: string | undefined | null): boolean {
|
||||
return /^composer(?:-|$)/i.test(id ?? "");
|
||||
}
|
||||
|
||||
// Composer's protobuf sometimes wraps the visible suffix in sentinel tags:
|
||||
// `<|final|>` (full-width pipes) or `<|final|>` (ASCII), optionally closed
|
||||
// with a matching `<|/final|>` / `<|/final|>`. These are protocol-internal
|
||||
// and must never leak to OpenAI-compatible clients (decolua/9router#1316).
|
||||
const COMPOSER_OPEN_MARKER = /^\s*<[||]\s*final\s*[||]>\s*/i;
|
||||
const COMPOSER_CLOSE_MARKER = /\s*<[||]\s*\/\s*final\s*[||]>\s*$/i;
|
||||
const COMPOSER_PARTIAL_OPEN = /^\s*<(?![||/])/;
|
||||
const COMPOSER_PARTIAL_OPEN_PIPE = /^\s*<[||][^>]*$/;
|
||||
|
||||
export function visibleComposerContentFromThinking(thinking: string): string {
|
||||
if (!thinking) return "";
|
||||
const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END);
|
||||
if (endIdx < 0) return "";
|
||||
return thinking.slice(endIdx + COMPOSER_THINK_END.length).trimStart();
|
||||
let visible = thinking.slice(endIdx + COMPOSER_THINK_END.length).trimStart();
|
||||
if (COMPOSER_OPEN_MARKER.test(visible)) {
|
||||
visible = visible.replace(COMPOSER_OPEN_MARKER, "");
|
||||
} else if (
|
||||
COMPOSER_PARTIAL_OPEN.test(visible) ||
|
||||
COMPOSER_PARTIAL_OPEN_PIPE.test(visible)
|
||||
) {
|
||||
// A streamed chunk delivered only a partial opening marker (e.g. `<` or
|
||||
// `<|fin`). Hold back everything until more data arrives so the marker
|
||||
// fragment never leaks as content.
|
||||
return "";
|
||||
}
|
||||
return visible.replace(COMPOSER_CLOSE_MARKER, "").trim();
|
||||
}
|
||||
|
||||
export function composerReasoningRemainder(thinking: string): string {
|
||||
|
||||
@@ -71,6 +71,41 @@ test("visibleComposerContentFromThinking returns suffix after last </think> (tri
|
||||
assert.equal(visibleComposerContentFromThinking("ends with</think>"), "");
|
||||
});
|
||||
|
||||
test("visibleComposerContentFromThinking strips `<|final|>` sentinel markers (full-width + ASCII)", () => {
|
||||
// Full-width pipe sentinels (decolua/9router#1316).
|
||||
assert.equal(
|
||||
visibleComposerContentFromThinking("reasoning</think><|final|>OK_PR1316<|/final|>"),
|
||||
"OK_PR1316"
|
||||
);
|
||||
// ASCII pipe sentinels.
|
||||
assert.equal(
|
||||
visibleComposerContentFromThinking("reasoning</think><|final|>HELLO<|/final|>"),
|
||||
"HELLO"
|
||||
);
|
||||
// Open marker without a closing tag still gets stripped.
|
||||
assert.equal(
|
||||
visibleComposerContentFromThinking("r</think><|final|>just open"),
|
||||
"just open"
|
||||
);
|
||||
// No sentinel — plain suffix is unchanged.
|
||||
assert.equal(
|
||||
visibleComposerContentFromThinking("reasoning</think>plain answer"),
|
||||
"plain answer"
|
||||
);
|
||||
});
|
||||
|
||||
test("visibleComposerContentFromThinking holds back a partial opening marker until complete", () => {
|
||||
// A streamed chunk delivered only the start of the sentinel — emit nothing yet.
|
||||
assert.equal(visibleComposerContentFromThinking("r</think><"), "");
|
||||
assert.equal(visibleComposerContentFromThinking("r</think><|fin"), "");
|
||||
assert.equal(visibleComposerContentFromThinking("r</think><|fin"), "");
|
||||
// Once the full marker + payload arrive the real content surfaces.
|
||||
assert.equal(
|
||||
visibleComposerContentFromThinking("r</think><|final|>NOW_VISIBLE"),
|
||||
"NOW_VISIBLE"
|
||||
);
|
||||
});
|
||||
|
||||
test("composerReasoningRemainder returns only the hidden portion before last </think>", () => {
|
||||
assert.equal(
|
||||
composerReasoningRemainder("private reasoning</think>OK"),
|
||||
@@ -135,6 +170,27 @@ test("Composer non-streaming aggregation: thinking with </think> populates total
|
||||
assert.equal(ctx.totalText, "OK");
|
||||
});
|
||||
|
||||
test("Composer streaming: partial `<|final|>` sentinel split across chunks never leaks", () => {
|
||||
const chunks: string[] = [];
|
||||
const ctx: StreamCtx = newStreamCtx("cu/composer-2.5", (c) => chunks.push(c));
|
||||
// The sentinel arrives byte-fragmented across three thinking-delta frames.
|
||||
processFrame(buildThinkingDeltaPayload("reasoning</think><|fina"), ctx, new Set());
|
||||
processFrame(buildThinkingDeltaPayload("l|>OK_S"), ctx, new Set());
|
||||
processFrame(buildThinkingDeltaPayload("TREAM"), ctx, new Set());
|
||||
|
||||
const events = parseSSE(chunks.join(""));
|
||||
const content = events
|
||||
.map((e) => {
|
||||
const choices = (e as { choices?: Array<{ delta?: { content?: string } }> }).choices;
|
||||
return choices?.[0]?.delta?.content ?? "";
|
||||
})
|
||||
.join("");
|
||||
assert.equal(content, "OK_STREAM");
|
||||
assert.equal(ctx.totalText, "OK_STREAM");
|
||||
assert.ok(!chunks.join("").includes("final"), "sentinel literal must not leak");
|
||||
assert.ok(!chunks.join("").includes("|"), "full-width pipe must not leak");
|
||||
});
|
||||
|
||||
test("Non-Composer model: thinking field stays in reasoning_content (unchanged contract)", () => {
|
||||
const chunks: string[] = [];
|
||||
const ctx: StreamCtx = newStreamCtx("gpt-5.3-codex", (c) => chunks.push(c));
|
||||
|
||||
Reference in New Issue
Block a user