mslxdff 0.1.66 → 0.1.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/bench/probe.js +68 -0
  3. package/src/bench/report.js +56 -0
  4. package/src/bench/runner.js +74 -0
  5. package/src/chat/cooling.js +99 -0
  6. package/src/chat/direct.js +57 -0
  7. package/src/chat/gateway.js +148 -0
  8. package/src/chat/orchestrator.js +218 -0
  9. package/src/chat/sse.js +69 -0
  10. package/src/chat/upstream.js +59 -501
  11. package/src/cli/bootstrap.js +1 -408
  12. package/src/cli/commands/provider/bench.js +140 -0
  13. package/src/cli/commands/provider/index.js +2 -0
  14. package/src/cli/provider-row.js +73 -0
  15. package/src/cli/status.js +3 -52
  16. package/src/metrics.js +63 -0
  17. package/src/providers/dispatcher.js +18 -6
  18. package/src/providers/generic.js +2 -0
  19. package/src/providers/openrouter.js +16 -72
  20. package/src/providers/workbuddy/auth.js +175 -0
  21. package/src/providers/workbuddy/balance.js +84 -0
  22. package/src/providers/workbuddy/chat.js +310 -0
  23. package/src/providers/workbuddy/index.js +263 -0
  24. package/src/providers/workbuddy/models.js +111 -0
  25. package/src/providers/workbuddy/rotation-log.js +54 -0
  26. package/src/providers/workbuddy.js +2 -652
  27. package/src/routes/chat/broadband-handler.js +25 -46
  28. package/src/routes/chat/exhausted-handler.js +2 -2
  29. package/src/routes/chat/hedge-handler.js +65 -83
  30. package/src/routes/chat/local-handler.js +32 -61
  31. package/src/routes/chat/peer-handler.js +32 -23
  32. package/src/routes/chat/relay-pipeline.js +151 -0
  33. package/src/runtime/bootstrap.js +408 -0
  34. package/src/state/memory.js +2 -21
  35. package/src/state/merge.js +26 -0
  36. package/src/state/provider-config.js +143 -0
  37. package/src/state/schemas/provider.js +83 -133
  38. package/src/state/store.js +2 -28
