How to Parse the GLM-5.2 Streaming API Safely
Independent research — not an official Z.ai publication.Identity and provider disclosure
We sent five small requests on July 26, 2026 from a pinned, disposable Docker container. The image above is a browser capture of the sanitized measurements, not a mock provider dashboard. API keys, response IDs, tool-call IDs, account data, and reasoning text were neither published nor stored.
Streaming changes when an application can show progress, but it does not change what “correct” means. A parser still has to distinguish transport fields, reconstruct fragmented values, recognize an incomplete response, validate tool arguments, and decide whether the final answer is usable.
That is especially important for GLM-5.2. A thinking response can send reasoning_content before user-visible content; a streamed function call can repeat stable metadata while splitting its JSON arguments; and token usage arrives at the end. A loop that prints every delta or executes a tool as soon as it sees a brace is not production-safe.
Navigate this measured streaming guide
Section titled “Navigate this measured streaming guide”- The SSE contract in one table
- Five live requests
- Send a minimal request
- Parse complete SSE events
- Separate reasoning from the answer
- Assemble streamed tools
- Define successful completion
- Classify failures
- Choose the correct Z.ai route
- Production release gate
- Streaming questions
- Evidence and sources
The GLM-5.2 SSE contract in one table
Section titled “The GLM-5.2 SSE contract in one table”Z.ai’s current Chat Completion reference says stream=true returns a standard Event Stream and ends with data: [DONE]. Its streaming guide places visible text and reasoning in different delta fields, with finish_reason and usage in the final JSON chunk.
| Signal | When it can appear | Safe handling |
|---|---|---|
choices[0].delta.reasoning_content |
zero or many intermediate events | keep separate from visible answer and sensitive logs |
choices[0].delta.content |
zero or many intermediate events | append in order; an empty piece is not a failure |
choices[0].delta.tool_calls |
tool-bearing events | group by index; do not execute partial arguments |
choices[0].finish_reason |
terminal JSON event | accept stop or route to validated tools; classify other reasons |
usage |
terminal JSON event in the documented format | record prompt, completion, total, and cached tokens |
data: [DONE] |
after the terminal JSON event | mark the SSE transport complete |
Those signals solve different problems. [DONE] proves the server closed its event sequence normally. finish_reason explains why generation stopped. usage closes metering. Your own schema, policy, citation, tool, or answer checks decide whether the result is acceptable.
Five live requests: what crossed the wire
Section titled “Five live requests: what crossed the wire”The probe used model ID glm-5.2 and Python’s standard library inside
python:3.13.5-alpine3.22, pinned by digest. Each row is one request, so the
timings show event order rather than average model speed.
| Route and case | JSON events | First relevant delta | Total | Terminal evidence |
|---|---|---|---|---|
| pay-as-you-go, exact text | 4 | content at 2,468 ms | 2,468 ms | stop, 23 tokens, [DONE] |
| pay-as-you-go, thinking | 105 | reasoning at 2,973 ms; content at 3,263 ms | 3,263 ms | stop, 131 tokens, [DONE] |
| pay-as-you-go, default tool behavior | 2 | tool event at 3,512 ms | 3,512 ms | tool_calls, valid arguments, 177 tokens |
pay-as-you-go, tool_stream=true |
7 | tool event at 4,968 ms | 4,968 ms | six pieces, valid assembled arguments, 177 tokens |
| Coding Plan, exact text | 4 | content at 1,435 ms | 1,436 ms | stop, 23 tokens, [DONE] |
The thinking request returned 105 JSON events and 254 reasoning characters before the visible answer 437; only the character count was retained. The streamed-tool request produced one lookup_timezone call whose final arguments were {"city":"Hong Kong"}. The function name appeared once, while type: "function" and an argument fragment appeared in all six tool pieces.
That repetition exposed a concrete parser bug: concatenating every string field would create functionfunctionfunctionfunctionfunctionfunction. The correct rule for this response shape is to accept repeated identical stable fields, append argument deltas in order, and reject conflicting stable values.
These are single observations, not a latency benchmark. Do not rank the two routes by these times. Geography, service load, account tier, prompt shape, and network path were uncontrolled. The Coding Plan row verifies its SSE transport on this account; it does not change the plan’s product scope.
Send the smallest useful streaming request
Section titled “Send the smallest useful streaming request”Use the general pay-as-you-go endpoint for an application request:
curl -N --fail-with-body \ https://api.z.ai/api/paas/v4/chat/completions \ -H "Authorization: Bearer ${ZAI_API_KEY}" \ -H "Content-Type: application/json" \ -H "Accept: text/event-stream" \ --data '{ "model": "glm-5.2", "messages": [ {"role": "user", "content": "Return exactly GLM52_STREAM_OK."} ], "stream": true, "thinking": {"type": "disabled"}, "do_sample": false, "max_tokens": 32 }'curl -N disables output buffering, so events become visible as they arrive. Keep the API key in an environment variable or secret manager, never in source control or a shell-history example. In a service, also set connect, first-byte, idle, and total deadlines rather than one ambiguous timeout.
Thinking is disabled here to create an easy acceptance sentinel. For a reasoning workload, enable it deliberately and choose an effort value using the GLM-5.2 reasoning-effort test guide. Streaming does not make reasoning free; it changes delivery order.
Use a stateful SSE parser, not splitlines
Section titled “Use a stateful SSE parser, not splitlines”SSE events end at a blank line and can contain more than one data: line. A parser should therefore assemble the event first, then decode its payload. Splitting an arbitrary TCP chunk on newlines is unsafe because network chunks are not event boundaries.
This dependency-free Python skeleton handles comments, multi-line data, terminal metadata, streamed tools, and a truncated connection:
import json, os, urllib.request
URL = "https://api.z.ai/api/paas/v4/chat/completions"
def iter_sse(response): data_lines = [] for raw in response: line = raw.decode("utf-8", errors="strict").rstrip("\r\n") if line == "": if data_lines: yield "\n".join(data_lines) data_lines.clear() continue if line.startswith(":"): continue field, separator, value = line.partition(":") if separator and field == "data": data_lines.append(value.lstrip(" ")) if data_lines: # connection ended mid-protocol yield "\n".join(data_lines)
def set_stable(call, key, piece): if not piece: return if call[key] not in ("", piece): raise ValueError(f"conflicting streamed {key}") call[key] = piece
payload = { "model": "glm-5.2", "messages": [{"role": "user", "content": "Return exactly OK."}], "stream": True, "thinking": {"type": "disabled"}, "do_sample": False, "max_tokens": 16,}request = urllib.request.Request( URL, data=json.dumps(payload).encode(), headers={ "Authorization": f"Bearer {os.environ['ZAI_API_KEY']}", "Content-Type": "application/json", "Accept": "text/event-stream", }, method="POST",)
content, reasoning, tools = [], [], {}finish_reason = usage = Nonedone = False
with urllib.request.urlopen(request, timeout=90) as response: media_type = response.headers.get_content_type() if response.status != 200 or media_type != "text/event-stream": raise RuntimeError(f"unexpected response: {response.status} {media_type}")
for data in iter_sse(response): if data == "[DONE]": done = True break event = json.loads(data) if "error" in event: raise RuntimeError("provider returned a streamed error") usage = event.get("usage") or usage choices = event.get("choices") or [] if not choices: continue choice = choices[0] finish_reason = choice.get("finish_reason") or finish_reason delta = choice.get("delta") or {} reasoning.append(delta.get("reasoning_content") or "") content.append(delta.get("content") or "")
for piece in delta.get("tool_calls") or []: index = int(piece.get("index", 0)) call = tools.setdefault( index, {"id": "", "type": "", "name": "", "arguments": ""} ) set_stable(call, "id", piece.get("id") or "") set_stable(call, "type", piece.get("type") or "") function = piece.get("function") or {} set_stable(call, "name", function.get("name") or "") call["arguments"] += function.get("arguments") or ""
if not done or finish_reason is None or usage is None: raise RuntimeError("truncated or incomplete GLM-5.2 stream")if finish_reason not in {"stop", "tool_calls"}: raise RuntimeError(f"generation ended with {finish_reason}")
for call in tools.values(): call["parsed_arguments"] = json.loads(call["arguments"])
result = {"content": "".join(content), "tools": list(tools.values()), "usage": usage}Production code should catch HTTPError separately, redact provider bodies before logging, and cap bytes, event count, reasoning size, content size, tool count, and argument size. The full sanitized probe is preserved with the article evidence, including exact container controls.
This measured parser expects one response choice. A local vLLM or SGLang request that enables several candidates needs an outer accumulator keyed by choice index before reasoning, text, tools, and finish reasons are assembled. The GLM-5.2 multiple-output guide defines that index-aware fan-out and its count-times-output budget gate.
Keep reasoning and visible answer in different buffers
Section titled “Keep reasoning and visible answer in different buffers”The live thinking request’s first meaningful event was reasoning, not visible text. If a UI changes from “waiting” to “responding” only when delta.content appears, it can look frozen while the model is already producing reasoning.
Use separate state:
- transport started when headers and the first valid SSE event arrive;
- reasoning active when non-empty
reasoning_contentarrives; - answer active when non-empty
contentarrives; - complete only after the terminal invariants pass.
Do not concatenate reasoning into the final answer. It is a different field, can be large, may contain sensitive prompt-derived material, and is not an application-level proof of correctness. Our evidence retains only a count. If your product exposes or stores reasoning, make that a deliberate privacy, retention, and UX decision.
For JSON workloads, stream visible bytes into a buffer but validate only the completed document. The GLM-5.2 structured-output guide shows why a provider accepting a response-format field is not the same as enforcing every schema constraint.
After assembling a complete tool_calls array, continue with the tested GLM-5.2 tool-calling loop. It covers argument validation, application execution, matching tool_call_id results, multiple calls, structured failures, and route controls that can be accepted without enforcing their familiar OpenAI semantics.
Assemble tool calls by index and validate only at the end
Section titled “Assemble tool calls by index and validate only at the end”Z.ai documents tool_stream in the current API reference for GLM-4.6 and later. Its separate Stream Tool Call guide still lists older models, so the pages are version-skewed: the older guide does not explicitly name GLM-5.2, while the current reference and our dated live request establish that this route accepted it.
With the default tool_stream=false, our forced call was effectively buffered into one tool-bearing JSON event plus the terminal sequence. With tool_stream=true, its arguments arrived in six fragments.
Never run a function on a partial fragment. Wait for finish_reason: "tool_calls", assemble by index, then:
- require an allowed function name and
type: "function"; - parse the complete argument string as JSON;
- validate it against your own schema;
- enforce authorization, path, host, amount, and resource limits;
- require human approval for consequential actions;
- execute with an idempotency key and a deadline;
- append the tool result using the exact call ID kept in memory.
The model chooses arguments; your application owns permission. Valid JSON can still request the wrong city, an unauthorized record, or a destructive operation.
Define success beyond data: [DONE]
Section titled “Define success beyond data: [DONE]”A safe acceptance state is a conjunction:
HTTP 200AND Content-Type is text/event-streamAND every received data event parsesAND a terminal finish_reason existsAND final usage existsAND [DONE] arrivesAND all tool arguments or visible output pass application validationTreat stop and tool_calls as two different successful transport outcomes. A tool result is not an answer; it starts another controlled turn. Treat length, sensitive, model_context_window_exceeded, and network_error as explicit non-acceptance states until your application applies a bounded recovery rule.
Do not automatically replay a billable or tool-bearing request after an ambiguous disconnect. You may have received only part of the response even though the provider completed work. Attach a unique request_id, keep an application idempotency key, and make tool execution independently idempotent.
Classify failures by the stage that owns them
Section titled “Classify failures by the stage that owns them”Before SSE begins: a 401 or 403 belongs to authentication, entitlement, balance, or route selection. A 400 belongs to request shape or model parameters. Parse the HTTP status first; do not feed an error JSON body to the SSE loop.
During event decoding: invalid UTF-8, malformed JSON, an unknown oversized event, or conflicting stable tool fields is a protocol failure. Stop, preserve a redacted diagnostic, and do not execute tools.
At connection close: missing [DONE], missing finish_reason, or missing final usage means incomplete transport under this parser contract. Keep any partial text visibly marked as incomplete rather than presenting it as a final answer.
After normal transport: schema failure, prohibited content, an unapproved tool, a failed citation check, or a wrong sentinel is an application rejection. Transport success must not overwrite it.
For long conversations, prompt caching and streaming are independent. Caching can change input billing and telemetry, while streaming controls delivery. The GLM-5.2 prompt-caching guide explains how to verify cached_tokens without inferring a hit from faster first output.
Keep Coding Plan and application traffic separate
Section titled “Keep Coding Plan and application traffic separate”The general application base URL is:
https://api.z.ai/api/paas/v4The dedicated Coding Plan base URL is:
https://api.z.ai/api/coding/paas/v4Z.ai’s API introduction documents the general route and sends Coding Plan users to a dedicated tutorial. Its supported development-tools guide describes the Coding endpoint as a route for covered coding tools.
Our plan sentinel returned the same basic SSE contract on the test account. That result does not authorize a customer-facing service, batch job, or arbitrary backend to consume subscription quota. Use pay-as-you-go API access for application traffic unless current provider terms explicitly say otherwise. The Coding Plan versus API versus self-hosting guide covers the product decision rather than conflating two working URLs.
If a managed gateway is preferable, compare its exact model ID, event format, reasoning field, terminal usage, tool-stream behavior, timeouts, and retry semantics in the GLM-5.2 API provider guide. “OpenAI-compatible” is a starting shape, not proof of identical wire behavior.
A release gate for production streams
Section titled “A release gate for production streams”Before shipping, capture one fixture for each state your application accepts or rejects:
- normal text, thinking text, no-content tool call, and multiple tool calls;
stop,tool_calls,length, provider error, client timeout, and abrupt close;- repeated stable tool fields and fragmented JSON arguments;
- oversized event, too many events, and a tool argument beyond your limit;
- user cancellation before and after a tool is approved;
- retry after a request whose server-side completion is unknown.
Record model ID, route, date, request template version, first-event time, first-content time, terminal reason, usage, retry count, and application verdict. Keep a sanitized raw fixture or event digest so a provider or SDK change can be detected. Re-run the suite after changing the model, provider, SDK, proxy, timeout, tool schema, or reasoning setting.
The operational target is not merely “text appeared quickly.” It is “the stream was bounded, reconstructable, attributable, billable, cancellable, and accepted by the same rules as a non-streamed response.”
Questions about GLM-5.2 streams
Section titled “Questions about GLM-5.2 streams”Does GLM-5.2 return token usage in streaming mode?
Section titled “Does GLM-5.2 return token usage in streaming mode?”Yes in the checked Z.ai format. Official documentation places usage in the last JSON chunk, and all five live requests returned it before [DONE]. Code should still treat missing final usage as an incomplete monitored response rather than inventing a token count.
Should an app display reasoning_content to users?
Section titled “Should an app display reasoning_content to users?”Not by default. Keep it separate from visible content, apply an explicit privacy and retention policy, and never treat hidden reasoning as a correctness guarantee. A product can show a bounded status indicator without publishing the reasoning text.
Are streamed tool arguments valid JSON in every event?
Section titled “Are streamed tool arguments valid JSON in every event?”No. Our tool_stream=true call split one valid argument object into six fragments. Concatenate function.arguments in index order, wait for the terminal tool-call reason, then parse and validate the complete string.
Is tool_stream required for a streamed response?
Section titled “Is tool_stream required for a streamed response?”No. stream=true streams the chat response. tool_stream=true additionally streams function-call information. With its default false setting, our tool call was buffered into far fewer events.
Can a web app use the Coding Plan endpoint?
Section titled “Can a web app use the Coding Plan endpoint?”Do not infer that from a successful HTTP request. Z.ai presents the dedicated route for supported coding tools. Use the general metered endpoint for an application unless the current plan terms explicitly cover that workload.
Evidence sources and reproduction notes
Section titled “Evidence sources and reproduction notes”Sources checked July 26, 2026:
- Z.ai Chat Completion API — model ID, streaming, terminal sentinel, thinking, tool-stream parameter, finish reasons, and usage.
- Z.ai Streaming Messages — SSE field placement and final-event example.
- Z.ai Stream Tool Call — incremental tool arguments and its older model list.
- Z.ai API introduction — general endpoint, Bearer authentication, and current GLM-5.2 examples.
- Z.ai development-tools guide — dedicated Coding Plan endpoint and supported-tool context.
- WHATWG Server-Sent Events standard — event framing, data fields, and dispatch behavior.
The Docker probe, sanitized JSON results, isolation exceptions, and cleanup inventory are archived in the repository under docs/evidence/glm-5-2-streaming-api-2026-07-26/. The normal Docker bridge could not resolve the provider hostname in this environment, so the final container used host networking for outbound HTTPS only, with no listener or published port. It ran non-root with a read-only filesystem, all capabilities dropped, and resource limits; the task-owned image was deleted after the run.
The measurements prove the observed event order and parser edge case for these five requests. They do not estimate average latency, throughput, uptime, or future route behavior.
