feat(ai): Implement unified AI API with Anthropic provider

- Define clean API with complete() method and callbacks for streaming
- Add comprehensive type system for messages, tools, and usage
- Implement AnthropicAI provider with full feature support:
  - Thinking/reasoning with signatures
  - Tool calling with parallel execution
  - Streaming via callbacks (onText, onThinking)
  - Proper error handling and stop reasons
  - Cache tracking for input/output tokens
- Add working test/example demonstrating tool execution flow
- Support for system prompts, temperature, max tokens
- Proper message role types: user, assistant, toolResult
This commit is contained in:
Mario Zechner 2025-08-17 23:30:20 +02:00
parent f064ea0e14
commit e5aedfed29
7 changed files with 597 additions and 1 deletions

View file

@ -15,7 +15,8 @@
"dependencies": {
"openai": "5.12.2",
"@anthropic-ai/sdk": "0.60.0",
"@google/genai": "1.14.0"
"@google/genai": "1.14.0",
"chalk": "^5.5.0"
},
"devDependencies": {},
"keywords": ["ai", "llm", "openai", "anthropic", "gemini", "unified", "api"],

View file

@ -0,0 +1,246 @@
import Anthropic from "@anthropic-ai/sdk";
import type {
ContentBlockParam,
MessageCreateParamsStreaming,
MessageParam,
Tool,
} from "@anthropic-ai/sdk/resources/messages.js";
import type { AI, AssistantMessage, Event, Message, Request, StopReason, TokenUsage, ToolCall } from "../types.js";
export interface AnthropicOptions {
thinking?: {
enabled: boolean;
budgetTokens?: number;
};
toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };
}
export class AnthropicAI implements AI<AnthropicOptions> {
private client: Anthropic;
private model: string;
constructor(model: string, apiKey?: string, baseUrl?: string) {
if (!apiKey) {
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error(
"Anthropic API key is required. Set ANTHROPIC_API_KEY environment variable or pass it as an argument.",
);
}
apiKey = process.env.ANTHROPIC_API_KEY;
}
this.client = new Anthropic({ apiKey, baseURL: baseUrl });
this.model = model;
}
async complete(request: Request, options?: AnthropicOptions): Promise<AssistantMessage> {
try {
const messages = this.convertMessages(request.messages);
const params: MessageCreateParamsStreaming = {
model: this.model,
messages,
max_tokens: request.maxTokens || 4096,
stream: true,
};
if (request.systemPrompt) {
params.system = request.systemPrompt;
}
if (request.temperature !== undefined) {
params.temperature = request.temperature;
}
if (request.tools) {
params.tools = this.convertTools(request.tools);
}
if (options?.thinking?.enabled) {
params.thinking = {
type: "enabled",
budget_tokens: options.thinking.budgetTokens || 1024,
};
}
if (options?.toolChoice) {
if (typeof options.toolChoice === "string") {
params.tool_choice = { type: options.toolChoice };
} else {
params.tool_choice = options.toolChoice;
}
}
const stream = this.client.messages.stream(
{
...params,
stream: true,
},
{
signal: request.signal,
},
);
for await (const event of stream) {
if (event.type === "content_block_delta") {
if (event.delta.type === "text_delta") {
request.onText?.(event.delta.text);
}
if (event.delta.type === "thinking_delta") {
request.onThinking?.(event.delta.thinking);
}
}
}
const msg = await stream.finalMessage();
const thinking = msg.content.some((block) => block.type === "thinking")
? msg.content
.filter((block) => block.type === "thinking")
.map((block) => block.thinking)
.join("\n")
: undefined;
// This is kinda wrong if there is more than one thinking block. We do not use interleaved thinking though, so we should
// always have a single thinking block.
const thinkingSignature = msg.content.some((block) => block.type === "thinking")
? msg.content
.filter((block) => block.type === "thinking")
.map((block) => block.signature)
.join("\n")
: undefined;
const content = msg.content.some((block) => block.type === "text")
? msg.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n")
: undefined;
const toolCalls: ToolCall[] = msg.content
.filter((block) => block.type === "tool_use")
.map((block) => ({
id: block.id,
name: block.name,
arguments: block.input as Record<string, any>,
}));
const usage: TokenUsage = {
input: msg.usage.input_tokens,
output: msg.usage.output_tokens,
cacheRead: msg.usage.cache_read_input_tokens || 0,
cacheWrite: msg.usage.cache_creation_input_tokens || 0,
// TODO add cost
};
return {
role: "assistant",
content,
thinking,
thinkingSignature,
toolCalls,
model: this.model,
usage,
stopResaon: this.mapStopReason(msg.stop_reason),
};
} catch (error) {
return {
role: "assistant",
model: this.model,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
stopResaon: "error",
error: error instanceof Error ? error.message : String(error),
};
}
}
private convertMessages(messages: Message[]): MessageParam[] {
const params: MessageParam[] = [];
for (const msg of messages) {
if (msg.role === "user") {
params.push({
role: "user",
content: msg.content,
});
} else if (msg.role === "assistant") {
const blocks: ContentBlockParam[] = [];
if (msg.thinking && msg.thinkingSignature) {
blocks.push({
type: "thinking",
thinking: msg.thinking,
signature: msg.thinkingSignature,
});
}
if (msg.content) {
blocks.push({
type: "text",
text: msg.content,
});
}
if (msg.toolCalls) {
for (const toolCall of msg.toolCalls) {
blocks.push({
type: "tool_use",
id: toolCall.id,
name: toolCall.name,
input: toolCall.arguments,
});
}
}
params.push({
role: "assistant",
content: blocks,
});
} else if (msg.role === "toolResult") {
params.push({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: msg.toolCallId,
content: msg.content,
is_error: msg.isError,
},
],
});
}
}
return params;
}
private convertTools(tools: Request["tools"]): Tool[] {
if (!tools) return [];
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
input_schema: {
type: "object" as const,
properties: tool.parameters.properties || {},
required: tool.parameters.required || [],
},
}));
}
private mapStopReason(reason: Anthropic.Messages.StopReason | null): StopReason {
switch (reason) {
case "end_turn":
return "stop";
case "max_tokens":
return "length";
case "tool_use":
return "toolUse";
case "refusal":
return "safety";
case "pause_turn": // Stop is good enough -> resubmit
return "stop";
case "stop_sequence":
return "stop"; // We don't supply stop sequences, so this should never happen
default:
return "stop";
}
}
}

