e1d2dfeffa
Addresses review feedback on #1507.
183 lines
5.0 KiB
TypeScript
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 };
|
|
}
|