Files
STCourses/packages/feature-ai/test/anthropic.test.ts
T

117 lines
7.3 KiB
TypeScript

import { expect, test } from "bun:test";
import type { ChatMessage } from "@stc/contracts";
import { createAnthropicProvider } from "../src/providers/anthropic";
import { collect, fakeFetch, sseBody } from "./helpers";
const ok = (events: [string | null, unknown][]) => () => new Response(sseBody(events), { headers: { "content-type": "text/event-stream" } });
const textTurn: [string, unknown][] = [
["message_start", { type: "message_start", message: { id: "m1", content: [] } }],
["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }],
["ping", { type: "ping" }],
["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello " } }],
["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "world" } }],
["content_block_stop", { type: "content_block_stop", index: 0 }],
["message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" } }],
["message_stop", { type: "message_stop" }],
];
const toolTurn: [string, unknown][] = [
["message_start", { type: "message_start", message: { id: "m2", content: [] } }],
["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "", signature: "" } }],
["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "" } }],
["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "SIG-abc" } }],
["content_block_stop", { type: "content_block_stop", index: 0 }],
["content_block_start", { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }],
["content_block_delta", { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Let me look." } }],
["content_block_stop", { type: "content_block_stop", index: 1 }],
["content_block_start", { type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "toolu_1", name: "search_course", input: {} } }],
["content_block_delta", { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: "{\"que" } }],
["content_block_delta", { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: "ry\": \"add\"}" } }],
["content_block_stop", { type: "content_block_stop", index: 2 }],
["message_delta", { type: "message_delta", delta: { stop_reason: "tool_use" } }],
["message_stop", { type: "message_stop" }],
];
test("streams text and sends a well-formed request", async () => {
const ff = fakeFetch([ok(textTurn)]);
const p = createAnthropicProvider({ apiKey: "sk-test", fetch: ff.fetch });
const msgs: ChatMessage[] = [{ role: "system", content: "You are kind." }, { role: "user", content: "Hi" }];
const r = await collect(p.chat(msgs, { tools: [{ name: "search_course", description: "Search", inputSchema: { type: "object", properties: {} } }] }));
expect(r.text).toBe("Hello world");
const c = ff.calls[0]!;
expect(c.url).toBe("https://api.anthropic.com/v1/messages");
const h = new Headers(c.init.headers);
expect(h.get("x-api-key")).toBe("sk-test");
expect(h.get("anthropic-version")).toBe("2023-06-01");
expect(c.body.model).toBe("claude-opus-5-5");
expect(c.body.stream).toBe(true);
expect(c.body.system).toBe("You are kind.");
expect(c.body.messages).toEqual([{ role: "user", content: [{ type: "text", text: "Hi" }] }]);
expect(c.body.tools).toEqual([{ name: "search_course", description: "Search", input_schema: { type: "object", properties: {} } }]);
expect(c.body.thinking).toBeUndefined();
expect(c.body.temperature).toBeUndefined();
expect(c.body.tool_choice).toBeUndefined();
expect(c.body.fallbacks).toBe("default");
expect(h.get("anthropic-beta")).toBe("server-side-fallback-2026-07-01");
});
test("assembles tool input and replays thinking blocks unchanged on the next call", async () => {
const ff = fakeFetch([ok(toolTurn), ok(textTurn)]);
const p = createAnthropicProvider({ apiKey: "k", fetch: ff.fetch });
const msgs: ChatMessage[] = [{ role: "user", content: "How do I add?" }];
const first = await collect(p.chat(msgs, {}));
expect(first.text).toBe("Let me look.");
expect(first.toolCalls).toEqual([{ id: "toolu_1", name: "search_course", input: { query: "add" } }]);
msgs.push({ role: "assistant", content: "Let me look." });
msgs.push({ role: "assistant", content: JSON.stringify({ query: "add" }), toolCallId: "toolu_1", name: "search_course" });
msgs.push({ role: "tool", content: "lessons/04-exercise.md: ...", toolCallId: "toolu_1", name: "search_course" });
await collect(p.chat(msgs, {}));
const sent = ff.calls[1]!.body.messages;
expect(sent).toHaveLength(3);
expect(sent[1]).toEqual({
role: "assistant",
content: [
{ type: "thinking", thinking: "", signature: "SIG-abc" },
{ type: "text", text: "Let me look." },
{ type: "tool_use", id: "toolu_1", name: "search_course", input: { query: "add" } },
],
});
expect(sent[2]).toEqual({ role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "lessons/04-exercise.md: ..." }] });
});
test("tool calls it did not produce are mapped from the message fields", async () => {
const ff = fakeFetch([ok(textTurn)]);
const p = createAnthropicProvider({ apiKey: "k", fetch: ff.fetch });
await collect(p.chat([
{ role: "user", content: "q" },
{ role: "assistant", content: "{\"x\":1}", toolCallId: "t9", name: "list_notes" },
{ role: "tool", content: "none", toolCallId: "t9" },
], {}));
expect(ff.calls[0]!.body.messages[1]).toEqual({ role: "assistant", content: [{ type: "tool_use", id: "t9", name: "list_notes", input: { x: 1 } }] });
});
test("errors carry the API message", async () => {
const ff = fakeFetch([() => Response.json({ type: "error", error: { type: "authentication_error", message: "invalid x-api-key" } }, { status: 401 })]);
const p = createAnthropicProvider({ apiKey: "bad", fetch: ff.fetch });
await expect(collect(p.chat([{ role: "user", content: "x" }], {}))).rejects.toThrow(/invalid x-api-key/);
const ff2 = fakeFetch([ok([["error", { type: "error", error: { type: "overloaded_error", message: "Overloaded" } }]])]);
await expect(collect(createAnthropicProvider({ apiKey: "k", fetch: ff2.fetch }).chat([{ role: "user", content: "x" }], {}))).rejects.toThrow(/Overloaded/);
});
test("a refusal yields a plain sentence", async () => {
const ff = fakeFetch([ok([["message_start", { type: "message_start", message: { content: [] } }], ["message_delta", { type: "message_delta", delta: { stop_reason: "refusal" } }], ["message_stop", { type: "message_stop" }]])]);
const r = await collect(createAnthropicProvider({ apiKey: "k", fetch: ff.fetch }).chat([{ role: "user", content: "x" }], {}));
expect(r.text).toContain("declined");
});
test("custom base URL, model and effort", async () => {
const ff = fakeFetch([ok(textTurn)]);
await collect(createAnthropicProvider({ apiKey: "k", baseUrl: "http://proxy.local/", model: "claude-sonnet-5-5", effort: "high", fetch: ff.fetch }).chat([{ role: "user", content: "x" }], {}));
expect(ff.calls[0]!.url).toBe("http://proxy.local/v1/messages");
expect(ff.calls[0]!.body.model).toBe("claude-sonnet-5-5");
expect(ff.calls[0]!.body.output_config).toEqual({ effort: "high" });
expect(ff.calls[0]!.body.fallbacks).toBeUndefined();
});