@adversity/coding-tool-x 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +39 -18
  2. package/README.md +8 -8
  3. package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
  4. package/dist/web/assets/ConfigTemplates-DvcbKKdS.js +1 -0
  5. package/dist/web/assets/Home-BJKPCBuk.css +1 -0
  6. package/dist/web/assets/Home-Cw-F_Wnu.js +1 -0
  7. package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
  8. package/dist/web/assets/PluginManager-jy_4GVxI.js +1 -0
  9. package/dist/web/assets/ProjectList-C1fQb9OW.css +1 -0
  10. package/dist/web/assets/ProjectList-Df1-NcNr.js +1 -0
  11. package/dist/web/assets/SessionList-BGJWyneI.css +1 -0
  12. package/dist/web/assets/SessionList-UWcZtC2r.js +1 -0
  13. package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
  14. package/dist/web/assets/SkillManager-IRdseMKB.js +1 -0
  15. package/dist/web/assets/Terminal-BasTyDut.js +1 -0
  16. package/dist/web/assets/Terminal-DGNJeVtc.css +1 -0
  17. package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
  18. package/dist/web/assets/WorkspaceManager-D-D2kK1V.js +1 -0
  19. package/dist/web/assets/icons-kcfLIMBB.js +1 -0
  20. package/dist/web/assets/index-CoB3zF0K.css +1 -0
  21. package/dist/web/assets/index-CryrSLv8.js +2 -0
  22. package/dist/web/assets/markdown-BfC0goYb.css +10 -0
  23. package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
  24. package/dist/web/assets/naive-ui-CSrLusZZ.js +1 -0
  25. package/dist/web/assets/{vendors-D2HHw_aW.js → vendors-CO3Upi1d.js} +2 -2
  26. package/dist/web/assets/vue-vendor-DqyWIXEb.js +45 -0
  27. package/dist/web/assets/xterm-6GBZ9nXN.css +32 -0
  28. package/dist/web/assets/xterm-BJzAjXCH.js +13 -0
  29. package/dist/web/index.html +8 -6
  30. package/package.json +4 -2
  31. package/src/commands/channels.js +48 -1
  32. package/src/commands/cli-type.js +4 -2
  33. package/src/commands/daemon.js +81 -12
  34. package/src/commands/doctor.js +10 -9
  35. package/src/commands/list.js +1 -1
  36. package/src/commands/logs.js +6 -4
  37. package/src/commands/port-config.js +24 -4
  38. package/src/commands/proxy-control.js +12 -6
  39. package/src/commands/search.js +1 -1
  40. package/src/commands/security.js +3 -2
  41. package/src/commands/stats.js +226 -52
  42. package/src/commands/switch.js +1 -1
  43. package/src/commands/toggle-proxy.js +31 -6
  44. package/src/commands/update.js +97 -0
  45. package/src/commands/workspace.js +1 -1
  46. package/src/config/default.js +41 -2
  47. package/src/config/loader.js +74 -8
  48. package/src/config/model-metadata.js +415 -0
  49. package/src/config/model-pricing.js +23 -93
  50. package/src/config/paths.js +105 -33
  51. package/src/index.js +64 -3
  52. package/src/plugins/constants.js +3 -2
  53. package/src/plugins/plugin-api.js +1 -1
  54. package/src/reset-config.js +4 -2
  55. package/src/server/api/agents.js +57 -14
  56. package/src/server/api/channels.js +112 -33
  57. package/src/server/api/codex-channels.js +111 -18
  58. package/src/server/api/codex-proxy.js +14 -8
  59. package/src/server/api/commands.js +71 -18
  60. package/src/server/api/config-export.js +0 -6
  61. package/src/server/api/config-registry.js +11 -3
  62. package/src/server/api/config.js +376 -5
  63. package/src/server/api/convert.js +133 -0
  64. package/src/server/api/dashboard.js +22 -6
  65. package/src/server/api/gemini-channels.js +107 -18
  66. package/src/server/api/gemini-proxy.js +14 -8
  67. package/src/server/api/gemini-sessions.js +1 -1
  68. package/src/server/api/health-check.js +4 -3
  69. package/src/server/api/mcp.js +3 -3
  70. package/src/server/api/opencode-channels.js +497 -0
  71. package/src/server/api/opencode-projects.js +99 -0
  72. package/src/server/api/opencode-proxy.js +207 -0
  73. package/src/server/api/opencode-sessions.js +345 -0
  74. package/src/server/api/opencode-statistics.js +57 -0
  75. package/src/server/api/plugins.js +66 -19
  76. package/src/server/api/prompts.js +2 -2
  77. package/src/server/api/proxy.js +7 -4
  78. package/src/server/api/sessions.js +3 -0
  79. package/src/server/api/settings.js +111 -0
  80. package/src/server/api/skills.js +69 -18
  81. package/src/server/api/workspaces.js +78 -6
  82. package/src/server/codex-proxy-server.js +36 -22
  83. package/src/server/dev-server.js +1 -1
  84. package/src/server/gemini-proxy-server.js +21 -7
  85. package/src/server/index.js +174 -58
  86. package/src/server/opencode-proxy-server.js +5486 -0
  87. package/src/server/proxy-server.js +33 -22
  88. package/src/server/services/agents-service.js +61 -24
  89. package/src/server/services/channel-scheduler.js +9 -5
  90. package/src/server/services/channels.js +64 -37
  91. package/src/server/services/codex-channels.js +56 -43
  92. package/src/server/services/codex-sessions.js +105 -6
  93. package/src/server/services/codex-settings-manager.js +271 -49
  94. package/src/server/services/codex-statistics-service.js +2 -2
  95. package/src/server/services/commands-service.js +84 -25
  96. package/src/server/services/config-export-service.js +7 -45
  97. package/src/server/services/config-registry-service.js +63 -17
  98. package/src/server/services/config-sync-manager.js +160 -7
  99. package/src/server/services/config-templates-service.js +204 -51
  100. package/src/server/services/env-checker.js +50 -13
  101. package/src/server/services/env-manager.js +155 -19
  102. package/src/server/services/favorites.js +5 -3
  103. package/src/server/services/gemini-channels.js +33 -44
  104. package/src/server/services/gemini-statistics-service.js +2 -2
  105. package/src/server/services/mcp-service.js +350 -9
  106. package/src/server/services/model-detector.js +707 -221
  107. package/src/server/services/network-access.js +80 -0
  108. package/src/server/services/opencode-channels.js +208 -0
  109. package/src/server/services/opencode-gateway-converter.js +639 -0
  110. package/src/server/services/opencode-sessions.js +931 -0
  111. package/src/server/services/opencode-settings-manager.js +478 -0
  112. package/src/server/services/opencode-statistics-service.js +255 -0
  113. package/src/server/services/plugins-service.js +479 -22
  114. package/src/server/services/prompts-service.js +53 -11
  115. package/src/server/services/proxy-runtime.js +1 -1
  116. package/src/server/services/repo-scanner-base.js +1 -1
  117. package/src/server/services/response-decoder.js +21 -0
  118. package/src/server/services/security-config.js +1 -1
  119. package/src/server/services/session-cache.js +1 -1
  120. package/src/server/services/skill-service.js +300 -46
  121. package/src/server/services/speed-test.js +464 -186
  122. package/src/server/services/statistics-service.js +2 -2
  123. package/src/server/services/terminal-commands.js +10 -3
  124. package/src/server/services/terminal-config.js +1 -1
  125. package/src/server/services/ui-config.js +1 -1
  126. package/src/server/services/workspace-service.js +57 -100
  127. package/src/server/websocket-server.js +156 -8
  128. package/src/ui/menu.js +49 -40
  129. package/src/utils/port-helper.js +22 -8
  130. package/src/utils/session.js +5 -4
  131. package/dist/web/assets/icons-CO_2OFES.js +0 -1
  132. package/dist/web/assets/index-DI8QOi-E.js +0 -14
  133. package/dist/web/assets/index-uLHGdeZh.css +0 -41
  134. package/dist/web/assets/naive-ui-B1re3c-e.js +0 -1
  135. package/dist/web/assets/vue-vendor-6JaYHOiI.js +0 -44
  136. package/src/server/api/oauth.js +0 -294
  137. package/src/server/api/permissions.js +0 -385
  138. package/src/server/config/oauth-providers.js +0 -68
  139. package/src/server/services/oauth-callback-server.js +0 -284
  140. package/src/server/services/oauth-service.js +0 -378
  141. package/src/server/services/oauth-token-storage.js +0 -135
  142. package/src/server/services/permission-templates-service.js +0 -308
