/** * AnthropicAdapter - Provider adapter for Anthropic API * * Implements ProviderAdapter contract (contracts §15); DD §12.2. * * @module packages/llm/src/adapters/AnthropicAdapter */ import type { ModelID, ProviderAdapter, ProviderCapabilityMatrix, ProviderCompletionInput, ProviderID, ProviderStreamEvent, } from '@aircoding/contracts' import { AnthropicCanonicalConverter } from '../canonical/AnthropicCanonical.js' export interface AnthropicConfig { api_key?: string base_url?: string max_retries?: number timeout?: number } interface AnthropicApiResponse { id: string type: string role: string content: Array<{ type: string; text?: string; thinking?: string; id?: string; name?: string; input?: unknown }> stop_reason?: string usage?: { input_tokens: number; output_tokens: number } } interface AnthropicApiRequest { model: string messages: Array<{ role: string; content: Array> }> max_tokens: number temperature?: number top_p?: number system?: string stream?: boolean } export class AnthropicAdapter implements ProviderAdapter { readonly provider_id: ProviderID = 'anthropic' private api_key: string private base_url: string private max_retries: number private timeout: number private converter: AnthropicCanonicalConverter constructor(config: AnthropicConfig = {}) { this.api_key = config.api_key || process.env.ANTHROPIC_API_KEY || '' this.base_url = config.base_url || process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com' this.max_retries = config.max_retries || 3 this.timeout = config.timeout || 60000 this.converter = new AnthropicCanonicalConverter() } /** * Known Anthropic models. */ private static readonly KNOWN_MODELS: Array<{ model_id: string display_name: string family: 'claude-opus' | 'claude-sonnet' | 'claude-haiku' }> = [ { model_id: 'claude-opus-4-7-20251119', display_name: 'Claude Opus 4.7', family: 'claude-opus' }, { model_id: 'claude-sonnet-4-6-20250501', display_name: 'Claude Sonnet 4.6', family: 'claude-sonnet' }, { model_id: 'claude-haiku-4-5-20251001', display_name: 'Claude Haiku 4.5', family: 'claude-haiku' }, ] async list_models(): Promise { return AnthropicAdapter.KNOWN_MODELS.map(m => this.capability_matrix(m.model_id, m.display_name, m.family)) } async validate_model(model_id: ModelID): Promise { const known = AnthropicAdapter.KNOWN_MODELS.find(m => m.model_id === model_id) if (known) { return this.capability_matrix(known.model_id, known.display_name, known.family) } // Allow any model that looks like a Claude model (flexible acceptance) if (String(model_id).startsWith('claude-')) { return this.capability_matrix(String(model_id), `Custom Claude ${model_id}`, 'claude-sonnet') } throw new Error(`Unknown Anthropic model: ${model_id}`) } /** * Execute a completion request (implements ProviderAdapter.complete). * Returns an AsyncIterable of ProviderStreamEvent ({type, payload}). */ async *complete(input: ProviderCompletionInput): AsyncIterable { const messages = this.convert_to_anthropic_messages(input.messages as { role: string; content: unknown }[]) const response = await this.make_request({ model: String(input.model_id), messages, max_tokens: input.max_output_tokens ?? 4096, temperature: input.temperature, system: input.system as string | undefined, stream: false, }) // Yield each content block as an event yield { type: 'message_start', payload: { id: response.id, role: response.role } } for (const block of response.content) { if (block.type === 'text' && block.text) { yield { type: 'content_delta', payload: { type: 'text_delta', text: block.text } } } else if (block.type === 'thinking' && block.thinking) { yield { type: 'content_delta', payload: { type: 'thinking_delta', thinking: block.thinking } } } } if (response.usage) { yield { type: 'message_stop', payload: { stop_reason: response.stop_reason || 'end_turn', usage: { output_tokens: response.usage.output_tokens } } } } else { yield { type: 'message_stop', payload: { stop_reason: 'end_turn' } } } } /** * Backward-compat: single-shot complete that returns string content. * Used by MainAgent.classify_via_llm. */ async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number } }> { const response = await this.make_request({ model: options.model || 'claude-haiku-4-5-20251001', messages: this.convert_raw_messages(messages), max_tokens: options.max_tokens || 1024, stream: false, }) return { content: this.extract_content(response), usage: response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined, } } private convert_to_anthropic_messages(messages: Array<{ role: string; content: unknown }>): Array<{ role: string; content: Array> }> { return messages.map(m => { const blocks: Array> = [] if (typeof m.content === 'string') { blocks.push({ type: 'text', text: m.content }) } else if (Array.isArray(m.content)) { for (const c of m.content) { if (typeof c === 'string') blocks.push({ type: 'text', text: c }) else blocks.push(c as Record) } } return { role: m.role, content: blocks } }) } private convert_raw_messages(messages: unknown[]): Array<{ role: string; content: Array> }> { return messages.map(m => { const obj = m as { role: string; content: unknown } if (typeof obj.content === 'string') { return { role: obj.role, content: [{ type: 'text', text: obj.content }] } } if (Array.isArray(obj.content)) { return { role: obj.role, content: obj.content as Array> } } return { role: obj.role, content: [{ type: 'text', text: String(obj.content) }] } }) } private extract_content(response: AnthropicApiResponse): string { return response.content .filter(b => b.type === 'text') .map(b => b.text || '') .join('') } private capability_matrix(model_id: string, display_name: string, family: string): ProviderCapabilityMatrix { return { provider_id: this.provider_id, model_id: model_id as ModelID, display_name, max_output_tokens: 200000, provider_kind: 'anthropic', enabled: true, quality_tier: 'frontier', cost_tier: 'high', conversion: { from_anthropic_canonical: 'lossless' as const, tool_schema: 'native' as const, image_input: 'native' as const, thinking: 'native' as const, cache_control: 'native' as const }, supports: { text_input: true, text_output: true, streaming: true, tool_use: true, parallel_tool_use: true, structured_output: true, json_mode: true, thinking: family === 'claude-opus' || family === 'claude-sonnet', prompt_cache: true, system_prompt: true, image_input: true, image_output: false, audio_input: false, audio_output: false, file_input: true, computer_use: false, long_context: true, batch: false, }, } } private async make_request(body: AnthropicApiRequest): Promise { const response = await fetch(`${this.base_url}/v1/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': this.api_key, 'anthropic-version': '2023-06-01', }, body: JSON.stringify(body), }) if (!response.ok) { const error = await response.text() throw new Error(`Anthropic API error: ${response.status} - ${error}`) } return response.json() as Promise } } export function createAnthropicAdapter(config?: AnthropicConfig): AnthropicAdapter { return new AnthropicAdapter(config) } // Backward-compat export export type AnthropicStreamEvent = ProviderStreamEvent export type { CanonicalMessage } from '../canonical/AnthropicCanonical.js'