Skip to content

GLM-5.2 Tool Calling: What the API Actually Returns

Independent research — not an official Z.ai publication.Identity and provider disclosure

Conceptual GLM-5.2 tool-calling loop showing a model request, JSON argument validation, two external tools, result return, and a separate error path

Original editorial illustration. The measured response screenshot and sanitized JSON appear below.

Function calling is not remote code execution hidden inside a model. GLM-5.2 receives tool definitions and may return a function name plus a JSON argument string. Your application validates that request, decides whether it has permission, performs the work, and sends a matching tool result in a second model call. Nothing useful happens if a client only displays the first response.

That distinction explains many “the model did not execute my tool” reports: the API delivered a request correctly, but no application loop consumed it. It also explains a quieter migration risk. An endpoint can accept an OpenAI-shaped field without enforcing the OpenAI behavior associated with that field.

We ran 27 non-streaming requests in disposable Docker containers to test the complete path and three compatibility conflicts. This guide publishes the request shape, a bounded Python implementation, sanitized results, and the limits needed to avoid turning one clean run into a universal reliability claim.

  1. Separate model request from execution
  2. Review the documented contract
  3. Inspect the Docker result
  4. See measured wire shapes
  5. Interpret multiple calls
  6. Avoid compatibility assumptions
  7. Copy the Python loop
  8. Validate before running
  9. Return failures safely
  10. Choose an access route
  11. Set production gates
  12. Resolve common questions
  13. Open sources and evidence

The API returns a request, not an execution

Section titled “The API returns a request, not an execution”

The official Z.ai function-calling guide describes four parties in the loop:

  1. the application sends messages and tool definitions;
  2. the model returns one or more tool_calls;
  3. the application executes an allowed function and appends a role: "tool" result with the matching tool_call_id;
  4. the model receives that result and produces the user-facing answer or asks for another tool.

The second step is intent, not side effect. The model cannot know whether your inventory database, shell command, payment API or browser action actually ran. Conversely, an application must never equate a plausible function name with authorization.

Treat every returned call as untrusted structured input. Parse the argument string, reject unknown keys, check types and business rules, authorize the current user, impose timeouts and spend limits, and log an audit record before execution. If the function changes state, add an idempotency key or a human approval boundary.

Z.ai’s hosted retrieval surfaces use a different contract: your application does not define or execute the search function itself. The GLM-5.2 Web Search route test separates the raw API, Chat synthesis and Coding Plan MCP, including the source checks needed before using retrieved claims.

The response’s finish_reason: "tool_calls" means “the current generation stopped to request tools.” It does not mean “the task finished.” After execution, preserve the assistant’s complete tool_calls array and attach one result for each call ID. Dropping the assistant turn or inventing a new ID breaks the causal link the next request needs.

The current Chat Completion reference allows up to 128 function definitions. A documented function name has at most 64 characters and matches ^[a-zA-Z0-9_-]+$. Its parameters use JSON Schema, and the response carries function.arguments as a JSON string that the application must validate.

For tool_choice, the Z.ai reference currently lists only auto. That mode lets the model answer normally or request a function. The page does not currently document required, none, a forced-function object, or parallel_tool_calls. GLM-5.2’s model guide confirms function calling as a capability, but it does not expand those control semantics.

“OpenAI compatible” still helps with the broad shape—tools, tool_calls, function names, JSON arguments and tool result messages. It does not promise every optional control from the OpenAI function-calling reference. Gate extensions against the documentation and the exact route you operate.

Use the smallest useful schema. Descriptions should explain when a tool applies and what each field means. Avoid overlapping functions whose names or purposes differ only subtly. An enum or pattern helps generation, but application validation remains mandatory even when a provider offers a strict-schema option.

The live run used glm-5.2, non-streaming Chat Completions, disabled thinking, deterministic sampling settings, and the Coding Plan endpoint:

https://api.z.ai/api/coding/paas/v4/chat/completions

