feat: Cloudflare AI Gateway 지원 추가

- AI_GATEWAY_URL 환경변수로 AI Gateway 활성화
- OpenAI 지역 차단(HKG 등) 우회 가능
- 403 에러 시 지역 차단 감지 및 안내 메시지 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-25 16:03:05 +09:00
parent dcc8be6f5b
commit 1d0cbdd7cc

View File

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