UNPKG

cmte

Version:

Design by Committee™ except it's just you and LLMs

160 lines (141 loc) 6.01 kB
import { Anthropic } from '@anthropic-ai/sdk'; import { BaseLLMClient } from "./base-llm-client.js"; import { logger } from '../../utils/logger.js'; // Define default models const DEFAULT_LITE_MODEL = 'claude-3-haiku-20240307'; const DEFAULT_STANDARD_MODEL = process.env.DEFAULT_MODEL || 'claude-3-5-sonnet-20240620'; // Use Claude 3.5 Sonnet as default export class ClaudeAdapter extends BaseLLMClient { anthropic = null; constructor(config) { // Determine initial model based on env vars FIRST, before super call const initialModel = config?.model || (config?.lite ? DEFAULT_LITE_MODEL : DEFAULT_STANDARD_MODEL); super({ ...config, model: initialModel // Pass the resolved initial model to base constructor }); const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey && process.env.NODE_ENV !== 'test') { throw new Error('ANTHROPIC_API_KEY environment variable is required'); } if (apiKey) { this.anthropic = new Anthropic({ apiKey }); } logger.debug('Claude adapter initialized', { model: this.config.model }); } _getEffectiveModelId(requestedModel, isLite) { if (requestedModel && requestedModel !== 'default') { return requestedModel; // Use the specific model if provided and not 'default' } // If 'default' or null/undefined, use defaults based on lite flag const defaultModel = isLite ? DEFAULT_LITE_MODEL : DEFAULT_STANDARD_MODEL; if (requestedModel === 'default' && defaultModel !== DEFAULT_STANDARD_MODEL) { logger.warn(`Using default model \'${defaultModel}\' instead of requested \'default\'. Set DEFAULT_MODEL env var to override.`); } return defaultModel; } async healthCheck() { if (!this.anthropic) { return false; } try { // Simple health check by sending a minimal request await this.completePrompt('test', { maxTokens: 1 }); return true; } catch (error) { logger.error('Claude health check failed', { error }); return false; } } /** * Complete a conversation with Claude * @param messages Array of message objects with role and content * @param config Optional configuration overrides * @returns Promise resolving to Claude's response */ async completeMessages(messages, config = {}) { if (!this.anthropic) { throw new Error('Claude client not initialized'); } // Ensure config is an object const callSpecificConfig = config || {}; // Determine the effective model ID for *this specific call*, considering call-specific config const effectiveModelId = this._getEffectiveModelId( callSpecificConfig.model || this.config.model, // Prefer call-specific model, fallback to instance default callSpecificConfig.lite ?? this.config.lite // Prefer call-specific lite flag ); // Merge instance config with call-specific config const mergedConfig = this.getMergedConfig(callSpecificConfig); // Handle dry run modes if (this.config.apiDryRun) { const lastMessage = messages[messages.length - 1]; const compressed = await this.createCompressedPrompt(lastMessage.content); return `[API Dry Run] Would process ${messages.length} messages. Last message purpose: ${compressed}`; } logger.debug(`Sending message to Claude model: ${effectiveModelId}`); return await this.enqueueRequest(async () => { // Use withExponentialBackoff for retries return await this.withExponentialBackoff(async () => { const stream = await this.anthropic.messages.create({ model: effectiveModelId, messages: messages.map(msg => ({ role: msg.role, content: msg.content })), temperature: mergedConfig.temperature, max_tokens: mergedConfig.maxTokens, stream: true // Explicitly enable streaming }); // Handle the streaming response let fullResponse = ""; for await (const event of stream) { if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { fullResponse += event.delta.text; } } logger.debug('Claude stream finished.'); return fullResponse; }); }); } async completePrompt(prompt, config) { // Convert single prompt to message format return this.completeMessages([{ role: 'user', content: prompt }], config); } async createCompressedPrompt(content) { // Get the first 200 characters to show the prompt structure const promptPreview = content.substring(0, 200); // Extract headings to understand the structure const headings = []; const headingRegex = /^#+\s+(.+)$/gm; let match; while ((match = headingRegex.exec(content)) !== null) { headings.push(match[1]); } // Count code blocks and their languages const codeBlocks = {}; const codeBlockRegex = /```([a-zA-Z0-9]*)/g; while ((match = codeBlockRegex.exec(content)) !== null) { const language = match[1] || 'text'; codeBlocks[language] = (codeBlocks[language] || 0) + 1; } // Extract variables used in the prompt const variables = []; const variableRegex = /\{\{([^}]+)\}\}/g; while ((match = variableRegex.exec(content)) !== null) { variables.push(match[1]); } return ["# Compressed Prompt for API Dry Run", "", "## Prompt Preview", promptPreview + (content.length > 200 ? "..." : ""), "", "## Structure", `- Length: ${content.length} chars`, `- Sections: ${headings.length}`, headings.length > 0 ? `- Headings: ${headings.join(", ")}` : "", "", "## Code Blocks", Object.entries(codeBlocks).map(([lang, count]) => `- ${lang || "plain"}: ${count}`).join("\n"), "", "## Variables", variables.length > 0 ? variables.map(v => `- ${v}`).join("\n") : "- No variables"].join("\n"); } } export default function getClaudeClient(options) { return new ClaudeAdapter(options); }