openclawapi 1.0.0 → 1.1.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.
Files changed (4) hide show
  1. package/README.md +120 -26
  2. package/cli.js +1241 -104
  3. package/lib/config-manager.js +405 -54
  4. package/package.json +3 -2
package/cli.js CHANGED
@@ -2,35 +2,247 @@
2
2
 
3
3
  const inquirer = require('inquirer');
4
4
  const chalk = require('chalk');
5
+ const fs = require('fs');
6
+ const JSON5 = require('json5');
5
7
  const path = require('path');
6
8
  const os = require('os');
7
- const { ConfigManager } = require('./lib/config-manager');
8
- const { displayMenu, displaySuccess, displayError, displayInfo } = require('./lib/ui');
9
+ const { ConfigManager, getDefaultWorkspace } = require('./lib/config-manager');
10
+ const { displayMenu, displaySuccess, displayError, displayInfo, displayWarning } = require('./lib/ui');
9
11
  const { testMultipleRelays, sortBySpeed, formatLatency } = require('./lib/speed-test');
10
12
 
13
+ const RELAY_ENDPOINT_PRESETS = {
14
+ 'yunyi-claude': [
15
+ { name: '国内节点', url: 'https://yunyi.skem.cn' },
16
+ { name: 'CF国外节点1', url: 'https://yunyi.cfd' },
17
+ { name: 'CF国外节点2', url: 'https://cdn1.yunyi.cfd' },
18
+ { name: 'CF国外节点3', url: 'https://cdn2.yunyi.cfd' },
19
+ { name: '备用节点1', url: 'http://47.99.42.193' },
20
+ { name: '备用节点2', url: 'http://47.97.100.10' }
21
+ ],
22
+ 'yunyi-codex': [
23
+ { name: '国内节点', url: 'https://yunyi.skem.cn' },
24
+ { name: 'CF国外节点1', url: 'https://yunyi.cfd' },
25
+ { name: 'CF国外节点2', url: 'https://cdn1.yunyi.cfd' },
26
+ { name: 'CF国外节点3', url: 'https://cdn2.yunyi.cfd' },
27
+ { name: '备用节点1', url: 'http://47.99.42.193' },
28
+ { name: '备用节点2', url: 'http://47.97.100.10' }
29
+ ]
30
+ };
31
+
32
+ const MODEL_PRESETS = {
33
+ 'yunyi-claude': [
34
+ { id: 'claude-opus-4-5', name: 'Claude Opus 4.5' },
35
+ { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },
36
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' }
37
+ ],
38
+ 'yunyi-codex': [
39
+ { id: 'gpt-5.2', name: 'GPT 5.2' },
40
+ { id: 'gpt-5.2-codex', name: 'GPT 5.2 Codex' }
41
+ ]
42
+ };
43
+
44
+ function normalizeRelayKey(relayName) {
45
+ if (!relayName) return '';
46
+ if (relayName.startsWith('yunyi-claude')) return 'yunyi-claude';
47
+ if (relayName.startsWith('yunyi-codex')) return 'yunyi-codex';
48
+ return relayName;
49
+ }
50
+
51
+ function getRelaySuffix(relayName) {
52
+ const key = normalizeRelayKey(relayName);
53
+ if (key === 'yunyi-claude') return '/claude/v1/messages';
54
+ if (key === 'yunyi-codex') return '/codex/response';
55
+ return '';
56
+ }
57
+
58
+ function buildRelayBaseUrl(baseUrl, relayName) {
59
+ const suffix = getRelaySuffix(relayName);
60
+ if (!suffix) return baseUrl;
61
+ if (!baseUrl) return baseUrl;
62
+ const trimmed = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
63
+
64
+ if (suffix === '/claude/v1/messages') {
65
+ if (/\/claude\/v1\/messages$/.test(trimmed) || /\/claude\/v1\/message$/.test(trimmed)) {
66
+ return trimmed;
67
+ }
68
+ if (/\/claude\/v1$/.test(trimmed)) {
69
+ return `${trimmed}/messages`;
70
+ }
71
+ if (/\/claude$/.test(trimmed)) {
72
+ return `${trimmed}/v1/messages`;
73
+ }
74
+ }
75
+
76
+ if (suffix === '/codex/response') {
77
+ if (/\/codex\/response$/.test(trimmed)) {
78
+ return trimmed;
79
+ }
80
+ if (/\/codex$/.test(trimmed)) {
81
+ return `${trimmed}/response`;
82
+ }
83
+ }
84
+
85
+ if (/\/claude(\/|$)/.test(trimmed) || /\/codex(\/|$)/.test(trimmed)) {
86
+ return trimmed;
87
+ }
88
+
89
+ return `${trimmed}${suffix}`;
90
+ }
91
+
92
+ function getRelayEndpoints(relayName) {
93
+ return RELAY_ENDPOINT_PRESETS[normalizeRelayKey(relayName)] || [];
94
+ }
95
+
96
+ function getRelayModelPresets(relayName) {
97
+ return MODEL_PRESETS[normalizeRelayKey(relayName)] || [];
98
+ }
99
+
100
+ function buildModelEntry(model, apiType) {
101
+ const defaults = apiType === 'openai-responses'
102
+ ? { contextWindow: 128000, maxTokens: 32768, reasoning: true, input: ['text', 'image'] }
103
+ : { contextWindow: 200000, maxTokens: 8192, reasoning: false, input: ['text'] };
104
+
105
+ return {
106
+ id: model.id,
107
+ name: model.name || model.id,
108
+ reasoning: defaults.reasoning,
109
+ input: defaults.input,
110
+ cost: {
111
+ input: 0,
112
+ output: 0,
113
+ cacheRead: 0,
114
+ cacheWrite: 0
115
+ },
116
+ contextWindow: defaults.contextWindow,
117
+ maxTokens: defaults.maxTokens
118
+ };
119
+ }
120
+
11
121
  // 获取配置文件路径(跨平台)
12
122
  function getConfigPath() {
13
123
  const homeDir = os.homedir();
14
- const configDir = path.join(homeDir, '.clawdbot');
124
+ const openclawStateDir = process.env.OPENCLAW_STATE_DIR || path.join(homeDir, '.openclaw');
125
+ const clawdbotStateDir = process.env.CLAWDBOT_STATE_DIR || path.join(homeDir, '.clawdbot');
126
+
127
+ const envConfig =
128
+ process.env.OPENCLAW_CONFIG_PATH ||
129
+ process.env.CLAWDBOT_CONFIG_PATH ||
130
+ process.env.OPENCLAW_CONFIG;
131
+
132
+ const openclawConfig = envConfig || ([
133
+ path.join(openclawStateDir, 'openclaw.json'),
134
+ path.join(clawdbotStateDir, 'openclaw.json'),
135
+ path.join(clawdbotStateDir, 'clawdbot.json'),
136
+ path.join(clawdbotStateDir, 'moltbot.json')
137
+ ].find(p => fs.existsSync(p)) || path.join(openclawStateDir, 'openclaw.json'));
138
+
139
+ const configDir = path.dirname(openclawConfig);
140
+ const authProfiles = resolveAuthProfilesPath({
141
+ configDir,
142
+ openclawStateDir,
143
+ clawdbotStateDir
144
+ });
145
+
15
146
  return {
16
- openclawConfig: path.join(configDir, 'openclaw.json'),
17
- authProfiles: path.join(configDir, 'agents', 'main', 'agent', 'auth-profiles.json')
147
+ openclawConfig,
148
+ authProfiles
18
149
  };
19
150
  }
20
151
 