The standard-library probe ran as UID/GID 1000 in a digest-pinned image with a read-only root filesystem, dropped capabilities, 256 MiB memory, one CPU, a 64-PID ceiling, a no-exec tmpfs, no listener and no published port. This host’s documented Docker bridge DNS issue required host networking for outbound HTTPS. Both task containers used --rm; the post-run container list was empty.

Tested behavior Observed result
HTTP 200 27/27
Exact function and exact synthetic argument 4/4
Explicit no-tool instruction and exact marker 3/3
Two requested orders as two correct top-level calls 4/4
Deterministic success result reached final marker 2/2
Non-retryable tool error reached final marker 2/2
Recorded total tokens 5,987
Request elapsed time 5.342 s median; 4.070–11.099 s range

The test executed only in-memory synthetic inventory and order fixtures. It made no destructive, paid, privileged or external tool call. The archive excludes credentials, authorization headers, response IDs, account metadata, visible model text and reasoning text. Harmless synthetic arguments and result objects remain so readers can inspect the actual call structure.

Twenty-seven clean transports do not establish an uptime rate, capacity limit, provider ranking or SLA. Strong instructions also made these cases easier than an ambiguous agent task. The useful result is narrower: this route completed a correct end-to-end loop under the recorded fixtures, and the compatibility controls below behaved differently from their familiar names.

Sanitized GLM-5.2 tool-calling test screenshot showing 27 successful HTTP responses, exact tool and result-loop counts, and three accepted-but-unenforced compatibility controls

Actual screenshot rendered from the sanitized result files. It is a GLM52.ai evidence view, not a Z.ai dashboard.

One real first response was sanitized into this equivalent shape. The ID is required for the next request but intentionally omitted from the public archive:

{
"finish_reason": "tool_calls",
"message": {
"content": null,
"tool_calls": [
{
"id": "<omitted>",
"type": "function",
"function": {
"name": "lookup_inventory",
"arguments": "{\"sku\":\"GLM52-SKU-01\"}"
}
}
]
}
}

After validation, the fixture returned a deterministic object:

{
"ok": true,
"sku": "GLM52-OK-01",
"available": 7,
"warehouse": "EAST-7"
}

The application appended the original assistant message and then:

{
"role": "tool",
"tool_call_id": "<same in-memory ID>",
"name": "lookup_inventory",
"content": "{\"ok\":true,\"sku\":\"GLM52-OK-01\",\"available\":7,\"warehouse\":\"EAST-7\"}"
}

Both success cases then returned finish_reason: "stop", zero new tool calls, and the exact requested final marker. Both error cases did the same after receiving {"ok":false,"error_code":"SKU_NOT_FOUND","retryable":false}. A structured failure therefore remained inside the reasoning loop instead of becoming an unhandled application exception.

Multi-call works, but parallel control did not

Section titled “Multi-call works, but parallel control did not”

Four prompts each requested two independent order lookups in the same response. Every response contained two top-level lookup_order calls, both argument strings parsed as JSON, and both exact order IDs were present. None encoded the second request inside the first argument object.

That is multi-call evidence, not proof of parallel execution. The API returned an array. The application still decides whether calls can run concurrently. Read-only lookups with independent rate budgets may be safe to parallelize; two writes against the same account often are not.

A paired control sent parallel_tool_calls: false with another two-order request. The endpoint returned HTTP 200 and still emitted two correct top-level calls. In this one run, the field neither caused a validation error nor prevented multiple calls. Do not depend on it on this route merely because an SDK serializes the field.

If execution order matters, impose it in the application:

  • group calls by side-effect and dependency;
  • serialize writes unless the operation is explicitly commutative;
  • cap calls per model turn;
  • deduplicate repeated IDs or arguments;
  • stop the loop after a fixed number of model and tool steps;
  • return a bounded error when the plan exceeds policy.

The separate GLM-5.2 streaming API guide shows how tool name and argument fragments arrive across SSE deltas. This page uses non-streaming responses so execution semantics do not get mixed with fragment assembly.

OpenAI-compatible controls were accepted but ignored

Section titled “OpenAI-compatible controls were accepted but ignored”

