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 +42 -0
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/assets/model-picker.png +0 -0
- package/assets/settings-card.png +0 -0
- package/lib/bin.js +175 -27
- package/lib/client.js +128 -13
- package/lib/index.d.ts +64 -13
- package/lib/index.js +179 -29
- package/package.json +1 -1
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
|
-

|
|
19
19
|
|
|
20
|
-
**Model picker (rate multiplier baked into model.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
|
-

|
|
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
|
-

|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
**模型选择器(国内版 / 国际版 两个独立供应商分组;倍率直接拼进 model.name:DSH 0.1.2 composer 只读 name)**
|
|
21
21
|
|
|
22
|
-

|
|
23
23
|
|
|
24
24
|
**国内版 / 国际版 双供应商(各自独立账号、积分与模型,可同时使用)**
|
|
25
25
|
|
package/assets/model-picker.png
CHANGED
|
Binary file
|
package/assets/settings-card.png
CHANGED
|
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
|
|
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" ?
|
|
75
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_CHAT_BASE;
|
|
49
76
|
}
|
|
50
77
|
function billingBase(credential) {
|
|
51
|
-
return regionOf(credential.domain) === "global" ?
|
|
78
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
|
|
52
79
|
}
|
|
53
80
|
function originReferer(credential) {
|
|
54
|
-
return regionOf(credential.domain) === "global" ?
|
|
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
|
|
153
|
-
const
|
|
154
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
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
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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) =>
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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?:
|
|
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
|
|
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" ?
|
|
77
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_CHAT_BASE;
|
|
51
78
|
}
|
|
52
79
|
function billingBase(credential) {
|
|
53
|
-
return regionOf(credential.domain) === "global" ?
|
|
80
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
|
|
54
81
|
}
|
|
55
82
|
function originReferer(credential) {
|
|
56
|
-
return regionOf(credential.domain) === "global" ?
|
|
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
|
|
169
|
-
const
|
|
170
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
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
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
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.
|
|
5
|
+
"version": "0.4.0",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "aosi526",
|
|
8
8
|
"repository": {
|