@adversity/coding-tool-x 2.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 (125) hide show
  1. package/CHANGELOG.md +333 -0
  2. package/LICENSE +21 -0
  3. package/README.md +404 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/index-D1AYlFLZ.js +3220 -0
  6. package/dist/web/assets/index-aL3cKxSK.css +41 -0
  7. package/dist/web/favicon.ico +0 -0
  8. package/dist/web/index.html +14 -0
  9. package/dist/web/logo.png +0 -0
  10. package/docs/CHANGELOG.md +582 -0
  11. package/docs/DIRECTORY_MIGRATION.md +112 -0
  12. package/docs/PROJECT_STRUCTURE.md +396 -0
  13. package/docs/bannel.png +0 -0
  14. package/docs/home.png +0 -0
  15. package/docs/logo.png +0 -0
  16. package/docs/multi-channel-load-balancing.md +249 -0
  17. package/package.json +73 -0
  18. package/src/commands/channels.js +504 -0
  19. package/src/commands/cli-type.js +99 -0
  20. package/src/commands/daemon.js +286 -0
  21. package/src/commands/doctor.js +332 -0
  22. package/src/commands/list.js +222 -0
  23. package/src/commands/logs.js +259 -0
  24. package/src/commands/port-config.js +115 -0
  25. package/src/commands/proxy-control.js +258 -0
  26. package/src/commands/proxy.js +152 -0
  27. package/src/commands/resume.js +137 -0
  28. package/src/commands/search.js +190 -0
  29. package/src/commands/stats.js +224 -0
  30. package/src/commands/switch.js +48 -0
  31. package/src/commands/toggle-proxy.js +222 -0
  32. package/src/commands/ui.js +92 -0
  33. package/src/commands/workspace.js +454 -0
  34. package/src/config/default.js +40 -0
  35. package/src/config/loader.js +75 -0
  36. package/src/config/paths.js +121 -0
  37. package/src/index.js +373 -0
  38. package/src/reset-config.js +92 -0
  39. package/src/server/api/agents.js +248 -0
  40. package/src/server/api/aliases.js +36 -0
  41. package/src/server/api/channels.js +258 -0
  42. package/src/server/api/claude-hooks.js +480 -0
  43. package/src/server/api/codex-channels.js +312 -0
  44. package/src/server/api/codex-projects.js +91 -0
  45. package/src/server/api/codex-proxy.js +182 -0
  46. package/src/server/api/codex-sessions.js +491 -0
  47. package/src/server/api/codex-statistics.js +57 -0
  48. package/src/server/api/commands.js +245 -0
  49. package/src/server/api/config-templates.js +182 -0
  50. package/src/server/api/config.js +147 -0
  51. package/src/server/api/convert.js +127 -0
  52. package/src/server/api/dashboard.js +125 -0
  53. package/src/server/api/env.js +144 -0
  54. package/src/server/api/favorites.js +77 -0
  55. package/src/server/api/gemini-channels.js +261 -0
  56. package/src/server/api/gemini-projects.js +91 -0
  57. package/src/server/api/gemini-proxy.js +160 -0
  58. package/src/server/api/gemini-sessions.js +397 -0
  59. package/src/server/api/gemini-statistics.js +57 -0
  60. package/src/server/api/health-check.js +118 -0
  61. package/src/server/api/mcp.js +336 -0
  62. package/src/server/api/pm2-autostart.js +269 -0
  63. package/src/server/api/projects.js +124 -0
  64. package/src/server/api/prompts.js +279 -0
  65. package/src/server/api/proxy.js +235 -0
  66. package/src/server/api/rules.js +271 -0
  67. package/src/server/api/sessions.js +595 -0
  68. package/src/server/api/settings.js +61 -0
  69. package/src/server/api/skills.js +305 -0
  70. package/src/server/api/statistics.js +91 -0
  71. package/src/server/api/terminal.js +202 -0
  72. package/src/server/api/ui-config.js +64 -0
  73. package/src/server/api/workspaces.js +407 -0
  74. package/src/server/codex-proxy-server.js +538 -0
  75. package/src/server/dev-server.js +26 -0
  76. package/src/server/gemini-proxy-server.js +518 -0
  77. package/src/server/index.js +305 -0
  78. package/src/server/proxy-server.js +469 -0
  79. package/src/server/services/agents-service.js +354 -0
  80. package/src/server/services/alias.js +71 -0
  81. package/src/server/services/channel-health.js +234 -0
  82. package/src/server/services/channel-scheduler.js +234 -0
  83. package/src/server/services/channels.js +347 -0
  84. package/src/server/services/codex-channels.js +625 -0
  85. package/src/server/services/codex-config.js +90 -0
  86. package/src/server/services/codex-parser.js +322 -0
  87. package/src/server/services/codex-sessions.js +665 -0
  88. package/src/server/services/codex-settings-manager.js +397 -0
  89. package/src/server/services/codex-speed-test-template.json +24 -0
  90. package/src/server/services/codex-statistics-service.js +255 -0
  91. package/src/server/services/commands-service.js +360 -0
  92. package/src/server/services/config-templates-service.js +732 -0
  93. package/src/server/services/env-checker.js +307 -0
  94. package/src/server/services/env-manager.js +300 -0
  95. package/src/server/services/favorites.js +163 -0
  96. package/src/server/services/gemini-channels.js +333 -0
  97. package/src/server/services/gemini-config.js +73 -0
  98. package/src/server/services/gemini-sessions.js +689 -0
  99. package/src/server/services/gemini-settings-manager.js +263 -0
  100. package/src/server/services/gemini-statistics-service.js +253 -0
  101. package/src/server/services/health-check.js +399 -0
  102. package/src/server/services/mcp-service.js +1188 -0
  103. package/src/server/services/prompts-service.js +492 -0
  104. package/src/server/services/proxy-runtime.js +79 -0
  105. package/src/server/services/pty-manager.js +435 -0
  106. package/src/server/services/rules-service.js +401 -0
  107. package/src/server/services/session-cache.js +127 -0
  108. package/src/server/services/session-converter.js +577 -0
  109. package/src/server/services/sessions.js +757 -0
  110. package/src/server/services/settings-manager.js +163 -0
  111. package/src/server/services/skill-service.js +965 -0
  112. package/src/server/services/speed-test.js +545 -0
  113. package/src/server/services/statistics-service.js +386 -0
  114. package/src/server/services/terminal-commands.js +155 -0
  115. package/src/server/services/terminal-config.js +140 -0
  116. package/src/server/services/terminal-detector.js +306 -0
  117. package/src/server/services/ui-config.js +130 -0
  118. package/src/server/services/workspace-service.js +662 -0
  119. package/src/server/utils/pricing.js +41 -0
  120. package/src/server/websocket-server.js +557 -0
  121. package/src/ui/menu.js +129 -0
  122. package/src/ui/prompts.js +100 -0
  123. package/src/utils/format.js +43 -0
  124. package/src/utils/port-helper.js +94 -0
  125. package/src/utils/session.js +239 -0
