Revert "feat: model-router插件 + 安全增强 + embeddings支持"
This commit is contained in:
parent
9c1a84c156
commit
ebf03d9e37
10 changed files with 33 additions and 985 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,8 +3,6 @@ node_modules
|
|||
.claude/
|
||||
CLAUDE.md
|
||||
config.json
|
||||
configs/pwd
|
||||
configs/provider_pools.json
|
||||
provider_pools.json
|
||||
plugins.json
|
||||
fetch_system_prompt.txt
|
||||
|
|
|
|||
1
configs/pwd
Normal file
1
configs/pwd
Normal file
|
|
@ -0,0 +1 @@
|
|||
admin123
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'aiclient-2-api',
|
||||
script: './src/core/master.js',
|
||||
cwd: '/root/.openclaw/workspace/projects/AIClient-2-API',
|
||||
interpreter: '/usr/bin/node',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '1G',
|
||||
env: {
|
||||
NODE_ENV: 'production'
|
||||
},
|
||||
error_file: './logs/pm2-error.log',
|
||||
out_file: './logs/pm2-out.log',
|
||||
log_file: './logs/pm2-combined.log',
|
||||
time: true,
|
||||
merge_logs: true
|
||||
}
|
||||
]
|
||||
}
|
||||
39
package-lock.json
generated
39
package-lock.json
generated
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "AIClient-2-API",
|
||||
"name": "AIClient2API",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
"dependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.14.0",
|
||||
"axios": "^1.10.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"google-auth-library": "^10.1.0",
|
||||
|
|
@ -2473,14 +2473,14 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
|
|
@ -3725,9 +3725,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
@ -3745,9 +3745,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
|
|
@ -5695,13 +5695,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
|
|
|
|||
|
|
@ -264,8 +264,6 @@ export class GrokConverter extends BaseConverter {
|
|||
return this.toOpenAIResponsesResponse(data, model);
|
||||
case MODEL_PROTOCOL_PREFIX.CODEX:
|
||||
return this.toCodexResponse(data, model);
|
||||
case MODEL_PROTOCOL_PREFIX.CLAUDE:
|
||||
return this.toClaudeResponse(data, model);
|
||||
default:
|
||||
return data;
|
||||
}
|
||||
|
|
@ -276,8 +274,6 @@ export class GrokConverter extends BaseConverter {
|
|||
*/
|
||||
convertStreamChunk(chunk, targetProtocol, model) {
|
||||
switch (targetProtocol) {
|
||||
case MODEL_PROTOCOL_PREFIX.CLAUDE:
|
||||
return this.toClaudeStreamChunk(chunk, model);
|
||||
case MODEL_PROTOCOL_PREFIX.OPENAI:
|
||||
return this.toOpenAIStreamChunk(chunk, model);
|
||||
case MODEL_PROTOCOL_PREFIX.GEMINI:
|
||||
|
|
@ -1143,76 +1139,6 @@ export class GrokConverter extends BaseConverter {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grok响应 -> Claude响应 (通过OpenAI中转)
|
||||
*/
|
||||
toClaudeResponse(grokResponse, model) {
|
||||
const openaiRes = this.toOpenAIResponse(grokResponse, model);
|
||||
if (!openaiRes) {
|
||||
return {
|
||||
id: `msg_${uuidv4()}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [],
|
||||
model: model,
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 0, output_tokens: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
const choice = openaiRes.choices?.[0] || {};
|
||||
const message = choice.message || {};
|
||||
const contentList = [];
|
||||
|
||||
// 工具调用
|
||||
const toolCalls = message.tool_calls || message.function_calls || [];
|
||||
for (const tc of (toolCalls || [])) {
|
||||
if (tc.function) {
|
||||
let argObj;
|
||||
try {
|
||||
argObj = typeof tc.function.arguments === 'string' ? JSON.parse(tc.function.arguments) : tc.function.arguments;
|
||||
} catch (e) {
|
||||
argObj = {};
|
||||
}
|
||||
contentList.push({
|
||||
type: "tool_use",
|
||||
id: tc.id || `toolu_${uuidv4().slice(0, 8)}`,
|
||||
name: tc.function.name || "",
|
||||
input: argObj
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 文本内容
|
||||
const text = message.content || "";
|
||||
if (text) {
|
||||
contentList.push({ type: "text", text });
|
||||
}
|
||||
|
||||
// 空内容兜底
|
||||
if (contentList.length === 0) {
|
||||
contentList.push({ type: "text", text: "" });
|
||||
}
|
||||
|
||||
const finishReason = choice.finish_reason || "stop";
|
||||
const stopReason = finishReason === "stop" ? "end_turn" : finishReason === "length" ? "max_tokens" : finishReason === "tool_calls" ? "tool_use" : finishReason;
|
||||
|
||||
return {
|
||||
id: `msg_${uuidv4()}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: contentList,
|
||||
model: choice.model || model,
|
||||
stop_reason: stopReason,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: openaiRes.usage?.prompt_tokens || 0,
|
||||
output_tokens: openaiRes.usage?.completion_tokens || 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grok流式响应块 -> Codex流式响应块
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,287 +0,0 @@
|
|||
/**
|
||||
* Model Router 插件 - 自定义模型别名路由
|
||||
*
|
||||
* 功能:
|
||||
* - 将自定义模型别名映射到 provider:model 格式
|
||||
* - 支持热编辑(通过 API 或配置文件)
|
||||
* - 支持 /plugin/model-router/ 路径查看和编辑映射规则
|
||||
*
|
||||
* 原理:
|
||||
* - 作为 middleware 插件,在请求处理最早期拦截
|
||||
* - 读取 body,匹配别名,改写 model 字段和 MODEL_PROVIDER
|
||||
* - 将处理后的 body 缓存到 req._cachedBody 供下游使用
|
||||
*/
|
||||
|
||||
import logger from '../../utils/logger.js';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CONFIG_FILE = path.join(process.cwd(), 'src', 'plugins', 'model-router', 'config.json');
|
||||
|
||||
/**
|
||||
* 加载映射配置
|
||||
*/
|
||||
async function loadMappings() {
|
||||
try {
|
||||
const content = await fs.readFile(CONFIG_FILE, 'utf8');
|
||||
const config = JSON.parse(content);
|
||||
return config.mappings || {};
|
||||
} catch (error) {
|
||||
logger.error(`[ModelRouter] Failed to load config: ${error.message}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存映射配置
|
||||
*/
|
||||
async function saveMappings(mappings) {
|
||||
const config = { mappings };
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配模型别名
|
||||
* 优先级:精确匹配 > 不区分大小写 > 包含匹配
|
||||
*/
|
||||
function matchAlias(requestedModel, mappings) {
|
||||
const lower = requestedModel.toLowerCase();
|
||||
|
||||
// 1. 精确匹配
|
||||
if (mappings[requestedModel]) return mappings[requestedModel];
|
||||
|
||||
// 2. 不区分大小写
|
||||
for (const [alias, rule] of Object.entries(mappings)) {
|
||||
if (alias.toLowerCase() === lower) return rule;
|
||||
}
|
||||
|
||||
// 3. 包含匹配
|
||||
for (const [alias, rule] of Object.entries(mappings)) {
|
||||
if (lower.includes(alias.toLowerCase()) || alias.toLowerCase().includes(lower)) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取完整的 request body 并缓存
|
||||
*/
|
||||
function readFullBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', chunk => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Monkey-patch getRequestBody 以支持缓存的 body
|
||||
*/
|
||||
function patchGetRequestBody() {
|
||||
const commonModuleUrl = new URL('../../utils/common.js', import.meta.url);
|
||||
import(commonModuleUrl).then(common => {
|
||||
// 保存原始函数引用(通过重新导入)
|
||||
// 由于 ESM 的 export 是只读的,我们用另一种方式
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const modelRouterPlugin = {
|
||||
name: 'model-router',
|
||||
version: '1.0.0',
|
||||
description: '模型别名路由插件 - 将自定义模型名映射到 provider:model<br>管理:<a href="/plugin/model-router/" target="_blank">model-router 管理面板</a>',
|
||||
type: 'middleware',
|
||||
_priority: 1, // 最高优先级,最先执行
|
||||
|
||||
async init(config) {
|
||||
// Monkey-patch getRequestBody to support cached body from this plugin
|
||||
try {
|
||||
const common = await import('../../utils/common.js');
|
||||
const origFn = common.getRequestBody;
|
||||
// We can't directly reassign ESM exports, but we can wrap the module
|
||||
// Instead, we'll patch it at runtime by modifying the imported reference
|
||||
// The trick: store the patched version on req for downstream to use
|
||||
// Actually, the simplest approach: we make getRequestBody check req._cachedBodyString
|
||||
} catch (e) {
|
||||
logger.warn(`[ModelRouter] Init warning: ${e.message}`);
|
||||
}
|
||||
logger.info('[ModelRouter] Plugin initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* 中间件:拦截请求,改写模型名
|
||||
*/
|
||||
async middleware(req, res, requestUrl, config) {
|
||||
if (req.method !== 'POST') return null;
|
||||
|
||||
const pathName = requestUrl.pathname;
|
||||
const isApiRequest = pathName.includes('/chat/completions') ||
|
||||
pathName.includes('/embeddings') ||
|
||||
pathName.includes('/images/generations') ||
|
||||
pathName.includes('/responses') ||
|
||||
pathName.includes('/messages') ||
|
||||
pathName.includes('/generateContent');
|
||||
|
||||
if (!isApiRequest) return null;
|
||||
|
||||
// 如果已经被本插件处理过,跳过
|
||||
if (req._modelRouterProcessed) return null;
|
||||
req._modelRouterProcessed = true;
|
||||
|
||||
// 读取完整 body
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await readFullBody(req);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(rawBody.toString() || '{}');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestedModel = body.model;
|
||||
if (!requestedModel) {
|
||||
// 不改写,但需要让 body 可被下游重新读取
|
||||
req._cachedBodyBuffer = rawBody;
|
||||
req._cachedBodyString = rawBody.toString();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 已经是 provider:model 格式且有对应号池,跳过
|
||||
if (requestedModel.includes(':')) {
|
||||
const prefix = requestedModel.split(':')[0];
|
||||
if (config.providerPools?.[prefix]) {
|
||||
req._cachedBodyBuffer = rawBody;
|
||||
req._cachedBodyString = rawBody.toString();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 匹配别名
|
||||
const mappings = await loadMappings();
|
||||
const rule = matchAlias(requestedModel, mappings);
|
||||
|
||||
let finalBodyString = rawBody.toString();
|
||||
|
||||
if (rule && rule.target) {
|
||||
logger.info(`[ModelRouter] "${requestedModel}" → "${rule.target}" (${rule.description || ''})`);
|
||||
|
||||
body.model = rule.target;
|
||||
finalBodyString = JSON.stringify(body);
|
||||
|
||||
// 设置 MODEL_PROVIDER
|
||||
const [provider] = rule.target.split(':');
|
||||
if (provider) {
|
||||
config.MODEL_PROVIDER = provider;
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存 body 供下游读取
|
||||
req._cachedBodyBuffer = Buffer.from(finalBodyString);
|
||||
req._cachedBodyString = finalBodyString;
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* API 路由:查看和编辑映射规则
|
||||
*/
|
||||
routes: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/plugin/model-router/',
|
||||
handler: async (method, pathUrl, req, res) => {
|
||||
try {
|
||||
const htmlPath = path.join(process.cwd(), 'src', 'plugins', 'model-router', 'static', 'index.html');
|
||||
const html = await fs.readFile(htmlPath, 'utf8');
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
} catch (e) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Plugin page not found');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/plugin/model-router/api/mappings',
|
||||
handler: async (method, pathUrl, req, res) => {
|
||||
const mappings = await loadMappings();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, mappings }, null, 2));
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/plugin/model-router/api/mappings',
|
||||
handler: async (method, pathUrl, req, res) => {
|
||||
try {
|
||||
const bodyBuffer = await readFullBody(req);
|
||||
const body = JSON.parse(bodyBuffer.toString());
|
||||
|
||||
const { alias, target, description } = body;
|
||||
if (!alias || !target) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'alias and target are required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const mappings = await loadMappings();
|
||||
mappings[alias] = { target, description: description || '' };
|
||||
await saveMappings(mappings);
|
||||
|
||||
logger.info(`[ModelRouter] Mapping added: ${alias} → ${target}`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, mappings }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/plugin/model-router/api/mappings',
|
||||
handler: async (method, pathUrl, req, res) => {
|
||||
try {
|
||||
const bodyBuffer = await readFullBody(req);
|
||||
const body = JSON.parse(bodyBuffer.toString());
|
||||
|
||||
const { alias } = body;
|
||||
if (!alias) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'alias is required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const mappings = await loadMappings();
|
||||
if (mappings[alias]) {
|
||||
delete mappings[alias];
|
||||
await saveMappings(mappings);
|
||||
logger.info(`[ModelRouter] Mapping deleted: ${alias}`);
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, mappings }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default modelRouterPlugin;
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Model Router - 模型别名路由管理</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; padding: 20px; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 20px; color: #38bdf8; }
|
||||
.card { background: #1e293b; border-radius: 12px; padding: 20px; margin-bottom: 16px; border: 1px solid #334155; }
|
||||
.card h2 { font-size: 1rem; margin-bottom: 12px; color: #94a3b8; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #334155; }
|
||||
th { color: #94a3b8; font-weight: 500; font-size: 0.85rem; }
|
||||
td { font-size: 0.9rem; }
|
||||
code { background: #0f172a; padding: 2px 6px; border-radius: 4px; font-size: 0.85rem; color: #a5f3fc; }
|
||||
.desc { color: #64748b; font-size: 0.8rem; }
|
||||
.actions { display: flex; gap: 6px; }
|
||||
button { padding: 6px 12px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem; transition: opacity 0.2s; }
|
||||
button:hover { opacity: 0.8; }
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-danger { background: #ef4444; color: white; }
|
||||
.btn-success { background: #22c55e; color: white; }
|
||||
.btn-secondary { background: #475569; color: white; }
|
||||
.form-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
input { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; padding: 8px 12px; border-radius: 6px; font-size: 0.85rem; }
|
||||
input:focus { outline: none; border-color: #3b82f6; }
|
||||
input.alias { width: 120px; }
|
||||
input.target { width: 280px; }
|
||||
input.desc { width: 200px; }
|
||||
.arrow { color: #38bdf8; font-size: 1.2rem; }
|
||||
.empty { color: #64748b; text-align: center; padding: 20px; }
|
||||
#toast { position: fixed; top: 20px; right: 20px; padding: 10px 20px; border-radius: 8px; color: white; font-size: 0.9rem; opacity: 0; transition: opacity 0.3s; z-index: 999; }
|
||||
#toast.success { background: #22c55e; }
|
||||
#toast.error { background: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔄 Model Router — 模型别名路由</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>➕ 添加映射</h2>
|
||||
<div class="form-row">
|
||||
<input class="alias" id="newAlias" placeholder="别名 (glm5)">
|
||||
<span class="arrow">→</span>
|
||||
<input class="target" id="newTarget" placeholder="目标 (provider:model)">
|
||||
<input class="desc" id="newDesc" placeholder="描述 (可选)">
|
||||
<button class="btn-success" onclick="addMapping()">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>📋 当前映射规则</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>别名</th><th>目标</th><th>描述</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="mappingTable">
|
||||
<tr><td colspan="4" class="empty">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
const API = '/plugin/model-router/api/mappings';
|
||||
|
||||
// 认证检查:从 localStorage 读取 token
|
||||
function getToken() {
|
||||
return localStorage.getItem('authToken');
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = getToken();
|
||||
if (!token) return {};
|
||||
return { 'Authorization': 'Bearer ' + token };
|
||||
}
|
||||
|
||||
// 页面加载时检查登录
|
||||
(function() {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
window.location.href = '/login.html?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}
|
||||
})();
|
||||
|
||||
function toast(msg, type) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = type;
|
||||
el.style.opacity = 1;
|
||||
setTimeout(() => el.style.opacity = 0, 2000);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(API, { headers: authHeaders() });
|
||||
if (res.status === 401) { window.location.href = '/login.html?redirect=' + encodeURIComponent(window.location.pathname); return; }
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('mappingTable');
|
||||
const mappings = data.mappings || {};
|
||||
const keys = Object.keys(mappings);
|
||||
|
||||
if (keys.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty">暂无映射规则</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = keys.map(alias => {
|
||||
const m = mappings[alias];
|
||||
return `<tr>
|
||||
<td><code>${esc(alias)}</code></td>
|
||||
<td><code>${esc(m.target)}</code></td>
|
||||
<td class="desc">${esc(m.description || '')}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-danger" onclick="deleteMapping('${esc(alias)}')">删除</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
toast('加载失败: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function addMapping() {
|
||||
const alias = document.getElementById('newAlias').value.trim();
|
||||
const target = document.getElementById('newTarget').value.trim();
|
||||
const description = document.getElementById('newDesc').value.trim();
|
||||
|
||||
if (!alias || !target) { toast('别名和目标不能为空', 'error'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(API, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ alias, target, description })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
toast('添加成功', 'success');
|
||||
document.getElementById('newAlias').value = '';
|
||||
document.getElementById('newTarget').value = '';
|
||||
document.getElementById('newDesc').value = '';
|
||||
load();
|
||||
} else {
|
||||
toast('添加失败: ' + data.error, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
toast('请求失败: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMapping(alias) {
|
||||
if (!confirm(`确定删除 "${alias}" ?`)) return;
|
||||
try {
|
||||
const res = await fetch(API, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ alias })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
toast('已删除', 'success');
|
||||
load();
|
||||
}
|
||||
} catch (e) {
|
||||
toast('删除失败: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -6,330 +6,6 @@ import {
|
|||
} from '../utils/common.js';
|
||||
import { getProviderPoolManager } from './service-manager.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { GrokApiService } from '../providers/grok/grok-core.js';
|
||||
|
||||
/**
|
||||
* Handle /v1/embeddings requests - lightweight passthrough
|
||||
*/
|
||||
async function handleEmbeddingsRequest(req, res, currentConfig, providerPoolManager) {
|
||||
logger.info(`[Embeddings] Handling request, cached=${!!req._cachedBodyString}, provider=${currentConfig.MODEL_PROVIDER}`);
|
||||
try {
|
||||
const body = await getRequestBody(req);
|
||||
const requestedModel = body.model;
|
||||
logger.info(`[Embeddings] Body parsed, model=${requestedModel}`);
|
||||
|
||||
if (!requestedModel) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'model is required', type: 'invalid_request_error' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine provider (model-router may have set MODEL_PROVIDER)
|
||||
const provider = currentConfig.MODEL_PROVIDER || 'openai-custom';
|
||||
|
||||
// Get a healthy account from the pool
|
||||
const pool = providerPoolManager?.providerPools?.[provider];
|
||||
if (!pool || pool.length === 0) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: `No accounts found for provider: ${provider}`, type: 'invalid_request_error' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip unhealthy accounts
|
||||
const account = pool.find(a => a.isHealthy !== false) || pool[0];
|
||||
const baseUrl = account.OPENAI_BASE_URL || account.baseUrl || account.base_url || '';
|
||||
const apiKey = account.OPENAI_API_KEY || account.apiKey || account.api_key || '';
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Account missing baseUrl or apiKey', type: 'invalid_request_error' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract actual model name (strip provider prefix)
|
||||
const actualModel = requestedModel.includes(':') ? requestedModel.split(':').slice(1).join(':') : requestedModel;
|
||||
|
||||
const targetUrl = baseUrl.replace(/\/+$/, '') + '/embeddings';
|
||||
logger.info(`[Embeddings] ${actualModel} → ${provider} (${targetUrl})`);
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({ ...body, model: actualModel })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
res.writeHead(response.status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
} catch (error) {
|
||||
logger.error(`[Embeddings] Error: ${error.message}`);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: error.message, type: 'server_error' } }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NVIDIA GenAI 模型映射:model 名称 → genai 路径后缀
|
||||
*/
|
||||
const NVIDIA_GENAI_MODEL_MAP = {
|
||||
'flux.2-klein-4b': '/genai/black-forest-labs/flux.2-klein-4b',
|
||||
'flux-2-klein-4b': '/genai/black-forest-labs/flux.2-klein-4b',
|
||||
'flux': '/genai/black-forest-labs/flux.2-klein-4b',
|
||||
'flux.1-dev': '/genai/black-forest-labs/flux-dev',
|
||||
'flux-dev': '/genai/black-forest-labs/flux-dev',
|
||||
'flux-pro': '/genai/black-forest-labs/flux-pro',
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否为 NVIDIA GenAI 模型,返回 genai 路径后缀
|
||||
*/
|
||||
function getNvidiaGenaiPath(modelName) {
|
||||
const lower = modelName.toLowerCase();
|
||||
// 精确匹配
|
||||
if (NVIDIA_GENAI_MODEL_MAP[lower]) return NVIDIA_GENAI_MODEL_MAP[lower];
|
||||
// 前缀匹配:flux 相关模型统一走 genai
|
||||
if (lower.startsWith('flux') || lower.startsWith('black-forest') || lower.includes('klein')) {
|
||||
return '/genai/black-forest-labs/flux.2-klein-4b';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 NVIDIA GenAI 响应格式转换为 OpenAI images/generations 格式
|
||||
* NVIDIA 返回:{ "artifacts": [{ "image": "<base64>", "seed": 123 }] }
|
||||
* OpenAI 返回:{ "data": [{ "b64_json": "<base64>" }] }
|
||||
*/
|
||||
function convertNvidiaGenaiToOpenAI(nvidiaResponse, requestedFormat) {
|
||||
// NVIDIA GenAI 返回 base64 image
|
||||
if (nvidiaResponse.artifacts && nvidiaResponse.artifacts.length > 0) {
|
||||
const artifact = nvidiaResponse.artifacts[0];
|
||||
const imageBase64 = artifact.image || artifact.base64 || '';
|
||||
if (requestedFormat === 'url' || !requestedFormat) {
|
||||
// 默认返回 data URL
|
||||
return {
|
||||
data: [{ url: `data:image/png;base64,${imageBase64}` }],
|
||||
seed: artifact.seed
|
||||
};
|
||||
} else if (requestedFormat === 'b64_json') {
|
||||
return {
|
||||
data: [{ b64_json: imageBase64 }],
|
||||
seed: artifact.seed
|
||||
};
|
||||
}
|
||||
}
|
||||
// 如果不认得的格式,原样返回
|
||||
return nvidiaResponse;
|
||||
}
|
||||
|
||||
async function handleImageGenerationsRequest(req, res, currentConfig, providerPoolManager) {
|
||||
logger.info(`[Images] Handling request, provider=${currentConfig.MODEL_PROVIDER}`);
|
||||
try {
|
||||
const body = await getRequestBody(req);
|
||||
const requestedModel = body.model;
|
||||
|
||||
if (!requestedModel) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'model is required', type: 'invalid_request_error' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = currentConfig.MODEL_PROVIDER || 'openai-custom';
|
||||
const actualModelFull = requestedModel.includes(':') ? requestedModel : `${provider}:${requestedModel}`;
|
||||
const actualModel = requestedModel.includes(':') ? requestedModel.split(':').slice(1).join(':') : requestedModel;
|
||||
|
||||
// Grok imagine 模型:使用 WebSocket 生成图片(不走 OpenAI 兼容流程)
|
||||
if (actualModel.toLowerCase().includes('grok') && actualModel.toLowerCase().includes('imagine')) {
|
||||
const providerKey = requestedModel.includes(':') ? requestedModel.split(':')[0] : provider;
|
||||
let grokAccount = null;
|
||||
const grokPool = providerPoolManager?.providerPools?.[providerKey];
|
||||
if (grokPool && grokPool.length > 0) {
|
||||
grokAccount = grokPool.find(a => a.isHealthy !== false) || grokPool[0];
|
||||
}
|
||||
if (!grokAccount) {
|
||||
for (const [poolName, poolEntries] of Object.entries(providerPoolManager?.providerPools || {})) {
|
||||
if (poolName.toLowerCase().includes('grok') && poolEntries.length > 0) {
|
||||
grokAccount = poolEntries.find(a => a.isHealthy !== false) || poolEntries[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!grokAccount || !grokAccount.GROK_COOKIE_TOKEN) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'No grok account with valid token found', type: 'invalid_request_error' } }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const grokService = new GrokApiService(grokAccount);
|
||||
const result = await grokService._generateAndCollectWS(actualModel, {
|
||||
message: body.prompt || '',
|
||||
n: body.n || 1,
|
||||
});
|
||||
const images = [];
|
||||
for (const cardJson of result.modelResponse?.cardAttachmentsJson || []) {
|
||||
const card = JSON.parse(cardJson);
|
||||
if (card.image?.original) {
|
||||
const dataUrl = card.image.original;
|
||||
const b64Match = dataUrl.match(/^data:image\/[a-z]+;base64,(.+)$/);
|
||||
if (b64Match) {
|
||||
images.push({ b64_json: b64Match[1] });
|
||||
} else {
|
||||
images.push({ url: dataUrl });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (images.length === 0) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Grok image generation returned no images', type: 'server_error' } }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ created: Math.floor(Date.now() / 1000), data: images }));
|
||||
} catch (grokError) {
|
||||
logger.error(`[Images] Grok imagine error: ${grokError.message}`);
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: `Grok image generation failed: ${grokError.message}`, type: 'server_error' } }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试从所有 pool 中找到该模型对应的 provider(支持 model-router 路由后的情况)
|
||||
let pool = providerPoolManager?.providerPools?.[provider];
|
||||
let account = null;
|
||||
let baseUrl = '';
|
||||
let apiKey = '';
|
||||
|
||||
if (!pool || pool.length === 0) {
|
||||
// fallback:遍历所有 pools 查找
|
||||
for (const [poolName, poolEntries] of Object.entries(providerPoolManager?.providerPools || {})) {
|
||||
const found = poolEntries.find(a => a.isHealthy !== false);
|
||||
if (found) {
|
||||
pool = poolEntries;
|
||||
account = found;
|
||||
baseUrl = account.OPENAI_BASE_URL || account.baseUrl || account.base_url || '';
|
||||
apiKey = account.OPENAI_API_KEY || account.apiKey || account.api_key || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!account) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: `No accounts found for provider: ${provider}`, type: 'invalid_request_error' } }));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
account = pool.find(a => a.isHealthy !== false) || pool[0];
|
||||
baseUrl = account.OPENAI_BASE_URL || account.baseUrl || account.base_url || '';
|
||||
apiKey = account.OPENAI_API_KEY || account.apiKey || account.api_key || '';
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Account missing baseUrl or apiKey', type: 'invalid_request_error' } }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 NVIDIA GenAI 模型(flux 等图像生成模型)
|
||||
const isNvidiaBaseUrl = baseUrl.includes('nvidia.com') && baseUrl.includes('integrate.api');
|
||||
const genaiPath = isNvidiaBaseUrl ? getNvidiaGenaiPath(actualModel) : null;
|
||||
|
||||
let targetUrl, requestBody, useOpenAiFormat = true;
|
||||
|
||||
if (genaiPath) {
|
||||
// NVIDIA GenAI 原生格式(域名从 integrate.api → ai.api)
|
||||
useOpenAiFormat = false;
|
||||
const genaiBaseUrl = baseUrl.replace('integrate.api.nvidia.com', 'ai.api.nvidia.com');
|
||||
targetUrl = genaiBaseUrl.replace(/\/+$/, '') + genaiPath;
|
||||
// 转换为 NVIDIA GenAI 格式:{ prompt, width, height, seed, steps }
|
||||
requestBody = {
|
||||
prompt: body.prompt || '',
|
||||
width: body.width || 1024,
|
||||
height: body.height || 1024,
|
||||
seed: body.seed !== undefined ? body.seed : 0,
|
||||
steps: body.steps || body.num_inference_steps || 4,
|
||||
};
|
||||
if (body.guidance_scale) requestBody.guidance_scale = body.guidance_scale;
|
||||
logger.info(`[Images] NVIDIA GenAI: ${actualModel} → ${targetUrl}`);
|
||||
} else {
|
||||
// 标准 OpenAI /images/generations 格式
|
||||
targetUrl = baseUrl.replace(/\/+$/, '') + '/images/generations';
|
||||
requestBody = { ...body, model: actualModel };
|
||||
logger.info(`[Images] ${actualModel} → ${provider} (${targetUrl})`);
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'application/json';
|
||||
|
||||
// 如果是 GenAI 请求,转换响应格式
|
||||
if (!useOpenAiFormat) {
|
||||
// NVIDIA 可能返回 JSON 或二进制图片
|
||||
if (contentType.includes('json')) {
|
||||
const data = await response.json();
|
||||
const format = body.response_format;
|
||||
const converted = response.ok ? convertNvidiaGenaiToOpenAI(data, format) : data;
|
||||
res.writeHead(response.status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(converted));
|
||||
} else {
|
||||
// 二进制响应(图片直接返回)
|
||||
const data = await response.arrayBuffer();
|
||||
logger.info(`[Images] NVIDIA GenAI binary response: ${contentType}, ${data.byteLength} bytes`);
|
||||
if (response.ok) {
|
||||
// 转 base64 并包装成 OpenAI 格式
|
||||
const b64 = Buffer.from(data).toString('base64');
|
||||
const format = body.response_format;
|
||||
if (format === 'b64_json') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ data: [{ b64_json: b64 }] }));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ data: [{ url: `data:${contentType};base64,${b64}` }] }));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(response.status, { 'Content-Type': contentType });
|
||||
res.end(Buffer.from(data));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const data = await response.arrayBuffer();
|
||||
res.writeHead(response.status, { 'Content-Type': contentType });
|
||||
res.end(Buffer.from(data));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[Images] Error: ${error.message}`);
|
||||
logger.error(`[Images] Stack: ${error.stack}`);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: error.message, type: 'server_error' } }));
|
||||
}
|
||||
}
|
||||
|
||||
function getRequestBody(req) {
|
||||
if (req._cachedBodyString) {
|
||||
try {
|
||||
return Promise.resolve(JSON.parse(req._cachedBodyString));
|
||||
} catch (e) {
|
||||
logger.error(`[getRequestBody] cached body parse failed: ${e.message}, raw=${req._cachedBodyString.substring(0, 200)}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk.toString(); });
|
||||
req.on('end', () => {
|
||||
try { resolve(JSON.parse(body || '{}')); }
|
||||
catch (e) { reject(new Error('Invalid JSON: ' + e.message)); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Handle API authentication and routing
|
||||
* @param {string} method - The HTTP method
|
||||
|
|
@ -376,14 +52,6 @@ export async function handleAPIRequests(method, path, req, res, currentConfig, a
|
|||
await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.CLAUDE_MESSAGE, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid, path);
|
||||
return true;
|
||||
}
|
||||
if (path === '/v1/embeddings') {
|
||||
await handleEmbeddingsRequest(req, res, currentConfig, providerPoolManager);
|
||||
return true;
|
||||
}
|
||||
if (path === '/v1/images/generations') {
|
||||
await handleImageGenerationsRequest(req, res, currentConfig, providerPoolManager);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -115,55 +115,31 @@ function getExpiryTime() {
|
|||
|
||||
/**
|
||||
* 读取token存储文件
|
||||
* 安全增强:读取失败时抛出异常而非返回空对象,防止数据覆盖
|
||||
*/
|
||||
async function readTokenStore() {
|
||||
if (existsSync(TOKEN_STORE_FILE)) {
|
||||
const content = await fs.readFile(TOKEN_STORE_FILE, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} else {
|
||||
// 如果文件不存在,创建一个默认的token store
|
||||
try {
|
||||
if (existsSync(TOKEN_STORE_FILE)) {
|
||||
const content = await fs.readFile(TOKEN_STORE_FILE, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} else {
|
||||
// 如果文件不存在,创建一个默认的token store
|
||||
await writeTokenStore({ tokens: {} });
|
||||
return { tokens: {} };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[Token Store] Failed to read token store file:', error);
|
||||
return { tokens: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入token存储文件
|
||||
* 安全增强:
|
||||
* 1. 写前校验:禁止写入空tokens(防数据丢失)
|
||||
* 2. 原子写入:先写临时文件再rename,防止断电/崩溃导致文件截断
|
||||
*/
|
||||
async function writeTokenStore(tokenStore) {
|
||||
// 写前校验:如果tokens为空且文件已有数据,拒绝写入(防意外清空)
|
||||
if (!tokenStore || !tokenStore.tokens || Object.keys(tokenStore.tokens).length === 0) {
|
||||
if (existsSync(TOKEN_STORE_FILE)) {
|
||||
try {
|
||||
const existing = await fs.readFile(TOKEN_STORE_FILE, 'utf8');
|
||||
const parsed = JSON.parse(existing);
|
||||
const existingCount = parsed.tokens ? Object.keys(parsed.tokens).length : 0;
|
||||
if (existingCount > 0) {
|
||||
logger.error('[Token Store] REJECTED write: attempting to overwrite ' + existingCount + ' tokens with empty data. Possible data corruption detected.');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[Token Store] Failed to check existing token store before write:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.stringify(tokenStore, null, 2);
|
||||
const tmpPath = TOKEN_STORE_FILE + '.tmp';
|
||||
// 原子写入:先写临时文件,再rename
|
||||
await fs.writeFile(tmpPath, data, 'utf8');
|
||||
await fs.rename(tmpPath, TOKEN_STORE_FILE);
|
||||
return true;
|
||||
await fs.writeFile(TOKEN_STORE_FILE, JSON.stringify(tokenStore, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
logger.error('[Token Store] Failed to write token store file:', error);
|
||||
// 清理可能残留的临时文件
|
||||
try { await fs.unlink(TOKEN_STORE_FILE + '.tmp'); } catch {}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -437,29 +413,7 @@ export async function handleLoginRequest(req, res) {
|
|||
return true;
|
||||
}
|
||||
|
||||
// 定时清理过期token(带防护:清理后必须仍有≥1个字段,否则不写入)
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const tokenStore = await readTokenStore();
|
||||
const existingCount = tokenStore.tokens ? Object.keys(tokenStore.tokens).length : 0;
|
||||
const now = Date.now();
|
||||
let hasChanges = false;
|
||||
|
||||
for (const token in tokenStore.tokens) {
|
||||
if (now > tokenStore.tokens[token].expiryTime) {
|
||||
delete tokenStore.tokens[token];
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 防护:只有非首次运行+有变更时才写入(避免首次启动读不到旧文件时空写)
|
||||
if (hasChanges && existingCount > 0) {
|
||||
await writeTokenStore(tokenStore);
|
||||
logger.info(`[Token Store] Cleaned up expired tokens. Before: ${existingCount}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[Token Store] Cleanup interval error:', error.message);
|
||||
}
|
||||
}, 5 * 60 * 1000); // 每5分钟清理一次
|
||||
// 定时清理过期token
|
||||
setInterval(cleanupExpiredTokens, 5 * 60 * 1000); // 每5分钟清理一次
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -191,12 +191,6 @@ export function getClientIp(req) {
|
|||
* @throws {Error} If the request body is not valid JSON.
|
||||
*/
|
||||
export function getRequestBody(req) {
|
||||
// Support cached body from model-router plugin
|
||||
if (req._cachedBodyString) {
|
||||
try {
|
||||
return Promise.resolve(JSON.parse(req._cachedBodyString));
|
||||
} catch (e) {}
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue