zhizhi/persona-studio/backend/routes/proxy.js

285 lines
7.6 KiB
JavaScript
Raw 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 · 服务器端 AI 模型代理路由
*
* 目的:消除用户手动输入 API 密钥的需求
* 所有模型调用由服务器端代理完成API 密钥仅存在于服务器 .env
*
* 路由前缀:/api/ps/proxy
*
* 📜 Copyright: 国作登字-2026-A-00037559
* 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
*/
const express = require('express');
const router = express.Router();
const https = require('https');
const http = require('http');
// 服务器端配置的模型 API
const MODEL_API_KEY = process.env.MODEL_API_KEY || process.env.YUNWU_API_KEY || '';
const MODEL_API_BASE = process.env.MODEL_API_BASE || 'https://api.yunwu.ai/v1';
const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'gemini-2.0-flash';
/**
* 检查服务器端代理是否可用
* GET /api/ps/proxy/status
*/
router.get('/status', (_req, res) => {
const available = !!MODEL_API_KEY;
res.json({
error: false,
proxy_available: available,
model: available ? DEFAULT_MODEL : null,
api_base: available ? MODEL_API_BASE : null,
message: available
? '服务器端代理可用 · 无需 API 密钥'
: '服务器端代理未配置 · 请使用自有 API 密钥'
});
});
/**
* 获取服务器端可用模型列表
* GET /api/ps/proxy/models
*/
router.get('/models', async (_req, res) => {
if (!MODEL_API_KEY) {
return res.status(503).json({
error: true,
code: 'PROXY_NOT_CONFIGURED',
message: '服务器端 API 密钥未配置'
});
}
try {
const data = await callModelAPI('GET', '/models', null);
const models = (data.data || []).map(m => m.id).filter(Boolean).sort();
res.json({ error: false, models, api_base: MODEL_API_BASE });
} catch (err) {
res.status(502).json({
error: true,
code: 'MODEL_API_ERROR',
message: '无法获取模型列表: ' + (err.message || 'Unknown error')
});
}
});
/**
* 服务器端代理聊天
* POST /api/ps/proxy/chat
* Body: { messages: [...], model?: string, max_tokens?: number }
*/
router.post('/chat', async (req, res) => {
if (!MODEL_API_KEY) {
return res.status(503).json({
error: true,
code: 'PROXY_NOT_CONFIGURED',
message: '服务器端 API 密钥未配置,请使用自有 API 密钥'
});
}
const { messages, model, max_tokens } = req.body || {};
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({
error: true,
code: 'INVALID_MESSAGES',
message: '消息列表不能为空'
});
}
// Validate message format
for (const msg of messages) {
if (!msg.role || !msg.content) {
return res.status(400).json({
error: true,
code: 'INVALID_MESSAGE_FORMAT',
message: '每条消息必须包含 role 和 content 字段'
});
}
if (!['system', 'user', 'assistant'].includes(msg.role)) {
return res.status(400).json({
error: true,
code: 'INVALID_ROLE',
message: '消息 role 必须是 system/user/assistant'
});
}
}
try {
const data = await callModelAPI('POST', '/chat/completions', {
model: model || DEFAULT_MODEL,
messages,
max_tokens: Math.min(max_tokens || 4000, 8000),
temperature: 0.3
});
if (data.choices && data.choices[0] && data.choices[0].message) {
res.json({
error: false,
reply: data.choices[0].message.content,
model: data.model || model || DEFAULT_MODEL,
usage: data.usage || null
});
} else if (data.error) {
res.status(502).json({
error: true,
code: 'MODEL_ERROR',
message: data.error.message || '模型返回错误'
});
} else {
res.status(502).json({
error: true,
code: 'UNEXPECTED_RESPONSE',
message: '模型返回了意外格式的响应'
});
}
} catch (err) {
res.status(502).json({
error: true,
code: 'PROXY_ERROR',
message: '服务器代理请求失败: ' + (err.message || 'Unknown error')
});
}
});
/**
* 服务器端代理流式聊天SSE
* POST /api/ps/proxy/chat/stream
* Body: { messages: [...], model?: string, max_tokens?: number }
*/
router.post('/chat/stream', async (req, res) => {
if (!MODEL_API_KEY) {
return res.status(503).json({
error: true,
code: 'PROXY_NOT_CONFIGURED',
message: '服务器端 API 密钥未配置'
});
}
const { messages, model, max_tokens } = req.body || {};
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({
error: true,
code: 'INVALID_MESSAGES',
message: '消息列表不能为空'
});
}
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
try {
const base = MODEL_API_BASE.replace(/\/+$/, '');
const chatUrl = new URL(base + '/chat/completions');
const isHttps = chatUrl.protocol === 'https:';
const mod = isHttps ? https : http;
const body = JSON.stringify({
model: model || DEFAULT_MODEL,
messages,
max_tokens: Math.min(max_tokens || 4000, 8000),
temperature: 0.3,
stream: true
});
const options = {
hostname: chatUrl.hostname,
port: chatUrl.port || (isHttps ? 443 : 80),
path: chatUrl.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + MODEL_API_KEY,
'Content-Length': Buffer.byteLength(body)
},
timeout: 120000
};
const proxyReq = mod.request(options, (proxyRes) => {
proxyRes.on('data', (chunk) => {
res.write(chunk);
});
proxyRes.on('end', () => {
res.end();
});
});
proxyReq.on('error', (err) => {
res.write('data: ' + JSON.stringify({ error: true, message: err.message }) + '\n\n');
res.end();
});
proxyReq.on('timeout', () => {
proxyReq.destroy();
res.write('data: ' + JSON.stringify({ error: true, message: 'Request timeout' }) + '\n\n');
res.end();
});
req.on('close', () => {
proxyReq.destroy();
});
proxyReq.write(body);
proxyReq.end();
} catch (err) {
res.write('data: ' + JSON.stringify({ error: true, message: err.message }) + '\n\n');
res.end();
}
});
/**
* Helper: Call model API (non-streaming)
*/
function callModelAPI(method, endpoint, body) {
return new Promise((resolve, reject) => {
const base = MODEL_API_BASE.replace(/\/+$/, '');
const url = new URL(base + endpoint);
const isHttps = url.protocol === 'https:';
const mod = isHttps ? https : http;
const bodyStr = body ? JSON.stringify(body) : null;
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method,
headers: {
'Authorization': 'Bearer ' + MODEL_API_KEY,
'Content-Type': 'application/json'
},
timeout: 60000
};
if (bodyStr) {
options.headers['Content-Length'] = Buffer.byteLength(bodyStr);
}
const req = mod.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (_e) {
reject(new Error('Failed to parse API response'));
}
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timeout'));
});
if (bodyStr) req.write(bodyStr);
req.end();
});
}
module.exports = router;