coding-tool-x 3.7.1 → 3.7.3

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 (36) hide show
  1. package/README.md +5 -5
  2. package/dist/web/assets/{Analytics-Dpje9L84.js → Analytics-BqDjJUJl.js} +1 -1
  3. package/dist/web/assets/{ConfigTemplates-Cz6kasek.js → ConfigTemplates-DV2KFuC4.js} +1 -1
  4. package/dist/web/assets/{Home-BwTaYu3q.js → Home-D7hovkk9.js} +1 -1
  5. package/dist/web/assets/{PluginManager-DsN-xNXx.js → PluginManager-Bd3hhc5A.js} +1 -1
  6. package/dist/web/assets/{ProjectList-4BCObimU.js → ProjectList-BB4Whns8.js} +1 -1
  7. package/dist/web/assets/{SessionList-D4c8FRzq.js → SessionList-CvurKSRK.js} +1 -1
  8. package/dist/web/assets/{SkillManager-C-KYbXB5.js → SkillManager-DQjRXOFg.js} +1 -1
  9. package/dist/web/assets/{WorkspaceManager-B-BlPJHX.js → WorkspaceManager-CZeXnxu1.js} +1 -1
  10. package/dist/web/assets/{icons-Dop5j7Bf.js → icons-nGdSKQn2.js} +1 -1
  11. package/dist/web/assets/index-BovYCFsd.js +2 -0
  12. package/dist/web/assets/index-kQCC5dlu.css +1 -0
  13. package/dist/web/index.html +3 -3
  14. package/package.json +1 -1
  15. package/src/commands/ui.js +1 -0
  16. package/src/index.js +3 -2
  17. package/src/server/api/channels.js +14 -1
  18. package/src/server/api/codex-channels.js +21 -1
  19. package/src/server/api/gemini-channels.js +21 -1
  20. package/src/server/api/oauth-credentials.js +15 -2
  21. package/src/server/api/omp-channels.js +117 -39
  22. package/src/server/api/opencode-channels.js +21 -1
  23. package/src/server/index.js +13 -5
  24. package/src/server/services/channel-sync-utils.js +368 -0
  25. package/src/server/services/channels.js +119 -0
  26. package/src/server/services/codex-channels.js +125 -0
  27. package/src/server/services/gemini-channels.js +123 -0
  28. package/src/server/services/model-detector.js +15 -2
  29. package/src/server/services/native-oauth-adapters.js +212 -1
  30. package/src/server/services/network-access.js +5 -0
  31. package/src/server/services/oauth-credentials-service.js +110 -3
  32. package/src/server/services/omp-channels.js +304 -2
  33. package/src/server/services/omp-settings-manager.js +2 -0
  34. package/src/server/services/opencode-channels.js +172 -4
  35. package/dist/web/assets/index-Btfvh0GG.js +0 -2
  36. package/dist/web/assets/index-CLNa6DhH.css +0 -1
@@ -5,7 +5,8 @@ const {
5
5
  createChannel,
6
6
  updateChannel,
7
7
  deleteChannel,
8
- saveChannelOrder
8
+ saveChannelOrder,
9
+ syncCurrentOmpChannel
9
10
  } = require('../services/omp-channels');
10
11
  const { isOmpInstalled } = require('../services/omp-sessions');
11
12
  const { getSchedulerState } = require('../services/channel-scheduler');
@@ -61,6 +62,63 @@ function collectPreferredModels(channel = {}) {
61
62
  ]);
62
63
  }
63
64
 
