313 lines
8.5 KiB
JavaScript
313 lines
8.5 KiB
JavaScript
/**
|
||
* 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 injection:API 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;
|