2026-03-10 20:06:23 +08:00
/ * *
* 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 ( /\/+$/ , '' ) ;
2026-03-11 11:04:29 +08:00
// 直接拼接 /models, 因为 apiBase 已经由前端规范化
const modelsPath = base + '/models' ;
2026-03-10 20:06:23 +08:00
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 ( '未检测到可用模型' ) ) ;
}
} ) ;
} ) ;
2026-03-10 20:08:37 +08:00
req . on ( 'error' , ( err ) => {
2026-03-10 20:53:19 +08:00
const wrapped = new Error ( 'API Base 不可访问: ' + err . message ) ;
wrapped . code = err . code ;
reject ( wrapped ) ;
2026-03-10 20:06:23 +08:00
} ) ;
req . on ( 'timeout' , ( ) => {
req . destroy ( ) ;
2026-03-10 20:53:19 +08:00
const err = new Error ( 'API Base 不可访问(请求超时)' ) ;
err . code = 'ETIMEDOUT' ;
reject ( err ) ;
2026-03-10 20:06:23 +08:00
} ) ;
req . end ( ) ;
} ) ;
}
/ * *
* 调用用户 API 的 chat / completions 接口
* /
function callUserApi ( { apiBase , apiKey , model , messages , maxTokens , temperature , timeoutMs } ) {
return new Promise ( ( resolve , reject ) => {
const base = apiBase . replace ( /\/+$/ , '' ) ;
2026-03-11 11:04:29 +08:00
// 直接拼接 /chat/completions, 因为 apiBase 已经由前端规范化
// 包含完整版本路径(如 /v1, /v1beta/openai, /v4 等)
const chatPath = base + '/chat/completions' ;
2026-03-10 20:06:23 +08:00
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'
} ) ;
}
2026-03-10 20:51:34 +08:00
// 校验 URL 格式
try {
const testBase = api _base . replace ( /\/+$/ , '' ) ;
2026-03-11 11:04:29 +08:00
new URL ( testBase + '/models' ) ;
2026-03-10 20:51:34 +08:00
} catch ( _e ) {
return res . status ( 400 ) . json ( {
error : true ,
code : 'INVALID_API_BASE' ,
message : 'API Base URL 格式无效'
} ) ;
}
2026-03-10 20:06:23 +08:00
if ( ! api _key || typeof api _key !== 'string' ) {
return res . status ( 400 ) . json ( {
error : true ,
code : 'MISSING_API_KEY' ,
message : '请输入 API Key'
} ) ;
}
2026-03-10 20:08:37 +08:00
// 防止 header injection: API Key 不得包含换行符
if ( /[\r\n]/ . test ( api _key ) ) {
return res . status ( 400 ) . json ( {
error : true ,
code : 'INVALID_API_KEY' ,
message : 'API Key 格式无效'
} ) ;
}
2026-03-10 20:06:23 +08:00
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 ) {
2026-03-10 20:51:34 +08:00
const errMsg = err . message || '模型检测失败' ;
2026-03-10 20:53:19 +08:00
const errCode = err . code || '' ;
2026-03-10 20:51:34 +08:00
let code = 'DETECT_FAILED' ;
2026-03-10 20:53:19 +08:00
// 区分 DNS / 网络 / 超时错误(优先使用 Node.js 错误码)
if ( errCode === 'ENOTFOUND' || errCode === 'EAI_AGAIN' || /ENOTFOUND|getaddrinfo/ . test ( errMsg ) ) {
2026-03-10 20:51:34 +08:00
code = 'DNS_ERROR' ;
2026-03-10 20:53:19 +08:00
} else if ( errCode === 'ECONNREFUSED' || errCode === 'ECONNRESET' || errCode === 'EHOSTUNREACH' || errCode === 'ENETUNREACH' || /ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH|socket hang up/ . test ( errMsg ) ) {
2026-03-10 20:51:34 +08:00
code = 'NETWORK_ERROR' ;
2026-03-10 20:53:19 +08:00
} else if ( errCode === 'ETIMEDOUT' || errCode === 'ESOCKETTIMEDOUT' || /timeout|ETIMEDOUT/ . test ( errMsg ) ) {
2026-03-10 20:51:34 +08:00
code = 'TIMEOUT' ;
}
2026-03-10 20:06:23 +08:00
res . status ( 502 ) . json ( {
error : true ,
2026-03-10 20:51:34 +08:00
code : code ,
message : errMsg
2026-03-10 20:06:23 +08:00
} ) ;
}
} ) ;
// 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)'
} ) ;
}
2026-03-10 20:08:37 +08:00
// 防止 header injection
if ( /[\r\n]/ . test ( api _key ) ) {
return res . status ( 400 ) . json ( {
error : true ,
code : 'INVALID_API_KEY' ,
message : 'API Key 格式无效'
} ) ;
}
2026-03-10 20:06:23 +08:00
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 ;