mslxdff 0.1.65 → 0.1.66
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/bin/mslxdff.js +3 -3159
- package/package.json +1 -1
- package/src/cli/bootstrap.js +408 -0
- package/src/cli/commands/daemon.js +151 -0
- package/src/cli/commands/group.js +272 -0
- package/src/cli/commands/model.js +405 -0
- package/src/cli/commands/provider/add.js +106 -0
- package/src/cli/commands/provider/allowlist.js +99 -0
- package/src/cli/commands/provider/config.js +82 -0
- package/src/cli/commands/provider/index.js +193 -0
- package/src/cli/commands/provider/keys.js +135 -0
- package/src/cli/commands/provider/models.js +99 -0
- package/src/cli/commands/provider.js +1 -0
- package/src/cli/commands/sync.js +143 -0
- package/src/cli/commands/system.js +236 -0
- package/src/cli/commands/workbuddy.js +91 -0
- package/src/cli/format.js +119 -0
- package/src/cli/group-helpers.js +69 -0
- package/src/cli/help.js +66 -0
- package/src/cli/index.js +59 -0
- package/src/cli/interactive.js +84 -0
- package/src/cli/policy.js +119 -0
- package/src/cli/status.js +332 -0
- package/src/cli/util.js +24 -0
- package/src/providers/base.js +159 -0
- package/src/providers/generic.js +26 -166
- package/src/providers/openrouter.js +32 -162
- package/src/providers/workbuddy.js +7 -32
- package/src/routes/chat/gateway.js +301 -0
- package/src/routes/chat/index.js +1 -384
- package/src/state/facade.js +57 -0
- package/src/state/memory.js +180 -0
- package/src/state/persist.js +42 -0
- package/src/state/schemas/allowlist.js +87 -0
- package/src/state/schemas/group.js +31 -0
- package/src/state/schemas/model.js +53 -0
- package/src/state/schemas/peer.js +31 -0
- package/src/state/schemas/port.js +10 -0
- package/src/state/schemas/provider.js +254 -0
- package/src/state/schemas/token.js +43 -0
- package/src/state/store.js +202 -0
- package/src/state.js +1 -711
package/src/routes/chat/index.js
CHANGED
|
@@ -1,384 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { injectReasoningContent, normalizeModel } from "../../reasoning.js";
|
|
3
|
-
import { isAutoModel } from "../../auto.js";
|
|
4
|
-
import { toInternalId as aliasToInternal } from "../../sync-opencode.js";
|
|
5
|
-
import { clientIp, json, readBody, parseHops, summarizePrompt, errMsg } from "../helpers.js";
|
|
6
|
-
import { hedgeDelayMs, shouldHedge } from "../hedge.js";
|
|
7
|
-
import { runHook } from "../../plugins.js";
|
|
8
|
-
import { parseShareKeysHeader, SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
|
|
9
|
-
import { handleHedge } from "./hedge-handler.js";
|
|
10
|
-
import { handleLocalRelay } from "./local-handler.js";
|
|
11
|
-
import { handlePeerRelay } from "./peer-handler.js";
|
|
12
|
-
import { handleBroadbandRelay } from "./broadband-handler.js";
|
|
13
|
-
import { handleExhaustedLocal, handleExhaustedAll } from "./exhausted-handler.js";
|
|
14
|
-
import { normalizeFullId, getModelAlias } from "../../providers/model-id.js";
|
|
15
|
-
|
|
16
|
-
export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHops, groups, bus, token, plugins }) {
|
|
17
|
-
let body;
|
|
18
|
-
try {
|
|
19
|
-
body = await readBody(req);
|
|
20
|
-
} catch {
|
|
21
|
-
return json(res, 400, { error: "Invalid JSON body" });
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (plugins?.length) {
|
|
25
|
-
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 });
|
|
26
|
-
for (const e of rc.errors) logs?.appendEvent?.({ ts: Date.now(), type: "plugin-hook-error", hook: "request:received", plugin: e.plugin, error: e.error });
|
|
27
|
-
const respond = rc.value?.respond;
|
|
28
|
-
if (respond && typeof respond === "object") {
|
|
29
|
-
return json(res, respond.status || 200, respond.body ?? {});
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const startedAt = Date.now();
|
|
34
|
-
const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
35
|
-
const perf0 = performance.now();
|
|
36
|
-
const stages = [];
|
|
37
|
-
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
38
|
-
const hops = parseHops(req.headers["x-mslxdff-hops"]);
|
|
39
|
-
// ADR-0008:瞬时共享 key(组员侧接收)。本请求内有效,用完即弃。
|
|
40
|
-
const shareKeys = parseShareKeysHeader(req.headers[SHARE_KEYS_HEADER] || "");
|
|
41
|
-
const workbuddyUid = (req.headers["x-mslxdff-workbuddy-uid"] || req.headers["x-workbuddy-uid"] || "").toString().trim();
|
|
42
|
-
const lockModel = req.headers["x-mslxdff-model-lock"] || "";
|
|
43
|
-
const rawModel = body.model || "";
|
|
44
|
-
let normalizedRequested = normalizeModel(lockModel || rawModel || "");
|
|
45
|
-
// WorkBuddy 别名还原:clinebot-z-ai-glm-5.3-flash → clinebot/z-ai/glm-5.3-flash
|
|
46
|
-
const aliasResolved = getModelAlias(normalizedRequested);
|
|
47
|
-
if (aliasResolved) {
|
|
48
|
-
normalizedRequested = aliasResolved;
|
|
49
|
-
body = { ...body, model: aliasResolved };
|
|
50
|
-
}
|
|
51
|
-
// alias 还原:mslxdff-deepseek -> deepseek(原名仍兼容,双向支持 mslxdff/mslxdff-deepseek 与裸 mslxdff-deepseek/裸 deepseek)
|
|
52
|
-
let requested = normalizedRequested;
|
|
53
|
-
let aliasInfo = null;
|
|
54
|
-
if (requested.startsWith("mslxdff-")) {
|
|
55
|
-
const internal = aliasToInternal(requested);
|
|
56
|
-
if (internal) {
|
|
57
|
-
aliasInfo = `${requested} -> ${internal}`;
|
|
58
|
-
requested = internal;
|
|
59
|
-
}
|
|
60
|
-
} else if (requested.includes("/")) {
|
|
61
|
-
const slashIdx = requested.indexOf("/");
|
|
62
|
-
const rawPart = requested.slice(slashIdx + 1);
|
|
63
|
-
const providerPart = requested.slice(0, slashIdx);
|
|
64
|
-
if (rawPart.startsWith("mslxdff-")) {
|
|
65
|
-
const internal = aliasToInternal(rawPart);
|
|
66
|
-
if (internal) {
|
|
67
|
-
aliasInfo = `${requested} -> ${providerPart}/${internal} (alias stripped)`;
|
|
68
|
-
requested = `${providerPart}/${internal}`;
|
|
69
|
-
if (providerPart === "mslxdff") {
|
|
70
|
-
requested = internal;
|
|
71
|
-
aliasInfo = `${rawModel} -> ${internal} (mslxdff alias stripped)`;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
} else if (providerPart === "mslxdff") {
|
|
75
|
-
// mslxdff/deepseek 原名直用 -> deepseek(原名兼容,provider 前缀剥离)
|
|
76
|
-
aliasInfo = `${requested} -> ${rawPart} (mslxdff provider stripped, 原名兼容)`;
|
|
77
|
-
requested = rawPart;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
const useAuto = isAutoModel(requested);
|
|
81
|
-
mark("parsed");
|
|
82
|
-
if (aliasInfo) {
|
|
83
|
-
// 供日志与 header 透传
|
|
84
|
-
try { res.setHeader("x-mslxdff-alias", aliasInfo); } catch {}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
let order;
|
|
88
|
-
if (lockModel) {
|
|
89
|
-
order = [requested];
|
|
90
|
-
} else if (useAuto) {
|
|
91
|
-
order = auto ? await auto.candidates() : [""];
|
|
92
|
-
} else {
|
|
93
|
-
order = auto ? await auto.candidatesFor(requested) : [requested];
|
|
94
|
-
}
|
|
95
|
-
if (!order.length) order = [""];
|
|
96
|
-
const canFallback = order.length > 1;
|
|
97
|
-
const canForwardPeers = Boolean(peers) && hops < maxHops;
|
|
98
|
-
mark("ordered");
|
|
99
|
-
|
|
100
|
-
const logCall = (model, status) =>
|
|
101
|
-
logs?.appendCall({ reqId, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(body.stream), stages });
|
|
102
|
-
const logError = (model, status, message) =>
|
|
103
|
-
logs?.appendError({ reqId, model, auto: useAuto, status, message, stages });
|
|
104
|
-
const evt = (type, data) => {
|
|
105
|
-
const entry = { ts: Date.now(), reqId, type, ...data, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
106
|
-
if (bus) bus.emit(entry);
|
|
107
|
-
logs?.appendEvent?.(entry);
|
|
108
|
-
};
|
|
109
|
-
const done = (info) => {
|
|
110
|
-
if (!plugins?.length) return;
|
|
111
|
-
runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
|
|
112
|
-
};
|
|
113
|
-
evt("request", { reqId, hops, ip: clientIp(req), stream: Boolean(body.stream), prompt: summarizePrompt(body), rawModel, requested, lockModel: lockModel || null });
|
|
114
|
-
if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel, requested });
|
|
115
|
-
if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
|
|
116
|
-
evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
|
|
117
|
-
|
|
118
|
-
if (plugins?.length && !lockModel) {
|
|
119
|
-
const sel = await runHook(plugins, "model:select", { reqId, requested, useAuto, order: [...order], hops, stream: Boolean(body.stream) });
|
|
120
|
-
if (sel.changed && Array.isArray(sel.value) && sel.value.length) {
|
|
121
|
-
order = sel.value.filter(Boolean);
|
|
122
|
-
if (!order.length) order = [requested];
|
|
123
|
-
evt("plugin-hook", { reqId, hook: "model:select", applied: true, order: [...order] });
|
|
124
|
-
}
|
|
125
|
-
for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const handlerCtx = { reqId, model: null, body, hops, peers, plugins, evt, logError, logCall, logs };
|
|
129
|
-
|
|
130
|
-
// 首次 auto 并发测速:勾选的供应商并发比谁快,谁快下次优先(跳过明知故障的模型)
|
|
131
|
-
// 胜者会同时写入 preferredModel + auto 成功,下次 plugin 会将其排首位
|
|
132
|
-
if (useAuto && order.length > 1 && auto && !lockModel) {
|
|
133
|
-
const statuses = auto.statuses?.() ?? {};
|
|
134
|
-
const hasPriorSuccess = Object.values(statuses).some((e) => e && typeof e === "object" && e.status === "normal");
|
|
135
|
-
const nonCoolingOrder = order.filter((m) => {
|
|
136
|
-
try { return !auto.isCooling(m); } catch { return true; }
|
|
137
|
-
});
|
|
138
|
-
// 首次(无 normal)且至少 2 个非冷却候选 → 并发赛跑
|
|
139
|
-
if (!hasPriorSuccess && nonCoolingOrder.length > 1) {
|
|
140
|
-
const concLimit = (() => {
|
|
141
|
-
const v = Number(process.env.MSLXDFF_AUTO_CONCURRENT);
|
|
142
|
-
if (Number.isInteger(v) && v > 0) return Math.min(v, nonCoolingOrder.length);
|
|
143
|
-
return Math.min(nonCoolingOrder.length, 5);
|
|
144
|
-
})();
|
|
145
|
-
const raceModels = nonCoolingOrder.slice(0, concLimit);
|
|
146
|
-
evt("auto-concurrent-race", { reqId, models: raceModels, skippedFaulty: order.length - nonCoolingOrder.length, limit: concLimit });
|
|
147
|
-
// 并发发起 upstream.chat,取首个 200 成功者
|
|
148
|
-
const raceStart = performance.now();
|
|
149
|
-
const attempts = raceModels.map(async (m) => {
|
|
150
|
-
const fwd = { ...injectReasoningContent(m, body), model: m };
|
|
151
|
-
let r = null;
|
|
152
|
-
try {
|
|
153
|
-
const chatOpts = {};
|
|
154
|
-
if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
|
|
155
|
-
if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
|
|
156
|
-
r = await upstream.chat(fwd, Object.keys(chatOpts).length ? chatOpts : undefined);
|
|
157
|
-
} catch (err) {
|
|
158
|
-
return { model: m, ok: false, error: errMsg(err), status: 502, timing: err?._t ?? null };
|
|
159
|
-
}
|
|
160
|
-
if (r && r.status >= 400) {
|
|
161
|
-
const isAllow = r.status === 403 && r.headers?.get?.("x-mslxdff-allowlist") === "1";
|
|
162
|
-
if (isAllow) return { model: m, ok: false, error: "allowlist", status: 403, allowlist: true };
|
|
163
|
-
return { model: m, ok: false, error: `upstream ${r.status}`, status: r.status, res: r, timing: r._t ?? null };
|
|
164
|
-
}
|
|
165
|
-
if (r instanceof Error) return { model: m, ok: false, error: errMsg(r), status: 502 };
|
|
166
|
-
return { model: m, ok: true, res: r, status: r.status, timing: r._t ?? null };
|
|
167
|
-
});
|
|
168
|
-
// 首个成功优先:轮询 settled,首个 ok 即胜;若全 fail 则走原串行兜底
|
|
169
|
-
let winner = null;
|
|
170
|
-
let winnerIdx = -1;
|
|
171
|
-
const pending = new Set(attempts.map((p, i) => ({ p, i })));
|
|
172
|
-
// 用 allSettled + 最快成功挑选(最小 timing 或最先 settled 的 ok)
|
|
173
|
-
const results = await Promise.allSettled(attempts);
|
|
174
|
-
// 按实际成功且 timing 最小排序(首包/总耗时最小者胜)
|
|
175
|
-
const okList = results.map((r, i) => ({ r, i, model: raceModels[i] }))
|
|
176
|
-
.filter(({ r }) => r.status === "fulfilled" && r.value?.ok)
|
|
177
|
-
.map(({ r, i, model }) => ({ model, idx: i, val: r.value, t: r.value.timing?.totalMs ?? r.value.timing?.ms ?? Number.MAX_SAFE_INTEGER }));
|
|
178
|
-
if (okList.length) {
|
|
179
|
-
okList.sort((a, b) => a.t - b.t);
|
|
180
|
-
const best = okList[0];
|
|
181
|
-
winner = best.val.res;
|
|
182
|
-
winnerIdx = best.idx;
|
|
183
|
-
const winModel = best.model;
|
|
184
|
-
evt("auto-concurrent-win", { reqId, model: winModel, timing: best.val.timing, totalMs: Math.round(performance.now() - raceStart), tried: raceModels.length });
|
|
185
|
-
// 记录优胜者为 normal + 设为 preferred(下次 plugin 排首位),其余失败者计 error 但不影响优先
|
|
186
|
-
for (const { r, i } of results.map((r, i) => ({ r, i }))) {
|
|
187
|
-
const m = raceModels[i];
|
|
188
|
-
if (r.status === "fulfilled" && r.value?.ok) {
|
|
189
|
-
if (m === winModel) {
|
|
190
|
-
const latencyMs = r.value.timing?.totalMs ?? Math.round(performance.now() - raceStart);
|
|
191
|
-
await auto.recordOk(m, { latencyMs });
|
|
192
|
-
try {
|
|
193
|
-
const { savePreferredModel } = await import("../../state.js");
|
|
194
|
-
savePreferredModel(m);
|
|
195
|
-
evt("auto-concurrent-preferred", { reqId, model: m });
|
|
196
|
-
} catch {}
|
|
197
|
-
}
|
|
198
|
-
} else if (r.status === "fulfilled" && !r.value?.ok && !r.value?.allowlist) {
|
|
199
|
-
// 非 allowlist 的失败才计 error(跳过的 allowlist 不计)
|
|
200
|
-
await auto.recordError(m, { status: r.value.status || 502 });
|
|
201
|
-
} else if (r.status === "rejected") {
|
|
202
|
-
await auto.recordError(m, { status: 502 });
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
// 直接中继优胜者
|
|
206
|
-
handlerCtx.model = winModel;
|
|
207
|
-
const isStream = Boolean(body.stream);
|
|
208
|
-
// 复用本地中继逻辑(不走 hedge,直接 relay)
|
|
209
|
-
const { handleLocalRelay: _relay } = await import("./local-handler.js");
|
|
210
|
-
const lr = await _relay({
|
|
211
|
-
upRes: winner,
|
|
212
|
-
model: winModel,
|
|
213
|
-
body,
|
|
214
|
-
order: raceModels,
|
|
215
|
-
idx: winnerIdx,
|
|
216
|
-
lastErr: null,
|
|
217
|
-
requested,
|
|
218
|
-
useAuto,
|
|
219
|
-
lockModel,
|
|
220
|
-
auto,
|
|
221
|
-
handlerCtx,
|
|
222
|
-
evt,
|
|
223
|
-
logCall,
|
|
224
|
-
logError,
|
|
225
|
-
mark,
|
|
226
|
-
perf0,
|
|
227
|
-
stages,
|
|
228
|
-
startedAt,
|
|
229
|
-
plugins,
|
|
230
|
-
res,
|
|
231
|
-
});
|
|
232
|
-
if (lr.handled) return;
|
|
233
|
-
// 若中继未 handled(如 interrupted),按原逻辑继续
|
|
234
|
-
if (lr.lastErr) {
|
|
235
|
-
// 优胜者中继失败,降级为串行兜底(剩余 order 中未测的继续)
|
|
236
|
-
} else {
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
} else {
|
|
240
|
-
evt("auto-concurrent-all-fail", { reqId, tried: raceModels.length, totalMs: Math.round(performance.now() - raceStart) });
|
|
241
|
-
// 全失败:记录失败并继续走原串行(会按 order 逐个重试,含未并发的尾部)
|
|
242
|
-
for (const { r, i } of results.map((r, i) => ({ r, i }))) {
|
|
243
|
-
const m = raceModels[i];
|
|
244
|
-
if (r.status === "fulfilled" && !r.value?.ok && !r.value?.allowlist) await auto.recordError(m, { status: r.value.status || 502 });
|
|
245
|
-
else if (r.status === "rejected") await auto.recordError(m, { status: 502 });
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
// 并发未决出胜者或中继失败,回落到原串行循环(会跳过已试的 raceModels,继续 trial 剩余 order)
|
|
249
|
-
// 为避免重复试已失败的 raceModels,过滤 order
|
|
250
|
-
const triedSet = new Set(raceModels);
|
|
251
|
-
order = order.filter((m) => !triedSet.has(m));
|
|
252
|
-
if (!order.length) {
|
|
253
|
-
// 首次并发已试全部且全失败 → 按“所有勾选都不通”直接失败
|
|
254
|
-
const last = { model: raceModels[0] || requested, status: 502, message: "all concurrent candidates failed" };
|
|
255
|
-
await handleExhaustedAll({ res, body, lastErr: last, order: raceModels, requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
// 继续走下方串行 for 循环(新 order)
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
let lastErr = null;
|
|
263
|
-
for (let idx = 0; idx < order.length; idx++) {
|
|
264
|
-
const model = order[idx];
|
|
265
|
-
handlerCtx.model = model;
|
|
266
|
-
evt("model-try", { reqId, model, idx, remaining: order.length - idx });
|
|
267
|
-
if (plugins?.length) {
|
|
268
|
-
const bt = await runHook(plugins, "model:beforeTry", { reqId, requested, model, idx, hops });
|
|
269
|
-
for (const e of bt.errors) evt("plugin-hook-error", { reqId, hook: "model:beforeTry", plugin: e.plugin, error: e.error });
|
|
270
|
-
if (bt.value === false || bt.value?.skip === true) {
|
|
271
|
-
evt("plugin-hook", { reqId, hook: "model:beforeTry", applied: true, skipped: model });
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
let upRes = null;
|
|
276
|
-
let forwarded = { ...injectReasoningContent(model, body), model };
|
|
277
|
-
if (plugins?.length) {
|
|
278
|
-
const ur = await runHook(plugins, "upstream:request", { reqId, requested, model, payload: forwarded, stream: Boolean(body.stream) });
|
|
279
|
-
for (const e of ur.errors) evt("plugin-hook-error", { reqId, hook: "upstream:request", plugin: e.plugin, error: e.error });
|
|
280
|
-
if (ur.changed && ur.value?.payload && typeof ur.value.payload === "object") {
|
|
281
|
-
forwarded = ur.value.payload;
|
|
282
|
-
evt("plugin-hook", { reqId, hook: "upstream:request", applied: true, model, rewrittenModel: forwarded.model ?? null });
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
const tUp = performance.now();
|
|
286
|
-
evt("upstream-try", { reqId, model, attempt: idx + 1 });
|
|
287
|
-
try {
|
|
288
|
-
const chatOpts = {};
|
|
289
|
-
if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
|
|
290
|
-
if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
|
|
291
|
-
upRes = await upstream.chat(forwarded, Object.keys(chatOpts).length ? chatOpts : undefined);
|
|
292
|
-
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 });
|
|
293
|
-
} catch (err) {
|
|
294
|
-
if (auto) await auto.recordError(model, { message: errMsg(err) });
|
|
295
|
-
lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
|
|
296
|
-
logError(model, 502, errMsg(err));
|
|
297
|
-
evt("upstream-error", { reqId, model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
|
|
298
|
-
}
|
|
299
|
-
if (plugins?.length) {
|
|
300
|
-
runHook(plugins, "upstream:response", {
|
|
301
|
-
reqId, requested, model,
|
|
302
|
-
status: upRes instanceof Error ? null : upRes instanceof Object ? (upRes.status ?? null) : null,
|
|
303
|
-
ok: !(upRes instanceof Error) && upRes ? upRes.status < 400 : false,
|
|
304
|
-
error: upRes instanceof Error ? errMsg(upRes) : null,
|
|
305
|
-
timing: upRes?._t ?? null,
|
|
306
|
-
}).catch(() => {});
|
|
307
|
-
}
|
|
308
|
-
mark(`up-${model}`);
|
|
309
|
-
if (upRes && upRes.status >= 400) {
|
|
310
|
-
const isAllowlistBlock = upRes.status === 403 && (upRes.headers?.get?.("x-mslxdff-allowlist") === "1");
|
|
311
|
-
if (isAllowlistBlock) {
|
|
312
|
-
let bodyText = null;
|
|
313
|
-
try { bodyText = await upRes.clone().text(); } catch {}
|
|
314
|
-
let errBody = { error: `model not allowed for provider` };
|
|
315
|
-
try { errBody = bodyText ? JSON.parse(bodyText) : errBody; } catch { errBody = { error: bodyText || "model not allowed" }; }
|
|
316
|
-
// 白名单 403:显式模型直通 403;auto 时跳过该候选继续往下走(不打断多供应商 auto)
|
|
317
|
-
if (useAuto) {
|
|
318
|
-
// auto:软错跳过,不计冷却,继续试下一个供应商/模型
|
|
319
|
-
logError(model, 403, errBody.error || "model not allowed");
|
|
320
|
-
evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true, skipped: true });
|
|
321
|
-
lastErr = { model, upstream: upRes, status: 403, message: errBody.error || "model not allowed" };
|
|
322
|
-
if (canFallback && idx < order.length - 1) {
|
|
323
|
-
evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: `allowlist skip ${errBody.error || "blocked"}` });
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
// auto 已到末尾仍全被拦 → 直通 403
|
|
327
|
-
return json(res, 403, errBody);
|
|
328
|
-
}
|
|
329
|
-
// 非 auto:显式指定被拦 → 硬 403,不 fallback(防绕过 allowlist)
|
|
330
|
-
logError(model, 403, errBody.error || "model not allowed");
|
|
331
|
-
evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null, allowlist: true });
|
|
332
|
-
return json(res, 403, errBody);
|
|
333
|
-
}
|
|
334
|
-
if (auto) await auto.recordError(model, { status: upRes.status });
|
|
335
|
-
lastErr = { model, upstream: upRes, status: upRes.status, message: null };
|
|
336
|
-
logError(model, upRes.status, `upstream ${upRes.status}`);
|
|
337
|
-
evt("upstream-error", { reqId, model, status: upRes.status, message: null, timing: upRes._t ?? null });
|
|
338
|
-
upRes = null;
|
|
339
|
-
}
|
|
340
|
-
if (upRes) {
|
|
341
|
-
const isStream = Boolean(body.stream);
|
|
342
|
-
const d = hedgeDelayMs();
|
|
343
|
-
const hasPeers = Boolean(peers) && peers.ordered().length > 0;
|
|
344
|
-
const doHedge = shouldHedge({ isStream, canForwardPeers, hedgeDelayMs: d, hasPeers }) && upRes.status === 200 && upRes.body;
|
|
345
|
-
if (doHedge) {
|
|
346
|
-
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 });
|
|
347
|
-
if (hr.handled) return;
|
|
348
|
-
if (hr.lastErr) lastErr = hr.lastErr;
|
|
349
|
-
if (hr.upRes === null) upRes = null;
|
|
350
|
-
else if (hr.upRes) upRes = hr.upRes;
|
|
351
|
-
if (upRes === null) {
|
|
352
|
-
// hedge 触发后走下面的 peer/broadband 分支,不再走本地串行
|
|
353
|
-
} else {
|
|
354
|
-
// hedge 未决出胜负,回退到串行本地
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
if (upRes) {
|
|
358
|
-
const lr = await handleLocalRelay({ upRes, model, body, order, idx, lastErr, requested, useAuto, lockModel, auto, handlerCtx, evt, logCall, logError, mark, perf0, stages, startedAt, plugins, res });
|
|
359
|
-
if (lr.handled) return;
|
|
360
|
-
if (lr.lastErr) { lastErr = lr.lastErr; continue; }
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
if (canForwardPeers) {
|
|
366
|
-
const pr = await handlePeerRelay({ model, body, lastErr, requested, useAuto, lockModel, auto, peers, handlerCtx, evt, logCall, mark, perf0, stages, startedAt, plugins, res });
|
|
367
|
-
if (pr.handled) return;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (groups) {
|
|
371
|
-
const br = await handleBroadbandRelay({ model, body, hops, lastErr, requested, useAuto, lockModel, auto, groups, token, bus, logs, handlerCtx, evt, mark, perf0, stages, res, startedAt, plugins });
|
|
372
|
-
if (br.handled) return;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
if (canFallback) {
|
|
376
|
-
evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: lastErr?.message || `upstream ${lastErr?.status ?? 502}` });
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
await handleExhaustedLocal({ res, body, lastErr, order, handlerCtx: { ...handlerCtx, model, reqId }, evt, logCall, mark, perf0, stages, done, requested, useAuto });
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
await handleExhaustedAll({ res, body, lastErr, order, requested, handlerCtx: { ...handlerCtx, reqId, startedAt }, evt, logCall, mark, perf0, stages });
|
|
384
|
-
}
|
|
1
|
+
export { createChatGateway, chatHandler } from "./gateway.js";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export { DEFAULT_PORT, defaultStateFile, tokenFile, generateToken, flushStateSync, clearStateCache, readState, writeStateImmediate, writeStateDeferred } from "./store.js";
|
|
2
|
+
export { loadToken, refreshToken } from "./schemas/token.js";
|
|
3
|
+
export { getPort, setPort } from "./schemas/port.js";
|
|
4
|
+
export {
|
|
5
|
+
WORKBUDDY_DEFAULT_BASE_URL,
|
|
6
|
+
providerKeyEnv,
|
|
7
|
+
providerBaseUrlEnv,
|
|
8
|
+
providerShareEnv,
|
|
9
|
+
loadProviderKeys,
|
|
10
|
+
loadProviderKey,
|
|
11
|
+
saveProviderKeys,
|
|
12
|
+
saveProviderKey,
|
|
13
|
+
addProviderKey,
|
|
14
|
+
removeProviderKey,
|
|
15
|
+
removeProviderKeys,
|
|
16
|
+
loadProviderShareKeys,
|
|
17
|
+
saveProviderShareKeys,
|
|
18
|
+
loadProviderConfigs,
|
|
19
|
+
loadProviderConfig,
|
|
20
|
+
loadProviderBaseUrl,
|
|
21
|
+
loadProviderAuths,
|
|
22
|
+
saveProviderAuths,
|
|
23
|
+
saveProviderBaseUrl,
|
|
24
|
+
saveProviderConfig,
|
|
25
|
+
normalizeAllowedModel,
|
|
26
|
+
normalizeBaseUrl,
|
|
27
|
+
normalizeAuths,
|
|
28
|
+
normalizeEndpointPath,
|
|
29
|
+
defaultModelsPath,
|
|
30
|
+
defaultChatPath,
|
|
31
|
+
loadProviderModelsPath,
|
|
32
|
+
loadProviderChatPath,
|
|
33
|
+
} from "./schemas/provider.js";
|
|
34
|
+
export {
|
|
35
|
+
loadProviderAllowedModels,
|
|
36
|
+
saveProviderAllowedModels,
|
|
37
|
+
addProviderAllowedModel,
|
|
38
|
+
removeProviderAllowedModel,
|
|
39
|
+
removeProviderAllowedModels,
|
|
40
|
+
loadProviderAllowAnyModels,
|
|
41
|
+
saveProviderAllowAnyModels,
|
|
42
|
+
isModelAllowed,
|
|
43
|
+
} from "./schemas/allowlist.js";
|
|
44
|
+
export {
|
|
45
|
+
loadModelErrors,
|
|
46
|
+
saveModelErrors,
|
|
47
|
+
loadModelLatencies,
|
|
48
|
+
saveModelLatencies,
|
|
49
|
+
loadModelStats,
|
|
50
|
+
saveModelStats,
|
|
51
|
+
loadPreferredModel,
|
|
52
|
+
loadModelPicks,
|
|
53
|
+
saveModelPicks,
|
|
54
|
+
savePreferredModel,
|
|
55
|
+
} from "./schemas/model.js";
|
|
56
|
+
export { loadPeers, savePeers, loadPeerErrors, savePeerErrors, loadPeerStats, savePeerStats } from "./schemas/peer.js";
|
|
57
|
+
export { loadGroups, loadGroupsJoined, saveGroupsJoined, loadBans, saveBans, saveGroups } from "./schemas/group.js";
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory Store adapter — 零 IO 的 Store 第二实现,证明 Store seam 真实。
|
|
3
|
+
* 与 fileStore(src/state/store.js)共享同一 merge 语义与冷/热分层,
|
|
4
|
+
* 仅将 persist 层替换为 Map<file, {json, mtimeMs}>,不触 `node:fs`。
|
|
5
|
+
* 用于 `—test-concurrency>1` 并行与单测快速路径。
|
|
6
|
+
*/
|
|
7
|
+
const memCache = new Map(); // file -> { data, dirty, timer, mtimeMs }
|
|
8
|
+
const memDisk = new Map(); // file -> { obj, mtimeMs }
|
|
9
|
+
|
|
10
|
+
function getEntry(file) {
|
|
11
|
+
let e = memCache.get(file);
|
|
12
|
+
if (!e) {
|
|
13
|
+
e = { data: null, dirty: false, timer: null, mtimeMs: 0 };
|
|
14
|
+
memCache.set(file, e);
|
|
15
|
+
}
|
|
16
|
+
return e;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function loadFromDiskMem(file) {
|
|
20
|
+
const v = memDisk.get(file);
|
|
21
|
+
return v ? structuredClone(v.obj) : {};
|
|
22
|
+
}
|
|
23
|
+
function getMtimeMem(file) {
|
|
24
|
+
const v = memDisk.get(file);
|
|
25
|
+
return v ? v.mtimeMs : 0;
|
|
26
|
+
}
|
|
27
|
+
function atomicWriteSyncMem(file, data) {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
// ensure monotonic mtime
|
|
30
|
+
const prev = memDisk.get(file)?.mtimeMs || 0;
|
|
31
|
+
const mtime = now <= prev ? prev + 1 : now;
|
|
32
|
+
memDisk.set(file, { obj: structuredClone(data), mtimeMs: mtime });
|
|
33
|
+
return mtime;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const COLD_WINS = new Set([
|
|
37
|
+
"providerConfigs",
|
|
38
|
+
"providerKeys",
|
|
39
|
+
"providerShareKeys",
|
|
40
|
+
"port",
|
|
41
|
+
"token",
|
|
42
|
+
"preferredModel",
|
|
43
|
+
"modelPicks",
|
|
44
|
+
"peers",
|
|
45
|
+
"groups",
|
|
46
|
+
"groupsJoined",
|
|
47
|
+
"bans",
|
|
48
|
+
"createdAt",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
function mergeState(disk, mem) {
|
|
52
|
+
const merged = { ...disk, ...mem };
|
|
53
|
+
for (const k of COLD_WINS) if (disk[k] !== undefined) merged[k] = disk[k];
|
|
54
|
+
return merged;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readState(file) {
|
|
58
|
+
const e = getEntry(file);
|
|
59
|
+
if (e.data !== null) {
|
|
60
|
+
const mtime = getMtimeMem(file);
|
|
61
|
+
if (mtime === e.mtimeMs) return e.data;
|
|
62
|
+
if (e.dirty) {
|
|
63
|
+
const disk = loadFromDiskMem(file);
|
|
64
|
+
const merged = mergeState(disk || {}, e.data);
|
|
65
|
+
e.data = merged;
|
|
66
|
+
e.mtimeMs = mtime;
|
|
67
|
+
return e.data;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const disk = loadFromDiskMem(file);
|
|
71
|
+
e.data = disk && typeof disk === "object" ? structuredClone(disk) : {};
|
|
72
|
+
e.mtimeMs = getMtimeMem(file) || Date.now();
|
|
73
|
+
e.dirty = false;
|
|
74
|
+
return e.data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeStateImmediate(file, patch) {
|
|
78
|
+
const e = getEntry(file);
|
|
79
|
+
const base = e.data !== null ? e.data : readState(file);
|
|
80
|
+
const merged = { ...base, ...patch };
|
|
81
|
+
e.data = merged;
|
|
82
|
+
e.dirty = false;
|
|
83
|
+
if (e.timer) { clearTimeout(e.timer); e.timer = null; }
|
|
84
|
+
e.mtimeMs = atomicWriteSyncMem(file, merged);
|
|
85
|
+
return merged;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writeStateDeferred(file, patch) {
|
|
89
|
+
const e = getEntry(file);
|
|
90
|
+
const base = e.data !== null ? e.data : readState(file);
|
|
91
|
+
const merged = { ...base, ...patch };
|
|
92
|
+
e.data = merged;
|
|
93
|
+
e.dirty = true;
|
|
94
|
+
// 内存版无需定时刷盘,延迟写即 dirty,下次 flush 或 read 时合并
|
|
95
|
+
return merged;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function flushStateSync(file) {
|
|
99
|
+
const e = memCache.get(file);
|
|
100
|
+
if (!e || !e.dirty || !e.data) return;
|
|
101
|
+
e.mtimeMs = atomicWriteSyncMem(file || "mem://default", e.data);
|
|
102
|
+
e.dirty = false;
|
|
103
|
+
if (e.timer) { clearTimeout(e.timer); e.timer = null; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function clearStateCache(file) {
|
|
107
|
+
if (file) memCache.delete(file);
|
|
108
|
+
else memCache.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- domain 工厂(复用 schemas 的 normalize 逻辑,但闭包于 memory store) ----
|
|
112
|
+
// 为保持零依赖,memory 侧直接内联必要的 normalize(如 normalizeAllowedModel)
|
|
113
|
+
// 此处仅暴露与 fileStore 对等的最小子集用于测试 seam;完整 facade 可按需扩展
|
|
114
|
+
|
|
115
|
+
const WORKBUDDY_DEFAULT_BASE_URL = "https://copilot.tencent.com";
|
|
116
|
+
function normalizeAllowedModel(model, providerId) {
|
|
117
|
+
let s = String(model || "").trim();
|
|
118
|
+
if (!s) return "";
|
|
119
|
+
const idx = s.indexOf("/");
|
|
120
|
+
if (idx > 0 && s.slice(0, idx).toLowerCase() === String(providerId || "").toLowerCase()) s = s.slice(idx + 1);
|
|
121
|
+
return s;
|
|
122
|
+
}
|
|
123
|
+
function defaultStateFileMem(file) { return file || "mem://default"; }
|
|
124
|
+
|
|
125
|
+
export function createMemoryState(file = "mem://default") {
|
|
126
|
+
const f = defaultStateFileMem(file);
|
|
127
|
+
return {
|
|
128
|
+
loadModelErrors: () => {
|
|
129
|
+
const v = readState(f).modelErrors;
|
|
130
|
+
return v && typeof v === "object" && !Array.isArray(v) ? structuredClone(v) : {};
|
|
131
|
+
},
|
|
132
|
+
saveModelErrors: (errors) => { writeStateDeferred(f, { modelErrors: structuredClone(errors) }); return errors; },
|
|
133
|
+
loadModelLatencies: () => {
|
|
134
|
+
const v = readState(f).modelLatencies;
|
|
135
|
+
return v && typeof v === "object" && !Array.isArray(v) ? structuredClone(v) : {};
|
|
136
|
+
},
|
|
137
|
+
saveModelLatencies: (v) => { writeStateDeferred(f, { modelLatencies: structuredClone(v) }); return v; },
|
|
138
|
+
loadProviderKeys: (id) => {
|
|
139
|
+
const keys = readState(f).providerKeys;
|
|
140
|
+
const v = keys && typeof keys === "object" ? keys[id] : undefined;
|
|
141
|
+
if (typeof v === "string") return v.trim() ? [v.trim()] : [];
|
|
142
|
+
if (Array.isArray(v)) return [...new Set(v.filter((x) => typeof x === "string" && x.trim().length))];
|
|
143
|
+
return [];
|
|
144
|
+
},
|
|
145
|
+
saveProviderKeys: (id, list) => {
|
|
146
|
+
const keys = { ...(readState(f).providerKeys || {}) };
|
|
147
|
+
const clean = [...new Set((Array.isArray(list) ? list : []).map((k) => String(k || "").trim()).filter(Boolean))];
|
|
148
|
+
if (clean.length) keys[id] = clean; else delete keys[id];
|
|
149
|
+
writeStateImmediate(f, { providerKeys: keys });
|
|
150
|
+
return clean;
|
|
151
|
+
},
|
|
152
|
+
loadProviderAllowedModels: (id) => {
|
|
153
|
+
const cfg = readState(f).providerConfigs?.[id];
|
|
154
|
+
if (cfg && Array.isArray(cfg.allowedModels)) return [...cfg.allowedModels].map((m) => normalizeAllowedModel(m, id)).filter(Boolean);
|
|
155
|
+
return [];
|
|
156
|
+
},
|
|
157
|
+
saveProviderAllowedModels: (id, list) => {
|
|
158
|
+
const clean = [...new Set((Array.isArray(list) ? list : []).map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
|
|
159
|
+
const configs = { ...(readState(f).providerConfigs || {}) };
|
|
160
|
+
const cur = configs[id] || {};
|
|
161
|
+
configs[id] = { ...cur, allowedModels: clean };
|
|
162
|
+
if (!clean.length) delete configs[id].allowedModels;
|
|
163
|
+
if (!configs[id].baseUrl && !(configs[id].keys||[]).length && !(configs[id].allowedModels||[]).length) delete configs[id];
|
|
164
|
+
writeStateImmediate(f, { providerConfigs: configs });
|
|
165
|
+
return clean;
|
|
166
|
+
},
|
|
167
|
+
loadPeers: () => {
|
|
168
|
+
const v = readState(f).peers;
|
|
169
|
+
return Array.isArray(v) ? structuredClone(v) : [];
|
|
170
|
+
},
|
|
171
|
+
savePeers: (peers) => { writeStateImmediate(f, { peers: structuredClone(peers) }); return peers; },
|
|
172
|
+
flushSync: () => flushStateSync(f),
|
|
173
|
+
clear: () => clearStateCache(f),
|
|
174
|
+
_memDisk: memDisk,
|
|
175
|
+
_memCache: memCache,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 单例 memory facade(便于直接 import 使用)
|
|
180
|
+
export const memoryState = createMemoryState("mem://default");
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, statSync, renameSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function loadFromDisk(file) {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
8
|
+
} catch {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ensureDir(file) {
|
|
14
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getMtimeMs(file) {
|
|
18
|
+
try {
|
|
19
|
+
return statSync(file).mtimeMs;
|
|
20
|
+
} catch {
|
|
21
|
+
return Date.now();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function atomicWriteSync(file, data) {
|
|
26
|
+
ensureDir(file);
|
|
27
|
+
const tmp = `${file}.tmp.${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
28
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
29
|
+
try {
|
|
30
|
+
renameSync(tmp, file);
|
|
31
|
+
} catch {
|
|
32
|
+
try { writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 }); } catch {}
|
|
33
|
+
try { unlinkSync(tmp); } catch {}
|
|
34
|
+
}
|
|
35
|
+
return getMtimeMs(file);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function atomicWriteAsync(file, data) {
|
|
39
|
+
ensureDir(file);
|
|
40
|
+
await writeFile(file, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
41
|
+
return getMtimeMs(file);
|
|
42
|
+
}
|