dsh-workbuddy-xdpool 1.7.8 → 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 CHANGED
@@ -4,6 +4,56 @@
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
+
7
57
  ## 1.7.8 — 说清楚卡片在哪儿、以及我们支持到哪个版本
8
58
 
9
59
  > 这次没有功能问题,全在「声明」上:一处把你引到不存在的路径,一处让你以为装错了版本。
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 both modes an explicit user selection (`prefer`) heads the list, a
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
- const account = this.distribution === "round-robin" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
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
- ]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)")),
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": "冷却已清除",
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 both modes an explicit user selection (`prefer`) heads the list, a
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 both modes an explicit user selection (`prefer`) heads the list, a
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
- const account = this.distribution === "round-robin" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
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
- ]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)")),
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.8",
5
+ "version": "1.7.9",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",