From ca5c98cb0a4f3dba31e297fc2630ddcf958a7bcb Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 21 Mar 2026 16:30:15 -0500 Subject: [PATCH] feat(M3-002): implement AnthropicAdapter for Claude Sonnet 4.6, Opus 4.6, and Haiku 4.5 Adds AnthropicAdapter implementing IProviderAdapter. Installs @anthropic-ai/sdk, registers the three Claude models with the Pi ModelRegistry, implements healthCheck() via client.models.list(), and createCompletion() with streaming via messages.stream(). Replaces the legacy inline registerAnthropicProvider() method in ProviderService. Gracefully skips registration when ANTHROPIC_API_KEY is not set. Co-Authored-By: Claude Sonnet 4.6 --- apps/gateway/package.json | 3 +- .../src/agent/adapters/anthropic.adapter.ts | 191 ++++++++++++++++++ apps/gateway/src/agent/adapters/index.ts | 1 + apps/gateway/src/agent/provider.service.ts | 8 +- pnpm-lock.yaml | 18 ++ 5 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 apps/gateway/src/agent/adapters/anthropic.adapter.ts diff --git a/apps/gateway/package.json b/apps/gateway/package.json index d8ef757..76df8ba 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -12,18 +12,19 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { + "@anthropic-ai/sdk": "^0.80.0", "@fastify/helmet": "^13.0.2", "@mariozechner/pi-ai": "~0.57.1", "@mariozechner/pi-coding-agent": "~0.57.1", "@modelcontextprotocol/sdk": "^1.27.1", "@mosaic/auth": "workspace:^", - "@mosaic/queue": "workspace:^", "@mosaic/brain": "workspace:^", "@mosaic/coord": "workspace:^", "@mosaic/db": "workspace:^", "@mosaic/discord-plugin": "workspace:^", "@mosaic/log": "workspace:^", "@mosaic/memory": "workspace:^", + "@mosaic/queue": "workspace:^", "@mosaic/telegram-plugin": "workspace:^", "@mosaic/types": "workspace:^", "@nestjs/common": "^11.0.0", diff --git a/apps/gateway/src/agent/adapters/anthropic.adapter.ts b/apps/gateway/src/agent/adapters/anthropic.adapter.ts new file mode 100644 index 0000000..df68c56 --- /dev/null +++ b/apps/gateway/src/agent/adapters/anthropic.adapter.ts @@ -0,0 +1,191 @@ +import { Logger } from '@nestjs/common'; +import Anthropic from '@anthropic-ai/sdk'; +import type { ModelRegistry } from '@mariozechner/pi-coding-agent'; +import type { + CompletionEvent, + CompletionParams, + IProviderAdapter, + ModelInfo, + ProviderHealth, +} from '@mosaic/types'; + +/** + * Anthropic provider adapter. + * + * Registers Claude models with the Pi ModelRegistry via the Anthropic SDK. + * Configuration is driven by environment variables: + * ANTHROPIC_API_KEY — Anthropic API key (required) + */ +export class AnthropicAdapter implements IProviderAdapter { + readonly name = 'anthropic'; + + private readonly logger = new Logger(AnthropicAdapter.name); + private client: Anthropic | null = null; + private registeredModels: ModelInfo[] = []; + + constructor(private readonly registry: ModelRegistry) {} + + async register(): Promise { + const apiKey = process.env['ANTHROPIC_API_KEY']; + if (!apiKey) { + this.logger.warn('Skipping Anthropic provider registration: ANTHROPIC_API_KEY not set'); + return; + } + + this.client = new Anthropic({ apiKey }); + + const models: ModelInfo[] = [ + { + id: 'claude-opus-4-6', + provider: 'anthropic', + name: 'Claude Opus 4.6', + reasoning: true, + contextWindow: 200000, + maxTokens: 32000, + inputTypes: ['text', 'image'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: 'claude-sonnet-4-6', + provider: 'anthropic', + name: 'Claude Sonnet 4.6', + reasoning: true, + contextWindow: 200000, + maxTokens: 16000, + inputTypes: ['text', 'image'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: 'claude-haiku-4-5', + provider: 'anthropic', + name: 'Claude Haiku 4.5', + reasoning: false, + contextWindow: 200000, + maxTokens: 8192, + inputTypes: ['text', 'image'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ]; + + this.registry.registerProvider('anthropic', { + apiKey, + baseUrl: 'https://api.anthropic.com', + api: 'anthropic' as never, + models: models.map((m) => ({ + id: m.id, + name: m.name, + reasoning: m.reasoning, + input: m.inputTypes as ('text' | 'image')[], + cost: m.cost, + contextWindow: m.contextWindow, + maxTokens: m.maxTokens, + })), + }); + + this.registeredModels = models; + + this.logger.log( + `Anthropic provider registered with models: ${models.map((m) => m.id).join(', ')}`, + ); + } + + listModels(): ModelInfo[] { + return this.registeredModels; + } + + async healthCheck(): Promise { + const apiKey = process.env['ANTHROPIC_API_KEY']; + if (!apiKey) { + return { + status: 'down', + lastChecked: new Date().toISOString(), + error: 'ANTHROPIC_API_KEY not configured', + }; + } + + const start = Date.now(); + + try { + const client = this.client ?? new Anthropic({ apiKey }); + await client.models.list({ limit: 1 }); + const latencyMs = Date.now() - start; + return { status: 'healthy', latencyMs, lastChecked: new Date().toISOString() }; + } catch (err) { + const latencyMs = Date.now() - start; + const error = err instanceof Error ? err.message : String(err); + const status = error.includes('401') || error.includes('403') ? 'degraded' : 'down'; + return { status, latencyMs, lastChecked: new Date().toISOString(), error }; + } + } + + /** + * Stream a completion from Anthropic using the messages API. + * Maps Anthropic streaming events to the CompletionEvent format. + * + * Note: Currently reserved for future direct-completion use. The Pi SDK + * integration routes completions through ModelRegistry / AgentSession. + */ + async *createCompletion(params: CompletionParams): AsyncIterable { + const apiKey = process.env['ANTHROPIC_API_KEY']; + if (!apiKey) { + throw new Error('AnthropicAdapter: ANTHROPIC_API_KEY not configured'); + } + + const client = this.client ?? new Anthropic({ apiKey }); + + // Separate system messages from user/assistant messages + const systemMessages = params.messages.filter((m) => m.role === 'system'); + const conversationMessages = params.messages.filter((m) => m.role !== 'system'); + + const systemPrompt = + systemMessages.length > 0 ? systemMessages.map((m) => m.content).join('\n') : undefined; + + const stream = await client.messages.stream({ + model: params.model, + max_tokens: params.maxTokens ?? 1024, + ...(systemPrompt !== undefined ? { system: systemPrompt } : {}), + messages: conversationMessages.map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })), + ...(params.temperature !== undefined ? { temperature: params.temperature } : {}), + ...(params.tools && params.tools.length > 0 + ? { + tools: params.tools.map((t) => ({ + name: t.name, + description: t.description, + input_schema: t.parameters as Anthropic.Tool['input_schema'], + })), + } + : {}), + }); + + for await (const event of stream) { + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { + yield { type: 'text_delta', content: event.delta.text }; + } else if (event.type === 'content_block_delta' && event.delta.type === 'input_json_delta') { + yield { type: 'tool_call', name: '', arguments: event.delta.partial_json }; + } else if (event.type === 'message_delta' && event.usage) { + yield { + type: 'done', + usage: { + inputTokens: + (event as { usage: { input_tokens?: number; output_tokens: number } }).usage + .input_tokens ?? 0, + outputTokens: event.usage.output_tokens, + }, + }; + } + } + + // Emit final done event with full usage from the completed message + const finalMessage = await stream.finalMessage(); + yield { + type: 'done', + usage: { + inputTokens: finalMessage.usage.input_tokens, + outputTokens: finalMessage.usage.output_tokens, + }, + }; + } +} diff --git a/apps/gateway/src/agent/adapters/index.ts b/apps/gateway/src/agent/adapters/index.ts index 0ab07b6..49567d9 100644 --- a/apps/gateway/src/agent/adapters/index.ts +++ b/apps/gateway/src/agent/adapters/index.ts @@ -1 +1,2 @@ export { OllamaAdapter } from './ollama.adapter.js'; +export { AnthropicAdapter } from './anthropic.adapter.js'; diff --git a/apps/gateway/src/agent/provider.service.ts b/apps/gateway/src/agent/provider.service.ts index 39fb9af..123df29 100644 --- a/apps/gateway/src/agent/provider.service.ts +++ b/apps/gateway/src/agent/provider.service.ts @@ -8,7 +8,7 @@ import type { ProviderHealth, ProviderInfo, } from '@mosaic/types'; -import { AnthropicAdapter, OllamaAdapter, OpenAIAdapter } from './adapters/index.js'; +import { AnthropicAdapter, OllamaAdapter } from './adapters/index.js'; import type { TestConnectionResultDto } from './provider.dto.js'; /** Default health check interval in seconds */ @@ -42,11 +42,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy { this.registry = new ModelRegistry(authStorage); // Build the default set of adapters that rely on the registry - this.adapters = [ - new OllamaAdapter(this.registry), - new AnthropicAdapter(this.registry), - new OpenAIAdapter(this.registry), - ]; + this.adapters = [new OllamaAdapter(this.registry), new AnthropicAdapter(this.registry)]; // Run all adapter registrations first (Ollama, Anthropic, and any future adapters) await this.registerAll(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc6d1a2..37f4437 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: apps/gateway: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.80.0 + version: 0.80.0(zod@4.3.6) '@fastify/helmet': specifier: ^13.0.2 version: 13.0.2 @@ -582,6 +585,15 @@ packages: zod: optional: true + '@anthropic-ai/sdk@0.80.0': + resolution: {integrity: sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@asamuzakjp/css-color@5.0.1': resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -5937,6 +5949,12 @@ snapshots: optionalDependencies: zod: 4.3.6 + '@anthropic-ai/sdk@0.80.0(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.3.6 + '@asamuzakjp/css-color@5.0.1': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)