mslxdff 0.1.56 → 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.
@@ -1,6 +1,7 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { injectReasoningContent, normalizeModel } from "../../reasoning.js";
3
3
  import { isAutoModel } from "../../auto.js";
4
+ import { toInternalId as aliasToInternal } from "../../sync-opencode.js";
4
5
  import { clientIp, json, readBody, parseHops, summarizePrompt, errMsg } from "../helpers.js";
5
6
  import { hedgeDelayMs, shouldHedge } from "../hedge.js";
6
7
  import { runHook } from "../../plugins.js";
@@ -10,6 +11,7 @@ import { handleLocalRelay } from "./local-handler.js";
10
11
  import { handlePeerRelay } from "./peer-handler.js";
11
12
  import { handleBroadbandRelay } from "./broadband-handler.js";
12
13
  import { handleExhaustedLocal, handleExhaustedAll } from "./exhausted-handler.js";
14
+ import { normalizeFullId, getModelAlias } from "../../providers/model-id.js";
13
15
 
14
16
  export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHops, groups, bus, token, plugins }) {
15
17
  let body;
@@ -39,9 +41,48 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
39
41
  const workbuddyUid = (req.headers["x-mslxdff-workbuddy-uid"] || req.headers["x-workbuddy-uid"] || "").toString().trim();
40
42
  const lockModel = req.headers["x-mslxdff-model-lock"] || "";
41
43
  const rawModel = body.model || "";
42
- const requested = 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
+ }
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
+ }
43
80
  const useAuto = isAutoModel(requested);
44
81
  mark("parsed");
82
+ if (aliasInfo) {
83
+ // 供日志与 header 透传
84
+ try { res.setHeader("x-mslxdff-alias", aliasInfo); } catch {}
85
+ }
45
86
 
46
87
  let order;
47
88
  if (lockModel) {
@@ -70,6 +111,7 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
70
111
  runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
71
112
  };
72
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 });
73
115
  if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
74
116
  evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
75
117
 
@@ -83,7 +125,7 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
83
125
  for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
84
126
  }
85
127
 
86
- 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 };
87
129
 
88
130
  let lastErr = null;
89
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,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
  }
