feat(M3-002): implement AnthropicAdapter for Claude Sonnet 4.6, Opus 4.6, and Haiku 4.5 (#309)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #309.
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 { 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();
|
||||
|
||||
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