dsh-workbuddy-xdpool 1.8.1 → 1.8.3
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 +92 -0
- package/README.md +2 -0
- package/lib/bin.js +72 -5
- package/lib/client.js +161 -62
- package/lib/index.d.ts +49 -4
- package/lib/index.js +141 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,98 @@
|
|
|
4
4
|
|
|
5
5
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
6
6
|
|
|
7
|
+
## 1.8.3 — 快过期的积分先花掉,失效的登录别再刷屏
|
|
8
|
+
|
|
9
|
+
> 这一版有两件事。一件是新的第五种账号使用方式:让积分包最快过期的那个账号先上,别把积分放过期了。另一件是修一个看着很吓人的毛病——账号失效之后,卡片上会顶着一大段英文,把整个面板撑爆,而且每刷新一次就再撞一次墙。
|
|
10
|
+
|
|
11
|
+
### 新增:第五种模式「先花快到期的」
|
|
12
|
+
|
|
13
|
+
积分有两种:**月度包**每月自动刷新,永远不会过期;**一次性包**有明确截止日,到点没用完就归零。以前插件不知道这个区别,轮着用谁都是随机——结果就是某个账号的一次性积分包在角落里静静过期,而你还在用另一个账号刷。
|
|
14
|
+
|
|
15
|
+
现在多了一个选项:**优先使用积分包最快过期的账号**。
|
|
16
|
+
|
|
17
|
+
- 数据直接来自上游本来就返回的 `nearestExpiryMs` 字段,**没有为这个模式多发任何一次请求**。
|
|
18
|
+
- 月度包不参与排序——它不会过期,算它「紧急」纯属瞎紧张。
|
|
19
|
+
- 没读过积分的账号排在最后。理由很简单:「没看过」不等于「有截止日」,拿未知去压真实截止日是错的。
|
|
20
|
+
- 积分花完或过期之后,那个截止日会被**擦掉**,不是一直记着。否则池子会死死钉在一个积分已经空了的账号上,跟这个模式的目的正好相反。
|
|
21
|
+
- 读不到任何到期信息时,行为退回「优先」模式。放心开。
|
|
22
|
+
|
|
23
|
+
默认仍然是「优先」,没有偷偷改任何人的设置。
|
|
24
|
+
|
|
25
|
+
### 模式选择换了排版:五个并排,说明放下面
|
|
26
|
+
|
|
27
|
+
第五个模式一加进来,原来那个「两列网格 + 最后一个跨整行」的排版就散了:五个格子掉成两行,最后一个还孤零零撑满一行,看着像掉出了网格。
|
|
28
|
+
|
|
29
|
+
现在改成**一行可换行的芯片**:
|
|
30
|
+
|
|
31
|
+
- 面板放得下的时候,五个模式**一行排开**,宽度自动均分。设置面板常见的 700px 宽度也还读作一行选择,而不是在 760px 处突然塌成两列。
|
|
32
|
+
- 真的放不下了才换行,换行后最后那个会自动撑满整行——这是 flex 的天然行为,不需要任何「最后一项跨行」的补丁规则,也就不会出现「明明第一行还装得下、却把第五个挤到第二行」的怪事。
|
|
33
|
+
- 每块芯片里**只剩模式名**。五句话塞进五个小方块会让选择器变成五段散文,所以说明改成**行下方一行字,只讲当前选中的那个模式**。
|
|
34
|
+
|
|
35
|
+
### 修:网关 401 不再打印一整段英文
|
|
36
|
+
|
|
37
|
+
有人的卡片上,三个失效账号每个都顶着这么一段:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
the WorkBuddy gateway rejected this credential (http 401). This usually means
|
|
41
|
+
the account is using a stale sign-in the upstream no longer accepts: sign in
|
|
42
|
+
again in the WorkBuddy desktop app, then pick the account on the plugin card.
|
|
43
|
+
Run `dsh-workbuddy-xdpool doctor` to list every credential found.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
话说得没错,但**它出现在面板正文里**,三个账号就是三段,把卡片撑到没法看。
|
|
47
|
+
|
|
48
|
+
现在:
|
|
49
|
+
|
|
50
|
+
- 这段文字折叠成一个**红色短标记**,完整原文进鼠标悬停提示。要看细节还在,不想看的时候不占地方。
|
|
51
|
+
- 更关键的是**它现在真的会被当成「登录已失效」**。以前只有「刷新 token 被拒」才会判死,而如果凭据文件里写的过期时间还在未来,刷新那一步根本不会跑——于是每次卡片刷新都老老实实再撞一次 401,撞完再显示一次红字。现在只要**网关返回那段 HTML 拒绝页**,就按失效处理:账号进冷却、退出轮换。
|
|
52
|
+
- 失效的账号**不再多吃一次签到探测**。以前每个死账号刷新时会被打两次(积分一次、签到一次),现在只打一次——签到那步直接跳过。
|
|
53
|
+
- 会**自我纠正**:只要某次积分读成功了,就说明登录其实是好的,死标立刻清掉。不用手动点任何按钮。
|
|
54
|
+
|
|
55
|
+
### 关于那三个账号
|
|
56
|
+
|
|
57
|
+
顺带说一句:本机实测那三个报 401 的账号**是真的失效了**,不是插件误判——强制刷新时上游明确回 `session_dead (http 401): 12153:refresh token is invalid`。要恢复它们,去 WorkBuddy 桌面端重新登录,再回卡片点「重新检测账号」。
|
|
58
|
+
|
|
59
|
+
## 1.8.2 — 免费的那个被限流了,旁边那个收费的同款还能用
|
|
60
|
+
|
|
61
|
+
> 免费模型被限流的时候,插件以前只会说一句「所有账号都在冷却」。这句话没错,但它让用户以为自己只剩一条路:等。其实同一个模型往往还有一条**收费的**产品行,而且在限流器眼里它们是两个不同的模型——免费行被关小黑屋的时候,收费行通常毫发无伤。这个版本就是把这个「另一条路」写出来。
|
|
62
|
+
|
|
63
|
+
### 同一个模型,凭什么有两行
|
|
64
|
+
|
|
65
|
+
上游把一条产品线按**多行**卖,每行有自己的 id、自己的价格、自己的限流额度,但**显示名字是一样的**。实测两个网关:
|
|
66
|
+
|
|
67
|
+
| 网关 | 免费行 | 收费行 |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| 国际版 | `deepseek-v4.1-flash`(x0.00) | `deepseek-v4.1-flash-sg`(x0.03) |
|
|
70
|
+
| 国内版 | `hy3`(x0.00) | `hy3-x`(x0.05) |
|
|
71
|
+
|
|
72
|
+
注意 `-sg` 和 `-x` 跟免费行**没有任何前缀关系**,能把它们认成一家的只有那个相同的显示名。所以匹配是按名字来的,不是按 id 猜的。
|
|
73
|
+
|
|
74
|
+
### 现在会怎么提示
|
|
75
|
+
|
|
76
|
+
- **模型列表**:免费行后面挂一个小灰标「付费同款 deepseek-v4.1-flash-sg(0.03x)」,鼠标悬停解释「免费档先被限流;同一个模型的付费行不受这个限制影响」。灰的、低调的——这一行现在仍然是免费的那个,标签只是告诉你退路在哪,不是催你花钱。
|
|
77
|
+
- **429 报错**:以前是「every account is rate-limited for model deepseek-v4.1-flash (4/4 cooling, tried 0)」,现在后面会接一句「deepseek-v4.1-flash-sg is the same model on a paid row (x0.03 credits) and is rate-limited separately; switch to it to keep going」。
|
|
78
|
+
- **账号卡片**:某个账号上「`hy4-preview` cooling to 10:14」这种小标后面,也会跟上「→ 收费行的 id(倍率)还能用」。
|
|
79
|
+
|
|
80
|
+
### 刻意没做的事
|
|
81
|
+
|
|
82
|
+
- **收费行不标标签。** 只有倍率等于 0 的行才找孪生兄弟。否则「GLM-5.3」这种本身收费、又恰好有个更便宜的闪速版的模型,也会被挂上一个标签,最后满屏都是提示,等于没提示。
|
|
83
|
+
- **没有收费同款就不说话。** 独苗模型(整条线只有它自己)不会有任何多余文字,也不会留下一个孤零零的分隔符。
|
|
84
|
+
- **不把另一个免费行当退路。** 两个免费行是一起被限流的,切过去纯属换个人排队,说了等于骗人。
|
|
85
|
+
- **倍率未知的行不算数。** 上游没给价格的兄弟行不能被承诺成「那个收费的」——用户切过去以为知道价钱,结果未必。
|
|
86
|
+
|
|
87
|
+
### 顺带说清楚:国际版没有「每天领 30 积分」
|
|
88
|
+
|
|
89
|
+
上一版已经查过一遍,这次把话说完整。国际版(`www.workbuddy.ai`)**没有**签到活动,也**没有** `daily-checkin` 这条路由:
|
|
90
|
+
|
|
91
|
+
- `POST /v2/billing/meter/checkin-activity-status` 返回 200,但 `active: false`(字段在,活动没开);
|
|
92
|
+
- `GET /v2/billing/meter/get-user-resource` 直接 404(国内版才有这个接口);
|
|
93
|
+
- `/v2/billing/meter/checkin`、`/daily-checkin-activity`、`/checkin-activity` 全是 404。
|
|
94
|
+
|
|
95
|
+
国际版真正有的是另一套东西:**成长任务板**(5 条:完成一次对话、安装技能、绑定微信、召唤专家、使用模板)和 **streak 连续打卡**(7 天 / 14 天 / 28 天档位,分别给 0 / 50 / 150 积分)。但它的字段形状跟国内版不一样(没有 credit / energy / target / current),插件目前解析不了,所以**没有**为国际版开自动化——没有可领的东西,开了也只是每天准时去撞 404。
|
|
96
|
+
|
|
97
|
+
国内版的签到是正常的,多账号逐个领取的链路本来就有,不受本次改动影响。
|
|
98
|
+
|
|
7
99
|
## 1.8.1 — 一个过期的登录状态,不再拖着整个池子陪跑
|
|
8
100
|
|
|
9
101
|
> 有人的卡片上,好几个账号下面顶着一大段红色英文,大意是「上游不认这个登录了,去桌面端重新登录」。话说得没错,可问题是:它只说了结论,没解决过程——那几个账号明明已经废了,每次请求还是要挨个撞一遍,撞得头破血流,旁边几个好端端的账号一次都轮不上。
|
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
|
|
51
51
|
- **模型目录带标注**:列出池里能用的模型,附带积分倍率(如 `GLM-5.2 · x0.79`)、免费 / 限时免费 / 夜间折扣标签、能否收图和上下文窗口大小,全部跟随上游实时更新。
|
|
52
52
|
|
|
53
|
+
- **被限流时告诉你退路**:免费档被 429 冷却时,卡片和报错都会点名**同名的付费行**(如国际版 `deepseek-v4.1-flash` 对 `deepseek-v4.1-flash-sg`、国内版 `hy3` 对 `hy3-x`)。上游按「行」分别限流,所以免费行被关小黑屋时,收费行通常还是通的。
|
|
54
|
+
|
|
53
55
|
- **国内版 / 国际版自动适配**:按账号凭据里的登录域名自动选上游——国际版登录(`workbuddy.ai`)走 `www.workbuddy.ai`,国内版(默认)走 `copilot.tencent.com`。两种账号可以混在同一个池里。
|
|
54
56
|
|
|
55
57
|
- **想手动做点啥也行**:卡片和命令行都提供「重新检测账号」「清除所有冷却」「每日签到」三个操作。
|
package/lib/bin.js
CHANGED
|
@@ -266,9 +266,18 @@ function isGatewayHtmlRejection(status, text) {
|
|
|
266
266
|
const head = text.slice(0, 512).toLowerCase();
|
|
267
267
|
return head.includes("<html") || head.includes("openresty") || head.includes("apisix");
|
|
268
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* The sentence thrown for a gateway HTML rejection.
|
|
271
|
+
*
|
|
272
|
+
* Exported (as a prefix test) because callers outside this module have to tell
|
|
273
|
+
* this specific failure apart from an ordinary network error: it is proof the
|
|
274
|
+
* *credential* is no longer honoured, so the pool may mark the account dead
|
|
275
|
+
* instead of re-probing it on every refresh.
|
|
276
|
+
*/
|
|
277
|
+
const GATEWAY_REJECTION_MESSAGE = "the WorkBuddy gateway rejected this credential (http 401).";
|
|
269
278
|
async function readEnvelope(response) {
|
|
270
279
|
const text = await response.text();
|
|
271
|
-
if (isGatewayHtmlRejection(response.status, text)) throw new Error(
|
|
280
|
+
if (isGatewayHtmlRejection(response.status, text)) throw new Error(`${GATEWAY_REJECTION_MESSAGE} 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.`);
|
|
272
281
|
let parsed;
|
|
273
282
|
try {
|
|
274
283
|
parsed = JSON.parse(text);
|
|
@@ -2748,6 +2757,19 @@ var WorkBuddyAccountPool = class {
|
|
|
2748
2757
|
*/
|
|
2749
2758
|
creditBalances = /* @__PURE__ */ new Map();
|
|
2750
2759
|
/**
|
|
2760
|
+
* Nearest expiry among each account's one-off credit packs, epoch ms.
|
|
2761
|
+
*
|
|
2762
|
+
* Credit packs are use-it-or-lose-it, so "which account should serve next"
|
|
2763
|
+
* has a second honest answer besides "which one is idle": the one whose
|
|
2764
|
+
* credits die soonest. Only one-off packs count — monthly packs refresh on
|
|
2765
|
+
* their own cycle and are therefore never urgent — which is why this is
|
|
2766
|
+
* written from the same `fetchCredits` reading that fills `creditBalances`.
|
|
2767
|
+
*
|
|
2768
|
+
* An absent entry means "never probed, or nothing is about to expire". Both
|
|
2769
|
+
* are the same thing to `expiry` mode: not urgent, so it sorts last.
|
|
2770
|
+
*/
|
|
2771
|
+
creditExpiry = /* @__PURE__ */ new Map();
|
|
2772
|
+
/**
|
|
2751
2773
|
* Last time each account served a request, epoch ms. Drives the idle term
|
|
2752
2774
|
* of the priority-mode weighting below: an account that just served loses to
|
|
2753
2775
|
* one that has been idle, so a small pool stops hammering a single account.
|
|
@@ -3050,6 +3072,33 @@ var WorkBuddyAccountPool = class {
|
|
|
3050
3072
|
return pool[pool.length - 1];
|
|
3051
3073
|
}
|
|
3052
3074
|
/**
|
|
3075
|
+
* `expiry` mode: the account whose one-off credit packs die soonest.
|
|
3076
|
+
*
|
|
3077
|
+
* Credit packs are use-it-or-lose-it, so spending the dying ones first is
|
|
3078
|
+
* strictly better than spreading the spend: an account that expires with
|
|
3079
|
+
* credits left is money burnt, while an account whose packs have no deadline
|
|
3080
|
+
* loses nothing by waiting. Two accounts expiring at the same instant fall
|
|
3081
|
+
* back to the pool order, and an account with no known expiry sorts last —
|
|
3082
|
+
* "we have not looked" must never outrank a real deadline.
|
|
3083
|
+
*
|
|
3084
|
+
* A pool where nobody has a known expiry therefore behaves exactly like
|
|
3085
|
+
* priority, which is the honest degradation: without a reading there is
|
|
3086
|
+
* nothing to sort by.
|
|
3087
|
+
*/
|
|
3088
|
+
pickByExpiry(pool) {
|
|
3089
|
+
let best;
|
|
3090
|
+
let bestAt = Number.POSITIVE_INFINITY;
|
|
3091
|
+
for (const account of pool) {
|
|
3092
|
+
const at = this.creditExpiry.get(account.id);
|
|
3093
|
+
if (at === void 0) continue;
|
|
3094
|
+
if (at < bestAt) {
|
|
3095
|
+
best = account;
|
|
3096
|
+
bestAt = at;
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
return best ?? pool[0];
|
|
3100
|
+
}
|
|
3101
|
+
/**
|
|
3053
3102
|
* Pick the account to serve a request.
|
|
3054
3103
|
*
|
|
3055
3104
|
* Two distributions, chosen by the `distribution` setting:
|
|
@@ -3064,6 +3113,8 @@ var WorkBuddyAccountPool = class {
|
|
|
3064
3113
|
* - **sticky**: one account per conversation, and a new conversation moves to
|
|
3065
3114
|
* the next account in order. Keeps the upstream prompt cache warm within a
|
|
3066
3115
|
* conversation while still spreading spend across conversations.
|
|
3116
|
+
* - **expiry**: the account whose one-off credit packs expire soonest serves
|
|
3117
|
+
* first, so use-it-or-lose-it credits are spent before they vanish.
|
|
3067
3118
|
*
|
|
3068
3119
|
* In every mode an explicit user selection (`prefer`) heads the list, a
|
|
3069
3120
|
* cooling account is skipped for that model only, and an unrecognised setting
|
|
@@ -3092,7 +3143,7 @@ var WorkBuddyAccountPool = class {
|
|
|
3092
3143
|
const bound = this.affinityAccount(pool, conversationKey);
|
|
3093
3144
|
if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
|
|
3094
3145
|
}
|
|
3095
|
-
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3146
|
+
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : this.distribution === "expiry" ? this.pickByExpiry(pool) : pool[0];
|
|
3096
3147
|
if (account === void 0) return void 0;
|
|
3097
3148
|
if (!await this.ensureFresh(account)) continue;
|
|
3098
3149
|
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
@@ -3134,11 +3185,26 @@ var WorkBuddyAccountPool = class {
|
|
|
3134
3185
|
* check has something to compare against. A reading for an unknown account is
|
|
3135
3186
|
* dropped: `scan()` rebuilds the account list and a stale id would otherwise
|
|
3136
3187
|
* accumulate forever.
|
|
3188
|
+
*
|
|
3189
|
+
* `nearestExpiryMs` is the same reading's nearest one-off pack deadline, or
|
|
3190
|
+
* undefined when nothing is about to expire. It is written through, not
|
|
3191
|
+
* merged: a pack that has been spent or has died disappears from the next
|
|
3192
|
+
* reading, and keeping the stale deadline would pin the pool to an account
|
|
3193
|
+
* whose credits are already gone — the opposite of what `expiry` mode wants.
|
|
3137
3194
|
*/
|
|
3138
|
-
noteCredits(accountId, balance) {
|
|
3195
|
+
noteCredits(accountId, balance, nearestExpiryMs) {
|
|
3139
3196
|
if (!Number.isFinite(balance)) return;
|
|
3140
3197
|
if (!this.accounts.some((account) => account.id === accountId)) return;
|
|
3141
3198
|
this.creditBalances.set(accountId, balance);
|
|
3199
|
+
if (nearestExpiryMs === void 0 || !Number.isFinite(nearestExpiryMs)) {
|
|
3200
|
+
this.creditExpiry.delete(accountId);
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
this.creditExpiry.set(accountId, nearestExpiryMs);
|
|
3204
|
+
}
|
|
3205
|
+
/** Nearest one-off pack expiry for one account, or undefined when none known. */
|
|
3206
|
+
creditExpiryOf(accountId) {
|
|
3207
|
+
return this.creditExpiry.get(accountId);
|
|
3142
3208
|
}
|
|
3143
3209
|
/** Last known balance for one account, or undefined when never read. */
|
|
3144
3210
|
creditsOf(accountId) {
|
|
@@ -5243,8 +5309,9 @@ z.object({
|
|
|
5243
5309
|
"priority",
|
|
5244
5310
|
"round-robin",
|
|
5245
5311
|
"balanced",
|
|
5246
|
-
"sticky"
|
|
5247
|
-
|
|
5312
|
+
"sticky",
|
|
5313
|
+
"expiry"
|
|
5314
|
+
]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), balanced (idle-weighted random), sticky (one account per conversation, new conversations rotate), or expiry (spend the account whose credit packs expire soonest)")),
|
|
5248
5315
|
disabledAccountIds: asVolatile(z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)")),
|
|
5249
5316
|
creditReserves: asVolatile(z.dict(z.number().step(1).min(0)).default({}).description("Per-account credit floor: stop using an account once its balance reaches this value")),
|
|
5250
5317
|
enabledModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet")),
|
package/lib/client.js
CHANGED
|
@@ -182,8 +182,17 @@ window.__ModuleLoader__.load({
|
|
|
182
182
|
/* "now: X" — repeats the answer in the header so the user does not have to
|
|
183
183
|
scan four cards for the raised one. */
|
|
184
184
|
.dsm-workbuddy-xdpool-dist-now{flex:none;padding:2px 10px;border-radius:999px;border:1px solid color-mix(in oklab, var(--dsw-alias-label-primary,#e6e6e6) 26%, transparent);background:color-mix(in oklab, var(--dsw-alias-label-primary,#e6e6e6) 10%, transparent);color:var(--dsw-alias-label-primary,#e6e6e6);font-size:12px;font-weight:600;line-height:18px;white-space:nowrap}
|
|
185
|
-
.
|
|
186
|
-
|
|
185
|
+
/* One row of equal chips, and let the row itself decide when it has to break.
|
|
186
|
+
flex-wrap plus a 112px basis keeps the five modes side by side for as long as
|
|
187
|
+
the card can hold them (a chip is ~112px: the longest label plus padding), so
|
|
188
|
+
a 700px panel still reads as one row of choices instead of a hard 760px cliff
|
|
189
|
+
dropping it to two columns. A flex line stretches its items to fill the row,
|
|
190
|
+
so when the chips finally do wrap, the odd last one spans the width on its own
|
|
191
|
+
— no last-child span rule, and therefore no way to accidentally strand the
|
|
192
|
+
last chip on a second row while the first row was still wide enough for all
|
|
193
|
+
five. */
|
|
194
|
+
.dsm-workbuddy-xdpool-dist-options{display:flex;flex-wrap:wrap;gap:8px}
|
|
195
|
+
.dsm-workbuddy-xdpool-dist-option{flex:1 1 112px;min-width:0;appearance:none;font:inherit;cursor:pointer;text-align:left;display:flex;flex-direction:column;justify-content:center;gap:3px;min-height:46px;border:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 80%, transparent);border-radius:12px;padding:9px 11px;background:transparent;color:var(--dsw-alias-label-tertiary,#9aa0a8);transition:color .16s,border-color .16s,background .16s,box-shadow .16s}
|
|
187
196
|
.dsm-workbuddy-xdpool-dist-option:hover:not(:disabled):not(.dsm-workbuddy-xdpool-dist-option-active){color:var(--dsw-alias-label-secondary,#c6c9d0);border-color:var(--dsw-alias-label-dimmed,#777);background:rgba(255,255,255,.03)}
|
|
188
197
|
.dsm-workbuddy-xdpool-dist-option:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
|
|
189
198
|
/* Selected: raised onto layer-3 (the same surface the region tabs use when
|
|
@@ -192,11 +201,15 @@ window.__ModuleLoader__.load({
|
|
|
192
201
|
.dsm-workbuddy-xdpool-dist-option-active{background:var(--dsw-alias-bg-layer-3,#2a2c33);border-color:color-mix(in oklab, var(--dsw-alias-label-primary,#e6e6e6) 34%, transparent);box-shadow:inset 3px 0 0 var(--dsw-alias-label-primary,#e6e6e6);color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
193
202
|
.dsm-workbuddy-xdpool-dist-option-active .dsm-workbuddy-xdpool-dist-option-name{color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
194
203
|
.dsm-workbuddy-xdpool-dist-option:disabled{cursor:default;opacity:.6}
|
|
195
|
-
.dsm-workbuddy-xdpool-dist-option-top{display:flex;align-items:center;gap:
|
|
196
|
-
|
|
204
|
+
.dsm-workbuddy-xdpool-dist-option-top{display:flex;align-items:center;gap:6px;min-width:0}
|
|
205
|
+
/* The name is the only thing in a chip, so it may wrap rather than ellipsize:
|
|
206
|
+
a truncated mode name is unreadable, a two-line one is merely taller. */
|
|
207
|
+
.dsm-workbuddy-xdpool-dist-option-name{font-size:12.5px;line-height:17px;font-weight:600;min-width:0;overflow-wrap:anywhere}
|
|
197
208
|
/* "recommended" chip: quiet, and only on the mode this release argues for. */
|
|
198
|
-
.dsm-workbuddy-xdpool-dist-option-badge{flex:none;margin-left:auto;padding:1px
|
|
199
|
-
.
|
|
209
|
+
.dsm-workbuddy-xdpool-dist-option-badge{flex:none;margin-left:auto;padding:1px 6px;border-radius:999px;background:var(--dsw-alias-state-success-subtle,rgba(34,160,107,.14));color:var(--dsw-alias-state-success-primary,#22a06b);font-size:10px;font-weight:600;line-height:15px;white-space:nowrap}
|
|
210
|
+
/* One line of prose under the row explains the mode that is selected. Five
|
|
211
|
+
chips cannot each carry a sentence without becoming five paragraphs. */
|
|
212
|
+
.dsm-workbuddy-xdpool-dist-hint{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px}
|
|
200
213
|
.dsm-workbuddy-xdpool-usage-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
201
214
|
|
|
202
215
|
/* Account list (each account = a labeled subpanel, same as dingminhua). */
|
|
@@ -256,6 +269,10 @@ window.__ModuleLoader__.load({
|
|
|
256
269
|
.dsm-workbuddy-xdpool-account-meta-dead{color:var(--dsw-alias-state-error-primary,#ef4444)}
|
|
257
270
|
.dsm-workbuddy-xdpool-account-modelcool{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}
|
|
258
271
|
.dsm-workbuddy-xdpool-account-modelcool-chip{display:inline-flex;align-items:center;gap:4px;padding:1px 8px;border-radius:999px;font-size:11px;line-height:18px;background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.12));color:var(--dsw-alias-state-warning-primary,#d97706)}
|
|
272
|
+
/* "→ deepseek-v4.1-flash-sg (0.03x) still works" inside a cooling chip. Keeps
|
|
273
|
+
the warning tint of its parent but drops the weight: it is the way out, not
|
|
274
|
+
a second problem. */
|
|
275
|
+
.dsm-workbuddy-xdpool-account-modelcool-twin{color:var(--dsw-alias-label-secondary,#c6c9d0);cursor:help}
|
|
259
276
|
.dsm-workbuddy-xdpool-account-error{margin:0;color:var(--dsw-alias-state-error-primary,#ef4444);font-size:13px;line-height:20px}
|
|
260
277
|
|
|
261
278
|
/* Two-column stats: packages on the left, total + check-in on the right. */
|
|
@@ -307,6 +324,10 @@ window.__ModuleLoader__.load({
|
|
|
307
324
|
/* "活动至 10-31": the campaign's end date. Muted — it is context for planning,
|
|
308
325
|
not a claim about the current price. */
|
|
309
326
|
.dsm-workbuddy-xdpool-model-meta-promo{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px;opacity:.85}
|
|
327
|
+
/* "付费同款 deepseek-v4.1-flash-sg (0.03x)": the way out when the free row is
|
|
328
|
+
rate-limited. Muted like the other meta chips — it is an alternative, not a
|
|
329
|
+
warning, and the row it sits on is still the free one. */
|
|
330
|
+
.dsm-workbuddy-xdpool-model-meta-twin{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px;opacity:.85;cursor:help}
|
|
310
331
|
/* Unreadable credential files: a warning tint, since it explains a smaller pool
|
|
311
332
|
and "encrypted" is actionable (start the app once). */
|
|
312
333
|
.dsm-workbuddy-xdpool-skipped{margin:6px 0 0;padding:7px 10px;border-radius:8px;background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.12));display:flex;flex-direction:column;gap:3px}
|
|
@@ -362,9 +383,8 @@ window.__ModuleLoader__.load({
|
|
|
362
383
|
.dsm-workbuddy-xdpool-checkin{align-items:stretch}
|
|
363
384
|
.dsm-workbuddy-xdpool-checkin-meta{align-items:flex-start}
|
|
364
385
|
.dsm-workbuddy-xdpool-checkin-bonus{text-align:left}
|
|
365
|
-
/*
|
|
366
|
-
|
|
367
|
-
.dsm-workbuddy-xdpool-dist-options{grid-template-columns:minmax(0,1fr)}
|
|
386
|
+
/* The chips wrap on their own (auto-fit), so the only thing left to do here
|
|
387
|
+
is stop reserving desktop height for rows that are now one chip tall. */
|
|
368
388
|
.dsm-workbuddy-xdpool-dist-option{min-height:0}
|
|
369
389
|
}
|
|
370
390
|
|
|
@@ -510,6 +530,43 @@ window.__ModuleLoader__.load({
|
|
|
510
530
|
return status?.kind === "free" || status?.kind === "night";
|
|
511
531
|
}
|
|
512
532
|
//#endregion
|
|
533
|
+
//#region src/siblings.ts
|
|
534
|
+
/**
|
|
535
|
+
* The cheapest PAID model sharing `model`'s display name, or undefined.
|
|
536
|
+
*
|
|
537
|
+
* Cheapest rather than first, because the point of the hint is "there is a
|
|
538
|
+
* cheap way out": offering the most expensive twin would make switching look
|
|
539
|
+
* like a trap. A sibling whose price is unknown is skipped — an unpriced row
|
|
540
|
+
* cannot be promised as "the paid one".
|
|
541
|
+
*/
|
|
542
|
+
function paidSiblingOf(model, models) {
|
|
543
|
+
let best;
|
|
544
|
+
for (const candidate of models) {
|
|
545
|
+
if (candidate.id === model.id) continue;
|
|
546
|
+
if (candidate.name !== model.name) continue;
|
|
547
|
+
const multiplier = candidate.multiplier;
|
|
548
|
+
if (multiplier === void 0 || multiplier <= 0) continue;
|
|
549
|
+
if (best === void 0 || multiplier < best.multiplier) best = {
|
|
550
|
+
id: candidate.id,
|
|
551
|
+
name: candidate.name,
|
|
552
|
+
multiplier
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
return best;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* The paid sibling of a model, but ONLY when the model itself is free.
|
|
559
|
+
*
|
|
560
|
+
* The guard is the whole point: a `x0.79` row with a `x0.06` cousin is not a
|
|
561
|
+
* "free model with a paid twin", it is just a more expensive row, and labelling
|
|
562
|
+
* it would be noise on most of the list. The hint answers one question — "the
|
|
563
|
+
* free one is limited, what now".
|
|
564
|
+
*/
|
|
565
|
+
function paidAlternativeFor(model, models) {
|
|
566
|
+
if (model.multiplier !== 0) return void 0;
|
|
567
|
+
return paidSiblingOf(model, models);
|
|
568
|
+
}
|
|
569
|
+
//#endregion
|
|
513
570
|
//#region src/client/PoolCard.tsx
|
|
514
571
|
/**
|
|
515
572
|
* WorkBuddy XD Pool card contributed to DSH Plugin configuration.
|
|
@@ -630,22 +687,25 @@ window.__ModuleLoader__.load({
|
|
|
630
687
|
return String(value);
|
|
631
688
|
}
|
|
632
689
|
/**
|
|
633
|
-
* The
|
|
690
|
+
* The five ways the pool can hand out accounts.
|
|
634
691
|
*
|
|
635
692
|
* Order is the reading order of the picker: the recommended mode first, then
|
|
636
|
-
* the two cache-friendly extremes, then the two spreading modes.
|
|
693
|
+
* the two cache-friendly extremes, then the two spreading modes. The row is
|
|
694
|
+
* laid out one chip per mode, so this list length is also the column count.
|
|
637
695
|
*/
|
|
638
696
|
const DIST_OPTIONS = [
|
|
639
697
|
"sticky",
|
|
640
698
|
"priority",
|
|
641
699
|
"balanced",
|
|
642
|
-
"round-robin"
|
|
700
|
+
"round-robin",
|
|
701
|
+
"expiry"
|
|
643
702
|
];
|
|
644
703
|
/** Localized name of one distribution mode. */
|
|
645
704
|
function distLabel(option, t) {
|
|
646
705
|
if (option === "sticky") return t?.("row.distSticky") ?? "Per conversation";
|
|
647
706
|
if (option === "priority") return t?.("row.distPriority") ?? "Priority";
|
|
648
707
|
if (option === "balanced") return t?.("row.distBalanced") ?? "Balanced";
|
|
708
|
+
if (option === "expiry") return t?.("row.distExpiry") ?? "Expiring first";
|
|
649
709
|
return t?.("row.distRoundRobin") ?? "Round-robin";
|
|
650
710
|
}
|
|
651
711
|
/** One-line explanation of what a mode does to caching and spend. */
|
|
@@ -653,6 +713,7 @@ window.__ModuleLoader__.load({
|
|
|
653
713
|
if (option === "sticky") return t?.("row.distStickyHint") ?? "";
|
|
654
714
|
if (option === "priority") return t?.("row.distPriorityHint") ?? "";
|
|
655
715
|
if (option === "balanced") return t?.("row.distBalancedHint") ?? "";
|
|
716
|
+
if (option === "expiry") return t?.("row.distExpiryHint") ?? "";
|
|
656
717
|
return t?.("row.distRoundRobinHint") ?? "";
|
|
657
718
|
}
|
|
658
719
|
/** Build the draft from the server's selection + catalog flags. */
|
|
@@ -1356,47 +1417,51 @@ window.__ModuleLoader__.load({
|
|
|
1356
1417
|
className: "dsm-workbuddy-xdpool-dist",
|
|
1357
1418
|
role: "radiogroup",
|
|
1358
1419
|
"aria-label": t?.("row.distTitle") ?? "Account usage",
|
|
1359
|
-
children: [
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
children:
|
|
1387
|
-
className: "dsm-workbuddy-xdpool-dist-option-
|
|
1388
|
-
children:
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
})
|
|
1397
|
-
}
|
|
1420
|
+
children: [
|
|
1421
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1422
|
+
className: "dsm-workbuddy-xdpool-dist-head",
|
|
1423
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1424
|
+
className: "dsm-workbuddy-xdpool-dist-title",
|
|
1425
|
+
children: t?.("row.distTitle") ?? "Account usage"
|
|
1426
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1427
|
+
className: "dsm-workbuddy-xdpool-dist-now",
|
|
1428
|
+
children: distLabel(status.distribution ?? "priority", t)
|
|
1429
|
+
})]
|
|
1430
|
+
}),
|
|
1431
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1432
|
+
className: "dsm-workbuddy-xdpool-dist-options",
|
|
1433
|
+
children: DIST_OPTIONS.map((option) => {
|
|
1434
|
+
const active = (status.distribution ?? "priority") === option;
|
|
1435
|
+
const label = distLabel(option, t);
|
|
1436
|
+
const hint = distHint(option, t);
|
|
1437
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1438
|
+
type: "button",
|
|
1439
|
+
role: "radio",
|
|
1440
|
+
"aria-checked": active,
|
|
1441
|
+
title: hint,
|
|
1442
|
+
disabled: !modelsEditable,
|
|
1443
|
+
className: "dsm-workbuddy-xdpool-dist-option" + (active ? " dsm-workbuddy-xdpool-dist-option-active" : ""),
|
|
1444
|
+
onClick: () => {
|
|
1445
|
+
setDistribution(option);
|
|
1446
|
+
},
|
|
1447
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1448
|
+
className: "dsm-workbuddy-xdpool-dist-option-top",
|
|
1449
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1450
|
+
className: "dsm-workbuddy-xdpool-dist-option-name",
|
|
1451
|
+
children: label
|
|
1452
|
+
}), option === "sticky" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1453
|
+
className: "dsm-workbuddy-xdpool-dist-option-badge",
|
|
1454
|
+
children: t?.("row.distRecommended") ?? "Recommended"
|
|
1455
|
+
}) : null]
|
|
1456
|
+
})
|
|
1457
|
+
}, option);
|
|
1458
|
+
})
|
|
1459
|
+
}),
|
|
1460
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1461
|
+
className: "dsm-workbuddy-xdpool-dist-hint",
|
|
1462
|
+
children: distHint(status.distribution ?? "priority", t)
|
|
1398
1463
|
})
|
|
1399
|
-
|
|
1464
|
+
]
|
|
1400
1465
|
})
|
|
1401
1466
|
]
|
|
1402
1467
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -1617,6 +1682,7 @@ window.__ModuleLoader__.load({
|
|
|
1617
1682
|
})(),
|
|
1618
1683
|
status?.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountBlock, {
|
|
1619
1684
|
account,
|
|
1685
|
+
models: status?.models ?? [],
|
|
1620
1686
|
...checkinBusyId === void 0 ? {} : { checkinBusyId },
|
|
1621
1687
|
onClaimCheckin: (accountId) => {
|
|
1622
1688
|
claimCheckin(accountId);
|
|
@@ -1723,6 +1789,7 @@ window.__ModuleLoader__.load({
|
|
|
1723
1789
|
children: [...status?.models ?? []].sort((a, b) => Number(isFreeNow(b, now, activeRegion)) - Number(isFreeNow(a, now, activeRegion))).map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelRow, {
|
|
1724
1790
|
model,
|
|
1725
1791
|
region: activeRegion,
|
|
1792
|
+
paidTwin: paidAlternativeFor(model, status?.models ?? []),
|
|
1726
1793
|
t,
|
|
1727
1794
|
draft: modelDraft[model.id] ?? {
|
|
1728
1795
|
enabled: model.enabled,
|
|
@@ -1739,7 +1806,7 @@ window.__ModuleLoader__.load({
|
|
|
1739
1806
|
});
|
|
1740
1807
|
}
|
|
1741
1808
|
/** One account block: label + status tag + meta + optional credit panels. */
|
|
1742
|
-
function AccountBlock({ account, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled, onIgnoreAccount, onSaveCreditReserve, reserveBusyId }) {
|
|
1809
|
+
function AccountBlock({ account, models, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled, onIgnoreAccount, onSaveCreditReserve, reserveBusyId }) {
|
|
1743
1810
|
const isDisabled = account.disabled === true;
|
|
1744
1811
|
const isCooling = account.cooling === true;
|
|
1745
1812
|
const isDead = account.credentialDead === true;
|
|
@@ -1812,13 +1879,24 @@ window.__ModuleLoader__.load({
|
|
|
1812
1879
|
}) : null,
|
|
1813
1880
|
modelCooldowns.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1814
1881
|
className: "dsm-workbuddy-xdpool-account-modelcool",
|
|
1815
|
-
children: modelCooldowns.map((mc) =>
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1882
|
+
children: modelCooldowns.map((mc) => {
|
|
1883
|
+
const coolingModel = models.find((model) => model.id === mc.modelId);
|
|
1884
|
+
const twin = coolingModel === void 0 ? void 0 : paidAlternativeFor(coolingModel, models);
|
|
1885
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1886
|
+
className: "dsm-workbuddy-xdpool-account-modelcool-chip",
|
|
1887
|
+
children: [t?.("row.modelCooling", {
|
|
1888
|
+
model: mc.modelId,
|
|
1889
|
+
time: formatDateTime(mc.until)
|
|
1890
|
+
}) ?? `${mc.modelId} cooling to ${formatDateTime(mc.until)}`, twin === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1891
|
+
className: "dsm-workbuddy-xdpool-account-modelcool-twin",
|
|
1892
|
+
title: t?.("row.paidTwinHint") ?? "Same model, paid row",
|
|
1893
|
+
children: t?.("row.rateLimitedPaidTwin", {
|
|
1894
|
+
id: twin.id,
|
|
1895
|
+
rate: twin.multiplier.toFixed(2)
|
|
1896
|
+
}) ?? `→ ${twin.id} (${twin.multiplier.toFixed(2)}x) still works`
|
|
1897
|
+
})]
|
|
1898
|
+
}, mc.modelId);
|
|
1899
|
+
})
|
|
1822
1900
|
}) : null
|
|
1823
1901
|
]
|
|
1824
1902
|
}),
|
|
@@ -2044,7 +2122,8 @@ window.__ModuleLoader__.load({
|
|
|
2044
2122
|
className: "dsm-workbuddy-xdpool-checkin",
|
|
2045
2123
|
children: account.checkinError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2046
2124
|
className: "dsm-workbuddy-xdpool-checkin-error",
|
|
2047
|
-
|
|
2125
|
+
title: account.checkinError,
|
|
2126
|
+
children: t?.("row.checkinUnavailable") ?? "check-in unavailable"
|
|
2048
2127
|
}) : checkin === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
2049
2128
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2050
2129
|
className: "dsm-workbuddy-xdpool-checkin-meta",
|
|
@@ -2085,7 +2164,7 @@ window.__ModuleLoader__.load({
|
|
|
2085
2164
|
* context radios stay disabled rather than pretending an edit took hold. The
|
|
2086
2165
|
* draft lives in the parent, so this component only ever reports intent.
|
|
2087
2166
|
*/
|
|
2088
|
-
function ModelRow({ model, region, t, draft, editable, onToggle, onToggleImage, onBudget }) {
|
|
2167
|
+
function ModelRow({ model, region, paidTwin, t, draft, editable, onToggle, onToggleImage, onBudget }) {
|
|
2089
2168
|
const now = /* @__PURE__ */ new Date();
|
|
2090
2169
|
const tag = tagFor(model, now, region);
|
|
2091
2170
|
const promo = promoStatusFor(model, now, region);
|
|
@@ -2184,6 +2263,14 @@ window.__ModuleLoader__.load({
|
|
|
2184
2263
|
className: "dsm-workbuddy-xdpool-model-meta-promo",
|
|
2185
2264
|
children: promoUntil
|
|
2186
2265
|
}),
|
|
2266
|
+
paidTwin === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2267
|
+
className: "dsm-workbuddy-xdpool-model-meta-twin",
|
|
2268
|
+
title: t?.("row.paidTwinHint") ?? "Same model, paid row",
|
|
2269
|
+
children: t?.("row.paidTwin", {
|
|
2270
|
+
id: paidTwin.id,
|
|
2271
|
+
rate: paidTwin.multiplier.toFixed(2)
|
|
2272
|
+
}) ?? `paid twin ${paidTwin.id} (${paidTwin.multiplier.toFixed(2)}x)`
|
|
2273
|
+
}),
|
|
2187
2274
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2188
2275
|
className: "dsm-workbuddy-xdpool-model-cap",
|
|
2189
2276
|
children: t?.("row.modelOutput", { size: formatCapacity(model.maxOutputTokens) }) ?? `out ${formatCapacity(model.maxOutputTokens)}`
|
|
@@ -2315,6 +2402,7 @@ window.__ModuleLoader__.load({
|
|
|
2315
2402
|
"row.checkinStreakBonus": "day {days} bonus +{credit}",
|
|
2316
2403
|
"row.checkinClaimedReward": "Claimed +{credit} credits",
|
|
2317
2404
|
"row.checkinError": "Check-in failed: {message}",
|
|
2405
|
+
"row.checkinUnavailable": "check-in unavailable",
|
|
2318
2406
|
"row.checkinAllHint": "Collect every account’s daily reward here — no need to switch accounts first.",
|
|
2319
2407
|
"row.modelsTitle": "Models",
|
|
2320
2408
|
"row.modelsSummary": "{count} model(s) in the live catalog",
|
|
@@ -2345,6 +2433,9 @@ window.__ModuleLoader__.load({
|
|
|
2345
2433
|
"row.nightFreeNow": "free until {time}",
|
|
2346
2434
|
"row.nightFreeLater": "free from {time}",
|
|
2347
2435
|
"row.promoUntil": "promo to {date}",
|
|
2436
|
+
"row.paidTwin": "paid twin {id} ({rate}x)",
|
|
2437
|
+
"row.paidTwinHint": "The free tier is rate-limited first; this same model has a paid row that keeps working",
|
|
2438
|
+
"row.rateLimitedPaidTwin": "Tip: {id} is the same model on a paid row ({rate}x credits) and is limited separately — switch to it to keep going",
|
|
2348
2439
|
"row.skippedFiles": "{count} credential file(s) could not be read",
|
|
2349
2440
|
"row.skippedEncrypted": "encrypted — start WorkBuddy once so its key can be read",
|
|
2350
2441
|
"row.skippedUnreadable": "file could not be read",
|
|
@@ -2362,6 +2453,8 @@ window.__ModuleLoader__.load({
|
|
|
2362
2453
|
"row.distBalancedHint": "Draw at random, favouring the account idle longest",
|
|
2363
2454
|
"row.distSticky": "Per conversation",
|
|
2364
2455
|
"row.distStickyHint": "One account per chat, new chats take the next account — keeps the prompt cache warm",
|
|
2456
|
+
"row.distExpiry": "Expiring first",
|
|
2457
|
+
"row.distExpiryHint": "Spend the account whose credit packs expire soonest — use them before they are lost",
|
|
2365
2458
|
"row.distRecommended": "Recommended",
|
|
2366
2459
|
"row.resetCooldowns": "Clear all cooldowns",
|
|
2367
2460
|
"row.resetCooldownsBusy": "Clearing…",
|
|
@@ -2473,6 +2566,7 @@ window.__ModuleLoader__.load({
|
|
|
2473
2566
|
"row.checkinStreakBonus": "第 {days} 天额外 +{credit}",
|
|
2474
2567
|
"row.checkinClaimedReward": "已领取 +{credit} 积分",
|
|
2475
2568
|
"row.checkinError": "签到失败:{message}",
|
|
2569
|
+
"row.checkinUnavailable": "签到不可用",
|
|
2476
2570
|
"row.checkinAllHint": "这里可以为每个账号分别领取每日签到奖励,无需先切换账号。",
|
|
2477
2571
|
"row.modelsTitle": "模型",
|
|
2478
2572
|
"row.modelsSummary": "实时目录中 {count} 个模型",
|
|
@@ -2503,6 +2597,9 @@ window.__ModuleLoader__.load({
|
|
|
2503
2597
|
"row.nightFreeNow": "免费至 {time}",
|
|
2504
2598
|
"row.nightFreeLater": "{time} 起免费",
|
|
2505
2599
|
"row.promoUntil": "活动至 {date}",
|
|
2600
|
+
"row.paidTwin": "付费同款 {id}({rate}x)",
|
|
2601
|
+
"row.paidTwinHint": "免费档先被限流;同一个模型的付费行不受这个限制影响",
|
|
2602
|
+
"row.rateLimitedPaidTwin": "提示:{id} 是同一个模型的付费行({rate}x 积分),限流是分开算的 —— 切过去就能继续用",
|
|
2506
2603
|
"row.skippedFiles": "有 {count} 个凭据文件读不出来",
|
|
2507
2604
|
"row.skippedEncrypted": "已加密 —— 启动一次 WorkBuddy 才能取到密钥",
|
|
2508
2605
|
"row.skippedUnreadable": "文件读不出来",
|
|
@@ -2520,6 +2617,8 @@ window.__ModuleLoader__.load({
|
|
|
2520
2617
|
"row.distBalancedHint": "随机抽取,闲置越久的账号被选中概率越高",
|
|
2521
2618
|
"row.distSticky": "每对话固定",
|
|
2522
2619
|
"row.distStickyHint": "一个对话只用一个账号,新对话自动换下一个账号——保住上游缓存,命中率和速度都更好",
|
|
2620
|
+
"row.distExpiry": "先花快到期的",
|
|
2621
|
+
"row.distExpiryHint": "优先使用积分包最快过期的账号——把要过期的积分花掉,别浪费",
|
|
2523
2622
|
"row.distRecommended": "推荐",
|
|
2524
2623
|
"row.resetCooldowns": "清除所有冷却",
|
|
2525
2624
|
"row.resetCooldownsBusy": "正在清除…",
|
package/lib/index.d.ts
CHANGED
|
@@ -845,7 +845,7 @@ export declare function workbuddyAccountId(credential: Pick<WorkBuddyCredential,
|
|
|
845
845
|
/** Every directory the pool should scan, in probe order. */
|
|
846
846
|
export declare function candidateAuthDirs(env?: NodeJS.ProcessEnv): string[];
|
|
847
847
|
/** How the pool chooses which account serves the next request. */
|
|
848
|
-
type AccountDistribution = 'priority' | 'round-robin' | 'balanced' | 'sticky';
|
|
848
|
+
type AccountDistribution = 'priority' | 'round-robin' | 'balanced' | 'sticky' | 'expiry';
|
|
849
849
|
interface AccountPoolOptions {
|
|
850
850
|
/** Logger for discovery and rotation events. */
|
|
851
851
|
logger?: {
|
|
@@ -880,6 +880,10 @@ interface AccountPoolOptions {
|
|
|
880
880
|
* - `sticky`: one account per conversation, and a new conversation moves to
|
|
881
881
|
* the next account in order. Keeps the upstream prompt cache warm inside a
|
|
882
882
|
* conversation while still spreading spend across conversations.
|
|
883
|
+
* - `expiry`: the account whose one-off credit packs expire soonest serves
|
|
884
|
+
* first, so use-it-or-lose-it credits are spent before they die. Accounts
|
|
885
|
+
* with no known expiry sort last, so a pool that has never been probed
|
|
886
|
+
* behaves like `priority`.
|
|
883
887
|
*/
|
|
884
888
|
distribution?: AccountDistribution;
|
|
885
889
|
}
|
|
@@ -964,6 +968,19 @@ export declare class WorkBuddyAccountPool {
|
|
|
964
968
|
* strand a healthy pool, and the first 402 still cools it as before.
|
|
965
969
|
*/
|
|
966
970
|
private creditBalances;
|
|
971
|
+
/**
|
|
972
|
+
* Nearest expiry among each account's one-off credit packs, epoch ms.
|
|
973
|
+
*
|
|
974
|
+
* Credit packs are use-it-or-lose-it, so "which account should serve next"
|
|
975
|
+
* has a second honest answer besides "which one is idle": the one whose
|
|
976
|
+
* credits die soonest. Only one-off packs count — monthly packs refresh on
|
|
977
|
+
* their own cycle and are therefore never urgent — which is why this is
|
|
978
|
+
* written from the same `fetchCredits` reading that fills `creditBalances`.
|
|
979
|
+
*
|
|
980
|
+
* An absent entry means "never probed, or nothing is about to expire". Both
|
|
981
|
+
* are the same thing to `expiry` mode: not urgent, so it sorts last.
|
|
982
|
+
*/
|
|
983
|
+
private creditExpiry;
|
|
967
984
|
/**
|
|
968
985
|
* Last time each account served a request, epoch ms. Drives the idle term
|
|
969
986
|
* of the priority-mode weighting below: an account that just served loses to
|
|
@@ -1078,6 +1095,21 @@ export declare class WorkBuddyAccountPool {
|
|
|
1078
1095
|
* spreads the very first picks instead of always returning index 0.
|
|
1079
1096
|
*/
|
|
1080
1097
|
private pickByWeight;
|
|
1098
|
+
/**
|
|
1099
|
+
* `expiry` mode: the account whose one-off credit packs die soonest.
|
|
1100
|
+
*
|
|
1101
|
+
* Credit packs are use-it-or-lose-it, so spending the dying ones first is
|
|
1102
|
+
* strictly better than spreading the spend: an account that expires with
|
|
1103
|
+
* credits left is money burnt, while an account whose packs have no deadline
|
|
1104
|
+
* loses nothing by waiting. Two accounts expiring at the same instant fall
|
|
1105
|
+
* back to the pool order, and an account with no known expiry sorts last —
|
|
1106
|
+
* "we have not looked" must never outrank a real deadline.
|
|
1107
|
+
*
|
|
1108
|
+
* A pool where nobody has a known expiry therefore behaves exactly like
|
|
1109
|
+
* priority, which is the honest degradation: without a reading there is
|
|
1110
|
+
* nothing to sort by.
|
|
1111
|
+
*/
|
|
1112
|
+
private pickByExpiry;
|
|
1081
1113
|
/**
|
|
1082
1114
|
* Pick the account to serve a request.
|
|
1083
1115
|
*
|
|
@@ -1093,6 +1125,8 @@ export declare class WorkBuddyAccountPool {
|
|
|
1093
1125
|
* - **sticky**: one account per conversation, and a new conversation moves to
|
|
1094
1126
|
* the next account in order. Keeps the upstream prompt cache warm within a
|
|
1095
1127
|
* conversation while still spreading spend across conversations.
|
|
1128
|
+
* - **expiry**: the account whose one-off credit packs expire soonest serves
|
|
1129
|
+
* first, so use-it-or-lose-it credits are spent before they vanish.
|
|
1096
1130
|
*
|
|
1097
1131
|
* In every mode an explicit user selection (`prefer`) heads the list, a
|
|
1098
1132
|
* cooling account is skipped for that model only, and an unrecognised setting
|
|
@@ -1128,8 +1162,16 @@ export declare class WorkBuddyAccountPool {
|
|
|
1128
1162
|
* check has something to compare against. A reading for an unknown account is
|
|
1129
1163
|
* dropped: `scan()` rebuilds the account list and a stale id would otherwise
|
|
1130
1164
|
* accumulate forever.
|
|
1165
|
+
*
|
|
1166
|
+
* `nearestExpiryMs` is the same reading's nearest one-off pack deadline, or
|
|
1167
|
+
* undefined when nothing is about to expire. It is written through, not
|
|
1168
|
+
* merged: a pack that has been spent or has died disappears from the next
|
|
1169
|
+
* reading, and keeping the stale deadline would pin the pool to an account
|
|
1170
|
+
* whose credits are already gone — the opposite of what `expiry` mode wants.
|
|
1131
1171
|
*/
|
|
1132
|
-
noteCredits(accountId: string, balance: number): void;
|
|
1172
|
+
noteCredits(accountId: string, balance: number, nearestExpiryMs?: number): void;
|
|
1173
|
+
/** Nearest one-off pack expiry for one account, or undefined when none known. */
|
|
1174
|
+
creditExpiryOf(accountId: string): number | undefined;
|
|
1133
1175
|
/** Last known balance for one account, or undefined when never read. */
|
|
1134
1176
|
creditsOf(accountId: string): number | undefined;
|
|
1135
1177
|
/** The credit floor the user set for one account; 0 when unset. */
|
|
@@ -1607,7 +1649,7 @@ interface PoolWebAutomationEarnings {
|
|
|
1607
1649
|
*/
|
|
1608
1650
|
type PoolRegion = 'cn' | 'global';
|
|
1609
1651
|
/** How the pool spreads requests across its accounts. */
|
|
1610
|
-
type PoolDistribution = 'priority' | 'round-robin' | 'balanced' | 'sticky';
|
|
1652
|
+
type PoolDistribution = 'priority' | 'round-robin' | 'balanced' | 'sticky' | 'expiry';
|
|
1611
1653
|
/**
|
|
1612
1654
|
* The schedule every automation job falls back to.
|
|
1613
1655
|
*
|
|
@@ -2579,10 +2621,13 @@ export interface Config {
|
|
|
2579
2621
|
* conversation throws away the upstream prompt cache (it is per tenant), so
|
|
2580
2622
|
* this keeps the cache warm while still spreading spend across
|
|
2581
2623
|
* conversations.
|
|
2624
|
+
* - `expiry` picks the account whose one-off credit packs expire soonest, so
|
|
2625
|
+
* use-it-or-lose-it credits are spent before they die. Accounts with no
|
|
2626
|
+
* known expiry sort last, so an unprobed pool degrades to `priority`.
|
|
2582
2627
|
*
|
|
2583
2628
|
* Absent reads as `priority`.
|
|
2584
2629
|
*/
|
|
2585
|
-
distribution?: 'priority' | 'round-robin' | 'balanced' | 'sticky';
|
|
2630
|
+
distribution?: 'priority' | 'round-robin' | 'balanced' | 'sticky' | 'expiry';
|
|
2586
2631
|
/**
|
|
2587
2632
|
* Account ids switched off on the card. A disabled account is never picked
|
|
2588
2633
|
* to serve a request, but it stays in the pool and on the card so it can be
|
package/lib/index.js
CHANGED
|
@@ -269,9 +269,22 @@ function isGatewayHtmlRejection(status, text) {
|
|
|
269
269
|
const head = text.slice(0, 512).toLowerCase();
|
|
270
270
|
return head.includes("<html") || head.includes("openresty") || head.includes("apisix");
|
|
271
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* The sentence thrown for a gateway HTML rejection.
|
|
274
|
+
*
|
|
275
|
+
* Exported (as a prefix test) because callers outside this module have to tell
|
|
276
|
+
* this specific failure apart from an ordinary network error: it is proof the
|
|
277
|
+
* *credential* is no longer honoured, so the pool may mark the account dead
|
|
278
|
+
* instead of re-probing it on every refresh.
|
|
279
|
+
*/
|
|
280
|
+
const GATEWAY_REJECTION_MESSAGE = "the WorkBuddy gateway rejected this credential (http 401).";
|
|
281
|
+
/** Was this failure a gateway HTML rejection (a stale sign-in, not a hiccup)? */
|
|
282
|
+
function isGatewayRejectionError(error) {
|
|
283
|
+
return (error instanceof Error ? error.message : String(error)).startsWith(GATEWAY_REJECTION_MESSAGE);
|
|
284
|
+
}
|
|
272
285
|
async function readEnvelope(response) {
|
|
273
286
|
const text = await response.text();
|
|
274
|
-
if (isGatewayHtmlRejection(response.status, text)) throw new Error(
|
|
287
|
+
if (isGatewayHtmlRejection(response.status, text)) throw new Error(`${GATEWAY_REJECTION_MESSAGE} 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.`);
|
|
275
288
|
let parsed;
|
|
276
289
|
try {
|
|
277
290
|
parsed = JSON.parse(text);
|
|
@@ -2856,6 +2869,19 @@ var WorkBuddyAccountPool = class {
|
|
|
2856
2869
|
*/
|
|
2857
2870
|
creditBalances = /* @__PURE__ */ new Map();
|
|
2858
2871
|
/**
|
|
2872
|
+
* Nearest expiry among each account's one-off credit packs, epoch ms.
|
|
2873
|
+
*
|
|
2874
|
+
* Credit packs are use-it-or-lose-it, so "which account should serve next"
|
|
2875
|
+
* has a second honest answer besides "which one is idle": the one whose
|
|
2876
|
+
* credits die soonest. Only one-off packs count — monthly packs refresh on
|
|
2877
|
+
* their own cycle and are therefore never urgent — which is why this is
|
|
2878
|
+
* written from the same `fetchCredits` reading that fills `creditBalances`.
|
|
2879
|
+
*
|
|
2880
|
+
* An absent entry means "never probed, or nothing is about to expire". Both
|
|
2881
|
+
* are the same thing to `expiry` mode: not urgent, so it sorts last.
|
|
2882
|
+
*/
|
|
2883
|
+
creditExpiry = /* @__PURE__ */ new Map();
|
|
2884
|
+
/**
|
|
2859
2885
|
* Last time each account served a request, epoch ms. Drives the idle term
|
|
2860
2886
|
* of the priority-mode weighting below: an account that just served loses to
|
|
2861
2887
|
* one that has been idle, so a small pool stops hammering a single account.
|
|
@@ -3158,6 +3184,33 @@ var WorkBuddyAccountPool = class {
|
|
|
3158
3184
|
return pool[pool.length - 1];
|
|
3159
3185
|
}
|
|
3160
3186
|
/**
|
|
3187
|
+
* `expiry` mode: the account whose one-off credit packs die soonest.
|
|
3188
|
+
*
|
|
3189
|
+
* Credit packs are use-it-or-lose-it, so spending the dying ones first is
|
|
3190
|
+
* strictly better than spreading the spend: an account that expires with
|
|
3191
|
+
* credits left is money burnt, while an account whose packs have no deadline
|
|
3192
|
+
* loses nothing by waiting. Two accounts expiring at the same instant fall
|
|
3193
|
+
* back to the pool order, and an account with no known expiry sorts last —
|
|
3194
|
+
* "we have not looked" must never outrank a real deadline.
|
|
3195
|
+
*
|
|
3196
|
+
* A pool where nobody has a known expiry therefore behaves exactly like
|
|
3197
|
+
* priority, which is the honest degradation: without a reading there is
|
|
3198
|
+
* nothing to sort by.
|
|
3199
|
+
*/
|
|
3200
|
+
pickByExpiry(pool) {
|
|
3201
|
+
let best;
|
|
3202
|
+
let bestAt = Number.POSITIVE_INFINITY;
|
|
3203
|
+
for (const account of pool) {
|
|
3204
|
+
const at = this.creditExpiry.get(account.id);
|
|
3205
|
+
if (at === void 0) continue;
|
|
3206
|
+
if (at < bestAt) {
|
|
3207
|
+
best = account;
|
|
3208
|
+
bestAt = at;
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
return best ?? pool[0];
|
|
3212
|
+
}
|
|
3213
|
+
/**
|
|
3161
3214
|
* Pick the account to serve a request.
|
|
3162
3215
|
*
|
|
3163
3216
|
* Two distributions, chosen by the `distribution` setting:
|
|
@@ -3172,6 +3225,8 @@ var WorkBuddyAccountPool = class {
|
|
|
3172
3225
|
* - **sticky**: one account per conversation, and a new conversation moves to
|
|
3173
3226
|
* the next account in order. Keeps the upstream prompt cache warm within a
|
|
3174
3227
|
* conversation while still spreading spend across conversations.
|
|
3228
|
+
* - **expiry**: the account whose one-off credit packs expire soonest serves
|
|
3229
|
+
* first, so use-it-or-lose-it credits are spent before they vanish.
|
|
3175
3230
|
*
|
|
3176
3231
|
* In every mode an explicit user selection (`prefer`) heads the list, a
|
|
3177
3232
|
* cooling account is skipped for that model only, and an unrecognised setting
|
|
@@ -3200,7 +3255,7 @@ var WorkBuddyAccountPool = class {
|
|
|
3200
3255
|
const bound = this.affinityAccount(pool, conversationKey);
|
|
3201
3256
|
if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
|
|
3202
3257
|
}
|
|
3203
|
-
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3258
|
+
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : this.distribution === "expiry" ? this.pickByExpiry(pool) : pool[0];
|
|
3204
3259
|
if (account === void 0) return void 0;
|
|
3205
3260
|
if (!await this.ensureFresh(account)) continue;
|
|
3206
3261
|
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
@@ -3242,11 +3297,26 @@ var WorkBuddyAccountPool = class {
|
|
|
3242
3297
|
* check has something to compare against. A reading for an unknown account is
|
|
3243
3298
|
* dropped: `scan()` rebuilds the account list and a stale id would otherwise
|
|
3244
3299
|
* accumulate forever.
|
|
3300
|
+
*
|
|
3301
|
+
* `nearestExpiryMs` is the same reading's nearest one-off pack deadline, or
|
|
3302
|
+
* undefined when nothing is about to expire. It is written through, not
|
|
3303
|
+
* merged: a pack that has been spent or has died disappears from the next
|
|
3304
|
+
* reading, and keeping the stale deadline would pin the pool to an account
|
|
3305
|
+
* whose credits are already gone — the opposite of what `expiry` mode wants.
|
|
3245
3306
|
*/
|
|
3246
|
-
noteCredits(accountId, balance) {
|
|
3307
|
+
noteCredits(accountId, balance, nearestExpiryMs) {
|
|
3247
3308
|
if (!Number.isFinite(balance)) return;
|
|
3248
3309
|
if (!this.accounts.some((account) => account.id === accountId)) return;
|
|
3249
3310
|
this.creditBalances.set(accountId, balance);
|
|
3311
|
+
if (nearestExpiryMs === void 0 || !Number.isFinite(nearestExpiryMs)) {
|
|
3312
|
+
this.creditExpiry.delete(accountId);
|
|
3313
|
+
return;
|
|
3314
|
+
}
|
|
3315
|
+
this.creditExpiry.set(accountId, nearestExpiryMs);
|
|
3316
|
+
}
|
|
3317
|
+
/** Nearest one-off pack expiry for one account, or undefined when none known. */
|
|
3318
|
+
creditExpiryOf(accountId) {
|
|
3319
|
+
return this.creditExpiry.get(accountId);
|
|
3250
3320
|
}
|
|
3251
3321
|
/** Last known balance for one account, or undefined when never read. */
|
|
3252
3322
|
creditsOf(accountId) {
|
|
@@ -5687,6 +5757,43 @@ function injectSummary(compacted, summary) {
|
|
|
5687
5757
|
];
|
|
5688
5758
|
}
|
|
5689
5759
|
//#endregion
|
|
5760
|
+
//#region src/siblings.ts
|
|
5761
|
+
/**
|
|
5762
|
+
* The cheapest PAID model sharing `model`'s display name, or undefined.
|
|
5763
|
+
*
|
|
5764
|
+
* Cheapest rather than first, because the point of the hint is "there is a
|
|
5765
|
+
* cheap way out": offering the most expensive twin would make switching look
|
|
5766
|
+
* like a trap. A sibling whose price is unknown is skipped — an unpriced row
|
|
5767
|
+
* cannot be promised as "the paid one".
|
|
5768
|
+
*/
|
|
5769
|
+
function paidSiblingOf(model, models) {
|
|
5770
|
+
let best;
|
|
5771
|
+
for (const candidate of models) {
|
|
5772
|
+
if (candidate.id === model.id) continue;
|
|
5773
|
+
if (candidate.name !== model.name) continue;
|
|
5774
|
+
const multiplier = candidate.multiplier;
|
|
5775
|
+
if (multiplier === void 0 || multiplier <= 0) continue;
|
|
5776
|
+
if (best === void 0 || multiplier < best.multiplier) best = {
|
|
5777
|
+
id: candidate.id,
|
|
5778
|
+
name: candidate.name,
|
|
5779
|
+
multiplier
|
|
5780
|
+
};
|
|
5781
|
+
}
|
|
5782
|
+
return best;
|
|
5783
|
+
}
|
|
5784
|
+
/**
|
|
5785
|
+
* The paid sibling of a model, but ONLY when the model itself is free.
|
|
5786
|
+
*
|
|
5787
|
+
* The guard is the whole point: a `x0.79` row with a `x0.06` cousin is not a
|
|
5788
|
+
* "free model with a paid twin", it is just a more expensive row, and labelling
|
|
5789
|
+
* it would be noise on most of the list. The hint answers one question — "the
|
|
5790
|
+
* free one is limited, what now".
|
|
5791
|
+
*/
|
|
5792
|
+
function paidAlternativeFor(model, models) {
|
|
5793
|
+
if (model.multiplier !== 0) return void 0;
|
|
5794
|
+
return paidSiblingOf(model, models);
|
|
5795
|
+
}
|
|
5796
|
+
//#endregion
|
|
5690
5797
|
//#region src/shim.ts
|
|
5691
5798
|
/**
|
|
5692
5799
|
* Loopback OpenAI-compatible endpoint with multi-account failover.
|
|
@@ -5872,6 +5979,22 @@ function createWorkBuddyShim(options) {
|
|
|
5872
5979
|
const server = createServer((req, res) => {
|
|
5873
5980
|
handle(req, res);
|
|
5874
5981
|
});
|
|
5982
|
+
/**
|
|
5983
|
+
* "The same model has a paid row" sentence for a 429 body, or undefined.
|
|
5984
|
+
*
|
|
5985
|
+
* Reads the catalog the shim was built with, so the hint names a row this
|
|
5986
|
+
* gateway ACTUALLY serves — a CN-only id in a global 429 would be worse than
|
|
5987
|
+
* no hint at all. Absent when the cooling model is itself paid, or when the
|
|
5988
|
+
* family has no priced twin.
|
|
5989
|
+
*/
|
|
5990
|
+
function paidTwinHint(modelId) {
|
|
5991
|
+
const models = catalog.current();
|
|
5992
|
+
const model = models.find((candidate) => candidate.id === modelId);
|
|
5993
|
+
if (model === void 0) return void 0;
|
|
5994
|
+
const twin = paidAlternativeFor(model, models);
|
|
5995
|
+
if (twin === void 0) return void 0;
|
|
5996
|
+
return `${twin.id} is the same model on a paid row (x${twin.multiplier.toFixed(2)} credits) and is rate-limited separately; switch to it to keep going`;
|
|
5997
|
+
}
|
|
5875
5998
|
const ready = new Promise((resolve, reject) => {
|
|
5876
5999
|
server.once("listening", () => resolve());
|
|
5877
6000
|
server.once("error", reject);
|
|
@@ -5902,7 +6025,7 @@ function createWorkBuddyShim(options) {
|
|
|
5902
6025
|
lastBalanceAt.set(account.id, now);
|
|
5903
6026
|
try {
|
|
5904
6027
|
const credits = await client.fetchCredits(account.credential);
|
|
5905
|
-
pool.noteCredits(account.id, credits.total);
|
|
6028
|
+
pool.noteCredits(account.id, credits.total, credits.nearestExpiryMs);
|
|
5906
6029
|
} catch {}
|
|
5907
6030
|
}
|
|
5908
6031
|
async function handle(req, res) {
|
|
@@ -5983,7 +6106,8 @@ function createWorkBuddyShim(options) {
|
|
|
5983
6106
|
if (why.reason === "cooling") {
|
|
5984
6107
|
const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
|
|
5985
6108
|
const upstream = last === void 0 ? "" : ` — ${last.message.slice(0, 200)}`;
|
|
5986
|
-
|
|
6109
|
+
const twin = modelId === void 0 ? void 0 : paidTwinHint(modelId);
|
|
6110
|
+
writeOpenAIError(res, 429, "soft_rate", `${subject} (${why.cooling}/${why.total} cooling, tried ${tried.length})${tried.length === 0 ? "" : `: ${tried.join(" → ")}`}${upstream}` + (twin === void 0 ? "" : ` — ${twin}`));
|
|
5987
6111
|
return;
|
|
5988
6112
|
}
|
|
5989
6113
|
if (why.reason === "disabled") {
|
|
@@ -6560,9 +6684,11 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
6560
6684
|
const earned = deps.scheduler?.().earningsToday[account.id];
|
|
6561
6685
|
if (earned !== void 0) Object.assign(row, { automationToday: earned });
|
|
6562
6686
|
if (!row.cooling && row.credentialDead !== true) {
|
|
6687
|
+
let deadNow = false;
|
|
6563
6688
|
try {
|
|
6564
6689
|
const credits = await deps.client.fetchCredits(account.credential);
|
|
6565
|
-
deps.pool.noteCredits(account.id, credits.total);
|
|
6690
|
+
deps.pool.noteCredits(account.id, credits.total, credits.nearestExpiryMs);
|
|
6691
|
+
deps.pool.clearCredentialDead(account.id);
|
|
6566
6692
|
Object.assign(row, { credits: {
|
|
6567
6693
|
total: credits.total,
|
|
6568
6694
|
packages: credits.packages,
|
|
@@ -6570,9 +6696,14 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
6570
6696
|
...credits.nearestExpiryMs === void 0 ? {} : { nearestExpiryMs: credits.nearestExpiryMs }
|
|
6571
6697
|
} });
|
|
6572
6698
|
} catch (error) {
|
|
6699
|
+
if (isGatewayRejectionError(error)) {
|
|
6700
|
+
deps.pool.penalizeCredentialDead(account.id);
|
|
6701
|
+
deadNow = true;
|
|
6702
|
+
Object.assign(row, { credentialDead: true });
|
|
6703
|
+
}
|
|
6573
6704
|
Object.assign(row, { creditsError: safeMessage(error) });
|
|
6574
6705
|
}
|
|
6575
|
-
try {
|
|
6706
|
+
if (!deadNow) try {
|
|
6576
6707
|
const checkin = await deps.client.fetchCheckinStatus(account.credential);
|
|
6577
6708
|
const web = {
|
|
6578
6709
|
active: checkin.active,
|
|
@@ -7317,8 +7448,9 @@ const Config = z.object({
|
|
|
7317
7448
|
"priority",
|
|
7318
7449
|
"round-robin",
|
|
7319
7450
|
"balanced",
|
|
7320
|
-
"sticky"
|
|
7321
|
-
|
|
7451
|
+
"sticky",
|
|
7452
|
+
"expiry"
|
|
7453
|
+
]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), balanced (idle-weighted random), sticky (one account per conversation, new conversations rotate), or expiry (spend the account whose credit packs expire soonest)")),
|
|
7322
7454
|
disabledAccountIds: asVolatile(z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)")),
|
|
7323
7455
|
creditReserves: asVolatile(z.dict(z.number().step(1).min(0)).default({}).description("Per-account credit floor: stop using an account once its balance reaches this value")),
|
|
7324
7456
|
enabledModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet")),
|
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": "1.8.
|
|
5
|
+
"version": "1.8.3",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",
|