@@ -0,0 +1,545 @@
1
+ /**
2
+ * 速度测试服务
3
+ * 用于测试渠道 API 的响应延迟
4
+ * 参考 cc-switch 的实现方式
5
+ */
6
+
7
+ const https = require('https');
8
+ const http = require('http');
9
+ const { URL } = require('url');
10
+ const path = require('path');
11
+ const fs = require('fs');
12
+
13
+ // 测试结果缓存
14
+ const testResultsCache = new Map();
15
+
16
+ // Codex 请求体模板文件路径
17
+ const CODEX_REQUEST_TEMPLATE_PATH = path.join(__dirname, 'codex-speed-test-template.json');
18
+
19
+ // 超时配置(毫秒)
20
+ const DEFAULT_TIMEOUT = 15000;
21
+ const MIN_TIMEOUT = 5000;
22
+ const MAX_TIMEOUT = 60000;
23
+
24
+ /**
25
+ * 规范化超时时间
26
+ */
27
+ function sanitizeTimeout(timeout) {
28
+ const ms = timeout || DEFAULT_TIMEOUT;
29
+ return Math.min(Math.max(ms, MIN_TIMEOUT), MAX_TIMEOUT);
30
+ }
31
+
32
+ /**
33
+ * 测试单个渠道的连接速度和 API 功能
34
+ * @param {Object} channel - 渠道配置
35
+ * @param {number} timeout - 超时时间(毫秒)
36
+ * @param {string} channelType - 渠道类型:'claude' | 'codex' | 'gemini'
37
+ * @returns {Promise<Object>} 测试结果
38
+ */
39
+ async function testChannelSpeed(channel, timeout = DEFAULT_TIMEOUT, channelType = 'claude') {
40
+ const sanitizedTimeout = sanitizeTimeout(timeout);
41
+
42
+ try {
43
+ if (!channel.baseUrl) {
44
+ return {
45
+ channelId: channel.id,
46
+ channelName: channel.name,
47
+ success: false,
48
+ networkOk: false,
49
+ apiOk: false,
50
+ error: 'URL 不能为空',
51
+ latency: null,
52
+ statusCode: null,
53
+ testedAt: Date.now()
54
+ };
55
+ }
56
+
57
+ // 规范化 URL(去除末尾斜杠)
58
+ let testUrl;
59
+ try {
60
+ const url = new URL(channel.baseUrl.trim());
61
+ testUrl = url.toString().replace(/\/+$/, '');
62
+ } catch (urlError) {
63
+ return {
64
+ channelId: channel.id,
65
+ channelName: channel.name,
66
+ success: false,
67
+ networkOk: false,
68
+ apiOk: false,
69
+ error: `URL 无效: ${urlError.message}`,
70
+ latency: null,
71
+ statusCode: null,
72
+ testedAt: Date.now()
73
+ };
74
+ }
75
+
76
+ // 直接测试 API 功能(发送测试消息)
77
+ // 不再单独测试网络连通性,因为直接 GET base_url 可能返回 404
78
+ const apiResult = await testAPIFunctionality(testUrl, channel.apiKey, sanitizedTimeout, channelType, channel.model);
79
+
80
+ const success = apiResult.success;
81
+ const networkOk = apiResult.latency !== null; // 如果有延迟数据,说明网络是通的
82
+
83
+ // 缓存结果
84
+ const finalResult = {
85
+ channelId: channel.id,
86
+ channelName: channel.name,
87
+ success,
88
+ networkOk,
89
+ apiOk: success,
90
+ statusCode: apiResult.statusCode || null,
91
+ error: success ? null : (apiResult.error || '测试失败'),
92
+ latency: apiResult.latency || null, // 无论成功失败都保留延迟数据
93
+ testedAt: Date.now()
94
+ };
95
+
96
+ testResultsCache.set(channel.id, finalResult);
97
+
98
+ return finalResult;
99
+ } catch (error) {
100
+ return {
101
+ channelId: channel.id,
102
+ channelName: channel.name,
103
+ success: false,
104
+ networkOk: false,
105
+ apiOk: false,
106
+ error: error.message || '连接失败',
107
+ latency: null,
108
+ statusCode: null,
109
+ testedAt: Date.now()
110
+ };
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 测试网络连通性(简单 GET 请求)
116
+ */
117
+ function testNetworkConnectivity(url, apiKey, timeout) {
118
+ return new Promise((resolve) => {
119
+ const startTime = Date.now();
120
+ const parsedUrl = new URL(url);
121
+ const isHttps = parsedUrl.protocol === 'https:';
122
+ const httpModule = isHttps ? https : http;
123
+
124
+ const options = {
125
+ hostname: parsedUrl.hostname,
126
+ port: parsedUrl.port || (isHttps ? 443 : 80),
127
+ path: parsedUrl.pathname + parsedUrl.search,
128
+ method: 'GET',
129
+ timeout,
130
+ headers: {
131
+ 'Authorization': `Bearer ${apiKey || ''}`,
132
+ 'Content-Type': 'application/json',
133
+ 'User-Agent': 'Coding-Tool-SpeedTest/1.0'
134
+ }
135
+ };
136
+
137
+ const req = httpModule.request(options, (res) => {
138
+ let data = '';
139
+ res.on('data', chunk => { data += chunk; });
140
+ res.on('end', () => {
141
+ const latency = Date.now() - startTime;
142
+ resolve({
143
+ statusCode: res.statusCode,
144
+ latency,
145
+ error: null
146
+ });
147
+ });
148
+ });
149
+
150
+ req.on('error', (error) => {
151
+ let errorMsg;
152
+ if (error.code === 'ECONNREFUSED') {
153
+ errorMsg = '连接被拒绝';
154
+ } else if (error.code === 'ETIMEDOUT') {
155
+ errorMsg = '连接超时';
156
+ } else if (error.code === 'ENOTFOUND') {
157
+ errorMsg = 'DNS 解析失败';
158
+ } else if (error.code === 'ECONNRESET') {
159
+ errorMsg = '连接被重置';
160
+ } else {
161
+ errorMsg = error.message || '连接失败';
162
+ }
163
+
164
+ resolve({
165
+ statusCode: null,
166
+ latency: null,
167
+ error: errorMsg
168
+ });
169
+ });
170
+
171
+ req.on('timeout', () => {
172
+ req.destroy();
173
+ resolve({
174
+ statusCode: null,
175
+ latency: null,
176
+ error: '请求超时'
177
+ });
178
+ });
179
+
180
+ req.end();
181
+ });
182
+ }
183
+
184
+ /**
185
+ * 测试 API 功能(发送真实的聊天请求)
186
+ * 根据渠道类型选择正确的 API 格式
187
+ * @param {string} baseUrl - 基础 URL
188
+ * @param {string} apiKey - API Key
189
+ * @param {number} timeout - 超时时间
190
+ * @param {string} channelType - 渠道类型:'claude' | 'codex' | 'gemini'
191
+ * @param {string} model - 模型名称(可选,用于 Gemini)
192
+ */
193
+ function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'claude', model = null) {
194
+ return new Promise((resolve) => {
195
+ const startTime = Date.now();
196
+ const parsedUrl = new URL(baseUrl);
197
+ const isHttps = parsedUrl.protocol === 'https:';
198
+ const httpModule = isHttps ? https : http;
199
+
200
+ // 根据渠道类型确定 API 路径和请求格式
201
+ let apiPath;
202
+ let requestBody;
203
+ let headers;
204
+
205
+ // Claude 渠道使用 Anthropic 格式
206
+ if (channelType === 'claude') {
207
+ // Anthropic Messages API - 模拟 Claude Code 请求格式
208
+ apiPath = parsedUrl.pathname.replace(/\/$/, '');
209
+ if (!apiPath.endsWith('/messages')) {
210
+ apiPath = apiPath + (apiPath.endsWith('/v1') ? '/messages' : '/v1/messages');
211
+ }
212
+ // 添加 ?beta=true 查询参数
213
+ apiPath += '?beta=true';
214
+
215
+ // 使用 Claude Code 的请求格式
216
+ // user_id 必须符合特定格式: user_xxx_account__session_xxx
217
+ // 使用 claude-sonnet-4 模型测试,因为 haiku 可能没有配额
218
+ const sessionId = Math.random().toString(36).substring(2, 15);
219
+ requestBody = JSON.stringify({
220
+ model: 'claude-sonnet-4-20250514',
221
+ max_tokens: 10,
222
+ stream: true,
223
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }],
224
+ system: [{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." }],
225
+ metadata: { user_id: `user_0000000000000000000000000000000000000000000000000000000000000000_account__session_${sessionId}` }
226
+ });
227
+
228
+ headers = {
229
+ 'x-api-key': apiKey || '',
230
+ 'Authorization': `Bearer ${apiKey || ''}`,
231
+ 'anthropic-version': '2023-06-01',
232
+ 'anthropic-beta': 'claude-code-20250219,interleaved-thinking-2025-05-14',
233
+ 'anthropic-dangerous-direct-browser-access': 'true',
234
+ 'x-app': 'cli',
235
+ 'x-stainless-lang': 'js',
236
+ 'x-stainless-runtime': 'node',
237
+ 'Content-Type': 'application/json',
238
+ 'User-Agent': 'claude-cli/2.0.53 (external, cli)'
239
+ };
240
+ } else if (channelType === 'codex') {
241
+ // Codex 使用 OpenAI Responses API 格式
242
+ // 路径: /v1/responses
243
+ apiPath = parsedUrl.pathname.replace(/\/$/, '');
244
+ if (!apiPath.endsWith('/responses')) {
245
+ apiPath = apiPath + (apiPath.endsWith('/v1') ? '/responses' : '/v1/responses');
246
+ }
247
+ // 从模板文件加载完整的 Codex 请求格式
248
+ try {
249
+ const template = JSON.parse(fs.readFileSync(CODEX_REQUEST_TEMPLATE_PATH, 'utf-8'));
250
+ // 生成新的 prompt_cache_key
251
+ template.prompt_cache_key = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
252
+ requestBody = JSON.stringify(template);
253
+ } catch (err) {
254
+ console.error('[SpeedTest] Failed to load Codex template:', err.message);
255
+ // 降级使用简化版本(可能会失败)
256
+ requestBody = JSON.stringify({
257
+ model: 'gpt-5-codex',
258
+ instructions: 'You are Codex.',
259
+ input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'ping' }] }],
260
+ max_output_tokens: 10,
261
+ stream: false,
262
+ store: false
263
+ });
264
+ }
265
+ headers = {
266
+ 'Authorization': `Bearer ${apiKey || ''}`,
267
+ 'Content-Type': 'application/json',
268
+ 'User-Agent': 'codex_cli_rs/0.65.0',
269
+ 'openai-beta': 'responses=experimental'
270
+ };
271
+ } else if (channelType === 'gemini') {
272
+ // Gemini 也使用 OpenAI 兼容格式
273
+ apiPath = parsedUrl.pathname.replace(/\/$/, '');
274
+ if (!apiPath.endsWith('/chat/completions')) {
275
+ apiPath = apiPath + (apiPath.endsWith('/v1') ? '/chat/completions' : '/v1/chat/completions');
276
+ }
277
+ // 使用渠道配置的模型,如果没有则默认使用 gemini-2.5-pro
278
+ const geminiModel = model || 'gemini-2.5-pro';
279
+ requestBody = JSON.stringify({
280
+ model: geminiModel,
281
+ max_tokens: 10,
282
+ messages: [{ role: 'user', content: 'Hi' }]
283
+ });
284
+ headers = {
285
+ 'Authorization': `Bearer ${apiKey || ''}`,
286
+ 'Content-Type': 'application/json',
287
+ 'User-Agent': 'Coding-Tool-SpeedTest/1.0'
288
+ };
289
+ } else {
290
+ // 默认使用 OpenAI 格式
291
+ apiPath = parsedUrl.pathname.replace(/\/$/, '');
292
+ if (!apiPath.endsWith('/chat/completions')) {
293
+ apiPath = apiPath + (apiPath.endsWith('/v1') ? '/chat/completions' : '/v1/chat/completions');
294
+ }
295
+ requestBody = JSON.stringify({
296
+ model: 'gpt-4o-mini',
297
+ max_tokens: 10,
298
+ messages: [{ role: 'user', content: 'Hi' }]
299
+ });
300
+ headers = {
301
+ 'Authorization': `Bearer ${apiKey || ''}`,
302
+ 'Content-Type': 'application/json',
303
+ 'User-Agent': 'Coding-Tool-SpeedTest/1.0'
304
+ };
305
+ }
306
+
307
+ const options = {
308
+ hostname: parsedUrl.hostname,
309
+ port: parsedUrl.port || (isHttps ? 443 : 80),
310
+ path: apiPath,
311
+ method: 'POST',
312
+ timeout,
313
+ headers
314
+ };
315
+
316
+ const req = httpModule.request(options, (res) => {
317
+ let data = '';
318
+ let resolved = false;
319
+ const isStreamingResponse = channelType === 'codex'; // Codex 使用流式响应
320
+
321
+ // 解析响应体中的错误信息
322
+ const parseErrorMessage = (responseData) => {
323
+ try {
324
+ const errData = JSON.parse(responseData);
325
+ return errData.error?.message || errData.message || errData.detail || null;
326
+ } catch {
327
+ return null;
328
+ }
329
+ };
330
+
331
+ res.on('data', chunk => {
332
+ data += chunk;
333
+ const chunkStr = chunk.toString();
334
+
335
+ // 对于流式响应(Codex),在收到第一个有效事件时立即返回成功
336
+ if (isStreamingResponse && !resolved && res.statusCode >= 200 && res.statusCode < 300) {
337
+ // 检查是否收到了 response.created 或 response.in_progress 事件
338
+ if (chunkStr.includes('response.created') || chunkStr.includes('response.in_progress')) {
339
+ resolved = true;
340
+ const latency = Date.now() - startTime;
341
+ req.destroy();
342
+ resolve({
343
+ success: true,
344
+ latency,
345
+ error: null,
346
+ statusCode: res.statusCode
347
+ });
348
+ } else if (chunkStr.includes('"detail"') || chunkStr.includes('"error"')) {
349
+ // 流式响应中的错误
350
+ resolved = true;
351
+ const latency = Date.now() - startTime;
352
+ req.destroy();
353
+ const errMsg = parseErrorMessage(chunkStr) || '流式响应错误';
354
+ resolve({
355
+ success: false,
356
+ latency,
357
+ error: errMsg,
358
+ statusCode: res.statusCode
359
+ });
360
+ }
361
+ }
362
+ });
363
+
364
+ res.on('end', () => {
365
+ if (resolved) return; // 已经处理过了
366
+
367
+ const latency = Date.now() - startTime;
368
+
369
+ // 严格判断:只有 2xx 且没有错误信息才算成功
370
+ if (res.statusCode >= 200 && res.statusCode < 300) {
371
+ // 检查响应体是否包含错误信息
372
+ const errMsg = parseErrorMessage(data);
373
+ if (errMsg && (errMsg.includes('error') || errMsg.includes('Error') ||
374
+ errMsg.includes('失败') || errMsg.includes('错误'))) {
375
+ resolve({
376
+ success: false,
377
+ latency,
378
+ error: errMsg,
379
+ statusCode: res.statusCode
380
+ });
381
+ } else {
382
+ // 真正的成功响应
383
+ resolve({
384
+ success: true,
385
+ latency,
386
+ error: null,
387
+ statusCode: res.statusCode
388
+ });
389
+ }
390
+ } else if (res.statusCode === 401) {
391
+ resolve({
392
+ success: false,
393
+ latency,
394
+ error: 'API Key 无效或已过期',
395
+ statusCode: res.statusCode
396
+ });
397
+ } else if (res.statusCode === 403) {
398
+ resolve({
399
+ success: false,
400
+ latency,
401
+ error: 'API Key 权限不足',
402
+ statusCode: res.statusCode
403
+ });
404
+ } else if (res.statusCode === 429) {
405
+ // 请求过多 - 标记为失败
406
+ const errMsg = parseErrorMessage(data) || '请求过多,服务限流中';
407
+ resolve({
408
+ success: false,
409
+ latency,
410
+ error: errMsg,
411
+ statusCode: res.statusCode
412
+ });
413
+ } else if (res.statusCode === 503 || res.statusCode === 529) {
414
+ // 服务暂时不可用/过载 - 标记为失败
415
+ const errMsg = parseErrorMessage(data) || (res.statusCode === 503 ? '服务暂时不可用' : '服务过载');
416
+ resolve({
417
+ success: false,
418
+ latency,
419
+ error: errMsg,
420
+ statusCode: res.statusCode
421
+ });
422
+ } else if (res.statusCode === 402) {
423
+ resolve({
424
+ success: false,
425
+ latency,
426
+ error: '账户余额不足',
427
+ statusCode: res.statusCode
428
+ });
429
+ } else if (res.statusCode === 400) {
430
+ // 请求参数错误
431
+ const errMsg = parseErrorMessage(data) || '请求参数错误';
432
+ resolve({
433
+ success: false,
434
+ latency,
435
+ error: errMsg,
436
+ statusCode: res.statusCode
437
+ });
438
+ } else if (res.statusCode >= 500) {
439
+ // 5xx 服务器错误
440
+ const errMsg = parseErrorMessage(data) || `服务器错误 (${res.statusCode})`;
441
+ resolve({
442
+ success: false,
443
+ latency,
444
+ error: errMsg,
445
+ statusCode: res.statusCode
446
+ });
447
+ } else {
448
+ // 其他错误
449
+ const errMsg = parseErrorMessage(data) || `HTTP ${res.statusCode}`;
450
+ resolve({
451
+ success: false,
452
+ latency,
453
+ error: errMsg,
454
+ statusCode: res.statusCode
455
+ });
456
+ }
457
+ });
458
+ });
459
+
460
+ req.on('error', (error) => {
461
+ resolve({
462
+ success: false,
463
+ latency: null,
464
+ error: error.message || '请求失败'
465
+ });
466
+ });
467
+
468
+ req.on('timeout', () => {
469
+ req.destroy();
470
+ resolve({
471
+ success: false,
472
+ latency: null,
473
+ error: 'API 请求超时'
474
+ });
475
+ });
476
+
477
+ req.write(requestBody);
478
+ req.end();
479
+ });
480
+ }
481
+
482
+ /**
483
+ * 批量测试多个渠道
484
+ * @param {Array} channels - 渠道列表
485
+ * @param {number} timeout - 超时时间
486
+ * @param {string} channelType - 渠道类型:'claude' | 'codex' | 'gemini'
487
+ * @returns {Promise<Array>} 测试结果列表
488
+ */
489
+ async function testMultipleChannels(channels, timeout = DEFAULT_TIMEOUT, channelType = 'claude') {
490
+ const results = await Promise.all(
491
+ channels.map(channel => testChannelSpeed(channel, timeout, channelType))
492
+ );
493
+
494
+ // 按延迟排序(成功的在前,按延迟升序)
495
+ results.sort((a, b) => {
496
+ if (a.success && !b.success) return -1;
497
+ if (!a.success && b.success) return 1;
498
+ if (a.success && b.success) return (a.latency || Infinity) - (b.latency || Infinity);
499
+ return 0;
500
+ });
501
+
502
+ return results;
503
+ }
504
+
505
+ /**
506
+ * 获取缓存的测试结果
507
+ * @param {string} channelId - 渠道 ID
508
+ * @returns {Object|null} 缓存的测试结果
509
+ */
510
+ function getCachedResult(channelId) {
511
+ const cached = testResultsCache.get(channelId);
512
+ // 5 分钟内的缓存有效
513
+ if (cached && Date.now() - cached.testedAt < 5 * 60 * 1000) {
514
+ return cached;
515
+ }
516
+ return null;
517
+ }
518
+
519
+ /**
520
+ * 清除测试结果缓存
521
+ */
522
+ function clearCache() {
523
+ testResultsCache.clear();
524
+ }
525
+
526
+ /**
527
+ * 获取延迟等级
528
+ * @param {number} latency - 延迟毫秒数
529
+ * @returns {string} 等级:excellent/good/fair/poor
530
+ */
531
+ function getLatencyLevel(latency) {
532
+ if (!latency) return 'unknown';
533
+ if (latency < 300) return 'excellent'; // < 300ms 优秀
534
+ if (latency < 500) return 'good'; // < 500ms 良好
535
+ if (latency < 800) return 'fair'; // < 800ms 一般
536
+ return 'poor'; // >= 800ms 较差
537
+ }
538
+
539
+ module.exports = {
540
+ testChannelSpeed,
541
+ testMultipleChannels,
542
+ getCachedResult,
543
+ clearCache,
544
+ getLatencyLevel
545
+ };