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 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
191
apps/gateway/src/agent/adapters/anthropic.adapter.ts
Normal file
191
apps/gateway/src/agent/adapters/anthropic.adapter.ts
Normal file
@@ -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<void> {
|
||||
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<ProviderHealth> {
|
||||
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<CompletionEvent> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { OllamaAdapter } from './ollama.adapter.js';
|
||||
export { AnthropicAdapter } from './anthropic.adapter.js';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user