65
+ function resolveModelDiscoverySourceType(gatewaySourceType) {
66
+ return gatewaySourceType === 'opencode' ? 'openai_compatible' : gatewaySourceType;
67
+ }
68
+
69
+ async function discoverOmpModels(channel, gatewaySourceType, { forceRefresh = false } = {}) {
70
+ if (channel?.authMode === 'oauth' && !String(channel.apiKey || '').trim()) {
71
+ const preferredModels = collectPreferredModels(channel);
72
+ return {
73
+ models: preferredModels,
74
+ supported: preferredModels.length > 0,
75
+ cached: false,
76
+ fallbackUsed: true,
77
+ fetchedAt: new Date().toISOString(),
78
+ error: preferredModels.length > 0 ? null : 'OAuth 渠道未配置 API Key,无法直接调用远端模型列表接口',
79
+ errorHint: '请手动填写默认模型、测速模型或可用模型;运行时由 OMP 的登录凭证提供访问权限'
80
+ };
81
+ }
82
+
83
+ const discoverySourceType = resolveModelDiscoverySourceType(gatewaySourceType);
84
+ const listResult = await fetchModelsFromProvider(channel, discoverySourceType, {
85
+ useV1ModelsEndpoint: true,
86
+ forceRefresh
87
+ });
88
+ const listedModels = Array.isArray(listResult.models) ? uniqueModels(listResult.models) : [];
89
+
90
+ if (listedModels.length > 0) {
91
+ return {
92
+ models: listedModels,
93
+ supported: true,
94
+ cached: !!listResult.cached,
95
+ fallbackUsed: false,
96
+ fetchedAt: listResult.lastChecked || new Date().toISOString(),
97
+ error: null,
98
+ errorHint: null
99
+ };
100
+ }
101
+
102
+ const probe = await probeModelAvailability(channel, discoverySourceType, {
103
+ stopOnFirstAvailable: false,
104
+ preferredModels: collectPreferredModels(channel),
105
+ forceRefresh
106
+ });
107
+ const probedModels = Array.isArray(probe.availableModels) ? uniqueModels(probe.availableModels) : [];
108
+
109
+ return {
110
+ models: probedModels,
111
+ supported: probedModels.length > 0,
112
+ cached: !!probe.cached || !!listResult.cached,
113
+ fallbackUsed: probedModels.length > 0,
114
+ fetchedAt: probe.lastChecked || listResult.lastChecked || new Date().toISOString(),
115
+ error: probedModels.length > 0 ? null : (listResult.error || '无法获取可用模型'),
116
+ errorHint: probedModels.length > 0
117
+ ? '模型列表接口不可用,已自动切换为模型探测结果'
118
+ : (listResult.errorHint || '请手动填写模型名称')
119
+ };
120
+ }
121
+
64
122
  function attachAuthProvider(channel, snapshot) {
65
123
  const authProvider = findAuthProviderForKey(channel.providerKey || channel.provider || channel.name, snapshot);
66
124
  if (!authProvider) return channel;
@@ -114,25 +172,57 @@ module.exports = () => {
114
172
  }
115
173
  });
116
174
 
