n4nAI

Streaming partial tool calls over SSE in chat completions

Step-by-step guide to sse streaming partial tool calls in chat completions: parse Server-Sent Events, reconstruct tool arguments, and verify streams.

n4n Team3 min read644 words

Audio narration

Coming soon — every post will get a voice note here.

Most LLM APIs dump tool calls as a single JSON blob at the end of a response. sse streaming partial tool calls changes that: the model emits argument fragments incrementally over Server-Sent Events, and your client must reassemble them before execution. This guide walks through a complete implementation against an OpenAI-compatible chat completions endpoint.

Step 1: Configure the chat completion request for streaming tool calls

Set stream: true and supply a tools array. The server returns delta.tool_calls items inside each chunk instead of a finalized message.tool_calls at the end.

{
  "model": "gpt-4o-mini",
  "stream": true,
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "parameters": {
          "type": "object",
          "properties": { "city": { "type": "string" } },
          "required": ["city"]
        }
      }
    }
  ],
  "messages": [{ "role": "user", "content": "Weather in Paris?" }]
}

The first chunk containing a tool call typically carries id, type, and function.name. Subsequent chunks carry only function.arguments fragments. If you front your requests with an OpenAI-compatible gateway such as n4n.ai, automatic fallback on provider rate limits keeps the sse streaming partial tool calls connection alive without client changes.

Step 2: Open the SSE stream and read raw lines

Use a client that does not buffer the response body. In Python, requests with stream=True works; in Node, fetch with response.body.getReader() is correct.

import os, json, requests

url = "https://api.openai.com/v1/chat/completions"
headers = {
    "Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}",
    "Content-Type": "application/json",
}
payload = { ... }  # from Step 1

with requests.post(url, json=payload, headers=headers, stream=True) as r:
    for raw in r.iter_lines():
        if not raw:
            continue
        if raw.startswith(b"data: "):
            event = raw[6:].decode("utf-8").strip()
            if event == "[DONE]":
                break
            chunk = json.loads(event)
            # handle chunk (next steps)

The iter_lines helper splits on newlines, which matches the SSE framing of data: {json}\n\n. Do not assume each network packet is one event.

TypeScript reader

const resp = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
const acc: Record<number, any> = {};
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  let idx;
  while ((idx = buf.indexOf("\n\n")) !== -1) {
    const event = buf.slice(0, idx); buf = buf.slice(idx + 2);
    if (event.startsWith("data: ")) {
      const data = event.slice(6);
      if (data === "[DONE]") break;
      handleChunk(JSON.parse(data), acc);
    }
  }
}

This manual buffer split is required because fetch exposes a byte stream, not line semantics.

Step 3: Extract tool call deltas from each chunk

Every chunk has choices[0].delta. When the model invokes a tool, delta.tool_calls is a list of objects keyed by index. The index identifies which parallel tool call the fragment belongs to.

def handle_chunk(chunk, acc):
    delta = chunk["choices"][0]["delta"]
    for tc in delta.get("tool_calls", []) or []:
        idx = tc["index"]
        if idx not in acc:
            acc[idx] = {
                "id": tc.get("id"),
                "type": tc.get("type", "function"),
                "function": {"name": "", "arguments": ""},
            }
        if tc.get("id"):
            acc[idx]["id"] = tc["id"]
        if tc.get("function", {}).get("name"):
            acc[idx]["function"]["name"] = tc["function"]["name"]
        if tc.get("function", {}).get("arguments"):
            acc[idx]["function"]["arguments"] += tc["function"]["arguments"]

This accumulation pattern is the core of sse streaming partial tool calls: you stitch fragments per index, not per request.

Step 4: Reconstruct and parse partial arguments safely

The arguments field is a JSON string built incrementally. It may be {, then "city", then :"Par, then is"}. Do not call json.loads on each fragment. Wait until the stream closes, then parse the full string.

If you need to act on partial arguments (e.g., to show a live UI), use an incremental JSON parser such as jsonyx or a stack-based scanner. For execution, defer until complete.

import json

def finalize(acc):
    calls = []
    for idx in sorted(acc):
        item = acc[idx]
        args_str = item["function"]["arguments"]
        try:
            args = json.loads(args_str)
        except json.JSONDecodeError:
            args = {}  # or raise, depending on strictness
        calls.append({
            "id": item["id"],
            "type": item["type"],
            "function": {"name": item["function"]["name"], "arguments": args},
        })
    return calls

A robust client treats malformed final arguments as a stream error and retries the request.

Step 5: Execute the tool and continue the conversation

After finalizing, call your local function. Then return the result as a role: "tool" message with the matching tool_call_id.

def get_weather(city):
    return f"Sunny in {city}"

messages = [{"role": "user", "content": "Weather in Paris?"}]
# assume calls[0] is the weather call
tool_result = get_weather(calls[0]["function"]["arguments"]["city"])
messages.append({
    "role": "tool",
    "tool_call_id": calls[0]["id"],
    "content": tool_result,
})

Send messages back with stream: true again if you want the model’s final natural language answer streamed. The same sse streaming partial tool calls logic applies if the model chains another tool.

Step 6: Handle multiple parallel tool calls

Models can emit several tool_calls with different indices in the same or interleaved chunks. Your accumulator must keep them independent. When finalizing, preserve index order; most runtimes execute them concurrently.

import concurrent.futures

def run_calls(calls):
    with concurrent.futures.ThreadPoolExecutor() as ex:
        futures = {ex.submit(execute, c): c for c in calls}
        for f in concurrent.futures.as_completed(futures):
            c = futures[f]
            result = f.result()
            messages.append({"role":"tool","tool_call_id":c["id"],"content":result})

Do not assume the first index is the only one. SSE ordering is per-index, not global.

Step 7: Verify success

Verification is concrete: assert that the streamed arguments parse to valid JSON, that the tool executed, and that the follow-up completion finishes. A minimal test:

def test_stream():
    acc = {}
    for chunk in captured_chunks:
        handle_chunk(chunk, acc)
    calls = finalize(acc)
    assert calls[0]["function"]["name"] == "get_weather"
    assert calls[0]["function"]["arguments"]["city"] == "Paris"
    assert get_weather("Paris")
    print("OK: sse streaming partial tool calls reconstructed correctly")

Run this against a recorded stream or a live call with curl -N and pipe through jq to eyeball fragments:

curl -N https://api.example.com/v1/chat/completions \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d @payload.json | grep '^data:' | tail -5

You should see the arguments string grow across lines and a final [DONE]. If the connection drops mid-argument, the accumulator will have a truncated JSON; treat that as a retry signal.

Edge cases that will bite you

  • Blank lines: SSE requires an empty line between events. Ignore empty raw bytes.
  • Role fragments: Some providers send delta.role on the first chunk; ignore it for tool logic.
  • Id latency: id may arrive a chunk after index. Always key on index, backfill id.
  • Arguments empty string: A chunk may carry arguments: "" legitimately; concatenating is safe.
  • Provider cache hints: Gateways that forward cache-control headers can reuse prompt prefixes; your streaming parser stays identical.

Getting sse streaming partial tool calls right means treating the stream as a state machine, not a queue of messages. Build the accumulator once, feed it every chunk, and execute only on a clean finish.

Tagsssetool-callingstreamingchat-completions

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All server-sent events (sse) streaming deep dive posts →