Files
OmniRoute/open-sse/utils/directivePreambleStripper.ts
initguru b7192b72e2 fix(thinking): parse/scrub DSML tool-call markers and recognize adaptive thinking (#12905)
* fix(thinking): recognize adaptive thinking + parse/scrub DSML tool-call markers

Two defects combined to break DeepSeek-V4-Flash turns and raise 502
empty_response on Claude Code autocompact.

Defect 1 — DSML tool-call markers leaked as visible content:
DeepSeek-V4-Flash occasionally emits tool calls in a non-standard DSML
text format using full-width pipes instead of the OpenAI tool_calls JSON.
Two shapes appear in production call logs:
  - complete block: <|DSML|:Read><path>...</path></|DSML|:Read>
  - stray closers (truncated call): </|DSML|parameter></|DSML|invoke>
    </|DSML|tool_calls>, sometimes trailing a system-prompt echo
The openai-compatible path never parsed these, so the markers leaked to
the client as visible content and the turn ended incomplete.

Fix: add open-sse/utils/dsmlToolCalls.ts — parseDsmlToolCalls() converts
complete DSML blocks into OpenAI tool_calls and strips stray closing
markers from content (streaming-safe via a holdback for partial openers).
Wire it into the response translator before extractXmlInvokeBlocks so
DSML and XML invoke tool calls share the same pending queue.

Defect 2 — adaptive thinking silently suppressed:
A prior inline === 'enabled' check on body.thinking.type silently
suppressed adaptive (the intent Claude Code actually sends), so
reasoning was dropped. The model then emitted DSML tool-call markers
as plain text, producing an incomplete stop finish. Fix: use
hasActiveClaudeThinking() (which recognizes enabled AND adaptive) to
set requestedThinking, thread it through stream.ts and translator
state, and gate thinking block emission on state.requestedThinking
so upstream reasoning_content only relays when the client opted in.

Tests: 29/29 (6 dsml-tool-calls, 5 thinking-active-claude-adapter,
3 translator-resp-dsml-integration, 15 translator-resp-openai-to-claude
incl. requestedThinking suppression regression). typecheck:core clean.

* fix(sse): strip echoed system-prompt preamble + preserve large analysis/summary blocks

DeepSeek-V4 and similar models echo the OMNIROUTE_SYSTEM_INSTRUCTION_APPEND
directive (appended to the system tail by claude-to-openai.ts) and whole chunks
of the system prompt (<analysis>/<system-reminder>/<summary> blocks, prose
reproductions of the superpowers skill section) verbatim at the START of their
reply — the 'system message leak' persisting after the request-side fix.

Add two streaming-safe preamble strippers in directivePreambleStripper.ts:
- createDirectivePreambleStripper(directive): drops a leading reproduction of
  the exact configured directive across arbitrary SSE chunk boundaries.
- createSystemPreambleStripper(): removes <analysis>/<system-reminder>/
  <summary> echo blocks and known prose heads (Phase B) from the very start
  of a stream, only while the stream is still a preamble.

Wire both into openai-to-claude.ts content-delta path: chain the exact-directive
stripper then the system-echo stripper before DSML/XML-invoke parsing, so a
leading system echo is dropped before it reaches the client.

Preserve large blocks (>= SYSTEM_ECHO_THRESHOLD=1000 chars) and blocks with no
trailing content — these are the model's real response (e.g. a Claude Code
autocompact summary), not a short system-echo. Stops the autocompact
empty-response regression where a whole-summary <analysis> block was stripped
to empty (3a8515).

Regression: origin's markdown-boundary feature (bufferedPrefix /
splitMarkdownBoundary, commit 1b39873ea) is preserved — preamble strip runs
before the markdown buffer rehydration, and the scrubbed content flows into
the existing DSML/XML-invoke/markdown pipeline unchanged.

TDD: tests/unit/directive-preamble-strip.test.ts (7 cases),
system-preamble-strip.test.ts (12 cases incl. 3a8515 regression),
system-preamble-wiring.test.ts (3 integration cases); group F regression
24/24 green; typecheck:core 0 errors.

