mslxdff 0.1.66 → 0.1.68

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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/bench/probe.js +68 -0
  3. package/src/bench/report.js +56 -0
  4. package/src/bench/runner.js +74 -0
  5. package/src/chat/cooling.js +99 -0
  6. package/src/chat/direct.js +57 -0
  7. package/src/chat/gateway.js +148 -0
  8. package/src/chat/orchestrator.js +218 -0
  9. package/src/chat/sse.js +69 -0
  10. package/src/chat/upstream.js +59 -501
  11. package/src/cli/bootstrap.js +1 -408
  12. package/src/cli/commands/provider/bench.js +140 -0
  13. package/src/cli/commands/provider/index.js +2 -0
  14. package/src/cli/provider-row.js +73 -0
  15. package/src/cli/status.js +3 -52
  16. package/src/metrics.js +63 -0
  17. package/src/providers/dispatcher.js +18 -6
  18. package/src/providers/generic.js +2 -0
  19. package/src/providers/openrouter.js +16 -72
  20. package/src/providers/workbuddy/auth.js +175 -0
  21. package/src/providers/workbuddy/balance.js +84 -0
  22. package/src/providers/workbuddy/chat.js +310 -0
  23. package/src/providers/workbuddy/index.js +263 -0
  24. package/src/providers/workbuddy/models.js +111 -0
  25. package/src/providers/workbuddy/rotation-log.js +54 -0
  26. package/src/providers/workbuddy.js +2 -652
  27. package/src/routes/chat/broadband-handler.js +25 -46
  28. package/src/routes/chat/exhausted-handler.js +2 -2
  29. package/src/routes/chat/hedge-handler.js +65 -83
  30. package/src/routes/chat/local-handler.js +32 -61
  31. package/src/routes/chat/peer-handler.js +32 -23
  32. package/src/routes/chat/relay-pipeline.js +151 -0
  33. package/src/runtime/bootstrap.js +408 -0
  34. package/src/state/memory.js +2 -21
  35. package/src/state/merge.js +26 -0
  36. package/src/state/provider-config.js +143 -0
  37. package/src/state/schemas/provider.js +83 -133
  38. package/src/state/store.js +2 -28
package/src/cli/status.js CHANGED
@@ -13,6 +13,7 @@ import { refreshGroupMembers } from "../groups.js";
13
13
  import { fmtShanghaiYMDHM } from "../time.js";
14
14
  import { fmtStatus, fmtUptime, fmtTs } from "./format.js";
15
15
  import { compareSemver } from "./policy.js";
16
+ import { buildProviderRows, formatProviderRow } from "./provider-row.js";
16
17
 
