mirror of
https://github.com/hanzo-js/kai.git
synced 2026-10-30 15:04:57 +00:00
170 lines
8.0 KiB
TypeScript
170 lines
8.0 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { choice, Kai, KaiError, noul, score, VERSION } from "@hanzo/kai";
|
|
import { DECISION, json, scripted } from "./fetch.ts";
|
|
|
|
const client = (extra: ConstructorParameters<typeof Kai>[0] = {}) => {
|
|
const { fetch, calls } = scripted(() => json(DECISION));
|
|
return { kai: new Kai({ apiKey: "sk-test", baseURL: "https://api.test", fetch, ...extra }), calls };
|
|
};
|
|
|
|
test("decide posts model, state and questions to /v1/decisions with auth and identity headers", async () => {
|
|
const { kai, calls } = client();
|
|
await kai.decide({
|
|
state: { ticket: "I was charged twice." },
|
|
questions: {
|
|
team: choice("Which team?", { billing: "charges", tech: null }),
|
|
refund: noul("The customer asks for money back.", { true: "a refund is requested" }),
|
|
urgency: score("How urgent?", ["can wait", "this week", "today"]),
|
|
},
|
|
});
|
|
assert.equal(calls.length, 1);
|
|
const [call] = calls;
|
|
assert.equal(call?.url, "https://api.test/v1/decisions");
|
|
assert.equal(call?.method, "POST");
|
|
assert.deepEqual(call?.body, {
|
|
model: "kai",
|
|
state: { ticket: "I was charged twice." },
|
|
questions: {
|
|
team: { type: "choice", instructions: "Which team?", criteria: { billing: "charges", tech: null } },
|
|
refund: {
|
|
type: "noul",
|
|
instructions: "The customer asks for money back.",
|
|
criteria: { true: "a refund is requested" },
|
|
},
|
|
urgency: { type: "score", instructions: "How urgent?", criteria: ["can wait", "this week", "today"] },
|
|
},
|
|
});
|
|
const h = call?.headers;
|
|
assert.equal(h?.get("authorization"), "Bearer sk-test");
|
|
assert.equal(h?.get("content-type"), "application/json");
|
|
assert.equal(h?.get("accept"), "application/json");
|
|
assert.equal(h?.get("user-agent"), `@hanzo/kai/${VERSION}`);
|
|
assert.match(h?.get("x-kai-runtime") ?? "", /^node\/\d+\.\d+\.\d+ \([a-z0-9]+; [a-z0-9]+\)$/);
|
|
assert.equal(h?.has("x-kai-retry-count"), false);
|
|
});
|
|
|
|
test("a call's model overrides the client's, and hanzo/kai is sent as given", async () => {
|
|
const { kai, calls } = client({ model: "hanzo/kai" });
|
|
await kai.decide({ state: "s", questions: { q: noul("q?") } });
|
|
await kai.decide({ state: "s", questions: { q: noul("q?") }, model: "kai" });
|
|
assert.equal((calls[0]?.body as { model: string }).model, "hanzo/kai");
|
|
assert.equal((calls[1]?.body as { model: string }).model, "kai");
|
|
});
|
|
|
|
test("optional request fields and fields this client does not know are sent as given", async () => {
|
|
const { kai, calls } = client();
|
|
const request = {
|
|
state: ["line one", { line: 2 }],
|
|
questions: { q: noul({ ask: "Is it spam?" }) },
|
|
session_id: "s-1",
|
|
user: "u-1",
|
|
trace: { run: 7 },
|
|
provider: { order: ["hanzo"] },
|
|
handle: "h-1",
|
|
};
|
|
await kai.decide(request);
|
|
assert.deepEqual(calls[0]?.body, { model: "kai", ...request });
|
|
});
|
|
|
|
test("default and per-call headers merge, the call winning; auth and identity cannot be replaced", async () => {
|
|
const { kai, calls } = client({
|
|
defaultHeaders: { "X-Team": "client", "X-Only-Client": "1", Authorization: "Bearer other", "X-Kai-Retry-Count": "9" },
|
|
});
|
|
await kai.decide(
|
|
{ state: "s", questions: { q: noul("q?") } },
|
|
{ headers: { "X-Team": "call", "X-Only-Call": "1", "User-Agent": "mine" } },
|
|
);
|
|
const h = calls[0]?.headers;
|
|
assert.equal(h?.get("x-team"), "call");
|
|
assert.equal(h?.get("x-only-client"), "1");
|
|
assert.equal(h?.get("x-only-call"), "1");
|
|
assert.equal(h?.get("authorization"), "Bearer sk-test");
|
|
assert.equal(h?.get("user-agent"), `@hanzo/kai/${VERSION}`);
|
|
assert.equal(h?.has("x-kai-retry-count"), false);
|
|
});
|
|
|
|
test("models.list is a GET with no body and no content type", async () => {
|
|
const { fetch, calls } = scripted(() => json({ object: "list", data: [] }));
|
|
await new Kai({ apiKey: "sk-test", baseURL: "https://api.test/", fetch }).models.list();
|
|
assert.equal(calls[0]?.url, "https://api.test/v1/models");
|
|
assert.equal(calls[0]?.method, "GET");
|
|
assert.equal(calls[0]?.body, undefined);
|
|
assert.equal(calls[0]?.headers.has("content-type"), false);
|
|
});
|
|
|
|
test("builders keep what they are given and omit a noul's absent criteria", () => {
|
|
assert.deepEqual(noul("Spam?"), { type: "noul", instructions: "Spam?" });
|
|
assert.deepEqual(noul("Spam?", null), { type: "noul", instructions: "Spam?", criteria: null });
|
|
assert.deepEqual(noul("Spam?", { false: "a real message" }), {
|
|
type: "noul",
|
|
instructions: "Spam?",
|
|
criteria: { false: "a real message" },
|
|
});
|
|
assert.deepEqual(choice("Tone?", ["calm", "angry"]), { type: "choice", instructions: "Tone?", criteria: ["calm", "angry"] });
|
|
const rich = { summary: "charges", examples: ["double charge", "refund"] };
|
|
assert.deepEqual(choice(["Team?", { context: "support" }], { billing: rich, tech: null }).criteria.billing, rich);
|
|
assert.deepEqual(score("Risk?", ["low", { level: "high" }]).criteria, ["low", { level: "high" }]);
|
|
});
|
|
|
|
test("instructions are optional: builders given none send none", async () => {
|
|
assert.deepEqual(noul(), { type: "noul" });
|
|
assert.deepEqual(noul(null, { true: "spam" }), { type: "noul", criteria: { true: "spam" } });
|
|
assert.deepEqual(choice(undefined, { yes: null, no: null }), { type: "choice", criteria: { yes: null, no: null } });
|
|
assert.deepEqual(score(null, ["low", "high"]), { type: "score", criteria: ["low", "high"] });
|
|
const { kai, calls } = client();
|
|
await kai.decide({ state: "s", questions: { q: noul(), c: choice(null, ["a", "b"]) } });
|
|
assert.deepEqual((calls[0]?.body as { questions: unknown }).questions, {
|
|
q: { type: "noul" },
|
|
c: { type: "choice", criteria: ["a", "b"] },
|
|
});
|
|
});
|
|
|
|
test("a noul's labels, the words its two sides go by, reach the wire", async () => {
|
|
const words = { true: "refund", false: "no refund" };
|
|
assert.deepEqual(noul("Refund?", undefined, words), { type: "noul", instructions: "Refund?", labels: words });
|
|
const both = noul("Refund?", { true: "money back is asked for" }, words);
|
|
assert.deepEqual(both, { type: "noul", instructions: "Refund?", criteria: { true: "money back is asked for" }, labels: words });
|
|
const { kai, calls } = client();
|
|
await kai.decide({ state: "s", questions: { refund: both } });
|
|
assert.deepEqual((calls[0]?.body as { questions: unknown }).questions, { refund: both });
|
|
});
|
|
|
|
test("builders refuse criteria of the wrong shape", () => {
|
|
const loose = (v: unknown) => v as never;
|
|
assert.throws(() => score("Risk?", loose({ 0: "low", 1: "high" })), {
|
|
name: "KaiError",
|
|
message: "score criteria must list the levels, lowest first",
|
|
});
|
|
assert.throws(() => choice("Team?", loose("billing")), {
|
|
name: "KaiError",
|
|
message: "choice criteria must map labels to descriptions, or list the labels",
|
|
});
|
|
assert.throws(() => choice("Team?", loose(null)), KaiError);
|
|
});
|
|
|
|
test("decide refuses no questions and malformed criteria before sending anything", () => {
|
|
const { kai, calls } = client();
|
|
const ask = (questions: unknown) => kai.decide({ state: "s", questions: questions as never });
|
|
assert.throws(() => ask({}), { name: "KaiError", message: "at least one question is required" });
|
|
assert.throws(() => ask(undefined), { name: "KaiError", message: "at least one question is required" });
|
|
assert.throws(() => ask({ q: { type: "score", instructions: "x", criteria: { 0: "low" } } }), {
|
|
name: "KaiError",
|
|
message: 'question "q": score criteria must list the levels, lowest first',
|
|
});
|
|
assert.throws(() => ask({ q: { type: "choice", instructions: "x", criteria: "a" } }), {
|
|
name: "KaiError",
|
|
message: 'question "q": choice criteria must map labels to descriptions, or list the labels',
|
|
});
|
|
assert.equal(calls.length, 0);
|
|
});
|
|
|
|
test("what the server can judge is left to it: missing instructions, null levels, many questions", async () => {
|
|
const { kai, calls } = client();
|
|
const questions = Object.fromEntries(
|
|
Array.from({ length: 80 }, (_, i) => [`q${i}`, { type: "score", instructions: "x", criteria: ["a", null] }]),
|
|
);
|
|
await kai.decide({ state: "s", questions: { ...questions, bare: { type: "noul" } } as never });
|
|
assert.equal(calls.length, 1);
|
|
});
|