duonglee / server.js
Fyuker's picture
Upload 4 files
9de44c1 verified
Raw
History Blame Contribute Delete
11.3 kB
// server.js - OpenAI to OpenCode Zen Proxy (Optimized for Render Free Tier)
const express = require('express');
const cors = require('cors');
const axios = require('axios');
const path = require('path');
const http = require('http');
const https = require('https');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const app = express();
// ===== [QUAN TRỌNG CHO RENDER] =====
// Giúp Express nhận diện đúng IP thực của người dùng đứng sau Load Balancer của Render.
// Nếu không có dòng này, toàn bộ user sẽ bị định diện chung 1 IP và khóa Rate Limit nhầm!
app.set('trust proxy', 1);
// ===== TỐI ƯU HIỆU NĂNG =====
// Nén gzipping cho toàn bộ response -> Giảm băng thông, AI trả lời cảm giác nhanh hơn
app.use(compression());
// ===== CẤU HÌNH BẢO MẬT CORS =====
// Đọc danh sách domain hợp lệ từ biến môi trường ALLOWED_ORIGINS (ví dụ: https://myapp.com,http://localhost:3000)
// Nếu không cấu hình ALLOWED_ORIGINS, mặc định mở cho tất cả (*)
const allowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : '*';
app.use(cors({
origin: function (origin, callback) {
if (!origin || allowedOrigins === '*' || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Blocked by CORS policy: Invalid Origin'));
}
},
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// ===== GIỚI HẠN PAYLOAD (BẢO VỆ RAM) =====
// Hạ từ 10MB xuống 1MB để tránh hacker/user gửi payload lớn làm sập RAM 512MB của Render
app.use(express.json({ limit: '1mb' }));
// ===== CẤU HÌNH TỪ ENVIRONMENT VARIABLES =====
const ZEN_API_BASE = process.env.ZEN_API_BASE || 'https://opencode.ai/zen/v1';
const ZEN_API_KEY = process.env.ZEN_API_KEY;
const PORT = process.env.PORT || 3000;
if (!ZEN_API_KEY) {
console.warn('⚠️ WARNING: ZEN_API_KEY is not set!');
}
// ===== TỐI ƯU KẾT NỐI MẠNG (HTTP KEEP-ALIVE) =====
// Giữ kết nối mở để tái sử dụng, tránh tốn CPU/RAM tạo lại kết nối SSL cho mỗi request
const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 50 });
const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 50 });
const apiInstance = axios.create({
baseURL: ZEN_API_BASE,
timeout: 45000, // Hạ từ 120s xuống 45s để nhanh chóng giải phóng RAM nếu server AI upstream bị treo
httpAgent,
httpsAgent,
headers: {
'Authorization': `Bearer ${ZEN_API_KEY}`,
'Content-Type': 'application/json'
},
validateStatus: status => status < 500
});
// ===== RATE LIMITER (20 requests/phút/IP) =====
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 phút
max: 20, // 20 requests mỗi phút
message: {
error: {
message: 'Too many requests. Please wait a moment and try again.',
type: 'rate_limit_error',
limit: 20,
window: '1 minute'
}
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
// Sẽ lấy chính xác IP client nhờ cấu hình app.set('trust proxy', 1) ở trên
return req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
},
handler: (req, res) => {
res.status(429).json({
error: {
message: 'Rate limit exceeded. Maximum 20 requests per minute allowed.',
type: 'rate_limit_error',
retry_after: 60
}
});
}
});
// ===== MODEL MAPPING =====
const MODEL_MAPPING = {
'big-pickle': 'big-pickle',
'deepseek-v4-flash-free': 'deepseek-v4-flash-free',
'mimo-v2.5-free': 'mimo-v2.5-free',
'laguna-s-2.1-free': 'laguna-s-2.1-free',
'north-mini-code-free': 'north-mini-code-free',
'nemotron-3-ultra-free': 'nemotron-3-ultra-free'
};
const MODEL_LIST = Object.keys(MODEL_MAPPING).map(model => ({
id: model,
object: 'model',
created: Math.floor(Date.now() / 1000),
owned_by: 'opencode-free-proxy',
permission: []
}));
// ===== HÀM CHUẨN HÓA CONTENT =====
function extractContent(message) {
if (!message) return null;
if (Array.isArray(message.content)) {
const textParts = message.content
.filter(part => part && (part.type === 'text' || part.text))
.map(part => (typeof part.text === 'string' ? part.text : part));
if (textParts.length > 0) return textParts.join('\n').trim();
}
const fields = [
message.content,
message.reasoning_content,
message.reasoning,
message.text,
message.response,
message.output
];
for (const field of fields) {
if (field && typeof field === 'string' && field.trim().length > 0) {
return field.trim();
}
}
if (message.content && typeof message.content === 'object') {
try {
return JSON.stringify(message.content);
} catch (e) {
return null;
}
}
return null;
}
// ===== HÀM CHUẨN HÓA RESPONSE =====
function normalizeResponse(data, originalModel) {
const choice = data.choices?.[0] || {};
const message = choice.message || {};
let content = extractContent(message);
if (!content && data.choices?.[0]?.delta) {
content = extractContent(data.choices[0].delta);
}
if (!content) {
content = "I'm here to help! What would you like to know?";
}
return {
id: data.id || `chatcmpl-${Date.now()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: originalModel || 'mimo-v2.5-free',
choices: [{
index: 0,
message: { role: 'assistant', content: content },
finish_reason: choice.finish_reason || 'stop'
}],
usage: data.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
};
}
// ===== ROUTES =====
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Health check (Không bị rate limit)
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
models: Object.keys(MODEL_MAPPING).length,
api_key_configured: !!ZEN_API_KEY,
memory_usage_mb: Math.round(process.memoryUsage().rss / 1024 / 1024), // Theo dõi RAM thực tế
environment: process.env.NODE_ENV || 'production'
});
});
app.get('/api', (req, res) => {
res.json({
name: 'OpenCode Free Proxy (Render Optimized)',
version: '2.0.0',
status: 'running',
rate_limit: '20 requests per minute per IP'
});
});
// ===== API ENDPOINTS (CÓ RATE LIMIT) =====
app.get('/v1/models', apiLimiter, (req, res) => {
res.json({ object: 'list', data: MODEL_LIST });
});
app.post('/v1/chat/completions', apiLimiter, async (req, res) => {
if (!ZEN_API_KEY) {
return res.status(500).json({
error: { message: 'ZEN_API_KEY is not configured.', type: 'server_error' }
});
}
const { model, messages, temperature, max_tokens, stream, reasoning_effort } = req.body;
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({
error: { message: 'Messages are required', type: 'invalid_request_error' }
});
}
try {
let zenModel = MODEL_MAPPING[model];
if (!zenModel) {
if (model?.startsWith('opencode/')) {
zenModel = MODEL_MAPPING[model.replace('opencode/', '')];
}
zenModel = zenModel || 'mimo-v2.5-free';
}
let requestMaxTokens = Math.min(max_tokens || 1024, 4096);
if (zenModel === 'deepseek-v4-flash-free') {
requestMaxTokens = Math.min(max_tokens || 2048, 8192);
}
const payload = {
model: zenModel,
messages,
temperature: temperature ?? 0.7,
max_tokens: requestMaxTokens,
stream: stream || false
};
if (reasoning_effort) payload.reasoning_effort = reasoning_effort;
// Sử dụng apiInstance đã tối ưu Keep-Alive & Timeout
const response = await apiInstance.post('/chat/completions', payload, {
responseType: stream ? 'stream' : 'json'
});
if (stream) {
// Truyền thêm req để bắt sự kiện người dùng ngắt kết nối
return handleStream(req, response, res, model);
}
return handleResponse(response.data, res, model);
} catch (error) {
console.error('API Error:', error.message);
const status = error.response?.status || 500;
const message = error.response?.data?.error?.message || error.message || 'Server error';
res.status(status).json({
error: { message: message, type: 'api_error' }
});
}
});
// ===== STREAMING HANDLER (TỐI ƯU CHỐNG RÒ RỈ RAM) =====
function handleStream(req, response, res, originalModel) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
let buffer = '';
// [QUAN TRỌNG] Nếu user tắt tab/trình duyệt giữa chừng -> Hủy ngay stream từ upstream
// Giúp Node.js không bị ép tải tiếp data về RAM gây tràn bộ nhớ (OOM)
req.on('close', () => {
if (response.data && typeof response.data.destroy === 'function') {
response.data.destroy();
}
});
response.data.on('data', chunk => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
if (line.includes('[DONE]')) {
res.write('data: [DONE]\n\n');
continue;
}
try {
const data = JSON.parse(line.slice(6));
const delta = data.choices?.[0]?.delta;
if (delta) {
const extractedContent = extractContent(delta);
if (extractedContent) {
data.choices[0].delta.content = extractedContent;
}
}
res.write(`data: ${JSON.stringify(data)}\n\n`);
} catch {
res.write(`${line}\n`);
}
}
});
response.data.on('end', () => res.end());
response.data.on('error', (err) => {
console.error('Stream error:', err.message);
res.write(`data: ${JSON.stringify({ error: 'Stream interrupted from upstream' })}\n\n`);
res.end();
});
}
// ===== NON-STREAMING HANDLER =====
function handleResponse(data, res, originalModel) {
const normalized = normalizeResponse(data, originalModel);
res.json(normalized);
}
// 404 handler
app.all('*', (req, res) => {
res.status(404).json({
error: { message: `Endpoint ${req.method} ${req.path} not found`, type: 'invalid_request_error' }
});
});
// ===== ERROR HANDLING =====
app.use((err, req, res, next) => {
console.error('Server Error:', err.message);
res.status(500).json({
error: { message: 'Internal server error', type: 'server_error' }
});
});
// ===== KHỞI TẠO SERVER =====
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Server running on port ${PORT}`);
console.log(`🛡️ Trust Proxy: Enabled (Render Ready)`);
console.log(`🗜️ Compression & Keep-Alive: Enabled`);
console.log(`⏱️ Rate Limit: 20 requests/minute/IP`);
});
module.exports = app;