dsh-connect-workbuddy 4.4.2 → 4.4.4
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 +87 -0
- package/README.en.md +22 -0
- package/README.md +22 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +101 -16
- package/lib/{host-heartbeat-CBFAiYPg.js → host-heartbeat-BwbDWvzw.js} +69 -2
- package/lib/index.d.ts +92 -13
- package/lib/index.js +164 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.4.4 (2026-10-13)
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
- **实时聊天路径丢掉了「确定的积分刷新时刻」:同一个「积分耗尽」在一屏上给出两个恢复时间,其中一个差了 18 天。**(issue #56 报告者在核验 4.4.3 时发现)
|
|
8
|
+
- **来源**:报告者核完 4.4.3 的三处修复后,回答我请他判断的那个问题(「`out-of-credit` 无时刻时仍走 30 分钟兜底,是否该改」),并给出了**实测依据**。他先写下、又**自己更正掉**了一个错误结论(「`quotaRefreshAtMs` 没有调用点」—— 实测有 15 处、管道是通的),更正后的结论反而更具体。
|
|
9
|
+
- **现象**:同一个 `out-of-credit` 失败,**按它从哪个门进来**得到两个答案:
|
|
10
|
+
| 路径 | `cooldownOf` 输出 | 池表显示的恢复时刻 |
|
|
11
|
+
| --- | --- | --- |
|
|
12
|
+
| 探针(传 `quotaRefreshAtMs`) | `{retryAtMs: 2026-11-01, retrySource: 'quota-refresh'}` | **2026-11-01** |
|
|
13
|
+
| 实时聊天(当时不传) | `{}` → 落到 `POOL_UNKNOWN_COOLDOWN_MS` | **约 30 分钟后** |
|
|
14
|
+
**两条路径相差约 18 天**,而计费侧给出的那个「下月 1 日」才是唯一诚实的答案。
|
|
15
|
+
- **两个独立缺陷,且会叠加**:
|
|
16
|
+
1. **`recordAccountFailure()` 没有传 `quotaRefreshAtMs`** —— 探针路径早已证明这个数据拿得到(`quotaRefreshOf(region, credential)`),实时路径却没接上。
|
|
17
|
+
2. **`writePoolProbes()` 是账户级整体展开**(`{ ...existing, ...updates }`),`updates[accountId]` 这整个**新对象**替换掉旧对象 —— 所以实时聊天那次写入会把探针**已经存好的** `retryAtMs` 一起删掉。只修第 1 条不够:一条不带 `retryAtMs` 的 `live-request` 记录仍会抹掉好值。
|
|
18
|
+
- **修法**:
|
|
19
|
+
- `recordAccountFailure()` 在 `outcome === 'out-of-credit'` 时查一次 `quotaRefreshOf(region, credential)` 并传入 `cooldownOf`。**只对这一个 outcome 查**:`quotaRefreshOf` 要发一次上游请求,而限流或网络抖动**没有刷新点可找** —— 为它付这次请求等于花钱去学「没有答案」。查不到不是错误,兜底照旧。
|
|
20
|
+
- `writePoolProbes()` 改为逐账户调用新增的纯函数 **`mergePoolProbe(existing, update)`**:`outcome` / `atMs` / `source` / `message` 一律归**更新的那次测量**,而 `retryAtMs` 在**更新没给**时**继承**旧值。继承只针对「缺失不等于声明」的这一个字段 —— 更新**给了**时刻仍然覆盖(新的、更短的冷却必须能压过旧的)。另有一条边界:**已经过去的时刻不继承**,否则记录会被钉在一个已经消失的时刻上,行里会读成「<过去的时刻> 之后可再用」。
|
|
21
|
+
- **刻意未改**「无时刻时仍走 30 分钟兜底」—— 报告者明确不主张改这一条(拿不到刷新点时,用短窗口偶尔试试是合理的)。我们反对的只是**在拿得到真值的时候用那个数字**。
|
|
22
|
+
- **守卫**:新增 `tests/issue56-quota-refresh-merge.spec.ts`(11 例),覆盖继承规则、覆盖规则、过期时刻不继承、以及**磁盘上真实的「探针先写、实时聊天后写」序列**仍然保住刷新点;另把 `tests/pool-e2e.spec.ts` 里那条按**源码形状**断言的守卫扩了三条(必须查到 `quotaRefreshOf(`、必须把 `quotaRefreshAtMs` 传进 `cooldownOf`、且只对能有的 outcome 查)—— 因为 `recordAccountFailure` 此前**没有任何测试覆盖到**,把修复还原后全套测试仍然全绿,这正是它会溜掉的原因。变异验证:还原合并改动 → 2 例变红;还原实时路径的入参 → 源码守卫变红。
|
|
23
|
+
|
|
24
|
+
- **两处「下一版补」的文档欠账,本版补上。**
|
|
25
|
+
- **`chatStream()` 返回信封而非可迭代流**(issue #56 报告者最初就提醒过,我当时说了「下一版补」):中英 README 的「开发」一节新增一节,写明要先查 `envelope.ok`,并点名跳过它的典型后果是**把失败全报成成功**(`response` 为 `undefined`,读它会抛错或被吞掉,于是「每个模型都成功」—— 报告者自己第一次写脚本就踩了这个)。
|
|
26
|
+
- **pnpm 新版本等待期会挡住浮动升级**(报告者在核验 4.4.3 时顺手实测):中英 README 的「安装」一节新增告示 —— 版本刚发布时浮动范围(`^4.4.2`)会被**静默压回上一版**,而精确版本(`4.4.3`)立即生效;两种写法都「安装成功」,所以症状是「升级跑完了、版本没变」,**极易被误报成发布失败**。附上他的实测表(隔离目录、pnpm 12.3.4、`--lockfile-only`),并明确建议写精确版本号。
|
|
27
|
+
|
|
28
|
+
### Docs
|
|
29
|
+
|
|
30
|
+
- 无其它。本版含上述修复与两处文档补记。
|
|
31
|
+
|
|
32
|
+
## 4.4.3 (2026-10-13)
|
|
33
|
+
|
|
34
|
+
### Fixes
|
|
35
|
+
|
|
36
|
+
- **插件页里 `dsh-connect-workbuddy` 的设置项整片空白,且不报任何错。**(issue #55)
|
|
37
|
+
- **现象**:侧边栏「插件」→「已安装」→ 点进 `dsh-connect-workbuddy`,详情页里本该出现的 WorkBuddy 卡片(国内版 / 国际版两个标签页、扫码登录、账号检测、积分、模型勾选)**整个不见**,没有占位、没有报错、控制台也干净。报告者按另一份 AI 建议在 `node_modules` 里打补丁续了一版,但那是打在产品产物上、升级即失效。
|
|
38
|
+
- **根因是两处叠加,各自都足以让卡片消失**:
|
|
39
|
+
- **① 注册用的 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()` 平静结束。
|
|
40
|
+
- **② 注册这件事被挂在了「namespace 到达」的边沿上。** 卡片原本只在 `configForms.whileServed()` 的 `register` 回调里注册,而这个回调只在 **unserved→served 的跳变**上跑一次。若 settings 镜像始终不上报本插件的 namespace(名字不在候选表里、settings 面答得晚或答失败、该部署本就没把插件挂成可配置条目),这个跳变永远不会发生,`registerAllSlots()` 一次都不执行。因为整个 `apply` 体包在只打日志的 try/catch 里,**UI 就这样静默空白** —— 报告者遇到的那份补丁也正是这条。
|
|
41
|
+
- **修法**:① 卡片的 `plugins.bundle.config` / `plugins.row.config` 注册改为覆盖包名的**所有拼写**(`WORKBUDDY_CARD_KEYS`:裸包名 + `include:` 前缀形式,行 key 为 `<包名>#<包名>`)。这些 key 各占一个 cell,属于**无遮蔽的重复条目**而非冲突,所以「全注册一遍」严格优于赌一个。② 注册**不再等待边沿**:`whileServed` 保留原语义(等 namespace 真到了再重绑一次、拿到可写表单),同时无条件先注册一遍;卡片在没有 `settingsScope` 时本就以**只读**形态渲染,这与「宿主没有 settings 面」的既有降级路径是同一条。
|
|
42
|
+
- **顺带修掉一个会让上面②反而变成「整片空白 2.0」的顺序坑**:`rebindSlots()` 必须**先 dispose 再 register**。keyed 槽位会拒绝一个仍然存活的 key(`already has an entry for key …`),而每次注册都有各自的 try/catch 把它吞成一行 `console.error` —— 于是「先注册后 dispose」的结果是:旧条目刚被拆掉、新条目全被拒绝,账本归空。这一点由新增用例显式钉住,并在变异验证(把顺序改回去)下变红。
|
|
43
|
+
- **守卫**:新增 `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)。
|
|
44
|
+
|
|
45
|
+
- **14003 是模型级、逐请求的软限流,却被当成账号级失败:换号循环把池内每个账号各锁 30 分钟。**(issue #56)
|
|
46
|
+
- **现象**:报告者的决定性对照 —— **同一凭据、同一时刻**,除 `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 倍,还放大到了整个账号池。
|
|
47
|
+
- **根因**:`classifyUpstreamError()` 把 429 一律归为 `soft_rate`。14003 的报文里**没有任何日期时间**,所以 `parseUpstreamResetAt()` 返回 `undefined` → `cooldownOf()` 返回 `{}` → `retryDue()` 落到 `probe.atMs + 18e5`(`POOL_UNKNOWN_COOLDOWN_MS` = 30 分钟)。三重放大同时发生:**等太久**、**锁错对象**(锁的是账号,而限制跟着模型走)、**把每个账号都锁一遍**(failover 逐个撞同一堵墙,每撞一次就给一个本来健康的账号写入一条 `live-request` 的限流记录,覆盖掉它先前的 `ok` 测量)。
|
|
48
|
+
- **修法**(四处,各自独立可验):
|
|
49
|
+
- **分类**:新增 `model_busy` 这一 kind(`UpstreamErrorKind` / `WorkBuddyProbeOutcome` / `WorkBuddyPoolExclusion` 三处词汇表各加一枚),判据是 **`"code":14003` 这一带引号的 key 形式** —— 不用 `displayTips` 文案当判据,否则一个恰好提到「负载较低的模型」的网关横幅也会被改判。检查放在**积分标记之后**(两者同时出现时仍按「要充值」这条可执行的结论走)、**普通 429 分支之前**。
|
|
50
|
+
- **时长**:`MODEL_BUSY_COOLDOWN_MS = 2 分钟`,与上游「约 1 分钟」同量级(取 2 而非 1,是为了越过这一阵而不是踩在边上)。`cooldownOf()` 为 `model-busy` 补上这个兜底,并把 `retrySource` 标成新的 `'upstream-tips'` —— 它如实说明这个数字的来路是**上游给的数量级**,不是上游给的时刻。
|
|
51
|
+
- **不换号**:`isFailoverWorthy()` 把 `model_busy` 与 `client` 一起排除。换号撞的是同一堵墙,只会每个账号各花一次请求去学到第一发已经说过的事实,并往每个账号写一条假测量。失败**仍然如实上报一次**(卡片要靠它显示「为什么这个账号不在池里」)。
|
|
52
|
+
- **如实告知**:shim 给 `model_busy` 单独一条错误文案 —— 沿用上游自己的话(「请求过于频繁,请稍后重试。」)+ `requestId`,并明确写出**这不是账号被限流、换账号没用、建议等待约 1 分钟或切换模型**。卡片侧新增 `row.probeModelBusy*` / `row.poolExcludedModelBusy` 文案,池表里「模型繁忙」与「被限流」**分开显示**(把模型的问题写在账号名旁边,正是让报告者去找一个并不存在的坏账号的原因)。
|
|
53
|
+
- **顺带对齐两处**:`probe-reason.ts` 把 `model-busy` 列入「标签已说清、不再内联原始报文」的集合(该报文只是 14003 信封,会把补救办法挤掉);`AccountPool` 的状态色把 `model-busy` 归入琥珀(会自行恢复、账号没坏)而非红色。
|
|
54
|
+
- **守卫**:新增 `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` **不走**换号(池里明明有候选也不问)、失败仍上报且只报一次、用户看到的是「模型繁忙」而不是「账号被限流」。
|
|
55
|
+
- **刻意保留的重复**:`MODEL_BUSY_COOLDOWN_MS` 在 `src/probe.ts` 与 `src/account-pool.ts` 各声明一次。两者不能互相 import(一个是 Host 侧探测、一个是纯排序规则,后者刻意不依赖前者),所以由一条测试断言两个声明**必须相等** —— 探测写一种冷却、排序读另一种,就会悄悄把这个缺陷重新打开。
|
|
56
|
+
|
|
57
|
+
- **`14018`「积分耗尽」被判成普通限流:marker 表漏了复数形式,用户拿到的建议从「去充值」变成「等一等」。**(issue #56 的追加报告)
|
|
58
|
+
- **来源**:这条是报告者在回答「`14018` 是否与 `14003` 同族」时**顺手挖出来的**,不在原 issue 范围内。
|
|
59
|
+
- **现象**:上游对「这个账号在这些模型上没积分了」返回 `429` / `code 14018`,报文逐字为
|
|
60
|
+
`{"error":{"data":{"code":14018,"msg":"Credits exhausted. Please visit the link below to purchase add-on packs and get more credits: …"}}}`。
|
|
61
|
+
- **根因**:`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 → {}`。
|
|
62
|
+
- **后果**:一条本该读成「**去充值**」的失败被降级成「**等一等**」,用户看到「稍后重试」而正确答案是「这个号的积分用完了」。这与 issue #56 那个「把一分钟放大成半小时」是**同一类放大,只是入口不同**。
|
|
63
|
+
- **报告者同时做了两条反证,都很关键**:① 特意在**网络层**确认真实 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` 合并**。
|
|
64
|
+
- **修法**(两条都采用,因为它们防的是不同失效):
|
|
65
|
+
1. 补 **`'credits exhausted'`** 复数形式,并**保留单数**(网关改过措辞,只认今天这一种的表就是同一个坑的下一轮);
|
|
66
|
+
2. 新增按 **`"code":14018`** 的带引号 key 判定(`CREDITS_EXHAUSTED_CODE`),与既有的 `MODEL_BUSY_CODE` 同构 —— 措辞**完全改掉**的报文也能正确归类,且不受大小写与单复数影响。
|
|
67
|
+
- **顺序**:code 判定放在中文/英文 marker **之后**、`model_busy` **之前**,因此一条同时携带 `14018` 与 `14003` 的报文读作「去充值」(那是可执行的结论)。
|
|
68
|
+
- **未改动**:`exclusionOf` 对 `out-of-credit` 在**无时刻**时仍走 `POOL_UNKNOWN_COOLDOWN_MS` 通用兜底。这是**既有行为**,本版未动 —— 积分耗尽只能靠充值或周期刷新解除,与 `14003` 的秒级恢复性质不同。已在 issue 里如实说明并请报告者判断是否另案处理。
|
|
69
|
+
- **守卫**:新增 `tests/issue56-credits-exhausted-code.spec.ts`(9 例),**用报告者贴的报文原文当 fixture**(含尾随空格与 `{"error":{"data":…}}` 嵌套 —— 它和 `14003` 的扁平信封不同,本身就是「按散文做表很脆」的又一证据)。既钉住 `14018 → hard_credit → out-of-credit`,也钉住「两者必须分开」与「配额刷新点仍被采用」。变异验证:同时还原复数 marker 与 code 判定,**9 例中 5 例变红**,恢复后全绿。
|
|
70
|
+
|
|
71
|
+
- **客户端注册后打印一行摘要,把「静默空白」变成一眼可诊断。**(issue #55 报告者的建议)
|
|
72
|
+
- **动机来自报告者的原话**:整片 UI 消失与一切正常在页面上**完全无法区分**,因为他(和后来的我们)定位耗时几乎全花在那个只打日志的静默 `try/catch` 上。
|
|
73
|
+
- 现在每个 rebind 结束都会打一行**成功路径**的 `console.info`:
|
|
74
|
+
`[dsh-connect-workbuddy] registered 5 slots (settings form: bound)` 或
|
|
75
|
+
`[dsh-connect-workbuddy] registered 5 slots (settings form: none — card is read-only)`。
|
|
76
|
+
后者正是「公网域名部署」那一类场景(客户端根本不发 `/api/settings/describe`,命名空间永不到达)会打出的样子:一眼看出「注册成功但表单只读」,而不是对着空白页猜。
|
|
77
|
+
- 刻意用 `console.info` 而非 `warn`/`error`:健康路径打警告会训练用户忽略真正出事的那条通道。
|
|
78
|
+
- **未采纳**「把 `apply()` 拆成多个 try/catch」:外层兜底的作用是「槽位 API 变更时降级为日志,而不是让宿主弹 `Failed to load plugins` 红条」,拆开会让部分 UI 失败时更难判断整体状态。加了摘要后日志已能定位到具体哪几个没上。
|
|
79
|
+
|
|
80
|
+
- **换号提示把「积分耗尽」说成「被限流」,用户被叫去等一个不会来的恢复。**(issue #56 报告者指出)
|
|
81
|
+
- **来源**:报告者在同一份追加报告里顺带指出 `src/shim.ts` 的 `failoverReasonText()` 把 `soft_rate` 与 `hard_credit` **并列成同一句 `'rate limited'`**,并预期「§三 修好之后这个号会走 `hard_credit` ⇒ 用户能拿到『积分耗尽』而不是『被限流』」。**这个预期当时并不成立** —— 卡片/池表一侧确实会显示「积分耗尽」(`poolExcludedOutOfCredit`),但**回复里的换号提示**仍读作「rate limited」,而那正是用户在对话中最先看到的那一句。
|
|
82
|
+
- **为什么必须分开**:两者**都会出池、都会触发换号**(另一个账号可能确实有余额),但**补救办法不同类**——限流会在上游给出的时刻自行解除,积分耗尽在充值或周期刷新之前**永不会**好。共用一句话等于叫人去等一件不会发生的事,而正确动作是去充值或换号。
|
|
83
|
+
- **修法**:`hard_credit` 拆出独立的 `'out of credits'`。**未改动** `isFailoverWorthy()`:`hard_credit` 依旧可换号(换到有余额的账号是可行的自救),只是提示词如实说明原因。
|
|
84
|
+
- **守卫**:`tests/shim-failover.spec.ts` 新增 1 例,断言耗尽场景的提示含 `out of credits` 且**不含** `rate limited`,同时确认它**仍然换号**(`seen == ['selected', 'other']`)。变异验证:把两个 case 合并回 `'rate limited'`,该例立即变红,恢复后全绿。
|
|
85
|
+
|
|
86
|
+
### Docs
|
|
87
|
+
|
|
88
|
+
- 无。本版含上述修复与配套测试。
|
|
89
|
+
|
|
3
90
|
## 4.4.2 (2026-10-13)
|
|
4
91
|
|
|
5
92
|
### Fixes
|
package/README.en.md
CHANGED
|
@@ -179,6 +179,16 @@ npm install dsh-connect-workbuddy
|
|
|
179
179
|
|
|
180
180
|
Restart the DSH process after install/update/uninstall.
|
|
181
181
|
|
|
182
|
+
> ⚠️ **Pin an EXACT version for a fresh release; do not rely on a floating range.** pnpm enforces a release-age window (`minimumReleaseAge`, 24h by default), so shortly after a version ships a floating range is **silently resolved back to the previous one**: `^4.4.2` still resolves to `4.4.2`, while an exact `4.4.3` gets `4.4.3` immediately. Both spellings "install successfully", so the symptom is *"the upgrade command finished and the version did not change"* — easily misread as a failed release. Measured (isolated directory, pnpm 12.3.4, `--lockfile-only`, touching nothing of the user's environment):
|
|
183
|
+
>
|
|
184
|
+
> | Target spelling | Resolved version |
|
|
185
|
+
> | --- | --- |
|
|
186
|
+
> | floating `^4.4.2` | **4.4.2** ⚠️ |
|
|
187
|
+
> | exact `4.4.3` | **4.4.3** |
|
|
188
|
+
> | floating + `--config.minimum-release-age=0` | **4.4.3** |
|
|
189
|
+
>
|
|
190
|
+
> To pick up a new release right away, pin it: `dsh plugin --profile desktop add dsh-connect-workbuddy@4.4.4`. A floating range catches up once the window elapses.
|
|
191
|
+
|
|
182
192
|
The plugin also runs under **Web** and **TUI**; pick the command matching your profile:
|
|
183
193
|
|
|
184
194
|
```sh
|
|
@@ -290,6 +300,18 @@ Local dev via a `link:` install to the desktop profile (restart DSH Desktop afte
|
|
|
290
300
|
dsh plugin --profile desktop add /Users/dmh2002/DshProject/dsh-connect-workbuddy
|
|
291
301
|
```
|
|
292
302
|
|
|
303
|
+
### Calling `chatStream()` from a script: it returns an **envelope**, not a stream
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const envelope = await client.chatStream(credential, bodyJson)
|
|
307
|
+
if (!envelope.ok) { /* failure: envelope.status / .kind / .message / .detail */ }
|
|
308
|
+
else { await envelope.response.text() /* or consume it as SSE */ }
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
**Check `envelope.ok` before consuming.** It returns `{ ok: true, response }` or `{ ok: false, status, kind, message, detail }` — not something you can `for await` directly. Skip that check and the usual outcome is **every failure reported as a success** (there is no `response`, reading it throws or is swallowed, and "all models passed"). Recorded here on the strength of the issue #56 reporter's own note, so the next person does not lose the same hour.
|
|
312
|
+
|
|
313
|
+
`kind` on a failed envelope is the failure classification used throughout this document (`soft_rate` / `model_busy` / `hard_credit` / `session_dead` / `policy_reject` / `not_found` / `server` / `client`), and `detail` carries the upstream `upstreamCode` / `requestId` / `displayMsg`.
|
|
314
|
+
|
|
293
315
|
## Marketplace listing
|
|
294
316
|
|
|
295
317
|
The plugin ships an installable `dsh.bundle` manifest and is published to npm. Community marketplaces generally sync entries from the [Awesome DSH Plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) registry; the submission draft lives at [`awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml`](awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml), and the formal submission is PR [#3812](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/3812) (`data/plugins/dingminhua__dsh-connect-workbuddy.yml`, CI and Submission gate green, awaiting maintainer merge).
|
package/README.md
CHANGED
|
@@ -189,6 +189,16 @@ npm install dsh-connect-workbuddy
|
|
|
189
189
|
|
|
190
190
|
安装、更新或卸载 bundle 后,需要重启对应的 DSH 进程。
|
|
191
191
|
|
|
192
|
+
> ⚠️ **刚发布的版本请写「精确版本号」,不要依赖浮动范围。** pnpm 有**新版本等待期**(`minimumReleaseAge`,默认 24 小时),所以一个版本刚发布不久时,浮动范围会被**静默压回上一个版本**:声明 `^4.4.2` 时它仍解析到 `4.4.2`,而写精确的 `4.4.3` 则立即拿到 `4.4.3`。由于**两种写法都「安装成功」**,表现是「升级命令跑完了、版本却没变」,很容易被误判成发布失败。已实测(隔离目录、pnpm 12.3.4、`--lockfile-only`,未触碰用户环境):
|
|
193
|
+
>
|
|
194
|
+
> | 目标写法 | 解析到的版本 |
|
|
195
|
+
> | --- | --- |
|
|
196
|
+
> | 浮动 `^4.4.2` | **4.4.2** ⚠️ |
|
|
197
|
+
> | 精确 `4.4.3` | **4.4.3** |
|
|
198
|
+
> | 浮动 + `--config.minimum-release-age=0` | **4.4.3** |
|
|
199
|
+
>
|
|
200
|
+
> 想立刻用上新版,请写精确版本号:`dsh plugin --profile desktop add dsh-connect-workbuddy@4.4.4`。过了等待期之后,浮动范围会自然跟上。
|
|
201
|
+
|
|
192
202
|
插件在 **Web**、**Desktop**、**TUI** 三种界面下均可运行,按你使用的 profile 选择对应命令:
|
|
193
203
|
|
|
194
204
|
```sh
|
|
@@ -300,6 +310,18 @@ pnpm run check # typecheck + test + build
|
|
|
300
310
|
dsh plugin --profile desktop add /Users/dmh2002/DshProject/dsh-connect-workbuddy
|
|
301
311
|
```
|
|
302
312
|
|
|
313
|
+
### 写脚本调用 `chatStream()` 时注意:它返回的是**信封**,不是可迭代流
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
const envelope = await client.chatStream(credential, bodyJson)
|
|
317
|
+
if (!envelope.ok) { /* 失败:envelope.status / .kind / .message / .detail */ }
|
|
318
|
+
else { await envelope.response.text() /* 或者按 SSE 消费 */ }
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**先查 `envelope.ok` 再消费** —— 它返回 `{ ok: true, response }` 或 `{ ok: false, status, kind, message, detail }`,而不是一个可以直接 `for await` 的流。跳过这一步的典型后果是**把失败全报成成功**(`response` 是 `undefined`,读它会抛错或被吞掉,于是「每个模型都成功」)。这条来自 issue #56 报告者的实测提醒,写在这里是为了让下一个人少踩一次同样的坑。
|
|
322
|
+
|
|
323
|
+
失败信封里的 `kind` 就是本文提到的失败分类(`soft_rate` / `model_busy` / `hard_credit` / `session_dead` / `policy_reject` / `not_found` / `server` / `client`),`detail` 携带上游的 `upstreamCode` / `requestId` / `displayMsg`。
|
|
324
|
+
|
|
303
325
|
## 市场收录与展示
|
|
304
326
|
|
|
305
327
|
插件已包含可安装的 `dsh.bundle` manifest,并发布到 npm。社区市场通常从 [Awesome DSH Plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 注册表同步条目;仓库内的提交草稿位于 [`awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml`](awesome-dsh-plugin-submission/dingminhua__dsh-connect-workbuddy.yml),正式提交为 PR [#3812](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/3812)(`data/plugins/dingminhua__dsh-connect-workbuddy.yml`,已通过 CI 与 Submission gate,等待维护者合并)。
|
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-
|
|
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-BwbDWvzw.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
|
-
*
|
|
790
|
-
* bodies restate that instant and add a
|
|
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([
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
3803
|
+
const WORKBUDDY_CONNECT_VERSION = "4.4.4";
|
|
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' |
|
|
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
|
-
/**
|
|
1708
|
-
|
|
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"),
|
|
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' |
|
|
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.
|
|
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);
|
|
2369
|
-
*
|
|
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
|
|
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' |
|
|
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-
|
|
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-BwbDWvzw.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 = {
|
|
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 "
|
|
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.
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
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"),
|
|
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 +
|
|
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.
|
|
@@ -3295,11 +3383,51 @@ function knownProbeSource(value) {
|
|
|
3295
3383
|
return value === "test-batch" || value === "live-request" ? value : void 0;
|
|
3296
3384
|
}
|
|
3297
3385
|
/**
|
|
3386
|
+
* Merge ONE new measurement over the record already stored for that account.
|
|
3387
|
+
*
|
|
3388
|
+
* A plain `{ ...existing, ...update }` is wrong here, and the damage is silent.
|
|
3389
|
+
* The two writers describe the same account at different moments and carry
|
|
3390
|
+
* different fields:
|
|
3391
|
+
*
|
|
3392
|
+
* - `test-batch` measures the TARGET MODEL deliberately, so it is the only
|
|
3393
|
+
* writer that can produce a refresh point (`retryAtMs` with
|
|
3394
|
+
* `retrySource: 'quota-refresh'`) — a time the billing side states outright.
|
|
3395
|
+
* - `live-request` reports what a real chat request just hit. It often carries
|
|
3396
|
+
* no `retryAtMs` at all (a transport blip names no time), and a spread would
|
|
3397
|
+
* therefore DELETE the refresh point the probe had already resolved.
|
|
3398
|
+
*
|
|
3399
|
+
* Measured consequence of the spread, on a real `14018` account: the probe
|
|
3400
|
+
* recorded "quota refreshes 2026-11-01", the next live failure rewrote the
|
|
3401
|
+
* record without that field, and the pool then reported "retry in about 30
|
|
3402
|
+
* minutes" — putting an exhausted account back into rotation to be refused
|
|
3403
|
+
* again. The correct value was in the file one write earlier.
|
|
3404
|
+
*
|
|
3405
|
+
* So `retryAtMs` is INHERITED when the update does not state one, and every
|
|
3406
|
+
* other field is the update's (the newer measurement owns `outcome`, `atMs`,
|
|
3407
|
+
* `source` and `message`). Inheriting is only about the one field whose absence
|
|
3408
|
+
* is not a statement: an update that DOES carry a time still overrides, which is
|
|
3409
|
+
* what lets a fresh, shorter cooldown supersede a stale longer one.
|
|
3410
|
+
*
|
|
3411
|
+
* Pure and exported so the rule is unit-testable without a filesystem — this is
|
|
3412
|
+
* exactly the kind of "two writers, one slot" rule that this project has been
|
|
3413
|
+
* bitten by before.
|
|
3414
|
+
*/
|
|
3415
|
+
function mergePoolProbe(existing, update) {
|
|
3416
|
+
if (existing === void 0) return update;
|
|
3417
|
+
if (update.retryAtMs !== void 0) return update;
|
|
3418
|
+
if (existing.retryAtMs === void 0 || existing.retryAtMs <= update.atMs) return update;
|
|
3419
|
+
return {
|
|
3420
|
+
...update,
|
|
3421
|
+
retryAtMs: existing.retryAtMs
|
|
3422
|
+
};
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3298
3425
|
* Merge measurements into one region's store and write it atomically.
|
|
3299
3426
|
*
|
|
3300
3427
|
* A MERGE rather than a replace, because batches measure one account at a time
|
|
3301
3428
|
* and a partial failure must not erase what earlier runs learned. An account
|
|
3302
|
-
* absent from `updates` keeps its previous measurement
|
|
3429
|
+
* absent from `updates` keeps its previous measurement, and an account present
|
|
3430
|
+
* in both is combined by {@link mergePoolProbe} rather than overwritten.
|
|
3303
3431
|
*
|
|
3304
3432
|
* Never throws: a failed write loses only the newest measurements, and the
|
|
3305
3433
|
* caller's results are still returned to the user. Surfacing a disk error here
|
|
@@ -3311,14 +3439,12 @@ async function writePoolProbes(region, updates) {
|
|
|
3311
3439
|
try {
|
|
3312
3440
|
await withFileLock(path, async () => {
|
|
3313
3441
|
const existing = await readPoolProbes(region);
|
|
3314
|
-
const
|
|
3442
|
+
const merged = { ...existing };
|
|
3443
|
+
for (const [accountId, update] of Object.entries(updates)) merged[accountId] = mergePoolProbe(existing[accountId], update);
|
|
3444
|
+
await writeFileAtomic(path, `${JSON.stringify({
|
|
3315
3445
|
version: STORE_FORMAT_VERSION,
|
|
3316
|
-
probes:
|
|
3317
|
-
|
|
3318
|
-
...updates
|
|
3319
|
-
}
|
|
3320
|
-
};
|
|
3321
|
-
await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, {
|
|
3446
|
+
probes: merged
|
|
3447
|
+
}, null, 2)}\n`, {
|
|
3322
3448
|
mode: 384,
|
|
3323
3449
|
dirMode: 448
|
|
3324
3450
|
});
|
|
@@ -5332,6 +5458,21 @@ function apply(ctx, config) {
|
|
|
5332
5458
|
* 「将在 … 重置」 — which `cooldownOf` parses. When it states no time, the store's
|
|
5333
5459
|
* own short window applies rather than a guess at a long one.
|
|
5334
5460
|
*
|
|
5461
|
+
* THE QUOTA REFRESH POINT IS PASSED IN, and it is not decoration. This path
|
|
5462
|
+
* used to omit `quotaRefreshAtMs` while the PROBE path supplied it, so one
|
|
5463
|
+
* `out-of-credit` failure got two different answers depending on which door it
|
|
5464
|
+
* arrived through: the probe said "the quota refreshes on the 1st" (a time the
|
|
5465
|
+
* billing side states outright) while a live chat failure said "30 minutes
|
|
5466
|
+
* from now" — a window the account cannot possibly clear, so the pool put it
|
|
5467
|
+
* back and paid another refused round trip. Worse, the write below combines
|
|
5468
|
+
* with the stored record, and the stored refresh point was being LOST in the
|
|
5469
|
+
* process (see {@link mergePoolProbe}).
|
|
5470
|
+
*
|
|
5471
|
+
* Reading it costs one `fetchCredits`, affordable here but not on every
|
|
5472
|
+
* failure: it runs only for `out-of-credit`, where the refresh point is the one
|
|
5473
|
+
* honest answer and there is no cheaper source. Failing to read it is not an
|
|
5474
|
+
* error — the fallback still applies.
|
|
5475
|
+
*
|
|
5335
5476
|
* The upstream's text is persisted alongside the outcome so the card can name
|
|
5336
5477
|
* the reason. It is REDACTED first because this message is a raw upstream body:
|
|
5337
5478
|
* without that, a failure containing a token-shaped string would write it into
|
|
@@ -5343,11 +5484,13 @@ function apply(ctx, config) {
|
|
|
5343
5484
|
const members = poolPreferencesOf(current(), region).memberAccountIds;
|
|
5344
5485
|
if (members.length > 0 && !members.includes(accountId)) return;
|
|
5345
5486
|
const outcome = outcomeOfFailure(failure.status, failure.message);
|
|
5487
|
+
const quotaRefreshAtMs = outcome === "out-of-credit" ? await stacks[region].store.credentialFor(accountId).then((credential) => credential === void 0 ? void 0 : quotaRefreshOf(region, credential)).catch(() => void 0) : void 0;
|
|
5346
5488
|
const { retryAtMs } = cooldownOf({
|
|
5347
5489
|
outcome,
|
|
5348
5490
|
retryAfter: null,
|
|
5349
5491
|
nowMs: Date.now(),
|
|
5350
|
-
body: failure.message
|
|
5492
|
+
body: failure.message,
|
|
5493
|
+
...quotaRefreshAtMs === void 0 ? {} : { quotaRefreshAtMs }
|
|
5351
5494
|
});
|
|
5352
5495
|
const message = failure.message === "" ? "" : redactUpstreamText(failure.message);
|
|
5353
5496
|
await writePoolProbes(region, { [accountId]: {
|
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.
|
|
5
|
+
"version": "4.4.4",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|