diff --git a/.gitignore b/.gitignore index c375cbc..63485fc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ node_modules .claude/ CLAUDE.md config.json +configs/pwd +configs/provider_pools.json provider_pools.json plugins.json fetch_system_prompt.txt diff --git a/VERSION b/VERSION index ccc99d0..5a5ee51 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.12.3 +2.12.3 \ No newline at end of file diff --git a/configs/pwd b/configs/pwd deleted file mode 100644 index 32e9c62..0000000 --- a/configs/pwd +++ /dev/null @@ -1 +0,0 @@ -admin123 \ No newline at end of file diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..57c9887 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,23 @@ +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 + } + ] +} diff --git a/package-lock.json b/package-lock.json index 8e81846..b3b8bfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "AIClient2API", + "name": "AIClient-2-API", "lockfileVersion": 3, "requires": true, "packages": { @@ -7,7 +7,7 @@ "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", "adm-zip": "^0.5.16", - "axios": "^1.10.0", + "axios": "^1.14.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.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-jest": { @@ -3725,9 +3725,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -3745,9 +3745,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -5695,10 +5695,13 @@ } }, "node_modules/proxy-from-env": { - "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" + "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" + } }, "node_modules/pure-rand": { "version": "6.1.0", diff --git a/src/converters/strategies/GrokConverter.js b/src/converters/strategies/GrokConverter.js index 38ebfb5..86ece0e 100644 --- a/src/converters/strategies/GrokConverter.js +++ b/src/converters/strategies/GrokConverter.js @@ -264,6 +264,8 @@ 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; } @@ -274,6 +276,8 @@ 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: @@ -1139,6 +1143,76 @@ 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流式响应块 */ diff --git a/src/plugins/model-router/index.js b/src/plugins/model-router/index.js new file mode 100644 index 0000000..9739219 --- /dev/null +++ b/src/plugins/model-router/index.js @@ -0,0 +1,287 @@ +/** + * 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
管理:model-router 管理面板', + 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; diff --git a/src/plugins/model-router/static/index.html b/src/plugins/model-router/static/index.html new file mode 100644 index 0000000..d38925d --- /dev/null +++ b/src/plugins/model-router/static/index.html @@ -0,0 +1,180 @@ + + + + + + Model Router - 模型别名路由管理 + + + +

🔄 Model Router — 模型别名路由

+ +
+

➕ 添加映射

+
+ + + + + +
+
+ +
+

📋 当前映射规则

+ + + + + + + +
别名目标描述操作
加载中...
+
+ +
+ + + + diff --git a/src/services/api-manager.js b/src/services/api-manager.js index 8cacc09..76d2880 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -6,6 +6,330 @@ 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": "", "seed": 123 }] } + * OpenAI 返回:{ "data": [{ "b64_json": "" }] } + */ +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 @@ -52,6 +376,14 @@ 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; diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js index b60995f..f3fa15a 100644 --- a/src/ui-modules/auth.js +++ b/src/ui-modules/auth.js @@ -115,31 +115,55 @@ function getExpiryTime() { /** * 读取token存储文件 + * 安全增强:读取失败时抛出异常而非返回空对象,防止数据覆盖 */ async function readTokenStore() { - 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); + if (existsSync(TOKEN_STORE_FILE)) { + const content = await fs.readFile(TOKEN_STORE_FILE, 'utf8'); + return JSON.parse(content); + } else { + // 如果文件不存在,创建一个默认的token store 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 { - await fs.writeFile(TOKEN_STORE_FILE, JSON.stringify(tokenStore, null, 2), 'utf8'); + 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; } catch (error) { logger.error('[Token Store] Failed to write token store file:', error); + // 清理可能残留的临时文件 + try { await fs.unlink(TOKEN_STORE_FILE + '.tmp'); } catch {} + return false; } } @@ -413,7 +437,29 @@ export async function handleLoginRequest(req, res) { return true; } -// 定时清理过期token -setInterval(cleanupExpiredTokens, 5 * 60 * 1000); // 每5分钟清理一次 +// 定时清理过期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分钟清理一次 diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index d39b028..2ae46d4 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -114,7 +114,7 @@ export async function handleUpdateConfig(req, res, currentConfig) { // Update config values in memory(含类型校验) if (newConfig.REQUIRED_API_KEY !== undefined) { - if (typeof newConfig.REQUIRED_API_KEY === 'string') currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; + if (typeof newConfig.REQUIRED_API_KEY === 'string' && newConfig.REQUIRED_API_KEY !== '******') currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; } if (newConfig.HOST !== undefined) { if (typeof newConfig.HOST === 'string' && newConfig.HOST.length > 0) currentConfig.HOST = newConfig.HOST; diff --git a/src/utils/common.js b/src/utils/common.js index 3582277..d29d49e 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -191,6 +191,12 @@ 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 => {