mslxdff 0.1.39 → 0.1.42

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.
@@ -0,0 +1,121 @@
1
+ import { clientIp, json, readBody, errMsg } from "./helpers.js";
2
+
3
+ export async function joinHandler({ req, res, groups, token, bans }) {
4
+ if (!groups) return json(res, 501, { error: "Groups service not configured" });
5
+ const ip = clientIp(req);
6
+ const banned = bans?.isBanned(ip);
7
+ if (banned) {
8
+ return json(res, 403, { error: `banned until ${new Date(banned.until).toISOString()}` });
9
+ }
10
+ let body;
11
+ try {
12
+ body = await readBody(req);
13
+ } catch {
14
+ return json(res, 400, { error: "Invalid JSON body" });
15
+ }
16
+ if (!body?.name) return json(res, 400, { error: "group name is required" });
17
+
18
+ const fail = (msg) => {
19
+ try {
20
+ if (bans) {
21
+ const b = bans.recordFailure(ip);
22
+ if (b) console.error(`${ip} banned (${bans.threshold} failed joins)`);
23
+ }
24
+ } catch {}
25
+ return json(res, 403, { error: msg });
26
+ };
27
+
28
+ if (!body.key) {
29
+ const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
30
+ const hit = auth && groups.membersForToken(body.name, auth[1]);
31
+ if (!hit) return fail("invalid member token");
32
+ try {
33
+ const isBroadbandRe = String(body.url || hit.member?.url || "").startsWith("relay://") || body.kind === "broadband" || hit.member?.kind === "broadband";
34
+ const extra = {};
35
+ if (isBroadbandRe) {
36
+ extra.kind = "broadband";
37
+ extra.publicIp = ip;
38
+ extra.lastSeen = Date.now();
39
+ if (body.url) extra.url = String(body.url);
40
+ }
41
+ const refreshed = groups.upsertMember(body.name, {
42
+ memberName: body.memberName,
43
+ url: body.url || hit.member.url,
44
+ token: body.token || hit.member.token,
45
+ kind: extra.kind,
46
+ publicIp: extra.publicIp,
47
+ lastSeen: extra.lastSeen,
48
+ });
49
+ if (isBroadbandRe && refreshed) {
50
+ const targetId = Object.keys(refreshed).find((k) => refreshed[k].url === (body.url || hit.member.url));
51
+ if (targetId) {
52
+ refreshed[targetId].publicIp = ip;
53
+ refreshed[targetId].lastSeen = Date.now();
54
+ refreshed[targetId].kind = "broadband";
55
+ }
56
+ }
57
+ return json(res, 200, { object: "group", name: body.name, members: refreshed });
58
+ } catch (err) {
59
+ return json(res, 400, { error: errMsg(err) });
60
+ }
61
+ }
62
+
63
+ try {
64
+ const youPort = Number(body.myPort);
65
+ const youUrl = Number.isInteger(youPort) && youPort > 0 ? `http://${ip}:${youPort}` : "";
66
+ let memberUrl = String(body.url || youUrl);
67
+ if (!memberUrl) throw new Error("member url is required");
68
+ const isBroadband = String(memberUrl).startsWith("relay://") || body.kind === "broadband";
69
+ if (isBroadband) {
70
+ memberUrl = String(body.url || memberUrl);
71
+ }
72
+ const members = groups.addMember(body.name, {
73
+ key: body.key,
74
+ memberName: body.memberName,
75
+ url: memberUrl,
76
+ token: body.token,
77
+ kind: isBroadband ? "broadband" : "static",
78
+ publicIp: isBroadband ? ip : undefined,
79
+ lastSeen: isBroadband ? Date.now() : undefined,
80
+ });
81
+ if (bans) bans.clear(ip);
82
+ if (!members.leader) {
83
+ const leaderUrl = String(body.leaderUrl || "").replace(/\/+$/, "");
84
+ if (leaderUrl) {
85
+ groups.upsertMember(body.name, { memberName: "leader", url: leaderUrl, token });
86
+ Object.assign(members, { leader: { url: leaderUrl, token } });
87
+ }
88
+ }
89
+ json(res, 200, { object: "group", name: body.name, members, you: { url: memberUrl } });
90
+ } catch (err) {
91
+ return fail(errMsg(err));
92
+ }
93
+ }
94
+
95
+ export async function leaveHandler({ req, res, groups }) {
96
+ if (!groups) return json(res, 501, { error: "Groups service not configured" });
97
+ let body;
98
+ try {
99
+ body = await readBody(req);
100
+ } catch {
101
+ return json(res, 400, { error: "Invalid JSON body" });
102
+ }
103
+ if (!body?.name) return json(res, 400, { error: "group name is required" });
104
+ const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
105
+ if (!auth) return json(res, 401, { error: "bearer token required" });
106
+ const group = groups.list()[body.name];
107
+ if (!group) return json(res, 404, { error: `group "${body.name}" not found` });
108
+ const hit = groups.membersForToken(body.name, auth[1]);
109
+ if (!hit) return json(res, 403, { error: "invalid member token" });
110
+ try {
111
+ const removed = groups.removeMember(body.name, { url: hit.member.url });
112
+ return json(res, 200, {
113
+ object: "group",
114
+ name: body.name,
115
+ removed: removed?.removed ?? null,
116
+ members: groups.list()[body.name]?.members ?? {},
117
+ });
118
+ } catch (err) {
119
+ return json(res, 400, { error: errMsg(err) });
120
+ }
121
+ }
@@ -0,0 +1,66 @@
1
+ import { timingSafeEqual, createHash } from "node:crypto";
2
+
3
+ export const errMsg = (err) => String(err?.message || err);
4
+
5
+ export function clientIp(req) {
6
+ const fwd = req.headers["x-forwarded-for"];
7
+ const head = typeof fwd === "string" ? fwd.split(",")[0].trim() : "";
8
+ const raw = String(head || req.socket.remoteAddress || "");
9
+ return raw.replace(/^::ffff:/, "").replace(/^::1$/, "127.0.0.1") || null;
10
+ }
11
+
12
+ export function authorized(req, token) {
13
+ const header = req.headers["authorization"] || "";
14
+ const match = /^Bearer (.+)$/.exec(header);
15
+ if (!match) return false;
16
+ const digests = (s) => createHash("sha256").update(s).digest();
17
+ return timingSafeEqual(digests(match[1]), digests(token));
18
+ }
19
+
20
+ export function json(res, status, body) {
21
+ res.statusCode = status;
22
+ res.setHeader("Content-Type", "application/json");
23
+ res.end(JSON.stringify(body));
24
+ }
25
+
26
+ export function notFound(res) {
27
+ return json(res, 404, { error: "Not Found" });
28
+ }
29
+
30
+ export function readBody(req) {
31
+ return new Promise((resolve, reject) => {
32
+ let data = "";
33
+ req.on("data", (c) => (data += c));
34
+ req.on("end", () => {
35
+ try {
36
+ resolve(data ? JSON.parse(data) : {});
37
+ } catch (err) {
38
+ reject(err);
39
+ }
40
+ });
41
+ req.on("error", reject);
42
+ });
43
+ }
44
+
45
+ export function parseHops(header) {
46
+ const n = Number(header);
47
+ return Number.isInteger(n) && n >= 0 ? n : 0;
48
+ }
49
+
50
+ export const PROMPT_MAX_LEN = 160;
51
+
52
+ export function summarizePrompt(body) {
53
+ const msgs = body?.messages;
54
+ if (!Array.isArray(msgs) || !msgs.length) return "";
55
+ const msg = msgs[msgs.length - 1];
56
+ const c = msg?.content;
57
+ let text = "";
58
+ if (typeof c === "string") text = c;
59
+ else if (Array.isArray(c)) {
60
+ text = c
61
+ .map((p) => (typeof p === "string" ? p : p && typeof p.text === "string" ? p.text : ""))
62
+ .join(" ");
63
+ }
64
+ text = String(text || "").replace(/\s+/g, " ").trim();
65
+ return text.length > PROMPT_MAX_LEN ? text.slice(0, PROMPT_MAX_LEN) + "…" : text;
66
+ }
@@ -0,0 +1,91 @@
1
+ import { timingSafeEqual, createHash } from "node:crypto";
2
+ import { DEFAULT_MAX_HOPS } from "../peers.js";
3
+ import { json, notFound, authorized } from "./helpers.js";
4
+ import { chatHandler } from "./chat.js";
5
+ import { joinHandler, leaveHandler } from "./groups.js";
6
+ import { heartbeatHandler, pollHandler, resultHandler, forwardHandler } from "./groups-relay.js";
7
+ import { modelsHandler, modelsStatusHandler } from "./models-route.js";
8
+
9
+ export function createRouter({ token, upstream, models, auto, logs, peers, maxHops = DEFAULT_MAX_HOPS, groups, bans, bus }) {
10
+ return async function router(req, res) {
11
+ const method = req.method || "GET";
12
+ const path = (req.url || "").split("?")[0];
13
+ const route = ROUTES.find((r) => r.method === method && r.path === path);
14
+ if (!route) return notFound(res);
15
+ if (route.requiresAuth && !authorized(req, token)) {
16
+ res.statusCode = 401;
17
+ res.setHeader("WWW-Authenticate", "Bearer");
18
+ return json(res, 401, { error: "Unauthorized" });
19
+ }
20
+ await route.handler({ req, res, upstream, models, auto, logs, peers, maxHops, groups, bans, token, bus });
21
+ };
22
+ }
23
+
24
+ const ROUTES = [
25
+ {
26
+ method: "GET",
27
+ path: "/health",
28
+ handler: ({ res }) => json(res, 200, { status: "ok" }),
29
+ },
30
+ {
31
+ method: "POST",
32
+ path: "/v1/chat/completions",
33
+ requiresAuth: true,
34
+ handler: chatHandler,
35
+ },
36
+ {
37
+ method: "POST",
38
+ path: "/v1/groups/join",
39
+ requiresAuth: false,
40
+ handler: joinHandler,
41
+ },
42
+ {
43
+ method: "POST",
44
+ path: "/v1/groups/leave",
45
+ requiresAuth: false,
46
+ handler: leaveHandler,
47
+ },
48
+ {
49
+ method: "POST",
50
+ path: "/v1/groups/relay/heartbeat",
51
+ requiresAuth: false,
52
+ handler: heartbeatHandler,
53
+ },
54
+ {
55
+ method: "POST",
56
+ path: "/v1/groups/relay/poll",
57
+ requiresAuth: false,
58
+ handler: pollHandler,
59
+ },
60
+ {
61
+ method: "POST",
62
+ path: "/v1/groups/relay/result",
63
+ requiresAuth: false,
64
+ handler: resultHandler,
65
+ },
66
+ {
67
+ method: "POST",
68
+ path: "/v1/groups/relay/forward",
69
+ requiresAuth: true,
70
+ handler: forwardHandler,
71
+ },
72
+ {
73
+ method: "GET",
74
+ path: "/v1/models",
75
+ requiresAuth: true,
76
+ handler: modelsHandler,
77
+ },
78
+ {
79
+ method: "GET",
80
+ path: "/v1/models/status",
81
+ requiresAuth: true,
82
+ handler: modelsStatusHandler,
83
+ },
84
+ ];
85
+
86
+ // re-export for facade & tests
87
+ export { errMsg, PROMPT_MAX_LEN, summarizePrompt, clientIp, authorized, json, notFound, readBody, parseHops } from "./helpers.js";
88
+ export { buildFallbackInfo, applyFallbackHeaders, enrichNonStreamJson, enrichSseChunkText } from "./fallback.js";
89
+ export { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS, MAX_STREAM_MS } from "./stream.js";
90
+ export { peerHealthyModels, racePeerCandidates, PEER_RACE_LIMIT } from "./peers.js";
91
+ export { enqueueRelay, dequeueRelayForPoll, resolveRelay, tryBroadbandRelay } from "./relay-queue.js";
@@ -0,0 +1,31 @@
1
+ import { json, errMsg } from "./helpers.js";
2
+
3
+ export async function modelsHandler({ res, models }) {
4
+ if (!models) return json(res, 501, { error: "Models service not configured" });
5
+ try {
6
+ const data = await models.get();
7
+ json(res, 200, data);
8
+ } catch (err) {
9
+ json(res, 502, { error: errMsg(err) });
10
+ }
11
+ }
12
+
13
+ export async function modelsStatusHandler({ res, models, auto }) {
14
+ const statuses = auto?.statuses?.() || {};
15
+ let ids = [];
16
+ try {
17
+ ids = (await models?.get?.())?.data?.map((m) => m.id) || [];
18
+ } catch {}
19
+ const seen = new Set();
20
+ const data = [];
21
+ for (const id of [...ids, ...Object.keys(statuses)]) {
22
+ if (seen.has(id)) continue;
23
+ seen.add(id);
24
+ const e = statuses[id];
25
+ const entry = typeof e === "number"
26
+ ? { id, status: "error", at: e }
27
+ : { id, status: e?.status || "normal", at: e?.at ?? null, code: e?.code ?? null };
28
+ data.push(entry);
29
+ }
30
+ json(res, 200, { object: "list", data });
31
+ }
@@ -0,0 +1,125 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { isAutoModel } from "../auto.js";
3
+ import { errMsg } from "./helpers.js";
4
+
5
+ const PEER_TIMEOUT_MS = 30_000;
6
+ const PEER_STATUS_TIMEOUT_MS = 2_000;
7
+
8
+ export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_MS, fetchImpl = fetch } = {}) {
9
+ try {
10
+ const res = await fetchImpl(`${peer.url}/v1/models/status`, {
11
+ headers: {
12
+ "Authorization": `Bearer ${peer.token || ""}`,
13
+ "Accept": "application/json",
14
+ },
15
+ signal: AbortSignal.timeout(timeoutMs),
16
+ });
17
+ if (!res.ok) return [];
18
+ const j = await res.json().catch(() => ({}));
19
+ return (j.data || [])
20
+ .filter((m) => m && typeof m.id === "string" && m.status === "normal")
21
+ .map((m) => m.id);
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+
27
+ async function forwardToPeer(peer, body, model, hops) {
28
+ const controller = new AbortController();
29
+ const timer = setTimeout(() => controller.abort(), PEER_TIMEOUT_MS);
30
+ try {
31
+ return await fetch(`${peer.url}/v1/chat/completions`, {
32
+ method: "POST",
33
+ headers: {
34
+ "Content-Type": "application/json",
35
+ "Authorization": `Bearer ${peer.token}`,
36
+ "x-mslxdff-hops": String(hops + 1),
37
+ "x-mslxdff-model-lock": model,
38
+ "Accept": "text/event-stream",
39
+ },
40
+ body: JSON.stringify({ ...body, model }),
41
+ signal: controller.signal,
42
+ });
43
+ } catch (err) {
44
+ return err;
45
+ } finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+
50
+ async function resolvePeerTarget(ctx, peer) {
51
+ const prevModel = ctx.peers.stat(peer.url)?.model;
52
+ const hot = ctx.peers.isHot(peer.url) && prevModel === ctx.model;
53
+ if (hot) return { peer, target: prevModel };
54
+ const isExplicit = !!ctx.model && !isAutoModel(ctx.model);
55
+ if (isExplicit) {
56
+ const healthy = await peerHealthyModels(peer);
57
+ if (!healthy.length) {
58
+ ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0, strict: true });
59
+ return { peer, target: ctx.model };
60
+ }
61
+ ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length, strict: true });
62
+ return { peer, target: ctx.model };
63
+ }
64
+ const healthy = await peerHealthyModels(peer);
65
+ if (!healthy.length) {
66
+ await ctx.peers.recordError(peer.url);
67
+ ctx.logError(ctx.model, 0, `peer ${peer.url} has no healthy models`);
68
+ ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0 });
69
+ return null;
70
+ }
71
+ ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length });
72
+ return { peer, target: healthy.includes(ctx.model) ? ctx.model : healthy[0] };
73
+ }
74
+
75
+ export const PEER_RACE_LIMIT = Number(process.env.MSLXDFF_PEER_RACE_LIMIT) > 0
76
+ ? Number(process.env.MSLXDFF_PEER_RACE_LIMIT)
77
+ : 3;
78
+
79
+ export async function racePeerCandidates(candidates, ctx) {
80
+ for (let i = 0; i < candidates.length; i += PEER_RACE_LIMIT) {
81
+ const batch = candidates.slice(i, i + PEER_RACE_LIMIT);
82
+ const prepared = (await Promise.all(batch.map((peer) => resolvePeerTarget(ctx, peer)))).filter(Boolean);
83
+ if (!prepared.length) continue;
84
+ const completed = await new Promise((resolve) => {
85
+ const order = [];
86
+ const total = prepared.length;
87
+ for (const { peer, target } of prepared) {
88
+ ctx.evt("peer-request", { peer: peer.url, model: target, hops: ctx.hops + 1 });
89
+ const t0 = performance.now();
90
+ forwardToPeer(peer, ctx.body, target, ctx.hops).then((res) => {
91
+ const latencyMs = Math.round(performance.now() - t0);
92
+ const failed = res instanceof Error || res.status >= 400;
93
+ ctx.evt("peer-forward", { peer: peer.url, model: target, hops: ctx.hops + 1, latencyMs, ok: !failed });
94
+ if (failed) {
95
+ const status = res instanceof Error ? 502 : res.status;
96
+ ctx.logError(ctx.model, status, res instanceof Error ? errMsg(res) : `peer ${status}`);
97
+ ctx.evt("peer-error", { peer: peer.url, model: target, status, message: res instanceof Error ? errMsg(res) : null });
98
+ order.push({ ok: false, peer, target, res, status });
99
+ } else {
100
+ order.push({ ok: true, peer, target, res, latencyMs });
101
+ }
102
+ if (order.length === total) resolve(order);
103
+ });
104
+ }
105
+ });
106
+ const winner = completed.find((o) => o.ok);
107
+ if (winner) {
108
+ for (const o of completed) {
109
+ if (o === winner) continue;
110
+ if (!o.ok) {
111
+ await ctx.peers.recordError(o.peer.url);
112
+ await ctx.peers.recordResult(o.peer.url, { ok: false });
113
+ } else {
114
+ await ctx.peers.recordResult(o.peer.url, { ok: true, latencyMs: o.latencyMs, model: o.target });
115
+ }
116
+ }
117
+ return { peer: winner.peer, target: winner.target, res: winner.res, latencyMs: winner.latencyMs };
118
+ }
119
+ for (const o of completed) {
120
+ await ctx.peers.recordError(o.peer.url);
121
+ await ctx.peers.recordResult(o.peer.url, { ok: false });
122
+ }
123
+ }
124
+ return null;
125
+ }
@@ -0,0 +1,127 @@
1
+ import { loadGroupsJoined } from "../state.js";
2
+
3
+ const relayPending = new Map();
4
+ const relayPendingByReqId = new Map();
5
+
6
+ export function enqueueRelay({ group, target, reqId, body, hops }) {
7
+ const key = `${group}::${target}`;
8
+ const list = relayPending.get(key) || [];
9
+ return new Promise((resolve, reject) => {
10
+ const timer = setTimeout(() => {
11
+ const idx = list.findIndex((e) => e.reqId === reqId);
12
+ if (idx >= 0) list.splice(idx, 1);
13
+ relayPendingByReqId.delete(reqId);
14
+ reject(new Error("relay timeout"));
15
+ }, 30_000);
16
+ timer.unref?.();
17
+ const entry = { reqId, body, hops, resolve, reject, timer };
18
+ list.push(entry);
19
+ relayPending.set(key, list);
20
+ relayPendingByReqId.set(reqId, entry);
21
+ });
22
+ }
23
+
24
+ export function dequeueRelayForPoll({ group, target, limit = 10 }) {
25
+ const key = `${group}::${target}`;
26
+ const list = relayPending.get(key) || [];
27
+ const batch = list.splice(0, limit);
28
+ if (list.length) relayPending.set(key, list);
29
+ else relayPending.delete(key);
30
+ return batch.map((e) => ({ reqId: e.reqId, body: e.body, hops: e.hops }));
31
+ }
32
+
33
+ export function resolveRelay(reqId, result) {
34
+ const entry = relayPendingByReqId.get(reqId);
35
+ if (!entry) return false;
36
+ clearTimeout(entry.timer);
37
+ relayPendingByReqId.delete(reqId);
38
+ entry.resolve(result);
39
+ return true;
40
+ }
41
+
42
+ export function getRelayPending() { return relayPending; }
43
+ export function getRelayPendingByReqId() { return relayPendingByReqId; }
44
+
45
+ export async function tryBroadbandRelay({ groups, token: myToken, model, body, hops, bus, logs, reqId, evt, res, mark, perf0, stages }) {
46
+ try {
47
+ const joined = loadGroupsJoined();
48
+ const broadbandGroups = joined.filter((g) => g.kind === "broadband" || g.myUrl?.startsWith("relay://"));
49
+ const allCandidates = [];
50
+ if (groups) {
51
+ const localGroups = groups.list();
52
+ for (const [gName, g] of Object.entries(localGroups)) {
53
+ for (const [id, m] of Object.entries(g.members || {})) {
54
+ if (id === "leader") continue;
55
+ const isBb = m?.kind === "broadband" || String(m?.url || "").startsWith("relay://");
56
+ if (!isBb) continue;
57
+ const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
58
+ if (typeof m.lastSeen === "number" && Date.now() - m.lastSeen > staleMs) continue;
59
+ allCandidates.push({ group: gName, target: m.url, member: m, via: "local-leader", leaderUrl: null });
60
+ }
61
+ }
62
+ }
63
+ for (const g of joined) {
64
+ if (!g.leaderUrl) continue;
65
+ try {
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), 5000);
68
+ const r = await fetch(`${g.leaderUrl}/v1/groups/join`, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${myToken}` },
71
+ body: JSON.stringify({ name: g.name, memberName: g.memberName, url: g.myUrl, token: myToken }),
72
+ signal: controller.signal,
73
+ });
74
+ clearTimeout(timer);
75
+ if (!r.ok) continue;
76
+ const data = await r.json().catch(() => ({}));
77
+ const members = data.members || {};
78
+ for (const [id, m] of Object.entries(members)) {
79
+ if (id === "leader") continue;
80
+ const isBb = m?.kind === "broadband" || String(m?.url || "").startsWith("relay://");
81
+ if (!isBb) continue;
82
+ if (m.url === g.myUrl) continue;
83
+ const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
84
+ if (typeof m.lastSeen === "number" && Date.now() - m.lastSeen > staleMs) continue;
85
+ allCandidates.push({ group: g.name, target: m.url, member: m, via: "via-leader", leaderUrl: g.leaderUrl });
86
+ }
87
+ } catch {}
88
+ }
89
+ if (!allCandidates.length) return null;
90
+ for (const cand of allCandidates) {
91
+ try {
92
+ evt?.("relay-try", { reqId, model, via: cand.via, target: cand.target, group: cand.group });
93
+ if (!cand.leaderUrl) {
94
+ const fwdBody = { model, ...body, model };
95
+ const reqIdLocal = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
96
+ const promise = enqueueRelay({ group: cand.group, target: cand.target, reqId: reqIdLocal, body: fwdBody, hops });
97
+ const result = await promise;
98
+ if (result && result.status) {
99
+ return { via: "broadband-local", result, target: cand.target, group: cand.group };
100
+ }
101
+ } else {
102
+ const ctrl = new AbortController();
103
+ const t = setTimeout(() => ctrl.abort(), 35_000);
104
+ const r = await fetch(`${cand.leaderUrl}/v1/groups/relay/forward`, {
105
+ method: "POST",
106
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${myToken}`, "x-mslxdff-hops": String(hops + 1) },
107
+ body: JSON.stringify({ group: cand.group, target: cand.target, body: { ...body, model }, hops: hops + 1, reqId }),
108
+ signal: ctrl.signal,
109
+ });
110
+ clearTimeout(t);
111
+ if (!r.ok) {
112
+ const txt = await r.text().catch(() => "");
113
+ evt?.("relay-fail", { reqId, model, via: cand.via, target: cand.target, status: r.status, message: txt.slice(0, 200) });
114
+ continue;
115
+ }
116
+ return { via: "broadband-via-leader", result: r, target: cand.target, group: cand.group };
117
+ }
118
+ } catch (err) {
119
+ evt?.("relay-fail", { reqId, model, via: cand.via, target: cand.target, message: String(err?.message || err).slice(0, 200) });
120
+ continue;
121
+ }
122
+ }
123
+ return null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }