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
@@ -8,602 +8,237 @@ const { getCodexDir } = require('./codex-config');
8
8
  const { isProxyConfig } = require('./codex-settings-manager');
9
9
  const { clearNativeOAuth } = require('./native-oauth-adapters');
10
10
  const { syncCodexUserEnvironment } = require('./codex-env-manager');
11
+ const BaseChannelService = require('./base/base-channel-service');
11
12
 
12
13
  const CODEX_PROXY_ENV_KEY = 'CC_PROXY_KEY';
13
14
  const CODEX_PROXY_ENV_VALUE = 'PROXY_KEY';
14
15
 
15
- /**
16
- * Codex 渠道管理服务(多渠道架构)
17
- *
18
- * Codex 配置结构:
19
- * - config.toml: 主配置,包含 model_provider 和各提供商配置
20
- * - 用户级环境变量: env_key 对应的 API Key 存储
21
- * - 我们的 codex-channels.json: 完整渠道信息(用于管理)
22
- *
23
- * 多渠道模式:
24
- * - 使用 enabled 字段标记渠道是否启用
25
- * - 使用 weight 和 maxConcurrency 控制负载均衡
26
- */
27
-
28
- function normalizeGatewaySourceType(value, fallback = 'codex') {
29
- const normalized = String(value || '').trim().toLowerCase();
30
- if (normalized === 'claude') return 'claude';
31
- if (normalized === 'codex') return 'codex';
32
- if (normalized === 'gemini') return 'gemini';
33
- return fallback;
34
- }
16
+ // ── Codex 特有工具函数 ──
35
17
 
