Files
oh-my-pi/packages/ai/src/utils/json-parse.ts
T
can1357 e1d2dfeffa fix(ai): parse first streaming tool args
Addresses review feedback on #1507.
2026-05-31 04:49:20 +02:00

183 lines
5.0 KiB
TypeScript

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<T>(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<T = Record<string, unknown>>(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<T = Record<string, unknown>>(
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<T>(partialJson), parsedLen: len };
}