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.
Files changed (151) hide show
  1. package/bin/mslxdff.js +4 -4
  2. package/docs/ARCHITECTURE.md +426 -0
  3. package/docs/FEATURE_TREE.md +164 -0
  4. package/docs/MOBILE.md +82 -0
  5. package/docs/adr/0001-reasoning-content-injection.md +14 -0
  6. package/docs/adr/0002-models-free-filter.md +12 -0
  7. package/docs/adr/0003-zero-state-no-auth.md +10 -0
  8. package/docs/adr/0004-bearer-token.md +18 -0
  9. package/docs/adr/0005-peer-mesh.md +53 -0
  10. package/docs/adr/0006-broadband-member.md +103 -0
  11. package/docs/adr/0007-multi-provider-prefix.md +25 -0
  12. package/docs/adr/0008-share-keys-to-peers.md +52 -0
  13. package/docs/adr/0009-chat-repl.md +37 -0
  14. package/docs/adr/0010-allowlist.md +36 -0
  15. package/docs/adr/0011-broadband-stream.md +30 -0
  16. package/docs/adr/0012-responses-endpoint-codex-sync.md +48 -0
  17. package/docs/adr/0013-node16-compat.md +41 -0
  18. package/docs/adr/0014-deepseek-provider.md +52 -0
  19. package/docs/adr/0015-upstream-probe-routing.md +50 -0
  20. package/docs/adr/0016-model-capabilities.md +27 -0
  21. package/docs/adr/0017-ai-sdk-upstream-engine.md +59 -0
  22. package/docs/adr/0018-zen-client-identity.md +52 -0
  23. package/docs/adr/0019-share-keys-always-lend.md +59 -0
  24. package/docs/adr/0020-zen-free-lane-agent-shape.md +52 -0
  25. package/docs/adr/0021-usage-report-jsonl.md +47 -0
  26. package/docs/adr/0022-models-capability-merge.md +61 -0
  27. package/docs/adr/0023-key-provider-default-direct.md +58 -0
  28. package/docs/adr/0024-node18-baseline.md +63 -0
  29. package/docs/adr/0025-workbuddy-authdir-follows-state.md +71 -0
  30. package/docs/adr/0026-cline-provider-id-unify.md +67 -0
  31. package/docs/adr/0027-codearts-provider.md +48 -0
  32. package/docs/adr/0028-traework-provider.md +34 -0
  33. package/docs/adr/0029-qoder-native-provider.md +60 -0
  34. package/docs/adr/0030-models-list-scoped-by-picks.md +53 -0
  35. package/docs/adr/0031-qoder-true-streaming.md +50 -0
  36. package/docs/adr/0032-generic-responses-channel.md +72 -0
  37. package/docs/adr/0033-cline-allowlist-auto-sync.md +75 -0
  38. package/docs/adr/0034-request-level-human-readable-observability.md +60 -0
  39. package/docs/adr/0035-sdk-channel-headers-timeout.md +49 -0
  40. package/docs/adr/0036-qoder-per-request-sticky-account.md +82 -0
  41. package/docs/adr/0037-qwenwork-independent-provider.md +82 -0
  42. package/docs/adr/0038-zcode-provider.md +140 -0
  43. package/docs/agents/domain.md +51 -0
  44. package/docs/agents/issue-tracker.md +30 -0
  45. package/docs/agents/triage-labels.md +15 -0
  46. package/docs/cli_help.md +1391 -0
  47. package/docs/cli_help_mini.md +133 -0
  48. package/docs/plans/bench-via-latency-2026-09-01.md +215 -0
  49. package/docs/plugins.md +187 -0
  50. package/package.json +1 -1
  51. package/src/auto.js +254 -254
  52. package/src/bench/cline-bench.js +42 -42
  53. package/src/bench/probe.js +70 -70
  54. package/src/bench/report.js +162 -162
  55. package/src/bench/runner.js +77 -77
  56. package/src/bench/via-probe.js +124 -124
  57. package/src/bench/via-routes.js +87 -87
  58. package/src/bench/workbuddy-bench.js +54 -54
  59. package/src/chat/engine.js +160 -160
  60. package/src/chat/gateway.js +163 -163
  61. package/src/chat/orchestrator.js +234 -234
  62. package/src/chat/prompt.js +70 -70
  63. package/src/chat/repl.js +88 -88
  64. package/src/chat/terminal.js +135 -135
  65. package/src/chat/tools.js +306 -306
  66. package/src/chat-pipeline/index.js +123 -123
  67. package/src/chat-pipeline/policy.js +76 -76
  68. package/src/chat-pipeline/serial-trial.js +210 -210
  69. package/src/cli/commands/group.js +249 -249
  70. package/src/cli/commands/model/list-providers.js +4 -3
  71. package/src/cli/commands/model/list-render.js +7 -9
  72. package/src/cli/commands/model/list-sort.js +52 -0
  73. package/src/cli/commands/model/list-tty.js +76 -0
  74. package/src/cli/commands/model/list.js +5 -62
  75. package/src/cli/commands/model/picks.js +50 -50
  76. package/src/cli/commands/provider/bench-via.js +247 -247
  77. package/src/cli/commands/provider/bench.js +141 -141
  78. package/src/cli/commands/provider/index.js +124 -116
  79. package/src/cli/commands/provider/models.js +139 -128
  80. package/src/cli/commands/provider/qwenwork-login.js +119 -0
  81. package/src/cli/commands/provider/zcode-login.js +77 -0
  82. package/src/cli/commands/provider/zcode-quota.js +55 -0
  83. package/src/cli/commands/sync.js +232 -232
  84. package/src/cli/provider-row.js +2 -2
  85. package/src/cli/status.js +279 -279
  86. package/src/daemon.js +96 -96
  87. package/src/model-capabilities/enrich.js +86 -86
  88. package/src/model-capabilities/index.js +183 -183
  89. package/src/model-capabilities/parse.js +70 -70
  90. package/src/model-trace.js +1 -0
  91. package/src/models.js +225 -225
  92. package/src/providers/classify.js +1 -1
  93. package/src/providers/cline/auth.js +228 -228
  94. package/src/providers/cline/chat.js +307 -307
  95. package/src/providers/cline.js +2 -2
  96. package/src/providers/keyring.js +60 -56
  97. package/src/providers/qoder/chat.js +183 -100
  98. package/src/providers/qoder/index.js +230 -184
  99. package/src/providers/qoder/sse.js +103 -46
  100. package/src/providers/qoder/sticky.js +1 -0
  101. package/src/providers/qoder/stream.js +32 -9
  102. package/src/providers/qwenwork/account-store.js +133 -0
  103. package/src/providers/qwenwork/constants.js +67 -0
  104. package/src/providers/qwenwork/cosy.js +120 -0
  105. package/src/providers/qwenwork/crypto.js +218 -0
  106. package/src/providers/qwenwork/http.js +20 -0
  107. package/src/providers/qwenwork/index.js +327 -0
  108. package/src/providers/qwenwork/payload.js +142 -0
  109. package/src/providers/qwenwork/rsa.js +54 -0
  110. package/src/providers/qwenwork/sse.js +268 -0
  111. package/src/providers/qwenwork/stream.js +130 -0
  112. package/src/providers/qwenwork/upstream.js +120 -0
  113. package/src/providers/qwenwork.js +1 -0
  114. package/src/providers/registry.js +66 -56
  115. package/src/providers/share-keys.js +2 -2
  116. package/src/providers/workbuddy/chat.js +248 -248
  117. package/src/providers/workbuddy/reshape.js +152 -152
  118. package/src/providers/workbuddy.js +2 -2
  119. package/src/providers/zcode/account-store.js +129 -0
  120. package/src/providers/zcode/auth.js +28 -0
  121. package/src/providers/zcode/chat.js +171 -0
  122. package/src/providers/zcode/const.js +54 -0
  123. package/src/providers/zcode/headers.js +55 -0
  124. package/src/providers/zcode/index.js +124 -0
  125. package/src/providers/zcode/models.js +65 -0
  126. package/src/providers/zcode/oauth.js +120 -0
  127. package/src/providers/zcode/quota.js +176 -0
  128. package/src/providers/zcode/sse.js +179 -0
  129. package/src/reasoning.js +32 -32
  130. package/src/routes/chat/gateway.js +46 -46
  131. package/src/routes/chat/relay-pipeline.js +250 -246
  132. package/src/routes/chat/via-route-handler.js +144 -144
  133. package/src/routes/hedge.js +255 -255
  134. package/src/routes/models-route.js +167 -167
  135. package/src/routes/peers.js +273 -273
  136. package/src/routes/stream.js +438 -399
  137. package/src/runtime/bootstrap.js +45 -45
  138. package/src/runtime/provider-gate.js +33 -30
  139. package/src/runtime/providers-setup.js +165 -165
  140. package/src/server.js +64 -64
  141. package/src/state/schemas/allowlist.js +92 -92
  142. package/src/sync-opencode.js +280 -280
  143. package/src/transport/index.js +244 -244
  144. package/src/transport/pool.js +56 -56
  145. package/src/transport/retry.js +24 -24
  146. package/src/transport/sse.js +93 -93
  147. package/src/upstream-probe/display.js +52 -52
  148. package/src/upstream-probe/probe.js +49 -49
  149. package/src/upstream-probe/rotate.js +110 -110
  150. package/src/upstream-probe/start.js +45 -45
  151. package/src/upstream.js +289 -289
