/** * ๐Ÿ”Œ AGE OS ยท API ไปฃ็†ๅฑ‚ * * ่งฃๅ†ณไธคไธชๆ ธๅฟƒ้—ฎ้ข˜๏ผš * 1. ๅ›ฝๅ†…ๅผ€ๅ‘่€…ๆ— ๆณ•็›ด่ฟžๆตทๅค–ๆจกๅž‹API๏ผˆ่ขซๅข™๏ผ‰ * 2. API Key ไธๅบ”ๆšด้œฒๅœจๅ‰็ซฏ JavaScript ไธญ * * ๆžถๆž„๏ผš * ๆต่งˆๅ™จ โ†’ guanghulab.com/api/chat โ†’ ๆœฌไปฃ็† โ†’ ็œŸๅฎžๆจกๅž‹API * * ๆ”ฏๆŒ็š„ๆจกๅž‹๏ผš * - deepseek (api.deepseek.com) โ€” ๅ›ฝๅ†…็›ด่ฟž๏ผŒๆŽจ่้ฆ–้€‰ * - moonshot (api.moonshot.cn) โ€” ๅ›ฝๅ†…็›ด่ฟž * - zhipu (open.bigmodel.cn) โ€” ๅ›ฝๅ†…็›ด่ฟž * - yunwu (api.yunwu.ai) โ€” ๅ›ข้˜ŸๆŽจ่ * - openai (api.openai.com) โ€” ้œ€ๆตทๅค–ๆœๅŠกๅ™จ * - gemini (generativelanguage.googleapis.com) โ€” ้œ€ๆตทๅค–ๆœๅŠกๅ™จ * * ็Žฏๅขƒๅ˜้‡๏ผš * DEEPSEEK_API_KEY โ€” DeepSeek API ๅฏ†้’ฅ * MOONSHOT_API_KEY โ€” Moonshot/Kimi API ๅฏ†้’ฅ * ZHIPU_API_KEY โ€” ๆ™บ่ฐฑ API ๅฏ†้’ฅ * YUNWU_API_KEY โ€” ไบ‘้›พ AI API ๅฏ†้’ฅ * OPENAI_API_KEY โ€” OpenAI API ๅฏ†้’ฅ * GEMINI_API_KEY โ€” Google Gemini API ๅฏ†้’ฅ * PROXY_PORT โ€” ไปฃ็†ๆœๅŠก็ซฏๅฃ๏ผˆ้ป˜่ฎค 3721๏ผ‰ * ALLOWED_ORIGINS โ€” CORS ๅ…่ฎธ็š„ๅŸŸๅ๏ผˆ้€—ๅทๅˆ†้š”๏ผŒ้ป˜่ฎค *๏ผ‰ * RATE_LIMIT_RPM โ€” ๆฏ็”จๆˆทๆฏๅˆ†้’Ÿๆœ€ๅคง่ฏทๆฑ‚ๆ•ฐ๏ผˆ้ป˜่ฎค 10๏ผ‰ * * ๅฏๅŠจ๏ผš * node backend-integration/api-proxy.js * ๆˆ–้€š่ฟ‡ PM2: pm2 start backend-integration/api-proxy.js --name api-proxy */ const http = require('http'); const https = require('https'); const crypto = require('crypto'); const { URL } = require('url'); // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // ้…็ฝฎ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• const PORT = parseInt(process.env.PROXY_PORT || '3721', 10); const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'https://qinfendebingshuo.github.io,https://guanghulab.com,http://localhost:8765').split(',').map(s => s.trim()); const RATE_LIMIT_RPM = parseInt(process.env.RATE_LIMIT_RPM || '10', 10); // ๆจกๅž‹็ซฏ็‚นๆ˜ ๅฐ„ const MODEL_CONFIG = { deepseek: { base: 'https://api.deepseek.com/v1', keyEnv: 'DEEPSEEK_API_KEY', models: ['deepseek-chat', 'deepseek-reasoner'], label: 'DeepSeek๏ผˆๅ›ฝๅ†…็›ด่ฟž๏ผ‰' }, moonshot: { base: 'https://api.moonshot.cn/v1', keyEnv: 'MOONSHOT_API_KEY', models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'], label: 'Moonshot/Kimi๏ผˆๅ›ฝๅ†…็›ด่ฟž๏ผ‰' }, zhipu: { base: 'https://open.bigmodel.cn/api/paas/v4', keyEnv: 'ZHIPU_API_KEY', models: ['glm-4', 'glm-4-flash', 'glm-4-air'], label: 'ๆ™บ่ฐฑAI/GLM๏ผˆๅ›ฝๅ†…็›ด่ฟž๏ผ‰' }, yunwu: { base: 'https://api.yunwu.ai/v1', keyEnv: 'YUNWU_API_KEY', models: ['gpt-4o', 'gpt-4o-mini', 'claude-3-5-sonnet-20241022', 'deepseek-chat', 'gemini-1.5-pro'], label: 'ไบ‘้›พAI๏ผˆๅ›ข้˜ŸๆŽจ่๏ผ‰' }, openai: { base: 'https://api.openai.com/v1', keyEnv: 'OPENAI_API_KEY', models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'], label: 'OpenAI๏ผˆ้œ€ๆตทๅค–ๆœๅŠกๅ™จ๏ผ‰' }, gemini: { base: 'https://generativelanguage.googleapis.com/v1beta/openai', keyEnv: 'GEMINI_API_KEY', models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-pro', 'gemini-1.5-flash'], label: 'Google Gemini๏ผˆ้œ€ๆตทๅค–ๆœๅŠกๅ™จ๏ผ‰' } }; // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // ้ข‘็އ้™ๅˆถ๏ผˆๅ†…ๅญ˜็บง๏ผŒๅŸบไบŽ IP๏ผ‰ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• const rateLimitMap = new Map(); function checkRateLimit(clientId) { const now = Date.now(); const windowMs = 60 * 1000; // 1 minute window if (!rateLimitMap.has(clientId)) { rateLimitMap.set(clientId, []); } const timestamps = rateLimitMap.get(clientId); // Remove entries older than the window while (timestamps.length > 0 && timestamps[0] < now - windowMs) { timestamps.shift(); } if (timestamps.length >= RATE_LIMIT_RPM) { return false; // Rate limited } timestamps.push(now); return true; } // Clean up old entries every 5 minutes setInterval(() => { const cutoff = Date.now() - 120 * 1000; for (const [key, timestamps] of rateLimitMap.entries()) { const filtered = timestamps.filter(t => t > cutoff); if (filtered.length === 0) { rateLimitMap.delete(key); } else { rateLimitMap.set(key, filtered); } } }, 5 * 60 * 1000); // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // CORS ๅค„็† // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• function setCorsHeaders(res, origin) { const allowed = ALLOWED_ORIGINS.includes('*') || ALLOWED_ORIGINS.includes(origin); if (allowed) { res.setHeader('Access-Control-Allow-Origin', origin || '*'); } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-User-Id'); res.setHeader('Access-Control-Max-Age', '86400'); } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // ่ฏทๆฑ‚ไฝ“่งฃๆž // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• function parseBody(req) { return new Promise((resolve, reject) => { let body = ''; let size = 0; const MAX_BODY = 512 * 1024; // 512KB max req.on('data', chunk => { size += chunk.length; if (size > MAX_BODY) { reject(new Error('่ฏทๆฑ‚ไฝ“่ฟ‡ๅคง')); return; } body += chunk; }); req.on('end', () => { try { resolve(body ? JSON.parse(body) : {}); } catch { reject(new Error('ๆ— ๆ•ˆ็š„ JSON ่ฏทๆฑ‚ไฝ“')); } }); req.on('error', reject); }); } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // JSON ๅ“ๅบ” // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• function jsonResponse(res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // ่Žทๅ–ๅฎขๆˆท็ซฏ IP // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• function getClientId(req) { return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown'; } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // ไธŠๆธธ HTTPS ่ฏทๆฑ‚๏ผˆๆตๅผ้€ไผ ๏ผ‰ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• function proxyStream(upstreamUrl, apiKey, requestBody, res) { return new Promise((resolve, reject) => { const url = new URL(upstreamUrl); const options = { hostname: url.hostname, port: url.port || 443, path: url.pathname + url.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'Accept': 'text/event-stream' } }; const bodyStr = JSON.stringify(requestBody); options.headers['Content-Length'] = Buffer.byteLength(bodyStr); const upstream = https.request(options, (upstreamRes) => { // Forward status and content-type const ct = upstreamRes.headers['content-type'] || 'text/event-stream'; res.writeHead(upstreamRes.statusCode, { 'Content-Type': ct, 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' // Nginx: disable buffering for SSE }); // Pipe upstream response directly to client (streaming) upstreamRes.on('data', chunk => { res.write(chunk); }); upstreamRes.on('end', () => { res.end(); resolve(); }); upstreamRes.on('error', err => { console.error('ไธŠๆธธๅ“ๅบ”้”™่ฏฏ:', err.message); res.end(); reject(err); }); }); upstream.on('error', err => { console.error('ไธŠๆธธ่ฟžๆŽฅ้”™่ฏฏ:', err.message); if (!res.headersSent) { jsonResponse(res, 502, { error: true, code: 'UPSTREAM_ERROR', message: 'ๆ— ๆณ•่ฟžๆŽฅๆจกๅž‹API: ' + err.message }); } reject(err); }); // Connection timeout only โ€” once response headers arrive, streaming can run longer upstream.setTimeout(60000, () => { upstream.destroy(new Error('่ฟžๆŽฅ่ถ…ๆ—ถ')); }); upstream.write(bodyStr); upstream.end(); }); } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // ่ทฏ็”ฑๅค„็†ๅ™จ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // GET /api/models โ€” ๅˆ—ๅ‡บๅฏ็”จๆจกๅž‹ function handleGetModels(req, res) { const available = {}; for (const [provider, config] of Object.entries(MODEL_CONFIG)) { const key = process.env[config.keyEnv]; if (key) { available[provider] = { label: config.label, models: config.models }; } } jsonResponse(res, 200, { providers: available, default_provider: Object.keys(available)[0] || null }); } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // ๆจกๅž‹ๅคฑ่ดฅ่ฎฐๅฝ•๏ผˆ่‡ชๅŠจ fallback ็”จ๏ผ‰ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• const proxyFailureLog = new Map(); const PROXY_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; function recordProxyFailure(provider) { const entry = proxyFailureLog.get(provider) || { count: 0, lastFail: 0 }; entry.count++; entry.lastFail = Date.now(); proxyFailureLog.set(provider, entry); } function isProviderInCooldown(provider) { const entry = proxyFailureLog.get(provider); if (!entry) return false; if (Date.now() - entry.lastFail > PROXY_FAILURE_COOLDOWN_MS) { proxyFailureLog.delete(provider); return false; } return entry.count >= 3; } // ่‡ชๅŠจ fallback ๆไพ›ๅ•†้กบๅบ๏ผˆๆœ‰ API key ไธ”ๆœชๅ†ทๅด็š„ไผ˜ๅ…ˆ๏ผ‰ function getAvailableProviders(preferredProvider) { const ordered = [preferredProvider, 'yunwu', 'deepseek', 'moonshot', 'zhipu', 'openai', 'gemini']; const seen = new Set(); const available = []; for (const prov of ordered) { if (seen.has(prov)) continue; seen.add(prov); const cfg = MODEL_CONFIG[prov]; if (!cfg) continue; const key = process.env[cfg.keyEnv]; if (!key) continue; if (isProviderInCooldown(prov)) continue; available.push({ provider: prov, config: cfg, apiKey: key }); } return available; } // POST /api/chat โ€” ไปฃ็†่ฝฌๅ‘่Šๅคฉ่ฏทๆฑ‚๏ผˆๆ”ฏๆŒ่‡ชๅŠจ fallback๏ผ‰ async function handleChat(req, res) { const clientId = getClientId(req); // Rate limit check if (!checkRateLimit(clientId)) { jsonResponse(res, 429, { error: true, code: 'RATE_LIMITED', message: `่ฏทๆฑ‚่ฟ‡ไบŽ้ข‘็น๏ผŒๆฏๅˆ†้’Ÿๆœ€ๅคš ${RATE_LIMIT_RPM} ๆฌก่ฏทๆฑ‚` }); return; } let body; try { body = await parseBody(req); } catch (err) { jsonResponse(res, 400, { error: true, code: 'INVALID_BODY', message: err.message }); return; } const { provider, model, messages, system, stream, temperature, max_tokens } = body; if (!messages || !Array.isArray(messages) || messages.length === 0) { jsonResponse(res, 400, { error: true, code: 'MISSING_MESSAGES', message: '็ผบๅฐ‘ messages ๅ‚ๆ•ฐ' }); return; } // Validate numeric parameters const temp = typeof temperature === 'number' ? Math.max(0, Math.min(2, temperature)) : 0.8; const maxTok = typeof max_tokens === 'number' ? Math.max(1, Math.min(8192, Math.floor(max_tokens))) : 2000; // Get available providers with auto-fallback const prov = provider || 'deepseek'; const availableProviders = getAvailableProviders(prov); if (availableProviders.length === 0) { // Even cooled-down providers โ€” try the requested one anyway const config = MODEL_CONFIG[prov]; if (!config) { jsonResponse(res, 400, { error: true, code: 'INVALID_PROVIDER', message: `ไธๆ”ฏๆŒ็š„ๆจกๅž‹ๆไพ›ๅ•†: ${prov}๏ผŒๅฏ้€‰: ${Object.keys(MODEL_CONFIG).join(', ')}` }); return; } const apiKey = process.env[config.keyEnv]; if (!apiKey) { jsonResponse(res, 503, { error: true, code: 'NO_API_KEY', message: `ๆ‰€ๆœ‰ๆจกๅž‹APIๅฏ†้’ฅๅ‡ๆœช้…็ฝฎ๏ผŒ่ฏท่”็ณป็ฎก็†ๅ‘˜` }); return; } availableProviders.push({ provider: prov, config, apiKey }); } // Try providers in order with auto-fallback for (let i = 0; i < availableProviders.length; i++) { const { provider: curProv, config: curConfig, apiKey: curKey } = availableProviders[i]; const mdl = (i === 0 && model) ? model : curConfig.models[0]; const upstreamBody = { model: mdl, messages: messages, stream: stream !== false, temperature: temp, max_tokens: maxTok }; if (system && !messages.find(m => m.role === 'system')) { upstreamBody.messages = [{ role: 'system', content: system }, ...messages]; } const upstreamUrl = curConfig.base + '/chat/completions'; const isLastAttempt = i === availableProviders.length - 1; console.log(`[ไปฃ็†] ${i > 0 ? '้™็บงโ†’' : ''}${curProv}/${mdl}`); try { await proxyStream(upstreamUrl, curKey, upstreamBody, res); return; // Success โ€” stop trying } catch (err) { recordProxyFailure(curProv); console.error(`[ไปฃ็†] ${curProv} ่ฏทๆฑ‚ๅคฑ่ดฅ: ${err.message}`); // If headers already sent (partial streaming started), can't switch provider if (res.headersSent) return; if (isLastAttempt) { jsonResponse(res, 502, { error: true, code: 'ALL_PROVIDERS_FAILED', message: 'ๆ‰€ๆœ‰ๆจกๅž‹ๆไพ›ๅ•†ๅ‡่ฏทๆฑ‚ๅคฑ่ดฅ๏ผŒ่ฏท็จๅŽ้‡่ฏ•' }); } // Otherwise continue to next provider } } } // GET /api/health โ€” ๅฅๅบทๆฃ€ๆŸฅ function handleHealth(req, res) { const configured = Object.entries(MODEL_CONFIG) .filter(([, config]) => process.env[config.keyEnv]) .map(([name, config]) => ({ provider: name, label: config.label })); jsonResponse(res, 200, { status: 'ok', version: '1.0.0', service: 'AGE OS API Proxy', configured_providers: configured, rate_limit: RATE_LIMIT_RPM + ' req/min' }); } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // ็”จๆˆท API Key ๆจกๅž‹ๆฃ€ๆต‹๏ผˆPersona Studio ๅค็”จไธป็ซ™ไปฃ็†้“พ่ทฏ๏ผ‰ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• const userModelCache = new Map(); const USER_MODEL_CACHE_TTL_MS = 60 * 60 * 1000; // 1 ๅฐๆ—ถ function getUserCacheKey(apiBase, apiKey) { const hash = crypto.createHash('sha256').update(apiBase + '\0' + apiKey).digest('hex').slice(0, 32); return 'user-models::' + hash; } function getCachedUserModels(apiBase, apiKey) { const key = getUserCacheKey(apiBase, apiKey); const entry = userModelCache.get(key); if (entry && Date.now() - entry.timestamp < USER_MODEL_CACHE_TTL_MS) { return entry.models; } return null; } function setCachedUserModels(apiBase, apiKey, models) { const key = getUserCacheKey(apiBase, apiKey); userModelCache.set(key, { models, timestamp: Date.now() }); } /** * ่ฏทๆฑ‚็”จๆˆทๆŒ‡ๅฎš็š„็ฌฌไธ‰ๆ–น API ็š„ /v1/models ๆŽฅๅฃ */ function fetchUserModels(apiBase, apiKey, timeoutMs) { return new Promise((resolve, reject) => { const base = apiBase.replace(/\/+$/, ''); const modelsPath = base.endsWith('/v1') ? base + '/models' : base + '/v1/models'; let url; try { url = new URL(modelsPath); } catch (_e) { return reject({ code: 'INVALID_API_BASE', message: 'API Base URL ๆ ผๅผๆ— ๆ•ˆ' }); } const isHttps = url.protocol === 'https:'; const mod = isHttps ? https : http; const options = { hostname: url.hostname, port: url.port || (isHttps ? 443 : 80), path: url.pathname + (url.search || ''), method: 'GET', headers: { 'Authorization': 'Bearer ' + apiKey, 'Accept': 'application/json' }, timeout: timeoutMs || 15000 }; const req = mod.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode === 401 || res.statusCode === 403) { return reject({ code: 'INVALID_API_KEY', message: 'API Key ๆ— ๆ•ˆๆˆ–ๆƒ้™ไธ่ถณ (HTTP ' + res.statusCode + ')' }); } if (res.statusCode >= 400) { return reject({ code: 'API_BASE_ERROR', message: 'API Base ่ฟ”ๅ›ž้”™่ฏฏ (HTTP ' + res.statusCode + ')' }); } try { const json = JSON.parse(data); const models = json.data || json.models || []; const modelIds = models .map(function (m) { return m.id || m.name || null; }) .filter(Boolean); resolve(modelIds); } catch (_e) { reject({ code: 'PARSE_ERROR', message: 'ๅฝ“ๅ‰ๆŽฅๅฃไธๆ”ฏๆŒๆจกๅž‹ๆžšไธพ๏ผˆ่ฟ”ๅ›žๆ ผๅผๅผ‚ๅธธ๏ผ‰' }); } }); }); req.on('error', (err) => { if (err.code === 'ENOTFOUND') { reject({ code: 'DNS_ERROR', message: 'API Base ๅŸŸๅๆ— ๆณ•่งฃๆž: ' + url.hostname }); } else if (err.code === 'ECONNREFUSED') { reject({ code: 'CONN_REFUSED', message: 'API Base ่ฟžๆŽฅ่ขซๆ‹’็ป: ' + url.hostname }); } else { reject({ code: 'NETWORK_ERROR', message: 'API Base ไธๅฏ่ฎฟ้—ฎ: ' + err.message }); } }); req.on('timeout', () => { req.destroy(); reject({ code: 'TIMEOUT', message: '่ฏทๆฑ‚่ถ…ๆ—ถ๏ผŒAPI Base ๅฏ่ƒฝไธๅฏ่ฎฟ้—ฎๆˆ–็ฝ‘็ปœไธ็จณๅฎš' }); }); req.end(); }); } /** * ่ฐƒ็”จ็”จๆˆทๆŒ‡ๅฎš็š„็ฌฌไธ‰ๆ–น API ็š„ chat/completions ๆŽฅๅฃ */ function callUserApiChat({ apiBase, apiKey, model, messages, maxTokens, temperature, timeoutMs }) { return new Promise((resolve, reject) => { const base = apiBase.replace(/\/+$/, ''); const chatPath = base.endsWith('/v1') ? base + '/chat/completions' : base + '/v1/chat/completions'; let url; try { url = new URL(chatPath); } catch (_e) { return reject(new Error('API Base URL ๆ ผๅผๆ— ๆ•ˆ')); } const isHttps = url.protocol === 'https:'; const mod = isHttps ? https : http; const body = JSON.stringify({ model: model, messages: messages, max_tokens: maxTokens || 2000, temperature: temperature != null ? temperature : 0.8 }); const options = { hostname: url.hostname, port: url.port || (isHttps ? 443 : 80), path: url.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey, 'Content-Length': Buffer.byteLength(body) }, timeout: timeoutMs || 60000 }; const req = mod.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const json = JSON.parse(data); if (json.choices && json.choices[0] && json.choices[0].message) { resolve(json.choices[0].message.content); } else if (json.error) { reject(new Error(json.error.message || 'API ่ฐƒ็”จๅคฑ่ดฅ')); } else { reject(new Error('API ่ฟ”ๅ›žๆ ผๅผๅผ‚ๅธธ')); } } catch (_e) { reject(new Error('API ่ฟ”ๅ›ž่งฃๆžๅคฑ่ดฅ')); } }); }); req.on('error', (err) => { reject(new Error('API ่ฏทๆฑ‚ๅคฑ่ดฅ: ' + err.message)); }); req.on('timeout', () => { req.destroy(); reject(new Error('API ่ฏทๆฑ‚่ถ…ๆ—ถ')); }); req.write(body); req.end(); }); } // POST /api/ps/apikey/detect-models โ€” ๆฃ€ๆต‹็”จๆˆท็ฌฌไธ‰ๆ–น API ๅฏ็”จๆจกๅž‹ async function handleDetectModels(req, res) { let body; try { body = await parseBody(req); } catch (err) { return jsonResponse(res, 400, { error: true, code: 'INVALID_BODY', message: err.message }); } const { api_base, api_key } = body; if (!api_base || typeof api_base !== 'string') { return jsonResponse(res, 400, { error: true, code: 'MISSING_API_BASE', message: '่ฏท่พ“ๅ…ฅ API Base URL' }); } if (!api_key || typeof api_key !== 'string') { return jsonResponse(res, 400, { error: true, code: 'MISSING_API_KEY', message: '่ฏท่พ“ๅ…ฅ API Key' }); } // ้˜ฒๆญข header injection if (/[\r\n]/.test(api_key)) { return jsonResponse(res, 400, { error: true, code: 'INVALID_API_KEY', message: 'API Key ๆ ผๅผๆ— ๆ•ˆ๏ผˆๅซ้žๆณ•ๅญ—็ฌฆ๏ผ‰' }); } // ๆ ก้ชŒ api_base ๆ ผๅผๅ’Œๅ่ฎฎ๏ผˆไป…ๅ…่ฎธ http/https๏ผŒ้˜ฒๆญข SSRF๏ผ‰ try { const parsedUrl = new URL(api_base); if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { return jsonResponse(res, 400, { error: true, code: 'INVALID_API_BASE', message: 'API Base URL ไป…ๆ”ฏๆŒ http/https ๅ่ฎฎ' }); } } catch (_e) { return jsonResponse(res, 400, { error: true, code: 'INVALID_API_BASE', message: 'API Base URL ๆ ผๅผๆ— ๆ•ˆ๏ผŒ่ฏท่พ“ๅ…ฅๅฎŒๆ•ด URL๏ผˆๅฆ‚ https://api.openai.com๏ผ‰' }); } // ๆฃ€ๆŸฅ็ผ“ๅญ˜ const cached = getCachedUserModels(api_base, api_key); if (cached) { return jsonResponse(res, 200, { error: false, models: cached, count: cached.length, cached: true }); } try { const models = await fetchUserModels(api_base, api_key, 15000); if (!models || models.length === 0) { return jsonResponse(res, 404, { error: true, code: 'NO_MODELS', message: 'ๆœชๆฃ€ๆต‹ๅˆฐๅฏ็”จๆจกๅž‹๏ผŒ่ฏฅ API ๅฏ่ƒฝไธๆ”ฏๆŒๆจกๅž‹ๆžšไธพ' }); } setCachedUserModels(api_base, api_key, models); jsonResponse(res, 200, { error: false, models: models, count: models.length, cached: false }); } catch (err) { const code = err.code || 'DETECT_FAILED'; const message = err.message || 'ๆจกๅž‹ๆฃ€ๆต‹ๅคฑ่ดฅ'; jsonResponse(res, 502, { error: true, code: code, message: message }); } } // POST /api/ps/apikey/chat โ€” ้€š่ฟ‡็”จๆˆท API Key ๅฏน่ฏ async function handleApiKeyChat(req, res) { let body; try { body = await parseBody(req); } catch (err) { return jsonResponse(res, 400, { error: true, code: 'INVALID_BODY', message: err.message }); } const { api_base, api_key, model, messages } = body; if (!api_base || !api_key || !model) { return jsonResponse(res, 400, { error: true, code: 'MISSING_PARAMS', message: '็ผบๅฐ‘ๅฟ…่ฆๅ‚ๆ•ฐ (api_base, api_key, model)' }); } if (/[\r\n]/.test(api_key)) { return jsonResponse(res, 400, { error: true, code: 'INVALID_API_KEY', message: 'API Key ๆ ผๅผๆ— ๆ•ˆ' }); } if (!messages || !Array.isArray(messages) || messages.length === 0) { return jsonResponse(res, 400, { error: true, code: 'MISSING_MESSAGES', message: '็ผบๅฐ‘ๆถˆๆฏๅ†…ๅฎน' }); } try { const reply = await callUserApiChat({ apiBase: api_base, apiKey: api_key, model: model, messages: messages, maxTokens: 2000, temperature: 0.8, timeoutMs: 60000 }); jsonResponse(res, 200, { error: false, reply: reply, model: model }); } catch (err) { jsonResponse(res, 502, { error: true, code: 'CHAT_FAILED', message: err.message || 'ๅฏน่ฏ่ฏทๆฑ‚ๅคฑ่ดฅ' }); } } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // Persona Studio ๅŽ็ซฏๅๅ‘ไปฃ็† // ๅฐ† /api/ps/chat/*, /api/ps/auth/*, /api/ps/build/*, /api/ps/notify/* // ่ฝฌๅ‘ๅˆฐ persona-studio ๅŽ็ซฏๆœๅŠก๏ผˆ้ป˜่ฎค็ซฏๅฃ 3002๏ผ‰ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• const PS_BACKEND_PORT = parseInt(process.env.PS_PORT || '3002', 10); const PS_PROXY_PATHS = ['/api/ps/chat/', '/api/ps/auth/', '/api/ps/build/', '/api/ps/notify/']; function shouldProxyToPersonaStudio(pathname) { return PS_PROXY_PATHS.some(prefix => pathname.startsWith(prefix)); } function proxyToPersonaStudio(req, res, fullPath) { return new Promise((resolve, reject) => { const options = { hostname: '127.0.0.1', port: PS_BACKEND_PORT, path: fullPath, method: req.method, headers: Object.assign({}, req.headers, { host: '127.0.0.1:' + PS_BACKEND_PORT }), timeout: 60000 }; const proxyReq = http.request(options, (proxyRes) => { // Forward status and headers, but skip backend's CORS headers // because the api-proxy already set CORS headers via setCorsHeaders() // at the top of the request handler โ€” forwarding both would cause duplicates const headers = {}; for (const [key, value] of Object.entries(proxyRes.headers)) { if (!key.startsWith('access-control-')) { headers[key] = value; } } res.writeHead(proxyRes.statusCode, headers); proxyRes.pipe(res); proxyRes.on('end', resolve); proxyRes.on('error', reject); }); proxyReq.on('error', (err) => { console.error('[ไปฃ็†โ†’PS] persona-studio ๅŽ็ซฏ่ฟžๆŽฅๅคฑ่ดฅ:', err.message); if (!res.headersSent) { jsonResponse(res, 502, { error: true, code: 'PS_BACKEND_UNAVAILABLE', message: 'Persona Studio ๅŽ็ซฏๆœๅŠกไธๅฏ็”จ๏ผŒ่ฏท็จๅŽ้‡่ฏ•' }); } resolve(); }); proxyReq.on('timeout', () => { proxyReq.destroy(); if (!res.headersSent) { jsonResponse(res, 504, { error: true, code: 'PS_BACKEND_TIMEOUT', message: 'Persona Studio ๅŽ็ซฏๅ“ๅบ”่ถ…ๆ—ถ' }); } resolve(); }); // Pipe the original request body to the proxy request req.pipe(proxyReq); }); } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // HTTP ๆœๅŠกๅ™จ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• const server = http.createServer(async (req, res) => { const origin = req.headers.origin || ''; setCorsHeaders(res, origin); // Handle preflight if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const path = url.pathname; try { if (path === '/api/chat' && req.method === 'POST') { await handleChat(req, res); } else if (path === '/api/models' && req.method === 'GET') { handleGetModels(req, res); } else if (path === '/api/health' && req.method === 'GET') { handleHealth(req, res); } else if (path === '/api/ps/apikey/detect-models' && req.method === 'POST') { await handleDetectModels(req, res); } else if (path === '/api/ps/apikey/chat' && req.method === 'POST') { await handleApiKeyChat(req, res); } else if (shouldProxyToPersonaStudio(path)) { await proxyToPersonaStudio(req, res, url.pathname + url.search); } else { jsonResponse(res, 404, { error: true, code: 'NOT_FOUND', message: 'ๆŽฅๅฃไธๅญ˜ๅœจใ€‚ๅฏ็”จๆŽฅๅฃ: POST /api/chat, GET /api/models, GET /api/health, POST /api/ps/apikey/detect-models, POST /api/ps/apikey/chat, POST /api/ps/chat/message, GET /api/ps/chat/history, POST /api/ps/auth/login' }); } } catch (err) { console.error('ๆœๅŠกๅ™จ้”™่ฏฏ:', err); if (!res.headersSent) { jsonResponse(res, 500, { error: true, code: 'INTERNAL_ERROR', message: 'ๆœๅŠกๅ™จๅ†…้ƒจ้”™่ฏฏ' }); } } }); server.listen(PORT, () => { console.log(`\n๐Ÿ”Œ AGE OS API ไปฃ็†ๅฑ‚ๅทฒๅฏๅŠจ`); console.log(` ็ซฏๅฃ: ${PORT}`); console.log(` ๅœฐๅ€: http://localhost:${PORT}`); console.log(` ้ข‘็އ้™ๅˆถ: ${RATE_LIMIT_RPM} ๆฌก/ๅˆ†้’Ÿ`); console.log(` CORS: ${ALLOWED_ORIGINS.join(', ')}`); console.log(''); // Show configured providers let hasAny = false; for (const [name, config] of Object.entries(MODEL_CONFIG)) { const key = process.env[config.keyEnv]; const status = key ? 'โœ… ๅทฒ้…็ฝฎ' : 'โŒ ๆœช้…็ฝฎ'; const china = ['deepseek', 'moonshot', 'zhipu'].includes(name) ? '๐Ÿ‡จ๐Ÿ‡ณ' : '๐ŸŒ'; console.log(` ${china} ${config.label}: ${status}`); if (key) hasAny = true; } if (!hasAny) { console.log('\n โš ๏ธ ๆœช้…็ฝฎไปปไฝ• API ๅฏ†้’ฅ๏ผ'); console.log(' ่ฏท่ฎพ็ฝฎ็Žฏๅขƒๅ˜้‡๏ผˆๆŽจ่ๅ…ˆ้… DeepSeek๏ผŒๅ›ฝๅ†…็›ด่ฟž๏ผ‰:'); console.log(' export DEEPSEEK_API_KEY=sk-xxx'); console.log(' export YUNWU_API_KEY=sk-xxx'); } console.log(`\n Persona Studio ๅŽ็ซฏไปฃ็†: http://127.0.0.1:${PS_BACKEND_PORT}`); console.log(' ไปฃ็†่ทฏๅพ„: /api/ps/chat/*, /api/ps/auth/*, /api/ps/build/*, /api/ps/notify/*'); console.log('\n ๅฏ็”จๆŽฅๅฃ:'); console.log(' POST /api/chat โ€” ่Šๅคฉไปฃ็†๏ผˆSSE ๆตๅผ๏ผ‰'); console.log(' GET /api/models โ€” ๅˆ—ๅ‡บๅฏ็”จๆจกๅž‹'); console.log(' GET /api/health โ€” ๅฅๅบทๆฃ€ๆŸฅ'); console.log(' POST /api/ps/apikey/detect-models โ€” ็”จๆˆท API Key ๆจกๅž‹ๆฃ€ๆต‹'); console.log(' POST /api/ps/apikey/chat โ€” ็”จๆˆท API Key ๅฏน่ฏ'); console.log(' POST /api/ps/chat/message โ€” ็Ÿฅ็ง‹ๅฏน่ฏ๏ผˆโ†’ PSๅŽ็ซฏ๏ผ‰'); console.log(' GET /api/ps/chat/history โ€” ๅฏน่ฏๅކๅฒ๏ผˆโ†’ PSๅŽ็ซฏ๏ผ‰'); console.log(' POST /api/ps/auth/login โ€” ็™ปๅฝ•ๆ ก้ชŒ๏ผˆโ†’ PSๅŽ็ซฏ๏ผ‰'); console.log(''); });