mslxdff 0.1.57 → 0.1.62

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.
@@ -1,10 +1,77 @@
1
1
  // 模型 ID 前缀解析:`<provider>/<rawId>`。默认供应商(opencode)保持裸 id(向后兼容),
2
2
  // 其它供应商必须带前缀,请求按前缀路由到对应上游通道。
3
+ //
4
+ // WorkBuddy 别名系统:
5
+ // WorkBuddy 不支持模型 ID 中的 /,写入 models.json 时替换为 -
6
+ // mslxdff 在收到请求时通过固定映射表还原为 / 格式
7
+ // 映射表持久化在 ~/.config/mslxdff/model-aliases.json
8
+
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import os from "node:os";
3
12
 
4
13
  export const DEFAULT_PROVIDER = "opencode";
5
14
 
6
15
  const ALIASES = { oc: "opencode", opencode: "opencode" };
7
16
 
17
+ // WorkBuddy 别名映射(alias → canonical):clinebot-z-ai-glm-5.3-flash → clinebot/z-ai/glm-5.3-flash
18
+ const _modelAliases = new Map();
19
+
20
+ function aliasFile() {
21
+ const env = process.env.MSLXDFF_ALIASES_FILE;
22
+ if (typeof env === "string" && env.trim()) return env.trim();
23
+ return join(os.homedir(), ".config", "mslxdff", "model-aliases.json");
24
+ }
25
+
26
+ export function loadModelAliases(file) {
27
+ const fp = file || aliasFile();
28
+ try {
29
+ const raw = JSON.parse(readFileSync(fp, "utf8"));
30
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
31
+ for (const [k, v] of Object.entries(raw)) {
32
+ if (typeof v === "string" && v) _modelAliases.set(String(k), v);
33
+ }
34
+ _aliasLoaded = true;
35
+ }
36
+ } catch {}
37
+ }
38
+
39
+ export function registerModelAlias(alias, canonical) {
40
+ const a = String(alias || "").trim();
41
+ const c = String(canonical || "").trim();
42
+ if (!a || !c || a === c) return;
43
+ _modelAliases.set(a, c);
44
+ }
45
+
46
+ export function persistModelAliases(file) {
47
+ const fp = file || aliasFile();
48
+ try {
49
+ mkdirSync(dirname(fp), { recursive: true });
50
+ const obj = Object.fromEntries(_modelAliases);
51
+ writeFileSync(fp, JSON.stringify(obj, null, 2), "utf8");
52
+ } catch {}
53
+ }
54
+
55
+ let _aliasLoaded = false;
56
+ export function getModelAlias(id) {
57
+ const key = String(id || "").trim();
58
+ let hit = _modelAliases.get(key);
59
+ if (hit) return hit;
60
+ // 未命中时尝试加载(支持 daemon 运行期间由 -setto 进程写入的新 alias)
61
+ try { loadModelAliases(); } catch {}
62
+ return _modelAliases.get(key) || null;
63
+ }
64
+
65
+ // 反向查询:canonical id → alias(如 clinebot/z-ai/glm-5.3-flash → clinebot-z-ai-glm-5.3-flash)
66
+ export function getAliasForModel(canonicalId) {
67
+ const c = String(canonicalId || "").trim();
68
+ if (!_aliasLoaded) { try { loadModelAliases(); } catch {} }
69
+ for (const [alias, target] of _modelAliases) {
70
+ if (target === c) return alias;
71
+ }
72
+ return null;
73
+ }
74
+
8
75
  export function normalizeProviderId(p) {
9
76
  const id = String(p || "").trim();
10
77
  return ALIASES[id] || id;
@@ -33,4 +100,35 @@ export function joinModelId(provider, raw, { force = false } = {}) {
33
100
  raw = String(raw || "");
34
101
  if (provider === DEFAULT_PROVIDER && !force) return raw;
35
102
  return `${provider}/${raw}`;
103
+ }
104
+
105
+ // 观测用全称:始终带前缀(opencode 也带),用于 modelStats / calls.log 聚合主键
106
+ export function toFullId(provider, raw) {
107
+ provider = normalizeProviderId(provider || "");
108
+ raw = String(raw || "").trim();
109
+ if (!raw) return "";
110
+ if (!provider || provider === DEFAULT_PROVIDER) return `${DEFAULT_PROVIDER}/${raw}`;
111
+ return `${provider}/${raw}`;
112
+ }
113
+
114
+ // 归一任意输入为全称:裸 id -> opencode/xxx,已带前缀保持不变(大小写保留,provider 归一)
115
+ // 优先查 WorkBuddy 别名表(固定映射),命中直接还原为 / 格式
116
+ export function normalizeFullId(id, knownProviders = []) {
117
+ const s = String(id || "").trim();
118
+ if (!s) return "";
119
+ // 1. 查别名表(clinebot-z-ai-glm-5.3-flash → clinebot/z-ai/glm-5.3-flash)
120
+ const aliased = getModelAlias(s);
121
+ if (aliased) return aliased;
122
+ // 2. 已含 /,走原有逻辑
123
+ const idx = s.indexOf("/");
124
+ if (idx > 0) {
125
+ const head = normalizeProviderId(s.slice(0, idx));
126
+ if (knownProviders.length) {
127
+ const known = new Set([DEFAULT_PROVIDER, ...knownProviders.map(normalizeProviderId)]);
128
+ if (known.has(head)) return `${head}/${s.slice(idx + 1)}`;
129
+ return `${DEFAULT_PROVIDER}/${s}`;
130
+ }
131
+ return `${head}/${s.slice(idx + 1)}`;
132
+ }
133
+ return `${DEFAULT_PROVIDER}/${s}`;
36
134
  }
@@ -3,6 +3,7 @@ import { createKeyRing } from "./keyring.js";
3
3
  import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL } from "../state.js";
