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.
- package/README.md +5 -5
- package/dist/web/assets/{Analytics-Dpje9L84.js → Analytics-BqDjJUJl.js} +1 -1
- package/dist/web/assets/{ConfigTemplates-Cz6kasek.js → ConfigTemplates-DV2KFuC4.js} +1 -1
- package/dist/web/assets/{Home-BwTaYu3q.js → Home-D7hovkk9.js} +1 -1
- package/dist/web/assets/{PluginManager-DsN-xNXx.js → PluginManager-Bd3hhc5A.js} +1 -1
- package/dist/web/assets/{ProjectList-4BCObimU.js → ProjectList-BB4Whns8.js} +1 -1
- package/dist/web/assets/{SessionList-D4c8FRzq.js → SessionList-CvurKSRK.js} +1 -1
- package/dist/web/assets/{SkillManager-C-KYbXB5.js → SkillManager-DQjRXOFg.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-B-BlPJHX.js → WorkspaceManager-CZeXnxu1.js} +1 -1
- package/dist/web/assets/{icons-Dop5j7Bf.js → icons-nGdSKQn2.js} +1 -1
- package/dist/web/assets/index-BovYCFsd.js +2 -0
- package/dist/web/assets/index-kQCC5dlu.css +1 -0
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/src/commands/ui.js +1 -0
- package/src/index.js +3 -2
- package/src/server/api/channels.js +14 -1
- package/src/server/api/codex-channels.js +21 -1
- package/src/server/api/gemini-channels.js +21 -1
- package/src/server/api/oauth-credentials.js +15 -2
- package/src/server/api/omp-channels.js +117 -39
- package/src/server/api/opencode-channels.js +21 -1
- package/src/server/index.js +13 -5
- package/src/server/services/channel-sync-utils.js +368 -0
- package/src/server/services/channels.js +119 -0
- package/src/server/services/codex-channels.js +125 -0
- package/src/server/services/gemini-channels.js +123 -0
- package/src/server/services/model-detector.js +15 -2
- package/src/server/services/native-oauth-adapters.js +212 -1
- package/src/server/services/network-access.js +5 -0
- package/src/server/services/oauth-credentials-service.js +110 -3
- package/src/server/services/omp-channels.js +304 -2
- package/src/server/services/omp-settings-manager.js +2 -0
- package/src/server/services/opencode-channels.js +172 -4
- package/dist/web/assets/index-Btfvh0GG.js +0 -2
- 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 {
|
|
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 = {
|
|
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
|
|
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:
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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:
|
|
177
|
-
cached:
|
|
178
|
-
fallbackUsed:
|
|
179
|
-
fetchedAt:
|
|
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
|
-
|
|
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
|
package/src/server/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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: '
|
|
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'
|
|
329
|
-
|
|
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
|
+
};
|