mslxdff 0.1.55 → 0.1.56
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 +1 -160
- package/bin/mslxdff.js +666 -26
- package/package.json +3 -2
- package/src/chat/config.js +10 -0
- package/src/chat/index.js +6 -0
- package/src/chat/prompt.js +63 -0
- package/src/chat/repl.js +245 -0
- package/src/chat/spinner.js +38 -0
- package/src/chat/stats.js +170 -0
- package/src/chat/store.js +48 -0
- package/src/chat/tools.js +304 -0
- package/src/chat/upstream.js +90 -0
- package/src/models.js +90 -2
- package/src/providers/dispatcher.js +100 -0
- package/src/providers/generic.js +221 -0
- package/src/providers/index.js +6 -0
- package/src/providers/keyring.js +38 -0
- package/src/providers/model-id.js +36 -0
- package/src/providers/opencode.js +20 -0
- package/src/providers/openrouter.js +217 -0
- package/src/providers/share-keys.js +53 -0
- package/src/providers/workbuddy-balance.js +76 -0
- package/src/providers/workbuddy.js +563 -0
- package/src/routes/chat/index.js +20 -1
- package/src/routes/peers.js +12 -7
- package/src/routes/stream.js +9 -0
- package/src/state.js +299 -4
- package/src/time.js +73 -0
- package/src/upstream.js +126 -8
- package/docs/adr/0001-reasoning-content-injection.md +0 -14
- package/docs/adr/0002-models-free-filter.md +0 -12
- package/docs/adr/0003-zero-state-no-auth.md +0 -10
- package/docs/adr/0004-bearer-token.md +0 -18
- package/docs/adr/0005-peer-mesh.md +0 -53
- package/docs/adr/0006-broadband-member.md +0 -103
- package/docs/agents/domain.md +0 -51
- package/docs/agents/issue-tracker.md +0 -30
- package/docs/agents/triage-labels.md +0 -15
- package/docs/plugins.md +0 -185
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFileSync, statSync, existsSync } from "node:fs";
|
|
3
|
+
import { resolve, join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { performance } from "node:perf_hooks";
|
|
6
|
+
import { FORBIDDEN } from "./config.js";
|
|
7
|
+
import { logDir } from "../logs.js";
|
|
8
|
+
import { defaultStateFile, loadProviderKeys, loadProviderConfigs } from "../state.js";
|
|
9
|
+
|
|
10
|
+
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
11
|
+
const allowedRoots = [
|
|
12
|
+
resolve(pkgRoot),
|
|
13
|
+
resolve(logDir()),
|
|
14
|
+
resolve(dirname(defaultStateFile())),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function isAllowed(p) {
|
|
18
|
+
const abs = resolve(p);
|
|
19
|
+
// 展开 ~/ 形式
|
|
20
|
+
const expanded = abs.startsWith("~/") ? join(process.env.HOME || process.env.USERPROFILE || "", abs.slice(2)) : abs;
|
|
21
|
+
const chk = resolve(expanded);
|
|
22
|
+
return allowedRoots.some((r) => chk === r || chk.startsWith(r + "/") || chk.startsWith(r + "\\"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseCommand(str) {
|
|
26
|
+
// 支持引号包裹
|
|
27
|
+
const out = [];
|
|
28
|
+
let cur = "";
|
|
29
|
+
let q = null;
|
|
30
|
+
for (let i = 0; i < str.length; i++) {
|
|
31
|
+
const c = str[i];
|
|
32
|
+
if (q) {
|
|
33
|
+
if (c === q) q = null;
|
|
34
|
+
else cur += c;
|
|
35
|
+
} else if (c === '"' || c === "'") {
|
|
36
|
+
q = c;
|
|
37
|
+
} else if (c === " " || c === "\t") {
|
|
38
|
+
if (cur) { out.push(cur); cur = ""; }
|
|
39
|
+
} else {
|
|
40
|
+
cur += c;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (cur) out.push(cur);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getToolDefs() {
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
type: "function",
|
|
51
|
+
function: {
|
|
52
|
+
name: "run_command",
|
|
53
|
+
description: "执行一条 mslxdff CLI 命令(不含 mslxdff 前缀)。仅限 cli_help_mini 所列命令,禁止 -uninstall。",
|
|
54
|
+
parameters: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
command: { type: "string", description: "例如: -model set hy3-free 或 -group list 或 -log 20" },
|
|
58
|
+
},
|
|
59
|
+
required: ["command"],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: "function",
|
|
65
|
+
function: {
|
|
66
|
+
name: "read_file",
|
|
67
|
+
description: "读取项目内的文件内容(src/ docs/ logs/ 等),用于查看日志或配置。禁止读取项目外文件。",
|
|
68
|
+
parameters: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
path: { type: "string", description: "相对项目根或绝对路径,如 src/logs.js 或 ~/.config/mslxdff/events.log" },
|
|
72
|
+
limit: { type: "number", description: "最多读取字符数,默认 8000" },
|
|
73
|
+
},
|
|
74
|
+
required: ["path"],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
type: "function",
|
|
80
|
+
function: {
|
|
81
|
+
name: "curl",
|
|
82
|
+
description: "网络/HTTP 探活,检测上游或本机服务可用性。支持任意 http(s) URL,返回状态码、耗时、响应头与前几千字符。常用: upstream(上游模型列表)、local/health(本机健康)、local/models(本机模型列表)。简写会自动补全为完整 URL。",
|
|
83
|
+
parameters: {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
url: { type: "string", description: "要请求的 URL 或简写:upstream、upstream/models、upstream/chat、local、local/health、local/models、health、models,或完整 http(s) URL" },
|
|
87
|
+
method: { type: "string", description: "HTTP 方法,默认 GET", enum: ["GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"] },
|
|
88
|
+
headers: { type: "object", description: "附加请求头,键值均为字符串", additionalProperties: { type: "string" } },
|
|
89
|
+
body: { type: "string", description: "请求体(POST/PUT 时),字符串,如 JSON" },
|
|
90
|
+
timeoutMs: { type: "number", description: "超时毫秒,默认 8000,最大 15000" },
|
|
91
|
+
},
|
|
92
|
+
required: ["url"],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function validateCommand(cmd) {
|
|
100
|
+
const low = String(cmd || "").toLowerCase();
|
|
101
|
+
for (const f of FORBIDDEN) if (low.includes(f)) return `forbidden: ${f} 已拦截,禁止执行`;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function execCommand(command) {
|
|
106
|
+
const err = validateCommand(command);
|
|
107
|
+
if (err) return { ok: false, output: err };
|
|
108
|
+
const args = parseCommand(String(command).trim());
|
|
109
|
+
if (!args.length) return { ok: false, output: "empty command" };
|
|
110
|
+
// 关键路径打点:exec 本身耗时(fork+加载)是 -chat 最大的本地开销,后续可改直调以省 300-500ms
|
|
111
|
+
const bin = join(pkgRoot, "bin/mslxdff.js");
|
|
112
|
+
const t0 = performance.now();
|
|
113
|
+
const res = await new Promise((resolve) => {
|
|
114
|
+
execFile(process.execPath, [bin, ...args], { timeout: 15000, maxBuffer: 1024 * 500 }, (e, stdout, stderr) => {
|
|
115
|
+
const out = String(stdout || "") + (stderr ? `\n${stderr}` : "");
|
|
116
|
+
if (e) {
|
|
117
|
+
resolve({ ok: false, output: out.slice(0, 8000) || String(e.message).slice(0, 2000) });
|
|
118
|
+
} else {
|
|
119
|
+
resolve({ ok: true, output: out.slice(0, 8000) || "(no output)" });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
res._ms = Math.round(performance.now() - t0);
|
|
124
|
+
return res;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function readFileTool({ path, limit }) {
|
|
128
|
+
const raw = String(path || "").trim();
|
|
129
|
+
if (!raw) return { ok: false, output: "empty path" };
|
|
130
|
+
let target = raw;
|
|
131
|
+
if (target.startsWith("~/")) {
|
|
132
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
133
|
+
target = join(home, target.slice(2));
|
|
134
|
+
} else if (!target.startsWith("/") && !/^[A-Za-z]:/.test(target)) {
|
|
135
|
+
// 相对路径视作项目根相对
|
|
136
|
+
target = join(pkgRoot, target);
|
|
137
|
+
}
|
|
138
|
+
if (!isAllowed(target)) return { ok: false, output: `not allowed: ${raw}(仅限项目目录或日志目录)` };
|
|
139
|
+
try {
|
|
140
|
+
if (!existsSync(target)) return { ok: false, output: `not found: ${raw}` };
|
|
141
|
+
const st = statSync(target);
|
|
142
|
+
if (st.isDirectory()) {
|
|
143
|
+
// 列目录
|
|
144
|
+
const { readdirSync } = await import("node:fs");
|
|
145
|
+
const files = readdirSync(target).slice(0, 80);
|
|
146
|
+
return { ok: true, output: `dir ${raw}:\n` + files.join("\n") };
|
|
147
|
+
}
|
|
148
|
+
const cap = Number(limit) > 0 ? Math.min(Number(limit), 20000) : 8000;
|
|
149
|
+
const data = readFileSync(target, "utf8");
|
|
150
|
+
const txt = data.length > cap ? data.slice(0, cap) + `\n... (truncated ${data.length - cap} chars)` : data;
|
|
151
|
+
return { ok: true, output: txt || "(empty file)" };
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return { ok: false, output: String(e.message).slice(0, 2000) };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveLocalPort() {
|
|
158
|
+
// 复用 state 的端口优先级:state.port > MSLXDFF_PORT env > 8989
|
|
159
|
+
try {
|
|
160
|
+
const f = defaultStateFile();
|
|
161
|
+
const j = JSON.parse(readFileSync(f, "utf8"));
|
|
162
|
+
const p = j?.port;
|
|
163
|
+
if (Number.isInteger(p) && p > 0) return p;
|
|
164
|
+
} catch {}
|
|
165
|
+
const env = Number(process.env.MSLXDFF_PORT);
|
|
166
|
+
if (Number.isInteger(env) && env > 0) return env;
|
|
167
|
+
return 8989;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveLocalToken() {
|
|
171
|
+
try {
|
|
172
|
+
const f = defaultStateFile();
|
|
173
|
+
const j = JSON.parse(readFileSync(f, "utf8"));
|
|
174
|
+
const t = j?.token;
|
|
175
|
+
if (typeof t === "string" && t.trim()) return t.trim();
|
|
176
|
+
} catch {}
|
|
177
|
+
return "";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function expandCurlUrl(raw) {
|
|
181
|
+
const s = String(raw || "").trim();
|
|
182
|
+
const low = s.toLowerCase();
|
|
183
|
+
const port = resolveLocalPort();
|
|
184
|
+
const localBase = `http://127.0.0.1:${port}`;
|
|
185
|
+
// 完整 http(s) 直接用
|
|
186
|
+
if (/^https?:\/\//i.test(s)) return s;
|
|
187
|
+
// 简写映射
|
|
188
|
+
if (low === "upstream" || low === "upstream/models" || low === "upstream/models/") return "https://opencode.ai/zen/v1/models";
|
|
189
|
+
if (low === "upstream/chat" || low === "upstream/chat/completions") return "https://opencode.ai/zen/v1/chat/completions";
|
|
190
|
+
if (low === "local" || low === "local/health" || low === "health") return `${localBase}/health`;
|
|
191
|
+
if (low === "local/models" || low === "models" || low === "v1/models") return `${localBase}/v1/models`;
|
|
192
|
+
if (low === "local/chat" || low === "v1/chat/completions") return `${localBase}/v1/chat/completions`;
|
|
193
|
+
// 供应商简写:bai/models、openrouter/models 等 → 该供应商 baseUrl + /models
|
|
194
|
+
const provMatch = low.match(/^([a-z0-9_-]+)\/models\/?$/);
|
|
195
|
+
if (provMatch) {
|
|
196
|
+
try {
|
|
197
|
+
const pid = provMatch[1];
|
|
198
|
+
const cfgs = loadProviderConfigs();
|
|
199
|
+
const cfg = cfgs[pid];
|
|
200
|
+
if (cfg?.baseUrl) return `${cfg.baseUrl.replace(/\/+$/, "")}/models`;
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
// 裸路径视作本机相对
|
|
204
|
+
if (s.startsWith("/")) return `${localBase}${s}`;
|
|
205
|
+
return s;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function curlTool({ url, method, headers, body, timeoutMs }) {
|
|
209
|
+
const raw = String(url || "").trim();
|
|
210
|
+
if (!raw) return { ok: false, output: "empty url" };
|
|
211
|
+
let target = expandCurlUrl(raw);
|
|
212
|
+
// 仅允许 http(s)
|
|
213
|
+
if (!/^https?:\/\//i.test(target)) return { ok: false, output: `only http(s) allowed: ${raw} -> ${target}` };
|
|
214
|
+
let m = String(method || "GET").toUpperCase();
|
|
215
|
+
if (!["GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"].includes(m)) return { ok: false, output: `unsupported method: ${m}` };
|
|
216
|
+
const timeout = Math.min(Math.max(Number(timeoutMs) || 8000, 500), 15000);
|
|
217
|
+
// 组装头
|
|
218
|
+
const h = {};
|
|
219
|
+
if (headers && typeof headers === "object") {
|
|
220
|
+
for (const [k, v] of Object.entries(headers)) if (typeof v === "string") h[String(k)] = v;
|
|
221
|
+
}
|
|
222
|
+
// 上游自动补最小可用头
|
|
223
|
+
if (/opencode\.ai/i.test(target)) {
|
|
224
|
+
if (!h["x-opencode-client"]) h["x-opencode-client"] = "desktop";
|
|
225
|
+
if (!h["Authorization"] && !h["authorization"]) h["Authorization"] = "Bearer public";
|
|
226
|
+
if (!h["Accept"]) h["Accept"] = m === "GET" ? "*/*" : "application/json";
|
|
227
|
+
if (!h["Content-Type"] && m !== "GET" && m !== "HEAD") h["Content-Type"] = "application/json";
|
|
228
|
+
}
|
|
229
|
+
// 本机自动带 token(/v1/* 需要鉴权);/v1/models 必须是 GET,模型常误用 POST 直接纠正
|
|
230
|
+
if (/127\.0\.0\.1|localhost/i.test(target) && /\/v1\//i.test(target)) {
|
|
231
|
+
const hasAuth = !!(h["Authorization"] || h["authorization"]);
|
|
232
|
+
if (!hasAuth) {
|
|
233
|
+
const tok = resolveLocalToken();
|
|
234
|
+
if (tok) h["Authorization"] = `Bearer ${tok}`;
|
|
235
|
+
}
|
|
236
|
+
if (/\/v1\/models/i.test(target) && m === "POST") m = "GET";
|
|
237
|
+
}
|
|
238
|
+
// 已配置供应商自动带 key(curl 直连供应商时免手动传头)
|
|
239
|
+
if (!h["Authorization"] && !h["authorization"]) {
|
|
240
|
+
try {
|
|
241
|
+
const cfgs = loadProviderConfigs();
|
|
242
|
+
for (const [pid, cfg] of Object.entries(cfgs)) {
|
|
243
|
+
const base = String(cfg?.baseUrl || "").replace(/\/+$/, "");
|
|
244
|
+
if (!base) continue;
|
|
245
|
+
if (target.toLowerCase().startsWith(base.toLowerCase())) {
|
|
246
|
+
const keys = loadProviderKeys(pid);
|
|
247
|
+
if (keys.length) { h["Authorization"] = `Bearer ${keys[0]}`; break; }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// 兜底:api.b.ai 即 bai 供应商(env 或 state)
|
|
251
|
+
if (!h["Authorization"] && /api\.b\.ai/i.test(target)) {
|
|
252
|
+
const k = loadProviderKeys("bai")[0];
|
|
253
|
+
if (k) h["Authorization"] = `Bearer ${k}`;
|
|
254
|
+
}
|
|
255
|
+
} catch {}
|
|
256
|
+
}
|
|
257
|
+
// fetch 实现优先 undici,其次全局
|
|
258
|
+
let fetchImpl = globalThis.fetch;
|
|
259
|
+
try {
|
|
260
|
+
const mod = await import("undici");
|
|
261
|
+
if (mod?.fetch) fetchImpl = mod.fetch;
|
|
262
|
+
} catch {}
|
|
263
|
+
const controller = new AbortController();
|
|
264
|
+
const timer = setTimeout(() => controller.abort(new Error(`curl timed out after ${timeout}ms`)), timeout);
|
|
265
|
+
const t0 = performance.now();
|
|
266
|
+
try {
|
|
267
|
+
const opts = { method: m, headers: h, signal: controller.signal };
|
|
268
|
+
if (body != null && m !== "GET" && m !== "HEAD") opts.body = String(body);
|
|
269
|
+
const res = await fetchImpl(target, opts);
|
|
270
|
+
const ms = Math.round(performance.now() - t0);
|
|
271
|
+
// 抓响应头(过滤成可读)
|
|
272
|
+
const rh = {};
|
|
273
|
+
try {
|
|
274
|
+
for (const [k, v] of res.headers.entries()) rh[k] = v;
|
|
275
|
+
} catch {}
|
|
276
|
+
let txt = "";
|
|
277
|
+
try {
|
|
278
|
+
// 限制读取大小,避免超大 body
|
|
279
|
+
const ab = await res.arrayBuffer();
|
|
280
|
+
const buf = Buffer.from(ab);
|
|
281
|
+
const lim = 6000;
|
|
282
|
+
txt = buf.toString("utf8").slice(0, lim);
|
|
283
|
+
if (buf.length > lim) txt += `\n... (truncated ${buf.length - lim} bytes)`;
|
|
284
|
+
} catch (e) {
|
|
285
|
+
txt = String(e.message || e).slice(0, 1000);
|
|
286
|
+
}
|
|
287
|
+
const headerLines = Object.entries(rh).slice(0, 20).map(([k, v]) => `${k}: ${v}`).join("\n");
|
|
288
|
+
const out = [
|
|
289
|
+
`${m} ${target}`,
|
|
290
|
+
`status: ${res.status} ${res.statusText || ""}`.trim(),
|
|
291
|
+
`latency: ${ms}ms`,
|
|
292
|
+
headerLines ? `headers:\n${headerLines}` : "headers: (none)",
|
|
293
|
+
`body (${txt.length} chars):`,
|
|
294
|
+
txt || "(empty body)",
|
|
295
|
+
].join("\n");
|
|
296
|
+
// 2xx 视为 ok,其余仍返回 ok:true 但带状态,便于模型判断(网络层面成功)
|
|
297
|
+
return { ok: true, output: out.slice(0, 8000) };
|
|
298
|
+
} catch (e) {
|
|
299
|
+
const ms = Math.round(performance.now() - t0);
|
|
300
|
+
return { ok: false, output: `curl failed: ${String(e.message || e).slice(0, 1200)}\nurl: ${target}\nlatency: ${ms}ms` };
|
|
301
|
+
} finally {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { CHAT_PREFERRED, CHAT_FALLBACK, CHAT_TIMEOUT_MS } from "./config.js";
|
|
3
|
+
import { createUpstreamClient } from "../upstream.js";
|
|
4
|
+
|
|
5
|
+
function modelForAttempt(attempt) {
|
|
6
|
+
return attempt === 0 ? CHAT_PREFERRED : CHAT_FALLBACK;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function chatOnce({ messages, tools, model }) {
|
|
10
|
+
const client = createUpstreamClient({ connectTimeoutMs: CHAT_TIMEOUT_MS, keepAlive: false, fetchImpl: globalThis.fetch });
|
|
11
|
+
const body = {
|
|
12
|
+
model: model || CHAT_PREFERRED,
|
|
13
|
+
messages,
|
|
14
|
+
stream: false,
|
|
15
|
+
};
|
|
16
|
+
if (tools?.length) {
|
|
17
|
+
body.tools = tools;
|
|
18
|
+
body.tool_choice = "auto";
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const res = await client.chat(body);
|
|
22
|
+
const txt = await res.text();
|
|
23
|
+
let j;
|
|
24
|
+
try { j = JSON.parse(txt); } catch { return { ok: false, error: `non-json upstream: ${txt.slice(0, 800)}`, status: res.status }; }
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
const msg = j?.error?.message || txt.slice(0, 800);
|
|
27
|
+
const isInput400 = res.status === 400 && /prompt|messages/i.test(msg) && tools?.length;
|
|
28
|
+
if (isInput400) {
|
|
29
|
+
try { await client.close(); } catch {}
|
|
30
|
+
const retry = await chatOnceNoTools({ messages, model });
|
|
31
|
+
if (retry.ok) return { ...retry, retriedWithoutTools: true };
|
|
32
|
+
return { ok: false, error: msg, status: res.status, retried: retry.error };
|
|
33
|
+
}
|
|
34
|
+
return { ok: false, error: msg, status: res.status };
|
|
35
|
+
}
|
|
36
|
+
const choice = j.choices?.[0];
|
|
37
|
+
if (!choice) return { ok: false, error: "no choice", status: res.status };
|
|
38
|
+
return { ok: true, message: choice.message, usage: j.usage, raw: j, status: res.status };
|
|
39
|
+
} finally {
|
|
40
|
+
try { await client.close(); } catch {}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function chatOnceNoTools({ messages, model }) {
|
|
45
|
+
const client = createUpstreamClient({ connectTimeoutMs: CHAT_TIMEOUT_MS, keepAlive: false, fetchImpl: globalThis.fetch });
|
|
46
|
+
const body = { model: model || CHAT_PREFERRED, messages, stream: false };
|
|
47
|
+
try {
|
|
48
|
+
const res = await client.chat(body);
|
|
49
|
+
const txt = await res.text();
|
|
50
|
+
let j;
|
|
51
|
+
try { j = JSON.parse(txt); } catch { return { ok: false, error: `non-json: ${txt.slice(0,800)}`, status: res.status }; }
|
|
52
|
+
if (!res.ok) return { ok: false, error: j?.error?.message || txt.slice(0,800), status: res.status };
|
|
53
|
+
const choice = j.choices?.[0];
|
|
54
|
+
if (!choice) return { ok: false, error: "no choice", status: res.status };
|
|
55
|
+
return { ok: true, message: choice.message, usage: j.usage, raw: j, status: res.status };
|
|
56
|
+
} finally { try { await client.close(); } catch {} }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 带自动降级:仅 mimo-v2.5-free → big-pickle,不引入其他模型
|
|
60
|
+
export async function chatWithFallback(opts) {
|
|
61
|
+
const TRACE = process.env.MSLXDFF_CHAT_TRACE !== "0";
|
|
62
|
+
const t0 = TRACE ? performance.now() : 0;
|
|
63
|
+
const first = await chatOnce({ ...opts, model: CHAT_PREFERRED });
|
|
64
|
+
if (TRACE) {
|
|
65
|
+
const dt = Math.round(performance.now() - t0);
|
|
66
|
+
console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} ${first.ok ? "OK" : "FAIL"} · ${dt}ms${first.ok ? "" : ` · ${String(first.error).slice(0, 80)}`}\x1b[0m`);
|
|
67
|
+
}
|
|
68
|
+
if (first.ok) return { ...first, model: CHAT_PREFERRED };
|
|
69
|
+
const t1 = TRACE ? performance.now() : 0;
|
|
70
|
+
const second = await chatOnce({ ...opts, model: CHAT_FALLBACK });
|
|
71
|
+
if (TRACE) {
|
|
72
|
+
const dt = Math.round(performance.now() - t1);
|
|
73
|
+
const total = Math.round(performance.now() - t0);
|
|
74
|
+
console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} ${second.ok ? "OK" : "FAIL"} · ${dt}ms · 总 ${total}ms (fallback)\x1b[0m`);
|
|
75
|
+
}
|
|
76
|
+
if (second.ok) return { ...second, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
|
|
77
|
+
return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: ${second.error}`, status: second.status || first.status };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 压缩用:简短摘要请求(不带 tools),128k 上下文下仅 95% 触发,需完整摘要
|
|
81
|
+
export async function summarizeHistory(messages) {
|
|
82
|
+
const prompt = [
|
|
83
|
+
{ role: "system", content: "你是对话压缩助手,把以下历史对话压缩成 800 字以内的中文摘要,保留关键操作与结果、用户的偏好与待办、模型设置与群组操作及时间线,不要遗漏重要细节。" },
|
|
84
|
+
{ role: "user", content: messages.map((m) => `${m.role}: ${m.content || JSON.stringify(m.tool_calls || "")}`).join("\n").slice(0, 90000) },
|
|
85
|
+
];
|
|
86
|
+
const r = await chatWithFallback({ messages: prompt });
|
|
87
|
+
if (!r.ok) return null;
|
|
88
|
+
const txt = String(r.message?.content || "").trim();
|
|
89
|
+
return txt ? `【历史摘要】${txt}` : null;
|
|
90
|
+
}
|
package/src/models.js
CHANGED
|
@@ -22,14 +22,102 @@ export function filterFreeModels(list) {
|
|
|
22
22
|
return out;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, refreshMs = DEFAULT_REFRESH_MS, cacheFile } = {}) {
|
|
25
|
+
export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, refreshMs = DEFAULT_REFRESH_MS, cacheFile, providers } = {}) {
|
|
26
|
+
// 多供应商聚合模式:给 `providers` 数组,则缓存并返回所有供应商的模型列表
|
|
27
|
+
if (providers?.length) {
|
|
28
|
+
let aggregate = null;
|
|
29
|
+
let aggFetchedAt = 0;
|
|
30
|
+
let lastAllowlistKey = "";
|
|
31
|
+
async function currentAllowlistKey() {
|
|
32
|
+
try {
|
|
33
|
+
const { loadProviderAllowedModels } = await import("./state.js");
|
|
34
|
+
return providers.map((p) => `${p.id}:${loadProviderAllowedModels(p.id).join(",")}`).join("|");
|
|
35
|
+
} catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function aggLoad() {
|
|
40
|
+
const all = [];
|
|
41
|
+
for (const p of providers) {
|
|
42
|
+
try {
|
|
43
|
+
let list = (await p.listModels?.()) ?? [];
|
|
44
|
+
// 白名单过滤:若该供应商设置了 allowlist,则仅保留名单内模型
|
|
45
|
+
try {
|
|
46
|
+
const { loadProviderAllowedModels } = await import("./state.js");
|
|
47
|
+
const allowed = loadProviderAllowedModels(p.id);
|
|
48
|
+
if (allowed.length) {
|
|
49
|
+
const allowedSet = new Set(allowed);
|
|
50
|
+
const { splitModelId } = await import("./providers/model-id.js");
|
|
51
|
+
list = list.filter((m) => {
|
|
52
|
+
if (!m || !m.id) return false;
|
|
53
|
+
const { raw } = splitModelId(m.id, providers.map((x) => x.id));
|
|
54
|
+
return allowedSet.has(String(raw || "").trim());
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
all.push(...list);
|
|
59
|
+
} catch {
|
|
60
|
+
// 单供应商取数失败不拖垮整体
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
aggregate = { object: "list", data: all };
|
|
64
|
+
try {
|
|
65
|
+
lastAllowlistKey = await currentAllowlistKey();
|
|
66
|
+
} catch {}
|
|
67
|
+
if (cacheFile) persistModels(aggregate, cacheFile);
|
|
68
|
+
return aggregate;
|
|
69
|
+
}
|
|
70
|
+
async function get() {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
if (aggregate && now - aggFetchedAt < ttlMs) {
|
|
73
|
+
// 热更新白名单:allowlist 变化时,即使命中 TTL 也要重载(否则 clear 后仍返回旧过滤结果)
|
|
74
|
+
try {
|
|
75
|
+
const curKey = await currentAllowlistKey();
|
|
76
|
+
if (curKey !== lastAllowlistKey) {
|
|
77
|
+
const out = await aggLoad();
|
|
78
|
+
aggFetchedAt = Date.now();
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
} catch {}
|
|
82
|
+
// 否则尝试在缓存上二次过滤(处理 allowlist 从空变非空等未触发重载的场景)
|
|
83
|
+
try {
|
|
84
|
+
const { loadProviderAllowedModels } = await import("./state.js");
|
|
85
|
+
const { splitModelId } = await import("./providers/model-id.js");
|
|
86
|
+
const filteredData = aggregate.data.filter((m) => {
|
|
87
|
+
if (!m || !m.id) return false;
|
|
88
|
+
const { provider, raw } = splitModelId(m.id, providers.map((x) => x.id));
|
|
89
|
+
const allowed = loadProviderAllowedModels(provider);
|
|
90
|
+
if (!allowed.length) return true;
|
|
91
|
+
return allowed.includes(String(raw || "").trim());
|
|
92
|
+
});
|
|
93
|
+
if (filteredData.length !== aggregate.data.length) return { ...aggregate, data: filteredData };
|
|
94
|
+
return aggregate;
|
|
95
|
+
} catch {
|
|
96
|
+
return aggregate;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const out = await aggLoad();
|
|
100
|
+
aggFetchedAt = Date.now();
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
return { get, startAutoRefresh: () => {}, stopAutoRefresh: () => {} };
|
|
104
|
+
}
|
|
26
105
|
let cache = null;
|
|
27
106
|
let fetchedAt = 0;
|
|
28
107
|
let inflight = null;
|
|
29
108
|
let timer = null;
|
|
30
109
|
|
|
31
110
|
async function load() {
|
|
32
|
-
|
|
111
|
+
let data = await fetchUpstreamModels({ baseUrl, headers });
|
|
112
|
+
// 白名单过滤:opencode 亦支持 allowlist
|
|
113
|
+
try {
|
|
114
|
+
const { loadProviderAllowedModels } = await import("./state.js");
|
|
115
|
+
const allowed = loadProviderAllowedModels("opencode");
|
|
116
|
+
if (allowed.length) {
|
|
117
|
+
const allowedSet = new Set(allowed);
|
|
118
|
+
data = { ...data, data: (data.data || []).filter((m) => m && m.id && allowedSet.has(String(m.id).trim())) };
|
|
119
|
+
}
|
|
120
|
+
} catch {}
|
|
33
121
|
cache = data;
|
|
34
122
|
fetchedAt = Date.now();
|
|
35
123
|
if (cacheFile) persistModels(data, cacheFile);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { splitModelId, DEFAULT_PROVIDER, joinModelId } from "./model-id.js";
|
|
2
|
+
import { isModelAllowed, loadProviderAllowedModels } from "../state.js";
|
|
3
|
+
|
|
4
|
+
// 多供应商 dispatcher:把多个 Provider 聚合成一个 `upstream` 形状(chat/preheat/close),
|
|
5
|
+
// 按 body.model 的前缀路由到对应供应商,转发上游前剥掉前缀只发原始 id。
|
|
6
|
+
export function createProviderDispatcher(providers = []) {
|
|
7
|
+
const byId = new Map(providers.map((p) => [p.id, p]));
|
|
8
|
+
|
|
9
|
+
function resolve(model) {
|
|
10
|
+
const split = splitModelId(model, providers.map((p) => p.id));
|
|
11
|
+
return {
|
|
12
|
+
provider: byId.get(split.provider) ?? byId.get(DEFAULT_PROVIDER) ?? providers[0],
|
|
13
|
+
raw: split.raw,
|
|
14
|
+
prefixed: split.prefixed,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function chat(body, opts = {}) {
|
|
19
|
+
let { provider, raw } = resolve(body?.model);
|
|
20
|
+
if (!provider) throw new Error(`no provider for model ${body?.model ?? "(empty)"}`);
|
|
21
|
+
let workbuddyUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
|
|
22
|
+
// model 前缀钉死:workbuddy/<uid>:<rawId> -> 剥 uid,rawId 为真实模型
|
|
23
|
+
if (provider.id === "workbuddy" && typeof raw === "string" && raw.includes(":")) {
|
|
24
|
+
const idx = raw.indexOf(":");
|
|
25
|
+
const uidPart = raw.slice(0, idx).trim();
|
|
26
|
+
const modelPart = raw.slice(idx + 1).trim();
|
|
27
|
+
if (uidPart && modelPart) {
|
|
28
|
+
if (!workbuddyUid) workbuddyUid = uidPart;
|
|
29
|
+
raw = modelPart;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!isModelAllowed(provider.id, raw)) {
|
|
33
|
+
const allowed = loadProviderAllowedModels(provider.id);
|
|
34
|
+
const msg = `model not allowed for provider "${provider.id}": "${raw}" — allowed: ${allowed.join(", ") || "(none)"} (use: mslxdff -provider ${provider.id} allowlist add <model>)`;
|
|
35
|
+
return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-allowlist": "1" } });
|
|
36
|
+
}
|
|
37
|
+
const forwarded = raw === body?.model ? body : { ...body, model: raw };
|
|
38
|
+
// ADR-0008:本请求携带瞬时共享 key(shareKeys 由组员侧按 header 解析后传入)。
|
|
39
|
+
const sharedKeys = opts?.shareKeys?.[provider.id];
|
|
40
|
+
if (sharedKeys && sharedKeys.length && typeof provider.chatWithKeys === "function") {
|
|
41
|
+
return provider.chatWithKeys(forwarded, sharedKeys);
|
|
42
|
+
}
|
|
43
|
+
if (provider.id === "workbuddy" && workbuddyUid) {
|
|
44
|
+
return provider.chat(forwarded, { workbuddyUid });
|
|
45
|
+
}
|
|
46
|
+
return provider.chat(forwarded);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 聚合所有供应商的模型列表;默认供应商(opencode)裸 id,其它带前缀
|
|
50
|
+
// 若某供应商设置了 allowlist(非空),则仅暴露白名单内的模型(按 raw id 匹配)
|
|
51
|
+
async function listModels() {
|
|
52
|
+
const out = [];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const p of providers) {
|
|
55
|
+
let list;
|
|
56
|
+
try {
|
|
57
|
+
list = (await p.listModels?.()) ?? [];
|
|
58
|
+
} catch {
|
|
59
|
+
list = [];
|
|
60
|
+
}
|
|
61
|
+
const allowed = loadProviderAllowedModels(p.id);
|
|
62
|
+
const allowedSet = allowed.length ? new Set(allowed) : null;
|
|
63
|
+
for (const m of list) {
|
|
64
|
+
if (!m || !m.id) continue;
|
|
65
|
+
if (seen.has(m.id)) continue;
|
|
66
|
+
if (allowedSet) {
|
|
67
|
+
// m.id 已是带前缀的对外 id,需剥回 raw 再比对
|
|
68
|
+
const { raw } = splitModelId(m.id, providers.map((x) => x.id));
|
|
69
|
+
const rawNorm = String(raw || "").trim();
|
|
70
|
+
if (!allowedSet.has(rawNorm)) continue;
|
|
71
|
+
// 对于 opencode 裸 id,rawNorm 即 m.id 本身
|
|
72
|
+
}
|
|
73
|
+
seen.add(m.id);
|
|
74
|
+
out.push(m);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function preheat() {
|
|
81
|
+
const results = [];
|
|
82
|
+
for (const p of providers) {
|
|
83
|
+
if (typeof p.preheat !== "function") continue;
|
|
84
|
+
try {
|
|
85
|
+
results.push(await p.preheat());
|
|
86
|
+
} catch {
|
|
87
|
+
results.push({ ok: false, error: "preheat failed" });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return results.length ? results[0] : { ok: false, skipped: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function close() {
|
|
94
|
+
for (const p of providers) {
|
|
95
|
+
try { await p.close?.(); } catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { providers, byId, resolve, listModels, chat, preheat, close, joinModelId };
|
|
100
|
+
}
|