@adversity/coding-tool-x 3.1.0 → 3.1.2

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 (142) hide show
  1. package/CHANGELOG.md +39 -18
  2. package/README.md +8 -8
  3. package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
  4. package/dist/web/assets/ConfigTemplates-DvcbKKdS.js +1 -0
  5. package/dist/web/assets/Home-BJKPCBuk.css +1 -0
  6. package/dist/web/assets/Home-Cw-F_Wnu.js +1 -0
  7. package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
  8. package/dist/web/assets/PluginManager-jy_4GVxI.js +1 -0
  9. package/dist/web/assets/ProjectList-C1fQb9OW.css +1 -0
  10. package/dist/web/assets/ProjectList-Df1-NcNr.js +1 -0
  11. package/dist/web/assets/SessionList-BGJWyneI.css +1 -0
  12. package/dist/web/assets/SessionList-UWcZtC2r.js +1 -0
  13. package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
  14. package/dist/web/assets/SkillManager-IRdseMKB.js +1 -0
  15. package/dist/web/assets/Terminal-BasTyDut.js +1 -0
  16. package/dist/web/assets/Terminal-DGNJeVtc.css +1 -0
  17. package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
  18. package/dist/web/assets/WorkspaceManager-D-D2kK1V.js +1 -0
  19. package/dist/web/assets/icons-kcfLIMBB.js +1 -0
  20. package/dist/web/assets/index-CoB3zF0K.css +1 -0
  21. package/dist/web/assets/index-CryrSLv8.js +2 -0
  22. package/dist/web/assets/markdown-BfC0goYb.css +10 -0
  23. package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
  24. package/dist/web/assets/naive-ui-CSrLusZZ.js +1 -0
  25. package/dist/web/assets/{vendors-D2HHw_aW.js → vendors-CO3Upi1d.js} +2 -2
  26. package/dist/web/assets/vue-vendor-DqyWIXEb.js +45 -0
  27. package/dist/web/assets/xterm-6GBZ9nXN.css +32 -0
  28. package/dist/web/assets/xterm-BJzAjXCH.js +13 -0
  29. package/dist/web/index.html +8 -6
  30. package/package.json +4 -2
  31. package/src/commands/channels.js +48 -1
  32. package/src/commands/cli-type.js +4 -2
  33. package/src/commands/daemon.js +81 -12
  34. package/src/commands/doctor.js +10 -9
  35. package/src/commands/list.js +1 -1
  36. package/src/commands/logs.js +6 -4
  37. package/src/commands/port-config.js +24 -4
  38. package/src/commands/proxy-control.js +12 -6
  39. package/src/commands/search.js +1 -1
  40. package/src/commands/security.js +3 -2
  41. package/src/commands/stats.js +226 -52
  42. package/src/commands/switch.js +1 -1
  43. package/src/commands/toggle-proxy.js +31 -6
  44. package/src/commands/update.js +97 -0
  45. package/src/commands/workspace.js +1 -1
  46. package/src/config/default.js +41 -2
  47. package/src/config/loader.js +74 -8
  48. package/src/config/model-metadata.js +415 -0
  49. package/src/config/model-pricing.js +23 -93
  50. package/src/config/paths.js +105 -33
  51. package/src/index.js +64 -3
  52. package/src/plugins/constants.js +3 -2
  53. package/src/plugins/plugin-api.js +1 -1
  54. package/src/reset-config.js +4 -2
  55. package/src/server/api/agents.js +57 -14
  56. package/src/server/api/channels.js +112 -33
  57. package/src/server/api/codex-channels.js +111 -18
  58. package/src/server/api/codex-proxy.js +14 -8
  59. package/src/server/api/commands.js +71 -18
  60. package/src/server/api/config-export.js +0 -6
  61. package/src/server/api/config-registry.js +11 -3
  62. package/src/server/api/config.js +376 -5
  63. package/src/server/api/convert.js +133 -0
  64. package/src/server/api/dashboard.js +22 -6
  65. package/src/server/api/gemini-channels.js +107 -18
  66. package/src/server/api/gemini-proxy.js +14 -8
  67. package/src/server/api/gemini-sessions.js +1 -1
  68. package/src/server/api/health-check.js +4 -3
  69. package/src/server/api/mcp.js +3 -3
  70. package/src/server/api/opencode-channels.js +497 -0
  71. package/src/server/api/opencode-projects.js +99 -0
  72. package/src/server/api/opencode-proxy.js +207 -0
  73. package/src/server/api/opencode-sessions.js +345 -0
  74. package/src/server/api/opencode-statistics.js +57 -0
  75. package/src/server/api/plugins.js +66 -19
  76. package/src/server/api/prompts.js +2 -2
  77. package/src/server/api/proxy.js +7 -4
  78. package/src/server/api/sessions.js +3 -0
  79. package/src/server/api/settings.js +111 -0
  80. package/src/server/api/skills.js +69 -18
  81. package/src/server/api/workspaces.js +78 -6
  82. package/src/server/codex-proxy-server.js +36 -22
  83. package/src/server/dev-server.js +1 -1
  84. package/src/server/gemini-proxy-server.js +21 -7
  85. package/src/server/index.js +174 -58
  86. package/src/server/opencode-proxy-server.js +5486 -0
  87. package/src/server/proxy-server.js +33 -22
  88. package/src/server/services/agents-service.js +61 -24
  89. package/src/server/services/channel-scheduler.js +9 -5
  90. package/src/server/services/channels.js +64 -37
  91. package/src/server/services/codex-channels.js +56 -43
  92. package/src/server/services/codex-sessions.js +105 -6
  93. package/src/server/services/codex-settings-manager.js +271 -49
  94. package/src/server/services/codex-statistics-service.js +2 -2
  95. package/src/server/services/commands-service.js +84 -25
  96. package/src/server/services/config-export-service.js +7 -45
  97. package/src/server/services/config-registry-service.js +63 -17
  98. package/src/server/services/config-sync-manager.js +160 -7
  99. package/src/server/services/config-templates-service.js +204 -51
  100. package/src/server/services/env-checker.js +50 -13
  101. package/src/server/services/env-manager.js +155 -19
  102. package/src/server/services/favorites.js +5 -3
  103. package/src/server/services/gemini-channels.js +33 -44
  104. package/src/server/services/gemini-statistics-service.js +2 -2
  105. package/src/server/services/mcp-service.js +350 -9
  106. package/src/server/services/model-detector.js +707 -221
  107. package/src/server/services/network-access.js +80 -0
  108. package/src/server/services/opencode-channels.js +208 -0
  109. package/src/server/services/opencode-gateway-converter.js +639 -0
  110. package/src/server/services/opencode-sessions.js +931 -0
  111. package/src/server/services/opencode-settings-manager.js +478 -0
  112. package/src/server/services/opencode-statistics-service.js +255 -0
  113. package/src/server/services/plugins-service.js +479 -22
  114. package/src/server/services/prompts-service.js +53 -11
  115. package/src/server/services/proxy-runtime.js +1 -1
  116. package/src/server/services/repo-scanner-base.js +1 -1
  117. package/src/server/services/response-decoder.js +21 -0
  118. package/src/server/services/security-config.js +1 -1
  119. package/src/server/services/session-cache.js +1 -1
  120. package/src/server/services/skill-service.js +300 -46
  121. package/src/server/services/speed-test.js +464 -186
  122. package/src/server/services/statistics-service.js +2 -2
  123. package/src/server/services/terminal-commands.js +10 -3
  124. package/src/server/services/terminal-config.js +1 -1
  125. package/src/server/services/ui-config.js +1 -1
  126. package/src/server/services/workspace-service.js +57 -100
  127. package/src/server/websocket-server.js +156 -8
  128. package/src/ui/menu.js +49 -40
  129. package/src/utils/port-helper.js +22 -8
  130. package/src/utils/session.js +5 -4
  131. package/dist/web/assets/icons-CO_2OFES.js +0 -1
  132. package/dist/web/assets/index-DI8QOi-E.js +0 -14
  133. package/dist/web/assets/index-uLHGdeZh.css +0 -41
  134. package/dist/web/assets/naive-ui-B1re3c-e.js +0 -1
  135. package/dist/web/assets/vue-vendor-6JaYHOiI.js +0 -44
  136. package/src/server/api/oauth.js +0 -294
  137. package/src/server/api/permissions.js +0 -385
  138. package/src/server/config/oauth-providers.js +0 -68
  139. package/src/server/services/oauth-callback-server.js +0 -284
  140. package/src/server/services/oauth-service.js +0 -378
  141. package/src/server/services/oauth-token-storage.js +0 -135
  142. package/src/server/services/permission-templates-service.js +0 -308
