// ThinkSplitter — peels inline ... reasoning out of streamed text // content. Some local models (QwQ, DeepSeek-R1 distills, MiniMax) served raw // emit their chain-of-thought inline in the assistant `content` channel rather // than on a structured reasoning channel; BooCode's stream adapter otherwise // treats that as ordinary prose. This splitter routes the reasoning span to the // reasoning accumulator and passes the rest through unchanged. // // Ported from deepseek-reasonix internal/provider/openai/think.go. Two // guarantees make it safe to run on every text delta: // 1. It only ARMS if the turn's content begins with (after leading // whitespace), so an answer that merely mentions the tag is never hijacked. // 2. For any content that does not start with it degrades to a // verbatim pass-through (a no-op for models on a structured reasoning // channel). // It buffers partial closing tags across chunk boundaries so a ` block). */ reasoning: string; /** Text classified as ordinary content to pass through. */ text: string; } /** * Longest proper suffix of `s` that is a prefix of `marker`. Used to hold back * the bytes that might be the start of a closing tag split across chunks. Never * returns the full marker length (that is a complete match, handled separately). */ function markerSuffixLen(s: string, marker: string): number { const max = Math.min(marker.length - 1, s.length); for (let n = max; n > 0; n--) { if (marker.startsWith(s.slice(s.length - n))) return n; } return 0; } /** Stateful, single-stream splitter. Create one per streamed completion. */ export class ThinkSplitter { private state: State = 'probe'; private buf = ''; push(s: string): SplitResult { if (this.state === 'passthrough') return { reasoning: '', text: s }; if (this.state === 'inside') return this.scanClose(s); // probe this.buf += s; const trimmed = this.buf.replace(LEADING_WS, ''); if (trimmed.length < OPEN.length) { // Not enough yet to decide. Hold only if still a viable prefix. if (OPEN.startsWith(trimmed)) return { reasoning: '', text: '' }; return this.drainPassthrough(); } if (trimmed.startsWith(OPEN)) { this.state = 'inside'; this.buf = ''; return this.scanClose(trimmed.slice(OPEN.length)); } return this.drainPassthrough(); } /** Resolve any buffered remainder at stream end. */ flush(): SplitResult { const r = this.buf; this.buf = ''; if (this.state === 'inside') return { reasoning: r, text: '' }; return { reasoning: '', text: r }; } private scanClose(s: string): SplitResult { this.buf += s; const idx = this.buf.indexOf(CLOSE); if (idx >= 0) { const reasoning = this.buf.slice(0, idx); const text = this.buf.slice(idx + CLOSE.length).replace(LEADING_WS, ''); this.buf = ''; this.state = 'passthrough'; return { reasoning, text }; } // No full closing tag yet — emit everything except a possible partial tag. const keep = markerSuffixLen(this.buf, CLOSE); const reasoning = this.buf.slice(0, this.buf.length - keep); this.buf = this.buf.slice(this.buf.length - keep); return { reasoning, text: '' }; } private drainPassthrough(): SplitResult { const text = this.buf; this.buf = ''; this.state = 'passthrough'; return { reasoning: '', text }; } }