mslxdff 0.1.174 → 0.1.176
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/package.json +1 -1
- package/src/auto.js +254 -254
- package/src/bench/cline-bench.js +42 -42
- package/src/bench/probe.js +70 -70
- package/src/bench/report.js +162 -162
- package/src/bench/runner.js +77 -77
- package/src/bench/via-probe.js +124 -124
- package/src/bench/via-routes.js +87 -87
- package/src/bench/workbuddy-bench.js +54 -54
- package/src/chat/engine.js +160 -160
- package/src/chat/gateway.js +163 -163
- package/src/chat/orchestrator.js +234 -234
- package/src/chat/prompt.js +70 -70
- package/src/chat/repl.js +88 -88
- package/src/chat/terminal.js +135 -135
- package/src/chat/tools.js +306 -306
- package/src/chat-pipeline/auto-race.js +26 -1
- package/src/chat-pipeline/index.js +131 -131
- package/src/chat-pipeline/policy.js +84 -84
- package/src/chat-pipeline/serial-trial.js +35 -8
- package/src/chat-pipeline/terminal-verdict.js +109 -0
- package/src/cli/commands/group.js +249 -249
- 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 +136 -133
- package/src/cli/commands/provider/keys-guard.js +50 -0
- package/src/cli/commands/provider/keys.js +7 -0
- package/src/cli/commands/provider/models.js +145 -145
- package/src/cli/commands/provider/qwenwork-login.js +119 -119
- package/src/cli/commands/provider/traework-checkin.js +144 -0
- package/src/cli/commands/provider/traework-login.js +7 -45
- package/src/cli/commands/sync.js +310 -310
- package/src/cli/help.js +3 -0
- package/src/cli/status.js +279 -279
- package/src/daemon.js +112 -112
- package/src/model-capabilities/enrich.js +86 -86
- package/src/model-capabilities/index.js +183 -183
- package/src/model-capabilities/parse.js +70 -70
- package/src/model-trace.js +2 -0
- package/src/models.js +225 -225
- 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 +7 -1
- package/src/providers/codearts/const.js +6 -1
- package/src/providers/codearts/index.js +18 -11
- 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 +203 -203
- package/src/providers/qoder/index.js +249 -249
- package/src/providers/qoder/sse.js +145 -145
- 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 +327 -327
- 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/registry.js +79 -79
- package/src/providers/traework/account-store.js +87 -2
- package/src/providers/traework/checkin.js +51 -9
- package/src/providers/traework/constants.js +20 -0
- package/src/providers/traework/headers.js +42 -10
- package/src/providers/traework/index.js +14 -4
- package/src/providers/traework/market-identity.js +116 -0
- package/src/providers/workbuddy/chat.js +248 -248
- package/src/providers/workbuddy/reshape.js +152 -152
- package/src/providers/workbuddy.js +2 -2
- package/src/reasoning.js +32 -32
- package/src/routes/chat/gateway.js +46 -46
- package/src/routes/chat/relay-pipeline.js +294 -294
- package/src/routes/chat/via-route-handler.js +146 -146
- package/src/routes/hedge.js +255 -255
- package/src/routes/models-route.js +167 -167
- package/src/routes/peers.js +273 -273
- package/src/routes/stream.js +385 -385
- package/src/runtime/bootstrap.js +50 -50
- package/src/runtime/provider-gate.js +36 -36
- package/src/runtime/providers-setup.js +168 -165
- package/src/runtime/traework-checkin.js +26 -11
- package/src/server.js +69 -69
- 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 +291 -289
- package/src/warp-pool/config.js +71 -0
- package/src/warp-pool/health.js +65 -0
- package/src/warp-pool/index.js +261 -0
- package/src/warp-pool/keys.js +29 -0
- package/src/warp-pool/nodes.js +94 -0
- package/src/warp-pool/register.js +113 -0
- package/src/warp-pool/rotate.js +150 -0
- package/src/warp-pool/strategy.js +43 -0
- package/src/warp-pool/wireproxy.js +153 -0
- package/docs/cli_help_mini.md +0 -143
- package/src/providers/AGENTS.md +0 -49
- package/src/routes/AGENTS.md +0 -37
|
@@ -3,6 +3,7 @@ import { injectReasoningContent } from "../reasoning.js";
|
|
|
3
3
|
import { runHook } from "../plugins.js";
|
|
4
4
|
import { errMsg } from "../routes/helpers.js";
|
|
5
5
|
import { withOutputFloor } from "./empty-turn.js";
|
|
6
|
+
import { classifyTerminalUpstream, readVerdictText, terminalShortcircuitOff } from "./terminal-verdict.js";
|
|
6
7
|
import { handleLocalRelay } from "../routes/chat/local-handler.js";
|
|
7
8
|
import { handleExhaustedAll } from "../routes/chat/exhausted-handler.js";
|
|
8
9
|
|
|
@@ -123,9 +124,33 @@ export async function runAutoRace(ctx, deps = {}) {
|
|
|
123
124
|
const triedSet = new Set(raceModels);
|
|
124
125
|
order = order.filter((m) => !triedSet.has(m));
|
|
125
126
|
if (!order.length) {
|
|
127
|
+
// 并发竞速全挂时也要认终局判决(判据见 terminal-verdict.js):否则「上游 6s 就把话说死」在这条出口
|
|
128
|
+
// 换成 502 "all concurrent candidates failed",serial-trial 那条短路救不到它——同一失败模式的另一条命
|
|
129
|
+
// (`auto` 是 chat 门缺省路径,冷启动无 prior-success 且 >1 可用候选时必走竞速)。
|
|
130
|
+
// 走到这里 order 已空(没有下一候选可换)、组员也不在这条路上,所以本段只改「客户端看到什么」:
|
|
131
|
+
// 挑第一条能定终局的失败,把它的**原响应**交给 exhaustedAll 透传(原状态码 + 原字节 + 原头)。
|
|
132
|
+
let terminalLast = null;
|
|
133
|
+
if (!terminalShortcircuitOff()) {
|
|
134
|
+
for (const r of results) {
|
|
135
|
+
const v = r.status === "fulfilled" ? r.value : null;
|
|
136
|
+
if (!v || v.ok || v.allowlist || !v.res) continue;
|
|
137
|
+
const txt = await readVerdictText(v.res);
|
|
138
|
+
if (!txt.trim()) continue; // 读不到正文一律不判终局(保守回退「照跑收场」的旧行为)
|
|
139
|
+
const verdict = classifyTerminalUpstream(v.status, txt);
|
|
140
|
+
if (!verdict.terminal) continue;
|
|
141
|
+
evt("upstream-terminal", { reqId, model: v.model, status: v.status, kind: verdict.kind, marker: verdict.marker, peersSkipped: true, via: "auto-race" });
|
|
142
|
+
logError(v.model, v.status, `upstream ${v.status} body=${txt.replace(/\s+/g, " ").slice(0, 300)} · 终局判决 ${verdict.kind}(auto 竞速收场直发原文)`);
|
|
143
|
+
terminalLast = { model: v.model, upstream: v.res, status: v.status, message: txt.replace(/\s+/g, " ").slice(0, 400) || `upstream ${v.status}`, terminal: verdict.kind };
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
126
147
|
const failedStatuses = results.map((r) => (r.status === "fulfilled" ? r.value?.status : null)).filter((s) => Number.isInteger(s));
|
|
127
148
|
const lastStatus = failedStatuses[failedStatuses.length - 1] || failedStatuses[0] || 502;
|
|
128
|
-
|
|
149
|
+
// 竞速出口的取证与串行一致:状态码、原响应、同名回显头(客户端只看得到这一个面)
|
|
150
|
+
if (terminalLast && !res.headersSent && typeof res?.setHeader === "function") {
|
|
151
|
+
try { res.setHeader("x-mslxdff-terminal", terminalLast.terminal); } catch {}
|
|
152
|
+
}
|
|
153
|
+
const last = terminalLast || { model: raceModels[0] || requested, status: lastStatus, message: "all concurrent candidates failed" };
|
|
129
154
|
await exhaustedAll({ res, body, lastErr: last, order: raceModels, requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
|
|
130
155
|
return { done: true };
|
|
131
156
|
}
|
|
@@ -1,132 +1,132 @@
|
|
|
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
|
-
export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, token, plugins, maxHops } = {}) {
|
|
17
|
-
const engine = createEngine();
|
|
18
|
-
|
|
19
|
-
async function execute({ req, res } = {}) {
|
|
20
|
-
const startedAt = Date.now();
|
|
21
|
-
const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
22
|
-
// 入站协议门(chat|responses|messages|other):全场只在这里判一次,之后随 handlerCtx 透传;
|
|
23
|
-
// 各日志写点不得再各自匹配路径(第二份判定迟早漂移)—— 见 src/api-kind.js
|
|
24
|
-
const api = apiKindOf(req);
|
|
25
|
-
const perf0 = performance.now();
|
|
26
|
-
const stages = [];
|
|
27
|
-
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
28
|
-
|
|
29
|
-
const policy = analyzePolicy({ headers: req?.headers || {}, body: req?.body || {} });
|
|
30
|
-
const { requested, useAuto, autoProvider, lockModel, hops, shareKeys, workbuddyUid, aliasInfo } = policy;
|
|
31
|
-
mark("parsed");
|
|
32
|
-
if (aliasInfo) { try { res?.setHeader?.("x-mslxdff-alias", aliasInfo); } catch {} }
|
|
33
|
-
// mslxdff/ 前缀或 alias 命中时,把 body.model 改写为还原后的模型(与原 gateway 语义一致)
|
|
34
|
-
const timeline = { direct: [], peers: [], retries: 0, retryResult: null, result: null };
|
|
35
|
-
if (aliasInfo && req?.body && req.body.model !== requested) {
|
|
36
|
-
req.body = { ...req.body, model: requested };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// 事件/日志帮手先于 order 推导声明:evt 在 auto-scope 分支即被使用,
|
|
40
|
-
// 声明置后会让 useAuto && autoProvider 的请求在 TDZ 上崩(线上 -chat 回退路径)
|
|
41
|
-
const logCall = (model, status) => logs?.appendCall({ reqId, api, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(req?.body?.stream), stages });
|
|
42
|
-
const logError = (model, status, message) => logs?.appendError({ reqId, api, model, auto: useAuto, status, message, stages });
|
|
43
|
-
const evt = (type, data) => {
|
|
44
|
-
const entry = { ts: Date.now(), reqId, type, ...data, api, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
45
|
-
const traceModel = entry.model || requested;
|
|
46
|
-
if (shouldTraceModel(type)) {
|
|
47
|
-
// Note: 模型日志只投影安全字段,不能把含 prompt 的 entry 原样下传 — 见 .agents/notes/implemented/feature/2026-09-25-model-trace-log.md
|
|
48
|
-
const safeTraceData = { ...entry };
|
|
49
|
-
if (safeTraceData.prompt !== undefined) delete safeTraceData.prompt;
|
|
50
|
-
logs?.appendModelTrace?.(traceModel, { type, reqId, model: traceModel, data: safeTraceData, request: type === "request" ? summarizeRequest(req?.body) : null, totalMs: entry.durationMs });
|
|
51
|
-
}
|
|
52
|
-
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 || "" });
|
|
53
|
-
if (type === "upstream-done" || type === "upstream-error") timeline.direct.push({ status: entry.status, reason: entry.message || entry.error || "" });
|
|
54
|
-
if (type === "peer-error") {
|
|
55
|
-
const known = timeline.peers.find((p) => p.peer === entry.peer);
|
|
56
|
-
if (known) { known.ok = false; known.status = entry.status ?? known.status; known.message = entry.message || entry.error || known.message; }
|
|
57
|
-
else timeline.peers.push({ peer: entry.peer, ok: false, status: entry.status, message: entry.message || entry.error || "" });
|
|
58
|
-
}
|
|
59
|
-
if (type === "empty-turn-retry") timeline.retries += 1;
|
|
60
|
-
// 空转重试必须交代结局:只记次数的话,"重试后救回来"和"重试到死"在日志里长得一模一样
|
|
61
|
-
if (type === "empty-turn-recovered") timeline.retryResult = "ok";
|
|
62
|
-
if (type === "empty-turn-exhausted") timeline.retryResult = timeline.retryResult || "lost";
|
|
63
|
-
if (type === "result" || (type === "client-response" && !timeline.result)) {
|
|
64
|
-
timeline.result = { status: entry.status, detail: entry.detail || null };
|
|
65
|
-
try { logs?.appendTimeline?.(formatTimeline({ reqId, model: entry.model || requested, ...timeline, totalMs: Date.now() - startedAt })); } catch {}
|
|
66
|
-
}
|
|
67
|
-
if (bus) bus.emit(entry);
|
|
68
|
-
logs?.appendEvent?.(entry);
|
|
69
|
-
};
|
|
70
|
-
const done = (info) => {
|
|
71
|
-
if (!plugins?.length) return;
|
|
72
|
-
runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(req?.body?.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
// order 推导 + plugin model:select 可改
|
|
76
|
-
// 语义:指定模型 = 死锁单模型(本机→组员同款,挂了就报挂,不兜其他 picks);只有 auto 才轮 picks
|
|
77
|
-
// x-mslxdff-auto-provider 头可把 auto 候选限定到单供应商(opencode=裸 id 免费池),-chat 默认带
|
|
78
|
-
let order;
|
|
79
|
-
if (lockModel) order = [requested];
|
|
80
|
-
else if (useAuto) {
|
|
81
|
-
let cands = auto ? await auto.candidates() : [""];
|
|
82
|
-
if (autoProvider) {
|
|
83
|
-
const before = cands.length;
|
|
84
|
-
cands = cands.filter((m) => (autoProvider === "opencode"
|
|
85
|
-
? !String(m).includes("/") && isFreeModel(m)
|
|
86
|
-
: String(m).startsWith(`${autoProvider}/`)));
|
|
87
|
-
evt("auto-scope", { reqId, provider: autoProvider, before, after: cands.length });
|
|
88
|
-
}
|
|
89
|
-
order = cands;
|
|
90
|
-
} else {
|
|
91
|
-
if (auto && requested) { try { await auto.candidatesFor(requested); } catch {} }
|
|
92
|
-
order = [requested];
|
|
93
|
-
}
|
|
94
|
-
if (!order.length) order = [""];
|
|
95
|
-
const canFallback = order.length > 1;
|
|
96
|
-
const canForwardPeers = Boolean(peers) && hops < (maxHops ?? 3);
|
|
97
|
-
mark("ordered");
|
|
98
|
-
|
|
99
|
-
const reqIp = clientIp(req); // 一次解析两处用:request 事件的 ip 字段 + 回路语料的 clientIp(routes/helpers.js 取 x-forwarded-for 首段)
|
|
100
|
-
evt("request", { reqId, hops, ip: reqIp, stream: Boolean(req?.body?.stream), prompt: summarizePrompt(req?.body), rawModel: policy.rawModel, requested, lockModel: lockModel || null });
|
|
101
|
-
if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel: policy.rawModel, requested });
|
|
102
|
-
if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
|
|
103
|
-
evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
|
|
104
|
-
|
|
105
|
-
if (plugins?.length && !lockModel) {
|
|
106
|
-
const sel = await runHook(plugins, "model:select", { reqId, requested, useAuto, order: [...order], hops, stream: Boolean(req?.body?.stream) });
|
|
107
|
-
if (sel.changed && Array.isArray(sel.value) && sel.value.length) {
|
|
108
|
-
order = sel.value.filter(Boolean);
|
|
109
|
-
if (!order.length) order = [requested];
|
|
110
|
-
evt("plugin-hook", { reqId, hook: "model:select", applied: true, order: [...order] });
|
|
111
|
-
}
|
|
112
|
-
for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// 客户端会话标识(opencode 插件 chat.headers 注入)→ 透传上游做粘性路由/缓存亲和;
|
|
116
|
-
// 无头时由 upstream.js 按对话首两条消息哈希兜底(不再每请求随机)
|
|
117
|
-
const clientSession = String(req?.headers?.["x-session-affinity"] || req?.headers?.["x-session-id"] || "").trim() || null;
|
|
118
|
-
const handlerCtx = { reqId, api, model: null, body: req?.body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid, sessionId: clientSession, clientIp: reqIp };
|
|
119
|
-
if (clientSession) evt("client-session", { reqId, sessionId: clientSession.slice(0, 24) });
|
|
120
|
-
|
|
121
|
-
await engine.run({
|
|
122
|
-
reqId, startedAt, req, res, body: req?.body, policy,
|
|
123
|
-
useAuto, lockModel, requested, hops,
|
|
124
|
-
canFallback, canForwardPeers,
|
|
125
|
-
perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
|
|
126
|
-
auto, upstream, peers, groups, bus, token, plugins, logs,
|
|
127
|
-
order,
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
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
|
+
export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, token, plugins, maxHops } = {}) {
|
|
17
|
+
const engine = createEngine();
|
|
18
|
+
|
|
19
|
+
async function execute({ req, res } = {}) {
|
|
20
|
+
const startedAt = Date.now();
|
|
21
|
+
const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
22
|
+
// 入站协议门(chat|responses|messages|other):全场只在这里判一次,之后随 handlerCtx 透传;
|
|
23
|
+
// 各日志写点不得再各自匹配路径(第二份判定迟早漂移)—— 见 src/api-kind.js
|
|
24
|
+
const api = apiKindOf(req);
|
|
25
|
+
const perf0 = performance.now();
|
|
26
|
+
const stages = [];
|
|
27
|
+
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
28
|
+
|
|
29
|
+
const policy = analyzePolicy({ headers: req?.headers || {}, body: req?.body || {} });
|
|
30
|
+
const { requested, useAuto, autoProvider, lockModel, hops, shareKeys, workbuddyUid, aliasInfo } = policy;
|
|
31
|
+
mark("parsed");
|
|
32
|
+
if (aliasInfo) { try { res?.setHeader?.("x-mslxdff-alias", aliasInfo); } catch {} }
|
|
33
|
+
// mslxdff/ 前缀或 alias 命中时,把 body.model 改写为还原后的模型(与原 gateway 语义一致)
|
|
34
|
+
const timeline = { direct: [], peers: [], retries: 0, retryResult: null, result: null };
|
|
35
|
+
if (aliasInfo && req?.body && req.body.model !== requested) {
|
|
36
|
+
req.body = { ...req.body, model: requested };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 事件/日志帮手先于 order 推导声明:evt 在 auto-scope 分支即被使用,
|
|
40
|
+
// 声明置后会让 useAuto && autoProvider 的请求在 TDZ 上崩(线上 -chat 回退路径)
|
|
41
|
+
const logCall = (model, status) => logs?.appendCall({ reqId, api, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(req?.body?.stream), stages });
|
|
42
|
+
const logError = (model, status, message) => logs?.appendError({ reqId, api, model, auto: useAuto, status, message, stages });
|
|
43
|
+
const evt = (type, data) => {
|
|
44
|
+
const entry = { ts: Date.now(), reqId, type, ...data, api, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
45
|
+
const traceModel = entry.model || requested;
|
|
46
|
+
if (shouldTraceModel(type)) {
|
|
47
|
+
// Note: 模型日志只投影安全字段,不能把含 prompt 的 entry 原样下传 — 见 .agents/notes/implemented/feature/2026-09-25-model-trace-log.md
|
|
48
|
+
const safeTraceData = { ...entry };
|
|
49
|
+
if (safeTraceData.prompt !== undefined) delete safeTraceData.prompt;
|
|
50
|
+
logs?.appendModelTrace?.(traceModel, { type, reqId, model: traceModel, data: safeTraceData, request: type === "request" ? summarizeRequest(req?.body) : null, totalMs: entry.durationMs });
|
|
51
|
+
}
|
|
52
|
+
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 || "" });
|
|
53
|
+
if (type === "upstream-done" || type === "upstream-error") timeline.direct.push({ status: entry.status, reason: entry.message || entry.error || "" });
|
|
54
|
+
if (type === "peer-error") {
|
|
55
|
+
const known = timeline.peers.find((p) => p.peer === entry.peer);
|
|
56
|
+
if (known) { known.ok = false; known.status = entry.status ?? known.status; known.message = entry.message || entry.error || known.message; }
|
|
57
|
+
else timeline.peers.push({ peer: entry.peer, ok: false, status: entry.status, message: entry.message || entry.error || "" });
|
|
58
|
+
}
|
|
59
|
+
if (type === "empty-turn-retry") timeline.retries += 1;
|
|
60
|
+
// 空转重试必须交代结局:只记次数的话,"重试后救回来"和"重试到死"在日志里长得一模一样
|
|
61
|
+
if (type === "empty-turn-recovered") timeline.retryResult = "ok";
|
|
62
|
+
if (type === "empty-turn-exhausted") timeline.retryResult = timeline.retryResult || "lost";
|
|
63
|
+
if (type === "result" || (type === "client-response" && !timeline.result)) {
|
|
64
|
+
timeline.result = { status: entry.status, detail: entry.detail || null };
|
|
65
|
+
try { logs?.appendTimeline?.(formatTimeline({ reqId, model: entry.model || requested, ...timeline, totalMs: Date.now() - startedAt })); } catch {}
|
|
66
|
+
}
|
|
67
|
+
if (bus) bus.emit(entry);
|
|
68
|
+
logs?.appendEvent?.(entry);
|
|
69
|
+
};
|
|
70
|
+
const done = (info) => {
|
|
71
|
+
if (!plugins?.length) return;
|
|
72
|
+
runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(req?.body?.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// order 推导 + plugin model:select 可改
|
|
76
|
+
// 语义:指定模型 = 死锁单模型(本机→组员同款,挂了就报挂,不兜其他 picks);只有 auto 才轮 picks
|
|
77
|
+
// x-mslxdff-auto-provider 头可把 auto 候选限定到单供应商(opencode=裸 id 免费池),-chat 默认带
|
|
78
|
+
let order;
|
|
79
|
+
if (lockModel) order = [requested];
|
|
80
|
+
else if (useAuto) {
|
|
81
|
+
let cands = auto ? await auto.candidates() : [""];
|
|
82
|
+
if (autoProvider) {
|
|
83
|
+
const before = cands.length;
|
|
84
|
+
cands = cands.filter((m) => (autoProvider === "opencode"
|
|
85
|
+
? !String(m).includes("/") && isFreeModel(m)
|
|
86
|
+
: String(m).startsWith(`${autoProvider}/`)));
|
|
87
|
+
evt("auto-scope", { reqId, provider: autoProvider, before, after: cands.length });
|
|
88
|
+
}
|
|
89
|
+
order = cands;
|
|
90
|
+
} else {
|
|
91
|
+
if (auto && requested) { try { await auto.candidatesFor(requested); } catch {} }
|
|
92
|
+
order = [requested];
|
|
93
|
+
}
|
|
94
|
+
if (!order.length) order = [""];
|
|
95
|
+
const canFallback = order.length > 1;
|
|
96
|
+
const canForwardPeers = Boolean(peers) && hops < (maxHops ?? 3);
|
|
97
|
+
mark("ordered");
|
|
98
|
+
|
|
99
|
+
const reqIp = clientIp(req); // 一次解析两处用:request 事件的 ip 字段 + 回路语料的 clientIp(routes/helpers.js 取 x-forwarded-for 首段)
|
|
100
|
+
evt("request", { reqId, hops, ip: reqIp, stream: Boolean(req?.body?.stream), prompt: summarizePrompt(req?.body), rawModel: policy.rawModel, requested, lockModel: lockModel || null });
|
|
101
|
+
if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel: policy.rawModel, requested });
|
|
102
|
+
if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
|
|
103
|
+
evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
|
|
104
|
+
|
|
105
|
+
if (plugins?.length && !lockModel) {
|
|
106
|
+
const sel = await runHook(plugins, "model:select", { reqId, requested, useAuto, order: [...order], hops, stream: Boolean(req?.body?.stream) });
|
|
107
|
+
if (sel.changed && Array.isArray(sel.value) && sel.value.length) {
|
|
108
|
+
order = sel.value.filter(Boolean);
|
|
109
|
+
if (!order.length) order = [requested];
|
|
110
|
+
evt("plugin-hook", { reqId, hook: "model:select", applied: true, order: [...order] });
|
|
111
|
+
}
|
|
112
|
+
for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 客户端会话标识(opencode 插件 chat.headers 注入)→ 透传上游做粘性路由/缓存亲和;
|
|
116
|
+
// 无头时由 upstream.js 按对话首两条消息哈希兜底(不再每请求随机)
|
|
117
|
+
const clientSession = String(req?.headers?.["x-session-affinity"] || req?.headers?.["x-session-id"] || "").trim() || null;
|
|
118
|
+
const handlerCtx = { reqId, api, model: null, body: req?.body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid, sessionId: clientSession, clientIp: reqIp };
|
|
119
|
+
if (clientSession) evt("client-session", { reqId, sessionId: clientSession.slice(0, 24) });
|
|
120
|
+
|
|
121
|
+
await engine.run({
|
|
122
|
+
reqId, startedAt, req, res, body: req?.body, policy,
|
|
123
|
+
useAuto, lockModel, requested, hops,
|
|
124
|
+
canFallback, canForwardPeers,
|
|
125
|
+
perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
|
|
126
|
+
auto, upstream, peers, groups, bus, token, plugins, logs,
|
|
127
|
+
order,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { execute, _policy: analyzePolicy, _engine: engine };
|
|
132
132
|
}
|
|
@@ -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
|
+
}
|
|
@@ -13,6 +13,7 @@ import { shouldUseGroupForModel, isHardLocalOnly, isKeyProviderDirectOnly } from
|
|
|
13
13
|
import { summarizeRequest, upstreamEcho } from "../model-trace.js";
|
|
14
14
|
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
|
+
import { classifyTerminalUpstream, readVerdictText, terminalShortcircuitOff } from "./terminal-verdict.js";
|
|
16
17
|
|
|
17
18
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
19
|
|
|
@@ -141,18 +142,44 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
141
142
|
return json(res, 403, errBody);
|
|
142
143
|
}
|
|
143
144
|
if (auto) await auto.recordError(model, { status: upRes.status });
|
|
144
|
-
// 读失败响应体(clone 不影响后续 relay 转发原响应;1s
|
|
145
|
+
// 读失败响应体(clone 不影响后续 relay 转发原响应;1s 上限防流式错误体拖慢)。
|
|
146
|
+
// 两份文本分工写死:`upRaw` 喂判据(≤4KB,反向护栏不能被 400 字摘要喂不到——多 provider 明细体
|
|
147
|
+
// 如 OpenRouter 的 `metadata.raw[]` 会把 rate limit 排在 model not found 之后),`upBody` 供日志摘要。
|
|
148
|
+
let upRaw = "";
|
|
145
149
|
let upBody = "";
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
upRes.clone().text(),
|
|
149
|
-
new Promise((r) => { const t = setTimeout(() => r(""), 1000); t.unref?.(); }),
|
|
150
|
-
])).replace(/\s+/g, " ").slice(0, 400);
|
|
151
|
-
} catch {}
|
|
150
|
+
upRaw = await readVerdictText(upRes);
|
|
151
|
+
upBody = upRaw.replace(/\s+/g, " ").slice(0, 400);
|
|
152
152
|
const upMsg = upBody || `upstream ${upRes.status}`;
|
|
153
153
|
lastErr = { model, upstream: upRes, status: upRes.status, message: upMsg };
|
|
154
|
-
|
|
154
|
+
// 终局判决短路(判据与现网取证见 terminal-verdict.js):上游把「这个模型你别想用」——免费层身份
|
|
155
|
+
// 闸门 / 模型 id 不存在——写死在错误里时,换组员撞的是同一堵墙(实测组员回同一条 FreeTierError),
|
|
156
|
+
// 接力只会把 6s 的真相拖成 70s 的 502 "peers failed"。逃生阀 MSLXDFF_TERMINAL_SHORTCIRCUIT=0。
|
|
157
|
+
const verdict = classifyTerminalUpstream(upRes.status, upRaw);
|
|
158
|
+
const terminalOn = verdict.terminal && !terminalShortcircuitOff();
|
|
159
|
+
// 一行话把「判决」与「原文」一起落 errors.log(不分两行:同一发失败在 errors.log 里只该有一条)
|
|
160
|
+
logError(model, upRes.status, `upstream ${upRes.status}${upBody ? ` body=${upBody.slice(0, 300)}` : ""}${terminalOn ? ` · 终局判决 ${verdict.kind}(跳过组员接力)` : ""}`);
|
|
155
161
|
evt("upstream-error", { reqId, model, status: upRes.status, message: upMsg.slice(0, 300), timing: upRes._t ?? null, ...upstreamEcho(upRes) });
|
|
162
|
+
if (terminalOn) {
|
|
163
|
+
evt("upstream-terminal", { reqId, model, status: upRes.status, kind: verdict.kind, marker: verdict.marker, peersSkipped: true });
|
|
164
|
+
// ASCII 具名头。**不**复用 x-mslxdff-notice:那头的既定语义是「已被别的模型代答」(与
|
|
165
|
+
// x-mslxdff-fallback 成组,src/routes/fallback.js:27-39),拿它报「没代答、直接拒」会把按头
|
|
166
|
+
// 判是否换模型的下游 UI 读反。头值只填 ASCII 枚举,中文留在 evt/logError/timeline(文件是 UTF-8)。
|
|
167
|
+
try { if (!res.headersSent && typeof res.setHeader === "function") res.setHeader("x-mslxdff-terminal", verdict.kind); } catch {}
|
|
168
|
+
if (canFallback && idx < order.length - 1) {
|
|
169
|
+
// 判决只对**这个模型**成立 → 换下一候选。这里刻意不挂 upstream:这条 Response 的 body 没被
|
|
170
|
+
// 消费,挂在 lastErr 上带到收场会白占连接(收场只 relay 最后一发),故直接 cancel 后只留文本。
|
|
171
|
+
lastErr = { model, upstream: null, status: upRes.status, message: upMsg, terminal: verdict.kind };
|
|
172
|
+
try { const cx = upRes.body?.cancel?.(); if (cx && typeof cx.catch === "function") cx.catch(() => {}); } catch {}
|
|
173
|
+
evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: `terminal ${verdict.kind}: ${upMsg.slice(0, 80)}` });
|
|
174
|
+
continue candidate;
|
|
175
|
+
}
|
|
176
|
+
// 收场交回既有出口:exhaustedLocal 见 lastErr.upstream 会 relay **原响应**(原状态码 + 原字节 +
|
|
177
|
+
// 原头),并自带 logCall / result / client-response / talk 语料落盘。自己 json() 反而劣化:
|
|
178
|
+
// 只能给被截到 400 字的二手摘要、JSON.parse 失败时退成字符串 error(SDK 读 err.error.message
|
|
179
|
+
// 拿到 undefined),且 SSE 已 flush 时 helpers 的 in-band 错误帧会把对象写成 [object Object]。
|
|
180
|
+
await exhaustedLocal({ res, body, lastErr, order, handlerCtx: { ...handlerCtx, model, reqId }, evt, logCall, mark, perf0, stages, done, requested, useAuto });
|
|
181
|
+
return { done: true };
|
|
182
|
+
}
|
|
156
183
|
upRes = null;
|
|
157
184
|
break;
|
|
158
185
|
}
|