GLM-5.2 with PydanticAI: Typed Agents, Tools and Reasoning
Independent research — not an official Z.ai publication.Identity and provider disclosure
Original editorial illustration of the tested agent path. It contains no Pydantic, Z.ai or OpenRouter logo and is not a product interface. The later terminal-style image is generated from the sanitized live result JSON.
PydanticAI has a native Z.ai provider, so GLM-5.2 does not need to masquerade
as an OpenAI model. The compact form is Agent("zai:glm-5.2"); the provider
reads ZAI_API_KEY, builds the first-party client and exposes Z.ai-specific
thinking settings.
That successful import is only the first gate. An agent can still return the wrong type, call a tool twice, lose reasoning state between turns, hide several upstream requests behind one method call, or point a valid key at the wrong commercial route. We therefore tested six observable boundaries in a clean, digest-pinned container:
- an exact direct response;
- a validated
RoutingReceiptPydantic model; - one tool call followed by a final answer;
- one reasoning turn and one history continuation;
- one OpenRouter fallback response; and
- an intentionally invalid model name that had to fail with HTTP 400.
All six checks behaved as expected with pydantic-ai-slim==2.20.0. This guide
turns those receipts into copyable profiles, not a claim that every future
PydanticAI release or provider route will behave identically.
Follow the PydanticAI acceptance path
Section titled “Follow the PydanticAI acceptance path”- Choose a supported route
- Install the pinned extras
- Configure native Z.ai
- Prove a live response
- Validate typed output
- Bound a tool loop
- Preserve reasoning history
- Add OpenRouter
- Read the receipts
- Reconstruct direct cost
- Repair failures
- Promote safely
- Audit the method
Separate native Z.ai, OpenRouter and Coding Plan
Section titled “Separate native Z.ai, OpenRouter and Coding Plan”These routes answer different operational questions:
| PydanticAI profile | Credential | Commercial path | July 29 result |
|---|---|---|---|
zai:glm-5.2 |
ZAI_API_KEY |
Z.ai general metered API | Five direct behaviors passed |
openrouter:z-ai/glm-5.2 |
OPENROUTER_API_KEY |
OpenRouter gateway | One exact marker passed |
| Z.ai Coding Plan endpoint | Coding Plan key | Supported coding tools only | Not called; PydanticAI was not listed |
Pydantic’s Z.AI provider documentation publishes the native model form and environment variable. Its OpenRouter documentation publishes the gateway form.
The distinction is not merely syntactic. The native provider selects the
documented Z.ai API path internally. In the tested version, inspection of
ZaiProvider produced this constructor:
ZaiProvider( *, api_key: str | None = None, openai_client: AsyncOpenAI | None = None, http_client: httpx.AsyncClient | None = None,)There is no base_url parameter to swap casually. Z.ai’s current
subscription terms
and supported-tool list restrict
Coding Plan benefits to approved tools. PydanticAI was absent on the test
date. The Coding Plan endpoint was not called. Use the general API for this
SDK unless Z.ai adds explicit support or gives you a written exception.
If your main decision is between subscription, metered API and self-hosting, use the separate Coding Plan versus API guide. This page stays focused on one Python agent framework.
Install only the two tested provider extras
Section titled “Install only the two tested provider extras”The successful container used Python 3.13.13 and:
pydantic-ai-slim 2.20.0pydantic 2.13.4openai 2.50.0httpx 0.28.1Install the slim package with the two provider extras used in this guide:
python -m venv .venv. .venv/bin/activatepython -m pip install \ "pydantic-ai-slim[zai,openrouter]==2.20.0"python -m pip checkPyPI lists
pydantic-ai-slim 2.20.0
for Python 3.10 and newer. Pinning the exact framework version matters because
provider namespaces, result properties and model settings can change.
Our test did not install these packages on the host. It used a digest-pinned
ghcr.io/astral-sh/uv container, mounted the repository read-only and
published no port. The default Docker bridge reproduced this host’s known
bounded DNS failure, so the trusted outbound-only image used the documented
host-network exception. That network accommodation is part of the evidence,
not general permission for untrusted images.
Copy the native Z.ai agent profile
Section titled “Copy the native Z.ai agent profile”Keep the key outside source code:
export ZAI_API_KEY="your-general-api-key"test -n "$ZAI_API_KEY" && echo "ZAI_API_KEY is set"Create the smallest explicit agent:
from pydantic_ai import Agentfrom pydantic_ai.models.zai import ZaiModelSettings
agent = Agent( "zai:glm-5.2", instructions="Follow the requested output format exactly.", model_settings=ZaiModelSettings( thinking=False, timeout=120, ),)The model string contains two identifiers:
zaiselects PydanticAI’s provider namespace;glm-5.2is Z.ai’s documented model ID.
The GLM-5.2 model guide
publishes text input/output, tools, structured output, a one-million-token
context and 128K maximum output. Those model maxima are not sensible
application defaults. The example disables thinking for a deterministic
smoke test and applies a bounded client timeout.
Assert one direct application-visible marker
Section titled “Assert one direct application-visible marker”Run a real request and inspect what the application receives:
import asynciofrom pydantic_ai.usage import UsageLimitsfrom glm_agent import agent
async def main() -> None: result = await agent.run( "Return exactly PYDANTICAI_DIRECT_OK and nothing else.", usage_limits=UsageLimits(request_limit=1), ) assert result.output.strip() == "PYDANTICAI_DIRECT_OK" print(result.output) print(result.usage)
asyncio.run(main())The July 29 call returned PYDANTICAI_DIRECT_OK in 2,893 ms. The result’s
usage property reported 27 input tokens, 8 output tokens and one request.
The property detail is version-sensitive: in PydanticAI 2.20.0,
result.usage is a value, not a method. Calling result.usage() produced a
local TypeError in an early probe revision. That was our harness error, not
a provider failure.
Return a real Pydantic model, not JSON-shaped text
Section titled “Return a real Pydantic model, not JSON-shaped text”A JSON-looking string is not a typed result. Define the accepted values and let PydanticAI validate before returning:
import asynciofrom typing import Literal
from pydantic import BaseModel, Fieldfrom pydantic_ai import Agentfrom pydantic_ai.models.zai import ZaiModelSettingsfrom pydantic_ai.usage import UsageLimits
class RoutingReceipt(BaseModel): marker: Literal["PYDANTICAI_TYPED_OK"] queue: Literal["orchid"] retry_budget: int = Field(ge=2, le=2)
typed_agent = Agent( "zai:glm-5.2", output_type=RoutingReceipt, instructions="Return only data that validates against the requested schema.", model_settings=ZaiModelSettings(thinking=False, timeout=120), retries=1,)
async def main() -> None: result = await typed_agent.run( ( "Create the routing receipt with marker PYDANTICAI_TYPED_OK, " "queue orchid, and retry_budget 2." ), usage_limits=UsageLimits(request_limit=2), ) assert result.output == RoutingReceipt( marker="PYDANTICAI_TYPED_OK", queue="orchid", retry_budget=2, ) print(result.output.model_dump())
asyncio.run(main())The live result was a RoutingReceipt, not str or dict, with:
{ "marker": "PYDANTICAI_TYPED_OK", "queue": "orchid", "retry_budget": 2}It completed in 9,006 ms with 247 input and 31 output tokens. Pydantic’s output documentation explains that a Pydantic output model is represented as a tool schema by default. That is why the archived message summary contains a tool-call and tool-return pair even though this is “output” rather than a business tool.
This receipt proves one small schema. For raw JSON mode, schema enforcement boundaries and adversarial fields, see the dedicated GLM-5.2 structured-output test.
Execute one tool under two independent limits
Section titled “Execute one tool under two independent limits”A safe tool test must verify the arguments, local execution count and final answer:
import asynciofrom typing import Any
from pydantic_ai import Agentfrom pydantic_ai.models.zai import ZaiModelSettingsfrom pydantic_ai.usage import UsageLimits
calls: list[dict[str, str]] = []tool_agent = Agent( "zai:glm-5.2", instructions=( "Use lookup_ticket for ticket questions. After the tool result, " "follow the requested final format exactly." ), model_settings=ZaiModelSettings(thinking=False, timeout=120),)
@tool_agent.tool_plaindef lookup_ticket(ticket_id: str) -> dict[str, Any]: """Look up one synthetic support ticket by its exact identifier.""" calls.append({"ticket_id": ticket_id}) return {"owner": "delta", "severity": 2}
async def main() -> None: result = await tool_agent.run( ( "Use lookup_ticket exactly once for ORCHID-7442. Then return " "exactly PYDANTICAI_TOOL_OK delta 2." ), usage_limits=UsageLimits( request_limit=3, tool_calls_limit=1, ), ) assert calls == [{"ticket_id": "ORCHID-7442"}] assert result.output.strip() == "PYDANTICAI_TOOL_OK delta 2" print(result.output)
asyncio.run(main())The tool ran exactly once with ORCHID-7442. PydanticAI then sent the tool
result back to GLM-5.2 and returned PYDANTICAI_TOOL_OK delta 2. The entire
loop took 12,229 ms, used two provider requests, one tool call, 448 input
tokens and 29 output tokens.
request_limit and tool_calls_limit protect different resources. The first
bounds model traffic; the second bounds local tool execution. Neither makes a
side-effecting function safe. Validate authorization and arguments inside the
tool, use idempotency keys for writes, and require human confirmation where a
mistake can send money, delete data or contact another person.
For the underlying wire-level cycle and forced/parallel controls, use the GLM-5.2 tool-calling acceptance guide.
Continue a turn without logging hidden reasoning
Section titled “Continue a turn without logging hidden reasoning”Z.ai exposes provider-specific thinking settings through
ZaiModelSettings. The final probe used a prompt large enough to observe one
thinking part, preserved it in message history, and continued the
conversation:
import asyncio
from pydantic_ai import Agentfrom pydantic_ai.models.zai import ZaiModelSettingsfrom pydantic_ai.usage import UsageLimits
reasoning_agent = Agent( "zai:glm-5.2", instructions="Follow exact-output requests.", model_settings=ZaiModelSettings( thinking="high", zai_clear_thinking=False, max_tokens=512, timeout=120, ),)
async def main() -> None: first = await reasoning_agent.run( ( "Find the smallest positive integer divisible by every integer " "from 1 through 12, then return exactly REASONING_BASE=27720." ), usage_limits=UsageLimits(request_limit=1), ) second = await reasoning_agent.run( "Return exactly PYDANTICAI_HISTORY_OK.", message_history=first.all_messages(), usage_limits=UsageLimits(request_limit=1), ) assert first.output.strip() == "REASONING_BASE=27720" assert second.output.strip() == "PYDANTICAI_HISTORY_OK" print(first.output, second.output)
asyncio.run(main())The first turn took 24,216 ms, used 46 input and 297 output tokens, and
contained one ThinkingPart. The continuation took 3,765 ms, used 355
input and 104 output tokens, and returned the exact history marker.
We archived only the presence and 579-character length of the thinking part,
not its content. That is an intentional data-minimization choice. If your
application persists all_messages(), treat the history as potentially
sensitive model and user data. Encrypt it, apply retention limits and do not
ship it indiscriminately to logs or analytics.
A shorter minimal-thinking prompt in an exploratory check did not expose a
ThinkingPart. Absence on one easy prompt is not proof that the setting is
broken. First select a documented effort, provide enough output budget and
use a task that actually warrants reasoning. The
reasoning-effort guide covers the broader
quality, latency and cost decision.
Keep OpenRouter as an explicit alternative profile
Section titled “Keep OpenRouter as an explicit alternative profile”OpenRouter is a separate provider choice, not an automatic retry hidden inside the native agent. Load its own credential:
export OPENROUTER_API_KEY="your-openrouter-api-key"test -n "$OPENROUTER_API_KEY" && echo "OPENROUTER_API_KEY is set"Then use the documented provider namespace and model slug:
import asyncio
from pydantic_ai import Agentfrom pydantic_ai.models.openrouter import OpenRouterModelSettingsfrom pydantic_ai.usage import UsageLimits
router_agent = Agent( "openrouter:z-ai/glm-5.2", instructions="Follow the requested output format exactly.", model_settings=OpenRouterModelSettings( openrouter_reasoning={ "effort": "minimal", "exclude": True, }, openrouter_usage={"include": True}, timeout=120, ),)
async def main() -> None: result = await router_agent.run( "Return exactly PYDANTICAI_OPENROUTER_OK and nothing else.", usage_limits=UsageLimits(request_limit=1), ) assert result.output.strip() == "PYDANTICAI_OPENROUTER_OK" print(result.output)
asyncio.run(main())The live gateway call returned PYDANTICAI_OPENROUTER_OK in 8,443 ms and
reported 35 input plus 165 output tokens. That is one compatibility
observation. We did not pin the downstream OpenRouter provider, so it is not
a direct-versus-router latency comparison and it is excluded from the direct
Z.ai cost reconstruction.
Use an explicit profile per route so logs, budgets and incident response can distinguish them. If provider location, quantization, parameter support or availability is contractual, constrain the OpenRouter route and re-run the same acceptance test. The GLM-5.2 LangChain guide shows why gateway serialization and downstream capabilities should not be assumed.
Inspect the six sanitized July 29 receipts
Section titled “Inspect the six sanitized July 29 receipts”
Actual test capture generated from the sanitized result JSON. It contains no API key, Authorization header, cookie, response ID, provider error message or hidden reasoning. It is not a Z.ai, OpenRouter or Pydantic product screen.
| Acceptance check | Observed time | Application result | Evidence boundary |
|---|---|---|---|
| Native text | 2.893 s | PYDANTICAI_DIRECT_OK |
One direct request |
| Typed output | 9.006 s | Valid RoutingReceipt |
One small Pydantic model |
| Tool loop | 12.229 s | One call; delta 2 |
One local function; two requests |
| Reasoning + history | 24.216 s + 3.765 s | One omitted thinking part; continued marker | Two turns; reasoning text not stored |
| OpenRouter | 8.443 s | PYDANTICAI_OPENROUTER_OK |
Downstream provider not pinned |
| Invalid model | bounded | HTTP 400 ModelHTTPError |
Deliberate negative control |
These are single observations from one account, region and timestamp. They are not p50/p95 measurements and should not be used to rank provider speed. Their value is behavioral: five different successful paths produced the expected application object, and the bad model name failed instead of being silently remapped.
The complete sanitized public record is available as GLM-5.2 PydanticAI test data. The reproducible probe, pinned image digest and cleanup receipt are archived with this article.
Price the direct run from provider token usage
Section titled “Price the direct run from provider token usage”Z.ai’s pricing page listed GLM-5.2 at $1.40 per million fresh input tokens, $0.26 per million cached input tokens and $4.40 per million output tokens on July 29.
The five successful direct cases made six requests and reported:
fresh input = 27 + 247 + 448 + 46 + 355 = 1,123 tokensoutput = 8 + 31 + 29 + 297 + 104 = 469 tokens
estimated list cost = 1,123 × $1.40 / 1,000,000 + 469 × $4.40 / 1,000,000 = $0.0036358Rounded for planning, the direct matrix cost about $0.003636 at those list rates. This is arithmetic, not an invoice. It assumes fresh input, excludes the OpenRouter call, and does not account for account discounts, cache eligibility, rounding, failed-call policy or future rate changes.
The reasoning turn used most of the output tokens. That is why visible answer
length is a poor budget signal. Read result.usage, record request count and
set task-specific output limits. For larger scenarios, the
GLM-5.2 API cost calculator makes the token
assumptions explicit.
Diagnose eight PydanticAI and GLM-5.2 failures
Section titled “Diagnose eight PydanticAI and GLM-5.2 failures”1. Import fails for the provider namespace
Section titled “1. Import fails for the provider namespace”Symptom: importing pydantic_ai.models.zai or .openrouter raises an
import error.
Fix: install the matching slim extras and confirm the pinned version:
python -m pip install \ "pydantic-ai-slim[zai,openrouter]==2.20.0"python -m pip show pydantic-ai-slimpython -m pip checkDo not add the full package and every provider dependency merely to repair one missing extra.
2. Authentication returns 401 or 403
Section titled “2. Authentication returns 401 or 403”Symptom: a real request fails before any typed result or tool call.
Fix: keep each key with its provider namespace. ZAI_API_KEY belongs to
zai:glm-5.2; OPENROUTER_API_KEY belongs to
openrouter:z-ai/glm-5.2. Check variable presence without printing its value,
then verify account balance, region and key permissions.
Do not paste secrets into the model string, source file, exception report, Docker argument list or screenshot.
3. A model typo returns HTTP 400
Section titled “3. A model typo returns HTTP 400”Symptom: PydanticAI raises ModelHTTPError with status 400.
Fix: use zai:glm-5.2 or openrouter:z-ai/glm-5.2 exactly. Our deliberate
zai:glm-5.2-intentional-invalid control returned HTTP 400. Preserve the
status and safe error class in logs, but redact provider messages if they may
contain account or request details.
4. Coding Plan appears protocol-compatible but remains unsupported
Section titled “4. Coding Plan appears protocol-compatible but remains unsupported”Symptom: a developer tries to force
https://api.z.ai/api/coding/paas/v4 into a generic SDK because it resembles
the metered endpoint.
Fix: stop and check the current supported-tool list. PydanticAI was not
listed on July 29, and the tested native provider exposed no base_url
parameter. Use the general API unless Z.ai explicitly authorizes this
framework. Do not treat a successful HTTP response as proof of subscription
eligibility.
5. Typed output exhausts retries
Section titled “5. Typed output exhausts retries”Symptom: the model returns values outside the Pydantic constraints and the agent raises after validation retries.
Fix: simplify the schema, make literal choices explicit in the prompt, set
a bounded retries value and keep request_limit high enough to include the
initial request plus allowed repair attempts. Validate semantic business
rules again after model-level validation.
Never set unlimited retries to hide a brittle schema; each repair can add latency and billable traffic.
6. A tool loops or executes more than expected
Section titled “6. A tool loops or executes more than expected”Symptom: one user action triggers repeated model calls or local side effects.
Fix: set both request_limit and tool_calls_limit, assert the observed
arguments, and make writes idempotent. The tested path needed two requests for
one tool call. A request_limit=1 would have blocked the final model turn,
while a generous request limit alone would not cap tool executions.
7. Thinking is enabled but no ThinkingPart appears
Section titled “7. Thinking is enabled but no ThinkingPart appears”Symptom: an easy prompt returns a correct answer without an observable thinking part.
Fix: check the documented effort value, output budget and actual task
difficulty before blaming the provider. Our minimal exploratory prompt did
not expose a thinking part; a bounded thinking="high" task did. Do not log
hidden reasoning just to prove the feature. Count sanitized part types and
verify the application-visible answer instead.
8. A large prompt hits a context or output boundary
Section titled “8. A large prompt hits a context or output boundary”Symptom: the provider rejects the request, truncates output or spends far more than expected.
Fix: distinguish the model’s published one-million-token context from your application’s tested ceiling. Budget system instructions, message history, tool schemas, retrieved documents, reasoning and final output together. Add a preflight token limit and a summarization or retrieval policy well below the provider maximum.
If a router sits in front, its selected downstream path may have a different effective limit. Re-run the exact profile after provider or model-version changes.
Turn the probe into a deployment gate
Section titled “Turn the probe into a deployment gate”A production gate should assert behavior rather than configuration text:
- pin
pydantic-ai-slimand provider extras; - select one named provider profile;
- send an exact text marker;
- validate one representative Pydantic output;
- execute one side-effect-free tool fixture;
- enforce request and tool-call limits;
- record sanitized request and token counts;
- run one negative control without leaking the provider message; and
- fail deployment if any expected object or bound changes.
Keep the native and router checks separate. A fallback that silently changes provider, region, price or retention terms is a policy decision, not merely resilience. Alert on the route chosen for each request.
The full probe used for this page is intentionally bounded and archived. It prints one sanitized JSON document and omits credential values, response IDs, Authorization headers, provider error strings and reasoning content.
Audit sources, versions and evidence boundaries
Section titled “Audit sources, versions and evidence boundaries”Sources were checked on 2026-07-29:
- Pydantic Z.AI provider
- Pydantic OpenRouter provider
- Pydantic output types
- Pydantic function tools
- PydanticAI Slim on PyPI
- Z.ai quick start
- GLM-5.2 model guide
- Z.ai thinking mode
- Z.ai pricing
- Z.ai Coding Plan terms
- Z.ai supported tools
- OpenRouter GLM-5.2 listing
The probe proves this version set, two credentials, one timestamp and six bounded behaviors. It does not prove every Pydantic schema, parallel tool execution, MCP, production side effects, Coding Plan eligibility, million- token stability, a latency service level, a fixed OpenRouter downstream provider or future compatibility.
