Files

12 KiB

Hanzo TypeScript SDK

hanzoai

TypeScript client for the Hanzo API, generated from the API's own OpenAPI document, with budget, policy, audit, search, kb and graph written over it by hand.

Install

npm i hanzoai

Types are included. axios is the only dependency.

Client and the six capabilities below ship from 8.5.158. 8.5.157 and earlier have the generated *Api classes and not them. While npm view hanzoai version still answers 8.5.157, build the package from main:

git clone https://github.com/hanzoai/js-sdk && cd js-sdk
npm ci && npm run build && npm pack    # prints hanzoai-<version>.tgz

and install that file in your project with npm i <path to the .tgz>.

Quickstart

GET /v1/models needs no credential, so this runs before you have one. Save it as quickstart.mjs and run node quickstart.mjs:

import { AiApi, Configuration } from 'hanzoai';

const ai = new AiApi(new Configuration({ basePath: 'https://api.hanzo.ai' }));
const { data } = await ai.getModels();

console.log(`${data.data.length} models`);
for (const m of data.data.slice(0, 5)) console.log(`  ${m.id}`);

From CommonJS, require('hanzoai') returns the same classes.

Calls without a valid credential are limited per address in 8-hour windows. Past the limit the answer is 429, which arrives as an AxiosError with err.response.status set.

Auth

One scheme: a bearer token — an IAM access token or a Cloud API key. The server derives your org from the token's owner claim, so no route takes an org argument.

It goes in accessToken. When it is set, every operation sends Authorization: Bearer <token> from that field.

import { AiApi, Configuration } from 'hanzoai';

const config = new Configuration({
  basePath: 'https://api.hanzo.ai',
  accessToken: process.env.HANZO_API_KEY,
});

type Completion = { choices?: Array<{ message?: { content?: string } }> };

async function main() {
  const { data } = await new AiApi(config).postChatCompletions({
    openaiChatCompletionRequest: {
      model: 'zen5',
      messages: [{ role: 'user', content: 'Say hello in five words.' }],
    },
  });
  const reply = data as unknown as Completion;
  console.log(reply.choices?.[0]?.message?.content);
}

main();

Four operations need no credential: GET /v1/models, GET /v1/models/providers, GET /v1/commands, GET /v1/openapi.json. For those, construct a Configuration with no accessToken and the client sends no Authorization header.

A generated call that gets a status outside 2xx throws axios's AxiosError, with the status on err.response.status, after one request — or, for a usage refusal, its own class (below). Nothing is retried. A 200 whose body is an error object resolves like any other 200, and a completion's content comes back as the server sent it, <think> blocks included.

A Configuration reads no environment variable of its own — HANZO_API_KEY above is just where the examples keep theirs. Client, below, does read the environment, and mints its own token rather than being handed one.

GET /v1/iam/oauth/userinfo is how you check a token: it answers the token's identity, or 401 {"error":"invalid_token"}. That is the hello flow below.

Plan usage

Every AI answer says how it was paid for in X-Hanzo-* headers. readUsage reads them off an axios response, a fetch Response or a plain record; an absent header reads undefined.

import { AiApi, Configuration, ModelCapError, UsageLimitError, readUsage } from 'hanzoai';

const ai = new AiApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
try {
  const res = await ai.postChatCompletions({
    openaiChatCompletionRequest: { model: 'anthropic/claude-sonnet-4.5', messages },
  });
  readUsage(res.headers); // { usage: 'ok', usageClass: 'premium', paidBy: 'credits', served: '…', fallback: undefined, reason: undefined }
} catch (err) {
  if (err instanceof ModelCapError) retry(err.fallback);
  else if (err instanceof UsageLimitError) offer(err.actions);
  else throw err;
}

A 402 or 429 refusal nothing may pay for throws a subclass of UsageLimitError, which carries status, code, type, usageClass, model, fallback, resetsAt, upgradeUrl, actions, request (the x-request-id) and, from a generated call, cause (the AxiosError):

class status code
PlanAllowanceUsedError 402 plan_allowance_used
PaidPlanRequiredError 402 paid_plan_required
FreePlanCapError 429 free_plan_cap
ModelCapError 402 model_cap
UsageCapExceededError 429 usage_cap_exceeded
InsufficientBalanceError 402 insufficient_balance

Any other refusal stays an AxiosError, as does a refusal through an *Api built without the package's Configuration or with an axios instance of your own. A responseType: 'stream' JSON body is read for the code, within the request's signal and timeout, and left whole on err.cause.response.data. Client's six throw these where they would throw answer.Fault; a 402 there is still the denied arm.

The routes are generated: aiLimits() and aiSetLimits({ aiLimitsSet: { creditsAfterAllowance } }) on /v1/ai/limits; SyncApi's getSync, getSyncById, postSync and postSyncByIdRun ("sync now") on /v1/sync; postDecisions; and getModels, whose rows carry class (premium, ours or free), family and pricing.variable.

The six capabilities

Budget, policy, audit, search, kb and graph — the same six words in the Go, Python and TypeScript SDKs. They hang off Client, which holds an IAM client id and secret and mints its own access token.

import { Client } from 'hanzoai';

