mslxdff 0.1.62 → 0.1.64

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/bin/mslxdff.js CHANGED
@@ -232,8 +232,25 @@ if (args.includes("-model") || args.includes("-models")) {
232
232
  for (const id of picks) console.log(` ${id}`);
233
233
  process.exit(0);
234
234
  }
235
+ // -model list supports optional provider filter: -model list --provider <id> | -model list <id> | --json
236
+ let modelListProvider = null;
237
+ let modelListJson = false;
238
+ if (sub === "list") {
239
+ const restArgs = args.slice(idx + 2);
240
+ for (let i = 0; i < restArgs.length; i++) {
241
+ const a = String(restArgs[i] || "");
242
+ if (a === "--json" || a === "-json") modelListJson = true;
243
+ else if (a === "--provider" || a === "-provider" || a === "--providerId") { modelListProvider = String(restArgs[i + 1] || "").trim() || null; i++; }
244
+ else if (!a.startsWith("-") && !modelListProvider) modelListProvider = a;
245
+ }
246
+ if (modelListProvider) {
247
+ const { normalizeProviderId } = await import("../src/providers/model-id.js");
248
+ const nid = normalizeProviderId(modelListProvider);
249
+ modelListProvider = nid || modelListProvider.toLowerCase();
250
+ }
251
+ }
235
252
  if (sub !== undefined && sub !== "list") {
236
- console.error("usage: mslxdff -models (interactive multi-pick) | mslxdff -model list | mslxdff -model set <id> | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear | mslxdff -model picks | mslxdff -model status | mslxdff -model refresh");
253
+ console.error("usage: mslxdff -models (interactive multi-pick) | mslxdff -model list [--provider <id>] [--json] | mslxdff -model set <id> | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear | mslxdff -model picks | mslxdff -model status | mslxdff -model refresh");
237
254
  process.exit(1);
238
255
  }
239
256
  const cacheFile = join(logDir(), "models.json");
@@ -275,16 +292,109 @@ if (args.includes("-model") || args.includes("-models")) {
275
292
  throw new Error("no cached models and refresh failed");
276
293
  }
277
294
  }
