coding-tool-x 3.4.0 → 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.
Files changed (27) hide show
  1. package/dist/web/assets/{Analytics-DEjfL5Jx.js → Analytics-CbGxotgz.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-DkRL_-tf.js → ConfigTemplates-oP6nrFEb.js} +1 -1
  3. package/dist/web/assets/{Home-CF-L640I.js → Home-DMntmEvh.js} +1 -1
  4. package/dist/web/assets/{PluginManager-BzNYTdNB.js → PluginManager-BUC_c7nH.js} +1 -1
  5. package/dist/web/assets/{ProjectList-C0-JgHMM.js → ProjectList-CW8J49n7.js} +1 -1
  6. package/dist/web/assets/{SessionList-CkZUdX5N.js → SessionList-7lYnF92v.js} +1 -1
  7. package/dist/web/assets/{SkillManager-Cak0-4d4.js → SkillManager-Cs08216i.js} +1 -1
  8. package/dist/web/assets/{WorkspaceManager-CGDJzwEr.js → WorkspaceManager-CY-oGtyB.js} +1 -1
  9. package/dist/web/assets/{index-Dz7v9OM0.css → index-5qy5NMIP.css} +1 -1
  10. package/dist/web/assets/index-ClCqKpvX.js +2 -0
  11. package/dist/web/index.html +2 -2
  12. package/package.json +6 -2
  13. package/src/server/codex-proxy-server.js +4 -92
  14. package/src/server/gemini-proxy-server.js +5 -28
  15. package/src/server/opencode-proxy-server.js +3 -93
  16. package/src/server/proxy-server.js +2 -57
  17. package/src/server/services/base/base-channel-service.js +247 -0
  18. package/src/server/services/base/proxy-utils.js +152 -0
  19. package/src/server/services/channel-health.js +30 -19
  20. package/src/server/services/channels.js +125 -293
  21. package/src/server/services/codex-channels.js +148 -513
  22. package/src/server/services/codex-env-manager.js +49 -19
  23. package/src/server/services/gemini-channels.js +2 -7
  24. package/src/server/services/oauth-credentials-service.js +12 -2
  25. package/src/server/services/opencode-channels.js +7 -9
  26. package/src/server/services/repo-scanner-base.js +1 -0
  27. package/dist/web/assets/index-D_WItvHE.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 now = Date.now();
140
+ const currentHealth = transitionFrozenChannelIfExpired(channelId, source);
123
141
 
124
- switch (health.status) {
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, health.freezeUntil - now);
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: health.status,
186
- statusText: statusMap[health.status]?.text || '未知',
187
- statusColor: statusMap[health.status]?.color || '#909399',
188
- consecutiveFailures: health.consecutiveFailures,
189
- consecutiveSuccesses: health.consecutiveSuccesses,
190
- totalFailures: health.totalFailures,
191
- totalSuccesses: health.totalSuccesses,
192
- freezeUntil: health.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
- function getChannelsFilePath() {
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
- // 避免把明文 API Key 写入可执行命令,降低注入风险
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
- function readChannelsFromFile() {
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.apiKey || null;
257
+ return service.getEffectiveApiKey(channel);
424
258
  }
425
259
 
426
260
  function disableAllChannels() {
427
- const data = loadChannels();
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
  };