coding-tool-x 3.2.0

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 (185) hide show
  1. package/CHANGELOG.md +599 -0
  2. package/LICENSE +21 -0
  3. package/README.md +439 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
  6. package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
  7. package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
  8. package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
  9. package/dist/web/assets/Home-38JTUlYt.js +1 -0
  10. package/dist/web/assets/Home-CjupSEWE.css +1 -0
  11. package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
  12. package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
  13. package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
  14. package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
  15. package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
  16. package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
  17. package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
  18. package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
  19. package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
  20. package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
  21. package/dist/web/assets/icons-DRrXwWZi.js +1 -0
  22. package/dist/web/assets/index-CetESrXw.css +1 -0
  23. package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
  24. package/dist/web/assets/markdown-BfC0goYb.css +10 -0
  25. package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
  26. package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
  27. package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
  28. package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
  29. package/dist/web/favicon.ico +0 -0
  30. package/dist/web/index.html +20 -0
  31. package/dist/web/logo.png +0 -0
  32. package/docs/bannel.png +0 -0
  33. package/docs/home.png +0 -0
  34. package/docs/logo.png +0 -0
  35. package/docs/model-redirection.md +251 -0
  36. package/docs/multi-channel-load-balancing.md +249 -0
  37. package/package.json +80 -0
  38. package/src/commands/channels.js +551 -0
  39. package/src/commands/cli-type.js +101 -0
  40. package/src/commands/daemon.js +365 -0
  41. package/src/commands/doctor.js +333 -0
  42. package/src/commands/export-config.js +205 -0
  43. package/src/commands/list.js +222 -0
  44. package/src/commands/logs.js +261 -0
  45. package/src/commands/plugin.js +585 -0
  46. package/src/commands/port-config.js +135 -0
  47. package/src/commands/proxy-control.js +264 -0
  48. package/src/commands/proxy.js +152 -0
  49. package/src/commands/resume.js +137 -0
  50. package/src/commands/search.js +190 -0
  51. package/src/commands/security.js +37 -0
  52. package/src/commands/stats.js +398 -0
  53. package/src/commands/switch.js +48 -0
  54. package/src/commands/toggle-proxy.js +247 -0
  55. package/src/commands/ui.js +99 -0
  56. package/src/commands/update.js +97 -0
  57. package/src/commands/workspace.js +454 -0
  58. package/src/config/default.js +69 -0
  59. package/src/config/loader.js +149 -0
  60. package/src/config/model-metadata.js +167 -0
  61. package/src/config/model-metadata.json +125 -0
  62. package/src/config/model-pricing.js +35 -0
  63. package/src/config/paths.js +190 -0
  64. package/src/index.js +680 -0
  65. package/src/plugins/constants.js +15 -0
  66. package/src/plugins/event-bus.js +54 -0
  67. package/src/plugins/manifest-validator.js +129 -0
  68. package/src/plugins/plugin-api.js +128 -0
  69. package/src/plugins/plugin-installer.js +601 -0
  70. package/src/plugins/plugin-loader.js +229 -0
  71. package/src/plugins/plugin-manager.js +170 -0
  72. package/src/plugins/registry.js +152 -0
  73. package/src/plugins/schema/plugin-manifest.json +115 -0
  74. package/src/reset-config.js +94 -0
  75. package/src/server/api/agents.js +826 -0
  76. package/src/server/api/aliases.js +36 -0
  77. package/src/server/api/channels.js +368 -0
  78. package/src/server/api/claude-hooks.js +480 -0
  79. package/src/server/api/codex-channels.js +417 -0
  80. package/src/server/api/codex-projects.js +104 -0
  81. package/src/server/api/codex-proxy.js +195 -0
  82. package/src/server/api/codex-sessions.js +483 -0
  83. package/src/server/api/codex-statistics.js +57 -0
  84. package/src/server/api/commands.js +482 -0
  85. package/src/server/api/config-export.js +212 -0
  86. package/src/server/api/config-registry.js +357 -0
  87. package/src/server/api/config-sync.js +155 -0
  88. package/src/server/api/config-templates.js +248 -0
  89. package/src/server/api/config.js +521 -0
  90. package/src/server/api/convert.js +260 -0
  91. package/src/server/api/dashboard.js +142 -0
  92. package/src/server/api/env.js +144 -0
  93. package/src/server/api/favorites.js +77 -0
  94. package/src/server/api/gemini-channels.js +366 -0
  95. package/src/server/api/gemini-projects.js +91 -0
  96. package/src/server/api/gemini-proxy.js +173 -0
  97. package/src/server/api/gemini-sessions.js +376 -0
  98. package/src/server/api/gemini-statistics.js +57 -0
  99. package/src/server/api/health-check.js +31 -0
  100. package/src/server/api/mcp.js +399 -0
  101. package/src/server/api/opencode-channels.js +419 -0
  102. package/src/server/api/opencode-projects.js +99 -0
  103. package/src/server/api/opencode-proxy.js +207 -0
  104. package/src/server/api/opencode-sessions.js +327 -0
  105. package/src/server/api/opencode-statistics.js +57 -0
  106. package/src/server/api/plugins.js +463 -0
  107. package/src/server/api/pm2-autostart.js +269 -0
  108. package/src/server/api/projects.js +124 -0
  109. package/src/server/api/prompts.js +279 -0
  110. package/src/server/api/proxy.js +306 -0
  111. package/src/server/api/security.js +53 -0
  112. package/src/server/api/sessions.js +514 -0
  113. package/src/server/api/settings.js +142 -0
  114. package/src/server/api/skills.js +570 -0
  115. package/src/server/api/statistics.js +238 -0
  116. package/src/server/api/ui-config.js +64 -0
  117. package/src/server/api/workspaces.js +456 -0
  118. package/src/server/codex-proxy-server.js +681 -0
  119. package/src/server/dev-server.js +26 -0
  120. package/src/server/gemini-proxy-server.js +610 -0
  121. package/src/server/index.js +422 -0
  122. package/src/server/opencode-proxy-server.js +4771 -0
  123. package/src/server/proxy-server.js +669 -0
  124. package/src/server/services/agents-service.js +1137 -0
  125. package/src/server/services/alias.js +71 -0
  126. package/src/server/services/channel-health.js +234 -0
  127. package/src/server/services/channel-scheduler.js +240 -0
  128. package/src/server/services/channels.js +447 -0
  129. package/src/server/services/codex-channels.js +705 -0
  130. package/src/server/services/codex-config.js +90 -0
  131. package/src/server/services/codex-parser.js +322 -0
  132. package/src/server/services/codex-sessions.js +936 -0
  133. package/src/server/services/codex-settings-manager.js +619 -0
  134. package/src/server/services/codex-speed-test-template.json +24 -0
  135. package/src/server/services/codex-statistics-service.js +161 -0
  136. package/src/server/services/commands-service.js +574 -0
  137. package/src/server/services/config-export-service.js +1165 -0
  138. package/src/server/services/config-registry-service.js +828 -0
  139. package/src/server/services/config-sync-manager.js +941 -0
  140. package/src/server/services/config-sync-service.js +504 -0
  141. package/src/server/services/config-templates-service.js +913 -0
  142. package/src/server/services/enhanced-cache.js +196 -0
  143. package/src/server/services/env-checker.js +409 -0
  144. package/src/server/services/env-manager.js +436 -0
  145. package/src/server/services/favorites.js +165 -0
  146. package/src/server/services/format-converter.js +620 -0
  147. package/src/server/services/gemini-channels.js +459 -0
  148. package/src/server/services/gemini-config.js +73 -0
  149. package/src/server/services/gemini-sessions.js +689 -0
  150. package/src/server/services/gemini-settings-manager.js +263 -0
  151. package/src/server/services/gemini-statistics-service.js +157 -0
  152. package/src/server/services/health-check.js +85 -0
  153. package/src/server/services/mcp-client.js +790 -0
  154. package/src/server/services/mcp-service.js +1732 -0
  155. package/src/server/services/model-detector.js +1245 -0
  156. package/src/server/services/network-access.js +80 -0
  157. package/src/server/services/opencode-channels.js +366 -0
  158. package/src/server/services/opencode-gateway-adapters.js +1168 -0
  159. package/src/server/services/opencode-gateway-converter.js +639 -0
  160. package/src/server/services/opencode-sessions.js +931 -0
  161. package/src/server/services/opencode-settings-manager.js +478 -0
  162. package/src/server/services/opencode-statistics-service.js +161 -0
  163. package/src/server/services/plugins-service.js +1268 -0
  164. package/src/server/services/prompts-service.js +534 -0
  165. package/src/server/services/proxy-runtime.js +79 -0
  166. package/src/server/services/repo-scanner-base.js +708 -0
  167. package/src/server/services/request-logger.js +130 -0
  168. package/src/server/services/response-decoder.js +21 -0
  169. package/src/server/services/security-config.js +131 -0
  170. package/src/server/services/session-cache.js +127 -0
  171. package/src/server/services/session-converter.js +577 -0
  172. package/src/server/services/sessions.js +900 -0
  173. package/src/server/services/settings-manager.js +163 -0
  174. package/src/server/services/skill-service.js +1482 -0
  175. package/src/server/services/speed-test.js +1146 -0
  176. package/src/server/services/statistics-service.js +1043 -0
  177. package/src/server/services/ui-config.js +132 -0
  178. package/src/server/services/workspace-service.js +830 -0
  179. package/src/server/utils/pricing.js +73 -0
  180. package/src/server/websocket-server.js +513 -0
  181. package/src/ui/menu.js +139 -0
  182. package/src/ui/prompts.js +100 -0
  183. package/src/utils/format.js +43 -0
  184. package/src/utils/port-helper.js +108 -0
  185. package/src/utils/session.js +240 -0