// HANZO_CLIENT_ID and HANZO_CLIENT_SECRET, or pass { id, secret }.
const c = new Client();

const left = await c.budget.left();          // 17 of 20 calls left today
const may = await c.policy.check('usr_7', 'write', 'acme/graph');
const a = await c.search.find('incident runbook', { limit: 5 });

A refusal is an answer, not an exception. Anything a gate can stop returns Answer<T>, a union with three arms:

switch (a.status) {
  case 'ok':
    for (const hit of a.value.items) show(hit.title, hit.score);
    if (a.value.partial) warn('a search leg was down');
    break;
  case 'denied':                             // out of credit, or policy said no
    console.log(a.code, a.reason);           // "insufficient_balance", …
    for (const cure of a.cures) offer(cure.kind, cure.url);
    break;
  case 'held':                               // a person was asked
    console.log(`approval ${a.id} on ${a.clause}`);
    break;
}

a.value does not compile until the union is narrowed, so there is no path that reads a value the server never sent. All three arms carry request — the x-request-id that finds the call in the audit trail:

for await (const event of c.audit.all({ request: a.request })) {
  console.log(event.actor, event.action, event.result);
}

Exceptions are for outcomes with no decision in them: no credential, a transport failure, a 401, a 5xx. They arrive as answer.Fault, carrying status, code and request. A 401 re-mints the token and replays the call once before it throws. A 402, or a 403 with a refusal code, is the denied arm. Nothing else is retried, and a 200 is always ok.

Everything else:

capability methods
c.budget left balance plan spent
c.policy check
c.audit list all
c.search find
c.kb put get list drop import reindex connectors connect sync revoke links install
c.graph assert read find resolve walk extract ingest vocabulary

c.as('usr_7') returns a client acting as one of your tenant's subjects — the minted token carries the scope, so no method takes a user id. c.configuration hands the same identity to any generated *Api class.

Types are namespaced by capability: budget.Allowance, search.Hit, audit.Event, graph.Fact, kb.Doc, answer.Answer.

Examples

Nine flows under examples/, one directory each, every one a complete program:

flow what it does routes
models the catalog, no credential needed GET /v1/models
hello identity — prove the key works GET /v1/iam/oauth/userinfo
chat one completion POST /v1/chat/completions
money balance + usage GET /v1/billing/balance, GET /v1/billing/usage
store KV round-trip POST /v1/provisioning/kv, GET/DELETE /v1/provisioning/kv/{name}
agent create + run + read POST /v1/agent, POST /v1/agent/{ref}/run, GET /v1/agent/{ref}/runs
tools tool catalog GET /v1/tool
six budget → policy → search → kb → graph → audit, in one pass the six capabilities
limits plan, catalog by class, who paid, a typed refusal, repo links GET /v1/ai/limits, GET /v1/models, POST /v1/chat/completions, POST /v1/decisions, GET /v1/sync

models runs with nothing exported:

npm ci && npm run build && npx tsx examples/models/index.ts

(npm run build first because the examples import hanzoai by name and the package resolves its own name through exports, which points at dist/.)

Seven of the others read HANZO_API_KEY and six reads HANZO_CLIENT_ID/HANZO_CLIENT_SECRET; all nine talk to https://api.hanzo.ai unless HANZO_BASE_URL says otherwise:

export HANZO_API_KEY=...
npx tsx examples/hello/index.ts

A token the server refuses answers HTTP 401: {"error":"invalid_token","error_description":"the access token is invalid or revoked"}.

npm run examples type-checks all nine against the client, npm run wire runs the six and the usage refusals against a scripted fetch, and hanzo.yml makes both a CI gate.

The API surface

One class per product — the first path segment after /v1/: AiApi, IamApi, BillingApi, GraphApi, KnowledgeApi, SearchApi, AgentApi, ToolApi, CommerceApi, O11yApi, and so on. Each takes a Configuration; each method takes one request object.

import { Configuration, BillingApi } from 'hanzoai';

const billing = new BillingApi(new Configuration({ basePath: 'https://api.hanzo.ai' }));
billing.getBillingBalance().then(({ data }) => console.log(data));

Method names are the document's operation ids in camelCase — get_billing_balance is getBillingBalance, and a path parameter reads as by: GET /v1/provisioning/kv/{name} is getProvisioningKvByName({ name }).

Some operations declare a route but not a response shape, so their data arrives untyped and wants a cast.

Full reference: docs.hanzo.ai.

Regenerating

Most of src/ is generated — never hand-edit a generated file. Fix the spec upstream and rerun:

export OPENAPI=~/work/hanzo/openapi       # the checkout holding the generator
export SPEC=~/work/hanzo/cloud/openapi.yaml   # the document, by value

./scripts/generate.sh            # rewrite src/
./scripts/generate.sh --check    # diff only; non-zero if src/ drifted

Drop SPEC and the driver fetches the ref .spec-lock names, which needs a forge token; passing the document by value is the offline route. Every generator knob lives once, in that repo's generate.py and sdks.yaml.

.generated lists what the driver owns. A file it does not name is this repo's own and a regeneration never touches it — which is where hanzo.ts, client.ts, answer.ts, usage.ts, transport.ts and the six capabilities live.

Requires java 17+ and uv.

License

Apache-2.0