175
+ router.post('/sync-current', (req, res) => {
176
+ try {
177
+ if (!isOmpInstalled()) {
178
+ return res.status(404).json({ error: 'OMP CLI not installed' });
179
+ }
180
+
181
+ const result = syncCurrentOmpChannel();
182
+ res.json(result);
183
+ broadcastSchedulerState('omp', getSchedulerState('omp'));
184
+ } catch (err) {
185
+ console.error('[OMP Channels API] Failed to sync current channel:', err);
186
+ res.status(500).json({ error: err.message });
187
+ }
188
+ });
189
+
117
190
  router.post('/probe-models', async (req, res) => {
118
191
  try {
119
- const { baseUrl, apiKey, gatewaySourceType } = req.body || {};
192
+ const {
193
+ baseUrl,
194
+ apiKey,
195
+ gatewaySourceType,
196
+ authMode,
197
+ oauthProviderId,
198
+ model,
199
+ speedTestModel,
200
+ allowedModels,
201
+ modelRedirects
202
+ } = req.body || {};
120
203
  if (!baseUrl) {
121
204
  return res.status(400).json({ error: 'baseUrl is required' });
122
205
  }
123
- const tempChannel = { baseUrl, apiKey: apiKey || '', gatewaySourceType: gatewaySourceType || 'openai_compatible' };
206
+ const tempChannel = {
207
+ name: 'Temporary OMP Channel',
208
+ baseUrl,
209
+ apiKey: apiKey || '',
210
+ gatewaySourceType: gatewaySourceType || 'openai_compatible',
211
+ authMode: authMode === 'oauth' ? 'oauth' : 'api_key',
212
+ oauthProviderId: oauthProviderId || '',
213
+ model: model || null,
214
+ speedTestModel: speedTestModel || null,
215
+ allowedModels: Array.isArray(allowedModels) ? allowedModels : [],
216
+ modelRedirects: Array.isArray(modelRedirects) ? modelRedirects : []
217
+ };
124
218
  const resolvedGatewaySourceType = resolveGatewaySourceType(tempChannel);
125
- const modelListSourceType = resolvedGatewaySourceType === 'opencode' ? 'openai_compatible' : resolvedGatewaySourceType;
126
- const listResult = await fetchModelsFromProvider(tempChannel, modelListSourceType, {
127
- useV1ModelsEndpoint: true,
128
- forceRefresh: true
129
- });
130
- const models = Array.isArray(listResult.models) ? uniqueModels(listResult.models) : [];
219
+ const result = await discoverOmpModels(tempChannel, resolvedGatewaySourceType, { forceRefresh: true });
131
220
  res.json({
132
- models,
133
- supported: models.length > 0,
134
- error: models.length > 0 ? null : (listResult.error || '未返回可用模型列表'),
135
- errorHint: models.length > 0 ? null : (listResult.errorHint || '请手动填写模型名称')
221
+ models: result.models,
222
+ supported: result.supported,
223
+ fallbackUsed: result.fallbackUsed,
224
+ error: result.error,
225
+ errorHint: result.errorHint
136
226
  });
137
227
  } catch (error) {
138
228
  console.error('[OMP Channels API] Error probing models:', error);
@@ -150,35 +240,18 @@ module.exports = () => {
150
240
  }
151
241
  const forceRefresh = req.query.forceRefresh === 'true';
152
242
  const gatewaySourceType = resolveGatewaySourceType(channel);
153
- const modelListSourceType = gatewaySourceType === 'opencode' ? 'openai_compatible' : gatewaySourceType;
154
- const listResult = await fetchModelsFromProvider(channel, modelListSourceType, {
155
- useV1ModelsEndpoint: true,
156
- forceRefresh
157
- });
158
- let models = Array.isArray(listResult.models) ? uniqueModels(listResult.models) : [];
159
- let error = models.length > 0 ? null : listResult.error;
160
- let errorHint = models.length > 0 ? null : listResult.errorHint;
161
-
162
- if (models.length === 0) {
163
- const probe = await probeModelAvailability(channel, gatewaySourceType, {
164
- stopOnFirstAvailable: false,
165
- preferredModels: collectPreferredModels(channel)
166
- });
167
- models = Array.isArray(probe.availableModels) ? uniqueModels(probe.availableModels) : [];
168
- error = models.length > 0 ? null : (error || '无法获取可用模型');
169
- errorHint = models.length > 0 ? '模型列表接口不可用,已自动切换为模型探测结果' : (errorHint || '请手动填写模型名称');
170
- }
243
+ const result = await discoverOmpModels(channel, gatewaySourceType, { forceRefresh });
171
244
 
172
245
  res.json({
173
246
  channelId,
174
247
  gatewaySourceType,
175
- models,
176
- supported: models.length > 0,
177
- cached: !!listResult.cached,
178
- fallbackUsed: false,
179
- fetchedAt: listResult.lastChecked || new Date().toISOString(),
180
- error,
181
- errorHint
248
+ models: result.models,
249
+ supported: result.supported,
250
+ cached: result.cached,
251
+ fallbackUsed: result.fallbackUsed,
252
+ fetchedAt: result.fetchedAt,
253
+ error: result.error,
254
+ errorHint: result.errorHint
182
255
  });
183
256
  } catch (error) {
184
257
  console.error('[OMP Channels API] Error fetching models:', error);
@@ -203,6 +276,8 @@ module.exports = () => {
203
276
  speedTestModel,
204
277
  providerKey,
205
278
  providerApi,
279
+ authMode,
280
+ oauthProviderId,
206
281
  presetId,
207
282
  websiteUrl,
208
283
  balanceToken,
@@ -212,7 +287,8 @@ module.exports = () => {
212
287
  if (!name || !baseUrl) {
213
288
  return res.status(400).json({ error: 'Missing required fields: name and baseUrl' });
214
289
  }
215
- if (!apiKey) {
290
+ const normalizedAuthMode = authMode === 'oauth' ? 'oauth' : 'api_key';
291
+ if (!apiKey && normalizedAuthMode !== 'oauth') {
216
292
  return res.status(400).json({ error: 'API Key is required' });
217
293
  }
218
294
 
@@ -220,6 +296,8 @@ module.exports = () => {
220
296
  wireApi: wireApi || 'openai',
221
297
  providerApi: providerApi || wireApi || 'openai-completions',
222
298
  providerKey,
299
+ authMode: normalizedAuthMode,
300
+ oauthProviderId: oauthProviderId || providerKey || '',
223
301
  enabled,
224
302
  weight,
225
303
  maxConcurrency,
@@ -5,7 +5,8 @@ const {
5
5
  createChannel,
6
6
  updateChannel,
7
7
  deleteChannel,
8
- saveChannelOrder
8
+ saveChannelOrder,
9
+ syncCurrentOpenCodeChannel
9
10
  } = require('../services/opencode-channels');
10
11
  const { isOpenCodeInstalled } = require('../services/opencode-sessions');
11
12
  const { getSchedulerState } = require('../services/channel-scheduler');
@@ -131,6 +132,25 @@ module.exports = (config) => {
131
132
  }
132
133
  });
133
134
 
135
+ /**
136
+ * POST /api/opencode/channels/sync-current
137
+ * 将当前 OpenCode 原生/ctx 配置同步到渠道列表
138
+ */
139
+ router.post('/sync-current', (req, res) => {
140
+ try {
141
+ if (!isOpenCodeInstalled()) {
142
+ return res.status(404).json({ error: 'OpenCode CLI not installed' });
143
+ }
144
+
145
+ const result = syncCurrentOpenCodeChannel();
146
+ res.json(result);
147
+ broadcastSchedulerState('opencode', getSchedulerState('opencode'));
148
+ } catch (err) {
149
+ console.error('[OpenCode Channels API] Failed to sync current channel:', err);
150
+ res.status(500).json({ error: err.message });
151
+ }
152
+ });
153
+
134
154
  /**
135
155
  * POST /api/opencode/channels/probe-models
136
156
  * 用临时配置(新建渠道时)获取模型列表,无需 channelId
@@ -24,7 +24,10 @@ const { startCodexProxyServer } = require('./codex-proxy-server');
24
24
  const { startGeminiProxyServer } = require('./gemini-proxy-server');
25
25
  const { startOpenCodeProxyServer, collectProxyModelList } = require('./opencode-proxy-server');
26
26
  const { startOmpProxyServer } = require('./omp-proxy-server');
27
- const { createRemoteMutationGuard } = require('./services/network-access');
27
+ const {
28
+ createRemoteMutationGuard,
29
+ isRemoteMutationAllowedByEnv
30
+ } = require('./services/network-access');
28
31
  const { createApiRequestLogger } = require('./services/request-logger');
29
32
  const { inspectWebBuildState, ensureWebDistReady } = require('./services/web-build');
30
33
  const { ensureHttpsCredentials } = require('./services/https-cert');
@@ -151,7 +154,7 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
151
154
  const app = express();
152
155
  const useHttps = options.useHttps === true || process.argv.includes('--https');
153
156
  const lanMode = host === '0.0.0.0';
154
- const allowRemoteMutation = process.env.CC_TOOL_ALLOW_REMOTE_WRITE === 'true';
157
+ const allowRemoteMutation = isRemoteMutationAllowedByEnv(process.env);
155
158
 
156
159
  // Middleware
157
160
  app.use(express.json({ limit: '100mb' }));
@@ -175,7 +178,7 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
175
178
  app.use('/api', createRemoteMutationGuard({
176
179
  enabled: true,
177
180
  allowRemoteMutation,
178
- message: '出于安全考虑,LAN 模式默认仅允许本机执行写操作。可设置 CC_TOOL_ALLOW_REMOTE_WRITE=true 覆盖。'
181
+ message: 'LAN 模式下远程写操作已被 CC_TOOL_ALLOW_REMOTE_WRITE=false 禁止。'
179
182
  }));
180
183
 
181
184
  }
@@ -325,8 +328,13 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
325
328
  console.log(chalk.gray(' [TIP] 首次访问自签名证书时,浏览器可能会提示手动信任本地证书'));
326
329
  }
327
330
 
328
- if (host === '0.0.0.0' && !allowRemoteMutation) {
329
- console.log(chalk.yellow(' [LOCK] 已启用 LAN 安全保护:远程写操作默认禁用'));
331
+ if (host === '0.0.0.0') {
332
+ if (allowRemoteMutation) {
333
+ console.log(chalk.yellow(' [WARN] LAN 远程写操作已启用(默认行为)'));
334
+ console.log(chalk.gray(' 如需禁止,请设置 CC_TOOL_ALLOW_REMOTE_WRITE=false'));
335
+ } else {
336
+ console.log(chalk.yellow(' [LOCK] 已启用 LAN 安全保护:远程写操作被 CC_TOOL_ALLOW_REMOTE_WRITE=false 禁止'));
337
+ }
330
338
  }
331
339
  // 自动恢复代理状态
332
340
  autoRestoreProxies();
@@ -0,0 +1,368 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const { PATHS, ensureStorageDirMigrated } = require('../../config/paths');
5
+
6
+ function normalizeString(value) {
7
+ return String(value || '').trim();
8
+ }
9
+
10
+ function normalizeLower(value) {
11
+ return normalizeString(value).toLowerCase();
12
+ }
13
+
14
+ function normalizeUrl(value) {
15
+ const raw = normalizeString(value);
16
+ if (!raw) return '';
17
+ return raw.replace(/\/+$/, '').toLowerCase();
18
+ }
19
+
20
+ function isLocalProxyBaseUrl(value) {
21
+ const raw = normalizeString(value);
22
+ if (!raw) return false;
23
+ try {
24
+ const parsed = new URL(raw);
25
+ const host = normalizeLower(parsed.hostname).replace(/^\[|\]$/g, '');
26
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
27
+ } catch {
28
+ return raw.includes('127.0.0.1') || raw.includes('localhost');
29
+ }
30
+ }
31
+
32
+ function isProxyCredential(value) {
33
+ const raw = normalizeString(value);
34
+ return raw === 'PROXY_KEY' || raw === 'CC_PROXY_KEY';
35
+ }
36
+
37
+ function parseEnvReference(value) {
38
+ const raw = normalizeString(value);
39
+ if (!raw) return '';
40
+ let match = raw.match(/^\$\{?([A-Z_][A-Z0-9_]*)\}?$/);
41
+ if (match) return match[1];
42
+ match = raw.match(/^env:([A-Z_][A-Z0-9_]*)$/i);
43
+ if (match) return match[1].toUpperCase();
44
+ match = raw.match(/^process\.env\.([A-Z_][A-Z0-9_]*)$/);
45
+ if (match) return match[1];
46
+ if (/^[A-Z_][A-Z0-9_]*$/.test(raw) && /(?:API|KEY|TOKEN|SECRET|AUTH)/.test(raw)) {
47
+ return raw;
48
+ }
49
+ return '';
50
+ }
51
+
52
+ function resolveApiKeyValue(value, env = process.env) {
53
+ const raw = normalizeString(value);
54
+ if (!raw || isProxyCredential(raw)) {
55
+ return { value: '', source: '', envName: '' };
56
+ }
57
+
58
+ const envName = parseEnvReference(raw);
59
+ if (envName) {
60
+ const envValue = normalizeString(env?.[envName]);
61
+ if (isProxyCredential(envValue)) {
62
+ return { value: '', source: 'proxy-env', envName };
63
+ }
64
+ return {
65
+ value: envValue,
66
+ source: envValue ? 'env' : 'env-missing',
67
+ envName
68
+ };
69
+ }
70
+
71
+ return { value: raw, source: 'config', envName: '' };
72
+ }
73
+
74
+ function readActiveChannelId(platform) {
75
+ try {
76
+ ensureStorageDirMigrated?.();
77
+ const filePath = PATHS.activeChannel?.[platform];
78
+ if (!filePath || !fs.existsSync(filePath)) return null;
79
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
80
+ return data?.activeChannelId || null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function selectLatestEnabledChannel(channels = []) {
87
+ const enabledChannels = (Array.isArray(channels) ? channels : [])
88
+ .filter(channel => channel && channel.enabled !== false);
89
+ if (enabledChannels.length === 0) return null;
90
+ return enabledChannels.reduce((latest, current) => {
91
+ const latestTs = Number(latest?.updatedAt || latest?.createdAt || 0);
92
+ const currentTs = Number(current?.updatedAt || current?.createdAt || 0);
93
+ return currentTs > latestTs ? current : latest;
94
+ }, enabledChannels[0]);
95
+ }
96
+
97
+ function resolveExistingActiveChannel(platform, channels = []) {
98
+ const allChannels = Array.isArray(channels) ? channels : [];
99
+ const activeChannelId = readActiveChannelId(platform);
100
+ if (activeChannelId) {
101
+ const matched = allChannels.find(channel => channel.id === activeChannelId);
102
+ if (matched) return matched;
103
+ }
104
+ return selectLatestEnabledChannel(allChannels)
105
+ || allChannels.find(channel => channel.enabled !== false)
106
+ || allChannels[0]
107
+ || null;
108
+ }
109
+
110
+ function secretHash(value) {
111
+ const raw = normalizeString(value);
112
+ if (!raw) return '';
113
+ return crypto.createHash('sha256').update(raw).digest('hex');
114
+ }
115
+
116
+ function channelIdentity(channel = {}) {
117
+ return {
118
+ id: normalizeString(channel.id),
119
+ providerKey: normalizeLower(channel.providerKey || channel.provider),
120
+ baseUrl: normalizeUrl(channel.baseUrl),
121
+ apiKeyHash: secretHash(channel.apiKey || channel.key),
122
+ model: normalizeLower(channel.model),
123
+ name: normalizeLower(channel.name)
124
+ };
125
+ }
126
+
127
+ function defaultFindExistingChannel(channels = [], candidate = {}, options = {}) {
128
+ const target = channelIdentity(candidate);
129
+ const candidateId = normalizeString(candidate.id);
130
+ if (candidateId) {
131
+ const byId = channels.find(channel => channel.id === candidateId);
132
+ if (byId) return byId;
133
+ }
134
+
135
+ const matchers = Array.isArray(options.matchers) ? options.matchers : [];
136
+ for (const matcher of matchers) {
137
+ const matched = channels.find(channel => matcher(channel, candidate));
138
+ if (matched) return matched;
139
+ }
140
+
141
+ if (target.providerKey) {
142
+ const byProvider = channels.find(channel => channelIdentity(channel).providerKey === target.providerKey);
143
+ if (byProvider) return byProvider;
144
+ }
145
+
146
+ if (target.baseUrl && target.apiKeyHash) {
147
+ const byUrlAndKey = channels.find((channel) => {
148
+ const identity = channelIdentity(channel);
149
+ return identity.baseUrl === target.baseUrl && identity.apiKeyHash === target.apiKeyHash;
150
+ });
151
+ if (byUrlAndKey) return byUrlAndKey;
152
+ }
153
+
154
+ if (target.baseUrl && target.model) {
155
+ const byUrlAndModel = channels.find((channel) => {
156
+ const identity = channelIdentity(channel);
157
+ return identity.baseUrl === target.baseUrl && identity.model === target.model;
158
+ });
159
+ if (byUrlAndModel) return byUrlAndModel;
160
+ }
161
+
162
+ if (target.baseUrl) {
163
+ return channels.find(channel => channelIdentity(channel).baseUrl === target.baseUrl) || null;
164
+ }
165
+
166
+ return null;
167
+ }
168
+
169
+ function shouldPreserveField(field, existing, candidate) {
170
+ if (field === 'name') {
171
+ return normalizeString(existing?.name) && normalizeLower(existing.name) !== 'default';
172
+ }
173
+ return Object.prototype.hasOwnProperty.call(existing || {}, field)
174
+ && !Object.prototype.hasOwnProperty.call(candidate || {}, field);
175
+ }
176
+
177
+ function mergeSyncedChannel(existing, candidate, options = {}) {
178
+ const preserveFields = options.preserveFields || [
179
+ 'id',
180
+ 'createdAt',
181
+ 'name',
182
+ 'enabled',
183
+ 'weight',
184
+ 'maxConcurrency',
185
+ 'balanceToken',
186
+ 'balanceUserId'
187
+ ];
188
+
189
+ const merged = {
190
+ ...existing,
191
+ ...candidate,
192
+ id: existing.id,
193
+ createdAt: existing.createdAt || candidate.createdAt || Date.now(),
194
+ updatedAt: Date.now()
195
+ };
196
+
197
+ for (const field of preserveFields) {
198
+ if (shouldPreserveField(field, existing, candidate)) {
199
+ merged[field] = existing[field];
200
+ }
201
+ }
202
+
203
+ return merged;
204
+ }
205
+
206
+ function stableComparableValue(value) {
207
+ if (Array.isArray(value)) {
208
+ return value.map(stableComparableValue);
209
+ }
210
+ if (!value || typeof value !== 'object') {
211
+ return value;
212
+ }
213
+ return Object.keys(value)
214
+ .sort()
215
+ .reduce((acc, key) => {
216
+ acc[key] = stableComparableValue(value[key]);
217
+ return acc;
218
+ }, {});
219
+ }
220
+
221
+ function comparableChannel(channel = {}) {
222
+ const copy = { ...channel };
223
+ delete copy.updatedAt;
224
+ delete copy.health;
225
+ delete copy.websiteUrl;
226
+ return JSON.stringify(stableComparableValue(copy));
227
+ }
228
+
229
+ function sanitizeChannel(channel) {
230
+ if (!channel) return null;
231
+ const sanitized = {
232
+ id: channel.id,
233
+ name: channel.name,
234
+ baseUrl: channel.baseUrl,
235
+ enabled: channel.enabled !== false
236
+ };
237
+ if (channel.providerKey) sanitized.providerKey = channel.providerKey;
238
+ if (channel.model) sanitized.model = channel.model;
239
+ if (channel.credentialSource) sanitized.credentialSource = channel.credentialSource;
240
+ return sanitized;
241
+ }
242
+
243
+ function createSkippedResult(toolType, warning, channel = null) {
244
+ return {
245
+ success: true,
246
+ toolType,
247
+ added: 0,
248
+ updated: 0,
249
+ skipped: 1,
250
+ channels: channel ? [sanitizeChannel(channel)] : [],
251
+ warnings: warning ? [warning] : []
252
+ };
253
+ }
254
+
255
+ function upsertSyncedChannels({
256
+ toolType,
257
+ loadChannels,
258
+ saveChannels,
259
+ applyDefaults,
260
+ candidates,
261
+ matchers,
262
+ preserveFields
263
+ }) {
264
+ const data = loadChannels();
265
+ const channels = Array.isArray(data?.channels) ? [...data.channels] : [];
266
+ const result = {
267
+ success: true,
268
+ toolType,
269
+ added: 0,
270
+ updated: 0,
271
+ skipped: 0,
272
+ channels: [],
273
+ warnings: []
274
+ };
275
+
276
+ let changed = false;
277
+ for (const rawCandidate of candidates || []) {
278
+ const candidate = rawCandidate || {};
279
+ if (candidate.warning) {
280
+ result.warnings.push(candidate.warning);
281
+ }
282
+ if (candidate.skip) {
283
+ result.skipped += 1;
284
+ if (candidate.channel) result.channels.push(sanitizeChannel(candidate.channel));
285
+ continue;
286
+ }
287
+ if (!normalizeString(candidate.baseUrl)) {
288
+ result.skipped += 1;
289
+ result.warnings.push(`${toolType}: missing baseUrl, skipped current channel sync`);
290
+ continue;
291
+ }
292
+ if (!normalizeString(candidate.apiKey)) {
293
+ result.skipped += 1;
294
+ result.warnings.push(`${toolType}: missing API key or OAuth-only config, skipped current channel sync`);
295
+ continue;
296
+ }
297
+
298
+ const normalizedCandidate = applyDefaults ? applyDefaults(candidate) : candidate;
299
+ const existing = defaultFindExistingChannel(channels, normalizedCandidate, { matchers });
300
+ if (existing) {
301
+ const merged = applyDefaults
302
+ ? applyDefaults(mergeSyncedChannel(existing, normalizedCandidate, { preserveFields }))
303
+ : mergeSyncedChannel(existing, normalizedCandidate, { preserveFields });
304
+ if (comparableChannel(existing) === comparableChannel(merged)) {
305
+ result.skipped += 1;
306
+ result.channels.push(sanitizeChannel(existing));
307
+ continue;
308
+ }
309
+ const index = channels.findIndex(channel => channel.id === existing.id);
310
+ channels[index] = merged;
311
+ changed = true;
312
+ result.updated += 1;
313
+ result.channels.push(sanitizeChannel(merged));
314
+ continue;
315
+ }
316
+
317
+ const now = Date.now();
318
+ const created = applyDefaults ? applyDefaults({
319
+ id: candidate.id || crypto.randomUUID(),
320
+ enabled: candidate.enabled !== false,
321
+ weight: candidate.weight || 1,
322
+ maxConcurrency: candidate.maxConcurrency || null,
323
+ ...normalizedCandidate,
324
+ createdAt: now,
325
+ updatedAt: now
326
+ }) : {
327
+ id: candidate.id || crypto.randomUUID(),
328
+ enabled: candidate.enabled !== false,
329
+ weight: candidate.weight || 1,
330
+ maxConcurrency: candidate.maxConcurrency || null,
331
+ ...normalizedCandidate,
332
+ createdAt: now,
333
+ updatedAt: now
334
+ };
335
+ channels.push(created);
336
+ changed = true;
337
+ result.added += 1;
338
+ result.channels.push(sanitizeChannel(created));
339
+ }
340
+
341
+ if (changed) {
342
+ const dir = path.dirname(loadChannels.filePath || '');
343
+ if (dir && dir !== '.' && !fs.existsSync(dir)) {
344
+ fs.mkdirSync(dir, { recursive: true });
345
+ }
346
+ saveChannels({ ...data, channels });
347
+ }
348
+
349
+ result.warnings = [...new Set(result.warnings.filter(Boolean))];
350
+ return result;
351
+ }
352
+
353
+ module.exports = {
354
+ createSkippedResult,
355
+ defaultFindExistingChannel,
356
+ isLocalProxyBaseUrl,
357
+ isProxyCredential,
358
+ normalizeLower,
359
+ normalizeString,
360
+ normalizeUrl,
361
+ readActiveChannelId,
362
+ resolveApiKeyValue,
363
+ resolveExistingActiveChannel,
364
+ sanitizeChannel,
365
+ secretHash,
366
+ selectLatestEnabledChannel,
367
+ upsertSyncedChannels
368
+ };