dsh-workbuddy-xdpool 0.3.0 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@
4
4
 
5
5
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
6
6
 
7
+ ## 0.4.0 (2026-09-16)
8
+
9
+ 本次更新来自两条用户反馈与一次上游兼容性对齐:账号现在可以**按顺序逐个用尽**(不再平均分摊),积分包**逐条显示具体到期时刻**,并修掉了一处会让所有接口返回 401 的凭据选择缺陷。
10
+
11
+ ### 新增
12
+
13
+ - **账号使用方式可切换(优先 / 轮换)**:卡片状态行下方新增开关,默认**优先模式**。
14
+
15
+ - **优先模式**:始终由同一个账号服务,直到它被限流才顺延到下一个;冷却结束后它**立刻回到队首**接管 —— 该账号的积分在冷却期间并未被消耗,所以无需从头排队。
16
+ - **轮换模式**:保留原来的平均分摊行为,供希望把额度摊平到各账号的用户选择。
17
+ - 切换**即时生效**并写入插件设置,**对所有账号、所有切换状态通用**,不需要重启宿主。
18
+ - 优先级顺序是确定性的:桌面 App 当前登录的账号 → 上游签发时间较新者 → 存储过期时间较晚者。此前顺序取决于文件名字典序,实际会出现 `0, 2, 1` 这类非预期排列。
19
+
20
+ - **积分包逐条显示到期时刻**:原先只给出「3 天内到期」的合计数,看不到具体时间;现在每个积分包单独标注它的到期时刻(精确到分钟,例如「到期 09/19 15:36」),3 天内到期的以琥珀色高亮。月度周期套餐显示下次刷新时刻(「刷新 10/01 00:00」)。
21
+
22
+ - 之所以精确到分钟而不是只给日期:上游发放一次性礼包的到期时刻是**任意的钟点**,并非「过 0 点即失效」,只显示日期会让人误判。
23
+
24
+ ### 布局
25
+
26
+ - **积分包列表改为两行网格**:包名与数量在首行左右分栏,到期时刻在次行跨栏并与包名左对齐。此前到期时刻被挤到第二行且从容器左边缘起排,列表看起来参差不齐。
27
+
28
+ ### 修复
29
+
30
+ - **同一账号存在多个凭据文件时会选中已失效的那一个,导致积分 / 签到 / 模型全部返回 401**。此前按**存储的 `expiresAt` 最大**来挑选凭据,但 `expiresAt` 描述的是「签发时有效期有多长」,**不代表上游仍然接受** —— 上游吊销 token 时不会同步改写该字段,于是一个早已失效的备份可以声称比真实可用的文件**更晚过期**,从而被长期选中。
31
+
32
+ - 现改用 **`auth.lastRefreshTime`(上游自己的签发时间)** 作为新鲜度判据,并在解析凭据时读入该字段。排序优先级:① 桌面 App 当前登录的 live 文件 → ② 签发时间较新者 → ③ 存储过期时间(仅作为文档缺少签发时间时的回退,保证比较是全序的)。
33
+
34
+ - **国际版登录域名识别不全**:参考桌面客户端自身的国际域名清单(`workbuddy.ai`、`workbuddy.cc`),并补充 CodeBuddy CLI 使用的 `codebuddy.ai`。此前只识别 `workbuddy.ai`,落在其他拼写上的国际账号会被误判为国内版,token 被发往国内网关而在 openresty 层被拒绝。
35
+
36
+ - **国际网关写死单一域名**:国际版账号按品牌域名区分且**互不通用**(在 `codebuddy.ai` 签发的 token 会被 `workbuddy.ai` 网关拒绝,反之亦然),因此网关地址改为**跟随凭据自身的域**,未识别的域名回落至桌面端网关。chat / billing / referer / 模型目录四处一并生效。
37
+
38
+ - **上游以 HTML 页面拒绝凭据时给出可执行提示**:识别 openresty / APISIX 的鉴权拒绝(401/403 且响应体是 HTML),改为提示「凭据已被上游网关拒绝,通常说明用的是旧登录留下的失效凭据;请重新登录 WorkBuddy 桌面端后在卡片中选择该账号」,不再原样抛出 HTML 片段。非鉴权类的非 JSON 响应保持原有的通用提示。
39
+
40
+ - **部分模型不显示思考档位**:上游部分模型的推理元数据使用**单数形式**(`effort` 而非 `supportedEfforts`),此前该形态会被直接丢弃。现将其规范化为插件已理解的复数形状,并携带声明的档位到 `defaultEffort`。
41
+
42
+ ### 测试
43
+
44
+ - 新增 `tests/pool-priority.test.ts`(10 例):优先模式下连续请求命中同一账号、仅在该账号冷却后顺延、冷却结束后回归队首、按模型冷却不影响其他模型;轮换模式的轮转与运行时切换;凭据新鲜度的四类排序(live 优先、签发时间优先、缺失签发时间时回退过期时间、秒/毫秒单位归一化)。
45
+ - 修正 `tests/failover.test.ts` 中依赖轮转顺序的限流桩:改为按**已出现的不同凭据数**计次,不再假设「token 序号即账号位置」,因此与账号排序解耦。
46
+ - 全套 **36 个测试通过**。
47
+
48
+
7
49
  ## 0.3.0 (2026-09-16)
8
50
 
9
51
  本次更新把卡片界面按参考插件的形态重做了一遍,并补齐了国内版 / 国际版的完整分离。
package/README.en.md CHANGED
@@ -15,11 +15,11 @@ Merge **every WorkBuddy account** you have ever signed into on this machine into
15
15
 
16
16
  **Settings card (Settings → Plugins → DSH WorkBuddy XD Pool)**
17
17
 
18
- ![WorkBuddy pool settings card: pool health, per-account panels, credit packages, totals](assets/settings-card.png)
18
+ ![WorkBuddy pool settings card: domestic/international tab strip, pool health, per-account panels, credit packages and totals, per-account daily check-in, and model management (enable, image input, context window)](assets/settings-card.png)
19
19
 
20
- **Model picker (rate multiplier baked into model.name; DSH 0.1.2 composer only reads name)**
20
+ **Model picker (domestic and international appear as two separate supplier groups; the rate multiplier is baked into model.name because the DSH 0.1.2 composer only reads name)**
21
21
 
22
- ![Model picker shows the rate and promo badge next to each model name](assets/model-picker.png)
22
+ ![Model picker: the domestic and international suppliers each form their own group, with the credit rate and promo badge shown next to every model name](assets/model-picker.png)
23
23
 
24
24
  **Domestic / international dual suppliers (each with its own accounts, credits and models, usable at the same time)**
25
25
 
package/README.md CHANGED
@@ -15,11 +15,11 @@
15
15
 
16
16
  **插件配置卡片(设置 → 插件 → DSH WorkBuddy XD Pool)**
17
17
 
18
- ![WorkBuddy 池设置卡片:池健康状态、账号面板、积分包、合计](assets/settings-card.png)
18
+ ![WorkBuddy 池设置卡片:国内版 / 国际版切换栏、池健康状态、账号面板、积分包与合计、每账号每日签到、模型管理(启用勾选 / 图片输入 / 上下文窗口)](assets/settings-card.png)
19
19
 
20
- **模型选择器(倍率直接拼进 model.name:DSH 0.1.2 composer 只读 name)**
20
+ **模型选择器(国内版 / 国际版 两个独立供应商分组;倍率直接拼进 model.name:DSH 0.1.2 composer 只读 name)**
21
21
 
22
- ![模型选择器每个模型名后显示倍率与促销标签](assets/model-picker.png)
22
+ ![模型选择器:国内版与国际版各占一个分组,每个模型名后显示积分倍率与促销标签](assets/model-picker.png)
23
23
 
24
24
  **国内版 / 国际版 双供应商(各自独立账号、积分与模型,可同时使用)**
25
25
 
