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