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.
@@ -1,26 +1,14 @@
1
- import { performance } from "node:perf_hooks";
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 { parseShareKeysHeader, SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
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 深模块:对外 1 handle,内部 Policy→Selector→Executor 三段编排
18
- * Policy: 别名/allowlist/header 透传
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({ upstream, auto, logs, peers, maxHops, groups, bus, token, plugins }) {
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", { ip: clientIp(req), hops: parseHops(req.headers["x-mslxdff-hops"]), headers: { "content-type": req.headers["content-type"] }, body });
33
- for (const e of rc.errors) logs?.appendEvent?.({ ts: Date.now(), type: "plugin-hook-error", hook: "request:received", plugin: e.plugin, error: e.error });
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
- const startedAt = Date.now();
39
- const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
40
- const perf0 = performance.now();
41
- const stages = [];
42
- const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
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" }); }
@@ -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(() => {