@@ -7,20 +7,21 @@
7
7
  const https = require('https');
8
8
  const http = require('http');
9
9
  const { URL } = require('url');
10
- const path = require('path');
11
- const fs = require('fs');
12
10
  const { probeModelAvailability } = require('./model-detector');
11
+ const { getEffectiveApiKey: getClaudeEffectiveApiKey } = require('./channels');
12
+ const { getEffectiveApiKey: getCodexEffectiveApiKey } = require('./codex-channels');
13
+ const { getEffectiveApiKey: getGeminiEffectiveApiKey } = require('./gemini-channels');
14
+ const { getEffectiveApiKey: getOpenCodeEffectiveApiKey } = require('./opencode-channels');
13
15
 
14
16
  // 测试结果缓存
15
17
  const testResultsCache = new Map();
16
18
 
17
- // Codex 请求体模板文件路径
18
- const CODEX_REQUEST_TEMPLATE_PATH = path.join(__dirname, 'codex-speed-test-template.json');
19
-
20
19
  // 超时配置(毫秒)
21
20
  const DEFAULT_TIMEOUT = 15000;
22
21
  const MIN_TIMEOUT = 5000;
23
22
  const MAX_TIMEOUT = 60000;
23
+ const CLAUDE_CODE_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,prompt-caching-2024-07-31';
24
+ const ROUTE_OR_METHOD_MISMATCH_STATUS = new Set([404, 405, 501]);
24
25
 
