yymaxapi 1.0.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/README.md +82 -0
- package/bin/yymaxapi.js +2728 -0
- package/config/API/350/212/202/347/202/271/350/256/276/347/275/256.md +92 -0
- package/lib/config-manager.js +707 -0
- package/lib/speed-test.js +88 -0
- package/lib/ui.js +38 -0
- package/package.json +43 -0
package/bin/yymaxapi.js
ADDED
|
@@ -0,0 +1,2728 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const inquirer = require('inquirer');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const JSON5 = require('json5');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const https = require('https');
|
|
10
|
+
const http = require('http');
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const net = require('net');
|
|
13
|
+
const { exec, execFileSync, execSync, spawn } = require('child_process');
|
|
14
|
+
const ora = require('ora');
|
|
15
|
+
const DEFAULT_AUTH_MODE = 'api-key';
|
|
16
|
+
|
|
17
|
+
// ============ 错误码定义 ============
|
|
18
|
+
const ERROR_CODES = {
|
|
19
|
+
NODE_VERSION: { code: 1, message: 'Node.js 版本过低' },
|
|
20
|
+
OPENCLAW_NOT_FOUND: { code: 2, message: '未检测到 OpenClaw 安装' },
|
|
21
|
+
OPENCLAW_CMD_NOT_FOUND: { code: 3, message: '未找到 openclaw 命令' },
|
|
22
|
+
CONFIG_FAILED: { code: 4, message: '配置失败' },
|
|
23
|
+
GATEWAY_RESTART_FAILED: { code: 5, message: 'Gateway 重启失败' },
|
|
24
|
+
INVALID_INPUT: { code: 6, message: '输入无效' },
|
|
25
|
+
PERMISSION_DENIED: { code: 7, message: '权限不足' },
|
|
26
|
+
NETWORK_ERROR: { code: 8, message: '网络错误' },
|
|
27
|
+
API_TEST_FAILED: { code: 9, message: 'API 测试失败' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// 退出并显示错误
|
|
31
|
+
function exitWithError(errorType, details = '', solutions = []) {
|
|
32
|
+
const err = ERROR_CODES[errorType] || { code: 99, message: '未知错误' };
|
|
33
|
+
console.log(chalk.bold(chalk.red('\n========================================')));
|
|
34
|
+
console.log(chalk.bold(chalk.red(`❌ 错误: ${err.message}`)));
|
|
35
|
+
console.log(chalk.bold(chalk.red('========================================')));
|
|
36
|
+
if (details) console.log(chalk.gray(` ${details}`));
|
|
37
|
+
if (solutions.length > 0) {
|
|
38
|
+
console.log(chalk.cyan('\n解决方案:'));
|
|
39
|
+
solutions.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
|
|
40
|
+
}
|
|
41
|
+
console.log(chalk.gray(`\n错误码: ${err.code}`));
|
|
42
|
+
process.exit(err.code);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function safeExec(cmd, options = {}) {
|
|
46
|
+
try {
|
|
47
|
+
const output = execSync(cmd, { encoding: 'utf8', stdio: 'pipe', ...options });
|
|
48
|
+
return { ok: true, output: output.trim() };
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: e.message,
|
|
53
|
+
stderr: e.stderr?.toString() || '',
|
|
54
|
+
stdout: e.stdout?.toString() || ''
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============ 预设 (默认值, 可由 API节点设置.md 覆盖) ============
|
|
60
|
+
const DEFAULT_ENDPOINTS = [
|
|
61
|
+
{ name: '国内主节点', url: 'https://yunyi.rdzhvip.com' },
|
|
62
|
+
{ name: 'CF国外节点1', url: 'https://yunyi.cfd' },
|
|
63
|
+
{ name: 'CF国外节点2', url: 'https://cdn1.yunyi.cfd' },
|
|
64
|
+
{ name: 'CF国外节点3', url: 'https://cdn2.yunyi.cfd' }
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const FALLBACK_ENDPOINTS = [
|
|
68
|
+
{ name: '备用节点1', url: 'http://47.99.42.193' },
|
|
69
|
+
{ name: '备用节点2', url: 'http://47.97.100.10' }
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const DEFAULT_CLAUDE_MODELS = [
|
|
73
|
+
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
|
|
74
|
+
{ id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },
|
|
75
|
+
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' }
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const DEFAULT_CODEX_MODELS = [
|
|
79
|
+
{ id: 'gpt-5.3-codex', name: 'GPT 5.3 Codex' },
|
|
80
|
+
{ id: 'gpt-5.2', name: 'GPT 5.2' }
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const DEFAULT_API_CONFIG = {
|
|
84
|
+
claude: {
|
|
85
|
+
urlSuffix: '/claude',
|
|
86
|
+
api: 'anthropic-messages',
|
|
87
|
+
contextWindow: 200000,
|
|
88
|
+
maxTokens: 8192,
|
|
89
|
+
providerName: 'claude-yunyi'
|
|
90
|
+
},
|
|
91
|
+
codex: {
|
|
92
|
+
urlSuffix: '/codex',
|
|
93
|
+
api: 'openai-responses',
|
|
94
|
+
contextWindow: 128000,
|
|
95
|
+
maxTokens: 32768,
|
|
96
|
+
providerName: 'yunyi'
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function normalizeEndpoints(raw, fallback) {
|
|
101
|
+
if (!Array.isArray(raw)) return fallback;
|
|
102
|
+
const normalized = raw
|
|
103
|
+
.map(item => ({
|
|
104
|
+
name: String(item?.name || '').trim(),
|
|
105
|
+
url: String(item?.url || '').trim()
|
|
106
|
+
}))
|
|
107
|
+
.filter(item => item.name && item.url);
|
|
108
|
+
return normalized.length > 0 ? normalized : fallback;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeModels(raw, fallback) {
|
|
112
|
+
if (!Array.isArray(raw)) return fallback;
|
|
113
|
+
const normalized = raw
|
|
114
|
+
.map(item => {
|
|
115
|
+
const id = String(item?.id || item?.model || '').trim();
|
|
116
|
+
const name = String(item?.name || id).trim();
|
|
117
|
+
return id ? { id, name } : null;
|
|
118
|
+
})
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
return normalized.length > 0 ? normalized : fallback;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeApiConfig(raw, fallback) {
|
|
124
|
+
if (!raw || typeof raw !== 'object') return fallback;
|
|
125
|
+
const merged = { ...fallback };
|
|
126
|
+
for (const key of Object.keys(fallback)) {
|
|
127
|
+
if (!raw[key]) continue;
|
|
128
|
+
merged[key] = { ...fallback[key], ...raw[key] };
|
|
129
|
+
}
|
|
130
|
+
return merged;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function extractJsonBlockFromMarkdown(markdown) {
|
|
134
|
+
if (!markdown) return null;
|
|
135
|
+
const fenceRegex = /```(?:json5|json)?\s*([\s\S]*?)```/i;
|
|
136
|
+
const match = markdown.match(fenceRegex);
|
|
137
|
+
if (match && match[1]) return match[1].trim();
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parsePresetFile(filePath) {
|
|
142
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
143
|
+
if (filePath.toLowerCase().endsWith('.md')) {
|
|
144
|
+
const jsonBlock = extractJsonBlockFromMarkdown(raw);
|
|
145
|
+
if (jsonBlock) return JSON5.parse(jsonBlock);
|
|
146
|
+
}
|
|
147
|
+
return JSON5.parse(raw);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function loadPresetData() {
|
|
151
|
+
const defaultData = {
|
|
152
|
+
endpoints: DEFAULT_ENDPOINTS,
|
|
153
|
+
fallbackEndpoints: FALLBACK_ENDPOINTS,
|
|
154
|
+
models: {
|
|
155
|
+
claude: DEFAULT_CLAUDE_MODELS,
|
|
156
|
+
codex: DEFAULT_CODEX_MODELS
|
|
157
|
+
},
|
|
158
|
+
apiConfig: DEFAULT_API_CONFIG
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const envPreset = process.env.OPENCLAW_PRESET_PATH;
|
|
162
|
+
const presetCandidates = [
|
|
163
|
+
envPreset,
|
|
164
|
+
path.join(__dirname, '..', 'config', 'API节点设置.md'),
|
|
165
|
+
path.join(__dirname, 'API节点设置.md'),
|
|
166
|
+
path.join(__dirname, 'presets.json')
|
|
167
|
+
].filter(Boolean);
|
|
168
|
+
|
|
169
|
+
const presetPath = presetCandidates.find(p => fs.existsSync(p));
|
|
170
|
+
if (!presetPath) return defaultData;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const parsed = parsePresetFile(presetPath);
|
|
174
|
+
return {
|
|
175
|
+
endpoints: normalizeEndpoints(parsed?.endpoints, DEFAULT_ENDPOINTS),
|
|
176
|
+
fallbackEndpoints: normalizeEndpoints(parsed?.fallbackEndpoints, FALLBACK_ENDPOINTS),
|
|
177
|
+
models: {
|
|
178
|
+
claude: normalizeModels(parsed?.models?.claude, DEFAULT_CLAUDE_MODELS),
|
|
179
|
+
codex: normalizeModels(parsed?.models?.codex, DEFAULT_CODEX_MODELS)
|
|
180
|
+
},
|
|
181
|
+
apiConfig: normalizeApiConfig(parsed?.apiConfig, DEFAULT_API_CONFIG)
|
|
182
|
+
};
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const hint = path.basename(presetPath);
|
|
185
|
+
console.log(chalk.yellow(`⚠️ ${hint} 读取失败,已使用默认配置: ${error.message}`));
|
|
186
|
+
return defaultData;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const PRESETS = loadPresetData();
|
|
191
|
+
const ENDPOINTS = PRESETS.endpoints;
|
|
192
|
+
const FALLBACK_EPS = PRESETS.fallbackEndpoints;
|
|
193
|
+
const CLAUDE_MODELS = PRESETS.models.claude;
|
|
194
|
+
const CODEX_MODELS = PRESETS.models.codex;
|
|
195
|
+
const API_CONFIG = PRESETS.apiConfig;
|
|
196
|
+
|
|
197
|
+
// 备份文件名
|
|
198
|
+
const BACKUP_FILENAME = 'openclaw-default.json.bak';
|
|
199
|
+
const EXTRA_BIN_DIRS = [
|
|
200
|
+
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
201
|
+
path.join(os.homedir(), '.local', 'bin'),
|
|
202
|
+
'/usr/local/bin',
|
|
203
|
+
'/usr/local/sbin',
|
|
204
|
+
'/usr/bin',
|
|
205
|
+
'/usr/sbin',
|
|
206
|
+
'/bin',
|
|
207
|
+
'/sbin',
|
|
208
|
+
'/opt/moltbot/bin',
|
|
209
|
+
'/opt/moltbot/node/bin'
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
// ============ 测速功能(TCP 多采样 + 加权评分) ============
|
|
213
|
+
const SPEED_SAMPLES = 3;
|
|
214
|
+
const SPEED_TIMEOUT = 5000;
|
|
215
|
+
const SCORE_WEIGHTS = { latency: 0.6, stability: 0.3, reachability: 0.1 };
|
|
216
|
+
|
|
217
|
+
function tcpPing(host, port, timeout = SPEED_TIMEOUT) {
|
|
218
|
+
return new Promise((resolve) => {
|
|
219
|
+
const start = Date.now();
|
|
220
|
+
const sock = new net.Socket();
|
|
221
|
+
sock.setTimeout(timeout);
|
|
222
|
+
sock.once('connect', () => { sock.destroy(); resolve({ ok: true, ms: Date.now() - start }); });
|
|
223
|
+
sock.once('timeout', () => { sock.destroy(); resolve({ ok: false, ms: null }); });
|
|
224
|
+
sock.once('error', () => { sock.destroy(); resolve({ ok: false, ms: null }); });
|
|
225
|
+
sock.connect(port, host);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function multiSampleTest(url, samples = SPEED_SAMPLES) {
|
|
230
|
+
const urlObj = new URL(url);
|
|
231
|
+
const host = urlObj.hostname;
|
|
232
|
+
const port = parseInt(urlObj.port) || (urlObj.protocol === 'https:' ? 443 : 80);
|
|
233
|
+
const pings = [];
|
|
234
|
+
for (let i = 0; i < samples; i++) {
|
|
235
|
+
pings.push(await tcpPing(host, port));
|
|
236
|
+
}
|
|
237
|
+
const okPings = pings.filter(p => p.ok);
|
|
238
|
+
if (okPings.length === 0) return { success: false, latency: null, error: '连接失败', score: 0 };
|
|
239
|
+
const latencies = okPings.map(p => p.ms);
|
|
240
|
+
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
|
241
|
+
const min = Math.min(...latencies);
|
|
242
|
+
const stddev = latencies.length > 1
|
|
243
|
+
? Math.sqrt(latencies.reduce((s, v) => s + (v - avg) ** 2, 0) / latencies.length)
|
|
244
|
+
: 0;
|
|
245
|
+
const latencyScore = Math.max(0, 100 - avg / 10);
|
|
246
|
+
const stabilityScore = Math.max(0, 100 - stddev * 2);
|
|
247
|
+
const reachScore = (okPings.length / samples) * 100;
|
|
248
|
+
const score = Math.round(
|
|
249
|
+
latencyScore * SCORE_WEIGHTS.latency +
|
|
250
|
+
stabilityScore * SCORE_WEIGHTS.stability +
|
|
251
|
+
reachScore * SCORE_WEIGHTS.reachability
|
|
252
|
+
);
|
|
253
|
+
return { success: true, latency: Math.round(avg), min: Math.round(min), stddev: Math.round(stddev), samples: okPings.length, total: samples, score };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatSpeedResult(r) {
|
|
257
|
+
if (r.success) {
|
|
258
|
+
const bar = r.score >= 70 ? chalk.green('■') : r.score >= 40 ? chalk.yellow('■') : chalk.red('■');
|
|
259
|
+
return ` ${bar} ${chalk.gray(r.name)} ${chalk.green(r.latency + 'ms')} ${chalk.gray(`(±${r.stddev}ms)`)} ${chalk.cyan(`评分:${r.score}`)}`;
|
|
260
|
+
}
|
|
261
|
+
return ` ${chalk.red('□')} ${chalk.gray(r.name)} ${chalk.red(r.error)}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function testAllEndpoints(endpoints = ENDPOINTS, { label = '检测中', autoFallback = false } = {}) {
|
|
265
|
+
const spinner = ora({ text: `${label}... (0/${endpoints.length} 完成)`, spinner: 'dots' }).start();
|
|
266
|
+
let done = 0;
|
|
267
|
+
const results = await Promise.all(endpoints.map(async (ep) => {
|
|
268
|
+
const result = await multiSampleTest(ep.url);
|
|
269
|
+
done++;
|
|
270
|
+
spinner.text = `${label}... (${done}/${endpoints.length} 完成)`;
|
|
271
|
+
return { ...ep, ...result };
|
|
272
|
+
}));
|
|
273
|
+
spinner.stop();
|
|
274
|
+
for (const r of results) console.log(formatSpeedResult(r));
|
|
275
|
+
const reachable = results.filter(r => r.success);
|
|
276
|
+
|
|
277
|
+
// 自动容灾:主节点全部不可达时自动尝试备用节点
|
|
278
|
+
if (reachable.length === 0 && autoFallback && FALLBACK_EPS.length > 0) {
|
|
279
|
+
console.log(chalk.yellow('\n⚠️ 所有常规节点不可达,自动尝试备用节点...\n'));
|
|
280
|
+
const fbResults = await testAllEndpoints(FALLBACK_EPS, { label: '检测备用节点', autoFallback: false });
|
|
281
|
+
return { primary: results, fallback: fbResults.ranked || [], best: fbResults.best || null, usedFallback: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const ranked = reachable.sort((a, b) => b.score - a.score);
|
|
285
|
+
if (ranked.length > 0) {
|
|
286
|
+
console.log(chalk.green(`\n🏆 推荐节点: ${ranked[0].name} (${ranked[0].latency}ms, 评分:${ranked[0].score})`));
|
|
287
|
+
}
|
|
288
|
+
return { primary: results, ranked, best: ranked[0] || null, usedFallback: false };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function testFallbackEndpoints() {
|
|
292
|
+
if (FALLBACK_EPS.length === 0) return [];
|
|
293
|
+
return testAllEndpoints(FALLBACK_EPS, { label: '检测备用节点', autoFallback: false });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ============ API Key 验证 ============
|
|
297
|
+
function httpGetJson(url, headers = {}, timeout = 10000) {
|
|
298
|
+
return new Promise((resolve, reject) => {
|
|
299
|
+
const urlObj = new URL(url);
|
|
300
|
+
const protocol = urlObj.protocol === 'https:' ? https : http;
|
|
301
|
+
const req = protocol.get(url, { headers, timeout, rejectUnauthorized: false }, (res) => {
|
|
302
|
+
let data = '';
|
|
303
|
+
res.on('data', chunk => { data += chunk; });
|
|
304
|
+
res.on('end', () => {
|
|
305
|
+
try { resolve({ status: res.statusCode, data: JSON.parse(data) }); }
|
|
306
|
+
catch { resolve({ status: res.statusCode, data: data }); }
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('请求超时')); });
|
|
310
|
+
req.on('error', (e) => reject(e));
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function validateApiKey(nodeUrl, apiKey) {
|
|
315
|
+
const verifyUrl = `${nodeUrl.replace(/\/+$/, '')}/user/api/v1/me`;
|
|
316
|
+
const spinner = ora({ text: '正在验证 API Key...', spinner: 'dots' }).start();
|
|
317
|
+
try {
|
|
318
|
+
const res = await httpGetJson(verifyUrl, { Authorization: `Bearer ${apiKey}` });
|
|
319
|
+
if (res.status === 200 && res.data) {
|
|
320
|
+
spinner.succeed('API Key 验证成功');
|
|
321
|
+
const d = res.data;
|
|
322
|
+
// 基本信息
|
|
323
|
+
if (d.service_type) console.log(chalk.gray(` 服务类型: ${d.service_type}`));
|
|
324
|
+
if (d.billing_mode) console.log(chalk.gray(` 计费模式: ${d.billing_mode}`));
|
|
325
|
+
if (d.status && d.status !== 'active') console.log(chalk.yellow(` ⚠ 状态: ${d.status}`));
|
|
326
|
+
// 配额信息
|
|
327
|
+
if (d.total_quota !== undefined || d.remaining_quota !== undefined) {
|
|
328
|
+
const total = d.total_quota || 0;
|
|
329
|
+
const remaining = d.remaining_quota !== undefined ? d.remaining_quota : total;
|
|
330
|
+
const used = total - remaining;
|
|
331
|
+
const pct = total > 0 ? Math.round((used / total) * 100) : 0;
|
|
332
|
+
const barLen = 20;
|
|
333
|
+
const filled = Math.round(barLen * pct / 100);
|
|
334
|
+
const bar = chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(barLen - filled));
|
|
335
|
+
console.log(chalk.gray(` 总配额: ${total}`) + (d.max_requests ? chalk.gray(` | 最大请求: ${d.max_requests}`) : ''));
|
|
336
|
+
console.log(chalk.gray(` 已用/剩余: ${used} / ${remaining}`));
|
|
337
|
+
console.log(` [${bar}] ${pct}%`);
|
|
338
|
+
}
|
|
339
|
+
// 每日配额
|
|
340
|
+
if (d.daily_quota !== undefined || d.daily_remaining !== undefined) {
|
|
341
|
+
const dTotal = d.daily_quota || 0;
|
|
342
|
+
const dRemain = d.daily_remaining !== undefined ? d.daily_remaining : dTotal;
|
|
343
|
+
const dUsed = d.daily_used !== undefined ? d.daily_used : (dTotal - dRemain);
|
|
344
|
+
console.log(chalk.gray(` 今日配额: ${dTotal} | 已用: ${dUsed} | 剩余: ${dRemain}`));
|
|
345
|
+
}
|
|
346
|
+
// 有效期
|
|
347
|
+
if (d.activated_at) console.log(chalk.gray(` 激活时间: ${new Date(d.activated_at).toLocaleDateString()}`));
|
|
348
|
+
if (d.expires_at) {
|
|
349
|
+
const exp = new Date(d.expires_at);
|
|
350
|
+
const daysLeft = Math.ceil((exp - Date.now()) / 86400000);
|
|
351
|
+
const expColor = daysLeft <= 7 ? chalk.red : daysLeft <= 30 ? chalk.yellow : chalk.gray;
|
|
352
|
+
console.log(expColor(` 有效期至: ${exp.toLocaleDateString()} (${daysLeft > 0 ? `剩余 ${daysLeft} 天` : '已过期'})`));
|
|
353
|
+
}
|
|
354
|
+
return { valid: true, data: d };
|
|
355
|
+
} else {
|
|
356
|
+
spinner.fail('API Key 验证失败');
|
|
357
|
+
console.log(chalk.red(` HTTP ${res.status}`));
|
|
358
|
+
return { valid: false, status: res.status };
|
|
359
|
+
}
|
|
360
|
+
} catch (err) {
|
|
361
|
+
spinner.fail('API Key 验证失败');
|
|
362
|
+
console.log(chalk.gray(` ${err.message}`));
|
|
363
|
+
return { valid: false, error: err.message };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ============ 配置路径 ============
|
|
368
|
+
function getMoltbotStateDirs(homeDir) {
|
|
369
|
+
const candidates = [];
|
|
370
|
+
if (process.env.MOLTBOT_STATE_DIR) candidates.push(process.env.MOLTBOT_STATE_DIR);
|
|
371
|
+
candidates.push(path.join(homeDir, '.moltbot'));
|
|
372
|
+
candidates.push('/opt/moltbot');
|
|
373
|
+
candidates.push('/opt/moltbot/.moltbot');
|
|
374
|
+
candidates.push('/etc/moltbot');
|
|
375
|
+
candidates.push('/var/lib/moltbot');
|
|
376
|
+
return candidates.filter((value, index) => value && candidates.indexOf(value) === index);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function buildStateCandidates(baseDirs) {
|
|
380
|
+
const configs = [];
|
|
381
|
+
for (const baseDir of baseDirs) {
|
|
382
|
+
configs.push(
|
|
383
|
+
path.join(baseDir, 'moltbot.json'),
|
|
384
|
+
path.join(baseDir, 'openclaw.json'),
|
|
385
|
+
path.join(baseDir, 'clawdbot.json'),
|
|
386
|
+
path.join(baseDir, 'config', 'moltbot.json'),
|
|
387
|
+
path.join(baseDir, 'config', 'openclaw.json'),
|
|
388
|
+
path.join(baseDir, 'config', 'clawdbot.json')
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return configs;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function buildAuthCandidates(baseDirs) {
|
|
395
|
+
const auths = [];
|
|
396
|
+
for (const baseDir of baseDirs) {
|
|
397
|
+
auths.push(
|
|
398
|
+
path.join(baseDir, 'agents', 'main', 'agent', 'auth-profiles.json'),
|
|
399
|
+
path.join(baseDir, 'agent', 'auth-profiles.json')
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
return auths;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getConfigPath() {
|
|
406
|
+
const homeDir = os.homedir();
|
|
407
|
+
const openclawStateDir = process.env.OPENCLAW_STATE_DIR || path.join(homeDir, '.openclaw');
|
|
408
|
+
const clawdbotStateDir = process.env.CLAWDBOT_STATE_DIR || path.join(homeDir, '.clawdbot');
|
|
409
|
+
const moltbotStateDirs = getMoltbotStateDirs(homeDir);
|
|
410
|
+
const moltbotPrimaryDir = moltbotStateDirs.find(dir => fs.existsSync(dir)) || path.join(homeDir, '.moltbot');
|
|
411
|
+
|
|
412
|
+
const envConfig = process.env.OPENCLAW_CONFIG_PATH || process.env.CLAWDBOT_CONFIG_PATH || process.env.MOLTBOT_CONFIG_PATH;
|
|
413
|
+
const envConfigName = envConfig ? path.basename(envConfig).toLowerCase() : '';
|
|
414
|
+
const envHintsMoltbot = envConfigName.includes('moltbot');
|
|
415
|
+
const { cliName } = getCliMeta();
|
|
416
|
+
|
|
417
|
+
const moltbotCandidates = buildStateCandidates(moltbotStateDirs);
|
|
418
|
+
|
|
419
|
+
const openclawCandidates = [
|
|
420
|
+
path.join(openclawStateDir, 'openclaw.json'),
|
|
421
|
+
path.join(openclawStateDir, 'moltbot.json'),
|
|
422
|
+
path.join(clawdbotStateDir, 'openclaw.json'),
|
|
423
|
+
path.join(clawdbotStateDir, 'clawdbot.json'),
|
|
424
|
+
path.join(clawdbotStateDir, 'moltbot.json')
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
const moltbotExisting = moltbotCandidates.find(p => p && fs.existsSync(p));
|
|
428
|
+
const moltbotDirExists = moltbotStateDirs.some(dir => fs.existsSync(dir));
|
|
429
|
+
const preferMoltbot = envHintsMoltbot || moltbotDirExists || !!moltbotExisting || cliName === 'moltbot';
|
|
430
|
+
|
|
431
|
+
const candidates = [];
|
|
432
|
+
if (envConfig) candidates.push(envConfig);
|
|
433
|
+
if (preferMoltbot) {
|
|
434
|
+
candidates.push(...moltbotCandidates, ...openclawCandidates);
|
|
435
|
+
} else {
|
|
436
|
+
candidates.push(...openclawCandidates, ...moltbotCandidates);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const defaultConfig = preferMoltbot
|
|
440
|
+
? path.join(moltbotPrimaryDir, 'moltbot.json')
|
|
441
|
+
: path.join(openclawStateDir, 'openclaw.json');
|
|
442
|
+
|
|
443
|
+
const openclawConfig = candidates.find(p => p && fs.existsSync(p)) || (envConfig || defaultConfig);
|
|
444
|
+
|
|
445
|
+
const configDir = path.dirname(openclawConfig);
|
|
446
|
+
|
|
447
|
+
const baseAuthCandidates = buildAuthCandidates([openclawStateDir, clawdbotStateDir]);
|
|
448
|
+
const moltbotAuthCandidates = buildAuthCandidates(moltbotStateDirs);
|
|
449
|
+
|
|
450
|
+
const authCandidates = preferMoltbot
|
|
451
|
+
? [...moltbotAuthCandidates, ...baseAuthCandidates]
|
|
452
|
+
: [...baseAuthCandidates, ...moltbotAuthCandidates];
|
|
453
|
+
|
|
454
|
+
const authProfiles = authCandidates.find(p => fs.existsSync(p)) || authCandidates[0];
|
|
455
|
+
|
|
456
|
+
const syncTargets = [];
|
|
457
|
+
if (openclawConfig.startsWith(openclawStateDir) && fs.existsSync(clawdbotStateDir)) {
|
|
458
|
+
syncTargets.push(
|
|
459
|
+
path.join(clawdbotStateDir, 'openclaw.json'),
|
|
460
|
+
path.join(clawdbotStateDir, 'clawdbot.json')
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { openclawConfig, authProfiles, configDir, syncTargets };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ============ 配置读写 ============
|
|
468
|
+
function readConfig(configPath) {
|
|
469
|
+
if (fs.existsSync(configPath)) {
|
|
470
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
471
|
+
return JSON5.parse(raw);
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function writeConfig(configPath, config) {
|
|
477
|
+
const dir = path.dirname(configPath);
|
|
478
|
+
if (!fs.existsSync(dir)) {
|
|
479
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
480
|
+
}
|
|
481
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function syncClawdbotConfigs(paths, config) {
|
|
485
|
+
if (!paths.syncTargets || paths.syncTargets.length === 0) return;
|
|
486
|
+
for (const target of paths.syncTargets) {
|
|
487
|
+
if (target === paths.openclawConfig) continue;
|
|
488
|
+
const dir = path.dirname(target);
|
|
489
|
+
if (!fs.existsSync(dir)) {
|
|
490
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
491
|
+
}
|
|
492
|
+
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function writeConfigWithSync(paths, config) {
|
|
497
|
+
writeConfig(paths.openclawConfig, config);
|
|
498
|
+
syncClawdbotConfigs(paths, config);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function coerceModelsRecord(value) {
|
|
502
|
+
if (value && !Array.isArray(value) && typeof value === 'object') {
|
|
503
|
+
return value;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const record = {};
|
|
507
|
+
if (Array.isArray(value)) {
|
|
508
|
+
for (const item of value) {
|
|
509
|
+
if (typeof item === 'string') {
|
|
510
|
+
record[item] = {};
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (item && typeof item === 'object') {
|
|
514
|
+
const key = item.key || item.id || item.model || item.name;
|
|
515
|
+
if (!key) continue;
|
|
516
|
+
const entry = {};
|
|
517
|
+
if (item.alias) entry.alias = item.alias;
|
|
518
|
+
record[key] = entry;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return record;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function ensureConfigStructure(config) {
|
|
526
|
+
const next = config || {};
|
|
527
|
+
if (!next.models) next.models = {};
|
|
528
|
+
if (!next.models.providers) next.models.providers = {};
|
|
529
|
+
if (!next.agents) next.agents = {};
|
|
530
|
+
if (!next.agents.defaults) next.agents.defaults = {};
|
|
531
|
+
if (!next.agents.defaults.model) next.agents.defaults.model = {};
|
|
532
|
+
if (!next.agents.defaults.models || Array.isArray(next.agents.defaults.models) || typeof next.agents.defaults.models !== 'object') {
|
|
533
|
+
next.agents.defaults.models = coerceModelsRecord(next.agents.defaults.models);
|
|
534
|
+
}
|
|
535
|
+
if (!next.auth) next.auth = {};
|
|
536
|
+
if (!next.auth.profiles) next.auth.profiles = {};
|
|
537
|
+
return next;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function pruneProvidersByPrefix(config, prefixBase, keepProviders = []) {
|
|
541
|
+
if (!config?.models?.providers) return [];
|
|
542
|
+
const removed = [];
|
|
543
|
+
const keepSet = new Set(keepProviders);
|
|
544
|
+
|
|
545
|
+
for (const name of Object.keys(config.models.providers)) {
|
|
546
|
+
if (name.startsWith(prefixBase) && !keepSet.has(name)) {
|
|
547
|
+
delete config.models.providers[name];
|
|
548
|
+
removed.push(name);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (removed.length === 0) return removed;
|
|
553
|
+
|
|
554
|
+
if (config?.agents?.defaults?.models) {
|
|
555
|
+
for (const key of Object.keys(config.agents.defaults.models)) {
|
|
556
|
+
const provider = key.split('/')[0];
|
|
557
|
+
if (removed.includes(provider)) {
|
|
558
|
+
delete config.agents.defaults.models[key];
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (config?.agents?.defaults?.model) {
|
|
564
|
+
const currentPrimary = config.agents.defaults.model.primary || '';
|
|
565
|
+
const currentProvider = currentPrimary.split('/')[0];
|
|
566
|
+
if (removed.includes(currentProvider)) {
|
|
567
|
+
config.agents.defaults.model.primary = '';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (Array.isArray(config.agents.defaults.model.fallbacks)) {
|
|
571
|
+
config.agents.defaults.model.fallbacks = config.agents.defaults.model.fallbacks.filter((modelKey) => {
|
|
572
|
+
const provider = String(modelKey || '').split('/')[0];
|
|
573
|
+
return provider && !removed.includes(provider);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return removed;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function pruneProvidersExcept(config, keepProviders = []) {
|
|
582
|
+
if (!config?.models?.providers) return [];
|
|
583
|
+
const removed = [];
|
|
584
|
+
const keepSet = new Set(keepProviders);
|
|
585
|
+
|
|
586
|
+
for (const name of Object.keys(config.models.providers)) {
|
|
587
|
+
if (!keepSet.has(name)) {
|
|
588
|
+
delete config.models.providers[name];
|
|
589
|
+
removed.push(name);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (removed.length === 0) return removed;
|
|
594
|
+
|
|
595
|
+
if (config?.agents?.defaults?.models) {
|
|
596
|
+
for (const key of Object.keys(config.agents.defaults.models)) {
|
|
597
|
+
const provider = key.split('/')[0];
|
|
598
|
+
if (removed.includes(provider)) {
|
|
599
|
+
delete config.agents.defaults.models[key];
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (config?.agents?.defaults?.model) {
|
|
605
|
+
const currentPrimary = config.agents.defaults.model.primary || '';
|
|
606
|
+
const currentProvider = currentPrimary.split('/')[0];
|
|
607
|
+
if (removed.includes(currentProvider)) {
|
|
608
|
+
config.agents.defaults.model.primary = '';
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (Array.isArray(config.agents.defaults.model.fallbacks)) {
|
|
612
|
+
config.agents.defaults.model.fallbacks = config.agents.defaults.model.fallbacks.filter((modelKey) => {
|
|
613
|
+
const provider = String(modelKey || '').split('/')[0];
|
|
614
|
+
return provider && !removed.includes(provider);
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (config?.auth?.profiles) {
|
|
620
|
+
for (const key of Object.keys(config.auth.profiles)) {
|
|
621
|
+
const provider = key.split(':')[0];
|
|
622
|
+
if (removed.includes(provider)) {
|
|
623
|
+
delete config.auth.profiles[key];
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return removed;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Read auth-profiles.json in Gateway's versioned format { version, profiles }
|
|
632
|
+
function readAuthStore(authProfilesPath) {
|
|
633
|
+
const store = { version: 1, profiles: {} };
|
|
634
|
+
if (!fs.existsSync(authProfilesPath)) return store;
|
|
635
|
+
try {
|
|
636
|
+
const raw = JSON.parse(fs.readFileSync(authProfilesPath, 'utf8'));
|
|
637
|
+
if (raw && typeof raw === 'object' && raw.profiles && typeof raw.profiles === 'object') {
|
|
638
|
+
return raw; // already versioned
|
|
639
|
+
}
|
|
640
|
+
// migrate flat/legacy entries
|
|
641
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
642
|
+
if (v && typeof v === 'object' && v.type) store.profiles[k] = v;
|
|
643
|
+
}
|
|
644
|
+
} catch {}
|
|
645
|
+
return store;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function writeAuthStore(authProfilesPath, store) {
|
|
649
|
+
fs.writeFileSync(authProfilesPath, JSON.stringify(store, null, 2), 'utf8');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function pruneAuthProfilesByPrefix(authProfilesPath, prefixBase, keepProviders = []) {
|
|
653
|
+
const keepSet = new Set(keepProviders);
|
|
654
|
+
const store = readAuthStore(authProfilesPath);
|
|
655
|
+
|
|
656
|
+
const removed = [];
|
|
657
|
+
for (const key of Object.keys(store.profiles)) {
|
|
658
|
+
const provider = key.split(':')[0];
|
|
659
|
+
if (provider.startsWith(prefixBase) && !keepSet.has(provider)) {
|
|
660
|
+
delete store.profiles[key];
|
|
661
|
+
removed.push(provider);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (removed.length > 0) {
|
|
666
|
+
writeAuthStore(authProfilesPath, store);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return removed;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function pruneAuthProfilesExcept(authProfilesPath, keepProviders = []) {
|
|
673
|
+
const keepSet = new Set(keepProviders);
|
|
674
|
+
const store = readAuthStore(authProfilesPath);
|
|
675
|
+
|
|
676
|
+
const removed = [];
|
|
677
|
+
for (const key of Object.keys(store.profiles)) {
|
|
678
|
+
const provider = key.split(':')[0];
|
|
679
|
+
if (!keepSet.has(provider)) {
|
|
680
|
+
delete store.profiles[key];
|
|
681
|
+
removed.push(provider);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (removed.length > 0) {
|
|
686
|
+
writeAuthStore(authProfilesPath, store);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return removed;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function updateAuthProfiles(authProfilesPath, providerName, apiKey) {
|
|
693
|
+
const authDir = path.dirname(authProfilesPath);
|
|
694
|
+
if (!fs.existsSync(authDir)) {
|
|
695
|
+
fs.mkdirSync(authDir, { recursive: true });
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const store = readAuthStore(authProfilesPath);
|
|
699
|
+
const profileKey = `${providerName}:default`;
|
|
700
|
+
store.profiles[profileKey] = {
|
|
701
|
+
type: 'api_key',
|
|
702
|
+
key: apiKey.trim(),
|
|
703
|
+
provider: providerName
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
writeAuthStore(authProfilesPath, store);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function getApiKeyFromArgs(args, envFallbacks = []) {
|
|
710
|
+
const direct = (args['api-key'] || args.apiKey || args.key || '').toString().trim();
|
|
711
|
+
if (direct) return direct;
|
|
712
|
+
|
|
713
|
+
for (const envKey of envFallbacks) {
|
|
714
|
+
const value = (process.env[envKey] || '').toString().trim();
|
|
715
|
+
if (value) return value;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return '';
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function promptApiKey(message, defaultValue) {
|
|
722
|
+
// inquirer 的 password 类型在 Windows PowerShell 下会被跳过,
|
|
723
|
+
// readline 和 inquirer 共享 stdin 在 Windows 上也会冲突。
|
|
724
|
+
// 解决方案:用 inquirer 的 input 类型,加延迟确保上一个 prompt 完全释放 stdin。
|
|
725
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
726
|
+
const displayDefault = defaultValue
|
|
727
|
+
? ` (回车保留: ${defaultValue.slice(0, 4)}...${defaultValue.slice(-4)})`
|
|
728
|
+
: '';
|
|
729
|
+
const { apiKeyInput } = await inquirer.prompt([{
|
|
730
|
+
type: 'input',
|
|
731
|
+
name: 'apiKeyInput',
|
|
732
|
+
message: (message || '请输入 API Key:') + displayDefault,
|
|
733
|
+
validate: input => {
|
|
734
|
+
if (input.trim() !== '') return true;
|
|
735
|
+
if (defaultValue) return true;
|
|
736
|
+
return 'API Key 不能为空';
|
|
737
|
+
}
|
|
738
|
+
}]);
|
|
739
|
+
const key = apiKeyInput.trim();
|
|
740
|
+
if (key) return key;
|
|
741
|
+
if (defaultValue) return defaultValue;
|
|
742
|
+
return '';
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function extendPathEnv(preferredNodePath) {
|
|
746
|
+
const current = process.env.PATH || '';
|
|
747
|
+
const parts = current.split(path.delimiter).filter(Boolean);
|
|
748
|
+
if (preferredNodePath) {
|
|
749
|
+
const nodeDir = path.dirname(preferredNodePath);
|
|
750
|
+
if (nodeDir && !parts.includes(nodeDir)) {
|
|
751
|
+
parts.unshift(nodeDir);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
for (const extra of EXTRA_BIN_DIRS) {
|
|
755
|
+
if (extra && !parts.includes(extra)) {
|
|
756
|
+
parts.push(extra);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return parts.join(path.delimiter);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function isNodeShebang(filePath) {
|
|
763
|
+
try {
|
|
764
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
765
|
+
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
|
|
766
|
+
return true;
|
|
767
|
+
}
|
|
768
|
+
const fd = fs.openSync(filePath, 'r');
|
|
769
|
+
const buffer = Buffer.alloc(128);
|
|
770
|
+
const bytes = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
771
|
+
fs.closeSync(fd);
|
|
772
|
+
if (bytes <= 0) return false;
|
|
773
|
+
const header = buffer.toString('utf8', 0, bytes);
|
|
774
|
+
return header.startsWith('#!') && header.includes('node');
|
|
775
|
+
} catch {
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function shellQuote(value) {
|
|
781
|
+
const str = String(value);
|
|
782
|
+
return `"${str.replace(/(["\\$`])/g, '\\$1')}"`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function escapeXml(value) {
|
|
786
|
+
return String(value)
|
|
787
|
+
.replace(/&/g, '&')
|
|
788
|
+
.replace(/</g, '<')
|
|
789
|
+
.replace(/>/g, '>')
|
|
790
|
+
.replace(/"/g, '"')
|
|
791
|
+
.replace(/'/g, ''');
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function resolveCliBinary() {
|
|
795
|
+
const override = process.env.OPENCLAW_CLI_PATH || process.env.CLAWDBOT_CLI_PATH || process.env.MOLTBOT_CLI_PATH;
|
|
796
|
+
if (override) {
|
|
797
|
+
try {
|
|
798
|
+
if (fs.existsSync(override) && fs.statSync(override).isFile()) {
|
|
799
|
+
return override;
|
|
800
|
+
}
|
|
801
|
+
} catch {}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Validate that a found binary is a real gateway CLI, not yymaxapi itself
|
|
805
|
+
function isRealCli(filePath) {
|
|
806
|
+
try {
|
|
807
|
+
// Resolve symlinks to get the real target
|
|
808
|
+
const realPath = fs.realpathSync(filePath);
|
|
809
|
+
const baseName = path.basename(realPath).toLowerCase();
|
|
810
|
+
// Skip if it points to yymaxapi/openclawapi (our own config tool, not the gateway CLI)
|
|
811
|
+
if (baseName === 'yymaxapi' || baseName === 'yymaxapi.js' || realPath.includes('yymaxapi') || baseName === 'openclawapi' || baseName === 'openclawapi.js' || realPath.includes('openclawapi')) {
|
|
812
|
+
return false;
|
|
813
|
+
}
|
|
814
|
+
return true;
|
|
815
|
+
} catch {
|
|
816
|
+
return true; // If we can't resolve, assume it's valid
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const candidates = ['openclaw', 'clawdbot', 'moltbot'];
|
|
821
|
+
const searchDirs = (process.env.PATH || '').split(path.delimiter).concat(EXTRA_BIN_DIRS);
|
|
822
|
+
for (const name of candidates) {
|
|
823
|
+
for (const dir of searchDirs) {
|
|
824
|
+
if (!dir) continue;
|
|
825
|
+
const full = path.join(dir, name);
|
|
826
|
+
try {
|
|
827
|
+
if (fs.existsSync(full) && fs.statSync(full).isFile() && isRealCli(full)) {
|
|
828
|
+
return full;
|
|
829
|
+
}
|
|
830
|
+
} catch {}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const moltbotRoots = [];
|
|
835
|
+
if (process.env.MOLTBOT_ROOT) moltbotRoots.push(process.env.MOLTBOT_ROOT);
|
|
836
|
+
moltbotRoots.push('/opt/moltbot');
|
|
837
|
+
moltbotRoots.push('/opt/moltbot/app');
|
|
838
|
+
|
|
839
|
+
const scriptCandidates = [];
|
|
840
|
+
for (const root of moltbotRoots) {
|
|
841
|
+
scriptCandidates.push(
|
|
842
|
+
path.join(root, 'moltbot.mjs'),
|
|
843
|
+
path.join(root, 'bin', 'moltbot.mjs'),
|
|
844
|
+
path.join(root, 'bin', 'moltbot.js'),
|
|
845
|
+
path.join(root, 'src', 'moltbot.mjs'),
|
|
846
|
+
path.join(root, 'src', 'moltbot.js')
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
for (const candidate of scriptCandidates) {
|
|
851
|
+
try {
|
|
852
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
853
|
+
return candidate;
|
|
854
|
+
}
|
|
855
|
+
} catch {}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Fallback: use login shell to find the binary (loads .zshrc/.bashrc PATH)
|
|
859
|
+
for (const name of candidates) {
|
|
860
|
+
if (process.platform === 'win32') {
|
|
861
|
+
const r = safeExec(`where ${name}`);
|
|
862
|
+
if (r.ok && r.output) {
|
|
863
|
+
const resolved = r.output.split('\n')[0].trim();
|
|
864
|
+
if (resolved && fs.existsSync(resolved) && isRealCli(resolved)) return resolved;
|
|
865
|
+
}
|
|
866
|
+
} else {
|
|
867
|
+
// Try login shell (-l) so nvm/homebrew PATH is loaded
|
|
868
|
+
for (const sh of ['/bin/zsh', '/bin/bash', '/bin/sh']) {
|
|
869
|
+
if (!fs.existsSync(sh)) continue;
|
|
870
|
+
const r = safeExec(`${sh} -lc "command -v ${name}"`);
|
|
871
|
+
if (r.ok && r.output) {
|
|
872
|
+
const resolved = r.output.split('\n')[0].trim();
|
|
873
|
+
if (resolved && fs.existsSync(resolved) && isRealCli(resolved)) return resolved;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Fallback: check npm global bin directory
|
|
880
|
+
const npmPrefixResult = safeExec('npm prefix -g');
|
|
881
|
+
if (npmPrefixResult.ok && npmPrefixResult.output) {
|
|
882
|
+
const npmBin = path.join(npmPrefixResult.output.trim(), 'bin');
|
|
883
|
+
for (const name of candidates) {
|
|
884
|
+
const full = path.join(npmBin, name);
|
|
885
|
+
try {
|
|
886
|
+
if (fs.existsSync(full) && fs.statSync(full).isFile() && isRealCli(full)) return full;
|
|
887
|
+
} catch {}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Fallback: search common node_modules/.bin locations
|
|
892
|
+
const extraSearchDirs = [
|
|
893
|
+
path.join(os.homedir(), '.nvm', 'current', 'bin'),
|
|
894
|
+
'/opt/homebrew/bin',
|
|
895
|
+
'/opt/homebrew/sbin',
|
|
896
|
+
];
|
|
897
|
+
// Add nvm version dirs
|
|
898
|
+
const nvmDir = process.env.NVM_DIR || path.join(os.homedir(), '.nvm');
|
|
899
|
+
const nvmVersionsDir = path.join(nvmDir, 'versions', 'node');
|
|
900
|
+
try {
|
|
901
|
+
if (fs.existsSync(nvmVersionsDir)) {
|
|
902
|
+
for (const entry of fs.readdirSync(nvmVersionsDir)) {
|
|
903
|
+
extraSearchDirs.push(path.join(nvmVersionsDir, entry, 'bin'));
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
} catch {}
|
|
907
|
+
|
|
908
|
+
for (const dir of extraSearchDirs) {
|
|
909
|
+
for (const name of candidates) {
|
|
910
|
+
const full = path.join(dir, name);
|
|
911
|
+
try {
|
|
912
|
+
if (fs.existsSync(full) && fs.statSync(full).isFile() && isRealCli(full)) return full;
|
|
913
|
+
} catch {}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function getCliMeta() {
|
|
921
|
+
const cliBinary = resolveCliBinary();
|
|
922
|
+
const cliName = cliBinary ? path.basename(cliBinary) : '';
|
|
923
|
+
const cliLower = cliName.toLowerCase();
|
|
924
|
+
const isMoltbot = cliLower.startsWith('moltbot') || cliLower.includes('moltbot');
|
|
925
|
+
const nodeMajor = isMoltbot ? 24 : 22;
|
|
926
|
+
return { cliBinary, cliName, nodeMajor };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function getNodeMajor(versionOutput) {
|
|
930
|
+
const match = String(versionOutput || '').trim().match(/^v?(\d+)/);
|
|
931
|
+
return match ? Number(match[1]) : null;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function findCompatibleNode(minMajor = 22) {
|
|
935
|
+
const candidates = [];
|
|
936
|
+
|
|
937
|
+
if (process.env.OPENCLAW_NODE_PATH) {
|
|
938
|
+
candidates.push(process.env.OPENCLAW_NODE_PATH);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (process.execPath) {
|
|
942
|
+
candidates.push(process.execPath);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
candidates.push('/usr/bin/node', '/usr/local/bin/node', '/opt/homebrew/bin/node', '/opt/moltbot/node/bin/node');
|
|
946
|
+
|
|
947
|
+
const nvmDir = process.env.NVM_DIR || path.join(os.homedir(), '.nvm');
|
|
948
|
+
const nvmVersionsDir = path.join(nvmDir, 'versions', 'node');
|
|
949
|
+
if (fs.existsSync(nvmVersionsDir)) {
|
|
950
|
+
try {
|
|
951
|
+
const entries = fs.readdirSync(nvmVersionsDir);
|
|
952
|
+
for (const entry of entries) {
|
|
953
|
+
candidates.push(path.join(nvmVersionsDir, entry, 'bin', 'node'));
|
|
954
|
+
}
|
|
955
|
+
} catch {}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const seen = new Set();
|
|
959
|
+
for (const candidate of candidates) {
|
|
960
|
+
if (!candidate || seen.has(candidate)) continue;
|
|
961
|
+
seen.add(candidate);
|
|
962
|
+
try {
|
|
963
|
+
if (!fs.existsSync(candidate)) continue;
|
|
964
|
+
const version = execFileSync(candidate, ['-v'], { encoding: 'utf8', timeout: 2000 });
|
|
965
|
+
const major = getNodeMajor(version);
|
|
966
|
+
if (major && major >= minMajor) {
|
|
967
|
+
return { path: candidate, version: version.trim(), major };
|
|
968
|
+
}
|
|
969
|
+
} catch {}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function ensureGatewaySettings(config) {
|
|
976
|
+
if (!config.gateway) config.gateway = {};
|
|
977
|
+
const gateway = config.gateway;
|
|
978
|
+
|
|
979
|
+
if (!gateway.mode) gateway.mode = 'local';
|
|
980
|
+
if (!gateway.bind) gateway.bind = 'loopback';
|
|
981
|
+
if (!gateway.port) gateway.port = 18789;
|
|
982
|
+
|
|
983
|
+
if (!gateway.auth) gateway.auth = {};
|
|
984
|
+
if (!gateway.auth.mode) gateway.auth.mode = 'token';
|
|
985
|
+
if (!gateway.auth.token) gateway.auth.token = crypto.randomBytes(24).toString('hex');
|
|
986
|
+
|
|
987
|
+
if (!gateway.remote) gateway.remote = {};
|
|
988
|
+
const isLocal = gateway.mode === 'local' || gateway.bind === 'loopback';
|
|
989
|
+
if (isLocal && gateway.remote.token !== gateway.auth.token) {
|
|
990
|
+
gateway.remote.token = gateway.auth.token;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function isPortOpen(port, host = '127.0.0.1', timeoutMs = 800) {
|
|
995
|
+
return new Promise((resolve) => {
|
|
996
|
+
const socket = new net.Socket();
|
|
997
|
+
let settled = false;
|
|
998
|
+
|
|
999
|
+
const finish = (result) => {
|
|
1000
|
+
if (settled) return;
|
|
1001
|
+
settled = true;
|
|
1002
|
+
socket.destroy();
|
|
1003
|
+
resolve(result);
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
socket.setTimeout(timeoutMs);
|
|
1007
|
+
socket.once('connect', () => finish(true));
|
|
1008
|
+
socket.once('timeout', () => finish(false));
|
|
1009
|
+
socket.once('error', () => finish(false));
|
|
1010
|
+
|
|
1011
|
+
socket.connect(port, host);
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function waitForGateway(port, host = '127.0.0.1', timeoutMs = 8000) {
|
|
1016
|
+
const startedAt = Date.now();
|
|
1017
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
1018
|
+
if (await isPortOpen(port, host)) return true;
|
|
1019
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1020
|
+
}
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function spawnDetached(command, env) {
|
|
1025
|
+
try {
|
|
1026
|
+
const child = spawn(command, {
|
|
1027
|
+
shell: true,
|
|
1028
|
+
env,
|
|
1029
|
+
detached: true,
|
|
1030
|
+
stdio: 'ignore'
|
|
1031
|
+
});
|
|
1032
|
+
child.unref();
|
|
1033
|
+
return true;
|
|
1034
|
+
} catch {
|
|
1035
|
+
return false;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function buildLaunchAgentPlist(label, command, stdoutPath, stderrPath) {
|
|
1040
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1041
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1042
|
+
<plist version="1.0">
|
|
1043
|
+
<dict>
|
|
1044
|
+
<key>Label</key>
|
|
1045
|
+
<string>${escapeXml(label)}</string>
|
|
1046
|
+
<key>ProgramArguments</key>
|
|
1047
|
+
<array>
|
|
1048
|
+
<string>/bin/bash</string>
|
|
1049
|
+
<string>-lc</string>
|
|
1050
|
+
<string>${escapeXml(command)}</string>
|
|
1051
|
+
</array>
|
|
1052
|
+
<key>RunAtLoad</key>
|
|
1053
|
+
<true/>
|
|
1054
|
+
<key>KeepAlive</key>
|
|
1055
|
+
<true/>
|
|
1056
|
+
<key>StandardOutPath</key>
|
|
1057
|
+
<string>${escapeXml(stdoutPath)}</string>
|
|
1058
|
+
<key>StandardErrorPath</key>
|
|
1059
|
+
<string>${escapeXml(stderrPath)}</string>
|
|
1060
|
+
</dict>
|
|
1061
|
+
</plist>`;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function installMacGatewayDaemon() {
|
|
1065
|
+
if (process.platform !== 'darwin') return { success: false, reason: 'not-darwin' };
|
|
1066
|
+
|
|
1067
|
+
const { cliBinary, nodeMajor } = getCliMeta();
|
|
1068
|
+
if (!cliBinary) return { success: false, reason: 'cli-not-found' };
|
|
1069
|
+
|
|
1070
|
+
const nodeInfo = findCompatibleNode(nodeMajor);
|
|
1071
|
+
const useNode = !!(nodeInfo && isNodeShebang(cliBinary));
|
|
1072
|
+
const pathEnv = extendPathEnv(nodeInfo ? nodeInfo.path : null);
|
|
1073
|
+
const command = useNode
|
|
1074
|
+
? `PATH=${shellQuote(pathEnv)} ${shellQuote(nodeInfo.path)} ${shellQuote(cliBinary)} gateway`
|
|
1075
|
+
: `PATH=${shellQuote(pathEnv)} ${shellQuote(cliBinary)} gateway`;
|
|
1076
|
+
|
|
1077
|
+
const label = 'com.openclaw.gateway';
|
|
1078
|
+
const launchAgentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
1079
|
+
const logsDir = path.join(os.homedir(), 'Library', 'Logs');
|
|
1080
|
+
const plistPath = path.join(launchAgentsDir, `${label}.plist`);
|
|
1081
|
+
const stdoutPath = path.join(logsDir, 'openclaw-gateway.log');
|
|
1082
|
+
const stderrPath = path.join(logsDir, 'openclaw-gateway.err.log');
|
|
1083
|
+
|
|
1084
|
+
try {
|
|
1085
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
|
1086
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
1087
|
+
|
|
1088
|
+
const plist = buildLaunchAgentPlist(label, command, stdoutPath, stderrPath);
|
|
1089
|
+
fs.writeFileSync(plistPath, plist, 'utf8');
|
|
1090
|
+
|
|
1091
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
1092
|
+
if (!uid && uid !== 0) {
|
|
1093
|
+
return { success: false, reason: 'uid-missing', plistPath };
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const domain = `gui/${uid}`;
|
|
1097
|
+
const service = `${domain}/${label}`;
|
|
1098
|
+
|
|
1099
|
+
try { execFileSync('launchctl', ['bootout', service], { stdio: 'ignore' }); } catch {}
|
|
1100
|
+
execFileSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'ignore' });
|
|
1101
|
+
execFileSync('launchctl', ['kickstart', '-k', service], { stdio: 'ignore' });
|
|
1102
|
+
|
|
1103
|
+
return { success: true, plistPath };
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
return { success: false, reason: error.message };
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async function tryAutoStartGateway(port, allowAutoDaemon) {
|
|
1110
|
+
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
1111
|
+
|
|
1112
|
+
if (process.platform === 'darwin' && allowAutoDaemon) {
|
|
1113
|
+
console.log(chalk.yellow('⚠️ Gateway 未检测到运行,尝试在 macOS 后台启动 (LaunchAgent)...'));
|
|
1114
|
+
const daemonResult = installMacGatewayDaemon();
|
|
1115
|
+
if (daemonResult.success) {
|
|
1116
|
+
console.log(chalk.green('✅ 已尝试安装并启动 LaunchAgent'));
|
|
1117
|
+
if (daemonResult.plistPath) {
|
|
1118
|
+
console.log(chalk.gray(` 配置: ${daemonResult.plistPath}`));
|
|
1119
|
+
}
|
|
1120
|
+
if (await waitForGateway(port, '127.0.0.1', 10000)) {
|
|
1121
|
+
return { started: true, method: 'launchd' };
|
|
1122
|
+
}
|
|
1123
|
+
} else {
|
|
1124
|
+
if (daemonResult.reason === 'cli-not-found') {
|
|
1125
|
+
console.log(chalk.red('❌ 未找到 openclaw/clawdbot/moltbot 命令,无法自动启动 Gateway'));
|
|
1126
|
+
} else {
|
|
1127
|
+
console.log(chalk.red(`❌ 自动启动失败: ${daemonResult.reason}`));
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (process.platform === 'linux' && allowAutoDaemon) {
|
|
1133
|
+
if (safeExec('command -v systemctl').ok) {
|
|
1134
|
+
if (!isRoot) {
|
|
1135
|
+
console.log(chalk.yellow('⚠️ Gateway 未检测到运行,尝试启动 systemd --user 服务...'));
|
|
1136
|
+
const systemdUser = safeExec('systemctl --user start openclaw');
|
|
1137
|
+
if (systemdUser.ok) {
|
|
1138
|
+
if (await waitForGateway(port, '127.0.0.1', 15000)) {
|
|
1139
|
+
console.log(chalk.green('✅ 已启动 systemd --user 服务'));
|
|
1140
|
+
return { started: true, method: 'systemd-user' };
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
console.log(chalk.yellow('⚠️ systemd --user 启动失败,尝试启动 systemd 系统服务...'));
|
|
1145
|
+
const systemdSystem = safeExec('systemctl start openclaw');
|
|
1146
|
+
if (systemdSystem.ok) {
|
|
1147
|
+
if (await waitForGateway(port, '127.0.0.1', 15000)) {
|
|
1148
|
+
console.log(chalk.green('✅ 已启动 systemd 服务'));
|
|
1149
|
+
return { started: true, method: 'systemd' };
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
} else {
|
|
1153
|
+
console.log(chalk.yellow('⚠️ Gateway 未检测到运行,尝试启动 systemd 系统服务...'));
|
|
1154
|
+
const systemdSystem = safeExec('systemctl start openclaw');
|
|
1155
|
+
if (systemdSystem.ok) {
|
|
1156
|
+
if (await waitForGateway(port, '127.0.0.1', 15000)) {
|
|
1157
|
+
console.log(chalk.green('✅ 已启动 systemd 服务'));
|
|
1158
|
+
return { started: true, method: 'systemd' };
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const { cliBinary, nodeMajor } = getCliMeta();
|
|
1166
|
+
const nodeInfo = findCompatibleNode(nodeMajor);
|
|
1167
|
+
const env = { ...process.env, PATH: extendPathEnv(nodeInfo ? nodeInfo.path : null) };
|
|
1168
|
+
const useNode = cliBinary && nodeInfo && isNodeShebang(cliBinary);
|
|
1169
|
+
const cliCmd = cliBinary
|
|
1170
|
+
? (useNode ? `"${nodeInfo.path}" "${cliBinary}" gateway` : `"${cliBinary}" gateway`)
|
|
1171
|
+
: null;
|
|
1172
|
+
|
|
1173
|
+
const candidates = [];
|
|
1174
|
+
if (cliCmd) candidates.push(cliCmd);
|
|
1175
|
+
candidates.push('openclaw gateway', 'clawdbot gateway', 'moltbot gateway');
|
|
1176
|
+
|
|
1177
|
+
for (const cmd of [...new Set(candidates)].filter(Boolean)) {
|
|
1178
|
+
console.log(chalk.yellow(`⚠️ 尝试启动 Gateway: ${cmd}`));
|
|
1179
|
+
if (spawnDetached(cmd, env)) {
|
|
1180
|
+
if (await waitForGateway(port, '127.0.0.1', 10000)) {
|
|
1181
|
+
console.log(chalk.green('✅ Gateway 已启动'));
|
|
1182
|
+
return { started: true, method: 'cli', cmd };
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
return { started: false };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// ============ 备份/恢复 ============
|
|
1191
|
+
function backupOriginalConfig(configPath, configDir) {
|
|
1192
|
+
const backupPath = path.join(configDir, BACKUP_FILENAME);
|
|
1193
|
+
if (!fs.existsSync(backupPath) && fs.existsSync(configPath)) {
|
|
1194
|
+
fs.copyFileSync(configPath, backupPath);
|
|
1195
|
+
return true;
|
|
1196
|
+
}
|
|
1197
|
+
return false;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function restoreDefaultConfig(configPath, configDir) {
|
|
1201
|
+
const backupPath = path.join(configDir, BACKUP_FILENAME);
|
|
1202
|
+
if (fs.existsSync(backupPath)) {
|
|
1203
|
+
fs.copyFileSync(backupPath, configPath);
|
|
1204
|
+
return true;
|
|
1205
|
+
}
|
|
1206
|
+
return false;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// ============ URL 构建 ============
|
|
1210
|
+
function buildFullUrl(baseUrl, type) {
|
|
1211
|
+
let trimmed = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
1212
|
+
if (type === 'claude') {
|
|
1213
|
+
trimmed = trimClaudeMessagesSuffix(trimmed);
|
|
1214
|
+
}
|
|
1215
|
+
const suffix = API_CONFIG[type].urlSuffix;
|
|
1216
|
+
if (trimmed.endsWith(suffix)) return trimmed;
|
|
1217
|
+
return trimmed + suffix;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function normalizeBaseUrl(baseUrl, type, confirmAutoAppend) {
|
|
1221
|
+
let trimmed = baseUrl.trim();
|
|
1222
|
+
if (!type || !API_CONFIG[type]) return trimmed;
|
|
1223
|
+
|
|
1224
|
+
if (type === 'claude') {
|
|
1225
|
+
trimmed = trimClaudeMessagesSuffix(trimmed);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const suffix = API_CONFIG[type].urlSuffix;
|
|
1229
|
+
if (trimmed.includes(suffix)) return trimmed;
|
|
1230
|
+
|
|
1231
|
+
const urlObj = new URL(trimmed);
|
|
1232
|
+
const shouldAutoAppend = urlObj.pathname === '/' || urlObj.pathname === '';
|
|
1233
|
+
|
|
1234
|
+
if (!shouldAutoAppend) {
|
|
1235
|
+
return trimmed;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (confirmAutoAppend === false) {
|
|
1239
|
+
return trimmed;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return buildFullUrl(trimmed, type);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function isValidUrl(urlString) {
|
|
1246
|
+
try {
|
|
1247
|
+
const url = new URL(urlString);
|
|
1248
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
1249
|
+
} catch {
|
|
1250
|
+
return false;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function parseArgs(argv) {
|
|
1255
|
+
const args = { _: [] };
|
|
1256
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1257
|
+
const arg = argv[i];
|
|
1258
|
+
if (!arg.startsWith('--')) {
|
|
1259
|
+
args._.push(arg);
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const raw = arg.slice(2);
|
|
1264
|
+
const [key, inlineValue] = raw.split('=');
|
|
1265
|
+
if (inlineValue !== undefined) {
|
|
1266
|
+
args[key] = inlineValue;
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const next = argv[i + 1];
|
|
1271
|
+
if (next && !next.startsWith('--')) {
|
|
1272
|
+
args[key] = next;
|
|
1273
|
+
i += 1;
|
|
1274
|
+
} else {
|
|
1275
|
+
args[key] = true;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return args;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function trimClaudeMessagesSuffix(baseUrl) {
|
|
1282
|
+
const trimmed = baseUrl.trim();
|
|
1283
|
+
if (trimmed.endsWith('/v1/messages')) {
|
|
1284
|
+
return trimmed.slice(0, -'/v1/messages'.length);
|
|
1285
|
+
}
|
|
1286
|
+
return trimmed;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
async function quickSetup(paths, args = {}) {
|
|
1290
|
+
console.log(chalk.cyan.bold('\n🚀 快速配置向导\n'));
|
|
1291
|
+
|
|
1292
|
+
const typeArg = (args.type || args.t || '').toString().toLowerCase();
|
|
1293
|
+
const validTypes = ['claude', 'codex'];
|
|
1294
|
+
const initialType = validTypes.includes(typeArg) ? typeArg : null;
|
|
1295
|
+
|
|
1296
|
+
const { relayType } = initialType
|
|
1297
|
+
? { relayType: initialType }
|
|
1298
|
+
: await inquirer.prompt([{
|
|
1299
|
+
type: 'list',
|
|
1300
|
+
name: 'relayType',
|
|
1301
|
+
message: '选择类型:',
|
|
1302
|
+
choices: [
|
|
1303
|
+
{ name: 'Claude', value: 'claude' },
|
|
1304
|
+
{ name: 'Codex / GPT', value: 'codex' }
|
|
1305
|
+
]
|
|
1306
|
+
}]);
|
|
1307
|
+
|
|
1308
|
+
const type = relayType;
|
|
1309
|
+
const typeLabel = type === 'claude' ? 'Claude' : 'Codex';
|
|
1310
|
+
const apiConfig = API_CONFIG[type];
|
|
1311
|
+
|
|
1312
|
+
const providerName = (args.provider || args['provider-name'] || apiConfig.providerName).toString().trim() || apiConfig.providerName;
|
|
1313
|
+
|
|
1314
|
+
let baseUrl = (args['base-url'] || args.baseUrl || '').toString().trim();
|
|
1315
|
+
if (!baseUrl || !isValidUrl(baseUrl)) {
|
|
1316
|
+
const { baseUrlInput } = await inquirer.prompt([{
|
|
1317
|
+
type: 'input',
|
|
1318
|
+
name: 'baseUrlInput',
|
|
1319
|
+
message: `请输入 ${typeLabel} 中转 Base URL(可自动补全路径):`,
|
|
1320
|
+
validate: input => isValidUrl(input.trim()) || '请输入有效的 URL (http:// 或 https://)'
|
|
1321
|
+
}]);
|
|
1322
|
+
baseUrl = baseUrlInput.trim();
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl, type, true);
|
|
1326
|
+
if (normalizedBaseUrl !== baseUrl) {
|
|
1327
|
+
console.log(chalk.gray(`已自动补全路径: ${normalizedBaseUrl}`));
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const models = type === 'claude' ? CLAUDE_MODELS : CODEX_MODELS;
|
|
1331
|
+
|
|
1332
|
+
let apiKey = (args['api-key'] || args.apiKey || '').toString();
|
|
1333
|
+
if (!apiKey) {
|
|
1334
|
+
apiKey = await promptApiKey(`请输入 ${typeLabel} API Key:`);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
let modelId = (args.model || args['model-id'] || '').toString().trim();
|
|
1338
|
+
let modelName = (args['model-name'] || '').toString().trim();
|
|
1339
|
+
|
|
1340
|
+
if (!modelId) {
|
|
1341
|
+
const { selectedModel } = await inquirer.prompt([{
|
|
1342
|
+
type: 'list',
|
|
1343
|
+
name: 'selectedModel',
|
|
1344
|
+
message: `选择 ${typeLabel} 模型:`,
|
|
1345
|
+
choices: models.map(m => ({ name: m.name, value: m.id }))
|
|
1346
|
+
}]);
|
|
1347
|
+
modelId = selectedModel;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const modelConfig = models.find(m => m.id === modelId);
|
|
1351
|
+
if (!modelName) {
|
|
1352
|
+
modelName = modelConfig ? modelConfig.name : modelId;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
let setPrimary = true;
|
|
1356
|
+
if (args['no-primary'] || args['noPrimary']) {
|
|
1357
|
+
setPrimary = false;
|
|
1358
|
+
} else if (args.primary !== undefined || args['set-primary'] !== undefined) {
|
|
1359
|
+
const raw = args.primary !== undefined ? args.primary : args['set-primary'];
|
|
1360
|
+
if (typeof raw === 'string') {
|
|
1361
|
+
setPrimary = !['false', '0', 'no'].includes(raw.toLowerCase());
|
|
1362
|
+
} else {
|
|
1363
|
+
setPrimary = !!raw;
|
|
1364
|
+
}
|
|
1365
|
+
} else {
|
|
1366
|
+
const { confirmPrimary } = await inquirer.prompt([{
|
|
1367
|
+
type: 'confirm',
|
|
1368
|
+
name: 'confirmPrimary',
|
|
1369
|
+
message: '设为默认模型?',
|
|
1370
|
+
default: true
|
|
1371
|
+
}]);
|
|
1372
|
+
setPrimary = confirmPrimary;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
let config = readConfig(paths.openclawConfig) || {};
|
|
1376
|
+
config = ensureConfigStructure(config);
|
|
1377
|
+
|
|
1378
|
+
const existingProviders = Object.keys(config.models.providers || {});
|
|
1379
|
+
const toRemove = existingProviders.filter(name => name !== providerName);
|
|
1380
|
+
if (toRemove.length > 0 && !args.force) {
|
|
1381
|
+
const { overwrite } = await inquirer.prompt([{
|
|
1382
|
+
type: 'confirm',
|
|
1383
|
+
name: 'overwrite',
|
|
1384
|
+
message: `检测到已有中转配置: ${existingProviders.join(', ')},将仅保留 ${providerName}。是否继续?`,
|
|
1385
|
+
default: false
|
|
1386
|
+
}]);
|
|
1387
|
+
if (!overwrite) {
|
|
1388
|
+
console.log(chalk.gray('已取消'));
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
if (toRemove.length > 0) {
|
|
1394
|
+
pruneProvidersExcept(config, [providerName]);
|
|
1395
|
+
pruneAuthProfilesExcept(paths.authProfiles, [providerName]);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
config.models.providers[providerName] = {
|
|
1399
|
+
baseUrl: normalizedBaseUrl,
|
|
1400
|
+
auth: DEFAULT_AUTH_MODE,
|
|
1401
|
+
api: apiConfig.api,
|
|
1402
|
+
headers: {},
|
|
1403
|
+
authHeader: false,
|
|
1404
|
+
apiKey: apiKey.trim(),
|
|
1405
|
+
models: [
|
|
1406
|
+
{
|
|
1407
|
+
id: modelId,
|
|
1408
|
+
name: modelName,
|
|
1409
|
+
contextWindow: apiConfig.contextWindow,
|
|
1410
|
+
maxTokens: apiConfig.maxTokens
|
|
1411
|
+
}
|
|
1412
|
+
]
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
config.auth.profiles[`${providerName}:default`] = {
|
|
1416
|
+
provider: providerName,
|
|
1417
|
+
mode: 'api_key'
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1420
|
+
const modelKey = `${providerName}/${modelId}`;
|
|
1421
|
+
config.agents.defaults.models[modelKey] = { alias: providerName };
|
|
1422
|
+
|
|
1423
|
+
if (setPrimary) {
|
|
1424
|
+
config.agents.defaults.model.primary = modelKey;
|
|
1425
|
+
config.agents.defaults.model.fallbacks = [];
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const ws = ora({ text: '正在写入配置...', spinner: 'dots' }).start();
|
|
1429
|
+
ensureGatewaySettings(config);
|
|
1430
|
+
writeConfigWithSync(paths, config);
|
|
1431
|
+
updateAuthProfiles(paths.authProfiles, providerName, apiKey);
|
|
1432
|
+
ws.succeed('配置写入完成');
|
|
1433
|
+
|
|
1434
|
+
console.log(chalk.green(`\n✅ ${typeLabel} 中转已配置完成!`));
|
|
1435
|
+
console.log(chalk.cyan(` Provider: ${providerName}`));
|
|
1436
|
+
console.log(chalk.gray(` Base URL: ${normalizedBaseUrl}`));
|
|
1437
|
+
console.log(chalk.gray(` 模型: ${modelName}`));
|
|
1438
|
+
console.log(chalk.gray(` API Key: 已设置`));
|
|
1439
|
+
if (setPrimary) {
|
|
1440
|
+
console.log(chalk.yellow(` 主模型: ${modelKey}`));
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async function presetClaude(paths, args = {}) {
|
|
1445
|
+
console.log(chalk.cyan.bold('\n🚀 Claude 快速配置(自动测速推荐节点)\n'));
|
|
1446
|
+
|
|
1447
|
+
const apiConfig = API_CONFIG.claude;
|
|
1448
|
+
const providerPrefix = (args['provider-prefix'] || args.prefix || apiConfig.providerName).toString().trim() || apiConfig.providerName;
|
|
1449
|
+
|
|
1450
|
+
const shouldTest = !(args['no-test'] || args.noTest);
|
|
1451
|
+
let selectedEndpoint = ENDPOINTS[0];
|
|
1452
|
+
|
|
1453
|
+
if (shouldTest) {
|
|
1454
|
+
console.log(chalk.cyan('📡 开始测速 Claude 节点...\n'));
|
|
1455
|
+
const speedResult = await testAllEndpoints(ENDPOINTS, { autoFallback: true });
|
|
1456
|
+
|
|
1457
|
+
if (speedResult.best) {
|
|
1458
|
+
selectedEndpoint = speedResult.best;
|
|
1459
|
+
if (speedResult.usedFallback) {
|
|
1460
|
+
console.log(chalk.yellow(`\n⚠ 使用备用节点: ${selectedEndpoint.name} (${selectedEndpoint.latency}ms, 评分:${selectedEndpoint.score})\n`));
|
|
1461
|
+
}
|
|
1462
|
+
} else {
|
|
1463
|
+
console.log(chalk.red('\n⚠️ 所有节点(含备用)均不可达'));
|
|
1464
|
+
const { proceed } = await inquirer.prompt([{
|
|
1465
|
+
type: 'confirm', name: 'proceed',
|
|
1466
|
+
message: '仍要写入默认节点配置吗?', default: false
|
|
1467
|
+
}]);
|
|
1468
|
+
if (!proceed) { console.log(chalk.gray('已取消')); return; }
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
const config = ensureConfigStructure(readConfig(paths.openclawConfig) || {});
|
|
1473
|
+
|
|
1474
|
+
const providerName = (args['provider-name'] || args.provider || providerPrefix).toString().trim() || apiConfig.providerName;
|
|
1475
|
+
|
|
1476
|
+
const existingProviders = Object.keys(config.models.providers || {});
|
|
1477
|
+
const toRemove = existingProviders.filter(name => name !== providerName);
|
|
1478
|
+
|
|
1479
|
+
if (toRemove.length > 0 && !args.force) {
|
|
1480
|
+
const { overwrite } = await inquirer.prompt([{
|
|
1481
|
+
type: 'confirm',
|
|
1482
|
+
name: 'overwrite',
|
|
1483
|
+
message: `检测到已有中转配置: ${existingProviders.join(', ')},将仅保留 ${providerName}。是否继续?`,
|
|
1484
|
+
default: false
|
|
1485
|
+
}]);
|
|
1486
|
+
if (!overwrite) {
|
|
1487
|
+
console.log(chalk.gray('已取消'));
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const removedProviders = toRemove.length > 0
|
|
1493
|
+
? pruneProvidersExcept(config, [providerName])
|
|
1494
|
+
: [];
|
|
1495
|
+
if (removedProviders.length > 0) {
|
|
1496
|
+
pruneAuthProfilesExcept(paths.authProfiles, [providerName]);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const baseUrl = buildFullUrl(selectedEndpoint.url, 'claude');
|
|
1500
|
+
|
|
1501
|
+
const apiKeyEnvFallbacks = [
|
|
1502
|
+
'OPENCLAW_CLAUDE_KEY',
|
|
1503
|
+
'CLAUDE_API_KEY',
|
|
1504
|
+
'OPENCLAW_API_KEY'
|
|
1505
|
+
];
|
|
1506
|
+
const directKey = (args['api-key'] || args.apiKey || args.key || '').toString().trim();
|
|
1507
|
+
let apiKey;
|
|
1508
|
+
if (directKey) {
|
|
1509
|
+
apiKey = directKey;
|
|
1510
|
+
} else {
|
|
1511
|
+
const envKey = getApiKeyFromArgs({}, apiKeyEnvFallbacks);
|
|
1512
|
+
const configKey = config.models.providers[providerName]?.apiKey || '';
|
|
1513
|
+
const existingKey = envKey || configKey;
|
|
1514
|
+
apiKey = await promptApiKey('请输入 Claude API Key(将用于当前节点):', existingKey);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// 验证 API Key
|
|
1518
|
+
console.log('');
|
|
1519
|
+
const validation = await validateApiKey(selectedEndpoint.url, apiKey);
|
|
1520
|
+
if (!validation.valid) {
|
|
1521
|
+
const { continueAnyway } = await inquirer.prompt([{
|
|
1522
|
+
type: 'confirm', name: 'continueAnyway',
|
|
1523
|
+
message: 'API Key 验证失败,是否仍然继续写入配置?', default: false
|
|
1524
|
+
}]);
|
|
1525
|
+
if (!continueAnyway) { console.log(chalk.gray('已取消')); return; }
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const modelIdArg = (args.model || args['model-id'] || '').toString().trim();
|
|
1529
|
+
let modelId = modelIdArg;
|
|
1530
|
+
if (!modelId) {
|
|
1531
|
+
const { selectedModel } = await inquirer.prompt([{
|
|
1532
|
+
type: 'list',
|
|
1533
|
+
name: 'selectedModel',
|
|
1534
|
+
message: '选择 Claude 模型:',
|
|
1535
|
+
choices: CLAUDE_MODELS.map(m => ({ name: m.name, value: m.id }))
|
|
1536
|
+
}]);
|
|
1537
|
+
modelId = selectedModel;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
const modelConfig = CLAUDE_MODELS.find(m => m.id === modelId);
|
|
1541
|
+
const modelName = modelConfig ? modelConfig.name : modelId;
|
|
1542
|
+
|
|
1543
|
+
const modelKey = `${providerName}/${modelId}`;
|
|
1544
|
+
const currentPrimary = config.agents.defaults.model.primary || '';
|
|
1545
|
+
const currentProvider = currentPrimary.split('/')[0];
|
|
1546
|
+
|
|
1547
|
+
let setPrimary = true;
|
|
1548
|
+
if (args['no-primary'] || args.noPrimary) {
|
|
1549
|
+
setPrimary = false;
|
|
1550
|
+
} else if (args.primary !== undefined || args['set-primary'] !== undefined) {
|
|
1551
|
+
const raw = args.primary !== undefined ? args.primary : args['set-primary'];
|
|
1552
|
+
if (typeof raw === 'string') {
|
|
1553
|
+
setPrimary = !['false', '0', 'no'].includes(raw.toLowerCase());
|
|
1554
|
+
} else {
|
|
1555
|
+
setPrimary = !!raw;
|
|
1556
|
+
}
|
|
1557
|
+
} else {
|
|
1558
|
+
const { confirmPrimary } = await inquirer.prompt([{
|
|
1559
|
+
type: 'confirm',
|
|
1560
|
+
name: 'confirmPrimary',
|
|
1561
|
+
message: '设为默认模型?',
|
|
1562
|
+
default: true
|
|
1563
|
+
}]);
|
|
1564
|
+
setPrimary = confirmPrimary;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
config.models.providers[providerName] = {
|
|
1568
|
+
baseUrl,
|
|
1569
|
+
auth: DEFAULT_AUTH_MODE,
|
|
1570
|
+
api: apiConfig.api,
|
|
1571
|
+
headers: {},
|
|
1572
|
+
authHeader: false,
|
|
1573
|
+
apiKey: apiKey.trim(),
|
|
1574
|
+
models: [
|
|
1575
|
+
{
|
|
1576
|
+
id: modelId,
|
|
1577
|
+
name: modelName,
|
|
1578
|
+
contextWindow: apiConfig.contextWindow,
|
|
1579
|
+
maxTokens: apiConfig.maxTokens
|
|
1580
|
+
}
|
|
1581
|
+
]
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
config.auth.profiles[`${providerName}:default`] = {
|
|
1585
|
+
provider: providerName,
|
|
1586
|
+
mode: 'api_key'
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
config.agents.defaults.models[modelKey] = { alias: providerName };
|
|
1590
|
+
|
|
1591
|
+
if (setPrimary || !currentPrimary || removedProviders.includes(currentProvider)) {
|
|
1592
|
+
config.agents.defaults.model.primary = modelKey;
|
|
1593
|
+
config.agents.defaults.model.fallbacks = [];
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const writeSpinner = ora({ text: '正在写入配置...', spinner: 'dots' }).start();
|
|
1597
|
+
ensureGatewaySettings(config);
|
|
1598
|
+
writeConfigWithSync(paths, config);
|
|
1599
|
+
updateAuthProfiles(paths.authProfiles, providerName, apiKey);
|
|
1600
|
+
writeSpinner.succeed('配置写入完成');
|
|
1601
|
+
|
|
1602
|
+
console.log(chalk.green('\n✅ Claude 节点配置完成!'));
|
|
1603
|
+
const tag = setPrimary ? ' (主)' : '';
|
|
1604
|
+
console.log(chalk.cyan(` ${providerName}${tag}: ${buildFullUrl(selectedEndpoint.url, 'claude')}`));
|
|
1605
|
+
console.log(chalk.gray(` 模型: ${modelName}`));
|
|
1606
|
+
console.log(chalk.gray(' API Key: 已设置'));
|
|
1607
|
+
|
|
1608
|
+
const shouldTestGateway = args.test !== undefined
|
|
1609
|
+
? !['false', '0', 'no'].includes(String(args.test).toLowerCase())
|
|
1610
|
+
: await inquirer.prompt([{
|
|
1611
|
+
type: 'confirm',
|
|
1612
|
+
name: 'testGateway',
|
|
1613
|
+
message: '是否立即通过 OpenClaw Gateway 测试?',
|
|
1614
|
+
default: true
|
|
1615
|
+
}]).then(r => r.testGateway);
|
|
1616
|
+
|
|
1617
|
+
if (shouldTestGateway) {
|
|
1618
|
+
await testConnection(paths, args);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
async function presetCodex(paths, args = {}) {
|
|
1623
|
+
console.log(chalk.cyan.bold('\n🚀 Codex 快速配置(自动测速推荐节点)\n'));
|
|
1624
|
+
|
|
1625
|
+
const apiConfig = API_CONFIG.codex;
|
|
1626
|
+
const providerPrefix = (args['provider-prefix'] || args.prefix || apiConfig.providerName).toString().trim() || apiConfig.providerName;
|
|
1627
|
+
const providerName = (args['provider-name'] || args.provider || providerPrefix).toString().trim() || apiConfig.providerName;
|
|
1628
|
+
|
|
1629
|
+
const shouldTest = !(args['no-test'] || args.noTest);
|
|
1630
|
+
let selectedEndpoint = ENDPOINTS[0];
|
|
1631
|
+
|
|
1632
|
+
if (shouldTest) {
|
|
1633
|
+
console.log(chalk.cyan('📡 开始测速 Codex 节点...\n'));
|
|
1634
|
+
const speedResult = await testAllEndpoints(ENDPOINTS, { autoFallback: true });
|
|
1635
|
+
|
|
1636
|
+
if (speedResult.best) {
|
|
1637
|
+
selectedEndpoint = speedResult.best;
|
|
1638
|
+
if (speedResult.usedFallback) {
|
|
1639
|
+
console.log(chalk.yellow(`\n⚠ 使用备用节点: ${selectedEndpoint.name} (${selectedEndpoint.latency}ms, 评分:${selectedEndpoint.score})\n`));
|
|
1640
|
+
}
|
|
1641
|
+
} else {
|
|
1642
|
+
console.log(chalk.red('\n⚠️ 所有节点(含备用)均不可达'));
|
|
1643
|
+
const { proceed } = await inquirer.prompt([{
|
|
1644
|
+
type: 'confirm', name: 'proceed',
|
|
1645
|
+
message: '仍要写入默认节点配置吗?', default: false
|
|
1646
|
+
}]);
|
|
1647
|
+
if (!proceed) { console.log(chalk.gray('已取消')); return; }
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
const config = ensureConfigStructure(readConfig(paths.openclawConfig) || {});
|
|
1652
|
+
const existingProviders = Object.keys(config.models.providers || {});
|
|
1653
|
+
const toRemove = existingProviders.filter(name => name !== providerName);
|
|
1654
|
+
|
|
1655
|
+
if (toRemove.length > 0 && !args.force) {
|
|
1656
|
+
const { overwrite } = await inquirer.prompt([{
|
|
1657
|
+
type: 'confirm',
|
|
1658
|
+
name: 'overwrite',
|
|
1659
|
+
message: `检测到已有中转配置: ${existingProviders.join(', ')},将仅保留 ${providerName}。是否继续?`,
|
|
1660
|
+
default: false
|
|
1661
|
+
}]);
|
|
1662
|
+
if (!overwrite) {
|
|
1663
|
+
console.log(chalk.gray('已取消'));
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
const removedProviders = toRemove.length > 0
|
|
1669
|
+
? pruneProvidersExcept(config, [providerName])
|
|
1670
|
+
: [];
|
|
1671
|
+
if (removedProviders.length > 0) {
|
|
1672
|
+
pruneAuthProfilesExcept(paths.authProfiles, [providerName]);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const baseUrl = buildFullUrl(selectedEndpoint.url, 'codex');
|
|
1676
|
+
|
|
1677
|
+
const apiKeyEnvFallbacks = [
|
|
1678
|
+
'OPENCLAW_CODEX_KEY',
|
|
1679
|
+
'OPENAI_API_KEY',
|
|
1680
|
+
'OPENCLAW_API_KEY'
|
|
1681
|
+
];
|
|
1682
|
+
const directKey = (args['api-key'] || args.apiKey || args.key || '').toString().trim();
|
|
1683
|
+
let apiKey;
|
|
1684
|
+
if (directKey) {
|
|
1685
|
+
apiKey = directKey;
|
|
1686
|
+
} else {
|
|
1687
|
+
const existingKey = getApiKeyFromArgs({}, apiKeyEnvFallbacks)
|
|
1688
|
+
|| config.models.providers[providerName]?.apiKey || '';
|
|
1689
|
+
apiKey = await promptApiKey('请输入 Codex API Key(将用于当前节点):', existingKey);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// 验证 API Key
|
|
1693
|
+
console.log('');
|
|
1694
|
+
const validation = await validateApiKey(selectedEndpoint.url, apiKey);
|
|
1695
|
+
if (!validation.valid) {
|
|
1696
|
+
const { continueAnyway } = await inquirer.prompt([{
|
|
1697
|
+
type: 'confirm', name: 'continueAnyway',
|
|
1698
|
+
message: 'API Key 验证失败,是否仍然继续写入配置?', default: false
|
|
1699
|
+
}]);
|
|
1700
|
+
if (!continueAnyway) { console.log(chalk.gray('已取消')); return; }
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const modelIdArg = (args.model || args['model-id'] || '').toString().trim();
|
|
1704
|
+
let modelId = modelIdArg;
|
|
1705
|
+
if (!modelId) {
|
|
1706
|
+
const { selectedModel } = await inquirer.prompt([{
|
|
1707
|
+
type: 'list',
|
|
1708
|
+
name: 'selectedModel',
|
|
1709
|
+
message: '选择 Codex 模型:',
|
|
1710
|
+
choices: CODEX_MODELS.map(m => ({ name: m.name, value: m.id }))
|
|
1711
|
+
}]);
|
|
1712
|
+
modelId = selectedModel;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
const modelConfig = CODEX_MODELS.find(m => m.id === modelId);
|
|
1716
|
+
const modelName = modelConfig ? modelConfig.name : modelId;
|
|
1717
|
+
|
|
1718
|
+
const modelKey = `${providerName}/${modelId}`;
|
|
1719
|
+
const currentPrimary = config.agents.defaults.model.primary || '';
|
|
1720
|
+
const currentProvider = currentPrimary.split('/')[0];
|
|
1721
|
+
|
|
1722
|
+
let setPrimary = true;
|
|
1723
|
+
if (args['no-primary'] || args.noPrimary) {
|
|
1724
|
+
setPrimary = false;
|
|
1725
|
+
} else if (args.primary !== undefined || args['set-primary'] !== undefined) {
|
|
1726
|
+
const raw = args.primary !== undefined ? args.primary : args['set-primary'];
|
|
1727
|
+
if (typeof raw === 'string') {
|
|
1728
|
+
setPrimary = !['false', '0', 'no'].includes(raw.toLowerCase());
|
|
1729
|
+
} else {
|
|
1730
|
+
setPrimary = !!raw;
|
|
1731
|
+
}
|
|
1732
|
+
} else {
|
|
1733
|
+
const { confirmPrimary } = await inquirer.prompt([{
|
|
1734
|
+
type: 'confirm',
|
|
1735
|
+
name: 'confirmPrimary',
|
|
1736
|
+
message: '设为默认模型?',
|
|
1737
|
+
default: true
|
|
1738
|
+
}]);
|
|
1739
|
+
setPrimary = confirmPrimary;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
config.models.providers[providerName] = {
|
|
1743
|
+
baseUrl,
|
|
1744
|
+
auth: DEFAULT_AUTH_MODE,
|
|
1745
|
+
api: apiConfig.api,
|
|
1746
|
+
headers: {},
|
|
1747
|
+
authHeader: false,
|
|
1748
|
+
apiKey: apiKey.trim(),
|
|
1749
|
+
models: [
|
|
1750
|
+
{
|
|
1751
|
+
id: modelId,
|
|
1752
|
+
name: modelName,
|
|
1753
|
+
contextWindow: apiConfig.contextWindow,
|
|
1754
|
+
maxTokens: apiConfig.maxTokens
|
|
1755
|
+
}
|
|
1756
|
+
]
|
|
1757
|
+
};
|
|
1758
|
+
|
|
1759
|
+
config.auth.profiles[`${providerName}:default`] = {
|
|
1760
|
+
provider: providerName,
|
|
1761
|
+
mode: 'api_key'
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
config.agents.defaults.models[modelKey] = { alias: providerName };
|
|
1765
|
+
|
|
1766
|
+
if (setPrimary || !currentPrimary || removedProviders.includes(currentProvider)) {
|
|
1767
|
+
config.agents.defaults.model.primary = modelKey;
|
|
1768
|
+
config.agents.defaults.model.fallbacks = [];
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
const writeSpinner2 = ora({ text: '正在写入配置...', spinner: 'dots' }).start();
|
|
1772
|
+
ensureGatewaySettings(config);
|
|
1773
|
+
writeConfigWithSync(paths, config);
|
|
1774
|
+
updateAuthProfiles(paths.authProfiles, providerName, apiKey);
|
|
1775
|
+
writeSpinner2.succeed('配置写入完成');
|
|
1776
|
+
|
|
1777
|
+
console.log(chalk.green('\n✅ Codex 节点配置完成!'));
|
|
1778
|
+
const tag = setPrimary ? ' (主)' : '';
|
|
1779
|
+
console.log(chalk.cyan(` ${providerName}${tag}: ${baseUrl}`));
|
|
1780
|
+
console.log(chalk.gray(` 模型: ${modelName}`));
|
|
1781
|
+
console.log(chalk.gray(' API Key: 已设置'));
|
|
1782
|
+
|
|
1783
|
+
const shouldTestGateway = args.test !== undefined
|
|
1784
|
+
? !['false', '0', 'no'].includes(String(args.test).toLowerCase())
|
|
1785
|
+
: await inquirer.prompt([{
|
|
1786
|
+
type: 'confirm',
|
|
1787
|
+
name: 'testGateway',
|
|
1788
|
+
message: '是否立即通过 OpenClaw Gateway 测试?',
|
|
1789
|
+
default: true
|
|
1790
|
+
}]).then(r => r.testGateway);
|
|
1791
|
+
|
|
1792
|
+
if (shouldTestGateway) {
|
|
1793
|
+
await testConnection(paths, args);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// ============ 主程序 ============
|
|
1798
|
+
async function main() {
|
|
1799
|
+
console.clear();
|
|
1800
|
+
console.log(chalk.cyan.bold('\n🔧 OpenClaw API 配置工具\n'));
|
|
1801
|
+
|
|
1802
|
+
const paths = getConfigPath();
|
|
1803
|
+
console.log(chalk.gray(`配置文件: ${paths.openclawConfig}\n`));
|
|
1804
|
+
|
|
1805
|
+
// 首次运行备份
|
|
1806
|
+
if (backupOriginalConfig(paths.openclawConfig, paths.configDir)) {
|
|
1807
|
+
console.log(chalk.green('✓ 已备份原始配置\n'));
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1811
|
+
if (args.quick || args._.includes('quick')) {
|
|
1812
|
+
await quickSetup(paths, args);
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
if (args.preset === 'claude' || args._.includes('preset-claude') || args._.includes('claude-preset')) {
|
|
1816
|
+
await presetClaude(paths, args);
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
if (args.preset === 'codex' || args._.includes('preset-codex') || args._.includes('codex-preset')) {
|
|
1820
|
+
await presetCodex(paths, args);
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
if (args._.includes('test')) {
|
|
1824
|
+
await testConnection(paths, args);
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
while (true) {
|
|
1829
|
+
// 显示当前状态
|
|
1830
|
+
const statusLine = getConfigStatusLine(paths);
|
|
1831
|
+
if (statusLine) {
|
|
1832
|
+
console.log(chalk.gray('─'.repeat(40)));
|
|
1833
|
+
console.log(statusLine);
|
|
1834
|
+
console.log(chalk.gray('─'.repeat(40)) + '\n');
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
const { action } = await inquirer.prompt([{
|
|
1838
|
+
type: 'list',
|
|
1839
|
+
name: 'action',
|
|
1840
|
+
message: '请选择操作:',
|
|
1841
|
+
pageSize: 10,
|
|
1842
|
+
loop: false,
|
|
1843
|
+
choices: [
|
|
1844
|
+
new inquirer.Separator(chalk.gray('── 配置模型 ──')),
|
|
1845
|
+
{ name: '⚡ 激活 Claude', value: 'activate_claude' },
|
|
1846
|
+
{ name: '⚡ 激活 Codex (GPT)', value: 'activate_codex' },
|
|
1847
|
+
new inquirer.Separator(chalk.gray('── 工具 ──')),
|
|
1848
|
+
{ name: '→ 测试连接', value: 'test_connection' },
|
|
1849
|
+
{ name: '→ 查看配置', value: 'view_config' },
|
|
1850
|
+
{ name: '→ 恢复默认', value: 'restore' },
|
|
1851
|
+
new inquirer.Separator(''),
|
|
1852
|
+
{ name: chalk.gray('退出'), value: 'exit' }
|
|
1853
|
+
]
|
|
1854
|
+
}]);
|
|
1855
|
+
|
|
1856
|
+
console.log('');
|
|
1857
|
+
|
|
1858
|
+
if (action === 'exit') {
|
|
1859
|
+
console.log(chalk.cyan('👋 再见!\n'));
|
|
1860
|
+
process.exit(0);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
try {
|
|
1864
|
+
switch (action) {
|
|
1865
|
+
case 'activate_claude':
|
|
1866
|
+
await presetClaude(paths, {});
|
|
1867
|
+
break;
|
|
1868
|
+
case 'activate_codex':
|
|
1869
|
+
await presetCodex(paths, {});
|
|
1870
|
+
break;
|
|
1871
|
+
case 'test_connection':
|
|
1872
|
+
await testConnection(paths, {});
|
|
1873
|
+
break;
|
|
1874
|
+
case 'view_config':
|
|
1875
|
+
await viewConfig(paths);
|
|
1876
|
+
break;
|
|
1877
|
+
case 'restore':
|
|
1878
|
+
await restore(paths);
|
|
1879
|
+
break;
|
|
1880
|
+
}
|
|
1881
|
+
} catch (error) {
|
|
1882
|
+
console.log(chalk.red(`\n错误: ${error.message}\n`));
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// 操作完成后暂停,让用户看到结果
|
|
1886
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1887
|
+
console.log('');
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// 获取当前配置状态摘要
|
|
1892
|
+
function getConfigStatusLine(paths) {
|
|
1893
|
+
try {
|
|
1894
|
+
const config = readConfig(paths.openclawConfig);
|
|
1895
|
+
if (!config?.models?.providers) return null;
|
|
1896
|
+
|
|
1897
|
+
const providers = Object.keys(config.models.providers);
|
|
1898
|
+
const primary = config?.agents?.defaults?.model?.primary || '';
|
|
1899
|
+
|
|
1900
|
+
const parts = [];
|
|
1901
|
+
|
|
1902
|
+
// 检查 Claude
|
|
1903
|
+
const hasClaude = providers.some(p => p.includes('claude') || p.includes('yunyi-claude'));
|
|
1904
|
+
if (hasClaude) {
|
|
1905
|
+
const isActive = primary.includes('claude');
|
|
1906
|
+
parts.push(isActive ? chalk.green('Claude ✓') : chalk.yellow('Claude ○'));
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// 检查 Codex/GPT
|
|
1910
|
+
const hasCodex = providers.some(p => p.includes('codex') || p.includes('gpt') || p.includes('yunyi-codex'));
|
|
1911
|
+
if (hasCodex) {
|
|
1912
|
+
const isActive = primary.includes('codex') || primary.includes('gpt');
|
|
1913
|
+
parts.push(isActive ? chalk.green('Codex ✓') : chalk.yellow('Codex ○'));
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (parts.length === 0) {
|
|
1917
|
+
return chalk.gray('当前状态: 未配置任何模型');
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
return chalk.gray('当前状态: ') + parts.join(' ') + chalk.gray(' (✓ 主模型 ○ 已配置)');
|
|
1921
|
+
} catch {
|
|
1922
|
+
return null;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// ============ 选择节点 (Claude/Codex) ============
|
|
1927
|
+
async function selectNode(paths, type) {
|
|
1928
|
+
const typeLabel = type === 'claude' ? 'Claude' : 'Codex';
|
|
1929
|
+
const models = type === 'claude' ? CLAUDE_MODELS : CODEX_MODELS;
|
|
1930
|
+
const apiConfig = API_CONFIG[type];
|
|
1931
|
+
|
|
1932
|
+
console.log(chalk.cyan(`📡 ${typeLabel} 节点测速中...\n`));
|
|
1933
|
+
|
|
1934
|
+
const speedResult = await testAllEndpoints(ENDPOINTS, { autoFallback: true });
|
|
1935
|
+
|
|
1936
|
+
const sorted = speedResult.ranked || [];
|
|
1937
|
+
|
|
1938
|
+
if (sorted.length === 0) {
|
|
1939
|
+
console.log(chalk.red('\n所有节点都无法访问!'));
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
console.log('');
|
|
1944
|
+
|
|
1945
|
+
// 选择节点
|
|
1946
|
+
const { selectedIndex } = await inquirer.prompt([{
|
|
1947
|
+
type: 'list',
|
|
1948
|
+
name: 'selectedIndex',
|
|
1949
|
+
message: '选择节点:',
|
|
1950
|
+
choices: [
|
|
1951
|
+
{ name: `🚀 使用推荐节点 (${sorted[0].name}, 评分:${sorted[0].score})`, value: -1 },
|
|
1952
|
+
new inquirer.Separator('--- 或手动选择 ---'),
|
|
1953
|
+
...sorted.map((e, i) => ({
|
|
1954
|
+
name: `${e.name} - ${e.latency}ms (评分:${e.score})`,
|
|
1955
|
+
value: i
|
|
1956
|
+
}))
|
|
1957
|
+
]
|
|
1958
|
+
}]);
|
|
1959
|
+
|
|
1960
|
+
const primaryIndex = selectedIndex === -1 ? 0 : selectedIndex;
|
|
1961
|
+
const selectedEndpoint = sorted[primaryIndex];
|
|
1962
|
+
|
|
1963
|
+
// 选择模型
|
|
1964
|
+
const { selectedModel } = await inquirer.prompt([{
|
|
1965
|
+
type: 'list',
|
|
1966
|
+
name: 'selectedModel',
|
|
1967
|
+
message: `选择 ${typeLabel} 模型:`,
|
|
1968
|
+
choices: models.map(m => ({ name: m.name, value: m.id }))
|
|
1969
|
+
}]);
|
|
1970
|
+
|
|
1971
|
+
const modelConfig = models.find(m => m.id === selectedModel);
|
|
1972
|
+
|
|
1973
|
+
// 读取或创建配置
|
|
1974
|
+
let config = readConfig(paths.openclawConfig) || {};
|
|
1975
|
+
|
|
1976
|
+
// 初始化结构
|
|
1977
|
+
if (!config.models) config.models = {};
|
|
1978
|
+
if (!config.models.providers) config.models.providers = {};
|
|
1979
|
+
if (!config.agents) config.agents = {};
|
|
1980
|
+
if (!config.agents.defaults) config.agents.defaults = {};
|
|
1981
|
+
if (!config.agents.defaults.model) config.agents.defaults.model = {};
|
|
1982
|
+
if (!config.agents.defaults.models) config.agents.defaults.models = {};
|
|
1983
|
+
|
|
1984
|
+
// 保留旧的 API Key
|
|
1985
|
+
const oldProvider = config.models.providers[apiConfig.providerName];
|
|
1986
|
+
const oldApiKey = oldProvider?.apiKey;
|
|
1987
|
+
|
|
1988
|
+
// 添加/更新节点
|
|
1989
|
+
config.models.providers[apiConfig.providerName] = {
|
|
1990
|
+
baseUrl: buildFullUrl(selectedEndpoint.url, type),
|
|
1991
|
+
api: apiConfig.api,
|
|
1992
|
+
apiKey: oldApiKey,
|
|
1993
|
+
models: [{
|
|
1994
|
+
id: modelConfig.id,
|
|
1995
|
+
name: modelConfig.name,
|
|
1996
|
+
contextWindow: apiConfig.contextWindow,
|
|
1997
|
+
maxTokens: apiConfig.maxTokens
|
|
1998
|
+
}]
|
|
1999
|
+
};
|
|
2000
|
+
|
|
2001
|
+
// 注册模型
|
|
2002
|
+
const modelKey = `${apiConfig.providerName}/${modelConfig.id}`;
|
|
2003
|
+
config.agents.defaults.models[modelKey] = { alias: apiConfig.providerName };
|
|
2004
|
+
|
|
2005
|
+
writeConfigWithSync(paths, config);
|
|
2006
|
+
|
|
2007
|
+
console.log(chalk.green(`\n✅ ${typeLabel} 节点配置完成!`));
|
|
2008
|
+
console.log(chalk.cyan(` 节点: ${selectedEndpoint.name} (${selectedEndpoint.url})`));
|
|
2009
|
+
console.log(chalk.gray(` 模型: ${modelConfig.name}`));
|
|
2010
|
+
console.log(chalk.gray(` API Key: ${oldApiKey ? '已设置' : '未设置'}`));
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// ============ 激活 (Claude/Codex) ============
|
|
2014
|
+
async function activate(paths, type) {
|
|
2015
|
+
const typeLabel = type === 'claude' ? 'Claude' : 'Codex';
|
|
2016
|
+
const apiConfig = API_CONFIG[type];
|
|
2017
|
+
const models = type === 'claude' ? CLAUDE_MODELS : CODEX_MODELS;
|
|
2018
|
+
|
|
2019
|
+
let config = readConfig(paths.openclawConfig);
|
|
2020
|
+
|
|
2021
|
+
if (!config?.models?.providers?.[apiConfig.providerName]) {
|
|
2022
|
+
console.log(chalk.yellow(`⚠️ 请先选择 ${typeLabel} 节点`));
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
const provider = config.models.providers[apiConfig.providerName];
|
|
2027
|
+
const currentModelId = provider.models?.[0]?.id || models[0].id;
|
|
2028
|
+
const modelConfig = models.find(m => m.id === currentModelId) || models[0];
|
|
2029
|
+
|
|
2030
|
+
// 输入 API Key
|
|
2031
|
+
const currentKey = provider.apiKey;
|
|
2032
|
+
const apiKey = await promptApiKey(`请输入 ${typeLabel} API Key:`, currentKey);
|
|
2033
|
+
|
|
2034
|
+
// 保存 API Key
|
|
2035
|
+
config.models.providers[apiConfig.providerName].apiKey = apiKey;
|
|
2036
|
+
|
|
2037
|
+
// 设置为主模型
|
|
2038
|
+
const modelKey = `${apiConfig.providerName}/${modelConfig.id}`;
|
|
2039
|
+
config.agents.defaults.model.primary = modelKey;
|
|
2040
|
+
config.agents.defaults.model.fallbacks = [];
|
|
2041
|
+
|
|
2042
|
+
writeConfigWithSync(paths, config);
|
|
2043
|
+
|
|
2044
|
+
// 同时写入 auth-profiles (versioned format)
|
|
2045
|
+
const authDir = path.dirname(paths.authProfiles);
|
|
2046
|
+
if (!fs.existsSync(authDir)) {
|
|
2047
|
+
fs.mkdirSync(authDir, { recursive: true });
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
const authStore = readAuthStore(paths.authProfiles);
|
|
2051
|
+
authStore.profiles[`${apiConfig.providerName}:default`] = { type: 'api_key', key: apiKey.trim(), provider: apiConfig.providerName };
|
|
2052
|
+
writeAuthStore(paths.authProfiles, authStore);
|
|
2053
|
+
|
|
2054
|
+
console.log(chalk.green(`\n✅ 已激活 ${typeLabel}`));
|
|
2055
|
+
console.log(chalk.cyan(` 节点: ${provider.baseUrl}`));
|
|
2056
|
+
console.log(chalk.gray(` 模型: ${modelConfig.name}`));
|
|
2057
|
+
console.log(chalk.gray(` API Key: 已设置`));
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// ============ 测试连接 ============
|
|
2061
|
+
async function testConnection(paths, args = {}) {
|
|
2062
|
+
console.log(chalk.cyan('🧪 测试 OpenClaw Gateway 连接\n'));
|
|
2063
|
+
|
|
2064
|
+
const config = readConfig(paths.openclawConfig);
|
|
2065
|
+
|
|
2066
|
+
if (!config) {
|
|
2067
|
+
console.log(chalk.yellow('配置文件不存在,请先选择节点'));
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// 检查当前激活的是哪个
|
|
2072
|
+
const primary = config.agents?.defaults?.model?.primary || '';
|
|
2073
|
+
if (!primary.includes('/')) {
|
|
2074
|
+
console.log(chalk.yellow('⚠️ 请先设置主模型'));
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
const providerName = primary.split('/')[0];
|
|
2079
|
+
const provider = config.models?.providers?.[providerName];
|
|
2080
|
+
if (!provider) {
|
|
2081
|
+
console.log(chalk.yellow(`⚠️ 主模型对应的中转站不存在: ${providerName}`));
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
const apiType = provider.api || '';
|
|
2086
|
+
const typeLabel = apiType.startsWith('anthropic')
|
|
2087
|
+
? 'Claude'
|
|
2088
|
+
: (apiType.startsWith('openai') ? 'Codex' : '模型');
|
|
2089
|
+
|
|
2090
|
+
if (!provider.apiKey) {
|
|
2091
|
+
console.log(chalk.yellow(`⚠️ ${typeLabel} API Key 未设置`));
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// 获取 Gateway 配置
|
|
2096
|
+
const gatewayPort = config.gateway?.port || 18789;
|
|
2097
|
+
|
|
2098
|
+
console.log(chalk.gray(`当前激活: ${typeLabel}`));
|
|
2099
|
+
console.log(chalk.gray(`中转节点: ${provider.baseUrl}`));
|
|
2100
|
+
console.log(chalk.gray(`模型: ${primary}`));
|
|
2101
|
+
console.log(chalk.gray(`Gateway: http://localhost:${gatewayPort}\n`));
|
|
2102
|
+
|
|
2103
|
+
// 获取 Gateway token
|
|
2104
|
+
const gatewayToken = config.gateway?.auth?.token;
|
|
2105
|
+
if (!gatewayToken) {
|
|
2106
|
+
console.log(chalk.yellow('⚠️ Gateway token 未配置'));
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
const allowAutoDaemon = !(args['no-daemon'] || args.noDaemon);
|
|
2111
|
+
|
|
2112
|
+
// 步骤1: 先重启 Gateway 使配置生效
|
|
2113
|
+
console.log(chalk.cyan('步骤 1/2: 重启 Gateway 使配置生效...'));
|
|
2114
|
+
await restartGateway();
|
|
2115
|
+
|
|
2116
|
+
// 等待 Gateway 启动
|
|
2117
|
+
const gwSpinner = ora({ text: '等待 Gateway 启动...', spinner: 'dots' }).start();
|
|
2118
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
2119
|
+
|
|
2120
|
+
let gatewayRunning = await waitForGateway(gatewayPort);
|
|
2121
|
+
if (!gatewayRunning) {
|
|
2122
|
+
gwSpinner.text = '尝试自动启动 Gateway...';
|
|
2123
|
+
const autoResult = await tryAutoStartGateway(gatewayPort, allowAutoDaemon);
|
|
2124
|
+
gatewayRunning = autoResult.started;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
if (!gatewayRunning) {
|
|
2128
|
+
gwSpinner.fail('Gateway 未运行');
|
|
2129
|
+
console.log(chalk.gray(' 请在新的终端执行: openclaw gateway'));
|
|
2130
|
+
console.log(chalk.gray(' 或: clawdbot gateway'));
|
|
2131
|
+
console.log(chalk.gray(' 或: moltbot gateway'));
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
gwSpinner.succeed('Gateway 已启动');
|
|
2136
|
+
|
|
2137
|
+
// 步骤2: 通过 Gateway 端点测试(优先使用 CLI agent)
|
|
2138
|
+
console.log(chalk.cyan(`\n步骤 2/2: 测试 Gateway API 端点...`));
|
|
2139
|
+
|
|
2140
|
+
try {
|
|
2141
|
+
const cliResult = await testGatewayViaAgent(primary);
|
|
2142
|
+
let cliPassed = false;
|
|
2143
|
+
|
|
2144
|
+
if (cliResult.usedCli) {
|
|
2145
|
+
if (cliResult.success) {
|
|
2146
|
+
// 检查模型回复是否实际上是错误信息
|
|
2147
|
+
const replyText = (cliResult.message || '').toLowerCase().trim();
|
|
2148
|
+
const isErrorReply = /^(connection error|error|timeout|refused|econnrefused|econnreset|fetch failed)\.?$/i.test(replyText)
|
|
2149
|
+
|| (replyText.length < 50 && /\b(connection error|connect(ion)? (refused|reset|timeout))\b/i.test(replyText));
|
|
2150
|
+
|
|
2151
|
+
if (isErrorReply) {
|
|
2152
|
+
console.log(chalk.yellow(`\n⚠️ CLI 返回了错误信息而非模型回复`));
|
|
2153
|
+
console.log(chalk.red(` 回复内容: ${cliResult.message}`));
|
|
2154
|
+
console.log(chalk.gray(` 这通常表示中转节点连接失败,请检查节点配置和网络`));
|
|
2155
|
+
} else {
|
|
2156
|
+
cliPassed = true;
|
|
2157
|
+
console.log(chalk.green(`\n✅ CLI 对话测试成功`));
|
|
2158
|
+
if (cliResult.provider && cliResult.model) {
|
|
2159
|
+
console.log(chalk.cyan(` Provider: ${cliResult.provider}`));
|
|
2160
|
+
console.log(chalk.cyan(` Model: ${cliResult.model}`));
|
|
2161
|
+
}
|
|
2162
|
+
if (cliResult.message) {
|
|
2163
|
+
const reply = sanitizeModelReply(cliResult.message, {
|
|
2164
|
+
provider: cliResult.provider,
|
|
2165
|
+
model: cliResult.model,
|
|
2166
|
+
modelKey: primary
|
|
2167
|
+
});
|
|
2168
|
+
console.log(chalk.yellow(` 模型回复: ${reply}`));
|
|
2169
|
+
}
|
|
2170
|
+
console.log(chalk.gray(' 将继续验证 Web 鉴权端点(避免"CLI 正常但网页 401")...'));
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
if (!cliResult.success) {
|
|
2175
|
+
console.log(chalk.red(`\n❌ Gateway CLI 测试失败`));
|
|
2176
|
+
console.log(chalk.red(` 错误: ${cliResult.error || '未知错误'}`));
|
|
2177
|
+
console.log(chalk.gray(` 将尝试使用 HTTP 端点测试...`));
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
console.log(chalk.gray(` 端点: http://localhost:${gatewayPort}/v1/responses`));
|
|
2182
|
+
const startTime = Date.now();
|
|
2183
|
+
let result = await testGatewayApi(gatewayPort, gatewayToken, primary);
|
|
2184
|
+
const latency = Date.now() - startTime;
|
|
2185
|
+
|
|
2186
|
+
// /v1/responses 返回 405 时,回退测试 /v1/chat/completions
|
|
2187
|
+
if (!result.success && result.reachable && result.status === 405) {
|
|
2188
|
+
console.log(chalk.gray(' /v1/responses 不支持,回退测试 /v1/chat/completions...'));
|
|
2189
|
+
result = await testGatewayApi(gatewayPort, gatewayToken, primary, '/v1/chat/completions');
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// CLI 对话已成功 + Gateway 可达(405)= Dashboard 通过 WebSocket 工作,视为通过
|
|
2193
|
+
if (!result.success && cliPassed && result.reachable && result.status === 405) {
|
|
2194
|
+
console.log(chalk.green(`\n✅ Gateway 测试通过`));
|
|
2195
|
+
console.log(chalk.cyan(` CLI 对话正常,Gateway 可达`));
|
|
2196
|
+
console.log(chalk.gray(` 注: Gateway Dashboard 通过 WebSocket 通信,REST 端点返回 405 属正常现象`));
|
|
2197
|
+
console.log(chalk.green(`\n🌐 Web Dashboard 访问地址:`));
|
|
2198
|
+
console.log(chalk.cyan(` http://127.0.0.1:${gatewayPort}/?token=${gatewayToken}`));
|
|
2199
|
+
} else if (result.success) {
|
|
2200
|
+
console.log(chalk.green(`\n✅ Gateway 测试成功!Web Dashboard 可正常使用`));
|
|
2201
|
+
console.log(chalk.cyan(` 响应时间: ${latency}ms`));
|
|
2202
|
+
const reply = sanitizeModelReply(result.message, {
|
|
2203
|
+
provider: providerName,
|
|
2204
|
+
model: primary.includes('/') ? primary.split('/')[1] : '',
|
|
2205
|
+
modelKey: primary
|
|
2206
|
+
});
|
|
2207
|
+
console.log(chalk.yellow(` 模型回复: ${reply}`));
|
|
2208
|
+
console.log(chalk.green(`\n🌐 Web Dashboard 访问地址:`));
|
|
2209
|
+
console.log(chalk.cyan(` http://127.0.0.1:${gatewayPort}/?token=${gatewayToken}`));
|
|
2210
|
+
} else {
|
|
2211
|
+
if (result.reachable) {
|
|
2212
|
+
console.log(chalk.yellow(`\n⚠ Gateway HTTP 可达,但接口返回限制`));
|
|
2213
|
+
} else {
|
|
2214
|
+
console.log(chalk.red(`\n❌ Gateway API 连接失败`));
|
|
2215
|
+
}
|
|
2216
|
+
console.log(chalk.red(` 错误: ${result.error}`));
|
|
2217
|
+
|
|
2218
|
+
if (cliPassed && /\b401\b/.test(String(result.error || ''))) {
|
|
2219
|
+
console.log(chalk.yellow(`\n⚠️ 已定位:CLI 可对话,但 Web 鉴权失败(401)`));
|
|
2220
|
+
console.log(chalk.yellow(` 这通常会导致 Dashboard 网页对话返回 401。`));
|
|
2221
|
+
console.log(chalk.gray(`\n 建议操作:`));
|
|
2222
|
+
console.log(chalk.gray(` 1) 复制最新地址并重新打开浏览器(不要用旧书签)`));
|
|
2223
|
+
console.log(chalk.cyan(` http://127.0.0.1:${gatewayPort}/?token=${gatewayToken}`));
|
|
2224
|
+
console.log(chalk.gray(` 2) 执行 Gateway 重启:openclaw gateway restart / clawdbot gateway restart`));
|
|
2225
|
+
console.log(chalk.gray(` 3) 若仍 401,检查是否存在多个配置目录(.openclaw 与 .clawdbot)`));
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
if (cliPassed && /\b405\b/.test(String(result.error || ''))) {
|
|
2229
|
+
console.log(chalk.yellow(`\n⚠️ 已定位:Gateway 可达(HTTP 405),Web API 端点不支持当前请求方法`));
|
|
2230
|
+
console.log(chalk.gray(` CLI 对话正常,Web Dashboard 应可正常使用。`));
|
|
2231
|
+
console.log(chalk.gray(` 如遇问题,尝试更新 Gateway: npm install -g openclaw@latest && openclaw gateway restart`));
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
console.log(chalk.gray(`\n 提示: 如果 Gateway 未运行,请执行: openclaw gateway / clawdbot gateway / moltbot gateway`));
|
|
2235
|
+
}
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
console.log(chalk.red(`❌ 测试失败: ${error.message}`));
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
// ============ 重启 Gateway ============
|
|
2242
|
+
async function restartGateway() {
|
|
2243
|
+
console.log(chalk.cyan('\n🔄 正在重启 OpenClaw Gateway...'));
|
|
2244
|
+
|
|
2245
|
+
const { cliBinary: resolved, nodeMajor } = getCliMeta();
|
|
2246
|
+
const nodeInfo = findCompatibleNode(nodeMajor);
|
|
2247
|
+
const env = { ...process.env, PATH: extendPathEnv(nodeInfo ? nodeInfo.path : null) };
|
|
2248
|
+
const useNode = resolved && nodeInfo && isNodeShebang(resolved);
|
|
2249
|
+
|
|
2250
|
+
// 尝试多种命令
|
|
2251
|
+
const commands = resolved
|
|
2252
|
+
? [
|
|
2253
|
+
useNode ? `"${nodeInfo.path}" "${resolved}" gateway restart` : `"${resolved}" gateway restart`
|
|
2254
|
+
]
|
|
2255
|
+
: [
|
|
2256
|
+
'openclaw gateway restart',
|
|
2257
|
+
'clawdbot gateway restart',
|
|
2258
|
+
'moltbot gateway restart',
|
|
2259
|
+
'npx openclaw gateway restart',
|
|
2260
|
+
'npx clawdbot gateway restart',
|
|
2261
|
+
'npx moltbot gateway restart'
|
|
2262
|
+
];
|
|
2263
|
+
|
|
2264
|
+
return new Promise((resolve) => {
|
|
2265
|
+
let tried = 0;
|
|
2266
|
+
|
|
2267
|
+
const tryNext = () => {
|
|
2268
|
+
if (tried >= commands.length) {
|
|
2269
|
+
console.log(chalk.red(`❌ 重启失败: 找不到 openclaw/clawdbot/moltbot 命令`));
|
|
2270
|
+
console.log(chalk.gray(` 请手动运行: openclaw gateway restart`));
|
|
2271
|
+
console.log(chalk.gray(` 或: clawdbot gateway restart`));
|
|
2272
|
+
console.log(chalk.gray(` 或: moltbot gateway restart`));
|
|
2273
|
+
// 诊断信息
|
|
2274
|
+
console.log(chalk.gray(`\n [诊断] resolveCliBinary = ${resolved || 'null'}`));
|
|
2275
|
+
const npmPrefix = safeExec('npm prefix -g');
|
|
2276
|
+
if (npmPrefix.ok) console.log(chalk.gray(` [诊断] npm prefix -g = ${npmPrefix.output}`));
|
|
2277
|
+
for (const name of ['openclaw', 'clawdbot', 'moltbot']) {
|
|
2278
|
+
const which = safeExec(process.platform === 'win32' ? `where ${name} 2>nul` : `/bin/zsh -lc "command -v ${name}" 2>/dev/null || /bin/bash -lc "command -v ${name}" 2>/dev/null`);
|
|
2279
|
+
if (which.ok && which.output) console.log(chalk.gray(` [诊断] ${name} -> ${which.output.split('\n')[0].trim()}`));
|
|
2280
|
+
}
|
|
2281
|
+
console.log(chalk.gray(` [诊断] 等待 Gateway 启动...`));
|
|
2282
|
+
resolve();
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
const cmd = commands[tried];
|
|
2287
|
+
tried++;
|
|
2288
|
+
|
|
2289
|
+
exec(cmd, { timeout: 30000, env }, (error) => {
|
|
2290
|
+
if (error) {
|
|
2291
|
+
tryNext();
|
|
2292
|
+
} else {
|
|
2293
|
+
console.log(chalk.green(`✅ Gateway 已重启`));
|
|
2294
|
+
console.log(chalk.gray(` 现在可以在 Web/Telegram/Discord 等渠道测试对话了`));
|
|
2295
|
+
resolve();
|
|
2296
|
+
}
|
|
2297
|
+
});
|
|
2298
|
+
};
|
|
2299
|
+
|
|
2300
|
+
tryNext();
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// Gateway API 测试 - 通过本地 Gateway 端口测试
|
|
2305
|
+
function testGatewayApi(port, token, model, endpoint = '/v1/responses') {
|
|
2306
|
+
return new Promise((resolve) => {
|
|
2307
|
+
const isChatCompletions = endpoint.includes('chat/completions');
|
|
2308
|
+
const postData = isChatCompletions
|
|
2309
|
+
? JSON.stringify({
|
|
2310
|
+
model: model,
|
|
2311
|
+
messages: [{ role: 'user', content: '你是什么模型?' }]
|
|
2312
|
+
})
|
|
2313
|
+
: JSON.stringify({
|
|
2314
|
+
model: model,
|
|
2315
|
+
input: '你是什么模型?'
|
|
2316
|
+
});
|
|
2317
|
+
|
|
2318
|
+
const options = {
|
|
2319
|
+
hostname: '127.0.0.1',
|
|
2320
|
+
port: port,
|
|
2321
|
+
path: endpoint,
|
|
2322
|
+
method: 'POST',
|
|
2323
|
+
timeout: 60000,
|
|
2324
|
+
headers: {
|
|
2325
|
+
'Content-Type': 'application/json',
|
|
2326
|
+
'Authorization': `Bearer ${token}`,
|
|
2327
|
+
'Content-Length': Buffer.byteLength(postData)
|
|
2328
|
+
}
|
|
2329
|
+
};
|
|
2330
|
+
|
|
2331
|
+
const req = http.request(options, (res) => {
|
|
2332
|
+
let data = '';
|
|
2333
|
+
res.on('data', chunk => data += chunk);
|
|
2334
|
+
res.on('end', () => {
|
|
2335
|
+
const status = Number(res.statusCode || 0);
|
|
2336
|
+
const gatewayReachable = status === 401 || status === 403 || status === 404 || status === 405;
|
|
2337
|
+
|
|
2338
|
+
if (gatewayReachable) {
|
|
2339
|
+
resolve({
|
|
2340
|
+
success: false,
|
|
2341
|
+
reachable: true,
|
|
2342
|
+
status,
|
|
2343
|
+
error: `HTTP ${status}: ${data.substring(0, 300)}`
|
|
2344
|
+
});
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
try {
|
|
2349
|
+
const json = JSON.parse(data);
|
|
2350
|
+
// OpenResponses 格式响应
|
|
2351
|
+
const outputText = json.output?.[0]?.content?.[0]?.text;
|
|
2352
|
+
// Chat Completions 格式响应
|
|
2353
|
+
const chatText = json.choices?.[0]?.message?.content;
|
|
2354
|
+
const message = outputText || chatText;
|
|
2355
|
+
if (message) {
|
|
2356
|
+
resolve({ success: true, message });
|
|
2357
|
+
} else if (json.error) {
|
|
2358
|
+
resolve({ success: false, error: json.error.message || JSON.stringify(json.error) });
|
|
2359
|
+
} else {
|
|
2360
|
+
resolve({ success: false, error: `HTTP ${res.statusCode}: ${data.substring(0, 300)}` });
|
|
2361
|
+
}
|
|
2362
|
+
} catch {
|
|
2363
|
+
resolve({ success: false, error: `HTTP ${res.statusCode}: ${data.substring(0, 300)}` });
|
|
2364
|
+
}
|
|
2365
|
+
});
|
|
2366
|
+
});
|
|
2367
|
+
|
|
2368
|
+
req.on('timeout', () => {
|
|
2369
|
+
req.destroy();
|
|
2370
|
+
resolve({ success: false, error: '请求超时 (60s)' });
|
|
2371
|
+
});
|
|
2372
|
+
|
|
2373
|
+
req.on('error', (e) => {
|
|
2374
|
+
if (e.code === 'ECONNREFUSED') {
|
|
2375
|
+
resolve({ success: false, error: 'Gateway 未运行,请先启动: openclaw gateway / clawdbot gateway / moltbot gateway' });
|
|
2376
|
+
} else {
|
|
2377
|
+
resolve({ success: false, error: e.message });
|
|
2378
|
+
}
|
|
2379
|
+
});
|
|
2380
|
+
|
|
2381
|
+
req.write(postData);
|
|
2382
|
+
req.end();
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function testGatewayViaAgent(model) {
|
|
2387
|
+
return new Promise((resolve) => {
|
|
2388
|
+
const { cliBinary, nodeMajor } = getCliMeta();
|
|
2389
|
+
if (!cliBinary) {
|
|
2390
|
+
resolve({ success: false, usedCli: false, error: '未找到 openclaw/clawdbot/moltbot 命令' });
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
const nodeInfo = findCompatibleNode(nodeMajor);
|
|
2395
|
+
// 清除可能覆盖配置文件 apiKey 的环境变量,避免 Gateway 使用错误的 key
|
|
2396
|
+
const env = { ...process.env, PATH: extendPathEnv(nodeInfo ? nodeInfo.path : null), NODE_NO_WARNINGS: '1' };
|
|
2397
|
+
delete env.CLAUDE_API_KEY;
|
|
2398
|
+
delete env.OPENCLAW_CLAUDE_KEY;
|
|
2399
|
+
delete env.OPENCLAW_API_KEY;
|
|
2400
|
+
delete env.OPENAI_API_KEY;
|
|
2401
|
+
delete env.OPENCLAW_CODEX_KEY;
|
|
2402
|
+
const useNode = nodeInfo && isNodeShebang(cliBinary);
|
|
2403
|
+
const cmd = useNode
|
|
2404
|
+
? `"${nodeInfo.path}" "${cliBinary}" agent --session-id yymaxapi-test --message "请回复你的模型名称" --json --timeout 120`
|
|
2405
|
+
: `"${cliBinary}" agent --session-id yymaxapi-test --message "请回复你的模型名称" --json --timeout 120`;
|
|
2406
|
+
|
|
2407
|
+
exec(cmd, { timeout: 120000, env }, (error, stdout, stderr) => {
|
|
2408
|
+
// 过滤 stderr 中的 Node.js DeprecationWarning 噪音
|
|
2409
|
+
const cleanStderr = (stderr || '').replace(/\(node:\d+\) \[DEP\d+\] DeprecationWarning:.*(\n.*trace-deprecation.*)?/g, '').trim();
|
|
2410
|
+
|
|
2411
|
+
if (error) {
|
|
2412
|
+
// 即使 exec 报错,stdout 中可能仍有有效 JSON(如 CLI 输出了结果但 exit code 非零)
|
|
2413
|
+
const fallbackOutput = (stdout || '').trim();
|
|
2414
|
+
const fbJsonStart = fallbackOutput.indexOf('{');
|
|
2415
|
+
const fbJsonEnd = fallbackOutput.lastIndexOf('}');
|
|
2416
|
+
if (fbJsonStart !== -1 && fbJsonEnd > fbJsonStart) {
|
|
2417
|
+
// stdout 有 JSON,走正常解析流程而非直接报错
|
|
2418
|
+
stdout = fallbackOutput;
|
|
2419
|
+
} else {
|
|
2420
|
+
resolve({
|
|
2421
|
+
success: false,
|
|
2422
|
+
usedCli: true,
|
|
2423
|
+
error: (cleanStderr || fallbackOutput || error.message || 'CLI 执行失败').trim()
|
|
2424
|
+
});
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
const output = (stdout || '').trim();
|
|
2430
|
+
const jsonStart = output.indexOf('{');
|
|
2431
|
+
const jsonEnd = output.lastIndexOf('}');
|
|
2432
|
+
if (jsonStart === -1 || jsonEnd === -1 || jsonEnd <= jsonStart) {
|
|
2433
|
+
resolve({
|
|
2434
|
+
success: false,
|
|
2435
|
+
usedCli: true,
|
|
2436
|
+
error: (stdout || stderr || 'CLI 输出无法解析').trim()
|
|
2437
|
+
});
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
try {
|
|
2442
|
+
const parsed = JSON.parse(output.slice(jsonStart, jsonEnd + 1));
|
|
2443
|
+
// Support both top-level format (current) and legacy .result wrapper
|
|
2444
|
+
const envelope = parsed?.result ?? parsed;
|
|
2445
|
+
const message = envelope?.payloads?.[0]?.text || '';
|
|
2446
|
+
const provider = envelope?.meta?.agentMeta?.provider || '';
|
|
2447
|
+
const modelId = envelope?.meta?.agentMeta?.model || '';
|
|
2448
|
+
|
|
2449
|
+
if (!provider || !modelId) {
|
|
2450
|
+
resolve({
|
|
2451
|
+
success: false,
|
|
2452
|
+
usedCli: true,
|
|
2453
|
+
error: message || 'CLI 返回缺少 provider/model'
|
|
2454
|
+
});
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// 检查回复内容是否实际上是错误信息
|
|
2459
|
+
const msgLower = (message || '').toLowerCase();
|
|
2460
|
+
const isErrorResponse = (
|
|
2461
|
+
(msgLower.includes('401') && (msgLower.includes('invalid api key') || msgLower.includes('unauthorized'))) ||
|
|
2462
|
+
(msgLower.includes('403') && (msgLower.includes('forbidden') || msgLower.includes('access denied'))) ||
|
|
2463
|
+
/^http\s+\d{3}:/i.test(message.trim())
|
|
2464
|
+
);
|
|
2465
|
+
|
|
2466
|
+
if (isErrorResponse) {
|
|
2467
|
+
resolve({
|
|
2468
|
+
success: false,
|
|
2469
|
+
usedCli: true,
|
|
2470
|
+
provider,
|
|
2471
|
+
model: modelId,
|
|
2472
|
+
error: message.substring(0, 200)
|
|
2473
|
+
});
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
resolve({
|
|
2478
|
+
success: true,
|
|
2479
|
+
usedCli: true,
|
|
2480
|
+
provider,
|
|
2481
|
+
model: modelId,
|
|
2482
|
+
message
|
|
2483
|
+
});
|
|
2484
|
+
} catch (parseError) {
|
|
2485
|
+
resolve({
|
|
2486
|
+
success: false,
|
|
2487
|
+
usedCli: true,
|
|
2488
|
+
error: (stdout || stderr || String(parseError)).trim()
|
|
2489
|
+
});
|
|
2490
|
+
}
|
|
2491
|
+
});
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
function escapeRegExp(text) {
|
|
2496
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
function sanitizeModelReply(message, options = {}) {
|
|
2500
|
+
let text = String(message || '').trim();
|
|
2501
|
+
const tokens = [options.provider, options.model, options.modelKey]
|
|
2502
|
+
.filter(Boolean)
|
|
2503
|
+
.map(escapeRegExp);
|
|
2504
|
+
|
|
2505
|
+
const tailPattern = tokens.length
|
|
2506
|
+
? new RegExp(
|
|
2507
|
+
`[((][^))]*(?:${tokens.join('|')}|[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+)[^))]*[))]\\s*$`,
|
|
2508
|
+
'i'
|
|
2509
|
+
)
|
|
2510
|
+
: /[((][^))]*[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+[^))]*[))]\s*$/i;
|
|
2511
|
+
|
|
2512
|
+
text = text
|
|
2513
|
+
.replace(/(\s*通过[^)]*别名配置\s*)/g, '')
|
|
2514
|
+
.replace(/\(\s*通过[^)]*别名配置\s*\)/g, '')
|
|
2515
|
+
.replace(tailPattern, '')
|
|
2516
|
+
.trim();
|
|
2517
|
+
|
|
2518
|
+
return text;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
// Claude API 测试 (直接测试中转,备用)
|
|
2522
|
+
function testClaudeApi(baseUrl, apiKey, model) {
|
|
2523
|
+
return new Promise((resolve) => {
|
|
2524
|
+
const trimmed = trimClaudeMessagesSuffix(baseUrl);
|
|
2525
|
+
const normalized = trimmed.endsWith('/claude') ? `${trimmed}/v1/messages` : trimmed;
|
|
2526
|
+
const urlObj = new URL(normalized);
|
|
2527
|
+
const protocol = urlObj.protocol === 'https:' ? https : http;
|
|
2528
|
+
|
|
2529
|
+
const postData = JSON.stringify({
|
|
2530
|
+
model: model || 'claude-sonnet-4-5',
|
|
2531
|
+
max_tokens: 150,
|
|
2532
|
+
messages: [{ role: 'user', content: '你是哪个模型?请用一句话回答你的模型名称和版本。' }]
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
const options = {
|
|
2536
|
+
hostname: urlObj.hostname,
|
|
2537
|
+
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
2538
|
+
path: urlObj.pathname,
|
|
2539
|
+
method: 'POST',
|
|
2540
|
+
timeout: 30000,
|
|
2541
|
+
rejectUnauthorized: false,
|
|
2542
|
+
headers: {
|
|
2543
|
+
'Content-Type': 'application/json',
|
|
2544
|
+
'x-api-key': apiKey,
|
|
2545
|
+
'anthropic-version': '2023-06-01',
|
|
2546
|
+
'Content-Length': Buffer.byteLength(postData)
|
|
2547
|
+
}
|
|
2548
|
+
};
|
|
2549
|
+
|
|
2550
|
+
const req = protocol.request(options, (res) => {
|
|
2551
|
+
let data = '';
|
|
2552
|
+
res.on('data', chunk => data += chunk);
|
|
2553
|
+
res.on('end', () => {
|
|
2554
|
+
try {
|
|
2555
|
+
const json = JSON.parse(data);
|
|
2556
|
+
if (json.content && json.content[0]) {
|
|
2557
|
+
resolve({ success: true, message: json.content[0].text?.substring(0, 100) || 'OK' });
|
|
2558
|
+
} else if (json.error) {
|
|
2559
|
+
resolve({ success: false, error: json.error.message || JSON.stringify(json.error) });
|
|
2560
|
+
} else {
|
|
2561
|
+
resolve({ success: false, error: `HTTP ${res.statusCode}: ${data.substring(0, 200)}` });
|
|
2562
|
+
}
|
|
2563
|
+
} catch {
|
|
2564
|
+
resolve({ success: false, error: `HTTP ${res.statusCode}: ${data.substring(0, 200)}` });
|
|
2565
|
+
}
|
|
2566
|
+
});
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
req.on('timeout', () => {
|
|
2570
|
+
req.destroy();
|
|
2571
|
+
resolve({ success: false, error: '请求超时 (30s)' });
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2574
|
+
req.on('error', (e) => {
|
|
2575
|
+
resolve({ success: false, error: e.message });
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
req.write(postData);
|
|
2579
|
+
req.end();
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// Codex API 测试
|
|
2584
|
+
function testCodexApi(baseUrl, apiKey, model) {
|
|
2585
|
+
return new Promise((resolve) => {
|
|
2586
|
+
const urlObj = new URL(baseUrl);
|
|
2587
|
+
const protocol = urlObj.protocol === 'https:' ? https : http;
|
|
2588
|
+
|
|
2589
|
+
const postData = JSON.stringify({
|
|
2590
|
+
model: model || 'gpt-5.2',
|
|
2591
|
+
input: '你是哪个模型?请用一句话回答你的模型名称和版本。'
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
const options = {
|
|
2595
|
+
hostname: urlObj.hostname,
|
|
2596
|
+
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
2597
|
+
path: urlObj.pathname,
|
|
2598
|
+
method: 'POST',
|
|
2599
|
+
timeout: 30000,
|
|
2600
|
+
rejectUnauthorized: false,
|
|
2601
|
+
headers: {
|
|
2602
|
+
'Content-Type': 'application/json',
|
|
2603
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
2604
|
+
'Content-Length': Buffer.byteLength(postData)
|
|
2605
|
+
}
|
|
2606
|
+
};
|
|
2607
|
+
|
|
2608
|
+
const req = protocol.request(options, (res) => {
|
|
2609
|
+
let data = '';
|
|
2610
|
+
res.on('data', chunk => data += chunk);
|
|
2611
|
+
res.on('end', () => {
|
|
2612
|
+
try {
|
|
2613
|
+
const json = JSON.parse(data);
|
|
2614
|
+
if (json.output && json.output[0]) {
|
|
2615
|
+
const text = json.output[0].content?.[0]?.text || json.output[0].text || 'OK';
|
|
2616
|
+
resolve({ success: true, message: text.substring(0, 100) });
|
|
2617
|
+
} else if (json.error) {
|
|
2618
|
+
resolve({ success: false, error: json.error.message || JSON.stringify(json.error) });
|
|
2619
|
+
} else {
|
|
2620
|
+
resolve({ success: false, error: `HTTP ${res.statusCode}: ${data.substring(0, 200)}` });
|
|
2621
|
+
}
|
|
2622
|
+
} catch {
|
|
2623
|
+
resolve({ success: false, error: `HTTP ${res.statusCode}: ${data.substring(0, 200)}` });
|
|
2624
|
+
}
|
|
2625
|
+
});
|
|
2626
|
+
});
|
|
2627
|
+
|
|
2628
|
+
req.on('timeout', () => {
|
|
2629
|
+
req.destroy();
|
|
2630
|
+
resolve({ success: false, error: '请求超时 (30s)' });
|
|
2631
|
+
});
|
|
2632
|
+
|
|
2633
|
+
req.on('error', (e) => {
|
|
2634
|
+
resolve({ success: false, error: e.message });
|
|
2635
|
+
});
|
|
2636
|
+
|
|
2637
|
+
req.write(postData);
|
|
2638
|
+
req.end();
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
// ============ 查看配置 ============
|
|
2643
|
+
async function viewConfig(paths) {
|
|
2644
|
+
console.log(chalk.cyan('📋 当前配置\n'));
|
|
2645
|
+
|
|
2646
|
+
const config = readConfig(paths.openclawConfig);
|
|
2647
|
+
|
|
2648
|
+
if (!config) {
|
|
2649
|
+
console.log(chalk.yellow('配置文件不存在,请先选择节点'));
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// 当前激活
|
|
2654
|
+
const primary = config.agents?.defaults?.model?.primary || '未设置';
|
|
2655
|
+
const isClaudeActive = primary.startsWith('claude-yunyi') || primary.startsWith('yunyi-claude');
|
|
2656
|
+
const isCodexActive = primary.startsWith('yunyi/') || primary.startsWith('yunyi-codex') || primary.startsWith('codex-yunyi');
|
|
2657
|
+
|
|
2658
|
+
console.log(chalk.yellow('当前激活:'));
|
|
2659
|
+
if (isClaudeActive) {
|
|
2660
|
+
console.log(chalk.blue(` 🔵 Claude: ${primary}`));
|
|
2661
|
+
} else if (isCodexActive) {
|
|
2662
|
+
console.log(chalk.green(` 🟢 Codex: ${primary}`));
|
|
2663
|
+
} else {
|
|
2664
|
+
console.log(` ${primary}`);
|
|
2665
|
+
}
|
|
2666
|
+
console.log('');
|
|
2667
|
+
|
|
2668
|
+
// 中转站列表
|
|
2669
|
+
console.log(chalk.yellow('中转站配置:'));
|
|
2670
|
+
const providers = config.models?.providers || {};
|
|
2671
|
+
const providerEntries = Object.entries(providers);
|
|
2672
|
+
if (providerEntries.length === 0) {
|
|
2673
|
+
console.log(chalk.gray(' 未配置'));
|
|
2674
|
+
} else {
|
|
2675
|
+
for (const [name, provider] of providerEntries) {
|
|
2676
|
+
const hasKey = provider.apiKey ? chalk.green('✓') : chalk.red('✗');
|
|
2677
|
+
const model = provider.models?.[0]?.name || 'N/A';
|
|
2678
|
+
const isPrimary = primary.startsWith(`${name}/`);
|
|
2679
|
+
console.log(` ${isPrimary ? '⭐ ' : ''}${name}`);
|
|
2680
|
+
console.log(` URL: ${provider.baseUrl}`);
|
|
2681
|
+
console.log(` 模型: ${model}`);
|
|
2682
|
+
console.log(` API Key: ${hasKey}`);
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
console.log('');
|
|
2686
|
+
|
|
2687
|
+
// 备份状态
|
|
2688
|
+
const backupPath = path.join(paths.configDir, BACKUP_FILENAME);
|
|
2689
|
+
console.log(chalk.yellow('备份状态:'));
|
|
2690
|
+
console.log(` ${fs.existsSync(backupPath) ? chalk.green('✓ 已备份') : chalk.gray('未备份')}`);
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
// ============ 恢复默认配置 ============
|
|
2694
|
+
async function restore(paths) {
|
|
2695
|
+
const backupPath = path.join(paths.configDir, BACKUP_FILENAME);
|
|
2696
|
+
|
|
2697
|
+
if (!fs.existsSync(backupPath)) {
|
|
2698
|
+
console.log(chalk.yellow('⚠️ 没有找到备份文件'));
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
const { confirm } = await inquirer.prompt([{
|
|
2703
|
+
type: 'confirm',
|
|
2704
|
+
name: 'confirm',
|
|
2705
|
+
message: '确定要恢复默认配置吗?当前配置将被覆盖。',
|
|
2706
|
+
default: false
|
|
2707
|
+
}]);
|
|
2708
|
+
|
|
2709
|
+
if (!confirm) {
|
|
2710
|
+
console.log(chalk.gray('已取消'));
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
if (restoreDefaultConfig(paths.openclawConfig, paths.configDir)) {
|
|
2715
|
+
console.log(chalk.green('\n✅ 已恢复默认配置'));
|
|
2716
|
+
} else {
|
|
2717
|
+
console.log(chalk.red('\n❌ 恢复失败'));
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// 启动
|
|
2722
|
+
main().catch(error => {
|
|
2723
|
+
exitWithError('CONFIG_FAILED', error.message, [
|
|
2724
|
+
'检查网络连接',
|
|
2725
|
+
'确保 OpenClaw 已正确安装',
|
|
2726
|
+
'查看详细错误信息并重试',
|
|
2727
|
+
]);
|
|
2728
|
+
});
|