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 +164 -6
- package/package.json +1 -1
- package/src/chat/prompt.js +1 -1
- package/src/chat/repl.js +74 -7
- package/src/providers/generic.js +17 -4
- package/src/providers/workbuddy.js +27 -7
- package/src/routes/index.js +10 -1
- package/src/routes/models-route.js +28 -0
- package/src/state.js +67 -12
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/chat/prompt.js
CHANGED
|
@@ -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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
245
|
+
// 若本轮有 SKIPPED_DUP,额外追加系统提示并禁用后续工具,强制直接回答
|
|
189
246
|
if (toolResults.some((tr) => String(tr.content).startsWith("SKIPPED_DUP"))) {
|
|
190
|
-
|
|
191
|
-
|
|
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);
|
package/src/providers/generic.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
537
|
+
const url = joinUrl(resolvedBase, resolvedModelsPath);
|
|
518
538
|
const t0 = performance.now();
|
|
519
539
|
try {
|
|
520
540
|
const key = ring.next() || keys[0] || "";
|
package/src/routes/index.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|