136 lines
4.6 KiB
TypeScript
136 lines
4.6 KiB
TypeScript
import { afterEach, expect, test } from "bun:test";
|
|
import {
|
|
AIError,
|
|
createAI,
|
|
createAIClient,
|
|
deterministicAIProvider,
|
|
type AIProvider,
|
|
} from "../src/index.ts";
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
test("requires an API key before making a request", async () => {
|
|
const previous = process.env.ANTHROPIC_API_KEY;
|
|
delete process.env.ANTHROPIC_API_KEY;
|
|
await expect(createAI().generate("hello")).rejects.toBeInstanceOf(AIError);
|
|
if (previous !== undefined) process.env.ANTHROPIC_API_KEY = previous;
|
|
});
|
|
|
|
test("builds a bounded messages request and joins text blocks", async () => {
|
|
let request: RequestInit | undefined;
|
|
globalThis.fetch = (async (_url, init) => {
|
|
request = init;
|
|
return Response.json({ content: [{ type: "text", text: "Hello" }, { type: "tool_use" }] });
|
|
}) as typeof fetch;
|
|
const text = await createAI({ apiKey: "secret", model: "test-model", maxTokens: 123 }).generate(
|
|
"Hi",
|
|
);
|
|
expect(text).toBe("Hello");
|
|
expect(JSON.parse(String(request?.body))).toMatchObject({
|
|
model: "test-model",
|
|
max_tokens: 123,
|
|
messages: [{ role: "user", content: "Hi" }],
|
|
stream: false,
|
|
});
|
|
});
|
|
|
|
test("streams fragmented SSE and keeps a final event without a newline", async () => {
|
|
const encoder = new TextEncoder();
|
|
globalThis.fetch = (async () =>
|
|
new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(
|
|
encoder.encode('data: {"type":"content_block_delta","delta":{"type":"text_'),
|
|
);
|
|
controller.enqueue(encoder.encode('delta","text":"A"}}\n'));
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"B"}}',
|
|
),
|
|
);
|
|
controller.close();
|
|
},
|
|
}),
|
|
)) as unknown as typeof fetch;
|
|
const chunks: string[] = [];
|
|
for await (const chunk of createAI({ apiKey: "secret" }).stream("Hi")) chunks.push(chunk);
|
|
expect(chunks).toEqual(["A", "B"]);
|
|
});
|
|
|
|
test("generates and validates structured output with an offline provider", async () => {
|
|
const client = createAIClient({
|
|
providers: [deterministicAIProvider({ responses: ['```json\n{"ok":true}\n```'] })],
|
|
});
|
|
const result = await client.generateObject<{ ok: boolean }>("ignored", {
|
|
validate: (value): value is { ok: boolean } =>
|
|
typeof value === "object" && value !== null && "ok" in value,
|
|
});
|
|
expect(result.value).toEqual({ ok: true });
|
|
expect(client.capabilities().deterministic?.structuredOutput).toBe(true);
|
|
});
|
|
|
|
test("retries transient failures and reports redacted metadata", async () => {
|
|
let calls = 0;
|
|
const events: unknown[] = [];
|
|
const provider: AIProvider = {
|
|
name: "retrying",
|
|
async generate() {
|
|
calls++;
|
|
if (calls === 1) throw new AIError("temporary secret-token", 503, "overloaded");
|
|
return { value: "done", provider: "retrying", usage: { totalTokens: 3, costUsd: 0.01 } };
|
|
},
|
|
};
|
|
const result = await createAIClient({
|
|
providers: [provider],
|
|
retry: { attempts: 2, baseDelayMs: 0 },
|
|
onAttempt: (event) => {
|
|
events.push(event);
|
|
},
|
|
}).generate("private prompt");
|
|
expect(result.value).toBe("done");
|
|
expect(calls).toBe(2);
|
|
expect(JSON.stringify(events)).not.toContain("private prompt");
|
|
expect(JSON.stringify(events)).not.toContain("raw");
|
|
});
|
|
|
|
test("opens a provider circuit and falls back", async () => {
|
|
let failedCalls = 0;
|
|
const failing: AIProvider = {
|
|
name: "failing",
|
|
async generate() {
|
|
failedCalls++;
|
|
throw new AIError("down", 503);
|
|
},
|
|
};
|
|
const client = createAIClient({
|
|
providers: [failing, deterministicAIProvider({ responses: ["one", "two"] })],
|
|
retry: { attempts: 1 },
|
|
circuitBreaker: { failureThreshold: 1, resetAfterMs: 60_000 },
|
|
});
|
|
expect((await client.generate("first")).value).toBe("one");
|
|
expect((await client.generate("second")).value).toBe("two");
|
|
expect(failedCalls).toBe(1);
|
|
});
|
|
|
|
test("executes validated tool calls and forwards cancellation", async () => {
|
|
const client = createAIClient({ providers: [deterministicAIProvider({ responses: ["ok"] })] });
|
|
const result = {
|
|
value: "",
|
|
provider: "deterministic",
|
|
toolCalls: [{ id: "1", name: "double", arguments: { value: 4 } }],
|
|
};
|
|
const output = await client.executeTools(result, [
|
|
{
|
|
name: "double",
|
|
validate: (value): value is { value: number } =>
|
|
typeof value === "object" && value !== null && "value" in value,
|
|
execute: ({ value }) => value * 2,
|
|
},
|
|
]);
|
|
expect(output[0]?.value).toBe(8);
|
|
});
|