mslxdff 0.1.129 → 0.1.131

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.129",
3
+ "version": "0.1.131",
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) {
package/src/chat/tools.js CHANGED
@@ -7,6 +7,7 @@ import { FORBIDDEN } from "./config.js";
7
7
  import { logDir } from "../logs.js";
8
8
  import { defaultStateFile, loadProviderKeys, loadProviderConfigs } from "../state.js";
9
9
  import { compatFetch } from "../compat.js";
10
+ import { opencodeClientIdentity } from "../opencode-identity.js";
10
11
 
11
12
  const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
12
13
  const allowedRoots = [
@@ -220,12 +221,17 @@ export async function curlTool({ url, method, headers, body, timeoutMs }) {
220
221
  if (headers && typeof headers === "object") {
221
222
  for (const [k, v] of Object.entries(headers)) if (typeof v === "string") h[String(k)] = v;
222
223
  }
223
- // 上游自动补最小可用头
224
+ // 上游自动补最小可用头(zen 免费层门禁:UA 带版本 + opencode 形状 session/request)
224
225
  if (/opencode\.ai/i.test(target)) {
226
+ const ident = opencodeClientIdentity();
225
227
  if (!h["x-opencode-client"]) h["x-opencode-client"] = "desktop";
226
228
  if (!h["Authorization"] && !h["authorization"]) h["Authorization"] = "Bearer public";
227
229
  if (!h["Accept"]) h["Accept"] = m === "GET" ? "*/*" : "application/json";
228
230
  if (!h["Content-Type"] && m !== "GET" && m !== "HEAD") h["Content-Type"] = "application/json";
231
+ if (!h["User-Agent"] && !h["user-agent"]) h["User-Agent"] = ident["User-Agent"];
232
+ if (!h["x-opencode-session"]) h["x-opencode-session"] = ident["x-opencode-session"];
233
+ if (!h["x-opencode-request"]) h["x-opencode-request"] = ident["x-opencode-request"];
234
+ if (!h["x-opencode-project"]) h["x-opencode-project"] = "global";
229
235
  }
230
236
  // 本机自动带 token(/v1/* 需要鉴权);/v1/models 必须是 GET,模型常误用 POST 直接纠正
231
237
  if (/127\.0\.0\.1|localhost/i.test(target) && /\/v1\//i.test(target)) {
@@ -5,6 +5,7 @@ import { runOne } from "../../../bench/runner.js";
5
5
  import { formatViaReport } from "../../../bench/report.js";
6
6
  import { clineBenchOne } from "../../../bench/cline-bench.js";
7
7
  import { workbuddyBenchOne } from "../../../bench/workbuddy-bench.js";
8
+ import { opencodeClientIdentity } from "../../../opencode-identity.js";
8
9
 
9
10
  export function buildHeadersForProvider(providerId, apiKey, auth) {
10
11
  const h = {};
@@ -21,6 +22,16 @@ export function buildHeadersForProvider(providerId, apiKey, auth) {
21
22
  if (auth?.enterpriseId) { h["X-Enterprise-Id"] = auth.enterpriseId; h["X-Tenant-Id"] = auth.enterpriseId; }
22
23
  return h;
23
24
  }
25
+ // opencode 免费层门禁:UA 带版本 + opencode 形状 session/request,否则 403
26
+ if (String(providerId).toLowerCase() === "opencode") {
27
+ const ident = opencodeClientIdentity();
28
+ h["Content-Type"] = "application/json";
29
+ if (!h["User-Agent"]) h["User-Agent"] = ident["User-Agent"];
30
+ h["x-opencode-client"] = "desktop";
31
+ h["x-opencode-session"] = ident["x-opencode-session"];
32
+ h["x-opencode-request"] = ident["x-opencode-request"];
33
+ h["x-opencode-project"] = "global";
34
+ }
24
35
  if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
25
36
  return h;
26
37
  }
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;
@@ -0,0 +1,51 @@
1
+ import crypto from "node:crypto";
2
+
3
+ // opencode 客户端身份规格单一来源(源码 packages/schema/src/identifier.ts +
4
+ // packages/opencode/src/session/llm/request.ts):id = <prefix>_ + 12 位 hex
5
+ // (timestamp*4096+同毫秒计数,截 48bit) + 14 位 base62,共 26 字符;UA 必须 "opencode/<semver>"。
6
+ // zen 免费层 2026-09-17 起按该规格放行(缺版本 UA 或 id 形状不符 → 403 FreeTierError)。
7
+ const ID_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
8
+ const DEFAULT_OPENCODE_UA = "opencode/1.17.20";
9
+ let idLastTs = 0;
10
+ let idCounter = 0;
11
+
12
+ export function opencodeIdTail(timestamp = Date.now()) {
13
+ if (timestamp !== idLastTs) {
14
+ idLastTs = timestamp;
15
+ idCounter = 0;
16
+ }
17
+ idCounter += 1;
18
+ const value = BigInt(timestamp) * 0x1000n + BigInt(idCounter);
19
+ let tail = "";
20
+ for (let i = 0; i < 6; i++) {
21
+ tail += Number((value >> BigInt(40 - 8 * i)) & 0xffn)
22
+ .toString(16)
23
+ .padStart(2, "0");
24
+ }
25
+ for (const b of crypto.randomBytes(14)) tail += ID_CHARS[b % 62];
26
+ return tail;
27
+ }
28
+
29
+ // 摘要派生:同种子恒同 id(会话亲和),形态仍合规
30
+ export function digestIdTail(digest) {
31
+ let tail = digest.subarray(0, 6).toString("hex");
32
+ for (const b of digest.subarray(6, 20)) tail += ID_CHARS[b % 62];
33
+ return tail;
34
+ }
35
+
36
+ export function genId(prefix) {
37
+ return `${prefix}${opencodeIdTail()}`;
38
+ }
39
+
40
+ export function opencodeUa() {
41
+ return process.env.MSLXDFF_OPENCODE_UA || DEFAULT_OPENCODE_UA;
42
+ }
43
+
44
+ // 旁路(-chat curl 工具 / bench 直连)共用同一身份,避免各自伪造头再被 403
45
+ export function opencodeClientIdentity() {
46
+ return {
47
+ "User-Agent": opencodeUa(),
48
+ "x-opencode-session": genId("ses_"),
49
+ "x-opencode-request": genId("msg_"),
50
+ };
51
+ }
@@ -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, loadProviderShareKeys, 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
 
@@ -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 });
@@ -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
  }
@@ -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;
package/src/upstream.js CHANGED
@@ -7,11 +7,9 @@ import { isFreeModel } from "./models.js";
7
7
  import { fmtShanghaiYMDHMS } from "./time.js";
8
8
  import { createTransport } from "./transport/index.js";
9
9
  import { isResponsesModel, chatToResponsesBody, toChatResponse, reshapeResponsesSse } from "./upstream-responses.js";
10
- import { uuid } from "./compat.js";
10
+ import { digestIdTail, genId, opencodeClientIdentity, opencodeUa } from "./opencode-identity.js";
11
11
 
12
- function genId(prefix) {
13
- return `${prefix}${uuid().replace(/-/g, "")}`;
14
- }
12
+ export { opencodeClientIdentity, opencodeUa };
15
13
  // 上游按 session 做粘性路由(实测:固定 session 两次请求均 ~1.2s;每次随机时可能撞冷机器 26s+)。
16
14
  // 客户端(opencode AI SDK 路径)不带会话标识 → 用对话首两条消息(system + 首条 user)哈希做稳定会话:
17
15
  // 同一会话多轮里这两条不变 ⇒ 路由亲和稳定;不同会话天然分散。
@@ -25,7 +23,7 @@ function sessionFromMessages(messages) {
25
23
  };
26
24
  const seed = `${pick("system")}|${pick("user")}`.slice(0, 4000);
27
25
  if (seed === "|") return null;
28
- return `ses_${crypto.createHash("sha1").update(seed).digest("hex").slice(0, 32)}`;
26
+ return `ses_${digestIdTail(crypto.createHash("sha1").update(seed).digest())}`;
29
27
  } catch {
30
28
  return null;
31
29
  }
@@ -56,7 +54,7 @@ export function createOpencodeHeaderBuilder({ authToken = "public", env = proces
56
54
  "Content-Type": "application/json",
57
55
  Authorization: "",
58
56
  "x-opencode-client": "desktop",
59
- "User-Agent": "opencode",
57
+ "User-Agent": opencodeUa(),
60
58
  "HTTP-Referer": "https://hermes-agent.nousresearch.com",
61
59
  "X-Title": "Hermes Agent",
62
60
  }
@@ -73,7 +71,7 @@ export function createOpencodeHeaderBuilder({ authToken = "public", env = proces
73
71
  const base = {
74
72
  ...baseHeaders,
75
73
  Accept: isStream ? "text/event-stream" : "*/*",
76
- "User-Agent": "opencode",
74
+ "User-Agent": opencodeUa(),
77
75
  "x-opencode-session": session,
78
76
  "x-opencode-request": genId("msg_"),
79
77
  "x-opencode-project": "global",