mslxdff 0.1.55 → 0.1.56
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 +666 -26
- 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 +20 -1
- package/src/routes/peers.js +12 -7
- package/src/routes/stream.js +9 -0
- package/src/state.js +299 -4
- 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/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;
|
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
|
+
}
|
package/src/upstream.js
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import { isFreeModel } from "./models.js";
|
|
7
7
|
import { defaultStateFile } from "./state.js";
|
|
8
|
+
import { fmtShanghaiYMDHMS } from "./time.js";
|
|
8
9
|
|
|
9
10
|
let UndiciAgent = null;
|
|
10
11
|
let UndiciFetch = null;
|
|
@@ -45,6 +46,7 @@ export function createUpstreamClient({
|
|
|
45
46
|
},
|
|
46
47
|
fetchImpl,
|
|
47
48
|
hooks,
|
|
49
|
+
keepAlive = true,
|
|
48
50
|
} = {}) {
|
|
49
51
|
// 使用 undici 的 fetch 与 Agent 配对,避免 global fetch 与 npm undici Agent 不兼容
|
|
50
52
|
if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
|
|
@@ -91,6 +93,74 @@ export function createUpstreamClient({
|
|
|
91
93
|
return true;
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
function isResponsesModel(model) {
|
|
97
|
+
const m = String(model || "").toLowerCase();
|
|
98
|
+
return m.startsWith("muse-spark");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function chatToResponsesBody(chatBody) {
|
|
102
|
+
const msgs = Array.isArray(chatBody?.messages) ? chatBody.messages : [];
|
|
103
|
+
const system = msgs.filter((m) => m.role === "system").map((m) => String(m.content || "")).join("\n");
|
|
104
|
+
const nonSystem = msgs.filter((m) => m.role !== "system");
|
|
105
|
+
// 取最后一条 user 内容作为 input,其余拼到 input 前(保留上下文)
|
|
106
|
+
const inputParts = nonSystem.map((m) => {
|
|
107
|
+
const c = m.content;
|
|
108
|
+
if (typeof c === "string") return `${m.role}: ${c}`;
|
|
109
|
+
if (Array.isArray(c)) return `${m.role}: ${c.map((x) => x.text || "").join("")}`;
|
|
110
|
+
return `${m.role}: ${String(c || "")}`;
|
|
111
|
+
});
|
|
112
|
+
const input = inputParts.join("\n\n") || "hi";
|
|
113
|
+
const out = {
|
|
114
|
+
model: chatBody.model,
|
|
115
|
+
input,
|
|
116
|
+
stream: false, // muse-spark 的 chat 流式经 responses 的 SSE 格式不同,先以非流式 200 保证可用
|
|
117
|
+
};
|
|
118
|
+
if (system) out.instructions = system;
|
|
119
|
+
if (chatBody.tools) out.tools = chatBody.tools;
|
|
120
|
+
if (chatBody.tool_choice) out.tool_choice = chatBody.tool_choice;
|
|
121
|
+
if (chatBody.temperature != null) out.temperature = chatBody.temperature;
|
|
122
|
+
if (chatBody.max_tokens != null) out.max_output_tokens = chatBody.max_tokens;
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function responsesToChatJson(respJson) {
|
|
127
|
+
// responses: {id, model, output:[{type:reasoning},{type:message, content:[{type:output_text,text}]}]}
|
|
128
|
+
let text = "";
|
|
129
|
+
for (const item of respJson.output || []) {
|
|
130
|
+
if (item.type === "message" && item.role === "assistant") {
|
|
131
|
+
for (const c of item.content || []) {
|
|
132
|
+
if (c.type === "output_text") text += c.text || "";
|
|
133
|
+
else if (c.type === "text") text += c.text || "";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!text) {
|
|
138
|
+
// 兜底:取 output_text 的第一个
|
|
139
|
+
for (const item of respJson.output || []) {
|
|
140
|
+
if (item.type === "message") {
|
|
141
|
+
const t = item.content?.[0]?.text;
|
|
142
|
+
if (t) { text = t; break; }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!text) text = "";
|
|
147
|
+
const chatJson = {
|
|
148
|
+
id: respJson.id || `resp_${Date.now()}`,
|
|
149
|
+
object: "chat.completion",
|
|
150
|
+
created: Math.floor((respJson.created_at || Date.now() / 1000)),
|
|
151
|
+
model: respJson.model,
|
|
152
|
+
choices: [
|
|
153
|
+
{
|
|
154
|
+
index: 0,
|
|
155
|
+
finish_reason: respJson.status === "completed" ? "stop" : "length",
|
|
156
|
+
message: { role: "assistant", content: text },
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
usage: respJson.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
160
|
+
};
|
|
161
|
+
return chatJson;
|
|
162
|
+
}
|
|
163
|
+
|
|
94
164
|
function freeAnonLogFile() {
|
|
95
165
|
// 按用户要求:放在当前项目目录,方便一眼看到;可用 env 覆盖
|
|
96
166
|
return process.env.MSLXDFF_FREE_ANON_LOG || join(process.cwd(), "free-anon-extra.txt");
|
|
@@ -98,7 +168,7 @@ export function createUpstreamClient({
|
|
|
98
168
|
|
|
99
169
|
function logFreeAnon({ model, publicStatus, anonStatus, anonAttempts, hit, totalMs }) {
|
|
100
170
|
const file = freeAnonLogFile();
|
|
101
|
-
const line = `${new Date()
|
|
171
|
+
const line = `${fmtShanghaiYMDHMS(new Date())} model=${model} public=429 anonTries=${anonAttempts} anonStatus=${anonStatus ?? "none"} hit=${hit ? "YES额外额度" : "NO无额外"} totalMs=${totalMs} count=${hit ? "1" : "0"}\n`;
|
|
102
172
|
try {
|
|
103
173
|
mkdirSync(dirname(file), { recursive: true });
|
|
104
174
|
} catch {}
|
|
@@ -118,7 +188,7 @@ export function createUpstreamClient({
|
|
|
118
188
|
const keepAliveConnections = envInt("MSLXDFF_UPSTREAM_KEEPALIVE_CONNECTIONS", 20);
|
|
119
189
|
let dispatcher = null;
|
|
120
190
|
let agent = null;
|
|
121
|
-
if (UndiciAgent) {
|
|
191
|
+
if (keepAlive && UndiciAgent) {
|
|
122
192
|
try {
|
|
123
193
|
agent = new UndiciAgent({
|
|
124
194
|
keepAliveTimeout,
|
|
@@ -134,13 +204,15 @@ export function createUpstreamClient({
|
|
|
134
204
|
}
|
|
135
205
|
|
|
136
206
|
async function chat(body) {
|
|
137
|
-
const
|
|
207
|
+
const isResp = isResponsesModel(body?.model);
|
|
208
|
+
const url = isResp ? `${baseUrl}/zen/v1/responses` : `${baseUrl}/zen/v1/chat/completions`;
|
|
209
|
+
const reqBody = isResp ? chatToResponsesBody(body) : body;
|
|
138
210
|
const t0 = performance.now();
|
|
139
211
|
const attempts = [];
|
|
140
212
|
let waitMs = 0;
|
|
141
213
|
for (let attempt = 0; ; attempt++) {
|
|
142
214
|
const t = performance.now();
|
|
143
|
-
const result = await attemptOnce(url,
|
|
215
|
+
const result = await attemptOnce(url, reqBody);
|
|
144
216
|
attempts.push({
|
|
145
217
|
attempt,
|
|
146
218
|
type: result instanceof Error ? "network" : `http${result.status}`,
|
|
@@ -177,7 +249,7 @@ export function createUpstreamClient({
|
|
|
177
249
|
await sleep(anonDelay);
|
|
178
250
|
waitMs += anonDelay;
|
|
179
251
|
const t2 = performance.now();
|
|
180
|
-
anonResult = await attemptOnce(url,
|
|
252
|
+
anonResult = await attemptOnce(url, reqBody, { anonymous: true });
|
|
181
253
|
attempts.push({
|
|
182
254
|
attempt: `anon-${i}`,
|
|
183
255
|
type: anonResult instanceof Error ? "network" : `http${anonResult.status}`,
|
|
@@ -185,7 +257,23 @@ export function createUpstreamClient({
|
|
|
185
257
|
});
|
|
186
258
|
if (anonResult instanceof Error) continue;
|
|
187
259
|
if (anonResult.status !== 429) {
|
|
188
|
-
|
|
260
|
+
// responses 模型的 anon 命中也需转回 chat 形状
|
|
261
|
+
let outAnon = anonResult;
|
|
262
|
+
if (isResp && anonResult.ok) {
|
|
263
|
+
try {
|
|
264
|
+
const txt = await anonResult.text();
|
|
265
|
+
const j = JSON.parse(txt);
|
|
266
|
+
if (j && Array.isArray(j.output)) {
|
|
267
|
+
const chatJson = responsesToChatJson(j);
|
|
268
|
+
const newHeaders = new Headers(anonResult.headers);
|
|
269
|
+
newHeaders.set("content-type", "application/json");
|
|
270
|
+
outAnon = new Response(JSON.stringify(chatJson), { status: anonResult.status, headers: newHeaders });
|
|
271
|
+
} else {
|
|
272
|
+
outAnon = new Response(txt, { status: anonResult.status, headers: anonResult.headers });
|
|
273
|
+
}
|
|
274
|
+
} catch { outAnon = anonResult; }
|
|
275
|
+
}
|
|
276
|
+
outAnon._t = {
|
|
189
277
|
attempts,
|
|
190
278
|
waitMs,
|
|
191
279
|
totalMs: Math.round(performance.now() - t0),
|
|
@@ -195,7 +283,7 @@ export function createUpstreamClient({
|
|
|
195
283
|
hit = true;
|
|
196
284
|
hitAt = i + 1;
|
|
197
285
|
consecutiveHits += 1;
|
|
198
|
-
logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonResult.status, anonAttempts: i + 1, hit: true, totalMs:
|
|
286
|
+
logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonResult.status, anonAttempts: i + 1, hit: true, totalMs: outAnon._t.totalMs });
|
|
199
287
|
// 额外在 txt 里强调连续命中
|
|
200
288
|
try {
|
|
201
289
|
if (consecutiveHits >= 2) {
|
|
@@ -204,7 +292,7 @@ export function createUpstreamClient({
|
|
|
204
292
|
}
|
|
205
293
|
} catch {}
|
|
206
294
|
if (process.env.MSLXDFF_DEBUG === "1") try { console.log(`[free-anon] ${body?.model} public 429 -> anon ${anonResult.status} after ${i + 1} try HIT`); } catch {}
|
|
207
|
-
return
|
|
295
|
+
return outAnon;
|
|
208
296
|
}
|
|
209
297
|
}
|
|
210
298
|
// 3 次 anon 仍 429
|
|
@@ -222,6 +310,36 @@ export function createUpstreamClient({
|
|
|
222
310
|
return anonResult;
|
|
223
311
|
}
|
|
224
312
|
}
|
|
313
|
+
// Muse Spark 走 /responses,需把 responses 的 JSON 转回 chat.completions 形状,保持上层无感
|
|
314
|
+
if (isResp && result.ok) {
|
|
315
|
+
try {
|
|
316
|
+
const txt = await result.text();
|
|
317
|
+
const j = JSON.parse(txt);
|
|
318
|
+
// responses 成功态:{output:[...]};chat 态:{choices:[...]}。非 responses 形状则原样回
|
|
319
|
+
if (j && Array.isArray(j.output)) {
|
|
320
|
+
const chatJson = responsesToChatJson(j);
|
|
321
|
+
const newBody = JSON.stringify(chatJson);
|
|
322
|
+
const newHeaders = new Headers(result.headers);
|
|
323
|
+
newHeaders.set("content-type", "application/json");
|
|
324
|
+
const transformed = new Response(newBody, { status: result.status, headers: newHeaders });
|
|
325
|
+
transformed._t = {
|
|
326
|
+
attempts,
|
|
327
|
+
waitMs,
|
|
328
|
+
totalMs: Math.round(performance.now() - t0),
|
|
329
|
+
};
|
|
330
|
+
// 保留原始 timing 供日志
|
|
331
|
+
if (result._t) transformed._t = { ...transformed._t, ...result._t };
|
|
332
|
+
return transformed;
|
|
333
|
+
}
|
|
334
|
+
// 不是 responses 形状,回退:用原文本重建可读 Response
|
|
335
|
+
const fallback = new Response(txt, { status: result.status, headers: result.headers });
|
|
336
|
+
fallback._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
337
|
+
return fallback;
|
|
338
|
+
} catch {
|
|
339
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
225
343
|
result._t = {
|
|
226
344
|
attempts,
|
|
227
345
|
waitMs,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# ADR-0001: Inject a reasoning_content placeholder on outbound assistant messages
|
|
2
|
-
|
|
3
|
-
The Zen upstream's thinking-mode models (deepseek-family at minimum) return
|
|
4
|
-
`400 "The reasoning_content in the thinking mode must be passed back"` when a
|
|
5
|
-
multi-turn request echoes an assistant message without its `reasoning_content`.
|
|
6
|
-
Clients speaking plain OpenAI format never send that field, so the proxy writes
|
|
7
|
-
a `" "` placeholder into assistant messages before forwarding. Scope is `all`
|
|
8
|
-
for deepseek-family models and `tool_calls` for kimi-family models; messages
|
|
9
|
-
that already carry non-empty `reasoning_content` are left untouched.
|
|
10
|
-
|
|
11
|
-
The alternative — telling clients to manage `reasoning_content` themselves —
|
|
12
|
-
would break standard OpenAI-compatible clients, so the proxy eats this
|
|
13
|
-
compatibility cost instead. Matches `/root/9router` v0.5.45
|
|
14
|
-
`open-sse/utils/reasoningContentInjector.js`.
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# ADR-0002: /v1/models exposes only free models, matched by suffix or whitelist
|
|
2
|
-
|
|
3
|
-
The upstream `/zen/v1/models` list contains ~60 models; exposing them all would
|
|
4
|
-
pollute clients with paid models this proxy can't serve for free. `/v1/models`
|
|
5
|
-
therefore filters to: `id` ending in `-free`, OR the explicit whitelist entry
|
|
6
|
-
`big-pickle`. The whitelist exists because `big-pickle` is a free model without
|
|
7
|
-
the `-free` suffix, and a suffix-only filter would silently drop it.
|
|
8
|
-
|
|
9
|
-
A plain `endsWith("-free")` filter was considered and rejected for exactly that
|
|
10
|
-
reason. Matches `/root/9router` v0.5.45
|
|
11
|
-
`src/app/api/providers/suggested-models/filters.js`
|
|
12
|
-
(`KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"]`).
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# ADR-0003: Zero-state, no-DB, no-account local proxy
|
|
2
|
-
|
|
3
|
-
Status: partially superseded by [ADR-0004](./0004-bearer-token.md) — the
|
|
4
|
-
no-auth clause below is replaced; the zero-DB / no-account / no-cloud principles
|
|
5
|
-
stand.
|
|
6
|
-
|
|
7
|
-
By design this proxy holds no database, no token store, no account rotation,
|
|
8
|
-
and no cloud sync. A single static bearer token is the only credential, kept
|
|
9
|
-
in a 0600 state file (see ADR-0004); everything else is stateless per-process
|
|
10
|
-
memory at most.
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# ADR-0004: Single static bearer token, persisted in a 0600 state file
|
|
2
|
-
|
|
3
|
-
The proxy requires a bearer token on `/v1/*` so an accidentally-exposed port
|
|
4
|
-
isn't an open relay. There is no account system: the token is a random
|
|
5
|
-
`crypto` 32-byte value (hex), generated once on first run, persisted to a
|
|
6
|
-
state file (default `~/.config/mslxdff/state.json`, `0600`, path overridable
|
|
7
|
-
via `MSLXDFF_STATE_FILE`), and printed to stdout on creation. Rotate with
|
|
8
|
-
`mslxdff -refresh-token`, which regenerates, rewrites the file, prints the
|
|
9
|
-
new token, and exits (does not start the server).
|
|
10
|
-
|
|
11
|
-
Auth is enforced with a constant-time string compare on
|
|
12
|
-
`Authorization: Bearer <token>`; mismatches get `401` with `WWW-Authenticate`.
|
|
13
|
-
`/health` stays public (no token). Tokens never appear in logs.
|
|
14
|
-
|
|
15
|
-
Alternatives rejected: a fixed default token (same key on every install),
|
|
16
|
-
per-user accounts (needs a DB — that's the 9Router provisioning surface we
|
|
17
|
-
rejected in ADR-0003), and unauthenticated local-only binding (fragile;
|
|
18
|
-
a proxy relay deserves an explicit secret even on localhost).
|