mslxdff 0.1.231 → 0.1.233
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 -3
- package/package.json +1 -1
- package/src/auto.js +264 -254
- package/src/bench/cline-bench.js +42 -42
- package/src/bench/probe.js +70 -70
- package/src/bench/report.js +162 -162
- package/src/bench/runner.js +77 -77
- package/src/bench/via-probe.js +124 -124
- package/src/bench/via-routes.js +87 -87
- package/src/bench/workbuddy-bench.js +54 -54
- package/src/chat/engine.js +160 -160
- package/src/chat/gateway.js +163 -163
- package/src/chat/orchestrator.js +234 -234
- package/src/chat/prompt.js +70 -70
- package/src/chat/repl.js +88 -88
- package/src/chat/terminal.js +135 -135
- package/src/chat/tools.js +306 -306
- package/src/chat-pipeline/index.js +155 -145
- package/src/chat-pipeline/policy.js +84 -84
- package/src/cli/commands/group.js +266 -266
- package/src/cli/commands/model/picks.js +50 -50
- package/src/cli/commands/provider/bench-via.js +247 -247
- package/src/cli/commands/provider/bench.js +141 -141
- package/src/cli/commands/provider/index.js +139 -139
- package/src/cli/commands/provider/models.js +149 -149
- package/src/cli/commands/provider/qwenwork-login.js +119 -119
- package/src/cli/commands/provider/raccoon-quota.js +156 -156
- package/src/cli/commands/sync.js +310 -310
- package/src/cli/status.js +279 -279
- package/src/daemon.js +246 -246
- package/src/model-capabilities/enrich.js +86 -86
- package/src/model-capabilities/index.js +183 -183
- package/src/model-capabilities/parse.js +70 -70
- package/src/model-trace.js +10 -2
- package/src/models.js +292 -225
- package/src/peers.js +345 -345
- package/src/providers/agentrouter.js +238 -238
- package/src/providers/base.js +164 -164
- package/src/providers/cline/auth.js +228 -228
- package/src/providers/cline/chat.js +307 -307
- package/src/providers/cline.js +2 -2
- package/src/providers/codearts/chat.js +207 -207
- package/src/providers/dispatcher.js +147 -147
- package/src/providers/globalqwenwork/cosy.js +123 -123
- package/src/providers/globalqwenwork/crypto.js +218 -218
- package/src/providers/globalqwenwork/http.js +20 -20
- package/src/providers/globalqwenwork/rsa.js +54 -54
- package/src/providers/globalqwenwork/sse.js +268 -268
- package/src/providers/globalqwenwork/stream.js +130 -130
- package/src/providers/globalqwenwork/upstream.js +122 -122
- package/src/providers/keyring.js +60 -60
- package/src/providers/qoder/account-store.js +18 -0
- package/src/providers/qoder/chat.js +292 -203
- package/src/providers/qoder/checkin.js +25 -12
- package/src/providers/qoder/index.js +311 -249
- package/src/providers/qoder/models.js +64 -2
- package/src/providers/qoder/oauth.js +42 -1
- package/src/providers/qoder/payload.js +116 -80
- package/src/providers/qoder/request.js +7 -2
- package/src/providers/qoder/session.js +26 -10
- package/src/providers/qoder/sse.js +233 -145
- package/src/providers/qoder/sticky.js +5 -2
- package/src/providers/qoder/wire-messages.js +242 -0
- package/src/providers/qwenwork/account-store.js +133 -133
- package/src/providers/qwenwork/constants.js +67 -67
- package/src/providers/qwenwork/cosy.js +120 -120
- package/src/providers/qwenwork/crypto.js +218 -218
- package/src/providers/qwenwork/http.js +20 -20
- package/src/providers/qwenwork/index.js +337 -337
- package/src/providers/qwenwork/payload.js +144 -144
- package/src/providers/qwenwork/rsa.js +54 -54
- package/src/providers/qwenwork/sse.js +268 -268
- package/src/providers/qwenwork/stream.js +130 -130
- package/src/providers/qwenwork/upstream.js +120 -120
- package/src/providers/qwenwork.js +1 -1
- package/src/providers/raccoon/chat.js +1 -1
- package/src/providers/raccoon/const.js +219 -202
- package/src/providers/raccoon/credits.js +285 -285
- package/src/providers/raccoon/index.js +15 -2
- package/src/providers/raccoon/models.js +68 -17
- package/src/providers/registry.js +89 -89
- package/src/providers/workbuddy/auth.js +16 -0
- package/src/providers/workbuddy/chat.js +298 -264
- package/src/providers/workbuddy/client-headers.js +148 -0
- package/src/providers/workbuddy/desktop-info.js +17 -1
- package/src/providers/workbuddy/models.js +8 -20
- package/src/providers/workbuddy/reshape.js +152 -152
- package/src/providers/workbuddy.js +2 -2
- package/src/reasoning.js +32 -32
- package/src/routes/chat/broadband-handler.js +80 -80
- package/src/routes/chat/gateway.js +46 -46
- package/src/routes/chat/relay-pipeline.js +282 -282
- package/src/routes/chat/via-route-handler.js +156 -156
- package/src/routes/hedge.js +260 -260
- package/src/routes/models-route.js +167 -167
- package/src/routes/peers.js +373 -373
- package/src/routes/stream.js +364 -347
- package/src/runtime/bootstrap.js +75 -75
- package/src/runtime/keep-warm.js +135 -135
- package/src/runtime/provider-gate.js +75 -75
- package/src/runtime/providers-setup.js +187 -187
- package/src/runtime/raccoon-checkin.js +175 -175
- package/src/server.js +85 -85
- package/src/state/schemas/allowlist.js +92 -92
- package/src/sync-opencode.js +280 -280
- package/src/transport/index.js +244 -244
- package/src/transport/pool.js +56 -56
- package/src/transport/retry.js +24 -24
- package/src/transport/sse.js +93 -93
- package/src/upstream-probe/display.js +52 -52
- package/src/upstream-probe/probe.js +49 -49
- package/src/upstream-probe/rotate.js +110 -110
- package/src/upstream-probe/start.js +45 -45
- package/src/upstream.js +355 -355
- package/docs/cli_help_mini.md +0 -148
- package/src/providers/AGENTS.md +0 -49
- package/src/routes/AGENTS.md +0 -37
package/src/routes/peers.js
CHANGED
|
@@ -1,373 +1,373 @@
|
|
|
1
|
-
import { performance } from "node:perf_hooks";
|
|
2
|
-
import { isAutoModel } from "../auto.js";
|
|
3
|
-
import { errMsg } from "./helpers.js";
|
|
4
|
-
import { runHook } from "../plugins.js";
|
|
5
|
-
import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
|
|
6
|
-
import { compatFetch, timeoutSignal, getUndici } from "../compat.js";
|
|
7
|
-
import { serialTryPeers, PEER_SERIAL } from "./peer-serial.js";
|
|
8
|
-
import { probePeer, peerProbeTimeoutMs } from "./peer-probe.js";
|
|
9
|
-
import { overForwardLimit, contextGuardMessage } from "../chat-pipeline/context-guard.js";
|
|
10
|
-
|
|
11
|
-
// peer 阶段总预算:三批候选(ordered / orderedByLastError / coolingByLastError)共享同一个
|
|
12
|
-
// 截止时间,而不是每批各吃满一次等头超时。实测最坏路径是三批串行各 30s(≈90s),
|
|
13
|
-
// 客户端表现为"随机转圈";预算到点即停,把余下时间还给本地兜底。
|
|
14
|
-
const DEFAULT_PEER_RACE_BUDGET_MS = 45_000;
|
|
15
|
-
const PEER_STATUS_TIMEOUT_MS = 2_000;
|
|
16
|
-
const DEFAULT_PEER_CONNECT_TIMEOUT_MS = 3_000;
|
|
17
|
-
// SSE 响应体数据间隔超时(undici bodyTimeout)。慢模型首块后可静默思考远超 30s,
|
|
18
|
-
// 沿用 30s 会把组内转发的中继活流掐成 "terminated"(实测首块后 ~31s 必断,本地直连
|
|
19
|
-
// 无此限制——组内因此比直连"不流畅")。与 provider 请求级超时同量级(注意:**不是**与应用层
|
|
20
|
-
// MAX_STREAM_MS 对齐——后者默认 0=关,见 src/routes/stream.js 的实测依据):
|
|
21
|
-
// undici 层只做最后兜底,掐流交给应用层的首块/总时长策略。
|
|
22
|
-
import { summarizeRequest } from "../model-trace.js";
|
|
23
|
-
const PEER_BODY_TIMEOUT_MS = 120_000;
|
|
24
|
-
export { PEER_BODY_TIMEOUT_MS };
|
|
25
|
-
|
|
26
|
-
// undici dispatcher 的「收到响应头」时限(headersTimeout)。**必须与 bodyTimeout 同档**:组员节点
|
|
27
|
-
// 自己也压着响应头等首块(ADR-0043 首块前不 flush),undici 层若是 30s,连接会在组建还没吐头时被
|
|
28
|
-
// 掐成 `fetch failed (UND_ERR_HEADERS_TIMEOUT)` —— 实测三个组员全部精确卡 ~30620ms abort,组网
|
|
29
|
-
// 兜底退化成「每次白等 30s」(BUG-1 的 dispatcher 层残留:当时只改了应用层 abort 定时器,漏了这行)。
|
|
30
|
-
// 提到 120s 不会让请求真的挂 120s:真正的闸门是应用层 peer 阶段总预算(peerStageCtx,缺省 45s),
|
|
31
|
-
// 到点即 abort;undici 只做最后兜底,与本文件 PEER_BODY_TIMEOUT_MS / forwardToPeer 的等待上限同口径。
|
|
32
|
-
const PEER_HEADERS_TIMEOUT_MS = PEER_BODY_TIMEOUT_MS;
|
|
33
|
-
export { PEER_HEADERS_TIMEOUT_MS };
|
|
34
|
-
|
|
35
|
-
function peerConnectTimeoutMs() {
|
|
36
|
-
const n = Number(process.env.MSLXDFF_PEER_CONNECT_TIMEOUT_MS);
|
|
37
|
-
return Number.isInteger(n) && n > 0 ? n : DEFAULT_PEER_CONNECT_TIMEOUT_MS;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// 单组员等待上限(串行模式下逐个体生效,MSLXDFF_PEER_ATTEMPT_TIMEOUT_MS,缺省 20s)。
|
|
41
|
-
// 为什么必须限:串行是「一个失败才试下一个」,若一个黑洞节点吃满 120s(bodyTimeout),
|
|
42
|
-
// 后面延迟更小的好组员根本轮不到——实测 172.93.221.187 常 38~44s 才回/被 abort。
|
|
43
|
-
// 20s 足以覆盖正常首包,又不会让一个坏节点吃掉整个阶段预算;可用 env 调。
|
|
44
|
-
function peerAttemptTimeoutMs() {
|
|
45
|
-
const n = Number(process.env.MSLXDFF_PEER_ATTEMPT_TIMEOUT_MS);
|
|
46
|
-
return Number.isInteger(n) && n > 0 ? n : 20_000;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// peer 阶段总预算(MSLXDFF_PEER_RACE_BUDGET_MS,0=不限):一次 peer 阶段(同批或若干批候选)
|
|
50
|
-
// 共享同一个截止时刻。调用方用 peerStageCtx() 起一个阶段,别把 deadline 挂在整个请求上下文上——
|
|
51
|
-
// 否则前一个候选把预算吃光,后一个候选一进 peer 阶段就直接放弃。
|
|
52
|
-
function peerRaceBudgetMs() {
|
|
53
|
-
const raw = process.env.MSLXDFF_PEER_RACE_BUDGET_MS;
|
|
54
|
-
if (raw === undefined || raw === null || raw === "") return DEFAULT_PEER_RACE_BUDGET_MS;
|
|
55
|
-
const s = String(raw).trim().toLowerCase();
|
|
56
|
-
if (s === "0" || s === "off" || s === "false" || s === "no") return 0;
|
|
57
|
-
const n = Number(s);
|
|
58
|
-
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_PEER_RACE_BUDGET_MS;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* 起一个 peer 阶段:返回带本阶段截止时刻的上下文副本。
|
|
63
|
-
* `triedUrls`(去重集合)必须**原样沿用源 ctx 的同一引用**:它在副本上新建会让
|
|
64
|
-
* "同一请求内不重复试同一 peer" 的契约失效——换下一个候选时集合是空的,死节点被反复竞速。
|
|
65
|
-
*/
|
|
66
|
-
export function peerStageCtx(ctx = {}) {
|
|
67
|
-
const budgetMs = peerRaceBudgetMs();
|
|
68
|
-
const tried = ctx.triedUrls ?? (ctx.triedUrls = new Set());
|
|
69
|
-
return { ...ctx, triedUrls: tried, peerRaceDeadline: budgetMs > 0 ? Date.now() + budgetMs : 0, peerRaceBudgetMs: budgetMs };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// 连接级超时(DNS + TCP/TLS 握手):黑洞节点(端口挂起)必须快速失败,
|
|
73
|
-
// 不能占用 30s 的响应超时(曾把一次请求拖到 92s)。仅 undici 可用时生效。
|
|
74
|
-
let peerDispatcher = null;
|
|
75
|
-
export function getPeerDispatcher() {
|
|
76
|
-
if (peerDispatcher) return peerDispatcher;
|
|
77
|
-
const { Agent } = getUndici();
|
|
78
|
-
if (!Agent) return null;
|
|
79
|
-
try {
|
|
80
|
-
peerDispatcher = new Agent({
|
|
81
|
-
connect: { timeout: peerConnectTimeoutMs() },
|
|
82
|
-
headersTimeout: PEER_HEADERS_TIMEOUT_MS,
|
|
83
|
-
bodyTimeout: PEER_BODY_TIMEOUT_MS,
|
|
84
|
-
keepAliveTimeout: 30_000,
|
|
85
|
-
keepAliveMaxTimeout: 60_000,
|
|
86
|
-
});
|
|
87
|
-
} catch { peerDispatcher = null; }
|
|
88
|
-
return peerDispatcher;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* 保热:用**组员转发同一条连接池**(getPeerDispatcher)打 GET /health,把 (本机→组员) 的连接
|
|
93
|
-
* 保持在温热、且**正是 chat 会复用的那条 socket**(裸 fetch 的全局池热的是另一条,等于白热)。
|
|
94
|
-
* /health 无鉴权、无副作用、零额度。绝不抛;返回 {ok}。
|
|
95
|
-
*/
|
|
96
|
-
export async function warmPeerHealth(url, { timeoutMs = 5000 } = {}) {
|
|
97
|
-
const base = String(url || "").replace(/\/+$/, "");
|
|
98
|
-
if (!base) return { ok: false, error: "no url" };
|
|
99
|
-
const dispatcher = getPeerDispatcher();
|
|
100
|
-
try {
|
|
101
|
-
const res = await compatFetch(`${base}/health`, {
|
|
102
|
-
headers: { Accept: "application/json" },
|
|
103
|
-
signal: timeoutSignal(timeoutMs),
|
|
104
|
-
...(dispatcher ? { dispatcher } : {}),
|
|
105
|
-
});
|
|
106
|
-
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
107
|
-
return { ok: Boolean(res?.ok), status: res?.status };
|
|
108
|
-
} catch (e) {
|
|
109
|
-
return { ok: false, error: errMsg(e) };
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// 跨 caller 中继必须剥离 reasoning 加密态:encrypted_content 由上游按 caller(出口)签发,
|
|
114
|
-
// 换个节点转发会被拒(400 "reasoning encrypted_content was not issued to this caller")。
|
|
115
|
-
// 明文 reasoning_content 保留(不绑定 caller,thinking 模式需要)。
|
|
116
|
-
export function stripCallerBoundReasoning(body) {
|
|
117
|
-
const messages = body?.messages;
|
|
118
|
-
if (!Array.isArray(messages)) return body;
|
|
119
|
-
let changed = false;
|
|
120
|
-
const out = messages.map((m) => {
|
|
121
|
-
if (!m || m.role !== "assistant") return m;
|
|
122
|
-
if (!Array.isArray(m.reasoning_items) || !m.reasoning_items.length) return m;
|
|
123
|
-
changed = true;
|
|
124
|
-
const copy = { ...m };
|
|
125
|
-
delete copy.reasoning_items;
|
|
126
|
-
return copy;
|
|
127
|
-
});
|
|
128
|
-
return changed ? { ...body, messages: out } : body;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// 健康探测与缓存已拆到 ./peer-health.js(本文件受 20KB 硬闸约束)。
|
|
132
|
-
// 这里 import 后再 re-export:既要给本文件内部函数用,也要保持既有 import 方无需改动。
|
|
133
|
-
import { clearPeerHealthCache, peerHealthyModels } from "./peer-health.js";
|
|
134
|
-
export { clearPeerHealthCache, peerHealthyModels };
|
|
135
|
-
|
|
136
|
-
// controller 由调用方持有:赛跑 winner 确定后可主动 abort 其余 in-flight,
|
|
137
|
-
// 避免迟到流挂满 bodyTimeout(并给对方省下无用负载)。
|
|
138
|
-
//
|
|
139
|
-
// ⚠ 这个 timer 名义上"仅覆盖等响应头",但组员节点自己也会**压着响应头等首块**
|
|
140
|
-
// (ADR-0043 空轮留口 / 首块前不 flush),于是"等头"实际就是"等对方首块"。
|
|
141
|
-
// 实测(mimo-v2.6-flash-free,2026-10-17):本地 9.7s 拿到头、首块 30~60s 才到;组员侧
|
|
142
|
-
// 全部恰好卡在 30001ms 被 abort —— 30s 只够本地拿头,组员永远等不到,组网兜底退化成
|
|
143
|
-
// "每次白等 30s"。故这里的等待上限与 bodyTimeout 同档(120s),真正的时间闸门交给
|
|
144
|
-
// 阶段总预算(见 startPeerStage / MSLXDFF_PEER_RACE_BUDGET_MS)。
|
|
145
|
-
const DEFAULT_PEER_HEADERS_TIMEOUT_MS = 120_000;
|
|
146
|
-
|
|
147
|
-
async function forwardToPeer(peer, body, model, hops, controller = new AbortController(), waitMs = DEFAULT_PEER_HEADERS_TIMEOUT_MS) {
|
|
148
|
-
// 转发前上下文预检(真正的出站闸门):所有经组员的转发都从这里出去,大上下文必须在这里拦下。
|
|
149
|
-
// 现网实证:errors.log 里 376 条 `context_length_exceeded`,274 条正是组员执行撞的 —— 白烧对方额度。
|
|
150
|
-
const guard = overForwardLimit(body);
|
|
151
|
-
if (guard) {
|
|
152
|
-
const err = new Error(contextGuardMessage(guard));
|
|
153
|
-
err.contextGuard = guard;
|
|
154
|
-
return err;
|
|
155
|
-
}
|
|
156
|
-
const timer = setTimeout(() => controller.abort(), Math.max(1, Math.round(waitMs))); try {
|
|
157
|
-
const headers = {
|
|
158
|
-
"Content-Type": "application/json",
|
|
159
|
-
"Authorization": `Bearer ${peer.token}`,
|
|
160
|
-
"x-mslxdff-hops": String(hops + 1),
|
|
161
|
-
"x-mslxdff-model-lock": model,
|
|
162
|
-
"Accept": "text/event-stream",
|
|
163
|
-
};
|
|
164
|
-
// ADR-0019:命中供应商的 key 随转发自动借出(组内互信,无开关;opencode/workbuddy/cline 硬排除)
|
|
165
|
-
const shareHeader = buildShareKeysHeader(model);
|
|
166
|
-
if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
|
|
167
|
-
const dispatcher = getPeerDispatcher();
|
|
168
|
-
return await compatFetch(`${peer.url}/v1/chat/completions`, {
|
|
169
|
-
method: "POST",
|
|
170
|
-
headers,
|
|
171
|
-
body: JSON.stringify({ ...stripCallerBoundReasoning(body), model }),
|
|
172
|
-
signal: controller.signal,
|
|
173
|
-
...(dispatcher ? { dispatcher } : {}),
|
|
174
|
-
});
|
|
175
|
-
} catch (err) {
|
|
176
|
-
return err;
|
|
177
|
-
} finally {
|
|
178
|
-
clearTimeout(timer);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async function resolvePeerTarget(ctx, peer) {
|
|
183
|
-
const prevModel = ctx.peers.stat(peer.url)?.model;
|
|
184
|
-
const hot = ctx.peers.isHot(peer.url) && prevModel === ctx.model;
|
|
185
|
-
if (hot) return { peer, target: prevModel };
|
|
186
|
-
const isExplicit = !!ctx.model && !isAutoModel(ctx.model);
|
|
187
|
-
if (isExplicit) {
|
|
188
|
-
const healthy = await peerHealthyModels(peer);
|
|
189
|
-
if (!healthy.length) {
|
|
190
|
-
ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0, strict: true });
|
|
191
|
-
return { peer, target: ctx.model };
|
|
192
|
-
}
|
|
193
|
-
ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length, strict: true });
|
|
194
|
-
return { peer, target: ctx.model };
|
|
195
|
-
}
|
|
196
|
-
const healthy = await peerHealthyModels(peer);
|
|
197
|
-
if (!healthy.length) {
|
|
198
|
-
await ctx.peers.recordError(peer.url);
|
|
199
|
-
ctx.logError(ctx.model, 0, `peer ${peer.url} has no healthy models`);
|
|
200
|
-
ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0 });
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length });
|
|
204
|
-
return { peer, target: healthy.includes(ctx.model) ? ctx.model : healthy[0] };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export const PEER_RACE_LIMIT = Number(process.env.MSLXDFF_PEER_RACE_LIMIT) > 0
|
|
208
|
-
? Number(process.env.MSLXDFF_PEER_RACE_LIMIT)
|
|
209
|
-
: 3;
|
|
210
|
-
|
|
211
|
-
// 串行记账队列:失败/迟到成功的记录不阻塞赢家返回,同时避免并发写盘互相覆盖。
|
|
212
|
-
let peerRecordChain = Promise.resolve();
|
|
213
|
-
function recordLater(fn) {
|
|
214
|
-
peerRecordChain = peerRecordChain.then(fn).catch(() => {});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// 节点显示名:事件里同时带上 name 与 url,多组员时不必再对着 state.json 认 IP
|
|
218
|
-
// (同一个 peer 对象在 prepare 后会被拷进 order,故按 url 回查)。
|
|
219
|
-
function peerName(peer) {
|
|
220
|
-
return String(peer?.name || "").trim() || null;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// 组员试法:**串行**(用户 2026-10-19 决定:不竞速)。实现在 ./peer-serial.js,
|
|
224
|
-
// 本文件只保留调度与并发版(PEER_SERIAL=false 时的旧路径)。
|
|
225
|
-
export { PEER_SERIAL };
|
|
226
|
-
// 测试接缝:转发闸门是「上下文超限不许出站」的唯一落点,必须有直接单测钉住
|
|
227
|
-
// (经 race/serial 间接测会被网络的真实失败盖掉,判不出是闸门还是对端挂了)。
|
|
228
|
-
export { forwardToPeer };
|
|
229
|
-
|
|
230
|
-
export async function racePeerCandidates(candidates, ctx) {
|
|
231
|
-
const tried = (ctx.triedUrls ??= new Set());
|
|
232
|
-
const fresh = candidates.filter((p) => !tried.has(p.url));
|
|
233
|
-
// 阶段总预算:由 ctx.peerRaceDeadline 提供(调用方经 peerStageCtx() 起阶段)。
|
|
234
|
-
// 没传的阶段(单批调用)不带预算——保持旧行为,不外溢到整条请求。
|
|
235
|
-
const budgetMs = ctx.peerRaceBudgetMs ?? 0;
|
|
236
|
-
const deadline = Number(ctx.peerRaceDeadline) || 0;
|
|
237
|
-
|
|
238
|
-
// 串行:按 candidates 的既有次序(调用方已按「延迟最小优先」排好)逐个试。
|
|
239
|
-
if (PEER_SERIAL) {
|
|
240
|
-
// 常驻热备(用户 2026-10-20):IPv4 链走空后进 IPv6 阶段。
|
|
241
|
-
// v6 候选 = 心跳自报 hasV6 的节点(组长优先、其余延迟升序),由 peers.standbyChain 产出。
|
|
242
|
-
// 兼容:ctx.peers 可能是测试注入的假对象,缺 standbyChain 时 v6 阶段自然为空。
|
|
243
|
-
let v6Candidates = [];
|
|
244
|
-
try {
|
|
245
|
-
v6Candidates = ctx.peers?.standbyChain?.().ipv6 ?? [];
|
|
246
|
-
} catch { v6Candidates = []; }
|
|
247
|
-
return serialTryPeers({
|
|
248
|
-
candidates: fresh,
|
|
249
|
-
v6Candidates,
|
|
250
|
-
ctx, tried,
|
|
251
|
-
deps: {
|
|
252
|
-
resolvePeerTarget, forwardToPeer, peerName, recordLater, summarizeRequest, runHook, errMsg,
|
|
253
|
-
perAttemptCap: peerAttemptTimeoutMs(), deadline, budgetMs,
|
|
254
|
-
// 连接预热:对「下一个备选」提前打 /health,复用 chat 的同一连接池(见 warmPeerHealth 注释)
|
|
255
|
-
warmPeer: (peer) => { if (peer?.url) warmPeerHealth(peer.url, { timeoutMs: 1500 }).catch(() => {}); },
|
|
256
|
-
// 用户 2026-10-20:备选时打一个 hi、轮到它时再打一个 hi,hi 回不来就换下一个。
|
|
257
|
-
// 走 getPeerDispatcher 的**同一条连接池**(与 chat 转发复用 socket,裸 fetch 热的是另一条等于白热)。
|
|
258
|
-
probePeer: (peer, model) => probePeer(peer, {
|
|
259
|
-
model: model || null,
|
|
260
|
-
timeoutMs: peerProbeTimeoutMs(),
|
|
261
|
-
dispatcher: getPeerDispatcher(),
|
|
262
|
-
}),
|
|
263
|
-
},
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
for (let i = 0; i < fresh.length; i += PEER_RACE_LIMIT) {
|
|
268
|
-
if (deadline > 0 && Date.now() >= deadline) {
|
|
269
|
-
ctx.evt("peer-budget-out", { peer: null, reason: `peer 阶段总预算 ${budgetMs}ms 用尽`, budgetMs, remaining: fresh.length - i });
|
|
270
|
-
break;
|
|
271
|
-
}
|
|
272
|
-
const batch = fresh.slice(i, i + PEER_RACE_LIMIT);
|
|
273
|
-
const prepared = (await Promise.all(batch.map((peer) => resolvePeerTarget(ctx, peer)))).filter(Boolean);
|
|
274
|
-
if (!prepared.length) continue;
|
|
275
|
-
const completed = await new Promise((resolve) => {
|
|
276
|
-
const order = [];
|
|
277
|
-
const total = prepared.length;
|
|
278
|
-
const ctrls = new Map(); // url -> controller(winner 后统一取消其余)
|
|
279
|
-
let settled = false;
|
|
280
|
-
const finish = (winner) => {
|
|
281
|
-
if (settled) return;
|
|
282
|
-
settled = true;
|
|
283
|
-
if (winner) {
|
|
284
|
-
for (const c of ctrls.values()) {
|
|
285
|
-
if (c === winner.ctrl) continue;
|
|
286
|
-
c.killedByUs = true;
|
|
287
|
-
c.abort();
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
resolve({ list: order, winner });
|
|
291
|
-
};
|
|
292
|
-
for (const { peer, target } of prepared) {
|
|
293
|
-
tried.add(peer.url);
|
|
294
|
-
const ctrl = new AbortController();
|
|
295
|
-
ctrls.set(peer.url, ctrl);
|
|
296
|
-
const name = peerName(peer);
|
|
297
|
-
ctx.evt("peer-request", { peer: peer.url, name, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body) });
|
|
298
|
-
// 插件 hook:peer:beforeForward — 转发给组员前观察
|
|
299
|
-
if (ctx.plugins?.length) {
|
|
300
|
-
runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
|
|
301
|
-
}
|
|
302
|
-
// 单次等待上限 = 阶段剩余预算(未超预算时用默认 120s);预算耗尽即 abort,不再空等
|
|
303
|
-
const remainingMs = deadline > 0 ? deadline - Date.now() : 0;
|
|
304
|
-
const waitMs = deadline > 0 ? Math.max(1, Math.min(DEFAULT_PEER_HEADERS_TIMEOUT_MS, remainingMs)) : DEFAULT_PEER_HEADERS_TIMEOUT_MS;
|
|
305
|
-
const t0 = performance.now();
|
|
306
|
-
forwardToPeer(peer, ctx.body, target, ctx.hops, ctrl, waitMs).then((res) => {
|
|
307
|
-
const latencyMs = Math.round(performance.now() - t0);
|
|
308
|
-
const failed = res instanceof Error || res.status >= 400;
|
|
309
|
-
// winner 已定后被我们主动取消:静默,不记账不报错(否则污染健康统计与错误日志)
|
|
310
|
-
if (res instanceof Error && ctrl.killedByUs) {
|
|
311
|
-
order.push({ ok: false, peer, target, res, status: 0, killed: true });
|
|
312
|
-
if (order.length === total) finish(null);
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
// 插件 hook:peer:result — 组员响应后观察
|
|
316
|
-
if (ctx.plugins?.length) {
|
|
317
|
-
runHook(ctx.plugins, "peer:result", { reqId: ctx.reqId, peer: peer.url, model: target, ok: !failed, status: failed ? (res instanceof Error ? 502 : res.status) : res.status, latencyMs }).catch(() => {});
|
|
318
|
-
}
|
|
319
|
-
if (failed) {
|
|
320
|
-
const status = res instanceof Error ? 502 : res.status;
|
|
321
|
-
const rawMsg = res instanceof Error ? errMsg(res) : null;
|
|
322
|
-
const msg = rawMsg ? rawMsg.slice(0, 200) : null;
|
|
323
|
-
const failRec = { peer: peer.url, status, message: rawMsg };
|
|
324
|
-
if (Array.isArray(ctx.peerErrors)) ctx.peerErrors.push(failRec);
|
|
325
|
-
// ⚠ 事件名与语义对齐:peer-forward 字面是"已转发成功",失败不再占用它,
|
|
326
|
-
// 否则成功率统计(按名字 grep)会把失败也算成转发成功(现网 229 条全量里混着大量失败)。
|
|
327
|
-
ctx.evt("peer-attempt", { peer: peer.url, name, model: target, hops: ctx.hops + 1, latencyMs, ok: false, status, message: msg });
|
|
328
|
-
// 中间失败只进 events(debug 流),不进 errors.log:这条请求最终可能由别处成功送达,
|
|
329
|
-
// 写进 errors.log 会把「已恢复的中间失败」算成错误率(实测 200 成功的请求仍在 errors.log 留 4 条 502)。
|
|
330
|
-
ctx.evt("peer-error", { peer: peer.url, name, model: target, status, message: msg });
|
|
331
|
-
order.push({ ok: false, peer, target, res, status });
|
|
332
|
-
recordLater(() => ctx.peers.recordError(peer.url, { status, message: msg }));
|
|
333
|
-
recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
|
|
334
|
-
if (order.length === total) finish(null);
|
|
335
|
-
} else {
|
|
336
|
-
ctx.evt("peer-forward", { peer: peer.url, name, model: target, hops: ctx.hops + 1, latencyMs, ok: true, status: res.status });
|
|
337
|
-
const entry = { ok: true, peer, target, res, latencyMs, ctrl };
|
|
338
|
-
order.push(entry);
|
|
339
|
-
if (!settled) {
|
|
340
|
-
// 第一个成功立即返回:不等慢/黑洞候选(迟到者的记账由各分支自理)
|
|
341
|
-
finish(entry);
|
|
342
|
-
} else {
|
|
343
|
-
recordLater(() => ctx.peers.recordResult(peer.url, { ok: true, latencyMs, model: target }));
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
});
|
|
349
|
-
const winner = completed.winner;
|
|
350
|
-
if (winner) {
|
|
351
|
-
return { peer: winner.peer, target: winner.target, res: winner.res, latencyMs: winner.latencyMs };
|
|
352
|
-
}
|
|
353
|
-
// 全失败:补读失败响应体(诊断 + 调用者详情)。仅在"本轮无 winner"时执行,
|
|
354
|
-
// 不拖慢成功路径;并行读、每 peer 上限 600ms,body 里才有 400/429 的真实原因。
|
|
355
|
-
await Promise.all(completed.list.map(async (o) => {
|
|
356
|
-
if (o.ok || !o.res || typeof o.res !== "object" || o.res instanceof Error) return;
|
|
357
|
-
try {
|
|
358
|
-
const snip = String(await Promise.race([
|
|
359
|
-
o.res.clone().text(),
|
|
360
|
-
new Promise((r) => { const t = setTimeout(() => r(""), 600); t.unref?.(); }),
|
|
361
|
-
])).replace(/\s+/g, " ").slice(0, 300);
|
|
362
|
-
if (!snip) return;
|
|
363
|
-
if (Array.isArray(ctx.peerErrors)) {
|
|
364
|
-
const rec = ctx.peerErrors.find((x) => x.peer === o.peer.url && x.status === o.status && !x.message);
|
|
365
|
-
if (rec) rec.message = snip;
|
|
366
|
-
}
|
|
367
|
-
// 同上:中继失败只进 events(debug 流),不写 errors.log —— 这条请求还可能由本地/下一批兜住
|
|
368
|
-
ctx.evt("peer-error", { peer: o.peer.url, name: peerName(o.peer), model: o.target, status: o.status, message: `body=${snip}` });
|
|
369
|
-
} catch {}
|
|
370
|
-
}));
|
|
371
|
-
}
|
|
372
|
-
return null;
|
|
373
|
-
}
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { isAutoModel } from "../auto.js";
|
|
3
|
+
import { errMsg } from "./helpers.js";
|
|
4
|
+
import { runHook } from "../plugins.js";
|
|
5
|
+
import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
|
|
6
|
+
import { compatFetch, timeoutSignal, getUndici } from "../compat.js";
|
|
7
|
+
import { serialTryPeers, PEER_SERIAL } from "./peer-serial.js";
|
|
8
|
+
import { probePeer, peerProbeTimeoutMs } from "./peer-probe.js";
|
|
9
|
+
import { overForwardLimit, contextGuardMessage } from "../chat-pipeline/context-guard.js";
|
|
10
|
+
|
|
11
|
+
// peer 阶段总预算:三批候选(ordered / orderedByLastError / coolingByLastError)共享同一个
|
|
12
|
+
// 截止时间,而不是每批各吃满一次等头超时。实测最坏路径是三批串行各 30s(≈90s),
|
|
13
|
+
// 客户端表现为"随机转圈";预算到点即停,把余下时间还给本地兜底。
|
|
14
|
+
const DEFAULT_PEER_RACE_BUDGET_MS = 45_000;
|
|
15
|
+
const PEER_STATUS_TIMEOUT_MS = 2_000;
|
|
16
|
+
const DEFAULT_PEER_CONNECT_TIMEOUT_MS = 3_000;
|
|
17
|
+
// SSE 响应体数据间隔超时(undici bodyTimeout)。慢模型首块后可静默思考远超 30s,
|
|
18
|
+
// 沿用 30s 会把组内转发的中继活流掐成 "terminated"(实测首块后 ~31s 必断,本地直连
|
|
19
|
+
// 无此限制——组内因此比直连"不流畅")。与 provider 请求级超时同量级(注意:**不是**与应用层
|
|
20
|
+
// MAX_STREAM_MS 对齐——后者默认 0=关,见 src/routes/stream.js 的实测依据):
|
|
21
|
+
// undici 层只做最后兜底,掐流交给应用层的首块/总时长策略。
|
|
22
|
+
import { summarizeRequest } from "../model-trace.js";
|
|
23
|
+
const PEER_BODY_TIMEOUT_MS = 120_000;
|
|
24
|
+
export { PEER_BODY_TIMEOUT_MS };
|
|
25
|
+
|
|
26
|
+
// undici dispatcher 的「收到响应头」时限(headersTimeout)。**必须与 bodyTimeout 同档**:组员节点
|
|
27
|
+
// 自己也压着响应头等首块(ADR-0043 首块前不 flush),undici 层若是 30s,连接会在组建还没吐头时被
|
|
28
|
+
// 掐成 `fetch failed (UND_ERR_HEADERS_TIMEOUT)` —— 实测三个组员全部精确卡 ~30620ms abort,组网
|
|
29
|
+
// 兜底退化成「每次白等 30s」(BUG-1 的 dispatcher 层残留:当时只改了应用层 abort 定时器,漏了这行)。
|
|
30
|
+
// 提到 120s 不会让请求真的挂 120s:真正的闸门是应用层 peer 阶段总预算(peerStageCtx,缺省 45s),
|
|
31
|
+
// 到点即 abort;undici 只做最后兜底,与本文件 PEER_BODY_TIMEOUT_MS / forwardToPeer 的等待上限同口径。
|
|
32
|
+
const PEER_HEADERS_TIMEOUT_MS = PEER_BODY_TIMEOUT_MS;
|
|
33
|
+
export { PEER_HEADERS_TIMEOUT_MS };
|
|
34
|
+
|
|
35
|
+
function peerConnectTimeoutMs() {
|
|
36
|
+
const n = Number(process.env.MSLXDFF_PEER_CONNECT_TIMEOUT_MS);
|
|
37
|
+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_PEER_CONNECT_TIMEOUT_MS;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 单组员等待上限(串行模式下逐个体生效,MSLXDFF_PEER_ATTEMPT_TIMEOUT_MS,缺省 20s)。
|
|
41
|
+
// 为什么必须限:串行是「一个失败才试下一个」,若一个黑洞节点吃满 120s(bodyTimeout),
|
|
42
|
+
// 后面延迟更小的好组员根本轮不到——实测 172.93.221.187 常 38~44s 才回/被 abort。
|
|
43
|
+
// 20s 足以覆盖正常首包,又不会让一个坏节点吃掉整个阶段预算;可用 env 调。
|
|
44
|
+
function peerAttemptTimeoutMs() {
|
|
45
|
+
const n = Number(process.env.MSLXDFF_PEER_ATTEMPT_TIMEOUT_MS);
|
|
46
|
+
return Number.isInteger(n) && n > 0 ? n : 20_000;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// peer 阶段总预算(MSLXDFF_PEER_RACE_BUDGET_MS,0=不限):一次 peer 阶段(同批或若干批候选)
|
|
50
|
+
// 共享同一个截止时刻。调用方用 peerStageCtx() 起一个阶段,别把 deadline 挂在整个请求上下文上——
|
|
51
|
+
// 否则前一个候选把预算吃光,后一个候选一进 peer 阶段就直接放弃。
|
|
52
|
+
function peerRaceBudgetMs() {
|
|
53
|
+
const raw = process.env.MSLXDFF_PEER_RACE_BUDGET_MS;
|
|
54
|
+
if (raw === undefined || raw === null || raw === "") return DEFAULT_PEER_RACE_BUDGET_MS;
|
|
55
|
+
const s = String(raw).trim().toLowerCase();
|
|
56
|
+
if (s === "0" || s === "off" || s === "false" || s === "no") return 0;
|
|
57
|
+
const n = Number(s);
|
|
58
|
+
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_PEER_RACE_BUDGET_MS;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 起一个 peer 阶段:返回带本阶段截止时刻的上下文副本。
|
|
63
|
+
* `triedUrls`(去重集合)必须**原样沿用源 ctx 的同一引用**:它在副本上新建会让
|
|
64
|
+
* "同一请求内不重复试同一 peer" 的契约失效——换下一个候选时集合是空的,死节点被反复竞速。
|
|
65
|
+
*/
|
|
66
|
+
export function peerStageCtx(ctx = {}) {
|
|
67
|
+
const budgetMs = peerRaceBudgetMs();
|
|
68
|
+
const tried = ctx.triedUrls ?? (ctx.triedUrls = new Set());
|
|
69
|
+
return { ...ctx, triedUrls: tried, peerRaceDeadline: budgetMs > 0 ? Date.now() + budgetMs : 0, peerRaceBudgetMs: budgetMs };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 连接级超时(DNS + TCP/TLS 握手):黑洞节点(端口挂起)必须快速失败,
|
|
73
|
+
// 不能占用 30s 的响应超时(曾把一次请求拖到 92s)。仅 undici 可用时生效。
|
|
74
|
+
let peerDispatcher = null;
|
|
75
|
+
export function getPeerDispatcher() {
|
|
76
|
+
if (peerDispatcher) return peerDispatcher;
|
|
77
|
+
const { Agent } = getUndici();
|
|
78
|
+
if (!Agent) return null;
|
|
79
|
+
try {
|
|
80
|
+
peerDispatcher = new Agent({
|
|
81
|
+
connect: { timeout: peerConnectTimeoutMs() },
|
|
82
|
+
headersTimeout: PEER_HEADERS_TIMEOUT_MS,
|
|
83
|
+
bodyTimeout: PEER_BODY_TIMEOUT_MS,
|
|
84
|
+
keepAliveTimeout: 30_000,
|
|
85
|
+
keepAliveMaxTimeout: 60_000,
|
|
86
|
+
});
|
|
87
|
+
} catch { peerDispatcher = null; }
|
|
88
|
+
return peerDispatcher;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 保热:用**组员转发同一条连接池**(getPeerDispatcher)打 GET /health,把 (本机→组员) 的连接
|
|
93
|
+
* 保持在温热、且**正是 chat 会复用的那条 socket**(裸 fetch 的全局池热的是另一条,等于白热)。
|
|
94
|
+
* /health 无鉴权、无副作用、零额度。绝不抛;返回 {ok}。
|
|
95
|
+
*/
|
|
96
|
+
export async function warmPeerHealth(url, { timeoutMs = 5000 } = {}) {
|
|
97
|
+
const base = String(url || "").replace(/\/+$/, "");
|
|
98
|
+
if (!base) return { ok: false, error: "no url" };
|
|
99
|
+
const dispatcher = getPeerDispatcher();
|
|
100
|
+
try {
|
|
101
|
+
const res = await compatFetch(`${base}/health`, {
|
|
102
|
+
headers: { Accept: "application/json" },
|
|
103
|
+
signal: timeoutSignal(timeoutMs),
|
|
104
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
105
|
+
});
|
|
106
|
+
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
107
|
+
return { ok: Boolean(res?.ok), status: res?.status };
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return { ok: false, error: errMsg(e) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 跨 caller 中继必须剥离 reasoning 加密态:encrypted_content 由上游按 caller(出口)签发,
|
|
114
|
+
// 换个节点转发会被拒(400 "reasoning encrypted_content was not issued to this caller")。
|
|
115
|
+
// 明文 reasoning_content 保留(不绑定 caller,thinking 模式需要)。
|
|
116
|
+
export function stripCallerBoundReasoning(body) {
|
|
117
|
+
const messages = body?.messages;
|
|
118
|
+
if (!Array.isArray(messages)) return body;
|
|
119
|
+
let changed = false;
|
|
120
|
+
const out = messages.map((m) => {
|
|
121
|
+
if (!m || m.role !== "assistant") return m;
|
|
122
|
+
if (!Array.isArray(m.reasoning_items) || !m.reasoning_items.length) return m;
|
|
123
|
+
changed = true;
|
|
124
|
+
const copy = { ...m };
|
|
125
|
+
delete copy.reasoning_items;
|
|
126
|
+
return copy;
|
|
127
|
+
});
|
|
128
|
+
return changed ? { ...body, messages: out } : body;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 健康探测与缓存已拆到 ./peer-health.js(本文件受 20KB 硬闸约束)。
|
|
132
|
+
// 这里 import 后再 re-export:既要给本文件内部函数用,也要保持既有 import 方无需改动。
|
|
133
|
+
import { clearPeerHealthCache, peerHealthyModels } from "./peer-health.js";
|
|
134
|
+
export { clearPeerHealthCache, peerHealthyModels };
|
|
135
|
+
|
|
136
|
+
// controller 由调用方持有:赛跑 winner 确定后可主动 abort 其余 in-flight,
|
|
137
|
+
// 避免迟到流挂满 bodyTimeout(并给对方省下无用负载)。
|
|
138
|
+
//
|
|
139
|
+
// ⚠ 这个 timer 名义上"仅覆盖等响应头",但组员节点自己也会**压着响应头等首块**
|
|
140
|
+
// (ADR-0043 空轮留口 / 首块前不 flush),于是"等头"实际就是"等对方首块"。
|
|
141
|
+
// 实测(mimo-v2.6-flash-free,2026-10-17):本地 9.7s 拿到头、首块 30~60s 才到;组员侧
|
|
142
|
+
// 全部恰好卡在 30001ms 被 abort —— 30s 只够本地拿头,组员永远等不到,组网兜底退化成
|
|
143
|
+
// "每次白等 30s"。故这里的等待上限与 bodyTimeout 同档(120s),真正的时间闸门交给
|
|
144
|
+
// 阶段总预算(见 startPeerStage / MSLXDFF_PEER_RACE_BUDGET_MS)。
|
|
145
|
+
const DEFAULT_PEER_HEADERS_TIMEOUT_MS = 120_000;
|
|
146
|
+
|
|
147
|
+
async function forwardToPeer(peer, body, model, hops, controller = new AbortController(), waitMs = DEFAULT_PEER_HEADERS_TIMEOUT_MS) {
|
|
148
|
+
// 转发前上下文预检(真正的出站闸门):所有经组员的转发都从这里出去,大上下文必须在这里拦下。
|
|
149
|
+
// 现网实证:errors.log 里 376 条 `context_length_exceeded`,274 条正是组员执行撞的 —— 白烧对方额度。
|
|
150
|
+
const guard = overForwardLimit(body);
|
|
151
|
+
if (guard) {
|
|
152
|
+
const err = new Error(contextGuardMessage(guard));
|
|
153
|
+
err.contextGuard = guard;
|
|
154
|
+
return err;
|
|
155
|
+
}
|
|
156
|
+
const timer = setTimeout(() => controller.abort(), Math.max(1, Math.round(waitMs))); try {
|
|
157
|
+
const headers = {
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
"Authorization": `Bearer ${peer.token}`,
|
|
160
|
+
"x-mslxdff-hops": String(hops + 1),
|
|
161
|
+
"x-mslxdff-model-lock": model,
|
|
162
|
+
"Accept": "text/event-stream",
|
|
163
|
+
};
|
|
164
|
+
// ADR-0019:命中供应商的 key 随转发自动借出(组内互信,无开关;opencode/workbuddy/cline 硬排除)
|
|
165
|
+
const shareHeader = buildShareKeysHeader(model);
|
|
166
|
+
if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
|
|
167
|
+
const dispatcher = getPeerDispatcher();
|
|
168
|
+
return await compatFetch(`${peer.url}/v1/chat/completions`, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers,
|
|
171
|
+
body: JSON.stringify({ ...stripCallerBoundReasoning(body), model }),
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
174
|
+
});
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return err;
|
|
177
|
+
} finally {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function resolvePeerTarget(ctx, peer) {
|
|
183
|
+
const prevModel = ctx.peers.stat(peer.url)?.model;
|
|
184
|
+
const hot = ctx.peers.isHot(peer.url) && prevModel === ctx.model;
|
|
185
|
+
if (hot) return { peer, target: prevModel };
|
|
186
|
+
const isExplicit = !!ctx.model && !isAutoModel(ctx.model);
|
|
187
|
+
if (isExplicit) {
|
|
188
|
+
const healthy = await peerHealthyModels(peer);
|
|
189
|
+
if (!healthy.length) {
|
|
190
|
+
ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0, strict: true });
|
|
191
|
+
return { peer, target: ctx.model };
|
|
192
|
+
}
|
|
193
|
+
ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length, strict: true });
|
|
194
|
+
return { peer, target: ctx.model };
|
|
195
|
+
}
|
|
196
|
+
const healthy = await peerHealthyModels(peer);
|
|
197
|
+
if (!healthy.length) {
|
|
198
|
+
await ctx.peers.recordError(peer.url);
|
|
199
|
+
ctx.logError(ctx.model, 0, `peer ${peer.url} has no healthy models`);
|
|
200
|
+
ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0 });
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length });
|
|
204
|
+
return { peer, target: healthy.includes(ctx.model) ? ctx.model : healthy[0] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const PEER_RACE_LIMIT = Number(process.env.MSLXDFF_PEER_RACE_LIMIT) > 0
|
|
208
|
+
? Number(process.env.MSLXDFF_PEER_RACE_LIMIT)
|
|
209
|
+
: 3;
|
|
210
|
+
|
|
211
|
+
// 串行记账队列:失败/迟到成功的记录不阻塞赢家返回,同时避免并发写盘互相覆盖。
|
|
212
|
+
let peerRecordChain = Promise.resolve();
|
|
213
|
+
function recordLater(fn) {
|
|
214
|
+
peerRecordChain = peerRecordChain.then(fn).catch(() => {});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 节点显示名:事件里同时带上 name 与 url,多组员时不必再对着 state.json 认 IP
|
|
218
|
+
// (同一个 peer 对象在 prepare 后会被拷进 order,故按 url 回查)。
|
|
219
|
+
function peerName(peer) {
|
|
220
|
+
return String(peer?.name || "").trim() || null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 组员试法:**串行**(用户 2026-10-19 决定:不竞速)。实现在 ./peer-serial.js,
|
|
224
|
+
// 本文件只保留调度与并发版(PEER_SERIAL=false 时的旧路径)。
|
|
225
|
+
export { PEER_SERIAL };
|
|
226
|
+
// 测试接缝:转发闸门是「上下文超限不许出站」的唯一落点,必须有直接单测钉住
|
|
227
|
+
// (经 race/serial 间接测会被网络的真实失败盖掉,判不出是闸门还是对端挂了)。
|
|
228
|
+
export { forwardToPeer };
|
|
229
|
+
|
|
230
|
+
export async function racePeerCandidates(candidates, ctx) {
|
|
231
|
+
const tried = (ctx.triedUrls ??= new Set());
|
|
232
|
+
const fresh = candidates.filter((p) => !tried.has(p.url));
|
|
233
|
+
// 阶段总预算:由 ctx.peerRaceDeadline 提供(调用方经 peerStageCtx() 起阶段)。
|
|
234
|
+
// 没传的阶段(单批调用)不带预算——保持旧行为,不外溢到整条请求。
|
|
235
|
+
const budgetMs = ctx.peerRaceBudgetMs ?? 0;
|
|
236
|
+
const deadline = Number(ctx.peerRaceDeadline) || 0;
|
|
237
|
+
|
|
238
|
+
// 串行:按 candidates 的既有次序(调用方已按「延迟最小优先」排好)逐个试。
|
|
239
|
+
if (PEER_SERIAL) {
|
|
240
|
+
// 常驻热备(用户 2026-10-20):IPv4 链走空后进 IPv6 阶段。
|
|
241
|
+
// v6 候选 = 心跳自报 hasV6 的节点(组长优先、其余延迟升序),由 peers.standbyChain 产出。
|
|
242
|
+
// 兼容:ctx.peers 可能是测试注入的假对象,缺 standbyChain 时 v6 阶段自然为空。
|
|
243
|
+
let v6Candidates = [];
|
|
244
|
+
try {
|
|
245
|
+
v6Candidates = ctx.peers?.standbyChain?.().ipv6 ?? [];
|
|
246
|
+
} catch { v6Candidates = []; }
|
|
247
|
+
return serialTryPeers({
|
|
248
|
+
candidates: fresh,
|
|
249
|
+
v6Candidates,
|
|
250
|
+
ctx, tried,
|
|
251
|
+
deps: {
|
|
252
|
+
resolvePeerTarget, forwardToPeer, peerName, recordLater, summarizeRequest, runHook, errMsg,
|
|
253
|
+
perAttemptCap: peerAttemptTimeoutMs(), deadline, budgetMs,
|
|
254
|
+
// 连接预热:对「下一个备选」提前打 /health,复用 chat 的同一连接池(见 warmPeerHealth 注释)
|
|
255
|
+
warmPeer: (peer) => { if (peer?.url) warmPeerHealth(peer.url, { timeoutMs: 1500 }).catch(() => {}); },
|
|
256
|
+
// 用户 2026-10-20:备选时打一个 hi、轮到它时再打一个 hi,hi 回不来就换下一个。
|
|
257
|
+
// 走 getPeerDispatcher 的**同一条连接池**(与 chat 转发复用 socket,裸 fetch 热的是另一条等于白热)。
|
|
258
|
+
probePeer: (peer, model) => probePeer(peer, {
|
|
259
|
+
model: model || null,
|
|
260
|
+
timeoutMs: peerProbeTimeoutMs(),
|
|
261
|
+
dispatcher: getPeerDispatcher(),
|
|
262
|
+
}),
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (let i = 0; i < fresh.length; i += PEER_RACE_LIMIT) {
|
|
268
|
+
if (deadline > 0 && Date.now() >= deadline) {
|
|
269
|
+
ctx.evt("peer-budget-out", { peer: null, reason: `peer 阶段总预算 ${budgetMs}ms 用尽`, budgetMs, remaining: fresh.length - i });
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
const batch = fresh.slice(i, i + PEER_RACE_LIMIT);
|
|
273
|
+
const prepared = (await Promise.all(batch.map((peer) => resolvePeerTarget(ctx, peer)))).filter(Boolean);
|
|
274
|
+
if (!prepared.length) continue;
|
|
275
|
+
const completed = await new Promise((resolve) => {
|
|
276
|
+
const order = [];
|
|
277
|
+
const total = prepared.length;
|
|
278
|
+
const ctrls = new Map(); // url -> controller(winner 后统一取消其余)
|
|
279
|
+
let settled = false;
|
|
280
|
+
const finish = (winner) => {
|
|
281
|
+
if (settled) return;
|
|
282
|
+
settled = true;
|
|
283
|
+
if (winner) {
|
|
284
|
+
for (const c of ctrls.values()) {
|
|
285
|
+
if (c === winner.ctrl) continue;
|
|
286
|
+
c.killedByUs = true;
|
|
287
|
+
c.abort();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
resolve({ list: order, winner });
|
|
291
|
+
};
|
|
292
|
+
for (const { peer, target } of prepared) {
|
|
293
|
+
tried.add(peer.url);
|
|
294
|
+
const ctrl = new AbortController();
|
|
295
|
+
ctrls.set(peer.url, ctrl);
|
|
296
|
+
const name = peerName(peer);
|
|
297
|
+
ctx.evt("peer-request", { peer: peer.url, name, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body) });
|
|
298
|
+
// 插件 hook:peer:beforeForward — 转发给组员前观察
|
|
299
|
+
if (ctx.plugins?.length) {
|
|
300
|
+
runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
|
|
301
|
+
}
|
|
302
|
+
// 单次等待上限 = 阶段剩余预算(未超预算时用默认 120s);预算耗尽即 abort,不再空等
|
|
303
|
+
const remainingMs = deadline > 0 ? deadline - Date.now() : 0;
|
|
304
|
+
const waitMs = deadline > 0 ? Math.max(1, Math.min(DEFAULT_PEER_HEADERS_TIMEOUT_MS, remainingMs)) : DEFAULT_PEER_HEADERS_TIMEOUT_MS;
|
|
305
|
+
const t0 = performance.now();
|
|
306
|
+
forwardToPeer(peer, ctx.body, target, ctx.hops, ctrl, waitMs).then((res) => {
|
|
307
|
+
const latencyMs = Math.round(performance.now() - t0);
|
|
308
|
+
const failed = res instanceof Error || res.status >= 400;
|
|
309
|
+
// winner 已定后被我们主动取消:静默,不记账不报错(否则污染健康统计与错误日志)
|
|
310
|
+
if (res instanceof Error && ctrl.killedByUs) {
|
|
311
|
+
order.push({ ok: false, peer, target, res, status: 0, killed: true });
|
|
312
|
+
if (order.length === total) finish(null);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// 插件 hook:peer:result — 组员响应后观察
|
|
316
|
+
if (ctx.plugins?.length) {
|
|
317
|
+
runHook(ctx.plugins, "peer:result", { reqId: ctx.reqId, peer: peer.url, model: target, ok: !failed, status: failed ? (res instanceof Error ? 502 : res.status) : res.status, latencyMs }).catch(() => {});
|
|
318
|
+
}
|
|
319
|
+
if (failed) {
|
|
320
|
+
const status = res instanceof Error ? 502 : res.status;
|
|
321
|
+
const rawMsg = res instanceof Error ? errMsg(res) : null;
|
|
322
|
+
const msg = rawMsg ? rawMsg.slice(0, 200) : null;
|
|
323
|
+
const failRec = { peer: peer.url, status, message: rawMsg };
|
|
324
|
+
if (Array.isArray(ctx.peerErrors)) ctx.peerErrors.push(failRec);
|
|
325
|
+
// ⚠ 事件名与语义对齐:peer-forward 字面是"已转发成功",失败不再占用它,
|
|
326
|
+
// 否则成功率统计(按名字 grep)会把失败也算成转发成功(现网 229 条全量里混着大量失败)。
|
|
327
|
+
ctx.evt("peer-attempt", { peer: peer.url, name, model: target, hops: ctx.hops + 1, latencyMs, ok: false, status, message: msg });
|
|
328
|
+
// 中间失败只进 events(debug 流),不进 errors.log:这条请求最终可能由别处成功送达,
|
|
329
|
+
// 写进 errors.log 会把「已恢复的中间失败」算成错误率(实测 200 成功的请求仍在 errors.log 留 4 条 502)。
|
|
330
|
+
ctx.evt("peer-error", { peer: peer.url, name, model: target, status, message: msg });
|
|
331
|
+
order.push({ ok: false, peer, target, res, status });
|
|
332
|
+
recordLater(() => ctx.peers.recordError(peer.url, { status, message: msg }));
|
|
333
|
+
recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
|
|
334
|
+
if (order.length === total) finish(null);
|
|
335
|
+
} else {
|
|
336
|
+
ctx.evt("peer-forward", { peer: peer.url, name, model: target, hops: ctx.hops + 1, latencyMs, ok: true, status: res.status });
|
|
337
|
+
const entry = { ok: true, peer, target, res, latencyMs, ctrl };
|
|
338
|
+
order.push(entry);
|
|
339
|
+
if (!settled) {
|
|
340
|
+
// 第一个成功立即返回:不等慢/黑洞候选(迟到者的记账由各分支自理)
|
|
341
|
+
finish(entry);
|
|
342
|
+
} else {
|
|
343
|
+
recordLater(() => ctx.peers.recordResult(peer.url, { ok: true, latencyMs, model: target }));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
const winner = completed.winner;
|
|
350
|
+
if (winner) {
|
|
351
|
+
return { peer: winner.peer, target: winner.target, res: winner.res, latencyMs: winner.latencyMs };
|
|
352
|
+
}
|
|
353
|
+
// 全失败:补读失败响应体(诊断 + 调用者详情)。仅在"本轮无 winner"时执行,
|
|
354
|
+
// 不拖慢成功路径;并行读、每 peer 上限 600ms,body 里才有 400/429 的真实原因。
|
|
355
|
+
await Promise.all(completed.list.map(async (o) => {
|
|
356
|
+
if (o.ok || !o.res || typeof o.res !== "object" || o.res instanceof Error) return;
|
|
357
|
+
try {
|
|
358
|
+
const snip = String(await Promise.race([
|
|
359
|
+
o.res.clone().text(),
|
|
360
|
+
new Promise((r) => { const t = setTimeout(() => r(""), 600); t.unref?.(); }),
|
|
361
|
+
])).replace(/\s+/g, " ").slice(0, 300);
|
|
362
|
+
if (!snip) return;
|
|
363
|
+
if (Array.isArray(ctx.peerErrors)) {
|
|
364
|
+
const rec = ctx.peerErrors.find((x) => x.peer === o.peer.url && x.status === o.status && !x.message);
|
|
365
|
+
if (rec) rec.message = snip;
|
|
366
|
+
}
|
|
367
|
+
// 同上:中继失败只进 events(debug 流),不写 errors.log —— 这条请求还可能由本地/下一批兜住
|
|
368
|
+
ctx.evt("peer-error", { peer: o.peer.url, name: peerName(o.peer), model: o.target, status: o.status, message: `body=${snip}` });
|
|
369
|
+
} catch {}
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|