mslxdff 0.1.95 → 0.1.97

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 (45) hide show
  1. package/package.json +3 -3
  2. package/src/bench/probe.js +2 -1
  3. package/src/bench/runner.js +2 -1
  4. package/src/bench/via-probe.js +2 -1
  5. package/src/bench/workbuddy-bench.js +2 -1
  6. package/src/chat/direct.js +2 -1
  7. package/src/chat/gateway.js +2 -1
  8. package/src/chat/stats.js +2 -1
  9. package/src/chat/tools.js +3 -6
  10. package/src/chat/upstream.js +3 -2
  11. package/src/cli/commands/daemon.js +2 -1
  12. package/src/cli/commands/group.js +4 -3
  13. package/src/cli/commands/provider/bench.js +3 -2
  14. package/src/cli/commands/provider/cline-login.js +4 -3
  15. package/src/cli/commands/provider/workbuddy-login.js +8 -7
  16. package/src/cli/group-helpers.js +2 -1
  17. package/src/cli/policy.js +2 -1
  18. package/src/cli/status.js +3 -2
  19. package/src/compat.js +35 -0
  20. package/src/free-watcher.js +2 -1
  21. package/src/groups.js +2 -1
  22. package/src/models.js +2 -1
  23. package/src/providers/base.js +3 -8
  24. package/src/providers/cline/auth.js +2 -1
  25. package/src/providers/cline/index.js +2 -1
  26. package/src/providers/cline/models.js +2 -1
  27. package/src/providers/generic.js +2 -1
  28. package/src/providers/workbuddy/auth.js +4 -7
  29. package/src/providers/workbuddy/balance.js +3 -1
  30. package/src/providers/workbuddy/checkin.js +4 -3
  31. package/src/providers/workbuddy/index.js +2 -1
  32. package/src/providers/workbuddy-balance.js +3 -1
  33. package/src/readline-compat.js +4 -4
  34. package/src/routes/chat/via-route-handler.js +2 -1
  35. package/src/routes/groups-relay.js +2 -1
  36. package/src/routes/peers.js +4 -3
  37. package/src/routes/relay-queue.js +3 -2
  38. package/src/routes/relay.js +2 -1
  39. package/src/runtime/broadband-stream.js +2 -1
  40. package/src/runtime/broadband.js +7 -6
  41. package/src/runtime/workbuddy-checkin.js +2 -1
  42. package/src/state/memory.js +10 -9
  43. package/src/transport/index.js +3 -6
  44. package/src/transport/pool.js +3 -7
  45. package/src/upstream.js +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.95",
3
+ "version": "0.1.97",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "docs:check": "node scripts/docs-check.js"
13
13
  },
14
14
  "engines": {
15
- "node": ">=20"
15
+ "node": ">=16"
16
16
  },
17
17
  "files": [
18
18
  "bin/",
@@ -28,6 +28,6 @@
28
28
  ],
29
29
  "license": "MIT",
30
30
  "dependencies": {
31
- "undici": "^8.10.0"
31
+ "undici": "^5.28.4"
32
32
  }
33
33
  }
@@ -1,6 +1,7 @@
1
1
  import { joinUrl } from "../providers/base.js";
2
2
  import { getCustomNormalizer } from "../providers/registry.js";
3
3
  import { createTransport } from "../transport/index.js";
4
+ import { compatFetch } from "../compat.js";
4
5
 
