mslxdff 0.1.130 → 0.1.132

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.130",
3
+ "version": "0.1.132",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -79,21 +79,3 @@ export function createCooling({
79
79
 
80
80
  return { isCooling, isCoolingAsync, recordError, recordChatError, recordOk, recordChatOk };
81
81
  }
82
-
83
- // 默认实例(对接真实 state.js)
84
- let _default = null;
85
- export function getDefaultCooling() {
86
- if (_default) return _default;
87
- // 懒加载 state,避免循环
88
- _default = createCooling({
89
- loadModelErrors: () => {
90
- try { const s = require("../state.js"); return s.loadModelErrors(); } catch { return {}; }
91
- },
92
- saveModelErrors: (o) => { try { const s = require("../state.js"); s.saveModelErrors(o); } catch {} },
93
- loadModelLatencies: () => { try { const s = require("../state.js"); return s.loadModelLatencies(); } catch { return {}; } },
94
- saveModelLatencies: (o) => { try { const s = require("../state.js"); s.saveModelLatencies(o); } catch {} },
95
- flush: () => { try { const s = require("../state.js"); s.flushStateSync(); } catch {} },
96
- now: () => Date.now(),
97
- });
98
- return _default;
99
- }
@@ -24,8 +24,14 @@ export function createGatewayClient({
24
24
  return String(loaded?.token || "").trim();
25
25
  } catch { return ""; }
26
26
  });
