coding-tool-x 3.7.2 → 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 (26) hide show
  1. package/dist/web/assets/{Analytics-D5kLOhUo.js → Analytics-BqDjJUJl.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-D_-16blp.js → ConfigTemplates-DV2KFuC4.js} +1 -1
  3. package/dist/web/assets/{Home-DM_56Fyr.js → Home-D7hovkk9.js} +1 -1
  4. package/dist/web/assets/{PluginManager-Cv_rv8Dh.js → PluginManager-Bd3hhc5A.js} +1 -1
  5. package/dist/web/assets/{ProjectList-H_m4Tx4E.js → ProjectList-BB4Whns8.js} +1 -1
  6. package/dist/web/assets/{SessionList-DcUQvsbR.js → SessionList-CvurKSRK.js} +1 -1
  7. package/dist/web/assets/{SkillManager-CRav3m2a.js → SkillManager-DQjRXOFg.js} +1 -1
  8. package/dist/web/assets/{WorkspaceManager-B3f8R4EF.js → WorkspaceManager-CZeXnxu1.js} +1 -1
  9. package/dist/web/assets/{icons-Dop5j7Bf.js → icons-nGdSKQn2.js} +1 -1
  10. package/dist/web/assets/{index-Bnni1zPN.js → index-BovYCFsd.js} +2 -2
  11. package/dist/web/assets/index-kQCC5dlu.css +1 -0
  12. package/dist/web/index.html +3 -3
  13. package/package.json +1 -1
  14. package/src/server/api/channels.js +14 -1
  15. package/src/server/api/codex-channels.js +21 -1
  16. package/src/server/api/gemini-channels.js +21 -1
  17. package/src/server/api/omp-channels.js +17 -1
  18. package/src/server/api/opencode-channels.js +21 -1
  19. package/src/server/services/channel-sync-utils.js +368 -0
  20. package/src/server/services/channels.js +119 -0
  21. package/src/server/services/codex-channels.js +125 -0
  22. package/src/server/services/gemini-channels.js +123 -0
  23. package/src/server/services/omp-channels.js +299 -2
  24. package/src/server/services/omp-settings-manager.js +2 -0
  25. package/src/server/services/opencode-channels.js +172 -4
  26. package/dist/web/assets/index-CfMuHG8u.css +0 -1
@@ -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
+ };
@@ -6,6 +6,12 @@ const { PATHS, NATIVE_PATHS } = require('../../config/paths');
6
6
  const { clearNativeOAuth } = require('./native-oauth-adapters');
7
7
  const { isWindowsLikePlatform } = require('../../utils/home-dir');
8
8
  const { normalizeGatewaySourceType } = require('./base/proxy-utils');
9
+ const {
10
+ createSkippedResult,
11
+ isLocalProxyBaseUrl,
12
+ resolveExistingActiveChannel,
13
+ upsertSyncedChannels
14
+ } = require('./channel-sync-utils');
9
15
 
10
16
  // ── Claude 特有工具函数 ──
11
17
 
@@ -338,6 +344,118 @@ function getCurrentSettings() {
338
344
  };
339
345
  }
340
346
 
