An operator holds one key and serves N customers. Until now every call it made on a customer's behalf was indistinguishable from every other, and nothing in the SDK could tell them apart. `Client.as_(subject)` binds a client to one of them. IAM mints a short-lived token for that subject — POST hanzo.id/v1/iam/tokens/issue?id=…, on IAM's own host, the subject in the query, the grant read off the key — and the scoped client sends it on every call, holds it until it nears expiry, and re-mints once on a 401. No operation takes a user id, so there is none to pass wrongly. The other half is the answer a policy holds for a human. It arrives as 202, and 202 is a 2xx: a caller who only checks for a raise reads a queued call as one that completed and returned nothing, because no operation declares a schema for it. `Client` raises `Held` instead, carrying the approval that gates it, and `result()` hands the same outcome back as a value whose two arms share no member — an `Approval` has no `value`, a `Done` has no `id`. Discrimination is on the body, not the status: the dozen long-running operations whose 202 means "accepted" carry their own schema and pass straight through. `Client` is the generated `ApiClient` plus those two behaviours, so all 196 generated API classes take it unchanged and there is one client, not two. 25 tests against a stub with `RESTClientObject`'s signature — the only thing the mint and the client ever call — pin the mint URL and query, the camelCase response, the cache and its skew, the re-mint on 401, and that a hold raises. (cherry picked from commit cda66ab6a5e9f51d50e161bbb3dfe96648688fa2)
8.2 KiB
Hanzo Python SDK
hanzoai is the Python client for the Hanzo API, generated from the API's own
OpenAPI document. Every /v1 route is in it, and the names it exposes are the
document's operation ids. .spec-lock names the commit and sha256
of the document this tree was cut from.
Install
pip install hanzoai
Check the install without a key — GET /v1/models is public:
python -m examples.models
https://api.hanzo.ai serves 112 models, no credential required
all-mini-lm-l6-v2 · do-ai · $0.02/Mtok in
anthropic-claude-opus-5 · do-ai · $1/Mtok in
…
Quickstart
import os
from hanzoai import Client
from hanzoai.cloud import Configuration, KeysApi
client = Client(Configuration(
host="https://api.hanzo.ai",
access_token=os.environ["HANZO_API_KEY"],
))
with client as api:
for key in KeysApi(api).get_keys().keys or []:
print(key.prefix, key.type, key.created_at)
Every route follows that shape: one *Api class per tag, one method per
operation, typed models in and out. Client is the generated ApiClient plus
the two behaviours below — scoping and the held result — so it goes anywhere an
ApiClient goes.
Auth
access_token is the whole configuration — it becomes
Authorization: Bearer <token> on every operation that asks for a credential.
Four operations do not: GET /v1/models, GET /v1/models/providers,
GET /v1/commands, GET /v1/openapi.json. They send no credential, which is why
examples/models runs before you have a key.
Keys come from cloud.hanzo.ai or hanzo login in two
shapes, and only one of them works here. Use an sk-: it carries a principal,
which every credentialled call needs. A pk- is publishable — safe in a browser
bundle because it names an org and authenticates nobody — so cloud refuses it at
the identity boundary.
No generated code reads the environment, so no variable you export reaches a
request on its own. (The hand-written hanzoai.zap and hanzoai.config read
HANZO_ZAP_ENDPOINT and HANZO_CONFIG_HOME; neither is a credential.)
examples/client.py is where HANZO_API_KEY and
HANZO_BASE_URL get resolved, for all six flows.
Tenants
If you are building on top of Hanzo, you hold one key and your customers hold
none. as_ binds a client to one of them:
from hanzoai import Client
from hanzoai.cloud import Configuration, MemoryApi
hanzo = Client(Configuration(access_token=os.environ["HANZO_API_KEY"]))
memory = MemoryApi(hanzo.as_("user_42")) # a subject id, or your own externalId
IAM mints a short-lived token bound to that subject — POST https://hanzo.id/v1/iam/tokens/issue?id=user_42, on IAM's own host, reading the
grant off your key — and the scoped client sends it on every call, keeps it
until it nears expiry, and re-mints once on a 401. No operation takes a user id,
so there is none to pass wrongly and none to forget. Client(..., issuer=...)
points the mint at a private estate.
The spelling is as_ rather than as only because as is a keyword; the
trailing underscore is what PEP 8 prescribes, and it keeps the platform's one
word for this from growing a synonym in Python.
Held calls
A call your policy holds for a human decision answers 202 with the approval —
{"status": "held", "id", "clause", "reason"}, the same shape
GET /v1/approvals/{id} returns. A 202 is a 2xx, so a client that only checks
for a raise reads a queued call as a completed one. This one raises:
from hanzoai import Held, is_held, result, unwrap
try:
fact = memory.post_memory_remember(body)
except Held as held:
print(held.approval.id, held.approval.clause)
Or take the outcome as a value, when handling the hold is the normal path:
r = result(memory.post_memory_remember, body)
if is_held(r):
print(r.id, r.clause, r.reason) # queued — nothing ran
else:
fact = unwrap(r) # raises Held rather than return a hold
Approval has no value and Done has no id, so there is no member you can
read off the wrong one. The dozen long-running operations whose 202 means
"accepted, working on it" carry their own schema and pass straight through — the
body is the discriminator, not the status code.
Examples
examples/ carries one directory per flow. Each is a whole path through one part
of the API. CI imports all six and resolves every method name they call against
the client.
| flow | what it does | routes | key |
|---|---|---|---|
models |
the model catalog | GET /v1/models |
none |
hello |
prove the key works | GET /v1/keys |
sk- |
money |
balance + usage | GET /v1/billing/balance, GET /v1/billing/usage |
sk- |
store |
KV round-trip | POST /v1/kv, GET/DELETE /v1/kv/{name} |
sk- |
agent |
create, run, read the runs | POST /v1/agents, POST /v1/agents/{ref}/run, GET /v1/agents/{ref}/runs |
sk- |
tools |
the tool catalog | GET /v1/tools |
sk- |
One command each, from the repo root:
python -m examples.models # no credential
export HANZO_API_KEY=sk-...
python -m examples.hello
A real key prints your keys; a bogus one prints
HTTP 403: {"code":"forbidden","error":"sign in to manage API keys"}.
There is no chat flow: POST /v1/chat/completions is declared with no
requestBody and no responses, so the method takes no arguments and returns
None. It comes back the day the document describes the body.
money reads its two payloads through the generated
*_without_preload_content variant, for the same reason — an operation that
declares no responses, or a 2xx carrying no content, models no body to
deserialize.
Reference for the routes themselves: api.hanzo.ai/docs, served from the same document — api.hanzo.ai/v1/openapi.json.
The rest of the repo
This is a uv workspace. pkg/hanzoai is the client above; the other packages
are hand-written, ship separately, and mostly carry their own README:
| Package | Install | Purpose |
|---|---|---|
pkg/hanzoai |
hanzoai |
the client above |
pkg/hanzo-mcp |
hanzo-mcp |
Model Context Protocol server |
pkg/hanzo-agent |
hanzo-agent |
agent framework (import path agents) |
pkg/hanzo-agents |
hanzo-agents |
agent networks and swarms |
pkg/hanzo-memory |
hanzo-memory |
persistent memory + RAG over SQLite |
pkg/hanzo-network |
hanzo-network |
distributed compute nodes |
pkg/hanzo-tools-* |
one each | single-concern tool packages, each registering a TOOLS list under the hanzo.tools entry point, which is how hanzo-mcp finds them |
The hanzo command is a native binary, not a Python package:
curl -fsSL https://hanzo.sh | sh. pip install hanzo ships the older Python CLI
under the name hanzo-py, so the two never fight over one name on a PATH.
Development
git clone https://github.com/hanzoai/python-sdk && cd python-sdk
uv sync --all-packages
uv run pytest tests/ -v
pkg/hanzoai/cloud/ is generated and is never edited by hand — a regeneration
does rmtree then copytree, so an edit there is gone on the next run. It comes
from hanzoai/openapi:
cd ../openapi && uv run --with pyyaml python3 generate.py python \
--repo ../python-sdk --spec ../cloud/openapi.yaml
A defect in generated code is fixed in the document.
License
Apache 2.0 — see LICENSE. Report vulnerabilities to [email protected] (SECURITY.md).
Hanzo — the Open AI Cloud
hanzo.ai · docs.hanzo.ai · same client in other languages: TypeScript · Go · Java · Kotlin · umbrella