* fix(sse): gate thinking block on requestedThinking + synthesize text block for reasoning-only responses

Reasoning-content (thinking) blocks were emitted unconditionally to
Claude-format clients, leaking reasoning to thinking-opt-out clients
(Claude Code sends thinking:{type:"disabled"}) — the operator reported
'reasoning is exposed'. On reasoning-only upstream responses (GLM-5.2
autocompact pattern), the unconditional thinking block also caused either
a 502 'no content block' at flush, or — after a text-block fallback — an
autocompact 'empty response' rejection that looped the session forever.

Streaming translator (openai-to-claude.ts):
- Compute hasReasoning outside the emission gate; accumulate into
  state._reasoningAccum always (so fix B can fire).
- Gate only the thinking-block EMISSION on requestedThinking === true.
- FIX B at finish: when no text block was started and requestedThinking
  !== true, synthesize a text content block from _reasoningAccum so
  autocompact can extract the summary (no 502, compact applies).
- Skip fix B when requestedThinking === true to avoid double-exposure
  (thinking block + text block both carrying reasoning).

Non-streaming translator (responseTranslator.ts):
- Thread requestedThinking through translateNonStreamingResponse into
  convertOpenAINonStreamingToClaude.
- suppressThinking = requestedThinking === false: drop the thinking block
  when content is present (no leak); relay reasoning as a text block when
  the response is reasoning-only (no 502). requestedThinking === undefined
  keeps the legacy 'always a thinking block' relay.

chatCore.ts: pass hasActiveClaudeThinking(body) to the non-stream
translate call (inline, since the shared const is in the stream branch's
temporal dead zone here).

Tests: 25/25 (5 gate-restore, 1 gate-502-repro, 4 nonstream-leak,
15 resp-openai-to-claude incl. requestedThinking suppression regression).
Group E (22) + F (14) regression-free. typecheck:core clean.

* fix(sse): restore requestToolIdentityMap in the Codex CLI responses-translation path

The needsResponsesTranslation branch (openai-responses -> openai, used
when the client also speaks Responses) silently dropped the
requestToolIdentityMap argument to createSSETransformStreamWithLogger
when the requestedThinking parameter was added, reverting the #7936
tool-identity round-trip fix for that branch. The sibling
needsTranslation branch was updated correctly; restore the same
argument here.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* docs(changelog): add the 3 fragments documented in the PR body

The PR body already writes out the changelog.d/ entries for the DSML
parser (Group F), the directive-preamble stripper (Group E), and the
reasoning-gate thinking-leak fix (Group G), but none of the files
existed in the diff. Add them so the release aggregator picks them up.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(sse): realign GLM's positional call after the new requestedThinking parameter

