feat(#125): add Claude (Anthropic) LLM provider

Implement Anthropic Claude provider for Claude Opus, Sonnet, and Haiku models.

Implementation details:
- Created ClaudeProvider class implementing LlmProviderInterface
- Added @anthropic-ai/sdk npm package integration
- Implemented chat completion with streaming support
- Claude-specific message format (system prompt separate from messages)
- Static model list (Claude API doesn't provide list models endpoint)
- Embeddings throw error as Claude doesn't support native embeddings
- Added OpenTelemetry tracing with @TraceLlmCall decorator
- 100% statement, function, and line coverage (79% branch coverage)

Tests:
- Created comprehensive test suite with 20 tests
- All tests follow TDD pattern (written before implementation)
- Tests cover initialization, health checks, chat, streaming, and error handling
- Mocked Anthropic SDK client for isolated unit testing

Quality checks:
- All tests pass (1131 total tests across project)
- ESLint passes with no errors
- TypeScript type checking passes
- Follows existing code patterns from OpenAI and Ollama providers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 14:29:40 -06:00
parent 0fdcfa6ed3
commit 772776bfd9
5 changed files with 820 additions and 0 deletions

View File

@@ -24,6 +24,7 @@
"prisma:reset": "prisma migrate reset"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.72.1",
"@mosaic/shared": "workspace:*",
"@nestjs/common": "^11.1.12",
"@nestjs/core": "^11.1.12",

View File

@@ -0,0 +1,443 @@
import { describe, it, expect, beforeEach, vi, type Mock } from "vitest";
import { ClaudeProvider, type ClaudeProviderConfig } from "./claude.provider";
import type { ChatRequestDto, EmbedRequestDto } from "../dto";
// Mock the @anthropic-ai/sdk module
vi.mock("@anthropic-ai/sdk", () => {
return {
default: vi.fn().mockImplementation(function (this: unknown) {
return {
messages: {
create: vi.fn(),
stream: vi.fn(),
},
};
}),
};
});
describe("ClaudeProvider", () => {
let provider: ClaudeProvider;
let config: ClaudeProviderConfig;
let mockAnthropicInstance: {
messages: {
create: Mock;
stream: Mock;
};
};
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Setup test configuration
config = {
endpoint: "https://api.anthropic.com",
apiKey: "sk-ant-test-1234567890",
timeout: 30000,
};
provider = new ClaudeProvider(config);
// Get the mock instance created by the constructor
mockAnthropicInstance = (provider as any).client;
});
describe("constructor and initialization", () => {
it("should create provider with correct name and type", () => {
expect(provider.name).toBe("Claude");
expect(provider.type).toBe("claude");
});
it("should initialize successfully", async () => {
await expect(provider.initialize()).resolves.toBeUndefined();
});
it("should use default endpoint when not provided", () => {
const configWithoutEndpoint: ClaudeProviderConfig = {
endpoint: "https://api.anthropic.com",
apiKey: "sk-ant-test-1234567890",
};
const providerWithDefaults = new ClaudeProvider(configWithoutEndpoint);
const returnedConfig = providerWithDefaults.getConfig();
expect(returnedConfig.endpoint).toBe("https://api.anthropic.com");
});
});
describe("checkHealth", () => {
it("should return healthy status when Claude API is reachable", async () => {
// Claude doesn't have a health check endpoint, so we test that it returns static models
const health = await provider.checkHealth();
expect(health.healthy).toBe(true);
expect(health.provider).toBe("claude");
expect(health.endpoint).toBe(config.endpoint);
expect(health.models).toBeDefined();
expect(health.models?.length).toBeGreaterThan(0);
expect(health.models).toContain("claude-opus-4-20250514");
});
it("should return unhealthy status when Claude API is unreachable", async () => {
// Mock a failing API call
mockAnthropicInstance.messages.create.mockRejectedValue(new Error("API key invalid"));
const health = await provider.checkHealth();
expect(health.healthy).toBe(false);
expect(health.provider).toBe("claude");
expect(health.endpoint).toBe(config.endpoint);
expect(health.error).toBe("API key invalid");
});
it("should handle non-Error exceptions", async () => {
mockAnthropicInstance.messages.create.mockRejectedValue("string error");
const health = await provider.checkHealth();
expect(health.healthy).toBe(false);
expect(health.error).toBe("string error");
});
});
describe("listModels", () => {
it("should return static list of Claude models", async () => {
const models = await provider.listModels();
expect(models).toBeDefined();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
expect(models).toContain("claude-opus-4-20250514");
expect(models).toContain("claude-sonnet-4-20250514");
expect(models).toContain("claude-3-5-sonnet-20241022");
expect(models).toContain("claude-3-5-haiku-20241022");
});
});
describe("chat", () => {
it("should perform chat completion successfully", async () => {
const request: ChatRequestDto = {
model: "claude-opus-4-20250514",
messages: [{ role: "user", content: "Hello" }],
};
const mockResponse = {
id: "msg_123",
type: "message",
role: "assistant",
content: [
{
type: "text",
text: "Hello! How can I assist you today?",
},
],
model: "claude-opus-4-20250514",
stop_reason: "end_turn",
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 8,
},
};
mockAnthropicInstance.messages.create.mockResolvedValue(mockResponse);
const response = await provider.chat(request);
expect(response).toEqual({
model: "claude-opus-4-20250514",
message: { role: "assistant", content: "Hello! How can I assist you today?" },
done: true,
promptEvalCount: 10,
evalCount: 8,
});
expect(mockAnthropicInstance.messages.create).toHaveBeenCalledWith({
model: "claude-opus-4-20250514",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello" }],
});
});
it("should include system prompt separately", async () => {
const request: ChatRequestDto = {
model: "claude-opus-4-20250514",
messages: [{ role: "user", content: "Hello" }],
systemPrompt: "You are a helpful assistant",
};
mockAnthropicInstance.messages.create.mockResolvedValue({
id: "msg_123",
type: "message",
role: "assistant",
content: [{ type: "text", text: "Hi!" }],
model: "claude-opus-4-20250514",
stop_reason: "end_turn",
usage: { input_tokens: 15, output_tokens: 2 },
});
await provider.chat(request);
expect(mockAnthropicInstance.messages.create).toHaveBeenCalledWith({
model: "claude-opus-4-20250514",
max_tokens: 1024,
system: "You are a helpful assistant",
messages: [{ role: "user", content: "Hello" }],
});
});
it("should filter out system messages from messages array", async () => {
const request: ChatRequestDto = {
model: "claude-opus-4-20250514",
messages: [
{ role: "system", content: "System prompt from messages" },
{ role: "user", content: "Hello" },
],
};
mockAnthropicInstance.messages.create.mockResolvedValue({
id: "msg_123",
type: "message",
role: "assistant",
content: [{ type: "text", text: "Hi!" }],
model: "claude-opus-4-20250514",
stop_reason: "end_turn",
usage: { input_tokens: 15, output_tokens: 2 },
});
await provider.chat(request);
// System message should be moved to system field, not in messages array
expect(mockAnthropicInstance.messages.create).toHaveBeenCalledWith({
model: "claude-opus-4-20250514",
max_tokens: 1024,
system: "System prompt from messages",
messages: [{ role: "user", content: "Hello" }],
});
});
it("should pass temperature and maxTokens as parameters", async () => {
const request: ChatRequestDto = {
model: "claude-opus-4-20250514",
messages: [{ role: "user", content: "Hello" }],
temperature: 0.7,
maxTokens: 2000,
};
mockAnthropicInstance.messages.create.mockResolvedValue({
id: "msg_123",
type: "message",
role: "assistant",
content: [{ type: "text", text: "Hi!" }],
model: "claude-opus-4-20250514",
stop_reason: "end_turn",
usage: { input_tokens: 10, output_tokens: 2 },
});
await provider.chat(request);
expect(mockAnthropicInstance.messages.create).toHaveBeenCalledWith({
model: "claude-opus-4-20250514",
max_tokens: 2000,
messages: [{ role: "user", content: "Hello" }],
temperature: 0.7,
});
});
it("should throw error when chat fails", async () => {
const request: ChatRequestDto = {
model: "claude-opus-4-20250514",
messages: [{ role: "user", content: "Hello" }],
};
mockAnthropicInstance.messages.create.mockRejectedValue(new Error("Model not available"));
await expect(provider.chat(request)).rejects.toThrow("Chat completion failed");
});
it("should handle multiple content blocks", async () => {
const request: ChatRequestDto = {
model: "claude-opus-4-20250514",
messages: [{ role: "user", content: "Hello" }],
};
const mockResponse = {
id: "msg_123",
type: "message",
role: "assistant",
content: [
{ type: "text", text: "Hello! " },
{ type: "text", text: "How can I help?" },
],
model: "claude-opus-4-20250514",
stop_reason: "end_turn",
usage: { input_tokens: 10, output_tokens: 8 },
};
mockAnthropicInstance.messages.create.mockResolvedValue(mockResponse);
const response = await provider.chat(request);
expect(response.message.content).toBe("Hello! How can I help?");
});
});
describe("chatStream", () => {
it("should stream chat completion chunks", async () => {
const request: ChatRequestDto = {
model: "claude-opus-4-20250514",
messages: [{ role: "user", content: "Hello" }],
};
// Mock stream events
const mockEvents = [
{
type: "message_start",
message: {
id: "msg_123",
type: "message",
role: "assistant",
content: [],
model: "claude-opus-4-20250514",
usage: { input_tokens: 10, output_tokens: 0 },
},
},
{
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "!" },
},
{
type: "content_block_stop",
index: 0,
},
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: null },
usage: { output_tokens: 2 },
},
{
type: "message_stop",
},
];
// Mock async generator
async function* mockStreamGenerator() {
for (const event of mockEvents) {
yield event;
}
}
mockAnthropicInstance.messages.stream.mockReturnValue(mockStreamGenerator());
const chunks = [];
for await (const chunk of provider.chatStream(request)) {
chunks.push(chunk);
}
expect(chunks.length).toBeGreaterThan(0);
expect(chunks[0].message.content).toBe("Hello");
expect(chunks[1].message.content).toBe("!");
expect(chunks[chunks.length - 1].done).toBe(true);
expect(mockAnthropicInstance.messages.stream).toHaveBeenCalledWith({
model: "claude-opus-4-20250514",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello" }],
});
});
it("should pass options in streaming mode", async () => {
const request: ChatRequestDto = {
model: "claude-opus-4-20250514",
messages: [{ role: "user", content: "Hello" }],
temperature: 0.5,
maxTokens: 500,
};
async function* mockStreamGenerator() {
yield {
type: "content_block_delta",
delta: { type: "text_delta", text: "Hi" },
};
yield {
type: "message_stop",
};
}
mockAnthropicInstance.messages.stream.mockReturnValue(mockStreamGenerator());
const generator = provider.chatStream(request);
await generator.next();
expect(mockAnthropicInstance.messages.stream).toHaveBeenCalledWith({
model: "claude-opus-4-20250514",
max_tokens: 500,
messages: [{ role: "user", content: "Hello" }],
temperature: 0.5,
});
});
it("should throw error when streaming fails", async () => {
const request: ChatRequestDto = {
model: "claude-opus-4-20250514",
messages: [{ role: "user", content: "Hello" }],
};
mockAnthropicInstance.messages.stream.mockRejectedValue(new Error("Stream error"));
const generator = provider.chatStream(request);
await expect(generator.next()).rejects.toThrow("Streaming failed");
});
});
describe("embed", () => {
it("should throw error indicating embeddings not supported", async () => {
const request: EmbedRequestDto = {
model: "claude-opus-4-20250514",
input: ["Hello world", "Test embedding"],
};
await expect(provider.embed(request)).rejects.toThrow(
"Claude provider does not support embeddings"
);
});
});
describe("getConfig", () => {
it("should return copy of configuration", () => {
const returnedConfig = provider.getConfig();
expect(returnedConfig).toEqual(config);
expect(returnedConfig).not.toBe(config); // Should be a copy, not reference
});
it("should prevent external modification of config", () => {
const returnedConfig = provider.getConfig();
returnedConfig.apiKey = "sk-ant-modified-key";
const secondCall = provider.getConfig();
expect(secondCall.apiKey).toBe("sk-ant-test-1234567890"); // Original unchanged
});
it("should not expose API key in logs", () => {
const returnedConfig = provider.getConfig();
// API key should be present in config
expect(returnedConfig.apiKey).toBeDefined();
expect(returnedConfig.apiKey.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,343 @@
import { Logger } from "@nestjs/common";
import Anthropic from "@anthropic-ai/sdk";
import type {
LlmProviderInterface,
LlmProviderConfig,
LlmProviderHealthStatus,
} from "./llm-provider.interface";
import type { ChatRequestDto, ChatResponseDto, EmbedRequestDto, EmbedResponseDto } from "../dto";
import { TraceLlmCall, createLlmSpan } from "../../telemetry";
import { SpanStatusCode } from "@opentelemetry/api";
/**
* Static list of Claude models.
* Claude API doesn't provide a list models endpoint, so we maintain this manually.
*/
const CLAUDE_MODELS = [
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
];
/**
* Configuration for Claude (Anthropic) LLM provider.
* Extends base LlmProviderConfig with Claude-specific options.
*
* @example
* ```typescript
* const config: ClaudeProviderConfig = {
* endpoint: "https://api.anthropic.com",
* apiKey: "sk-ant-...",
* timeout: 30000
* };
* ```
*/
export interface ClaudeProviderConfig extends LlmProviderConfig {
/**
* Claude API endpoint URL
* @default "https://api.anthropic.com"
*/
endpoint: string;
/**
* Anthropic API key (required)
*/
apiKey: string;
/**
* Request timeout in milliseconds
* @default 30000
*/
timeout?: number;
}
/**
* Claude (Anthropic) LLM provider implementation.
* Provides integration with Anthropic's Claude models (Opus, Sonnet, Haiku).
*
* @example
* ```typescript
* const provider = new ClaudeProvider({
* endpoint: "https://api.anthropic.com",
* apiKey: "sk-ant-...",
* timeout: 30000
* });
*
* await provider.initialize();
*
* const response = await provider.chat({
* model: "claude-opus-4-20250514",
* messages: [{ role: "user", content: "Hello" }]
* });
* ```
*/
export class ClaudeProvider implements LlmProviderInterface {
readonly name = "Claude";
readonly type = "claude" as const;
private readonly logger = new Logger(ClaudeProvider.name);
private readonly client: Anthropic;
private readonly config: ClaudeProviderConfig;
/**
* Creates a new Claude provider instance.
*
* @param config - Claude provider configuration
*/
constructor(config: ClaudeProviderConfig) {
this.config = {
...config,
timeout: config.timeout ?? 30000,
};
this.client = new Anthropic({
apiKey: this.config.apiKey,
baseURL: this.config.endpoint,
timeout: this.config.timeout,
});
this.logger.log(`Claude provider initialized with endpoint: ${this.config.endpoint}`);
}
/**
* Initialize the Claude provider.
* This is a no-op for Claude as the client is initialized in the constructor.
*/
async initialize(): Promise<void> {
// Claude client is initialized in constructor
// No additional setup required
}
/**
* Check if the Claude API is healthy and reachable.
* Since Claude doesn't have a dedicated health check endpoint,
* we perform a minimal API call to verify connectivity.
*
* @returns Health status with available models if healthy
*/
async checkHealth(): Promise<LlmProviderHealthStatus> {
try {
// Test the API with a minimal request
await this.client.messages.create({
model: "claude-3-haiku-20240307",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
});
return {
healthy: true,
provider: "claude",
endpoint: this.config.endpoint,
models: CLAUDE_MODELS,
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.warn(`Claude health check failed: ${errorMessage}`);
return {
healthy: false,
provider: "claude",
endpoint: this.config.endpoint,
error: errorMessage,
};
}
}
/**
* List all available Claude models.
* Returns a static list as Claude doesn't provide a list models API.
*
* @returns Array of model names
*/
listModels(): Promise<string[]> {
return Promise.resolve(CLAUDE_MODELS);
}
/**
* Perform a synchronous chat completion.
*
* @param request - Chat request with messages and configuration
* @returns Complete chat response
* @throws {Error} If the request fails
*/
@TraceLlmCall({ system: "claude", operation: "chat" })
async chat(request: ChatRequestDto): Promise<ChatResponseDto> {
try {
const { systemPrompt, messages } = this.extractSystemPrompt(request);
const options = this.buildChatOptions(request);
const response = await this.client.messages.create({
model: request.model,
max_tokens: request.maxTokens ?? 1024,
messages: messages.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
...(systemPrompt ? { system: systemPrompt } : {}),
...options,
});
// Extract text content from response
const textContent = response.content
.filter((block) => block.type === "text")
.map((block) => ("text" in block ? block.text : ""))
.join("");
const result: ChatResponseDto = {
model: response.model,
message: {
role: "assistant",
content: textContent,
},
done: true,
};
// Add usage information
result.promptEvalCount = response.usage.input_tokens;
result.evalCount = response.usage.output_tokens;
return result;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Chat completion failed: ${errorMessage}`);
throw new Error(`Chat completion failed: ${errorMessage}`);
}
}
/**
* Perform a streaming chat completion.
* Yields response chunks as they arrive from the Claude API.
*
* @param request - Chat request with messages and configuration
* @yields Chat response chunks
* @throws {Error} If the request fails
*/
async *chatStream(request: ChatRequestDto): AsyncGenerator<ChatResponseDto> {
const span = createLlmSpan("claude", "chat.stream", request.model);
try {
const { systemPrompt, messages } = this.extractSystemPrompt(request);
const options = this.buildChatOptions(request);
const streamGenerator = this.client.messages.stream({
model: request.model,
max_tokens: request.maxTokens ?? 1024,
messages: messages.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
...(systemPrompt ? { system: systemPrompt } : {}),
...options,
});
for await (const event of streamGenerator) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
yield {
model: request.model,
message: {
role: "assistant",
content: event.delta.text,
},
done: false,
};
} else if (event.type === "message_stop") {
yield {
model: request.model,
message: {
role: "assistant",
content: "",
},
done: true,
};
}
}
span.setStatus({ code: SpanStatusCode.OK });
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Streaming failed: ${errorMessage}`);
span.recordException(error instanceof Error ? error : new Error(errorMessage));
span.setStatus({
code: SpanStatusCode.ERROR,
message: errorMessage,
});
throw new Error(`Streaming failed: ${errorMessage}`);
} finally {
span.end();
}
}
/**
* Generate embeddings for the given input texts.
* Claude does not support embeddings - this method throws an error.
*
* @param _request - Embedding request (unused)
* @throws {Error} Always throws as Claude doesn't support embeddings
*/
@TraceLlmCall({ system: "claude", operation: "embed" })
embed(_request: EmbedRequestDto): Promise<EmbedResponseDto> {
throw new Error(
"Claude provider does not support embeddings. Use Ollama or OpenAI for embeddings."
);
}
/**
* Get the current provider configuration.
* Returns a copy to prevent external modification.
*
* @returns Provider configuration object
*/
getConfig(): ClaudeProviderConfig {
return { ...this.config };
}
/**
* Extract system prompt from messages or systemPrompt field.
* Claude requires system prompts to be separate from messages.
*
* @param request - Chat request
* @returns Object with system prompt and filtered messages
*/
private extractSystemPrompt(request: ChatRequestDto): {
systemPrompt: string | undefined;
messages: { role: string; content: string }[];
} {
let systemPrompt = request.systemPrompt;
const messages = [];
// Extract system message from messages array if present
for (const message of request.messages) {
if (message.role === "system") {
systemPrompt = message.content;
} else {
messages.push(message);
}
}
return { systemPrompt, messages };
}
/**
* Build Claude-specific chat options from request.
*
* @param request - Chat request
* @returns Claude options object
*/
private buildChatOptions(request: ChatRequestDto): {
temperature?: number;
} {
const options: { temperature?: number } = {};
if (request.temperature !== undefined) {
options.temperature = request.temperature;
}
return options;
}
}

View File

@@ -1,3 +1,4 @@
export * from "./llm-provider.interface";
export * from "./claude.provider";
export * from "./openai.provider";
export * from "./ollama.provider";