mslxdff 0.1.60 → 0.1.63

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,12 +292,32 @@ 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
+ ids = 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
+ if (modelListJson) {
304
+ console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
305
+ process.exit(0);
306
+ }
307
+ if (!ids.length) {
308
+ console.log(`no models for provider "${prov}" — try: mslxdff -provider ${prov} models or mslxdff -model refresh`);
309
+ process.exit(0);
310
+ }
311
+ } else if (modelListJson) {
312
+ console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
313
+ process.exit(0);
314
+ }
278
315
  if (!ids.length) {
279
316
  console.log("no models available — try: mslxdff -model refresh");
280
317
  process.exit(0);
281
318
  }
282
319
  // TTY:交互式多选勾选常用模型(空格勾选,Enter 保存);非 TTY(管道/脚本):保持纯列表并标注勾选
283
- if (process.stdin.isTTY && process.stdout.isTTY) {
320
+ if (process.stdin.isTTY && process.stdout.isTTY && !modelListProvider) {
284
321
  const statuses = loadModelErrors();
285
322
  const current = getPreferredModel();
286
323
  const pickedIds = loadModelPicks();
@@ -809,10 +846,24 @@ if (args.includes("-provider") || args.includes("--provider")) {
809
846
  const nid = normalizeProviderId(gid);
810
847
  if (!nid) { console.error(`invalid provider id: ${gid}`); process.exit(1); }
811
848
  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: [] };
849
+ const cur = loadProviderConfig(nid) || { baseUrl: "", keys: [], allowedModels: [], auths: [], modelsPath: "", chatPath: "" };
813
850
  let keys, auths, baseUrl;
814
851
  baseUrl = String(gBase).trim();
815
- const extraModels = rest.slice(3).filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
852
+ // parse --models-path / --chat-path from tail
853
+ let parsedModelsPath = null;
854
+ let parsedChatPath = null;
855
+ const extraTokens = [];
856
+ for (let _i = 3; _i < rest.length; _i++) {
857
+ const tok = String(rest[_i] || "");
858
+ if (tok === "--models-path" || tok === "--modelsPath" || tok === "--models_path") { parsedModelsPath = String(rest[_i + 1] || "").trim() || null; _i++; }
859
+ else if (tok.startsWith("--models-path=")) { parsedModelsPath = tok.slice("--models-path=".length).trim() || null; }
860
+ else if (tok === "--chat-path" || tok === "--chatPath" || tok === "--chat_path") { parsedChatPath = String(rest[_i + 1] || "").trim() || null; _i++; }
861
+ else if (tok.startsWith("--chat-path=")) { parsedChatPath = tok.slice("--chat-path=".length).trim() || null; }
862
+ else extraTokens.push(tok);
863
+ }
864
+ if (parsedModelsPath && !String(parsedModelsPath).startsWith("/")) { console.error(`invalid --models-path: ${parsedModelsPath} (must start with /)`); process.exit(1); }
865
+ if (parsedChatPath && !String(parsedChatPath).startsWith("/")) { console.error(`invalid --chat-path: ${parsedChatPath} (must start with /)`); process.exit(1); }
866
+ const extraModels = extraTokens.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
816
867
  const allowedModels = extraModels.length ? [...new Set([...(cur.allowedModels || []), ...extraModels])] : (cur.allowedModels || []);
817
868
  if (nid === "workbuddy") {
818
869
  // workbuddy: keys/auths 一一对应,需解析 uid
@@ -838,7 +889,12 @@ if (args.includes("-provider") || args.includes("--provider")) {
838
889
  while (newKeys.length < newAuths.length) newKeys.push(token);
839
890
  }
840
891
  keys = newKeys; auths = newAuths;
841
- saveProviderConfig(nid, { baseUrl, keys, auths, allowedModels });
892
+ const cfgToSave = { baseUrl, keys, auths, allowedModels };
893
+ if (parsedModelsPath) cfgToSave.modelsPath = parsedModelsPath;
894
+ else if (cur.modelsPath) cfgToSave.modelsPath = cur.modelsPath;
895
+ if (parsedChatPath) cfgToSave.chatPath = parsedChatPath;
896
+ else if (cur.chatPath) cfgToSave.chatPath = cur.chatPath;
897
+ saveProviderConfig(nid, cfgToSave);
842
898
  // 同步写 auths/workbuddy-<uid>.json 供 checkin 使用
843
899
  try {
844
900
  const { writeFileSync, mkdirSync } = await import("node:fs");
@@ -860,7 +916,12 @@ if (args.includes("-provider") || args.includes("--provider")) {
860
916
  }
861
917
  keys = [...new Set([...(cur.keys || []), trimmed].filter(Boolean))];
862
918
  auths = undefined;
863
- saveProviderConfig(nid, { baseUrl, keys, allowedModels });
919
+ const cfgToSave2 = { baseUrl, keys, allowedModels };
920
+ if (parsedModelsPath) cfgToSave2.modelsPath = parsedModelsPath;
921
+ else if (cur.modelsPath) cfgToSave2.modelsPath = cur.modelsPath;
922
+ if (parsedChatPath) cfgToSave2.chatPath = parsedChatPath;
923
+ else if (cur.chatPath) cfgToSave2.chatPath = cur.chatPath;
924
+ saveProviderConfig(nid, cfgToSave2);
864
925
  }
865
926
  console.log(`added generic provider: ${nid}`);
866
927
  console.log(` baseUrl: ${String(gBase).trim().replace(/\/+$/, "")}`);
@@ -880,6 +941,40 @@ if (args.includes("-provider") || args.includes("--provider")) {
880
941
  process.exit(0);
881
942
  }
882
943
  const { loadProviderKeys, saveProviderKeys, addProviderKey, removeProviderKeys, loadProviderShareKeys, saveProviderShareKeys, loadProviderConfig, saveProviderConfig, saveProviderBaseUrl, loadProviderConfigs } = await import("../src/state.js");
944
+ if (sub === "set-models-path" || sub === "setModelsPath" || sub === "models-path") {
945
+ const p = rest[1];
946
+ if (!p) {
947
+ console.error(`usage: mslxdff -provider ${id} set-models-path <path> (e.g. /v1/models)`);
948
+ process.exit(1);
949
+ }
950
+ if (!String(p).trim().startsWith("/")) {
951
+ console.error(`invalid modelsPath: ${p} (must start with /)`);
952
+ process.exit(1);
953
+ }
954
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
955
+ const { normalizeProviderId } = await import("../src/providers/model-id.js");
956
+ const nid = normalizeProviderId(id);
957
+ saveProviderConfig(nid || id, { baseUrl: cur.baseUrl || "", keys: cur.keys || [], modelsPath: String(p).trim() });
958
+ console.log(`set ${nid || id} modelsPath: ${String(p).trim()} — restart daemon to activate`);
959
+ process.exit(0);
960
+ }
961
+ if (sub === "set-chat-path" || sub === "setChatPath" || sub === "chat-path") {
962
+ const p = rest[1];
963
+ if (!p) {
964
+ console.error(`usage: mslxdff -provider ${id} set-chat-path <path> (e.g. /v1/chat/completions)`);
965
+ process.exit(1);
966
+ }
967
+ if (!String(p).trim().startsWith("/")) {
968
+ console.error(`invalid chatPath: ${p} (must start with /)`);
969
+ process.exit(1);
970
+ }
971
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
972
+ const { normalizeProviderId } = await import("../src/providers/model-id.js");
973
+ const nid = normalizeProviderId(id);
974
+ saveProviderConfig(nid || id, { baseUrl: cur.baseUrl || "", keys: cur.keys || [], chatPath: String(p).trim() });
975
+ console.log(`set ${nid || id} chatPath: ${String(p).trim()} — restart daemon to activate`);
976
+ process.exit(0);
977
+ }
883
978
  if (sub === "clear") {
884
979
  const configs = loadProviderConfigs();
885
980
  if (configs[id]) {
@@ -1020,6 +1115,69 @@ if (args.includes("-provider") || args.includes("--provider")) {
1020
1115
  console.error(` mslxdff -provider ${id} allowAny on|off (empty allowlist = block or allow all)`);
1021
1116
  process.exit(1);
1022
1117
  }
1118
+ if (sub === "models" || sub === "list-models" || sub === "ls") {
1119
+ const wantsJson = args.includes("--json") || args.includes("-json");
1120
+ const cfg = loadProviderConfig(id);
1121
+ // opencode: use aggregated cache + provider-specific? For opencode, show bare ids from cache
1122
+ if (id === "opencode" || id === "oc") {
1123
+ try {
1124
+ const cacheFile = join(logDir(), "models.json");
1125
+ const { readFileSync } = await import("node:fs");
1126
+ const raw = JSON.parse(readFileSync(cacheFile, "utf8"));
1127
+ const ids = (raw.data || []).map((m) => m.id).filter((x) => !String(x).includes("/"));
1128
+ if (wantsJson) {
1129
+ console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
1130
+ } else {
1131
+ console.log(`opencode models (${ids.length}):`);
1132
+ for (const mid of ids) console.log(` ${mid}`);
1133
+ }
1134
+ } catch (e) {
1135
+ console.error(`could not read models cache: ${String(e?.message || e)}`);
1136
+ process.exit(1);
1137
+ }
1138
+ process.exit(0);
1139
+ }
1140
+ // generic/workbuddy: live fetch via provider listModels
1141
+ try {
1142
+ const { createGenericProvider } = await import("../src/providers/generic.js");
1143
+ const { createWorkbuddyProvider } = await import("../src/providers/workbuddy.js");
1144
+ const { isModelAllowed } = await import("../src/state.js");
1145
+ const baseUrl = cfg?.baseUrl || (id === "workbuddy" ? "https://copilot.tencent.com" : "");
1146
+ const keys = loadProviderKeys(id);
1147
+ const auths = cfg?.auths || [];
1148
+ let provider;
1149
+ if (id === "workbuddy") {
1150
+ provider = createWorkbuddyProvider({ baseUrl, apiKeys: keys, auths, file: defaultStateFile() });
1151
+ } else {
1152
+ if (!baseUrl) {
1153
+ console.error(`provider ${id}: missing baseUrl — set via: mslxdff -provider ${id} set-url <baseUrl>`);
1154
+ process.exit(1);
1155
+ }
1156
+ provider = createGenericProvider({ id, baseUrl, apiKeys: keys, file: defaultStateFile() });
1157
+ }
1158
+ 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;
1164
+ return isModelAllowed(id, checkRaw);
1165
+ });
1166
+ if (wantsJson) {
1167
+ console.log(JSON.stringify({ object: "list", data: filtered }, null, 2));
1168
+ } 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)`);
1172
+ if (!all.length) console.log(` (no models — check baseUrl/keys or try: curl ${baseUrl}/models)`);
1173
+ }
1174
+ try { await provider.close?.(); } catch {}
1175
+ } catch (e) {
1176
+ console.error(`could not list ${id} models: ${String(e?.message || e)}`);
1177
+ process.exit(1);
1178
+ }
1179
+ process.exit(0);
1180
+ }
1023
1181
  if (sub === "list" || sub === "status") {
1024
1182
  const keys = loadProviderKeys(id);
1025
1183
  const cfg = loadProviderConfig(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.60",
3
+ "version": "0.1.63",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- - 查“某供应商有哪些模型”:优先直接用上方“可用模型”按前缀过滤回答(如 bai/ 开头的即 bai 供应商),无需调工具;如需实时刷新,调 curl local/models(GET,自动带本机 token)看网关聚合列表,或 curl https://api.b.ai/v1/models(自动带 key)看上游全量。禁止为此调用 -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 时才用,查模型/查供应商严禁调用。
package/src/chat/repl.js CHANGED
@@ -30,6 +30,36 @@ 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
+ }
62
+
33
63
  async function maybeCompress(messages) {
34
64
  if (!needsCompress(messages)) return [...messages];
35
65
  const sys = messages[0];
@@ -55,6 +85,19 @@ async function maybeCompress(messages) {
55
85
  async function runAgentTurn(userText, messages) {
56
86
  const tools = getToolDefs();
57
87
  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
+ }
58
101
  let loops = 0;
59
102
  let lastModel = null;
60
103
  let lastUsage = null;
@@ -64,6 +107,8 @@ async function runAgentTurn(userText, messages) {
64
107
  const turnStart = performance.now();
65
108
  // 同轮去重:同一工具+参数只真正执行一次,重复直接复用并提示 LLM
66
109
  const seenCalls = new Map(); // key -> { count, firstResult }
110
+ let duplicateStrikes = 0;
111
+ let forceNoTools = false;
67
112
  trace(`[turn] 开始 "${userText.slice(0, 60)}${userText.length > 60 ? "…" : ""}" · 历史 ${messages.length}条 约 ${estimateChars(messages)}字`);
68
113
  while (loops < CHAT_MAX_TOOL_LOOPS) {
69
114
  const tLoop = performance.now();
@@ -74,12 +119,19 @@ async function runAgentTurn(userText, messages) {
74
119
  messages.length = 0;
75
120
  for (const m of cur) messages.push(m);
76
121
  const tCall = performance.now();
77
- const spinnerLabel = loops === 0 ? "已发送给 AI,等待回复中" : "AI 正在整理回复中";
122
+ const spinnerLabel = loops === 0 ? "已发送给 AI,等待回复中" : forceNoTools ? "AI 整理回答中(已禁工具)" : "AI 正在整理回复中";
78
123
  const spinner = createSpinner(spinnerLabel);
79
124
  spinner.start();
80
125
  let res;
81
126
  try {
82
- res = await chatWithFallback({ messages, tools });
127
+ const activeTools = forceNoTools ? [] : tools;
128
+ res = await chatWithFallback({ messages, tools: activeTools });
129
+ if (forceNoTools && res.ok && res.message?.tool_calls?.length) {
130
+ // LLM 在禁工具模式下仍尝试调工具,视为违规,直接转文本
131
+ trace(`[guard] 禁工具模式下仍收到 tool_calls,已拦截`);
132
+ res.message.tool_calls = [];
133
+ if (!res.message.content) res.message.content = "(已拦截违规工具调用,请基于已有结果直接回答)";
134
+ }
83
135
  } finally {
84
136
  const ms = Math.round(performance.now() - tCall);
85
137
  spinner.stop(`\x1b[90m✓ AI 已回复 · ${ms}ms\x1b[0m`);
@@ -149,13 +201,18 @@ async function runAgentTurn(userText, messages) {
149
201
  console.log(`\x1b[90m→ 执行: mslxdff ${cmd}\x1b[0m`);
150
202
  const r = await execCommand(cmd);
151
203
  result = `${r.ok ? "OK" : "FAIL"}: ${r.output}`;
152
- // 查询类命令直接在结果里植入“立即回答”锚点,降低 LLM 再发一次的概率
204
+ // 查询类命令直接在结果里植入“立即回答”锚点,降低 LLM 再发一次的概率;若用户问模型,则 provider list 不算答案
153
205
  const lowCmd = cmd.toLowerCase().replace(/\s+/g, " ").trim();
206
+ const asksModel = String(userText || "").toLowerCase().includes("模型");
154
207
  const isOnceAndDone =
155
208
  /^-+(showtoken|status|s|providers?\b|model\b|group\b|log\b|workbuddy\b|free\b|autostart\b|plugins\b)/.test(lowCmd) ||
156
209
  lowCmd === "-provider list" || lowCmd === "-providers list";
157
210
  if (isOnceAndDone && r.ok) {
158
- result += `\n\n[系统提示:此查询已完成,结果即答案,请直接用中文回答用户,禁止再调用相同或同类查询工具]`;
211
+ if (asksModel && lowCmd.includes("-provider")) {
212
+ result += `\n\n[提示:此命令仅显示供应商配置,不包含模型列表。用户问的是“有哪些模型”,请用系统提示中的“可用模型”按前缀过滤回答,或调 curl local/models,不要再调 provider list]`;
213
+ } else {
214
+ result += `\n\n[系统提示:此查询已完成,结果即答案,请直接用中文回答用户,禁止再调用相同或同类查询工具]`;
215
+ }
159
216
  }
160
217
  const dt = Math.round(performance.now() - t1);
161
218
  trace(`[tool] run_command "${cmd.slice(0, 40)}" · ${dt}ms · ${r.ok ? "OK" : "FAIL"} ${r.output.length}字`);
@@ -185,10 +242,20 @@ async function runAgentTurn(userText, messages) {
185
242
  return { id: c.id, content: result };
186
243
  }));
187
244
  for (const tr of toolResults) messages.push({ role: "tool", tool_call_id: tr.id, content: tr.content });
188
- // 若本轮有 SKIPPED_DUP,额外追加一条系统提示,强制 LLM 基于已有结果回答而不是再调工具
245
+ // 若本轮有 SKIPPED_DUP,额外追加系统提示并禁用后续工具,强制直接回答
189
246
  if (toolResults.some((tr) => String(tr.content).startsWith("SKIPPED_DUP"))) {
190
- messages.push({ role: "system", content: "系统提示:你已重复调用相同工具,工具侧已复用首次结果并跳过执行。请直接基于以上工具结果用中文回答用户,不要再发起任何工具调用。" });
191
- trace(`[dup] 检测到重复调用,已注入系统提示要求直接回答`);
247
+ duplicateStrikes++;
248
+ forceNoTools = true;
249
+ messages.push({ role: "system", content: "系统提示:你已重复调用相同工具,工具侧已复用首次结果并跳过执行。你已被禁止再调用任何工具,必须立即基于以上工具结果用中文直接回答用户,0 工具调用。" });
250
+ trace(`[dup] 检测到重复调用 ${duplicateStrikes} 次,已禁用后续工具调用`);
251
+ if (duplicateStrikes >= 2) {
252
+ const seen = [...seenCalls.values()].map((v) => v.firstResult).join("\n---\n").slice(0, 6000);
253
+ const synth = `检测到重复调用已达 ${duplicateStrikes} 次,为避免空转,直接基于已有结果回答:\n\n${seen}`;
254
+ messages.push({ role: "assistant", content: synth });
255
+ const totalMs = Math.round(performance.now() - t0);
256
+ trace(`[turn] 提前结束(重复阈值) 总计 ${totalMs}ms`);
257
+ return { text: synth, model: lastModel || "local", latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
258
+ }
192
259
  }
193
260
  const toolsMs = Math.round(performance.now() - tTools);
194
261
  const loopMs = Math.round(performance.now() - tLoop);
@@ -1,6 +1,14 @@
1
1
  import { joinModelId } from "./model-id.js";
2
2
  import { createKeyRing } from "./keyring.js";
3
- import { loadProviderKeys, loadProviderBaseUrl } from "../state.js";
3
+ import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath } from "../state.js";
4
+
5
+ function joinUrl(base, path) {
6
+ const b = String(base || "").trim().replace(/\/+$/, "");
7
+ const p = String(path || "").trim();
8
+ if (!p) return b;
9
+ const pp = p.startsWith("/") ? p : `/${p}`;
10
+ return `${b}${pp}`;
11
+ }
4
12
 
5
13
  let UndiciAgent = null;
6
14
  let UndiciFetch = null;
@@ -36,6 +44,8 @@ export function createGenericProvider({
36
44
  baseUrl,
37
45
  apiKeys,
38
46
  apiKey,
47
+ modelsPath,
48
+ chatPath,
39
49
  connectTimeoutMs = Number(process.env.MSLXDFF_GENERIC_TIMEOUT_MS) || 30_000,
40
50
  cooldownMs = envInt("MSLXDFF_GENERIC_COOLDOWN_MS", 30_000),
41
51
  retry = {
@@ -48,11 +58,14 @@ export function createGenericProvider({
48
58
  fetchImpl,
49
59
  headers: extraHeaders,
50
60
  noAgent = false,
61
+ file,
51
62
  } = {}) {
52
63
  if (!id) throw new Error("generic provider requires id");
53
64
  const resolvedBase = resolveBaseUrl(id, baseUrl);
54
65
  if (!resolvedBase) throw new Error(`generic provider ${id}: missing baseUrl`);
55
66
  if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
67
+ const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
68
+ const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
56
69
 
57
70
  const ring = createKeyRing(collectApiKeys(id, apiKeys, apiKey), { cooldownMs });
58
71
 
@@ -98,7 +111,7 @@ export function createGenericProvider({
98
111
  }
99
112
 
100
113
  async function runChat(body, activeRing, sourceKey) {
101
- const url = `${resolvedBase}/chat/completions`;
114
+ const url = joinUrl(resolvedBase, resolvedChatPath);
102
115
  const t0 = performance.now();
103
116
  const attempts = [];
104
117
  let waitMs = 0;
@@ -156,7 +169,7 @@ export function createGenericProvider({
156
169
  async function listModels() {
157
170
  const now = Date.now();
158
171
  if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
159
- const url = `${resolvedBase}/models`;
172
+ const url = joinUrl(resolvedBase, resolvedModelsPath);
160
173
  const controller = new AbortController();
161
174
  const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
162
175
  try {
@@ -181,7 +194,7 @@ export function createGenericProvider({
181
194
  }
182
195
 
183
196
  async function preheat() {
184
- const url = `${resolvedBase}/models`;
197
+ const url = joinUrl(resolvedBase, resolvedModelsPath);
185
198
  const t0 = performance.now();
186
199
  try {
187
200
  const headers = { Accept: "application/json" };
@@ -1,8 +1,9 @@
1
1
  import { joinModelId } from "./model-id.js";
2
2
  import { createKeyRing } from "./keyring.js";
3
- import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL } from "../state.js";
3
+ import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../state.js";
4
4
  import { writeFileSync, existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, statSync } from "node:fs";
5
5
  import { join, dirname } from "node:path";
6
+ import { tmpdir } from "node:os";
6
7
  import { getCachedBalance, setCachedBalance } from "./workbuddy-balance.js";
7
8
 
8
9
  let UndiciAgent = null;
@@ -13,10 +14,25 @@ try {
13
14
  UndiciFetch = mod.fetch;
14
15
  } catch {}
15
16
 
17
+ function joinUrl(base, path) {
18
+ const b = String(base || "").trim().replace(/\/+$/, "");
19
+ const p = String(path || "").trim();
20
+ if (!p) return b;
21
+ const pp = p.startsWith("/") ? p : `/${p}`;
22
+ return `${b}${pp}`;
23
+ }
16
24
  function envInt(name, fallback) {
17
25
  const v = Number(process.env[name]);
18
26
  return Number.isInteger(v) && v > 0 ? v : fallback;
19
27
  }
28
+ function isTestEnv() {
29
+ if (process.env.NODE_ENV === "test") return true;
30
+ if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
31
+ if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
32
+ if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
33
+ if (process.env.NODE_TEST_CONTEXT) return true;
34
+ return false;
35
+ }
20
36
 
21
37
  function resolveBaseUrl(baseUrl) {
22
38
  if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
@@ -111,6 +127,8 @@ export function createWorkbuddyProvider({
111
127
  apiKeys,
112
128
  apiKey,
113
129
  auths,
130
+ modelsPath,
131
+ chatPath,
114
132
  connectTimeoutMs = Number(process.env.MSLXDFF_WORKBUDDY_TIMEOUT_MS) || 30_000,
115
133
  cooldownMs = envInt("MSLXDFF_WORKBUDDY_COOLDOWN_MS", 30_000),
116
134
  retry = {
@@ -125,6 +143,8 @@ export function createWorkbuddyProvider({
125
143
  } = {}) {
126
144
  const id = "workbuddy";
127
145
  const resolvedBase = resolveBaseUrl(baseUrl);
146
+ const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
147
+ const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
128
148
  if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
129
149
 
130
150
  // keys 优先显式传入,其次 state
@@ -143,7 +163,7 @@ export function createWorkbuddyProvider({
143
163
  // fallback scan auths/workbuddy-*.json if still empty
144
164
  if (!authList.length && !keys.length) {
145
165
  try {
146
- const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
166
+ const authDir = process.env.WORKBUDDY_AUTH_DIR || (isTestEnv() ? join(tmpdir(), "mslxdff-test-auths") : (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths")));
147
167
  if (existsSync(authDir)) {
148
168
  const files = readdirSync(authDir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
149
169
  for (const f of files) {
@@ -192,7 +212,7 @@ export function createWorkbuddyProvider({
192
212
  const rt = auth?.refreshToken;
193
213
  const uid = auth?.uid;
194
214
  if (!rt || !uid) return null;
195
- const url = `${resolvedBase}/v2/plugin/auth/token/refresh`;
215
+ const url = joinUrl(resolvedBase, "/v2/plugin/auth/token/refresh");
196
216
  const headers = {
197
217
  "Content-Type": "application/json",
198
218
  Authorization: `Bearer ${key}`,
@@ -223,7 +243,7 @@ export function createWorkbuddyProvider({
223
243
  saveProviderConfig(id, { baseUrl: resolvedBase, keys: [...keys], auths: [...authList] }, file ? { file } : {});
224
244
  } catch {}
225
245
  try {
226
- const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
246
+ const authDir = process.env.WORKBUDDY_AUTH_DIR || (isTestEnv() ? join(tmpdir(), "mslxdff-test-auths") : (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths")));
227
247
  mkdirSync(authDir, { recursive: true });
228
248
  const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now()/1000)+5184000; } })();
229
249
  const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
@@ -259,7 +279,7 @@ export function createWorkbuddyProvider({
259
279
  }
260
280
 
261
281
  async function runChat(body, activeRing, opts = {}) {
262
- const url = `${resolvedBase}/v2/chat/completions`;
282
+ const url = joinUrl(resolvedBase, resolvedChatPath);
263
283
  const t0 = performance.now();
264
284
  const preferredUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
265
285
  const modelForLog = body?.model || "";
@@ -479,7 +499,7 @@ export function createWorkbuddyProvider({
479
499
  async function listModels() {
480
500
  const now = Date.now();
481
501
  if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
482
- const url = `${resolvedBase}/console/enterprises/personal/models`;
502
+ const url = joinUrl(resolvedBase, resolvedModelsPath);
483
503
  const controller = new AbortController();
484
504
  const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
485
505
  try {
@@ -514,7 +534,7 @@ export function createWorkbuddyProvider({
514
534
  }
515
535
 
516
536
  async function preheat() {
517
- const url = `${resolvedBase}/console/enterprises/personal/models`;
537
+ const url = joinUrl(resolvedBase, resolvedModelsPath);
518
538
  const t0 = performance.now();
519
539
  try {
520
540
  const key = ring.next() || keys[0] || "";
@@ -4,12 +4,21 @@ import { json, notFound, authorized } from "./helpers.js";
4
4
  import { chatHandler } from "./chat.js";
5
5
  import { joinHandler, leaveHandler } from "./groups.js";
6
6
  import { heartbeatHandler, pollHandler, resultHandler, forwardHandler } from "./groups-relay.js";
7
- import { modelsHandler, modelsStatusHandler } from "./models-route.js";
7
+ import { modelsHandler, modelsStatusHandler, providerModelsHandler } from "./models-route.js";
8
8
 
9
9
  export function createRouter({ token, upstream, models, auto, logs, peers, maxHops = DEFAULT_MAX_HOPS, groups, bans, bus, plugins }) {
10
10
  return async function router(req, res) {
11
11
  const method = req.method || "GET";
12
12
  const path = (req.url || "").split("?")[0];
13
+ // dynamic provider models route: GET /v1/providers/:id/models
14
+ if (method === "GET" && /^\/v1\/providers\/[^/]+\/models\/?$/.test(path)) {
15
+ if (!authorized(req, token)) {
16
+ res.statusCode = 401;
17
+ res.setHeader("WWW-Authenticate", "Bearer");
18
+ return json(res, 401, { error: "Unauthorized" });
19
+ }
20
+ return providerModelsHandler({ req, res, models, upstream });
21
+ }
13
22
  const route = ROUTES.find((r) => r.method === method && r.path === path);
14
23
  if (!route) return notFound(res);
15
24
  if (route.requiresAuth && !authorized(req, token)) {
@@ -1,5 +1,6 @@
1
1
  import { json, errMsg } from "./helpers.js";
2
2
  import { runHook } from "../plugins.js";
3
+ import { isModelAllowed } from "../state.js";
3
4
 
4
5
  export async function modelsHandler({ res, models, plugins }) {
5
6
  if (!models) return json(res, 501, { error: "Models service not configured" });
@@ -42,3 +43,30 @@ export async function modelsStatusHandler({ res, models, auto }) {
42
43
  }
43
44
  json(res, 200, { object: "list", data });
44
45
  }
46
+
47
+ export async function providerModelsHandler({ req, res, models }) {
48
+ if (!models) return json(res, 501, { error: "Models service not configured" });
49
+ const url = req.url || "";
50
+ const path = url.split("?")[0] || "";
51
+ const m = path.match(/^\/v1\/providers\/([^/]+)\/models\/?$/);
52
+ const pid = m ? decodeURIComponent(m[1]).toLowerCase() : "";
53
+ if (!pid) return json(res, 400, { error: "missing provider id" });
54
+ try {
55
+ const data = await models.get();
56
+ const all = Array.isArray(data?.data) ? data.data : [];
57
+ const filtered = all.filter((entry) => {
58
+ const id = String(entry?.id || "");
59
+ const slash = id.indexOf("/");
60
+ const prov = slash > 0 ? id.slice(0, slash).toLowerCase() : "opencode";
61
+ if (prov !== pid) return false;
62
+ const raw = slash > 0 ? id.slice(slash + 1) : id;
63
+ // allowlist check: opencode always allowed via allowAny, others respect config
64
+ try {
65
+ return isModelAllowed(pid, raw);
66
+ } catch { return true; }
67
+ });
68
+ json(res, 200, { object: "list", data: filtered });
69
+ } catch (err) {
70
+ json(res, 502, { error: errMsg(err) });
71
+ }
72
+ }
package/src/state.js CHANGED
@@ -6,9 +6,19 @@ import os from "node:os";
6
6
 
7
7
  export const DEFAULT_PORT = 8989;
8
8
 
9
+ function isTestEnv() {
10
+ if (process.env.NODE_ENV === "test") return true;
11
+ if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
12
+ if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
13
+ if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
14
+ // Node test runner sets this
15
+ if (process.env.NODE_TEST_CONTEXT) return true;
16
+ return false;
17
+ }
18
+
9
19
  export function defaultStateFile() {
10
20
  if (process.env.MSLXDFF_STATE_FILE) return process.env.MSLXDFF_STATE_FILE;
11
- if (process.argv.includes("--test") || process.env.NODE_ENV === "test") {
21
+ if (isTestEnv()) {
12
22
  return join(os.tmpdir(), "mslxdff-test-state.json");
13
23
  }
14
24
  return join(os.homedir(), ".config", "mslxdff", "state.json");
@@ -19,7 +29,7 @@ export function tokenFile(file) {
19
29
  const sf = file || defaultStateFile();
20
30
  const realDefault = join(os.homedir(), ".config", "mslxdff", "state.json");
21
31
  if (sf !== realDefault) return join(dirname(sf), "token");
22
- if (process.argv.includes("--test") || process.env.NODE_ENV === "test") {
32
+ if (isTestEnv()) {
23
33
  return join(os.tmpdir(), "mslxdff-test-token");
24
34
  }
25
35
  return join(os.homedir(), ".config", "mslxdff", "token");
@@ -385,6 +395,29 @@ function normalizeAllowedModel(model, providerId) {
385
395
  }
386
396
  return s;
387
397
  }
398
+ function normalizeEndpointPath(v) {
399
+ const s = String(v || "").trim();
400
+ if (!s) return "";
401
+ return s.startsWith("/") ? s : `/${s}`;
402
+ }
403
+ function defaultModelsPath(id) {
404
+ if (String(id).toLowerCase() === "workbuddy") return "/console/enterprises/personal/models";
405
+ return "/models";
406
+ }
407
+ function defaultChatPath(id) {
408
+ if (String(id).toLowerCase() === "workbuddy") return "/v2/chat/completions";
409
+ return "/chat/completions";
410
+ }
411
+ export function loadProviderModelsPath(id, { file = defaultStateFile() } = {}) {
412
+ const cfg = loadProviderConfigs({ file })[id];
413
+ if (cfg && typeof cfg.modelsPath === "string" && cfg.modelsPath.trim()) return normalizeEndpointPath(cfg.modelsPath);
414
+ return defaultModelsPath(id);
415
+ }
416
+ export function loadProviderChatPath(id, { file = defaultStateFile() } = {}) {
417
+ const cfg = loadProviderConfigs({ file })[id];
418
+ if (cfg && typeof cfg.chatPath === "string" && cfg.chatPath.trim()) return normalizeEndpointPath(cfg.chatPath);
419
+ return defaultChatPath(id);
420
+ }
388
421
 
389
422
  export function loadProviderConfigs({ file = defaultStateFile() } = {}) {
390
423
  const v = readState(file).providerConfigs;
@@ -401,7 +434,9 @@ export function loadProviderConfig(id, { file = defaultStateFile() } = {}) {
401
434
  const cfg = loadProviderConfigs({ file })[id];
402
435
  const allowedModels = cfg && Array.isArray(cfg.allowedModels) ? [...new Set(cfg.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
403
436
  const auths = cfg && Array.isArray(cfg.auths) ? normalizeAuths(cfg.auths) : [];
404
- if (baseUrl || envKeys.length || allowedModels.length || auths.length) return { baseUrl: normalizeBaseUrl(baseUrl), keys: envKeys, auths, allowedModels };
437
+ const modelsPath = cfg && typeof cfg.modelsPath === "string" && cfg.modelsPath.trim() ? normalizeEndpointPath(cfg.modelsPath) : defaultModelsPath(id);
438
+ const chatPath = cfg && typeof cfg.chatPath === "string" && cfg.chatPath.trim() ? normalizeEndpointPath(cfg.chatPath) : defaultChatPath(id);
439
+ if (baseUrl || envKeys.length || allowedModels.length || auths.length) return { baseUrl: normalizeBaseUrl(baseUrl), keys: envKeys, auths, allowedModels, modelsPath, chatPath };
405
440
  return null;
406
441
  }
407
442
  const configs = loadProviderConfigs({ file });
@@ -412,11 +447,13 @@ export function loadProviderConfig(id, { file = defaultStateFile() } = {}) {
412
447
  keys: Array.isArray(cfg.keys) ? [...new Set(cfg.keys.filter((x) => typeof x === "string" && x.trim().length))] : [],
413
448
  auths: normalizeAuths(cfg.auths),
414
449
  allowedModels: Array.isArray(cfg.allowedModels) ? [...new Set(cfg.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [],
450
+ modelsPath: typeof cfg.modelsPath === "string" && cfg.modelsPath.trim() ? normalizeEndpointPath(cfg.modelsPath) : defaultModelsPath(id),
451
+ chatPath: typeof cfg.chatPath === "string" && cfg.chatPath.trim() ? normalizeEndpointPath(cfg.chatPath) : defaultChatPath(id),
415
452
  };
416
453
  }
417
454
  // 兼容旧 providerKeys 形态:有 key 但无 configs 时视为通用供应商(baseUrl 为空,需后补)
418
455
  const keys = loadProviderKeys(id, { file });
419
- if (keys.length) return { baseUrl: String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "", keys, auths: [], allowedModels: [] };
456
+ if (keys.length) return { baseUrl: String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "", keys, auths: [], allowedModels: [], modelsPath: defaultModelsPath(id), chatPath: defaultChatPath(id) };
420
457
  return null;
421
458
  }
422
459
 
@@ -441,12 +478,16 @@ export function saveProviderAuths(id, list, { file = defaultStateFile() } = {})
441
478
  const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
442
479
  const keys = Array.isArray(cur.keys) ? [...new Set(cur.keys.filter((x) => typeof x === "string" && x.trim().length))] : loadProviderKeys(id, { file });
443
480
  const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
444
- if (!baseUrl && !keys.length && !clean.length && !allowedModels.length) {
481
+ const modelsPath = typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "";
482
+ const chatPath = typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "";
483
+ if (!baseUrl && !keys.length && !clean.length && !allowedModels.length && !modelsPath && !chatPath) {
445
484
  delete configs[id];
446
485
  } else {
447
486
  configs[id] = { baseUrl, keys };
448
487
  if (clean.length) configs[id].auths = clean;
449
488
  if (allowedModels.length) configs[id].allowedModels = allowedModels;
489
+ if (modelsPath) configs[id].modelsPath = modelsPath;
490
+ if (chatPath) configs[id].chatPath = chatPath;
450
491
  }
451
492
  writeStateImmediate(file, { providerConfigs: configs });
452
493
  return clean;
@@ -459,33 +500,43 @@ export function saveProviderBaseUrl(id, baseUrl, { file = defaultStateFile() } =
459
500
  const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
460
501
  const auths = normalizeAuths(cur.auths);
461
502
  const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
462
- if (!clean && !keys.length && !auths.length && !allowedModels.length) {
503
+ const modelsPath = typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "";
504
+ const chatPath = typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "";
505
+ if (!clean && !keys.length && !auths.length && !allowedModels.length && !modelsPath && !chatPath) {
463
506
  delete configs[id];
464
507
  } else {
465
508
  configs[id] = { baseUrl: clean, keys };
466
509
  if (auths.length) configs[id].auths = auths;
467
510
  if (allowedModels.length) configs[id].allowedModels = allowedModels;
511
+ if (modelsPath) configs[id].modelsPath = modelsPath;
512
+ if (chatPath) configs[id].chatPath = chatPath;
468
513
  }
469
514
  writeStateImmediate(file, { providerConfigs: configs });
470
515
  return clean;
471
516
  }
472
517
 
473
- export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels }, { file = defaultStateFile() } = {}) {
518
+ export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels, modelsPath, chatPath }, { file = defaultStateFile() } = {}) {
474
519
  const cleanUrl = normalizeBaseUrl(baseUrl);
475
520
  const cleanKeys = [...new Set((Array.isArray(keys) ? keys : []).map((k) => String(k || "").trim()).filter(Boolean))];
476
521
  const cleanAuths = auths === undefined ? undefined : normalizeAuths(auths);
477
522
  const cleanAllowed = [...new Set((Array.isArray(allowedModels) ? allowedModels : []).map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
523
+ const cleanModelsPath = modelsPath === undefined ? undefined : (String(modelsPath).trim() ? normalizeEndpointPath(modelsPath) : "");
524
+ const cleanChatPath = chatPath === undefined ? undefined : (String(chatPath).trim() ? normalizeEndpointPath(chatPath) : "");
478
525
  const configs = { ...loadProviderConfigs({ file }) };
479
526
  const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
480
- // 保留已有的 allowedModels / auths 若本次未传入
527
+ // 保留已有的 allowedModels / auths / paths 若本次未传入
481
528
  const finalAllowed = allowedModels === undefined ? (Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : []) : cleanAllowed;
482
529
  const finalAuths = cleanAuths === undefined ? normalizeAuths(cur.auths) : cleanAuths;
483
- if (!cleanUrl && !cleanKeys.length && !finalAllowed.length && !finalAuths.length) {
530
+ const finalModelsPath = cleanModelsPath === undefined ? (typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "") : cleanModelsPath;
531
+ const finalChatPath = cleanChatPath === undefined ? (typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "") : cleanChatPath;
532
+ if (!cleanUrl && !cleanKeys.length && !finalAllowed.length && !finalAuths.length && !finalModelsPath && !finalChatPath) {
484
533
  delete configs[id];
485
534
  } else {
486
535
  configs[id] = { baseUrl: cleanUrl, keys: cleanKeys };
487
536
  if (finalAuths.length) configs[id].auths = finalAuths;
488
537
  if (finalAllowed.length) configs[id].allowedModels = finalAllowed;
538
+ if (finalModelsPath) configs[id].modelsPath = finalModelsPath;
539
+ if (finalChatPath) configs[id].chatPath = finalChatPath;
489
540
  }
490
541
  // 同步清理旧 providerKeys 中同 id 的残留,避免双写
491
542
  const oldKeys = readState(file).providerKeys;
@@ -493,10 +544,10 @@ export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels },
493
544
  const nextKeys = { ...oldKeys };
494
545
  delete nextKeys[id];
495
546
  writeStateImmediate(file, { providerKeys: nextKeys, providerConfigs: configs });
496
- return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed };
547
+ return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed, modelsPath: finalModelsPath, chatPath: finalChatPath };
497
548
  }
498
549
  writeStateImmediate(file, { providerConfigs: configs });
499
- return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed };
550
+ return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed, modelsPath: finalModelsPath, chatPath: finalChatPath };
500
551
  }
501
552
 
502
553
  // ---- 供应商模型白名单:providerConfigs.<id>.allowedModels(空 = 不限) ----
@@ -515,12 +566,16 @@ export function saveProviderAllowedModels(id, list, { file = defaultStateFile()
515
566
  const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
516
567
  const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
517
568
  const auths = normalizeAuths(cur.auths);
518
- if (!baseUrl && !keys.length && !auths.length && !clean.length) {
569
+ const modelsPath = typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "";
570
+ const chatPath = typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "";
571
+ if (!baseUrl && !keys.length && !auths.length && !clean.length && !modelsPath && !chatPath) {
519
572
  delete configs[id];
520
573
  } else {
521
574
  configs[id] = { baseUrl, keys };
522
575
  if (auths.length) configs[id].auths = auths;
523
576
  if (clean.length) configs[id].allowedModels = clean;
577
+ if (modelsPath) configs[id].modelsPath = modelsPath;
578
+ if (chatPath) configs[id].chatPath = chatPath;
524
579
  }
525
580
  writeStateImmediate(file, { providerConfigs: configs });
526
581
  return clean;