SSE Stream Parser that Reassembles Tool-Call Arguments
A streaming LLM API sends Server-Sent Events. Each event's `data:` is JSON: `{"type":"text","delta":...}`, `{"type":"tool_call_start","index","id","name"}`, `{"type":"tool_call_delta","index","args":<partial JSON string>}`, `{"type":"tool_call_end","index"}`, and finally `data: [DONE]`. Bytes arrive in arbitrary chunks that can split lines, CRLFs and multi-byte UTF-8 characters. Write `StreamAssembler.feed(chunk: bytes)` and `result()` returning the full text and the parsed arguments of every tool call; `result()` must raise if the stream ended early or a tool call never closed.
There are three layers of framing and each can be split: bytes into UTF-8 characters, characters into lines, lines into events. Handle each with its own buffer (an incremental UTF-8 decoder, a line buffer, an event accumulator dispatched on a blank line) and the parser becomes chunk-size-invariant, which the test proves by feeding every chunk size from 1 to 7. Tool-call arguments arrive as fragments of a JSON string, so you concatenate per call index and parse only on the end event; parsing early fails, and parallel tool calls interleave by index. Treat a missing `[DONE]` or an unclosed call as an error, because a truncated stream that looks successful executes a tool with half its arguments.
Try it first.
tests.py · 5 passing
def ev(obj):
return "data: " + json.dumps(obj, ensure_ascii=False) + "\r\n\r\n"
stream = (": keep-alive\n\n"
+ ev({"type": "text", "delta": "Café ☕ "})
+ ev({"type": "tool_call_start", "index": 0, "id": "c1", "name": "search"})
+ ev({"type": "tool_call_start", "index": 1, "id": "c2", "name": "weather"})
+ ev({"type": "tool_call_delta", "index": 0, "args": "{\"q\": \"crè"})
+ ev({"type": "tool_call_delta", "index": 1, "args": "{\"city\":"})
+ ev({"type": "tool_call_delta", "index": 0, "args": "me brûlée\", \"k\": 3}"})
+ ev({"type": "tool_call_end", "index": 0})
+ ev({"type": "tool_call_delta", "index": 1, "args": " \"Paris\"}"})
+ ev({"type": "tool_call_end", "index": 1})
+ "event: done\ndata: [DONE]\n\n").encode("utf-8")
expected = None
for size in range(1, 8):
a = StreamAssembler()
for i in range(0, len(stream), size):
a.feed(stream[i:i + size])
text, calls = a.result()
got = (text, [(c.id, c.name, c.args) for c in calls])
expected = expected or got
assert got == expected
assert expected[0] == "Café ☕ "
assert expected[1] == [("c1", "search", {"q": "crème brûlée", "k": 3}), ("c2", "weather", {"city": "Paris"})]
cut = stream[: stream.index(b"tool_call_end")]
a = StreamAssembler()
a.feed(cut)
try:
a.result()
assert False
except ValueError:
pass
p = SSEParser()
assert p.feed(b"data: a\ndata: b\n\n") == [("message", "a\nb")]For UI responsiveness you often want to show arguments while they stream; that needs a tolerant partial-JSON parser that closes open strings and brackets, used only for display, never for execution. Also handle provider error events mid-stream, reconnection with Last-Event-ID, and backpressure: if the consumer is slow, buffer in bytes with a cap rather than growing unbounded.