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.
- package/bin/mslxdff.js +502 -56
- 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 +44 -2
- package/src/routes/stream.js +58 -1
- package/src/state.js +83 -7
- package/src/sync-opencode.js +152 -0
- package/src/sync-workbuddy.js +38 -6
package/src/routes/chat/index.js
CHANGED
|
@@ -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
|
-
|
|
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++) {
|
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
|
}
|
|
@@ -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
|
+
}
|
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
|
}
|