mslxdff 0.1.65 → 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.
- package/bin/mslxdff.js +3 -3159
- package/package.json +1 -1
- package/src/chat/cooling.js +99 -0
- package/src/chat/direct.js +57 -0
- package/src/chat/gateway.js +148 -0
- package/src/chat/orchestrator.js +218 -0
- package/src/chat/sse.js +69 -0
- package/src/chat/upstream.js +59 -501
- package/src/cli/bootstrap.js +1 -0
- package/src/cli/commands/daemon.js +151 -0
- package/src/cli/commands/group.js +272 -0
- package/src/cli/commands/model.js +405 -0
- package/src/cli/commands/provider/add.js +106 -0
- package/src/cli/commands/provider/allowlist.js +99 -0
- package/src/cli/commands/provider/config.js +82 -0
- package/src/cli/commands/provider/index.js +193 -0
- package/src/cli/commands/provider/keys.js +135 -0
- package/src/cli/commands/provider/models.js +99 -0
- package/src/cli/commands/provider.js +1 -0
- package/src/cli/commands/sync.js +143 -0
- package/src/cli/commands/system.js +236 -0
- package/src/cli/commands/workbuddy.js +91 -0
- package/src/cli/format.js +119 -0
- package/src/cli/group-helpers.js +69 -0
- package/src/cli/help.js +66 -0
- package/src/cli/index.js +59 -0
- package/src/cli/interactive.js +84 -0
- package/src/cli/policy.js +119 -0
- package/src/cli/provider-row.js +73 -0
- package/src/cli/status.js +283 -0
- package/src/cli/util.js +24 -0
- package/src/providers/base.js +159 -0
- package/src/providers/dispatcher.js +18 -6
- package/src/providers/generic.js +28 -166
- package/src/providers/openrouter.js +19 -205
- package/src/providers/workbuddy/auth.js +175 -0
- package/src/providers/workbuddy/balance.js +84 -0
- package/src/providers/workbuddy/chat.js +310 -0
- package/src/providers/workbuddy/index.js +263 -0
- package/src/providers/workbuddy/models.js +111 -0
- package/src/providers/workbuddy/rotation-log.js +54 -0
- package/src/providers/workbuddy.js +2 -677
- package/src/routes/chat/broadband-handler.js +25 -46
- package/src/routes/chat/exhausted-handler.js +2 -2
- package/src/routes/chat/gateway.js +301 -0
- package/src/routes/chat/hedge-handler.js +65 -83
- package/src/routes/chat/index.js +1 -384
- package/src/routes/chat/local-handler.js +32 -61
- package/src/routes/chat/peer-handler.js +32 -23
- package/src/routes/chat/relay-pipeline.js +151 -0
- package/src/runtime/bootstrap.js +408 -0
- package/src/state/facade.js +57 -0
- package/src/state/memory.js +161 -0
- package/src/state/merge.js +26 -0
- package/src/state/persist.js +42 -0
- package/src/state/provider-config.js +143 -0
- package/src/state/schemas/allowlist.js +87 -0
- package/src/state/schemas/group.js +31 -0
- package/src/state/schemas/model.js +53 -0
- package/src/state/schemas/peer.js +31 -0
- package/src/state/schemas/port.js +10 -0
- package/src/state/schemas/provider.js +204 -0
- package/src/state/schemas/token.js +43 -0
- package/src/state/store.js +176 -0
- package/src/state.js +1 -711
|
@@ -1,677 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../state.js";
|
|
4
|
-
import { writeFileSync, existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, statSync } from "node:fs";
|
|
5
|
-
import { join, dirname } from "node:path";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
|
-
import { getCachedBalance, setCachedBalance } from "./workbuddy-balance.js";
|
|
8
|
-
|
|
9
|
-
let UndiciAgent = null;
|
|
10
|
-
let UndiciFetch = null;
|
|
11
|
-
try {
|
|
12
|
-
const mod = await import("undici");
|
|
13
|
-
UndiciAgent = mod.Agent;
|
|
14
|
-
UndiciFetch = mod.fetch;
|
|
15
|
-
} catch {}
|
|
16
|
-
|
|
17
|
-
function joinUrl(base, path) {
|
|
18
|
-
const b = String(base || "").trim().replace(/\/+$/, "");
|
|
19
|
-
const p = String(path || "").trim();
|
|
20
|
-
if (!p) return b;
|
|
21
|
-
const pp = p.startsWith("/") ? p : `/${p}`;
|
|
22
|
-
return `${b}${pp}`;
|
|
23
|
-
}
|
|
24
|
-
function envInt(name, fallback) {
|
|
25
|
-
const v = Number(process.env[name]);
|
|
26
|
-
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
27
|
-
}
|
|
28
|
-
function isTestEnv() {
|
|
29
|
-
if (process.env.NODE_ENV === "test") return true;
|
|
30
|
-
if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
|
|
31
|
-
if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
|
|
32
|
-
if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
|
|
33
|
-
if (process.env.NODE_TEST_CONTEXT) return true;
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function resolveBaseUrl(baseUrl) {
|
|
38
|
-
if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
|
|
39
|
-
const env = loadProviderBaseUrl("workbuddy");
|
|
40
|
-
if (env) return env;
|
|
41
|
-
return WORKBUDDY_DEFAULT_BASE_URL;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function buildAuthHeaders(key, auth) {
|
|
45
|
-
const h = {
|
|
46
|
-
"Content-Type": "application/json",
|
|
47
|
-
Accept: "text/event-stream",
|
|
48
|
-
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
49
|
-
Origin: "https://www.codebuddy.cn",
|
|
50
|
-
Referer: "https://www.codebuddy.cn/",
|
|
51
|
-
"X-Product": "SaaS",
|
|
52
|
-
};
|
|
53
|
-
if (key) h["Authorization"] = `Bearer ${key}`;
|
|
54
|
-
if (auth?.uid) h["X-User-Id"] = auth.uid;
|
|
55
|
-
if (auth?.domain) h["X-Domain"] = auth.domain;
|
|
56
|
-
else h["X-Domain"] = "www.codebuddy.cn";
|
|
57
|
-
if (auth?.enterpriseId) {
|
|
58
|
-
h["X-Enterprise-Id"] = auth.enterpriseId;
|
|
59
|
-
h["X-Tenant-Id"] = auth.enterpriseId;
|
|
60
|
-
}
|
|
61
|
-
return h;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function isInsufficientStatus(status, bodyText, cached) {
|
|
65
|
-
if (cached && Number(cached.total) === 0) return true;
|
|
66
|
-
if (status === 402) return true;
|
|
67
|
-
if (status === 403 || status === 429) {
|
|
68
|
-
const t = String(bodyText || "").toLowerCase();
|
|
69
|
-
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;
|
|
70
|
-
}
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// 401/403 或 body 含 token 失效关键词 → 视为需刷新(workbuddy 返回 200+JSON 或 SSE 错误时也能命中)
|
|
75
|
-
function isAuthError(status, bodyText) {
|
|
76
|
-
if (status === 401 || status === 403) return true;
|
|
77
|
-
const t = String(bodyText || "").toLowerCase();
|
|
78
|
-
// 常见 workbuddy 鉴权失败文案
|
|
79
|
-
if (t.includes("unauthorized") || t.includes("authenticate") || t.includes("invalid token") || t.includes("token expired") || t.includes("token invalid") || t.includes("access token") || t.includes("login expired") || t.includes("need login") || t.includes("session expired")) return true;
|
|
80
|
-
if (t.includes("code") && (t.includes("401") || t.includes("403")) && t.includes("token")) return true;
|
|
81
|
-
// JWT 失效的 400 也可能带 token
|
|
82
|
-
if (status === 400 && t.includes("token")) return true;
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function decodeJwtExp(token) {
|
|
87
|
-
try {
|
|
88
|
-
const payload = String(token || "").split(".")[1];
|
|
89
|
-
if (!payload) return 0;
|
|
90
|
-
const json = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
91
|
-
return Number(json.exp || 0);
|
|
92
|
-
} catch { return 0; }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// 并发去重:同一 uid 同时只刷一次
|
|
96
|
-
const inflightRefresh = new Map();
|
|
97
|
-
|
|
98
|
-
function withUidHeader(res, uid) {
|
|
99
|
-
try {
|
|
100
|
-
// try mutable set first
|
|
101
|
-
res.headers.set("x-mslxdff-workbuddy-uid", uid);
|
|
102
|
-
return res;
|
|
103
|
-
} catch {
|
|
104
|
-
try {
|
|
105
|
-
const h = new Headers(res.headers);
|
|
106
|
-
h.set("x-mslxdff-workbuddy-uid", uid);
|
|
107
|
-
// clone with new headers, preserve body stream without consuming
|
|
108
|
-
const clone = new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
|
|
109
|
-
clone._t = res._t;
|
|
110
|
-
return clone;
|
|
111
|
-
} catch {
|
|
112
|
-
return res;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function appendRotationLog({ uid, model, totalMs, balanceHit, error }) {
|
|
118
|
-
try {
|
|
119
|
-
const dirs = new Set();
|
|
120
|
-
try {
|
|
121
|
-
const sf = process.env.MSLXDFF_STATE_FILE;
|
|
122
|
-
if (sf) {
|
|
123
|
-
const cut = Math.max(sf.lastIndexOf("/"), sf.lastIndexOf("\\"));
|
|
124
|
-
if (cut > 0) dirs.add(sf.slice(0, cut));
|
|
125
|
-
}
|
|
126
|
-
} catch {}
|
|
127
|
-
const envDir = process.env.MSLXDFF_DAEMON_DIR || process.env.MSLXDFF_LOG_DIR;
|
|
128
|
-
if (envDir) dirs.add(envDir);
|
|
129
|
-
dirs.add(process.cwd());
|
|
130
|
-
dirs.add(join(process.cwd(), "logs"));
|
|
131
|
-
const line = `${new Date().toISOString()} uid=${uid} model=${model||"-"} totalMs=${totalMs} balanceHit=${balanceHit?1:0}${error?` error=${String(error).slice(0,120)}`:""}\n`;
|
|
132
|
-
for (const dir of dirs) {
|
|
133
|
-
try {
|
|
134
|
-
mkdirSync(dir, { recursive: true });
|
|
135
|
-
const file = join(dir, "workbuddy-rotation.log");
|
|
136
|
-
appendFileSync(file, line);
|
|
137
|
-
try {
|
|
138
|
-
const st = statSync(file);
|
|
139
|
-
if (st.size > 1024*1024) {
|
|
140
|
-
const content = readFileSync(file, "utf8");
|
|
141
|
-
writeFileSync(file, content.slice(-512*1024));
|
|
142
|
-
}
|
|
143
|
-
} catch {}
|
|
144
|
-
} catch {}
|
|
145
|
-
}
|
|
146
|
-
} catch {}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function createWorkbuddyProvider({
|
|
150
|
-
baseUrl,
|
|
151
|
-
apiKeys,
|
|
152
|
-
apiKey,
|
|
153
|
-
auths,
|
|
154
|
-
modelsPath,
|
|
155
|
-
chatPath,
|
|
156
|
-
connectTimeoutMs = Number(process.env.MSLXDFF_WORKBUDDY_TIMEOUT_MS) || 30_000,
|
|
157
|
-
cooldownMs = envInt("MSLXDFF_WORKBUDDY_COOLDOWN_MS", 30_000),
|
|
158
|
-
retry = {
|
|
159
|
-
network: { attempts: 2, delayMs: 300 },
|
|
160
|
-
429: { attempts: 1, delayMs: 100 },
|
|
161
|
-
502: { attempts: 1, delayMs: 100 },
|
|
162
|
-
503: { attempts: 1, delayMs: 100 },
|
|
163
|
-
504: { attempts: 1, delayMs: 100 },
|
|
164
|
-
},
|
|
165
|
-
fetchImpl,
|
|
166
|
-
file,
|
|
167
|
-
} = {}) {
|
|
168
|
-
const id = "workbuddy";
|
|
169
|
-
const resolvedBase = resolveBaseUrl(baseUrl);
|
|
170
|
-
const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
|
|
171
|
-
const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
|
|
172
|
-
if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
|
|
173
|
-
|
|
174
|
-
// keys 优先显式传入,其次 state
|
|
175
|
-
const keysFromState = loadProviderKeys(id, file ? { file } : {});
|
|
176
|
-
const authsFromState = loadProviderAuths(id, file ? { file } : {});
|
|
177
|
-
const keys = (() => {
|
|
178
|
-
const list = [
|
|
179
|
-
...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
|
|
180
|
-
apiKey,
|
|
181
|
-
...(apiKeys === undefined && apiKey === undefined ? keysFromState : []),
|
|
182
|
-
].filter((k) => typeof k === "string" && k.trim().length);
|
|
183
|
-
return [...new Set(list.map((k) => k.trim()))];
|
|
184
|
-
})();
|
|
185
|
-
let authList = Array.isArray(auths) && auths.length ? auths : authsFromState;
|
|
186
|
-
|
|
187
|
-
// fallback scan auths/workbuddy-*.json if still empty
|
|
188
|
-
if (!authList.length && !keys.length) {
|
|
189
|
-
try {
|
|
190
|
-
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")));
|
|
191
|
-
if (existsSync(authDir)) {
|
|
192
|
-
const files = readdirSync(authDir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
|
|
193
|
-
for (const f of files) {
|
|
194
|
-
try {
|
|
195
|
-
const j = JSON.parse(readFileSync(join(authDir, f), "utf8"));
|
|
196
|
-
if (j?.auth?.accessToken && j?.account?.uid) {
|
|
197
|
-
if (!keys.includes(j.auth.accessToken)) keys.push(j.auth.accessToken);
|
|
198
|
-
authList.push({
|
|
199
|
-
uid: j.account.uid,
|
|
200
|
-
domain: j.auth.domain || "www.codebuddy.cn",
|
|
201
|
-
enterpriseId: j.account.enterpriseId || "",
|
|
202
|
-
refreshToken: j.auth.refreshToken || "",
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
} catch {}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
} catch {}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
let ring = createKeyRing(keys, { cooldownMs });
|
|
212
|
-
|
|
213
|
-
let dispatcher = null;
|
|
214
|
-
let agent = null;
|
|
215
|
-
if (UndiciAgent) {
|
|
216
|
-
try {
|
|
217
|
-
agent = new UndiciAgent({
|
|
218
|
-
keepAliveTimeout: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_TIMEOUT", 30_000),
|
|
219
|
-
keepAliveMaxTimeout: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_MAX_TIMEOUT", 60_000),
|
|
220
|
-
connections: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_CONNECTIONS", 20),
|
|
221
|
-
pipelining: 1,
|
|
222
|
-
});
|
|
223
|
-
dispatcher = agent;
|
|
224
|
-
} catch {}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function authForKey(key) {
|
|
228
|
-
const idx = keys.indexOf(key);
|
|
229
|
-
if (idx >= 0 && authList[idx]) return authList[idx];
|
|
230
|
-
// try match by uid if key's JWT contains uid? fallback to first auth
|
|
231
|
-
if (authList.length) return authList[0];
|
|
232
|
-
return { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
async function refreshTokenFor(key, auth) {
|
|
236
|
-
const rt = auth?.refreshToken;
|
|
237
|
-
const uid = auth?.uid;
|
|
238
|
-
if (!rt || !uid) return null;
|
|
239
|
-
const dedupKey = String(uid);
|
|
240
|
-
if (inflightRefresh.has(dedupKey)) {
|
|
241
|
-
try { return await inflightRefresh.get(dedupKey); } catch { return null; }
|
|
242
|
-
}
|
|
243
|
-
const p = (async () => {
|
|
244
|
-
const url = joinUrl(resolvedBase, "/v2/plugin/auth/token/refresh");
|
|
245
|
-
const headers = {
|
|
246
|
-
"Content-Type": "application/json",
|
|
247
|
-
Authorization: `Bearer ${key}`,
|
|
248
|
-
"X-Refresh-Token": rt,
|
|
249
|
-
"X-User-Id": uid,
|
|
250
|
-
"X-Domain": auth.domain || "www.codebuddy.cn",
|
|
251
|
-
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
252
|
-
Origin: "https://www.codebuddy.cn",
|
|
253
|
-
Referer: "https://www.codebuddy.cn/",
|
|
254
|
-
};
|
|
255
|
-
try {
|
|
256
|
-
const opts = { method: "POST", headers, body: "{}" };
|
|
257
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
258
|
-
const res = await fetchImpl(url, opts);
|
|
259
|
-
const text = await res.text();
|
|
260
|
-
const j = JSON.parse(text);
|
|
261
|
-
if (j.code === 0 && j.data?.accessToken) {
|
|
262
|
-
const newAt = j.data.accessToken;
|
|
263
|
-
const newRt = j.data.refreshToken || rt;
|
|
264
|
-
const idx = keys.indexOf(key);
|
|
265
|
-
if (idx >= 0) {
|
|
266
|
-
keys[idx] = newAt;
|
|
267
|
-
authList[idx] = { ...auth, refreshToken: newRt };
|
|
268
|
-
// 同步 ring,避免下一轮仍取旧 token
|
|
269
|
-
try { ring.replace(key, newAt); } catch {}
|
|
270
|
-
try { saveProviderConfig(id, { baseUrl: resolvedBase, keys: [...keys], auths: [...authList] }, file ? { file } : {}); } catch {}
|
|
271
|
-
try {
|
|
272
|
-
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")));
|
|
273
|
-
mkdirSync(authDir, { recursive: true });
|
|
274
|
-
const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now()/1000)+5184000; } })();
|
|
275
|
-
const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
|
|
276
|
-
const fp = join(authDir, `workbuddy-${uid}.json`);
|
|
277
|
-
const tmp = fp + ".tmp";
|
|
278
|
-
writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
279
|
-
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 }); }
|
|
280
|
-
} catch {}
|
|
281
|
-
} else {
|
|
282
|
-
// 未在 keys 里的 key(如临时 ring),也尝试追加
|
|
283
|
-
if (!keys.includes(newAt)) {
|
|
284
|
-
keys.push(newAt);
|
|
285
|
-
authList.push({ ...auth, refreshToken: newRt });
|
|
286
|
-
try { ring.replace(key, newAt); } catch {}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
return newAt;
|
|
290
|
-
}
|
|
291
|
-
} catch {}
|
|
292
|
-
return null;
|
|
293
|
-
})();
|
|
294
|
-
inflightRefresh.set(dedupKey, p);
|
|
295
|
-
try { const r = await p; return r; } finally { inflightRefresh.delete(dedupKey); }
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// 主动续期:JWT 5 分钟内过期则后台刷新一次(不阻塞当前请求)
|
|
299
|
-
function maybeProactiveRefresh(auth, key) {
|
|
300
|
-
try {
|
|
301
|
-
const exp = decodeJwtExp(key);
|
|
302
|
-
if (!exp) return;
|
|
303
|
-
const remain = exp * 1000 - Date.now();
|
|
304
|
-
if (remain < 5 * 60 * 1000 && remain > -60 * 60 * 1000) {
|
|
305
|
-
// 剩余 <5min 且未过期太久才刷,避免每次都刷
|
|
306
|
-
void refreshTokenFor(key, auth).catch(() => {});
|
|
307
|
-
}
|
|
308
|
-
} catch {}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
async function attemptOnce(url, body, key) {
|
|
312
|
-
const controller = new AbortController();
|
|
313
|
-
const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
314
|
-
try {
|
|
315
|
-
const auth = authForKey(key);
|
|
316
|
-
const headers = buildAuthHeaders(key, auth);
|
|
317
|
-
// ensure stream true
|
|
318
|
-
const finalBody = { ...body, stream: true };
|
|
319
|
-
const opts = { method: "POST", headers, body: JSON.stringify(finalBody), signal: controller.signal };
|
|
320
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
321
|
-
const res = await fetchImpl(url, opts);
|
|
322
|
-
return res;
|
|
323
|
-
} catch (err) {
|
|
324
|
-
return err;
|
|
325
|
-
} finally {
|
|
326
|
-
clearTimeout(timer);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
async function runChat(body, activeRing, opts = {}) {
|
|
331
|
-
const url = joinUrl(resolvedBase, resolvedChatPath);
|
|
332
|
-
const t0 = performance.now();
|
|
333
|
-
const preferredUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
|
|
334
|
-
const modelForLog = body?.model || "";
|
|
335
|
-
// manual pin: force single uid
|
|
336
|
-
if (preferredUid) {
|
|
337
|
-
const idx = authList.findIndex(a => a.uid === preferredUid || a.uid.startsWith(preferredUid));
|
|
338
|
-
if (idx < 0) {
|
|
339
|
-
const errBody = JSON.stringify({ error: `workbuddy uid not found: ${preferredUid}` });
|
|
340
|
-
const res = new Response(errBody, { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-workbuddy-reason": "uid-not-found", "x-mslxdff-workbuddy-uid": preferredUid } });
|
|
341
|
-
res._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
|
|
342
|
-
return res;
|
|
343
|
-
}
|
|
344
|
-
const key = keys[idx];
|
|
345
|
-
const auth = authList[idx];
|
|
346
|
-
maybeProactiveRefresh(auth, key);
|
|
347
|
-
const cached = getCachedBalance(auth.uid);
|
|
348
|
-
if (cached && Number(cached.total) === 0) {
|
|
349
|
-
const errBody = JSON.stringify({ error: `workbuddy uid in cooldown (balance 0): ${auth.uid}` });
|
|
350
|
-
const res = new Response(errBody, { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-workbuddy-reason": "uid-cooling", "x-mslxdff-workbuddy-uid": auth.uid } });
|
|
351
|
-
res._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
|
|
352
|
-
return res;
|
|
353
|
-
}
|
|
354
|
-
const attempts = [];
|
|
355
|
-
let waitMs = 0;
|
|
356
|
-
for (let attempt = 0; ; attempt++) {
|
|
357
|
-
const t = performance.now();
|
|
358
|
-
let result = await attemptOnce(url, body, key);
|
|
359
|
-
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
360
|
-
attempts.push({ attempt, type, ms: Math.round(performance.now() - t) });
|
|
361
|
-
if (result instanceof Error) {
|
|
362
|
-
const entry = retry?.network;
|
|
363
|
-
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
364
|
-
activeRing.onError(key);
|
|
365
|
-
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
366
|
-
throw result;
|
|
367
|
-
}
|
|
368
|
-
// 401/403 或 body 含 token 失效 → 自动续期(覆盖 workbuddy 200+JSON 错误体)
|
|
369
|
-
if (attempt === 0) {
|
|
370
|
-
let bodyText = "";
|
|
371
|
-
try { bodyText = await result.clone().text(); } catch {}
|
|
372
|
-
if (isAuthError(result.status, bodyText)) {
|
|
373
|
-
const newKey = await refreshTokenFor(key, auth);
|
|
374
|
-
if (newKey) {
|
|
375
|
-
const auth2 = authForKey(newKey);
|
|
376
|
-
const headers2 = buildAuthHeaders(newKey, auth2);
|
|
377
|
-
const finalBody = { ...body, stream: true };
|
|
378
|
-
const controller2 = new AbortController();
|
|
379
|
-
const timer2 = setTimeout(() => controller2.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
380
|
-
try {
|
|
381
|
-
const opts2 = { method: "POST", headers: headers2, body: JSON.stringify(finalBody), signal: controller2.signal };
|
|
382
|
-
if (dispatcher) opts2.dispatcher = dispatcher;
|
|
383
|
-
let res2 = await fetchImpl(url, opts2);
|
|
384
|
-
// 刷新后若仍 401/403,视为刷新未生效,标记新 key 冷却并返回错误
|
|
385
|
-
let res2Body = "";
|
|
386
|
-
try { res2Body = await res2.clone().text(); } catch {}
|
|
387
|
-
const stillAuth = isAuthError(res2.status, res2Body);
|
|
388
|
-
res2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0), refreshed: true };
|
|
389
|
-
res2 = withUidHeader(res2, auth2.uid || auth.uid);
|
|
390
|
-
if (stillAuth || res2.status === 429 || res2.status >= 500) activeRing.onError(newKey);
|
|
391
|
-
appendRotationLog({ uid: auth2.uid || auth.uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false, error: stillAuth ? `still auth ${res2.status}` : undefined });
|
|
392
|
-
return res2;
|
|
393
|
-
} catch (e2) { e2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) }; throw e2; } finally { clearTimeout(timer2); }
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
const entry = retry?.[result.status];
|
|
398
|
-
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
399
|
-
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
400
|
-
result = withUidHeader(result, auth.uid);
|
|
401
|
-
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
402
|
-
appendRotationLog({ uid: auth.uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false });
|
|
403
|
-
return result;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// auto rotation: try each key at most once, with ring.next() order
|
|
408
|
-
const attempts = [];
|
|
409
|
-
let waitMs = 0;
|
|
410
|
-
let lastErr = null;
|
|
411
|
-
const tried = new Set();
|
|
412
|
-
const maxTries = Math.max(1, keys.length);
|
|
413
|
-
for (let triedCount = 0; triedCount < maxTries; triedCount++) {
|
|
414
|
-
const key = activeRing.next();
|
|
415
|
-
if (!key) {
|
|
416
|
-
if (triedCount === 0) {
|
|
417
|
-
const err = new Error(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
|
|
418
|
-
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0), cooldownMs };
|
|
419
|
-
throw err;
|
|
420
|
-
}
|
|
421
|
-
break;
|
|
422
|
-
}
|
|
423
|
-
const auth = authForKey(key);
|
|
424
|
-
const uid = auth?.uid || "";
|
|
425
|
-
if (tried.has(uid || key)) continue;
|
|
426
|
-
tried.add(uid || key);
|
|
427
|
-
const cached = uid ? getCachedBalance(uid) : null;
|
|
428
|
-
if (cached && Number(cached.total) === 0) {
|
|
429
|
-
// treat as insufficient without request, cooldown and try next
|
|
430
|
-
activeRing.onError(key);
|
|
431
|
-
setCachedBalance(uid, { ...cached, total: 0 });
|
|
432
|
-
appendRotationLog({ uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: true });
|
|
433
|
-
continue;
|
|
434
|
-
}
|
|
435
|
-
if (!key && activeRing.size === 0) {
|
|
436
|
-
const err = new Error(`${id}: missing MSLXDFF_WORKBUDDY_KEY (chat requires a real key) — run node workbuddy-token-auto.js`);
|
|
437
|
-
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
438
|
-
throw err;
|
|
439
|
-
}
|
|
440
|
-
// 主动续期(JWT 5min 内过期)
|
|
441
|
-
maybeProactiveRefresh(auth, key);
|
|
442
|
-
// single attempt with retry for network/429
|
|
443
|
-
let result = null;
|
|
444
|
-
let attempt = 0;
|
|
445
|
-
for (; ; attempt++) {
|
|
446
|
-
const t = performance.now();
|
|
447
|
-
result = await attemptOnce(url, body, key);
|
|
448
|
-
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
449
|
-
attempts.push({ attempt: triedCount*10+attempt, type, ms: Math.round(performance.now() - t), uid });
|
|
450
|
-
if (result instanceof Error) {
|
|
451
|
-
const entry = retry?.network;
|
|
452
|
-
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
453
|
-
activeRing.onError(key);
|
|
454
|
-
lastErr = result;
|
|
455
|
-
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
456
|
-
break;
|
|
457
|
-
}
|
|
458
|
-
// 401/403 或 body 含 token 失效 → 自动续期后重试一次;若仍失败则切下一账号
|
|
459
|
-
if (attempt === 0) {
|
|
460
|
-
let bodyText = "";
|
|
461
|
-
try { bodyText = await result.clone().text(); } catch {}
|
|
462
|
-
const needRefresh = isAuthError(result.status, bodyText);
|
|
463
|
-
if (needRefresh) {
|
|
464
|
-
const newKey = await refreshTokenFor(key, auth);
|
|
465
|
-
if (newKey) {
|
|
466
|
-
const auth2 = authForKey(newKey);
|
|
467
|
-
const headers2 = buildAuthHeaders(newKey, auth2);
|
|
468
|
-
const finalBody = { ...body, stream: true };
|
|
469
|
-
const controller2 = new AbortController();
|
|
470
|
-
const timer2 = setTimeout(() => controller2.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
471
|
-
try {
|
|
472
|
-
const opts2 = { method: "POST", headers: headers2, body: JSON.stringify(finalBody), signal: controller2.signal };
|
|
473
|
-
if (dispatcher) opts2.dispatcher = dispatcher;
|
|
474
|
-
let res2 = await fetchImpl(url, opts2);
|
|
475
|
-
let res2Body = "";
|
|
476
|
-
try { res2Body = await res2.clone().text(); } catch {}
|
|
477
|
-
const stillAuth = isAuthError(res2.status, res2Body);
|
|
478
|
-
res2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0), refreshed: true };
|
|
479
|
-
res2 = withUidHeader(res2, auth2.uid || uid);
|
|
480
|
-
if (stillAuth || res2.status === 429 || res2.status >= 500) activeRing.onError(newKey);
|
|
481
|
-
appendRotationLog({ uid: auth2.uid || uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false, error: stillAuth ? `still auth ${res2.status}` : undefined });
|
|
482
|
-
if (stillAuth) {
|
|
483
|
-
lastErr = new Error(`workbuddy auth still failing after refresh for ${uid}: ${res2Body.slice(0,120)}`);
|
|
484
|
-
lastErr._t = res2._t;
|
|
485
|
-
activeRing.onError(newKey);
|
|
486
|
-
break; // 切下一账号
|
|
487
|
-
}
|
|
488
|
-
return res2;
|
|
489
|
-
} catch (e2) { e2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) }; lastErr = e2; break; } finally { clearTimeout(timer2); }
|
|
490
|
-
} else {
|
|
491
|
-
// 刷新失败也切下一账号
|
|
492
|
-
lastErr = new Error(`workbuddy refresh failed for ${uid}: ${bodyText.slice(0,120)}`);
|
|
493
|
-
lastErr._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
494
|
-
activeRing.onError(key);
|
|
495
|
-
break;
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
const entry = retry?.[result.status];
|
|
500
|
-
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
501
|
-
break;
|
|
502
|
-
}
|
|
503
|
-
if (result instanceof Error) {
|
|
504
|
-
// network error already handled, try next key if any
|
|
505
|
-
continue;
|
|
506
|
-
}
|
|
507
|
-
// check insufficient -> rotate
|
|
508
|
-
let bodyText = "";
|
|
509
|
-
let isInsuff = false;
|
|
510
|
-
if (result.status === 402 || result.status === 403 || result.status === 429) {
|
|
511
|
-
try { bodyText = await result.clone().text(); } catch { try { bodyText = await result.text(); } catch { bodyText = ""; } }
|
|
512
|
-
isInsuff = isInsufficientStatus(result.status, bodyText, cached);
|
|
513
|
-
}
|
|
514
|
-
if (isInsuff) {
|
|
515
|
-
activeRing.onError(key);
|
|
516
|
-
if (uid) setCachedBalance(uid, { total: 0, dailyPacks: 0, activeCount: 0, nextExpire: null, fetchedAt: Date.now() });
|
|
517
|
-
appendRotationLog({ uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: true, error: bodyText.slice(0,120) });
|
|
518
|
-
// need to recreate response for next loop? we already cloned, so result is still usable but we discard it
|
|
519
|
-
lastErr = new Error(`workbuddy insufficient for ${uid}: ${bodyText.slice(0,120)}`);
|
|
520
|
-
lastErr._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
521
|
-
continue;
|
|
522
|
-
}
|
|
523
|
-
// success or non-insufficient error -> return
|
|
524
|
-
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
525
|
-
result = withUidHeader(result, uid);
|
|
526
|
-
result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
527
|
-
appendRotationLog({ uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false });
|
|
528
|
-
return result;
|
|
529
|
-
}
|
|
530
|
-
// all tried, none succeeded
|
|
531
|
-
if (lastErr) throw lastErr;
|
|
532
|
-
const err = new Error(`${id}: all workbuddy accounts exhausted or unavailable`);
|
|
533
|
-
err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
|
|
534
|
-
throw err;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
async function chat(body, opts = {}) {
|
|
538
|
-
// support header/model pin via opts or body._workbuddyUid
|
|
539
|
-
const uid = opts?.workbuddyUid || body?._workbuddyUid;
|
|
540
|
-
const cleanBody = uid ? (({ _workbuddyUid, ...rest }) => rest)(body) : body;
|
|
541
|
-
return runChat(cleanBody, ring, uid ? { workbuddyUid: uid } : {});
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
async function chatWithKeys(body, keysOverride) {
|
|
545
|
-
// workbuddy 默认不外借,需显式 MSLXDFF_WORKBUDDY_SHARE_KEYS=1 或 state providerShareKeys.workbuddy=true
|
|
546
|
-
const shareOn = (() => {
|
|
547
|
-
try { return loadProviderShareKeys(id, file ? { file } : {}); } catch { return false; }
|
|
548
|
-
})();
|
|
549
|
-
if (!shareOn) {
|
|
550
|
-
// fallback to normal chat (ignore share)
|
|
551
|
-
return runChat(body, ring);
|
|
552
|
-
}
|
|
553
|
-
const tmp = createKeyRing(keysOverride, { cooldownMs });
|
|
554
|
-
const tmpAuth = authList[0] || { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
|
|
555
|
-
const savedKeys = [...keys];
|
|
556
|
-
const savedAuthList = [...authList];
|
|
557
|
-
try {
|
|
558
|
-
keys.length = 0; keys.push(...keysOverride);
|
|
559
|
-
authList = keysOverride.map(() => tmpAuth);
|
|
560
|
-
return await runChat(body, tmp);
|
|
561
|
-
} finally {
|
|
562
|
-
keys.length = 0; keys.push(...savedKeys);
|
|
563
|
-
authList = savedAuthList;
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// listModels / preheat shared
|
|
568
|
-
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
569
|
-
let cache = null;
|
|
570
|
-
let fetchedAt = 0;
|
|
571
|
-
|
|
572
|
-
function creditsValue(c) {
|
|
573
|
-
if (!c || !String(c).trim()) return 0;
|
|
574
|
-
const m = String(c).match(/x([\d.]+)/);
|
|
575
|
-
return m ? parseFloat(m[1]) : 999;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
async function listModels() {
|
|
579
|
-
const now = Date.now();
|
|
580
|
-
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
581
|
-
const url = joinUrl(resolvedBase, resolvedModelsPath);
|
|
582
|
-
const execList = async (useKey, useAuth) => {
|
|
583
|
-
const controller2 = new AbortController();
|
|
584
|
-
const timer2 = setTimeout(() => controller2.abort(new Error(`${id} models timed out`)), 15_000);
|
|
585
|
-
try {
|
|
586
|
-
const headers = {
|
|
587
|
-
Accept: "application/json",
|
|
588
|
-
"X-User-Id": useAuth?.uid || "",
|
|
589
|
-
"X-Domain": useAuth?.domain || "www.codebuddy.cn",
|
|
590
|
-
"X-Product": "SaaS",
|
|
591
|
-
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
592
|
-
Origin: "https://www.codebuddy.cn",
|
|
593
|
-
Referer: "https://www.codebuddy.cn/",
|
|
594
|
-
};
|
|
595
|
-
if (useKey) headers["Authorization"] = `Bearer ${useKey}`;
|
|
596
|
-
const opts = { headers, signal: controller2.signal };
|
|
597
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
598
|
-
return await fetchImpl(url, opts);
|
|
599
|
-
} finally { clearTimeout(timer2); }
|
|
600
|
-
};
|
|
601
|
-
try {
|
|
602
|
-
const key = ring.next() || keys[0] || "";
|
|
603
|
-
const auth = authForKey(key);
|
|
604
|
-
maybeProactiveRefresh(auth, key);
|
|
605
|
-
let res = await execList(key, auth);
|
|
606
|
-
if (!res.ok) {
|
|
607
|
-
let t = "";
|
|
608
|
-
try { t = await res.clone().text(); } catch {}
|
|
609
|
-
if (isAuthError(res.status, t)) {
|
|
610
|
-
const newKey = await refreshTokenFor(key, auth);
|
|
611
|
-
if (newKey) {
|
|
612
|
-
const auth2 = authForKey(newKey);
|
|
613
|
-
res = await execList(newKey, auth2);
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
if (!res.ok) return [];
|
|
618
|
-
const json = await res.json().catch(() => ({}));
|
|
619
|
-
const models = json?.data?.models;
|
|
620
|
-
if (!Array.isArray(models)) return [];
|
|
621
|
-
const sorted = [...models].sort((a, b) => creditsValue(a.credits) - creditsValue(b.credits));
|
|
622
|
-
cache = sorted.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
|
|
623
|
-
fetchedAt = now;
|
|
624
|
-
return cache;
|
|
625
|
-
} catch {
|
|
626
|
-
return [];
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
async function preheat() {
|
|
631
|
-
const url = joinUrl(resolvedBase, resolvedModelsPath);
|
|
632
|
-
const t0 = performance.now();
|
|
633
|
-
try {
|
|
634
|
-
const key = ring.next() || keys[0] || "";
|
|
635
|
-
const auth = authForKey(key);
|
|
636
|
-
const headers = {
|
|
637
|
-
Accept: "application/json",
|
|
638
|
-
"X-User-Id": auth?.uid || "",
|
|
639
|
-
"X-Domain": auth?.domain || "www.codebuddy.cn",
|
|
640
|
-
"X-Product": "SaaS",
|
|
641
|
-
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
642
|
-
Origin: "https://www.codebuddy.cn",
|
|
643
|
-
Referer: "https://www.codebuddy.cn/",
|
|
644
|
-
};
|
|
645
|
-
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
646
|
-
const opts = { headers };
|
|
647
|
-
if (dispatcher) opts.dispatcher = dispatcher;
|
|
648
|
-
const res = await fetchImpl(url, opts);
|
|
649
|
-
try { if (res.body) await res.text().catch(() => {}); } catch {}
|
|
650
|
-
return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
|
|
651
|
-
} catch (err) {
|
|
652
|
-
return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
async function close() {
|
|
657
|
-
if (agent && typeof agent.close === "function") {
|
|
658
|
-
try { await agent.close(); } catch {}
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
return {
|
|
663
|
-
id,
|
|
664
|
-
chat,
|
|
665
|
-
chatWithKeys,
|
|
666
|
-
listModels,
|
|
667
|
-
preheat,
|
|
668
|
-
close,
|
|
669
|
-
agent,
|
|
670
|
-
keyRing: ring,
|
|
671
|
-
baseUrl: resolvedBase,
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function sleep(ms) {
|
|
676
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
677
|
-
}
|
|
1
|
+
// Shim — 保持历史导入路径兼容,真实实现下沉至 ./workbuddy/ 深模块
|
|
2
|
+
export * from "./workbuddy/index.js";
|