coding-tool-x 3.3.9 → 3.4.1
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/dist/web/assets/{Analytics-D6LzK9hk.js → Analytics-CbGxotgz.js} +4 -4
- package/dist/web/assets/Analytics-RNn1BUbG.css +1 -0
- package/dist/web/assets/{ConfigTemplates-BUDYuxRi.js → ConfigTemplates-oP6nrFEb.js} +1 -1
- package/dist/web/assets/{Home-D7KX7iF8.js → Home-DMntmEvh.js} +1 -1
- package/dist/web/assets/{PluginManager-DTgQ--vB.js → PluginManager-BUC_c7nH.js} +1 -1
- package/dist/web/assets/{ProjectList-DMCiGmCT.js → ProjectList-CW8J49n7.js} +1 -1
- package/dist/web/assets/{SessionList-CRBsdVRe.js → SessionList-7lYnF92v.js} +1 -1
- package/dist/web/assets/{SkillManager-DMwx2Q4k.js → SkillManager-Cs08216i.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-DapB4ljL.js → WorkspaceManager-CY-oGtyB.js} +1 -1
- package/dist/web/assets/{index-D_5dRFOL.css → index-5qy5NMIP.css} +1 -1
- package/dist/web/assets/index-ClCqKpvX.js +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +6 -2
- package/src/server/api/statistics.js +4 -4
- package/src/server/api/workspaces.js +1 -3
- package/src/server/codex-proxy-server.js +4 -92
- package/src/server/gemini-proxy-server.js +5 -28
- package/src/server/opencode-proxy-server.js +3 -93
- package/src/server/proxy-server.js +2 -57
- package/src/server/services/base/base-channel-service.js +247 -0
- package/src/server/services/base/proxy-utils.js +152 -0
- package/src/server/services/channel-health.js +30 -19
- package/src/server/services/channels.js +125 -293
- package/src/server/services/codex-channels.js +149 -517
- package/src/server/services/codex-env-manager.js +100 -67
- package/src/server/services/gemini-channels.js +2 -7
- package/src/server/services/oauth-credentials-service.js +12 -2
- package/src/server/services/opencode-channels.js +7 -9
- package/src/server/services/repo-scanner-base.js +1 -0
- package/src/server/services/statistics-service.js +5 -1
- package/src/server/services/workspace-service.js +100 -155
- package/dist/web/assets/Analytics-DuYvId7u.css +0 -1
- package/dist/web/assets/index-CL-qpoJ_.js +0 -2
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proxy-utils.js - 代理服务器共享工具函数
|
|
3
|
+
*
|
|
4
|
+
* 从四个 proxy-server 中提取的公共逻辑,消除重复代码。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 检测模型层级(Claude 系列)
|
|
9
|
+
* @param {string} modelName
|
|
10
|
+
* @returns {'opus'|'sonnet'|'haiku'|null}
|
|
11
|
+
*/
|
|
12
|
+
function detectModelTier(modelName) {
|
|
13
|
+
if (!modelName) return null;
|
|
14
|
+
const lower = modelName.toLowerCase();
|
|
15
|
+
if (lower.includes('opus')) return 'opus';
|
|
16
|
+
if (lower.includes('sonnet')) return 'sonnet';
|
|
17
|
+
if (lower.includes('haiku')) return 'haiku';
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 应用模型重定向
|
|
23
|
+
*
|
|
24
|
+
* 支持两种格式:
|
|
25
|
+
* 1. modelRedirects 数组(新格式,精确匹配)
|
|
26
|
+
* 2. modelConfig 对象(旧格式,层级匹配 + 通用覆盖)
|
|
27
|
+
*
|
|
28
|
+
* @param {string} originalModel
|
|
29
|
+
* @param {object} channel - 渠道对象
|
|
30
|
+
* @param {object} [options]
|
|
31
|
+
* @param {boolean} [options.useTierFallback=true] - 是否启用层级回退(Gemini 不用)
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function redirectModel(originalModel, channel, options = {}) {
|
|
35
|
+
if (!originalModel) return originalModel;
|
|
36
|
+
|
|
37
|
+
// 优先使用 modelRedirects 数组格式(精确匹配)
|
|
38
|
+
const modelRedirects = channel?.modelRedirects;
|
|
39
|
+
if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
|
|
40
|
+
for (const rule of modelRedirects) {
|
|
41
|
+
if (rule.from && rule.to && rule.from === originalModel) {
|
|
42
|
+
return rule.to;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 如果不启用层级回退,到此为止
|
|
48
|
+
const useTierFallback = options.useTierFallback !== false;
|
|
49
|
+
if (!useTierFallback) {
|
|
50
|
+
return originalModel;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 向后兼容:使用旧的 modelConfig 格式
|
|
54
|
+
const modelConfig = channel?.modelConfig;
|
|
55
|
+
if (!modelConfig) return originalModel;
|
|
56
|
+
|
|
57
|
+
const tier = detectModelTier(originalModel);
|
|
58
|
+
|
|
59
|
+
if (tier === 'opus' && modelConfig.opusModel) return modelConfig.opusModel;
|
|
60
|
+
if (tier === 'sonnet' && modelConfig.sonnetModel) return modelConfig.sonnetModel;
|
|
61
|
+
if (tier === 'haiku' && modelConfig.haikuModel) return modelConfig.haikuModel;
|
|
62
|
+
|
|
63
|
+
// 回退到通用模型覆盖
|
|
64
|
+
if (modelConfig.model) return modelConfig.model;
|
|
65
|
+
|
|
66
|
+
return originalModel;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 解析代理目标 URL,避免 /v1/v1 重复
|
|
71
|
+
*
|
|
72
|
+
* 当 baseUrl 以 /v1 结尾且请求路径也以 /v1 开头时,
|
|
73
|
+
* 去掉 baseUrl 的 /v1 后缀,因为 http-proxy 会自动拼接 req.url。
|
|
74
|
+
*
|
|
75
|
+
* @param {string} baseUrl - 渠道配置的 base_url
|
|
76
|
+
* @param {string} requestPath - 请求路径
|
|
77
|
+
* @returns {string} 传给 http-proxy 的 target
|
|
78
|
+
*/
|
|
79
|
+
function resolveTargetUrl(baseUrl, requestPath) {
|
|
80
|
+
let target = baseUrl || '';
|
|
81
|
+
if (target.endsWith('/')) {
|
|
82
|
+
target = target.slice(0, -1);
|
|
83
|
+
}
|
|
84
|
+
if (target.endsWith('/v1') && requestPath.startsWith('/v1')) {
|
|
85
|
+
target = target.slice(0, -3);
|
|
86
|
+
}
|
|
87
|
+
return target;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 规范化网关来源类型
|
|
92
|
+
* @param {string} value
|
|
93
|
+
* @param {string} [fallback='claude']
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
function normalizeGatewaySourceType(value, fallback = 'claude') {
|
|
97
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
98
|
+
if (normalized === 'claude') return 'claude';
|
|
99
|
+
if (normalized === 'codex') return 'codex';
|
|
100
|
+
if (normalized === 'gemini') return 'gemini';
|
|
101
|
+
if (normalized === 'openai_compatible') return 'openai_compatible';
|
|
102
|
+
return fallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 规范化数值字段
|
|
107
|
+
* @param {*} value
|
|
108
|
+
* @param {number} defaultValue
|
|
109
|
+
* @param {number|null} [max=null]
|
|
110
|
+
* @returns {number}
|
|
111
|
+
*/
|
|
112
|
+
function normalizeNumber(value, defaultValue, max = null) {
|
|
113
|
+
const num = Number(value);
|
|
114
|
+
if (!Number.isFinite(num) || num <= 0) {
|
|
115
|
+
return defaultValue;
|
|
116
|
+
}
|
|
117
|
+
if (max !== null && num > max) {
|
|
118
|
+
return max;
|
|
119
|
+
}
|
|
120
|
+
return num;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 记录模型重定向日志(避免重复打印)
|
|
125
|
+
* @param {Map} cache - 重定向缓存 Map
|
|
126
|
+
* @param {string} channelId
|
|
127
|
+
* @param {string} originalModel
|
|
128
|
+
* @param {string} redirectedModel
|
|
129
|
+
* @param {string} channelName
|
|
130
|
+
* @param {string} source - 平台标识
|
|
131
|
+
*/
|
|
132
|
+
function logModelRedirect(cache, channelId, originalModel, redirectedModel, channelName, source) {
|
|
133
|
+
if (originalModel === redirectedModel) return;
|
|
134
|
+
|
|
135
|
+
if (!cache.has(channelId)) {
|
|
136
|
+
cache.set(channelId, {});
|
|
137
|
+
}
|
|
138
|
+
const channelCache = cache.get(channelId);
|
|
139
|
+
if (channelCache[originalModel] === redirectedModel) return;
|
|
140
|
+
|
|
141
|
+
channelCache[originalModel] = redirectedModel;
|
|
142
|
+
console.log(`[${source}-proxy] Model redirect: ${originalModel} → ${redirectedModel} (channel: ${channelName})`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
detectModelTier,
|
|
147
|
+
redirectModel,
|
|
148
|
+
resolveTargetUrl,
|
|
149
|
+
normalizeGatewaySourceType,
|
|
150
|
+
normalizeNumber,
|
|
151
|
+
logModelRedirect,
|
|
152
|
+
};
|
|
@@ -51,6 +51,24 @@ function initChannelHealth(channelId, source = 'claude') {
|
|
|
51
51
|
return channelHealth.get(key);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function transitionFrozenChannelIfExpired(channelId, source = 'claude') {
|
|
55
|
+
const health = initChannelHealth(channelId, source);
|
|
56
|
+
if (health.status !== 'frozen') {
|
|
57
|
+
return health;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
if (now < health.freezeUntil) {
|
|
62
|
+
return health;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
health.status = 'checking';
|
|
66
|
+
health.consecutiveSuccesses = 0;
|
|
67
|
+
health.freezeUntil = 0;
|
|
68
|
+
console.log(`[ChannelHealth] Channel ${channelId} freeze expired, entering checking mode`);
|
|
69
|
+
return health;
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
/**
|
|
55
73
|
* 记录成功请求
|
|
56
74
|
*/
|
|
@@ -119,21 +137,13 @@ function isChannelAvailable(channelId, source = 'claude') {
|
|
|
119
137
|
const health = channelHealth.get(key);
|
|
120
138
|
if (!health) return true;
|
|
121
139
|
|
|
122
|
-
const
|
|
140
|
+
const currentHealth = transitionFrozenChannelIfExpired(channelId, source);
|
|
123
141
|
|
|
124
|
-
switch (
|
|
142
|
+
switch (currentHealth.status) {
|
|
125
143
|
case 'healthy':
|
|
126
144
|
return true;
|
|
127
145
|
|
|
128
146
|
case 'frozen':
|
|
129
|
-
// 检查冻结时间是否到期
|
|
130
|
-
if (now >= health.freezeUntil) {
|
|
131
|
-
// 进入检测状态
|
|
132
|
-
health.status = 'checking';
|
|
133
|
-
health.consecutiveSuccesses = 0;
|
|
134
|
-
console.log(`[ChannelHealth] Channel ${channelId} freeze expired, entering checking mode`);
|
|
135
|
-
return true; // 允许一个请求用于健康检测
|
|
136
|
-
}
|
|
137
147
|
return false;
|
|
138
148
|
|
|
139
149
|
case 'checking':
|
|
@@ -172,8 +182,9 @@ function getChannelHealthStatus(channelId, source = 'claude') {
|
|
|
172
182
|
};
|
|
173
183
|
}
|
|
174
184
|
|
|
185
|
+
const currentHealth = transitionFrozenChannelIfExpired(channelId, source);
|
|
175
186
|
const now = Date.now();
|
|
176
|
-
const freezeRemaining = Math.max(0,
|
|
187
|
+
const freezeRemaining = Math.max(0, currentHealth.freezeUntil - now);
|
|
177
188
|
|
|
178
189
|
const statusMap = {
|
|
179
190
|
'healthy': { text: '健康', color: '#18a058' },
|
|
@@ -182,14 +193,14 @@ function getChannelHealthStatus(channelId, source = 'claude') {
|
|
|
182
193
|
};
|
|
183
194
|
|
|
184
195
|
return {
|
|
185
|
-
status:
|
|
186
|
-
statusText: statusMap[
|
|
187
|
-
statusColor: statusMap[
|
|
188
|
-
consecutiveFailures:
|
|
189
|
-
consecutiveSuccesses:
|
|
190
|
-
totalFailures:
|
|
191
|
-
totalSuccesses:
|
|
192
|
-
freezeUntil:
|
|
196
|
+
status: currentHealth.status,
|
|
197
|
+
statusText: statusMap[currentHealth.status]?.text || '未知',
|
|
198
|
+
statusColor: statusMap[currentHealth.status]?.color || '#909399',
|
|
199
|
+
consecutiveFailures: currentHealth.consecutiveFailures,
|
|
200
|
+
consecutiveSuccesses: currentHealth.consecutiveSuccesses,
|
|
201
|
+
totalFailures: currentHealth.totalFailures,
|
|
202
|
+
totalSuccesses: currentHealth.totalSuccesses,
|
|
203
|
+
freezeUntil: currentHealth.freezeUntil,
|
|
193
204
|
freezeRemaining: Math.ceil(freezeRemaining / 1000), // 剩余秒数
|
|
194
205
|
};
|
|
195
206
|
}
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const BaseChannelService = require('./base/base-channel-service');
|
|
3
4
|
const { isProxyConfig } = require('./settings-manager');
|
|
4
5
|
const { PATHS, NATIVE_PATHS } = require('../../config/paths');
|
|
5
6
|
const { clearNativeOAuth } = require('./native-oauth-adapters');
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
const dir = path.dirname(PATHS.channels.claude);
|
|
9
|
-
if (!fs.existsSync(dir)) {
|
|
10
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
-
}
|
|
12
|
-
return PATHS.channels.claude;
|
|
13
|
-
}
|
|
8
|
+
// ── Claude 特有工具函数 ──
|
|
14
9
|
|
|
15
10
|
function getActiveChannelIdPath() {
|
|
16
11
|
const dir = path.dirname(PATHS.activeChannel.claude);
|
|
@@ -43,298 +38,23 @@ function loadActiveChannelId() {
|
|
|
43
38
|
return null;
|
|
44
39
|
}
|
|
45
40
|
|
|
46
|
-
let channelsCache = null;
|
|
47
|
-
let channelsCacheInitialized = false;
|
|
48
|
-
const DEFAULT_CHANNELS = { channels: [] };
|
|
49
|
-
|
|
50
|
-
function normalizeNumber(value, defaultValue, max = null) {
|
|
51
|
-
const num = Number(value);
|
|
52
|
-
if (!Number.isFinite(num) || num <= 0) {
|
|
53
|
-
return defaultValue;
|
|
54
|
-
}
|
|
55
|
-
if (max !== null && num > max) {
|
|
56
|
-
return max;
|
|
57
|
-
}
|
|
58
|
-
return num;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function normalizeGatewaySourceType(value, fallback = 'claude') {
|
|
62
|
-
const normalized = String(value || '').trim().toLowerCase();
|
|
63
|
-
if (normalized === 'claude') return 'claude';
|
|
64
|
-
if (normalized === 'codex') return 'codex';
|
|
65
|
-
if (normalized === 'gemini') return 'gemini';
|
|
66
|
-
return fallback;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
41
|
function extractApiKeyFromHelper(apiKeyHelper) {
|
|
70
42
|
if (typeof apiKeyHelper !== 'string' || !apiKeyHelper.trim()) {
|
|
71
43
|
return '';
|
|
72
44
|
}
|
|
73
|
-
|
|
74
45
|
const helper = apiKeyHelper.trim();
|
|
75
46
|
let match = helper.match(/^echo\s+["']([^"']+)["']$/);
|
|
76
|
-
if (match && match[1])
|
|
77
|
-
return match[1];
|
|
78
|
-
}
|
|
79
|
-
|
|
47
|
+
if (match && match[1]) return match[1];
|
|
80
48
|
match = helper.match(/^printf\s+["'][^"']*["']\s+["']([^"']+)["']$/);
|
|
81
|
-
if (match && match[1])
|
|
82
|
-
return match[1];
|
|
83
|
-
}
|
|
84
|
-
|
|
49
|
+
if (match && match[1]) return match[1];
|
|
85
50
|
return '';
|
|
86
51
|
}
|
|
87
52
|
|
|
88
53
|
function buildApiKeyHelperCommand() {
|
|
89
|
-
|
|
90
|
-
return 'printf "%s" "${ANTHROPIC_AUTH_TOKEN:-${ANTHROPIC_API_KEY:-}}"';
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function applyChannelDefaults(channel) {
|
|
94
|
-
const normalized = { ...channel };
|
|
95
|
-
if (normalized.enabled === undefined) {
|
|
96
|
-
normalized.enabled = true;
|
|
97
|
-
} else {
|
|
98
|
-
normalized.enabled = !!normalized.enabled;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
normalized.weight = normalizeNumber(normalized.weight, 1, 100);
|
|
102
|
-
|
|
103
|
-
if (normalized.maxConcurrency === undefined ||
|
|
104
|
-
normalized.maxConcurrency === null ||
|
|
105
|
-
normalized.maxConcurrency === 0) {
|
|
106
|
-
normalized.maxConcurrency = null;
|
|
107
|
-
} else {
|
|
108
|
-
normalized.maxConcurrency = normalizeNumber(normalized.maxConcurrency, 1, 100);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
normalized.gatewaySourceType = normalizeGatewaySourceType(normalized.gatewaySourceType, 'claude');
|
|
112
|
-
|
|
113
|
-
return normalized;
|
|
54
|
+
return 'echo \'ctx-managed\'';
|
|
114
55
|
}
|
|
115
56
|
|
|
116
|
-
|
|
117
|
-
const filePath = getChannelsFilePath();
|
|
118
|
-
try {
|
|
119
|
-
if (fs.existsSync(filePath)) {
|
|
120
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
121
|
-
const data = JSON.parse(content);
|
|
122
|
-
data.channels = (data.channels || []).map(applyChannelDefaults);
|
|
123
|
-
return data;
|
|
124
|
-
}
|
|
125
|
-
} catch (error) {
|
|
126
|
-
console.error('Error loading channels:', error);
|
|
127
|
-
}
|
|
128
|
-
return { ...DEFAULT_CHANNELS };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function initializeChannelsCache() {
|
|
132
|
-
if (channelsCacheInitialized) return;
|
|
133
|
-
channelsCache = readChannelsFromFile();
|
|
134
|
-
channelsCacheInitialized = true;
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
const filePath = getChannelsFilePath();
|
|
138
|
-
fs.watchFile(filePath, { persistent: false }, () => {
|
|
139
|
-
channelsCache = readChannelsFromFile();
|
|
140
|
-
});
|
|
141
|
-
} catch (err) {
|
|
142
|
-
console.error('Failed to watch channels file:', err);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function loadChannels() {
|
|
147
|
-
if (!channelsCacheInitialized) {
|
|
148
|
-
initializeChannelsCache();
|
|
149
|
-
}
|
|
150
|
-
return JSON.parse(JSON.stringify(channelsCache));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function saveChannels(data) {
|
|
154
|
-
const filePath = getChannelsFilePath();
|
|
155
|
-
const payload = {
|
|
156
|
-
...data,
|
|
157
|
-
channels: (data.channels || []).map(applyChannelDefaults)
|
|
158
|
-
};
|
|
159
|
-
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
|
160
|
-
channelsCache = JSON.parse(JSON.stringify(payload));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function getCurrentSettings() {
|
|
164
|
-
try {
|
|
165
|
-
const settingsPath = getClaudeSettingsPath();
|
|
166
|
-
if (!fs.existsSync(settingsPath)) {
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
170
|
-
const nativeOAuth = require('./native-oauth-adapters').readNativeOAuth('claude');
|
|
171
|
-
|
|
172
|
-
let baseUrl = settings.env?.ANTHROPIC_BASE_URL || '';
|
|
173
|
-
let apiKey = settings.env?.ANTHROPIC_API_KEY || '';
|
|
174
|
-
if (!apiKey && !nativeOAuth) {
|
|
175
|
-
apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN || '';
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (!apiKey && settings.apiKeyHelper) {
|
|
179
|
-
apiKey = extractApiKeyFromHelper(settings.apiKeyHelper);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (!baseUrl && !apiKey) {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return { baseUrl, apiKey };
|
|
187
|
-
} catch (error) {
|
|
188
|
-
console.error('Error reading current settings:', error);
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function getBestChannelForRestore() {
|
|
194
|
-
const data = loadChannels();
|
|
195
|
-
const enabledChannels = data.channels.filter(ch => ch.enabled !== false);
|
|
196
|
-
|
|
197
|
-
if (enabledChannels.length === 0) {
|
|
198
|
-
return data.channels[0];
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
enabledChannels.sort((a, b) => (b.weight || 1) - (a.weight || 1));
|
|
202
|
-
return enabledChannels[0];
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function getAllChannels() {
|
|
206
|
-
const data = loadChannels();
|
|
207
|
-
return data.channels;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function getCurrentChannel() {
|
|
211
|
-
const channels = getAllChannels();
|
|
212
|
-
if (!Array.isArray(channels) || channels.length === 0) {
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const activeChannelId = loadActiveChannelId();
|
|
217
|
-
if (activeChannelId) {
|
|
218
|
-
const matched = channels.find(ch => ch.id === activeChannelId);
|
|
219
|
-
if (matched) {
|
|
220
|
-
return matched;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return channels.find(ch => ch.enabled !== false) || channels[0];
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function createChannel(name, baseUrl, apiKey, websiteUrl, extraConfig = {}) {
|
|
228
|
-
const data = loadChannels();
|
|
229
|
-
const newChannel = applyChannelDefaults({
|
|
230
|
-
id: `channel-${Date.now()}`,
|
|
231
|
-
name,
|
|
232
|
-
baseUrl,
|
|
233
|
-
apiKey,
|
|
234
|
-
createdAt: Date.now(),
|
|
235
|
-
websiteUrl: websiteUrl || undefined,
|
|
236
|
-
enabled: extraConfig.enabled !== undefined ? !!extraConfig.enabled : true,
|
|
237
|
-
weight: extraConfig.weight,
|
|
238
|
-
maxConcurrency: extraConfig.maxConcurrency,
|
|
239
|
-
presetId: extraConfig.presetId || 'official',
|
|
240
|
-
modelConfig: extraConfig.modelConfig || null,
|
|
241
|
-
modelRedirects: extraConfig.modelRedirects || [],
|
|
242
|
-
proxyUrl: extraConfig.proxyUrl || '',
|
|
243
|
-
speedTestModel: extraConfig.speedTestModel || null,
|
|
244
|
-
gatewaySourceType: normalizeGatewaySourceType(extraConfig.gatewaySourceType, 'claude')
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
data.channels.push(newChannel);
|
|
248
|
-
saveChannels(data);
|
|
249
|
-
return newChannel;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function updateChannel(id, updates) {
|
|
253
|
-
const data = loadChannels();
|
|
254
|
-
const index = data.channels.findIndex(ch => ch.id === id);
|
|
255
|
-
|
|
256
|
-
if (index === -1) {
|
|
257
|
-
throw new Error('Channel not found');
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Store old channel data before updates
|
|
261
|
-
const oldChannel = { ...data.channels[index] };
|
|
262
|
-
|
|
263
|
-
const merged = { ...data.channels[index], ...updates };
|
|
264
|
-
const nextChannel = applyChannelDefaults({
|
|
265
|
-
...merged,
|
|
266
|
-
weight: merged.weight,
|
|
267
|
-
maxConcurrency: merged.maxConcurrency,
|
|
268
|
-
enabled: merged.enabled,
|
|
269
|
-
presetId: merged.presetId,
|
|
270
|
-
modelConfig: merged.modelConfig,
|
|
271
|
-
modelRedirects: merged.modelRedirects || [],
|
|
272
|
-
proxyUrl: merged.proxyUrl,
|
|
273
|
-
speedTestModel: merged.speedTestModel,
|
|
274
|
-
gatewaySourceType: normalizeGatewaySourceType(merged.gatewaySourceType, 'claude'),
|
|
275
|
-
updatedAt: Date.now()
|
|
276
|
-
});
|
|
277
|
-
data.channels[index] = nextChannel;
|
|
278
|
-
|
|
279
|
-
// Get proxy status
|
|
280
|
-
const { getProxyStatus } = require('../proxy-server');
|
|
281
|
-
const proxyStatus = getProxyStatus();
|
|
282
|
-
const isProxyRunning = proxyStatus.running;
|
|
283
|
-
|
|
284
|
-
// Single-channel enforcement: enabling a channel disables all others ONLY when proxy is OFF
|
|
285
|
-
// When proxy is ON (dynamic switching), multiple channels can be enabled simultaneously
|
|
286
|
-
if (!isProxyRunning && nextChannel.enabled && !oldChannel.enabled) {
|
|
287
|
-
data.channels.forEach((ch, i) => {
|
|
288
|
-
if (i !== index && ch.enabled) {
|
|
289
|
-
ch.enabled = false;
|
|
290
|
-
}
|
|
291
|
-
});
|
|
292
|
-
console.log(`[Single-channel mode] Enabled "${nextChannel.name}", disabled all others`);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
saveChannels(data);
|
|
296
|
-
|
|
297
|
-
// Sync settings.json only when proxy is OFF.
|
|
298
|
-
// In dynamic switching mode, defer local config writes until proxy stop.
|
|
299
|
-
if (!isProxyRunning && nextChannel.enabled) {
|
|
300
|
-
console.log(`[Settings-sync] Channel "${nextChannel.name}" enabled, syncing settings.json...`);
|
|
301
|
-
updateClaudeSettingsWithModelConfig(nextChannel);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return data.channels[index];
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
async function deleteChannel(id) {
|
|
308
|
-
const data = loadChannels();
|
|
309
|
-
const index = data.channels.findIndex(ch => ch.id === id);
|
|
310
|
-
|
|
311
|
-
if (index === -1) {
|
|
312
|
-
throw new Error('Channel not found');
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
data.channels.splice(index, 1);
|
|
316
|
-
saveChannels(data);
|
|
317
|
-
|
|
318
|
-
return { success: true };
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function applyChannelToSettings(id) {
|
|
322
|
-
const data = loadChannels();
|
|
323
|
-
const channel = data.channels.find(ch => ch.id === id);
|
|
324
|
-
|
|
325
|
-
if (!channel) {
|
|
326
|
-
throw new Error('Channel not found');
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// In single-channel mode, only this channel should be enabled
|
|
330
|
-
data.channels.forEach(ch => {
|
|
331
|
-
ch.enabled = ch.id === id;
|
|
332
|
-
});
|
|
333
|
-
saveChannels(data);
|
|
334
|
-
updateClaudeSettingsWithModelConfig(channel);
|
|
335
|
-
|
|
336
|
-
return channel;
|
|
337
|
-
}
|
|
57
|
+
// ── Claude 原生设置写入 ──
|
|
338
58
|
|
|
339
59
|
function updateClaudeSettingsWithModelConfig(channel) {
|
|
340
60
|
clearNativeOAuth('claude');
|
|
@@ -386,7 +106,6 @@ function updateClaudeSettingsWithModelConfig(channel) {
|
|
|
386
106
|
}
|
|
387
107
|
|
|
388
108
|
settings.apiKeyHelper = buildApiKeyHelperCommand();
|
|
389
|
-
|
|
390
109
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
|
391
110
|
}
|
|
392
111
|
|
|
@@ -415,18 +134,131 @@ function updateClaudeSettings(baseUrl, apiKey) {
|
|
|
415
134
|
}
|
|
416
135
|
|
|
417
136
|
settings.apiKeyHelper = buildApiKeyHelperCommand();
|
|
418
|
-
|
|
419
137
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
|
420
138
|
}
|
|
421
139
|
|
|
140
|
+
// ── ClaudeChannelService ──
|
|
141
|
+
|
|
142
|
+
class ClaudeChannelService extends BaseChannelService {
|
|
143
|
+
constructor() {
|
|
144
|
+
super({
|
|
145
|
+
platform: 'claude',
|
|
146
|
+
channelsFilePath: PATHS.channels.claude,
|
|
147
|
+
defaultGatewaySource: 'claude',
|
|
148
|
+
isProxyRunning: () => isProxyConfig(),
|
|
149
|
+
});
|
|
150
|
+
// Claude 特有:文件监听缓存
|
|
151
|
+
this._cache = null;
|
|
152
|
+
this._cacheInitialized = false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_generateId() {
|
|
156
|
+
return `channel-${Date.now()}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Claude 使用缓存 + fs.watchFile
|
|
160
|
+
loadChannels() {
|
|
161
|
+
if (this._cacheInitialized && this._cache) {
|
|
162
|
+
return { channels: this._cache.channels.map(ch => this._applyDefaults(ch)) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const data = super.loadChannels();
|
|
166
|
+
this._cache = data;
|
|
167
|
+
this._cacheInitialized = true;
|
|
168
|
+
|
|
169
|
+
// 设置文件监听
|
|
170
|
+
try {
|
|
171
|
+
fs.watchFile(this.channelsFilePath, { interval: 2000 }, () => {
|
|
172
|
+
try {
|
|
173
|
+
this._cache = null;
|
|
174
|
+
this._cacheInitialized = false;
|
|
175
|
+
} catch (_) {}
|
|
176
|
+
});
|
|
177
|
+
} catch (_) {}
|
|
178
|
+
|
|
179
|
+
return data;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
saveChannels(data) {
|
|
183
|
+
super.saveChannels(data);
|
|
184
|
+
this._cache = data;
|
|
185
|
+
this._cacheInitialized = true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_applyToNativeSettings(channel) {
|
|
189
|
+
updateClaudeSettingsWithModelConfig(channel);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getEffectiveApiKey(channel) {
|
|
193
|
+
return channel?.apiKey || null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── 单例 + 兼容导出 ──
|
|
198
|
+
|
|
199
|
+
const service = new ClaudeChannelService();
|
|
200
|
+
|
|
201
|
+
function getAllChannels() {
|
|
202
|
+
const data = service.loadChannels();
|
|
203
|
+
return data.channels;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getCurrentChannel() {
|
|
207
|
+
const channels = getAllChannels();
|
|
208
|
+
const activeId = loadActiveChannelId();
|
|
209
|
+
if (activeId) {
|
|
210
|
+
const active = channels.find(ch => ch.id === activeId);
|
|
211
|
+
if (active) return active;
|
|
212
|
+
}
|
|
213
|
+
return channels.find(ch => ch.enabled !== false) || channels[0] || null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getCurrentSettings() {
|
|
217
|
+
const channel = getCurrentChannel();
|
|
218
|
+
if (!channel) return null;
|
|
219
|
+
return {
|
|
220
|
+
baseUrl: channel.baseUrl,
|
|
221
|
+
apiKey: channel.apiKey,
|
|
222
|
+
channelName: channel.name,
|
|
223
|
+
channelId: channel.id,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function getBestChannelForRestore() {
|
|
228
|
+
const channels = getAllChannels();
|
|
229
|
+
const enabled = channels.filter(ch => ch.enabled !== false);
|
|
230
|
+
if (enabled.length > 0) return enabled[0];
|
|
231
|
+
return channels[0] || null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function createChannel(name, baseUrl, apiKey, websiteUrl, extraConfig) {
|
|
235
|
+
return service.createChannel({
|
|
236
|
+
name,
|
|
237
|
+
baseUrl,
|
|
238
|
+
apiKey,
|
|
239
|
+
websiteUrl,
|
|
240
|
+
...extraConfig,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function updateChannel(id, updates) {
|
|
245
|
+
return service.updateChannel(id, updates);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function deleteChannel(id) {
|
|
249
|
+
return service.deleteChannel(id);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function applyChannelToSettings(id) {
|
|
253
|
+
return service.applyChannelToSettings(id);
|
|
254
|
+
}
|
|
255
|
+
|
|
422
256
|
function getEffectiveApiKey(channel) {
|
|
423
|
-
return channel
|
|
257
|
+
return service.getEffectiveApiKey(channel);
|
|
424
258
|
}
|
|
425
259
|
|
|
426
260
|
function disableAllChannels() {
|
|
427
|
-
|
|
428
|
-
data.channels.forEach(ch => { ch.enabled = false; });
|
|
429
|
-
saveChannels(data);
|
|
261
|
+
return service.disableAllChannels();
|
|
430
262
|
}
|
|
431
263
|
|
|
432
264
|
module.exports = {
|
|
@@ -441,5 +273,5 @@ module.exports = {
|
|
|
441
273
|
updateClaudeSettings,
|
|
442
274
|
updateClaudeSettingsWithModelConfig,
|
|
443
275
|
getEffectiveApiKey,
|
|
444
|
-
disableAllChannels
|
|
276
|
+
disableAllChannels,
|
|
445
277
|
};
|