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