233 lines
7.4 KiB
JavaScript
233 lines
7.4 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
// ═══════════════════════════════════════════════
|
|||
|
|
// 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
|
|||
|
|
// 📜 Copyright: 国作登字-2026-A-00037559
|
|||
|
|
// ═══════════════════════════════════════════════
|
|||
|
|
// server/proxy/service/llm-router.js
|
|||
|
|
// 🤖 AI大模型动态路由器
|
|||
|
|
//
|
|||
|
|
// 核心原则 (冰朔指令):
|
|||
|
|
// - 不写死模型,系统自动检测+按需调用
|
|||
|
|
// - 调用失败自动切换下一个
|
|||
|
|
// - 所有模型都失败 → 推送邮件给冰朔
|
|||
|
|
//
|
|||
|
|
// 使用仓库已配置的 ZY_LLM_API_KEY + ZY_LLM_BASE_URL
|
|||
|
|
// secrets-manifest.json 确认此KEY支持多模型动态路由
|
|||
|
|
// ═══════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const https = require('https');
|
|||
|
|
const http = require('http');
|
|||
|
|
|
|||
|
|
// ── 模型优先级列表(自动降级)──────────────────
|
|||
|
|
// API网关会根据可用性自动路由,但我们仍按优先级尝试
|
|||
|
|
const MODEL_PRIORITY = [
|
|||
|
|
'deepseek-chat',
|
|||
|
|
'qwen-turbo',
|
|||
|
|
'moonshot-v1-8k',
|
|||
|
|
'glm-4-flash',
|
|||
|
|
'claude-3-haiku-20240307',
|
|||
|
|
'gpt-4o-mini'
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// ── Claude模型识别 (需走硅谷中继) ──────────────
|
|||
|
|
const CLAUDE_MODELS = [
|
|||
|
|
'claude-3-haiku-20240307',
|
|||
|
|
'claude-3-sonnet-20240229',
|
|||
|
|
'claude-3-opus-20240229',
|
|||
|
|
'claude-3-5-sonnet-20241022'
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// 硅谷SSH隧道本地端口 (Claude API中继)
|
|||
|
|
// 新加坡服务器通过SSH隧道将Claude请求从硅谷IP出口
|
|||
|
|
// 配置: server/proxy/config/claude-relay-config.json
|
|||
|
|
const CLAUDE_RELAY_PORT = parseInt(process.env.ZY_CLAUDE_RELAY_PORT || '18443', 10);
|
|||
|
|
const CLAUDE_RELAY_ENABLED = process.env.ZY_CLAUDE_RELAY_ENABLED === 'true';
|
|||
|
|
|
|||
|
|
function isClaudeModel(model) {
|
|||
|
|
return CLAUDE_MODELS.some(cm => model.includes(cm)) || model.includes('claude');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 路由器状态 ──────────────────────────────
|
|||
|
|
const routerState = {
|
|||
|
|
total_calls: 0,
|
|||
|
|
successful_calls: 0,
|
|||
|
|
failed_calls: 0,
|
|||
|
|
last_success: null,
|
|||
|
|
last_error: null,
|
|||
|
|
model_stats: {} // 每个模型的调用统计
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 调用LLM API(自动模型降级)
|
|||
|
|
* @param {string} prompt - 用户/系统提示
|
|||
|
|
* @param {Object} options - 可选配置
|
|||
|
|
* @param {string} options.systemPrompt - 系统提示
|
|||
|
|
* @param {number} options.maxTokens - 最大token数
|
|||
|
|
* @param {number} options.timeout - 超时时间(ms)
|
|||
|
|
* @param {string[]} options.preferModels - 优先尝试的模型列表
|
|||
|
|
* @returns {Promise<{content: string, model: string, usage: Object}|null>}
|
|||
|
|
*/
|
|||
|
|
async function callLLM(prompt, options = {}) {
|
|||
|
|
const apiKey = process.env.ZY_LLM_API_KEY || '';
|
|||
|
|
const baseUrl = process.env.ZY_LLM_BASE_URL || '';
|
|||
|
|
|
|||
|
|
if (!apiKey || !baseUrl) {
|
|||
|
|
console.log('[LLM路由器] API未配置 (ZY_LLM_API_KEY / ZY_LLM_BASE_URL)');
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
systemPrompt = '你是光湖语言世界VPN系统的AI助手。请简洁、准确地回答问题。',
|
|||
|
|
maxTokens = 500,
|
|||
|
|
timeout = 30000,
|
|||
|
|
preferModels = null
|
|||
|
|
} = options;
|
|||
|
|
|
|||
|
|
const modelsToTry = preferModels || MODEL_PRIORITY;
|
|||
|
|
routerState.total_calls++;
|
|||
|
|
|
|||
|
|
for (const model of modelsToTry) {
|
|||
|
|
try {
|
|||
|
|
const result = await _callSingleModel(baseUrl, apiKey, model, prompt, systemPrompt, maxTokens, timeout);
|
|||
|
|
if (result) {
|
|||
|
|
routerState.successful_calls++;
|
|||
|
|
routerState.last_success = {
|
|||
|
|
model,
|
|||
|
|
time: new Date().toISOString()
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 更新模型统计
|
|||
|
|
if (!routerState.model_stats[model]) {
|
|||
|
|
routerState.model_stats[model] = { success: 0, fail: 0 };
|
|||
|
|
}
|
|||
|
|
routerState.model_stats[model].success++;
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.log(`[LLM路由器] 模型 ${model} 调用失败: ${err.message},尝试下一个...`);
|
|||
|
|
|
|||
|
|
if (!routerState.model_stats[model]) {
|
|||
|
|
routerState.model_stats[model] = { success: 0, fail: 0 };
|
|||
|
|
}
|
|||
|
|
routerState.model_stats[model].fail++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 所有模型都失败
|
|||
|
|
routerState.failed_calls++;
|
|||
|
|
routerState.last_error = {
|
|||
|
|
time: new Date().toISOString(),
|
|||
|
|
message: `所有模型(${modelsToTry.length}个)均调用失败`
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
console.error(`[LLM路由器] ❌ 所有模型均不可用`);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 调用单个模型
|
|||
|
|
* Claude模型自动走硅谷中继 (当 ZY_CLAUDE_RELAY_ENABLED=true)
|
|||
|
|
*/
|
|||
|
|
function _callSingleModel(baseUrl, apiKey, model, prompt, systemPrompt, maxTokens, timeout) {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
let urlObj;
|
|||
|
|
try {
|
|||
|
|
urlObj = new URL(baseUrl);
|
|||
|
|
} catch {
|
|||
|
|
// 如果baseUrl不含协议,加上https
|
|||
|
|
urlObj = new URL(`https://${baseUrl}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Claude模型走硅谷中继 (SSH隧道 localhost:18443 → 硅谷 → api.anthropic.com)
|
|||
|
|
if (CLAUDE_RELAY_ENABLED && isClaudeModel(model)) {
|
|||
|
|
console.log(`[LLM路由器] Claude模型 ${model} → 硅谷中继 (localhost:${CLAUDE_RELAY_PORT})`);
|
|||
|
|
urlObj = new URL(`https://localhost:${CLAUDE_RELAY_PORT}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const postData = JSON.stringify({
|
|||
|
|
model,
|
|||
|
|
messages: [
|
|||
|
|
{ role: 'system', content: systemPrompt },
|
|||
|
|
{ role: 'user', content: prompt }
|
|||
|
|
],
|
|||
|
|
max_tokens: maxTokens,
|
|||
|
|
temperature: 0.3
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 构建路径:如果baseUrl已包含chat/completions路径则使用
|
|||
|
|
// 否则追加 /v1/chat/completions
|
|||
|
|
let apiPath = urlObj.pathname;
|
|||
|
|
if (!apiPath || apiPath === '/') {
|
|||
|
|
apiPath = '/v1/chat/completions';
|
|||
|
|
} else if (!apiPath.includes('/chat/completions')) {
|
|||
|
|
apiPath = apiPath.replace(/\/+$/, '') + '/chat/completions';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const options = {
|
|||
|
|
hostname: urlObj.hostname,
|
|||
|
|
port: urlObj.port || (urlObj.protocol === 'http:' ? 80 : 443),
|
|||
|
|
path: apiPath,
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'Authorization': `Bearer ${apiKey}`,
|
|||
|
|
'Content-Length': Buffer.byteLength(postData)
|
|||
|
|
},
|
|||
|
|
timeout
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const httpModule = urlObj.protocol === 'http:' ? http : https;
|
|||
|
|
|
|||
|
|
const req = httpModule.request(options, (res) => {
|
|||
|
|
let data = '';
|
|||
|
|
res.on('data', (d) => { data += d; });
|
|||
|
|
res.on('end', () => {
|
|||
|
|
try {
|
|||
|
|
if (res.statusCode >= 400) {
|
|||
|
|
reject(new Error(`HTTP ${res.statusCode}: 请求失败`));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const json = JSON.parse(data);
|
|||
|
|
const content = json.choices?.[0]?.message?.content;
|
|||
|
|
if (!content) {
|
|||
|
|
reject(new Error('响应中无content字段'));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
resolve({
|
|||
|
|
content,
|
|||
|
|
model: json.model || model,
|
|||
|
|
usage: json.usage || null
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
reject(new Error(`JSON解析失败: ${e.message}`));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
req.on('error', (err) => reject(err));
|
|||
|
|
req.on('timeout', () => {
|
|||
|
|
req.destroy();
|
|||
|
|
reject(new Error(`超时(${timeout}ms)`));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
req.write(postData);
|
|||
|
|
req.end();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取路由器状态
|
|||
|
|
*/
|
|||
|
|
function getRouterStatus() {
|
|||
|
|
return {
|
|||
|
|
...routerState,
|
|||
|
|
api_configured: !!(process.env.ZY_LLM_API_KEY && process.env.ZY_LLM_BASE_URL),
|
|||
|
|
models_available: MODEL_PRIORITY
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = { callLLM, getRouterStatus, MODEL_PRIORITY, CLAUDE_MODELS, isClaudeModel };
|