mslxdff 0.1.55 → 0.1.57
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/README.md +1 -160
- package/bin/mslxdff.js +741 -29
- package/package.json +3 -2
- package/src/chat/config.js +10 -0
- package/src/chat/index.js +6 -0
- package/src/chat/prompt.js +63 -0
- package/src/chat/repl.js +245 -0
- package/src/chat/spinner.js +38 -0
- package/src/chat/stats.js +170 -0
- package/src/chat/store.js +48 -0
- package/src/chat/tools.js +304 -0
- package/src/chat/upstream.js +90 -0
- package/src/models.js +90 -2
- package/src/providers/dispatcher.js +100 -0
- package/src/providers/generic.js +221 -0
- package/src/providers/index.js +6 -0
- package/src/providers/keyring.js +38 -0
- package/src/providers/model-id.js +36 -0
- package/src/providers/opencode.js +20 -0
- package/src/providers/openrouter.js +217 -0
- package/src/providers/share-keys.js +53 -0
- package/src/providers/workbuddy-balance.js +76 -0
- package/src/providers/workbuddy.js +563 -0
- package/src/routes/chat/index.js +56 -2
- package/src/routes/peers.js +12 -7
- package/src/routes/stream.js +9 -0
- package/src/state.js +299 -4
- package/src/sync-opencode.js +152 -0
- package/src/time.js +73 -0
- package/src/upstream.js +126 -8
- package/docs/adr/0001-reasoning-content-injection.md +0 -14
- package/docs/adr/0002-models-free-filter.md +0 -12
- package/docs/adr/0003-zero-state-no-auth.md +0 -10
- package/docs/adr/0004-bearer-token.md +0 -18
- package/docs/adr/0005-peer-mesh.md +0 -53
- package/docs/adr/0006-broadband-member.md +0 -103
- package/docs/agents/domain.md +0 -51
- package/docs/agents/issue-tracker.md +0 -30
- package/docs/agents/triage-labels.md +0 -15
- package/docs/plugins.md +0 -185
package/src/routes/peers.js
CHANGED
|
@@ -2,6 +2,7 @@ import { performance } from "node:perf_hooks";
|
|
|
2
2
|
import { isAutoModel } from "../auto.js";
|
|
3
3
|
import { errMsg } from "./helpers.js";
|
|
4
4
|
import { runHook } from "../plugins.js";
|
|
5
|
+
import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
|
|
5
6
|
|
|
6
7
|
const PEER_TIMEOUT_MS = 30_000;
|
|
7
8
|
const PEER_STATUS_TIMEOUT_MS = 2_000;
|
|
@@ -63,15 +64,19 @@ async function forwardToPeer(peer, body, model, hops) {
|
|
|
63
64
|
const controller = new AbortController();
|
|
64
65
|
const timer = setTimeout(() => controller.abort(), PEER_TIMEOUT_MS);
|
|
65
66
|
try {
|
|
67
|
+
const headers = {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"Authorization": `Bearer ${peer.token}`,
|
|
70
|
+
"x-mslxdff-hops": String(hops + 1),
|
|
71
|
+
"x-mslxdff-model-lock": model,
|
|
72
|
+
"Accept": "text/event-stream",
|
|
73
|
+
};
|
|
74
|
+
// ADR-0008:该模型命中的供应商若开启 share → 附带瞬时 key 给组员借用(opencode 恒排除)
|
|
75
|
+
const shareHeader = buildShareKeysHeader(model);
|
|
76
|
+
if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
|
|
66
77
|
return await fetch(`${peer.url}/v1/chat/completions`, {
|
|
67
78
|
method: "POST",
|
|
68
|
-
headers
|
|
69
|
-
"Content-Type": "application/json",
|
|
70
|
-
"Authorization": `Bearer ${peer.token}`,
|
|
71
|
-
"x-mslxdff-hops": String(hops + 1),
|
|
72
|
-
"x-mslxdff-model-lock": model,
|
|
73
|
-
"Accept": "text/event-stream",
|
|
74
|
-
},
|
|
79
|
+
headers,
|
|
75
80
|
body: JSON.stringify({ ...body, model }),
|
|
76
81
|
signal: controller.signal,
|
|
77
82
|
});
|
package/src/routes/stream.js
CHANGED
|
@@ -33,6 +33,15 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
33
33
|
const contentType = upRes.headers.get("content-type") || "";
|
|
34
34
|
const isStream = Boolean(body?.stream) || contentType.includes("text/event-stream");
|
|
35
35
|
res.statusCode = upRes.status;
|
|
36
|
+
// propagate workbuddy uid / allowlist headers
|
|
37
|
+
try {
|
|
38
|
+
const uid = upRes.headers.get("x-mslxdff-workbuddy-uid");
|
|
39
|
+
if (uid) res.setHeader("x-mslxdff-workbuddy-uid", uid);
|
|
40
|
+
const reason = upRes.headers.get("x-mslxdff-workbuddy-reason");
|
|
41
|
+
if (reason) res.setHeader("x-mslxdff-workbuddy-reason", reason);
|
|
42
|
+
const allow = upRes.headers.get("x-mslxdff-allowlist");
|
|
43
|
+
if (allow) res.setHeader("x-mslxdff-allowlist", allow);
|
|
44
|
+
} catch {}
|
|
36
45
|
if (fallback) applyFallbackHeaders(res, fallback);
|
|
37
46
|
|
|
38
47
|
let ttf = null;
|
package/src/state.js
CHANGED
|
@@ -41,15 +41,31 @@ function loadFromDisk(file) {
|
|
|
41
41
|
|
|
42
42
|
function readState(file) {
|
|
43
43
|
const e = getEntry(file);
|
|
44
|
-
//
|
|
45
|
-
if (e.data !== null && e.dirty) return e.data;
|
|
46
|
-
// 已有干净缓存且文件未变,直接命中
|
|
44
|
+
// 即使脏数据,也要检查 mtime 是否被外部(CLI)改过;若改过则合并,避免 CLI 的 providerConfigs 白名单等被 daemon 的 modelErrors 刷盘覆盖
|
|
47
45
|
if (e.data !== null) {
|
|
48
46
|
try {
|
|
49
47
|
const st = statSync(file);
|
|
50
48
|
if (st.mtimeMs === e.mtimeMs) return e.data;
|
|
49
|
+
// mtime 变了,说明外部改过文件
|
|
50
|
+
if (e.dirty) {
|
|
51
|
+
const disk = loadFromDisk(file);
|
|
52
|
+
const diskObj = typeof disk === "object" && disk !== null ? disk : {};
|
|
53
|
+
// 合并:disk 为新基准,e.data 的热数据(modelErrors/modelLatencies/peerStats 等)覆盖回去,但 providerConfigs 等配置以 disk 为准
|
|
54
|
+
const merged = { ...diskObj, ...e.data };
|
|
55
|
+
// 配置类字段以 disk 为准(CLI 改的白名单、keys、port、token 等)
|
|
56
|
+
if (diskObj.providerConfigs !== undefined) merged.providerConfigs = diskObj.providerConfigs;
|
|
57
|
+
if (diskObj.providerKeys !== undefined) merged.providerKeys = diskObj.providerKeys;
|
|
58
|
+
if (diskObj.providerShareKeys !== undefined) merged.providerShareKeys = diskObj.providerShareKeys;
|
|
59
|
+
if (diskObj.port !== undefined) merged.port = diskObj.port;
|
|
60
|
+
if (diskObj.token !== undefined) merged.token = diskObj.token;
|
|
61
|
+
if (diskObj.preferredModel !== undefined) merged.preferredModel = diskObj.preferredModel;
|
|
62
|
+
if (diskObj.modelPicks !== undefined) merged.modelPicks = diskObj.modelPicks;
|
|
63
|
+
e.data = merged;
|
|
64
|
+
e.mtimeMs = st.mtimeMs;
|
|
65
|
+
// 保持 dirty,让热数据下次刷盘时带上合并后的配置
|
|
66
|
+
return e.data;
|
|
67
|
+
}
|
|
51
68
|
} catch {
|
|
52
|
-
// 文件不存在等,沿用内存
|
|
53
69
|
if (e.data) return e.data;
|
|
54
70
|
}
|
|
55
71
|
}
|
|
@@ -201,6 +217,285 @@ export function loadPreferredModel({ file = defaultStateFile() } = {}) {
|
|
|
201
217
|
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
202
218
|
}
|
|
203
219
|
|
|
220
|
+
// 供应商 API key:state 存 `providerKeys: { openrouter: ["sk-1", "sk-2"] }`
|
|
221
|
+
// 兼容旧版单字符串 `openrouter: "sk-1"`(读到自动转数组)。
|
|
222
|
+
// env `MSLXDFF_<ID>_KEY`(大写)优先,state 兜底;key 为空视为未配置
|
|
223
|
+
export const providerKeyEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_KEY`;
|
|
224
|
+
export const providerBaseUrlEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BASE_URL`;
|
|
225
|
+
|
|
226
|
+
export function loadProviderKeys(id, { file = defaultStateFile() } = {}) {
|
|
227
|
+
const env = (process.env[providerKeyEnv(id)] || "").trim();
|
|
228
|
+
if (env) return [env];
|
|
229
|
+
// 通用供应商的 keys 优先从 providerConfigs 读取
|
|
230
|
+
const configs = readState(file).providerConfigs;
|
|
231
|
+
const cfg = configs && typeof configs === "object" ? configs[id] : undefined;
|
|
232
|
+
if (cfg && typeof cfg === "object" && Array.isArray(cfg.keys)) {
|
|
233
|
+
const list = [...new Set(cfg.keys.filter((x) => typeof x === "string" && x.trim().length))];
|
|
234
|
+
if (list.length) return list;
|
|
235
|
+
}
|
|
236
|
+
const keys = readState(file).providerKeys;
|
|
237
|
+
const v = keys && typeof keys === "object" ? keys[id] : undefined;
|
|
238
|
+
if (typeof v === "string") return v.trim() ? [v.trim()] : [];
|
|
239
|
+
if (Array.isArray(v)) return [...new Set(v.filter((x) => typeof x === "string" && x.trim().length))];
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function loadProviderKey(id, opts = {}) {
|
|
244
|
+
return loadProviderKeys(id, opts)[0] || "";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function saveProviderKeys(id, list, { file = defaultStateFile() } = {}) {
|
|
248
|
+
const keys = { ...(readState(file).providerKeys || {}) };
|
|
249
|
+
const clean = [...new Set((Array.isArray(list) ? list : []).map((k) => String(k || "").trim()).filter(Boolean))];
|
|
250
|
+
if (clean.length) keys[id] = clean;
|
|
251
|
+
else delete keys[id];
|
|
252
|
+
writeStateImmediate(file, { providerKeys: keys });
|
|
253
|
+
return clean;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function saveProviderKey(id, key, opts = {}) {
|
|
257
|
+
const list = [...loadProviderKeys(id, opts)];
|
|
258
|
+
const clean = String(key || "").trim();
|
|
259
|
+
if (clean && !list.includes(clean)) list.push(clean);
|
|
260
|
+
return saveProviderKeys(id, clean ? list : [], opts);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function addProviderKey(id, key, opts = {}) {
|
|
264
|
+
return saveProviderKey(id, key, opts);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function removeProviderKey(id, key, opts = {}) {
|
|
268
|
+
return removeProviderKeys(id, [key], opts);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function removeProviderKeys(id, targets = [], opts = {}) {
|
|
272
|
+
const set = new Set((Array.isArray(targets) ? targets : [targets]).map((k) => String(k || "").trim()).filter(Boolean));
|
|
273
|
+
const list = loadProviderKeys(id, opts).filter((k) => !set.has(k));
|
|
274
|
+
return saveProviderKeys(id, list, opts);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 供应商 key 瞬时共享开关(ADR-0008):默认 false;true = 本节点在 outgoing 转发时附带
|
|
278
|
+
// 该供应商 key 给组员(瞬时借用,不落盘)。env `MSLXDFF_<ID>_SHARE_KEYS` 可覆盖 state。
|
|
279
|
+
export const providerShareEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_SHARE_KEYS`;
|
|
280
|
+
|
|
281
|
+
export function loadProviderShareKeys(id, { file = defaultStateFile() } = {}) {
|
|
282
|
+
const env = process.env[providerShareEnv(id)] || "";
|
|
283
|
+
if (env) return ["1", "true", "on", "yes"].includes(String(env).trim().toLowerCase());
|
|
284
|
+
const map = readState(file).providerShareKeys;
|
|
285
|
+
return !!(map && typeof map === "object" && map[id]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function saveProviderShareKeys(id, on, { file = defaultStateFile() } = {}) {
|
|
289
|
+
const map = { ...(readState(file).providerShareKeys || {}) };
|
|
290
|
+
if (on) map[id] = true;
|
|
291
|
+
else delete map[id];
|
|
292
|
+
writeStateImmediate(file, { providerShareKeys: map });
|
|
293
|
+
return !!on;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---- 通用 OpenAI 兼容供应商配置:providerConfigs: { [id]: { baseUrl, keys, auths, allowedModels } } ----
|
|
297
|
+
export const WORKBUDDY_DEFAULT_BASE_URL = "https://copilot.tencent.com";
|
|
298
|
+
function normalizeBaseUrl(url) {
|
|
299
|
+
const s = String(url || "").trim();
|
|
300
|
+
if (!s) return "";
|
|
301
|
+
return s.replace(/\/+$/, "");
|
|
302
|
+
}
|
|
303
|
+
function normalizeAuths(list) {
|
|
304
|
+
if (!Array.isArray(list)) return [];
|
|
305
|
+
const out = [];
|
|
306
|
+
for (const a of list) {
|
|
307
|
+
if (!a || typeof a !== "object") continue;
|
|
308
|
+
const uid = String(a.uid || a.userId || "").trim();
|
|
309
|
+
if (!uid) continue;
|
|
310
|
+
out.push({
|
|
311
|
+
uid,
|
|
312
|
+
domain: String(a.domain || "www.codebuddy.cn").trim() || "www.codebuddy.cn",
|
|
313
|
+
enterpriseId: String(a.enterpriseId || a.enterprise_id || "").trim(),
|
|
314
|
+
refreshToken: String(a.refreshToken || a.refresh_token || "").trim(),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function normalizeAllowedModel(model, providerId) {
|
|
321
|
+
let s = String(model || "").trim();
|
|
322
|
+
if (!s) return "";
|
|
323
|
+
// 支持带前缀的输入:bai/glm-5.3-flash -> glm-5.3-flash(仅当 provider 匹配时剥前缀)
|
|
324
|
+
const idx = s.indexOf("/");
|
|
325
|
+
if (idx > 0) {
|
|
326
|
+
const head = s.slice(0, idx).toLowerCase();
|
|
327
|
+
if (head === String(providerId || "").toLowerCase()) s = s.slice(idx + 1);
|
|
328
|
+
}
|
|
329
|
+
return s;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function loadProviderConfigs({ file = defaultStateFile() } = {}) {
|
|
333
|
+
const v = readState(file).providerConfigs;
|
|
334
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
|
335
|
+
return {};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function loadProviderConfig(id, { file = defaultStateFile() } = {}) {
|
|
339
|
+
const envUrl = (process.env[providerBaseUrlEnv(id)] || "").trim();
|
|
340
|
+
const envKeys = loadProviderKeys(id, { file });
|
|
341
|
+
// env 覆盖时以 env 为准
|
|
342
|
+
if (envUrl || (process.env[providerKeyEnv(id)] || "").trim()) {
|
|
343
|
+
const baseUrl = envUrl || loadProviderConfigs({ file })[id]?.baseUrl || (String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "");
|
|
344
|
+
const cfg = loadProviderConfigs({ file })[id];
|
|
345
|
+
const allowedModels = cfg && Array.isArray(cfg.allowedModels) ? [...new Set(cfg.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
|
|
346
|
+
const auths = cfg && Array.isArray(cfg.auths) ? normalizeAuths(cfg.auths) : [];
|
|
347
|
+
if (baseUrl || envKeys.length || allowedModels.length || auths.length) return { baseUrl: normalizeBaseUrl(baseUrl), keys: envKeys, auths, allowedModels };
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
const configs = loadProviderConfigs({ file });
|
|
351
|
+
const cfg = configs[id];
|
|
352
|
+
if (cfg && typeof cfg === "object") {
|
|
353
|
+
return {
|
|
354
|
+
baseUrl: normalizeBaseUrl(cfg.baseUrl || (String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "")),
|
|
355
|
+
keys: Array.isArray(cfg.keys) ? [...new Set(cfg.keys.filter((x) => typeof x === "string" && x.trim().length))] : [],
|
|
356
|
+
auths: normalizeAuths(cfg.auths),
|
|
357
|
+
allowedModels: Array.isArray(cfg.allowedModels) ? [...new Set(cfg.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [],
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
// 兼容旧 providerKeys 形态:有 key 但无 configs 时视为通用供应商(baseUrl 为空,需后补)
|
|
361
|
+
const keys = loadProviderKeys(id, { file });
|
|
362
|
+
if (keys.length) return { baseUrl: String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "", keys, auths: [], allowedModels: [] };
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function loadProviderBaseUrl(id, opts = {}) {
|
|
367
|
+
const env = (process.env[providerBaseUrlEnv(id)] || "").trim();
|
|
368
|
+
if (env) return normalizeBaseUrl(env);
|
|
369
|
+
const cfg = loadProviderConfigs(opts)[id];
|
|
370
|
+
if (cfg && typeof cfg.baseUrl === "string" && normalizeBaseUrl(cfg.baseUrl)) return normalizeBaseUrl(cfg.baseUrl);
|
|
371
|
+
if (String(id || "").toLowerCase() === "workbuddy") return WORKBUDDY_DEFAULT_BASE_URL;
|
|
372
|
+
return "";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function loadProviderAuths(id, { file = defaultStateFile() } = {}) {
|
|
376
|
+
const cfg = loadProviderConfigs({ file })[id];
|
|
377
|
+
if (cfg && Array.isArray(cfg.auths)) return normalizeAuths(cfg.auths);
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
export function saveProviderAuths(id, list, { file = defaultStateFile() } = {}) {
|
|
381
|
+
const clean = normalizeAuths(list);
|
|
382
|
+
const configs = { ...loadProviderConfigs({ file }) };
|
|
383
|
+
const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
|
|
384
|
+
const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
|
|
385
|
+
const keys = Array.isArray(cur.keys) ? [...new Set(cur.keys.filter((x) => typeof x === "string" && x.trim().length))] : loadProviderKeys(id, { file });
|
|
386
|
+
const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
|
|
387
|
+
if (!baseUrl && !keys.length && !clean.length && !allowedModels.length) {
|
|
388
|
+
delete configs[id];
|
|
389
|
+
} else {
|
|
390
|
+
configs[id] = { baseUrl, keys };
|
|
391
|
+
if (clean.length) configs[id].auths = clean;
|
|
392
|
+
if (allowedModels.length) configs[id].allowedModels = allowedModels;
|
|
393
|
+
}
|
|
394
|
+
writeStateImmediate(file, { providerConfigs: configs });
|
|
395
|
+
return clean;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function saveProviderBaseUrl(id, baseUrl, { file = defaultStateFile() } = {}) {
|
|
399
|
+
const clean = normalizeBaseUrl(baseUrl);
|
|
400
|
+
const configs = { ...loadProviderConfigs({ file }) };
|
|
401
|
+
const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
|
|
402
|
+
const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
|
|
403
|
+
const auths = normalizeAuths(cur.auths);
|
|
404
|
+
const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
|
|
405
|
+
if (!clean && !keys.length && !auths.length && !allowedModels.length) {
|
|
406
|
+
delete configs[id];
|
|
407
|
+
} else {
|
|
408
|
+
configs[id] = { baseUrl: clean, keys };
|
|
409
|
+
if (auths.length) configs[id].auths = auths;
|
|
410
|
+
if (allowedModels.length) configs[id].allowedModels = allowedModels;
|
|
411
|
+
}
|
|
412
|
+
writeStateImmediate(file, { providerConfigs: configs });
|
|
413
|
+
return clean;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels }, { file = defaultStateFile() } = {}) {
|
|
417
|
+
const cleanUrl = normalizeBaseUrl(baseUrl);
|
|
418
|
+
const cleanKeys = [...new Set((Array.isArray(keys) ? keys : []).map((k) => String(k || "").trim()).filter(Boolean))];
|
|
419
|
+
const cleanAuths = auths === undefined ? undefined : normalizeAuths(auths);
|
|
420
|
+
const cleanAllowed = [...new Set((Array.isArray(allowedModels) ? allowedModels : []).map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
|
|
421
|
+
const configs = { ...loadProviderConfigs({ file }) };
|
|
422
|
+
const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
|
|
423
|
+
// 保留已有的 allowedModels / auths 若本次未传入
|
|
424
|
+
const finalAllowed = allowedModels === undefined ? (Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : []) : cleanAllowed;
|
|
425
|
+
const finalAuths = cleanAuths === undefined ? normalizeAuths(cur.auths) : cleanAuths;
|
|
426
|
+
if (!cleanUrl && !cleanKeys.length && !finalAllowed.length && !finalAuths.length) {
|
|
427
|
+
delete configs[id];
|
|
428
|
+
} else {
|
|
429
|
+
configs[id] = { baseUrl: cleanUrl, keys: cleanKeys };
|
|
430
|
+
if (finalAuths.length) configs[id].auths = finalAuths;
|
|
431
|
+
if (finalAllowed.length) configs[id].allowedModels = finalAllowed;
|
|
432
|
+
}
|
|
433
|
+
// 同步清理旧 providerKeys 中同 id 的残留,避免双写
|
|
434
|
+
const oldKeys = readState(file).providerKeys;
|
|
435
|
+
if (oldKeys && typeof oldKeys === "object" && oldKeys[id] !== undefined) {
|
|
436
|
+
const nextKeys = { ...oldKeys };
|
|
437
|
+
delete nextKeys[id];
|
|
438
|
+
writeStateImmediate(file, { providerKeys: nextKeys, providerConfigs: configs });
|
|
439
|
+
return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed };
|
|
440
|
+
}
|
|
441
|
+
writeStateImmediate(file, { providerConfigs: configs });
|
|
442
|
+
return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---- 供应商模型白名单:providerConfigs.<id>.allowedModels(空 = 不限) ----
|
|
446
|
+
export function loadProviderAllowedModels(id, { file = defaultStateFile() } = {}) {
|
|
447
|
+
const cfg = loadProviderConfig(id, { file });
|
|
448
|
+
if (cfg && Array.isArray(cfg.allowedModels)) return [...cfg.allowedModels];
|
|
449
|
+
const raw = readState(file).providerConfigs?.[id];
|
|
450
|
+
if (raw && Array.isArray(raw.allowedModels)) return [...new Set(raw.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function saveProviderAllowedModels(id, list, { file = defaultStateFile() } = {}) {
|
|
455
|
+
const clean = [...new Set((Array.isArray(list) ? list : []).map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
|
|
456
|
+
const configs = { ...loadProviderConfigs({ file }) };
|
|
457
|
+
const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
|
|
458
|
+
const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
|
|
459
|
+
const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
|
|
460
|
+
const auths = normalizeAuths(cur.auths);
|
|
461
|
+
if (!baseUrl && !keys.length && !auths.length && !clean.length) {
|
|
462
|
+
delete configs[id];
|
|
463
|
+
} else {
|
|
464
|
+
configs[id] = { baseUrl, keys };
|
|
465
|
+
if (auths.length) configs[id].auths = auths;
|
|
466
|
+
if (clean.length) configs[id].allowedModels = clean;
|
|
467
|
+
}
|
|
468
|
+
writeStateImmediate(file, { providerConfigs: configs });
|
|
469
|
+
return clean;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function addProviderAllowedModel(id, model, opts = {}) {
|
|
473
|
+
const cur = loadProviderAllowedModels(id, opts);
|
|
474
|
+
const norm = normalizeAllowedModel(model, id);
|
|
475
|
+
if (!norm || cur.includes(norm)) return cur;
|
|
476
|
+
return saveProviderAllowedModels(id, [...cur, norm], opts);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function removeProviderAllowedModel(id, model, opts = {}) {
|
|
480
|
+
return removeProviderAllowedModels(id, [model], opts);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function removeProviderAllowedModels(id, targets = [], opts = {}) {
|
|
484
|
+
const set = new Set((Array.isArray(targets) ? targets : [targets]).map((m) => normalizeAllowedModel(m, id)).filter(Boolean));
|
|
485
|
+
const cur = loadProviderAllowedModels(id, opts);
|
|
486
|
+
const next = cur.filter((m) => !set.has(m));
|
|
487
|
+
return saveProviderAllowedModels(id, next, opts);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function isModelAllowed(id, rawModel, { file = defaultStateFile() } = {}) {
|
|
491
|
+
const raw = String(rawModel || "").trim();
|
|
492
|
+
if (!raw) return true;
|
|
493
|
+
const allowed = loadProviderAllowedModels(id, { file });
|
|
494
|
+
if (!allowed.length) return true;
|
|
495
|
+
const norm = normalizeAllowedModel(raw, id);
|
|
496
|
+
return allowed.includes(norm);
|
|
497
|
+
}
|
|
498
|
+
|
|
204
499
|
// 常用模型勾选集(auto 候选池白名单):空数组 = 不启用筛选(全量 auto)
|
|
205
500
|
export function loadModelPicks({ file = defaultStateFile() } = {}) {
|
|
206
501
|
const picks = readState(file).modelPicks;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
export function opencodeConfigPath() {
|
|
6
|
+
const env = process.env.OPENCODE_CONFIG || process.env.OPENCODE_CONFIG_PATH;
|
|
7
|
+
if (typeof env === "string" && env.trim()) return env.trim();
|
|
8
|
+
return join(os.homedir(), ".config", "opencode", "opencode.json");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function toExternalAlias(id) {
|
|
12
|
+
const s = String(id || "").trim();
|
|
13
|
+
if (!s) return "";
|
|
14
|
+
return s.startsWith("mslxdff-") ? s : `mslxdff-${s}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function toInternalId(aliasOrRaw) {
|
|
18
|
+
const s = String(aliasOrRaw || "").trim();
|
|
19
|
+
if (!s) return "";
|
|
20
|
+
return s.startsWith("mslxdff-") ? s.slice("mslxdff-".length) : s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildOpencodeProvider({ id, token, port }) {
|
|
24
|
+
const p = Number(port) || 8989;
|
|
25
|
+
const alias = toExternalAlias(id);
|
|
26
|
+
return {
|
|
27
|
+
name: "mslxdff",
|
|
28
|
+
npm: "@ai-sdk/openai-compatible",
|
|
29
|
+
options: {
|
|
30
|
+
apiKey: String(token || ""),
|
|
31
|
+
baseURL: `http://127.0.0.1:${p}/v1`,
|
|
32
|
+
},
|
|
33
|
+
models: {
|
|
34
|
+
[alias]: { name: alias },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isOpencodeLocalUrl(url) {
|
|
40
|
+
const u = String(url || "");
|
|
41
|
+
return u.includes("127.0.0.1") && u.includes("/v1");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function syncToOpencode({ id, token, port, file } = {}) {
|
|
45
|
+
const targetFile = file || opencodeConfigPath();
|
|
46
|
+
// id 可能是 alias 或原名,统一以 internal 去重、以 external 入库(原名兼容)
|
|
47
|
+
const normalizedRaw = String(id || "").trim();
|
|
48
|
+
if (!normalizedRaw) throw new Error("model id required");
|
|
49
|
+
const internal = toInternalId(normalizedRaw);
|
|
50
|
+
if (!internal) throw new Error("model id required");
|
|
51
|
+
const external = toExternalAlias(internal);
|
|
52
|
+
const cleanToken = String(token || "");
|
|
53
|
+
const p = Number(port) || 8989;
|
|
54
|
+
|
|
55
|
+
let data = null;
|
|
56
|
+
let corrupted = false;
|
|
57
|
+
let rawText = null;
|
|
58
|
+
try {
|
|
59
|
+
rawText = readFileSync(targetFile, "utf8");
|
|
60
|
+
const parsed = JSON.parse(rawText);
|
|
61
|
+
data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (rawText !== null) {
|
|
64
|
+
corrupted = true;
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(dirname(targetFile), { recursive: true });
|
|
67
|
+
writeFileSync(targetFile + ".bak", rawText ?? "", "utf8");
|
|
68
|
+
} catch {}
|
|
69
|
+
data = {};
|
|
70
|
+
} else {
|
|
71
|
+
data = {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!data.provider || typeof data.provider !== "object" || Array.isArray(data.provider)) {
|
|
76
|
+
data.provider = {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const oldProvider = data.provider.mslxdff && typeof data.provider.mslxdff === "object" && !Array.isArray(data.provider.mslxdff)
|
|
80
|
+
? data.provider.mslxdff
|
|
81
|
+
: null;
|
|
82
|
+
|
|
83
|
+
let action;
|
|
84
|
+
let effectiveId = external;
|
|
85
|
+
if (oldProvider) {
|
|
86
|
+
const oldModels = oldProvider.models && typeof oldProvider.models === "object" && !Array.isArray(oldProvider.models)
|
|
87
|
+
? oldProvider.models
|
|
88
|
+
: {};
|
|
89
|
+
// 去重:internal 或 external 任一已存在即视为已存在(原名兼容,以 internal 为基准)
|
|
90
|
+
const hasExternal = Object.prototype.hasOwnProperty.call(oldModels, external);
|
|
91
|
+
const hasInternal = Object.prototype.hasOwnProperty.call(oldModels, internal);
|
|
92
|
+
const exists = hasExternal || hasInternal;
|
|
93
|
+
const nextModels = { ...oldModels };
|
|
94
|
+
if (exists) {
|
|
95
|
+
if (hasExternal) {
|
|
96
|
+
nextModels[external] = { name: external, ...(oldModels[external] && typeof oldModels[external] === "object" ? oldModels[external] : {}), name: external };
|
|
97
|
+
effectiveId = external;
|
|
98
|
+
} else if (hasInternal) {
|
|
99
|
+
// 仅原名存在:保留原名不强制迁移为 alias,视为 updated(原名兼容)
|
|
100
|
+
nextModels[internal] = { name: internal, ...(oldModels[internal] && typeof oldModels[internal] === "object" ? oldModels[internal] : {}), name: internal };
|
|
101
|
+
effectiveId = internal;
|
|
102
|
+
}
|
|
103
|
+
action = "updated";
|
|
104
|
+
} else {
|
|
105
|
+
nextModels[external] = { name: external };
|
|
106
|
+
effectiveId = external;
|
|
107
|
+
action = "inserted";
|
|
108
|
+
}
|
|
109
|
+
// 合并 provider:保留 name/npm,覆盖 options.baseURL/apiKey,合并 models
|
|
110
|
+
const nextProvider = {
|
|
111
|
+
...oldProvider,
|
|
112
|
+
name: oldProvider.name || "mslxdff",
|
|
113
|
+
npm: oldProvider.npm || "@ai-sdk/openai-compatible",
|
|
114
|
+
options: {
|
|
115
|
+
...(oldProvider.options && typeof oldProvider.options === "object" ? oldProvider.options : {}),
|
|
116
|
+
baseURL: `http://127.0.0.1:${p}/v1`,
|
|
117
|
+
apiKey: cleanToken,
|
|
118
|
+
},
|
|
119
|
+
models: nextModels,
|
|
120
|
+
};
|
|
121
|
+
// 若插入的是 alias 但原名已存在,上面已处理为不新增;否则正常
|
|
122
|
+
data.provider.mslxdff = nextProvider;
|
|
123
|
+
// 若是新插入且 external 不等于 internal,且 internal 已存在时,需避免双键,上面已处理
|
|
124
|
+
// 若是新插入 external 且 internal 不存在,正常插入
|
|
125
|
+
if (!exists) {
|
|
126
|
+
data.provider.mslxdff.models = nextModels;
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
data.provider.mslxdff = buildOpencodeProvider({ id: external, token: cleanToken, port: p });
|
|
130
|
+
action = "inserted";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 原子写
|
|
134
|
+
mkdirSync(dirname(targetFile), { recursive: true });
|
|
135
|
+
const tmp = `${targetFile}.tmp.${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
136
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
|
|
137
|
+
try {
|
|
138
|
+
renameSync(tmp, targetFile);
|
|
139
|
+
} catch {
|
|
140
|
+
try {
|
|
141
|
+
writeFileSync(targetFile, readFileSync(tmp, "utf8"), "utf8");
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
if (existsSync(tmp)) {
|
|
146
|
+
const { unlinkSync } = await import("node:fs");
|
|
147
|
+
unlinkSync(tmp);
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
|
|
151
|
+
return { action, file: targetFile, id: effectiveId, alias: external, internal, corrupted };
|
|
152
|
+
}
|
package/src/time.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const TZ = "Asia/Shanghai";
|
|
2
|
+
|
|
3
|
+
function shanghaiParts(d) {
|
|
4
|
+
const date = d instanceof Date ? d : new Date(d);
|
|
5
|
+
const fmt = new Intl.DateTimeFormat("en-GB", {
|
|
6
|
+
timeZone: TZ,
|
|
7
|
+
year: "numeric",
|
|
8
|
+
month: "2-digit",
|
|
9
|
+
day: "2-digit",
|
|
10
|
+
hour: "2-digit",
|
|
11
|
+
minute: "2-digit",
|
|
12
|
+
second: "2-digit",
|
|
13
|
+
hour12: false,
|
|
14
|
+
});
|
|
15
|
+
const parts = fmt.formatToParts(date);
|
|
16
|
+
const m = {};
|
|
17
|
+
for (const p of parts) m[p.type] = p.value;
|
|
18
|
+
return m; // {year, month, day, hour, minute, second}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// "MM-DD HH:mm:ss" e.g. "08-27 15:07:14"
|
|
22
|
+
export function fmtShanghai(isoOrTs) {
|
|
23
|
+
if (isoOrTs == null) return "-";
|
|
24
|
+
try {
|
|
25
|
+
const p = shanghaiParts(isoOrTs);
|
|
26
|
+
return `${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
|
|
27
|
+
} catch {
|
|
28
|
+
return "-";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// "YYYY-MM-DD HH:mm" e.g. "2026-08-27 15:07"
|
|
33
|
+
export function fmtShanghaiYMDHM(isoOrTs) {
|
|
34
|
+
if (isoOrTs == null) return "-";
|
|
35
|
+
try {
|
|
36
|
+
const p = shanghaiParts(isoOrTs);
|
|
37
|
+
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`;
|
|
38
|
+
} catch {
|
|
39
|
+
return "-";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// "YYYY-MM-DD HH:mm:ss" e.g. "2026-08-27 15:07:14"
|
|
44
|
+
export function fmtShanghaiYMDHMS(isoOrTs) {
|
|
45
|
+
if (isoOrTs == null) return "-";
|
|
46
|
+
try {
|
|
47
|
+
const p = shanghaiParts(isoOrTs);
|
|
48
|
+
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
|
|
49
|
+
} catch {
|
|
50
|
+
return "-";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// "HH:mm:ss" e.g. "15:07:14"
|
|
55
|
+
export function fmtShanghaiHMS(isoOrTs) {
|
|
56
|
+
if (isoOrTs == null) return "--:--:--";
|
|
57
|
+
try {
|
|
58
|
+
const p = shanghaiParts(isoOrTs);
|
|
59
|
+
return `${p.hour}:${p.minute}:${p.second}`;
|
|
60
|
+
} catch {
|
|
61
|
+
return "--:--:--";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 当前上海时间 "YYYY-MM-DD HH:mm" 用于 prompt
|
|
66
|
+
export function nowShanghaiYMDHM() {
|
|
67
|
+
return fmtShanghaiYMDHM(new Date());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 兼容:传入 Date 或 ts 或 ISO,都返回 "MM-DD HH:mm:ss"
|
|
71
|
+
export function fmtTsShanghai(iso) {
|
|
72
|
+
return fmtShanghai(iso);
|
|
73
|
+
}
|