dsh-workbuddy-xdpool 1.7.7 → 1.7.9
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 +107 -0
- package/README.md +13 -4
- package/lib/bin.js +74 -5
- package/lib/client.js +51 -4
- package/lib/index.d.ts +43 -5
- package/lib/index.js +104 -6
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,113 @@
|
|
|
4
4
|
|
|
5
5
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
6
6
|
|
|
7
|
+
## 1.7.9 — 一个对话只用一个账号,新对话才换人
|
|
8
|
+
|
|
9
|
+
> 有人反馈:除了「优先模式」,换别的模式都会掉命中率、掉速度。这个观察是对的,而且原因不在你的网络——在账号切换本身。所以这次加了一种新模式,专门解决这件事。
|
|
10
|
+
|
|
11
|
+
### 为什么换个模式就会掉命中率
|
|
12
|
+
|
|
13
|
+
上游的 prompt 缓存是按**账号**隔离的,不是按你这个人。
|
|
14
|
+
|
|
15
|
+
所以「轮换模式」和「均衡模式」每发一条消息都可能换一个账号,对上游来说就是换了一个租户——**整段对话的缓存全部作废,从第一个字重新算**。命中率当然掉,首字延迟当然涨。
|
|
16
|
+
|
|
17
|
+
而「优先模式」把这一切都押在一个账号上:缓存稳了,速度好了,代价是这个账号的额度会被你一个人用完。
|
|
18
|
+
|
|
19
|
+
一句话:以前你只能在「额度分摊」和「缓存命中」之间选一个。
|
|
20
|
+
|
|
21
|
+
### 新增「每对话固定」模式
|
|
22
|
+
|
|
23
|
+
现在可以两个都要。
|
|
24
|
+
|
|
25
|
+
规则很简单:**一个对话从头到尾只用一个账号;你新开一个对话,才轮到下一个账号。**
|
|
26
|
+
|
|
27
|
+
- 同一个对话的每一轮都打同一个账号 → 上游缓存一直是热的 → 命中率和速度都是优先模式的水平。
|
|
28
|
+
- 新对话按顺序铺到下一个账号 → 额度还是分散在几个账号上,不会只烧一个。
|
|
29
|
+
|
|
30
|
+
卡片上「账号使用方式」里多了一个「每对话固定(推荐)」,点一下就能切。
|
|
31
|
+
|
|
32
|
+
**默认值仍然是「优先模式」,没有偷偷改你的设置。** 想试新模式,自己点一下;不满意,再点回来。
|
|
33
|
+
|
|
34
|
+
### 账号被限流或被你关掉,会自动换绑
|
|
35
|
+
|
|
36
|
+
绑定不是死契。如果这个对话的账号:
|
|
37
|
+
|
|
38
|
+
- 被上游限流了(只针对当前模型,别的模型照用),
|
|
39
|
+
- 被你在卡片上关掉了,
|
|
40
|
+
- 或者积分到了你设的保留线,
|
|
41
|
+
|
|
42
|
+
那么这个对话下一轮会自动换到一个能用的账号,并且**以后就固定在新的那个账号上**——不会每轮都重新挑,也不会因为一个账号打盹就让整个对话报错。
|
|
43
|
+
|
|
44
|
+
### 四种模式怎么选
|
|
45
|
+
|
|
46
|
+
- **每对话固定**:多个对话同时用,既想省额度又想保住命中率。推荐先试这个。
|
|
47
|
+
- **优先模式**:就一个对话,或者只有一个账号额度够用。缓存最稳。
|
|
48
|
+
- **轮换模式**:不在乎速度,只想让几个账号的积分严格平均地掉。
|
|
49
|
+
- **均衡模式**:随机抽,闲置越久的账号越容易被选中;比轮换更不容易被一个不健康的账号拖住。
|
|
50
|
+
|
|
51
|
+
### 已知边界
|
|
52
|
+
|
|
53
|
+
- 绑定只存在内存里,重启 DSH 后就忘了。影响很小:重启只是让每个对话重新认一次账号,之后照样固定。
|
|
54
|
+
- 绑定按**对话的第一条用户消息**区分。两个对话如果第一句话一字不差,会被当成同一个对话、共用账号。这种情况很少见,共用了也没有坏处,只是共享一份缓存。
|
|
55
|
+
- 最多记住 200 个对话的绑定,超了会淘汰最久没用过的那个。被淘汰的对话下一轮重新绑定一次,仅此一次,行为不受影响。
|
|
56
|
+
|
|
57
|
+
## 1.7.8 — 说清楚卡片在哪儿、以及我们支持到哪个版本
|
|
58
|
+
|
|
59
|
+
> 这次没有功能问题,全在「声明」上:一处把你引到不存在的路径,一处让你以为装错了版本。
|
|
60
|
+
|
|
61
|
+
### README 的入口路径是 0.1.x 时代的,0.2.0 上按它找不到
|
|
62
|
+
|
|
63
|
+
README 一直写着「设置 → 插件 → DSH WorkBuddy XD Pool」。**0.2.0 起没有这条二层路径了。**
|
|
64
|
+
|
|
65
|
+
实际位置是:**打开设置面板 → 顶部平级标签里的「XD Pool」**(排在 `通用设置 / 模型 / 内置插件 / Agent 预设` 后面)。开设置面板的方式是**侧边栏左下角的齿轮**,或快捷键 `Ctrl+Alt+,`。
|
|
66
|
+
|
|
67
|
+
已把 README 三处改掉,并且**补上了开面板的方法**——只写「切到 XD Pool」而不说怎么开设置,等于没写。老路径保留一句「0.1.x 时代叫这个」,方便升级上来的人理解自己的习惯为什么突然不灵了。
|
|
68
|
+
|
|
69
|
+
### `engines.dsh` 写着 `<0.2.0`,可它在 0.2.0 上跑得好好的
|
|
70
|
+
|
|
71
|
+
这个声明的问题不在于"保守",而在于**它会把人带偏**:任何人按 `engines` 做兼容判断,第一反应都是"这插件不支持 0.2.0",然后往错误的方向排查。反馈里就有一位因此走了一大圈。
|
|
72
|
+
|
|
73
|
+
现在放宽到 `<0.3.0`,并且**把三个 host peer 依赖(`dsh-llm` / `dsh-llm-pi-ai` / `dsh-settings`)一起对齐**——它们约束的是同一批宿主包,只改一个反而制造新的不一致。
|
|
74
|
+
|
|
75
|
+
放宽不等于"什么都接受":上界仍然卡在 `<0.3.0`,**没测过的大版本不该被默认声称支持**。
|
|
76
|
+
|
|
77
|
+
### 导航图标匹配不上时,现在会说话
|
|
78
|
+
|
|
79
|
+
`installNavIcon()` 靠选择器找设置面板里那一行。**找不到时它完全静默** —— 表现就是"图标没出来",没有任何线索。这次排查里它不是故障点,但它是最贵的一环:**看起来什么都没发生**。
|
|
80
|
+
|
|
81
|
+
现在连续匹配失败会 `console.warn` 一次,带上选择器、期望的标签、以及**当前导航里实际有什么**:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
[dsh-workbuddy-xdpool] the settings nav row could not be found, so the icon was not applied.
|
|
85
|
+
selector="[role=\"dialog\"] nav button" expectedLabel="XD Pool" rowsPresent=["通用设置","模型",...]
|
|
86
|
+
This is cosmetic only — the card itself still works. Please report this line…
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**不是每次都喊**:设置面板没打开时找不到是**正常的**(那一行压根不在 DOM 里),每次都喊只会让人学会忽略它。所以只在"面板开着、却连续 N 次找不到"时才报一次,且每轮只报一次。
|
|
90
|
+
|
|
91
|
+
### 关于那份"0.2.0 删了 slots 服务"的报告
|
|
92
|
+
|
|
93
|
+
反馈者**自己撤回了**那个结论,我更正一下:**不成立**。
|
|
94
|
+
|
|
95
|
+
他后来实测到:`__DSH_BOOT__.entries` 含本插件、`importErrors` 为空、客户端 fiber 状态是 ACTIVE、卡片 DOM 完整渲染、`installNavIcon` 正常生效。而且**把 `dsh.client.inject` 改掉再测,行为完全一致** —— 所以那两个包的声明**不会阻断加载,不需要改**。
|
|
96
|
+
|
|
97
|
+
他复盘出的两个方法论坑,我认为值得原样记下来,因为它们非常容易重犯:
|
|
98
|
+
- 只用正则扫「谁 `provide("name")` 了服务」,漏掉别的声明写法,就宣布服务被删了 —— **典型的假阴性**;
|
|
99
|
+
- 在**会话页**(设置面板没开)dump DOM 搜插件节点,0 命中当成故障证据 —— 而 `settings.section` **只在面板打开时才进 DOM**,0 命中是必然的。
|
|
100
|
+
|
|
101
|
+
`slots` 和 `settings.section` 在 0.2.0 里都健在,渲染方是 `dsh-client-ui-settings-general` 的 `renderSlot("settings.section", …)`。
|
|
102
|
+
|
|
103
|
+
### 验证
|
|
104
|
+
|
|
105
|
+
- 类型检查全绿,测试 **332 项全绿**(+7)。
|
|
106
|
+
- 新增 `tests/compatibility-declaration.test.ts`:`engines.dsh` 必须容纳 0.2.x、**上界仍要卡住没测过的大版本**、三个 host peer 与 `engines.dsh` 保持一致、README **不得再把老路径当指引**(但允许作为历史说明出现)、必须写出开设置面板的方法。
|
|
107
|
+
- **反向验证**:把 `engines` 退回 `<0.2.0` → 2 项变红;把 README 退回老路径 → 1 项变红。
|
|
108
|
+
- 本机核对宿主代码:`renderSlot("settings.section", { close }, { only: active })` 确为**平级渲染**,插件注册 `order: 440`(排最后),与反馈的实测描述一致。
|
|
109
|
+
|
|
110
|
+
### 一点说明
|
|
111
|
+
|
|
112
|
+
我本机是 DSH Desktop 2.0.15(内核 0.1.7-rc.2),**不是 0.2.0-rc.2**,所以我**没法在本地复现 0.2.0 的渲染行为**。本次改动依据的是反馈者提供的实测数据,外加我对本机宿主代码里 `renderSlot` 调用的核对。放宽 `engines` 这个结论我是采信的,但没有在 0.2.0 上亲手验过。
|
|
113
|
+
|
|
7
114
|
## 1.7.7 — 别再让你为了一个「等一会儿」去重新登录
|
|
8
115
|
|
|
9
116
|
> 报错说「API 密钥无效」,于是你跑去重新登录 —— 但密钥根本没问题,只是那个模型暂时限流了。
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
## 先看几张图
|
|
21
21
|
|
|
22
|
-
**插件配置卡片(设置 →
|
|
22
|
+
**插件配置卡片(设置 → 「XD Pool」一节)**
|
|
23
23
|
|
|
24
24
|

|
|
25
25
|
|
|
@@ -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
|
|
|
@@ -83,9 +84,13 @@
|
|
|
83
84
|
|
|
84
85
|
## 安装
|
|
85
86
|
|
|
86
|
-
前置条件:装好 WorkBuddy 桌面 App 并登录(插件复用它存在本机的登录状态)。多账号 = 在桌面 App
|
|
87
|
+
前置条件:装好 WorkBuddy 桌面 App 并登录(插件复用它存在本机的登录状态)。多账号 = 在桌面 App 里换着登几次,每次登录都会被自动吸进池里。
|
|
87
88
|
|
|
88
|
-
> 兼容 host `0.1.1-rc.2`
|
|
89
|
+
> 兼容 host `0.1.1-rc.2` 起的整个 0.1.x 系列,以及 `0.2.0-rc.2`。安装设置节时会按 host 能力自动选择 `settings.installSection`(0.1.2-rc.1+)或更早的自由函数写法;卡片在 0.2.0 上作为设置面板里一个**平级标签页**渲染。
|
|
90
|
+
|
|
91
|
+
> **卡片在哪儿打开**:DSH 设置面板本身。开法是**侧边栏左下角的齿轮**,或者快捷键 `Ctrl+Alt+,`。
|
|
92
|
+
> 面板顶部是平级标签:`通用设置 / 模型 / 内置插件 / Agent 预设 / XD Pool` —— 本插件就是最后那个 **XD Pool**。
|
|
93
|
+
> (0.1.x 时代它叫「设置 → 插件 → DSH WorkBuddy XD Pool」,0.2.0 起改成了平级标签,按老路径找不到。)
|
|
89
94
|
|
|
90
95
|
**方式一:从 npm 安装(推荐)**
|
|
91
96
|
|
|
@@ -129,7 +134,7 @@ pnpm typecheck:client # 客户端类型检查
|
|
|
129
134
|
|
|
130
135
|
> 注意:`pnpm install` 要用 pnpm 11(`npx pnpm@11`),必要时加 `--config.confirmModulesPurge=false --config.minimumReleaseAge=0`——pnpm 11 默认的 `minimumReleaseAge` 供应链年龄策略会拦下刚发布的 rc 包。
|
|
131
136
|
|
|
132
|
-
装完以后:模型选择器里会出现 **WorkBuddy XD Pool**
|
|
137
|
+
装完以后:模型选择器里会出现 **WorkBuddy XD Pool** 分组;打开设置面板(左下角齿轮或 `Ctrl+Alt+,`)切到 **XD Pool** 一栏,能看池健康、各账号的令牌 / 积分 / 签到 / 冷却状态,还有「重新检测账号」「清除所有冷却」按钮和每个账号的签到按钮。
|
|
133
138
|
|
|
134
139
|
Web / TUI profile 也能用(`--profile web` / `--profile dsh-tui`)。
|
|
135
140
|
|
|
@@ -172,12 +177,16 @@ dsh plugin --profile desktop exec dsh-workbuddy-xdpool remove myKey
|
|
|
172
177
|
| --- | --- | --- |
|
|
173
178
|
| `authFile` | 覆盖 WorkBuddy 桌面 auth 文件路径(跨平台自动探测出问题时用,等同于 `WORKBUDDY_AUTH_FILE`) | 自动探测 |
|
|
174
179
|
| `cooldownMs` | 单账号 429 冷却时长(毫秒) | `60000` |
|
|
180
|
+
| `distribution` | 账号分配方式:`sticky`(每对话固定一个账号,新对话换下一个)/ `priority`(先用完一个)/ `round-robin`(按顺序轮换)/ `balanced`(随机,偏向闲置久的) | `priority` |
|
|
181
|
+
|
|
182
|
+
> 上游 prompt 缓存按账号隔离,所以对话中途换号会把缓存整段作废。想让多账号同时分摊额度又不想掉命中率,用 `sticky`。
|
|
175
183
|
|
|
176
184
|
也可以直接写进 `~/.dsh/settings.yaml`:
|
|
177
185
|
|
|
178
186
|
```yaml
|
|
179
187
|
workbuddy-xdpool:
|
|
180
188
|
cooldownMs: 120000
|
|
189
|
+
distribution: sticky
|
|
181
190
|
```
|
|
182
191
|
|
|
183
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
|
@@ -1304,13 +1304,14 @@ window.__ModuleLoader__.load({
|
|
|
1304
1304
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1305
1305
|
className: "dsm-workbuddy-xdpool-dist-options",
|
|
1306
1306
|
children: [
|
|
1307
|
+
"sticky",
|
|
1307
1308
|
"priority",
|
|
1308
1309
|
"balanced",
|
|
1309
1310
|
"round-robin"
|
|
1310
1311
|
].map((option) => {
|
|
1311
1312
|
const active = (status.distribution ?? "priority") === option;
|
|
1312
|
-
const label = option === "priority" ? t?.("row.distPriority") ?? "Priority" : option === "balanced" ? t?.("row.distBalanced") ?? "Balanced" : t?.("row.distRoundRobin") ?? "Round-robin";
|
|
1313
|
-
const hint = option === "priority" ? t?.("row.distPriorityHint") ?? "" : option === "balanced" ? t?.("row.distBalancedHint") ?? "" : t?.("row.distRoundRobinHint") ?? "";
|
|
1313
|
+
const label = option === "sticky" ? t?.("row.distSticky") ?? "Per conversation" : option === "priority" ? t?.("row.distPriority") ?? "Priority" : option === "balanced" ? t?.("row.distBalanced") ?? "Balanced" : t?.("row.distRoundRobin") ?? "Round-robin";
|
|
1314
|
+
const hint = option === "sticky" ? t?.("row.distStickyHint") ?? "" : option === "priority" ? t?.("row.distPriorityHint") ?? "" : option === "balanced" ? t?.("row.distBalancedHint") ?? "" : t?.("row.distRoundRobinHint") ?? "";
|
|
1314
1315
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1315
1316
|
type: "button",
|
|
1316
1317
|
role: "radio",
|
|
@@ -2282,6 +2283,8 @@ window.__ModuleLoader__.load({
|
|
|
2282
2283
|
"row.distRoundRobinHint": "Take turns in order, spreading the spend evenly",
|
|
2283
2284
|
"row.distBalanced": "Balanced",
|
|
2284
2285
|
"row.distBalancedHint": "Draw at random, favouring the account idle longest",
|
|
2286
|
+
"row.distSticky": "Per conversation",
|
|
2287
|
+
"row.distStickyHint": "One account per chat, new chats take the next account — keeps the prompt cache warm",
|
|
2285
2288
|
"row.resetCooldowns": "Clear all cooldowns",
|
|
2286
2289
|
"row.resetCooldownsBusy": "Clearing…",
|
|
2287
2290
|
"row.resetCooldownsDone": "Cooldowns cleared",
|
|
@@ -2434,6 +2437,8 @@ window.__ModuleLoader__.load({
|
|
|
2434
2437
|
"row.distRoundRobinHint": "按顺序轮流使用,积分均匀分摊",
|
|
2435
2438
|
"row.distBalanced": "均衡模式",
|
|
2436
2439
|
"row.distBalancedHint": "随机抽取,闲置越久的账号被选中概率越高",
|
|
2440
|
+
"row.distSticky": "每对话固定(推荐)",
|
|
2441
|
+
"row.distStickyHint": "一个对话只用一个账号,新对话自动换下一个账号——保住上游缓存,命中率和速度都更好",
|
|
2437
2442
|
"row.resetCooldowns": "清除所有冷却",
|
|
2438
2443
|
"row.resetCooldownsBusy": "正在清除…",
|
|
2439
2444
|
"row.resetCooldownsDone": "冷却已清除",
|
|
@@ -2536,6 +2541,15 @@ window.__ModuleLoader__.load({
|
|
|
2536
2541
|
*/
|
|
2537
2542
|
const NAV_ROW_SELECTOR = "[role=\"dialog\"] nav button";
|
|
2538
2543
|
/**
|
|
2544
|
+
* How many consecutive in-dialog passes may miss the nav row before the
|
|
2545
|
+
* diagnostic fires.
|
|
2546
|
+
*
|
|
2547
|
+
* Above 1 because a single miss is routine — the shell can re-render between
|
|
2548
|
+
* our mutation callback and the query. Small enough that a genuinely broken
|
|
2549
|
+
* selector is reported within a frame or two of opening the settings panel.
|
|
2550
|
+
*/
|
|
2551
|
+
const NAV_ICON_MISS_THRESHOLD = 3;
|
|
2552
|
+
/**
|
|
2539
2553
|
* Draw this page's own glyph in its Settings nav row.
|
|
2540
2554
|
*
|
|
2541
2555
|
* The `settings.section` contract carries no icon: the shell decides the glyph
|
|
@@ -2576,13 +2590,46 @@ window.__ModuleLoader__.load({
|
|
|
2576
2590
|
document.head.appendChild(tag);
|
|
2577
2591
|
let disposed = false;
|
|
2578
2592
|
let scheduled = false;
|
|
2593
|
+
/**
|
|
2594
|
+
* Consecutive passes that found no matching row.
|
|
2595
|
+
*
|
|
2596
|
+
* A miss is NORMAL most of the time: the settings dialog is closed, so its
|
|
2597
|
+
* nav is not in the DOM at all, and the icon stays unapplied by design.
|
|
2598
|
+
* Warning on the first miss would fire constantly and train everyone to
|
|
2599
|
+
* ignore it.
|
|
2600
|
+
*
|
|
2601
|
+
* What is worth reporting is a PERSISTENT miss while the dialog IS open —
|
|
2602
|
+
* that means the shell's nav markup changed and the selector no longer
|
|
2603
|
+
* matches, which is exactly the failure that looks like "nothing happened"
|
|
2604
|
+
* and cost hours to find. So the threshold is a burst of misses, and the
|
|
2605
|
+
* warning is emitted once per burst rather than once per mutation.
|
|
2606
|
+
*/
|
|
2607
|
+
let missStreak = 0;
|
|
2608
|
+
let warned = false;
|
|
2579
2609
|
const sync = () => {
|
|
2580
2610
|
scheduled = false;
|
|
2581
2611
|
if (disposed) return;
|
|
2582
2612
|
const wanted = String(resolveLabel() ?? "").trim();
|
|
2583
2613
|
if (wanted === "") return;
|
|
2584
|
-
|
|
2585
|
-
|
|
2614
|
+
let matched = 0;
|
|
2615
|
+
for (const row of document.querySelectorAll(NAV_ROW_SELECTOR)) if (String(row.textContent ?? "").trim() === wanted) {
|
|
2616
|
+
row.setAttribute(NAV_ICON_MARKER, "");
|
|
2617
|
+
matched += 1;
|
|
2618
|
+
} else row.removeAttribute(NAV_ICON_MARKER);
|
|
2619
|
+
if (matched > 0) {
|
|
2620
|
+
missStreak = 0;
|
|
2621
|
+
warned = false;
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
if (document.querySelector(NAV_ROW_SELECTOR) === null) {
|
|
2625
|
+
missStreak = 0;
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
missStreak += 1;
|
|
2629
|
+
if (missStreak < NAV_ICON_MISS_THRESHOLD || warned) return;
|
|
2630
|
+
warned = true;
|
|
2631
|
+
const present = [...document.querySelectorAll(NAV_ROW_SELECTOR)].map((row) => String(row.textContent ?? "").trim()).filter((text) => text !== "");
|
|
2632
|
+
console.warn(`[dsh-workbuddy-xdpool] the settings nav row could not be found, so the icon was not applied. selector=${JSON.stringify(NAV_ROW_SELECTOR)} expectedLabel=${JSON.stringify(wanted)} rowsPresent=${JSON.stringify(present)}. This is cosmetic only — the card itself still works. Please report this line so the selector can be updated.`);
|
|
2586
2633
|
};
|
|
2587
2634
|
const schedule = () => {
|
|
2588
2635
|
if (scheduled || disposed) return;
|
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.7.
|
|
5
|
+
"version": "1.7.9",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"type": "module",
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": "^22.19.0 || >=24.0.0",
|
|
28
|
-
"dsh": ">=0.1.5-rc.1 <0.
|
|
28
|
+
"dsh": ">=0.1.5-rc.1 <0.3.0"
|
|
29
29
|
},
|
|
30
30
|
"main": "lib/index.js",
|
|
31
31
|
"types": "lib/index.d.ts",
|
|
@@ -99,9 +99,9 @@
|
|
|
99
99
|
},
|
|
100
100
|
"peerDependencies": {
|
|
101
101
|
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
102
|
-
"@deepseek-ai/dsh-llm": ">=0.1.1-rc.1 <0.
|
|
103
|
-
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.1-rc.1 <0.
|
|
104
|
-
"@deepseek-ai/dsh-settings": ">=0.1.1-rc.1 <0.
|
|
102
|
+
"@deepseek-ai/dsh-llm": ">=0.1.1-rc.1 <0.3.0",
|
|
103
|
+
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.1-rc.1 <0.3.0",
|
|
104
|
+
"@deepseek-ai/dsh-settings": ">=0.1.1-rc.1 <0.3.0",
|
|
105
105
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
106
106
|
"@earendil-works/pi-ai": ">=0.82.1"
|
|
107
107
|
},
|