diff --git a/src/index.ts b/src/index.ts index 2d90a76..f532950 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ interface Env { DB: D1Database; CACHE: KVNamespace; OPENAI_API_KEY: string; + AI_GATEWAY_URL?: string; // Cloudflare AI Gateway URL to bypass regional restrictions } interface ValidationError { @@ -49,6 +50,17 @@ interface Server { region_code: string; } +interface BandwidthInfo { + included_transfer_tb: number; // 기본 포함 트래픽 (TB/월) + overage_cost_per_gb: number; // 초과 비용 ($/GB) + overage_cost_per_tb: number; // 초과 비용 ($/TB) + estimated_monthly_tb: number; // 예상 월간 사용량 (TB) + estimated_overage_tb: number; // 예상 초과량 (TB) + estimated_overage_cost: number; // 예상 초과 비용 ($) + total_estimated_cost: number; // 총 예상 비용 (서버 + 트래픽) + warning?: string; // 트래픽 관련 경고 +} + interface RecommendationResult { server: Server; score: number; @@ -63,6 +75,7 @@ interface RecommendationResult { max_concurrent_users: number; requests_per_second: number; }; + bandwidth_info?: BandwidthInfo; benchmark_reference?: BenchmarkReference; vps_benchmark_reference?: { plan_name: string; @@ -133,6 +146,7 @@ interface BandwidthEstimate { description: string; estimated_dau_min: number; // Daily Active Users estimate (min) estimated_dau_max: number; // Daily Active Users estimate (max) + active_ratio: number; // Active user ratio (0.0-1.0) } /** @@ -211,52 +225,13 @@ function getActiveUserRatio(useCase: string): number { function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPattern?: string): BandwidthEstimate { const useCaseLower = useCase.toLowerCase(); - // Determine use case category and bandwidth multiplier - let avgPageSizeMB: number; - let requestsPerSession: number; - let categoryMultiplier: number; - - if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) { - // Video/Streaming: very heavy bandwidth - avgPageSizeMB = 50; // 50MB per video segment - requestsPerSession = 10; - categoryMultiplier = 1.5; - } else if (/download|file|storage|cdn|파일|다운로드|저장소/.test(useCaseLower)) { - // File downloads: heavy bandwidth - avgPageSizeMB = 20; - requestsPerSession = 5; - categoryMultiplier = 1.3; - } else if (/game|gaming|minecraft|게임/.test(useCaseLower)) { - // Gaming: many small packets - avgPageSizeMB = 0.05; - requestsPerSession = 1000; - categoryMultiplier = 1.2; - } else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) { - // E-commerce: images heavy - avgPageSizeMB = 1; - requestsPerSession = 20; - categoryMultiplier = 1.0; - } else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) { - // API/SaaS: many small requests - avgPageSizeMB = 0.1; - requestsPerSession = 50; - categoryMultiplier = 0.8; - } else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) { - // Forum/Community: moderate - avgPageSizeMB = 0.3; - requestsPerSession = 30; - categoryMultiplier = 0.9; - } else if (/blog|static|portfolio|블로그|포트폴리오/.test(useCaseLower)) { - // Static/Blog: light - avgPageSizeMB = 0.5; - requestsPerSession = 5; - categoryMultiplier = 0.5; - } else { - // Default: moderate web app - avgPageSizeMB = 0.5; - requestsPerSession = 15; - categoryMultiplier = 1.0; - } + // Calculate DAU estimate from concurrent users with use-case-specific multipliers + const dauMultiplier = getDauMultiplier(useCase); + const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min); + const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max); + const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2); + const activeUserRatio = getActiveUserRatio(useCase); + const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio); // Traffic pattern adjustment let patternMultiplier = 1.0; @@ -266,23 +241,109 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt patternMultiplier = 1.3; // Headroom for growth } - // Assume 8 active hours per day average (varies by use case) - const activeHoursPerDay = 8; + let dailyBandwidthGB: number; + let bandwidthModel: string; - // Calculate DAU estimate from concurrent users with use-case-specific multipliers - const dauMultiplier = getDauMultiplier(useCase); - const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min); - const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max); + // ========== IMPROVED BANDWIDTH MODELS ========== + // Each use case uses the most appropriate calculation method - // Calculate daily bandwidth with active user ratio - const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2); - const activeUserRatio = getActiveUserRatio(useCase); - const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio); + if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) { + // VIDEO/STREAMING: Bitrate-based model + // - HD streaming: ~5 Mbps = 2.25 GB/hour + // - Average watch time: 1.5 hours per session + // - 4K streaming: ~25 Mbps = 11.25 GB/hour (detect if mentioned) + const is4K = /4k|uhd|ultra/i.test(useCaseLower); + const bitrateGBperHour = is4K ? 11.25 : 2.25; // 4K vs HD + const avgWatchTimeHours = is4K ? 1.0 : 1.5; // 4K users watch less + const gbPerActiveUser = bitrateGBperHour * avgWatchTimeHours; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `bitrate-based: ${activeDau} active × ${bitrateGBperHour} GB/hr × ${avgWatchTimeHours}hr`; - console.log(`[Bandwidth] DAU: ${dailyUniqueVisitors} (${dauMultiplier.min}-${dauMultiplier.max}x), Active DAU: ${activeDau} (${(activeUserRatio * 100).toFixed(0)}%)`); + } else if (/download|file|storage|cdn|파일|다운로드|저장소/.test(useCaseLower)) { + // FILE DOWNLOAD: File-size based model + // - Average file size: 100-500 MB depending on type + // - Downloads per active user: 2-5 per day + const isLargeFiles = /iso|video|backup|대용량/.test(useCaseLower); + const avgFileSizeGB = isLargeFiles ? 2.0 : 0.2; // 2GB for large, 200MB for normal + const downloadsPerUser = isLargeFiles ? 1 : 3; + const gbPerActiveUser = avgFileSizeGB * downloadsPerUser; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `file-based: ${activeDau} active × ${avgFileSizeGB} GB × ${downloadsPerUser} downloads`; - const dailyBandwidthMB = activeDau * avgPageSizeMB * requestsPerSession * categoryMultiplier * patternMultiplier; - const dailyBandwidthGB = dailyBandwidthMB / 1024; + } else if (/game|gaming|minecraft|게임/.test(useCaseLower)) { + // GAMING: Session-duration based model + // - Multiplayer games: 50-150 MB/hour (small packets, frequent) + // - Average session: 2-3 hours + // - Minecraft specifically uses more due to chunk loading + const isMinecraft = /minecraft|마인크래프트/.test(useCaseLower); + const mbPerHour = isMinecraft ? 150 : 80; // Minecraft uses more + const avgSessionHours = isMinecraft ? 3 : 2.5; + const gbPerActiveUser = (mbPerHour * avgSessionHours) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `session-based: ${activeDau} active × ${mbPerHour} MB/hr × ${avgSessionHours}hr`; + + } else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) { + // API/SAAS: Request-based model + // - Average request+response: 10-50 KB + // - Requests per active user per day: 500-2000 + const avgRequestKB = 20; + const requestsPerUserPerDay = 1000; + const gbPerActiveUser = (avgRequestKB * requestsPerUserPerDay) / (1024 * 1024); + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `request-based: ${activeDau} active × ${avgRequestKB}KB × ${requestsPerUserPerDay} req`; + + } else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) { + // E-COMMERCE: Page-based model (images heavy) + // - Average page with images: 2-3 MB + // - Pages per session: 15-25 (product browsing) + const avgPageSizeMB = 2.5; + const pagesPerSession = 20; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + + } else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) { + // FORUM/COMMUNITY: Page-based model (text + some images) + // - Average page: 0.5-1 MB + // - Pages per session: 20-40 (thread reading) + const avgPageSizeMB = 0.7; + const pagesPerSession = 30; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + + } else if (/blog|static|portfolio|블로그|포트폴리오|landing/.test(useCaseLower)) { + // STATIC/BLOG: Lightweight page-based model + // - Average page: 1-2 MB (optimized images) + // - Pages per session: 3-5 (bounce rate high) + const avgPageSizeMB = 1.5; + const pagesPerSession = 4; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + + } else if (/chat|messaging|slack|discord|채팅|메신저/.test(useCaseLower)) { + // CHAT/MESSAGING: Message-based model + // - Average message: 1-5 KB (text + small attachments) + // - Messages per active user: 100-500 per day + // - Some image/file sharing: adds 10-50 MB/user + const textBandwidthMB = (3 * 200) / 1024; // 3KB × 200 messages + const attachmentBandwidthMB = 20; // occasional images/files + const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`; + + } else { + // DEFAULT: General web app (page-based) + const avgPageSizeMB = 1.0; + const pagesPerSession = 10; + const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024; + dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier; + bandwidthModel = `page-based (default): ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`; + } + + console.log(`[Bandwidth] Model: ${bandwidthModel}`); + console.log(`[Bandwidth] DAU: ${dailyUniqueVisitors} (${dauMultiplier.min}-${dauMultiplier.max}x), Active: ${activeDau} (${(activeUserRatio * 100).toFixed(0)}%), Daily: ${dailyBandwidthGB.toFixed(1)} GB`); // Monthly bandwidth const monthlyGB = dailyBandwidthGB * 30; @@ -313,7 +374,92 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt category, description, estimated_dau_min: estimatedDauMin, - estimated_dau_max: estimatedDauMax + estimated_dau_max: estimatedDauMax, + active_ratio: activeUserRatio + }; +} + +/** + * Get provider bandwidth allocation based on memory size + * Returns included transfer in TB/month + */ +function getProviderBandwidthAllocation(providerName: string, memoryGb: number): { + included_tb: number; + overage_per_gb: number; + overage_per_tb: number; +} { + const provider = providerName.toLowerCase(); + + if (provider.includes('linode')) { + // Linode: roughly 1TB per 1GB RAM (Nanode 1GB = 1TB, 2GB = 2TB, etc.) + // Max around 20TB for largest plans + const includedTb = Math.min(Math.max(memoryGb, 1), 20); + return { + included_tb: includedTb, + overage_per_gb: 0.005, // $0.005/GB = $5/TB + overage_per_tb: 5 + }; + } else if (provider.includes('vultr')) { + // Vultr: varies by plan, roughly 1-2TB for small, up to 10TB for large + // Generally less generous than Linode + let includedTb: number; + if (memoryGb <= 2) includedTb = 1; + else if (memoryGb <= 4) includedTb = 2; + else if (memoryGb <= 8) includedTb = 3; + else if (memoryGb <= 16) includedTb = 4; + else if (memoryGb <= 32) includedTb = 5; + else includedTb = Math.min(memoryGb / 4, 10); + + return { + included_tb: includedTb, + overage_per_gb: 0.01, // $0.01/GB = $10/TB + overage_per_tb: 10 + }; + } else { + // Default/Other providers: conservative estimate + return { + included_tb: Math.min(memoryGb, 5), + overage_per_gb: 0.01, + overage_per_tb: 10 + }; + } +} + +/** + * Calculate bandwidth cost info for a server + */ +function calculateBandwidthInfo( + server: Server, + bandwidthEstimate: BandwidthEstimate +): BandwidthInfo { + const allocation = getProviderBandwidthAllocation(server.provider_name, server.memory_gb); + const estimatedTb = bandwidthEstimate.monthly_tb; + const overageTb = Math.max(0, estimatedTb - allocation.included_tb); + const overageCost = overageTb * allocation.overage_per_tb; + + // Convert server price to USD if needed for total cost calculation + const serverPriceUsd = server.currency === 'KRW' + ? server.monthly_price / 1400 // Approximate KRW to USD + : server.monthly_price; + + const totalCost = serverPriceUsd + overageCost; + + let warning: string | undefined; + if (overageTb > allocation.included_tb) { + warning = `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${allocation.included_tb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`; + } else if (overageTb > 0) { + warning = `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~$${overageCost.toFixed(0)}/월)`; + } + + return { + included_transfer_tb: allocation.included_tb, + overage_cost_per_gb: allocation.overage_per_gb, + overage_cost_per_tb: allocation.overage_per_tb, + estimated_monthly_tb: Math.round(estimatedTb * 10) / 10, + estimated_overage_tb: Math.round(overageTb * 10) / 10, + estimated_overage_cost: Math.round(overageCost * 100) / 100, + total_estimated_cost: Math.round(totalCost * 100) / 100, + warning }; } @@ -891,12 +1037,14 @@ async function handleRecommend( // Use OpenAI GPT-4o-mini to analyze and recommend (techSpecs already queried above) const aiResult = await getAIRecommendations( + env, env.OPENAI_API_KEY, body, candidates, benchmarkData, vpsBenchmarks, techSpecs, + bandwidthEstimate, lang ); @@ -905,6 +1053,15 @@ async function handleRecommend( const response = { recommendations: aiResult.recommendations, infrastructure_tips: aiResult.infrastructure_tips || [], + bandwidth_estimate: { + monthly_tb: bandwidthEstimate.monthly_tb, + monthly_gb: bandwidthEstimate.monthly_gb, + daily_gb: bandwidthEstimate.daily_gb, + category: bandwidthEstimate.category, + description: bandwidthEstimate.description, + active_ratio: bandwidthEstimate.active_ratio, + calculation_note: `Based on ${body.expected_users} concurrent users with ${Math.round(bandwidthEstimate.active_ratio * 100)}% active ratio`, + }, total_candidates: candidates.length, cached: false, }; @@ -1179,17 +1336,15 @@ async function queryCandidateServers( console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`); } - // Provider filtering based on bandwidth requirements - // Heavy bandwidth (>2TB/month) → Linode only (better bandwidth allowance) - // Very heavy bandwidth (>6TB/month) → Linode only with warning + // Provider preference based on bandwidth requirements (no hard filtering to avoid empty results) + // Heavy/Very heavy bandwidth → Prefer Linode (better bandwidth allowance), but allow all providers + // AI prompt will warn about bandwidth costs for non-Linode providers if (bandwidthEstimate) { if (bandwidthEstimate.category === 'very_heavy') { - // >6TB/month: Linode only (includes up to 20TB depending on plan) - query += ` AND p.id = 1`; // Linode only - console.log(`[Candidates] Very heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode only`); + // >6TB/month: Strongly prefer Linode, but don't exclude others (Linode may not be available in all regions) + console.log(`[Candidates] Very heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode strongly preferred, all providers included`); } else if (bandwidthEstimate.category === 'heavy') { - // 2-6TB/month: Prefer Linode, but allow Vultr - // Order by Linode first (handled in ORDER BY) + // 2-6TB/month: Prefer Linode console.log(`[Candidates] Heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode preferred`); } } @@ -1268,8 +1423,9 @@ async function queryCandidateServers( } // Group by instance + region to show each server per region - // For heavy bandwidth, prioritize Linode (p.id=1) over Vultr (p.id=2) - const orderByClause = bandwidthEstimate?.category === 'heavy' + // For heavy/very_heavy bandwidth, prioritize Linode (p.id=1) due to generous bandwidth allowance + const isHighBandwidth = bandwidthEstimate?.category === 'heavy' || bandwidthEstimate?.category === 'very_heavy'; + const orderByClause = isHighBandwidth ? `ORDER BY CASE WHEN p.id = 1 THEN 0 ELSE 1 END, monthly_price ASC` : `ORDER BY monthly_price ASC`; query += ` GROUP BY it.id, r.id ${orderByClause} LIMIT 50`; @@ -1676,14 +1832,27 @@ function formatTechSpecsForPrompt(techSpecs: TechSpec[]): string { * Get AI-powered recommendations using OpenAI GPT-4o-mini */ async function getAIRecommendations( + env: Env, apiKey: string, req: RecommendRequest, candidates: Server[], benchmarkData: BenchmarkData[], vpsBenchmarks: VPSBenchmark[], techSpecs: TechSpec[], + bandwidthEstimate: BandwidthEstimate, lang: string = 'en' ): Promise<{ recommendations: RecommendationResult[]; infrastructure_tips?: string[] }> { + // Validate API key before making any API calls + if (!apiKey || !apiKey.trim()) { + console.error('[AI] OPENAI_API_KEY is not configured or empty'); + throw new Error('OPENAI_API_KEY not configured. Please set the secret via: wrangler secret put OPENAI_API_KEY'); + } + if (!apiKey.startsWith('sk-')) { + console.error('[AI] OPENAI_API_KEY has invalid format (should start with sk-)'); + throw new Error('Invalid OPENAI_API_KEY format'); + } + console.log('[AI] API key validated (sk-***' + apiKey.slice(-4) + ')'); + // Build dynamic tech specs prompt from database const techSpecsPrompt = formatTechSpecsForPrompt(techSpecs); @@ -1719,9 +1888,6 @@ Use REAL BENCHMARK DATA to validate capacity estimates. ${languageInstruction}`; // Build user prompt with requirements and candidates - - // Estimate bandwidth based on concurrent users and use case - const bandwidthEstimate = estimateBandwidth(req.expected_users, req.use_case, req.traffic_pattern); console.log('[AI] Bandwidth estimate:', bandwidthEstimate); // Detect high-traffic based on bandwidth estimate (more accurate than keyword matching) @@ -1796,14 +1962,24 @@ SCORING (100 points total): The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have the HIGHEST score.`; - console.log('[AI] Sending request to OpenAI GPT-4o-mini'); + // Use AI Gateway if configured (bypasses regional restrictions like HKG) + // AI Gateway URL format: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_name}/openai + const useAIGateway = !!env.AI_GATEWAY_URL; + const apiEndpoint = useAIGateway + ? `${env.AI_GATEWAY_URL}/chat/completions` + : 'https://api.openai.com/v1/chat/completions'; + + console.log(`[AI] Sending request to ${useAIGateway ? 'AI Gateway → ' : ''}OpenAI GPT-4o-mini`); + if (useAIGateway) { + console.log('[AI] Using Cloudflare AI Gateway to bypass regional restrictions'); + } // Create AbortController with 30 second timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); try { - const openaiResponse = await fetch('https://api.openai.com/v1/chat/completions', { + const openaiResponse = await fetch(apiEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1825,8 +2001,46 @@ The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have if (!openaiResponse.ok) { const errorText = await openaiResponse.text(); - const sanitized = errorText.slice(0, 100).replace(/sk-[a-zA-Z0-9-_]+/g, 'sk-***'); - console.error('[AI] OpenAI API error:', openaiResponse.status, sanitized); + + // Parse error details for better debugging + let errorDetails = ''; + try { + const errorObj = JSON.parse(errorText); + errorDetails = errorObj?.error?.message || errorObj?.error?.type || ''; + } catch { + errorDetails = errorText.slice(0, 200); + } + + // Sanitize API keys from error messages + const sanitized = errorDetails.replace(/sk-[a-zA-Z0-9-_]+/g, 'sk-***'); + + // Enhanced logging for specific error codes + if (openaiResponse.status === 403) { + const isRegionalBlock = errorDetails.includes('Country') || errorDetails.includes('region') || errorDetails.includes('territory'); + if (isRegionalBlock && !useAIGateway) { + console.error('[AI] ❌ REGIONAL BLOCK (403) - OpenAI blocked this region'); + console.error('[AI] Worker is running in a blocked region (e.g., HKG)'); + console.error('[AI] FIX: Set AI_GATEWAY_URL secret to use Cloudflare AI Gateway'); + console.error('[AI] 1. Create AI Gateway: https://dash.cloudflare.com → AI → AI Gateway'); + console.error('[AI] 2. Run: wrangler secret put AI_GATEWAY_URL'); + console.error('[AI] 3. Enter: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_name}/openai'); + } else { + console.error('[AI] ❌ AUTH FAILED (403) - Possible causes:'); + console.error('[AI] 1. Invalid or expired OPENAI_API_KEY'); + console.error('[AI] 2. API key not properly set in Cloudflare secrets'); + console.error('[AI] 3. Account billing issue or quota exceeded'); + } + console.error('[AI] Error details:', sanitized); + } else if (openaiResponse.status === 429) { + console.error('[AI] ⚠️ RATE LIMITED (429) - Too many requests'); + console.error('[AI] Error details:', sanitized); + } else if (openaiResponse.status === 401) { + console.error('[AI] ❌ UNAUTHORIZED (401) - API key invalid'); + console.error('[AI] Error details:', sanitized); + } else { + console.error('[AI] OpenAI API error:', openaiResponse.status, sanitized); + } + throw new Error(`OpenAI API error: ${openaiResponse.status}`); } @@ -1892,11 +2106,15 @@ The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have ); } + // Calculate bandwidth info for this server + const bandwidthInfo = calculateBandwidthInfo(server, bandwidthEstimate); + results.push({ server: server, score: aiRec.score, analysis: aiRec.analysis, estimated_capacity: aiRec.estimated_capacity, + bandwidth_info: bandwidthInfo, benchmark_reference: benchmarkRef, vps_benchmark_reference: matchingVPS ? {