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)