mslxdff 0.1.224 → 0.1.225
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/package.json +1 -1
- package/src/chat-pipeline/context-forward.js +55 -0
- package/src/chat-pipeline/context-guard.js +101 -0
- package/src/chat-pipeline/serial-trial.js +9 -5
- package/src/routes/chat/broadband-handler.js +80 -72
- package/src/routes/chat/via-route-handler.js +10 -0
- package/src/routes/peers.js +13 -2
package/package.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// 转发阶段的上下文闸门编排(从 serial-trial.js 拆出,控单文件 ≤20KB 硬门)。
|
|
2
|
+
//
|
|
3
|
+
// 分工:context-guard.js 做纯判决(估算 token、比上限),本文件做**编排**——把「组员/宽带转发」
|
|
4
|
+
// 整段包起来,闸门一触发就收场,不再落到后面的换路逻辑。
|
|
5
|
+
//
|
|
6
|
+
// 为什么必须在这里停:闸门拦下**不是**上游失败。同一份大上下文换个目标(组员→宽带成员)照样撑爆,
|
|
7
|
+
// 只会白烧另一台机器的额度。故直接以标准 `context_length_exceeded` 收场,让客户端压缩后重试。
|
|
8
|
+
import { json } from "../routes/helpers.js";
|
|
9
|
+
|
|
10
|
+
/** 闸门产出的失败(finishContextGuard 与各 handler 共用同一个标记)。 */
|
|
11
|
+
export function isContextGuardErr(e) {
|
|
12
|
+
return Boolean(e && typeof e === "object" && e.contextGuard);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 收场:未 flush 时回标准错误体(客户端据 code 触发压缩);已 flush(流式已开始)交给既有出口写 in-band 帧。
|
|
17
|
+
*/
|
|
18
|
+
export async function finishContextGuard({
|
|
19
|
+
res, body, lastErr, order, handlerCtx, evt, logCall, logError, mark, perf0, stages, done,
|
|
20
|
+
requested, useAuto, model, exhaustedLocal,
|
|
21
|
+
}) {
|
|
22
|
+
const g = lastErr?.contextGuard || {};
|
|
23
|
+
evt("context-guard", {
|
|
24
|
+
reqId: handlerCtx.reqId, model, estimated: g.estimated, limit: g.limit, over: g.over,
|
|
25
|
+
action: "stop-forwarding",
|
|
26
|
+
});
|
|
27
|
+
try { logError(model, 400, lastErr.message); } catch {}
|
|
28
|
+
if (!res?.headersSent) {
|
|
29
|
+
return json(res, 400, {
|
|
30
|
+
error: { type: "invalid_request_error", code: "context_length_exceeded", message: lastErr.message },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
await exhaustedLocal({
|
|
34
|
+
res, body, lastErr, order,
|
|
35
|
+
handlerCtx: { ...handlerCtx, model, reqId: handlerCtx.reqId },
|
|
36
|
+
evt, logCall, mark, perf0, stages, done, requested, useAuto,
|
|
37
|
+
});
|
|
38
|
+
return { done: true };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 跑一段转发并在闸门触发时立即收场。
|
|
43
|
+
* @param {Function} run 真正发起转发的函数(应返回 {handled, lastErr})
|
|
44
|
+
* @param {object} ctx 收场所需的上下文(res/body/order/handlerCtx/evt/...)
|
|
45
|
+
* @returns {Promise<{done:boolean, lastErr?:object}>}
|
|
46
|
+
*/
|
|
47
|
+
export async function forwardOrGuard(run, ctx) {
|
|
48
|
+
const r = await run();
|
|
49
|
+
if (r?.handled) return { done: true };
|
|
50
|
+
if (isContextGuardErr(r?.lastErr)) {
|
|
51
|
+
await finishContextGuard({ ...ctx, lastErr: r.lastErr });
|
|
52
|
+
return { done: true };
|
|
53
|
+
}
|
|
54
|
+
return { done: false, lastErr: r?.lastErr };
|
|
55
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// 转发前上下文预检:本地额度用完要转给组员时,先把「上下文会不会撑爆对方」判掉。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要(用户定的口径):各家 free 模型的上下文窗口各不相同、大多在 200k 以下,而网关**没有**
|
|
4
|
+
// 一个可靠的上游窗口数据源 —— 靠模型自报会拿到 1M 这类不实的值(见 context-lookup 的 L4 兜底)。
|
|
5
|
+
// 于是本地出口还有额度时一切正常(本地模型窗口往往更大),一旦额度用完转给组员,同一份大上下文
|
|
6
|
+
// 在对方那里直接 `400 context_length_exceeded`。现网实证:errors.log 里 376 条超限,其中 274 条
|
|
7
|
+
// 来自 broadband(组员执行)、93 条来自 messages、仅 9 条 chat;模型集中在 deepseek-v4.1-flash-free(323)。
|
|
8
|
+
//
|
|
9
|
+
// 设计取舍:**不猜每个模型的确切窗口**,只设一个保守的统一上限(默认 200k)。超了就**不转发**,
|
|
10
|
+
// 让客户端按标准 `context_length_exceeded` 触发自己的压缩流程 —— 这正是「在转发之前触发压缩」。
|
|
11
|
+
// 宁可让客户端多压一次,也不要把一个大上下文白转给组员、烧掉对方额度再撞墙。
|
|
12
|
+
//
|
|
13
|
+
// 与 count-tokens 的关系:估算公式**同源**(../text-tokens.js),不另造第二个读数来源(ADR-0055)。
|
|
14
|
+
// 但计量对象不同:那边是 Anthropic 请求体(system/messages blocks),这边是**已翻译好的 chat body**
|
|
15
|
+
// (messages 数组 + tools),是真正要发给上游的那份,所以单独走一遍计数。
|
|
16
|
+
|
|
17
|
+
import { createTokenCounter } from "../text-tokens.js";
|
|
18
|
+
|
|
19
|
+
// 统一保守窗口。取值依据:现网 free 层主流 200k,且错误集中在 -free 模型。
|
|
20
|
+
// 未自报窗口的模型按这个值对外宣称(见 context-lookup 的 L4);转发前也按它判。
|
|
21
|
+
export const DEFAULT_FORWARD_MAX_CONTEXT_TOKENS = 200_000;
|
|
22
|
+
|
|
23
|
+
/** 读取转发前上限;`0`/负数 = 关闭预检(退回旧行为:无脑转发)。非法值按默认。 */
|
|
24
|
+
export function forwardMaxContextTokens(env = process.env) {
|
|
25
|
+
const raw = String(env.MSLXDFF_FORWARD_MAX_CONTEXT_TOKENS ?? "").trim();
|
|
26
|
+
if (raw === "") return DEFAULT_FORWARD_MAX_CONTEXT_TOKENS;
|
|
27
|
+
const n = Number(raw);
|
|
28
|
+
if (!Number.isFinite(n)) return DEFAULT_FORWARD_MAX_CONTEXT_TOKENS;
|
|
29
|
+
if (n <= 0) return 0;
|
|
30
|
+
const f = Math.floor(n);
|
|
31
|
+
// (0,1) 会被 floor 成 0 —— 那等于悄悄关掉闸门,按非法值回默认(同 context-lookup 的取舍)
|
|
32
|
+
return f > 0 ? f : DEFAULT_FORWARD_MAX_CONTEXT_TOKENS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 单条消息的文本面(role 名与 tool_call 参数也算:它们同样占上游上下文)
|
|
36
|
+
function addMessage(counter, m) {
|
|
37
|
+
if (!m || typeof m !== "object") return;
|
|
38
|
+
const c = m.content;
|
|
39
|
+
if (typeof c === "string") counter.add(c);
|
|
40
|
+
else if (Array.isArray(c)) {
|
|
41
|
+
for (const p of c) {
|
|
42
|
+
if (!p || typeof p !== "object") continue;
|
|
43
|
+
if (typeof p.text === "string") counter.add(p.text);
|
|
44
|
+
else if (p.type === "image") counter.add("x".repeat(4000)); // 图片按固定面值近似(不吃字符但占真实上下文)
|
|
45
|
+
}
|
|
46
|
+
} else if (c != null) counter.add(String(c));
|
|
47
|
+
if (Array.isArray(m.tool_calls)) {
|
|
48
|
+
for (const tc of m.tool_calls) {
|
|
49
|
+
counter.add(String(tc?.function?.name || ""));
|
|
50
|
+
counter.add(String(tc?.function?.arguments || ""));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 估算**已翻译的 chat body** 的输入 token 数。
|
|
57
|
+
* 逐段喂进同一个计数器再一次性套公式(与 text-tokens 的累计口径一致:逐段各套会乘上「非空至少 1」下限)。
|
|
58
|
+
*/
|
|
59
|
+
export function estimateChatBodyTokens(body = {}) {
|
|
60
|
+
const counter = createTokenCounter();
|
|
61
|
+
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
62
|
+
for (const m of messages) addMessage(counter, m);
|
|
63
|
+
if (Array.isArray(body?.tools) && body.tools.length) {
|
|
64
|
+
try { counter.add(JSON.stringify(body.tools)); } catch { /* 循环引用等异常:不计工具面 */ }
|
|
65
|
+
}
|
|
66
|
+
// tools 的 JSON 里已含 name/description/schema;system 已在 messages 首条里
|
|
67
|
+
return counter.tokens();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 同一次请求的 body 对象会在多个出站口被反复判(via-route → peer → broadband),
|
|
71
|
+
// 而大上下文扫描是 O(字符数)。按对象身份缓存,保证「每请求只算一次」(同 ADR-0055 决策 4 的口径)。
|
|
72
|
+
const estimateCache = new WeakMap();
|
|
73
|
+
|
|
74
|
+
function estimateOnce(body) {
|
|
75
|
+
if (!body || typeof body !== "object") return estimateChatBodyTokens(body);
|
|
76
|
+
const hit = estimateCache.get(body);
|
|
77
|
+
if (hit !== undefined) return hit;
|
|
78
|
+
const n = estimateChatBodyTokens(body);
|
|
79
|
+
estimateCache.set(body, n);
|
|
80
|
+
return n;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 转发前判决。返回 null = 放行;否则给出超限信息(调用方据此短路,**不转发**)。
|
|
85
|
+
* @param {object} body 已翻译的 chat body
|
|
86
|
+
* @param {{limit?:number}} opts limit 缺省读 env
|
|
87
|
+
* @returns {{estimated:number, limit:number, over:number}|null}
|
|
88
|
+
*/
|
|
89
|
+
export function overForwardLimit(body, opts = {}) {
|
|
90
|
+
const limit = opts.limit === undefined ? forwardMaxContextTokens() : Number(opts.limit) || 0;
|
|
91
|
+
if (!limit || limit <= 0) return null;
|
|
92
|
+
const estimated = estimateOnce(body);
|
|
93
|
+
if (estimated <= limit) return null;
|
|
94
|
+
return { estimated, limit, over: estimated - limit };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 给客户端看的文案:必须含 `context_length_exceeded` 语义,客户端据此触发压缩。 */
|
|
98
|
+
export function contextGuardMessage({ estimated, limit } = {}) {
|
|
99
|
+
return `context_length_exceeded: 请求约 ${estimated ?? "?"} tokens,超过网关注入的转发上限 ${limit ?? "?"};`
|
|
100
|
+
+ "本地节点额度已用尽,为避免把同一份上下文转发给组员后再次超限,请先运行 /compact 压缩上下文再重试。";
|
|
101
|
+
}
|
|
@@ -14,6 +14,7 @@ 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
16
|
import { classifyTerminalUpstream, readVerdictText, terminalShortcircuitOff } from "./terminal-verdict.js";
|
|
17
|
+
import { forwardOrGuard } from "./context-forward.js";
|
|
17
18
|
|
|
18
19
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
20
|
|
|
@@ -253,21 +254,24 @@ export async function runSerialTrial(ctx, deps = {}) {
|
|
|
253
254
|
}
|
|
254
255
|
break;
|
|
255
256
|
}
|
|
257
|
+
// 转发闸门(详见 context-forward.js):被拦即收场,不许换路重试——同一份大上下文换个目标照样撑爆。
|
|
258
|
+
const gc = { res, body, order, handlerCtx, evt, logCall, logError, mark, perf0, stages, done, requested, useAuto, model, exhaustedLocal };
|
|
256
259
|
if (canForwardPeers) {
|
|
257
260
|
if (!shouldUseGroupForModel(model)) {
|
|
258
261
|
evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (peer)` });
|
|
259
262
|
} else {
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
+
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);
|
|
264
|
+
if (fr.done) return fr;
|
|
265
|
+
lastErr = fr.lastErr ?? lastErr;
|
|
263
266
|
}
|
|
264
267
|
}
|
|
265
268
|
if (groups) {
|
|
266
269
|
if (!shouldUseGroupForModel(model)) {
|
|
267
270
|
evt("group-skip", { reqId, model, reason: `${groupSkipReason(model)} (broadband)` });
|
|
268
271
|
} else {
|
|
269
|
-
const
|
|
270
|
-
if (
|
|
272
|
+
const fr = await forwardOrGuard(() => broadbandRelay({ model, body, hops, lastErr, requested, useAuto, lockModel, auto, groups, token, bus, logs, handlerCtx, evt, mark, perf0, attemptStartMs: performance.now(), stages, res, startedAt, plugins }), gc);
|
|
273
|
+
if (fr.done) return fr;
|
|
274
|
+
lastErr = fr.lastErr ?? lastErr;
|
|
271
275
|
}
|
|
272
276
|
}
|
|
273
277
|
if (canFailover()) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: lastErr?.message || `upstream ${lastErr?.status ?? 502}` }); continue; }
|
|
@@ -1,72 +1,80 @@
|
|
|
1
|
-
import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "../stream.js";
|
|
2
|
-
import { buildFallbackInfo } from "../fallback.js";
|
|
3
|
-
import { createRelayPipeline } from "./relay-pipeline.js";
|
|
4
|
-
import { tryBroadbandRelay } from "../relay-queue.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
evt,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
1
|
+
import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "../stream.js";
|
|
2
|
+
import { buildFallbackInfo } from "../fallback.js";
|
|
3
|
+
import { createRelayPipeline } from "./relay-pipeline.js";
|
|
4
|
+
import { tryBroadbandRelay } from "../relay-queue.js";
|
|
5
|
+
import { overForwardLimit, contextGuardMessage } from "../../chat-pipeline/context-guard.js";
|
|
6
|
+
|
|
7
|
+
/** 薄适配:broadband 两种形态合一经由 pipeline */
|
|
8
|
+
export async function handleBroadbandRelay({
|
|
9
|
+
model,
|
|
10
|
+
body,
|
|
11
|
+
hops,
|
|
12
|
+
lastErr,
|
|
13
|
+
requested,
|
|
14
|
+
useAuto,
|
|
15
|
+
lockModel,
|
|
16
|
+
auto,
|
|
17
|
+
groups,
|
|
18
|
+
token,
|
|
19
|
+
bus,
|
|
20
|
+
logs,
|
|
21
|
+
handlerCtx,
|
|
22
|
+
evt,
|
|
23
|
+
mark,
|
|
24
|
+
perf0,
|
|
25
|
+
attemptStartMs,
|
|
26
|
+
stages,
|
|
27
|
+
res,
|
|
28
|
+
startedAt,
|
|
29
|
+
plugins,
|
|
30
|
+
deps = {},
|
|
31
|
+
}) {
|
|
32
|
+
const { createPipeline = createRelayPipeline, tryBroadbandRelay: tryRelay = tryBroadbandRelay } = deps;
|
|
33
|
+
// 转发前上下文预检:宽带成员是完全不同的另一台机器,上下文撑爆的代价(白烧它的额度)最实在。
|
|
34
|
+
const guard = overForwardLimit(body);
|
|
35
|
+
if (guard) {
|
|
36
|
+
const msg = contextGuardMessage(guard);
|
|
37
|
+
evt("context-guard", { reqId: handlerCtx.reqId, model, via: "broadband", estimated: guard.estimated, limit: guard.limit, over: guard.over });
|
|
38
|
+
return { handled: false, lastErr: { model, upstream: null, status: 400, message: msg, contextGuard: true } };
|
|
39
|
+
}
|
|
40
|
+
const bb = await tryRelay({ groups, token, model, body, hops, bus, logs, reqId: handlerCtx.reqId, evt, res, mark, perf0, stages });
|
|
41
|
+
if (!bb) {
|
|
42
|
+
evt("relay-miss", { reqId: handlerCtx.reqId, model });
|
|
43
|
+
return { handled: false };
|
|
44
|
+
}
|
|
45
|
+
const pipeline = createPipeline({
|
|
46
|
+
relay,
|
|
47
|
+
buildFallbackInfo,
|
|
48
|
+
auto,
|
|
49
|
+
plugins,
|
|
50
|
+
evt,
|
|
51
|
+
mark,
|
|
52
|
+
logCall: () => {},
|
|
53
|
+
logError: () => {},
|
|
54
|
+
constants: { STREAM_TIMEOUT_MS, SLOW_TOTAL_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS },
|
|
55
|
+
startedAt,
|
|
56
|
+
stages,
|
|
57
|
+
});
|
|
58
|
+
const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
|
|
59
|
+
if (isResponse) {
|
|
60
|
+
const r = await pipeline.execute({ res, upRes: bb.result, body, requested, actual: model, lastErr, via: "broadband", lockModel, useAuto, handlerCtx, mark, perf0, attemptStartMs, stages, startedAt });
|
|
61
|
+
if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
|
|
62
|
+
return { handled: true };
|
|
63
|
+
}
|
|
64
|
+
if (bb.result && typeof bb.result.status === "number") {
|
|
65
|
+
const b = bb.result.body || "";
|
|
66
|
+
const str = typeof b === "string" ? b : JSON.stringify(b);
|
|
67
|
+
const isSSE = bb.result.headers?.["Content-Type"]?.includes("text/event-stream");
|
|
68
|
+
const fakeRes = {
|
|
69
|
+
status: bb.result.status,
|
|
70
|
+
headers: { get: (k) => bb.result.headers?.[k] || bb.result.headers?.[k.toLowerCase()] || null },
|
|
71
|
+
text: async () => str,
|
|
72
|
+
body: isSSE ? (async function* () { yield Buffer.from(str); })() : null,
|
|
73
|
+
};
|
|
74
|
+
const r = await pipeline.execute({ res, upRes: fakeRes, body, requested, actual: model, lastErr, via: "broadband-local", lockModel, useAuto, handlerCtx, mark, perf0, attemptStartMs, stages, startedAt });
|
|
75
|
+
if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
|
|
76
|
+
return { handled: true };
|
|
77
|
+
}
|
|
78
|
+
evt("relay-miss", { reqId: handlerCtx.reqId, model });
|
|
79
|
+
return { handled: false };
|
|
80
|
+
}
|
|
@@ -5,6 +5,7 @@ import { getViaRoute } from "../../bench/via-routes.js";
|
|
|
5
5
|
import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
|
|
6
6
|
import { errMsg } from "../helpers.js";
|
|
7
7
|
import { compatFetch } from "../../compat.js";
|
|
8
|
+
import { overForwardLimit, contextGuardMessage } from "../../chat-pipeline/context-guard.js";
|
|
8
9
|
|
|
9
10
|
function shortLabel(p) {
|
|
10
11
|
const raw = String(p?.name || p?.id || p?.url || "").trim();
|
|
@@ -59,6 +60,15 @@ export async function handleViaRoute({
|
|
|
59
60
|
return { handled: false };
|
|
60
61
|
}
|
|
61
62
|
evt("via-route-hit", { reqId: handlerCtx.reqId, model, peer: peer.url, peerLabel, routeBest: route.best, at: route.at });
|
|
63
|
+
// 转发前上下文预检:via-route 是**第一条**出站口(比 peer/broadband 更早),大上下文在这里就该拦下,
|
|
64
|
+
// 否则白烧组员额度再撞上游 `400 context_length_exceeded`。放行由调用方继续走 peer/broadband 的同一闸门。
|
|
65
|
+
const guard = overForwardLimit(body);
|
|
66
|
+
if (guard) {
|
|
67
|
+
const msg = contextGuardMessage(guard);
|
|
68
|
+
evt("context-guard", { reqId: handlerCtx.reqId, model, via: "via-route", peer: peer.url, estimated: guard.estimated, limit: guard.limit, over: guard.over });
|
|
69
|
+
try { logError?.(model, 400, msg); } catch {}
|
|
70
|
+
return { handled: false, lastErr: { model, upstream: null, status: 400, message: msg, contextGuard: true } };
|
|
71
|
+
}
|
|
62
72
|
// 单路径转发,不并发:直接打 best peer
|
|
63
73
|
const hops = handlerCtx.hops || 0;
|
|
64
74
|
// ADR-0019:借道 = 用 A 的 key —— 转发时自动附带(组内互信,无开关),与 peers.js 共用同一组装点。
|
package/src/routes/peers.js
CHANGED
|
@@ -5,6 +5,7 @@ import { runHook } from "../plugins.js";
|
|
|
5
5
|
import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
|
|
6
6
|
import { compatFetch, timeoutSignal, getUndici } from "../compat.js";
|
|
7
7
|
import { serialTryPeers, PEER_SERIAL } from "./peer-serial.js";
|
|
8
|
+
import { overForwardLimit, contextGuardMessage } from "../chat-pipeline/context-guard.js";
|
|
8
9
|
|
|
9
10
|
// peer 阶段总预算:三批候选(ordered / orderedByLastError / coolingByLastError)共享同一个
|
|
10
11
|
// 截止时间,而不是每批各吃满一次等头超时。实测最坏路径是三批串行各 30s(≈90s),
|
|
@@ -143,8 +144,15 @@ export { clearPeerHealthCache, peerHealthyModels };
|
|
|
143
144
|
const DEFAULT_PEER_HEADERS_TIMEOUT_MS = 120_000;
|
|
144
145
|
|
|
145
146
|
async function forwardToPeer(peer, body, model, hops, controller = new AbortController(), waitMs = DEFAULT_PEER_HEADERS_TIMEOUT_MS) {
|
|
146
|
-
|
|
147
|
-
|
|
147
|
+
// 转发前上下文预检(真正的出站闸门):所有经组员的转发都从这里出去,大上下文必须在这里拦下。
|
|
148
|
+
// 现网实证:errors.log 里 376 条 `context_length_exceeded`,274 条正是组员执行撞的 —— 白烧对方额度。
|
|
149
|
+
const guard = overForwardLimit(body);
|
|
150
|
+
if (guard) {
|
|
151
|
+
const err = new Error(contextGuardMessage(guard));
|
|
152
|
+
err.contextGuard = guard;
|
|
153
|
+
return err;
|
|
154
|
+
}
|
|
155
|
+
const timer = setTimeout(() => controller.abort(), Math.max(1, Math.round(waitMs))); try {
|
|
148
156
|
const headers = {
|
|
149
157
|
"Content-Type": "application/json",
|
|
150
158
|
"Authorization": `Bearer ${peer.token}`,
|
|
@@ -214,6 +222,9 @@ function peerName(peer) {
|
|
|
214
222
|
// 组员试法:**串行**(用户 2026-10-19 决定:不竞速)。实现在 ./peer-serial.js,
|
|
215
223
|
// 本文件只保留调度与并发版(PEER_SERIAL=false 时的旧路径)。
|
|
216
224
|
export { PEER_SERIAL };
|
|
225
|
+
// 测试接缝:转发闸门是「上下文超限不许出站」的唯一落点,必须有直接单测钉住
|
|
226
|
+
// (经 race/serial 间接测会被网络的真实失败盖掉,判不出是闸门还是对端挂了)。
|
|
227
|
+
export { forwardToPeer };
|
|
217
228
|
|
|
218
229
|
export async function racePeerCandidates(candidates, ctx) {
|
|
219
230
|
const tried = (ctx.triedUrls ??= new Set());
|