import { describe, expect, it } from "bun:test";
import { encodeResponse, encodeStream, parseRequest } from "../src/providers/anthropic-messages-server";
import type { AssistantMessage, AssistantMessageEvent, ToolResultMessage } from "../src/types";
import { AssistantMessageEventStream } from "../src/utils/event-stream";
function emptyUsage(): AssistantMessage["usage"] {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function makeStream(events: AssistantMessageEvent[]): AssistantMessageEventStream {
const s = new AssistantMessageEventStream();
queueMicrotask(() => {
for (const ev of events) s.push(ev);
s.end();
});
return s;
}
interface SseEvent {
event: string;
data: Record<string, unknown>;
}
async function collectSse(stream: ReadableStream<Uint8Array>): Promise<SseEvent[]> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buf = "";
const out: SseEvent[] = [];
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
}
buf += decoder.decode();
for (const chunk of buf.split("\n\n")) {
if (!chunk.trim()) continue;
let event = "";
let dataLine = "";
for (const line of chunk.split("\n")) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ")) dataLine = line.slice(6);
}
out.push({ event, data: JSON.parse(dataLine) as Record<string, unknown> });
}
return out;
}
describe("anthropic-messages parseRequest", () => {
it("parses system + user + assistant(thinking,text,tool_use) + tool_result", () => {
const parsed = parseRequest({
model: "claude-opus-4-7",
max_tokens: 1024,
temperature: 0.2,
top_p: 0.9,
stop_sequences: ["\n\n"],
tool_choice: { type: "any" },
thinking: { type: "enabled", budget_tokens: 2048 },
system: [
{ type: "text", text: "You are X" },
{ type: "text", text: "Be brief." },
],
tools: [
{
name: "lookup",
description: "find a thing",
input_schema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] },
},
],
messages: [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "hmm", signature: "sig-1" },
{ type: "redacted_thinking", data: "REDACTED" },
{ type: "text", text: "calling tool" },
{ type: "tool_use", id: "toolu_abc", name: "lookup", input: { q: "x" } },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_abc",
content: [{ type: "text", text: "result text" }],
is_error: false,
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_def",
content: "string body",
is_error: true,
},
{ type: "text", text: "and another result coming" },
],
},
],
});
expect(parsed.modelId).toBe("claude-opus-4-7");
expect(parsed.stream).toBe(false);
expect(parsed.context.systemPrompt).toEqual(["You are X\n\nBe brief."]);
expect(parsed.options.maxOutputTokens).toBe(1024);
expect(parsed.options.temperature).toBe(0.2);
expect(parsed.options.topP).toBe(0.9);
expect(parsed.options.stopSequences).toEqual(["\n\n"]);
expect(parsed.options.toolChoice).toBe("required");
expect(parsed.options.explicitThinkingBudgetTokens).toBe(2048);
expect(parsed.options.extra).toBeUndefined();
expect(parsed.context.tools).toHaveLength(1);
const tool = parsed.context.tools![0]!;
expect(tool.name).toBe("lookup");
expect(tool.description).toBe("find a thing");
expect(tool.parameters).toEqual({
type: "object",
properties: { q: { type: "string" } },
required: ["q"],
});
const msgs = parsed.context.messages;
expect(msgs).toHaveLength(5);
expect(msgs[0]).toMatchObject({ role: "user", content: "hi" });
const asst = msgs[1];
expect(asst.role).toBe("assistant");
if (asst.role !== "assistant") throw new Error();
expect(asst.content).toEqual([
{ type: "thinking", thinking: "hmm", thinkingSignature: "sig-1" },
{ type: "redactedThinking", data: "REDACTED" },
{ type: "text", text: "calling tool" },
{ type: "toolCall", id: "toolu_abc", name: "lookup", arguments: { q: "x" } },
]);
expect(asst.api).toBe("anthropic-messages");
expect(asst.provider).toBe("anthropic");
expect(asst.model).toBe("claude-opus-4-7");
const tr1 = msgs[2] as ToolResultMessage;
expect(tr1.role).toBe("toolResult");
expect(tr1.toolCallId).toBe("toolu_abc");
expect(tr1.isError).toBe(false);
expect(tr1.content).toEqual([{ type: "text", text: "result text" }]);
const tr2 = msgs[3] as ToolResultMessage;
expect(tr2.role).toBe("toolResult");
expect(tr2.toolCallId).toBe("toolu_def");
expect(tr2.isError).toBe(true);
expect(tr2.content).toEqual([{ type: "text", text: "string body" }]);
expect(msgs[4]).toMatchObject({ role: "user", content: "and another result coming" });
});
it("maps tool_choice variants and suppresses user wrappers that hold only tool_result", () => {
const auto = parseRequest({
model: "m",
max_tokens: 8,
tool_choice: { type: "auto" },
messages: [{ role: "user", content: "hi" }],
});
expect(auto.options.toolChoice).toBe("auto");
const named = parseRequest({
model: "m",
max_tokens: 8,
tool_choice: { type: "tool", name: "lookup" },
messages: [{ role: "user", content: "hi" }],
});
expect(named.options.toolChoice).toEqual({ name: "lookup" });
const onlyResult = parseRequest({
model: "m",
max_tokens: 8,
messages: [
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "t1", content: [{ type: "text", text: "ok" }] }],
},
],
});
expect(onlyResult.context.messages).toHaveLength(1);
expect(onlyResult.context.messages[0]!.role).toBe("toolResult");
});
it("splits user text/image blocks into a separate UserMessage before a tool_result", () => {
const parsed = parseRequest({
model: "m",
max_tokens: 8,
messages: [
{
role: "user",
content: [
{ type: "text", text: "preface text" },
{ type: "tool_result", tool_use_id: "t1", content: "ok" },
],
},
],
});
expect(parsed.context.messages).toHaveLength(2);
expect(parsed.context.messages[0]).toMatchObject({ role: "user", content: "preface text" });
expect(parsed.context.messages[1]!.role).toBe("toolResult");
});
it("rejects missing required fields and unsupported request controls", () => {
expect(() => parseRequest({})).toThrow(/model/);
expect(() => parseRequest({ model: "m", messages: [] })).toThrow(/max_tokens/);
expect(() => parseRequest({ model: "m", max_tokens: 1 })).toThrow(/messages/);
const topK = parseRequest({ model: "m", max_tokens: 1, messages: [{ role: "user", content: "hi" }], top_k: 50 });
expect(topK.options.topK).toBe(50);
const withMetadata = parseRequest({
model: "m",
max_tokens: 1,
messages: [{ role: "user", content: "hi" }],
metadata: { user_id: "u_1" },
});
expect(withMetadata.options.extra).toBeUndefined();
expect(withMetadata.options.metadata).toEqual({ user_id: "u_1" });
});
});
describe("anthropic-messages encodeResponse", () => {
it("encodes text + thinking + tool_use with correct ordering and stop_reason mapping", () => {
const message: AssistantMessage = {
role: "assistant",
content: [
{ type: "thinking", thinking: "let me think", thinkingSignature: "sig-xyz" },
{ type: "text", text: "calling tool now" },
{ type: "toolCall", id: "toolu_999", name: "lookup", arguments: { q: "hello" } },
],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-opus-4-7",
usage: { ...emptyUsage(), input: 12, output: 34, cacheRead: 5, cacheWrite: 7, totalTokens: 58 },
stopReason: "toolUse",
timestamp: 1000,
};
const encoded = encodeResponse(message, "claude-opus-4-7");
expect(encoded.type).toBe("message");
expect(encoded.role).toBe("assistant");
expect(encoded.model).toBe("claude-opus-4-7");
expect(encoded.stop_reason).toBe("tool_use");
expect(encoded.stop_sequence).toBeNull();
expect(encoded.usage).toEqual({
input_tokens: 12,
output_tokens: 34,
cache_read_input_tokens: 5,
cache_creation_input_tokens: 7,
});
expect(encoded.content).toEqual([
{ type: "thinking", thinking: "let me think", signature: "sig-xyz" },
{ type: "text", text: "calling tool now" },
{ type: "tool_use", id: "toolu_999", name: "lookup", input: { q: "hello" } },
]);
expect(typeof encoded.id).toBe("string");
expect((encoded.id as string).startsWith("msg_")).toBe(true);
});
it("maps stop reasons and rejects upstream terminal errors", () => {
const base: AssistantMessage = {
role: "assistant",
content: [],
api: "anthropic-messages",
provider: "anthropic",
model: "m",
usage: emptyUsage(),
stopReason: "stop",
timestamp: 0,
};
expect(encodeResponse({ ...base, stopReason: "stop" }, "m").stop_reason).toBe("end_turn");
expect(encodeResponse({ ...base, stopReason: "length" }, "m").stop_reason).toBe("max_tokens");
expect(encodeResponse({ ...base, stopReason: "toolUse" }, "m").stop_reason).toBe("tool_use");
expect(() => encodeResponse({ ...base, stopReason: "error", errorMessage: "upstream failed" }, "m")).toThrow(
/upstream failed/,
);
expect(() => encodeResponse({ ...base, stopReason: "aborted", errorMessage: "request aborted" }, "m")).toThrow(
/request aborted/,
);
});
});
describe("anthropic-messages encodeStream", () => {
it("emits thinking_delta + signature_delta + text_delta + tool_use input_json_delta + message_stop", async () => {
const finalMessage: AssistantMessage = {
role: "assistant",
content: [
{ type: "thinking", thinking: "thoughts", thinkingSignature: "SIG" },
{ type: "text", text: "hi there" },
{ type: "toolCall", id: "toolu_1", name: "go", arguments: { x: 1 } },
],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-opus-4-7",
usage: { ...emptyUsage(), input: 11, output: 42, cacheRead: 3, cacheWrite: 5 },
stopReason: "toolUse",
timestamp: 0,
};
const partialAfterThinkingEnd: AssistantMessage = {
...finalMessage,
content: [{ type: "thinking", thinking: "thoughts", thinkingSignature: "SIG" }],
};
const partialAtToolStart: AssistantMessage = {
...finalMessage,
content: [
{ type: "thinking", thinking: "thoughts", thinkingSignature: "SIG" },
{ type: "text", text: "hi there" },
{ type: "toolCall", id: "toolu_1", name: "go", arguments: {} },
],
};
const events: AssistantMessageEvent[] = [
{ type: "start", partial: finalMessage },
{ type: "thinking_start", contentIndex: 0, partial: finalMessage },
{ type: "thinking_delta", contentIndex: 0, delta: "thoughts", partial: finalMessage },
{ type: "thinking_end", contentIndex: 0, content: "thoughts", partial: partialAfterThinkingEnd },
{ type: "text_start", contentIndex: 1, partial: finalMessage },
{ type: "text_delta", contentIndex: 1, delta: "hi ", partial: finalMessage },
{ type: "text_delta", contentIndex: 1, delta: "there", partial: finalMessage },
{ type: "text_end", contentIndex: 1, content: "hi there", partial: finalMessage },
{ type: "toolcall_start", contentIndex: 2, partial: partialAtToolStart },
{ type: "toolcall_delta", contentIndex: 2, delta: '{"x":', partial: partialAtToolStart },
{ type: "toolcall_delta", contentIndex: 2, delta: "1}", partial: partialAtToolStart },
{
type: "toolcall_end",
contentIndex: 2,
toolCall: { type: "toolCall", id: "toolu_1", name: "go", arguments: { x: 1 } },
partial: finalMessage,
},
{ type: "done", reason: "toolUse", message: finalMessage },
];
const sse = await collectSse(encodeStream(makeStream(events), "claude-opus-4-7"));
const types = sse.map(e => e.event);
expect(types).toEqual([
"message_start",
"content_block_start",
"content_block_delta",
"content_block_delta",
"content_block_stop",
"content_block_start",
"content_block_delta",
"content_block_delta",
"content_block_stop",
"content_block_start",
"content_block_delta",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
]);
const start = sse[0]!.data as {
type: string;
message: { id: string; model: string; role: string; usage: Record<string, unknown> };
};
expect(start.type).toBe("message_start");
expect(start.message.model).toBe("claude-opus-4-7");
expect(start.message.role).toBe("assistant");
expect(start.message.id.startsWith("msg_")).toBe(true);
expect(start.message.usage).toEqual({
input_tokens: 11,
output_tokens: 42,
cache_read_input_tokens: 3,
cache_creation_input_tokens: 5,
});
expect(sse[1]!.data).toEqual({
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: "" },
});
expect(sse[2]!.data).toEqual({
type: "content_block_delta",
index: 0,
delta: { type: "thinking_delta", thinking: "thoughts" },
});
expect(sse[3]!.data).toEqual({
type: "content_block_delta",
index: 0,
delta: { type: "signature_delta", signature: "SIG" },
});
expect(sse[4]!.data).toEqual({ type: "content_block_stop", index: 0 });
expect(sse[5]!.data).toEqual({
type: "content_block_start",
index: 1,
content_block: { type: "text", text: "" },
});
expect(sse[6]!.data).toEqual({
type: "content_block_delta",
index: 1,
delta: { type: "text_delta", text: "hi " },
});
expect(sse[9]!.data).toEqual({
type: "content_block_start",
index: 2,
content_block: { type: "tool_use", id: "toolu_1", name: "go", input: {} },
});
expect(sse[10]!.data).toEqual({
type: "content_block_delta",
index: 2,
delta: { type: "input_json_delta", partial_json: '{"x":' },
});
expect(sse[13]!.data).toEqual({
type: "message_delta",
delta: { stop_reason: "tool_use", stop_sequence: null },
usage: {
input_tokens: 11,
output_tokens: 42,
cache_read_input_tokens: 3,
cache_creation_input_tokens: 5,
},
});
expect(sse[14]!.data).toEqual({ type: "message_stop" });
});
it("emits an error event when the upstream stream errors", async () => {
const errMessage: AssistantMessage = {
role: "assistant",
content: [],
api: "anthropic-messages",
provider: "anthropic",
model: "m",
usage: emptyUsage(),
stopReason: "error",
errorMessage: "boom",
timestamp: 0,
};
const events: AssistantMessageEvent[] = [
{ type: "start", partial: errMessage },
{ type: "error", reason: "error", error: errMessage },
];
const sse = await collectSse(encodeStream(makeStream(events), "m"));
const last = sse.at(-1)!;
expect(last.event).toBe("error");
expect(last.data).toEqual({ type: "error", error: { type: "api_error", message: "boom" } });
});
});