105
packages/ai/src/types.ts Normal file
View file

@ -0,0 +1,105 @@
export interface AI<T = any> {
complete(request: Request, options?: T): Promise<AssistantMessage>;
}
export interface ModelInfo {
id: string;
name: string;
provider: string;
capabilities: {
reasoning: boolean;
toolCall: boolean;
vision: boolean;
audio?: boolean;
};
cost: {
input: number; // per million tokens
output: number; // per million tokens
cacheRead?: number;
cacheWrite?: number;
};
limits: {
context: number;
output: number;
};
knowledge?: string;
}
export interface UserMessage {
role: "user";
content: string;
}
export interface AssistantMessage {
role: "assistant";
thinking?: string;
thinkingSignature?: string; // Leaky abstraction: needed for Anthropic
content?: string;
toolCalls?: {
id: string;
name: string;
arguments: Record<string, any>;
}[];
model: string;
usage: TokenUsage;
stopResaon: StopReason;
error?: string | Error;
}
export interface ToolResultMessage {
role: "toolResult";
content: string;
toolCallId: string;
isError: boolean;
}
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
export interface Tool {
name: string;
description: string;
parameters: Record<string, any>; // JSON Schema
}
export interface Request {
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
temperature?: number;
maxTokens?: number;
onText?: (text: string) => void;
onThinking?: (thinking: string) => void;
signal?: AbortSignal;
}
export type Event =
| { type: "start"; model: string; provider: string }
| { type: "text"; content: string; delta: string }
| { type: "thinking"; content: string; delta: string }
| { type: "toolCall"; toolCall: ToolCall }
| { type: "usage"; usage: TokenUsage }
| { type: "done"; reason: StopReason; message: AssistantMessage }
| { type: "error"; error: Error };
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, any>;
}
export interface TokenUsage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost?: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
}
export type StopReason = "stop" | "length" | "toolUse" | "safety" | "error";

View file

@ -0,0 +1,63 @@
import Anthropic from "@anthropic-ai/sdk";
import { MessageCreateParamsBase } from "@anthropic-ai/sdk/resources/messages.mjs";
import chalk from "chalk";
import { AnthropicAI } from "../../src/providers/anthropic";
import { Request, Message, Tool } from "../../src/types";
const anthropic = new Anthropic();
// Define a simple calculator tool
const tools: Tool[] = [
{
name: "calculate",
description: "Perform a mathematical calculation",
parameters: {
type: "object" as const,
properties: {
expression: {
type: "string",
description: "The mathematical expression to evaluate"
}
},
required: ["expression"]
}
}
];
const ai = new AnthropicAI("claude-sonnet-4-0");
const context: Request = {
messages: [
{
role: "user",
content: "Think about birds briefly. Then give me a list of 10 birds. Finally, calculate 42 * 17 + 123 and 453 + 434 in parallel using the calculator tool.",
}
],
tools,
onText: (t) => process.stdout.write(t),
onThinking: (t) => process.stdout.write(chalk.dim(t))
}
const options = {thinking: { enabled: true }};
let msg = await ai.complete(context, options)
context.messages.push(msg);
console.log(JSON.stringify(msg, null, 2));
for (const toolCall of msg.toolCalls || []) {
if (toolCall.name === "calculate") {
const expression = toolCall.arguments.expression;
const result = eval(expression);
context.messages.push({
role: "toolResult",
content: `The result of ${expression} is ${result}.`,
toolCallId: toolCall.id,
isError: false
});
}
}
msg = await ai.complete(context, options);
console.log(JSON.stringify(msg, null, 2));