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:
372
src/index.ts
372
src/index.ts
@@ -9,6 +9,7 @@ interface Env {
|
|||||||
DB: D1Database;
|
DB: D1Database;
|
||||||
CACHE: KVNamespace;
|
CACHE: KVNamespace;
|
||||||
OPENAI_API_KEY: string;
|
OPENAI_API_KEY: string;
|
||||||
|
AI_GATEWAY_URL?: string; // Cloudflare AI Gateway URL to bypass regional restrictions
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ValidationError {
|
interface ValidationError {
|
||||||
@@ -49,6 +50,17 @@ interface Server {
|
|||||||
region_code: string;
|
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 {
|
interface RecommendationResult {
|
||||||
server: Server;
|
server: Server;
|
||||||
score: number;
|
score: number;
|
||||||
@@ -63,6 +75,7 @@ interface RecommendationResult {
|
|||||||
max_concurrent_users: number;
|
max_concurrent_users: number;
|
||||||
requests_per_second: number;
|
requests_per_second: number;
|
||||||
};
|
};
|
||||||
|
bandwidth_info?: BandwidthInfo;
|
||||||
benchmark_reference?: BenchmarkReference;
|
benchmark_reference?: BenchmarkReference;
|
||||||
vps_benchmark_reference?: {
|
vps_benchmark_reference?: {
|
||||||
plan_name: string;
|
plan_name: string;
|
||||||
@@ -133,6 +146,7 @@ interface BandwidthEstimate {
|
|||||||
description: string;
|
description: string;
|
||||||
estimated_dau_min: number; // Daily Active Users estimate (min)
|
estimated_dau_min: number; // Daily Active Users estimate (min)
|
||||||
estimated_dau_max: number; // Daily Active Users estimate (max)
|
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 {
|
function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPattern?: string): BandwidthEstimate {
|
||||||
const useCaseLower = useCase.toLowerCase();
|
const useCaseLower = useCase.toLowerCase();
|
||||||
|
|
||||||
// Determine use case category and bandwidth multiplier
|
// Calculate DAU estimate from concurrent users with use-case-specific multipliers
|
||||||
let avgPageSizeMB: number;
|
const dauMultiplier = getDauMultiplier(useCase);
|
||||||
let requestsPerSession: number;
|
const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min);
|
||||||
let categoryMultiplier: number;
|
const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max);
|
||||||
|
const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2);
|
||||||
if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) {
|
const activeUserRatio = getActiveUserRatio(useCase);
|
||||||
// Video/Streaming: very heavy bandwidth
|
const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio);
|
||||||
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
|
// Traffic pattern adjustment
|
||||||
let patternMultiplier = 1.0;
|
let patternMultiplier = 1.0;
|
||||||
@@ -266,23 +241,109 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt
|
|||||||
patternMultiplier = 1.3; // Headroom for growth
|
patternMultiplier = 1.3; // Headroom for growth
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assume 8 active hours per day average (varies by use case)
|
let dailyBandwidthGB: number;
|
||||||
const activeHoursPerDay = 8;
|
let bandwidthModel: string;
|
||||||
|
|
||||||
// Calculate DAU estimate from concurrent users with use-case-specific multipliers
|
// ========== IMPROVED BANDWIDTH MODELS ==========
|
||||||
const dauMultiplier = getDauMultiplier(useCase);
|
// Each use case uses the most appropriate calculation method
|
||||||
const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min);
|
|
||||||
const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max);
|
|
||||||
|
|
||||||
// Calculate daily bandwidth with active user ratio
|
if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) {
|
||||||
const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2);
|
// VIDEO/STREAMING: Bitrate-based model
|
||||||
const activeUserRatio = getActiveUserRatio(useCase);
|
// - HD streaming: ~5 Mbps = 2.25 GB/hour
|
||||||
const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio);
|
// - 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;
|
} else if (/game|gaming|minecraft|게임/.test(useCaseLower)) {
|
||||||
const dailyBandwidthGB = dailyBandwidthMB / 1024;
|
// 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
|
// Monthly bandwidth
|
||||||
const monthlyGB = dailyBandwidthGB * 30;
|
const monthlyGB = dailyBandwidthGB * 30;
|
||||||
@@ -313,7 +374,92 @@ function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPatt
|
|||||||
category,
|
category,
|
||||||
description,
|
description,
|
||||||
estimated_dau_min: estimatedDauMin,
|
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)
|
// Use OpenAI GPT-4o-mini to analyze and recommend (techSpecs already queried above)
|
||||||
const aiResult = await getAIRecommendations(
|
const aiResult = await getAIRecommendations(
|
||||||
|
env,
|
||||||
env.OPENAI_API_KEY,
|
env.OPENAI_API_KEY,
|
||||||
body,
|
body,
|
||||||
candidates,
|
candidates,
|
||||||
benchmarkData,
|
benchmarkData,
|
||||||
vpsBenchmarks,
|
vpsBenchmarks,
|
||||||
techSpecs,
|
techSpecs,
|
||||||
|
bandwidthEstimate,
|
||||||
lang
|
lang
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -905,6 +1053,15 @@ async function handleRecommend(
|
|||||||
const response = {
|
const response = {
|
||||||
recommendations: aiResult.recommendations,
|
recommendations: aiResult.recommendations,
|
||||||
infrastructure_tips: aiResult.infrastructure_tips || [],
|
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,
|
total_candidates: candidates.length,
|
||||||
cached: false,
|
cached: false,
|
||||||
};
|
};
|
||||||
@@ -1179,17 +1336,15 @@ async function queryCandidateServers(
|
|||||||
console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`);
|
console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider filtering based on bandwidth requirements
|
// Provider preference based on bandwidth requirements (no hard filtering to avoid empty results)
|
||||||
// Heavy bandwidth (>2TB/month) → Linode only (better bandwidth allowance)
|
// Heavy/Very heavy bandwidth → Prefer Linode (better bandwidth allowance), but allow all providers
|
||||||
// Very heavy bandwidth (>6TB/month) → Linode only with warning
|
// AI prompt will warn about bandwidth costs for non-Linode providers
|
||||||
if (bandwidthEstimate) {
|
if (bandwidthEstimate) {
|
||||||
if (bandwidthEstimate.category === 'very_heavy') {
|
if (bandwidthEstimate.category === 'very_heavy') {
|
||||||
// >6TB/month: Linode only (includes up to 20TB depending on plan)
|
// >6TB/month: Strongly prefer Linode, but don't exclude others (Linode may not be available in all regions)
|
||||||
query += ` AND p.id = 1`; // Linode only
|
console.log(`[Candidates] Very heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode strongly preferred, all providers included`);
|
||||||
console.log(`[Candidates] Very heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode only`);
|
|
||||||
} else if (bandwidthEstimate.category === 'heavy') {
|
} else if (bandwidthEstimate.category === 'heavy') {
|
||||||
// 2-6TB/month: Prefer Linode, but allow Vultr
|
// 2-6TB/month: Prefer Linode
|
||||||
// Order by Linode first (handled in ORDER BY)
|
|
||||||
console.log(`[Candidates] Heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode preferred`);
|
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
|
// Group by instance + region to show each server per region
|
||||||
// For heavy bandwidth, prioritize Linode (p.id=1) over Vultr (p.id=2)
|
// For heavy/very_heavy bandwidth, prioritize Linode (p.id=1) due to generous bandwidth allowance
|
||||||
const orderByClause = bandwidthEstimate?.category === 'heavy'
|
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 CASE WHEN p.id = 1 THEN 0 ELSE 1 END, monthly_price ASC`
|
||||||
: `ORDER BY monthly_price ASC`;
|
: `ORDER BY monthly_price ASC`;
|
||||||
query += ` GROUP BY it.id, r.id ${orderByClause} LIMIT 50`;
|
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
|
* Get AI-powered recommendations using OpenAI GPT-4o-mini
|
||||||
*/
|
*/
|
||||||
async function getAIRecommendations(
|
async function getAIRecommendations(
|
||||||
|
env: Env,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
req: RecommendRequest,
|
req: RecommendRequest,
|
||||||
candidates: Server[],
|
candidates: Server[],
|
||||||
benchmarkData: BenchmarkData[],
|
benchmarkData: BenchmarkData[],
|
||||||
vpsBenchmarks: VPSBenchmark[],
|
vpsBenchmarks: VPSBenchmark[],
|
||||||
techSpecs: TechSpec[],
|
techSpecs: TechSpec[],
|
||||||
|
bandwidthEstimate: BandwidthEstimate,
|
||||||
lang: string = 'en'
|
lang: string = 'en'
|
||||||
): Promise<{ recommendations: RecommendationResult[]; infrastructure_tips?: string[] }> {
|
): 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
|
// Build dynamic tech specs prompt from database
|
||||||
const techSpecsPrompt = formatTechSpecsForPrompt(techSpecs);
|
const techSpecsPrompt = formatTechSpecsForPrompt(techSpecs);
|
||||||
|
|
||||||
@@ -1719,9 +1888,6 @@ Use REAL BENCHMARK DATA to validate capacity estimates.
|
|||||||
${languageInstruction}`;
|
${languageInstruction}`;
|
||||||
|
|
||||||
// Build user prompt with requirements and candidates
|
// 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);
|
console.log('[AI] Bandwidth estimate:', bandwidthEstimate);
|
||||||
|
|
||||||
// Detect high-traffic based on bandwidth estimate (more accurate than keyword matching)
|
// 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.`;
|
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
|
// Create AbortController with 30 second timeout
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const openaiResponse = await fetch('https://api.openai.com/v1/chat/completions', {
|
const openaiResponse = await fetch(apiEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -1825,8 +2001,46 @@ The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have
|
|||||||
|
|
||||||
if (!openaiResponse.ok) {
|
if (!openaiResponse.ok) {
|
||||||
const errorText = await openaiResponse.text();
|
const errorText = await openaiResponse.text();
|
||||||
const sanitized = errorText.slice(0, 100).replace(/sk-[a-zA-Z0-9-_]+/g, 'sk-***');
|
|
||||||
console.error('[AI] OpenAI API error:', openaiResponse.status, sanitized);
|
// Parse error details for better debugging
|
||||||
|
let errorDetails = '';
|
||||||
|
try {
|
||||||
|
const errorObj = JSON.parse(errorText);
|
||||||
|
errorDetails = errorObj?.error?.message || errorObj?.error?.type || '';
|
||||||
|
} catch {
|
||||||
|
errorDetails = errorText.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize API keys from error messages
|
||||||
|
const sanitized = errorDetails.replace(/sk-[a-zA-Z0-9-_]+/g, 'sk-***');
|
||||||
|
|
||||||
|
// Enhanced logging for specific error codes
|
||||||
|
if (openaiResponse.status === 403) {
|
||||||
|
const isRegionalBlock = errorDetails.includes('Country') || errorDetails.includes('region') || errorDetails.includes('territory');
|
||||||
|
if (isRegionalBlock && !useAIGateway) {
|
||||||
|
console.error('[AI] ❌ REGIONAL BLOCK (403) - OpenAI blocked this region');
|
||||||
|
console.error('[AI] Worker is running in a blocked region (e.g., HKG)');
|
||||||
|
console.error('[AI] FIX: Set AI_GATEWAY_URL secret to use Cloudflare AI Gateway');
|
||||||
|
console.error('[AI] 1. Create AI Gateway: https://dash.cloudflare.com → AI → AI Gateway');
|
||||||
|
console.error('[AI] 2. Run: wrangler secret put AI_GATEWAY_URL');
|
||||||
|
console.error('[AI] 3. Enter: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_name}/openai');
|
||||||
|
} else {
|
||||||
|
console.error('[AI] ❌ AUTH FAILED (403) - Possible causes:');
|
||||||
|
console.error('[AI] 1. Invalid or expired OPENAI_API_KEY');
|
||||||
|
console.error('[AI] 2. API key not properly set in Cloudflare secrets');
|
||||||
|
console.error('[AI] 3. Account billing issue or quota exceeded');
|
||||||
|
}
|
||||||
|
console.error('[AI] Error details:', sanitized);
|
||||||
|
} else if (openaiResponse.status === 429) {
|
||||||
|
console.error('[AI] ⚠️ RATE LIMITED (429) - Too many requests');
|
||||||
|
console.error('[AI] Error details:', sanitized);
|
||||||
|
} else if (openaiResponse.status === 401) {
|
||||||
|
console.error('[AI] ❌ UNAUTHORIZED (401) - API key invalid');
|
||||||
|
console.error('[AI] Error details:', sanitized);
|
||||||
|
} else {
|
||||||
|
console.error('[AI] OpenAI API error:', openaiResponse.status, sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`OpenAI API error: ${openaiResponse.status}`);
|
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({
|
results.push({
|
||||||
server: server,
|
server: server,
|
||||||
score: aiRec.score,
|
score: aiRec.score,
|
||||||
analysis: aiRec.analysis,
|
analysis: aiRec.analysis,
|
||||||
estimated_capacity: aiRec.estimated_capacity,
|
estimated_capacity: aiRec.estimated_capacity,
|
||||||
|
bandwidth_info: bandwidthInfo,
|
||||||
benchmark_reference: benchmarkRef,
|
benchmark_reference: benchmarkRef,
|
||||||
vps_benchmark_reference: matchingVPS
|
vps_benchmark_reference: matchingVPS
|
||||||
? {
|
? {
|
||||||
|
|||||||
Reference in New Issue
Block a user