mslxdff 0.1.66 → 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.
- package/package.json +1 -1
- package/src/chat/cooling.js +99 -0
- package/src/chat/direct.js +57 -0
- package/src/chat/gateway.js +148 -0
- package/src/chat/orchestrator.js +218 -0
- package/src/chat/sse.js +69 -0
- package/src/chat/upstream.js +59 -501
- package/src/cli/bootstrap.js +1 -408
- package/src/cli/provider-row.js +73 -0
- package/src/cli/status.js +3 -52
- package/src/providers/dispatcher.js +18 -6
- package/src/providers/generic.js +2 -0
- package/src/providers/openrouter.js +16 -72
- package/src/providers/workbuddy/auth.js +175 -0
- package/src/providers/workbuddy/balance.js +84 -0
- package/src/providers/workbuddy/chat.js +310 -0
- package/src/providers/workbuddy/index.js +263 -0
- package/src/providers/workbuddy/models.js +111 -0
- package/src/providers/workbuddy/rotation-log.js +54 -0
- package/src/providers/workbuddy.js +2 -652
- package/src/routes/chat/broadband-handler.js +25 -46
- package/src/routes/chat/exhausted-handler.js +2 -2
- package/src/routes/chat/hedge-handler.js +65 -83
- package/src/routes/chat/local-handler.js +32 -61
- package/src/routes/chat/peer-handler.js +32 -23
- package/src/routes/chat/relay-pipeline.js +151 -0
- package/src/runtime/bootstrap.js +408 -0
- package/src/state/memory.js +2 -21
- package/src/state/merge.js +26 -0
- package/src/state/provider-config.js +143 -0
- package/src/state/schemas/provider.js +83 -133
- package/src/state/store.js +2 -28
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { join, dirname } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { startServer } from "../server.js";
|
|
4
|
+
import { defaultStateFile } from "../state.js";
|
|
5
|
+
import { createRouter } from "../routes.js";
|
|
6
|
+
import { createUpstreamClient } from "../upstream.js";
|
|
7
|
+
import { createModelsService } from "../models.js";
|
|
8
|
+
import { loadToken } from "../state.js";
|
|
9
|
+
import { createAutoSelector } from "../auto.js";
|
|
10
|
+
import { createPeersService } from "../peers.js";
|
|
11
|
+
import { createEventBus } from "../events.js";
|
|
12
|
+
import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../groups.js";
|
|
13
|
+
import { logDir, appendCall, appendError, appendEvent } from "../logs.js";
|
|
14
|
+
import { loadPlugins, runHook, resolvePluginDirs } from "../plugins.js";
|
|
15
|
+
import { createOpenCodeProvider } from "../providers/opencode.js";
|
|
16
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs } from "../state.js";
|
|
17
|
+
import { effectiveHost, refreshIntervalMs, modelCooldownMs, slowCooldownMs, peerCooldownMs, peerHeatMs, maxHopsValue, groupSyncIntervalMs, autoUpdateIntervalMs, banWindowMs, banThreshold } from "./policy.js";
|
|
18
|
+
import { fmtEvent } from "./format.js";
|
|
19
|
+
import { errMsg, npmCmd, run } from "./util.js";
|
|
20
|
+
import { loadGroupsJoined } from "../state.js";
|
|
21
|
+
import { writePid } from "../daemon.js";
|
|
22
|
+
import { resolvePort } from "../server.js";
|
|
23
|
+
|
|
24
|
+
const logs = { appendCall, appendError, appendEvent };
|
|
25
|
+
|
|
26
|
+
export async function startDaemonMain(VERSION) {
|
|
27
|
+
const { token, created } = await loadToken();
|
|
28
|
+
const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
29
|
+
// src/cli/bootstrap.js -> 2 levels up to src, then 1 to root
|
|
30
|
+
const pkgRoot2 = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
31
|
+
const pluginDirs = resolvePluginDirs({ pkgRoot: pkgRoot2 });
|
|
32
|
+
const { plugins: loadedPlugins, errors: pluginErrors } = await loadPlugins({ dirs: pluginDirs });
|
|
33
|
+
for (const e of pluginErrors) {
|
|
34
|
+
console.log(`plugin load failed: ${e.file} — ${e.error}`);
|
|
35
|
+
appendEvent({ ts: Date.now(), type: "plugin-load-error", file: e.file, error: e.error });
|
|
36
|
+
}
|
|
37
|
+
if (loadedPlugins.length) {
|
|
38
|
+
console.log(`plugins loaded (${loadedPlugins.length}): ${loadedPlugins.map((p) => `${p.name}${p.version ? `@${p.version}` : ""}`).join(", ")}`);
|
|
39
|
+
appendEvent({ ts: Date.now(), type: "plugins-loaded", plugins: loadedPlugins.map((p) => ({ name: p.name, version: p.version })) });
|
|
40
|
+
}
|
|
41
|
+
const upstreamHooks = loadedPlugins.length
|
|
42
|
+
? (name, ctx) => runHook(loadedPlugins, name, ctx)
|
|
43
|
+
: null;
|
|
44
|
+
const providerPlugin = loadedPlugins.find((p) => typeof p.createUpstream === "function");
|
|
45
|
+
let upstream;
|
|
46
|
+
const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
|
|
47
|
+
let providers = [];
|
|
48
|
+
if (providerPlugin) {
|
|
49
|
+
if (!upstream) {
|
|
50
|
+
try {
|
|
51
|
+
upstream = await providerPlugin.createUpstream({ baseUrl, authToken: process.env.UPSTREAM_AUTH_TOKEN || "public", env: process.env });
|
|
52
|
+
console.log(`upstream provider replaced by plugin: ${providerPlugin.name}`);
|
|
53
|
+
appendEvent({ ts: Date.now(), type: "plugin-upstream-active", plugin: providerPlugin.name });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.log(`plugin upstream (${providerPlugin.name}) failed: ${errMsg(err)} — falling back to default`);
|
|
56
|
+
appendEvent({ ts: Date.now(), type: "plugin-upstream-error", plugin: providerPlugin.name, error: errMsg(err) });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
|
|
60
|
+
} else {
|
|
61
|
+
const opencodeClient = createUpstreamClient({ hooks: upstreamHooks });
|
|
62
|
+
const opencodeModels = createModelsService({
|
|
63
|
+
baseUrl,
|
|
64
|
+
headers: opencodeClient.headers,
|
|
65
|
+
refreshMs: refreshIntervalMs(),
|
|
66
|
+
cacheFile: join(logDir(), "models.json"),
|
|
67
|
+
});
|
|
68
|
+
providers.push(createOpenCodeProvider({ upstream: opencodeClient, modelsService: opencodeModels }));
|
|
69
|
+
const orKeys = loadProviderKeys("openrouter");
|
|
70
|
+
if (orKeys.length) {
|
|
71
|
+
const { createOpenRouterProvider } = await import("../providers/openrouter.js");
|
|
72
|
+
providers.push(createOpenRouterProvider({ apiKeys: orKeys }));
|
|
73
|
+
console.log(`provider enabled: openrouter (${orKeys.length} key${orKeys.length > 1 ? "s" : ""})`);
|
|
74
|
+
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "openrouter", keys: orKeys.length });
|
|
75
|
+
}
|
|
76
|
+
const genericConfigs = loadProviderConfigs();
|
|
77
|
+
for (const [gid, cfg] of Object.entries(genericConfigs)) {
|
|
78
|
+
if (gid === "opencode" || gid === "openrouter") continue;
|
|
79
|
+
if (gid === "workbuddy") {
|
|
80
|
+
const base = String(cfg?.baseUrl || "").trim() || "https://copilot.tencent.com";
|
|
81
|
+
const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
|
|
82
|
+
const auths = Array.isArray(cfg?.auths) ? cfg.auths : [];
|
|
83
|
+
if (!keys.length) continue;
|
|
84
|
+
try {
|
|
85
|
+
const { createWorkbuddyProvider } = await import("../providers/workbuddy.js");
|
|
86
|
+
providers.push(createWorkbuddyProvider({ baseUrl: base, apiKeys: keys, auths }));
|
|
87
|
+
console.log(`provider enabled: workbuddy (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
|
|
88
|
+
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.log(`provider ${gid} failed: ${err?.message || err}`);
|
|
91
|
+
appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const base = String(cfg?.baseUrl || "").trim();
|
|
96
|
+
const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
|
|
97
|
+
if (!base || !keys.length) continue;
|
|
98
|
+
try {
|
|
99
|
+
const { createGenericProvider } = await import("../providers/generic.js");
|
|
100
|
+
providers.push(createGenericProvider({ id: gid, baseUrl: base, apiKeys: keys }));
|
|
101
|
+
console.log(`provider enabled: ${gid} (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
|
|
102
|
+
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.log(`provider ${gid} failed: ${err?.message || err}`);
|
|
105
|
+
appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!genericConfigs["workbuddy"]) {
|
|
109
|
+
const wbKeys = loadProviderKeys("workbuddy");
|
|
110
|
+
if (wbKeys.length) {
|
|
111
|
+
try {
|
|
112
|
+
const { createWorkbuddyProvider } = await import("../providers/workbuddy.js");
|
|
113
|
+
const wbAuths = loadProviderAuths("workbuddy");
|
|
114
|
+
providers.push(createWorkbuddyProvider({ apiKeys: wbKeys, auths: wbAuths }));
|
|
115
|
+
console.log(`provider enabled: workbuddy (${wbKeys.length} key${wbKeys.length > 1 ? "s" : ""}) baseUrl=https://copilot.tencent.com (env)`);
|
|
116
|
+
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "workbuddy", keys: wbKeys.length, baseUrl: "https://copilot.tencent.com" });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.log(`provider workbuddy failed: ${err?.message || err}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const { createProviderDispatcher } = await import("../providers/dispatcher.js");
|
|
123
|
+
upstream = createProviderDispatcher(providers);
|
|
124
|
+
appendEvent({ ts: Date.now(), type: "providers", providers: providers.map((p) => p.id) });
|
|
125
|
+
}
|
|
126
|
+
const opencodeProvider = providers.find((p) => p.id === "opencode");
|
|
127
|
+
const models = createModelsService({
|
|
128
|
+
providers: providers.length > 1 ? providers : undefined,
|
|
129
|
+
baseUrl,
|
|
130
|
+
headers: upstream.headers || opencodeProvider?.upstream?.headers,
|
|
131
|
+
refreshMs: refreshIntervalMs(),
|
|
132
|
+
cacheFile: join(logDir(), "models.json"),
|
|
133
|
+
});
|
|
134
|
+
const auto = createAutoSelector({
|
|
135
|
+
cooldownMs: modelCooldownMs(),
|
|
136
|
+
slowCooldownMs: slowCooldownMs(),
|
|
137
|
+
file: defaultStateFile(),
|
|
138
|
+
loadCandidates: async () => {
|
|
139
|
+
try {
|
|
140
|
+
return (await models.get()).data.map((m) => m.id);
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const peers = createPeersService({ cooldownMs: peerCooldownMs(), heatMs: peerHeatMs() });
|
|
147
|
+
const groups = createGroupsService({});
|
|
148
|
+
const bans = createBansService({ windowMs: banWindowMs(), threshold: banThreshold() });
|
|
149
|
+
|
|
150
|
+
const isDebug = process.env.MSLXDFF_DEBUG === "1";
|
|
151
|
+
const bus = createEventBus();
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const { loadModelAliases } = await import("../providers/model-id.js");
|
|
155
|
+
loadModelAliases();
|
|
156
|
+
} catch {}
|
|
157
|
+
|
|
158
|
+
const router = createRouter({ token, upstream, models, auto, logs, peers, maxHops: maxHopsValue(), groups, bans, bus, plugins: loadedPlugins });
|
|
159
|
+
const listenHost = effectiveHost();
|
|
160
|
+
const srv = startServer({
|
|
161
|
+
router,
|
|
162
|
+
signals: !isDebug,
|
|
163
|
+
host: listenHost,
|
|
164
|
+
onBeforeClose: loadedPlugins.length
|
|
165
|
+
? () => runHook(loadedPlugins, "server:stop", { version: VERSION }).then(() => {})
|
|
166
|
+
: undefined,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (isDebug) {
|
|
170
|
+
bus.subscribe((e) => {
|
|
171
|
+
try {
|
|
172
|
+
console.log(fmtEvent(e));
|
|
173
|
+
} catch {}
|
|
174
|
+
});
|
|
175
|
+
const { startDaemon: sd } = await import("../daemon.js");
|
|
176
|
+
const restore2 = () => {
|
|
177
|
+
console.log("\n[debug] restoring background daemon...");
|
|
178
|
+
try {
|
|
179
|
+
const restoredPid = sd([]);
|
|
180
|
+
console.log(`[debug] daemon restored (pid ${restoredPid})`);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error(`[debug] could not restore daemon: ${err.message}`);
|
|
183
|
+
}
|
|
184
|
+
setTimeout(() => process.exit(0), 300);
|
|
185
|
+
};
|
|
186
|
+
process.on("SIGINT", restore2);
|
|
187
|
+
process.on("SIGTERM", restore2);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await srv.ready();
|
|
191
|
+
|
|
192
|
+
if (loadedPlugins.length) {
|
|
193
|
+
runHook(loadedPlugins, "server:start", { port: srv.server.address()?.port, host: listenHost, version: VERSION }).catch(() => {});
|
|
194
|
+
const eventPlugins = loadedPlugins.filter((p) => typeof p.onEvent === "function");
|
|
195
|
+
if (eventPlugins.length) {
|
|
196
|
+
bus.subscribe((e) => {
|
|
197
|
+
for (const p of eventPlugins) {
|
|
198
|
+
try { p.onEvent(e); } catch {}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setTimeout(() => {
|
|
205
|
+
upstream.preheat().then((r) => {
|
|
206
|
+
const entry = { ts: Date.now(), type: "upstream-preheat", ...r, baseUrl };
|
|
207
|
+
try { bus.emit(entry); } catch {}
|
|
208
|
+
try { logs.appendEvent(entry); } catch {}
|
|
209
|
+
if (r.skipped) console.log(`[preheat] skipped (MSLXDFF_PREHEAT disabled)`);
|
|
210
|
+
else if (r.ok) console.log(`[preheat] opencode models ok ${r.status} ${r.ms}ms`);
|
|
211
|
+
else console.log(`[preheat] opencode models failed ${r.error || r.status || ""} ${r.ms || 0}ms`);
|
|
212
|
+
}).catch(() => {});
|
|
213
|
+
}, 100).unref?.();
|
|
214
|
+
|
|
215
|
+
models.startAutoRefresh();
|
|
216
|
+
if (process.env.MSLXDFF_DAEMON) {
|
|
217
|
+
writePid(process.pid, VERSION);
|
|
218
|
+
}
|
|
219
|
+
const addr = srv.server.address();
|
|
220
|
+
const host = addr.address === "0.0.0.0" || addr.address === "::" ? "localhost" : addr.address;
|
|
221
|
+
console.log(`mslxdff v${VERSION} listening on http://${host}:${addr.port}`);
|
|
222
|
+
if (created) {
|
|
223
|
+
console.log(`auth token: ${token}`);
|
|
224
|
+
}
|
|
225
|
+
console.log(`endpoint: http://${host}:${addr.port}/v1`);
|
|
226
|
+
try {
|
|
227
|
+
const { hedgeDelayMs } = await import("../routes/hedge.js");
|
|
228
|
+
const hd = hedgeDelayMs();
|
|
229
|
+
console.log(`hedge: ${hd ? `${hd}ms` : "off"} (MSLXDFF_HEDGE_DELAY_MS)`);
|
|
230
|
+
} catch {}
|
|
231
|
+
|
|
232
|
+
// group sync
|
|
233
|
+
const { syncAllJoinedGroups } = await import("./group-helpers.js");
|
|
234
|
+
syncAllJoinedGroups({ peers, groups })
|
|
235
|
+
.then((results) => {
|
|
236
|
+
for (const r of results) {
|
|
237
|
+
if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
|
|
238
|
+
else console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
.catch((err) => console.log(`group sync: ${errMsg(err)}`));
|
|
242
|
+
const groupSyncTimer = setInterval(() => {
|
|
243
|
+
syncAllJoinedGroups({ peers, groups })
|
|
244
|
+
.then((results) => {
|
|
245
|
+
for (const r of results) {
|
|
246
|
+
if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
|
|
247
|
+
else if (r.added) console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
.catch((err) => console.log(`group sync: ${errMsg(err)}`));
|
|
251
|
+
}, groupSyncIntervalMs());
|
|
252
|
+
groupSyncTimer.unref();
|
|
253
|
+
|
|
254
|
+
const broadbandGroups = () => loadGroupsJoined().filter((g) => g.kind === "broadband" && g.leaderUrl);
|
|
255
|
+
if (broadbandGroups().length) {
|
|
256
|
+
const doHeartbeat = async () => {
|
|
257
|
+
for (const g of broadbandGroups()) {
|
|
258
|
+
try {
|
|
259
|
+
const res = await fetch(`${g.leaderUrl}/v1/groups/relay/heartbeat`, {
|
|
260
|
+
method: "POST",
|
|
261
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
262
|
+
body: JSON.stringify({ name: g.name, group: g.name }),
|
|
263
|
+
signal: AbortSignal.timeout(5000),
|
|
264
|
+
});
|
|
265
|
+
if (!res.ok) {
|
|
266
|
+
const txt = await res.text().catch(() => "");
|
|
267
|
+
console.log(`broadband heartbeat ${g.name}: ${res.status} ${txt.slice(0, 100)}`);
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.log(`broadband heartbeat ${g.name}: failed — ${errMsg(err)}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const doPoll = async () => {
|
|
275
|
+
for (const g of broadbandGroups()) {
|
|
276
|
+
try {
|
|
277
|
+
const pollRes = await fetch(`${g.leaderUrl}/v1/groups/relay/poll`, {
|
|
278
|
+
method: "POST",
|
|
279
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
280
|
+
body: JSON.stringify({ name: g.name, group: g.name }),
|
|
281
|
+
signal: AbortSignal.timeout(8000),
|
|
282
|
+
});
|
|
283
|
+
if (!pollRes.ok) continue;
|
|
284
|
+
const data = await pollRes.json().catch(() => ({}));
|
|
285
|
+
const items = data.data || [];
|
|
286
|
+
for (const item of items) {
|
|
287
|
+
const { reqId, body } = item;
|
|
288
|
+
let result;
|
|
289
|
+
try {
|
|
290
|
+
const upRes = await upstream.chat(body);
|
|
291
|
+
const ct = upRes.headers.get("content-type") || "";
|
|
292
|
+
const isStream = Boolean(body?.stream) || ct.includes("text/event-stream");
|
|
293
|
+
if (isStream && upRes.body) {
|
|
294
|
+
let collected = "";
|
|
295
|
+
for await (const chunk of upRes.body) {
|
|
296
|
+
collected += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
297
|
+
}
|
|
298
|
+
result = { status: upRes.status, headers: { "Content-Type": "text/event-stream" }, body: collected };
|
|
299
|
+
} else {
|
|
300
|
+
const txt = await upRes.text();
|
|
301
|
+
let parsed;
|
|
302
|
+
try { parsed = JSON.parse(txt); } catch { parsed = txt; }
|
|
303
|
+
result = { status: upRes.status, headers: { "Content-Type": upRes.headers.get("content-type") || "application/json" }, body: typeof parsed === "string" ? parsed : JSON.stringify(parsed) };
|
|
304
|
+
}
|
|
305
|
+
} catch (err) {
|
|
306
|
+
result = { status: 502, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ error: errMsg(err) }) };
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
await fetch(`${g.leaderUrl}/v1/groups/relay/result`, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
312
|
+
body: JSON.stringify({ name: g.name, group: g.name, reqId, result }),
|
|
313
|
+
signal: AbortSignal.timeout(5000),
|
|
314
|
+
});
|
|
315
|
+
} catch {}
|
|
316
|
+
}
|
|
317
|
+
} catch {}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
doHeartbeat().catch(() => {});
|
|
321
|
+
const hbTimer = setInterval(doHeartbeat, 30_000);
|
|
322
|
+
hbTimer.unref();
|
|
323
|
+
const pollTimer = setInterval(doPoll, 1000);
|
|
324
|
+
pollTimer.unref();
|
|
325
|
+
console.log(`broadband relay: heartbeat 30s + poll 1s for ${broadbandGroups().length} group(s)`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const autoUpdateMs = autoUpdateIntervalMs();
|
|
329
|
+
function emitAutoUpdate(type, data = {}) {
|
|
330
|
+
const entry = { ts: Date.now(), type, ...data };
|
|
331
|
+
try { bus?.emit(entry); } catch {}
|
|
332
|
+
try { logs?.appendEvent?.(entry); } catch {}
|
|
333
|
+
const line = `[auto-update] ${type} ${JSON.stringify(data)}`;
|
|
334
|
+
console.log(line);
|
|
335
|
+
}
|
|
336
|
+
if (autoUpdateMs) {
|
|
337
|
+
console.log(`auto-update enabled: checking every ${Math.round(autoUpdateMs / 60000)}m`);
|
|
338
|
+
emitAutoUpdate("auto-update-enabled", { intervalMs: autoUpdateMs, current: VERSION });
|
|
339
|
+
setTimeout(() => {
|
|
340
|
+
emitAutoUpdate("auto-update-check", { current: VERSION });
|
|
341
|
+
checkAndAutoUpdate().catch((err) => {
|
|
342
|
+
console.log(`auto-update check failed: ${errMsg(err)}`);
|
|
343
|
+
emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
|
|
344
|
+
});
|
|
345
|
+
}, 30_000).unref?.();
|
|
346
|
+
const autoUpdateTimer = setInterval(() => {
|
|
347
|
+
emitAutoUpdate("auto-update-check", { current: VERSION });
|
|
348
|
+
checkAndAutoUpdate().catch((err) => {
|
|
349
|
+
console.log(`auto-update check failed: ${errMsg(err)}`);
|
|
350
|
+
emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
|
|
351
|
+
});
|
|
352
|
+
}, autoUpdateMs);
|
|
353
|
+
autoUpdateTimer.unref();
|
|
354
|
+
} else {
|
|
355
|
+
console.log(`auto-update disabled (set MSLXDFF_AUTO_UPDATE=1 to enable hourly)`);
|
|
356
|
+
emitAutoUpdate("auto-update-disabled", { current: VERSION });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function checkAndAutoUpdate() {
|
|
360
|
+
emitAutoUpdate("auto-update-query", { current: VERSION });
|
|
361
|
+
const info = await run(npmCmd(), ["view", "mslxdff", "dist-tags.latest", "--json"]);
|
|
362
|
+
if (info.err) {
|
|
363
|
+
emitAutoUpdate("auto-update-query-failed", { error: info.err.message || String(info.stderr || "").slice(0, 500) });
|
|
364
|
+
throw new Error(info.err.message || String(info.stderr || "").slice(0, 500));
|
|
365
|
+
}
|
|
366
|
+
let latest = "";
|
|
367
|
+
try {
|
|
368
|
+
latest = JSON.parse(String(info.stdout || "").trim());
|
|
369
|
+
if (Array.isArray(latest)) latest = latest[latest.length - 1];
|
|
370
|
+
latest = String(latest || "").replace(/^v/, "").trim();
|
|
371
|
+
} catch {
|
|
372
|
+
const raw = String(info.stdout || "").trim();
|
|
373
|
+
const m = raw.match(/(\d+\.\d+\.\d+[^\s'"]*)/);
|
|
374
|
+
latest = m ? m[1] : raw.split(/\s+/).pop()?.replace(/['"]/g, "") || "";
|
|
375
|
+
}
|
|
376
|
+
latest = latest.replace(/['"]/g, "").trim();
|
|
377
|
+
emitAutoUpdate("auto-update-queried", { current: VERSION, latest, stdout: String(info.stdout || "").trim().slice(0, 200) });
|
|
378
|
+
if (!latest || latest === VERSION) {
|
|
379
|
+
emitAutoUpdate("auto-update-noop", { current: VERSION, latest });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const { compareSemver } = await import("./policy.js");
|
|
383
|
+
if (compareSemver(latest, VERSION) <= 0) {
|
|
384
|
+
emitAutoUpdate("auto-update-noop", { current: VERSION, latest, reason: "not newer" });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
emitAutoUpdate("auto-update-found", { current: VERSION, latest });
|
|
388
|
+
console.log(`auto-update: v${VERSION} -> v${latest}, installing...`);
|
|
389
|
+
emitAutoUpdate("auto-update-installing", { current: VERSION, latest });
|
|
390
|
+
const up = await run(npmCmd(), ["install", "-g", `mslxdff@${latest}`]);
|
|
391
|
+
if (up.err) {
|
|
392
|
+
emitAutoUpdate("auto-update-install-failed", { current: VERSION, latest, error: up.err.message || String(up.stderr || "").slice(0, 500) });
|
|
393
|
+
throw new Error(up.err.message || String(up.stderr || "").slice(0, 500));
|
|
394
|
+
}
|
|
395
|
+
emitAutoUpdate("auto-update-installed", { current: VERSION, latest, stdout: String(up.stdout || "").slice(0, 500) });
|
|
396
|
+
console.log(`auto-update: installed v${latest}, restarting daemon...`);
|
|
397
|
+
emitAutoUpdate("auto-update-restarting", { current: VERSION, latest });
|
|
398
|
+
const { stopDaemon, startDaemon } = await import("../daemon.js");
|
|
399
|
+
try { stopDaemon(); } catch (e) { emitAutoUpdate("auto-update-stop-failed", { error: errMsg(e) }); }
|
|
400
|
+
const { waitForHealth } = await import("./policy.js");
|
|
401
|
+
const newPid = startDaemon([]);
|
|
402
|
+
await waitForHealth(resolvePort(), 8000);
|
|
403
|
+
console.log(`auto-update: restarted as v${latest} (pid ${newPid})`);
|
|
404
|
+
emitAutoUpdate("auto-update-restarted", { current: VERSION, latest, newPid });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
|
package/src/state/memory.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* 仅将 persist 层替换为 Map<file, {json, mtimeMs}>,不触 `node:fs`。
|
|
5
5
|
* 用于 `—test-concurrency>1` 并行与单测快速路径。
|
|
6
6
|
*/
|
|
7
|
+
import { mergeState, COLD_WINS } from "./merge.js";
|
|
8
|
+
|
|
7
9
|
const memCache = new Map(); // file -> { data, dirty, timer, mtimeMs }
|
|
8
10
|
const memDisk = new Map(); // file -> { obj, mtimeMs }
|
|
9
11
|
|
|
@@ -33,27 +35,6 @@ function atomicWriteSyncMem(file, data) {
|
|
|
33
35
|
return mtime;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
const COLD_WINS = new Set([
|
|
37
|
-
"providerConfigs",
|
|
38
|
-
"providerKeys",
|
|
39
|
-
"providerShareKeys",
|
|
40
|
-
"port",
|
|
41
|
-
"token",
|
|
42
|
-
"preferredModel",
|
|
43
|
-
"modelPicks",
|
|
44
|
-
"peers",
|
|
45
|
-
"groups",
|
|
46
|
-
"groupsJoined",
|
|
47
|
-
"bans",
|
|
48
|
-
"createdAt",
|
|
49
|
-
]);
|
|
50
|
-
|
|
51
|
-
function mergeState(disk, mem) {
|
|
52
|
-
const merged = { ...disk, ...mem };
|
|
53
|
-
for (const k of COLD_WINS) if (disk[k] !== undefined) merged[k] = disk[k];
|
|
54
|
-
return merged;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
38
|
function readState(file) {
|
|
58
39
|
const e = getEntry(file);
|
|
59
40
|
if (e.data !== null) {
|
|
@@ -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,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
|
+
}
|