mslxdff 0.1.199 → 0.1.203
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/README.md +3 -3
- package/bin/mslxdff.js +1 -1
- package/package.json +38 -38
- package/plugins/README.md +14 -14
- package/plugins/prefer-model.mjs.example +14 -14
- package/src/anthropic/count-tokens.js +61 -61
- package/src/anthropic/request-body.js +126 -126
- package/src/anthropic/shared.js +18 -18
- package/src/anthropic/sse-aggregate.js +56 -56
- package/src/anthropic/translate.js +287 -287
- package/src/anthropic/web-search.js +361 -361
- package/src/api-kind.js +31 -31
- package/src/autostart.js +288 -288
- package/src/bench/probe.js +1 -1
- package/src/bench/runner.js +1 -1
- package/src/bench/via-probe.js +1 -1
- package/src/bench/via.js +144 -144
- package/src/bench/workbuddy-bench.js +1 -1
- package/src/chat/config.js +11 -11
- package/src/chat/cooling.js +81 -81
- package/src/chat/direct.js +58 -58
- package/src/chat/index.js +6 -6
- package/src/chat/spinner.js +38 -38
- package/src/chat/sse.js +69 -69
- package/src/chat/stats.js +247 -247
- package/src/chat/store.js +48 -48
- package/src/chat/tool-handlers.js +67 -67
- package/src/chat/tools.js +1 -1
- package/src/chat/upstream.js +59 -59
- package/src/chat-pipeline/auto-race.js +158 -158
- package/src/chat-pipeline/empty-turn.js +147 -147
- package/src/chat-pipeline/engine.js +17 -17
- package/src/chat-pipeline/serial-trial.js +283 -283
- package/src/chat-pipeline/terminal-verdict.js +147 -147
- package/src/chooser.js +54 -54
- package/src/claude-desktop/apply.js +101 -101
- package/src/claude-desktop/context1m.js +38 -38
- package/src/claude-desktop/doctor.js +146 -146
- package/src/claude-desktop/slots.js +152 -152
- package/src/cli/bootstrap.js +1 -1
- package/src/cli/commands/claude-desktop.js +284 -284
- package/src/cli/commands/daemon.js +231 -231
- package/src/cli/commands/model/list-live.js +22 -22
- package/src/cli/commands/model/list-providers.js +76 -76
- package/src/cli/commands/model/list-render.js +77 -77
- package/src/cli/commands/model/list-sort.js +52 -52
- package/src/cli/commands/model/list-tty.js +76 -76
- package/src/cli/commands/model/list.js +166 -166
- package/src/cli/commands/model/live-models.js +24 -24
- package/src/cli/commands/model/stats.js +43 -43
- package/src/cli/commands/model/status.js +47 -47
- package/src/cli/commands/model.js +51 -51
- package/src/cli/commands/provider/add.js +108 -108
- package/src/cli/commands/provider/allowlist.js +99 -99
- package/src/cli/commands/provider/cline-free.js +99 -99
- package/src/cli/commands/provider/cline-login.js +218 -218
- package/src/cli/commands/provider/cline-quota.js +65 -65
- package/src/cli/commands/provider/codearts-login.js +45 -45
- package/src/cli/commands/provider/config.js +119 -119
- package/src/cli/commands/provider/globalqwenwork-login.js +126 -126
- package/src/cli/commands/provider/keys-guard.js +50 -50
- package/src/cli/commands/provider/keys.js +161 -161
- package/src/cli/commands/provider/qoder-checkin.js +81 -81
- package/src/cli/commands/provider/qoder-login.js +102 -102
- package/src/cli/commands/provider/raccoon-login.js +180 -180
- package/src/cli/commands/provider/raccoon-password.js +162 -162
- package/src/cli/commands/provider/raccoon-quota.js +109 -109
- package/src/cli/commands/provider/traework-checkin.js +144 -144
- package/src/cli/commands/provider/traework-login.js +164 -164
- package/src/cli/commands/provider/workbuddy-login.js +153 -153
- package/src/cli/commands/provider/zcode-login.js +77 -77
- package/src/cli/commands/provider/zcode-quota.js +55 -55
- package/src/cli/commands/provider.js +1 -1
- package/src/cli/commands/stats.js +149 -149
- package/src/cli/commands/system.js +261 -261
- package/src/cli/commands/timezone.js +47 -47
- package/src/cli/commands/use-group.js +60 -60
- package/src/cli/commands/workbuddy.js +189 -189
- package/src/cli/format.js +141 -141
- package/src/cli/group-helpers.js +70 -70
- package/src/cli/help.js +80 -80
- package/src/cli/index.js +70 -70
- package/src/cli/interactive.js +121 -121
- package/src/cli/join-core.js +99 -99
- package/src/cli/join-wizard.js +175 -175
- package/src/cli/policy.js +136 -136
- package/src/cli/provider-row.js +126 -126
- package/src/cli/util.js +24 -24
- package/src/compat.js +53 -53
- package/src/events.js +38 -38
- package/src/free-lane.js +131 -131
- package/src/free-watcher.js +63 -63
- package/src/groups.js +262 -262
- package/src/logs.js +195 -195
- package/src/metrics.js +62 -62
- package/src/model-capabilities/context-lookup.js +65 -65
- package/src/model-capabilities/merge.js +142 -142
- package/src/model-trace.js +187 -187
- package/src/models.js +1 -1
- package/src/opencode-identity.js +72 -72
- package/src/peers.js +254 -251
- package/src/plugins.js +107 -107
- package/src/providers/base.js +164 -164
- package/src/providers/classify.js +22 -22
- package/src/providers/cline/allowlist-sync.js +93 -93
- package/src/providers/cline/free-catalog.js +71 -71
- package/src/providers/cline/headers.js +37 -37
- package/src/providers/cline/index.js +130 -130
- package/src/providers/cline/models.js +150 -150
- package/src/providers/cline/usage.js +274 -274
- package/src/providers/codearts/auth-pool.js +148 -148
- package/src/providers/codearts/concurrency.js +34 -34
- package/src/providers/codearts/const.js +65 -65
- package/src/providers/codearts/dpop.js +95 -95
- package/src/providers/codearts/index.js +175 -175
- package/src/providers/codearts/login.js +214 -214
- package/src/providers/codearts/models.js +182 -182
- package/src/providers/codearts/sign.js +96 -96
- package/src/providers/codearts/sse.js +204 -204
- package/src/providers/codearts/stream.js +209 -209
- package/src/providers/codearts/sts.js +124 -124
- package/src/providers/codearts.js +1 -1
- package/src/providers/dispatcher.js +146 -146
- package/src/providers/generic.js +156 -156
- package/src/providers/globalqwenwork/account-store.js +141 -141
- package/src/providers/globalqwenwork/constants.js +73 -73
- package/src/providers/globalqwenwork/crypto.js +2 -2
- package/src/providers/globalqwenwork/http.js +2 -2
- package/src/providers/globalqwenwork/index.js +331 -331
- package/src/providers/globalqwenwork/payload.js +147 -147
- package/src/providers/globalqwenwork/rsa.js +2 -2
- package/src/providers/globalqwenwork/sse.js +2 -2
- package/src/providers/globalqwenwork/stream.js +2 -2
- package/src/providers/globalqwenwork.js +1 -1
- package/src/providers/globalworkbuddy/account-store.js +198 -198
- package/src/providers/globalworkbuddy/auth.js +97 -97
- package/src/providers/globalworkbuddy/catalog.js +138 -138
- package/src/providers/globalworkbuddy/constants.js +73 -73
- package/src/providers/globalworkbuddy/device-auth.js +120 -120
- package/src/providers/globalworkbuddy/errors.js +98 -98
- package/src/providers/globalworkbuddy/headers.js +89 -89
- package/src/providers/globalworkbuddy/models.js +96 -96
- package/src/providers/globalworkbuddy/payload.js +80 -80
- package/src/providers/index.js +5 -5
- package/src/providers/model-id.js +172 -172
- package/src/providers/opencode.js +19 -19
- package/src/providers/openrouter.js +31 -31
- package/src/providers/qoder/account-store.js +106 -106
- package/src/providers/qoder/aggregate.js +60 -60
- package/src/providers/qoder/baseprompt.json +591 -591
- package/src/providers/qoder/checkin.js +160 -160
- package/src/providers/qoder/constants.js +44 -44
- package/src/providers/qoder/encode.js +41 -41
- package/src/providers/qoder/fingerprint.js +38 -38
- package/src/providers/qoder/models.js +77 -77
- package/src/providers/qoder/oauth.js +84 -84
- package/src/providers/qoder/payload.js +162 -162
- package/src/providers/qoder/request.js +27 -27
- package/src/providers/qoder/session.js +134 -134
- package/src/providers/qoder/sticky.js +38 -38
- package/src/providers/qoder/stream.js +116 -116
- package/src/providers/qoder.js +1 -1
- package/src/providers/raccoon/account-store.js +216 -216
- package/src/providers/raccoon/auth.js +67 -67
- package/src/providers/raccoon/chat.js +228 -228
- package/src/providers/raccoon/const.js +180 -165
- package/src/providers/raccoon/credits.js +185 -175
- package/src/providers/raccoon/crypto.js +43 -0
- package/src/providers/raccoon/envelope.js +31 -31
- package/src/providers/raccoon/headers.js +45 -45
- package/src/providers/raccoon/index.js +216 -216
- package/src/providers/raccoon/login.js +279 -279
- package/src/providers/raccoon/models.js +160 -160
- package/src/providers/raccoon/password-login.js +102 -87
- package/src/providers/raccoon/qr-codewords.js +115 -115
- package/src/providers/raccoon/qr.js +236 -236
- package/src/providers/raccoon/sse.js +135 -135
- package/src/providers/responses-channel.js +120 -120
- package/src/providers/share-keys.js +53 -53
- package/src/providers/traework/account-store.js +234 -234
- package/src/providers/traework/chat.js +217 -217
- package/src/providers/traework/checkin.js +77 -77
- package/src/providers/traework/constants.js +41 -41
- package/src/providers/traework/errors.js +52 -52
- package/src/providers/traework/headers.js +79 -79
- package/src/providers/traework/index.js +132 -132
- package/src/providers/traework/market-identity.js +116 -116
- package/src/providers/traework/models.js +98 -98
- package/src/providers/traework/payload.js +86 -86
- package/src/providers/traework/sse.js +184 -184
- package/src/providers/traework/token.js +52 -52
- package/src/providers/traework.js +2 -2
- package/src/providers/workbuddy/account-store.js +186 -186
- package/src/providers/workbuddy/auth.js +158 -158
- package/src/providers/workbuddy/balance.js +86 -86
- package/src/providers/workbuddy/cat-travel.js +125 -125
- package/src/providers/workbuddy/checkin.js +75 -75
- package/src/providers/workbuddy/desktop-info.js +78 -78
- package/src/providers/workbuddy/growth-api.js +67 -67
- package/src/providers/workbuddy/growth-plans.js +63 -63
- package/src/providers/workbuddy/growth.js +181 -181
- package/src/providers/workbuddy/index.js +217 -217
- package/src/providers/workbuddy/models.js +126 -126
- package/src/providers/workbuddy/payload.js +141 -141
- package/src/providers/workbuddy/rotation-log.js +83 -83
- package/src/providers/workbuddy/sanitize-tools.js +61 -61
- package/src/providers/workbuddy/sdk-chat.js +26 -26
- package/src/providers/workbuddy-balance.js +78 -78
- package/src/providers/zcode/account-store.js +129 -129
- package/src/providers/zcode/auth.js +28 -28
- package/src/providers/zcode/chat.js +179 -179
- package/src/providers/zcode/const.js +54 -54
- package/src/providers/zcode/context-shape.js +162 -162
- package/src/providers/zcode/headers.js +93 -93
- package/src/providers/zcode/index.js +124 -124
- package/src/providers/zcode/models.js +65 -65
- package/src/providers/zcode/oauth.js +120 -120
- package/src/providers/zcode/quota.js +176 -176
- package/src/providers/zcode/sse.js +196 -196
- package/src/readline-compat.js +25 -25
- package/src/responses/translate.js +311 -311
- package/src/routes/chat/broadband-handler.js +72 -72
- package/src/routes/chat/empty-turn-report.js +31 -31
- package/src/routes/chat/exhausted-handler.js +87 -87
- package/src/routes/chat/hedge-handler.js +130 -130
- package/src/routes/chat/index.js +1 -1
- package/src/routes/chat/local-handler.js +63 -63
- package/src/routes/chat/peer-handler.js +81 -81
- package/src/routes/chat.js +3 -3
- package/src/routes/fallback.js +88 -88
- package/src/routes/groups-relay.js +214 -214
- package/src/routes/groups.js +122 -122
- package/src/routes/helpers.js +83 -83
- package/src/routes/index.js +147 -147
- package/src/routes/messages-route.js +365 -365
- package/src/routes/peers.js +23 -1
- package/src/routes/relay-queue.js +181 -181
- package/src/routes/relay.js +60 -60
- package/src/routes/responses-route.js +213 -213
- package/src/routes/stream-body.js +84 -84
- package/src/routes/stream-hold.js +337 -337
- package/src/routes/stream-scan.js +366 -366
- package/src/routes.js +3 -3
- package/src/runtime/auto-update.js +113 -113
- package/src/runtime/bootstrap.js +8 -0
- package/src/runtime/broadband-stream.js +79 -79
- package/src/runtime/broadband.js +103 -103
- package/src/runtime/group-sync.js +81 -81
- package/src/runtime/keep-warm.js +120 -0
- package/src/runtime/lifecycle-forensics.js +116 -116
- package/src/runtime/lifecycle-log.js +87 -87
- package/src/runtime/log-stamp.js +73 -73
- package/src/runtime/proc-info.js +137 -137
- package/src/runtime/qoder-checkin.js +93 -93
- package/src/runtime/raccoon-checkin.js +126 -126
- package/src/runtime/server-lifecycle.js +189 -189
- package/src/runtime/traework-checkin.js +118 -118
- package/src/runtime/workbuddy-checkin.js +120 -120
- package/src/runtime/workbuddy-growth.js +133 -133
- package/src/server.js +7 -0
- package/src/state/facade.js +58 -58
- package/src/state/memory.js +162 -162
- package/src/state/merge.js +25 -25
- package/src/state/migrations/cline-unify.js +94 -94
- package/src/state/migrations.js +33 -33
- package/src/state/persist.js +42 -42
- package/src/state/provider-config.js +142 -142
- package/src/state/schemas/group.js +31 -31
- package/src/state/schemas/model.js +94 -94
- package/src/state/schemas/peer.js +31 -31
- package/src/state/schemas/port.js +10 -10
- package/src/state/schemas/provider.js +202 -202
- package/src/state/schemas/timezone.js +70 -70
- package/src/state/schemas/token.js +48 -48
- package/src/state/schemas/use-group.js +102 -102
- package/src/state/store.js +176 -176
- package/src/state.js +1 -1
- package/src/sync-claude-desktop.js +234 -234
- package/src/sync-claude.js +152 -152
- package/src/sync-codex.js +116 -116
- package/src/sync-workbuddy.js +158 -158
- package/src/talk-full-redact.js +129 -129
- package/src/talk-full.js +131 -131
- package/src/talk-log.js +257 -257
- package/src/text-tokens.js +52 -52
- package/src/thinking-effort.js +67 -67
- package/src/time.js +106 -106
- package/src/timeline.js +26 -26
- package/src/transport/index.js +1 -1
- package/src/upstream-engine/index.js +74 -72
- package/src/upstream-engine/mode.js +15 -15
- package/src/upstream-engine/sdk/attempt.js +221 -221
- package/src/upstream-engine/sdk/chat.js +28 -28
- package/src/upstream-engine/sdk/convert.js +192 -192
- package/src/upstream-engine/sdk/diagnose.js +62 -62
- package/src/upstream-engine/sdk/dispatch.js +55 -55
- package/src/upstream-engine/sdk/responses.js +176 -176
- package/src/upstream-engine/sdk/sse.js +166 -166
- package/src/upstream-responses.js +305 -305
- package/src/upstream.js +53 -1
- package/src/usage/record.js +106 -106
- package/src/usage/report.js +195 -195
- package/src/v6pool/claim.js +157 -157
- package/src/v6pool/config.js +65 -65
- package/src/v6pool/detect.js +130 -130
- package/src/v6pool/index.js +152 -152
- package/src/v6pool/prefix.js +140 -140
- package/src/v6pool/probe.js +40 -40
- package/src/v6pool/report.js +104 -104
- package/src/v6pool/rotate.js +263 -239
- package/src/warp-pool/config.js +74 -74
- package/src/warp-pool/health.js +106 -106
- package/src/warp-pool/identity.js +141 -141
- package/src/warp-pool/index.js +358 -358
- package/src/warp-pool/keys.js +29 -29
- package/src/warp-pool/nodes.js +95 -95
- package/src/warp-pool/ports.js +160 -160
- package/src/warp-pool/register.js +113 -113
- package/src/warp-pool/report.js +232 -232
- package/src/warp-pool/revive.js +168 -168
- package/src/warp-pool/rotate.js +214 -214
- package/src/warp-pool/strategy.js +43 -43
- package/src/warp-pool/verdict.js +69 -69
- package/src/warp-pool/wireproxy.js +162 -162
- package/docs/cli_help_mini.md +0 -146
- package/src/providers/AGENTS.md +0 -49
- package/src/routes/AGENTS.md +0 -37
package/src/routes/fallback.js
CHANGED
|
@@ -1,88 +1,88 @@
|
|
|
1
|
-
function fallbackReason(lastErr) {
|
|
2
|
-
if (!lastErr) return "cooldown";
|
|
3
|
-
const s = Number(lastErr.status);
|
|
4
|
-
if (s === 429) return "rate_limited";
|
|
5
|
-
if (lastErr.message && /timeout/i.test(String(lastErr.message))) return "timeout";
|
|
6
|
-
if (s === 502 || s === 503 || s === 504) return "upstream_error";
|
|
7
|
-
if (s >= 400) return "upstream_error";
|
|
8
|
-
return "fallback";
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function buildFallbackInfo({ requested, actual, lastErr, via, useAuto, lockModel }) {
|
|
12
|
-
if (!requested || !actual) return null;
|
|
13
|
-
const alwaysHeaders = {
|
|
14
|
-
requested_model: requested,
|
|
15
|
-
actual_model: actual,
|
|
16
|
-
via: via || "local",
|
|
17
|
-
};
|
|
18
|
-
if (useAuto || lockModel) {
|
|
19
|
-
return { ...alwaysHeaders, fallback: false, reason: null, notice: null };
|
|
20
|
-
}
|
|
21
|
-
const isFallback = requested !== actual;
|
|
22
|
-
if (!isFallback) {
|
|
23
|
-
return { ...alwaysHeaders, fallback: false, reason: null, notice: null };
|
|
24
|
-
}
|
|
25
|
-
const reason = fallbackReason(lastErr);
|
|
26
|
-
const reasonZh = reason === "rate_limited" ? "限流" : reason === "timeout" ? "超时" : reason === "cooldown" ? "冷却中" : "不可用";
|
|
27
|
-
const notice = `${requested} ${reasonZh},已由 ${actual} 代答`;
|
|
28
|
-
return { ...alwaysHeaders, fallback: true, reason, notice };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function applyFallbackHeaders(res, info) {
|
|
32
|
-
if (!info) return;
|
|
33
|
-
if (info.requested_model) res.setHeader("x-mslxdff-requested-model", info.requested_model);
|
|
34
|
-
if (info.actual_model) res.setHeader("x-mslxdff-actual-model", info.actual_model);
|
|
35
|
-
if (info.via) res.setHeader("x-mslxdff-via", info.via);
|
|
36
|
-
if (info.fallback) {
|
|
37
|
-
res.setHeader("x-mslxdff-fallback", "1");
|
|
38
|
-
if (info.reason) res.setHeader("x-mslxdff-fallback-reason", info.reason);
|
|
39
|
-
if (info.notice) res.setHeader("x-mslxdff-notice", encodeURIComponent(info.notice));
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function enrichNonStreamJson(obj, info) {
|
|
44
|
-
if (!info || typeof obj !== "object" || obj === null) return obj;
|
|
45
|
-
if (!info.fallback) return obj;
|
|
46
|
-
if (obj.mslxdff) return obj;
|
|
47
|
-
return {
|
|
48
|
-
...obj,
|
|
49
|
-
mslxdff: {
|
|
50
|
-
fallback: true,
|
|
51
|
-
requested_model: info.requested_model,
|
|
52
|
-
actual_model: info.actual_model,
|
|
53
|
-
reason: info.reason,
|
|
54
|
-
via: info.via,
|
|
55
|
-
notice: info.notice,
|
|
56
|
-
},
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function enrichSseChunkText(text, info) {
|
|
61
|
-
if (!info?.fallback) return text;
|
|
62
|
-
const lines = text.split("\n");
|
|
63
|
-
let changed = false;
|
|
64
|
-
for (let i = 0; i < lines.length; i++) {
|
|
65
|
-
const line = lines[i];
|
|
66
|
-
const m = /^data:\s*(\{.*\})\s*$/.exec(line);
|
|
67
|
-
if (!m) continue;
|
|
68
|
-
try {
|
|
69
|
-
const obj = JSON.parse(m[1]);
|
|
70
|
-
if (obj && typeof obj === "object" && !obj.mslxdff) {
|
|
71
|
-
obj.mslxdff = {
|
|
72
|
-
fallback: true,
|
|
73
|
-
requested_model: info.requested_model,
|
|
74
|
-
actual_model: info.actual_model,
|
|
75
|
-
reason: info.reason,
|
|
76
|
-
via: info.via,
|
|
77
|
-
notice: info.notice,
|
|
78
|
-
};
|
|
79
|
-
lines[i] = `data: ${JSON.stringify(obj)}`;
|
|
80
|
-
changed = true;
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
|
-
} catch {
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return changed ? lines.join("\n") : text;
|
|
88
|
-
}
|
|
1
|
+
function fallbackReason(lastErr) {
|
|
2
|
+
if (!lastErr) return "cooldown";
|
|
3
|
+
const s = Number(lastErr.status);
|
|
4
|
+
if (s === 429) return "rate_limited";
|
|
5
|
+
if (lastErr.message && /timeout/i.test(String(lastErr.message))) return "timeout";
|
|
6
|
+
if (s === 502 || s === 503 || s === 504) return "upstream_error";
|
|
7
|
+
if (s >= 400) return "upstream_error";
|
|
8
|
+
return "fallback";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildFallbackInfo({ requested, actual, lastErr, via, useAuto, lockModel }) {
|
|
12
|
+
if (!requested || !actual) return null;
|
|
13
|
+
const alwaysHeaders = {
|
|
14
|
+
requested_model: requested,
|
|
15
|
+
actual_model: actual,
|
|
16
|
+
via: via || "local",
|
|
17
|
+
};
|
|
18
|
+
if (useAuto || lockModel) {
|
|
19
|
+
return { ...alwaysHeaders, fallback: false, reason: null, notice: null };
|
|
20
|
+
}
|
|
21
|
+
const isFallback = requested !== actual;
|
|
22
|
+
if (!isFallback) {
|
|
23
|
+
return { ...alwaysHeaders, fallback: false, reason: null, notice: null };
|
|
24
|
+
}
|
|
25
|
+
const reason = fallbackReason(lastErr);
|
|
26
|
+
const reasonZh = reason === "rate_limited" ? "限流" : reason === "timeout" ? "超时" : reason === "cooldown" ? "冷却中" : "不可用";
|
|
27
|
+
const notice = `${requested} ${reasonZh},已由 ${actual} 代答`;
|
|
28
|
+
return { ...alwaysHeaders, fallback: true, reason, notice };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function applyFallbackHeaders(res, info) {
|
|
32
|
+
if (!info) return;
|
|
33
|
+
if (info.requested_model) res.setHeader("x-mslxdff-requested-model", info.requested_model);
|
|
34
|
+
if (info.actual_model) res.setHeader("x-mslxdff-actual-model", info.actual_model);
|
|
35
|
+
if (info.via) res.setHeader("x-mslxdff-via", info.via);
|
|
36
|
+
if (info.fallback) {
|
|
37
|
+
res.setHeader("x-mslxdff-fallback", "1");
|
|
38
|
+
if (info.reason) res.setHeader("x-mslxdff-fallback-reason", info.reason);
|
|
39
|
+
if (info.notice) res.setHeader("x-mslxdff-notice", encodeURIComponent(info.notice));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function enrichNonStreamJson(obj, info) {
|
|
44
|
+
if (!info || typeof obj !== "object" || obj === null) return obj;
|
|
45
|
+
if (!info.fallback) return obj;
|
|
46
|
+
if (obj.mslxdff) return obj;
|
|
47
|
+
return {
|
|
48
|
+
...obj,
|
|
49
|
+
mslxdff: {
|
|
50
|
+
fallback: true,
|
|
51
|
+
requested_model: info.requested_model,
|
|
52
|
+
actual_model: info.actual_model,
|
|
53
|
+
reason: info.reason,
|
|
54
|
+
via: info.via,
|
|
55
|
+
notice: info.notice,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function enrichSseChunkText(text, info) {
|
|
61
|
+
if (!info?.fallback) return text;
|
|
62
|
+
const lines = text.split("\n");
|
|
63
|
+
let changed = false;
|
|
64
|
+
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
const line = lines[i];
|
|
66
|
+
const m = /^data:\s*(\{.*\})\s*$/.exec(line);
|
|
67
|
+
if (!m) continue;
|
|
68
|
+
try {
|
|
69
|
+
const obj = JSON.parse(m[1]);
|
|
70
|
+
if (obj && typeof obj === "object" && !obj.mslxdff) {
|
|
71
|
+
obj.mslxdff = {
|
|
72
|
+
fallback: true,
|
|
73
|
+
requested_model: info.requested_model,
|
|
74
|
+
actual_model: info.actual_model,
|
|
75
|
+
reason: info.reason,
|
|
76
|
+
via: info.via,
|
|
77
|
+
notice: info.notice,
|
|
78
|
+
};
|
|
79
|
+
lines[i] = `data: ${JSON.stringify(obj)}`;
|
|
80
|
+
changed = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return changed ? lines.join("\n") : text;
|
|
88
|
+
}
|
|
@@ -1,214 +1,214 @@
|
|
|
1
|
-
import { clientIp, json, readBody, parseHops, errMsg } from "./helpers.js";
|
|
2
|
-
import { DEFAULT_MAX_HOPS } from "../peers.js";
|
|
3
|
-
import { enqueueRelay, dequeueRelayForPoll, resolveRelay, subscribeStream, unsubscribeStream } from "./relay-queue.js";
|
|
4
|
-
import { compatFetch } from "../compat.js";
|
|
5
|
-
|
|
6
|
-
export async function heartbeatHandler({ req, res, groups, bus, logs }) {
|
|
7
|
-
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
8
|
-
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
9
|
-
let body;
|
|
10
|
-
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
11
|
-
const groupName = body?.name || body?.group;
|
|
12
|
-
if (!groupName) return json(res, 400, { error: "group name is required" });
|
|
13
|
-
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
14
|
-
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
15
|
-
const ip = clientIp(req);
|
|
16
|
-
try {
|
|
17
|
-
const memberUrl = hit.member?.url;
|
|
18
|
-
const members = groups.list()[groupName]?.members || {};
|
|
19
|
-
const targetId = Object.keys(members).find((k) => members[k].url === memberUrl) || hit.member?.url;
|
|
20
|
-
if (hit.member?.kind === "broadband" || String(memberUrl).startsWith("relay://")) {
|
|
21
|
-
const m = members[targetId] || hit.member;
|
|
22
|
-
if (m) {
|
|
23
|
-
m.publicIp = ip;
|
|
24
|
-
m.lastSeen = Date.now();
|
|
25
|
-
try { groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", publicIp: ip, lastSeen: m.lastSeen }); } catch {}
|
|
26
|
-
}
|
|
27
|
-
const evtData = { ts: Date.now(), type: "relay-heartbeat", member: targetId, ip, lastSeen: m?.lastSeen, group: groupName };
|
|
28
|
-
if (bus) bus.emit(evtData);
|
|
29
|
-
logs?.appendEvent?.(evtData);
|
|
30
|
-
}
|
|
31
|
-
return json(res, 200, { object: "heartbeat", ok: true, ip, lastSeen: Date.now() });
|
|
32
|
-
} catch (err) {
|
|
33
|
-
return json(res, 400, { error: errMsg(err) });
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function pollHandler({ req, res, groups }) {
|
|
38
|
-
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
39
|
-
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
40
|
-
let body;
|
|
41
|
-
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
42
|
-
const groupName = body?.name || body?.group;
|
|
43
|
-
if (!groupName) return json(res, 400, { error: "group name is required" });
|
|
44
|
-
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
45
|
-
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
46
|
-
const targetUrl = hit.member?.url;
|
|
47
|
-
if (!targetUrl) return json(res, 400, { error: "member url not found" });
|
|
48
|
-
const batch = dequeueRelayForPoll({ group: groupName, target: targetUrl, limit: 10 });
|
|
49
|
-
return json(res, 200, { object: "poll", data: batch });
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export async function resultHandler({ req, res, groups }) {
|
|
53
|
-
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
54
|
-
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
55
|
-
let body;
|
|
56
|
-
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
57
|
-
const groupName = body?.name || body?.group;
|
|
58
|
-
const reqId = body?.reqId;
|
|
59
|
-
if (!groupName || !reqId) return json(res, 400, { error: "group and reqId required" });
|
|
60
|
-
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
61
|
-
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
62
|
-
const ok = resolveRelay(reqId, body.result || body);
|
|
63
|
-
if (!ok) return json(res, 404, { error: "pending request not found or timed out" });
|
|
64
|
-
return json(res, 200, { object: "result", ok: true });
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function isStreamEnabled() {
|
|
68
|
-
const v = process.env.MSLXDFF_BROADBAND_STREAM;
|
|
69
|
-
if (v === "0" || v === "false" || v === "off") return false;
|
|
70
|
-
return true;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export async function streamHandler({ req, res, groups, bus, logs }) {
|
|
74
|
-
if (!isStreamEnabled()) return json(res, 404, { error: "broadband stream disabled" });
|
|
75
|
-
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
76
|
-
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
77
|
-
// support GET query ?name= & ?group=
|
|
78
|
-
let groupName = null;
|
|
79
|
-
try {
|
|
80
|
-
const u = new URL(req.url || "", "http://127.0.0.1");
|
|
81
|
-
groupName = u.searchParams.get("name") || u.searchParams.get("group");
|
|
82
|
-
} catch {}
|
|
83
|
-
if (!groupName) {
|
|
84
|
-
// also try body for POST-compat (though GET should use query)
|
|
85
|
-
try {
|
|
86
|
-
const b = await readBody(req);
|
|
87
|
-
groupName = b?.name || b?.group;
|
|
88
|
-
} catch {}
|
|
89
|
-
}
|
|
90
|
-
if (!groupName) return json(res, 400, { error: "group name is required" });
|
|
91
|
-
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
92
|
-
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
93
|
-
const targetUrl = hit.member?.url;
|
|
94
|
-
if (!targetUrl) return json(res, 400, { error: "member url not found" });
|
|
95
|
-
const isBb = hit.member?.kind === "broadband" || String(targetUrl).startsWith("relay://");
|
|
96
|
-
if (!isBb) return json(res, 403, { error: "only broadband members can stream" });
|
|
97
|
-
// SSE headers
|
|
98
|
-
res.setHeader("Content-Type", "text/event-stream");
|
|
99
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
100
|
-
res.setHeader("Connection", "keep-alive");
|
|
101
|
-
res.setHeader("X-Accel-Buffering", "no");
|
|
102
|
-
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
103
|
-
// initial frame
|
|
104
|
-
try { res.write(`:connected ${Date.now()}\n\n`); } catch {}
|
|
105
|
-
const pingMs = Number(process.env.MSLXDFF_BROADBAND_PING_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_PING_MS) : 25_000;
|
|
106
|
-
subscribeStream({ group: groupName, target: targetUrl, res });
|
|
107
|
-
const evtOpen = { ts: Date.now(), type: "relay-stream-open", member: targetUrl, group: groupName };
|
|
108
|
-
try { bus?.emit(evtOpen); } catch {}
|
|
109
|
-
try { logs?.appendEvent?.(evtOpen); } catch {}
|
|
110
|
-
// heartbeat touch
|
|
111
|
-
try {
|
|
112
|
-
const ip = clientIp(req);
|
|
113
|
-
const members = groups.list()[groupName]?.members || {};
|
|
114
|
-
const targetId = Object.keys(members).find((k) => members[k].url === targetUrl) || targetUrl;
|
|
115
|
-
const m = members[targetId] || hit.member;
|
|
116
|
-
if (m) {
|
|
117
|
-
m.publicIp = ip;
|
|
118
|
-
m.lastSeen = Date.now();
|
|
119
|
-
try { groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", publicIp: ip, lastSeen: m.lastSeen }); } catch {}
|
|
120
|
-
}
|
|
121
|
-
} catch {}
|
|
122
|
-
const pingTimer = setInterval(() => {
|
|
123
|
-
try { res.write(`:ping ${Date.now()}\n\n`); } catch {}
|
|
124
|
-
// keep lastSeen fresh so forward doesn't see stale while stream is alive
|
|
125
|
-
try {
|
|
126
|
-
const members = groups.list()[groupName]?.members || {};
|
|
127
|
-
const targetId = Object.keys(members).find((k) => members[k].url === targetUrl) || targetUrl;
|
|
128
|
-
const m = members[targetId] || hit.member;
|
|
129
|
-
if (m) {
|
|
130
|
-
m.lastSeen = Date.now();
|
|
131
|
-
try { groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", lastSeen: m.lastSeen }); } catch {}
|
|
132
|
-
}
|
|
133
|
-
} catch {}
|
|
134
|
-
const evt = { ts: Date.now(), type: "relay-stream-ping", member: targetUrl, group: groupName };
|
|
135
|
-
try { bus?.emit(evt); } catch {}
|
|
136
|
-
}, pingMs);
|
|
137
|
-
pingTimer.unref?.();
|
|
138
|
-
const cleanup = () => {
|
|
139
|
-
clearInterval(pingTimer);
|
|
140
|
-
unsubscribeStream({ group: groupName, target: targetUrl, res });
|
|
141
|
-
const evt = { ts: Date.now(), type: "relay-stream-close", member: targetUrl, group: groupName };
|
|
142
|
-
try { bus?.emit(evt); } catch {}
|
|
143
|
-
try { logs?.appendEvent?.(evt); } catch {}
|
|
144
|
-
};
|
|
145
|
-
req.on("close", cleanup);
|
|
146
|
-
req.on("error", cleanup);
|
|
147
|
-
// keep promise pending until close — do not end res here
|
|
148
|
-
await new Promise(() => {});
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export async function forwardHandler({ req, res, groups, bus, logs }) {
|
|
152
|
-
let body;
|
|
153
|
-
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
154
|
-
const groupName = body?.group || body?.name;
|
|
155
|
-
const target = body?.target || body?.url;
|
|
156
|
-
const hops = parseHops(req.headers["x-mslxdff-hops"] || body?.hops);
|
|
157
|
-
if (!groupName || !target) return json(res, 400, { error: "group and target required" });
|
|
158
|
-
if (hops >= DEFAULT_MAX_HOPS) return json(res, 429, { error: "max hops exceeded" });
|
|
159
|
-
const members = groups?.list()[groupName]?.members || {};
|
|
160
|
-
const targetMember = Object.values(members).find((m) => m.url === target) || Object.entries(members).find(([id]) => id === target)?.[1];
|
|
161
|
-
if (!targetMember) return json(res, 404, { error: `target ${target} not found in group ${groupName}` });
|
|
162
|
-
const isBb = targetMember.kind === "broadband" || String(targetMember.url).startsWith("relay://");
|
|
163
|
-
if (!isBb) {
|
|
164
|
-
try {
|
|
165
|
-
const fwdBody = body.body || body;
|
|
166
|
-
const r = await compatFetch(`${targetMember.url}/v1/chat/completions`, {
|
|
167
|
-
method: "POST",
|
|
168
|
-
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${targetMember.token || ""}`, "x-mslxdff-hops": String(hops + 1), "x-mslxdff-model-lock": fwdBody.model || "", "Accept": "text/event-stream" },
|
|
169
|
-
body: JSON.stringify(fwdBody),
|
|
170
|
-
});
|
|
171
|
-
const evtData = { ts: Date.now(), type: "relay-forward", target, via: "direct", group: groupName, hops };
|
|
172
|
-
if (bus) bus.emit(evtData);
|
|
173
|
-
logs?.appendEvent?.(evtData);
|
|
174
|
-
res.statusCode = r.status;
|
|
175
|
-
if (r.headers.get("content-type")?.includes("text/event-stream")) {
|
|
176
|
-
res.setHeader("Content-Type", "text/event-stream");
|
|
177
|
-
if (r.body) for await (const c of r.body) res.write(c);
|
|
178
|
-
res.end();
|
|
179
|
-
} else {
|
|
180
|
-
const txt = await r.text();
|
|
181
|
-
res.setHeader("Content-Type", r.headers.get("content-type") || "application/json");
|
|
182
|
-
res.end(txt);
|
|
183
|
-
}
|
|
184
|
-
return;
|
|
185
|
-
} catch (err) {
|
|
186
|
-
return json(res, 502, { error: errMsg(err) });
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
const reqId = body.reqId || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
190
|
-
const fwdBody = body.body || { model: body.model, messages: body.messages, stream: body.stream };
|
|
191
|
-
const evtData = { ts: Date.now(), type: "relay-forward", target, via: "leader", group: groupName, hops, reqId, model: fwdBody.model };
|
|
192
|
-
if (bus) bus.emit(evtData);
|
|
193
|
-
logs?.appendEvent?.(evtData);
|
|
194
|
-
const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
|
|
195
|
-
if (typeof targetMember.lastSeen === "number" && Date.now() - targetMember.lastSeen > staleMs) {
|
|
196
|
-
return json(res, 502, { error: "broadband member stale (no heartbeat)" });
|
|
197
|
-
}
|
|
198
|
-
try {
|
|
199
|
-
const resultPromise = enqueueRelay({ group: groupName, target: targetMember.url, reqId, body: fwdBody, hops });
|
|
200
|
-
const result = await resultPromise;
|
|
201
|
-
if (result && typeof result.status === "number") {
|
|
202
|
-
res.statusCode = result.status;
|
|
203
|
-
if (result.headers) for (const [k, v] of Object.entries(result.headers)) res.setHeader(k, v);
|
|
204
|
-
if (result.body) {
|
|
205
|
-
if (typeof result.body === "string") res.end(result.body);
|
|
206
|
-
else res.end(JSON.stringify(result.body));
|
|
207
|
-
} else res.end();
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
return json(res, 200, result);
|
|
211
|
-
} catch (err) {
|
|
212
|
-
return json(res, 504, { error: errMsg(err) || "relay timeout" });
|
|
213
|
-
}
|
|
214
|
-
}
|
|
1
|
+
import { clientIp, json, readBody, parseHops, errMsg } from "./helpers.js";
|
|
2
|
+
import { DEFAULT_MAX_HOPS } from "../peers.js";
|
|
3
|
+
import { enqueueRelay, dequeueRelayForPoll, resolveRelay, subscribeStream, unsubscribeStream } from "./relay-queue.js";
|
|
4
|
+
import { compatFetch } from "../compat.js";
|
|
5
|
+
|
|
6
|
+
export async function heartbeatHandler({ req, res, groups, bus, logs }) {
|
|
7
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
8
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
9
|
+
let body;
|
|
10
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
11
|
+
const groupName = body?.name || body?.group;
|
|
12
|
+
if (!groupName) return json(res, 400, { error: "group name is required" });
|
|
13
|
+
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
14
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
15
|
+
const ip = clientIp(req);
|
|
16
|
+
try {
|
|
17
|
+
const memberUrl = hit.member?.url;
|
|
18
|
+
const members = groups.list()[groupName]?.members || {};
|
|
19
|
+
const targetId = Object.keys(members).find((k) => members[k].url === memberUrl) || hit.member?.url;
|
|
20
|
+
if (hit.member?.kind === "broadband" || String(memberUrl).startsWith("relay://")) {
|
|
21
|
+
const m = members[targetId] || hit.member;
|
|
22
|
+
if (m) {
|
|
23
|
+
m.publicIp = ip;
|
|
24
|
+
m.lastSeen = Date.now();
|
|
25
|
+
try { groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", publicIp: ip, lastSeen: m.lastSeen }); } catch {}
|
|
26
|
+
}
|
|
27
|
+
const evtData = { ts: Date.now(), type: "relay-heartbeat", member: targetId, ip, lastSeen: m?.lastSeen, group: groupName };
|
|
28
|
+
if (bus) bus.emit(evtData);
|
|
29
|
+
logs?.appendEvent?.(evtData);
|
|
30
|
+
}
|
|
31
|
+
return json(res, 200, { object: "heartbeat", ok: true, ip, lastSeen: Date.now() });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return json(res, 400, { error: errMsg(err) });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function pollHandler({ req, res, groups }) {
|
|
38
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
39
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
40
|
+
let body;
|
|
41
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
42
|
+
const groupName = body?.name || body?.group;
|
|
43
|
+
if (!groupName) return json(res, 400, { error: "group name is required" });
|
|
44
|
+
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
45
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
46
|
+
const targetUrl = hit.member?.url;
|
|
47
|
+
if (!targetUrl) return json(res, 400, { error: "member url not found" });
|
|
48
|
+
const batch = dequeueRelayForPoll({ group: groupName, target: targetUrl, limit: 10 });
|
|
49
|
+
return json(res, 200, { object: "poll", data: batch });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function resultHandler({ req, res, groups }) {
|
|
53
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
54
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
55
|
+
let body;
|
|
56
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
57
|
+
const groupName = body?.name || body?.group;
|
|
58
|
+
const reqId = body?.reqId;
|
|
59
|
+
if (!groupName || !reqId) return json(res, 400, { error: "group and reqId required" });
|
|
60
|
+
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
61
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
62
|
+
const ok = resolveRelay(reqId, body.result || body);
|
|
63
|
+
if (!ok) return json(res, 404, { error: "pending request not found or timed out" });
|
|
64
|
+
return json(res, 200, { object: "result", ok: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isStreamEnabled() {
|
|
68
|
+
const v = process.env.MSLXDFF_BROADBAND_STREAM;
|
|
69
|
+
if (v === "0" || v === "false" || v === "off") return false;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function streamHandler({ req, res, groups, bus, logs }) {
|
|
74
|
+
if (!isStreamEnabled()) return json(res, 404, { error: "broadband stream disabled" });
|
|
75
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
76
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
77
|
+
// support GET query ?name= & ?group=
|
|
78
|
+
let groupName = null;
|
|
79
|
+
try {
|
|
80
|
+
const u = new URL(req.url || "", "http://127.0.0.1");
|
|
81
|
+
groupName = u.searchParams.get("name") || u.searchParams.get("group");
|
|
82
|
+
} catch {}
|
|
83
|
+
if (!groupName) {
|
|
84
|
+
// also try body for POST-compat (though GET should use query)
|
|
85
|
+
try {
|
|
86
|
+
const b = await readBody(req);
|
|
87
|
+
groupName = b?.name || b?.group;
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
if (!groupName) return json(res, 400, { error: "group name is required" });
|
|
91
|
+
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
92
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
93
|
+
const targetUrl = hit.member?.url;
|
|
94
|
+
if (!targetUrl) return json(res, 400, { error: "member url not found" });
|
|
95
|
+
const isBb = hit.member?.kind === "broadband" || String(targetUrl).startsWith("relay://");
|
|
96
|
+
if (!isBb) return json(res, 403, { error: "only broadband members can stream" });
|
|
97
|
+
// SSE headers
|
|
98
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
99
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
100
|
+
res.setHeader("Connection", "keep-alive");
|
|
101
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
102
|
+
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
103
|
+
// initial frame
|
|
104
|
+
try { res.write(`:connected ${Date.now()}\n\n`); } catch {}
|
|
105
|
+
const pingMs = Number(process.env.MSLXDFF_BROADBAND_PING_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_PING_MS) : 25_000;
|
|
106
|
+
subscribeStream({ group: groupName, target: targetUrl, res });
|
|
107
|
+
const evtOpen = { ts: Date.now(), type: "relay-stream-open", member: targetUrl, group: groupName };
|
|
108
|
+
try { bus?.emit(evtOpen); } catch {}
|
|
109
|
+
try { logs?.appendEvent?.(evtOpen); } catch {}
|
|
110
|
+
// heartbeat touch
|
|
111
|
+
try {
|
|
112
|
+
const ip = clientIp(req);
|
|
113
|
+
const members = groups.list()[groupName]?.members || {};
|
|
114
|
+
const targetId = Object.keys(members).find((k) => members[k].url === targetUrl) || targetUrl;
|
|
115
|
+
const m = members[targetId] || hit.member;
|
|
116
|
+
if (m) {
|
|
117
|
+
m.publicIp = ip;
|
|
118
|
+
m.lastSeen = Date.now();
|
|
119
|
+
try { groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", publicIp: ip, lastSeen: m.lastSeen }); } catch {}
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
const pingTimer = setInterval(() => {
|
|
123
|
+
try { res.write(`:ping ${Date.now()}\n\n`); } catch {}
|
|
124
|
+
// keep lastSeen fresh so forward doesn't see stale while stream is alive
|
|
125
|
+
try {
|
|
126
|
+
const members = groups.list()[groupName]?.members || {};
|
|
127
|
+
const targetId = Object.keys(members).find((k) => members[k].url === targetUrl) || targetUrl;
|
|
128
|
+
const m = members[targetId] || hit.member;
|
|
129
|
+
if (m) {
|
|
130
|
+
m.lastSeen = Date.now();
|
|
131
|
+
try { groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", lastSeen: m.lastSeen }); } catch {}
|
|
132
|
+
}
|
|
133
|
+
} catch {}
|
|
134
|
+
const evt = { ts: Date.now(), type: "relay-stream-ping", member: targetUrl, group: groupName };
|
|
135
|
+
try { bus?.emit(evt); } catch {}
|
|
136
|
+
}, pingMs);
|
|
137
|
+
pingTimer.unref?.();
|
|
138
|
+
const cleanup = () => {
|
|
139
|
+
clearInterval(pingTimer);
|
|
140
|
+
unsubscribeStream({ group: groupName, target: targetUrl, res });
|
|
141
|
+
const evt = { ts: Date.now(), type: "relay-stream-close", member: targetUrl, group: groupName };
|
|
142
|
+
try { bus?.emit(evt); } catch {}
|
|
143
|
+
try { logs?.appendEvent?.(evt); } catch {}
|
|
144
|
+
};
|
|
145
|
+
req.on("close", cleanup);
|
|
146
|
+
req.on("error", cleanup);
|
|
147
|
+
// keep promise pending until close — do not end res here
|
|
148
|
+
await new Promise(() => {});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function forwardHandler({ req, res, groups, bus, logs }) {
|
|
152
|
+
let body;
|
|
153
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
154
|
+
const groupName = body?.group || body?.name;
|
|
155
|
+
const target = body?.target || body?.url;
|
|
156
|
+
const hops = parseHops(req.headers["x-mslxdff-hops"] || body?.hops);
|
|
157
|
+
if (!groupName || !target) return json(res, 400, { error: "group and target required" });
|
|
158
|
+
if (hops >= DEFAULT_MAX_HOPS) return json(res, 429, { error: "max hops exceeded" });
|
|
159
|
+
const members = groups?.list()[groupName]?.members || {};
|
|
160
|
+
const targetMember = Object.values(members).find((m) => m.url === target) || Object.entries(members).find(([id]) => id === target)?.[1];
|
|
161
|
+
if (!targetMember) return json(res, 404, { error: `target ${target} not found in group ${groupName}` });
|
|
162
|
+
const isBb = targetMember.kind === "broadband" || String(targetMember.url).startsWith("relay://");
|
|
163
|
+
if (!isBb) {
|
|
164
|
+
try {
|
|
165
|
+
const fwdBody = body.body || body;
|
|
166
|
+
const r = await compatFetch(`${targetMember.url}/v1/chat/completions`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${targetMember.token || ""}`, "x-mslxdff-hops": String(hops + 1), "x-mslxdff-model-lock": fwdBody.model || "", "Accept": "text/event-stream" },
|
|
169
|
+
body: JSON.stringify(fwdBody),
|
|
170
|
+
});
|
|
171
|
+
const evtData = { ts: Date.now(), type: "relay-forward", target, via: "direct", group: groupName, hops };
|
|
172
|
+
if (bus) bus.emit(evtData);
|
|
173
|
+
logs?.appendEvent?.(evtData);
|
|
174
|
+
res.statusCode = r.status;
|
|
175
|
+
if (r.headers.get("content-type")?.includes("text/event-stream")) {
|
|
176
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
177
|
+
if (r.body) for await (const c of r.body) res.write(c);
|
|
178
|
+
res.end();
|
|
179
|
+
} else {
|
|
180
|
+
const txt = await r.text();
|
|
181
|
+
res.setHeader("Content-Type", r.headers.get("content-type") || "application/json");
|
|
182
|
+
res.end(txt);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return json(res, 502, { error: errMsg(err) });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const reqId = body.reqId || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
190
|
+
const fwdBody = body.body || { model: body.model, messages: body.messages, stream: body.stream };
|
|
191
|
+
const evtData = { ts: Date.now(), type: "relay-forward", target, via: "leader", group: groupName, hops, reqId, model: fwdBody.model };
|
|
192
|
+
if (bus) bus.emit(evtData);
|
|
193
|
+
logs?.appendEvent?.(evtData);
|
|
194
|
+
const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
|
|
195
|
+
if (typeof targetMember.lastSeen === "number" && Date.now() - targetMember.lastSeen > staleMs) {
|
|
196
|
+
return json(res, 502, { error: "broadband member stale (no heartbeat)" });
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const resultPromise = enqueueRelay({ group: groupName, target: targetMember.url, reqId, body: fwdBody, hops });
|
|
200
|
+
const result = await resultPromise;
|
|
201
|
+
if (result && typeof result.status === "number") {
|
|
202
|
+
res.statusCode = result.status;
|
|
203
|
+
if (result.headers) for (const [k, v] of Object.entries(result.headers)) res.setHeader(k, v);
|
|
204
|
+
if (result.body) {
|
|
205
|
+
if (typeof result.body === "string") res.end(result.body);
|
|
206
|
+
else res.end(JSON.stringify(result.body));
|
|
207
|
+
} else res.end();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
return json(res, 200, result);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
return json(res, 504, { error: errMsg(err) || "relay timeout" });
|
|
213
|
+
}
|
|
214
|
+
}
|