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>
This commit is contained in:
initguru
2026-09-18 00:56:30 +09:00
committed by GitHub
parent 20f3900889
commit b7192b72e2
28 changed files with 2224 additions and 39 deletions

View File

@@ -0,0 +1 @@
- **feat(sse):** parse/scrub DSML tool-call markers embedded in reasoning and recognize adaptive thinking on the response side — `dsmlToolCalls.ts` module + translator/stream/handler wiring ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru

View File

@@ -0,0 +1 @@
- **fix(sse):** strip echoed system/directive preamble on /v1/messages responses and preserve large analysis/summary blocks in systemPreambleStripper to stop autocompact empty-response ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru

View File

@@ -0,0 +1 @@
- **fix(sse):** thread the client's thinking intent into the non-streaming translation path so the same request answered with `stream:false` no longer leaks a thinking block that `stream:true` withholds ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru

View File

@@ -0,0 +1 @@
- **fix(sse):** gate thinking block emission on requestedThinking (streaming + non-stream) and flush reasoning-only responses as text to stop reasoning leak, autocompact loops, and 502 ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru

View File

@@ -0,0 +1 @@
- **fix(sse):** make the system-preamble stripper opt-in (`OMNIROUTE_STRIP_SYSTEM_PREAMBLE=1`) and flush both preamble strippers at stream end, so English-prose heuristics no longer delete a legitimate section of every openai→claude reply and an unterminated echo block no longer reaches the client as an empty message ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru

View File

@@ -247,6 +247,7 @@ export function translateSseResponse(
suppressThinkClose,
undefined,
undefined,
undefined,
GLM_STREAM_BUFFER_BYTES
);
const headers = cloneHeaders(response.headers);

View File

