Revert "feat: model-router插件 + 安全增强 + embeddings支持"

This commit is contained in:
何夕2077 2026-04-05 22:09:24 +08:00 committed by GitHub
parent 9c1a84c156
commit ebf03d9e37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 33 additions and 985 deletions

2
.gitignore vendored
View file

@ -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
View file

@ -0,0 +1 @@
admin123

View file

@ -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
View file

@ -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",

View file

@ -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流式响应块
*/

View file

@ -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;

View file

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
load();
</script>
</body>
</html>

View file

@ -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;

View file

@ -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分钟清理一次

View file

@ -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 => {