// CRITICAL import { randomUUID } from "node:crypto"; /** * Tool call payload. */ export interface ToolCall { index: number; id: string; type: "function"; function: { name: string; arguments: string }; } /** * Thinking tag state tracker. */ export interface ThinkState { inThinking: boolean; } /** * Tool call buffer for streaming. */ export interface ToolCallBuffer { content: string; tool_args: string; tool_name: string; has_malformed_tool_calls: boolean; tool_calls_found: boolean; } /** * UTF-8 streaming state for handling incomplete multi-byte sequences. * Some tokenizers (like GLM) split Unicode characters across tokens, * causing replacement characters (U+FFFD) in streaming output. */ export interface Utf8State { /** Buffered content ending with replacement character */ pendingContent: string; /** Buffered reasoning ending with replacement character */ pendingReasoning: string; } /** Unicode replacement character */ const REPLACEMENT_CHAR = "\uFFFD"; const BOX_DRAWING_RANGE = "[\\u2500-\\u257F\\u2580-\\u259F]"; const BOX_DRAWING_REGEX = new RegExp(BOX_DRAWING_RANGE); /** * Pattern matching partial UTF-8 byte suffixes from tokenizer corruption. * When emoji/Unicode are split across tokens, trailing bytes often appear as * hex-like strings (e.g., "8f", "bf") after replacement characters. * This matches: replacement char(s) followed by 1-2 hex digits at end of string. */ const PARTIAL_BYTE_SUFFIX_PATTERN = /\uFFFD+[0-9a-f]{1,2}$/i; /** * Pattern for replacement chars followed by byte suffixes mid-string. * Matches patterns like "���8f " or "�orid" from corrupted emoji. */ const CORRUPTED_EMOJI_PATTERN = /\uFFFD+[0-9a-f]{1,2}(?=\s|$|[^\w])/gi; /** * Clean streaming content that may have UTF-8 corruption from tokenizer byte fallback. * This handles cases where multi-byte Unicode characters are split across tokens, * causing replacement characters to appear in the stream. * * Handles both box-drawing characters (GLM table corruption) and emoji/Unicode * (4-byte sequences like 🌸 that get split into partial bytes). * * @param content - The content string to clean. * @param state - UTF-8 streaming state. * @returns Cleaned content string. */ export const cleanUtf8StreamContent = ( content: string, state: Utf8State, ): string => { if (!content) return content; // Prepend any pending content from previous chunk let result = state.pendingContent + content; state.pendingContent = ""; // Buffer trailing replacement chars - they may combine with next chunk // This handles ALL multi-byte Unicode, not just box-drawing if (result.endsWith(REPLACEMENT_CHAR)) { state.pendingContent = result; return ""; } // Buffer patterns like "���8f" at end - partial emoji byte sequences // These may combine with next chunk to form valid Unicode const partialMatch = result.match(PARTIAL_BYTE_SUFFIX_PATTERN); if (partialMatch) { // Only buffer if this looks like mid-emission (short content or ends string) const matchIndex = result.lastIndexOf(partialMatch[0]); if (matchIndex > 0) { // Buffer the corrupted suffix for potential recombination state.pendingContent = result.slice(matchIndex); result = result.slice(0, matchIndex); if (!result) return ""; } } // Clean up corrupted emoji patterns that won't recombine // Pattern: replacement chars + hex suffix + boundary (space/punctuation/end) result = result.replace(CORRUPTED_EMOJI_PATTERN, ""); // Clean orphaned replacement chars followed by common word patterns // e.g., "�orid" was meant to be an emoji + "orid" suffix - just keep the text result = result.replace(/\uFFFD+(?=[a-z]{2,})/gi, ""); // Drop trailing replacement chars after box-drawing characters result = result.replace(new RegExp(`(${BOX_DRAWING_RANGE})\\uFFFD+$`, "g"), "$1"); // Remove replacement characters in box-drawing corruption patterns: // 1. Before box-drawing characters (e.g., "�─" should be just "─") result = result.replace(new RegExp(`\\uFFFD+(?=${BOX_DRAWING_RANGE})`, "g"), ""); // 2. After box-drawing characters (e.g., "─�" where corner/tee was corrupted) result = result.replace(new RegExp(`(${BOX_DRAWING_RANGE})\\uFFFD+`, "g"), "$1"); // 3. Inside backticks (code context) result = result.replace(/`\uFFFD`/g, "``"); result = result.replace(/`\uFFFD,/g, "`,"); result = result.replace(/`\uFFFD\)/g, "`)"); result = result.replace(/\uFFFD`/g, "`"); // 4. Space + replacement patterns result = result.replace(new RegExp(` \\uFFFD+(?=${BOX_DRAWING_RANGE}|[ ,\`])`, "g"), " "); result = result.replace(/ \uFFFD+ /g, " "); // 5. After common ASCII punctuation result = result.replace(/([,.:;])\uFFFD+/g, "$1"); // 6. Clean remaining replacement chars in contexts where they're clearly corruption if (result.includes(REPLACEMENT_CHAR)) { const hasBoxContext = BOX_DRAWING_REGEX.test(result); // Also clean if we see HTML tag context (emoji in HTML attributes/content) const hasHtmlContext = /<[^>]*>/.test(result) || result.includes(" `call_${randomUUID().replace(/-/g, "").slice(0, 9)}`; /** * Parse tool calls from a text payload. * @param content - Raw model content. * @returns Parsed tool calls. */ export const parseToolCallsFromContent = (content: string): ToolCall[] => { const toolCalls: ToolCall[] = []; const mcpPattern = /\s*([^<]*)<\/server_name>\s*([^<]*)<\/tool_name>\s*\s*(\{.*?\})\s*<\/arguments>\s*<\/use_mcp[\s_]*tool>/gs; const mcpMatches = Array.from(content.matchAll(mcpPattern)); for (const match of mcpMatches) { const toolName = (match[2] ?? "").trim(); const argsJson = (match[3] ?? "").trim(); toolCalls.push({ index: toolCalls.length, id: createToolCallId(), type: "function", function: { name: toolName, arguments: argsJson }, }); } if (toolCalls.length > 0) { return toolCalls; } if (content.includes("")) { const pattern = /\{"name"\s*:\s*"([^"]+)"\s*,\s*"arguments"\s*:\s*(\{[^}]*\})\s*\}\s*<\/tool_call>/gs; for (const match of content.matchAll(pattern)) { toolCalls.push({ index: toolCalls.length, id: createToolCallId(), type: "function", function: { name: String(match[1] ?? ""), arguments: String(match[2] ?? "") }, }); } } if (toolCalls.length === 0 && content.includes("")) { const pattern = /\s*(\{.*?\})\s*<\/tool_call>/gs; for (const match of content.matchAll(pattern)) { try { const payload = match[1]; if (!payload) { continue; } const parsed = JSON.parse(payload) as Record; toolCalls.push({ index: toolCalls.length, id: createToolCallId(), type: "function", function: { name: String(parsed["name"] ?? ""), arguments: JSON.stringify(parsed["arguments"] ?? {}), }, }); } catch { continue; } } } if (toolCalls.length === 0 && content.includes("\"name\"") && content.includes("\"arguments\"")) { const pattern = /\{"name"\s*:\s*"([^"]+)"\s*,\s*"arguments"\s*:\s*(\{[^}]*\})\s*\}/gs; for (const match of content.matchAll(pattern)) { toolCalls.push({ index: toolCalls.length, id: createToolCallId(), type: "function", function: { name: String(match[1] ?? ""), arguments: String(match[2] ?? "") }, }); } } return toolCalls; }; /** * Parse think tags into reasoning content. * @param payload - SSE payload object. * @param state - Think state. * @returns Updated payload. */ export const parseThinkTagsFromContent = ( payload: Record, state: ThinkState, ): Record => { const choices = payload["choices"]; if (!Array.isArray(choices)) { return payload; } for (const choice of choices) { const choiceRecord = choice as Record; const delta = (choiceRecord["delta"] ?? choiceRecord["message"]) as Record | undefined; if (!delta) { continue; } const content = delta["content"]; if (typeof content !== "string" || content.length === 0) { continue; } const hasReasoning = typeof delta["reasoning_content"] === "string"; if (hasReasoning) { if (content.includes("") || content.includes("")) { const cleaned = content.replace(/<\/?think>/g, "").trim(); delta["content"] = cleaned || null; } continue; } if (content.includes("") && !content.includes("") && !state.inThinking) { const [reasoning, remaining = ""] = content.split("", 2); delta["reasoning_content"] = reasoning; delta["content"] = remaining?.trim() || null; continue; } if (content.includes("")) { const [before = "", after = ""] = content.split("", 2); state.inThinking = true; if (after.includes("")) { const [reasoning, remaining = ""] = after.split("", 2); state.inThinking = false; delta["reasoning_content"] = reasoning; delta["content"] = `${before}${remaining ?? ""}`.trim() || null; } else { delta["reasoning_content"] = after; delta["content"] = before.trim() || null; } continue; } if (state.inThinking) { if (content.includes("")) { const [reasoning, remaining = ""] = content.split("", 2); state.inThinking = false; delta["reasoning_content"] = reasoning; delta["content"] = remaining?.trim() || null; } else { delta["reasoning_content"] = content; delta["content"] = null; } } } return payload; }; /** * Fix tool calls with missing function names by parsing from content. * @param payload - SSE payload object. * @param buffer - Tool call buffer. * @returns Updated payload. */ export const fixMalformedToolCalls = ( payload: Record, buffer: ToolCallBuffer, ): Record => { const choices = payload["choices"]; if (!Array.isArray(choices)) { return payload; } for (const choice of choices) { const choiceRecord = choice as Record; const delta = (choiceRecord["delta"] ?? choiceRecord["message"]) as Record | undefined; if (!delta) { continue; } const content = typeof delta["content"] === "string" ? String(delta["content"]) : ""; if (content) { buffer.content += content; } const toolCalls = Array.isArray(delta["tool_calls"]) ? (delta["tool_calls"] as unknown[]) : []; if (toolCalls.length === 0) { continue; } const fixed = toolCalls.map((toolCall) => { const toolRecord = toolCall as Record; const functionRecord = toolRecord["function"] as Record | undefined; const name = functionRecord?.["name"]; if (!name || String(name).trim() === "") { buffer.has_malformed_tool_calls = true; const nameMatch = buffer.content.match(/"name"\s*:\s*"([^"]+)"/); if (nameMatch && functionRecord) { functionRecord["name"] = nameMatch[1] ?? ""; } } return toolRecord; }); delta["tool_calls"] = fixed; } return payload; };