295
+ // provider filter: bare ids are opencode, prefixed are <provider>/...
296
+ if (modelListProvider) {
297
+ const prov = String(modelListProvider).toLowerCase();
298
+ const filtered = ids.filter((id) => {
299
+ const slash = String(id).indexOf("/");
300
+ const p = slash > 0 ? String(id).slice(0, slash).toLowerCase() : "opencode";
301
+ return p === prov;
302
+ });
303
+ // 非 opencode 供应商的模型不在 opencode 缓存中,改从 allowlist 展示(原名+别名)
304
+ if (prov !== "opencode" && filtered.length === 0) {
305
+ try {
306
+ const { loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../src/state.js");
307
+ const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
308
+ try { loadModelAliases(); } catch {}
309
+ const allowed = loadProviderAllowedModels(prov);
310
+ const allowAny = loadProviderAllowAnyModels(prov);
311
+ const baseUrl = loadProviderBaseUrl(prov);
312
+ if (modelListJson) {
313
+ const data = allowed.length
314
+ ? allowed.map((raw) => ({ id: `${prov}/${raw}`, object: "model" }))
315
+ : [];
316
+ console.log(JSON.stringify({ object: "list", data }, null, 2));
317
+ process.exit(0);
318
+ }
319
+ if (!allowed.length) {
320
+ if (allowAny) {
321
+ console.log(`provider "${prov}" allowAny ON (allowlist 空=放行全部)${baseUrl ? ` baseUrl=${baseUrl}` : ""}`);
322
+ console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${prov} models`);
323
+ } else {
324
+ console.log(`no models for provider "${prov}" — allowlist 空 + allowAny OFF = 阻塞`);
325
+ console.log(` 设白名单: mslxdff -provider ${prov} allowlist set <model1> <model2> 或 mslxdff -provider ${prov} allowAny on`);
326
+ console.log(` live 查看: mslxdff -provider ${prov} models`);
327
+ }
328
+ process.exit(0);
329
+ }
330
+ const at2 = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
331
+ console.log(`${allowed.length} model(s) for ${prov}${at2} (allowlist,原名 + 别名):`);
332
+ const pickedIds2 = loadModelPicks();
333
+ for (const raw of allowed) {
334
+ const canonical = `${prov}/${raw}`;
335
+ let alias = null;
336
+ try { alias = getAliasForModel(canonical); } catch {}
337
+ if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
338
+ const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
339
+ const mark2 = pickedIds2.includes(canonical) || (alias && pickedIds2.includes(alias)) ? "*" : " ";
340
+ console.log(` ${mark2} ${canonical}${aliasStr}`);
341
+ }
342
+ process.exit(0);
343
+ } catch {}
344
+ }
345
+ ids = filtered;
346
+ if (modelListJson) {
347
+ console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
348
+ process.exit(0);
349
+ }
350
+ if (!ids.length) {
351
+ console.log(`no models for provider "${prov}" — try: mslxdff -provider ${prov} models or mslxdff -model refresh`);
352
+ process.exit(0);
353
+ }
354
+ } else if (modelListJson) {
355
+ console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
356
+ process.exit(0);
357
+ }
278
358
  if (!ids.length) {
279
359
  console.log("no models available — try: mslxdff -model refresh");
280
360
  process.exit(0);
281
361
  }
282
- // TTY:交互式多选勾选常用模型(空格勾选,Enter 保存);非 TTY(管道/脚本):保持纯列表并标注勾选
283
- if (process.stdin.isTTY && process.stdout.isTTY) {
362
+ // TTY 交互式:`mslxdff -models`(无 list)走交互;` -model list` 默认列表,但交互入口统一为 -models
363
+ // 为支持“opencode + 其他供应商 allowlist 原名/别名”一起勾选,交互池 = opencode 免费池 + 各供应商 allowlist + 已勾选的遗留 picks
364
+ if (sub === undefined && process.stdin.isTTY && process.stdout.isTTY) {
284
365
  const statuses = loadModelErrors();
285
366
  const current = getPreferredModel();
286
367
  const pickedIds = loadModelPicks();
287
- const items = ids.map((id) => {
368
+ // 基础:opencode 免费池
369
+ const combinedIds = [...ids];
370
+ const seen = new Set(combinedIds);
371
+ try {
372
+ const { loadProviderConfigs, loadProviderAllowedModels } = await import("../src/state.js");
373
+ const configs = loadProviderConfigs();
374
+ for (const pid of Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode")) {
375
+ const allowed = loadProviderAllowedModels(pid);
376
+ for (const raw of allowed) {
377
+ const canonical = `${pid}/${raw}`;
378
+ if (!seen.has(canonical)) {
379
+ seen.add(canonical);
380
+ combinedIds.push(canonical);
381
+ }
382
+ // 别名(dash 版)若已被 picks 选中,也确保可见(便于取消)
383
+ const aliasDash = String(canonical).replace(/\//g, "-");
384
+ if (aliasDash !== canonical && pickedIds.includes(aliasDash) && !seen.has(aliasDash)) {
385
+ // 不直接加入 alias 作为独立选项,保留 canonical 即可(canonical 与 alias 视为同一模型,勾选 canonical)
386
+ }
387
+ }
388
+ }
389
+ } catch {}
390
+ // 已勾选但不在上述池中的(例如 workbuddy/* 当 allowAny ON 时无 allowlist,或历史 picks),补齐以便取消
391
+ for (const pid of pickedIds) {
392
+ if (!seen.has(pid)) {
393
+ seen.add(pid);
394
+ combinedIds.push(pid);
395
+ }
396
+ }
397
+ const items = combinedIds.map((id) => {
288
398
  const e = statuses[id];
289
399
  return {
290
400
  id,
@@ -305,38 +415,158 @@ if (args.includes("-model") || args.includes("-models")) {
305
415
  const at = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
306
416
  const pickedIds = loadModelPicks();
307
417
  const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
308
- console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
309
- // 加载别名映射,显示原始名+别名
310
- let aliasMap = {};
311
- let fullAliases = {};
312
- try {
313
- const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
314
- loadModelAliases();
315
- for (const id of ids) {
316
- const alias = getAliasForModel(id);
317
- if (alias) aliasMap[id] = alias;
418
+ // 分组展示:按供应商前缀分组,opencode id 归为 opencode
419
+ const groups = {};
420
+ for (const id of ids) {
421
+ const prov = String(id).includes("/") ? String(id).split("/")[0] : "opencode";
422
+ if (!groups[prov]) groups[prov] = [];
423
+ groups[prov].push(id);
424
+ }
425
+ const order = ["opencode", "workbuddy", "clinebot", "openrouter"];
426
+ const sortedProvs = Object.keys(groups).sort((a, b) => {
427
+ const ia = order.indexOf(a), ib = order.indexOf(b);
428
+ if (ia !== -1 || ib !== -1) {
429
+ if (ia === -1) return 1;
430
+ if (ib === -1) return -1;
431
+ return ia - ib;
318
432
  }
319
- // 读取完整 alias 表(用于展示本地别名,如 clinebot/*)
433
+ return a.localeCompare(b);
434
+ });
435
+ if (modelListProvider) {
436
+ console.log(`${ids.length} model(s) for ${modelListProvider}${at} (${pickedIds.length} picked, * = picked):`);
437
+ // 加载别名映射,显示原始名+别名
438
+ let aliasMap = {};
320
439
  try {
321
- const aliasesFile = join(homedir(), ".config", "mslxdff", "model-aliases.json");
322
- const raw = JSON.parse(readFileSync(aliasesFile, "utf8"));
323
- if (raw && typeof raw === "object") fullAliases = raw;
440
+ const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
441
+ loadModelAliases();
442
+ for (const id of ids) {
443
+ const alias = getAliasForModel(id);
444
+ if (alias) aliasMap[id] = alias;
445
+ else if (String(id).includes("/")) {
446
+ const dashAlias = String(id).replace(/\//g, "-");
447
+ if (dashAlias !== id) aliasMap[id] = dashAlias;
448
+ }
449
+ }
324
450
  } catch {}
325
- } catch {}
326
- for (const id of ids) {
327
- const alias = aliasMap[id];
328
- const aliasStr = alias ? ` (alias: ${alias})` : "";
329
- console.log(` ${mark(id)} ${id}${aliasStr}`);
330
- }
331
- // 额外展示本地别名(不在上游列表中的,如 clinebot/*)
332
- const aliasEntries = Object.entries(fullAliases).filter(([alias, canonical]) => !ids.includes(canonical));
333
- if (aliasEntries.length) {
334
- console.log(`\nlocal aliases (${aliasEntries.length}):`);
335
- for (const [alias, canonical] of aliasEntries) {
336
- console.log(` ${canonical} => ${alias}`);
451
+ for (const prov of sortedProvs) {
452
+ const list = groups[prov];
453
+ console.log(`\n ── ${prov} (${list.length}) ──`);
454
+ for (const id of list) {
455
+ const alias = aliasMap[id];
456
+ const aliasStr = alias ? ` (别名: ${alias})` : "";
457
+ console.log(` ${mark(id)} ${id}${aliasStr}`);
458
+ }
337
459
  }
460
+ console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
461
+ } else {
462
+ console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
463
+ // 加载别名映射,显示原始名+别名
464
+ let aliasMap = {};
465
+ let fullAliases = {};
466
+ try {
467
+ const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
468
+ loadModelAliases();
469
+ for (const id of ids) {
470
+ const alias = getAliasForModel(id);
471
+ if (alias) aliasMap[id] = alias;
472
+ else if (String(id).includes("/")) {
473
+ const dashAlias = String(id).replace(/\//g, "-");
474
+ if (dashAlias !== id) aliasMap[id] = dashAlias;
475
+ }
476
+ }
477
+ try {
478
+ const aliasesFile = join(homedir(), ".config", "mslxdff", "model-aliases.json");
479
+ const raw = JSON.parse(readFileSync(aliasesFile, "utf8"));
480
+ if (raw && typeof raw === "object") fullAliases = raw;
481
+ } catch {}
482
+ } catch {}
483
+ for (const prov of sortedProvs) {
484
+ const list = groups[prov];
485
+ console.log(`\n ── ${prov} (${list.length}) ──`);
486
+ for (const id of list) {
487
+ const alias = aliasMap[id];
488
+ const aliasStr = alias ? ` (别名: ${alias})` : "";
489
+ console.log(` ${mark(id)} ${id}${aliasStr}`);
490
+ }
491
+ }
492
+ // ── 分隔 + 其他供应商 allowlist(原名 + 别名) ──
493
+ // 其余供应商的可用模型由 allowlist 决定(空 allowlist + allowAny OFF = 阻塞),此处直接展示 allowlist 条目与别名,不做网络拉取,速度快
494
+ try {
495
+ const { loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../src/state.js");
496
+ const { loadModelAliases: _la2, getAliasForModel: _gaf } = await import("../src/providers/model-id.js");
497
+ try { _la2(); } catch {}
498
+ const configs = loadProviderConfigs();
499
+ const otherIds = Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode");
500
+ // 仅展示已配置过的供应商(有 baseUrl/keys/allowlist 任一),按固定顺序 + 字典序
501
+ const order2 = ["workbuddy", "clinebot", "openrouter", "bai"];
502
+ otherIds.sort((a, b) => {
503
+ const ia = order2.indexOf(a), ib = order2.indexOf(b);
504
+ if (ia !== -1 || ib !== -1) {
505
+ if (ia === -1) return 1;
506
+ if (ib === -1) return -1;
507
+ return ia - ib;
508
+ }
509
+ return a.localeCompare(b);
510
+ });
511
+ if (otherIds.length) {
512
+ console.log(`\n────────────────────────────────────────`);
513
+ console.log(`其他供应商 (allowlist,原名 + 别名) (${otherIds.length} providers):`);
514
+ for (const pid of otherIds) {
515
+ const allowed = loadProviderAllowedModels(pid);
516
+ const allowAny = loadProviderAllowAnyModels(pid);
517
+ const baseUrl = loadProviderBaseUrl(pid) || configs[pid]?.baseUrl || "";
518
+ const header = allowAny
519
+ ? (allowed.length ? `allowlist ${allowed.length} (allowAny ON)` : `allowAny ON (allowlist 空=放行全部)`)
520
+ : (allowed.length ? `allowlist ${allowed.length} (allowAny OFF)` : `allowlist 空 + allowAny OFF = 阻塞`);
521
+ console.log(`\n ── ${pid} (${header})${baseUrl ? ` baseUrl=${baseUrl}` : ""} ──`);
522
+ if (!allowed.length) {
523
+ if (allowAny) {
524
+ console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${pid} models`);
525
+ console.log(` 限制可用模型: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
526
+ } else {
527
+ console.log(` 阻塞中:无可用模型 — 设白名单: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
528
+ console.log(` 或放行全部: mslxdff -provider ${pid} allowAny on`);
529
+ }
530
+ } else {
531
+ for (const raw of allowed) {
532
+ const canonical = `${pid}/${raw}`;
533
+ let alias = null;
534
+ try { alias = _gaf(canonical); } catch {}
535
+ if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
536
+ const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
537
+ const pickedMark = pickedIds.includes(canonical) || pickedIds.includes(alias || "") ? "*" : " ";
538
+ console.log(` ${pickedMark} ${canonical}${aliasStr}`);
539
+ }
540
+ console.log(` 管理: mslxdff -provider ${pid} allowlist [list|add|remove|clear] | allowAny on|off`);
541
+ }
542
+ }
543
+ } else {
544
+ console.log(`\n────────────────────────────────────────`);
545
+ console.log(`其他供应商 (allowlist,原名 + 别名): (none — 尚未配置)`);
546
+ console.log(` 添加示例: mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models`);
547
+ }
548
+ // 额外兜底:本地别名中不在任何 allowlist 里的,单独提示
549
+ const aliasEntries = Object.entries(fullAliases).filter(([alias, canonical]) => {
550
+ if (ids.includes(canonical)) return false;
551
+ // 已在 allowlist 中展示过的别名不重复
552
+ for (const pid of otherIds) {
553
+ const allowed = loadProviderAllowedModels(pid);
554
+ for (const raw of allowed) {
555
+ const can = `${pid}/${raw}`;
556
+ if (can === canonical) return false;
557
+ }
558
+ }
559
+ return true;
560
+ });
561
+ if (aliasEntries.length) {
562
+ console.log(`\n 本地别名 (不在 allowlist 里的遗留映射 ${aliasEntries.length}):`);
563
+ for (const [alias, canonical] of aliasEntries) {
564
+ console.log(` ${canonical} => ${alias}`);
565
+ }
566
+ }
567
+ } catch {}
568
+ console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
338
569
  }
