mslxdff 0.1.188 → 0.1.197

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 (107) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/package.json +1 -1
  3. package/src/anthropic/count-tokens.js +10 -3
  4. package/src/auto.js +254 -254
  5. package/src/bench/cline-bench.js +42 -42
  6. package/src/bench/probe.js +70 -70
  7. package/src/bench/report.js +162 -162
  8. package/src/bench/runner.js +77 -77
  9. package/src/bench/via-probe.js +124 -124
  10. package/src/bench/via-routes.js +87 -87
  11. package/src/bench/workbuddy-bench.js +54 -54
  12. package/src/chat/engine.js +160 -160
  13. package/src/chat/gateway.js +163 -163
  14. package/src/chat/orchestrator.js +234 -234
  15. package/src/chat/prompt.js +70 -70
  16. package/src/chat/repl.js +88 -88
  17. package/src/chat/terminal.js +135 -135
  18. package/src/chat/tools.js +306 -306
  19. package/src/chat-pipeline/index.js +173 -173
  20. package/src/chat-pipeline/policy.js +84 -84
  21. package/src/chat-pipeline/terminal-verdict.js +15 -6
  22. package/src/cli/commands/group.js +249 -249
  23. package/src/cli/commands/model/picks.js +50 -50
  24. package/src/cli/commands/provider/bench-via.js +247 -247
  25. package/src/cli/commands/provider/bench.js +141 -141
  26. package/src/cli/commands/provider/index.js +136 -136
  27. package/src/cli/commands/provider/models.js +145 -145
  28. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  29. package/src/cli/commands/provider/raccoon-login.js +37 -16
  30. package/src/cli/commands/sync.js +310 -310
  31. package/src/cli/status.js +279 -279
  32. package/src/daemon.js +232 -232
  33. package/src/logs.js +11 -1
  34. package/src/model-capabilities/enrich.js +86 -86
  35. package/src/model-capabilities/index.js +183 -183
  36. package/src/model-capabilities/parse.js +70 -70
  37. package/src/model-trace.js +8 -1
  38. package/src/models.js +225 -225
  39. package/src/providers/cline/auth.js +228 -228
  40. package/src/providers/cline/chat.js +307 -307
  41. package/src/providers/cline.js +2 -2
  42. package/src/providers/codearts/chat.js +182 -182
  43. package/src/providers/globalqwenwork/cosy.js +123 -123
  44. package/src/providers/globalqwenwork/crypto.js +218 -218
  45. package/src/providers/globalqwenwork/http.js +20 -20
  46. package/src/providers/globalqwenwork/rsa.js +54 -54
  47. package/src/providers/globalqwenwork/sse.js +268 -268
  48. package/src/providers/globalqwenwork/stream.js +130 -130
  49. package/src/providers/globalqwenwork/upstream.js +122 -122
  50. package/src/providers/keyring.js +60 -60
  51. package/src/providers/qoder/chat.js +203 -203
  52. package/src/providers/qoder/index.js +249 -249
  53. package/src/providers/qoder/sse.js +145 -145
  54. package/src/providers/qwenwork/account-store.js +133 -133
  55. package/src/providers/qwenwork/constants.js +67 -67
  56. package/src/providers/qwenwork/cosy.js +120 -120
  57. package/src/providers/qwenwork/crypto.js +218 -218
  58. package/src/providers/qwenwork/http.js +20 -20
  59. package/src/providers/qwenwork/index.js +337 -337
  60. package/src/providers/qwenwork/payload.js +144 -144
  61. package/src/providers/qwenwork/rsa.js +54 -54
  62. package/src/providers/qwenwork/sse.js +268 -268
  63. package/src/providers/qwenwork/stream.js +130 -130
  64. package/src/providers/qwenwork/upstream.js +120 -120
  65. package/src/providers/qwenwork.js +1 -1
  66. package/src/providers/raccoon/chat.js +28 -12
  67. package/src/providers/raccoon/const.js +35 -6
  68. package/src/providers/raccoon/credits.js +26 -12
  69. package/src/providers/raccoon/envelope.js +10 -2
  70. package/src/providers/raccoon/login.js +114 -30
  71. package/src/providers/registry.js +79 -79
  72. package/src/providers/workbuddy/auth.js +26 -1
  73. package/src/providers/workbuddy/chat.js +264 -248
  74. package/src/providers/workbuddy/index.js +1 -1
  75. package/src/providers/workbuddy/reshape.js +152 -152
  76. package/src/providers/workbuddy/rotation-log.js +30 -2
  77. package/src/providers/workbuddy.js +2 -2
  78. package/src/reasoning.js +32 -32
  79. package/src/responses/translate.js +5 -1
  80. package/src/routes/chat/gateway.js +46 -46
  81. package/src/routes/chat/relay-pipeline.js +282 -282
  82. package/src/routes/chat/via-route-handler.js +146 -146
  83. package/src/routes/hedge.js +260 -253
  84. package/src/routes/models-route.js +167 -167
  85. package/src/routes/peers.js +335 -335
  86. package/src/routes/stream-hold.js +66 -15
  87. package/src/routes/stream-scan.js +24 -4
  88. package/src/routes/stream.js +347 -343
  89. package/src/runtime/bootstrap.js +67 -67
  90. package/src/runtime/provider-gate.js +36 -36
  91. package/src/runtime/providers-setup.js +168 -168
  92. package/src/server.js +78 -78
  93. package/src/state/schemas/allowlist.js +92 -92
  94. package/src/sync-opencode.js +280 -280
  95. package/src/transport/index.js +244 -244
  96. package/src/transport/pool.js +56 -56
  97. package/src/transport/retry.js +24 -24
  98. package/src/transport/sse.js +93 -93
  99. package/src/upstream-engine/sdk/sse.js +15 -1
  100. package/src/upstream-probe/display.js +52 -52
  101. package/src/upstream-probe/probe.js +49 -49
  102. package/src/upstream-probe/rotate.js +110 -110
  103. package/src/upstream-probe/start.js +45 -45
  104. package/src/upstream.js +293 -293
  105. package/docs/cli_help_mini.md +0 -145
  106. package/src/providers/AGENTS.md +0 -49
  107. package/src/routes/AGENTS.md +0 -37
