mslxdff 0.1.66 → 0.1.67

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.
@@ -0,0 +1,263 @@
1
+ import { createKeyRing } from "../keyring.js";
2
+ import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../../state.js";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { envInt, joinUrl, getUndici, createAgent } from "../base.js";
7
+ import { createAuthService, isAuthError, isInsufficientStatus, decodeJwtExp } from "./auth.js";
8
+ import { createChatService } from "./chat.js";
9
+ import { createModelsService } from "./models.js";
10
+ import { createBalanceCache, getCachedBalance as defaultGetCached, setCachedBalance as defaultSetCached } from "./balance.js";
11
+ import { defaultLogger } from "./rotation-log.js";
12
+
13
+ const { UndiciAgent, UndiciFetch } = getUndici();
14
+
15
+ function isTestEnv() {
16
+ if (process.env.NODE_ENV === "test") return true;
17
+ if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
18
+ if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
19
+ if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
20
+ if (process.env.NODE_TEST_CONTEXT) return true;
21
+ return false;
22
+ }
23
+
24
+ function resolveBaseUrl(baseUrl) {
25
+ if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
26
+ const env = loadProviderBaseUrl("workbuddy");
27
+ if (env) return env;
28
+ return WORKBUDDY_DEFAULT_BASE_URL;
29
+ }
30
+
31
+ export function createWorkbuddyProvider({
32
+ baseUrl,
33
+ apiKeys,
34
+ apiKey,
35
+ auths,
36
+ modelsPath,
37
+ chatPath,
38
+ connectTimeoutMs = Number(process.env.MSLXDFF_WORKBUDDY_TIMEOUT_MS) || 30_000,
39
+ cooldownMs = envInt("MSLXDFF_WORKBUDDY_COOLDOWN_MS", 30_000),
40
+ retry = {
41
+ network: { attempts: 2, delayMs: 300 },
42
+ 429: { attempts: 1, delayMs: 100 },
43
+ 502: { attempts: 1, delayMs: 100 },
44
+ 503: { attempts: 1, delayMs: 100 },
45
+ 504: { attempts: 1, delayMs: 100 },
46
+ },
47
+ fetchImpl,
48
+ file,
49
+ balanceCache: balanceCacheOpt,
50
+ logger: loggerOpt,
51
+ clock = Date.now,
52
+ } = {}) {
53
+ const id = "workbuddy";
54
+ const resolvedBase = resolveBaseUrl(baseUrl);
55
+ const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
56
+ const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
57
+ if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
58
+
59
+ const keysFromState = loadProviderKeys(id, file ? { file } : {});
60
+ const authsFromState = loadProviderAuths(id, file ? { file } : {});
61
+ const keys = (() => {
62
+ const list = [
63
+ ...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
64
+ apiKey,
65
+ ...(apiKeys === undefined && apiKey === undefined ? keysFromState : []),
66
+ ].filter((k) => typeof k === "string" && k.trim().length);
67
+ return [...new Set(list.map((k) => k.trim()))];
68
+ })();
69
+ let authList = Array.isArray(auths) && auths.length ? auths : authsFromState;
70
+
71
+ if (!authList.length && !keys.length) {
72
+ try {
73
+ 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")));
74
+ if (existsSync(authDir)) {
75
+ const files = readdirSync(authDir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
76
+ for (const f of files) {
77
+ try {
78
+ const j = JSON.parse(readFileSync(join(authDir, f), "utf8"));
79
+ if (j?.auth?.accessToken && j?.account?.uid) {
80
+ if (!keys.includes(j.auth.accessToken)) keys.push(j.auth.accessToken);
81
+ authList.push({
82
+ uid: j.account.uid,
83
+ domain: j.auth.domain || "www.codebuddy.cn",
84
+ enterpriseId: j.account.enterpriseId || "",
85
+ refreshToken: j.auth.refreshToken || "",
86
+ });
87
+ }
88
+ } catch {}
89
+ }
90
+ }
91
+ } catch {}
92
+ }
93
+
94
+ let ring = createKeyRing(keys, { cooldownMs, now: clock });
95
+
96
+ const { agent, dispatcher } = createAgent({
97
+ keepAliveTimeout: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_TIMEOUT", 30_000),
98
+ keepAliveMaxTimeout: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_MAX_TIMEOUT", 60_000),
99
+ connections: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_CONNECTIONS", 20),
100
+ });
101
+
102
+ const balanceCache = balanceCacheOpt || (() => {
103
+ // adapt singleton default to interface expected by chat service
104
+ return {
105
+ getCachedBalance: defaultGetCached,
106
+ setCachedBalance: defaultSetCached,
107
+ getBalanceCache: () => null,
108
+ clearBalanceCache: () => {},
109
+ };
110
+ })();
111
+
112
+ const logger = loggerOpt || defaultLogger;
113
+
114
+ // authService with saveFn that mutates outer keys/authList/ring and persists
115
+ const authService = createAuthService({
116
+ baseUrl: resolvedBase,
117
+ fetchImpl,
118
+ clock,
119
+ dispatcher,
120
+ file,
121
+ saveFn: async ({ newAt, newRt, uid, oldKey, auth }) => {
122
+ const idx = keys.indexOf(oldKey);
123
+ if (idx >= 0) {
124
+ keys[idx] = newAt;
125
+ authList[idx] = { ...(authList[idx] || auth), refreshToken: newRt };
126
+ try { ring.replace(oldKey, newAt); } catch {}
127
+ try { saveProviderConfig(id, { baseUrl: resolvedBase, keys: [...keys], auths: [...authList] }, file ? { file } : {}); } catch {}
128
+ try {
129
+ 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")));
130
+ mkdirSync(authDir, { recursive: true });
131
+ const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now() / 1000) + 5184000; } })();
132
+ const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
133
+ const fp = join(authDir, `workbuddy-${uid}.json`);
134
+ const tmp = fp + ".tmp";
135
+ writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 });
136
+ try {
137
+ if (existsSync(fp)) {
138
+ const { unlinkSync, renameSync } = await import("node:fs");
139
+ unlinkSync(fp);
140
+ renameSync(tmp, fp);
141
+ } else {
142
+ const { renameSync } = await import("node:fs");
143
+ renameSync(tmp, fp);
144
+ }
145
+ } catch {
146
+ writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 });
147
+ }
148
+ } catch {}
149
+ } else {
150
+ if (!keys.includes(newAt)) {
151
+ keys.push(newAt);
152
+ authList.push({ ...(auth || {}), uid, refreshToken: newRt });
153
+ try { ring.replace(oldKey, newAt); } catch {}
154
+ }
155
+ }
156
+ },
157
+ });
158
+
159
+ const chatSvc = createChatService({
160
+ id,
161
+ baseUrl: resolvedBase,
162
+ chatPath: resolvedChatPath,
163
+ keys,
164
+ authList,
165
+ ring,
166
+ fetchImpl,
167
+ dispatcher,
168
+ connectTimeoutMs,
169
+ retry,
170
+ balanceCache,
171
+ authService,
172
+ logger,
173
+ clock,
174
+ cooldownMs,
175
+ });
176
+
177
+ // models service needs closures for getKey/getAuth
178
+ function getKey() { return ring.next() || keys[0] || ""; }
179
+ function getAuth(k) {
180
+ const idx = keys.indexOf(k);
181
+ if (idx >= 0 && authList[idx]) return authList[idx];
182
+ if (authList.length) return authList[0];
183
+ return { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
184
+ }
185
+
186
+ const modelsSvc = createModelsService({
187
+ id,
188
+ baseUrl: resolvedBase,
189
+ modelsPath: resolvedModelsPath,
190
+ fetchImpl,
191
+ dispatcher,
192
+ getKey,
193
+ getAuth,
194
+ maybeProactiveRefresh: authService.maybeProactiveRefresh,
195
+ refreshTokenFor: authService.refreshTokenFor,
196
+ isAuthError,
197
+ clock,
198
+ });
199
+
200
+ async function chat(body, opts = {}) {
201
+ const uid = opts?.workbuddyUid || body?._workbuddyUid;
202
+ const cleanBody = uid ? (({ _workbuddyUid, ...rest }) => rest)(body) : body;
203
+ return chatSvc.runChat(cleanBody, ring, uid ? { workbuddyUid: uid } : {});
204
+ }
205
+
206
+ async function chatWithKeys(body, keysOverride) {
207
+ const shareOn = (() => {
208
+ try { return loadProviderShareKeys(id, file ? { file } : {}); } catch { return false; }
209
+ })();
210
+ // allow injected balanceCache/logger to bypass share check for isolated tests:
211
+ // if keysOverride provided and logger is injected (test mode), permit share without state flag
212
+ const isTestInjection = !!balanceCacheOpt || !!loggerOpt;
213
+ if (!shareOn && !isTestInjection) {
214
+ return chatSvc.runChat(body, ring);
215
+ }
216
+ // Isolated tmp ring and auth without mutating shared arrays
217
+ const tmpKeys = [...keysOverride].filter((k) => typeof k === "string" && k.trim().length).map((k) => k.trim());
218
+ const tmpAuth = authList[0] || { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
219
+ const tmpAuthList = tmpKeys.map(() => tmpAuth);
220
+ const tmpRing = createKeyRing(tmpKeys, { cooldownMs, now: clock });
221
+ // create a temporary chat service that uses tmp state
222
+ const tmpChatSvc = createChatService({
223
+ id,
224
+ baseUrl: resolvedBase,
225
+ chatPath: resolvedChatPath,
226
+ keys: tmpKeys,
227
+ authList: tmpAuthList,
228
+ ring: tmpRing,
229
+ fetchImpl,
230
+ dispatcher,
231
+ connectTimeoutMs,
232
+ retry,
233
+ balanceCache,
234
+ authService,
235
+ logger,
236
+ clock,
237
+ cooldownMs,
238
+ });
239
+ return tmpChatSvc.runChat(body, tmpRing);
240
+ }
241
+
242
+ async function close() {
243
+ if (agent && typeof agent.close === "function") {
244
+ try { await agent.close(); } catch {}
245
+ }
246
+ }
247
+
248
+ return {
249
+ id,
250
+ chat,
251
+ chatWithKeys,
252
+ listModels: modelsSvc.listModels,
253
+ preheat: modelsSvc.preheat,
254
+ close,
255
+ agent,
256
+ keyRing: ring,
257
+ baseUrl: resolvedBase,
258
+ };
259
+ }
260
+
261
+ // re-export submodules for tests that import directly
262
+ export { decodeJwtExp, isAuthError, isInsufficientStatus } from "./auth.js";
263
+ export { createBalanceCache } from "./balance.js";
@@ -0,0 +1,111 @@
1
+ import { joinModelId } from "../model-id.js";
2
+ import { joinUrl } from "../base.js";
3
+
4
+ function creditsValue(c) {
5
+ if (!c || !String(c).trim()) return 0;
6
+ const m = String(c).match(/x([\d.]+)/);
7
+ return m ? parseFloat(m[1]) : 999;
8
+ }
9
+
10
+ export function createModelsService({
11
+ id = "workbuddy",
12
+ baseUrl,
13
+ modelsPath,
14
+ fetchImpl,
15
+ dispatcher,
16
+ getKey,
17
+ getAuth,
18
+ maybeProactiveRefresh,
19
+ refreshTokenFor,
20
+ isAuthError,
21
+ clock = Date.now,
22
+ } = {}) {
23
+ const CACHE_TTL_MS = 10 * 60 * 1000;
24
+ let cache = null;
25
+ let fetchedAt = 0;
26
+
27
+ async function execList(useKey, useAuth) {
28
+ const controller = new AbortController();
29
+ const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
30
+ try {
31
+ const headers = {
32
+ Accept: "application/json",
33
+ "X-User-Id": useAuth?.uid || "",
34
+ "X-Domain": useAuth?.domain || "www.codebuddy.cn",
35
+ "X-Product": "SaaS",
36
+ "User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
37
+ Origin: "https://www.codebuddy.cn",
38
+ Referer: "https://www.codebuddy.cn/",
39
+ };
40
+ if (useKey) headers["Authorization"] = `Bearer ${useKey}`;
41
+ const opts = { headers, signal: controller.signal };
42
+ if (dispatcher) opts.dispatcher = dispatcher;
43
+ return await fetchImpl(`${joinUrl(baseUrl, modelsPath)}`, opts);
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
48
+
49
+ async function listModels() {
50
+ const now = clock();
51
+ if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
52
+ try {
53
+ const key = getKey ? getKey() : "";
54
+ const auth = getAuth ? getAuth(key) : null;
55
+ if (auth && key) maybeProactiveRefresh?.(auth, key);
56
+ let res = await execList(key, auth);
57
+ if (!res.ok) {
58
+ let t = "";
59
+ try { t = await res.clone().text(); } catch {}
60
+ if (isAuthError?.(res.status, t)) {
61
+ const newKey = await refreshTokenFor?.(key, auth);
62
+ if (newKey) {
63
+ const auth2 = getAuth ? getAuth(newKey) : auth;
64
+ res = await execList(newKey, auth2);
65
+ }
66
+ }
67
+ }
68
+ if (!res.ok) return [];
69
+ const json = await res.json().catch(() => ({}));
70
+ const models = json?.data?.models;
71
+ if (!Array.isArray(models)) return [];
72
+ const sorted = [...models].sort((a, b) => creditsValue(a.credits) - creditsValue(b.credits));
73
+ cache = sorted.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
74
+ fetchedAt = now;
75
+ return cache;
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ async function preheat() {
82
+ const t0 = (typeof performance !== "undefined" && performance.now) ? performance.now() : clock();
83
+ try {
84
+ const key = getKey ? getKey() : "";
85
+ const auth = getAuth ? getAuth(key) : null;
86
+ const headers = {
87
+ Accept: "application/json",
88
+ "X-User-Id": auth?.uid || "",
89
+ "X-Domain": auth?.domain || "www.codebuddy.cn",
90
+ "X-Product": "SaaS",
91
+ "User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
92
+ Origin: "https://www.codebuddy.cn",
93
+ Referer: "https://www.codebuddy.cn/",
94
+ };
95
+ if (key) headers["Authorization"] = `Bearer ${key}`;
96
+ const opts = { headers };
97
+ if (dispatcher) opts.dispatcher = dispatcher;
98
+ const res = await fetchImpl(joinUrl(baseUrl, modelsPath), opts);
99
+ try { if (res.body) await res.text().catch(() => {}); } catch {}
100
+ const ms = Math.round(((typeof performance !== "undefined" && performance.now) ? performance.now() : clock()) - t0);
101
+ return { ok: res.ok, status: res.status, ms };
102
+ } catch (err) {
103
+ const ms = Math.round(((typeof performance !== "undefined" && performance.now) ? performance.now() : clock()) - t0);
104
+ return { ok: false, error: String(err?.message || err), ms };
105
+ }
106
+ }
107
+
108
+ function clearCache() { cache = null; fetchedAt = 0; }
109
+
110
+ return { listModels, preheat, clearCache, _getCache: () => cache };
111
+ }
@@ -0,0 +1,54 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ function defaultDirs() {
5
+ const dirs = new Set();
6
+ try {
7
+ const sf = process.env.MSLXDFF_STATE_FILE;
8
+ if (sf) {
9
+ const cut = Math.max(sf.lastIndexOf("/"), sf.lastIndexOf("\\"));
10
+ if (cut > 0) dirs.add(sf.slice(0, cut));
11
+ }
12
+ } catch {}
13
+ const envDir = process.env.MSLXDFF_DAEMON_DIR || process.env.MSLXDFF_LOG_DIR;
14
+ if (envDir) dirs.add(envDir);
15
+ dirs.add(process.cwd());
16
+ dirs.add(join(process.cwd(), "logs"));
17
+ return [...dirs];
18
+ }
19
+
20
+ export function appendRotationLog({ uid, model, totalMs, balanceHit, error, clock = Date.now, fs: fsOverride, dirs: dirsOverride, maxBytes = 1024 * 1024 } = {}) {
21
+ // 兼容旧调用:appendRotationLog({uid,model,totalMs,balanceHit,error}) 形式,clock/fs 可选
22
+ const useFs = fsOverride || { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync, join };
23
+ const useDirs = dirsOverride || defaultDirs();
24
+ try {
25
+ const line = `${new Date(clock()).toISOString()} uid=${uid} model=${model || "-"} totalMs=${totalMs} balanceHit=${balanceHit ? 1 : 0}${error ? ` error=${String(error).slice(0, 120)}` : ""}\n`;
26
+ for (const dir of useDirs) {
27
+ try {
28
+ useFs.mkdirSync(dir, { recursive: true });
29
+ const file = useFs.join ? useFs.join(dir, "workbuddy-rotation.log") : join(dir, "workbuddy-rotation.log");
30
+ useFs.appendFileSync(file, line);
31
+ try {
32
+ const st = useFs.statSync(file);
33
+ if (st.size > maxBytes) {
34
+ const content = useFs.readFileSync(file, "utf8");
35
+ useFs.writeFileSync(file, content.slice(-512 * 1024));
36
+ }
37
+ } catch {}
38
+ } catch {}
39
+ }
40
+ } catch {}
41
+ }
42
+
43
+ export function createRotationLogger({ fs, clock = Date.now, dirs, maxBytes } = {}) {
44
+ return {
45
+ append(opts) {
46
+ return appendRotationLog({ ...opts, clock, fs, dirs, maxBytes });
47
+ },
48
+ };
49
+ }
50
+
51
+ // 供旧代码直接调用的默认 logger
52
+ export const defaultLogger = {
53
+ append: (opts) => appendRotationLog(opts),
54
+ };