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.
- package/CHANGELOG.md +599 -0
- package/LICENSE +21 -0
- package/README.md +439 -0
- package/bin/ctx.js +8 -0
- package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
- package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
- package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
- package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
- package/dist/web/assets/Home-38JTUlYt.js +1 -0
- package/dist/web/assets/Home-CjupSEWE.css +1 -0
- package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
- package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
- package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
- package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
- package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
- package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
- package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
- package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
- package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
- package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
- package/dist/web/assets/icons-DRrXwWZi.js +1 -0
- package/dist/web/assets/index-CetESrXw.css +1 -0
- package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
- package/dist/web/assets/markdown-BfC0goYb.css +10 -0
- package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
- package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
- package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
- package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/index.html +20 -0
- package/dist/web/logo.png +0 -0
- package/docs/bannel.png +0 -0
- package/docs/home.png +0 -0
- package/docs/logo.png +0 -0
- package/docs/model-redirection.md +251 -0
- package/docs/multi-channel-load-balancing.md +249 -0
- package/package.json +80 -0
- package/src/commands/channels.js +551 -0
- package/src/commands/cli-type.js +101 -0
- package/src/commands/daemon.js +365 -0
- package/src/commands/doctor.js +333 -0
- package/src/commands/export-config.js +205 -0
- package/src/commands/list.js +222 -0
- package/src/commands/logs.js +261 -0
- package/src/commands/plugin.js +585 -0
- package/src/commands/port-config.js +135 -0
- package/src/commands/proxy-control.js +264 -0
- package/src/commands/proxy.js +152 -0
- package/src/commands/resume.js +137 -0
- package/src/commands/search.js +190 -0
- package/src/commands/security.js +37 -0
- package/src/commands/stats.js +398 -0
- package/src/commands/switch.js +48 -0
- package/src/commands/toggle-proxy.js +247 -0
- package/src/commands/ui.js +99 -0
- package/src/commands/update.js +97 -0
- package/src/commands/workspace.js +454 -0
- package/src/config/default.js +69 -0
- package/src/config/loader.js +149 -0
- package/src/config/model-metadata.js +167 -0
- package/src/config/model-metadata.json +125 -0
- package/src/config/model-pricing.js +35 -0
- package/src/config/paths.js +190 -0
- package/src/index.js +680 -0
- package/src/plugins/constants.js +15 -0
- package/src/plugins/event-bus.js +54 -0
- package/src/plugins/manifest-validator.js +129 -0
- package/src/plugins/plugin-api.js +128 -0
- package/src/plugins/plugin-installer.js +601 -0
- package/src/plugins/plugin-loader.js +229 -0
- package/src/plugins/plugin-manager.js +170 -0
- package/src/plugins/registry.js +152 -0
- package/src/plugins/schema/plugin-manifest.json +115 -0
- package/src/reset-config.js +94 -0
- package/src/server/api/agents.js +826 -0
- package/src/server/api/aliases.js +36 -0
- package/src/server/api/channels.js +368 -0
- package/src/server/api/claude-hooks.js +480 -0
- package/src/server/api/codex-channels.js +417 -0
- package/src/server/api/codex-projects.js +104 -0
- package/src/server/api/codex-proxy.js +195 -0
- package/src/server/api/codex-sessions.js +483 -0
- package/src/server/api/codex-statistics.js +57 -0
- package/src/server/api/commands.js +482 -0
- package/src/server/api/config-export.js +212 -0
- package/src/server/api/config-registry.js +357 -0
- package/src/server/api/config-sync.js +155 -0
- package/src/server/api/config-templates.js +248 -0
- package/src/server/api/config.js +521 -0
- package/src/server/api/convert.js +260 -0
- package/src/server/api/dashboard.js +142 -0
- package/src/server/api/env.js +144 -0
- package/src/server/api/favorites.js +77 -0
- package/src/server/api/gemini-channels.js +366 -0
- package/src/server/api/gemini-projects.js +91 -0
- package/src/server/api/gemini-proxy.js +173 -0
- package/src/server/api/gemini-sessions.js +376 -0
- package/src/server/api/gemini-statistics.js +57 -0
- package/src/server/api/health-check.js +31 -0
- package/src/server/api/mcp.js +399 -0
- package/src/server/api/opencode-channels.js +419 -0
- package/src/server/api/opencode-projects.js +99 -0
- package/src/server/api/opencode-proxy.js +207 -0
- package/src/server/api/opencode-sessions.js +327 -0
- package/src/server/api/opencode-statistics.js +57 -0
- package/src/server/api/plugins.js +463 -0
- package/src/server/api/pm2-autostart.js +269 -0
- package/src/server/api/projects.js +124 -0
- package/src/server/api/prompts.js +279 -0
- package/src/server/api/proxy.js +306 -0
- package/src/server/api/security.js +53 -0
- package/src/server/api/sessions.js +514 -0
- package/src/server/api/settings.js +142 -0
- package/src/server/api/skills.js +570 -0
- package/src/server/api/statistics.js +238 -0
- package/src/server/api/ui-config.js +64 -0
- package/src/server/api/workspaces.js +456 -0
- package/src/server/codex-proxy-server.js +681 -0
- package/src/server/dev-server.js +26 -0
- package/src/server/gemini-proxy-server.js +610 -0
- package/src/server/index.js +422 -0
- package/src/server/opencode-proxy-server.js +4771 -0
- package/src/server/proxy-server.js +669 -0
- package/src/server/services/agents-service.js +1137 -0
- package/src/server/services/alias.js +71 -0
- package/src/server/services/channel-health.js +234 -0
- package/src/server/services/channel-scheduler.js +240 -0
- package/src/server/services/channels.js +447 -0
- package/src/server/services/codex-channels.js +705 -0
- package/src/server/services/codex-config.js +90 -0
- package/src/server/services/codex-parser.js +322 -0
- package/src/server/services/codex-sessions.js +936 -0
- package/src/server/services/codex-settings-manager.js +619 -0
- package/src/server/services/codex-speed-test-template.json +24 -0
- package/src/server/services/codex-statistics-service.js +161 -0
- package/src/server/services/commands-service.js +574 -0
- package/src/server/services/config-export-service.js +1165 -0
- package/src/server/services/config-registry-service.js +828 -0
- package/src/server/services/config-sync-manager.js +941 -0
- package/src/server/services/config-sync-service.js +504 -0
- package/src/server/services/config-templates-service.js +913 -0
- package/src/server/services/enhanced-cache.js +196 -0
- package/src/server/services/env-checker.js +409 -0
- package/src/server/services/env-manager.js +436 -0
- package/src/server/services/favorites.js +165 -0
- package/src/server/services/format-converter.js +620 -0
- package/src/server/services/gemini-channels.js +459 -0
- package/src/server/services/gemini-config.js +73 -0
- package/src/server/services/gemini-sessions.js +689 -0
- package/src/server/services/gemini-settings-manager.js +263 -0
- package/src/server/services/gemini-statistics-service.js +157 -0
- package/src/server/services/health-check.js +85 -0
- package/src/server/services/mcp-client.js +790 -0
- package/src/server/services/mcp-service.js +1732 -0
- package/src/server/services/model-detector.js +1245 -0
- package/src/server/services/network-access.js +80 -0
- package/src/server/services/opencode-channels.js +366 -0
- package/src/server/services/opencode-gateway-adapters.js +1168 -0
- package/src/server/services/opencode-gateway-converter.js +639 -0
- package/src/server/services/opencode-sessions.js +931 -0
- package/src/server/services/opencode-settings-manager.js +478 -0
- package/src/server/services/opencode-statistics-service.js +161 -0
- package/src/server/services/plugins-service.js +1268 -0
- package/src/server/services/prompts-service.js +534 -0
- package/src/server/services/proxy-runtime.js +79 -0
- package/src/server/services/repo-scanner-base.js +708 -0
- package/src/server/services/request-logger.js +130 -0
- package/src/server/services/response-decoder.js +21 -0
- package/src/server/services/security-config.js +131 -0
- package/src/server/services/session-cache.js +127 -0
- package/src/server/services/session-converter.js +577 -0
- package/src/server/services/sessions.js +900 -0
- package/src/server/services/settings-manager.js +163 -0
- package/src/server/services/skill-service.js +1482 -0
- package/src/server/services/speed-test.js +1146 -0
- package/src/server/services/statistics-service.js +1043 -0
- package/src/server/services/ui-config.js +132 -0
- package/src/server/services/workspace-service.js +830 -0
- package/src/server/utils/pricing.js +73 -0
- package/src/server/websocket-server.js +513 -0
- package/src/ui/menu.js +139 -0
- package/src/ui/prompts.js +100 -0
- package/src/utils/format.js +43 -0
- package/src/utils/port-helper.js +108 -0
- package/src/utils/session.js +240 -0
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 速度测试服务
|
|
3
|
+
* 用于测试渠道 API 的响应延迟
|
|
4
|
+
* 参考 cc-switch 的实现方式
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const https = require('https');
|
|
8
|
+
const http = require('http');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const { URL } = require('url');
|
|
14
|
+
const { probeModelAvailability } = require('./model-detector');
|
|
15
|
+
const { getEffectiveApiKey: getClaudeEffectiveApiKey } = require('./channels');
|
|
16
|
+
const { getEffectiveApiKey: getCodexEffectiveApiKey } = require('./codex-channels');
|
|
17
|
+
const { getEffectiveApiKey: getGeminiEffectiveApiKey } = require('./gemini-channels');
|
|
18
|
+
const { getEffectiveApiKey: getOpenCodeEffectiveApiKey } = require('./opencode-channels');
|
|
19
|
+
|
|
20
|
+
// 测试结果缓存
|
|
21
|
+
const testResultsCache = new Map();
|
|
22
|
+
|
|
23
|
+
// 超时配置(毫秒)
|
|
24
|
+
const DEFAULT_TIMEOUT = 15000;
|
|
25
|
+
const MIN_TIMEOUT = 5000;
|
|
26
|
+
const MAX_TIMEOUT = 60000;
|
|
27
|
+
const CLAUDE_CODE_USER_AGENT = 'claude-cli/2.1.59 (external, cli)';
|
|
28
|
+
const CLAUDE_MESSAGES_BETA_FLAGS = Object.freeze([
|
|
29
|
+
'claude-code-20250219',
|
|
30
|
+
'interleaved-thinking-2025-05-14',
|
|
31
|
+
'prompt-caching-scope-2026-01-05',
|
|
32
|
+
'effort-2025-11-24'
|
|
33
|
+
]);
|
|
34
|
+
const CLAUDE_ADVANCED_TOOL_USE_BETA = 'advanced-tool-use-2025-11-20';
|
|
35
|
+
const ROUTE_OR_METHOD_MISMATCH_STATUS = new Set([404, 405, 501]);
|
|
36
|
+
const CLAUDE_USER_ID_ACCOUNT_RE = /^user_([0-9a-f]{64})_account__session_[a-z0-9._-]+$/i;
|
|
37
|
+
const CLAUDE_USER_ID_FULL_RE = /^user_[0-9a-f]{64}_account__session_[a-z0-9._-]+$/i;
|
|
38
|
+
const DEFAULT_CLAUDE_CODE_TOOL_NAMES = Object.freeze([
|
|
39
|
+
'Task',
|
|
40
|
+
'Bash',
|
|
41
|
+
'Glob',
|
|
42
|
+
'Grep',
|
|
43
|
+
'Read',
|
|
44
|
+
'Edit',
|
|
45
|
+
'Write',
|
|
46
|
+
'ToolSearch'
|
|
47
|
+
]);
|
|
48
|
+
let cachedClaudeAccountId = '';
|
|
49
|
+
let cachedClaudeUserId = '';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 规范化超时时间
|
|
53
|
+
*/
|
|
54
|
+
function sanitizeTimeout(timeout) {
|
|
55
|
+
const ms = timeout || DEFAULT_TIMEOUT;
|
|
56
|
+
return Math.min(Math.max(ms, MIN_TIMEOUT), MAX_TIMEOUT);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 规范化批量测速并发度(默认小并发)
|
|
61
|
+
*/
|
|
62
|
+
function sanitizeBatchConcurrency(concurrency, defaultValue = 2) {
|
|
63
|
+
const value = Number(concurrency);
|
|
64
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
65
|
+
return defaultValue;
|
|
66
|
+
}
|
|
67
|
+
return Math.min(Math.max(Math.round(value), 1), 5);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 按并发限制执行异步任务,保持结果顺序与输入一致
|
|
72
|
+
*/
|
|
73
|
+
async function runWithConcurrencyLimit(items, concurrency, taskFn) {
|
|
74
|
+
const list = Array.isArray(items) ? items : [];
|
|
75
|
+
if (list.length === 0) return [];
|
|
76
|
+
|
|
77
|
+
const limit = sanitizeBatchConcurrency(concurrency);
|
|
78
|
+
const results = new Array(list.length);
|
|
79
|
+
let cursor = 0;
|
|
80
|
+
|
|
81
|
+
async function worker() {
|
|
82
|
+
while (true) {
|
|
83
|
+
const currentIndex = cursor;
|
|
84
|
+
cursor += 1;
|
|
85
|
+
if (currentIndex >= list.length) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
results[currentIndex] = await taskFn(list[currentIndex], currentIndex);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const workers = [];
|
|
93
|
+
const workerCount = Math.min(limit, list.length);
|
|
94
|
+
for (let i = 0; i < workerCount; i += 1) {
|
|
95
|
+
workers.push(worker());
|
|
96
|
+
}
|
|
97
|
+
await Promise.all(workers);
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeNonEmptyString(value) {
|
|
102
|
+
if (typeof value !== 'string') return null;
|
|
103
|
+
const trimmed = value.trim();
|
|
104
|
+
return trimmed || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveExplicitModel(channel, model) {
|
|
108
|
+
return (
|
|
109
|
+
normalizeNonEmptyString(model)
|
|
110
|
+
|| normalizeNonEmptyString(channel?.model)
|
|
111
|
+
|| normalizeNonEmptyString(channel?.modelConfig?.model)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveEffectiveApiKey(channel, channelType) {
|
|
116
|
+
switch (channelType) {
|
|
117
|
+
case 'codex':
|
|
118
|
+
return getCodexEffectiveApiKey(channel);
|
|
119
|
+
case 'gemini':
|
|
120
|
+
return getGeminiEffectiveApiKey(channel);
|
|
121
|
+
case 'opencode':
|
|
122
|
+
return getOpenCodeEffectiveApiKey(channel);
|
|
123
|
+
case 'claude':
|
|
124
|
+
default:
|
|
125
|
+
return getClaudeEffectiveApiKey(channel);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function mapStainlessOs() {
|
|
130
|
+
switch (process.platform) {
|
|
131
|
+
case 'darwin':
|
|
132
|
+
return 'MacOS';
|
|
133
|
+
case 'win32':
|
|
134
|
+
return 'Windows';
|
|
135
|
+
case 'linux':
|
|
136
|
+
return 'Linux';
|
|
137
|
+
default:
|
|
138
|
+
return `other::${process.platform}`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function mapStainlessArch() {
|
|
143
|
+
switch (process.arch) {
|
|
144
|
+
case 'x64':
|
|
145
|
+
return 'x64';
|
|
146
|
+
case 'arm64':
|
|
147
|
+
return 'arm64';
|
|
148
|
+
case 'ia32':
|
|
149
|
+
return 'x86';
|
|
150
|
+
default:
|
|
151
|
+
return `other::${process.arch}`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildClaudeBetaHeader(options = {}) {
|
|
156
|
+
const hasTools = !!options.hasTools;
|
|
157
|
+
const betaFlags = [...CLAUDE_MESSAGES_BETA_FLAGS];
|
|
158
|
+
if (hasTools) {
|
|
159
|
+
betaFlags.push(CLAUDE_ADVANCED_TOOL_USE_BETA);
|
|
160
|
+
}
|
|
161
|
+
return betaFlags.join(',');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildClaudeRequestHeaders(apiKey, options = {}) {
|
|
165
|
+
return {
|
|
166
|
+
'x-api-key': apiKey || '',
|
|
167
|
+
authorization: `Bearer ${apiKey || ''}`,
|
|
168
|
+
'anthropic-version': '2023-06-01',
|
|
169
|
+
'anthropic-beta': buildClaudeBetaHeader(options),
|
|
170
|
+
'anthropic-dangerous-direct-browser-access': 'true',
|
|
171
|
+
'x-app': 'cli',
|
|
172
|
+
'x-stainless-retry-count': '0',
|
|
173
|
+
'x-stainless-timeout': '600',
|
|
174
|
+
'x-stainless-runtime-version': process.version,
|
|
175
|
+
'x-stainless-package-version': '0.74.0',
|
|
176
|
+
'x-stainless-lang': 'js',
|
|
177
|
+
'x-stainless-runtime': 'node',
|
|
178
|
+
'x-stainless-arch': mapStainlessArch(),
|
|
179
|
+
'x-stainless-os': mapStainlessOs(),
|
|
180
|
+
accept: 'application/json',
|
|
181
|
+
'accept-encoding': 'gzip, deflate',
|
|
182
|
+
'accept-language': '*',
|
|
183
|
+
'sec-fetch-mode': 'cors',
|
|
184
|
+
connection: 'keep-alive',
|
|
185
|
+
'content-type': 'application/json',
|
|
186
|
+
'user-agent': CLAUDE_CODE_USER_AGENT
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resolveClaudeAccountIdFromUserId(userId = '') {
|
|
191
|
+
const value = normalizeNonEmptyString(userId);
|
|
192
|
+
if (!value) return '';
|
|
193
|
+
const matched = value.match(CLAUDE_USER_ID_ACCOUNT_RE);
|
|
194
|
+
return matched ? matched[1].toLowerCase() : '';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveClaudeAccountIdFromLogs() {
|
|
198
|
+
const logsPath = path.join(os.homedir(), '.cc-tool', 'claude-requests.jsonl');
|
|
199
|
+
if (!fs.existsSync(logsPath)) return '';
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const content = fs.readFileSync(logsPath, 'utf8');
|
|
203
|
+
const lines = content.trim().split('\n');
|
|
204
|
+
const accountIdCount = new Map();
|
|
205
|
+
|
|
206
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
207
|
+
const line = lines[index].trim();
|
|
208
|
+
if (!line) continue;
|
|
209
|
+
try {
|
|
210
|
+
const parsed = JSON.parse(line);
|
|
211
|
+
const userId = parsed?.request?.body?.metadata?.user_id;
|
|
212
|
+
const accountId = resolveClaudeAccountIdFromUserId(userId);
|
|
213
|
+
if (accountId) {
|
|
214
|
+
accountIdCount.set(accountId, (accountIdCount.get(accountId) || 0) + 1);
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
// ignore malformed line
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const ranked = Array.from(accountIdCount.entries())
|
|
222
|
+
.filter(([accountId]) => accountId !== '0'.repeat(64))
|
|
223
|
+
.sort((left, right) => right[1] - left[1]);
|
|
224
|
+
|
|
225
|
+
if (ranked.length > 0) {
|
|
226
|
+
return ranked[0][0];
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// ignore read error
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return '';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function resolveClaudeUserIdFromLogs() {
|
|
236
|
+
const logsPath = path.join(os.homedir(), '.cc-tool', 'claude-requests.jsonl');
|
|
237
|
+
if (!fs.existsSync(logsPath)) return '';
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const content = fs.readFileSync(logsPath, 'utf8');
|
|
241
|
+
const lines = content.trim().split('\n');
|
|
242
|
+
const userIdCount = new Map();
|
|
243
|
+
|
|
244
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
245
|
+
const line = lines[index].trim();
|
|
246
|
+
if (!line) continue;
|
|
247
|
+
try {
|
|
248
|
+
const parsed = JSON.parse(line);
|
|
249
|
+
const userId = normalizeNonEmptyString(parsed?.request?.body?.metadata?.user_id);
|
|
250
|
+
if (!userId || !CLAUDE_USER_ID_FULL_RE.test(userId)) continue;
|
|
251
|
+
const accountId = resolveClaudeAccountIdFromUserId(userId);
|
|
252
|
+
if (!accountId || accountId === '0'.repeat(64)) continue;
|
|
253
|
+
userIdCount.set(userId, (userIdCount.get(userId) || 0) + 1);
|
|
254
|
+
} catch {
|
|
255
|
+
// ignore malformed line
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const ranked = Array.from(userIdCount.entries()).sort((left, right) => right[1] - left[1]);
|
|
260
|
+
return ranked.length > 0 ? ranked[0][0] : '';
|
|
261
|
+
} catch {
|
|
262
|
+
return '';
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function resolveClaudeRequestTemplate() {
|
|
267
|
+
const logsPath = path.join(os.homedir(), '.cc-tool', 'claude-requests.jsonl');
|
|
268
|
+
if (!fs.existsSync(logsPath)) return null;
|
|
269
|
+
try {
|
|
270
|
+
const content = fs.readFileSync(logsPath, 'utf8');
|
|
271
|
+
const lines = content.trim().split('\n');
|
|
272
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
273
|
+
const line = lines[i].trim();
|
|
274
|
+
if (!line) continue;
|
|
275
|
+
try {
|
|
276
|
+
const parsed = JSON.parse(line);
|
|
277
|
+
const body = parsed?.request?.body;
|
|
278
|
+
if (!body || typeof body !== 'object') continue;
|
|
279
|
+
const system = Array.isArray(body.system) ? body.system : [];
|
|
280
|
+
const tools = Array.isArray(body.tools) ? body.tools : [];
|
|
281
|
+
const hasBilling = system.some(b => typeof b?.text === 'string' && b.text.startsWith('x-anthropic-billing-header:'));
|
|
282
|
+
if (!hasBilling || tools.length === 0) continue;
|
|
283
|
+
return { system, tools };
|
|
284
|
+
} catch { }
|
|
285
|
+
}
|
|
286
|
+
} catch { }
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function resolveClaudePreferredUserId() {
|
|
291
|
+
if (cachedClaudeUserId) {
|
|
292
|
+
return cachedClaudeUserId;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const envUserId = normalizeNonEmptyString(
|
|
296
|
+
process.env.OPENCODE_CLAUDE_USER_ID || process.env.CLAUDE_CODE_USER_ID
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (envUserId && CLAUDE_USER_ID_FULL_RE.test(envUserId)) {
|
|
300
|
+
cachedClaudeUserId = envUserId;
|
|
301
|
+
return cachedClaudeUserId;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const fromLogs = resolveClaudeUserIdFromLogs();
|
|
305
|
+
if (fromLogs) {
|
|
306
|
+
cachedClaudeUserId = fromLogs;
|
|
307
|
+
return cachedClaudeUserId;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return '';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function resolveClaudeAccountId() {
|
|
314
|
+
if (cachedClaudeAccountId) {
|
|
315
|
+
return cachedClaudeAccountId;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const envAccountId = String(
|
|
319
|
+
process.env.OPENCODE_CLAUDE_ACCOUNT_ID || process.env.CLAUDE_CODE_ACCOUNT_ID || ''
|
|
320
|
+
).trim().toLowerCase();
|
|
321
|
+
|
|
322
|
+
if (/^[0-9a-f]{64}$/.test(envAccountId)) {
|
|
323
|
+
cachedClaudeAccountId = envAccountId;
|
|
324
|
+
return cachedClaudeAccountId;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const fromLogs = resolveClaudeAccountIdFromLogs();
|
|
328
|
+
if (fromLogs) {
|
|
329
|
+
cachedClaudeAccountId = fromLogs;
|
|
330
|
+
return cachedClaudeAccountId;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
cachedClaudeAccountId = '0'.repeat(64);
|
|
334
|
+
return cachedClaudeAccountId;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildClaudeCodeUserId() {
|
|
338
|
+
const preferredUserId = resolveClaudePreferredUserId();
|
|
339
|
+
if (preferredUserId) {
|
|
340
|
+
return preferredUserId;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const accountId = resolveClaudeAccountId();
|
|
344
|
+
const sessionId = typeof crypto.randomUUID === 'function'
|
|
345
|
+
? crypto.randomUUID()
|
|
346
|
+
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
347
|
+
return `user_${accountId}_account__session_${sessionId}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function buildDefaultClaudeCodeTools() {
|
|
351
|
+
return DEFAULT_CLAUDE_CODE_TOOL_NAMES.map(name => ({
|
|
352
|
+
name,
|
|
353
|
+
description: `${name} tool`,
|
|
354
|
+
input_schema: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
properties: {},
|
|
357
|
+
additionalProperties: true
|
|
358
|
+
}
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function buildGeminiNativeGeneratePath(parsedUrl, model) {
|
|
363
|
+
let pathname = parsedUrl.pathname.replace(/\/+$/, '');
|
|
364
|
+
const modelsIndex = pathname.indexOf('/models');
|
|
365
|
+
if (modelsIndex >= 0) {
|
|
366
|
+
pathname = pathname.slice(0, modelsIndex);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let apiBasePath;
|
|
370
|
+
if (!pathname || pathname === '/') {
|
|
371
|
+
apiBasePath = '/v1beta';
|
|
372
|
+
} else if (pathname.endsWith('/v1beta') || pathname.endsWith('/v1')) {
|
|
373
|
+
apiBasePath = pathname;
|
|
374
|
+
} else {
|
|
375
|
+
apiBasePath = `${pathname}/v1beta`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return `${apiBasePath}/models/${encodeURIComponent(model)}:generateContent`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function buildGeminiCliGeneratePath(parsedUrl) {
|
|
382
|
+
let pathname = parsedUrl.pathname.replace(/\/+$/, '');
|
|
383
|
+
if (!pathname || pathname === '/') {
|
|
384
|
+
return '/v1internal:generateContent';
|
|
385
|
+
}
|
|
386
|
+
if (pathname.endsWith(':streamGenerateContent')) {
|
|
387
|
+
return pathname.replace(/:streamGenerateContent$/, ':generateContent');
|
|
388
|
+
}
|
|
389
|
+
if (pathname.endsWith(':generateContent')) {
|
|
390
|
+
return pathname;
|
|
391
|
+
}
|
|
392
|
+
if (pathname.endsWith('/v1internal')) {
|
|
393
|
+
return `${pathname}:generateContent`;
|
|
394
|
+
}
|
|
395
|
+
if (pathname.endsWith('/v1')) {
|
|
396
|
+
return '/v1internal:generateContent';
|
|
397
|
+
}
|
|
398
|
+
return `${pathname}/v1internal:generateContent`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function buildCodexResponsesPath(parsedUrl) {
|
|
402
|
+
let pathname = parsedUrl.pathname.replace(/\/+$/, '');
|
|
403
|
+
if (!pathname || pathname === '/') {
|
|
404
|
+
return '/responses';
|
|
405
|
+
}
|
|
406
|
+
if (pathname.endsWith('/responses') || pathname.endsWith('/v1/responses')) {
|
|
407
|
+
return pathname;
|
|
408
|
+
}
|
|
409
|
+
if (pathname.endsWith('/v1')) {
|
|
410
|
+
return `${pathname}/responses`;
|
|
411
|
+
}
|
|
412
|
+
return `${pathname}/responses`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function shouldUseGeminiCliFormat(parsedUrl) {
|
|
416
|
+
const host = String(parsedUrl.hostname || '').toLowerCase();
|
|
417
|
+
const pathname = parsedUrl.pathname.replace(/\/+$/, '');
|
|
418
|
+
|
|
419
|
+
if (pathname.includes('/v1internal') || pathname.endsWith(':generateContent') || pathname.endsWith(':streamGenerateContent')) {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
if (pathname.includes('/v1beta') || pathname.includes('/models/')) {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
if (host.includes('cloudcode-pa.googleapis.com')) {
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
if (!pathname || pathname === '/') {
|
|
429
|
+
return !host.includes('generativelanguage.googleapis.com') && !host.includes('aiplatform.googleapis.com');
|
|
430
|
+
}
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function extractJsonPayloads(responseData) {
|
|
435
|
+
const payloads = [];
|
|
436
|
+
const text = typeof responseData === 'string' ? responseData : String(responseData || '');
|
|
437
|
+
if (!text.trim()) {
|
|
438
|
+
return payloads;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
payloads.push(JSON.parse(text));
|
|
443
|
+
} catch {
|
|
444
|
+
// ignore and continue parsing SSE fragments
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const lines = text.split(/\r?\n/);
|
|
448
|
+
for (const line of lines) {
|
|
449
|
+
const trimmed = line.trim();
|
|
450
|
+
if (!trimmed.startsWith('data:')) continue;
|
|
451
|
+
const rawData = trimmed.slice(5).trim();
|
|
452
|
+
if (!rawData || rawData === '[DONE]') continue;
|
|
453
|
+
try {
|
|
454
|
+
payloads.push(JSON.parse(rawData));
|
|
455
|
+
} catch {
|
|
456
|
+
// ignore invalid SSE fragment
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return payloads;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* 测试单个渠道的连接速度和 API 功能
|
|
465
|
+
* @param {Object} channel - 渠道配置
|
|
466
|
+
* @param {number} timeout - 超时时间(毫秒)
|
|
467
|
+
* @param {string} channelType - 渠道类型:'claude' | 'codex' | 'gemini'
|
|
468
|
+
* @returns {Promise<Object>} 测试结果
|
|
469
|
+
*/
|
|
470
|
+
async function testChannelSpeed(channel, timeout = DEFAULT_TIMEOUT, channelType = 'claude') {
|
|
471
|
+
const sanitizedTimeout = sanitizeTimeout(timeout);
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
if (!channel.baseUrl) {
|
|
475
|
+
return {
|
|
476
|
+
channelId: channel.id,
|
|
477
|
+
channelName: channel.name,
|
|
478
|
+
success: false,
|
|
479
|
+
networkOk: false,
|
|
480
|
+
apiOk: false,
|
|
481
|
+
error: 'URL 不能为空',
|
|
482
|
+
latency: null,
|
|
483
|
+
statusCode: null,
|
|
484
|
+
testedAt: Date.now()
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// 规范化 URL(去除末尾斜杠)
|
|
489
|
+
let testUrl;
|
|
490
|
+
try {
|
|
491
|
+
const url = new URL(channel.baseUrl.trim());
|
|
492
|
+
testUrl = url.toString().replace(/\/+$/, '');
|
|
493
|
+
} catch (urlError) {
|
|
494
|
+
return {
|
|
495
|
+
channelId: channel.id,
|
|
496
|
+
channelName: channel.name,
|
|
497
|
+
success: false,
|
|
498
|
+
networkOk: false,
|
|
499
|
+
apiOk: false,
|
|
500
|
+
error: `URL 无效: ${urlError.message}`,
|
|
501
|
+
latency: null,
|
|
502
|
+
statusCode: null,
|
|
503
|
+
testedAt: Date.now()
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const effectiveApiKey = resolveEffectiveApiKey(channel, channelType);
|
|
508
|
+
if (!effectiveApiKey) {
|
|
509
|
+
return {
|
|
510
|
+
channelId: channel.id,
|
|
511
|
+
channelName: channel.name,
|
|
512
|
+
success: false,
|
|
513
|
+
networkOk: false,
|
|
514
|
+
apiOk: false,
|
|
515
|
+
error: 'API Key 未配置',
|
|
516
|
+
latency: null,
|
|
517
|
+
statusCode: null,
|
|
518
|
+
testedAt: Date.now()
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 直接测试 API 功能(发送测试消息)
|
|
523
|
+
// 不再单独测试网络连通性,因为直接 GET base_url 可能返回 404
|
|
524
|
+
const apiResult = await testAPIFunctionality(
|
|
525
|
+
testUrl,
|
|
526
|
+
effectiveApiKey,
|
|
527
|
+
sanitizedTimeout,
|
|
528
|
+
channelType,
|
|
529
|
+
channel.model,
|
|
530
|
+
channel
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
const success = apiResult.success;
|
|
534
|
+
const networkOk = apiResult.latency !== null; // 如果有延迟数据,说明网络是通的
|
|
535
|
+
|
|
536
|
+
// 缓存结果
|
|
537
|
+
const finalResult = {
|
|
538
|
+
channelId: channel.id,
|
|
539
|
+
channelName: channel.name,
|
|
540
|
+
success,
|
|
541
|
+
networkOk,
|
|
542
|
+
apiOk: success,
|
|
543
|
+
statusCode: apiResult.statusCode || null,
|
|
544
|
+
error: success ? null : (apiResult.error || '测试失败'),
|
|
545
|
+
latency: apiResult.latency ?? null, // 无论成功失败都保留延迟数据(保留 0ms)
|
|
546
|
+
testedAt: Date.now(),
|
|
547
|
+
testedModel: apiResult.testedModel,
|
|
548
|
+
availableModels: apiResult.availableModels,
|
|
549
|
+
modelDetectionMethod: apiResult.modelDetectionMethod
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
testResultsCache.set(channel.id, finalResult);
|
|
553
|
+
|
|
554
|
+
return finalResult;
|
|
555
|
+
} catch (error) {
|
|
556
|
+
return {
|
|
557
|
+
channelId: channel.id,
|
|
558
|
+
channelName: channel.name,
|
|
559
|
+
success: false,
|
|
560
|
+
networkOk: false,
|
|
561
|
+
apiOk: false,
|
|
562
|
+
error: error.message || '连接失败',
|
|
563
|
+
latency: null,
|
|
564
|
+
statusCode: null,
|
|
565
|
+
testedAt: Date.now()
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* 测试网络连通性(简单 GET 请求)
|
|
572
|
+
*/
|
|
573
|
+
function testNetworkConnectivity(url, apiKey, timeout) {
|
|
574
|
+
return new Promise((resolve) => {
|
|
575
|
+
const startTime = Date.now();
|
|
576
|
+
const parsedUrl = new URL(url);
|
|
577
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
578
|
+
const httpModule = isHttps ? https : http;
|
|
579
|
+
|
|
580
|
+
const options = {
|
|
581
|
+
hostname: parsedUrl.hostname,
|
|
582
|
+
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
583
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
584
|
+
method: 'GET',
|
|
585
|
+
timeout,
|
|
586
|
+
headers: {
|
|
587
|
+
'Authorization': `Bearer ${apiKey || ''}`,
|
|
588
|
+
'Content-Type': 'application/json',
|
|
589
|
+
'User-Agent': 'Coding-Tool-SpeedTest/1.0'
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const req = httpModule.request(options, (res) => {
|
|
594
|
+
let data = '';
|
|
595
|
+
res.on('data', chunk => { data += chunk; });
|
|
596
|
+
res.on('end', () => {
|
|
597
|
+
const latency = Date.now() - startTime;
|
|
598
|
+
resolve({
|
|
599
|
+
statusCode: res.statusCode,
|
|
600
|
+
latency,
|
|
601
|
+
error: null
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
req.on('error', (error) => {
|
|
607
|
+
let errorMsg;
|
|
608
|
+
if (error.code === 'ECONNREFUSED') {
|
|
609
|
+
errorMsg = '连接被拒绝';
|
|
610
|
+
} else if (error.code === 'ETIMEDOUT') {
|
|
611
|
+
errorMsg = '连接超时';
|
|
612
|
+
} else if (error.code === 'ENOTFOUND') {
|
|
613
|
+
errorMsg = 'DNS 解析失败';
|
|
614
|
+
} else if (error.code === 'ECONNRESET') {
|
|
615
|
+
errorMsg = '连接被重置';
|
|
616
|
+
} else {
|
|
617
|
+
errorMsg = error.message || '连接失败';
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
resolve({
|
|
621
|
+
statusCode: null,
|
|
622
|
+
latency: null,
|
|
623
|
+
error: errorMsg
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
req.on('timeout', () => {
|
|
628
|
+
req.destroy();
|
|
629
|
+
resolve({
|
|
630
|
+
statusCode: null,
|
|
631
|
+
latency: null,
|
|
632
|
+
error: '请求超时'
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
req.end();
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* 测试 API 功能(发送真实的聊天请求)
|
|
642
|
+
* 根据渠道类型选择正确的 API 格式
|
|
643
|
+
* @param {string} baseUrl - 基础 URL
|
|
644
|
+
* @param {string} apiKey - API Key
|
|
645
|
+
* @param {number} timeout - 超时时间
|
|
646
|
+
* @param {string} channelType - 渠道类型:'claude' | 'codex' | 'gemini'
|
|
647
|
+
* @param {string} model - 模型名称(可选,用于 Gemini)
|
|
648
|
+
* @param {Object} channel - 完整渠道配置(用于模型检测)
|
|
649
|
+
*/
|
|
650
|
+
async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'claude', model = null, channel = null) {
|
|
651
|
+
// Probe model availability if channel is provided
|
|
652
|
+
let modelProbe = null;
|
|
653
|
+
if (channel) {
|
|
654
|
+
const configuredSpeedTestModel = normalizeNonEmptyString(channel.speedTestModel);
|
|
655
|
+
const explicitModel = resolveExplicitModel(channel, model);
|
|
656
|
+
|
|
657
|
+
// 优先使用 speedTestModel,避免测速时额外探测
|
|
658
|
+
if (configuredSpeedTestModel) {
|
|
659
|
+
// Use the explicitly configured model for speed testing
|
|
660
|
+
modelProbe = {
|
|
661
|
+
preferredTestModel: configuredSpeedTestModel,
|
|
662
|
+
availableModels: [configuredSpeedTestModel],
|
|
663
|
+
cached: false,
|
|
664
|
+
method: 'configured'
|
|
665
|
+
};
|
|
666
|
+
console.log(`[SpeedTest] Using configured speedTestModel: ${configuredSpeedTestModel}`);
|
|
667
|
+
} else if (explicitModel) {
|
|
668
|
+
modelProbe = {
|
|
669
|
+
preferredTestModel: explicitModel,
|
|
670
|
+
availableModels: [explicitModel],
|
|
671
|
+
cached: false,
|
|
672
|
+
method: 'configured'
|
|
673
|
+
};
|
|
674
|
+
console.log(`[SpeedTest] Using explicit model: ${explicitModel}`);
|
|
675
|
+
} else {
|
|
676
|
+
// Fall back to auto-detection
|
|
677
|
+
try {
|
|
678
|
+
modelProbe = await probeModelAvailability(channel, channelType, { stopOnFirstAvailable: true });
|
|
679
|
+
} catch (error) {
|
|
680
|
+
console.error('[SpeedTest] Model detection failed:', error.message);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const parsedUrl = new URL(baseUrl);
|
|
686
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
687
|
+
const httpModule = isHttps ? https : http;
|
|
688
|
+
|
|
689
|
+
// 根据渠道类型确定 API 路径和请求格式
|
|
690
|
+
let testModel = null;
|
|
691
|
+
let primaryRequestConfig = null;
|
|
692
|
+
let fallbackRequestConfig = null;
|
|
693
|
+
|
|
694
|
+
// Helper to create result object with model info
|
|
695
|
+
const createResult = (result) => ({
|
|
696
|
+
...result,
|
|
697
|
+
testedModel: testModel,
|
|
698
|
+
availableModels: modelProbe?.availableModels,
|
|
699
|
+
modelDetectionMethod: modelProbe?.method || (modelProbe?.cached ? 'cached' : 'probed')
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
const parseErrorMessage = (responseData) => {
|
|
703
|
+
const payloads = extractJsonPayloads(responseData);
|
|
704
|
+
for (const payload of payloads) {
|
|
705
|
+
const message = payload?.error?.message || payload?.message || payload?.detail || payload?.error_description;
|
|
706
|
+
if (message) return message;
|
|
707
|
+
}
|
|
708
|
+
return null;
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const UNEXPECTED_ERROR_PATTERNS = [
|
|
712
|
+
/unexpected/i,
|
|
713
|
+
/internal.*error/i,
|
|
714
|
+
/something.*went.*wrong/i,
|
|
715
|
+
/service.*unavailable/i,
|
|
716
|
+
/temporarily.*unavailable/i,
|
|
717
|
+
/try.*again.*later/i,
|
|
718
|
+
/server.*error/i,
|
|
719
|
+
/bad.*gateway/i,
|
|
720
|
+
/gateway.*timeout/i
|
|
721
|
+
];
|
|
722
|
+
|
|
723
|
+
function containsUnexpectedError(responseBody) {
|
|
724
|
+
const payloads = extractJsonPayloads(responseBody);
|
|
725
|
+
for (const payload of payloads) {
|
|
726
|
+
// Only treat error as real error when it has actual content (not null/empty)
|
|
727
|
+
const errorField = payload?.error;
|
|
728
|
+
if (errorField && typeof errorField === 'object' && (errorField.message || errorField.type)) {
|
|
729
|
+
return { hasError: true, message: errorField.message || String(errorField.type) };
|
|
730
|
+
}
|
|
731
|
+
if (errorField && typeof errorField === 'string' && errorField.trim()) {
|
|
732
|
+
return { hasError: true, message: errorField };
|
|
733
|
+
}
|
|
734
|
+
const message = payload?.message || payload?.detail || payload?.error_description || '';
|
|
735
|
+
for (const pattern of UNEXPECTED_ERROR_PATTERNS) {
|
|
736
|
+
if (pattern.test(message)) {
|
|
737
|
+
return { hasError: true, message };
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return { hasError: false };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (channelType === 'claude') {
|
|
745
|
+
// Anthropic Messages API - 模拟 Claude Code 请求格式
|
|
746
|
+
let apiPath = parsedUrl.pathname.replace(/\/$/, '');
|
|
747
|
+
if (!apiPath.endsWith('/messages')) {
|
|
748
|
+
apiPath = apiPath + (apiPath.endsWith('/v1') ? '/messages' : '/v1/messages');
|
|
749
|
+
}
|
|
750
|
+
apiPath += '?beta=true';
|
|
751
|
+
|
|
752
|
+
testModel = modelProbe?.preferredTestModel || normalizeNonEmptyString(model) || 'claude-sonnet-4-20250514';
|
|
753
|
+
const sessionId = typeof crypto.randomUUID === 'function'
|
|
754
|
+
? crypto.randomUUID()
|
|
755
|
+
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
756
|
+
const userId = buildClaudeCodeUserId() || `user_${'0'.repeat(64)}_account__session_${sessionId}`;
|
|
757
|
+
const template = resolveClaudeRequestTemplate();
|
|
758
|
+
const systemBlocks = template?.system?.length > 0
|
|
759
|
+
? template.system
|
|
760
|
+
: [{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." }];
|
|
761
|
+
const toolsToUse = template?.tools?.length > 0
|
|
762
|
+
? template.tools
|
|
763
|
+
: buildDefaultClaudeCodeTools();
|
|
764
|
+
const requestPayload = {
|
|
765
|
+
model: testModel,
|
|
766
|
+
max_tokens: 10,
|
|
767
|
+
temperature: 1,
|
|
768
|
+
stream: true,
|
|
769
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }],
|
|
770
|
+
system: systemBlocks,
|
|
771
|
+
metadata: { user_id: userId },
|
|
772
|
+
tools: toolsToUse
|
|
773
|
+
};
|
|
774
|
+
primaryRequestConfig = {
|
|
775
|
+
apiPath,
|
|
776
|
+
requestBody: JSON.stringify(requestPayload),
|
|
777
|
+
headers: buildClaudeRequestHeaders(apiKey, { hasTools: true }),
|
|
778
|
+
isStreamingResponse: true
|
|
779
|
+
};
|
|
780
|
+
// Fallback: non-streaming for gateways that don't support SSE
|
|
781
|
+
fallbackRequestConfig = {
|
|
782
|
+
apiPath,
|
|
783
|
+
requestBody: JSON.stringify({ ...requestPayload, stream: false }),
|
|
784
|
+
headers: buildClaudeRequestHeaders(apiKey, { hasTools: true }),
|
|
785
|
+
isStreamingResponse: false
|
|
786
|
+
};
|
|
787
|
+
} else if (channelType === 'codex') {
|
|
788
|
+
const apiPath = buildCodexResponsesPath(parsedUrl);
|
|
789
|
+
testModel = modelProbe?.preferredTestModel || normalizeNonEmptyString(model) || 'gpt-5-codex';
|
|
790
|
+
const codexSessionId = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
|
791
|
+
|
|
792
|
+
const baseBody = {
|
|
793
|
+
model: testModel,
|
|
794
|
+
instructions: 'You are Codex.',
|
|
795
|
+
input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'ping' }] }],
|
|
796
|
+
store: false,
|
|
797
|
+
prompt_cache_key: codexSessionId
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
primaryRequestConfig = {
|
|
801
|
+
apiPath,
|
|
802
|
+
requestBody: JSON.stringify({ ...baseBody, stream: false }),
|
|
803
|
+
headers: {
|
|
804
|
+
'Authorization': `Bearer ${apiKey || ''}`,
|
|
805
|
+
'Accept': 'application/json',
|
|
806
|
+
'Connection': 'Keep-Alive',
|
|
807
|
+
'Version': '0.101.0',
|
|
808
|
+
'Session_id': codexSessionId,
|
|
809
|
+
'Originator': 'codex_cli_rs',
|
|
810
|
+
'Content-Type': 'application/json',
|
|
811
|
+
'User-Agent': 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464',
|
|
812
|
+
'openai-beta': 'responses=experimental'
|
|
813
|
+
},
|
|
814
|
+
isStreamingResponse: false
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
fallbackRequestConfig = {
|
|
818
|
+
apiPath,
|
|
819
|
+
requestBody: JSON.stringify({ ...baseBody, stream: true }),
|
|
820
|
+
headers: {
|
|
821
|
+
...primaryRequestConfig.headers,
|
|
822
|
+
'Accept': 'text/event-stream'
|
|
823
|
+
},
|
|
824
|
+
isStreamingResponse: true
|
|
825
|
+
};
|
|
826
|
+
} else if (channelType === 'gemini') {
|
|
827
|
+
testModel = modelProbe?.preferredTestModel || normalizeNonEmptyString(model) || 'gemini-2.5-pro';
|
|
828
|
+
const useCliFormat = shouldUseGeminiCliFormat(parsedUrl);
|
|
829
|
+
|
|
830
|
+
const cliRequestConfig = {
|
|
831
|
+
apiPath: buildGeminiCliGeneratePath(parsedUrl),
|
|
832
|
+
requestBody: JSON.stringify({
|
|
833
|
+
project: '',
|
|
834
|
+
model: testModel,
|
|
835
|
+
request: {
|
|
836
|
+
contents: [{ role: 'user', parts: [{ text: 'ping' }] }],
|
|
837
|
+
generationConfig: { maxOutputTokens: 1, temperature: 0 }
|
|
838
|
+
}
|
|
839
|
+
}),
|
|
840
|
+
headers: {
|
|
841
|
+
'Authorization': `Bearer ${apiKey || ''}`,
|
|
842
|
+
'x-goog-api-key': apiKey || '',
|
|
843
|
+
'Accept': 'application/json',
|
|
844
|
+
'Content-Type': 'application/json',
|
|
845
|
+
'User-Agent': 'google-api-nodejs-client/9.15.1',
|
|
846
|
+
'X-Goog-Api-Client': 'gl-node/22.17.0',
|
|
847
|
+
'Client-Metadata': 'ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI'
|
|
848
|
+
},
|
|
849
|
+
isStreamingResponse: false
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
const nativeRequestConfig = {
|
|
853
|
+
apiPath: buildGeminiNativeGeneratePath(parsedUrl, testModel),
|
|
854
|
+
requestBody: JSON.stringify({
|
|
855
|
+
contents: [{ role: 'user', parts: [{ text: 'ping' }] }],
|
|
856
|
+
generationConfig: { maxOutputTokens: 1, temperature: 0 }
|
|
857
|
+
}),
|
|
858
|
+
headers: {
|
|
859
|
+
'Authorization': `Bearer ${apiKey || ''}`,
|
|
860
|
+
'x-goog-api-key': apiKey || '',
|
|
861
|
+
'Accept': 'application/json',
|
|
862
|
+
'Content-Type': 'application/json',
|
|
863
|
+
'User-Agent': 'google-genai-sdk/0.8.0'
|
|
864
|
+
},
|
|
865
|
+
isStreamingResponse: false
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
primaryRequestConfig = useCliFormat ? cliRequestConfig : nativeRequestConfig;
|
|
869
|
+
fallbackRequestConfig = useCliFormat ? nativeRequestConfig : cliRequestConfig;
|
|
870
|
+
} else {
|
|
871
|
+
let apiPath = parsedUrl.pathname.replace(/\/$/, '');
|
|
872
|
+
if (!apiPath.endsWith('/chat/completions')) {
|
|
873
|
+
apiPath = apiPath + (apiPath.endsWith('/v1') ? '/chat/completions' : '/v1/chat/completions');
|
|
874
|
+
}
|
|
875
|
+
primaryRequestConfig = {
|
|
876
|
+
apiPath,
|
|
877
|
+
requestBody: JSON.stringify({
|
|
878
|
+
model: 'gpt-4o-mini',
|
|
879
|
+
max_tokens: 1,
|
|
880
|
+
messages: [{ role: 'user', content: 'Hi' }]
|
|
881
|
+
}),
|
|
882
|
+
headers: {
|
|
883
|
+
'Authorization': `Bearer ${apiKey || ''}`,
|
|
884
|
+
'Content-Type': 'application/json',
|
|
885
|
+
'User-Agent': 'Coding-Tool-SpeedTest/1.0'
|
|
886
|
+
},
|
|
887
|
+
isStreamingResponse: false
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const executeRequest = (requestConfig) => new Promise((resolve) => {
|
|
892
|
+
const startTime = Date.now();
|
|
893
|
+
const options = {
|
|
894
|
+
hostname: parsedUrl.hostname,
|
|
895
|
+
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
896
|
+
path: requestConfig.apiPath,
|
|
897
|
+
method: 'POST',
|
|
898
|
+
timeout,
|
|
899
|
+
headers: requestConfig.headers
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
const req = httpModule.request(options, (res) => {
|
|
903
|
+
let data = '';
|
|
904
|
+
let resolved = false;
|
|
905
|
+
|
|
906
|
+
res.on('data', chunk => {
|
|
907
|
+
data += chunk;
|
|
908
|
+
const chunkStr = chunk.toString();
|
|
909
|
+
|
|
910
|
+
if (requestConfig.isStreamingResponse && !resolved && res.statusCode >= 200 && res.statusCode < 300) {
|
|
911
|
+
// Claude SSE events: message_start, ping, content_block_start, content_block_delta, message_delta, message_stop
|
|
912
|
+
// Codex SSE events: response.created, response.in_progress
|
|
913
|
+
const isClaudeStreamSuccess = chunkStr.includes('message_start') || chunkStr.includes('"message_stop"') || chunkStr.includes('"ping"') || chunkStr.includes('content_block');
|
|
914
|
+
const isCodexStreamSuccess = chunkStr.includes('response.created') || chunkStr.includes('response.in_progress');
|
|
915
|
+
if (isClaudeStreamSuccess || isCodexStreamSuccess) {
|
|
916
|
+
resolved = true;
|
|
917
|
+
const latency = Date.now() - startTime;
|
|
918
|
+
req.destroy();
|
|
919
|
+
resolve(createResult({
|
|
920
|
+
success: true,
|
|
921
|
+
latency,
|
|
922
|
+
error: null,
|
|
923
|
+
statusCode: res.statusCode
|
|
924
|
+
}));
|
|
925
|
+
} else if (chunkStr.includes('"detail"') || chunkStr.includes('"error"')) {
|
|
926
|
+
const errorCheck = containsUnexpectedError(chunkStr);
|
|
927
|
+
if (errorCheck.hasError) {
|
|
928
|
+
resolved = true;
|
|
929
|
+
const latency = Date.now() - startTime;
|
|
930
|
+
req.destroy();
|
|
931
|
+
resolve(createResult({
|
|
932
|
+
success: false,
|
|
933
|
+
latency,
|
|
934
|
+
error: errorCheck.message,
|
|
935
|
+
statusCode: res.statusCode
|
|
936
|
+
}));
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
res.on('end', () => {
|
|
943
|
+
if (resolved) return;
|
|
944
|
+
|
|
945
|
+
const latency = Date.now() - startTime;
|
|
946
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
947
|
+
const errorCheck = containsUnexpectedError(data);
|
|
948
|
+
if (errorCheck.hasError) {
|
|
949
|
+
resolve(createResult({
|
|
950
|
+
success: false,
|
|
951
|
+
latency,
|
|
952
|
+
error: errorCheck.message,
|
|
953
|
+
statusCode: res.statusCode
|
|
954
|
+
}));
|
|
955
|
+
} else {
|
|
956
|
+
resolve(createResult({
|
|
957
|
+
success: true,
|
|
958
|
+
latency,
|
|
959
|
+
error: null,
|
|
960
|
+
statusCode: res.statusCode
|
|
961
|
+
}));
|
|
962
|
+
}
|
|
963
|
+
} else if (res.statusCode === 401) {
|
|
964
|
+
resolve(createResult({
|
|
965
|
+
success: false,
|
|
966
|
+
latency,
|
|
967
|
+
error: 'API Key 无效或已过期',
|
|
968
|
+
statusCode: res.statusCode
|
|
969
|
+
}));
|
|
970
|
+
} else if (res.statusCode === 403) {
|
|
971
|
+
resolve(createResult({
|
|
972
|
+
success: false,
|
|
973
|
+
latency,
|
|
974
|
+
error: 'API Key 权限不足',
|
|
975
|
+
statusCode: res.statusCode
|
|
976
|
+
}));
|
|
977
|
+
} else if (res.statusCode === 429) {
|
|
978
|
+
const errMsg = parseErrorMessage(data) || '请求过多,服务限流中';
|
|
979
|
+
resolve(createResult({
|
|
980
|
+
success: false,
|
|
981
|
+
latency,
|
|
982
|
+
error: errMsg,
|
|
983
|
+
statusCode: res.statusCode
|
|
984
|
+
}));
|
|
985
|
+
} else if (res.statusCode === 503 || res.statusCode === 529) {
|
|
986
|
+
const errMsg = parseErrorMessage(data) || (res.statusCode === 503 ? '服务暂时不可用' : '服务过载');
|
|
987
|
+
resolve(createResult({
|
|
988
|
+
success: false,
|
|
989
|
+
latency,
|
|
990
|
+
error: errMsg,
|
|
991
|
+
statusCode: res.statusCode
|
|
992
|
+
}));
|
|
993
|
+
} else if (res.statusCode === 402) {
|
|
994
|
+
resolve(createResult({
|
|
995
|
+
success: false,
|
|
996
|
+
latency,
|
|
997
|
+
error: '账户余额不足',
|
|
998
|
+
statusCode: res.statusCode
|
|
999
|
+
}));
|
|
1000
|
+
} else if (res.statusCode === 400) {
|
|
1001
|
+
const errMsg = parseErrorMessage(data) || '请求参数错误';
|
|
1002
|
+
resolve(createResult({
|
|
1003
|
+
success: false,
|
|
1004
|
+
latency,
|
|
1005
|
+
error: errMsg,
|
|
1006
|
+
statusCode: res.statusCode
|
|
1007
|
+
}));
|
|
1008
|
+
} else if (res.statusCode >= 500) {
|
|
1009
|
+
const errMsg = parseErrorMessage(data) || `服务器错误 (${res.statusCode})`;
|
|
1010
|
+
resolve(createResult({
|
|
1011
|
+
success: false,
|
|
1012
|
+
latency,
|
|
1013
|
+
error: errMsg,
|
|
1014
|
+
statusCode: res.statusCode
|
|
1015
|
+
}));
|
|
1016
|
+
} else {
|
|
1017
|
+
const errMsg = parseErrorMessage(data) || `HTTP ${res.statusCode}`;
|
|
1018
|
+
resolve(createResult({
|
|
1019
|
+
success: false,
|
|
1020
|
+
latency,
|
|
1021
|
+
error: errMsg,
|
|
1022
|
+
statusCode: res.statusCode
|
|
1023
|
+
}));
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
req.on('error', (error) => {
|
|
1029
|
+
resolve(createResult({
|
|
1030
|
+
success: false,
|
|
1031
|
+
latency: null,
|
|
1032
|
+
error: error.message || '请求失败'
|
|
1033
|
+
}));
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
req.on('timeout', () => {
|
|
1037
|
+
req.destroy();
|
|
1038
|
+
resolve(createResult({
|
|
1039
|
+
success: false,
|
|
1040
|
+
latency: null,
|
|
1041
|
+
error: 'API 请求超时'
|
|
1042
|
+
}));
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
req.write(requestConfig.requestBody);
|
|
1046
|
+
req.end();
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
const primaryResult = await executeRequest(primaryRequestConfig);
|
|
1050
|
+
if (primaryResult.success || !fallbackRequestConfig) {
|
|
1051
|
+
return primaryResult;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (channelType === 'claude' && ROUTE_OR_METHOD_MISMATCH_STATUS.has(primaryResult.statusCode)) {
|
|
1055
|
+
return executeRequest(fallbackRequestConfig);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (channelType === 'gemini' && ROUTE_OR_METHOD_MISMATCH_STATUS.has(primaryResult.statusCode)) {
|
|
1059
|
+
return executeRequest(fallbackRequestConfig);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (channelType === 'codex') {
|
|
1063
|
+
const codexError = String(primaryResult.error || '').toLowerCase();
|
|
1064
|
+
const shouldRetryWithStreaming = ROUTE_OR_METHOD_MISMATCH_STATUS.has(primaryResult.statusCode)
|
|
1065
|
+
|| (primaryResult.statusCode === 400 && (codexError.includes('stream') || codexError.includes('event-stream') || codexError.includes('sse')));
|
|
1066
|
+
if (shouldRetryWithStreaming) {
|
|
1067
|
+
return executeRequest(fallbackRequestConfig);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return primaryResult;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* 批量测试多个渠道
|
|
1076
|
+
* @param {Array} channels - 渠道列表
|
|
1077
|
+
* @param {number} timeout - 超时时间
|
|
1078
|
+
* @param {string} channelType - 渠道类型:'claude' | 'codex' | 'gemini'
|
|
1079
|
+
* @returns {Promise<Array>} 测试结果列表
|
|
1080
|
+
*/
|
|
1081
|
+
async function testMultipleChannels(channels, timeout = DEFAULT_TIMEOUT, channelType = 'claude', concurrency = 2) {
|
|
1082
|
+
const results = await runWithConcurrencyLimit(
|
|
1083
|
+
channels,
|
|
1084
|
+
concurrency,
|
|
1085
|
+
channel => testChannelSpeed(channel, timeout, channelType)
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
// 按延迟排序(成功的在前,按延迟升序)
|
|
1089
|
+
results.sort((a, b) => {
|
|
1090
|
+
if (a.success && !b.success) return -1;
|
|
1091
|
+
if (!a.success && b.success) return 1;
|
|
1092
|
+
if (a.success && b.success) {
|
|
1093
|
+
const aLatency = (a.latency === null || a.latency === undefined) ? Infinity : a.latency;
|
|
1094
|
+
const bLatency = (b.latency === null || b.latency === undefined) ? Infinity : b.latency;
|
|
1095
|
+
return aLatency - bLatency;
|
|
1096
|
+
}
|
|
1097
|
+
return 0;
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
return results;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* 获取缓存的测试结果
|
|
1105
|
+
* @param {string} channelId - 渠道 ID
|
|
1106
|
+
* @returns {Object|null} 缓存的测试结果
|
|
1107
|
+
*/
|
|
1108
|
+
function getCachedResult(channelId) {
|
|
1109
|
+
const cached = testResultsCache.get(channelId);
|
|
1110
|
+
// 5 分钟内的缓存有效
|
|
1111
|
+
if (cached && Date.now() - cached.testedAt < 5 * 60 * 1000) {
|
|
1112
|
+
return cached;
|
|
1113
|
+
}
|
|
1114
|
+
return null;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* 清除测试结果缓存
|
|
1119
|
+
*/
|
|
1120
|
+
function clearCache() {
|
|
1121
|
+
testResultsCache.clear();
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* 获取延迟等级
|
|
1126
|
+
* @param {number} latency - 延迟毫秒数
|
|
1127
|
+
* @returns {string} 等级:excellent/good/fair/poor
|
|
1128
|
+
*/
|
|
1129
|
+
function getLatencyLevel(latency) {
|
|
1130
|
+
if (latency === null || latency === undefined) return 'unknown';
|
|
1131
|
+
if (!Number.isFinite(Number(latency))) return 'unknown';
|
|
1132
|
+
if (latency < 300) return 'excellent'; // < 300ms 优秀
|
|
1133
|
+
if (latency < 500) return 'good'; // < 500ms 良好
|
|
1134
|
+
if (latency < 800) return 'fair'; // < 800ms 一般
|
|
1135
|
+
return 'poor'; // >= 800ms 较差
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
module.exports = {
|
|
1139
|
+
testChannelSpeed,
|
|
1140
|
+
testMultipleChannels,
|
|
1141
|
+
getCachedResult,
|
|
1142
|
+
clearCache,
|
|
1143
|
+
getLatencyLevel,
|
|
1144
|
+
sanitizeBatchConcurrency,
|
|
1145
|
+
runWithConcurrencyLimit
|
|
1146
|
+
};
|