@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.
- package/CHANGELOG.md +39 -18
- package/README.md +8 -8
- package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
- package/dist/web/assets/ConfigTemplates-DvcbKKdS.js +1 -0
- package/dist/web/assets/Home-BJKPCBuk.css +1 -0
- package/dist/web/assets/Home-Cw-F_Wnu.js +1 -0
- package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
- package/dist/web/assets/PluginManager-jy_4GVxI.js +1 -0
- package/dist/web/assets/ProjectList-C1fQb9OW.css +1 -0
- package/dist/web/assets/ProjectList-Df1-NcNr.js +1 -0
- package/dist/web/assets/SessionList-BGJWyneI.css +1 -0
- package/dist/web/assets/SessionList-UWcZtC2r.js +1 -0
- package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
- package/dist/web/assets/SkillManager-IRdseMKB.js +1 -0
- package/dist/web/assets/Terminal-BasTyDut.js +1 -0
- package/dist/web/assets/Terminal-DGNJeVtc.css +1 -0
- package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
- package/dist/web/assets/WorkspaceManager-D-D2kK1V.js +1 -0
- package/dist/web/assets/icons-kcfLIMBB.js +1 -0
- package/dist/web/assets/index-CoB3zF0K.css +1 -0
- package/dist/web/assets/index-CryrSLv8.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-CSrLusZZ.js +1 -0
- package/dist/web/assets/{vendors-D2HHw_aW.js → vendors-CO3Upi1d.js} +2 -2
- package/dist/web/assets/vue-vendor-DqyWIXEb.js +45 -0
- package/dist/web/assets/xterm-6GBZ9nXN.css +32 -0
- package/dist/web/assets/xterm-BJzAjXCH.js +13 -0
- package/dist/web/index.html +8 -6
- package/package.json +4 -2
- package/src/commands/channels.js +48 -1
- package/src/commands/cli-type.js +4 -2
- package/src/commands/daemon.js +81 -12
- package/src/commands/doctor.js +10 -9
- package/src/commands/list.js +1 -1
- package/src/commands/logs.js +6 -4
- package/src/commands/port-config.js +24 -4
- package/src/commands/proxy-control.js +12 -6
- package/src/commands/search.js +1 -1
- package/src/commands/security.js +3 -2
- package/src/commands/stats.js +226 -52
- package/src/commands/switch.js +1 -1
- package/src/commands/toggle-proxy.js +31 -6
- package/src/commands/update.js +97 -0
- package/src/commands/workspace.js +1 -1
- package/src/config/default.js +41 -2
- package/src/config/loader.js +74 -8
- package/src/config/model-metadata.js +415 -0
- package/src/config/model-pricing.js +23 -93
- package/src/config/paths.js +105 -33
- package/src/index.js +64 -3
- package/src/plugins/constants.js +3 -2
- package/src/plugins/plugin-api.js +1 -1
- package/src/reset-config.js +4 -2
- package/src/server/api/agents.js +57 -14
- package/src/server/api/channels.js +112 -33
- package/src/server/api/codex-channels.js +111 -18
- package/src/server/api/codex-proxy.js +14 -8
- package/src/server/api/commands.js +71 -18
- package/src/server/api/config-export.js +0 -6
- package/src/server/api/config-registry.js +11 -3
- package/src/server/api/config.js +376 -5
- package/src/server/api/convert.js +133 -0
- package/src/server/api/dashboard.js +22 -6
- package/src/server/api/gemini-channels.js +107 -18
- package/src/server/api/gemini-proxy.js +14 -8
- package/src/server/api/gemini-sessions.js +1 -1
- package/src/server/api/health-check.js +4 -3
- package/src/server/api/mcp.js +3 -3
- package/src/server/api/opencode-channels.js +497 -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 +345 -0
- package/src/server/api/opencode-statistics.js +57 -0
- package/src/server/api/plugins.js +66 -19
- package/src/server/api/prompts.js +2 -2
- package/src/server/api/proxy.js +7 -4
- package/src/server/api/sessions.js +3 -0
- package/src/server/api/settings.js +111 -0
- package/src/server/api/skills.js +69 -18
- package/src/server/api/workspaces.js +78 -6
- package/src/server/codex-proxy-server.js +36 -22
- package/src/server/dev-server.js +1 -1
- package/src/server/gemini-proxy-server.js +21 -7
- package/src/server/index.js +174 -58
- package/src/server/opencode-proxy-server.js +5486 -0
- package/src/server/proxy-server.js +33 -22
- package/src/server/services/agents-service.js +61 -24
- package/src/server/services/channel-scheduler.js +9 -5
- package/src/server/services/channels.js +64 -37
- package/src/server/services/codex-channels.js +56 -43
- package/src/server/services/codex-sessions.js +105 -6
- package/src/server/services/codex-settings-manager.js +271 -49
- package/src/server/services/codex-statistics-service.js +2 -2
- package/src/server/services/commands-service.js +84 -25
- package/src/server/services/config-export-service.js +7 -45
- package/src/server/services/config-registry-service.js +63 -17
- package/src/server/services/config-sync-manager.js +160 -7
- package/src/server/services/config-templates-service.js +204 -51
- package/src/server/services/env-checker.js +50 -13
- package/src/server/services/env-manager.js +155 -19
- package/src/server/services/favorites.js +5 -3
- package/src/server/services/gemini-channels.js +33 -44
- package/src/server/services/gemini-statistics-service.js +2 -2
- package/src/server/services/mcp-service.js +350 -9
- package/src/server/services/model-detector.js +707 -221
- package/src/server/services/network-access.js +80 -0
- package/src/server/services/opencode-channels.js +208 -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 +255 -0
- package/src/server/services/plugins-service.js +479 -22
- package/src/server/services/prompts-service.js +53 -11
- package/src/server/services/proxy-runtime.js +1 -1
- package/src/server/services/repo-scanner-base.js +1 -1
- package/src/server/services/response-decoder.js +21 -0
- package/src/server/services/security-config.js +1 -1
- package/src/server/services/session-cache.js +1 -1
- package/src/server/services/skill-service.js +300 -46
- package/src/server/services/speed-test.js +464 -186
- package/src/server/services/statistics-service.js +2 -2
- package/src/server/services/terminal-commands.js +10 -3
- package/src/server/services/terminal-config.js +1 -1
- package/src/server/services/ui-config.js +1 -1
- package/src/server/services/workspace-service.js +57 -100
- package/src/server/websocket-server.js +156 -8
- package/src/ui/menu.js +49 -40
- package/src/utils/port-helper.js +22 -8
- package/src/utils/session.js +5 -4
- package/dist/web/assets/icons-CO_2OFES.js +0 -1
- package/dist/web/assets/index-DI8QOi-E.js +0 -14
- package/dist/web/assets/index-uLHGdeZh.css +0 -41
- package/dist/web/assets/naive-ui-B1re3c-e.js +0 -1
- package/dist/web/assets/vue-vendor-6JaYHOiI.js +0 -44
- package/src/server/api/oauth.js +0 -294
- package/src/server/api/permissions.js +0 -385
- package/src/server/config/oauth-providers.js +0 -68
- package/src/server/services/oauth-callback-server.js +0 -284
- package/src/server/services/oauth-service.js +0 -378
- package/src/server/services/oauth-token-storage.js +0 -135
- package/src/server/services/permission-templates-service.js +0 -308
|
@@ -10,37 +10,107 @@ const https = require('https');
|
|
|
10
10
|
const http = require('http');
|
|
11
11
|
const { URL } = require('url');
|
|
12
12
|
const crypto = require('crypto');
|
|
13
|
+
const zlib = require('zlib');
|
|
14
|
+
const { loadConfig } = require('../../config/loader');
|
|
13
15
|
|
|
14
|
-
//
|
|
16
|
+
// 内置模型优先级(当配置缺失时兜底)
|
|
15
17
|
const MODEL_PRIORITY = {
|
|
16
18
|
claude: [
|
|
19
|
+
'claude-opus-4-6',
|
|
20
|
+
'claude-sonnet-4-6',
|
|
17
21
|
'claude-opus-4-5-20251101',
|
|
18
22
|
'claude-sonnet-4-5-20250929',
|
|
19
|
-
'claude-haiku-4-5-20251001'
|
|
20
|
-
'claude-sonnet-4-20250514',
|
|
21
|
-
'claude-opus-4-20250514'
|
|
23
|
+
'claude-haiku-4-5-20251001'
|
|
22
24
|
],
|
|
23
25
|
codex: [
|
|
24
26
|
'gpt-5.2-codex',
|
|
25
27
|
'gpt-5.1-codex-max',
|
|
26
|
-
'gpt-5.1-codex-mini',
|
|
27
28
|
'gpt-5.1-codex',
|
|
29
|
+
'gpt-5.1-codex-mini',
|
|
28
30
|
'gpt-5-codex',
|
|
29
31
|
'gpt-5.2',
|
|
30
32
|
'gpt-5.1',
|
|
31
33
|
'gpt-5'
|
|
32
34
|
],
|
|
33
35
|
gemini: [
|
|
34
|
-
'gemini-3-pro',
|
|
35
|
-
'gemini-3-flash',
|
|
36
|
-
'gemini-3-deep-think',
|
|
36
|
+
'gemini-3-pro-preview',
|
|
37
|
+
'gemini-3-flash-preview',
|
|
37
38
|
'gemini-2.5-pro',
|
|
38
|
-
'gemini-2.5-flash'
|
|
39
|
+
'gemini-2.5-flash',
|
|
40
|
+
'gemini-2.5-flash-lite'
|
|
39
41
|
]
|
|
40
42
|
};
|
|
41
43
|
// openai_compatible 复用 codex 的模型列表
|
|
42
44
|
MODEL_PRIORITY.openai_compatible = MODEL_PRIORITY.codex;
|
|
43
45
|
|
|
46
|
+
function normalizeModelToolType(type) {
|
|
47
|
+
const value = String(type || '').trim().toLowerCase();
|
|
48
|
+
if (value === 'openai_compatible') return 'codex';
|
|
49
|
+
if (value === 'claude' || value === 'codex' || value === 'gemini' || value === 'opencode') {
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 获取模型优先级列表(优先读取用户配置的 defaultModels)
|
|
57
|
+
* @param {string} channelType - 渠道类型
|
|
58
|
+
* @param {Object} options - 可选参数
|
|
59
|
+
* @param {string} options.toolType - 显式工具类型(claude/codex/gemini/opencode)
|
|
60
|
+
* @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
function getModelPriority(channelType, options = {}) {
|
|
63
|
+
const preferredToolType = normalizeModelToolType(options.toolType);
|
|
64
|
+
const normalizedChannelType = normalizeModelToolType(channelType);
|
|
65
|
+
const candidateTypes = [];
|
|
66
|
+
|
|
67
|
+
if (preferredToolType) {
|
|
68
|
+
candidateTypes.push(preferredToolType);
|
|
69
|
+
}
|
|
70
|
+
if (normalizedChannelType && !candidateTypes.includes(normalizedChannelType)) {
|
|
71
|
+
candidateTypes.push(normalizedChannelType);
|
|
72
|
+
}
|
|
73
|
+
if (String(channelType || '').trim().toLowerCase() === 'openai_compatible' && !candidateTypes.includes('openai_compatible')) {
|
|
74
|
+
candidateTypes.push('openai_compatible');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const config = loadConfig();
|
|
79
|
+
const defaultModels = config?.defaultModels || {};
|
|
80
|
+
for (const toolType of candidateTypes) {
|
|
81
|
+
const models = defaultModels[toolType];
|
|
82
|
+
if (Array.isArray(models) && models.length > 0) {
|
|
83
|
+
return normalizeModelCandidates(models);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.warn(`[ModelDetector] Failed to load default models config: ${error.message}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getModelDiscoveryConfig() {
|
|
94
|
+
try {
|
|
95
|
+
const config = loadConfig();
|
|
96
|
+
return {
|
|
97
|
+
useV1ModelsEndpoint: config?.modelDiscovery?.useV1ModelsEndpoint === true
|
|
98
|
+
};
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.warn(`[ModelDetector] Failed to load modelDiscovery config: ${error.message}`);
|
|
101
|
+
return {
|
|
102
|
+
useV1ModelsEndpoint: false
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function shouldUseV1ModelsEndpoint(options = {}) {
|
|
108
|
+
if (typeof options.useV1ModelsEndpoint === 'boolean') {
|
|
109
|
+
return options.useV1ModelsEndpoint;
|
|
110
|
+
}
|
|
111
|
+
return getModelDiscoveryConfig().useV1ModelsEndpoint;
|
|
112
|
+
}
|
|
113
|
+
|
|
44
114
|
const PROVIDER_CAPABILITIES = {
|
|
45
115
|
claude: {
|
|
46
116
|
supportsModelList: false,
|
|
@@ -121,6 +191,8 @@ const MODEL_ALIASES = {
|
|
|
121
191
|
'claude-3-haiku': 'claude-3-5-haiku-20241022',
|
|
122
192
|
'claude-sonnet-4': 'claude-sonnet-4-20250514',
|
|
123
193
|
'claude-4-sonnet': 'claude-sonnet-4-20250514',
|
|
194
|
+
'claude-sonnet-4-6': 'claude-sonnet-4-6',
|
|
195
|
+
'claude-4-6-sonnet': 'claude-sonnet-4-6',
|
|
124
196
|
'claude-sonnet-4-5': 'claude-sonnet-4-5-20250929',
|
|
125
197
|
'claude-4-5-sonnet': 'claude-sonnet-4-5-20250929',
|
|
126
198
|
'claude-opus-4': 'claude-opus-4-20250514',
|
|
@@ -145,8 +217,31 @@ const MODEL_ALIASES = {
|
|
|
145
217
|
'gemini-2-5-pro': 'gemini-2.5-pro'
|
|
146
218
|
};
|
|
147
219
|
|
|
148
|
-
const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
|
149
220
|
const TEST_TIMEOUT_MS = 10000; // 10 seconds per model test
|
|
221
|
+
const CLAUDE_CODE_BETA_HEADER = 'claude-code-20250219,interleaved-thinking-2025-05-14';
|
|
222
|
+
|
|
223
|
+
const MODEL_UNAVAILABLE_HINTS = [
|
|
224
|
+
'not found',
|
|
225
|
+
'does not exist',
|
|
226
|
+
'invalid model',
|
|
227
|
+
'unsupported model',
|
|
228
|
+
'not supported',
|
|
229
|
+
'model unavailable',
|
|
230
|
+
'deprecated',
|
|
231
|
+
'decommission',
|
|
232
|
+
'retired',
|
|
233
|
+
'offline',
|
|
234
|
+
'unknown model',
|
|
235
|
+
'下线',
|
|
236
|
+
'已下线',
|
|
237
|
+
'已停用',
|
|
238
|
+
'已废弃',
|
|
239
|
+
'已淘汰',
|
|
240
|
+
'模型不存在',
|
|
241
|
+
'无效模型',
|
|
242
|
+
'模型不可用',
|
|
243
|
+
'请切换'
|
|
244
|
+
];
|
|
150
245
|
|
|
151
246
|
/**
|
|
152
247
|
* Generate realistic User-Agent strings that mimic official SDKs
|
|
@@ -205,11 +300,375 @@ function buildRequestHeaders(channelType, channel) {
|
|
|
205
300
|
return headers;
|
|
206
301
|
}
|
|
207
302
|
|
|
303
|
+
function buildOpenAiCompatibleUrl(baseUrl, endpoint) {
|
|
304
|
+
const trimmed = String(baseUrl || '').trim().replace(/\/+$/, '');
|
|
305
|
+
if (!trimmed) {
|
|
306
|
+
throw new Error('Invalid baseUrl');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const normalizedEndpoint = String(endpoint || '').startsWith('/')
|
|
310
|
+
? String(endpoint)
|
|
311
|
+
: `/${endpoint}`;
|
|
312
|
+
|
|
313
|
+
if (trimmed.endsWith(normalizedEndpoint)) {
|
|
314
|
+
return trimmed;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const endpointWithoutV1 = normalizedEndpoint.startsWith('/v1/')
|
|
318
|
+
? normalizedEndpoint.slice(3)
|
|
319
|
+
: normalizedEndpoint;
|
|
320
|
+
|
|
321
|
+
if (trimmed.endsWith(endpointWithoutV1)) {
|
|
322
|
+
return trimmed;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (trimmed.endsWith('/v1') && normalizedEndpoint.startsWith('/v1/')) {
|
|
326
|
+
return `${trimmed}${normalizedEndpoint.slice(3)}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return `${trimmed}${normalizedEndpoint}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function buildClaudeMessagesUrl(baseUrl, options = {}) {
|
|
333
|
+
const withBeta = options.withBeta !== false;
|
|
334
|
+
const parsed = new URL(String(baseUrl || '').trim());
|
|
335
|
+
let pathname = parsed.pathname.replace(/\/+$/, '');
|
|
336
|
+
|
|
337
|
+
if (!pathname || pathname === '/') {
|
|
338
|
+
pathname = '/v1/messages';
|
|
339
|
+
} else if (pathname.endsWith('/messages')) {
|
|
340
|
+
// noop
|
|
341
|
+
} else if (pathname.endsWith('/v1')) {
|
|
342
|
+
pathname = `${pathname}/messages`;
|
|
343
|
+
} else {
|
|
344
|
+
pathname = `${pathname}/v1/messages`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
parsed.pathname = pathname;
|
|
348
|
+
if (withBeta) {
|
|
349
|
+
parsed.searchParams.set('beta', 'true');
|
|
350
|
+
}
|
|
351
|
+
return parsed.toString();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function buildGeminiGenerateContentUrl(baseUrl, model, apiKey = '') {
|
|
355
|
+
const modelName = String(model || '').trim();
|
|
356
|
+
if (!modelName) {
|
|
357
|
+
throw new Error('Model is required for Gemini probe');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const parsed = new URL(String(baseUrl || '').trim());
|
|
361
|
+
let pathname = parsed.pathname.replace(/\/+$/, '');
|
|
362
|
+
const modelsIndex = pathname.indexOf('/models');
|
|
363
|
+
if (modelsIndex >= 0) {
|
|
364
|
+
pathname = pathname.slice(0, modelsIndex);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let apiBasePath;
|
|
368
|
+
if (!pathname || pathname === '/') {
|
|
369
|
+
apiBasePath = '/v1beta';
|
|
370
|
+
} else if (pathname.endsWith('/v1beta') || pathname.endsWith('/v1')) {
|
|
371
|
+
apiBasePath = pathname;
|
|
372
|
+
} else {
|
|
373
|
+
apiBasePath = `${pathname}/v1beta`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
parsed.pathname = `${apiBasePath}/models/${encodeURIComponent(modelName)}:generateContent`;
|
|
377
|
+
if (apiKey) {
|
|
378
|
+
parsed.searchParams.set('key', apiKey);
|
|
379
|
+
}
|
|
380
|
+
return parsed.toString();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function buildClaudeProbePayload(model, options = {}) {
|
|
384
|
+
const includeSystem = options.includeSystem === true;
|
|
385
|
+
const systemAsArray = options.systemAsArray === true;
|
|
386
|
+
const includeMetadata = options.includeMetadata === true;
|
|
387
|
+
const sessionId = Math.random().toString(36).substring(2, 15);
|
|
388
|
+
|
|
389
|
+
const payload = {
|
|
390
|
+
model,
|
|
391
|
+
max_tokens: 1,
|
|
392
|
+
stream: false,
|
|
393
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }]
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
if (includeSystem) {
|
|
397
|
+
const systemPrompt = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
398
|
+
payload.system = systemAsArray
|
|
399
|
+
? [{ type: 'text', text: systemPrompt }]
|
|
400
|
+
: systemPrompt;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (includeMetadata) {
|
|
404
|
+
payload.metadata = {
|
|
405
|
+
user_id: `user_0000000000000000000000000000000000000000000000000000000000000000_account__session_${sessionId}`
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return JSON.stringify(payload);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function createClaudeProbeAttempts(channel, model) {
|
|
413
|
+
const apiKey = channel.apiKey || '';
|
|
414
|
+
const commonHeaders = {
|
|
415
|
+
...buildRequestHeaders('claude', channel),
|
|
416
|
+
'Content-Type': 'application/json',
|
|
417
|
+
'x-api-key': apiKey,
|
|
418
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
419
|
+
'anthropic-version': '2023-06-01',
|
|
420
|
+
'anthropic-beta': CLAUDE_CODE_BETA_HEADER,
|
|
421
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
422
|
+
'User-Agent': 'claude-cli/2.0.53 (external, cli)'
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const legacyBody = buildClaudeProbePayload(model, {
|
|
426
|
+
includeSystem: true,
|
|
427
|
+
systemAsArray: true,
|
|
428
|
+
includeMetadata: true
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
return [
|
|
432
|
+
{
|
|
433
|
+
label: 'claude-code-legacy-beta',
|
|
434
|
+
url: buildClaudeMessagesUrl(channel.baseUrl, { withBeta: true }),
|
|
435
|
+
body: legacyBody,
|
|
436
|
+
headers: {
|
|
437
|
+
...commonHeaders,
|
|
438
|
+
'anthropic-dangerous-direct-browser-access': 'true',
|
|
439
|
+
'x-app': 'cli',
|
|
440
|
+
'x-stainless-lang': 'js',
|
|
441
|
+
'x-stainless-runtime': 'node'
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function createCodexProbeAttempts(channel, model) {
|
|
448
|
+
const apiKey = channel.apiKey || '';
|
|
449
|
+
const commonHeaders = {
|
|
450
|
+
...buildRequestHeaders('codex', channel),
|
|
451
|
+
'Content-Type': 'application/json',
|
|
452
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
453
|
+
'User-Agent': 'codex_cli_rs/0.65.0'
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const responsesBody = JSON.stringify({
|
|
457
|
+
model,
|
|
458
|
+
instructions: 'You are Codex.',
|
|
459
|
+
input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'ping' }] }],
|
|
460
|
+
max_output_tokens: 1,
|
|
461
|
+
stream: false,
|
|
462
|
+
store: false
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
return [
|
|
466
|
+
{
|
|
467
|
+
label: 'codex-responses',
|
|
468
|
+
url: buildOpenAiCompatibleUrl(channel.baseUrl, '/v1/responses'),
|
|
469
|
+
body: responsesBody,
|
|
470
|
+
headers: {
|
|
471
|
+
...commonHeaders,
|
|
472
|
+
'openai-beta': 'responses=experimental'
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function createGeminiProbeAttempt(channel, model) {
|
|
479
|
+
const apiKey = channel.apiKey || '';
|
|
480
|
+
const body = JSON.stringify({
|
|
481
|
+
contents: [{ role: 'user', parts: [{ text: 'test' }] }],
|
|
482
|
+
generationConfig: { maxOutputTokens: 1, temperature: 0 }
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
label: 'gemini-generate-content',
|
|
487
|
+
url: buildGeminiGenerateContentUrl(channel.baseUrl, model, apiKey),
|
|
488
|
+
body,
|
|
489
|
+
headers: {
|
|
490
|
+
...buildRequestHeaders('gemini', channel),
|
|
491
|
+
'Content-Type': 'application/json',
|
|
492
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
493
|
+
'x-goog-api-key': apiKey,
|
|
494
|
+
'User-Agent': 'google-genai-sdk/0.8.0'
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function buildProbeAttempts(channel, channelType, model) {
|
|
500
|
+
const normalizedType = String(channelType || '').trim().toLowerCase();
|
|
501
|
+
if (normalizedType === 'claude') {
|
|
502
|
+
return createClaudeProbeAttempts(channel, model);
|
|
503
|
+
}
|
|
504
|
+
if (normalizedType === 'codex' || normalizedType === 'openai_compatible') {
|
|
505
|
+
return createCodexProbeAttempts(channel, model);
|
|
506
|
+
}
|
|
507
|
+
if (normalizedType === 'gemini') {
|
|
508
|
+
return [createGeminiProbeAttempt(channel, model)];
|
|
509
|
+
}
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function extractProbeErrorMessage(responseBody) {
|
|
514
|
+
const fallback = String(responseBody || '');
|
|
515
|
+
try {
|
|
516
|
+
const response = JSON.parse(responseBody || '{}');
|
|
517
|
+
if (typeof response === 'string') return response;
|
|
518
|
+
if (typeof response?.error?.message === 'string') return response.error.message;
|
|
519
|
+
if (typeof response?.error === 'string') return response.error;
|
|
520
|
+
if (typeof response?.message === 'string') return response.message;
|
|
521
|
+
if (typeof response?.detail === 'string') return response.detail;
|
|
522
|
+
return fallback;
|
|
523
|
+
} catch {
|
|
524
|
+
return fallback;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function classifyProbeResult(statusCode, responseBody, model) {
|
|
529
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
530
|
+
return 'available';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const errorMsg = extractProbeErrorMessage(responseBody).toLowerCase();
|
|
534
|
+
const modelLower = String(model || '').toLowerCase();
|
|
535
|
+
const hasModelContext = errorMsg.includes('model')
|
|
536
|
+
|| errorMsg.includes('模型')
|
|
537
|
+
|| (modelLower && errorMsg.includes(modelLower));
|
|
538
|
+
|
|
539
|
+
if (hasModelContext && MODEL_UNAVAILABLE_HINTS.some(hint => errorMsg.includes(hint))) {
|
|
540
|
+
return 'unavailable';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (statusCode === 400 || statusCode === 404 || statusCode === 405 || statusCode === 415 || statusCode === 422 || statusCode === 501) {
|
|
544
|
+
return 'retry';
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return 'retry';
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function sanitizeProbeErrorMessage(value) {
|
|
551
|
+
if (value === null || value === undefined) return '';
|
|
552
|
+
return String(value).replace(/\s+/g, ' ').trim().slice(0, 180);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function buildProbeFailureDetail(attempt, result) {
|
|
556
|
+
if (!attempt || !result) return null;
|
|
557
|
+
const statusCode = Number(result.statusCode) || 0;
|
|
558
|
+
const message = result.error?.message
|
|
559
|
+
? sanitizeProbeErrorMessage(result.error.message)
|
|
560
|
+
: sanitizeProbeErrorMessage(extractProbeErrorMessage(result.responseBody));
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
attempt: attempt.label || 'unknown',
|
|
564
|
+
statusCode,
|
|
565
|
+
message
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function formatProbeFailureDetail(detail) {
|
|
570
|
+
if (!detail) return '';
|
|
571
|
+
const parts = [];
|
|
572
|
+
if (detail.attempt) parts.push(`attempt=${detail.attempt}`);
|
|
573
|
+
if (detail.statusCode) parts.push(`status=${detail.statusCode}`);
|
|
574
|
+
if (detail.message) parts.push(`msg=${detail.message}`);
|
|
575
|
+
if (parts.length === 0) return '';
|
|
576
|
+
return ` (${parts.join(', ')})`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function executeProbeAttempt(attempt) {
|
|
580
|
+
return new Promise((resolve) => {
|
|
581
|
+
try {
|
|
582
|
+
const parsedUrl = new URL(attempt.url);
|
|
583
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
584
|
+
const httpModule = isHttps ? https : http;
|
|
585
|
+
const requestBody = String(attempt.body || '');
|
|
586
|
+
const headers = {
|
|
587
|
+
...(attempt.headers || {}),
|
|
588
|
+
'Content-Length': Buffer.byteLength(requestBody)
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const options = {
|
|
592
|
+
hostname: parsedUrl.hostname,
|
|
593
|
+
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
594
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
595
|
+
method: 'POST',
|
|
596
|
+
timeout: TEST_TIMEOUT_MS,
|
|
597
|
+
headers
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const req = httpModule.request(options, (res) => {
|
|
601
|
+
collectResponseBody(res)
|
|
602
|
+
.then((data) => {
|
|
603
|
+
resolve({
|
|
604
|
+
statusCode: res.statusCode || 0,
|
|
605
|
+
responseBody: data
|
|
606
|
+
});
|
|
607
|
+
})
|
|
608
|
+
.catch((error) => {
|
|
609
|
+
resolve({
|
|
610
|
+
statusCode: 0,
|
|
611
|
+
responseBody: '',
|
|
612
|
+
error
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
req.on('error', (error) => resolve({
|
|
618
|
+
statusCode: 0,
|
|
619
|
+
responseBody: '',
|
|
620
|
+
error
|
|
621
|
+
}));
|
|
622
|
+
req.on('timeout', () => {
|
|
623
|
+
req.destroy();
|
|
624
|
+
resolve({
|
|
625
|
+
statusCode: 0,
|
|
626
|
+
responseBody: '',
|
|
627
|
+
error: new Error('Request timeout')
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
req.write(requestBody);
|
|
632
|
+
req.end();
|
|
633
|
+
} catch (error) {
|
|
634
|
+
resolve({
|
|
635
|
+
statusCode: 0,
|
|
636
|
+
responseBody: '',
|
|
637
|
+
error
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function createDecodedStream(res) {
|
|
644
|
+
const encoding = String(res.headers['content-encoding'] || '').toLowerCase();
|
|
645
|
+
if (encoding.includes('gzip')) return res.pipe(zlib.createGunzip());
|
|
646
|
+
if (encoding.includes('deflate')) return res.pipe(zlib.createInflate());
|
|
647
|
+
if (encoding.includes('br') && typeof zlib.createBrotliDecompress === 'function') {
|
|
648
|
+
return res.pipe(zlib.createBrotliDecompress());
|
|
649
|
+
}
|
|
650
|
+
return res;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function collectResponseBody(res) {
|
|
654
|
+
return new Promise((resolve, reject) => {
|
|
655
|
+
const stream = createDecodedStream(res);
|
|
656
|
+
let data = '';
|
|
657
|
+
|
|
658
|
+
stream.on('data', chunk => {
|
|
659
|
+
data += chunk.toString('utf8');
|
|
660
|
+
});
|
|
661
|
+
stream.on('end', () => resolve(data));
|
|
662
|
+
stream.on('error', reject);
|
|
663
|
+
res.on('error', reject);
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
208
667
|
/**
|
|
209
668
|
* Get cache file path
|
|
210
669
|
*/
|
|
211
670
|
function getCacheFilePath() {
|
|
212
|
-
const dir = path.join(os.homedir(), '.
|
|
671
|
+
const dir = path.join(os.homedir(), '.cc-tool');
|
|
213
672
|
if (!fs.existsSync(dir)) {
|
|
214
673
|
fs.mkdirSync(dir, { recursive: true });
|
|
215
674
|
}
|
|
@@ -256,18 +715,64 @@ function normalizeModelName(model) {
|
|
|
256
715
|
return MODEL_ALIASES[normalized] || model;
|
|
257
716
|
}
|
|
258
717
|
|
|
718
|
+
function normalizeModelCandidates(models = []) {
|
|
719
|
+
if (!Array.isArray(models)) return [];
|
|
720
|
+
const seen = new Set();
|
|
721
|
+
const normalized = [];
|
|
722
|
+
|
|
723
|
+
models.forEach((model) => {
|
|
724
|
+
if (typeof model !== 'string') return;
|
|
725
|
+
const trimmed = model.trim();
|
|
726
|
+
if (!trimmed) return;
|
|
727
|
+
const canonical = normalizeModelName(trimmed) || trimmed;
|
|
728
|
+
const dedupeKey = canonical.toLowerCase();
|
|
729
|
+
if (seen.has(dedupeKey)) return;
|
|
730
|
+
seen.add(dedupeKey);
|
|
731
|
+
normalized.push(canonical);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
return normalized;
|
|
735
|
+
}
|
|
736
|
+
|
|
259
737
|
/**
|
|
260
|
-
*
|
|
261
|
-
* @param {Object} cacheEntry - Cache entry with lastChecked timestamp
|
|
262
|
-
* @returns {boolean}
|
|
738
|
+
* Stable stringify with key ordering to build deterministic cache signatures
|
|
263
739
|
*/
|
|
264
|
-
function
|
|
265
|
-
if (
|
|
266
|
-
|
|
740
|
+
function stableStringify(value) {
|
|
741
|
+
if (value === null || value === undefined) return 'null';
|
|
742
|
+
if (Array.isArray(value)) {
|
|
743
|
+
return `[${value.map(item => stableStringify(item)).join(',')}]`;
|
|
267
744
|
}
|
|
745
|
+
if (typeof value !== 'object') return JSON.stringify(value);
|
|
268
746
|
|
|
269
|
-
const
|
|
270
|
-
return
|
|
747
|
+
const keys = Object.keys(value).sort();
|
|
748
|
+
return `{${keys.map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function buildChannelCacheSignature(channel, payload = {}) {
|
|
752
|
+
const base = {
|
|
753
|
+
id: channel?.id || '',
|
|
754
|
+
name: channel?.name || '',
|
|
755
|
+
baseUrl: channel?.baseUrl || '',
|
|
756
|
+
apiKey: channel?.apiKey || '',
|
|
757
|
+
gatewaySourceType: channel?.gatewaySourceType || '',
|
|
758
|
+
wireApi: channel?.wireApi || '',
|
|
759
|
+
model: channel?.model || '',
|
|
760
|
+
speedTestModel: channel?.speedTestModel || '',
|
|
761
|
+
presetId: channel?.presetId || '',
|
|
762
|
+
modelConfig: channel?.modelConfig || null,
|
|
763
|
+
modelRedirects: Array.isArray(channel?.modelRedirects) ? channel.modelRedirects : []
|
|
764
|
+
};
|
|
765
|
+
const raw = stableStringify({
|
|
766
|
+
channel: base,
|
|
767
|
+
payload
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
return crypto.createHash('sha1').update(raw).digest('hex');
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function isSignatureCacheValid(cacheEntry, signatureKey, expectedSignature) {
|
|
774
|
+
if (!cacheEntry || !signatureKey || !expectedSignature) return false;
|
|
775
|
+
return cacheEntry[signatureKey] === expectedSignature;
|
|
271
776
|
}
|
|
272
777
|
|
|
273
778
|
/**
|
|
@@ -277,110 +782,45 @@ function isCacheValid(cacheEntry) {
|
|
|
277
782
|
* @param {string} model - Model name to test
|
|
278
783
|
* @returns {Promise<boolean>}
|
|
279
784
|
*/
|
|
280
|
-
async function
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
// Start with common headers that look like legitimate SDK clients
|
|
287
|
-
let headers = {
|
|
288
|
-
...buildRequestHeaders(channelType, channel),
|
|
289
|
-
'Content-Type': 'application/json'
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
// Construct API endpoint and request based on channel type
|
|
293
|
-
if (channelType === 'claude') {
|
|
294
|
-
testUrl = `${baseUrl}/v1/messages`;
|
|
295
|
-
headers['x-api-key'] = channel.apiKey;
|
|
296
|
-
headers['anthropic-version'] = '2023-06-01';
|
|
297
|
-
requestBody = JSON.stringify({
|
|
298
|
-
model: model,
|
|
299
|
-
max_tokens: 1,
|
|
300
|
-
messages: [{ role: 'user', content: 'test' }]
|
|
301
|
-
});
|
|
302
|
-
} else if (channelType === 'codex' || channelType === 'openai_compatible') {
|
|
303
|
-
// 处理 baseUrl 已包含 /v1 的情况
|
|
304
|
-
testUrl = baseUrl.endsWith('/v1')
|
|
305
|
-
? `${baseUrl}/chat/completions`
|
|
306
|
-
: `${baseUrl}/v1/chat/completions`;
|
|
307
|
-
headers['Authorization'] = `Bearer ${channel.apiKey}`;
|
|
308
|
-
requestBody = JSON.stringify({
|
|
309
|
-
model: model,
|
|
310
|
-
max_tokens: 1,
|
|
311
|
-
messages: [{ role: 'user', content: 'test' }]
|
|
312
|
-
});
|
|
313
|
-
} else if (channelType === 'gemini') {
|
|
314
|
-
// Gemini uses API key in URL
|
|
315
|
-
testUrl = `${baseUrl}/v1beta/models/${model}:generateContent?key=${channel.apiKey}`;
|
|
316
|
-
requestBody = JSON.stringify({
|
|
317
|
-
contents: [{ parts: [{ text: 'test' }] }],
|
|
318
|
-
generationConfig: { maxOutputTokens: 1 }
|
|
319
|
-
});
|
|
320
|
-
} else {
|
|
321
|
-
return resolve(false);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const parsedUrl = new URL(testUrl);
|
|
325
|
-
const isHttps = parsedUrl.protocol === 'https:';
|
|
326
|
-
const httpModule = isHttps ? https : http;
|
|
785
|
+
async function testModelAvailabilityDetailed(channel, channelType, model) {
|
|
786
|
+
try {
|
|
787
|
+
const attempts = buildProbeAttempts(channel, channelType, model);
|
|
788
|
+
if (!attempts.length) {
|
|
789
|
+
return { available: false, failureDetail: null };
|
|
790
|
+
}
|
|
327
791
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
headers: {
|
|
335
|
-
...headers,
|
|
336
|
-
'Content-Length': Buffer.byteLength(requestBody)
|
|
792
|
+
let failureDetail = null;
|
|
793
|
+
for (const attempt of attempts) {
|
|
794
|
+
const result = await executeProbeAttempt(attempt);
|
|
795
|
+
if (result.error) {
|
|
796
|
+
if (!failureDetail) {
|
|
797
|
+
failureDetail = buildProbeFailureDetail(attempt, result);
|
|
337
798
|
}
|
|
338
|
-
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
339
801
|
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
// Success: 200-299 status codes
|
|
345
|
-
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
346
|
-
resolve(true);
|
|
347
|
-
} else if (res.statusCode === 400 || res.statusCode === 404) {
|
|
348
|
-
// 400/404 often means model not found or invalid
|
|
349
|
-
try {
|
|
350
|
-
const response = JSON.parse(data);
|
|
351
|
-
const errorMsg = (response.error?.message || '').toLowerCase();
|
|
352
|
-
|
|
353
|
-
// Check for model-specific errors
|
|
354
|
-
if (errorMsg.includes('model') &&
|
|
355
|
-
(errorMsg.includes('not found') || errorMsg.includes('invalid') || errorMsg.includes('does not exist'))) {
|
|
356
|
-
resolve(false);
|
|
357
|
-
} else {
|
|
358
|
-
// Other 400 errors might be auth/validation issues, not model issues
|
|
359
|
-
resolve(true);
|
|
360
|
-
}
|
|
361
|
-
} catch {
|
|
362
|
-
resolve(false);
|
|
363
|
-
}
|
|
364
|
-
} else {
|
|
365
|
-
// Other errors (401, 403, 500, etc.) are inconclusive
|
|
366
|
-
resolve(false);
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
|
-
});
|
|
802
|
+
const verdict = classifyProbeResult(result.statusCode, result.responseBody, model);
|
|
803
|
+
if (verdict === 'available') {
|
|
804
|
+
return { available: true, failureDetail: null };
|
|
805
|
+
}
|
|
370
806
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
807
|
+
if (!failureDetail) {
|
|
808
|
+
failureDetail = buildProbeFailureDetail(attempt, result);
|
|
809
|
+
}
|
|
810
|
+
if (verdict === 'unavailable') {
|
|
811
|
+
return { available: false, failureDetail };
|
|
812
|
+
}
|
|
813
|
+
}
|
|
376
814
|
|
|
377
|
-
|
|
378
|
-
|
|
815
|
+
return { available: false, failureDetail };
|
|
816
|
+
} catch {
|
|
817
|
+
return { available: false, failureDetail: null };
|
|
818
|
+
}
|
|
819
|
+
}
|
|
379
820
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
});
|
|
821
|
+
async function testModelAvailability(channel, channelType, model) {
|
|
822
|
+
const result = await testModelAvailabilityDetailed(channel, channelType, model);
|
|
823
|
+
return result.available;
|
|
384
824
|
}
|
|
385
825
|
|
|
386
826
|
/**
|
|
@@ -392,12 +832,23 @@ async function testModelAvailability(channel, channelType, model) {
|
|
|
392
832
|
* @param {string} channelType - 'claude' | 'codex' | 'gemini'
|
|
393
833
|
* @returns {Promise<Object>} { availableModels: string[], preferredTestModel: string|null, cached: boolean }
|
|
394
834
|
*/
|
|
395
|
-
async function probeModelAvailability(channel, channelType) {
|
|
835
|
+
async function probeModelAvailability(channel, channelType, options = {}) {
|
|
836
|
+
const forceRefresh = !!options.forceRefresh;
|
|
837
|
+
const toolType = options.toolType;
|
|
838
|
+
const stopOnFirstAvailable = !!options.stopOnFirstAvailable;
|
|
396
839
|
const cache = loadModelCache();
|
|
397
840
|
const cacheKey = channel.id;
|
|
841
|
+
const preferredModels = normalizeModelCandidates(options.preferredModels);
|
|
842
|
+
const probeSignature = buildChannelCacheSignature(channel, {
|
|
843
|
+
type: 'probe',
|
|
844
|
+
channelType: String(channelType || '').trim().toLowerCase(),
|
|
845
|
+
toolType: String(toolType || '').trim().toLowerCase(),
|
|
846
|
+
stopOnFirstAvailable,
|
|
847
|
+
preferredModels
|
|
848
|
+
});
|
|
398
849
|
|
|
399
|
-
// Return cached result if
|
|
400
|
-
if (
|
|
850
|
+
// Return cached result if channel and probe options are unchanged
|
|
851
|
+
if (!forceRefresh && isSignatureCacheValid(cache[cacheKey], 'probeSignature', probeSignature)) {
|
|
401
852
|
return {
|
|
402
853
|
availableModels: cache[cacheKey].availableModels || [],
|
|
403
854
|
preferredTestModel: cache[cacheKey].preferredTestModel || null,
|
|
@@ -407,7 +858,8 @@ async function probeModelAvailability(channel, channelType) {
|
|
|
407
858
|
}
|
|
408
859
|
|
|
409
860
|
// Get model priority list for this channel type
|
|
410
|
-
const
|
|
861
|
+
const priorityModels = normalizeModelCandidates(getModelPriority(channelType, { toolType }));
|
|
862
|
+
const modelsToTest = normalizeModelCandidates([...preferredModels, ...priorityModels]);
|
|
411
863
|
if (modelsToTest.length === 0) {
|
|
412
864
|
console.warn(`[ModelDetector] No models defined for channel type: ${channelType}`);
|
|
413
865
|
return {
|
|
@@ -431,13 +883,17 @@ async function probeModelAvailability(channel, channelType) {
|
|
|
431
883
|
}
|
|
432
884
|
isFirstModel = false;
|
|
433
885
|
|
|
434
|
-
const
|
|
886
|
+
const probeResult = await testModelAvailabilityDetailed(channel, channelType, model);
|
|
887
|
+
const isAvailable = probeResult.available;
|
|
435
888
|
|
|
436
889
|
if (isAvailable) {
|
|
437
890
|
availableModels.push(model);
|
|
438
891
|
console.log(`[ModelDetector] ✓ ${model} available`);
|
|
892
|
+
if (stopOnFirstAvailable) {
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
439
895
|
} else {
|
|
440
|
-
console.log(`[ModelDetector] ✗ ${model} not available`);
|
|
896
|
+
console.log(`[ModelDetector] ✗ ${model} not available${formatProbeFailureDetail(probeResult.failureDetail)}`);
|
|
441
897
|
}
|
|
442
898
|
}
|
|
443
899
|
|
|
@@ -447,7 +903,10 @@ async function probeModelAvailability(channel, channelType) {
|
|
|
447
903
|
const cacheEntry = {
|
|
448
904
|
lastChecked: new Date().toISOString(),
|
|
449
905
|
availableModels,
|
|
450
|
-
preferredTestModel
|
|
906
|
+
preferredTestModel,
|
|
907
|
+
probeSignature,
|
|
908
|
+
fetchedModels: cache[cacheKey]?.fetchedModels || [],
|
|
909
|
+
listSignature: cache[cacheKey]?.listSignature || null
|
|
451
910
|
};
|
|
452
911
|
|
|
453
912
|
cache[cacheKey] = cacheEntry;
|
|
@@ -491,7 +950,7 @@ function getCachedModelInfo(channelId) {
|
|
|
491
950
|
const cache = loadModelCache();
|
|
492
951
|
const entry = cache[channelId];
|
|
493
952
|
|
|
494
|
-
if (entry &&
|
|
953
|
+
if (entry && (Array.isArray(entry.availableModels) || Array.isArray(entry.fetchedModels))) {
|
|
495
954
|
return entry;
|
|
496
955
|
}
|
|
497
956
|
|
|
@@ -504,10 +963,9 @@ function getCachedModelInfo(channelId) {
|
|
|
504
963
|
* @param {string} channelType - 'claude' | 'codex' | 'gemini' | 'openai_compatible'
|
|
505
964
|
* @returns {Promise<Object>} { models: string[], supported: boolean, cached: boolean, error: string|null, fallbackUsed: boolean }
|
|
506
965
|
*/
|
|
507
|
-
async function fetchModelsFromProvider(channel, channelType) {
|
|
508
|
-
|
|
509
|
-
const
|
|
510
|
-
|
|
966
|
+
async function fetchModelsFromProvider(channel, channelType, options = {}) {
|
|
967
|
+
const forceRefresh = !!options.forceRefresh;
|
|
968
|
+
const useV1ModelsEndpoint = shouldUseV1ModelsEndpoint(options);
|
|
511
969
|
// Only auto-detect if channelType is NOT specified at all
|
|
512
970
|
// DO NOT auto-detect when channelType is 'claude' - respect the caller's intent
|
|
513
971
|
if (!channelType) {
|
|
@@ -515,6 +973,18 @@ async function fetchModelsFromProvider(channel, channelType) {
|
|
|
515
973
|
console.log(`[ModelDetector] Auto-detected channel type: ${channelType} for ${channel.name}`);
|
|
516
974
|
}
|
|
517
975
|
|
|
976
|
+
if (!useV1ModelsEndpoint) {
|
|
977
|
+
return {
|
|
978
|
+
models: [],
|
|
979
|
+
supported: true,
|
|
980
|
+
fallbackUsed: true,
|
|
981
|
+
cached: false,
|
|
982
|
+
disabledByConfig: true,
|
|
983
|
+
error: '已关闭 /v1/models 模型列表探测',
|
|
984
|
+
errorHint: '当前使用默认模型探测策略'
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
518
988
|
// Check if provider supports model listing
|
|
519
989
|
const capability = PROVIDER_CAPABILITIES[channelType];
|
|
520
990
|
if (!capability || !capability.supportsModelList) {
|
|
@@ -529,9 +999,15 @@ async function fetchModelsFromProvider(channel, channelType) {
|
|
|
529
999
|
|
|
530
1000
|
const cache = loadModelCache();
|
|
531
1001
|
const cacheKey = channel.id;
|
|
1002
|
+
const listSignature = buildChannelCacheSignature(channel, {
|
|
1003
|
+
type: 'model-list',
|
|
1004
|
+
channelType: String(channelType || '').trim().toLowerCase()
|
|
1005
|
+
});
|
|
532
1006
|
|
|
533
|
-
// Check cache first
|
|
534
|
-
if (
|
|
1007
|
+
// Check cache first, and only reuse when channel/list context is unchanged
|
|
1008
|
+
if (!forceRefresh
|
|
1009
|
+
&& isSignatureCacheValid(cache[cacheKey], 'listSignature', listSignature)
|
|
1010
|
+
&& Array.isArray(cache[cacheKey].fetchedModels)) {
|
|
535
1011
|
return {
|
|
536
1012
|
models: cache[cacheKey].fetchedModels || [],
|
|
537
1013
|
supported: true,
|
|
@@ -559,8 +1035,10 @@ async function fetchModelsFromProvider(channel, channelType) {
|
|
|
559
1035
|
const headers = buildRequestHeaders(channelType, channel);
|
|
560
1036
|
|
|
561
1037
|
// Add authentication header
|
|
562
|
-
if (capability.authHeader
|
|
563
|
-
|
|
1038
|
+
if (capability.authHeader) {
|
|
1039
|
+
if (channel.apiKey) {
|
|
1040
|
+
headers['Authorization'] = `Bearer ${channel.apiKey}`;
|
|
1041
|
+
}
|
|
564
1042
|
}
|
|
565
1043
|
|
|
566
1044
|
const options = {
|
|
@@ -573,72 +1051,69 @@ async function fetchModelsFromProvider(channel, channelType) {
|
|
|
573
1051
|
};
|
|
574
1052
|
|
|
575
1053
|
const req = httpModule.request(options, (res) => {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1054
|
+
collectResponseBody(res)
|
|
1055
|
+
.then((data) => {
|
|
1056
|
+
// Handle different status codes
|
|
1057
|
+
if (res.statusCode === 200) {
|
|
1058
|
+
try {
|
|
1059
|
+
const response = JSON.parse(data);
|
|
1060
|
+
|
|
1061
|
+
// Parse OpenAI-compatible format: { data: [{ id: "model-name", ... }] }
|
|
1062
|
+
let models = [];
|
|
1063
|
+
if (response.data && Array.isArray(response.data)) {
|
|
1064
|
+
models = response.data
|
|
1065
|
+
.map(item => item.id || item.model)
|
|
1066
|
+
.filter(Boolean);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Update cache with fetched models
|
|
1070
|
+
const cacheEntry = {
|
|
1071
|
+
lastChecked: new Date().toISOString(),
|
|
1072
|
+
fetchedModels: models,
|
|
1073
|
+
availableModels: cache[cacheKey]?.availableModels || [],
|
|
1074
|
+
preferredTestModel: cache[cacheKey]?.preferredTestModel || null,
|
|
1075
|
+
probeSignature: cache[cacheKey]?.probeSignature || null,
|
|
1076
|
+
listSignature
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
cache[cacheKey] = cacheEntry;
|
|
1080
|
+
saveModelCache(cache);
|
|
1081
|
+
|
|
1082
|
+
console.log(`[ModelDetector] Fetched ${models.length} models from ${channel.name}`);
|
|
1083
|
+
|
|
1084
|
+
resolve({
|
|
1085
|
+
models,
|
|
1086
|
+
supported: true,
|
|
1087
|
+
cached: false,
|
|
1088
|
+
fallbackUsed: false,
|
|
1089
|
+
error: null,
|
|
1090
|
+
lastChecked: cacheEntry.lastChecked
|
|
1091
|
+
});
|
|
1092
|
+
} catch (parseError) {
|
|
1093
|
+
console.error(`[ModelDetector] Failed to parse models response: ${parseError.message}`);
|
|
1094
|
+
resolve({
|
|
1095
|
+
models: [],
|
|
1096
|
+
supported: true,
|
|
1097
|
+
cached: false,
|
|
1098
|
+
fallbackUsed: true,
|
|
1099
|
+
error: `Parse error: ${parseError.message}`
|
|
1100
|
+
});
|
|
590
1101
|
}
|
|
591
|
-
|
|
592
|
-
//
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
fetchedModels: models,
|
|
596
|
-
availableModels: cache[cacheKey]?.availableModels || [],
|
|
597
|
-
preferredTestModel: cache[cacheKey]?.preferredTestModel || null
|
|
598
|
-
};
|
|
599
|
-
|
|
600
|
-
cache[cacheKey] = cacheEntry;
|
|
601
|
-
saveModelCache(cache);
|
|
602
|
-
|
|
603
|
-
console.log(`[ModelDetector] Fetched ${models.length} models from ${channel.name}`);
|
|
604
|
-
|
|
605
|
-
resolve({
|
|
606
|
-
models,
|
|
607
|
-
supported: true,
|
|
608
|
-
cached: false,
|
|
609
|
-
fallbackUsed: false,
|
|
610
|
-
error: null,
|
|
611
|
-
lastChecked: cacheEntry.lastChecked
|
|
612
|
-
});
|
|
613
|
-
} catch (parseError) {
|
|
614
|
-
console.error(`[ModelDetector] Failed to parse models response: ${parseError.message}`);
|
|
615
|
-
resolve({
|
|
616
|
-
models: [],
|
|
617
|
-
supported: true,
|
|
618
|
-
cached: false,
|
|
619
|
-
fallbackUsed: true,
|
|
620
|
-
error: `Parse error: ${parseError.message}`
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
|
624
|
-
// Check if it's a Cloudflare protection issue
|
|
625
|
-
const bodyLower = data.toLowerCase();
|
|
626
|
-
const isCloudflare = bodyLower.includes('cloudflare') || bodyLower.includes('challenge') || bodyLower.includes('cf-ray');
|
|
1102
|
+
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
|
1103
|
+
// Check if it's a Cloudflare protection issue
|
|
1104
|
+
const bodyLower = data.toLowerCase();
|
|
1105
|
+
const isCloudflare = bodyLower.includes('cloudflare') || bodyLower.includes('challenge') || bodyLower.includes('cf-ray');
|
|
627
1106
|
|
|
628
1107
|
let errorMessage;
|
|
629
1108
|
let errorHint;
|
|
630
1109
|
|
|
631
1110
|
if (isCloudflare) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const fallbackLabel = fallbackModels[0] || 'unknown';
|
|
636
|
-
errorMessage = 'Cloudflare 防护拦截,已使用默认模型列表';
|
|
637
|
-
errorHint = `该 API 端点受 Cloudflare 保护,已自动使用默认模型列表`;
|
|
638
|
-
console.warn(`[ModelDetector] Cloudflare protection detected for ${channel.name}, using default models for ${originalChannelType || channelType}`);
|
|
1111
|
+
errorMessage = 'Cloudflare 防护拦截,无法自动获取模型列表';
|
|
1112
|
+
errorHint = '该 API 端点受 Cloudflare 保护,请手动填写模型名称';
|
|
1113
|
+
console.warn(`[ModelDetector] Cloudflare protection detected for ${channel.name}, no fallback models injected`);
|
|
639
1114
|
resolve({
|
|
640
|
-
models:
|
|
641
|
-
supported:
|
|
1115
|
+
models: [],
|
|
1116
|
+
supported: false,
|
|
642
1117
|
cached: false,
|
|
643
1118
|
fallbackUsed: true,
|
|
644
1119
|
error: errorMessage,
|
|
@@ -672,41 +1147,51 @@ async function fetchModelsFromProvider(channel, channelType) {
|
|
|
672
1147
|
statusCode: res.statusCode
|
|
673
1148
|
});
|
|
674
1149
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
1150
|
+
} else if (res.statusCode === 404) {
|
|
1151
|
+
console.warn(`[ModelDetector] Model list endpoint not found for ${channel.name}`);
|
|
1152
|
+
resolve({
|
|
1153
|
+
models: [],
|
|
1154
|
+
supported: false,
|
|
1155
|
+
cached: false,
|
|
1156
|
+
fallbackUsed: true,
|
|
1157
|
+
error: '模型列表端点不存在',
|
|
1158
|
+
errorHint: '该 API 可能不支持 /v1/models 接口,请手动输入模型名称',
|
|
1159
|
+
statusCode: 404
|
|
1160
|
+
});
|
|
1161
|
+
} else if (res.statusCode === 429) {
|
|
1162
|
+
console.warn(`[ModelDetector] Rate limited for ${channel.name}`);
|
|
1163
|
+
resolve({
|
|
1164
|
+
models: [],
|
|
1165
|
+
supported: true,
|
|
1166
|
+
cached: false,
|
|
1167
|
+
fallbackUsed: true,
|
|
1168
|
+
error: '请求频率限制',
|
|
1169
|
+
errorHint: '请稍后再试或联系服务提供商提高限额',
|
|
1170
|
+
statusCode: 429
|
|
1171
|
+
});
|
|
1172
|
+
} else {
|
|
1173
|
+
console.error(`[ModelDetector] Unexpected status ${res.statusCode} for ${channel.name}`);
|
|
1174
|
+
resolve({
|
|
1175
|
+
models: [],
|
|
1176
|
+
supported: true,
|
|
1177
|
+
cached: false,
|
|
1178
|
+
fallbackUsed: true,
|
|
1179
|
+
error: `HTTP 错误 ${res.statusCode}`,
|
|
1180
|
+
errorHint: '请检查 API 端点配置或联系服务提供商',
|
|
1181
|
+
statusCode: res.statusCode
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
})
|
|
1185
|
+
.catch((error) => {
|
|
1186
|
+
console.error(`[ModelDetector] Failed to read models response: ${error.message}`);
|
|
699
1187
|
resolve({
|
|
700
1188
|
models: [],
|
|
701
1189
|
supported: true,
|
|
702
1190
|
cached: false,
|
|
703
1191
|
fallbackUsed: true,
|
|
704
|
-
error: `
|
|
705
|
-
errorHint: '请检查 API 端点配置或联系服务提供商',
|
|
706
|
-
statusCode: res.statusCode
|
|
1192
|
+
error: `Read error: ${error.message}`
|
|
707
1193
|
});
|
|
708
|
-
}
|
|
709
|
-
});
|
|
1194
|
+
});
|
|
710
1195
|
});
|
|
711
1196
|
|
|
712
1197
|
req.on('error', (error) => {
|
|
@@ -750,6 +1235,7 @@ async function fetchModelsFromProvider(channel, channelType) {
|
|
|
750
1235
|
module.exports = {
|
|
751
1236
|
probeModelAvailability,
|
|
752
1237
|
testModelAvailability,
|
|
1238
|
+
getModelPriority,
|
|
753
1239
|
normalizeModelName,
|
|
754
1240
|
clearCache,
|
|
755
1241
|
getCachedModelInfo,
|