Five transport probes sent fields outside the current documented Z.ai surface. All returned HTTP 200: tool_choice: "required", an OpenAI-style forced-function object, parallel_tool_calls: false, strict: true, and the dotted function name inventory.lookup.

Acceptance alone was ambiguous, so three new prompts deliberately opposed the requested control:

Accepted request field Opposing prompt Observed response
tool_choice: "required" Do not use any tool stop, zero calls
forced lookup_inventory object Use lookup_order; never use inventory stop, zero calls
parallel_tool_calls: false Submit both order lookups now tool_calls, two calls

These are single semantic observations, not proof that a gateway always ignores the fields. They do show why a 200 response cannot certify portability. The strict: true request happened to return schema-conforming arguments, but one easy compliant result cannot distinguish enforcement from ordinary model compliance. The dotted name was echoed successfully once, yet it conflicts with the documented function-name pattern and should not be used in production.

The conservative request sends tool_choice: "auto" or omits it, uses documented names, validates arguments locally, and treats multiple calls as possible. If you need forced calls or strict conformance, add route-specific regression tests that create a real semantic conflict and fail closed when the behavior changes.

This standard-library pattern supports the documented shape, multiple calls and structured errors. It defaults to the metered API endpoint; set ZAI_ENDPOINT to the Coding Plan route only for a supported coding-tool workflow covered by that product’s terms.

import json
import os
import urllib.request
ENDPOINT = os.getenv(
"ZAI_ENDPOINT",
"https://api.z.ai/api/paas/v4/chat/completions",
)
API_KEY = os.environ["ZAI_API_KEY"]
TOOLS = [{
"type": "function",
"function": {
"name": "lookup_inventory",
"description": "Look up one exact SKU.",
"parameters": {
"type": "object",
"properties": {"sku": {"type": "string"}},
"required": ["sku"],
"additionalProperties": False,
},
},
}]
def chat(messages):
payload = {
"model": "glm-5.2",
"messages": messages,
"tools": TOOLS,
"tool_choice": "auto",
"thinking": {"type": "disabled"},
"stream": False,
"max_tokens": 800,
}
request = urllib.request.Request(
ENDPOINT,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response)["choices"][0]["message"]
def execute(name, raw_arguments):
if name != "lookup_inventory":
return {"ok": False, "error_code": "UNKNOWN_TOOL"}
try:
arguments = json.loads(raw_arguments)
except json.JSONDecodeError:
return {"ok": False, "error_code": "INVALID_JSON"}
if set(arguments) != {"sku"} or not isinstance(arguments["sku"], str):
return {"ok": False, "error_code": "INVALID_ARGUMENTS"}
# Replace this deterministic fixture only after adding authorization,
# timeout, audit, idempotency and side-effect controls.
return {"ok": True, "sku": arguments["sku"], "available": 7}
messages = [{"role": "user", "content": "Check SKU GLM52-DEMO-01."}]
for step in range(4):
assistant = chat(messages)
calls = assistant.get("tool_calls") or []
if not calls:
print(assistant.get("content", ""))
break
messages.append({
"role": "assistant",
"content": assistant.get("content"),
"tool_calls": calls,
})
for call in calls[:4]:
result = execute(
call["function"]["name"],
call["function"]["arguments"],
)
messages.append({
"role": "tool",
"tool_call_id": call["id"],
"name": call["function"]["name"],
"content": json.dumps(result, separators=(",", ":")),
})
else:
raise RuntimeError("tool loop exceeded four model steps")

The loop is intentionally small. A production implementation should use a real JSON Schema validator, typed tool registry, per-tool authorization and metrics. Never print the API key or store it in a repository; inject it from a secret manager or a protected local environment variable.

JSON parsing answers only “is this syntactically JSON?” It does not answer “does the object match the schema?”, “may this user perform the action?”, or “is the action safe now?”

Build the gate in this order:

  1. reject an unknown function before inspecting arguments;
  2. parse the string with a size limit;
  3. validate types, required fields, enums, patterns and extra properties;
  4. normalize identifiers without silently changing meaning;
  5. authorize the caller and resource;
  6. apply rate, time, cost and side-effect limits;
  7. request approval for consequential actions;
  8. execute with a timeout and idempotency key;
  9. redact sensitive input and output before logging;
  10. return a compact, structured result.

