mslxdff 0.1.89 → 0.1.91
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/engine.js +160 -0
- package/src/chat/repl.js +32 -299
- package/src/chat/terminal.js +95 -0
- package/src/chat/tool-handlers.js +67 -0
- package/src/chat/upstream.js +1 -4
- package/src/chat-pipeline/auto-race.js +124 -0
- package/src/chat-pipeline/engine.js +10 -234
- package/src/chat-pipeline/serial-trial.js +144 -0
- package/src/cli/commands/model/list-providers.js +75 -0
- package/src/cli/commands/model/list-render.js +79 -0
- package/src/cli/commands/model/list.js +208 -0
- package/src/cli/commands/model/picks.js +45 -0
- package/src/cli/commands/model/stats.js +43 -0
- package/src/cli/commands/model/status.js +47 -0
- package/src/cli/commands/model.js +17 -371
- package/src/cli/help.js +2 -1
- package/src/providers/cline/chat.js +15 -2
- package/src/routes/chat/relay-pipeline.js +26 -0
- package/src/runtime/bootstrap.js +14 -469
- package/src/runtime/broadband-stream.js +76 -0
- package/src/runtime/broadband.js +97 -0
- package/src/runtime/group-sync.js +28 -0
- package/src/runtime/providers-setup.js +147 -0
- package/src/runtime/server-lifecycle.js +156 -0
- package/src/state/facade.js +1 -0
- package/src/state/schemas/model.js +41 -0
- package/src/upstream-responses.js +64 -0
- package/src/upstream.js +5 -59
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { groupSyncIntervalMs } from "../cli/policy.js";
|
|
2
|
+
import { errMsg } from "../cli/util.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 群组成员同步 — 首拉 + 定时器(fire-and-forget,原语义)。
|
|
6
|
+
*/
|
|
7
|
+
export async function startGroupSync({ peers, groups }) {
|
|
8
|
+
const { syncAllJoinedGroups } = await import("../cli/group-helpers.js");
|
|
9
|
+
syncAllJoinedGroups({ peers, groups })
|
|
10
|
+
.then((results) => {
|
|
11
|
+
for (const r of results) {
|
|
12
|
+
if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
|
|
13
|
+
else console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
.catch((err) => console.log(`group sync: ${errMsg(err)}`));
|
|
17
|
+
const groupSyncTimer = setInterval(() => {
|
|
18
|
+
syncAllJoinedGroups({ peers, groups })
|
|
19
|
+
.then((results) => {
|
|
20
|
+
for (const r of results) {
|
|
21
|
+
if (r.error) console.log(`group sync ${r.name}: failed — ${r.error}`);
|
|
22
|
+
else if (r.added) console.log(`group sync ${r.name}: ${r.total} member(s), ${r.added} peer(s)`);
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
.catch((err) => console.log(`group sync: ${errMsg(err)}`));
|
|
26
|
+
}, groupSyncIntervalMs());
|
|
27
|
+
groupSyncTimer.unref();
|
|
28
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { join, dirname } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { defaultStateFile, loadToken } from "../state.js";
|
|
4
|
+
import { createUpstreamClient } from "../upstream.js";
|
|
5
|
+
import { createModelsService } from "../models.js";
|
|
6
|
+
import { createAutoSelector } from "../auto.js";
|
|
7
|
+
import { createPeersService } from "../peers.js";
|
|
8
|
+
import { createGroupsService, createBansService } from "../groups.js";
|
|
9
|
+
import { logDir, appendEvent } from "../logs.js";
|
|
10
|
+
import { loadPlugins, runHook, resolvePluginDirs } from "../plugins.js";
|
|
11
|
+
import { createOpenCodeProvider } from "../providers/opencode.js";
|
|
12
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs } from "../state.js";
|
|
13
|
+
import { refreshIntervalMs, modelCooldownMs, slowCooldownMs, peerCooldownMs, peerHeatMs, banWindowMs, banThreshold } from "../cli/policy.js";
|
|
14
|
+
import { errMsg } from "../cli/util.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 世界组装 — 插件加载 → providers → models/auto/peers/groups/bans。
|
|
18
|
+
* 无参(读 env/state 自身),返回 ctx 由门面接力 server-lifecycle。
|
|
19
|
+
*/
|
|
20
|
+
export async function setupProviders() {
|
|
21
|
+
const { token, created } = await loadToken();
|
|
22
|
+
const pkgRoot2 = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
23
|
+
const pluginDirs = resolvePluginDirs({ pkgRoot: pkgRoot2 });
|
|
24
|
+
const { plugins: loadedPlugins, errors: pluginErrors } = await loadPlugins({ dirs: pluginDirs });
|
|
25
|
+
for (const e of pluginErrors) {
|
|
26
|
+
console.log(`plugin load failed: ${e.file} — ${e.error}`);
|
|
27
|
+
appendEvent({ ts: Date.now(), type: "plugin-load-error", file: e.file, error: e.error });
|
|
28
|
+
}
|
|
29
|
+
if (loadedPlugins.length) {
|
|
30
|
+
console.log(`plugins loaded (${loadedPlugins.length}): ${loadedPlugins.map((p) => `${p.name}${p.version ? `@${p.version}` : ""}`).join(", ")}`);
|
|
31
|
+
appendEvent({ ts: Date.now(), type: "plugins-loaded", plugins: loadedPlugins.map((p) => ({ name: p.name, version: p.version })) });
|
|
32
|
+
}
|
|
33
|
+
const upstreamHooks = loadedPlugins.length
|
|
34
|
+
? (name, ctx) => runHook(loadedPlugins, name, ctx)
|
|
35
|
+
: null;
|
|
36
|
+
const providerPlugin = loadedPlugins.find((p) => typeof p.createUpstream === "function");
|
|
37
|
+
let upstream;
|
|
38
|
+
const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
|
|
39
|
+
let providers = [];
|
|
40
|
+
if (providerPlugin) {
|
|
41
|
+
if (!upstream) {
|
|
42
|
+
try {
|
|
43
|
+
upstream = await providerPlugin.createUpstream({ baseUrl, authToken: process.env.UPSTREAM_AUTH_TOKEN || "public", env: process.env });
|
|
44
|
+
console.log(`upstream provider replaced by plugin: ${providerPlugin.name}`);
|
|
45
|
+
appendEvent({ ts: Date.now(), type: "plugin-upstream-active", plugin: providerPlugin.name });
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.log(`plugin upstream (${providerPlugin.name}) failed: ${errMsg(err)} — falling back to default`);
|
|
48
|
+
appendEvent({ ts: Date.now(), type: "plugin-upstream-error", plugin: providerPlugin.name, error: errMsg(err) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
|
|
52
|
+
} else {
|
|
53
|
+
const opencodeClient = createUpstreamClient({ hooks: upstreamHooks });
|
|
54
|
+
const opencodeModels = createModelsService({
|
|
55
|
+
baseUrl,
|
|
56
|
+
headers: opencodeClient.headers,
|
|
57
|
+
refreshMs: refreshIntervalMs(),
|
|
58
|
+
cacheFile: join(logDir(), "models.json"),
|
|
59
|
+
});
|
|
60
|
+
providers.push(createOpenCodeProvider({ upstream: opencodeClient, modelsService: opencodeModels }));
|
|
61
|
+
const orKeys = loadProviderKeys("openrouter");
|
|
62
|
+
if (orKeys.length) {
|
|
63
|
+
const { createOpenRouterProvider } = await import("../providers/openrouter.js");
|
|
64
|
+
providers.push(createOpenRouterProvider({ apiKeys: orKeys }));
|
|
65
|
+
console.log(`provider enabled: openrouter (${orKeys.length} key${orKeys.length > 1 ? "s" : ""})`);
|
|
66
|
+
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "openrouter", keys: orKeys.length });
|
|
67
|
+
}
|
|
68
|
+
const genericConfigs = loadProviderConfigs();
|
|
69
|
+
for (const [gid, cfg] of Object.entries(genericConfigs)) {
|
|
70
|
+
if (gid === "opencode" || gid === "openrouter") continue;
|
|
71
|
+
const base = String(cfg?.baseUrl || "").trim();
|
|
72
|
+
const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
|
|
73
|
+
const auths = Array.isArray(cfg?.auths) ? cfg.auths : [];
|
|
74
|
+
// 可扩展:优先走注册表定制 provider(如 workbuddy、cline),新增供应商仅需在 registry.js 注册
|
|
75
|
+
const { getCustomProviderFactory } = await import("../providers/registry.js");
|
|
76
|
+
const customFactory = await getCustomProviderFactory(gid, base);
|
|
77
|
+
if (customFactory) {
|
|
78
|
+
if (gid === "workbuddy" && !keys.length) continue;
|
|
79
|
+
if (gid !== "workbuddy" && (!base || !keys.length)) continue;
|
|
80
|
+
try {
|
|
81
|
+
const provider = gid === "workbuddy"
|
|
82
|
+
? await customFactory({ baseUrl: base || "https://copilot.tencent.com", apiKeys: keys, auths })
|
|
83
|
+
: await customFactory({ id: gid, baseUrl: base, apiKeys: keys });
|
|
84
|
+
providers.push(provider);
|
|
85
|
+
console.log(`provider enabled: ${gid} (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base || provider.baseUrl} [custom]`);
|
|
86
|
+
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base || provider.baseUrl });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.log(`provider ${gid} failed: ${err?.message || err}`);
|
|
89
|
+
appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!base || !keys.length) continue;
|
|
94
|
+
try {
|
|
95
|
+
const { createGenericProvider } = await import("../providers/generic.js");
|
|
96
|
+
providers.push(createGenericProvider({ id: gid, baseUrl: base, apiKeys: keys }));
|
|
97
|
+
console.log(`provider enabled: ${gid} (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
|
|
98
|
+
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.log(`provider ${gid} failed: ${err?.message || err}`);
|
|
101
|
+
appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!genericConfigs["workbuddy"]) {
|
|
105
|
+
const wbKeys = loadProviderKeys("workbuddy");
|
|
106
|
+
if (wbKeys.length) {
|
|
107
|
+
try {
|
|
108
|
+
const { createWorkbuddyProvider } = await import("../providers/workbuddy.js");
|
|
109
|
+
const wbAuths = loadProviderAuths("workbuddy");
|
|
110
|
+
providers.push(createWorkbuddyProvider({ apiKeys: wbKeys, auths: wbAuths }));
|
|
111
|
+
console.log(`provider enabled: workbuddy (${wbKeys.length} key${wbKeys.length > 1 ? "s" : ""}) baseUrl=https://copilot.tencent.com (env)`);
|
|
112
|
+
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "workbuddy", keys: wbKeys.length, baseUrl: "https://copilot.tencent.com" });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.log(`provider workbuddy failed: ${err?.message || err}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const { createProviderDispatcher } = await import("../providers/dispatcher.js");
|
|
119
|
+
upstream = createProviderDispatcher(providers);
|
|
120
|
+
appendEvent({ ts: Date.now(), type: "providers", providers: providers.map((p) => p.id) });
|
|
121
|
+
}
|
|
122
|
+
const opencodeProvider = providers.find((p) => p.id === "opencode");
|
|
123
|
+
const models = createModelsService({
|
|
124
|
+
providers: providers.length > 1 ? providers : undefined,
|
|
125
|
+
baseUrl,
|
|
126
|
+
headers: upstream.headers || opencodeProvider?.upstream?.headers,
|
|
127
|
+
refreshMs: refreshIntervalMs(),
|
|
128
|
+
cacheFile: join(logDir(), "models.json"),
|
|
129
|
+
});
|
|
130
|
+
const auto = createAutoSelector({
|
|
131
|
+
cooldownMs: modelCooldownMs(),
|
|
132
|
+
slowCooldownMs: slowCooldownMs(),
|
|
133
|
+
file: defaultStateFile(),
|
|
134
|
+
loadCandidates: async () => {
|
|
135
|
+
try {
|
|
136
|
+
return (await models.get()).data.map((m) => m.id);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const peers = createPeersService({ cooldownMs: peerCooldownMs(), heatMs: peerHeatMs() });
|
|
143
|
+
const groups = createGroupsService({});
|
|
144
|
+
const bans = createBansService({ windowMs: banWindowMs(), threshold: banThreshold() });
|
|
145
|
+
|
|
146
|
+
return { token, created, loadedPlugins, upstreamHooks, providerPlugin, upstream, providers, baseUrl, opencodeProvider, models, auto, peers, groups, bans };
|
|
147
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { startServer, resolvePort } from "../server.js";
|
|
2
|
+
import { createRouter } from "../routes.js";
|
|
3
|
+
import { createEventBus } from "../events.js";
|
|
4
|
+
import { runHook } from "../plugins.js";
|
|
5
|
+
import { effectiveHost, maxHopsValue } from "../cli/policy.js";
|
|
6
|
+
import { fmtEvent } from "../cli/format.js";
|
|
7
|
+
import { writePid } from "../daemon.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 服务生命周期 — router 装配 → server 创建/ready-EADDRINUSE 自愈 →
|
|
11
|
+
* plugin 事件 → preheat → pid/监听日志 → autostart。返回 { srv, bus, router }。
|
|
12
|
+
*/
|
|
13
|
+
export async function startServerLifecycle({ VERSION, token, created, upstream, models, auto, peers, groups, bans, loadedPlugins, baseUrl, logs }) {
|
|
14
|
+
const bus = createEventBus();
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const { loadModelAliases } = await import("../providers/model-id.js");
|
|
18
|
+
loadModelAliases();
|
|
19
|
+
} catch {}
|
|
20
|
+
|
|
21
|
+
const router = createRouter({ token, upstream, models, auto, logs, peers, maxHops: maxHopsValue(), groups, bans, bus, plugins: loadedPlugins });
|
|
22
|
+
const listenHost = effectiveHost();
|
|
23
|
+
const isDebug = process.env.MSLXDFF_DEBUG === "1";
|
|
24
|
+
const srv = startServer({
|
|
25
|
+
router,
|
|
26
|
+
signals: !isDebug,
|
|
27
|
+
host: listenHost,
|
|
28
|
+
onBeforeClose: loadedPlugins.length
|
|
29
|
+
? () => runHook(loadedPlugins, "server:stop", { version: VERSION }).then(() => {})
|
|
30
|
+
: undefined,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (isDebug) {
|
|
34
|
+
bus.subscribe((e) => {
|
|
35
|
+
try {
|
|
36
|
+
console.log(fmtEvent(e));
|
|
37
|
+
} catch {}
|
|
38
|
+
});
|
|
39
|
+
const { startDaemon: sd } = await import("../daemon.js");
|
|
40
|
+
const restore2 = () => {
|
|
41
|
+
console.log("\n[debug] restoring background daemon...");
|
|
42
|
+
try {
|
|
43
|
+
const restoredPid = sd([]);
|
|
44
|
+
console.log(`[debug] daemon restored (pid ${restoredPid})`);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(`[debug] could not restore daemon: ${err.message}`);
|
|
47
|
+
}
|
|
48
|
+
setTimeout(() => process.exit(0), 300);
|
|
49
|
+
};
|
|
50
|
+
process.on("SIGINT", restore2);
|
|
51
|
+
process.on("SIGTERM", restore2);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Robust ready: if EADDRINUSE (bare daemon still holds port), kill holders and retry once
|
|
55
|
+
try {
|
|
56
|
+
await srv.ready();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const msg = String(err?.message || err);
|
|
59
|
+
const code = err?.code || "";
|
|
60
|
+
if (code === "EADDRINUSE" || msg.includes("EADDRINUSE")) {
|
|
61
|
+
console.log(`port ${resolvePort()} in use — freeing stale holder and retrying...`);
|
|
62
|
+
try {
|
|
63
|
+
const { execFile } = await import("node:child_process");
|
|
64
|
+
const execAsync2 = (f, a) => new Promise((res) => execFile(f, a, { windowsHide: true, timeout: 4000 }, (e, so, se) => res({ e, so: String(so||""), se: String(se||"") })));
|
|
65
|
+
const port = resolvePort();
|
|
66
|
+
// kill via ss parse (same as autostart)
|
|
67
|
+
const ss1 = await execAsync2("ss", ["-lptn", `sport = :${port}`]);
|
|
68
|
+
const out = ss1.so || "";
|
|
69
|
+
const pids = new Set();
|
|
70
|
+
let m;
|
|
71
|
+
const re = /pid=(\d+)/g;
|
|
72
|
+
while ((m = re.exec(out))) pids.add(Number(m[1]));
|
|
73
|
+
if (!pids.size) {
|
|
74
|
+
const ss2 = await execAsync2("ss", ["-lptn"]);
|
|
75
|
+
for (const line of (ss2.so||"").split("\n")) {
|
|
76
|
+
if (!line.includes(`:${port}`)) continue;
|
|
77
|
+
let m2; const re2 = /pid=(\d+)/g;
|
|
78
|
+
while ((m2 = re2.exec(line))) pids.add(Number(m2[1]));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const p of pids) { if (p !== process.pid) try { process.kill(p, "SIGTERM"); } catch {} }
|
|
82
|
+
if (pids.size) await new Promise((r2) => setTimeout(r2, 600));
|
|
83
|
+
for (const p of pids) try { const { isPidAlive } = await import("../daemon.js"); if (isPidAlive(p)) process.kill(p, "SIGKILL"); } catch {}
|
|
84
|
+
try { await execAsync2("fuser", ["-k", `${port}/tcp`]); } catch {}
|
|
85
|
+
for (let i=0;i<10;i++) {
|
|
86
|
+
const chk = await execAsync2("ss", ["-ltn"]);
|
|
87
|
+
if (!chk.so.includes(`:${port}`)) break;
|
|
88
|
+
await new Promise((r2)=>setTimeout(r2,200));
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
await srv.ready();
|
|
92
|
+
} else throw err;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (loadedPlugins.length) {
|
|
96
|
+
runHook(loadedPlugins, "server:start", { port: srv.server.address()?.port, host: listenHost, version: VERSION }).catch(() => {});
|
|
97
|
+
const eventPlugins = loadedPlugins.filter((p) => typeof p.onEvent === "function");
|
|
98
|
+
if (eventPlugins.length) {
|
|
99
|
+
bus.subscribe((e) => {
|
|
100
|
+
for (const p of eventPlugins) {
|
|
101
|
+
try { p.onEvent(e); } catch {}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
upstream.preheat().then((r) => {
|
|
109
|
+
const entry = { ts: Date.now(), type: "upstream-preheat", ...r, baseUrl };
|
|
110
|
+
try { bus.emit(entry); } catch {}
|
|
111
|
+
try { logs.appendEvent(entry); } catch {}
|
|
112
|
+
if (r.skipped) console.log(`[preheat] skipped (MSLXDFF_PREHEAT disabled)`);
|
|
113
|
+
else if (r.ok) console.log(`[preheat] opencode models ok ${r.status} ${r.ms}ms`);
|
|
114
|
+
else console.log(`[preheat] opencode models failed ${r.error || r.status || ""} ${r.ms || 0}ms`);
|
|
115
|
+
}).catch(() => {});
|
|
116
|
+
}, 100).unref?.();
|
|
117
|
+
|
|
118
|
+
models.startAutoRefresh();
|
|
119
|
+
if (process.env.MSLXDFF_DAEMON) {
|
|
120
|
+
writePid(process.pid, VERSION);
|
|
121
|
+
}
|
|
122
|
+
const addr = srv.server.address();
|
|
123
|
+
const host = addr.address === "0.0.0.0" || addr.address === "::" ? "localhost" : addr.address;
|
|
124
|
+
console.log(`mslxdff v${VERSION} listening on http://${host}:${addr.port}`);
|
|
125
|
+
if (created) {
|
|
126
|
+
console.log(`auth token: ${token}`);
|
|
127
|
+
}
|
|
128
|
+
console.log(`endpoint: http://${host}:${addr.port}/v1`);
|
|
129
|
+
try {
|
|
130
|
+
const { hedgeDelayMs } = await import("../routes/hedge.js");
|
|
131
|
+
const hd = hedgeDelayMs();
|
|
132
|
+
console.log(`hedge: ${hd ? `${hd}ms` : "off"} (MSLXDFF_HEDGE_DELAY_MS)`);
|
|
133
|
+
} catch {}
|
|
134
|
+
|
|
135
|
+
// best-effort: ensure autostart on Linux (so daemon survives reboot/SSH disconnect without manual cmd)
|
|
136
|
+
if (process.platform === "linux" && !process.env.MSLXDFF_NO_AUTOSTART) {
|
|
137
|
+
setTimeout(async () => {
|
|
138
|
+
try {
|
|
139
|
+
const { getAutostartStatus, enableAutostart } = await import("../autostart.js");
|
|
140
|
+
const st = await getAutostartStatus();
|
|
141
|
+
if (!st.enabled) {
|
|
142
|
+
const r = await enableAutostart();
|
|
143
|
+
if (r.ok) {
|
|
144
|
+
console.log(`autostart auto-enabled: ${r.method}${r.linger ? ` linger=${r.linger}` : ""}`);
|
|
145
|
+
try { bus?.emit({ ts: Date.now(), type: "autostart-auto-enabled", method: r.method }); } catch {}
|
|
146
|
+
try { logs.appendEvent({ ts: Date.now(), type: "autostart-auto-enabled", method: r.method }); } catch {}
|
|
147
|
+
} else {
|
|
148
|
+
console.log(`autostart auto-enable failed: ${r.error || "unknown"} (run mslxdff -enable-autostart manually)`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {}
|
|
152
|
+
}, 2500).unref?.();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { srv, bus, router };
|
|
156
|
+
}
|
package/src/state/facade.js
CHANGED
|
@@ -30,6 +30,47 @@ export function saveModelStats(stats, { file = defaultStateFile() } = {}) {
|
|
|
30
30
|
return stats;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
const STATS_ALPHA = 0.3;
|
|
34
|
+
function ema(prev, next) {
|
|
35
|
+
if (!Number.isFinite(prev) || prev <= 0) return Math.round(next);
|
|
36
|
+
if (!Number.isFinite(next) || next <= 0) return Math.round(prev);
|
|
37
|
+
return Math.round(prev * (1 - STATS_ALPHA) + next * STATS_ALPHA);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function recordModelStats(id, { ttfbMs, totalMs, tps, completionTokens, file = defaultStateFile() } = {}) {
|
|
41
|
+
if (!id || typeof id !== "string") return null;
|
|
42
|
+
const stats = loadModelStats({ file });
|
|
43
|
+
const cur = stats[id] || { count: 0 };
|
|
44
|
+
const next = { ...cur };
|
|
45
|
+
next.count = (cur.count || 0) + 1;
|
|
46
|
+
next.lastAt = Date.now();
|
|
47
|
+
if (Number.isFinite(ttfbMs) && ttfbMs >= 0) {
|
|
48
|
+
next.avgTtfbMs = cur.avgTtfbMs != null ? ema(cur.avgTtfbMs, ttfbMs) : Math.round(ttfbMs);
|
|
49
|
+
next.emaTtfbMs = next.avgTtfbMs;
|
|
50
|
+
// 简易 p95:取 max 的 EMA
|
|
51
|
+
if (cur.p95Ttfb == null) next.p95Ttfb = Math.round(ttfbMs);
|
|
52
|
+
else next.p95Ttfb = Math.max(cur.p95Ttfb, Math.round(ttfbMs * 0.7 + cur.p95Ttfb * 0.3));
|
|
53
|
+
}
|
|
54
|
+
if (Number.isFinite(totalMs) && totalMs >= 0) {
|
|
55
|
+
next.avgTotalMs = cur.avgTotalMs != null ? ema(cur.avgTotalMs, totalMs) : Math.round(totalMs);
|
|
56
|
+
next.emaTotalMs = next.avgTotalMs;
|
|
57
|
+
next.lastTotalMs = Math.round(totalMs);
|
|
58
|
+
}
|
|
59
|
+
if (Number.isFinite(tps) && tps > 0) {
|
|
60
|
+
next.avgTps = cur.avgTps != null ? Number((cur.avgTps * (1 - STATS_ALPHA) + tps * STATS_ALPHA).toFixed(1)) : Number(tps.toFixed(1));
|
|
61
|
+
next.emaTps = next.avgTps;
|
|
62
|
+
}
|
|
63
|
+
if (Number.isFinite(completionTokens) && completionTokens > 0) {
|
|
64
|
+
const prevAvg = cur.avgCompTok;
|
|
65
|
+
next.avgCompTok = prevAvg != null ? Math.round(prevAvg * (1 - STATS_ALPHA) + completionTokens * STATS_ALPHA) : Math.round(completionTokens);
|
|
66
|
+
}
|
|
67
|
+
// 兼容旧字段:lastAt 供排序
|
|
68
|
+
stats[id] = next;
|
|
69
|
+
// 监控需实时可见,用同步落盘而非 500ms debounce
|
|
70
|
+
try { writeStateImmediate(file, { modelStats: stats }); } catch { saveModelStats(stats, { file }); }
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
|
|
33
74
|
export function loadPreferredModel({ file = defaultStateFile() } = {}) {
|
|
34
75
|
const v = readState(file).preferredModel;
|
|
35
76
|
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* responses 转换层 — 从 upstream.js 抽出的 muse-spark 专用形状转换。
|
|
3
|
+
* chat ⇄ responses 互转纯函数,无网络、无副作用。
|
|
4
|
+
*/
|
|
5
|
+
export function isResponsesModel(model) {
|
|
6
|
+
return String(model || "").toLowerCase().startsWith("muse-spark");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function chatToResponsesBody(chatBody) {
|
|
10
|
+
const msgs = Array.isArray(chatBody?.messages) ? chatBody.messages : [];
|
|
11
|
+
const system = msgs.filter((m) => m.role === "system").map((m) => String(m.content || "")).join("\n");
|
|
12
|
+
const nonSystem = msgs.filter((m) => m.role !== "system");
|
|
13
|
+
const inputParts = nonSystem.map((m) => {
|
|
14
|
+
const c = m.content;
|
|
15
|
+
if (typeof c === "string") return `${m.role}: ${c}`;
|
|
16
|
+
if (Array.isArray(c)) return `${m.role}: ${c.map((x) => x.text || "").join("")}`;
|
|
17
|
+
return `${m.role}: ${String(c || "")}`;
|
|
18
|
+
});
|
|
19
|
+
const input = inputParts.join("\n\n") || "hi";
|
|
20
|
+
const out = { model: chatBody.model, input, stream: false };
|
|
21
|
+
if (system) out.instructions = system;
|
|
22
|
+
if (chatBody.tools) out.tools = chatBody.tools;
|
|
23
|
+
if (chatBody.tool_choice) out.tool_choice = chatBody.tool_choice;
|
|
24
|
+
if (chatBody.temperature != null) out.temperature = chatBody.temperature;
|
|
25
|
+
if (chatBody.max_tokens != null) out.max_output_tokens = chatBody.max_tokens;
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function responsesToChatJson(respJson) {
|
|
30
|
+
let text = "";
|
|
31
|
+
for (const item of respJson.output || []) {
|
|
32
|
+
if (item.type === "message" && item.role === "assistant") {
|
|
33
|
+
for (const c of item.content || []) {
|
|
34
|
+
if (c.type === "output_text") text += c.text || "";
|
|
35
|
+
else if (c.type === "text") text += c.text || "";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!text) {
|
|
40
|
+
for (const item of respJson.output || []) {
|
|
41
|
+
if (item.type === "message") {
|
|
42
|
+
const t = item.content?.[0]?.text;
|
|
43
|
+
if (t) { text = t; break; }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const chatJson = {
|
|
48
|
+
id: respJson.id || `resp_${Date.now()}`,
|
|
49
|
+
object: "chat.completion",
|
|
50
|
+
created: Math.floor((respJson.created_at || Date.now() / 1000)),
|
|
51
|
+
model: respJson.model,
|
|
52
|
+
choices: [{ index: 0, finish_reason: respJson.status === "completed" ? "stop" : "length", message: { role: "assistant", content: text } }],
|
|
53
|
+
usage: respJson.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
54
|
+
};
|
|
55
|
+
return chatJson;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** responses 成功 Response 转回 chat 形状(anon 兜底与主路径复用) */
|
|
59
|
+
export function toChatResponse(res, respJson) {
|
|
60
|
+
const chatJson = responsesToChatJson(respJson);
|
|
61
|
+
const headers = new Headers(res.headers);
|
|
62
|
+
headers.set("content-type", "application/json");
|
|
63
|
+
return new Response(JSON.stringify(chatJson), { status: res.status, headers });
|
|
64
|
+
}
|
package/src/upstream.js
CHANGED
|
@@ -6,6 +6,7 @@ import { performance } from "node:perf_hooks";
|
|
|
6
6
|
import { isFreeModel } from "./models.js";
|
|
7
7
|
import { fmtShanghaiYMDHMS } from "./time.js";
|
|
8
8
|
import { createTransport } from "./transport/index.js";
|
|
9
|
+
import { isResponsesModel, chatToResponsesBody, toChatResponse } from "./upstream-responses.js";
|
|
9
10
|
|
|
10
11
|
function genId(prefix) {
|
|
11
12
|
return `${prefix}${crypto.randomUUID().replace(/-/g, "")}`;
|
|
@@ -81,56 +82,6 @@ export function createUpstreamClient({
|
|
|
81
82
|
if (raw === "0" || raw === "off" || raw === "false" || raw === "no") return false;
|
|
82
83
|
return true;
|
|
83
84
|
}
|
|
84
|
-
function isResponsesModel(model) {
|
|
85
|
-
return String(model || "").toLowerCase().startsWith("muse-spark");
|
|
86
|
-
}
|
|
87
|
-
function chatToResponsesBody(chatBody) {
|
|
88
|
-
const msgs = Array.isArray(chatBody?.messages) ? chatBody.messages : [];
|
|
89
|
-
const system = msgs.filter((m) => m.role === "system").map((m) => String(m.content || "")).join("\n");
|
|
90
|
-
const nonSystem = msgs.filter((m) => m.role !== "system");
|
|
91
|
-
const inputParts = nonSystem.map((m) => {
|
|
92
|
-
const c = m.content;
|
|
93
|
-
if (typeof c === "string") return `${m.role}: ${c}`;
|
|
94
|
-
if (Array.isArray(c)) return `${m.role}: ${c.map((x) => x.text || "").join("")}`;
|
|
95
|
-
return `${m.role}: ${String(c || "")}`;
|
|
96
|
-
});
|
|
97
|
-
const input = inputParts.join("\n\n") || "hi";
|
|
98
|
-
const out = { model: chatBody.model, input, stream: false };
|
|
99
|
-
if (system) out.instructions = system;
|
|
100
|
-
if (chatBody.tools) out.tools = chatBody.tools;
|
|
101
|
-
if (chatBody.tool_choice) out.tool_choice = chatBody.tool_choice;
|
|
102
|
-
if (chatBody.temperature != null) out.temperature = chatBody.temperature;
|
|
103
|
-
if (chatBody.max_tokens != null) out.max_output_tokens = chatBody.max_tokens;
|
|
104
|
-
return out;
|
|
105
|
-
}
|
|
106
|
-
function responsesToChatJson(respJson) {
|
|
107
|
-
let text = "";
|
|
108
|
-
for (const item of respJson.output || []) {
|
|
109
|
-
if (item.type === "message" && item.role === "assistant") {
|
|
110
|
-
for (const c of item.content || []) {
|
|
111
|
-
if (c.type === "output_text") text += c.text || "";
|
|
112
|
-
else if (c.type === "text") text += c.text || "";
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
if (!text) {
|
|
117
|
-
for (const item of respJson.output || []) {
|
|
118
|
-
if (item.type === "message") {
|
|
119
|
-
const t = item.content?.[0]?.text;
|
|
120
|
-
if (t) { text = t; break; }
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
const chatJson = {
|
|
125
|
-
id: respJson.id || `resp_${Date.now()}`,
|
|
126
|
-
object: "chat.completion",
|
|
127
|
-
created: Math.floor((respJson.created_at || Date.now() / 1000)),
|
|
128
|
-
model: respJson.model,
|
|
129
|
-
choices: [{ index: 0, finish_reason: respJson.status === "completed" ? "stop" : "length", message: { role: "assistant", content: text } }],
|
|
130
|
-
usage: respJson.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
131
|
-
};
|
|
132
|
-
return chatJson;
|
|
133
|
-
}
|
|
134
85
|
function freeAnonLogFile() {
|
|
135
86
|
return process.env.MSLXDFF_FREE_ANON_LOG || join(process.cwd(), "free-anon-extra.txt");
|
|
136
87
|
}
|
|
@@ -196,16 +147,14 @@ export function createUpstreamClient({
|
|
|
196
147
|
continue;
|
|
197
148
|
}
|
|
198
149
|
if (anonRes.status !== 429) {
|
|
199
|
-
// responses 模型需转回 chat
|
|
150
|
+
// responses 模型需转回 chat 形状(复用 upstream-responses)
|
|
200
151
|
let outAnon = anonRes;
|
|
201
152
|
if (isResp && anonRes.ok) {
|
|
202
153
|
try {
|
|
203
154
|
const txt = await anonRes.text();
|
|
204
155
|
const j = JSON.parse(txt);
|
|
205
156
|
if (j && Array.isArray(j.output)) {
|
|
206
|
-
|
|
207
|
-
outAnon = new Response(JSON.stringify(chatJson), { status: anonRes.status, headers: new Headers(anonRes.headers) });
|
|
208
|
-
outAnon.headers.set("content-type", "application/json");
|
|
157
|
+
outAnon = toChatResponse(anonRes, j);
|
|
209
158
|
} else {
|
|
210
159
|
outAnon = new Response(txt, { status: anonRes.status, headers: anonRes.headers });
|
|
211
160
|
}
|
|
@@ -228,16 +177,13 @@ export function createUpstreamClient({
|
|
|
228
177
|
}
|
|
229
178
|
}
|
|
230
179
|
|
|
231
|
-
// responses 模型成功态转 chat
|
|
180
|
+
// responses 模型成功态转 chat(复用 upstream-responses)
|
|
232
181
|
if (isResp && res.ok) {
|
|
233
182
|
try {
|
|
234
183
|
const txt = await res.text();
|
|
235
184
|
const j = JSON.parse(txt);
|
|
236
185
|
if (j && Array.isArray(j.output)) {
|
|
237
|
-
const
|
|
238
|
-
const headers = new Headers(res.headers);
|
|
239
|
-
headers.set("content-type", "application/json");
|
|
240
|
-
const transformed = new Response(JSON.stringify(chatJson), { status: res.status, headers });
|
|
186
|
+
const transformed = toChatResponse(res, j);
|
|
241
187
|
transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
|
|
242
188
|
return transformed;
|
|
243
189
|
}
|