@@ -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
- // Model priority by channel type
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(), '.claude', 'cc-tool');
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
- * Check if cache entry is still valid
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 isCacheValid(cacheEntry) {
265
- if (!cacheEntry || !cacheEntry.lastChecked) {
266
- return false;
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 age = Date.now() - new Date(cacheEntry.lastChecked).getTime();
270
- return age < CACHE_DURATION_MS;
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 testModelAvailability(channel, channelType, model) {
281
- return new Promise((resolve) => {
282
- try {
283
- const baseUrl = channel.baseUrl.trim().replace(/\/+$/, '');
284
- let testUrl;
285
- let requestBody;
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
- const options = {
329
- hostname: parsedUrl.hostname,
330
- port: parsedUrl.port || (isHttps ? 443 : 80),
331
- path: parsedUrl.pathname + parsedUrl.search,
332
- method: 'POST',
333
- timeout: TEST_TIMEOUT_MS,
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 req = httpModule.request(options, (res) => {
341
- let data = '';
342
- res.on('data', chunk => { data += chunk; });
343
- res.on('end', () => {
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
- req.on('error', () => resolve(false));
372
- req.on('timeout', () => {
373
- req.destroy();
374
- resolve(false);
375
- });
807
+ if (!failureDetail) {
808
+ failureDetail = buildProbeFailureDetail(attempt, result);
809
+ }
810
+ if (verdict === 'unavailable') {
811
+ return { available: false, failureDetail };
812
+ }
813
+ }
376
814
 
377
- req.write(requestBody);
378
- req.end();
815
+ return { available: false, failureDetail };
816
+ } catch {
817
+ return { available: false, failureDetail: null };
818
+ }
819
+ }
379
820
 
380
- } catch (error) {
381
- resolve(false);
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 valid
400
- if (cache[cacheKey] && isCacheValid(cache[cacheKey])) {
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 modelsToTest = MODEL_PRIORITY[channelType] || [];
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 isAvailable = await testModelAvailability(channel, channelType, model);
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 && isCacheValid(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
- // PRESERVE original channel type for fallback model selection
509
- const originalChannelType = channelType;
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 (cache[cacheKey] && isCacheValid(cache[cacheKey]) && cache[cacheKey].fetchedModels) {
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 && channel.apiKey) {
563
- headers['Authorization'] = `Bearer ${channel.apiKey}`;
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
- let data = '';
577
- res.on('data', chunk => { data += chunk; });
578
- res.on('end', () => {
579
- // Handle different status codes
580
- if (res.statusCode === 200) {
581
- try {
582
- const response = JSON.parse(data);
583
-
584
- // Parse OpenAI-compatible format: { data: [{ id: "model-name", ... }] }
585
- let models = [];
586
- if (response.data && Array.isArray(response.data)) {
587
- models = response.data
588
- .map(item => item.id || item.model)
589
- .filter(Boolean);
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
- // Update cache with fetched models
593
- const cacheEntry = {
594
- lastChecked: new Date().toISOString(),
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
- // Use originalChannelType for fallback to ensure correct models
633
- // This prevents Claude channels from getting Codex models when using third-party proxies
634
- const fallbackModels = MODEL_PRIORITY[originalChannelType || channelType] || MODEL_PRIORITY.claude;
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: fallbackModels,
641
- supported: true,
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
- } else if (res.statusCode === 404) {
676
- console.warn(`[ModelDetector] Model list endpoint not found for ${channel.name}`);
677
- resolve({
678
- models: [],
679
- supported: false,
680
- cached: false,
681
- fallbackUsed: true,
682
- error: '模型列表端点不存在',
683
- errorHint: '该 API 可能不支持 /v1/models 接口,请手动输入模型名称',
684
- statusCode: 404
685
- });
686
- } else if (res.statusCode === 429) {
687
- console.warn(`[ModelDetector] Rate limited for ${channel.name}`);
688
- resolve({
689
- models: [],
690
- supported: true,
691
- cached: false,
692
- fallbackUsed: true,
693
- error: '请求频率限制',
694
- errorHint: '请稍后再试或联系服务提供商提高限额',
695
- statusCode: 429
696
- });
697
- } else {
698
- console.error(`[ModelDetector] Unexpected status ${res.statusCode} for ${channel.name}`);
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: `HTTP 错误 ${res.statusCode}`,
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,