Files
zeekay aa6aa95656 hanzo-mcp: search, ranked code spans from a local index
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.
2026-10-12 08:51:36 -07:00

495 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.