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/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 @@
+
+
+
| 别名 | 目标 | 描述 | 操作 |
|---|---|---|---|
| 加载中... | |||