/** * Think Tag Parser * * Parses ... tags from LLM output and converts them * to a structured `reasoning_content` field. * * Used by providers like DeepSeek, Qwen, and Qoder that embed * chain-of-thought reasoning inside tags. * * Usage: * import { extractThinkTags, hasThinkTags } from "./thinkTagParser.ts"; * * const { reasoning, content } = extractThinkTags(rawOutput); * // reasoning = "step-by-step thinking..." * // content = "final answer..." */ import { shouldParseTextualReasoningTags } from "../handlers/responseSanitizer/reasoning.ts"; import { appendBoundedText, buildSyntheticChatChunk } from "./streamHelpers.ts"; const THINK_OPEN = ""; const THINK_CLOSE = ""; /** * Create the mutable streaming-parse context for one SSE stream. * `enabled` decides whether the caller should attempt think-tag parsing at * all for this stream (passthrough mode + a provider/model that is known to * emit textual `` tags); `active`/`insideThink`/`buffer` track parse * progress once a tag has been seen. `model` is stashed for `flushThink`'s * synthetic chunk so callers don't need to pass it again at flush time. * * @param {boolean} isPassthroughMode * @param {unknown} provider * @param {string} [model] */ export function initThinkState(isPassthroughMode: boolean, provider?: unknown, model?: string) { return { enabled: isPassthroughMode && shouldParseTextualReasoningTags(provider, model), insideThink: false, buffer: "", active: false, model, }; } /** * Check whether a streaming text chunk contains a full `` open tag, * or ends with a prefix of one (so the caller knows to keep buffering * instead of treating the tag as ordinary content split across chunks). * * @param {string} value - Streaming text chunk * @returns {boolean} */ export function containsOrMayEndWithThinkOpenTag(value: string): boolean { return ( value.includes(THINK_OPEN) || ["<", " value.endsWith(suffix)) ); } /** * Check if text contains think tags * @param {string} text - Raw output text * @returns {boolean} */ export function hasThinkTags(text) { if (!text) return false; return text.includes(THINK_OPEN); } /** * Extract think tags from text * Returns the reasoning content (inside ) and the cleaned final content * * @param {string} text - Raw output text * @returns {{ reasoning: string|null, content: string }} */ export function extractThinkTags(text) { if (!text || !text.includes(THINK_OPEN)) { return { reasoning: null, content: text || "" }; } let reasoning = ""; let content = text; let iterations = 0; const maxIterations = 10; // safety limit while (content.includes(THINK_OPEN) && iterations < maxIterations) { const openIdx = content.indexOf(THINK_OPEN); const closeIdx = content.indexOf(THINK_CLOSE, openIdx); if (closeIdx === -1) { // Unclosed think tag — treat everything after as reasoning reasoning += content.slice(openIdx + THINK_OPEN.length); content = content.slice(0, openIdx); break; } // Extract the think content const thinkContent = content.slice(openIdx + THINK_OPEN.length, closeIdx); reasoning += (reasoning ? "\n" : "") + thinkContent; // Remove the think block from content content = content.slice(0, openIdx) + content.slice(closeIdx + THINK_CLOSE.length); iterations++; } return { reasoning: reasoning.trim() || null, content: content.trim(), }; } /** * Process a streaming delta chunk and extract think content. * Maintains state across chunks using a context object. * * @param {string} delta - New text chunk * @param {object} ctx - Mutable context object { insideThink, buffer } * @returns {{ reasoningDelta: string|null, contentDelta: string|null }} */ export function processStreamingThinkDelta(delta, ctx) { if (!ctx.buffer) ctx.buffer = ""; ctx.buffer += delta; let reasoningDelta = ""; let contentDelta = ""; while (ctx.buffer.length > 0) { if (ctx.insideThink) { // Looking for closing tag const closeIdx = ctx.buffer.indexOf(THINK_CLOSE); if (closeIdx === -1) { // Might be a partial tag at the end — keep last few chars if (ctx.buffer.length > THINK_CLOSE.length) { const safe = ctx.buffer.slice(0, -(THINK_CLOSE.length - 1)); reasoningDelta += safe; ctx.buffer = ctx.buffer.slice(-(THINK_CLOSE.length - 1)); } break; } reasoningDelta += ctx.buffer.slice(0, closeIdx); ctx.buffer = ctx.buffer.slice(closeIdx + THINK_CLOSE.length); ctx.insideThink = false; } else { // Looking for opening tag const openIdx = ctx.buffer.indexOf(THINK_OPEN); if (openIdx === -1) { // Might be a partial tag at the end if (ctx.buffer.length > THINK_OPEN.length) { const safe = ctx.buffer.slice(0, -(THINK_OPEN.length - 1)); contentDelta += safe; ctx.buffer = ctx.buffer.slice(-(THINK_OPEN.length - 1)); } break; } contentDelta += ctx.buffer.slice(0, openIdx); ctx.buffer = ctx.buffer.slice(openIdx + THINK_OPEN.length); ctx.insideThink = true; } } return { reasoningDelta: reasoningDelta || null, contentDelta: contentDelta || null, }; } /** * Parse a streaming SSE delta's `content` field for `` tags in place, * when applicable. No-ops (returns false, delta untouched) unless parsing is * `ctx.enabled` for this stream AND either a tag is already open or this * chunk contains/may end with a `` open tag. * * Mutates `delta.content` (stripped of think markup) and sets * `delta.reasoning_content` when reasoning text was extracted. * * @param {object} ctx - Mutable context object from `initThinkState` * @param {{ content: unknown, reasoning_content?: string }} delta * @returns {boolean} true if the delta was parsed for think tags */ export function applyThinkTag( ctx, delta: { content: unknown; reasoning_content?: string } ): boolean { if (!ctx.enabled || typeof delta?.content !== "string") return false; if (!ctx.active && !containsOrMayEndWithThinkOpenTag(delta.content)) return false; ctx.active = true; const { reasoningDelta, contentDelta } = processStreamingThinkDelta(delta.content, ctx); delta.content = contentDelta || ""; if (reasoningDelta) delta.reasoning_content = reasoningDelta; return true; } /** * Flush remaining buffer content from streaming context. * Call this when the stream ends. * * @param {object} ctx - Mutable context object { insideThink, buffer } * @returns {{ reasoningDelta: string|null, contentDelta: string|null }} */ export function flushThinkBuffer(ctx) { if (!ctx.buffer) return { reasoningDelta: null, contentDelta: null }; const remaining = ctx.buffer; ctx.buffer = ""; if (ctx.insideThink) { return { reasoningDelta: remaining || null, contentDelta: null }; } return { reasoningDelta: null, contentDelta: remaining || null }; } /** * Flush remaining buffer content from a streaming context at end-of-stream * and assemble a synthetic OpenAI-shaped chat completion chunk, plus the * accumulator updates for it, ready for the caller to enqueue as SSE output. * * Returns `null` when parsing was never `ctx.enabled`/`ctx.active` for this * stream, or when there is nothing left to flush — callers can skip * emitting an empty synthetic chunk and leave their accumulators untouched. * * @param {object} ctx - Mutable context object from `initThinkState` * (its `model` is reused for the synthetic chunk) * @param {string} [responsesId] - Response id to reuse, falls back to a * generated `chatcmpl-` id * @param {string} accReasoning - Caller's running reasoning accumulator * @param {string} accContent - Caller's running content accumulator * @returns {{ * syntheticChunk: Record, * flushOutput: string, * reasoning: string, * content: string, * addedLength: number * }|null} */ export function flushThink( ctx, responsesId: string | undefined, accReasoning: string, accContent: string ): { syntheticChunk: Record; flushOutput: string; reasoning: string; content: string; addedLength: number; } | null { if (!ctx.enabled || !ctx.active) return null; const { reasoningDelta, contentDelta } = flushThinkBuffer(ctx); if (!reasoningDelta && !contentDelta) return null; const delta: Record = {}; if (reasoningDelta) delta.reasoning_content = reasoningDelta; if (contentDelta) delta.content = contentDelta; const syntheticChunk = buildSyntheticChatChunk(responsesId, ctx.model, delta); return { syntheticChunk, flushOutput: `data: ${JSON.stringify(syntheticChunk)}\n\n`, reasoning: appendBoundedText(accReasoning, reasoningDelta || ""), content: appendBoundedText(accContent, contentDelta || ""), addedLength: (reasoningDelta?.length || 0) + (contentDelta?.length || 0), }; }