5
6
  function normalizeModelsPayload(json, baseUrl = "") {
6
7
  if (!json) return [];
@@ -32,7 +33,7 @@ export async function probeModels({
32
33
  modelsPath,
33
34
  chatPath,
34
35
  headers = {},
35
- fetchImpl = globalThis.fetch,
36
+ fetchImpl = compatFetch,
36
37
  timeoutMs = 8000,
37
38
  } = {}) {
38
39
  const base = String(baseUrl || "").replace(/\/+$/, "");
@@ -1,6 +1,7 @@
1
1
  import { joinUrl } from "../providers/base.js";
2
2
  import { computeMetrics, extractUsageFromJson } from "../metrics.js";
3
3
  import { createTransport } from "../transport/index.js";
4
+ import { compatFetch } from "../compat.js";
4
5
 
5
6
  function extractInnerMessage(bodyText) {
6
7
  const t = String(bodyText || "");
@@ -37,7 +38,7 @@ export async function runOne({
37
38
  prompt = "hi",
38
39
  maxTokens = 32,
39
40
  timeoutMs = 30000,
40
- fetchImpl = globalThis.fetch,
41
+ fetchImpl = compatFetch,
41
42
  } = {}) {
42
43
  if (!baseUrl) return { id: model, ok: false, error: "missing baseUrl", label: "配置错误", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
43
44
  if (!model) return { id: model, ok: false, error: "missing model", label: "配置错误", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
@@ -1,6 +1,7 @@
1
1
  import { joinUrl } from "../providers/base.js";
2
2
  import { computeMetrics, extractUsageFromJson } from "../metrics.js";
3
3
  import { createTransport } from "../transport/index.js";
4
+ import { compatFetch } from "../compat.js";
4
5
 
5
6
  function extractInnerMessage(bodyText) {
6
7
  const t = String(bodyText || "");
@@ -35,7 +36,7 @@ export async function viaProbe({
35
36
  prompt = "hi",
36
37
  maxTokens = 5,
37
38
  timeoutMs = 30000,
38
- fetchImpl = globalThis.fetch,
39
+ fetchImpl = compatFetch,
39
40
  clock = Date.now,
40
41
  shareKeys,
41
42
  shareKeysHeader,
@@ -1,5 +1,6 @@
1
1
  import { computeMetrics } from "../metrics.js";
2
2
  import { createTransport } from "../transport/index.js";
3
+ import { compatFetch } from "../compat.js";
3
4
 
4
5
  function buildWorkbuddyHeaders(apiKey, auth) {
5
6
  const h = {
@@ -22,7 +23,7 @@ function sseContent(obj) {
22
23
  return typeof c === "string" ? c : "";
23
24
  }
24
25
 
25
- export async function workbuddyBenchOne({ baseUrl, chatPath = "/v2/chat/completions", model, apiKey, auth, prompt = "hi", maxTokens = 5, timeoutMs = 30000, fetchImpl = globalThis.fetch }) {
26
+ export async function workbuddyBenchOne({ baseUrl, chatPath = "/v2/chat/completions", model, apiKey, auth, prompt = "hi", maxTokens = 5, timeoutMs = 30000, fetchImpl = compatFetch }) {
26
27
  const tr = createTransport({ fetchImpl, keepAlive: false, retry: {}, timeoutMs });
27
28
  let ttfbMs = null;
28
29
  let totalMs = null;
@@ -1,4 +1,5 @@
1
1
  import { performance } from "node:perf_hooks";
2
+ import { compatFetch } from "../compat.js";
2
3
 
3
4
  function isInput400(status, msg, hasTools) {
4
5
  return status === 400 && /prompt|messages/i.test(String(msg || "")) && hasTools;
@@ -8,7 +9,7 @@ function isInput400(status, msg, hasTools) {
8
9
  * 直连深模块:mimo/pickle 经 createUpstreamClient 的 stream:false 调用
9
10
  * 注入化:便于用 fake client 触发 400→去 tools 重试
10
11
  */
11
- export function createDirectClient({ createUpstreamClient, chatTimeoutMs = 15000, env = process.env, fetchImpl = globalThis.fetch } = {}) {
12
+ export function createDirectClient({ createUpstreamClient, chatTimeoutMs = 15000, env = process.env, fetchImpl = compatFetch } = {}) {
12
13
  const _create = createUpstreamClient || (() => { throw new Error("createUpstreamClient not injected"); });
13
14
 
14
15
  async function doChat({ messages, tools, model }, withoutTools) {
@@ -1,13 +1,14 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { parseSse } from "./sse.js";
3
3
  import { DEFAULT_PORT } from "../state.js";
4
+ import { compatFetch } from "../compat.js";
4
5
 
5
6
  /**
6
7
  * 网关深模块:POST 127.0.0.1:port/v1/chat/completions model:auto
7
8
  * 注入化:fetch/loadToken/getPort/readModelsJson 均可伪,便于单测
8
9
  */
9
10
  export function createGatewayClient({
10
- fetchImpl = globalThis.fetch,
11
+ fetchImpl = compatFetch,
11
12
  loadToken,
12
13
  getPort,
13
14
  defaultPort = DEFAULT_PORT,
package/src/chat/stats.js CHANGED
@@ -5,6 +5,7 @@ import { logDir, callsFile, errorsFile, recentCalls, lastError } from "../logs.j
5
5
  import { fmtShanghai } from "../time.js";
6
6
  import { CHAT_PREFERRED, CHAT_FALLBACK } from "./config.js";
7
7
  import { normalizeFullId } from "../providers/model-id.js";
8
+ import { compatFetch } from "../compat.js";
8
9
 
9
10
  function readLinesCount(file) {
10
11
  try {
@@ -146,7 +147,7 @@ export async function probeGateway(port, timeoutMs = 800) {
146
147
  const ctrl = new AbortController();
147
148
  const t = setTimeout(() => ctrl.abort(), timeoutMs);
148
149
  try {
149
- const r = await fetch(url, { signal: ctrl.signal });
150
+ const r = await compatFetch(url, { signal: ctrl.signal });
150
151
  clearTimeout(t);
151
152
  return { alive: r.ok, status: r.status, ms: 0 };
152
153
  } catch (e) {
package/src/chat/tools.js CHANGED
@@ -6,6 +6,7 @@ import { performance } from "node:perf_hooks";
6
6
  import { FORBIDDEN } from "./config.js";
7
7
  import { logDir } from "../logs.js";
8
8
  import { defaultStateFile, loadProviderKeys, loadProviderConfigs } from "../state.js";
9
+ import { compatFetch } from "../compat.js";
9
10
 
10
11
  const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
11
12
  const allowedRoots = [
@@ -254,12 +255,8 @@ export async function curlTool({ url, method, headers, body, timeoutMs }) {
254
255
  }
255
256
  } catch {}
256
257
  }
257
- // fetch 实现优先 undici,其次全局
258
- let fetchImpl = globalThis.fetch;
259
- try {
260
- const mod = await import("undici");
261
- if (mod?.fetch) fetchImpl = mod.fetch;
262
- } catch {}
258
+ // fetch 走兼容层(undici 优先,老 Node 兜底)
259
+ const fetchImpl = compatFetch;
263
260
  const controller = new AbortController();
264
261
  const timer = setTimeout(() => controller.abort(new Error(`curl timed out after ${timeout}ms`)), timeout);
265
262
  const t0 = performance.now();
@@ -6,6 +6,7 @@ import { createUpstreamClient } from "../upstream.js";
6
6
  import { CHAT_TIMEOUT_MS, CHAT_GATEWAY_TIMEOUT_MS, CHAT_PREFERRED, CHAT_FALLBACK } from "./config.js";
7
7
  import * as state from "../state.js";
8
8
  import { performance } from "node:perf_hooks";
9
+ import { compatFetch } from "../compat.js";
9
10
 
10
11
  // 冷却深模块对接真实 state
11
12
  const cooling = createCooling({
@@ -25,11 +26,11 @@ const direct = createDirectClient({
25
26
  createUpstreamClient,
26
27
  chatTimeoutMs: CHAT_TIMEOUT_MS,
27
28
  env: process.env,
28
- fetchImpl: globalThis.fetch,
29
+ fetchImpl: compatFetch,
29
30
  });
30
31
 
31
32
  const gateway = createGatewayClient({
32
- fetchImpl: globalThis.fetch,
33
+ fetchImpl: compatFetch,
33
34
  loadToken: async () => {
34
35
  try { const l = await state.loadToken(); return String(l?.token || "").trim(); } catch { return ""; }
35
36
  },
@@ -4,6 +4,7 @@ import { setPort } from "../../state.js";
4
4
  import { logDir, eventsFile, callsFile, errorsFile } from "../../logs.js";
5
5
  import { effectivePort, waitForHealth, stopDaemonIfOutdated, compareSemver, argValue } from "../policy.js";
6
6
  import { printStatus } from "../status.js";
7
+ import { compatFetch, timeoutSignal } from "../../compat.js";
7
8
 
8
9
  export async function handleStop(args) {
9
10
  if (!(args.includes("-stop") || args.includes("--stop"))) return false;
@@ -35,7 +36,7 @@ export async function handleRestart(args, VERSION) {
35
36
  await waitForHealth(port, 4000);
36
37
  let ok = false;
37
38
  try {
38
- const r = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1200) });
39
+ const r = await compatFetch(`http://127.0.0.1:${port}/health`, { signal: timeoutSignal(1200) });
39
40
  ok = r.ok;
40
41
  } catch {}
41
42
  if (ok) console.log(`mslxdff v${VERSION} restarted as a background daemon (pid ${spawnedPid})`);
@@ -4,6 +4,7 @@ import { loadToken, loadGroupsJoined, saveGroupsJoined } from "../../state.js";
4
4
  import { groupIs, markJoined, probeHealth, syncAllJoinedGroups } from "../group-helpers.js";
5
5
  import { errMsg } from "../util.js";
6
6
  import { argValue } from "../policy.js";
7
+ import { compatFetch, timeoutSignal } from "../../compat.js";
7
8
 
8
9
  export async function handleGroupCreate(args) {
9
10
  const createGroupArg = argValue(args, "-creategroup", "--creategroup") || groupIs("create", args);
@@ -50,7 +51,7 @@ export async function handleGroupCommand(args) {
50
51
  process.exit(0);
51
52
  }
52
53
  const { token } = await loadToken();
53
- const fetchImpl = (url, opts) => fetch(url, { ...opts, signal: AbortSignal.timeout(1500) });
54
+ const fetchImpl = (url, opts) => compatFetch(url, { ...opts, signal: timeoutSignal(1500) });
54
55
  for (const g of joinedList) {
55
56
  const isLeader = !g.leaderUrl;
56
57
  let members;
@@ -169,7 +170,7 @@ export async function handleAddToGroup(args) {
169
170
  joinBody = { name, key: name, leaderUrl, myPort, token: myToken, kind: "static" };
170
171
  }
171
172
  try {
172
- const res = await fetch(`${leaderUrl}/v1/groups/join`, {
173
+ const res = await compatFetch(`${leaderUrl}/v1/groups/join`, {
173
174
  method: "POST",
174
175
  headers: { "Content-Type": "application/json" },
175
176
  body: JSON.stringify(joinBody),
@@ -219,7 +220,7 @@ export async function handleLeaveGroup(args) {
219
220
  if (g.leaderUrl) {
220
221
  const peersRemoved = peers.removeByGroup(g.name);
221
222
  try {
222
- const res = await fetch(`${g.leaderUrl}/v1/groups/leave`, {
223
+ const res = await compatFetch(`${g.leaderUrl}/v1/groups/leave`, {
223
224
  method: "POST",
224
225
  headers: {
225
226
  "Content-Type": "application/json",
@@ -7,6 +7,7 @@ import { refreshTokenForBase } from "../../../providers/cline/auth.js";
7
7
  import { clineBenchOne } from "../../../bench/cline-bench.js";
8
8
  import { workbuddyBenchOne } from "../../../bench/workbuddy-bench.js";
9
9
  import { buildHeadersForProvider, filterBenchModels, handleVia } from "./bench-via.js";
10
+ import { compatFetch } from "../../../compat.js";
10
11
 
11
12
  function parseBenchArgs(rest) {
12
13
  const opts = { json: false, prompt: "hi", maxTokens: 32, timeoutMs: 30000, via: false, includeOpencode: false, samples: 1, apply: false };
@@ -43,10 +44,10 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
43
44
  const loadBaseUrl = deps.loadProviderBaseUrl || stateMod.loadProviderBaseUrl;
44
45
  const viaPid = _isBenchViaAll ? "bench" : String(id || "").trim();
45
46
  if (!viaPid) { console.error("usage: mslxdff -provider <id> bench --via [--json] [--include-opencode]"); process.exit(1); }
46
- await handleVia({ providerId: viaPid, opts, fetchImpl: deps.fetchImpl || globalThis.fetch, loadConfigs, loadKeys, loadAllowed, loadBaseUrl, loadAllowAny: deps.loadProviderAllowAnyModels || stateMod.loadProviderAllowAnyModels, loadModelPicks: deps.loadModelPicks, getOnlinePeersFn: deps.getOnlinePeers });
47
+ await handleVia({ providerId: viaPid, opts, fetchImpl: deps.fetchImpl || compatFetch, loadConfigs, loadKeys, loadAllowed, loadBaseUrl, loadAllowAny: deps.loadProviderAllowAnyModels || stateMod.loadProviderAllowAnyModels, loadModelPicks: deps.loadModelPicks, getOnlinePeersFn: deps.getOnlinePeers });
47
48
  return true;
48
49
  }
49
- const fetchImpl = deps.fetchImpl || globalThis.fetch;
50
+ const fetchImpl = deps.fetchImpl || compatFetch;
50
51
  const loadConfigs = deps.loadProviderConfigs || (await import("../../../state.js")).loadProviderConfigs;
51
52
  const loadKeys = deps.loadProviderKeys || (await import("../../../state.js")).loadProviderKeys;
52
53
  const loadAllowed = deps.loadProviderAllowedModels || (await import("../../../state.js")).loadProviderAllowedModels;
@@ -1,3 +1,4 @@
1
+ import { compatFetch, getUndici, timeoutSignal } from "../../../compat.js";
1
2
  export async function handleClineLogin(id, sub) {
2
3
  if (sub !== "login" && sub !== "auth" && sub !== "oauth") return false;
3
4
  if (id !== "cline" && id !== "clinebot" && id !== "cline-bot") return false;
@@ -13,7 +14,7 @@ export async function handleClineLogin(id, sub) {
13
14
  let dispatcher = null;
14
15
  if (PROXY_URL) {
15
16
  try {
16
- const { ProxyAgent } = await import("undici");
17
+ const { ProxyAgent } = getUndici();
17
18
  dispatcher = new ProxyAgent(PROXY_URL);
18
19
  } catch (e) {
19
20
  console.error(`⚠️ 代理变量 ${PROXY_URL} 不可用(${e.message}),回退直连`);
@@ -35,7 +36,7 @@ export async function handleClineLogin(id, sub) {
35
36
  const body = new URLSearchParams(form).toString();
36
37
  let res;
37
38
  try {
38
- res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, ...extraOpts, signal: AbortSignal.timeout(20000) });
39
+ res = await compatFetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, ...extraOpts, signal: timeoutSignal(20000) });
39
40
  } catch (e) { throw new Error(`连不上 WorkOS:${netHint(e, url)}`); }
40
41
  const txt = await res.text();
41
42
  try { return JSON.parse(txt); } catch { throw new Error(`WorkOS 返回非 JSON: ${txt.slice(0, 200)}`); }
@@ -43,7 +44,7 @@ export async function handleClineLogin(id, sub) {
43
44
  async function postJson(url, obj) {
44
45
  let res;
45
46
  try {
46
- res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(obj), ...extraOpts, signal: AbortSignal.timeout(20000) });
47
+ res = await compatFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(obj), ...extraOpts, signal: timeoutSignal(20000) });
47
48
  } catch (e) { throw new Error(`连不上 Cline:${netHint(e, url)}`); }
48
49
  const txt = await res.text();
49
50
  try { return JSON.parse(txt); } catch { throw new Error(`Cline 返回非 JSON: ${txt.slice(0, 200)}`); }
@@ -1,3 +1,4 @@
1
+ import { compatFetch, timeoutSignal } from "../../../compat.js";
1
2
  // mslxdff -provider workbuddy login — WorkBuddy 官方设备授权流(对标 clinebot login)。
2
3
  // 学自 Sliverkiss/workbuddy2api(cmd/login):POST /v2/plugin/auth/state 拿 state+authUrl →
3
4
  // 浏览器登录 → GET /v2/plugin/auth/token?state= 轮询 → GET /v2/plugin/login/account?state= 拿 uid。
@@ -26,12 +27,12 @@ async function readEnvelope(res) {
26
27
  return j;
27
28
  }
28
29
 
29
- export async function requestDeviceState(fetchImpl = globalThis.fetch) {
30
+ export async function requestDeviceState(fetchImpl = compatFetch) {
30
31
  const res = await fetchImpl(`${BASE}/v2/plugin/auth/state?platform=CLI`, {
31
32
  method: "POST",
32
33
  headers: baseHeaders(),
33
34
  body: "{}",
34
- signal: AbortSignal.timeout(20000),
35
+ signal: timeoutSignal(20000),
35
36
  });
36
37
  const j = await readEnvelope(res);
37
38
  if (j.code !== 0 || !j.data?.state || !j.data?.authUrl) {
@@ -41,12 +42,12 @@ export async function requestDeviceState(fetchImpl = globalThis.fetch) {
41
42
  }
42
43
 
43
44
  // 单次轮询:pending(业务 code 非 0)返回 null;HTTP 5xx 抛错;成功返回 token bundle。
44
- export async function pollDeviceToken(fetchImpl = globalThis.fetch, state) {
45
+ export async function pollDeviceToken(fetchImpl = compatFetch, state) {
45
46
  let res;
46
47
  try {
47
48
  res = await fetchImpl(`${BASE}/v2/plugin/auth/token?state=${encodeURIComponent(state)}`, {
48
49
  headers: baseHeaders(),
49
- signal: AbortSignal.timeout(20000),
50
+ signal: timeoutSignal(20000),
50
51
  });
51
52
  } catch (e) { throw new Error(`轮询失败: ${e.message}`); }
52
53
  if (res.status >= 500) throw new Error(`token 端点故障: HTTP ${res.status}`);
@@ -60,10 +61,10 @@ export async function pollDeviceToken(fetchImpl = globalThis.fetch, state) {
60
61
  };
61
62
  }
62
63
 
63
- export async function fetchDeviceAccount(fetchImpl = globalThis.fetch, state, accessToken) {
64
+ export async function fetchDeviceAccount(fetchImpl = compatFetch, state, accessToken) {
64
65
  const res = await fetchImpl(`${BASE}/v2/plugin/login/account?state=${encodeURIComponent(state)}`, {
65
66
  headers: { ...baseHeaders(), Authorization: `Bearer ${accessToken}` },
66
- signal: AbortSignal.timeout(20000),
67
+ signal: timeoutSignal(20000),
67
68
  });
68
69
  const j = await readEnvelope(res);
69
70
  if (j.code !== 0 || !j.data?.uid) throw new Error(`获取账号失败: code=${j.code} ${j.msg || ""}`.trim());
@@ -97,7 +98,7 @@ export async function handleWorkbuddyLogin(id, sub, rest = [], deps = {}) {
97
98
  process.exit(0);
98
99
  }
99
100
  if (sub !== "login" && sub !== "auth" && sub !== "oauth") return false;
100
- const fetchImpl = deps.fetchImpl || globalThis.fetch;
101
+ const fetchImpl = deps.fetchImpl || compatFetch;
101
102
 
102
103
  console.log("🚀 启动 WorkBuddy 设备授权流程...\n");
103
104
  let dev;
@@ -1,6 +1,7 @@
1
1
  import { loadToken, loadGroupsJoined, saveGroupsJoined } from "../state.js";
2
2
  import { refreshGroupMembers, syncPeersFromMembers } from "../groups.js";
3
3
  import { errMsg } from "./util.js";
4
+ import { compatFetch, timeoutSignal } from "../compat.js";
4
5
 
5
6
  const HEALTH_TIMEOUT_MS = 4000;
6
7
 
@@ -28,7 +29,7 @@ export async function probeHealth({ id, url, kind, lastSeen, publicIp } = {}) {
28
29
  const base = String(url).replace(/\/+$/, "");
29
30
  const startedAt = Date.now();
30
31
  try {
31
- const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) });
32
+ const res = await compatFetch(`${base}/health`, { signal: timeoutSignal(HEALTH_TIMEOUT_MS) });
32
33
  if (!res.ok) return { id, url: base, fail: `HTTP ${res.status}`, rank: 2 };
33
34
  return { id, url: base, ms: Date.now() - startedAt, rank: 0 };
34
35
  } catch (err) {
package/src/cli/policy.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readPid, readPidVersion, isPidAlive, stopDaemon } from "../daemon.js";
2
2
  import { resolvePort } from "../server.js";
3
3
  import { getPort, loadGroupsJoined } from "../state.js";
4
+ import { compatFetch } from "../compat.js";
4
5
 
5
6
  export function compareSemver(a, b) {
6
7
  const pa = a.split(".").map((x) => parseInt(x, 10) || 0);
@@ -39,7 +40,7 @@ export async function waitForHealth(port, timeoutMs) {
39
40
  const start = Date.now();
40
41
  while (Date.now() - start < timeoutMs) {
41
42
  try {
42
- const res = await fetch(`http://127.0.0.1:${port}/health`);
43
+ const res = await compatFetch(`http://127.0.0.1:${port}/health`);
43
44
  if (res.ok) return;
44
45
  } catch {
45
46
  // not up yet
package/src/cli/status.js CHANGED
@@ -14,6 +14,7 @@ import { fmtShanghaiYMDHM } from "../time.js";
14
14
  import { fmtStatus, fmtUptime, fmtTs } from "./format.js";
15
15
  import { compareSemver } from "./policy.js";
16
16
  import { buildProviderRows, formatProviderRow, formatProviderSection } from "./provider-row.js";
17
+ import { compatFetch, timeoutSignal } from "../compat.js";
17
18
 
18
19
  export async function printStatus(VERSION) {
19
20
  const daemon = readPid();
@@ -37,7 +38,7 @@ export async function printStatus(VERSION) {
37
38
  let healthLine = "";
38
39
  try {
39
40
  const t0 = Date.now();
40
- const r = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1200) });
41
+ const r = await compatFetch(`http://127.0.0.1:${port}/health`, { signal: timeoutSignal(1200) });
41
42
  const ms = Date.now() - t0;
42
43
  healthLine = r.ok ? `health ok ${ms}ms` : `health HTTP ${r.status} ${ms}ms`;
43
44
  } catch (e) {
@@ -175,7 +176,7 @@ export async function printStatus(VERSION) {
175
176
  if (isLeader) {
176
177
  members = groups.list()[g.name]?.members ?? {};
177
178
  } else {
178
- const fetchImpl = (url, opts) => fetch(url, { ...opts, signal: AbortSignal.timeout(1500) });
179
+ const fetchImpl = (url, opts) => compatFetch(url, { ...opts, signal: timeoutSignal(1500) });
179
180
  try {
180
181
  members = await refreshGroupMembers(g.name, {
181
182
  leaderUrl: g.leaderUrl,
package/src/compat.js ADDED
@@ -0,0 +1,35 @@
1
+ // 老 Node 兼容层(engines >=16):Node 16 缺 globalThis.fetch(18+)、
2
+ // AbortSignal.timeout(17.3+)、structuredClone(17+);裸全局 crypto 是
3
+ // WebCrypto,其 randomUUID 要 19+,只有 node:crypto 的 14.17+ 可用。
4
+ // undici 8 需 Node 22+,依赖已锁 5.x(老 Node 可跑,API 兼容我们用到的面)。
5
+ // 统一从这里取,源码里勿直接用这些全局。
6
+ import { randomUUID } from "node:crypto";
7
+
8
+ let _undici = null;
9
+ try { _undici = await import("undici"); } catch {}
10
+
11
+ export function getUndici() {
12
+ return _undici || {};
13
+ }
14
+
15
+ const _nativeFetch = typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : null;
16
+
17
+ export const compatFetch = _undici?.fetch || _nativeFetch || function noFetch() {
18
+ throw new Error("当前环境没有可用的 fetch:Node <18 且 undici 未安装。请 npm i undici@^5 或升级 Node >=18。");
19
+ };
20
+
21
+ export function timeoutSignal(ms) {
22
+ if (typeof AbortSignal?.timeout === "function") return AbortSignal.timeout(ms);
23
+ const ctl = new AbortController();
24
+ const t = setTimeout(() => ctl.abort(new Error(`aborted (timeout ${ms}ms)`)), ms);
25
+ if (typeof t.unref === "function") t.unref();
26
+ return ctl.signal;
27
+ }
28
+
29
+ export function clone(v) {
30
+ if (typeof structuredClone === "function") return structuredClone(v);
31
+ if (v === null || typeof v !== "object") return v;
32
+ return JSON.parse(JSON.stringify(v));
33
+ }
34
+
35
+ export const uuid = () => randomUUID();
@@ -1,3 +1,4 @@
1
+ import { compatFetch } from "./compat.js";
1
2
  const V2EX_LATEST = "https://www.v2ex.com/api/topics/latest.json";
2
3
  const V2EX_HOT = "https://www.v2ex.com/api/topics/hot.json";
3
4
 
@@ -15,7 +16,7 @@ async function fetchJson(url, timeoutMs = 6000) {
15
16
  const ctrl = new AbortController();
16
17
  const t = setTimeout(() => ctrl.abort(), timeoutMs);
17
18
  try {
18
- const res = await fetch(url, {
19
+ const res = await compatFetch(url, {
19
20
  headers: { "User-Agent": "mslxdff/free-watcher", Accept: "application/json" },
20
21
  signal: ctrl.signal,
21
22
  });
package/src/groups.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { timingSafeEqual, createHash } from "node:crypto";
2
+ import { compatFetch } from "./compat.js";
2
3
  import { loadGroups, saveGroups, loadBans, saveBans } from "./state.js";
3
4
  import { normalizePeerUrl } from "./peers.js";
4
5
 
@@ -159,7 +160,7 @@ export function createGroupsService({ file } = {}) {
159
160
 
160
161
  // Re-register with the leader (join is idempotent) and return the fresh member list.
161
162
  // No key is needed once registered: the leader verifies our bearer token.
162
- export async function refreshGroupMembers(name, { leaderUrl, memberName, url, token, kind, fetchImpl = fetch } = {}) {
163
+ export async function refreshGroupMembers(name, { leaderUrl, memberName, url, token, kind, fetchImpl = compatFetch } = {}) {
163
164
  const controller = new AbortController();
164
165
  const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS);
165
166
  try {
package/src/models.js CHANGED
@@ -3,6 +3,7 @@ const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
3
3
  const DEFAULT_REFRESH_MS = 2 * 60 * 60 * 1000;
4
4
  import { mkdirSync, writeFileSync } from "node:fs";
5
5
  import { dirname } from "node:path";
6
+ import { compatFetch } from "./compat.js";
6
7
 
7
8
  export function isFreeModel(id) {
8
9
  return (typeof id === "string" && id.endsWith("-free")) ||
@@ -197,7 +198,7 @@ async function attemptFetch(url, headers, connectTimeoutMs) {
197
198
  const controller = new AbortController();
198
199
  const timer = setTimeout(() => controller.abort(), connectTimeoutMs);
199
200
  try {
200
- return await fetch(url, { headers, signal: controller.signal });
201
+ return await compatFetch(url, { headers, signal: controller.signal });
201
202
  } catch (err) {
202
203
  return err;
203
204
  } finally {
@@ -1,13 +1,8 @@
1
1
  import { createKeyRing } from "./keyring.js";
2
2
  import { joinModelId } from "./model-id.js";
3
+ import { getUndici as compatGetUndici, compatFetch } from "../compat.js";
3
4
 
4
- let UndiciAgent = null;
5
- let UndiciFetch = null;
6
- try {
7
- const mod = await import("undici");
8
- UndiciAgent = mod.Agent;
9
- UndiciFetch = mod.fetch;
10
- } catch {}
5
+ const { fetch: UndiciFetch, Agent: UndiciAgent } = compatGetUndici();
11
6
 
12
7
  export function envInt(name, fallback) {
13
8
  const v = Number(process.env[name]);
@@ -27,7 +22,7 @@ export function sleep(ms) {
27
22
  }
28
23
 
29
24
  export function getUndici() {
30
- return { UndiciAgent, UndiciFetch };
25
+ return { UndiciAgent, UndiciFetch, fetch: UndiciFetch || compatFetch, Agent: UndiciAgent };
31
26
  }
32
27
 
33
28
  export function createAgent({ keepAliveTimeout = 30_000, keepAliveMaxTimeout = 60_000, connections = 20 } = {}) {
@@ -1,4 +1,5 @@
1
1
  import { joinUrl, sleep } from "../base.js";
2
+ import { compatFetch } from "../../compat.js";
2
3
 
3
4
  /**
4
5
  * Cline Token 池:多账号 round-robin + refresh 换 accessToken + 冷却 + 队列
@@ -22,7 +23,7 @@ export function parseCooldown(body, status) {
22
23
  * 一次性 refresh:bench/诊断用,不落盘、不建池。
23
24
  * 返回 accessToken 或 null。
24
25
  */
25
- export async function refreshTokenForBase({ refreshToken, baseUrl = "https://api.cline.bot", fetchImpl = globalThis.fetch, dispatcher } = {}) {
26
+ export async function refreshTokenForBase({ refreshToken, baseUrl = "https://api.cline.bot", fetchImpl = compatFetch, dispatcher } = {}) {
26
27
  const rt = String(refreshToken || "").trim();
27
28
  if (!rt) return null;
28
29
  const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "") || "https://api.cline.bot";
@@ -1,6 +1,7 @@
1
1
  import { createKeyRing } from "../keyring.js";
2
2
  import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath, saveProviderConfig } from "../../state.js";
3
3
  import { envInt, joinUrl, getUndici, createAgent, collectApiKeysGeneric, createChatRunner } from "../base.js";
4
+ import { compatFetch } from "../../compat.js";
4
5
  import { joinModelId } from "../model-id.js";
5
6
  import { createAuthPool } from "./auth.js";
6
7
  import { clineHeaders, isRefreshToken } from "./headers.js";
@@ -34,7 +35,7 @@ export function createClineProvider({
34
35
  const resolvedModelsPath = modelsPath || (_cfgModels && _cfgModels !== "/models" ? _cfgModels : null) || "/ai/cline/recommended-models";
35
36
  const defaultChat = String(resolvedBase).includes("/api/v1") ? "/chat/completions" : "/api/v1/chat/completions";
36
37
  const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {}) || defaultChat;
37
- if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
38
+ if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
38
39
 
39
40
  const rawKeys = collectApiKeysGeneric(id, apiKeys, apiKey, (pid) => loadProviderKeys(pid, file ? { file } : {}));
40
41
  // 同时兼容 cline 与 clinebot 两个 id 的 keys(用户可能配在任一)
@@ -1,4 +1,5 @@
1
1
  import { joinUrl, getUndici } from "../base.js";
2
+ import { compatFetch } from "../../compat.js";
2
3
  import { joinModelId } from "../model-id.js";
3
4
 
4
5
  const { UndiciFetch } = getUndici();
@@ -8,7 +9,7 @@ function isClineBotHost(baseUrl) {
8
9
  }
9
10
 
10
11
  export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispatcher, ring, loadKeys } = {}) {
11
- if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
12
+ if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
12
13
  const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "");
13
14
  const resolvedPath = modelsPath || "/ai/cline/recommended-models";
14
15
  const CACHE_TTL = 10 * 60 * 1000;
@@ -1,6 +1,7 @@
1
1
  import { createKeyRing } from "./keyring.js";
2
2
  import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath } from "../state.js";
3
3
  import { envInt, joinUrl, getUndici, createAgent, collectApiKeysGeneric, createChatRunner, createListModelsRunner, createPreheatRunner } from "./base.js";
4
+ import { compatFetch } from "../compat.js";
4
5
 
5
6
  const { UndiciFetch } = getUndici();
6
7
 
@@ -36,7 +37,7 @@ export function createGenericProvider({
36
37
  if (!id) throw new Error("generic provider requires id");
37
38
  const resolvedBase = resolveBaseUrl(id, baseUrl);
38
39
  if (!resolvedBase) throw new Error(`generic provider ${id}: missing baseUrl`);
39
- if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
40
+ if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
40
41
  const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
41
42
  const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
42
43
  const ring = createKeyRing(collectApiKeysGeneric(id, apiKeys, apiKey, loadProviderKeys), { cooldownMs });
@@ -1,4 +1,5 @@
1
1
  import { joinUrl } from "../base.js";
2
+ import { compatFetch } from "../../compat.js";
2
3
  import { WORKBUDDY_DEFAULT_BASE_URL } from "../../state/schemas/provider.js";
3
4
 
4
5
  export function decodeJwtExp(token) {
@@ -45,15 +46,11 @@ export function createAuthService({
45
46
  } = {}) {
46
47
  const resolvedBase = baseUrl ? String(baseUrl).trim().replace(/\/+$/, "") : WORKBUDDY_DEFAULT_BASE_URL;
47
48
  if (!fetchImpl) {
48
- try {
49
- const { getUndici } = awaitImportUndici();
50
- fetchImpl = getUndici()?.UndiciFetch || fetch;
51
- } catch {
52
- fetchImpl = fetch;
53
- }
49
+ // 兼容层统一取(undici 优先,老 Node 兜底)
50
+ fetchImpl = compatFetch;
54
51
  }
55
52
  // lazy resolve UndiciFetch if not provided
56
- if (!fetchImpl) fetchImpl = fetch;
53
+ if (!fetchImpl) fetchImpl = compatFetch;
57
54
 
58
55
  const inflightRefresh = new Map();
59
56
 
@@ -1,3 +1,5 @@
1
+ import { compatFetch } from "../../compat.js";
2
+
1
3
  const BALANCE_TTL_MS = 5 * 60 * 1000;
2
4
 
3
5
  export function createBalanceCache({ ttlMs = BALANCE_TTL_MS, now = Date.now } = {}) {
@@ -24,7 +26,7 @@ export function createBalanceCache({ ttlMs = BALANCE_TTL_MS, now = Date.now } =
24
26
  const u = uid || auth?.uid || "";
25
27
  const d = domain || auth?.domain || "www.codebuddy.cn";
26
28
  if (!u || !at) return null;
27
- const fetcher = fetchImpl || fetch;
29
+ const fetcher = fetchImpl || compatFetch;
28
30
  const body = JSON.stringify({
29
31
  PageNumber: 1, PageSize: 100, ProductCode: "p_tcaca", Status: [0, 3],
30
32
  PackageEndTimeRangeBegin: "2026-08-01 00:00:00",
@@ -1,3 +1,4 @@
1
+ import { compatFetch, timeoutSignal } from "../../compat.js";
1
2
  // WorkBuddy 每日签到 core:纯逻辑 + fetch 注入,被 daemon scheduler 与 CLI 共用。
2
3
  // 接口:双域 POST /v2/billing/meter/daily-checkin,code 0=新签成功,10001=已签(幂等成功)。
3
4
 
@@ -21,7 +22,7 @@ function checkinHeaders({ at, uid, domain, enterpriseId }) {
21
22
  };
22
23
  }
23
24
 
24
- export async function checkinOne({ uid, at, domain, enterpriseId, fetchImpl = globalThis.fetch, timeoutMs = 15000 } = {}) {
25
+ export async function checkinOne({ uid, at, domain, enterpriseId, fetchImpl = compatFetch, timeoutMs = 15000 } = {}) {
25
26
  const headers = checkinHeaders({ at, uid, domain, enterpriseId });
26
27
  let last = null;
27
28
  for (const url of CHECKIN_ENDPOINTS) {
@@ -30,7 +31,7 @@ export async function checkinOne({ uid, at, domain, enterpriseId, fetchImpl = gl
30
31
  method: "POST",
31
32
  headers,
32
33
  body: "{}",
33
- signal: AbortSignal.timeout(timeoutMs),
34
+ signal: timeoutSignal(timeoutMs),
34
35
  });
35
36
  const text = await res.text();
36
37
  let j;
@@ -49,7 +50,7 @@ export async function checkinOne({ uid, at, domain, enterpriseId, fetchImpl = gl
49
50
  }
50
51
 
51
52
  // 多账号签到:逐个独立,失败不挡别人;返回按 uid 排序的 rows。
52
- export async function checkinAll({ accounts = [], fetchImpl = globalThis.fetch, concurrency = 3, onAccount } = {}) {
53
+ export async function checkinAll({ accounts = [], fetchImpl = compatFetch, concurrency = 3, onAccount } = {}) {
53
54
  const list = (Array.isArray(accounts) ? accounts : []).filter((a) => a && a.uid && a.at);
54
55
  const results = new Array(list.length);
55
56
  let idx = 0;
@@ -1,6 +1,7 @@
1
1
  import { createKeyRing } from "../keyring.js";
2
2
  import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../../state.js";
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { compatFetch } from "../../compat.js";
4
5
  import { join, dirname } from "node:path";
5
6
  import { tmpdir } from "node:os";
6
7
  import { envInt, joinUrl, getUndici, createAgent } from "../base.js";
@@ -54,7 +55,7 @@ export function createWorkbuddyProvider({
54
55
  const resolvedBase = resolveBaseUrl(baseUrl);
55
56
  const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
56
57
  const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
57
- if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
58
+ if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
58
59
 
59
60
  const keysFromState = loadProviderKeys(id, file ? { file } : {});
60
61
  const authsFromState = loadProviderAuths(id, file ? { file } : {});
@@ -1,3 +1,5 @@
1
+ import { compatFetch } from "../compat.js";
2
+
1
3
  const BALANCE_TTL_MS = 5 * 60 * 1000;
2
4
 
3
5
  const balanceCache = new Map(); // uid -> { total, dailyPacks, activeCount, nextExpire, fetchedAt }
@@ -26,7 +28,7 @@ export async function fetchBalance({ uid, key, auth, domain, baseUrl = "https://
26
28
  const u = uid || auth?.uid || "";
27
29
  const d = domain || auth?.domain || "www.codebuddy.cn";
28
30
  if (!u || !at) return null;
29
- const fetcher = fetchImpl || fetch;
31
+ const fetcher = fetchImpl || compatFetch;
30
32
  const body = JSON.stringify({
31
33
  PageNumber: 1, PageSize: 100, ProductCode: "p_tcaca", Status: [0, 3],
32
34
  PackageEndTimeRangeBegin: "2026-08-01 00:00:00",
@@ -15,12 +15,12 @@ export function nodeMajor() {
15
15
  return Number(String(process.versions?.node || "0").split(".")[0]) || 0;
16
16
  }
17
17
 
18
- // -chat 全链路依赖 global fetch(Node 18+),老 Node 放行只会崩得更难看,
19
- // 不如在入口给一句人话。返回 true=通过,false=已打印升级指引。
20
- export function assertChatNode({ min = 18 } = {}) {
18
+ // -chat 依赖 fetch(<18 src/compat.js 用 undici polyfill),低于 engines 下限
19
+ // 直接给一句人话。返回 true=通过,false=已打印升级指引。
20
+ export function assertChatNode({ min = 16 } = {}) {
21
21
  const major = nodeMajor();
22
22
  if (major >= min) return true;
23
- console.error(`Node 版本过旧(当前 v${process.versions.node}),-chat 需要 Node ${min}+(推荐 20+,见 package.json engines)。`);
23
+ console.error(`Node 版本过旧(当前 v${process.versions.node}),-chat 需要 Node ${min}+(推荐 20+)。`);
24
24
  console.error("先升级 Node 再重试:nvm install 20 && nvm use 20,或到 https://nodejs.org/ 下 LTS。");
25
25
  return false;
26
26
  }
@@ -5,6 +5,7 @@ import { getViaRoute } from "../../bench/via-routes.js";
5
5
  import { loadProviderKeys } from "../../state.js";
6
6
  import { SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
7
7
  import { errMsg } from "../helpers.js";
8
+ import { compatFetch } from "../../compat.js";
8
9
 
9
10
  function shortLabel(p) {
10
11
  const raw = String(p?.name || p?.id || p?.url || "").trim();
@@ -81,7 +82,7 @@ export async function handleViaRoute({
81
82
  // workbuddyUid 透传
82
83
  if (handlerCtx.workbuddyUid) headers["x-mslxdff-workbuddy-uid"] = handlerCtx.workbuddyUid;
83
84
  evt("via-route-request", { reqId: handlerCtx.reqId, peer: peer.url, model, hops: hops + 1, hasShare: Boolean(shareHeader) });
84
- upRes = await fetch(`${String(peer.url).replace(/\/+$/, "")}/v1/chat/completions`, {
85
+ upRes = await compatFetch(`${String(peer.url).replace(/\/+$/, "")}/v1/chat/completions`, {
85
86
  method: "POST",
86
87
  headers,
87
88
  body: JSON.stringify({ ...body, model }),
@@ -1,6 +1,7 @@
1
1
  import { clientIp, json, readBody, parseHops, errMsg } from "./helpers.js";
2
2
  import { DEFAULT_MAX_HOPS } from "../peers.js";
3
3
  import { enqueueRelay, dequeueRelayForPoll, resolveRelay, subscribeStream, unsubscribeStream } from "./relay-queue.js";
4
+ import { compatFetch } from "../compat.js";
4
5
 
5
6
  export async function heartbeatHandler({ req, res, groups, bus, logs }) {
6
7
  const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
@@ -162,7 +163,7 @@ export async function forwardHandler({ req, res, groups, bus, logs }) {
162
163
  if (!isBb) {
163
164
  try {
164
165
  const fwdBody = body.body || body;
165
- const r = await fetch(`${targetMember.url}/v1/chat/completions`, {
166
+ const r = await compatFetch(`${targetMember.url}/v1/chat/completions`, {
166
167
  method: "POST",
167
168
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${targetMember.token || ""}`, "x-mslxdff-hops": String(hops + 1), "x-mslxdff-model-lock": fwdBody.model || "", "Accept": "text/event-stream" },
168
169
  body: JSON.stringify(fwdBody),
@@ -3,6 +3,7 @@ import { isAutoModel } from "../auto.js";
3
3
  import { errMsg } from "./helpers.js";
4
4
  import { runHook } from "../plugins.js";
5
5
  import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
6
+ import { compatFetch, timeoutSignal } from "../compat.js";
6
7
 
7
8
  const PEER_TIMEOUT_MS = 30_000;
8
9
  const PEER_STATUS_TIMEOUT_MS = 2_000;
@@ -20,7 +21,7 @@ export function clearPeerHealthCache() {
20
21
  healthInflight.clear();
21
22
  }
22
23
 
23
- export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_MS, fetchImpl = fetch } = {}) {
24
+ export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_MS, fetchImpl = compatFetch } = {}) {
24
25
  const key = peer?.url || "";
25
26
  const ttl = peerHealthTtlMs();
26
27
  if (ttl > 0) {
@@ -36,7 +37,7 @@ export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_
36
37
  "Authorization": `Bearer ${peer.token || ""}`,
37
38
  "Accept": "application/json",
38
39
  },
39
- signal: AbortSignal.timeout(timeoutMs),
40
+ signal: timeoutSignal(timeoutMs),
40
41
  });
41
42
  if (!res.ok) return [];
42
43
  const j = await res.json().catch(() => ({}));
@@ -74,7 +75,7 @@ async function forwardToPeer(peer, body, model, hops) {
74
75
  // ADR-0008:该模型命中的供应商若开启 share → 附带瞬时 key 给组员借用(opencode 恒排除)
75
76
  const shareHeader = buildShareKeysHeader(model);
76
77
  if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
77
- return await fetch(`${peer.url}/v1/chat/completions`, {
78
+ return await compatFetch(`${peer.url}/v1/chat/completions`, {
78
79
  method: "POST",
79
80
  headers,
80
81
  body: JSON.stringify({ ...body, model }),
@@ -1,4 +1,5 @@
1
1
  import { loadGroupsJoined } from "../state.js";
2
+ import { compatFetch } from "../compat.js";
2
3
 
3
4
  const relayPending = new Map();
4
5
  const relayPendingByReqId = new Map();
@@ -118,7 +119,7 @@ export async function tryBroadbandRelay({ groups, token: myToken, model, body, h
118
119
  try {
119
120
  const controller = new AbortController();
120
121
  const timer = setTimeout(() => controller.abort(), 5000);
121
- const r = await fetch(`${g.leaderUrl}/v1/groups/join`, {
122
+ const r = await compatFetch(`${g.leaderUrl}/v1/groups/join`, {
122
123
  method: "POST",
123
124
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${myToken}` },
124
125
  body: JSON.stringify({ name: g.name, memberName: g.memberName, url: g.myUrl, token: myToken }),
@@ -154,7 +155,7 @@ export async function tryBroadbandRelay({ groups, token: myToken, model, body, h
154
155
  } else {
155
156
  const ctrl = new AbortController();
156
157
  const t = setTimeout(() => ctrl.abort(), 35_000);
157
- const r = await fetch(`${cand.leaderUrl}/v1/groups/relay/forward`, {
158
+ const r = await compatFetch(`${cand.leaderUrl}/v1/groups/relay/forward`, {
158
159
  method: "POST",
159
160
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${myToken}`, "x-mslxdff-hops": String(hops + 1) },
160
161
  body: JSON.stringify({ group: cand.group, target: cand.target, body: { ...body, model }, hops: hops + 1, reqId }),
@@ -1,4 +1,5 @@
1
1
  import { readBody, json, authorized } from "./helpers.js";
2
+ import { compatFetch } from "../compat.js";
2
3
 
3
4
  // POST /v1/relay 纯网络中继:A 把 targetUrl+headers+body 发给 B,B 原样 fetch 到上游再回给 A
4
5
  // B 侧不查本地 providerConfigs、不做 model 前缀路由、不验 allowlist,仅当 TCP 出口
@@ -42,7 +43,7 @@ export async function relayHandler({ req, res, token }) {
42
43
  const timer = setTimeout(() => controller.abort(new Error("relay timeout 30000ms")), 30000);
43
44
  try {
44
45
  const fetchBody = rawBody == null ? undefined : (typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody));
45
- const r = await fetch(targetUrl, { method, headers: fwdHeaders, body: fetchBody, signal: controller.signal });
46
+ const r = await compatFetch(targetUrl, { method, headers: fwdHeaders, body: fetchBody, signal: controller.signal });
46
47
  const txt = await r.text();
47
48
  // 原样回透:状态码 + 头(仅透 content-type) + body
48
49
  res.statusCode = r.status;
@@ -1,4 +1,5 @@
1
1
  import { errMsg } from "../cli/util.js";
2
+ import { compatFetch } from "../compat.js";
2
3
 
3
4
  /**
4
5
  * 宽带 SSE 长连 — 单组成员订阅 + 指数退避重连 + ensure 定时器。
@@ -17,7 +18,7 @@ export function startBroadbandStream({ token, upstream, execAndPost, broadbandGr
17
18
  const controller = new AbortController();
18
19
  abort = controller;
19
20
  try {
20
- const res = await fetch(url, {
21
+ const res = await compatFetch(url, {
21
22
  headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
22
23
  signal: controller.signal,
23
24
  });
@@ -1,6 +1,7 @@
1
1
  import { loadGroupsJoined } from "../state.js";
2
2
  import { errMsg } from "../cli/util.js";
3
3
  import { startBroadbandStream } from "./broadband-stream.js";
4
+ import { compatFetch, timeoutSignal } from "../compat.js";
4
5
 
5
6
  /**
6
7
  * 宽带中继 — poll 模式(heartbeat + poll)与 stream 模式分发。
@@ -39,22 +40,22 @@ export function startBroadband({ token, upstream }) {
39
40
  result = { status: 502, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ error: errMsg(err) }) };
40
41
  }
41
42
  try {
42
- await fetch(`${g.leaderUrl}/v1/groups/relay/result`, {
43
+ await compatFetch(`${g.leaderUrl}/v1/groups/relay/result`, {
43
44
  method: "POST",
44
45
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
45
46
  body: JSON.stringify({ name: g.name, group: g.name, reqId, result }),
46
- signal: AbortSignal.timeout(5000),
47
+ signal: timeoutSignal(5000),
47
48
  });
48
49
  } catch {}
49
50
  };
50
51
  const doHeartbeat = async () => {
51
52
  for (const g of broadbandGroups()) {
52
53
  try {
53
- const res = await fetch(`${g.leaderUrl}/v1/groups/relay/heartbeat`, {
54
+ const res = await compatFetch(`${g.leaderUrl}/v1/groups/relay/heartbeat`, {
54
55
  method: "POST",
55
56
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
56
57
  body: JSON.stringify({ name: g.name, group: g.name }),
57
- signal: AbortSignal.timeout(5000),
58
+ signal: timeoutSignal(5000),
58
59
  });
59
60
  if (!res.ok) {
60
61
  const txt = await res.text().catch(() => "");
@@ -68,11 +69,11 @@ export function startBroadband({ token, upstream }) {
68
69
  const doPoll = async () => {
69
70
  for (const g of broadbandGroups()) {
70
71
  try {
71
- const pollRes = await fetch(`${g.leaderUrl}/v1/groups/relay/poll`, {
72
+ const pollRes = await compatFetch(`${g.leaderUrl}/v1/groups/relay/poll`, {
72
73
  method: "POST",
73
74
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
74
75
  body: JSON.stringify({ name: g.name, group: g.name }),
75
- signal: AbortSignal.timeout(8000),
76
+ signal: timeoutSignal(8000),
76
77
  });
77
78
  if (!pollRes.ok) continue;
78
79
  const data = await pollRes.json().catch(() => ({}));
@@ -1,3 +1,4 @@
1
+ import { compatFetch } from "../compat.js";
1
2
  // daemon 内置 WorkBuddy 每日自动签到(A 方案):多账号全签 + 启动补签 + 每日定时。
2
3
  // 开关:MSLXDFF_WORKBUDDY_CHECKIN=0 关闭(默认开);时间:MSLXDFF_WORKBUDDY_CHECKIN_HOUR(默认 9 点本地时)。
3
4
  // 幂等:上游 code 10001(今天已签)视为成功;落盘 workbuddyCheckin {date} 防重复。
@@ -68,7 +69,7 @@ export async function setupWorkbuddyCheckin({ bus, logs } = {}) {
68
69
  }
69
70
  // 先给过期 token 续期(复用 chat 同款 refresh,落盘回 state)
70
71
  const authService = createAuthService({
71
- fetchImpl: globalThis.fetch,
72
+ fetchImpl: compatFetch,
72
73
  store: { keys, authList },
73
74
  });
74
75
  await Promise.all(authList.map(async (auth, i) => {
@@ -5,6 +5,7 @@
5
5
  * 用于 `—test-concurrency>1` 并行与单测快速路径。
6
6
  */
7
7
  import { mergeState, COLD_WINS } from "./merge.js";
8
+ import { clone } from "../compat.js";
8
9
 
9
10
  const memCache = new Map(); // file -> { data, dirty, timer, mtimeMs }
10
11
  const memDisk = new Map(); // file -> { obj, mtimeMs }
@@ -20,7 +21,7 @@ function getEntry(file) {
20
21
 
21
22
  function loadFromDiskMem(file) {
22
23
  const v = memDisk.get(file);
23
- return v ? structuredClone(v.obj) : {};
24
+ return v ? clone(v.obj) : {};
24
25
  }
25
26
  function getMtimeMem(file) {
26
27
  const v = memDisk.get(file);
@@ -31,7 +32,7 @@ function atomicWriteSyncMem(file, data) {
31
32
  // ensure monotonic mtime
32
33
  const prev = memDisk.get(file)?.mtimeMs || 0;
33
34
  const mtime = now <= prev ? prev + 1 : now;
34
- memDisk.set(file, { obj: structuredClone(data), mtimeMs: mtime });
35
+ memDisk.set(file, { obj: clone(data), mtimeMs: mtime });
35
36
  return mtime;
36
37
  }
37
38
 
@@ -49,7 +50,7 @@ function readState(file) {
49
50
  }
50
51
  }
51
52
  const disk = loadFromDiskMem(file);
52
- e.data = disk && typeof disk === "object" ? structuredClone(disk) : {};
53
+ e.data = disk && typeof disk === "object" ? clone(disk) : {};
53
54
  e.mtimeMs = getMtimeMem(file) || Date.now();
54
55
  e.dirty = false;
55
56
  return e.data;
@@ -108,14 +109,14 @@ export function createMemoryState(file = "mem://default") {
108
109
  return {
109
110
  loadModelErrors: () => {
110
111
  const v = readState(f).modelErrors;
111
- return v && typeof v === "object" && !Array.isArray(v) ? structuredClone(v) : {};
112
+ return v && typeof v === "object" && !Array.isArray(v) ? clone(v) : {};
112
113
  },
113
- saveModelErrors: (errors) => { writeStateDeferred(f, { modelErrors: structuredClone(errors) }); return errors; },
114
+ saveModelErrors: (errors) => { writeStateDeferred(f, { modelErrors: clone(errors) }); return errors; },
114
115
  loadModelLatencies: () => {
115
116
  const v = readState(f).modelLatencies;
116
- return v && typeof v === "object" && !Array.isArray(v) ? structuredClone(v) : {};
117
+ return v && typeof v === "object" && !Array.isArray(v) ? clone(v) : {};
117
118
  },
118
- saveModelLatencies: (v) => { writeStateDeferred(f, { modelLatencies: structuredClone(v) }); return v; },
119
+ saveModelLatencies: (v) => { writeStateDeferred(f, { modelLatencies: clone(v) }); return v; },
119
120
  loadProviderKeys: (id) => {
120
121
  const keys = readState(f).providerKeys;
121
122
  const v = keys && typeof keys === "object" ? keys[id] : undefined;
@@ -147,9 +148,9 @@ export function createMemoryState(file = "mem://default") {
147
148
  },
148
149
  loadPeers: () => {
149
150
  const v = readState(f).peers;
150
- return Array.isArray(v) ? structuredClone(v) : [];
151
+ return Array.isArray(v) ? clone(v) : [];
151
152
  },
152
- savePeers: (peers) => { writeStateImmediate(f, { peers: structuredClone(peers) }); return peers; },
153
+ savePeers: (peers) => { writeStateImmediate(f, { peers: clone(peers) }); return peers; },
153
154
  flushSync: () => flushStateSync(f),
154
155
  clear: () => clearStateCache(f),
155
156
  _memDisk: memDisk,
@@ -2,12 +2,9 @@ import { performance } from "node:perf_hooks";
2
2
  import { resolveRetry, sleep, backoffDelay } from "./retry.js";
3
3
  import { createSseParser } from "./sse.js";
4
4
  import { createPool } from "./pool.js";
5
+ import { compatFetch, getUndici } from "../compat.js";
5
6
 
6
- let UndiciFetch = null;
7
- try {
8
- const mod = await import("undici");
9
- UndiciFetch = mod.fetch;
10
- } catch {}
7
+ const UndiciFetch = getUndici().fetch;
11
8
 
12
9
  const DEFAULT_RETRY = {
13
10
  network: { attempts: 2, delayMs: 300 },
@@ -27,7 +24,7 @@ export function createTransport({
27
24
  retry: defaultRetry = DEFAULT_RETRY,
28
25
  hooks,
29
26
  } = {}) {
30
- if (!fetchImpl) fetchImpl = UndiciFetch || globalThis.fetch;
27
+ if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
31
28
  const pool = keepAlive && !extDispatcher ? createPool({ keepAlive }) : null;
32
29
  const getDispatcher = () => extDispatcher || pool?.dispatcher || null;
33
30
 
@@ -1,10 +1,6 @@
1
- let UndiciAgent = null;
2
- try {
3
- const mod = await import("undici");
4
- UndiciAgent = mod.Agent;
5
- } catch {
6
- UndiciAgent = null;
7
- }
1
+ import { getUndici } from "../compat.js";
2
+
3
+ const UndiciAgent = getUndici().Agent;
8
4
 
9
5
  function envInt(name, fallback) {
10
6
  const v = Number(process.env[name]);
package/src/upstream.js CHANGED
@@ -7,9 +7,10 @@ import { isFreeModel } from "./models.js";
7
7
  import { fmtShanghaiYMDHMS } from "./time.js";
8
8
  import { createTransport } from "./transport/index.js";
9
9
  import { isResponsesModel, chatToResponsesBody, toChatResponse } from "./upstream-responses.js";
10
+ import { uuid } from "./compat.js";
10
11
 
11
12
  function genId(prefix) {
12
- return `${prefix}${crypto.randomUUID().replace(/-/g, "")}`;
13
+ return `${prefix}${uuid().replace(/-/g, "")}`;
13
14
  }
14
15
  function envInt(name, fallback) {
15
16
  const v = Number(process.env[name]);