UNPKG

shipdeck

Version:

Ship MVPs in 48 hours. Fix bugs in 30 seconds. The command deck for developers who ship.

541 lines (473 loc) 16.8 kB
/** * Anthropic API Client Wrapper for Shipdeck Ultimate * Production-ready client with token management, streaming, error handling, and cost tracking */ const Anthropic = require('@anthropic-ai/sdk'); // Model configurations with token limits and costs const MODEL_CONFIGS = { 'claude-3-5-sonnet-20241022': { maxTokens: 200000, inputCostPer1K: 0.003, outputCostPer1K: 0.015, isDefault: false, deprecated: true }, 'claude-3-5-haiku-20241022': { maxTokens: 200000, inputCostPer1K: 0.001, outputCostPer1K: 0.005, isDefault: false }, 'claude-3-opus-20240229': { maxTokens: 200000, inputCostPer1K: 0.015, outputCostPer1K: 0.075, isDefault: false }, 'claude-opus-4-1-20250805': { // Claude 4.1 Opus maxTokens: 200000, inputCostPer1K: 0.015, outputCostPer1K: 0.075, isDefault: true // Use Opus 4.1 as default } }; // Default configuration const DEFAULT_CONFIG = { maxRetries: 3, retryDelay: 1000, timeout: 300000, // 5 minutes maxTokensPerRequest: 4096, temperature: 0.7 // topP removed for Claude 4.1 Opus compatibility }; /** * Custom AnthropicAPIError class that preserves HTTP status codes and headers * for better programmatic error handling */ class AnthropicAPIError extends Error { constructor(message, options = {}) { super(message); this.name = 'AnthropicAPIError'; // Preserve HTTP status and headers this.status = options.status || null; this.statusCode = options.status || null; // Alias for compatibility this.headers = options.headers || {}; // Preserve original error for debugging this.originalError = options.originalError || null; // Additional context this.type = options.type || 'api_error'; this.code = options.code || null; this.param = options.param || null; // Ensure stack trace points to the right location if (Error.captureStackTrace) { Error.captureStackTrace(this, AnthropicAPIError); } } /** * Check if error is retryable based on status code */ isRetryable() { return this.status >= 500 || this.status === 429 || this.status === 408; } /** * Check if error is a rate limit error */ isRateLimit() { return this.status === 429; } /** * Check if error is a server error */ isServerError() { return this.status >= 500; } /** * Get retry delay from headers (for rate limiting) */ getRetryAfter() { if (this.headers['retry-after']) { return parseInt(this.headers['retry-after']) * 1000; // Convert to milliseconds } if (this.headers['x-ratelimit-reset-time']) { const resetTime = new Date(this.headers['x-ratelimit-reset-time']).getTime(); return Math.max(0, resetTime - Date.now()); } return null; } /** * Serialize error for logging (excludes sensitive data) */ toJSON() { return { name: this.name, message: this.message, status: this.status, type: this.type, code: this.code, param: this.param, headers: { 'x-request-id': this.headers['x-request-id'], 'retry-after': this.headers['retry-after'], 'x-ratelimit-reset-time': this.headers['x-ratelimit-reset-time'] } }; } } class AnthropicClient { constructor(options = {}) { this.apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY; if (!this.apiKey) { throw new Error('Anthropic API key is required. Set ANTHROPIC_API_KEY environment variable or pass apiKey option.'); } this.config = { ...DEFAULT_CONFIG, ...options.config }; this.model = options.model || this._getDefaultModel(); this.client = new Anthropic({ apiKey: this.apiKey }); // Usage tracking this.usage = { totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, requestCount: 0, errors: 0 }; // Validate model if (!MODEL_CONFIGS[this.model]) { throw new Error(`Unsupported model: ${this.model}. Supported models: ${Object.keys(MODEL_CONFIGS).join(', ')}`); } } /** * Get default model from configuration */ _getDefaultModel() { return Object.keys(MODEL_CONFIGS).find(model => MODEL_CONFIGS[model].isDefault) || 'claude-opus-4-1-20250805'; } /** * Estimate tokens for text (approximate) */ _estimateTokens(text) { // Rough estimation: ~4 characters per token return Math.ceil(text.length / 4); } /** * Calculate cost based on token usage */ _calculateCost(inputTokens, outputTokens, model = this.model) { const config = MODEL_CONFIGS[model]; const inputCost = (inputTokens / 1000) * config.inputCostPer1K; const outputCost = (outputTokens / 1000) * config.outputCostPer1K; return inputCost + outputCost; } /** * Check if request fits within context window */ _validateContextWindow(messages, model = this.model) { const config = MODEL_CONFIGS[model]; const totalTokens = messages.reduce((sum, msg) => { return sum + this._estimateTokens(typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)); }, 0); if (totalTokens > config.maxTokens * 0.8) { // Use 80% of limit for safety throw new Error(`Request too large: ${totalTokens} tokens estimated, model limit: ${config.maxTokens}`); } return totalTokens; } /** * Sleep function for retry delays */ _sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Handle API errors with proper classification */ _handleError(error, attempt = 1) { this.usage.errors++; // Rate limit errors if (error.status === 429) { const retryAfter = error.headers?.['retry-after'] ? parseInt(error.headers['retry-after']) * 1000 : this.config.retryDelay * Math.pow(2, attempt); return { shouldRetry: attempt < this.config.maxRetries, retryAfter }; } // Server errors (5xx) if (error.status >= 500 && error.status < 600) { return { shouldRetry: attempt < this.config.maxRetries, retryAfter: this.config.retryDelay * Math.pow(2, attempt) }; } // Client errors (4xx) - don't retry if (error.status >= 400 && error.status < 500) { return { shouldRetry: false, retryAfter: 0 }; } // Network/timeout errors if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT' || error.message?.includes('timeout')) { return { shouldRetry: attempt < this.config.maxRetries, retryAfter: this.config.retryDelay * Math.pow(2, attempt) }; } // Unknown errors - don't retry return { shouldRetry: false, retryAfter: 0 }; } /** * Create a message with retry logic and error handling */ async createMessage(messages, options = {}) { // Extract system messages and regular messages let processedMessages = Array.isArray(messages) ? messages : [{ role: 'user', content: messages }]; let systemContent = options.system || ''; // Filter out system messages and combine them const systemMessages = processedMessages.filter(m => m.role === 'system'); processedMessages = processedMessages.filter(m => m.role !== 'system'); if (systemMessages.length > 0) { systemContent = systemMessages.map(m => m.content).join('\n\n') + (systemContent ? '\n\n' + systemContent : ''); } // For Claude 4.1 Opus, only use temperature (not both temperature and top_p) const requestOptions = { model: options.model || this.model, messages: processedMessages, max_tokens: options.maxTokens || this.config.maxTokensPerRequest, temperature: options.temperature ?? this.config.temperature, // Only include top_p if temperature is not set (for Claude 4.1 Opus compatibility) ...(!(options.temperature ?? this.config.temperature) && (options.topP ?? this.config.topP) ? { top_p: options.topP ?? this.config.topP } : {}), stream: options.stream || false, ...(systemContent ? { system: systemContent } : {}), ...options.anthropicOptions }; // Validate context window this._validateContextWindow(requestOptions.messages, requestOptions.model); let attempt = 1; while (attempt <= this.config.maxRetries) { try { this.usage.requestCount++; const startTime = Date.now(); const response = await Promise.race([ this.client.messages.create(requestOptions), new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), this.config.timeout) ) ]); // Track usage if (response.usage) { this.usage.totalInputTokens += response.usage.input_tokens; this.usage.totalOutputTokens += response.usage.output_tokens; this.usage.totalCost += this._calculateCost(response.usage.input_tokens, response.usage.output_tokens, requestOptions.model); } // Add metadata response._metadata = { model: requestOptions.model, requestTime: Date.now() - startTime, attempt, estimatedCost: response.usage ? this._calculateCost(response.usage.input_tokens, response.usage.output_tokens, requestOptions.model) : null }; return response; } catch (error) { // Enhanced error handling with AnthropicAPIError if (error instanceof AnthropicAPIError) { throw error; } // Handle Anthropic SDK errors if (error.name === 'APIError' || error.status) { const apiError = new AnthropicAPIError(error.message || 'Anthropic API error occurred', { status: error.status, headers: error.headers || {}, originalError: error, type: error.type || 'api_error', code: error.code, param: error.param }); const { shouldRetry, retryAfter } = this._handleError(error, attempt); if (!shouldRetry || attempt >= this.config.maxRetries) { throw apiError; } console.warn(`Anthropic API request failed (attempt ${attempt}/${this.config.maxRetries}): ${error.message}. Retrying in ${retryAfter}ms...`); await this._sleep(retryAfter); attempt++; } else { // Handle network and other errors const { shouldRetry, retryAfter } = this._handleError(error, attempt); if (!shouldRetry || attempt >= this.config.maxRetries) { if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT') { throw new AnthropicAPIError(`Network error: ${error.message}`, { status: null, originalError: error, type: 'network_error', code: error.code }); } throw new AnthropicAPIError(error.message || 'Unknown error occurred', { originalError: error, type: 'unknown_error' }); } console.warn(`Request failed (attempt ${attempt}/${this.config.maxRetries}): ${error.message}. Retrying in ${retryAfter}ms...`); await this._sleep(retryAfter); attempt++; } } } } /** * Create a streaming message */ async createStreamingMessage(messages, options = {}) { const streamOptions = { ...options, stream: true }; const stream = await this.createMessage(messages, streamOptions); return this._createStreamWrapper(stream, options.model || this.model); } /** * Create a wrapper around the stream for easier handling */ _createStreamWrapper(stream, model) { let inputTokens = 0; let outputTokens = 0; let fullContent = ''; return { async *[Symbol.asyncIterator]() { try { for await (const chunk of stream) { if (chunk.type === 'message_start') { inputTokens = chunk.message.usage?.input_tokens || 0; } else if (chunk.type === 'content_block_delta') { if (chunk.delta?.text) { fullContent += chunk.delta.text; outputTokens = this._estimateTokens(fullContent); yield { type: 'content', content: chunk.delta.text, fullContent, tokens: { input: inputTokens, output: outputTokens, estimatedCost: this._calculateCost(inputTokens, outputTokens, model) } }; } } else if (chunk.type === 'message_delta') { outputTokens = chunk.usage?.output_tokens || outputTokens; yield { type: 'usage', tokens: { input: inputTokens, output: outputTokens, total: inputTokens + outputTokens, cost: this._calculateCost(inputTokens, outputTokens, model) } }; } } // Update usage tracking this.usage.totalInputTokens += inputTokens; this.usage.totalOutputTokens += outputTokens; this.usage.totalCost += this._calculateCost(inputTokens, outputTokens, model); } catch (error) { this.usage.errors++; throw error; } }, // Helper method to get all content at once async getAllContent() { let content = ''; for await (const chunk of this) { if (chunk.type === 'content') { content = chunk.fullContent; } } return content; } }; } /** * Get current usage statistics */ getUsage() { return { ...this.usage, averageCostPerRequest: this.usage.requestCount > 0 ? this.usage.totalCost / this.usage.requestCount : 0, successRate: this.usage.requestCount > 0 ? ((this.usage.requestCount - this.usage.errors) / this.usage.requestCount) * 100 : 0 }; } /** * Reset usage statistics */ resetUsage() { this.usage = { totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, requestCount: 0, errors: 0 }; } /** * Estimate cost for a message before sending */ estimateMessageCost(messages, options = {}) { const model = options.model || this.model; const messagesArray = Array.isArray(messages) ? messages : [{ role: 'user', content: messages }]; const inputTokens = messagesArray.reduce((sum, msg) => { return sum + this._estimateTokens(typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)); }, 0); const maxOutputTokens = options.maxTokens || this.config.maxTokensPerRequest; return { estimatedInputTokens: inputTokens, maxOutputTokens, estimatedMinCost: this._calculateCost(inputTokens, 0, model), estimatedMaxCost: this._calculateCost(inputTokens, maxOutputTokens, model), model }; } /** * Get model information */ getModelInfo(model = this.model) { return MODEL_CONFIGS[model]; } /** * List available models */ getAvailableModels() { return Object.keys(MODEL_CONFIGS).map(model => ({ name: model, ...MODEL_CONFIGS[model] })); } /** * Validate API key */ async validateApiKey() { try { await this.createMessage('Hello', { maxTokens: 10 }); return { valid: true }; } catch (error) { return { valid: false, error: error.message, suggestion: error.status === 401 ? 'Check if your API key is correct and active' : 'Check your network connection and try again' }; } } /** * Validate message format */ validateMessages(messages) { if (!Array.isArray(messages)) { throw new AnthropicAPIError('Messages must be an array', { type: 'invalid_request_error', param: 'messages' }); } for (let i = 0; i < messages.length; i++) { const message = messages[i]; if (!message.role || !message.content) { throw new AnthropicAPIError(`Message at index ${i} is missing required 'role' or 'content' field`, { type: 'invalid_request_error', param: `messages[${i}]` }); } if (!['user', 'assistant', 'system'].includes(message.role)) { throw new AnthropicAPIError(`Invalid role '${message.role}' at message index ${i}`, { type: 'invalid_request_error', param: `messages[${i}].role` }); } } return true; } } module.exports = { AnthropicClient, AnthropicAPIError, MODEL_CONFIGS, DEFAULT_CONFIG };