17
18
  export async function printStatus(VERSION) {
18
19
  const daemon = readPid();
@@ -50,61 +51,11 @@ export async function printStatus(VERSION) {
50
51
  } catch {}
51
52
 
52
53
  try {
53
- const configs = loadProviderConfigs();
54
- const upstreamBase = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
55
- const providerRows = [];
56
- const opAllowed = loadProviderAllowedModels("opencode");
57
- providerRows.push({ id: "opencode", enabled: true, baseUrl: upstreamBase, keys: [], allowed: opAllowed, share: false, note: "built-in, no key, cannot share" });
58
- const genericIds = new Set(Object.keys(configs).filter((id) => id !== "opencode"));
59
- try {
60
- const raw = JSON.parse(readFileSync(stateFile, "utf8"));
61
- const pk = raw.providerKeys || {};
62
- for (const id of Object.keys(pk)) if (id !== "opencode") genericIds.add(id);
63
- const cfgRaw = raw.providerConfigs || {};
64
- for (const id of Object.keys(cfgRaw)) if (id !== "opencode") genericIds.add(id);
65
- } catch {}
66
- for (const k of Object.keys(process.env)) {
67
- const m = k.match(/^MSLXDFF_(.+)_KEY$/);
68
- if (m) {
69
- const id = m[1].toLowerCase().replace(/__/g, "-");
70
- if (id !== "opencode") genericIds.add(id);
71
- }
72
- }
73
- for (const gid of [...genericIds].sort()) {
74
- const cfg = configs[gid];
75
- const keys = loadProviderKeys(gid);
76
- const baseUrl = loadProviderBaseUrl(gid) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : "");
77
- const share = loadProviderShareKeys(gid);
78
- const allowed = loadProviderAllowedModels(gid);
79
- let enabled = Boolean(baseUrl && keys.length) || (gid === "openrouter" && keys.length > 0);
80
- const auths = gid === "workbuddy" ? (loadProviderAuths(gid) || []) : [];
81
- let note = "";
82
- const isWorkbuddyStub = gid === "workbuddy" && (keys.includes("k-new") || baseUrl.includes("127.0.0.1") || (keys.length === 1 && keys[0].length < 20));
83
- if (isWorkbuddyStub) {
84
- enabled = false;
85
- note = "测试桩 (key=k-new, baseUrl=127.0.0.1) — 请重跑 node workbuddy-token-auto.js 写入真实 JWT";
86
- } else if (!enabled) {
87
- if (!baseUrl && !keys.length) note = "no baseUrl, no keys";
88
- else if (!baseUrl) note = "missing baseUrl";
89
- else if (!keys.length) note = "no keys";
90
- } else if (gid === "workbuddy" && auths.length && auths.length !== keys.length) {
91
- note = `${auths.length} auth(s) / ${keys.length} key(s) — 数量不一致请重跑 workbuddy-token-auto.js`;
92
- }
93
- providerRows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, share, note, authCount: auths.length });
94
- }
54
+ const providerRows = buildProviderRows({});
95
55
  const enabledCount = providerRows.filter((r) => r.enabled).length;
96
56
  console.log(`\nupstream providers (${providerRows.length}, ${enabledCount} enabled) — mslxdff -providers list 查看详情`);
97
57
  for (const p of providerRows) {
98
- const dot = p.enabled ? "●" : "○";
99
- const state = p.enabled ? "enabled " : "disabled";
100
- let keysInfo;
101
- if (p.id === "opencode") keysInfo = "无需 key (内置)";
102
- else keysInfo = p.keys.length ? `${p.keys.length} key${p.keys.length > 1 ? "s" : ""} ${p.keys.map((k) => `${k.slice(0, 3)}…${k.slice(-3)}`).join(", ")}` : "0 keys";
103
- const authInfo = p.authCount ? ` ${p.authCount} acc` : "";
104
- const allowInfo = p.allowed.length ? `allow=${p.allowed.length}(${p.allowed.slice(0, 2).join(",")}${p.allowed.length > 2 ? "…" : ""})` : "allow=all";
105
- const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
106
- const note = p.note ? ` (${p.note})` : "";
107
- console.log(` ${dot} ${p.id.padEnd(12)} ${state} ${keysInfo}${authInfo} ${allowInfo.padEnd(18)} baseUrl=${p.baseUrl} ${shareInfo}${note}`);
58
+ console.log(formatProviderRow(p));
108
59
  }
