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 };
|