mslxdff 0.1.65 → 0.1.67

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.
Files changed (65) hide show
  1. package/bin/mslxdff.js +3 -3159
  2. package/package.json +1 -1
  3. package/src/chat/cooling.js +99 -0
  4. package/src/chat/direct.js +57 -0
  5. package/src/chat/gateway.js +148 -0
  6. package/src/chat/orchestrator.js +218 -0
  7. package/src/chat/sse.js +69 -0
  8. package/src/chat/upstream.js +59 -501
  9. package/src/cli/bootstrap.js +1 -0
  10. package/src/cli/commands/daemon.js +151 -0
  11. package/src/cli/commands/group.js +272 -0
  12. package/src/cli/commands/model.js +405 -0
  13. package/src/cli/commands/provider/add.js +106 -0
  14. package/src/cli/commands/provider/allowlist.js +99 -0
  15. package/src/cli/commands/provider/config.js +82 -0
  16. package/src/cli/commands/provider/index.js +193 -0
  17. package/src/cli/commands/provider/keys.js +135 -0
  18. package/src/cli/commands/provider/models.js +99 -0
  19. package/src/cli/commands/provider.js +1 -0
  20. package/src/cli/commands/sync.js +143 -0
  21. package/src/cli/commands/system.js +236 -0
  22. package/src/cli/commands/workbuddy.js +91 -0
  23. package/src/cli/format.js +119 -0
  24. package/src/cli/group-helpers.js +69 -0
  25. package/src/cli/help.js +66 -0
  26. package/src/cli/index.js +59 -0
  27. package/src/cli/interactive.js +84 -0
  28. package/src/cli/policy.js +119 -0
  29. package/src/cli/provider-row.js +73 -0
  30. package/src/cli/status.js +283 -0
  31. package/src/cli/util.js +24 -0
  32. package/src/providers/base.js +159 -0
  33. package/src/providers/dispatcher.js +18 -6
  34. package/src/providers/generic.js +28 -166
  35. package/src/providers/openrouter.js +19 -205
  36. package/src/providers/workbuddy/auth.js +175 -0
  37. package/src/providers/workbuddy/balance.js +84 -0
  38. package/src/providers/workbuddy/chat.js +310 -0
  39. package/src/providers/workbuddy/index.js +263 -0
  40. package/src/providers/workbuddy/models.js +111 -0
  41. package/src/providers/workbuddy/rotation-log.js +54 -0
  42. package/src/providers/workbuddy.js +2 -677
  43. package/src/routes/chat/broadband-handler.js +25 -46
  44. package/src/routes/chat/exhausted-handler.js +2 -2
  45. package/src/routes/chat/gateway.js +301 -0
  46. package/src/routes/chat/hedge-handler.js +65 -83
  47. package/src/routes/chat/index.js +1 -384
  48. package/src/routes/chat/local-handler.js +32 -61
  49. package/src/routes/chat/peer-handler.js +32 -23
  50. package/src/routes/chat/relay-pipeline.js +151 -0
  51. package/src/runtime/bootstrap.js +408 -0
  52. package/src/state/facade.js +57 -0
  53. package/src/state/memory.js +161 -0
  54. package/src/state/merge.js +26 -0
  55. package/src/state/persist.js +42 -0
  56. package/src/state/provider-config.js +143 -0
  57. package/src/state/schemas/allowlist.js +87 -0
  58. package/src/state/schemas/group.js +31 -0
  59. package/src/state/schemas/model.js +53 -0
  60. package/src/state/schemas/peer.js +31 -0
  61. package/src/state/schemas/port.js +10 -0
  62. package/src/state/schemas/provider.js +204 -0
  63. package/src/state/schemas/token.js +43 -0
  64. package/src/state/store.js +176 -0
  65. package/src/state.js +1 -711