createSSETransformStreamWithLogger gained a new requestedThinking
parameter inserted before customToolNames. glm.ts's translateSseResponse
still called it with the pre-existing positional argument list, so the
new parameter silently absorbed the old customToolNames slot, and the
GLM_STREAM_BUFFER_BYTES tuning value (#12925) landed on
requestToolIdentityMap instead of streamBufferBytes — a TS2345 (number
is not assignable to Map<...> | null) caught by
check:open-sse-typecheck, and a real loss of GLM's 64KB stream buffer
budget. Insert an explicit `undefined` for requestedThinking to restore
the original alignment.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(sse): make the system-preamble stripper opt-in and flush it at stream end

`createSystemPreambleStripper()` was wired DEFAULT-ON and unconditional in the
openai→claude streaming translator, unlike the exact-directive stripper right
above it, which only runs when the operator configured
OMNIROUTE_SYSTEM_INSTRUCTION_APPEND. Cause: the system-echo stripper recognises
its openers by English-prose heuristics ("# Skill usage", "# Verification
Process", <analysis>/<summary>/<system-reminder>), so leaving it always-on made
it mutate the response payload of EVERY openai→claude stream. A legitimate reply
opening with "# Skill usage: how to write one\n\nHere is the guide." lost that
whole section. It is now gated on OMNIROUTE_STRIP_SYSTEM_PREAMBLE=1, mirroring
the directive stripper's opt-in.

Second cause, same feature: neither stripper was ever flushed. Both buffer while
a construct is still undecided — a directive prefix that never completes, an
<analysis> block that never closes — and nothing released that buffer at the end
of the stream. A reply consisting of an unterminated echo block therefore reached
the client as an EMPTY message: the answer was held in the buffer and discarded
with the stripper. Both strippers now expose flush(), the finish handler calls it
for both, and the released text is emitted as a text block. A construct that WAS
finally classified as an echo is not resurrected (the drop is final).

Tests: tests/unit/system-preamble-gate-and-flush.test.ts pins the default-off
contract, the opted-in behaviour, the flush for both strippers (unit + wiring),
and the no-resurrection guard. system-preamble-wiring.test.ts now opts in
explicitly, since it exercises the stripping path.

* fix(sse): thread the client's thinking intent into the non-streaming path

The streaming and non-streaming translators disagreed on the default meaning of
`requestedThinking`, so the SAME request produced different shapes depending on
`stream`. Cause: chatCore computes the client's intent
(hasActiveClaudeThinking) and threads it into the SSE translator, which relays
reasoning as a thinking block only when it is explicitly `true` — but NO caller
ever passed it to translateNonStreamingResponse(). The non-streaming
OpenAI→Claude conversion therefore only ever saw `undefined`, its legacy
"always relay a thinking block" default, and leaked reasoning to a client that
had opted out with `thinking: {"type":"disabled"}`. The streaming plumbing also
coerced an omitted value into an explicit `false`, hiding the divergence behind
two different spellings of "no intent".

Fix (least destructive of the options): do NOT flip either gate — both encode a
deliberate, regression-tested contract — but give the non-streaming path the
same input the streaming path already has. runNonStreamingProviderLeg owns the
client body (`sourceBody`), so it computes the intent with the very same helper
and passes it down through translateNonStreamingClientResponse. `undefined`
keeps its documented back-compat relay for callers that cannot express intent
(issue-7856 / issue-6623), and stream.ts no longer defaults the parameter to
`false`, so "absent" now means the same thing in both signatures.

No content is lost by the suppression: a reasoning-ONLY response is still
relayed as an ordinary text block (no empty response, no 502) — exactly what the
streaming finish handler does.

Tests: tests/unit/nonstream-requested-thinking-parity.test.ts drives the real
provider leg with thinking disabled / enabled / adaptive.

---------

Co-authored-by: Jihyun Son <jihyun.son@sk.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 12:56:30 -03:00

260 lines
10 KiB
TypeScript

/**
* Streaming-safe preamble stripper.
*
* Background: DeepSeek-V4 and similar models sometimes echo the tail of the
* system message (highest recency) — e.g. the OMNIROUTE_SYSTEM_INSTRUCTION_APPEND
* directive appended by claude-to-openai.ts — verbatim at the START of their
* response content. The user then sees the system instruction inside the
* assistant's reply ("시스템 메시지" leak). This helper deterministically
* removes a known directive when it appears as a leading prefix, across
* arbitrary streaming chunk boundaries, and only ever at the very start: once a
* stream diverges from the directive (or fully consumes it), matching is done
* and everything after passes through untouched.
*
* The directive text is operator-configured (opt-in via the same env var used
* on the request side), so this is a targeted remedy of the mitigation's side
* effect — legitimate responses that do not begin with the directive are never
* modified.
*
* API: `createDirectivePreambleStripper(directive)` returns a per-stream
* function. Call it with each incoming text chunk; it returns the portion of
* that chunk that may be emitted downstream. Chunks that are still matching a
* directive prefix yield "" (buffered implicitly by a position counter); if the
* stream later diverges from the directive, the so-far-matched prefix is
* released as real content together with the rest of the chunk.
*
* Every stripper also exposes `flush()`, which MUST be called once at the end
* of the stream: a stream that ends while a construct is still undecided (a
* directive prefix that never completed, a block that never closed) leaves the
* suppressed text in the internal buffer, and without a flush the client would
* receive an empty — or truncated — response. `flush()` releases whatever is
* still held and puts the stripper in the terminal state.
*/
/** A per-stream stripper: call it per chunk, then `flush()` once at stream end. */
export type PreambleStripper = ((text: string) => string) & { flush: () => string };
export function createDirectivePreambleStripper(directive: string): PreambleStripper {
if (!directive) {
return Object.assign((text: string) => text, { flush: () => "" });
}
let done = false; // stream diverged from, or fully consumed, the directive
let position = 0; // directive chars matched so far (contiguous from stream start)
const push = (text: string): string => {
if (done || text === "") return text;
let i = 0;
while (position < directive.length && i < text.length) {
if (text[i] === directive[position]) {
position++;
i++;
} else {
// Divergence at position: everything matched so far is real content,
// and the rest of this chunk (from the diverging char on) is real too.
done = true;
return directive.slice(0, position) + text;
}
}
if (position === directive.length) {
// Full directive consumed; emit only the tail after it within this chunk.
done = true;
return text.slice(i);
}
// Chunk was entirely a directive prefix (or an empty remainder) — buffer it.
return "";
};
// Stream ended while still matching a directive prefix: the buffered chars
// are the only content the model produced, so release them instead of
// swallowing the whole response.
const flush = (): string => {
if (done) return "";
done = true;
const held = directive.slice(0, position);
position = 0;
return held;
};
return Object.assign(push, { flush });
}
/**
* Streaming-safe system-preamble stripper (Phase B).
*
* DeepSeek-V4 and similar models sometimes echo not just the exact
* OMNIROUTE_SYSTEM_INSTRUCTION_APPEND directive but whole chunks of the system
* prompt at the START of their reply: `<analysis>...</analysis>` blocks,
* `<system-reminder>` blocks, `<summary>` blocks, or prose reproductions of the
* superpowers skill section ("Skill usage (when superpowers skills are
* installed)", "# Verification Process"). The exact-directive stripper does not
* match these, so they leak to the client. This helper removes any sequence of
* such known system-echo constructs from the very start of a stream, across
* arbitrary chunk boundaries, and only while the stream is still a preamble.
*
* OFF by default — see the OMNIROUTE_STRIP_SYSTEM_PREAMBLE gate at the call
* site (translator/response/openai-to-claude.ts). The heuristics below are
* English-prose shaped and DO mutate response payloads, so they only run when
* the operator opts in, exactly like the directive stripper above only runs
* when OMNIROUTE_SYSTEM_INSTRUCTION_APPEND is configured.
*/
// System-echo blocks (the system prompt's tail echoed at the start of a reply) are
// short — a few hundred chars at most. A block carrying >= this many chars of
// content is the model's real response (e.g. a Claude Code autocompact summary),
// not an echo: preserve it instead of suppressing it as a preamble.
const SYSTEM_ECHO_THRESHOLD = 1000;
export function createSystemPreambleStripper(): PreambleStripper {
const tagNames = ["analysis", "system-reminder", "summary"] as const;
const proseHeads = [
"# Verification Process",
"# Skill usage",
"Skill usage (when superpowers skills are installed)",
"Required skills for implementation work",
] as const;
let done = false;
// "leading" (whitespace only) | "head" (matching a known opener) |
// "block" (inside a suppressed construct; stack holds open tag names).
let phase: "leading" | "head" | "block" = "leading";
let stack: string[] = [];
let buf = ""; // suppressed, not-yet-decided prefix
// Opener text already consumed for the construct currently in flight (the
// `<tag>` itself). Cleared whenever a construct is FINALLY classified as an
// echo; kept so flush() can restore a construct that never closed.
let pendingDrop = "";
const isAllWs = (s: string): boolean => s.trim() === "";
// First "\n\n" that is followed by a structural marker (an opening tag, a
// markdown heading, or a divider line) — the boundary where a prose-echo
// section ends and the next construct (or real content) begins. -1 = none.
const proseBoundary = (s: string): number => {
const re = /\n[ \t]*\n[ \t]*(?=<|<[^>]*>|#{1,6} |[-=]{3,})/;
const m = re.exec(s);
return m ? m.index : -1;
};
const tryOpeners = (s: string): { kind: "tag" | "prose"; name?: string } | null => {
const t = s.trimStart();
for (const name of tagNames) {
if (t.startsWith(`<${name}>`)) return { kind: "tag", name };
}
for (const h of proseHeads) {
if (t.startsWith(h)) {
const rest = t.slice(h.length);
if (rest === "" || /^[\s;:.,\-]/.test(rest)) return { kind: "prose" };
}
}
return null;
};
const anyOpenerPrefix = (s: string): boolean => {
const t = s.trimStart();
for (const name of tagNames) {
if (`<${name}>`.startsWith(t)) return true;
}
for (const h of proseHeads) {
if (h.startsWith(t)) return true;
}
return false;
};
const push = (text: string): string => {
if (done || text === "") return text;
buf += text;
while (true) {
if (phase === "leading") {
if (isAllWs(buf)) return "";
phase = "head";
}
if (phase === "head") {
if (isAllWs(buf)) return "";
const open = tryOpeners(buf);
if (open) {
if (open.kind === "tag") {
const tagStart = buf.indexOf("<");
const afterOpen = buf.indexOf(">", tagStart) + 1;
pendingDrop += buf.slice(0, afterOpen);
buf = buf.slice(afterOpen);
stack.push(open.name!);
phase = "block";
continue;
}
// Prose head matched: suppress the echoed section until a structural
// boundary ends it (or the stream diverges into real content).
stack.push("__prose__");
phase = "block";
continue;
}
if (anyOpenerPrefix(buf)) {
return ""; // strict prefix of a known opener — keep buffering
}
// Diverged from every known opener: buffered prefix is real content.
done = true;
const out = pendingDrop + buf;
pendingDrop = "";
buf = "";
return out;
}
if (phase === "block") {
const top = stack[stack.length - 1];
if (top === "__prose__") {
const b = proseBoundary(buf);
if (b === -1) return ""; // stay suppressed until boundary or divergence
buf = buf.slice(b);
pendingDrop = ""; // prose echo finally classified: the drop is final
stack.pop();
phase = "head";
continue;
}
const closeTag = "</" + top + ">";
const ci = buf.indexOf(closeTag);
if (ci === -1) return ""; // block not closed yet — keep suppressing
const afterClose = buf.slice(ci + closeTag.length);
const trailing = afterClose.replace(/^[ \t\r\n]+/, "");
// A block carrying substantial content (>= SYSTEM_ECHO_THRESHOLD) is the
// model's real response (e.g. a Claude Code autocompact summary), not a
// short system-echo — preserve it. A small block with NO trailing content
// is also the real response (the block IS the entire reply) — preserve it
// too. Otherwise (small block followed by more content) it's a system
// echo — drop it (existing behavior).
if (ci >= SYSTEM_ECHO_THRESHOLD || trailing === "") {
done = true;
const out = "<" + top + ">" + buf.slice(0, ci) + closeTag + afterClose;
pendingDrop = "";
buf = "";
return out;
}
buf = trailing;
pendingDrop = ""; // block finally classified as an echo: the drop is final
stack.pop();
phase = stack.length === 0 ? "leading" : "block";
continue;
}
}
};
// Stream ended with a construct still undecided (e.g. `<analysis>` that never
// closed, or a prose head with no structural boundary). Without this the
// buffered text — possibly the ENTIRE response — would be dropped and the
// client would get an empty message. Release the opener plus everything held.
const flush = (): string => {
if (done) return "";
done = true;
const out = pendingDrop + buf;
pendingDrop = "";
buf = "";
stack = [];
return out;
};
return Object.assign(push, { flush });
}