@@ -1,174 +1,174 @@
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
- * 指定 free 模型时是否允许横向切同池兄弟(BUG-7)。
18
- * 缺省开:opencode 免费池的裸 id 模型(`mimo-v2.6-flash-free` 等)语义上就是"免费额度内随便用",
19
- * 单个模型额度耗尽不该让整条链路失败。逃生阀 `MSLXDFF_FREE_POOL_FALLBACK=0` 回到"显式指定即锁死"。
20
- * 只在**非 lockModel**(即不经组内 model-lock 转发)时生效。
21
- */
22
- // 同池互备最多横向试几个(含首个指定的那个):额度类失败常见于"整池都满",
23
- // 扫全池只会把快速失败拖成分钟级,试前几个足够区分"这个模型满"与"这池满"。
24
- const FREE_POOL_MAX = 4;
25
-
26
- export function freePoolFallbackOn(env = process.env) {
27
- const raw = env.MSLXDFF_FREE_POOL_FALLBACK;
28
- if (raw === undefined || raw === null || raw === "") return true;
29
- const s = String(raw).trim().toLowerCase();
30
- return !(s === "0" || s === "false" || s === "off" || s === "no");
31
- }
32
-
33
- export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, token, plugins, maxHops } = {}) {
34
- const engine = createEngine();
35
-
36
- async function execute({ req, res } = {}) {
37
- const startedAt = Date.now();
38
- const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
39
- // 入站协议门(chat|responses|messages|other):全场只在这里判一次,之后随 handlerCtx 透传;
40
- // 各日志写点不得再各自匹配路径(第二份判定迟早漂移)—— 见 src/api-kind.js
41
- const api = apiKindOf(req);
42
- const perf0 = performance.now();
43
- const stages = [];
44
- const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
45
-
46
- const policy = analyzePolicy({ headers: req?.headers || {}, body: req?.body || {} });
47
- const { requested, useAuto, autoProvider, lockModel, hops, shareKeys, workbuddyUid, aliasInfo } = policy;
48
- mark("parsed");
49
- if (aliasInfo) { try { res?.setHeader?.("x-mslxdff-alias", aliasInfo); } catch {} }
50
- // mslxdff/ 前缀或 alias 命中时,把 body.model 改写为还原后的模型(与原 gateway 语义一致)
51
- const timeline = { direct: [], peers: [], retries: 0, retryResult: null, result: null };
52
- if (aliasInfo && req?.body && req.body.model !== requested) {
53
- req.body = { ...req.body, model: requested };
54
- }
55
-
56
- // 事件/日志帮手先于 order 推导声明:evt 在 auto-scope 分支即被使用,
57
- // 声明置后会让 useAuto && autoProvider 的请求在 TDZ 上崩(线上 -chat 回退路径)
58
- const logCall = (model, status) => logs?.appendCall({ reqId, api, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(req?.body?.stream), stages });
59
- const logError = (model, status, message) => logs?.appendError({ reqId, api, model, auto: useAuto, status, message, stages });
60
- const evt = (type, data) => {
61
- const entry = { ts: Date.now(), reqId, type, ...data, api, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
62
- const traceModel = entry.model || requested;
63
- if (shouldTraceModel(type)) {
64
- // Note: 模型日志只投影安全字段,不能把含 prompt 的 entry 原样下传 — 见 .agents/notes/implemented/feature/2026-09-25-model-trace-log.md
65
- const safeTraceData = { ...entry };
66
- if (safeTraceData.prompt !== undefined) delete safeTraceData.prompt;
67
- logs?.appendModelTrace?.(traceModel, { type, reqId, model: traceModel, data: safeTraceData, request: type === "request" ? summarizeRequest(req?.body) : null, totalMs: entry.durationMs });
68
- }
69
- 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 || "" });
70
- if (type === "upstream-done" || type === "upstream-error") timeline.direct.push({ status: entry.status, reason: entry.message || entry.error || "" });
71
- if (type === "peer-error") {
72
- const known = timeline.peers.find((p) => p.peer === entry.peer);
73
- if (known) { known.ok = false; known.status = entry.status ?? known.status; known.message = entry.message || entry.error || known.message; }
74
- else timeline.peers.push({ peer: entry.peer, ok: false, status: entry.status, message: entry.message || entry.error || "" });
75
- }
76
- if (type === "empty-turn-retry") timeline.retries += 1;
77
- // 空转重试必须交代结局:只记次数的话,"重试后救回来"和"重试到死"在日志里长得一模一样
78
- if (type === "empty-turn-recovered") timeline.retryResult = "ok";
79
- if (type === "empty-turn-exhausted") timeline.retryResult = timeline.retryResult || "lost";
80
- if (type === "result" || (type === "client-response" && !timeline.result)) {
81
- timeline.result = { status: entry.status, detail: entry.detail || null };
82
- try { logs?.appendTimeline?.(formatTimeline({ reqId, model: entry.model || requested, ...timeline, totalMs: Date.now() - startedAt })); } catch {}
83
- }
84
- if (bus) bus.emit(entry);
85
- logs?.appendEvent?.(entry);
86
- };
87
- const done = (info) => {
88
- if (!plugins?.length) return;
89
- runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(req?.body?.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
90
- };
91
-
92
- // order 推导 + plugin model:select 可改
93
- // 语义:指定模型 = 死锁单模型(本机→组员同款,挂了就报挂,不兜其他 picks);只有 auto 才轮 picks
94
- // 例外:opencode 免费池的裸 id free 模型(mimo-v2.6-flash-free / deepseek-v4.1-flash-free…)
95
- // 额度耗尽时允许**同池互备**——见 freePoolFallbackOn()(BUG-7 用户点名诉求)。
96
- // x-mslxdff-auto-provider 头可把 auto 候选限定到单供应商(opencode=裸 id 免费池),-chat 默认带
97
- let order;
98
- let poolFallback = false; // 指定 free 模型时的「同池互备」标记:只有额度类失败才允许继续换兄弟
99
- if (lockModel) order = [requested];
100
- else if (useAuto) {
101
- let cands = auto ? await auto.candidates() : [""];
102
- if (autoProvider) {
103
- const before = cands.length;
104
- cands = cands.filter((m) => (autoProvider === "opencode"
105
- ? !String(m).includes("/") && isFreeModel(m)
106
- : String(m).startsWith(`${autoProvider}/`)));
107
- evt("auto-scope", { reqId, provider: autoProvider, before, after: cands.length });
108
- }
109
- order = cands;
110
- } else {
111
- // 指定模型的常态是死锁单模型。**唯一例外**:裸 id 的 free 模型(opencode 免费池),
112
- // 且只在**额度类**失败(429 / 具名额度判决)时才横向切同池兄弟——参数错/5xx 照旧原样透传,
113
- // 不把「客户端发错了」伪装成换模型成功,也不白烧别的模型额度(见 isPoolSwitchWorthy)。
114
- // candidatesFor 的持久化副作用(把指定模型加入勾选集)在任何分支都必须保留。
115
- const wantFreePool = Boolean(auto && requested && freePoolFallbackOn() && isFreeModel(requested) && !String(requested).includes("/"));
116
- if (auto && requested) {
117
- try {
118
- const cands = await auto.candidatesFor(requested);
119
- if (wantFreePool && Array.isArray(cands) && cands.length) {
120
- // 上限截断:串行换人是"试一试"不是"扫全池",太多候选会把额度类失败也拖成分钟级
121
- const samePool = cands
122
- .filter((m) => m === requested || (isFreeModel(m) && !String(m).includes("/")))
123
- .slice(0, FREE_POOL_MAX);
124
- if (samePool.length > 1) {
125
- order = samePool;
126
- poolFallback = true;
127
- evt("free-pool-fallback", { reqId, model: requested, pool: samePool, size: samePool.length });
128
- }
129
- }
130
- } catch {}
131
- }
132
- if (!order) order = [requested];
133
- }
134
- if (!order.length) order = [""];
135
- const canFallback = order.length > 1;
136
- const canForwardPeers = Boolean(peers) && hops < (maxHops ?? 3);
137
- mark("ordered");
138
-
139
- const reqIp = clientIp(req); // 一次解析两处用:request 事件的 ip 字段 + 回路语料的 clientIp(routes/helpers.js 取 x-forwarded-for 首段)
140
- evt("request", { reqId, hops, ip: reqIp, stream: Boolean(req?.body?.stream), prompt: summarizePrompt(req?.body), rawModel: policy.rawModel, requested, lockModel: lockModel || null });
141
- if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel: policy.rawModel, requested });
142
- if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
143
- evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
144
-
145
- if (plugins?.length && !lockModel) {
146
- const sel = await runHook(plugins, "model:select", { reqId, requested, useAuto, order: [...order], hops, stream: Boolean(req?.body?.stream) });
147
- if (sel.changed && Array.isArray(sel.value) && sel.value.length) {
148
- order = sel.value.filter(Boolean);
149
- if (!order.length) order = [requested];
150
- evt("plugin-hook", { reqId, hook: "model:select", applied: true, order: [...order] });
151
- }
152
- for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
153
- }
154
-
155
- // 客户端会话标识(opencode 插件 chat.headers 注入)→ 透传上游做粘性路由/缓存亲和;
156
- // 无头时由 upstream.js 按对话首两条消息哈希兜底(不再每请求随机)
157
- const clientSession = String(req?.headers?.["x-session-affinity"] || req?.headers?.["x-session-id"] || "").trim() || null;
158
- // triedUrls 挂请求级并**按引用**共享:同一请求内"同一组员不重复试"的契约跨候选生效
159
- // (peer 阶段各分支会 spread 出上下文副本,集合在这里建一次才不会各存一份)。
160
- const handlerCtx = { reqId, api, model: null, body: req?.body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid, sessionId: clientSession, clientIp: reqIp, triedUrls: new Set() };
161
- if (clientSession) evt("client-session", { reqId, sessionId: clientSession.slice(0, 24) });
162
-
163
- await engine.run({
164
- reqId, startedAt, req, res, body: req?.body, policy,
165
- useAuto, lockModel, requested, hops,
166
- canFallback, canForwardPeers, poolFallback,
167
- perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
168
- auto, upstream, peers, groups, bus, token, plugins, logs,
169
- order,
170
- });
171
- }
172
-
173
- 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
+ * 指定 free 模型时是否允许横向切同池兄弟(BUG-7)。
18
+ * 缺省开:opencode 免费池的裸 id 模型(`mimo-v2.6-flash-free` 等)语义上就是"免费额度内随便用",
19
+ * 单个模型额度耗尽不该让整条链路失败。逃生阀 `MSLXDFF_FREE_POOL_FALLBACK=0` 回到"显式指定即锁死"。
20
+ * 只在**非 lockModel**(即不经组内 model-lock 转发)时生效。
21
+ */
22
+ // 同池互备最多横向试几个(含首个指定的那个):额度类失败常见于"整池都满",
23
+ // 扫全池只会把快速失败拖成分钟级,试前几个足够区分"这个模型满"与"这池满"。
24
+ const FREE_POOL_MAX = 4;
25
+
26
+ export function freePoolFallbackOn(env = process.env) {
27
+ const raw = env.MSLXDFF_FREE_POOL_FALLBACK;
28
+ if (raw === undefined || raw === null || raw === "") return true;
29
+ const s = String(raw).trim().toLowerCase();
30
+ return !(s === "0" || s === "false" || s === "off" || s === "no");
31
+ }
32
+
33
+ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, token, plugins, maxHops } = {}) {
34
+ const engine = createEngine();
35
+
36
+ async function execute({ req, res } = {}) {
37
+ const startedAt = Date.now();
38
+ const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
39
+ // 入站协议门(chat|responses|messages|other):全场只在这里判一次,之后随 handlerCtx 透传;
40
+ // 各日志写点不得再各自匹配路径(第二份判定迟早漂移)—— 见 src/api-kind.js
41
+ const api = apiKindOf(req);
42
+ const perf0 = performance.now();
43
+ const stages = [];
44
+ const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
45
+
46
+ const policy = analyzePolicy({ headers: req?.headers || {}, body: req?.body || {} });
47
+ const { requested, useAuto, autoProvider, lockModel, hops, shareKeys, workbuddyUid, aliasInfo } = policy;
48
+ mark("parsed");
49
+ if (aliasInfo) { try { res?.setHeader?.("x-mslxdff-alias", aliasInfo); } catch {} }
50
+ // mslxdff/ 前缀或 alias 命中时,把 body.model 改写为还原后的模型(与原 gateway 语义一致)
51
+ const timeline = { direct: [], peers: [], retries: 0, retryResult: null, result: null };
52
+ if (aliasInfo && req?.body && req.body.model !== requested) {
53
+ req.body = { ...req.body, model: requested };
54
+ }
55
+
56
+ // 事件/日志帮手先于 order 推导声明:evt 在 auto-scope 分支即被使用,
57
+ // 声明置后会让 useAuto && autoProvider 的请求在 TDZ 上崩(线上 -chat 回退路径)
58
+ const logCall = (model, status) => logs?.appendCall({ reqId, api, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(req?.body?.stream), stages });
59
+ const logError = (model, status, message) => logs?.appendError({ reqId, api, model, auto: useAuto, status, message, stages });
60
+ const evt = (type, data) => {
61
+ const entry = { ts: Date.now(), reqId, type, ...data, api, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
62
+ const traceModel = entry.model || requested;
63
+ if (shouldTraceModel(type)) {
64
+ // Note: 模型日志只投影安全字段,不能把含 prompt 的 entry 原样下传 — 见 .agents/notes/implemented/feature/2026-09-25-model-trace-log.md
65
+ const safeTraceData = { ...entry };
66
+ if (safeTraceData.prompt !== undefined) delete safeTraceData.prompt;
67
+ logs?.appendModelTrace?.(traceModel, { type, reqId, model: traceModel, data: safeTraceData, request: type === "request" ? summarizeRequest(req?.body) : null, totalMs: entry.durationMs });
68
+ }
69
+ 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 || "" });
70
+ if (type === "upstream-done" || type === "upstream-error") timeline.direct.push({ status: entry.status, reason: entry.message || entry.error || "" });
71
+ if (type === "peer-error") {
72
+ const known = timeline.peers.find((p) => p.peer === entry.peer);
73
+ if (known) { known.ok = false; known.status = entry.status ?? known.status; known.message = entry.message || entry.error || known.message; }
74
+ else timeline.peers.push({ peer: entry.peer, ok: false, status: entry.status, message: entry.message || entry.error || "" });
75
+ }
76
+ if (type === "empty-turn-retry") timeline.retries += 1;
77
+ // 空转重试必须交代结局:只记次数的话,"重试后救回来"和"重试到死"在日志里长得一模一样
78
+ if (type === "empty-turn-recovered") timeline.retryResult = "ok";
79
+ if (type === "empty-turn-exhausted") timeline.retryResult = timeline.retryResult || "lost";
80
+ if (type === "result" || (type === "client-response" && !timeline.result)) {
81
+ timeline.result = { status: entry.status, detail: entry.detail || null };
82
+ try { logs?.appendTimeline?.(formatTimeline({ reqId, model: entry.model || requested, ...timeline, totalMs: Date.now() - startedAt })); } catch {}
83
+ }
84
+ if (bus) bus.emit(entry);
85
+ logs?.appendEvent?.(entry);
86
+ };
87
+ const done = (info) => {
88
+ if (!plugins?.length) return;
89
+ runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(req?.body?.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
90
+ };
91
+
92
+ // order 推导 + plugin model:select 可改
93
+ // 语义:指定模型 = 死锁单模型(本机→组员同款,挂了就报挂,不兜其他 picks);只有 auto 才轮 picks
94
+ // 例外:opencode 免费池的裸 id free 模型(mimo-v2.6-flash-free / deepseek-v4.1-flash-free…)
95
+ // 额度耗尽时允许**同池互备**——见 freePoolFallbackOn()(BUG-7 用户点名诉求)。
96
+ // x-mslxdff-auto-provider 头可把 auto 候选限定到单供应商(opencode=裸 id 免费池),-chat 默认带
97
+ let order;
98
+ let poolFallback = false; // 指定 free 模型时的「同池互备」标记:只有额度类失败才允许继续换兄弟
99
+ if (lockModel) order = [requested];
100
+ else if (useAuto) {
101
+ let cands = auto ? await auto.candidates() : [""];
102
+ if (autoProvider) {
103
+ const before = cands.length;
104
+ cands = cands.filter((m) => (autoProvider === "opencode"
105
+ ? !String(m).includes("/") && isFreeModel(m)
106
+ : String(m).startsWith(`${autoProvider}/`)));
107
+ evt("auto-scope", { reqId, provider: autoProvider, before, after: cands.length });
108
+ }
109
+ order = cands;
110
+ } else {
111
+ // 指定模型的常态是死锁单模型。**唯一例外**:裸 id 的 free 模型(opencode 免费池),
112
+ // 且只在**额度类**失败(429 / 具名额度判决)时才横向切同池兄弟——参数错/5xx 照旧原样透传,
113
+ // 不把「客户端发错了」伪装成换模型成功,也不白烧别的模型额度(见 isPoolSwitchWorthy)。
114
+ // candidatesFor 的持久化副作用(把指定模型加入勾选集)在任何分支都必须保留。
115
+ const wantFreePool = Boolean(auto && requested && freePoolFallbackOn() && isFreeModel(requested) && !String(requested).includes("/"));
116
+ if (auto && requested) {
117
+ try {
118
+ const cands = await auto.candidatesFor(requested);
119
+ if (wantFreePool && Array.isArray(cands) && cands.length) {
120
+ // 上限截断:串行换人是"试一试"不是"扫全池",太多候选会把额度类失败也拖成分钟级
121
+ const samePool = cands
122
+ .filter((m) => m === requested || (isFreeModel(m) && !String(m).includes("/")))
123
+ .slice(0, FREE_POOL_MAX);
124
+ if (samePool.length > 1) {
125
+ order = samePool;
126
+ poolFallback = true;
127
+ evt("free-pool-fallback", { reqId, model: requested, pool: samePool, size: samePool.length });
128
+ }
129
+ }
130
+ } catch {}
131
+ }
132
+ if (!order) order = [requested];
133
+ }
134
+ if (!order.length) order = [""];
135
+ const canFallback = order.length > 1;
136
+ const canForwardPeers = Boolean(peers) && hops < (maxHops ?? 3);
137
+ mark("ordered");
138
+
139
+ const reqIp = clientIp(req); // 一次解析两处用:request 事件的 ip 字段 + 回路语料的 clientIp(routes/helpers.js 取 x-forwarded-for 首段)
140
+ evt("request", { reqId, hops, ip: reqIp, stream: Boolean(req?.body?.stream), prompt: summarizePrompt(req?.body), rawModel: policy.rawModel, requested, lockModel: lockModel || null });
141
+ if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel: policy.rawModel, requested });
142
+ if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
143
+ evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
144
+
145
+ if (plugins?.length && !lockModel) {
146
+ const sel = await runHook(plugins, "model:select", { reqId, requested, useAuto, order: [...order], hops, stream: Boolean(req?.body?.stream) });
147
+ if (sel.changed && Array.isArray(sel.value) && sel.value.length) {
148
+ order = sel.value.filter(Boolean);
149
+ if (!order.length) order = [requested];
150
+ evt("plugin-hook", { reqId, hook: "model:select", applied: true, order: [...order] });
151
+ }
152
+ for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
153
+ }
154
+
155
+ // 客户端会话标识(opencode 插件 chat.headers 注入)→ 透传上游做粘性路由/缓存亲和;
156
+ // 无头时由 upstream.js 按对话首两条消息哈希兜底(不再每请求随机)
157
+ const clientSession = String(req?.headers?.["x-session-affinity"] || req?.headers?.["x-session-id"] || "").trim() || null;
158
+ // triedUrls 挂请求级并**按引用**共享:同一请求内"同一组员不重复试"的契约跨候选生效
159
+ // (peer 阶段各分支会 spread 出上下文副本,集合在这里建一次才不会各存一份)。
160
+ const handlerCtx = { reqId, api, model: null, body: req?.body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid, sessionId: clientSession, clientIp: reqIp, triedUrls: new Set() };
161
+ if (clientSession) evt("client-session", { reqId, sessionId: clientSession.slice(0, 24) });
162
+
163
+ await engine.run({
164
+ reqId, startedAt, req, res, body: req?.body, policy,
165
+ useAuto, lockModel, requested, hops,
166
+ canFallback, canForwardPeers, poolFallback,
167
+ perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
168
+ auto, upstream, peers, groups, bus, token, plugins, logs,
169
+ order,
170
+ });
171
+ }
172
+
173
+ return { execute, _policy: analyzePolicy, _engine: engine };
174
174
  }