Binary file
Binary file
package/lib/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import { homedir, platform } from "node:os";
4
- import { join, resolve } from "node:path";
4
+ import { basename, join, resolve } from "node:path";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import "@earendil-works/pi-ai";
7
7
  import "@earendil-works/pi-ai/api/openai-completions.lazy";
@@ -38,20 +38,47 @@ const HARD_CREDIT_MARKERS = [
38
38
  ];
39
39
  /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
40
40
  const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
41
+ /**
42
+ * Hosts the international product answers on, once each has been stripped of a
43
+ * leading label. The WorkBuddy AI desktop app signs in at `workbuddy.ai` (and
44
+ * the desktop client itself lists `workbuddy.cc` alongside it); the CodeBuddy
45
+ * CLI signs the same international account in at `codebuddy.ai`. All are served
46
+ * by one gateway stack, so all are `global` — missing a spelling sends those
47
+ * tokens to the CN gateway, which rejects them at the openresty layer with an
48
+ * HTML 401 instead of a business JSON error.
49
+ */
50
+ const GLOBAL_HOSTS = [
51
+ "workbuddy.ai",
52
+ "workbuddy.cc",
53
+ "codebuddy.ai"
54
+ ];
55
+ /** Region for a login domain; an empty domain means CN (matching upstream tooling). */
41
56
  function regionOf(domain) {
42
57
  const lowered = domain.trim().toLowerCase();
43
- if (lowered.endsWith(".workbuddy.ai") || lowered.endsWith(".workbuddy.cc")) return "global";
44
- if (lowered === "workbuddy.ai" || lowered === "workbuddy.cc") return "global";
58
+ for (const host of GLOBAL_HOSTS) if (lowered === host || lowered.endsWith(`.${host}`)) return "global";
45
59
  return "cn";
46
60
  }
61
+ /**
62
+ * Gateway for a global credential.
63
+ *
64
+ * International accounts are NOT interchangeable across brand domains: a token
65
+ * issued at `codebuddy.ai` is rejected by the `workbuddy.ai` gateway and vice
66
+ * versa, so the base must follow the credential's own domain rather than one
67
+ * hardcoded host. Anything unrecognised falls back to the desktop app's gateway.
68
+ */
69
+ function globalBase(credential) {
70
+ const lowered = credential.domain.trim().toLowerCase();
71
+ if (lowered === "codebuddy.ai" || lowered.endsWith(".codebuddy.ai")) return "https://www.codebuddy.ai";
72
+ return GLOBAL_BASE;
73
+ }
47
74
  function chatBase(credential) {
48
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_CHAT_BASE;
75
+ return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_CHAT_BASE;
49
76
  }
50
77
  function billingBase(credential) {
51
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
78
+ return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
52
79
  }
53
80
  function originReferer(credential) {
54
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
81
+ return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
55
82
  }
56
83
  /** Headers every upstream request shares. */
