/** * Cloudflare Worker - Server Recommendation System * * AI-powered server recommendation service using Workers AI, D1, and KV. */ interface Env { AI: Ai; // Legacy - kept for fallback DB: D1Database; CACHE: KVNamespace; OPENAI_API_KEY: string; } interface ValidationError { error: string; missing_fields?: string[]; invalid_fields?: { field: string; reason: string }[]; schema: Record; example: Record; } interface RecommendRequest { tech_stack: string[]; expected_users: number; use_case: string; traffic_pattern?: 'steady' | 'spiky' | 'growing'; region_preference?: string[]; budget_limit?: number; provider_filter?: string[]; // Filter by specific providers (e.g., ["Linode", "Vultr"]) lang?: 'en' | 'zh' | 'ja' | 'ko'; // Response language } interface Server { id: number; provider_name: string; instance_id: string; instance_name: string; vcpu: number; memory_mb: number; memory_gb: number; storage_gb: number; network_speed_gbps: number | null; instance_family: string | null; gpu_count: number; gpu_type: string | null; monthly_price: number; currency: 'USD' | 'KRW'; region_name: string; region_code: string; } interface RecommendationResult { server: Server; score: number; analysis: { tech_fit: string; capacity: string; cost_efficiency: string; scalability: string; }; estimated_capacity: { max_daily_users?: number; max_concurrent_users: number; requests_per_second: number; }; benchmark_reference?: BenchmarkReference; vps_benchmark_reference?: { plan_name: string; geekbench_single: number; geekbench_multi: number; monthly_price_usd: number; performance_per_dollar: number; }; } interface BenchmarkReference { processor_name: string; benchmarks: { name: string; category: string; score: number; percentile: number; }[]; } interface BenchmarkData { id: number; processor_name: string; benchmark_name: string; category: string; score: number; percentile: number; cores: number | null; } interface VPSBenchmark { id: number; provider_name: string; plan_name: string; cpu_type: string; vcpu: number; memory_gb: number; country_code: string; geekbench_single: number; geekbench_multi: number; geekbench_total: number; monthly_price_usd: number; performance_per_dollar: number; geekbench_version: string; gb6_single_normalized: number; gb6_multi_normalized: number; } interface TechSpec { id: number; name: string; category: string; vcpu_per_users: number; vcpu_per_users_max: number | null; min_memory_mb: number; max_memory_mb: number | null; description: string | null; aliases: string | null; is_memory_intensive: boolean; is_cpu_intensive: boolean; } interface BandwidthEstimate { monthly_gb: number; monthly_tb: number; daily_gb: number; category: 'light' | 'moderate' | 'heavy' | 'very_heavy'; description: string; estimated_dau_min: number; // Daily Active Users estimate (min) estimated_dau_max: number; // Daily Active Users estimate (max) } /** * Get DAU multiplier based on use case (how many daily active users per concurrent user) */ function getDauMultiplier(useCase: string): { min: number; max: number } { const useCaseLower = useCase.toLowerCase(); if (/game|gaming|minecraft|게임/.test(useCaseLower)) { // Gaming: users stay online longer, higher concurrent ratio return { min: 10, max: 20 }; } else if (/blog|news|static|블로그|뉴스|포트폴리오/.test(useCaseLower)) { // Blog/Static: short visits, lower concurrent ratio return { min: 30, max: 50 }; } else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) { // SaaS/API: business hours concentration return { min: 5, max: 10 }; } else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) { // E-commerce: moderate session lengths return { min: 20, max: 30 }; } else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) { // Forum/Community: moderate engagement return { min: 15, max: 25 }; } else if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) { // Video/Streaming: medium-long sessions return { min: 8, max: 12 }; } else { // Default: general web app return { min: 10, max: 14 }; } } /** * Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action) */ function getActiveUserRatio(useCase: string): number { const useCaseLower = useCase.toLowerCase(); if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) { // Video/Streaming: only 30% of DAU actually stream return 0.3; } else if (/game|gaming|minecraft|게임/.test(useCaseLower)) { // Gaming: 50% of DAU are active players return 0.5; } else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) { // E-commerce: 40% browse products return 0.4; } else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) { // API/SaaS: 60% active usage return 0.6; } else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) { // Forum/Community: 50% active posting/reading return 0.5; } else if (/blog|static|portfolio|블로그|포트폴리오/.test(useCaseLower)) { // Static/Blog: 30% active readers return 0.3; } else { // Default: 50% active return 0.5; } } /** * Estimate monthly bandwidth based on concurrent users and use case * * Formula: concurrent_users × dau_multiplier × active_ratio × avg_page_size_mb × requests_per_session × active_hours × 30 * * Multipliers by use case: * - Static site/blog: 0.5 MB/request, 5 requests/session * - API/SaaS: 0.1 MB/request, 50 requests/session * - E-commerce: 1 MB/request, 20 requests/session * - Media/Video: 50 MB/request, 10 requests/session * - Gaming: 0.05 MB/request, 1000 requests/session * - Forum/Community: 0.3 MB/request, 30 requests/session */ 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; } // Traffic pattern adjustment let patternMultiplier = 1.0; if (trafficPattern === 'spiky') { patternMultiplier = 1.5; // Account for peak loads } else if (trafficPattern === 'growing') { patternMultiplier = 1.3; // Headroom for growth } // Assume 8 active hours per day average (varies by use case) const activeHoursPerDay = 8; // 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); // Calculate daily bandwidth with active user ratio const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2); const activeUserRatio = getActiveUserRatio(useCase); const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio); console.log(`[Bandwidth] DAU: ${dailyUniqueVisitors} (${dauMultiplier.min}-${dauMultiplier.max}x), Active DAU: ${activeDau} (${(activeUserRatio * 100).toFixed(0)}%)`); const dailyBandwidthMB = activeDau * avgPageSizeMB * requestsPerSession * categoryMultiplier * patternMultiplier; const dailyBandwidthGB = dailyBandwidthMB / 1024; // Monthly bandwidth const monthlyGB = dailyBandwidthGB * 30; const monthlyTB = monthlyGB / 1024; // Categorize let category: 'light' | 'moderate' | 'heavy' | 'very_heavy'; let description: string; if (monthlyTB < 0.5) { category = 'light'; description = `~${Math.round(monthlyGB)} GB/month - Most VPS plans include sufficient bandwidth`; } else if (monthlyTB < 2) { category = 'moderate'; description = `~${monthlyTB.toFixed(1)} TB/month - Check provider bandwidth limits`; } else if (monthlyTB < 6) { category = 'heavy'; description = `~${monthlyTB.toFixed(1)} TB/month - Prefer providers with generous bandwidth (Linode: 1-6TB included)`; } else { category = 'very_heavy'; description = `~${monthlyTB.toFixed(1)} TB/month - HIGH BANDWIDTH: Linode strongly recommended for cost savings`; } return { monthly_gb: Math.round(monthlyGB), monthly_tb: Math.round(monthlyTB * 10) / 10, daily_gb: Math.round(dailyBandwidthGB * 10) / 10, category, description, estimated_dau_min: estimatedDauMin, estimated_dau_max: estimatedDauMax }; } interface AIRecommendationResponse { recommendations: Array<{ server_id: number; score: number; analysis: { tech_fit: string; capacity: string; cost_efficiency: string; scalability: string; }; estimated_capacity: { max_daily_users?: number; max_concurrent_users: number; requests_per_second: number; }; }>; infrastructure_tips?: string[]; } /** * i18n Messages for multi-language support */ const i18n: Record; example: Record; aiLanguageInstruction: string; }> = { en: { missingFields: 'Missing required fields', invalidFields: 'Invalid field values', schema: { tech_stack: "(required) string[] - e.g. ['nginx', 'nodejs']", expected_users: "(required) number - expected concurrent users, e.g. 1000", use_case: "(required) string - e.g. 'e-commerce website'", traffic_pattern: "(optional) 'steady' | 'spiky' | 'growing'", region_preference: "(optional) string[] - e.g. ['korea', 'japan']", budget_limit: "(optional) number - max monthly USD", provider_filter: "(optional) string[] - e.g. ['linode', 'vultr']", lang: "(optional) 'en' | 'zh' | 'ja' | 'ko' - response language" }, example: { tech_stack: ["nginx", "nodejs", "postgresql"], expected_users: 5000, use_case: "SaaS application" }, aiLanguageInstruction: 'Respond in English.' }, zh: { missingFields: '缺少必填字段', invalidFields: '字段值无效', schema: { tech_stack: "(必填) string[] - 例如 ['nginx', 'nodejs']", expected_users: "(必填) number - 预计同时在线用户数,例如 1000", use_case: "(必填) string - 例如 '电商网站'", traffic_pattern: "(可选) 'steady' | 'spiky' | 'growing'", region_preference: "(可选) string[] - 例如 ['korea', 'japan']", budget_limit: "(可选) number - 每月最高预算(美元)", provider_filter: "(可选) string[] - 例如 ['linode', 'vultr']", lang: "(可选) 'en' | 'zh' | 'ja' | 'ko' - 响应语言" }, example: { tech_stack: ["nginx", "nodejs", "postgresql"], expected_users: 5000, use_case: "SaaS应用程序" }, aiLanguageInstruction: 'Respond in Chinese (Simplified). All analysis text must be in Chinese.' }, ja: { missingFields: '必須フィールドがありません', invalidFields: 'フィールド値が無効です', schema: { tech_stack: "(必須) string[] - 例: ['nginx', 'nodejs']", expected_users: "(必須) number - 予想同時接続ユーザー数、例: 1000", use_case: "(必須) string - 例: 'ECサイト'", traffic_pattern: "(任意) 'steady' | 'spiky' | 'growing'", region_preference: "(任意) string[] - 例: ['korea', 'japan']", budget_limit: "(任意) number - 月額予算上限(USD)", provider_filter: "(任意) string[] - 例: ['linode', 'vultr']", lang: "(任意) 'en' | 'zh' | 'ja' | 'ko' - 応答言語" }, example: { tech_stack: ["nginx", "nodejs", "postgresql"], expected_users: 5000, use_case: "SaaSアプリケーション" }, aiLanguageInstruction: 'Respond in Japanese. All analysis text must be in Japanese.' }, ko: { missingFields: '필수 필드가 누락되었습니다', invalidFields: '필드 값이 잘못되었습니다', schema: { tech_stack: "(필수) string[] - 예: ['nginx', 'nodejs']", expected_users: "(필수) number - 예상 동시 접속자 수, 예: 1000", use_case: "(필수) string - 예: '이커머스 웹사이트'", traffic_pattern: "(선택) 'steady' | 'spiky' | 'growing'", region_preference: "(선택) string[] - 예: ['korea', 'japan']", budget_limit: "(선택) number - 월 예산 한도(원화, KRW)", provider_filter: "(선택) string[] - 예: ['linode', 'vultr']", lang: "(선택) 'en' | 'zh' | 'ja' | 'ko' - 응답 언어" }, example: { tech_stack: ["nginx", "nodejs", "postgresql"], expected_users: 5000, use_case: "SaaS 애플리케이션" }, aiLanguageInstruction: 'Respond in Korean. All analysis text must be in Korean.' } }; /** * Helper function to get allowed CORS origin */ function getAllowedOrigin(request: Request): string { const allowedOrigins = [ 'https://server-recommend.kappa-d8e.workers.dev', ]; const origin = request.headers.get('Origin'); if (origin && allowedOrigins.includes(origin)) { return origin; } // Allow requests without Origin header (non-browser, curl, etc.) if (!origin) { return '*'; } return allowedOrigins[0]; } /** * Rate limiting check using KV storage */ async function checkRateLimit(clientIP: string, env: Env): Promise<{ allowed: boolean; requestId: string }> { const requestId = crypto.randomUUID(); // If CACHE is not configured, allow the request if (!env.CACHE) { return { allowed: true, requestId }; } const now = Date.now(); const maxRequests = 60; const kvKey = `ratelimit:${clientIP}`; try { const recordJson = await env.CACHE.get(kvKey); const record = recordJson ? JSON.parse(recordJson) as { count: number; resetTime: number } : null; if (!record || record.resetTime < now) { // New window await env.CACHE.put( kvKey, JSON.stringify({ count: 1, resetTime: now + 60000 }), { expirationTtl: 60 } ); return { allowed: true, requestId }; } if (record.count >= maxRequests) { return { allowed: false, requestId }; } // Increment count record.count++; await env.CACHE.put( kvKey, JSON.stringify(record), { expirationTtl: 60 } ); return { allowed: true, requestId }; } catch (error) { console.error('[RateLimit] KV error:', error); // On error, allow the request (fail open) return { allowed: true, requestId }; } } /** * Main request handler */ export default { async fetch(request: Request, env: Env): Promise { const requestId = crypto.randomUUID(); try { const url = new URL(request.url); const path = url.pathname; // Rate limiting (except for health checks) if (path !== '/api/health') { const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; const rateCheck = await checkRateLimit(clientIP, env); if (!rateCheck.allowed) { const origin = getAllowedOrigin(request); return jsonResponse( { error: 'Too many requests', request_id: rateCheck.requestId }, 429, { 'Access-Control-Allow-Origin': origin, 'Vary': 'Origin', } ); } } // CORS headers for all responses const origin = getAllowedOrigin(request); const corsHeaders = { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Vary': 'Origin', }; // Handle preflight requests if (request.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } // Route handling if (path === '/api/health') { return handleHealth(corsHeaders); } if (path === '/api/servers' && request.method === 'GET') { return handleGetServers(request, env, corsHeaders); } if (path === '/api/recommend' && request.method === 'POST') { return handleRecommend(request, env, corsHeaders); } return jsonResponse( { error: 'Not found', request_id: requestId }, 404, corsHeaders ); } catch (error) { console.error('[Worker] Unhandled error:', error); const origin = getAllowedOrigin(request); return jsonResponse( { error: 'Internal server error', request_id: requestId, }, 500, { 'Access-Control-Allow-Origin': origin, 'Vary': 'Origin', } ); } }, }; /** * Health check endpoint */ function handleHealth(corsHeaders: Record): Response { return jsonResponse( { status: 'ok', timestamp: new Date().toISOString(), service: 'server-recommend', }, 200, corsHeaders ); } /** * GET /api/servers - Server list with filtering */ async function handleGetServers( request: Request, env: Env, corsHeaders: Record ): Promise { try { const url = new URL(request.url); const provider = url.searchParams.get('provider'); const minCpu = url.searchParams.get('minCpu'); const minMemory = url.searchParams.get('minMemory'); const region = url.searchParams.get('region'); console.log('[GetServers] Query params:', { provider, minCpu, minMemory, region, }); // Build SQL query dynamically let query = ` SELECT it.id, p.display_name as provider_name, it.instance_id, it.instance_name, it.vcpu, it.memory_mb, ROUND(it.memory_mb / 1024.0, 1) as memory_gb, it.storage_gb, it.network_speed_gbps, it.instance_family, it.gpu_count, it.gpu_type, MIN(pr.monthly_price) as monthly_price, MIN(r.region_name) as region_name, MIN(r.region_code) as region_code FROM instance_types it JOIN providers p ON it.provider_id = p.id JOIN pricing pr ON pr.instance_type_id = it.id JOIN regions r ON pr.region_id = r.id WHERE p.id IN (1, 2) -- Linode, Vultr only AND ( -- Korea (Seoul) r.region_code IN ('icn', 'ap-northeast-2') OR LOWER(r.region_name) LIKE '%seoul%' OR -- Japan (Tokyo, Osaka) r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR LOWER(r.region_code) LIKE '%tyo%' OR LOWER(r.region_code) LIKE '%osa%' OR LOWER(r.region_name) LIKE '%tokyo%' OR LOWER(r.region_name) LIKE '%osaka%' OR -- Singapore r.region_code IN ('sgp', 'ap-southeast-1') OR LOWER(r.region_code) LIKE '%sin%' OR LOWER(r.region_code) LIKE '%sgp%' OR LOWER(r.region_name) LIKE '%singapore%' ) `; const params: (string | number)[] = []; if (provider) { query += ` AND p.name = ?`; params.push(provider); } if (minCpu) { const parsedCpu = parseInt(minCpu, 10); if (isNaN(parsedCpu)) { return jsonResponse({ error: 'Invalid minCpu parameter' }, 400, corsHeaders); } query += ` AND it.vcpu >= ?`; params.push(parsedCpu); } if (minMemory) { const parsedMemory = parseInt(minMemory, 10); if (isNaN(parsedMemory)) { return jsonResponse({ error: 'Invalid minMemory parameter' }, 400, corsHeaders); } query += ` AND it.memory_mb >= ?`; params.push(parsedMemory * 1024); } if (region) { query += ` AND r.region_code = ?`; params.push(region); } query += ` GROUP BY it.id ORDER BY MIN(pr.monthly_price) ASC LIMIT 100`; const result = await env.DB.prepare(query).bind(...params).all(); if (!result.success) { throw new Error('Database query failed'); } // Validate each result with type guard const servers = (result.results as unknown[]).filter(isValidServer); const invalidCount = result.results.length - servers.length; if (invalidCount > 0) { console.warn(`[GetServers] Filtered out ${invalidCount} invalid server records`); } console.log('[GetServers] Found servers:', servers.length); return jsonResponse( { servers, count: servers.length, filters: { provider, minCpu, minMemory, region }, }, 200, corsHeaders ); } catch (error) { console.error('[GetServers] Error:', error); const requestId = crypto.randomUUID(); return jsonResponse( { error: 'Failed to retrieve servers', request_id: requestId, }, 500, corsHeaders ); } } /** * POST /api/recommend - AI-powered server recommendation */ async function handleRecommend( request: Request, env: Env, corsHeaders: Record ): Promise { const requestId = crypto.randomUUID(); try { // Parse and validate request const body = await request.json() as RecommendRequest; const lang = body.lang || 'en'; const validationError = validateRecommendRequest(body, lang); if (validationError) { return jsonResponse(validationError, 400, corsHeaders); } console.log('[Recommend] Request summary:', { tech_stack_count: body.tech_stack.length, expected_users: body.expected_users, use_case_length: body.use_case.length, traffic_pattern: body.traffic_pattern, has_region_pref: !!body.region_preference, has_budget: !!body.budget_limit, has_provider_filter: !!body.provider_filter, lang: lang, }); // Generate cache key const cacheKey = generateCacheKey(body); console.log('[Recommend] Cache key:', cacheKey); // Check cache (optional - may not be configured) if (env.CACHE) { const cached = await env.CACHE.get(cacheKey); if (cached) { console.log('[Recommend] Cache hit'); return jsonResponse( { ...JSON.parse(cached), cached: true }, 200, corsHeaders ); } } console.log('[Recommend] Cache miss or disabled'); // Phase 1: Execute independent queries in parallel const [techSpecs, benchmarkDataAll] = await Promise.all([ queryTechSpecs(env.DB, body.tech_stack), queryBenchmarkData(env.DB, body.tech_stack).catch(err => { console.warn('[Recommend] Benchmark data unavailable:', err.message); return [] as BenchmarkData[]; }), ]); console.log('[Recommend] Tech specs matched:', techSpecs.length); console.log('[Recommend] Benchmark data points (initial):', benchmarkDataAll.length); // Calculate minimum memory with proper aggregation // Memory-intensive services (Java, Elasticsearch, Redis): sum their memory requirements // Non-memory-intensive services: 256MB overhead each const memoryIntensiveSpecs = techSpecs.filter(s => s.is_memory_intensive); const otherSpecs = techSpecs.filter(s => !s.is_memory_intensive); let minMemoryMb: number | undefined; if (memoryIntensiveSpecs.length > 0 || otherSpecs.length > 0) { // Sum memory-intensive requirements const memoryIntensiveSum = memoryIntensiveSpecs.reduce((sum, s) => sum + s.min_memory_mb, 0); // Add 256MB overhead per non-memory-intensive service const otherOverhead = otherSpecs.length * 256; minMemoryMb = memoryIntensiveSum + otherOverhead; console.log(`[Recommend] Memory calculation: ${memoryIntensiveSpecs.length} memory-intensive (${(memoryIntensiveSum/1024).toFixed(1)}GB) + ${otherSpecs.length} other services (${(otherOverhead/1024).toFixed(1)}GB) = ${(minMemoryMb/1024).toFixed(1)}GB total`); } // Calculate minimum vCPU with category-based weighting // Different tech categories have different bottleneck characteristics let minVcpu: number | undefined; if (techSpecs.length > 0) { // Group specs by category const categoryWeights: Record = { 'web_server': 0.1, // nginx, apache: reverse proxy uses minimal resources 'runtime': 1.0, // nodejs, php, python: actual computation 'database': 1.0, // mysql, postgresql, mongodb: major bottleneck 'cache': 0.5, // redis, memcached: supporting role 'search': 0.8, // elasticsearch: CPU-intensive but not always primary 'container': 0.3, // docker: orchestration overhead 'messaging': 0.5, // rabbitmq, kafka: I/O bound 'default': 0.7 // unknown categories }; // Calculate weighted vCPU requirements per category const categoryRequirements = new Map(); for (const spec of techSpecs) { const category = spec.category || 'default'; const weight = categoryWeights[category] || categoryWeights['default']; const vcpuNeeded = Math.ceil(body.expected_users / spec.vcpu_per_users); const weightedVcpu = vcpuNeeded * weight; const existing = categoryRequirements.get(category) || 0; // Take max within same category (not additive) categoryRequirements.set(category, Math.max(existing, weightedVcpu)); console.log(`[Recommend] ${spec.name} (${category}): ${vcpuNeeded} vCPU × ${weight} weight = ${weightedVcpu.toFixed(1)} weighted vCPU`); } // Find bottleneck: use MAX across categories, not SUM // Request flow (web_server → runtime → database) means the slowest component is the bottleneck // SUM would over-provision since components process the SAME requests sequentially let maxWeightedVcpu = 0; let bottleneckCategory = ''; for (const [category, vcpu] of categoryRequirements) { console.log(`[Recommend] Category '${category}': ${vcpu.toFixed(1)} weighted vCPU`); if (vcpu > maxWeightedVcpu) { maxWeightedVcpu = vcpu; bottleneckCategory = category; } } minVcpu = Math.max(Math.ceil(maxWeightedVcpu), 1); // At least 1 vCPU console.log(`[Recommend] Bottleneck: '${bottleneckCategory}' with ${maxWeightedVcpu.toFixed(1)} weighted vCPU → ${minVcpu} vCPU (for ${body.expected_users} users)`); } // Calculate bandwidth estimate for provider filtering const bandwidthEstimate = estimateBandwidth(body.expected_users, body.use_case, body.traffic_pattern); console.log(`[Recommend] Bandwidth estimate: ${bandwidthEstimate.monthly_tb >= 1 ? bandwidthEstimate.monthly_tb + ' TB' : bandwidthEstimate.monthly_gb + ' GB'}/month (${bandwidthEstimate.category})`); // Phase 2: Query candidate servers (depends on minMemoryMb, minVcpu, bandwidth) const candidates = await queryCandidateServers(env.DB, body, minMemoryMb, minVcpu, bandwidthEstimate, lang); console.log('[Recommend] Candidate servers:', candidates.length); if (candidates.length === 0) { return jsonResponse( { error: 'No servers found matching your requirements', recommendations: [], request_id: requestId, }, 200, corsHeaders ); } // Calculate average specs from candidates for VPS benchmark queries const avgCores = Math.round( candidates.reduce((sum, s) => sum + s.vcpu, 0) / candidates.length ); const avgMemory = Math.round( candidates.reduce((sum, s) => sum + s.memory_gb, 0) / candidates.length ); // Use initially fetched benchmark data (already filtered by tech stack) const benchmarkData = benchmarkDataAll; // Get unique providers from candidates const providers = [...new Set(candidates.map((c) => c.provider_name))]; console.log('[Recommend] Providers:', providers); // Query VPS benchmarks using consolidated single query (includes provider-specific and spec-based matching) const vpsBenchmarks = await queryVPSBenchmarksBatch(env.DB, avgCores, avgMemory, providers).catch(err => { console.warn('[Recommend] VPS benchmark data unavailable:', err.message); return [] as VPSBenchmark[]; }); console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length); // Use OpenAI GPT-4o-mini to analyze and recommend (techSpecs already queried above) const aiResult = await getAIRecommendations( env.OPENAI_API_KEY, body, candidates, benchmarkData, vpsBenchmarks, techSpecs, lang ); console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length); const response = { recommendations: aiResult.recommendations, infrastructure_tips: aiResult.infrastructure_tips || [], total_candidates: candidates.length, cached: false, }; // Cache result for 1 hour (if cache is configured) if (env.CACHE) { await env.CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 3600, }); } return jsonResponse(response, 200, corsHeaders); } catch (error) { console.error('[Recommend] Error:', error); console.error('[Recommend] Error stack:', error instanceof Error ? error.stack : 'No stack'); return jsonResponse( { error: 'Failed to generate recommendations', details: error instanceof Error ? error.message : 'Unknown error', request_id: requestId, }, 500, corsHeaders ); } } /** * Type guard to validate Server object structure */ function isValidServer(obj: unknown): obj is Server { if (!obj || typeof obj !== 'object') return false; const s = obj as Record; return ( typeof s.id === 'number' && typeof s.provider_name === 'string' && typeof s.instance_id === 'string' && typeof s.vcpu === 'number' && typeof s.memory_mb === 'number' && typeof s.monthly_price === 'number' ); } /** * Type guard to validate VPSBenchmark object structure */ function isValidVPSBenchmark(obj: unknown): obj is VPSBenchmark { if (!obj || typeof obj !== 'object') return false; const v = obj as Record; return ( typeof v.id === 'number' && typeof v.provider_name === 'string' && typeof v.vcpu === 'number' && typeof v.geekbench_single === 'number' ); } /** * Type guard to validate TechSpec object structure */ function isValidTechSpec(obj: unknown): obj is TechSpec { if (!obj || typeof obj !== 'object') return false; const t = obj as Record; return ( typeof t.id === 'number' && typeof t.name === 'string' && typeof t.vcpu_per_users === 'number' && typeof t.min_memory_mb === 'number' ); } /** * Type guard to validate BenchmarkData object structure */ function isValidBenchmarkData(obj: unknown): obj is BenchmarkData { if (!obj || typeof obj !== 'object') return false; const b = obj as Record; return ( typeof b.id === 'number' && typeof b.processor_name === 'string' && typeof b.benchmark_name === 'string' && typeof b.score === 'number' ); } /** * Type guard to validate AI recommendation structure */ function isValidAIRecommendation(obj: unknown): obj is AIRecommendationResponse['recommendations'][0] { if (!obj || typeof obj !== 'object') return false; const r = obj as Record; return ( (typeof r.server_id === 'number' || typeof r.server_id === 'string') && typeof r.score === 'number' && r.analysis !== null && typeof r.analysis === 'object' && r.estimated_capacity !== null && typeof r.estimated_capacity === 'object' ); } /** * Validate recommendation request */ function validateRecommendRequest(body: any, lang: string = 'en'): ValidationError | null { // Ensure lang is valid const validLang = ['en', 'zh', 'ja', 'ko'].includes(lang) ? lang : 'en'; const messages = i18n[validLang]; if (!body || typeof body !== 'object') { return { error: 'Request body must be a JSON object', missing_fields: ['tech_stack', 'expected_users', 'use_case'], schema: messages.schema, example: messages.example }; } const missingFields: string[] = []; const invalidFields: { field: string; reason: string }[] = []; // Check required fields if (!body.tech_stack) { missingFields.push('tech_stack'); } else if (!Array.isArray(body.tech_stack) || body.tech_stack.length === 0) { invalidFields.push({ field: 'tech_stack', reason: 'must be a non-empty array of strings' }); } else if (body.tech_stack.length > 20) { invalidFields.push({ field: 'tech_stack', reason: 'must not exceed 20 items' }); } else if (!body.tech_stack.every((item: any) => typeof item === 'string')) { invalidFields.push({ field: 'tech_stack', reason: 'all items must be strings' }); } if (body.expected_users === undefined) { missingFields.push('expected_users'); } else if (typeof body.expected_users !== 'number' || body.expected_users < 1) { invalidFields.push({ field: 'expected_users', reason: 'must be a positive number' }); } else if (body.expected_users > 10000000) { invalidFields.push({ field: 'expected_users', reason: 'must not exceed 10,000,000' }); } if (!body.use_case) { missingFields.push('use_case'); } else if (typeof body.use_case !== 'string' || body.use_case.trim().length === 0) { invalidFields.push({ field: 'use_case', reason: 'must be a non-empty string' }); } else if (body.use_case.length > 500) { invalidFields.push({ field: 'use_case', reason: 'must not exceed 500 characters' }); } // Check optional fields if provided if (body.traffic_pattern !== undefined && !['steady', 'spiky', 'growing'].includes(body.traffic_pattern)) { invalidFields.push({ field: 'traffic_pattern', reason: "must be one of: 'steady', 'spiky', 'growing'" }); } if (body.region_preference !== undefined) { if (!Array.isArray(body.region_preference)) { invalidFields.push({ field: 'region_preference', reason: 'must be an array' }); } else if (body.region_preference.length > 10) { invalidFields.push({ field: 'region_preference', reason: 'must not exceed 10 items' }); } else if (!body.region_preference.every((item: any) => typeof item === 'string')) { invalidFields.push({ field: 'region_preference', reason: 'all items must be strings' }); } } if (body.budget_limit !== undefined && (typeof body.budget_limit !== 'number' || body.budget_limit < 0)) { invalidFields.push({ field: 'budget_limit', reason: 'must be a non-negative number' }); } if (body.provider_filter !== undefined) { if (!Array.isArray(body.provider_filter)) { invalidFields.push({ field: 'provider_filter', reason: 'must be an array' }); } else if (body.provider_filter.length > 10) { invalidFields.push({ field: 'provider_filter', reason: 'must not exceed 10 items' }); } else if (!body.provider_filter.every((item: any) => typeof item === 'string')) { invalidFields.push({ field: 'provider_filter', reason: 'all items must be strings' }); } } // Validate lang field if provided if (body.lang !== undefined && !['en', 'zh', 'ja', 'ko'].includes(body.lang)) { invalidFields.push({ field: 'lang', reason: "must be one of: 'en', 'zh', 'ja', 'ko'" }); } // Return error if any issues found if (missingFields.length > 0 || invalidFields.length > 0) { return { error: missingFields.length > 0 ? messages.missingFields : messages.invalidFields, ...(missingFields.length > 0 && { missing_fields: missingFields }), ...(invalidFields.length > 0 && { invalid_fields: invalidFields }), schema: messages.schema, example: messages.example }; } return null; } /** * Escape LIKE pattern special characters */ function escapeLikePattern(pattern: string): string { return pattern.replace(/[%_\\]/g, '\\$&'); } /** * Query candidate servers from database * @param minMemoryMb - Minimum memory requirement from tech specs (optional) * @param minVcpu - Minimum vCPU requirement based on expected users (optional) * @param bandwidthEstimate - Bandwidth estimate for provider prioritization (optional) * @param lang - Language for currency selection: 'ko' → KRW, others → retail USD */ async function queryCandidateServers( db: D1Database, req: RecommendRequest, minMemoryMb?: number, minVcpu?: number, bandwidthEstimate?: BandwidthEstimate, lang: string = 'en' ): Promise { // Select price column based on language // Korean → monthly_price_krw (KRW), Others → monthly_price_retail (1.21x USD) const priceColumn = lang === 'ko' ? 'pr.monthly_price_krw' : 'pr.monthly_price_retail'; const currency = lang === 'ko' ? 'KRW' : 'USD'; // Check if region preference is specified const hasRegionPref = req.region_preference && req.region_preference.length > 0; let query = ` SELECT it.id, p.display_name as provider_name, it.instance_id, it.instance_name, it.vcpu, it.memory_mb, ROUND(it.memory_mb / 1024.0, 1) as memory_gb, it.storage_gb, it.network_speed_gbps, it.instance_family, it.gpu_count, it.gpu_type, MIN(${priceColumn}) as monthly_price, '${currency}' as currency, r.region_name as region_name, r.region_code as region_code, r.country_code as country_code FROM instance_types it JOIN providers p ON it.provider_id = p.id JOIN pricing pr ON pr.instance_type_id = it.id JOIN regions r ON pr.region_id = r.id WHERE p.id IN (1, 2) -- Linode, Vultr only `; const params: (string | number)[] = []; if (req.budget_limit) { // Use same price column as display for budget filtering query += ` AND ${priceColumn} <= ?`; params.push(req.budget_limit); } // Filter by minimum memory requirement (from tech specs) if (minMemoryMb && minMemoryMb > 0) { query += ` AND it.memory_mb >= ?`; params.push(minMemoryMb); console.log(`[Candidates] Filtering by minimum memory: ${minMemoryMb}MB (${(minMemoryMb/1024).toFixed(1)}GB)`); } // Filter by minimum vCPU requirement (from expected users + tech specs) if (minVcpu && minVcpu > 0) { query += ` AND it.vcpu >= ?`; params.push(minVcpu); 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 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`); } else if (bandwidthEstimate.category === 'heavy') { // 2-6TB/month: Prefer Linode, but allow Vultr // Order by Linode first (handled in ORDER BY) console.log(`[Candidates] Heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode preferred`); } } // Country name to code mapping for common names // Note: Use specific city names to avoid LIKE pattern collisions (e.g., 'de' matches 'Delhi') const countryNameToCode: Record = { 'korea': ['seoul', 'ap-northeast-2'], 'south korea': ['seoul', 'ap-northeast-2'], 'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'], 'singapore': ['singapore', 'ap-southeast-1'], 'indonesia': ['jakarta', 'ap-southeast-3'], 'india': ['mumbai', 'delhi', 'bangalore', 'hyderabad', 'ap-south-1'], 'australia': ['sydney', 'melbourne', 'ap-southeast-2'], 'germany': ['frankfurt', 'nuremberg', 'falkenstein', 'eu-central-1'], 'usa': ['us-east', 'us-west', 'virginia', 'oregon', 'ohio'], 'united states': ['us-east', 'us-west', 'virginia', 'oregon', 'ohio'], 'uk': ['london', 'manchester', 'eu-west-2'], 'united kingdom': ['london', 'manchester', 'eu-west-2'], 'netherlands': ['amsterdam', 'eu-west-1'], 'france': ['paris', 'eu-west-3'], 'hong kong': ['hong kong', 'ap-east-1'], 'taiwan': ['taipei', 'ap-northeast-1'], 'brazil': ['sao paulo', 'sa-east-1'], 'canada': ['montreal', 'toronto', 'ca-central-1'], }; // Flexible region matching: region_code, region_name, or country_code if (req.region_preference && req.region_preference.length > 0) { // User specified region → filter to that region only const regionConditions: string[] = []; for (const region of req.region_preference) { const lowerRegion = region.toLowerCase(); // Expand country names to their codes/cities const expandedRegions = countryNameToCode[lowerRegion] || [lowerRegion]; const allRegions = [lowerRegion, ...expandedRegions]; for (const r of allRegions) { const escapedRegion = escapeLikePattern(r); regionConditions.push(`( LOWER(r.region_code) = ? OR LOWER(r.region_code) LIKE ? ESCAPE '\\' OR LOWER(r.region_name) LIKE ? ESCAPE '\\' OR LOWER(r.country_code) = ? )`); params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r); } } query += ` AND (${regionConditions.join(' OR ')})`; } else { // No region specified → default to Seoul/Tokyo/Osaka/Singapore query += ` AND ( -- Korea (Seoul) r.region_code IN ('icn', 'ap-northeast-2') OR LOWER(r.region_name) LIKE '%seoul%' OR -- Japan (Tokyo, Osaka) r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR LOWER(r.region_code) LIKE '%tyo%' OR LOWER(r.region_code) LIKE '%osa%' OR LOWER(r.region_name) LIKE '%tokyo%' OR LOWER(r.region_name) LIKE '%osaka%' OR -- Singapore r.region_code IN ('sgp', 'ap-southeast-1') OR LOWER(r.region_code) LIKE '%sin%' OR LOWER(r.region_code) LIKE '%sgp%' OR LOWER(r.region_name) LIKE '%singapore%' )`; } // Filter by provider if specified if (req.provider_filter && req.provider_filter.length > 0) { const placeholders = req.provider_filter.map(() => '?').join(','); query += ` AND (p.name IN (${placeholders}) OR p.display_name IN (${placeholders}))`; params.push(...req.provider_filter, ...req.provider_filter); } // 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' ? `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`; const result = await db.prepare(query).bind(...params).all(); if (!result.success) { throw new Error('Failed to query candidate servers'); } // Validate each result with type guard const validServers = (result.results as unknown[]).filter(isValidServer); const invalidCount = result.results.length - validServers.length; if (invalidCount > 0) { console.warn(`[Candidates] Filtered out ${invalidCount} invalid server records`); } return validServers; } /** * Query relevant benchmark data for tech stack */ async function queryBenchmarkData( db: D1Database, techStack: string[], coreCount?: number ): Promise { // Map tech stack to relevant benchmark types const techToBenchmark: Record = { 'node.js': ['pts-node-octane', 'pts-node-express-loadtest'], 'nodejs': ['pts-node-octane', 'pts-node-express-loadtest'], 'express': ['pts-node-express-loadtest'], 'nginx': ['pts-nginx'], 'apache': ['pts-apache'], 'php': ['pts-phpbench'], 'redis': ['pts-redis'], 'mysql': ['pts-mysqlslap'], 'postgresql': ['pts-mysqlslap'], // Use MySQL benchmark as proxy 'docker': ['pts-compress-7zip', 'pts-postmark'], // CPU + I/O for containers 'mongodb': ['pts-postmark'], // I/O intensive 'python': ['pts-coremark', 'pts-compress-7zip'], 'java': ['pts-coremark', 'pts-compress-7zip'], 'go': ['pts-coremark', 'pts-compress-7zip'], 'rust': ['pts-coremark', 'pts-compress-7zip'], }; // Find relevant benchmark types const relevantBenchmarks = new Set(); for (const tech of techStack) { const benchmarks = techToBenchmark[tech.toLowerCase()] || []; benchmarks.forEach(b => relevantBenchmarks.add(b)); } // Always include general CPU benchmark relevantBenchmarks.add('pts-compress-7zip'); if (relevantBenchmarks.size === 0) { return []; } const benchmarkNames = Array.from(relevantBenchmarks); const placeholders = benchmarkNames.map(() => '?').join(','); // Query benchmark data, optionally filtering by core count let query = ` SELECT p.id, p.name as processor_name, bt.name as benchmark_name, bt.category, br.score, br.percentile, p.cores FROM benchmark_results br JOIN processors p ON br.processor_id = p.id JOIN benchmark_types bt ON br.benchmark_type_id = bt.id WHERE bt.name IN (${placeholders}) `; const params: (string | number)[] = [...benchmarkNames]; // If we know core count, filter to similar processors if (coreCount && coreCount > 0) { query += ` AND (p.cores IS NULL OR (p.cores >= ? AND p.cores <= ?))`; params.push(Math.max(1, coreCount - 2), coreCount + 4); } query += ` ORDER BY br.percentile DESC, br.score DESC LIMIT 50`; const result = await db.prepare(query).bind(...params).all(); if (!result.success) { console.warn('[Benchmark] Query failed'); return []; } // Validate each result with type guard return (result.results as unknown[]).filter(isValidBenchmarkData); } /** * Get benchmark reference for a server */ function getBenchmarkReference( benchmarks: BenchmarkData[], vcpu: number ): BenchmarkReference | undefined { // Find benchmarks from processors with similar core count const similarBenchmarks = benchmarks.filter(b => b.cores === null || (b.cores >= vcpu - 2 && b.cores <= vcpu + 4) ); if (similarBenchmarks.length === 0) { return undefined; } // Group by processor and get the best match const byProcessor = new Map(); for (const b of similarBenchmarks) { const existing = byProcessor.get(b.processor_name) || []; existing.push(b); byProcessor.set(b.processor_name, existing); } // Find processor with most benchmark data let bestProcessor = ''; let maxBenchmarks = 0; for (const [name, data] of byProcessor) { if (data.length > maxBenchmarks) { maxBenchmarks = data.length; bestProcessor = name; } } if (!bestProcessor) { return undefined; } const processorBenchmarks = byProcessor.get(bestProcessor)!; return { processor_name: bestProcessor, benchmarks: processorBenchmarks.map(b => ({ name: b.benchmark_name, category: b.category, score: b.score, percentile: b.percentile, })), }; } /** * Query VPS benchmarks - prioritize matching provider */ async function queryVPSBenchmarks( db: D1Database, vcpu: number, memoryGb: number, providerHint?: string ): Promise { const vcpuMin = Math.max(1, vcpu - 1); const vcpuMax = vcpu + 2; const memMin = Math.max(1, memoryGb - 2); const memMax = memoryGb + 4; // First try to find benchmarks from the same provider if (providerHint) { const providerQuery = ` SELECT * FROM vps_benchmarks WHERE (LOWER(provider_name) LIKE ? ESCAPE '\\' OR LOWER(plan_name) LIKE ? ESCAPE '\\') ORDER BY gb6_single_normalized DESC LIMIT 20 `; const escapedHint = escapeLikePattern(providerHint.toLowerCase()); const providerPattern = `%${escapedHint}%`; const providerResult = await db.prepare(providerQuery).bind(providerPattern, providerPattern).all(); if (providerResult.success && providerResult.results.length > 0) { // Validate each result with type guard return (providerResult.results as unknown[]).filter(isValidVPSBenchmark); } } // Fallback: Find VPS with similar specs const query = ` SELECT * FROM vps_benchmarks WHERE vcpu >= ? AND vcpu <= ? AND memory_gb >= ? AND memory_gb <= ? ORDER BY gb6_single_normalized DESC LIMIT 10 `; const result = await db.prepare(query).bind(vcpuMin, vcpuMax, memMin, memMax).all(); if (!result.success) { return []; } // Validate each result with type guard return (result.results as unknown[]).filter(isValidVPSBenchmark); } /** * Query VPS benchmarks in a single batched query * Consolidates multiple provider-specific queries into one for better performance */ async function queryVPSBenchmarksBatch( db: D1Database, vcpu: number, memoryGb: number, providers: string[] ): Promise { const vcpuMin = Math.max(1, vcpu - 1); const vcpuMax = vcpu + 2; const memMin = Math.max(1, memoryGb - 2); const memMax = memoryGb + 4; // Build provider conditions for up to 3 providers const providerConditions: string[] = []; const params: (string | number)[] = []; const limitedProviders = providers.slice(0, 3); for (const provider of limitedProviders) { const pattern = `%${escapeLikePattern(provider.toLowerCase())}%`; providerConditions.push(`(LOWER(provider_name) LIKE ? ESCAPE '\\' OR LOWER(plan_name) LIKE ? ESCAPE '\\')`); params.push(pattern, pattern); } // Build query with provider matching OR spec matching const query = ` SELECT * FROM vps_benchmarks WHERE ${providerConditions.length > 0 ? `(${providerConditions.join(' OR ')})` : '1=0'} OR (vcpu >= ? AND vcpu <= ? AND memory_gb >= ? AND memory_gb <= ?) ORDER BY gb6_single_normalized DESC LIMIT 30 `; params.push(vcpuMin, vcpuMax, memMin, memMax); const result = await db.prepare(query).bind(...params).all(); if (!result.success) { console.warn('[VPSBenchmarksBatch] Query failed'); return []; } // Validate each result with type guard return (result.results as unknown[]).filter(isValidVPSBenchmark); } /** * Format VPS benchmark data for AI prompt * Uses GB6-normalized scores (GB5 scores converted with ×1.45 factor) */ function formatVPSBenchmarkSummary(benchmarks: VPSBenchmark[]): string { if (benchmarks.length === 0) { return ''; } const lines = ['Real VPS performance data (Geekbench 6 normalized):']; for (const b of benchmarks.slice(0, 5)) { const versionNote = b.geekbench_version?.startsWith('5.') ? ' [GB5→6]' : ''; lines.push( `- ${b.plan_name} (${b.country_code}): Single=${b.gb6_single_normalized}, Multi=${b.gb6_multi_normalized}${versionNote}, $${b.monthly_price_usd}/mo, Perf/$=${b.performance_per_dollar.toFixed(1)}` ); } return lines.join('\n'); } /** * Format benchmark data for AI prompt */ function formatBenchmarkSummary(benchmarks: BenchmarkData[]): string { if (benchmarks.length === 0) { return ''; } // Group by benchmark type const byType = new Map(); for (const b of benchmarks) { const existing = byType.get(b.benchmark_name) || []; existing.push(b); byType.set(b.benchmark_name, existing); } const lines: string[] = []; for (const [type, data] of byType) { // Get top 3 performers for this benchmark const top3 = data.slice(0, 3); const scores = top3.map(d => `${d.processor_name}${d.cores ? ` (${d.cores} cores)` : ''}: ${d.score} (${d.percentile}th percentile)` ); lines.push(`### ${type} (${data[0].category})`); lines.push(scores.join('\n')); lines.push(''); } return lines.join('\n'); } /** * Query tech stack specifications from database * Matches user's tech_stack against canonical names and aliases */ async function queryTechSpecs( db: D1Database, techStack: string[] ): Promise { if (!techStack || techStack.length === 0) { return []; } // Normalize user input const normalizedStack = techStack.map(t => t.toLowerCase().trim()); // Build query that matches both name and aliases (case-insensitive) // Using LOWER() for alias matching since aliases are stored as JSON array strings const conditions: string[] = []; const params: string[] = []; for (const tech of normalizedStack) { conditions.push(`(LOWER(name) = ? OR LOWER(aliases) LIKE ?)`); params.push(tech, `%"${tech}"%`); } const query = ` SELECT id, name, category, vcpu_per_users, vcpu_per_users_max, min_memory_mb, max_memory_mb, description, aliases, is_memory_intensive, is_cpu_intensive FROM tech_specs WHERE ${conditions.join(' OR ')} ORDER BY category, name `; try { const result = await db.prepare(query).bind(...params).all(); if (!result.success) { console.warn('[TechSpecs] Query failed'); return []; } // Validate each result with type guard const validSpecs = (result.results as unknown[]).filter(isValidTechSpec); console.log(`[TechSpecs] Found ${validSpecs.length} specs for: ${normalizedStack.join(', ')}`); return validSpecs; } catch (error) { console.error('[TechSpecs] Error:', error); return []; } } /** * Format tech specs for AI prompt */ function formatTechSpecsForPrompt(techSpecs: TechSpec[]): string { if (!techSpecs || techSpecs.length === 0) { return `Tech stack resource guidelines: - Default: 1 vCPU per 100-300 users, 1-2GB RAM`; } const lines = ['Tech stack resource guidelines (MUST follow minimum RAM requirements):']; for (const spec of techSpecs) { const vcpuRange = spec.vcpu_per_users_max ? `${spec.vcpu_per_users}-${spec.vcpu_per_users_max}` : `${spec.vcpu_per_users}`; // Convert MB to GB for readability const minMemoryGB = (spec.min_memory_mb / 1024).toFixed(1).replace('.0', ''); const maxMemoryGB = spec.max_memory_mb ? (spec.max_memory_mb / 1024).toFixed(1).replace('.0', '') : null; const memoryRange = maxMemoryGB ? `${minMemoryGB}-${maxMemoryGB}GB` : `${minMemoryGB}GB+`; let line = `- ${spec.name}: 1 vCPU per ${vcpuRange} users, MINIMUM ${minMemoryGB}GB RAM`; // Add warnings for special requirements const warnings: string[] = []; if (spec.is_memory_intensive) warnings.push('⚠️ MEMORY-INTENSIVE: must have at least ' + minMemoryGB + 'GB RAM'); if (spec.is_cpu_intensive) warnings.push('⚠️ CPU-INTENSIVE'); if (warnings.length > 0) { line += ` [${warnings.join(', ')}]`; } lines.push(line); } // Add explicit warning for memory-intensive apps const memoryIntensive = techSpecs.filter(s => s.is_memory_intensive); if (memoryIntensive.length > 0) { const maxMinMemory = Math.max(...memoryIntensive.map(s => s.min_memory_mb)); lines.push(''); lines.push(`⚠️ CRITICAL: This tech stack includes memory-intensive apps. Servers with less than ${(maxMinMemory / 1024).toFixed(0)}GB RAM will NOT work properly!`); } return lines.join('\n'); } /** * Get AI-powered recommendations using OpenAI GPT-4o-mini */ async function getAIRecommendations( apiKey: string, req: RecommendRequest, candidates: Server[], benchmarkData: BenchmarkData[], vpsBenchmarks: VPSBenchmark[], techSpecs: TechSpec[], lang: string = 'en' ): Promise<{ recommendations: RecommendationResult[]; infrastructure_tips?: string[] }> { // Build dynamic tech specs prompt from database const techSpecsPrompt = formatTechSpecsForPrompt(techSpecs); // Ensure lang is valid const validLang = ['en', 'zh', 'ja', 'ko'].includes(lang) ? lang : 'en'; const languageInstruction = i18n[validLang].aiLanguageInstruction; // Build system prompt with benchmark awareness const systemPrompt = `You are a cloud infrastructure expert focused on COST-EFFECTIVE solutions. Your goal is to recommend the SMALLEST and CHEAPEST server that can handle the user's requirements. CRITICAL RULES: 1. NEVER over-provision. Recommend the minimum specs needed. 2. Cost efficiency is the PRIMARY factor - cheaper is better if it meets requirements. 3. A 1-2 vCPU server can handle 100-500 concurrent users for most web workloads. 4. Nginx/reverse proxy needs very little resources - 1 vCPU can handle 1000+ req/sec. 5. Provide 3 options: Budget (cheapest viable), Balanced (some headroom), Premium (growth ready). BANDWIDTH CONSIDERATIONS (VERY IMPORTANT): - Estimated monthly bandwidth is provided based on concurrent users and use case. - TOTAL COST = Base server price + Bandwidth overage charges - Provider bandwidth allowances: * Linode: 1TB (1GB plan) to 20TB (192GB plan) included free, $0.005/GB overage * Vultr: 1TB-10TB depending on plan, $0.01/GB overage (2x Linode rate) * DigitalOcean: 1TB-12TB depending on plan, $0.01/GB overage - For bandwidth >1TB/month: Linode is often cheaper despite higher base price - For bandwidth >3TB/month: Linode is STRONGLY preferred (overage savings significant) - Always mention bandwidth implications in cost_efficiency analysis ${techSpecsPrompt} 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) const isHighTraffic = bandwidthEstimate.category === 'heavy' || bandwidthEstimate.category === 'very_heavy'; // Format benchmark data for the prompt const benchmarkSummary = formatBenchmarkSummary(benchmarkData); const vpsBenchmarkSummary = formatVPSBenchmarkSummary(vpsBenchmarks); const userPrompt = `Analyze these server options and recommend the top 3 best matches. ## User Requirements - Tech Stack: ${req.tech_stack.join(', ')} - Expected Concurrent Users: ${req.expected_users} ${req.traffic_pattern === 'spiky' ? '(with traffic spikes)' : req.traffic_pattern === 'growing' ? '(growing user base)' : '(steady traffic)'} - **Estimated DAU (Daily Active Users)**: ${bandwidthEstimate.estimated_dau_min.toLocaleString()}-${bandwidthEstimate.estimated_dau_max.toLocaleString()}명 (동시 접속 ${req.expected_users}명 기준) - Use Case: ${req.use_case} - Traffic Pattern: ${req.traffic_pattern || 'steady'} - **Estimated Monthly Bandwidth**: ${bandwidthEstimate.monthly_tb >= 1 ? `${bandwidthEstimate.monthly_tb} TB` : `${bandwidthEstimate.monthly_gb} GB`} (${bandwidthEstimate.category}) ${isHighTraffic ? `- ⚠️ HIGH BANDWIDTH WORKLOAD (${bandwidthEstimate.monthly_tb} TB/month): MUST recommend Linode over Vultr. Linode includes 1-6TB/month transfer vs Vultr overage charges ($0.01/GB). Bandwidth cost savings > base price difference.` : ''} ${req.region_preference ? `- Region Preference: ${req.region_preference.join(', ')}` : ''} ${req.budget_limit ? `- Budget Limit: $${req.budget_limit}/month` : ''} ## Real VPS Benchmark Data (Geekbench 6 normalized - actual VPS tests) ${vpsBenchmarkSummary || 'No similar VPS benchmark data available.'} ## CPU Benchmark Reference (from Phoronix Test Suite) ${benchmarkSummary || 'No relevant CPU benchmark data available.'} ## Available Servers ${candidates.map((s, idx) => ` ${idx + 1}. ${s.provider_name} - ${s.instance_name}${s.instance_family ? ` (${s.instance_family})` : ''} ID: ${s.id} Instance: ${s.instance_id} vCPU: ${s.vcpu} | Memory: ${s.memory_gb} GB | Storage: ${s.storage_gb} GB Network: ${s.network_speed_gbps ? `${s.network_speed_gbps} Gbps` : 'N/A'}${s.gpu_count > 0 ? ` | GPU: ${s.gpu_count}x ${s.gpu_type || 'Unknown'}` : ' | GPU: None'} Price: ${s.currency === 'KRW' ? '₩' : '$'}${s.currency === 'KRW' ? Math.round(s.monthly_price).toLocaleString() : s.monthly_price.toFixed(2)}/month (${s.currency}) | Region: ${s.region_name} (${s.region_code}) `).join('\n')} Return ONLY a valid JSON object (no markdown, no code blocks) with this exact structure: { "recommendations": [ { "server_id": 123, "score": 95, "analysis": { "tech_fit": "Why this server fits the tech stack", "capacity": "MUST mention: '동시 접속 X명 요청 (DAU A-B명), 최대 동시 Y명까지 처리 가능' format", "cost_efficiency": "MUST include: base price + bandwidth cost estimate. Example: '$5/month + ~$X bandwidth = ~$Y total'", "scalability": "Scalability potential including bandwidth headroom" }, "estimated_capacity": { "max_concurrent_users": 7500, "requests_per_second": 1000 } } ], "infrastructure_tips": [ "Practical tip 1", "Practical tip 2" ] } Provide exactly 3 recommendations: 1. BUDGET option: Cheapest TOTAL cost (base + bandwidth) that can handle the load (highest score if viable) 2. BALANCED option: Some headroom for traffic spikes 3. PREMIUM option: Ready for 2-3x growth SCORING (100 points total): - Total Cost Efficiency (40%): Base price + estimated bandwidth overage. Lower total = higher score. - Capacity Fit (30%): Can it handle the concurrent users and bandwidth? - Scalability (30%): Room for growth in CPU, memory, AND bandwidth allowance. 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'); // 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', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, }, body: JSON.stringify({ model: 'gpt-4o-mini', messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], max_tokens: 2000, temperature: 0.3, }), signal: controller.signal, }); clearTimeout(timeoutId); 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); throw new Error(`OpenAI API error: ${openaiResponse.status}`); } const openaiResult = await openaiResponse.json() as { choices: Array<{ message: { content: string } }>; }; const response = openaiResult.choices[0]?.message?.content || ''; console.log('[AI] Response received from OpenAI, length:', response.length); console.log('[AI] Raw response preview:', response.substring(0, 500)); // Parse AI response const aiResult = parseAIResponse(response); console.log('[AI] Parsed recommendations count:', aiResult.recommendations.length); // Pre-index VPS benchmarks by provider for O(1) lookups const vpsByProvider = new Map(); for (const vps of vpsBenchmarks) { const providerKey = vps.provider_name.toLowerCase(); const existing = vpsByProvider.get(providerKey) || []; existing.push(vps); vpsByProvider.set(providerKey, existing); } // Map AI recommendations to full results const results: RecommendationResult[] = []; for (const aiRec of aiResult.recommendations) { // Handle both string and number server_id from AI const serverId = Number(aiRec.server_id); const server = candidates.find((s) => s.id === serverId); if (!server) { console.warn('[AI] Server not found:', aiRec.server_id); continue; } // Get benchmark reference for this server's CPU count const benchmarkRef = getBenchmarkReference(benchmarkData, server.vcpu); // Find matching VPS benchmark using pre-indexed data const providerName = server.provider_name.toLowerCase(); let matchingVPS: VPSBenchmark | undefined; // Try to find from indexed provider benchmarks for (const [providerKey, benchmarks] of vpsByProvider.entries()) { if (providerKey.includes(providerName) || providerName.includes(providerKey)) { // First try exact or close vCPU match matchingVPS = benchmarks.find( (v) => v.vcpu === server.vcpu || (v.vcpu >= server.vcpu - 1 && v.vcpu <= server.vcpu + 1) ); // Fallback to any from this provider if (!matchingVPS && benchmarks.length > 0) { matchingVPS = benchmarks[0]; } if (matchingVPS) break; } } // Final fallback: similar specs from any provider if (!matchingVPS) { matchingVPS = vpsBenchmarks.find( (v) => v.vcpu === server.vcpu || (v.vcpu >= server.vcpu - 1 && v.vcpu <= server.vcpu + 1) ); } results.push({ server: server, score: aiRec.score, analysis: aiRec.analysis, estimated_capacity: aiRec.estimated_capacity, benchmark_reference: benchmarkRef, vps_benchmark_reference: matchingVPS ? { plan_name: matchingVPS.plan_name, geekbench_single: matchingVPS.geekbench_single, geekbench_multi: matchingVPS.geekbench_multi, monthly_price_usd: matchingVPS.monthly_price_usd, performance_per_dollar: matchingVPS.performance_per_dollar, } : undefined, }); if (results.length >= 3) break; } return { recommendations: results, infrastructure_tips: aiResult.infrastructure_tips, }; } catch (error) { clearTimeout(timeoutId); // Handle timeout specifically if (error instanceof Error && error.name === 'AbortError') { console.error('[AI] Request timed out after 30 seconds'); throw new Error('AI request timed out - please try again'); } console.error('[AI] Error:', error); throw new Error(`AI processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Parse AI response and extract JSON */ function parseAIResponse(response: any): AIRecommendationResponse { try { // Handle different response formats let content: string; if (typeof response === 'string') { content = response; } else if (response.response) { content = response.response; } else if (response.result && response.result.response) { content = response.result.response; } else if (response.choices && response.choices[0]?.message?.content) { content = response.choices[0].message.content; } else { console.error('[AI] Unexpected response format:', response); throw new Error('Unexpected AI response format'); } // Remove markdown code blocks if present content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); // Find JSON object in response const jsonMatch = content.match(/\{[\s\S]*\}/); if (!jsonMatch) { throw new Error('No JSON found in AI response'); } const parsed = JSON.parse(jsonMatch[0]); if (!parsed.recommendations || !Array.isArray(parsed.recommendations)) { throw new Error('Invalid recommendations structure'); } // Validate each recommendation with type guard const validRecommendations = parsed.recommendations.filter(isValidAIRecommendation); if (validRecommendations.length === 0 && parsed.recommendations.length > 0) { console.warn('[AI] All recommendations failed validation, raw:', JSON.stringify(parsed.recommendations[0]).slice(0, 200)); throw new Error('AI recommendations failed validation'); } return { recommendations: validRecommendations, infrastructure_tips: Array.isArray(parsed.infrastructure_tips) ? parsed.infrastructure_tips : [], } as AIRecommendationResponse; } catch (error) { console.error('[AI] Parse error:', error); console.error('[AI] Response was:', response); throw new Error(`Failed to parse AI response: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Sanitize special characters for cache key */ function sanitizeCacheValue(value: string): string { return value.replace(/[|:,]/g, '_'); } /** * Generate cache key from request parameters */ function generateCacheKey(req: RecommendRequest): string { // Don't mutate original arrays - create sorted copies const sortedStack = [...req.tech_stack].sort(); const sanitizedStack = sortedStack.map(sanitizeCacheValue).join(','); // Hash use_case to avoid special characters and length issues const useCaseHash = hashString(req.use_case); const parts = [ `stack:${sanitizedStack}`, `users:${req.expected_users}`, `case:${useCaseHash}`, ]; if (req.traffic_pattern) { parts.push(`traffic:${req.traffic_pattern}`); } if (req.region_preference) { const sortedRegions = [...req.region_preference].sort(); const sanitizedRegions = sortedRegions.map(sanitizeCacheValue).join(','); parts.push(`reg:${sanitizedRegions}`); } if (req.budget_limit) { parts.push(`budget:${req.budget_limit}`); } if (req.provider_filter && req.provider_filter.length > 0) { const sortedProviders = [...req.provider_filter].sort(); const sanitizedProviders = sortedProviders.map(sanitizeCacheValue).join(','); parts.push(`prov:${sanitizedProviders}`); } // Include language in cache key if (req.lang) { parts.push(`lang:${req.lang}`); } return `recommend:${parts.join('|')}`; } /** * Simple hash function for strings */ function hashString(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(36); } /** * JSON response helper */ function jsonResponse( data: any, status: number, headers: Record = {} ): Response { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json', 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Cache-Control': 'no-store', ...headers, }, }); }