mslxdff 0.1.234 → 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/docs/cli_help_mini.md
CHANGED
|
@@ -92,6 +92,7 @@
|
|
|
92
92
|
| 空轮留口与终局收场(ADR-0043) | `MSLXDFF_EMPTY_TURN_HOLD_END`(默认开)/`MSLXDFF_EMPTY_TURN_BUDGET_MS`(45000)/`MSLXDFF_EMPTY_TURN_MIN_RAISE_TO`(16384) | 「200 但零正文」的空轮曾先 `res.end()` 再判定,重拉/换候选写的是已终结响应(客户端只看到空白)。现把「可撤销」定义为**尚未提交模型产出**:空 delta 帧与 `[DONE]` 暂扣不写、流结束不封口,交回重拉续写同一条连接;思考已刷出的一类撤不回 → 不留口,改在流尾补 `EMPTY_MODEL_RESPONSE` SSE 错误帧 + `[DONE]`(客户端看到具体原因而非莫名空白)。终局按 `res.headersSent` 分叉(未 flush→502 JSON / 已 flush→SSE 错误帧),单一出口 + `writableEnded` 守卫保证恰好一次;预算=请求级跨候选累计封顶;`MIN_RAISE_TO` 给"客户端没设额度"的主流空轮兜底抬一次(`0`=回旧口径不发明);`HOLD_END=0` 逐字节回退旧行为 |
|
|
93
93
|
| 首发输出额度托底 | `MSLXDFF_EMPTY_TURN_MIN_TOKENS`(缺省 8192,`0`/负数=关)/`MSLXDFF_EMPTY_TURN_TINY_MAX`(缺省 64) | 客户端额度 < 门槛(算爆,实测 `max_completion_tokens=1` 被上游截成 1 token 碎渣、agent 循环停摆)→ 首发上游前托到托底值;只加不减、没设额度不发明、0/负数(上游默认语义)不托;serial-trial、auto-race 与宽带成员三处 `upstream.chat` 出口都托;事件 `output-floor`(`key/raiseFrom/raiseTo`) |
|
|
94
94
|
| 免费层形状门禁(ADR-0020) | `MSLXDFF_FREE_LANE`(缺省 1)/`MSLXDFF_FREE_LANE_DEBUG=1` | zen 免费模型必须 `stream:true` + tools 含 bash/edit/glob/grep/read 五名且 UA≥`opencode/1.18.0`(否则 403/426);`src/free-lane.js` 自动补形状、非流式聚合回 JSON;`0`=关(逃生阀),DEBUG 打 `[free-lane]` 日志 |
|
|
95
|
+
| 指定模型 5xx 退避(ADR-0068) | `MSLXDFF_5XX_RETRIES`(缺省 `3`)/`MSLXDFF_5XX_RETRY_DELAY_MS`(缺省 `5000`) | 指定模型(非 auto)撞 5xx 后同模型等 5s 重拉、连续 3 次;仍 5xx 才换 1 个节点(组长优先→延迟升序),该节点仍 5xx 直接收场,不再全节点 fan-out、不走宽带;`RETRIES=0`=整套关闭(回旧路径);429/4xx 与 auto 不适用;事件 `upstream-5xx-retry` |
|
|
95
96
|
| WorkBuddy 摘除 | `-workbuddy remove <uid> [--keep-file]` / `-wb remove` | 按 `uid`(前缀6位)摘除,删 `keys/auths` 与 `auths/workbuddy-<uid>.json` |
|
|
96
97
|
| 定号消耗 | `header x-mslxdff-workbuddy-uid: <uid>` 或 `model workbuddy/<uid>:<model>` | 钉死指定账号消耗,`x-mslxdff-workbuddy-uid` 回显实际账号 |
|
|
97
98
|
| 同步 WB | `-setto workbuddy [modelId]` | 同步到 WorkBuddy(原子写 `~/.workbuddy/models.json`,`127.0.0.1/v1`,多模型累积;picks 非空时摘除失效本地条目) |
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -30,10 +30,20 @@ export async function handlePeerRelay({
|
|
|
30
30
|
const peerErrors = [];
|
|
31
31
|
// 三批共享同一 peer 阶段总预算(同 hedge:旧行为是每批各吃满一次等头超时,串行叠加到 90s+)
|
|
32
32
|
const pctx = peerStageCtx({ ...handlerCtx, peerErrors });
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
// singlePeer:指定模型 5xx 重试耗尽后只换 1 个节点,该节点仍失败就交回上层收场。
|
|
34
|
+
// 选人仍按「组长第一 → 延迟升序」,只是取链头一个;三批候选不叠加,也不进 IPv6 阶段。
|
|
35
|
+
const singlePeer = handlerCtx?.singlePeer === true;
|
|
36
|
+
let win = null;
|
|
37
|
+
if (singlePeer) {
|
|
38
|
+
pctx.peerMaxPeers = 1;
|
|
39
|
+
const pick = peers.ordered()[0] || peers.orderedByLastError?.()?.[0] || peers.coolingByLastError?.()?.[0] || null;
|
|
40
|
+
win = pick ? await race([pick], pctx) : null;
|
|
41
|
+
} else {
|
|
42
|
+
win =
|
|
43
|
+
(await race(peers.ordered(), pctx)) ||
|
|
44
|
+
(await race(peers.orderedByLastError(), pctx)) ||
|
|
45
|
+
(await race(peers.coolingByLastError(), pctx));
|
|
46
|
+
}
|
|
37
47
|
if (!win) {
|
|
38
48
|
evt("peer-race-lose", { reqId: handlerCtx.reqId, model });
|
|
39
49
|
// 组员全失败:把每个组员的真实返回汇总给调用者(否则只剩一个无信息的 429/502)
|
|
@@ -68,8 +68,11 @@ export async function serialTryPeers({ candidates, v6Candidates = [], ctx, tried
|
|
|
68
68
|
// 两段链:先 IPv4(组长→组员延迟升序),IPv4 全败才进 IPv6(同样组长优先 + hasV6 过滤)。
|
|
69
69
|
// 只保留本次还没试过的。
|
|
70
70
|
const fresh = (list) => list.filter((p) => !tried.has(p.url));
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// 指定模型 5xx 场景只要 1 个节点:peerMaxPeers=1 时 IPv4 链截断、IPv6 阶段直接跳过。
|
|
72
|
+
// 缺省 Infinity = 现有行为(整链 + v6 阶段)。
|
|
73
|
+
const maxPeers = Number(ctx.peerMaxPeers) > 0 ? Math.floor(Number(ctx.peerMaxPeers)) : Infinity;
|
|
74
|
+
const ipv4Chain = filterSlow(fresh(candidates)).slice(0, maxPeers);
|
|
75
|
+
const ipv6Chain = Number.isFinite(maxPeers) ? [] : filterSlow(fresh(v6Candidates));
|
|
73
76
|
|
|
74
77
|
// —— 预取 + 探针(用户 2026-10-20 口径)——
|
|
75
78
|
// 「某个节点当备选的时候打一个 hi,准备轮到它了也打一个 hi;hi 都回不来就当它不行,直接换。」
|