mslxdff 0.1.87 → 0.1.89
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/bench/cline-bench.js +21 -33
- package/src/bench/probe.js +4 -11
- package/src/bench/runner.js +8 -23
- package/src/bench/via-probe.js +12 -30
- package/src/bench/workbuddy-bench.js +15 -31
- package/src/chat-pipeline/engine.js +241 -0
- package/src/chat-pipeline/index.js +86 -0
- package/src/chat-pipeline/planner.js +31 -0
- package/src/chat-pipeline/policy.js +73 -0
- package/src/providers/cline/chat.js +26 -53
- package/src/providers/workbuddy/chat.js +109 -221
- package/src/routes/chat/gateway.js +23 -286
- package/src/routes/groups-relay.js +85 -1
- package/src/routes/index.js +7 -1
- package/src/routes/relay-queue.js +53 -0
- package/src/runtime/bootstrap.js +116 -34
- package/src/transport/index.js +248 -0
- package/src/transport/pool.js +60 -0
- package/src/transport/retry.js +24 -0
- package/src/transport/sse.js +93 -0
- package/src/upstream.js +113 -287
|
@@ -1,26 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { injectReasoningContent, normalizeModel } from "../../reasoning.js";
|
|
3
|
-
import { isAutoModel } from "../../auto.js";
|
|
4
|
-
import { clientIp, json, readBody, parseHops, summarizePrompt, errMsg } from "../helpers.js";
|
|
5
|
-
import { hedgeDelayMs, shouldHedge } from "../hedge.js";
|
|
1
|
+
import { clientIp, json, readBody, parseHops } from "../helpers.js";
|
|
6
2
|
import { runHook } from "../../plugins.js";
|
|
7
|
-
import {
|
|
8
|
-
import { handleHedge } from "./hedge-handler.js";
|
|
9
|
-
import { handleLocalRelay } from "./local-handler.js";
|
|
10
|
-
import { handlePeerRelay } from "./peer-handler.js";
|
|
11
|
-
import { handleBroadbandRelay } from "./broadband-handler.js";
|
|
12
|
-
import { handleViaRoute } from "./via-route-handler.js";
|
|
13
|
-
import { handleExhaustedLocal, handleExhaustedAll } from "./exhausted-handler.js";
|
|
14
|
-
import { normalizeFullId, getModelAlias } from "../../providers/model-id.js";
|
|
3
|
+
import { createChatPipeline } from "../../chat-pipeline/index.js";
|
|
15
4
|
|
|
16
5
|
/**
|
|
17
|
-
* ChatGateway
|
|
18
|
-
* Policy
|
|
19
|
-
* Selector: order 推导 + 并发择优 + 排序
|
|
20
|
-
* Executor: 串行 trial → hedge → local → peer → broadband → exhausted
|
|
21
|
-
* 两 adapter:Provider(upstream.chat) + Clock/Latency(auto) 可注入 fake
|
|
6
|
+
* ChatGateway 薄适配层:仅负责 HTTP I/O(readBody + request:received hook)→ 委托 ChatPipeline.execute
|
|
7
|
+
* Policy/Order/AutoRace/hedge/peer/broadband 全部在 chat-pipeline 深模块内。
|
|
22
8
|
*/
|
|
23
|
-
export function createChatGateway(
|
|
9
|
+
export function createChatGateway(deps = {}) {
|
|
10
|
+
const pipeline = createChatPipeline(deps);
|
|
11
|
+
|
|
24
12
|
async function handle({ req, res }) {
|
|
25
13
|
let body;
|
|
26
14
|
try {
|
|
@@ -28,276 +16,25 @@ export function createChatGateway({ upstream, auto, logs, peers, maxHops, groups
|
|
|
28
16
|
} catch {
|
|
29
17
|
return json(res, 400, { error: "Invalid JSON body" });
|
|
30
18
|
}
|
|
31
|
-
if (plugins?.length) {
|
|
32
|
-
const rc = await runHook(plugins, "request:received", {
|
|
33
|
-
|
|
19
|
+
if (deps.plugins?.length) {
|
|
20
|
+
const rc = await runHook(deps.plugins, "request:received", {
|
|
21
|
+
ip: clientIp(req),
|
|
22
|
+
hops: parseHops(req.headers["x-mslxdff-hops"]),
|
|
23
|
+
headers: { "content-type": req.headers["content-type"] },
|
|
24
|
+
body,
|
|
25
|
+
});
|
|
26
|
+
for (const e of rc.errors) deps.logs?.appendEvent?.({ ts: Date.now(), type: "plugin-hook-error", hook: "request:received", plugin: e.plugin, error: e.error });
|
|
34
27
|
const respond = rc.value?.respond;
|
|
35
28
|
if (respond && typeof respond === "object") return json(res, respond.status || 200, respond.body ?? {});
|
|
36
29
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// ===== Policy =====
|
|
45
|
-
const hops = parseHops(req.headers["x-mslxdff-hops"]);
|
|
46
|
-
const shareKeys = parseShareKeysHeader(req.headers[SHARE_KEYS_HEADER] || "");
|
|
47
|
-
const workbuddyUid = (req.headers["x-mslxdff-workbuddy-uid"] || req.headers["x-workbuddy-uid"] || "").toString().trim();
|
|
48
|
-
const lockModel = req.headers["x-mslxdff-model-lock"] || "";
|
|
49
|
-
const rawModel = body.model || "";
|
|
50
|
-
let normalizedRequested = normalizeModel(lockModel || rawModel || "");
|
|
51
|
-
const aliasResolved = getModelAlias(normalizedRequested);
|
|
52
|
-
if (aliasResolved) { normalizedRequested = aliasResolved; body = { ...body, model: aliasResolved }; }
|
|
53
|
-
let requested = normalizedRequested;
|
|
54
|
-
let aliasInfo = null;
|
|
55
|
-
// opencode 侧 provider.mslxdff 模型会以 mslxdff/<id> 形式到达,剥掉前缀即得真实模型
|
|
56
|
-
if (requested.startsWith("mslxdff/")) {
|
|
57
|
-
const rawPart = requested.slice("mslxdff/".length);
|
|
58
|
-
aliasInfo = `${requested} -> ${rawPart} (mslxdff provider stripped)`;
|
|
59
|
-
requested = rawPart;
|
|
60
|
-
// mslxdff/bai-deepseek... 这类 dash 形态二次走 alias 表还原为 bai/...
|
|
61
|
-
const alias2 = getModelAlias(requested);
|
|
62
|
-
if (alias2) {
|
|
63
|
-
aliasInfo = `${rawModel} -> ${alias2} (mslxdff + alias)`;
|
|
64
|
-
requested = alias2;
|
|
65
|
-
body = { ...body, model: alias2 };
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
// 非 mslxdff 前缀的 dash 形态(如 bai-deepseek)已在首轮 aliasResolved 处理
|
|
69
|
-
const useAuto = isAutoModel(requested);
|
|
70
|
-
mark("parsed");
|
|
71
|
-
if (aliasInfo) { try { res.setHeader("x-mslxdff-alias", aliasInfo); } catch {} }
|
|
72
|
-
|
|
73
|
-
// ===== Selector: order 推导 =====
|
|
74
|
-
let order;
|
|
75
|
-
if (lockModel) order = [requested];
|
|
76
|
-
else if (useAuto) order = auto ? await auto.candidates() : [""];
|
|
77
|
-
else order = auto ? await auto.candidatesFor(requested) : [requested];
|
|
78
|
-
if (!order.length) order = [""];
|
|
79
|
-
const canFallback = order.length > 1;
|
|
80
|
-
const canForwardPeers = Boolean(peers) && hops < maxHops;
|
|
81
|
-
mark("ordered");
|
|
82
|
-
|
|
83
|
-
const logCall = (model, status) => logs?.appendCall({ reqId, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(body.stream), stages });
|
|
84
|
-
const logError = (model, status, message) => logs?.appendError({ reqId, model, auto: useAuto, status, message, stages });
|
|
85
|
-
const evt = (type, data) => {
|
|
86
|
-
const entry = { ts: Date.now(), reqId, type, ...data, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
87
|
-
if (bus) bus.emit(entry);
|
|
88
|
-
logs?.appendEvent?.(entry);
|
|
89
|
-
};
|
|
90
|
-
const done = (info) => {
|
|
91
|
-
if (!plugins?.length) return;
|
|
92
|
-
runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
|
|
93
|
-
};
|
|
94
|
-
evt("request", { reqId, hops, ip: clientIp(req), stream: Boolean(body.stream), prompt: summarizePrompt(body), rawModel, requested, lockModel: lockModel || null });
|
|
95
|
-
if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel, requested });
|
|
96
|
-
if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
|
|
97
|
-
evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
|
|
98
|
-
|
|
99
|
-
if (plugins?.length && !lockModel) {
|
|
100
|
-
const sel = await runHook(plugins, "model:select", { reqId, requested, useAuto, order: [...order], hops, stream: Boolean(body.stream) });
|
|
101
|
-
if (sel.changed && Array.isArray(sel.value) && sel.value.length) {
|
|
102
|
-
order = sel.value.filter(Boolean);
|
|
103
|
-
if (!order.length) order = [requested];
|
|
104
|
-
evt("plugin-hook", { reqId, hook: "model:select", applied: true, order: [...order] });
|
|
105
|
-
}
|
|
106
|
-
for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const handlerCtx = { reqId, model: null, body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid };
|
|
110
|
-
|
|
111
|
-
// ===== Selector: 首次 auto 并发择优 =====
|
|
112
|
-
if (useAuto && order.length > 1 && auto && !lockModel) {
|
|
113
|
-
const statuses = auto.statuses?.() ?? {};
|
|
114
|
-
const hasPriorSuccess = Object.values(statuses).some((e) => e && typeof e === "object" && e.status === "normal");
|
|
115
|
-
const nonCoolingOrder = order.filter((m) => { try { return !auto.isCooling(m); } catch { return true; } });
|
|
116
|
-
if (!hasPriorSuccess && nonCoolingOrder.length > 1) {
|
|
117
|
-
const concLimit = (() => {
|
|
118
|
-
const v = Number(process.env.MSLXDFF_AUTO_CONCURRENT);
|
|
119
|
-
if (Number.isInteger(v) && v > 0) return Math.min(v, nonCoolingOrder.length);
|
|
120
|
-
return Math.min(nonCoolingOrder.length, 5);
|
|
121
|
-
})();
|
|
122
|
-
let raceModels=nonCoolingOrder.slice(0,concLimit);
|
|
123
|
-
if(plugins?.length){const k=[];for(const m of raceModels){const b=await runHook(plugins,"model:beforeTry",{reqId,requested,model:m,hops});if(b.value===false||b.value?.skip)continue;k.push(m);}raceModels=k;}
|
|
124
|
-
if(!raceModels.length){order=order.filter(m=>!new Set(nonCoolingOrder.slice(0,concLimit)).has(m));if(!order.length){await handleExhaustedAll({res,body,lastErr:{model:requested,status:502,message:"all concurrent candidates skipped by plugin"},order:nonCoolingOrder.slice(0,concLimit),requested,handlerCtx:{...handlerCtx,reqId,startedAt},evt,logCall,mark,perf0,stages});return;}}else{evt("auto-concurrent-race",{reqId,models:raceModels,skippedFaulty:order.length-nonCoolingOrder.length,limit:concLimit});const raceStart=performance.now();const attempts=raceModels.map(async m=>{let f={...injectReasoningContent(m,body),model:m};if(plugins?.length){const u=await runHook(plugins,"upstream:request",{reqId,requested,model:m,payload:f,stream:Boolean(body.stream)});if(u.changed&&u.value?.payload) f=u.value.payload;}let r=null;try{const o={};if(Object.keys(shareKeys).length)o.shareKeys=shareKeys;if(workbuddyUid)o.workbuddyUid=workbuddyUid;r=await upstream.chat(f,Object.keys(o).length?o:undefined);}catch(e){if(plugins?.length)runHook(plugins,"upstream:response",{reqId,requested,model:m,status:null,ok:false,error:errMsg(e),timing:e?._t??null}).catch(()=>{});return{model:m,ok:false,error:errMsg(e),status:502,timing:e?._t??null};}if(plugins?.length)runHook(plugins,"upstream:response",{reqId,requested,model:m,status:r instanceof Error?null:r?.status??null,ok:!(r instanceof Error)&&r?r.status<400:false,error:r instanceof Error?errMsg(r):null,timing:r?._t??null}).catch(()=>{});if(r&&r.status>=400){const a=r.status===403&&r.headers?.get?.("x-mslxdff-allowlist")==="1";if(a)return{model:m,ok:false,error:"allowlist",status:403,allowlist:true};return{model:m,ok:false,error:`upstream ${r.status}`,status:r.status,res:r,timing:r._t??null};}if(r instanceof Error)return{model:m,ok:false,error:errMsg(r),status:502};return{model:m,ok:true,res:r,status:r.status,timing:r._t??null};});
|
|
125
|
-
const results = await Promise.allSettled(attempts);
|
|
126
|
-
const okList = results.map((r, i) => ({ r, i, model: raceModels[i] }))
|
|
127
|
-
.filter(({ r }) => r.status === "fulfilled" && r.value?.ok)
|
|
128
|
-
.map(({ r, i, model }) => ({ model, idx: i, val: r.value, t: r.value.timing?.totalMs ?? r.value.timing?.ms ?? Number.MAX_SAFE_INTEGER }));
|
|
129
|
-
if (okList.length) {
|
|
130
|
-
okList.sort((a, b) => a.t - b.t);
|
|
131
|
-
const best = okList[0];
|
|
132
|
-
const winModel = best.model;
|
|
133
|
-
evt("auto-concurrent-win", { reqId, model: winModel, timing: best.val.timing, totalMs: Math.round(performance.now() - raceStart), tried: raceModels.length });
|
|
134
|
-
for (const { r, i } of results.map((r, i) => ({ r, i }))) {
|
|
135
|
-
const m = raceModels[i];
|
|
136
|
-
if (r.status === "fulfilled" && r.value?.ok) {
|
|
137
|
-
if (m === winModel) {
|
|
138
|
-
const latencyMs = r.value.timing?.totalMs ?? Math.round(performance.now() - raceStart);
|
|
139
|
-
await auto.recordOk(m, { latencyMs });
|
|
140
|
-
try { const { savePreferredModel } = await import("../../state.js"); savePreferredModel(m); evt("auto-concurrent-preferred", { reqId, model: m }); } catch {}
|
|
141
|
-
}
|
|
142
|
-
} else if (r.status === "fulfilled" && !r.value?.ok && !r.value?.allowlist) await auto.recordError(m, { status: r.value.status || 502 });
|
|
143
|
-
else if (r.status === "rejected") await auto.recordError(m, { status: 502 });
|
|
144
|
-
}
|
|
145
|
-
handlerCtx.model = winModel;
|
|
146
|
-
const { handleLocalRelay: _relay } = await import("./local-handler.js");
|
|
147
|
-
const lr = await _relay({
|
|
148
|
-
upRes: best.val.res, model: winModel, body, order: raceModels, idx: best.idx,
|
|
149
|
-
lastErr: null, requested, useAuto, lockModel, auto, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res,
|
|
150
|
-
});
|
|
151
|
-
if (lr.handled) return;
|
|
152
|
-
if (!lr.lastErr) return;
|
|
153
|
-
} else {
|
|
154
|
-
evt("auto-concurrent-all-fail", { reqId, tried: raceModels.length, totalMs: Math.round(performance.now() - raceStart) });
|
|
155
|
-
for (const { r, i } of results.map((r, i) => ({ r, i }))) {
|
|
156
|
-
const m = raceModels[i];
|
|
157
|
-
if (r.status === "fulfilled" && !r.value?.ok && !r.value?.allowlist) await auto.recordError(m, { status: r.value.status || 502 });
|
|
158
|
-
else if (r.status === "rejected") await auto.recordError(m, { status: 502 });
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
const triedSet = new Set(raceModels);
|
|
162
|
-
order = order.filter((m) => !triedSet.has(m));
|
|
163
|
-
if (!order.length) {
|
|
164
|
-
const failedStatuses = results.map((r) => (r.status === "fulfilled" ? r.value?.status : null)).filter((s) => Number.isInteger(s));
|
|
165
|
-
const lastStatus = failedStatuses[failedStatuses.length - 1] || failedStatuses[0] || 502;
|
|
166
|
-
const last = { model: raceModels[0] || requested, status: lastStatus, message: "all concurrent candidates failed" };
|
|
167
|
-
await handleExhaustedAll({ res, body, lastErr: last, order: raceModels, requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
} // close else (raceModels not empty)
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ===== VIA-ROUTE 单路径择路(显式锁模型,不并发) =====
|
|
175
|
-
let viaRouteLastErr = null;
|
|
176
|
-
if (!useAuto && requested && requested.includes("/") && canForwardPeers && !lockModel && peers) {
|
|
177
|
-
try {
|
|
178
|
-
const vr = await handleViaRoute({
|
|
179
|
-
model: requested,
|
|
180
|
-
body,
|
|
181
|
-
peers,
|
|
182
|
-
handlerCtx,
|
|
183
|
-
evt,
|
|
184
|
-
logCall,
|
|
185
|
-
logError,
|
|
186
|
-
mark,
|
|
187
|
-
perf0,
|
|
188
|
-
stages,
|
|
189
|
-
startedAt,
|
|
190
|
-
plugins,
|
|
191
|
-
res,
|
|
192
|
-
requested,
|
|
193
|
-
useAuto,
|
|
194
|
-
lockModel,
|
|
195
|
-
auto,
|
|
196
|
-
});
|
|
197
|
-
if (vr.handled) return;
|
|
198
|
-
if (vr.lastErr) viaRouteLastErr = vr.lastErr;
|
|
199
|
-
} catch (e) {
|
|
200
|
-
evt("via-route-exception", { reqId, model: requested, error: errMsg(e) });
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// ===== Executor: 串行 trial =====
|
|
205
|
-
let lastErr = viaRouteLastErr;
|
|
206
|
-
for (let idx = 0; idx < order.length; idx++) {
|
|
207
|
-
const model = order[idx];
|
|
208
|
-
handlerCtx.model = model;
|
|
209
|
-
evt("model-try", { reqId, model, idx, remaining: order.length - idx });
|
|
210
|
-
if (plugins?.length) {
|
|
211
|
-
const bt = await runHook(plugins, "model:beforeTry", { reqId, requested, model, idx, hops });
|
|
212
|
-
for (const e of bt.errors) evt("plugin-hook-error", { reqId, hook: "model:beforeTry", plugin: e.plugin, error: e.error });
|
|
213
|
-
if (bt.value === false || bt.value?.skip === true) { evt("plugin-hook", { reqId, hook: "model:beforeTry", applied: true, skipped: model }); continue; }
|
|
214
|
-
}
|
|
215
|
-
let upRes = null;
|
|
216
|
-
let forwarded = { ...injectReasoningContent(model, body), model };
|
|
217
|
-
if (plugins?.length) {
|
|
218
|
-
const ur = await runHook(plugins, "upstream:request", { reqId, requested, model, payload: forwarded, stream: Boolean(body.stream) });
|
|
219
|
-
for (const e of ur.errors) evt("plugin-hook-error", { reqId, hook: "upstream:request", plugin: e.plugin, error: e.error });
|
|
220
|
-
if (ur.changed && ur.value?.payload && typeof ur.value.payload === "object") { forwarded = ur.value.payload; evt("plugin-hook", { reqId, hook: "upstream:request", applied: true, model, rewrittenModel: forwarded.model ?? null }); }
|
|
221
|
-
}
|
|
222
|
-
const tUp = performance.now();
|
|
223
|
-
evt("upstream-try", { reqId, model, attempt: idx + 1 });
|
|
224
|
-
try {
|
|
225
|
-
const chatOpts = {};
|
|
226
|
-
if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
|
|
227
|
-
if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
|
|
228
|
-
upRes = await upstream.chat(forwarded, Object.keys(chatOpts).length ? chatOpts : undefined);
|
|
229
|
-
evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null });
|
|
230
|
-
} catch (err) {
|
|
231
|
-
if (auto) await auto.recordError(model, { message: errMsg(err) });
|
|
232
|
-
lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
|
|
233
|
-
logError(model, 502, errMsg(err));
|
|
234
|
-
evt("upstream-error", { reqId, model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
|
|
235
|
-
}
|
|
236
|
-
if (plugins?.length) {
|
|
237
|
-
runHook(plugins, "upstream:response", {
|
|
238
|
-
reqId, requested, model,
|
|
239
|
-
status: upRes instanceof Error ? null : upRes instanceof Object ? (upRes.status ?? null) : null,
|
|
240
|
-
ok: !(upRes instanceof Error) && upRes ? upRes.status < 400 : false,
|
|
241
|
-
error: upRes instanceof Error ? errMsg(upRes) : null,
|
|
242
|
-
timing: upRes?._t ?? null,
|
|
243
|
-
}).catch(() => {});
|
|
244
|
-
}
|
|
245
|
-
mark(`up-${model}`);
|
|
246
|
-
if (upRes && upRes.status >= 400) {
|
|
247
|
-
const isAllowlistBlock = upRes.status === 403 && (upRes.headers?.get?.("x-mslxdff-allowlist") === "1");
|
|
248
|
-
if (isAllowlistBlock) {
|
|
249
|
-
let bodyText = null; try { bodyText = await upRes.clone().text(); } catch {}
|
|
250
|
-
let errBody = { error: `model not allowed for provider` };
|
|
251
|
-
try { errBody = bodyText ? JSON.parse(bodyText) : errBody; } catch { errBody = { error: bodyText || "model not allowed" }; }
|
|
252
|
-
if (useAuto) {
|
|
253
|
-
logError(model, 403, errBody.error || "model not allowed");
|
|
254
|
-
evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true, skipped: true });
|
|
255
|
-
lastErr = { model, upstream: upRes, status: 403, message: errBody.error || "model not allowed" };
|
|
256
|
-
if (canFallback && idx < order.length - 1) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: `allowlist skip ${errBody.error || "blocked"}` }); continue; }
|
|
257
|
-
return json(res, 403, errBody);
|
|
258
|
-
}
|
|
259
|
-
logError(model, 403, errBody.error || "model not allowed");
|
|
260
|
-
evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true });
|
|
261
|
-
return json(res, 403, errBody);
|
|
262
|
-
}
|
|
263
|
-
if (auto) await auto.recordError(model, { status: upRes.status });
|
|
264
|
-
lastErr = { model, upstream: upRes, status: upRes.status, message: null };
|
|
265
|
-
logError(model, upRes.status, `upstream ${upRes.status}`);
|
|
266
|
-
evt("upstream-error", { reqId, model, status: upRes.status, message: null, timing: upRes._t ?? null });
|
|
267
|
-
upRes = null;
|
|
268
|
-
}
|
|
269
|
-
if (upRes) {
|
|
270
|
-
const isStream = Boolean(body.stream);
|
|
271
|
-
const d = hedgeDelayMs();
|
|
272
|
-
const hasPeers = Boolean(peers) && peers.ordered().length > 0;
|
|
273
|
-
const doHedge = shouldHedge({ isStream, canForwardPeers, hedgeDelayMs: d, hasPeers }) && upRes.status === 200 && upRes.body;
|
|
274
|
-
if (doHedge) {
|
|
275
|
-
const hr = await handleHedge({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res, hedgeDelayMs: d });
|
|
276
|
-
if (hr.handled) return;
|
|
277
|
-
if (hr.lastErr) lastErr = hr.lastErr;
|
|
278
|
-
if (hr.upRes === null) upRes = null;
|
|
279
|
-
else if (hr.upRes) upRes = hr.upRes;
|
|
280
|
-
}
|
|
281
|
-
if (upRes) {
|
|
282
|
-
const lr = await handleLocalRelay({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res });
|
|
283
|
-
if (lr.handled) return;
|
|
284
|
-
if (lr.lastErr) { lastErr = lr.lastErr; continue; }
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
if (canForwardPeers) {
|
|
289
|
-
const pr = await handlePeerRelay({ model, body, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, mark, perf0, stages, startedAt, plugins, res });
|
|
290
|
-
if (pr.handled) return;
|
|
291
|
-
}
|
|
292
|
-
if (groups) {
|
|
293
|
-
const br = await handleBroadbandRelay({ model, body, hops, lastErr, requested, useAuto, lockModel, auto, groups, token, bus, logs, handlerCtx, evt, mark, perf0, stages, res, startedAt, plugins });
|
|
294
|
-
if (br.handled) return;
|
|
295
|
-
}
|
|
296
|
-
if (canFallback) { evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: lastErr?.message || `upstream ${lastErr?.status ?? 502}` }); continue; }
|
|
297
|
-
await handleExhaustedLocal({ res, body, lastErr, order, handlerCtx: { ...handlerCtx, model, reqId }, evt, logCall, mark, perf0, stages, done, requested, useAuto });
|
|
298
|
-
return;
|
|
30
|
+
req.body = body;
|
|
31
|
+
try {
|
|
32
|
+
await pipeline.execute({ req, res });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const msg = err?.message || String(err);
|
|
35
|
+
if (!res.headersSent) return json(res, 502, { error: msg });
|
|
36
|
+
try { res.end(); } catch {}
|
|
299
37
|
}
|
|
300
|
-
await handleExhaustedAll({ res, body, lastErr, order, requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
|
|
301
38
|
}
|
|
302
39
|
|
|
303
40
|
return { handle };
|
|
@@ -307,4 +44,4 @@ export function createChatGateway({ upstream, auto, logs, peers, maxHops, groups
|
|
|
307
44
|
export async function chatHandler(ctx) {
|
|
308
45
|
const gw = createChatGateway(ctx);
|
|
309
46
|
return gw.handle(ctx);
|
|
310
|
-
}
|
|
47
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { clientIp, json, readBody, parseHops, errMsg } from "./helpers.js";
|
|
2
2
|
import { DEFAULT_MAX_HOPS } from "../peers.js";
|
|
3
|
-
import { enqueueRelay, dequeueRelayForPoll, resolveRelay } from "./relay-queue.js";
|
|
3
|
+
import { enqueueRelay, dequeueRelayForPoll, resolveRelay, subscribeStream, unsubscribeStream } from "./relay-queue.js";
|
|
4
4
|
|
|
5
5
|
export async function heartbeatHandler({ req, res, groups, bus, logs }) {
|
|
6
6
|
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
@@ -63,6 +63,90 @@ export async function resultHandler({ req, res, groups }) {
|
|
|
63
63
|
return json(res, 200, { object: "result", ok: true });
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function isStreamEnabled() {
|
|
67
|
+
const v = process.env.MSLXDFF_BROADBAND_STREAM;
|
|
68
|
+
if (v === "0" || v === "false" || v === "off") return false;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function streamHandler({ req, res, groups, bus, logs }) {
|
|
73
|
+
if (!isStreamEnabled()) return json(res, 404, { error: "broadband stream disabled" });
|
|
74
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
75
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
76
|
+
// support GET query ?name= & ?group=
|
|
77
|
+
let groupName = null;
|
|
78
|
+
try {
|
|
79
|
+
const u = new URL(req.url || "", "http://127.0.0.1");
|
|
80
|
+
groupName = u.searchParams.get("name") || u.searchParams.get("group");
|
|
81
|
+
} catch {}
|
|
82
|
+
if (!groupName) {
|
|
83
|
+
// also try body for POST-compat (though GET should use query)
|
|
84
|
+
try {
|
|
85
|
+
const b = await readBody(req);
|
|
86
|
+
groupName = b?.name || b?.group;
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
if (!groupName) return json(res, 400, { error: "group name is required" });
|
|
90
|
+
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
91
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
92
|
+
const targetUrl = hit.member?.url;
|
|
93
|
+
if (!targetUrl) return json(res, 400, { error: "member url not found" });
|
|
94
|
+
const isBb = hit.member?.kind === "broadband" || String(targetUrl).startsWith("relay://");
|
|
95
|
+
if (!isBb) return json(res, 403, { error: "only broadband members can stream" });
|
|
96
|
+
// SSE headers
|
|
97
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
98
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
99
|
+
res.setHeader("Connection", "keep-alive");
|
|
100
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
101
|
+
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
102
|
+
// initial frame
|
|
103
|
+
try { res.write(`:connected ${Date.now()}\n\n`); } catch {}
|
|
104
|
+
const pingMs = Number(process.env.MSLXDFF_BROADBAND_PING_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_PING_MS) : 25_000;
|
|
105
|
+
subscribeStream({ group: groupName, target: targetUrl, res });
|
|
106
|
+
const evtOpen = { ts: Date.now(), type: "relay-stream-open", member: targetUrl, group: groupName };
|
|
107
|
+
try { bus?.emit(evtOpen); } catch {}
|
|
108
|
+
try { logs?.appendEvent?.(evtOpen); } catch {}
|
|
109
|
+
// heartbeat touch
|
|
110
|
+
try {
|
|
111
|
+
const ip = clientIp(req);
|
|
112
|
+
const members = groups.list()[groupName]?.members || {};
|
|
113
|
+
const targetId = Object.keys(members).find((k) => members[k].url === targetUrl) || targetUrl;
|
|
114
|
+
const m = members[targetId] || hit.member;
|
|
115
|
+
if (m) {
|
|
116
|
+
m.publicIp = ip;
|
|
117
|
+
m.lastSeen = Date.now();
|
|
118
|
+
try { groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", publicIp: ip, lastSeen: m.lastSeen }); } catch {}
|
|
119
|
+
}
|
|
120
|
+
} catch {}
|
|
121
|
+
const pingTimer = setInterval(() => {
|
|
122
|
+
try { res.write(`:ping ${Date.now()}\n\n`); } catch {}
|
|
123
|
+
// keep lastSeen fresh so forward doesn't see stale while stream is alive
|
|
124
|
+
try {
|
|
125
|
+
const members = groups.list()[groupName]?.members || {};
|
|
126
|
+
const targetId = Object.keys(members).find((k) => members[k].url === targetUrl) || targetUrl;
|
|
127
|
+
const m = members[targetId] || hit.member;
|
|
128
|
+
if (m) {
|
|
129
|
+
m.lastSeen = Date.now();
|
|
130
|
+
try { groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", lastSeen: m.lastSeen }); } catch {}
|
|
131
|
+
}
|
|
132
|
+
} catch {}
|
|
133
|
+
const evt = { ts: Date.now(), type: "relay-stream-ping", member: targetUrl, group: groupName };
|
|
134
|
+
try { bus?.emit(evt); } catch {}
|
|
135
|
+
}, pingMs);
|
|
136
|
+
pingTimer.unref?.();
|
|
137
|
+
const cleanup = () => {
|
|
138
|
+
clearInterval(pingTimer);
|
|
139
|
+
unsubscribeStream({ group: groupName, target: targetUrl, res });
|
|
140
|
+
const evt = { ts: Date.now(), type: "relay-stream-close", member: targetUrl, group: groupName };
|
|
141
|
+
try { bus?.emit(evt); } catch {}
|
|
142
|
+
try { logs?.appendEvent?.(evt); } catch {}
|
|
143
|
+
};
|
|
144
|
+
req.on("close", cleanup);
|
|
145
|
+
req.on("error", cleanup);
|
|
146
|
+
// keep promise pending until close — do not end res here
|
|
147
|
+
await new Promise(() => {});
|
|
148
|
+
}
|
|
149
|
+
|
|
66
150
|
export async function forwardHandler({ req, res, groups, bus, logs }) {
|
|
67
151
|
let body;
|
|
68
152
|
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
package/src/routes/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { DEFAULT_MAX_HOPS } from "../peers.js";
|
|
|
3
3
|
import { json, notFound, authorized } from "./helpers.js";
|
|
4
4
|
import { chatHandler } from "./chat.js";
|
|
5
5
|
import { joinHandler, leaveHandler } from "./groups.js";
|
|
6
|
-
import { heartbeatHandler, pollHandler, resultHandler, forwardHandler } from "./groups-relay.js";
|
|
6
|
+
import { heartbeatHandler, pollHandler, resultHandler, forwardHandler, streamHandler } from "./groups-relay.js";
|
|
7
7
|
import { modelsHandler, modelsStatusHandler, providerModelsHandler } from "./models-route.js";
|
|
8
8
|
import { relayHandler } from "./relay.js";
|
|
9
9
|
|
|
@@ -73,6 +73,12 @@ const ROUTES = [
|
|
|
73
73
|
requiresAuth: false,
|
|
74
74
|
handler: resultHandler,
|
|
75
75
|
},
|
|
76
|
+
{
|
|
77
|
+
method: "GET",
|
|
78
|
+
path: "/v1/groups/relay/stream",
|
|
79
|
+
requiresAuth: false,
|
|
80
|
+
handler: streamHandler,
|
|
81
|
+
},
|
|
76
82
|
{
|
|
77
83
|
method: "POST",
|
|
78
84
|
path: "/v1/groups/relay/forward",
|
|
@@ -2,9 +2,62 @@ import { loadGroupsJoined } from "../state.js";
|
|
|
2
2
|
|
|
3
3
|
const relayPending = new Map();
|
|
4
4
|
const relayPendingByReqId = new Map();
|
|
5
|
+
const streamSubscribers = new Map(); // key group::target -> Set<res>
|
|
6
|
+
|
|
7
|
+
function streamKey(group, target) { return `${group}::${target}`; }
|
|
8
|
+
|
|
9
|
+
export function subscribeStream({ group, target, res }) {
|
|
10
|
+
const k = streamKey(group, target);
|
|
11
|
+
const set = streamSubscribers.get(k) || new Set();
|
|
12
|
+
set.add(res);
|
|
13
|
+
streamSubscribers.set(k, set);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function unsubscribeStream({ group, target, res }) {
|
|
17
|
+
const k = streamKey(group, target);
|
|
18
|
+
const set = streamSubscribers.get(k);
|
|
19
|
+
if (!set) return;
|
|
20
|
+
set.delete(res);
|
|
21
|
+
if (set.size === 0) streamSubscribers.delete(k);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clearStreamSubscribers() { streamSubscribers.clear(); }
|
|
25
|
+
export function getStreamSubscribers() { return streamSubscribers; }
|
|
26
|
+
|
|
27
|
+
export function pushToStream({ group, target, entry }) {
|
|
28
|
+
const k = streamKey(group, target);
|
|
29
|
+
const set = streamSubscribers.get(k);
|
|
30
|
+
if (!set || set.size === 0) return false;
|
|
31
|
+
const payload = JSON.stringify({ reqId: entry.reqId, body: entry.body, hops: entry.hops });
|
|
32
|
+
const frame = `event: relay\ndata: ${payload}\n\n`;
|
|
33
|
+
let pushed = 0;
|
|
34
|
+
for (const res of [...set]) {
|
|
35
|
+
try {
|
|
36
|
+
res.write(frame);
|
|
37
|
+
pushed++;
|
|
38
|
+
} catch {
|
|
39
|
+
set.delete(res);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (set.size === 0) streamSubscribers.delete(k);
|
|
43
|
+
return pushed > 0;
|
|
44
|
+
}
|
|
5
45
|
|
|
6
46
|
export function enqueueRelay({ group, target, reqId, body, hops }) {
|
|
7
47
|
const key = `${group}::${target}`;
|
|
48
|
+
// try stream push first — if any subscriber, push and don't enqueue to poll queue
|
|
49
|
+
const entryForPush = { reqId, body, hops };
|
|
50
|
+
if (pushToStream({ group, target, entry: entryForPush })) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
relayPendingByReqId.delete(reqId);
|
|
54
|
+
reject(new Error("relay timeout"));
|
|
55
|
+
}, 30_000);
|
|
56
|
+
timer.unref?.();
|
|
57
|
+
const entry = { reqId, body, hops, resolve, reject, timer };
|
|
58
|
+
relayPendingByReqId.set(reqId, entry);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
8
61
|
const list = relayPending.get(key) || [];
|
|
9
62
|
return new Promise((resolve, reject) => {
|
|
10
63
|
const timer = setTimeout(() => {
|