UNPKG

route-claudecode

Version:

Advanced routing and transformation system for Claude Code outputs to multiple AI providers

614 lines 27.5 kB
"use strict"; /** * Simple Provider Manager with Round-Robin and Failover * Replaces complex concurrency management with simple blacklisting * Task 1 Implementation: Multi-provider round-robin with failure-based blacklisting */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SimpleProviderManager = void 0; const logger_1 = require("@/utils/logger"); class SimpleProviderManager { roundRobinIndex = new Map(); blacklist = new Map(); // Key format: 'providerId' or 'providerId:model' consecutiveFailures = new Map(); // Track consecutive failures for 429 detection // Key-level management for providers with multiple API keys keyRoundRobinIndex = new Map(); // Format: 'providerId' -> current key index keyBlacklist = new Map(); // Format: 'providerId' -> key status array // Blacklist durations in seconds RATE_LIMIT_BLACKLIST_DURATION = 60; // 1 minute for 429 errors AUTH_FAILURE_BLACKLIST_DURATION = 300; // 5 minutes for auth failures NETWORK_ERROR_BLACKLIST_DURATION = 120; // 2 minutes for network errors SERVER_ERROR_BLACKLIST_DURATION = 180; // 3 minutes for server errors // 429 consecutive failure threshold CONSECUTIVE_429_THRESHOLD = 3; // 连续3次429才拉黑 constructor() { // 🔧 启动时清空所有黑名单,防止重启后仍被拉黑 this.blacklist.clear(); this.consecutiveFailures.clear(); logger_1.logger.info('SimpleProviderManager initialized with weighted round-robin and intelligent blacklisting', { blacklistCleared: true, consecutiveFailuresCleared: true, startupTime: new Date().toISOString() }); } /** * Select next provider using weighted selection with blacklist filtering * Supports both weight-based and round-robin selection */ selectProviderWeighted(providers, category) { // Filter out blacklisted providers const availableProviders = providers.filter(provider => !this.isBlacklisted(provider.providerId, provider.model)); if (availableProviders.length === 0) { logger_1.logger.warn('All weighted providers are blacklisted, using first provider anyway', { category, totalProviders: providers.length, blacklistedProviders: providers.filter(p => this.isBlacklisted(p.providerId, p.model)) }); // Return first provider even if blacklisted (emergency fallback) return providers[0] || null; } // Redistribute weights among available providers const redistributedProviders = this.redistributeWeights(availableProviders, providers); // Weighted random selection const selectedProvider = this.weightedRandomSelection(redistributedProviders); logger_1.logger.debug('Weighted provider selection', { category, selectedProvider: selectedProvider?.providerId, selectedModel: selectedProvider?.model, selectedWeight: selectedProvider?.weight, availableProviders: availableProviders.length, totalProviders: providers.length, totalWeight: redistributedProviders.reduce((sum, p) => sum + p.weight, 0) }); return selectedProvider; } /** * Select next provider using round-robin with blacklist filtering * Now supports model-specific blacklisting (legacy method) */ selectProvider(providers, category, model) { // Filter out blacklisted providers (check both model-specific and provider-wide blacklists) const availableProviders = providers.filter(providerId => !this.isBlacklisted(providerId, model)); if (availableProviders.length === 0) { logger_1.logger.warn('All providers are blacklisted, using first provider anyway', { category, model: model || 'all-models', totalProviders: providers.length, blacklistedProviders: providers.filter(p => this.isBlacklisted(p, model)) }); // Return first provider even if blacklisted (emergency fallback) return providers[0] || null; } // Round-robin selection among available providers const currentIndex = this.roundRobinIndex.get(category) || 0; const selectedProvider = availableProviders[currentIndex % availableProviders.length]; // Update round-robin index this.roundRobinIndex.set(category, currentIndex + 1); logger_1.logger.debug('Round-robin provider selection', { category, model: model || 'all-models', selectedProvider, availableProviders: availableProviders.length, totalProviders: providers.length, roundRobinIndex: currentIndex, blacklistScope: model ? 'model-specific' : 'provider-wide' }); return selectedProvider; } /** * Report provider failure and apply blacklisting if needed * Now supports model-specific blacklisting when model is provided * Enhanced with consecutive 429 detection */ reportFailure(providerId, error, httpCode, model) { const failureType = this.categorizeFailure(error, httpCode); // Create composite key for model-specific blacklisting when model is provided const blacklistKey = model ? `${providerId}:${model}` : providerId; const existing = this.blacklist.get(blacklistKey); // Track consecutive failures for rate limiting const consecutiveKey = blacklistKey; logger_1.logger.info('Provider failure reported', { providerId, model: model || 'all-models', failureType, error: error.substring(0, 100), // Truncate long errors httpCode, existingErrorCount: existing?.errorCount || 0, blacklistScope: model ? 'model-specific' : 'provider-wide' }); switch (failureType) { case 'rate_limit': // Enhanced consecutive 429 detection const consecutive429 = this.consecutiveFailures.get(consecutiveKey) || 0; this.consecutiveFailures.set(consecutiveKey, consecutive429 + 1); if (consecutive429 + 1 >= this.CONSECUTIVE_429_THRESHOLD) { logger_1.logger.warn(`🚫 Consecutive 429 threshold reached (${consecutive429 + 1}/${this.CONSECUTIVE_429_THRESHOLD}) - blacklisting provider`, { providerId, model: model || 'all-models', consecutiveErrors: consecutive429 + 1 }); this.blacklistProvider(blacklistKey, providerId, failureType, this.RATE_LIMIT_BLACKLIST_DURATION, model, consecutive429 + 1); // Reset consecutive counter after blacklisting this.consecutiveFailures.set(consecutiveKey, 0); } else { logger_1.logger.debug(`Rate limit failure ${consecutive429 + 1}/${this.CONSECUTIVE_429_THRESHOLD} - not blacklisting yet`, { providerId, model: model || 'all-models', remainingAttempts: this.CONSECUTIVE_429_THRESHOLD - (consecutive429 + 1) }); } break; case 'auth_failure': const authErrorCount = (existing?.errorCount || 0) + 1; if (authErrorCount >= 3) { // Permanent-like blacklisting for repeated auth failures (very long duration) this.blacklistProvider(blacklistKey, providerId, failureType, 3600, model); // 1 hour } else { this.blacklistProvider(blacklistKey, providerId, failureType, this.AUTH_FAILURE_BLACKLIST_DURATION, model); } break; case 'network_error': this.blacklistProvider(blacklistKey, providerId, failureType, this.NETWORK_ERROR_BLACKLIST_DURATION, model); break; case 'server_error': this.blacklistProvider(blacklistKey, providerId, failureType, this.SERVER_ERROR_BLACKLIST_DURATION, model); break; default: // Don't blacklist for unknown errors, just log logger_1.logger.debug('Unknown error type, not blacklisting', { providerId, error, httpCode }); break; } } /** * Report provider success (removes from blacklist if temporary) * Now supports model-specific recovery and resets consecutive failure counts */ reportSuccess(providerId, model) { // Check both model-specific and provider-wide blacklists const keys = model ? [`${providerId}:${model}`, providerId] : [providerId]; for (const key of keys) { // Reset consecutive failure counter on success if (this.consecutiveFailures.has(key)) { const previousConsecutive = this.consecutiveFailures.get(key) || 0; this.consecutiveFailures.set(key, 0); logger_1.logger.debug('Reset consecutive failure counter after success', { providerId, model: model || 'all-models', previousConsecutive, blacklistKey: key }); } const blacklisted = this.blacklist.get(key); if (blacklisted && blacklisted.reason !== 'auth_failure') { // Remove from blacklist on success (except for auth failures which need time-based recovery) this.blacklist.delete(key); logger_1.logger.debug('Provider recovered from blacklist after success', { providerId, model: model || 'all-models', blacklistKey: key }); } } } /** * Check if provider is currently blacklisted * Now supports model-specific blacklisting */ isBlacklisted(providerId, model) { // Check model-specific blacklist first, then provider-wide blacklist const keys = model ? [`${providerId}:${model}`, providerId] : [providerId]; for (const key of keys) { const blacklisted = this.blacklist.get(key); if (!blacklisted) { continue; } const now = new Date(); if (now >= blacklisted.blacklistedUntil) { // Blacklist expired, remove it this.blacklist.delete(key); logger_1.logger.info('Provider blacklist expired', { providerId, model: blacklisted.model || 'all-models', reason: blacklisted.reason, blacklistKey: key, duration: Math.round((now.getTime() - (blacklisted.blacklistedUntil.getTime() - this.getBlacklistDuration(blacklisted.reason) * 1000)) / 1000) }); continue; } // Found active blacklist return true; } return false; } /** * Get blacklist status for all providers */ getBlacklistStatus() { const status = {}; // Clean expired blacklists first const now = new Date(); for (const [providerId, blacklisted] of this.blacklist.entries()) { if (now >= blacklisted.blacklistedUntil) { this.blacklist.delete(providerId); } } // Return current blacklist status this.blacklist.forEach((blacklisted, providerId) => { status[providerId] = blacklisted; }); return status; } /** * Clear all blacklists (for testing or emergency recovery) */ clearAllBlacklists() { const count = this.blacklist.size; this.blacklist.clear(); logger_1.logger.info(`Cleared ${count} provider blacklists`); } /** * Get round-robin state for monitoring */ getRoundRobinState() { const state = {}; this.roundRobinIndex.forEach((index, category) => { state[category] = index; }); return state; } // Private methods blacklistProvider(blacklistKey, providerId, reason, durationSeconds, model, consecutiveErrors) { const now = new Date(); const blacklistedUntil = new Date(now.getTime() + (durationSeconds * 1000)); const existing = this.blacklist.get(blacklistKey); const errorCount = (existing?.errorCount || 0) + 1; this.blacklist.set(blacklistKey, { providerId, model, blacklistedUntil, reason, errorCount, consecutiveErrors: consecutiveErrors || errorCount }); logger_1.logger.warn('Provider blacklisted', { providerId, model: model || 'all-models', reason, durationSeconds, blacklistedUntil: blacklistedUntil.toISOString(), errorCount, consecutiveErrors: consecutiveErrors || errorCount, isRecurring: !!existing, blacklistKey, scope: model ? 'model-specific' : 'provider-wide' }); } categorizeFailure(error, httpCode) { const errorLower = error.toLowerCase(); // Rate limit detection if (httpCode === 429) return 'rate_limit'; if (errorLower.includes('rate limit') || errorLower.includes('too many requests')) { return 'rate_limit'; } // Authentication failure detection if (httpCode === 401 || httpCode === 403) return 'auth_failure'; if (errorLower.includes('unauthorized') || errorLower.includes('forbidden')) { return 'auth_failure'; } if (errorLower.includes('token') && (errorLower.includes('invalid') || errorLower.includes('expired'))) { return 'auth_failure'; } // Network error detection if (errorLower.includes('network') || errorLower.includes('connection')) { return 'network_error'; } if (errorLower.includes('timeout') || errorLower.includes('econnreset')) { return 'network_error'; } // Server error detection if (httpCode && httpCode >= 500) return 'server_error'; if (errorLower.includes('internal server error') || errorLower.includes('bad gateway')) { return 'server_error'; } // Default to server error for unknown failures return 'server_error'; } getBlacklistDuration(reason) { switch (reason) { case 'rate_limit': return this.RATE_LIMIT_BLACKLIST_DURATION; case 'auth_failure': return this.AUTH_FAILURE_BLACKLIST_DURATION; case 'network_error': return this.NETWORK_ERROR_BLACKLIST_DURATION; case 'server_error': return this.SERVER_ERROR_BLACKLIST_DURATION; default: return this.SERVER_ERROR_BLACKLIST_DURATION; } } /** * Redistribute weights among available providers * When providers are blacklisted, their weights are redistributed proportionally */ redistributeWeights(availableProviders, allProviders) { // Calculate total weight of all providers (including blacklisted) const totalOriginalWeight = allProviders.reduce((sum, p) => sum + p.weight, 0); // Calculate total weight of available providers const availableWeight = availableProviders.reduce((sum, p) => sum + p.weight, 0); // If all providers are available, no redistribution needed if (availableProviders.length === allProviders.length) { return availableProviders; } // Calculate weight of blacklisted providers const blacklistedWeight = totalOriginalWeight - availableWeight; if (availableWeight === 0) { // All available providers have 0 weight, distribute equally const equalWeight = totalOriginalWeight / availableProviders.length; return availableProviders.map(p => ({ ...p, weight: equalWeight })); } // Redistribute blacklisted weight proportionally to available providers const redistributedProviders = availableProviders.map(provider => { const proportionalShare = (provider.weight / availableWeight) * blacklistedWeight; const newWeight = provider.weight + proportionalShare; return { ...provider, weight: newWeight }; }); logger_1.logger.debug('Weight redistribution applied', { totalOriginalWeight, availableWeight, blacklistedWeight, availableProviders: availableProviders.length, totalProviders: allProviders.length, redistributedWeights: redistributedProviders.map(p => ({ provider: p.providerId, originalWeight: availableProviders.find(ap => ap.providerId === p.providerId)?.weight, newWeight: p.weight })) }); return redistributedProviders; } /** * Weighted random selection algorithm * Uses cumulative weight distribution for O(n) selection */ weightedRandomSelection(providers) { if (providers.length === 0) { return null; } if (providers.length === 1) { return providers[0]; } // Calculate total weight const totalWeight = providers.reduce((sum, p) => sum + p.weight, 0); if (totalWeight <= 0) { // All providers have 0 or negative weight, select randomly const randomIndex = Math.floor(Math.random() * providers.length); return providers[randomIndex]; } // Generate random number between 0 and totalWeight let random = Math.random() * totalWeight; // Find the provider that matches the random value for (const provider of providers) { random -= provider.weight; if (random <= 0) { return provider; } } // Fallback (should not happen, but just in case) return providers[providers.length - 1]; } /** * Get consecutive failure count for a provider */ getConsecutiveFailures(providerId, model) { const key = model ? `${providerId}:${model}` : providerId; return this.consecutiveFailures.get(key) || 0; } /** * Clear consecutive failure counters (for testing or recovery) */ clearConsecutiveFailures() { const count = this.consecutiveFailures.size; this.consecutiveFailures.clear(); logger_1.logger.info(`Cleared ${count} consecutive failure counters`); } // ==================== KEY-LEVEL MANAGEMENT ==================== /** * Initialize key-level tracking for a provider */ initializeProviderKeys(providerId, keyCount) { if (keyCount <= 1) { // Single key or no keys, no need for key-level management this.keyBlacklist.delete(providerId); this.keyRoundRobinIndex.delete(providerId); return; } // Initialize key status array const keyStatuses = []; for (let i = 0; i < keyCount; i++) { keyStatuses.push({ providerId, keyIndex: i, isBlacklisted: false, consecutiveErrors: 0 }); } this.keyBlacklist.set(providerId, keyStatuses); this.keyRoundRobinIndex.set(providerId, 0); logger_1.logger.debug(`Initialized key-level tracking for ${providerId}`, { keyCount, providerId }); } /** * Select next available key for a provider using round-robin */ selectProviderKey(providerId) { const keyStatuses = this.keyBlacklist.get(providerId); if (!keyStatuses || keyStatuses.length <= 1) { // Single key or no key management, return 0 (first key) return 0; } // Clean expired blacklists first this.cleanExpiredKeyBlacklists(providerId); // Find available keys (not blacklisted) const availableKeys = keyStatuses.filter(status => !status.isBlacklisted); if (availableKeys.length === 0) { logger_1.logger.warn(`All keys for provider ${providerId} are blacklisted, using first key anyway`, { providerId, totalKeys: keyStatuses.length, blacklistedKeys: keyStatuses.filter(s => s.isBlacklisted).length }); // Return first key as emergency fallback return 0; } // Round-robin selection among available keys const currentIndex = this.keyRoundRobinIndex.get(providerId) || 0; const selectedKeyStatus = availableKeys[currentIndex % availableKeys.length]; // Update round-robin index this.keyRoundRobinIndex.set(providerId, currentIndex + 1); logger_1.logger.debug(`Key round-robin selection for ${providerId}`, { selectedKeyIndex: selectedKeyStatus.keyIndex, availableKeys: availableKeys.length, totalKeys: keyStatuses.length, roundRobinIndex: currentIndex }); return selectedKeyStatus.keyIndex; } /** * Report key-level failure for a specific provider key */ reportKeyFailure(providerId, keyIndex, error, httpCode) { const keyStatuses = this.keyBlacklist.get(providerId); if (!keyStatuses || keyIndex >= keyStatuses.length) { // No key management or invalid key index, fall back to provider-level failure this.reportFailure(providerId, error, httpCode); return; } const keyStatus = keyStatuses[keyIndex]; const failureType = this.categorizeFailure(error, httpCode); keyStatus.consecutiveErrors++; keyStatus.lastError = error; logger_1.logger.info(`Key-level failure reported for ${providerId}[${keyIndex}]`, { providerId, keyIndex, failureType, consecutiveErrors: keyStatus.consecutiveErrors, error: error.substring(0, 100), httpCode }); // Apply key-level blacklisting based on failure type switch (failureType) { case 'rate_limit': if (keyStatus.consecutiveErrors >= this.CONSECUTIVE_429_THRESHOLD) { this.blacklistProviderKey(providerId, keyIndex, this.RATE_LIMIT_BLACKLIST_DURATION, 'rate_limit'); } break; case 'auth_failure': // Blacklist immediately for auth failures this.blacklistProviderKey(providerId, keyIndex, this.AUTH_FAILURE_BLACKLIST_DURATION, 'auth_failure'); break; case 'network_error': if (keyStatus.consecutiveErrors >= 2) { this.blacklistProviderKey(providerId, keyIndex, this.NETWORK_ERROR_BLACKLIST_DURATION, 'network_error'); } break; case 'server_error': if (keyStatus.consecutiveErrors >= 3) { this.blacklistProviderKey(providerId, keyIndex, this.SERVER_ERROR_BLACKLIST_DURATION, 'server_error'); } break; } // Check if all keys are blacklisted, then report provider-level failure const availableKeys = keyStatuses.filter(status => !status.isBlacklisted); if (availableKeys.length === 0) { logger_1.logger.warn(`All keys for provider ${providerId} are blacklisted, reporting provider-level failure`, { providerId, totalKeys: keyStatuses.length }); // Report provider-level failure to trigger provider-level blacklisting this.reportFailure(providerId, `All ${keyStatuses.length} keys blacklisted`, httpCode); } } /** * Report key-level success for a specific provider key */ reportKeySuccess(providerId, keyIndex) { const keyStatuses = this.keyBlacklist.get(providerId); if (!keyStatuses || keyIndex >= keyStatuses.length) { // No key management or invalid key index, fall back to provider-level success this.reportSuccess(providerId); return; } const keyStatus = keyStatuses[keyIndex]; // Reset consecutive errors and remove from blacklist if temporarily blacklisted keyStatus.consecutiveErrors = 0; if (keyStatus.isBlacklisted && keyStatus.blacklistedUntil && new Date() < keyStatus.blacklistedUntil) { keyStatus.isBlacklisted = false; keyStatus.blacklistedUntil = undefined; keyStatus.lastError = undefined; logger_1.logger.debug(`Key recovered from blacklist after success: ${providerId}[${keyIndex}]`, { providerId, keyIndex }); } // Also report provider-level success this.reportSuccess(providerId); } /** * Get key-level status for a provider */ getProviderKeyStatus(providerId) { return this.keyBlacklist.get(providerId) || null; } // Private key management methods blacklistProviderKey(providerId, keyIndex, durationSeconds, reason) { const keyStatuses = this.keyBlacklist.get(providerId); if (!keyStatuses || keyIndex >= keyStatuses.length) { return; } const keyStatus = keyStatuses[keyIndex]; const blacklistedUntil = new Date(Date.now() + (durationSeconds * 1000)); keyStatus.isBlacklisted = true; keyStatus.blacklistedUntil = blacklistedUntil; logger_1.logger.warn(`Key blacklisted: ${providerId}[${keyIndex}]`, { providerId, keyIndex, reason, durationSeconds, blacklistedUntil: blacklistedUntil.toISOString(), consecutiveErrors: keyStatus.consecutiveErrors }); } cleanExpiredKeyBlacklists(providerId) { const keyStatuses = this.keyBlacklist.get(providerId); if (!keyStatuses) { return; } const now = new Date(); let recoveredKeys = 0; for (const keyStatus of keyStatuses) { if (keyStatus.isBlacklisted && keyStatus.blacklistedUntil && now >= keyStatus.blacklistedUntil) { keyStatus.isBlacklisted = false; keyStatus.blacklistedUntil = undefined; keyStatus.lastError = undefined; recoveredKeys++; } } if (recoveredKeys > 0) { logger_1.logger.info(`${recoveredKeys} keys recovered from blacklist for provider ${providerId}`, { providerId, recoveredKeys, totalKeys: keyStatuses.length }); } } } exports.SimpleProviderManager = SimpleProviderManager; //# sourceMappingURL=simple-provider-manager.js.map