@@ -0,0 +1,152 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import os from "node:os";
4
+
5
+ export function opencodeConfigPath() {
6
+ const env = process.env.OPENCODE_CONFIG || process.env.OPENCODE_CONFIG_PATH;
7
+ if (typeof env === "string" && env.trim()) return env.trim();
8
+ return join(os.homedir(), ".config", "opencode", "opencode.json");
9
+ }
10
+
11
+ export function toExternalAlias(id) {
12
+ const s = String(id || "").trim();
13
+ if (!s) return "";
14
+ return s.startsWith("mslxdff-") ? s : `mslxdff-${s}`;
15
+ }
16
+
17
+ export function toInternalId(aliasOrRaw) {
18
+ const s = String(aliasOrRaw || "").trim();
19
+ if (!s) return "";
20
+ return s.startsWith("mslxdff-") ? s.slice("mslxdff-".length) : s;
21
+ }
22
+
23
+ export function buildOpencodeProvider({ id, token, port }) {
24
+ const p = Number(port) || 8989;
25
+ const alias = toExternalAlias(id);
26
+ return {
27
+ name: "mslxdff",
28
+ npm: "@ai-sdk/openai-compatible",
29
+ options: {
30
+ apiKey: String(token || ""),
31
+ baseURL: `http://127.0.0.1:${p}/v1`,
32
+ },
33
+ models: {
34
+ [alias]: { name: alias },
35
+ },
36
+ };
37
+ }
38
+
39
+ export function isOpencodeLocalUrl(url) {
40
+ const u = String(url || "");
41
+ return u.includes("127.0.0.1") && u.includes("/v1");
42
+ }
43
+
44
+ export async function syncToOpencode({ id, token, port, file } = {}) {
45
+ const targetFile = file || opencodeConfigPath();
46
+ // id 可能是 alias 或原名,统一以 internal 去重、以 external 入库(原名兼容)
47
+ const normalizedRaw = String(id || "").trim();
48
+ if (!normalizedRaw) throw new Error("model id required");
49
+ const internal = toInternalId(normalizedRaw);
50
+ if (!internal) throw new Error("model id required");
51
+ const external = toExternalAlias(internal);
52
+ const cleanToken = String(token || "");
53
+ const p = Number(port) || 8989;
54
+
55
+ let data = null;
56
+ let corrupted = false;
57
+ let rawText = null;
58
+ try {
59
+ rawText = readFileSync(targetFile, "utf8");
60
+ const parsed = JSON.parse(rawText);
61
+ data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
62
+ } catch (err) {
63
+ if (rawText !== null) {
64
+ corrupted = true;
65
+ try {
66
+ mkdirSync(dirname(targetFile), { recursive: true });
67
+ writeFileSync(targetFile + ".bak", rawText ?? "", "utf8");
68
+ } catch {}
69
+ data = {};
70
+ } else {
71
+ data = {};
72
+ }
73
+ }
74
+
75
+ if (!data.provider || typeof data.provider !== "object" || Array.isArray(data.provider)) {
76
+ data.provider = {};
77
+ }
78
+
79
+ const oldProvider = data.provider.mslxdff && typeof data.provider.mslxdff === "object" && !Array.isArray(data.provider.mslxdff)
80
+ ? data.provider.mslxdff
81
+ : null;
82
+
83
+ let action;
84
+ let effectiveId = external;
85
+ if (oldProvider) {
86
+ const oldModels = oldProvider.models && typeof oldProvider.models === "object" && !Array.isArray(oldProvider.models)
87
+ ? oldProvider.models
88
+ : {};
89
+ // 去重:internal 或 external 任一已存在即视为已存在(原名兼容,以 internal 为基准)
90
+ const hasExternal = Object.prototype.hasOwnProperty.call(oldModels, external);
91
+ const hasInternal = Object.prototype.hasOwnProperty.call(oldModels, internal);
92
+ const exists = hasExternal || hasInternal;
93
+ const nextModels = { ...oldModels };
94
+ if (exists) {
95
+ if (hasExternal) {
96
+ nextModels[external] = { name: external, ...(oldModels[external] && typeof oldModels[external] === "object" ? oldModels[external] : {}), name: external };
97
+ effectiveId = external;
98
+ } else if (hasInternal) {
99
+ // 仅原名存在:保留原名不强制迁移为 alias,视为 updated(原名兼容)
100
+ nextModels[internal] = { name: internal, ...(oldModels[internal] && typeof oldModels[internal] === "object" ? oldModels[internal] : {}), name: internal };
101
+ effectiveId = internal;
102
+ }
103
+ action = "updated";
104
+ } else {
105
+ nextModels[external] = { name: external };
106
+ effectiveId = external;
107
+ action = "inserted";
108
+ }
109
+ // 合并 provider:保留 name/npm,覆盖 options.baseURL/apiKey,合并 models
110
+ const nextProvider = {
111
+ ...oldProvider,
112
+ name: oldProvider.name || "mslxdff",
113
+ npm: oldProvider.npm || "@ai-sdk/openai-compatible",
114
+ options: {
115
+ ...(oldProvider.options && typeof oldProvider.options === "object" ? oldProvider.options : {}),
116
+ baseURL: `http://127.0.0.1:${p}/v1`,
117
+ apiKey: cleanToken,
118
+ },
119
+ models: nextModels,
120
+ };
121
+ // 若插入的是 alias 但原名已存在,上面已处理为不新增;否则正常
122
+ data.provider.mslxdff = nextProvider;
123
+ // 若是新插入且 external 不等于 internal,且 internal 已存在时,需避免双键,上面已处理
124
+ // 若是新插入 external 且 internal 不存在,正常插入
125
+ if (!exists) {
126
+ data.provider.mslxdff.models = nextModels;
127
+ }
128
+ } else {
129
+ data.provider.mslxdff = buildOpencodeProvider({ id: external, token: cleanToken, port: p });
130
+ action = "inserted";
131
+ }
132
+
133
+ // 原子写
134
+ mkdirSync(dirname(targetFile), { recursive: true });
135
+ const tmp = `${targetFile}.tmp.${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
136
+ writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
137
+ try {
138
+ renameSync(tmp, targetFile);
139
+ } catch {
140
+ try {
141
+ writeFileSync(targetFile, readFileSync(tmp, "utf8"), "utf8");
142
+ } catch {}
143
+ }
144
+ try {
145
+ if (existsSync(tmp)) {
146
+ const { unlinkSync } = await import("node:fs");
147
+ unlinkSync(tmp);
148
+ }
149
+ } catch {}
150
+
151
+ return { action, file: targetFile, id: effectiveId, alias: external, internal, corrupted };
152
+ }
@@ -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
  }