mslxdff 0.1.57 → 0.1.60
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 +427 -53
- package/package.json +2 -2
- package/src/auto.js +1 -1
- package/src/chat/prompt.js +2 -0
- package/src/chat/repl.js +62 -5
- package/src/chat/stats.js +83 -30
- package/src/chat/tools.js +3 -3
- package/src/free-watcher.js +62 -0
- package/src/providers/dispatcher.js +4 -1
- package/src/providers/model-id.js +98 -0
- package/src/routes/chat/index.js +9 -2
- package/src/routes/stream.js +58 -1
- package/src/state.js +83 -7
- package/src/sync-workbuddy.js +38 -6
package/src/routes/stream.js
CHANGED
|
@@ -61,6 +61,8 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
61
61
|
exitReason: null,
|
|
62
62
|
upstreamError: null,
|
|
63
63
|
downstreamClosed: false,
|
|
64
|
+
usage: null,
|
|
65
|
+
chars: 0,
|
|
64
66
|
};
|
|
65
67
|
let prevChunkAt = t0;
|
|
66
68
|
const onClose = () => {
|
|
@@ -125,6 +127,43 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
125
127
|
if (txt.includes("[DONE]")) detail.sawDone = true;
|
|
126
128
|
const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);
|
|
127
129
|
if (m) detail.sawFinishReason = m[1];
|
|
130
|
+
// 尝试提取 usage(流式末帧)
|
|
131
|
+
if (txt.includes("\"usage\"") || txt.includes("prompt_tokens")) {
|
|
132
|
+
try {
|
|
133
|
+
const lines = txt.split("\n");
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const t = line.trim();
|
|
136
|
+
if (!t.startsWith("data:")) continue;
|
|
137
|
+
const d = t.slice(5).trim();
|
|
138
|
+
if (d === "[DONE]" || !d) continue;
|
|
139
|
+
const j = JSON.parse(d);
|
|
140
|
+
if (j && j.usage && typeof j.usage === "object") {
|
|
141
|
+
const u = j.usage;
|
|
142
|
+
const pt = Number(u.prompt_tokens ?? u.promptTokens ?? u.input_tokens);
|
|
143
|
+
const ct = Number(u.completion_tokens ?? u.completionTokens ?? u.output_tokens);
|
|
144
|
+
const tt = Number(u.total_tokens ?? u.totalTokens);
|
|
145
|
+
const cur = {};
|
|
146
|
+
if (Number.isFinite(pt)) cur.prompt_tokens = pt;
|
|
147
|
+
if (Number.isFinite(ct)) cur.completion_tokens = ct;
|
|
148
|
+
if (Number.isFinite(tt)) cur.total_tokens = tt;
|
|
149
|
+
if (Object.keys(cur).length) detail.usage = cur;
|
|
150
|
+
// 兜底 chars:从 choices 文本长度累加
|
|
151
|
+
const delta = j.choices?.[0]?.delta?.content || j.choices?.[0]?.message?.content || "";
|
|
152
|
+
if (delta) detail.chars += String(delta).length;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {}
|
|
156
|
+
} else {
|
|
157
|
+
// 非 usage 的普通 delta 也累 chars
|
|
158
|
+
try {
|
|
159
|
+
const txt2 = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
160
|
+
const ms = txt2.match(/"content"\s*:\s*"([^"]*)"/g);
|
|
161
|
+
if (ms) for (const mm of ms) {
|
|
162
|
+
const c = JSON.parse(`{${mm}}`);
|
|
163
|
+
if (c.content) detail.chars += String(c.content).length;
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
128
167
|
} catch { /* ignore */ }
|
|
129
168
|
if (timedOut || stalled || tooLong) break;
|
|
130
169
|
if (first) {
|
|
@@ -190,14 +229,32 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
|
|
|
190
229
|
const text = await upRes.text();
|
|
191
230
|
detail.receivedBytes = Buffer.byteLength(text);
|
|
192
231
|
detail.exitReason = "normal-non-stream";
|
|
232
|
+
// 非流式 usage 与 chars 提取
|
|
193
233
|
try {
|
|
194
234
|
const parsed = JSON.parse(text);
|
|
235
|
+
const u = parsed.usage;
|
|
236
|
+
if (u && typeof u === "object") {
|
|
237
|
+
const pt = Number(u.prompt_tokens ?? u.promptTokens ?? u.input_tokens);
|
|
238
|
+
const ct = Number(u.completion_tokens ?? u.completionTokens ?? u.output_tokens);
|
|
239
|
+
const tt = Number(u.total_tokens ?? u.totalTokens);
|
|
240
|
+
const cur = {};
|
|
241
|
+
if (Number.isFinite(pt)) cur.prompt_tokens = pt;
|
|
242
|
+
if (Number.isFinite(ct)) cur.completion_tokens = ct;
|
|
243
|
+
if (Number.isFinite(tt)) cur.total_tokens = tt;
|
|
244
|
+
if (Object.keys(cur).length) detail.usage = cur;
|
|
245
|
+
if (parsed.choices?.[0]?.message?.content) detail.chars = String(parsed.choices[0].message.content).length;
|
|
246
|
+
else if (parsed.choices?.[0]?.text) detail.chars = String(parsed.choices[0].text).length;
|
|
247
|
+
}
|
|
195
248
|
const enriched = enrichNonStreamJson(parsed, fallback);
|
|
196
249
|
json(res, upRes.status, enriched);
|
|
197
250
|
} catch {
|
|
198
251
|
res.statusCode = upRes.status;
|
|
199
252
|
res.setHeader("Content-Type", contentType || "text/plain");
|
|
200
253
|
res.end(text);
|
|
254
|
+
// 纯文本时按长度估 chars
|
|
255
|
+
try { detail.chars = text.length; } catch {}
|
|
201
256
|
}
|
|
202
|
-
|
|
257
|
+
// 非流式 ttf 视为 total(一次性返回)
|
|
258
|
+
const totalMs = Math.round(performance.now() - t0);
|
|
259
|
+
return { status: upRes.status, ttfMs: totalMs, totalMs, aborted: false, interrupted: false, detail };
|
|
203
260
|
}
|
package/src/state.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, statSync, renameSync, unlinkSync, existsSync } from "node:fs";
|
|
3
3
|
import { writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import os from "node:os";
|
|
@@ -7,8 +7,30 @@ import os from "node:os";
|
|
|
7
7
|
export const DEFAULT_PORT = 8989;
|
|
8
8
|
|
|
9
9
|
export function defaultStateFile() {
|
|
10
|
-
return process.env.MSLXDFF_STATE_FILE
|
|
11
|
-
|
|
10
|
+
if (process.env.MSLXDFF_STATE_FILE) return process.env.MSLXDFF_STATE_FILE;
|
|
11
|
+
if (process.argv.includes("--test") || process.env.NODE_ENV === "test") {
|
|
12
|
+
return join(os.tmpdir(), "mslxdff-test-state.json");
|
|
13
|
+
}
|
|
14
|
+
return join(os.homedir(), ".config", "mslxdff", "state.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function tokenFile(file) {
|
|
18
|
+
if (process.env.MSLXDFF_TOKEN_FILE) return process.env.MSLXDFF_TOKEN_FILE;
|
|
19
|
+
const sf = file || defaultStateFile();
|
|
20
|
+
const realDefault = join(os.homedir(), ".config", "mslxdff", "state.json");
|
|
21
|
+
if (sf !== realDefault) return join(dirname(sf), "token");
|
|
22
|
+
if (process.argv.includes("--test") || process.env.NODE_ENV === "test") {
|
|
23
|
+
return join(os.tmpdir(), "mslxdff-test-token");
|
|
24
|
+
}
|
|
25
|
+
return join(os.homedir(), ".config", "mslxdff", "token");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function syncTokenFile(token, file) {
|
|
29
|
+
try {
|
|
30
|
+
const tf = tokenFile(file);
|
|
31
|
+
mkdirSync(dirname(tf), { recursive: true });
|
|
32
|
+
writeFileSync(tf, String(token || "").trim() + "\n", "utf8");
|
|
33
|
+
} catch {}
|
|
12
34
|
}
|
|
13
35
|
|
|
14
36
|
export function generateToken() {
|
|
@@ -93,7 +115,14 @@ function writeStateImmediate(file, patch) {
|
|
|
93
115
|
e.timer = null;
|
|
94
116
|
}
|
|
95
117
|
mkdirSync(dirname(file), { recursive: true });
|
|
96
|
-
|
|
118
|
+
const tmp = `${file}.tmp.${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
119
|
+
writeFileSync(tmp, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
120
|
+
try {
|
|
121
|
+
renameSync(tmp, file);
|
|
122
|
+
} catch {
|
|
123
|
+
try { writeFileSync(file, JSON.stringify(merged, null, 2), { mode: 0o600 }); } catch {}
|
|
124
|
+
try { unlinkSync(tmp); } catch {}
|
|
125
|
+
}
|
|
97
126
|
try {
|
|
98
127
|
e.mtimeMs = statSync(file).mtimeMs;
|
|
99
128
|
} catch {
|
|
@@ -174,13 +203,31 @@ export function clearStateCache(file) {
|
|
|
174
203
|
export async function loadToken({ file = defaultStateFile() } = {}) {
|
|
175
204
|
const state = readState(file);
|
|
176
205
|
if (typeof state.token === "string" && state.token.length > 0) {
|
|
206
|
+
syncTokenFile(state.token, file);
|
|
177
207
|
return { token: state.token, created: false };
|
|
178
208
|
}
|
|
179
|
-
|
|
209
|
+
if (existsSync(file)) {
|
|
210
|
+
try {
|
|
211
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
212
|
+
if (typeof raw.token === "string" && raw.token.length > 0) {
|
|
213
|
+
syncTokenFile(raw.token, file);
|
|
214
|
+
const e = getEntry(file);
|
|
215
|
+
e.data = raw;
|
|
216
|
+
try { e.mtimeMs = statSync(file).mtimeMs; } catch {}
|
|
217
|
+
return { token: raw.token, created: false };
|
|
218
|
+
}
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
const tok = generateToken();
|
|
222
|
+
const saved = writeStateImmediate(file, { token: tok, createdAt: new Date().toISOString() }).token;
|
|
223
|
+
syncTokenFile(saved, file);
|
|
224
|
+
return { token: saved, created: true };
|
|
180
225
|
}
|
|
181
226
|
|
|
182
227
|
export async function refreshToken({ file = defaultStateFile() } = {}) {
|
|
183
|
-
|
|
228
|
+
const tok = writeStateImmediate(file, { token: generateToken(), createdAt: new Date().toISOString() }).token;
|
|
229
|
+
syncTokenFile(tok, file);
|
|
230
|
+
return tok;
|
|
184
231
|
}
|
|
185
232
|
|
|
186
233
|
export function setPort(port, { file = defaultStateFile() } = {}) {
|
|
@@ -212,6 +259,16 @@ export function saveModelLatencies(latencies, { file = defaultStateFile() } = {}
|
|
|
212
259
|
return latencies;
|
|
213
260
|
}
|
|
214
261
|
|
|
262
|
+
export function loadModelStats({ file = defaultStateFile() } = {}) {
|
|
263
|
+
const s = readState(file).modelStats;
|
|
264
|
+
return s && typeof s === "object" && !Array.isArray(s) ? s : {};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function saveModelStats(stats, { file = defaultStateFile() } = {}) {
|
|
268
|
+
writeStateDeferred(file, { modelStats: stats });
|
|
269
|
+
return stats;
|
|
270
|
+
}
|
|
271
|
+
|
|
215
272
|
export function loadPreferredModel({ file = defaultStateFile() } = {}) {
|
|
216
273
|
const v = readState(file).preferredModel;
|
|
217
274
|
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
@@ -487,11 +544,30 @@ export function removeProviderAllowedModels(id, targets = [], opts = {}) {
|
|
|
487
544
|
return saveProviderAllowedModels(id, next, opts);
|
|
488
545
|
}
|
|
489
546
|
|
|
547
|
+
function normalizeAllowAny(v, id) {
|
|
548
|
+
if (typeof v === "boolean") return v;
|
|
549
|
+
if (String(id || "").toLowerCase() === "opencode") return true;
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
export function loadProviderAllowAnyModels(id, { file = defaultStateFile() } = {}) {
|
|
553
|
+
const cfg = readState(file).providerConfigs?.[id];
|
|
554
|
+
if (cfg && typeof cfg.allowAnyModels === "boolean") return cfg.allowAnyModels;
|
|
555
|
+
return normalizeAllowAny(undefined, id);
|
|
556
|
+
}
|
|
557
|
+
export function saveProviderAllowAnyModels(id, allowAny, { file = defaultStateFile() } = {}) {
|
|
558
|
+
const state = readState(file);
|
|
559
|
+
const cfgs = { ...(state.providerConfigs || {}) };
|
|
560
|
+
const cur = cfgs[id] || {};
|
|
561
|
+
cfgs[id] = { ...cur, allowAnyModels: normalizeAllowAny(allowAny, id) };
|
|
562
|
+
writeStateImmediate(file, { providerConfigs: cfgs });
|
|
563
|
+
return cfgs[id].allowAnyModels;
|
|
564
|
+
}
|
|
490
565
|
export function isModelAllowed(id, rawModel, { file = defaultStateFile() } = {}) {
|
|
491
566
|
const raw = String(rawModel || "").trim();
|
|
492
567
|
if (!raw) return true;
|
|
493
568
|
const allowed = loadProviderAllowedModels(id, { file });
|
|
494
|
-
|
|
569
|
+
const allowAny = loadProviderAllowAnyModels(id, { file });
|
|
570
|
+
if (!allowed.length) return allowAny;
|
|
495
571
|
const norm = normalizeAllowedModel(raw, id);
|
|
496
572
|
return allowed.includes(norm);
|
|
497
573
|
}
|
package/src/sync-workbuddy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import { loadModelAliases, registerModelAlias, persistModelAliases } from "./providers/model-id.js";
|
|
4
5
|
|
|
5
6
|
export function workbuddyModelsPath() {
|
|
6
7
|
const env = process.env.WORKBUDDY_MODELS_FILE || process.env.WORKBUDDY_MODELS_PATH;
|
|
@@ -8,11 +9,17 @@ export function workbuddyModelsPath() {
|
|
|
8
9
|
return join(os.homedir(), ".workbuddy", "models.json");
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
// WorkBuddy 不支持模型 ID 中的 /,写入时替换为 -
|
|
13
|
+
// mslxdff 需保留原始 / 格式用于路由,所以同时存原始 id
|
|
14
|
+
const toWorkbuddyId = (id) => String(id).replace(/\//g, "-");
|
|
15
|
+
|
|
11
16
|
export function buildWorkbuddyEntry({ id, token, port }) {
|
|
12
17
|
const p = Number(port) || 8989;
|
|
18
|
+
const originalId = String(id);
|
|
19
|
+
const wbId = toWorkbuddyId(originalId);
|
|
13
20
|
return {
|
|
14
|
-
id:
|
|
15
|
-
name:
|
|
21
|
+
id: wbId,
|
|
22
|
+
name: wbId,
|
|
16
23
|
vendor: "Custom",
|
|
17
24
|
url: `http://127.0.0.1:${p}/v1/chat/completions`,
|
|
18
25
|
apiKey: String(token || ""),
|
|
@@ -30,8 +37,13 @@ export function isLocalUrl(url) {
|
|
|
30
37
|
|
|
31
38
|
function isTargetEntry(m, id) {
|
|
32
39
|
if (!m || typeof m !== "object") return false;
|
|
33
|
-
if (m.
|
|
34
|
-
|
|
40
|
+
if (!isLocalUrl(m.url)) return false;
|
|
41
|
+
// 匹配原始 id(/ 格式)和 WorkBuddy id(- 格式)
|
|
42
|
+
if (m.id === id) return true;
|
|
43
|
+
if (m.id === toWorkbuddyId(id)) return true;
|
|
44
|
+
// 兜底:匹配 _mslxdffOriginalId 字段
|
|
45
|
+
if (m._mslxdffOriginalId === id) return true;
|
|
46
|
+
return false;
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
export async function syncToWorkbuddy({ id, token, port, file } = {}) {
|
|
@@ -66,11 +78,19 @@ export async function syncToWorkbuddy({ id, token, port, file } = {}) {
|
|
|
66
78
|
|
|
67
79
|
// find exact target (id + local url, any port -> will update port)
|
|
68
80
|
let idx = arr.findIndex((m) => isTargetEntry(m, cleanId));
|
|
81
|
+
const wbId = toWorkbuddyId(cleanId);
|
|
69
82
|
let action;
|
|
70
83
|
if (idx >= 0) {
|
|
71
84
|
// update token/url/name, preserve other fields
|
|
72
85
|
const old = arr[idx];
|
|
73
|
-
|
|
86
|
+
const { _mslxdffOriginalId, ...oldClean } = old;
|
|
87
|
+
arr[idx] = {
|
|
88
|
+
...oldClean,
|
|
89
|
+
id: wbId,
|
|
90
|
+
name: wbId,
|
|
91
|
+
url: `http://127.0.0.1:${p}/v1/chat/completions`,
|
|
92
|
+
apiKey: cleanToken,
|
|
93
|
+
};
|
|
74
94
|
if (!arr[idx].vendor) arr[idx].vendor = "Custom";
|
|
75
95
|
if (arr[idx].supportsToolCall === undefined) arr[idx].supportsToolCall = true;
|
|
76
96
|
if (arr[idx].supportsImages === undefined) arr[idx].supportsImages = true;
|
|
@@ -78,7 +98,12 @@ export async function syncToWorkbuddy({ id, token, port, file } = {}) {
|
|
|
78
98
|
if (arr[idx].useCustomProtocol === undefined) arr[idx].useCustomProtocol = false;
|
|
79
99
|
action = "updated";
|
|
80
100
|
} else {
|
|
81
|
-
//
|
|
101
|
+
// 检查归一化 id 是否与已有条目冲突(如已存在 clinebot-z-ai-glm-5.3-flash 但不是本地 URL)
|
|
102
|
+
const conflict = arr.findIndex((m) => m && m.id === wbId);
|
|
103
|
+
if (conflict >= 0) {
|
|
104
|
+
// 已有同名条目且非我们写的,跳过不覆盖
|
|
105
|
+
return { action: "skipped-conflict", file: targetFile, id: cleanId, conflictId: arr[conflict].id, corrupted: false };
|
|
106
|
+
}
|
|
82
107
|
arr.push(buildWorkbuddyEntry({ id: cleanId, token: cleanToken, port: p }));
|
|
83
108
|
action = "inserted";
|
|
84
109
|
}
|
|
@@ -100,5 +125,12 @@ export async function syncToWorkbuddy({ id, token, port, file } = {}) {
|
|
|
100
125
|
// cleanup tmp if still exists
|
|
101
126
|
try { if (existsSync(tmp)) { const { unlinkSync } = await import("node:fs"); unlinkSync(tmp); } } catch {}
|
|
102
127
|
|
|
128
|
+
// 注册 WorkBuddy 别名:/ → -(仅原始 id 含 / 时)
|
|
129
|
+
if (cleanId.includes("/")) {
|
|
130
|
+
loadModelAliases();
|
|
131
|
+
registerModelAlias(wbId, cleanId);
|
|
132
|
+
persistModelAliases();
|
|
133
|
+
}
|
|
134
|
+
|
|
103
135
|
return { action, file: targetFile, id: cleanId, corrupted };
|
|
104
136
|
}
|