Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

313 lines
8.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* persona-studio · API Key 模型检测路由
* POST /api/ps/apikey/detect-models 检测可用模型
* POST /api/ps/apikey/chat 通过用户 API Key 对话
*/
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const https = require('https');
const http = require('http');
/* ---- 模型列表缓存1 小时有效期) ---- */
const modelCache = new Map();
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 小时
function getCacheKey(apiBase, apiKey) {
const hash = crypto.createHash('sha256').update(apiKey).digest('hex').slice(0, 16);
return apiBase + '::' + hash;
}
function getCachedModels(apiBase, apiKey) {
const key = getCacheKey(apiBase, apiKey);
const entry = modelCache.get(key);
if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) {
return entry.models;
}
return null;
}
function setCachedModels(apiBase, apiKey, models) {
const key = getCacheKey(apiBase, apiKey);
modelCache.set(key, { models, timestamp: Date.now() });
}
/**
* 请求第三方 API 的 /v1/models 接口
*/
function fetchModels(apiBase, apiKey, timeoutMs) {
return new Promise((resolve, reject) => {
// 规范化 apiBase去除末尾斜杠
const base = apiBase.replace(/\/+$/, '');
// 直接拼接 /models因为 apiBase 已经由前端规范化
const modelsPath = base + '/models';
const url = new URL(modelsPath);
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(new Error('API Key 无效'));
}
if (res.statusCode >= 400) {
return reject(new Error('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(new Error('未检测到可用模型'));
}
});
});
req.on('error', (err) => {
const wrapped = new Error('API Base 不可访问: ' + err.message);
wrapped.code = err.code;
reject(wrapped);
});
req.on('timeout', () => {
req.destroy();
const err = new Error('API Base 不可访问(请求超时)');
err.code = 'ETIMEDOUT';
reject(err);
});
req.end();
});
}
/**
* 调用用户 API 的 chat/completions 接口
*/
function callUserApi({ apiBase, apiKey, model, messages, maxTokens, temperature, timeoutMs }) {
return new Promise((resolve, reject) => {
const base = apiBase.replace(/\/+$/, '');
// 直接拼接 /chat/completions因为 apiBase 已经由前端规范化
// 包含完整版本路径(如 /v1, /v1beta/openai, /v4 等)
const chatPath = base + '/chat/completions';
const url = new URL(chatPath);
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
router.post('/detect-models', async (req, res) => {
const { api_base, api_key } = req.body || {};
if (!api_base || typeof api_base !== 'string') {
return res.status(400).json({
error: true,
code: 'MISSING_API_BASE',
message: '请输入 API Base URL'
});
}
// 校验 URL 格式
try {
const testBase = api_base.replace(/\/+$/, '');
new URL(testBase + '/models');
} catch (_e) {
return res.status(400).json({
error: true,
code: 'INVALID_API_BASE',
message: 'API Base URL 格式无效'
});
}
if (!api_key || typeof api_key !== 'string') {
return res.status(400).json({
error: true,
code: 'MISSING_API_KEY',
message: '请输入 API Key'
});
}
// 防止 header injectionAPI Key 不得包含换行符
if (/[\r\n]/.test(api_key)) {
return res.status(400).json({
error: true,
code: 'INVALID_API_KEY',
message: 'API Key 格式无效'
});
}
const cached = getCachedModels(api_base, api_key);
if (cached) {
return res.json({
error: false,
models: cached,
count: cached.length,
cached: true
});
}
try {
const models = await fetchModels(api_base, api_key, 15000);
if (!models || models.length === 0) {
return res.status(404).json({
error: true,
code: 'NO_MODELS',
message: '未检测到可用模型'
});
}
// 写入缓存
setCachedModels(api_base, api_key, models);
res.json({
error: false,
models: models,
count: models.length,
cached: false
});
} catch (err) {
const errMsg = err.message || '模型检测失败';
const errCode = err.code || '';
let code = 'DETECT_FAILED';
// 区分 DNS / 网络 / 超时错误(优先使用 Node.js 错误码)
if (errCode === 'ENOTFOUND' || errCode === 'EAI_AGAIN' || /ENOTFOUND|getaddrinfo/.test(errMsg)) {
code = 'DNS_ERROR';
} else if (errCode === 'ECONNREFUSED' || errCode === 'ECONNRESET' || errCode === 'EHOSTUNREACH' || errCode === 'ENETUNREACH' || /ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH|socket hang up/.test(errMsg)) {
code = 'NETWORK_ERROR';
} else if (errCode === 'ETIMEDOUT' || errCode === 'ESOCKETTIMEDOUT' || /timeout|ETIMEDOUT/.test(errMsg)) {
code = 'TIMEOUT';
}
res.status(502).json({
error: true,
code: code,
message: errMsg
});
}
});
// POST /api/ps/apikey/chat
router.post('/chat', async (req, res) => {
const { api_base, api_key, model, messages } = req.body || {};
if (!api_base || !api_key || !model) {
return res.status(400).json({
error: true,
code: 'MISSING_PARAMS',
message: '缺少必要参数 (api_base, api_key, model)'
});
}
// 防止 header injection
if (/[\r\n]/.test(api_key)) {
return res.status(400).json({
error: true,
code: 'INVALID_API_KEY',
message: 'API Key 格式无效'
});
}
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({
error: true,
code: 'MISSING_MESSAGES',
message: '缺少消息内容'
});
}
try {
const reply = await callUserApi({
apiBase: api_base,
apiKey: api_key,
model: model,
messages: messages,
maxTokens: 2000,
temperature: 0.8,
timeoutMs: 60000
});
res.json({
error: false,
reply: reply,
model: model
});
} catch (err) {
res.status(502).json({
error: true,
code: 'CHAT_FAILED',
message: err.message || '对话请求失败'
});
}
});
module.exports = router;