57
84
  function commonHeaders(credential) {
@@ -100,8 +127,23 @@ function billingHeaders(credential) {
100
127
  if (credential.domain !== "") headers["X-Domain"] = credential.domain;
101
128
  return headers;
102
129
  }
130
+ /**
131
+ * Gateway denials that arrive as an HTML page rather than a JSON envelope.
132
+ *
133
+ * openresty / APISIX reject a request before it reaches the product when the
134
+ * credential is one the gateway no longer honours — most often a stale sign-in
135
+ * left in the auth directory. The status alone (401) is not actionable and the
136
+ * HTML body leaks nothing useful, so this turns it into a sentence the user can
137
+ * act on.
138
+ */
139
+ function isGatewayHtmlRejection(status, text) {
140
+ if (status !== 401 && status !== 403) return false;
141
+ const head = text.slice(0, 512).toLowerCase();
142
+ return head.includes("<html") || head.includes("openresty") || head.includes("apisix");
143
+ }
103
144
  async function readEnvelope(response) {
104
145
  const text = await response.text();
146
+ if (isGatewayHtmlRejection(response.status, text)) throw new Error("the WorkBuddy gateway rejected this credential (http 401). This usually means the account is using a stale sign-in the upstream no longer accepts: sign in again in the WorkBuddy desktop app, then pick the account on the plugin card. Run `dsh-workbuddy-xdpool doctor` to list every credential found.");
105
147
  let parsed;
106
148
  try {
107
149
  parsed = JSON.parse(text);
@@ -146,12 +188,57 @@ function parseCreditMultiplier(value) {
146
188
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
147
189
  }
148
190
  /** Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`. */
191
+ /**
192
+ * The effort ladder the upstream's plural-form payloads declare across both
193
+ * gateways (the live union of every `supportedEfforts` list seen; `minimal` has
194
+ * never appeared). Both gateways also accept every level of it on
195
+ * singular-form models — medium/xhigh fold into high, low/max answer with their
196
+ * own budgets — so a singular `effort` value is a DEFAULT, never the model's
197
+ * only level.
198
+ */
199
+ const SINGULAR_EFFORT_LADDER = [
200
+ "low",
201
+ "medium",
202
+ "high",
203
+ "xhigh",
204
+ "max"
205
+ ];
206
+ /**
207
+ * True when `reasoning` arrives in the singular spelling: an `effort` string,
208
+ * with none of the plural-form fields alongside it. Seen on CN
209
+ * `deepseek-v4.1-flash` / `kimi-k3-1` / `glm-5.2` and global
210
+ * `deepseek-v4.1-flash` / `kimi-k3` / `gemini-3.5-flash`.
211
+ */
212
+ function isSingularEffortForm(raw) {
213
+ return typeof raw["effort"] === "string" && !Array.isArray(raw["supportedEfforts"]) && typeof raw["defaultEffort"] !== "string" && typeof raw["canDisableThinking"] !== "boolean";
214
+ }
215
+ /**
216
+ * Fold a singular-form `effort` into the plural shape the rest of the plugin
217
+ * already understands. Probes on both gateways show these models answer with
218
+ * distinct `reasoning_content` across the whole ladder — and do not think at
219
+ * all when no `reasoning_effort` is sent — so the fold widens
220
+ * `supportedEfforts` and carries the declared value into `defaultEffort`. An
221
+ * unrecognized `effort` passes through as the lone level.
222
+ */
223
+ function singularEffortLadder(raw) {
224
+ const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
225
+ if (effort === void 0) return void 0;
226
+ return SINGULAR_EFFORT_LADDER.includes(effort) ? [...SINGULAR_EFFORT_LADDER] : [effort];
227
+ }
228
+ /**
229
+ * Parse the upstream's `reasoning` object; unknown shapes degrade to
230
+ * `undefined`. Both spellings normalize here: the plural form passes through as
231
+ * declared, and the singular `effort` form folds via
232
+ * {@link singularEffortLadder}.
233
+ */
149
234
  function parseReasoning(value) {
150
235
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
151
236
  const raw = value;
152
- const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((effort) => typeof effort === "string") : void 0;
153
- const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : void 0;
154
- const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : void 0;
237
+ const singularForm = isSingularEffortForm(raw);
238
+ const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
239
+ const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((entry) => typeof entry === "string") : singularEffortLadder(raw);
240
+ const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : effort;
241
+ const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : singularForm ? true : void 0;
155
242
  if (supportedEfforts === void 0 && defaultEffort === void 0 && canDisableThinking === void 0) return;
156
243
  return {
157
244
  ...supportedEfforts === void 0 || supportedEfforts.length === 0 ? {} : { supportedEfforts },
@@ -525,11 +612,13 @@ function parseWorkBuddyAuth(text, sourcePath) {
525
612
  if (accessToken === "") return void 0;
526
613
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
527
614
  if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
615
+ const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
528
616
  return {
529
617
  accessToken,
530
618
  refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
531
619
  expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
532
620
  ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
621
+ ...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
533
622
  ...optionalString(identity["nickname"]) === void 0 ? {} : { nickname: optionalString(identity["nickname"]) },
534
623
  ...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
535
624
  ...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
@@ -542,6 +631,42 @@ function parseWorkBuddyAuth(text, sourcePath) {
542
631
  * Stable account id. `uin` is the billing identity the upstream keys on and
543
632
  * survives re-login; `uid` is the fallback.
544
633
  */
634
+ /**
635
+ * True when `path` is the desktop app's live sign-in (as opposed to a backup
636
+ * snapshot it left behind). The live file always wins: it is the session the
637
+ * app itself is using.
638
+ */
639
+ function isLiveAuthFile(path) {
640
+ return basename(path) === WORKBUDDY_LIVE_FILENAME;
641
+ }
642
+ /**
643
+ * Which of two credentials for the same account the pool should keep.
644
+ *
645
+ * Ordering, highest first:
646
+ *
647
+ * 1. the live file the desktop app is signed in with;
648
+ * 2. the credential the upstream issued most recently (`lastRefreshAtMs`);
649
+ * 3. the longer stored expiry, as a fallback for documents that carry no issue
650
+ * time (the plugin's own refreshed copy, older builds).
651
+ *
652
+ * The stored expiry alone is NOT a freshness signal: the upstream does not
653
+ * rewrite it when it revokes a token, so a long-dead backup can claim to expire
654
+ * later than the token that actually works. Selecting on it made every upstream
655
+ * call return 401 while a perfectly good credential sat in the same directory.
656
+ */
657
+ function compareFreshness(a, b) {
658
+ const aLive = isLiveAuthFile(a.sourcePath) ? 1 : 0;
659
+ const bLive = isLiveAuthFile(b.sourcePath) ? 1 : 0;
660
+ if (aLive !== bLive) return bLive - aLive;
661
+ const aIssued = a.lastRefreshAtMs ?? 0;
662
+ const bIssued = b.lastRefreshAtMs ?? 0;
663
+ if (aIssued !== bIssued) return bIssued - aIssued;
664
+ return b.expiresAtMs - a.expiresAtMs;
665
+ }
666
+ /** True when `candidate` should replace `incumbent` for the same account. */
667
+ function isFresher(candidate, incumbent) {
668
+ return compareFreshness(candidate, incumbent) < 0;
669
+ }
545
670
  function workbuddyAccountId(credential) {
546
671
  const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
547
672
  return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 16);
@@ -593,6 +718,8 @@ var WorkBuddyAccountPool = class {
593
718
  client;
594
719
  refreshMarginMs;
595
720
  accounts = [];
721
+ distribution;
722
+ /** Cursor for round-robin mode; unused under priority distribution. */
596
723
  cursor = 0;
597
724
  lastScanAtMs = 0;
598
725
  preferredId;
@@ -603,6 +730,7 @@ var WorkBuddyAccountPool = class {
603
730
  this.cooldownMs = options.cooldownMs ?? 6e4;
604
731
  this.client = options.client;
605
732
  this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
733
+ this.distribution = options.distribution ?? "priority";
606
734
  }
607
735
  /**
608
736
  * Re-apply configuration that only affects discovery and cooldown policy,
@@ -612,6 +740,7 @@ var WorkBuddyAccountPool = class {
612
740
  applyConfig(options) {
613
741
  if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
614
742
  if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
743
+ if (options.distribution !== void 0) this.distribution = options.distribution;
615
744
  }
616
745
  /** Rescan the auth directories and merge newly discovered accounts. */
617
746
  async scan() {
@@ -636,13 +765,15 @@ var WorkBuddyAccountPool = class {
636
765
  });
637
766
  continue;
638
767
  }
639
- if ((credential.expiresAtMs ?? 0) > (existing.credential.expiresAtMs ?? 0)) byId.set(id, {
768
+ if (isFresher(credential, existing.credential)) byId.set(id, {
640
769
  ...existing,
641
770
  credential,
642
771
  label: accountLabel(credential)
643
772
  });
644
773
  }
645
- this.accounts = [...byId.values()];
774
+ const ordered = [...byId.values()];
775
+ ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
776
+ this.accounts = ordered;
646
777
  this.lastScanAtMs = Date.now();
647
778
  return this.accounts;
648
779
  }
@@ -669,12 +800,24 @@ var WorkBuddyAccountPool = class {
669
800
  });
670
801
  }
671
802
  /**
672
- * Pick the next usable account for an optional model. Scans on first use,
673
- * and rescans when every known account is cooling down — a fresh desktop
674
- * login is the usual way out of an exhausted pool. A preferred
675
- * (user-selected) account that is healthy is tried first; otherwise the
676
- * cursor round-robins so consecutive requests spread across accounts and a
677
- * still-cooling preferred account is skipped.
803
+ * Pick the account to serve a request.
804
+ *
805
+ * Two distributions, chosen by the `distribution` setting:
806
+ *
807
+ * - **priority** (default, and what the card ships with): one account serves
808
+ * every request until it is rate-limited, then the next in order takes over.
809
+ * Credits drain one account at a time, and a cooling account returns to the
810
+ * head of the queue the moment its window resets — it was never consumed, so
811
+ * it resumes straight away.
812
+ * - **round-robin**: consecutive requests rotate through the pool so spend
813
+ * spreads evenly across every account.
814
+ *
815
+ * In both modes an explicit user selection (`prefer`) heads the list, a
816
+ * cooling account is skipped for that model only, and an unrecognised setting
817
+ * falls back to priority.
818
+ *
819
+ * Scans on first use, and rescans when every known account is cooling down: a
820
+ * fresh desktop login is the usual way out of an exhausted pool.
678
821
  */
679
822
  async acquire(modelId, region) {
680
823
  if (this.accounts.length === 0) await this.scan();
@@ -684,21 +827,25 @@ var WorkBuddyAccountPool = class {
684
827
  pool = this.available(Date.now(), modelId, region);
685
828
  }
686
829
  if (pool.length === 0) return void 0;
687
- let start = this.cursor % pool.length;
688
830
  if (this.preferredId !== void 0) {
689
831
  const preferredIndex = pool.findIndex((account) => account.id === this.preferredId);
690
- if (preferredIndex !== -1) start = preferredIndex;
691
- }
692
- for (let step = 0; step < pool.length; step += 1) {
693
- const account = pool[(start + step) % pool.length];
694
- if (account === void 0) continue;
695
- await this.ensureFresh(account);
696
- this.cursor = (start + step + 1) % pool.length;
697
- return account;
832
+ if (preferredIndex > 0) {
833
+ const [preferred] = pool.splice(preferredIndex, 1);
834
+ if (preferred !== void 0) pool = [preferred, ...pool];
835
+ }
698
836
  }
699
- return pool[0];
837
+ const index = this.distribution === "round-robin" ? this.cursor % pool.length : 0;
838
+ const account = pool[index];
839
+ if (account === void 0) return void 0;
840
+ if (this.distribution === "round-robin") this.cursor = (index + 1) % pool.length;
841
+ await this.ensureFresh(account);
842
+ return account;
700
843
  }
701
844
  /** Pin the account the plugin card should prefer; tokens stay out of settings. */
845
+ /** How the pool currently spreads requests. Shown on the card. */
846
+ currentDistribution() {
847
+ return this.distribution;
848
+ }
702
849
  prefer(accountId) {
703
850
  this.preferredId = accountId;
704
851
  }
@@ -963,6 +1110,7 @@ function formatRates(status) {
963
1110
  z.object({
964
1111
  authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
965
1112
  cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
1113
+ distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
966
1114
  enabledModelIds: z.array(z.string()).default([]).description("Model ids enabled in the picker (empty = all)"),
967
1115
  imageModelIds: z.array(z.string()).default([]).description("Model ids accepting image input (empty = follow upstream)"),
968
1116
  contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Per-model context-window override, keyed by model id")
package/lib/client.js CHANGED
@@ -115,6 +115,14 @@ window.__ModuleLoader__.load({
115
115
  .dsm-workbuddy-xdpool-usage-status{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:500;color:var(--dsw-alias-label-primary,#e6e6e6)}
116
116
  .dsm-workbuddy-xdpool-usage-dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto}
117
117
  .dsm-workbuddy-xdpool-usage-hint{padding-left:19px;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
118
+ /* Distribution switch: priority (drain one) vs round-robin (spread). */
119
+ .dsm-workbuddy-xdpool-dist{display:flex;align-items:center;gap:8px;padding-left:19px;flex-wrap:wrap}
120
+ .dsm-workbuddy-xdpool-dist-title{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
121
+ .dsm-workbuddy-xdpool-dist-option{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:999px;padding:2px 10px;font-size:11px;line-height:18px;background:transparent;color:var(--dsw-alias-label-tertiary,#9aa0a8);transition:color .16s,border-color .16s,background .16s}
122
+ .dsm-workbuddy-xdpool-dist-option:hover:not(:disabled):not(.dsm-workbuddy-xdpool-dist-option-active){color:var(--dsw-alias-label-primary,#e6e6e6);border-color:var(--dsw-alias-label-dimmed,#777)}
123
+ .dsm-workbuddy-xdpool-dist-option:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
124
+ .dsm-workbuddy-xdpool-dist-option-active{background:var(--dsw-alias-state-success-subtle,rgba(34,160,107,.14));border-color:var(--dsw-alias-state-success-primary,#22a06b);color:var(--dsw-alias-state-success-primary,#22a06b)}
125
+ .dsm-workbuddy-xdpool-dist-option:disabled{cursor:default;opacity:.6}
118
126
  .dsm-workbuddy-xdpool-usage-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
119
127
 
120
128
  /* Account list (each account = a labeled subpanel, same as dingminhua). */
@@ -143,9 +151,18 @@ window.__ModuleLoader__.load({
143
151
  .dsm-workbuddy-xdpool-panel-foot{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-top:9px;padding-top:9px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:12px;line-height:18px}
144
152
  .dsm-workbuddy-xdpool-panel-foot strong{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:15px;font-variant-numeric:tabular-nums}
145
153
  .dsm-workbuddy-xdpool-packages{display:flex;flex-direction:column;gap:5px;margin:0;padding:0;list-style:none}
146
- .dsm-workbuddy-xdpool-packages li{display:flex;align-items:baseline;justify-content:space-between;gap:10px;color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:12px;line-height:18px}
147
- .dsm-workbuddy-xdpool-packages-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
148
- .dsm-workbuddy-xdpool-packages-value{flex:none;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-variant-numeric:tabular-nums}
154
+ /* One credit package: name + amount on the first line, its deadline beneath.
155
+ A two-row grid keeps the columns aligned across rows; a wrapping flex row
156
+ dropped the deadline onto a second line that started at the container edge,
157
+ so the list read as ragged text rather than a table. */
158
+ .dsm-workbuddy-xdpool-packages li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:10px;row-gap:1px;align-items:baseline;color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:12px;line-height:18px}
159
+ .dsm-workbuddy-xdpool-packages-name{grid-column:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
160
+ .dsm-workbuddy-xdpool-packages-value{grid-column:2;justify-self:end;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-variant-numeric:tabular-nums}
161
+ /* Per-package deadline: the upstream grants one-off packs at arbitrary clock
162
+ times, so each row carries its own timestamp; the soon ones are tinted and
163
+ fold onto their own line so a long package name cannot squeeze them out. */
164
+ .dsm-workbuddy-xdpool-packages-when{grid-column:1/-1;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:15px;font-variant-numeric:tabular-nums}
165
+ .dsm-workbuddy-xdpool-packages-when-soon{color:var(--dsw-alias-state-warning-primary,#d97706)}
149
166
  .dsm-workbuddy-xdpool-panel-total{position:relative;align-items:center;text-align:center;overflow:hidden}
150
167
  .dsm-workbuddy-xdpool-panel-total::before{content:"";position:absolute;top:0;left:0;right:0;height:3px;opacity:.9;background:var(--dsw-alias-state-success-primary,#22a06b)}
151
168
  .dsm-workbuddy-xdpool-total-value{color:var(--dsw-alias-state-success-primary,#22a06b);font-size:30px;line-height:34px;font-weight:700;letter-spacing:-.5px;white-space:nowrap;font-variant-numeric:tabular-nums}
@@ -307,6 +324,30 @@ window.__ModuleLoader__.load({
307
324
  }
308
325
  return false;
309
326
  }
327
+ /** Absolute expiry with the time of day: the upstream grants one-off packages at
328
+ * arbitrary clock times, so "expires 09/19 15:36" is what the user needs — a
329
+ * date alone would read as if it lapsed at midnight. */
330
+ function formatExpiry(ms) {
331
+ if (ms === void 0 || !Number.isFinite(ms)) return "";
332
+ return new Intl.DateTimeFormat(void 0, {
333
+ month: "2-digit",
334
+ day: "2-digit",
335
+ hour: "2-digit",
336
+ minute: "2-digit",
337
+ hour12: false
338
+ }).format(new Date(ms));
339
+ }
340
+ /** Whole days until `ms`, floored at 0; undefined when there is no deadline. */
341
+ function daysUntil(ms) {
342
+ if (ms === void 0 || !Number.isFinite(ms)) return void 0;
343
+ return Math.max(0, Math.floor((ms - Date.now()) / 864e5));
344
+ }
345
+ /** True when a one-off package lapses inside the "expiring soon" window. */
346
+ function isExpiringSoon(pack) {
347
+ if (pack.monthly === true) return false;
348
+ const days = daysUntil(pack.expiresAtMs);
349
+ return days !== void 0 && days <= 3;
350
+ }
310
351
  function tagFor(model) {
311
352
  const tags = model.tags ?? [];
312
353
  if (tags.includes("free")) return "free";
@@ -522,6 +563,25 @@ window.__ModuleLoader__.load({
522
563
  * The card refuses an empty enable-list: saving one would leave the picker
523
564
  * with nothing to offer and no obvious way back.
524
565
  */
566
+ /**
567
+ * Switch how the pool spreads requests. Written straight through the
568
+ * settings scope (that is where the host keeps the pool options), so the
569
+ * change lands without a restart and survives the next card refresh.
570
+ */
571
+ const setDistribution = async (next) => {
572
+ const write = settingsScope?.set;
573
+ if (write === void 0) {
574
+ setError(t?.("row.modelsSaveError", { message: "settings scope is read-only" }) ?? "settings scope is read-only");
575
+ return;
576
+ }
577
+ setFlash(void 0);
578
+ try {
579
+ await write.call(settingsScope, "distribution", next);
580
+ await refresh(activeRegion);
581
+ } catch (cause) {
582
+ if (mounted.current) setError(String(cause));
583
+ }
584
+ };
525
585
  const saveModels = async () => {
526
586
  if (draft === void 0 || status === void 0) return;
527
587
  if (enabledCount === 0) {
@@ -639,6 +699,31 @@ window.__ModuleLoader__.load({
639
699
  shimHint === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
640
700
  className: "dsm-workbuddy-xdpool-usage-hint",
641
701
  children: shimHint
702
+ }),
703
+ status === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
704
+ className: "dsm-workbuddy-xdpool-dist",
705
+ role: "radiogroup",
706
+ "aria-label": t?.("row.distTitle") ?? "Account usage",
707
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
708
+ className: "dsm-workbuddy-xdpool-dist-title",
709
+ children: t?.("row.distTitle") ?? "Account usage"
710
+ }), ["priority", "round-robin"].map((option) => {
711
+ const active = (status.distribution ?? "priority") === option;
712
+ const label = option === "priority" ? t?.("row.distPriority") ?? "Priority" : t?.("row.distRoundRobin") ?? "Round-robin";
713
+ const hint = option === "priority" ? t?.("row.distPriorityHint") ?? "" : t?.("row.distRoundRobinHint") ?? "";
714
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
715
+ type: "button",
716
+ role: "radio",
717
+ "aria-checked": active,
718
+ title: hint,
719
+ disabled: !modelsEditable,
720
+ className: `dsm-workbuddy-xdpool-dist-option${active ? " dsm-workbuddy-xdpool-dist-option-active" : ""}`,
721
+ onClick: () => {
722
+ setDistribution(option);
723
+ },
724
+ children: label
725
+ }, option);
726
+ })]
642
727
  })
643
728
  ]
644
729
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -878,16 +963,30 @@ window.__ModuleLoader__.load({
878
963
  children: "–"
879
964
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
880
965
  className: "dsm-workbuddy-xdpool-packages",
881
- children: packages.map((pack, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
882
- className: "dsm-workbuddy-xdpool-packages-name",
883
- children: pack.packageName
884
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
885
- className: "dsm-workbuddy-xdpool-packages-value",
886
- children: t?.("row.creditsPackage", {
887
- remain: formatNumber(pack.remain),
888
- size: formatNumber(pack.size)
889
- }) ?? `${formatNumber(pack.remain)} / ${formatNumber(pack.size)}`
890
- })] }, `${pack.packageName}-${String(index)}`))
966
+ children: packages.map((pack, index) => {
967
+ const expiry = formatExpiry(pack.expiresAtMs);
968
+ const refresh = formatExpiry(pack.cycleRefreshMs);
969
+ const soon = isExpiringSoon(pack);
970
+ const when = pack.monthly === true ? refresh === "" ? null : t?.("row.creditsRefreshAt", { time: refresh }) ?? `Refreshes ${refresh}` : expiry === "" ? null : t?.("row.creditsExpiresAt", { time: expiry }) ?? `Expires ${expiry}`;
971
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
972
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
973
+ className: "dsm-workbuddy-xdpool-packages-name",
974
+ children: pack.packageName
975
+ }),
976
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
977
+ className: "dsm-workbuddy-xdpool-packages-value",
978
+ children: t?.("row.creditsPackage", {
979
+ remain: formatNumber(pack.remain),
980
+ size: formatNumber(pack.size)
981
+ }) ?? `${formatNumber(pack.remain)} / ${formatNumber(pack.size)}`
982
+ }),
983
+ when === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
984
+ className: `dsm-workbuddy-xdpool-packages-when${soon ? " dsm-workbuddy-xdpool-packages-when-soon" : ""}`,
985
+ title: t?.("row.creditsExpiresSoonTitle") ?? "Expiring within 3 days",
986
+ children: when
987
+ })
988
+ ] }, `${pack.packageName}-${String(index)}`);
989
+ })
891
990
  }),
892
991
  credits?.expiringSoon !== void 0 && credits.expiringSoon > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
893
992
  className: "dsm-workbuddy-xdpool-panel-foot",
@@ -1082,6 +1181,9 @@ window.__ModuleLoader__.load({
1082
1181
  "row.creditsPackage": "{remain} / {size}",
1083
1182
  "row.creditsError": "credits unavailable",
1084
1183
  "row.creditsSoon": "Expiring in 3 days",
1184
+ "row.creditsExpiresAt": "Expires {time}",
1185
+ "row.creditsExpiresSoonTitle": "Expiring within 3 days",
1186
+ "row.creditsRefreshAt": "Refreshes {time}",
1085
1187
  "row.checkinTitle": "Daily check-in",
1086
1188
  "row.checkinClaim": "Check in",
1087
1189
  "row.checkinClaiming": "Checking in…",
@@ -1116,6 +1218,11 @@ window.__ModuleLoader__.load({
1116
1218
  "row.imageCapable": "image input",
1117
1219
  "row.rate": "{rate}x credits",
1118
1220
  "row.accountsRescan": "Detect accounts again",
1221
+ "row.distTitle": "Account usage",
1222
+ "row.distPriority": "Priority",
1223
+ "row.distPriorityHint": "Drain one account before moving to the next",
1224
+ "row.distRoundRobin": "Round-robin",
1225
+ "row.distRoundRobinHint": "Spread the spend evenly across accounts",
1119
1226
  "row.accountsScanning": "Detecting…",
1120
1227
  "row.resetCooldowns": "Clear all cooldowns",
1121
1228
  "row.resetCooldownsBusy": "Clearing…",
@@ -1160,6 +1267,9 @@ window.__ModuleLoader__.load({
1160
1267
  "row.creditsPackage": "{remain} / {size}",
1161
1268
  "row.creditsError": "积分不可用",
1162
1269
  "row.creditsSoon": "3 天内到期",
1270
+ "row.creditsExpiresAt": "到期 {time}",
1271
+ "row.creditsExpiresSoonTitle": "3 天内到期",
1272
+ "row.creditsRefreshAt": "刷新 {time}",
1163
1273
  "row.checkinTitle": "每日签到",
1164
1274
  "row.checkinClaim": "签到",
1165
1275
  "row.checkinClaiming": "签到中…",
@@ -1194,6 +1304,11 @@ window.__ModuleLoader__.load({
1194
1304
  "row.imageCapable": "图片输入",
1195
1305
  "row.rate": "{rate}x 积分",
1196
1306
  "row.accountsRescan": "重新检测账号",
1307
+ "row.distTitle": "账号使用方式",
1308
+ "row.distPriority": "优先模式",
1309
+ "row.distPriorityHint": "优先用完一个账号再换下一个",
1310
+ "row.distRoundRobin": "轮换模式",
1311
+ "row.distRoundRobinHint": "积分平均分摊到各账号",
1197
1312
  "row.accountsScanning": "正在检测…",
1198
1313
  "row.resetCooldowns": "清除所有冷却",
1199
1314
  "row.resetCooldownsBusy": "正在清除…",
package/lib/index.d.ts CHANGED
@@ -150,6 +150,16 @@ interface WorkBuddyCredential {
150
150
  refreshToken: string;
151
151
  expiresAtMs: number;
152
152
  refreshExpiresAtMs?: number;
153
+ /**
154
+ * When the upstream says it issued this token (`auth.lastRefreshTime`).
155
+ *
156
+ * This, not `expiresAtMs`, is the reliable freshness signal: the upstream
157
+ * never rewrites a stored expiry when it revokes a token, so a long-dead
158
+ * backup can claim to expire later than the token that actually works.
159
+ * Absent on documents the desktop app did not write (the plugin's own
160
+ * refreshed copy, older builds).
161
+ */
162
+ lastRefreshAtMs?: number;
153
163
  nickname?: string;
154
164
  uin?: string;
155
165
  uid?: string;
@@ -194,13 +204,11 @@ export declare function defaultDesktopAuthDirs(platform?: NodeJS.Platform, home?
194
204
  * when there is no usable access token.
195
205
  */
196
206
  export declare function parseWorkBuddyAuth(text: string, sourcePath: string): WorkBuddyCredential | undefined;
197
- /**
198
- * Stable account id. `uin` is the billing identity the upstream keys on and
199
- * survives re-login; `uid` is the fallback.
200
- */
201
207
  export declare function workbuddyAccountId(credential: Pick<WorkBuddyCredential, 'uin' | 'uid' | 'nickname'>): string;
202
208
  /** Every directory the pool should scan, in probe order. */
203
209
  export declare function candidateAuthDirs(env?: NodeJS.ProcessEnv): string[];
210
+ /** How the pool chooses which account serves the next request. */
211
+ type AccountDistribution = 'priority' | 'round-robin';
204
212
  interface AccountPoolOptions {
205
213
  /** Logger for discovery and rotation events. */
206
214
  logger?: {
@@ -216,6 +224,17 @@ interface AccountPoolOptions {
216
224
  client?: TokenRefresher;
217
225
  /** Refresh this long before actual expiry; default five minutes. */
218
226
  refreshMarginMs?: number;
227
+ /**
228
+ * How requests are spread across the pool.
229
+ *
230
+ * - `priority` (default): one account serves every request until it is
231
+ * rate-limited, then the next in order takes over. Credits drain one
232
+ * account at a time, and a cooled account resumes at the head of the
233
+ * queue the moment its window resets.
234
+ * - `round-robin`: consecutive requests rotate through the pool so the
235
+ * spend spreads evenly.
236
+ */
237
+ distribution?: AccountDistribution;
219
238
  }
220
239
  /**
221
240
  * Read-only pool of every discovered WorkBuddy account, with rate-limit
@@ -228,6 +247,8 @@ export declare class WorkBuddyAccountPool {
228
247
  private readonly client;
229
248
  private readonly refreshMarginMs;
230
249
  private accounts;
250
+ private distribution;
251
+ /** Cursor for round-robin mode; unused under priority distribution. */
231
252
  private cursor;
232
253
  private lastScanAtMs;
233
254
  private preferredId;
@@ -241,6 +262,7 @@ export declare class WorkBuddyAccountPool {
241
262
  applyConfig(options: {
242
263
  authDirs?: readonly string[];
243
264
  cooldownMs?: number;
265
+ distribution?: AccountDistribution;
244
266
  }): void;
245
267
  /** Rescan the auth directories and merge newly discovered accounts. */
246
268
  scan(): Promise<WorkBuddyAccount[]>;
@@ -257,15 +279,29 @@ export declare class WorkBuddyAccountPool {
257
279
  */
258
280
  private available;
259
281
  /**
260
- * Pick the next usable account for an optional model. Scans on first use,
261
- * and rescans when every known account is cooling down — a fresh desktop
262
- * login is the usual way out of an exhausted pool. A preferred
263
- * (user-selected) account that is healthy is tried first; otherwise the
264
- * cursor round-robins so consecutive requests spread across accounts and a
265
- * still-cooling preferred account is skipped.
282
+ * Pick the account to serve a request.
283
+ *
284
+ * Two distributions, chosen by the `distribution` setting:
285
+ *
286
+ * - **priority** (default, and what the card ships with): one account serves
287
+ * every request until it is rate-limited, then the next in order takes over.
288
+ * Credits drain one account at a time, and a cooling account returns to the
289
+ * head of the queue the moment its window resets — it was never consumed, so
290
+ * it resumes straight away.
291
+ * - **round-robin**: consecutive requests rotate through the pool so spend
292
+ * spreads evenly across every account.
293
+ *
294
+ * In both modes an explicit user selection (`prefer`) heads the list, a
295
+ * cooling account is skipped for that model only, and an unrecognised setting
296
+ * falls back to priority.
297
+ *
298
+ * Scans on first use, and rescans when every known account is cooling down: a
299
+ * fresh desktop login is the usual way out of an exhausted pool.
266
300
  */
267
301
  acquire(modelId?: string, region?: WorkBuddyRegion): Promise<WorkBuddyAccount | undefined>;
268
302
  /** Pin the account the plugin card should prefer; tokens stay out of settings. */
303
+ /** How the pool currently spreads requests. Shown on the card. */
304
+ currentDistribution(): AccountDistribution;
269
305
  prefer(accountId: string | undefined): void;
270
306
  /** Best-effort refresh of one account after a session-dead upstream answer. */
271
307
  refreshAccount(accountId: string): Promise<void>;
@@ -351,7 +387,7 @@ interface ModelSelection {
351
387
  /** Absent = each model follows its upstream image capability. */
352
388
  imageModelIds?: readonly string[];
353
389
  /** Per-model context-window cap, keyed by model id. */
354
- contextBudgets?: Readonly<Record<string, number>>;
390
+ contextBudgets?: Readonly<Record<string, number | undefined>>;
355
391
  }
356
392
  //#endregion
357
393
  //#region src/shim.d.ts
@@ -605,7 +641,7 @@ interface PoolWebModelSelection {
605
641
  /** Absent = each model follows its upstream image capability. */
606
642
  imageModelIds?: readonly string[];
607
643
  /** Per-model context-window cap, keyed by model id. */
608
- contextBudgets?: Readonly<Record<string, number>>;
644
+ contextBudgets?: Readonly<Record<string, number | undefined>>;
609
645
  }
610
646
  /** The JSON document the pool card renders. */
611
647
  interface PoolWebStatus {
@@ -617,6 +653,11 @@ interface PoolWebStatus {
617
653
  models: readonly PoolWebModel[];
618
654
  /** The saved selection the card diffs its draft against. */
619
655
  selection: PoolWebModelSelection;
656
+ /**
657
+ * How the pool spreads requests: `priority` drains one account before
658
+ * moving on, `round-robin` splits the spend evenly.
659
+ */
660
+ distribution: PoolDistribution;
620
661
  /** Which region this document describes. */
621
662
  region: PoolRegion;
622
663
  /** Every region holding at least one account, in display order. */
@@ -632,6 +673,8 @@ interface PoolWebStatus {
632
673
  * international one (`workbuddy.ai`).
633
674
  */
634
675
  type PoolRegion = 'cn' | 'global';
676
+ /** How the pool spreads requests across its accounts. */
677
+ type PoolDistribution = 'priority' | 'round-robin';
635
678
  //#endregion
636
679
  //#region src/index.d.ts
637
680
  /** Stable Cordis plugin name. */
@@ -651,6 +694,14 @@ export interface Config {
651
694
  authFile?: string;
652
695
  /** Rate-limit cooldown per account, milliseconds. */
653
696
  cooldownMs?: number;
697
+ /**
698
+ * How the pool spreads requests across accounts.
699
+ *
700
+ * `priority` (default) drains one account before moving to the next, which
701
+ * is what a pool of your own accounts is for. `round-robin` splits the
702
+ * spend evenly instead. Absent reads as `priority`.
703
+ */
704
+ distribution?: 'priority' | 'round-robin';
654
705
  /**
655
706
  * Model ids enabled in the picker. Absent means "every model the catalog
656
707
  * advertises" — an unconfigured install should never present an empty model
@@ -668,7 +719,7 @@ export interface Config {
668
719
  * advertise more than DSH wants to hand a single turn, so the card lets the
669
720
  * user cap a model without touching the catalog.
670
721
  */
671
- contextBudgets?: Partial<Record<string, number>>;
722
+ contextBudgets?: Record<string, number>;
672
723
  }
673
724
  /** Upper bound the card offers as the "default" context window, in tokens. */
674
725
  export declare const DEFAULT_CONTEXT_BUDGET = 200000;
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { dirname, join, resolve } from "node:path";
1
+ import { basename, dirname, join, resolve } from "node:path";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { readFile, readdir } from "node:fs/promises";
@@ -40,20 +40,47 @@ const HARD_CREDIT_MARKERS = [
40
40
  ];
41
41
  /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
42
42
  const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
43
+ /**
44
+ * Hosts the international product answers on, once each has been stripped of a
45
+ * leading label. The WorkBuddy AI desktop app signs in at `workbuddy.ai` (and
46
+ * the desktop client itself lists `workbuddy.cc` alongside it); the CodeBuddy
47
+ * CLI signs the same international account in at `codebuddy.ai`. All are served
48
+ * by one gateway stack, so all are `global` — missing a spelling sends those
49
+ * tokens to the CN gateway, which rejects them at the openresty layer with an
50
+ * HTML 401 instead of a business JSON error.
51
+ */
52
+ const GLOBAL_HOSTS = [
53
+ "workbuddy.ai",
54
+ "workbuddy.cc",
55
+ "codebuddy.ai"
56
+ ];
57
+ /** Region for a login domain; an empty domain means CN (matching upstream tooling). */
43
58
  function regionOf(domain) {
44
59
  const lowered = domain.trim().toLowerCase();
45
- if (lowered.endsWith(".workbuddy.ai") || lowered.endsWith(".workbuddy.cc")) return "global";
46
- if (lowered === "workbuddy.ai" || lowered === "workbuddy.cc") return "global";
60
+ for (const host of GLOBAL_HOSTS) if (lowered === host || lowered.endsWith(`.${host}`)) return "global";
47
61
  return "cn";
48
62
  }
63
+ /**
64
+ * Gateway for a global credential.
65
+ *
66
+ * International accounts are NOT interchangeable across brand domains: a token
67
+ * issued at `codebuddy.ai` is rejected by the `workbuddy.ai` gateway and vice
68
+ * versa, so the base must follow the credential's own domain rather than one
69
+ * hardcoded host. Anything unrecognised falls back to the desktop app's gateway.
70
+ */
71
+ function globalBase(credential) {
72
+ const lowered = credential.domain.trim().toLowerCase();
73
+ if (lowered === "codebuddy.ai" || lowered.endsWith(".codebuddy.ai")) return "https://www.codebuddy.ai";
74
+ return GLOBAL_BASE;
75
+ }
49
76
  function chatBase(credential) {
50
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_CHAT_BASE;
77
+ return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_CHAT_BASE;
51
78
  }
52
79
  function billingBase(credential) {
53
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
80
+ return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
54
81
  }
55
82
  function originReferer(credential) {
56
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
83
+ return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
57
84
  }
58
85
  /** Headers every upstream request shares. */
59
86
  function commonHeaders(credential) {
@@ -102,8 +129,23 @@ function billingHeaders(credential) {
102
129
  if (credential.domain !== "") headers["X-Domain"] = credential.domain;
103
130
  return headers;
104
131
  }
132
+ /**
133
+ * Gateway denials that arrive as an HTML page rather than a JSON envelope.
134
+ *
135
+ * openresty / APISIX reject a request before it reaches the product when the
136
+ * credential is one the gateway no longer honours — most often a stale sign-in
137
+ * left in the auth directory. The status alone (401) is not actionable and the
138
+ * HTML body leaks nothing useful, so this turns it into a sentence the user can
139
+ * act on.
140
+ */
141
+ function isGatewayHtmlRejection(status, text) {
142
+ if (status !== 401 && status !== 403) return false;
143
+ const head = text.slice(0, 512).toLowerCase();
144
+ return head.includes("<html") || head.includes("openresty") || head.includes("apisix");
145
+ }
105
146
  async function readEnvelope(response) {
106
147
  const text = await response.text();
148
+ if (isGatewayHtmlRejection(response.status, text)) throw new Error("the WorkBuddy gateway rejected this credential (http 401). This usually means the account is using a stale sign-in the upstream no longer accepts: sign in again in the WorkBuddy desktop app, then pick the account on the plugin card. Run `dsh-workbuddy-xdpool doctor` to list every credential found.");
107
149
  let parsed;
108
150
  try {
109
151
  parsed = JSON.parse(text);
@@ -162,12 +204,57 @@ function parseCreditMultiplier(value) {
162
204
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
163
205
  }
164
206
  /** Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`. */
207
+ /**
208
+ * The effort ladder the upstream's plural-form payloads declare across both
209
+ * gateways (the live union of every `supportedEfforts` list seen; `minimal` has
210
+ * never appeared). Both gateways also accept every level of it on
211
+ * singular-form models — medium/xhigh fold into high, low/max answer with their
212
+ * own budgets — so a singular `effort` value is a DEFAULT, never the model's
213
+ * only level.
214
+ */
215
+ const SINGULAR_EFFORT_LADDER = [
216
+ "low",
217
+ "medium",
218
+ "high",
219
+ "xhigh",
220
+ "max"
221
+ ];
222
+ /**
223
+ * True when `reasoning` arrives in the singular spelling: an `effort` string,
224
+ * with none of the plural-form fields alongside it. Seen on CN
225
+ * `deepseek-v4.1-flash` / `kimi-k3-1` / `glm-5.2` and global
226
+ * `deepseek-v4.1-flash` / `kimi-k3` / `gemini-3.5-flash`.
227
+ */
228
+ function isSingularEffortForm(raw) {
229
+ return typeof raw["effort"] === "string" && !Array.isArray(raw["supportedEfforts"]) && typeof raw["defaultEffort"] !== "string" && typeof raw["canDisableThinking"] !== "boolean";
230
+ }
231
+ /**
232
+ * Fold a singular-form `effort` into the plural shape the rest of the plugin
233
+ * already understands. Probes on both gateways show these models answer with
234
+ * distinct `reasoning_content` across the whole ladder — and do not think at
235
+ * all when no `reasoning_effort` is sent — so the fold widens
236
+ * `supportedEfforts` and carries the declared value into `defaultEffort`. An
237
+ * unrecognized `effort` passes through as the lone level.
238
+ */
239
+ function singularEffortLadder(raw) {
240
+ const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
241
+ if (effort === void 0) return void 0;
242
+ return SINGULAR_EFFORT_LADDER.includes(effort) ? [...SINGULAR_EFFORT_LADDER] : [effort];
243
+ }
244
+ /**
245
+ * Parse the upstream's `reasoning` object; unknown shapes degrade to
246
+ * `undefined`. Both spellings normalize here: the plural form passes through as
247
+ * declared, and the singular `effort` form folds via
248
+ * {@link singularEffortLadder}.
249
+ */
165
250
  function parseReasoning(value) {
166
251
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
167
252
  const raw = value;
168
- const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((effort) => typeof effort === "string") : void 0;
169
- const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : void 0;
170
- const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : void 0;
253
+ const singularForm = isSingularEffortForm(raw);
254
+ const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
255
+ const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((entry) => typeof entry === "string") : singularEffortLadder(raw);
256
+ const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : effort;
257
+ const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : singularForm ? true : void 0;
171
258
  if (supportedEfforts === void 0 && defaultEffort === void 0 && canDisableThinking === void 0) return;
172
259
  return {
173
260
  ...supportedEfforts === void 0 || supportedEfforts.length === 0 ? {} : { supportedEfforts },
@@ -541,11 +628,13 @@ function parseWorkBuddyAuth(text, sourcePath) {
541
628
  if (accessToken === "") return void 0;
542
629
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
543
630
  if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
631
+ const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
544
632
  return {
545
633
  accessToken,
546
634
  refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
547
635
  expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
548
636
  ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
637
+ ...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
549
638
  ...optionalString(identity["nickname"]) === void 0 ? {} : { nickname: optionalString(identity["nickname"]) },
550
639
  ...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
551
640
  ...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
@@ -558,6 +647,42 @@ function parseWorkBuddyAuth(text, sourcePath) {
558
647
  * Stable account id. `uin` is the billing identity the upstream keys on and
559
648
  * survives re-login; `uid` is the fallback.
560
649
  */
650
+ /**
651
+ * True when `path` is the desktop app's live sign-in (as opposed to a backup
652
+ * snapshot it left behind). The live file always wins: it is the session the
653
+ * app itself is using.
654
+ */
655
+ function isLiveAuthFile(path) {
656
+ return basename(path) === WORKBUDDY_LIVE_FILENAME;
657
+ }
658
+ /**
659
+ * Which of two credentials for the same account the pool should keep.
660
+ *
661
+ * Ordering, highest first:
662
+ *
663
+ * 1. the live file the desktop app is signed in with;
664
+ * 2. the credential the upstream issued most recently (`lastRefreshAtMs`);
665
+ * 3. the longer stored expiry, as a fallback for documents that carry no issue
666
+ * time (the plugin's own refreshed copy, older builds).
667
+ *
668
+ * The stored expiry alone is NOT a freshness signal: the upstream does not
669
+ * rewrite it when it revokes a token, so a long-dead backup can claim to expire
670
+ * later than the token that actually works. Selecting on it made every upstream
671
+ * call return 401 while a perfectly good credential sat in the same directory.
672
+ */
673
+ function compareFreshness(a, b) {
674
+ const aLive = isLiveAuthFile(a.sourcePath) ? 1 : 0;
675
+ const bLive = isLiveAuthFile(b.sourcePath) ? 1 : 0;
676
+ if (aLive !== bLive) return bLive - aLive;
677
+ const aIssued = a.lastRefreshAtMs ?? 0;
678
+ const bIssued = b.lastRefreshAtMs ?? 0;
679
+ if (aIssued !== bIssued) return bIssued - aIssued;
680
+ return b.expiresAtMs - a.expiresAtMs;
681
+ }
682
+ /** True when `candidate` should replace `incumbent` for the same account. */
683
+ function isFresher(candidate, incumbent) {
684
+ return compareFreshness(candidate, incumbent) < 0;
685
+ }
561
686
  function workbuddyAccountId(credential) {
562
687
  const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
563
688
  return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 16);
@@ -609,6 +734,8 @@ var WorkBuddyAccountPool = class {
609
734
  client;
610
735
  refreshMarginMs;
611
736
  accounts = [];
737
+ distribution;
738
+ /** Cursor for round-robin mode; unused under priority distribution. */
612
739
  cursor = 0;
613
740
  lastScanAtMs = 0;
614
741
  preferredId;
@@ -619,6 +746,7 @@ var WorkBuddyAccountPool = class {
619
746
  this.cooldownMs = options.cooldownMs ?? 6e4;
620
747
  this.client = options.client;
621
748
  this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
749
+ this.distribution = options.distribution ?? "priority";
622
750
  }
623
751
  /**
624
752
  * Re-apply configuration that only affects discovery and cooldown policy,
@@ -628,6 +756,7 @@ var WorkBuddyAccountPool = class {
628
756
  applyConfig(options) {
629
757
  if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
630
758
  if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
759
+ if (options.distribution !== void 0) this.distribution = options.distribution;
631
760
  }
632
761
  /** Rescan the auth directories and merge newly discovered accounts. */
633
762
  async scan() {
@@ -652,13 +781,15 @@ var WorkBuddyAccountPool = class {
652
781
  });
653
782
  continue;
654
783
  }
655
- if ((credential.expiresAtMs ?? 0) > (existing.credential.expiresAtMs ?? 0)) byId.set(id, {
784
+ if (isFresher(credential, existing.credential)) byId.set(id, {
656
785
  ...existing,
657
786
  credential,
658
787
  label: accountLabel(credential)
659
788
  });
660
789
  }
661
- this.accounts = [...byId.values()];
790
+ const ordered = [...byId.values()];
791
+ ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
792
+ this.accounts = ordered;
662
793
  this.lastScanAtMs = Date.now();
663
794
  return this.accounts;
664
795
  }
@@ -685,12 +816,24 @@ var WorkBuddyAccountPool = class {
685
816
  });
686
817
  }
687
818
  /**
688
- * Pick the next usable account for an optional model. Scans on first use,
689
- * and rescans when every known account is cooling down — a fresh desktop
690
- * login is the usual way out of an exhausted pool. A preferred
691
- * (user-selected) account that is healthy is tried first; otherwise the
692
- * cursor round-robins so consecutive requests spread across accounts and a
693
- * still-cooling preferred account is skipped.
819
+ * Pick the account to serve a request.
820
+ *
821
+ * Two distributions, chosen by the `distribution` setting:
822
+ *
823
+ * - **priority** (default, and what the card ships with): one account serves
824
+ * every request until it is rate-limited, then the next in order takes over.
825
+ * Credits drain one account at a time, and a cooling account returns to the
826
+ * head of the queue the moment its window resets — it was never consumed, so
827
+ * it resumes straight away.
828
+ * - **round-robin**: consecutive requests rotate through the pool so spend
829
+ * spreads evenly across every account.
830
+ *
831
+ * In both modes an explicit user selection (`prefer`) heads the list, a
832
+ * cooling account is skipped for that model only, and an unrecognised setting
833
+ * falls back to priority.
834
+ *
835
+ * Scans on first use, and rescans when every known account is cooling down: a
836
+ * fresh desktop login is the usual way out of an exhausted pool.
694
837
  */
695
838
  async acquire(modelId, region) {
696
839
  if (this.accounts.length === 0) await this.scan();
@@ -700,21 +843,25 @@ var WorkBuddyAccountPool = class {
700
843
  pool = this.available(Date.now(), modelId, region);
701
844
  }
702
845
  if (pool.length === 0) return void 0;
703
- let start = this.cursor % pool.length;
704
846
  if (this.preferredId !== void 0) {
705
847
  const preferredIndex = pool.findIndex((account) => account.id === this.preferredId);
706
- if (preferredIndex !== -1) start = preferredIndex;
707
- }
708
- for (let step = 0; step < pool.length; step += 1) {
709
- const account = pool[(start + step) % pool.length];
710
- if (account === void 0) continue;
711
- await this.ensureFresh(account);
712
- this.cursor = (start + step + 1) % pool.length;
713
- return account;
848
+ if (preferredIndex > 0) {
849
+ const [preferred] = pool.splice(preferredIndex, 1);
850
+ if (preferred !== void 0) pool = [preferred, ...pool];
851
+ }
714
852
  }
715
- return pool[0];
853
+ const index = this.distribution === "round-robin" ? this.cursor % pool.length : 0;
854
+ const account = pool[index];
855
+ if (account === void 0) return void 0;
856
+ if (this.distribution === "round-robin") this.cursor = (index + 1) % pool.length;
857
+ await this.ensureFresh(account);
858
+ return account;
716
859
  }
717
860
  /** Pin the account the plugin card should prefer; tokens stay out of settings. */
861
+ /** How the pool currently spreads requests. Shown on the card. */
862
+ currentDistribution() {
863
+ return this.distribution;
864
+ }
718
865
  prefer(accountId) {
719
866
  this.preferredId = accountId;
720
867
  }
@@ -1741,6 +1888,7 @@ async function poolWebStatus(deps, region = "cn") {
1741
1888
  }, selection)),
1742
1889
  selection,
1743
1890
  region,
1891
+ distribution: deps.pool.currentDistribution(),
1744
1892
  regions,
1745
1893
  shim
1746
1894
  };
@@ -1884,6 +2032,7 @@ const DEFAULT_CONTEXT_BUDGET = 2e5;
1884
2032
  const Config = z.object({
1885
2033
  authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
1886
2034
  cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
2035
+ distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
1887
2036
  enabledModelIds: z.array(z.string()).default([]).description("Model ids enabled in the picker (empty = all)"),
1888
2037
  imageModelIds: z.array(z.string()).default([]).description("Model ids accepting image input (empty = follow upstream)"),
1889
2038
  contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Per-model context-window override, keyed by model id")
@@ -1940,10 +2089,11 @@ function apply(ctx, config = {}) {
1940
2089
  }
1941
2090
  };
1942
2091
  const applyConfigFromSource = () => {
1943
- const { authFile, cooldownMs, enabledModelIds, imageModelIds, contextBudgets } = current();
2092
+ const { authFile, cooldownMs, distribution, enabledModelIds, imageModelIds, contextBudgets } = current();
1944
2093
  core.pool.applyConfig({
1945
2094
  ...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
1946
- ...cooldownMs === void 0 ? {} : { cooldownMs }
2095
+ ...cooldownMs === void 0 ? {} : { cooldownMs },
2096
+ distribution: distribution ?? "priority"
1947
2097
  });
1948
2098
  core.catalog.applySelection({
1949
2099
  ...enabledModelIds === void 0 ? {} : { enabledModelIds },
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-workbuddy-xdpool",
3
3
  "displayName": "DSH WorkBuddy XD Pool",
4
4
  "description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
5
- "version": "0.3.0",
5
+ "version": "0.4.0",
6
6
  "license": "MIT",
7
7
  "author": "aosi526",
8
8
  "repository": {