mslxdff 0.1.233 → 0.1.235
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 +149 -0
- package/package.json +1 -1
- package/src/auto.js +264 -264
- package/src/bench/cline-bench.js +42 -42
- package/src/bench/probe.js +70 -70
- package/src/bench/report.js +162 -162
- package/src/bench/runner.js +77 -77
- package/src/bench/via-probe.js +124 -124
- package/src/bench/via-routes.js +87 -87
- package/src/bench/workbuddy-bench.js +54 -54
- package/src/chat/engine.js +160 -160
- package/src/chat/gateway.js +163 -163
- package/src/chat/orchestrator.js +234 -234
- package/src/chat/prompt.js +70 -70
- package/src/chat/repl.js +88 -88
- package/src/chat/terminal.js +135 -135
- package/src/chat/tools.js +306 -306
- package/src/chat-pipeline/index.js +155 -155
- package/src/chat-pipeline/policy.js +84 -84
- package/src/chat-pipeline/serial-trial.js +16 -3
- package/src/chat-pipeline/upstream-5xx-retry.js +72 -0
- package/src/cli/commands/group.js +266 -266
- package/src/cli/commands/model/picks.js +50 -50
- package/src/cli/commands/provider/bench-via.js +247 -247
- package/src/cli/commands/provider/bench.js +141 -141
- package/src/cli/commands/provider/index.js +139 -139
- package/src/cli/commands/provider/models.js +149 -149
- package/src/cli/commands/provider/qwenwork-login.js +119 -119
- package/src/cli/commands/provider/raccoon-quota.js +156 -156
- package/src/cli/commands/sync.js +310 -310
- package/src/cli/status.js +279 -279
- package/src/daemon.js +246 -246
- package/src/model-capabilities/enrich.js +86 -86
- package/src/model-capabilities/index.js +183 -183
- package/src/model-capabilities/parse.js +70 -70
- package/src/models.js +293 -293
- package/src/peers.js +345 -345
- package/src/providers/AGENTS.md +49 -0
- package/src/providers/agentrouter.js +238 -238
- package/src/providers/base.js +164 -164
- package/src/providers/cline/auth.js +228 -228
- package/src/providers/cline/chat.js +307 -307
- package/src/providers/cline.js +2 -2
- package/src/providers/codearts/chat.js +207 -207
- package/src/providers/dispatcher.js +147 -147
- package/src/providers/globalqwenwork/cosy.js +123 -123
- package/src/providers/globalqwenwork/crypto.js +218 -218
- package/src/providers/globalqwenwork/http.js +20 -20
- package/src/providers/globalqwenwork/rsa.js +54 -54
- package/src/providers/globalqwenwork/sse.js +268 -268
- package/src/providers/globalqwenwork/stream.js +130 -130
- package/src/providers/globalqwenwork/upstream.js +122 -122
- package/src/providers/keyring.js +60 -60
- package/src/providers/qoder/chat.js +292 -292
- package/src/providers/qoder/index.js +311 -311
- package/src/providers/qoder/sse.js +233 -233
- 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 +219 -219
- package/src/providers/raccoon/credits.js +285 -285
- package/src/providers/raccoon/index.js +36 -4
- package/src/providers/raccoon/sticky.js +46 -0
- package/src/providers/registry.js +89 -89
- package/src/providers/workbuddy/chat.js +298 -298
- 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/broadband-handler.js +80 -80
- package/src/routes/chat/gateway.js +46 -46
- package/src/routes/chat/peer-handler.js +14 -4
- package/src/routes/chat/relay-pipeline.js +282 -282
- package/src/routes/chat/via-route-handler.js +156 -156
- package/src/routes/hedge.js +260 -260
- package/src/routes/models-route.js +167 -167
- package/src/routes/peer-serial.js +5 -2
- package/src/routes/peers.js +373 -373
- package/src/routes/stream.js +364 -364
- package/src/runtime/bootstrap.js +75 -75
- package/src/runtime/keep-warm.js +135 -135
- package/src/runtime/provider-gate.js +75 -75
- package/src/runtime/providers-setup.js +187 -187
- package/src/runtime/raccoon-checkin.js +175 -175
- package/src/server.js +85 -85
- package/src/state/schemas/allowlist.js +92 -92
- package/src/sync-opencode.js +280 -280
- package/src/transport/index.js +244 -244
- package/src/transport/pool.js +56 -56
- package/src/transport/retry.js +24 -24
- package/src/transport/sse.js +93 -93
- package/src/upstream-probe/display.js +52 -52
- package/src/upstream-probe/probe.js +49 -49
- package/src/upstream-probe/rotate.js +110 -110
- package/src/upstream-probe/start.js +45 -45
- package/src/upstream.js +355 -355
|
@@ -1,156 +1,156 @@
|
|
|
1
|
-
import { performance } from "node:perf_hooks";
|
|
2
|
-
import { analyzePolicy } from "./policy.js";
|
|
3
|
-
import { createEngine } from "./engine.js";
|
|
4
|
-
import { runHook } from "../plugins.js";
|
|
5
|
-
import { isFreeModel } from "../models.js";
|
|
6
|
-
import { clientIp, summarizePrompt } from "../routes/helpers.js";
|
|
7
|
-
import { formatTimeline } from "../timeline.js";
|
|
8
|
-
import { summarizeRequest, shouldTraceModel } from "../model-trace.js";
|
|
9
|
-
import { apiKindOf } from "../api-kind.js";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* ChatPipeline 深模块门面 — 对外 execute(req) 单一 inlet
|
|
13
|
-
* 内部组合 Policy→Engine:解析 header/model → 产 order → 委托 engine 执行
|
|
14
|
-
* gateway 仅薄适配:readBody + request:received hook + 调 execute
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, token, plugins, maxHops } = {}) {
|
|
18
|
-
const engine = createEngine();
|
|
19
|
-
|
|
20
|
-
async function execute({ req, res } = {}) {
|
|
21
|
-
const startedAt = Date.now();
|
|
22
|
-
const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
23
|
-
// 入站协议门(chat|responses|messages|other):全场只在这里判一次,之后随 handlerCtx 透传;
|
|
24
|
-
// 各日志写点不得再各自匹配路径(第二份判定迟早漂移)—— 见 src/api-kind.js
|
|
25
|
-
const api = apiKindOf(req);
|
|
26
|
-
const perf0 = performance.now();
|
|
27
|
-
const stages = [];
|
|
28
|
-
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
29
|
-
|
|
30
|
-
const policy = analyzePolicy({ headers: req?.headers || {}, body: req?.body || {} });
|
|
31
|
-
const { requested, useAuto, autoProvider, lockModel, hops, shareKeys, workbuddyUid, aliasInfo } = policy;
|
|
32
|
-
mark("parsed");
|
|
33
|
-
if (aliasInfo) { try { res?.setHeader?.("x-mslxdff-alias", aliasInfo); } catch {} }
|
|
34
|
-
// mslxdff/ 前缀或 alias 命中时,把 body.model 改写为还原后的模型(与原 gateway 语义一致)
|
|
35
|
-
const timeline = { direct: [], peers: [], retries: 0, retryResult: null, result: null };
|
|
36
|
-
if (aliasInfo && req?.body && req.body.model !== requested) {
|
|
37
|
-
req.body = { ...req.body, model: requested };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// 事件/日志帮手先于 order 推导声明:evt 在 auto-scope 分支即被使用,
|
|
41
|
-
// 声明置后会让 useAuto && autoProvider 的请求在 TDZ 上崩(线上 -chat 回退路径)
|
|
42
|
-
const logCall = (model, status) => logs?.appendCall({ reqId, api, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(req?.body?.stream), stages });
|
|
43
|
-
const logError = (model, status, message) => logs?.appendError({ reqId, api, model, auto: useAuto, status, message, stages });
|
|
44
|
-
const evt = (type, data) => {
|
|
45
|
-
const entry = { ts: Date.now(), reqId, type, ...data, api, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
46
|
-
const traceModel = entry.model || requested;
|
|
47
|
-
if (shouldTraceModel(type)) {
|
|
48
|
-
// Note: 模型日志只投影安全字段,不能把含 prompt 的 entry 原样下传 — 见 .agents/notes/implemented/feature/2026-09-25-model-trace-log.md
|
|
49
|
-
const safeTraceData = { ...entry };
|
|
50
|
-
if (safeTraceData.prompt !== undefined) delete safeTraceData.prompt;
|
|
51
|
-
logs?.appendModelTrace?.(traceModel, { type, reqId, model: traceModel, data: safeTraceData, request: type === "request" ? summarizeRequest(req?.body) : null, totalMs: entry.durationMs });
|
|
52
|
-
}
|
|
53
|
-
if (type === "peer-forward") timeline.peers.push({ peer: entry.peer, ok: entry.ok === true, status: entry.status, latencyMs: entry.latencyMs, message: entry.message || entry.error || "" });
|
|
54
|
-
if (type === "upstream-done" || type === "upstream-error") timeline.direct.push({ status: entry.status, reason: entry.message || entry.error || "" });
|
|
55
|
-
if (type === "peer-error") {
|
|
56
|
-
const known = timeline.peers.find((p) => p.peer === entry.peer);
|
|
57
|
-
if (known) { known.ok = false; known.status = entry.status ?? known.status; known.message = entry.message || entry.error || known.message; }
|
|
58
|
-
else timeline.peers.push({ peer: entry.peer, ok: false, status: entry.status, message: entry.message || entry.error || "" });
|
|
59
|
-
}
|
|
60
|
-
if (type === "empty-turn-retry") timeline.retries += 1;
|
|
61
|
-
// 空转重试必须交代结局:只记次数的话,"重试后救回来"和"重试到死"在日志里长得一模一样
|
|
62
|
-
if (type === "empty-turn-recovered") timeline.retryResult = "ok";
|
|
63
|
-
if (type === "empty-turn-exhausted") timeline.retryResult = timeline.retryResult || "lost";
|
|
64
|
-
if (type === "result" || (type === "client-response" && !timeline.result)) {
|
|
65
|
-
timeline.result = { status: entry.status, detail: entry.detail || null };
|
|
66
|
-
try { logs?.appendTimeline?.(formatTimeline({ reqId, model: entry.model || requested, ...timeline, totalMs: Date.now() - startedAt })); } catch {}
|
|
67
|
-
}
|
|
68
|
-
if (bus) bus.emit(entry);
|
|
69
|
-
logs?.appendEvent?.(entry);
|
|
70
|
-
};
|
|
71
|
-
const done = (info) => {
|
|
72
|
-
if (!plugins?.length) return;
|
|
73
|
-
runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(req?.body?.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
// order 推导 + plugin model:select 可改
|
|
77
|
-
// 语义:指定模型 = 死锁单模型(本机→组员同款,挂了就报挂,不兜其他 picks);只有 auto 才轮 picks
|
|
78
|
-
// 指定模型的额度耗尽时**不换模型**:横向只在同模型的组员/组长间接力(本机出口 IP 耗尽 → 换组员机器),
|
|
79
|
-
// 模型永不漂移。详见 serial-trial 的 order 单元素路径与 peer 接力。
|
|
80
|
-
// x-mslxdff-auto-provider 头可把 auto 候选限定到单供应商(opencode=裸 id 免费池),-chat 默认带
|
|
81
|
-
let order;
|
|
82
|
-
if (lockModel) order = [requested];
|
|
83
|
-
else if (useAuto) {
|
|
84
|
-
let cands = auto ? await auto.candidates() : [""];
|
|
85
|
-
if (autoProvider) {
|
|
86
|
-
const before = cands.length;
|
|
87
|
-
cands = cands.filter((m) => (autoProvider === "opencode"
|
|
88
|
-
? !String(m).includes("/") && isFreeModel(m)
|
|
89
|
-
: String(m).startsWith(`${autoProvider}/`)));
|
|
90
|
-
evt("auto-scope", { reqId, provider: autoProvider, before, after: cands.length });
|
|
91
|
-
}
|
|
92
|
-
order = cands;
|
|
93
|
-
} else {
|
|
94
|
-
// 指定模型 = 死锁单模型:额度耗尽只换组员/组长(同模型换机器),**绝不换模型**。
|
|
95
|
-
// candidatesFor 的持久化副作用(把显式指定的模型加入勾选集,供日后 auto 用)必须保留——
|
|
96
|
-
// 见 auto.js:180「显式指定某模型 = 认可它,自动加入勾选集」,历史上删它会丢勾选记录。
|
|
97
|
-
// 但**不 await**(2026-10-22 日志定罪:loadList 拉全量模型目录烧 24s,写盘 persistPicks 仅 0ms)。
|
|
98
|
-
// 此路径 order 已定死 [requested],candidatesFor 返回值根本不被消费,只为触发"加入勾选集"副作用——
|
|
99
|
-
// 为副作用让主请求干等 24s 是自伤。改后台 fire-and-forget:勾选照样持久化,主请求零阻塞。
|
|
100
|
-
if (auto && requested) {
|
|
101
|
-
const tCf = performance.now();
|
|
102
|
-
Promise.resolve()
|
|
103
|
-
.then(() => auto.candidatesFor(requested))
|
|
104
|
-
.catch(() => {})
|
|
105
|
-
.finally(() => {
|
|
106
|
-
const ms = Math.round(performance.now() - tCf);
|
|
107
|
-
if (ms > 5000) console.log(`[pipeline-bg] candidatesFor 后台耗时 model=${requested} ms=${ms}(未阻塞主请求)`);
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
order = [requested];
|
|
111
|
-
}
|
|
112
|
-
if (!order.length) order = [""];
|
|
113
|
-
const canFallback = order.length > 1;
|
|
114
|
-
// hi 探针(x-mslxdff-probe:1)只问「这台机器此刻能不能打通上游」,不代表真实业务:
|
|
115
|
-
// 它绝不能再触发本机的组员/宽带接力——否则探针会跟着走完整条接力链(最坏 45s 预算),
|
|
116
|
-
// 而调用方只看它几百毫秒内活不活。组员侧就地直连,成败如实回给调用方。
|
|
117
|
-
const isProbe = String(req?.headers?.["x-mslxdff-probe"] || "") === "1";
|
|
118
|
-
const canForwardPeers = !isProbe && Boolean(peers) && hops < (maxHops ?? 3);
|
|
119
|
-
mark("ordered");
|
|
120
|
-
|
|
121
|
-
const reqIp = clientIp(req); // 一次解析两处用:request 事件的 ip 字段 + 回路语料的 clientIp(routes/helpers.js 取 x-forwarded-for 首段)
|
|
122
|
-
evt("request", { reqId, hops, ip: reqIp, stream: Boolean(req?.body?.stream), prompt: summarizePrompt(req?.body), rawModel: policy.rawModel, requested, lockModel: lockModel || null });
|
|
123
|
-
if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel: policy.rawModel, requested });
|
|
124
|
-
if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
|
|
125
|
-
evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
|
|
126
|
-
|
|
127
|
-
if (plugins?.length && !lockModel) {
|
|
128
|
-
const sel = await runHook(plugins, "model:select", { reqId, requested, useAuto, order: [...order], hops, stream: Boolean(req?.body?.stream) });
|
|
129
|
-
if (sel.changed && Array.isArray(sel.value) && sel.value.length) {
|
|
130
|
-
order = sel.value.filter(Boolean);
|
|
131
|
-
if (!order.length) order = [requested];
|
|
132
|
-
evt("plugin-hook", { reqId, hook: "model:select", applied: true, order: [...order] });
|
|
133
|
-
}
|
|
134
|
-
for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// 客户端会话标识(opencode 插件 chat.headers 注入)→ 透传上游做粘性路由/缓存亲和;
|
|
138
|
-
// 无头时由 upstream.js 按对话首两条消息哈希兜底(不再每请求随机)
|
|
139
|
-
const clientSession = String(req?.headers?.["x-session-affinity"] || req?.headers?.["x-session-id"] || "").trim() || null;
|
|
140
|
-
// triedUrls 挂请求级并**按引用**共享:同一请求内"同一组员不重复试"的契约跨候选生效
|
|
141
|
-
// (peer 阶段各分支会 spread 出上下文副本,集合在这里建一次才不会各存一份)。
|
|
142
|
-
const handlerCtx = { reqId, api, model: null, body: req?.body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid, sessionId: clientSession, clientIp: reqIp, triedUrls: new Set(), isProbe };
|
|
143
|
-
if (clientSession) evt("client-session", { reqId, sessionId: clientSession.slice(0, 24) });
|
|
144
|
-
|
|
145
|
-
await engine.run({
|
|
146
|
-
reqId, startedAt, req, res, body: req?.body, policy,
|
|
147
|
-
useAuto, lockModel, requested, hops,
|
|
148
|
-
canFallback, canForwardPeers,
|
|
149
|
-
perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
|
|
150
|
-
auto, upstream, peers, groups, bus, token, plugins, logs,
|
|
151
|
-
order,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return { execute, _policy: analyzePolicy, _engine: engine };
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { analyzePolicy } from "./policy.js";
|
|
3
|
+
import { createEngine } from "./engine.js";
|
|
4
|
+
import { runHook } from "../plugins.js";
|
|
5
|
+
import { isFreeModel } from "../models.js";
|
|
6
|
+
import { clientIp, summarizePrompt } from "../routes/helpers.js";
|
|
7
|
+
import { formatTimeline } from "../timeline.js";
|
|
8
|
+
import { summarizeRequest, shouldTraceModel } from "../model-trace.js";
|
|
9
|
+
import { apiKindOf } from "../api-kind.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ChatPipeline 深模块门面 — 对外 execute(req) 单一 inlet
|
|
13
|
+
* 内部组合 Policy→Engine:解析 header/model → 产 order → 委托 engine 执行
|
|
14
|
+
* gateway 仅薄适配:readBody + request:received hook + 调 execute
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, token, plugins, maxHops } = {}) {
|
|
18
|
+
const engine = createEngine();
|
|
19
|
+
|
|
20
|
+
async function execute({ req, res } = {}) {
|
|
21
|
+
const startedAt = Date.now();
|
|
22
|
+
const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
23
|
+
// 入站协议门(chat|responses|messages|other):全场只在这里判一次,之后随 handlerCtx 透传;
|
|
24
|
+
// 各日志写点不得再各自匹配路径(第二份判定迟早漂移)—— 见 src/api-kind.js
|
|
25
|
+
const api = apiKindOf(req);
|
|
26
|
+
const perf0 = performance.now();
|
|
27
|
+
const stages = [];
|
|
28
|
+
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
29
|
+
|
|
30
|
+
const policy = analyzePolicy({ headers: req?.headers || {}, body: req?.body || {} });
|
|
31
|
+
const { requested, useAuto, autoProvider, lockModel, hops, shareKeys, workbuddyUid, aliasInfo } = policy;
|
|
32
|
+
mark("parsed");
|
|
33
|
+
if (aliasInfo) { try { res?.setHeader?.("x-mslxdff-alias", aliasInfo); } catch {} }
|
|
34
|
+
// mslxdff/ 前缀或 alias 命中时,把 body.model 改写为还原后的模型(与原 gateway 语义一致)
|
|
35
|
+
const timeline = { direct: [], peers: [], retries: 0, retryResult: null, result: null };
|
|
36
|
+
if (aliasInfo && req?.body && req.body.model !== requested) {
|
|
37
|
+
req.body = { ...req.body, model: requested };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 事件/日志帮手先于 order 推导声明:evt 在 auto-scope 分支即被使用,
|
|
41
|
+
// 声明置后会让 useAuto && autoProvider 的请求在 TDZ 上崩(线上 -chat 回退路径)
|
|
42
|
+
const logCall = (model, status) => logs?.appendCall({ reqId, api, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(req?.body?.stream), stages });
|
|
43
|
+
const logError = (model, status, message) => logs?.appendError({ reqId, api, model, auto: useAuto, status, message, stages });
|
|
44
|
+
const evt = (type, data) => {
|
|
45
|
+
const entry = { ts: Date.now(), reqId, type, ...data, api, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
46
|
+
const traceModel = entry.model || requested;
|
|
47
|
+
if (shouldTraceModel(type)) {
|
|
48
|
+
// Note: 模型日志只投影安全字段,不能把含 prompt 的 entry 原样下传 — 见 .agents/notes/implemented/feature/2026-09-25-model-trace-log.md
|
|
49
|
+
const safeTraceData = { ...entry };
|
|
50
|
+
if (safeTraceData.prompt !== undefined) delete safeTraceData.prompt;
|
|
51
|
+
logs?.appendModelTrace?.(traceModel, { type, reqId, model: traceModel, data: safeTraceData, request: type === "request" ? summarizeRequest(req?.body) : null, totalMs: entry.durationMs });
|
|
52
|
+
}
|
|
53
|
+
if (type === "peer-forward") timeline.peers.push({ peer: entry.peer, ok: entry.ok === true, status: entry.status, latencyMs: entry.latencyMs, message: entry.message || entry.error || "" });
|
|
54
|
+
if (type === "upstream-done" || type === "upstream-error") timeline.direct.push({ status: entry.status, reason: entry.message || entry.error || "" });
|
|
55
|
+
if (type === "peer-error") {
|
|
56
|
+
const known = timeline.peers.find((p) => p.peer === entry.peer);
|
|
57
|
+
if (known) { known.ok = false; known.status = entry.status ?? known.status; known.message = entry.message || entry.error || known.message; }
|
|
58
|
+
else timeline.peers.push({ peer: entry.peer, ok: false, status: entry.status, message: entry.message || entry.error || "" });
|
|
59
|
+
}
|
|
60
|
+
if (type === "empty-turn-retry") timeline.retries += 1;
|
|
61
|
+
// 空转重试必须交代结局:只记次数的话,"重试后救回来"和"重试到死"在日志里长得一模一样
|
|
62
|
+
if (type === "empty-turn-recovered") timeline.retryResult = "ok";
|
|
63
|
+
if (type === "empty-turn-exhausted") timeline.retryResult = timeline.retryResult || "lost";
|
|
64
|
+
if (type === "result" || (type === "client-response" && !timeline.result)) {
|
|
65
|
+
timeline.result = { status: entry.status, detail: entry.detail || null };
|
|
66
|
+
try { logs?.appendTimeline?.(formatTimeline({ reqId, model: entry.model || requested, ...timeline, totalMs: Date.now() - startedAt })); } catch {}
|
|
67
|
+
}
|
|
68
|
+
if (bus) bus.emit(entry);
|
|
69
|
+
logs?.appendEvent?.(entry);
|
|
70
|
+
};
|
|
71
|
+
const done = (info) => {
|
|
72
|
+
if (!plugins?.length) return;
|
|
73
|
+
runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(req?.body?.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// order 推导 + plugin model:select 可改
|
|
77
|
+
// 语义:指定模型 = 死锁单模型(本机→组员同款,挂了就报挂,不兜其他 picks);只有 auto 才轮 picks
|
|
78
|
+
// 指定模型的额度耗尽时**不换模型**:横向只在同模型的组员/组长间接力(本机出口 IP 耗尽 → 换组员机器),
|
|
79
|
+
// 模型永不漂移。详见 serial-trial 的 order 单元素路径与 peer 接力。
|
|
80
|
+
// x-mslxdff-auto-provider 头可把 auto 候选限定到单供应商(opencode=裸 id 免费池),-chat 默认带
|
|
81
|
+
let order;
|
|
82
|
+
if (lockModel) order = [requested];
|
|
83
|
+
else if (useAuto) {
|
|
84
|
+
let cands = auto ? await auto.candidates() : [""];
|
|
85
|
+
if (autoProvider) {
|
|
86
|
+
const before = cands.length;
|
|
87
|
+
cands = cands.filter((m) => (autoProvider === "opencode"
|
|
88
|
+
? !String(m).includes("/") && isFreeModel(m)
|
|
89
|
+
: String(m).startsWith(`${autoProvider}/`)));
|
|
90
|
+
evt("auto-scope", { reqId, provider: autoProvider, before, after: cands.length });
|
|
91
|
+
}
|
|
92
|
+
order = cands;
|
|
93
|
+
} else {
|
|
94
|
+
// 指定模型 = 死锁单模型:额度耗尽只换组员/组长(同模型换机器),**绝不换模型**。
|
|
95
|
+
// candidatesFor 的持久化副作用(把显式指定的模型加入勾选集,供日后 auto 用)必须保留——
|
|
96
|
+
// 见 auto.js:180「显式指定某模型 = 认可它,自动加入勾选集」,历史上删它会丢勾选记录。
|
|
97
|
+
// 但**不 await**(2026-10-22 日志定罪:loadList 拉全量模型目录烧 24s,写盘 persistPicks 仅 0ms)。
|
|
98
|
+
// 此路径 order 已定死 [requested],candidatesFor 返回值根本不被消费,只为触发"加入勾选集"副作用——
|
|
99
|
+
// 为副作用让主请求干等 24s 是自伤。改后台 fire-and-forget:勾选照样持久化,主请求零阻塞。
|
|
100
|
+
if (auto && requested) {
|
|
101
|
+
const tCf = performance.now();
|
|
102
|
+
Promise.resolve()
|
|
103
|
+
.then(() => auto.candidatesFor(requested))
|
|
104
|
+
.catch(() => {})
|
|
105
|
+
.finally(() => {
|
|
106
|
+
const ms = Math.round(performance.now() - tCf);
|
|
107
|
+
if (ms > 5000) console.log(`[pipeline-bg] candidatesFor 后台耗时 model=${requested} ms=${ms}(未阻塞主请求)`);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
order = [requested];
|
|
111
|
+
}
|
|
112
|
+
if (!order.length) order = [""];
|
|
113
|
+
const canFallback = order.length > 1;
|
|
114
|
+
// hi 探针(x-mslxdff-probe:1)只问「这台机器此刻能不能打通上游」,不代表真实业务:
|
|
115
|
+
// 它绝不能再触发本机的组员/宽带接力——否则探针会跟着走完整条接力链(最坏 45s 预算),
|
|
116
|
+
// 而调用方只看它几百毫秒内活不活。组员侧就地直连,成败如实回给调用方。
|
|
117
|
+
const isProbe = String(req?.headers?.["x-mslxdff-probe"] || "") === "1";
|
|
118
|
+
const canForwardPeers = !isProbe && Boolean(peers) && hops < (maxHops ?? 3);
|
|
119
|
+
mark("ordered");
|
|
120
|
+
|
|
121
|
+
const reqIp = clientIp(req); // 一次解析两处用:request 事件的 ip 字段 + 回路语料的 clientIp(routes/helpers.js 取 x-forwarded-for 首段)
|
|
122
|
+
evt("request", { reqId, hops, ip: reqIp, stream: Boolean(req?.body?.stream), prompt: summarizePrompt(req?.body), rawModel: policy.rawModel, requested, lockModel: lockModel || null });
|
|
123
|
+
if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel: policy.rawModel, requested });
|
|
124
|
+
if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
|
|
125
|
+
evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
|
|
126
|
+
|
|
127
|
+
if (plugins?.length && !lockModel) {
|
|
128
|
+
const sel = await runHook(plugins, "model:select", { reqId, requested, useAuto, order: [...order], hops, stream: Boolean(req?.body?.stream) });
|
|
129
|
+
if (sel.changed && Array.isArray(sel.value) && sel.value.length) {
|
|
130
|
+
order = sel.value.filter(Boolean);
|
|
131
|
+
if (!order.length) order = [requested];
|
|
132
|
+
evt("plugin-hook", { reqId, hook: "model:select", applied: true, order: [...order] });
|
|
133
|
+
}
|
|
134
|
+
for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 客户端会话标识(opencode 插件 chat.headers 注入)→ 透传上游做粘性路由/缓存亲和;
|
|
138
|
+
// 无头时由 upstream.js 按对话首两条消息哈希兜底(不再每请求随机)
|
|
139
|
+
const clientSession = String(req?.headers?.["x-session-affinity"] || req?.headers?.["x-session-id"] || "").trim() || null;
|
|
140
|
+
// triedUrls 挂请求级并**按引用**共享:同一请求内"同一组员不重复试"的契约跨候选生效
|
|
141
|
+
// (peer 阶段各分支会 spread 出上下文副本,集合在这里建一次才不会各存一份)。
|
|
142
|
+
const handlerCtx = { reqId, api, model: null, body: req?.body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid, sessionId: clientSession, clientIp: reqIp, triedUrls: new Set(), isProbe };
|
|
143
|
+
if (clientSession) evt("client-session", { reqId, sessionId: clientSession.slice(0, 24) });
|
|
144
|
+
|
|
145
|
+
await engine.run({
|
|
146
|
+
reqId, startedAt, req, res, body: req?.body, policy,
|
|
147
|
+
useAuto, lockModel, requested, hops,
|
|
148
|
+
canFallback, canForwardPeers,
|
|
149
|
+
perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
|
|
150
|
+
auto, upstream, peers, groups, bus, token, plugins, logs,
|
|
151
|
+
order,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { execute, _policy: analyzePolicy, _engine: engine };
|
|
156
156
|
}
|
|
@@ -1,84 +1,84 @@
|
|
|
1
|
-
import { normalizeModel } from "../reasoning.js";
|
|
2
|
-
import { isAutoModel } from "../auto.js";
|
|
3
|
-
import { parseHops } from "../routes/helpers.js";
|
|
4
|
-
import { parseShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
|
|
5
|
-
import { normalizeFullId, getModelAlias } from "../providers/model-id.js";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* PolicyStage 纯函数 — 解析 model 别名 / 白名单前置 / header 透传
|
|
9
|
-
* 零网络副作用,输入 headers+body,输出 PolicyResult
|
|
10
|
-
*/
|
|
11
|
-
export function analyzePolicy({ headers = {}, body = {} } = {}) {
|
|
12
|
-
const hops = parseHops(headers["x-mslxdff-hops"] || headers["X-Mslxdff-Hops"] || "");
|
|
13
|
-
const shareKeys = parseShareKeysHeader(headers[SHARE_KEYS_HEADER] || headers["x-mslxdff-share-keys"] || "");
|
|
14
|
-
const workbuddyUid = (headers["x-mslxdff-workbuddy-uid"] || headers["x-workbuddy-uid"] || "").toString().trim();
|
|
15
|
-
const lockModel = (headers["x-mslxdff-model-lock"] || headers["X-Mslxdff-Model-Lock"] || "").toString();
|
|
16
|
-
// auto 范围限定:x-mslxdff-auto-provider: opencode → auto 候选只留该供应商(opencode=裸 id 免费池)
|
|
17
|
-
const autoProvider = (headers["x-mslxdff-auto-provider"] || headers["X-Mslxdff-Auto-Provider"] || "").toString().trim().toLowerCase() || null;
|
|
18
|
-
const rawModel = body.model || "";
|
|
19
|
-
|
|
20
|
-
let normalizedRequested = normalizeModel(lockModel || rawModel || "");
|
|
21
|
-
// [1m] 后缀回退(Claude Desktop 1M 条目 / Claude Code 1M 记法,ADR-0056):alias 直查未命中时
|
|
22
|
-
// 剥去尾部 [1m] 再查一次。仅用于本次查找——不回写、不注册 alias,未命中路径逐字不变(design D2)。
|
|
23
|
-
// 两个 alias 查找点(首轮 + mslxdff/ 前缀二段)共用同一回退,否则 `mslxdff/<槽位>[1m]` 不等价(评审 1 P1)。
|
|
24
|
-
const resolveAlias = (id) => {
|
|
25
|
-
const hit = getModelAlias(id);
|
|
26
|
-
if (hit) return hit;
|
|
27
|
-
return /\[1m\]$/i.test(id) ? getModelAlias(id.replace(/\[1m\]$/i, "")) : null;
|
|
28
|
-
};
|
|
29
|
-
let aliasResolved = resolveAlias(normalizedRequested);
|
|
30
|
-
if (aliasResolved) {
|
|
31
|
-
normalizedRequested = aliasResolved;
|
|
32
|
-
}
|
|
33
|
-
let requested = normalizedRequested;
|
|
34
|
-
let aliasInfo = null;
|
|
35
|
-
|
|
36
|
-
if (requested.startsWith("mslxdff/")) {
|
|
37
|
-
const rawPart = requested.slice("mslxdff/".length);
|
|
38
|
-
aliasInfo = `${requested} -> ${rawPart} (mslxdff provider stripped)`;
|
|
39
|
-
requested = rawPart;
|
|
40
|
-
const alias2 = resolveAlias(requested);
|
|
41
|
-
if (alias2) {
|
|
42
|
-
aliasInfo = `${rawModel} -> ${alias2} (mslxdff + alias)`;
|
|
43
|
-
requested = alias2;
|
|
44
|
-
}
|
|
45
|
-
} else if (aliasResolved) {
|
|
46
|
-
// 非 mslxdff 的 dash 形态已在首轮 aliasResolved 处理
|
|
47
|
-
aliasInfo = aliasResolved !== (lockModel || rawModel) ? `${rawModel} -> ${aliasResolved} (alias)` : null;
|
|
48
|
-
if (!aliasInfo) aliasInfo = null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// workbuddy <uid>:model 形式的 uid 钉死在 normalizeFullId 侧处理,这里透传原始 requested
|
|
52
|
-
// 若 requested 含 workbuddy/ 前缀且含 :,则尝试提取 uid
|
|
53
|
-
let extractedUid = workbuddyUid;
|
|
54
|
-
if (!extractedUid && requested.startsWith("workbuddy/") && requested.includes(":")) {
|
|
55
|
-
const after = requested.slice("workbuddy/".length);
|
|
56
|
-
const uidPart = after.split(":")[0];
|
|
57
|
-
if (uidPart) extractedUid = uidPart;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const useAuto = isAutoModel(requested);
|
|
61
|
-
|
|
62
|
-
// 对 workbuddy 前缀的 model,做 normalizeFullId 归一(剥 uid 供上游)
|
|
63
|
-
// 但保留 requested 为完整带前缀形态,供 serial-trial 做 ViaRoute 判定与组员路由
|
|
64
|
-
let normalizedForUpstream = requested;
|
|
65
|
-
try {
|
|
66
|
-
const norm = normalizeFullId(requested);
|
|
67
|
-
if (norm && norm.raw) normalizedForUpstream = norm.raw ? `${norm.provider ? norm.provider + "/" : ""}${norm.raw}` : requested;
|
|
68
|
-
} catch {}
|
|
69
|
-
|
|
70
|
-
return {
|
|
71
|
-
rawModel,
|
|
72
|
-
requested,
|
|
73
|
-
normalizedRequested,
|
|
74
|
-
normalizedForUpstream,
|
|
75
|
-
aliasInfo,
|
|
76
|
-
useAuto,
|
|
77
|
-
autoProvider,
|
|
78
|
-
shareKeys,
|
|
79
|
-
workbuddyUid: extractedUid,
|
|
80
|
-
lockModel,
|
|
81
|
-
hops,
|
|
82
|
-
bodyModel: body.model || null,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
1
|
+
import { normalizeModel } from "../reasoning.js";
|
|
2
|
+
import { isAutoModel } from "../auto.js";
|
|
3
|
+
import { parseHops } from "../routes/helpers.js";
|
|
4
|
+
import { parseShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
|
|
5
|
+
import { normalizeFullId, getModelAlias } from "../providers/model-id.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PolicyStage 纯函数 — 解析 model 别名 / 白名单前置 / header 透传
|
|
9
|
+
* 零网络副作用,输入 headers+body,输出 PolicyResult
|
|
10
|
+
*/
|
|
11
|
+
export function analyzePolicy({ headers = {}, body = {} } = {}) {
|
|
12
|
+
const hops = parseHops(headers["x-mslxdff-hops"] || headers["X-Mslxdff-Hops"] || "");
|
|
13
|
+
const shareKeys = parseShareKeysHeader(headers[SHARE_KEYS_HEADER] || headers["x-mslxdff-share-keys"] || "");
|
|
14
|
+
const workbuddyUid = (headers["x-mslxdff-workbuddy-uid"] || headers["x-workbuddy-uid"] || "").toString().trim();
|
|
15
|
+
const lockModel = (headers["x-mslxdff-model-lock"] || headers["X-Mslxdff-Model-Lock"] || "").toString();
|
|
16
|
+
// auto 范围限定:x-mslxdff-auto-provider: opencode → auto 候选只留该供应商(opencode=裸 id 免费池)
|
|
17
|
+
const autoProvider = (headers["x-mslxdff-auto-provider"] || headers["X-Mslxdff-Auto-Provider"] || "").toString().trim().toLowerCase() || null;
|
|
18
|
+
const rawModel = body.model || "";
|
|
19
|
+
|
|
20
|
+
let normalizedRequested = normalizeModel(lockModel || rawModel || "");
|
|
21
|
+
// [1m] 后缀回退(Claude Desktop 1M 条目 / Claude Code 1M 记法,ADR-0056):alias 直查未命中时
|
|
22
|
+
// 剥去尾部 [1m] 再查一次。仅用于本次查找——不回写、不注册 alias,未命中路径逐字不变(design D2)。
|
|
23
|
+
// 两个 alias 查找点(首轮 + mslxdff/ 前缀二段)共用同一回退,否则 `mslxdff/<槽位>[1m]` 不等价(评审 1 P1)。
|
|
24
|
+
const resolveAlias = (id) => {
|
|
25
|
+
const hit = getModelAlias(id);
|
|
26
|
+
if (hit) return hit;
|
|
27
|
+
return /\[1m\]$/i.test(id) ? getModelAlias(id.replace(/\[1m\]$/i, "")) : null;
|
|
28
|
+
};
|
|
29
|
+
let aliasResolved = resolveAlias(normalizedRequested);
|
|
30
|
+
if (aliasResolved) {
|
|
31
|
+
normalizedRequested = aliasResolved;
|
|
32
|
+
}
|
|
33
|
+
let requested = normalizedRequested;
|
|
34
|
+
let aliasInfo = null;
|
|
35
|
+
|
|
36
|
+
if (requested.startsWith("mslxdff/")) {
|
|
37
|
+
const rawPart = requested.slice("mslxdff/".length);
|
|
38
|
+
aliasInfo = `${requested} -> ${rawPart} (mslxdff provider stripped)`;
|
|
39
|
+
requested = rawPart;
|
|
40
|
+
const alias2 = resolveAlias(requested);
|
|
41
|
+
if (alias2) {
|
|
42
|
+
aliasInfo = `${rawModel} -> ${alias2} (mslxdff + alias)`;
|
|
43
|
+
requested = alias2;
|
|
44
|
+
}
|
|
45
|
+
} else if (aliasResolved) {
|
|
46
|
+
// 非 mslxdff 的 dash 形态已在首轮 aliasResolved 处理
|
|
47
|
+
aliasInfo = aliasResolved !== (lockModel || rawModel) ? `${rawModel} -> ${aliasResolved} (alias)` : null;
|
|
48
|
+
if (!aliasInfo) aliasInfo = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// workbuddy <uid>:model 形式的 uid 钉死在 normalizeFullId 侧处理,这里透传原始 requested
|
|
52
|
+
// 若 requested 含 workbuddy/ 前缀且含 :,则尝试提取 uid
|
|
53
|
+
let extractedUid = workbuddyUid;
|
|
54
|
+
if (!extractedUid && requested.startsWith("workbuddy/") && requested.includes(":")) {
|
|
55
|
+
const after = requested.slice("workbuddy/".length);
|
|
56
|
+
const uidPart = after.split(":")[0];
|
|
57
|
+
if (uidPart) extractedUid = uidPart;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const useAuto = isAutoModel(requested);
|
|
61
|
+
|
|
62
|
+
// 对 workbuddy 前缀的 model,做 normalizeFullId 归一(剥 uid 供上游)
|
|
63
|
+
// 但保留 requested 为完整带前缀形态,供 serial-trial 做 ViaRoute 判定与组员路由
|
|
64
|
+
let normalizedForUpstream = requested;
|
|
65
|
+
try {
|
|
66
|
+
const norm = normalizeFullId(requested);
|
|
67
|
+
if (norm && norm.raw) normalizedForUpstream = norm.raw ? `${norm.provider ? norm.provider + "/" : ""}${norm.raw}` : requested;
|
|
68
|
+
} catch {}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
rawModel,
|
|
72
|
+
requested,
|
|
73
|
+
normalizedRequested,
|
|
74
|
+
normalizedForUpstream,
|
|
75
|
+
aliasInfo,
|
|
76
|
+
useAuto,
|
|
77
|
+
autoProvider,
|
|
78
|
+
shareKeys,
|
|
79
|
+
workbuddyUid: extractedUid,
|
|
80
|
+
lockModel,
|
|
81
|
+
hops,
|
|
82
|
+
bodyModel: body.model || null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -15,6 +15,7 @@ import { isEmptyTurnError } from "../routes/chat/relay-pipeline.js";
|
|
|
15
15
|
import { emptyRetryCfg, emptyRaiseCap, withRaisedMaxTokens, withOutputFloor, emptyNudgeCfg, withEmptyNudge, computeNextDelay, emptyTurnBudgetMs, emptyTurnMinRaiseTo } from "./empty-turn.js";
|
|
16
16
|
import { classifyTerminalUpstream, readVerdictText, terminalShortcircuitOff } from "./terminal-verdict.js";
|
|
17
17
|
import { forwardOrGuard } from "./context-forward.js";
|
|
18
|
+
import { chatWith5xxRetry, http5xxRetryCfg } from "./upstream-5xx-retry.js";
|
|
18
19
|
|
|
19
20
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
21
|
|
|
@@ -111,7 +112,14 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
111
112
|
const tUp = performance.now();
|
|
112
113
|
evt("upstream-try", { reqId, model, attempt: idx + 1, emptyRetry: emptyRetried, payload: summarizeRequest(forwarded) });
|
|
113
114
|
try {
|
|
114
|
-
|
|
115
|
+
// 指定模型:5xx 先原地等 5s 重试(默认 3 次),仍 5xx 才交给上层换 1 个节点;auto 原样直发。
|
|
116
|
+
// 见 upstream-5xx-retry.js(用户口径 2026-10-23)。
|
|
117
|
+
upRes = await chatWith5xxRetry({
|
|
118
|
+
call: () => upstream.chat(forwarded, chatOptsArg),
|
|
119
|
+
singleModel: !useAuto,
|
|
120
|
+
evt, reqId, model,
|
|
121
|
+
isClosed: () => Boolean(res?.destroyed || res?.writableEnded),
|
|
122
|
+
});
|
|
115
123
|
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) });
|
|
116
124
|
} catch (err) {
|
|
117
125
|
if (auto) await auto.recordError(model, { message: errMsg(err) });
|
|
@@ -258,16 +266,21 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
258
266
|
}
|
|
259
267
|
// 转发闸门(详见 context-forward.js):被拦即收场,不许换路重试——同一份大上下文换个目标照样撑爆。
|
|
260
268
|
const gc = { res, body, order, handlerCtx, evt, logCall, logError, mark, perf0, stages, done, requested, useAuto, model, exhaustedLocal };
|
|
269
|
+
// 指定模型 5xx 重试耗尽后:只换 1 个节点(同模型换机器),该节点仍失败就直接收场,不再走宽带。
|
|
270
|
+
// 429 不在此列——额度按出口 IP 计,等待无意义,仍按原逻辑逐个问节点。
|
|
271
|
+
// retries=0 是整套功能的逃生阀:连「只换一个节点」一起关,回到旧的 5xx 全节点路径。
|
|
272
|
+
const pinned5xx = !useAuto && http5xxRetryCfg().retries > 0 && Number(lastErr?.status) >= 500;
|
|
273
|
+
const peerCtx = pinned5xx ? { ...handlerCtx, singlePeer: true } : handlerCtx;
|
|
261
274
|
if (canForwardPeers) {
|
|
262
275
|
if (!shouldUseGroupForModel(model)) {
|
|
263
276
|
evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (peer)` });
|
|
264
277
|
} else {
|
|
265
|
-
const fr = await forwardOrGuard(() => peerRelay({ model, body, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, mark, perf0, attemptStartMs: performance.now(), stages, startedAt, plugins, res }), gc);
|
|
278
|
+
const fr = await forwardOrGuard(() => peerRelay({ model, body, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx: peerCtx, evt, logCall, mark, perf0, attemptStartMs: performance.now(), stages, startedAt, plugins, res }), gc);
|
|
266
279
|
if (fr.done) return fr;
|
|
267
280
|
lastErr = fr.lastErr ?? lastErr;
|
|
268
281
|
}
|
|
269
282
|
}
|
|
270
|
-
if (groups) {
|
|
283
|
+
if (groups && !pinned5xx) {
|
|
271
284
|
if (!shouldUseGroupForModel(model)) {
|
|
272
285
|
evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (broadband)` });
|
|
273
286
|
} else {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// 指定模型撞 5xx 的原地重试(用户口径,2026-10-23):
|
|
2
|
+
// 5xx → 等 5s → 同模型重拉,连续重试 3 次;仍 5xx 才交给上层换 1 个节点。
|
|
3
|
+
// 为什么不在 auto 里用:auto 的候选是不同模型池,换模型比等待更划算;指定模型的模型不能换,
|
|
4
|
+
// 只能先等上游缓过来,避免「一失败就换节点」把一次故障放大成整组节点同时打上游。
|
|
5
|
+
// 只处理 HTTP 5xx;429(按出口 IP 的额度)与 4xx 一律不等待,交回上层走原有路径。
|
|
6
|
+
const DEFAULTS = Object.freeze({ retries: 3, delayMs: 5000 });
|
|
7
|
+
|
|
8
|
+
function envIntOrZero(env, name, fallback) {
|
|
9
|
+
const raw = String(env[name] ?? "").trim();
|
|
10
|
+
if (raw === "") return fallback;
|
|
11
|
+
const n = Number(raw);
|
|
12
|
+
return Number.isInteger(n) && n >= 0 ? n : fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 读配置:次数与间隔都允许 0(0 次 = 关闭;0ms = 不等待)。空串/非法值回落默认。 */
|
|
16
|
+
export function http5xxRetryCfg(env = process.env) {
|
|
17
|
+
return {
|
|
18
|
+
retries: envIntOrZero(env, "MSLXDFF_5XX_RETRIES", DEFAULTS.retries),
|
|
19
|
+
delayMs: envIntOrZero(env, "MSLXDFF_5XX_RETRY_DELAY_MS", DEFAULTS.delayMs),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isHttp5xx(status) {
|
|
24
|
+
return Number.isInteger(Number(status)) && Number(status) >= 500 && Number(status) <= 599;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sleep(ms) {
|
|
28
|
+
return ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 上一发失败的 5xx 响应体没有被业务消费(判据只读 clone),重试前主动取消,防连接空占。
|
|
32
|
+
function cancelBody(res) {
|
|
33
|
+
try {
|
|
34
|
+
const p = res?.body?.cancel?.();
|
|
35
|
+
if (p && typeof p.catch === "function") p.catch(() => {});
|
|
36
|
+
} catch { /* 取消失败不影响重试 */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 包一层 upstream.chat:指定模型 + 5xx 时原地退避重试。
|
|
41
|
+
* @param {object} p
|
|
42
|
+
* @param {() => Promise<any>} p.call 真正发上游(每次调用都重新发)
|
|
43
|
+
* @param {boolean} p.singleModel 是否指定模型(false=auto,原样返回)
|
|
44
|
+
* @param {object} [p.cfg] http5xxRetryCfg()
|
|
45
|
+
* @param {(name:string, data:object)=>void} [p.evt]
|
|
46
|
+
* @param {string} [p.reqId]
|
|
47
|
+
* @param {string} [p.model]
|
|
48
|
+
* @param {() => boolean} [p.isClosed] 客户端是否已断开;断开即停止重试
|
|
49
|
+
* @returns {Promise<any>} 最后一发响应(可能是 5xx,也可能是成功)
|
|
50
|
+
*/
|
|
51
|
+
export async function chatWith5xxRetry({
|
|
52
|
+
call,
|
|
53
|
+
singleModel = false,
|
|
54
|
+
cfg = http5xxRetryCfg(),
|
|
55
|
+
evt,
|
|
56
|
+
reqId,
|
|
57
|
+
model,
|
|
58
|
+
isClosed,
|
|
59
|
+
} = {}) {
|
|
60
|
+
let res = await call();
|
|
61
|
+
if (!singleModel || !cfg.retries) return res;
|
|
62
|
+
let retried = 0;
|
|
63
|
+
while (isHttp5xx(res?.status) && retried < cfg.retries) {
|
|
64
|
+
if (typeof isClosed === "function" && isClosed()) break;
|
|
65
|
+
retried += 1;
|
|
66
|
+
cancelBody(res);
|
|
67
|
+
try { evt?.("upstream-5xx-retry", { reqId, model, retry: retried, max: cfg.retries, delayMs: cfg.delayMs, status: res.status }); } catch {}
|
|
68
|
+
await sleep(cfg.delayMs);
|
|
69
|
+
res = await call();
|
|
70
|
+
}
|
|
71
|
+
return res;
|
|
72
|
+
}
|