mslxdff 0.1.63 → 0.1.65

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
@@ -295,11 +295,54 @@ if (args.includes("-model") || args.includes("-models")) {
295
295
  // provider filter: bare ids are opencode, prefixed are <provider>/...
296
296
  if (modelListProvider) {
297
297
  const prov = String(modelListProvider).toLowerCase();
298
- ids = ids.filter((id) => {
298
+ const filtered = ids.filter((id) => {
299
299
  const slash = String(id).indexOf("/");
300
300
  const p = slash > 0 ? String(id).slice(0, slash).toLowerCase() : "opencode";
301
301
  return p === prov;
302
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;
303
346
  if (modelListJson) {
304
347
  console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
305
348
  process.exit(0);
@@ -316,12 +359,42 @@ if (args.includes("-model") || args.includes("-models")) {
316
359
  console.log("no models available — try: mslxdff -model refresh");
317
360
  process.exit(0);
318
361
  }
319
- // TTY:交互式多选勾选常用模型(空格勾选,Enter 保存);非 TTY(管道/脚本):保持纯列表并标注勾选
320
- if (process.stdin.isTTY && process.stdout.isTTY && !modelListProvider) {
362
+ // TTY 交互式:`mslxdff -models`(无 list)走交互;` -model list` 默认列表,但交互入口统一为 -models
363
+ // 为支持“opencode + 其他供应商 allowlist 原名/别名”一起勾选,交互池 = opencode 免费池 + 各供应商 allowlist + 已勾选的遗留 picks
364
+ if (sub === undefined && process.stdin.isTTY && process.stdout.isTTY) {
321
365
  const statuses = loadModelErrors();
322
366
  const current = getPreferredModel();
323
367
  const pickedIds = loadModelPicks();
324
- 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) => {
325
398
  const e = statuses[id];
326
399
  return {
327
400
  id,
@@ -342,38 +415,158 @@ if (args.includes("-model") || args.includes("-models")) {
342
415
  const at = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
343
416
  const pickedIds = loadModelPicks();
344
417
  const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
345
- console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
346
- // 加载别名映射,显示原始名+别名
347
- let aliasMap = {};
348
- let fullAliases = {};
349
- try {
350
- const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
351
- loadModelAliases();
352
- for (const id of ids) {
353
- const alias = getAliasForModel(id);
354
- 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;
355
432
  }
356
- // 读取完整 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 = {};
357
439
  try {
358
- const aliasesFile = join(homedir(), ".config", "mslxdff", "model-aliases.json");
359
- const raw = JSON.parse(readFileSync(aliasesFile, "utf8"));
360
- 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
+ }
361
450
  } catch {}
362
- } catch {}
363
- for (const id of ids) {
364
- const alias = aliasMap[id];
365
- const aliasStr = alias ? ` (alias: ${alias})` : "";
366
- console.log(` ${mark(id)} ${id}${aliasStr}`);
367
- }
368
- // 额外展示本地别名(不在上游列表中的,如 clinebot/*)
369
- const aliasEntries = Object.entries(fullAliases).filter(([alias, canonical]) => !ids.includes(canonical));
370
- if (aliasEntries.length) {
371
- console.log(`\nlocal aliases (${aliasEntries.length}):`);
372
- for (const [alias, canonical] of aliasEntries) {
373
- 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
+ }
374
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`);
375
569
  }
376
- console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
377
570
  } catch (err) {
378
571
  console.error(`could not fetch models: ${String(err?.message || err)}`);
379
572
  process.exit(1);
@@ -764,17 +957,21 @@ if (args.includes("-provider") || args.includes("--provider")) {
764
957
  const sub = args[idx + 2];
765
958
  const rest = args.slice(idx + 2);
766
959
  if (!id) {
767
- 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]");
768
961
  console.error(" e.g. mslxdff -provider openrouter sk-1 sk-2 sk-3 set multiple keys (replaces all)");
769
962
  console.error(" mslxdff -provider openrouter add sk-4 append one key");
770
963
  console.error(" mslxdff -provider openrouter remove sk-1 remove a key by value");
771
964
  console.error(" mslxdff -provider openrouter list list all keys (masked)");
965
+ console.error(" mslxdff -provider openrouter models [--json] list provider models (allowlist filtered)");
772
966
  console.error(" mslxdff -provider openrouter share on|off share keys with peers on outgoing forward (ADR-0008)");
773
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");
774
970
  console.error(" mslxdff -provider openrouter allowlist set gpt-4 gpt-3.5 manage allowed models (empty=block unless allowAny ON)");
775
971
  console.error(" mslxdff -provider openrouter allowAny on|off empty allowlist = allow all or block all (default OFF, secure)");
776
972
  console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx add generic OpenAI-compatible provider");
777
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");
778
975
  console.error(" mslxdff -provider openrouter interactive hidden input (append)");
779
976
  console.error(" mslxdff -provider openrouter clear remove all keys");
780
977
  process.exit(1);
@@ -832,9 +1029,10 @@ if (args.includes("-provider") || args.includes("--provider")) {
832
1029
  const gBase = rest[1];
833
1030
  const gKey = rest[2];
834
1031
  if (!gid || !gBase || !gKey) {
835
- 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>]");
836
1033
  console.error(" e.g. mslxdff -provider add myapi https://api.example.com/v1 sk-xxx");
837
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");
838
1036
  process.exit(1);
839
1037
  }
840
1038
  if (gid === "opencode" || gid === "oc" || gid === "openrouter") {
@@ -1115,7 +1313,7 @@ if (args.includes("-provider") || args.includes("--provider")) {
1115
1313
  console.error(` mslxdff -provider ${id} allowAny on|off (empty allowlist = block or allow all)`);
1116
1314
  process.exit(1);
1117
1315
  }
1118
- if (sub === "models" || sub === "list-models" || sub === "ls") {
1316
+ if (sub === "models" || sub === "show-models" || sub === "list-models" || sub === "ls") {
1119
1317
  const wantsJson = args.includes("--json") || args.includes("-json");
1120
1318
  const cfg = loadProviderConfig(id);
1121
1319
  // opencode: use aggregated cache + provider-specific? For opencode, show bare ids from cache
@@ -1156,20 +1354,57 @@ if (args.includes("-provider") || args.includes("--provider")) {
1156
1354
  provider = createGenericProvider({ id, baseUrl, apiKeys: keys, file: defaultStateFile() });
1157
1355
  }
1158
1356
  const all = await provider.listModels();
1159
- // apply allowlist filtering mirroring dispatcher
1160
- const filtered = all.filter((m) => {
1161
- const raw = String(m.id || "").includes("/") ? String(m.id).split("/").slice(1).join("/") : String(m.id);
1162
- // for workbuddy, raw is like hy3, for generic it's raw id without prefix? joinModelId adds prefix, so need to extract raw
1163
- const checkRaw = m.id.startsWith(`${id}/`) ? m.id.slice(id.length + 1) : raw;
1357
+ // show 全部上游模型,仅标注是否被 allowlist 放行(不拦截展示)
1358
+ const markAllowed = (mid) => {
1359
+ const raw = String(mid || "").includes("/") ? String(mid).split("/").slice(1).join("/") : String(mid);
1360
+ const checkRaw = mid.startsWith(`${id}/`) ? mid.slice(id.length + 1) : raw;
1164
1361
  return isModelAllowed(id, checkRaw);
1165
- });
1362
+ };
1166
1363
  if (wantsJson) {
1364
+ // --json 仍按 allowlist 过滤(给脚本消费可用模型)
1365
+ const filtered = all.filter((m) => markAllowed(m.id));
1167
1366
  console.log(JSON.stringify({ object: "list", data: filtered }, null, 2));
1168
1367
  } else {
1169
- console.log(`${id} models (${filtered.length}${filtered.length !== all.length ? `/${all.length}` : ""}):`);
1170
- for (const m of filtered) console.log(` ${m.id}`);
1171
- if (!filtered.length && all.length) console.log(` (all ${all.length} filtered by allowlist — use: mslxdff -provider ${id} allowlist list)`);
1368
+ const allowedCount = all.filter((m) => markAllowed(m.id)).length;
1369
+ console.log(`${id} models (${all.length} total, ${allowedCount} allowed${allowedCount !== all.length ? `, ${all.length - allowedCount} x blocked by allowlist` : ""}):`);
1370
+ const fmtPrice = (m) => {
1371
+ const c = String(m.credits || "").trim();
1372
+ if (c) {
1373
+ // "x0.00 credits" / "x0.00" → 统一成 "x0.00"
1374
+ const m0 = c.match(/x\s*([\d.]+)/i);
1375
+ if (m0) return `x${m0[1]}`;
1376
+ return c.replace(/\s*credits\s*/gi, "").trim().replace(/\s+/g, " ");
1377
+ }
1378
+ if (m.pricing && typeof m.pricing === "object") {
1379
+ const p = m.pricing.prompt ?? m.pricing.input ?? m.pricing.completion ?? "";
1380
+ if (p) return String(p);
1381
+ }
1382
+ if (m.price != null && String(m.price).trim()) return String(m.price).trim();
1383
+ if (String(m.id).endsWith("/auto")) return "浮动";
1384
+ return "—";
1385
+ };
1386
+ const fmtBadge = (m) => {
1387
+ const tags = Array.isArray(m.tags) ? m.tags : [];
1388
+ const b = tags.find((t) => String(t).includes("限时免费") || String(t).toLowerCase().includes("free"));
1389
+ if (!b) return "";
1390
+ const part = String(b).split(":")[1];
1391
+ return part ? ` [${part}]` : ` [${b}]`;
1392
+ };
1393
+ // 按 credits 升序已在 provider 排好序,展示时对齐价格列便于分辨
1394
+ const idW = Math.max(22, ...all.map((m) => String(m.id).length)) + 2;
1395
+ const priceW = Math.max(6, ...all.map((m) => fmtPrice(m).length)) + 2;
1396
+ for (const m of all) {
1397
+ const ok = markAllowed(m.id);
1398
+ const price = fmtPrice(m);
1399
+ const badge = fmtBadge(m);
1400
+ const name = m.name ? ` ${m.name}` : "";
1401
+ const blocked = ok ? "" : " [blocked — allowlist]";
1402
+ const line = ` ${ok ? "✓" : "x"} ${String(m.id).padEnd(idW)}${String(price).padEnd(priceW)}${name}${badge}${blocked}`;
1403
+ console.log(line);
1404
+ }
1172
1405
  if (!all.length) console.log(` (no models — check baseUrl/keys or try: curl ${baseUrl}/models)`);
1406
+ else if (allowedCount === 0) console.log(` tip: all blocked — mslxdff -provider ${id} allowAny on 或 allowlist set <model...>`);
1407
+ else if (allowedCount !== all.length) console.log(` tip: blocked 仅影响 /v1/chat 调用,展示已全量列出`);
1173
1408
  }
1174
1409
  try { await provider.close?.(); } catch {}
1175
1410
  } catch (e) {
@@ -1317,7 +1552,8 @@ async function pickInteractive(items, startCursor = 0) {
1317
1552
  let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
1318
1553
  const draw = () => {
1319
1554
  const lines = [...renderChooser(items, cursor), ...renderChooserHelp()];
1320
- process.stdout.write("\x1b[G\x1b[J" + lines.join("\n"));
1555
+ // PowerShell 兼容:\x1b[2J 清屏 + \x1b[H 回到左上,比 \x1b[G\x1b[J 更可靠
1556
+ process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
1321
1557
  };
1322
1558
  draw();
1323
1559
  return new Promise((resolve) => {
@@ -1359,7 +1595,7 @@ async function pickInteractiveMulti(items, initialPicked = new Set(), startCurso
1359
1595
  const draw = () => {
1360
1596
  const rows = items.map((it, i) => ({ ...it, picked: picked.has(it.id) }));
1361
1597
  const lines = [...renderChooser(rows, cursor, { multi: true }), ...renderChooserHelp(true)];
1362
- process.stdout.write("\x1b[G\x1b[J" + lines.join("\n"));
1598
+ process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
1363
1599
  };
1364
1600
  draw();
1365
1601
  return new Promise((resolve) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
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"];
@@ -47,6 +47,11 @@ export function buildSystemPrompt({ modelsOverride } = {}) {
47
47
 
48
48
  ${mini}
49
49
 
50
+ 语言(最高优先级):
51
+ - 全部面向用户的自然语言回复**必须使用简体中文**(无论用户用英文/日文/拼音提问,都用中文回答)。
52
+ - 仅代码、命令、模型 id、路径、JSON 等技术标识保持原文,不做翻译。
53
+ - 禁止输出英文长段解释;中英文混排时中文为主。
54
+
50
55
  规则:
51
56
  - 用户说简称你必须自行查“可用模型”找到全称,例如 hy3→hy3-free,mimo→mimo-v2.5-free,bigpickle→big-pickle。
52
57
  - 永远输出精确的命令与模型 id,大小写敏感。
@@ -56,8 +61,8 @@ ${mini}
56
61
  - 严禁幻觉命令: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
62
  - **禁止重复调用(最高优先级)**:同一 run_command/curl/read_file 在本轮只执行一次,重复会被工具侧 SKIPPED_DUP 拦截;查询类(-showtoken/-status/-provider list/-providers list/-model list/-group list/-log 等)**调用一次即答案**,拿到 OK 结果后必须**立即用中文直接回答用户**,禁止再发起任何工具调用。收到 SKIPPED_DUP 或“请直接回答/禁止再调用”提示时,必须 0 工具直接回答。
58
63
  - 禁止调用 -uninstall,包含即拒绝;-showtoken 仅在用户明确要求查看/调试本机 token 时才用,查模型/查供应商严禁调用。
59
- - 回复用中文,简洁友好,执行前后说明你在做什么。
60
- - 若用户只是闲聊/提问且可用模型列表已能回答,不调工具,直接回答。`;
64
+ - 回复风格:简洁友好,执行前后用中文说明你在做什么。
65
+ - 若用户只是闲聊/提问且可用模型列表已能回答,不调工具,直接用中文回答。`;
61
66
  }
62
67
 
63
68
  export function getModelsForPrompt() {
package/src/chat/repl.js CHANGED
@@ -30,35 +30,7 @@ function trace(line) {
30
30
  console.log(`\x1b[90m· ${line}\x1b[0m`);
31
31
  }
32
32
 
33
- function extractProvFromQuery(text) {
34
- const low = String(text || "").toLowerCase();
35
- const known = ["workbuddy", "clinebot", "opencode", "bai", "openrouter", "poolside", "z-ai", "deepseek"];
36
- for (const k of known) if (low.includes(k)) return k;
37
- return null;
38
- }
39
- function isModelListQuery(text) {
40
- const low = String(text || "").toLowerCase();
41
- if (!low.includes("模型")) return false;
42
- return low.includes("哪些") || low.includes("可用") || low.includes("支持") || low.includes("列表") || low.includes("有啥") || low.includes("都有") || low.includes("可以") || low.includes("用");
43
- }
44
- function formatModelAnswer(prov, models) {
45
- const byProv = {};
46
- for (const id of models) {
47
- const slash = id.indexOf("/");
48
- const p = slash > 0 ? id.slice(0, slash) : "opencode";
49
- if (!byProv[p]) byProv[p] = [];
50
- byProv[p].push(id);
51
- }
52
- if (prov) {
53
- const list = byProv[prov] || models.filter((m) => m.toLowerCase().startsWith(prov.toLowerCase() + "/"));
54
- if (!list.length) return `**${prov}** 暂无可用模型(可能未配置或网关未聚合)。可用总量 ${models.length},按供应商:${Object.entries(byProv).map(([k, v]) => `${k}(${v.length})`).join(" | ")}`;
55
- // 更友好:直接列 id 和调用方式
56
- return `**${prov}** 可用模型(共 ${list.length} 个,网关已聚合):\n\n| 模型 id | 调用方式 |\n|:---|:---|\n${list.map((m) => `| \`${m}\` | \`${m}\` |`).join("\n")}\n\n> 提示:直接用 \`${prov}/<模型>\` 调用,例如 \`${list[0]}\``;
57
- }
58
- // 无指定供应商:按分组汇总
59
- const summary = Object.entries(byProv).map(([p, arr]) => `**${p}**(${arr.length}):${arr.slice(0, 8).join(", ")}${arr.length > 8 ? " …" : ""}`).join("\n");
60
- return `可用模型总计 ${models.length} 个,按供应商分组:\n\n${summary}\n\n> 查某供应商请说“workbuddy有哪些模型”`;
61
- }
33
+
62
34
 
63
35
  async function maybeCompress(messages) {
64
36
  if (!needsCompress(messages)) return [...messages];
@@ -85,23 +57,12 @@ async function maybeCompress(messages) {
85
57
  async function runAgentTurn(userText, messages) {
86
58
  const tools = getToolDefs();
87
59
  messages.push({ role: "user", content: userText });
88
- // Fast-path:模型列表类问题本地直答,不走 LLM,避免 “provider list” 幻觉和 6 轮重复
89
- if (isModelListQuery(userText)) {
90
- const prov = extractProvFromQuery(userText);
91
- try {
92
- const models = getModelsForPrompt();
93
- const answer = formatModelAnswer(prov, models);
94
- if (answer) {
95
- messages.push({ role: "assistant", content: answer });
96
- trace(`[fast] 模型列表直答 prov=${prov || "all"} 共 ${models.length} 个`);
97
- return { text: answer, model: "local", latency: 0, usage: null, fallback: false, ok: true, totalMs: 0 };
98
- }
99
- } catch {}
100
- }
101
60
  let loops = 0;
102
61
  let lastModel = null;
62
+ let lastProvider = null;
103
63
  let lastUsage = null;
104
64
  let lastFallback = false;
65
+ let lastFallbackGateway = false;
105
66
  let lastLatency = 0;
106
67
  const t0 = performance.now();
107
68
  const turnStart = performance.now();
@@ -145,8 +106,10 @@ async function runAgentTurn(userText, messages) {
145
106
  return { text: err, model: null, latency: lastLatency, usage: null, fallback: false, ok: false };
146
107
  }
147
108
  lastModel = res.model;
109
+ lastProvider = res.provider || null;
148
110
  lastUsage = res.usage || null;
149
111
  lastFallback = !!res.fallback;
112
+ lastFallbackGateway = !!res.fallbackGateway || !!res.viaGateway;
150
113
  const msg = res.message;
151
114
  const toolCalls = msg.tool_calls || [];
152
115
  let fallbackCmd = null;
@@ -158,9 +121,11 @@ async function runAgentTurn(userText, messages) {
158
121
  const text = String(msg.content || "").trim() || "(空回复)";
159
122
  messages.push({ role: "assistant", content: text });
160
123
  const totalMs = Math.round(performance.now() - t0);
161
- const note = lastFallback ? "\n\x1b[90m[注:mimo 不可用,已用 big-pickle]\x1b[0m" : "";
162
- trace(`[turn] 完成 总计 ${totalMs}ms · LLM ${lastLatency}ms · 0 工具`);
163
- return { text: text + note, model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
124
+ let note = "";
125
+ if (lastFallbackGateway) note = "\n\x1b[90m[注:mimo/big-pickle 均不可用,已自动切本地网关 auto(:8989)]\x1b[0m";
126
+ else if (lastFallback) note = "\n\x1b[90m[注:mimo 不可用,已用 big-pickle]\x1b[0m";
127
+ trace(`[turn] 完成 总计 ${totalMs}ms · LLM ${lastLatency}ms · 0 工具${lastFallbackGateway ? " · gateway-fallback" : ""}`);
128
+ return { text: text + note, model: lastModel, provider: lastProvider, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: true, totalMs };
164
129
  }
165
130
  const calls = toolCalls.length ? toolCalls : [{ id: "fallback-1", function: { name: "run_command", arguments: JSON.stringify({ command: fallbackCmd }) } }];
166
131
  messages.push({ role: "assistant", content: msg.content || "", tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: c.function })) });
@@ -254,7 +219,7 @@ async function runAgentTurn(userText, messages) {
254
219
  messages.push({ role: "assistant", content: synth });
255
220
  const totalMs = Math.round(performance.now() - t0);
256
221
  trace(`[turn] 提前结束(重复阈值) 总计 ${totalMs}ms`);
257
- return { text: synth, model: lastModel || "local", latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
222
+ return { text: synth, model: lastModel || "local", provider: lastProvider, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
258
223
  }
259
224
  }
260
225
  const toolsMs = Math.round(performance.now() - tTools);
@@ -262,19 +227,21 @@ async function runAgentTurn(userText, messages) {
262
227
  trace(`[loop ${loops}] 工具 ${toolsMs}ms · 本轮总 ${loopMs}ms · 累计 ${Math.round(performance.now() - turnStart)}ms`);
263
228
  loops++;
264
229
  }
265
- return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: false };
230
+ return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: false };
266
231
  }
267
232
 
268
- function printFooter({ model, latency, usage, totalMs, fallback }) {
233
+ function printFooter({ model, provider, latency, usage, totalMs, fallback, fallbackGateway, viaGateway }) {
269
234
  const dim = "\x1b[90m";
270
235
  const rst = "\x1b[0m";
271
236
  let gw = null;
272
237
  try { gw = collectStats(); } catch {}
273
- const modelLabel = model || "";
238
+ const prov = provider && provider !== "opencode" ? `${provider}/` : "";
239
+ const baseLabel = model ? `${prov}${model}` : "—";
240
+ const modelLabel = model ? (fallbackGateway || viaGateway ? `${baseLabel} (gateway auto)` : baseLabel) : "—";
274
241
  const latLabel = latency ? `${latency}ms` : "—";
275
242
  const totalLabel = totalMs ? ` · 总耗时 ${totalMs}ms` : "";
276
243
  const tokLabel = usage ? ` · tokens ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : "";
277
- const fbLabel = fallback ? " · fallback" : "";
244
+ const fbLabel = fallbackGateway || viaGateway ? " · gateway-fallback" : fallback ? " · fallback" : "";
278
245
  let gwLabel = "";
279
246
  let extra = "";
280
247
  if (gw) {
package/src/chat/stats.js CHANGED
@@ -150,7 +150,7 @@ export function formatBannerLines() {
150
150
  const green = "\x1b[32m";
151
151
  const lines = [];
152
152
  lines.push(`${cyan}┌─ mslxdff chat · 数据来自 -d 网关进程(非本会话) ─────${rst}`);
153
- lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall}(自动降级)${rst} ${dim}[${s.chatPrefStatus}/${s.chatFallStatus}]${rst}`);
153
+ lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall} → gateway auto:8989${rst} ${dim}[${s.chatPrefStatus}/${s.chatFallStatus}]${rst} ${dim}三级兜底${rst}`);
154
154
  lines.push(`${cyan}│${rst} 网关默认 ${green}${s.gatewayModel}${rst} ${dim}[${s.gatewayStatus}]${rst} · 端口 ${s.port} · ${dim}${s.endpointUrl}${rst}`);
155
155
  const prefTtfb = s.chatPrefStat?.avgTtfbMs ?? s.chatPrefStat?.emaTtfbMs ?? s.chatPrefLat?.emaMs;
156
156
  const fallTtfb = s.chatFallStat?.avgTtfbMs ?? s.chatFallStat?.emaTtfbMs ?? s.chatFallLat?.emaMs;