mslxdff 0.1.159 → 0.1.161

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 (59) hide show
  1. package/docs/cli_help_mini.md +3 -1
  2. package/package.json +1 -1
  3. package/src/autostart.js +3 -0
  4. package/src/chat-pipeline/auto-race.js +1 -1
  5. package/src/chat-pipeline/empty-turn.js +119 -0
  6. package/src/chat-pipeline/index.js +4 -1
  7. package/src/chat-pipeline/serial-trial.js +244 -210
  8. package/src/cli/commands/daemon.js +4 -4
  9. package/src/cli/commands/model/list-providers.js +1 -1
  10. package/src/cli/commands/provider/globalqwenwork-login.js +126 -0
  11. package/src/cli/commands/provider/index.js +2 -0
  12. package/src/cli/commands/provider/models.js +4 -1
  13. package/src/cli/commands/stats.js +20 -5
  14. package/src/cli/commands/system.js +2 -2
  15. package/src/cli/format.js +6 -0
  16. package/src/cli/policy.js +2 -2
  17. package/src/cli/provider-row.js +2 -2
  18. package/src/daemon.js +7 -2
  19. package/src/logs.js +14 -1
  20. package/src/model-trace.js +5 -2
  21. package/src/providers/AGENTS.md +46 -0
  22. package/src/providers/classify.js +1 -1
  23. package/src/providers/globalqwenwork/account-store.js +141 -0
  24. package/src/providers/globalqwenwork/constants.js +73 -0
  25. package/src/providers/globalqwenwork/cosy.js +123 -0
  26. package/src/providers/globalqwenwork/crypto.js +220 -0
  27. package/src/providers/globalqwenwork/http.js +22 -0
  28. package/src/providers/globalqwenwork/index.js +331 -0
  29. package/src/providers/globalqwenwork/payload.js +145 -0
  30. package/src/providers/globalqwenwork/rsa.js +56 -0
  31. package/src/providers/globalqwenwork/sse.js +270 -0
  32. package/src/providers/globalqwenwork/stream.js +132 -0
  33. package/src/providers/globalqwenwork/upstream.js +122 -0
  34. package/src/providers/globalqwenwork.js +1 -0
  35. package/src/providers/registry.js +8 -0
  36. package/src/providers/zcode/chat.js +11 -10
  37. package/src/providers/zcode/const.js +1 -1
  38. package/src/providers/zcode/context-shape.js +162 -0
  39. package/src/providers/zcode/headers.js +38 -0
  40. package/src/providers/zcode/sse.js +19 -2
  41. package/src/routes/AGENTS.md +37 -0
  42. package/src/routes/chat/broadband-handler.js +3 -2
  43. package/src/routes/chat/exhausted-handler.js +23 -8
  44. package/src/routes/chat/hedge-handler.js +3 -0
  45. package/src/routes/chat/local-handler.js +5 -1
  46. package/src/routes/chat/peer-handler.js +2 -0
  47. package/src/routes/chat/relay-pipeline.js +40 -14
  48. package/src/routes/chat/via-route-handler.js +2 -0
  49. package/src/routes/helpers.js +20 -4
  50. package/src/routes/stream-hold.js +138 -0
  51. package/src/routes/stream-scan.js +278 -0
  52. package/src/routes/stream.js +106 -158
  53. package/src/runtime/lifecycle-forensics.js +116 -0
  54. package/src/runtime/lifecycle-log.js +23 -0
  55. package/src/runtime/provider-gate.js +4 -3
  56. package/src/talk-log.js +226 -0
  57. package/src/timeline.js +5 -2
  58. package/src/usage/record.js +8 -1
  59. package/src/usage/report.js +39 -2
