mslxdff 0.1.62 → 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/providers/generic.js +17 -4
- package/src/providers/workbuddy.js +16 -5
- package/src/routes/index.js +10 -1
- package/src/routes/models-route.js +28 -0
- package/src/state.js +55 -10
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/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,6 +1,6 @@
|
|
|
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
6
|
import { tmpdir } from "node:os";
|
|
@@ -14,6 +14,13 @@ try {
|
|
|
14
14
|
UndiciFetch = mod.fetch;
|
|
15
15
|
} catch {}
|
|
16
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
|
+
}
|
|
17
24
|
function envInt(name, fallback) {
|
|
18
25
|
const v = Number(process.env[name]);
|
|
19
26
|
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
@@ -120,6 +127,8 @@ export function createWorkbuddyProvider({
|
|
|
120
127
|
apiKeys,
|
|
121
128
|
apiKey,
|
|
122
129
|
auths,
|
|
130
|
+
modelsPath,
|
|
131
|
+
chatPath,
|
|
123
132
|
connectTimeoutMs = Number(process.env.MSLXDFF_WORKBUDDY_TIMEOUT_MS) || 30_000,
|
|
124
133
|
cooldownMs = envInt("MSLXDFF_WORKBUDDY_COOLDOWN_MS", 30_000),
|
|
125
134
|
retry = {
|
|
@@ -134,6 +143,8 @@ export function createWorkbuddyProvider({
|
|
|
134
143
|
} = {}) {
|
|
135
144
|
const id = "workbuddy";
|
|
136
145
|
const resolvedBase = resolveBaseUrl(baseUrl);
|
|
146
|
+
const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
|
|
147
|
+
const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
|
|
137
148
|
if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
|
|
138
149
|
|
|
139
150
|
// keys 优先显式传入,其次 state
|
|
@@ -201,7 +212,7 @@ export function createWorkbuddyProvider({
|
|
|
201
212
|
const rt = auth?.refreshToken;
|
|
202
213
|
const uid = auth?.uid;
|
|
203
214
|
if (!rt || !uid) return null;
|
|
204
|
-
const url =
|
|
215
|
+
const url = joinUrl(resolvedBase, "/v2/plugin/auth/token/refresh");
|
|
205
216
|
const headers = {
|
|
206
217
|
"Content-Type": "application/json",
|
|
207
218
|
Authorization: `Bearer ${key}`,
|
|
@@ -268,7 +279,7 @@ export function createWorkbuddyProvider({
|
|
|
268
279
|
}
|
|
269
280
|
|
|
270
281
|
async function runChat(body, activeRing, opts = {}) {
|
|
271
|
-
const url =
|
|
282
|
+
const url = joinUrl(resolvedBase, resolvedChatPath);
|
|
272
283
|
const t0 = performance.now();
|
|
273
284
|
const preferredUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
|
|
274
285
|
const modelForLog = body?.model || "";
|
|
@@ -488,7 +499,7 @@ export function createWorkbuddyProvider({
|
|
|
488
499
|
async function listModels() {
|
|
489
500
|
const now = Date.now();
|
|
490
501
|
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
491
|
-
const url =
|
|
502
|
+
const url = joinUrl(resolvedBase, resolvedModelsPath);
|
|
492
503
|
const controller = new AbortController();
|
|
493
504
|
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
494
505
|
try {
|
|
@@ -523,7 +534,7 @@ export function createWorkbuddyProvider({
|
|
|
523
534
|
}
|
|
524
535
|
|
|
525
536
|
async function preheat() {
|
|
526
|
-
const url =
|
|
537
|
+
const url = joinUrl(resolvedBase, resolvedModelsPath);
|
|
527
538
|
const t0 = performance.now();
|
|
528
539
|
try {
|
|
529
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
|
@@ -395,6 +395,29 @@ function normalizeAllowedModel(model, providerId) {
|
|
|
395
395
|
}
|
|
396
396
|
return s;
|
|
397
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
|
+
}
|
|
398
421
|
|
|
399
422
|
export function loadProviderConfigs({ file = defaultStateFile() } = {}) {
|
|
400
423
|
const v = readState(file).providerConfigs;
|
|
@@ -411,7 +434,9 @@ export function loadProviderConfig(id, { file = defaultStateFile() } = {}) {
|
|
|
411
434
|
const cfg = loadProviderConfigs({ file })[id];
|
|
412
435
|
const allowedModels = cfg && Array.isArray(cfg.allowedModels) ? [...new Set(cfg.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
|
|
413
436
|
const auths = cfg && Array.isArray(cfg.auths) ? normalizeAuths(cfg.auths) : [];
|
|
414
|
-
|
|
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 };
|
|
415
440
|
return null;
|
|
416
441
|
}
|
|
417
442
|
const configs = loadProviderConfigs({ file });
|
|
@@ -422,11 +447,13 @@ export function loadProviderConfig(id, { file = defaultStateFile() } = {}) {
|
|
|
422
447
|
keys: Array.isArray(cfg.keys) ? [...new Set(cfg.keys.filter((x) => typeof x === "string" && x.trim().length))] : [],
|
|
423
448
|
auths: normalizeAuths(cfg.auths),
|
|
424
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),
|
|
425
452
|
};
|
|
426
453
|
}
|
|
427
454
|
// 兼容旧 providerKeys 形态:有 key 但无 configs 时视为通用供应商(baseUrl 为空,需后补)
|
|
428
455
|
const keys = loadProviderKeys(id, { file });
|
|
429
|
-
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) };
|
|
430
457
|
return null;
|
|
431
458
|
}
|
|
432
459
|
|
|
@@ -451,12 +478,16 @@ export function saveProviderAuths(id, list, { file = defaultStateFile() } = {})
|
|
|
451
478
|
const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
|
|
452
479
|
const keys = Array.isArray(cur.keys) ? [...new Set(cur.keys.filter((x) => typeof x === "string" && x.trim().length))] : loadProviderKeys(id, { file });
|
|
453
480
|
const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
|
|
454
|
-
|
|
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) {
|
|
455
484
|
delete configs[id];
|
|
456
485
|
} else {
|
|
457
486
|
configs[id] = { baseUrl, keys };
|
|
458
487
|
if (clean.length) configs[id].auths = clean;
|
|
459
488
|
if (allowedModels.length) configs[id].allowedModels = allowedModels;
|
|
489
|
+
if (modelsPath) configs[id].modelsPath = modelsPath;
|
|
490
|
+
if (chatPath) configs[id].chatPath = chatPath;
|
|
460
491
|
}
|
|
461
492
|
writeStateImmediate(file, { providerConfigs: configs });
|
|
462
493
|
return clean;
|
|
@@ -469,33 +500,43 @@ export function saveProviderBaseUrl(id, baseUrl, { file = defaultStateFile() } =
|
|
|
469
500
|
const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
|
|
470
501
|
const auths = normalizeAuths(cur.auths);
|
|
471
502
|
const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
|
|
472
|
-
|
|
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) {
|
|
473
506
|
delete configs[id];
|
|
474
507
|
} else {
|
|
475
508
|
configs[id] = { baseUrl: clean, keys };
|
|
476
509
|
if (auths.length) configs[id].auths = auths;
|
|
477
510
|
if (allowedModels.length) configs[id].allowedModels = allowedModels;
|
|
511
|
+
if (modelsPath) configs[id].modelsPath = modelsPath;
|
|
512
|
+
if (chatPath) configs[id].chatPath = chatPath;
|
|
478
513
|
}
|
|
479
514
|
writeStateImmediate(file, { providerConfigs: configs });
|
|
480
515
|
return clean;
|
|
481
516
|
}
|
|
482
517
|
|
|
483
|
-
export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels }, { file = defaultStateFile() } = {}) {
|
|
518
|
+
export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels, modelsPath, chatPath }, { file = defaultStateFile() } = {}) {
|
|
484
519
|
const cleanUrl = normalizeBaseUrl(baseUrl);
|
|
485
520
|
const cleanKeys = [...new Set((Array.isArray(keys) ? keys : []).map((k) => String(k || "").trim()).filter(Boolean))];
|
|
486
521
|
const cleanAuths = auths === undefined ? undefined : normalizeAuths(auths);
|
|
487
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) : "");
|
|
488
525
|
const configs = { ...loadProviderConfigs({ file }) };
|
|
489
526
|
const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
|
|
490
|
-
// 保留已有的 allowedModels / auths 若本次未传入
|
|
527
|
+
// 保留已有的 allowedModels / auths / paths 若本次未传入
|
|
491
528
|
const finalAllowed = allowedModels === undefined ? (Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : []) : cleanAllowed;
|
|
492
529
|
const finalAuths = cleanAuths === undefined ? normalizeAuths(cur.auths) : cleanAuths;
|
|
493
|
-
|
|
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) {
|
|
494
533
|
delete configs[id];
|
|
495
534
|
} else {
|
|
496
535
|
configs[id] = { baseUrl: cleanUrl, keys: cleanKeys };
|
|
497
536
|
if (finalAuths.length) configs[id].auths = finalAuths;
|
|
498
537
|
if (finalAllowed.length) configs[id].allowedModels = finalAllowed;
|
|
538
|
+
if (finalModelsPath) configs[id].modelsPath = finalModelsPath;
|
|
539
|
+
if (finalChatPath) configs[id].chatPath = finalChatPath;
|
|
499
540
|
}
|
|
500
541
|
// 同步清理旧 providerKeys 中同 id 的残留,避免双写
|
|
501
542
|
const oldKeys = readState(file).providerKeys;
|
|
@@ -503,10 +544,10 @@ export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels },
|
|
|
503
544
|
const nextKeys = { ...oldKeys };
|
|
504
545
|
delete nextKeys[id];
|
|
505
546
|
writeStateImmediate(file, { providerKeys: nextKeys, providerConfigs: configs });
|
|
506
|
-
return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed };
|
|
547
|
+
return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed, modelsPath: finalModelsPath, chatPath: finalChatPath };
|
|
507
548
|
}
|
|
508
549
|
writeStateImmediate(file, { providerConfigs: configs });
|
|
509
|
-
return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed };
|
|
550
|
+
return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed, modelsPath: finalModelsPath, chatPath: finalChatPath };
|
|
510
551
|
}
|
|
511
552
|
|
|
512
553
|
// ---- 供应商模型白名单:providerConfigs.<id>.allowedModels(空 = 不限) ----
|
|
@@ -525,12 +566,16 @@ export function saveProviderAllowedModels(id, list, { file = defaultStateFile()
|
|
|
525
566
|
const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
|
|
526
567
|
const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
|
|
527
568
|
const auths = normalizeAuths(cur.auths);
|
|
528
|
-
|
|
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) {
|
|
529
572
|
delete configs[id];
|
|
530
573
|
} else {
|
|
531
574
|
configs[id] = { baseUrl, keys };
|
|
532
575
|
if (auths.length) configs[id].auths = auths;
|
|
533
576
|
if (clean.length) configs[id].allowedModels = clean;
|
|
577
|
+
if (modelsPath) configs[id].modelsPath = modelsPath;
|
|
578
|
+
if (chatPath) configs[id].chatPath = chatPath;
|
|
534
579
|
}
|
|
535
580
|
writeStateImmediate(file, { providerConfigs: configs });
|
|
536
581
|
return clean;
|