25
26
  /**
26
27
  * 规范化超时时间
@@ -30,6 +31,203 @@ function sanitizeTimeout(timeout) {
30
31
  return Math.min(Math.max(ms, MIN_TIMEOUT), MAX_TIMEOUT);
31
32
  }
32
33
 
34
+ /**
35
+ * 规范化批量测速并发度(默认小并发)
36
+ */
37
+ function sanitizeBatchConcurrency(concurrency, defaultValue = 2) {
38
+ const value = Number(concurrency);
39
+ if (!Number.isFinite(value) || value <= 0) {
40
+ return defaultValue;
41
+ }
42
+ return Math.min(Math.max(Math.round(value), 1), 5);
43
+ }
44
+
45
+ /**
46
+ * 按并发限制执行异步任务,保持结果顺序与输入一致
47
+ */
48
+ async function runWithConcurrencyLimit(items, concurrency, taskFn) {
49
+ const list = Array.isArray(items) ? items : [];
50
+ if (list.length === 0) return [];
51
+
52
+ const limit = sanitizeBatchConcurrency(concurrency);
53
+ const results = new Array(list.length);
54
+ let cursor = 0;
55
+
56
+ async function worker() {
57
+ while (true) {
58
+ const currentIndex = cursor;
59
+ cursor += 1;
60
+ if (currentIndex >= list.length) {
61
+ return;
62
+ }
63
+ results[currentIndex] = await taskFn(list[currentIndex], currentIndex);
64
+ }
65
+ }
66
+
67
+ const workers = [];
68
+ const workerCount = Math.min(limit, list.length);
69
+ for (let i = 0; i < workerCount; i += 1) {
70
+ workers.push(worker());
71
+ }
72
+ await Promise.all(workers);
73
+ return results;
74
+ }
75
+
76
+ function normalizeNonEmptyString(value) {
77
+ if (typeof value !== 'string') return null;
78
+ const trimmed = value.trim();
79
+ return trimmed || null;
80
+ }
81
+
82
+ function resolveExplicitModel(channel, model) {
83
+ return (
84
+ normalizeNonEmptyString(model)
85
+ || normalizeNonEmptyString(channel?.model)
86
+ || normalizeNonEmptyString(channel?.modelConfig?.model)
87
+ );
88
+ }
89
+
90
+ function resolveEffectiveApiKey(channel, channelType) {
91
+ switch (channelType) {
92
+ case 'codex':
93
+ return getCodexEffectiveApiKey(channel);
94
+ case 'gemini':
95
+ return getGeminiEffectiveApiKey(channel);
96
+ case 'opencode':
97
+ return getOpenCodeEffectiveApiKey(channel);
98
+ case 'claude':
99
+ default:
100
+ return getClaudeEffectiveApiKey(channel);
101
+ }
102
+ }
103
+
104
+ function mapStainlessOs() {
105
+ switch (process.platform) {
106
+ case 'darwin':
107
+ return 'MacOS';
108
+ case 'win32':
109
+ return 'Windows';
110
+ case 'linux':
111
+ return 'Linux';
112
+ default:
113
+ return `other::${process.platform}`;
114
+ }
115
+ }
116
+
117
+ function mapStainlessArch() {
118
+ switch (process.arch) {
119
+ case 'x64':
120
+ return 'x64';
121
+ case 'arm64':
122
+ return 'arm64';
123
+ case 'ia32':
124
+ return 'x86';
125
+ default:
126
+ return `other::${process.arch}`;
127
+ }
128
+ }
129
+
130
+ function buildGeminiNativeGeneratePath(parsedUrl, model) {
131
+ let pathname = parsedUrl.pathname.replace(/\/+$/, '');
132
+ const modelsIndex = pathname.indexOf('/models');
133
+ if (modelsIndex >= 0) {
134
+ pathname = pathname.slice(0, modelsIndex);
135
+ }
136
+
137
+ let apiBasePath;
138
+ if (!pathname || pathname === '/') {
139
+ apiBasePath = '/v1beta';
140
+ } else if (pathname.endsWith('/v1beta') || pathname.endsWith('/v1')) {
141
+ apiBasePath = pathname;
142
+ } else {
143
+ apiBasePath = `${pathname}/v1beta`;
144
+ }
145
+
146
+ return `${apiBasePath}/models/${encodeURIComponent(model)}:generateContent`;
147
+ }
148
+
149
+ function buildGeminiCliGeneratePath(parsedUrl) {
150
+ let pathname = parsedUrl.pathname.replace(/\/+$/, '');
151
+ if (!pathname || pathname === '/') {
152
+ return '/v1internal:generateContent';
153
+ }
154
+ if (pathname.endsWith(':streamGenerateContent')) {
155
+ return pathname.replace(/:streamGenerateContent$/, ':generateContent');
156
+ }
157
+ if (pathname.endsWith(':generateContent')) {
158
+ return pathname;
159
+ }
160
+ if (pathname.endsWith('/v1internal')) {
161
+ return `${pathname}:generateContent`;
162
+ }
163
+ if (pathname.endsWith('/v1')) {
164
+ return '/v1internal:generateContent';
165
+ }
166
+ return `${pathname}/v1internal:generateContent`;
167
+ }
168
+
169
+ function buildCodexResponsesPath(parsedUrl) {
170
+ let pathname = parsedUrl.pathname.replace(/\/+$/, '');
171
+ if (!pathname || pathname === '/') {
172
+ return '/responses';
173
+ }
174
+ if (pathname.endsWith('/responses') || pathname.endsWith('/v1/responses')) {
175
+ return pathname;
176
+ }
177
+ if (pathname.endsWith('/v1')) {
178
+ return `${pathname}/responses`;
179
+ }
180
+ return `${pathname}/responses`;
181
+ }
182
+
183
+ function shouldUseGeminiCliFormat(parsedUrl) {
184
+ const host = String(parsedUrl.hostname || '').toLowerCase();
185
+ const pathname = parsedUrl.pathname.replace(/\/+$/, '');
186
+
187
+ if (pathname.includes('/v1internal') || pathname.endsWith(':generateContent') || pathname.endsWith(':streamGenerateContent')) {
188
+ return true;
189
+ }
190
+ if (pathname.includes('/v1beta') || pathname.includes('/models/')) {
191
+ return false;
192
+ }
193
+ if (host.includes('cloudcode-pa.googleapis.com')) {
194
+ return true;
195
+ }
196
+ if (!pathname || pathname === '/') {
197
+ return !host.includes('generativelanguage.googleapis.com') && !host.includes('aiplatform.googleapis.com');
198
+ }
199
+ return false;
200
+ }
201
+
202
+ function extractJsonPayloads(responseData) {
203
+ const payloads = [];
204
+ const text = typeof responseData === 'string' ? responseData : String(responseData || '');
205
+ if (!text.trim()) {
206
+ return payloads;
207
+ }
208
+
209
+ try {
210
+ payloads.push(JSON.parse(text));
211
+ } catch {
212
+ // ignore and continue parsing SSE fragments
213
+ }
214
+
215
+ const lines = text.split(/\r?\n/);
216
+ for (const line of lines) {
217
+ const trimmed = line.trim();
218
+ if (!trimmed.startsWith('data:')) continue;
219
+ const rawData = trimmed.slice(5).trim();
220
+ if (!rawData || rawData === '[DONE]') continue;
221
+ try {
222
+ payloads.push(JSON.parse(rawData));
223
+ } catch {
224
+ // ignore invalid SSE fragment
225
+ }
226
+ }
227
+
228
+ return payloads;
229
+ }
230
+
33
231
  /**
34
232
  * 测试单个渠道的连接速度和 API 功能
35
233
  * @param {Object} channel - 渠道配置
@@ -74,9 +272,31 @@ async function testChannelSpeed(channel, timeout = DEFAULT_TIMEOUT, channelType
74
272
  };
75
273
  }
76
274
 
275
+ const effectiveApiKey = resolveEffectiveApiKey(channel, channelType);
276
+ if (!effectiveApiKey) {
277
+ return {
278
+ channelId: channel.id,
279
+ channelName: channel.name,
280
+ success: false,
281
+ networkOk: false,
282
+ apiOk: false,
283
+ error: 'API Key 未配置',
284
+ latency: null,
285
+ statusCode: null,
286
+ testedAt: Date.now()
287
+ };
288
+ }
289
+
77
290
  // 直接测试 API 功能(发送测试消息)
78
291
  // 不再单独测试网络连通性,因为直接 GET base_url 可能返回 404
79
- const apiResult = await testAPIFunctionality(testUrl, channel.apiKey, sanitizedTimeout, channelType, channel.model, channel);
292
+ const apiResult = await testAPIFunctionality(
293
+ testUrl,
294
+ effectiveApiKey,
295
+ sanitizedTimeout,
296
+ channelType,
297
+ channel.model,
298
+ channel
299
+ );
80
300
 
81
301
  const success = apiResult.success;
82
302
  const networkOk = apiResult.latency !== null; // 如果有延迟数据,说明网络是通的
@@ -90,7 +310,7 @@ async function testChannelSpeed(channel, timeout = DEFAULT_TIMEOUT, channelType
90
310
  apiOk: success,
91
311
  statusCode: apiResult.statusCode || null,
92
312
  error: success ? null : (apiResult.error || '测试失败'),
93
- latency: apiResult.latency || null, // 无论成功失败都保留延迟数据
313
+ latency: apiResult.latency ?? null, // 无论成功失败都保留延迟数据(保留 0ms)
94
314
  testedAt: Date.now(),
95
315
  testedModel: apiResult.testedModel,
96
316
  availableModels: apiResult.availableModels,
@@ -199,220 +419,259 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
199
419
  // Probe model availability if channel is provided
200
420
  let modelProbe = null;
201
421
  if (channel) {
202
- // Check if speedTestModel is explicitly configured
203
- if (channel.speedTestModel) {
422
+ const configuredSpeedTestModel = normalizeNonEmptyString(channel.speedTestModel);
423
+ const explicitModel = resolveExplicitModel(channel, model);
424
+
425
+ // 优先使用 speedTestModel,避免测速时额外探测
426
+ if (configuredSpeedTestModel) {
204
427
  // Use the explicitly configured model for speed testing
205
428
  modelProbe = {
206
- preferredTestModel: channel.speedTestModel,
207
- availableModels: [channel.speedTestModel],
208
- cached: false
429
+ preferredTestModel: configuredSpeedTestModel,
430
+ availableModels: [configuredSpeedTestModel],
431
+ cached: false,
432
+ method: 'configured'
209
433
  };
210
- console.log(`[SpeedTest] Using configured speedTestModel: ${channel.speedTestModel}`);
434
+ console.log(`[SpeedTest] Using configured speedTestModel: ${configuredSpeedTestModel}`);
435
+ } else if (explicitModel) {
436
+ modelProbe = {
437
+ preferredTestModel: explicitModel,
438
+ availableModels: [explicitModel],
439
+ cached: false,
440
+ method: 'configured'
441
+ };
442
+ console.log(`[SpeedTest] Using explicit model: ${explicitModel}`);
211
443
  } else {
212
444
  // Fall back to auto-detection
213
445
  try {
214
- modelProbe = await probeModelAvailability(channel, channelType);
446
+ modelProbe = await probeModelAvailability(channel, channelType, { stopOnFirstAvailable: true });
215
447
  } catch (error) {
216
448
  console.error('[SpeedTest] Model detection failed:', error.message);
217
449
  }
218
450
  }
219
451
  }
220
452
 
221
- return new Promise((resolve) => {
222
- const startTime = Date.now();
223
- const parsedUrl = new URL(baseUrl);
224
- const isHttps = parsedUrl.protocol === 'https:';
225
- const httpModule = isHttps ? https : http;
226
-
227
- // Helper to create result object with model info
228
- const createResult = (result) => ({
229
- ...result,
230
- testedModel: testModel,
231
- availableModels: modelProbe?.availableModels,
232
- modelDetectionMethod: modelProbe?.cached ? 'cached' : 'probed'
233
- });
453
+ const parsedUrl = new URL(baseUrl);
454
+ const isHttps = parsedUrl.protocol === 'https:';
455
+ const httpModule = isHttps ? https : http;
456
+
457
+ // 根据渠道类型确定 API 路径和请求格式
458
+ let testModel = null;
459
+ let primaryRequestConfig = null;
460
+ let fallbackRequestConfig = null;
461
+
462
+ // Helper to create result object with model info
463
+ const createResult = (result) => ({
464
+ ...result,
465
+ testedModel: testModel,
466
+ availableModels: modelProbe?.availableModels,
467
+ modelDetectionMethod: modelProbe?.method || (modelProbe?.cached ? 'cached' : 'probed')
468
+ });
234
469
 
235
- // 根据渠道类型确定 API 路径和请求格式
236
- let apiPath;
237
- let requestBody;
238
- let headers;
239
- let testModel = null; // Track which model is actually being tested
240
-
241
- // Claude 渠道使用 Anthropic 格式
242
- if (channelType === 'claude') {
243
- // Anthropic Messages API - 模拟 Claude Code 请求格式
244
- apiPath = parsedUrl.pathname.replace(/\/$/, '');
245
- if (!apiPath.endsWith('/messages')) {
246
- apiPath = apiPath + (apiPath.endsWith('/v1') ? '/messages' : '/v1/messages');
470
+ const parseErrorMessage = (responseData) => {
471
+ const payloads = extractJsonPayloads(responseData);
472
+ for (const payload of payloads) {
473
+ const message = payload?.error?.message || payload?.message || payload?.detail || payload?.error_description;
474
+ if (message) return message;
475
+ }
476
+ return null;
477
+ };
478
+
479
+ const UNEXPECTED_ERROR_PATTERNS = [
480
+ /unexpected/i,
481
+ /internal.*error/i,
482
+ /something.*went.*wrong/i,
483
+ /service.*unavailable/i,
484
+ /temporarily.*unavailable/i,
485
+ /try.*again.*later/i,
486
+ /server.*error/i,
487
+ /bad.*gateway/i,
488
+ /gateway.*timeout/i
489
+ ];
490
+
491
+ function containsUnexpectedError(responseBody) {
492
+ const payloads = extractJsonPayloads(responseBody);
493
+ for (const payload of payloads) {
494
+ if (payload?.error) {
495
+ return { hasError: true, message: payload.error.message || payload.error };
496
+ }
497
+ const message = payload?.message || payload?.detail || payload?.error_description || '';
498
+ for (const pattern of UNEXPECTED_ERROR_PATTERNS) {
499
+ if (pattern.test(message)) {
500
+ return { hasError: true, message };
501
+ }
247
502
  }
248
- // 添加 ?beta=true 查询参数
249
- apiPath += '?beta=true';
250
-
251
- // 使用 Claude Code 的请求格式
252
- // user_id 必须符合特定格式: user_xxx_account__session_xxx
253
- // 优先使用模型检测结果,否则回退到 claude-sonnet-4-20250514
254
- testModel = modelProbe?.preferredTestModel || 'claude-sonnet-4-20250514';
255
- const sessionId = Math.random().toString(36).substring(2, 15);
256
- requestBody = JSON.stringify({
503
+ }
504
+ return { hasError: false };
505
+ }
506
+
507
+ if (channelType === 'claude') {
508
+ // Anthropic Messages API - 模拟 Claude Code 请求格式
509
+ let apiPath = parsedUrl.pathname.replace(/\/$/, '');
510
+ if (!apiPath.endsWith('/messages')) {
511
+ apiPath = apiPath + (apiPath.endsWith('/v1') ? '/messages' : '/v1/messages');
512
+ }
513
+ apiPath += '?beta=true';
514
+
515
+ testModel = modelProbe?.preferredTestModel || normalizeNonEmptyString(model) || 'claude-sonnet-4-20250514';
516
+ const sessionId = Math.random().toString(36).substring(2, 15);
517
+ primaryRequestConfig = {
518
+ apiPath,
519
+ requestBody: JSON.stringify({
257
520
  model: testModel,
258
521
  max_tokens: 1,
259
- stream: true,
260
- messages: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }],
522
+ stream: false,
523
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }],
261
524
  system: [{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." }],
262
525
  metadata: { user_id: `user_0000000000000000000000000000000000000000000000000000000000000000_account__session_${sessionId}` }
263
- });
264
-
265
- headers = {
526
+ }),
527
+ headers: {
266
528
  'x-api-key': apiKey || '',
267
529
  'Authorization': `Bearer ${apiKey || ''}`,
268
530
  'anthropic-version': '2023-06-01',
269
- 'anthropic-beta': 'claude-code-20250219,interleaved-thinking-2025-05-14',
531
+ 'anthropic-beta': CLAUDE_CODE_BETA_HEADER,
270
532
  'anthropic-dangerous-direct-browser-access': 'true',
271
533
  'x-app': 'cli',
534
+ 'x-stainless-helper-method': 'stream',
535
+ 'x-stainless-retry-count': '0',
536
+ 'x-stainless-runtime-version': 'v24.3.0',
537
+ 'x-stainless-package-version': '0.74.0',
272
538
  'x-stainless-lang': 'js',
273
539
  'x-stainless-runtime': 'node',
540
+ 'x-stainless-arch': mapStainlessArch(),
541
+ 'x-stainless-os': mapStainlessOs(),
542
+ 'x-stainless-timeout': '600',
543
+ 'Accept': 'application/json',
544
+ 'Accept-Encoding': 'gzip, deflate, br, zstd',
545
+ 'Connection': 'keep-alive',
274
546
  'Content-Type': 'application/json',
275
- 'User-Agent': 'claude-cli/2.0.53 (external, cli)'
276
- };
277
- } else if (channelType === 'codex') {
278
- // Codex 使用 OpenAI Responses API 格式
279
- // 路径: /v1/responses
280
- apiPath = parsedUrl.pathname.replace(/\/$/, '');
281
- if (!apiPath.endsWith('/responses')) {
282
- apiPath = apiPath + (apiPath.endsWith('/v1') ? '/responses' : '/v1/responses');
283
- }
284
- // 从模板文件加载完整的 Codex 请求格式
285
- try {
286
- const template = JSON.parse(fs.readFileSync(CODEX_REQUEST_TEMPLATE_PATH, 'utf-8'));
287
- // 生成新的 prompt_cache_key
288
- template.prompt_cache_key = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
289
- // 使用模型检测结果更新模型(如果有)
290
- if (modelProbe?.preferredTestModel) {
291
- template.model = modelProbe.preferredTestModel;
292
- }
293
- testModel = template.model; // Track the model being used
294
- requestBody = JSON.stringify(template);
295
- } catch (err) {
296
- console.error('[SpeedTest] Failed to load Codex template:', err.message);
297
- // 降级使用简化版本(可能会失败)
298
- testModel = modelProbe?.preferredTestModel || 'gpt-5-codex';
299
- requestBody = JSON.stringify({
300
- model: testModel,
301
- instructions: 'You are Codex.',
302
- input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'ping' }] }],
303
- max_output_tokens: 1,
304
- stream: false,
305
- store: false
306
- });
307
- }
308
- headers = {
547
+ 'User-Agent': 'claude-cli/2.1.44 (external, sdk-cli)'
548
+ },
549
+ isStreamingResponse: false
550
+ };
551
+ } else if (channelType === 'codex') {
552
+ const apiPath = buildCodexResponsesPath(parsedUrl);
553
+ testModel = modelProbe?.preferredTestModel || normalizeNonEmptyString(model) || 'gpt-5-codex';
554
+ const codexSessionId = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
555
+
556
+ const baseBody = {
557
+ model: testModel,
558
+ instructions: 'You are Codex.',
559
+ input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'ping' }] }],
560
+ store: false,
561
+ prompt_cache_key: codexSessionId
562
+ };
563
+
564
+ primaryRequestConfig = {
565
+ apiPath,
566
+ requestBody: JSON.stringify({ ...baseBody, stream: false }),
567
+ headers: {
309
568
  'Authorization': `Bearer ${apiKey || ''}`,
569
+ 'Accept': 'application/json',
570
+ 'Connection': 'Keep-Alive',
571
+ 'Version': '0.101.0',
572
+ 'Session_id': codexSessionId,
573
+ 'Originator': 'codex_cli_rs',
310
574
  'Content-Type': 'application/json',
311
- 'User-Agent': 'codex_cli_rs/0.65.0',
575
+ 'User-Agent': 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464',
312
576
  'openai-beta': 'responses=experimental'
313
- };
314
- } else if (channelType === 'gemini') {
315
- // Gemini 也使用 OpenAI 兼容格式
316
- apiPath = parsedUrl.pathname.replace(/\/$/, '');
317
- if (!apiPath.endsWith('/chat/completions')) {
318
- apiPath = apiPath + (apiPath.endsWith('/v1') ? '/chat/completions' : '/v1/chat/completions');
319
- }
320
- // 优先使用模型检测结果,其次使用渠道配置的模型,最后默认使用 gemini-2.5-pro
321
- testModel = modelProbe?.preferredTestModel || model || 'gemini-2.5-pro';
322
- requestBody = JSON.stringify({
577
+ },
578
+ isStreamingResponse: false
579
+ };
580
+
581
+ fallbackRequestConfig = {
582
+ apiPath,
583
+ requestBody: JSON.stringify({ ...baseBody, stream: true }),
584
+ headers: {
585
+ ...primaryRequestConfig.headers,
586
+ 'Accept': 'text/event-stream'
587
+ },
588
+ isStreamingResponse: true
589
+ };
590
+ } else if (channelType === 'gemini') {
591
+ testModel = modelProbe?.preferredTestModel || normalizeNonEmptyString(model) || 'gemini-2.5-pro';
592
+ const useCliFormat = shouldUseGeminiCliFormat(parsedUrl);
593
+
594
+ const cliRequestConfig = {
595
+ apiPath: buildGeminiCliGeneratePath(parsedUrl),
596
+ requestBody: JSON.stringify({
597
+ project: '',
323
598
  model: testModel,
324
- max_tokens: 1,
325
- messages: [{ role: 'user', content: 'Hi' }]
326
- });
327
- headers = {
599
+ request: {
600
+ contents: [{ role: 'user', parts: [{ text: 'ping' }] }],
601
+ generationConfig: { maxOutputTokens: 1, temperature: 0 }
602
+ }
603
+ }),
604
+ headers: {
328
605
  'Authorization': `Bearer ${apiKey || ''}`,
606
+ 'x-goog-api-key': apiKey || '',
607
+ 'Accept': 'application/json',
329
608
  'Content-Type': 'application/json',
330
- 'User-Agent': 'Coding-Tool-SpeedTest/1.0'
331
- };
332
- } else {
333
- // 默认使用 OpenAI 格式
334
- apiPath = parsedUrl.pathname.replace(/\/$/, '');
335
- if (!apiPath.endsWith('/chat/completions')) {
336
- apiPath = apiPath + (apiPath.endsWith('/v1') ? '/chat/completions' : '/v1/chat/completions');
337
- }
338
- requestBody = JSON.stringify({
609
+ 'User-Agent': 'google-api-nodejs-client/9.15.1',
610
+ 'X-Goog-Api-Client': 'gl-node/22.17.0',
611
+ 'Client-Metadata': 'ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI'
612
+ },
613
+ isStreamingResponse: false
614
+ };
615
+
616
+ const nativeRequestConfig = {
617
+ apiPath: buildGeminiNativeGeneratePath(parsedUrl, testModel),
618
+ requestBody: JSON.stringify({
619
+ contents: [{ role: 'user', parts: [{ text: 'ping' }] }],
620
+ generationConfig: { maxOutputTokens: 1, temperature: 0 }
621
+ }),
622
+ headers: {
623
+ 'Authorization': `Bearer ${apiKey || ''}`,
624
+ 'x-goog-api-key': apiKey || '',
625
+ 'Accept': 'application/json',
626
+ 'Content-Type': 'application/json',
627
+ 'User-Agent': 'google-genai-sdk/0.8.0'
628
+ },
629
+ isStreamingResponse: false
630
+ };
631
+
632
+ primaryRequestConfig = useCliFormat ? cliRequestConfig : nativeRequestConfig;
633
+ fallbackRequestConfig = useCliFormat ? nativeRequestConfig : cliRequestConfig;
634
+ } else {
635
+ let apiPath = parsedUrl.pathname.replace(/\/$/, '');
636
+ if (!apiPath.endsWith('/chat/completions')) {
637
+ apiPath = apiPath + (apiPath.endsWith('/v1') ? '/chat/completions' : '/v1/chat/completions');
638
+ }
639
+ primaryRequestConfig = {
640
+ apiPath,
641
+ requestBody: JSON.stringify({
339
642
  model: 'gpt-4o-mini',
340
643
  max_tokens: 1,
341
644
  messages: [{ role: 'user', content: 'Hi' }]
342
- });
343
- headers = {
645
+ }),
646
+ headers: {
344
647
  'Authorization': `Bearer ${apiKey || ''}`,
345
648
  'Content-Type': 'application/json',
346
649
  'User-Agent': 'Coding-Tool-SpeedTest/1.0'
347
- };
348
- }
650
+ },
651
+ isStreamingResponse: false
652
+ };
653
+ }
349
654
 
655
+ const executeRequest = (requestConfig) => new Promise((resolve) => {
656
+ const startTime = Date.now();
350
657
  const options = {
351
658
  hostname: parsedUrl.hostname,
352
659
  port: parsedUrl.port || (isHttps ? 443 : 80),
353
- path: apiPath,
660
+ path: requestConfig.apiPath,
354
661
  method: 'POST',
355
662
  timeout,
356
- headers
663
+ headers: requestConfig.headers
357
664
  };
358
665
 
359
666
  const req = httpModule.request(options, (res) => {
360
667
  let data = '';
361
668
  let resolved = false;
362
- const isStreamingResponse = channelType === 'codex'; // Codex 使用流式响应
363
-
364
- // 解析响应体中的错误信息
365
- const parseErrorMessage = (responseData) => {
366
- try {
367
- const errData = JSON.parse(responseData);
368
- return errData.error?.message || errData.message || errData.detail || null;
369
- } catch {
370
- return null;
371
- }
372
- };
373
-
374
- const UNEXPECTED_ERROR_PATTERNS = [
375
- /unexpected/i,
376
- /internal.*error/i,
377
- /something.*went.*wrong/i,
378
- /service.*unavailable/i,
379
- /temporarily.*unavailable/i,
380
- /try.*again.*later/i,
381
- /server.*error/i,
382
- /bad.*gateway/i,
383
- /gateway.*timeout/i
384
- ];
385
-
386
- function containsUnexpectedError(responseBody) {
387
- try {
388
- const data = typeof responseBody === 'string' ? JSON.parse(responseBody) : responseBody;
389
-
390
- // Check for explicit error field
391
- if (data.error) {
392
- return { hasError: true, message: data.error.message || data.error };
393
- }
394
-
395
- // Check message patterns
396
- const message = data.message || data.detail || data.error_description || '';
397
- for (const pattern of UNEXPECTED_ERROR_PATTERNS) {
398
- if (pattern.test(message)) {
399
- return { hasError: true, message };
400
- }
401
- }
402
-
403
- return { hasError: false };
404
- } catch {
405
- return { hasError: false };
406
- }
407
- }
408
669
 
409
670
  res.on('data', chunk => {
410
671
  data += chunk;
411
672
  const chunkStr = chunk.toString();
412
673
 
413
- // 对于流式响应(Codex),在收到第一个有效事件时立即返回成功
414
- if (isStreamingResponse && !resolved && res.statusCode >= 200 && res.statusCode < 300) {
415
- // 检查是否收到了 response.created 或 response.in_progress 事件
674
+ if (requestConfig.isStreamingResponse && !resolved && res.statusCode >= 200 && res.statusCode < 300) {
416
675
  if (chunkStr.includes('response.created') || chunkStr.includes('response.in_progress')) {
417
676
  resolved = true;
418
677
  const latency = Date.now() - startTime;
@@ -423,8 +682,7 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
423
682
  error: null,
424
683
  statusCode: res.statusCode
425
684
  }));
426
- } else if (chunkStr.includes('"detail"') || chunkStr.includes('"error"')) {
427
- // 流式响应中的错误 - 使用新的错误检测函数
685
+ } else if (chunkStr.includes('"detail"') || chunkStr.includes('"error"') || chunkStr.includes('data:')) {
428
686
  const errorCheck = containsUnexpectedError(chunkStr);
429
687
  if (errorCheck.hasError) {
430
688
  resolved = true;
@@ -442,13 +700,10 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
442
700
  });
443
701
 
444
702
  res.on('end', () => {
445
- if (resolved) return; // 已经处理过了
703
+ if (resolved) return;
446
704
 
447
705
  const latency = Date.now() - startTime;
448
-
449
- // 严格判断:只有 2xx 且没有错误信息才算成功
450
706
  if (res.statusCode >= 200 && res.statusCode < 300) {
451
- // 使用新的错误检测函数
452
707
  const errorCheck = containsUnexpectedError(data);
453
708
  if (errorCheck.hasError) {
454
709
  resolve(createResult({
@@ -458,7 +713,6 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
458
713
  statusCode: res.statusCode
459
714
  }));
460
715
  } else {
461
- // 真正的成功响应
462
716
  resolve(createResult({
463
717
  success: true,
464
718
  latency,
@@ -481,7 +735,6 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
481
735
  statusCode: res.statusCode
482
736
  }));
483
737
  } else if (res.statusCode === 429) {
484
- // 请求过多 - 标记为失败
485
738
  const errMsg = parseErrorMessage(data) || '请求过多,服务限流中';
486
739
  resolve(createResult({
487
740
  success: false,
@@ -490,7 +743,6 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
490
743
  statusCode: res.statusCode
491
744
  }));
492
745
  } else if (res.statusCode === 503 || res.statusCode === 529) {
493
- // 服务暂时不可用/过载 - 标记为失败
494
746
  const errMsg = parseErrorMessage(data) || (res.statusCode === 503 ? '服务暂时不可用' : '服务过载');
495
747
  resolve(createResult({
496
748
  success: false,
@@ -506,7 +758,6 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
506
758
  statusCode: res.statusCode
507
759
  }));
508
760
  } else if (res.statusCode === 400) {
509
- // 请求参数错误
510
761
  const errMsg = parseErrorMessage(data) || '请求参数错误';
511
762
  resolve(createResult({
512
763
  success: false,
@@ -515,7 +766,6 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
515
766
  statusCode: res.statusCode
516
767
  }));
517
768
  } else if (res.statusCode >= 500) {
518
- // 5xx 服务器错误
519
769
  const errMsg = parseErrorMessage(data) || `服务器错误 (${res.statusCode})`;
520
770
  resolve(createResult({
521
771
  success: false,
@@ -524,7 +774,6 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
524
774
  statusCode: res.statusCode
525
775
  }));
526
776
  } else {
527
- // 其他错误
528
777
  const errMsg = parseErrorMessage(data) || `HTTP ${res.statusCode}`;
529
778
  resolve(createResult({
530
779
  success: false,
@@ -553,9 +802,29 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
553
802
  }));
554
803
  });
555
804
 
556
- req.write(requestBody);
805
+ req.write(requestConfig.requestBody);
557
806
  req.end();
558
807
  });
808
+
809
+ const primaryResult = await executeRequest(primaryRequestConfig);
810
+ if (primaryResult.success || !fallbackRequestConfig) {
811
+ return primaryResult;
812
+ }
813
+
814
+ if (channelType === 'gemini' && ROUTE_OR_METHOD_MISMATCH_STATUS.has(primaryResult.statusCode)) {
815
+ return executeRequest(fallbackRequestConfig);
816
+ }
817
+
818
+ if (channelType === 'codex') {
819
+ const codexError = String(primaryResult.error || '').toLowerCase();
820
+ const shouldRetryWithStreaming = ROUTE_OR_METHOD_MISMATCH_STATUS.has(primaryResult.statusCode)
821
+ || (primaryResult.statusCode === 400 && (codexError.includes('stream') || codexError.includes('event-stream') || codexError.includes('sse')));
822
+ if (shouldRetryWithStreaming) {
823
+ return executeRequest(fallbackRequestConfig);
824
+ }
825
+ }
826
+
827
+ return primaryResult;
559
828
  }
560
829
 
561
830
  /**
@@ -565,16 +834,22 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
565
834
  * @param {string} channelType - 渠道类型:'claude' | 'codex' | 'gemini'
566
835
  * @returns {Promise<Array>} 测试结果列表
567
836
  */
