@adversity/coding-tool-x 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/web/assets/icons-CNM9_Fh0.js +1 -0
- package/dist/web/assets/index-BcmuQT-z.css +41 -0
- package/dist/web/assets/index-Ej0MPDUI.js +14 -0
- package/dist/web/index.html +3 -3
- package/package.json +4 -2
- package/src/commands/plugin.js +585 -0
- package/src/config/default.js +22 -3
- package/src/config/loader.js +6 -1
- package/src/index.js +229 -1
- package/src/server/api/config-export.js +122 -32
- package/src/server/api/dashboard.js +4 -3
- package/src/server/api/mcp.js +63 -0
- package/src/server/api/plugins.js +276 -0
- package/src/server/index.js +1 -0
- package/src/server/proxy-server.js +6 -3
- package/src/server/services/config-export-service.js +331 -5
- package/src/server/services/mcp-client.js +775 -0
- package/src/server/services/mcp-service.js +203 -0
- package/src/server/services/model-detector.js +350 -0
- package/src/server/services/plugins-service.js +177 -0
- package/src/server/services/pty-manager.js +65 -2
- package/src/server/services/speed-test.js +68 -37
- package/src/server/services/ui-config.js +2 -0
- package/src/server/utils/pricing.js +32 -1
- package/src/ui/menu.js +1 -0
- package/dist/web/assets/icons-BALJo7bE.js +0 -1
- package/dist/web/assets/index-CcYz-Mcz.css +0 -41
- package/dist/web/assets/index-k9b43kTe.js +0 -14
|
@@ -11,6 +11,7 @@ const toml = require('@iarna/toml');
|
|
|
11
11
|
const { spawn } = require('child_process');
|
|
12
12
|
const http = require('http');
|
|
13
13
|
const https = require('https');
|
|
14
|
+
const { McpClient } = require('./mcp-client');
|
|
14
15
|
|
|
15
16
|
// MCP 配置文件路径
|
|
16
17
|
const CC_TOOL_DIR = path.join(os.homedir(), '.claude', 'cc-tool');
|
|
@@ -21,6 +22,11 @@ const CLAUDE_CONFIG_PATH = path.join(os.homedir(), '.claude.json');
|
|
|
21
22
|
const CODEX_CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml');
|
|
22
23
|
const GEMINI_CONFIG_PATH = path.join(os.homedir(), '.gemini', 'settings.json');
|
|
23
24
|
|
|
25
|
+
// MCP 客户端连接池
|
|
26
|
+
// serverId -> { client, timestamp }
|
|
27
|
+
const mcpClientPool = new Map();
|
|
28
|
+
const POOL_TTL = 5 * 60 * 1000; // 5 minutes
|
|
29
|
+
|
|
24
30
|
// MCP 预设模板
|
|
25
31
|
const MCP_PRESETS = [
|
|
26
32
|
{
|
|
@@ -1053,6 +1059,201 @@ async function testHttpServer(spec) {
|
|
|
1053
1059
|
});
|
|
1054
1060
|
}
|
|
1055
1061
|
|
|
1062
|
+
/**
|
|
1063
|
+
* Get tools list from MCP server
|
|
1064
|
+
* @param {string} serverId - Server ID from config
|
|
1065
|
+
* @returns {Promise<{tools: Array, duration: number, status: string}>}
|
|
1066
|
+
*/
|
|
1067
|
+
async function getServerTools(serverId) {
|
|
1068
|
+
const server = getServer(serverId);
|
|
1069
|
+
if (!server) {
|
|
1070
|
+
throw new Error(`MCP 服务器 "${serverId}" 不存在`);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const startTime = Date.now();
|
|
1074
|
+
const spec = server.server;
|
|
1075
|
+
|
|
1076
|
+
try {
|
|
1077
|
+
// Check if we have a cached connection
|
|
1078
|
+
const cached = mcpClientPool.get(serverId);
|
|
1079
|
+
const now = Date.now();
|
|
1080
|
+
|
|
1081
|
+
let client;
|
|
1082
|
+
let needsInitialization = false;
|
|
1083
|
+
|
|
1084
|
+
if (cached && now - cached.timestamp < POOL_TTL && cached.client.connected) {
|
|
1085
|
+
// Reuse existing connection
|
|
1086
|
+
client = cached.client;
|
|
1087
|
+
console.log(`[MCP] Reusing pooled connection for "${serverId}"`);
|
|
1088
|
+
} else {
|
|
1089
|
+
// Create new connection
|
|
1090
|
+
if (cached) {
|
|
1091
|
+
// Clean up expired connection
|
|
1092
|
+
try {
|
|
1093
|
+
await cached.client.disconnect();
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
console.error(`[MCP] Error disconnecting expired client: ${err.message}`);
|
|
1096
|
+
}
|
|
1097
|
+
mcpClientPool.delete(serverId);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Create new client with 10s timeout
|
|
1101
|
+
client = new McpClient(spec, { timeout: 10000 });
|
|
1102
|
+
needsInitialization = true;
|
|
1103
|
+
console.log(`[MCP] Creating new connection for "${serverId}"`);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Connect and initialize if needed
|
|
1107
|
+
if (needsInitialization) {
|
|
1108
|
+
await client.connect();
|
|
1109
|
+
await client.initialize();
|
|
1110
|
+
|
|
1111
|
+
// Cache the connection
|
|
1112
|
+
mcpClientPool.set(serverId, {
|
|
1113
|
+
client,
|
|
1114
|
+
timestamp: Date.now()
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Get tools list
|
|
1119
|
+
const tools = await client.listTools();
|
|
1120
|
+
|
|
1121
|
+
return {
|
|
1122
|
+
tools,
|
|
1123
|
+
duration: Date.now() - startTime,
|
|
1124
|
+
status: 'online'
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
// Clean up failed connection from pool
|
|
1129
|
+
const cached = mcpClientPool.get(serverId);
|
|
1130
|
+
if (cached) {
|
|
1131
|
+
try {
|
|
1132
|
+
await cached.client.disconnect();
|
|
1133
|
+
} catch (e) {
|
|
1134
|
+
// ignore
|
|
1135
|
+
}
|
|
1136
|
+
mcpClientPool.delete(serverId);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
return {
|
|
1140
|
+
tools: [],
|
|
1141
|
+
duration: Date.now() - startTime,
|
|
1142
|
+
status: 'error',
|
|
1143
|
+
error: err.message
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Execute a tool on MCP server
|
|
1150
|
+
* @param {string} serverId - Server ID
|
|
1151
|
+
* @param {string} toolName - Tool name
|
|
1152
|
+
* @param {Object} arguments - Tool arguments
|
|
1153
|
+
* @returns {Promise<{result: Object, duration: number, isError: boolean, truncated?: boolean, truncatedSize?: number}>}
|
|
1154
|
+
*/
|
|
1155
|
+
async function callServerTool(serverId, toolName, arguments = {}) {
|
|
1156
|
+
const server = getServer(serverId);
|
|
1157
|
+
if (!server) {
|
|
1158
|
+
throw new Error(`MCP 服务器 "${serverId}" 不存在`);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const startTime = Date.now();
|
|
1162
|
+
const spec = server.server;
|
|
1163
|
+
|
|
1164
|
+
try {
|
|
1165
|
+
// Check if we have a cached connection
|
|
1166
|
+
const cached = mcpClientPool.get(serverId);
|
|
1167
|
+
const now = Date.now();
|
|
1168
|
+
|
|
1169
|
+
let client;
|
|
1170
|
+
let needsInitialization = false;
|
|
1171
|
+
|
|
1172
|
+
if (cached && now - cached.timestamp < POOL_TTL && cached.client.connected) {
|
|
1173
|
+
// Reuse existing connection
|
|
1174
|
+
client = cached.client;
|
|
1175
|
+
// Update timestamp
|
|
1176
|
+
cached.timestamp = now;
|
|
1177
|
+
console.log(`[MCP] Reusing pooled connection for "${serverId}"`);
|
|
1178
|
+
} else {
|
|
1179
|
+
// Create new connection
|
|
1180
|
+
if (cached) {
|
|
1181
|
+
// Clean up expired connection
|
|
1182
|
+
try {
|
|
1183
|
+
await cached.client.disconnect();
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
console.error(`[MCP] Error disconnecting expired client: ${err.message}`);
|
|
1186
|
+
}
|
|
1187
|
+
mcpClientPool.delete(serverId);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Create new client with 30s timeout
|
|
1191
|
+
client = new McpClient(spec, { timeout: 30000 });
|
|
1192
|
+
needsInitialization = true;
|
|
1193
|
+
console.log(`[MCP] Creating new connection for "${serverId}"`);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Connect and initialize if needed
|
|
1197
|
+
if (needsInitialization) {
|
|
1198
|
+
await client.connect();
|
|
1199
|
+
await client.initialize();
|
|
1200
|
+
|
|
1201
|
+
// Cache the connection
|
|
1202
|
+
mcpClientPool.set(serverId, {
|
|
1203
|
+
client,
|
|
1204
|
+
timestamp: Date.now()
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Call the tool
|
|
1209
|
+
const result = await client.callTool(toolName, arguments);
|
|
1210
|
+
|
|
1211
|
+
const duration = Date.now() - startTime;
|
|
1212
|
+
|
|
1213
|
+
// Check result size, truncate if > 10KB
|
|
1214
|
+
const resultStr = JSON.stringify(result);
|
|
1215
|
+
if (resultStr.length > 10 * 1024) {
|
|
1216
|
+
return {
|
|
1217
|
+
result: {
|
|
1218
|
+
...result,
|
|
1219
|
+
truncated: true
|
|
1220
|
+
},
|
|
1221
|
+
truncatedSize: resultStr.length,
|
|
1222
|
+
duration,
|
|
1223
|
+
isError: result.isError || false
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
return {
|
|
1228
|
+
result,
|
|
1229
|
+
duration,
|
|
1230
|
+
isError: result.isError || false
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
// Clean up failed connection from pool
|
|
1235
|
+
const cached = mcpClientPool.get(serverId);
|
|
1236
|
+
if (cached) {
|
|
1237
|
+
try {
|
|
1238
|
+
await cached.client.disconnect();
|
|
1239
|
+
} catch (e) {
|
|
1240
|
+
// ignore
|
|
1241
|
+
}
|
|
1242
|
+
mcpClientPool.delete(serverId);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
return {
|
|
1246
|
+
result: {
|
|
1247
|
+
error: err.message,
|
|
1248
|
+
code: err.code,
|
|
1249
|
+
data: err.data
|
|
1250
|
+
},
|
|
1251
|
+
duration: Date.now() - startTime,
|
|
1252
|
+
isError: true
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1056
1257
|
/**
|
|
1057
1258
|
* 更新服务器状态
|
|
1058
1259
|
*/
|
|
@@ -1182,6 +1383,8 @@ module.exports = {
|
|
|
1182
1383
|
validateServerSpec,
|
|
1183
1384
|
// 新增功能
|
|
1184
1385
|
testServer,
|
|
1386
|
+
getServerTools,
|
|
1387
|
+
callServerTool,
|
|
1185
1388
|
updateServerStatus,
|
|
1186
1389
|
updateServerOrder,
|
|
1187
1390
|
exportServers
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Detector Service
|
|
3
|
+
* Probes model availability for channels and caches results
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const https = require('https');
|
|
10
|
+
const http = require('http');
|
|
11
|
+
const { URL } = require('url');
|
|
12
|
+
|
|
13
|
+
// Model priority by channel type
|
|
14
|
+
const MODEL_PRIORITY = {
|
|
15
|
+
claude: [
|
|
16
|
+
'claude-haiku-3-5-20241022',
|
|
17
|
+
'claude-3-5-haiku-20241022',
|
|
18
|
+
'claude-sonnet-4-20250514',
|
|
19
|
+
'claude-sonnet-4-5-20250929',
|
|
20
|
+
'claude-opus-4-20250514'
|
|
21
|
+
],
|
|
22
|
+
codex: ['gpt-4o-mini', 'gpt-4o', 'gpt-5-codex', 'o3'],
|
|
23
|
+
gemini: ['gemini-2.5-flash', 'gemini-2.5-pro']
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Model name normalization mapping
|
|
27
|
+
const MODEL_ALIASES = {
|
|
28
|
+
// Claude variants
|
|
29
|
+
'claude-3-5-haiku': 'claude-3-5-haiku-20241022',
|
|
30
|
+
'claude-haiku-3-5': 'claude-haiku-3-5-20241022',
|
|
31
|
+
'claude-3-haiku': 'claude-3-5-haiku-20241022',
|
|
32
|
+
'claude-sonnet-4': 'claude-sonnet-4-20250514',
|
|
33
|
+
'claude-4-sonnet': 'claude-sonnet-4-20250514',
|
|
34
|
+
'claude-sonnet-4-5': 'claude-sonnet-4-5-20250929',
|
|
35
|
+
'claude-4-5-sonnet': 'claude-sonnet-4-5-20250929',
|
|
36
|
+
'claude-opus-4': 'claude-opus-4-20250514',
|
|
37
|
+
'claude-4-opus': 'claude-opus-4-20250514',
|
|
38
|
+
|
|
39
|
+
// Codex variants
|
|
40
|
+
'gpt-4o': 'gpt-4o',
|
|
41
|
+
'gpt4o': 'gpt-4o',
|
|
42
|
+
'gpt-4-o': 'gpt-4o',
|
|
43
|
+
'gpt-4o-mini': 'gpt-4o-mini',
|
|
44
|
+
'gpt4o-mini': 'gpt-4o-mini',
|
|
45
|
+
'gpt-5': 'gpt-5-codex',
|
|
46
|
+
'gpt5': 'gpt-5-codex',
|
|
47
|
+
'o3': 'o3',
|
|
48
|
+
|
|
49
|
+
// Gemini variants
|
|
50
|
+
'gemini-2.5-flash': 'gemini-2.5-flash',
|
|
51
|
+
'gemini-flash': 'gemini-2.5-flash',
|
|
52
|
+
'gemini-2-5-flash': 'gemini-2.5-flash',
|
|
53
|
+
'gemini-2.5-pro': 'gemini-2.5-pro',
|
|
54
|
+
'gemini-pro': 'gemini-2.5-pro',
|
|
55
|
+
'gemini-2-5-pro': 'gemini-2.5-pro'
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
|
59
|
+
const TEST_TIMEOUT_MS = 10000; // 10 seconds per model test
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get cache file path
|
|
63
|
+
*/
|
|
64
|
+
function getCacheFilePath() {
|
|
65
|
+
const dir = path.join(os.homedir(), '.claude', 'cc-tool');
|
|
66
|
+
if (!fs.existsSync(dir)) {
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
return path.join(dir, 'channel-models.json');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load model cache from disk
|
|
74
|
+
*/
|
|
75
|
+
function loadModelCache() {
|
|
76
|
+
const cachePath = getCacheFilePath();
|
|
77
|
+
try {
|
|
78
|
+
if (fs.existsSync(cachePath)) {
|
|
79
|
+
const content = fs.readFileSync(cachePath, 'utf8');
|
|
80
|
+
return JSON.parse(content);
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error('[ModelDetector] Error loading cache:', error.message);
|
|
84
|
+
}
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Save model cache to disk
|
|
90
|
+
*/
|
|
91
|
+
function saveModelCache(cache) {
|
|
92
|
+
const cachePath = getCacheFilePath();
|
|
93
|
+
try {
|
|
94
|
+
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf8');
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('[ModelDetector] Error saving cache:', error.message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Normalize model name to canonical form
|
|
102
|
+
* @param {string} model - Raw model name
|
|
103
|
+
* @returns {string} Normalized model name
|
|
104
|
+
*/
|
|
105
|
+
function normalizeModelName(model) {
|
|
106
|
+
if (!model) return null;
|
|
107
|
+
|
|
108
|
+
const normalized = model.toLowerCase().trim();
|
|
109
|
+
return MODEL_ALIASES[normalized] || model;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if cache entry is still valid
|
|
114
|
+
* @param {Object} cacheEntry - Cache entry with lastChecked timestamp
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
function isCacheValid(cacheEntry) {
|
|
118
|
+
if (!cacheEntry || !cacheEntry.lastChecked) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const age = Date.now() - new Date(cacheEntry.lastChecked).getTime();
|
|
123
|
+
return age < CACHE_DURATION_MS;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Test if a specific model is available for a channel
|
|
128
|
+
* @param {Object} channel - Channel configuration
|
|
129
|
+
* @param {string} channelType - 'claude' | 'codex' | 'gemini'
|
|
130
|
+
* @param {string} model - Model name to test
|
|
131
|
+
* @returns {Promise<boolean>}
|
|
132
|
+
*/
|
|
133
|
+
async function testModelAvailability(channel, channelType, model) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
try {
|
|
136
|
+
const baseUrl = channel.baseUrl.trim().replace(/\/+$/, '');
|
|
137
|
+
let testUrl;
|
|
138
|
+
let requestBody;
|
|
139
|
+
let headers = {
|
|
140
|
+
'Content-Type': 'application/json',
|
|
141
|
+
'User-Agent': 'Coding-Tool-ModelDetector/1.0'
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Construct API endpoint and request based on channel type
|
|
145
|
+
if (channelType === 'claude') {
|
|
146
|
+
testUrl = `${baseUrl}/v1/messages`;
|
|
147
|
+
headers['x-api-key'] = channel.apiKey;
|
|
148
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
149
|
+
requestBody = JSON.stringify({
|
|
150
|
+
model: model,
|
|
151
|
+
max_tokens: 1,
|
|
152
|
+
messages: [{ role: 'user', content: 'test' }]
|
|
153
|
+
});
|
|
154
|
+
} else if (channelType === 'codex') {
|
|
155
|
+
testUrl = `${baseUrl}/v1/chat/completions`;
|
|
156
|
+
headers['Authorization'] = `Bearer ${channel.apiKey}`;
|
|
157
|
+
requestBody = JSON.stringify({
|
|
158
|
+
model: model,
|
|
159
|
+
max_tokens: 1,
|
|
160
|
+
messages: [{ role: 'user', content: 'test' }]
|
|
161
|
+
});
|
|
162
|
+
} else if (channelType === 'gemini') {
|
|
163
|
+
// Gemini uses API key in URL
|
|
164
|
+
testUrl = `${baseUrl}/v1beta/models/${model}:generateContent?key=${channel.apiKey}`;
|
|
165
|
+
requestBody = JSON.stringify({
|
|
166
|
+
contents: [{ parts: [{ text: 'test' }] }],
|
|
167
|
+
generationConfig: { maxOutputTokens: 1 }
|
|
168
|
+
});
|
|
169
|
+
} else {
|
|
170
|
+
return resolve(false);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const parsedUrl = new URL(testUrl);
|
|
174
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
175
|
+
const httpModule = isHttps ? https : http;
|
|
176
|
+
|
|
177
|
+
const options = {
|
|
178
|
+
hostname: parsedUrl.hostname,
|
|
179
|
+
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
180
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
181
|
+
method: 'POST',
|
|
182
|
+
timeout: TEST_TIMEOUT_MS,
|
|
183
|
+
headers: {
|
|
184
|
+
...headers,
|
|
185
|
+
'Content-Length': Buffer.byteLength(requestBody)
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const req = httpModule.request(options, (res) => {
|
|
190
|
+
let data = '';
|
|
191
|
+
res.on('data', chunk => { data += chunk; });
|
|
192
|
+
res.on('end', () => {
|
|
193
|
+
// Success: 200-299 status codes
|
|
194
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
195
|
+
resolve(true);
|
|
196
|
+
} else if (res.statusCode === 400 || res.statusCode === 404) {
|
|
197
|
+
// 400/404 often means model not found or invalid
|
|
198
|
+
try {
|
|
199
|
+
const response = JSON.parse(data);
|
|
200
|
+
const errorMsg = (response.error?.message || '').toLowerCase();
|
|
201
|
+
|
|
202
|
+
// Check for model-specific errors
|
|
203
|
+
if (errorMsg.includes('model') &&
|
|
204
|
+
(errorMsg.includes('not found') || errorMsg.includes('invalid') || errorMsg.includes('does not exist'))) {
|
|
205
|
+
resolve(false);
|
|
206
|
+
} else {
|
|
207
|
+
// Other 400 errors might be auth/validation issues, not model issues
|
|
208
|
+
resolve(true);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
resolve(false);
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
// Other errors (401, 403, 500, etc.) are inconclusive
|
|
215
|
+
resolve(false);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
req.on('error', () => resolve(false));
|
|
221
|
+
req.on('timeout', () => {
|
|
222
|
+
req.destroy();
|
|
223
|
+
resolve(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
req.write(requestBody);
|
|
227
|
+
req.end();
|
|
228
|
+
|
|
229
|
+
} catch (error) {
|
|
230
|
+
resolve(false);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Probe model availability for a channel
|
|
237
|
+
* Tests models in priority order and returns first available
|
|
238
|
+
* Uses 5-minute cache to avoid repeated testing
|
|
239
|
+
*
|
|
240
|
+
* @param {Object} channel - Channel configuration
|
|
241
|
+
* @param {string} channelType - 'claude' | 'codex' | 'gemini'
|
|
242
|
+
* @returns {Promise<Object>} { availableModels: string[], preferredTestModel: string|null, cached: boolean }
|
|
243
|
+
*/
|
|
244
|
+
async function probeModelAvailability(channel, channelType) {
|
|
245
|
+
const cache = loadModelCache();
|
|
246
|
+
const cacheKey = channel.id;
|
|
247
|
+
|
|
248
|
+
// Return cached result if valid
|
|
249
|
+
if (cache[cacheKey] && isCacheValid(cache[cacheKey])) {
|
|
250
|
+
return {
|
|
251
|
+
availableModels: cache[cacheKey].availableModels || [],
|
|
252
|
+
preferredTestModel: cache[cacheKey].preferredTestModel || null,
|
|
253
|
+
cached: true,
|
|
254
|
+
lastChecked: cache[cacheKey].lastChecked
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Get model priority list for this channel type
|
|
259
|
+
const modelsToTest = MODEL_PRIORITY[channelType] || [];
|
|
260
|
+
if (modelsToTest.length === 0) {
|
|
261
|
+
console.warn(`[ModelDetector] No models defined for channel type: ${channelType}`);
|
|
262
|
+
return {
|
|
263
|
+
availableModels: [],
|
|
264
|
+
preferredTestModel: null,
|
|
265
|
+
cached: false,
|
|
266
|
+
lastChecked: new Date().toISOString()
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log(`[ModelDetector] Testing models for channel ${channel.name} (${channelType})...`);
|
|
271
|
+
|
|
272
|
+
const availableModels = [];
|
|
273
|
+
|
|
274
|
+
// Test models in priority order
|
|
275
|
+
for (const model of modelsToTest) {
|
|
276
|
+
const isAvailable = await testModelAvailability(channel, channelType, model);
|
|
277
|
+
|
|
278
|
+
if (isAvailable) {
|
|
279
|
+
availableModels.push(model);
|
|
280
|
+
console.log(`[ModelDetector] ✓ ${model} available`);
|
|
281
|
+
} else {
|
|
282
|
+
console.log(`[ModelDetector] ✗ ${model} not available`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const preferredTestModel = availableModels.length > 0 ? availableModels[0] : null;
|
|
287
|
+
|
|
288
|
+
// Update cache
|
|
289
|
+
const cacheEntry = {
|
|
290
|
+
lastChecked: new Date().toISOString(),
|
|
291
|
+
availableModels,
|
|
292
|
+
preferredTestModel
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
cache[cacheKey] = cacheEntry;
|
|
296
|
+
saveModelCache(cache);
|
|
297
|
+
|
|
298
|
+
console.log(`[ModelDetector] Found ${availableModels.length} available model(s) for ${channel.name}`);
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
availableModels,
|
|
302
|
+
preferredTestModel,
|
|
303
|
+
cached: false,
|
|
304
|
+
lastChecked: cacheEntry.lastChecked
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Clear cache for specific channel or all channels
|
|
310
|
+
* @param {string|null} channelId - Channel ID to clear, or null for all
|
|
311
|
+
*/
|
|
312
|
+
function clearCache(channelId = null) {
|
|
313
|
+
const cache = loadModelCache();
|
|
314
|
+
|
|
315
|
+
if (channelId) {
|
|
316
|
+
delete cache[channelId];
|
|
317
|
+
console.log(`[ModelDetector] Cleared cache for channel: ${channelId}`);
|
|
318
|
+
} else {
|
|
319
|
+
// Clear all
|
|
320
|
+
Object.keys(cache).forEach(key => delete cache[key]);
|
|
321
|
+
console.log('[ModelDetector] Cleared all model cache');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
saveModelCache(cache);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Get cached model info without probing
|
|
329
|
+
* @param {string} channelId - Channel ID
|
|
330
|
+
* @returns {Object|null} Cache entry or null if not found/expired
|
|
331
|
+
*/
|
|
332
|
+
function getCachedModelInfo(channelId) {
|
|
333
|
+
const cache = loadModelCache();
|
|
334
|
+
const entry = cache[channelId];
|
|
335
|
+
|
|
336
|
+
if (entry && isCacheValid(entry)) {
|
|
337
|
+
return entry;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
module.exports = {
|
|
344
|
+
probeModelAvailability,
|
|
345
|
+
testModelAvailability,
|
|
346
|
+
normalizeModelName,
|
|
347
|
+
clearCache,
|
|
348
|
+
getCachedModelInfo,
|
|
349
|
+
MODEL_PRIORITY
|
|
350
|
+
};
|