dsh-workbuddy-xdpool 0.4.5 → 1.0.1
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 +61 -0
- package/lib/bin.js +149 -7
- package/lib/client.js +186 -79
- package/lib/index.d.ts +92 -7
- package/lib/index.js +231 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,67 @@
|
|
|
4
4
|
|
|
5
5
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
6
6
|
|
|
7
|
+
## 1.0.1 (2026-09-23)
|
|
8
|
+
|
|
9
|
+
修复两个用号逻辑上的缺陷,并把账号卡片重新排了一遍。
|
|
10
|
+
|
|
11
|
+
### 一、「当前使用」显示的账号不对
|
|
12
|
+
|
|
13
|
+
卡片上显示的账号,是你**最近一次实际在用**的那个吗?之前不是 —— 它显示的是「**准备尝试**」的账号。
|
|
14
|
+
|
|
15
|
+
举个例子:某个账号积分用光了,插件去试它、失败、自动换到另一个号继续服务。但你确实在用(换号成功了),卡片却还显示那个已经废掉的号。
|
|
16
|
+
|
|
17
|
+
现在改成**只有请求真正成功**才记录,所以显示的永远是你实际在用的那个。
|
|
18
|
+
|
|
19
|
+
### 二、账号积分用尽时,会直接把错误抛给你
|
|
20
|
+
|
|
21
|
+
上游有两种"不让用":
|
|
22
|
+
|
|
23
|
+
| 情况 | 含义 | 之前的表现 |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| **频率限制** | 这个号暂时被限流,等会儿能恢复 | 正确:冷却这个号,**自动换号** |
|
|
26
|
+
| **额度用尽** | 这个号这个周期的积分花完了 | ❌ **直接报错给你**,不换号 |
|
|
27
|
+
|
|
28
|
+
现在额度用尽也会**自动换到有积分的账号**,并把那个号整体停用 30 分钟(不是只停用某个模型)。请求不会再因此失败。
|
|
29
|
+
|
|
30
|
+
### 三、账号卡片排版重做
|
|
31
|
+
|
|
32
|
+
- 每个账号的**开关**做成了胶囊按钮(和「账号使用方式」同一套样式),一眼能看出是开还是关
|
|
33
|
+
- 账号卡**分层更清楚**:标题行(名字|状态|开关)→ 分隔线 → 信息与积分两栏
|
|
34
|
+
- 去掉了多余的边框和重复信息(不再显示 `www.workbuddy.cn` 这类域名 —— 上面的区域标签已经说明了)
|
|
35
|
+
- 新增顶部「**当前使用**」提示条,一眼看到正在用哪个号
|
|
36
|
+
|
|
37
|
+
## 1.0.0 (2026-09-21)
|
|
38
|
+
|
|
39
|
+
这一版把「账号」和「用号方式」都交给你控制。
|
|
40
|
+
|
|
41
|
+
### 一、可以停用不想用的账号了
|
|
42
|
+
|
|
43
|
+
账号列表里每个账号右侧新增一个开关,关掉它这个账号就**不再参与请求**。
|
|
44
|
+
|
|
45
|
+
- 关掉的账号仍然显示在列表里(变灰),随时可以再打开
|
|
46
|
+
- 设置会被保存,**重启 DSH 后依然有效**
|
|
47
|
+
- 只影响路由,不会删除或改动你的登录凭据
|
|
48
|
+
|
|
49
|
+
### 二、新增「均衡」用号模式
|
|
50
|
+
|
|
51
|
+
原来的两种方式都保留,另加一种:
|
|
52
|
+
|
|
53
|
+
| 模式 | 行为 |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| **优先**(默认,行为不变) | 一直用同一个账号,直到它被限流才换下一个 |
|
|
56
|
+
| **均衡**(新增) | 每个可用账号都参与抽取,**闲置越久权重越高** |
|
|
57
|
+
| **轮换** | 严格轮流,把额度摊平 |
|
|
58
|
+
|
|
59
|
+
**为什么需要「均衡」**:默认的「优先」会死磕第一个账号,把它打到限流才换 —— 如果你有 5 个号,其余 4 个基本闲着。切到「均衡」后请求会自然分散到各个账号,**更不容易撞限流**,可用时间更长。
|
|
60
|
+
|
|
61
|
+
在卡片顶部「账号使用方式」里切换,即时生效。
|
|
62
|
+
|
|
63
|
+
### 其他
|
|
64
|
+
|
|
65
|
+
- 显式选定的账号(如卡片上「下一个」标记的那个)在三种模式下**都会被优先使用**
|
|
66
|
+
- 处于冷却中的账号在「均衡」模式下**一定不会被选中**,冷却结束自动回到抽取池
|
|
67
|
+
|
|
7
68
|
## 0.4.5 (2026-09-21)
|
|
8
69
|
|
|
9
70
|
本版**功能与 0.4.4 完全相同**,只是换了一条发布通道。
|
package/lib/bin.js
CHANGED
|
@@ -758,10 +758,38 @@ function candidateAuthDirs(env = process.env) {
|
|
|
758
758
|
* Read-only pool of every discovered WorkBuddy account, with rate-limit
|
|
759
759
|
* cooldown and round-robin failover.
|
|
760
760
|
*/
|
|
761
|
+
/** Idle bonus per hour an account has been unused (reference-panel default). */
|
|
762
|
+
const IDLE_WEIGHT_PER_HOUR = .5;
|
|
763
|
+
/** Ceiling for the idle bonus, so an idle account cannot dominate forever. */
|
|
764
|
+
const IDLE_WEIGHT_MAX = 5;
|
|
765
|
+
/**
|
|
766
|
+
* Weight one account by how long it has been idle.
|
|
767
|
+
*
|
|
768
|
+
* The base of 1 keeps every eligible account in play: an account that served a
|
|
769
|
+
* moment ago still has a small chance, so a single unhealthy account cannot pin
|
|
770
|
+
* the pool to itself, and the weights never sum to zero.
|
|
771
|
+
*
|
|
772
|
+
* `lastUsedAt === undefined` means "never used in this process", which earns the
|
|
773
|
+
* full bonus: on a fresh start every account ties, and the weighted draw spreads
|
|
774
|
+
* the first requests instead of always picking the first entry.
|
|
775
|
+
*/
|
|
776
|
+
function idleWeight(lastUsedAt, now) {
|
|
777
|
+
if (lastUsedAt === void 0) return 6;
|
|
778
|
+
const hours = (now - lastUsedAt) / 36e5;
|
|
779
|
+
return 1 + Math.min(Math.max(hours, 0) * IDLE_WEIGHT_PER_HOUR, IDLE_WEIGHT_MAX);
|
|
780
|
+
}
|
|
781
|
+
/** Default rest for an account whose credits ran out (packs reset on their own schedule). */
|
|
782
|
+
const EXHAUST_COOLDOWN_MS = 18e5;
|
|
761
783
|
var WorkBuddyAccountPool = class {
|
|
762
784
|
logger;
|
|
763
785
|
authDirs;
|
|
764
786
|
cooldownMs;
|
|
787
|
+
/**
|
|
788
|
+
* How long an account stays out of rotation after the upstream reports its
|
|
789
|
+
* credits are spent. Credit packs reset on their own schedule rather than on a
|
|
790
|
+
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
791
|
+
*/
|
|
792
|
+
exhaustCooldownMs;
|
|
765
793
|
client;
|
|
766
794
|
refreshMarginMs;
|
|
767
795
|
accounts = [];
|
|
@@ -770,11 +798,30 @@ var WorkBuddyAccountPool = class {
|
|
|
770
798
|
cursor = 0;
|
|
771
799
|
lastScanAtMs = 0;
|
|
772
800
|
preferredId;
|
|
801
|
+
/**
|
|
802
|
+
* Account ids the user switched off on the card.
|
|
803
|
+
*
|
|
804
|
+
* Disabling is a user preference rather than a property of the credential:
|
|
805
|
+
* `scan()` rebuilds every account object from the auth files, so the set
|
|
806
|
+
* lives on the pool and is re-applied from settings after each scan.
|
|
807
|
+
*/
|
|
808
|
+
disabledIds = /* @__PURE__ */ new Set();
|
|
809
|
+
/**
|
|
810
|
+
* Last time each account served a request, epoch ms. Drives the idle term
|
|
811
|
+
* of the priority-mode weighting below: an account that just served loses to
|
|
812
|
+
* one that has been idle, so a small pool stops hammering a single account.
|
|
813
|
+
*
|
|
814
|
+
* In-memory on purpose: it only biases the next pick, so a cold start that
|
|
815
|
+
* treats every account as idle is the right default. Not keyed by id lookup
|
|
816
|
+
* misses because a removed account simply disappears from the map on re-scan.
|
|
817
|
+
*/
|
|
818
|
+
lastUsedAt = /* @__PURE__ */ new Map();
|
|
773
819
|
refreshInflight = /* @__PURE__ */ new Map();
|
|
774
820
|
constructor(options = {}) {
|
|
775
821
|
this.logger = options.logger;
|
|
776
822
|
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
777
823
|
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
824
|
+
this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
|
|
778
825
|
this.client = options.client;
|
|
779
826
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
780
827
|
this.distribution = options.distribution ?? "priority";
|
|
@@ -787,7 +834,9 @@ var WorkBuddyAccountPool = class {
|
|
|
787
834
|
applyConfig(options) {
|
|
788
835
|
if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
|
|
789
836
|
if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
|
|
837
|
+
if (options.exhaustCooldownMs !== void 0 && options.exhaustCooldownMs >= 1e3) this.exhaustCooldownMs = options.exhaustCooldownMs;
|
|
790
838
|
if (options.distribution !== void 0) this.distribution = options.distribution;
|
|
839
|
+
if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
|
|
791
840
|
}
|
|
792
841
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
793
842
|
async scan() {
|
|
@@ -840,12 +889,47 @@ var WorkBuddyAccountPool = class {
|
|
|
840
889
|
*/
|
|
841
890
|
available(now, modelId, region) {
|
|
842
891
|
return this.accounts.filter((account) => {
|
|
892
|
+
if (this.disabledIds.has(account.id)) return false;
|
|
843
893
|
if (account.cooldownUntilMs > now) return false;
|
|
844
894
|
if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
|
|
845
895
|
if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
|
|
846
896
|
return true;
|
|
847
897
|
});
|
|
848
898
|
}
|
|
899
|
+
/** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
|
|
900
|
+
pickRoundRobin(pool) {
|
|
901
|
+
const index = this.cursor % pool.length;
|
|
902
|
+
const account = pool[index];
|
|
903
|
+
if (account === void 0) return void 0;
|
|
904
|
+
this.cursor = (index + 1) % pool.length;
|
|
905
|
+
return account;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Priority mode: weighted random over the eligible accounts.
|
|
909
|
+
*
|
|
910
|
+
* The weight is an idle bonus — `1 + min(idleHours * perHour, max)` — so an
|
|
911
|
+
* account that has never served (or has been idle for a while) outranks one
|
|
912
|
+
* that just answered. Reference panel logic drops its success-rate term
|
|
913
|
+
* entirely because a lifetime error counter penalises an account forever;
|
|
914
|
+
* instantaneous health is already handled by cooldowns, which is why those
|
|
915
|
+
* accounts never reach this list.
|
|
916
|
+
*
|
|
917
|
+
* A pool with no idle history (fresh process) hashes to equal weights, which
|
|
918
|
+
* spreads the very first picks instead of always returning index 0.
|
|
919
|
+
*/
|
|
920
|
+
pickByWeight(pool) {
|
|
921
|
+
if (pool.length === 1) return pool[0];
|
|
922
|
+
const now = Date.now();
|
|
923
|
+
const weights = pool.map((account) => idleWeight(this.lastUsedAt.get(account.id), now));
|
|
924
|
+
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
|
925
|
+
if (!Number.isFinite(total) || total <= 0) return pool[0];
|
|
926
|
+
let roll = Math.random() * total;
|
|
927
|
+
for (let index = 0; index < pool.length; index += 1) {
|
|
928
|
+
roll -= weights[index] ?? 0;
|
|
929
|
+
if (roll < 0) return pool[index];
|
|
930
|
+
}
|
|
931
|
+
return pool[pool.length - 1];
|
|
932
|
+
}
|
|
849
933
|
/**
|
|
850
934
|
* Pick the account to serve a request.
|
|
851
935
|
*
|
|
@@ -875,16 +959,14 @@ var WorkBuddyAccountPool = class {
|
|
|
875
959
|
}
|
|
876
960
|
if (pool.length === 0) return void 0;
|
|
877
961
|
if (this.preferredId !== void 0) {
|
|
878
|
-
const
|
|
879
|
-
if (
|
|
880
|
-
|
|
881
|
-
|
|
962
|
+
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
963
|
+
if (preferred !== void 0) {
|
|
964
|
+
await this.ensureFresh(preferred);
|
|
965
|
+
return preferred;
|
|
882
966
|
}
|
|
883
967
|
}
|
|
884
|
-
const
|
|
885
|
-
const account = pool[index];
|
|
968
|
+
const account = this.distribution === "round-robin" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
886
969
|
if (account === void 0) return void 0;
|
|
887
|
-
if (this.distribution === "round-robin") this.cursor = (index + 1) % pool.length;
|
|
888
970
|
await this.ensureFresh(account);
|
|
889
971
|
return account;
|
|
890
972
|
}
|
|
@@ -896,6 +978,48 @@ var WorkBuddyAccountPool = class {
|
|
|
896
978
|
prefer(accountId) {
|
|
897
979
|
this.preferredId = accountId;
|
|
898
980
|
}
|
|
981
|
+
/** Whether the user switched this account off on the card. */
|
|
982
|
+
isDisabled(accountId) {
|
|
983
|
+
return this.disabledIds.has(accountId);
|
|
984
|
+
}
|
|
985
|
+
/** Every account id the user switched off, in discovery order. */
|
|
986
|
+
disabledIdsInOrder() {
|
|
987
|
+
return this.accounts.filter((account) => this.disabledIds.has(account.id)).map((account) => account.id);
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Record that an account actually served a request.
|
|
991
|
+
*
|
|
992
|
+
* Called by the shim once the upstream answers 200 — only then is the account
|
|
993
|
+
* the one the user is really being served by. `balanced` mode reads the same map
|
|
994
|
+
* for its idle weighting, so a request that failed over to another account must
|
|
995
|
+
* not count as used for the account that was merely tried.
|
|
996
|
+
*/
|
|
997
|
+
noteServed(accountId) {
|
|
998
|
+
if (!this.accounts.some((account) => account.id === accountId)) return;
|
|
999
|
+
this.lastUsedAt.set(accountId, Date.now());
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* The account that served the most recent request, if any.
|
|
1003
|
+
*
|
|
1004
|
+
* Distinct from "who would serve the next one": this is a record of what
|
|
1005
|
+
* actually happened, which is what the card needs to answer "which account am
|
|
1006
|
+
* I using right now?". Under `balanced` there is no deterministic next account
|
|
1007
|
+
* at all, so a recorded fact is the only honest answer.
|
|
1008
|
+
*
|
|
1009
|
+
* Returns undefined before the first request of the process, and after every
|
|
1010
|
+
* known account has been re-scanned away (a login swapped out under us).
|
|
1011
|
+
*/
|
|
1012
|
+
lastServedId() {
|
|
1013
|
+
let newest;
|
|
1014
|
+
for (const [id, at] of this.lastUsedAt) {
|
|
1015
|
+
if (!this.accounts.some((account) => account.id === id)) continue;
|
|
1016
|
+
if (newest === void 0 || at > newest.at) newest = {
|
|
1017
|
+
id,
|
|
1018
|
+
at
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
return newest?.id;
|
|
1022
|
+
}
|
|
899
1023
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
900
1024
|
async refreshAccount(accountId) {
|
|
901
1025
|
const account = this.accounts.find((item) => item.id === accountId);
|
|
@@ -946,6 +1070,23 @@ var WorkBuddyAccountPool = class {
|
|
|
946
1070
|
}
|
|
947
1071
|
}
|
|
948
1072
|
/**
|
|
1073
|
+
* Cool a whole account after the upstream reports its credits are spent.
|
|
1074
|
+
*
|
|
1075
|
+
* Credit exhaustion is an ACCOUNT condition, unlike a model rate limit: every
|
|
1076
|
+
* model on that account is unusable until the quota resets, so this cools the
|
|
1077
|
+
* account as a whole (no `modelId`) for the configured exhaustion window. The
|
|
1078
|
+
* shim then rotates to a different account instead of failing the request.
|
|
1079
|
+
*/
|
|
1080
|
+
penalizeExhausted(accountId) {
|
|
1081
|
+
const until = Date.now() + this.exhaustCooldownMs;
|
|
1082
|
+
this.penalize(accountId, until);
|
|
1083
|
+
this.logger?.warn(`
|
|
1084
|
+
dsh-workbuddy-xdpool: account credits exhausted; cooling the whole account until
|
|
1085
|
+
|
|
1086
|
+
${new Date(until).toISOString()}
|
|
1087
|
+
`);
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
949
1090
|
* Mark an account (or one of its models) rate-limited.
|
|
950
1091
|
*
|
|
951
1092
|
* With `modelId`, only that model on the account is cooled — the account's
|
|
@@ -1173,6 +1314,7 @@ z.object({
|
|
|
1173
1314
|
authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
|
|
1174
1315
|
cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
|
|
1175
1316
|
distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
|
|
1317
|
+
disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
|
|
1176
1318
|
enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
|
|
1177
1319
|
imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
|
|
1178
1320
|
contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Legacy shared context budgets; used by a region with no per-region selection yet"),
|
package/lib/client.js
CHANGED
|
@@ -128,11 +128,32 @@ window.__ModuleLoader__.load({
|
|
|
128
128
|
/* Account list (each account = a labeled subpanel, same as dingminhua). */
|
|
129
129
|
.dsm-workbuddy-xdpool-accounts{display:flex;flex-direction:column;gap:14px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);padding-top:14px}
|
|
130
130
|
.dsm-workbuddy-xdpool-accounts-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|
131
|
+
/* "In use now": answers which account is serving without scanning every row. */
|
|
132
|
+
/* A hairline + tint reads as status; a filled bar would read as a call to action. */
|
|
133
|
+
.dsm-workbuddy-xdpool-current{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin:10px 0 0;padding:9px 13px;border:1px solid color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 32%, transparent);border-radius:12px;background:color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 9%, transparent)}
|
|
134
|
+
.dsm-workbuddy-xdpool-current-dot{width:7px;height:7px;border-radius:50%;flex:none;background:var(--dsw-alias-state-success-primary,#22a06b);box-shadow:0 0 0 3px color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 18%, transparent)}
|
|
135
|
+
.dsm-workbuddy-xdpool-current-label{color:var(--dsw-alias-state-success-primary,#22a06b);font-size:11.5px;line-height:18px;font-weight:600;letter-spacing:.02em;flex:none}
|
|
136
|
+
.dsm-workbuddy-xdpool-current-name{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:13px;line-height:18px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
137
|
+
.dsm-workbuddy-xdpool-current-note{color:var(--dsw-alias-state-warning-primary,#d97706);font-size:11px;line-height:16px}
|
|
131
138
|
.dsm-workbuddy-xdpool-accounts-title{margin:0;color:var(--dsw-alias-label-primary,#e6e6e6);font-size:14px;font-weight:600;line-height:20px}
|
|
132
139
|
.dsm-workbuddy-xdpool-accounts-summary{margin:2px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
|
|
133
|
-
.dsm-workbuddy-xdpool-account{display:flex;
|
|
134
|
-
.
|
|
140
|
+
.dsm-workbuddy-xdpool-account{display:flex;flex-direction:column;gap:0;padding:0;border:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 55%, transparent);border-radius:16px;background:var(--dsw-alias-bg-layer-2,#24262c);box-shadow:0 1px 3px rgba(0,0,0,.04);overflow:hidden}
|
|
141
|
+
/* Body row inside a card: identity on the left, credit panels on the right. */
|
|
142
|
+
/* flex-wrap lets the panels drop below on a narrow card instead of squeezing both. */
|
|
143
|
+
.dsm-workbuddy-xdpool-account-body{display:flex;align-items:stretch;gap:0;flex-wrap:wrap}
|
|
144
|
+
.dsm-workbuddy-xdpool-account-copy{display:flex;flex-direction:column;align-items:flex-start;gap:6px;flex:1 1 200px;min-width:0;padding:14px 16px;justify-content:center}
|
|
135
145
|
.dsm-workbuddy-xdpool-account-label{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:14px;font-weight:600;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
146
|
+
.dsm-workbuddy-xdpool-account-head .dsm-workbuddy-xdpool-account-toggle{margin-left:auto}
|
|
147
|
+
.dsm-workbuddy-xdpool-account-head{display:flex;align-items:center;gap:10px;min-width:0;padding:12px 16px;border-bottom:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 45%, transparent)}
|
|
148
|
+
/* Account switched off on the card: still listed (so it can be turned back on) but visually muted. */
|
|
149
|
+
.dsm-workbuddy-xdpool-account-off{opacity:.55}
|
|
150
|
+
/* Small pill switch: "in rotation" vs "off". Native checkbox styled by the label. */
|
|
151
|
+
.dsm-workbuddy-xdpool-account-toggle{appearance:none;font:inherit;display:inline-flex;align-items:center;gap:5px;cursor:pointer;flex:none;border:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 70%, transparent);border-radius:999px;padding:3px 10px;font-size:11px;line-height:17px;background:transparent;color:var(--dsw-alias-label-tertiary,#9aa0a8);transition:color .16s,border-color .16s,background .16s}
|
|
152
|
+
.dsm-workbuddy-xdpool-account-toggle:hover:not(:disabled){color:var(--dsw-alias-label-primary,#e6e6e6);border-color:var(--dsw-alias-label-dimmed,#777)}
|
|
153
|
+
.dsm-workbuddy-xdpool-account-toggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
|
|
154
|
+
.dsm-workbuddy-xdpool-account-toggle:disabled{cursor:default;opacity:.6}
|
|
155
|
+
.dsm-workbuddy-xdpool-account-toggle-on{background:color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 14%, transparent);border-color:color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 55%, transparent);color:var(--dsw-alias-state-success-primary,#22a06b);font-weight:600}
|
|
156
|
+
.dsm-workbuddy-xdpool-account-toggle-dot{width:6px;height:6px;border-radius:50%;flex:none;background:currentColor;opacity:.9}
|
|
136
157
|
.dsm-workbuddy-xdpool-account-tags{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
137
158
|
.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)}
|
|
138
159
|
.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)}
|
|
@@ -143,9 +164,9 @@ window.__ModuleLoader__.load({
|
|
|
143
164
|
.dsm-workbuddy-xdpool-account-error{margin:0;color:var(--dsw-alias-state-error-primary,#ef4444);font-size:13px;line-height:20px}
|
|
144
165
|
|
|
145
166
|
/* Two-column stats: packages on the left, total + check-in on the right. */
|
|
146
|
-
.dsm-workbuddy-xdpool-stats{display:grid;grid-template-columns:minmax(0,1.
|
|
147
|
-
.dsm-workbuddy-xdpool-panel{display:flex;flex-direction:column;min-width:0;gap:7px;padding:14px
|
|
148
|
-
.dsm-workbuddy-xdpool-panel-title{color:var(--dsw-alias-label-tertiary,#999);font-size:
|
|
167
|
+
.dsm-workbuddy-xdpool-stats{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(168px,.85fr);gap:0;flex:1 1 420px;min-width:0;max-width:660px;border-left:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 45%, transparent)}
|
|
168
|
+
.dsm-workbuddy-xdpool-panel{display:flex;flex-direction:column;min-width:0;gap:7px;padding:14px 16px}
|
|
169
|
+
.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}
|
|
149
170
|
.dsm-workbuddy-xdpool-panel-empty{color:var(--dsw-alias-label-tertiary,#999);font-size:14px;line-height:20px}
|
|
150
171
|
.dsm-workbuddy-xdpool-panel-error{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:12px;line-height:18px;word-break:break-word}
|
|
151
172
|
.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}
|
|
@@ -163,9 +184,9 @@ window.__ModuleLoader__.load({
|
|
|
163
184
|
fold onto their own line so a long package name cannot squeeze them out. */
|
|
164
185
|
.dsm-workbuddy-xdpool-packages-when{grid-column:1/-1;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:15px;font-variant-numeric:tabular-nums}
|
|
165
186
|
.dsm-workbuddy-xdpool-packages-when-soon{color:var(--dsw-alias-state-warning-primary,#d97706)}
|
|
166
|
-
.dsm-workbuddy-xdpool-panel-total{position:relative;align-items:center;text-align:center;overflow:hidden}
|
|
187
|
+
.dsm-workbuddy-xdpool-panel-total{position:relative;align-items:center;text-align:center;justify-content:center;overflow:hidden;background:color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 5%, transparent)}
|
|
167
188
|
.dsm-workbuddy-xdpool-panel-total::before{content:"";position:absolute;top:0;left:0;right:0;height:3px;opacity:.9;background:var(--dsw-alias-state-success-primary,#22a06b)}
|
|
168
|
-
.dsm-workbuddy-xdpool-total-value{color:var(--dsw-alias-state-success-primary,#22a06b);font-size:
|
|
189
|
+
.dsm-workbuddy-xdpool-total-value{color:var(--dsw-alias-state-success-primary,#22a06b);font-size:28px;line-height:32px;font-weight:800;letter-spacing:-.02em;white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
169
190
|
|
|
170
191
|
/* Model directory list. */
|
|
171
192
|
.dsm-workbuddy-xdpool-models{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);padding-top:14px}
|
|
@@ -374,6 +395,8 @@ window.__ModuleLoader__.load({
|
|
|
374
395
|
const [flash, setFlash] = (0, react.useState)(void 0);
|
|
375
396
|
/** Account id whose daily claim is currently in flight. */
|
|
376
397
|
const [checkinBusyId, setCheckinBusyId] = (0, react.useState)(void 0);
|
|
398
|
+
/** Account id whose enable/disable switch is in flight, if any. */
|
|
399
|
+
const [accountBusyId, setAccountBusyId] = (0, react.useState)(void 0);
|
|
377
400
|
/**
|
|
378
401
|
* Draft model selection. `undefined` means "no local edits"; once a checkbox
|
|
379
402
|
* is touched the draft takes over and is what the Save button posts. Discard
|
|
@@ -509,6 +532,35 @@ window.__ModuleLoader__.load({
|
|
|
509
532
|
if (mounted.current) setCheckinBusyId(void 0);
|
|
510
533
|
}
|
|
511
534
|
};
|
|
535
|
+
/**
|
|
536
|
+
* Switch one account in or out of the pool.
|
|
537
|
+
*
|
|
538
|
+
* The id travels in the body and the host validates it against the accounts it
|
|
539
|
+
* really knows, so a stale tab cannot write an orphan id. Disabling only stops
|
|
540
|
+
* the account from being picked — it stays listed so it can be turned back on —
|
|
541
|
+
* and the change is saved through the settings section, so it survives a
|
|
542
|
+
* restart and is re-applied after every re-scan.
|
|
543
|
+
*/
|
|
544
|
+
const toggleAccountDisabled = async (accountId, disabled) => {
|
|
545
|
+
const write = settingsScope?.set;
|
|
546
|
+
if (write === void 0) {
|
|
547
|
+
setError(t?.("row.modelsSaveError", { message: "settings scope is read-only" }) ?? "settings scope is read-only");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
setAccountBusyId(accountId);
|
|
551
|
+
setFlash(void 0);
|
|
552
|
+
try {
|
|
553
|
+
const current = (status?.accounts ?? []).filter((account) => account.disabled === true).map((account) => account.id);
|
|
554
|
+
const next = disabled ? current.includes(accountId) ? current : [...current, accountId] : current.filter((id) => id !== accountId);
|
|
555
|
+
await write.call(settingsScope, "disabledAccountIds", next);
|
|
556
|
+
await refresh(activeRegion);
|
|
557
|
+
} catch (cause) {
|
|
558
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
559
|
+
if (mounted.current) setError(t?.("row.accountToggleError", { message }) ?? "Could not switch the account: " + message);
|
|
560
|
+
} finally {
|
|
561
|
+
if (mounted.current) setAccountBusyId(void 0);
|
|
562
|
+
}
|
|
563
|
+
};
|
|
512
564
|
/** Keep the draft in step with the server copy while nothing is dirty. */
|
|
513
565
|
const modelDraft = draft ?? (status === void 0 ? {} : draftFromStatus(status));
|
|
514
566
|
/** Model edits need a writable settings scope; otherwise the rows are read-only. */
|
|
@@ -722,17 +774,21 @@ window.__ModuleLoader__.load({
|
|
|
722
774
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
723
775
|
className: "dsm-workbuddy-xdpool-dist-title",
|
|
724
776
|
children: t?.("row.distTitle") ?? "Account usage"
|
|
725
|
-
}), [
|
|
777
|
+
}), [
|
|
778
|
+
"priority",
|
|
779
|
+
"balanced",
|
|
780
|
+
"round-robin"
|
|
781
|
+
].map((option) => {
|
|
726
782
|
const active = (status.distribution ?? "priority") === option;
|
|
727
|
-
const label = option === "priority" ? t?.("row.distPriority") ?? "Priority" : t?.("row.distRoundRobin") ?? "Round-robin";
|
|
728
|
-
const hint = option === "priority" ? t?.("row.distPriorityHint") ?? "" : t?.("row.distRoundRobinHint") ?? "";
|
|
783
|
+
const label = option === "priority" ? t?.("row.distPriority") ?? "Priority" : option === "balanced" ? t?.("row.distBalanced") ?? "Balanced" : t?.("row.distRoundRobin") ?? "Round-robin";
|
|
784
|
+
const hint = option === "priority" ? t?.("row.distPriorityHint") ?? "" : option === "balanced" ? t?.("row.distBalancedHint") ?? "" : t?.("row.distRoundRobinHint") ?? "";
|
|
729
785
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
730
786
|
type: "button",
|
|
731
787
|
role: "radio",
|
|
732
788
|
"aria-checked": active,
|
|
733
789
|
title: hint,
|
|
734
790
|
disabled: !modelsEditable,
|
|
735
|
-
className:
|
|
791
|
+
className: "dsm-workbuddy-xdpool-dist-option" + (active ? " dsm-workbuddy-xdpool-dist-option-active" : ""),
|
|
736
792
|
onClick: () => {
|
|
737
793
|
setDistribution(option);
|
|
738
794
|
},
|
|
@@ -801,27 +857,55 @@ window.__ModuleLoader__.load({
|
|
|
801
857
|
accountCount > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
802
858
|
className: "dsm-workbuddy-xdpool-accounts",
|
|
803
859
|
"aria-label": t?.("row.accountsTitle") ?? "Accounts",
|
|
804
|
-
children: [
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
860
|
+
children: [
|
|
861
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
862
|
+
className: "dsm-workbuddy-xdpool-accounts-head",
|
|
863
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
864
|
+
className: "dsm-workbuddy-xdpool-accounts-title",
|
|
865
|
+
children: t?.("row.accountsTitle") ?? "Accounts in the pool"
|
|
866
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
867
|
+
className: "dsm-workbuddy-xdpool-accounts-summary",
|
|
868
|
+
children: t?.("row.accountsSummary", {
|
|
869
|
+
count: accountCount,
|
|
870
|
+
cooling
|
|
871
|
+
}) ?? `${accountCount} account(s) · ${cooling} cooling`
|
|
872
|
+
})]
|
|
873
|
+
}),
|
|
874
|
+
(() => {
|
|
875
|
+
const active = status?.activeAccountId === void 0 ? void 0 : status.accounts.find((account) => account.id === status.activeAccountId);
|
|
876
|
+
if (active === void 0) return null;
|
|
877
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
878
|
+
className: "dsm-workbuddy-xdpool-current",
|
|
879
|
+
children: [
|
|
880
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsm-workbuddy-xdpool-current-dot" }),
|
|
881
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
882
|
+
className: "dsm-workbuddy-xdpool-current-label",
|
|
883
|
+
children: t?.("row.currentAccount") ?? "In use now"
|
|
884
|
+
}),
|
|
885
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
886
|
+
className: "dsm-workbuddy-xdpool-current-name",
|
|
887
|
+
children: active.label
|
|
888
|
+
}),
|
|
889
|
+
active.disabled === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
890
|
+
className: "dsm-workbuddy-xdpool-current-note",
|
|
891
|
+
children: t?.("row.currentAccountDisabled") ?? "disabled — will switch on the next request"
|
|
892
|
+
}) : null
|
|
893
|
+
]
|
|
894
|
+
});
|
|
895
|
+
})(),
|
|
896
|
+
status?.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountBlock, {
|
|
897
|
+
account,
|
|
898
|
+
...checkinBusyId === void 0 ? {} : { checkinBusyId },
|
|
899
|
+
onClaimCheckin: (accountId) => {
|
|
900
|
+
claimCheckin(accountId);
|
|
901
|
+
},
|
|
902
|
+
onToggleDisabled: (accountId, disabled) => {
|
|
903
|
+
toggleAccountDisabled(accountId, disabled);
|
|
904
|
+
},
|
|
905
|
+
...accountBusyId === void 0 ? {} : { accountBusyId },
|
|
906
|
+
t
|
|
907
|
+
}, account.id))
|
|
908
|
+
]
|
|
825
909
|
}) : null,
|
|
826
910
|
(status?.models.length ?? 0) > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
827
911
|
className: "dsm-workbuddy-xdpool-models",
|
|
@@ -880,66 +964,75 @@ window.__ModuleLoader__.load({
|
|
|
880
964
|
});
|
|
881
965
|
}
|
|
882
966
|
/** One account block: label + status tag + meta + optional credit panels. */
|
|
883
|
-
function AccountBlock({ account,
|
|
884
|
-
const
|
|
967
|
+
function AccountBlock({ account, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled }) {
|
|
968
|
+
const isDisabled = account.disabled === true;
|
|
885
969
|
const isCooling = account.cooling === true;
|
|
886
970
|
const cooldownUntil = account.cooldownUntil !== void 0 ? Date.parse(account.cooldownUntil) : void 0;
|
|
887
971
|
const modelCooldowns = account.modelCooldowns ?? [];
|
|
888
|
-
const tag =
|
|
889
|
-
text: t?.("row.accountNext") ?? "Next up",
|
|
890
|
-
cls: "dsm-workbuddy-xdpool-account-tag"
|
|
891
|
-
} : isCooling ? {
|
|
972
|
+
const tag = isCooling ? {
|
|
892
973
|
text: t?.("row.cooling") ?? "Cooling",
|
|
893
974
|
cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-cooling"
|
|
894
975
|
} : null;
|
|
895
976
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
896
|
-
className: "dsm-workbuddy-xdpool-account",
|
|
977
|
+
className: isDisabled ? "dsm-workbuddy-xdpool-account dsm-workbuddy-xdpool-account-off" : "dsm-workbuddy-xdpool-account",
|
|
897
978
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
898
|
-
className: "dsm-workbuddy-xdpool-account-
|
|
979
|
+
className: "dsm-workbuddy-xdpool-account-head",
|
|
899
980
|
children: [
|
|
900
981
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
901
982
|
className: "dsm-workbuddy-xdpool-account-label",
|
|
902
983
|
children: account.label
|
|
903
984
|
}),
|
|
904
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("
|
|
905
|
-
className:
|
|
906
|
-
children: tag
|
|
907
|
-
className: tag.cls,
|
|
908
|
-
children: tag.text
|
|
909
|
-
})
|
|
910
|
-
}),
|
|
911
|
-
account.domain !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
912
|
-
className: "dsm-workbuddy-xdpool-account-meta",
|
|
913
|
-
children: account.domain
|
|
985
|
+
tag === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
986
|
+
className: tag.cls,
|
|
987
|
+
children: tag.text
|
|
914
988
|
}),
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
]
|
|
926
|
-
})
|
|
927
|
-
modelCooldowns.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
928
|
-
className: "dsm-workbuddy-xdpool-account-modelcool",
|
|
929
|
-
children: modelCooldowns.map((mc) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
930
|
-
className: "dsm-workbuddy-xdpool-account-modelcool-chip",
|
|
931
|
-
children: t?.("row.modelCooling", {
|
|
932
|
-
model: mc.modelId,
|
|
933
|
-
time: formatDateTime(mc.until)
|
|
934
|
-
}) ?? `${mc.modelId} cooling to ${formatDateTime(mc.until)}`
|
|
935
|
-
}, mc.modelId))
|
|
936
|
-
}) : null
|
|
989
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
990
|
+
type: "button",
|
|
991
|
+
role: "switch",
|
|
992
|
+
"aria-checked": !isDisabled,
|
|
993
|
+
className: isDisabled ? "dsm-workbuddy-xdpool-account-toggle" : "dsm-workbuddy-xdpool-account-toggle dsm-workbuddy-xdpool-account-toggle-on",
|
|
994
|
+
title: t?.("row.accountToggleHint") ?? "Enable this account (uncheck to keep it out of the pool)",
|
|
995
|
+
disabled: accountBusyId === account.id,
|
|
996
|
+
onClick: () => {
|
|
997
|
+
onToggleDisabled(account.id, !isDisabled);
|
|
998
|
+
},
|
|
999
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsm-workbuddy-xdpool-account-toggle-dot" }), isDisabled ? t?.("row.accountOff") ?? "Disabled" : t?.("row.accountInRotation") ?? "Enabled"]
|
|
1000
|
+
})
|
|
937
1001
|
]
|
|
938
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.
|
|
939
|
-
account,
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1002
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1003
|
+
className: "dsm-workbuddy-xdpool-account-body",
|
|
1004
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1005
|
+
className: "dsm-workbuddy-xdpool-account-copy",
|
|
1006
|
+
children: [
|
|
1007
|
+
account.expiresAt !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1008
|
+
className: "dsm-workbuddy-xdpool-account-meta",
|
|
1009
|
+
children: t?.("row.tokenExpiry", { time: formatDateTime(account.expiresAt) }) ?? `token ${formatDateTime(account.expiresAt)}`
|
|
1010
|
+
}) : null,
|
|
1011
|
+
isCooling && cooldownUntil !== void 0 && !Number.isNaN(cooldownUntil) ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1012
|
+
className: "dsm-workbuddy-xdpool-account-meta",
|
|
1013
|
+
children: [
|
|
1014
|
+
t?.("row.cooldownUntil", { time: formatTime(cooldownUntil) }) ?? `until ${formatTime(cooldownUntil)}`,
|
|
1015
|
+
" · ",
|
|
1016
|
+
t?.("row.cooldownHits", { hits: account.rateLimitHits ?? 0 }) ?? `${account.rateLimitHits ?? 0} hit(s)`
|
|
1017
|
+
]
|
|
1018
|
+
}) : null,
|
|
1019
|
+
modelCooldowns.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1020
|
+
className: "dsm-workbuddy-xdpool-account-modelcool",
|
|
1021
|
+
children: modelCooldowns.map((mc) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1022
|
+
className: "dsm-workbuddy-xdpool-account-modelcool-chip",
|
|
1023
|
+
children: t?.("row.modelCooling", {
|
|
1024
|
+
model: mc.modelId,
|
|
1025
|
+
time: formatDateTime(mc.until)
|
|
1026
|
+
}) ?? `${mc.modelId} cooling to ${formatDateTime(mc.until)}`
|
|
1027
|
+
}, mc.modelId))
|
|
1028
|
+
}) : null
|
|
1029
|
+
]
|
|
1030
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountStats, {
|
|
1031
|
+
account,
|
|
1032
|
+
t,
|
|
1033
|
+
checkinBusy: checkinBusyId === account.id,
|
|
1034
|
+
onClaim: onClaimCheckin
|
|
1035
|
+
})]
|
|
943
1036
|
})]
|
|
944
1037
|
});
|
|
945
1038
|
}
|
|
@@ -1185,7 +1278,12 @@ window.__ModuleLoader__.load({
|
|
|
1185
1278
|
"row.accountsSummary": "{count} account(s) · {cooling} cooling",
|
|
1186
1279
|
"row.ok": "Healthy — requests auto-rotate across accounts",
|
|
1187
1280
|
"row.allCooling": "Every account is rate-limited right now; requests pause until a cooldown lifts.",
|
|
1188
|
-
"row.
|
|
1281
|
+
"row.accountInRotation": "Enabled",
|
|
1282
|
+
"row.accountOff": "Disabled",
|
|
1283
|
+
"row.accountToggleHint": "Enable this account (uncheck to keep it out of the pool)",
|
|
1284
|
+
"row.accountToggleError": "Could not switch the account: {message}",
|
|
1285
|
+
"row.currentAccount": "In use now",
|
|
1286
|
+
"row.currentAccountDisabled": "switched off — the next request moves on",
|
|
1189
1287
|
"row.cooling": "Cooling (rate-limited)",
|
|
1190
1288
|
"row.cooldownHits": "{hits} hit(s)",
|
|
1191
1289
|
"row.cooldownUntil": "until {time}",
|
|
@@ -1238,6 +1336,8 @@ window.__ModuleLoader__.load({
|
|
|
1238
1336
|
"row.distPriorityHint": "Drain one account before moving to the next",
|
|
1239
1337
|
"row.distRoundRobin": "Round-robin",
|
|
1240
1338
|
"row.distRoundRobinHint": "Spread the spend evenly across accounts",
|
|
1339
|
+
"row.distBalanced": "Balanced",
|
|
1340
|
+
"row.distBalancedHint": "Draw from every account, favouring whichever has been idle longest",
|
|
1241
1341
|
"row.accountsScanning": "Detecting…",
|
|
1242
1342
|
"row.resetCooldowns": "Clear all cooldowns",
|
|
1243
1343
|
"row.resetCooldownsBusy": "Clearing…",
|
|
@@ -1271,7 +1371,12 @@ window.__ModuleLoader__.load({
|
|
|
1271
1371
|
"row.accountsSummary": "{count} 个账号 · {cooling} 个冷却中",
|
|
1272
1372
|
"row.ok": "运行健康 —— 请求会在各账号间自动轮换",
|
|
1273
1373
|
"row.allCooling": "当前所有账号都处于限流冷却,请求会暂停直到某个冷却结束。",
|
|
1274
|
-
"row.
|
|
1374
|
+
"row.accountInRotation": "已启用",
|
|
1375
|
+
"row.accountOff": "已停用",
|
|
1376
|
+
"row.accountToggleHint": "启用该账号(取消勾选则不参与池子)",
|
|
1377
|
+
"row.accountToggleError": "切换账号失败:{message}",
|
|
1378
|
+
"row.currentAccount": "当前使用",
|
|
1379
|
+
"row.currentAccountDisabled": "已停用 —— 下次请求会换号",
|
|
1275
1380
|
"row.cooling": "冷却中(被限流)",
|
|
1276
1381
|
"row.cooldownHits": "触发 {hits} 次",
|
|
1277
1382
|
"row.cooldownUntil": "至 {time}",
|
|
@@ -1324,6 +1429,8 @@ window.__ModuleLoader__.load({
|
|
|
1324
1429
|
"row.distPriorityHint": "优先用完一个账号再换下一个",
|
|
1325
1430
|
"row.distRoundRobin": "轮换模式",
|
|
1326
1431
|
"row.distRoundRobinHint": "积分平均分摊到各账号",
|
|
1432
|
+
"row.distBalanced": "均衡",
|
|
1433
|
+
"row.distBalancedHint": "每个账号都参与抽取,闲置越久权重越高",
|
|
1327
1434
|
"row.accountsScanning": "正在检测…",
|
|
1328
1435
|
"row.resetCooldowns": "清除所有冷却",
|
|
1329
1436
|
"row.resetCooldownsBusy": "正在清除…",
|
package/lib/index.d.ts
CHANGED
|
@@ -228,7 +228,7 @@ export declare function workbuddyAccountId(credential: Pick<WorkBuddyCredential,
|
|
|
228
228
|
/** Every directory the pool should scan, in probe order. */
|
|
229
229
|
export declare function candidateAuthDirs(env?: NodeJS.ProcessEnv): string[];
|
|
230
230
|
/** How the pool chooses which account serves the next request. */
|
|
231
|
-
type AccountDistribution = 'priority' | 'round-robin';
|
|
231
|
+
type AccountDistribution = 'priority' | 'round-robin' | 'balanced';
|
|
232
232
|
interface AccountPoolOptions {
|
|
233
233
|
/** Logger for discovery and rotation events. */
|
|
234
234
|
logger?: {
|
|
@@ -240,6 +240,8 @@ interface AccountPoolOptions {
|
|
|
240
240
|
authDirs?: readonly string[];
|
|
241
241
|
/** How long a rate-limited account stays out of rotation. */
|
|
242
242
|
cooldownMs?: number;
|
|
243
|
+
/** How long an account rests after its credits run out (default 30 minutes). */
|
|
244
|
+
exhaustCooldownMs?: number;
|
|
243
245
|
/** Upstream client used to refresh near-expiry tokens. */
|
|
244
246
|
client?: TokenRefresher;
|
|
245
247
|
/** Refresh this long before actual expiry; default five minutes. */
|
|
@@ -256,14 +258,16 @@ interface AccountPoolOptions {
|
|
|
256
258
|
*/
|
|
257
259
|
distribution?: AccountDistribution;
|
|
258
260
|
}
|
|
259
|
-
/**
|
|
260
|
-
* Read-only pool of every discovered WorkBuddy account, with rate-limit
|
|
261
|
-
* cooldown and round-robin failover.
|
|
262
|
-
*/
|
|
263
261
|
export declare class WorkBuddyAccountPool {
|
|
264
262
|
private readonly logger;
|
|
265
263
|
private authDirs;
|
|
266
264
|
private cooldownMs;
|
|
265
|
+
/**
|
|
266
|
+
* How long an account stays out of rotation after the upstream reports its
|
|
267
|
+
* credits are spent. Credit packs reset on their own schedule rather than on a
|
|
268
|
+
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
269
|
+
*/
|
|
270
|
+
private exhaustCooldownMs;
|
|
267
271
|
private readonly client;
|
|
268
272
|
private readonly refreshMarginMs;
|
|
269
273
|
private accounts;
|
|
@@ -272,6 +276,24 @@ export declare class WorkBuddyAccountPool {
|
|
|
272
276
|
private cursor;
|
|
273
277
|
private lastScanAtMs;
|
|
274
278
|
private preferredId;
|
|
279
|
+
/**
|
|
280
|
+
* Account ids the user switched off on the card.
|
|
281
|
+
*
|
|
282
|
+
* Disabling is a user preference rather than a property of the credential:
|
|
283
|
+
* `scan()` rebuilds every account object from the auth files, so the set
|
|
284
|
+
* lives on the pool and is re-applied from settings after each scan.
|
|
285
|
+
*/
|
|
286
|
+
private disabledIds;
|
|
287
|
+
/**
|
|
288
|
+
* Last time each account served a request, epoch ms. Drives the idle term
|
|
289
|
+
* of the priority-mode weighting below: an account that just served loses to
|
|
290
|
+
* one that has been idle, so a small pool stops hammering a single account.
|
|
291
|
+
*
|
|
292
|
+
* In-memory on purpose: it only biases the next pick, so a cold start that
|
|
293
|
+
* treats every account as idle is the right default. Not keyed by id lookup
|
|
294
|
+
* misses because a removed account simply disappears from the map on re-scan.
|
|
295
|
+
*/
|
|
296
|
+
private lastUsedAt;
|
|
275
297
|
private refreshInflight;
|
|
276
298
|
constructor(options?: AccountPoolOptions);
|
|
277
299
|
/**
|
|
@@ -282,7 +304,9 @@ export declare class WorkBuddyAccountPool {
|
|
|
282
304
|
applyConfig(options: {
|
|
283
305
|
authDirs?: readonly string[];
|
|
284
306
|
cooldownMs?: number;
|
|
307
|
+
exhaustCooldownMs?: number;
|
|
285
308
|
distribution?: AccountDistribution;
|
|
309
|
+
disabledAccountIds?: readonly string[];
|
|
286
310
|
}): void;
|
|
287
311
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
288
312
|
scan(): Promise<WorkBuddyAccount[]>;
|
|
@@ -298,6 +322,22 @@ export declare class WorkBuddyAccountPool {
|
|
|
298
322
|
* model, e.g. CLI diagnostics).
|
|
299
323
|
*/
|
|
300
324
|
private available;
|
|
325
|
+
/** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
|
|
326
|
+
private pickRoundRobin;
|
|
327
|
+
/**
|
|
328
|
+
* Priority mode: weighted random over the eligible accounts.
|
|
329
|
+
*
|
|
330
|
+
* The weight is an idle bonus — `1 + min(idleHours * perHour, max)` — so an
|
|
331
|
+
* account that has never served (or has been idle for a while) outranks one
|
|
332
|
+
* that just answered. Reference panel logic drops its success-rate term
|
|
333
|
+
* entirely because a lifetime error counter penalises an account forever;
|
|
334
|
+
* instantaneous health is already handled by cooldowns, which is why those
|
|
335
|
+
* accounts never reach this list.
|
|
336
|
+
*
|
|
337
|
+
* A pool with no idle history (fresh process) hashes to equal weights, which
|
|
338
|
+
* spreads the very first picks instead of always returning index 0.
|
|
339
|
+
*/
|
|
340
|
+
private pickByWeight;
|
|
301
341
|
/**
|
|
302
342
|
* Pick the account to serve a request.
|
|
303
343
|
*
|
|
@@ -323,6 +363,31 @@ export declare class WorkBuddyAccountPool {
|
|
|
323
363
|
/** How the pool currently spreads requests. Shown on the card. */
|
|
324
364
|
currentDistribution(): AccountDistribution;
|
|
325
365
|
prefer(accountId: string | undefined): void;
|
|
366
|
+
/** Whether the user switched this account off on the card. */
|
|
367
|
+
isDisabled(accountId: string): boolean;
|
|
368
|
+
/** Every account id the user switched off, in discovery order. */
|
|
369
|
+
disabledIdsInOrder(): string[];
|
|
370
|
+
/**
|
|
371
|
+
* Record that an account actually served a request.
|
|
372
|
+
*
|
|
373
|
+
* Called by the shim once the upstream answers 200 — only then is the account
|
|
374
|
+
* the one the user is really being served by. `balanced` mode reads the same map
|
|
375
|
+
* for its idle weighting, so a request that failed over to another account must
|
|
376
|
+
* not count as used for the account that was merely tried.
|
|
377
|
+
*/
|
|
378
|
+
noteServed(accountId: string): void;
|
|
379
|
+
/**
|
|
380
|
+
* The account that served the most recent request, if any.
|
|
381
|
+
*
|
|
382
|
+
* Distinct from "who would serve the next one": this is a record of what
|
|
383
|
+
* actually happened, which is what the card needs to answer "which account am
|
|
384
|
+
* I using right now?". Under `balanced` there is no deterministic next account
|
|
385
|
+
* at all, so a recorded fact is the only honest answer.
|
|
386
|
+
*
|
|
387
|
+
* Returns undefined before the first request of the process, and after every
|
|
388
|
+
* known account has been re-scanned away (a login swapped out under us).
|
|
389
|
+
*/
|
|
390
|
+
lastServedId(): string | undefined;
|
|
326
391
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
327
392
|
refreshAccount(accountId: string): Promise<void>;
|
|
328
393
|
/**
|
|
@@ -332,6 +397,15 @@ export declare class WorkBuddyAccountPool {
|
|
|
332
397
|
* endpoint never takes down a working session.
|
|
333
398
|
*/
|
|
334
399
|
private ensureFresh;
|
|
400
|
+
/**
|
|
401
|
+
* Cool a whole account after the upstream reports its credits are spent.
|
|
402
|
+
*
|
|
403
|
+
* Credit exhaustion is an ACCOUNT condition, unlike a model rate limit: every
|
|
404
|
+
* model on that account is unusable until the quota resets, so this cools the
|
|
405
|
+
* account as a whole (no `modelId`) for the configured exhaustion window. The
|
|
406
|
+
* shim then rotates to a different account instead of failing the request.
|
|
407
|
+
*/
|
|
408
|
+
penalizeExhausted(accountId: string): void;
|
|
335
409
|
/**
|
|
336
410
|
* Mark an account (or one of its models) rate-limited.
|
|
337
411
|
*
|
|
@@ -572,6 +646,11 @@ interface PoolWebAccount {
|
|
|
572
646
|
modelId: string;
|
|
573
647
|
until: string;
|
|
574
648
|
}>;
|
|
649
|
+
/**
|
|
650
|
+
* Whether the user switched this account off. A disabled account never
|
|
651
|
+
* serves a request, but it stays listed so the card can switch it back on.
|
|
652
|
+
*/
|
|
653
|
+
disabled: boolean;
|
|
575
654
|
rateLimitHits: number;
|
|
576
655
|
/** ISO timestamp of the last successful use (best-effort pool bookkeeping). */
|
|
577
656
|
lastUsedAt?: string;
|
|
@@ -657,7 +736,6 @@ interface PoolWebModel {
|
|
|
657
736
|
/** Whether this model is currently enabled in the picker. */
|
|
658
737
|
enabled: boolean;
|
|
659
738
|
}
|
|
660
|
-
/** The user's saved model selection, echoed back so the card can diff a draft. */
|
|
661
739
|
interface PoolWebModelSelection {
|
|
662
740
|
/** Absent = every model is enabled. */
|
|
663
741
|
enabledModelIds?: readonly string[];
|
|
@@ -697,7 +775,7 @@ interface PoolWebStatus {
|
|
|
697
775
|
*/
|
|
698
776
|
type PoolRegion = 'cn' | 'global';
|
|
699
777
|
/** How the pool spreads requests across its accounts. */
|
|
700
|
-
type PoolDistribution = 'priority' | 'round-robin';
|
|
778
|
+
type PoolDistribution = 'priority' | 'round-robin' | 'balanced';
|
|
701
779
|
//#endregion
|
|
702
780
|
//#region src/index.d.ts
|
|
703
781
|
/** Stable Cordis plugin name. */
|
|
@@ -725,6 +803,13 @@ export interface Config {
|
|
|
725
803
|
* spend evenly instead. Absent reads as `priority`.
|
|
726
804
|
*/
|
|
727
805
|
distribution?: 'priority' | 'round-robin';
|
|
806
|
+
/**
|
|
807
|
+
* Account ids switched off on the card. A disabled account is never picked
|
|
808
|
+
* to serve a request, but it stays in the pool and on the card so it can be
|
|
809
|
+
* switched back on. Ids are the pool's stable per-credential keys, which
|
|
810
|
+
* survive re-scans (see WorkBuddyAccountPool.disabledIds).
|
|
811
|
+
*/
|
|
812
|
+
disabledAccountIds?: string[];
|
|
728
813
|
/**
|
|
729
814
|
* Model ids enabled in the picker. Absent means "every model the catalog
|
|
730
815
|
* advertises" — an unconfigured install should never present an empty model
|
package/lib/index.js
CHANGED
|
@@ -774,10 +774,38 @@ function candidateAuthDirs(env = process.env) {
|
|
|
774
774
|
* Read-only pool of every discovered WorkBuddy account, with rate-limit
|
|
775
775
|
* cooldown and round-robin failover.
|
|
776
776
|
*/
|
|
777
|
+
/** Idle bonus per hour an account has been unused (reference-panel default). */
|
|
778
|
+
const IDLE_WEIGHT_PER_HOUR = .5;
|
|
779
|
+
/** Ceiling for the idle bonus, so an idle account cannot dominate forever. */
|
|
780
|
+
const IDLE_WEIGHT_MAX = 5;
|
|
781
|
+
/**
|
|
782
|
+
* Weight one account by how long it has been idle.
|
|
783
|
+
*
|
|
784
|
+
* The base of 1 keeps every eligible account in play: an account that served a
|
|
785
|
+
* moment ago still has a small chance, so a single unhealthy account cannot pin
|
|
786
|
+
* the pool to itself, and the weights never sum to zero.
|
|
787
|
+
*
|
|
788
|
+
* `lastUsedAt === undefined` means "never used in this process", which earns the
|
|
789
|
+
* full bonus: on a fresh start every account ties, and the weighted draw spreads
|
|
790
|
+
* the first requests instead of always picking the first entry.
|
|
791
|
+
*/
|
|
792
|
+
function idleWeight(lastUsedAt, now) {
|
|
793
|
+
if (lastUsedAt === void 0) return 6;
|
|
794
|
+
const hours = (now - lastUsedAt) / 36e5;
|
|
795
|
+
return 1 + Math.min(Math.max(hours, 0) * IDLE_WEIGHT_PER_HOUR, IDLE_WEIGHT_MAX);
|
|
796
|
+
}
|
|
797
|
+
/** Default rest for an account whose credits ran out (packs reset on their own schedule). */
|
|
798
|
+
const EXHAUST_COOLDOWN_MS = 18e5;
|
|
777
799
|
var WorkBuddyAccountPool = class {
|
|
778
800
|
logger;
|
|
779
801
|
authDirs;
|
|
780
802
|
cooldownMs;
|
|
803
|
+
/**
|
|
804
|
+
* How long an account stays out of rotation after the upstream reports its
|
|
805
|
+
* credits are spent. Credit packs reset on their own schedule rather than on a
|
|
806
|
+
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
807
|
+
*/
|
|
808
|
+
exhaustCooldownMs;
|
|
781
809
|
client;
|
|
782
810
|
refreshMarginMs;
|
|
783
811
|
accounts = [];
|
|
@@ -786,11 +814,30 @@ var WorkBuddyAccountPool = class {
|
|
|
786
814
|
cursor = 0;
|
|
787
815
|
lastScanAtMs = 0;
|
|
788
816
|
preferredId;
|
|
817
|
+
/**
|
|
818
|
+
* Account ids the user switched off on the card.
|
|
819
|
+
*
|
|
820
|
+
* Disabling is a user preference rather than a property of the credential:
|
|
821
|
+
* `scan()` rebuilds every account object from the auth files, so the set
|
|
822
|
+
* lives on the pool and is re-applied from settings after each scan.
|
|
823
|
+
*/
|
|
824
|
+
disabledIds = /* @__PURE__ */ new Set();
|
|
825
|
+
/**
|
|
826
|
+
* Last time each account served a request, epoch ms. Drives the idle term
|
|
827
|
+
* of the priority-mode weighting below: an account that just served loses to
|
|
828
|
+
* one that has been idle, so a small pool stops hammering a single account.
|
|
829
|
+
*
|
|
830
|
+
* In-memory on purpose: it only biases the next pick, so a cold start that
|
|
831
|
+
* treats every account as idle is the right default. Not keyed by id lookup
|
|
832
|
+
* misses because a removed account simply disappears from the map on re-scan.
|
|
833
|
+
*/
|
|
834
|
+
lastUsedAt = /* @__PURE__ */ new Map();
|
|
789
835
|
refreshInflight = /* @__PURE__ */ new Map();
|
|
790
836
|
constructor(options = {}) {
|
|
791
837
|
this.logger = options.logger;
|
|
792
838
|
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
793
839
|
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
840
|
+
this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
|
|
794
841
|
this.client = options.client;
|
|
795
842
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
796
843
|
this.distribution = options.distribution ?? "priority";
|
|
@@ -803,7 +850,9 @@ var WorkBuddyAccountPool = class {
|
|
|
803
850
|
applyConfig(options) {
|
|
804
851
|
if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
|
|
805
852
|
if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
|
|
853
|
+
if (options.exhaustCooldownMs !== void 0 && options.exhaustCooldownMs >= 1e3) this.exhaustCooldownMs = options.exhaustCooldownMs;
|
|
806
854
|
if (options.distribution !== void 0) this.distribution = options.distribution;
|
|
855
|
+
if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
|
|
807
856
|
}
|
|
808
857
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
809
858
|
async scan() {
|
|
@@ -856,12 +905,47 @@ var WorkBuddyAccountPool = class {
|
|
|
856
905
|
*/
|
|
857
906
|
available(now, modelId, region) {
|
|
858
907
|
return this.accounts.filter((account) => {
|
|
908
|
+
if (this.disabledIds.has(account.id)) return false;
|
|
859
909
|
if (account.cooldownUntilMs > now) return false;
|
|
860
910
|
if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
|
|
861
911
|
if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
|
|
862
912
|
return true;
|
|
863
913
|
});
|
|
864
914
|
}
|
|
915
|
+
/** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
|
|
916
|
+
pickRoundRobin(pool) {
|
|
917
|
+
const index = this.cursor % pool.length;
|
|
918
|
+
const account = pool[index];
|
|
919
|
+
if (account === void 0) return void 0;
|
|
920
|
+
this.cursor = (index + 1) % pool.length;
|
|
921
|
+
return account;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Priority mode: weighted random over the eligible accounts.
|
|
925
|
+
*
|
|
926
|
+
* The weight is an idle bonus — `1 + min(idleHours * perHour, max)` — so an
|
|
927
|
+
* account that has never served (or has been idle for a while) outranks one
|
|
928
|
+
* that just answered. Reference panel logic drops its success-rate term
|
|
929
|
+
* entirely because a lifetime error counter penalises an account forever;
|
|
930
|
+
* instantaneous health is already handled by cooldowns, which is why those
|
|
931
|
+
* accounts never reach this list.
|
|
932
|
+
*
|
|
933
|
+
* A pool with no idle history (fresh process) hashes to equal weights, which
|
|
934
|
+
* spreads the very first picks instead of always returning index 0.
|
|
935
|
+
*/
|
|
936
|
+
pickByWeight(pool) {
|
|
937
|
+
if (pool.length === 1) return pool[0];
|
|
938
|
+
const now = Date.now();
|
|
939
|
+
const weights = pool.map((account) => idleWeight(this.lastUsedAt.get(account.id), now));
|
|
940
|
+
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
|
941
|
+
if (!Number.isFinite(total) || total <= 0) return pool[0];
|
|
942
|
+
let roll = Math.random() * total;
|
|
943
|
+
for (let index = 0; index < pool.length; index += 1) {
|
|
944
|
+
roll -= weights[index] ?? 0;
|
|
945
|
+
if (roll < 0) return pool[index];
|
|
946
|
+
}
|
|
947
|
+
return pool[pool.length - 1];
|
|
948
|
+
}
|
|
865
949
|
/**
|
|
866
950
|
* Pick the account to serve a request.
|
|
867
951
|
*
|
|
@@ -891,16 +975,14 @@ var WorkBuddyAccountPool = class {
|
|
|
891
975
|
}
|
|
892
976
|
if (pool.length === 0) return void 0;
|
|
893
977
|
if (this.preferredId !== void 0) {
|
|
894
|
-
const
|
|
895
|
-
if (
|
|
896
|
-
|
|
897
|
-
|
|
978
|
+
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
979
|
+
if (preferred !== void 0) {
|
|
980
|
+
await this.ensureFresh(preferred);
|
|
981
|
+
return preferred;
|
|
898
982
|
}
|
|
899
983
|
}
|
|
900
|
-
const
|
|
901
|
-
const account = pool[index];
|
|
984
|
+
const account = this.distribution === "round-robin" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
902
985
|
if (account === void 0) return void 0;
|
|
903
|
-
if (this.distribution === "round-robin") this.cursor = (index + 1) % pool.length;
|
|
904
986
|
await this.ensureFresh(account);
|
|
905
987
|
return account;
|
|
906
988
|
}
|
|
@@ -912,6 +994,48 @@ var WorkBuddyAccountPool = class {
|
|
|
912
994
|
prefer(accountId) {
|
|
913
995
|
this.preferredId = accountId;
|
|
914
996
|
}
|
|
997
|
+
/** Whether the user switched this account off on the card. */
|
|
998
|
+
isDisabled(accountId) {
|
|
999
|
+
return this.disabledIds.has(accountId);
|
|
1000
|
+
}
|
|
1001
|
+
/** Every account id the user switched off, in discovery order. */
|
|
1002
|
+
disabledIdsInOrder() {
|
|
1003
|
+
return this.accounts.filter((account) => this.disabledIds.has(account.id)).map((account) => account.id);
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Record that an account actually served a request.
|
|
1007
|
+
*
|
|
1008
|
+
* Called by the shim once the upstream answers 200 — only then is the account
|
|
1009
|
+
* the one the user is really being served by. `balanced` mode reads the same map
|
|
1010
|
+
* for its idle weighting, so a request that failed over to another account must
|
|
1011
|
+
* not count as used for the account that was merely tried.
|
|
1012
|
+
*/
|
|
1013
|
+
noteServed(accountId) {
|
|
1014
|
+
if (!this.accounts.some((account) => account.id === accountId)) return;
|
|
1015
|
+
this.lastUsedAt.set(accountId, Date.now());
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* The account that served the most recent request, if any.
|
|
1019
|
+
*
|
|
1020
|
+
* Distinct from "who would serve the next one": this is a record of what
|
|
1021
|
+
* actually happened, which is what the card needs to answer "which account am
|
|
1022
|
+
* I using right now?". Under `balanced` there is no deterministic next account
|
|
1023
|
+
* at all, so a recorded fact is the only honest answer.
|
|
1024
|
+
*
|
|
1025
|
+
* Returns undefined before the first request of the process, and after every
|
|
1026
|
+
* known account has been re-scanned away (a login swapped out under us).
|
|
1027
|
+
*/
|
|
1028
|
+
lastServedId() {
|
|
1029
|
+
let newest;
|
|
1030
|
+
for (const [id, at] of this.lastUsedAt) {
|
|
1031
|
+
if (!this.accounts.some((account) => account.id === id)) continue;
|
|
1032
|
+
if (newest === void 0 || at > newest.at) newest = {
|
|
1033
|
+
id,
|
|
1034
|
+
at
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
return newest?.id;
|
|
1038
|
+
}
|
|
915
1039
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
916
1040
|
async refreshAccount(accountId) {
|
|
917
1041
|
const account = this.accounts.find((item) => item.id === accountId);
|
|
@@ -962,6 +1086,23 @@ var WorkBuddyAccountPool = class {
|
|
|
962
1086
|
}
|
|
963
1087
|
}
|
|
964
1088
|
/**
|
|
1089
|
+
* Cool a whole account after the upstream reports its credits are spent.
|
|
1090
|
+
*
|
|
1091
|
+
* Credit exhaustion is an ACCOUNT condition, unlike a model rate limit: every
|
|
1092
|
+
* model on that account is unusable until the quota resets, so this cools the
|
|
1093
|
+
* account as a whole (no `modelId`) for the configured exhaustion window. The
|
|
1094
|
+
* shim then rotates to a different account instead of failing the request.
|
|
1095
|
+
*/
|
|
1096
|
+
penalizeExhausted(accountId) {
|
|
1097
|
+
const until = Date.now() + this.exhaustCooldownMs;
|
|
1098
|
+
this.penalize(accountId, until);
|
|
1099
|
+
this.logger?.warn(`
|
|
1100
|
+
dsh-workbuddy-xdpool: account credits exhausted; cooling the whole account until
|
|
1101
|
+
|
|
1102
|
+
${new Date(until).toISOString()}
|
|
1103
|
+
`);
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
965
1106
|
* Mark an account (or one of its models) rate-limited.
|
|
966
1107
|
*
|
|
967
1108
|
* With `modelId`, only that model on the account is cooled — the account's
|
|
@@ -1333,10 +1474,10 @@ function createWorkBuddyAdapter(options) {
|
|
|
1333
1474
|
streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
|
|
1334
1475
|
retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-xdpool retryPolicy"),
|
|
1335
1476
|
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
1336
|
-
modelErrors: /* @__PURE__ */ new Map(),
|
|
1337
1477
|
...REQUEST_IMAGE_BUDGETS,
|
|
1338
1478
|
piProvider: provider
|
|
1339
1479
|
};
|
|
1480
|
+
Object.assign(profile, { modelErrors: /* @__PURE__ */ new Map() });
|
|
1340
1481
|
let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
1341
1482
|
return {
|
|
1342
1483
|
providerId,
|
|
@@ -1573,6 +1714,7 @@ function createWorkBuddyShim(options) {
|
|
|
1573
1714
|
const result = await client.chatStream(account.credential, prepared, controller.signal);
|
|
1574
1715
|
if (result.ok) {
|
|
1575
1716
|
logger?.info?.(`dsh-workbuddy-xdpool: served by ${account.label}`);
|
|
1717
|
+
pool.noteServed(account.id);
|
|
1576
1718
|
res.writeHead(200, {
|
|
1577
1719
|
"Content-Type": "text/event-stream",
|
|
1578
1720
|
"Cache-Control": "no-cache",
|
|
@@ -1601,6 +1743,11 @@ function createWorkBuddyShim(options) {
|
|
|
1601
1743
|
await pool.refreshAccount(account.id);
|
|
1602
1744
|
continue;
|
|
1603
1745
|
}
|
|
1746
|
+
if (result.kind === "hard_credit") {
|
|
1747
|
+
pool.penalizeExhausted(account.id);
|
|
1748
|
+
logger?.warn(`dsh-workbuddy-xdpool: ${account.label} has no credits left (attempt ${attempt + 1}/${maxAttempts}); rotating`);
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1604
1751
|
if (result.kind !== "soft_rate") break;
|
|
1605
1752
|
exhaustedByRateLimit = true;
|
|
1606
1753
|
pool.penalize(account.id, parseRateLimitReset(result.message), modelId);
|
|
@@ -1739,6 +1886,8 @@ const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset"
|
|
|
1739
1886
|
const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
|
|
1740
1887
|
/** Plugin-owned model-selection save endpoint (writes the settings section). */
|
|
1741
1888
|
const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
|
|
1889
|
+
/** Switch one account in or out of the pool (card toggle). */
|
|
1890
|
+
const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
|
|
1742
1891
|
//#endregion
|
|
1743
1892
|
//#region src/web-status.ts
|
|
1744
1893
|
/** Redact token-like content before it crosses to the browser. */
|
|
@@ -1830,7 +1979,25 @@ function parseSelection(body) {
|
|
|
1830
1979
|
}
|
|
1831
1980
|
return out;
|
|
1832
1981
|
}
|
|
1833
|
-
|
|
1982
|
+
/**
|
|
1983
|
+
* Validate the account-toggle body. Returns undefined for anything malformed so
|
|
1984
|
+
* the route answers 400 instead of writing a half-applied switch.
|
|
1985
|
+
*
|
|
1986
|
+
* The id must name a currently known account: accepting an arbitrary string
|
|
1987
|
+
* would let a stale tab (or a renamed credential) leave orphan ids in the
|
|
1988
|
+
* settings file that no card can ever switch back off.
|
|
1989
|
+
*/
|
|
1990
|
+
function parseAccountToggle(body, known) {
|
|
1991
|
+
const accountId = typeof body["accountId"] === "string" ? body["accountId"].trim() : "";
|
|
1992
|
+
const disabled = body["disabled"];
|
|
1993
|
+
if (accountId === "" || typeof disabled !== "boolean") return void 0;
|
|
1994
|
+
if (!known(accountId)) return void 0;
|
|
1995
|
+
return {
|
|
1996
|
+
accountId,
|
|
1997
|
+
disabled
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
function toWebAccount(account, disabled) {
|
|
1834
2001
|
const now = Date.now();
|
|
1835
2002
|
const cooling = account.cooldownUntilMs > now;
|
|
1836
2003
|
const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
|
|
@@ -1846,6 +2013,7 @@ function toWebAccount(account) {
|
|
|
1846
2013
|
cooling,
|
|
1847
2014
|
...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
|
|
1848
2015
|
...modelCooldowns.length === 0 ? {} : { modelCooldowns },
|
|
2016
|
+
disabled,
|
|
1849
2017
|
rateLimitHits: account.rateLimitHits
|
|
1850
2018
|
};
|
|
1851
2019
|
}
|
|
@@ -1878,7 +2046,7 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
1878
2046
|
const rows = [];
|
|
1879
2047
|
const now = Date.now();
|
|
1880
2048
|
for (const account of accounts) {
|
|
1881
|
-
const row = toWebAccount(account);
|
|
2049
|
+
const row = toWebAccount(account, deps.pool.isDisabled(account.id));
|
|
1882
2050
|
if (!row.cooling) {
|
|
1883
2051
|
try {
|
|
1884
2052
|
const credits = await deps.client.fetchCredits(account.credential);
|
|
@@ -1911,7 +2079,8 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
1911
2079
|
rows.push(row);
|
|
1912
2080
|
}
|
|
1913
2081
|
const cooling = rows.filter((row) => row.cooling).length;
|
|
1914
|
-
const
|
|
2082
|
+
const lastServed = deps.pool.lastServedId();
|
|
2083
|
+
const firstUsable = lastServed !== void 0 ? accounts.find((account) => account.id === lastServed) : accounts.find((account) => account.cooldownUntilMs <= now);
|
|
1915
2084
|
let shim;
|
|
1916
2085
|
if (deps.shim === void 0) shim = { running: false };
|
|
1917
2086
|
else try {
|
|
@@ -2040,8 +2209,31 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
2040
2209
|
}
|
|
2041
2210
|
}
|
|
2042
2211
|
});
|
|
2212
|
+
const disposeAccountDisable = ctx.webServer.register({
|
|
2213
|
+
kind: "exact",
|
|
2214
|
+
path: POOL_ACCOUNT_DISABLE_PATH,
|
|
2215
|
+
handler: async (req, res) => {
|
|
2216
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
2217
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
2218
|
+
if (deps.setAccountDisabled === void 0) return json(res, 503, { error: "settings service unavailable; the account switch cannot be saved" });
|
|
2219
|
+
try {
|
|
2220
|
+
const body = await readJsonBody(req);
|
|
2221
|
+
const known = new Set(deps.pool.list().map((account) => account.id));
|
|
2222
|
+
const toggle = parseAccountToggle(body, (id) => known.has(id));
|
|
2223
|
+
if (toggle === void 0) return json(res, 400, { error: "invalid account toggle payload" });
|
|
2224
|
+
await deps.setAccountDisabled(toggle.accountId, toggle.disabled);
|
|
2225
|
+
json(res, 200, {
|
|
2226
|
+
ok: true,
|
|
2227
|
+
...toggle
|
|
2228
|
+
});
|
|
2229
|
+
} catch (error) {
|
|
2230
|
+
json(res, 500, { error: safeMessage(error) });
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
});
|
|
2043
2234
|
return () => {
|
|
2044
2235
|
disposeCheckin();
|
|
2236
|
+
disposeAccountDisable();
|
|
2045
2237
|
disposeModelsSave();
|
|
2046
2238
|
disposeReset();
|
|
2047
2239
|
disposeRescan();
|
|
@@ -2100,6 +2292,7 @@ const Config = z.object({
|
|
|
2100
2292
|
authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
|
|
2101
2293
|
cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
|
|
2102
2294
|
distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
|
|
2295
|
+
disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
|
|
2103
2296
|
enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
|
|
2104
2297
|
imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
|
|
2105
2298
|
contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Legacy shared context budgets; used by a region with no per-region selection yet"),
|
|
@@ -2169,11 +2362,12 @@ function apply(ctx, config = {}) {
|
|
|
2169
2362
|
}
|
|
2170
2363
|
};
|
|
2171
2364
|
const applyConfigFromSource = () => {
|
|
2172
|
-
const { authFile, cooldownMs, distribution, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal } = current();
|
|
2365
|
+
const { authFile, cooldownMs, distribution, disabledAccountIds, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal } = current();
|
|
2173
2366
|
core.pool.applyConfig({
|
|
2174
2367
|
...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
|
|
2175
2368
|
...cooldownMs === void 0 ? {} : { cooldownMs },
|
|
2176
|
-
distribution: distribution ?? "priority"
|
|
2369
|
+
distribution: distribution ?? "priority",
|
|
2370
|
+
...disabledAccountIds === void 0 ? {} : { disabledAccountIds }
|
|
2177
2371
|
});
|
|
2178
2372
|
const legacySelection = {
|
|
2179
2373
|
...enabledModelIds === void 0 ? {} : { enabledModelIds },
|
|
@@ -2191,14 +2385,30 @@ function apply(ctx, config = {}) {
|
|
|
2191
2385
|
* the three model-selection keys, so the settings file cannot be steered from
|
|
2192
2386
|
* the browser; the catalog re-reads through `onChange` either way.
|
|
2193
2387
|
*/
|
|
2388
|
+
/**
|
|
2389
|
+
* Write one key of the plugin's own settings section. Only ever called with the
|
|
2390
|
+
* model-selection keys, the distribution, and the disabled-account list, so the
|
|
2391
|
+
* settings file cannot be steered from the browser; the catalogs and the pool
|
|
2392
|
+
* re-read through `onChange` either way.
|
|
2393
|
+
*
|
|
2394
|
+
* `set` returns a promise, so a rejection must be caught explicitly: a bare
|
|
2395
|
+
* `void write.call(...)` swallows it, and the card then looks like it saved
|
|
2396
|
+
* while the value never reached the settings file.
|
|
2397
|
+
*/
|
|
2194
2398
|
const setSetting = (key, value) => {
|
|
2195
2399
|
if (value === void 0) return;
|
|
2196
2400
|
const write = settingsService?.set;
|
|
2197
|
-
if (write === void 0)
|
|
2401
|
+
if (write === void 0) {
|
|
2402
|
+
ctx.logger.warn?.(`dsh-workbuddy-xdpool: no settings writer; ` + key + ` was not saved`);
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2198
2405
|
try {
|
|
2199
|
-
write.call(settingsService, key, value);
|
|
2406
|
+
const result = write.call(settingsService, key, value);
|
|
2407
|
+
if (result !== void 0 && typeof result.then === "function") result.catch((error) => {
|
|
2408
|
+
ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
|
|
2409
|
+
});
|
|
2200
2410
|
} catch (error) {
|
|
2201
|
-
ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist
|
|
2411
|
+
ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
|
|
2202
2412
|
}
|
|
2203
2413
|
};
|
|
2204
2414
|
const shims = {
|
|
@@ -2246,6 +2456,11 @@ function apply(ctx, config = {}) {
|
|
|
2246
2456
|
...selection.imageModelIds === void 0 ? {} : { imageModelIds: [...selection.imageModelIds] },
|
|
2247
2457
|
...selection.contextBudgets === void 0 ? {} : { contextBudgets: { ...selection.contextBudgets } }
|
|
2248
2458
|
});
|
|
2459
|
+
},
|
|
2460
|
+
setAccountDisabled: (accountId, disabled) => {
|
|
2461
|
+
const currentIds = current().disabledAccountIds ?? [];
|
|
2462
|
+
const next = disabled ? currentIds.includes(accountId) ? currentIds : [...currentIds, accountId] : currentIds.filter((id) => id !== accountId);
|
|
2463
|
+
setSetting("disabledAccountIds", next);
|
|
2249
2464
|
}
|
|
2250
2465
|
}));
|
|
2251
2466
|
api = {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-workbuddy-xdpool",
|
|
3
3
|
"displayName": "DSH WorkBuddy XD Pool",
|
|
4
4
|
"description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "1.0.1",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",
|