import { parse as partialParse } from "partial-json"; const QUOTE = 0x22; const BACKSLASH = 0x5c; const U = 0x75; // Valid chars after `\`: " \ / b f n r t u const VALID_ESCAPE_CHAR = new Uint8Array(128); for (const ch of '"\\/bfnrtu') VALID_ESCAPE_CHAR[ch.charCodeAt(0)] = 1; const CONTROL_ESCAPES: readonly string[] = (() => { const e: string[] = []; e[0x08] = "\\b"; e[0x09] = "\\t"; e[0x0a] = "\\n"; e[0x0c] = "\\f"; e[0x0d] = "\\r"; for (let cp = 0; cp <= 0x1f; cp++) { e[cp] ??= `\\u${cp.toString(16).padStart(4, "0")}`; } return e; })(); function isHexDigit(cp: number): boolean { return (cp >= 0x30 && cp <= 0x39) || ((cp | 0x20) >= 0x61 && (cp | 0x20) <= 0x66); } export function repairJson(json: string): string { const len = json.length; const parts: string[] = []; let lastEmit = 0; let inString = false; let i = 0; while (i < len) { if (!inString) { // Fast scan: skip to next quote. while (i < len && json.charCodeAt(i) !== QUOTE) i++; if (i >= len) break; inString = true; i++; continue; } // Fast scan inside string: advance past chars that need no handling. while (i < len) { const cp = json.charCodeAt(i); if (cp < 0x20 || cp === QUOTE || cp === BACKSLASH) break; i++; } if (i >= len) break; const cp = json.charCodeAt(i); if (cp === QUOTE) { inString = false; i++; continue; } if (cp === BACKSLASH) { // Need at least one char after the backslash; treat EOI as invalid escape. if (i + 1 >= len) { parts.push(json.slice(lastEmit, i), "\\\\"); lastEmit = i + 1; i++; continue; } const nextCp = json.charCodeAt(i + 1); if (nextCp === U) { // Need full \uXXXX, all four digits, all hex. if ( i + 5 < len && isHexDigit(json.charCodeAt(i + 2)) && isHexDigit(json.charCodeAt(i + 3)) && isHexDigit(json.charCodeAt(i + 4)) && isHexDigit(json.charCodeAt(i + 5)) ) { i += 6; continue; } // Truncated or non-hex \u — escape the backslash, re-process the rest. parts.push(json.slice(lastEmit, i), "\\\\"); lastEmit = i + 1; i++; continue; } if (nextCp < 128 && VALID_ESCAPE_CHAR[nextCp] === 1) { i += 2; continue; } parts.push(json.slice(lastEmit, i), "\\\\"); lastEmit = i + 1; i++; continue; } // Control character (cp < 0x20). parts.push(json.slice(lastEmit, i), CONTROL_ESCAPES[cp]); lastEmit = i + 1; i++; } if (!parts.length) return json; if (lastEmit < len) parts.push(json.slice(lastEmit)); return parts.join(""); } export function parseJsonWithRepair(json: string): T { try { return JSON.parse(json) as T; } catch (error) { const repairedJson = repairJson(json); if (repairedJson !== json) { return JSON.parse(repairedJson) as T; } throw error; } } /** * Attempts to parse potentially incomplete JSON during streaming. * Always returns a valid object, even if the JSON is incomplete. * * @param partialJson The partial JSON string from streaming * @returns Parsed object or empty object if parsing fails */ export function parseStreamingJson>(partialJson: string | undefined): T { partialJson = partialJson?.trimStart(); if (!partialJson) { return {} as T; } try { return JSON.parse(partialJson) as T; } catch { partialJson = repairJson(partialJson); try { return (partialParse(partialJson) ?? {}) as T; } catch { // If all parsing fails, return empty object return {} as T; } } } /** * Default minimum byte growth before `parseStreamingJsonThrottled` will * re-parse a streaming tool-call argument buffer. Bounds the mid-stream * partial-parse cost from quadratic to linear in N. */ export const STREAMING_JSON_PARSE_MIN_GROWTH = 256; /** * Throttled variant of {@link parseStreamingJson} for the per-delta hot path. * * Tool calls arrive as a long sequence of small deltas — calling * `parseStreamingJson(buffer)` on every delta re-parses the entire buffer * each time, giving O(N²) work in the total buffer length. Throttling skips * the re-parse until at least `minGrowthBytes` of new content has arrived * since the last successful parse, bounding mid-stream cost to O(N). * * Each provider tracks the last parsed length on its tool-call block, so the * final `toolcall_end` parse (which providers already perform unconditionally) * is the authoritative full parse — the throttle only delays mid-stream UI * updates by at most `minGrowthBytes` of accumulated partial content. * * @returns the parsed object plus the new `parsedLen` to persist; or `null` * when the buffer has not grown enough to warrant a re-parse. */ export function parseStreamingJsonThrottled>( partialJson: string | undefined, lastParsedLen: number, minGrowthBytes: number = STREAMING_JSON_PARSE_MIN_GROWTH, ): { value: T; parsedLen: number } | null { const len = partialJson?.length ?? 0; if (len === 0 || (lastParsedLen > 0 && len - lastParsedLen < minGrowthBytes)) return null; return { value: parseStreamingJson(partialJson), parsedLen: len }; }