4
4
  import { writeFileSync, existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, statSync } from "node:fs";
5
5
  import { join, dirname } from "node:path";
6
+ import { tmpdir } from "node:os";
6
7
  import { getCachedBalance, setCachedBalance } from "./workbuddy-balance.js";
7
8
 
8
9
  let UndiciAgent = null;
@@ -17,6 +18,14 @@ function envInt(name, fallback) {
17
18
  const v = Number(process.env[name]);
18
19
  return Number.isInteger(v) && v > 0 ? v : fallback;
19
20
  }
21
+ function isTestEnv() {
22
+ if (process.env.NODE_ENV === "test") return true;
23
+ if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
24
+ if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
25
+ if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
26
+ if (process.env.NODE_TEST_CONTEXT) return true;
27
+ return false;
28
+ }
20
29
 
21
30
  function resolveBaseUrl(baseUrl) {
22
31
  if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
@@ -143,7 +152,7 @@ export function createWorkbuddyProvider({
143
152
  // fallback scan auths/workbuddy-*.json if still empty
144
153
  if (!authList.length && !keys.length) {
145
154
  try {
146
- const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
155
+ const authDir = process.env.WORKBUDDY_AUTH_DIR || (isTestEnv() ? join(tmpdir(), "mslxdff-test-auths") : (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths")));
147
156
  if (existsSync(authDir)) {
148
157
  const files = readdirSync(authDir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
149
158
  for (const f of files) {
@@ -223,7 +232,7 @@ export function createWorkbuddyProvider({
223
232
  saveProviderConfig(id, { baseUrl: resolvedBase, keys: [...keys], auths: [...authList] }, file ? { file } : {});
224
233
  } catch {}
225
234
  try {
226
- const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
235
+ const authDir = process.env.WORKBUDDY_AUTH_DIR || (isTestEnv() ? join(tmpdir(), "mslxdff-test-auths") : (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths")));
227
236
  mkdirSync(authDir, { recursive: true });
228
237
  const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now()/1000)+5184000; } })();
229
238
  const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
@@ -11,6 +11,7 @@ import { handleLocalRelay } from "./local-handler.js";
11
11
  import { handlePeerRelay } from "./peer-handler.js";
12
12
  import { handleBroadbandRelay } from "./broadband-handler.js";
13
13
  import { handleExhaustedLocal, handleExhaustedAll } from "./exhausted-handler.js";
14
+ import { normalizeFullId, getModelAlias } from "../../providers/model-id.js";
14
15
 
15
16
  export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHops, groups, bus, token, plugins }) {
16
17
  let body;
@@ -40,7 +41,13 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
40
41
  const workbuddyUid = (req.headers["x-mslxdff-workbuddy-uid"] || req.headers["x-workbuddy-uid"] || "").toString().trim();
41
42
  const lockModel = req.headers["x-mslxdff-model-lock"] || "";
42
43
  const rawModel = body.model || "";
43
- const normalizedRequested = normalizeModel(lockModel || rawModel || "");
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
+ }
44
51
  // alias 还原:mslxdff-deepseek -> deepseek(原名仍兼容,双向支持 mslxdff/mslxdff-deepseek 与裸 mslxdff-deepseek/裸 deepseek)
45
52
  let requested = normalizedRequested;
46
53
  let aliasInfo = null;
@@ -118,7 +125,7 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
118
125
  for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
119
126
  }
120
127
 
121
- const handlerCtx = { reqId, model: null, body, hops, peers, plugins, evt, logError, logCall };
128
+ const handlerCtx = { reqId, model: null, body, hops, peers, plugins, evt, logError, logCall, logs };
122
129
 
123
130
  let lastErr = null;
124
131
  for (let idx = 0; idx < order.length; idx++) {
@@ -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,14 +1,46 @@
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";
6
6
 
7
7
  export const DEFAULT_PORT = 8989;
8
8
 
9
+ function isTestEnv() {
10
+ if (process.env.NODE_ENV === "test") return true;
11
+ if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
12
+ if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
13
+ if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
14
+ // Node test runner sets this
15
+ if (process.env.NODE_TEST_CONTEXT) return true;
16
+ return false;
17
+ }
18
+
9
19
  export function defaultStateFile() {
10
- return process.env.MSLXDFF_STATE_FILE ||
11
- join(os.homedir(), ".config", "mslxdff", "state.json");
20
+ if (process.env.MSLXDFF_STATE_FILE) return process.env.MSLXDFF_STATE_FILE;
21
+ if (isTestEnv()) {
22
+ return join(os.tmpdir(), "mslxdff-test-state.json");
23
+ }
24
+ return join(os.homedir(), ".config", "mslxdff", "state.json");
25
+ }
26
+
27
+ export function tokenFile(file) {
28
+ if (process.env.MSLXDFF_TOKEN_FILE) return process.env.MSLXDFF_TOKEN_FILE;
29
+ const sf = file || defaultStateFile();
30
+ const realDefault = join(os.homedir(), ".config", "mslxdff", "state.json");
31
+ if (sf !== realDefault) return join(dirname(sf), "token");
32
+ if (isTestEnv()) {
33
+ return join(os.tmpdir(), "mslxdff-test-token");
34
+ }
35
+ return join(os.homedir(), ".config", "mslxdff", "token");
36
+ }
37
+
38
+ function syncTokenFile(token, file) {
39
+ try {
40
+ const tf = tokenFile(file);
41
+ mkdirSync(dirname(tf), { recursive: true });
42
+ writeFileSync(tf, String(token || "").trim() + "\n", "utf8");
43
+ } catch {}
12
44
  }
13
45
 
14
46
  export function generateToken() {
@@ -93,7 +125,14 @@ function writeStateImmediate(file, patch) {
93
125
  e.timer = null;
94
126
  }
95
127
  mkdirSync(dirname(file), { recursive: true });
96
- writeFileSync(file, JSON.stringify(merged, null, 2), { mode: 0o600 });
128
+ const tmp = `${file}.tmp.${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
129
+ writeFileSync(tmp, JSON.stringify(merged, null, 2), { mode: 0o600 });
130
+ try {
131
+ renameSync(tmp, file);
132
+ } catch {
133
+ try { writeFileSync(file, JSON.stringify(merged, null, 2), { mode: 0o600 }); } catch {}
134
+ try { unlinkSync(tmp); } catch {}
135
+ }
97
136
  try {
98
137
  e.mtimeMs = statSync(file).mtimeMs;
99
138
  } catch {
@@ -174,13 +213,31 @@ export function clearStateCache(file) {
174
213
  export async function loadToken({ file = defaultStateFile() } = {}) {
175
214
  const state = readState(file);
176
215
  if (typeof state.token === "string" && state.token.length > 0) {
216
+ syncTokenFile(state.token, file);
177
217
  return { token: state.token, created: false };
178
218
  }
179
- return { token: writeStateImmediate(file, { token: generateToken(), createdAt: new Date().toISOString() }).token, created: true };
219
+ if (existsSync(file)) {
220
+ try {
221
+ const raw = JSON.parse(readFileSync(file, "utf8"));
222
+ if (typeof raw.token === "string" && raw.token.length > 0) {
223
+ syncTokenFile(raw.token, file);
224
+ const e = getEntry(file);
225
+ e.data = raw;
226
+ try { e.mtimeMs = statSync(file).mtimeMs; } catch {}
227
+ return { token: raw.token, created: false };
228
+ }
229
+ } catch {}
230
+ }
231
+ const tok = generateToken();
232
+ const saved = writeStateImmediate(file, { token: tok, createdAt: new Date().toISOString() }).token;
233
+ syncTokenFile(saved, file);
234
+ return { token: saved, created: true };
180
235
  }
181
236
 
182
237
  export async function refreshToken({ file = defaultStateFile() } = {}) {
183
- return writeStateImmediate(file, { token: generateToken(), createdAt: new Date().toISOString() }).token;
238
+ const tok = writeStateImmediate(file, { token: generateToken(), createdAt: new Date().toISOString() }).token;
239
+ syncTokenFile(tok, file);
240
+ return tok;
184
241
  }
185
242
 
186
243
  export function setPort(port, { file = defaultStateFile() } = {}) {
@@ -212,6 +269,16 @@ export function saveModelLatencies(latencies, { file = defaultStateFile() } = {}
212
269
  return latencies;
213
270
  }
214
271
 
272
+ export function loadModelStats({ file = defaultStateFile() } = {}) {
273
+ const s = readState(file).modelStats;
274
+ return s && typeof s === "object" && !Array.isArray(s) ? s : {};
275
+ }
276
+
277
+ export function saveModelStats(stats, { file = defaultStateFile() } = {}) {
278
+ writeStateDeferred(file, { modelStats: stats });
279
+ return stats;
280
+ }
281
+
215
282
  export function loadPreferredModel({ file = defaultStateFile() } = {}) {
216
283
  const v = readState(file).preferredModel;
217
284
  return typeof v === "string" && v.trim() ? v.trim() : null;
@@ -487,11 +554,30 @@ export function removeProviderAllowedModels(id, targets = [], opts = {}) {
487
554
  return saveProviderAllowedModels(id, next, opts);
488
555
  }
489
556
 
557
+ function normalizeAllowAny(v, id) {
558
+ if (typeof v === "boolean") return v;
559
+ if (String(id || "").toLowerCase() === "opencode") return true;
560
+ return false;
561
+ }
562
+ export function loadProviderAllowAnyModels(id, { file = defaultStateFile() } = {}) {
563
+ const cfg = readState(file).providerConfigs?.[id];
564
+ if (cfg && typeof cfg.allowAnyModels === "boolean") return cfg.allowAnyModels;
565
+ return normalizeAllowAny(undefined, id);
566
+ }
567
+ export function saveProviderAllowAnyModels(id, allowAny, { file = defaultStateFile() } = {}) {
568
+ const state = readState(file);
569
+ const cfgs = { ...(state.providerConfigs || {}) };
570
+ const cur = cfgs[id] || {};
571
+ cfgs[id] = { ...cur, allowAnyModels: normalizeAllowAny(allowAny, id) };
572
+ writeStateImmediate(file, { providerConfigs: cfgs });
573
+ return cfgs[id].allowAnyModels;
574
+ }
490
575
  export function isModelAllowed(id, rawModel, { file = defaultStateFile() } = {}) {
491
576
  const raw = String(rawModel || "").trim();
492
577
  if (!raw) return true;
493
578
  const allowed = loadProviderAllowedModels(id, { file });
494
- if (!allowed.length) return true;
579
+ const allowAny = loadProviderAllowAnyModels(id, { file });
580
+ if (!allowed.length) return allowAny;
495
581
  const norm = normalizeAllowedModel(raw, id);
496
582
  return allowed.includes(norm);
497
583
  }
@@ -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
  }