339
- console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
340
570
  } catch (err) {
341
571
  console.error(`could not fetch models: ${String(err?.message || err)}`);
342
572
  process.exit(1);
@@ -727,17 +957,21 @@ if (args.includes("-provider") || args.includes("--provider")) {
727
957
  const sub = args[idx + 2];
728
958
  const rest = args.slice(idx + 2);
729
959
  if (!id) {
730
- console.error("usage: mslxdff -provider <id> [key...|add|remove|list|clear|share|set-url|allowlist|allowAny]");
960
+ console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|share|set-url|allowlist|allowAny]");
731
961
  console.error(" e.g. mslxdff -provider openrouter sk-1 sk-2 sk-3 set multiple keys (replaces all)");
732
962
  console.error(" mslxdff -provider openrouter add sk-4 append one key");
733
963
  console.error(" mslxdff -provider openrouter remove sk-1 remove a key by value");
734
964
  console.error(" mslxdff -provider openrouter list list all keys (masked)");
965
+ console.error(" mslxdff -provider openrouter models [--json] list provider models (allowlist filtered)");
735
966
  console.error(" mslxdff -provider openrouter share on|off share keys with peers on outgoing forward (ADR-0008)");
736
967
  console.error(" mslxdff -provider openrouter set-url https://api.example.com/v1");
968
+ console.error(" mslxdff -provider openrouter set-models-path /v1/models");
969
+ console.error(" mslxdff -provider openrouter set-chat-path /v1/chat/completions");
737
970
  console.error(" mslxdff -provider openrouter allowlist set gpt-4 gpt-3.5 manage allowed models (empty=block unless allowAny ON)");
738
971
  console.error(" mslxdff -provider openrouter allowAny on|off empty allowlist = allow all or block all (default OFF, secure)");
739
972
  console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx add generic OpenAI-compatible provider");
740
973
  console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 add with allowlist");
974
+ console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models --chat-path /v1/chat/completions");
741
975
  console.error(" mslxdff -provider openrouter interactive hidden input (append)");
742
976
  console.error(" mslxdff -provider openrouter clear remove all keys");
743
977
  process.exit(1);
@@ -795,9 +1029,10 @@ if (args.includes("-provider") || args.includes("--provider")) {
795
1029
  const gBase = rest[1];
796
1030
  const gKey = rest[2];
797
1031
  if (!gid || !gBase || !gKey) {
798
- console.error("usage: mslxdff -provider add <id> <baseUrl> <key> [allowedModel...]");
1032
+ console.error("usage: mslxdff -provider add <id> <baseUrl> <key> [allowedModel...] [--models-path <path>] [--chat-path <path>]");
799
1033
  console.error(" e.g. mslxdff -provider add myapi https://api.example.com/v1 sk-xxx");
800
1034
  console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 gpt-3.5");
1035
+ console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models --chat-path /v1/chat/completions");
801
1036
  process.exit(1);
802
1037
  }
803
1038
  if (gid === "opencode" || gid === "oc" || gid === "openrouter") {
@@ -809,10 +1044,24 @@ if (args.includes("-provider") || args.includes("--provider")) {
809
1044
  const nid = normalizeProviderId(gid);
810
1045
  if (!nid) { console.error(`invalid provider id: ${gid}`); process.exit(1); }
811
1046
  if (!/^https?:\/\/.+/.test(String(gBase).trim())) { console.error(`invalid baseUrl: ${gBase} (must start with http:// or https://)`); process.exit(1); }
812
- const cur = loadProviderConfig(nid) || { baseUrl: "", keys: [], allowedModels: [], auths: [] };
1047
+ const cur = loadProviderConfig(nid) || { baseUrl: "", keys: [], allowedModels: [], auths: [], modelsPath: "", chatPath: "" };
813
1048
  let keys, auths, baseUrl;
814
1049
  baseUrl = String(gBase).trim();
815
- const extraModels = rest.slice(3).filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
1050
+ // parse --models-path / --chat-path from tail
1051
+ let parsedModelsPath = null;
1052
+ let parsedChatPath = null;
1053
+ const extraTokens = [];
1054
+ for (let _i = 3; _i < rest.length; _i++) {
1055
+ const tok = String(rest[_i] || "");
1056
+ if (tok === "--models-path" || tok === "--modelsPath" || tok === "--models_path") { parsedModelsPath = String(rest[_i + 1] || "").trim() || null; _i++; }
1057
+ else if (tok.startsWith("--models-path=")) { parsedModelsPath = tok.slice("--models-path=".length).trim() || null; }
1058
+ else if (tok === "--chat-path" || tok === "--chatPath" || tok === "--chat_path") { parsedChatPath = String(rest[_i + 1] || "").trim() || null; _i++; }
1059
+ else if (tok.startsWith("--chat-path=")) { parsedChatPath = tok.slice("--chat-path=".length).trim() || null; }
1060
+ else extraTokens.push(tok);
1061
+ }
1062
+ if (parsedModelsPath && !String(parsedModelsPath).startsWith("/")) { console.error(`invalid --models-path: ${parsedModelsPath} (must start with /)`); process.exit(1); }
1063
+ if (parsedChatPath && !String(parsedChatPath).startsWith("/")) { console.error(`invalid --chat-path: ${parsedChatPath} (must start with /)`); process.exit(1); }
1064
+ const extraModels = extraTokens.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
816
1065
  const allowedModels = extraModels.length ? [...new Set([...(cur.allowedModels || []), ...extraModels])] : (cur.allowedModels || []);
817
1066
  if (nid === "workbuddy") {
818
1067
  // workbuddy: keys/auths 一一对应,需解析 uid
@@ -838,7 +1087,12 @@ if (args.includes("-provider") || args.includes("--provider")) {
838
1087
  while (newKeys.length < newAuths.length) newKeys.push(token);
839
1088
  }
840
1089
  keys = newKeys; auths = newAuths;
841
- saveProviderConfig(nid, { baseUrl, keys, auths, allowedModels });
1090
+ const cfgToSave = { baseUrl, keys, auths, allowedModels };
1091
+ if (parsedModelsPath) cfgToSave.modelsPath = parsedModelsPath;
1092
+ else if (cur.modelsPath) cfgToSave.modelsPath = cur.modelsPath;
1093
+ if (parsedChatPath) cfgToSave.chatPath = parsedChatPath;
1094
+ else if (cur.chatPath) cfgToSave.chatPath = cur.chatPath;
1095
+ saveProviderConfig(nid, cfgToSave);
842
1096
  // 同步写 auths/workbuddy-<uid>.json 供 checkin 使用
843
1097
  try {
844
1098
  const { writeFileSync, mkdirSync } = await import("node:fs");
@@ -860,7 +1114,12 @@ if (args.includes("-provider") || args.includes("--provider")) {
860
1114
  }
861
1115
  keys = [...new Set([...(cur.keys || []), trimmed].filter(Boolean))];
862
1116
  auths = undefined;
863
- saveProviderConfig(nid, { baseUrl, keys, allowedModels });
1117
+ const cfgToSave2 = { baseUrl, keys, allowedModels };
1118
+ if (parsedModelsPath) cfgToSave2.modelsPath = parsedModelsPath;
1119
+ else if (cur.modelsPath) cfgToSave2.modelsPath = cur.modelsPath;
1120
+ if (parsedChatPath) cfgToSave2.chatPath = parsedChatPath;
1121
+ else if (cur.chatPath) cfgToSave2.chatPath = cur.chatPath;
1122
+ saveProviderConfig(nid, cfgToSave2);
864
1123
  }
865
1124
  console.log(`added generic provider: ${nid}`);
866
1125
  console.log(` baseUrl: ${String(gBase).trim().replace(/\/+$/, "")}`);
@@ -880,6 +1139,40 @@ if (args.includes("-provider") || args.includes("--provider")) {
880
1139
  process.exit(0);
881
1140
  }
882
1141
  const { loadProviderKeys, saveProviderKeys, addProviderKey, removeProviderKeys, loadProviderShareKeys, saveProviderShareKeys, loadProviderConfig, saveProviderConfig, saveProviderBaseUrl, loadProviderConfigs } = await import("../src/state.js");
1142
+ if (sub === "set-models-path" || sub === "setModelsPath" || sub === "models-path") {
1143
+ const p = rest[1];
1144
+ if (!p) {
1145
+ console.error(`usage: mslxdff -provider ${id} set-models-path <path> (e.g. /v1/models)`);
1146
+ process.exit(1);
1147
+ }
1148
+ if (!String(p).trim().startsWith("/")) {
1149
+ console.error(`invalid modelsPath: ${p} (must start with /)`);
1150
+ process.exit(1);
1151
+ }
1152
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
1153
+ const { normalizeProviderId } = await import("../src/providers/model-id.js");
1154
+ const nid = normalizeProviderId(id);
1155
+ saveProviderConfig(nid || id, { baseUrl: cur.baseUrl || "", keys: cur.keys || [], modelsPath: String(p).trim() });
1156
+ console.log(`set ${nid || id} modelsPath: ${String(p).trim()} — restart daemon to activate`);
1157
+ process.exit(0);
1158
+ }
1159
+ if (sub === "set-chat-path" || sub === "setChatPath" || sub === "chat-path") {
1160
+ const p = rest[1];
1161
+ if (!p) {
1162
+ console.error(`usage: mslxdff -provider ${id} set-chat-path <path> (e.g. /v1/chat/completions)`);
1163
+ process.exit(1);
1164
+ }
1165
+ if (!String(p).trim().startsWith("/")) {
1166
+ console.error(`invalid chatPath: ${p} (must start with /)`);
1167
+ process.exit(1);
1168
+ }
1169
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
1170
+ const { normalizeProviderId } = await import("../src/providers/model-id.js");
1171
+ const nid = normalizeProviderId(id);
1172
+ saveProviderConfig(nid || id, { baseUrl: cur.baseUrl || "", keys: cur.keys || [], chatPath: String(p).trim() });
1173
+ console.log(`set ${nid || id} chatPath: ${String(p).trim()} — restart daemon to activate`);
1174
+ process.exit(0);
1175
+ }
883
1176
  if (sub === "clear") {
884
1177
  const configs = loadProviderConfigs();
885
1178
  if (configs[id]) {
@@ -1020,6 +1313,69 @@ if (args.includes("-provider") || args.includes("--provider")) {
1020
1313
  console.error(` mslxdff -provider ${id} allowAny on|off (empty allowlist = block or allow all)`);
1021
1314
  process.exit(1);
1022
1315
  }
1316
+ if (sub === "models" || sub === "list-models" || sub === "ls") {
1317
+ const wantsJson = args.includes("--json") || args.includes("-json");
1318
+ const cfg = loadProviderConfig(id);
1319
+ // opencode: use aggregated cache + provider-specific? For opencode, show bare ids from cache
1320
+ if (id === "opencode" || id === "oc") {
1321
+ try {
1322
+ const cacheFile = join(logDir(), "models.json");
1323
+ const { readFileSync } = await import("node:fs");
1324
+ const raw = JSON.parse(readFileSync(cacheFile, "utf8"));
1325
+ const ids = (raw.data || []).map((m) => m.id).filter((x) => !String(x).includes("/"));
1326
+ if (wantsJson) {
1327
+ console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
1328
+ } else {
1329
+ console.log(`opencode models (${ids.length}):`);
1330
+ for (const mid of ids) console.log(` ${mid}`);
1331
+ }
1332
+ } catch (e) {
1333
+ console.error(`could not read models cache: ${String(e?.message || e)}`);
1334
+ process.exit(1);
1335
+ }
1336
+ process.exit(0);
1337
+ }
1338
+ // generic/workbuddy: live fetch via provider listModels
1339
+ try {
1340
+ const { createGenericProvider } = await import("../src/providers/generic.js");
1341
+ const { createWorkbuddyProvider } = await import("../src/providers/workbuddy.js");
1342
+ const { isModelAllowed } = await import("../src/state.js");
1343
+ const baseUrl = cfg?.baseUrl || (id === "workbuddy" ? "https://copilot.tencent.com" : "");
1344
+ const keys = loadProviderKeys(id);
1345
+ const auths = cfg?.auths || [];
1346
+ let provider;
1347
+ if (id === "workbuddy") {
1348
+ provider = createWorkbuddyProvider({ baseUrl, apiKeys: keys, auths, file: defaultStateFile() });
1349
+ } else {
1350
+ if (!baseUrl) {
1351
+ console.error(`provider ${id}: missing baseUrl — set via: mslxdff -provider ${id} set-url <baseUrl>`);
1352
+ process.exit(1);
1353
+ }
1354
+ provider = createGenericProvider({ id, baseUrl, apiKeys: keys, file: defaultStateFile() });
1355
+ }
1356
+ const all = await provider.listModels();
1357
+ // apply allowlist filtering mirroring dispatcher
1358
+ const filtered = all.filter((m) => {
1359
+ const raw = String(m.id || "").includes("/") ? String(m.id).split("/").slice(1).join("/") : String(m.id);
1360
+ // for workbuddy, raw is like hy3, for generic it's raw id without prefix? joinModelId adds prefix, so need to extract raw
1361
+ const checkRaw = m.id.startsWith(`${id}/`) ? m.id.slice(id.length + 1) : raw;
1362
+ return isModelAllowed(id, checkRaw);
1363
+ });
1364
+ if (wantsJson) {
1365
+ console.log(JSON.stringify({ object: "list", data: filtered }, null, 2));
1366
+ } else {
1367
+ console.log(`${id} models (${filtered.length}${filtered.length !== all.length ? `/${all.length}` : ""}):`);
1368
+ for (const m of filtered) console.log(` ${m.id}`);
1369
+ if (!filtered.length && all.length) console.log(` (all ${all.length} filtered by allowlist — use: mslxdff -provider ${id} allowlist list)`);
1370
+ if (!all.length) console.log(` (no models — check baseUrl/keys or try: curl ${baseUrl}/models)`);
1371
+ }
1372
+ try { await provider.close?.(); } catch {}
1373
+ } catch (e) {
1374
+ console.error(`could not list ${id} models: ${String(e?.message || e)}`);
1375
+ process.exit(1);
1376
+ }
1377
+ process.exit(0);
1378
+ }
1023
1379
  if (sub === "list" || sub === "status") {
1024
1380
  const keys = loadProviderKeys(id);
1025
1381
  const cfg = loadProviderConfig(id);
@@ -1159,7 +1515,8 @@ async function pickInteractive(items, startCursor = 0) {
1159
1515
  let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
1160
1516
  const draw = () => {
1161
1517
  const lines = [...renderChooser(items, cursor), ...renderChooserHelp()];
1162
- process.stdout.write("\x1b[G\x1b[J" + lines.join("\n"));
1518
+ // PowerShell 兼容:\x1b[2J 清屏 + \x1b[H 回到左上,比 \x1b[G\x1b[J 更可靠
1519
+ process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
1163
1520
  };
1164
1521
  draw();
1165
1522
  return new Promise((resolve) => {
@@ -1201,7 +1558,7 @@ async function pickInteractiveMulti(items, initialPicked = new Set(), startCurso
1201
1558
  const draw = () => {
1202
1559
  const rows = items.map((it, i) => ({ ...it, picked: picked.has(it.id) }));
1203
1560
  const lines = [...renderChooser(rows, cursor, { multi: true }), ...renderChooserHelp(true)];
1204
- process.stdout.write("\x1b[G\x1b[J" + lines.join("\n"));
1561
+ process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
1205
1562
  };
1206
1563
  draw();
1207
1564
  return new Promise((resolve) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auto.js CHANGED
@@ -85,6 +85,7 @@ function effectiveCooldown(entry, slowCooldownMs, cooldownMs) {
85
85
  function inCooldown(id, errors, now, cooldownMs, slowCooldownMs) {
86
86
  const e = normEntry(errors[id]);
87
87
  if (!e || !(e.at > 0)) return false;
88
+ if (e.status === MODEL_STATUS.NORMAL) return false;
88
89
  const cd = effectiveCooldown(e, slowCooldownMs, cooldownMs);
89
90
  return cd > 0 && now - e.at < cd;
90
91
  }
@@ -98,6 +99,16 @@ function normLatency(e) {
98
99
 
99
100
  export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0, slowCooldownMs = 0, latencies = {}, preferred } = {}) {
100
101
  const pref = preferred ?? getPreferredModel();
102
+ // 最近一次成功的模型(NORMAL 且非慢且 at 最大),用于“上次成功优先”——慢模型即使刚成功也不应钉死
103
+ let lastSuccessId = null;
104
+ let lastSuccessAt = 0;
105
+ for (const [id, e] of Object.entries(errors)) {
106
+ const ne = normEntry(e);
107
+ if (ne && ne.status === MODEL_STATUS.NORMAL && !ne.slow && ne.at > lastSuccessAt) {
108
+ lastSuccessAt = ne.at;
109
+ lastSuccessId = id;
110
+ }
111
+ }
101
112
  return [...new Set(ids)]
102
113
  .filter(Boolean)
103
114
  .map((id) => ({
@@ -105,12 +116,14 @@ export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0,
105
116
  e: normEntry(errors[id]),
106
117
  err: normEntry(errors[id])?.at ?? 0,
107
118
  isPreferred: id === pref,
119
+ isLastSuccess: id === lastSuccessId,
108
120
  cooling: inCooldown(id, errors, now, cooldownMs, slowCooldownMs),
109
121
  latency: normLatency(latencies[id]) ?? Number.MAX_SAFE_INTEGER,
110
122
  }))
111
123
  .sort(
112
124
  (a, b) =>
113
125
  (a.cooling ? 1 : 0) - (b.cooling ? 1 : 0) ||
126
+ (b.isLastSuccess ? 1 : 0) - (a.isLastSuccess ? 1 : 0) ||
114
127
  (b.isPreferred ? 1 : 0) - (a.isPreferred ? 1 : 0) ||
115
128
  a.latency - b.latency ||
116
129
  a.err - b.err
@@ -6,5 +6,6 @@ export const CHAT_HISTORY_MAX_CHARS = 400000;
6
6
  export const CHAT_KEEP_RECENT = 40;
7
7
  export const CHAT_SUMMARY_TRIGGER = 400000;
8
8
  export const CHAT_MAX_TOOL_LOOPS = 6;
9
- export const CHAT_TIMEOUT_MS = 30000;
9
+ export const CHAT_TIMEOUT_MS = 15000;
10
+ export const CHAT_GATEWAY_TIMEOUT_MS = 25000;
10
11
  export const FORBIDDEN = ["-uninstall", "--uninstall"];
@@ -52,7 +52,7 @@ ${mini}
52
52
  - 永远输出精确的命令与模型 id,大小写敏感。
53
53
  - 需要执行命令时调用 run_command,需要看文件时调用 read_file,需要检查网络/服务可用性时调用 curl。
54
54
  - curl 简写:upstream(=上游 https://opencode.ai/zen/v1/models)、local/health(=本机 /health)、local/models(=本机 /v1/models),也支持完整 http(s) URL;会自动补上游头、本机 token 与已配置供应商 key(直连 https://api.b.ai/v1/models 会自动带 bai 的 key,无需手动加头)。
55
- - 查“某供应商有哪些模型”**禁止调用 run_command**:**直接用上方“可用模型”按前缀过滤回答**(如 workbuddy/ 开头的即 workbuddy 供应商,clinebot/ 开头即 clinebot),无需调工具;如需实时刷新,调 curl local/models(GET,自动带本机 token)看网关聚合列表。**错误示例**:用户问 workbuddy有哪些模型 → 调用 -provider workbuddy list(这是查配置,不是查模型!)→ 错。**正确**:直接列 workbuddy/ 前缀的可用模型。禁止为此调用 -showtoken(本机 token 已自动注入)。
55
+ - 查“某供应商有哪些模型”**优先用 CLI 直查**:若上方“可用模型”已能回答,直接前缀过滤回答(如 workbuddy/ workbuddy);需实时拉取时调用 run_command: "-provider <id> models"(如 -provider workbuddy models)或 "-model list --provider <id>",按 allowlist 过滤,--json 供脚本。**禁止**调 -provider <id> list(这是查配置,不是查模型!)。**错误示例**:workbuddy有哪些模型 → -provider workbuddy list 错。**正确**:-provider workbuddy models。禁止为此调用 -showtoken
56
56
  - 严禁幻觉命令:mslxdff "hi" --model X / mslxdff --model X "hi" / mslxdff -chat --model X 都不存在,输出只会是 status 页。探活任意模型(含 clinebot/*、workbuddy/*、bai/*)必须用 curl POST http://localhost:8989/v1/chat/completions,body 为 {"model":"<前缀/模型>","messages":[{"role":"user","content":"hi"}],"stream":false},成功 200 + x-mslxdff-via:local 即通;401 代表本机 token 陈旧需提示 mslxdff -stop && mslxdff;403 + x-mslxdff-allowlist:1 代表白名单未放行需 allowlist add。
57
57
  - **禁止重复调用(最高优先级)**:同一 run_command/curl/read_file 在本轮只执行一次,重复会被工具侧 SKIPPED_DUP 拦截;查询类(-showtoken/-status/-provider list/-providers list/-model list/-group list/-log 等)**调用一次即答案**,拿到 OK 结果后必须**立即用中文直接回答用户**,禁止再发起任何工具调用。收到 SKIPPED_DUP 或“请直接回答/禁止再调用”提示时,必须 0 工具直接回答。
58
58
  - 禁止调用 -uninstall,包含即拒绝;-showtoken 仅在用户明确要求查看/调试本机 token 时才用,查模型/查供应商严禁调用。