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}