152
+ function resolveAuthProfilesPath({ configDir, openclawStateDir, clawdbotStateDir }) {
153
+ const envAgentDir =
154
+ process.env.OPENCLAW_AGENT_DIR ||
155
+ process.env.CLAWDBOT_AGENT_DIR ||
156
+ process.env.PI_CODING_AGENT_DIR;
157
+ const envAgentName =
158
+ process.env.OPENCLAW_AGENT ||
159
+ process.env.CLAWDBOT_AGENT ||
160
+ process.env.PI_CODING_AGENT;
161
+
162
+ const candidates = [];
163
+
164
+ if (envAgentDir) {
165
+ candidates.push(path.join(envAgentDir, 'auth-profiles.json'));
166
+ }
167
+
168
+ if (envAgentName) {
169
+ candidates.push(path.join(openclawStateDir, 'agents', envAgentName, 'agent', 'auth-profiles.json'));
170
+ candidates.push(path.join(clawdbotStateDir, 'agents', envAgentName, 'agent', 'auth-profiles.json'));
171
+ }
172
+
173
+ // OpenClaw default + legacy paths
174
+ candidates.push(path.join(openclawStateDir, 'agents', 'main', 'agent', 'auth-profiles.json'));
175
+ candidates.push(path.join(openclawStateDir, 'agent', 'auth-profiles.json'));
176
+
177
+ // Clawdbot default + legacy paths
178
+ candidates.push(path.join(clawdbotStateDir, 'agent', 'auth-profiles.json'));
179
+ candidates.push(path.join(clawdbotStateDir, 'agents', 'main', 'agent', 'auth-profiles.json'));
180
+
181
+ // Fallback to config directory if user stores alongside config
182
+ candidates.push(path.join(configDir, 'agent', 'auth-profiles.json'));
183
+ candidates.push(path.join(configDir, 'agents', 'main', 'agent', 'auth-profiles.json'));
184
+
185
+ const inferred = inferSingleAgentAuthPath(openclawStateDir, clawdbotStateDir);
186
+ if (inferred) {
187
+ candidates.unshift(inferred);
188
+ }
189
+
190
+ return candidates.find(p => fs.existsSync(p)) || candidates[0];
191
+ }
192
+
193
+ function inferSingleAgentAuthPath(openclawStateDir, clawdbotStateDir) {
194
+ const openclawAgentsDir = path.join(openclawStateDir, 'agents');
195
+ if (fs.existsSync(openclawAgentsDir)) {
196
+ const dirs = fs.readdirSync(openclawAgentsDir, { withFileTypes: true })
197
+ .filter(entry => entry.isDirectory())
198
+ .map(entry => entry.name);
199
+ if (dirs.length === 1) {
200
+ return path.join(openclawAgentsDir, dirs[0], 'agent', 'auth-profiles.json');
201
+ }
202
+ }
203
+
204
+ const clawdbotAgentsDir = path.join(clawdbotStateDir, 'agents');
205
+ if (fs.existsSync(clawdbotAgentsDir)) {
206
+ const dirs = fs.readdirSync(clawdbotAgentsDir, { withFileTypes: true })
207
+ .filter(entry => entry.isDirectory())
208
+ .map(entry => entry.name);
209
+ if (dirs.length === 1) {
210
+ return path.join(clawdbotAgentsDir, dirs[0], 'agent', 'auth-profiles.json');
211
+ }
212
+ }
213
+
214
+ return '';
215
+ }
216
+
21
217
  async function main() {
22
218
  console.clear();
23
219
  console.log(chalk.cyan.bold('\n🔧 OpenClaw 配置管理工具\n'));
24
220
 
221
+ // 显示平台信息
222
+ const platformInfo = {
223
+ win32: 'Windows',
224
+ darwin: 'macOS',
225
+ linux: 'Linux'
226
+ };
227
+ console.log(chalk.gray(`平台: ${platformInfo[process.platform] || process.platform}`));
25
228
  const configPaths = getConfigPath();
229
+ console.log(chalk.gray(`配置目录: ${path.dirname(configPaths.openclawConfig)}`));
230
+ console.log(chalk.gray(`配置文件: ${configPaths.openclawConfig}\n`));
231
+ console.log(chalk.gray(`认证文件: ${configPaths.authProfiles}\n`));
26
232
  const configManager = new ConfigManager(configPaths);
27
233
 
28
- // 检查配置文件是否存在
234
+ // 检查配置文件是否存在,如果不存在则自动初始化
29
235
  const exists = await configManager.checkConfigExists();
30
236
  if (!exists.openclaw) {
31
- displayError(`配置文件不存在: ${configPaths.openclawConfig}`);
32
- displayInfo('请先运行 clawdbot onboard 初始化配置');
33
- process.exit(1);
237
+ displayInfo('配置文件不存在,正在初始化...');
238
+ try {
239
+ await configManager.initializeConfig();
240
+ displaySuccess('配置文件初始化成功!');
241
+ } catch (error) {
242
+ displayError(`初始化失败: ${error.message}`);
243
+ displayInfo('请检查目录权限或手动创建配置文件');
244
+ process.exit(1);
245
+ }
34
246
  }
35
247
 
36
248
  while (true) {
@@ -40,9 +252,13 @@ async function main() {
40
252
  name: 'action',
41
253
  message: '请选择操作:',
42
254
  choices: [
255
+ { name: '🚀 快速配置向导 (URL/Key/Token/Workspace)', value: 'quicksetup' },
256
+ new inquirer.Separator(),
43
257
  { name: '📡 管理中转站配置', value: 'relay' },
258
+ { name: '🧭 快速切换中转端点', value: 'endpoint' },
44
259
  { name: '🤖 管理模型配置', value: 'model' },
45
- { name: '🔑 管理 API Keys', value: 'apikey' },
260
+ { name: '🧩 快速切换模型', value: 'modelSwitch' },
261
+ { name: '🔑 管理 API Keys / Tokens', value: 'apikey' },
46
262
  { name: '⚡ 测速并切换中转站', value: 'speedtest' },
47
263
  { name: '⚙️ 高级设置', value: 'advanced' },
48
264
  { name: '📄 查看当前配置', value: 'view' },
@@ -59,12 +275,21 @@ async function main() {
59
275
 
60
276
  try {
61
277
  switch (action) {
278
+ case 'quicksetup':
279
+ await quickSetup(configManager);
280
+ break;
62
281
  case 'relay':
63
282
  await manageRelay(configManager);
64
283
  break;
284
+ case 'endpoint':
285
+ await quickSwitchEndpoint(configManager);
286
+ break;
65
287
  case 'model':
66
288
  await manageModel(configManager);
67
289
  break;
290
+ case 'modelSwitch':
291
+ await quickSwitchModel(configManager);
292
+ break;
68
293
  case 'apikey':
69
294
  await manageApiKey(configManager);
70
295
  break;
@@ -86,6 +311,604 @@ async function main() {
86
311
  }
87
312
  }
88
313
 
314
+ // 快速配置向导 - 一次性配置 URL、Key、Token、Workspace
315
+ async function quickSetup(configManager) {
316
+ console.log(chalk.cyan.bold('\n🚀 快速配置向导\n'));
317
+ console.log(chalk.gray('此向导将帮助您快速配置中转站的 URL、API Key、Token 和工作区\n'));
318
+
319
+ const defaultWorkspace = getDefaultWorkspace();
320
+
321
+ const presetChoices = [
322
+ { name: 'Claude (云逸 国内) https://yunyi.skem.cn/claude', value: 'yunyi-claude-cn' },
323
+ { name: 'Claude (云逸 海外) https://yunyi.cfd/claude', value: 'yunyi-claude-global' },
324
+ { name: 'Codex (云逸 国内) https://yunyi.skem.cn/codex', value: 'yunyi-codex-cn' },
325
+ { name: 'Codex (云逸 海外) https://yunyi.cfd/codex', value: 'yunyi-codex-global' },
326
+ { name: '自定义 (其他模型)', value: 'custom' }
327
+ ];
328
+
329
+ const presetMap = {
330
+ 'yunyi-claude-cn': {
331
+ name: 'yunyi-claude',
332
+ baseUrl: 'https://yunyi.skem.cn/claude/v1/messages',
333
+ modelType: 'claude',
334
+ api: 'anthropic-messages',
335
+ defaultModelId: 'claude-opus-4-5',
336
+ compactionMode: 'safeguard'
337
+ },
338
+ 'yunyi-claude-global': {
339
+ name: 'yunyi-claude',
340
+ baseUrl: 'https://yunyi.cfd/claude/v1/messages',
341
+ modelType: 'claude',
342
+ api: 'anthropic-messages',
343
+ defaultModelId: 'claude-opus-4-5',
344
+ compactionMode: 'safeguard'
345
+ },
346
+ 'yunyi-codex-cn': {
347
+ name: 'yunyi-codex',
348
+ baseUrl: 'https://yunyi.skem.cn/codex/response',
349
+ modelType: 'codex',
350
+ api: 'openai-responses',
351
+ defaultModelId: 'gpt-5.2',
352
+ defaultModelName: 'GPT 5.2',
353
+ compactionMode: 'safeguard'
354
+ },
355
+ 'yunyi-codex-global': {
356
+ name: 'yunyi-codex',
357
+ baseUrl: 'https://yunyi.cfd/codex/response',
358
+ modelType: 'codex',
359
+ api: 'openai-responses',
360
+ defaultModelId: 'gpt-5.2',
361
+ defaultModelName: 'GPT 5.2',
362
+ compactionMode: 'safeguard'
363
+ },
364
+ custom: {}
365
+ };
366
+
367
+ const { preset } = await inquirer.prompt([
368
+ { type: 'list', name: 'preset', message: '选择模板:', choices: presetChoices }
369
+ ]);
370
+ const presetConfig = presetMap[preset] || {};
371
+
372
+ // 步骤 1: 中转站名称和 URL
373
+ const step1 = await inquirer.prompt([
374
+ {
375
+ type: 'input',
376
+ name: 'name',
377
+ message: '中转站名称 (例如: my-relay):',
378
+ default: presetConfig.name || 'default-relay',
379
+ validate: (input) => input.trim() !== '' || '名称不能为空'
380
+ },
381
+ {
382
+ type: 'input',
383
+ name: 'baseUrl',
384
+ message: '中转站 URL (例如: https://api.example.com/v1):',
385
+ default: presetConfig.baseUrl || '',
386
+ validate: (input) => {
387
+ try {
388
+ const url = new URL(input);
389
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
390
+ return '请输入有效的 HTTP/HTTPS URL';
391
+ }
392
+ return true;
393
+ } catch {
394
+ return '请输入有效的 URL';
395
+ }
396
+ }
397
+ }
398
+ ]);
399
+
400
+ step1.baseUrl = buildRelayBaseUrl(step1.baseUrl, step1.name);
401
+
402
+ let backupConfig = null;
403
+ const { enableBackup } = await inquirer.prompt([
404
+ {
405
+ type: 'confirm',
406
+ name: 'enableBackup',
407
+ message: '是否添加备用中转端点(用于主备切换)?',
408
+ default: false
409
+ }
410
+ ]);
411
+
412
+ if (enableBackup) {
413
+ const endpoints = getRelayEndpoints(step1.name);
414
+ const fallbackEndpoint = endpoints.find(item => item.url !== step1.baseUrl);
415
+ const backupAnswers = await inquirer.prompt([
416
+ {
417
+ type: 'input',
418
+ name: 'name',
419
+ message: '备用中转站名称:',
420
+ default: `${step1.name}-backup`,
421
+ validate: (input) => input.trim() !== '' || '名称不能为空'
422
+ },
423
+ {
424
+ type: 'input',
425
+ name: 'baseUrl',
426
+ message: '备用端点 Base URL:',
427
+ default: fallbackEndpoint ? fallbackEndpoint.url : '',
428
+ validate: (input) => {
429
+ try {
430
+ const url = new URL(input);
431
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
432
+ return '请输入有效的 HTTP/HTTPS URL';
433
+ }
434
+ return true;
435
+ } catch {
436
+ return '请输入有效的 URL';
437
+ }
438
+ }
439
+ }
440
+ ]);
441
+ backupConfig = {
442
+ name: backupAnswers.name.trim(),
443
+ baseUrl: buildRelayBaseUrl(backupAnswers.baseUrl.trim(), step1.name)
444
+ };
445
+ }
446
+
447
+ // 步骤 2: 模型类型(仅支持 Claude / Codex)
448
+ let modelType = presetConfig.modelType;
449
+ if (!modelType) {
450
+ const result = await inquirer.prompt([
451
+ {
452
+ type: 'list',
453
+ name: 'modelType',
454
+ message: '选择模型类型:',
455
+ choices: [
456
+ { name: 'Claude (Anthropic)', value: 'claude' },
457
+ { name: 'Codex / GPT (OpenAI Responses)', value: 'codex' },
458
+ { name: '其他/自定义', value: 'other' }
459
+ ]
460
+ }
461
+ ]);
462
+ modelType = result.modelType;
463
+ }
464
+
465
+ if (modelType === 'other') {
466
+ displayWarning('非 GPT/Claude 模型仅做配置写入,不保证可用,请优先用 OpenClaw 配置页面。');
467
+ }
468
+
469
+ // 步骤 3: API 类型
470
+ let apiType = presetConfig.api;
471
+ if (!apiType) {
472
+ if (modelType === 'codex') {
473
+ apiType = 'openai-responses';
474
+ } else if (modelType === 'claude') {
475
+ apiType = 'anthropic-messages';
476
+ } else {
477
+ const result = await inquirer.prompt([
478
+ {
479
+ type: 'list',
480
+ name: 'apiType',
481
+ message: 'API 接口类型:',
482
+ choices: [
483
+ { name: 'Anthropic Messages (Claude)', value: 'anthropic-messages' },
484
+ { name: 'OpenAI Responses (Codex / GPT-5+)', value: 'openai-responses' },
485
+ { name: 'OpenAI Completions', value: 'openai-completions' },
486
+ { name: 'Google Generative AI (Gemini)', value: 'google-generative-ai' },
487
+ { name: '自定义', value: 'custom' }
488
+ ]
489
+ }
490
+ ]);
491
+ apiType = result.apiType;
492
+ if (apiType === 'custom') {
493
+ const custom = await inquirer.prompt([
494
+ { type: 'input', name: 'customApi', message: '输入 API 类型:', validate: (i) => i.trim() !== '' || '不能为空' }
495
+ ]);
496
+ apiType = custom.customApi;
497
+ }
498
+ }
499
+ }
500
+
501
+ // 步骤 4: 模型选择
502
+ let modelConfig;
503
+ if (modelType === 'claude') {
504
+ const { modelChoice } = await inquirer.prompt([
505
+ {
506
+ type: 'list',
507
+ name: 'modelChoice',
508
+ message: '选择 Claude 模型:',
509
+ choices: [
510
+ { name: 'Claude Opus 4.5', value: { id: 'claude-opus-4-5', name: 'Claude Opus 4.5' } },
511
+ { name: 'Claude Sonnet 4.5', value: { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' } },
512
+ { name: 'Claude Haiku 4.5', value: { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' } }
513
+ ],
514
+ default: presetConfig.defaultModelId
515
+ }
516
+ ]);
517
+ modelConfig = modelChoice;
518
+ } else if (modelType === 'codex') {
519
+ const { modelChoice } = await inquirer.prompt([
520
+ {
521
+ type: 'list',
522
+ name: 'modelChoice',
523
+ message: '选择 Codex / GPT 模型:',
524
+ choices: [
525
+ { name: 'GPT 5.2', value: { id: 'gpt-5.2', name: 'GPT 5.2' } },
526
+ { name: 'GPT 5.2 Codex', value: { id: 'gpt-5.2-codex', name: 'GPT 5.2 Codex' } }
527
+ ]
528
+ }
529
+ ]);
530
+ modelConfig = modelChoice;
531
+ } else {
532
+ const custom = await inquirer.prompt([
533
+ { type: 'input', name: 'id', message: '模型 ID:', validate: (i) => i.trim() !== '' || '不能为空' },
534
+ { type: 'input', name: 'name', message: '模型名称:', validate: (i) => i.trim() !== '' || '不能为空' }
535
+ ]);
536
+ modelConfig = { id: custom.id.trim(), name: custom.name.trim() };
537
+ }
538
+
539
+ const includeModelsList = apiType !== 'anthropic-messages';
540
+ let modelEntry = null;
541
+
542
+ if (includeModelsList) {
543
+ const defaults = modelType === 'codex'
544
+ ? { contextWindow: 128000, maxTokens: 32768 }
545
+ : { contextWindow: 200000, maxTokens: 8192 };
546
+ const { contextWindow, maxTokens } = await inquirer.prompt([
547
+ {
548
+ type: 'number',
549
+ name: 'contextWindow',
550
+ message: '上下文窗口大小:',
551
+ default: defaults.contextWindow
552
+ },
553
+ {
554
+ type: 'number',
555
+ name: 'maxTokens',
556
+ message: '最大输出 tokens:',
557
+ default: defaults.maxTokens
558
+ }
559
+ ]);
560
+ modelEntry = {
561
+ id: modelConfig.id,
562
+ name: modelConfig.name,
563
+ reasoning: apiType === 'openai-responses',
564
+ input: apiType === 'openai-responses' ? ['text', 'image'] : ['text'],
565
+ cost: {
566
+ input: 0,
567
+ output: 0,
568
+ cacheRead: 0,
569
+ cacheWrite: 0
570
+ },
571
+ contextWindow,
572
+ maxTokens
573
+ };
574
+ }
575
+
576
+ // 步骤 5: API Key / Token
577
+ const step3 = await inquirer.prompt([
578
+ {
579
+ type: 'password',
580
+ name: 'apiKey',
581
+ message: 'API Key (留空跳过):',
582
+ mask: '*'
583
+ },
584
+ {
585
+ type: 'password',
586
+ name: 'providerToken',
587
+ message: '中转站 Token (留空跳过):',
588
+ mask: '*'
589
+ },
590
+ {
591
+ type: 'password',
592
+ name: 'gatewayToken',
593
+ message: '网关 Token (留空跳过):',
594
+ mask: '*'
595
+ }
596
+ ]);
597
+
598
+ // 步骤 6: 工作区
599
+ const step4 = await inquirer.prompt([
600
+ {
601
+ type: 'input',
602
+ name: 'workspace',
603
+ message: `工作区路径 (留空使用默认):`,
604
+ default: defaultWorkspace
605
+ }
606
+ ]);
607
+
608
+ // 执行配置
609
+ console.log(chalk.cyan('\n正在保存配置...\n'));
610
+
611
+ try {
612
+ // 添加中转站
613
+ await configManager.addRelay({
614
+ name: step1.name,
615
+ baseUrl: step1.baseUrl,
616
+ auth: 'api-key',
617
+ api: apiType,
618
+ headers: {},
619
+ authHeader: false,
620
+ modelsMode: 'merge',
621
+ model: {
622
+ id: modelConfig.id,
623
+ name: modelConfig.name,
624
+ contextWindow: includeModelsList ? modelEntry.contextWindow : undefined,
625
+ maxTokens: includeModelsList ? modelEntry.maxTokens : undefined,
626
+ reasoning: includeModelsList ? modelEntry.reasoning : undefined,
627
+ input: includeModelsList ? modelEntry.input : undefined,
628
+ cost: includeModelsList ? modelEntry.cost : undefined
629
+ },
630
+ models: includeModelsList ? [modelEntry] : []
631
+ });
632
+ console.log(chalk.green(' ✓ 中转站已添加'));
633
+
634
+ // 设置为主模型
635
+ await configManager.setPrimaryModel(step1.name, modelConfig.id);
636
+ console.log(chalk.green(' ✓ 已设为主模型'));
637
+
638
+ if (backupConfig) {
639
+ try {
640
+ await configManager.addRelay({
641
+ name: backupConfig.name,
642
+ baseUrl: backupConfig.baseUrl,
643
+ auth: 'api-key',
644
+ api: apiType,
645
+ headers: {},
646
+ authHeader: false,
647
+ modelsMode: 'merge',
648
+ model: {
649
+ id: modelConfig.id,
650
+ name: modelConfig.name,
651
+ contextWindow: includeModelsList ? modelEntry.contextWindow : undefined,
652
+ maxTokens: includeModelsList ? modelEntry.maxTokens : undefined,
653
+ reasoning: includeModelsList ? modelEntry.reasoning : undefined,
654
+ input: includeModelsList ? modelEntry.input : undefined,
655
+ cost: includeModelsList ? modelEntry.cost : undefined
656
+ },
657
+ models: includeModelsList ? [modelEntry] : []
658
+ });
659
+ console.log(chalk.green(' ✓ 备用中转站已添加'));
660
+
661
+ const existingFallbacks = await configManager.getFallbackModels();
662
+ const fallbackKey = `${backupConfig.name}/${modelConfig.id}`;
663
+ const mergedFallbacks = Array.from(new Set([...existingFallbacks, fallbackKey]));
664
+ await configManager.setFallbackModels(mergedFallbacks);
665
+ console.log(chalk.green(' ✓ 已设置备用模型'));
666
+
667
+ if (step3.apiKey && step3.apiKey.trim() !== '') {
668
+ const { useSameKey } = await inquirer.prompt([
669
+ {
670
+ type: 'confirm',
671
+ name: 'useSameKey',
672
+ message: '是否给备用中转站使用相同 API Key?',
673
+ default: true
674
+ }
675
+ ]);
676
+ if (useSameKey) {
677
+ await configManager.setApiKey(backupConfig.name, step3.apiKey);
678
+ console.log(chalk.green(' ✓ 备用中转站 API Key 已保存'));
679
+ } else {
680
+ const { backupApiKey } = await inquirer.prompt([
681
+ {
682
+ type: 'password',
683
+ name: 'backupApiKey',
684
+ message: '输入备用中转站 API Key:',
685
+ mask: '*',
686
+ validate: (input) => input.trim() !== '' || 'API Key 不能为空'
687
+ }
688
+ ]);
689
+ await configManager.setApiKey(backupConfig.name, backupApiKey);
690
+ console.log(chalk.green(' ✓ 备用中转站 API Key 已保存'));
691
+ }
692
+ }
693
+ } catch (error) {
694
+ displayWarning(`备用中转站添加失败: ${error.message}`);
695
+ }
696
+ }
697
+
698
+ // 保存 API Key
699
+ if (step3.apiKey && step3.apiKey.trim() !== '') {
700
+ await configManager.setApiKey(step1.name, step3.apiKey);
701
+ console.log(chalk.green(' ✓ API Key 已保存'));
702
+ }
703
+
704
+ // 保存中转站 Token
705
+ if (step3.providerToken && step3.providerToken.trim() !== '') {
706
+ await configManager.setToken(step1.name, step3.providerToken);
707
+ console.log(chalk.green(' ✓ 中转站 Token 已保存'));
708
+ }
709
+
710
+ // 设置工作区 / 网关 Token / 压缩模式
711
+ await configManager.setAdvancedSettings({
712
+ workspace: step4.workspace || defaultWorkspace,
713
+ compactionMode: presetConfig.compactionMode,
714
+ gatewayToken: step3.gatewayToken
715
+ });
716
+ console.log(chalk.green(' ✓ 高级设置已更新'));
717
+
718
+ displaySuccess('\n🎉 配置完成!您现在可以开始使用了。');
719
+
720
+ // 显示配置摘要
721
+ console.log(chalk.cyan('\n配置摘要:'));
722
+ console.log(` 中转站: ${step1.name}`);
723
+ console.log(` URL: ${step1.baseUrl}`);
724
+ console.log(` 模型: ${modelConfig.name}`);
725
+ console.log(` API: ${apiType}`);
726
+ console.log(` API Key: ${step3.apiKey ? chalk.green('已设置') : chalk.gray('未设置')}`);
727
+ console.log(` 网关 Token: ${step3.gatewayToken ? chalk.green('已设置') : chalk.gray('未设置')}`);
728
+ console.log(` 工作区: ${step4.workspace || defaultWorkspace}`);
729
+
730
+ } catch (error) {
731
+ displayError(`配置失败: ${error.message}`);
732
+ }
733
+ }
734
+
735
+ // 快速切换中转端点
736
+ async function quickSwitchEndpoint(configManager) {
737
+ const relays = await configManager.listRelays();
738
+ if (relays.length === 0) {
739
+ displayInfo('请先添加中转站');
740
+ return;
741
+ }
742
+
743
+ const { relayName } = await inquirer.prompt([
744
+ {
745
+ type: 'list',
746
+ name: 'relayName',
747
+ message: '选择要切换端点的中转站:',
748
+ choices: relays.map(r => ({ name: `${r.name} (${r.baseUrl})`, value: r.name }))
749
+ }
750
+ ]);
751
+
752
+ const relay = relays.find(r => r.name === relayName);
753
+ const endpoints = getRelayEndpoints(relayName);
754
+ let selectedUrl = '';
755
+
756
+ if (endpoints.length > 0) {
757
+ const { endpoint } = await inquirer.prompt([
758
+ {
759
+ type: 'list',
760
+ name: 'endpoint',
761
+ message: '选择接入端点:',
762
+ choices: [
763
+ ...endpoints.map(item => ({ name: item.name, value: item.url })),
764
+ { name: '自定义', value: '__custom__' }
765
+ ]
766
+ }
767
+ ]);
768
+
769
+ if (endpoint === '__custom__') {
770
+ const custom = await inquirer.prompt([
771
+ {
772
+ type: 'input',
773
+ name: 'url',
774
+ message: '输入 Base URL:',
775
+ validate: (input) => {
776
+ try {
777
+ const url = new URL(input);
778
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
779
+ return '请输入有效的 HTTP/HTTPS URL';
780
+ }
781
+ return true;
782
+ } catch {
783
+ return '请输入有效的 URL';
784
+ }
785
+ }
786
+ }
787
+ ]);
788
+ selectedUrl = custom.url;
789
+ } else {
790
+ selectedUrl = endpoint;
791
+ }
792
+ } else {
793
+ const custom = await inquirer.prompt([
794
+ {
795
+ type: 'input',
796
+ name: 'url',
797
+ message: '输入 Base URL:',
798
+ default: relay.baseUrl,
799
+ validate: (input) => {
800
+ try {
801
+ const url = new URL(input);
802
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
803
+ return '请输入有效的 HTTP/HTTPS URL';
804
+ }
805
+ return true;
806
+ } catch {
807
+ return '请输入有效的 URL';
808
+ }
809
+ }
810
+ }
811
+ ]);
812
+ selectedUrl = custom.url;
813
+ }
814
+
815
+ const finalUrl = buildRelayBaseUrl(selectedUrl, relayName);
816
+ if (finalUrl && finalUrl !== relay.baseUrl) {
817
+ await configManager.updateRelay(relayName, { baseUrl: finalUrl });
818
+ displaySuccess(`✅ 已切换 "${relayName}" 的端点`);
819
+ } else {
820
+ displayInfo('端点未变化');
821
+ }
822
+ }
823
+
824
+ // 快速切换模型
825
+ async function quickSwitchModel(configManager) {
826
+ const relays = await configManager.listRelays();
827
+ if (relays.length === 0) {
828
+ displayInfo('请先添加中转站');
829
+ return;
830
+ }
831
+
832
+ const { relayName } = await inquirer.prompt([
833
+ {
834
+ type: 'list',
835
+ name: 'relayName',
836
+ message: '选择要切换模型的中转站:',
837
+ choices: relays.map(r => ({ name: `${r.name} (${r.modelName})`, value: r.name }))
838
+ }
839
+ ]);
840
+
841
+ const config = await configManager.readOpenclawConfig();
842
+ const provider = config.models?.providers?.[relayName] || {};
843
+ const apiType = provider.api || (normalizeRelayKey(relayName) === 'yunyi-codex'
844
+ ? 'openai-responses'
845
+ : 'anthropic-messages');
846
+ const providerModels = Array.isArray(provider.models) ? provider.models : [];
847
+ const presetModels = getRelayModelPresets(relayName);
848
+
849
+ const choiceMap = new Map();
850
+ const choices = [];
851
+
852
+ const allowedIds = new Set(presetModels.map(m => m.id));
853
+
854
+ providerModels.forEach(m => {
855
+ const key = m.id;
856
+ if (allowedIds.has(key) && !choiceMap.has(key)) {
857
+ choiceMap.set(key, m);
858
+ choices.push({ name: `${m.name || m.id} (${m.id})`, value: m.id });
859
+ }
860
+ });
861
+
862
+ presetModels.forEach(m => {
863
+ if (!choiceMap.has(m.id)) {
864
+ choiceMap.set(m.id, m);
865
+ choices.push({ name: `${m.name} (${m.id})`, value: m.id });
866
+ }
867
+ });
868
+
869
+ if (choices.length === 0) {
870
+ displayInfo('没有可用的模型选项');
871
+ return;
872
+ }
873
+
874
+ const { selected } = await inquirer.prompt([
875
+ {
876
+ type: 'list',
877
+ name: 'selected',
878
+ message: '选择模型:',
879
+ choices
880
+ }
881
+ ]);
882
+
883
+ const selectedModel = choiceMap.get(selected);
884
+ const model = {
885
+ id: selected,
886
+ name: selectedModel?.name || selected
887
+ };
888
+
889
+ await configManager.setPrimaryModel(relayName, model.id);
890
+ displaySuccess(`✅ 已切换主模型为 "${relayName}/${model.id}"`);
891
+
892
+ if (apiType !== 'anthropic-messages' && Array.isArray(provider.models)) {
893
+ const exists = providerModels.some(m => m.id === model.id);
894
+ if (!exists) {
895
+ const { shouldAdd } = await inquirer.prompt([
896
+ {
897
+ type: 'confirm',
898
+ name: 'shouldAdd',
899
+ message: '是否将模型写入 providers.models 列表?',
900
+ default: false
901
+ }
902
+ ]);
903
+ if (shouldAdd) {
904
+ provider.models.push(buildModelEntry(model, apiType));
905
+ await configManager.writeOpenclawConfig(config);
906
+ displaySuccess('✅ 已更新模型列表');
907
+ }
908
+ }
909
+ }
910
+ }
911
+
89
912
  // 管理中转站配置
90
913
  async function manageRelay(configManager) {
91
914
  const { relayAction } = await inquirer.prompt([
@@ -151,88 +974,133 @@ async function addRelay(configManager) {
151
974
  message: '模型类型:',
152
975
  choices: [
153
976
  { name: 'Claude (Anthropic)', value: 'claude' },
154
- { name: 'GPT (OpenAI)', value: 'gpt' },
155
- { name: 'Gemini (Google)', value: 'gemini' },
156
- { name: '其他', value: 'other' }
977
+ { name: 'Codex / GPT (OpenAI Responses)', value: 'codex' },
978
+ { name: '其他/自定义', value: 'other' }
157
979
  ]
158
980
  }
159
981
  ]);
160
982
 
983
+ answers.baseUrl = buildRelayBaseUrl(answers.baseUrl, answers.name);
984
+
985
+ let apiType = answers.modelType === 'codex'
986
+ ? 'openai-responses'
987
+ : 'anthropic-messages';
988
+ if (answers.modelType === 'other') {
989
+ displayWarning('非 GPT/Claude 模型仅做配置写入,不保证可用,请优先用 OpenClaw 配置页面。');
990
+ const result = await inquirer.prompt([
991
+ {
992
+ type: 'list',
993
+ name: 'apiType',
994
+ message: 'API 接口类型:',
995
+ choices: [
996
+ { name: 'Anthropic Messages (Claude)', value: 'anthropic-messages' },
997
+ { name: 'OpenAI Responses (Codex / GPT-5+)', value: 'openai-responses' },
998
+ { name: 'OpenAI Completions', value: 'openai-completions' },
999
+ { name: 'Google Generative AI (Gemini)', value: 'google-generative-ai' },
1000
+ { name: '自定义', value: 'custom' }
1001
+ ]
1002
+ }
1003
+ ]);
1004
+ apiType = result.apiType;
1005
+ if (apiType === 'custom') {
1006
+ const custom = await inquirer.prompt([
1007
+ { type: 'input', name: 'customApi', message: '输入 API 类型:', validate: (i) => i.trim() !== '' || '不能为空' }
1008
+ ]);
1009
+ apiType = custom.customApi;
1010
+ }
1011
+ }
1012
+
161
1013
  let modelConfig;
162
1014
  if (answers.modelType === 'claude') {
163
- const { modelId } = await inquirer.prompt([
1015
+ const { modelChoice } = await inquirer.prompt([
164
1016
  {
165
1017
  type: 'list',
166
- name: 'modelId',
1018
+ name: 'modelChoice',
167
1019
  message: '选择 Claude 模型:',
168
1020
  choices: [
169
- { name: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
170
- { name: 'Claude Sonnet 3.7', value: 'claude-3-7-sonnet-latest' },
171
- { name: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' },
172
- { name: '自定义', value: 'custom' }
1021
+ { name: 'Claude Opus 4.5', value: { id: 'claude-opus-4-5', name: 'Claude Opus 4.5' } },
1022
+ { name: 'Claude Sonnet 4.5', value: { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' } },
1023
+ { name: 'Claude Haiku 4.5', value: { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' } }
173
1024
  ]
174
1025
  }
175
1026
  ]);
176
1027
 
177
- if (modelId === 'custom') {
178
- const { customId } = await inquirer.prompt([
179
- {
180
- type: 'input',
181
- name: 'customId',
182
- message: '输入自定义模型 ID:',
183
- validate: (input) => input.trim() !== '' || '模型 ID 不能为空'
184
- }
185
- ]);
186
- modelConfig = { id: customId, name: customId };
187
- } else {
188
- modelConfig = { id: modelId, name: 'Claude Sonnet 4.5' };
189
- }
190
- } else if (answers.modelType === 'gpt') {
191
- modelConfig = { id: 'gpt-4o', name: 'GPT-4o' };
192
- } else if (answers.modelType === 'gemini') {
193
- modelConfig = { id: 'gemini-3-pro', name: 'Gemini 3 Pro' };
1028
+ modelConfig = modelChoice;
1029
+ } else if (answers.modelType === 'codex') {
1030
+ const { modelChoice } = await inquirer.prompt([
1031
+ {
1032
+ type: 'list',
1033
+ name: 'modelChoice',
1034
+ message: '选择 Codex / GPT 模型:',
1035
+ choices: [
1036
+ { name: 'GPT 5.2', value: { id: 'gpt-5.2', name: 'GPT 5.2' } },
1037
+ { name: 'GPT 5.2 Codex', value: { id: 'gpt-5.2-codex', name: 'GPT 5.2 Codex' } }
1038
+ ]
1039
+ }
1040
+ ]);
1041
+ modelConfig = modelChoice;
194
1042
  } else {
195
- const { customId, customName } = await inquirer.prompt([
1043
+ const custom = await inquirer.prompt([
1044
+ { type: 'input', name: 'id', message: '模型 ID:', validate: (i) => i.trim() !== '' || '不能为空' },
1045
+ { type: 'input', name: 'name', message: '模型名称:', validate: (i) => i.trim() !== '' || '不能为空' }
1046
+ ]);
1047
+ modelConfig = { id: custom.id.trim(), name: custom.name.trim() };
1048
+ }
1049
+
1050
+ const includeModelsList = apiType !== 'anthropic-messages';
1051
+ let modelEntry = null;
1052
+ if (includeModelsList) {
1053
+ const defaults = answers.modelType === 'codex'
1054
+ ? { contextWindow: 128000, maxTokens: 32768 }
1055
+ : { contextWindow: 200000, maxTokens: 8192 };
1056
+ const { contextWindow, maxTokens } = await inquirer.prompt([
196
1057
  {
197
- type: 'input',
198
- name: 'customId',
199
- message: '模型 ID:',
200
- validate: (input) => input.trim() !== '' || '模型 ID 不能为空'
1058
+ type: 'number',
1059
+ name: 'contextWindow',
1060
+ message: '上下文窗口大小:',
1061
+ default: defaults.contextWindow
201
1062
  },
202
1063
  {
203
- type: 'input',
204
- name: 'customName',
205
- message: '模型显示名称:',
206
- validate: (input) => input.trim() !== '' || '名称不能为空'
1064
+ type: 'number',
1065
+ name: 'maxTokens',
1066
+ message: '最大输出 tokens:',
1067
+ default: defaults.maxTokens
207
1068
  }
208
1069
  ]);
209
- modelConfig = { id: customId, name: customName };
1070
+ modelEntry = {
1071
+ id: modelConfig.id,
1072
+ name: modelConfig.name,
1073
+ reasoning: apiType === 'openai-responses',
1074
+ input: apiType === 'openai-responses' ? ['text', 'image'] : ['text'],
1075
+ cost: {
1076
+ input: 0,
1077
+ output: 0,
1078
+ cacheRead: 0,
1079
+ cacheWrite: 0
1080
+ },
1081
+ contextWindow,
1082
+ maxTokens
1083
+ };
210
1084
  }
211
1085
 
212
- const { contextWindow, maxTokens } = await inquirer.prompt([
213
- {
214
- type: 'number',
215
- name: 'contextWindow',
216
- message: '上下文窗口大小:',
217
- default: 200000
218
- },
219
- {
220
- type: 'number',
221
- name: 'maxTokens',
222
- message: '最大输出 tokens:',
223
- default: 8192
224
- }
225
- ]);
226
-
227
1086
  await configManager.addRelay({
228
1087
  name: answers.name,
229
1088
  baseUrl: answers.baseUrl,
1089
+ auth: 'api-key',
1090
+ api: apiType,
1091
+ headers: {},
1092
+ authHeader: false,
1093
+ modelsMode: 'merge',
230
1094
  model: {
231
1095
  id: modelConfig.id,
232
1096
  name: modelConfig.name,
233
- contextWindow,
234
- maxTokens
235
- }
1097
+ contextWindow: includeModelsList ? modelEntry.contextWindow : undefined,
1098
+ maxTokens: includeModelsList ? modelEntry.maxTokens : undefined,
1099
+ reasoning: includeModelsList ? modelEntry.reasoning : undefined,
1100
+ input: includeModelsList ? modelEntry.input : undefined,
1101
+ cost: includeModelsList ? modelEntry.cost : undefined
1102
+ },
1103
+ models: includeModelsList ? [modelEntry] : []
236
1104
  });
237
1105
 
238
1106
  displaySuccess(`✅ 中转站 "${answers.name}" 添加成功!`);
@@ -256,8 +1124,29 @@ async function editRelay(configManager) {
256
1124
  ]);
257
1125
 
258
1126
  const relay = relays.find(r => r.name === relayName);
1127
+ const config = await configManager.readOpenclawConfig();
1128
+ const provider = config.models?.providers?.[relayName] || {};
1129
+ const apiTypeDefault = provider.api || (normalizeRelayKey(relayName) === 'yunyi-codex'
1130
+ ? 'openai-responses'
1131
+ : 'anthropic-messages');
1132
+ const hasModelList = Array.isArray(provider.models) && provider.models.length > 0;
1133
+ if (!['yunyi-claude', 'yunyi-codex'].includes(normalizeRelayKey(relayName))) {
1134
+ displayWarning('该中转站不在本工具保障范围,仅做配置写入。');
1135
+ }
259
1136
 
260
- const answers = await inquirer.prompt([
1137
+ const apiChoices = [
1138
+ { name: 'Anthropic Messages (Claude)', value: 'anthropic-messages' },
1139
+ { name: 'OpenAI Responses (Codex / GPT-5+)', value: 'openai-responses' },
1140
+ { name: 'OpenAI Completions', value: 'openai-completions' },
1141
+ { name: 'Google Generative AI (Gemini)', value: 'google-generative-ai' },
1142
+ { name: '自定义', value: 'custom' }
1143
+ ];
1144
+
1145
+ if (apiTypeDefault && !apiChoices.some(choice => choice.value === apiTypeDefault)) {
1146
+ apiChoices.unshift({ name: `当前 (${apiTypeDefault})`, value: apiTypeDefault });
1147
+ }
1148
+
1149
+ const questions = [
261
1150
  {
262
1151
  type: 'input',
263
1152
  name: 'baseUrl',
@@ -265,18 +1154,76 @@ async function editRelay(configManager) {
265
1154
  default: relay.baseUrl
266
1155
  },
267
1156
  {
268
- type: 'number',
269
- name: 'contextWindow',
270
- message: '上下文窗口:',
271
- default: relay.contextWindow
1157
+ type: 'list',
1158
+ name: 'api',
1159
+ message: 'API 接口类型:',
1160
+ default: apiTypeDefault,
1161
+ choices: apiChoices
272
1162
  },
273
1163
  {
274
- type: 'number',
275
- name: 'maxTokens',
276
- message: '最大输出 tokens:',
277
- default: relay.maxTokens
1164
+ type: 'confirm',
1165
+ name: 'authHeader',
1166
+ message: '是否启用 authHeader?',
1167
+ default: !!provider.authHeader
1168
+ },
1169
+ {
1170
+ type: 'input',
1171
+ name: 'headers',
1172
+ message: 'Headers (JSON/JSON5,留空保持不变):',
1173
+ default: provider.headers ? JSON.stringify(provider.headers) : ''
1174
+ },
1175
+ {
1176
+ type: 'password',
1177
+ name: 'apiKey',
1178
+ message: 'API Key (留空保持不变):',
1179
+ mask: '*'
278
1180
  }
279
- ]);
1181
+ ];
1182
+
1183
+ if (hasModelList) {
1184
+ questions.push(
1185
+ {
1186
+ type: 'number',
1187
+ name: 'contextWindow',
1188
+ message: '上下文窗口:',
1189
+ default: relay.contextWindow
1190
+ },
1191
+ {
1192
+ type: 'number',
1193
+ name: 'maxTokens',
1194
+ message: '最大输出 tokens:',
1195
+ default: relay.maxTokens
1196
+ }
1197
+ );
1198
+ }
1199
+
1200
+ const answers = await inquirer.prompt(questions);
1201
+
1202
+ if (answers.api === 'custom') {
1203
+ const custom = await inquirer.prompt([
1204
+ { type: 'input', name: 'customApi', message: '输入 API 类型:', validate: (i) => i.trim() !== '' || '不能为空' }
1205
+ ]);
1206
+ answers.api = custom.customApi;
1207
+ }
1208
+
1209
+ if (answers.headers && answers.headers.trim() !== '') {
1210
+ try {
1211
+ answers.headers = JSON5.parse(answers.headers);
1212
+ } catch (error) {
1213
+ displayError(`Headers 解析失败: ${error.message}`);
1214
+ return;
1215
+ }
1216
+ } else {
1217
+ delete answers.headers;
1218
+ }
1219
+
1220
+ if (answers.baseUrl) {
1221
+ answers.baseUrl = buildRelayBaseUrl(answers.baseUrl, relayName);
1222
+ }
1223
+
1224
+ if (!answers.apiKey || answers.apiKey.trim() === '') {
1225
+ delete answers.apiKey;
1226
+ }
280
1227
 
281
1228
  await configManager.updateRelay(relayName, answers);
282
1229
  displaySuccess(`✅ 中转站 "${relayName}" 更新成功!`);
@@ -336,7 +1283,21 @@ async function switchRelay(configManager) {
336
1283
  }
337
1284
  ]);
338
1285
 
339
- await configManager.setPrimaryModel(relayName);
1286
+ const selected = relays.find(r => r.name === relayName);
1287
+ let modelId = selected?.modelId;
1288
+ if (!modelId) {
1289
+ const result = await inquirer.prompt([
1290
+ {
1291
+ type: 'input',
1292
+ name: 'modelId',
1293
+ message: '该中转站未配置模型 ID,请输入模型 ID:',
1294
+ validate: (input) => input.trim() !== '' || '模型 ID 不能为空'
1295
+ }
1296
+ ]);
1297
+ modelId = result.modelId;
1298
+ }
1299
+
1300
+ await configManager.setPrimaryModel(relayName, modelId);
340
1301
  displaySuccess(`✅ 已切换到 "${relayName}"`);
341
1302
  }
342
1303
 
@@ -371,13 +1332,18 @@ async function manageModel(configManager) {
371
1332
  async function manageFallback(configManager) {
372
1333
  const relays = await configManager.listRelays();
373
1334
  const currentFallbacks = await configManager.getFallbackModels();
1335
+ const availableRelays = relays.filter(r => r.modelId);
1336
+
1337
+ if (availableRelays.length !== relays.length) {
1338
+ displayWarning('部分中转站未配置模型 ID,已跳过');
1339
+ }
374
1340
 
375
1341
  const { selected } = await inquirer.prompt([
376
1342
  {
377
1343
  type: 'checkbox',
378
1344
  name: 'selected',
379
1345
  message: '选择备用模型(按优先级排序):',
380
- choices: relays.map(r => ({
1346
+ choices: availableRelays.map(r => ({
381
1347
  name: `${r.name} - ${r.modelName}`,
382
1348
  value: `${r.name}/${r.modelId}`,
383
1349
  checked: currentFallbacks.includes(`${r.name}/${r.modelId}`)
@@ -389,17 +1355,19 @@ async function manageFallback(configManager) {
389
1355
  displaySuccess('✅ 备用模型配置已更新');
390
1356
  }
391
1357
 
392
- // 管理 API Keys
1358
+ // 管理 API Keys 和 Tokens
393
1359
  async function manageApiKey(configManager) {
394
1360
  const { keyAction } = await inquirer.prompt([
395
1361
  {
396
1362
  type: 'list',
397
1363
  name: 'keyAction',
398
- message: 'API Key 管理:',
1364
+ message: 'API Key / Token 管理:',
399
1365
  choices: [
400
1366
  { name: '➕ 添加/更新 API Key', value: 'add' },
401
- { name: '👁️ 查看已配置的 Keys', value: 'view' },
1367
+ { name: '🔐 添加/更新 Token', value: 'addToken' },
1368
+ { name: '👁️ 查看已配置的 Keys/Tokens', value: 'view' },
402
1369
  { name: '🗑️ 删除 API Key', value: 'delete' },
1370
+ { name: '🗑️ 删除 Token', value: 'deleteToken' },
403
1371
  { name: '⬅️ 返回', value: 'back' }
404
1372
  ]
405
1373
  }
@@ -411,12 +1379,18 @@ async function manageApiKey(configManager) {
411
1379
  case 'add':
412
1380
  await addApiKey(configManager);
413
1381
  break;
1382
+ case 'addToken':
1383
+ await addToken(configManager);
1384
+ break;
414
1385
  case 'view':
415
1386
  await viewApiKeys(configManager);
416
1387
  break;
417
1388
  case 'delete':
418
1389
  await deleteApiKey(configManager);
419
1390
  break;
1391
+ case 'deleteToken':
1392
+ await deleteToken(configManager);
1393
+ break;
420
1394
  }
421
1395
  }
422
1396
 
@@ -424,6 +1398,11 @@ async function manageApiKey(configManager) {
424
1398
  async function addApiKey(configManager) {
425
1399
  const relays = await configManager.listRelays();
426
1400
 
1401
+ if (relays.length === 0) {
1402
+ displayInfo('请先添加中转站');
1403
+ return;
1404
+ }
1405
+
427
1406
  const { relayName } = await inquirer.prompt([
428
1407
  {
429
1408
  type: 'list',
@@ -447,27 +1426,97 @@ async function addApiKey(configManager) {
447
1426
  displaySuccess(`✅ API Key 已保存到 "${relayName}"`);
448
1427
  }
449
1428
 
450
- // 查看 API Keys
1429
+ // 添加 Token
1430
+ async function addToken(configManager) {
1431
+ const relays = await configManager.listRelays();
1432
+
1433
+ if (relays.length === 0) {
1434
+ displayInfo('请先添加中转站');
1435
+ return;
1436
+ }
1437
+
1438
+ const { relayName } = await inquirer.prompt([
1439
+ {
1440
+ type: 'list',
1441
+ name: 'relayName',
1442
+ message: '选择中转站:',
1443
+ choices: relays.map(r => ({ name: r.name, value: r.name }))
1444
+ }
1445
+ ]);
1446
+
1447
+ const { token } = await inquirer.prompt([
1448
+ {
1449
+ type: 'password',
1450
+ name: 'token',
1451
+ message: '输入 Token:',
1452
+ mask: '*',
1453
+ validate: (input) => input.trim() !== '' || 'Token 不能为空'
1454
+ }
1455
+ ]);
1456
+
1457
+ await configManager.setToken(relayName, token);
1458
+ displaySuccess(`✅ Token 已保存到 "${relayName}"`);
1459
+ }
1460
+
1461
+ // 查看 API Keys 和 Tokens
451
1462
  async function viewApiKeys(configManager) {
452
1463
  const keys = await configManager.listApiKeys();
453
1464
 
454
1465
  if (keys.length === 0) {
455
- displayInfo('没有配置任何 API Key');
1466
+ displayInfo('没有配置任何 API Key 或 Token');
456
1467
  return;
457
1468
  }
458
1469
 
459
- console.log(chalk.cyan('\n已配置的 API Keys:\n'));
1470
+ console.log(chalk.cyan('\n已配置的 API Keys / Tokens:\n'));
460
1471
  keys.forEach(k => {
461
- const masked = k.key ? `${k.key.substring(0, 8)}...${k.key.substring(k.key.length - 4)}` : '未设置';
462
- console.log(` ${chalk.green('●')} ${k.provider}: ${masked}`);
1472
+ const maskedKey = k.key ? `${k.key.substring(0, 8)}...${k.key.substring(k.key.length - 4)}` : '未设置';
1473
+ const maskedToken = k.token ? `${k.token.substring(0, 8)}...${k.token.substring(k.token.length - 4)}` : '未设置';
1474
+ console.log(` ${chalk.green('●')} ${k.provider}`);
1475
+ console.log(` API Key: ${k.key ? chalk.green(maskedKey) : chalk.gray(maskedKey)}`);
1476
+ console.log(` Token: ${k.token ? chalk.green(maskedToken) : chalk.gray(maskedToken)}`);
463
1477
  });
464
1478
  }
465
1479
 
1480
+ // 删除 Token
1481
+ async function deleteToken(configManager) {
1482
+ const keys = await configManager.listApiKeys();
1483
+ const keysWithToken = keys.filter(k => k.token);
1484
+
1485
+ if (keysWithToken.length === 0) {
1486
+ displayInfo('没有可删除的 Token');
1487
+ return;
1488
+ }
1489
+
1490
+ const { provider } = await inquirer.prompt([
1491
+ {
1492
+ type: 'list',
1493
+ name: 'provider',
1494
+ message: '选择要删除的 Token:',
1495
+ choices: keysWithToken.map(k => ({ name: k.provider, value: k.provider }))
1496
+ }
1497
+ ]);
1498
+
1499
+ const { confirm } = await inquirer.prompt([
1500
+ {
1501
+ type: 'confirm',
1502
+ name: 'confirm',
1503
+ message: `确定要删除 "${provider}" 的 Token 吗?`,
1504
+ default: false
1505
+ }
1506
+ ]);
1507
+
1508
+ if (confirm) {
1509
+ await configManager.deleteToken(provider);
1510
+ displaySuccess(`✅ Token 已删除`);
1511
+ }
1512
+ }
1513
+
466
1514
  // 删除 API Key
467
1515
  async function deleteApiKey(configManager) {
468
1516
  const keys = await configManager.listApiKeys();
1517
+ const keysWithApiKey = keys.filter(k => k.key);
469
1518
 
470
- if (keys.length === 0) {
1519
+ if (keysWithApiKey.length === 0) {
471
1520
  displayInfo('没有可删除的 API Key');
472
1521
  return;
473
1522
  }
@@ -477,7 +1526,7 @@ async function deleteApiKey(configManager) {
477
1526
  type: 'list',
478
1527
  name: 'provider',
479
1528
  message: '选择要删除的 API Key:',
480
- choices: keys.map(k => ({ name: k.provider, value: k.provider }))
1529
+ choices: keysWithApiKey.map(k => ({ name: k.provider, value: k.provider }))
481
1530
  }
482
1531
  ]);
483
1532
 
@@ -553,13 +1602,26 @@ async function speedTestAndSwitch(configManager) {
553
1602
 
554
1603
  if (action === 'fastest') {
555
1604
  // 切换到最快的
556
- await configManager.setPrimaryModel(fastest.name);
1605
+ const fastestRelay = relays.find(rel => rel.name === fastest.name);
1606
+ let fastestModelId = fastestRelay?.modelId;
1607
+ if (!fastestModelId) {
1608
+ const result = await inquirer.prompt([
1609
+ {
1610
+ type: 'input',
1611
+ name: 'modelId',
1612
+ message: `未找到 "${fastest.name}" 的模型 ID,请输入:`,
1613
+ validate: (input) => input.trim() !== '' || '模型 ID 不能为空'
1614
+ }
1615
+ ]);
1616
+ fastestModelId = result.modelId;
1617
+ }
1618
+ await configManager.setPrimaryModel(fastest.name, fastestModelId);
557
1619
 
558
1620
  // 设置其他可用的为备用
559
- const fallbacks = sorted.slice(1).map(r => {
560
- const relay = relays.find(rel => rel.name === r.name);
561
- return `${relay.name}/${relay.modelId}`;
562
- });
1621
+ const fallbacks = sorted.slice(1)
1622
+ .map(r => relays.find(rel => rel.name === r.name))
1623
+ .filter(relay => relay && relay.modelId)
1624
+ .map(relay => `${relay.name}/${relay.modelId}`);
563
1625
  await configManager.setFallbackModels(fallbacks);
564
1626
 
565
1627
  displaySuccess(`✅ 已切换到 "${fastest.name}" (${fastest.latency}ms)`);
@@ -580,15 +1642,27 @@ async function speedTestAndSwitch(configManager) {
580
1642
  }
581
1643
  ]);
582
1644
 
583
- await configManager.setPrimaryModel(selected);
1645
+ const selectedRelay = relays.find(rel => rel.name === selected);
1646
+ let selectedModelId = selectedRelay?.modelId;
1647
+ if (!selectedModelId) {
1648
+ const result = await inquirer.prompt([
1649
+ {
1650
+ type: 'input',
1651
+ name: 'modelId',
1652
+ message: `未找到 "${selected}" 的模型 ID,请输入:`,
1653
+ validate: (input) => input.trim() !== '' || '模型 ID 不能为空'
1654
+ }
1655
+ ]);
1656
+ selectedModelId = result.modelId;
1657
+ }
1658
+ await configManager.setPrimaryModel(selected, selectedModelId);
584
1659
 
585
1660
  // 设置其他为备用
586
1661
  const fallbacks = sorted
587
1662
  .filter(r => r.name !== selected)
588
- .map(r => {
589
- const relay = relays.find(rel => rel.name === r.name);
590
- return `${relay.name}/${relay.modelId}`;
591
- });
1663
+ .map(r => relays.find(rel => rel.name === r.name))
1664
+ .filter(relay => relay && relay.modelId)
1665
+ .map(relay => `${relay.name}/${relay.modelId}`);
592
1666
  await configManager.setFallbackModels(fallbacks);
593
1667
 
594
1668
  const selectedResult = results.find(r => r.name === selected);
@@ -602,29 +1676,91 @@ async function speedTestAndSwitch(configManager) {
602
1676
  // 高级设置
603
1677
  async function manageAdvanced(configManager) {
604
1678
  const currentConfig = await configManager.getAdvancedSettings();
1679
+ const defaultWorkspace = getDefaultWorkspace();
1680
+
1681
+ console.log(chalk.cyan('\n当前高级设置:'));
1682
+ console.log(` 最大并发: ${currentConfig.maxConcurrent}`);
1683
+ console.log(` 子代理并发: ${currentConfig.subagentMaxConcurrent}`);
1684
+ console.log(` 工作区: ${currentConfig.workspace || chalk.gray('(使用默认)')}`);
1685
+ console.log(` 压缩模式: ${currentConfig.compactionMode || chalk.gray('(未设置)')}`);
1686
+ if (currentConfig.gatewayToken) {
1687
+ const token = currentConfig.gatewayToken;
1688
+ const masked = `${token.substring(0, 6)}...${token.substring(token.length - 4)}`;
1689
+ console.log(` 网关 Token: ${chalk.gray(masked)}`);
1690
+ } else {
1691
+ console.log(` 网关 Token: ${chalk.gray('(未设置)')}`);
1692
+ }
1693
+ console.log(` 默认工作区: ${chalk.gray(defaultWorkspace)}\n`);
605
1694
 
606
1695
  const answers = await inquirer.prompt([
607
1696
  {
608
1697
  type: 'number',
609
1698
  name: 'maxConcurrent',
610
- message: '最大并发任务数:',
611
- default: currentConfig.maxConcurrent || 4
1699
+ message: '最大并发任务数 (1-100):',
1700
+ default: currentConfig.maxConcurrent || 4,
1701
+ validate: (input) => {
1702
+ const num = Number(input);
1703
+ if (isNaN(num) || num < 1 || num > 100) {
1704
+ return '请输入 1 到 100 之间的数字';
1705
+ }
1706
+ return true;
1707
+ }
612
1708
  },
613
1709
  {
614
1710
  type: 'number',
615
1711
  name: 'subagentMaxConcurrent',
616
- message: '子代理最大并发数:',
617
- default: currentConfig.subagentMaxConcurrent || 8
1712
+ message: '子代理最大并发数 (1-100):',
1713
+ default: currentConfig.subagentMaxConcurrent || 8,
1714
+ validate: (input) => {
1715
+ const num = Number(input);
1716
+ if (isNaN(num) || num < 1 || num > 100) {
1717
+ return '请输入 1 到 100 之间的数字';
1718
+ }
1719
+ return true;
1720
+ }
618
1721
  },
619
1722
  {
620
1723
  type: 'input',
621
1724
  name: 'workspace',
622
- message: '工作区路径:',
623
- default: currentConfig.workspace || path.join(os.homedir(), '.openclaw', 'workspace')
1725
+ message: `工作区路径 (留空使用默认: ${defaultWorkspace}):`,
1726
+ default: currentConfig.workspace || ''
1727
+ },
1728
+ {
1729
+ type: 'list',
1730
+ name: 'compactionMode',
1731
+ message: '压缩模式:',
1732
+ choices: [
1733
+ { name: '保持不变', value: '__keep__' },
1734
+ { name: 'safeguard', value: 'safeguard' },
1735
+ { name: 'auto', value: 'auto' },
1736
+ { name: 'off', value: 'off' }
1737
+ ],
1738
+ default: currentConfig.compactionMode ? currentConfig.compactionMode : '__keep__'
1739
+ },
1740
+ {
1741
+ type: 'password',
1742
+ name: 'gatewayToken',
1743
+ message: '网关 Token (留空保持不变):',
1744
+ mask: '*'
624
1745
  }
625
1746
  ]);
626
1747
 
627
- await configManager.setAdvancedSettings(answers);
1748
+ // 如果用户没有输入工作区路径,使用默认值
1749
+ if (!answers.workspace || answers.workspace.trim() === '') {
1750
+ answers.workspace = defaultWorkspace;
1751
+ }
1752
+
1753
+ const advancedUpdate = {
1754
+ maxConcurrent: answers.maxConcurrent,
1755
+ subagentMaxConcurrent: answers.subagentMaxConcurrent,
1756
+ workspace: answers.workspace,
1757
+ gatewayToken: answers.gatewayToken
1758
+ };
1759
+ if (answers.compactionMode !== '__keep__') {
1760
+ advancedUpdate.compactionMode = answers.compactionMode;
1761
+ }
1762
+
1763
+ await configManager.setAdvancedSettings(advancedUpdate);
628
1764
  displaySuccess('✅ 高级设置已更新');
629
1765
  }
630
1766
 
@@ -646,7 +1782,8 @@ async function viewConfig(configManager) {
646
1782
  console.log(` ${chalk.green('●')} ${r.name}`);
647
1783
  console.log(` URL: ${r.baseUrl}`);
648
1784
  console.log(` 模型: ${r.modelName} (${r.modelId})`);
649
- console.log(` 上下文: ${r.contextWindow} tokens`);
1785
+ const contextInfo = r.contextWindow ? `${r.contextWindow} tokens` : '-';
1786
+ console.log(` 上下文: ${contextInfo}`);
650
1787
  });
651
1788
 
652
1789
  console.log(chalk.yellow('\n高级设置:'));