dsh-workbuddy-xdpool 1.7.8 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/README.md +5 -0
- package/lib/bin.js +74 -5
- package/lib/client.js +86 -21
- package/lib/index.d.ts +43 -5
- package/lib/index.js +104 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,90 @@
|
|
|
4
4
|
|
|
5
5
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
6
6
|
|
|
7
|
+
## 1.8.0 — 四种模式,终于排得像个样子了
|
|
8
|
+
|
|
9
|
+
> 上一版加了第四种模式「每对话固定」,功能是好的,排版是坏的。坏得很实在:格子还是按三个排的,第四个只好委屈地掉到下一行,占着半个身位,像个插班生。
|
|
10
|
+
|
|
11
|
+
### 问题出在哪
|
|
12
|
+
|
|
13
|
+
「账号使用方式」这一块本来写着 `grid-template-columns: repeat(3, ...)`——三列。模式从三个变成四个之后,没人去改这个数字。
|
|
14
|
+
|
|
15
|
+
于是:
|
|
16
|
+
|
|
17
|
+
- 第四张卡被挤到第二行,左边空出一大块,右边孤零零一张;
|
|
18
|
+
- 每张卡只剩大约 190 像素宽,中文说明被折成两三行,长短参差不齐,眼睛没法竖着扫;
|
|
19
|
+
- 更尴尬的是,**测试全绿、构建全过**——因为没有任何一个测试会去数 CSS 里的网格列数。这种错只能靠眼睛发现。
|
|
20
|
+
|
|
21
|
+
### 现在长什么样
|
|
22
|
+
|
|
23
|
+
- **两列排布**,四张卡正好 2×2,方方正正;窄窗口下自动变成一列,每张卡独占一行,说明文字不再折行。
|
|
24
|
+
- **顶部多了一个「当前:每对话固定」的小徽章**,不用在四张卡里自己找哪张亮着。
|
|
25
|
+
- **选中的那张卡明显抬高**:底色提亮一层,左侧加一条竖色条,一眼就能认出来。
|
|
26
|
+
- 「推荐」不再塞进标题文字里(`每对话固定(推荐)`这种写法会让标题长度不齐),改成一个**独立的小绿标**挂在卡片右上角。
|
|
27
|
+
|
|
28
|
+
### 配色上的一点坚持
|
|
29
|
+
|
|
30
|
+
这次参考了 `dingminhua/dsh-connect-workbuddy` 的界面,它的思路很清楚:给「当前项」一条竖色条 + 抬高的底色。这套手法我们借来了。
|
|
31
|
+
|
|
32
|
+
但**颜色没借**。它用的是宿主品牌蓝 `#5686fe`——那是 DSH 自己的颜色,不是这张卡片的。我们的卡片有自己的一套:底色用和上面区域切换按钮选中时同一个「抬升表面」色,竖色条用中性色,和宿主保持一致但不冒充宿主。
|
|
33
|
+
|
|
34
|
+
绿色只留给「推荐」徽章,因为卡片下方那个绿点已经在表示「账号健康」了,别让两种绿打架。
|
|
35
|
+
|
|
36
|
+
### 已知边界
|
|
37
|
+
|
|
38
|
+
- 这次只动了样式和排版,**分发逻辑一行没改**——四种模式的行为和 1.7.9 完全一样。
|
|
39
|
+
- 布局测试是**读源码文本**断言的(比如「网格必须是两列」「选中态不许出现 brand-primary」),能防住改错,但防不住视觉上「两列也不好看」。真正的判断还得靠眼睛。
|
|
40
|
+
|
|
41
|
+
## 1.7.9 — 一个对话只用一个账号,新对话才换人
|
|
42
|
+
|
|
43
|
+
> 有人反馈:除了「优先模式」,换别的模式都会掉命中率、掉速度。这个观察是对的,而且原因不在你的网络——在账号切换本身。所以这次加了一种新模式,专门解决这件事。
|
|
44
|
+
|
|
45
|
+
### 为什么换个模式就会掉命中率
|
|
46
|
+
|
|
47
|
+
上游的 prompt 缓存是按**账号**隔离的,不是按你这个人。
|
|
48
|
+
|
|
49
|
+
所以「轮换模式」和「均衡模式」每发一条消息都可能换一个账号,对上游来说就是换了一个租户——**整段对话的缓存全部作废,从第一个字重新算**。命中率当然掉,首字延迟当然涨。
|
|
50
|
+
|
|
51
|
+
而「优先模式」把这一切都押在一个账号上:缓存稳了,速度好了,代价是这个账号的额度会被你一个人用完。
|
|
52
|
+
|
|
53
|
+
一句话:以前你只能在「额度分摊」和「缓存命中」之间选一个。
|
|
54
|
+
|
|
55
|
+
### 新增「每对话固定」模式
|
|
56
|
+
|
|
57
|
+
现在可以两个都要。
|
|
58
|
+
|
|
59
|
+
规则很简单:**一个对话从头到尾只用一个账号;你新开一个对话,才轮到下一个账号。**
|
|
60
|
+
|
|
61
|
+
- 同一个对话的每一轮都打同一个账号 → 上游缓存一直是热的 → 命中率和速度都是优先模式的水平。
|
|
62
|
+
- 新对话按顺序铺到下一个账号 → 额度还是分散在几个账号上,不会只烧一个。
|
|
63
|
+
|
|
64
|
+
卡片上「账号使用方式」里多了一个「每对话固定(推荐)」,点一下就能切。
|
|
65
|
+
|
|
66
|
+
**默认值仍然是「优先模式」,没有偷偷改你的设置。** 想试新模式,自己点一下;不满意,再点回来。
|
|
67
|
+
|
|
68
|
+
### 账号被限流或被你关掉,会自动换绑
|
|
69
|
+
|
|
70
|
+
绑定不是死契。如果这个对话的账号:
|
|
71
|
+
|
|
72
|
+
- 被上游限流了(只针对当前模型,别的模型照用),
|
|
73
|
+
- 被你在卡片上关掉了,
|
|
74
|
+
- 或者积分到了你设的保留线,
|
|
75
|
+
|
|
76
|
+
那么这个对话下一轮会自动换到一个能用的账号,并且**以后就固定在新的那个账号上**——不会每轮都重新挑,也不会因为一个账号打盹就让整个对话报错。
|
|
77
|
+
|
|
78
|
+
### 四种模式怎么选
|
|
79
|
+
|
|
80
|
+
- **每对话固定**:多个对话同时用,既想省额度又想保住命中率。推荐先试这个。
|
|
81
|
+
- **优先模式**:就一个对话,或者只有一个账号额度够用。缓存最稳。
|
|
82
|
+
- **轮换模式**:不在乎速度,只想让几个账号的积分严格平均地掉。
|
|
83
|
+
- **均衡模式**:随机抽,闲置越久的账号越容易被选中;比轮换更不容易被一个不健康的账号拖住。
|
|
84
|
+
|
|
85
|
+
### 已知边界
|
|
86
|
+
|
|
87
|
+
- 绑定只存在内存里,重启 DSH 后就忘了。影响很小:重启只是让每个对话重新认一次账号,之后照样固定。
|
|
88
|
+
- 绑定按**对话的第一条用户消息**区分。两个对话如果第一句话一字不差,会被当成同一个对话、共用账号。这种情况很少见,共用了也没有坏处,只是共享一份缓存。
|
|
89
|
+
- 最多记住 200 个对话的绑定,超了会淘汰最久没用过的那个。被淘汰的对话下一轮重新绑定一次,仅此一次,行为不受影响。
|
|
90
|
+
|
|
7
91
|
## 1.7.8 — 说清楚卡片在哪儿、以及我们支持到哪个版本
|
|
8
92
|
|
|
9
93
|
> 这次没有功能问题,全在「声明」上:一处把你引到不存在的路径,一处让你以为装错了版本。
|
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
- **零配置**:装上、打开,就完事了。WorkBuddy 桌面 App 里每个登过的账号都会被自动发现并入池,不需要在插件里录任何东西。
|
|
43
43
|
|
|
44
44
|
- **自动容错换号**:池子记着每个账号的限流状态。某个号触发 429 就冷却它,后面的请求自动落到下一个健康的号上;冷却结束自动恢复。所有号都在冷却时,请求才排队等待。
|
|
45
|
+
- **四种账号分配方式**:`priority` 先用完一个号;`round-robin` 按顺序轮换;`balanced` 随机抽、偏向闲置久的;`sticky` 让**一个对话只用一个账号,新对话才换下一个**——上游缓存按账号隔离,中途换号等于把整段对话的缓存作废,`sticky` 就是为这件事准备的。卡片上点一下即可切换,默认仍是 `priority`。
|
|
45
46
|
|
|
46
47
|
- **池健康一眼看清**:卡片显示「几个账号 / 几个在冷却」「下一个会轮到谁」,以及每个账号的令牌有效期和冷却倒计时。
|
|
47
48
|
|
|
@@ -176,12 +177,16 @@ dsh plugin --profile desktop exec dsh-workbuddy-xdpool remove myKey
|
|
|
176
177
|
| --- | --- | --- |
|
|
177
178
|
| `authFile` | 覆盖 WorkBuddy 桌面 auth 文件路径(跨平台自动探测出问题时用,等同于 `WORKBUDDY_AUTH_FILE`) | 自动探测 |
|
|
178
179
|
| `cooldownMs` | 单账号 429 冷却时长(毫秒) | `60000` |
|
|
180
|
+
| `distribution` | 账号分配方式:`sticky`(每对话固定一个账号,新对话换下一个)/ `priority`(先用完一个)/ `round-robin`(按顺序轮换)/ `balanced`(随机,偏向闲置久的) | `priority` |
|
|
181
|
+
|
|
182
|
+
> 上游 prompt 缓存按账号隔离,所以对话中途换号会把缓存整段作废。想让多账号同时分摊额度又不想掉命中率,用 `sticky`。
|
|
179
183
|
|
|
180
184
|
也可以直接写进 `~/.dsh/settings.yaml`:
|
|
181
185
|
|
|
182
186
|
```yaml
|
|
183
187
|
workbuddy-xdpool:
|
|
184
188
|
cooldownMs: 120000
|
|
189
|
+
distribution: sticky
|
|
185
190
|
```
|
|
186
191
|
|
|
187
192
|
## 架构
|
package/lib/bin.js
CHANGED
|
@@ -2612,6 +2612,13 @@ function candidateAuthDirs(env = process.env) {
|
|
|
2612
2612
|
return dirs;
|
|
2613
2613
|
}
|
|
2614
2614
|
/**
|
|
2615
|
+
* How many conversation→account bindings `sticky` mode remembers.
|
|
2616
|
+
*
|
|
2617
|
+
* Only a memory bound: evicting the oldest binding costs one re-pick on that
|
|
2618
|
+
* conversation's next turn, it never loses an account or a request.
|
|
2619
|
+
*/
|
|
2620
|
+
const STICKY_AFFINITY_LIMIT = 200;
|
|
2621
|
+
/**
|
|
2615
2622
|
* Read-only pool of every discovered WorkBuddy account, with rate-limit
|
|
2616
2623
|
* cooldown and round-robin failover.
|
|
2617
2624
|
*/
|
|
@@ -2660,6 +2667,15 @@ var WorkBuddyAccountPool = class {
|
|
|
2660
2667
|
distribution;
|
|
2661
2668
|
/** Cursor for round-robin mode; unused under priority distribution. */
|
|
2662
2669
|
cursor = 0;
|
|
2670
|
+
/**
|
|
2671
|
+
* `sticky` mode: conversation key → account id.
|
|
2672
|
+
*
|
|
2673
|
+
* A conversation that keeps the same account also keeps that account's
|
|
2674
|
+
* upstream prompt cache warm — the cache is per tenant, so rotating accounts
|
|
2675
|
+
* mid-conversation pays full prompt cost on every turn. Insertion order is
|
|
2676
|
+
* the LRU order: re-binding deletes then re-inserts.
|
|
2677
|
+
*/
|
|
2678
|
+
affinity = /* @__PURE__ */ new Map();
|
|
2663
2679
|
lastScanAtMs = 0;
|
|
2664
2680
|
preferredId;
|
|
2665
2681
|
/**
|
|
@@ -2918,6 +2934,45 @@ var WorkBuddyAccountPool = class {
|
|
|
2918
2934
|
this.cursor = (index + 1) % pool.length;
|
|
2919
2935
|
return account;
|
|
2920
2936
|
}
|
|
2937
|
+
/** Remember which account a conversation is bound to, keeping LRU order. */
|
|
2938
|
+
bindAffinity(conversationKey, accountId) {
|
|
2939
|
+
this.affinity.delete(conversationKey);
|
|
2940
|
+
this.affinity.set(conversationKey, accountId);
|
|
2941
|
+
while (this.affinity.size > STICKY_AFFINITY_LIMIT) {
|
|
2942
|
+
const oldest = this.affinity.keys().next().value;
|
|
2943
|
+
if (oldest === void 0) break;
|
|
2944
|
+
this.affinity.delete(oldest);
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* `sticky`: the account this conversation already used, when it can still
|
|
2949
|
+
* serve the model being asked for.
|
|
2950
|
+
*
|
|
2951
|
+
* Returns `undefined` both when there is no binding and when the binding is
|
|
2952
|
+
* no longer eligible (cooling for this model, disabled, out of credits) — the
|
|
2953
|
+
* caller then rebinds, which is what makes a rate-limited conversation hop to
|
|
2954
|
+
* a fresh account instead of failing.
|
|
2955
|
+
*/
|
|
2956
|
+
affinityAccount(pool, conversationKey) {
|
|
2957
|
+
if (conversationKey === void 0 || conversationKey === "") return void 0;
|
|
2958
|
+
const boundId = this.affinity.get(conversationKey);
|
|
2959
|
+
if (boundId === void 0) return void 0;
|
|
2960
|
+
const bound = pool.find((account) => account.id === boundId);
|
|
2961
|
+
if (bound === void 0) {
|
|
2962
|
+
this.affinity.delete(conversationKey);
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
this.bindAffinity(conversationKey, boundId);
|
|
2966
|
+
return bound;
|
|
2967
|
+
}
|
|
2968
|
+
/** Bindings currently remembered; exposed for tests and diagnostics. */
|
|
2969
|
+
affinitySize() {
|
|
2970
|
+
return this.affinity.size;
|
|
2971
|
+
}
|
|
2972
|
+
/** Forget every conversation binding (tests, and a settings change). */
|
|
2973
|
+
clearAffinity() {
|
|
2974
|
+
this.affinity.clear();
|
|
2975
|
+
}
|
|
2921
2976
|
/**
|
|
2922
2977
|
* Priority mode: weighted random over the eligible accounts.
|
|
2923
2978
|
*
|
|
@@ -2956,15 +3011,20 @@ var WorkBuddyAccountPool = class {
|
|
|
2956
3011
|
* it resumes straight away.
|
|
2957
3012
|
* - **round-robin**: consecutive requests rotate through the pool so spend
|
|
2958
3013
|
* spreads evenly across every account.
|
|
3014
|
+
* - **sticky**: one account per conversation, and a new conversation moves to
|
|
3015
|
+
* the next account in order. Keeps the upstream prompt cache warm within a
|
|
3016
|
+
* conversation while still spreading spend across conversations.
|
|
2959
3017
|
*
|
|
2960
|
-
* In
|
|
3018
|
+
* In every mode an explicit user selection (`prefer`) heads the list, a
|
|
2961
3019
|
* cooling account is skipped for that model only, and an unrecognised setting
|
|
2962
3020
|
* falls back to priority.
|
|
2963
3021
|
*
|
|
2964
3022
|
* Scans on first use, and rescans when every known account is cooling down: a
|
|
2965
3023
|
* fresh desktop login is the usual way out of an exhausted pool.
|
|
3024
|
+
*
|
|
3025
|
+
* `conversationKey` is only consulted under `sticky`; other modes ignore it.
|
|
2966
3026
|
*/
|
|
2967
|
-
async acquire(modelId, region) {
|
|
3027
|
+
async acquire(modelId, region, conversationKey) {
|
|
2968
3028
|
if (this.accounts.length === 0) await this.scan();
|
|
2969
3029
|
let pool = this.available(Date.now(), modelId, region);
|
|
2970
3030
|
if (pool.length === 0) {
|
|
@@ -2979,8 +3039,16 @@ var WorkBuddyAccountPool = class {
|
|
|
2979
3039
|
return preferred;
|
|
2980
3040
|
}
|
|
2981
3041
|
}
|
|
2982
|
-
|
|
3042
|
+
if (this.distribution === "sticky") {
|
|
3043
|
+
const bound = this.affinityAccount(pool, conversationKey);
|
|
3044
|
+
if (bound !== void 0) {
|
|
3045
|
+
await this.ensureFresh(bound);
|
|
3046
|
+
return bound;
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
2983
3050
|
if (account === void 0) return void 0;
|
|
3051
|
+
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
2984
3052
|
await this.ensureFresh(account);
|
|
2985
3053
|
return account;
|
|
2986
3054
|
}
|
|
@@ -5080,8 +5148,9 @@ z.object({
|
|
|
5080
5148
|
distribution: asVolatile(z.union([
|
|
5081
5149
|
"priority",
|
|
5082
5150
|
"round-robin",
|
|
5083
|
-
"balanced"
|
|
5084
|
-
|
|
5151
|
+
"balanced",
|
|
5152
|
+
"sticky"
|
|
5153
|
+
]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), balanced (idle-weighted random), or sticky (one account per conversation, new conversations rotate)")),
|
|
5085
5154
|
disabledAccountIds: asVolatile(z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)")),
|
|
5086
5155
|
creditReserves: asVolatile(z.dict(z.number().step(1).min(0)).default({}).description("Per-account credit floor: stop using an account once its balance reaches this value")),
|
|
5087
5156
|
enabledModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet")),
|
package/lib/client.js
CHANGED
|
@@ -165,17 +165,38 @@ window.__ModuleLoader__.load({
|
|
|
165
165
|
.dsm-workbuddy-xdpool-usage-status{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
166
166
|
.dsm-workbuddy-xdpool-usage-dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto}
|
|
167
167
|
.dsm-workbuddy-xdpool-usage-hint{margin:0;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
|
|
168
|
-
/*
|
|
169
|
-
|
|
168
|
+
/*
|
|
169
|
+
* Distribution picker: four modes, so the old three-column grid left the
|
|
170
|
+
* fourth card wrapped onto a line of its own. Two columns give every card room
|
|
171
|
+
* for a full hint sentence and keep the reading order 2x2.
|
|
172
|
+
*
|
|
173
|
+
* The selection cue is this card's own: an inset accent bar plus a solid
|
|
174
|
+
* surface, the same "current one is a raised chip" language the account list
|
|
175
|
+
* uses for the serving account. It is deliberately NOT the brand blue (that is
|
|
176
|
+
* the host's, not ours) and not the health green (that already means "this
|
|
177
|
+
* account works" two cards down).
|
|
178
|
+
*/
|
|
179
|
+
.dsm-workbuddy-xdpool-dist{display:flex;flex-direction:column;gap:9px}
|
|
180
|
+
.dsm-workbuddy-xdpool-dist-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
|
|
170
181
|
.dsm-workbuddy-xdpool-dist-title{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px;letter-spacing:.03em;text-transform:uppercase;font-weight:600}
|
|
171
|
-
|
|
172
|
-
.
|
|
182
|
+
/* "now: X" — repeats the answer in the header so the user does not have to
|
|
183
|
+
scan four cards for the raised one. */
|
|
184
|
+
.dsm-workbuddy-xdpool-dist-now{flex:none;padding:2px 10px;border-radius:999px;border:1px solid color-mix(in oklab, var(--dsw-alias-label-primary,#e6e6e6) 26%, transparent);background:color-mix(in oklab, var(--dsw-alias-label-primary,#e6e6e6) 10%, transparent);color:var(--dsw-alias-label-primary,#e6e6e6);font-size:12px;font-weight:600;line-height:18px;white-space:nowrap}
|
|
185
|
+
.dsm-workbuddy-xdpool-dist-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
|
186
|
+
.dsm-workbuddy-xdpool-dist-option{appearance:none;font:inherit;cursor:pointer;text-align:left;display:flex;flex-direction:column;justify-content:center;gap:4px;min-height:62px;border:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 80%, transparent);border-radius:12px;padding:9px 13px;background:transparent;color:var(--dsw-alias-label-tertiary,#9aa0a8);transition:color .16s,border-color .16s,background .16s,box-shadow .16s}
|
|
187
|
+
.dsm-workbuddy-xdpool-dist-option:hover:not(:disabled):not(.dsm-workbuddy-xdpool-dist-option-active){color:var(--dsw-alias-label-secondary,#c6c9d0);border-color:var(--dsw-alias-label-dimmed,#777);background:rgba(255,255,255,.03)}
|
|
173
188
|
.dsm-workbuddy-xdpool-dist-option:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
|
|
174
|
-
|
|
189
|
+
/* Selected: raised onto layer-3 (the same surface the region tabs use when
|
|
190
|
+
active) with an inset accent bar. The bar is what survives a glance — a
|
|
191
|
+
tinted wash alone is indistinguishable from hover. */
|
|
192
|
+
.dsm-workbuddy-xdpool-dist-option-active{background:var(--dsw-alias-bg-layer-3,#2a2c33);border-color:color-mix(in oklab, var(--dsw-alias-label-primary,#e6e6e6) 34%, transparent);box-shadow:inset 3px 0 0 var(--dsw-alias-label-primary,#e6e6e6);color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
193
|
+
.dsm-workbuddy-xdpool-dist-option-active .dsm-workbuddy-xdpool-dist-option-name{color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
175
194
|
.dsm-workbuddy-xdpool-dist-option:disabled{cursor:default;opacity:.6}
|
|
176
|
-
.dsm-workbuddy-xdpool-dist-option-
|
|
177
|
-
.dsm-workbuddy-xdpool-dist-option-
|
|
178
|
-
|
|
195
|
+
.dsm-workbuddy-xdpool-dist-option-top{display:flex;align-items:center;gap:7px;min-width:0}
|
|
196
|
+
.dsm-workbuddy-xdpool-dist-option-name{font-size:12.5px;line-height:18px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
197
|
+
/* "recommended" chip: quiet, and only on the mode this release argues for. */
|
|
198
|
+
.dsm-workbuddy-xdpool-dist-option-badge{flex:none;margin-left:auto;padding:1px 7px;border-radius:999px;background:var(--dsw-alias-state-success-subtle,rgba(34,160,107,.14));color:var(--dsw-alias-state-success-primary,#22a06b);font-size:10px;font-weight:600;line-height:15px;white-space:nowrap}
|
|
199
|
+
.dsm-workbuddy-xdpool-dist-option-hint{font-size:11px;line-height:16px;opacity:.85}
|
|
179
200
|
.dsm-workbuddy-xdpool-usage-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
180
201
|
|
|
181
202
|
/* Account list (each account = a labeled subpanel, same as dingminhua). */
|
|
@@ -334,6 +355,10 @@ window.__ModuleLoader__.load({
|
|
|
334
355
|
.dsm-workbuddy-xdpool-checkin{align-items:stretch}
|
|
335
356
|
.dsm-workbuddy-xdpool-checkin-meta{align-items:flex-start}
|
|
336
357
|
.dsm-workbuddy-xdpool-checkin-bonus{text-align:left}
|
|
358
|
+
/* One mode per row: at this width a two-up grid leaves ~150px per card, and
|
|
359
|
+
the hint sentence wraps to four lines. */
|
|
360
|
+
.dsm-workbuddy-xdpool-dist-options{grid-template-columns:minmax(0,1fr)}
|
|
361
|
+
.dsm-workbuddy-xdpool-dist-option{min-height:0}
|
|
337
362
|
}
|
|
338
363
|
|
|
339
364
|
/* Automation panel: schedule and last-run summary, collapsed behind a switch. */
|
|
@@ -597,6 +622,32 @@ window.__ModuleLoader__.load({
|
|
|
597
622
|
if (value >= 1e3 && value % 1e3 === 0) return `${value / 1e3}K`;
|
|
598
623
|
return String(value);
|
|
599
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* The four ways the pool can hand out accounts.
|
|
627
|
+
*
|
|
628
|
+
* Order is the reading order of the picker: the recommended mode first, then
|
|
629
|
+
* the two cache-friendly extremes, then the two spreading modes.
|
|
630
|
+
*/
|
|
631
|
+
const DIST_OPTIONS = [
|
|
632
|
+
"sticky",
|
|
633
|
+
"priority",
|
|
634
|
+
"balanced",
|
|
635
|
+
"round-robin"
|
|
636
|
+
];
|
|
637
|
+
/** Localized name of one distribution mode. */
|
|
638
|
+
function distLabel(option, t) {
|
|
639
|
+
if (option === "sticky") return t?.("row.distSticky") ?? "Per conversation";
|
|
640
|
+
if (option === "priority") return t?.("row.distPriority") ?? "Priority";
|
|
641
|
+
if (option === "balanced") return t?.("row.distBalanced") ?? "Balanced";
|
|
642
|
+
return t?.("row.distRoundRobin") ?? "Round-robin";
|
|
643
|
+
}
|
|
644
|
+
/** One-line explanation of what a mode does to caching and spend. */
|
|
645
|
+
function distHint(option, t) {
|
|
646
|
+
if (option === "sticky") return t?.("row.distStickyHint") ?? "";
|
|
647
|
+
if (option === "priority") return t?.("row.distPriorityHint") ?? "";
|
|
648
|
+
if (option === "balanced") return t?.("row.distBalancedHint") ?? "";
|
|
649
|
+
return t?.("row.distRoundRobinHint") ?? "";
|
|
650
|
+
}
|
|
600
651
|
/** Build the draft from the server's selection + catalog flags. */
|
|
601
652
|
function draftFromStatus(status) {
|
|
602
653
|
const selection = status.selection;
|
|
@@ -1298,19 +1349,21 @@ window.__ModuleLoader__.load({
|
|
|
1298
1349
|
className: "dsm-workbuddy-xdpool-dist",
|
|
1299
1350
|
role: "radiogroup",
|
|
1300
1351
|
"aria-label": t?.("row.distTitle") ?? "Account usage",
|
|
1301
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.
|
|
1302
|
-
className: "dsm-workbuddy-xdpool-dist-
|
|
1303
|
-
children:
|
|
1352
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1353
|
+
className: "dsm-workbuddy-xdpool-dist-head",
|
|
1354
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1355
|
+
className: "dsm-workbuddy-xdpool-dist-title",
|
|
1356
|
+
children: t?.("row.distTitle") ?? "Account usage"
|
|
1357
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1358
|
+
className: "dsm-workbuddy-xdpool-dist-now",
|
|
1359
|
+
children: distLabel(status.distribution ?? "priority", t)
|
|
1360
|
+
})]
|
|
1304
1361
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1305
1362
|
className: "dsm-workbuddy-xdpool-dist-options",
|
|
1306
|
-
children:
|
|
1307
|
-
"priority",
|
|
1308
|
-
"balanced",
|
|
1309
|
-
"round-robin"
|
|
1310
|
-
].map((option) => {
|
|
1363
|
+
children: DIST_OPTIONS.map((option) => {
|
|
1311
1364
|
const active = (status.distribution ?? "priority") === option;
|
|
1312
|
-
const label =
|
|
1313
|
-
const hint =
|
|
1365
|
+
const label = distLabel(option, t);
|
|
1366
|
+
const hint = distHint(option, t);
|
|
1314
1367
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1315
1368
|
type: "button",
|
|
1316
1369
|
role: "radio",
|
|
@@ -1321,9 +1374,15 @@ window.__ModuleLoader__.load({
|
|
|
1321
1374
|
onClick: () => {
|
|
1322
1375
|
setDistribution(option);
|
|
1323
1376
|
},
|
|
1324
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.
|
|
1325
|
-
className: "dsm-workbuddy-xdpool-dist-option-
|
|
1326
|
-
children:
|
|
1377
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1378
|
+
className: "dsm-workbuddy-xdpool-dist-option-top",
|
|
1379
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1380
|
+
className: "dsm-workbuddy-xdpool-dist-option-name",
|
|
1381
|
+
children: label
|
|
1382
|
+
}), option === "sticky" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1383
|
+
className: "dsm-workbuddy-xdpool-dist-option-badge",
|
|
1384
|
+
children: t?.("row.distRecommended") ?? "Recommended"
|
|
1385
|
+
}) : null]
|
|
1327
1386
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1328
1387
|
className: "dsm-workbuddy-xdpool-dist-option-hint",
|
|
1329
1388
|
children: hint
|
|
@@ -2282,6 +2341,9 @@ window.__ModuleLoader__.load({
|
|
|
2282
2341
|
"row.distRoundRobinHint": "Take turns in order, spreading the spend evenly",
|
|
2283
2342
|
"row.distBalanced": "Balanced",
|
|
2284
2343
|
"row.distBalancedHint": "Draw at random, favouring the account idle longest",
|
|
2344
|
+
"row.distSticky": "Per conversation",
|
|
2345
|
+
"row.distStickyHint": "One account per chat, new chats take the next account — keeps the prompt cache warm",
|
|
2346
|
+
"row.distRecommended": "Recommended",
|
|
2285
2347
|
"row.resetCooldowns": "Clear all cooldowns",
|
|
2286
2348
|
"row.resetCooldownsBusy": "Clearing…",
|
|
2287
2349
|
"row.resetCooldownsDone": "Cooldowns cleared",
|
|
@@ -2434,6 +2496,9 @@ window.__ModuleLoader__.load({
|
|
|
2434
2496
|
"row.distRoundRobinHint": "按顺序轮流使用,积分均匀分摊",
|
|
2435
2497
|
"row.distBalanced": "均衡模式",
|
|
2436
2498
|
"row.distBalancedHint": "随机抽取,闲置越久的账号被选中概率越高",
|
|
2499
|
+
"row.distSticky": "每对话固定",
|
|
2500
|
+
"row.distStickyHint": "一个对话只用一个账号,新对话自动换下一个账号——保住上游缓存,命中率和速度都更好",
|
|
2501
|
+
"row.distRecommended": "推荐",
|
|
2437
2502
|
"row.resetCooldowns": "清除所有冷却",
|
|
2438
2503
|
"row.resetCooldownsBusy": "正在清除…",
|
|
2439
2504
|
"row.resetCooldownsDone": "冷却已清除",
|
package/lib/index.d.ts
CHANGED
|
@@ -831,7 +831,7 @@ export declare function workbuddyAccountId(credential: Pick<WorkBuddyCredential,
|
|
|
831
831
|
/** Every directory the pool should scan, in probe order. */
|
|
832
832
|
export declare function candidateAuthDirs(env?: NodeJS.ProcessEnv): string[];
|
|
833
833
|
/** How the pool chooses which account serves the next request. */
|
|
834
|
-
type AccountDistribution = 'priority' | 'round-robin' | 'balanced';
|
|
834
|
+
type AccountDistribution = 'priority' | 'round-robin' | 'balanced' | 'sticky';
|
|
835
835
|
interface AccountPoolOptions {
|
|
836
836
|
/** Logger for discovery and rotation events. */
|
|
837
837
|
logger?: {
|
|
@@ -858,6 +858,9 @@ interface AccountPoolOptions {
|
|
|
858
858
|
* queue the moment its window resets.
|
|
859
859
|
* - `round-robin`: consecutive requests rotate through the pool so the
|
|
860
860
|
* spend spreads evenly.
|
|
861
|
+
* - `sticky`: one account per conversation, and a new conversation moves to
|
|
862
|
+
* the next account in order. Keeps the upstream prompt cache warm inside a
|
|
863
|
+
* conversation while still spreading spend across conversations.
|
|
861
864
|
*/
|
|
862
865
|
distribution?: AccountDistribution;
|
|
863
866
|
}
|
|
@@ -884,6 +887,15 @@ export declare class WorkBuddyAccountPool {
|
|
|
884
887
|
private distribution;
|
|
885
888
|
/** Cursor for round-robin mode; unused under priority distribution. */
|
|
886
889
|
private cursor;
|
|
890
|
+
/**
|
|
891
|
+
* `sticky` mode: conversation key → account id.
|
|
892
|
+
*
|
|
893
|
+
* A conversation that keeps the same account also keeps that account's
|
|
894
|
+
* upstream prompt cache warm — the cache is per tenant, so rotating accounts
|
|
895
|
+
* mid-conversation pays full prompt cost on every turn. Insertion order is
|
|
896
|
+
* the LRU order: re-binding deletes then re-inserts.
|
|
897
|
+
*/
|
|
898
|
+
private readonly affinity;
|
|
887
899
|
private lastScanAtMs;
|
|
888
900
|
private preferredId;
|
|
889
901
|
/**
|
|
@@ -1010,6 +1022,22 @@ export declare class WorkBuddyAccountPool {
|
|
|
1010
1022
|
};
|
|
1011
1023
|
/** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
|
|
1012
1024
|
private pickRoundRobin;
|
|
1025
|
+
/** Remember which account a conversation is bound to, keeping LRU order. */
|
|
1026
|
+
private bindAffinity;
|
|
1027
|
+
/**
|
|
1028
|
+
* `sticky`: the account this conversation already used, when it can still
|
|
1029
|
+
* serve the model being asked for.
|
|
1030
|
+
*
|
|
1031
|
+
* Returns `undefined` both when there is no binding and when the binding is
|
|
1032
|
+
* no longer eligible (cooling for this model, disabled, out of credits) — the
|
|
1033
|
+
* caller then rebinds, which is what makes a rate-limited conversation hop to
|
|
1034
|
+
* a fresh account instead of failing.
|
|
1035
|
+
*/
|
|
1036
|
+
private affinityAccount;
|
|
1037
|
+
/** Bindings currently remembered; exposed for tests and diagnostics. */
|
|
1038
|
+
affinitySize(): number;
|
|
1039
|
+
/** Forget every conversation binding (tests, and a settings change). */
|
|
1040
|
+
clearAffinity(): void;
|
|
1013
1041
|
/**
|
|
1014
1042
|
* Priority mode: weighted random over the eligible accounts.
|
|
1015
1043
|
*
|
|
@@ -1036,15 +1064,20 @@ export declare class WorkBuddyAccountPool {
|
|
|
1036
1064
|
* it resumes straight away.
|
|
1037
1065
|
* - **round-robin**: consecutive requests rotate through the pool so spend
|
|
1038
1066
|
* spreads evenly across every account.
|
|
1067
|
+
* - **sticky**: one account per conversation, and a new conversation moves to
|
|
1068
|
+
* the next account in order. Keeps the upstream prompt cache warm within a
|
|
1069
|
+
* conversation while still spreading spend across conversations.
|
|
1039
1070
|
*
|
|
1040
|
-
* In
|
|
1071
|
+
* In every mode an explicit user selection (`prefer`) heads the list, a
|
|
1041
1072
|
* cooling account is skipped for that model only, and an unrecognised setting
|
|
1042
1073
|
* falls back to priority.
|
|
1043
1074
|
*
|
|
1044
1075
|
* Scans on first use, and rescans when every known account is cooling down: a
|
|
1045
1076
|
* fresh desktop login is the usual way out of an exhausted pool.
|
|
1077
|
+
*
|
|
1078
|
+
* `conversationKey` is only consulted under `sticky`; other modes ignore it.
|
|
1046
1079
|
*/
|
|
1047
|
-
acquire(modelId?: string, region?: WorkBuddyRegion): Promise<WorkBuddyAccount | undefined>;
|
|
1080
|
+
acquire(modelId?: string, region?: WorkBuddyRegion, conversationKey?: string): Promise<WorkBuddyAccount | undefined>;
|
|
1048
1081
|
/** Pin the account the plugin card should prefer; tokens stay out of settings. */
|
|
1049
1082
|
/** How the pool currently spreads requests. Shown on the card. */
|
|
1050
1083
|
currentDistribution(): AccountDistribution;
|
|
@@ -1513,7 +1546,7 @@ interface PoolWebAutomationEarnings {
|
|
|
1513
1546
|
*/
|
|
1514
1547
|
type PoolRegion = 'cn' | 'global';
|
|
1515
1548
|
/** How the pool spreads requests across its accounts. */
|
|
1516
|
-
type PoolDistribution = 'priority' | 'round-robin' | 'balanced';
|
|
1549
|
+
type PoolDistribution = 'priority' | 'round-robin' | 'balanced' | 'sticky';
|
|
1517
1550
|
/**
|
|
1518
1551
|
* The schedule every automation job falls back to.
|
|
1519
1552
|
*
|
|
@@ -2480,10 +2513,15 @@ export interface Config {
|
|
|
2480
2513
|
* - `balanced` draws at random, weighting whichever account has been idle
|
|
2481
2514
|
* longest. Spend still spreads, but without a fixed order, so one unhealthy
|
|
2482
2515
|
* account cannot pin the pool to itself.
|
|
2516
|
+
* - `sticky` gives each conversation one account and moves the next new
|
|
2517
|
+
* conversation to the next account in order. Rotating accounts inside a
|
|
2518
|
+
* conversation throws away the upstream prompt cache (it is per tenant), so
|
|
2519
|
+
* this keeps the cache warm while still spreading spend across
|
|
2520
|
+
* conversations.
|
|
2483
2521
|
*
|
|
2484
2522
|
* Absent reads as `priority`.
|
|
2485
2523
|
*/
|
|
2486
|
-
distribution?: 'priority' | 'round-robin' | 'balanced';
|
|
2524
|
+
distribution?: 'priority' | 'round-robin' | 'balanced' | 'sticky';
|
|
2487
2525
|
/**
|
|
2488
2526
|
* Account ids switched off on the card. A disabled account is never picked
|
|
2489
2527
|
* to serve a request, but it stays in the pool and on the card so it can be
|
package/lib/index.js
CHANGED
|
@@ -2720,6 +2720,13 @@ function candidateAuthDirs(env = process.env) {
|
|
|
2720
2720
|
return dirs;
|
|
2721
2721
|
}
|
|
2722
2722
|
/**
|
|
2723
|
+
* How many conversation→account bindings `sticky` mode remembers.
|
|
2724
|
+
*
|
|
2725
|
+
* Only a memory bound: evicting the oldest binding costs one re-pick on that
|
|
2726
|
+
* conversation's next turn, it never loses an account or a request.
|
|
2727
|
+
*/
|
|
2728
|
+
const STICKY_AFFINITY_LIMIT = 200;
|
|
2729
|
+
/**
|
|
2723
2730
|
* Read-only pool of every discovered WorkBuddy account, with rate-limit
|
|
2724
2731
|
* cooldown and round-robin failover.
|
|
2725
2732
|
*/
|
|
@@ -2768,6 +2775,15 @@ var WorkBuddyAccountPool = class {
|
|
|
2768
2775
|
distribution;
|
|
2769
2776
|
/** Cursor for round-robin mode; unused under priority distribution. */
|
|
2770
2777
|
cursor = 0;
|
|
2778
|
+
/**
|
|
2779
|
+
* `sticky` mode: conversation key → account id.
|
|
2780
|
+
*
|
|
2781
|
+
* A conversation that keeps the same account also keeps that account's
|
|
2782
|
+
* upstream prompt cache warm — the cache is per tenant, so rotating accounts
|
|
2783
|
+
* mid-conversation pays full prompt cost on every turn. Insertion order is
|
|
2784
|
+
* the LRU order: re-binding deletes then re-inserts.
|
|
2785
|
+
*/
|
|
2786
|
+
affinity = /* @__PURE__ */ new Map();
|
|
2771
2787
|
lastScanAtMs = 0;
|
|
2772
2788
|
preferredId;
|
|
2773
2789
|
/**
|
|
@@ -3026,6 +3042,45 @@ var WorkBuddyAccountPool = class {
|
|
|
3026
3042
|
this.cursor = (index + 1) % pool.length;
|
|
3027
3043
|
return account;
|
|
3028
3044
|
}
|
|
3045
|
+
/** Remember which account a conversation is bound to, keeping LRU order. */
|
|
3046
|
+
bindAffinity(conversationKey, accountId) {
|
|
3047
|
+
this.affinity.delete(conversationKey);
|
|
3048
|
+
this.affinity.set(conversationKey, accountId);
|
|
3049
|
+
while (this.affinity.size > STICKY_AFFINITY_LIMIT) {
|
|
3050
|
+
const oldest = this.affinity.keys().next().value;
|
|
3051
|
+
if (oldest === void 0) break;
|
|
3052
|
+
this.affinity.delete(oldest);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
/**
|
|
3056
|
+
* `sticky`: the account this conversation already used, when it can still
|
|
3057
|
+
* serve the model being asked for.
|
|
3058
|
+
*
|
|
3059
|
+
* Returns `undefined` both when there is no binding and when the binding is
|
|
3060
|
+
* no longer eligible (cooling for this model, disabled, out of credits) — the
|
|
3061
|
+
* caller then rebinds, which is what makes a rate-limited conversation hop to
|
|
3062
|
+
* a fresh account instead of failing.
|
|
3063
|
+
*/
|
|
3064
|
+
affinityAccount(pool, conversationKey) {
|
|
3065
|
+
if (conversationKey === void 0 || conversationKey === "") return void 0;
|
|
3066
|
+
const boundId = this.affinity.get(conversationKey);
|
|
3067
|
+
if (boundId === void 0) return void 0;
|
|
3068
|
+
const bound = pool.find((account) => account.id === boundId);
|
|
3069
|
+
if (bound === void 0) {
|
|
3070
|
+
this.affinity.delete(conversationKey);
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
this.bindAffinity(conversationKey, boundId);
|
|
3074
|
+
return bound;
|
|
3075
|
+
}
|
|
3076
|
+
/** Bindings currently remembered; exposed for tests and diagnostics. */
|
|
3077
|
+
affinitySize() {
|
|
3078
|
+
return this.affinity.size;
|
|
3079
|
+
}
|
|
3080
|
+
/** Forget every conversation binding (tests, and a settings change). */
|
|
3081
|
+
clearAffinity() {
|
|
3082
|
+
this.affinity.clear();
|
|
3083
|
+
}
|
|
3029
3084
|
/**
|
|
3030
3085
|
* Priority mode: weighted random over the eligible accounts.
|
|
3031
3086
|
*
|
|
@@ -3064,15 +3119,20 @@ var WorkBuddyAccountPool = class {
|
|
|
3064
3119
|
* it resumes straight away.
|
|
3065
3120
|
* - **round-robin**: consecutive requests rotate through the pool so spend
|
|
3066
3121
|
* spreads evenly across every account.
|
|
3122
|
+
* - **sticky**: one account per conversation, and a new conversation moves to
|
|
3123
|
+
* the next account in order. Keeps the upstream prompt cache warm within a
|
|
3124
|
+
* conversation while still spreading spend across conversations.
|
|
3067
3125
|
*
|
|
3068
|
-
* In
|
|
3126
|
+
* In every mode an explicit user selection (`prefer`) heads the list, a
|
|
3069
3127
|
* cooling account is skipped for that model only, and an unrecognised setting
|
|
3070
3128
|
* falls back to priority.
|
|
3071
3129
|
*
|
|
3072
3130
|
* Scans on first use, and rescans when every known account is cooling down: a
|
|
3073
3131
|
* fresh desktop login is the usual way out of an exhausted pool.
|
|
3132
|
+
*
|
|
3133
|
+
* `conversationKey` is only consulted under `sticky`; other modes ignore it.
|
|
3074
3134
|
*/
|
|
3075
|
-
async acquire(modelId, region) {
|
|
3135
|
+
async acquire(modelId, region, conversationKey) {
|
|
3076
3136
|
if (this.accounts.length === 0) await this.scan();
|
|
3077
3137
|
let pool = this.available(Date.now(), modelId, region);
|
|
3078
3138
|
if (pool.length === 0) {
|
|
@@ -3087,8 +3147,16 @@ var WorkBuddyAccountPool = class {
|
|
|
3087
3147
|
return preferred;
|
|
3088
3148
|
}
|
|
3089
3149
|
}
|
|
3090
|
-
|
|
3150
|
+
if (this.distribution === "sticky") {
|
|
3151
|
+
const bound = this.affinityAccount(pool, conversationKey);
|
|
3152
|
+
if (bound !== void 0) {
|
|
3153
|
+
await this.ensureFresh(bound);
|
|
3154
|
+
return bound;
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3091
3158
|
if (account === void 0) return void 0;
|
|
3159
|
+
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3092
3160
|
await this.ensureFresh(account);
|
|
3093
3161
|
return account;
|
|
3094
3162
|
}
|
|
@@ -5646,6 +5714,34 @@ function requestShape(raw) {
|
|
|
5646
5714
|
return "body was not parseable JSON";
|
|
5647
5715
|
}
|
|
5648
5716
|
}
|
|
5717
|
+
/**
|
|
5718
|
+
* Stable identity of the conversation a request belongs to, for `sticky`.
|
|
5719
|
+
*
|
|
5720
|
+
* The FIRST user message is the key: it is the one part of the body that does
|
|
5721
|
+
* not change as the history grows, so every turn of a conversation hashes the
|
|
5722
|
+
* same way while a new chat (with a different opening message) hashes
|
|
5723
|
+
* differently. Hashing rather than storing the text keeps conversation content
|
|
5724
|
+
* out of the pool's memory.
|
|
5725
|
+
*
|
|
5726
|
+
* Two conversations that open with the exact same message share an account —
|
|
5727
|
+
* rare, and harmless: it only means they share one account's prompt cache.
|
|
5728
|
+
*/
|
|
5729
|
+
function conversationKeyOf(raw) {
|
|
5730
|
+
try {
|
|
5731
|
+
const parsed = JSON.parse(raw);
|
|
5732
|
+
if (!Array.isArray(parsed.messages)) return void 0;
|
|
5733
|
+
for (const entry of parsed.messages) {
|
|
5734
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
5735
|
+
const message = entry;
|
|
5736
|
+
if (message["role"] !== "user") continue;
|
|
5737
|
+
const content = typeof message["content"] === "string" ? message["content"] : JSON.stringify(message["content"] ?? "");
|
|
5738
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 32);
|
|
5739
|
+
}
|
|
5740
|
+
return;
|
|
5741
|
+
} catch {
|
|
5742
|
+
return;
|
|
5743
|
+
}
|
|
5744
|
+
}
|
|
5649
5745
|
function readBody(req) {
|
|
5650
5746
|
return new Promise((resolve, reject) => {
|
|
5651
5747
|
const chunks = [];
|
|
@@ -5775,6 +5871,7 @@ function createWorkBuddyShim(options) {
|
|
|
5775
5871
|
const prepared = client.prepareChatBody(raw);
|
|
5776
5872
|
const controller = new AbortController();
|
|
5777
5873
|
req.on("close", () => controller.abort());
|
|
5874
|
+
const conversationKey = conversationKeyOf(raw);
|
|
5778
5875
|
let modelId;
|
|
5779
5876
|
try {
|
|
5780
5877
|
const parsed = JSON.parse(raw);
|
|
@@ -5786,7 +5883,7 @@ function createWorkBuddyShim(options) {
|
|
|
5786
5883
|
let last;
|
|
5787
5884
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
5788
5885
|
if (controller.signal.aborted) return;
|
|
5789
|
-
const account = await pool.acquire(modelId, region);
|
|
5886
|
+
const account = await pool.acquire(modelId, region, conversationKey);
|
|
5790
5887
|
if (account === void 0) {
|
|
5791
5888
|
const why = pool.unavailableReason(modelId, region);
|
|
5792
5889
|
if (why.reason === "cooling") {
|
|
@@ -7112,8 +7209,9 @@ const Config = z.object({
|
|
|
7112
7209
|
distribution: asVolatile(z.union([
|
|
7113
7210
|
"priority",
|
|
7114
7211
|
"round-robin",
|
|
7115
|
-
"balanced"
|
|
7116
|
-
|
|
7212
|
+
"balanced",
|
|
7213
|
+
"sticky"
|
|
7214
|
+
]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), balanced (idle-weighted random), or sticky (one account per conversation, new conversations rotate)")),
|
|
7117
7215
|
disabledAccountIds: asVolatile(z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)")),
|
|
7118
7216
|
creditReserves: asVolatile(z.dict(z.number().step(1).min(0)).default({}).description("Per-account credit floor: stop using an account once its balance reaches this value")),
|
|
7119
7217
|
enabledModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet")),
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-workbuddy-xdpool",
|
|
3
3
|
"displayName": "DSH WorkBuddy XD Pool",
|
|
4
4
|
"description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.8.0",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",
|