@@ -1,408 +1 @@
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
-
1
+ export * from "../runtime/bootstrap.js";
@@ -0,0 +1,140 @@
1
+ import { probeModels } from "../../../bench/probe.js";
2
+ import { runOne } from "../../../bench/runner.js";
3
+ import { formatReport } from "../../../bench/report.js";
4
+ import { defaultModelsPath, defaultChatPath } from "../../../state/provider-config.js";
5
+
6
+ function parseBenchArgs(rest) {
7
+ const opts = { json: false, prompt: "hi", maxTokens: 32, timeoutMs: 30000 };
8
+ for (let i = 0; i < rest.length; i++) {
9
+ const a = rest[i];
10
+ if (a === "--json" || a === "-json") opts.json = true;
11
+ else if (a === "--prompt" && rest[i + 1]) opts.prompt = rest[++i];
12
+ else if (a.startsWith("--prompt=")) opts.prompt = a.slice(9);
13
+ else if (a === "--max-tokens" && rest[i + 1]) opts.maxTokens = Number(rest[++i]) || 32;
14
+ else if (a.startsWith("--max-tokens=")) opts.maxTokens = Number(a.split("=")[1]) || 32;
15
+ else if (a === "--timeout" && rest[i + 1]) opts.timeoutMs = Number(rest[++i]) || 30000;
16
+ else if (a.startsWith("--timeout=")) opts.timeoutMs = Number(a.split("=")[1]) || 30000;
17
+ }
18
+ return opts;
19
+ }
20
+
21
+ function buildHeadersForProvider(providerId, apiKey, auth) {
22
+ const h = {};
23
+ if (String(providerId).toLowerCase() === "workbuddy") {
24
+ h["Content-Type"] = "application/json";
25
+ h["Accept"] = "text/event-stream";
26
+ h["User-Agent"] = "CLI/2.115.0 WorkBuddy/2.115.0";
27
+ h["Origin"] = "https://www.codebuddy.cn";
28
+ h["Referer"] = "https://www.codebuddy.cn/";
29
+ h["X-Product"] = "SaaS";
30
+ if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
31
+ if (auth?.uid) h["X-User-Id"] = auth.uid;
32
+ h["X-Domain"] = auth?.domain || "www.codebuddy.cn";
33
+ if (auth?.enterpriseId) {
34
+ h["X-Enterprise-Id"] = auth.enterpriseId;
35
+ h["X-Tenant-Id"] = auth.enterpriseId;
36
+ }
37
+ return h;
38
+ }
39
+ if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
40
+ return h;
41
+ }
42
+
43
+ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
44
+ const isBench = sub === "bench" || sub === "benchmark" || sub === "eval" || sub === "test";
45
+ if (!isBench) return false;
46
+ const fetchImpl = deps.fetchImpl || globalThis.fetch;
47
+ const loadConfigs = deps.loadProviderConfigs || (await import("../../../state.js")).loadProviderConfigs;
48
+ const loadKeys = deps.loadProviderKeys || (await import("../../../state.js")).loadProviderKeys;
49
+ const loadAllowed = deps.loadProviderAllowedModels || (await import("../../../state.js")).loadProviderAllowedModels;
50
+ const loadAllowAny = deps.loadProviderAllowAnyModels || (await import("../../../state.js")).loadProviderAllowAnyModels;
51
+ const loadBaseUrl = deps.loadProviderBaseUrl || (await import("../../../state.js")).loadProviderBaseUrl;
52
+ const loadAuths = deps.loadProviderAuths || (async () => { try { const m = await import("../../../state.js"); return m.loadProviderAuths ? m.loadProviderAuths(id) : []; } catch { return []; } }) && (await import("../../../state.js")).loadProviderAuths;
53
+
54
+ const providerId = String(id || "").trim();
55
+ if (!providerId) {
56
+ console.error("usage: mslxdff -provider <id> bench [--json] [--prompt hi] [--max-tokens 32]");
57
+ process.exit(1);
58
+ }
59
+ const opts = parseBenchArgs(rest || []);
60
+ const configs = loadConfigs();
61
+ const cfg = configs[providerId] || {};
62
+ const keys = loadKeys(providerId) || [];
63
+ const allowed = loadAllowed(providerId) || [];
64
+ const allowAny = loadAllowAny(providerId);
65
+ const baseUrl = (loadBaseUrl(providerId) || cfg.baseUrl || "").trim();
66
+ let auths = [];
67
+ try { const m = await import("../../../state.js"); auths = m.loadProviderAuths ? m.loadProviderAuths(providerId) : []; } catch {}
68
+ if (!baseUrl) {
69
+ console.error(`provider ${providerId}: missing baseUrl — 先设置: mslxdff -provider ${providerId} set-url https://api.example.com/v1`);
70
+ process.exit(1);
71
+ }
72
+ if (!keys.length) {
73
+ console.error(`provider ${providerId}: 未配置 Key — 先设置: mslxdff -provider ${providerId} <key>`);
74
+ process.exit(1);
75
+ }
76
+ // 空勾选 → 不发 chat,仅探活模型列表并提示
77
+ if (!allowed.length) {
78
+ const modelsPath = cfg.modelsPath || defaultModelsPath(providerId);
79
+ console.log(`provider ${providerId}: 未设置 allowlist(allowAny=${allowAny ? "ON" : "OFF"}),不发起测速,仅探活模型列表...`);
80
+ console.log(`尝试:GET ${baseUrl}${modelsPath} → GET ${baseUrl}/v1/models → GET ${baseUrl}/models`);
81
+ const headers = buildHeadersForProvider(providerId, keys[0], auths[0]);
82
+ const probed = await probeModels({ baseUrl, modelsPath, headers, fetchImpl, timeoutMs: 8000 });
83
+ if (!probed.ok) {
84
+ console.error(`探活失败:${probed.error}`);
85
+ console.error(`已尝试:${probed.tried.join(", ")}`);
86
+ console.error(`请手动设置 allowlist:mslxdff -provider ${providerId} allowlist set <model>`);
87
+ if (opts.json) console.log(JSON.stringify({ ok: false, error: probed.error, tried: probed.tried }, null, 2));
88
+ process.exit(1);
89
+ }
90
+ const list = probed.data || [];
91
+ if (!list.length) {
92
+ console.log("探活成功但返回空列表,请确认上游是否暴露 /v1/models");
93
+ if (opts.json) console.log(JSON.stringify({ ok: true, data: [] }, null, 2));
94
+ process.exit(0);
95
+ }
96
+ console.log(`\n发现 ${list.length} 个模型:`);
97
+ for (const m of list.slice(0, 30)) {
98
+ console.log(` - ${m.id || m.model || m.name || JSON.stringify(m).slice(0, 80)}`);
99
+ }
100
+ if (list.length > 30) console.log(` ... 还有 ${list.length - 30} 个未展示`);
101
+ console.log(`\n下一步:勾选后再测(只测勾选,避免扣费)`);
102
+ console.log(` mslxdff -provider ${providerId} allowlist set ${list.slice(0, 2).map((m) => m.id).join(" ")}`);
103
+ console.log(` mslxdff -provider ${providerId} bench${opts.json ? " --json" : ""}`);
104
+ if (opts.json) console.log(JSON.stringify({ ok: true, data: list, hint: `pick then bench` }, null, 2));
105
+ process.exit(0);
106
+ }
107
+
108
+ // 有勾选 → 逐个测
109
+ console.log(`bench ${providerId}: 共 ${allowed.length} 个已勾选模型,逐个测速(串行,${opts.timeoutMs}ms 超时)...`);
110
+ if (!opts.json) console.log(`prompt="${opts.prompt}" maxTokens=${opts.maxTokens}\n`);
111
+ const chatPath = cfg.chatPath || defaultChatPath(providerId);
112
+ const results = [];
113
+ for (let i = 0; i < allowed.length; i++) {
114
+ const raw = allowed[i];
115
+ const model = String(raw || "").trim();
116
+ if (!opts.json) process.stdout.write(` [${i + 1}/${allowed.length}] ${model} ... `);
117
+ // 轮询 key/auth:按索引取,超长循环
118
+ const kIdx = i % keys.length;
119
+ const aIdx = Math.min(kIdx, auths.length - 1);
120
+ const key = keys[kIdx];
121
+ const auth = auths[aIdx] || auths[0] || null;
122
+ const headers = buildHeadersForProvider(providerId, key, auth);
123
+ const r = await runOne({ baseUrl, chatPath, model, providerId, apiKey: key, headers, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
124
+ results.push(r);
125
+ if (!opts.json) {
126
+ if (r.ok) console.log(`OK TTFB ${r.ttfbMs}ms 总 ${r.totalMs}ms ${r.tps != null ? `${r.tps} t/s` : r.charsPerSec != null ? `${r.charsPerSec} 字/秒` : "—"}`);
127
+ else console.log(`FAIL ${r.label} ${r.error ? `(${r.error.slice(0, 60)})` : ""}`);
128
+ }
129
+ }
130
+ const report = formatReport(results, { json: opts.json });
131
+ console.log("\n" + report.text);
132
+ if (opts.json) {
133
+ // json 已在 text 中输出一次(report.text 是 JSON),无需重复
134
+ }
135
+ const failed = results.filter((r) => !r.ok).length;
136
+ if (failed && !opts.json) {
137
+ console.log(`\n提示:失败 ${failed} 个多为 402余额不足/429限流/超时,可清冷却或换 Key 后重试`);
138
+ }
139
+ process.exit(failed ? 2 : 0);
140
+ }
@@ -187,6 +187,8 @@ export async function handleProvider(args) {
187
187
  if (await handleProviderAllowlist(id, sub, rest)) return true;
188
188
  const { handleProviderModels } = await import("./models.js");
189
189
  if (await handleProviderModels(id, sub, args, rest)) return true;
190
+ const { handleProviderBench } = await import("./bench.js");
191
+ if (await handleProviderBench(id, sub, rest, args)) return true;
190
192
  const { handleProviderKeys } = await import("./keys.js");
191
193
  await handleProviderKeys(id, sub, rest, args);
192
194
  return true;
@@ -0,0 +1,73 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderShareKeys, loadProviderBaseUrl } from "../state.js";
3
+
4
+ /**
5
+ * 聚合 genericIds:来自 providerConfigs + providerKeys + env MSLXDFF_*_KEY
6
+ * 纯逻辑抽取,便于单测;默认读真实 stateFile 与 env,测试可注入 stateFile
7
+ */
8
+ export function buildProviderRows({ stateFile, env = process.env } = {}) {
9
+ // 复用 status 的聚合逻辑,但抽为纯函数
10
+ let configs = {};
11
+ try { configs = loadProviderConfigs(stateFile ? { file: stateFile } : undefined); } catch {}
12
+ const upstreamBase = env.UPSTREAM_BASE_URL || "https://opencode.ai";
13
+ const rows = [];
14
+ const opAllowed = (() => { try { return loadProviderAllowedModels("opencode", stateFile ? { file: stateFile } : undefined); } catch { return []; } })();
15
+ rows.push({ id: "opencode", enabled: true, baseUrl: upstreamBase, keys: [], allowed: opAllowed, share: false, note: "built-in, no key, cannot share", authCount: 0 });
16
+
17
+ const genericIds = new Set(Object.keys(configs).filter((id) => id !== "opencode"));
18
+ try {
19
+ const raw = JSON.parse(readFileSync(stateFile || "", "utf8"));
20
+ const pk = raw.providerKeys || {};
21
+ for (const id of Object.keys(pk)) if (id !== "opencode") genericIds.add(id);
22
+ const cfgRaw = raw.providerConfigs || {};
23
+ for (const id of Object.keys(cfgRaw)) if (id !== "opencode") genericIds.add(id);
24
+ } catch {}
25
+ for (const k of Object.keys(env)) {
26
+ const m = k.match(/^MSLXDFF_(.+)_KEY$/);
27
+ if (m) {
28
+ const id = m[1].toLowerCase().replace(/__/g, "-");
29
+ if (id !== "opencode") genericIds.add(id);
30
+ }
31
+ }
32
+
33
+ for (const gid of [...genericIds].sort()) {
34
+ const cfg = configs[gid];
35
+ let keys = [];
36
+ let baseUrl = "";
37
+ let share = false;
38
+ let allowed = [];
39
+ try { keys = loadProviderKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
40
+ try { baseUrl = loadProviderBaseUrl(gid, stateFile ? { file: stateFile } : undefined) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : ""); } catch { baseUrl = cfg?.baseUrl || ""; }
41
+ try { share = loadProviderShareKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
42
+ try { allowed = loadProviderAllowedModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
43
+ let enabled = Boolean(baseUrl && keys.length) || (gid === "openrouter" && keys.length > 0);
44
+ const auths = gid === "workbuddy" ? (() => { try { return loadProviderAuths(gid, stateFile ? { file: stateFile } : undefined) || []; } catch { return []; } })() : [];
45
+ let note = "";
46
+ const isWorkbuddyStub = gid === "workbuddy" && (keys.includes("k-new") || String(baseUrl).includes("127.0.0.1") || (keys.length === 1 && keys[0].length < 20));
47
+ if (isWorkbuddyStub) {
48
+ enabled = false;
49
+ note = "测试桩 (key=k-new, baseUrl=127.0.0.1) — 请重跑 node workbuddy-token-auto.js 写入真实 JWT";
50
+ } else if (!enabled) {
51
+ if (!baseUrl && !keys.length) note = "no baseUrl, no keys";
52
+ else if (!baseUrl) note = "missing baseUrl";
53
+ else if (!keys.length) note = "no keys";
54
+ } else if (gid === "workbuddy" && auths.length && auths.length !== keys.length) {
55
+ note = `${auths.length} auth(s) / ${keys.length} key(s) — 数量不一致请重跑 workbuddy-token-auto.js`;
56
+ }
57
+ rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, share, note, authCount: auths.length });
58
+ }
59
+ return rows;
60
+ }
61
+
62
+ export function formatProviderRow(p) {
63
+ const dot = p.enabled ? "●" : "○";
64
+ const state = p.enabled ? "enabled " : "disabled";
65
+ let keysInfo;
66
+ if (p.id === "opencode") keysInfo = "无需 key (内置)";
67
+ else keysInfo = p.keys.length ? `${p.keys.length} key${p.keys.length > 1 ? "s" : ""} ${p.keys.map((k) => `${k.slice(0, 3)}…${k.slice(-3)}`).join(", ")}` : "0 keys";
68
+ const authInfo = p.authCount ? ` ${p.authCount} acc` : "";
69
+ const allowInfo = p.allowed.length ? `allow=${p.allowed.length}(${p.allowed.slice(0, 2).join(",")}${p.allowed.length > 2 ? "…" : ""})` : "allow=all";
70
+ const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
71
+ const note = p.note ? ` (${p.note})` : "";
72
+ return ` ${dot} ${p.id.padEnd(12)} ${state} ${keysInfo}${authInfo} ${allowInfo.padEnd(18)} baseUrl=${p.baseUrl} ${shareInfo}${note}`;
73
+ }