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.
- package/bin/mslxdff.js +3 -3159
- 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 -0
- package/src/cli/commands/daemon.js +151 -0
- package/src/cli/commands/group.js +272 -0
- package/src/cli/commands/model.js +405 -0
- package/src/cli/commands/provider/add.js +106 -0
- package/src/cli/commands/provider/allowlist.js +99 -0
- package/src/cli/commands/provider/config.js +82 -0
- package/src/cli/commands/provider/index.js +193 -0
- package/src/cli/commands/provider/keys.js +135 -0
- package/src/cli/commands/provider/models.js +99 -0
- package/src/cli/commands/provider.js +1 -0
- package/src/cli/commands/sync.js +143 -0
- package/src/cli/commands/system.js +236 -0
- package/src/cli/commands/workbuddy.js +91 -0
- package/src/cli/format.js +119 -0
- package/src/cli/group-helpers.js +69 -0
- package/src/cli/help.js +66 -0
- package/src/cli/index.js +59 -0
- package/src/cli/interactive.js +84 -0
- package/src/cli/policy.js +119 -0
- package/src/cli/provider-row.js +73 -0
- package/src/cli/status.js +283 -0
- package/src/cli/util.js +24 -0
- package/src/providers/base.js +159 -0
- package/src/providers/dispatcher.js +18 -6
- package/src/providers/generic.js +28 -166
- package/src/providers/openrouter.js +19 -205
- 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 -677
- package/src/routes/chat/broadband-handler.js +25 -46
- package/src/routes/chat/exhausted-handler.js +2 -2
- package/src/routes/chat/gateway.js +301 -0
- package/src/routes/chat/hedge-handler.js +65 -83
- package/src/routes/chat/index.js +1 -384
- 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/facade.js +57 -0
- package/src/state/memory.js +161 -0
- package/src/state/merge.js +26 -0
- package/src/state/persist.js +42 -0
- package/src/state/provider-config.js +143 -0
- package/src/state/schemas/allowlist.js +87 -0
- package/src/state/schemas/group.js +31 -0
- package/src/state/schemas/model.js +53 -0
- package/src/state/schemas/peer.js +31 -0
- package/src/state/schemas/port.js +10 -0
- package/src/state/schemas/provider.js +204 -0
- package/src/state/schemas/token.js +43 -0
- package/src/state/store.js +176 -0
- package/src/state.js +1 -711
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { runHook } from "../../plugins.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RelayPipeline 深模块
|
|
5
|
+
* 把 5 个 handler 各自的 fallback→relay→scoring→事件 6段流水收敛为单一真相。
|
|
6
|
+
* 对外 1 接口:createRelayPipeline(deps) => { execute(ctx) }
|
|
7
|
+
* 设计要点:全部外部可注入,便于在 pipeline seam 上做行为测试;constants 注入便于单测加速。
|
|
8
|
+
*/
|
|
9
|
+
export function createRelayPipeline({
|
|
10
|
+
relay,
|
|
11
|
+
buildFallbackInfo,
|
|
12
|
+
auto,
|
|
13
|
+
plugins,
|
|
14
|
+
evt,
|
|
15
|
+
mark,
|
|
16
|
+
logCall,
|
|
17
|
+
logError,
|
|
18
|
+
constants,
|
|
19
|
+
startedAt: defaultStartedAt,
|
|
20
|
+
stages: defaultStages,
|
|
21
|
+
perfNow,
|
|
22
|
+
} = {}) {
|
|
23
|
+
const C = {
|
|
24
|
+
STREAM_TIMEOUT_MS: 25_000,
|
|
25
|
+
SLOW_TOTAL_MS: 20_000,
|
|
26
|
+
STALL_TIMEOUT_MS: 0,
|
|
27
|
+
SCORE_STALL_MS: 15_000,
|
|
28
|
+
...(constants || {}),
|
|
29
|
+
};
|
|
30
|
+
const _relay = relay;
|
|
31
|
+
const _build = buildFallbackInfo;
|
|
32
|
+
const _evt = evt || (() => {});
|
|
33
|
+
const _mark = mark || (() => {});
|
|
34
|
+
const _logCall = logCall || (() => {});
|
|
35
|
+
const _logError = logError || (() => {});
|
|
36
|
+
const _perfNow = perfNow || (() => Date.now());
|
|
37
|
+
|
|
38
|
+
async function execute({
|
|
39
|
+
res,
|
|
40
|
+
upRes,
|
|
41
|
+
body,
|
|
42
|
+
requested,
|
|
43
|
+
actual,
|
|
44
|
+
lastErr,
|
|
45
|
+
via,
|
|
46
|
+
lockModel,
|
|
47
|
+
useAuto,
|
|
48
|
+
handlerCtx,
|
|
49
|
+
mark: m2,
|
|
50
|
+
perf0,
|
|
51
|
+
stages: s2,
|
|
52
|
+
startedAt: sa2,
|
|
53
|
+
} = {}) {
|
|
54
|
+
const markFn = m2 || _mark;
|
|
55
|
+
const curStartedAt = sa2 ?? defaultStartedAt ?? Date.now();
|
|
56
|
+
const curStages = s2 ?? defaultStages ?? [];
|
|
57
|
+
const reqId = handlerCtx?.reqId;
|
|
58
|
+
const hops = handlerCtx?.hops;
|
|
59
|
+
|
|
60
|
+
// 1. logCall(pre) — 保持原 handler 的 logCall→fallback→relay-start 时序
|
|
61
|
+
try { _logCall(actual, upRes?.status); } catch {}
|
|
62
|
+
// 2. fallback + relay-start
|
|
63
|
+
let fallback = null;
|
|
64
|
+
try {
|
|
65
|
+
if (_build) fallback = _build({ requested, actual, lastErr, via, useAuto, lockModel });
|
|
66
|
+
} catch {}
|
|
67
|
+
if (fallback?.fallback) {
|
|
68
|
+
_evt("fallback-notice", { reqId, requested, actual, reason: fallback.reason, notice: fallback.notice, via, fallback: true });
|
|
69
|
+
}
|
|
70
|
+
_evt("relay-start", { reqId, model: actual, via, isStream: Boolean(body?.stream), fallback });
|
|
71
|
+
|
|
72
|
+
// 3. relay
|
|
73
|
+
const out = await _relay(res, upRes, body, {
|
|
74
|
+
fallback,
|
|
75
|
+
onFirstChunk: (delta) => {
|
|
76
|
+
try { markFn(`ttf-${actual}`); } catch {}
|
|
77
|
+
_evt("relay-first-chunk", { reqId, model: actual, ttfMs: delta, via });
|
|
78
|
+
if (plugins?.length) runHook(plugins, "relay:first-chunk", { reqId, requested, model: actual, via, ttfMs: delta }).catch(() => {});
|
|
79
|
+
},
|
|
80
|
+
onDownstreamAbort: () => {
|
|
81
|
+
_evt("client-abort", { reqId, model: actual, totalMs: Math.round(_perfNow() - (perf0 ?? 0)), stages: [...curStages] });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// 4. relay-done
|
|
86
|
+
_evt("relay-done", {
|
|
87
|
+
reqId,
|
|
88
|
+
model: actual,
|
|
89
|
+
via,
|
|
90
|
+
status: out.status,
|
|
91
|
+
ttfMs: out.ttfMs,
|
|
92
|
+
totalMs: out.totalMs,
|
|
93
|
+
aborted: out.aborted,
|
|
94
|
+
interrupted: out.interrupted ?? false,
|
|
95
|
+
detail: out.detail ?? null,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// 5a. 首块超时未写字节 → 回退
|
|
99
|
+
if (out.status === C.STREAM_TIMEOUT_MS) {
|
|
100
|
+
if (auto) try { await auto.recordError(actual, { status: 502, slow: true, note: `stream timeout ${C.STREAM_TIMEOUT_MS}ms` }); } catch {}
|
|
101
|
+
try { _logError(actual, 502, `stream timeout ${C.STREAM_TIMEOUT_MS}ms`); } catch {}
|
|
102
|
+
_evt("upstream-error", { reqId, model: actual, status: 502, message: "stream timeout", timing: null });
|
|
103
|
+
_evt("fallback", { reqId, from: actual, to: null, reason: "stream timeout" });
|
|
104
|
+
return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: `stream timed out after ${C.STREAM_TIMEOUT_MS}ms` } };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 5b. 中断(stall 超时 / max 流时长)
|
|
108
|
+
if (out.interrupted) {
|
|
109
|
+
if (auto) {
|
|
110
|
+
try { await auto.recordError(actual, { status: 200, slow: true, note: `stall ${C.STALL_TIMEOUT_MS}ms` }); } catch {}
|
|
111
|
+
try { await auto.recordLatency(actual, out.totalMs ?? (Date.now() - curStartedAt)); } catch {}
|
|
112
|
+
}
|
|
113
|
+
_evt("slow-model", { reqId, model: actual, elapsedMs: out.totalMs ?? (Date.now() - curStartedAt), threshold: C.STALL_TIMEOUT_MS, interrupted: true, detail: out.detail ?? null });
|
|
114
|
+
try { _logCall(actual, 200); } catch {}
|
|
115
|
+
_evt("result", { reqId, model: actual, status: out.status, via, timing: upRes?._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true, detail: out.detail ?? null, fallback, requested, actual });
|
|
116
|
+
_evt("client-response", { requested, actual, via, fallback, status: out.status, reqId, interrupted: true });
|
|
117
|
+
if (plugins?.length) runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body?.stream), durationMs: Date.now() - curStartedAt, via, status: out.status, actual, interrupted: true, fallback }).catch(() => {});
|
|
118
|
+
return { handled: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 5c. 慢速计分 + ok
|
|
122
|
+
const elapsed = Date.now() - curStartedAt;
|
|
123
|
+
const latencyMs = out.totalMs ?? elapsed;
|
|
124
|
+
let scoredSlow = false;
|
|
125
|
+
|
|
126
|
+
if (C.SLOW_TOTAL_MS && auto && elapsed > C.SLOW_TOTAL_MS && out.status === 200) {
|
|
127
|
+
try { await auto.recordError(actual, { status: 200, slow: true, note: `slow ${elapsed}ms` }); } catch {}
|
|
128
|
+
try { await auto.recordLatency(actual, latencyMs); } catch {}
|
|
129
|
+
_evt("slow-model", { reqId, model: actual, elapsedMs: elapsed, threshold: C.SLOW_TOTAL_MS, reason: "total", detail: out.detail ?? null });
|
|
130
|
+
scoredSlow = true;
|
|
131
|
+
}
|
|
132
|
+
if (out.detail?.stallHits > 0 && auto && out.status === 200) {
|
|
133
|
+
try { await auto.recordError(actual, { status: 200, slow: true, note: `stall ${out.detail.stallHits}x gap>${C.SCORE_STALL_MS}ms maxGap ${out.detail.maxGapMs}ms` }); } catch {}
|
|
134
|
+
try { await auto.recordLatency(actual, latencyMs); } catch {}
|
|
135
|
+
_evt("slow-model", { reqId, model: actual, elapsedMs: elapsed, threshold: C.SCORE_STALL_MS, reason: "stall", stallHits: out.detail.stallHits, maxGapMs: out.detail.maxGapMs, detail: out.detail ?? null });
|
|
136
|
+
scoredSlow = true;
|
|
137
|
+
}
|
|
138
|
+
if (!scoredSlow && auto && out.status === 200) {
|
|
139
|
+
try { await auto.recordOk(actual, { latencyMs }); } catch {}
|
|
140
|
+
} else if (!scoredSlow && auto) {
|
|
141
|
+
try { await auto.recordLatency(actual, latencyMs); } catch {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_evt("result", { reqId, model: actual, status: out.status, via, timing: upRes?._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback, requested, actual });
|
|
145
|
+
_evt("client-response", { requested, actual, via, fallback, status: out.status, reqId });
|
|
146
|
+
if (plugins?.length) runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body?.stream), durationMs: Date.now() - curStartedAt, via, status: out.status, actual, fallback }).catch(() => {});
|
|
147
|
+
return { handled: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { execute };
|
|
151
|
+
}
|
|
@@ -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
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export { DEFAULT_PORT, defaultStateFile, tokenFile, generateToken, flushStateSync, clearStateCache, readState, writeStateImmediate, writeStateDeferred } from "./store.js";
|
|
2
|
+
export { loadToken, refreshToken } from "./schemas/token.js";
|
|
3
|
+
export { getPort, setPort } from "./schemas/port.js";
|
|
4
|
+
export {
|
|
5
|
+
WORKBUDDY_DEFAULT_BASE_URL,
|
|
6
|
+
providerKeyEnv,
|
|
7
|
+
providerBaseUrlEnv,
|
|
8
|
+
providerShareEnv,
|
|
9
|
+
loadProviderKeys,
|
|
10
|
+
loadProviderKey,
|
|
11
|
+
saveProviderKeys,
|
|
12
|
+
saveProviderKey,
|
|
13
|
+
addProviderKey,
|
|
14
|
+
removeProviderKey,
|
|
15
|
+
removeProviderKeys,
|
|
16
|
+
loadProviderShareKeys,
|
|
17
|
+
saveProviderShareKeys,
|
|
18
|
+
loadProviderConfigs,
|
|
19
|
+
loadProviderConfig,
|
|
20
|
+
loadProviderBaseUrl,
|
|
21
|
+
loadProviderAuths,
|
|
22
|
+
saveProviderAuths,
|
|
23
|
+
saveProviderBaseUrl,
|
|
24
|
+
saveProviderConfig,
|
|
25
|
+
normalizeAllowedModel,
|
|
26
|
+
normalizeBaseUrl,
|
|
27
|
+
normalizeAuths,
|
|
28
|
+
normalizeEndpointPath,
|
|
29
|
+
defaultModelsPath,
|
|
30
|
+
defaultChatPath,
|
|
31
|
+
loadProviderModelsPath,
|
|
32
|
+
loadProviderChatPath,
|
|
33
|
+
} from "./schemas/provider.js";
|
|
34
|
+
export {
|
|
35
|
+
loadProviderAllowedModels,
|
|
36
|
+
saveProviderAllowedModels,
|
|
37
|
+
addProviderAllowedModel,
|
|
38
|
+
removeProviderAllowedModel,
|
|
39
|
+
removeProviderAllowedModels,
|
|
40
|
+
loadProviderAllowAnyModels,
|
|
41
|
+
saveProviderAllowAnyModels,
|
|
42
|
+
isModelAllowed,
|
|
43
|
+
} from "./schemas/allowlist.js";
|
|
44
|
+
export {
|
|
45
|
+
loadModelErrors,
|
|
46
|
+
saveModelErrors,
|
|
47
|
+
loadModelLatencies,
|
|
48
|
+
saveModelLatencies,
|
|
49
|
+
loadModelStats,
|
|
50
|
+
saveModelStats,
|
|
51
|
+
loadPreferredModel,
|
|
52
|
+
loadModelPicks,
|
|
53
|
+
saveModelPicks,
|
|
54
|
+
savePreferredModel,
|
|
55
|
+
} from "./schemas/model.js";
|
|
56
|
+
export { loadPeers, savePeers, loadPeerErrors, savePeerErrors, loadPeerStats, savePeerStats } from "./schemas/peer.js";
|
|
57
|
+
export { loadGroups, loadGroupsJoined, saveGroupsJoined, loadBans, saveBans, saveGroups } from "./schemas/group.js";
|