mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
One call answers a question or a failing test's output with the spans of the tree most likely to hold the cause, as path:start-end and their lines, so an agent stops chaining grep and read. Python is chunked by ast, other text in overlapping 40-line windows. BM25 over code tokens is fused by reciprocal rank with the spans a traceback's path:line frames name; when the query names a test file the index holds, the code under test ranks first. Every search stats the tree and re-chunks only what moved. A dense stage (Qwen3-Embedding form, vectors cached by text hash) is on only when HANZO_SEARCH_MODEL names a model: on the 42 dev tasks it lifts span MRR .385 to .508, but a CPU query embedding takes seconds. `python -m hanzo_search.server --root DIR` serves the tool alone over stdio with the standard library, outside hanzo_mcp, whose import is seconds long. The `hanzo` mode lists it, so the default surface carries it; another mode, `tool disable search` or HANZO_MCP_ENABLED_TOOLS decide it as they decide every other tool.
495 lines
31 KiB
Markdown
495 lines
31 KiB
Markdown
# LLM.md — hanzoai/python-sdk
|
||
|
||
**What this is:** the flagship, most-complete Hanzo SDK — a `uv` workspace of 65
|
||
packages: the typed cloud client (`hanzoai`), agents, MCP server + tools, memory,
|
||
distributed compute, and the `hanzo` CLI. `pip install hanzo`.
|
||
|
||
**Canonical role (one-way SDK model):** Hanzo ships two SDK lines per language —
|
||
(1) the full cloud SDK generated from OpenAPI, (2) the AI/agents library. This repo
|
||
is the Python flagship of line 2, the reference for every other language.
|
||
Completeness: Python → Rust → C++ → Go. One impl, one place; discovery repos link
|
||
OUT, never duplicate. Full spec: `~/work/hanzo/SDK-ARCHITECTURE.md`.
|
||
|
||
## Install / run
|
||
```bash
|
||
pip install hanzoai==3.2.13 # the current client
|
||
pip install hanzo # agents + MCP + orchestration helpers
|
||
uv sync --all-packages # dev: whole workspace
|
||
uv run pytest tests/ -v # tests
|
||
```
|
||
|
||
**PyPI serves this tree again.** `hanzoai` had sat at **3.2.1** while v3.2.3 …
|
||
v3.2.12 were tagged with nothing published, and the gap was not cosmetic: 3.2.1
|
||
predates the default version leaving the operation ids, so it carries
|
||
`get_v1_keys`/`get_v1_tools` (182 api modules, 2172 models) plus the retired flat
|
||
`hanzoai/api` tree. **3.2.13** carries `get_keys`/`get_tools` and is what the
|
||
README's quickstart runs on. The same tag published 13 sibling packages that had
|
||
also drifted behind — `hanzo-mcp` 0.15.14, `hanzo-tools` 0.3.5, `hanzo-kms`
|
||
1.1.1, `hanzo-network` 0.1.4, seven `hanzo-tools-*`, and first releases of
|
||
`hanzo-flags` and `hanzo-research`.
|
||
|
||
**Two faults stopped them.** Six of the eight tags never started the lane at all;
|
||
the two that did, failed.
|
||
|
||
`hanzo.yml` declares `client.version:`, so the release is cut by the pipeline —
|
||
regenerate, bump, tag, then push `HEAD:main` and `refs/tags/v<next>` in one push.
|
||
`actions/checkout` leaves an `http.<host>.extraheader` behind, that config is
|
||
multi-valued, and the server honours the first `Authorization` it reads, so the
|
||
push travelled as the forge's own Actions user (uid **-2**) rather than as
|
||
`FORGE_TOKEN`. The forge starts no run for a ref that user pushed — loop
|
||
prevention, working as designed. So the tag was on the remote, `publish-pypi.yml`
|
||
was in the tree at that commit, its `push: tags:` matched it, and there was no
|
||
event to match: v3.2.3, v3.2.4, v3.2.6, v3.2.7, v3.2.8 and v3.2.10 have zero runs
|
||
between them. `js-sdk` v2.2.6 carries the same signature, while the tags on either
|
||
side of it have two runs each. `hanzoai/ci` now clears every
|
||
`http.*.extraheader` before setting its own, so one credential goes on the wire;
|
||
this repo pins `[email protected]`, which carries that.
|
||
|
||
**A tag pushed by automation is not the same event as a tag pushed by a person.**
|
||
A green pipeline that ends "cut v<next>" says nothing about whether the publish
|
||
ran. Read `trigger_user_id` on the run, or `act_user_id` in the repo's activity
|
||
feed — uid `-2` means the push started nothing — and push a hand-cut tag with a
|
||
real account.
|
||
|
||
The two that did start, v3.2.5 and v3.2.9, then failed on a route, which reads
|
||
like a missing secret and is not one. The job asked KMS for
|
||
`/v1/kms/orgs/hanzo/secrets/<path>/<key>` and parsed `.secret.value`. KMS serves
|
||
`GET /v1/kms/secrets/<path>/<key>?env=<env>` and answers `{"env","name","value"}`,
|
||
taking the org from the caller's own token claim. A wrong route 404s exactly like
|
||
an unseeded path, so the log said "KMS holds no PyPI token" about a token that was
|
||
sitting at `hanzo`/`prod`/`python-sdk-publish`/`PYPI_TOKEN` the whole time. The
|
||
route the kms-operator uses is the one that answers; check a read against a secret
|
||
known to exist before believing a 404.
|
||
|
||
## Console-script law — only the native binary is called `hanzo`
|
||
|
||
The `hanzo` command is the Rust CLI (`curl -fsSL https://hanzo.sh | sh`). No
|
||
package in this workspace may declare a `hanzo` console script.
|
||
|
||
Both `hanzo` and `hanzo-cli` used to, and `hanzo` depends on `hanzo-cli`, so
|
||
`pip install hanzo` installed two distributions fighting over one name — whichever
|
||
landed last won. Measured on a clean venv at 0.4.3: `hanzo --help` printed
|
||
*hanzo-cli's* program (bot/deploy/iam/k8s/kms/login/logout/paas/s3/whoami), not
|
||
the one `pkg/hanzo/README.md` documented. And if the native CLI was already on
|
||
PATH, `hanzo login` meant one of three different things depending on install
|
||
order — the real CLI reads a bare `hanzo login` as an AI task, since the verb
|
||
there is `hanzo auth login`.
|
||
|
||
Now: `hanzo` ships `hanzo-py`, `hanzo-cli` ships `hanzo-cli`. Script named after
|
||
its distribution, one canonical `hanzo`.
|
||
|
||
`hanzo-node` on PyPI is also **not** the `hanzo-node` command. That command is a
|
||
symlink to the Hanzo CLI, installed by hanzo.sh. The PyPI package fetches a
|
||
different Rust binary from `hanzoai/node` — a private repo, so its release assets
|
||
404 for anyone outside the org. Both READMEs say so rather than implying one
|
||
product.
|
||
|
||
## Brand rules (hard — enforce in all docs)
|
||
- Never "LLM gateway"; never position against LiteLLM. Hanzo is a full AI SDK / AI
|
||
cloud, not a proxy.
|
||
- Zen models are our own family — never name upstream models.
|
||
- Paths are `/v1/…`, never `/api/…`. Base host: `https://api.hanzo.ai`.
|
||
- Voice: "Hanzo — the Open AI Cloud." Crisp, developer-first, no emoji-spam.
|
||
|
||
## Codegen — this repo PULLS, it never pushes
|
||
```
|
||
hanzoai/cloud emits its own router spec -> cloud/openapi.yaml [the ONE SDK input]
|
||
hanzoai/openapi generate.py + sdks.yaml -> the invocation, as data
|
||
this repo owns its test + bump + release, and pins what it projected
|
||
```
|
||
The client is a projection of **cloud's document directly**, and `.spec-lock`
|
||
names the commit and sha256 it was cut from. `generate.py` passes
|
||
`--skip-validate-spec`, so the 1012 missing-`responses` errors that once made
|
||
cloud's emission write zero files no longer stop it; `hanzo.yaml` is out of this
|
||
SDK's path (it still feeds the doc site and the skills plane). Regenerate with
|
||
the document by value:
|
||
```bash
|
||
cd ~/work/hanzo/openapi && uv run --with pyyaml python3 generate.py python \
|
||
--repo ~/work/hanzo/python-sdk --spec ~/work/hanzo/cloud/openapi.yaml
|
||
```
|
||
Current `pkg/hanzoai/cloud/` is **1814 paths / 2479 operations** → 192 api
|
||
modules + 2460 model modules, 2659 importable modules in total. The document
|
||
carries 191 tags; the 192nd module is `default_api`, which holds the 50
|
||
operations that carry no tag (`/`, `/.well-known/agent-skills/*`, …).
|
||
|
||
**834 of the 2479 operations model no response body** — 716 declare no
|
||
`responses` at all and 118 declare responses with no `content` — so those methods
|
||
are typed `-> None` and the payload is reachable only through the generated
|
||
`*_without_preload_content` variant. `examples/money` is the worked example of
|
||
reading one honestly, including the part that is easy to miss: the raw variant
|
||
does not raise on a 4xx, because raising is part of the typed deserialization
|
||
those operations do not have.
|
||
|
||
**Two renamings arrived with the lineage, and neither is a defect to undo.**
|
||
IAM's types are namespace-qualified — `iam.Role`, `iam.Application`, 95 of them
|
||
— because a bare `Role` had been two unrelated shapes under one name (IAM's
|
||
14-property role, and a 2-property `{role, user}` row from another service).
|
||
Both exist now and each says which it is. And the `<svc>_` operationId prefix is
|
||
gone, so every method lost it: `cloud_get_v1_tools` → `get_v1_tools` → `get_tools` (the default version
|
||
left the id at 3.2.10; the wire never carried it),
|
||
`AIApi`/`APIKeysApi`/`MCPApi` → `AiApi`/`KeysApi`/`McpApi`,
|
||
`AdminApi.plugin_admin_plugins` → `admin_plugins`.
|
||
`pkg/hanzoai/cloud/` is **generated — never hand-edit it.** `generate.py` does
|
||
`rmtree(dst) + copytree(src)`, so anything written there dies on the next run. Regenerate
|
||
only from hanzoai/openapi (`python3 generate.py python`) — never from here. The old
|
||
`scripts/generate.sh` was a second, destructive driver (`rm -rf pkg/hanzoai`, which would
|
||
have eaten the hand-written `config/mcp/protocols/session/zap` modules); it is deleted.
|
||
Consumed at 3.1.3: cloud `8143fc0e`, openapi `2861089`. Regenerate whenever either moves —
|
||
openapi `3300cda` dropped `{org}` from the KMS secrets routes (`/v1/kms/orgs/{org}/secrets`
|
||
-> `/v1/kms/secrets`; the org is read from the token), which silently stranded 3.1.2 on a
|
||
path the server no longer serves.
|
||
|
||
**The case-variant tag defect is CLOSED.** `hanzo.yaml` used to carry 23 tag groups
|
||
differing only by case (`AI`/`ai`, `Users`/`users`, …); openapi-generator mapped both
|
||
spellings onto one module and 127 of the 411 operations in those groups never reached the
|
||
client. Fixed upstream in the per-service specs, as that note predicted. Verified on the
|
||
document this tree is pinned to: **191 tags → 191 api modules, 1:1**, plus `default_api`
|
||
for the untagged 50, so nothing collapses, and `generate.py python --check` reports
|
||
`[python] clean` with no local strip of any kind.
|
||
|
||
## The client authenticates itself — CLOSED, and it was fixed in the document
|
||
|
||
`Configuration(access_token=…)` sends the credential. cloud's document declares
|
||
`components.securitySchemes.bearer` (`type: http`, `scheme: bearer`) and applies
|
||
it document-wide with `security: [{bearer: []}]`, so openapi-generator wrote a
|
||
populated `Configuration.auth_settings()` —
|
||
|
||
auth['bearer'] = {'type': 'bearer', 'in': 'header',
|
||
'key': 'Authorization', 'value': 'Bearer ' + self.access_token}
|
||
|
||
— and `_auth_settings: List[str] = ['bearer']` into **2498 call sites**.
|
||
`ApiClient._apply_auth_params` reads that and sets the header. Four call sites
|
||
carry an empty list instead, and they are exactly the operations the document
|
||
marks `security: []`: `get_models`, `get_models_providers`, `get_commands`,
|
||
`get_openapi_json`.
|
||
|
||
Before this, the document declared no scheme at all: `auth_settings()` returned
|
||
`{}`, `update_params_for_auth` returned on its first line, and a Configuration
|
||
built with `access_token="sk-test"` produced a request with **no `Authorization`
|
||
header**. `examples/client.py` compensated with `ApiClient`'s
|
||
`header_name`/`header_value` pair. That compensation is gone — the flows pass
|
||
`access_token` and nothing else, which is the shape a reader of any other
|
||
openapi-generator client already expects.
|
||
|
||
Proven on the wire, not by reading: `python -m examples.hello` against
|
||
api.hanzo.ai returns the caller's keys with a real `sk-`, and the same command
|
||
with a bogus one returns `HTTP 403 {"code":"forbidden","error":"sign in to
|
||
manage API keys"}`. Two different answers to the same code means the credential
|
||
is reaching the server.
|
||
|
||
## A `pk-` is not a read key
|
||
|
||
`cloud.APIKeyPrefixes` is `{"pk-", "sk-"}`, and it is tempting to read that pair
|
||
as a permission split — publishable for reads, secret for writes. It is not one.
|
||
`middleware_identity.go` returns nil for **any** `pk-` before it ever consults the
|
||
key store: "a key meant for a browser bundle must not read … resolvable, not
|
||
authenticating". A `pk-` resolves to an org so an analytics beacon can be
|
||
attributed to a tenant, and that is the whole of it.
|
||
|
||
So every route these examples use refuses a valid `pk-` exactly as it refuses no
|
||
key at all — `/v1/tools` says why in as many words, `"a validated principal is
|
||
required"` — and the 403 that comes back reads like a revoked key rather than the
|
||
wrong shape of key. `examples/client.py` rejects a `pk-` up front for that reason:
|
||
an unauthenticating credential produces the same misleading refusal as a revoked
|
||
one, so it is caught before the request goes out.
|
||
|
||
**Two spec defects were found and fixed upstream while regenerating at 3.1.5.** Neither was
|
||
patched here; both are in `hanzoai/openapi` main:
|
||
- `fc0c17a` — 35 `/v1/platform` operations carried no `responses`. OAS 3.x requires it and
|
||
openapi-generator aborts the entire document, so `hanzo.yaml` was producing no client in
|
||
*any* language, not just Python.
|
||
- `07783f5` — `ChatCompletionResponse.choices` was `items: {type: object}`, so
|
||
`choices[0].message.content` was `List[object]` and unusable without a cast. Now an
|
||
`ai_ChatChoice` schema.
|
||
|
||
The rule holds in both directions: a generated tree is never hand-repaired, and a defect
|
||
found by generating is fixed in the spec, where every other language gets the fix too.
|
||
|
||
## Examples — six flows, and nothing loose beside them
|
||
|
||
`examples/{models,hello,money,store,agent,tools}`, one directory each, plus
|
||
`examples/client.py` as the single place a base URL or an env var is resolved. Run one with
|
||
`python -m examples.hello` from the repo root.
|
||
|
||
`models` is the one that needs no credential — `GET /v1/models` is one of the four
|
||
operations the document marks `security: []` — so it is the install check a reader can run
|
||
before they have a key, and the flow that proves the package reaches api.hanzo.ai at all.
|
||
`client.py` exposes it as `public()` beside the credentialled `client()`.
|
||
|
||
`chat` is absent by measurement, not by choice: `POST /v1/chat/completions` is declared with
|
||
no `requestBody` and no `responses`, so the generated method takes no arguments and returns
|
||
`None` — the one call the flow exists to make cannot be expressed. It returns the day the
|
||
document describes the body; `hanzo.yml` carries the one-line test for that.
|
||
|
||
Five loose scripts used to sit beside the flows (`using_grok.py`, `unified_ai_example.py`,
|
||
`self_learning_agent.py`, `parallel_ai_doc_editing.py`, `worktree_orchestration.py`) and all
|
||
five were dead: they imported `hanzoai.Hanzo`, `hanzoai.cluster`, `hanzoai.agents`,
|
||
`hanzoai.completion` — none of which exist — or were dict-literal sketches captioned "in
|
||
practice this would be called through the MCP interface". `compileall` passed them all,
|
||
because syntax is not a claim. They are deleted. `examples/` is the flows.
|
||
|
||
Each flow's call sits behind `if __name__ == "__main__":` on purpose. That is what lets CI
|
||
*import* all six to prove every `from hanzoai.cloud import X` still resolves, without an API
|
||
key and without opening a socket — so a spec change that renames or drops an operation goes
|
||
red in the gate instead of in a user's app.
|
||
|
||
They are a gate, not decoration. The TypeScript twin of the `chat` flow is what surfaced the
|
||
`choices` defect above: the generated tree imported and built perfectly, because building
|
||
generated code only proves it is internally consistent. Only calling it proves the surface
|
||
is usable.
|
||
|
||
## CI
|
||
|
||
Fleet convention: root `hanzo.yml` (the `test:` gate and `pypi:`) plus
|
||
`.github/workflows/cicd.yml`, which calls `hanzoai/ci/.github/workflows/build.yml@v2` and runs
|
||
on GitHub. The gate is four blocks — import every generated module (for generated
|
||
code that IS the build step; there is no compiler to catch a bad `$ref`), report the imports
|
||
no package declares (report only), read the syntax tree for duplicate fields the import
|
||
cannot see, then resolve and import the six flows. Each provisions an interpreter with `uv`
|
||
when the runner lacks one.
|
||
|
||
Scope is deliberate: the cloud client and its flows, not all 65 packages. A red gate should
|
||
mean "the client the spec just produced is broken", not "something, somewhere".
|
||
|
||
**Publishing is hanzoai/ci's `Publish to PyPI` step, on a tag only.** `v*` publishes every
|
||
`pypi: [., pkg/*]` project whose version PyPI lacks (`twine --skip-existing`);
|
||
`<name>-v<version>` publishes the one whose pyproject `name` matches. Each wheel is installed
|
||
alone in a clean venv and imported before upload, with the tokens KMS holds at
|
||
`python-sdk-publish` (`PYPI_TOKEN`, then `HANZO_AI_PYPI_TOKEN`). The publish job runs the
|
||
client check first: it regenerates `pkg/hanzoai/cloud` from `https://api.hanzo.ai/v1/openapi.json`
|
||
and fails on any drift, so once production's document moves past `.spec-lock` no package here
|
||
publishes until the projection is moved (`workflow_dispatch` with a `spec-ref`); a tag on a
|
||
drifted tree publishes nothing.
|
||
|
||
The `examples` step used to import the flows and stop there, and the comment beside it said
|
||
so honestly — a method name is looked up at call time, so a renamed operation passed the
|
||
import and failed in a user's app. It now resolves them: the syntax tree gives every
|
||
attribute access on a `*Api`-bound name, and the class is asked whether it has it. Ten names
|
||
resolve today, and a stale `get_v1_tools` fails the step in the same second the import
|
||
passes.
|
||
|
||
## Prose is checked the same way as code
|
||
|
||
Three docs under `docs/` were fabrication, not drift: `FEATURES.md`
|
||
(`from hanzoai import cluster/agents/mcp` — none exist), `QUICKSTART.md` and
|
||
`GPT5_ORCHESTRATION.md` (invented console output for CLI flags and models that were never
|
||
ours). None were in the mkdocs nav, so nothing rendered them and nothing checked them.
|
||
Deleted. `pkg/hanzo-agent` told the reader to `pip install hanzoai` and
|
||
`from hanzoai import Agent, Runner` — the cloud client has no `Agent` — and its own `full`
|
||
extra resolved to `hanzoai[web3,tee,marketplace,cli]`, extras the cloud client does not
|
||
declare, so it installed the client and none of the extensions. Both corrected to
|
||
`hanzo-agent` / `from agents import`.
|
||
|
||
`pyproject.toml` said `BSD-3-Clause` while `LICENSE` is the Apache 2.0 text, so the wheel's
|
||
metadata contradicted the file inside it. Metadata now matches the file: `Apache-2.0`.
|
||
|
||
`tests/test_smoke.py` imported `hanzoai.cloud.api.tracker_api`; no `/v1/tracker` path is
|
||
emitted any more, so the suite had a failing test that named a module the client does not
|
||
have. It reads `analytics_api` now, off the client.
|
||
|
||
## The unified `hanzo` tool offers what the FLEET offers, not what the contract lists
|
||
|
||
`hanzo-tools-api`'s `hanzo_tool.py` is the one unified tool: one MCP tool over
|
||
every service cloud serves, its service/action surface derived from
|
||
`/v1/openapi.json` at runtime with a disk cache, so a newly mounted app is
|
||
callable the moment cloud serves it.
|
||
|
||
**The contract is not the agent surface, and that gap was live.** cloud keeps an
|
||
operation off the agent surface when its name discloses a bearer secret at any
|
||
verb, or when a mutating verb acts on identity or authority. `/v1/openapi.json`
|
||
carries them anyway, because it describes the API rather than what a model is
|
||
handed — measured: `post_iam_users` is in the contract (2,265 operations) and is
|
||
refused at `/v1/mcp`. So this tool offered an identity mutation the fleet declines.
|
||
|
||
`spec.py` now filters by `catalog.json`, generated out of cloud's own typed
|
||
operations by `plugin/gen-mcp-catalog`, which asks `fleet.Withheld` — the same
|
||
predicate the endpoint asks. Two facts, one source each: the document supplies
|
||
each operation's METHOD and PATH, which a caller needs to make the request, and
|
||
the catalog supplies which operations are OFFERED. The rule itself is never
|
||
restated here. An unreadable catalog offers NOTHING rather than everything, since
|
||
failing open would widen the surface silently.
|
||
|
||
It is the same file `@hanzo/mcp` and the Rust `hanzo-mcp` crate embed. Refresh
|
||
every runtime's copy at once with `pnpm sync:catalog` in `hanzoai/mcp`.
|
||
|
||
**Two aliases pointed at names the fleet had left behind** — `platform -> paas`
|
||
and `identity -> iam` — so they mapped a caller off the live surface. A synonym
|
||
may only point AT a name the fleet serves; `knowledge`/`rag -> kb` stay.
|
||
|
||
**A test fixture that names no operations is not exercising the offer rule**, and
|
||
says so: `test_spec_catalog.py` and `test_api_tools.py` pass an explicit
|
||
everything-allow for their synthetic specs, and the rule gets its own test
|
||
(`test_the_offer_rule_filters`) rather than the default being weakened to keep
|
||
them green.
|
||
|
||
**A model was handed secrets, and the fleet had already said no.** Nine per-service
|
||
packages registered a `hanzo.tools` entry point AND sat in `hanzo-mcp`'s default
|
||
dependencies, so `pip install hanzo-mcp` put them in a model's tool list — beside
|
||
the unified tool, and without its filter. What that offered:
|
||
|
||
| package | actions reaching a model | what the fleet offers an agent |
|
||
|---|---|---|
|
||
| `hanzo-tools-kms` | `list get set delete inject` | `get_kms_config`, `get_kms_health` — and nothing else |
|
||
| `hanzo-tools-iam` | `create_user delete_user update_user tokens sessions roles` | the reads; every one of those is withheld |
|
||
| `hanzo-tools-team` | `create_workspace delete_workspace invite` | the reads |
|
||
|
||
`inject` is the sharp one: it lists a path and reads every secret at it, in one
|
||
call. cloud's rule exists to prevent exactly this — "a tool is not projected when
|
||
its name discloses a bearer secret at any verb, or when a mutating verb acts on an
|
||
identity or authority object" — and these packages were a second, unfiltered way
|
||
to the same API, so the rule held on one path and not the other.
|
||
|
||
**The auto-registration is withdrawn, not the packages.** `iam`, `kms`, `team`,
|
||
`billing`, `commerce`, `ingress`, `paas` and `s3` no longer declare
|
||
`[project.entry-points."hanzo.tools"]` and no longer ship in `hanzo-mcp`. They
|
||
stay importable for a human at a CLI, which is where reading a secret is a
|
||
legitimate act. An agent reaches all eight through the unified `hanzo` tool, whose
|
||
surface is filtered — verified against the live document: iam 41 operations, kms
|
||
2, team 7, billing 35, commerce 5, ingress 18, platform 34, s3 6.
|
||
|
||
**Two stay registered, for reasons worth stating.** `hanzo-tools-mpc` has NO
|
||
equivalent in the fleet catalog, so unregistering it would remove capability with
|
||
nothing to replace it. `hanzo-tools-auth` is a login and session tool — it reads
|
||
the CALLER's own claims and verifies tokens against the IdP's signing keys, which
|
||
is not another identity's secret and which no catalog replaces.
|
||
|
||
**`paas` is the name the fleet left behind.** It is `platform` there, which is why
|
||
the retired alias mattered: a caller typing the old name was mapped off the live
|
||
surface rather than onto it.
|
||
|
||
## devserver — running dev servers as MCP nodes
|
||
|
||
`pkg/hanzo-tools-devserver` registers `devserver` (`index`, `call`, `errors`, `help`).
|
||
`hanzo_tools.devserver.discover()` is the seam: plain `DevServer` records (port, url,
|
||
pid, framework, version, root, command, mcp, tools), one per listening port — the
|
||
shape a ZAP node of kind devserver takes.
|
||
|
||
- **Identification**: argv basename (Next renames its server `next-server (vX)`), the
|
||
nearest package.json naming a framework (most specific first, `vite` last), and
|
||
psutil's listening ports for the process.
|
||
- **MCP**: Next 16 serves streamable HTTP at `/_next/mcp`; the Vite family serves SSE at
|
||
`/__mcp/sse` only with `vite-plugin-mcp` / `nuxt-mcp-dev`. A probe tries http, then
|
||
https, and caches the scheme that answered per port.
|
||
- **One client**: `mcp_proxy.MCPServerConfig(url=…)` gives `MCPServerConnection` an
|
||
HTTP/SSE transport through the SDK (`http_session`); a loopback URL skips TLS
|
||
verification.
|
||
- **Next's `get_errors` answers for connected browser sessions only** (measured on
|
||
16.3.7). At `load` the session is connected but has not reported the build error yet;
|
||
at `networkidle` it has. So `errors` opens the app through the `browser` tool with
|
||
`state=networkidle` before asking.
|
||
- **Vite writes a transform error to its overlay, not the console.** `errors` reads the
|
||
`vite-error-overlay` shadow root. A plain `.js` syntax error is no transform error in
|
||
Vite 8; it reaches the browser and surfaces as a page error.
|
||
- The browser keeps console messages and page errors across page loads, so `errors`
|
||
counts them before navigating and returns only the new ones.
|
||
- Tests run MCP servers in a subprocess: the SDK's streamable HTTP server leaves memory
|
||
streams unclosed, and in-process the warnings-as-errors config blames the client.
|
||
|
||
## hanzo-kai — the Kai decisions client
|
||
|
||
`pkg/hanzo-kai` (`import hanzo_kai`) is the Python client for Kai: `POST /v1/decisions`
|
||
(`Kai.decide`, `AsyncKai.decide` → `Decision`) and `GET /v1/models` narrowed to the models whose
|
||
`outputs` include `decision`. It depends on httpx and pydantic ≥ 2.10 (the first to read its
|
||
PEP 695 aliases) and nothing else; Python ≥ 3.12.
|
||
|
||
Its surface is one surface with the TypeScript `@hanzo/kai`: typesafe-sdk 0.7.2's call shapes
|
||
under Kai names (`Kai`/`AsyncKai`, `decide`, `Decision`, `Choice`/`Noul`/`Score`, the `APIError`
|
||
family, `RetryPolicy`, `HANZO_API_KEY`, `HANZO_BASE_URL`, `KAI_MODEL`, `KAI_LOG_LEVEL`).
|
||
`tests/test_surface.py` pins the names and signatures, so a rename fails here before it splits
|
||
the two. The client checks only what the server cannot see (at least one question, a score's
|
||
criteria a list, a choice's a map or list); every other rule is the server's 422, and its
|
||
sentence becomes `error.message`. `hanzo/kai` bodies arrive with sorted keys, so answers are
|
||
read by name, and score maps are keyed and ordered by int level.
|
||
|
||
`hanzo_kai.jev` is the Jev-compatibility layer: `Client.system_one` on `POST /v1/systemone`, Jev's
|
||
body as `Response`, FastAPI errors as the same classes, and `models.list()` presenting the decision
|
||
models of `data` in Jev's shape, because api.hanzo.ai keeps the `models` key empty. `from
|
||
hanzo_kai.jev import Choice, Noul, Score, Client as TypeSafeClient` ports a TypeSafe program. A
|
||
refusal over reach is `422` with `error.code` a name on both paths (`state_too_long`,
|
||
`question_too_long`, `option_too_long`, `request_too_long`), as hanzoai/decision's
|
||
`decision/src/error.rs` writes it.
|
||
|
||
Tests: `cd pkg/hanzo-kai && uv run pytest`, over `httpx.MockTransport` with no network, each
|
||
client test on both flavours; they are outside `hanzo.yml`'s gate, whose scope is the cloud
|
||
client. `tests/conformance/*.json` states one contract rule per file for both paths: the suite
|
||
runs them against `conformance/fake.py`, and `uv run python tests/live_conformance.py` runs them
|
||
against api.hanzo.ai and prints pass or fail per rule. Release: tag `hanzo-kai-v<version>`, and
|
||
`https://pypi.org/pypi/hanzo-kai/json` is the proof, not a green run.
|
||
|
||
## search — ranked code spans from a local index
|
||
|
||
`hanzo_search` ships in the hanzo-mcp distribution as its own top-level package, because
|
||
`import hanzo_mcp` costs seconds and the standalone server must answer in under one. hanzo-mcp
|
||
registers `search(query, root="", k=5)` in `_register_system_tools`; `python -m
|
||
hanzo_search.server --root DIR` serves it alone over stdio (newline JSON-RPC, standard library
|
||
only, ~0.2 s to the first answer, index included, on a 10K-line tree).
|
||
|
||
- **Index** (`index.py`): Python chunked by `ast` (each function and method, each class header,
|
||
code between them in windows), other text in 40-line windows 30 apart; nothing over 1 MB,
|
||
binaries, hidden dirs, `node_modules`, venvs or build output. Every search stats the tree
|
||
first: (size, mtime, ctime, inode) moved → read; bytes changed → re-chunked; gone → dropped.
|
||
- **Rank**: BM25 over code tokens (an identifier whole, then its dotted, snake and camel parts),
|
||
fused by reciprocal rank with the spans a traceback's `path:line` frames name, deepest first.
|
||
When the query names a test file the index holds, test files rank after the code under test.
|
||
- **Dense** (`dense.py`), off unless `HANZO_SEARCH_MODEL` names a local model directory
|
||
(extra `search`: torch, transformers): Qwen3-Embedding form, CPU, fp32; chunk vectors by the
|
||
SHA-256 of their text in `HANZO_SEARCH_CACHE` (default `~/.cache/hanzo/search`), one SQLite
|
||
file per model. On dev it lifts span MRR .385 → .508 (traceback) and .327 → .472 (short),
|
||
but a query embedding took 2.5–7 s on dgx's CPU, so the default is BM25 and frames.
|
||
- Dev (42 kai-day code tasks, `~/.cache/kai-day/search.dev.json`), failing-test output as the
|
||
query: file r@1 .714, r@5 1.0; span r@1 .19, r@5 .667. Warm search on a 72K-line tree p50
|
||
11 ms, p95 43 ms.
|
||
- Tests: `tests/test_hanzo_search.py` (tokens, chunks, ranking, freshness, caps, schema size,
|
||
dense cache with a fake embedder, stdio round trip).
|
||
|
||
## Plan usage — `hanzoai.usage`
|
||
|
||
`read_usage(headers)` → `Usage(usage, usage_class, paid_by, fallback, served, reason)` from
|
||
the `X-Hanzo-*` headers, case-insensitive; an absent header is `None`. `UsageLimitError`
|
||
(a `Fault`) with one subclass per AI-router refusal code — `PlanAllowanceUsedError`,
|
||
`PaidPlanRequiredError`, `FreePlanCapError`, `ModelCapError`, `UsageCapExceededError`,
|
||
`InsufficientBalanceError`, the JS and Go names, plus `window` (the spent session or day). `refusal(reply)` reads only the router's
|
||
nested `{"error": {code, ...}}` body (or a controller's `{status: "error", code, msg}`); the money gate's flat body stays `Denied`.
|
||
`Client.send` and `Client.response_deserialize` raise it, so every generated operation run
|
||
through `hanzoai.Client` does; a bare generated `ApiClient` still raises `ApiException`.
|
||
|
||
The rest is generated, typed, and needs nothing hand-written: `AiApi.ai_limits` /
|
||
`ai_set_limits`, `SyncApi.get_sync` / `get_sync_by_id` / `post_sync` /
|
||
`post_sync_by_id_run`, `AiApi.post_decisions`, `AiApi.get_models` (`ModelInfo.var_class`
|
||
is the wire's `class`; `pricing.variable`). `/v1/models` ignores `?class=`, so filter
|
||
client-side. Headers: the `*_with_http_info` variant's `.headers` into `read_usage`.
|
||
`ai.DecisionsChoice.criteria` is an object in cloud's document, so the `[label, ...]` form
|
||
the server also takes cannot be sent through the generated client.
|
||
|
||
**Hand-written root modules ship with the next cloud fanout.** The root version is
|
||
cloud's; the fanout's `v8.5.N` tag publishes whatever `pkg/hanzoai` holds at that commit.
|
||
Never cut a root version by hand.
|
||
|
||
**Commit with `LEFTHOOK=0`.** The global hook falls through to `uv run lefthook -h`, which
|
||
re-locks `uv.lock` with the local uv and syncs a `.venv` on every checkout and commit.
|
||
|
||
## Key entry points
|
||
- `pkg/hanzoai/` — the client (`ApiClient`, `Configuration`, `*Api`) under `cloud/`, plus
|
||
seven hand-written modules beside it (`config`, `mcp`, `protocols`, `session`, `zap`, …).
|
||
`pkg/hanzoai/{api,models}` is GONE from git; if a checkout still shows `resources/` or
|
||
`types/` on disk they are untracked leftovers of that surface, and the wheel excludes them
|
||
— the published 3.2.13 wheel is 2667 files of `hanzoai/cloud/` and those seven modules,
|
||
nothing else.
|
||
- `pkg/hanzo/src/hanzo/cli.py` — the `hanzo` CLI command tree.
|
||
- `pkg/hanzo-mcp/` — MCP server; tools via `[project.entry-points."hanzo.tools"]`.
|
||
- `pkg/hanzo-tools-*/` — one concern each; 30 of the 39 register a `TOOLS` list under the
|
||
`hanzo.tools` entry point (`hanzo-tools-core` is the shared base, and the eight service
|
||
packages withdrawn above register none).
|
||
- `pkg/hanzo-{agents,agent,network,memory}/` — agent/compute/memory libraries.
|
||
- `pkg/hanzo-kms/` — KMS client. The server is **luxfi/kms** (`kms.hanzo.ai`,
|
||
`kms.lux.cloud`) and its whole surface is `/v1/kms/auth/login` plus
|
||
`/v1/kms/orgs/{org}/secrets[/{path}/{name}]`. `/api/*` is Infisical's and was
|
||
never served — it looked like a decode error rather than a 404 only because old
|
||
builds answered every unmatched path with the console SPA (200 text/html).
|
||
A secret is (org, path, name, env), one value each — no versions. The server
|
||
splits the trailing URL at its LAST slash into (path, name), so escape each
|
||
segment individually. `pkg/hanzo-kms/tests/` pins all of it.
|
||
|
||
**Rules for agents:** update THIS file with significant discoveries; never write
|
||
random summary files; keep the README cross-link block intact.
|