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