@@ -1,210 +1,210 @@
1
- import { performance } from "node:perf_hooks";
2
- import { injectReasoningContent } from "../reasoning.js";
3
- import { runHook } from "../plugins.js";
4
- import { errMsg, json } from "../routes/helpers.js";
5
- import { hedgeDelayMs, shouldHedge } from "../routes/hedge.js";
6
- import { handleHedge } from "../routes/chat/hedge-handler.js";
7
- import { handleLocalRelay } from "../routes/chat/local-handler.js";
8
- import { handlePeerRelay } from "../routes/chat/peer-handler.js";
9
- import { handleBroadbandRelay } from "../routes/chat/broadband-handler.js";
10
- import { handleViaRoute } from "../routes/chat/via-route-handler.js";
11
- import { handleExhaustedLocal, handleExhaustedAll } from "../routes/chat/exhausted-handler.js";
12
- import { shouldUseGroupForModel, isHardLocalOnly, isKeyProviderDirectOnly } from "../state/schemas/use-group.js";
13
- import { summarizeRequest, upstreamEcho } from "../model-trace.js";
14
- import { isEmptyTurnError } from "../routes/chat/relay-pipeline.js";
15
-
16
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
-
18
- // 空转重试档位(每次请求读取 env,单测可覆盖):默认同模型最多重试 2 次、间隔 1s;
19
- // MSLXDFF_EMPTY_TURN_RETRIES=0 关闭(回旧行为:空转直接换候选/终结)。
20
- function emptyRetryCfg() {
21
- const r = Number(process.env.MSLXDFF_EMPTY_TURN_RETRIES);
22
- const d = Number(process.env.MSLXDFF_EMPTY_TURN_RETRY_DELAY_MS);
23
- return {
24
- max: Number.isInteger(r) && r >= 0 ? r : 2,
25
- delayMs: Number.isFinite(d) && d >= 0 ? d : 1000,
26
- };
27
- }
28
-
29
- function groupSkipReason(model) {
30
- if (isHardLocalOnly(model)) return "provider local-only(禁组员,仅本机直连)";
31
- if (isKeyProviderDirectOnly(model)) return "key provider default direct(仅本机直连,MSLXDFF_USE_GROUP_KEYS=1 可开组员)";
32
- return "useGroup=off";
33
- }
34
-
35
- /**
36
- * 串行 trial — 从 engine.js 抽出的第二段:via-route 单路径 → 串行 trial →
37
- * hedge/local/peer/broadband/exhausted。恒终结,返回 { done:true }。
38
- */
39
- export async function runSerialTrial(ctx, deps = {}) {
40
- const {
41
- viaRoute = handleViaRoute,
42
- hedge = handleHedge,
43
- localRelay = handleLocalRelay,
44
- peerRelay = handlePeerRelay,
45
- broadbandRelay = handleBroadbandRelay,
46
- exhaustedLocal = handleExhaustedLocal,
47
- exhaustedAll = handleExhaustedAll,
48
- } = deps;
49
- const {
50
- order, reqId, requested, body, hops, useAuto, lockModel, plugins,
51
- auto, upstream, peers, groups, bus, token, canFallback, canForwardPeers,
52
- perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
53
- res, startedAt, logs,
54
- } = ctx;
55
- const shareKeys = ctx.shareKeys ?? ctx.policy?.shareKeys ?? {};
56
- const workbuddyUid = ctx.workbuddyUid ?? ctx.policy?.workbuddyUid ?? null;
57
-
58
- let viaRouteLastErr = null;
59
- if (!useAuto && requested && requested.includes("/") && canForwardPeers && !lockModel && peers && shouldUseGroupForModel(requested)) {
60
- try {
61
- const vr = await viaRoute({ model: requested, body, peers, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res, requested, useAuto, lockModel, auto });
62
- if (vr.handled) return { done: true };
63
- if (vr.lastErr) viaRouteLastErr = vr.lastErr;
64
- } catch (e) {
65
- evt("via-route-exception", { reqId, model: requested, error: errMsg(e) });
66
- }
67
- } else if (!useAuto && requested && requested.includes("/") && canForwardPeers && !lockModel && peers) {
68
- evt("group-skip", { reqId, model: requested, reason: `${groupSkipReason(requested)} (via-route)` });
69
- }
70
-
71
- let lastErr = viaRouteLastErr;
72
- candidate: for (let idx = 0; idx < order.length; idx++) {
73
- const model = order[idx];
74
- handlerCtx.model = model;
75
- handlerCtx.orderLen = order.length;
76
- handlerCtx.idx = idx;
77
- evt("model-try", { reqId, model, idx, remaining: order.length - idx });
78
- if (plugins?.length) {
79
- const bt = await runHook(plugins, "model:beforeTry", { reqId, requested, model, idx, hops });
80
- for (const e of bt.errors) evt("plugin-hook-error", { reqId, hook: "model:beforeTry", plugin: e.plugin, error: e.error });
81
- if (bt.value === false || bt.value?.skip === true) { evt("plugin-hook", { reqId, hook: "model:beforeTry", applied: true, skipped: model }); continue; }
82
- }
83
- let upRes = null;
84
- let forwarded = { ...injectReasoningContent(model, body), model };
85
- if (plugins?.length) {
86
- const ur = await runHook(plugins, "upstream:request", { reqId, requested, model, payload: forwarded, stream: Boolean(body.stream) });
87
- for (const e of ur.errors) evt("plugin-hook-error", { reqId, hook: "upstream:request", plugin: e.plugin, error: e.error });
88
- if (ur.changed && ur.value?.payload && typeof ur.value.payload === "object") { forwarded = ur.value.payload; evt("plugin-hook", { reqId, hook: "upstream:request", applied: true, model, rewrittenModel: forwarded.model ?? null }); }
89
- }
90
- const chatOpts = {};
91
- if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
92
- if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
93
- if (handlerCtx?.sessionId) chatOpts.sessionId = handlerCtx.sessionId;
94
- // reqId = 本次客户端请求的身份:供应商据此"同请求粘号"(重试不换号,只有 401/403/429/5xx 冷却才换)
95
- if (reqId) chatOpts.reqId = reqId;
96
- const chatOptsArg = Object.keys(chatOpts).length ? chatOpts : undefined;
97
- // 空转 200(模型无输出)同模型暂停重试:默认 2 次、间隔 1s;仅 EMPTY_MODEL_RESPONSE,
98
- // 429/403/500 与 fetch 异常走原有切号/failover(防烧额度)。MSLXDFF_EMPTY_TURN_RETRIES=0 关闭。
99
- const emptyCfg = emptyRetryCfg();
100
- let emptyRetried = 0;
101
- for (;;) {
102
- const tUp = performance.now();
103
- evt("upstream-try", { reqId, model, attempt: idx + 1, emptyRetry: emptyRetried, payload: summarizeRequest(forwarded) });
104
- try {
105
- upRes = await upstream.chat(forwarded, chatOptsArg);
106
- evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null, ...upstreamEcho(upRes) });
107
- } catch (err) {
108
- if (auto) await auto.recordError(model, { message: errMsg(err) });
109
- lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
110
- logError(model, 502, errMsg(err));
111
- evt("upstream-error", { reqId, model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
112
- upRes = null;
113
- }
114
- if (plugins?.length) {
115
- runHook(plugins, "upstream:response", {
116
- reqId, requested, model,
117
- status: upRes instanceof Error ? null : upRes instanceof Object ? (upRes.status ?? null) : null,
118
- ok: !(upRes instanceof Error) && upRes ? upRes.status < 400 : false,
119
- error: upRes instanceof Error ? errMsg(upRes) : null,
120
- timing: upRes?._t ?? null,
121
- }).catch(() => {});
122
- }
123
- mark(`up-${model}`);
124
- if (upRes && upRes.status >= 400) {
125
- const isAllowlistBlock = upRes.status === 403 && (upRes.headers?.get?.("x-mslxdff-allowlist") === "1");
126
- if (isAllowlistBlock) {
127
- let bodyText = null; try { bodyText = await upRes.clone().text(); } catch {}
128
- let errBody = { error: `model not allowed for provider` };
129
- try { errBody = bodyText ? JSON.parse(bodyText) : errBody; } catch { errBody = { error: bodyText || "model not allowed" }; }
130
- if (useAuto) {
131
- logError(model, 403, errBody.error || "model not allowed");
132
- evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true, skipped: true });
133
- lastErr = { model, upstream: upRes, status: 403, message: errBody.error || "model not allowed" };
134
- if (canFallback && idx < order.length - 1) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: `allowlist skip ${errBody.error || "blocked"}` }); continue candidate; }
135
- return json(res, 403, errBody);
136
- }
137
- logError(model, 403, errBody.error || "model not allowed");
138
- evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true });
139
- return json(res, 403, errBody);
140
- }
141
- if (auto) await auto.recordError(model, { status: upRes.status });
142
- // 读失败响应体(clone 不影响后续 relay 转发原响应;1s 上限防流式错误体拖慢)
143
- let upBody = "";
144
- try {
145
- upBody = String(await Promise.race([
146
- upRes.clone().text(),
147
- new Promise((r) => { const t = setTimeout(() => r(""), 1000); t.unref?.(); }),
148
- ])).replace(/\s+/g, " ").slice(0, 400);
149
- } catch {}
150
- const upMsg = upBody || `upstream ${upRes.status}`;
151
- lastErr = { model, upstream: upRes, status: upRes.status, message: upMsg };
152
- logError(model, upRes.status, `upstream ${upRes.status}${upBody ? ` body=${upBody.slice(0, 300)}` : ""}`);
153
- evt("upstream-error", { reqId, model, status: upRes.status, message: upMsg.slice(0, 300), timing: upRes._t ?? null, ...upstreamEcho(upRes) });
154
- upRes = null;
155
- break;
156
- }
157
- if (upRes) {
158
- const isStream = Boolean(body.stream);
159
- const d = hedgeDelayMs();
160
- const hasPeers = Boolean(peers) && peers.ordered().length > 0;
161
- const canUseGroup = shouldUseGroupForModel(model);
162
- const doHedge = canUseGroup && shouldHedge({ isStream, canForwardPeers, hedgeDelayMs: d, hasPeers, model }) && upRes.status === 200 && upRes.body;
163
- if (doHedge) {
164
- const hr = await hedge({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res, hedgeDelayMs: d });
165
- if (hr.handled) return { done: true };
166
- if (hr.lastErr) lastErr = hr.lastErr;
167
- if (hr.upRes === null) upRes = null;
168
- else if (hr.upRes) upRes = hr.upRes;
169
- }
170
- if (upRes) {
171
- const lr = await localRelay({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res });
172
- if (lr.handled) return { done: true };
173
- if (lr.lastErr && isEmptyTurnError(lr.lastErr) && emptyRetried < emptyCfg.max) {
174
- emptyRetried++;
175
- // 带上"刚空转的是哪个号/哪个站":切号是重试驱动的,日志必须能自证
176
- evt("empty-turn-retry", { reqId, model, retry: emptyRetried, max: emptyCfg.max, delayMs: emptyCfg.delayMs, ...upstreamEcho(upRes) });
177
- await sleep(emptyCfg.delayMs);
178
- continue;
179
- }
180
- if (lr.lastErr) { lastErr = lr.lastErr; continue candidate; }
181
- return { done: true };
182
- }
183
- break;
184
- }
185
- break;
186
- }
187
- if (canForwardPeers) {
188
- if (!shouldUseGroupForModel(model)) {
189
- evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (peer)` });
190
- } else {
191
- const pr = await peerRelay({ model, body, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, mark, perf0, stages, startedAt, plugins, res });
192
- if (pr.handled) return { done: true };
193
- if (pr.lastErr) lastErr = pr.lastErr;
194
- }
195
- }
196
- if (groups) {
197
- if (!shouldUseGroupForModel(model)) {
198
- evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (broadband)` });
199
- } else {
200
- const br = await broadbandRelay({ model, body, hops, lastErr, requested, useAuto, lockModel, auto, groups, token, bus, logs, handlerCtx, evt, mark, perf0, stages, res, startedAt, plugins });
201
- if (br.handled) return { done: true };
202
- }
203
- }
204
- if (canFallback) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: lastErr?.message || `upstream ${lastErr?.status ?? 502}` }); continue; }
205
- await exhaustedLocal({ res, body, lastErr, order, handlerCtx: { ...handlerCtx, model, reqId }, evt, logCall, mark, perf0, stages, done, requested, useAuto });
206
- return { done: true };
207
- }
208
- await exhaustedAll({ res, body, lastErr, order, requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
209
- return { done: true };
210
- }
1
+ import { performance } from "node:perf_hooks";
2
+ import { injectReasoningContent } from "../reasoning.js";
3
+ import { runHook } from "../plugins.js";
4
+ import { errMsg, json } from "../routes/helpers.js";
5
+ import { hedgeDelayMs, shouldHedge } from "../routes/hedge.js";
6
+ import { handleHedge } from "../routes/chat/hedge-handler.js";
7
+ import { handleLocalRelay } from "../routes/chat/local-handler.js";
8
+ import { handlePeerRelay } from "../routes/chat/peer-handler.js";
9
+ import { handleBroadbandRelay } from "../routes/chat/broadband-handler.js";
10
+ import { handleViaRoute } from "../routes/chat/via-route-handler.js";
11
+ import { handleExhaustedLocal, handleExhaustedAll } from "../routes/chat/exhausted-handler.js";
12
+ import { shouldUseGroupForModel, isHardLocalOnly, isKeyProviderDirectOnly } from "../state/schemas/use-group.js";
13
+ import { summarizeRequest, upstreamEcho } from "../model-trace.js";
14
+ import { isEmptyTurnError } from "../routes/chat/relay-pipeline.js";
15
+
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+
18
+ // 空转重试档位(每次请求读取 env,单测可覆盖):默认同模型最多重试 2 次、间隔 1s;
19
+ // MSLXDFF_EMPTY_TURN_RETRIES=0 关闭(回旧行为:空转直接换候选/终结)。
20
+ function emptyRetryCfg() {
21
+ const r = Number(process.env.MSLXDFF_EMPTY_TURN_RETRIES);
22
+ const d = Number(process.env.MSLXDFF_EMPTY_TURN_RETRY_DELAY_MS);
23
+ return {
24
+ max: Number.isInteger(r) && r >= 0 ? r : 2,
25
+ delayMs: Number.isFinite(d) && d >= 0 ? d : 1000,
26
+ };
27
+ }
28
+
29
+ function groupSkipReason(model) {
30
+ if (isHardLocalOnly(model)) return "provider local-only(禁组员,仅本机直连)";
31
+ if (isKeyProviderDirectOnly(model)) return "key provider default direct(仅本机直连,MSLXDFF_USE_GROUP_KEYS=1 可开组员)";
32
+ return "useGroup=off";
33
+ }
34
+
35
+ /**
36
+ * 串行 trial — 从 engine.js 抽出的第二段:via-route 单路径 → 串行 trial →
37
+ * hedge/local/peer/broadband/exhausted。恒终结,返回 { done:true }。
38
+ */
39
+ export async function runSerialTrial(ctx, deps = {}) {
40
+ const {
41
+ viaRoute = handleViaRoute,
42
+ hedge = handleHedge,
43
+ localRelay = handleLocalRelay,
44
+ peerRelay = handlePeerRelay,
45
+ broadbandRelay = handleBroadbandRelay,
46
+ exhaustedLocal = handleExhaustedLocal,
47
+ exhaustedAll = handleExhaustedAll,
48
+ } = deps;
49
+ const {
50
+ order, reqId, requested, body, hops, useAuto, lockModel, plugins,
51
+ auto, upstream, peers, groups, bus, token, canFallback, canForwardPeers,
52
+ perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
53
+ res, startedAt, logs,
54
+ } = ctx;
55
+ const shareKeys = ctx.shareKeys ?? ctx.policy?.shareKeys ?? {};
56
+ const workbuddyUid = ctx.workbuddyUid ?? ctx.policy?.workbuddyUid ?? null;
57
+
58
+ let viaRouteLastErr = null;
59
+ if (!useAuto && requested && requested.includes("/") && canForwardPeers && !lockModel && peers && shouldUseGroupForModel(requested)) {
60
+ try {
61
+ const vr = await viaRoute({ model: requested, body, peers, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res, requested, useAuto, lockModel, auto });
62
+ if (vr.handled) return { done: true };
63
+ if (vr.lastErr) viaRouteLastErr = vr.lastErr;
64
+ } catch (e) {
65
+ evt("via-route-exception", { reqId, model: requested, error: errMsg(e) });
66
+ }
67
+ } else if (!useAuto && requested && requested.includes("/") && canForwardPeers && !lockModel && peers) {
68
+ evt("group-skip", { reqId, model: requested, reason: `${groupSkipReason(requested)} (via-route)` });
69
+ }
70
+
71
+ let lastErr = viaRouteLastErr;
72
+ candidate: for (let idx = 0; idx < order.length; idx++) {
73
+ const model = order[idx];
74
+ handlerCtx.model = model;
75
+ handlerCtx.orderLen = order.length;
76
+ handlerCtx.idx = idx;
77
+ evt("model-try", { reqId, model, idx, remaining: order.length - idx });
78
+ if (plugins?.length) {
79
+ const bt = await runHook(plugins, "model:beforeTry", { reqId, requested, model, idx, hops });
80
+ for (const e of bt.errors) evt("plugin-hook-error", { reqId, hook: "model:beforeTry", plugin: e.plugin, error: e.error });
81
+ if (bt.value === false || bt.value?.skip === true) { evt("plugin-hook", { reqId, hook: "model:beforeTry", applied: true, skipped: model }); continue; }
82
+ }
83
+ let upRes = null;
84
+ let forwarded = { ...injectReasoningContent(model, body), model };
85
+ if (plugins?.length) {
86
+ const ur = await runHook(plugins, "upstream:request", { reqId, requested, model, payload: forwarded, stream: Boolean(body.stream) });
87
+ for (const e of ur.errors) evt("plugin-hook-error", { reqId, hook: "upstream:request", plugin: e.plugin, error: e.error });
88
+ if (ur.changed && ur.value?.payload && typeof ur.value.payload === "object") { forwarded = ur.value.payload; evt("plugin-hook", { reqId, hook: "upstream:request", applied: true, model, rewrittenModel: forwarded.model ?? null }); }
89
+ }
90
+ const chatOpts = {};
91
+ if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
92
+ if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
93
+ if (handlerCtx?.sessionId) chatOpts.sessionId = handlerCtx.sessionId;
94
+ // reqId = 本次客户端请求的身份:供应商据此"同请求粘号"(重试不换号,只有 401/403/429/5xx 冷却才换)
95
+ if (reqId) chatOpts.reqId = reqId;
96
+ const chatOptsArg = Object.keys(chatOpts).length ? chatOpts : undefined;
97
+ // 空转 200(模型无输出)同模型暂停重试:默认 2 次、间隔 1s;仅 EMPTY_MODEL_RESPONSE,
98
+ // 429/403/500 与 fetch 异常走原有切号/failover(防烧额度)。MSLXDFF_EMPTY_TURN_RETRIES=0 关闭。
99
+ const emptyCfg = emptyRetryCfg();
100
+ let emptyRetried = 0;
101
+ for (;;) {
102
+ const tUp = performance.now();
103
+ evt("upstream-try", { reqId, model, attempt: idx + 1, emptyRetry: emptyRetried, payload: summarizeRequest(forwarded) });
104
+ try {
105
+ upRes = await upstream.chat(forwarded, chatOptsArg);
106
+ evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null, ...upstreamEcho(upRes) });
107
+ } catch (err) {
108
+ if (auto) await auto.recordError(model, { message: errMsg(err) });
109
+ lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
110
+ logError(model, 502, errMsg(err));
111
+ evt("upstream-error", { reqId, model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
112
+ upRes = null;
113
+ }
114
+ if (plugins?.length) {
115
+ runHook(plugins, "upstream:response", {
116
+ reqId, requested, model,
117
+ status: upRes instanceof Error ? null : upRes instanceof Object ? (upRes.status ?? null) : null,
118
+ ok: !(upRes instanceof Error) && upRes ? upRes.status < 400 : false,
119
+ error: upRes instanceof Error ? errMsg(upRes) : null,
120
+ timing: upRes?._t ?? null,
121
+ }).catch(() => {});
122
+ }
123
+ mark(`up-${model}`);
124
+ if (upRes && upRes.status >= 400) {
125
+ const isAllowlistBlock = upRes.status === 403 && (upRes.headers?.get?.("x-mslxdff-allowlist") === "1");
126
+ if (isAllowlistBlock) {
127
+ let bodyText = null; try { bodyText = await upRes.clone().text(); } catch {}
128
+ let errBody = { error: `model not allowed for provider` };
129
+ try { errBody = bodyText ? JSON.parse(bodyText) : errBody; } catch { errBody = { error: bodyText || "model not allowed" }; }
130
+ if (useAuto) {
131
+ logError(model, 403, errBody.error || "model not allowed");
132
+ evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true, skipped: true });
133
+ lastErr = { model, upstream: upRes, status: 403, message: errBody.error || "model not allowed" };
134
+ if (canFallback && idx < order.length - 1) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: `allowlist skip ${errBody.error || "blocked"}` }); continue candidate; }
135
+ return json(res, 403, errBody);
136
+ }
137
+ logError(model, 403, errBody.error || "model not allowed");
138
+ evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true });
139
+ return json(res, 403, errBody);
140
+ }
141
+ if (auto) await auto.recordError(model, { status: upRes.status });
142
+ // 读失败响应体(clone 不影响后续 relay 转发原响应;1s 上限防流式错误体拖慢)
143
+ let upBody = "";
144
+ try {
145
+ upBody = String(await Promise.race([
146
+ upRes.clone().text(),
147
+ new Promise((r) => { const t = setTimeout(() => r(""), 1000); t.unref?.(); }),
148
+ ])).replace(/\s+/g, " ").slice(0, 400);
149
+ } catch {}
150
+ const upMsg = upBody || `upstream ${upRes.status}`;
151
+ lastErr = { model, upstream: upRes, status: upRes.status, message: upMsg };
152
+ logError(model, upRes.status, `upstream ${upRes.status}${upBody ? ` body=${upBody.slice(0, 300)}` : ""}`);
153
+ evt("upstream-error", { reqId, model, status: upRes.status, message: upMsg.slice(0, 300), timing: upRes._t ?? null, ...upstreamEcho(upRes) });
154
+ upRes = null;
155
+ break;
156
+ }
157
+ if (upRes) {
158
+ const isStream = Boolean(body.stream);
159
+ const d = hedgeDelayMs();
160
+ const hasPeers = Boolean(peers) && peers.ordered().length > 0;
161
+ const canUseGroup = shouldUseGroupForModel(model);
162
+ const doHedge = canUseGroup && shouldHedge({ isStream, canForwardPeers, hedgeDelayMs: d, hasPeers, model }) && upRes.status === 200 && upRes.body;
163
+ if (doHedge) {
164
+ const hr = await hedge({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res, hedgeDelayMs: d });
165
+ if (hr.handled) return { done: true };
166
+ if (hr.lastErr) lastErr = hr.lastErr;
167
+ if (hr.upRes === null) upRes = null;
168
+ else if (hr.upRes) upRes = hr.upRes;
169
+ }
170
+ if (upRes) {
171
+ const lr = await localRelay({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res });
172
+ if (lr.handled) return { done: true };
173
+ if (lr.lastErr && isEmptyTurnError(lr.lastErr) && emptyRetried < emptyCfg.max) {
174
+ emptyRetried++;
175
+ // 带上"刚空转的是哪个号/哪个站":切号是重试驱动的,日志必须能自证
176
+ evt("empty-turn-retry", { reqId, model, retry: emptyRetried, max: emptyCfg.max, delayMs: emptyCfg.delayMs, ...upstreamEcho(upRes) });
177
+ await sleep(emptyCfg.delayMs);
178
+ continue;
179
+ }
180
+ if (lr.lastErr) { lastErr = lr.lastErr; continue candidate; }
181
+ return { done: true };
182
+ }
183
+ break;
184
+ }
185
+ break;
186
+ }
187
+ if (canForwardPeers) {
188
+ if (!shouldUseGroupForModel(model)) {
189
+ evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (peer)` });
190
+ } else {
191
+ const pr = await peerRelay({ model, body, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, mark, perf0, stages, startedAt, plugins, res });
192
+ if (pr.handled) return { done: true };
193
+ if (pr.lastErr) lastErr = pr.lastErr;
194
+ }
195
+ }
196
+ if (groups) {
197
+ if (!shouldUseGroupForModel(model)) {
198
+ evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (broadband)` });
199
+ } else {
200
+ const br = await broadbandRelay({ model, body, hops, lastErr, requested, useAuto, lockModel, auto, groups, token, bus, logs, handlerCtx, evt, mark, perf0, stages, res, startedAt, plugins });
201
+ if (br.handled) return { done: true };
202
+ }
203
+ }
204
+ if (canFallback) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: lastErr?.message || `upstream ${lastErr?.status ?? 502}` }); continue; }
205
+ await exhaustedLocal({ res, body, lastErr, order, handlerCtx: { ...handlerCtx, model, reqId }, evt, logCall, mark, perf0, stages, done, requested, useAuto });
206
+ return { done: true };
207
+ }
208
+ await exhaustedAll({ res, body, lastErr, order, requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
209
+ return { done: true };
210
+ }