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
JavaScript
/**
* 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 };