@@ -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
+ }
@@ -19,12 +19,16 @@
19
19
  // 防上游把同一判决挪到 400/404 就失明。
20
20
  const FREE_TIER_PATTERNS = [
21
21
  /FreeTierError/i,
22
- // 额度耗尽(具名判决)。裸 "Rate limit exceeded" 不在此列——那可能只是这一发撞上限流,换号换节点
23
- // 就恢复;只有上游**具名**说"你的免费额度用完了"才是对同源组员同样成立的判决。
24
- // 实测(errors.log,2026-10-16):muse-spark / ll-alpha 的 429 体是
25
- // `{"error":{"type":"FreeUsageLimitError","message":"Rate limit exceeded..."}}`,旧判据漏掉它
26
- // ⇒ 白跑组员接力 70s 才回错。escape hatch 同 MSLXDFF_TERMINAL_SHORTCIRCUIT=0。
27
- /FreeUsageLimitError/i,
22
+ // ⚠ `FreeUsageLimitError` 曾列在此(BUG-8 于 2026-10-16 加入)**现已移除**(2026-10-17 纠正):
23
+ // 它是**限额/限流**判决,不是身份闸门。三条独立实证都证明「换出口/换组员就恢复」⇒ 属可接力:
24
+ // ① 本仓自测记录(warp-pool/verdict.js:9-10):同一身份连发 3 次 429 FreeUsageLimitError,
25
+ // **身份一换、同一个请求立刻 200**(限额按出口 IP 计);
26
+ // ② warp-pool 的决策表(verdict.js:62)对 429 FreeUsage 明写「强制换出口、跳过冷却」;
27
+ // ③ v6 池(v6pool-claim-rotate.test.js:140)同样据它换源地址。
28
+ // 更根本:ADR-0058 §2 本就把「401/402/**429**/5xx」明列为**可恢复、接力照常**;本文件头 :9-11 也是
29
+ // 同一口径。BUG-8 把 429 的具名文案判成终局,偏离了本模块自己写下的边界 —— 现网代价是
30
+ // 组员额度还在、仍被 `peersSkipped:true` 一刀跳过(2026-10-18 events.log 满屏实证)。
31
+ // 改由 TRANSIENT_MARKERS 显式排除(不依赖 message 里偶然出现的 "rate limit")。
28
32
  // 具名资格判决:上游明说「你没订阅这个模型套餐」。它比 free 层判决更硬——免费层判决还可能随
29
33
  // 上游口径变化,套餐缺失则是**换号换节点都不会变**的判决,属本判据射程内却曾被漏判。
30
34
  // 实测(errors.log,2026-10-17 两次逐字相同,cline/cline-pass/deepseek-v4.1-flash):
@@ -56,6 +60,11 @@ const MODEL_GONE_PATTERNS = [
56
60
  // 反向护栏:形状像终局但语义是「这个号/这一发不行」,换号换节点可能就好——绝不终局。
57
61
  // 只认明确凭据/限流语义词,不收裸 `token`:`max_tokens` 之类的参数报错会被它误判成可接力。
58
62
  const TRANSIENT_MARKERS = [
63
+ // 限额/限流具名判决 ⇒ **可恢复**(换出口换组员就恢复,见 FREE_TIER_PATTERNS 上方三条实证)。
64
+ // 显式列出而非指望 message 里的 "rate limit" 字样:上游改文案时这层不能跟着失明
65
+ // (`FreeUsageLimitError` 的类名比它的 message 稳定得多)。
66
+ // GoUsageLimitError 与它同族(warp-pool/verdict.js:25 把两者并列),同样按出口计、同样可换。
67
+ /FreeUsageLimitError|GoUsageLimitError/i,
59
68
  /security_reject/i,
60
69
  /安全校验拒绝/,
61
70
  /isQueued/i,