main
1import Anthropic from "@anthropic-ai/sdk";
2import { LLMProvider, Message, ToolDefinition, LLMResponse } from "./types.js";
3import { TSchema, Kind } from "@sinclair/typebox";
4
5export class AnthropicProvider implements LLMProvider {
6 name = "anthropic";
7 private client: Anthropic;
8 private model: string;
9
10 constructor(apiKey: string, model: string) {
11 this.client = new Anthropic({ apiKey });
12 this.model = model;
13 }
14
15 async chat(
16 messages: Message[],
17 options: {
18 systemPrompt?: string;
19 tools?: ToolDefinition[];
20 maxTokens?: number;
21 }
22 ): Promise<LLMResponse> {
23 const anthropicMessages: Anthropic.MessageParam[] = messages
24 .filter((m) => m.role !== "system")
25 .map((m) => ({
26 role: m.role as "user" | "assistant",
27 content: m.content,
28 }));
29
30 const tools = options.tools?.map((t) => ({
31 name: t.name,
32 description: t.description,
33 input_schema: typeboxToJsonSchema(t.parameters),
34 }));
35
36 const response = await this.client.messages.create({
37 model: this.model,
38 max_tokens: options.maxTokens || 4096,
39 system: options.systemPrompt,
40 messages: anthropicMessages,
41 tools: tools as Anthropic.Tool[],
42 });
43
44 let textContent: string | null = null;
45 const toolCalls: LLMResponse["toolCalls"] = [];
46
47 for (const block of response.content) {
48 if (block.type === "text") {
49 textContent = block.text;
50 } else if (block.type === "tool_use") {
51 toolCalls.push({
52 id: block.id,
53 name: block.name,
54 arguments: block.input as Record<string, unknown>,
55 });
56 }
57 }
58
59 let stopReason: LLMResponse["stopReason"] = "end_turn";
60 if (response.stop_reason === "tool_use") {
61 stopReason = "tool_use";
62 } else if (response.stop_reason === "max_tokens") {
63 stopReason = "max_tokens";
64 }
65
66 return {
67 content: textContent,
68 toolCalls,
69 stopReason,
70 };
71 }
72}
73
74function typeboxToJsonSchema(schema: TSchema): Record<string, unknown> {
75 // Convert TypeBox schema to JSON Schema for Anthropic
76 const result: Record<string, unknown> = {
77 type: getJsonSchemaType(schema),
78 };
79
80 if (schema.description) {
81 result.description = schema.description;
82 }
83
84 if (schema[Kind] === "Object" && schema.properties) {
85 result.properties = {};
86 const required: string[] = [];
87
88 for (const [key, prop] of Object.entries(
89 schema.properties as Record<string, TSchema>
90 )) {
91 (result.properties as Record<string, unknown>)[key] =
92 typeboxToJsonSchema(prop);
93 // In TypeBox, properties are required by default unless wrapped in Type.Optional
94 const optionalSymbol = Symbol.for("TypeBox.Optional");
95 if (prop[Kind] !== "Optional" && !(optionalSymbol in prop)) {
96 required.push(key);
97 }
98 }
99
100 if (required.length > 0) {
101 result.required = required;
102 }
103 }
104
105 if (schema[Kind] === "Array" && schema.items) {
106 result.items = typeboxToJsonSchema(schema.items as TSchema);
107 }
108
109 if (schema[Kind] === "Union" && schema.anyOf) {
110 result.anyOf = (schema.anyOf as TSchema[]).map(typeboxToJsonSchema);
111 delete result.type;
112 }
113
114 if (schema[Kind] === "Literal") {
115 result.const = schema.const;
116 }
117
118 if (schema.default !== undefined) {
119 result.default = schema.default;
120 }
121
122 if (schema.minimum !== undefined) {
123 result.minimum = schema.minimum;
124 }
125
126 if (schema.maximum !== undefined) {
127 result.maximum = schema.maximum;
128 }
129
130 return result;
131}
132
133function getJsonSchemaType(schema: TSchema): string {
134 const kind = schema[Kind];
135 switch (kind) {
136 case "String":
137 return "string";
138 case "Number":
139 case "Integer":
140 return "number";
141 case "Boolean":
142 return "boolean";
143 case "Array":
144 return "array";
145 case "Object":
146 return "object";
147 case "Null":
148 return "null";
149 case "Optional":
150 // Unwrap optional
151 return getJsonSchemaType(schema.anyOf?.[0] || schema);
152 default:
153 return "object";
154 }
155}