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.
@@ -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
- return { status: upRes.status, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted: false, detail };
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
- join(os.homedir(), ".config", "mslxdff", "state.json");
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
- writeFileSync(file, JSON.stringify(merged, null, 2), { mode: 0o600 });
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
- return { token: writeStateImmediate(file, { token: generateToken(), createdAt: new Date().toISOString() }).token, created: true };
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
- return writeStateImmediate(file, { token: generateToken(), createdAt: new Date().toISOString() }).token;
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
- if (!allowed.length) return true;
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
  }
@@ -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: String(id),
15
- name: String(id),
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.id !== id) return false;
34
- return isLocalUrl(m.url);
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
- arr[idx] = { ...old, id: cleanId, name: cleanId, url: `http://127.0.0.1:${p}/v1/chat/completions`, apiKey: cleanToken };
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
- // ensure existence: no reuse, always insert new entry (preserves other local entries like x-preview)
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
  }