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.
- 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 -408
- package/src/cli/provider-row.js +73 -0
- package/src/cli/status.js +3 -52
- package/src/providers/dispatcher.js +18 -6
- package/src/providers/generic.js +2 -0
- package/src/providers/openrouter.js +16 -72
- 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 -652
- package/src/routes/chat/broadband-handler.js +25 -46
- package/src/routes/chat/exhausted-handler.js +2 -2
- package/src/routes/chat/hedge-handler.js +65 -83
- 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/memory.js +2 -21
- package/src/state/merge.js +26 -0
- package/src/state/provider-config.js +143 -0
- package/src/state/schemas/provider.js +83 -133
- package/src/state/store.js +2 -28
|
@@ -1,87 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { loadProviderKeys } from "../state.js";
|
|
3
|
-
import { envInt, joinUrl, getUndici, createAgent, createChatRunner, createListModelsRunner, createPreheatRunner } from "./base.js";
|
|
4
|
-
import { joinModelId } from "./model-id.js";
|
|
1
|
+
import { createGenericProvider } from "./generic.js";
|
|
5
2
|
|
|
6
|
-
const { UndiciFetch } = getUndici();
|
|
7
3
|
const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
|
|
8
4
|
|
|
9
|
-
function collectApiKeys(apiKeys, apiKey) {
|
|
10
|
-
const list = [
|
|
11
|
-
...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
|
|
12
|
-
apiKey,
|
|
13
|
-
...(apiKeys === undefined && apiKey === undefined ? loadProviderKeys("openrouter") : []),
|
|
14
|
-
].filter((k) => typeof k === "string" && k.trim().length);
|
|
15
|
-
return [...new Set(list.map((k) => k.trim()))];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
5
|
export function createOpenRouterProvider({
|
|
19
6
|
apiKeys,
|
|
20
7
|
apiKey,
|
|
21
8
|
baseUrl = process.env.MSLXDFF_OPENROUTER_BASE_URL || DEFAULT_BASE_URL,
|
|
22
|
-
connectTimeoutMs = Number(process.env.MSLXDFF_OPENROUTER_TIMEOUT_MS) || 30_000,
|
|
23
|
-
cooldownMs = envInt("MSLXDFF_OPENROUTER_COOLDOWN_MS", 30_000),
|
|
24
|
-
retry = {
|
|
25
|
-
network: { attempts: 2, delayMs: 300 },
|
|
26
|
-
429: { attempts: 1, delayMs: 100 },
|
|
27
|
-
502: { attempts: 1, delayMs: 100 },
|
|
28
|
-
503: { attempts: 1, delayMs: 100 },
|
|
29
|
-
504: { attempts: 1, delayMs: 100 },
|
|
30
|
-
},
|
|
31
9
|
fetchImpl,
|
|
32
|
-
headers
|
|
10
|
+
headers,
|
|
33
11
|
noAgent = false,
|
|
12
|
+
...rest
|
|
34
13
|
} = {}) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
44
|
-
agent = a.agent; dispatcher = a.dispatcher;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function buildHeaders(body, key) {
|
|
48
|
-
const isStream = body?.stream !== false;
|
|
49
|
-
const h = {
|
|
50
|
-
"Content-Type": "application/json",
|
|
51
|
-
Accept: isStream ? "text/event-stream" : "*/*",
|
|
52
|
-
"User-Agent": "mslxdff",
|
|
14
|
+
return createGenericProvider({
|
|
15
|
+
id: "openrouter",
|
|
16
|
+
baseUrl,
|
|
17
|
+
apiKeys,
|
|
18
|
+
apiKey,
|
|
19
|
+
modelsPath: "/models",
|
|
20
|
+
chatPath: "/chat/completions",
|
|
21
|
+
headers: {
|
|
53
22
|
"HTTP-Referer": process.env.MSLXDFF_OPENROUTER_REFERER || "https://github.com/mslxdff",
|
|
54
23
|
"X-Title": process.env.MSLXDFF_OPENROUTER_TITLE || "mslxdff",
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return { ...h, ...extraHeaders };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const { runChat } = createChatRunner({
|
|
61
|
-
id: "openrouter", ring, cooldownMs, retry, fetchImpl, dispatcher, buildHeaders,
|
|
62
|
-
getUrl: () => `${baseUrl}/chat/completions`,
|
|
63
|
-
connectTimeoutMs,
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
async function chat(body) { return runChat(body, ring, "MSLXDFF_OPENROUTER_KEY"); }
|
|
67
|
-
async function chatWithKeys(body, keys) {
|
|
68
|
-
const tmp = createKeyRing(keys, { cooldownMs });
|
|
69
|
-
return runChat(body, tmp, "shared provider keys");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const { listModels } = createListModelsRunner({
|
|
73
|
-
id: "openrouter", ring, dispatcher, fetchImpl,
|
|
74
|
-
getUrl: () => `${baseUrl}/models`,
|
|
75
|
-
// openrouter 仅暴露免费模型
|
|
24
|
+
...(headers || {}),
|
|
25
|
+
},
|
|
76
26
|
mapModel: (raw) => raw.filter((m) => Number(m.pricing?.prompt || 0) === 0 && Number(m.pricing?.completion || 0) === 0),
|
|
27
|
+
fetchImpl,
|
|
28
|
+
noAgent,
|
|
29
|
+
...rest,
|
|
77
30
|
});
|
|
78
|
-
|
|
79
|
-
const { preheat } = createPreheatRunner({
|
|
80
|
-
dispatcher, fetchImpl, getUrl: () => `${baseUrl}/models`,
|
|
81
|
-
id: "openrouter", ring,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
async function close() { if (agent?.close) { try { await agent.close(); } catch {} } }
|
|
85
|
-
|
|
86
|
-
return { id: "openrouter", chat, chatWithKeys, listModels, preheat, close, agent, keyRing: ring };
|
|
87
31
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { joinUrl } from "../base.js";
|
|
2
|
+
import { WORKBUDDY_DEFAULT_BASE_URL } from "../../state/schemas/provider.js";
|
|
3
|
+
|
|
4
|
+
export function decodeJwtExp(token) {
|
|
5
|
+
try {
|
|
6
|
+
const payload = String(token || "").split(".")[1];
|
|
7
|
+
if (!payload) return 0;
|
|
8
|
+
let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
9
|
+
const pad = b64.length % 4;
|
|
10
|
+
if (pad) b64 += "=".repeat(4 - pad);
|
|
11
|
+
const json = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
|
|
12
|
+
return Number(json.exp || 0);
|
|
13
|
+
} catch {
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isInsufficientStatus(status, bodyText, cached) {
|
|
19
|
+
if (cached && Number(cached.total) === 0) return true;
|
|
20
|
+
if (status === 402) return true;
|
|
21
|
+
if (status === 403 || status === 429) {
|
|
22
|
+
const t = String(bodyText || "").toLowerCase();
|
|
23
|
+
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;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isAuthError(status, bodyText) {
|
|
29
|
+
if (status === 401 || status === 403) return true;
|
|
30
|
+
const t = String(bodyText || "").toLowerCase();
|
|
31
|
+
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;
|
|
32
|
+
if (t.includes("code") && (t.includes("401") || t.includes("403")) && t.includes("token")) return true;
|
|
33
|
+
if (status === 400 && t.includes("token")) return true;
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createAuthService({
|
|
38
|
+
baseUrl,
|
|
39
|
+
fetchImpl,
|
|
40
|
+
clock = Date.now,
|
|
41
|
+
dispatcher,
|
|
42
|
+
file,
|
|
43
|
+
store, // optional { saveProviderConfig, keysRef, authListRef } or custom saveFn
|
|
44
|
+
saveFn,
|
|
45
|
+
} = {}) {
|
|
46
|
+
const resolvedBase = baseUrl ? String(baseUrl).trim().replace(/\/+$/, "") : WORKBUDDY_DEFAULT_BASE_URL;
|
|
47
|
+
if (!fetchImpl) {
|
|
48
|
+
try {
|
|
49
|
+
const { getUndici } = awaitImportUndici();
|
|
50
|
+
fetchImpl = getUndici()?.UndiciFetch || fetch;
|
|
51
|
+
} catch {
|
|
52
|
+
fetchImpl = fetch;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// lazy resolve UndiciFetch if not provided
|
|
56
|
+
if (!fetchImpl) fetchImpl = fetch;
|
|
57
|
+
|
|
58
|
+
const inflightRefresh = new Map();
|
|
59
|
+
|
|
60
|
+
async function refreshTokenFor(key, auth) {
|
|
61
|
+
const rt = auth?.refreshToken;
|
|
62
|
+
const uid = auth?.uid;
|
|
63
|
+
if (!rt || !uid) return null;
|
|
64
|
+
const dedupKey = String(uid);
|
|
65
|
+
if (inflightRefresh.has(dedupKey)) {
|
|
66
|
+
try { return await inflightRefresh.get(dedupKey); } catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
const p = (async () => {
|
|
69
|
+
const url = joinUrl(resolvedBase, "/v2/plugin/auth/token/refresh");
|
|
70
|
+
const headers = {
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
Authorization: `Bearer ${key}`,
|
|
73
|
+
"X-Refresh-Token": rt,
|
|
74
|
+
"X-User-Id": uid,
|
|
75
|
+
"X-Domain": auth.domain || "www.codebuddy.cn",
|
|
76
|
+
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
77
|
+
Origin: "https://www.codebuddy.cn",
|
|
78
|
+
Referer: "https://www.codebuddy.cn/",
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
const opts = { method: "POST", headers, body: "{}" };
|
|
82
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
83
|
+
const res = await fetchImpl(url, opts);
|
|
84
|
+
const text = await res.text();
|
|
85
|
+
let j;
|
|
86
|
+
try { j = JSON.parse(text); } catch { return null; }
|
|
87
|
+
if (j.code === 0 && j.data?.accessToken) {
|
|
88
|
+
const newAt = j.data.accessToken;
|
|
89
|
+
const newRt = j.data.refreshToken || rt;
|
|
90
|
+
// attempt to persist if store provided
|
|
91
|
+
try {
|
|
92
|
+
if (saveFn) {
|
|
93
|
+
await saveFn({ newAt, newRt, uid, oldKey: key, auth });
|
|
94
|
+
} else if (store && typeof store.save === "function") {
|
|
95
|
+
await store.save({ newAt, newRt, uid, oldKey: key, auth });
|
|
96
|
+
} else if (store && Array.isArray(store.keys) && Array.isArray(store.authList)) {
|
|
97
|
+
// direct mutation fallback (legacy shape)
|
|
98
|
+
const idx = store.keys.indexOf(key);
|
|
99
|
+
if (idx >= 0) {
|
|
100
|
+
store.keys[idx] = newAt;
|
|
101
|
+
store.authList[idx] = { ...auth, refreshToken: newRt };
|
|
102
|
+
try {
|
|
103
|
+
const { saveProviderConfig } = await import("../../state.js");
|
|
104
|
+
saveProviderConfig("workbuddy", { baseUrl: resolvedBase, keys: [...store.keys], auths: [...store.authList] }, file ? { file } : {});
|
|
105
|
+
} catch {}
|
|
106
|
+
try {
|
|
107
|
+
const { writeFileSync, mkdirSync, existsSync } = await import("node:fs");
|
|
108
|
+
const { join, dirname } = await import("node:path");
|
|
109
|
+
const { tmpdir } = await import("node:os");
|
|
110
|
+
const isTest = process.env.NODE_ENV === "test" || (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test"));
|
|
111
|
+
const authDir = process.env.WORKBUDDY_AUTH_DIR || (isTest ? join(tmpdir(), "mslxdff-test-auths") : (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths")));
|
|
112
|
+
mkdirSync(authDir, { recursive: true });
|
|
113
|
+
const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now() / 1000) + 5184000; } })();
|
|
114
|
+
const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
|
|
115
|
+
const fp = join(authDir, `workbuddy-${uid}.json`);
|
|
116
|
+
const tmp = fp + ".tmp";
|
|
117
|
+
writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
118
|
+
try {
|
|
119
|
+
if (existsSync(fp)) {
|
|
120
|
+
const { unlinkSync, renameSync } = await import("node:fs");
|
|
121
|
+
unlinkSync(fp);
|
|
122
|
+
renameSync(tmp, fp);
|
|
123
|
+
} else {
|
|
124
|
+
const { renameSync } = await import("node:fs");
|
|
125
|
+
renameSync(tmp, fp);
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {}
|
|
134
|
+
return newAt;
|
|
135
|
+
}
|
|
136
|
+
} catch {}
|
|
137
|
+
return null;
|
|
138
|
+
})();
|
|
139
|
+
inflightRefresh.set(dedupKey, p);
|
|
140
|
+
try {
|
|
141
|
+
const r = await p;
|
|
142
|
+
return r;
|
|
143
|
+
} finally {
|
|
144
|
+
inflightRefresh.delete(dedupKey);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function maybeProactiveRefresh(auth, key) {
|
|
149
|
+
try {
|
|
150
|
+
const exp = decodeJwtExp(key);
|
|
151
|
+
if (!exp) return;
|
|
152
|
+
const remain = exp * 1000 - clock();
|
|
153
|
+
if (remain < 5 * 60 * 1000 && remain > -60 * 60 * 1000) {
|
|
154
|
+
void refreshTokenFor(key, auth).catch(() => {});
|
|
155
|
+
}
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { refreshTokenFor, maybeProactiveRefresh, decodeJwtExp, isAuthError, isInsufficientStatus, _inflight: inflightRefresh };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function awaitImportUndici() {
|
|
163
|
+
try {
|
|
164
|
+
// dynamic to avoid top-level await
|
|
165
|
+
let UndiciFetch = null;
|
|
166
|
+
try {
|
|
167
|
+
// eslint-disable-next-line no-undef
|
|
168
|
+
const mod = globalThis.__mslxdff_undici || null;
|
|
169
|
+
if (mod) return mod;
|
|
170
|
+
} catch {}
|
|
171
|
+
return { UndiciFetch: null };
|
|
172
|
+
} catch {
|
|
173
|
+
return { UndiciFetch: null };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const BALANCE_TTL_MS = 5 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
export function createBalanceCache({ ttlMs = BALANCE_TTL_MS, now = Date.now } = {}) {
|
|
4
|
+
const map = new Map(); // uid -> { total, dailyPacks, activeCount, nextExpire, fetchedAt, totalStr? }
|
|
5
|
+
function getCachedBalance(uid) {
|
|
6
|
+
const v = map.get(String(uid));
|
|
7
|
+
if (!v) return null;
|
|
8
|
+
if (now() - v.fetchedAt > ttlMs) {
|
|
9
|
+
map.delete(String(uid));
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return v;
|
|
13
|
+
}
|
|
14
|
+
function setCachedBalance(uid, data) {
|
|
15
|
+
const v = { ...data, fetchedAt: now() };
|
|
16
|
+
map.set(String(uid), v);
|
|
17
|
+
return v;
|
|
18
|
+
}
|
|
19
|
+
function getBalanceCache() { return map; }
|
|
20
|
+
function clearBalanceCache() { map.clear(); }
|
|
21
|
+
|
|
22
|
+
async function fetchBalance({ uid, key, auth, domain, baseUrl = "https://www.codebuddy.cn", fetchImpl } = {}) {
|
|
23
|
+
const at = key || auth?.accessToken || "";
|
|
24
|
+
const u = uid || auth?.uid || "";
|
|
25
|
+
const d = domain || auth?.domain || "www.codebuddy.cn";
|
|
26
|
+
if (!u || !at) return null;
|
|
27
|
+
const fetcher = fetchImpl || fetch;
|
|
28
|
+
const body = JSON.stringify({
|
|
29
|
+
PageNumber: 1, PageSize: 100, ProductCode: "p_tcaca", Status: [0, 3],
|
|
30
|
+
PackageEndTimeRangeBegin: "2026-08-01 00:00:00",
|
|
31
|
+
PackageEndTimeRangeEnd: "2030-01-01 00:00:00",
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetcher(`${baseUrl.replace(/\/+$/, "")}/v2/billing/meter/get-user-resource`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: `Bearer ${at}`,
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
"X-User-Id": u,
|
|
40
|
+
"X-Domain": d,
|
|
41
|
+
},
|
|
42
|
+
body,
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) return null;
|
|
45
|
+
const j = await res.json().catch(() => null);
|
|
46
|
+
const accs = j?.data?.Response?.Data?.Accounts || j?.data?.Response?.data?.Accounts || [];
|
|
47
|
+
const accounts = Array.isArray(accs) ? accs : [];
|
|
48
|
+
const active = accounts.filter(a => a.Status === 0);
|
|
49
|
+
const total = active.reduce((s, a) => s + Number(a.CycleCapacityRemainPrecise || a.CycleCapacityRemain || 0), 0);
|
|
50
|
+
const dailyPacks = active.filter(a => a.PackageName?.includes("裂变包") && String(a.CycleCapacitySizePrecise) === "100");
|
|
51
|
+
const sorted = [...active].sort((a,b)=> new Date(a.CycleEndTime) - new Date(b.CycleEndTime));
|
|
52
|
+
const nextExpire = sorted[0]?.CycleEndTime || null;
|
|
53
|
+
const data = {
|
|
54
|
+
total: Number(total.toFixed(2)),
|
|
55
|
+
dailyPacks: dailyPacks.length,
|
|
56
|
+
activeCount: active.length,
|
|
57
|
+
nextExpire,
|
|
58
|
+
fetchedAt: now(),
|
|
59
|
+
};
|
|
60
|
+
data.totalStr = total.toFixed(2);
|
|
61
|
+
setCachedBalance(u, data);
|
|
62
|
+
return data;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function getBalanceWithCache({ uid, key, auth, domain, baseUrl, fetchImpl } = {}) {
|
|
69
|
+
const cached = getCachedBalance(uid || auth?.uid);
|
|
70
|
+
if (cached) return cached;
|
|
71
|
+
return fetchBalance({ uid, key, auth, domain, baseUrl, fetchImpl });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { getCachedBalance, setCachedBalance, getBalanceCache, clearBalanceCache, fetchBalance, getBalanceWithCache, _map: map, _ttlMs: ttlMs };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 默认单例(保持与 src/providers/workbuddy-balance.js 兼容,供未注入场景使用)
|
|
78
|
+
const defaultCache = createBalanceCache();
|
|
79
|
+
export const getCachedBalance = defaultCache.getCachedBalance;
|
|
80
|
+
export const setCachedBalance = defaultCache.setCachedBalance;
|
|
81
|
+
export const getBalanceCache = defaultCache.getBalanceCache;
|
|
82
|
+
export const clearBalanceCache = defaultCache.clearBalanceCache;
|
|
83
|
+
export const fetchBalance = defaultCache.fetchBalance;
|
|
84
|
+
export const getBalanceWithCache = defaultCache.getBalanceWithCache;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { joinUrl } from "../base.js";
|
|
2
|
+
import { isAuthError, isInsufficientStatus } from "./auth.js";
|
|
3
|
+
import { appendRotationLog as defaultAppend } from "./rotation-log.js";
|
|
4
|
+
|
|
5
|
+
function buildAuthHeaders(key, auth) {
|
|
6
|
+
const h = {
|
|
7
|
+
"Content-Type": "application/json",
|
|
8
|
+
Accept: "text/event-stream",
|
|
9
|
+
"User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
|
|
10
|
+
Origin: "https://www.codebuddy.cn",
|
|
11
|
+
Referer: "https://www.codebuddy.cn/",
|
|
12
|
+
"X-Product": "SaaS",
|
|
13
|
+
};
|
|
14
|
+
if (key) h["Authorization"] = `Bearer ${key}`;
|
|
15
|
+
if (auth?.uid) h["X-User-Id"] = auth.uid;
|
|
16
|
+
if (auth?.domain) h["X-Domain"] = auth.domain;
|
|
17
|
+
else h["X-Domain"] = "www.codebuddy.cn";
|
|
18
|
+
if (auth?.enterpriseId) {
|
|
19
|
+
h["X-Enterprise-Id"] = auth.enterpriseId;
|
|
20
|
+
h["X-Tenant-Id"] = auth.enterpriseId;
|
|
21
|
+
}
|
|
22
|
+
return h;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function withUidHeader(res, uid) {
|
|
26
|
+
try {
|
|
27
|
+
res.headers.set("x-mslxdff-workbuddy-uid", uid);
|
|
28
|
+
return res;
|
|
29
|
+
} catch {
|
|
30
|
+
try {
|
|
31
|
+
const h = new Headers(res.headers);
|
|
32
|
+
h.set("x-mslxdff-workbuddy-uid", uid);
|
|
33
|
+
const clone = new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
|
|
34
|
+
clone._t = res._t;
|
|
35
|
+
return clone;
|
|
36
|
+
} catch {
|
|
37
|
+
return res;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
43
|
+
|
|
44
|
+
export function createChatService({
|
|
45
|
+
id = "workbuddy",
|
|
46
|
+
baseUrl,
|
|
47
|
+
chatPath,
|
|
48
|
+
keys, // mutable array ref
|
|
49
|
+
authList, // mutable array ref
|
|
50
|
+
ring, // keyRing instance for auto mode
|
|
51
|
+
fetchImpl,
|
|
52
|
+
dispatcher,
|
|
53
|
+
connectTimeoutMs = 30_000,
|
|
54
|
+
retry = {
|
|
55
|
+
network: { attempts: 2, delayMs: 300 },
|
|
56
|
+
429: { attempts: 1, delayMs: 100 },
|
|
57
|
+
502: { attempts: 1, delayMs: 100 },
|
|
58
|
+
503: { attempts: 1, delayMs: 100 },
|
|
59
|
+
504: { attempts: 1, delayMs: 100 },
|
|
60
|
+
},
|
|
61
|
+
balanceCache, // { getCachedBalance, setCachedBalance }
|
|
62
|
+
authService, // { refreshTokenFor, maybeProactiveRefresh }
|
|
63
|
+
logger, // { append } or null
|
|
64
|
+
clock = Date.now,
|
|
65
|
+
cooldownMs = 30_000,
|
|
66
|
+
} = {}) {
|
|
67
|
+
const getBalance = balanceCache?.getCachedBalance || (() => null);
|
|
68
|
+
const setBalance = balanceCache?.setCachedBalance || (() => {});
|
|
69
|
+
const doLog = (opts) => {
|
|
70
|
+
if (logger && typeof logger.append === "function") {
|
|
71
|
+
try { logger.append(opts); } catch {}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (logger && typeof logger.log === "function") {
|
|
75
|
+
try { logger.log(opts); } catch {}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// fallback to default append (may write to fs; test passes noop logger so not called)
|
|
79
|
+
try { defaultAppend(opts); } catch {}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function authForKey(key) {
|
|
83
|
+
const idx = keys.indexOf(key);
|
|
84
|
+
if (idx >= 0 && authList[idx]) return authList[idx];
|
|
85
|
+
if (authList.length) return authList[0];
|
|
86
|
+
return { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function attemptOnce(url, body, key) {
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
92
|
+
try {
|
|
93
|
+
const auth = authForKey(key);
|
|
94
|
+
const headers = buildAuthHeaders(key, auth);
|
|
95
|
+
const finalBody = { ...body, stream: true };
|
|
96
|
+
const opts = { method: "POST", headers, body: JSON.stringify(finalBody), signal: controller.signal };
|
|
97
|
+
if (dispatcher) opts.dispatcher = dispatcher;
|
|
98
|
+
const res = await fetchImpl(url, opts);
|
|
99
|
+
return res;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return err;
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function runChat(body, activeRing, opts = {}) {
|
|
108
|
+
const url = joinUrl(baseUrl, chatPath);
|
|
109
|
+
const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : clock();
|
|
110
|
+
const preferredUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
|
|
111
|
+
const modelForLog = body?.model || "";
|
|
112
|
+
|
|
113
|
+
if (preferredUid) {
|
|
114
|
+
const idx = authList.findIndex(a => a.uid === preferredUid || a.uid.startsWith(preferredUid));
|
|
115
|
+
if (idx < 0) {
|
|
116
|
+
const errBody = JSON.stringify({ error: `workbuddy uid not found: ${preferredUid}` });
|
|
117
|
+
const res = new Response(errBody, { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-workbuddy-reason": "uid-not-found", "x-mslxdff-workbuddy-uid": preferredUid } });
|
|
118
|
+
res._t = { attempts: [], waitMs: 0, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) };
|
|
119
|
+
return res;
|
|
120
|
+
}
|
|
121
|
+
const key = keys[idx];
|
|
122
|
+
const auth = authList[idx];
|
|
123
|
+
authService?.maybeProactiveRefresh?.(auth, key);
|
|
124
|
+
const cached = getBalance(auth.uid);
|
|
125
|
+
if (cached && Number(cached.total) === 0) {
|
|
126
|
+
const errBody = JSON.stringify({ error: `workbuddy uid in cooldown (balance 0): ${auth.uid}` });
|
|
127
|
+
const res = new Response(errBody, { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-workbuddy-reason": "uid-cooling", "x-mslxdff-workbuddy-uid": auth.uid } });
|
|
128
|
+
res._t = { attempts: [], waitMs: 0, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) };
|
|
129
|
+
return res;
|
|
130
|
+
}
|
|
131
|
+
const attempts = [];
|
|
132
|
+
let waitMs = 0;
|
|
133
|
+
for (let attempt = 0; ; attempt++) {
|
|
134
|
+
const t = typeof performance !== "undefined" && performance.now ? performance.now() : clock();
|
|
135
|
+
let result = await attemptOnce(url, body, key);
|
|
136
|
+
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
137
|
+
attempts.push({ attempt, type, ms: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t) });
|
|
138
|
+
if (result instanceof Error) {
|
|
139
|
+
const entry = retry?.network;
|
|
140
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
141
|
+
activeRing.onError(key);
|
|
142
|
+
result._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) };
|
|
143
|
+
throw result;
|
|
144
|
+
}
|
|
145
|
+
if (attempt === 0) {
|
|
146
|
+
let bodyText = "";
|
|
147
|
+
try { bodyText = await result.clone().text(); } catch {}
|
|
148
|
+
if (isAuthError(result.status, bodyText)) {
|
|
149
|
+
const newKey = await authService?.refreshTokenFor?.(key, auth);
|
|
150
|
+
if (newKey) {
|
|
151
|
+
const auth2 = authForKey(newKey);
|
|
152
|
+
const headers2 = buildAuthHeaders(newKey, auth2);
|
|
153
|
+
const finalBody = { ...body, stream: true };
|
|
154
|
+
const controller2 = new AbortController();
|
|
155
|
+
const timer2 = setTimeout(() => controller2.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
156
|
+
try {
|
|
157
|
+
const opts2 = { method: "POST", headers: headers2, body: JSON.stringify(finalBody), signal: controller2.signal };
|
|
158
|
+
if (dispatcher) opts2.dispatcher = dispatcher;
|
|
159
|
+
let res2 = await fetchImpl(url, opts2);
|
|
160
|
+
let res2Body = "";
|
|
161
|
+
try { res2Body = await res2.clone().text(); } catch {}
|
|
162
|
+
const stillAuth = isAuthError(res2.status, res2Body);
|
|
163
|
+
res2._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0), refreshed: true };
|
|
164
|
+
res2 = withUidHeader(res2, auth2.uid || auth.uid);
|
|
165
|
+
if (stillAuth || res2.status === 429 || res2.status >= 500) activeRing.onError(newKey);
|
|
166
|
+
doLog({ uid: auth2.uid || auth.uid, model: modelForLog, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0), balanceHit: false, error: stillAuth ? `still auth ${res2.status}` : undefined });
|
|
167
|
+
return res2;
|
|
168
|
+
} catch (e2) { e2._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) }; throw e2; } finally { clearTimeout(timer2); }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const entry = retry?.[result.status];
|
|
173
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
174
|
+
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
175
|
+
result = withUidHeader(result, auth.uid);
|
|
176
|
+
result._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) };
|
|
177
|
+
doLog({ uid: auth.uid, model: modelForLog, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0), balanceHit: false });
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// auto rotation
|
|
183
|
+
const attempts = [];
|
|
184
|
+
let waitMs = 0;
|
|
185
|
+
let lastErr = null;
|
|
186
|
+
const tried = new Set();
|
|
187
|
+
const maxTries = Math.max(1, keys.length);
|
|
188
|
+
for (let triedCount = 0; triedCount < maxTries; triedCount++) {
|
|
189
|
+
const key = activeRing.next();
|
|
190
|
+
if (!key) {
|
|
191
|
+
if (triedCount === 0) {
|
|
192
|
+
const err = new Error(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
|
|
193
|
+
err._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0), cooldownMs };
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
const auth = authForKey(key);
|
|
199
|
+
const uid = auth?.uid || "";
|
|
200
|
+
if (tried.has(uid || key)) continue;
|
|
201
|
+
tried.add(uid || key);
|
|
202
|
+
const cached = uid ? getBalance(uid) : null;
|
|
203
|
+
if (cached && Number(cached.total) === 0) {
|
|
204
|
+
activeRing.onError(key);
|
|
205
|
+
setBalance(uid, { ...cached, total: 0 });
|
|
206
|
+
doLog({ uid, model: modelForLog, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0), balanceHit: true });
|
|
207
|
+
// 单号余额为 0 直接返回 403,避免抛“exhausted”让调用方误判为 500
|
|
208
|
+
if (keys.length === 1 || activeRing.available() === 0) {
|
|
209
|
+
const errBody = JSON.stringify({ error: `workbuddy uid in cooldown (balance 0): ${uid}` });
|
|
210
|
+
const res = new Response(errBody, { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-workbuddy-reason": "uid-cooling", "x-mslxdff-workbuddy-uid": uid } });
|
|
211
|
+
res._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) };
|
|
212
|
+
return res;
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!key && activeRing.size === 0) {
|
|
217
|
+
const err = new Error(`${id}: missing MSLXDFF_WORKBUDDY_KEY (chat requires a real key) — run node workbuddy-token-auto.js`);
|
|
218
|
+
err._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) };
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
authService?.maybeProactiveRefresh?.(auth, key);
|
|
222
|
+
let result = null;
|
|
223
|
+
let attempt = 0;
|
|
224
|
+
for (; ; attempt++) {
|
|
225
|
+
const t = typeof performance !== "undefined" && performance.now ? performance.now() : clock();
|
|
226
|
+
result = await attemptOnce(url, body, key);
|
|
227
|
+
const type = result instanceof Error ? "network" : `http${result?.status}`;
|
|
228
|
+
attempts.push({ attempt: triedCount * 10 + attempt, type, ms: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t), uid });
|
|
229
|
+
if (result instanceof Error) {
|
|
230
|
+
const entry = retry?.network;
|
|
231
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
232
|
+
activeRing.onError(key);
|
|
233
|
+
lastErr = result;
|
|
234
|
+
result._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) };
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
if (attempt === 0) {
|
|
238
|
+
let bodyText = "";
|
|
239
|
+
try { bodyText = await result.clone().text(); } catch {}
|
|
240
|
+
const needRefresh = isAuthError(result.status, bodyText);
|
|
241
|
+
if (needRefresh) {
|
|
242
|
+
const newKey = await authService?.refreshTokenFor?.(key, auth);
|
|
243
|
+
if (newKey) {
|
|
244
|
+
const auth2 = authForKey(newKey);
|
|
245
|
+
const headers2 = buildAuthHeaders(newKey, auth2);
|
|
246
|
+
const finalBody = { ...body, stream: true };
|
|
247
|
+
const controller2 = new AbortController();
|
|
248
|
+
const timer2 = setTimeout(() => controller2.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
249
|
+
try {
|
|
250
|
+
const opts2 = { method: "POST", headers: headers2, body: JSON.stringify(finalBody), signal: controller2.signal };
|
|
251
|
+
if (dispatcher) opts2.dispatcher = dispatcher;
|
|
252
|
+
let res2 = await fetchImpl(url, opts2);
|
|
253
|
+
let res2Body = "";
|
|
254
|
+
try { res2Body = await res2.clone().text(); } catch {}
|
|
255
|
+
const stillAuth = isAuthError(res2.status, res2Body);
|
|
256
|
+
res2._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0), refreshed: true };
|
|
257
|
+
res2 = withUidHeader(res2, auth2.uid || uid);
|
|
258
|
+
if (stillAuth || res2.status === 429 || res2.status >= 500) activeRing.onError(newKey);
|
|
259
|
+
doLog({ uid: auth2.uid || uid, model: modelForLog, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0), balanceHit: false, error: stillAuth ? `still auth ${res2.status}` : undefined });
|
|
260
|
+
if (stillAuth) {
|
|
261
|
+
lastErr = new Error(`workbuddy auth still failing after refresh for ${uid}: ${res2Body.slice(0, 120)}`);
|
|
262
|
+
lastErr._t = res2._t;
|
|
263
|
+
activeRing.onError(newKey);
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
return res2;
|
|
267
|
+
} catch (e2) { e2._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) }; lastErr = e2; break; } finally { clearTimeout(timer2); }
|
|
268
|
+
} else {
|
|
269
|
+
lastErr = new Error(`workbuddy refresh failed for ${uid}: ${bodyText.slice(0, 120)}`);
|
|
270
|
+
lastErr._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) };
|
|
271
|
+
activeRing.onError(key);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const entry = retry?.[result.status];
|
|
277
|
+
if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
if (result instanceof Error) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
let bodyText = "";
|
|
284
|
+
let isInsuff = false;
|
|
285
|
+
if (result.status === 402 || result.status === 403 || result.status === 429) {
|
|
286
|
+
try { bodyText = await result.clone().text(); } catch { try { bodyText = await result.text(); } catch { bodyText = ""; } }
|
|
287
|
+
isInsuff = isInsufficientStatus(result.status, bodyText, cached);
|
|
288
|
+
}
|
|
289
|
+
if (isInsuff) {
|
|
290
|
+
activeRing.onError(key);
|
|
291
|
+
if (uid) setBalance(uid, { total: 0, dailyPacks: 0, activeCount: 0, nextExpire: null, fetchedAt: clock() });
|
|
292
|
+
doLog({ uid, model: modelForLog, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0), balanceHit: true, error: bodyText.slice(0, 120) });
|
|
293
|
+
lastErr = new Error(`workbuddy insufficient for ${uid}: ${bodyText.slice(0, 120)}`);
|
|
294
|
+
lastErr._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) };
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
|
|
298
|
+
result = withUidHeader(result, uid);
|
|
299
|
+
result._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) };
|
|
300
|
+
doLog({ uid, model: modelForLog, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0), balanceHit: false });
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
if (lastErr) throw lastErr;
|
|
304
|
+
const err = new Error(`${id}: all workbuddy accounts exhausted or unavailable`);
|
|
305
|
+
err._t = { attempts, waitMs, totalMs: Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0) };
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { runChat, authForKey, buildAuthHeaders, withUidHeader, attemptOnce };
|
|
310
|
+
}
|