Schema descriptions are model guidance, not a security boundary. An allowlisted tool with overbroad permissions can be more dangerous than a malformed call, because the latter is easy to reject. Keep the runtime identity narrower than the human user’s identity.

The structured-output test covers a related boundary: a request field can be accepted while supplied JSON Schema constraints remain unenforced. Tool arguments deserve the same independent validation discipline.

Do not hide every tool failure behind an HTTP 500. A model can often recover when it receives a bounded object such as:

{
"ok": false,
"error_code": "RATE_LIMITED",
"retryable": true,
"retry_after_ms": 1200
}

Separate retryable transport failures from permanent business failures. Allow at most one or two retries inside the model loop, and let application policy—not model prose—decide whether a write may repeat. Include stable error codes; exclude stack traces, raw SQL, credentials and internal hostnames.

Our two synthetic SKU_NOT_FOUND cases ended with zero new calls and the exact fallback marker. That shows GLM-5.2 can consume a clean non-retryable error in this fixture. It does not prove reliable recovery from every exception, nor does it authorize the model to choose an unbounded retry strategy.

For observability, record the model step, tool name, validated argument digest, result status, duration, retry count and final task acceptance. The agent debugging guide shows how to keep tool traces useful without turning secrets into telemetry.

The endpoint is part of the product contract:

Access route Endpoint base Appropriate use
Coding Plan https://api.z.ai/api/coding/paas/v4 Supported interactive coding clients under the subscription’s tool and quota terms
Pay-as-you-go API https://api.z.ai/api/paas/v4 Metered application requests, with application-side budget and reliability controls

This run authenticated only the Coding Plan route. It does not claim that a separate pay-as-you-go credential produces identical latency or compatibility behavior. Likewise, the direct metered endpoint in the copyable code is documented, not live-authenticated by this specific test.

Review the Coding Plan versus API versus self-hosting guide before choosing credentials. If a client such as OpenCode is the actual goal, use the tested GLM-5.2 OpenCode configuration rather than embedding this low-level loop into its config.

Promote a route only after a versioned fixture passes:

  • exact tool selection for clear positive prompts;
  • no tool for clear negative prompts;
  • wrong and extra arguments rejected before execution;
  • multiple calls bounded and ordered safely;
  • assistant call IDs preserved through result return;
  • structured permanent and retryable errors handled;
  • forced-call or strict behavior tested only if the route documents or requires it;
  • loop, token, time, concurrency and spend ceilings enforced;
  • state-changing calls protected by approval and idempotency;
  • secrets absent from prompts, logs, screenshots and public evidence;
  • final task acceptance measured separately from HTTP success.

Run the suite against the same endpoint, model alias, SDK version and gateway used in production. A proxy update can change validation even when model: "glm-5.2" stays constant. Keep a canary and stop deployment if a control becomes accepted-but-ignored.

Yes. Z.ai documents it, and the tested Coding Plan route returned valid tool_calls, accepted tool results, and produced final answers in the bounded fixtures.

No. The model requests a named function. Your application validates and executes it, then sends the result back. A client without that loop will appear to “stop” at the call.

It did in 4/4 two-order fixtures. Treat that as route-specific evidence that multiple calls are possible, not proof they execute concurrently.

The tested endpoint accepted the field, but an opposing prompt returned zero calls. The current Z.ai reference documents only auto, so do not depend on required without a route-specific regression and a supported contract.

Does strict: true guarantee valid arguments?

Section titled “Does strict: true guarantee valid arguments?”

This test cannot support that claim. The field was accepted once and the easy arguments happened to conform. Validate locally regardless.

No. One request accepted and echoed inventory.lookup, but the current documented pattern excludes a dot. Follow the stable documented contract.

The evidence was captured July 28, 2026. Provider documentation, validation, model routing and product terms can change. Re-run the probe on the exact route before relying on an optional control.