/** * POST /api/recommend - AI-powered server recommendation handler */ import type { Env, RecommendRequest, Server, BenchmarkData, VPSBenchmark, TechSpec, BandwidthEstimate, RecommendationResult, BenchmarkReference, AIRecommendationResponse, AvailableRegion } from '../types'; import { i18n, LIMITS } from '../config'; import { jsonResponse, validateRecommendRequest, generateCacheKey, estimateBandwidth, calculateBandwidthInfo, escapeLikePattern, isValidServer, isValidBenchmarkData, isValidVPSBenchmark, isValidTechSpec, isValidAIRecommendation, sanitizeForAIPrompt, DEFAULT_REGION_FILTER_SQL, buildFlexibleRegionConditions } from '../utils'; export async function handleRecommend( request: Request, env: Env, corsHeaders: Record ): Promise { const requestId = crypto.randomUUID(); try { // Check request body size to prevent large payload attacks const contentLength = request.headers.get('Content-Length'); if (contentLength && parseInt(contentLength, 10) > LIMITS.MAX_REQUEST_BODY_BYTES) { return jsonResponse( { error: 'Request body too large', max_size: '10KB' }, 413, corsHeaders ); } // Parse and validate request with actual body size check const bodyText = await request.text(); const actualBodySize = new TextEncoder().encode(bodyText).length; if (actualBodySize > LIMITS.MAX_REQUEST_BODY_BYTES) { return jsonResponse( { error: 'Request body too large', max_size: '10KB', actual_size: actualBodySize }, 413, corsHeaders ); } const body = JSON.parse(bodyText) 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; // DB workload multiplier based on use_case (databases need different resources based on workload type) // Lower multiplier = heavier workload = more resources needed const getDbWorkloadMultiplier = (useCase: string): { multiplier: number; type: string } => { const lowerUseCase = useCase.toLowerCase(); // Heavy DB workloads (analytics, big data, reporting) - multiplier 0.3x // Note: use \blog(s|ging)?\b to match "log", "logs", "logging" but NOT "blog" if (/analytics|warehouse|reporting|dashboard|\bbi\b|olap|\blog(s|ging)?\b|metric|monitoring|time.?series|대시보드|분석|리포트|로그/.test(lowerUseCase)) { return { multiplier: 0.3, type: 'heavy (analytics/reporting)' }; } // Medium-heavy DB workloads (e-commerce, ERP, CRM, social) - multiplier 0.5x if (/e.?commerce|shop|store|cart|order|payment|erp|crm|inventory|social|community|forum|게시판|쇼핑몰|주문|결제|커뮤니티/.test(lowerUseCase)) { return { multiplier: 0.5, type: 'medium-heavy (transactional)' }; } // Medium DB workloads (API, SaaS, app backend) - multiplier 0.7x if (/api|saas|backend|service|app|mobile|플랫폼|서비스|앱/.test(lowerUseCase)) { return { multiplier: 0.7, type: 'medium (API/SaaS)' }; } // Light DB workloads (blog, landing, portfolio, docs) - multiplier 1.0x (use default) if (/blog|landing|portfolio|doc|wiki|static|personal|홈페이지|블로그|포트폴리오|문서/.test(lowerUseCase)) { return { multiplier: 1.0, type: 'light (content/read-heavy)' }; } // Default: medium workload return { multiplier: 0.7, type: 'default (medium)' }; }; const dbWorkload = getDbWorkloadMultiplier(body.use_case); console.log(`[Recommend] DB workload inferred from use_case: ${dbWorkload.type} (multiplier: ${dbWorkload.multiplier})`); 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']; // Apply DB workload multiplier for database category // Lower multiplier = heavier workload = higher resource needs (lower vcpu_per_users) let effectiveVcpuPerUsers = spec.vcpu_per_users; if (category === 'database') { effectiveVcpuPerUsers = Math.max(1, Math.floor(spec.vcpu_per_users * dbWorkload.multiplier)); } const vcpuNeeded = Math.ceil(body.expected_users / effectiveVcpuPerUsers); const weightedVcpu = vcpuNeeded * weight; const existing = categoryRequirements.get(category) || 0; // Take max within same category (not additive) categoryRequirements.set(category, Math.max(existing, weightedVcpu)); const dbNote = category === 'database' ? ` (adjusted for ${dbWorkload.type})` : ''; console.log(`[Recommend] ${spec.name} (${category}): ${vcpuNeeded} vCPU × ${weight} weight = ${weightedVcpu.toFixed(1)} weighted vCPU${dbNote}`); } // 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})`); // Estimate specs for VPS benchmark query (doesn't need exact candidates) const estimatedCores = minVcpu || 2; const estimatedMemory = minMemoryMb ? Math.ceil(minMemoryMb / 1024) : 4; const defaultProviders = bandwidthEstimate?.category === 'very_heavy' ? ['Linode'] : ['Linode', 'Vultr']; // Phase 2: Query candidate servers and VPS benchmarks in parallel const [candidates, vpsBenchmarks] = await Promise.all([ queryCandidateServers(env.DB, body, minMemoryMb, minVcpu, bandwidthEstimate, lang), queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); console.warn('[Recommend] VPS benchmarks unavailable:', message); return [] as VPSBenchmark[]; }), ]); console.log('[Recommend] Candidate servers:', candidates.length); console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length); if (candidates.length === 0) { return jsonResponse( { error: 'No servers found matching your requirements', recommendations: [], request_id: requestId, }, 200, corsHeaders ); } // Use initially fetched benchmark data (already filtered by tech stack) const benchmarkData = benchmarkDataAll; // 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 ); console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length); 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, }; // Cache result only if we have recommendations (don't cache empty/failed results) if (env.CACHE && response.recommendations && response.recommendations.length > 0) { await env.CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 300, // 5 minutes (reduced from 1 hour for faster iteration) }); } return jsonResponse(response, 200, corsHeaders); } catch (error) { console.error('[Recommend] Error:', error); console.error('[Recommend] Error stack:', error instanceof Error ? error.stack : 'No stack'); console.error('[Recommend] Error details:', error instanceof Error ? error.message : 'Unknown error'); return jsonResponse( { error: 'Failed to generate recommendations', request_id: requestId, }, 500, corsHeaders ); } } 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, 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 LOWER(p.name) IN ('linode', 'vultr') -- 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 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: 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 console.log(`[Candidates] Heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode preferred`); } } // Flexible region matching: region_code, region_name, or country_code if (req.region_preference && req.region_preference.length > 0) { const { conditions, params: regionParams } = buildFlexibleRegionConditions(req.region_preference); query += ` AND (${conditions.join(' OR ')})`; params.push(...regionParams); } else { // No region specified → default to Seoul/Tokyo/Osaka/Singapore query += ` AND ${DEFAULT_REGION_FILTER_SQL}`; } // 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/very_heavy bandwidth, prioritize Linode due to generous bandwidth allowance const isHighBandwidth = bandwidthEstimate?.category === 'heavy' || bandwidthEstimate?.category === 'very_heavy'; const orderByClause = isHighBandwidth ? `ORDER BY CASE WHEN LOWER(p.name) = 'linode' 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'); } // Add currency to each result and validate with type guard const serversWithCurrency = (result.results as unknown[]).map(server => { if (typeof server === 'object' && server !== null) { return { ...server, currency }; } return server; }); const validServers = serversWithCurrency.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 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( 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 (format: sk-***)'); // 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 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); // Pre-filter candidates to reduce AI prompt size and cost // Sort by price and limit to top 15 most affordable options const topCandidates = candidates .sort((a, b) => a.monthly_price - b.monthly_price) .slice(0, 15); console.log(`[AI] Filtered ${candidates.length} candidates to ${topCandidates.length} for AI analysis`); // Sanitize user inputs to prevent prompt injection const sanitizedTechStack = req.tech_stack.map(t => sanitizeForAIPrompt(t, 50)).join(', '); const sanitizedUseCase = sanitizeForAIPrompt(req.use_case, 200); const userPrompt = `Analyze these server options and recommend the top 3 best matches. ## User Requirements - Tech Stack: ${sanitizedTechStack} - 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: ${sanitizedUseCase} - 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 (IMPORTANT: Use the server_id value, NOT the list number!) ${topCandidates.map((s) => ` [server_id=${s.id}] ${s.provider_name} - ${s.instance_name}${s.instance_family ? ` (${s.instance_family})` : ''} 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": 2045, // Use the actual server_id from [server_id=XXXX] above, NOT list position! "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.`; // 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(apiEndpoint, { 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(); // 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}`); } 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) ); } // Calculate bandwidth info for this server const bandwidthInfo = calculateBandwidthInfo(server, bandwidthEstimate); // Find all available regions for the same server spec const availableRegions: AvailableRegion[] = candidates .filter(c => c.provider_name === server.provider_name && c.instance_id === server.instance_id && c.region_code !== server.region_code // Exclude current region ) .map(c => ({ region_name: c.region_name, region_code: c.region_code, monthly_price: c.monthly_price })) .sort((a, b) => a.monthly_price - b.monthly_price); 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 ? { 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, available_regions: availableRegions.length > 0 ? availableRegions : 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: unknown): AIRecommendationResponse { try { // Handle different response formats let content: string; if (typeof response === 'string') { content = response; } else if (typeof response === 'object' && response !== null) { // Type guard for response object with different structures const resp = response as Record; if (typeof resp.response === 'string') { content = resp.response; } else if (typeof resp.result === 'object' && resp.result !== null) { const result = resp.result as Record; if (typeof result.response === 'string') { content = result.response; } else { throw new Error('Unexpected AI response format'); } } else if (Array.isArray(resp.choices) && resp.choices.length > 0) { const choice = resp.choices[0] as Record; const message = choice?.message as Record; if (typeof message?.content === 'string') { content = message.content; } else { throw new Error('Unexpected AI response format'); } } else { console.error('[AI] Unexpected response format:', response); throw new Error('Unexpected AI response format'); } } 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'}`); } }