zooqueen 402d03fd97 one credential, one subject: the scope rides the key, not the argument
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.
2026-08-21 07:27:15 -07:00
2025-03-15 03:31:14 +00:00
2025-03-15 03:31:14 +00:00
2026-06-26 11:57:39 -07:00
2025-03-15 03:56:11 +00:00

Hanzo Python SDK

hanzoai is the Python client for the Hanzo API, generated from the API's own OpenAPI document — 1814 paths, 2479 operations, 192 API classes over 2460 models. Every /v1 route is in it, and the names it exposes are the document's operation ids rather than a hand-picked subset. .spec-lock names the commit and sha256 of the document this tree was cut from.

PyPI Python

Install

pip install hanzoai==3.2.13

3.2.1 and earlier predate the rename that dropped the default version from the document's operation ids — their methods are get_v1_keys and get_v1_tools where the current ones are get_keys and get_tools. Pin 3.2.13 or later and the names on this page are the names you get.

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. The document declares one security scheme — bearer, HTTP bearer — and applies it to every operation but four, so the generated Configuration.auth_settings() produces Authorization: Bearer <token> and 2498 call sites ask for it:

auth['bearer'] = {'type': 'bearer', 'in': 'header',
                  'key': 'Authorization', 'value': 'Bearer ' + self.access_token}

The four exceptions are the operations the document marks security: [] — GET /v1/models, GET /v1/models/providers, GET /v1/commands, GET /v1/openapi.json. Those carry an empty auth_settings and 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 — it is safe in a browser bundle precisely because it names an org and authenticates nobody, so cloud refuses it at the identity boundary and it reads nothing.

No generated code reads the environment — not one os.environ in all of hanzoai.cloud, 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 — one place, 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. On every push CI imports all six and resolves every method name they call against the client, which is what keeps them from rotting into pseudocode.

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"}. Two different answers to the same code is what proves the credential is on the wire.

There is no chat flow because there is nothing to generate one from: POST /v1/chat/completions is declared with no requestBody and no responses, so the method takes no arguments and returns None. Hand-rolling the request inside a generated client is the drift these SDKs exist to prevent. It comes back the day the document describes the body.

The same gap shows up in money, which reads its two payloads through the generated *_without_preload_content variant: of 2479 operations, 716 declare no responses and another 118 declare no response content, so 834 of them model no body to deserialize. Those become ordinary typed calls when the schemas land, and nothing else about them changes.

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 64 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 37 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 found in generated code is fixed in the document, where every other language gets the fix too. See LLM.md for how the lane works.

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

S
Description
No description provided
Readme Apache-2.0
76 MiB
Languages
Python 99.9%