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.
- package/bin/mslxdff.js +427 -53
- package/package.json +2 -2
- package/src/auto.js +1 -1
- package/src/chat/prompt.js +3 -1
- package/src/chat/repl.js +131 -7
- 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/providers/workbuddy.js +11 -2
- package/src/routes/chat/index.js +9 -2
- package/src/routes/stream.js +58 -1
- package/src/state.js +93 -7
- package/src/sync-workbuddy.js +38 -6
|
@@ -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" } };
|
package/src/routes/chat/index.js
CHANGED
|
@@ -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
|
-
|
|
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++) {
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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
|
}
|