109
60
  if (enabledCount === 1 && providerRows.length === 1) {
110
61
  console.log(` (仅 opencode 内置免费通道;按需加:mslxdff -provider add bai https://api.b.ai/v1 <key> 或 node workbuddy-token-auto.js)`);
package/src/metrics.js ADDED
@@ -0,0 +1,63 @@
1
+ import { normalizeFullId, toFullId } from "./providers/model-id.js";
2
+
3
+ export { normalizeFullId, toFullId };
4
+
5
+ // 从上游 usage 或文本兜底算 tps / charsPerSec
6
+ export function computeMetrics({ ttfbMs, totalMs, promptTokens, completionTokens, chars }) {
7
+ const ttfb = Number(ttfbMs);
8
+ const total = Number(totalMs);
9
+ const hasTtfb = Number.isFinite(ttfb) && ttfb >= 0;
10
+ const hasTotal = Number.isFinite(total) && total >= 0;
11
+ const completionMs = hasTtfb && hasTotal ? Math.max(0, total - ttfb) : hasTotal ? total : null;
12
+ const comp = Number(completionTokens);
13
+ const hasComp = Number.isFinite(comp) && comp > 0 && Number.isFinite(completionMs) && completionMs > 0;
14
+ const tps = hasComp ? Number((comp / (completionMs / 1000)).toFixed(1)) : null;
15
+ const c = Number(chars);
16
+ const hasChars = Number.isFinite(c) && c > 0 && Number.isFinite(completionMs) && completionMs > 0;
17
+ const charsPerSec = !hasComp && hasChars ? Math.round(c / (completionMs / 1000)) : null;
18
+ return { completionMs, tps, charsPerSec };
19
+ }
20
+
21
+ // 从 Response 文本或 SSE 末帧提取 usage
22
+ export function extractUsageFromJson(parsed) {
23
+ if (!parsed || typeof parsed !== "object") return null;
24
+ const u = parsed.usage;
25
+ if (!u || typeof u !== "object") return null;
26
+ const prompt = Number(u.prompt_tokens ?? u.promptTokens ?? u.input_tokens);
27
+ const comp = Number(u.completion_tokens ?? u.completionTokens ?? u.output_tokens);
28
+ const total = Number(u.total_tokens ?? u.totalTokens);
29
+ const out = {};
30
+ if (Number.isFinite(prompt)) out.prompt_tokens = prompt;
31
+ if (Number.isFinite(comp)) out.completion_tokens = comp;
32
+ if (Number.isFinite(total)) out.total_tokens = total;
33
+ // reasoning_tokens 透传
34
+ const rt = u.completion_tokens_details?.reasoning_tokens ?? u.reasoning_tokens;
35
+ if (Number.isFinite(Number(rt))) out.reasoning_tokens = Number(rt);
36
+ return Object.keys(out).length ? out : null;
37
+ }
38
+
39
+ export function extractUsageFromSseText(sseText) {
40
+ if (!sseText) return null;
41
+ const lines = String(sseText).split("\n");
42
+ for (let i = lines.length - 1; i >= 0; i--) {
43
+ const line = lines[i].trim();
44
+ if (!line.startsWith("data:")) continue;
45
+ const data = line.slice(5).trim();
46
+ if (data === "[DONE]") continue;
47
+ try {
48
+ const j = JSON.parse(data);
49
+ if (j.usage) return extractUsageFromJson(j);
50
+ if (j.choices?.[0]?.finish_reason && j.usage) return extractUsageFromJson(j);
51
+ } catch {}
52
+ }
53
+ return null;
54
+ }
55
+
56
+ // 归一模型全称:provider + raw -> opencode/xxx
57
+ export function resolveFullId(rawModel, providerHint) {
58
+ const raw = String(rawModel || "").trim();
59
+ if (!raw) return "";
60
+ if (raw.includes("/")) return normalizeFullId(raw);
61
+ const prov = String(providerHint || "opencode").trim() || "opencode";
62
+ return toFullId(prov, raw);
63
+ }
@@ -1,10 +1,22 @@
1
1
  import { splitModelId, DEFAULT_PROVIDER, joinModelId } from "./model-id.js";
2
- import { isModelAllowed, loadProviderAllowedModels, loadProviderAllowAnyModels } from "../state.js";
2
+ import { isModelAllowed as stateIsAllowed, loadProviderAllowedModels as stateLoadAllowed, loadProviderAllowAnyModels as stateLoadAllowAny } from "../state.js";
3
3
 
4
4
  // 多供应商 dispatcher:把多个 Provider 聚合成一个 `upstream` 形状(chat/preheat/close),
5
5
  // 按 body.model 的前缀路由到对应供应商,转发上游前剥掉前缀只发原始 id。
6
- export function createProviderDispatcher(providers = []) {
6
+ // 纯化:第二参可注入纯函数 isAllowed/getAllowed/getAllowAny,便于单测不读盘
7
+ export function createProviderDispatcher(providers = [], opts = {}) {
7
8
  const byId = new Map(providers.map((p) => [p.id, p]));
9
+ // 纯化注入:第二参可为 {isAllowed, getAllowedModels, getAllowAny} 或直接函数 isAllowed
10
+ let isAllowedFn, getAllowedFn, getAllowAnyFn;
11
+ if (typeof opts === "function") {
12
+ isAllowedFn = opts;
13
+ getAllowedFn = stateLoadAllowed;
14
+ getAllowAnyFn = stateLoadAllowAny;
15
+ } else {
16
+ isAllowedFn = opts.isAllowed || stateIsAllowed;
17
+ getAllowedFn = opts.getAllowedModels || stateLoadAllowed;
18
+ getAllowAnyFn = opts.getAllowAny || stateLoadAllowAny;
19
+ }
8
20
 
9
21
  function resolve(model) {
10
22
  const split = splitModelId(model, providers.map((p) => p.id));
@@ -29,8 +41,8 @@ export function createProviderDispatcher(providers = []) {
29
41
  raw = modelPart;
30
42
  }
31
43
  }
32
- if (!isModelAllowed(provider.id, raw)) {
33
- const allowed = loadProviderAllowedModels(provider.id);
44
+ if (!isAllowedFn(provider.id, raw)) {
45
+ const allowed = getAllowedFn(provider.id) || [];
34
46
  const msg = `model not allowed for provider "${provider.id}": "${raw}" — allowed: ${allowed.join(", ") || "(none)"} (use: mslxdff -provider ${provider.id} allowlist add <model>)`;
35
47
  return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-allowlist": "1" } });
36
48
  }
@@ -58,8 +70,8 @@ export function createProviderDispatcher(providers = []) {
58
70
  } catch {
59
71
  list = [];
60
72
  }
61
- const allowed = loadProviderAllowedModels(p.id);
62
- const allowAny = loadProviderAllowAnyModels(p.id);
73
+ const allowed = getAllowedFn(p.id) || [];
74
+ const allowAny = getAllowAnyFn(p.id);
63
75
  const allowedSet = allowed.length ? new Set(allowed) : null;
64
76
  // 空名单且不允许任意模型 => 该供应商不暴露任何模型(安全默认)
65
77
  if (!allowedSet && !allowAny) continue;
@@ -29,6 +29,7 @@ export function createGenericProvider({
29
29
  },
30
30
  fetchImpl,
31
31
  headers: extraHeaders,
32
+ mapModel,
32
33
  noAgent = false,
33
34
  file,
34
35
  } = {}) {
@@ -79,6 +80,7 @@ export function createGenericProvider({
79
80
  const { listModels } = createListModelsRunner({
80
81
  id, ring, dispatcher, fetchImpl,
81
82
  getUrl: () => joinUrl(resolvedBase, resolvedModelsPath),
83
+ mapModel,
82
84
  });
83
85
 
84
86
  const { preheat } = createPreheatRunner({
@@ -1,87 +1,31 @@
1
- import { createKeyRing } from "./keyring.js";
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: extraHeaders,
10
+ headers,
33
11
  noAgent = false,
12
+ ...rest
34
13
  } = {}) {
35
- if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
36
- const ring = createKeyRing(collectApiKeys(apiKeys, apiKey), { cooldownMs });
37
- let dispatcher = null; let agent = null;
38
- if (!noAgent) {
39
- const a = createAgent({
40
- keepAliveTimeout: envInt("MSLXDFF_OA_KEEPALIVE_TIMEOUT", 30_000),
41
- keepAliveMaxTimeout: envInt("MSLXDFF_OA_KEEPALIVE_MAX_TIMEOUT", 60_000),
42
- connections: envInt("MSLXDFF_OA_KEEPALIVE_CONNECTIONS", 20),
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
- if (key) h["Authorization"] = `Bearer ${key}`;
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;