27
- const _getPort = getPort || (() => {
28
- try { const s = require("../state.js"); const p = s.getPort(); if (Number.isInteger(p) && p > 0) return p; } catch {}
27
+ // 默认实现与 loadToken/readModelsJson 同款:懒加载 state(ESM 里没有 require)
28
+ // Note: 这里曾用 require 导致 state.port 被静默忽略 — 见 .agents/notes/implemented/bug-fix/2026-09-17-esm-dead-require-and-orphan-cooling.md
29
+ const _getPort = getPort || (async () => {
30
+ try {
31
+ const s = await import("../state.js");
32
+ const p = s.getPort();
33
+ if (Number.isInteger(p) && p > 0) return p;
34
+ } catch {}
29
35
  const v = Number(env.MSLXDFF_PORT);
30
36
  if (Number.isInteger(v) && v > 0) return v;
31
37
  return defaultPort;
@@ -48,7 +54,7 @@ export function createGatewayClient({
48
54
  let token = "";
49
55
  try {
50
56
  token = String((await _loadToken()) || "").trim();
51
- const p = _getPort();
57
+ const p = await _getPort();
52
58
  if (Number.isInteger(p) && p > 0) port = p;
53
59
  } catch {}
54
60
  if (!token) {
@@ -16,7 +16,7 @@ export async function handleProviderAdd(id, sub, rest) {
16
16
  console.error(`provider "${gid}" is built-in — use: mslxdff -provider ${gid} add <key> or mslxdff -provider ${gid} set-url <url>`);
17
17
  process.exit(1);
18
18
  }
19
- const { saveProviderConfig, loadProviderConfig, loadProviderShareKeys } = await import("../../../state.js");
19
+ const { saveProviderConfig, loadProviderConfig } = await import("../../../state.js");
20
20
  const { normalizeProviderId } = await import("../../../providers/model-id.js");
21
21
  const nid = normalizeProviderId(gid);
22
22
  if (!nid) { console.error(`invalid provider id: ${gid}`); process.exit(1); }
@@ -98,7 +98,7 @@ export async function handleProviderAdd(id, sub, rest) {
98
98
  console.log(` keys: ${keys.length} (${keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")})`);
99
99
  if (allowedModels.length) console.log(` allowedModels: ${allowedModels.length} (${allowedModels.join(", ")})`);
100
100
  else console.log(` allowedModels: (none — BLOCKED, otherwise unusable) → mslxdff -provider ${nid} allowlist set <model...> OR mslxdff -provider ${nid} allowAny on (allow all)`);
101
- console.log(` share: ${loadProviderShareKeys(nid) ? "ON" : "off"} (mslxdff -provider ${nid} share on|off)`);
101
+ console.log(` share: 借出(默认,key 随转发自动附带,ADR-0019)`);
102
102
  console.log(` allowAny: OFF (secure, empty allowlist = 403 block before upstream) — enable via: mslxdff -provider ${nid} allowAny on`);
103
103
  console.log(` use as: ${nid}/<model-id> — restart daemon to activate`);
104
104
  if (allowedModels.length) console.log(` opencode 中使用: mslxdff -setto opencode ${nid}/${allowedModels[0]} (同步进 opencode.json,会一并设为默认模型)`);
@@ -5,7 +5,7 @@ export async function handleProviderConfig(id, sub, rest) {
5
5
  console.error("opencode 是内置供应商,不能删除");
6
6
  process.exit(1);
7
7
  }
8
- const { loadProviderConfigs, saveProviderConfig, saveProviderShareKeys } = await import("../../../state.js");
8
+ const { loadProviderConfigs, saveProviderConfig } = await import("../../../state.js");
9
9
  const { isPidAlive, readPid } = await import("../../../daemon.js");
10
10
  const configs = loadProviderConfigs();
11
11
  if (!configs[id] && !configs[nid]) {
@@ -33,16 +33,9 @@ export async function handleProviderConfig(id, sub, rest) {
33
33
  writeStateImmediate(file, { providerKeys: nk });
34
34
  changed = true;
35
35
  }
36
- if (raw.providerShareKeys && raw.providerShareKeys[id] !== undefined) {
37
- const ns = { ...raw.providerShareKeys };
38
- delete ns[id];
39
- writeStateImmediate(file, { providerShareKeys: ns });
40
- changed = true;
41
- }
42
36
  // 清理模型错误/延迟中该供应商前缀的条目(可选,不阻塞)
43
37
  void changed;
44
38
  } catch {}
45
- try { saveProviderShareKeys(id, false); } catch {}
46
39
  console.log(`已删除供应商: ${id} — 配置已清空`);
47
40
  // 需要重启才生效,自动重启
48
41
  const pid = readPid();
@@ -122,21 +115,5 @@ export async function handleProviderConfig(id, sub, rest) {
122
115
  console.log(`set ${id} baseUrl: ${String(url).trim().replace(/\/+$/, "")} — restart daemon to activate`);
123
116
  process.exit(0);
124
117
  }
125
- if (sub === "share") {
126
- const { loadProviderShareKeys, saveProviderShareKeys } = await import("../../../state.js");
127
- const on = rest[1];
128
- if (!on) {
129
- console.log(`share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"}`);
130
- process.exit(0);
131
- }
132
- if (!["on", "off", "1", "0", "true", "false"].includes(String(on).toLowerCase())) {
133
- console.error("usage: mslxdff -provider openrouter share on|off");
134
- process.exit(1);
135
- }
136
- const state = ["on", "1", "true"].includes(String(on).toLowerCase());
137
- saveProviderShareKeys(id, state);
138
- console.log(`share keys to peers: ${state ? "ON" : "off"} — restart daemon to activate`);
139
- process.exit(0);
140
- }
141
118
  return false;
142
119
  }
@@ -25,13 +25,12 @@ export async function handleProvider(args) {
25
25
  const sub = args[idx + 2];
26
26
  const rest = args.slice(idx + 2);
27
27
  if (!id) {
28
- console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|share|set-url|allowlist|allowAny]");
28
+ console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|set-url|allowlist|allowAny]");
29
29
  console.error(" e.g. mslxdff -provider openrouter sk-1 sk-2 sk-3 set multiple keys (replaces all)");
30
30
  console.error(" mslxdff -provider openrouter add sk-4 append one key");
31
31
  console.error(" mslxdff -provider openrouter remove sk-1 remove a key by value");
32
32
  console.error(" mslxdff -provider openrouter list list all keys (masked)");
33
33
  console.error(" mslxdff -provider openrouter models [--json] list provider models (allowlist filtered)");
34
- console.error(" mslxdff -provider openrouter share on|off share keys with peers on outgoing forward (ADR-0008)");
35
34
  console.error(" mslxdff -provider openrouter set-url https://api.example.com/v1");
36
35
  console.error(" mslxdff -provider openrouter set-models-path /v1/models");
37
36
  console.error(" mslxdff -provider openrouter set-chat-path /v1/chat/completions");
@@ -1,6 +1,6 @@
1
1
  export async function handleProviderKeys(id, sub, rest, args) {
2
2
  if (sub === "list" || sub === "status") {
3
- const { loadProviderKeys, loadProviderConfig, loadProviderShareKeys, loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("../../../state.js");
3
+ const { loadProviderKeys, loadProviderConfig, loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("../../../state.js");
4
4
  const keys = loadProviderKeys(id);
5
5
  const cfg = loadProviderConfig(id);
6
6
  const allowed = loadProviderAllowedModels(id);
@@ -15,7 +15,7 @@ export async function handleProviderKeys(id, sub, rest, args) {
15
15
  } else {
16
16
  console.log(` keys: (no keys configured)`);
17
17
  }
18
- console.log(` share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"} (mslxdff -provider ${id} share on|off)`);
18
+ console.log(` share keys to peers: 借出(默认,key 随转发自动附带,ADR-0019)`);
19
19
  console.log(` allowAnyModels: ${allowAny ? "ON (empty allowlist = allow all)" : "OFF (empty allowlist = BLOCK ALL)"} (mslxdff -provider ${id} allowAny on|off)`);
20
20
  if (allowed.length) console.log(` allowedModels: ${allowed.length} (${allowed.join(", ")}) — only these can be used`);
21
21
  else console.log(` allowedModels: (none — ${allowAny ? "allow all" : "BLOCK ALL"}) (mslxdff -provider ${id} allowlist set <model...> or allowAny on)`);
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
- import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderShareKeys, loadProviderBaseUrl } from "../state.js";
2
+ import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } from "../state.js";
3
3
 
4
4
  /**
5
5
  * 聚合 genericIds:来自 providerConfigs + providerKeys + env MSLXDFF_*_KEY
@@ -37,12 +37,10 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
37
37
  const cfg = configs[gid];
38
38
  let keys = [];
39
39
  let baseUrl = "";
40
- let share = false;
41
40
  let allowed = [];
42
41
  let allowAny = false;
43
42
  try { keys = loadProviderKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
44
43
  try { baseUrl = loadProviderBaseUrl(gid, stateFile ? { file: stateFile } : undefined) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : ""); } catch { baseUrl = cfg?.baseUrl || ""; }
45
- try { share = loadProviderShareKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
46
44
  try { allowed = loadProviderAllowedModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
47
45
  try { allowAny = loadProviderAllowAnyModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
48
46
  // openrouter 特殊:opencode 例外默认 allowAny true,其余默认 false
@@ -61,7 +59,7 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
61
59
  } else if (gid === "workbuddy" && auths.length && auths.length !== keys.length) {
62
60
  note = `${auths.length} auth(s) / ${keys.length} key(s) — 数量不一致请重跑 workbuddy-token-auto.js`;
63
61
  }
64
- rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, allowAny, share, note, authCount: auths.length });
62
+ rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, allowAny, note, authCount: auths.length });
65
63
  }
66
64
  return rows;
67
65
  }
@@ -94,8 +92,8 @@ export function formatProviderRow(p) {
94
92
  const more = p.allowed.length > 2 ? ` …+${p.allowed.length - 2}` : "";
95
93
  allowInfo = `allow ${p.allowed.length} → ${head}${more}`;
96
94
  }
97
- // share
98
- const shareInfo = p.id === "opencode" ? "无法共享" : `共享 ${p.share ? "开" : "关"}`;
95
+ // share(ADR-0019:默认借出,无开关)
96
+ const shareInfo = p.id === "opencode" ? "无法共享" : "共享 借出";
99
97
  // base
100
98
  const base = p.baseUrl && p.baseUrl !== "(none)" ? p.baseUrl : "(none)";
101
99
  const baseLine = ` └ ${base}${p.note ? ` · ${p.note}` : ""}`;
package/src/cli/status.js CHANGED
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { readPid, readPidVersion, isPidAlive, pidFile } from "../daemon.js";
5
5
  import { defaultStateFile, getPort, loadGroupsJoined, loadModelErrors, loadModelStats, loadModelPicks } from "../state.js";
6
6
  import { resolvePort } from "../server.js";
7
- import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderShareKeys, loadProviderBaseUrl } from "../state.js";
7
+ import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderBaseUrl } from "../state.js";
8
8
  import { loadToken } from "../state.js";
9
9
  import { logDir } from "../logs.js";
10
10
  import { createGroupsService } from "../groups.js";
package/src/metrics.js CHANGED
@@ -47,7 +47,6 @@ export function extractUsageFromSseText(sseText) {
47
47
  try {
48
48
  const j = JSON.parse(data);
49
49
  if (j.usage) return extractUsageFromJson(j);
50
- if (j.choices?.[0]?.finish_reason && j.usage) return extractUsageFromJson(j);
51
50
  } catch {}
52
51
  }
53
52
  return null;
@@ -1,29 +1,25 @@
1
- // ADR-0008:供应商 key 瞬时共享 —— 转发时附带、组员一次性借用,用完即弃。
1
+ // ADR-0019:借道 = 用你的 key —— 转发时自动借出,无开关、无白名单(组内互信是前提)。
2
2
  // header: `x-mslxdff-share-keys: <providerId>=<key1>,<key2>;<providerId2>=<key3>`
3
3
  import { splitModelId, DEFAULT_PROVIDER } from "./model-id.js";
4
- import { loadProviderKeys, loadProviderShareKeys } from "../state.js";
4
+ import { classifyProvider } from "./classify.js";
5
+ import { loadProviderKeys, listProviderIdsWithKeys } from "../state.js";
5
6
 
6
7
  export const SHARE_KEYS_HEADER = "x-mslxdff-share-keys";
7
8
 
8
- // 本节点应 cast key 到出站转发的供应商 id 集合(开启 share 且配了 key 的)。
9
- // share 只在"有 key 且开关为 true"时发生;否则不附带,避免空头泄露配置意图。
10
- // 默认供应商(opencode)恒排除:它无 key 可共享(public 即可),限流以 IP 为主,
11
- // 分散 IP 由 peer 转发天然实现,不需要也不应走 key 共享。
9
+ // 硬排除:refresh-token 型凭据(对端刷新会轮换 token,与本机互相踢下线)——正确性问题。
10
+ const NEVER_SHARE_IDS = new Set(["cline", "clinebot"]);
11
+
12
+ // 本节点应 cast key 到出站转发的供应商 id 集合 = 所有「本机有 key」的供应商,
13
+ // 减去:opencode(无 key 且恒排除)、local-only(workbuddy,本就不走组员)、刷新型凭据。
12
14
  export function shareableProviderIds({ file } = {}) {
13
- const ids = new Set();
14
- const raw = process.env.MSLXDFF_SHARE_PROVIDERS || ""; // 高级:显式白名单,逗号分隔
15
- const explicit = raw.split(",").map((s) => s.trim()).filter(Boolean);
16
- if (explicit.length) {
17
- for (const id of explicit) {
18
- if (id === DEFAULT_PROVIDER) continue; // opencode 永远不可 share
19
- ids.add(id);
20
- }
21
- return [...ids];
22
- }
23
- for (const id of ["openrouter"]) {
24
- if (id !== DEFAULT_PROVIDER && loadProviderKeys(id, { file }).length && loadProviderShareKeys(id, { file })) ids.add(id);
15
+ const ids = [];
16
+ for (const id of listProviderIdsWithKeys({ file })) {
17
+ if (id === DEFAULT_PROVIDER) continue;
18
+ if (NEVER_SHARE_IDS.has(id)) continue;
19
+ try { if (classifyProvider(id) === "local-only") continue; } catch {}
20
+ ids.push(id);
25
21
  }
26
- return [...ids];
22
+ return ids;
27
23
  }
28
24
 
29
25
  // 组装 share header 值(只含 shareable 且当前请求命中前缀的供应商)。
@@ -1,19 +1,33 @@
1
- // WorkBuddy 账号落盘:auths/workbuddy-<uid>.json(0600 tmp+rename)+ state.json keys/auths 按 uid 去重追加。
2
- // token-auto 与 device-login 共用,保证两种入口写出完全一致。
1
+ // WorkBuddy 账号落盘单一源:auths/workbuddy-<uid>.json(0600 tmp+rename)+ state.json keys/auths 按 uid 去重。
2
+ // token-auto / device-login / 刷新(服务端 saveFn 与 CLI 定时任务)共用同一实现,安全细节只维护一份。
3
+ // Note: 刷新落盘只走 applyTokenRefresh,别再各自内联一套 — 见 .agents/notes/implemented/architecture/2026-09-17-workbuddy-persist-single-seam.md
3
4
  import { writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from "node:fs";
4
5
  import { join, dirname } from "node:path";
5
6
  import { tmpdir } from "node:os";
7
+ import { isTestEnv } from "../../state/store.js";
6
8
 
7
9
  export function resolveAuthDir() {
8
10
  if (process.env.WORKBUDDY_AUTH_DIR) return process.env.WORKBUDDY_AUTH_DIR;
9
11
  const sf = process.env.MSLXDFF_STATE_FILE || "";
10
- if (sf.includes("mslxdff-test") || process.env.NODE_ENV === "test") return join(tmpdir(), "mslxdff-test-auths");
12
+ if (sf.includes("mslxdff-test") || isTestEnv()) return join(tmpdir(), "mslxdff-test-auths");
11
13
  if (sf && sf.includes("mslxdff-")) return join(dirname(sf), "auths");
12
14
  return join(process.cwd(), "auths");
13
15
  }
14
16
 
15
- export async function saveWorkbuddyAccount({ uid, enterpriseId = "", nickname = "", accessToken, refreshToken = "", expiresAt = 0, domain = "www.codebuddy.cn", file } = {}) {
16
- if (!uid || !accessToken) throw new Error("saveWorkbuddyAccount: 缺少 uid/accessToken");
17
+ function jwtExp(token) {
18
+ try {
19
+ const payload = String(token || "").split(".")[1];
20
+ if (!payload) return 0;
21
+ let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
22
+ const pad = b64.length % 4;
23
+ if (pad) b64 += "=".repeat(4 - pad);
24
+ return Number(JSON.parse(Buffer.from(b64, "base64").toString("utf8")).exp || 0);
25
+ } catch {
26
+ return 0;
27
+ }
28
+ }
29
+
30
+ function writeAccountFile({ uid, accessToken, refreshToken = "", expiresAt = 0, domain = "www.codebuddy.cn", enterpriseId = "", nickname = "" }) {
17
31
  const authDir = resolveAuthDir();
18
32
  mkdirSync(authDir, { recursive: true });
19
33
  const exp = expiresAt || Math.floor(Date.now() / 1000) + 5184000;
@@ -26,16 +40,81 @@ export async function saveWorkbuddyAccount({ uid, enterpriseId = "", nickname =
26
40
  renameSync(tmp, fp);
27
41
  } catch {
28
42
  writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 });
43
+ try { unlinkSync(tmp); } catch {} // 失败路径别把含凭据的 .tmp 留在盘上
29
44
  }
30
- // 同步 state(对话轮换只读 state)
45
+ return fp;
46
+ }
47
+
48
+ // state.json 落盘单一实现:按 uid 配对更新(存在→更新该账号行,不存在→追加);extraKeys 为没有 auth 行的裸 key,原样保留。
49
+ async function persistAccounts(accounts, file, extraKeys = []) {
31
50
  const { loadProviderConfigs, saveProviderConfig } = await import("../../state.js");
32
51
  const cfg = (loadProviderConfigs(file ? { file } : {})?.workbuddy) || {};
33
52
  const keys = [...(Array.isArray(cfg.keys) ? cfg.keys : [])];
34
53
  const auths = [...(Array.isArray(cfg.auths) ? cfg.auths : [])];
35
- const row = { uid, domain, enterpriseId, refreshToken };
36
- const atIdx = auths.findIndex((a) => a?.uid === uid);
37
- if (atIdx >= 0) { keys[atIdx] = accessToken; auths[atIdx] = row; }
38
- else { keys.push(accessToken); auths.push(row); }
54
+ let updated = 0;
55
+ for (const acc of accounts) {
56
+ if (!acc?.uid || !acc?.accessToken) continue;
57
+ const row = { uid: acc.uid, domain: acc.domain || "www.codebuddy.cn", enterpriseId: acc.enterpriseId || "", refreshToken: acc.refreshToken || "" };
58
+ const atIdx = auths.findIndex((a) => a?.uid === acc.uid);
59
+ if (atIdx >= 0) { keys[atIdx] = acc.accessToken; auths[atIdx] = row; updated += 1; }
60
+ else { keys.push(acc.accessToken); auths.push(row); }
61
+ }
62
+ for (const k of extraKeys) {
63
+ if (k && !keys.includes(k)) keys.push(k);
64
+ }
39
65
  saveProviderConfig("workbuddy", { baseUrl: cfg.baseUrl || "https://copilot.tencent.com", keys, auths }, file ? { file } : {});
40
- return { file: fp, accounts: keys.length, updated: atIdx >= 0 };
66
+ return { keys, auths, updated };
67
+ }
68
+
69
+ export async function saveWorkbuddyAccount({ uid, enterpriseId = "", nickname = "", accessToken, refreshToken = "", expiresAt = 0, domain = "www.codebuddy.cn", file } = {}) {
70
+ if (!uid || !accessToken) throw new Error("saveWorkbuddyAccount: 缺少 uid/accessToken");
71
+ const fp = writeAccountFile({ uid, accessToken, refreshToken, expiresAt, domain, enterpriseId, nickname });
72
+ const st = await persistAccounts([{ uid, accessToken, refreshToken, domain, enterpriseId }], file);
73
+ return { file: fp, accounts: st.keys.length, updated: st.updated > 0 };
74
+ }
75
+
76
+ // 由平行数组(keys[i] ↔ authList[i],provider 构造时即如此)还原账号列表;没有 auth 行的裸 key 走 extraKeys 原样保留。
77
+ function accountsFromArrays(keys, authList) {
78
+ const accounts = [];
79
+ const extraKeys = [];
80
+ const n = Math.max(keys.length, authList.length);
81
+ for (let i = 0; i < n; i++) {
82
+ const a = authList[i];
83
+ if (a && typeof a === "object" && a.uid) {
84
+ accounts.push({ uid: a.uid, accessToken: keys[i] || "", refreshToken: a.refreshToken || "", domain: a.domain, enterpriseId: a.enterpriseId });
85
+ } else if (keys[i]) {
86
+ extraKeys.push(keys[i]);
87
+ }
88
+ }
89
+ return { accounts, extraKeys };
90
+ }
91
+
92
+ // 刷新后的 token 落盘:keys 与 authList 是两条平行数组,按下标更新命中的槽位(旧 key 命中优先,
93
+ // 否则按 uid 命中;都没有 → 追加),再把整表经 persistAccounts 单点落 state + auths 文件。
94
+ // 服务端 saveFn 与 CLI/定时任务共用。
95
+ export async function applyTokenRefresh({ uid, oldKey, newToken, refreshToken = "", domain = "www.codebuddy.cn", enterpriseId = "", auth = null, keys = [], authList = [], file } = {}) {
96
+ if (!uid || !newToken) return { updated: false, accounts: keys.length, file: null };
97
+ const keyIdx = keys.indexOf(oldKey);
98
+ const uidIdx = authList.findIndex((a) => a && String(a.uid) === String(uid));
99
+ // 平行数组约定:命中任一侧即用该下标(keys[i] 与 authList[i] 同属一个账号,不会覆盖他人)
100
+ const idx = keyIdx >= 0 ? keyIdx : uidIdx;
101
+ const matched = idx >= 0;
102
+ if (idx >= 0) keys[idx] = newToken;
103
+ else if (!keys.includes(newToken)) keys.push(newToken);
104
+ if (uidIdx >= 0) {
105
+ const prev = authList[uidIdx] && typeof authList[uidIdx] === "object" ? authList[uidIdx] : null;
106
+ authList[uidIdx] = {
107
+ ...(prev || auth || {}),
108
+ uid,
109
+ domain: prev?.domain || auth?.domain || domain,
110
+ enterpriseId: prev?.enterpriseId || auth?.enterpriseId || enterpriseId,
111
+ refreshToken,
112
+ };
113
+ } else {
114
+ authList.push({ ...(auth || {}), uid, domain: auth?.domain || domain, enterpriseId: auth?.enterpriseId || enterpriseId, refreshToken });
115
+ }
116
+ const fp = writeAccountFile({ uid, accessToken: newToken, refreshToken, expiresAt: jwtExp(newToken), domain, enterpriseId });
117
+ const { accounts, extraKeys } = accountsFromArrays(keys, authList);
118
+ const st = await persistAccounts(accounts, file, extraKeys);
119
+ return { updated: matched, accounts: st.keys.length, file: fp };
41
120
  }
@@ -1,6 +1,7 @@
1
1
  import { joinUrl } from "../base.js";
2
2
  import { compatFetch } from "../../compat.js";
3
3
  import { WORKBUDDY_DEFAULT_BASE_URL } from "../../state/schemas/provider.js";
4
+ import { applyTokenRefresh } from "./account-store.js";
4
5
 
5
6
  export function decodeJwtExp(token) {
6
7
  try {
@@ -45,11 +46,7 @@ export function createAuthService({
45
46
  saveFn,
46
47
  } = {}) {
47
48
  const resolvedBase = baseUrl ? String(baseUrl).trim().replace(/\/+$/, "") : WORKBUDDY_DEFAULT_BASE_URL;
48
- if (!fetchImpl) {
49
- // 兼容层统一取(undici 优先,老 Node 兜底)
50
- fetchImpl = compatFetch;
51
- }
52
- // lazy resolve UndiciFetch if not provided
49
+ // 兼容层统一取(undici 优先,老 Node 兜底)
53
50
  if (!fetchImpl) fetchImpl = compatFetch;
54
51
 
55
52
  const inflightRefresh = new Map();
@@ -84,50 +81,29 @@ export function createAuthService({
84
81
  if (j.code === 0 && j.data?.accessToken) {
85
82
  const newAt = j.data.accessToken;
86
83
  const newRt = j.data.refreshToken || rt;
87
- // attempt to persist if store provided
84
+ // attempt to persist if store provided(落盘细节单一源:account-store.applyTokenRefresh)
88
85
  try {
89
86
  if (saveFn) {
90
87
  await saveFn({ newAt, newRt, uid, oldKey: key, auth });
91
88
  } else if (store && typeof store.save === "function") {
92
89
  await store.save({ newAt, newRt, uid, oldKey: key, auth });
93
90
  } else if (store && Array.isArray(store.keys) && Array.isArray(store.authList)) {
94
- // direct mutation fallback (legacy shape)
95
- const idx = store.keys.indexOf(key);
96
- if (idx >= 0) {
97
- store.keys[idx] = newAt;
98
- store.authList[idx] = { ...auth, refreshToken: newRt };
99
- try {
100
- const { saveProviderConfig } = await import("../../state.js");
101
- saveProviderConfig("workbuddy", { baseUrl: resolvedBase, keys: [...store.keys], auths: [...store.authList] }, file ? { file } : {});
102
- } catch {}
103
- try {
104
- const { writeFileSync, mkdirSync, existsSync } = await import("node:fs");
105
- const { join, dirname } = await import("node:path");
106
- const { tmpdir } = await import("node:os");
107
- const isTest = process.env.NODE_ENV === "test" || (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test"));
108
- 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")));
109
- mkdirSync(authDir, { recursive: true });
110
- const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now() / 1000) + 5184000; } })();
111
- const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
112
- const fp = join(authDir, `workbuddy-${uid}.json`);
113
- const tmp = fp + ".tmp";
114
- writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 });
115
- try {
116
- if (existsSync(fp)) {
117
- const { unlinkSync, renameSync } = await import("node:fs");
118
- unlinkSync(fp);
119
- renameSync(tmp, fp);
120
- } else {
121
- const { renameSync } = await import("node:fs");
122
- renameSync(tmp, fp);
123
- }
124
- } catch {
125
- writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 });
126
- }
127
- } catch {}
128
- }
91
+ await applyTokenRefresh({
92
+ uid,
93
+ oldKey: key,
94
+ newToken: newAt,
95
+ refreshToken: newRt,
96
+ domain: auth.domain || "www.codebuddy.cn",
97
+ enterpriseId: auth.enterpriseId || "",
98
+ auth,
99
+ keys: store.keys,
100
+ authList: store.authList,
101
+ file: file || undefined,
102
+ });
129
103
  }
130
- } catch {}
104
+ } catch (err) {
105
+ console.error(`[workbuddy] token 落盘失败(刷新已成功,重启将丢失): ${String(err?.message || err).slice(0, 200)}`);
106
+ }
131
107
  return newAt;
132
108
  }
133
109
  } catch {}
@@ -155,18 +131,3 @@ export function createAuthService({
155
131
 
156
132
  return { refreshTokenFor, maybeProactiveRefresh, decodeJwtExp, isAuthError, isInsufficientStatus, _inflight: inflightRefresh };
157
133
  }
158
-
159
- function awaitImportUndici() {
160
- try {
161
- // dynamic to avoid top-level await
162
- let UndiciFetch = null;
163
- try {
164
- // eslint-disable-next-line no-undef
165
- const mod = globalThis.__mslxdff_undici || null;
166
- if (mod) return mod;
167
- } catch {}
168
- return { UndiciFetch: null };
169
- } catch {
170
- return { UndiciFetch: null };
171
- }
172
- }
@@ -1,11 +1,11 @@
1
1
  import { createKeyRing } from "../keyring.js";
2
- import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../../state.js";
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../../state.js";
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { compatFetch } from "../../compat.js";
5
- import { join, dirname } from "node:path";
6
- import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
7
6
  import { envInt, joinUrl, getUndici, createAgent } from "../base.js";
8
7
  import { createAuthService, isAuthError, isInsufficientStatus, decodeJwtExp } from "./auth.js";
8
+ import { applyTokenRefresh, resolveAuthDir } from "./account-store.js";
9
9
  import { createChatService } from "./chat.js";
10
10
  import { createModelsService } from "./models.js";
11
11
  import { createBalanceCache, getCachedBalance as defaultGetCached, setCachedBalance as defaultSetCached } from "./balance.js";
@@ -13,15 +13,6 @@ import { defaultLogger } from "./rotation-log.js";
13
13
 
14
14
  const { UndiciAgent, UndiciFetch } = getUndici();
15
15
 
16
- function isTestEnv() {
17
- if (process.env.NODE_ENV === "test") return true;
18
- if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
19
- if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
20
- if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
21
- if (process.env.NODE_TEST_CONTEXT) return true;
22
- return false;
23
- }
24
-
25
16
  function resolveBaseUrl(baseUrl) {
26
17
  if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
27
18
  const env = loadProviderBaseUrl("workbuddy");
@@ -71,7 +62,7 @@ export function createWorkbuddyProvider({
71
62
 
72
63
  if (!authList.length && !keys.length) {
73
64
  try {
74
- 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")));
65
+ const authDir = resolveAuthDir();
75
66
  if (existsSync(authDir)) {
76
67
  const files = readdirSync(authDir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
77
68
  for (const f of files) {
@@ -112,48 +103,28 @@ export function createWorkbuddyProvider({
112
103
 
113
104
  const logger = loggerOpt || defaultLogger;
114
105
 
115
- // authService with saveFn that mutates outer keys/authList/ring and persists
116
106
  const authService = createAuthService({
117
107
  baseUrl: resolvedBase,
118
108
  fetchImpl,
119
109
  clock,
120
110
  dispatcher,
121
111
  file,
112
+ // 落盘细节单一源:account-store.applyTokenRefresh(数组就地更新 + state + auths 文件)
122
113
  saveFn: async ({ newAt, newRt, uid, oldKey, auth }) => {
123
- const idx = keys.indexOf(oldKey);
124
- if (idx >= 0) {
125
- keys[idx] = newAt;
126
- authList[idx] = { ...(authList[idx] || auth), refreshToken: newRt };
127
- try { ring.replace(oldKey, newAt); } catch {}
128
- try { saveProviderConfig(id, { baseUrl: resolvedBase, keys: [...keys], auths: [...authList] }, file ? { file } : {}); } catch {}
129
- try {
130
- 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")));
131
- mkdirSync(authDir, { recursive: true });
132
- const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now() / 1000) + 5184000; } })();
133
- const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
134
- const fp = join(authDir, `workbuddy-${uid}.json`);
135
- const tmp = fp + ".tmp";
136
- writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 });
137
- try {
138
- if (existsSync(fp)) {
139
- const { unlinkSync, renameSync } = await import("node:fs");
140
- unlinkSync(fp);
141
- renameSync(tmp, fp);
142
- } else {
143
- const { renameSync } = await import("node:fs");
144
- renameSync(tmp, fp);
145
- }
146
- } catch {
147
- writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 });
148
- }
149
- } catch {}
150
- } else {
151
- if (!keys.includes(newAt)) {
152
- keys.push(newAt);
153
- authList.push({ ...(auth || {}), uid, refreshToken: newRt });
154
- try { ring.replace(oldKey, newAt); } catch {}
155
- }
156
- }
114
+ const r = await applyTokenRefresh({
115
+ uid,
116
+ oldKey,
117
+ newToken: newAt,
118
+ refreshToken: newRt,
119
+ domain: auth?.domain || "www.codebuddy.cn",
120
+ enterpriseId: auth?.enterpriseId || "",
121
+ auth,
122
+ keys,
123
+ authList,
124
+ file,
125
+ });
126
+ try { ring.replace(oldKey, newAt); } catch {}
127
+ return r;
157
128
  },
158
129
  });
159
130
 
@@ -205,15 +176,7 @@ export function createWorkbuddyProvider({
205
176
  }
206
177
 
207
178
  async function chatWithKeys(body, keysOverride) {
208
- const shareOn = (() => {
209
- try { return loadProviderShareKeys(id, file ? { file } : {}); } catch { return false; }
210
- })();
211
- // allow injected balanceCache/logger to bypass share check for isolated tests:
212
- // if keysOverride provided and logger is injected (test mode), permit share without state flag
213
- const isTestInjection = !!balanceCacheOpt || !!loggerOpt;
214
- if (!shareOn && !isTestInjection) {
215
- return chatSvc.runChat(body, ring);
216
- }
179
+ // 借入的 key(ADR-0019:转发时自动附带)——用隔离的 tmp ring/auth,不改动本机 ring。
217
180
  // Isolated tmp ring and auth without mutating shared arrays
218
181
  const tmpKeys = [...keysOverride].filter((k) => typeof k === "string" && k.trim().length).map((k) => k.trim());
219
182
  const tmpAuth = authList[0] || { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
@@ -1,7 +1,7 @@
1
1
  import { runHook } from "../../plugins.js";
2
2
  import { recordModelStats } from "../../state.js";
3
3
  import { normalizeFullId } from "../../providers/model-id.js";
4
- import { computeMetrics, extractUsageFromJson, extractUsageFromSseText } from "../../metrics.js";
4
+ import { computeMetrics } from "../../metrics.js";
5
5
 
6
6
  // 唯一/最后候选没有 failover 去向:首块闸门退化为纯"防连接泄漏",放宽避免误杀慢模型
7
7
  // (参考 opencode:zen 通道不设超时;openai responses 硬编码 300s headerTimeout)
@@ -109,11 +109,12 @@ export function createRelayPipeline({
109
109
  totalMs: out.totalMs,
110
110
  aborted: out.aborted,
111
111
  interrupted: out.interrupted ?? false,
112
+ timedOut: out.timedOut ?? false,
112
113
  detail: out.detail ?? null,
113
114
  });
114
115
 
115
- // 5a. 首块超时未写字节 → 回退
116
- if (streamTimeoutMs > 0 && out.status === streamTimeoutMs) {
116
+ // 5a. 首块超时未写字节 → 回退(显式 timedOut 字段,status 只是 HTTP 语义展示)
117
+ if (out.timedOut === true) {
117
118
  if (auto) try { await auto.recordError(actual, { status: 502, slow: true, note: `stream timeout ${streamTimeoutMs}ms` }); } catch {}
118
119
  try { _logError(actual, 502, `stream timeout ${streamTimeoutMs}ms`); } catch {}
119
120
  _evt("upstream-error", { reqId, model: actual, status: 502, message: "stream timeout", timing: null });
@@ -2,8 +2,7 @@ import { createRelayPipeline } from "./relay-pipeline.js";
2
2
  import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "../stream.js";
3
3
  import { buildFallbackInfo } from "../fallback.js";
4
4
  import { getViaRoute } from "../../bench/via-routes.js";
5
- import { loadProviderKeys } from "../../state.js";
6
- import { SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
5
+ import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
7
6
  import { errMsg } from "../helpers.js";
8
7
  import { compatFetch } from "../../compat.js";
9
8
 
@@ -61,12 +60,9 @@ export async function handleViaRoute({
61
60
  evt("via-route-hit", { reqId: handlerCtx.reqId, model, peer: peer.url, peerLabel, routeBest: route.best, at: route.at });
62
61
  // 单路径转发,不并发:直接打 best peer
63
62
  const hops = handlerCtx.hops || 0;
64
- const providerId = String(model).split("/")[0] || "";
65
- let shareHeader = null;
66
- try {
67
- const keys = loadProviderKeys(providerId) || [];
68
- if (keys.length) shareHeader = `${providerId}=${keys.join(",")}`;
69
- } catch {}
63
+ // ADR-0019:借道 = 用 A 的 key —— 转发时自动附带(组内互信,无开关),与 peers.js 共用同一组装点。
64
+ // Note: 见 .agents/notes/implemented/architecture/2026-09-17-share-keys-no-switch.md
65
+ const shareHeader = buildShareKeysHeader(model);
70
66
  const controller = new AbortController();
71
67
  const timer = setTimeout(() => controller.abort(new Error("via-route timeout 30000ms")), 30000);
72
68
  let upRes;
@@ -122,7 +122,7 @@ async function forwardToPeer(peer, body, model, hops, controller = new AbortCont
122
122
  "x-mslxdff-model-lock": model,
123
123
  "Accept": "text/event-stream",
124
124
  };
125
- // ADR-0008:该模型命中的供应商若开启 share → 附带瞬时 key 给组员借用(opencode 恒排除)
125
+ // ADR-0019:命中供应商的 key 随转发自动借出(组内互信,无开关;opencode/workbuddy/cline 硬排除)
126
126
  const shareHeader = buildShareKeysHeader(model);
127
127
  if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
128
128
  const dispatcher = getPeerDispatcher();
@@ -1,6 +1,7 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { applyFallbackHeaders, enrichNonStreamJson, enrichSseChunkText } from "./fallback.js";
3
3
  import { json } from "./helpers.js";
4
+ import { extractUsageFromJson, extractUsageFromSseText } from "../metrics.js";
4
5
 
5
6
  // SDK 通道(TextEncoder)产出 Uint8Array,legacy 通道为 Buffer;
6
7
  // 统一转文本,避免 [DONE]/finish_reason/usage/chars 统计在 SDK 路径下静默失效。
@@ -163,8 +164,8 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
163
164
  if (txt.includes("[DONE]")) detail.sawDone = true;
164
165
  const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);
165
166
  if (m) detail.sawFinishReason = m[1];
166
- // 尝试提取 usage(流式末帧)
167
- if (txt.includes("\"usage\"") || txt.includes("prompt_tokens")) {
167
+ // 尝试提取 usage(流式末帧):口径收口到 metrics.js,与未流式分支共用
168
+ if (txt.includes("\"usage\"") || txt.includes("\"prompt_tokens\"")) {
168
169
  try {
169
170
  const lines = txt.split("\n");
170
171
  for (const line of lines) {
@@ -172,28 +173,22 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
172
173
  if (!t.startsWith("data:")) continue;
173
174
  const d = t.slice(5).trim();
174
175
  if (d === "[DONE]" || !d) continue;
175
- const j = JSON.parse(d);
176
- if (j && j.usage && typeof j.usage === "object") {
177
- const u = j.usage;
178
- const pt = Number(u.prompt_tokens ?? u.promptTokens ?? u.input_tokens);
179
- const ct = Number(u.completion_tokens ?? u.completionTokens ?? u.output_tokens);
180
- const tt = Number(u.total_tokens ?? u.totalTokens);
181
- const cur = {};
182
- if (Number.isFinite(pt)) cur.prompt_tokens = pt;
183
- if (Number.isFinite(ct)) cur.completion_tokens = ct;
184
- if (Number.isFinite(tt)) cur.total_tokens = tt;
185
- if (Object.keys(cur).length) detail.usage = cur;
176
+ // 行级隔离:单行坏 JSON 不拖累同 chunk 其余行
177
+ try {
178
+ const j = JSON.parse(d);
179
+ if (!j || typeof j !== "object") continue;
180
+ const u = extractUsageFromJson(j);
181
+ if (u) detail.usage = u;
186
182
  // 兜底 chars:从 choices 文本长度累加
187
183
  const delta = j.choices?.[0]?.delta?.content || j.choices?.[0]?.message?.content || "";
188
184
  if (delta) detail.chars += String(delta).length;
189
- }
185
+ } catch { /* 单行坏帧忽略 */ }
190
186
  }
191
187
  } catch {}
192
188
  } else {
193
189
  // 非 usage 的普通 delta 也累 chars
194
190
  try {
195
- const txt2 = chunkText(chunk);
196
- const ms = txt2.match(/"content"\s*:\s*"([^"]*)"/g);
191
+ const ms = txt.match(/"content"\s*:\s*"([^"]*)"/g);
197
192
  if (ms) for (const mm of ms) {
198
193
  const c = JSON.parse(`{${mm}}`);
199
194
  if (c.content) detail.chars += String(c.content).length;
@@ -250,14 +245,15 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
250
245
  }
251
246
  if (timedOut && !wroteAny) {
252
247
  res.removeListener("close", onClose);
253
- return { status: streamTimeoutMs, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true, interrupted: false, detail };
248
+ // Note: 超时是显式字段(timedOut),别再用 status 数值当信号 — 见 .agents/notes/implemented/architecture/2026-09-17-relay-timedout-explicit-and-metrics-seam.md
249
+ return { status: 504, timedOut: true, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true, interrupted: false, detail };
254
250
  }
255
251
  if ((stalled || tooLong) && wroteAny) {
256
252
  interrupted = true;
257
253
  detail.exitReason = detail.exitReason || (stalled ? "stall" : "max");
258
254
  res.removeListener("close", onClose);
259
255
  try { res.end(); } catch { /* ignore */ }
260
- return { status: 200, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted, detail };
256
+ return { status: 200, timedOut: false, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted, detail };
261
257
  }
262
258
  } else {
263
259
  detail.exitReason = "empty-body";
@@ -267,7 +263,7 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
267
263
  finishedNormally = true;
268
264
  res.removeListener("close", onClose);
269
265
  try { res.end(); } catch { /* ignore */ }
270
- return { status: 200, ttfMs: ttf, totalMs, aborted: false, interrupted: false, detail };
266
+ return { status: 200, timedOut: false, ttfMs: ttf, totalMs, aborted: false, interrupted: false, detail };
271
267
  }
272
268
 
273
269
  finishedNormally = true;
@@ -275,32 +271,24 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
275
271
  const text = await upRes.text();
276
272
  detail.receivedBytes = Buffer.byteLength(text);
277
273
  detail.exitReason = "normal-non-stream";
278
- // 非流式 usage 与 chars 提取
274
+ // 非流式 usage 与 chars 提取(口径与流式分支共用 metrics.js)
279
275
  try {
280
276
  const parsed = JSON.parse(text);
281
- const u = parsed.usage;
282
- if (u && typeof u === "object") {
283
- const pt = Number(u.prompt_tokens ?? u.promptTokens ?? u.input_tokens);
284
- const ct = Number(u.completion_tokens ?? u.completionTokens ?? u.output_tokens);
285
- const tt = Number(u.total_tokens ?? u.totalTokens);
286
- const cur = {};
287
- if (Number.isFinite(pt)) cur.prompt_tokens = pt;
288
- if (Number.isFinite(ct)) cur.completion_tokens = ct;
289
- if (Number.isFinite(tt)) cur.total_tokens = tt;
290
- if (Object.keys(cur).length) detail.usage = cur;
291
- if (parsed.choices?.[0]?.message?.content) detail.chars = String(parsed.choices[0].message.content).length;
292
- else if (parsed.choices?.[0]?.text) detail.chars = String(parsed.choices[0].text).length;
293
- }
277
+ const u = extractUsageFromJson(parsed);
278
+ if (u) detail.usage = u;
279
+ if (parsed.choices?.[0]?.message?.content) detail.chars = String(parsed.choices[0].message.content).length;
280
+ else if (parsed.choices?.[0]?.text) detail.chars = String(parsed.choices[0].text).length;
294
281
  const enriched = enrichNonStreamJson(parsed, fallback);
295
282
  json(res, upRes.status, enriched);
296
283
  } catch {
297
284
  res.statusCode = upRes.status;
298
285
  res.setHeader("Content-Type", contentType || "text/plain");
299
286
  res.end(text);
300
- // 纯文本时按长度估 chars
287
+ // 纯文本时按长度估 chars;若文本其实是 SSE(client 未要流但上游给流)仍尝试提 usage
301
288
  try { detail.chars = text.length; } catch {}
289
+ try { const u = extractUsageFromSseText(text); if (u) detail.usage = u; } catch {}
302
290
  }
303
291
  // 非流式 ttf 视为 total(一次性返回)
304
292
  const totalMs = Math.round(performance.now() - t0);
305
- return { status: upRes.status, ttfMs: totalMs, totalMs, aborted: false, interrupted: false, detail };
293
+ return { status: upRes.status, timedOut: false, ttfMs: totalMs, totalMs, aborted: false, interrupted: false, detail };
306
294
  }
@@ -5,16 +5,14 @@ export {
5
5
  WORKBUDDY_DEFAULT_BASE_URL,
6
6
  providerKeyEnv,
7
7
  providerBaseUrlEnv,
8
- providerShareEnv,
9
8
  loadProviderKeys,
10
9
  loadProviderKey,
10
+ listProviderIdsWithKeys,
11
11
  saveProviderKeys,
12
12
  saveProviderKey,
13
13
  addProviderKey,
14
14
  removeProviderKey,
15
15
  removeProviderKeys,
16
- loadProviderShareKeys,
17
- saveProviderShareKeys,
18
16
  loadProviderConfigs,
19
17
  loadProviderConfig,
20
18
  loadProviderBaseUrl,
@@ -5,7 +5,6 @@
5
5
  export const COLD_WINS = new Set([
6
6
  "providerConfigs",
7
7
  "providerKeys",
8
- "providerShareKeys",
9
8
  "port",
10
9
  "token",
11
10
  "preferredModel",
@@ -56,10 +56,9 @@ export function defaultChatPath(id) {
56
56
 
57
57
  export const providerKeyEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_KEY`;
58
58
  export const providerBaseUrlEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BASE_URL`;
59
- export const providerShareEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_SHARE_KEYS`;
60
59
 
61
60
  /**
62
- * validate 7 字段:baseUrl, keys, auths, allowedModels, modelsPath, chatPath, shareKeys?(share 单独)
61
+ * validate 7 字段:baseUrl, keys, auths, allowedModels, modelsPath, chatPath(share 已删,key 随转发自动借出)
63
62
  * 返回 {ok, clean, errors}
64
63
  */
65
64
  export function validateProviderConfig(raw = {}, providerId = "") {
@@ -9,7 +9,6 @@ export {
9
9
  defaultChatPath,
10
10
  providerKeyEnv,
11
11
  providerBaseUrlEnv,
12
- providerShareEnv,
13
12
  } from "../provider-config.js";
14
13
  import {
15
14
  normalizeBaseUrl as _normalizeBaseUrl,
@@ -20,7 +19,6 @@ import {
20
19
  defaultChatPath as _defaultChatPath,
21
20
  providerKeyEnv as _providerKeyEnv,
22
21
  providerBaseUrlEnv as _providerBaseUrlEnv,
23
- providerShareEnv as _providerShareEnv,
24
22
  WORKBUDDY_DEFAULT_BASE_URL as _WORKBUDDY,
25
23
  } from "../provider-config.js";
26
24
 
@@ -52,6 +50,16 @@ export function loadProviderKeys(id, { file = defaultStateFile() } = {}) {
52
50
  return [];
53
51
  }
54
52
  export function loadProviderKey(id, opts = {}) { return loadProviderKeys(id, opts)[0] || ""; }
53
+ // 本节点所有「有 key」的供应商 id(providerConfigs ∪ providerKeys,含 env 覆盖)——
54
+ // 转发时默认借出的候选集合由它枚举;见 src/providers/share-keys.js
55
+ export function listProviderIdsWithKeys({ file = defaultStateFile() } = {}) {
56
+ const st = readState(file);
57
+ const ids = new Set();
58
+ for (const src of [st.providerConfigs, st.providerKeys]) {
59
+ if (src && typeof src === "object" && !Array.isArray(src)) for (const id of Object.keys(src)) ids.add(id);
60
+ }
61
+ return [...ids].filter((id) => loadProviderKeys(id, { file }).length > 0);
62
+ }
55
63
  export function saveProviderKeys(id, list, { file = defaultStateFile() } = {}) {
56
64
  const keys = { ...(readState(file).providerKeys || {}) };
57
65
  const clean = [...new Set((Array.isArray(list) ? list : []).map((k) => String(k || "").trim()).filter(Boolean))];
@@ -72,18 +80,6 @@ export function removeProviderKeys(id, targets = [], opts = {}) {
72
80
  const list = loadProviderKeys(id, opts).filter((k) => !set.has(k));
73
81
  return saveProviderKeys(id, list, opts);
74
82
  }
75
- export function loadProviderShareKeys(id, { file = defaultStateFile() } = {}) {
76
- const env = process.env[_providerShareEnv(id)] || "";
77
- if (env) return ["1", "true", "on", "yes"].includes(String(env).trim().toLowerCase());
78
- const map = readState(file).providerShareKeys;
79
- return !!(map && typeof map === "object" && map[id]);
80
- }
81
- export function saveProviderShareKeys(id, on, { file = defaultStateFile() } = {}) {
82
- const map = { ...(readState(file).providerShareKeys || {}) };
83
- if (on) map[id] = true; else delete map[id];
84
- writeStateImmediate(file, { providerShareKeys: map });
85
- return !!on;
86
- }
87
83
  export function loadProviderConfigs({ file = defaultStateFile() } = {}) {
88
84
  const v = readState(file).providerConfigs;
89
85
  if (v && typeof v === "object" && !Array.isArray(v)) return v;
@@ -8,7 +8,7 @@ import { mergeState, COLD_WINS } from "./merge.js";
8
8
 
9
9
  export const DEFAULT_PORT = 8989;
10
10
 
11
- function isTestEnv() {
11
+ export function isTestEnv() {
12
12
  if (process.env.NODE_ENV === "test") return true;
13
13
  if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
14
14
  if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;