@@ -145,6 +145,7 @@ import { ensureStreamReadiness } from "../utils/streamReadiness.ts";
import { resolveSuppressThinkClose, THINKING_MARKER_HEADER } from "../utils/thinkCloseMarker.ts";
import { resolveStreamReadinessTimeout } from "../utils/streamReadinessPolicy.ts";
import { resolveAgentGoalPolicy } from "../utils/agentGoalPolicy.ts";
import { hasActiveClaudeThinking } from "../utils/thinkingBudget.ts";
import { createStreamController } from "../utils/streamHandler.ts";
import * as streamFailure from "../utils/streamFailureFinalization.ts";
import { normalizeUsage } from "../utils/usageTracking.ts";
@@ -6088,6 +6089,19 @@ export async function handleChatCore({
!isDroidCLI;
const streamStateBody = finalBody || body;
// Client's explicit thinking intent (Anthropic Messages shape). Claude Code
// sends `{type:"enabled"}` or `{type:"adaptive"}` to opt into relaying
// upstream reasoning_content as Claude thinking blocks; `{type:"disabled"}`
// or an omitted `thinking` field opts out. Kept false for every other
// client schema (OpenAI / Responses), which never express intent through
// `body.thinking`. Mirrors hasActiveClaudeThinking() so the request and
// response sides agree on what counts as "thinking requested" — a prior
// inline `=== "enabled"` check silently suppressed `adaptive` (the intent
// Claude Code actually sends), leaking the mismatch as a broken tool-call
// turn (call log 1787566395384-bab9ab: reasoning dropped → model emitted
// DSML tool-call markers as plain text → incomplete `stop` finish).
const requestedThinking = hasActiveClaudeThinking((body ?? {}) as Record<string, unknown>);
if (needsResponsesTranslation) {
// Provider returns openai-responses, translate to openai (Chat Completions) that clients expect
log?.debug?.("STREAM", `Responses translation mode: openai-responses → openai`);
@@ -6105,6 +6119,7 @@ export async function handleChatCore({
handleStreamFailure,
copilotCompatibleReasoning,
false,
requestedThinking,
customToolNames,
// openai-responses → openai translation still wants the namespace identity
// map for #7936-style round-trip closure when the client also speaks
@@ -6138,6 +6153,7 @@ export async function handleChatCore({
thinkingMarkerHeader,
clientResponseFormat,
}),
requestedThinking,
customToolNames,
requestToolIdentityMap
);

View File

@@ -59,6 +59,7 @@ export function translateNonStreamingClientResponse(
reasoningCacheScope,
clientHeaders,
isClaudeCodeCompatible,
requestedThinking,
phase,
} = input;
@@ -73,7 +74,8 @@ export function translateNonStreamingClientResponse(
responsePayloadFormat,
clientResponseFormat,
responseToolNameMap,
responseToolSchemas
responseToolSchemas,
requestedThinking
)
: responseBody;
const responseForMemoryExtraction = translatedResponse;

View File

@@ -35,6 +35,7 @@ import {
} from "../../services/modelFamilyFallback.ts";
import { isEmptyContentResponse } from "../../services/errorClassifier.ts";
import { FORMATS } from "../../translator/formats.ts";
import { hasActiveClaudeThinking } from "../../utils/thinkingBudget.ts";
/* -- exported types -------------------------------------------------------- */
@@ -287,6 +288,11 @@ function finishOk(
reasoningCacheScope: input.reasoningCacheScope ?? null,
clientHeaders: input.clientHeaders ?? null,
isClaudeCodeCompatible: input.isClaudeCodeCompatible ?? false,
// Same intent the streaming path computes in chatCore before building the
// SSE transform. Threading it here is what makes `stream:false` and
// `stream:true` agree on whether upstream reasoning may surface as a
// thinking block; the non-streaming converter used to never receive it.
requestedThinking: hasActiveClaudeThinking(input.sourceBody ?? {}),
phase: input.phase === "initial" ? "final" : "intermediate",
});
const receipt = buildReceipt(input, {

View File

@@ -148,21 +148,24 @@ export function translateNonStreamingResponse(
targetFormat: string,
sourceFormat: string,
toolNameMap?: Map<string, string> | null,
toolSchemas?: Map<string, JsonRecord> | null
toolSchemas?: Map<string, JsonRecord> | null,
requestedThinking?: boolean
): JsonRecord;
export function translateNonStreamingResponse(
responseBody: unknown,
targetFormat: string,
sourceFormat: string,
toolNameMap?: Map<string, string> | null,
toolSchemas?: Map<string, JsonRecord> | null
toolSchemas?: Map<string, JsonRecord> | null,
requestedThinking?: boolean
): unknown;
export function translateNonStreamingResponse(
responseBody: unknown,
targetFormat: string,
sourceFormat: string,
toolNameMap?: Map<string, string> | null,
toolSchemas?: Map<string, JsonRecord> | null
toolSchemas?: Map<string, JsonRecord> | null,
requestedThinking?: boolean
): unknown {
// If already in source format, return as-is
if (targetFormat === sourceFormat) {
@@ -675,7 +678,11 @@ export function translateNonStreamingResponse(
// Phase 3: Translate from OpenAI back to Client Source format
if (sourceFormat === FORMATS.CLAUDE && sourceFormat !== targetFormat) {
return convertOpenAINonStreamingToClaude(toRecord(intermediateOpenAI), toolNameMap ?? null);
return convertOpenAINonStreamingToClaude(
toRecord(intermediateOpenAI),
toolNameMap ?? null,
requestedThinking
);
}
// Gemini-family clients (Gemini, Antigravity): the streaming SSE path already
@@ -721,7 +728,8 @@ function resolveReasoningText(messageObj: JsonRecord): string {
*/
function convertOpenAINonStreamingToClaude(
openaiResponse: JsonRecord,
toolNameMap?: Map<string, string> | null
toolNameMap?: Map<string, string> | null,
requestedThinking?: boolean
): JsonRecord {
const choices = openaiResponse.choices as unknown[] | undefined;
const isChoicesArray = Array.isArray(choices);
@@ -738,7 +746,16 @@ function convertOpenAINonStreamingToClaude(
let hasTextOrReasoning = false;
const reasoningText = resolveReasoningText(messageObj);
if (reasoningText) {
// `requestedThinking === false` (client explicitly opted out): mirror the
// streaming translator's gate. When ordinary content is present, reasoning
// is suppressed entirely (no thinking leak). When the response is
// reasoning-ONLY (empty content — the GLM-5.2 autocompact pattern), relay
// reasoning as an ordinary text block so the response is not empty (no 502)
// and no thinking block leaks to a thinking-opt-out client.
// `requestedThinking === undefined` (legacy callers that do not pass it)
// keeps the original "always a thinking block" relay.
const suppressThinking = requestedThinking === false;
if (reasoningText && !suppressThinking) {
hasTextOrReasoning = true;
content.push({
type: "thinking",
@@ -756,6 +773,16 @@ function convertOpenAINonStreamingToClaude(
type: "text",
text: resolvedText === "" ? "(empty response)" : resolvedText,
});
} else if (suppressThinking && reasoningText) {
// Reasoning-ONLY response with thinking opted out (requestedThinking===false):
// no ordinary content, reasoning suppressed above. Relay the reasoning text as
// an ordinary text block so the response is not empty (no 502) and no thinking
// block leaks — mirrors the streaming translator's finish-time fallback.
hasTextOrReasoning = true;
content.push({
type: "text",
text: reasoningText,
});
} else if (!hasTextOrReasoning) {
content.push({
type: "text",

View File

@@ -889,6 +889,11 @@ export function initState(sourceFormat) {
finishReasonSent: false,
usage: null,
contentBlockIndex: -1,
// Client thinking intent threaded from the request side. The response
// translator only relays upstream reasoning (thinking blocks) when the
// client explicitly opted in — otherwise DeepSeek/GLM reasoning_content
// would leak into the UI as a thinking block it never asked for.
requestedThinking: false,
};
// Add openai-responses specific fields

View File

@@ -11,6 +11,11 @@ import {
import { REVERSE_MAP, restoreClaudeToolName } from "../../services/claudeCodeToolRemapper.ts";
import { sanitizeToolId } from "../helpers/schemaCoercion.ts";
import { splitMarkdownBoundary } from "../helpers/markdownBoundary.ts";
import { hasDsmlToolCalls, parseDsmlToolCalls } from "../../utils/dsmlToolCalls.ts";
import {
createDirectivePreambleStripper,
createSystemPreambleStripper,
} from "../../utils/directivePreambleStripper.ts";
function normalizeToolName(name: string): string {
return REVERSE_MAP[name] ?? name;
@@ -47,10 +52,22 @@ function extractXmlInvokeBlocks(
const toolCallTextMatch = remaining.match(/TOOL_CALL\s+([A-Za-z0-9_]+):\s*/);
const matches = [
invokeMatch ? { type: "invoke" as const, index: invokeMatch.index!, data: invokeMatch } : null,
toolCallTagMatch ? { type: "tool_call_tag" as const, index: toolCallTagMatch.index!, data: toolCallTagMatch } : null,
toolCallTextMatch ? { type: "tool_call_text" as const, index: toolCallTextMatch.index!, data: toolCallTextMatch } : null,
].filter(Boolean).sort((a, b) => a!.index - b!.index);
invokeMatch
? { type: "invoke" as const, index: invokeMatch.index!, data: invokeMatch }
: null,
toolCallTagMatch
? { type: "tool_call_tag" as const, index: toolCallTagMatch.index!, data: toolCallTagMatch }
: null,
toolCallTextMatch
? {
type: "tool_call_text" as const,
index: toolCallTextMatch.index!,
data: toolCallTextMatch,
}
: null,
]
.filter(Boolean)
.sort((a, b) => a!.index - b!.index);
if (matches.length === 0) {
cleaned += remaining;
@@ -95,9 +112,7 @@ function extractXmlInvokeBlocks(
const name = (parsed.name || parsed.tool_name || "") as string;
const rawArgs = parsed.arguments || parsed.args || parsed.parameters || {};
const args: Record<string, string> =
typeof rawArgs === "string"
? JSON.parse(rawArgs)
: (rawArgs as Record<string, string>);
typeof rawArgs === "string" ? JSON.parse(rawArgs) : (rawArgs as Record<string, string>);
if (name) {
toolCalls.push({ id: `toolu_txt_${Date.now()}_${toolCalls.length}`, name, args });
}
@@ -115,12 +130,27 @@ function extractXmlInvokeBlocks(
let jsonEndIndex = -1;
for (let i = 0; i < afterPrefix.length; i++) {
const c = afterPrefix[i];
if (escape) { escape = false; continue; }
if (c === "\\" && inString) { escape = true; continue; }
if (c === '"') { inString = !inString; continue; }
if (escape) {
escape = false;
continue;
}
if (c === "\\" && inString) {
escape = true;
continue;
}
if (c === '"') {
inString = !inString;
continue;
}
if (!inString) {
if (c === "{") depth++;
else if (c === "}") { depth--; if (depth === 0) { jsonEndIndex = i + 1; break; } }
else if (c === "}") {
depth--;
if (depth === 0) {
jsonEndIndex = i + 1;
break;
}
}
}
}
if (jsonEndIndex === -1) {
@@ -274,6 +304,7 @@ export function openaiToClaudeResponse(chunk, state) {
state.model = chunk.model || "unknown";
state.nextBlockIndex = 0;
state._pendingXmlToolCalls = [];
state._dsmlHoldback = undefined;
state._xmlInvokeBuffer = "";
state._markdownBuffer = "";
state._markdownCodeSpanRun = 0;
@@ -310,28 +341,43 @@ export function openaiToClaudeResponse(chunk, state) {
}
if (parts.length > 0) reasoningContent = parts.join("");
}
if (
const hasReasoning =
typeof reasoningContent === "string" &&
reasoningContent !== "" &&
!isInternalReasoningPlaceholder(reasoningContent)
) {
stopTextBlock(state, results);
!isInternalReasoningPlaceholder(reasoningContent);
if (hasReasoning) {
// Re-gate the thinking block EMISSION on requestedThinking === true. The
// _reasoningAccum accumulation below stays OUTSIDE the gate and always runs,
// so fix B still synthesizes a text block for reasoning-only responses (no
// 502, compact applies). Gating the whole block including accumulation
// breaks fix B => 502/compact loop.
if (state.requestedThinking === true) {
stopTextBlock(state, results);
if (!state.thinkingBlockStarted) {
state.thinkingBlockIndex = state.nextBlockIndex++;
state.thinkingBlockStarted = true;
results.push({
type: "content_block_start",
index: state.thinkingBlockIndex,
content_block: { type: "thinking", thinking: "" },
});
}
if (!state.thinkingBlockStarted) {
state.thinkingBlockIndex = state.nextBlockIndex++;
state.thinkingBlockStarted = true;
results.push({
type: "content_block_start",
type: "content_block_delta",
index: state.thinkingBlockIndex,
content_block: { type: "thinking", thinking: "" },
delta: { type: "thinking_delta", thinking: reasoningContent },
});
}
results.push({
type: "content_block_delta",
index: state.thinkingBlockIndex,
delta: { type: "thinking_delta", thinking: reasoningContent },
});
// FIX B: accumulate the reasoning text so the finish handler can synthesize
// a text content block when the response ends reasoning-only (no ordinary
// content block). Claude Code's autocompact parser extracts the summary from
// a TEXT content block — a thinking block alone is judged "empty response"
// and the compact is rejected, looping the session. When real content DOES
// arrive, it starts its own text block and this buffer is simply ignored.
state._reasoningAccum = (state._reasoningAccum || "") + reasoningContent;
}
// Handle regular content — strip the internal reasoning placeholder if
@@ -341,24 +387,86 @@ export function openaiToClaudeResponse(chunk, state) {
if (delta?.content) {
const strippedContent = stripInternalReasoningPlaceholder(delta.content);
if (strippedContent) {
stopThinkingBlock(state, results);
// #reasoning-bilingual response side: DeepSeek-V4 and similar models echo the
// OMNIROUTE_SYSTEM_INSTRUCTION_APPEND directive (appended to the system tail by
// claude-to-openai.ts) verbatim at the START of their reply — the "system message
// leak" the operator reports. When the directive is configured, run the stream's
// first text chunk(s) through a preamble stripper so a leading reproduction is
// dropped before it reaches the client.
const directive = process.env.OMNIROUTE_SYSTEM_INSTRUCTION_APPEND?.trim();
if (directive) {
state._directiveStripper ??= createDirectivePreambleStripper(directive);
}
// #reasoning-bilingual response side (Phase B): DeepSeek-V4 and similar models
// may also echo whole chunks of the system prompt at the START of their reply —
// <analysis>/<system-reminder>/<summary> blocks or prose reproductions of the
// superpowers skill section. Chained after the exact-directive stripper.
//
// OPT-IN (OMNIROUTE_STRIP_SYSTEM_PREAMBLE=1), mirroring the directive
// stripper right above, which only runs when the operator configured
// OMNIROUTE_SYSTEM_INSTRUCTION_APPEND. Unlike the exact-directive match,
// this one recognises constructs by English-prose heuristics, so leaving it
// default-on would mutate the payload of EVERY openai→claude stream and can
// delete a legitimate section (a reply that genuinely opens with
// "# Skill usage: ..." loses it). Operators who hit the system-echo leak
// turn it on explicitly.
if (process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE === "1") {
state._systemPreambleStripper ??= createSystemPreambleStripper();
}
let scrubbedContent = state._directiveStripper
? state._directiveStripper(strippedContent)
: strippedContent;
if (state._systemPreambleStripper) {
scrubbedContent = state._systemPreambleStripper(scrubbedContent);
}
if (scrubbedContent) {
stopThinkingBlock(state, results);
}
// Rehydrate any Markdown boundary suffix buffered from the previous chunk
// before searching for XML tool calls, so the prefix is not lost.
const bufferedPrefix = state._markdownBuffer || "";
state._markdownBuffer = "";
// DSML tool calls (DeepSeek-V4-Flash's full-width-pipe format) can appear
// in content instead of the standard JSON tool_calls. Run the DSML
// parser/scrubber BEFORE extractXmlInvokeBlocks: complete <DSML:Tool>
// blocks become tool calls, stray closing markers are stripped, and the
// scrubbed remainder is handed to the XML-invoke path below. A partial
// opener at the chunk tail is held back in state for the next chunk so
// the marker cannot leak as visible text mid-stream.
let dsmlContent = scrubbedContent;
const dsmlPending = state._dsmlHoldback;
if (dsmlPending) {
dsmlContent = dsmlPending + dsmlContent;
state._dsmlHoldback = undefined;
}
let dsmlToolCalls: { id: string; name: string; args: Record<string, string> }[] = [];
if (hasDsmlToolCalls(dsmlContent)) {
const dsmlResult = parseDsmlToolCalls(dsmlContent);
dsmlContent = dsmlResult.content;
if (dsmlResult.holdback) {
state._dsmlHoldback = dsmlResult.holdback;
}
dsmlToolCalls = dsmlResult.toolCalls.map((tc) => ({
id: tc.id,
name: tc.function.name,
args: JSON.parse(tc.function.arguments) as Record<string, string>,
}));
}
// Check for XML <invoke> blocks that some models emit instead of JSON tool_calls
const { cleaned, toolCalls: xmlToolCalls } = extractXmlInvokeBlocks(
bufferedPrefix + strippedContent,
bufferedPrefix + dsmlContent,
state
);
// Accumulate extracted tool calls for emission at finish
if (xmlToolCalls.length > 0) {
// Accumulate extracted tool calls for emission at finish. DSML and XML
// invoke tool calls share the same pending queue and finish emission.
if (xmlToolCalls.length > 0 || dsmlToolCalls.length > 0) {
// Close any ongoing text block before tool calls
stopTextBlock(state, results);
state._pendingXmlToolCalls.push(...xmlToolCalls);
state._pendingXmlToolCalls.push(...xmlToolCalls, ...dsmlToolCalls);
}
// Defer any trailing incomplete Markdown boundary token to the next chunk.
@@ -378,7 +486,7 @@ export function openaiToClaudeResponse(chunk, state) {
state._markdownFenceRun || 0,
state._markdownFenceOpening === true,
state._markdownFenceClosingRun || 0,
state._markdownLineIndent || 0,
state._markdownLineIndent || 0
);
state._markdownBuffer = textToHold;
state._markdownCodeSpanRun = backtickRun || 0;
@@ -521,6 +629,57 @@ export function openaiToClaudeResponse(chunk, state) {
state.claudeFinishEmitted = true;
stopThinkingBlock(state, results);
// Both preamble strippers buffer while a construct is still undecided (a
// directive prefix that never completed, an <analysis>/<summary> block that
// never closed). Nothing flushed that buffer, so a stream ending mid-construct
// dropped the held text silently — for a single-chunk response whose block
// never closes, that is the ENTIRE answer replaced by an empty message.
// Release whatever is still held before the terminal blocks are emitted.
const flushedPreamble =
(state._directiveStripper?.flush?.() ?? "") +
(state._systemPreambleStripper?.flush?.() ?? "");
if (flushedPreamble) {
if (!state.textBlockStarted || state.textBlockClosed) {
state.textBlockIndex = state.nextBlockIndex++;
state.textBlockStarted = true;
state.textBlockClosed = false;
results.push({
type: "content_block_start",
index: state.textBlockIndex,
content_block: { type: "text", text: "" },
});
}
results.push({
type: "content_block_delta",
index: state.textBlockIndex,
delta: { type: "text_delta", text: flushedPreamble },
});
}
// FIX B: when the response ended reasoning-only (no ordinary text block was
// started) and the client did NOT explicitly request thinking, synthesize a
// text content block from the accumulated reasoning. Claude Code's
// autocompact parser extracts the summary from a TEXT content block — a
// thinking block alone is judged "empty response" and the compact is
// rejected, looping the session. When requestedThinking===true, skip this so
// reasoning is not double-exposed (thinking block + text block both carrying it).
if (!state.textBlockStarted && state._reasoningAccum && state.requestedThinking !== true) {
state.textBlockIndex = state.nextBlockIndex++;
state.textBlockStarted = true;
state.textBlockClosed = false;
results.push({
type: "content_block_start",
index: state.textBlockIndex,
content_block: { type: "text", text: "" },
});
results.push({
type: "content_block_delta",
index: state.textBlockIndex,
delta: { type: "text_delta", text: state._reasoningAccum },
});
}
stopTextBlock(state, results);
for (const [, toolInfo] of state.toolCalls) {

View File

@@ -0,0 +1,259 @@
/**
* 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 });
}

View File

@@ -0,0 +1,193 @@
/**
* Parser/scrubber for DeepSeek-V4-Flash's non-standard "DSML" tool-call text
* format.
*
* Background: DeepSeek-V4-Flash occasionally emits tool calls as inline text
* wrapped in full-width-pipe (U+FF5C) markers instead of the standard OpenAI
* `tool_calls` JSON. Two shapes appear in production call logs:
*
* 1. Complete block (call log 1787040934679-f8f772):
* <DSML:Read>
* <path>images/omniroute.png</path>
* </DSML:Read>
* The tool name follows a leading colon (`:Read`); child elements
* (`<path>`, `<parameter name="...">`) carry the arguments.
*
* 2. Stray closing markers with no opener (call logs 1787566395384-bab9ab,
* 2026-08-20 22:5923:49 run, 2026-08-23 22:5123:38 run): the model
* starts a tool call, truncates it, and emits only the closers — sometimes
* trailing a system-prompt echo — then finishes with `stop` and no
* `tool_calls`:
* </DSMLparameter>
* </DSMLinvoke>
* </DSMLtool_calls>
* or a mixed ASCII/DSML variant:
* </parameter>
* </invoke>
* </DSMLtool_calls>
*
* The openai-compatible path (default.ts + openai-to-claude.ts) never parsed
* these, so the markers leaked to the client as visible content and the turn
* ended incomplete. This module converts complete DSML blocks into OpenAI
* `tool_calls` and strips stray closing markers from content so neither the
* markers nor the tool-call grammar reach the client.
*
* Markers use full-width pipes `` (U+FF5C); we also accept the ASCII `|`
* defensively. The marker body is the literal `DSML` followed by either a
* colon-prefixed tool name (opening tag) or one of the structural words
* `tool_calls` / `invoke` / `parameter` (structural closing tags).
*/
// Full-width or ASCII pipe — matches composerToolCalls.ts's FW class.
const FW = "[|]";
// Opening tool-call tag: <DSML:ToolName> (colon + name, optional attrs).
const DSML_OPEN_RE = new RegExp(`<${FW}DSML${FW}:(\\w+)([^>]*)>`, "i");
// The matching closing tag for a tool named ToolName: </DSML:ToolName>.
function dsmlCloseRe(name: string): RegExp {
return new RegExp(`</${FW}DSML${FW}:${name}\\s*>`, "i");
}
// A single child element carrying an argument: <path>value</path> (name only)
// or <parameter name="arg">value</parameter> (named). Value is verbatim inner
// text up to the matching close.
const DSML_CHILD_NAME_ONLY_RE = /<(\w+)>([\s\S]*?)<\/\1>/g;
const DSML_CHILD_NAMED_RE = /<parameter\s+name="([^"]*)"[^>]*>([\s\S]*?)<\/parameter>/gi;
// Source string for the stray-closer regex (kept as a constant so the pattern
// is buildable from the `FW` class without re-interpolating it inline). Each
// alternative matches the closer + any trailing newlines, so the marker and
// its separator are removed together while a leading newline (an echo's own
// trailing newline) stays intact.
const STRAY_CLOSE_SRC =
`</${FW}DSML${FW}(?:tool_calls|invoke|parameter)\\s*>\\n*` + `|</(?:parameter|invoke)>\\n*`;
// Structural closing markers that appear with NO opener when a tool call is
// truncated: </DSMLparameter>, </DSMLinvoke>, </DSMLtool_calls>, plus
// the mixed ASCII variant </parameter> </invoke> </DSMLtool_calls>. The
// closers are usually \n-separated and often trail a system-prompt echo, so
// each alternative also consumes the newline(s) that FOLLOW it — that keeps an
// echoed preamble intact while removing the marker together with its trailing
// separator newline. A leading newline before the first closer is NOT
// consumed, so the echo's own trailing newline is preserved.
const DSML_STRAY_CLOSE_RE = new RegExp(STRAY_CLOSE_SRC, "gi");
// Partial opening marker at the very tail of a (streaming) chunk: a `<DSML`
// that never closes — hold it back so it cannot leak as visible text and can
// be re-evaluated once more bytes arrive.
const DSML_PARTIAL_OPEN_RE = new RegExp(`(<${FW}DSML${FW}[^<]*)$`, "i");
export interface DsmlToolCall {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
}
export interface ParseDsmlResult {
content: string;
toolCalls: DsmlToolCall[];
/** Partial opening marker held back for the next chunk (streaming-safe). */
holdback: string;
}
/**
* Parse a complete (non-streaming) DSML content string into OpenAI tool calls
* + scrubbed content. Complete `<DSML:Tool>` blocks become tool calls;
* stray closing markers are removed; everything else passes through.
*/
export function parseDsmlToolCalls(text: string): ParseDsmlResult {
if (!text || typeof text !== "string") {
return { content: text || "", toolCalls: [], holdback: "" };
}
let remaining = text;
const toolCalls: DsmlToolCall[] = [];
let cleaned = "";
while (remaining.length > 0) {
const openMatch = remaining.match(DSML_OPEN_RE);
if (!openMatch || openMatch.index === undefined) {
// No more complete openers. Strip stray closing markers from the tail
// (and a trailing partial opener, if any).
let tail = remaining.replace(DSML_STRAY_CLOSE_RE, "");
const partial = tail.match(DSML_PARTIAL_OPEN_RE);
let holdback = "";
if (partial) {
tail = tail.slice(0, partial.index);
holdback = partial[1];
}
cleaned += tail;
return { content: cleaned, toolCalls, holdback };
}
// Emit any text before the opener (stray closers already removed).
const before = remaining.slice(0, openMatch.index).replace(DSML_STRAY_CLOSE_RE, "");
cleaned += before;
const toolName = openMatch[1];
const afterOpen = remaining.slice(openMatch.index + openMatch[0].length);
const closeRe = dsmlCloseRe(toolName);
const closeMatch = afterOpen.match(closeRe);
if (!closeMatch) {
// Opener without a closer — incomplete block. Treat the opener itself
// as held-back (drop it from content) rather than leaking the marker.
return { content: cleaned, toolCalls, holdback: openMatch[0] + afterOpen };
}
const inner = afterOpen.slice(0, closeMatch.index);
const args = parseDsmlArgs(inner);
toolCalls.push({
id: `call_dsml_${crypto.randomUUID().replace(/-/g, "").slice(0, 10)}`,
type: "function",
function: { name: toolName, arguments: JSON.stringify(args) },
});
remaining = afterOpen.slice(closeMatch.index + closeMatch[0].length);
}
return { content: cleaned, toolCalls, holdback: "" };
}
/**
* Extract arguments from a DSML block body. Named `<parameter name="x">`
* children win; bare `<tag>value</tag>` children fall back to the tag name as
* the argument key.
*/
function parseDsmlArgs(inner: string): Record<string, unknown> {
const args: Record<string, unknown> = {};
// Named parameters first (explicit argument names).
const namedRe = new RegExp(DSML_CHILD_NAMED_RE);
let m;
const consumed = new Set<string>();
while ((m = namedRe.exec(inner)) !== null) {
args[m[1]] = m[2].trim();
consumed.add(m[0]);
}
// Bare-name children that were not part of a named <parameter>.
const bareRe = new RegExp(DSML_CHILD_NAME_ONLY_RE);
while ((m = bareRe.exec(inner)) !== null) {
if (consumed.has(m[0])) continue;
// Skip nested children already captured by an outer bare element.
let nested = false;
for (const c of consumed) {
if (c.includes(m[0]) && c !== m[0]) {
nested = true;
break;
}
}
if (nested) continue;
args[m[1]] = m[2].trim();
consumed.add(m[0]);
}
return args;
}
/** Cheap detection used by callers to decide whether to run the full parser. */
export function hasDsmlToolCalls(text: string): boolean {
if (!text || typeof text !== "string") return false;
return /[/]?[|]DSML[|]/.test(text);
}

View File

@@ -157,6 +157,13 @@ type StreamOptions = {
copilotCompatibleReasoning?: boolean;
/** Suppress the `</think>` close marker for clients that render it verbatim (#5245). */
suppressThinkClose?: boolean;
/**
* True when the CLIENT explicitly asked for thinking (body.thinking.type ===
* "enabled"). The response translator only relays upstream reasoning_content
* as Claude thinking blocks when this is set — otherwise DeepSeek/GLM
* reasoning would leak into UIs that never opted in.
*/
requestedThinking?: boolean;
/**
* Drop internal commentary-phase output items from Responses API passthrough
* streams before forwarding (#6199). When omitted, falls back to the
@@ -201,6 +208,8 @@ type TranslateState = ReturnType<typeof initState> & {
copilotCompatibleReasoning?: boolean;
/** Suppress the `</think>` close marker for clients that render it verbatim (#5245). */
suppressThinkClose?: boolean;
/** Client's explicit thinking intent — see StreamOptions.requestedThinking. */
requestedThinking?: boolean;
/** Accumulated message content for call log response body */
accumulatedContent?: string;
/** Accumulated reasoning content (separate from content) */
@@ -650,6 +659,11 @@ export function createSSEStream(options: StreamOptions = {}) {
clientResponseFormat = null,
copilotCompatibleReasoning = false,
suppressThinkClose = false,
// No default: "absent" must stay absent instead of being coerced into an
// explicit "thinking NOT requested". Mirrors translateNonStreamingResponse's
// `requestedThinking?: boolean` so both translation paths spell the
// no-intent case the same way.
requestedThinking,
provider = null,
reqLogger = null,
toolNameMap = null,
@@ -755,6 +769,7 @@ export function createSSEStream(options: StreamOptions = {}) {
signatureNamespace,
copilotCompatibleReasoning,
suppressThinkClose,
requestedThinking,
accumulatedContent: "",
accumulatedReasoning: "",
toolSchemas: extractToolSchemaMap(body),
@@ -3065,6 +3080,7 @@ export function createSSETransformStreamWithLogger(
onFailure: ((payload: StreamFailurePayload) => boolean | void | Promise<void>) | null = null,
copilotCompatibleReasoning = false,
suppressThinkClose = false,
requestedThinking: boolean | undefined = undefined,
customToolNames: ReadonlySet<string> = new Set(),
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null,
streamBufferBytes: number = DEFAULT_STREAM_BUFFER_BYTES
@@ -3084,6 +3100,7 @@ export function createSSETransformStreamWithLogger(
onFailure,
copilotCompatibleReasoning,
suppressThinkClose,
requestedThinking,
customToolNames,
requestToolIdentityMap,
streamBufferBytes,

View File

@@ -193,6 +193,15 @@ export interface NonStreamingClientTranslateInput {
reasoningCacheScope: string | null;
clientHeaders: Headers | Record<string, unknown> | null;
isClaudeCodeCompatible: boolean;
/**
* The client's explicit thinking intent for THIS request (same value the
* streaming path threads into the SSE translator). Without it the
* non-streaming OpenAI→Claude conversion falls back to its legacy
* "always relay a thinking block" default, so the very same request answered
* with `stream:false` leaked reasoning that `stream:true` correctly withheld.
* `undefined` keeps the legacy relay for callers that cannot express intent.
*/
requestedThinking?: boolean;
phase: "intermediate" | "final";
}

View File

@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
const { createDirectivePreambleStripper } =
await import("../../open-sse/utils/directivePreambleStripper.ts");
const DIRECTIVE =
"Respond ONLY in the same language as the user message. " +
"Put all planning, reasoning, and chain-of-thought in the reasoning_content " +
"field, never in the content field. Do not repeat or translate your response " +
"in another language.";
test("strips a single-chunk full directive preamble", () => {
const strip = createDirectivePreambleStripper(DIRECTIVE);
assert.equal(
strip(DIRECTIVE + "\n\n안녕하세요. 준비된 답변입니다."),
"\n\n안녕하세요. 준비된 답변입니다."
);
});
test("strips directive split across streaming chunks", () => {
const strip = createDirectivePreambleStripper(DIRECTIVE);
const a = DIRECTIVE.slice(0, 10);
const b = DIRECTIVE.slice(10, 40);
const c = DIRECTIVE.slice(40, 90);
const d = DIRECTIVE.slice(90, 120);
const rest = DIRECTIVE.slice(120);
assert.equal(strip(a), "", "nothing emitted while still matching prefix");
assert.equal(strip(b), "");
assert.equal(strip(c), "");
assert.equal(strip(d), "");
assert.equal(strip(rest + "\n\n후반부"), "\n\n후반부");
});
test("flushes matched prefix as real content when stream diverges from directive", () => {
const strip = createDirectivePreambleStripper(DIRECTIVE);
assert.equal(strip("Respond ONLY in the sam"), ""); // partial match so far
assert.equal(strip("x"), "Respond ONLY in the samx"); // diverges at 'x'
});
test("passes content through untouched when it never matches the directive", () => {
const strip = createDirectivePreambleStripper(DIRECTIVE);
assert.equal(strip("안녕하세요. 일반적인 응답입니다."), "안녕하세요. 일반적인 응답입니다.");
});
test("handles exact full-directive stream with no trailing text", () => {
const strip = createDirectivePreambleStripper(DIRECTIVE);
assert.equal(strip(DIRECTIVE.slice(0, 50)), "");
assert.equal(strip(DIRECTIVE.slice(50)), "");
});
test("subsequent chunks pass through untouched once directive is consumed", () => {
const strip = createDirectivePreambleStripper(DIRECTIVE);
assert.equal(strip(DIRECTIVE), "");
assert.equal(strip("\n\n후반부"), "\n\n후반부");
assert.equal(strip("추가 텍스트"), "추가 텍스트");
});
test("empty directive behaves as no-op passthrough", () => {
const strip = createDirectivePreambleStripper("");
assert.equal(strip("text"), "text");
});

View File

@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";
// DeepSeek-V4-Flash occasionally emits tool calls in a non-standard "DSML"
// text format using full-width pipes (U+FF5C), e.g.
// <DSML:Read>\n<path>images/x.png</path>\n</DSML:Read>
// or, when a tool call is truncated mid-stream, only the closing markers:
// </DSMLparameter>\n</DSMLinvoke>\n</DSMLtool_calls>
// The openai-compatible path must not leak these markers to the client as
// visible content. Complete DSML blocks are parsed into tool calls; stray
// closing markers (and the system-prompt echo they sometimes ride on) are
// stripped from content.
const { parseDsmlToolCalls } = await import("../../open-sse/utils/dsmlToolCalls.ts");
const FW = "\u{FF5C}"; // full-width vertical line
test("parseDsmlToolCalls: complete <DSML:Tool>...</DSML:Tool> becomes a tool call", () => {
const content = `<${FW}DSML${FW}:Read>\n<path>images/omniroute.png</path>\n</${FW}DSML${FW}:Read>`;
const result = parseDsmlToolCalls(content);
assert.equal(result.toolCalls.length, 1, "one tool call extracted");
assert.equal(result.toolCalls[0].function.name, "Read");
assert.deepEqual(JSON.parse(result.toolCalls[0].function.arguments), {
path: "images/omniroute.png",
});
assert.equal(result.content.trim(), "", "no residual DSML in content");
});
test("parseDsmlToolCalls: stray closing DSML markers are stripped from content", () => {
// The 2026-08-24 broken case (call log 1787566395384-bab9ab): only closing
// markers, no opening, tool_calls absent, finish_reason "stop".
const content = `</${FW}DSML${FW}parameter>\n</${FW}DSML${FW}invoke>\n</${FW}DSML${FW}tool_calls>`;
const result = parseDsmlToolCalls(content);
assert.equal(result.toolCalls.length, 0, "no tool call from stray closers");
assert.equal(result.content.trim(), "", "stray markers removed from content");
});
test("parseDsmlToolCalls: mixed ASCII + DSML closing markers are stripped", () => {
// 2026-08-23 variant: </parameter></invoke></DSMLtool_calls>
const content = `</parameter>\n</invoke>\n</${FW}DSML${FW}tool_calls>`;
const result = parseDsmlToolCalls(content);
assert.equal(result.toolCalls.length, 0);
assert.equal(result.content.trim(), "", "mixed closers removed");
});
test("parseDsmlToolCalls: closing markers trailing an echoed prompt are removed", () => {
// 2026-08-20 case: a system-prompt echo followed by three DSML closers.
const echo = "# Harness CWD note\n\nsome echoed text.\n";
const content = `${echo}</${FW}DSML${FW}parameter>\n</${FW}DSML${FW}invoke>\n</${FW}DSML${FW}tool_calls>`;
const result = parseDsmlToolCalls(content);
assert.equal(result.toolCalls.length, 0);
// The echoed preamble is retained (it is not a DSML marker); only the
// trailing DSML closers are removed.
assert.equal(result.content, echo, "echo retained, markers removed");
assert.ok(!result.content.includes("DSML"), "no DSML marker remains");
});
test("parseDsmlToolCalls: content with no DSML markers passes through unchanged", () => {
const content = "Hello, this is a normal response with no markers.";
const result = parseDsmlToolCalls(content);
assert.equal(result.toolCalls.length, 0);
assert.equal(result.content, content);
});
test("parseDsmlToolCalls: partial opening marker at the tail is held back (streaming-safe)", () => {
// A truncated stream might end mid-marker; the partial opener must not leak.
const content = `normal text <${FW}DSML${FW}:Wri`;
const result = parseDsmlToolCalls(content);
assert.equal(result.toolCalls.length, 0);
assert.equal(result.content, "normal text ", "safe text emitted, partial opener held back");
assert.equal(
result.holdback,
"<" + FW + "DSML" + FW + ":Wri",
"partial opener returned as holdback"
);
});

View File

@@ -0,0 +1,122 @@
/**
* Rework guard for PR #12905, item (c): the non-streaming translation path never
* received the client's thinking intent.
*
* `convertOpenAINonStreamingToClaude()` already accepts `requestedThinking` and
* suppresses the thinking block when it is explicitly `false` — but NO caller
* ever passed it, so `undefined` (the legacy "always relay a thinking block"
* default) was the only value it ever saw in production. The streaming path, by
* contrast, computes the intent in chatCore and threads it into the SSE
* translator, which relays reasoning only when it is explicitly `true`.
*
* Net effect before this fix: the SAME request answered with `stream:false`
* leaked a thinking block that `stream:true` correctly withheld. These tests
* pin the parity at the seam that was missing — the non-streaming provider leg,
* which owns the client body (`sourceBody`).
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import {
runNonStreamingProviderLeg,
type ChatCoreExecutorResult,
type ProviderLegInput,
} from "../../open-sse/handlers/chatCore/nonStreamingProviderLeg.ts";
const UPSTREAM = {
id: "chatcmpl-thinking-parity",
object: "chat.completion",
choices: [
{
index: 0,
message: {
role: "assistant",
content: "The answer is 4.",
reasoning_content: "Let me add two and two.",
},
finish_reason: "stop",
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
};
function makeResponse(body: object): Response {
const text = JSON.stringify(body);
return {
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "application/json" }),
text: async () => text,
clone() {
return { ...this, text: async () => text } as unknown as Response;
},
body: null,
} as unknown as Response;
}
function legInput(thinking: Record<string, unknown> | undefined): ProviderLegInput {
return {
phase: "initial",
sourceBody: {
model: "deepseek-v4",
messages: [{ role: "user", content: "2+2?" }],
...(thinking ? { thinking } : {}),
},
allowAccountRotation: true,
allowModelFallback: true,
executeProviderRequest: async (): Promise<ChatCoreExecutorResult> => ({
response: makeResponse(UPSTREAM),
url: "https://upstream/v1/chat/completions",
headers: {},
transformedBody: UPSTREAM,
}),
setRequestWireState: () => {},
provider: "deepseek",
model: "deepseek-v4",
connectionId: "conn-thinking-parity",
// Client speaks Claude; upstream answered OpenAI chat.completion.
sourceFormat: "claude",
clientResponseFormat: "claude",
targetFormat: "openai",
};
}
function contentTypes(response: Record<string, unknown>): string[] {
const content = response.content as { type: string }[] | undefined;
return Array.isArray(content) ? content.map((c) => c.type) : [];
}
test("(c) PARITY: non-streaming with thinking NOT requested must NOT relay a thinking block (matches the streaming gate)", async () => {
const result = await runNonStreamingProviderLeg(legInput({ type: "disabled" }));
assert.equal(result.kind, "ok");
if (result.kind !== "ok") return;
const types = contentTypes(result.response as unknown as Record<string, unknown>);
console.log(" thinking:disabled -> content types:", types);
assert.ok(
!types.includes("thinking"),
"thinking-opt-out client must not receive a thinking block on stream:false either"
);
assert.ok(types.includes("text"), "the ordinary answer must still be relayed");
});
test("(c) PARITY: non-streaming with thinking explicitly requested DOES relay the thinking block", async () => {
const result = await runNonStreamingProviderLeg(
legInput({ type: "enabled", budget_tokens: 1024 })
);
assert.equal(result.kind, "ok");
if (result.kind !== "ok") return;
const types = contentTypes(result.response as unknown as Record<string, unknown>);
console.log(" thinking:enabled -> content types:", types);
assert.ok(types.includes("thinking"), "an opted-in client keeps its thinking block");
assert.ok(types.includes("text"), "the ordinary answer is relayed too");
});
test("(c) PARITY: `adaptive` thinking counts as requested (same helper the streaming path uses)", async () => {
const result = await runNonStreamingProviderLeg(legInput({ type: "adaptive" }));
assert.equal(result.kind, "ok");
if (result.kind !== "ok") return;
const types = contentTypes(result.response as unknown as Record<string, unknown>);
console.log(" thinking:adaptive -> content types:", types);
assert.ok(types.includes("thinking"), "adaptive is an ACTIVE thinking intent");
});

View File

@@ -0,0 +1,149 @@
/**
* Rework guards for PR #12905, Group E (system-preamble stripper).
*
* Three defects were confirmed by probe on the PR head:
*
* (a) `createSystemPreambleStripper()` was wired DEFAULT-ON and unconditional
* in openai-to-claude.ts, unlike the directive stripper right above it
* (gated on OMNIROUTE_SYSTEM_INSTRUCTION_APPEND). Its openers are English
* prose heuristics, so a legitimate reply that opens with "# Skill usage:
* ..." had that whole section deleted from every openai→claude stream.
*
* (b) Neither stripper was ever flushed. A block that never closes keeps the
* buffered text forever, so a reply consisting of an unterminated
* `<analysis>` reached the client as an EMPTY message.
*/
import test from "node:test";
import assert from "node:assert/strict";
const { createSystemPreambleStripper, createDirectivePreambleStripper } =
await import("../../open-sse/utils/directivePreambleStripper.ts");
const { openaiToClaudeResponse } =
await import("../../open-sse/translator/response/openai-to-claude.ts");
function createState(): Record<string, unknown> {
return { toolCalls: new Map() };
}
function emittedText(events: Record<string, unknown>[]): string {
return events
.filter(
(e) =>
e?.type === "content_block_delta" &&
(e.delta as Record<string, unknown>)?.type === "text_delta"
)
.map((e) => (e.delta as Record<string, unknown>).text as string)
.join("");
}
function contentChunk(content: string): Record<string, unknown> {
return {
id: "chatcmpl-rework-12905",
model: "auto/deepseek-v4",
choices: [{ index: 0, delta: { content }, finish_reason: null }],
};
}
function finishChunk(): Record<string, unknown> {
return {
id: "chatcmpl-rework-12905",
model: "auto/deepseek-v4",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
};
}
function withEnv(value: string | undefined, fn: () => void): void {
const previous = process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE;
if (value === undefined) delete process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE;
else process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE = value;
try {
fn();
} finally {
if (previous === undefined) delete process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE;
else process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE = previous;
}
}
// ── (a) opt-in gate ────────────────────────────────────────────────────────────
test("(a) GATE: with OMNIROUTE_STRIP_SYSTEM_PREAMBLE unset, a legitimate reply opening with a prose head is relayed VERBATIM", () => {
withEnv(undefined, () => {
const state = createState();
const reply = "# Skill usage: how to write one\n\nHere is the guide.\n\n# Next";
const events = [
...openaiToClaudeResponse(contentChunk(reply), state),
...openaiToClaudeResponse(finishChunk(), state),
];
assert.equal(
emittedText(events as Record<string, unknown>[]),
reply,
"default-off: the response payload must not be mutated by prose heuristics"
);
});
});
test("(a) GATE: with OMNIROUTE_STRIP_SYSTEM_PREAMBLE=1 the operator opts in and the echo block IS stripped", () => {
withEnv("1", () => {
const state = createState();
const echo = "<analysis>\nchronological analysis\n</analysis>\n\nReal answer body.";
const events = [
...openaiToClaudeResponse(contentChunk(echo), state),
...openaiToClaudeResponse(finishChunk(), state),
];
const text = emittedText(events as Record<string, unknown>[]);
assert.ok(!text.includes("<analysis"), "opted in: the echo must not reach the client");
assert.ok(text.includes("Real answer body."), "real content still passes through");
});
});
// ── (b) end-of-stream flush ────────────────────────────────────────────────────
test("(b) FLUSH (unit): a system-echo block that never closes is released by flush(), not swallowed", () => {
const strip = createSystemPreambleStripper();
const unterminated = "<analysis>\nthis block never closes and IS the whole reply";
assert.equal(strip(unterminated), "", "still buffered while the block may be an echo");
assert.equal(
strip.flush(),
unterminated,
"flush must release the buffered text (opener included) at end of stream"
);
assert.equal(strip.flush(), "", "flush is idempotent");
});
test("(b) FLUSH (unit): a directive prefix that never completes is released by flush()", () => {
const strip = createDirectivePreambleStripper("SYSTEM DIRECTIVE TAIL");
assert.equal(strip("SYSTEM DIR"), "", "still matching the directive prefix");
assert.equal(strip.flush(), "SYSTEM DIR", "flush must release the partially matched prefix");
});
test("(b) FLUSH (wiring): an unterminated <analysis> reply reaches the client instead of an EMPTY message", () => {
withEnv("1", () => {
const state = createState();
const unterminated = "<analysis>\nthe model never closed the tag and this is the answer";
const events = [
...openaiToClaudeResponse(contentChunk(unterminated), state),
...openaiToClaudeResponse(finishChunk(), state),
];
const text = emittedText(events as Record<string, unknown>[]);
assert.notEqual(text, "", "the whole response must not be swallowed by the stripper");
assert.ok(
text.includes("the model never closed the tag and this is the answer"),
"the buffered answer must be flushed to the client"
);
});
});
test("(b) FLUSH: a CLOSED echo block stays stripped — flush must not resurrect a finalized drop", () => {
withEnv("1", () => {
const state = createState();
const echo = "<summary>hi there</summary>\nReal answer body.";
const events = [
...openaiToClaudeResponse(contentChunk(echo), state),
...openaiToClaudeResponse(finishChunk(), state),
];
const text = emittedText(events as Record<string, unknown>[]);
assert.ok(!text.includes("<summary>"), "a finalized drop must not come back on flush");
assert.ok(text.includes("Real answer body."), "real content preserved");
});
});

View File

@@ -0,0 +1,131 @@
import test from "node:test";
import assert from "node:assert/strict";
const { createSystemPreambleStripper } =
await import("../../open-sse/utils/directivePreambleStripper.ts");
test("passes normal content through untouched", () => {
const strip = createSystemPreambleStripper();
assert.equal(strip("안녕하세요. 준비된 답변입니다."), "안녕하세요. 준비된 답변입니다.");
});
test("strips a single-chunk full <analysis> echo block", () => {
const strip = createSystemPreambleStripper();
const echoed =
"<analysis>\nLet me chronologically analyze the conversation.\nSteps:\n" +
"1. First\n2. Second\n</analysis>\n\n답변 본문입니다.";
assert.equal(strip(echoed), "답변 본문입니다.");
});
test("strips analysis echo split across streaming chunks", () => {
const strip = createSystemPreambleStripper();
assert.equal(strip("<analysis>\nThink"), "");
assert.equal(strip("ing and analyze.\n"), "");
assert.equal(strip("</analysis>\n\n대답입니다."), "대답입니다.");
});
test("strips consecutive analysis + summary echo blocks", () => {
const strip = createSystemPreambleStripper();
const t = "<analysis>\nA\n</analysis>\n<summary>\nS\n</summary>\n\n결론";
assert.equal(strip(t), "결론");
});
test("strips a standalone system-reminder block", () => {
const strip = createSystemPreambleStripper();
const t = "<system-reminder>\ncontext\n</system-reminder>\n\n바로 본문";
assert.equal(strip(t), "바로 본문");
});
test("strips # Verification Process prose + system-reminder echo (real session 06:42)", () => {
const strip = createSystemPreambleStripper();
const echoed =
"# Verification Process\n\nWhen using WebFetch results for verification, " +
"note the following:\n- Web results are transient\n- Prefer official sources\n\n" +
"<system-reminder>\nFound existing memory, loading all matching memories:\n" +
"<br>environment: user_bearer_token</br>\n</system-reminder>\n\n" +
"사용자가 실제 세션 검증 방법을 묻고 있습니다.";
assert.equal(strip(echoed), "사용자가 실제 세션 검증 방법을 묻고 있습니다.");
});
test("strips Skill usage prose head then continues through system-reminder echo", () => {
const strip = createSystemPreambleStripper();
const echoed =
"Skill usage (when superpowers skills are installed)\n\n" +
"When superpowers skills are installed, use appropriate skills.\n" +
"- superpowers:brainstorming\n- superpowers:systematic-debugging\n\n" +
"<system-reminder>\ncontext\n</system-reminder>\n\n한국어 답변입니다.";
assert.equal(strip(echoed), "한국어 답변입니다.");
});
test("flushes partial tag text as real content when stream diverges", () => {
const strip = createSystemPreambleStripper();
assert.equal(strip("<analys"), "");
assert.equal(strip("x 후속"), "<analysx 후속");
});
test("subsequent chunks pass through untouched once real content begins", () => {
const strip = createSystemPreambleStripper();
assert.equal(strip("일반 답변입니다."), "일반 답변입니다.");
assert.equal(strip(" 무언가 더."), " 무언가 더.");
});
// REGRESSION (3a8515, 2026-09-01): an autocompact summary whose ENTIRE content is
// a single <analysis> block is indistinguishable from a system-echo <analysis> block.
// The stripper suppresses the whole block until </analysis>, then drops it; with no
// trailing real content the client receives "" => Claude Code autocompact reports
// "summarization produced empty response". This reproduces the defect at the code
// level (the fix is a separate task; this test documents the bug).
test("REGRESSION (3a8515): a whole-summary <analysis> block with no trailing content is stripped to empty (autocompact empty-response root cause)", () => {
const strip = createSystemPreambleStripper();
// The model's entire summary is one <analysis> block (legitimate structured
// summary, not a system echo). Streamed across chunks like a real response.
const chunks = [
"<analysis>",
"\nThis conversation covered the OmniRoute reasoning-leak diagnosis. ",
"We traced the bug to the requestedThinking gate in openai-to-claude.ts ",
"and confirmed fix B synthesizes a text block for reasoning-only responses.",
"\n</analysis>",
];
let out = "";
for (const c of chunks) out += strip(c);
// DEFECT: the whole summary was suppressed (treated as an echo). The fix must
// preserve it. This assert will FAIL until the stripper is taught that a
// <analysis> block carrying substantial real content (not a verbatim system
// echo) is the response, not a preamble.
assert.ok(
out.length > 0,
"a whole-summary <analysis> block MUST NOT be stripped to empty (autocompact empty-response root cause)"
);
});
test("REGRESSION (3a8515b): a whole-summary <analysis> block + trailing newline is stripped to empty", () => {
const strip = createSystemPreambleStripper();
const chunks = ["<analysis>Summary of the 208k-token context for compaction.</analysis>", "\n"];
let out = "";
for (const c of chunks) out += strip(c);
assert.ok(
out.length > 0,
"a <analysis> summary + trailing newline MUST NOT be stripped to empty"
);
});
test("REGRESSION (3a8515c): a long <analysis> summary block loses ~90% of its content (only post-close-tag chars survive)", () => {
const strip = createSystemPreambleStripper();
// Build a long summary inside <analysis>, then a short tail after </analysis>.
// ~5000 chars inside the block (mirrors the 5076 chars that were suppressed),
// ~500 chars after the close tag (mirrors the 552 chars that survived).
const inside = "Summary of the conversation. ".repeat(180); // ~5000 chars
const tail = "Final note. ".repeat(40); // ~480 chars
const full = "<analysis>\n" + inside + "\n</analysis>\n\n" + tail;
// Stream it in realistic-sized chunks (~8 chars each, like the 704 deltas)
const chunks = [];
for (let i = 0; i < full.length; i += 8) chunks.push(full.slice(i, i + 8));
let out = "";
for (const c of chunks) out += strip(c);
// DEFECT: only the post-close-tag tail (~480 chars) survives; the ~5000-char
// summary inside <analysis> is suppressed. The fix must preserve the inside.
assert.ok(
out.includes(inside.slice(0, 40)),
"the summary INSIDE <analysis> MUST survive (not be suppressed as an echo); only ~tail survives today"
);
});

View File

@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
const { openaiToClaudeResponse } =
await import("../../open-sse/translator/response/openai-to-claude.ts");
function createState() {
return { toolCalls: new Map() };
}
function emittedText(events: Array<Record<string, unknown>>): string {
return events
.filter(
(e) =>
e?.type === "content_block_delta" &&
(e.delta as Record<string, unknown>)?.type === "text_delta"
)
.map((e) => (e.delta as Record<string, unknown>).text as string)
.join("");
}
// The system-preamble stripper is OPT-IN (it mutates response payloads with
// English-prose heuristics). These wiring tests exercise the opted-in path;
// the default-off contract is pinned in system-preamble-gate-and-flush.test.ts.
test.before(() => {
process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE = "1";
});
test.after(() => {
delete process.env.OMNIROUTE_STRIP_SYSTEM_PREAMBLE;
});
function chunkWith(content: string) {
return {
id: "chatcmpl-preamble",
model: "auto/deepseek-v4",
choices: [
{
index: 0,
delta: { content },
finish_reason: null,
},
],
};
}
test("wiring: strips an <analysis> echo block at the START of the streamed reply", () => {
const state = createState();
const echo =
"<analysis>\nLet me chronologically analyze the conversation.\n</analysis>\n\n실제 답변입니다.";
const events1 = openaiToClaudeResponse(chunkWith(echo.slice(0, 20)), state);
const events2 = openaiToClaudeResponse(chunkWith(echo.slice(20)), state);
const text = emittedText([...events1, ...events2]);
assert.ok(!text.includes("<analysis"), "echo must not reach the client");
assert.ok(text.includes("실제 답변입니다."), "real content must pass through");
});
test("wiring: strips a standalone <system-reminder> echo block", () => {
const state = createState();
const echo = "<system-reminder>\ncontext\n</system-reminder>\n\n바로 본문";
const events = openaiToClaudeResponse(chunkWith(echo), state);
const text = emittedText(events);
assert.ok(!text.includes("system-reminder"), "echo must not reach the client");
assert.ok(text.includes("바로 본문"), "real content must pass through");
});
test("wiring: normal content is untouched (no false positives)", () => {
const state = createState();
const events = openaiToClaudeResponse(chunkWith("안녕하세요. 일반 답변입니다."), state);
const text = emittedText(events);
assert.equal(text, "안녕하세요. 일반 답변입니다.");
});

View File

@@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
// hasActiveClaudeThinking lives in open-sse/utils/thinkingBudget.ts (the pure
// thinking-budget helpers), distinct from open-sse/services/thinkingBudget.ts.
const { hasActiveClaudeThinking } = await import("../../open-sse/utils/thinkingBudget.ts");
test("hasActiveClaudeThinking: enabled is active", () => {
assert.equal(hasActiveClaudeThinking({ thinking: { type: "enabled" } }), true);
});
test("hasActiveClaudeThinking: adaptive is active", () => {
// Claude Code sends {type:"adaptive"} to opt into reasoning. The helper must
// recognise adaptive exactly as it recognises enabled, so the response
// translator relays upstream reasoning_content as a thinking block.
assert.equal(hasActiveClaudeThinking({ thinking: { type: "adaptive" } }), true);
});
test("hasActiveClaudeThinking: disabled is NOT active", () => {
assert.equal(hasActiveClaudeThinking({ thinking: { type: "disabled" } }), false);
});
test("hasActiveClaudeThinking: absent thinking is NOT active", () => {
assert.equal(hasActiveClaudeThinking({}), false);
assert.equal(hasActiveClaudeThinking({ thinking: undefined }), false);
});
test("hasActiveClaudeThinking: thinking without type is NOT active", () => {
assert.equal(hasActiveClaudeThinking({ thinking: {} }), false);
});

View File

@@ -0,0 +1,133 @@
import test from "node:test";
import assert from "node:assert/strict";
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
// Regression for the non-stream reasoning leak (call log 1787645055806-4256f2,
// 2026-08-25T08:06:52Z). The streaming translator (openai-to-claude.ts) gained a
// `requestedThinking` gate + reasoning-as-text fallback in ba0bd3af8 so a
// reasoning-only upstream response with thinking NOT requested still yields a
// content block (no 502) without leaking a thinking block. The NON-streaming
// translator (responseTranslator.ts::convertOpenAINonStreamingToClaude) is a
// separate, duplicated code path that never received `requestedThinking`, so it
// unconditionally pushes reasoning_content as a `type:"thinking"` block — leaking
// reasoning to a thinking-opt-out client (Claude Code sends `thinking:{type:"disabled"}`).
//
// This reproduces the requestedThinking===false path that the existing tests
// (issue-7856, issue-6623) do NOT cover — those call translateNonStreamingResponse
// WITHOUT a requestedThinking argument, so they keep the legacy "always thinking
// block" behaviour. The fix must only change behaviour when requestedThinking is
// EXPLICITLY false (opt-out), preserving the un-passed default for back-compat.
// OpenAI-shape response that carries ONLY reasoning_content, empty content — the
// exact GLM-5.2 autocompact pattern (reasoning-only, no ordinary content).
const reasoningOnlyResponse = {
id: "chatcmpl-test",
object: "chat.completion",
model: "GLM-5.2",
choices: [
{
index: 0,
message: { role: "assistant", content: null, reasoning_content: 'The user just said "hi"' },
finish_reason: "length",
},
],
usage: { prompt_tokens: 117, completion_tokens: 5 },
} as const;
// OpenAI-shape response with BOTH reasoning_content and ordinary content.
const reasoningAndContentResponse = {
id: "chatcmpl-test2",
object: "chat.completion",
model: "GLM-5.2",
choices: [
{
index: 0,
message: { role: "assistant", content: "Answer", reasoning_content: "Plan" },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 10, completion_tokens: 20 },
} as const;
const typesOf = (r: unknown): string[] => {
const content = (r as { content?: Array<{ type?: string }> })?.content;
return Array.isArray(content) ? content.map((b) => b?.type ?? "?") : [];
};
test("REGRESSION guard: non-stream reasoning-only with requestedThinking=false relays reasoning as a TEXT block (no thinking leak, no 502)", () => {
// requestedThinking EXPLICITLY false — client opted out of thinking.
const translated = translateNonStreamingResponse(
reasoningOnlyResponse,
"openai",
"claude",
null,
null,
false
) as { content?: Array<{ type?: string }> };
const ts = typesOf(translated);
console.log(" reasoning-only requestedThinking=false -> content types:", ts);
// FIX: reasoning must NOT surface as a thinking block when the client opted out.
assert.ok(!ts.includes("thinking"), "no thinking block leaked to a thinking-opt-out client");
// FIX: a content block must still be produced (reasoning relayed as text) so the
// response is not empty — mirrors the streaming 502 fix.
assert.ok(
ts.includes("text"),
"reasoning relayed as an ordinary text block (not dropped, not 502)"
);
});
test("REGRESSION guard: non-stream reasoning+content with requestedThinking=false drops reasoning, keeps only the text block", () => {
// When ordinary content IS present, reasoning is suppressed (thinking not requested)
// and content is the sole block — matching the streaming translator's
// "content supersedes buffered reasoning" behaviour.
const translated = translateNonStreamingResponse(
reasoningAndContentResponse,
"openai",
"claude",
null,
null,
false
) as { content?: Array<{ type?: string }> };
const ts = typesOf(translated);
console.log(" reasoning+content requestedThinking=false -> content types:", ts);
assert.ok(!ts.includes("thinking"), "no thinking block leaked when content is present");
assert.ok(ts.includes("text"), "ordinary content relayed as text");
// Only one text block — reasoning suppressed, not concatenated into the text block.
const textCount = ts.filter((t) => t === "text").length;
assert.equal(textCount, 1, "exactly one text block (reasoning suppressed, not merged)");
});
test("BACKCOMPAT: non-stream reasoning-only WITHOUT requestedThinking arg keeps the legacy thinking block (existing callers unchanged)", () => {
// No 6th argument — legacy callers (issue-7856, issue-6623) keep thinking-block relay.
const translated = translateNonStreamingResponse(
reasoningOnlyResponse,
"openai",
"claude",
null
) as { content?: Array<{ type?: string }> };
const ts = typesOf(translated);
console.log(" reasoning-only no requestedThinking arg -> content types:", ts);
assert.ok(ts.includes("thinking"), "legacy callers still get a thinking block (back-compat)");
});
test("BACKCOMPAT: non-stream reasoning-only with requestedThinking=true keeps the thinking block (opted in)", () => {
const translated = translateNonStreamingResponse(
reasoningOnlyResponse,
"openai",
"claude",
null,
null,
true
) as { content?: Array<{ type?: string }> };
const ts = typesOf(translated);
console.log(" reasoning-only requestedThinking=true -> content types:", ts);
assert.ok(ts.includes("thinking"), "thinking block relayed when client opted in");
});

View File

@@ -0,0 +1,111 @@
import test from "node:test";
import assert from "node:assert/strict";
const { openaiToClaudeResponse } =
await import("../../open-sse/translator/response/openai-to-claude.ts");
function createState() {
return {
toolCalls: new Map(),
_pendingXmlToolCalls: [],
_xmlInvokeBuffer: "",
};
}
function flatten(items: unknown[]) {
return items.flatMap((item) => (item as unknown[]) || []);
}
// Regression for the autocompact 502 empty_response. The no-502 guard must keep
// holding after the requestedThinking gate is restored on the thinking block
// emission.
//
// History: f6eb328ba/e28d02066 gated reasoning_content relay on
// state.requestedThinking === true. A reasoning-only upstream response
// (GLM-5.2 on a huge autocompact context returns ONLY reasoning_content, empty
// content) with requestedThinking===false produced ZERO content blocks — the
// stream reached flush with no content_block_start, and
// stream.ts::emitClaudeEmptyStreamErrorAndAbort raised a 502 "no content block".
//
// fix A (14534ee95) removed the gate so reasoning_content was ALWAYS relayed as
// a thinking block (content_block_start stays alive => no 502). But that leaked
// reasoning as a thinking block to thinking-opt-out clients (the operator
// reported "reasoning is exposed").
//
// RESOLUTION (this fix): restore the requestedThinking gate on the thinking
// block EMISSION only, so requestedThinking=false emits NO thinking block (no
// reasoning leak). The _reasoningAccum accumulation stays OUTSIDE the gate and
// always runs, so fix B (translator/response/openai-to-claude.ts finish
// handler) synthesizes a TEXT block from the accumulated reasoning for
// reasoning-only responses — keeping a content_block_start alive so flush does
// NOT 502. This guard verifies the no-502 invariant holds via fix B (not via a
// leaked thinking block).
test("REGRESSION guard: reasoning-only response with requestedThinking=false does NOT 502 (fix B synthesizes a text block; gate suppresses the thinking block)", () => {
const state = createState(); // requestedThinking absent => false
// GLM-5.2 autocompact: ONLY reasoning_content, no content delta.
const reasoning = openaiToClaudeResponse(
{
id: "chatcmpl-ec8832",
model: "glm-5.2",
choices: [
{
index: 0,
delta: { reasoning_content: "Compacting 154k-token context..." },
finish_reason: null,
},
],
},
state
);
const final = openaiToClaudeResponse(
{
id: "chatcmpl-ec8832",
model: "glm-5.2",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 154172, completion_tokens: 0, total_tokens: 154172 },
},
state
);
const result = flatten([reasoning, final]);
const contentBlockStarts = result.filter(
(e) => (e as { type?: string })?.type === "content_block_start"
);
const thinkingBlocks = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking"
);
const textBlocks = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text"
);
const thinkingDeltas = result.filter(
(e) => (e as { delta?: { type?: string } })?.delta?.type === "thinking_delta"
);
console.log(
` content_block_start count=${contentBlockStarts.length} thinking=${thinkingBlocks.length} text=${textBlocks.length} thinkingDelta=${thinkingDeltas.length}`
);
// No 502: fix B synthesizes a text block so flush has >= 1 content block.
assert.ok(
contentBlockStarts.length >= 1,
"FIX A/B: requestedThinking=false + reasoning-only must emit >= 1 content block (fix B text block => no 502 at flush)"
);
// Gate: requestedThinking=false => NO thinking block (no reasoning leak).
assert.equal(
thinkingBlocks.length,
0,
"requestedThinking=false => NO thinking block (gate stops reasoning leak)"
);
assert.equal(
thinkingDeltas.length,
0,
"requestedThinking=false => NO thinking_delta (gate stops reasoning leak)"
);
// Fix B: a text block is synthesized from the accumulated reasoning.
assert.ok(
textBlocks.length >= 1,
"fix B: reasoning-only synthesizes a text block (keeps content_block_start alive => no 502)"
);
});

View File

@@ -0,0 +1,375 @@
import test from "node:test";
import assert from "node:assert/strict";
// Regression for the reasoning-leak + compact-loop resolution.
//
// History (systematic-debugging, confirmed 2026-08-31):
// e28d02066 (2026-08-24) gated reasoning_content relay on
// state.requestedThinking === true. With Claude Code autocompact (thinking
// disabled) + GLM-5.2/DeepSeek-V4-Flash returning ONLY reasoning_content on
// huge contexts, the gate produced zero content blocks => 502 "no content
// block", or (ba0bd3af8) reasoning flushed as text that the compact parser
// rejected => compact loop.
// fix A (14534ee95) removed the gate entirely so reasoning_content is ALWAYS
// relayed as a thinking block. That stopped the 502 and (with fix B's text
// synthesis) the compact loop, but leaked reasoning as a thinking block to
// thinking-opt-out clients (requestedThinking=false) — the operator reported
// "reasoning is exposed".
//
// RESOLUTION (this fix): restore the requestedThinking gate on the thinking
// block EMISSION only (content_block_start type:thinking + thinking_delta),
// so requestedThinking=false emits NO thinking block (no reasoning leak).
// The _reasoningAccum accumulation stays OUTSIDE the gate and always runs, so
// fix B still synthesizes a text block for reasoning-only responses (no 502,
// compact applies). This avoids the e28d02066 regression (which gated the
// whole block including accumulation, breaking fix B => 502/compact loop).
const { openaiToClaudeResponse } =
await import("../../open-sse/translator/response/openai-to-claude.ts");
function createState() {
return {
toolCalls: new Map(),
_pendingXmlToolCalls: [],
_xmlInvokeBuffer: "",
};
}
function flatten(items: unknown[]) {
return items.flatMap((item) => (item as unknown[]) || []);
}
// RED: requestedThinking=false (default, autocompact) + reasoning-only. The gate
// must suppress the thinking block (no reasoning leak), while fix B synthesizes
// a text block from the accumulated reasoning so flush has a content block (no
// 502) and Claude Code's autocompact parser has a real summary to apply.
test("REGRESSION: requestedThinking=false + reasoning-only MUST NOT emit a thinking block (gate) but MUST synthesize a text block (fix B) => no 502, compact applies", () => {
const state = createState(); // requestedThinking absent => false (autocompact)
// GLM-5.2 autocompact: ONLY reasoning_content, no content delta.
const reasoning = openaiToClaudeResponse(
{
id: "chatcmpl-gate-fix",
model: "glm-5.2",
choices: [
{
index: 0,
delta: { reasoning_content: "Compacting 145k-token context..." },
finish_reason: null,
},
],
},
state
);
const final = openaiToClaudeResponse(
{
id: "chatcmpl-gate-fix",
model: "glm-5.2",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 145376, completion_tokens: 5, total_tokens: 145381 },
},
state
);
const result = flatten([reasoning, final]);
const thinkingStarts = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking"
);
const textStarts = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text"
);
const contentBlockStarts = result.filter(
(e) => (e as { type?: string })?.type === "content_block_start"
);
// Gate: requestedThinking=false => NO thinking block (stops reasoning leak).
assert.equal(
thinkingStarts.length,
0,
"requestedThinking=false + reasoning-only MUST NOT emit a thinking block (gate stops reasoning leak)"
);
// Fix B: reasoning-only synthesizes a text block so flush has a content block.
assert.ok(
contentBlockStarts.length >= 1,
"reasoning-only must keep >= 1 content_block_start (fix B text block, no 502 at flush)"
);
assert.ok(
textStarts.length >= 1,
"fix B: reasoning-only MUST synthesize a text block (autocompact summary)"
);
// The message must finish normally (end_turn), not as an error.
const messageDeltas = result.filter((e) => e?.type === "message_delta");
assert.equal(messageDeltas[0].delta.stop_reason, "end_turn");
});
// RED: requestedThinking=false + reasoning THEN content. The gate suppresses
// the thinking block (no reasoning leak); the ordinary content still starts its
// own text block. e28d02066 suppressed the thinking block too but ALSO blocked
// accumulation; this fix keeps accumulation so fix B never false-fires (real
// content sets textBlockStarted, so the finish gate is skipped).
test("REGRESSION: requestedThinking=false + reasoning THEN content emits NO thinking block (gate) but a text block (content)", () => {
const state = createState(); // requestedThinking absent => false
const reasoning = openaiToClaudeResponse(
{
id: "chatcmpl-gate-fix-2",
model: "glm-5.2",
choices: [{ index: 0, delta: { reasoning_content: "Planning..." }, finish_reason: null }],
},
state
);
const text = openaiToClaudeResponse(
{
id: "chatcmpl-gate-fix-2",
model: "glm-5.2",
choices: [{ index: 0, delta: { content: "Answer" }, finish_reason: null }],
},
state
);
const final = openaiToClaudeResponse(
{
id: "chatcmpl-gate-fix-2",
model: "glm-5.2",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
},
state
);
const result = flatten([reasoning, text, final]);
const thinkingStarts = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking"
);
const textStarts = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text"
);
// Gate: no thinking block (requestedThinking=false, no reasoning leak).
assert.equal(
thinkingStarts.length,
0,
"requestedThinking=false => no thinking block (gate, no reasoning leak)"
);
// Ordinary content still emits a text block.
assert.ok(textStarts.length >= 1, "ordinary content still emits a text block");
});
// RED (fix B guard): requestedThinking=false + reasoning-ONLY (no content). The
// gate suppresses the thinking block; fix B synthesizes a text block from the
// accumulated reasoning so Claude Code's autocompact parser has a real summary.
// The accumulation MUST stay outside the gate (e28d02066 gated it too => fix B
// never fired => 502/compact loop regression).
test("REGRESSION (fix B): requestedThinking=false + reasoning-ONLY MUST synthesize a text block (NOT a thinking block) so autocompact can use it as the summary", () => {
const state = createState(); // requestedThinking absent => false (autocompact)
const reasoning = openaiToClaudeResponse(
{
id: "chatcmpl-fix-b",
model: "glm-5.2",
choices: [
{
index: 0,
delta: { reasoning_content: "Summary of the 153k-token context..." },
finish_reason: null,
},
],
},
state
);
const final = openaiToClaudeResponse(
{
id: "chatcmpl-fix-b",
model: "glm-5.2",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 153225, completion_tokens: 8772, total_tokens: 161997 },
},
state
);
const result = flatten([reasoning, final]);
const thinkingStarts = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking"
);
const textStarts = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text"
);
const textDeltas = result.filter(
(e) => (e as { delta?: { type?: string } })?.delta?.type === "text_delta"
);
// Gate: NO thinking block (requestedThinking=false, no reasoning leak).
assert.equal(
thinkingStarts.length,
0,
"requestedThinking=false => no thinking block (gate, no reasoning leak)"
);
// Fix B: a text block is synthesized from the reasoning so the autocompact
// parser has a real text summary to apply.
assert.ok(textStarts.length >= 1, "fix B: reasoning-only MUST synthesize a text block");
assert.ok(textDeltas.length >= 1, "fix B: reasoning text carried as text_delta");
// The synthesized text block carries the reasoning content.
const synthesizedText = (textDeltas[0] as { delta?: { text?: string } }).delta?.text;
assert.equal(synthesizedText, "Summary of the 153k-token context...");
});
// GREEN guard: requestedThinking=true + reasoning MUST still emit a thinking
// block — the gate passes when the client explicitly opted into thinking. This
// guards against the gate over-suppressing (e.g. inverted condition) and
// regressing the thinking-opted-in path.
test("REGRESSION: requestedThinking=true + reasoning MUST emit a thinking block (gate passes)", () => {
const state = createState();
state.requestedThinking = true; // client explicitly opted into thinking
const reasoning = openaiToClaudeResponse(
{
id: "chatcmpl-gate-true",
model: "glm-5.2",
choices: [
{
index: 0,
delta: { reasoning_content: "Reasoning through the request..." },
finish_reason: null,
},
],
},
state
);
const final = openaiToClaudeResponse(
{
id: "chatcmpl-gate-true",
model: "glm-5.2",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
},
state
);
const result = flatten([reasoning, final]);
const thinkingStarts = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking"
);
const thinkingDeltas = result.filter(
(e) => (e as { delta?: { type?: string } })?.delta?.type === "thinking_delta"
);
const textStarts = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text"
);
const textDeltas = result.filter(
(e) => (e as { delta?: { type?: string } })?.delta?.type === "text_delta"
);
const contentBlockStarts = result.filter(
(e) => (e as { type?: string })?.type === "content_block_start"
);
assert.ok(
thinkingStarts.length >= 1,
"requestedThinking=true => thinking block MUST be emitted (gate passes)"
);
assert.ok(
thinkingDeltas.length >= 1,
"requestedThinking=true => reasoning content carried as thinking_delta"
);
// FIX-B double-exposure guard (2026-09-01): when the client opted into
// thinking (requestedThinking===true) AND the response is reasoning-only (no
// content), the thinking block above already provides a content_block_start
// (no 502 at flush). fix B's text-block synthesis MUST NOT fire here — it would
// double-expose the reasoning as BOTH a thinking block AND a text block, showing
// reasoning and response as indistinguishable plain text (the operator reported
// "구별이 전혀 안가고 지저분하다").
assert.equal(
textStarts.length,
0,
"requestedThinking=true + reasoning-only => NO fix-B text block (no double exposure)"
);
assert.equal(
textDeltas.length,
0,
"requestedThinking=true + reasoning-only => NO text_delta (fix B skipped)"
);
assert.equal(
contentBlockStarts.length,
1,
"requestedThinking=true + reasoning-only => exactly 1 content_block_start (the thinking block; no 502)"
);
});
// RED (double-exposure fix, 2026-09-01): requestedThinking=true + reasoning-ONLY.
// The emission gate passes (thinking block emitted) and fix B MUST be skipped
// (requestedThinking===true) so the reasoning is carried ONLY by the thinking
// block — no redundant text block synthesised. Before the fix, fix B fired
// unconditionally and the reasoning appeared as BOTH a thinking block AND a text
// block (double exposure).
test("REGRESSION (double-exposure): requestedThinking=true + reasoning-ONLY MUST emit a thinking block AND MUST NOT synthesize a fix-B text block (no double exposure)", () => {
const state = createState();
state.requestedThinking = true; // client explicitly opted into thinking
const reasoning = openaiToClaudeResponse(
{
id: "chatcmpl-double-expose",
model: "glm-5.2",
choices: [
{
index: 0,
delta: { reasoning_content: "Reasoning through the huge context..." },
finish_reason: null,
},
],
},
state
);
const final = openaiToClaudeResponse(
{
id: "chatcmpl-double-expose",
model: "glm-5.2",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 150000, completion_tokens: 12, total_tokens: 150012 },
},
state
);
const result = flatten([reasoning, final]);
const thinkingStarts = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "thinking"
);
const thinkingDeltas = result.filter(
(e) => (e as { delta?: { type?: string } })?.delta?.type === "thinking_delta"
);
const textStarts = result.filter(
(e) => (e as { content_block?: { type?: string } })?.content_block?.type === "text"
);
const textDeltas = result.filter(
(e) => (e as { delta?: { type?: string } })?.delta?.type === "text_delta"
);
const contentBlockStarts = result.filter(
(e) => (e as { type?: string })?.type === "content_block_start"
);
const messageDeltas = result.filter((e) => (e as { type?: string })?.type === "message_delta");
assert.equal(
thinkingStarts.length,
1,
"requestedThinking=true + reasoning-only => exactly 1 thinking block (gate passes)"
);
assert.ok(
thinkingDeltas.length >= 1,
"requestedThinking=true + reasoning-only => reasoning carried as thinking_delta"
);
assert.equal(
textStarts.length,
0,
"requestedThinking=true + reasoning-only => NO text block (fix B skipped, no double exposure)"
);
assert.equal(
textDeltas.length,
0,
"requestedThinking=true + reasoning-only => NO text_delta (fix B skipped)"
);
assert.equal(
contentBlockStarts.length,
1,
"requestedThinking=true + reasoning-only => exactly 1 content_block_start (the thinking block; no 502)"
);
assert.equal(
messageDeltas[0].delta.stop_reason,
"end_turn",
"requestedThinking=true + reasoning-only => message finishes end_turn"
);
});

View File

@@ -0,0 +1,138 @@
import test from "node:test";
import assert from "node:assert/strict";
// Integration: DSML markers that DeepSeek-V4-Flash leaks into the content
// delta must be parsed/scrubbed by the openai-to-claude response translator
// before they reach the Claude client as visible text. Complete <DSML:Tool>
// blocks become tool_use blocks; stray closing markers (the broken
// 1787566395384-bab9ab case — closers only, finish_reason "stop", no
// tool_calls) are stripped so the client never sees the markers.
const { openaiToClaudeResponse } =
await import("../../open-sse/translator/response/openai-to-claude.ts");
const FW = "\u{FF5C}"; // full-width vertical line
function createState() {
return {
toolCalls: new Map(),
_pendingXmlToolCalls: [],
_xmlInvokeBuffer: "",
};
}
function flatten(items) {
return items.flatMap((item) => item || []);
}
function allText(result) {
return result
.filter((e) => e?.delta?.type === "text_delta")
.map((e) => e.delta.text)
.join("");
}
test("translator strips stray DSML closing markers from content delta", () => {
// The 2026-08-24 broken case: content is ONLY the three stray closers, no
// opener, tool_calls absent, finish_reason "stop". The client must not
// receive the markers as visible text.
const state = createState();
const markers = `</${FW}DSML${FW}parameter>\n</${FW}DSML${FW}invoke>\n</${FW}DSML${FW}tool_calls>`;
const first = openaiToClaudeResponse(
{
id: "chatcmpl-dsml-stray",
model: "deepseek-v4-flash",
choices: [{ index: 0, delta: { content: markers }, finish_reason: null }],
},
state
);
const final = openaiToClaudeResponse(
{
id: "chatcmpl-dsml-stray",
model: "deepseek-v4-flash",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
},
state
);
const result = flatten([first, final]);
const text = allText(result);
assert.ok(
!text.includes("DSML"),
`DSML markers must not reach client, got: ${JSON.stringify(text)}`
);
assert.ok(
!text.includes(""),
`full-width pipe must not reach client, got: ${JSON.stringify(text)}`
);
});
test("translator keeps echoed preamble and strips only trailing DSML closers", () => {
// 2026-08-20 case: a real (echoed) text preamble precedes the stray closers.
// The preamble must be preserved; only the trailing markers removed.
const state = createState();
const echo = "# Harness CWD note\n\nsome echoed text.\n";
const markers = `</${FW}DSML${FW}parameter>\n</${FW}DSML${FW}invoke>\n</${FW}DSML${FW}tool_calls>`;
const first = openaiToClaudeResponse(
{
id: "chatcmpl-dsml-echo",
model: "deepseek-v4-flash",
choices: [{ index: 0, delta: { content: echo + markers }, finish_reason: null }],
},
state
);
const final = openaiToClaudeResponse(
{
id: "chatcmpl-dsml-echo",
model: "deepseek-v4-flash",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
},
state
);
const result = flatten([first, final]);
const text = allText(result);
assert.ok(
text.includes("# Harness CWD note"),
`echo preamble preserved, got: ${JSON.stringify(text)}`
);
assert.ok(!text.includes("DSML"), `DSML markers stripped, got: ${JSON.stringify(text)}`);
});
test("translator parses a complete DSML block into a tool_use block", () => {
// 2026-08-18 complete case: <DSML:Read><path>...</path></DSML:Read>
// should become a tool_use block, not visible text.
const state = createState();
const block = `<${FW}DSML${FW}:Read>\n<path>images/omniroute.png</path>\n</${FW}DSML${FW}:Read>`;
const first = openaiToClaudeResponse(
{
id: "chatcmpl-dsml-complete",
model: "deepseek-v4-flash",
choices: [{ index: 0, delta: { content: block }, finish_reason: null }],
},
state
);
const final = openaiToClaudeResponse(
{
id: "chatcmpl-dsml-complete",
model: "deepseek-v4-flash",
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
},
state
);
const result = flatten([first, final]);
const text = allText(result);
const toolUseStarts = result.filter((e) => e?.content_block?.type === "tool_use");
assert.ok(
!text.includes("DSML"),
`DSML markers must not reach client as text, got: ${JSON.stringify(text)}`
);
assert.ok(
toolUseStarts.length >= 1,
`complete DSML block should produce a tool_use block, got ${JSON.stringify(result.map((e) => e?.type))}`
);
assert.equal(toolUseStarts[0].content_block.name, "Read");
});

View File

@@ -15,6 +15,14 @@ function createState() {
};
}
/** State carrying explicit client thinking intent (thinking:{type:"enabled"}). */
function createThinkingState() {
return {
...createState(),
requestedThinking: true,
};
}
function flatten(items) {
return items.flatMap((item) => item || []);
}
@@ -52,7 +60,7 @@ test("OpenAI stream: text delta starts Claude message and closes cleanly on stop
});
test("OpenAI stream: reasoning_content closes before text content starts", () => {
const state = createState();
const state = createThinkingState();
const reasoning = openaiToClaudeResponse(
{
id: "chatcmpl-2",
@@ -78,6 +86,37 @@ test("OpenAI stream: reasoning_content closes before text content starts", () =>
assert.equal(result[5].delta.text, "Answer");
});
test("OpenAI stream: reasoning_content is suppressed by default when client did not request thinking", () => {
const state = createState();
const reasoning = openaiToClaudeResponse(
{
id: "chatcmpl-2d",
model: "gpt-4.1",
choices: [{ index: 0, delta: { reasoning_content: "Plan" }, finish_reason: null }],
},
state
);
const text = openaiToClaudeResponse(
{
id: "chatcmpl-2d",
model: "gpt-4.1",
choices: [{ index: 0, delta: { content: "Answer" }, finish_reason: null }],
},
state
);
const result = flatten([reasoning, text]);
assert.equal(
result.some(
(event) => event.type === "content_block_start" && event.content_block?.type === "thinking"
),
false
);
assert.equal(result[0].type, "message_start");
assert.equal(result[1].content_block.type, "text");
assert.equal(result[2].delta.text, "Answer");
});
test("OpenAI stream: internal reasoning replay placeholder stays hidden from Claude thinking block", () => {
const state = createState();
const placeholder = openaiToClaudeResponse(
@@ -594,3 +633,52 @@ test("OpenAI stream: no XML in content behaves normally", () => {
test("OpenAI stream: null chunk is ignored", () => {
assert.equal(openaiToClaudeResponse(null, createState()), null);
});
// Regression for the autocompact 502 empty_response (call logs
// 1787569671800-5782c0, 1787570213960-d98520): Claude Code sends
// thinking:{type:"adaptive"} on autocompact. A prior gate (e28d02066) only
// recognized type === "enabled", so adaptive left requestedThinking false and
// the translator DROPPED a GLM-5.2 reasoning-only response — no content block
// survived, and stream.ts:emitClaudeEmptyStreamErrorAndAbort raised a 502.
// With adaptive now recognized (hasActiveClaudeThinking), reasoning_content
// must become a thinking block so the stream is never empty.
test("OpenAI stream: reasoning-only response with thinking:{type:adaptive} is relayed as a thinking block (not empty 502)", () => {
// Simulate the chatCore-side decision: adaptive is now treated as thinking
// requested (mirrors hasActiveClaudeThinking).
const state = createThinkingState();
// GLM-5.2 returns ONLY reasoning_content, no content — the autocompact case.
const reasoning = openaiToClaudeResponse(
{
id: "chatcmpl-adaptive-502",
model: "glm-5.2",
choices: [
{ index: 0, delta: { reasoning_content: "Compacting context..." }, finish_reason: null },
],
},
state
);
const final = openaiToClaudeResponse(
{
id: "chatcmpl-adaptive-502",
model: "glm-5.2",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 100, completion_tokens: 5, total_tokens: 105 },
},
state
);
const result = flatten([reasoning, final]);
// A thinking block MUST be present — this is what prevents the empty-stream
// 502 at flush time. With the old enabled-only gate, requestedThinking would
// be false and this block would never be emitted.
const thinkingStarts = result.filter((e) => e?.content_block?.type === "thinking");
assert.ok(
thinkingStarts.length >= 1,
"adaptive must produce a thinking block so the stream is not empty"
);
const thinkingDeltas = result.filter((e) => e?.delta?.type === "thinking_delta");
assert.equal(thinkingDeltas[0].delta.thinking, "Compacting context...");
// The message must finish normally (end_turn), not as an error.
const messageDeltas = result.filter((e) => e?.type === "message_delta");
assert.equal(messageDeltas[0].delta.stop_reason, "end_turn");
});