@adversity/coding-tool-x 3.0.4 → 3.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +35 -0
- package/dist/web/assets/{icons-BlzwYoRU.js → icons-BxudHPiX.js} +1 -1
- package/dist/web/assets/index-D2VfwJBa.js +14 -0
- package/dist/web/assets/index-oXBzu0bd.css +41 -0
- package/dist/web/assets/{naive-ui-B1TP-0TP.js → naive-ui-DT-Uur8K.js} +1 -1
- package/dist/web/index.html +4 -4
- package/docs/model-redirection.md +251 -0
- package/package.json +1 -1
- package/src/server/api/channels.js +3 -0
- package/src/server/api/codex-channels.js +40 -0
- package/src/server/api/config-registry.js +341 -0
- package/src/server/api/gemini-channels.js +40 -0
- package/src/server/api/permissions.js +30 -15
- package/src/server/codex-proxy-server.js +126 -1
- package/src/server/gemini-proxy-server.js +61 -1
- package/src/server/index.js +3 -0
- package/src/server/proxy-server.js +98 -1
- package/src/server/services/channel-scheduler.js +3 -1
- package/src/server/services/channels.js +4 -1
- package/src/server/services/codex-channels.js +9 -3
- package/src/server/services/config-registry-service.js +762 -0
- package/src/server/services/config-sync-manager.js +456 -0
- package/src/server/services/config-templates-service.js +38 -3
- package/src/server/services/gemini-channels.js +7 -1
- package/src/server/services/model-detector.js +116 -23
- package/src/server/services/permission-templates-service.js +0 -31
- package/dist/web/assets/index-Bpjcdalh.js +0 -14
- package/dist/web/assets/index-CB782_71.css +0 -41
|
@@ -13,6 +13,8 @@ const { getChannelHealthStatus, resetChannelHealth } = require('../services/chan
|
|
|
13
13
|
const { broadcastSchedulerState } = require('../websocket-server');
|
|
14
14
|
const { isGeminiInstalled } = require('../services/gemini-config');
|
|
15
15
|
const { testChannelSpeed, testMultipleChannels, getLatencyLevel } = require('../services/speed-test');
|
|
16
|
+
const { clearGeminiRedirectCache } = require('../gemini-proxy-server');
|
|
17
|
+
const { fetchModelsFromProvider } = require('../services/model-detector');
|
|
16
18
|
|
|
17
19
|
module.exports = (config) => {
|
|
18
20
|
/**
|
|
@@ -41,6 +43,42 @@ module.exports = (config) => {
|
|
|
41
43
|
}
|
|
42
44
|
});
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* GET /api/gemini/channels/:id/models
|
|
48
|
+
* 获取渠道可用模型列表
|
|
49
|
+
*/
|
|
50
|
+
router.get('/:id/models', async (req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const { id } = req.params;
|
|
53
|
+
const channels = getChannels().channels || [];
|
|
54
|
+
const channel = channels.find(ch => ch.id === id);
|
|
55
|
+
|
|
56
|
+
if (!channel) {
|
|
57
|
+
return res.status(404).json({ error: '渠道不存在' });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Gemini 渠道尝试作为 OpenAI 兼容获取,失败则回退
|
|
61
|
+
const result = await fetchModelsFromProvider(channel, 'openai_compatible');
|
|
62
|
+
|
|
63
|
+
res.json({
|
|
64
|
+
channelId: id,
|
|
65
|
+
models: result.models,
|
|
66
|
+
supported: result.supported,
|
|
67
|
+
cached: result.cached,
|
|
68
|
+
fallbackUsed: result.fallbackUsed,
|
|
69
|
+
fetchedAt: result.lastChecked || new Date().toISOString(),
|
|
70
|
+
error: result.error,
|
|
71
|
+
errorHint: result.errorHint
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error('[Gemini Channels API] Error fetching models:', error);
|
|
75
|
+
res.status(500).json({
|
|
76
|
+
error: '获取模型列表失败',
|
|
77
|
+
channelId: req.params.id
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
44
82
|
/**
|
|
45
83
|
* POST /api/gemini/channels
|
|
46
84
|
* 创建新渠道
|
|
@@ -86,6 +124,8 @@ module.exports = (config) => {
|
|
|
86
124
|
const updates = req.body;
|
|
87
125
|
|
|
88
126
|
const channel = updateChannel(channelId, updates);
|
|
127
|
+
// 清除该渠道的模型重定向日志缓存,使下次请求时重新打印
|
|
128
|
+
clearGeminiRedirectCache(channelId);
|
|
89
129
|
res.json(channel);
|
|
90
130
|
broadcastSchedulerState('gemini', getSchedulerState('gemini'));
|
|
91
131
|
} catch (err) {
|
|
@@ -30,16 +30,16 @@ const permissionTemplatesService = require('../services/permission-templates-ser
|
|
|
30
30
|
const router = express.Router();
|
|
31
31
|
|
|
32
32
|
// Claude Code 设置文件路径
|
|
33
|
-
function getClaudeSettingsPath(projectPath
|
|
33
|
+
function getClaudeSettingsPath(projectPath) {
|
|
34
34
|
if (projectPath) {
|
|
35
|
-
return path.join(projectPath, '.claude',
|
|
35
|
+
return path.join(projectPath, '.claude', 'settings.json');
|
|
36
36
|
}
|
|
37
37
|
return path.join(os.homedir(), '.claude', 'settings.json');
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// 读取 Claude Code settings.json
|
|
41
|
-
function readClaudeSettings(projectPath
|
|
42
|
-
const settingsPath = getClaudeSettingsPath(projectPath
|
|
41
|
+
function readClaudeSettings(projectPath) {
|
|
42
|
+
const settingsPath = getClaudeSettingsPath(projectPath);
|
|
43
43
|
try {
|
|
44
44
|
if (fs.existsSync(settingsPath)) {
|
|
45
45
|
const content = fs.readFileSync(settingsPath, 'utf-8');
|
|
@@ -52,15 +52,29 @@ function readClaudeSettings(projectPath, isLocal = false) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// 保存 Claude Code settings.json
|
|
55
|
-
function saveClaudeSettings(projectPath, settings
|
|
56
|
-
const settingsPath = getClaudeSettingsPath(projectPath
|
|
55
|
+
function saveClaudeSettings(projectPath, settings) {
|
|
56
|
+
const settingsPath = getClaudeSettingsPath(projectPath);
|
|
57
57
|
const settingsDir = path.dirname(settingsPath);
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
try {
|
|
60
|
+
// 确保目录存在
|
|
61
|
+
if (!fs.existsSync(settingsDir)) {
|
|
62
|
+
fs.mkdirSync(settingsDir, { recursive: true });
|
|
63
|
+
}
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
// 写入文件
|
|
66
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
67
|
+
|
|
68
|
+
// 验证文件已创建
|
|
69
|
+
if (!fs.existsSync(settingsPath)) {
|
|
70
|
+
throw new Error('文件写入后验证失败,文件未被创建');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { success: true, path: settingsPath };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error('[Permissions API] Error saving Claude settings:', err);
|
|
76
|
+
throw new Error(`保存配置文件失败: ${err.message}`);
|
|
77
|
+
}
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
// 全局 all-allow 状态(内存中)
|
|
@@ -117,11 +131,11 @@ router.get('/', (req, res) => {
|
|
|
117
131
|
/**
|
|
118
132
|
* 保存项目的命令执行权限设置
|
|
119
133
|
* POST /api/permissions
|
|
120
|
-
* Body: { projectPath, settings: { allow, deny }
|
|
134
|
+
* Body: { projectPath, settings: { allow, deny } }
|
|
121
135
|
*/
|
|
122
136
|
router.post('/', (req, res) => {
|
|
123
137
|
try {
|
|
124
|
-
const { projectPath, settings: newPermissions
|
|
138
|
+
const { projectPath, settings: newPermissions } = req.body;
|
|
125
139
|
|
|
126
140
|
if (!projectPath) {
|
|
127
141
|
return res.status(400).json({
|
|
@@ -138,7 +152,7 @@ router.post('/', (req, res) => {
|
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
// 读取现有设置
|
|
141
|
-
const settings = readClaudeSettings(projectPath
|
|
155
|
+
const settings = readClaudeSettings(projectPath);
|
|
142
156
|
|
|
143
157
|
// 更新权限设置(使用 Claude Code 的标准格式)
|
|
144
158
|
settings.permissions = {
|
|
@@ -147,12 +161,13 @@ router.post('/', (req, res) => {
|
|
|
147
161
|
};
|
|
148
162
|
|
|
149
163
|
// 保存设置
|
|
150
|
-
saveClaudeSettings(projectPath, settings
|
|
164
|
+
const saveResult = saveClaudeSettings(projectPath, settings);
|
|
151
165
|
|
|
152
166
|
res.json({
|
|
153
167
|
success: true,
|
|
154
168
|
message: '权限设置已保存',
|
|
155
|
-
savedTo:
|
|
169
|
+
savedTo: '.claude/settings.json',
|
|
170
|
+
fullPath: saveResult.path
|
|
156
171
|
});
|
|
157
172
|
} catch (err) {
|
|
158
173
|
console.error('[Permissions API] Save permissions error:', err);
|
|
@@ -20,6 +20,10 @@ let currentPort = null;
|
|
|
20
20
|
// 用于存储每个请求的元数据
|
|
21
21
|
const requestMetadata = new Map();
|
|
22
22
|
|
|
23
|
+
// 用于缓存已打印过的模型重定向规则,避免重复打印
|
|
24
|
+
// 格式: { channelId: { "originalModel": "redirectedModel", ... } }
|
|
25
|
+
const printedRedirectCache = new Map();
|
|
26
|
+
|
|
23
27
|
// OpenAI 模型定价(每百万 tokens 的价格,单位:美元)
|
|
24
28
|
// Claude 模型使用 config/model-pricing.js 中的集中定价
|
|
25
29
|
const PRICING = {
|
|
@@ -40,6 +44,64 @@ const PRICING = {
|
|
|
40
44
|
const CODEX_BASE_PRICING = DEFAULT_CONFIG.pricing.codex;
|
|
41
45
|
const ONE_MILLION = 1000000;
|
|
42
46
|
|
|
47
|
+
/**
|
|
48
|
+
* 检测模型层级
|
|
49
|
+
* @param {string} modelName - 模型名称
|
|
50
|
+
* @returns {string|null} 模型层级 (opus/sonnet/haiku) 或 null
|
|
51
|
+
*/
|
|
52
|
+
function detectModelTier(modelName) {
|
|
53
|
+
if (!modelName) return null;
|
|
54
|
+
const lower = modelName.toLowerCase();
|
|
55
|
+
if (lower.includes('opus')) return 'opus';
|
|
56
|
+
if (lower.includes('sonnet')) return 'sonnet';
|
|
57
|
+
if (lower.includes('haiku')) return 'haiku';
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 应用模型重定向
|
|
63
|
+
* @param {string} originalModel - 原始模型名称
|
|
64
|
+
* @param {object} channel - 渠道对象,包含 modelConfig 和 modelRedirects
|
|
65
|
+
* @returns {string} 重定向后的模型名称
|
|
66
|
+
*/
|
|
67
|
+
function redirectModel(originalModel, channel) {
|
|
68
|
+
if (!originalModel) return originalModel;
|
|
69
|
+
|
|
70
|
+
// 优先使用新的 modelRedirects 数组格式
|
|
71
|
+
const modelRedirects = channel?.modelRedirects;
|
|
72
|
+
if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
|
|
73
|
+
for (const rule of modelRedirects) {
|
|
74
|
+
if (rule.from && rule.to && rule.from === originalModel) {
|
|
75
|
+
return rule.to;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 向后兼容:使用旧的 modelConfig 格式
|
|
81
|
+
const modelConfig = channel?.modelConfig;
|
|
82
|
+
if (!modelConfig) return originalModel;
|
|
83
|
+
|
|
84
|
+
const tier = detectModelTier(originalModel);
|
|
85
|
+
|
|
86
|
+
// 优先级:层级特定配置 > 通用模型覆盖
|
|
87
|
+
if (tier === 'opus' && modelConfig.opusModel) {
|
|
88
|
+
return modelConfig.opusModel;
|
|
89
|
+
}
|
|
90
|
+
if (tier === 'sonnet' && modelConfig.sonnetModel) {
|
|
91
|
+
return modelConfig.sonnetModel;
|
|
92
|
+
}
|
|
93
|
+
if (tier === 'haiku' && modelConfig.haikuModel) {
|
|
94
|
+
return modelConfig.haikuModel;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 回退到通用模型覆盖
|
|
98
|
+
if (modelConfig.model) {
|
|
99
|
+
return modelConfig.model;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return originalModel;
|
|
103
|
+
}
|
|
104
|
+
|
|
43
105
|
/**
|
|
44
106
|
* 解析 Codex 代理目标 URL
|
|
45
107
|
*
|
|
@@ -144,6 +206,18 @@ function calculateCost(model, tokens) {
|
|
|
144
206
|
);
|
|
145
207
|
}
|
|
146
208
|
|
|
209
|
+
const jsonBodyParser = express.json({
|
|
210
|
+
limit: '100mb',
|
|
211
|
+
verify: (req, res, buf) => {
|
|
212
|
+
req.rawBody = Buffer.from(buf);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
function shouldParseJson(req) {
|
|
217
|
+
const contentType = req.headers['content-type'] || '';
|
|
218
|
+
return req.method === 'POST' && contentType.includes('application/json');
|
|
219
|
+
}
|
|
220
|
+
|
|
147
221
|
// 启动 Codex 代理服务器
|
|
148
222
|
async function startCodexProxyServer(options = {}) {
|
|
149
223
|
// options.preserveStartTime - 是否保留现有的启动时间(用于切换渠道时)
|
|
@@ -160,6 +234,14 @@ async function startCodexProxyServer(options = {}) {
|
|
|
160
234
|
currentPort = port;
|
|
161
235
|
|
|
162
236
|
proxyApp = express();
|
|
237
|
+
|
|
238
|
+
proxyApp.use((req, res, next) => {
|
|
239
|
+
if (shouldParseJson(req)) {
|
|
240
|
+
return jsonBodyParser(req, res, next);
|
|
241
|
+
}
|
|
242
|
+
return next();
|
|
243
|
+
});
|
|
244
|
+
|
|
163
245
|
const proxy = httpProxy.createProxyServer({});
|
|
164
246
|
|
|
165
247
|
proxy.on('proxyReq', (proxyReq, req) => {
|
|
@@ -180,6 +262,15 @@ async function startCodexProxyServer(options = {}) {
|
|
|
180
262
|
if (!proxyReq.getHeader('content-type')) {
|
|
181
263
|
proxyReq.setHeader('content-type', 'application/json');
|
|
182
264
|
}
|
|
265
|
+
|
|
266
|
+
if (shouldParseJson(req) && (req.rawBody || req.body)) {
|
|
267
|
+
const bodyBuffer = req.rawBody
|
|
268
|
+
? Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody)
|
|
269
|
+
: Buffer.from(JSON.stringify(req.body));
|
|
270
|
+
proxyReq.setHeader('Content-Length', bodyBuffer.length);
|
|
271
|
+
proxyReq.write(bodyBuffer);
|
|
272
|
+
proxyReq.end();
|
|
273
|
+
}
|
|
183
274
|
});
|
|
184
275
|
|
|
185
276
|
proxyApp.use(async (req, res) => {
|
|
@@ -187,6 +278,26 @@ async function startCodexProxyServer(options = {}) {
|
|
|
187
278
|
const channel = await allocateChannel({ source: 'codex', enableSessionBinding: false });
|
|
188
279
|
req.selectedChannel = channel;
|
|
189
280
|
|
|
281
|
+
// 应用模型重定向(当 proxy 开启时)
|
|
282
|
+
if (req.body && req.body.model) {
|
|
283
|
+
const originalModel = req.body.model;
|
|
284
|
+
const redirectedModel = redirectModel(originalModel, channel);
|
|
285
|
+
|
|
286
|
+
if (redirectedModel !== originalModel) {
|
|
287
|
+
req.body.model = redirectedModel;
|
|
288
|
+
// 更新 rawBody 以匹配修改后的 body
|
|
289
|
+
req.rawBody = Buffer.from(JSON.stringify(req.body));
|
|
290
|
+
|
|
291
|
+
// 只在重定向规则变化时打印日志(避免每次请求都打印)
|
|
292
|
+
const cachedRedirects = printedRedirectCache.get(channel.id) || {};
|
|
293
|
+
if (cachedRedirects[originalModel] !== redirectedModel) {
|
|
294
|
+
cachedRedirects[originalModel] = redirectedModel;
|
|
295
|
+
printedRedirectCache.set(channel.id, cachedRedirects);
|
|
296
|
+
console.log(`[Codex Model Redirect] ${originalModel} → ${redirectedModel} (channel: ${channel.name})`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
190
301
|
const release = (() => {
|
|
191
302
|
let released = false;
|
|
192
303
|
return () => {
|
|
@@ -548,8 +659,22 @@ function getCodexProxyStatus() {
|
|
|
548
659
|
};
|
|
549
660
|
}
|
|
550
661
|
|
|
662
|
+
/**
|
|
663
|
+
* 清除指定渠道的模型重定向日志缓存
|
|
664
|
+
* 用于在渠道配置更新后触发重新打印日志
|
|
665
|
+
* @param {string} channelId - 渠道 ID
|
|
666
|
+
*/
|
|
667
|
+
function clearCodexRedirectCache(channelId) {
|
|
668
|
+
if (channelId) {
|
|
669
|
+
printedRedirectCache.delete(channelId);
|
|
670
|
+
} else {
|
|
671
|
+
printedRedirectCache.clear();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
551
675
|
module.exports = {
|
|
552
676
|
startCodexProxyServer,
|
|
553
677
|
stopCodexProxyServer,
|
|
554
|
-
getCodexProxyStatus
|
|
678
|
+
getCodexProxyStatus,
|
|
679
|
+
clearCodexRedirectCache
|
|
555
680
|
};
|
|
@@ -18,6 +18,10 @@ let currentPort = null;
|
|
|
18
18
|
// 用于存储每个请求的元数据
|
|
19
19
|
const requestMetadata = new Map();
|
|
20
20
|
|
|
21
|
+
// 用于缓存已打印过的模型重定向规则,避免重复打印
|
|
22
|
+
// 格式: { channelId: { "originalModel": "redirectedModel", ... } }
|
|
23
|
+
const printedGeminiRedirectCache = new Map();
|
|
24
|
+
|
|
21
25
|
// Gemini 模型定价(每百万 tokens 的价格,单位:美元)
|
|
22
26
|
const PRICING = {
|
|
23
27
|
'gemini-2.5-pro': { input: 1.25, output: 5 },
|
|
@@ -47,6 +51,27 @@ function resolveGeminiTarget(baseUrl = '', requestPath = '') {
|
|
|
47
51
|
return target;
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 应用模型重定向(精确匹配)
|
|
56
|
+
* @param {string} originalModel - 原始模型名称
|
|
57
|
+
* @param {object} channel - 渠道对象,包含 modelRedirects 数组
|
|
58
|
+
* @returns {string} 重定向后的模型名称
|
|
59
|
+
*/
|
|
60
|
+
function redirectModel(originalModel, channel) {
|
|
61
|
+
if (!originalModel) return originalModel;
|
|
62
|
+
|
|
63
|
+
const modelRedirects = channel?.modelRedirects;
|
|
64
|
+
if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
|
|
65
|
+
for (const rule of modelRedirects) {
|
|
66
|
+
if (rule.from && rule.to && rule.from === originalModel) {
|
|
67
|
+
return rule.to;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return originalModel;
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
/**
|
|
51
76
|
* 计算请求成本
|
|
52
77
|
*/
|
|
@@ -153,6 +178,27 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
153
178
|
|
|
154
179
|
broadcastSchedulerState('gemini', getSchedulerState('gemini'));
|
|
155
180
|
|
|
181
|
+
// 从 URL 中提取模型名称并应用重定向
|
|
182
|
+
// URL 格式: /models/gemini-2.5-pro:generateContent 或 /v1/models/gemini-2.5-pro:generateContent
|
|
183
|
+
const urlMatch = req.url.match(/\/models\/([\w.-]+)(:[^?]*)?/);
|
|
184
|
+
if (urlMatch) {
|
|
185
|
+
const originalModel = urlMatch[1];
|
|
186
|
+
const redirectedModel = redirectModel(originalModel, channel);
|
|
187
|
+
|
|
188
|
+
if (redirectedModel !== originalModel) {
|
|
189
|
+
// 替换 URL 中的模型名称
|
|
190
|
+
req.url = req.url.replace(`/models/${originalModel}`, `/models/${redirectedModel}`);
|
|
191
|
+
|
|
192
|
+
// 只在重定向规则变化时打印日志(避免每次请求都打印)
|
|
193
|
+
const cachedRedirects = printedGeminiRedirectCache.get(channel.id) || {};
|
|
194
|
+
if (cachedRedirects[originalModel] !== redirectedModel) {
|
|
195
|
+
cachedRedirects[originalModel] = redirectedModel;
|
|
196
|
+
printedGeminiRedirectCache.set(channel.id, cachedRedirects);
|
|
197
|
+
console.log(`[Gemini Model Redirect] ${originalModel} → ${redirectedModel} (channel: ${channel.name})`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
156
202
|
const target = resolveGeminiTarget(channel.baseUrl, req.url);
|
|
157
203
|
|
|
158
204
|
proxy.web(req, res, {
|
|
@@ -511,8 +557,22 @@ function getGeminiProxyStatus() {
|
|
|
511
557
|
};
|
|
512
558
|
}
|
|
513
559
|
|
|
560
|
+
/**
|
|
561
|
+
* 清除指定渠道的模型重定向日志缓存
|
|
562
|
+
* 用于在渠道配置更新后触发重新打印日志
|
|
563
|
+
* @param {string} channelId - 渠道 ID
|
|
564
|
+
*/
|
|
565
|
+
function clearGeminiRedirectCache(channelId) {
|
|
566
|
+
if (channelId) {
|
|
567
|
+
printedGeminiRedirectCache.delete(channelId);
|
|
568
|
+
} else {
|
|
569
|
+
printedGeminiRedirectCache.clear();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
514
573
|
module.exports = {
|
|
515
574
|
startGeminiProxyServer,
|
|
516
575
|
stopGeminiProxyServer,
|
|
517
|
-
getGeminiProxyStatus
|
|
576
|
+
getGeminiProxyStatus,
|
|
577
|
+
clearGeminiRedirectCache
|
|
518
578
|
};
|
package/src/server/index.js
CHANGED
|
@@ -154,6 +154,9 @@ async function startServer(port) {
|
|
|
154
154
|
// 配置同步 API
|
|
155
155
|
app.use('/api/config-sync', require('./api/config-sync'));
|
|
156
156
|
|
|
157
|
+
// 配置注册表 API (集中管理 skills/commands/agents/rules 的启用/禁用)
|
|
158
|
+
app.use('/api/config-registry', require('./api/config-registry'));
|
|
159
|
+
|
|
157
160
|
// 健康检查 API
|
|
158
161
|
app.use('/api/health-check', require('./api/health-check')(config));
|
|
159
162
|
|
|
@@ -21,9 +21,71 @@ let currentPort = null;
|
|
|
21
21
|
// 用于存储每个请求的元数据(用于 WebSocket 日志)
|
|
22
22
|
const requestMetadata = new Map();
|
|
23
23
|
|
|
24
|
+
// 用于缓存已打印过的模型重定向规则,避免重复打印
|
|
25
|
+
// 格式: { channelId: { "originalModel": "redirectedModel", ... } }
|
|
26
|
+
const printedRedirectCache = new Map();
|
|
27
|
+
|
|
24
28
|
const CLAUDE_BASE_PRICING = DEFAULT_CONFIG.pricing.claude;
|
|
25
29
|
const ONE_MILLION = 1000000;
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* 检测模型层级
|
|
33
|
+
* @param {string} modelName - 模型名称
|
|
34
|
+
* @returns {string|null} 模型层级 (opus/sonnet/haiku) 或 null
|
|
35
|
+
*/
|
|
36
|
+
function detectModelTier(modelName) {
|
|
37
|
+
if (!modelName) return null;
|
|
38
|
+
const lower = modelName.toLowerCase();
|
|
39
|
+
if (lower.includes('opus')) return 'opus';
|
|
40
|
+
if (lower.includes('sonnet')) return 'sonnet';
|
|
41
|
+
if (lower.includes('haiku')) return 'haiku';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 应用模型重定向
|
|
47
|
+
* @param {string} originalModel - 原始模型名称
|
|
48
|
+
* @param {object} channel - 渠道对象,包含 modelConfig 和 modelRedirects
|
|
49
|
+
* @returns {string} 重定向后的模型名称
|
|
50
|
+
*/
|
|
51
|
+
function redirectModel(originalModel, channel) {
|
|
52
|
+
if (!originalModel) return originalModel;
|
|
53
|
+
|
|
54
|
+
// 优先使用新的 modelRedirects 数组格式
|
|
55
|
+
const modelRedirects = channel?.modelRedirects;
|
|
56
|
+
if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
|
|
57
|
+
for (const rule of modelRedirects) {
|
|
58
|
+
if (rule.from && rule.to && rule.from === originalModel) {
|
|
59
|
+
return rule.to;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 向后兼容:使用旧的 modelConfig 格式
|
|
65
|
+
const modelConfig = channel?.modelConfig;
|
|
66
|
+
if (!modelConfig) return originalModel;
|
|
67
|
+
|
|
68
|
+
const tier = detectModelTier(originalModel);
|
|
69
|
+
|
|
70
|
+
// 优先级:层级特定配置 > 通用模型覆盖
|
|
71
|
+
if (tier === 'opus' && modelConfig.opusModel) {
|
|
72
|
+
return modelConfig.opusModel;
|
|
73
|
+
}
|
|
74
|
+
if (tier === 'sonnet' && modelConfig.sonnetModel) {
|
|
75
|
+
return modelConfig.sonnetModel;
|
|
76
|
+
}
|
|
77
|
+
if (tier === 'haiku' && modelConfig.haikuModel) {
|
|
78
|
+
return modelConfig.haikuModel;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 回退到通用模型覆盖
|
|
82
|
+
if (modelConfig.model) {
|
|
83
|
+
return modelConfig.model;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return originalModel;
|
|
87
|
+
}
|
|
88
|
+
|
|
27
89
|
/**
|
|
28
90
|
* 计算请求成本
|
|
29
91
|
* @param {string} model - 模型名称
|
|
@@ -157,6 +219,27 @@ async function startProxyServer(options = {}) {
|
|
|
157
219
|
|
|
158
220
|
req.selectedChannel = channel;
|
|
159
221
|
req.sessionId = sessionId || null;
|
|
222
|
+
|
|
223
|
+
// 应用模型重定向(当 proxy 开启时)
|
|
224
|
+
if (req.body && req.body.model) {
|
|
225
|
+
const originalModel = req.body.model;
|
|
226
|
+
const redirectedModel = redirectModel(originalModel, channel);
|
|
227
|
+
|
|
228
|
+
if (redirectedModel !== originalModel) {
|
|
229
|
+
req.body.model = redirectedModel;
|
|
230
|
+
// 更新 rawBody 以匹配修改后的 body
|
|
231
|
+
req.rawBody = Buffer.from(JSON.stringify(req.body));
|
|
232
|
+
|
|
233
|
+
// 只在重定向规则变化时打印日志(避免每次请求都打印)
|
|
234
|
+
const cachedRedirects = printedRedirectCache.get(channel.id) || {};
|
|
235
|
+
if (cachedRedirects[originalModel] !== redirectedModel) {
|
|
236
|
+
cachedRedirects[originalModel] = redirectedModel;
|
|
237
|
+
printedRedirectCache.set(channel.id, cachedRedirects);
|
|
238
|
+
console.log(`[Model Redirect] ${originalModel} → ${redirectedModel} (channel: ${channel.name})`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
160
243
|
let released = false;
|
|
161
244
|
|
|
162
245
|
const release = () => {
|
|
@@ -454,8 +537,22 @@ function getProxyStatus() {
|
|
|
454
537
|
};
|
|
455
538
|
}
|
|
456
539
|
|
|
540
|
+
/**
|
|
541
|
+
* 清除指定渠道的模型重定向日志缓存
|
|
542
|
+
* 用于在渠道配置更新后触发重新打印日志
|
|
543
|
+
* @param {string} channelId - 渠道 ID
|
|
544
|
+
*/
|
|
545
|
+
function clearRedirectCache(channelId) {
|
|
546
|
+
if (channelId) {
|
|
547
|
+
printedRedirectCache.delete(channelId);
|
|
548
|
+
} else {
|
|
549
|
+
printedRedirectCache.clear();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
457
553
|
module.exports = {
|
|
458
554
|
startProxyServer,
|
|
459
555
|
stopProxyServer,
|
|
460
|
-
getProxyStatus
|
|
556
|
+
getProxyStatus,
|
|
557
|
+
clearRedirectCache
|
|
461
558
|
};
|
|
@@ -74,7 +74,9 @@ function refreshChannels(source = 'claude') {
|
|
|
74
74
|
baseUrl: ch.baseUrl,
|
|
75
75
|
apiKey: ch.apiKey,
|
|
76
76
|
weight: Math.max(1, Number(ch.weight) || 1),
|
|
77
|
-
maxConcurrency: ch.maxConcurrency ?? null
|
|
77
|
+
maxConcurrency: ch.maxConcurrency ?? null,
|
|
78
|
+
modelConfig: ch.modelConfig || null,
|
|
79
|
+
modelRedirects: ch.modelRedirects || []
|
|
78
80
|
}));
|
|
79
81
|
|
|
80
82
|
state.channels.forEach(ch => {
|
|
@@ -187,6 +187,7 @@ function createChannel(name, baseUrl, apiKey, websiteUrl, extraConfig = {}) {
|
|
|
187
187
|
maxConcurrency: extraConfig.maxConcurrency,
|
|
188
188
|
presetId: extraConfig.presetId || 'official',
|
|
189
189
|
modelConfig: extraConfig.modelConfig || null,
|
|
190
|
+
modelRedirects: extraConfig.modelRedirects || [],
|
|
190
191
|
proxyUrl: extraConfig.proxyUrl || '',
|
|
191
192
|
speedTestModel: extraConfig.speedTestModel || null
|
|
192
193
|
});
|
|
@@ -215,8 +216,10 @@ function updateChannel(id, updates) {
|
|
|
215
216
|
enabled: merged.enabled,
|
|
216
217
|
presetId: merged.presetId,
|
|
217
218
|
modelConfig: merged.modelConfig,
|
|
219
|
+
modelRedirects: merged.modelRedirects || [],
|
|
218
220
|
proxyUrl: merged.proxyUrl,
|
|
219
|
-
speedTestModel: merged.speedTestModel
|
|
221
|
+
speedTestModel: merged.speedTestModel,
|
|
222
|
+
updatedAt: Date.now()
|
|
220
223
|
});
|
|
221
224
|
|
|
222
225
|
// Get proxy status
|
|
@@ -47,7 +47,9 @@ function loadChannels() {
|
|
|
47
47
|
...ch,
|
|
48
48
|
enabled: ch.enabled !== false, // 默认启用
|
|
49
49
|
weight: ch.weight || 1,
|
|
50
|
-
maxConcurrency: ch.maxConcurrency || null
|
|
50
|
+
maxConcurrency: ch.maxConcurrency || null,
|
|
51
|
+
modelRedirects: ch.modelRedirects || [],
|
|
52
|
+
speedTestModel: ch.speedTestModel || null
|
|
51
53
|
}));
|
|
52
54
|
}
|
|
53
55
|
return data;
|
|
@@ -175,6 +177,8 @@ function createChannel(name, providerKey, baseUrl, apiKey, wireApi = 'responses'
|
|
|
175
177
|
enabled: extraConfig.enabled !== false, // 默认启用
|
|
176
178
|
weight: extraConfig.weight || 1,
|
|
177
179
|
maxConcurrency: extraConfig.maxConcurrency || null,
|
|
180
|
+
modelRedirects: extraConfig.modelRedirects || [],
|
|
181
|
+
speedTestModel: extraConfig.speedTestModel || null,
|
|
178
182
|
createdAt: Date.now(),
|
|
179
183
|
updatedAt: Date.now()
|
|
180
184
|
};
|
|
@@ -217,11 +221,13 @@ function updateChannel(channelId, updates) {
|
|
|
217
221
|
}
|
|
218
222
|
}
|
|
219
223
|
|
|
224
|
+
const merged = { ...oldChannel, ...updates };
|
|
220
225
|
const newChannel = {
|
|
221
|
-
...
|
|
222
|
-
...updates,
|
|
226
|
+
...merged,
|
|
223
227
|
id: channelId, // 保持 ID 不变
|
|
224
228
|
createdAt: oldChannel.createdAt, // 保持创建时间
|
|
229
|
+
modelRedirects: merged.modelRedirects || [],
|
|
230
|
+
speedTestModel: merged.speedTestModel !== undefined ? merged.speedTestModel : (oldChannel.speedTestModel || null),
|
|
225
231
|
updatedAt: Date.now()
|
|
226
232
|
};
|
|
227
233
|
|