import { parseToolCallsFromContent, stripToolCallsFromContent } from "./tool-call-parser"; // Reasoning text can arrive under different keys depending on the upstream // OpenAI-compatible server: vLLM/SGLang emit `reasoning_content`, while some // endpoints use `reasoning` or `reasoning_text`. This mirrors how the pi SDK // resolves reasoning (see @earendil-works/pi-ai openai-completions): take the // first non-empty field so the same text is never counted twice. export const REASONING_FIELDS = ["reasoning_content", "reasoning", "reasoning_text"] as const; /** Return the first non-empty reasoning field on a delta/message record. */ export const firstReasoningField = (record: Record): string => { for (const field of REASONING_FIELDS) { const value = record[field]; if (typeof value === "string" && value.length > 0) return value; } return ""; }; const thinkingOpenPrefixes = [" boolean; drainCarry: () => string; drainPendingContent: () => string; rewrite: ( deltaText: string, defaultToReasoning?: boolean, ) => { content: string; reasoningAppend: string }; }; const getThinkingTagLength = ( suffix: string, ): { kind: "open" | "close"; length: number } | null => { if (!suffix.startsWith("<")) return null; const closeIndex = suffix.indexOf(">"); if (closeIndex < 0) return null; const tag = suffix.slice(0, closeIndex + 1); if (/^<(think|thinking|analysis)(?:\s+[^>]*)?>$/i.test(tag)) return { kind: "open", length: closeIndex + 1 }; if (/^<\/(think|thinking|analysis)(?:\s+[^>]*)?>$/i.test(tag)) return { kind: "close", length: closeIndex + 1 }; return null; }; export const thinkingTagPrefixIsPartial = (suffix: string): boolean => { const lower = suffix.toLowerCase(); if (!lower.startsWith("<")) return false; for (const prefix of thinkingAllPrefixes) { if (prefix.startsWith(lower)) { return true; } if (lower.startsWith(prefix)) { const next = lower[prefix.length]; if (!next) return true; if ( next === ">" || next === " " || next === "/" || next === "\t" || next === "\n" || next === "\r" ) return true; } } return false; }; export const createThinkRewriter = ( settings: { bufferImplicitReasoningContent?: boolean; } = {}, ): ThinkRewriter => { let inThink = false; let thinkCarry = ""; let pendingImplicitContent = ""; let seenOpen = false; let resolvedImplicitPrefix = false; return { inThink(): boolean { return inThink; }, drainCarry(): string { const tail = thinkCarry; thinkCarry = ""; return tail; }, drainPendingContent(): string { const pending = pendingImplicitContent; pendingImplicitContent = ""; return pending; }, rewrite( deltaText: string, defaultToReasoning = false, ): { content: string; reasoningAppend: string } { const combined = thinkCarry + (deltaText ?? ""); const combinedLower = combined.toLowerCase(); let carryIndex = combined.length; let index = 0; let contentOut = ""; let reasoningOut = ""; while (index < carryIndex) { const remainingLower = combinedLower.slice(index); if (combined[index] === "<") { const thinkTag = getThinkingTagLength(remainingLower); if (thinkTag?.kind === "open") { if (pendingImplicitContent) { contentOut += pendingImplicitContent; pendingImplicitContent = ""; } inThink = true; seenOpen = true; index += thinkTag.length; continue; } if (thinkTag?.kind === "close") { if (!inThink) { // Close tag without an opening tag: model uses implicit // thinking (e.g. DeepSeek sends `...` with no `...`). if (settings.bufferImplicitReasoningContent && !seenOpen && !resolvedImplicitPrefix) { reasoningOut += pendingImplicitContent; pendingImplicitContent = ""; resolvedImplicitPrefix = true; } const before = contentOut.trim(); if (before) { reasoningOut += contentOut; contentOut = ""; } } inThink = false; index += thinkTag.length; continue; } if (thinkingTagPrefixIsPartial(remainingLower)) { carryIndex = index; break; } } const ch = combined[index] ?? ""; if (inThink || defaultToReasoning) { reasoningOut += ch; } else if ( settings.bufferImplicitReasoningContent && !seenOpen && !resolvedImplicitPrefix ) { pendingImplicitContent += ch; } else { contentOut += ch; } index += 1; } thinkCarry = carryIndex < combined.length ? combined.slice(carryIndex) : ""; return { content: contentOut, reasoningAppend: reasoningOut, }; }, }; }; const stripToolCallXmlBlocks = (text: string): string => { if (!text) return ""; let cleaned = stripToolCallsFromContent(text); cleaned = cleaned.replace(/\n{3,}/g, "\n\n"); return cleaned.trim(); }; const collapseRepeatedVisibleContent = (text: string): string => { const trimmed = text.trim(); if (trimmed.length < 80) return text; for (let separatorLength = 0; separatorLength <= 4; separatorLength += 1) { const contentLength = trimmed.length - separatorLength; if (contentLength <= 0 || contentLength % 2 !== 0) continue; const midpoint = contentLength / 2; const first = trimmed.slice(0, midpoint).trimEnd(); const second = trimmed.slice(midpoint + separatorLength).trimStart(); if (first.length >= 40 && first === second) return first; } return text; }; const extractThinkBlocks = (text: string): { cleaned: string; extracted: string } => { if (!text) return { cleaned: "", extracted: "" }; const rewriter = createThinkRewriter(); const { content, reasoningAppend } = rewriter.rewrite(String(text)); const carry = rewriter.drainCarry(); const cleaned = rewriter.inThink() ? content : content + carry; const extracted = rewriter.inThink() ? reasoningAppend + carry : reasoningAppend; return { cleaned: cleaned.trim(), extracted: extracted.trim() }; }; export const normalizeReasoningAndContentInMessage = (message: Record): void => { // Only a string content carries inline blocks or is safe to rewrite. // A multi-part array content (e.g. text + image_url) must be left untouched — // coercing it to "" here would silently drop the whole message body. const contentIsString = typeof message["content"] === "string"; const contentRaw = contentIsString ? String(message["content"]) : ""; const reasoningRaw = firstReasoningField(message); const contentThink = extractThinkBlocks(contentRaw); const reasoningThink = extractThinkBlocks(reasoningRaw); // Dedup identical segments: when a model emits its reasoning BOTH inline in // content (…) and in the dedicated reasoning field, the // content-extracted and reasoning-field text are the same string — joining // them verbatim doubled the reasoning. const nextReasoning = [ reasoningThink.cleaned, contentThink.extracted, reasoningThink.extracted, ] .map((v) => v.trim()) .filter((v, index, all) => v.length > 0 && all.indexOf(v) === index) .join("\n"); const nextContent = contentThink.cleaned; if (contentIsString && nextContent !== contentRaw) message["content"] = nextContent; if (message["reasoning_content"] !== nextReasoning) message["reasoning_content"] = nextReasoning; if (contentIsString) { const strippedContent = stripToolCallXmlBlocks(String(message["content"] ?? "")); message["content"] = collapseRepeatedVisibleContent(strippedContent); } const strippedReasoning = stripToolCallXmlBlocks( typeof message["reasoning_content"] === "string" ? String(message["reasoning_content"]) : "", ); if (strippedReasoning) { message["reasoning_content"] = strippedReasoning; } else { delete message["reasoning_content"]; } delete message["reasoning"]; delete message["reasoning_text"]; }; export const normalizeToolCallsInMessage = (message: Record): boolean => { const existing = message["tool_calls"]; const hasToolCalls = Array.isArray(existing) && existing.length > 0; if (hasToolCalls) { return false; } const content = typeof message["content"] === "string" ? String(message["content"]) : ""; const parsed = parseToolCallsFromContent(content); if (parsed.length > 0) { message["tool_calls"] = parsed; return true; } return false; }; /** * Per-model quirks for reasoning/thinking content. The extractors above * handle the universal * ``/tool-call-XML shapes; these two are narrow, model-specific * workarounds). */ /** * Trinity's "thinking" variant sometimes returns a response with empty * visible `content` but a populated `reasoning`/`reasoning_content` field — * callers that only render `content` would see a blank message. Promote the * reasoning text into `content` so it's visible, while still keeping it in * `reasoning_content` for callers that distinguish the two. */ export const exposeReasoningAsContentWhenEmpty = ( message: Record, model: string, ): boolean => { const modelLower = model.toLowerCase(); if (!modelLower.includes("trinity-large-thinking")) return false; const content = typeof message["content"] === "string" ? message["content"].trim() : ""; if (content) return false; const reasoning = typeof message["reasoning"] === "string" ? message["reasoning"].trim() : typeof message["reasoning_content"] === "string" ? message["reasoning_content"].trim() : ""; if (!reasoning) return false; message["content"] = reasoning; if (!message["reasoning_content"]) { message["reasoning_content"] = reasoning; } return true; }; export const shouldBufferImplicitReasoningContent = ( model: string, reasoningParser: string | null | undefined, ): boolean => { const parser = (reasoningParser ?? "").toLowerCase(); const modelLower = model.toLowerCase(); return ( parser === "deepseek_r1" || parser === "minimax_m2_append_think" || modelLower.includes("deepseek") || modelLower.includes("r1") || modelLower.includes("reasoning") || modelLower.includes("thinking") ); };