mslxdff 0.1.55 → 0.1.57
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/README.md +1 -160
- package/bin/mslxdff.js +741 -29
- package/package.json +3 -2
- package/src/chat/config.js +10 -0
- package/src/chat/index.js +6 -0
- package/src/chat/prompt.js +63 -0
- package/src/chat/repl.js +245 -0
- package/src/chat/spinner.js +38 -0
- package/src/chat/stats.js +170 -0
- package/src/chat/store.js +48 -0
- package/src/chat/tools.js +304 -0
- package/src/chat/upstream.js +90 -0
- package/src/models.js +90 -2
- package/src/providers/dispatcher.js +100 -0
- package/src/providers/generic.js +221 -0
- package/src/providers/index.js +6 -0
- package/src/providers/keyring.js +38 -0
- package/src/providers/model-id.js +36 -0
- package/src/providers/opencode.js +20 -0
- package/src/providers/openrouter.js +217 -0
- package/src/providers/share-keys.js +53 -0
- package/src/providers/workbuddy-balance.js +76 -0
- package/src/providers/workbuddy.js +563 -0
- package/src/routes/chat/index.js +56 -2
- package/src/routes/peers.js +12 -7
- package/src/routes/stream.js +9 -0
- package/src/state.js +299 -4
- package/src/sync-opencode.js +152 -0
- package/src/time.js +73 -0
- package/src/upstream.js +126 -8
- package/docs/adr/0001-reasoning-content-injection.md +0 -14
- package/docs/adr/0002-models-free-filter.md +0 -12
- package/docs/adr/0003-zero-state-no-auth.md +0 -10
- package/docs/adr/0004-bearer-token.md +0 -18
- package/docs/adr/0005-peer-mesh.md +0 -53
- package/docs/adr/0006-broadband-member.md +0 -103
- package/docs/agents/domain.md +0 -51
- package/docs/agents/issue-tracker.md +0 -30
- package/docs/agents/triage-labels.md +0 -15
- package/docs/plugins.md +0 -185
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { joinModelId } from "./model-id.js";
|
|
2
|
+
import { createKeyRing } from "./keyring.js";
|
|
3
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL } from "../state.js";
|
|
4
|
+
import { writeFileSync, existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { getCachedBalance, setCachedBalance } from "./workbuddy-balance.js";
|
|
7
|
+
|
|
8
|
+
let UndiciAgent = null;
|
|
9
|
+
let UndiciFetch = null;
|
|
10
|
+
try {
|
|
11
|
+
const mod = await import("undici");
|
|
12
|
+
UndiciAgent = mod.Agent;
|
|
13
|
+
UndiciFetch = mod.fetch;
|
|
14
|
+
} catch {}
|
|
15
|
+
|
|
16
|
+
function envInt(name, fallback) {
|
|
17
|
+
const v = Number(process.env[name]);
|
|
18
|
+
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveBaseUrl(baseUrl) {
|
|
22
|
+
if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
|
|
23
|
+
const env = loadProviderBaseUrl("workbuddy");
|
|
24
|
+
if (env) return env;
|
|
25
|
+
return WORKBUDDY_DEFAULT_BASE_URL;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildAuthHeaders(key, auth) {
|
|
29
|
+
const h = {
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
Accept: "text/event-stream",
|
|
32
|
+
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
33
|
+
Origin: "https://www.codebuddy.cn",
|
|
34
|
+
Referer: "https://www.codebuddy.cn/",
|
|
35
|
+
"X-Product": "SaaS",
|
|
36
|
+
};
|
|
37
|
+
if (key) h["Authorization"] = `Bearer ${key}`;
|
|
38
|
+
if (auth?.uid) h["X-User-Id"] = auth.uid;
|
|
39
|
+
if (auth?.domain) h["X-Domain"] = auth.domain;
|
|
40
|
+
else h["X-Domain"] = "www.codebuddy.cn";
|
|
41
|
+
if (auth?.enterpriseId) {
|
|
42
|
+
h["X-Enterprise-Id"] = auth.enterpriseId;
|
|
43
|
+
h["X-Tenant-Id"] = auth.enterpriseId;
|
|
44
|
+
}
|
|
45
|
+
return h;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isInsufficientStatus(status, bodyText, cached) {
|
|
49
|
+
if (cached && Number(cached.total) === 0) return true;
|
|
50
|
+
if (status === 402) return true;
|
|
51
|
+
if (status === 403 || status === 429) {
|
|
52
|
+
const t = String(bodyText || "").toLowerCase();
|
|
53
|
+
if (t.includes("insufficient") || t.includes("quota") || t.includes("balance") || t.includes("credit") || t.includes("exhaust") || t.includes("402") || t.includes("10002") || t.includes("10003")) return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function withUidHeader(res, uid) {
|
|
59
|
+
try {
|
|
60
|
+
// try mutable set first
|
|
61
|
+
res.headers.set("x-mslxdff-workbuddy-uid", uid);
|
|
62
|
+
return res;
|
|
63
|
+
} catch {
|
|
64
|
+
try {
|
|
65
|
+
const h = new Headers(res.headers);
|
|
66
|
+
h.set("x-mslxdff-workbuddy-uid", uid);
|
|
67
|
+
// clone with new headers, preserve body stream without consuming
|
|
68
|
+
const clone = new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
|
|
69
|
+
clone._t = res._t;
|
|
70
|
+
return clone;
|
|
71
|
+
} catch {
|
|
72
|
+
return res;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function appendRotationLog({ uid, model, totalMs, balanceHit, error }) {
|
|
78
|
+
try {
|
|
79
|
+
const dirs = new Set();
|
|
80
|
+
try {
|
|
81
|
+
const sf = process.env.MSLXDFF_STATE_FILE;
|
|
82
|
+
if (sf) {
|
|
83
|
+
const cut = Math.max(sf.lastIndexOf("/"), sf.lastIndexOf("\\"));
|
|
84
|
+
if (cut > 0) dirs.add(sf.slice(0, cut));
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
const envDir = process.env.MSLXDFF_DAEMON_DIR || process.env.MSLXDFF_LOG_DIR;
|
|
88
|
+
if (envDir) dirs.add(envDir);
|
|
89
|
+
dirs.add(process.cwd());
|
|
90
|
+
dirs.add(join(process.cwd(), "logs"));
|
|
91
|
+
const line = `${new Date().toISOString()} uid=${uid} model=${model||"-"} totalMs=${totalMs} balanceHit=${balanceHit?1:0}${error?` error=${String(error).slice(0,120)}`:""}\n`;
|
|
92
|
+
for (const dir of dirs) {
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(dir, { recursive: true });
|
|
95
|
+
const file = join(dir, "workbuddy-rotation.log");
|
|
96
|
+
appendFileSync(file, line);
|
|
97
|
+
try {
|
|
98
|
+
const st = statSync(file);
|
|
99
|
+
if (st.size > 1024*1024) {
|
|
100
|
+
const content = readFileSync(file, "utf8");
|
|
101
|
+
writeFileSync(file, content.slice(-512*1024));
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createWorkbuddyProvider({
|
|
110
|
+
baseUrl,
|
|
111
|
+
apiKeys,
|
|
112
|
+
apiKey,
|
|
113
|
+
auths,
|
|
114
|
+
connectTimeoutMs = Number(process.env.MSLXDFF_WORKBUDDY_TIMEOUT_MS) || 30_000,
|
|
115
|
+
cooldownMs = envInt("MSLXDFF_WORKBUDDY_COOLDOWN_MS", 30_000),
|
|
116
|
+
retry = {
|
|
117
|
+
network: { attempts: 2, delayMs: 300 },
|
|
118
|
+
429: { attempts: 1, delayMs: 100 },
|
|
119
|
+
502: { attempts: 1, delayMs: 100 },
|
|
120
|
+
503: { attempts: 1, delayMs: 100 },
|
|
121
|
+
504: { attempts: 1, delayMs: 100 },
|
|
122
|
+
},
|
|
123
|
+
fetchImpl,
|
|
124
|
+
file,
|
|
125
|
+
} = {}) {
|
|
126
|
+
const id = "workbuddy";
|
|
127
|
+
const resolvedBase = resolveBaseUrl(baseUrl);
|
|
128
|
+
if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
|
|
129
|
+
|
|
130
|
+
// keys 优先显式传入,其次 state
|
|
131
|
+
const keysFromState = loadProviderKeys(id, file ? { file } : {});
|
|
132
|
+
const authsFromState = loadProviderAuths(id, file ? { file } : {});
|
|
133
|
+
const keys = (() => {
|
|
134
|
+
const list = [
|
|
135
|
+
...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
|
|
136
|
+
apiKey,
|
|
137
|
+
...(apiKeys === undefined && apiKey === undefined ? keysFromState : []),
|
|
138
|
+
].filter((k) => typeof k === "string" && k.trim().length);
|
|
139
|
+
return [...new Set(list.map((k) => k.trim()))];
|
|
140
|
+
})();
|
|
141
|
+
let authList = Array.isArray(auths) && auths.length ? auths : authsFromState;
|
|
142
|
+
|
|
143
|
+
// fallback scan auths/workbuddy-*.json if still empty
|
|
144
|
+
if (!authList.length && !keys.length) {
|
|
145
|
+
try {
|
|
146
|
+
const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
|
|
147
|
+
if (existsSync(authDir)) {
|
|
148
|
+
const files = readdirSync(authDir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
|
|
149
|
+
for (const f of files) {
|
|
150
|
+
try {
|
|
151
|
+
const j = JSON.parse(readFileSync(join(authDir, f), "utf8"));
|
|
152
|
+
if (j?.auth?.accessToken && j?.account?.uid) {
|
|
153
|
+
if (!keys.includes(j.auth.accessToken)) keys.push(j.auth.accessToken);
|
|
154
|
+
authList.push({
|
|
155
|
+
uid: j.account.uid,
|
|
156
|
+
domain: j.auth.domain || "www.codebuddy.cn",
|
|
157
|
+
enterpriseId: j.account.enterpriseId || "",
|
|
158
|
+
refreshToken: j.auth.refreshToken || "",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const ring = createKeyRing(keys, { cooldownMs });
|
|
168
|
+
|
|
169
|
+
let dispatcher = null;
|
|
170
|
+
let agent = null;
|
|
171
|
+
if (UndiciAgent) {
|
|
172
|
+
try {
|
|
173
|
+
agent = new UndiciAgent({
|
|
174
|
+
keepAliveTimeout: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_TIMEOUT", 30_000),
|
|
175
|
+
keepAliveMaxTimeout: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_MAX_TIMEOUT", 60_000),
|
|
176
|
+
connections: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_CONNECTIONS", 20),
|
|
177
|
+
pipelining: 1,
|
|
178
|
+
});
|
|
179
|
+
dispatcher = agent;
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function authForKey(key) {
|
|
184
|
+
const idx = keys.indexOf(key);
|
|
185
|
+
if (idx >= 0 && authList[idx]) return authList[idx];
|
|
186
|
+
// try match by uid if key's JWT contains uid? fallback to first auth
|
|
187
|
+
if (authList.length) return authList[0];
|
|
188
|
+
return { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function refreshTokenFor(key, auth) {
|
|
192
|
+
const rt = auth?.refreshToken;
|
|
193
|
+
const uid = auth?.uid;
|
|
194
|
+
if (!rt || !uid) return null;
|
|
195
|
+
const url = `${resolvedBase}/v2/plugin/auth/token/refresh`;
|
|
196
|
+
const headers = {
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
Authorization: `Bearer ${key}`,
|
|
199
|
+
"X-Refresh-Token": rt,
|
|
200
|
+
"X-User-Id": uid,
|
|
201
|
+
"X-Domain": auth.domain || "www.codebuddy.cn",
|
|
202
|
+
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
203
|
+
Origin: "https://www.codebuddy.cn",
|
|
204
|
+
Referer: "https://www.codebuddy.cn/",
|
|
205
|
+
};
|
|
206
|
+
try {
|
|
207
|
+
const opts = { method: "POST", headers, body: "{}" };
|
|
208
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
209
|
+
const res = await fetchImpl(url, opts);
|
|
210
|
+
const text = await res.text();
|
|
211
|
+
const j = JSON.parse(text);
|
|
212
|
+
if (j.code === 0 && j.data?.accessToken) {
|
|
213
|
+
const newAt = j.data.accessToken;
|
|
214
|
+
const newRt = j.data.refreshToken || rt;
|
|
215
|
+
// update in-memory
|
|
216
|
+
const idx = keys.indexOf(key);
|
|
217
|
+
if (idx >= 0) {
|
|
218
|
+
keys[idx] = newAt;
|
|
219
|
+
// also update ring? createKeyRing holds reference? need to reset
|
|
220
|
+
// simplest: save to state and update authList
|
|
221
|
+
authList[idx] = { ...auth, refreshToken: newRt };
|
|
222
|
+
try {
|
|
223
|
+
saveProviderConfig(id, { baseUrl: resolvedBase, keys: [...keys], auths: [...authList] }, file ? { file } : {});
|
|
224
|
+
} catch {}
|
|
225
|
+
try {
|
|
226
|
+
const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
|
|
227
|
+
mkdirSync(authDir, { recursive: true });
|
|
228
|
+
const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now()/1000)+5184000; } })();
|
|
229
|
+
const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
|
|
230
|
+
const fp = join(authDir, `workbuddy-${uid}.json`);
|
|
231
|
+
const tmp = fp + ".tmp";
|
|
232
|
+
writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
233
|
+
try { if (existsSync(fp)) { const { unlinkSync, renameSync } = await import("node:fs"); unlinkSync(fp); renameSync(tmp, fp); } else { const { renameSync } = await import("node:fs"); renameSync(tmp, fp); } } catch { writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 }); }
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
return newAt;
|
|
237
|
+
}
|
|
238
|
+
} catch {}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function attemptOnce(url, body, key) {
|
|
243
|
+
const controller = new AbortController();
|
|
244
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
245
|
+
try {
|
|
246
|
+
const auth = authForKey(key);
|
|
247
|
+
const headers = buildAuthHeaders(key, auth);
|
|
248
|
+
// ensure stream true
|
|
249
|
+
const finalBody = { ...body, stream: true };
|
|
250
|
+
const opts = { method: "POST", headers, body: JSON.stringify(finalBody), signal: controller.signal };
|
|
251
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
252
|
+
const res = await fetchImpl(url, opts);
|
|
253
|
+
return res;
|
|
254
|
+
} catch (err) {
|
|
255
|
+
return err;
|
|
256
|
+
} finally {
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function runChat(body, activeRing, opts = {}) {
|
|
262
|
+
const url = `${resolvedBase}/v2/chat/completions`;
|
|
263
|
+
const t0 = performance.now();
|
|
264
|
+
const preferredUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
|
|
265
|
+
const modelForLog = body?.model || "";
|
|
266
|
+
// manual pin: force single uid
|
|
267
|
+
if (preferredUid) {
|
|
268
|
+
const idx = authList.findIndex(a => a.uid === preferredUid || a.uid.startsWith(preferredUid));
|
|
269
|
+
if (idx < 0) {
|
|
270
|
+
const errBody = JSON.stringify({ error: `workbuddy uid not found: ${preferredUid}` });
|
|
271
|
+
const res = new Response(errBody, { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-workbuddy-reason": "uid-not-found", "x-mslxdff-workbuddy-uid": preferredUid } });
|
|
272
|
+
res._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
|
|
273
|
+
return res;
|
|
274
|
+
}
|
|
275
|
+
const key = keys[idx];
|
|
276
|
+
const auth = authList[idx];
|
|
277
|
+
const cached = getCachedBalance(auth.uid);
|
|
278
|
+
if (cached && Number(cached.total) === 0) {
|
|
279
|
+
const errBody = JSON.stringify({ error: `workbuddy uid in cooldown (balance 0): ${auth.uid}` });
|
|
280
|
+
const res = new Response(errBody, { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-workbuddy-reason": "uid-cooling", "x-mslxdff-workbuddy-uid": auth.uid } });
|
|
281
|
+
res._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
|
|
282
|
+
return res;
|
|
283
|
+
}
|
|
284
|
+
// check ring cooldown by peeking if key is cooling (ring doesn't expose, so try next and see)
|
|
285
|
+
// we enforce by attempting; if ring would skip, we still allow manual but mark cooling
|
|
286
|
+
// do single attempt with this key
|
|
287
|
+
const attempts = [];
|
|
288
|
+
let waitMs = 0;
|
|
289
|
+
for (let attempt = 0; ; attempt++) {
|
|
290
|
+
const t = performance.now();
|
|
291
|
+
let result = await attemptOnce(url, body, key);
|
|
292
|
+
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
293
|
+
attempts.push({ attempt, type, ms: Math.round(performance.now() - t) });
|
|
294
|
+
if (result instanceof Error) {
|
|
295
|
+
const entry = retry?.network;
|
|
296
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
297
|
+
activeRing.onError(key);
|
|
298
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
299
|
+
throw result;
|
|
300
|
+
}
|
|
301
|
+
if ((result.status === 401 || result.status === 403) && attempt === 0) {
|
|
302
|
+
const newKey = await refreshTokenFor(key, auth);
|
|
303
|
+
if (newKey) {
|
|
304
|
+
const auth2 = authForKey(newKey);
|
|
305
|
+
const headers2 = buildAuthHeaders(newKey, auth2);
|
|
306
|
+
const finalBody = { ...body, stream: true };
|
|
307
|
+
const controller2 = new AbortController();
|
|
308
|
+
const timer2 = setTimeout(() => controller2.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
309
|
+
try {
|
|
310
|
+
const opts2 = { method: "POST", headers: headers2, body: JSON.stringify(finalBody), signal: controller2.signal };
|
|
311
|
+
if (dispatcher) opts2.dispatcher = dispatcher;
|
|
312
|
+
let res2 = await fetchImpl(url, opts2);
|
|
313
|
+
res2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0), refreshed: true };
|
|
314
|
+
res2 = withUidHeader(res2, auth2.uid || auth.uid);
|
|
315
|
+
if (res2.status === 401 || res2.status === 403 || res2.status === 429 || res2.status >= 500) activeRing.onError(newKey);
|
|
316
|
+
appendRotationLog({ uid: auth2.uid || auth.uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false });
|
|
317
|
+
return res2;
|
|
318
|
+
} catch (e2) { e2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) }; throw e2; } finally { clearTimeout(timer2); }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const entry = retry?.[result.status];
|
|
322
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
323
|
+
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
324
|
+
result = withUidHeader(result, auth.uid);
|
|
325
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
326
|
+
appendRotationLog({ uid: auth.uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false });
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// auto rotation: try each key at most once, with ring.next() order
|
|
332
|
+
const attempts = [];
|
|
333
|
+
let waitMs = 0;
|
|
334
|
+
let lastErr = null;
|
|
335
|
+
const tried = new Set();
|
|
336
|
+
const maxTries = Math.max(1, keys.length);
|
|
337
|
+
for (let triedCount = 0; triedCount < maxTries; triedCount++) {
|
|
338
|
+
const key = activeRing.next();
|
|
339
|
+
if (!key) {
|
|
340
|
+
if (triedCount === 0) {
|
|
341
|
+
const err = new Error(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
|
|
342
|
+
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0), cooldownMs };
|
|
343
|
+
throw err;
|
|
344
|
+
}
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
const auth = authForKey(key);
|
|
348
|
+
const uid = auth?.uid || "";
|
|
349
|
+
if (tried.has(uid || key)) continue;
|
|
350
|
+
tried.add(uid || key);
|
|
351
|
+
const cached = uid ? getCachedBalance(uid) : null;
|
|
352
|
+
if (cached && Number(cached.total) === 0) {
|
|
353
|
+
// treat as insufficient without request, cooldown and try next
|
|
354
|
+
activeRing.onError(key);
|
|
355
|
+
setCachedBalance(uid, { ...cached, total: 0 });
|
|
356
|
+
appendRotationLog({ uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: true });
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (!key && activeRing.size === 0) {
|
|
360
|
+
const err = new Error(`${id}: missing MSLXDFF_WORKBUDDY_KEY (chat requires a real key) — run node workbuddy-token-auto.js`);
|
|
361
|
+
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
362
|
+
throw err;
|
|
363
|
+
}
|
|
364
|
+
// single attempt with retry for network/429
|
|
365
|
+
let result = null;
|
|
366
|
+
let attempt = 0;
|
|
367
|
+
for (; ; attempt++) {
|
|
368
|
+
const t = performance.now();
|
|
369
|
+
result = await attemptOnce(url, body, key);
|
|
370
|
+
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
371
|
+
attempts.push({ attempt: triedCount*10+attempt, type, ms: Math.round(performance.now() - t), uid });
|
|
372
|
+
if (result instanceof Error) {
|
|
373
|
+
const entry = retry?.network;
|
|
374
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
375
|
+
activeRing.onError(key);
|
|
376
|
+
lastErr = result;
|
|
377
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
if ((result.status === 401 || result.status === 403) && attempt === 0) {
|
|
381
|
+
const newKey = await refreshTokenFor(key, auth);
|
|
382
|
+
if (newKey) {
|
|
383
|
+
const auth2 = authForKey(newKey);
|
|
384
|
+
const headers2 = buildAuthHeaders(newKey, auth2);
|
|
385
|
+
const finalBody = { ...body, stream: true };
|
|
386
|
+
const controller2 = new AbortController();
|
|
387
|
+
const timer2 = setTimeout(() => controller2.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
388
|
+
try {
|
|
389
|
+
const opts2 = { method: "POST", headers: headers2, body: JSON.stringify(finalBody), signal: controller2.signal };
|
|
390
|
+
if (dispatcher) opts2.dispatcher = dispatcher;
|
|
391
|
+
let res2 = await fetchImpl(url, opts2);
|
|
392
|
+
res2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0), refreshed: true };
|
|
393
|
+
if (res2.status === 401 || res2.status === 403 || res2.status === 429 || res2.status >= 500) activeRing.onError(newKey);
|
|
394
|
+
res2 = withUidHeader(res2, auth2.uid || uid);
|
|
395
|
+
appendRotationLog({ uid: auth2.uid || uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false });
|
|
396
|
+
return res2;
|
|
397
|
+
} catch (e2) { e2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) }; lastErr = e2; break; } finally { clearTimeout(timer2); }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const entry = retry?.[result.status];
|
|
401
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
if (result instanceof Error) {
|
|
405
|
+
// network error already handled, try next key if any
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
// check insufficient -> rotate
|
|
409
|
+
let bodyText = "";
|
|
410
|
+
let isInsuff = false;
|
|
411
|
+
if (result.status === 402 || result.status === 403 || result.status === 429) {
|
|
412
|
+
try { bodyText = await result.clone().text(); } catch { try { bodyText = await result.text(); } catch { bodyText = ""; } }
|
|
413
|
+
isInsuff = isInsufficientStatus(result.status, bodyText, cached);
|
|
414
|
+
}
|
|
415
|
+
if (isInsuff) {
|
|
416
|
+
activeRing.onError(key);
|
|
417
|
+
if (uid) setCachedBalance(uid, { total: 0, dailyPacks: 0, activeCount: 0, nextExpire: null, fetchedAt: Date.now() });
|
|
418
|
+
appendRotationLog({ uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: true, error: bodyText.slice(0,120) });
|
|
419
|
+
// need to recreate response for next loop? we already cloned, so result is still usable but we discard it
|
|
420
|
+
lastErr = new Error(`workbuddy insufficient for ${uid}: ${bodyText.slice(0,120)}`);
|
|
421
|
+
lastErr._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
// success or non-insufficient error -> return
|
|
425
|
+
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
426
|
+
result = withUidHeader(result, uid);
|
|
427
|
+
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
428
|
+
appendRotationLog({ uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false });
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
// all tried, none succeeded
|
|
432
|
+
if (lastErr) throw lastErr;
|
|
433
|
+
const err = new Error(`${id}: all workbuddy accounts exhausted or unavailable`);
|
|
434
|
+
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function chat(body, opts = {}) {
|
|
439
|
+
// support header/model pin via opts or body._workbuddyUid
|
|
440
|
+
const uid = opts?.workbuddyUid || body?._workbuddyUid;
|
|
441
|
+
const cleanBody = uid ? (({ _workbuddyUid, ...rest }) => rest)(body) : body;
|
|
442
|
+
return runChat(cleanBody, ring, uid ? { workbuddyUid: uid } : {});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function chatWithKeys(body, keysOverride) {
|
|
446
|
+
// workbuddy 默认不外借,需显式 MSLXDFF_WORKBUDDY_SHARE_KEYS=1 或 state providerShareKeys.workbuddy=true
|
|
447
|
+
const shareOn = (() => {
|
|
448
|
+
try { return loadProviderShareKeys(id, file ? { file } : {}); } catch { return false; }
|
|
449
|
+
})();
|
|
450
|
+
if (!shareOn) {
|
|
451
|
+
// fallback to normal chat (ignore share)
|
|
452
|
+
return runChat(body, ring);
|
|
453
|
+
}
|
|
454
|
+
const tmp = createKeyRing(keysOverride, { cooldownMs });
|
|
455
|
+
const tmpAuth = authList[0] || { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
|
|
456
|
+
const savedKeys = [...keys];
|
|
457
|
+
const savedAuthList = [...authList];
|
|
458
|
+
try {
|
|
459
|
+
keys.length = 0; keys.push(...keysOverride);
|
|
460
|
+
authList = keysOverride.map(() => tmpAuth);
|
|
461
|
+
return await runChat(body, tmp);
|
|
462
|
+
} finally {
|
|
463
|
+
keys.length = 0; keys.push(...savedKeys);
|
|
464
|
+
authList = savedAuthList;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// listModels / preheat shared
|
|
469
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
470
|
+
let cache = null;
|
|
471
|
+
let fetchedAt = 0;
|
|
472
|
+
|
|
473
|
+
function creditsValue(c) {
|
|
474
|
+
if (!c || !String(c).trim()) return 0;
|
|
475
|
+
const m = String(c).match(/x([\d.]+)/);
|
|
476
|
+
return m ? parseFloat(m[1]) : 999;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function listModels() {
|
|
480
|
+
const now = Date.now();
|
|
481
|
+
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
482
|
+
const url = `${resolvedBase}/console/enterprises/personal/models`;
|
|
483
|
+
const controller = new AbortController();
|
|
484
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
485
|
+
try {
|
|
486
|
+
const key = ring.next() || keys[0] || "";
|
|
487
|
+
const auth = authForKey(key);
|
|
488
|
+
const headers = {
|
|
489
|
+
Accept: "application/json",
|
|
490
|
+
"X-User-Id": auth?.uid || "",
|
|
491
|
+
"X-Domain": auth?.domain || "www.codebuddy.cn",
|
|
492
|
+
"X-Product": "SaaS",
|
|
493
|
+
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
494
|
+
Origin: "https://www.codebuddy.cn",
|
|
495
|
+
Referer: "https://www.codebuddy.cn/",
|
|
496
|
+
};
|
|
497
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
498
|
+
const opts = { headers, signal: controller.signal };
|
|
499
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
500
|
+
const res = await fetchImpl(url, opts);
|
|
501
|
+
if (!res.ok) return [];
|
|
502
|
+
const json = await res.json().catch(() => ({}));
|
|
503
|
+
const models = json?.data?.models;
|
|
504
|
+
if (!Array.isArray(models)) return [];
|
|
505
|
+
const sorted = [...models].sort((a, b) => creditsValue(a.credits) - creditsValue(b.credits));
|
|
506
|
+
cache = sorted.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
507
|
+
fetchedAt = now;
|
|
508
|
+
return cache;
|
|
509
|
+
} catch {
|
|
510
|
+
return [];
|
|
511
|
+
} finally {
|
|
512
|
+
clearTimeout(timer);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function preheat() {
|
|
517
|
+
const url = `${resolvedBase}/console/enterprises/personal/models`;
|
|
518
|
+
const t0 = performance.now();
|
|
519
|
+
try {
|
|
520
|
+
const key = ring.next() || keys[0] || "";
|
|
521
|
+
const auth = authForKey(key);
|
|
522
|
+
const headers = {
|
|
523
|
+
Accept: "application/json",
|
|
524
|
+
"X-User-Id": auth?.uid || "",
|
|
525
|
+
"X-Domain": auth?.domain || "www.codebuddy.cn",
|
|
526
|
+
"X-Product": "SaaS",
|
|
527
|
+
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
528
|
+
Origin: "https://www.codebuddy.cn",
|
|
529
|
+
Referer: "https://www.codebuddy.cn/",
|
|
530
|
+
};
|
|
531
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
532
|
+
const opts = { headers };
|
|
533
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
534
|
+
const res = await fetchImpl(url, opts);
|
|
535
|
+
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
536
|
+
return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
|
|
537
|
+
} catch (err) {
|
|
538
|
+
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function close() {
|
|
543
|
+
if (agent && typeof agent.close === "function") {
|
|
544
|
+
try { await agent.close(); } catch {}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
id,
|
|
550
|
+
chat,
|
|
551
|
+
chatWithKeys,
|
|
552
|
+
listModels,
|
|
553
|
+
preheat,
|
|
554
|
+
close,
|
|
555
|
+
agent,
|
|
556
|
+
keyRing: ring,
|
|
557
|
+
baseUrl: resolvedBase,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function sleep(ms) {
|
|
562
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
563
|
+
}
|
package/src/routes/chat/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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";
|
|
8
|
+
import { parseShareKeysHeader, SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
|
|
7
9
|
import { handleHedge } from "./hedge-handler.js";
|
|
8
10
|
import { handleLocalRelay } from "./local-handler.js";
|
|
9
11
|
import { handlePeerRelay } from "./peer-handler.js";
|
|
@@ -33,11 +35,47 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
|
|
|
33
35
|
const stages = [];
|
|
34
36
|
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
35
37
|
const hops = parseHops(req.headers["x-mslxdff-hops"]);
|
|
38
|
+
// ADR-0008:瞬时共享 key(组员侧接收)。本请求内有效,用完即弃。
|
|
39
|
+
const shareKeys = parseShareKeysHeader(req.headers[SHARE_KEYS_HEADER] || "");
|
|
40
|
+
const workbuddyUid = (req.headers["x-mslxdff-workbuddy-uid"] || req.headers["x-workbuddy-uid"] || "").toString().trim();
|
|
36
41
|
const lockModel = req.headers["x-mslxdff-model-lock"] || "";
|
|
37
42
|
const rawModel = body.model || "";
|
|
38
|
-
const
|
|
43
|
+
const normalizedRequested = normalizeModel(lockModel || rawModel || "");
|
|
44
|
+
// alias 还原:mslxdff-deepseek -> deepseek(原名仍兼容,双向支持 mslxdff/mslxdff-deepseek 与裸 mslxdff-deepseek/裸 deepseek)
|
|
45
|
+
let requested = normalizedRequested;
|
|
46
|
+
let aliasInfo = null;
|
|
47
|
+
if (requested.startsWith("mslxdff-")) {
|
|
48
|
+
const internal = aliasToInternal(requested);
|
|
49
|
+
if (internal) {
|
|
50
|
+
aliasInfo = `${requested} -> ${internal}`;
|
|
51
|
+
requested = internal;
|
|
52
|
+
}
|
|
53
|
+
} else if (requested.includes("/")) {
|
|
54
|
+
const slashIdx = requested.indexOf("/");
|
|
55
|
+
const rawPart = requested.slice(slashIdx + 1);
|
|
56
|
+
const providerPart = requested.slice(0, slashIdx);
|
|
57
|
+
if (rawPart.startsWith("mslxdff-")) {
|
|
58
|
+
const internal = aliasToInternal(rawPart);
|
|
59
|
+
if (internal) {
|
|
60
|
+
aliasInfo = `${requested} -> ${providerPart}/${internal} (alias stripped)`;
|
|
61
|
+
requested = `${providerPart}/${internal}`;
|
|
62
|
+
if (providerPart === "mslxdff") {
|
|
63
|
+
requested = internal;
|
|
64
|
+
aliasInfo = `${rawModel} -> ${internal} (mslxdff alias stripped)`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} else if (providerPart === "mslxdff") {
|
|
68
|
+
// mslxdff/deepseek 原名直用 -> deepseek(原名兼容,provider 前缀剥离)
|
|
69
|
+
aliasInfo = `${requested} -> ${rawPart} (mslxdff provider stripped, 原名兼容)`;
|
|
70
|
+
requested = rawPart;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
39
73
|
const useAuto = isAutoModel(requested);
|
|
40
74
|
mark("parsed");
|
|
75
|
+
if (aliasInfo) {
|
|
76
|
+
// 供日志与 header 透传
|
|
77
|
+
try { res.setHeader("x-mslxdff-alias", aliasInfo); } catch {}
|
|
78
|
+
}
|
|
41
79
|
|
|
42
80
|
let order;
|
|
43
81
|
if (lockModel) {
|
|
@@ -66,6 +104,8 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
|
|
|
66
104
|
runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
|
|
67
105
|
};
|
|
68
106
|
evt("request", { reqId, hops, ip: clientIp(req), stream: Boolean(body.stream), prompt: summarizePrompt(body), rawModel, requested, lockModel: lockModel || null });
|
|
107
|
+
if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel, requested });
|
|
108
|
+
if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
|
|
69
109
|
evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
|
|
70
110
|
|
|
71
111
|
if (plugins?.length && !lockModel) {
|
|
@@ -106,7 +146,10 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
|
|
|
106
146
|
const tUp = performance.now();
|
|
107
147
|
evt("upstream-try", { reqId, model, attempt: idx + 1 });
|
|
108
148
|
try {
|
|
109
|
-
|
|
149
|
+
const chatOpts = {};
|
|
150
|
+
if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
|
|
151
|
+
if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
|
|
152
|
+
upRes = await upstream.chat(forwarded, Object.keys(chatOpts).length ? chatOpts : undefined);
|
|
110
153
|
evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null });
|
|
111
154
|
} catch (err) {
|
|
112
155
|
if (auto) await auto.recordError(model, { message: errMsg(err) });
|
|
@@ -125,6 +168,17 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
|
|
|
125
168
|
}
|
|
126
169
|
mark(`up-${model}`);
|
|
127
170
|
if (upRes && upRes.status >= 400) {
|
|
171
|
+
// 白名单 403 直通,不计冷却、不 fallback
|
|
172
|
+
const isAllowlistBlock = upRes.status === 403 && (upRes.headers?.get?.("x-mslxdff-allowlist") === "1");
|
|
173
|
+
if (isAllowlistBlock) {
|
|
174
|
+
let bodyText = null;
|
|
175
|
+
try { bodyText = await upRes.clone().text(); } catch {}
|
|
176
|
+
let errBody = { error: `model not allowed for provider` };
|
|
177
|
+
try { errBody = bodyText ? JSON.parse(bodyText) : errBody; } catch { errBody = { error: bodyText || "model not allowed" }; }
|
|
178
|
+
logError(model, 403, errBody.error || "model not allowed");
|
|
179
|
+
evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null });
|
|
180
|
+
return json(res, 403, errBody);
|
|
181
|
+
}
|
|
128
182
|
if (auto) await auto.recordError(model, { status: upRes.status });
|
|
129
183
|
lastErr = { model, upstream: upRes, status: upRes.status, message: null };
|
|
130
184
|
logError(model, upRes.status, `upstream ${upRes.status}`);
|