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:
Diego Rodrigues de Sa e Souza
2026-06-25 23:33:32 -03:00
committed by GitHub
parent 604e0041bd
commit 7d2501b286
2 changed files with 78 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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));