568
- async function testMultipleChannels(channels, timeout = DEFAULT_TIMEOUT, channelType = 'claude') {
569
- const results = await Promise.all(
570
- channels.map(channel => testChannelSpeed(channel, timeout, channelType))
837
+ async function testMultipleChannels(channels, timeout = DEFAULT_TIMEOUT, channelType = 'claude', concurrency = 2) {
838
+ const results = await runWithConcurrencyLimit(
839
+ channels,
840
+ concurrency,
841
+ channel => testChannelSpeed(channel, timeout, channelType)
571
842
  );
572
843
 
573
844
  // 按延迟排序(成功的在前,按延迟升序)
574
845
  results.sort((a, b) => {
575
846
  if (a.success && !b.success) return -1;
576
847
  if (!a.success && b.success) return 1;
577
- if (a.success && b.success) return (a.latency || Infinity) - (b.latency || Infinity);
848
+ if (a.success && b.success) {
849
+ const aLatency = (a.latency === null || a.latency === undefined) ? Infinity : a.latency;
850
+ const bLatency = (b.latency === null || b.latency === undefined) ? Infinity : b.latency;
851
+ return aLatency - bLatency;
852
+ }
578
853
  return 0;
579
854
  });
580
855
 
@@ -608,7 +883,8 @@ function clearCache() {
608
883
  * @returns {string} 等级:excellent/good/fair/poor
609
884
  */
610
885
  function getLatencyLevel(latency) {
611
- if (!latency) return 'unknown';
886
+ if (latency === null || latency === undefined) return 'unknown';
887
+ if (!Number.isFinite(Number(latency))) return 'unknown';
612
888
  if (latency < 300) return 'excellent'; // < 300ms 优秀
613
889
  if (latency < 500) return 'good'; // < 500ms 良好
614
890
  if (latency < 800) return 'fair'; // < 800ms 一般
@@ -620,5 +896,7 @@ module.exports = {
620
896
  testMultipleChannels,
621
897
  getCachedResult,
622
898
  clearCache,
623
- getLatencyLevel
899
+ getLatencyLevel,
900
+ sanitizeBatchConcurrency,
901
+ runWithConcurrencyLimit
624
902
  };