dsh-workbuddy-xdpool 1.8.0 → 1.8.2
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 +78 -0
- package/README.md +2 -0
- package/lib/bin.js +130 -36
- package/lib/client.js +106 -12
- package/lib/index.d.ts +63 -2
- package/lib/index.js +201 -40
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,84 @@
|
|
|
4
4
|
|
|
5
5
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
6
6
|
|
|
7
|
+
## 1.8.2 — 免费的那个被限流了,旁边那个收费的同款还能用
|
|
8
|
+
|
|
9
|
+
> 免费模型被限流的时候,插件以前只会说一句「所有账号都在冷却」。这句话没错,但它让用户以为自己只剩一条路:等。其实同一个模型往往还有一条**收费的**产品行,而且在限流器眼里它们是两个不同的模型——免费行被关小黑屋的时候,收费行通常毫发无伤。这个版本就是把这个「另一条路」写出来。
|
|
10
|
+
|
|
11
|
+
### 同一个模型,凭什么有两行
|
|
12
|
+
|
|
13
|
+
上游把一条产品线按**多行**卖,每行有自己的 id、自己的价格、自己的限流额度,但**显示名字是一样的**。实测两个网关:
|
|
14
|
+
|
|
15
|
+
| 网关 | 免费行 | 收费行 |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| 国际版 | `deepseek-v4.1-flash`(x0.00) | `deepseek-v4.1-flash-sg`(x0.03) |
|
|
18
|
+
| 国内版 | `hy3`(x0.00) | `hy3-x`(x0.05) |
|
|
19
|
+
|
|
20
|
+
注意 `-sg` 和 `-x` 跟免费行**没有任何前缀关系**,能把它们认成一家的只有那个相同的显示名。所以匹配是按名字来的,不是按 id 猜的。
|
|
21
|
+
|
|
22
|
+
### 现在会怎么提示
|
|
23
|
+
|
|
24
|
+
- **模型列表**:免费行后面挂一个小灰标「付费同款 deepseek-v4.1-flash-sg(0.03x)」,鼠标悬停解释「免费档先被限流;同一个模型的付费行不受这个限制影响」。灰的、低调的——这一行现在仍然是免费的那个,标签只是告诉你退路在哪,不是催你花钱。
|
|
25
|
+
- **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」。
|
|
26
|
+
- **账号卡片**:某个账号上「`hy4-preview` cooling to 10:14」这种小标后面,也会跟上「→ 收费行的 id(倍率)还能用」。
|
|
27
|
+
|
|
28
|
+
### 刻意没做的事
|
|
29
|
+
|
|
30
|
+
- **收费行不标标签。** 只有倍率等于 0 的行才找孪生兄弟。否则「GLM-5.3」这种本身收费、又恰好有个更便宜的闪速版的模型,也会被挂上一个标签,最后满屏都是提示,等于没提示。
|
|
31
|
+
- **没有收费同款就不说话。** 独苗模型(整条线只有它自己)不会有任何多余文字,也不会留下一个孤零零的分隔符。
|
|
32
|
+
- **不把另一个免费行当退路。** 两个免费行是一起被限流的,切过去纯属换个人排队,说了等于骗人。
|
|
33
|
+
- **倍率未知的行不算数。** 上游没给价格的兄弟行不能被承诺成「那个收费的」——用户切过去以为知道价钱,结果未必。
|
|
34
|
+
|
|
35
|
+
### 顺带说清楚:国际版没有「每天领 30 积分」
|
|
36
|
+
|
|
37
|
+
上一版已经查过一遍,这次把话说完整。国际版(`www.workbuddy.ai`)**没有**签到活动,也**没有** `daily-checkin` 这条路由:
|
|
38
|
+
|
|
39
|
+
- `POST /v2/billing/meter/checkin-activity-status` 返回 200,但 `active: false`(字段在,活动没开);
|
|
40
|
+
- `GET /v2/billing/meter/get-user-resource` 直接 404(国内版才有这个接口);
|
|
41
|
+
- `/v2/billing/meter/checkin`、`/daily-checkin-activity`、`/checkin-activity` 全是 404。
|
|
42
|
+
|
|
43
|
+
国际版真正有的是另一套东西:**成长任务板**(5 条:完成一次对话、安装技能、绑定微信、召唤专家、使用模板)和 **streak 连续打卡**(7 天 / 14 天 / 28 天档位,分别给 0 / 50 / 150 积分)。但它的字段形状跟国内版不一样(没有 credit / energy / target / current),插件目前解析不了,所以**没有**为国际版开自动化——没有可领的东西,开了也只是每天准时去撞 404。
|
|
44
|
+
|
|
45
|
+
国内版的签到是正常的,多账号逐个领取的链路本来就有,不受本次改动影响。
|
|
46
|
+
|
|
47
|
+
## 1.8.1 — 一个过期的登录状态,不再拖着整个池子陪跑
|
|
48
|
+
|
|
49
|
+
> 有人的卡片上,好几个账号下面顶着一大段红色英文,大意是「上游不认这个登录了,去桌面端重新登录」。话说得没错,可问题是:它只说了结论,没解决过程——那几个账号明明已经废了,每次请求还是要挨个撞一遍,撞得头破血流,旁边几个好端端的账号一次都轮不上。
|
|
50
|
+
|
|
51
|
+
### 事情是怎么变成这样的
|
|
52
|
+
|
|
53
|
+
上游吊销一个登录状态时,**不会顺手把凭据文件里的过期时间改掉**。于是本机留着一份「看起来还有效」的凭据:过期时间写着 11 月 4 日,今天才 10 月,一切正常。
|
|
54
|
+
|
|
55
|
+
插件就信了。它把这三个账号当成健康账号,按顺序一个一个试:
|
|
56
|
+
|
|
57
|
+
1. 拿账号 A 去请求,上游回 401;
|
|
58
|
+
2. 插件想「可能是 token 过期了」,于是刷新 token;
|
|
59
|
+
3. 上游回答「这个 refresh token 早就没了」(`invalid_grant: Offline user session not found`);
|
|
60
|
+
4. 插件刷新失败,但**没有记仇**,转头又拿 A 去试;
|
|
61
|
+
5. 八次重试名额(`maxAttempts`)就这么全喂给了同一个死账号,健康账号一次都没被碰过。
|
|
62
|
+
|
|
63
|
+
所以现象是「明明还有能用的账号,请求却一直失败」,外加卡片上一片红。
|
|
64
|
+
|
|
65
|
+
### 现在怎么处理
|
|
66
|
+
|
|
67
|
+
给账号加了第三种状态:**判死**(前两种是「正常」和「限流冷却」)。
|
|
68
|
+
|
|
69
|
+
- **只有上游亲口说死才算死。** 判据是 `invalid_grant` / `Offline user session not found` 这类明确答复。网络超时、DNS 抽风、5xx 一律不算——否则一次网络抖动就会把好账号关进小黑屋半小时,那比原来的 bug 还难查。
|
|
70
|
+
- **判死的账号立刻退出轮换**,不再参与 `acquire` 的选择,请求当场换健康账号继续,不用等到八次重试耗尽。
|
|
71
|
+
- **想复活有两条路**:在 WorkBuddy 桌面端重新登录该账号(插件发现 refresh token 变了,自动放行),或者点卡片上的「清除所有冷却」手动再给它一次机会。
|
|
72
|
+
- **全池都死了的时候**,返回的不再是含含糊糊的「你还没登录」,而是 401 `session_dead`:`N/M WorkBuddy account(s) for this gateway were rejected by the upstream (their sign-in was revoked); sign in again in the WorkBuddy desktop app to restore them`。「等待」和「重新登录」是两种完全不同的处置方式,不能混着报。
|
|
73
|
+
|
|
74
|
+
### 卡片上的红字也收拾了一下
|
|
75
|
+
|
|
76
|
+
- 原来那段整段英文报错**直接铺在「积分包」面板里**,把面板撑得老长,右边的「合计」都被挤出屏幕。现在折叠成一个红色小标「**登录已被拒绝**」,鼠标悬停才显示原文。
|
|
77
|
+
- 账号行上加了红色的「登录已被拒绝」标签,下面补一句「请在 WorkBuddy 桌面端重新登录该账号以恢复」——红色是故意的,用琥珀色会让人以为「等一会儿就好」,而这个状态等多久都不会好。
|
|
78
|
+
- 判死的账号**不再参与卡片刷新时的积分 / 签到探测**。以前每刷新一次卡片,就对每个死账号白打两个必定 401 的请求,刷新越勤越热闹。
|
|
79
|
+
|
|
80
|
+
### 已知边界
|
|
81
|
+
|
|
82
|
+
- 判死状态记在内存里,重启后重新判定。也就是说重启后的第一次请求还会再试一次那个账号,然后才把它请出去——这一次的代价是必要的,否则「重启一下就好了」的临时故障会被误判成永久失效。
|
|
83
|
+
- 国际版每日签到活动(`checkin-activity-status`)实测当前是 `active: false`,所以「多账号自动领 30 积分」目前没有可领的东西;国内版的活动是正常的,逐账号领取的链路本来就有。
|
|
84
|
+
|
|
7
85
|
## 1.8.0 — 四种模式,终于排得像个样子了
|
|
8
86
|
|
|
9
87
|
> 上一版加了第四种模式「每对话固定」,功能是好的,排版是坏的。坏得很实在:格子还是按三个排的,第四个只好委屈地掉到下一行,占着半个身位,像个插班生。
|
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
|
@@ -2644,6 +2644,28 @@ function idleWeight(lastUsedAt, now) {
|
|
|
2644
2644
|
}
|
|
2645
2645
|
/** Default rest for an account whose credits ran out (packs reset on their own schedule). */
|
|
2646
2646
|
const EXHAUST_COOLDOWN_MS = 18e5;
|
|
2647
|
+
/**
|
|
2648
|
+
* Default rest for an account the upstream rejected the sign-in of (401/403).
|
|
2649
|
+
*
|
|
2650
|
+
* Unlike a rate limit there is no reset time to read: only signing in again in
|
|
2651
|
+
* the desktop app revives the account. The rest is therefore long enough that a
|
|
2652
|
+
* single request never burns its whole retry budget on the same dead credential,
|
|
2653
|
+
* but short enough that a user who just signed in is not locked out of their own
|
|
2654
|
+
* account for long — and a newer credential file clears the mark outright.
|
|
2655
|
+
*/
|
|
2656
|
+
const CREDENTIAL_DEAD_COOLDOWN_MS = 18e5;
|
|
2657
|
+
/**
|
|
2658
|
+
* Does this refresh failure prove the credential itself is dead?
|
|
2659
|
+
*
|
|
2660
|
+
* The upstream answers a revoked refresh token with `invalid_grant: Offline user
|
|
2661
|
+
* session not found` (HTTP 400/401). Network failures, DNS errors and 5xx do NOT
|
|
2662
|
+
* prove anything about the credential and must not mark it dead — that would
|
|
2663
|
+
* take a working account out of rotation for half an hour over a hiccup.
|
|
2664
|
+
*/
|
|
2665
|
+
function isCredentialDeadError(error) {
|
|
2666
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2667
|
+
return message.includes("invalid_grant") || message.includes("Offline user session not found") || message.includes("session_dead");
|
|
2668
|
+
}
|
|
2647
2669
|
var WorkBuddyAccountPool = class {
|
|
2648
2670
|
logger;
|
|
2649
2671
|
authDirs;
|
|
@@ -2654,6 +2676,11 @@ var WorkBuddyAccountPool = class {
|
|
|
2654
2676
|
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
2655
2677
|
*/
|
|
2656
2678
|
exhaustCooldownMs;
|
|
2679
|
+
/**
|
|
2680
|
+
* How long an account stays out of rotation after the upstream rejected its
|
|
2681
|
+
* sign-in. Cleared by a newer credential file or an explicit sign-in.
|
|
2682
|
+
*/
|
|
2683
|
+
credentialDeadCooldownMs;
|
|
2657
2684
|
client;
|
|
2658
2685
|
refreshMarginMs;
|
|
2659
2686
|
accounts = [];
|
|
@@ -2736,6 +2763,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2736
2763
|
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
2737
2764
|
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
2738
2765
|
this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
|
|
2766
|
+
this.credentialDeadCooldownMs = options.credentialDeadCooldownMs ?? CREDENTIAL_DEAD_COOLDOWN_MS;
|
|
2739
2767
|
this.client = options.client;
|
|
2740
2768
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
2741
2769
|
this.distribution = options.distribution ?? "priority";
|
|
@@ -2822,11 +2850,15 @@ var WorkBuddyAccountPool = class {
|
|
|
2822
2850
|
});
|
|
2823
2851
|
continue;
|
|
2824
2852
|
}
|
|
2825
|
-
if (isFresher(credential, existing.credential))
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2853
|
+
if (isFresher(credential, existing.credential)) {
|
|
2854
|
+
const signedInAgain = credential.refreshToken !== existing.credential.refreshToken || credential.accessToken !== existing.credential.accessToken;
|
|
2855
|
+
byId.set(id, {
|
|
2856
|
+
...existing,
|
|
2857
|
+
credential,
|
|
2858
|
+
label: accountLabel(credential),
|
|
2859
|
+
...signedInAgain ? { credentialDeadUntilMs: 0 } : {}
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2830
2862
|
}
|
|
2831
2863
|
const ordered = [...byId.values()];
|
|
2832
2864
|
ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
|
|
@@ -2857,6 +2889,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2857
2889
|
if (balance !== void 0 && balance <= reserve) return false;
|
|
2858
2890
|
}
|
|
2859
2891
|
if (account.cooldownUntilMs > now) return false;
|
|
2892
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) return false;
|
|
2860
2893
|
if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
|
|
2861
2894
|
if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
|
|
2862
2895
|
return true;
|
|
@@ -2882,15 +2915,21 @@ var WorkBuddyAccountPool = class {
|
|
|
2882
2915
|
total: 0,
|
|
2883
2916
|
cooling: 0,
|
|
2884
2917
|
disabled: 0,
|
|
2918
|
+
dead: 0,
|
|
2885
2919
|
reason: "empty"
|
|
2886
2920
|
};
|
|
2887
2921
|
let cooling = 0;
|
|
2888
2922
|
let disabled = 0;
|
|
2923
|
+
let dead = 0;
|
|
2889
2924
|
for (const account of inRegion) {
|
|
2890
2925
|
if (this.disabledIds.has(account.id)) {
|
|
2891
2926
|
disabled += 1;
|
|
2892
2927
|
continue;
|
|
2893
2928
|
}
|
|
2929
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) {
|
|
2930
|
+
dead += 1;
|
|
2931
|
+
continue;
|
|
2932
|
+
}
|
|
2894
2933
|
const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
|
|
2895
2934
|
if (account.cooldownUntilMs > now || modelCooling) {
|
|
2896
2935
|
cooling += 1;
|
|
@@ -2903,26 +2942,37 @@ var WorkBuddyAccountPool = class {
|
|
|
2903
2942
|
total: inRegion.length,
|
|
2904
2943
|
cooling,
|
|
2905
2944
|
disabled,
|
|
2945
|
+
dead,
|
|
2906
2946
|
reason: "reserve"
|
|
2907
2947
|
};
|
|
2908
2948
|
}
|
|
2909
2949
|
}
|
|
2950
|
+
if (dead > 0) return {
|
|
2951
|
+
total: inRegion.length,
|
|
2952
|
+
cooling,
|
|
2953
|
+
disabled,
|
|
2954
|
+
dead,
|
|
2955
|
+
reason: "session_dead"
|
|
2956
|
+
};
|
|
2910
2957
|
if (cooling > 0) return {
|
|
2911
2958
|
total: inRegion.length,
|
|
2912
2959
|
cooling,
|
|
2913
2960
|
disabled,
|
|
2961
|
+
dead,
|
|
2914
2962
|
reason: "cooling"
|
|
2915
2963
|
};
|
|
2916
2964
|
if (disabled > 0) return {
|
|
2917
2965
|
total: inRegion.length,
|
|
2918
2966
|
cooling,
|
|
2919
2967
|
disabled,
|
|
2968
|
+
dead,
|
|
2920
2969
|
reason: "disabled"
|
|
2921
2970
|
};
|
|
2922
2971
|
return {
|
|
2923
2972
|
total: inRegion.length,
|
|
2924
2973
|
cooling,
|
|
2925
2974
|
disabled,
|
|
2975
|
+
dead,
|
|
2926
2976
|
reason: "none"
|
|
2927
2977
|
};
|
|
2928
2978
|
}
|
|
@@ -3026,31 +3076,28 @@ var WorkBuddyAccountPool = class {
|
|
|
3026
3076
|
*/
|
|
3027
3077
|
async acquire(modelId, region, conversationKey) {
|
|
3028
3078
|
if (this.accounts.length === 0) await this.scan();
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
if (this.preferredId !== void 0) {
|
|
3036
|
-
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3037
|
-
if (preferred !== void 0) {
|
|
3038
|
-
await this.ensureFresh(preferred);
|
|
3039
|
-
return preferred;
|
|
3079
|
+
const maxTries = this.accounts.length + 1;
|
|
3080
|
+
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
3081
|
+
let pool = this.available(Date.now(), modelId, region);
|
|
3082
|
+
if (pool.length === 0 && attempt === 0) {
|
|
3083
|
+
await this.scan();
|
|
3084
|
+
pool = this.available(Date.now(), modelId, region);
|
|
3040
3085
|
}
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3086
|
+
if (pool.length === 0) return void 0;
|
|
3087
|
+
if (this.preferredId !== void 0) {
|
|
3088
|
+
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3089
|
+
if (preferred !== void 0 && await this.ensureFresh(preferred)) return preferred;
|
|
3090
|
+
}
|
|
3091
|
+
if (this.distribution === "sticky") {
|
|
3092
|
+
const bound = this.affinityAccount(pool, conversationKey);
|
|
3093
|
+
if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
|
|
3047
3094
|
}
|
|
3095
|
+
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3096
|
+
if (account === void 0) return void 0;
|
|
3097
|
+
if (!await this.ensureFresh(account)) continue;
|
|
3098
|
+
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3099
|
+
return account;
|
|
3048
3100
|
}
|
|
3049
|
-
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3050
|
-
if (account === void 0) return void 0;
|
|
3051
|
-
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3052
|
-
await this.ensureFresh(account);
|
|
3053
|
-
return account;
|
|
3054
3101
|
}
|
|
3055
3102
|
/** Pin the account the plugin card should prefer; tokens stay out of settings. */
|
|
3056
3103
|
/** How the pool currently spreads requests. Shown on the card. */
|
|
@@ -3154,31 +3201,68 @@ var WorkBuddyAccountPool = class {
|
|
|
3154
3201
|
return newest?.id;
|
|
3155
3202
|
}
|
|
3156
3203
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
3157
|
-
async refreshAccount(accountId) {
|
|
3204
|
+
async refreshAccount(accountId, options) {
|
|
3205
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3206
|
+
if (account === void 0) return false;
|
|
3207
|
+
return await this.ensureFresh(account, options?.force === true);
|
|
3208
|
+
}
|
|
3209
|
+
/**
|
|
3210
|
+
* Cool a whole account because the upstream REJECTED its sign-in (401/403).
|
|
3211
|
+
*
|
|
3212
|
+
* Called with direct evidence (the request just came back `session_dead`), so
|
|
3213
|
+
* it does not need to guess: without this, the next retry of the SAME request
|
|
3214
|
+
* picks the same account again — its credential file still claims to be valid
|
|
3215
|
+
* — and a request with 8 attempts spends all 8 on one dead account while
|
|
3216
|
+
* healthy accounts are never tried.
|
|
3217
|
+
*/
|
|
3218
|
+
penalizeCredentialDead(accountId) {
|
|
3158
3219
|
const account = this.accounts.find((item) => item.id === accountId);
|
|
3159
3220
|
if (account === void 0) return;
|
|
3160
|
-
|
|
3221
|
+
const until = Date.now() + this.credentialDeadCooldownMs;
|
|
3222
|
+
account.credentialDeadUntilMs = Math.max(account.credentialDeadUntilMs ?? 0, until);
|
|
3223
|
+
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in was rejected by the upstream; keeping it out of rotation until ${new Date(until).toISOString()} (sign in again to restore it)`);
|
|
3224
|
+
}
|
|
3225
|
+
/** Put an account back in rotation after its sign-in was proven good again. */
|
|
3226
|
+
clearCredentialDead(accountId) {
|
|
3227
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3228
|
+
if (account === void 0) return;
|
|
3229
|
+
account.credentialDeadUntilMs = 0;
|
|
3230
|
+
}
|
|
3231
|
+
/** Accounts currently kept out of rotation because their sign-in was rejected. */
|
|
3232
|
+
deadCredentials() {
|
|
3233
|
+
const now = Date.now();
|
|
3234
|
+
return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
|
|
3161
3235
|
}
|
|
3162
3236
|
/**
|
|
3163
3237
|
* Refresh the account's access token when it is within the margin (or already
|
|
3164
3238
|
* expired), in-flight de-duped per account. A failed refresh keeps the
|
|
3165
3239
|
* existing token when it has not yet expired, so an unreachable refresh
|
|
3166
3240
|
* endpoint never takes down a working session.
|
|
3241
|
+
*
|
|
3242
|
+
* `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
|
|
3243
|
+
* not a correctness rule: it exists so a healthy token is not re-fetched on
|
|
3244
|
+
* every acquire. After the upstream has ALREADY rejected the token, the gate
|
|
3245
|
+
* is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
|
|
3246
|
+
* caller needs the refresh attempted so the credential can be proven dead.
|
|
3247
|
+
*
|
|
3248
|
+
* Returns true when the token is known good afterwards (refreshed, or still
|
|
3249
|
+
* valid), false when the credential was proven dead.
|
|
3167
3250
|
*/
|
|
3168
|
-
async ensureFresh(account) {
|
|
3169
|
-
if (this.client === void 0) return;
|
|
3251
|
+
async ensureFresh(account, force = false) {
|
|
3252
|
+
if (this.client === void 0) return true;
|
|
3170
3253
|
const credential = account.credential;
|
|
3171
|
-
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs)) return;
|
|
3254
|
+
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs) && !force) return true;
|
|
3172
3255
|
const existing = this.refreshInflight.get(account.id);
|
|
3173
3256
|
if (existing !== void 0) {
|
|
3174
3257
|
await existing;
|
|
3175
|
-
return;
|
|
3258
|
+
return (account.credentialDeadUntilMs ?? 0) <= Date.now();
|
|
3176
3259
|
}
|
|
3177
3260
|
const run = (async () => {
|
|
3178
3261
|
if (credential.refreshToken === "") {
|
|
3179
|
-
if (credential.expiresAtMs > Date.now() + 3e4) return;
|
|
3262
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return true;
|
|
3180
3263
|
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} token expired with no refresh token; sign in again`);
|
|
3181
|
-
|
|
3264
|
+
this.penalizeCredentialDead(account.id);
|
|
3265
|
+
return false;
|
|
3182
3266
|
}
|
|
3183
3267
|
try {
|
|
3184
3268
|
const outcome = await this.client.refreshToken(credential);
|
|
@@ -3189,15 +3273,23 @@ var WorkBuddyAccountPool = class {
|
|
|
3189
3273
|
expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
|
|
3190
3274
|
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain }
|
|
3191
3275
|
};
|
|
3276
|
+
this.clearCredentialDead(account.id);
|
|
3192
3277
|
this.logger?.info?.(`dsh-workbuddy-xdpool: refreshed token for ${account.label}`);
|
|
3278
|
+
return true;
|
|
3193
3279
|
} catch (error) {
|
|
3280
|
+
if (isCredentialDeadError(error)) {
|
|
3281
|
+
this.penalizeCredentialDead(account.id);
|
|
3282
|
+
this.logger?.warn?.(`dsh-workbuddy-xdpool: ${account.label} refresh token was revoked upstream; sign in again to restore it`, error);
|
|
3283
|
+
return false;
|
|
3284
|
+
}
|
|
3194
3285
|
if (credential.expiresAtMs > Date.now() + 3e4) this.logger?.warn?.(`dsh-workbuddy-xdpool: token refresh failed but token still valid for ${account.label}`, error);
|
|
3195
3286
|
else this.logger?.error?.(`dsh-workbuddy-xdpool: token refresh failed and token expired for ${account.label}`, error);
|
|
3287
|
+
return true;
|
|
3196
3288
|
}
|
|
3197
3289
|
})();
|
|
3198
3290
|
this.refreshInflight.set(account.id, run);
|
|
3199
3291
|
try {
|
|
3200
|
-
await run;
|
|
3292
|
+
return await run;
|
|
3201
3293
|
} finally {
|
|
3202
3294
|
this.refreshInflight.delete(account.id);
|
|
3203
3295
|
}
|
|
@@ -3246,6 +3338,7 @@ ${new Date(until).toISOString()}
|
|
|
3246
3338
|
account.cooldownUntilMs = 0;
|
|
3247
3339
|
account.modelCooldowns = {};
|
|
3248
3340
|
account.rateLimitHits = 0;
|
|
3341
|
+
account.credentialDeadUntilMs = 0;
|
|
3249
3342
|
}
|
|
3250
3343
|
}
|
|
3251
3344
|
/** Diagnostics snapshot. Account-wide cooling count (per-model cooling excluded:
|
|
@@ -3255,6 +3348,7 @@ ${new Date(until).toISOString()}
|
|
|
3255
3348
|
return {
|
|
3256
3349
|
count: this.accounts.length,
|
|
3257
3350
|
cooling: this.accounts.filter((account) => account.cooldownUntilMs > now).length,
|
|
3351
|
+
dead: this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now).length,
|
|
3258
3352
|
lastScanAtMs: this.lastScanAtMs
|
|
3259
3353
|
};
|
|
3260
3354
|
}
|
package/lib/client.js
CHANGED
|
@@ -249,9 +249,17 @@ window.__ModuleLoader__.load({
|
|
|
249
249
|
.dsm-workbuddy-xdpool-account-tag{padding:1px 8px;border-radius:999px;font-size:11px;line-height:18px;background:var(--dsw-alias-state-success-subtle,rgba(34,160,107,.12));color:var(--dsw-alias-state-success-primary,#22a06b)}
|
|
250
250
|
.dsm-workbuddy-xdpool-account-tag-cooling{background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.15));color:var(--dsw-alias-state-warning-primary,#d97706)}
|
|
251
251
|
.dsm-workbuddy-xdpool-account-tag-error{background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));color:var(--dsw-alias-state-error-primary,#ef4444)}
|
|
252
|
+
/* A rejected sign-in is red, not amber: unlike a cooldown it will not clear by
|
|
253
|
+
waiting, and it is the one state where the user has to go do something. */
|
|
254
|
+
.dsm-workbuddy-xdpool-account-tag-dead{background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));color:var(--dsw-alias-state-error-primary,#ef4444)}
|
|
252
255
|
.dsm-workbuddy-xdpool-account-meta{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px;display:flex;flex-wrap:wrap;gap:10px}
|
|
256
|
+
.dsm-workbuddy-xdpool-account-meta-dead{color:var(--dsw-alias-state-error-primary,#ef4444)}
|
|
253
257
|
.dsm-workbuddy-xdpool-account-modelcool{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}
|
|
254
258
|
.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)}
|
|
259
|
+
/* "→ deepseek-v4.1-flash-sg (0.03x) still works" inside a cooling chip. Keeps
|
|
260
|
+
the warning tint of its parent but drops the weight: it is the way out, not
|
|
261
|
+
a second problem. */
|
|
262
|
+
.dsm-workbuddy-xdpool-account-modelcool-twin{color:var(--dsw-alias-label-secondary,#c6c9d0);cursor:help}
|
|
255
263
|
.dsm-workbuddy-xdpool-account-error{margin:0;color:var(--dsw-alias-state-error-primary,#ef4444);font-size:13px;line-height:20px}
|
|
256
264
|
|
|
257
265
|
/* Two-column stats: packages on the left, total + check-in on the right. */
|
|
@@ -260,6 +268,9 @@ window.__ModuleLoader__.load({
|
|
|
260
268
|
.dsm-workbuddy-xdpool-panel-title{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;letter-spacing:.03em;text-transform:uppercase;font-weight:600}
|
|
261
269
|
.dsm-workbuddy-xdpool-panel-empty{color:var(--dsw-alias-label-tertiary,#999);font-size:14px;line-height:20px}
|
|
262
270
|
.dsm-workbuddy-xdpool-panel-error{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:12px;line-height:18px;word-break:break-word}
|
|
271
|
+
/* A rejected sign-in gets a compact chip: the upstream paragraph is long, and
|
|
272
|
+
printed inline it pushed the neighbouring "Total" panel off screen. */
|
|
273
|
+
.dsm-workbuddy-xdpool-panel-error-dead{display:inline-flex;align-items:center;align-self:flex-start;padding:1px 8px;border-radius:999px;background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));font-size:11px;line-height:18px;white-space:nowrap;cursor:help}
|
|
263
274
|
.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}
|
|
264
275
|
.dsm-workbuddy-xdpool-panel-foot strong{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:15px;font-variant-numeric:tabular-nums}
|
|
265
276
|
.dsm-workbuddy-xdpool-packages{display:flex;flex-direction:column;gap:5px;margin:0;padding:0;list-style:none}
|
|
@@ -300,6 +311,10 @@ window.__ModuleLoader__.load({
|
|
|
300
311
|
/* "活动至 10-31": the campaign's end date. Muted — it is context for planning,
|
|
301
312
|
not a claim about the current price. */
|
|
302
313
|
.dsm-workbuddy-xdpool-model-meta-promo{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px;opacity:.85}
|
|
314
|
+
/* "付费同款 deepseek-v4.1-flash-sg (0.03x)": the way out when the free row is
|
|
315
|
+
rate-limited. Muted like the other meta chips — it is an alternative, not a
|
|
316
|
+
warning, and the row it sits on is still the free one. */
|
|
317
|
+
.dsm-workbuddy-xdpool-model-meta-twin{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px;opacity:.85;cursor:help}
|
|
303
318
|
/* Unreadable credential files: a warning tint, since it explains a smaller pool
|
|
304
319
|
and "encrypted" is actionable (start the app once). */
|
|
305
320
|
.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}
|
|
@@ -503,6 +518,43 @@ window.__ModuleLoader__.load({
|
|
|
503
518
|
return status?.kind === "free" || status?.kind === "night";
|
|
504
519
|
}
|
|
505
520
|
//#endregion
|
|
521
|
+
//#region src/siblings.ts
|
|
522
|
+
/**
|
|
523
|
+
* The cheapest PAID model sharing `model`'s display name, or undefined.
|
|
524
|
+
*
|
|
525
|
+
* Cheapest rather than first, because the point of the hint is "there is a
|
|
526
|
+
* cheap way out": offering the most expensive twin would make switching look
|
|
527
|
+
* like a trap. A sibling whose price is unknown is skipped — an unpriced row
|
|
528
|
+
* cannot be promised as "the paid one".
|
|
529
|
+
*/
|
|
530
|
+
function paidSiblingOf(model, models) {
|
|
531
|
+
let best;
|
|
532
|
+
for (const candidate of models) {
|
|
533
|
+
if (candidate.id === model.id) continue;
|
|
534
|
+
if (candidate.name !== model.name) continue;
|
|
535
|
+
const multiplier = candidate.multiplier;
|
|
536
|
+
if (multiplier === void 0 || multiplier <= 0) continue;
|
|
537
|
+
if (best === void 0 || multiplier < best.multiplier) best = {
|
|
538
|
+
id: candidate.id,
|
|
539
|
+
name: candidate.name,
|
|
540
|
+
multiplier
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return best;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The paid sibling of a model, but ONLY when the model itself is free.
|
|
547
|
+
*
|
|
548
|
+
* The guard is the whole point: a `x0.79` row with a `x0.06` cousin is not a
|
|
549
|
+
* "free model with a paid twin", it is just a more expensive row, and labelling
|
|
550
|
+
* it would be noise on most of the list. The hint answers one question — "the
|
|
551
|
+
* free one is limited, what now".
|
|
552
|
+
*/
|
|
553
|
+
function paidAlternativeFor(model, models) {
|
|
554
|
+
if (model.multiplier !== 0) return void 0;
|
|
555
|
+
return paidSiblingOf(model, models);
|
|
556
|
+
}
|
|
557
|
+
//#endregion
|
|
506
558
|
//#region src/client/PoolCard.tsx
|
|
507
559
|
/**
|
|
508
560
|
* WorkBuddy XD Pool card contributed to DSH Plugin configuration.
|
|
@@ -1610,6 +1662,7 @@ window.__ModuleLoader__.load({
|
|
|
1610
1662
|
})(),
|
|
1611
1663
|
status?.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountBlock, {
|
|
1612
1664
|
account,
|
|
1665
|
+
models: status?.models ?? [],
|
|
1613
1666
|
...checkinBusyId === void 0 ? {} : { checkinBusyId },
|
|
1614
1667
|
onClaimCheckin: (accountId) => {
|
|
1615
1668
|
claimCheckin(accountId);
|
|
@@ -1716,6 +1769,7 @@ window.__ModuleLoader__.load({
|
|
|
1716
1769
|
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, {
|
|
1717
1770
|
model,
|
|
1718
1771
|
region: activeRegion,
|
|
1772
|
+
paidTwin: paidAlternativeFor(model, status?.models ?? []),
|
|
1719
1773
|
t,
|
|
1720
1774
|
draft: modelDraft[model.id] ?? {
|
|
1721
1775
|
enabled: model.enabled,
|
|
@@ -1732,12 +1786,16 @@ window.__ModuleLoader__.load({
|
|
|
1732
1786
|
});
|
|
1733
1787
|
}
|
|
1734
1788
|
/** One account block: label + status tag + meta + optional credit panels. */
|
|
1735
|
-
function AccountBlock({ account, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled, onIgnoreAccount, onSaveCreditReserve, reserveBusyId }) {
|
|
1789
|
+
function AccountBlock({ account, models, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled, onIgnoreAccount, onSaveCreditReserve, reserveBusyId }) {
|
|
1736
1790
|
const isDisabled = account.disabled === true;
|
|
1737
1791
|
const isCooling = account.cooling === true;
|
|
1792
|
+
const isDead = account.credentialDead === true;
|
|
1738
1793
|
const cooldownUntil = account.cooldownUntil !== void 0 ? Date.parse(account.cooldownUntil) : void 0;
|
|
1739
1794
|
const modelCooldowns = account.modelCooldowns ?? [];
|
|
1740
|
-
const tag =
|
|
1795
|
+
const tag = isDead ? {
|
|
1796
|
+
text: t?.("row.credentialDeadTag") ?? "Sign-in rejected",
|
|
1797
|
+
cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-dead"
|
|
1798
|
+
} : isCooling ? {
|
|
1741
1799
|
text: t?.("row.cooling") ?? "Cooling",
|
|
1742
1800
|
cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-cooling"
|
|
1743
1801
|
} : null;
|
|
@@ -1795,15 +1853,30 @@ window.__ModuleLoader__.load({
|
|
|
1795
1853
|
t?.("row.cooldownHits", { hits: account.rateLimitHits ?? 0 }) ?? `${account.rateLimitHits ?? 0} hit(s)`
|
|
1796
1854
|
]
|
|
1797
1855
|
}) : null,
|
|
1856
|
+
isDead ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1857
|
+
className: "dsm-workbuddy-xdpool-account-meta dsm-workbuddy-xdpool-account-meta-dead",
|
|
1858
|
+
children: t?.("row.credentialDeadHint") ?? "Sign in again in the WorkBuddy desktop app to restore it"
|
|
1859
|
+
}) : null,
|
|
1798
1860
|
modelCooldowns.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1799
1861
|
className: "dsm-workbuddy-xdpool-account-modelcool",
|
|
1800
|
-
children: modelCooldowns.map((mc) =>
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1862
|
+
children: modelCooldowns.map((mc) => {
|
|
1863
|
+
const coolingModel = models.find((model) => model.id === mc.modelId);
|
|
1864
|
+
const twin = coolingModel === void 0 ? void 0 : paidAlternativeFor(coolingModel, models);
|
|
1865
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1866
|
+
className: "dsm-workbuddy-xdpool-account-modelcool-chip",
|
|
1867
|
+
children: [t?.("row.modelCooling", {
|
|
1868
|
+
model: mc.modelId,
|
|
1869
|
+
time: formatDateTime(mc.until)
|
|
1870
|
+
}) ?? `${mc.modelId} cooling to ${formatDateTime(mc.until)}`, twin === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1871
|
+
className: "dsm-workbuddy-xdpool-account-modelcool-twin",
|
|
1872
|
+
title: t?.("row.paidTwinHint") ?? "Same model, paid row",
|
|
1873
|
+
children: t?.("row.rateLimitedPaidTwin", {
|
|
1874
|
+
id: twin.id,
|
|
1875
|
+
rate: twin.multiplier.toFixed(2)
|
|
1876
|
+
}) ?? `→ ${twin.id} (${twin.multiplier.toFixed(2)}x) still works`
|
|
1877
|
+
})]
|
|
1878
|
+
}, mc.modelId);
|
|
1879
|
+
})
|
|
1807
1880
|
}) : null
|
|
1808
1881
|
]
|
|
1809
1882
|
}),
|
|
@@ -1976,8 +2049,9 @@ window.__ModuleLoader__.load({
|
|
|
1976
2049
|
children: t?.("row.creditsPackages") ?? "Credit packages"
|
|
1977
2050
|
}),
|
|
1978
2051
|
account.creditsError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1979
|
-
className: "dsm-workbuddy-xdpool-panel-error"
|
|
1980
|
-
|
|
2052
|
+
className: `dsm-workbuddy-xdpool-panel-error${account.credentialDead === true ? " dsm-workbuddy-xdpool-panel-error-dead" : ""}`,
|
|
2053
|
+
title: account.creditsError,
|
|
2054
|
+
children: account.credentialDead === true ? t?.("row.credentialDead") ?? "Sign-in rejected" : t?.("row.creditsError") ?? "credits unavailable"
|
|
1981
2055
|
}) : packages.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1982
2056
|
className: "dsm-workbuddy-xdpool-panel-empty",
|
|
1983
2057
|
children: "–"
|
|
@@ -2069,7 +2143,7 @@ window.__ModuleLoader__.load({
|
|
|
2069
2143
|
* context radios stay disabled rather than pretending an edit took hold. The
|
|
2070
2144
|
* draft lives in the parent, so this component only ever reports intent.
|
|
2071
2145
|
*/
|
|
2072
|
-
function ModelRow({ model, region, t, draft, editable, onToggle, onToggleImage, onBudget }) {
|
|
2146
|
+
function ModelRow({ model, region, paidTwin, t, draft, editable, onToggle, onToggleImage, onBudget }) {
|
|
2073
2147
|
const now = /* @__PURE__ */ new Date();
|
|
2074
2148
|
const tag = tagFor(model, now, region);
|
|
2075
2149
|
const promo = promoStatusFor(model, now, region);
|
|
@@ -2168,6 +2242,14 @@ window.__ModuleLoader__.load({
|
|
|
2168
2242
|
className: "dsm-workbuddy-xdpool-model-meta-promo",
|
|
2169
2243
|
children: promoUntil
|
|
2170
2244
|
}),
|
|
2245
|
+
paidTwin === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2246
|
+
className: "dsm-workbuddy-xdpool-model-meta-twin",
|
|
2247
|
+
title: t?.("row.paidTwinHint") ?? "Same model, paid row",
|
|
2248
|
+
children: t?.("row.paidTwin", {
|
|
2249
|
+
id: paidTwin.id,
|
|
2250
|
+
rate: paidTwin.multiplier.toFixed(2)
|
|
2251
|
+
}) ?? `paid twin ${paidTwin.id} (${paidTwin.multiplier.toFixed(2)}x)`
|
|
2252
|
+
}),
|
|
2171
2253
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2172
2254
|
className: "dsm-workbuddy-xdpool-model-cap",
|
|
2173
2255
|
children: t?.("row.modelOutput", { size: formatCapacity(model.maxOutputTokens) }) ?? `out ${formatCapacity(model.maxOutputTokens)}`
|
|
@@ -2282,6 +2364,9 @@ window.__ModuleLoader__.load({
|
|
|
2282
2364
|
"row.creditsPackages": "Credit packages",
|
|
2283
2365
|
"row.creditsPackage": "{remain} / {size}",
|
|
2284
2366
|
"row.creditsError": "credits unavailable",
|
|
2367
|
+
"row.credentialDead": "Sign-in rejected",
|
|
2368
|
+
"row.credentialDeadTag": "Sign-in rejected",
|
|
2369
|
+
"row.credentialDeadHint": "The upstream no longer accepts this sign-in. Sign in again in the WorkBuddy desktop app to restore it.",
|
|
2285
2370
|
"row.creditsSoon": "Expiring in 3 days",
|
|
2286
2371
|
"row.creditsExpiresAt": "Expires {time}",
|
|
2287
2372
|
"row.creditsExpiresSoonTitle": "Expiring within 3 days",
|
|
@@ -2326,6 +2411,9 @@ window.__ModuleLoader__.load({
|
|
|
2326
2411
|
"row.nightFreeNow": "free until {time}",
|
|
2327
2412
|
"row.nightFreeLater": "free from {time}",
|
|
2328
2413
|
"row.promoUntil": "promo to {date}",
|
|
2414
|
+
"row.paidTwin": "paid twin {id} ({rate}x)",
|
|
2415
|
+
"row.paidTwinHint": "The free tier is rate-limited first; this same model has a paid row that keeps working",
|
|
2416
|
+
"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",
|
|
2329
2417
|
"row.skippedFiles": "{count} credential file(s) could not be read",
|
|
2330
2418
|
"row.skippedEncrypted": "encrypted — start WorkBuddy once so its key can be read",
|
|
2331
2419
|
"row.skippedUnreadable": "file could not be read",
|
|
@@ -2437,6 +2525,9 @@ window.__ModuleLoader__.load({
|
|
|
2437
2525
|
"row.creditsPackages": "积分包",
|
|
2438
2526
|
"row.creditsPackage": "{remain} / {size}",
|
|
2439
2527
|
"row.creditsError": "积分不可用",
|
|
2528
|
+
"row.credentialDead": "登录已被拒绝",
|
|
2529
|
+
"row.credentialDeadTag": "登录已被拒绝",
|
|
2530
|
+
"row.credentialDeadHint": "上游已不再接受这个登录状态。请在 WorkBuddy 桌面端重新登录该账号以恢复。",
|
|
2440
2531
|
"row.creditsSoon": "3 天内到期",
|
|
2441
2532
|
"row.creditsExpiresAt": "到期 {time}",
|
|
2442
2533
|
"row.creditsExpiresSoonTitle": "3 天内到期",
|
|
@@ -2481,6 +2572,9 @@ window.__ModuleLoader__.load({
|
|
|
2481
2572
|
"row.nightFreeNow": "免费至 {time}",
|
|
2482
2573
|
"row.nightFreeLater": "{time} 起免费",
|
|
2483
2574
|
"row.promoUntil": "活动至 {date}",
|
|
2575
|
+
"row.paidTwin": "付费同款 {id}({rate}x)",
|
|
2576
|
+
"row.paidTwinHint": "免费档先被限流;同一个模型的付费行不受这个限制影响",
|
|
2577
|
+
"row.rateLimitedPaidTwin": "提示:{id} 是同一个模型的付费行({rate}x 积分),限流是分开算的 —— 切过去就能继续用",
|
|
2484
2578
|
"row.skippedFiles": "有 {count} 个凭据文件读不出来",
|
|
2485
2579
|
"row.skippedEncrypted": "已加密 —— 启动一次 WorkBuddy 才能取到密钥",
|
|
2486
2580
|
"row.skippedUnreadable": "文件读不出来",
|
package/lib/index.d.ts
CHANGED
|
@@ -810,6 +810,20 @@ interface WorkBuddyAccount {
|
|
|
810
810
|
modelCooldowns: Record<string, number>;
|
|
811
811
|
/** Consecutive rate-limit hits, for diagnostics. */
|
|
812
812
|
rateLimitHits: number;
|
|
813
|
+
/**
|
|
814
|
+
* Epoch ms until which this account is skipped because the upstream REJECTED
|
|
815
|
+
* its sign-in (401/403), not because of a rate limit.
|
|
816
|
+
*
|
|
817
|
+
* A revoked session is invisible from the credential file: the upstream does
|
|
818
|
+
* not rewrite `expiresAtMs` when it kills a refresh token, so a dead account
|
|
819
|
+
* looks perfectly fresh and, without this field, gets picked again on the very
|
|
820
|
+
* next attempt. With `maxAttempts` retries that meant one dead account could
|
|
821
|
+
* absorb an entire request while healthy accounts were never tried.
|
|
822
|
+
*
|
|
823
|
+
* Only a fresh sign-in in the desktop app clears it, so the cooldown is long;
|
|
824
|
+
* `0`/undefined means "not known to be dead".
|
|
825
|
+
*/
|
|
826
|
+
credentialDeadUntilMs?: number;
|
|
813
827
|
}
|
|
814
828
|
/**
|
|
815
829
|
* Platform-default directories holding the desktop app's auth files.
|
|
@@ -845,6 +859,11 @@ interface AccountPoolOptions {
|
|
|
845
859
|
cooldownMs?: number;
|
|
846
860
|
/** How long an account rests after its credits run out (default 30 minutes). */
|
|
847
861
|
exhaustCooldownMs?: number;
|
|
862
|
+
/**
|
|
863
|
+
* How long an account rests after the upstream rejects its sign-in
|
|
864
|
+
* (default 30 minutes). Only a fresh sign-in in the desktop app clears it.
|
|
865
|
+
*/
|
|
866
|
+
credentialDeadCooldownMs?: number;
|
|
848
867
|
/** Upstream client used to refresh near-expiry tokens. */
|
|
849
868
|
client?: TokenRefresher;
|
|
850
869
|
/** Refresh this long before actual expiry; default five minutes. */
|
|
@@ -874,6 +893,11 @@ export declare class WorkBuddyAccountPool {
|
|
|
874
893
|
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
875
894
|
*/
|
|
876
895
|
private exhaustCooldownMs;
|
|
896
|
+
/**
|
|
897
|
+
* How long an account stays out of rotation after the upstream rejected its
|
|
898
|
+
* sign-in. Cleared by a newer credential file or an explicit sign-in.
|
|
899
|
+
*/
|
|
900
|
+
private credentialDeadCooldownMs;
|
|
877
901
|
private readonly client;
|
|
878
902
|
private readonly refreshMarginMs;
|
|
879
903
|
private accounts;
|
|
@@ -1018,7 +1042,9 @@ export declare class WorkBuddyAccountPool {
|
|
|
1018
1042
|
total: number;
|
|
1019
1043
|
cooling: number;
|
|
1020
1044
|
disabled: number;
|
|
1021
|
-
|
|
1045
|
+
/** Accounts skipped because the upstream rejected their sign-in. */
|
|
1046
|
+
dead: number;
|
|
1047
|
+
reason: 'empty' | 'cooling' | 'disabled' | 'reserve' | 'session_dead' | 'none';
|
|
1022
1048
|
};
|
|
1023
1049
|
/** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
|
|
1024
1050
|
private pickRoundRobin;
|
|
@@ -1135,12 +1161,37 @@ export declare class WorkBuddyAccountPool {
|
|
|
1135
1161
|
*/
|
|
1136
1162
|
lastServedId(): string | undefined;
|
|
1137
1163
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
1138
|
-
refreshAccount(accountId: string
|
|
1164
|
+
refreshAccount(accountId: string, options?: {
|
|
1165
|
+
force?: boolean;
|
|
1166
|
+
}): Promise<boolean>;
|
|
1167
|
+
/**
|
|
1168
|
+
* Cool a whole account because the upstream REJECTED its sign-in (401/403).
|
|
1169
|
+
*
|
|
1170
|
+
* Called with direct evidence (the request just came back `session_dead`), so
|
|
1171
|
+
* it does not need to guess: without this, the next retry of the SAME request
|
|
1172
|
+
* picks the same account again — its credential file still claims to be valid
|
|
1173
|
+
* — and a request with 8 attempts spends all 8 on one dead account while
|
|
1174
|
+
* healthy accounts are never tried.
|
|
1175
|
+
*/
|
|
1176
|
+
penalizeCredentialDead(accountId: string): void;
|
|
1177
|
+
/** Put an account back in rotation after its sign-in was proven good again. */
|
|
1178
|
+
clearCredentialDead(accountId: string): void;
|
|
1179
|
+
/** Accounts currently kept out of rotation because their sign-in was rejected. */
|
|
1180
|
+
deadCredentials(): readonly WorkBuddyAccount[];
|
|
1139
1181
|
/**
|
|
1140
1182
|
* Refresh the account's access token when it is within the margin (or already
|
|
1141
1183
|
* expired), in-flight de-duped per account. A failed refresh keeps the
|
|
1142
1184
|
* existing token when it has not yet expired, so an unreachable refresh
|
|
1143
1185
|
* endpoint never takes down a working session.
|
|
1186
|
+
*
|
|
1187
|
+
* `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
|
|
1188
|
+
* not a correctness rule: it exists so a healthy token is not re-fetched on
|
|
1189
|
+
* every acquire. After the upstream has ALREADY rejected the token, the gate
|
|
1190
|
+
* is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
|
|
1191
|
+
* caller needs the refresh attempted so the credential can be proven dead.
|
|
1192
|
+
*
|
|
1193
|
+
* Returns true when the token is known good afterwards (refreshed, or still
|
|
1194
|
+
* valid), false when the credential was proven dead.
|
|
1144
1195
|
*/
|
|
1145
1196
|
private ensureFresh;
|
|
1146
1197
|
/**
|
|
@@ -1168,6 +1219,7 @@ export declare class WorkBuddyAccountPool {
|
|
|
1168
1219
|
status(): {
|
|
1169
1220
|
count: number;
|
|
1170
1221
|
cooling: number;
|
|
1222
|
+
dead: number;
|
|
1171
1223
|
lastScanAtMs: number;
|
|
1172
1224
|
};
|
|
1173
1225
|
}
|
|
@@ -1218,6 +1270,15 @@ interface PoolWebAccount {
|
|
|
1218
1270
|
cooling: boolean;
|
|
1219
1271
|
/** ISO timestamp when the account-wide 429 cooldown lifts; only while cooling. */
|
|
1220
1272
|
cooldownUntil?: string;
|
|
1273
|
+
/**
|
|
1274
|
+
* The upstream REJECTED this account's sign-in (401/403) — the credential file
|
|
1275
|
+
* still looks valid because the upstream never rewrites its expiry when it
|
|
1276
|
+
* revokes a token. Distinct from `cooling`: waiting does not fix it, only
|
|
1277
|
+
* signing in again in the desktop app does.
|
|
1278
|
+
*/
|
|
1279
|
+
credentialDead?: boolean;
|
|
1280
|
+
/** ISO timestamp when the dead mark expires and the account is retried; only while `credentialDead`. */
|
|
1281
|
+
credentialDeadUntil?: string;
|
|
1221
1282
|
/**
|
|
1222
1283
|
* Per-model cooldowns currently active. The account is NOT `cooling` while a
|
|
1223
1284
|
* model is limited — its other models still serve — but each entry tells the
|
package/lib/index.js
CHANGED
|
@@ -2752,6 +2752,28 @@ function idleWeight(lastUsedAt, now) {
|
|
|
2752
2752
|
}
|
|
2753
2753
|
/** Default rest for an account whose credits ran out (packs reset on their own schedule). */
|
|
2754
2754
|
const EXHAUST_COOLDOWN_MS = 18e5;
|
|
2755
|
+
/**
|
|
2756
|
+
* Default rest for an account the upstream rejected the sign-in of (401/403).
|
|
2757
|
+
*
|
|
2758
|
+
* Unlike a rate limit there is no reset time to read: only signing in again in
|
|
2759
|
+
* the desktop app revives the account. The rest is therefore long enough that a
|
|
2760
|
+
* single request never burns its whole retry budget on the same dead credential,
|
|
2761
|
+
* but short enough that a user who just signed in is not locked out of their own
|
|
2762
|
+
* account for long — and a newer credential file clears the mark outright.
|
|
2763
|
+
*/
|
|
2764
|
+
const CREDENTIAL_DEAD_COOLDOWN_MS = 18e5;
|
|
2765
|
+
/**
|
|
2766
|
+
* Does this refresh failure prove the credential itself is dead?
|
|
2767
|
+
*
|
|
2768
|
+
* The upstream answers a revoked refresh token with `invalid_grant: Offline user
|
|
2769
|
+
* session not found` (HTTP 400/401). Network failures, DNS errors and 5xx do NOT
|
|
2770
|
+
* prove anything about the credential and must not mark it dead — that would
|
|
2771
|
+
* take a working account out of rotation for half an hour over a hiccup.
|
|
2772
|
+
*/
|
|
2773
|
+
function isCredentialDeadError(error) {
|
|
2774
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2775
|
+
return message.includes("invalid_grant") || message.includes("Offline user session not found") || message.includes("session_dead");
|
|
2776
|
+
}
|
|
2755
2777
|
var WorkBuddyAccountPool = class {
|
|
2756
2778
|
logger;
|
|
2757
2779
|
authDirs;
|
|
@@ -2762,6 +2784,11 @@ var WorkBuddyAccountPool = class {
|
|
|
2762
2784
|
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
2763
2785
|
*/
|
|
2764
2786
|
exhaustCooldownMs;
|
|
2787
|
+
/**
|
|
2788
|
+
* How long an account stays out of rotation after the upstream rejected its
|
|
2789
|
+
* sign-in. Cleared by a newer credential file or an explicit sign-in.
|
|
2790
|
+
*/
|
|
2791
|
+
credentialDeadCooldownMs;
|
|
2765
2792
|
client;
|
|
2766
2793
|
refreshMarginMs;
|
|
2767
2794
|
accounts = [];
|
|
@@ -2844,6 +2871,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2844
2871
|
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
2845
2872
|
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
2846
2873
|
this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
|
|
2874
|
+
this.credentialDeadCooldownMs = options.credentialDeadCooldownMs ?? CREDENTIAL_DEAD_COOLDOWN_MS;
|
|
2847
2875
|
this.client = options.client;
|
|
2848
2876
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
2849
2877
|
this.distribution = options.distribution ?? "priority";
|
|
@@ -2930,11 +2958,15 @@ var WorkBuddyAccountPool = class {
|
|
|
2930
2958
|
});
|
|
2931
2959
|
continue;
|
|
2932
2960
|
}
|
|
2933
|
-
if (isFresher(credential, existing.credential))
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2961
|
+
if (isFresher(credential, existing.credential)) {
|
|
2962
|
+
const signedInAgain = credential.refreshToken !== existing.credential.refreshToken || credential.accessToken !== existing.credential.accessToken;
|
|
2963
|
+
byId.set(id, {
|
|
2964
|
+
...existing,
|
|
2965
|
+
credential,
|
|
2966
|
+
label: accountLabel(credential),
|
|
2967
|
+
...signedInAgain ? { credentialDeadUntilMs: 0 } : {}
|
|
2968
|
+
});
|
|
2969
|
+
}
|
|
2938
2970
|
}
|
|
2939
2971
|
const ordered = [...byId.values()];
|
|
2940
2972
|
ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
|
|
@@ -2965,6 +2997,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2965
2997
|
if (balance !== void 0 && balance <= reserve) return false;
|
|
2966
2998
|
}
|
|
2967
2999
|
if (account.cooldownUntilMs > now) return false;
|
|
3000
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) return false;
|
|
2968
3001
|
if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
|
|
2969
3002
|
if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
|
|
2970
3003
|
return true;
|
|
@@ -2990,15 +3023,21 @@ var WorkBuddyAccountPool = class {
|
|
|
2990
3023
|
total: 0,
|
|
2991
3024
|
cooling: 0,
|
|
2992
3025
|
disabled: 0,
|
|
3026
|
+
dead: 0,
|
|
2993
3027
|
reason: "empty"
|
|
2994
3028
|
};
|
|
2995
3029
|
let cooling = 0;
|
|
2996
3030
|
let disabled = 0;
|
|
3031
|
+
let dead = 0;
|
|
2997
3032
|
for (const account of inRegion) {
|
|
2998
3033
|
if (this.disabledIds.has(account.id)) {
|
|
2999
3034
|
disabled += 1;
|
|
3000
3035
|
continue;
|
|
3001
3036
|
}
|
|
3037
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) {
|
|
3038
|
+
dead += 1;
|
|
3039
|
+
continue;
|
|
3040
|
+
}
|
|
3002
3041
|
const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
|
|
3003
3042
|
if (account.cooldownUntilMs > now || modelCooling) {
|
|
3004
3043
|
cooling += 1;
|
|
@@ -3011,26 +3050,37 @@ var WorkBuddyAccountPool = class {
|
|
|
3011
3050
|
total: inRegion.length,
|
|
3012
3051
|
cooling,
|
|
3013
3052
|
disabled,
|
|
3053
|
+
dead,
|
|
3014
3054
|
reason: "reserve"
|
|
3015
3055
|
};
|
|
3016
3056
|
}
|
|
3017
3057
|
}
|
|
3058
|
+
if (dead > 0) return {
|
|
3059
|
+
total: inRegion.length,
|
|
3060
|
+
cooling,
|
|
3061
|
+
disabled,
|
|
3062
|
+
dead,
|
|
3063
|
+
reason: "session_dead"
|
|
3064
|
+
};
|
|
3018
3065
|
if (cooling > 0) return {
|
|
3019
3066
|
total: inRegion.length,
|
|
3020
3067
|
cooling,
|
|
3021
3068
|
disabled,
|
|
3069
|
+
dead,
|
|
3022
3070
|
reason: "cooling"
|
|
3023
3071
|
};
|
|
3024
3072
|
if (disabled > 0) return {
|
|
3025
3073
|
total: inRegion.length,
|
|
3026
3074
|
cooling,
|
|
3027
3075
|
disabled,
|
|
3076
|
+
dead,
|
|
3028
3077
|
reason: "disabled"
|
|
3029
3078
|
};
|
|
3030
3079
|
return {
|
|
3031
3080
|
total: inRegion.length,
|
|
3032
3081
|
cooling,
|
|
3033
3082
|
disabled,
|
|
3083
|
+
dead,
|
|
3034
3084
|
reason: "none"
|
|
3035
3085
|
};
|
|
3036
3086
|
}
|
|
@@ -3134,31 +3184,28 @@ var WorkBuddyAccountPool = class {
|
|
|
3134
3184
|
*/
|
|
3135
3185
|
async acquire(modelId, region, conversationKey) {
|
|
3136
3186
|
if (this.accounts.length === 0) await this.scan();
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
if (this.preferredId !== void 0) {
|
|
3144
|
-
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3145
|
-
if (preferred !== void 0) {
|
|
3146
|
-
await this.ensureFresh(preferred);
|
|
3147
|
-
return preferred;
|
|
3187
|
+
const maxTries = this.accounts.length + 1;
|
|
3188
|
+
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
3189
|
+
let pool = this.available(Date.now(), modelId, region);
|
|
3190
|
+
if (pool.length === 0 && attempt === 0) {
|
|
3191
|
+
await this.scan();
|
|
3192
|
+
pool = this.available(Date.now(), modelId, region);
|
|
3148
3193
|
}
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
await this.ensureFresh(bound);
|
|
3154
|
-
return bound;
|
|
3194
|
+
if (pool.length === 0) return void 0;
|
|
3195
|
+
if (this.preferredId !== void 0) {
|
|
3196
|
+
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3197
|
+
if (preferred !== void 0 && await this.ensureFresh(preferred)) return preferred;
|
|
3155
3198
|
}
|
|
3199
|
+
if (this.distribution === "sticky") {
|
|
3200
|
+
const bound = this.affinityAccount(pool, conversationKey);
|
|
3201
|
+
if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
|
|
3202
|
+
}
|
|
3203
|
+
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3204
|
+
if (account === void 0) return void 0;
|
|
3205
|
+
if (!await this.ensureFresh(account)) continue;
|
|
3206
|
+
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3207
|
+
return account;
|
|
3156
3208
|
}
|
|
3157
|
-
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3158
|
-
if (account === void 0) return void 0;
|
|
3159
|
-
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3160
|
-
await this.ensureFresh(account);
|
|
3161
|
-
return account;
|
|
3162
3209
|
}
|
|
3163
3210
|
/** Pin the account the plugin card should prefer; tokens stay out of settings. */
|
|
3164
3211
|
/** How the pool currently spreads requests. Shown on the card. */
|
|
@@ -3262,31 +3309,68 @@ var WorkBuddyAccountPool = class {
|
|
|
3262
3309
|
return newest?.id;
|
|
3263
3310
|
}
|
|
3264
3311
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
3265
|
-
async refreshAccount(accountId) {
|
|
3312
|
+
async refreshAccount(accountId, options) {
|
|
3313
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3314
|
+
if (account === void 0) return false;
|
|
3315
|
+
return await this.ensureFresh(account, options?.force === true);
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* Cool a whole account because the upstream REJECTED its sign-in (401/403).
|
|
3319
|
+
*
|
|
3320
|
+
* Called with direct evidence (the request just came back `session_dead`), so
|
|
3321
|
+
* it does not need to guess: without this, the next retry of the SAME request
|
|
3322
|
+
* picks the same account again — its credential file still claims to be valid
|
|
3323
|
+
* — and a request with 8 attempts spends all 8 on one dead account while
|
|
3324
|
+
* healthy accounts are never tried.
|
|
3325
|
+
*/
|
|
3326
|
+
penalizeCredentialDead(accountId) {
|
|
3327
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3328
|
+
if (account === void 0) return;
|
|
3329
|
+
const until = Date.now() + this.credentialDeadCooldownMs;
|
|
3330
|
+
account.credentialDeadUntilMs = Math.max(account.credentialDeadUntilMs ?? 0, until);
|
|
3331
|
+
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in was rejected by the upstream; keeping it out of rotation until ${new Date(until).toISOString()} (sign in again to restore it)`);
|
|
3332
|
+
}
|
|
3333
|
+
/** Put an account back in rotation after its sign-in was proven good again. */
|
|
3334
|
+
clearCredentialDead(accountId) {
|
|
3266
3335
|
const account = this.accounts.find((item) => item.id === accountId);
|
|
3267
3336
|
if (account === void 0) return;
|
|
3268
|
-
|
|
3337
|
+
account.credentialDeadUntilMs = 0;
|
|
3338
|
+
}
|
|
3339
|
+
/** Accounts currently kept out of rotation because their sign-in was rejected. */
|
|
3340
|
+
deadCredentials() {
|
|
3341
|
+
const now = Date.now();
|
|
3342
|
+
return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
|
|
3269
3343
|
}
|
|
3270
3344
|
/**
|
|
3271
3345
|
* Refresh the account's access token when it is within the margin (or already
|
|
3272
3346
|
* expired), in-flight de-duped per account. A failed refresh keeps the
|
|
3273
3347
|
* existing token when it has not yet expired, so an unreachable refresh
|
|
3274
3348
|
* endpoint never takes down a working session.
|
|
3349
|
+
*
|
|
3350
|
+
* `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
|
|
3351
|
+
* not a correctness rule: it exists so a healthy token is not re-fetched on
|
|
3352
|
+
* every acquire. After the upstream has ALREADY rejected the token, the gate
|
|
3353
|
+
* is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
|
|
3354
|
+
* caller needs the refresh attempted so the credential can be proven dead.
|
|
3355
|
+
*
|
|
3356
|
+
* Returns true when the token is known good afterwards (refreshed, or still
|
|
3357
|
+
* valid), false when the credential was proven dead.
|
|
3275
3358
|
*/
|
|
3276
|
-
async ensureFresh(account) {
|
|
3277
|
-
if (this.client === void 0) return;
|
|
3359
|
+
async ensureFresh(account, force = false) {
|
|
3360
|
+
if (this.client === void 0) return true;
|
|
3278
3361
|
const credential = account.credential;
|
|
3279
|
-
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs)) return;
|
|
3362
|
+
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs) && !force) return true;
|
|
3280
3363
|
const existing = this.refreshInflight.get(account.id);
|
|
3281
3364
|
if (existing !== void 0) {
|
|
3282
3365
|
await existing;
|
|
3283
|
-
return;
|
|
3366
|
+
return (account.credentialDeadUntilMs ?? 0) <= Date.now();
|
|
3284
3367
|
}
|
|
3285
3368
|
const run = (async () => {
|
|
3286
3369
|
if (credential.refreshToken === "") {
|
|
3287
|
-
if (credential.expiresAtMs > Date.now() + 3e4) return;
|
|
3370
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return true;
|
|
3288
3371
|
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} token expired with no refresh token; sign in again`);
|
|
3289
|
-
|
|
3372
|
+
this.penalizeCredentialDead(account.id);
|
|
3373
|
+
return false;
|
|
3290
3374
|
}
|
|
3291
3375
|
try {
|
|
3292
3376
|
const outcome = await this.client.refreshToken(credential);
|
|
@@ -3297,15 +3381,23 @@ var WorkBuddyAccountPool = class {
|
|
|
3297
3381
|
expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
|
|
3298
3382
|
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain }
|
|
3299
3383
|
};
|
|
3384
|
+
this.clearCredentialDead(account.id);
|
|
3300
3385
|
this.logger?.info?.(`dsh-workbuddy-xdpool: refreshed token for ${account.label}`);
|
|
3386
|
+
return true;
|
|
3301
3387
|
} catch (error) {
|
|
3388
|
+
if (isCredentialDeadError(error)) {
|
|
3389
|
+
this.penalizeCredentialDead(account.id);
|
|
3390
|
+
this.logger?.warn?.(`dsh-workbuddy-xdpool: ${account.label} refresh token was revoked upstream; sign in again to restore it`, error);
|
|
3391
|
+
return false;
|
|
3392
|
+
}
|
|
3302
3393
|
if (credential.expiresAtMs > Date.now() + 3e4) this.logger?.warn?.(`dsh-workbuddy-xdpool: token refresh failed but token still valid for ${account.label}`, error);
|
|
3303
3394
|
else this.logger?.error?.(`dsh-workbuddy-xdpool: token refresh failed and token expired for ${account.label}`, error);
|
|
3395
|
+
return true;
|
|
3304
3396
|
}
|
|
3305
3397
|
})();
|
|
3306
3398
|
this.refreshInflight.set(account.id, run);
|
|
3307
3399
|
try {
|
|
3308
|
-
await run;
|
|
3400
|
+
return await run;
|
|
3309
3401
|
} finally {
|
|
3310
3402
|
this.refreshInflight.delete(account.id);
|
|
3311
3403
|
}
|
|
@@ -3354,6 +3446,7 @@ ${new Date(until).toISOString()}
|
|
|
3354
3446
|
account.cooldownUntilMs = 0;
|
|
3355
3447
|
account.modelCooldowns = {};
|
|
3356
3448
|
account.rateLimitHits = 0;
|
|
3449
|
+
account.credentialDeadUntilMs = 0;
|
|
3357
3450
|
}
|
|
3358
3451
|
}
|
|
3359
3452
|
/** Diagnostics snapshot. Account-wide cooling count (per-model cooling excluded:
|
|
@@ -3363,6 +3456,7 @@ ${new Date(until).toISOString()}
|
|
|
3363
3456
|
return {
|
|
3364
3457
|
count: this.accounts.length,
|
|
3365
3458
|
cooling: this.accounts.filter((account) => account.cooldownUntilMs > now).length,
|
|
3459
|
+
dead: this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now).length,
|
|
3366
3460
|
lastScanAtMs: this.lastScanAtMs
|
|
3367
3461
|
};
|
|
3368
3462
|
}
|
|
@@ -5593,6 +5687,43 @@ function injectSummary(compacted, summary) {
|
|
|
5593
5687
|
];
|
|
5594
5688
|
}
|
|
5595
5689
|
//#endregion
|
|
5690
|
+
//#region src/siblings.ts
|
|
5691
|
+
/**
|
|
5692
|
+
* The cheapest PAID model sharing `model`'s display name, or undefined.
|
|
5693
|
+
*
|
|
5694
|
+
* Cheapest rather than first, because the point of the hint is "there is a
|
|
5695
|
+
* cheap way out": offering the most expensive twin would make switching look
|
|
5696
|
+
* like a trap. A sibling whose price is unknown is skipped — an unpriced row
|
|
5697
|
+
* cannot be promised as "the paid one".
|
|
5698
|
+
*/
|
|
5699
|
+
function paidSiblingOf(model, models) {
|
|
5700
|
+
let best;
|
|
5701
|
+
for (const candidate of models) {
|
|
5702
|
+
if (candidate.id === model.id) continue;
|
|
5703
|
+
if (candidate.name !== model.name) continue;
|
|
5704
|
+
const multiplier = candidate.multiplier;
|
|
5705
|
+
if (multiplier === void 0 || multiplier <= 0) continue;
|
|
5706
|
+
if (best === void 0 || multiplier < best.multiplier) best = {
|
|
5707
|
+
id: candidate.id,
|
|
5708
|
+
name: candidate.name,
|
|
5709
|
+
multiplier
|
|
5710
|
+
};
|
|
5711
|
+
}
|
|
5712
|
+
return best;
|
|
5713
|
+
}
|
|
5714
|
+
/**
|
|
5715
|
+
* The paid sibling of a model, but ONLY when the model itself is free.
|
|
5716
|
+
*
|
|
5717
|
+
* The guard is the whole point: a `x0.79` row with a `x0.06` cousin is not a
|
|
5718
|
+
* "free model with a paid twin", it is just a more expensive row, and labelling
|
|
5719
|
+
* it would be noise on most of the list. The hint answers one question — "the
|
|
5720
|
+
* free one is limited, what now".
|
|
5721
|
+
*/
|
|
5722
|
+
function paidAlternativeFor(model, models) {
|
|
5723
|
+
if (model.multiplier !== 0) return void 0;
|
|
5724
|
+
return paidSiblingOf(model, models);
|
|
5725
|
+
}
|
|
5726
|
+
//#endregion
|
|
5596
5727
|
//#region src/shim.ts
|
|
5597
5728
|
/**
|
|
5598
5729
|
* Loopback OpenAI-compatible endpoint with multi-account failover.
|
|
@@ -5778,6 +5909,22 @@ function createWorkBuddyShim(options) {
|
|
|
5778
5909
|
const server = createServer((req, res) => {
|
|
5779
5910
|
handle(req, res);
|
|
5780
5911
|
});
|
|
5912
|
+
/**
|
|
5913
|
+
* "The same model has a paid row" sentence for a 429 body, or undefined.
|
|
5914
|
+
*
|
|
5915
|
+
* Reads the catalog the shim was built with, so the hint names a row this
|
|
5916
|
+
* gateway ACTUALLY serves — a CN-only id in a global 429 would be worse than
|
|
5917
|
+
* no hint at all. Absent when the cooling model is itself paid, or when the
|
|
5918
|
+
* family has no priced twin.
|
|
5919
|
+
*/
|
|
5920
|
+
function paidTwinHint(modelId) {
|
|
5921
|
+
const models = catalog.current();
|
|
5922
|
+
const model = models.find((candidate) => candidate.id === modelId);
|
|
5923
|
+
if (model === void 0) return void 0;
|
|
5924
|
+
const twin = paidAlternativeFor(model, models);
|
|
5925
|
+
if (twin === void 0) return void 0;
|
|
5926
|
+
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`;
|
|
5927
|
+
}
|
|
5781
5928
|
const ready = new Promise((resolve, reject) => {
|
|
5782
5929
|
server.once("listening", () => resolve());
|
|
5783
5930
|
server.once("error", reject);
|
|
@@ -5889,7 +6036,8 @@ function createWorkBuddyShim(options) {
|
|
|
5889
6036
|
if (why.reason === "cooling") {
|
|
5890
6037
|
const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
|
|
5891
6038
|
const upstream = last === void 0 ? "" : ` — ${last.message.slice(0, 200)}`;
|
|
5892
|
-
|
|
6039
|
+
const twin = modelId === void 0 ? void 0 : paidTwinHint(modelId);
|
|
6040
|
+
writeOpenAIError(res, 429, "soft_rate", `${subject} (${why.cooling}/${why.total} cooling, tried ${tried.length})${tried.length === 0 ? "" : `: ${tried.join(" → ")}`}${upstream}` + (twin === void 0 ? "" : ` — ${twin}`));
|
|
5893
6041
|
return;
|
|
5894
6042
|
}
|
|
5895
6043
|
if (why.reason === "disabled") {
|
|
@@ -5900,6 +6048,10 @@ function createWorkBuddyShim(options) {
|
|
|
5900
6048
|
writeOpenAIError(res, 402, "reserve_floor", `every WorkBuddy account is at or below its credit reserve (${why.total} account(s)); lower the reserve on the pool card or top up credits`);
|
|
5901
6049
|
return;
|
|
5902
6050
|
}
|
|
6051
|
+
if (why.reason === "session_dead") {
|
|
6052
|
+
writeOpenAIError(res, 401, "session_dead", `${why.dead}/${why.total} WorkBuddy account(s) for this gateway were rejected by the upstream (their sign-in was revoked); sign in again in the WorkBuddy desktop app to restore them`);
|
|
6053
|
+
return;
|
|
6054
|
+
}
|
|
5903
6055
|
writeOpenAIError(res, 401, "not_signed_in", why.total === 0 ? "no WorkBuddy credential found for this gateway; sign in on the desktop app (or set WORKBUDDY_AUTH_FILE)" : `no usable WorkBuddy credential among ${why.total} account(s) for this gateway; sign in again on the desktop app`);
|
|
5904
6056
|
return;
|
|
5905
6057
|
}
|
|
@@ -5916,7 +6068,11 @@ function createWorkBuddyShim(options) {
|
|
|
5916
6068
|
};
|
|
5917
6069
|
if (result.kind === "session_dead") {
|
|
5918
6070
|
logger?.warn(`dsh-workbuddy-xdpool: ${account.label} session dead; refreshing token and retrying`);
|
|
5919
|
-
|
|
6071
|
+
const before = account.credential.accessToken;
|
|
6072
|
+
if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) {
|
|
6073
|
+
pool.penalizeCredentialDead(account.id);
|
|
6074
|
+
logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in is no longer valid (attempt ${attempt + 1}/${maxAttempts}); rotating`);
|
|
6075
|
+
}
|
|
5920
6076
|
continue;
|
|
5921
6077
|
}
|
|
5922
6078
|
if (result.kind === "hard_credit") {
|
|
@@ -6109,7 +6265,8 @@ async function recoverFromContextOverrun(options) {
|
|
|
6109
6265
|
detail: "prompt still exceeded the window after compaction"
|
|
6110
6266
|
};
|
|
6111
6267
|
if (result.kind === "session_dead") {
|
|
6112
|
-
|
|
6268
|
+
const before = account.credential.accessToken;
|
|
6269
|
+
if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) pool.penalizeCredentialDead(account.id);
|
|
6113
6270
|
continue;
|
|
6114
6271
|
}
|
|
6115
6272
|
if (result.kind === "hard_credit") {
|
|
@@ -6401,6 +6558,8 @@ function parseAutomationRun(body) {
|
|
|
6401
6558
|
function toWebAccount(account, disabled, reserve, reserved) {
|
|
6402
6559
|
const now = Date.now();
|
|
6403
6560
|
const cooling = account.cooldownUntilMs > now;
|
|
6561
|
+
const deadUntil = account.credentialDeadUntilMs ?? 0;
|
|
6562
|
+
const credentialDead = deadUntil > now;
|
|
6404
6563
|
const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
|
|
6405
6564
|
modelId,
|
|
6406
6565
|
until: new Date(until).toISOString()
|
|
@@ -6413,6 +6572,8 @@ function toWebAccount(account, disabled, reserve, reserved) {
|
|
|
6413
6572
|
...account.credential.expiresAtMs === 0 ? {} : { expiresAt: new Date(account.credential.expiresAtMs).toISOString() },
|
|
6414
6573
|
cooling,
|
|
6415
6574
|
...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
|
|
6575
|
+
credentialDead,
|
|
6576
|
+
...credentialDead ? { credentialDeadUntil: new Date(deadUntil).toISOString() } : {},
|
|
6416
6577
|
...modelCooldowns.length === 0 ? {} : { modelCooldowns },
|
|
6417
6578
|
disabled,
|
|
6418
6579
|
creditReserve: reserve,
|
|
@@ -6452,7 +6613,7 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
6452
6613
|
const row = toWebAccount(account, deps.pool.isDisabled(account.id), deps.pool.creditReserveOf(account.id), deps.pool.isReserved(account.id));
|
|
6453
6614
|
const earned = deps.scheduler?.().earningsToday[account.id];
|
|
6454
6615
|
if (earned !== void 0) Object.assign(row, { automationToday: earned });
|
|
6455
|
-
if (!row.cooling) {
|
|
6616
|
+
if (!row.cooling && row.credentialDead !== true) {
|
|
6456
6617
|
try {
|
|
6457
6618
|
const credits = await deps.client.fetchCredits(account.credential);
|
|
6458
6619
|
deps.pool.noteCredits(account.id, credits.total);
|
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.2",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",
|