310 lines
9.4 KiB
JavaScript
310 lines
9.4 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
* 🇨🇳 CN LLM Relay · 国内模型API代理中继服务
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
*
|
|||
|
|
* 编号: ZY-CN-LLM-RELAY-001
|
|||
|
|
* 服务器: ZY-SVR-003 · 43.138.243.30 · 广州
|
|||
|
|
* 端口: 3900
|
|||
|
|
* 守护: 铸渊 · ICE-GL-ZY001
|
|||
|
|
* 版权: 国作登字-2026-A-00037559
|
|||
|
|
*
|
|||
|
|
* 功能:
|
|||
|
|
* - 接收新加坡服务器转发的LLM API请求
|
|||
|
|
* - 在国内网络环境下直连国内模型API(零跨境延迟)
|
|||
|
|
* - Bearer Token鉴权防止外部滥用
|
|||
|
|
* - 返回AI回复给新加坡服务器
|
|||
|
|
*
|
|||
|
|
* 架构 (与硅谷Claude中继对称):
|
|||
|
|
* 用户 → SG(ZY-SVR-002) → 广州(ZY-SVR-003):3900 → 国内API
|
|||
|
|
* 硅谷模式: SG → SV(SSH隧道) → Claude API
|
|||
|
|
* 广州模式: SG → 广州(HTTP代理) → 国内API
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const express = require('express');
|
|||
|
|
const https = require('https');
|
|||
|
|
const crypto = require('crypto');
|
|||
|
|
|
|||
|
|
const app = express();
|
|||
|
|
const PORT = process.env.RELAY_PORT || 3900;
|
|||
|
|
const RELAY_API_KEY = process.env.ZY_CN_RELAY_API_KEY || '';
|
|||
|
|
|
|||
|
|
// 启动时校验: 鉴权密钥必须配置
|
|||
|
|
if (!RELAY_API_KEY) {
|
|||
|
|
console.error('[CN中继] ❌ ZY_CN_RELAY_API_KEY 未配置 · 服务拒绝启动');
|
|||
|
|
console.error('[CN中继] 请在 .env.relay 中设置 ZY_CN_RELAY_API_KEY');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// API请求超时(可通过环境变量调整)
|
|||
|
|
const API_TIMEOUT_MS = parseInt(process.env.ZY_CN_API_TIMEOUT || '60000', 10);
|
|||
|
|
|
|||
|
|
// ─── 国内模型配置 ───
|
|||
|
|
const DOMESTIC_MODELS = {
|
|||
|
|
ds: {
|
|||
|
|
model: 'deepseek-chat',
|
|||
|
|
endpoint: 'https://api.deepseek.com/v1/chat/completions',
|
|||
|
|
envKey: 'ZY_DEEPSEEK_API_KEY'
|
|||
|
|
},
|
|||
|
|
qw: {
|
|||
|
|
model: 'qwen-turbo',
|
|||
|
|
endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
|
|||
|
|
envKey: 'ZY_QIANWEN_API_KEY'
|
|||
|
|
},
|
|||
|
|
km: {
|
|||
|
|
model: 'moonshot-v1-8k',
|
|||
|
|
endpoint: 'https://api.moonshot.cn/v1/chat/completions',
|
|||
|
|
envKey: 'ZY_KIMI_API_KEY'
|
|||
|
|
},
|
|||
|
|
zp: {
|
|||
|
|
model: 'glm-4-flash',
|
|||
|
|
endpoint: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
|
|||
|
|
envKey: 'ZY_QINGYAN_API_KEY'
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ─── 速率限制 ───
|
|||
|
|
const rateLimit = require('express-rate-limit');
|
|||
|
|
app.use(rateLimit({
|
|||
|
|
windowMs: 60 * 1000,
|
|||
|
|
max: 120,
|
|||
|
|
standardHeaders: true,
|
|||
|
|
legacyHeaders: false,
|
|||
|
|
message: { error: true, message: '请求过于频繁' }
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
app.use(express.json({ limit: '2mb' }));
|
|||
|
|
|
|||
|
|
// ─── 中继状态 ───
|
|||
|
|
const relayState = {
|
|||
|
|
totalCalls: 0,
|
|||
|
|
successCalls: 0,
|
|||
|
|
failedCalls: 0,
|
|||
|
|
startTime: Date.now(),
|
|||
|
|
lastCall: null,
|
|||
|
|
modelStats: {}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ─── Bearer Token 鉴权 ───
|
|||
|
|
function apiKeyAuth(req, res, next) {
|
|||
|
|
// 健康检查免鉴权
|
|||
|
|
if (req.path === '/health') return next();
|
|||
|
|
|
|||
|
|
if (!RELAY_API_KEY) {
|
|||
|
|
return res.status(503).json({
|
|||
|
|
error: true,
|
|||
|
|
message: '中继服务未配置鉴权密钥 (ZY_CN_RELAY_API_KEY)'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const authHeader = req.headers.authorization || '';
|
|||
|
|
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|||
|
|
|
|||
|
|
if (!token) {
|
|||
|
|
return res.status(401).json({ error: true, message: '缺少Authorization头' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 常数时间比较防止时序攻击
|
|||
|
|
try {
|
|||
|
|
const a = Buffer.from(token);
|
|||
|
|
const b = Buffer.from(RELAY_API_KEY);
|
|||
|
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|||
|
|
return res.status(403).json({ error: true, message: '鉴权失败' });
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
return res.status(403).json({ error: true, message: '鉴权失败' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
next();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
app.use(apiKeyAuth);
|
|||
|
|
|
|||
|
|
// ─── 健康检查 ───
|
|||
|
|
app.get('/health', (_req, res) => {
|
|||
|
|
const available = Object.entries(DOMESTIC_MODELS).filter(([, cfg]) => {
|
|||
|
|
const key = process.env[cfg.envKey];
|
|||
|
|
return key && key.length > 5;
|
|||
|
|
}).map(([id]) => id);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
status: 'ok',
|
|||
|
|
service: 'cn-llm-relay',
|
|||
|
|
server: 'ZY-SVR-003',
|
|||
|
|
location: 'Guangzhou, China',
|
|||
|
|
port: PORT,
|
|||
|
|
availableModels: available.length,
|
|||
|
|
totalModels: Object.keys(DOMESTIC_MODELS).length,
|
|||
|
|
uptime: Math.floor((Date.now() - relayState.startTime) / 1000),
|
|||
|
|
stats: {
|
|||
|
|
total: relayState.totalCalls,
|
|||
|
|
success: relayState.successCalls,
|
|||
|
|
failed: relayState.failedCalls
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── 调用国内模型API ───
|
|||
|
|
function callDomesticAPI(modelConfig, apiKey, requestBody) {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const url = new URL(modelConfig.endpoint);
|
|||
|
|
const bodyStr = JSON.stringify(requestBody);
|
|||
|
|
|
|||
|
|
const options = {
|
|||
|
|
hostname: url.hostname,
|
|||
|
|
port: url.port || 443,
|
|||
|
|
path: url.pathname,
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'Authorization': `Bearer ${apiKey}`,
|
|||
|
|
'Content-Length': Buffer.byteLength(bodyStr)
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const req = https.request(options, (res) => {
|
|||
|
|
const chunks = [];
|
|||
|
|
res.on('data', chunk => chunks.push(chunk));
|
|||
|
|
res.on('end', () => {
|
|||
|
|
try {
|
|||
|
|
const body = JSON.parse(Buffer.concat(chunks).toString());
|
|||
|
|
if (body.error) {
|
|||
|
|
reject(new Error(`${modelConfig.endpoint}: ${body.error.message || JSON.stringify(body.error)}`));
|
|||
|
|
} else {
|
|||
|
|
resolve(body);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
reject(new Error(`${modelConfig.endpoint} 响应解析失败 (HTTP ${res.statusCode})`));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
req.setTimeout(API_TIMEOUT_MS, () => {
|
|||
|
|
req.destroy();
|
|||
|
|
reject(new Error(`${modelConfig.endpoint} 请求超时(${API_TIMEOUT_MS}ms)`));
|
|||
|
|
});
|
|||
|
|
req.on('error', (err) => {
|
|||
|
|
reject(new Error(`${modelConfig.endpoint} 连接失败: ${err.message}`));
|
|||
|
|
});
|
|||
|
|
req.write(bodyStr);
|
|||
|
|
req.end();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 核心中继接口 ───
|
|||
|
|
app.post('/llm/chat', async (req, res) => {
|
|||
|
|
const startTime = Date.now();
|
|||
|
|
relayState.totalCalls++;
|
|||
|
|
relayState.lastCall = new Date().toISOString();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const { messages, model_id, temperature, max_tokens, fallback_order } = req.body;
|
|||
|
|
|
|||
|
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|||
|
|
return res.status(400).json({
|
|||
|
|
error: true,
|
|||
|
|
message: '缺少messages参数或消息列表为空'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 确定模型尝试顺序
|
|||
|
|
const tryOrder = fallback_order && Array.isArray(fallback_order)
|
|||
|
|
? fallback_order
|
|||
|
|
: (model_id ? [model_id] : Object.keys(DOMESTIC_MODELS));
|
|||
|
|
|
|||
|
|
let lastError = null;
|
|||
|
|
|
|||
|
|
for (const mid of tryOrder) {
|
|||
|
|
const modelCfg = DOMESTIC_MODELS[mid];
|
|||
|
|
if (!modelCfg) continue;
|
|||
|
|
|
|||
|
|
const apiKey = process.env[modelCfg.envKey];
|
|||
|
|
if (!apiKey || apiKey.length < 5) continue;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const requestBody = {
|
|||
|
|
model: modelCfg.model,
|
|||
|
|
messages,
|
|||
|
|
temperature: temperature || 0.7,
|
|||
|
|
max_tokens: max_tokens || 2000,
|
|||
|
|
stream: false
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const response = await callDomesticAPI(modelCfg, apiKey, requestBody);
|
|||
|
|
const elapsed = Date.now() - startTime;
|
|||
|
|
|
|||
|
|
// 统计
|
|||
|
|
relayState.successCalls++;
|
|||
|
|
if (!relayState.modelStats[mid]) {
|
|||
|
|
relayState.modelStats[mid] = { calls: 0, tokens: 0 };
|
|||
|
|
}
|
|||
|
|
relayState.modelStats[mid].calls++;
|
|||
|
|
relayState.modelStats[mid].tokens += (response.usage?.total_tokens || 0);
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
success: true,
|
|||
|
|
relay: 'cn-direct',
|
|||
|
|
model_id: mid,
|
|||
|
|
choices: response.choices,
|
|||
|
|
usage: response.usage,
|
|||
|
|
elapsed_ms: elapsed
|
|||
|
|
});
|
|||
|
|
} catch (err) {
|
|||
|
|
lastError = err;
|
|||
|
|
console.error(`[CN中继] ${mid} 调用失败: ${err.message}`);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 所有模型都失败
|
|||
|
|
relayState.failedCalls++;
|
|||
|
|
res.status(502).json({
|
|||
|
|
error: true,
|
|||
|
|
message: '所有国内模型通道均失败',
|
|||
|
|
last_error: lastError?.message,
|
|||
|
|
elapsed_ms: Date.now() - startTime
|
|||
|
|
});
|
|||
|
|
} catch (err) {
|
|||
|
|
relayState.failedCalls++;
|
|||
|
|
res.status(500).json({
|
|||
|
|
error: true,
|
|||
|
|
message: `中继内部错误: ${err.message}`
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── 中继状态接口 ───
|
|||
|
|
app.get('/llm/stats', (_req, res) => {
|
|||
|
|
res.json({
|
|||
|
|
...relayState,
|
|||
|
|
uptimeMs: Date.now() - relayState.startTime,
|
|||
|
|
availableModels: Object.entries(DOMESTIC_MODELS)
|
|||
|
|
.filter(([, cfg]) => {
|
|||
|
|
const key = process.env[cfg.envKey];
|
|||
|
|
return key && key.length > 5;
|
|||
|
|
})
|
|||
|
|
.map(([id, cfg]) => ({ id, model: cfg.model }))
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── 启动 ───
|
|||
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|||
|
|
const available = Object.entries(DOMESTIC_MODELS).filter(([, cfg]) => {
|
|||
|
|
const key = process.env[cfg.envKey];
|
|||
|
|
return key && key.length > 5;
|
|||
|
|
});
|
|||
|
|
console.log(`
|
|||
|
|
═══════════════════════════════════════════════
|
|||
|
|
🇨🇳 CN LLM Relay · 国内模型中继服务
|
|||
|
|
═══════════════════════════════════════════════
|
|||
|
|
端口: ${PORT}
|
|||
|
|
鉴权: ${RELAY_API_KEY ? '✅ 已配置' : '❌ 未配置'}
|
|||
|
|
可用模型: ${available.length}/${Object.keys(DOMESTIC_MODELS).length}
|
|||
|
|
${available.map(([id, cfg]) => ` · ${id}: ${cfg.model}`).join('\n')}
|
|||
|
|
═══════════════════════════════════════════════
|
|||
|
|
`);
|
|||
|
|
});
|