@@ -0,0 +1,126 @@
1
+ // mslxdff -provider globalqwenwork login — 千问办公**国际站**设备授权(PKCE + poll),对齐 qwenwork-login。
2
+ // 与 cn 站的三处实质差异(2026-10-01 现网取证):
3
+ // ① auth 域名 gateway.qwenwork.ai ② client_id cc65e5fc…(用 cn 的会整串 query 被判 not_allowed)
4
+ // ③ 深链 redirect_uri=qwenwork://;三处全部来自 constants.js,本文件不重复硬编码。
5
+ // 授权后落盘 auths/globalqwenwork-<uid>.json + state 双写,默认 allowAny=false 并种 2 个实测模型(ADR-0041)。
6
+ import { compatFetch } from "../../../compat.js";
7
+ import { normalizeProviderId } from "../../../providers/model-id.js";
8
+ import { newDeviceFlow, pollGrant, userInfo, accountContext, expiryUnix } from "../../../providers/globalqwenwork/upstream.js";
9
+
10
+ const POLL_MS = 3000;
11
+ const POLL_TIMEOUT_MS = 10 * 60 * 1000;
12
+ // 国际站实测 qwork 切片恒为这 2 个(价因子 0,仍是额度池供应商 → 保持 allowAny=false 防 auto 烧分)
13
+ const SEED_MODELS = ["qwork-auto", "qwork-advanced"];
14
+
15
+ export async function handleGlobalQwenworkLogin(id, sub, rest = [], deps = {}) {
16
+ if (normalizeProviderId(id) !== "globalqwenwork") return false;
17
+ if (sub !== "login" && sub !== "auth" && sub !== "oauth") return false;
18
+ const fetchImpl = deps.fetchImpl || compatFetch;
19
+ const log = deps.log || ((m) => console.log(m));
20
+
21
+ const flow = await newDeviceFlow();
22
+ log("=".repeat(60));
23
+ log(" 千问办公 · 国际站(QwenWork Global)登录");
24
+ log("=".repeat(60));
25
+ log("");
26
+ log(" 1. 在浏览器打开下面链接,用已登录**国际版**千问办公的账号确认授权");
27
+ log(" (cn 站账号无效:实测 cn token 打 .ai userinfo → 401 INVALID_TOKEN)");
28
+ log(" 2. 授权成功后会自动完成,不要关闭本终端");
29
+ log("");
30
+ log(` ${flow.url}`);
31
+ log("");
32
+ log("🔄 等待你授权(自动轮询,每 3 秒,最多 10 分钟)...");
33
+
34
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
35
+ let grant = null;
36
+ for (;;) {
37
+ if (Date.now() > deadline) {
38
+ console.error("\n❌ 授权超时(10 分钟),请重新运行 mslxdff -provider globalqwenwork login");
39
+ process.exit(1);
40
+ }
41
+ let outcome;
42
+ try {
43
+ outcome = await pollGrant(flow.nonce, flow.verifier, fetchImpl);
44
+ } catch (e) {
45
+ console.error(`\n❌ 轮询失败: ${e.message}`);
46
+ process.exit(1);
47
+ }
48
+ if (!outcome.pending) { grant = outcome.grant; break; }
49
+ try { process.stdout.write("."); } catch {}
50
+ await new Promise((r) => setTimeout(r, POLL_MS));
51
+ }
52
+ const accessToken = grant.token || grant.device_token;
53
+ const refreshToken = grant.refresh_token || "";
54
+ if (!accessToken) {
55
+ console.error("\n❌ 未获取到 token");
56
+ process.exit(1);
57
+ }
58
+ log(`\n✅ 授权成功!token=${String(accessToken).slice(0, 12)}...`);
59
+ if (refreshToken) log(` refreshToken=${String(refreshToken).slice(0, 12)}...`);
60
+
61
+ let uid = String(grant.user_id || "");
62
+ let name = String(grant.user_name || "");
63
+ let email = "";
64
+ try {
65
+ const info = await userInfo(accessToken, fetchImpl);
66
+ if (info?.id) uid = String(info.id);
67
+ if (info?.name) name = String(info.name);
68
+ if (info?.email) email = String(info.email);
69
+ log(` UID: ${uid} 昵称: ${name || "(空)"} 邮箱: ${email || "(空)"}`);
70
+ } catch (e) {
71
+ log(` ⚠️ userinfo 失败: ${e.message}(用 grant 兜底)`);
72
+ if (!uid) uid = String(accessToken).slice(0, 8);
73
+ }
74
+ if (!uid) {
75
+ console.error("❌ 无法获取用户标识");
76
+ process.exit(1);
77
+ }
78
+
79
+ const { saveGlobalQwenworkAccount } = await import("../../../providers/globalqwenwork/account-store.js");
80
+ const save = deps.saveAccount || saveGlobalQwenworkAccount;
81
+ const saved = await save({
82
+ uid,
83
+ accessToken,
84
+ refreshToken,
85
+ expiresAt: expiryUnix(grant),
86
+ name,
87
+ email,
88
+ });
89
+
90
+ log("");
91
+ log("=".repeat(60));
92
+ log("✅ 登录完成!");
93
+ log(` UID: ${uid}`);
94
+ log(` 账号数: ${saved.accounts}${saved.updated ? "(旧号凭证已更新)" : ""}`);
95
+ log(` 数据文件: ${saved.file}`);
96
+ log("=".repeat(60));
97
+ // 安全默认:allowAny 关 + 只放 2 个实测模型(额度池供应商,与 qwenwork/qoder 惯例一致,见 ADR-0041)
98
+ try {
99
+ const { saveProviderAllowAnyModels, saveProviderAllowedModels } = await import("../../../state.js");
100
+ saveProviderAllowAnyModels("globalqwenwork", false);
101
+ saveProviderAllowedModels("globalqwenwork", SEED_MODELS);
102
+ log(` allowAnyModels=off + allowlist 已种 ${SEED_MODELS.join("/")}(额度池供应商,防 auto 烧分)`);
103
+ } catch {}
104
+ // 额度展示:国际站免费档 pid 实测为 subscription-sgp-free(新加坡池,与 cn 积分池独立)
105
+ try {
106
+ const ctx = await accountContext(accessToken, fetchImpl);
107
+ const plan = ctx?.plan || {};
108
+ const quota = ctx?.quota || {};
109
+ if (plan?.name || quota?.remaining !== undefined) {
110
+ log(` 套餐: ${plan?.name || "-"} (${plan?.pid || "-"}) 积分剩余: ${quota?.remaining ?? "-"}`);
111
+ }
112
+ } catch {
113
+ log(" 额度查询失败,不影响使用");
114
+ }
115
+
116
+ log("下一步:");
117
+ log(" mslxdff -provider globalqwenwork models 列模型列表");
118
+ log(" mslxdff -restart(用户终端跑) 重启网关使新账号生效");
119
+ log(` 然后 curl http://localhost:8989/v1/chat/completions -d '{"model":"globalqwenwork/qwork-auto","messages":[{"role":"user","content":"hi"}]}'`);
120
+ log("");
121
+ log("多账号:重复 `mslxdff -provider globalqwenwork login` 追加,每号独立 uid,对话自动轮换");
122
+ log("(与 cn 站 qwenwork 凭证不互通,两站可在同一 state.json 共存,账号文件前缀互不吞)");
123
+
124
+ if (!deps.noExit) process.exit(0);
125
+ return true;
126
+ }
@@ -83,6 +83,8 @@ const { handleQoderLogin } = await import("./qoder-login.js");
83
83
  if (await handleQoderCheckin(id, sub, rest)) return true;
84
84
  const { handleQwenworkLogin } = await import("./qwenwork-login.js");
85
85
  if (await handleQwenworkLogin(id, sub, rest)) return true;
86
+ const { handleGlobalQwenworkLogin } = await import("./globalqwenwork-login.js");
87
+ if (await handleGlobalQwenworkLogin(id, sub, rest)) return true;
86
88
  const { handleZcodeLogin } = await import("./zcode-login.js");
87
89
  if (await handleZcodeLogin(id, sub, rest)) return true;
88
90
  const { handleZcodeQuota } = await import("./zcode-quota.js");
