Files
js-sdk/test/usage.test.mjs

615 lines
25 KiB
JavaScript

// Plan usage: the typed refusals, the headers, and the routes that read and set
// a plan's limits.
//
// The generated client is what reaches /v1/ai/limits, /v1/sync, /v1/decisions
// and /v1/models, so these drive it the way a consumer does — `new AiApi(new
// Configuration(...))` — through axios's fetch adapter onto the scripted fetch.
// The refusal bodies are the gateway's own: free_plan_cap is the one api.hanzo.ai
// answered for POST /v1/decisions on 2026-10-04, the other five are what
// hanzoai/ai routers/filter_balance.go limitReached and object/billing_notice.go
// write for their codes.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import test from 'node:test';
import { TOKEN, client, configuration, hanzoai, json, wire } from './wire.mjs';
const axios = createRequire(import.meta.url)('axios');
const REQUEST = 'cd426b48ea073fbc5427a99fb86d2b65';
const freePlanCap = {
error: {
message: "Free plan: today's Kai requests are used. Upgrade for more: https://hanzo.ai/pay",
type: 'rate_limit_error',
code: 'free_plan_cap',
class: 'ours',
resets_at: '2026-10-05T00:00:00Z',
upgrade_url: 'https://hanzo.ai/pay/cart?plan=dev',
actions: [
{ kind: 'upgrade', label: 'Upgrade your plan', url: 'https://hanzo.ai/pay/cart?plan=dev', plan: 'dev' },
{ kind: 'topup', label: 'Add prepaid credit', url: 'https://hanzo.ai/pay' },
],
},
};
const planAllowanceUsed = {
error: {
message: "Your plan's included premium usage is used. https://hanzo.ai/pay",
type: 'billing_error',
code: 'plan_allowance_used',
class: 'premium',
resets_at: '2026-11-01T00:00:00Z',
upgrade_url: 'https://hanzo.ai/pay/cart?plan=max-5x',
actions: [
{ kind: 'upgrade', label: 'Upgrade your plan', url: 'https://hanzo.ai/pay/cart?plan=max-5x', plan: 'max-5x' },
{ kind: 'credits', label: 'Continue with credits', url: '/v1/ai/limits' },
],
},
};
const paidPlanRequired = {
error: {
message: 'This request is outside what your plan includes. https://hanzo.ai/pay',
type: 'billing_error',
code: 'paid_plan_required',
class: 'premium',
model: 'anthropic/claude-sonnet-4.5',
upgrade_url: 'https://hanzo.ai/pay/cart?plan=dev',
actions: [
{ kind: 'upgrade', label: 'Upgrade your plan', url: 'https://hanzo.ai/pay/cart?plan=dev', plan: 'dev' },
{ kind: 'topup', label: 'Add prepaid credit', url: 'https://hanzo.ai/pay' },
],
},
};
const modelCap = {
error: {
message: 'Claude Sonnet 4.5 has used its share of your plan. https://hanzo.ai/pay',
type: 'billing_error',
code: 'model_cap',
class: 'premium',
model: 'anthropic/claude-sonnet-4.5',
fallback: 'enso',
resets_at: '2026-11-01T00:00:00Z',
actions: [
{ kind: 'switch', label: 'Try Enso', model: 'enso' },
{ kind: 'topup', label: 'Add prepaid credit', url: 'https://hanzo.ai/pay' },
],
},
};
const usageCapExceeded = {
error: {
message:
"You've used today's requests on your plan. They reset at 2026-10-05T00:00:00Z. " +
'Upgrade for more at https://hanzo.ai/pay/cart?plan=max-5x',
type: 'rate_limit_error',
code: 'usage_cap_exceeded',
limit: 'day',
resets_at: '2026-10-05T00:00:00Z',
upgrade_url: 'https://hanzo.ai/pay/cart?plan=max-5x',
actions: [
{ kind: 'upgrade', label: 'Upgrade your plan', url: 'https://hanzo.ai/pay/cart?plan=max-5x', plan: 'max-5x' },
],
},
};
const insufficientBalance = {
error: {
message: 'Insufficient balance. Add credits to your wallet at https://hanzo.ai/pay',
type: 'billing_error',
code: 'insufficient_balance',
},
};
const decision = {
aiDecisionsRequest: {
model: 'kai',
state: 'a ticket asks for a refund',
questions: {
route: { type: 'choice', criteria: { refund: 'asks for money back', other: 'anything else' } },
},
},
};
const refusals = [
['PlanAllowanceUsedError', 402, planAllowanceUsed],
['PaidPlanRequiredError', 402, paidPlanRequired],
['FreePlanCapError', 429, freePlanCap],
['ModelCapError', 402, modelCap],
['UsageCapExceededError', 429, usageCapExceeded],
['InsufficientBalanceError', 402, insufficientBalance],
];
for (const [name, status, body] of refusals) {
test(`${status} ${body.error.code} throws ${name} from the generated client`, async (t) => {
const sent = wire([{ status, body, headers: { 'x-request-id': REQUEST, 'x-hanzo-usage': 'limited' } }]);
t.after(sent.restore);
const ai = new hanzoai.AiApi(configuration());
await assert.rejects(() => ai.postDecisions(decision), (err) => {
assert.ok(err instanceof hanzoai[name]);
assert.ok(err instanceof hanzoai.UsageLimitError);
assert.ok(err instanceof Error);
assert.equal(axios.isAxiosError(err), false);
assert.equal(err.name, name);
assert.equal(err.status, status);
assert.equal(err.code, body.error.code);
assert.equal(err.message, body.error.message);
assert.equal(err.type, body.error.type);
assert.equal(err.usageClass, body.error.class);
assert.equal(err.model, body.error.model);
assert.equal(err.fallback, body.error.fallback);
assert.equal(err.upgradeUrl, body.error.upgrade_url);
assert.deepEqual(err.resetsAt, body.error.resets_at ? new Date(body.error.resets_at) : undefined);
assert.deepEqual(err.actions, body.error.actions ?? []);
assert.equal(err.request, REQUEST);
// What axios raised is kept, headers and all.
assert.ok(axios.isAxiosError(err.cause));
assert.equal(err.cause.response.status, status);
return true;
});
assert.equal(sent[0].method, 'POST');
assert.equal(sent[0].url, 'https://api.hanzo.ai/v1/decisions');
assert.equal(sent[0].headers.authorization, 'Bearer tok-1');
assert.deepEqual(json(sent[0]), decision.aiDecisionsRequest);
});
}
test('an absent field reads undefined, never ""', async (t) => {
const sent = wire([{ status: 402, body: insufficientBalance }]);
t.after(sent.restore);
await assert.rejects(() => new hanzoai.AiApi(configuration()).postDecisions(decision), (err) => {
assert.ok(err instanceof hanzoai.InsufficientBalanceError);
for (const k of ['usageClass', 'model', 'fallback', 'resetsAt', 'upgradeUrl']) {
assert.equal(err[k], undefined, k);
}
assert.deepEqual(err.actions, []);
return true;
});
});
test('any other refusal is still the AxiosError it was', async (t) => {
const sent = wire([
{ status: 400, body: { error: { message: 'unknown model', type: 'invalid_request_error', code: 'model_not_found' } } },
{ status: 503, body: { error: { message: 'Unable to verify your balance right now.', type: 'billing_error', code: 'balance_unavailable' } } },
{ status: 402, body: { type: 'about:blank', status: 402, detail: 'Add credits', code: 'insufficient_balance' } },
// A usage code outside 402 and 429 is not a usage refusal.
{ status: 401, body: modelCap },
// Nor is a name every object inherits.
{ status: 402, body: { error: { code: 'constructor' } } },
{ status: 429, body: { error: { code: '__proto__' } } },
{ status: 402, body: { error: { code: 'toString' } } },
]);
t.after(sent.restore);
const ai = new hanzoai.AiApi(configuration());
for (const status of [400, 503, 402, 401, 402, 429, 402]) {
await assert.rejects(() => ai.postDecisions(decision), (err) => {
assert.ok(axios.isAxiosError(err));
assert.equal(err instanceof hanzoai.UsageLimitError, false);
assert.equal(err.response.status, status);
return true;
});
}
});
test('an action with no kind is not offered', async (t) => {
const body = { error: { ...freePlanCap.error, actions: [{ label: 'no kind' }, 'upgrade', ...freePlanCap.error.actions] } };
const sent = wire([{ status: 429, body }]);
t.after(sent.restore);
await assert.rejects(() => new hanzoai.AiApi(configuration()).postDecisions(decision), (err) => {
assert.deepEqual(err.actions, freePlanCap.error.actions);
return true;
});
});
test('a buffer or a stream body is read, and a stream is left whole for the caller', async (t) => {
const sent = wire([
{ status: 402, body: modelCap },
{ status: 429, body: freePlanCap },
{ status: 429, body: { error: { message: 'slow down', code: 'rate_limited' } } },
]);
t.after(sent.restore);
const ai = new hanzoai.AiApi(configuration());
await assert.rejects(() => ai.postDecisions(decision, { responseType: 'arraybuffer' }), hanzoai.ModelCapError);
await assert.rejects(() => ai.postDecisions(decision, { responseType: 'stream' }), (err) => {
assert.ok(err instanceof hanzoai.FreePlanCapError);
assert.deepEqual(err.resetsAt, new Date('2026-10-05T00:00:00Z'));
return true;
});
const err = await ai.postDecisions(decision, { responseType: 'stream' }).catch((e) => e);
assert.ok(axios.isAxiosError(err));
assert.equal(await new Response(err.response.data).text(), JSON.stringify({ error: { message: 'slow down', code: 'rate_limited' } }));
});
test('through the http adapter: a parsed body, a Node stream, and a stream left whole', async (t) => {
const answers = [
[402, JSON.stringify(modelCap)],
[429, JSON.stringify(usageCapExceeded)],
[429, JSON.stringify({ error: { message: 'slow down', code: 'rate_limited' } })],
];
const server = createServer((req, res) => {
req.resume();
const [status, text] = answers.shift();
res.writeHead(status, { 'content-type': 'application/json', 'x-request-id': REQUEST });
res.end(text);
});
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
t.after(() => server.close());
const ai = new hanzoai.AiApi(new hanzoai.Configuration({ basePath: `http://127.0.0.1:${server.address().port}`, accessToken: 'tok-1' }));
await assert.rejects(() => ai.postDecisions(decision), (err) => {
assert.ok(err instanceof hanzoai.ModelCapError);
assert.equal(err.fallback, 'enso');
assert.equal(err.request, REQUEST);
return true;
});
await assert.rejects(() => ai.postDecisions(decision, { responseType: 'stream' }), hanzoai.UsageCapExceededError);
const err = await ai.postDecisions(decision, { responseType: 'stream' }).catch((e) => e);
assert.ok(axios.isAxiosError(err));
let text = '';
for await (const chunk of err.response.data) text += chunk;
assert.equal(text, JSON.stringify({ error: { message: 'slow down', code: 'rate_limited' } }));
});
test('an adapter of the caller\'s own is left alone, on an instance or set globally later', async (t) => {
let hits = 0;
const refuse = async (config) => {
hits++;
const err = new axios.AxiosError('402', 'ERR_BAD_REQUEST', config, null, {
status: 402, statusText: '', headers: {}, config, data: JSON.stringify(modelCap),
});
throw err;
};
// An instance of the caller's own: its adapter answers, and its own
// interceptors (none) decide the error's type.
const own = axios.create({ adapter: refuse });
await assert.rejects(
() => new hanzoai.AiApi(configuration({ baseOptions: {} }), undefined, own).postDecisions(decision),
(err) => axios.isAxiosError(err),
);
assert.equal(hits, 1);
// A global adapter set after the Configuration was built still answers.
const config = configuration({ baseOptions: {} });
const before = axios.defaults.adapter;
axios.defaults.adapter = refuse;
t.after(() => {
axios.defaults.adapter = before;
});
await assert.rejects(() => new hanzoai.AiApi(config).postDecisions(decision), hanzoai.ModelCapError);
assert.equal(hits, 2);
});
test('a stream is read only while it is JSON, the caller has not aborted, and time remains', async (t) => {
const real = globalThis.fetch;
t.after(() => {
globalThis.fetch = real;
});
const endless = (type) => {
globalThis.fetch = async () =>
new Response(new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode('{"error":')); } }), {
status: 429,
headers: { 'content-type': type },
});
};
const ai = new hanzoai.AiApi(configuration());
// An event stream is never a refusal, so it is not read: the AxiosError comes at once.
endless('text/event-stream');
const sse = await ai.postDecisions(decision, { responseType: 'stream' }).catch((e) => e);
assert.ok(axios.isAxiosError(sse));
// JSON that never ends: the caller's abort ends the wait with the AxiosError.
endless('application/json');
const abort = new AbortController();
setTimeout(() => abort.abort(), 50);
const aborted = await ai.postDecisions(decision, { responseType: 'stream', signal: abort.signal }).catch((e) => e);
assert.ok(axios.isAxiosError(aborted));
assert.equal(aborted.response.status, 429);
// And so does its timeout, with what was read still on the stream.
endless('application/json');
const timed = await ai.postDecisions(decision, { responseType: 'stream', timeout: 50 }).catch((e) => e);
assert.ok(axios.isAxiosError(timed));
const reader = timed.response.data.getReader();
assert.equal(new TextDecoder().decode((await reader.read()).value), '{"error":');
reader.cancel();
});
test('interceptors.response.clear() is undone by the next Configuration', async (t) => {
configuration();
axios.interceptors.response.clear();
const sent = wire([{ status: 402, body: modelCap }]);
t.after(sent.restore);
await assert.rejects(() => new hanzoai.AiApi(configuration()).postDecisions(decision), hanzoai.ModelCapError);
});
test('two copies of the package on one axios each type their own requests', async (t) => {
const require = createRequire(import.meta.url);
const entry = require.resolve('hanzoai');
const dist = entry.slice(0, entry.lastIndexOf('/') + 1);
const ours = Object.keys(require.cache).filter((k) => k.startsWith(dist));
const saved = Object.fromEntries(ours.map((k) => [k, require.cache[k]]));
for (const k of ours) delete require.cache[k];
const other = require('hanzoai');
t.after(() => Object.assign(require.cache, saved));
assert.notEqual(other.ModelCapError, hanzoai.ModelCapError);
const sent = wire([{ status: 402, body: modelCap }, { status: 402, body: modelCap }]);
t.after(sent.restore);
const cfg = (pkg) => new pkg.Configuration({ basePath: 'https://api.hanzo.ai', baseOptions: { adapter: 'fetch' } });
await assert.rejects(() => new other.AiApi(cfg(other)).postDecisions(decision), other.ModelCapError);
await assert.rejects(() => new hanzoai.AiApi(cfg(hanzoai)).postDecisions(decision), hanzoai.ModelCapError);
});
test('readUsage reads a priced answer off the axios response', async (t) => {
const sent = wire([
{
status: 200,
headers: {
'x-hanzo-usage': 'ok',
'x-hanzo-usage-class': 'premium',
'x-hanzo-paid-by': 'credits',
'x-hanzo-served': 'anthropic/claude-sonnet-4.5',
},
body: { id: 'chatcmpl-1', model: 'anthropic/claude-sonnet-4.5', choices: [{ message: { role: 'assistant', content: 'hi' } }] },
},
]);
t.after(sent.restore);
const res = await new hanzoai.AiApi(configuration()).postChatCompletions({
openaiChatCompletionRequest: { model: 'anthropic/claude-sonnet-4.5', messages: [{ role: 'user', content: 'hi' }] },
});
assert.deepEqual(hanzoai.readUsage(res.headers), {
usage: 'ok',
usageClass: 'premium',
paidBy: 'credits',
fallback: undefined,
served: 'anthropic/claude-sonnet-4.5',
reason: undefined,
});
});
test('readUsage takes fetch Headers, AxiosHeaders and a plain record alike', () => {
const limited = {
usage: 'limited',
usageClass: 'premium',
paidBy: undefined,
fallback: 'enso',
served: 'enso',
reason: 'model_cap',
};
const wire = {
'X-Hanzo-Usage': 'limited',
'X-Hanzo-Usage-Class': 'premium',
'X-Hanzo-Fallback': 'enso',
'X-Hanzo-Served': 'enso',
'X-Hanzo-Usage-Reason': 'model_cap',
};
assert.deepEqual(hanzoai.readUsage(new Headers(wire)), limited);
assert.deepEqual(hanzoai.readUsage(axios.AxiosHeaders.from(wire)), limited);
assert.deepEqual(hanzoai.readUsage(wire), limited);
// Node's raw headers: lower-cased, and an array where a name repeats.
assert.deepEqual(
hanzoai.readUsage({ 'x-hanzo-usage': ['limited'], 'x-hanzo-usage-class': 'premium', 'x-hanzo-fallback': 'enso', 'x-hanzo-served': 'enso', 'x-hanzo-usage-reason': 'model_cap' }),
limited,
);
});
test('readUsage: absent or empty reads undefined, never 0 or ""', () => {
const none = { usage: undefined, usageClass: undefined, paidBy: undefined, fallback: undefined, served: undefined, reason: undefined };
assert.deepEqual(hanzoai.readUsage(new Headers()), none);
assert.deepEqual(hanzoai.readUsage({}), none);
assert.deepEqual(hanzoai.readUsage(undefined), none);
assert.deepEqual(hanzoai.readUsage({ 'x-hanzo-usage': '', 'x-hanzo-paid-by': ' ' }), none);
// A free model answers with only the SKU.
assert.deepEqual(hanzoai.readUsage(new Headers({ 'x-hanzo-served': 'zen5' })), { ...none, served: 'zen5' });
});
test('GET /v1/ai/limits and PUT /v1/ai/limits', async (t) => {
const limits = {
plan: 'free',
state: 'ok',
classes: {},
actions: [
{ kind: 'upgrade', label: 'Upgrade to Pro', url: 'https://hanzo.ai/pay/cart?plan=dev', plan: 'dev' },
{ kind: 'topup', label: 'Add prepaid credit', url: 'https://hanzo.ai/pay' },
],
upgrade: 'dev',
credits_after_allowance: false,
};
const sent = wire([
{ status: 200, body: limits },
{ status: 200, body: { ...limits, credits_after_allowance: true } },
]);
t.after(sent.restore);
const ai = new hanzoai.AiApi(configuration());
const read = await ai.aiLimits();
assert.equal(read.data.plan, 'free');
assert.equal(read.data.credits_after_allowance, false);
assert.equal(sent[0].method, 'GET');
assert.equal(sent[0].url, 'https://api.hanzo.ai/v1/ai/limits');
assert.equal(sent[0].headers.authorization, 'Bearer tok-1');
const set = await ai.aiSetLimits({ aiLimitsSet: { creditsAfterAllowance: true } });
assert.equal(set.data.credits_after_allowance, true);
assert.equal(sent[1].method, 'PUT');
assert.equal(sent[1].url, 'https://api.hanzo.ai/v1/ai/limits');
assert.deepEqual(json(sent[1]), { creditsAfterAllowance: true });
});
test('/v1/sync: list, get, create, and sync now', async (t) => {
const row = {
id: 'sync_713c4bb98167c774be9d6d01a35ac69f',
kind: 'git',
source: { provider: 'github', locator: 'https://github.com/hanzoai/ui.git' },
target: { provider: 'hanzo-git', locator: 'ui' },
direction: 'pull',
trigger: 'poll',
createdAt: '2026-07-22T10:42:41Z',
updatedAt: '2026-10-04T23:06:13Z',
scope: 'repo',
native: {
url: 'https://git.hanzo.ai/hanzoai/hanzoai_ui',
clone: 'https://git.hanzo.ai/hanzoai/hanzoai_ui.git',
ssh: '[email protected]:hanzoai/hanzoai_ui.git',
branch: 'main',
status: 'synced',
sizeBytes: 131655680,
advancedAt: '2026-10-04T23:06:13Z',
},
};
const sent = wire([
{ status: 200, body: { data: [row] } },
{ status: 200, body: row },
{ status: 200, body: row },
{ status: 202, body: { queued: true, id: row.id } },
]);
t.after(sent.restore);
const sync = new hanzoai.SyncApi(configuration());
const list = await sync.getSync();
assert.equal(list.data.data[0].native.status, 'synced');
const one = await sync.getSyncById({ id: row.id });
assert.equal(one.data.source.locator, 'https://github.com/hanzoai/ui.git');
const req = {
kind: 'git',
source: { provider: 'github', locator: 'https://github.com/hanzoai/ui.git' },
target: { provider: 'hanzo-git', locator: 'ui' },
direction: 'pull',
trigger: 'poll',
run: true,
};
await sync.postSync({ syncSyncReq: req });
const now = await sync.postSyncByIdRun({ id: row.id });
assert.deepEqual(now.data, { queued: true, id: row.id });
assert.deepEqual(
sent.map((r) => `${r.method} ${new URL(r.url).pathname}`),
['GET /v1/sync', `GET /v1/sync/${row.id}`, 'POST /v1/sync', `POST /v1/sync/${row.id}/run`],
);
assert.deepEqual(json(sent[2]), req);
});
test('GET /v1/models carries no credential and types class, family and pricing.variable', async (t) => {
const sent = wire([
{
status: 200,
body: {
object: 'list',
data: [
{ id: 'enso', object: 'model', owned_by: 'hanzo', premium: false, class: 'free', family: 'enso', pricing: { prompt: '0', completion: '0', input_per_million: 0, output_per_million: 0 } },
{ id: 'kai', object: 'model', owned_by: 'hanzo', premium: false, class: 'ours', family: 'kai', pricing: { prompt: '0.000000021', completion: '0', input_per_million: 0.021, output_per_million: 0 } },
{ id: 'openrouter/auto', object: 'model', owned_by: 'openrouter', premium: true, class: 'premium', pricing: { prompt: '0.00018', completion: '0.00072', input_per_million: 180, output_per_million: 720, variable: true } },
],
},
},
]);
t.after(sent.restore);
const { data } = await new hanzoai.AiApi(configuration({ accessToken: undefined })).getModels();
assert.equal(sent[0].headers.authorization, undefined);
assert.deepEqual(data.data.filter((m) => m.class === 'ours').map((m) => m.id), ['kai']);
assert.deepEqual(data.data.map((m) => m.family ?? null), ['enso', 'kai', null]);
assert.deepEqual(data.data.filter((m) => m.pricing?.variable).map((m) => m.id), ['openrouter/auto']);
});
test('Client.configuration is the package Configuration, typed refusals included', async (t) => {
const c = client();
assert.ok(c.configuration instanceof hanzoai.Configuration);
const sent = wire([{ status: 200, body: TOKEN }, { status: 429, body: freePlanCap }]);
t.after(sent.restore);
await assert.rejects(
() => new hanzoai.AiApi(c.configuration).postDecisions(decision, { adapter: 'fetch' }),
hanzoai.FreePlanCapError,
);
assert.equal(sent[1].headers.authorization, 'Bearer tok-1');
});
test('no hand-written name hides a generated one, but Configuration', () => {
// `.generated` names every file the generator owns; their exports are the
// names a regeneration can add.
const generated = new Set();
const root = new URL('../', import.meta.url);
for (const f of readFileSync(new URL('.generated', root), 'utf8').split('\n')) {
if (!f.endsWith('.ts')) continue;
for (const m of readFileSync(new URL(f, root), 'utf8').matchAll(/^export (?:interface|type|class|const|function|enum) (\w+)/gm)) {
generated.add(m[1]);
}
}
const entry = readFileSync(new URL('../src/hanzo.ts', import.meta.url), 'utf8');
const named = [...entry.matchAll(/^export (?:type )?\{([^}]*)\}/gm)].flatMap((m) => m[1].split(',').map((n) => n.trim()).filter(Boolean));
assert.ok(named.includes('readUsage') && named.includes('Configuration'));
assert.deepEqual(named.filter((n) => generated.has(n)), ['Configuration']);
});
// The six capabilities' fetch path: what a gated call would throw as a Fault is
// the typed refusal when the body names one; a 402 stays the denied arm.
test('a 429 usage refusal on a gated call throws its type, not a Fault', async (t) => {
const sent = wire([{ status: 200, body: TOKEN }, { status: 429, body: freePlanCap, headers: { 'x-request-id': REQUEST } }]);
t.after(sent.restore);
await assert.rejects(() => client().search.find('runbook'), (err) => {
assert.ok(err instanceof hanzoai.FreePlanCapError);
assert.equal(err instanceof hanzoai.answer.Fault, false);
assert.equal(err.status, 429);
assert.equal(err.request, REQUEST);
assert.equal(err.cause, undefined);
assert.deepEqual(err.resetsAt, new Date('2026-10-05T00:00:00Z'));
return true;
});
});
test('an envelope that is no usage refusal is a Fault carrying the code it nests', async (t) => {
const sent = wire([
{ status: 200, body: TOKEN },
{ status: 400, body: { error: { message: 'unknown model', type: 'invalid_request_error', code: 'model_not_found' } } },
]);
t.after(sent.restore);
await assert.rejects(() => client().search.find('runbook'), (err) => {
assert.ok(err instanceof hanzoai.answer.Fault);
assert.equal(err.code, 'model_not_found');
assert.equal(err.detail, 'unknown model');
return true;
});
});
test('a usage refusal on a read throws its type', async (t) => {
const sent = wire([{ status: 200, body: TOKEN }, { status: 402, body: insufficientBalance }]);
t.after(sent.restore);
await assert.rejects(() => client().budget.balance(), hanzoai.InsufficientBalanceError);
});
test('a 402 in the OpenAI envelope is denied on the code it nests', async (t) => {
const sent = wire([{ status: 200, body: TOKEN }, { status: 402, body: planAllowanceUsed, headers: { 'x-request-id': REQUEST } }]);
t.after(sent.restore);
const a = await client().search.find('runbook');
assert.equal(a.status, 'denied');
assert.equal(a.code, 'plan_allowance_used');
assert.equal(a.reason, planAllowanceUsed.error.message);
assert.equal(a.request, REQUEST);
// A 403 refusal code is read from the envelope too.
const refused = wire([{ status: 200, body: TOKEN }, { status: 403, body: insufficientBalance }]);
t.after(refused.restore);
const b = await client().search.find('runbook');
assert.equal(b.status, 'denied');
assert.equal(b.code, 'insufficient_balance');
});