From 63f285cc4f3891763fee129dadb9e6328e9a6e21 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 | 33 +-- pnpm-lock.yaml | 18 ++ 5 files changed, 218 insertions(+), 28 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 43cc6d1..476712d 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 { OllamaAdapter } from './adapters/index.js'; +import { AnthropicAdapter, OllamaAdapter } from './adapters/index.js'; import type { TestConnectionResultDto } from './provider.dto.js'; /** DI injection token for the provider adapter array. */ @@ -31,14 +31,13 @@ export class ProviderService implements OnModuleInit { this.registry = new ModelRegistry(authStorage); // Build the default set of adapters that rely on the registry - this.adapters = [new OllamaAdapter(this.registry)]; + this.adapters = [new OllamaAdapter(this.registry), new AnthropicAdapter(this.registry)]; - // Run all adapter registrations first (Ollama, and any future adapters) + // Run all adapter registrations first (Ollama, Anthropic, and any future adapters) await this.registerAll(); - // Register API-key providers directly (Anthropic, OpenAI, Z.ai, custom) - // These do not yet have dedicated adapter classes (M3-002 through M3-005). - this.registerAnthropicProvider(); + // Register API-key providers directly (OpenAI, Z.ai, custom) + // These do not yet have dedicated adapter classes (M3-003 through M3-005). this.registerOpenAIProvider(); this.registerZaiProvider(); this.registerCustomProviders(); @@ -234,29 +233,9 @@ export class ProviderService implements OnModuleInit { // --------------------------------------------------------------------------- // Private helpers — direct registry registration for providers without adapters yet - // (Anthropic, OpenAI, Z.ai will move to adapters in M3-002 through M3-005) + // (OpenAI, Z.ai will move to adapters in M3-003 through M3-005) // --------------------------------------------------------------------------- - private registerAnthropicProvider(): void { - const apiKey = process.env['ANTHROPIC_API_KEY']; - if (!apiKey) { - this.logger.debug('Skipping Anthropic provider registration: ANTHROPIC_API_KEY not set'); - return; - } - - const models = ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5'].map((id) => - this.cloneBuiltInModel('anthropic', id, { maxTokens: 8192 }), - ); - - this.registry.registerProvider('anthropic', { - apiKey, - baseUrl: 'https://api.anthropic.com', - models, - }); - - this.logger.log('Anthropic provider registered with 3 models'); - } - private registerOpenAIProvider(): void { const apiKey = process.env['OPENAI_API_KEY']; if (!apiKey) { 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)