dsh-connect-workbuddy 4.4.1 → 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 +90 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +182 -30
- package/lib/{host-heartbeat-DROtSemV.js → host-heartbeat-C8imAyWG.js} +100 -9
- package/lib/index.d.ts +96 -16
- package/lib/index.js +156 -19
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,95 @@
|
|
|
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
|
+
|
|
61
|
+
## 4.4.2 (2026-10-13)
|
|
62
|
+
|
|
63
|
+
### Fixes
|
|
64
|
+
|
|
65
|
+
- **DSH 0.2.1-alpha.2 下 workbuddy 模型工具调用全失效:把 pi-ai 主版本 1 判为 modern。**(PR #52,issues #24/#25/#26 的同源延伸)
|
|
66
|
+
- **现象**:DSH `0.2.1-alpha.2` 起,**所有** workbuddy / workbuddy-global 模型都无法调用工具(本地模型与 deepseek 账号组不受影响),且**界面上没有任何报错**。会话事件流停在「只有思考块」→「整轮 completed」,全程没有 `tool-call` 事件;模型有时还会把 DSML 标记当**正文**吐出来。
|
|
67
|
+
- **根因**:`src/pi-ai-runtime.ts` 的 `generationOf()` 用 `/^0\.(\d+)\./u` 解析版本,**只认 `0.x`**。DSH 0.2.1-alpha.2 随包的是 `@earendil-works/pi-ai@1.0.2`,`'1.0.2'` 匹配不到 → 返回 `'legacy'`。`legacy` 会让 `withLegacyContext()` 用 `adaptLegacyPiAiContext()` 把上下文折成 pi-ai **0.85** 形状:system 消息抽出去当 `systemPrompt`,工具声明搬到**顶层 `tools`**。而 pi-ai 1.x 从 `context.messages` 里 system 消息的 `toolsAdded` 读工具(`resolveTranscriptTools` / `getCurrentTools`),**不看顶层 `tools`**——请求里一个工具声明都没有,模型自然发不出 `toolCall`。国内网关容忍无 system 的请求,模型就把 DSML 写进正文。更隐蔽的是 shim 的 DSML 恢复也一并失效(`gate.declaredNames` 为空 → 整段禁用),所以整套缺陷**完全静默**。
|
|
68
|
+
- **为什么在作者 checkout 上不复现**:源码注释里写着 "this checkout resolves 0.85.1 today"——解析到 0.85.1 时 `legacy` 分支是**正确**的。另外 `doctor` 在宿主**之外**跑会显示 `modern`,那是 `resolvedLocally: false` 的默认值,与宿主内的真实判定无关。
|
|
69
|
+
- **修法**:只特判**主版本 1**,`>= 87` 的 minor 判定保持不变。已逐包对照 **pi-ai 0.87.1、1.0.2、1.1.0、1.2.0** 四个版本:三者的 `openai-completions` 都从 `context.messages` 调 `resolveTranscriptTools()`,`getCurrentTools()` 都读 system 消息的 `toolsAdded`/`toolsRemoved`,契约一致。**刻意是 `=== 1` 而不是 `>= 1`**:未核验未来 2.x 的上下文契约,不替未验证的版本做兼容承诺;测试里 `2.0.0` 明确断言仍为 `legacy`。
|
|
70
|
+
- **顺带记录的边界事实**(本版**未**改动 peer 范围):宿主 `dsh-app-boot` 的 `evaluatePluginCompatibility()` 只校验 `@deepseek-ai/dsh*` 命名空间,所以 `"@earendil-works/pi-ai": ">=0.85.0 <0.88.0"` 这条 peer **从不参与校验**,插件会对着越界的 1.x 静默运行。peer 范围的取舍留给维护者单独决定。
|
|
71
|
+
- **守卫**:`tests/pi-ai-runtime.spec.ts` 的版本→代际用例改为数据驱动,并用临时目录里伪造的 `package.json` 驱动 `piAiRuntimeFor()`,**不依赖本机解析到哪个版本**,因此在解析到 0.85.1 的机器上也能跑。覆盖 `0.85.0/0.85.1/0.86.3 → legacy`、`0.87.0/0.87.1/1.0.0/1.0.2/1.1.0 → modern`、`2.0.0 → legacy`、不可解析版本保守判 `legacy`。变异验证(还原修复)后恰好 4 个 1.x 用例变红,其余 13 个不变。
|
|
72
|
+
|
|
73
|
+
- **上游流被截断时,恢复层把补写的 `[DONE]` 粘在半截帧之后,客户端 SSE 解析崩溃(PI_AI_ERROR)。**(PR #54,issue #53)
|
|
74
|
+
- **现象**:对话中途偶发整轮直接失败,报错文本 `Unexpected non-whitespace character after JSON at position 319 (line 2 column 1)`,宿主归到兜底码 `PI_AI_ERROR`。该码**不在宿主的原地重试集**里(只认 `EMPTY_RESPONSE`/`RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`),所以一旦出现,这一轮直接结束、不重试——用户看到的就是「突然断了」。已确认与具体模型、工作区无关,且**紧跟在一次 transport 故障之后**。
|
|
75
|
+
- **根因**:是恢复层自己制造了非法的 SSE 帧,不是上游发的。两处叠加——① `consume()` 把流尾巴的残余帧直接透传,不补帧分隔空行;② `finish()` 补写合成终止帧时,没有确保前面有帧分隔。于是客户端实际收到的是**同一个 SSE 事件里的两条 `data:` 行**(中间只有一个 `\n`,不是空行)。openai SDK 的 `SSEDecoder` 会把一个事件内的多条 `data:` 行用 `\n` 拼起来再解析,拼出来就是「一个完整 JSON + 换行 + 第二个 JSON」,`JSON.parse` 在第二行行首抛错。**报错位置恰好是 319,因为第一段 chunk 的长度正好是 318**——这不是巧合。
|
|
76
|
+
- **修法**:把「每个帧必须自带空行终止符」变成不变量,而不是在 `finish()` 里特判——`RecoveryStream` 有 6 处直接 `res.write`,逐个补分隔容易漏,而 `handleFrame` 里 JSON 解析失败的原帧透传路径同样会写出不带空行的帧。现在类内所有帧写出收敛到单一出口 `emitFrame()`,由它补齐帧尾;`consume()` 对 `carry` 剩下的半帧先归一化再交给 `handleFrame`。**缺换行和缺空行是两种情形**:前者补一个 `\n` 只结束「行」不结束「事件」,必须补 `\n\n` 才能让随后的 `[DONE]` 不粘上去。
|
|
77
|
+
- **顺带修掉一个静默丢内容**:`finish()` **不**补 `[DONE]` 时,半截帧没有空行收尾,SDK 永远等不到分隔,最后那一小段内容被静默丢弃。归一化帧尾后这段内容能正常 dispatch 出来。
|
|
78
|
+
- **不影响普通路径**:`push()` 切出的帧本就带终止符,经 `emitFrame` 原样通过,仍是 byte-for-byte 透传——「帧顺序永不改变」的既有承诺不受影响。
|
|
79
|
+
- **守卫**:新增 `tests/issue53-sse-terminator.spec.ts`,断言在**字节层**:按 SSE 规范切事件,每个事件内多条 `data:` 行拼接后必须是单个合法 JSON。缺陷本质是字节粘连,只有字节级断言能抓住它。变异验证(还原两处修复)后 3 个缺陷用例全部变红,「完整流字节不变」的守卫用例保持绿色——说明没有过度归一化。
|
|
80
|
+
|
|
81
|
+
- **保存/删除/导入时的忙碌遮罩:改用宿主主题令牌,spinner 换成宿主同款弧线。**
|
|
82
|
+
- **现象**:遮罩卡片是一块近黑的板子,上面的字(尤其是副标题「完成后列表会立即刷新。」)几乎看不清;spinner 的转法也和外壳里其他转圈明显不同拍。
|
|
83
|
+
- **根因**:三处都出在自造值上。① 卡片底色用 `--dsw-alias-bg-primary`,而这个变量在宿主的令牌集里**根本不存在**(宿主只有 `bg-base` / `bg-layer-1..3` / `bg-module-platform` / `bg-overlay` 等),于是一路回退到硬编码的近黑 `#1b1c20`。② 遮罩底色写死 `rgba(0,0,0,.38)`,既不跟随主题、也没有宿主的模糊。③ 副标题用 `label-tertiary`——那是宿主最暗的一档(亮色 `neutral-bluish-600`、暗色 `neutral-bluish-400`),给元数据用的,不该承载一句要等的人必须读的话。
|
|
84
|
+
- **修法**:卡片改用宿主给弹层用的 `--dsw-alias-bg-layer-2`(亮色白 / 暗色 `neutral-bluish-850`,各自成立);遮罩改用宿主自己遮罩用的 `--dsw-alias-bg-mask-1`(亮 `.24` / 暗 `.5`)+ `--dsw-mask-blur`,与外壳里其他遮罩一致;副标题改用 `label-secondary`。阴影也从手写的 `0 8px 28px rgba(0,0,0,.35)` 换成宿主的 `--dsw-elevation-prominent`。
|
|
85
|
+
- **spinner**:原本是 18px 的 CSS border 环、`.7s` 匀速转,比宿主自己的状态转圈(`_dsh-state-dot-spin`,SVG 弧线 + dash 呼吸、`1.5s`)快了近一倍。比起形状本身,**这个速度差才是它看着廉价的主因**。现在改为同款弧线几何与 `1.5s` 节奏,`currentColor` 继承所在层级,reduced-motion 降级为静态弧线(与宿主降级方式一致),并补 `aria-hidden` 免得 `role=status` 之外多报一次。
|
|
86
|
+
- **顺带记录一个会静默损坏产物的坑**:`WORKBUDDY_CARD_CSS` 是模板字符串,注释里写反引号会**提前截断整个样式表**,而 typecheck 与 build **都不报错**(截断后的尾部仍是合法 TS,被当成一堆散落的表达式语句)。本次改注释时真的踩到了。新增一条守卫:按顶层语句数断言(正常 2 条变量声明;一旦截断会炸成几十条表达式语句)。变异验证:塞回一个反引号后该守卫变红,恢复后全绿。
|
|
87
|
+
- **守卫**:`tests/client-host-contract.spec.ts` 由 8 例增至 10 例。
|
|
88
|
+
|
|
89
|
+
### Docs
|
|
90
|
+
|
|
91
|
+
- 无。本版含上述三处修复与配套测试。
|
|
92
|
+
|
|
3
93
|
## 4.4.1 (2026-10-12)
|
|
4
94
|
|
|
5
95
|
### 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-
|
|
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");
|
|
@@ -668,6 +694,45 @@ window.__ModuleLoader__.load({
|
|
|
668
694
|
//#endregion
|
|
669
695
|
//#region src/client/BusyOverlay.tsx
|
|
670
696
|
/**
|
|
697
|
+
* The shared busy veil's spinner: an arc that breathes while it turns.
|
|
698
|
+
*
|
|
699
|
+
* Deliberately the host's own state-spinner shape (dsh's `_dsh-state-dot-spin`)
|
|
700
|
+
* rather than a border-ring: a two-tone CSS ring at .7s read as a loading
|
|
701
|
+
* indicator from another decade and was out of step with every spinner in the
|
|
702
|
+
* shell. The motion lives in CSS (`dsm-workbuddy-veil-spin` /
|
|
703
|
+
* `-dash`), so this element only describes the geometry; `currentColor` lets it
|
|
704
|
+
* inherit whatever tier it sits in.
|
|
705
|
+
*
|
|
706
|
+
* `aria-hidden` because the veil's `role="status"` already announces the
|
|
707
|
+
* message — a second announcement from a decorative arc is noise.
|
|
708
|
+
*/
|
|
709
|
+
function VeilSpinner() {
|
|
710
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
711
|
+
className: "dsm-workbuddy-veil-spinner",
|
|
712
|
+
viewBox: "0 0 20 20",
|
|
713
|
+
"aria-hidden": "true",
|
|
714
|
+
focusable: "false",
|
|
715
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
716
|
+
className: "dsm-workbuddy-veil-spinner-track",
|
|
717
|
+
cx: "10",
|
|
718
|
+
cy: "10",
|
|
719
|
+
r: "8",
|
|
720
|
+
fill: "none",
|
|
721
|
+
stroke: "currentColor",
|
|
722
|
+
strokeWidth: "2"
|
|
723
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
724
|
+
className: "dsm-workbuddy-veil-spinner-arc",
|
|
725
|
+
cx: "10",
|
|
726
|
+
cy: "10",
|
|
727
|
+
r: "8",
|
|
728
|
+
fill: "none",
|
|
729
|
+
stroke: "currentColor",
|
|
730
|
+
strokeWidth: "2",
|
|
731
|
+
strokeLinecap: "round"
|
|
732
|
+
})]
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
671
736
|
* The veil. Deliberately NOT a modal dialog: it carries no focusable control
|
|
672
737
|
* and no dismissal — there is nothing for the user to decide, only a step to
|
|
673
738
|
* wait through. `aria-busy` plus a polite live region state that to screen
|
|
@@ -683,10 +748,7 @@ window.__ModuleLoader__.load({
|
|
|
683
748
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
684
749
|
className: "dsm-workbuddy-veil-card",
|
|
685
750
|
children: [
|
|
686
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
687
|
-
className: "dsm-workbuddy-veil-spinner",
|
|
688
|
-
"aria-hidden": "true"
|
|
689
|
-
}),
|
|
751
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(VeilSpinner, {}),
|
|
690
752
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
691
753
|
className: "dsm-workbuddy-veil-text",
|
|
692
754
|
children: message
|
|
@@ -750,14 +812,25 @@ window.__ModuleLoader__.load({
|
|
|
750
812
|
/**
|
|
751
813
|
* Outcomes whose label + stated recovery time ARE the whole story.
|
|
752
814
|
*
|
|
753
|
-
*
|
|
754
|
-
* 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.
|
|
755
824
|
*
|
|
756
825
|
* `credential-rejected` and `not-found` are deliberately NOT here: their labels
|
|
757
826
|
* state a conclusion ("sign in again", "no such model"), and the upstream's own
|
|
758
827
|
* words are what make that conclusion checkable rather than asserted.
|
|
759
828
|
*/
|
|
760
|
-
const SELF_EXPLANATORY = /* @__PURE__ */ new Set([
|
|
829
|
+
const SELF_EXPLANATORY = /* @__PURE__ */ new Set([
|
|
830
|
+
"rate-limited",
|
|
831
|
+
"model-busy",
|
|
832
|
+
"out-of-credit"
|
|
833
|
+
]);
|
|
761
834
|
/**
|
|
762
835
|
* Longest inline reason kept.
|
|
763
836
|
*
|
|
@@ -1123,6 +1196,7 @@ window.__ModuleLoader__.load({
|
|
|
1123
1196
|
switch (outcome) {
|
|
1124
1197
|
case "ok": return t("row.probeOk");
|
|
1125
1198
|
case "rate-limited": return t("row.poolExcludedRateLimited");
|
|
1199
|
+
case "model-busy": return t("row.poolExcludedModelBusy");
|
|
1126
1200
|
case "out-of-credit": return t("row.poolExcludedOutOfCredit");
|
|
1127
1201
|
case "credential-rejected": return t("row.probeCredentialRejected");
|
|
1128
1202
|
case "not-found": return t("row.probeNotFound");
|
|
@@ -1165,7 +1239,7 @@ window.__ModuleLoader__.load({
|
|
|
1165
1239
|
}
|
|
1166
1240
|
/** A dot colour for one account's state. */
|
|
1167
1241
|
function stateColor(account) {
|
|
1168
|
-
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)";
|
|
1169
1243
|
if (account.probe === void 0) return "var(--dsw-alias-label-dimmed, #9aa0a6)";
|
|
1170
1244
|
return outcomeOk(account.probe.outcome) ? "var(--dsw-alias-state-success-primary, #22a06b)" : "var(--dsw-alias-state-warn-primary, #f59e0b)";
|
|
1171
1245
|
}
|
|
@@ -1734,7 +1808,7 @@ window.__ModuleLoader__.load({
|
|
|
1734
1808
|
const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
|
|
1735
1809
|
const excluded = exclusionText(t, account.excludedBy);
|
|
1736
1810
|
const probe = account.probe;
|
|
1737
|
-
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);
|
|
1738
1812
|
const remaining = probe?.retryAtMs === void 0 ? "" : remainingText(t, probe.retryAtMs, Date.now());
|
|
1739
1813
|
const reason = probe === void 0 ? void 0 : inlineProbeReason(probe.outcome, probe.message);
|
|
1740
1814
|
const age = probe === void 0 ? void 0 : probeAgeText(t, probe.atMs, Date.now());
|
|
@@ -3055,17 +3129,48 @@ window.__ModuleLoader__.load({
|
|
|
3055
3129
|
"here is why nothing moved yet". Fixed positioning so it covers the card
|
|
3056
3130
|
regardless of which section started the operation, and a semi-transparent
|
|
3057
3131
|
backdrop so the table stays visible underneath — the user is watching a
|
|
3058
|
-
specific row change, and hiding it would defeat the point.
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
.
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3132
|
+
specific row change, and hiding it would defeat the point.
|
|
3133
|
+
|
|
3134
|
+
The mask and the card surface are the HOST's own tokens, not this file's
|
|
3135
|
+
invention. --dsw-alias-bg-mask-1 + --dsw-mask-blur is exactly what the
|
|
3136
|
+
host's own overlays use (see dsh-client-ui-settings-general's Dws9Sa_mask),
|
|
3137
|
+
so the veil now dims the way every other overlay in the shell does instead
|
|
3138
|
+
of laying its own black wash on top. The previous hardcoded rgba(0,0,0,.38)
|
|
3139
|
+
was tuned against neither theme: too weak next to the host's .24 mask in
|
|
3140
|
+
light mode, and it ignored the host's blur entirely.
|
|
3141
|
+
|
|
3142
|
+
bg-layer-2 (not bg-primary, which does not exist in the host's token set
|
|
3143
|
+
at all and therefore always fell through to this file's near-black #1b1c20)
|
|
3144
|
+
is the same surface the host puts behind its panels — that near-black fallback
|
|
3145
|
+
is what made the veil read as a black slab with unreadable text on it. */
|
|
3146
|
+
.dsm-workbuddy-veil{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.32));backdrop-filter:var(--dsw-mask-blur);cursor:progress}
|
|
3147
|
+
/* --dsw-elevation-prominent is the host's own panel shadow (a hairline stroke
|
|
3148
|
+
plus two soft layers); the previous 0 8px 28px rgba(0,0,0,.35) was a
|
|
3149
|
+
hand-rolled drop shadow that read as a heavy grey box. */
|
|
3150
|
+
.dsm-workbuddy-veil-card{display:flex;flex-direction:column;align-items:center;gap:8px;max-width:min(340px,80vw);padding:16px 20px;border:1px solid var(--dsw-alias-border-l2,#0000001a);border-radius:12px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:var(--dsw-elevation-prominent,0 8px 28px rgba(0,0,0,.35))}
|
|
3151
|
+
.dsm-workbuddy-veil-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#0f1115);text-align:center}
|
|
3152
|
+
/* label-secondary, not label-tertiary: this line carries the operation's
|
|
3153
|
+
detail and must stay legible. tertiary is the host's dimmest tier
|
|
3154
|
+
(neutral-bluish-600 on light, neutral-bluish-400 on dark) and is meant
|
|
3155
|
+
for metadata, not for a sentence the user has to read while waiting. */
|
|
3156
|
+
.dsm-workbuddy-veil-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#61666b);text-align:center}
|
|
3157
|
+
/* The spinner proves the UI is alive when the row under it has not changed yet
|
|
3158
|
+
— which is precisely the confusion the veil exists to remove. It follows the
|
|
3159
|
+
host's own state spinner (dsh's _dsh-state-dot-spin): an SVG arc whose
|
|
3160
|
+
dash breathes while it turns, currentColor so it inherits the tier it sits
|
|
3161
|
+
in, and the same 1.5s beat. The previous 18px border-ring at .7s was roughly
|
|
3162
|
+
twice the host's speed and visibly out of step with every other spinner in
|
|
3163
|
+
the shell — that mismatch, more than the shape itself, is what made it look
|
|
3164
|
+
cheap.
|
|
3165
|
+
|
|
3166
|
+
Reduced-motion users get the static arc rather than a frozen ring, matching
|
|
3167
|
+
how the host degrades its own spinner. */
|
|
3168
|
+
.dsm-workbuddy-veil-spinner{width:20px;height:20px;color:var(--dsw-alias-label-secondary,#61666b);animation:dsm-workbuddy-veil-spin 1.5s linear infinite}
|
|
3169
|
+
.dsm-workbuddy-veil-spinner-track{opacity:.25}
|
|
3170
|
+
.dsm-workbuddy-veil-spinner-arc{stroke-dasharray:12 150;animation:dsm-workbuddy-veil-dash 1.5s ease-in-out infinite}
|
|
3067
3171
|
@keyframes dsm-workbuddy-veil-spin{to{transform:rotate(360deg)}}
|
|
3068
|
-
@
|
|
3172
|
+
@keyframes dsm-workbuddy-veil-dash{0%{stroke-dasharray:12 150;stroke-dashoffset:0}50%{stroke-dasharray:24 150;stroke-dashoffset:-6}to{stroke-dasharray:12 150;stroke-dashoffset:0}}
|
|
3173
|
+
@media (prefers-reduced-motion: reduce){.dsm-workbuddy-veil-spinner{animation:none}.dsm-workbuddy-veil-spinner-arc{animation:none;stroke-dasharray:18 150;stroke-dashoffset:-3}}
|
|
3069
3174
|
`;
|
|
3070
3175
|
/**
|
|
3071
3176
|
* Composer credit readout styles, injected by the readout component itself.
|
|
@@ -3505,6 +3610,10 @@ window.__ModuleLoader__.load({
|
|
|
3505
3610
|
text: result.retryAtMs === void 0 ? t("row.probeRateLimitedUnknown") : t("row.probeRateLimitedAt", { at: formatDate(result.retryAtMs) }) + retrySuffix(t, result.retryAtMs, nowMs),
|
|
3506
3611
|
tone: "warn"
|
|
3507
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
|
+
};
|
|
3508
3617
|
case "out-of-credit": return {
|
|
3509
3618
|
text: result.retryAtMs === void 0 ? t("row.probeOutOfCreditUnknown") : t("row.probeOutOfCreditAt", { at: formatDate(result.retryAtMs) }) + retrySuffix(t, result.retryAtMs, nowMs),
|
|
3510
3619
|
tone: "bad"
|
|
@@ -5247,6 +5356,8 @@ window.__ModuleLoader__.load({
|
|
|
5247
5356
|
"row.probeOkMs": "✓ Usable ({ms})",
|
|
5248
5357
|
"row.probeRateLimitedUnknown": "✗ Rate limited · the upstream gave no time for when it frees up",
|
|
5249
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",
|
|
5250
5361
|
"row.probeOutOfCreditUnknown": "✗ Not enough credits · the upstream gave no time for when it frees up",
|
|
5251
5362
|
"row.probeOutOfCreditAt": "✗ Not enough credits · quota refreshes {at}",
|
|
5252
5363
|
"row.probeCredentialRejected": "✗ WorkBuddy refused this account's token — sign in again or pick another account",
|
|
@@ -5327,6 +5438,7 @@ window.__ModuleLoader__.load({
|
|
|
5327
5438
|
"row.poolCreditSoon": "{count} expiring within 3 days",
|
|
5328
5439
|
"row.poolCreditNearest": "Nearest package expires {at}",
|
|
5329
5440
|
"row.poolExcludedRateLimited": "Rate limited",
|
|
5441
|
+
"row.poolExcludedModelBusy": "Model busy",
|
|
5330
5442
|
"row.poolExcludedOutOfCredit": "Out of credits",
|
|
5331
5443
|
"row.poolExcludedRejected": "Rejected",
|
|
5332
5444
|
"row.poolExcludedUnusable": "Unusable",
|
|
@@ -5507,6 +5619,8 @@ window.__ModuleLoader__.load({
|
|
|
5507
5619
|
"row.probeOkMs": "✓ 可用({ms})",
|
|
5508
5620
|
"row.probeRateLimitedUnknown": "✗ 被限流 · 上游未给出何时恢复",
|
|
5509
5621
|
"row.probeRateLimitedAt": "✗ 被限流 · {at} 之后可再用",
|
|
5622
|
+
"row.probeModelBusyUnknown": "✗ 该模型当前负载较高 · 不是账号被限流——换账号没用,建议等待约 1 分钟或切换模型",
|
|
5623
|
+
"row.probeModelBusyAt": "✗ 该模型当前负载较高 · 不是账号被限流;{at} 之后可重试,或切换模型",
|
|
5510
5624
|
"row.probeOutOfCreditUnknown": "✗ 积分不足 · 上游未给出何时恢复",
|
|
5511
5625
|
"row.probeOutOfCreditAt": "✗ 积分不足 · 积分包 {at} 刷新",
|
|
5512
5626
|
"row.probeCredentialRejected": "✗ WorkBuddy 拒绝了这个账号的 token——请重新登录或换一个账号",
|
|
@@ -5587,6 +5701,7 @@ window.__ModuleLoader__.load({
|
|
|
5587
5701
|
"row.poolCreditSoon": "{count} 积分 3 天内过期",
|
|
5588
5702
|
"row.poolCreditNearest": "最近积分包 {at} 过期",
|
|
5589
5703
|
"row.poolExcludedRateLimited": "被限流",
|
|
5704
|
+
"row.poolExcludedModelBusy": "模型繁忙",
|
|
5590
5705
|
"row.poolExcludedOutOfCredit": "积分耗尽",
|
|
5591
5706
|
"row.poolExcludedRejected": "被拒绝",
|
|
5592
5707
|
"row.poolExcludedUnusable": "不可用",
|
|
@@ -5735,7 +5850,15 @@ window.__ModuleLoader__.load({
|
|
|
5735
5850
|
const softGet = (name) => ctx.get(name);
|
|
5736
5851
|
let settingsScope;
|
|
5737
5852
|
const forms = softGet("configForms");
|
|
5738
|
-
/**
|
|
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
|
+
*/
|
|
5739
5862
|
const registerCard = (slotName, key) => {
|
|
5740
5863
|
try {
|
|
5741
5864
|
return ctx.slots.inject(slotName, () => ctx.slots.register({
|
|
@@ -5794,9 +5917,21 @@ window.__ModuleLoader__.load({
|
|
|
5794
5917
|
* registrations must move TOGETHER: see {@link bindSettingsScope} for why a
|
|
5795
5918
|
* late-arriving namespace has to produce fresh registrations rather than a
|
|
5796
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.
|
|
5797
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);
|
|
5798
5933
|
/**
|
|
5799
|
-
* Disposers of the slots registered for the CURRENT
|
|
5934
|
+
* Disposers of the slots registered for the CURRENT binding.
|
|
5800
5935
|
*
|
|
5801
5936
|
* Declared before {@link bindSettingsScope} because that function closes
|
|
5802
5937
|
* over it and reassigns it; the binding is only armed at the end of `apply`,
|
|
@@ -5804,11 +5939,30 @@ window.__ModuleLoader__.load({
|
|
|
5804
5939
|
* ordering a non-requirement rather than a trap.
|
|
5805
5940
|
*/
|
|
5806
5941
|
let liveDisposers = [];
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
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
|
+
};
|
|
5812
5966
|
/**
|
|
5813
5967
|
* Bind `settingsScope` to whichever namespace the Host really serves, and
|
|
5814
5968
|
* RE-REGISTER every slot when that answer changes.
|
|
@@ -5849,10 +6003,7 @@ window.__ModuleLoader__.load({
|
|
|
5849
6003
|
ctx.effect(() => forms.whileServed(WORKBUDDY_SETTINGS_NAMESPACE_CANDIDATES, (served) => {
|
|
5850
6004
|
const ns = WORKBUDDY_SETTINGS_NAMESPACE_CANDIDATES.find((candidate) => served.has(candidate)) ?? [...served].find((entry) => /workbuddy/i.test(entry));
|
|
5851
6005
|
settingsScope = ns === void 0 ? void 0 : forms.get(ns);
|
|
5852
|
-
|
|
5853
|
-
dispose();
|
|
5854
|
-
} catch {}
|
|
5855
|
-
liveDisposers = registerAllSlots();
|
|
6006
|
+
rebindSlots();
|
|
5856
6007
|
return () => {
|
|
5857
6008
|
for (const dispose of liveDisposers) try {
|
|
5858
6009
|
dispose();
|
|
@@ -5860,6 +6011,7 @@ window.__ModuleLoader__.load({
|
|
|
5860
6011
|
liveDisposers = [];
|
|
5861
6012
|
};
|
|
5862
6013
|
}), "dsh-connect-workbuddy: settings namespace binding");
|
|
6014
|
+
rebindSlots();
|
|
5863
6015
|
};
|
|
5864
6016
|
bindSettingsScope();
|
|
5865
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";
|
|
@@ -3611,9 +3678,10 @@ var WorkBuddyCatalog = class {
|
|
|
3611
3678
|
* pi-ai into the standalone `doctor` CLI, which must also run from market
|
|
3612
3679
|
* installs where NO local pi-ai exists and the module is only provided inside
|
|
3613
3680
|
* the DSH host — a static import there is a load-time crash. Resolution
|
|
3614
|
-
* itself does not need the module:
|
|
3615
|
-
*
|
|
3616
|
-
*
|
|
3681
|
+
* itself does not need the module: the manifest version and the
|
|
3682
|
+
* `normalizeContext` feature (present from 0.87 on, not before) agree across
|
|
3683
|
+
* `0.87+` AND `1.x` — 1.x is 0.87's successor and keeps the same normalized
|
|
3684
|
+
* transcript, so the split is `>= 0.87` rather than "within 0.x".
|
|
3617
3685
|
*
|
|
3618
3686
|
* When resolution FAILS — the normal state of a market install probed outside
|
|
3619
3687
|
* the host — there is no local copy, so inside DSH the host supplies pi-ai.
|
|
@@ -3695,13 +3763,36 @@ function readVersion(moduleFile) {
|
|
|
3695
3763
|
}
|
|
3696
3764
|
}
|
|
3697
3765
|
/**
|
|
3698
|
-
* The generation a manifest version belongs to.
|
|
3699
|
-
*
|
|
3700
|
-
*
|
|
3701
|
-
*
|
|
3766
|
+
* The generation a manifest version belongs to.
|
|
3767
|
+
*
|
|
3768
|
+
* `0.87+` consumes normalized transcripts natively; earlier releases want the
|
|
3769
|
+
* 0.85 shape, so within the declared peer range the split is exactly `0.87+`
|
|
3770
|
+
* versus earlier. An unreadable version conservatively reports `legacy` (and
|
|
3771
|
+
* doctor shows it as unreadable, so the guess is visible rather than silent).
|
|
3772
|
+
*
|
|
3773
|
+
* The 1.x line MUST land on `modern`, and it is not a hypothetical: DSH
|
|
3774
|
+
* 0.2.1-alpha.2 ships `@earendil-works/pi-ai@1.0.2`, while npm currently
|
|
3775
|
+
* publishes 1.1.0. Both 0.87.1 and 1.0.2/1.1.0 read tools from the same place
|
|
3776
|
+
* (`context.messages` → the leading system message's `toolsAdded`; see
|
|
3777
|
+
* pi-ai's `resolveTranscriptTools`). Matching only `0.<minor>.` classified
|
|
3778
|
+
* 1.0.2 as `legacy`, so `withLegacyContext` folded the transcript into the
|
|
3779
|
+
* 0.85 shape — which moves the tool declarations to a top-level `tools` field
|
|
3780
|
+
* pi-ai 1.x never reads. Every request then went upstream with NO tools
|
|
3781
|
+
* declared: no model could emit a `toolCall`, and the domestic gateway, which
|
|
3782
|
+
* tolerates a prompt-less body, had the model write DSML markup into the answer
|
|
3783
|
+
* text instead. The host's compatibility gate does not catch this: it only
|
|
3784
|
+
* evaluates peers in the `@deepseek-ai/dsh*` namespace, so an out-of-range
|
|
3785
|
+
* `@earendil-works/pi-ai` peer is never enforced.
|
|
3786
|
+
*
|
|
3787
|
+
* Deliberately match major 1 only. This establishes compatibility for the
|
|
3788
|
+
* observed 1.x line (including current 1.1.0) without assuming an uninspected
|
|
3789
|
+
* future major such as 2.x keeps the same transcript contract. The declared
|
|
3790
|
+
* peer range remains the plugin's supported-version boundary.
|
|
3702
3791
|
*/
|
|
3703
3792
|
function generationOf(version) {
|
|
3704
3793
|
if (version === void 0) return "legacy";
|
|
3794
|
+
const major = /^(\d+)\./u.exec(version);
|
|
3795
|
+
if (major !== null && Number(major[1]) === 1) return "modern";
|
|
3705
3796
|
const match = /^0\.(\d+)\./u.exec(version);
|
|
3706
3797
|
if (match === null) return "legacy";
|
|
3707
3798
|
return Number(match[1]) >= 87 ? "modern" : "legacy";
|
|
@@ -3709,7 +3800,7 @@ function generationOf(version) {
|
|
|
3709
3800
|
//#endregion
|
|
3710
3801
|
//#region src/version.ts
|
|
3711
3802
|
/** The npm package version this build was produced from. */
|
|
3712
|
-
const WORKBUDDY_CONNECT_VERSION = "4.4.
|
|
3803
|
+
const WORKBUDDY_CONNECT_VERSION = "4.4.3";
|
|
3713
3804
|
//#endregion
|
|
3714
3805
|
//#region src/host-heartbeat.ts
|
|
3715
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[];
|
|
@@ -1112,9 +1143,10 @@ declare function createWorkBuddyShim(options: WorkBuddyShimOptions): WorkBuddySh
|
|
|
1112
1143
|
* pi-ai into the standalone `doctor` CLI, which must also run from market
|
|
1113
1144
|
* installs where NO local pi-ai exists and the module is only provided inside
|
|
1114
1145
|
* the DSH host — a static import there is a load-time crash. Resolution
|
|
1115
|
-
* itself does not need the module:
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1146
|
+
* itself does not need the module: the manifest version and the
|
|
1147
|
+
* `normalizeContext` feature (present from 0.87 on, not before) agree across
|
|
1148
|
+
* `0.87+` AND `1.x` — 1.x is 0.87's successor and keeps the same normalized
|
|
1149
|
+
* transcript, so the split is `>= 0.87` rather than "within 0.x".
|
|
1118
1150
|
*
|
|
1119
1151
|
* When resolution FAILS — the normal state of a market install probed outside
|
|
1120
1152
|
* the host — there is no local copy, so inside DSH the host supplies pi-ai.
|
|
@@ -1682,8 +1714,14 @@ declare function probeRequestBody(modelId: string, inputTokens?: number): string
|
|
|
1682
1714
|
* `rate-limited` is split out from a generic failure because it is the one
|
|
1683
1715
|
* outcome the feature exists for: it means "this model works, but not right
|
|
1684
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.
|
|
1685
1723
|
*/
|
|
1686
|
-
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';
|
|
1687
1725
|
/**
|
|
1688
1726
|
* One probe result, as the card renders it.
|
|
1689
1727
|
*
|
|
@@ -1703,8 +1741,16 @@ interface WorkBuddyProbeResult {
|
|
|
1703
1741
|
message?: string;
|
|
1704
1742
|
/** A time the upstream named, in ms. Absent when it named none. */
|
|
1705
1743
|
retryAtMs?: number;
|
|
1706
|
-
/**
|
|
1707
|
-
|
|
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';
|
|
1708
1754
|
}
|
|
1709
1755
|
/**
|
|
1710
1756
|
* Parse a `Retry-After` header value into an absolute time.
|
|
@@ -1736,6 +1782,14 @@ declare function parseRetryAfter(value: string | null, nowMs: number): number |
|
|
|
1736
1782
|
* A limited outcome with none of the three returns `{}`, which the card renders
|
|
1737
1783
|
* as "the upstream did not say when". That remains the honest answer for a
|
|
1738
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.
|
|
1739
1793
|
*/
|
|
1740
1794
|
declare function cooldownOf(input: {
|
|
1741
1795
|
outcome: WorkBuddyProbeOutcome;
|
|
@@ -1753,8 +1807,10 @@ declare function cooldownOf(input: {
|
|
|
1753
1807
|
* probe and a real chat request can never disagree about what the same upstream
|
|
1754
1808
|
* answer means. The extra splits here are `credential-rejected`, which the
|
|
1755
1809
|
* upstream signals with a non-JSON 401/403 edge page and which needs completely
|
|
1756
|
-
* different advice (re-auth, not "wait"),
|
|
1757
|
-
* 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).
|
|
1758
1814
|
*/
|
|
1759
1815
|
declare function outcomeOfFailure(status: number, body: string): WorkBuddyProbeOutcome;
|
|
1760
1816
|
/**
|
|
@@ -2346,7 +2402,18 @@ type WorkBuddyWebUsage =
|
|
|
2346
2402
|
* here because this module is the node-free bridge and must not import the
|
|
2347
2403
|
* Host-side probe module (which pulls in the credential type).
|
|
2348
2404
|
*/
|
|
2349
|
-
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';
|
|
2350
2417
|
/** One model's probe result, as the card renders it. */
|
|
2351
2418
|
interface WorkBuddyWebProbeResult {
|
|
2352
2419
|
modelId: string;
|
|
@@ -2361,18 +2428,31 @@ interface WorkBuddyWebProbeResult {
|
|
|
2361
2428
|
* A time the upstream named for using this model again, in ms.
|
|
2362
2429
|
*
|
|
2363
2430
|
* ABSENT means the upstream named none, and the card must say exactly that.
|
|
2364
|
-
* A client-invented countdown here would be fiction.
|
|
2431
|
+
* A client-invented countdown here would be fiction. Four sources fill it:
|
|
2365
2432
|
* a real `Retry-After` header; the reset sentence the upstream writes into
|
|
2366
2433
|
* its own failure BODY (`将在 … UTC+8 重置`, which is where this service
|
|
2367
|
-
* actually puts it — its 429s carry no rate-limit header at all);
|
|
2368
|
-
*
|
|
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}.
|
|
2369
2438
|
*/
|
|
2370
2439
|
retryAtMs?: number;
|
|
2371
|
-
/** Which of those
|
|
2372
|
-
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';
|
|
2373
2442
|
}
|
|
2374
2443
|
/** Why one account is currently out of the pool. */
|
|
2375
|
-
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';
|
|
2376
2456
|
/**
|
|
2377
2457
|
* One account's row in the pool, as the card renders it.
|
|
2378
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-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 = {
|
|
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
|
*
|
|
@@ -1308,6 +1320,15 @@ function readBody(req) {
|
|
|
1308
1320
|
* Once a native `delta.tool_calls` appears, recovery switches off for the
|
|
1309
1321
|
* rest of the response — two live call channels for one answer is worse
|
|
1310
1322
|
* than either one alone.
|
|
1323
|
+
*
|
|
1324
|
+
* 5. EVERY FRAME LEAVES SELF-CONTAINED. An SSE event ends at a blank line,
|
|
1325
|
+
* and a frame written without its terminator merges into the NEXT event
|
|
1326
|
+
* the client decodes — whose `data:` lines are then joined and parsed as
|
|
1327
|
+
* one document (issue #53). Every write therefore goes through
|
|
1328
|
+
* {@link emitFrame}, which completes the terminator; `push()` cuts frames
|
|
1329
|
+
* that already carry it, and the one source of unterminated bytes — the
|
|
1330
|
+
* half-frame left in `carry` when the upstream dies — is normalized in
|
|
1331
|
+
* {@link consume}.
|
|
1311
1332
|
*/
|
|
1312
1333
|
var RecoveryStream = class {
|
|
1313
1334
|
res;
|
|
@@ -1382,7 +1403,7 @@ var RecoveryStream = class {
|
|
|
1382
1403
|
finish_reason: ""
|
|
1383
1404
|
};
|
|
1384
1405
|
this.windowOpened = true;
|
|
1385
|
-
this.
|
|
1406
|
+
this.emitFrame(contentFrame(chunk, choiceRecord, notice));
|
|
1386
1407
|
}
|
|
1387
1408
|
/** True when a call or real prose has reached the client. */
|
|
1388
1409
|
get delivered() {
|
|
@@ -1392,6 +1413,43 @@ var RecoveryStream = class {
|
|
|
1392
1413
|
get residueSeen() {
|
|
1393
1414
|
return this.sawResidue;
|
|
1394
1415
|
}
|
|
1416
|
+
/**
|
|
1417
|
+
* The single exit every byte of a FRAME passes through.
|
|
1418
|
+
*
|
|
1419
|
+
* SSE dispatches one event per blank-line-terminated run of `data:` lines.
|
|
1420
|
+
* A frame written WITHOUT its terminator leaves the client's decoder
|
|
1421
|
+
* waiting for a blank line that never arrives, so the NEXT write is
|
|
1422
|
+
* appended to the SAME event — and a client that joins the several `data:`
|
|
1423
|
+
* lines of one event with `\n` and parses the result as one JSON document
|
|
1424
|
+
* (openai's `SSEDecoder` does exactly that) then reads two payloads as one
|
|
1425
|
+
* and fails. Issue #53 is that failure: the upstream died between a `data:`
|
|
1426
|
+
* line and its blank line, `consume()` forwarded the half-frame verbatim,
|
|
1427
|
+
* and `finish()` glued its synthetic `[DONE]` onto it, producing
|
|
1428
|
+
* `data: {...}\ndata: [DONE]\n\n` — parsed as one event whose data is
|
|
1429
|
+
* `"{...}\n[DONE]"`, which throws "Unexpected non-whitespace character
|
|
1430
|
+
* after JSON at position N". The whole turn is then classified as a
|
|
1431
|
+
* non-retryable error, so the user simply sees the answer stop.
|
|
1432
|
+
*
|
|
1433
|
+
* Completing the terminator here fixes both halves at once: the truncated
|
|
1434
|
+
* frame becomes a self-contained event, and the `[DONE]` that follows can no
|
|
1435
|
+
* longer be joined to it. It is idempotent for the frames `push()` cuts —
|
|
1436
|
+
* those always carry the terminator already, so they pass through
|
|
1437
|
+
* byte-for-byte, which keeps guarantee 1 (frame order is never changed)
|
|
1438
|
+
* intact.
|
|
1439
|
+
*
|
|
1440
|
+
* Note that a missing NEWLINE is also completed: `data: {…}` cut mid-line
|
|
1441
|
+
* needs the full `\n\n`, not a single `\n`, to close the event. Such a frame
|
|
1442
|
+
* is upstream-corrupted JSON that cannot be repaired here; closing it at
|
|
1443
|
+
* least keeps the corruption confined to its own event instead of taking
|
|
1444
|
+
* the terminator down with it.
|
|
1445
|
+
*/
|
|
1446
|
+
emitFrame(frame) {
|
|
1447
|
+
if (frame.endsWith("\n\n")) {
|
|
1448
|
+
this.res.write(frame);
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
this.res.write(frame.endsWith("\n") ? `${frame}\n` : `${frame}\n\n`);
|
|
1452
|
+
}
|
|
1395
1453
|
/** Record that text the gates refused was seen (markup residue, not prose). */
|
|
1396
1454
|
noteResidue(seen) {
|
|
1397
1455
|
this.sawResidue ||= seen;
|
|
@@ -1407,7 +1465,10 @@ var RecoveryStream = class {
|
|
|
1407
1465
|
}
|
|
1408
1466
|
const tail = this.carry;
|
|
1409
1467
|
this.carry = "";
|
|
1410
|
-
if (tail !== "")
|
|
1468
|
+
if (tail !== "") {
|
|
1469
|
+
const lineTerminated = tail.endsWith("\n");
|
|
1470
|
+
this.handleFrame(lineTerminated ? `${tail}\n` : `${tail}\n\n`);
|
|
1471
|
+
}
|
|
1411
1472
|
}
|
|
1412
1473
|
/**
|
|
1413
1474
|
* Close the response: emit the block still unfinished at end-of-stream, then
|
|
@@ -1420,7 +1481,7 @@ var RecoveryStream = class {
|
|
|
1420
1481
|
this.emitContent(flushed.text);
|
|
1421
1482
|
}
|
|
1422
1483
|
this.closeWindow();
|
|
1423
|
-
if (!this.sawDone && this.res.writable) this.
|
|
1484
|
+
if (!this.sawDone && this.res.writable) this.emitFrame("data: [DONE]\n\n");
|
|
1424
1485
|
if (this.res.writable) this.res.end();
|
|
1425
1486
|
}
|
|
1426
1487
|
/** Drop held bytes without writing them (used when a retry replaced them). */
|
|
@@ -1498,7 +1559,7 @@ var RecoveryStream = class {
|
|
|
1498
1559
|
this.windowOpened = true;
|
|
1499
1560
|
this.flushNotice();
|
|
1500
1561
|
this.closeWindow();
|
|
1501
|
-
this.
|
|
1562
|
+
this.emitFrame(frame);
|
|
1502
1563
|
this.wroteContent = true;
|
|
1503
1564
|
return;
|
|
1504
1565
|
}
|
|
@@ -1509,7 +1570,7 @@ var RecoveryStream = class {
|
|
|
1509
1570
|
this.send(callsFrame(chunk, choiceRecord, outcome.calls), false);
|
|
1510
1571
|
this.wroteContent = true;
|
|
1511
1572
|
this.closeWindow();
|
|
1512
|
-
if (outcome.text !== "") this.
|
|
1573
|
+
if (outcome.text !== "") this.emitFrame(contentFrame(chunk, choiceRecord, outcome.text));
|
|
1513
1574
|
return;
|
|
1514
1575
|
}
|
|
1515
1576
|
this.noteResidue(!outcome.prose && outcome.text !== "");
|
|
@@ -1573,7 +1634,7 @@ var RecoveryStream = class {
|
|
|
1573
1634
|
/** Queue a frame while the window is open, or write it once it has closed. */
|
|
1574
1635
|
send(frame, final) {
|
|
1575
1636
|
if (this.windowClosed || !this.windowOpened) {
|
|
1576
|
-
this.
|
|
1637
|
+
this.emitFrame(frame);
|
|
1577
1638
|
return;
|
|
1578
1639
|
}
|
|
1579
1640
|
const entry = {
|
|
@@ -1597,7 +1658,7 @@ var RecoveryStream = class {
|
|
|
1597
1658
|
this.windowClosed = true;
|
|
1598
1659
|
const pending = this.held;
|
|
1599
1660
|
this.held = [];
|
|
1600
|
-
for (const entry of pending) this.
|
|
1661
|
+
for (const entry of pending) this.emitFrame(entry.frame);
|
|
1601
1662
|
}
|
|
1602
1663
|
};
|
|
1603
1664
|
/** The SSE `data:` payload of one frame, or nothing when it carries none. */
|
|
@@ -1664,8 +1725,9 @@ function describeAccount(credential) {
|
|
|
1664
1725
|
function failoverReasonText(failure) {
|
|
1665
1726
|
if (failure.ok) return "unusable";
|
|
1666
1727
|
switch (failure.kind) {
|
|
1667
|
-
case "soft_rate":
|
|
1668
|
-
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)";
|
|
1669
1731
|
case "session_dead": return "signed out";
|
|
1670
1732
|
case "policy_reject": return "rejected by policy";
|
|
1671
1733
|
case "not_found": return "model unavailable";
|
|
@@ -1788,12 +1850,24 @@ function createWorkBuddyShim(options) {
|
|
|
1788
1850
|
* `client` is deliberately NOT one of them: the upstream rejected the
|
|
1789
1851
|
* REQUEST (malformed body, unsupported field), so every account answers the
|
|
1790
1852
|
* same 400 and walking the rest of the pool only multiplies the wait before
|
|
1791
|
-
* the user sees an error they must act on anyway.
|
|
1792
|
-
*
|
|
1793
|
-
*
|
|
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.
|
|
1794
1868
|
*/
|
|
1795
1869
|
function isFailoverWorthy(kind) {
|
|
1796
|
-
return kind !== "client";
|
|
1870
|
+
return kind !== "client" && kind !== "model_busy";
|
|
1797
1871
|
}
|
|
1798
1872
|
async function chatCompletions(req, res) {
|
|
1799
1873
|
if (!isJsonContentType(req)) {
|
|
@@ -1900,6 +1974,16 @@ function createWorkBuddyShim(options) {
|
|
|
1900
1974
|
writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
|
|
1901
1975
|
return;
|
|
1902
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
|
+
}
|
|
1903
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));
|
|
1904
1988
|
return;
|
|
1905
1989
|
}
|
|
@@ -1960,6 +2044,21 @@ function createWorkBuddyShim(options) {
|
|
|
1960
2044
|
//#endregion
|
|
1961
2045
|
//#region src/probe.ts
|
|
1962
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
|
+
/**
|
|
1963
2062
|
* The system message every probe carries.
|
|
1964
2063
|
*
|
|
1965
2064
|
* Not decoration: the international gateway rejects a conversation that does not
|
|
@@ -2166,6 +2265,14 @@ function parseUpstreamResetAt(message) {
|
|
|
2166
2265
|
* A limited outcome with none of the three returns `{}`, which the card renders
|
|
2167
2266
|
* as "the upstream did not say when". That remains the honest answer for a
|
|
2168
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.
|
|
2169
2276
|
*/
|
|
2170
2277
|
function cooldownOf(input) {
|
|
2171
2278
|
const fromHeader = parseRetryAfter(input.retryAfter, input.nowMs);
|
|
@@ -2182,6 +2289,10 @@ function cooldownOf(input) {
|
|
|
2182
2289
|
retryAtMs: input.quotaRefreshAtMs,
|
|
2183
2290
|
retrySource: "quota-refresh"
|
|
2184
2291
|
};
|
|
2292
|
+
if (input.outcome === "model-busy") return {
|
|
2293
|
+
retryAtMs: input.nowMs + MODEL_BUSY_COOLDOWN_MS$1,
|
|
2294
|
+
retrySource: "upstream-tips"
|
|
2295
|
+
};
|
|
2185
2296
|
return {};
|
|
2186
2297
|
}
|
|
2187
2298
|
/**
|
|
@@ -2191,8 +2302,10 @@ function cooldownOf(input) {
|
|
|
2191
2302
|
* probe and a real chat request can never disagree about what the same upstream
|
|
2192
2303
|
* answer means. The extra splits here are `credential-rejected`, which the
|
|
2193
2304
|
* upstream signals with a non-JSON 401/403 edge page and which needs completely
|
|
2194
|
-
* different advice (re-auth, not "wait"),
|
|
2195
|
-
* 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).
|
|
2196
2309
|
*/
|
|
2197
2310
|
function outcomeOfFailure(status, body) {
|
|
2198
2311
|
if (status === 401 || status === 403) {
|
|
@@ -2201,6 +2314,7 @@ function outcomeOfFailure(status, body) {
|
|
|
2201
2314
|
}
|
|
2202
2315
|
switch (classifyUpstreamError(status, body)) {
|
|
2203
2316
|
case "soft_rate": return "rate-limited";
|
|
2317
|
+
case "model_busy": return "model-busy";
|
|
2204
2318
|
case "hard_credit": return "out-of-credit";
|
|
2205
2319
|
case "session_dead": return "credential-rejected";
|
|
2206
2320
|
case "not_found": return "not-found";
|
|
@@ -2478,6 +2592,23 @@ function defaultWait(ms) {
|
|
|
2478
2592
|
function messageOf$1(error) {
|
|
2479
2593
|
return (error instanceof Error ? error.message : String(error)).slice(0, 300);
|
|
2480
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;
|
|
2481
2612
|
/**
|
|
2482
2613
|
* Whether one account can be billed at `nowMs`.
|
|
2483
2614
|
*
|
|
@@ -2529,6 +2660,7 @@ function exclusionOf(probe, nowMs) {
|
|
|
2529
2660
|
case "unavailable": return retryDue(probe, nowMs) ? void 0 : "unavailable";
|
|
2530
2661
|
case "failed": return retryDue(probe, nowMs) ? void 0 : "failed";
|
|
2531
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";
|
|
2532
2664
|
case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
|
|
2533
2665
|
case "policy-rejected": return;
|
|
2534
2666
|
default: return "unusable";
|
|
@@ -2542,9 +2674,14 @@ function exclusionOf(probe, nowMs) {
|
|
|
2542
2674
|
* transport failure, so `atMs + POOL_UNKNOWN_COOLDOWN_MS` is what brings those
|
|
2543
2675
|
* back. That is deliberate — the alternative is a permanent exclusion decided by
|
|
2544
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".
|
|
2545
2682
|
*/
|
|
2546
|
-
function retryDue(probe, nowMs) {
|
|
2547
|
-
return nowMs >= (probe.retryAtMs ?? probe.atMs +
|
|
2683
|
+
function retryDue(probe, nowMs, fallbackMs = POOL_UNKNOWN_COOLDOWN_MS) {
|
|
2684
|
+
return nowMs >= (probe.retryAtMs ?? probe.atMs + fallbackMs);
|
|
2548
2685
|
}
|
|
2549
2686
|
/**
|
|
2550
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.
|
|
5
|
+
"version": "4.4.3",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|