dsh-connect-workbuddy 4.4.2 → 4.4.3

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
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.4.3 (2026-10-13)
4
+
5
+ ### Fixes
6
+
7
+ - **插件页里 `dsh-connect-workbuddy` 的设置项整片空白,且不报任何错。**(issue #55)
8
+ - **现象**:侧边栏「插件」→「已安装」→ 点进 `dsh-connect-workbuddy`,详情页里本该出现的 WorkBuddy 卡片(国内版 / 国际版两个标签页、扫码登录、账号检测、积分、模型勾选)**整个不见**,没有占位、没有报错、控制台也干净。报告者按另一份 AI 建议在 `node_modules` 里打补丁续了一版,但那是打在产品产物上、升级即失效。
9
+ - **根因是两处叠加,各自都足以让卡片消失**:
10
+ - **① 注册用的 key 与页面查找用的 name 不是同一个东西。** `plugins.bundle.config` / `plugins.row.config` 是 **keyed** 槽位(`kind: 'keyed'`,每个 `key` 每个优先级只能有一条)。插件页渲染它们时用的是 `renderSlot('plugins.bundle.config', { view: 'page' }, { entryKey: pkg.name })`,而 `pkg.name` 来自插件管理器清单里的**包名**;渲染前还要先过一道 `ledger.bundles.has(pkg.name)` —— 而这个 Set 装的正是各注册条目的 `key`。**两侧都是精确相等比较,比的都是包名,没有一处回落到 Loader 条目 id**。所以清单里叫 `dsh-connect-workbuddy`、而卡片只注册在 `include:dsh-connect-workbuddy` 下时,查找必然落空:整段不渲染,`apply()` 平静结束。
11
+ - **② 注册这件事被挂在了「namespace 到达」的边沿上。** 卡片原本只在 `configForms.whileServed()` 的 `register` 回调里注册,而这个回调只在 **unserved→served 的跳变**上跑一次。若 settings 镜像始终不上报本插件的 namespace(名字不在候选表里、settings 面答得晚或答失败、该部署本就没把插件挂成可配置条目),这个跳变永远不会发生,`registerAllSlots()` 一次都不执行。因为整个 `apply` 体包在只打日志的 try/catch 里,**UI 就这样静默空白** —— 报告者遇到的那份补丁也正是这条。
12
+ - **修法**:① 卡片的 `plugins.bundle.config` / `plugins.row.config` 注册改为覆盖包名的**所有拼写**(`WORKBUDDY_CARD_KEYS`:裸包名 + `include:` 前缀形式,行 key 为 `<包名>#<包名>`)。这些 key 各占一个 cell,属于**无遮蔽的重复条目**而非冲突,所以「全注册一遍」严格优于赌一个。② 注册**不再等待边沿**:`whileServed` 保留原语义(等 namespace 真到了再重绑一次、拿到可写表单),同时无条件先注册一遍;卡片在没有 `settingsScope` 时本就以**只读**形态渲染,这与「宿主没有 settings 面」的既有降级路径是同一条。
13
+ - **顺带修掉一个会让上面②反而变成「整片空白 2.0」的顺序坑**:`rebindSlots()` 必须**先 dispose 再 register**。keyed 槽位会拒绝一个仍然存活的 key(`already has an entry for key …`),而每次注册都有各自的 try/catch 把它吞成一行 `console.error` —— 于是「先注册后 dispose」的结果是:旧条目刚被拆掉、新条目全被拒绝,账本归空。这一点由新增用例显式钉住,并在变异验证(把顺序改回去)下变红。
14
+ - **守卫**:新增 `tests/issue55-card-not-registered.spec.ts`(8 例),按**页面真正读的那个账本**断言:裸名与 `include:` 前缀两种清单名下 `ledger.bundles.has(pkg.name)` 都为真;namespace 永不到达时仍完成注册;namespace 已经到了时不产生重复注册(不触发 keyed 冲突);无 settings 面的宿主仍注册(只读降级);以及上面那条 dispose/register 顺序。`tests/client-fallback.spec.ts` 的手工镜像同步更新(注册数 3→5)。
15
+
16
+ - **14003 是模型级、逐请求的软限流,却被当成账号级失败:换号循环把池内每个账号各锁 30 分钟。**(issue #56)
17
+ - **现象**:报告者的决定性对照 —— **同一凭据、同一时刻**,除 `model` 一个字段外逐字节相同的请求,`deepseek-v4.1-flash`(×0)3 发只成 1 发、`deepseek-v4.1-flash-sg`(×0.03)同样 1/3、而 `glm-5.3-flash` / `hy3` / `kimi-k2.6` 全部 **3/3**。同一秒内同账号别的模型全部正常,所以**账号没有被限流**。上游原话也把维度说明白了:`displayTips` = 「建议等待约 1 分钟后重试,**或切换到负载较低的模型**」。而插件的实际效果是:三个账号全部变 `rate-limited`,用户在此后约 30 分钟里完全无法交流 —— 把上游说的一分钟放大了 30 倍,还放大到了整个账号池。
18
+ - **根因**:`classifyUpstreamError()` 把 429 一律归为 `soft_rate`。14003 的报文里**没有任何日期时间**,所以 `parseUpstreamResetAt()` 返回 `undefined` → `cooldownOf()` 返回 `{}` → `retryDue()` 落到 `probe.atMs + 18e5`(`POOL_UNKNOWN_COOLDOWN_MS` = 30 分钟)。三重放大同时发生:**等太久**、**锁错对象**(锁的是账号,而限制跟着模型走)、**把每个账号都锁一遍**(failover 逐个撞同一堵墙,每撞一次就给一个本来健康的账号写入一条 `live-request` 的限流记录,覆盖掉它先前的 `ok` 测量)。
19
+ - **修法**(四处,各自独立可验):
20
+ - **分类**:新增 `model_busy` 这一 kind(`UpstreamErrorKind` / `WorkBuddyProbeOutcome` / `WorkBuddyPoolExclusion` 三处词汇表各加一枚),判据是 **`"code":14003` 这一带引号的 key 形式** —— 不用 `displayTips` 文案当判据,否则一个恰好提到「负载较低的模型」的网关横幅也会被改判。检查放在**积分标记之后**(两者同时出现时仍按「要充值」这条可执行的结论走)、**普通 429 分支之前**。
21
+ - **时长**:`MODEL_BUSY_COOLDOWN_MS = 2 分钟`,与上游「约 1 分钟」同量级(取 2 而非 1,是为了越过这一阵而不是踩在边上)。`cooldownOf()` 为 `model-busy` 补上这个兜底,并把 `retrySource` 标成新的 `'upstream-tips'` —— 它如实说明这个数字的来路是**上游给的数量级**,不是上游给的时刻。
22
+ - **不换号**:`isFailoverWorthy()` 把 `model_busy` 与 `client` 一起排除。换号撞的是同一堵墙,只会每个账号各花一次请求去学到第一发已经说过的事实,并往每个账号写一条假测量。失败**仍然如实上报一次**(卡片要靠它显示「为什么这个账号不在池里」)。
23
+ - **如实告知**:shim 给 `model_busy` 单独一条错误文案 —— 沿用上游自己的话(「请求过于频繁,请稍后重试。」)+ `requestId`,并明确写出**这不是账号被限流、换账号没用、建议等待约 1 分钟或切换模型**。卡片侧新增 `row.probeModelBusy*` / `row.poolExcludedModelBusy` 文案,池表里「模型繁忙」与「被限流」**分开显示**(把模型的问题写在账号名旁边,正是让报告者去找一个并不存在的坏账号的原因)。
24
+ - **顺带对齐两处**:`probe-reason.ts` 把 `model-busy` 列入「标签已说清、不再内联原始报文」的集合(该报文只是 14003 信封,会把补救办法挤掉);`AccountPool` 的状态色把 `model-busy` 归入琥珀(会自行恢复、账号没坏)而非红色。
25
+ - **守卫**:新增 `tests/issue56-model-scoped-throttle.spec.ts`(14 例),用报告者的原文报文当 fixture:分类正确(14003→`model-busy`,6004→`soft_rate` 不变)、不误伤(只有文案没有 code、或 requestId 恰好含 `14003`,都仍是普通限流)、与积分同时出现时仍判积分、冷却时长为分钟级且带 `'upstream-tips'` 来源、有真实时刻时以时刻为准、模型级账号在一分钟后回到池内而普通限流仍在池外。`tests/shim-failover.spec.ts` 新增 3 例:`model_busy` **不走**换号(池里明明有候选也不问)、失败仍上报且只报一次、用户看到的是「模型繁忙」而不是「账号被限流」。
26
+ - **刻意保留的重复**:`MODEL_BUSY_COOLDOWN_MS` 在 `src/probe.ts` 与 `src/account-pool.ts` 各声明一次。两者不能互相 import(一个是 Host 侧探测、一个是纯排序规则,后者刻意不依赖前者),所以由一条测试断言两个声明**必须相等** —— 探测写一种冷却、排序读另一种,就会悄悄把这个缺陷重新打开。
27
+
28
+ - **`14018`「积分耗尽」被判成普通限流:marker 表漏了复数形式,用户拿到的建议从「去充值」变成「等一等」。**(issue #56 的追加报告)
29
+ - **来源**:这条是报告者在回答「`14018` 是否与 `14003` 同族」时**顺手挖出来的**,不在原 issue 范围内。
30
+ - **现象**:上游对「这个账号在这些模型上没积分了」返回 `429` / `code 14018`,报文逐字为
31
+ `{"error":{"data":{"code":14018,"msg":"Credits exhausted. Please visit the link below to purchase add-on packs and get more credits: …"}}}`。
32
+ - **根因**:`HARD_CREDIT_MARKERS` 里写的是 **`'credit exhausted'`(单数)**,而上游发的是 **`"Credits exhausted."`(复数 + 大写 C)**。`'credits exhausted.'.includes('credit exhausted')` 是 **false** —— `s` 正好夹在两个词中间。**八条英文 marker 逐条试过,全部未命中**;中文 marker 也不在报文里,救不了。于是它落到通用 429 分支:`classifyUpstreamError → soft_rate`、`outcomeOfFailure → rate-limited`、`cooldownOf → {}`。
33
+ - **后果**:一条本该读成「**去充值**」的失败被降级成「**等一等**」,用户看到「稍后重试」而正确答案是「这个号的积分用完了」。这与 issue #56 那个「把一分钟放大成半小时」是**同一类放大,只是入口不同**。
34
+ - **报告者同时做了两条反证,都很关键**:① 特意在**网络层**确认真实 HTTP 状态是 **429 而不是 402** —— 因为 `classifyUpstreamError()` 第一行就是 `if (status === 402) return 'hard_credit'`,若上游真发 402 则这条 bug 根本不成立;② 用 **3 账号 × 4 模型** 的矩阵证明 `14018` 是**账号级**而非模型级(账号 B 余额 100 上 `hy4-preview` 0/2,而余额 379/350 的 A/C 服务同一模型 2/2;同号上免费模型 `hy3`/`deepseek-v4.1-flash` 均 2/2 而计费模型全挂)⇒ **`14018` 保持账号级路径,不与 `14003` 合并**。
35
+ - **修法**(两条都采用,因为它们防的是不同失效):
36
+ 1. 补 **`'credits exhausted'`** 复数形式,并**保留单数**(网关改过措辞,只认今天这一种的表就是同一个坑的下一轮);
37
+ 2. 新增按 **`"code":14018`** 的带引号 key 判定(`CREDITS_EXHAUSTED_CODE`),与既有的 `MODEL_BUSY_CODE` 同构 —— 措辞**完全改掉**的报文也能正确归类,且不受大小写与单复数影响。
38
+ - **顺序**:code 判定放在中文/英文 marker **之后**、`model_busy` **之前**,因此一条同时携带 `14018` 与 `14003` 的报文读作「去充值」(那是可执行的结论)。
39
+ - **未改动**:`exclusionOf` 对 `out-of-credit` 在**无时刻**时仍走 `POOL_UNKNOWN_COOLDOWN_MS` 通用兜底。这是**既有行为**,本版未动 —— 积分耗尽只能靠充值或周期刷新解除,与 `14003` 的秒级恢复性质不同。已在 issue 里如实说明并请报告者判断是否另案处理。
40
+ - **守卫**:新增 `tests/issue56-credits-exhausted-code.spec.ts`(9 例),**用报告者贴的报文原文当 fixture**(含尾随空格与 `{"error":{"data":…}}` 嵌套 —— 它和 `14003` 的扁平信封不同,本身就是「按散文做表很脆」的又一证据)。既钉住 `14018 → hard_credit → out-of-credit`,也钉住「两者必须分开」与「配额刷新点仍被采用」。变异验证:同时还原复数 marker 与 code 判定,**9 例中 5 例变红**,恢复后全绿。
41
+
42
+ - **客户端注册后打印一行摘要,把「静默空白」变成一眼可诊断。**(issue #55 报告者的建议)
43
+ - **动机来自报告者的原话**:整片 UI 消失与一切正常在页面上**完全无法区分**,因为他(和后来的我们)定位耗时几乎全花在那个只打日志的静默 `try/catch` 上。
44
+ - 现在每个 rebind 结束都会打一行**成功路径**的 `console.info`:
45
+ `[dsh-connect-workbuddy] registered 5 slots (settings form: bound)` 或
46
+ `[dsh-connect-workbuddy] registered 5 slots (settings form: none — card is read-only)`。
47
+ 后者正是「公网域名部署」那一类场景(客户端根本不发 `/api/settings/describe`,命名空间永不到达)会打出的样子:一眼看出「注册成功但表单只读」,而不是对着空白页猜。
48
+ - 刻意用 `console.info` 而非 `warn`/`error`:健康路径打警告会训练用户忽略真正出事的那条通道。
49
+ - **未采纳**「把 `apply()` 拆成多个 try/catch」:外层兜底的作用是「槽位 API 变更时降级为日志,而不是让宿主弹 `Failed to load plugins` 红条」,拆开会让部分 UI 失败时更难判断整体状态。加了摘要后日志已能定位到具体哪几个没上。
50
+
51
+ - **换号提示把「积分耗尽」说成「被限流」,用户被叫去等一个不会来的恢复。**(issue #56 报告者指出)
52
+ - **来源**:报告者在同一份追加报告里顺带指出 `src/shim.ts` 的 `failoverReasonText()` 把 `soft_rate` 与 `hard_credit` **并列成同一句 `'rate limited'`**,并预期「§三 修好之后这个号会走 `hard_credit` ⇒ 用户能拿到『积分耗尽』而不是『被限流』」。**这个预期当时并不成立** —— 卡片/池表一侧确实会显示「积分耗尽」(`poolExcludedOutOfCredit`),但**回复里的换号提示**仍读作「rate limited」,而那正是用户在对话中最先看到的那一句。
53
+ - **为什么必须分开**:两者**都会出池、都会触发换号**(另一个账号可能确实有余额),但**补救办法不同类**——限流会在上游给出的时刻自行解除,积分耗尽在充值或周期刷新之前**永不会**好。共用一句话等于叫人去等一件不会发生的事,而正确动作是去充值或换号。
54
+ - **修法**:`hard_credit` 拆出独立的 `'out of credits'`。**未改动** `isFailoverWorthy()`:`hard_credit` 依旧可换号(换到有余额的账号是可行的自救),只是提示词如实说明原因。
55
+ - **守卫**:`tests/shim-failover.spec.ts` 新增 1 例,断言耗尽场景的提示含 `out of credits` 且**不含** `rate limited`,同时确认它**仍然换号**(`seen == ['selected', 'other']`)。变异验证:把两个 case 合并回 `'rate limited'`,该例立即变红,恢复后全绿。
56
+
57
+ ### Docs
58
+
59
+ - 无。本版含上述修复与配套测试。
60
+
3
61
  ## 4.4.2 (2026-10-13)
4
62
 
5
63
  ### Fixes
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, Z as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, at as hostPeersInfo, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-CBFAiYPg.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, Z as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, at as hostPeersInfo, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-C8imAyWG.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
package/lib/client.js CHANGED
@@ -326,6 +326,31 @@ window.__ModuleLoader__.load({
326
326
  "include:dsh-connect-workbuddy",
327
327
  WORKBUDDY_SETTINGS_NS
328
328
  ];
329
+ /**
330
+ * The `<package name>` keys under which the card registers itself in the
331
+ * Plugins panel.
332
+ *
333
+ * `plugins.bundle.config` and `plugins.row.config` are KEYED slots: the page
334
+ * renders them with `entryKey: pkg.name`, and it decides whether to render the
335
+ * section at all from `ledger.bundles.has(pkg.name)` — a `Set` of exactly the
336
+ * `key` values registered on that slot, compared against the plugin-manager
337
+ * inventory's own package name. Both halves of that comparison are EXACT and
338
+ * neither side is the settings namespace: the inventory name comes from the
339
+ * profile's package list, while a deployment may mount the same code under a
340
+ * different Loader entry id.
341
+ *
342
+ * That is the whole of issue #55. A deployment whose inventory name is
343
+ * `dsh-connect-workbuddy` and whose card registered only under
344
+ * `include:dsh-connect-workbuddy` gets NO card and NO error: the keyed lookup
345
+ * finds nothing, the section is simply not rendered, and `apply()` finished
346
+ * happily. Since the keys live in separate cells (one entry per `key` per
347
+ * priority), registering under every spelling at once is a shadowing-free
348
+ * duplicate rather than a conflict, and is strictly safer than betting on one.
349
+ *
350
+ * The bare package name is FIRST because it is the common case — a profile that
351
+ * installs the package by name mounts it under that name.
352
+ */
353
+ const WORKBUDDY_CARD_KEYS = ["dsh-connect-workbuddy", "include:dsh-connect-workbuddy"];
329
354
  //#endregion
330
355
  //#region src/client/account-selection.ts
331
356
  /**
@@ -656,6 +681,7 @@ window.__ModuleLoader__.load({
656
681
  function exclusionText(t, excludedBy) {
657
682
  switch (excludedBy) {
658
683
  case "rate-limited": return t("row.poolExcludedRateLimited");
684
+ case "model-busy": return t("row.poolExcludedModelBusy");
659
685
  case "out-of-credit": return t("row.poolExcludedOutOfCredit");
660
686
  case "credential-rejected": return t("row.poolExcludedRejected");
661
687
  case "not-found": return t("row.probeNotFound");
@@ -786,14 +812,25 @@ window.__ModuleLoader__.load({
786
812
  /**
787
813
  * Outcomes whose label + stated recovery time ARE the whole story.
788
814
  *
789
- * Both carry a reset instant, which the row prints right beside them; their
790
- * bodies restate that instant and add a `code`/`requestId` pair nobody reads.
815
+ * `rate-limited` and `out-of-credit` both carry a reset instant, which the row
816
+ * prints right beside them; their bodies restate that instant and add a
817
+ * `code`/`requestId` pair nobody reads.
818
+ *
819
+ * `model-busy` belongs here for the opposite reason: its label states the whole
820
+ * finding INCLUDING the remedy ("this model is loaded right now · not your
821
+ * account — wait about a minute or switch models"). The recorded body is the
822
+ * upstream's `14003` envelope, and appending it in a 72-character cell would
823
+ * bury the remedy under the JSON the label was written to translate.
791
824
  *
792
825
  * `credential-rejected` and `not-found` are deliberately NOT here: their labels
793
826
  * state a conclusion ("sign in again", "no such model"), and the upstream's own
794
827
  * words are what make that conclusion checkable rather than asserted.
795
828
  */
796
- const SELF_EXPLANATORY = /* @__PURE__ */ new Set(["rate-limited", "out-of-credit"]);
829
+ const SELF_EXPLANATORY = /* @__PURE__ */ new Set([
830
+ "rate-limited",
831
+ "model-busy",
832
+ "out-of-credit"
833
+ ]);
797
834
  /**
798
835
  * Longest inline reason kept.
799
836
  *
@@ -1159,6 +1196,7 @@ window.__ModuleLoader__.load({
1159
1196
  switch (outcome) {
1160
1197
  case "ok": return t("row.probeOk");
1161
1198
  case "rate-limited": return t("row.poolExcludedRateLimited");
1199
+ case "model-busy": return t("row.poolExcludedModelBusy");
1162
1200
  case "out-of-credit": return t("row.poolExcludedOutOfCredit");
1163
1201
  case "credential-rejected": return t("row.probeCredentialRejected");
1164
1202
  case "not-found": return t("row.probeNotFound");
@@ -1201,7 +1239,7 @@ window.__ModuleLoader__.load({
1201
1239
  }
1202
1240
  /** A dot colour for one account's state. */
1203
1241
  function stateColor(account) {
1204
- if (account.excludedBy !== void 0) return account.excludedBy === "rate-limited" || account.excludedBy === "unavailable" ? "var(--dsw-alias-state-warn-primary, #f59e0b)" : "var(--dsw-alias-state-error-primary, #ef4444)";
1242
+ if (account.excludedBy !== void 0) return account.excludedBy === "rate-limited" || account.excludedBy === "model-busy" || account.excludedBy === "unavailable" ? "var(--dsw-alias-state-warn-primary, #f59e0b)" : "var(--dsw-alias-state-error-primary, #ef4444)";
1205
1243
  if (account.probe === void 0) return "var(--dsw-alias-label-dimmed, #9aa0a6)";
1206
1244
  return outcomeOk(account.probe.outcome) ? "var(--dsw-alias-state-success-primary, #22a06b)" : "var(--dsw-alias-state-warn-primary, #f59e0b)";
1207
1245
  }
@@ -1770,7 +1808,7 @@ window.__ModuleLoader__.load({
1770
1808
  const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
1771
1809
  const excluded = exclusionText(t, account.excludedBy);
1772
1810
  const probe = account.probe;
1773
- const outcomeLine = probe === void 0 ? t("row.poolNeverTested") : probe.retryAtMs !== void 0 ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryAt", { at: formatShort(probe.retryAtMs) })}` : probe.outcome === "rate-limited" || probe.outcome === "out-of-credit" ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}` : outcomeText(t, probe.outcome);
1811
+ const outcomeLine = probe === void 0 ? t("row.poolNeverTested") : probe.retryAtMs !== void 0 ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryAt", { at: formatShort(probe.retryAtMs) })}` : probe.outcome === "rate-limited" || probe.outcome === "model-busy" || probe.outcome === "out-of-credit" ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}` : outcomeText(t, probe.outcome);
1774
1812
  const remaining = probe?.retryAtMs === void 0 ? "" : remainingText(t, probe.retryAtMs, Date.now());
1775
1813
  const reason = probe === void 0 ? void 0 : inlineProbeReason(probe.outcome, probe.message);
1776
1814
  const age = probe === void 0 ? void 0 : probeAgeText(t, probe.atMs, Date.now());
@@ -3572,6 +3610,10 @@ window.__ModuleLoader__.load({
3572
3610
  text: result.retryAtMs === void 0 ? t("row.probeRateLimitedUnknown") : t("row.probeRateLimitedAt", { at: formatDate(result.retryAtMs) }) + retrySuffix(t, result.retryAtMs, nowMs),
3573
3611
  tone: "warn"
3574
3612
  };
3613
+ case "model-busy": return {
3614
+ text: result.retryAtMs === void 0 ? t("row.probeModelBusyUnknown") : t("row.probeModelBusyAt", { at: formatDate(result.retryAtMs) }) + retrySuffix(t, result.retryAtMs, nowMs),
3615
+ tone: "warn"
3616
+ };
3575
3617
  case "out-of-credit": return {
3576
3618
  text: result.retryAtMs === void 0 ? t("row.probeOutOfCreditUnknown") : t("row.probeOutOfCreditAt", { at: formatDate(result.retryAtMs) }) + retrySuffix(t, result.retryAtMs, nowMs),
3577
3619
  tone: "bad"
@@ -5314,6 +5356,8 @@ window.__ModuleLoader__.load({
5314
5356
  "row.probeOkMs": "✓ Usable ({ms})",
5315
5357
  "row.probeRateLimitedUnknown": "✗ Rate limited · the upstream gave no time for when it frees up",
5316
5358
  "row.probeRateLimitedAt": "✗ Rate limited · usable again after {at}",
5359
+ "row.probeModelBusyUnknown": "✗ This model is loaded right now · not your account — another account will not help; wait about a minute or switch models",
5360
+ "row.probeModelBusyAt": "✗ This model is loaded right now · not your account; retry after {at}, or switch models",
5317
5361
  "row.probeOutOfCreditUnknown": "✗ Not enough credits · the upstream gave no time for when it frees up",
5318
5362
  "row.probeOutOfCreditAt": "✗ Not enough credits · quota refreshes {at}",
5319
5363
  "row.probeCredentialRejected": "✗ WorkBuddy refused this account's token — sign in again or pick another account",
@@ -5394,6 +5438,7 @@ window.__ModuleLoader__.load({
5394
5438
  "row.poolCreditSoon": "{count} expiring within 3 days",
5395
5439
  "row.poolCreditNearest": "Nearest package expires {at}",
5396
5440
  "row.poolExcludedRateLimited": "Rate limited",
5441
+ "row.poolExcludedModelBusy": "Model busy",
5397
5442
  "row.poolExcludedOutOfCredit": "Out of credits",
5398
5443
  "row.poolExcludedRejected": "Rejected",
5399
5444
  "row.poolExcludedUnusable": "Unusable",
@@ -5574,6 +5619,8 @@ window.__ModuleLoader__.load({
5574
5619
  "row.probeOkMs": "✓ 可用({ms})",
5575
5620
  "row.probeRateLimitedUnknown": "✗ 被限流 · 上游未给出何时恢复",
5576
5621
  "row.probeRateLimitedAt": "✗ 被限流 · {at} 之后可再用",
5622
+ "row.probeModelBusyUnknown": "✗ 该模型当前负载较高 · 不是账号被限流——换账号没用,建议等待约 1 分钟或切换模型",
5623
+ "row.probeModelBusyAt": "✗ 该模型当前负载较高 · 不是账号被限流;{at} 之后可重试,或切换模型",
5577
5624
  "row.probeOutOfCreditUnknown": "✗ 积分不足 · 上游未给出何时恢复",
5578
5625
  "row.probeOutOfCreditAt": "✗ 积分不足 · 积分包 {at} 刷新",
5579
5626
  "row.probeCredentialRejected": "✗ WorkBuddy 拒绝了这个账号的 token——请重新登录或换一个账号",
@@ -5654,6 +5701,7 @@ window.__ModuleLoader__.load({
5654
5701
  "row.poolCreditSoon": "{count} 积分 3 天内过期",
5655
5702
  "row.poolCreditNearest": "最近积分包 {at} 过期",
5656
5703
  "row.poolExcludedRateLimited": "被限流",
5704
+ "row.poolExcludedModelBusy": "模型繁忙",
5657
5705
  "row.poolExcludedOutOfCredit": "积分耗尽",
5658
5706
  "row.poolExcludedRejected": "被拒绝",
5659
5707
  "row.poolExcludedUnusable": "不可用",
@@ -5802,7 +5850,15 @@ window.__ModuleLoader__.load({
5802
5850
  const softGet = (name) => ctx.get(name);
5803
5851
  let settingsScope;
5804
5852
  const forms = softGet("configForms");
5805
- /** Register one card slot; returns its disposer, or undefined if it failed. */
5853
+ /**
5854
+ * Register one card slot; returns its disposer, or undefined if it failed.
5855
+ *
5856
+ * `ctx.slots.inject(name, …)` resolves as soon as the slot is DECLARED (the
5857
+ * Plugins page declares all three ledgers — `plugins.item`,
5858
+ * `plugins.bundle.config`, `plugins.row.config` — as children of its own
5859
+ * registration), and is otherwise queued until a later declaration. So a
5860
+ * registration here is not lost just because the page has not mounted yet.
5861
+ */
5806
5862
  const registerCard = (slotName, key) => {
5807
5863
  try {
5808
5864
  return ctx.slots.inject(slotName, () => ctx.slots.register({
@@ -5861,9 +5917,21 @@ window.__ModuleLoader__.load({
5861
5917
  * registrations must move TOGETHER: see {@link bindSettingsScope} for why a
5862
5918
  * late-arriving namespace has to produce fresh registrations rather than a
5863
5919
  * mutated closure.
5920
+ *
5921
+ * The card registers under EVERY spelling of its package name (see
5922
+ * {@link WORKBUDDY_CARD_KEYS}): the Plugins page looks a
5923
+ * configuration up by exact `key` equality against the inventory's package
5924
+ * name, and a deployment is free to name the install row whatever it likes
5925
+ * — issue #55 is exactly that mismatch, which renders an empty page and
5926
+ * reports nothing at all.
5864
5927
  */
5928
+ const registerAllSlots = () => [
5929
+ ...WORKBUDDY_CARD_KEYS.map((key) => registerCard("plugins.bundle.config", key)),
5930
+ ...WORKBUDDY_CARD_KEYS.map((key) => registerCard("plugins.row.config", `${key}#${key}`)),
5931
+ registerComposerCredits()
5932
+ ].filter((disposer) => disposer !== void 0);
5865
5933
  /**
5866
- * Disposers of the slots registered for the CURRENT namespace binding.
5934
+ * Disposers of the slots registered for the CURRENT binding.
5867
5935
  *
5868
5936
  * Declared before {@link bindSettingsScope} because that function closes
5869
5937
  * over it and reassigns it; the binding is only armed at the end of `apply`,
@@ -5871,11 +5939,30 @@ window.__ModuleLoader__.load({
5871
5939
  * ordering a non-requirement rather than a trap.
5872
5940
  */
5873
5941
  let liveDisposers = [];
5874
- const registerAllSlots = () => [
5875
- registerCard("plugins.bundle.config", "dsh-connect-workbuddy"),
5876
- registerCard("plugins.row.config", "dsh-connect-workbuddy#dsh-connect-workbuddy"),
5877
- registerComposerCredits()
5878
- ].filter((disposer) => disposer !== void 0);
5942
+ /**
5943
+ * Replace the live registrations with a fresh set.
5944
+ *
5945
+ * ORDER MATTERS: dispose FIRST, then register.
5946
+ *
5947
+ * The ledger is a KEYED slot with one entry per `key` per priority, so
5948
+ * registering a key that is still live THROWS (`already has an entry for
5949
+ * key …`). Every registration in this entry is isolated by its own
5950
+ * try/catch, so the failure is not visible — the throw is logged and the
5951
+ * key is simply left with NO entry, because the old one was just disposed
5952
+ * by the `previous` loop. Registering before disposing therefore produces
5953
+ * an EMPTY ledger, which is a worse version of the very defect this
5954
+ * function exists to prevent.
5955
+ */
5956
+ const rebindSlots = () => {
5957
+ for (const dispose of liveDisposers) try {
5958
+ dispose();
5959
+ } catch {}
5960
+ liveDisposers = [];
5961
+ liveDisposers = registerAllSlots();
5962
+ try {
5963
+ console.info(`[dsh-connect-workbuddy] registered ${String(liveDisposers.length)} slots (settings form: ${settingsScope === void 0 ? "none — card is read-only" : "bound"})`);
5964
+ } catch {}
5965
+ };
5879
5966
  /**
5880
5967
  * Bind `settingsScope` to whichever namespace the Host really serves, and
5881
5968
  * RE-REGISTER every slot when that answer changes.
@@ -5916,10 +6003,7 @@ window.__ModuleLoader__.load({
5916
6003
  ctx.effect(() => forms.whileServed(WORKBUDDY_SETTINGS_NAMESPACE_CANDIDATES, (served) => {
5917
6004
  const ns = WORKBUDDY_SETTINGS_NAMESPACE_CANDIDATES.find((candidate) => served.has(candidate)) ?? [...served].find((entry) => /workbuddy/i.test(entry));
5918
6005
  settingsScope = ns === void 0 ? void 0 : forms.get(ns);
5919
- for (const dispose of liveDisposers) try {
5920
- dispose();
5921
- } catch {}
5922
- liveDisposers = registerAllSlots();
6006
+ rebindSlots();
5923
6007
  return () => {
5924
6008
  for (const dispose of liveDisposers) try {
5925
6009
  dispose();
@@ -5927,6 +6011,7 @@ window.__ModuleLoader__.load({
5927
6011
  liveDisposers = [];
5928
6012
  };
5929
6013
  }), "dsh-connect-workbuddy: settings namespace binding");
6014
+ rebindSlots();
5930
6015
  };
5931
6016
  bindSettingsScope();
5932
6017
  } catch (error) {
@@ -918,17 +918,38 @@ const ERROR_BODY_LIMIT = 4096;
918
918
  * budget covers the response only, never the one-line body that follows.
919
919
  */
920
920
  const PROBE_TIMEOUT_MS = 1e4;
921
- /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
921
+ /**
922
+ * Insufficient-credit markers.
923
+ *
924
+ * ENGLISH IS MATCHED CASE-INSENSITIVELY (see {@link classifyUpstreamError}) so
925
+ * each entry is spelled once, lowercase. Chinese has no case, so those entries
926
+ * are the upstream's own wording.
927
+ *
928
+ * `credits exhausted` is listed alongside `credit exhausted` because the
929
+ * upstream ships the PLURAL — measured 2026-10-13 on a live `14018`:
930
+ *
931
+ * {"error":{"data":{"code":14018,"msg":"Credits exhausted. Please visit the
932
+ * link below to purchase add-on packs and get more credits: …"}}}
933
+ *
934
+ * The singular-only entry silently missed it (`'credits exhausted.'.includes(
935
+ * 'credit exhausted')` is FALSE — the `s` sits between the two words), so a
936
+ * failure whose entire point is "go top up" was reported as "wait a bit" and
937
+ * benched a healthy account for the 30-minute fallback. Both spellings are kept
938
+ * because the gateway has been observed to change its wording; neither is
939
+ * assumed to be the only one.
940
+ */
922
941
  const HARD_CREDIT_MARKERS = [
923
942
  "insufficient credit",
924
943
  "no credit",
925
944
  "credit exhausted",
945
+ "credits exhausted",
926
946
  "out of credit",
927
947
  "quota exceeded",
928
948
  "quota exhaust",
929
949
  "payment required",
930
950
  "credit not enough",
931
951
  "not enough credit",
952
+ "credits not enough",
932
953
  "积分不足",
933
954
  "额度不足",
934
955
  "余额不足",
@@ -936,6 +957,21 @@ const HARD_CREDIT_MARKERS = [
936
957
  "额度用尽",
937
958
  "没有积分"
938
959
  ];
960
+ /**
961
+ * Business code of the exhaustion refusal (`14018`).
962
+ *
963
+ * The CODE, not the prose — see {@link HARD_CREDIT_MARKERS} for the wording bug
964
+ * this guards against. Marker tables rot in a way codes do not: the gateway can
965
+ * reword its sentence without changing the contract it advertises, and the
966
+ * failure is silent (the branch simply stops matching). A code cannot drift like
967
+ * that, and matching it in quoted-key form keeps a request id that happens to
968
+ * contain the digits from promoting an unrelated body.
969
+ *
970
+ * ONLY a corroborating signal, never a substitute: the code is checked in
971
+ * addition to the markers, so a body that names the code AND reads as a policy
972
+ * refusal still resolves by the marker table's order.
973
+ */
974
+ const CREDITS_EXHAUSTED_CODE = "\"code\":14018";
939
975
  /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
940
976
  const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
941
977
  /**
@@ -944,13 +980,44 @@ const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
944
980
  * contain `11140` by coincidence).
945
981
  */
946
982
  const POLICY_REJECT_MARKERS = ["request illegal", "\"code\":11140"];
983
+ /**
984
+ * Business code of the MODEL-scoped throttle (`14003`, "too many requests").
985
+ *
986
+ * The quoted-key form for the same reason as the policy markers: a request id is
987
+ * hexadecimal and can contain the digits `14003` by coincidence, and a bare
988
+ * substring search over an arbitrary failure body is how a red herring gets
989
+ * promoted into a routing decision.
990
+ */
991
+ const MODEL_BUSY_CODE = "\"code\":14003";
992
+ /**
993
+ * Whether a failure body is the MODEL-scoped throttle rather than an account
994
+ * limit.
995
+ *
996
+ * `14003` and `6004` are different codes from the same gateway and mean
997
+ * different things. Measured on one credential at one moment, with requests that
998
+ * differed ONLY in `model`, three attempts each: `deepseek-v4.1-flash`
999
+ * (multiplier x0.00) answered 1/3 and refused 2/3 with `429 / 14003`, while
1000
+ * `glm-5.3-flash`, `hy3` and `kimi-k2.6` answered 3/3 — the SAME account, the
1001
+ * SAME second. A 6004, by contrast, carries a `将在 … UTC+8 重置` sentence and
1002
+ * is per-account.
1003
+ *
1004
+ * So the criterion is the CODE alone, matched in its quoted-key form so a
1005
+ * request id that happens to contain the digits cannot promote it. Exported so
1006
+ * the probe and the chat path cannot drift into two different answers about one
1007
+ * body.
1008
+ */
1009
+ function isModelBusyBody(body) {
1010
+ return body.includes(MODEL_BUSY_CODE);
1011
+ }
947
1012
  /** Classify an upstream failure from its HTTP status and body excerpt. */
948
1013
  function classifyUpstreamError(status, body) {
949
1014
  if (status === 402) return "hard_credit";
950
1015
  const lower = body.toLowerCase();
951
1016
  for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
1017
+ if (body.includes(CREDITS_EXHAUSTED_CODE)) return "hard_credit";
952
1018
  for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
953
1019
  for (const marker of POLICY_REJECT_MARKERS) if (body.includes(marker)) return "policy_reject";
1020
+ if (isModelBusyBody(body)) return "model_busy";
954
1021
  if (status === 429) return "soft_rate";
955
1022
  if (status === 404) return "not_found";
956
1023
  if (status >= 500) return "server";
@@ -3733,7 +3800,7 @@ function generationOf(version) {
3733
3800
  //#endregion
3734
3801
  //#region src/version.ts
3735
3802
  /** The npm package version this build was produced from. */
3736
- const WORKBUDDY_CONNECT_VERSION = "4.4.2";
3803
+ const WORKBUDDY_CONNECT_VERSION = "4.4.3";
3737
3804
  //#endregion
3738
3805
  //#region src/host-heartbeat.ts
3739
3806
  /**
package/lib/index.d.ts CHANGED
@@ -565,7 +565,38 @@ declare class WorkBuddyCredentialStore {
565
565
  /** WorkBuddy region selected by the credential's login domain. */
566
566
  type WorkBuddyRegion = 'cn' | 'global';
567
567
  /** Upstream failure classes the shim maps onto distinct HTTP answers. */
568
- type UpstreamErrorKind = 'hard_credit' | 'soft_rate' | 'session_dead' | 'policy_reject' | 'not_found' | 'server' | 'client';
568
+ type UpstreamErrorKind = 'hard_credit' |
569
+ /**
570
+ * The upstream's own volume limit (business code 6004, or a 429 whose body
571
+ * names a reset time). A statement about the ACCOUNT: it comes with a
572
+ * timestamp, it lasts, and another account is a real way around it.
573
+ */
574
+ 'soft_rate' |
575
+ /**
576
+ * MODEL-scoped throttling with no stated time (business code 14003).
577
+ *
578
+ * Deliberately its own kind, because treating it as {@link soft_rate} is a
579
+ * 30x amplification of an upstream that says "wait about a minute" — and it
580
+ * makes the pool walk every account into the same wall (issue #56).
581
+ *
582
+ * The evidence that this is the MODEL's load and not the account's, measured
583
+ * on one credential at one moment, byte-identical requests differing ONLY in
584
+ * the `model` field, three attempts each:
585
+ *
586
+ * | model | multiplier | ok | failures |
587
+ * | --- | --- | --- | --- |
588
+ * | deepseek-v4.1-flash | x0.00 | 1/3 | 429 / 14003 |
589
+ * | deepseek-v4.1-flash-sg | x0.03 | 1/3 | 429 / 14003 |
590
+ * | glm-5.3-flash | x0.06 | 3/3 | — |
591
+ * | hy3 | x0.00 | 3/3 | — |
592
+ * | kimi-k2.6 | x0.77 | 3/3 | — |
593
+ *
594
+ * The same account served other models at the same second, so the credential
595
+ * was fine. The upstream's own words agree: "Wait about a minute before
596
+ * retrying, or switch to a less busy model." Both halves are rendered to the
597
+ * user; the second one is the actual remedy.
598
+ */
599
+ 'model_busy' | 'session_dead' | 'policy_reject' | 'not_found' | 'server' | 'client';
569
600
  /** Reasoning capability as the upstream catalog declares it. */
570
601
  interface WorkBuddyReasoning {
571
602
  supportedEfforts?: readonly string[];
@@ -1683,8 +1714,14 @@ declare function probeRequestBody(modelId: string, inputTokens?: number): string
1683
1714
  * `rate-limited` is split out from a generic failure because it is the one
1684
1715
  * outcome the feature exists for: it means "this model works, but not right
1685
1716
  * now". Everything else is a real, actionable failure or a success.
1717
+ *
1718
+ * `model-busy` is a second such split, and it is NOT cosmetic — see
1719
+ * {@link MODEL_BUSY_COOLDOWN_MS} and `exclusionOf`. A 14003 says the MODEL is
1720
+ * loaded, everywhere, for everyone; a 6004 says THIS ACCOUNT exceeded a volume
1721
+ * limit. Reporting both as `rate-limited` made the pool bench every account for
1722
+ * the cooldown of one, which is the outage issue #56 describes.
1686
1723
  */
1687
- type WorkBuddyProbeOutcome = 'ok' | 'rate-limited' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' | 'unavailable' | 'not-found' | 'failed';
1724
+ type WorkBuddyProbeOutcome = 'ok' | 'rate-limited' | 'model-busy' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' | 'unavailable' | 'not-found' | 'failed';
1688
1725
  /**
1689
1726
  * One probe result, as the card renders it.
1690
1727
  *
@@ -1704,8 +1741,16 @@ interface WorkBuddyProbeResult {
1704
1741
  message?: string;
1705
1742
  /** A time the upstream named, in ms. Absent when it named none. */
1706
1743
  retryAtMs?: number;
1707
- /** Where a supplied `retryAtMs` came from, so the card can say so. */
1708
- retrySource?: 'retry-after' | 'upstream-message' | 'quota-refresh';
1744
+ /**
1745
+ * Where a supplied `retryAtMs` came from, so the card can say so.
1746
+ *
1747
+ * `upstream-tips` is the odd one and it says exactly what it is: the upstream
1748
+ * stated a MAGNITUDE in prose ("wait about a minute") rather than a timestamp,
1749
+ * and this build turned it into {@link MODEL_BUSY_COOLDOWN_MS}. Naming it
1750
+ * separately keeps the card honest — the other three sources are the upstream
1751
+ * giving a time, this one is the upstream giving an order of magnitude.
1752
+ */
1753
+ retrySource?: 'retry-after' | 'upstream-message' | 'quota-refresh' | 'upstream-tips';
1709
1754
  }
1710
1755
  /**
1711
1756
  * Parse a `Retry-After` header value into an absolute time.
@@ -1737,6 +1782,14 @@ declare function parseRetryAfter(value: string | null, nowMs: number): number |
1737
1782
  * A limited outcome with none of the three returns `{}`, which the card renders
1738
1783
  * as "the upstream did not say when". That remains the honest answer for a
1739
1784
  * genuinely timeless failure; inventing a number here would be pure fiction.
1785
+ *
1786
+ * `model-busy` is the ONE exception, and it is not an invented countdown: the
1787
+ * upstream states a magnitude in prose ("about a minute") rather than a
1788
+ * timestamp, and the card's own vocabulary has no way to render "about". A
1789
+ * per-account fallback of 30 minutes is what it used to get, which is both wrong
1790
+ * about the magnitude and wrong about the scope (issue #56). The returned value
1791
+ * is {@link MODEL_BUSY_COOLDOWN_MS}, and it is used only to bench the MODEL, never
1792
+ * an account.
1740
1793
  */
1741
1794
  declare function cooldownOf(input: {
1742
1795
  outcome: WorkBuddyProbeOutcome;
@@ -1754,8 +1807,10 @@ declare function cooldownOf(input: {
1754
1807
  * probe and a real chat request can never disagree about what the same upstream
1755
1808
  * answer means. The extra splits here are `credential-rejected`, which the
1756
1809
  * upstream signals with a non-JSON 401/403 edge page and which needs completely
1757
- * different advice (re-auth, not "wait"), and `policy-rejected`, a JSON 403
1758
- * content-policy refusal where re-auth advice would be actively misleading.
1810
+ * different advice (re-auth, not "wait"), `policy-rejected`, a JSON 403
1811
+ * content-policy refusal where re-auth advice would be actively misleading, and
1812
+ * `model-busy`, the model-scoped 14003 throttle that must NOT be benched as an
1813
+ * account failure (issue #56).
1759
1814
  */
1760
1815
  declare function outcomeOfFailure(status: number, body: string): WorkBuddyProbeOutcome;
1761
1816
  /**
@@ -2347,7 +2402,18 @@ type WorkBuddyWebUsage =
2347
2402
  * here because this module is the node-free bridge and must not import the
2348
2403
  * Host-side probe module (which pulls in the credential type).
2349
2404
  */
2350
- type WorkBuddyWebProbeOutcome = 'ok' | 'rate-limited' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' | 'unavailable' | 'not-found' | 'failed';
2405
+ type WorkBuddyWebProbeOutcome = 'ok' | 'rate-limited' |
2406
+ /**
2407
+ * The MODEL is loaded, not the account (upstream code 14003).
2408
+ *
2409
+ * Kept apart from `rate-limited` because the two need OPPOSITE advice:
2410
+ * `rate-limited` is this account's volume limit (wait, or bill another
2411
+ * account), while `model-busy` follows the model everywhere — every account
2412
+ * hits the same wall, so switching accounts only spends a request per account
2413
+ * to learn what the first one already said. The remedy is the upstream's own:
2414
+ * wait about a minute, or switch to a less busy model.
2415
+ */
2416
+ 'model-busy' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' | 'unavailable' | 'not-found' | 'failed';
2351
2417
  /** One model's probe result, as the card renders it. */
2352
2418
  interface WorkBuddyWebProbeResult {
2353
2419
  modelId: string;
@@ -2362,18 +2428,31 @@ interface WorkBuddyWebProbeResult {
2362
2428
  * A time the upstream named for using this model again, in ms.
2363
2429
  *
2364
2430
  * ABSENT means the upstream named none, and the card must say exactly that.
2365
- * A client-invented countdown here would be fiction. Three sources fill it:
2431
+ * A client-invented countdown here would be fiction. Four sources fill it:
2366
2432
  * a real `Retry-After` header; the reset sentence the upstream writes into
2367
2433
  * its own failure BODY (`将在 … UTC+8 重置`, which is where this service
2368
- * actually puts it — its 429s carry no rate-limit header at all); or, for an
2369
- * exhausted quota, the region's own quota refresh point.
2434
+ * actually puts it — its 429s carry no rate-limit header at all); the
2435
+ * region's own quota refresh point for an exhausted quota; or, for the
2436
+ * model-scoped 14003 throttle, the magnitude the upstream states in PROSE
2437
+ * ("wait about a minute") — see {@link WorkBuddyWebProbeOutcome}.
2370
2438
  */
2371
2439
  retryAtMs?: number;
2372
- /** Which of those three sources supplied {@link retryAtMs}. */
2373
- retrySource?: 'retry-after' | 'upstream-message' | 'quota-refresh';
2440
+ /** Which of those four sources supplied {@link retryAtMs}. */
2441
+ retrySource?: 'retry-after' | 'upstream-message' | 'quota-refresh' | 'upstream-tips';
2374
2442
  }
2375
2443
  /** Why one account is currently out of the pool. */
2376
- type WorkBuddyWebPoolExclusion = 'unusable' | 'rate-limited' | 'out-of-credit' | 'credential-rejected' | 'not-found' | 'unavailable' | 'failed';
2444
+ type WorkBuddyWebPoolExclusion = 'unusable' | 'rate-limited' |
2445
+ /**
2446
+ * The MODEL this account was measured against is loaded, not the account.
2447
+ *
2448
+ * Mirrors the same-named member of `WorkBuddyPoolExclusion` in
2449
+ * `./account-pool.ts`, and mirrored here for the same reason as the outcome
2450
+ * vocabulary above: this module is the node-free bridge and must not import
2451
+ * the Host-side pool module. The card renders it with its own label, because
2452
+ * showing "被限流" next to an ACCOUNT name asserts something the measurement
2453
+ * does not say (issue #56).
2454
+ */
2455
+ 'model-busy' | 'out-of-credit' | 'credential-rejected' | 'not-found' | 'unavailable' | 'failed';
2377
2456
  /**
2378
2457
  * One account's row in the pool, as the card renders it.
2379
2458
  *
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-CBFAiYPg.js";
1
+ import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-C8imAyWG.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -1177,6 +1177,7 @@ function isJsonContentType(req) {
1177
1177
  const KIND_STATUS = {
1178
1178
  hard_credit: 402,
1179
1179
  soft_rate: 429,
1180
+ model_busy: 429,
1180
1181
  session_dead: 401,
1181
1182
  policy_reject: 403,
1182
1183
  not_found: 502,
@@ -1221,8 +1222,19 @@ const KIND_STATUS = {
1221
1222
  * place would spend the retry budget on a request the server will refuse again,
1222
1223
  * and cross-provider failover cannot help a policy decision either. The code the
1223
1224
  * host receives therefore names the class honestly.
1225
+ *
1226
+ * `model_busy` gets the same `quota_exceeded` translation as `soft_rate`, and
1227
+ * for a sharper version of the same reason: it arrives as a 429, and the host's
1228
+ * in-place retry set is exactly where a request that is about to be refused
1229
+ * again must not go. A model that is loaded for everyone will not clear inside
1230
+ * one turn's backoff, so spending five attempts on it delays the honest answer
1231
+ * without ever producing one. `type` still says `model_busy`, which is what a
1232
+ * human reads and what routes the wording below.
1224
1233
  */
1225
- const KIND_HOST_CODE = { soft_rate: "quota_exceeded" };
1234
+ const KIND_HOST_CODE = {
1235
+ soft_rate: "quota_exceeded",
1236
+ model_busy: "quota_exceeded"
1237
+ };
1226
1238
  /**
1227
1239
  * The `code` to write for an upstream failure, given its classified kind.
1228
1240
  *
@@ -1713,8 +1725,9 @@ function describeAccount(credential) {
1713
1725
  function failoverReasonText(failure) {
1714
1726
  if (failure.ok) return "unusable";
1715
1727
  switch (failure.kind) {
1716
- case "soft_rate":
1717
- case "hard_credit": return "rate limited";
1728
+ case "soft_rate": return "rate limited";
1729
+ case "hard_credit": return "out of credits";
1730
+ case "model_busy": return "throttled on this model (not the account)";
1718
1731
  case "session_dead": return "signed out";
1719
1732
  case "policy_reject": return "rejected by policy";
1720
1733
  case "not_found": return "model unavailable";
@@ -1837,12 +1850,24 @@ function createWorkBuddyShim(options) {
1837
1850
  * `client` is deliberately NOT one of them: the upstream rejected the
1838
1851
  * REQUEST (malformed body, unsupported field), so every account answers the
1839
1852
  * same 400 and walking the rest of the pool only multiplies the wait before
1840
- * the user sees an error they must act on anyway. Everything else describes a
1841
- * PER-ACCOUNT condition — a rate limit, exhausted credits, a dead session, a
1842
- * gateway that failed this one call — which another account may well survive.
1853
+ * the user sees an error they must act on anyway.
1854
+ *
1855
+ * `model_busy` is NOT one of them either, and the distinction is the whole of
1856
+ * issue #56. The 14003 throttle belongs to the MODEL — the upstream's own
1857
+ * message says so ("switch to a less busy model") and the reporter's
1858
+ * measurement confirms it: same credential, same second, other models answer.
1859
+ * So a second account does not answer better than the first; it answers the
1860
+ * same 14003, one round trip later, and each attempt writes a rate-limited
1861
+ * measurement onto an account that was never the problem. The measured outcome
1862
+ * was a pool in which every member had been taught a false fact, and a user
1863
+ * who could not talk to the model for half an hour.
1864
+ *
1865
+ * Everything else describes a PER-ACCOUNT condition — a volume limit,
1866
+ * exhausted credits, a dead session, a gateway that failed this one call —
1867
+ * which another account may well survive.
1843
1868
  */
1844
1869
  function isFailoverWorthy(kind) {
1845
- return kind !== "client";
1870
+ return kind !== "client" && kind !== "model_busy";
1846
1871
  }
1847
1872
  async function chatCompletions(req, res) {
1848
1873
  if (!isJsonContentType(req)) {
@@ -1949,6 +1974,16 @@ function createWorkBuddyShim(options) {
1949
1974
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
1950
1975
  return;
1951
1976
  }
1977
+ if (result.kind === "model_busy") {
1978
+ const detail = result.detail;
1979
+ const lead = detail?.displayMsg ?? result.message.slice(0, 200);
1980
+ const meta = ["模型当前负载较高"];
1981
+ if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
1982
+ if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
1983
+ const message = `${lead}(${meta.join(",")})${note}。这不是账号被限流——同一凭据下换一个模型即可正常使用;插件不会为此切换账号,因为该限制跟随模型本身。建议等待约 1 分钟后重试,或切换到负载较低的模型`;
1984
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
1985
+ return;
1986
+ }
1952
1987
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status})${note}: ${result.message.slice(0, 400)}`, hostErrorCode(result.kind));
1953
1988
  return;
1954
1989
  }
@@ -2009,6 +2044,21 @@ function createWorkBuddyShim(options) {
2009
2044
  //#endregion
2010
2045
  //#region src/probe.ts
2011
2046
  /**
2047
+ * How long to bench a MODEL that answered 14003 with no stated time.
2048
+ *
2049
+ * One minute, because that is what the upstream itself says: the failure body
2050
+ * carries `displayTips.zh` = "建议等待约 1 分钟后重试,或切换到负载较低的模型"
2051
+ * (and its English twin "Wait about a minute before retrying, or switch to a
2052
+ * less busy model"). A fallback coarser than the upstream's own advice is not
2053
+ * conservatism, it is a different and worse claim: issue #56 reports 14003 being
2054
+ * multiplied into 30 minutes per account, which turned a one-minute model
2055
+ * hiccup into a half-hour outage across the whole pool.
2056
+ *
2057
+ * Two minutes rather than one, because a probe costs a full request and the
2058
+ * point of the wait is to be past the burst, not to land exactly on its edge.
2059
+ */
2060
+ const MODEL_BUSY_COOLDOWN_MS$1 = 12e4;
2061
+ /**
2012
2062
  * The system message every probe carries.
2013
2063
  *
2014
2064
  * Not decoration: the international gateway rejects a conversation that does not
@@ -2215,6 +2265,14 @@ function parseUpstreamResetAt(message) {
2215
2265
  * A limited outcome with none of the three returns `{}`, which the card renders
2216
2266
  * as "the upstream did not say when". That remains the honest answer for a
2217
2267
  * genuinely timeless failure; inventing a number here would be pure fiction.
2268
+ *
2269
+ * `model-busy` is the ONE exception, and it is not an invented countdown: the
2270
+ * upstream states a magnitude in prose ("about a minute") rather than a
2271
+ * timestamp, and the card's own vocabulary has no way to render "about". A
2272
+ * per-account fallback of 30 minutes is what it used to get, which is both wrong
2273
+ * about the magnitude and wrong about the scope (issue #56). The returned value
2274
+ * is {@link MODEL_BUSY_COOLDOWN_MS}, and it is used only to bench the MODEL, never
2275
+ * an account.
2218
2276
  */
2219
2277
  function cooldownOf(input) {
2220
2278
  const fromHeader = parseRetryAfter(input.retryAfter, input.nowMs);
@@ -2231,6 +2289,10 @@ function cooldownOf(input) {
2231
2289
  retryAtMs: input.quotaRefreshAtMs,
2232
2290
  retrySource: "quota-refresh"
2233
2291
  };
2292
+ if (input.outcome === "model-busy") return {
2293
+ retryAtMs: input.nowMs + MODEL_BUSY_COOLDOWN_MS$1,
2294
+ retrySource: "upstream-tips"
2295
+ };
2234
2296
  return {};
2235
2297
  }
2236
2298
  /**
@@ -2240,8 +2302,10 @@ function cooldownOf(input) {
2240
2302
  * probe and a real chat request can never disagree about what the same upstream
2241
2303
  * answer means. The extra splits here are `credential-rejected`, which the
2242
2304
  * upstream signals with a non-JSON 401/403 edge page and which needs completely
2243
- * different advice (re-auth, not "wait"), and `policy-rejected`, a JSON 403
2244
- * content-policy refusal where re-auth advice would be actively misleading.
2305
+ * different advice (re-auth, not "wait"), `policy-rejected`, a JSON 403
2306
+ * content-policy refusal where re-auth advice would be actively misleading, and
2307
+ * `model-busy`, the model-scoped 14003 throttle that must NOT be benched as an
2308
+ * account failure (issue #56).
2245
2309
  */
2246
2310
  function outcomeOfFailure(status, body) {
2247
2311
  if (status === 401 || status === 403) {
@@ -2250,6 +2314,7 @@ function outcomeOfFailure(status, body) {
2250
2314
  }
2251
2315
  switch (classifyUpstreamError(status, body)) {
2252
2316
  case "soft_rate": return "rate-limited";
2317
+ case "model_busy": return "model-busy";
2253
2318
  case "hard_credit": return "out-of-credit";
2254
2319
  case "session_dead": return "credential-rejected";
2255
2320
  case "not_found": return "not-found";
@@ -2527,6 +2592,23 @@ function defaultWait(ms) {
2527
2592
  function messageOf$1(error) {
2528
2593
  return (error instanceof Error ? error.message : String(error)).slice(0, 300);
2529
2594
  }
2595
+ //#endregion
2596
+ //#region src/account-pool.ts
2597
+ /**
2598
+ * How long an account stays out of the pool when the upstream stated no time.
2599
+ *
2600
+ * A conservative FALLBACK, not a measurement: it applies whenever a failed
2601
+ * outcome carries no `retryAtMs`, which is every rate limit that came without a
2602
+ * reset sentence AND every transient failure (a transport error, a 5xx). In both
2603
+ * cases "when can I use this again" has no honest answer.
2604
+ *
2605
+ * The two failure directions it avoids: keeping the account in the pool would
2606
+ * re-bill it immediately and re-trip the same limit; dropping it FOREVER would
2607
+ * discard a working account over an event that said nothing about it. A short,
2608
+ * stated cooldown is the least-wrong of the two — and it is what lets the pool
2609
+ * heal itself, since nothing else re-probes an excluded member.
2610
+ */
2611
+ const POOL_UNKNOWN_COOLDOWN_MS = 18e5;
2530
2612
  /**
2531
2613
  * Whether one account can be billed at `nowMs`.
2532
2614
  *
@@ -2578,6 +2660,7 @@ function exclusionOf(probe, nowMs) {
2578
2660
  case "unavailable": return retryDue(probe, nowMs) ? void 0 : "unavailable";
2579
2661
  case "failed": return retryDue(probe, nowMs) ? void 0 : "failed";
2580
2662
  case "rate-limited": return retryDue(probe, nowMs) ? void 0 : "rate-limited";
2663
+ case "model-busy": return retryDue(probe, nowMs, 12e4) ? void 0 : "model-busy";
2581
2664
  case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
2582
2665
  case "policy-rejected": return;
2583
2666
  default: return "unusable";
@@ -2591,9 +2674,14 @@ function exclusionOf(probe, nowMs) {
2591
2674
  * transport failure, so `atMs + POOL_UNKNOWN_COOLDOWN_MS` is what brings those
2592
2675
  * back. That is deliberate — the alternative is a permanent exclusion decided by
2593
2676
  * an event that said nothing about the account.
2677
+ *
2678
+ * `fallbackMs` lets one outcome carry a SHORTER clock than the generic one: the
2679
+ * model-scoped throttle is upstream's own "about a minute", and applying the
2680
+ * half-hour account fallback to it is issue #56. It is a parameter rather than a
2681
+ * second function so every outcome keeps sharing one definition of "due".
2594
2682
  */
2595
- function retryDue(probe, nowMs) {
2596
- return nowMs >= (probe.retryAtMs ?? probe.atMs + 18e5);
2683
+ function retryDue(probe, nowMs, fallbackMs = POOL_UNKNOWN_COOLDOWN_MS) {
2684
+ return nowMs >= (probe.retryAtMs ?? probe.atMs + fallbackMs);
2597
2685
  }
2598
2686
  /**
2599
2687
  * Rank a pool's members best-first.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "4.4.2",
5
+ "version": "4.4.3",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",