36
18
  function buildManagedCodexEnvMap(channels = [], { includeProxyKey = false } = {}) {
37
- // 代理模式:只写代理 Key,不暴露真实 API Key
38
19
  if (includeProxyKey) {
39
20
  return { [CODEX_PROXY_ENV_KEY]: CODEX_PROXY_ENV_VALUE };
40
21
  }
41
-
42
- // 直连模式:只写当前激活渠道的 Key,环境中有且只有一条记录
43
- const activeChannel = channels.find(ch => ch.enabled && ch.envKey && ch.apiKey);
44
- if (!activeChannel) return {};
45
- return { [activeChannel.envKey]: activeChannel.apiKey };
46
- }
47
-
48
- // 获取渠道存储文件路径
49
- function getChannelsFilePath() {
50
- const channelsDir = path.dirname(PATHS.channels.codex);
51
- if (!fs.existsSync(channelsDir)) {
52
- fs.mkdirSync(channelsDir, { recursive: true });
22
+ const envMap = {};
23
+ for (const ch of channels) {
24
+ if (ch.enabled !== false && ch.envKey && ch.apiKey) {
25
+ envMap[ch.envKey] = ch.apiKey;
26
+ }
53
27
  }
54
- return PATHS.channels.codex;
28
+ return envMap;
55
29
  }
56
30
 
57
- // 读取所有渠道(从我们的存储文件)
58
- function loadChannels() {
59
- const filePath = getChannelsFilePath();
60
-
61
- if (!fs.existsSync(filePath)) {
62
- // 尝试从 config.toml 初始化
63
- return initializeFromConfig();
64
- }
65
-
31
+ function syncAllChannelEnvVars() {
66
32
  try {
67
- const content = fs.readFileSync(filePath, 'utf8');
68
- const data = JSON.parse(content);
69
- // 确保渠道有 enabled 字段(兼容旧数据)
70
- if (data.channels) {
71
- data.channels = data.channels.map(ch => {
72
- return {
73
- ...ch,
74
- enabled: ch.enabled !== false, // 默认启用
75
- weight: ch.weight || 1,
76
- maxConcurrency: ch.maxConcurrency || null,
77
- modelRedirects: ch.modelRedirects || [],
78
- speedTestModel: ch.speedTestModel || null,
79
- gatewaySourceType: normalizeGatewaySourceType(ch.gatewaySourceType, 'codex')
80
- };
81
- });
82
- }
83
- return data;
33
+ const svc = getServiceInstance();
34
+ const data = svc.loadChannels();
35
+ const proxyRunning = isProxyConfig();
36
+ const envMap = buildManagedCodexEnvMap(data.channels, {
37
+ includeProxyKey: proxyRunning
38
+ });
39
+ syncCodexUserEnvironment(envMap, { replace: true });
84
40
  } catch (err) {
85
- console.error('[Codex Channels] Failed to parse channels file:', err);
86
- return { channels: [] };
41
+ console.warn('[Codex Channels] syncAllChannelEnvVars failed:', err.message);
87
42
  }
88
43
  }
89
44
 
90
- // 从现有 config.toml 初始化渠道
91
- function initializeFromConfig() {
92
- const configPath = path.join(getCodexDir(), 'config.toml');
93
- const authPath = path.join(getCodexDir(), 'auth.json');
94
-
95
- const defaultData = { channels: [] };
96
-
97
- if (!fs.existsSync(configPath)) {
98
- saveChannels(defaultData);
99
- return defaultData;
45
+ function writeAnnotatedCodexConfig(configPath, config, comments = []) {
46
+ let tomlContent = tomlStringify(config);
47
+ if (comments.length > 0) {
48
+ tomlContent = comments.join('\n') + '\n\n' + tomlContent;
100
49
  }
50
+ fs.writeFileSync(configPath, tomlContent, 'utf8');
51
+ }
101
52
 
102
- try {
103
- // 读取 config.toml
104
- const configContent = fs.readFileSync(configPath, 'utf8');
105
- const config = toml.parse(configContent);
106
-
107
- // 读取 auth.json
108
- let auth = {};
109
- if (fs.existsSync(authPath)) {
110
- auth = JSON.parse(fs.readFileSync(authPath, 'utf8'));
111
- }
112
-
113
- // 从 model_providers 提取渠道
114
- const channels = [];
115
- if (config.model_providers) {
116
- for (const [providerKey, providerConfig] of Object.entries(config.model_providers)) {
117
- // env_key 优先级:配置的 env_key > PROVIDER_API_KEY > OPENAI_API_KEY
118
- let envKey = providerConfig.env_key || `${providerKey.toUpperCase()}_API_KEY`;
119
- let apiKey = process.env[envKey] || auth[envKey] || '';
120
-
121
- // 如果没找到,尝试 OPENAI_API_KEY 作为通用 fallback
122
- if (!apiKey && (process.env.OPENAI_API_KEY || auth['OPENAI_API_KEY'])) {
123
- apiKey = process.env.OPENAI_API_KEY || auth['OPENAI_API_KEY'];
124
- envKey = 'OPENAI_API_KEY';
125
- }
126
-
127
- channels.push({
128
- id: crypto.randomUUID(),
129
- name: providerConfig.name || providerKey,
130
- providerKey,
131
- baseUrl: providerConfig.base_url || '',
132
- wireApi: providerConfig.wire_api || 'responses',
133
- envKey,
134
- apiKey,
135
- websiteUrl: providerConfig.website_url || '',
136
- requiresOpenaiAuth: providerConfig.requires_openai_auth !== false,
137
- queryParams: providerConfig.query_params || null,
138
- enabled: config.model_provider === providerKey, // 当前激活的渠道启用
139
- weight: 1,
140
- maxConcurrency: null,
141
- gatewaySourceType: 'codex',
142
- createdAt: Date.now(),
143
- updatedAt: Date.now()
144
- });
145
- }
53
+ function pruneManagedProviders(existingProviders, currentProviderKey, allChannels) {
54
+ const knownKeys = new Set(allChannels.map(ch => ch.providerKey).filter(Boolean));
55
+ for (const key of Object.keys(existingProviders)) {
56
+ if (key !== currentProviderKey && !knownKeys.has(key)) {
57
+ delete existingProviders[key];
146
58
  }
147
-
148
- const data = {
149
- channels
150
- };
151
-
152
- saveChannels(data);
153
- return data;
154
- } catch (err) {
155
- console.error('[Codex Channels] Failed to initialize from config:', err);
156
- saveChannels(defaultData);
157
- return defaultData;
158
59
  }
159
60
  }
160
61
 
161
- // 保存渠道数据
162
- function saveChannels(data) {
163
- const filePath = getChannelsFilePath();
164
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
165
- }
166
-
167
- function getDefaultCodexConfig() {
168
- return {
169
- model: 'gpt-4',
170
- model_reasoning_effort: 'high',
171
- model_reasoning_summary_format: 'experimental',
172
- network_access: 'enabled',
173
- disable_response_storage: false,
174
- show_raw_agent_reasoning: true
175
- };
176
- }
177
-
178
- function cloneConfigValue(value) {
179
- return JSON.parse(JSON.stringify(value));
180
- }
181
-
182
- function readCodexConfigOrThrow(configPath, fallbackConfig = {}) {
183
- if (!fs.existsSync(configPath)) {
184
- return cloneConfigValue(fallbackConfig);
185
- }
186
-
187
- const content = fs.readFileSync(configPath, 'utf8');
62
+ function writeCodexConfigForMultiChannel(channels) {
63
+ const codexDir = getCodexDir();
64
+ const configPath = path.join(codexDir, 'config.toml');
188
65
 
189
- try {
190
- return toml.parse(content);
191
- } catch (err) {
192
- throw new Error(`Failed to parse existing config.toml: ${err.message}`);
66
+ let config = {};
67
+ if (fs.existsSync(configPath)) {
68
+ try {
69
+ config = toml.parse(fs.readFileSync(configPath, 'utf8'));
70
+ } catch (err) {
71
+ console.warn('[Codex Channels] Failed to parse existing config.toml:', err.message);
72
+ config = {};
73
+ }
193
74
  }
194
- }
195
75
 
196
- function writeTextAtomic(filePath, content) {
197
- const dirPath = path.dirname(filePath);
198
- if (!fs.existsSync(dirPath)) {
199
- fs.mkdirSync(dirPath, { recursive: true });
76
+ if (!config.model_providers || typeof config.model_providers !== 'object') {
77
+ config.model_providers = {};
200
78
  }
201
79
 
202
- const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
80
+ const enabledChannels = channels.filter(ch => ch.enabled !== false);
81
+ if (enabledChannels.length > 0) {
82
+ const primary = enabledChannels[0];
83
+ config.model_provider = primary.providerKey;
203
84
 
204
- try {
205
- fs.writeFileSync(tempPath, content, 'utf8');
206
- fs.renameSync(tempPath, filePath);
207
- } finally {
208
- if (fs.existsSync(tempPath)) {
209
- try {
210
- fs.unlinkSync(tempPath);
211
- } catch {
212
- // ignore temp cleanup errors
85
+ for (const ch of enabledChannels) {
86
+ config.model_providers[ch.providerKey] = {
87
+ name: ch.name,
88
+ base_url: ch.baseUrl,
89
+ wire_api: ch.wireApi || 'responses',
90
+ env_key: ch.envKey,
91
+ requires_openai_auth: ch.requiresOpenaiAuth !== false
92
+ };
93
+ if (ch.queryParams && Object.keys(ch.queryParams).length > 0) {
94
+ config.model_providers[ch.providerKey].query_params = ch.queryParams;
213
95
  }
214
96
  }
215
97
  }
216
- }
217
98
 
218
- function writeAnnotatedCodexConfig(configPath, config, headerLines = []) {
219
- const tomlContent = tomlStringify(config);
220
- const prefix = headerLines.length > 0 ? `${headerLines.join('\n')}\n\n` : '';
221
- writeTextAtomic(configPath, `${prefix}${tomlContent}`);
222
- }
223
-
224
- function getManagedProviderKeys(channels = []) {
225
- const keys = new Set(['cc-proxy']);
226
- for (const channel of channels) {
227
- if (channel?.providerKey) {
228
- keys.add(channel.providerKey);
229
- }
230
- }
231
- return keys;
99
+ writeAnnotatedCodexConfig(configPath, config, [
100
+ '# Codex Configuration',
101
+ '# Managed by Coding-Tool (multi-channel)'
102
+ ]);
232
103
  }
233
104
 
234
- function pruneManagedProviders(existingProviders = {}, currentProviderKey, channels = []) {
235
- const managedProviderKeys = getManagedProviderKeys(channels);
236
- const preservedProviders = {};
105
+ // ── CodexChannelService ──
237
106
 
238
- for (const [providerKey, providerConfig] of Object.entries(existingProviders)) {
239
- if (!managedProviderKeys.has(providerKey) || providerKey === currentProviderKey) {
240
- preservedProviders[providerKey] = providerConfig;
241
- }
107
+ class CodexChannelService extends BaseChannelService {
108
+ constructor() {
109
+ super({
110
+ platform: 'codex',
111
+ channelsFilePath: PATHS.channels.codex,
112
+ defaultGatewaySource: 'codex',
113
+ isProxyRunning: () => isProxyConfig(),
114
+ });
242
115
  }
243
116
 
244
- return preservedProviders;
245
- }
246
-
247
- // 获取所有渠道
248
- function getChannels() {
249
- const data = loadChannels();
250
- return {
251
- channels: data.channels || []
252
- };
253
- }
254
-
255
- // 添加渠道
256
- function createChannel(name, providerKey, baseUrl, apiKey, wireApi = 'responses', extraConfig = {}) {
257
- const data = loadChannels();
258
-
259
- // 检查 providerKey 是否已存在
260
- const existing = data.channels.find(c => c.providerKey === providerKey);
261
- if (existing) {
262
- throw new Error(`Provider key "${providerKey}" already exists`);
117
+ _generateId() {
118
+ return crypto.randomUUID();
263
119
  }
264
120
 
265
- const envKey = extraConfig.envKey || `${providerKey.toUpperCase()}_API_KEY`;
266
-
267
- const newChannel = {
268
- id: crypto.randomUUID(),
269
- name,
270
- providerKey,
271
- baseUrl,
272
- wireApi,
273
- envKey,
274
- apiKey,
275
- websiteUrl: extraConfig.websiteUrl || '',
276
- requiresOpenaiAuth: extraConfig.requiresOpenaiAuth !== false,
277
- queryParams: extraConfig.queryParams || null,
278
- enabled: extraConfig.enabled !== false, // 默认启用
279
- weight: extraConfig.weight || 1,
280
- maxConcurrency: extraConfig.maxConcurrency || null,
281
- modelRedirects: extraConfig.modelRedirects || [],
282
- speedTestModel: extraConfig.speedTestModel || null,
283
- gatewaySourceType: normalizeGatewaySourceType(extraConfig.gatewaySourceType, 'codex'),
284
- createdAt: Date.now(),
285
- updatedAt: Date.now()
286
- };
287
-
288
- data.channels.push(newChannel);
289
- saveChannels(data);
290
- syncAllChannelEnvVars(data.channels);
291
-
292
- // 注意:不再自动写入 config.toml,只在开启代理控制时才同步
293
- // writeCodexConfigForMultiChannel(data.channels);
294
-
295
- return newChannel;
296
- }
297
-
298
- // 更新渠道
299
- function updateChannel(channelId, updates) {
300
- const data = loadChannels();
301
- const index = data.channels.findIndex(c => c.id === channelId);
302
-
303
- if (index === -1) {
304
- throw new Error('Channel not found');
121
+ _applyDefaults(channel) {
122
+ const ch = super._applyDefaults(channel);
123
+ ch.providerKey = ch.providerKey || '';
124
+ ch.envKey = ch.envKey || '';
125
+ ch.wireApi = ch.wireApi || 'responses';
126
+ ch.model = ch.model || '';
127
+ ch.speedTestModel = ch.speedTestModel || null;
128
+ ch.modelRedirects = Array.isArray(ch.modelRedirects) ? ch.modelRedirects : [];
129
+ ch.gatewaySourceType = ch.gatewaySourceType || 'codex';
130
+ ch.requiresOpenaiAuth = ch.requiresOpenaiAuth !== false;
131
+ ch.queryParams = ch.queryParams || {};
132
+ return ch;
305
133
  }
306
134
 
307
- const oldChannel = data.channels[index];
308
-
309
- // 检查 providerKey 冲突
310
- if (updates.providerKey && updates.providerKey !== oldChannel.providerKey) {
311
- const existing = data.channels.find(c => c.providerKey === updates.providerKey && c.id !== channelId);
312
- if (existing) {
313
- throw new Error(`Provider key "${updates.providerKey}" already exists`);
135
+ _validateUniqueness(channels, fields, excludeId) {
136
+ if (!fields.providerKey) return;
137
+ const dup = channels.find(ch =>
138
+ ch.providerKey === fields.providerKey && ch.id !== excludeId
139
+ );
140
+ if (dup) {
141
+ throw new Error(`Provider key "${fields.providerKey}" already exists`);
314
142
  }
315
143
  }
316
144
 
317
- const merged = { ...oldChannel, ...updates };
318
- const newChannel = {
319
- ...merged,
320
- id: channelId, // 保持 ID 不变
321
- createdAt: oldChannel.createdAt, // 保持创建时间
322
- modelRedirects: merged.modelRedirects || [],
323
- speedTestModel: merged.speedTestModel !== undefined ? merged.speedTestModel : (oldChannel.speedTestModel || null),
324
- gatewaySourceType: normalizeGatewaySourceType(merged.gatewaySourceType, 'codex'),
325
- updatedAt: Date.now()
326
- };
327
-
328
- data.channels[index] = newChannel;
329
-
330
- // Get proxy status
331
- const { getCodexProxyStatus } = require('../codex-proxy-server');
332
- const proxyStatus = getCodexProxyStatus();
333
- const isProxyRunning = proxyStatus.running;
334
-
335
- // Single-channel enforcement: enabling a channel disables all others ONLY when proxy is OFF
336
- // When proxy is ON (dynamic switching), multiple channels can be enabled simultaneously
337
- if (!isProxyRunning && newChannel.enabled && !oldChannel.enabled) {
338
- data.channels.forEach((ch, i) => {
339
- if (i !== index && ch.enabled) {
340
- ch.enabled = false;
341
- }
342
- });
343
- console.log(`[Codex Single-channel mode] Enabled "${newChannel.name}", disabled all others`);
145
+ _onAfterCreate(_channel, _allChannels) {
146
+ syncAllChannelEnvVars();
147
+ // 注意:不再自动写入 config.toml,只在开启代理控制时才同步
344
148
  }
345
149
 
346
- saveChannels(data);
347
-
348
- // Sync config.toml only when proxy is OFF.
349
- // In dynamic switching mode, defer local config writes until proxy stop.
350
- if (!isProxyRunning && newChannel.enabled) {
351
- console.log(`[Codex Settings-sync] Channel "${newChannel.name}" enabled, syncing config.toml...`);
352
- applyChannelToSettings(channelId);
150
+ _onAfterUpdate(_old, _next, _allChannels) {
151
+ syncAllChannelEnvVars();
152
+ // 注意:不再自动写入 config.toml,只在开启代理控制时才同步
353
153
  }
354
154
 
355
- syncAllChannelEnvVars(data.channels);
356
-
357
- // 注意:不再自动写入 config.toml,只在开启代理控制时才同步
358
- // writeCodexConfigForMultiChannel(data.channels);
359
-
360
- return data.channels[index];
361
- }
362
-
363
- // 删除渠道
364
- async function deleteChannel(channelId) {
365
- const data = loadChannels();
366
-
367
- const index = data.channels.findIndex(c => c.id === channelId);
368
- if (index === -1) {
369
- throw new Error('Channel not found');
155
+ _onAfterDelete(_channel, _allChannels) {
156
+ clearNativeOAuth('codex');
157
+ syncAllChannelEnvVars();
158
+ // 注意:不再自动写入 config.toml,只在开启代理控制时才同步
370
159
  }
371
160
 
372
- const deletedChannel = data.channels[index];
373
- data.channels.splice(index, 1);
374
- saveChannels(data);
375
- syncAllChannelEnvVars(data.channels);
161
+ _applyToNativeSettings(channel) {
162
+ clearNativeOAuth('codex');
163
+ const codexDir = getCodexDir();
164
+ const configPath = path.join(codexDir, 'config.toml');
376
165
 
377
- // 注意:不再自动写入 config.toml,只在开启代理控制时才同步
378
- // writeCodexConfigForMultiChannel(data.channels);
379
-
380
- return { success: true };
381
- }
382
-
383
- /**
384
- * 写入 Codex 配置文件(多渠道模式)
385
- *
386
- * 关键改进:
387
- * 1. 完整保留现有配置(mcp_servers, projects 等)
388
- * 2. 如果已启用动态切换(cc-proxy),不覆盖 model_provider
389
- * 3. 使用 TOML 序列化而不是字符串拼接,确保配置完整性
390
- */
391
- function writeCodexConfigForMultiChannel(allChannels) {
392
- const codexDir = getCodexDir();
393
-
394
- if (!fs.existsSync(codexDir)) {
395
- fs.mkdirSync(codexDir, { recursive: true });
396
- }
397
-
398
- const configPath = path.join(codexDir, 'config.toml');
166
+ let config = {};
167
+ if (fs.existsSync(configPath)) {
168
+ try {
169
+ config = toml.parse(fs.readFileSync(configPath, 'utf8'));
170
+ } catch (err) {
171
+ config = {};
172
+ }
173
+ }
399
174
 
400
- // 读取现有配置,保留所有现有字段(特别是 mcp_servers, projects 等)
401
- const defaultConfig = getDefaultCodexConfig();
402
- const parsedConfig = readCodexConfigOrThrow(configPath, defaultConfig);
403
- let config = {
404
- ...parsedConfig,
405
- model: parsedConfig.model || defaultConfig.model,
406
- model_reasoning_effort: parsedConfig.model_reasoning_effort || defaultConfig.model_reasoning_effort,
407
- model_reasoning_summary_format: parsedConfig.model_reasoning_summary_format || defaultConfig.model_reasoning_summary_format,
408
- network_access: parsedConfig.network_access || defaultConfig.network_access,
409
- disable_response_storage: parsedConfig.disable_response_storage !== undefined ? parsedConfig.disable_response_storage : defaultConfig.disable_response_storage,
410
- show_raw_agent_reasoning: parsedConfig.show_raw_agent_reasoning !== undefined ? parsedConfig.show_raw_agent_reasoning : defaultConfig.show_raw_agent_reasoning
411
- };
412
-
413
- // 判断是否已启用动态切换
414
- const isProxyMode = config.model_provider === 'cc-proxy';
415
- const existingProviders = (config && typeof config.model_providers === 'object') ? config.model_providers : {};
416
- const existingProxyProvider = existingProviders['cc-proxy'];
417
-
418
- // 只有当未启用动态切换时,才更新 model_provider
419
- if (!isProxyMode) {
420
- const enabledChannels = allChannels.filter(c => c.enabled !== false);
421
- const defaultProvider = enabledChannels[0]?.providerKey || allChannels[0]?.providerKey || 'openai';
422
- config.model_provider = defaultProvider;
423
- }
175
+ config.model_provider = channel.providerKey;
424
176
 
425
- // 重建 model_providers 配置,先保留已有的非渠道 provider,避免丢失用户自定义配置
426
- config.model_providers = { ...existingProviders };
427
-
428
- // 在代理模式下,先保留 cc-proxy provider,避免被覆盖导致缺少 provider
429
- if (isProxyMode) {
430
- if (existingProxyProvider) {
431
- config.model_providers['cc-proxy'] = existingProxyProvider;
432
- } else {
433
- // 回退默认的代理配置(使用默认端口),确保 provider 存在
434
- config.model_providers['cc-proxy'] = {
435
- name: 'cc-proxy',
436
- base_url: 'http://127.0.0.1:20089/v1',
437
- wire_api: 'responses',
438
- env_key: 'CC_PROXY_KEY'
439
- };
177
+ if (!config.model_providers || typeof config.model_providers !== 'object') {
178
+ config.model_providers = {};
440
179
  }
441
- }
180
+ const data = this.loadChannels();
181
+ pruneManagedProviders(config.model_providers, channel.providerKey, data.channels);
442
182
 
443
- for (const channel of allChannels) {
444
183
  config.model_providers[channel.providerKey] = {
445
184
  name: channel.name,
446
185
  base_url: channel.baseUrl,
447
- wire_api: channel.wireApi,
186
+ wire_api: channel.wireApi || 'responses',
448
187
  env_key: channel.envKey,
449
188
  requires_openai_auth: channel.requiresOpenaiAuth !== false
450
189
  };
451
190
 
452
- // 添加额外查询参数(如 Azure 的 api-version)
453
191
  if (channel.queryParams && Object.keys(channel.queryParams).length > 0) {
454
192
  config.model_providers[channel.providerKey].query_params = channel.queryParams;
455
193
  }
456
- }
457
-
458
- writeAnnotatedCodexConfig(configPath, config, [
459
- '# Codex Configuration',
460
- '# Managed by Coding-Tool',
461
- '# WARNING: MCP servers and projects are preserved automatically'
462
- ]);
463
- syncCodexUserEnvironment(
464
- buildManagedCodexEnvMap(allChannels, { includeProxyKey: isProxyMode }),
465
- { replace: true }
466
- );
467
- }
468
194
 
469
- // 获取所有启用的渠道(供调度器使用)
470
- function getEnabledChannels() {
471
- const data = loadChannels();
472
- return data.channels.filter(c => c.enabled !== false);
473
- }
474
-
475
- // 保存渠道顺序
476
- function saveChannelOrder(order) {
477
- const data = loadChannels();
478
-
479
- // 按照给定的顺序重新排列
480
- const orderedChannels = [];
481
- for (const id of order) {
482
- const channel = data.channels.find(c => c.id === id);
483
- if (channel) {
484
- orderedChannels.push(channel);
485
- }
486
- }
487
-
488
- // 添加不在顺序中的渠道(新添加的)
489
- for (const channel of data.channels) {
490
- if (!orderedChannels.find(c => c.id === channel.id)) {
491
- orderedChannels.push(channel);
492
- }
195
+ writeAnnotatedCodexConfig(configPath, config, [
196
+ '# Codex Configuration',
197
+ '# Managed by Coding-Tool',
198
+ `# Current provider: ${channel.name}`
199
+ ]);
200
+ console.log(`[Codex Channels] Applied channel ${channel.name} to config.toml`);
201
+ syncAllChannelEnvVars();
493
202
  }
494
-
495
- data.channels = orderedChannels;
496
- saveChannels(data);
497
203
  }
498
204
 
499
- /**
500
- * 同步所有渠道的环境变量到 shell 配置文件
501
- * 确保用户可以直接使用 codex 命令而无需手动设置环境变量
502
- * 这个函数会在服务启动时自动调用
503
- */
504
- function syncAllChannelEnvVars(channels = null) {
505
- const data = channels ? { channels } : loadChannels();
506
- return syncCodexUserEnvironment(
507
- buildManagedCodexEnvMap(data.channels || [], { includeProxyKey: isProxyConfig() }),
508
- { replace: true }
509
- );
205
+ // ── 单例 + 兼容导出 ──
206
+
207
+ let _instance = null;
208
+ function getServiceInstance() {
209
+ if (!_instance) _instance = new CodexChannelService();
210
+ return _instance;
510
211
  }
511
212
 
512
- /**
513
- * 将指定渠道应用到 Codex 配置文件
514
- * 类似 Claude 的"写入配置"功能,将渠道设置为当前激活的 provider
515
- *
516
- * @param {string} channelId - 渠道 ID
517
- * @param {Object} options - 可选参数
518
- * @param {boolean} options.pruneProviders - 是否清理 model_providers 仅保留当前渠道
519
- * @returns {Object} 应用结果
520
- */
521
- function applyChannelToSettings(channelId, options = {}) {
522
- const data = loadChannels();
523
- const channel = data.channels.find(c => c.id === channelId);
524
-
525
- if (!channel) {
526
- throw new Error('Channel not found');
527
- }
213
+ const service = getServiceInstance();
528
214
 
529
- // In single-channel mode, only this channel should be enabled
530
- data.channels.forEach(ch => {
531
- ch.enabled = ch.id === channelId;
215
+ function getChannels() { return service.getChannels(); }
216
+ function getEnabledChannels() { return service.getEnabledChannels(); }
217
+ function createChannel(name, providerKey, baseUrl, apiKey, wireApi, extraConfig = {}) {
218
+ const envKey = extraConfig.envKey || `${providerKey.toUpperCase()}_API_KEY`;
219
+ return service.createChannel({
220
+ name, providerKey, baseUrl, apiKey, wireApi,
221
+ envKey,
222
+ ...extraConfig,
532
223
  });
533
- saveChannels(data);
534
- clearNativeOAuth('codex');
535
-
536
- const codexDir = getCodexDir();
537
-
538
- if (!fs.existsSync(codexDir)) {
539
- fs.mkdirSync(codexDir, { recursive: true });
540
- }
541
-
542
- const configPath = path.join(codexDir, 'config.toml');
543
-
544
- // 读取现有配置,保留 mcp_servers, projects 等
545
- let config = readCodexConfigOrThrow(configPath, getDefaultCodexConfig());
546
-
547
- // 设置当前渠道为 model_provider
548
- config.model_provider = channel.providerKey;
549
-
550
- // 可选:清理 provider,关闭动态切换后只保留当前渠道配置
551
- if (options.pruneProviders === true) {
552
- const existingProviders = (config.model_providers && typeof config.model_providers === 'object')
553
- ? config.model_providers
554
- : {};
555
- config.model_providers = pruneManagedProviders(existingProviders, channel.providerKey, data.channels);
556
- } else if (!config.model_providers || typeof config.model_providers !== 'object') {
557
- // 默认兼容历史行为:保留已有 provider
558
- config.model_providers = {};
559
- }
560
-
561
- // 添加/更新当前渠道的 provider 配置
562
- config.model_providers[channel.providerKey] = {
563
- name: channel.name,
564
- base_url: channel.baseUrl,
565
- wire_api: channel.wireApi || 'responses',
566
- env_key: channel.envKey,
567
- requires_openai_auth: channel.requiresOpenaiAuth !== false
568
- };
569
-
570
- // 添加额外查询参数(如 Azure 的 api-version)
571
- if (channel.queryParams && Object.keys(channel.queryParams).length > 0) {
572
- config.model_providers[channel.providerKey].query_params = channel.queryParams;
573
- }
574
-
575
- writeAnnotatedCodexConfig(configPath, config, [
576
- '# Codex Configuration',
577
- '# Managed by Coding-Tool',
578
- `# Current provider: ${channel.name}`
579
- ]);
580
- console.log(`[Codex Channels] Applied channel ${channel.name} to config.toml`);
581
- syncAllChannelEnvVars();
582
-
583
- return channel;
584
224
  }
225
+ function updateChannel(id, updates) { return service.updateChannel(id, updates); }
226
+ function deleteChannel(id) { return service.deleteChannel(id); }
227
+ function saveChannelOrder(order) { return service.saveChannelOrder(order); }
228
+ function applyChannelToSettings(id) { return service.applyChannelToSettings(id); }
229
+ function getEffectiveApiKey(channel) { return service.getEffectiveApiKey(channel); }
230
+ function disableAllChannels() { return service.disableAllChannels(); }
585
231
 
586
- // 服务启动时自动同步环境变量(静默执行,不影响其他功能)
232
+ // 服务启动时自动同步环境变量
587
233
  try {
588
- const data = loadChannels();
234
+ const data = service.loadChannels();
589
235
  if (data.channels && data.channels.length > 0) {
590
236
  syncAllChannelEnvVars();
591
237
  }
592
238
  } catch (err) {
593
- // 静默失败,不影响模块加载
594
239
  console.warn('[Codex Channels] Auto sync env vars failed:', err.message);
595
240
  }
596
241
 
597
- function getEffectiveApiKey(channel) {
598
- return channel.apiKey || null;
599
- }
600
-
601
- function disableAllChannels() {
602
- const data = loadChannels();
603
- data.channels.forEach(ch => { ch.enabled = false; });
604
- saveChannels(data);
605
- }
606
-
607
242
  module.exports = {
608
243
  getChannels,
609
244
  createChannel,
@@ -615,5 +250,5 @@ module.exports = {
615
250
  writeCodexConfigForMultiChannel,
616
251
  applyChannelToSettings,
617
252
  getEffectiveApiKey,
618
- disableAllChannels
253
+ disableAllChannels,
619
254
  };