converse-mcp-server
Version:
Converse MCP Server - Converse with other LLMs with chat and consensus tools
574 lines (509 loc) • 20.7 kB
JavaScript
/**
* Anthropic Provider
*
* Provider implementation for Anthropic Claude models using the official @anthropic-ai/sdk.
* Implements the unified interface: async invoke(messages, options) => { content, stop_reason, rawResponse }
*
* Note: The Anthropic SDK is ESM-only starting from v0.27.0. This provider dynamically imports
* the SDK to maintain compatibility with CommonJS environments.
*/
import { debugLog, debugError } from '../utils/console.js';
import { ProviderError, ErrorCodes, StopReasons } from './interface.js';
// Define supported Claude models with their capabilities
const SUPPORTED_MODELS = {
'claude-opus-4-1-20250805': {
modelName: 'claude-opus-4-1-20250805',
friendlyName: 'Claude Opus 4.1',
contextWindow: 200000,
maxOutputTokens: 32000,
supportsStreaming: true,
supportsImages: true,
supportsTemperature: true,
supportsWebSearch: false,
supportsThinking: true,
minThinkingTokens: 1024,
maxThinkingTokens: 32000,
timeout: 300000,
description: 'Claude Opus 4.1 - Highest level of intelligence and capability with extended thinking',
aliases: ['claude-opus-4-1', 'claude-4.1-opus', 'claude-4-1-opus', 'opus-4.1', 'opus-4-1', 'claude-4-opus', 'opus-4', 'opus', 'claude-opus', 'claude-opus-4', 'opus4', 'opus4.1', 'claude-opus-4.1']
},
'claude-sonnet-4-20250514': {
modelName: 'claude-sonnet-4-20250514',
friendlyName: 'Claude Sonnet 4',
contextWindow: 200000,
maxOutputTokens: 64000,
supportsStreaming: true,
supportsImages: true,
supportsTemperature: true,
supportsWebSearch: false,
supportsThinking: true,
minThinkingTokens: 1024,
maxThinkingTokens: 64000,
timeout: 300000,
description: 'Claude Sonnet 4 - High intelligence and balanced performance with extended thinking',
aliases: ['claude-4-sonnet', 'sonnet-4', 'sonnet', 'claude-sonnet', 'claude-sonnet-4', 'sonnet4']
},
'claude-3-7-sonnet-20250219': {
modelName: 'claude-3-7-sonnet-20250219',
friendlyName: 'Claude 3.7 Sonnet',
contextWindow: 200000,
maxOutputTokens: 64000,
supportsStreaming: true,
supportsImages: true,
supportsTemperature: true,
supportsWebSearch: false,
supportsThinking: true,
minThinkingTokens: 1024,
maxThinkingTokens: 64000,
timeout: 300000,
description: 'Claude 3.7 Sonnet - Enhanced 3.x generation with thinking',
aliases: ['claude-3.7-sonnet', 'sonnet-3.7', 'claude-3-7-sonnet', 'claude 3.7 sonnet', 'sonnet 3.7']
},
'claude-3-5-sonnet-20241022': {
modelName: 'claude-3-5-sonnet-20241022',
friendlyName: 'Claude 3.5 Sonnet',
contextWindow: 200000,
maxOutputTokens: 8192,
supportsStreaming: true,
supportsImages: true,
supportsTemperature: true,
supportsWebSearch: false,
supportsThinking: false,
timeout: 300000,
description: 'Claude 3.5 Sonnet - Fast and intelligent model',
aliases: ['claude-3.5-sonnet', 'claude-3-5-sonnet-latest', 'claude-sonnet-3.5', 'sonnet-3.5', 'claude 3.5 sonnet', 'sonnet 3.5']
},
'claude-3-5-haiku-20241022': {
modelName: 'claude-3-5-haiku-20241022',
friendlyName: 'Claude 3.5 Haiku',
contextWindow: 200000,
maxOutputTokens: 8192,
supportsStreaming: true,
supportsImages: false,
supportsTemperature: true,
supportsWebSearch: false,
supportsThinking: false,
timeout: 180000,
description: 'Claude 3.5 Haiku - Fastest model, best for simple queries',
aliases: ['claude-3.5-haiku', 'claude-3-5-haiku-latest', 'haiku', 'claude-haiku', 'haiku-3.5', 'claude 3.5 haiku', 'haiku 3.5']
}
};
/**
* Map Anthropic stop reasons to unified format
*/
const STOP_REASON_MAP = {
'end_turn': StopReasons.STOP,
'max_tokens': StopReasons.LENGTH,
'stop_sequence': StopReasons.STOP,
'tool_use': StopReasons.TOOL_USE
};
/**
* Thinking budget percentages mapped to reasoning_effort
*/
const THINKING_BUDGETS = {
minimal: 0.05, // 5% of max thinking tokens
low: 0.15, // 15% of max thinking tokens
medium: 0.33, // 33% of max thinking tokens (default)
high: 0.67, // 67% of max thinking tokens
max: 1.0 // 100% of max thinking tokens
};
/**
* Custom error class for Anthropic provider errors
*/
class AnthropicProviderError extends ProviderError {
constructor(message, code, originalError = null) {
super(message, code, originalError);
this.name = 'AnthropicProviderError';
}
}
/**
* Resolve model name to canonical form, including aliases
*/
function resolveModelName(modelName) {
const modelNameLower = modelName.toLowerCase();
// Check exact matches first
for (const [supportedModel] of Object.entries(SUPPORTED_MODELS)) {
if (supportedModel.toLowerCase() === modelNameLower) {
return supportedModel;
}
}
// Check aliases
for (const [supportedModel, config] of Object.entries(SUPPORTED_MODELS)) {
if (config.aliases) {
for (const alias of config.aliases) {
if (alias.toLowerCase() === modelNameLower) {
return supportedModel;
}
}
}
}
// Return as-is if not found (let Anthropic API handle unknown models)
return modelName;
}
/**
* Validate Anthropic API key format
*/
function validateApiKey(apiKey) {
if (!apiKey || typeof apiKey !== 'string') {
return false;
}
// Anthropic API keys typically start with 'sk-ant-' and are at least 30 characters
return apiKey.startsWith('sk-ant-') && apiKey.length >= 30;
}
/**
* Convert messages to Anthropic format
* Anthropic has specific requirements:
* - System messages must be passed separately
* - Messages must alternate between user and assistant
* - First message must be from user
* - System can now be an array with cache control blocks
*/
function convertMessagesToAnthropic(messages, options = {}) {
if (!Array.isArray(messages)) {
throw new AnthropicProviderError('Messages must be an array', ErrorCodes.INVALID_MESSAGES);
}
const {
enableSystemCache = true, // Always cache system messages by default
cacheUserMessages = false,
cacheMessageThreshold = 5 // Cache messages after this many turns
} = options;
const systemContent = [];
let systemText = '';
const anthropicMessages = [];
for (const [index, msg] of messages.entries()) {
if (!msg || typeof msg !== 'object') {
throw new AnthropicProviderError(`Message at index ${index} must be an object`, ErrorCodes.INVALID_MESSAGE);
}
const { role, content } = msg;
if (!role || !['system', 'user', 'assistant'].includes(role)) {
throw new AnthropicProviderError(`Invalid role "${role}" at message index ${index}`, ErrorCodes.INVALID_ROLE);
}
if (!content) {
throw new AnthropicProviderError(`Message content is required at index ${index}`, ErrorCodes.MISSING_CONTENT);
}
if (role === 'system') {
// Collect system messages
systemText += (systemText ? '\n\n' : '') + content;
} else {
// Handle complex content structure (array with text and images)
if (Array.isArray(content)) {
const anthropicContent = [];
for (const item of content) {
if (item.type === 'text') {
anthropicContent.push({
type: 'text',
text: item.text
});
} else if (item.type === 'image' && item.source) {
// Content is already in Anthropic format
anthropicContent.push({
type: 'image',
source: {
type: 'base64',
media_type: item.source.media_type,
data: item.source.data
}
});
debugLog(`[Anthropic] Processing image: ${item.source.media_type}, data length: ${item.source.data.length}`);
}
}
anthropicMessages.push({
role,
content: anthropicContent
});
} else {
// Simple string content
anthropicMessages.push({
role,
content
});
}
}
}
// Ensure first message is from user
if (anthropicMessages.length > 0 && anthropicMessages[0].role !== 'user') {
throw new AnthropicProviderError('First message must be from user role', ErrorCodes.INVALID_MESSAGE);
}
// Ensure messages alternate between user and assistant
for (let i = 1; i < anthropicMessages.length; i++) {
const prevRole = anthropicMessages[i - 1].role;
const currRole = anthropicMessages[i].role;
if (prevRole === currRole) {
throw new AnthropicProviderError(
`Messages must alternate between user and assistant. Found consecutive ${currRole} messages at index ${i}`,
ErrorCodes.INVALID_MESSAGE
);
}
}
// Build system content based on cache enablement
let systemResult = null;
if (systemText) {
if (enableSystemCache) {
// Use array format with cache control for system prompt
systemResult = [{
type: 'text',
text: systemText,
cache_control: {
type: 'ephemeral',
ttl: '1h' // 1 hour cache duration
}
}];
debugLog(`[Anthropic] System prompt caching enabled (ephemeral with ttl-extender for 1 hour) - ${systemText.length} chars`);
} else {
// Use simple string format without caching
systemResult = systemText;
}
}
return { systemPrompt: systemResult, messages: anthropicMessages };
}
/**
* Calculate thinking budget for models that support it
*/
function calculateThinkingBudget(modelConfig, reasoning_effort) {
if (!modelConfig.supportsThinking || !modelConfig.maxThinkingTokens) {
return 0;
}
const budget = THINKING_BUDGETS[reasoning_effort] || THINKING_BUDGETS.medium;
const calculatedBudget = Math.floor(modelConfig.maxThinkingTokens * budget);
// Ensure minimum thinking tokens
return Math.max(calculatedBudget, modelConfig.minThinkingTokens || 1024);
}
// Lazy load the Anthropic SDK (ESM module)
let AnthropicSDK = null;
async function getAnthropicSDK() {
if (!AnthropicSDK) {
try {
const module = await import('@anthropic-ai/sdk');
AnthropicSDK = module.default || module.Anthropic;
} catch (error) {
throw new AnthropicProviderError(
'Failed to load Anthropic SDK. Please install @anthropic-ai/sdk',
ErrorCodes.API_ERROR,
error
);
}
}
return AnthropicSDK;
}
/**
* Main Anthropic provider implementation
*/
export const anthropicProvider = {
/**
* Unified provider interface: invoke messages with options
* @param {Array} messages - Array of message objects with role and content
* @param {Object} options - Configuration options
* @returns {Object} - { content, stop_reason, rawResponse }
*/
async invoke(messages, options = {}) {
const {
model = 'claude-3-5-sonnet-20241022',
temperature = 0.7,
maxTokens = null,
stream = false,
reasoning_effort = 'medium',
// eslint-disable-next-line no-unused-vars
use_websearch = false, // Not supported by Anthropic, ignored
config,
...otherOptions
} = options;
// Validate API key
if (!config?.apiKeys?.anthropic) {
throw new AnthropicProviderError('Anthropic API key not configured', ErrorCodes.MISSING_API_KEY);
}
if (!validateApiKey(config.apiKeys.anthropic)) {
throw new AnthropicProviderError('Invalid Anthropic API key format', ErrorCodes.INVALID_API_KEY);
}
// Get Anthropic SDK
const Anthropic = await getAnthropicSDK();
// Resolve model name first
const resolvedModel = resolveModelName(model);
const modelConfig = SUPPORTED_MODELS[resolvedModel] || {};
// Initialize Anthropic client with default headers
// Use both prompt caching and extended cache duration headers for 1-hour caching
// Set beta headers for caching
const betaHeaders = ['prompt-caching-2024-07-31', 'extended-cache-ttl-2025-04-11'];
const anthropic = new Anthropic({
apiKey: config.apiKeys.anthropic,
defaultHeaders: {
'anthropic-beta': betaHeaders.join(',')
},
// Increase timeout to 20 minutes for thinking models that may take longer
timeout: 20 * 60 * 1000
});
// Convert messages to Anthropic format (system messages are always cached)
const { systemPrompt, messages: anthropicMessages } = convertMessagesToAnthropic(messages);
// Build request payload
const requestPayload = {
model: resolvedModel,
messages: anthropicMessages,
stream,
...otherOptions
};
// Add system prompt if present
if (systemPrompt) {
requestPayload.system = systemPrompt;
}
// Set max tokens - API requires this field
if (maxTokens) {
requestPayload.max_tokens = Math.min(maxTokens, modelConfig.maxOutputTokens || 8192);
} else {
// Use model's default max output tokens
requestPayload.max_tokens = modelConfig.maxOutputTokens || 8192;
}
// Add thinking configuration for models that support it
if (modelConfig.supportsThinking && reasoning_effort) {
const thinkingBudget = calculateThinkingBudget(modelConfig, reasoning_effort);
debugLog(`[Anthropic] Model ${resolvedModel}: maxOutputTokens=${modelConfig.maxOutputTokens}, maxThinkingTokens=${modelConfig.maxThinkingTokens}, thinkingBudget=${thinkingBudget}`);
// For 4 series models, we trust the SDK defaults work with thinking
// For other models, check against max_tokens if set
const maxTokensLimit = requestPayload.max_tokens ||
(resolvedModel.includes('claude-opus-4') ? 32000 :
resolvedModel.includes('claude-sonnet-4') ? 64000 :
modelConfig.maxOutputTokens);
if (thinkingBudget > 0 && thinkingBudget < maxTokensLimit) {
// According to Anthropic docs: thinking tokens count towards max_tokens limit
// thinking.budget_tokens must be >= 1024 and < max_tokens
requestPayload.thinking = {
type: 'enabled',
budget_tokens: thinkingBudget
};
debugLog(`[Anthropic] Thinking enabled with budget: ${thinkingBudget} tokens (${reasoning_effort} effort)`);
} else {
debugLog(`[Anthropic] Thinking not enabled: budget ${thinkingBudget} must be < max_tokens limit ${maxTokensLimit}`);
}
}
// Add temperature if specified
// When thinking is enabled, temperature must be 1
if (temperature !== undefined) {
if (requestPayload.thinking) {
requestPayload.temperature = 1;
debugLog('[Anthropic] Temperature forced to 1 for thinking mode');
} else {
requestPayload.temperature = Math.max(0, Math.min(1, temperature));
}
}
try {
debugLog(`[Anthropic] Calling ${resolvedModel} with ${anthropicMessages.length} messages`);
debugLog('[Anthropic] Request payload:', JSON.stringify({
model: requestPayload.model,
max_tokens: requestPayload.max_tokens,
thinking: requestPayload.thinking,
temperature: requestPayload.temperature,
message_count: requestPayload.messages?.length,
system_length: Array.isArray(requestPayload.system) ? requestPayload.system[0]?.text?.length : requestPayload.system?.length
}, null, 2));
if (systemPrompt) {
debugLog(`[Anthropic] System prompt length: ${systemPrompt.length} characters`);
}
const startTime = Date.now();
// Make the API call
const response = await anthropic.messages.create(requestPayload);
const responseTime = Date.now() - startTime;
debugLog(`[Anthropic] Response received in ${responseTime}ms`);
// Extract response content
let content = '';
// Handle different content types in the response
if (response.content && Array.isArray(response.content)) {
for (const block of response.content) {
if (block.type === 'text') {
content += block.text;
}
// Handle other content types if needed (tool_use, etc.)
}
} else if (typeof response.content === 'string') {
content = response.content;
}
if (!content) {
throw new AnthropicProviderError('No content in response from Anthropic', ErrorCodes.NO_RESPONSE_CONTENT);
}
// Map stop reason
const stopReason = STOP_REASON_MAP[response.stop_reason] || StopReasons.OTHER;
// Extract usage information
const usage = response.usage || {};
// Return unified response format
return {
content,
stop_reason: stopReason,
rawResponse: response,
metadata: {
model: response.model || resolvedModel,
usage: {
input_tokens: usage.input_tokens || 0,
output_tokens: usage.output_tokens || 0,
total_tokens: (usage.input_tokens || 0) + (usage.output_tokens || 0),
thinking_tokens: usage.thinking_input_tokens || 0,
cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
cache_read_input_tokens: usage.cache_read_input_tokens || 0
},
response_time_ms: responseTime,
finish_reason: response.stop_reason,
provider: 'anthropic',
reasoning_effort: modelConfig.supportsThinking ? reasoning_effort : null
}
};
} catch (error) {
debugError('[Anthropic] Error during API call:', error);
// Re-throw our own errors
if (error instanceof AnthropicProviderError) {
throw error;
}
// Handle specific Anthropic errors
if (error.status === 401) {
throw new AnthropicProviderError('Invalid Anthropic API key', ErrorCodes.INVALID_API_KEY, error);
} else if (error.status === 429) {
throw new AnthropicProviderError('Anthropic rate limit exceeded', ErrorCodes.RATE_LIMIT_EXCEEDED, error);
} else if (error.status === 403) {
throw new AnthropicProviderError('Anthropic API quota exceeded or forbidden', ErrorCodes.QUOTA_EXCEEDED, error);
} else if (error.error?.type === 'invalid_request_error') {
throw new AnthropicProviderError(`Invalid request: ${error.error.message}`, ErrorCodes.INVALID_REQUEST, error);
} else if (error.error?.type === 'not_found_error') {
throw new AnthropicProviderError(`Model ${resolvedModel} not found`, ErrorCodes.MODEL_NOT_FOUND, error);
} else if (error.message?.includes('context length') || error.message?.includes('context_length') ||
(error.message?.includes('token') && error.message?.includes('limit'))) {
debugError('[Anthropic] Context length error - Full error:', error);
debugError('[Anthropic] Error message:', error.message);
debugError('[Anthropic] Error response:', error.response);
throw new AnthropicProviderError(`Context length exceeded for model: ${error.message}`, ErrorCodes.CONTEXT_LENGTH_EXCEEDED, error);
}
// Generic error handling
throw new AnthropicProviderError(
`Anthropic API error: ${error.message || 'Unknown error'}`,
ErrorCodes.API_ERROR,
error
);
}
},
/**
* Validate configuration for Anthropic provider
* @param {Object} config - Configuration object
* @returns {boolean} - True if configuration is valid
*/
validateConfig(config) {
return !!(config?.apiKeys?.anthropic && validateApiKey(config.apiKeys.anthropic));
},
/**
* Check if provider is available with current configuration
* @param {Object} config - Configuration object
* @returns {boolean} - True if provider is available
*/
isAvailable(config) {
return this.validateConfig(config);
},
/**
* Get supported models
* @returns {Object} - Map of supported models and their configurations
*/
getSupportedModels() {
return SUPPORTED_MODELS;
},
/**
* Get model configuration
* @param {string} modelName - Model name
* @returns {Object|null} - Model configuration or null if not found
*/
getModelConfig(modelName) {
const resolved = resolveModelName(modelName);
return SUPPORTED_MODELS[resolved] || null;
}
};