cmte
Version:
Design by Committee™ except it's just you and LLMs
160 lines (141 loc) • 6.01 kB
JavaScript
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);
}