/**
* Streaming `…` splitter for OpenAI-compatible / Ollama models.
*
* Many open/local reasoning models (DeepSeek-R1, Qwen "thinking", QwQ, …) do NOT
* expose a separate reasoning channel; they inline their chain-of-thought as
* `…` inside the normal content stream. Without splitting, that
* reasoning is dumped into the answer as literal text. This stateful splitter
* routes think-tag content to `onReasoning` (the dimmed live trace) and returns
* only the user-visible answer text — handling tags that straddle chunk
* boundaries, so it is safe to feed raw streamed deltas one at a time.
*
* Passthrough is near-free: text with no `` tag flows through unchanged
* (only a trailing partial-tag fragment is briefly buffered).
*/
const OPEN = "";
const CLOSE = "";
/** Longest suffix of `s` that is a non-empty proper prefix of `tag` (0 if none). */
function partialTail(s: string, tag: string): number {
const max = Math.min(s.length, tag.length - 1);
for (let k = max; k > 0; k--) {
// Check if s.endsWith(tag.slice(0, k)) without allocating substrings.
let match = true;
const sStart = s.length - k;
for (let i = 0; i < k; i++) {
if (s.charCodeAt(sStart + i) !== tag.charCodeAt(i)) {
match = false;
break;
}
}
if (match) return k;
}
return 0;
}
export interface ThinkSplitter {
/** Feed one streamed delta; returns the visible (answer) text to yield. */
push(delta: string): string;
/** Flush any buffered partial tag at stream end; returns trailing visible text. */
flush(): string;
}
export function createThinkSplitter(onReasoning?: (delta: string) => void): ThinkSplitter {
let inThink = false;
let pending = ""; // a tail that might be the start of an OPEN/CLOSE tag
const push = (delta: string): string => {
let s = pending + delta;
pending = "";
let visible = "";
for (;;) {
if (!inThink) {
const idx = s.indexOf(OPEN);
if (idx === -1) {
const tail = partialTail(s, OPEN);
visible += s.slice(0, s.length - tail);
pending = s.slice(s.length - tail);
break;
}
visible += s.slice(0, idx);
s = s.slice(idx + OPEN.length);
inThink = true;
} else {
const idx = s.indexOf(CLOSE);
if (idx === -1) {
const tail = partialTail(s, CLOSE);
const think = s.slice(0, s.length - tail);
if (think) onReasoning?.(think);
pending = s.slice(s.length - tail);
break;
}
const think = s.slice(0, idx);
if (think) onReasoning?.(think);
s = s.slice(idx + CLOSE.length);
inThink = false;
}
}
return visible;
};
const flush = (): string => {
const out = pending;
pending = "";
// An unterminated tail is literal content: emit it on whichever channel was open.
if (inThink) {
if (out) onReasoning?.(out);
return "";
}
return out;
};
return { push, flush };
}
/**
* Strip leaked reasoning / tool-call markup from a model's FINAL visible text
* (a salvaged prose answer or a `done` reason). Some API-entered models emit
* XML/Harmony-style scaffolding — `…`, `…`,
* ``, `<|channel|>` markers — inside their plain-text reply. The
* streaming {@link createThinkSplitter} only removes *matched* think pairs seen
* mid-stream; an unmatched `` (a model that begins already "inside" its
* reasoning) or stray parameter tags leak through into the answer. This is a
* whole-text cleanup, safe to run once on the final string: text with no such
* markup is returned trimmed-but-otherwise-unchanged.
*/
export function stripLeakedReasoningTags(text: string): string {
let s = text;
// 1) Drop balanced … blocks.
s = s.replace(/]*>[\s\S]*?<\/think>/gi, "");
// 2) An unmatched closing implies an implicit reasoning prefix the
// splitter never saw an opener for: drop everything up to and including the
// LAST such close tag, keeping only the answer that follows it.
const lastClose = s.toLowerCase().lastIndexOf("");
if (lastClose !== -1) s = s.slice(lastClose + "".length);
// 3) Remove stray tool-call / parameter scaffolding tags (keep their inner text).
s = s.replace(
/<\/?(?:think|tool_call|tool_response|tool_result|parameter|invoke|function|function_call|antml:[a-z_]+)\b[^>]*>/gi,
"",
);
// 4) Remove Harmony channel markers like <|channel|>, <|message|>, <|end|>.
s = s.replace(/<\|[^|>]*\|>/g, "");
return s.trim();
}