@@ -0,0 +1,71 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { PATHS } = require('../../config/paths');
4
+
5
+ const ALIAS_DIR = PATHS.base;
6
+ const ALIAS_FILE = PATHS.aliases;
7
+
8
+ // Ensure alias directory exists
9
+ function ensureAliasDir() {
10
+ if (!fs.existsSync(ALIAS_DIR)) {
11
+ fs.mkdirSync(ALIAS_DIR, { recursive: true });
12
+ }
13
+ }
14
+
15
+ // Load all aliases
16
+ function loadAliases() {
17
+ ensureAliasDir();
18
+
19
+ if (!fs.existsSync(ALIAS_FILE)) {
20
+ return {};
21
+ }
22
+
23
+ try {
24
+ const content = fs.readFileSync(ALIAS_FILE, 'utf8');
25
+ return JSON.parse(content);
26
+ } catch (error) {
27
+ console.error('Error loading aliases:', error);
28
+ return {};
29
+ }
30
+ }
31
+
32
+ // Save aliases
33
+ function saveAliases(aliases) {
34
+ ensureAliasDir();
35
+
36
+ try {
37
+ fs.writeFileSync(ALIAS_FILE, JSON.stringify(aliases, null, 2), 'utf8');
38
+ } catch (error) {
39
+ console.error('Error saving aliases:', error);
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ // Set alias for a session
45
+ function setAlias(sessionId, alias) {
46
+ const aliases = loadAliases();
47
+ aliases[sessionId] = alias;
48
+ saveAliases(aliases);
49
+ return aliases;
50
+ }
51
+
52
+ // Delete alias
53
+ function deleteAlias(sessionId) {
54
+ const aliases = loadAliases();
55
+ delete aliases[sessionId];
56
+ saveAliases(aliases);
57
+ return aliases;
58
+ }
59
+
60
+ // Get alias for a session
61
+ function getAlias(sessionId) {
62
+ const aliases = loadAliases();
63
+ return aliases[sessionId] || null;
64
+ }
65
+
66
+ module.exports = {
67
+ loadAliases,
68
+ setAlias,
69
+ deleteAlias,
70
+ getAlias
71
+ };
@@ -0,0 +1,234 @@
1
+ // 渠道健康检查和智能切换模块
2
+
3
+ const healthConfig = {
4
+ // 故障检测
5
+ failureThreshold: 3, // 连续失败3次触发冻结
6
+
7
+ // 冻结时间配置
8
+ initialFreezeTime: 60 * 1000, // 初始冻结1分钟
9
+ maxFreezeTime: 30 * 60 * 1000, // 最大冻结30分钟
10
+ freezeMultiplier: 2, // 冻结时间倍增
11
+
12
+ // 健康检测
13
+ healthCheckWindow: 5, // 健康检测需要连续5次成功
14
+ };
15
+
16
+ // 渠道健康状态
17
+ const channelHealth = new Map(); // `${source}:${channelId}` → health info
18
+
19
+ // 冻结回调(用于通知调度器解绑会话)
20
+ let onChannelFrozenCallback = null;
21
+
22
+ /**
23
+ * 设置渠道冻结时的回调
24
+ */
25
+ function setOnChannelFrozen(callback) {
26
+ onChannelFrozenCallback = callback;
27
+ }
28
+
29
+ /**
30
+ * 初始化渠道健康信息
31
+ */
32
+ function makeKey(source, channelId) {
33
+ return `${source || 'claude'}:${channelId}`;
34
+ }
35
+
36
+ function initChannelHealth(channelId, source = 'claude') {
37
+ const key = makeKey(source, channelId);
38
+ if (!channelHealth.has(key)) {
39
+ channelHealth.set(key, {
40
+ status: 'healthy', // healthy, frozen, checking
41
+ consecutiveFailures: 0, // 连续失败次数
42
+ consecutiveSuccesses: 0, // 连续成功次数
43
+ totalFailures: 0, // 总失败次数
44
+ totalSuccesses: 0, // 总成功次数
45
+ freezeUntil: 0, // 冻结到期时间
46
+ nextFreezeTime: healthConfig.initialFreezeTime,
47
+ lastCheckTime: null, // 最后检查时间
48
+ source
49
+ });
50
+ }
51
+ return channelHealth.get(key);
52
+ }
53
+
54
+ /**
55
+ * 记录成功请求
56
+ */
57
+ function recordSuccess(channelId, source = 'claude') {
58
+ const health = initChannelHealth(channelId, source);
59
+ const now = Date.now();
60
+
61
+ health.totalSuccesses++;
62
+ health.consecutiveSuccesses++;
63
+ health.consecutiveFailures = 0;
64
+ health.lastCheckTime = now;
65
+
66
+ // 如果在检测中状态,检查是否可以恢复
67
+ if (health.status === 'checking') {
68
+ if (health.consecutiveSuccesses >= healthConfig.healthCheckWindow) {
69
+ // 恢复健康状态
70
+ health.status = 'healthy';
71
+ health.nextFreezeTime = healthConfig.initialFreezeTime; // 重置冻结时间
72
+ console.log(`[ChannelHealth] Channel ${channelId} recovered and marked as healthy`);
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 记录失败请求
79
+ */
80
+ function recordFailure(channelId, source = 'claude', error) {
81
+ const health = initChannelHealth(channelId, source);
82
+ const now = Date.now();
83
+
84
+ health.totalFailures++;
85
+ health.consecutiveFailures++;
86
+ health.consecutiveSuccesses = 0;
87
+ health.lastCheckTime = now;
88
+
89
+ // 如果当前是健康状态或检测中状态,检查是否需要冻结
90
+ if (health.status === 'healthy' || health.status === 'checking') {
91
+ if (health.consecutiveFailures >= healthConfig.failureThreshold) {
92
+ // 触发冻结
93
+ const previousStatus = health.status;
94
+ health.status = 'frozen';
95
+ health.freezeUntil = now + health.nextFreezeTime;
96
+
97
+ const freezeMinutes = Math.round(health.nextFreezeTime / 60000);
98
+ console.warn(`[ChannelHealth] Channel ${channelId} frozen due to ${health.consecutiveFailures} consecutive failures (was ${previousStatus}). Frozen for ${freezeMinutes} minutes`);
99
+
100
+ // 更新下次冻结时间(翻倍,不超过最大值)
101
+ health.nextFreezeTime = Math.min(
102
+ health.nextFreezeTime * healthConfig.freezeMultiplier,
103
+ healthConfig.maxFreezeTime
104
+ );
105
+
106
+ // 触发冻结回调(通知调度器解绑会话)
107
+ if (onChannelFrozenCallback) {
108
+ onChannelFrozenCallback(source || 'claude', channelId);
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 检查渠道是否可用
116
+ */
117
+ function isChannelAvailable(channelId, source = 'claude') {
118
+ const key = makeKey(source, channelId);
119
+ const health = channelHealth.get(key);
120
+ if (!health) return true;
121
+
122
+ const now = Date.now();
123
+
124
+ switch (health.status) {
125
+ case 'healthy':
126
+ return true;
127
+
128
+ case 'frozen':
129
+ // 检查冻结时间是否到期
130
+ if (now >= health.freezeUntil) {
131
+ // 进入检测状态
132
+ health.status = 'checking';
133
+ health.consecutiveSuccesses = 0;
134
+ console.log(`[ChannelHealth] Channel ${channelId} freeze expired, entering checking mode`);
135
+ return true; // 允许一个请求用于健康检测
136
+ }
137
+ return false;
138
+
139
+ case 'checking':
140
+ // 在检测中的渠道可用,等待成功记录
141
+ return true;
142
+
143
+ default:
144
+ return true;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * 从渠道列表中过滤出可用的渠道
150
+ */
151
+ function getAvailableChannels(channels, source = 'claude') {
152
+ return channels.filter(channel => isChannelAvailable(channel.id, source));
153
+ }
154
+
155
+ /**
156
+ * 获取渠道健康状态(用于前端显示)
157
+ */
158
+ function getChannelHealthStatus(channelId, source = 'claude') {
159
+ const key = makeKey(source, channelId);
160
+ const health = channelHealth.get(key);
161
+ if (!health) {
162
+ return {
163
+ status: 'healthy',
164
+ statusText: '健康',
165
+ statusColor: '#18a058',
166
+ consecutiveFailures: 0,
167
+ consecutiveSuccesses: 0,
168
+ totalFailures: 0,
169
+ totalSuccesses: 0,
170
+ freezeUntil: null,
171
+ freezeRemaining: 0,
172
+ };
173
+ }
174
+
175
+ const now = Date.now();
176
+ const freezeRemaining = Math.max(0, health.freezeUntil - now);
177
+
178
+ const statusMap = {
179
+ 'healthy': { text: '健康', color: '#18a058' },
180
+ 'frozen': { text: '冻结', color: '#d03050' },
181
+ 'checking': { text: '检测中', color: '#f0a020' }
182
+ };
183
+
184
+ return {
185
+ status: health.status,
186
+ statusText: statusMap[health.status]?.text || '未知',
187
+ statusColor: statusMap[health.status]?.color || '#909399',
188
+ consecutiveFailures: health.consecutiveFailures,
189
+ consecutiveSuccesses: health.consecutiveSuccesses,
190
+ totalFailures: health.totalFailures,
191
+ totalSuccesses: health.totalSuccesses,
192
+ freezeUntil: health.freezeUntil,
193
+ freezeRemaining: Math.ceil(freezeRemaining / 1000), // 剩余秒数
194
+ };
195
+ }
196
+
197
+ /**
198
+ * 获取所有渠道的健康状态
199
+ */
200
+ function getAllChannelHealthStatus(source = 'claude') {
201
+ const result = {};
202
+ for (const [key] of channelHealth) {
203
+ const [keySource, channelId] = key.split(':');
204
+ if (keySource === (source || 'claude')) {
205
+ result[channelId] = getChannelHealthStatus(channelId, keySource);
206
+ }
207
+ }
208
+ return result;
209
+ }
210
+
211
+ /**
212
+ * 手动重置渠道健康状态(用于测试或管理员操作)
213
+ */
214
+ function resetChannelHealth(channelId, source = 'claude') {
215
+ const health = initChannelHealth(channelId, source);
216
+ health.status = 'healthy';
217
+ health.consecutiveFailures = 0;
218
+ health.consecutiveSuccesses = 0;
219
+ health.freezeUntil = 0;
220
+ health.nextFreezeTime = healthConfig.initialFreezeTime;
221
+ console.log(`[ChannelHealth] Channel ${channelId} health status reset`);
222
+ }
223
+
224
+ module.exports = {
225
+ recordSuccess,
226
+ recordFailure,
227
+ isChannelAvailable,
228
+ getAvailableChannels,
229
+ getChannelHealthStatus,
230
+ getAllChannelHealthStatus,
231
+ resetChannelHealth,
232
+ setOnChannelFrozen,
233
+ healthConfig,
234
+ };
@@ -0,0 +1,240 @@
1
+ const { getAllChannels } = require('./channels');
2
+ const { getChannels: getCodexChannels } = require('./codex-channels');
3
+ const { getChannels: getGeminiChannels } = require('./gemini-channels');
4
+ const { getChannels: getOpenCodeChannels } = require('./opencode-channels');
5
+ const { isChannelAvailable, getChannelHealthStatus, setOnChannelFrozen } = require('./channel-health');
6
+
7
+ const channelProviders = {
8
+ claude: () => getAllChannels(),
9
+ codex: () => {
10
+ const data = getCodexChannels();
11
+ return Array.isArray(data?.channels) ? data.channels : [];
12
+ },
13
+ gemini: () => {
14
+ const data = getGeminiChannels();
15
+ return Array.isArray(data?.channels) ? data.channels : [];
16
+ },
17
+ opencode: () => {
18
+ const data = getOpenCodeChannels();
19
+ return Array.isArray(data?.channels) ? data.channels : [];
20
+ }
21
+ };
22
+
23
+ function createState() {
24
+ return {
25
+ channels: [],
26
+ inflight: new Map(),
27
+ sessionBindings: new Map(),
28
+ queue: []
29
+ };
30
+ }
31
+
32
+ const schedulerStates = {
33
+ claude: createState(),
34
+ codex: createState(),
35
+ gemini: createState(),
36
+ opencode: createState()
37
+ };
38
+
39
+ function getState(source = 'claude') {
40
+ if (!schedulerStates[source]) {
41
+ schedulerStates[source] = createState();
42
+ }
43
+ return schedulerStates[source];
44
+ }
45
+
46
+ const WAIT_TIMEOUT_MS = 15000;
47
+
48
+ /**
49
+ * 解绑指定渠道的所有会话
50
+ */
51
+ function unbindChannelSessions(source, channelId) {
52
+ const state = getState(source);
53
+ let unbindCount = 0;
54
+ for (const [sessionId, boundChannelId] of state.sessionBindings) {
55
+ if (boundChannelId === channelId) {
56
+ state.sessionBindings.delete(sessionId);
57
+ unbindCount++;
58
+ }
59
+ }
60
+ if (unbindCount > 0) {
61
+ console.log(`[ChannelScheduler] Unbound ${unbindCount} sessions from ${source} channel ${channelId}`);
62
+ }
63
+ }
64
+
65
+ // 注册冻结回调,当渠道被冻结时解绑其会话
66
+ setOnChannelFrozen(unbindChannelSessions);
67
+
68
+ function refreshChannels(source = 'claude') {
69
+ const state = getState(source);
70
+ const provider = channelProviders[source];
71
+ if (!provider) return;
72
+
73
+ // 每次直接读取最新配置,不做缓存
74
+ const raw = provider();
75
+ state.channels = raw
76
+ .filter(ch => ch.enabled !== false)
77
+ .map(ch => ({
78
+ // 保留渠道完整字段,避免 proxy 等运行时配置在调度层丢失
79
+ ...ch,
80
+ weight: Math.max(1, Number(ch.weight) || 1),
81
+ maxConcurrency: ch.maxConcurrency ?? null,
82
+ modelConfig: ch.modelConfig || null,
83
+ modelRedirects: ch.modelRedirects || []
84
+ }));
85
+
86
+ state.channels.forEach(ch => {
87
+ if (!state.inflight.has(ch.id)) {
88
+ state.inflight.set(ch.id, 0);
89
+ }
90
+ });
91
+ }
92
+
93
+ function getAvailableChannels(source = 'claude') {
94
+ refreshChannels(source);
95
+ const state = getState(source);
96
+ return state.channels.filter(ch => {
97
+ if (!isChannelAvailable(ch.id, source)) {
98
+ return false;
99
+ }
100
+ if (ch.maxConcurrency === null) {
101
+ return true;
102
+ }
103
+ return (state.inflight.get(ch.id) || 0) < ch.maxConcurrency;
104
+ });
105
+ }
106
+
107
+ function pickWeightedChannel(channels) {
108
+ if (!channels.length) return null;
109
+ const totalWeight = channels.reduce((sum, ch) => sum + ch.weight, 0);
110
+ let threshold = Math.random() * totalWeight;
111
+ for (const channel of channels) {
112
+ threshold -= channel.weight;
113
+ if (threshold <= 0) {
114
+ return channel;
115
+ }
116
+ }
117
+ return channels[channels.length - 1];
118
+ }
119
+
120
+ function tryAllocate(source = 'claude', options = {}) {
121
+ const state = getState(source);
122
+ const sessionId = options.sessionId;
123
+ const enableSessionBinding = options.enableSessionBinding !== false; // 默认开启
124
+ const available = getAvailableChannels(source);
125
+ if (!available.length) {
126
+ return null;
127
+ }
128
+
129
+ // 如果启用会话绑定且已有绑定,优先使用绑定渠道
130
+ if (enableSessionBinding && sessionId && state.sessionBindings.has(sessionId)) {
131
+ const boundId = state.sessionBindings.get(sessionId);
132
+ const boundChannel = available.find(ch => ch.id === boundId);
133
+ if (boundChannel) {
134
+ state.inflight.set(boundChannel.id, (state.inflight.get(boundChannel.id) || 0) + 1);
135
+ return boundChannel;
136
+ }
137
+ }
138
+
139
+ // 选择新的渠道(加权随机)
140
+ const chosen = pickWeightedChannel(available);
141
+ if (!chosen) return null;
142
+
143
+ // 只有在启用会话绑定时才记录绑定
144
+ if (enableSessionBinding && sessionId) {
145
+ state.sessionBindings.set(sessionId, chosen.id);
146
+ }
147
+ state.inflight.set(chosen.id, (state.inflight.get(chosen.id) || 0) + 1);
148
+ return chosen;
149
+ }
150
+
151
+ function drainQueue(source = 'claude') {
152
+ const state = getState(source);
153
+ if (!state.queue.length) return;
154
+
155
+ for (let i = 0; i < state.queue.length; i++) {
156
+ const entry = state.queue[i];
157
+ const channel = tryAllocate(source, entry.options);
158
+ if (channel) {
159
+ clearTimeout(entry.timer);
160
+ state.queue.splice(i, 1);
161
+ entry.resolve(channel);
162
+ return drainQueue(source);
163
+ }
164
+ }
165
+ }
166
+
167
+ function allocateChannel(options = {}) {
168
+ const source = options.source || 'claude';
169
+ const state = getState(source);
170
+ const channel = tryAllocate(source, options);
171
+ if (channel) {
172
+ return Promise.resolve(channel);
173
+ }
174
+
175
+ if (!state.channels.length) {
176
+ return Promise.reject(new Error('暂无可用渠道,请先添加并启用至少一个渠道'));
177
+ }
178
+
179
+ // 检查是否所有渠道都被冻结
180
+ const allFrozen = state.channels.every(ch => !isChannelAvailable(ch.id, source));
181
+
182
+ return new Promise((resolve, reject) => {
183
+ const timer = setTimeout(() => {
184
+ const index = state.queue.findIndex(item => item.timer === timer);
185
+ if (index !== -1) {
186
+ state.queue.splice(index, 1);
187
+ }
188
+ // 根据实际情况返回更准确的错误信息
189
+ if (allFrozen) {
190
+ reject(new Error('所有渠道均已被冻结,请等待健康检查恢复或手动重置'));
191
+ } else {
192
+ reject(new Error('所有渠道均已达到并发上限,请稍后重试'));
193
+ }
194
+ }, WAIT_TIMEOUT_MS);
195
+
196
+ state.queue.push({
197
+ source,
198
+ options,
199
+ resolve,
200
+ reject,
201
+ timer
202
+ });
203
+ });
204
+ }
205
+
206
+ function releaseChannel(channelId, source = 'claude') {
207
+ const state = getState(source);
208
+ if (!channelId) return;
209
+ if (!state.inflight.has(channelId)) {
210
+ state.inflight.set(channelId, 0);
211
+ }
212
+ const current = state.inflight.get(channelId) || 0;
213
+ state.inflight.set(channelId, current > 0 ? current - 1 : 0);
214
+ drainQueue(source);
215
+ }
216
+
217
+ function getSchedulerState(source = 'claude') {
218
+ refreshChannels(source);
219
+ const state = getState(source);
220
+ return {
221
+ channels: state.channels.map(ch => {
222
+ const healthStatus = getChannelHealthStatus(ch.id, source);
223
+ return {
224
+ id: ch.id,
225
+ name: ch.name,
226
+ weight: ch.weight,
227
+ maxConcurrency: ch.maxConcurrency,
228
+ inflight: state.inflight.get(ch.id) || 0,
229
+ health: healthStatus
230
+ };
231
+ }),
232
+ pending: state.queue.length
233
+ };
234
+ }
235
+
236
+ module.exports = {
237
+ allocateChannel,
238
+ releaseChannel,
239
+ getSchedulerState
240
+ };