347
+ function readClaudeNativeSettings() {
348
+ const settingsPath = getClaudeSettingsPath();
349
+ if (!fs.existsSync(settingsPath)) {
350
+ return {};
351
+ }
352
+ try {
353
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
354
+ } catch (error) {
355
+ throw new Error(`Failed to read Claude settings.json: ${error.message}`);
356
+ }
357
+ }
358
+
359
+ function readClaudeNativeOAuth() {
360
+ try {
361
+ const adapters = require('./native-oauth-adapters');
362
+ return typeof adapters.readNativeOAuth === 'function'
363
+ ? adapters.readNativeOAuth('claude')
364
+ : null;
365
+ } catch {
366
+ return null;
367
+ }
368
+ }
369
+
370
+ function buildClaudeSyncCandidate(settings, channels) {
371
+ const env = settings?.env && typeof settings.env === 'object' ? settings.env : {};
372
+ const baseUrl = String(env.ANTHROPIC_BASE_URL || '').trim();
373
+
374
+ if (isLocalProxyBaseUrl(baseUrl)) {
375
+ const existing = resolveExistingActiveChannel('claude', channels);
376
+ if (existing) {
377
+ return {
378
+ skip: true,
379
+ channel: existing,
380
+ warning: 'Claude 当前配置指向 ctx 代理;对应渠道已在列表中,未重复导入。'
381
+ };
382
+ }
383
+ return {
384
+ skip: true,
385
+ warning: 'Claude 当前配置指向 ctx 代理,但没有找到可匹配的现有渠道。请先确认 ctx 渠道列表。'
386
+ };
387
+ }
388
+
389
+ const helperKey = extractApiKeyFromHelper(settings?.apiKeyHelper);
390
+ const apiKey = String(env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || helperKey || '').trim();
391
+ const hasOAuth = Boolean(env.CLAUDE_CODE_OAUTH_TOKEN || readClaudeNativeOAuth());
392
+ const existingByBaseUrl = baseUrl
393
+ ? channels.find(ch => String(ch.baseUrl || '').trim() === baseUrl)
394
+ : null;
395
+
396
+ if (!apiKey) {
397
+ if (existingByBaseUrl) {
398
+ return {
399
+ skip: true,
400
+ channel: existingByBaseUrl,
401
+ warning: 'Claude 当前配置未暴露 API Key;已找到同 baseUrl 渠道,未重复导入。'
402
+ };
403
+ }
404
+ return {
405
+ skip: true,
406
+ warning: hasOAuth
407
+ ? 'Claude 当前配置是 OAuth/登录态,OAuth 渠道不支持同步导入。'
408
+ : 'Claude 当前配置缺少 API Key,无法同步导入。'
409
+ };
410
+ }
411
+
412
+ const modelConfig = {
413
+ model: env.ANTHROPIC_MODEL || '',
414
+ haikuModel: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || '',
415
+ sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL || '',
416
+ opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL || ''
417
+ };
418
+ Object.keys(modelConfig).forEach((key) => {
419
+ if (!modelConfig[key]) delete modelConfig[key];
420
+ });
421
+
422
+ const finalBaseUrl = baseUrl || 'https://api.anthropic.com';
423
+ return {
424
+ name: existingByBaseUrl?.name || 'Claude 当前配置',
425
+ baseUrl: finalBaseUrl,
426
+ apiKey,
427
+ presetId: finalBaseUrl.includes('api.anthropic.com') ? 'official' : 'custom',
428
+ modelConfig,
429
+ proxyUrl: env.HTTPS_PROXY || env.HTTP_PROXY || '',
430
+ targetApi: 'responses',
431
+ gatewaySourceType: 'claude',
432
+ credentialSource: env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN ? 'config' : 'apiKeyHelper'
433
+ };
434
+ }
435
+
436
+ function syncCurrentClaudeChannel() {
437
+ const data = service.loadChannels();
438
+ const channels = Array.isArray(data.channels) ? data.channels : [];
439
+ const settings = readClaudeNativeSettings();
440
+ const candidate = buildClaudeSyncCandidate(settings, channels);
441
+
442
+ if (candidate?.skip) {
443
+ return createSkippedResult('claude', candidate.warning, candidate.channel);
444
+ }
445
+
446
+ return upsertSyncedChannels({
447
+ toolType: 'claude',
448
+ loadChannels: () => service.loadChannels(),
449
+ saveChannels: payload => service.saveChannels(payload),
450
+ applyDefaults: channel => service._applyDefaults(channel),
451
+ candidates: [candidate],
452
+ matchers: [
453
+ (channel, current) => channel.baseUrl === current.baseUrl && channel.apiKey === current.apiKey,
454
+ (channel, current) => channel.baseUrl === current.baseUrl && channel.targetApi === current.targetApi
455
+ ]
456
+ });
457
+ }
458
+
341
459
  function getBestChannelForRestore() {
342
460
  const channels = getAllChannels();
343
461
  const enabled = channels.filter(ch => ch.enabled !== false);
@@ -394,4 +512,5 @@ module.exports = {
394
512
  getEffectiveApiKey,
395
513
  disableAllChannels,
396
514
  extractApiKeyFromHelper,
515
+ syncCurrentClaudeChannel,
397
516
  };
@@ -8,6 +8,13 @@ const { getCodexDir } = require('./codex-config');
8
8
  const { isProxyConfig, readConfig } = require('./codex-settings-manager');
9
9
  const { syncCodexUserEnvironment } = require('./codex-env-manager');
10
10
  const BaseChannelService = require('./base/base-channel-service');
11
+ const {
12
+ createSkippedResult,
13
+ isLocalProxyBaseUrl,
14
+ resolveApiKeyValue,
15
+ resolveExistingActiveChannel,
16
+ upsertSyncedChannels
17
+ } = require('./channel-sync-utils');
11
18
 
12
19
  const CODEX_MANAGED_ENV_KEY = 'CC_PROXY_KEY';
13
20
  const CODEX_PROXY_ENV_VALUE = 'PROXY_KEY';
@@ -269,6 +276,123 @@ function applyChannelToSettings(id) { return service.applyChannelToSettings(id);
269
276
  function getEffectiveApiKey(channel) { return service.getEffectiveApiKey(channel); }
270
277
  function disableAllChannels() { return service.disableAllChannels(); }
271
278
 
279
+ function findCodexExistingChannel(channels = [], providerKey = '', baseUrl = '') {
280
+ const key = String(providerKey || '').trim();
281
+ const url = String(baseUrl || '').trim();
282
+ return channels.find(channel => key && channel.providerKey === key)
283
+ || channels.find(channel => url && channel.baseUrl === url)
284
+ || null;
285
+ }
286
+
287
+ function buildDefaultCodexProvider(providerKey = '') {
288
+ if (providerKey !== 'openai') return null;
289
+ return {
290
+ name: 'OpenAI',
291
+ base_url: 'https://api.openai.com/v1',
292
+ env_key: 'OPENAI_API_KEY',
293
+ wire_api: 'responses'
294
+ };
295
+ }
296
+
297
+ function buildCodexSyncCandidate(config, channels) {
298
+ const configuredProvider = String(config?.model_provider || '').trim();
299
+ const currentProvider = configuredProvider || (process.env.OPENAI_API_KEY ? 'openai' : '');
300
+ if (!currentProvider) {
301
+ return {
302
+ skip: true,
303
+ warning: 'Codex config.toml 未配置 model_provider,且未找到 OPENAI_API_KEY,无法同步当前渠道。'
304
+ };
305
+ }
306
+
307
+ const provider = config?.model_providers?.[currentProvider]
308
+ || buildDefaultCodexProvider(currentProvider)
309
+ || null;
310
+ const baseUrl = String(provider?.base_url || provider?.baseUrl || '').trim();
311
+ if (currentProvider === 'cc-proxy' || isLocalProxyBaseUrl(baseUrl)) {
312
+ const existing = resolveExistingActiveChannel('codex', channels);
313
+ if (existing) {
314
+ return {
315
+ skip: true,
316
+ channel: existing,
317
+ warning: 'Codex 当前配置指向 ctx 代理;对应渠道已在列表中,未重复导入。'
318
+ };
319
+ }
320
+ return {
321
+ skip: true,
322
+ warning: 'Codex 当前配置指向 ctx 代理,但没有找到可匹配的现有渠道。'
323
+ };
324
+ }
325
+
326
+ if (!provider || typeof provider !== 'object') {
327
+ return {
328
+ skip: true,
329
+ warning: `Codex 当前 provider "${currentProvider}" 未在 model_providers 中定义。`
330
+ };
331
+ }
332
+
333
+ const existing = findCodexExistingChannel(channels, currentProvider, baseUrl);
334
+ const directKey = provider.api_key || provider.apiKey || provider.key || '';
335
+ let credential = resolveApiKeyValue(directKey);
336
+ if (!credential.value && provider.env_key) {
337
+ credential = resolveApiKeyValue(String(provider.env_key || ''));
338
+ }
339
+ const apiKey = credential.value || existing?.apiKey || '';
340
+ if (!apiKey) {
341
+ return {
342
+ skip: true,
343
+ channel: existing || null,
344
+ warning: provider.requires_openai_auth
345
+ ? 'Codex 当前 provider 使用 OAuth/OpenAI 登录态,OAuth 渠道不支持同步导入。'
346
+ : `Codex 当前 provider "${currentProvider}" 缺少可解析 API Key,无法同步导入。`
347
+ };
348
+ }
349
+
350
+ return {
351
+ name: existing?.name || provider.name || currentProvider,
352
+ providerKey: currentProvider,
353
+ baseUrl,
354
+ apiKey,
355
+ wireApi: provider.wire_api || provider.wireApi || 'responses',
356
+ envKey: CODEX_MANAGED_ENV_KEY,
357
+ requiresOpenaiAuth: false,
358
+ queryParams: provider.query_params || provider.queryParams || {},
359
+ gatewaySourceType: existing?.gatewaySourceType || 'codex',
360
+ credentialSource: credential.value ? credential.source : 'existing-channel'
361
+ };
362
+ }
363
+
364
+ function syncCurrentCodexChannel() {
365
+ const data = service.loadChannels();
366
+ const channels = Array.isArray(data.channels) ? data.channels : [];
367
+ let config = {};
368
+ try {
369
+ config = readConfig();
370
+ } catch (error) {
371
+ if (/ENOENT|no such file|config\.toml not found/i.test(String(error?.message || ''))) {
372
+ config = {};
373
+ } else {
374
+ return createSkippedResult('codex', `Codex config.toml 读取失败:${error.message}`);
375
+ }
376
+ }
377
+
378
+ const candidate = buildCodexSyncCandidate(config, channels);
379
+ if (candidate?.skip) {
380
+ return createSkippedResult('codex', candidate.warning, candidate.channel);
381
+ }
382
+
383
+ return upsertSyncedChannels({
384
+ toolType: 'codex',
385
+ loadChannels: () => service.loadChannels(),
386
+ saveChannels: payload => service.saveChannels(payload),
387
+ applyDefaults: channel => service._applyDefaults(channel),
388
+ candidates: [candidate],
389
+ matchers: [
390
+ (channel, current) => channel.providerKey && channel.providerKey === current.providerKey,
391
+ (channel, current) => channel.baseUrl === current.baseUrl && channel.apiKey === current.apiKey
392
+ ]
393
+ });
394
+ }
395
+
272
396
  // 服务启动时自动同步环境变量
273
397
  try {
274
398
  const data = service.loadChannels();
@@ -292,6 +416,7 @@ module.exports = {
292
416
  applyChannelToSettings,
293
417
  getEffectiveApiKey,
294
418
  disableAllChannels,
419
+ syncCurrentCodexChannel,
295
420
  _test: {
296
421
  buildManagedCodexEnvMap,
297
422
  CODEX_MANAGED_ENV_KEY,