mslxdff 0.1.155 → 0.1.158
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 +4 -4
- package/docs/ARCHITECTURE.md +426 -0
- package/docs/FEATURE_TREE.md +164 -0
- package/docs/MOBILE.md +82 -0
- package/docs/adr/0001-reasoning-content-injection.md +14 -0
- package/docs/adr/0002-models-free-filter.md +12 -0
- package/docs/adr/0003-zero-state-no-auth.md +10 -0
- package/docs/adr/0004-bearer-token.md +18 -0
- package/docs/adr/0005-peer-mesh.md +53 -0
- package/docs/adr/0006-broadband-member.md +103 -0
- package/docs/adr/0007-multi-provider-prefix.md +25 -0
- package/docs/adr/0008-share-keys-to-peers.md +52 -0
- package/docs/adr/0009-chat-repl.md +37 -0
- package/docs/adr/0010-allowlist.md +36 -0
- package/docs/adr/0011-broadband-stream.md +30 -0
- package/docs/adr/0012-responses-endpoint-codex-sync.md +48 -0
- package/docs/adr/0013-node16-compat.md +41 -0
- package/docs/adr/0014-deepseek-provider.md +52 -0
- package/docs/adr/0015-upstream-probe-routing.md +50 -0
- package/docs/adr/0016-model-capabilities.md +27 -0
- package/docs/adr/0017-ai-sdk-upstream-engine.md +59 -0
- package/docs/adr/0018-zen-client-identity.md +52 -0
- package/docs/adr/0019-share-keys-always-lend.md +59 -0
- package/docs/adr/0020-zen-free-lane-agent-shape.md +52 -0
- package/docs/adr/0021-usage-report-jsonl.md +47 -0
- package/docs/adr/0022-models-capability-merge.md +61 -0
- package/docs/adr/0023-key-provider-default-direct.md +58 -0
- package/docs/adr/0024-node18-baseline.md +63 -0
- package/docs/adr/0025-workbuddy-authdir-follows-state.md +71 -0
- package/docs/adr/0026-cline-provider-id-unify.md +67 -0
- package/docs/adr/0027-codearts-provider.md +48 -0
- package/docs/adr/0028-traework-provider.md +34 -0
- package/docs/adr/0029-qoder-native-provider.md +60 -0
- package/docs/adr/0030-models-list-scoped-by-picks.md +53 -0
- package/docs/adr/0031-qoder-true-streaming.md +50 -0
- package/docs/adr/0032-generic-responses-channel.md +72 -0
- package/docs/adr/0033-cline-allowlist-auto-sync.md +75 -0
- package/docs/adr/0034-request-level-human-readable-observability.md +60 -0
- package/docs/adr/0035-sdk-channel-headers-timeout.md +49 -0
- package/docs/adr/0036-qoder-per-request-sticky-account.md +82 -0
- package/docs/adr/0037-qwenwork-independent-provider.md +82 -0
- package/docs/adr/0038-zcode-provider.md +140 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +30 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/cli_help.md +1391 -0
- package/docs/cli_help_mini.md +133 -0
- package/docs/plans/bench-via-latency-2026-09-01.md +215 -0
- package/docs/plugins.md +187 -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 +123 -123
- package/src/chat-pipeline/policy.js +76 -76
- package/src/chat-pipeline/serial-trial.js +210 -210
- package/src/cli/commands/group.js +249 -249
- package/src/cli/commands/model/list-providers.js +4 -3
- package/src/cli/commands/model/list-render.js +7 -9
- package/src/cli/commands/model/list-sort.js +52 -0
- package/src/cli/commands/model/list-tty.js +76 -0
- package/src/cli/commands/model/list.js +5 -62
- 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 +124 -116
- package/src/cli/commands/provider/models.js +139 -128
- package/src/cli/commands/provider/qwenwork-login.js +119 -0
- package/src/cli/commands/provider/zcode-login.js +77 -0
- package/src/cli/commands/provider/zcode-quota.js +55 -0
- package/src/cli/commands/sync.js +232 -232
- package/src/cli/provider-row.js +2 -2
- package/src/cli/status.js +279 -279
- package/src/daemon.js +96 -96
- 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 +1 -0
- package/src/models.js +225 -225
- package/src/providers/classify.js +1 -1
- 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/keyring.js +60 -56
- package/src/providers/qoder/chat.js +183 -100
- package/src/providers/qoder/index.js +230 -184
- package/src/providers/qoder/sse.js +103 -46
- package/src/providers/qoder/sticky.js +1 -0
- package/src/providers/qoder/stream.js +32 -9
- package/src/providers/qwenwork/account-store.js +133 -0
- package/src/providers/qwenwork/constants.js +67 -0
- package/src/providers/qwenwork/cosy.js +120 -0
- package/src/providers/qwenwork/crypto.js +218 -0
- package/src/providers/qwenwork/http.js +20 -0
- package/src/providers/qwenwork/index.js +327 -0
- package/src/providers/qwenwork/payload.js +142 -0
- package/src/providers/qwenwork/rsa.js +54 -0
- package/src/providers/qwenwork/sse.js +268 -0
- package/src/providers/qwenwork/stream.js +130 -0
- package/src/providers/qwenwork/upstream.js +120 -0
- package/src/providers/qwenwork.js +1 -0
- package/src/providers/registry.js +66 -56
- package/src/providers/share-keys.js +2 -2
- package/src/providers/workbuddy/chat.js +248 -248
- package/src/providers/workbuddy/reshape.js +152 -152
- package/src/providers/workbuddy.js +2 -2
- package/src/providers/zcode/account-store.js +129 -0
- package/src/providers/zcode/auth.js +28 -0
- package/src/providers/zcode/chat.js +171 -0
- package/src/providers/zcode/const.js +54 -0
- package/src/providers/zcode/headers.js +55 -0
- package/src/providers/zcode/index.js +124 -0
- package/src/providers/zcode/models.js +65 -0
- package/src/providers/zcode/oauth.js +120 -0
- package/src/providers/zcode/quota.js +176 -0
- package/src/providers/zcode/sse.js +179 -0
- package/src/reasoning.js +32 -32
- package/src/routes/chat/gateway.js +46 -46
- package/src/routes/chat/relay-pipeline.js +250 -246
- package/src/routes/chat/via-route-handler.js +144 -144
- package/src/routes/hedge.js +255 -255
- package/src/routes/models-route.js +167 -167
- package/src/routes/peers.js +273 -273
- package/src/routes/stream.js +438 -399
- package/src/runtime/bootstrap.js +45 -45
- package/src/runtime/provider-gate.js +33 -30
- package/src/runtime/providers-setup.js +165 -165
- package/src/server.js +64 -64
- 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 +289 -289
package/src/routes/peers.js
CHANGED
|
@@ -1,273 +1,273 @@
|
|
|
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
|
-
const PEER_STATUS_TIMEOUT_MS = 2_000;
|
|
10
|
-
const DEFAULT_PEER_CONNECT_TIMEOUT_MS = 3_000;
|
|
11
|
-
// SSE 响应体数据间隔超时(undici bodyTimeout)。慢模型首块后可静默思考远超 30s,
|
|
12
|
-
// 沿用 30s 会把组内转发的中继活流掐成 "terminated"(实测首块后 ~31s 必断,本地直连
|
|
13
|
-
// 无此限制——组内因此比直连"不流畅")。与应用层 MAX_STREAM_MS(120s) 对齐:
|
|
14
|
-
// undici 层只做最后兜底,掐流交给应用层的首块/总时长策略。
|
|
15
|
-
import { summarizeRequest } from "../model-trace.js";
|
|
16
|
-
const PEER_BODY_TIMEOUT_MS = 120_000;
|
|
17
|
-
export { PEER_BODY_TIMEOUT_MS };
|
|
18
|
-
|
|
19
|
-
function peerConnectTimeoutMs() {
|
|
20
|
-
const n = Number(process.env.MSLXDFF_PEER_CONNECT_TIMEOUT_MS);
|
|
21
|
-
return Number.isInteger(n) && n > 0 ? n : DEFAULT_PEER_CONNECT_TIMEOUT_MS;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// 连接级超时(DNS + TCP/TLS 握手):黑洞节点(端口挂起)必须快速失败,
|
|
25
|
-
// 不能占用 30s 的响应超时(曾把一次请求拖到 92s)。仅 undici 可用时生效。
|
|
26
|
-
let peerDispatcher = null;
|
|
27
|
-
function getPeerDispatcher() {
|
|
28
|
-
if (peerDispatcher) return peerDispatcher;
|
|
29
|
-
const { Agent } = getUndici();
|
|
30
|
-
if (!Agent) return null;
|
|
31
|
-
try {
|
|
32
|
-
peerDispatcher = new Agent({
|
|
33
|
-
connect: { timeout: peerConnectTimeoutMs() },
|
|
34
|
-
headersTimeout: PEER_TIMEOUT_MS,
|
|
35
|
-
bodyTimeout: PEER_BODY_TIMEOUT_MS,
|
|
36
|
-
keepAliveTimeout: 30_000,
|
|
37
|
-
keepAliveMaxTimeout: 60_000,
|
|
38
|
-
});
|
|
39
|
-
} catch { peerDispatcher = null; }
|
|
40
|
-
return peerDispatcher;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// 跨 caller 中继必须剥离 reasoning 加密态:encrypted_content 由上游按 caller(出口)签发,
|
|
44
|
-
// 换个节点转发会被拒(400 "reasoning encrypted_content was not issued to this caller")。
|
|
45
|
-
// 明文 reasoning_content 保留(不绑定 caller,thinking 模式需要)。
|
|
46
|
-
export function stripCallerBoundReasoning(body) {
|
|
47
|
-
const messages = body?.messages;
|
|
48
|
-
if (!Array.isArray(messages)) return body;
|
|
49
|
-
let changed = false;
|
|
50
|
-
const out = messages.map((m) => {
|
|
51
|
-
if (!m || m.role !== "assistant") return m;
|
|
52
|
-
if (!Array.isArray(m.reasoning_items) || !m.reasoning_items.length) return m;
|
|
53
|
-
changed = true;
|
|
54
|
-
const copy = { ...m };
|
|
55
|
-
delete copy.reasoning_items;
|
|
56
|
-
return copy;
|
|
57
|
-
});
|
|
58
|
-
return changed ? { ...body, messages: out } : body;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function peerHealthTtlMs() {
|
|
62
|
-
const n = Number(process.env.MSLXDFF_PEER_HEALTH_TTL_MS);
|
|
63
|
-
return Number.isInteger(n) && n >= 0 ? n : 30_000;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const healthCache = new Map(); // url -> { at, data }
|
|
67
|
-
const healthInflight = new Map(); // url -> Promise
|
|
68
|
-
|
|
69
|
-
export function clearPeerHealthCache() {
|
|
70
|
-
healthCache.clear();
|
|
71
|
-
healthInflight.clear();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_MS, fetchImpl = compatFetch } = {}) {
|
|
75
|
-
const key = peer?.url || "";
|
|
76
|
-
const ttl = peerHealthTtlMs();
|
|
77
|
-
if (ttl > 0) {
|
|
78
|
-
const hit = healthCache.get(key);
|
|
79
|
-
if (hit && Date.now() - hit.at < ttl) return hit.data;
|
|
80
|
-
const inflight = healthInflight.get(key);
|
|
81
|
-
if (inflight) return inflight;
|
|
82
|
-
}
|
|
83
|
-
const p = (async () => {
|
|
84
|
-
try {
|
|
85
|
-
const res = await fetchImpl(`${peer.url}/v1/models/status`, {
|
|
86
|
-
headers: {
|
|
87
|
-
"Authorization": `Bearer ${peer.token || ""}`,
|
|
88
|
-
"Accept": "application/json",
|
|
89
|
-
},
|
|
90
|
-
signal: timeoutSignal(timeoutMs),
|
|
91
|
-
});
|
|
92
|
-
if (!res.ok) return [];
|
|
93
|
-
const j = await res.json().catch(() => ({}));
|
|
94
|
-
return (j.data || [])
|
|
95
|
-
.filter((m) => m && typeof m.id === "string" && m.status === "normal")
|
|
96
|
-
.map((m) => m.id);
|
|
97
|
-
} catch {
|
|
98
|
-
return [];
|
|
99
|
-
}
|
|
100
|
-
})();
|
|
101
|
-
if (ttl > 0) {
|
|
102
|
-
healthInflight.set(key, p);
|
|
103
|
-
try {
|
|
104
|
-
const data = await p;
|
|
105
|
-
healthCache.set(key, { at: Date.now(), data });
|
|
106
|
-
return data;
|
|
107
|
-
} finally {
|
|
108
|
-
healthInflight.delete(key);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return p;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// controller 由调用方持有:赛跑 winner 确定后可主动 abort 其余 in-flight,
|
|
115
|
-
// 避免迟到流挂满 bodyTimeout(并给对方省下无用负载)。30s timer 仅覆盖"等响应头"。
|
|
116
|
-
async function forwardToPeer(peer, body, model, hops, controller = new AbortController()) {
|
|
117
|
-
const timer = setTimeout(() => controller.abort(), PEER_TIMEOUT_MS);
|
|
118
|
-
try {
|
|
119
|
-
const headers = {
|
|
120
|
-
"Content-Type": "application/json",
|
|
121
|
-
"Authorization": `Bearer ${peer.token}`,
|
|
122
|
-
"x-mslxdff-hops": String(hops + 1),
|
|
123
|
-
"x-mslxdff-model-lock": model,
|
|
124
|
-
"Accept": "text/event-stream",
|
|
125
|
-
};
|
|
126
|
-
// ADR-0019:命中供应商的 key 随转发自动借出(组内互信,无开关;opencode/workbuddy/cline 硬排除)
|
|
127
|
-
const shareHeader = buildShareKeysHeader(model);
|
|
128
|
-
if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
|
|
129
|
-
const dispatcher = getPeerDispatcher();
|
|
130
|
-
return await compatFetch(`${peer.url}/v1/chat/completions`, {
|
|
131
|
-
method: "POST",
|
|
132
|
-
headers,
|
|
133
|
-
body: JSON.stringify({ ...stripCallerBoundReasoning(body), model }),
|
|
134
|
-
signal: controller.signal,
|
|
135
|
-
...(dispatcher ? { dispatcher } : {}),
|
|
136
|
-
});
|
|
137
|
-
} catch (err) {
|
|
138
|
-
return err;
|
|
139
|
-
} finally {
|
|
140
|
-
clearTimeout(timer);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function resolvePeerTarget(ctx, peer) {
|
|
145
|
-
const prevModel = ctx.peers.stat(peer.url)?.model;
|
|
146
|
-
const hot = ctx.peers.isHot(peer.url) && prevModel === ctx.model;
|
|
147
|
-
if (hot) return { peer, target: prevModel };
|
|
148
|
-
const isExplicit = !!ctx.model && !isAutoModel(ctx.model);
|
|
149
|
-
if (isExplicit) {
|
|
150
|
-
const healthy = await peerHealthyModels(peer);
|
|
151
|
-
if (!healthy.length) {
|
|
152
|
-
ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0, strict: true });
|
|
153
|
-
return { peer, target: ctx.model };
|
|
154
|
-
}
|
|
155
|
-
ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length, strict: true });
|
|
156
|
-
return { peer, target: ctx.model };
|
|
157
|
-
}
|
|
158
|
-
const healthy = await peerHealthyModels(peer);
|
|
159
|
-
if (!healthy.length) {
|
|
160
|
-
await ctx.peers.recordError(peer.url);
|
|
161
|
-
ctx.logError(ctx.model, 0, `peer ${peer.url} has no healthy models`);
|
|
162
|
-
ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0 });
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length });
|
|
166
|
-
return { peer, target: healthy.includes(ctx.model) ? ctx.model : healthy[0] };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export const PEER_RACE_LIMIT = Number(process.env.MSLXDFF_PEER_RACE_LIMIT) > 0
|
|
170
|
-
? Number(process.env.MSLXDFF_PEER_RACE_LIMIT)
|
|
171
|
-
: 3;
|
|
172
|
-
|
|
173
|
-
// 串行记账队列:失败/迟到成功的记录不阻塞赢家返回,同时避免并发写盘互相覆盖。
|
|
174
|
-
let peerRecordChain = Promise.resolve();
|
|
175
|
-
function recordLater(fn) {
|
|
176
|
-
peerRecordChain = peerRecordChain.then(fn).catch(() => {});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export async function racePeerCandidates(candidates, ctx) {
|
|
180
|
-
const tried = (ctx.triedUrls ??= new Set());
|
|
181
|
-
const fresh = candidates.filter((p) => !tried.has(p.url));
|
|
182
|
-
for (let i = 0; i < fresh.length; i += PEER_RACE_LIMIT) {
|
|
183
|
-
const batch = fresh.slice(i, i + PEER_RACE_LIMIT);
|
|
184
|
-
const prepared = (await Promise.all(batch.map((peer) => resolvePeerTarget(ctx, peer)))).filter(Boolean);
|
|
185
|
-
if (!prepared.length) continue;
|
|
186
|
-
const completed = await new Promise((resolve) => {
|
|
187
|
-
const order = [];
|
|
188
|
-
const total = prepared.length;
|
|
189
|
-
const ctrls = new Map(); // url -> controller(winner 后统一取消其余)
|
|
190
|
-
let settled = false;
|
|
191
|
-
const finish = (winner) => {
|
|
192
|
-
if (settled) return;
|
|
193
|
-
settled = true;
|
|
194
|
-
if (winner) {
|
|
195
|
-
for (const c of ctrls.values()) {
|
|
196
|
-
if (c === winner.ctrl) continue;
|
|
197
|
-
c.killedByUs = true;
|
|
198
|
-
c.abort();
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
resolve({ list: order, winner });
|
|
202
|
-
};
|
|
203
|
-
for (const { peer, target } of prepared) {
|
|
204
|
-
tried.add(peer.url);
|
|
205
|
-
const ctrl = new AbortController();
|
|
206
|
-
ctrls.set(peer.url, ctrl);
|
|
207
|
-
ctx.evt("peer-request", { peer: peer.url, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body) });
|
|
208
|
-
// 插件 hook:peer:beforeForward — 转发给组员前观察
|
|
209
|
-
if (ctx.plugins?.length) {
|
|
210
|
-
runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
|
|
211
|
-
}
|
|
212
|
-
const t0 = performance.now();
|
|
213
|
-
forwardToPeer(peer, ctx.body, target, ctx.hops, ctrl).then((res) => {
|
|
214
|
-
const latencyMs = Math.round(performance.now() - t0);
|
|
215
|
-
const failed = res instanceof Error || res.status >= 400;
|
|
216
|
-
// winner 已定后被我们主动取消:静默,不记账不报错(否则污染健康统计与错误日志)
|
|
217
|
-
if (res instanceof Error && ctrl.killedByUs) {
|
|
218
|
-
order.push({ ok: false, peer, target, res, status: 0, killed: true });
|
|
219
|
-
if (order.length === total) finish(null);
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
ctx.evt("peer-forward", { peer: peer.url, model: target, hops: ctx.hops + 1, latencyMs, ok: !failed });
|
|
223
|
-
// 插件 hook:peer:result — 组员响应后观察
|
|
224
|
-
if (ctx.plugins?.length) {
|
|
225
|
-
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(() => {});
|
|
226
|
-
}
|
|
227
|
-
if (failed) {
|
|
228
|
-
const status = res instanceof Error ? 502 : res.status;
|
|
229
|
-
const failRec = { peer: peer.url, status, message: res instanceof Error ? errMsg(res) : null };
|
|
230
|
-
if (Array.isArray(ctx.peerErrors)) ctx.peerErrors.push(failRec);
|
|
231
|
-
ctx.logError(ctx.model, status, res instanceof Error ? `peer ${peer.url} ${errMsg(res)}` : `peer ${peer.url} ${status}`);
|
|
232
|
-
ctx.evt("peer-error", { peer: peer.url, model: target, status, message: res instanceof Error ? errMsg(res) : null });
|
|
233
|
-
order.push({ ok: false, peer, target, res, status });
|
|
234
|
-
recordLater(() => ctx.peers.recordError(peer.url, { status }));
|
|
235
|
-
recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
|
|
236
|
-
if (order.length === total) finish(null);
|
|
237
|
-
} else {
|
|
238
|
-
const entry = { ok: true, peer, target, res, latencyMs, ctrl };
|
|
239
|
-
order.push(entry);
|
|
240
|
-
if (!settled) {
|
|
241
|
-
// 第一个成功立即返回:不等慢/黑洞候选(迟到者的记账由各分支自理)
|
|
242
|
-
finish(entry);
|
|
243
|
-
} else {
|
|
244
|
-
recordLater(() => ctx.peers.recordResult(peer.url, { ok: true, latencyMs, model: target }));
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
const winner = completed.winner;
|
|
251
|
-
if (winner) {
|
|
252
|
-
return { peer: winner.peer, target: winner.target, res: winner.res, latencyMs: winner.latencyMs };
|
|
253
|
-
}
|
|
254
|
-
// 全失败:补读失败响应体(诊断 + 调用者详情)。仅在"本轮无 winner"时执行,
|
|
255
|
-
// 不拖慢成功路径;并行读、每 peer 上限 600ms,body 里才有 400/429 的真实原因。
|
|
256
|
-
await Promise.all(completed.list.map(async (o) => {
|
|
257
|
-
if (o.ok || !o.res || typeof o.res !== "object" || o.res instanceof Error) return;
|
|
258
|
-
try {
|
|
259
|
-
const snip = String(await Promise.race([
|
|
260
|
-
o.res.clone().text(),
|
|
261
|
-
new Promise((r) => { const t = setTimeout(() => r(""), 600); t.unref?.(); }),
|
|
262
|
-
])).replace(/\s+/g, " ").slice(0, 300);
|
|
263
|
-
if (!snip) return;
|
|
264
|
-
if (Array.isArray(ctx.peerErrors)) {
|
|
265
|
-
const rec = ctx.peerErrors.find((x) => x.peer === o.peer.url && x.status === o.status && !x.message);
|
|
266
|
-
if (rec) rec.message = snip;
|
|
267
|
-
}
|
|
268
|
-
ctx.logError(ctx.model, o.status, `peer ${o.peer.url} ${o.status} body=${snip}`);
|
|
269
|
-
} catch {}
|
|
270
|
-
}));
|
|
271
|
-
}
|
|
272
|
-
return null;
|
|
273
|
-
}
|
|
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
|
+
const PEER_STATUS_TIMEOUT_MS = 2_000;
|
|
10
|
+
const DEFAULT_PEER_CONNECT_TIMEOUT_MS = 3_000;
|
|
11
|
+
// SSE 响应体数据间隔超时(undici bodyTimeout)。慢模型首块后可静默思考远超 30s,
|
|
12
|
+
// 沿用 30s 会把组内转发的中继活流掐成 "terminated"(实测首块后 ~31s 必断,本地直连
|
|
13
|
+
// 无此限制——组内因此比直连"不流畅")。与应用层 MAX_STREAM_MS(120s) 对齐:
|
|
14
|
+
// undici 层只做最后兜底,掐流交给应用层的首块/总时长策略。
|
|
15
|
+
import { summarizeRequest } from "../model-trace.js";
|
|
16
|
+
const PEER_BODY_TIMEOUT_MS = 120_000;
|
|
17
|
+
export { PEER_BODY_TIMEOUT_MS };
|
|
18
|
+
|
|
19
|
+
function peerConnectTimeoutMs() {
|
|
20
|
+
const n = Number(process.env.MSLXDFF_PEER_CONNECT_TIMEOUT_MS);
|
|
21
|
+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_PEER_CONNECT_TIMEOUT_MS;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 连接级超时(DNS + TCP/TLS 握手):黑洞节点(端口挂起)必须快速失败,
|
|
25
|
+
// 不能占用 30s 的响应超时(曾把一次请求拖到 92s)。仅 undici 可用时生效。
|
|
26
|
+
let peerDispatcher = null;
|
|
27
|
+
function getPeerDispatcher() {
|
|
28
|
+
if (peerDispatcher) return peerDispatcher;
|
|
29
|
+
const { Agent } = getUndici();
|
|
30
|
+
if (!Agent) return null;
|
|
31
|
+
try {
|
|
32
|
+
peerDispatcher = new Agent({
|
|
33
|
+
connect: { timeout: peerConnectTimeoutMs() },
|
|
34
|
+
headersTimeout: PEER_TIMEOUT_MS,
|
|
35
|
+
bodyTimeout: PEER_BODY_TIMEOUT_MS,
|
|
36
|
+
keepAliveTimeout: 30_000,
|
|
37
|
+
keepAliveMaxTimeout: 60_000,
|
|
38
|
+
});
|
|
39
|
+
} catch { peerDispatcher = null; }
|
|
40
|
+
return peerDispatcher;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 跨 caller 中继必须剥离 reasoning 加密态:encrypted_content 由上游按 caller(出口)签发,
|
|
44
|
+
// 换个节点转发会被拒(400 "reasoning encrypted_content was not issued to this caller")。
|
|
45
|
+
// 明文 reasoning_content 保留(不绑定 caller,thinking 模式需要)。
|
|
46
|
+
export function stripCallerBoundReasoning(body) {
|
|
47
|
+
const messages = body?.messages;
|
|
48
|
+
if (!Array.isArray(messages)) return body;
|
|
49
|
+
let changed = false;
|
|
50
|
+
const out = messages.map((m) => {
|
|
51
|
+
if (!m || m.role !== "assistant") return m;
|
|
52
|
+
if (!Array.isArray(m.reasoning_items) || !m.reasoning_items.length) return m;
|
|
53
|
+
changed = true;
|
|
54
|
+
const copy = { ...m };
|
|
55
|
+
delete copy.reasoning_items;
|
|
56
|
+
return copy;
|
|
57
|
+
});
|
|
58
|
+
return changed ? { ...body, messages: out } : body;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function peerHealthTtlMs() {
|
|
62
|
+
const n = Number(process.env.MSLXDFF_PEER_HEALTH_TTL_MS);
|
|
63
|
+
return Number.isInteger(n) && n >= 0 ? n : 30_000;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const healthCache = new Map(); // url -> { at, data }
|
|
67
|
+
const healthInflight = new Map(); // url -> Promise
|
|
68
|
+
|
|
69
|
+
export function clearPeerHealthCache() {
|
|
70
|
+
healthCache.clear();
|
|
71
|
+
healthInflight.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_MS, fetchImpl = compatFetch } = {}) {
|
|
75
|
+
const key = peer?.url || "";
|
|
76
|
+
const ttl = peerHealthTtlMs();
|
|
77
|
+
if (ttl > 0) {
|
|
78
|
+
const hit = healthCache.get(key);
|
|
79
|
+
if (hit && Date.now() - hit.at < ttl) return hit.data;
|
|
80
|
+
const inflight = healthInflight.get(key);
|
|
81
|
+
if (inflight) return inflight;
|
|
82
|
+
}
|
|
83
|
+
const p = (async () => {
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetchImpl(`${peer.url}/v1/models/status`, {
|
|
86
|
+
headers: {
|
|
87
|
+
"Authorization": `Bearer ${peer.token || ""}`,
|
|
88
|
+
"Accept": "application/json",
|
|
89
|
+
},
|
|
90
|
+
signal: timeoutSignal(timeoutMs),
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) return [];
|
|
93
|
+
const j = await res.json().catch(() => ({}));
|
|
94
|
+
return (j.data || [])
|
|
95
|
+
.filter((m) => m && typeof m.id === "string" && m.status === "normal")
|
|
96
|
+
.map((m) => m.id);
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
if (ttl > 0) {
|
|
102
|
+
healthInflight.set(key, p);
|
|
103
|
+
try {
|
|
104
|
+
const data = await p;
|
|
105
|
+
healthCache.set(key, { at: Date.now(), data });
|
|
106
|
+
return data;
|
|
107
|
+
} finally {
|
|
108
|
+
healthInflight.delete(key);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return p;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// controller 由调用方持有:赛跑 winner 确定后可主动 abort 其余 in-flight,
|
|
115
|
+
// 避免迟到流挂满 bodyTimeout(并给对方省下无用负载)。30s timer 仅覆盖"等响应头"。
|
|
116
|
+
async function forwardToPeer(peer, body, model, hops, controller = new AbortController()) {
|
|
117
|
+
const timer = setTimeout(() => controller.abort(), PEER_TIMEOUT_MS);
|
|
118
|
+
try {
|
|
119
|
+
const headers = {
|
|
120
|
+
"Content-Type": "application/json",
|
|
121
|
+
"Authorization": `Bearer ${peer.token}`,
|
|
122
|
+
"x-mslxdff-hops": String(hops + 1),
|
|
123
|
+
"x-mslxdff-model-lock": model,
|
|
124
|
+
"Accept": "text/event-stream",
|
|
125
|
+
};
|
|
126
|
+
// ADR-0019:命中供应商的 key 随转发自动借出(组内互信,无开关;opencode/workbuddy/cline 硬排除)
|
|
127
|
+
const shareHeader = buildShareKeysHeader(model);
|
|
128
|
+
if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
|
|
129
|
+
const dispatcher = getPeerDispatcher();
|
|
130
|
+
return await compatFetch(`${peer.url}/v1/chat/completions`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers,
|
|
133
|
+
body: JSON.stringify({ ...stripCallerBoundReasoning(body), model }),
|
|
134
|
+
signal: controller.signal,
|
|
135
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
136
|
+
});
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return err;
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function resolvePeerTarget(ctx, peer) {
|
|
145
|
+
const prevModel = ctx.peers.stat(peer.url)?.model;
|
|
146
|
+
const hot = ctx.peers.isHot(peer.url) && prevModel === ctx.model;
|
|
147
|
+
if (hot) return { peer, target: prevModel };
|
|
148
|
+
const isExplicit = !!ctx.model && !isAutoModel(ctx.model);
|
|
149
|
+
if (isExplicit) {
|
|
150
|
+
const healthy = await peerHealthyModels(peer);
|
|
151
|
+
if (!healthy.length) {
|
|
152
|
+
ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0, strict: true });
|
|
153
|
+
return { peer, target: ctx.model };
|
|
154
|
+
}
|
|
155
|
+
ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length, strict: true });
|
|
156
|
+
return { peer, target: ctx.model };
|
|
157
|
+
}
|
|
158
|
+
const healthy = await peerHealthyModels(peer);
|
|
159
|
+
if (!healthy.length) {
|
|
160
|
+
await ctx.peers.recordError(peer.url);
|
|
161
|
+
ctx.logError(ctx.model, 0, `peer ${peer.url} has no healthy models`);
|
|
162
|
+
ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0 });
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length });
|
|
166
|
+
return { peer, target: healthy.includes(ctx.model) ? ctx.model : healthy[0] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const PEER_RACE_LIMIT = Number(process.env.MSLXDFF_PEER_RACE_LIMIT) > 0
|
|
170
|
+
? Number(process.env.MSLXDFF_PEER_RACE_LIMIT)
|
|
171
|
+
: 3;
|
|
172
|
+
|
|
173
|
+
// 串行记账队列:失败/迟到成功的记录不阻塞赢家返回,同时避免并发写盘互相覆盖。
|
|
174
|
+
let peerRecordChain = Promise.resolve();
|
|
175
|
+
function recordLater(fn) {
|
|
176
|
+
peerRecordChain = peerRecordChain.then(fn).catch(() => {});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function racePeerCandidates(candidates, ctx) {
|
|
180
|
+
const tried = (ctx.triedUrls ??= new Set());
|
|
181
|
+
const fresh = candidates.filter((p) => !tried.has(p.url));
|
|
182
|
+
for (let i = 0; i < fresh.length; i += PEER_RACE_LIMIT) {
|
|
183
|
+
const batch = fresh.slice(i, i + PEER_RACE_LIMIT);
|
|
184
|
+
const prepared = (await Promise.all(batch.map((peer) => resolvePeerTarget(ctx, peer)))).filter(Boolean);
|
|
185
|
+
if (!prepared.length) continue;
|
|
186
|
+
const completed = await new Promise((resolve) => {
|
|
187
|
+
const order = [];
|
|
188
|
+
const total = prepared.length;
|
|
189
|
+
const ctrls = new Map(); // url -> controller(winner 后统一取消其余)
|
|
190
|
+
let settled = false;
|
|
191
|
+
const finish = (winner) => {
|
|
192
|
+
if (settled) return;
|
|
193
|
+
settled = true;
|
|
194
|
+
if (winner) {
|
|
195
|
+
for (const c of ctrls.values()) {
|
|
196
|
+
if (c === winner.ctrl) continue;
|
|
197
|
+
c.killedByUs = true;
|
|
198
|
+
c.abort();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
resolve({ list: order, winner });
|
|
202
|
+
};
|
|
203
|
+
for (const { peer, target } of prepared) {
|
|
204
|
+
tried.add(peer.url);
|
|
205
|
+
const ctrl = new AbortController();
|
|
206
|
+
ctrls.set(peer.url, ctrl);
|
|
207
|
+
ctx.evt("peer-request", { peer: peer.url, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body) });
|
|
208
|
+
// 插件 hook:peer:beforeForward — 转发给组员前观察
|
|
209
|
+
if (ctx.plugins?.length) {
|
|
210
|
+
runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
|
|
211
|
+
}
|
|
212
|
+
const t0 = performance.now();
|
|
213
|
+
forwardToPeer(peer, ctx.body, target, ctx.hops, ctrl).then((res) => {
|
|
214
|
+
const latencyMs = Math.round(performance.now() - t0);
|
|
215
|
+
const failed = res instanceof Error || res.status >= 400;
|
|
216
|
+
// winner 已定后被我们主动取消:静默,不记账不报错(否则污染健康统计与错误日志)
|
|
217
|
+
if (res instanceof Error && ctrl.killedByUs) {
|
|
218
|
+
order.push({ ok: false, peer, target, res, status: 0, killed: true });
|
|
219
|
+
if (order.length === total) finish(null);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
ctx.evt("peer-forward", { peer: peer.url, model: target, hops: ctx.hops + 1, latencyMs, ok: !failed });
|
|
223
|
+
// 插件 hook:peer:result — 组员响应后观察
|
|
224
|
+
if (ctx.plugins?.length) {
|
|
225
|
+
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(() => {});
|
|
226
|
+
}
|
|
227
|
+
if (failed) {
|
|
228
|
+
const status = res instanceof Error ? 502 : res.status;
|
|
229
|
+
const failRec = { peer: peer.url, status, message: res instanceof Error ? errMsg(res) : null };
|
|
230
|
+
if (Array.isArray(ctx.peerErrors)) ctx.peerErrors.push(failRec);
|
|
231
|
+
ctx.logError(ctx.model, status, res instanceof Error ? `peer ${peer.url} ${errMsg(res)}` : `peer ${peer.url} ${status}`);
|
|
232
|
+
ctx.evt("peer-error", { peer: peer.url, model: target, status, message: res instanceof Error ? errMsg(res) : null });
|
|
233
|
+
order.push({ ok: false, peer, target, res, status });
|
|
234
|
+
recordLater(() => ctx.peers.recordError(peer.url, { status }));
|
|
235
|
+
recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
|
|
236
|
+
if (order.length === total) finish(null);
|
|
237
|
+
} else {
|
|
238
|
+
const entry = { ok: true, peer, target, res, latencyMs, ctrl };
|
|
239
|
+
order.push(entry);
|
|
240
|
+
if (!settled) {
|
|
241
|
+
// 第一个成功立即返回:不等慢/黑洞候选(迟到者的记账由各分支自理)
|
|
242
|
+
finish(entry);
|
|
243
|
+
} else {
|
|
244
|
+
recordLater(() => ctx.peers.recordResult(peer.url, { ok: true, latencyMs, model: target }));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
const winner = completed.winner;
|
|
251
|
+
if (winner) {
|
|
252
|
+
return { peer: winner.peer, target: winner.target, res: winner.res, latencyMs: winner.latencyMs };
|
|
253
|
+
}
|
|
254
|
+
// 全失败:补读失败响应体(诊断 + 调用者详情)。仅在"本轮无 winner"时执行,
|
|
255
|
+
// 不拖慢成功路径;并行读、每 peer 上限 600ms,body 里才有 400/429 的真实原因。
|
|
256
|
+
await Promise.all(completed.list.map(async (o) => {
|
|
257
|
+
if (o.ok || !o.res || typeof o.res !== "object" || o.res instanceof Error) return;
|
|
258
|
+
try {
|
|
259
|
+
const snip = String(await Promise.race([
|
|
260
|
+
o.res.clone().text(),
|
|
261
|
+
new Promise((r) => { const t = setTimeout(() => r(""), 600); t.unref?.(); }),
|
|
262
|
+
])).replace(/\s+/g, " ").slice(0, 300);
|
|
263
|
+
if (!snip) return;
|
|
264
|
+
if (Array.isArray(ctx.peerErrors)) {
|
|
265
|
+
const rec = ctx.peerErrors.find((x) => x.peer === o.peer.url && x.status === o.status && !x.message);
|
|
266
|
+
if (rec) rec.message = snip;
|
|
267
|
+
}
|
|
268
|
+
ctx.logError(ctx.model, o.status, `peer ${o.peer.url} ${o.status} body=${snip}`);
|
|
269
|
+
} catch {}
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|