@@ -53,6 +53,9 @@ export async function handleProviderModels(id, sub, args, rest) {
53
53
  } else if (id === "qwenwork") {
54
54
  const { createQwenworkProvider } = await import("../../../providers/qwenwork/index.js");
55
55
  provider = createQwenworkProvider({ id, apiKeys: keys, file: defaultStateFile() });
56
+ } else if (id === "globalqwenwork") {
57
+ const { createGlobalQwenworkProvider } = await import("../../../providers/globalqwenwork/index.js");
58
+ provider = createGlobalQwenworkProvider({ id, apiKeys: keys, file: defaultStateFile() });
56
59
  } else if (id === "zcode") {
57
60
  const { createZcodeProvider } = await import("../../../providers/zcode/index.js");
58
61
  provider = createZcodeProvider({ id, apiKeys: keys, file: defaultStateFile() });
@@ -127,7 +130,7 @@ export async function handleProviderModels(id, sub, args, rest) {
127
130
  else if (allowedCount === 0) console.log(` tip: all blocked — mslxdff -provider ${id} allowAny on 或 allowlist set <model...>`);
128
131
  else if (allowedCount !== all.length) console.log(` tip: blocked 仅影响 /v1/chat 调用,展示已全量列出`);
129
132
  if (id === "zcode") {
130
- console.log(` tip: 目录/额度可用;模型调用需上游一次性验证码参数(x-aliyun-captcha-verify-param,Start Plan 通道),未启用验证码农场前会返回 403 security_reject(code 3007)— 详见 docs/adr/0038`);
133
+ console.log(` tip: 目录/额度/模型调用全通(上游 v3.14.4 起模型请求免验证码,docs/adr/0038);额度耗尽返回 429 quota_exhausted,按日窗口重置`);
131
134
  }
132
135
  }
133
136
  try { await provider.close?.(); } catch {}
@@ -19,7 +19,9 @@ export function parseStatsArgs(args) {
19
19
  }
20
20
 
21
21
  function fmtTok(n) {
22
- const v = Number(n) || 0;
22
+ // 先判未知:Number(null) || 0 会把「未知」静默渲染成 0,思考列的 — 就永远出不来
23
+ const v = Number(n);
24
+ if (n == null || !Number.isFinite(v)) return "—";
23
25
  if (v < 1000) return String(v);
24
26
  if (v < 1_000_000) return `${(v / 1000).toFixed(1)}k`;
25
27
  return `${(v / 1_000_000).toFixed(2)}M`;
@@ -62,11 +64,20 @@ function renderTable(headers, rows, aligns = []) {
62
64
  }
63
65
 
64
66
  function tokenRow(id, r) {
65
- return [id, r.requests, fmtTok(r.promptTokens), fmtTok(r.completionTokens), fmtTok(r.reasoningTokens), fmtTok(r.totalTokens)];
67
+ return [id, r.requests, fmtTok(r.promptTokens), fmtTok(r.completionTokens), reasoningCell(r), fmtTok(r.totalTokens)];
68
+ }
69
+
70
+ // 思考列的四态渲染:~ 只跟估算/混合来源;none(未知)显示 —,绝不显示成 0 冒充「不思考」
71
+ function reasoningCell(r) {
72
+ if (r.reasoningSource === "none") return "—";
73
+ const text = fmtTok(r.reasoningTokens);
74
+ return r.reasoningSource === "estimated" || r.reasoningSource === "mixed" ? `${text}~` : text;
66
75
  }
67
76
 
68
77
  function performanceRow(id, r) {
69
- return [id, fmtMs(r.avgTtfbMs), fmtMs(r.avgTotalMs), fmtTps(r.avgTps)];
78
+ const samples = Number(r.streamRequests) || 0;
79
+ const ttfb = samples > 0 ? fmtMs(r.avgTtfbMs) : "—"; // 无流式样本时不拿空样本算出的均值糊人
80
+ return [id, ttfb, fmtMs(r.avgTotalMs), fmtTps(r.avgTps), `${Number(r.ttfSamples) || 0}/${samples}`];
70
81
  }
71
82
 
72
83
  export function renderStats(report, { hours = 24, model = null } = {}) {
@@ -89,9 +100,13 @@ export function renderStats(report, { hours = 24, model = null } = {}) {
89
100
  lines.push(renderTable(["模型", "请求", "输入", "输出", "思考", "合计"], tokenRows, ["left", right, right, right, right, right]));
90
101
  lines.push("");
91
102
  lines.push("响应性能");
92
- lines.push(renderTable(["模型", "首字", "总耗时", "速度"], performanceRows, ["left", right, right, right]));
103
+ lines.push(renderTable(["模型", "首字", "总耗时", "速度", "首字样本"], performanceRows, ["left", right, right, right, right]));
93
104
  lines.push("");
94
- lines.push("说明:速度 = 输出 tokens ÷ 生成耗时(总耗时−首字),按窗口加权;只统计成功请求。");
105
+ lines.push("说明:首字 = 本次上游尝试 → 网关转发首个真实数据帧(含上游排队/建连/预读等待);非流式请求不进首字。");
106
+ lines.push("思考:数值后带 ~ = 按思考字符÷4 估算(上游未上报);— = 未上报且无可估内容;0 = 上游明确上报本轮无思考。");
107
+ lines.push("首字样本:n/N = 有首字样本数/流式请求数,N=0 时首字显示 —(样本太少的均值不可当全量真值)。");
108
+ lines.push("速度 = 输出 tokens ÷ 生成耗时(总耗时−首字),按窗口加权;只统计成功请求。");
109
+ lines.push("口径差异:-status / -model stats 的首字仍取自 state 的终生 EMA(按转发入口量),与本表**不同源**。");
95
110
  lines.push("范围:只含经 8989 网关的成功请求;不含失败请求和 -chat 直连。");
96
111
  const keepDays = usageKeepDays();
97
112
  if (hours > keepDays * 24) {
@@ -58,7 +58,7 @@ export async function handleUpdate(args, VERSION) {
58
58
  const daemon = readPid();
59
59
  if (daemon) {
60
60
  console.log("restarting daemon on the new version…");
61
- stopDaemon();
61
+ stopDaemon({ reason: "restart" });
62
62
  startDaemon([]);
63
63
  await waitForHealth(resolvePort(), 4000);
64
64
  console.log(`restarted (pid ${readPid()})`);
@@ -82,7 +82,7 @@ export async function handleShowToken(args) {
82
82
 
83
83
  export async function handleUninstall(args) {
84
84
  if (!(args.includes("-uninstall") || args.includes("--uninstall"))) return false;
85
- const { stopped, pid } = stopDaemon();
85
+ const { stopped, pid } = stopDaemon({ reason: "uninstall" });
86
86
  if (stopped) console.log(`mslxdff daemon stopped (pid ${pid})`);
87
87
  else console.log("mslxdff daemon not running");
88
88
  const stateFile = defaultStateFile();
package/src/cli/format.js CHANGED
@@ -46,6 +46,12 @@ export function fmtEvent(e) {
46
46
  return `${head} upstream ok ${m(e.model)} HTTP ${e.status}${e.timing ? ` total=${e.timing.totalMs}ms` : ""}`;
47
47
  case "upstream-error":
48
48
  return `${head} upstream err ${m(e.model)} ${e.status ? `HTTP ${e.status}` : "network"}: ${m(e.message)}`;
49
+ case "empty-turn-retry":
50
+ return `${head} 空转重试 模型零输出 model=${m(e.model)} 第 ${e.retry || "?"}/${e.max || "?"} 次,暂停 ${e.delayMs || 0}ms 后同模型重拉${e.raiseTo ? `(max_tokens ${e.raiseFrom}→${e.raiseTo})` : ""}${e.nudged ? "(末次已追问)" : ""} reqId=${e.reqId || ""}`;
51
+ case "empty-turn-recovered":
52
+ return `${head} 空转救回 重试成功 model=${m(e.model)} 第 ${e.retries || "?"}/${e.max || "?"} 次拿到输出,本轮多花 ${e.waitedMs || 0}ms reqId=${e.reqId || ""}`;
53
+ case "empty-turn-exhausted":
54
+ return `${head} 空转耗尽 重试无用 model=${m(e.model)} 已重试 ${e.retries || 0}/${e.max || "?"} 次仍零输出(等 ${e.waitedMs || 0}ms)→ 换候选/报错${e.reason ? ` ${String(e.reason).slice(0, 90)}` : ""} reqId=${e.reqId || ""}`;
49
55
  case "upstream-preheat":
50
56
  if (e.skipped) return `${head} preheat 跳过 (MSLXDFF_PREHEAT disabled)`;
51
57
  return `${head} preheat 预热 opencode models ${e.ok ? "ok" : "fail"} ${e.status ? `HTTP ${e.status}` : e.error || ""} ${e.ms ? `${e.ms}ms` : ""}`;
package/src/cli/policy.js CHANGED
@@ -60,7 +60,7 @@ export function stopDaemonIfOutdated(VERSION) {
60
60
  const runningVersion = readPidVersion();
61
61
  if (!runningVersion) {
62
62
  console.log(`daemon version unknown — restarting with v${VERSION}...`);
63
- stopDaemon();
63
+ stopDaemon({ reason: "upgrade" });
64
64
  return;
65
65
  }
66
66
  if (runningVersion === VERSION) return;
@@ -70,7 +70,7 @@ export function stopDaemonIfOutdated(VERSION) {
70
70
  return;
71
71
  }
72
72
  console.log(`daemon running v${runningVersion} — upgrading to v${VERSION}, restarting...`);
73
- stopDaemon();
73
+ stopDaemon({ reason: "upgrade" });
74
74
  }
75
75
 
76
76
  export function refreshIntervalMs() {
@@ -40,12 +40,12 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
40
40
  let allowed = [];
41
41
  let allowAny = false;
42
42
  try { keys = loadProviderKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
43
- try { baseUrl = loadProviderBaseUrl(gid, stateFile ? { file: stateFile } : undefined) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : gid === "traework" ? "https://trae-api-cn.mchost.guru" : gid === "qoder" ? "qoder://native" : gid === "qwenwork" ? "qwenwork://native" : ""); } catch { baseUrl = cfg?.baseUrl || ""; }
43
+ try { baseUrl = loadProviderBaseUrl(gid, stateFile ? { file: stateFile } : undefined) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : gid === "traework" ? "https://trae-api-cn.mchost.guru" : gid === "qoder" ? "qoder://native" : gid === "qwenwork" ? "qwenwork://native" : gid === "globalqwenwork" ? "globalqwenwork://native" : gid === "zcode" ? "zcode://native" : ""); } catch { baseUrl = cfg?.baseUrl || ""; }
44
44
  try { allowed = loadProviderAllowedModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
45
45
  try { allowAny = loadProviderAllowAnyModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
46
46
  // openrouter 特殊:opencode 例外默认 allowAny true,其余默认 false
47
47
  if (gid === "opencode") allowAny = true;
48
- let enabled = Boolean(baseUrl && keys.length) || (gid === "openrouter" && keys.length > 0) || ((gid === "qoder" || gid === "qwenwork" || gid === "traework") && keys.length > 0);
48
+ let enabled = Boolean(baseUrl && keys.length) || (gid === "openrouter" && keys.length > 0) || ((gid === "qoder" || gid === "qwenwork" || gid === "globalqwenwork" || gid === "traework" || gid === "zcode") && keys.length > 0);
49
49
  const auths = gid === "workbuddy" ? (() => { try { return loadProviderAuths(gid, stateFile ? { file: stateFile } : undefined) || []; } catch { return []; } })() : [];
50
50
  let note = "";
51
51
  const isWorkbuddyStub = gid === "workbuddy" && (keys.includes("k-new") || String(baseUrl).includes("127.0.0.1") || (keys.length === 1 && keys[0].length < 20));
package/src/daemon.js CHANGED
@@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, openSync, readFileSync, writeFil
3
3
  import { join, dirname } from "node:path";
4
4
  import os from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { writeExitMarker } from "./runtime/lifecycle-forensics.js";
6
7
 
7
8
  export function daemonDir() {
8
9
  return process.env.MSLXDFF_DAEMON_DIR || join(os.homedir(), ".config", "mslxdff");
@@ -73,15 +74,19 @@ export function isPidAlive(pid) {
73
74
  }
74
75
  }
75
76
 
76
- export function stopDaemon() {
77
+ export function stopDaemon({ reason = "stop" } = {}) {
77
78
  const pid = readPid();
78
79
  if (!pid) return { stopped: false, reason: "no pid file" };
80
+ // 杀者牌(验尸官的另一半证据):动手前写、自带死者身份。只给在世者留牌——给尸体留牌
81
+ // 会让下次验尸把"早已静默死亡"误判成"被本次杀死"。
82
+ const victimAlive = isPidAlive(pid);
79
83
  // 杀者留痕(写 daemon.log):Windows 的 SIGTERM 是 TerminateProcess 强杀,被杀的 daemon
80
84
  // 在 JS 层收不到任何事件(无法自记),故由"杀者"记录调用方 pid——
81
85
  // 没有这行 = 非我方所杀(外部 taskkill/任务管理器/崩溃),配合最后一条 heartbeat 定位死亡时刻。
82
86
  try {
83
87
  appendFileSync(logFile(), `[lifecycle] stopDaemon called by pid=${process.pid} — killing daemon pid=${pid}\n`);
84
88
  } catch {}
89
+ if (victimAlive) { try { writeExitMarker(daemonDir(), { reason, prevPid: pid, prevVersion: readPidVersion(), byPid: process.pid }); } catch {} }
85
90
  try {
86
91
  process.kill(pid, "SIGTERM");
87
92
  } catch (err) {
@@ -92,5 +97,5 @@ export function stopDaemon() {
92
97
  } catch {
93
98
  // already gone
94
99
  }
95
- return { stopped: true, pid };
100
+ return { stopped: true, pid, stale: !victimAlive };
96
101
  }
package/src/logs.js CHANGED
@@ -108,7 +108,20 @@ export function appendError(entry, { file = errorsFile() } = {}) {
108
108
  // Structured debug event stream: one JSON line per request/error/forward
109
109
  // step, consumed live by `mslxdff -debug`.
110
110
  export function appendEvent(entry, { file = eventsFile() } = {}) {
111
- appendLine(file, entry);
111
+ appendLine(file, dropTalk(entry));
112
+ }
113
+
114
+ // events.log 是 debug 流(1MB 环形),对话正文只准进 talk/*.log。
115
+ // 单点剥掉 detail.talk:任何 relay 路径(含绕过 relay-pipeline 的 exhausted 收尾)都不会
116
+ // 把整段问答灌进 debug 行;留 talkChars 计数,debug 时仍看得出这轮有没有出字。
117
+ function dropTalk(entry) {
118
+ const t = entry?.detail?.talk;
119
+ if (!t) return entry;
120
+ const detail = { ...entry.detail };
121
+ delete detail.talk;
122
+ detail.talkChars = (t.content || []).join("").length;
123
+ detail.talkReasoningChars = (t.reasoning || []).join("").length;
124
+ return { ...entry, detail };
112
125
  }
113
126
 
114
127
  export function recentEvents(n = 100, { file = eventsFile() } = {}) {
@@ -78,7 +78,8 @@ const DECISION_FIELDS = [
78
78
  "winPeer", "winTarget", "peer", "peers", "peerLabel", "routeBest", "at",
79
79
  "latencyMs", "ttfMs", "hedged", "hops", "hasShare", "elapsedMs", "threshold",
80
80
  "stallHits", "maxGapMs", "interrupted", "providers", "via",
81
- "retry", "max", "delayMs", "upstream", "account", "pick", "cooled",
81
+ "retry", "step", "max", "delayMs", "upstream", "account", "pick", "cooled", "nudged", "retries", "waitedMs", "raiseFrom", "raiseTo",
82
+ "heldOpen", "terminalForm", "requestWaitedMs", "budgetMs", "budgetOut",
82
83
  ];
83
84
 
84
85
  function decisionKv(data = {}) {
@@ -115,7 +116,9 @@ export function formatModelTrace({ type, reqId, model, data = {}, request = null
115
116
  else if (type === "exhausted-local" || type === "exhausted-all") detail = `last=${data.lastModel || model || "-"} status=${data.lastStatus ?? "-"} order=${(data.order || []).join(" | ")}`;
116
117
  else if (type === "upstream-try") detail = `target=${data.model || model || "-"} attempt=${data.attempt ?? "-"} payload=${kv(data.payload || {})}`;
117
118
  else if (type === "upstream-done" || type === "upstream-error") detail = `upstream status=${data.status ?? "-"} timing=${data.timing?.totalMs ?? "-"}ms${data.upstream ? ` via=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}${data.cooled ? ` cooled=${data.cooled}` : ""} ${safeText(data.message || data.error || "")}`;
118
- else if (type === "empty-turn-retry") detail = `retry ${data.retry ?? "-"}/${data.max ?? "-"} delay=${data.delayMs ?? "-"}ms${data.upstream ? ` after=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""} reason=empty turn`;
119
+ else if (type === "empty-turn-retry") detail = `空转重试:retry ${data.retry ?? "-"}/${data.max ?? "-"}${data.step != null ? ` step=${data.step}` : ""} delay=${data.delayMs ?? "-"}ms${data.waitedMs ? ` waited=${data.waitedMs}ms` : ""}${data.requestWaitedMs != null ? ` 请求级累计=${data.requestWaitedMs}ms/预算${data.budgetMs ?? "-"}ms` : ""}${data.raiseTo ? ` max_tokens ${data.raiseFrom}→${data.raiseTo}` : ""}${data.upstream ? ` after=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}${data.nudged ? ` nudged=1` : ""} reason=empty turn${data.reason ? ` :: ${safeText(data.reason, 120)}` : ""}`;
120
+ else if (type === "empty-turn-recovered") detail = `空转重试成功:第 ${data.retries ?? "-"}/${data.max ?? "-"} 次重拉拿到真实输出,本轮共白等 ${data.waitedMs ?? "-"}ms${data.upstream ? ` via=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}`;
121
+ else if (type === "empty-turn-exhausted") detail = `空转重试已用尽:重试 ${data.retries ?? 0}/${data.max ?? "-"} 次仍零输出(累计等待 ${data.waitedMs ?? 0}ms${data.budgetOut ? `,请求级等待预算 ${data.budgetMs ?? "-"}ms 用尽` : ""}),交回换候选${data.heldOpen ? "(响应仍开着,换过去的正文还能送达)" : "(响应已封口,重拉只为省额度)"}${data.upstream ? ` after=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}${data.reason ? ` :: ${safeText(data.reason, 120)}` : ""}`;
119
122
  else if (type === "peer-request" || type === "peer-forward" || type === "peer-error") detail = `peer=${hostPort(data.peer)} status=${data.status ?? "-"} latency=${data.latencyMs ?? "-"}ms payload=${kv(data.payload || {})} ${safeText(data.message || data.error || "")}`;
120
123
  else if (type === "relay-done") {
121
124
  const d = data.detail || {};
@@ -0,0 +1,46 @@
1
+ # src/providers — 供应商契约(改任何 provider 前必读)
2
+
3
+ > 从 `AGENTS.md` 下沉(2026-09-29 分层,就近生效)。改完跑:`npm run docs:check` + 相关 provider 测试 + 裸调验证 `-provider <id> models`。
4
+ > 相关 ADR:0007(前缀路由)/0010(allowlist)/0019(share-keys)/0023(key 直连)/0026(cline id)/0029(qoder)/0033(cline 自动同步)/0037(qwenwork)/0038(zcode)/0041(globalqwenwork)。
5
+
6
+ ## 通用机制(dispatcher / keyring / 门禁)
7
+
8
+ - `src/providers/` 每供应商一文件,前缀路由 `<provider>/raw`(裸 id 归 opencode),dispatcher 剥前缀转发;`providerKeys: {id:[k1,k2]}` round-robin 30s 冷却;`shareKeys` via `x-mslxdff-share-keys` 瞬时借用且 opencode 恒排除。
9
+ - `providerConfigs.allowAnyModels` 默认 `false`(`opencode:true` 例外)+ `allowlist` 空即 `403`;`isModelAllowed` 对未配置 provider 放行(兼容测试),`listModels` 同步过滤;`providerConfigs.{modelsPath,chatPath}` 可配异形路径(`workbuddy=/console/enterprises/personal/models` & `/v2/chat/completions`,其余默认 `/models` & `/chat/completions`,`b.ai=/v1/models`、`cline=/api/v1/models` 已验证)。
10
+ - `src/models.js` 的 `aggLoad`/`get` 二次过滤:`!allowed.length && !allowAny → 跳过整供应商`;`src/routes/chat/index.js` 的 `isAllowlistBlock` 在 `useAuto` 时软跳 `continue`(显式请求才硬 `403`)。
11
+ - 定制 provider 启用门禁下沉为纯函数 `src/runtime/provider-gate.js` —— auth 号型(workbuddy/traework/qoder)**不要求 baseUrl**,旧门禁 `baseUrl && keys` 曾把 qoder 整家静默跳过(表现:`/v1/models` 零条目 + `Model qoder/x is not supported`,无日志)。
12
+ - CLI:`provider <id> models` 与 `model list --provider` 为直查一级答案(`--json` 供脚本),HTTP `GET /v1/providers/:id/models`;价格列 `x0.00/浮动/—` 对齐展示(workbuddy 按 credits 升序)。
13
+ - 新增 provider 三步走:registry 注册 + classify(local-only 判据见 ADR-0023)+ allowlist 只增不减补齐;必须裸调验证 `free` 是否免 Key;`free` 4 个仅目录免 Key ≠ 对话免 Key(cline 教训)。
14
+
15
+ ## qoder(原生 COSY 栈,ADR-0029/0036)
16
+
17
+ - 凭证单一源 `auths/qoder-<uid>.json`(0600,含每号 `region` —— `state.normalizeAuths` 会剥该字段,故 region 只能存 auths 文件);签到按每号 region 选域名(cn=`openapi.qoder.com.cn` 走 `daily-check-in/status→claim`,409=今日已领;global=`openapi.qoder.sh` 该端点 404 → 回落 `/sash/api/v1/me/campaigns→/{id}/claim`);认证只需 `Bearer <device_token>` + `cosy-clienttype: 10`(**无 COSY 签名、claim 空 body**);默认只领 `CLAIM_BENEFIT`,促销条目 `VIEW_DETAILS` 需 `-provider qoder checkin --any`(实测能 claim,但奖励是订阅折扣、不进 quota);余额 `GET /api/v2/quota/usage`;daemon 每日 09:00 自动(`MSLXDFF_QODER_CHECKIN=0` 关,`_CHECKIN_HOUR` 改点)。
18
+ - **流式坏号判决面有两条**:①首包 HTTP 非 200;②**HTTP 200 但 SSE 信封内 `statusCodeValue` 非 200**(限流裹成 `200` + `{"code":"403","message":{"isQueued":true,"retryAfterSeconds":30}}`,只盖①会漏掉②——实测后果:94 次 empty turn 502、0 次恢复)。②的取法:返回 Response **前预读上游首帧**取判决(`PEEK_MAX_FRAMES=8`,遇判决或首个有效载荷即停),预读字节原样回灌给 `reshapeQoderStream`(`prefetched` 参数),对外仍恒 200、状态码只走内部头 `x-mslxdff-qoder-upstream-status`(fetch 异常折 502)。`test/qoder-envelope-status.test.js` 4 例。
19
+ - **同请求粘号**(`scope=reqId`,`MSLXDFF_QODER_STICKY_MS` 默认 600s,`0`=关):一次客户端请求内复用同一号,仅 401/403/429/5xx 冷却才换;`pick=new|sticky|switch|forced` 经 `x-mslxdff-qoder-account` 回显、冷却经 `x-mslxdff-qoder-cooldown` 回显。`test/qoder-cooldown-stream.test.js` 3 例。
20
+ - `GET /v1/models` 在 `modelPicks` 非空时按勾选集裁剪(`filterByPicks`,空勾选=不裁剪,`?all=1` 绕过),`-models` 交互候选取数恒走 `?all=1`;交互选择器按终端行高开窗分页(`src/chooser.js`,多选行形状 `❯ [✓] <id>`)。
21
+
22
+
23
+ ## qwenwork(cn)与 globalqwenwork(国际站)— 同协议两租户(ADR-0037/0041)
24
+
25
+ - 千问办公两站 `gateway.qwenwork.cn` / `gateway.qwenwork.ai`:COSY 签名(**同一枚 RSA 模数**)、SSE 端点 `/algo/api/v2/service/pro/sse/agent_chat_generation`、chat body、refresh `{refresh_token}` **逐字同构**(现网双站实测 200),故 globalqwenwork 的协议模块从 qwenwork 逐字 vendor(仓库约定:provider 目录自包含、不跨目录 import)。**差异只在三处租户参数**:auth 域名、OAuth `client_id`(cn `e883ade2…` / intl `cc65e5fc…`)、深链 `redirect_uri`(`qwenwork-cn://` / `qwenwork://`);**凭证与积分池两站不互通**(cn token 打 .ai → 401 INVALID_TOKEN)。
26
+ - ⚠ **registry.js 子串陷阱**:qwenwork 的 matcher 是 `includes("qwenwork")`,而 `globalqwenwork` / `globalqwenwork://native` 都含该子串且 `getCustomProviderFactory` first-match-wins —— **globalqwenwork 条目必须排在 qwenwork 之前**,否则国际站 token 被 cn 工厂接管(乱 401 + refresh 污染 cn 号)。`test/globalqwenwork-provider.test.js` 第 2 例锁死该顺序;动 registry 顺序前先跑它。
27
+ - 模型池零交集:cn `flash`/`pro`/`qwen3.8-max-preview`(pf 0.1/1/1.8,reasoning)↔ intl 仅 `qwork-auto`(→qwen3.8-flash)/`qwork-advanced`(→gpt-5.6-sol)(pf 0,非 reasoning,is_vl,max_input 180000)。登录各自种 allowlist、`allowAnyModels=false`(额度池防 auto 烧分)。国际站官方客户端自身走 gRPC `model.chat.ChatService/ChatCompletionStream`(其 `region/endpoints` 在 .ai 404),本仓不走 —— 见 ADR-0041 决策 1。
28
+ ## workbuddy
29
+
30
+ - 专用 `POST /v2/chat/completions` **强制 `stream:true`** + `GET /console/...` 按 credits 升序前缀 `workbuddy/`;401/403 自动 `POST /v2/plugin/auth/token/refresh` 回写 `state.json`+`auths/workbuddy-<uid>.json`(0600 tmp+rename)。
31
+ - 凭据目录 `authDirFor()` 跟账本走 + `authDirCandidates()` 只读兜底旧位置 + `listAccountDocs()` 单一读取出口(provider/CLI/token-auto 四处复制品合一);`-workbuddy remove` 扫全部候选目录防旧副本「复活」(ADR-0025,`test/workbuddy-authdir.test.js` 6 例)。
32
+ - 接入禁手填 `<key>`,必须 `node workbuddy-token-auto.js` 自动化(whistle MITM :8899;whistle/MITM 取 codex token 路径已证伪);桌面登录态跨平台发现 `src/providers/workbuddy/desktop-info.js`(显式 `--file`/env > 后缀快查 > 文件名 BFS depth≤6;Win `%LOCALAPPDATA%`/`%APPDATA%`、mac `~/Library/Application Support`、Linux `~/.config`);设备授权加号(0.1.93):`POST /v2/plugin/auth/state?platform=CLI` → 浏览器登录 → 轮询 `token?state=`,UA `CLI/2.63.2 CodeBuddy/2.63.2` 可 `MSLXDFF_WORKBUDDY_UA` 覆盖;每日 `workbuddy-checkin.js` 双域幂等 `code 10001` 视为成功。
33
+ - `share` 默认 `off` 需 `MSLXDFF_WORKBUDDY_SHARE_KEYS=1`;`-setto workbuddy [modelId]` 原子写 `~/.workbuddy/models.json`(仅 127.0.0.1/v1)。
34
+
35
+ ## cline
36
+
37
+ - 指纹头:UA `Cline/3.0.47` + `X-CLIENT-TYPE: cline-sdk` + `X-CLIENT-VERSION` 同 UA;对话链 = `POST /api/v1/auth/refresh`(**每次 refresh 轮换 refreshToken 必须回写**)→ `Bearer workos:<accessToken>` + 固定 `<base>/api/v1/chat/completions`(**baseUrl 已含 /api/v1 时只拼 `/chat/completions`,否则双 /api/v1 会 401/empty response**);deepseek/z-ai 免费通道必须 `stream:true` + SSE 聚合否则 `500 empty response content`;`X-Task-ID`/`session_id` 每次唯一。
38
+ - 发上游剥 `provider/` 前缀(`cline/deepseek/X→deepseek/X`)恢复 forceStream 聚合 + 非 JSON 响应体 TypeError 加固(`test/cline-chat-id.test.js` 4 用例,0.1.91)。
39
+ - 白名单自动同步(ADR-0033):`allowlist-sync.js` 纯函数 `planMerge`(只增不减)+ `createAllowlistSync`(无新增不写盘、异常吞掉留痕),由 `cline/index.js` 注入;`cline/models.js` 仅在**上游成功取数**路径调 `notifyRefresh`(内置兜底常量不写盘,防污染);`MSLXDFF_CLINE_AUTOSYNC=0` 关;**教训:`saveProviderAllowedModels`/`saveProviderConfig` 重建 cfg 时起手 `{baseUrl,keys}` 未带 `allowAnyModels` 会静默抹成 false**(已修,勿回退)。
40
+ - 登录走 WorkOS 设备授权流(client_id `client_01K3A541FN8TA3EPPHTD2325AR`),refreshToken 落 `providerConfigs.{cline}.keys`;bench 检测 keys 含 refreshToken 即走 refresh→指纹头→stream 测速专用分支。
41
+
42
+ ## bench 与额度保护(`src/bench/`)
43
+
44
+ - `bench/bench --via` 仅测(allowlist ∩ 全局 picks)交集(`filterBenchModels`,picks 空回退 allowlist);计费模型(无 `-free` 后缀)需显式点名才测(`max_tokens=5` 也计费);`--via` 额度保护默认跳过 `opencode` 需 `--include-opencode` 且 TTY 二次确认 `y/N`;0.1.84 起完全串行 `直连→逐 peer`(`MSLXDFF_BENCH_DELAY_MS=120`)逐模型实时打印(`--json` 进度走 `stderr`)。
45
+ - `aihubmix gemini 400 no_available_channel` 为上游无通道非 bench bug;peer name 统一短化 `host:port`。
46
+ - **烧额度的实测必须先经用户明确同意**:付费/订阅/按量计费上游的裸调、A/B 长文生成一律先问(2026-09-23 教训);免费池探针(public 免费模型、短 prompt 微量)可直接跑。
@@ -4,7 +4,7 @@
4
4
  // latency-compare:key/token 类,direct vs link+remote 比延迟
5
5
  export function classifyProvider(id) {
6
6
  const s = String(id || "").trim().toLowerCase();
7
- if (s === "workbuddy" || s === "cline" || s === "codearts" || s === "traework" || s === "qoder" || s === "qwenwork" || s === "zcode") return "local-only";
7
+ if (s === "workbuddy" || s === "cline" || s === "codearts" || s === "traework" || s === "qoder" || s === "qwenwork" || s === "globalqwenwork" || s === "zcode") return "local-only";
8
8
  if (s === "opencode") return "quota-pool";
9
9
  return "latency-compare";
10
10
  }
@@ -0,0 +1,141 @@
1
+ // globalqwenwork 账号落盘单一源:auths/globalqwenwork-<uid>.json(0600 tmp+rename)+ state.json 双写。
2
+ // vendor 自 src/providers/qwenwork/account-store.js 逐字搬,改三处命名空间:
3
+ // ① 文件名前缀 `globalqwenwork-`(与 cn 的 `qwenwork-` **互不为前缀**,故两站号同目录也不串;
4
+ // 反向亦然:`qwenwork-x.json` 不 startsWith("globalqwenwork-"))
5
+ // ② state 键 `providerConfigs.globalqwenwork`
6
+ // ③ auth 目录 env 独立为 `GLOBALQWENWORK_AUTH_DIR`(不与 cn 的 QWENWORK_AUTH_DIR 共享,防两站串号)
7
+ // 国际站与 cn 站凭证**不互通**(实测 cn token 打 .ai userinfo → 401 INVALID_TOKEN),故必须各存各的。
8
+ import { writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync, readdirSync, readFileSync, chmodSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import { isTestEnv, defaultStateFile } from "../../state/store.js";
12
+
13
+ const PROVIDER_ID = "globalqwenwork";
14
+ const FILE_PREFIX = "globalqwenwork-";
15
+
16
+ export function authDirFor({ explicit = "", testEnv = false, stateFile } = {}) {
17
+ if (explicit) return explicit;
18
+ if (testEnv) return join(tmpdir(), "mslxdff-test-auths-globalqwenwork");
19
+ return join(dirname(stateFile), "auths");
20
+ }
21
+
22
+ export function resolveAuthDir() {
23
+ return authDirFor({
24
+ explicit: process.env.GLOBALQWENWORK_AUTH_DIR || "",
25
+ testEnv: isTestEnv(),
26
+ stateFile: defaultStateFile(),
27
+ });
28
+ }
29
+
30
+ export function resolveAuthDirs() {
31
+ const primary = resolveAuthDir();
32
+ const cwdDir = join(process.cwd(), "auths");
33
+ return primary === cwdDir ? [primary] : [primary, cwdDir];
34
+ }
35
+
36
+ export function listAccountDocs({ dirs } = {}) {
37
+ const out = [];
38
+ const seen = new Set();
39
+ for (const dir of dirs || resolveAuthDirs()) {
40
+ let files = [];
41
+ try {
42
+ if (!existsSync(dir)) continue;
43
+ files = readdirSync(dir).filter((f) => f.startsWith(FILE_PREFIX) && f.endsWith(".json"));
44
+ } catch { continue; }
45
+ for (const f of files) {
46
+ try {
47
+ const doc = JSON.parse(readFileSync(join(dir, f), "utf8"));
48
+ const uid = doc?.account?.uid;
49
+ if (!uid || !doc?.auth?.accessToken) continue;
50
+ if (seen.has(String(uid))) continue;
51
+ seen.add(String(uid));
52
+ out.push({ uid: String(uid), file: join(dir, f), dir, doc });
53
+ } catch {}
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function writeAccountFile({ uid, accessToken, refreshToken = "", expiresAt = 0, name = "", email = "" }) {
60
+ const authDir = resolveAuthDir();
61
+ mkdirSync(authDir, { recursive: true });
62
+ const doc = {
63
+ account: { uid, name, email },
64
+ auth: { accessToken, refreshToken, expiresAt },
65
+ };
66
+ const fp = join(authDir, `${FILE_PREFIX}${uid}.json`);
67
+ const tmp = `${fp}.tmp`;
68
+ writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 });
69
+ try {
70
+ if (existsSync(fp)) { try { chmodSync(fp, 0o600); } catch {} unlinkSync(fp); }
71
+ renameSync(tmp, fp);
72
+ } catch {
73
+ writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 });
74
+ try { unlinkSync(tmp); } catch {}
75
+ }
76
+ try { chmodSync(fp, 0o600); } catch {}
77
+ return fp;
78
+ }
79
+
80
+ // state 落盘:keys[i] 存 JSON blob({device_token, refresh_token},对齐家族形状),auths 存 uid 索引行。
81
+ async function persistAccounts(accounts, file) {
82
+ const { loadProviderConfigs } = await import("../../state.js");
83
+ const { saveProviderConfig } = await import("../../state.js");
84
+ const cfg = (loadProviderConfigs(file ? { file } : {})?.[PROVIDER_ID]) || {};
85
+ const keys = [...(Array.isArray(cfg.keys) ? cfg.keys : [])];
86
+ const auths = [...(Array.isArray(cfg.auths) ? cfg.auths : [])];
87
+ let updated = 0;
88
+ for (const acc of accounts) {
89
+ if (!acc?.uid || !acc?.accessToken) continue;
90
+ const blob = JSON.stringify({ device_token: acc.accessToken, refresh_token: acc.refreshToken || "" });
91
+ const row = { uid: acc.uid, name: acc.name || "", refreshToken: acc.refreshToken || "" };
92
+ const idx = auths.findIndex((a) => String(a?.uid) === String(acc.uid));
93
+ if (idx >= 0) { keys[idx] = blob; auths[idx] = row; updated += 1; }
94
+ else { keys.push(blob); auths.push(row); }
95
+ }
96
+ // saveProviderConfig 起手保留 allowAnyModels(已在 state 层修复,见 ADR-0033 字段保留修复)
97
+ saveProviderConfig(PROVIDER_ID, { baseUrl: cfg.baseUrl || "", keys, auths }, file ? { file } : {});
98
+ return { keys, auths, updated };
99
+ }
100
+
101
+ export async function saveGlobalQwenworkAccount({ uid, accessToken, refreshToken = "", expiresAt = 0, name = "", email = "", file } = {}) {
102
+ if (!uid || !accessToken) throw new Error("saveGlobalQwenworkAccount: 缺少 uid/accessToken");
103
+ const fp = writeAccountFile({ uid, accessToken, refreshToken, expiresAt, name, email });
104
+ const st = await persistAccounts([{ uid, accessToken, refreshToken, name }], file);
105
+ return { file: fp, accounts: st.keys.length, updated: st.updated > 0 };
106
+ }
107
+
108
+ /** 回写刷新后的 token(上游每次 refresh 会轮换 refreshToken,必须落盘)。 */
109
+ export async function updateGlobalQwenworkTokens({ uid, accessToken, refreshToken = "", expiresAt = 0, file } = {}) {
110
+ if (!uid || !accessToken) throw new Error("updateGlobalQwenworkTokens: 缺少 uid/accessToken");
111
+ const docs = listAccountDocs();
112
+ const hit = docs.find((d) => String(d.uid) === String(uid));
113
+ const prev = hit?.doc || {};
114
+ const fp = writeAccountFile({
115
+ uid,
116
+ accessToken,
117
+ refreshToken: refreshToken || prev?.auth?.refreshToken || "",
118
+ expiresAt: expiresAt || 0,
119
+ name: prev?.account?.name || "",
120
+ email: prev?.account?.email || "",
121
+ });
122
+ await persistAccounts([{ uid, accessToken, refreshToken: refreshToken || prev?.auth?.refreshToken || "", name: prev?.account?.name || "" }], file);
123
+ try {
124
+ const { invalidateSession } = await import("./cosy.js");
125
+ invalidateSession({ uid, accessToken: prev?.auth?.accessToken });
126
+ } catch {}
127
+ return { file: fp };
128
+ }
129
+
130
+ // 从 provider keys 的 blob 还原凭据(device_token/deviceToken 双形状兼容)
131
+ export function accountFromBlob(blob) {
132
+ try {
133
+ const j = JSON.parse(String(blob || "{}"));
134
+ const accessToken = String(j.device_token || j.accessToken || "");
135
+ const refreshToken = String(j.refresh_token || j.refreshToken || "");
136
+ if (!accessToken) return null;
137
+ return { accessToken, refreshToken };
138
+ } catch {
139
+ return null;
140
+ }
141
+ }