@@ -0,0 +1,161 @@
1
+ /**
2
+ * memory Store adapter — 零 IO 的 Store 第二实现,证明 Store seam 真实。
3
+ * 与 fileStore(src/state/store.js)共享同一 merge 语义与冷/热分层,
4
+ * 仅将 persist 层替换为 Map<file, {json, mtimeMs}>,不触 `node:fs`。
5
+ * 用于 `—test-concurrency>1` 并行与单测快速路径。
6
+ */
7
+ import { mergeState, COLD_WINS } from "./merge.js";
8
+
9
+ const memCache = new Map(); // file -> { data, dirty, timer, mtimeMs }
10
+ const memDisk = new Map(); // file -> { obj, mtimeMs }
11
+
12
+ function getEntry(file) {
13
+ let e = memCache.get(file);
14
+ if (!e) {
15
+ e = { data: null, dirty: false, timer: null, mtimeMs: 0 };
16
+ memCache.set(file, e);
17
+ }
18
+ return e;
19
+ }
20
+
21
+ function loadFromDiskMem(file) {
22
+ const v = memDisk.get(file);
23
+ return v ? structuredClone(v.obj) : {};
24
+ }
25
+ function getMtimeMem(file) {
26
+ const v = memDisk.get(file);
27
+ return v ? v.mtimeMs : 0;
28
+ }
29
+ function atomicWriteSyncMem(file, data) {
30
+ const now = Date.now();
31
+ // ensure monotonic mtime
32
+ const prev = memDisk.get(file)?.mtimeMs || 0;
33
+ const mtime = now <= prev ? prev + 1 : now;
34
+ memDisk.set(file, { obj: structuredClone(data), mtimeMs: mtime });
35
+ return mtime;
36
+ }
37
+
38
+ function readState(file) {
39
+ const e = getEntry(file);
40
+ if (e.data !== null) {
41
+ const mtime = getMtimeMem(file);
42
+ if (mtime === e.mtimeMs) return e.data;
43
+ if (e.dirty) {
44
+ const disk = loadFromDiskMem(file);
45
+ const merged = mergeState(disk || {}, e.data);
46
+ e.data = merged;
47
+ e.mtimeMs = mtime;
48
+ return e.data;
49
+ }
50
+ }
51
+ const disk = loadFromDiskMem(file);
52
+ e.data = disk && typeof disk === "object" ? structuredClone(disk) : {};
53
+ e.mtimeMs = getMtimeMem(file) || Date.now();
54
+ e.dirty = false;
55
+ return e.data;
56
+ }
57
+
58
+ function writeStateImmediate(file, patch) {
59
+ const e = getEntry(file);
60
+ const base = e.data !== null ? e.data : readState(file);
61
+ const merged = { ...base, ...patch };
62
+ e.data = merged;
63
+ e.dirty = false;
64
+ if (e.timer) { clearTimeout(e.timer); e.timer = null; }
65
+ e.mtimeMs = atomicWriteSyncMem(file, merged);
66
+ return merged;
67
+ }
68
+
69
+ function writeStateDeferred(file, patch) {
70
+ const e = getEntry(file);
71
+ const base = e.data !== null ? e.data : readState(file);
72
+ const merged = { ...base, ...patch };
73
+ e.data = merged;
74
+ e.dirty = true;
75
+ // 内存版无需定时刷盘,延迟写即 dirty,下次 flush 或 read 时合并
76
+ return merged;
77
+ }
78
+
79
+ export function flushStateSync(file) {
80
+ const e = memCache.get(file);
81
+ if (!e || !e.dirty || !e.data) return;
82
+ e.mtimeMs = atomicWriteSyncMem(file || "mem://default", e.data);
83
+ e.dirty = false;
84
+ if (e.timer) { clearTimeout(e.timer); e.timer = null; }
85
+ }
86
+
87
+ export function clearStateCache(file) {
88
+ if (file) memCache.delete(file);
89
+ else memCache.clear();
90
+ }
91
+
92
+ // ---- domain 工厂(复用 schemas 的 normalize 逻辑,但闭包于 memory store) ----
93
+ // 为保持零依赖,memory 侧直接内联必要的 normalize(如 normalizeAllowedModel)
94
+ // 此处仅暴露与 fileStore 对等的最小子集用于测试 seam;完整 facade 可按需扩展
95
+
96
+ const WORKBUDDY_DEFAULT_BASE_URL = "https://copilot.tencent.com";
97
+ function normalizeAllowedModel(model, providerId) {
98
+ let s = String(model || "").trim();
99
+ if (!s) return "";
100
+ const idx = s.indexOf("/");
101
+ if (idx > 0 && s.slice(0, idx).toLowerCase() === String(providerId || "").toLowerCase()) s = s.slice(idx + 1);
102
+ return s;
103
+ }
104
+ function defaultStateFileMem(file) { return file || "mem://default"; }
105
+
106
+ export function createMemoryState(file = "mem://default") {
107
+ const f = defaultStateFileMem(file);
108
+ return {
109
+ loadModelErrors: () => {
110
+ const v = readState(f).modelErrors;
111
+ return v && typeof v === "object" && !Array.isArray(v) ? structuredClone(v) : {};
112
+ },
113
+ saveModelErrors: (errors) => { writeStateDeferred(f, { modelErrors: structuredClone(errors) }); return errors; },
114
+ loadModelLatencies: () => {
115
+ const v = readState(f).modelLatencies;
116
+ return v && typeof v === "object" && !Array.isArray(v) ? structuredClone(v) : {};
117
+ },
118
+ saveModelLatencies: (v) => { writeStateDeferred(f, { modelLatencies: structuredClone(v) }); return v; },
119
+ loadProviderKeys: (id) => {
120
+ const keys = readState(f).providerKeys;
121
+ const v = keys && typeof keys === "object" ? keys[id] : undefined;
122
+ if (typeof v === "string") return v.trim() ? [v.trim()] : [];
123
+ if (Array.isArray(v)) return [...new Set(v.filter((x) => typeof x === "string" && x.trim().length))];
124
+ return [];
125
+ },
126
+ saveProviderKeys: (id, list) => {
127
+ const keys = { ...(readState(f).providerKeys || {}) };
128
+ const clean = [...new Set((Array.isArray(list) ? list : []).map((k) => String(k || "").trim()).filter(Boolean))];
129
+ if (clean.length) keys[id] = clean; else delete keys[id];
130
+ writeStateImmediate(f, { providerKeys: keys });
131
+ return clean;
132
+ },
133
+ loadProviderAllowedModels: (id) => {
134
+ const cfg = readState(f).providerConfigs?.[id];
135
+ if (cfg && Array.isArray(cfg.allowedModels)) return [...cfg.allowedModels].map((m) => normalizeAllowedModel(m, id)).filter(Boolean);
136
+ return [];
137
+ },
138
+ saveProviderAllowedModels: (id, list) => {
139
+ const clean = [...new Set((Array.isArray(list) ? list : []).map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
140
+ const configs = { ...(readState(f).providerConfigs || {}) };
141
+ const cur = configs[id] || {};
142
+ configs[id] = { ...cur, allowedModels: clean };
143
+ if (!clean.length) delete configs[id].allowedModels;
144
+ if (!configs[id].baseUrl && !(configs[id].keys||[]).length && !(configs[id].allowedModels||[]).length) delete configs[id];
145
+ writeStateImmediate(f, { providerConfigs: configs });
146
+ return clean;
147
+ },
148
+ loadPeers: () => {
149
+ const v = readState(f).peers;
150
+ return Array.isArray(v) ? structuredClone(v) : [];
151
+ },
152
+ savePeers: (peers) => { writeStateImmediate(f, { peers: structuredClone(peers) }); return peers; },
153
+ flushSync: () => flushStateSync(f),
154
+ clear: () => clearStateCache(f),
155
+ _memDisk: memDisk,
156
+ _memCache: memCache,
157
+ };
158
+ }
159
+
160
+ // 单例 memory facade(便于直接 import 使用)
161
+ export const memoryState = createMemoryState("mem://default");
@@ -0,0 +1,26 @@
1
+ /**
2
+ * merge 深模块:COLD_WINS + mergeState 纯函数
3
+ * 供 store.js 与 memory.js 同源,避免复制
4
+ */
5
+ export const COLD_WINS = new Set([
6
+ "providerConfigs",
7
+ "providerKeys",
8
+ "providerShareKeys",
9
+ "port",
10
+ "token",
11
+ "preferredModel",
12
+ "modelPicks",
13
+ "peers",
14
+ "groups",
15
+ "groupsJoined",
16
+ "bans",
17
+ "createdAt",
18
+ ]);
19
+
20
+ export function mergeState(disk, mem) {
21
+ const merged = { ...disk, ...mem };
22
+ for (const k of COLD_WINS) {
23
+ if (disk[k] !== undefined) merged[k] = disk[k];
24
+ }
25
+ return merged;
26
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, statSync, renameSync, unlinkSync } from "node:fs";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+
5
+ export function loadFromDisk(file) {
6
+ try {
7
+ return JSON.parse(readFileSync(file, "utf8"));
8
+ } catch {
9
+ return {};
10
+ }
11
+ }
12
+
13
+ export function ensureDir(file) {
14
+ mkdirSync(dirname(file), { recursive: true });
15
+ }
16
+
17
+ export function getMtimeMs(file) {
18
+ try {
19
+ return statSync(file).mtimeMs;
20
+ } catch {
21
+ return Date.now();
22
+ }
23
+ }
24
+
25
+ export function atomicWriteSync(file, data) {
26
+ ensureDir(file);
27
+ const tmp = `${file}.tmp.${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
28
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
29
+ try {
30
+ renameSync(tmp, file);
31
+ } catch {
32
+ try { writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 }); } catch {}
33
+ try { unlinkSync(tmp); } catch {}
34
+ }
35
+ return getMtimeMs(file);
36
+ }
37
+
38
+ export async function atomicWriteAsync(file, data) {
39
+ ensureDir(file);
40
+ await writeFile(file, JSON.stringify(data, null, 2), { mode: 0o600 });
41
+ return getMtimeMs(file);
42
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * ProviderConfig 深模块:7 字段 validate/normalize/merge 单点
3
+ * 纯函数,不读 env/file,直接由调用方注入三级数据
4
+ */
5
+
6
+ export const WORKBUDDY_DEFAULT_BASE_URL = "https://copilot.tencent.com";
7
+
8
+ export function normalizeBaseUrl(url) {
9
+ const s = String(url || "").trim();
10
+ if (!s) return "";
11
+ return s.replace(/\/+$/, "");
12
+ }
13
+
14
+ export function normalizeAuths(list) {
15
+ if (!Array.isArray(list)) return [];
16
+ const out = [];
17
+ for (const a of list) {
18
+ if (!a || typeof a !== "object") continue;
19
+ const uid = String(a.uid || a.userId || "").trim();
20
+ if (!uid) continue;
21
+ out.push({
22
+ uid,
23
+ domain: String(a.domain || "www.codebuddy.cn").trim() || "www.codebuddy.cn",
24
+ enterpriseId: String(a.enterpriseId || a.enterprise_id || "").trim(),
25
+ refreshToken: String(a.refreshToken || a.refresh_token || "").trim(),
26
+ });
27
+ }
28
+ return out;
29
+ }
30
+
31
+ export function normalizeAllowedModel(model, providerId) {
32
+ let s = String(model || "").trim();
33
+ if (!s) return "";
34
+ const idx = s.indexOf("/");
35
+ if (idx > 0) {
36
+ const head = s.slice(0, idx).toLowerCase();
37
+ if (head === String(providerId || "").toLowerCase()) s = s.slice(idx + 1);
38
+ }
39
+ return s;
40
+ }
41
+
42
+ export function normalizeEndpointPath(v) {
43
+ const s = String(v || "").trim();
44
+ if (!s) return "";
45
+ return s.startsWith("/") ? s : `/${s}`;
46
+ }
47
+
48
+ export function defaultModelsPath(id) {
49
+ if (String(id).toLowerCase() === "workbuddy") return "/console/enterprises/personal/models";
50
+ return "/models";
51
+ }
52
+ export function defaultChatPath(id) {
53
+ if (String(id).toLowerCase() === "workbuddy") return "/v2/chat/completions";
54
+ return "/chat/completions";
55
+ }
56
+
57
+ export const providerKeyEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_KEY`;
58
+ export const providerBaseUrlEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BASE_URL`;
59
+ export const providerShareEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_SHARE_KEYS`;
60
+
61
+ /**
62
+ * validate 7 字段:baseUrl, keys, auths, allowedModels, modelsPath, chatPath, shareKeys?(share 单独)
63
+ * 返回 {ok, clean, errors}
64
+ */
65
+ export function validateProviderConfig(raw = {}, providerId = "") {
66
+ const errors = [];
67
+ const clean = {};
68
+ // baseUrl
69
+ const baseUrl = normalizeBaseUrl(raw.baseUrl);
70
+ if (raw.baseUrl !== undefined && raw.baseUrl !== null && String(raw.baseUrl).trim() && !baseUrl) errors.push("baseUrl 不能为空");
71
+ clean.baseUrl = baseUrl;
72
+ // keys
73
+ const keys = [...new Set((Array.isArray(raw.keys) ? raw.keys : []).map((k) => String(k || "").trim()).filter(Boolean))];
74
+ clean.keys = keys;
75
+ // auths
76
+ clean.auths = normalizeAuths(raw.auths);
77
+ // allowedModels
78
+ const allowed = [...new Set((Array.isArray(raw.allowedModels) ? raw.allowedModels : []).map((m) => normalizeAllowedModel(m, providerId)).filter(Boolean))];
79
+ clean.allowedModels = allowed;
80
+ // modelsPath / chatPath
81
+ clean.modelsPath = raw.modelsPath === undefined ? undefined : (String(raw.modelsPath).trim() ? normalizeEndpointPath(raw.modelsPath) : "");
82
+ clean.chatPath = raw.chatPath === undefined ? undefined : (String(raw.chatPath).trim() ? normalizeEndpointPath(raw.chatPath) : "");
83
+ // 若 baseUrl 非空但不是 url 形态,弱校验:必须含 . 或 ://
84
+ if (clean.baseUrl && !/^https?:\/\//.test(clean.baseUrl) && !clean.baseUrl.includes(".")) {
85
+ // workbuddy 默认 https 已过,此分支仅防空
86
+ }
87
+ return { ok: errors.length === 0, clean, errors };
88
+ }
89
+
90
+ /**
91
+ * normalize 保持与 validate 一致,但不返回 ok,直接回 clean(用于落盘前归一)
92
+ */
93
+ export function normalizeProviderConfig(raw = {}, providerId = "") {
94
+ const { clean } = validateProviderConfig(raw, providerId);
95
+ return clean;
96
+ }
97
+
98
+ /**
99
+ * merge 三级:env > file > legacy
100
+ * env: {baseUrl, keys} 来自 process.env
101
+ * file: 来自 providerConfigs[id]
102
+ * legacy: 来自 providerKeys[id](字符串或数组)
103
+ */
104
+ export function mergeProviderConfig({ env = {}, file = {}, legacy = [] } = {}, providerId = "") {
105
+ // env 优先
106
+ const envKeys = Array.isArray(env.keys) ? [...new Set(env.keys.filter(Boolean))] : [];
107
+ const hasEnv = !!(env.baseUrl || envKeys.length);
108
+ if (hasEnv) {
109
+ return {
110
+ baseUrl: normalizeBaseUrl(env.baseUrl || file.baseUrl || ""),
111
+ keys: envKeys.length ? envKeys : (Array.isArray(file.keys) ? file.keys : []),
112
+ // env 模式下 file 的 allowed/auths 仍透传(与原 loadProviderConfig 一致)
113
+ allowedModels: Array.isArray(file.allowedModels) ? [...new Set(file.allowedModels.map((m) => normalizeAllowedModel(m, providerId)).filter(Boolean))] : [],
114
+ auths: normalizeAuths(file.auths),
115
+ modelsPath: file.modelsPath ? normalizeEndpointPath(file.modelsPath) : defaultModelsPath(providerId),
116
+ chatPath: file.chatPath ? normalizeEndpointPath(file.chatPath) : defaultChatPath(providerId),
117
+ };
118
+ }
119
+ // file 优先
120
+ if (file && typeof file === "object" && (file.baseUrl || (Array.isArray(file.keys) && file.keys.length) || file.allowedModels || file.auths)) {
121
+ return {
122
+ baseUrl: normalizeBaseUrl(file.baseUrl || ""),
123
+ keys: Array.isArray(file.keys) ? [...new Set(file.keys.filter(Boolean))] : [],
124
+ allowedModels: Array.isArray(file.allowedModels) ? [...new Set(file.allowedModels.map((m) => normalizeAllowedModel(m, providerId)).filter(Boolean))] : [],
125
+ auths: normalizeAuths(file.auths),
126
+ modelsPath: file.modelsPath ? normalizeEndpointPath(file.modelsPath) : defaultModelsPath(providerId),
127
+ chatPath: file.chatPath ? normalizeEndpointPath(file.chatPath) : defaultChatPath(providerId),
128
+ };
129
+ }
130
+ // legacy
131
+ const legacyKeys = Array.isArray(legacy) ? [...new Set(legacy.filter((k) => typeof k === "string" && k.trim().length))] : (typeof legacy === "string" && legacy.trim() ? [legacy.trim()] : []);
132
+ if (legacyKeys.length) {
133
+ return {
134
+ baseUrl: String(providerId).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "",
135
+ keys: legacyKeys,
136
+ allowedModels: [],
137
+ auths: [],
138
+ modelsPath: defaultModelsPath(providerId),
139
+ chatPath: defaultChatPath(providerId),
140
+ };
141
+ }
142
+ return { baseUrl: "", keys: [], allowedModels: [], auths: [], modelsPath: defaultModelsPath(providerId), chatPath: defaultChatPath(providerId) };
143
+ }
@@ -0,0 +1,87 @@
1
+ import { defaultStateFile, readState, writeStateImmediate } from "../store.js";
2
+ import {
3
+ normalizeAllowedModel,
4
+ normalizeBaseUrl,
5
+ normalizeAuths,
6
+ normalizeEndpointPath,
7
+ loadProviderConfigs,
8
+ loadProviderConfig,
9
+ loadProviderBaseUrl,
10
+ loadProviderKeys,
11
+ } from "./provider.js";
12
+
13
+ export function loadProviderAllowedModels(id, { file = defaultStateFile() } = {}) {
14
+ const cfg = loadProviderConfig(id, { file });
15
+ if (cfg && Array.isArray(cfg.allowedModels)) return [...cfg.allowedModels];
16
+ const raw = readState(file).providerConfigs?.[id];
17
+ if (raw && Array.isArray(raw.allowedModels)) return [...new Set(raw.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
18
+ return [];
19
+ }
20
+
21
+ export function saveProviderAllowedModels(id, list, { file = defaultStateFile() } = {}) {
22
+ const clean = [...new Set((Array.isArray(list) ? list : []).map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
23
+ const configs = { ...loadProviderConfigs({ file }) };
24
+ const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
25
+ const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
26
+ const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
27
+ const auths = normalizeAuths(cur.auths);
28
+ const modelsPath = typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "";
29
+ const chatPath = typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "";
30
+ if (!baseUrl && !keys.length && !auths.length && !clean.length && !modelsPath && !chatPath) {
31
+ delete configs[id];
32
+ } else {
33
+ configs[id] = { baseUrl, keys };
34
+ if (auths.length) configs[id].auths = auths;
35
+ if (clean.length) configs[id].allowedModels = clean;
36
+ if (modelsPath) configs[id].modelsPath = modelsPath;
37
+ if (chatPath) configs[id].chatPath = chatPath;
38
+ }
39
+ writeStateImmediate(file, { providerConfigs: configs });
40
+ return clean;
41
+ }
42
+
43
+ export function addProviderAllowedModel(id, model, opts = {}) {
44
+ const cur = loadProviderAllowedModels(id, opts);
45
+ const norm = normalizeAllowedModel(model, id);
46
+ if (!norm || cur.includes(norm)) return cur;
47
+ return saveProviderAllowedModels(id, [...cur, norm], opts);
48
+ }
49
+
50
+ export function removeProviderAllowedModel(id, model, opts = {}) {
51
+ return removeProviderAllowedModels(id, [model], opts);
52
+ }
53
+
54
+ export function removeProviderAllowedModels(id, targets = [], opts = {}) {
55
+ const set = new Set((Array.isArray(targets) ? targets : [targets]).map((m) => normalizeAllowedModel(m, id)).filter(Boolean));
56
+ const cur = loadProviderAllowedModels(id, opts);
57
+ const next = cur.filter((m) => !set.has(m));
58
+ return saveProviderAllowedModels(id, next, opts);
59
+ }
60
+
61
+ function normalizeAllowAny(v, id) {
62
+ if (typeof v === "boolean") return v;
63
+ if (String(id || "").toLowerCase() === "opencode") return true;
64
+ return false;
65
+ }
66
+ export function loadProviderAllowAnyModels(id, { file = defaultStateFile() } = {}) {
67
+ const cfg = readState(file).providerConfigs?.[id];
68
+ if (cfg && typeof cfg.allowAnyModels === "boolean") return cfg.allowAnyModels;
69
+ return normalizeAllowAny(undefined, id);
70
+ }
71
+ export function saveProviderAllowAnyModels(id, allowAny, { file = defaultStateFile() } = {}) {
72
+ const state = readState(file);
73
+ const cfgs = { ...(state.providerConfigs || {}) };
74
+ const cur = cfgs[id] || {};
75
+ cfgs[id] = { ...cur, allowAnyModels: normalizeAllowAny(allowAny, id) };
76
+ writeStateImmediate(file, { providerConfigs: cfgs });
77
+ return cfgs[id].allowAnyModels;
78
+ }
79
+ export function isModelAllowed(id, rawModel, { file = defaultStateFile() } = {}) {
80
+ const raw = String(rawModel || "").trim();
81
+ if (!raw) return true;
82
+ const allowed = loadProviderAllowedModels(id, { file });
83
+ const allowAny = loadProviderAllowAnyModels(id, { file });
84
+ if (!allowed.length) return allowAny;
85
+ const norm = normalizeAllowedModel(raw, id);
86
+ return allowed.includes(norm);
87
+ }
@@ -0,0 +1,31 @@
1
+ import { defaultStateFile, readState, writeStateImmediate } from "../store.js";
2
+
3
+ export function loadGroups({ file = defaultStateFile() } = {}) {
4
+ const groups = readState(file).groups;
5
+ return groups && typeof groups === "object" && !Array.isArray(groups) ? groups : {};
6
+ }
7
+
8
+ export function loadGroupsJoined({ file = defaultStateFile() } = {}) {
9
+ const joined = readState(file).groupsJoined;
10
+ return Array.isArray(joined) ? joined : [];
11
+ }
12
+
13
+ export function saveGroupsJoined(joined, { file = defaultStateFile() } = {}) {
14
+ writeStateImmediate(file, { groupsJoined: joined });
15
+ return joined;
16
+ }
17
+
18
+ export function loadBans({ file = defaultStateFile() } = {}) {
19
+ const bans = readState(file).bans;
20
+ return bans && typeof bans === "object" && !Array.isArray(bans) ? bans : {};
21
+ }
22
+
23
+ export function saveBans(bans, { file = defaultStateFile() } = {}) {
24
+ writeStateImmediate(file, { bans });
25
+ return bans;
26
+ }
27
+
28
+ export function saveGroups(groups, { file = defaultStateFile() } = {}) {
29
+ writeStateImmediate(file, { groups });
30
+ return groups;
31
+ }
@@ -0,0 +1,53 @@
1
+ import { defaultStateFile, readState, writeStateImmediate, writeStateDeferred } from "../store.js";
2
+
3
+ export function loadModelErrors({ file = defaultStateFile() } = {}) {
4
+ const errors = readState(file).modelErrors;
5
+ return errors && typeof errors === "object" && !Array.isArray(errors) ? errors : {};
6
+ }
7
+
8
+ export function saveModelErrors(errors, { file = defaultStateFile() } = {}) {
9
+ writeStateDeferred(file, { modelErrors: errors });
10
+ return errors;
11
+ }
12
+
13
+ export function loadModelLatencies({ file = defaultStateFile() } = {}) {
14
+ const lat = readState(file).modelLatencies;
15
+ return lat && typeof lat === "object" && !Array.isArray(lat) ? lat : {};
16
+ }
17
+
18
+ export function saveModelLatencies(latencies, { file = defaultStateFile() } = {}) {
19
+ writeStateDeferred(file, { modelLatencies: latencies });
20
+ return latencies;
21
+ }
22
+
23
+ export function loadModelStats({ file = defaultStateFile() } = {}) {
24
+ const s = readState(file).modelStats;
25
+ return s && typeof s === "object" && !Array.isArray(s) ? s : {};
26
+ }
27
+
28
+ export function saveModelStats(stats, { file = defaultStateFile() } = {}) {
29
+ writeStateDeferred(file, { modelStats: stats });
30
+ return stats;
31
+ }
32
+
33
+ export function loadPreferredModel({ file = defaultStateFile() } = {}) {
34
+ const v = readState(file).preferredModel;
35
+ return typeof v === "string" && v.trim() ? v.trim() : null;
36
+ }
37
+
38
+ export function loadModelPicks({ file = defaultStateFile() } = {}) {
39
+ const picks = readState(file).modelPicks;
40
+ if (!Array.isArray(picks)) return [];
41
+ return [...new Set(picks.filter((x) => typeof x === "string" && x.trim().length))];
42
+ }
43
+
44
+ export function saveModelPicks(picks, { file = defaultStateFile() } = {}) {
45
+ const list = [...new Set((Array.isArray(picks) ? picks : []).filter((x) => typeof x === "string" && x.trim().length))];
46
+ writeStateImmediate(file, { modelPicks: list });
47
+ return list;
48
+ }
49
+
50
+ export function savePreferredModel(id, { file = defaultStateFile() } = {}) {
51
+ writeStateImmediate(file, { preferredModel: String(id || "").trim() });
52
+ return String(id || "").trim();
53
+ }
@@ -0,0 +1,31 @@
1
+ import { defaultStateFile, readState, writeStateImmediate, writeStateDeferred } from "../store.js";
2
+
3
+ export function loadPeers({ file = defaultStateFile() } = {}) {
4
+ const peers = readState(file).peers;
5
+ return Array.isArray(peers) ? peers : [];
6
+ }
7
+
8
+ export function savePeers(peers, { file = defaultStateFile() } = {}) {
9
+ writeStateImmediate(file, { peers });
10
+ return peers;
11
+ }
12
+
13
+ export function loadPeerErrors({ file = defaultStateFile() } = {}) {
14
+ const errors = readState(file).peerErrors;
15
+ return errors && typeof errors === "object" && !Array.isArray(errors) ? errors : {};
16
+ }
17
+
18
+ export function savePeerErrors(errors, { file = defaultStateFile() } = {}) {
19
+ writeStateDeferred(file, { peerErrors: errors });
20
+ return errors;
21
+ }
22
+
23
+ export function loadPeerStats({ file = defaultStateFile() } = {}) {
24
+ const stats = readState(file).peerStats;
25
+ return stats && typeof stats === "object" && !Array.isArray(stats) ? stats : {};
26
+ }
27
+
28
+ export function savePeerStats(stats, { file = defaultStateFile() } = {}) {
29
+ writeStateDeferred(file, { peerStats: stats });
30
+ return stats;
31
+ }
@@ -0,0 +1,10 @@
1
+ import { defaultStateFile, readState, writeStateImmediate } from "../store.js";
2
+
3
+ export function setPort(port, { file = defaultStateFile() } = {}) {
4
+ writeStateImmediate(file, { port: Number(port) });
5
+ }
6
+
7
+ export function getPort({ file = defaultStateFile() } = {}) {
8
+ const port = readState(file).port;
9
+ return typeof port === "number" && Number.isInteger(port) && port > 0 ? port : null;
10
+ }