mslxdff 0.1.131 → 0.1.133
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/cli/commands/provider/add.js +2 -2
- package/src/cli/commands/provider/config.js +1 -24
- package/src/cli/commands/provider/index.js +1 -2
- package/src/cli/commands/provider/keys.js +2 -2
- package/src/cli/provider-row.js +4 -6
- package/src/cli/status.js +1 -1
- package/src/providers/share-keys.js +15 -19
- package/src/providers/workbuddy/index.js +2 -10
- package/src/routes/chat/broadband-handler.js +8 -4
- package/src/routes/chat/exhausted-handler.js +14 -4
- package/src/routes/chat/hedge-handler.js +7 -4
- package/src/routes/chat/peer-handler.js +10 -6
- package/src/routes/chat/relay-pipeline.js +9 -5
- package/src/routes/chat/via-route-handler.js +9 -10
- package/src/routes/helpers.js +2 -1
- package/src/routes/peers.js +1 -1
- package/src/routes/stream.js +67 -19
- package/src/state/facade.js +1 -3
- package/src/state/merge.js +0 -1
- package/src/state/provider-config.js +1 -2
- package/src/state/schemas/provider.js +10 -14
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@ export async function handleProviderAdd(id, sub, rest) {
|
|
|
16
16
|
console.error(`provider "${gid}" is built-in — use: mslxdff -provider ${gid} add <key> or mslxdff -provider ${gid} set-url <url>`);
|
|
17
17
|
process.exit(1);
|
|
18
18
|
}
|
|
19
|
-
const { saveProviderConfig, loadProviderConfig
|
|
19
|
+
const { saveProviderConfig, loadProviderConfig } = await import("../../../state.js");
|
|
20
20
|
const { normalizeProviderId } = await import("../../../providers/model-id.js");
|
|
21
21
|
const nid = normalizeProviderId(gid);
|
|
22
22
|
if (!nid) { console.error(`invalid provider id: ${gid}`); process.exit(1); }
|
|
@@ -98,7 +98,7 @@ export async function handleProviderAdd(id, sub, rest) {
|
|
|
98
98
|
console.log(` keys: ${keys.length} (${keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")})`);
|
|
99
99
|
if (allowedModels.length) console.log(` allowedModels: ${allowedModels.length} (${allowedModels.join(", ")})`);
|
|
100
100
|
else console.log(` allowedModels: (none — BLOCKED, otherwise unusable) → mslxdff -provider ${nid} allowlist set <model...> OR mslxdff -provider ${nid} allowAny on (allow all)`);
|
|
101
|
-
console.log(` share:
|
|
101
|
+
console.log(` share: 借出(默认,key 随转发自动附带,ADR-0019)`);
|
|
102
102
|
console.log(` allowAny: OFF (secure, empty allowlist = 403 block before upstream) — enable via: mslxdff -provider ${nid} allowAny on`);
|
|
103
103
|
console.log(` use as: ${nid}/<model-id> — restart daemon to activate`);
|
|
104
104
|
if (allowedModels.length) console.log(` opencode 中使用: mslxdff -setto opencode ${nid}/${allowedModels[0]} (同步进 opencode.json,会一并设为默认模型)`);
|
|
@@ -5,7 +5,7 @@ export async function handleProviderConfig(id, sub, rest) {
|
|
|
5
5
|
console.error("opencode 是内置供应商,不能删除");
|
|
6
6
|
process.exit(1);
|
|
7
7
|
}
|
|
8
|
-
const { loadProviderConfigs, saveProviderConfig
|
|
8
|
+
const { loadProviderConfigs, saveProviderConfig } = await import("../../../state.js");
|
|
9
9
|
const { isPidAlive, readPid } = await import("../../../daemon.js");
|
|
10
10
|
const configs = loadProviderConfigs();
|
|
11
11
|
if (!configs[id] && !configs[nid]) {
|
|
@@ -33,16 +33,9 @@ export async function handleProviderConfig(id, sub, rest) {
|
|
|
33
33
|
writeStateImmediate(file, { providerKeys: nk });
|
|
34
34
|
changed = true;
|
|
35
35
|
}
|
|
36
|
-
if (raw.providerShareKeys && raw.providerShareKeys[id] !== undefined) {
|
|
37
|
-
const ns = { ...raw.providerShareKeys };
|
|
38
|
-
delete ns[id];
|
|
39
|
-
writeStateImmediate(file, { providerShareKeys: ns });
|
|
40
|
-
changed = true;
|
|
41
|
-
}
|
|
42
36
|
// 清理模型错误/延迟中该供应商前缀的条目(可选,不阻塞)
|
|
43
37
|
void changed;
|
|
44
38
|
} catch {}
|
|
45
|
-
try { saveProviderShareKeys(id, false); } catch {}
|
|
46
39
|
console.log(`已删除供应商: ${id} — 配置已清空`);
|
|
47
40
|
// 需要重启才生效,自动重启
|
|
48
41
|
const pid = readPid();
|
|
@@ -122,21 +115,5 @@ export async function handleProviderConfig(id, sub, rest) {
|
|
|
122
115
|
console.log(`set ${id} baseUrl: ${String(url).trim().replace(/\/+$/, "")} — restart daemon to activate`);
|
|
123
116
|
process.exit(0);
|
|
124
117
|
}
|
|
125
|
-
if (sub === "share") {
|
|
126
|
-
const { loadProviderShareKeys, saveProviderShareKeys } = await import("../../../state.js");
|
|
127
|
-
const on = rest[1];
|
|
128
|
-
if (!on) {
|
|
129
|
-
console.log(`share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"}`);
|
|
130
|
-
process.exit(0);
|
|
131
|
-
}
|
|
132
|
-
if (!["on", "off", "1", "0", "true", "false"].includes(String(on).toLowerCase())) {
|
|
133
|
-
console.error("usage: mslxdff -provider openrouter share on|off");
|
|
134
|
-
process.exit(1);
|
|
135
|
-
}
|
|
136
|
-
const state = ["on", "1", "true"].includes(String(on).toLowerCase());
|
|
137
|
-
saveProviderShareKeys(id, state);
|
|
138
|
-
console.log(`share keys to peers: ${state ? "ON" : "off"} — restart daemon to activate`);
|
|
139
|
-
process.exit(0);
|
|
140
|
-
}
|
|
141
118
|
return false;
|
|
142
119
|
}
|
|
@@ -25,13 +25,12 @@ export async function handleProvider(args) {
|
|
|
25
25
|
const sub = args[idx + 2];
|
|
26
26
|
const rest = args.slice(idx + 2);
|
|
27
27
|
if (!id) {
|
|
28
|
-
console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|
|
|
28
|
+
console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|set-url|allowlist|allowAny]");
|
|
29
29
|
console.error(" e.g. mslxdff -provider openrouter sk-1 sk-2 sk-3 set multiple keys (replaces all)");
|
|
30
30
|
console.error(" mslxdff -provider openrouter add sk-4 append one key");
|
|
31
31
|
console.error(" mslxdff -provider openrouter remove sk-1 remove a key by value");
|
|
32
32
|
console.error(" mslxdff -provider openrouter list list all keys (masked)");
|
|
33
33
|
console.error(" mslxdff -provider openrouter models [--json] list provider models (allowlist filtered)");
|
|
34
|
-
console.error(" mslxdff -provider openrouter share on|off share keys with peers on outgoing forward (ADR-0008)");
|
|
35
34
|
console.error(" mslxdff -provider openrouter set-url https://api.example.com/v1");
|
|
36
35
|
console.error(" mslxdff -provider openrouter set-models-path /v1/models");
|
|
37
36
|
console.error(" mslxdff -provider openrouter set-chat-path /v1/chat/completions");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export async function handleProviderKeys(id, sub, rest, args) {
|
|
2
2
|
if (sub === "list" || sub === "status") {
|
|
3
|
-
const { loadProviderKeys, loadProviderConfig,
|
|
3
|
+
const { loadProviderKeys, loadProviderConfig, loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("../../../state.js");
|
|
4
4
|
const keys = loadProviderKeys(id);
|
|
5
5
|
const cfg = loadProviderConfig(id);
|
|
6
6
|
const allowed = loadProviderAllowedModels(id);
|
|
@@ -15,7 +15,7 @@ export async function handleProviderKeys(id, sub, rest, args) {
|
|
|
15
15
|
} else {
|
|
16
16
|
console.log(` keys: (no keys configured)`);
|
|
17
17
|
}
|
|
18
|
-
console.log(` share keys to peers:
|
|
18
|
+
console.log(` share keys to peers: 借出(默认,key 随转发自动附带,ADR-0019)`);
|
|
19
19
|
console.log(` allowAnyModels: ${allowAny ? "ON (empty allowlist = allow all)" : "OFF (empty allowlist = BLOCK ALL)"} (mslxdff -provider ${id} allowAny on|off)`);
|
|
20
20
|
if (allowed.length) console.log(` allowedModels: ${allowed.length} (${allowed.join(", ")}) — only these can be used`);
|
|
21
21
|
else console.log(` allowedModels: (none — ${allowAny ? "allow all" : "BLOCK ALL"}) (mslxdff -provider ${id} allowlist set <model...> or allowAny on)`);
|
package/src/cli/provider-row.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
-
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels,
|
|
2
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } from "../state.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* 聚合 genericIds:来自 providerConfigs + providerKeys + env MSLXDFF_*_KEY
|
|
@@ -37,12 +37,10 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
|
|
|
37
37
|
const cfg = configs[gid];
|
|
38
38
|
let keys = [];
|
|
39
39
|
let baseUrl = "";
|
|
40
|
-
let share = false;
|
|
41
40
|
let allowed = [];
|
|
42
41
|
let allowAny = false;
|
|
43
42
|
try { keys = loadProviderKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
44
43
|
try { baseUrl = loadProviderBaseUrl(gid, stateFile ? { file: stateFile } : undefined) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : ""); } catch { baseUrl = cfg?.baseUrl || ""; }
|
|
45
|
-
try { share = loadProviderShareKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
46
44
|
try { allowed = loadProviderAllowedModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
47
45
|
try { allowAny = loadProviderAllowAnyModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
|
|
48
46
|
// openrouter 特殊:opencode 例外默认 allowAny true,其余默认 false
|
|
@@ -61,7 +59,7 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
|
|
|
61
59
|
} else if (gid === "workbuddy" && auths.length && auths.length !== keys.length) {
|
|
62
60
|
note = `${auths.length} auth(s) / ${keys.length} key(s) — 数量不一致请重跑 workbuddy-token-auto.js`;
|
|
63
61
|
}
|
|
64
|
-
rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, allowAny,
|
|
62
|
+
rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, allowAny, note, authCount: auths.length });
|
|
65
63
|
}
|
|
66
64
|
return rows;
|
|
67
65
|
}
|
|
@@ -94,8 +92,8 @@ export function formatProviderRow(p) {
|
|
|
94
92
|
const more = p.allowed.length > 2 ? ` …+${p.allowed.length - 2}` : "";
|
|
95
93
|
allowInfo = `allow ${p.allowed.length} → ${head}${more}`;
|
|
96
94
|
}
|
|
97
|
-
// share
|
|
98
|
-
const shareInfo = p.id === "opencode" ? "无法共享" :
|
|
95
|
+
// share(ADR-0019:默认借出,无开关)
|
|
96
|
+
const shareInfo = p.id === "opencode" ? "无法共享" : "共享 借出";
|
|
99
97
|
// base
|
|
100
98
|
const base = p.baseUrl && p.baseUrl !== "(none)" ? p.baseUrl : "(none)";
|
|
101
99
|
const baseLine = ` └ ${base}${p.note ? ` · ${p.note}` : ""}`;
|
package/src/cli/status.js
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { readPid, readPidVersion, isPidAlive, pidFile } from "../daemon.js";
|
|
5
5
|
import { defaultStateFile, getPort, loadGroupsJoined, loadModelErrors, loadModelStats, loadModelPicks } from "../state.js";
|
|
6
6
|
import { resolvePort } from "../server.js";
|
|
7
|
-
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels,
|
|
7
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderBaseUrl } from "../state.js";
|
|
8
8
|
import { loadToken } from "../state.js";
|
|
9
9
|
import { logDir } from "../logs.js";
|
|
10
10
|
import { createGroupsService } from "../groups.js";
|
|
@@ -1,29 +1,25 @@
|
|
|
1
|
-
// ADR-
|
|
1
|
+
// ADR-0019:借道 = 用你的 key —— 转发时自动借出,无开关、无白名单(组内互信是前提)。
|
|
2
2
|
// header: `x-mslxdff-share-keys: <providerId>=<key1>,<key2>;<providerId2>=<key3>`
|
|
3
3
|
import { splitModelId, DEFAULT_PROVIDER } from "./model-id.js";
|
|
4
|
-
import {
|
|
4
|
+
import { classifyProvider } from "./classify.js";
|
|
5
|
+
import { loadProviderKeys, listProviderIdsWithKeys } from "../state.js";
|
|
5
6
|
|
|
6
7
|
export const SHARE_KEYS_HEADER = "x-mslxdff-share-keys";
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
9
|
+
// 硬排除:refresh-token 型凭据(对端刷新会轮换 token,与本机互相踢下线)——正确性问题。
|
|
10
|
+
const NEVER_SHARE_IDS = new Set(["cline", "clinebot"]);
|
|
11
|
+
|
|
12
|
+
// 本节点应 cast key 到出站转发的供应商 id 集合 = 所有「本机有 key」的供应商,
|
|
13
|
+
// 减去:opencode(无 key 且恒排除)、local-only(workbuddy,本就不走组员)、刷新型凭据。
|
|
12
14
|
export function shareableProviderIds({ file } = {}) {
|
|
13
|
-
const ids =
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
ids.add(id);
|
|
20
|
-
}
|
|
21
|
-
return [...ids];
|
|
22
|
-
}
|
|
23
|
-
for (const id of ["openrouter"]) {
|
|
24
|
-
if (id !== DEFAULT_PROVIDER && loadProviderKeys(id, { file }).length && loadProviderShareKeys(id, { file })) ids.add(id);
|
|
15
|
+
const ids = [];
|
|
16
|
+
for (const id of listProviderIdsWithKeys({ file })) {
|
|
17
|
+
if (id === DEFAULT_PROVIDER) continue;
|
|
18
|
+
if (NEVER_SHARE_IDS.has(id)) continue;
|
|
19
|
+
try { if (classifyProvider(id) === "local-only") continue; } catch {}
|
|
20
|
+
ids.push(id);
|
|
25
21
|
}
|
|
26
|
-
return
|
|
22
|
+
return ids;
|
|
27
23
|
}
|
|
28
24
|
|
|
29
25
|
// 组装 share header 值(只含 shareable 且当前请求命中前缀的供应商)。
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createKeyRing } from "../keyring.js";
|
|
2
|
-
import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl,
|
|
2
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../../state.js";
|
|
3
3
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { compatFetch } from "../../compat.js";
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -176,15 +176,7 @@ export function createWorkbuddyProvider({
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
async function chatWithKeys(body, keysOverride) {
|
|
179
|
-
|
|
180
|
-
try { return loadProviderShareKeys(id, file ? { file } : {}); } catch { return false; }
|
|
181
|
-
})();
|
|
182
|
-
// allow injected balanceCache/logger to bypass share check for isolated tests:
|
|
183
|
-
// if keysOverride provided and logger is injected (test mode), permit share without state flag
|
|
184
|
-
const isTestInjection = !!balanceCacheOpt || !!loggerOpt;
|
|
185
|
-
if (!shareOn && !isTestInjection) {
|
|
186
|
-
return chatSvc.runChat(body, ring);
|
|
187
|
-
}
|
|
179
|
+
// 借入的 key(ADR-0019:转发时自动附带)——用隔离的 tmp ring/auth,不改动本机 ring。
|
|
188
180
|
// Isolated tmp ring and auth without mutating shared arrays
|
|
189
181
|
const tmpKeys = [...keysOverride].filter((k) => typeof k === "string" && k.trim().length).map((k) => k.trim());
|
|
190
182
|
const tmpAuth = authList[0] || { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
|
|
@@ -25,13 +25,15 @@ export async function handleBroadbandRelay({
|
|
|
25
25
|
res,
|
|
26
26
|
startedAt,
|
|
27
27
|
plugins,
|
|
28
|
+
deps = {},
|
|
28
29
|
}) {
|
|
29
|
-
const
|
|
30
|
+
const { createPipeline = createRelayPipeline, tryBroadbandRelay: tryRelay = tryBroadbandRelay } = deps;
|
|
31
|
+
const bb = await tryRelay({ groups, token, model, body, hops, bus, logs, reqId: handlerCtx.reqId, evt, res, mark, perf0, stages });
|
|
30
32
|
if (!bb) {
|
|
31
33
|
evt("relay-miss", { reqId: handlerCtx.reqId, model });
|
|
32
34
|
return { handled: false };
|
|
33
35
|
}
|
|
34
|
-
const pipeline =
|
|
36
|
+
const pipeline = createPipeline({
|
|
35
37
|
relay,
|
|
36
38
|
buildFallbackInfo,
|
|
37
39
|
auto,
|
|
@@ -46,7 +48,8 @@ export async function handleBroadbandRelay({
|
|
|
46
48
|
});
|
|
47
49
|
const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
|
|
48
50
|
if (isResponse) {
|
|
49
|
-
await pipeline.execute({ res, upRes: bb.result, body, requested, actual: model, lastErr, via: "broadband", lockModel, useAuto, handlerCtx, mark, perf0, stages, startedAt });
|
|
51
|
+
const r = await pipeline.execute({ res, upRes: bb.result, body, requested, actual: model, lastErr, via: "broadband", lockModel, useAuto, handlerCtx, mark, perf0, stages, startedAt });
|
|
52
|
+
if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
|
|
50
53
|
return { handled: true };
|
|
51
54
|
}
|
|
52
55
|
if (bb.result && typeof bb.result.status === "number") {
|
|
@@ -59,7 +62,8 @@ export async function handleBroadbandRelay({
|
|
|
59
62
|
text: async () => str,
|
|
60
63
|
body: isSSE ? (async function* () { yield Buffer.from(str); })() : null,
|
|
61
64
|
};
|
|
62
|
-
await pipeline.execute({ res, upRes: fakeRes, body, requested, actual: model, lastErr, via: "broadband-local", lockModel, useAuto, handlerCtx, mark, perf0, stages, startedAt });
|
|
65
|
+
const r = await pipeline.execute({ res, upRes: fakeRes, body, requested, actual: model, lastErr, via: "broadband-local", lockModel, useAuto, handlerCtx, mark, perf0, stages, startedAt });
|
|
66
|
+
if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
|
|
63
67
|
return { handled: true };
|
|
64
68
|
}
|
|
65
69
|
evt("relay-miss", { reqId: handlerCtx.reqId, model });
|
|
@@ -2,18 +2,23 @@ import { relay } from "../stream.js";
|
|
|
2
2
|
import { json } from "../helpers.js";
|
|
3
3
|
import { performance } from "node:perf_hooks";
|
|
4
4
|
|
|
5
|
-
export async function handleExhaustedLocal({ res, body, lastErr, order, handlerCtx, evt, logCall, mark, perf0, stages, done, requested, useAuto }) {
|
|
5
|
+
export async function handleExhaustedLocal({ res, body, lastErr, order, handlerCtx, evt, logCall, mark, perf0, stages, done, requested, useAuto, deps = {} }) {
|
|
6
|
+
const { relay: relayImpl = relay } = deps;
|
|
6
7
|
const model = lastErr?.model ?? handlerCtx.model;
|
|
7
8
|
evt("exhausted-local", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? model, lastStatus: lastErr?.status ?? 502, order });
|
|
8
9
|
logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
|
|
9
10
|
if (lastErr?.upstream) {
|
|
10
11
|
evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", isStream: Boolean(body.stream) });
|
|
11
|
-
const out = await
|
|
12
|
+
const out = await relayImpl(res, lastErr.upstream, body, {
|
|
12
13
|
onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
|
|
13
14
|
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
14
15
|
});
|
|
15
16
|
evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
16
17
|
evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
18
|
+
// 最后一站:relay 超时路径不写响应(设计留给上层 failover),这里没有上层,必须自己收尾
|
|
19
|
+
if (out.timedOut) {
|
|
20
|
+
json(res, 502, { error: out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms` });
|
|
21
|
+
}
|
|
17
22
|
return true;
|
|
18
23
|
}
|
|
19
24
|
evt("result", { reqId: handlerCtx.reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
@@ -22,17 +27,22 @@ export async function handleExhaustedLocal({ res, body, lastErr, order, handlerC
|
|
|
22
27
|
return true;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
export async function handleExhaustedAll({ res, body, lastErr, order, requested, handlerCtx, evt, logCall, mark, perf0, stages }) {
|
|
30
|
+
export async function handleExhaustedAll({ res, body, lastErr, order, requested, handlerCtx, evt, logCall, mark, perf0, stages, deps = {} }) {
|
|
31
|
+
const { relay: relayImpl = relay } = deps;
|
|
26
32
|
evt("exhausted-all", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? requested, lastStatus: lastErr?.status ?? 502, order });
|
|
27
33
|
logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
|
|
28
34
|
if (lastErr?.upstream) {
|
|
29
35
|
evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", isStream: Boolean(body.stream) });
|
|
30
|
-
const out = await
|
|
36
|
+
const out = await relayImpl(res, lastErr.upstream, body, {
|
|
31
37
|
onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
|
|
32
38
|
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
33
39
|
});
|
|
34
40
|
evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
35
41
|
evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
42
|
+
// 最后一站:relay 超时路径不写响应,这里没有上层 failover,必须自己收尾
|
|
43
|
+
if (out.timedOut) {
|
|
44
|
+
json(res, 502, { error: out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms` });
|
|
45
|
+
}
|
|
36
46
|
return true;
|
|
37
47
|
}
|
|
38
48
|
evt("result", { reqId: handlerCtx.reqId, model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
@@ -27,14 +27,16 @@ export async function handleHedge({
|
|
|
27
27
|
plugins,
|
|
28
28
|
res,
|
|
29
29
|
hedgeDelayMs,
|
|
30
|
+
deps = {},
|
|
30
31
|
}) {
|
|
32
|
+
const { createPipeline = createRelayPipeline, hedgedFirstChunkRace: race = hedgedFirstChunkRace } = deps;
|
|
31
33
|
const d = hedgeDelayMs;
|
|
32
34
|
try {
|
|
33
|
-
const hedged = await
|
|
35
|
+
const hedged = await race({ localUpRes: upRes, peers, handlerCtx, hedgeDelayMs: d, evt });
|
|
34
36
|
if (hedged && hedged.winner) {
|
|
35
37
|
if (hedged.winner === "local") {
|
|
36
38
|
const bufferedUpRes = { ...upRes, body: hedged.bufferedBody, headers: upRes.headers, status: upRes.status, _t: upRes._t };
|
|
37
|
-
const pipeline =
|
|
39
|
+
const pipeline = createPipeline({
|
|
38
40
|
relay,
|
|
39
41
|
buildFallbackInfo,
|
|
40
42
|
auto,
|
|
@@ -72,7 +74,7 @@ export async function handleHedge({
|
|
|
72
74
|
evt("peer-race-win", { reqId: handlerCtx.reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs, hedged: true, ttfMs: hedged.ttfMs });
|
|
73
75
|
await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
|
|
74
76
|
const bufferedPeerRes = { ...win.res, body: hedged.bufferedBody, headers: win.res.headers, status: win.res.status, _t: win.res._t };
|
|
75
|
-
const pipeline =
|
|
77
|
+
const pipeline = createPipeline({
|
|
76
78
|
relay,
|
|
77
79
|
buildFallbackInfo,
|
|
78
80
|
auto,
|
|
@@ -85,7 +87,7 @@ export async function handleHedge({
|
|
|
85
87
|
startedAt,
|
|
86
88
|
stages,
|
|
87
89
|
});
|
|
88
|
-
await pipeline.execute({
|
|
90
|
+
const r = await pipeline.execute({
|
|
89
91
|
res,
|
|
90
92
|
upRes: bufferedPeerRes,
|
|
91
93
|
body,
|
|
@@ -101,6 +103,7 @@ export async function handleHedge({
|
|
|
101
103
|
stages,
|
|
102
104
|
startedAt,
|
|
103
105
|
});
|
|
106
|
+
if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
|
|
104
107
|
return { handled: true };
|
|
105
108
|
}
|
|
106
109
|
}
|
|
@@ -22,14 +22,16 @@ export async function handlePeerRelay({
|
|
|
22
22
|
startedAt,
|
|
23
23
|
plugins,
|
|
24
24
|
res,
|
|
25
|
+
deps = {},
|
|
25
26
|
}) {
|
|
27
|
+
const { createPipeline = createRelayPipeline, racePeerCandidates: race = racePeerCandidates } = deps;
|
|
26
28
|
evt("peer-race-start", { reqId: handlerCtx.reqId, model, peers: peers.ordered().length });
|
|
27
29
|
const peerErrors = [];
|
|
28
30
|
const pctx = { ...handlerCtx, peerErrors };
|
|
29
31
|
const win =
|
|
30
|
-
(await
|
|
31
|
-
(await
|
|
32
|
-
(await
|
|
32
|
+
(await race(peers.ordered(), pctx)) ||
|
|
33
|
+
(await race(peers.orderedByLastError(), pctx)) ||
|
|
34
|
+
(await race(peers.coolingByLastError(), pctx));
|
|
33
35
|
if (!win) {
|
|
34
36
|
evt("peer-race-lose", { reqId: handlerCtx.reqId, model });
|
|
35
37
|
// 组员全失败:把每个组员的真实返回汇总给调用者(否则只剩一个无信息的 429/502)
|
|
@@ -42,7 +44,7 @@ export async function handlePeerRelay({
|
|
|
42
44
|
evt("peer-race-win", { reqId: handlerCtx.reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs });
|
|
43
45
|
await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
|
|
44
46
|
|
|
45
|
-
const pipeline =
|
|
47
|
+
const pipeline = createPipeline({
|
|
46
48
|
relay,
|
|
47
49
|
buildFallbackInfo,
|
|
48
50
|
auto,
|
|
@@ -55,7 +57,7 @@ export async function handlePeerRelay({
|
|
|
55
57
|
startedAt,
|
|
56
58
|
stages,
|
|
57
59
|
});
|
|
58
|
-
await pipeline.execute({
|
|
60
|
+
const r = await pipeline.execute({
|
|
59
61
|
res,
|
|
60
62
|
upRes: win.res,
|
|
61
63
|
body,
|
|
@@ -71,5 +73,7 @@ export async function handlePeerRelay({
|
|
|
71
73
|
stages,
|
|
72
74
|
startedAt,
|
|
73
75
|
});
|
|
76
|
+
// Note: 超时/失败必须把 handled:false 交回上层换候选(丢弃 → 响应永不 end)— 见 .agents/notes/implemented/bug-fix/2026-09-17-relay-first-chunk-gate-real-cancel.md
|
|
77
|
+
if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
|
|
74
78
|
return { handled: true };
|
|
75
|
-
}
|
|
79
|
+
}
|
|
@@ -5,7 +5,7 @@ import { computeMetrics } from "../../metrics.js";
|
|
|
5
5
|
|
|
6
6
|
// 唯一/最后候选没有 failover 去向:首块闸门退化为纯"防连接泄漏",放宽避免误杀慢模型
|
|
7
7
|
// (参考 opencode:zen 通道不设超时;openai responses 硬编码 300s headerTimeout)
|
|
8
|
-
const LAST_CANDIDATE_TIMEOUT_MS = (() => {
|
|
8
|
+
export const LAST_CANDIDATE_TIMEOUT_MS = (() => {
|
|
9
9
|
const n = Number(process.env.MSLXDFF_LAST_CANDIDATE_TIMEOUT_MS);
|
|
10
10
|
return Number.isInteger(n) && n >= 0 ? n : 120_000;
|
|
11
11
|
})();
|
|
@@ -60,6 +60,7 @@ export function createRelayPipeline({
|
|
|
60
60
|
perf0,
|
|
61
61
|
stages: s2,
|
|
62
62
|
startedAt: sa2,
|
|
63
|
+
streamTimeoutMs: ctxStreamTimeoutMs,
|
|
63
64
|
} = {}) {
|
|
64
65
|
const markFn = m2 || _mark;
|
|
65
66
|
const curStartedAt = sa2 ?? defaultStartedAt ?? Date.now();
|
|
@@ -79,13 +80,15 @@ export function createRelayPipeline({
|
|
|
79
80
|
}
|
|
80
81
|
_evt("relay-start", { reqId, model: actual, via, isStream: Boolean(body?.stream), fallback });
|
|
81
82
|
|
|
82
|
-
// 3. relay(唯一/最后候选:无 failover 去向 →
|
|
83
|
+
// 3. relay(唯一/最后候选:无 failover 去向 → 闸门放宽到防泄漏级别;显式 streamTimeoutMs 优先)
|
|
83
84
|
const orderLen = handlerCtx?.orderLen;
|
|
84
85
|
const curIdx = handlerCtx?.idx;
|
|
85
86
|
const isLastCandidate =
|
|
86
87
|
Number.isInteger(orderLen) && orderLen > 0 &&
|
|
87
88
|
(orderLen === 1 || (Number.isInteger(curIdx) && curIdx >= orderLen - 1));
|
|
88
|
-
const streamTimeoutMs =
|
|
89
|
+
const streamTimeoutMs = Number.isInteger(ctxStreamTimeoutMs) && ctxStreamTimeoutMs >= 0
|
|
90
|
+
? ctxStreamTimeoutMs
|
|
91
|
+
: (isLastCandidate ? LAST_CANDIDATE_TIMEOUT_MS : C.STREAM_TIMEOUT_MS);
|
|
89
92
|
const out = await _relay(res, upRes, body, {
|
|
90
93
|
fallback,
|
|
91
94
|
streamTimeoutMs,
|
|
@@ -115,11 +118,12 @@ export function createRelayPipeline({
|
|
|
115
118
|
|
|
116
119
|
// 5a. 首块超时未写字节 → 回退(显式 timedOut 字段,status 只是 HTTP 语义展示)
|
|
117
120
|
if (out.timedOut === true) {
|
|
121
|
+
const why = out.detail?.upstreamError ? ` (upstream read error: ${out.detail.upstreamError})` : "";
|
|
118
122
|
if (auto) try { await auto.recordError(actual, { status: 502, slow: true, note: `stream timeout ${streamTimeoutMs}ms` }); } catch {}
|
|
119
|
-
try { _logError(actual, 502, `stream timeout ${streamTimeoutMs}ms`); } catch {}
|
|
123
|
+
try { _logError(actual, 502, `stream timeout ${streamTimeoutMs}ms${why}`); } catch {}
|
|
120
124
|
_evt("upstream-error", { reqId, model: actual, status: 502, message: "stream timeout", timing: null });
|
|
121
125
|
_evt("fallback", { reqId, from: actual, to: null, reason: "stream timeout" });
|
|
122
|
-
return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: `stream timed out after ${streamTimeoutMs}ms` } };
|
|
126
|
+
return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: `stream timed out after ${streamTimeoutMs}ms${why}` } };
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
// 5b. 中断(stall 超时 / max 流时长)
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { createRelayPipeline } from "./relay-pipeline.js";
|
|
1
|
+
import { createRelayPipeline, LAST_CANDIDATE_TIMEOUT_MS } from "./relay-pipeline.js";
|
|
2
2
|
import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "../stream.js";
|
|
3
3
|
import { buildFallbackInfo } from "../fallback.js";
|
|
4
4
|
import { getViaRoute } from "../../bench/via-routes.js";
|
|
5
|
-
import {
|
|
6
|
-
import { SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
|
|
5
|
+
import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
|
|
7
6
|
import { errMsg } from "../helpers.js";
|
|
8
7
|
import { compatFetch } from "../../compat.js";
|
|
9
8
|
|
|
@@ -61,12 +60,9 @@ export async function handleViaRoute({
|
|
|
61
60
|
evt("via-route-hit", { reqId: handlerCtx.reqId, model, peer: peer.url, peerLabel, routeBest: route.best, at: route.at });
|
|
62
61
|
// 单路径转发,不并发:直接打 best peer
|
|
63
62
|
const hops = handlerCtx.hops || 0;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const keys = loadProviderKeys(providerId) || [];
|
|
68
|
-
if (keys.length) shareHeader = `${providerId}=${keys.join(",")}`;
|
|
69
|
-
} catch {}
|
|
63
|
+
// ADR-0019:借道 = 用 A 的 key —— 转发时自动附带(组内互信,无开关),与 peers.js 共用同一组装点。
|
|
64
|
+
// Note: 见 .agents/notes/implemented/architecture/2026-09-17-share-keys-no-switch.md
|
|
65
|
+
const shareHeader = buildShareKeysHeader(model);
|
|
70
66
|
const controller = new AbortController();
|
|
71
67
|
const timer = setTimeout(() => controller.abort(new Error("via-route timeout 30000ms")), 30000);
|
|
72
68
|
let upRes;
|
|
@@ -125,7 +121,7 @@ export async function handleViaRoute({
|
|
|
125
121
|
startedAt,
|
|
126
122
|
stages,
|
|
127
123
|
});
|
|
128
|
-
await pipeline.execute({
|
|
124
|
+
const r = await pipeline.execute({
|
|
129
125
|
res,
|
|
130
126
|
upRes,
|
|
131
127
|
body,
|
|
@@ -140,6 +136,9 @@ export async function handleViaRoute({
|
|
|
140
136
|
perf0,
|
|
141
137
|
stages,
|
|
142
138
|
startedAt,
|
|
139
|
+
// 借道单路径无第二候选可比:按末位耐心档(默认 120s,MSLXDFF_LAST_CANDIDATE_TIMEOUT_MS 可调/0=不限)
|
|
140
|
+
streamTimeoutMs: LAST_CANDIDATE_TIMEOUT_MS,
|
|
143
141
|
});
|
|
142
|
+
if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
|
|
144
143
|
return { handled: true };
|
|
145
144
|
}
|
package/src/routes/helpers.js
CHANGED
|
@@ -19,7 +19,8 @@ export function authorized(req, token) {
|
|
|
19
19
|
|
|
20
20
|
export function json(res, status, body) {
|
|
21
21
|
res.statusCode = status;
|
|
22
|
-
|
|
22
|
+
// 已发 headers(如 failover 时前一个候选写过 SSE 注释帧)不可再设,否则抛 ERR_HTTP_HEADERS_SENT
|
|
23
|
+
try { if (!res.headersSent) res.setHeader("Content-Type", "application/json"); } catch { /* ignore */ }
|
|
23
24
|
res.end(JSON.stringify(body));
|
|
24
25
|
}
|
|
25
26
|
|
package/src/routes/peers.js
CHANGED
|
@@ -122,7 +122,7 @@ async function forwardToPeer(peer, body, model, hops, controller = new AbortCont
|
|
|
122
122
|
"x-mslxdff-model-lock": model,
|
|
123
123
|
"Accept": "text/event-stream",
|
|
124
124
|
};
|
|
125
|
-
// ADR-
|
|
125
|
+
// ADR-0019:命中供应商的 key 随转发自动借出(组内互信,无开关;opencode/workbuddy/cline 硬排除)
|
|
126
126
|
const shareHeader = buildShareKeysHeader(model);
|
|
127
127
|
if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
|
|
128
128
|
const dispatcher = getPeerDispatcher();
|
package/src/routes/stream.js
CHANGED
|
@@ -20,6 +20,29 @@ function cancelBody(body) {
|
|
|
20
20
|
} catch { /* ignore */ }
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// 注释帧(": keepalive" 等)不是模型输出:不算首块、不解除闸门、不触发超时救回,但照常透传
|
|
24
|
+
function hasPayload(text) {
|
|
25
|
+
for (const line of String(text).split("\n")) {
|
|
26
|
+
const t = line.trim();
|
|
27
|
+
if (!t || t.startsWith(":")) continue;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 自持 reader 优先:for-await 会锁定 ReadableStream,使 body.cancel() 必 reject(真流上等于空操作),
|
|
34
|
+
// 超时/断下游要真能掐上游必须走 reader.cancel()
|
|
35
|
+
async function* bodyChunks(body, reader) {
|
|
36
|
+
if (reader) {
|
|
37
|
+
for (;;) {
|
|
38
|
+
const { done, value } = await reader.read();
|
|
39
|
+
if (done) return;
|
|
40
|
+
yield value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for await (const c of body) yield c;
|
|
44
|
+
}
|
|
45
|
+
|
|
23
46
|
export const SLOW_TOTAL_MS = (() => {
|
|
24
47
|
const n = Number(process.env.MSLXDFF_SLOW_TOTAL_MS);
|
|
25
48
|
return Number.isInteger(n) && n > 0 ? n : 20_000;
|
|
@@ -94,16 +117,26 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
94
117
|
recoveries: 0,
|
|
95
118
|
};
|
|
96
119
|
let prevChunkAt = t0;
|
|
120
|
+
// 断下游即掐上游:流式分支装配真实取消,非流式保持 no-op
|
|
121
|
+
let cancelUpstream = () => {};
|
|
97
122
|
const onClose = () => {
|
|
98
123
|
detail.downstreamClosed = true;
|
|
99
|
-
if (!finishedNormally
|
|
124
|
+
if (!finishedNormally) {
|
|
125
|
+
cancelUpstream();
|
|
126
|
+
if (onDownstreamAbort) onDownstreamAbort();
|
|
127
|
+
}
|
|
100
128
|
};
|
|
101
129
|
res.on("close", onClose);
|
|
102
130
|
|
|
103
131
|
if (isStream) {
|
|
104
|
-
|
|
105
|
-
res.
|
|
106
|
-
|
|
132
|
+
// failover 重入时 headers 可能已发(前一个候选只发过 keepalive 注释帧就被掐)——已发则不可再设
|
|
133
|
+
if (!res.headersSent) {
|
|
134
|
+
try {
|
|
135
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
136
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
137
|
+
res.setHeader("Connection", "keep-alive");
|
|
138
|
+
} catch { /* ignore */ }
|
|
139
|
+
}
|
|
107
140
|
if (fallback?.fallback) {
|
|
108
141
|
try {
|
|
109
142
|
res.write(`: mslxdff fallback ${fallback.requested_model} -> ${fallback.actual_model} (${fallback.reason})\n`);
|
|
@@ -113,10 +146,21 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
113
146
|
if (upRes.body) {
|
|
114
147
|
let first = true;
|
|
115
148
|
let wroteAny = false;
|
|
149
|
+
let wrotePayload = false; // 真实数据帧(注释帧不算):决定能否安全 failover
|
|
116
150
|
let timedOut = false;
|
|
117
151
|
let stalled = false;
|
|
118
152
|
let tooLong = false;
|
|
119
153
|
let stallTimer = null;
|
|
154
|
+
// 自持 reader:超时/断下游时 reader.cancel() 才真的掐得断上游
|
|
155
|
+
// Note: 闸门真取消 + 注释帧不算首块 + wrotePayload 判据 — 见 .agents/notes/implemented/bug-fix/2026-09-17-relay-first-chunk-gate-real-cancel.md
|
|
156
|
+
const reader = typeof upRes.body.getReader === "function" ? upRes.body.getReader() : null;
|
|
157
|
+
let cancelled = false;
|
|
158
|
+
cancelUpstream = () => {
|
|
159
|
+
if (cancelled) return;
|
|
160
|
+
cancelled = true;
|
|
161
|
+
if (reader) { try { reader.cancel().catch(() => {}); } catch { /* ignore */ } }
|
|
162
|
+
else cancelBody(upRes.body);
|
|
163
|
+
};
|
|
120
164
|
let pingTimer = keepaliveMs > 0
|
|
121
165
|
? setInterval(() => {
|
|
122
166
|
if (wroteAny) return;
|
|
@@ -129,7 +173,7 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
129
173
|
? setTimeout(() => {
|
|
130
174
|
stalled = true;
|
|
131
175
|
detail.exitReason = "stall";
|
|
132
|
-
|
|
176
|
+
cancelUpstream();
|
|
133
177
|
}, STALL_TIMEOUT_MS)
|
|
134
178
|
: null;
|
|
135
179
|
};
|
|
@@ -137,18 +181,18 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
137
181
|
? setTimeout(() => {
|
|
138
182
|
timedOut = true;
|
|
139
183
|
detail.exitReason = "first-timeout";
|
|
140
|
-
|
|
184
|
+
cancelUpstream();
|
|
141
185
|
}, streamTimeoutMs)
|
|
142
186
|
: null;
|
|
143
187
|
const maxTimer = MAX_STREAM_MS
|
|
144
188
|
? setTimeout(() => {
|
|
145
189
|
tooLong = true;
|
|
146
190
|
detail.exitReason = "max";
|
|
147
|
-
|
|
191
|
+
cancelUpstream();
|
|
148
192
|
}, MAX_STREAM_MS)
|
|
149
193
|
: null;
|
|
150
194
|
try {
|
|
151
|
-
for await (const chunk of upRes.body) {
|
|
195
|
+
for await (const chunk of bodyChunks(upRes.body, reader)) {
|
|
152
196
|
const now = performance.now();
|
|
153
197
|
detail.receivedChunks += 1;
|
|
154
198
|
const len = chunk?.length ?? chunk?.byteLength ?? 0;
|
|
@@ -159,8 +203,9 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
159
203
|
if (gap > detail.maxGapMs) detail.maxGapMs = gap;
|
|
160
204
|
if (gap > SCORE_STALL_MS) detail.stallHits += 1;
|
|
161
205
|
prevChunkAt = now;
|
|
206
|
+
const txt = chunkText(chunk);
|
|
207
|
+
const isPayload = hasPayload(txt);
|
|
162
208
|
try {
|
|
163
|
-
const txt = chunkText(chunk);
|
|
164
209
|
if (txt.includes("[DONE]")) detail.sawDone = true;
|
|
165
210
|
const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);
|
|
166
211
|
if (m) detail.sawFinishReason = m[1];
|
|
@@ -196,9 +241,10 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
196
241
|
} catch {}
|
|
197
242
|
}
|
|
198
243
|
} catch { /* ignore */ }
|
|
199
|
-
//
|
|
200
|
-
//(cancel
|
|
201
|
-
|
|
244
|
+
// 首块/空闲超时后上游仍吐出了真实数据 → 只是慢,不是死:撤销超时判定,照常转发
|
|
245
|
+
//(cancel 是异步的,竞态窗口内已到达的数据是纯收益;丢掉是纯损失)
|
|
246
|
+
// 注释帧(keepalive)不算:否则对端只要在发心跳,闸门就永远解除
|
|
247
|
+
if (isPayload && (timedOut || stalled)) {
|
|
202
248
|
timedOut = false;
|
|
203
249
|
stalled = false;
|
|
204
250
|
detail.recoveries = (detail.recoveries || 0) + 1;
|
|
@@ -206,14 +252,14 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
206
252
|
if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
|
|
207
253
|
}
|
|
208
254
|
if (timedOut || stalled || tooLong) break;
|
|
209
|
-
if (first) {
|
|
255
|
+
if (isPayload && first) {
|
|
210
256
|
first = false;
|
|
211
257
|
ttf = Math.round(now - t0);
|
|
212
258
|
onFirstChunk?.(ttf);
|
|
213
259
|
if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
|
|
214
260
|
}
|
|
215
261
|
let outChunk = chunk;
|
|
216
|
-
if (first === false && fallback?.fallback &&
|
|
262
|
+
if (first === false && fallback?.fallback && wrotePayload === false) {
|
|
217
263
|
try {
|
|
218
264
|
let txt = "";
|
|
219
265
|
if (Buffer.isBuffer(chunk)) txt = chunk.toString("utf8");
|
|
@@ -226,16 +272,17 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
226
272
|
} catch {}
|
|
227
273
|
}
|
|
228
274
|
wroteAny = true;
|
|
275
|
+
if (isPayload) wrotePayload = true;
|
|
229
276
|
detail.wroteChunks += 1;
|
|
230
277
|
detail.wroteBytes += Buffer.isBuffer(outChunk) ? outChunk.length : (outChunk?.length ?? len);
|
|
231
|
-
res.write(outChunk);
|
|
278
|
+
try { res.write(outChunk); } catch { /* 下游已断开:onClose 已掐上游 */ }
|
|
232
279
|
armStall();
|
|
233
280
|
}
|
|
234
|
-
if (!detail.exitReason) detail.exitReason = "normal";
|
|
281
|
+
if (!detail.exitReason) detail.exitReason = detail.downstreamClosed ? "downstream-closed" : "normal";
|
|
235
282
|
} catch (err) {
|
|
236
283
|
detail.upstreamError = String(err?.message || err).slice(0, 300);
|
|
237
284
|
detail.exitReason = "upstream-error";
|
|
238
|
-
if (!
|
|
285
|
+
if (!wrotePayload) timedOut = true;
|
|
239
286
|
else stalled = true;
|
|
240
287
|
} finally {
|
|
241
288
|
if (firstTimer) clearTimeout(firstTimer);
|
|
@@ -243,12 +290,13 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
243
290
|
if (stallTimer) clearTimeout(stallTimer);
|
|
244
291
|
if (pingTimer) clearInterval(pingTimer);
|
|
245
292
|
}
|
|
246
|
-
|
|
293
|
+
// 任一闸门到点且未写出真实数据 → 可安全 failover(注释帧对客户端无意义,不算已响应)
|
|
294
|
+
if ((timedOut || stalled || tooLong) && !wrotePayload) {
|
|
247
295
|
res.removeListener("close", onClose);
|
|
248
296
|
// Note: 超时是显式字段(timedOut),别再用 status 数值当信号 — 见 .agents/notes/implemented/architecture/2026-09-17-relay-timedout-explicit-and-metrics-seam.md
|
|
249
297
|
return { status: 504, timedOut: true, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true, interrupted: false, detail };
|
|
250
298
|
}
|
|
251
|
-
if ((stalled || tooLong) &&
|
|
299
|
+
if ((stalled || tooLong) && wrotePayload) {
|
|
252
300
|
interrupted = true;
|
|
253
301
|
detail.exitReason = detail.exitReason || (stalled ? "stall" : "max");
|
|
254
302
|
res.removeListener("close", onClose);
|
package/src/state/facade.js
CHANGED
|
@@ -5,16 +5,14 @@ export {
|
|
|
5
5
|
WORKBUDDY_DEFAULT_BASE_URL,
|
|
6
6
|
providerKeyEnv,
|
|
7
7
|
providerBaseUrlEnv,
|
|
8
|
-
providerShareEnv,
|
|
9
8
|
loadProviderKeys,
|
|
10
9
|
loadProviderKey,
|
|
10
|
+
listProviderIdsWithKeys,
|
|
11
11
|
saveProviderKeys,
|
|
12
12
|
saveProviderKey,
|
|
13
13
|
addProviderKey,
|
|
14
14
|
removeProviderKey,
|
|
15
15
|
removeProviderKeys,
|
|
16
|
-
loadProviderShareKeys,
|
|
17
|
-
saveProviderShareKeys,
|
|
18
16
|
loadProviderConfigs,
|
|
19
17
|
loadProviderConfig,
|
|
20
18
|
loadProviderBaseUrl,
|
package/src/state/merge.js
CHANGED
|
@@ -56,10 +56,9 @@ export function defaultChatPath(id) {
|
|
|
56
56
|
|
|
57
57
|
export const providerKeyEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_KEY`;
|
|
58
58
|
export const providerBaseUrlEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BASE_URL`;
|
|
59
|
-
export const providerShareEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_SHARE_KEYS`;
|
|
60
59
|
|
|
61
60
|
/**
|
|
62
|
-
* validate 7 字段:baseUrl, keys, auths, allowedModels, modelsPath, chatPath
|
|
61
|
+
* validate 7 字段:baseUrl, keys, auths, allowedModels, modelsPath, chatPath(share 已删,key 随转发自动借出)
|
|
63
62
|
* 返回 {ok, clean, errors}
|
|
64
63
|
*/
|
|
65
64
|
export function validateProviderConfig(raw = {}, providerId = "") {
|
|
@@ -9,7 +9,6 @@ export {
|
|
|
9
9
|
defaultChatPath,
|
|
10
10
|
providerKeyEnv,
|
|
11
11
|
providerBaseUrlEnv,
|
|
12
|
-
providerShareEnv,
|
|
13
12
|
} from "../provider-config.js";
|
|
14
13
|
import {
|
|
15
14
|
normalizeBaseUrl as _normalizeBaseUrl,
|
|
@@ -20,7 +19,6 @@ import {
|
|
|
20
19
|
defaultChatPath as _defaultChatPath,
|
|
21
20
|
providerKeyEnv as _providerKeyEnv,
|
|
22
21
|
providerBaseUrlEnv as _providerBaseUrlEnv,
|
|
23
|
-
providerShareEnv as _providerShareEnv,
|
|
24
22
|
WORKBUDDY_DEFAULT_BASE_URL as _WORKBUDDY,
|
|
25
23
|
} from "../provider-config.js";
|
|
26
24
|
|
|
@@ -52,6 +50,16 @@ export function loadProviderKeys(id, { file = defaultStateFile() } = {}) {
|
|
|
52
50
|
return [];
|
|
53
51
|
}
|
|
54
52
|
export function loadProviderKey(id, opts = {}) { return loadProviderKeys(id, opts)[0] || ""; }
|
|
53
|
+
// 本节点所有「有 key」的供应商 id(providerConfigs ∪ providerKeys,含 env 覆盖)——
|
|
54
|
+
// 转发时默认借出的候选集合由它枚举;见 src/providers/share-keys.js
|
|
55
|
+
export function listProviderIdsWithKeys({ file = defaultStateFile() } = {}) {
|
|
56
|
+
const st = readState(file);
|
|
57
|
+
const ids = new Set();
|
|
58
|
+
for (const src of [st.providerConfigs, st.providerKeys]) {
|
|
59
|
+
if (src && typeof src === "object" && !Array.isArray(src)) for (const id of Object.keys(src)) ids.add(id);
|
|
60
|
+
}
|
|
61
|
+
return [...ids].filter((id) => loadProviderKeys(id, { file }).length > 0);
|
|
62
|
+
}
|
|
55
63
|
export function saveProviderKeys(id, list, { file = defaultStateFile() } = {}) {
|
|
56
64
|
const keys = { ...(readState(file).providerKeys || {}) };
|
|
57
65
|
const clean = [...new Set((Array.isArray(list) ? list : []).map((k) => String(k || "").trim()).filter(Boolean))];
|
|
@@ -72,18 +80,6 @@ export function removeProviderKeys(id, targets = [], opts = {}) {
|
|
|
72
80
|
const list = loadProviderKeys(id, opts).filter((k) => !set.has(k));
|
|
73
81
|
return saveProviderKeys(id, list, opts);
|
|
74
82
|
}
|
|
75
|
-
export function loadProviderShareKeys(id, { file = defaultStateFile() } = {}) {
|
|
76
|
-
const env = process.env[_providerShareEnv(id)] || "";
|
|
77
|
-
if (env) return ["1", "true", "on", "yes"].includes(String(env).trim().toLowerCase());
|
|
78
|
-
const map = readState(file).providerShareKeys;
|
|
79
|
-
return !!(map && typeof map === "object" && map[id]);
|
|
80
|
-
}
|
|
81
|
-
export function saveProviderShareKeys(id, on, { file = defaultStateFile() } = {}) {
|
|
82
|
-
const map = { ...(readState(file).providerShareKeys || {}) };
|
|
83
|
-
if (on) map[id] = true; else delete map[id];
|
|
84
|
-
writeStateImmediate(file, { providerShareKeys: map });
|
|
85
|
-
return !!on;
|
|
86
|
-
}
|
|
87
83
|
export function loadProviderConfigs({ file = defaultStateFile() } = {}) {
|
|
88
84
|
const v = readState(file).providerConfigs;
|
|
89
85
|
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|