dsh-connect-workbuddy 4.11.0 → 4.13.0
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 +75 -12
- package/README.en.md +4 -0
- package/README.md +3 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +11 -24
- package/lib/{host-heartbeat-BrIEnor4.js → host-heartbeat-CxoHaypp.js} +80 -7
- package/lib/index.d.ts +17 -6
- package/lib/index.js +29 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,24 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 4.
|
|
3
|
+
## 4.13.0 (2026-10-21)
|
|
4
4
|
|
|
5
|
-
> 版本号是 **
|
|
5
|
+
> 版本号是 **minor**:本版有两块改动,其中**请求体**首次新增一条 system 提示的「渠道指纹」归一化(只对开头命中上游清单的 system 消息前置一行占位语,未命中的 body 逐字节不变),这是发往上游的内容变化;另一块是**报错文案**的 405 判断与文案修正。路由 id、模型 id、凭据、计费与换号规则一律未变。
|
|
6
6
|
|
|
7
7
|
### Fixes
|
|
8
8
|
|
|
9
|
-
- **issue #
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
- **CN 版每次发消息都 400 / 11128「这次调用没通过渠道校验,可能不是官方客户端发出的」:上游按 system 提示的**开头**匹配第三方 agent 指纹,命中即拒**(issue #87 / #88 / #89 / #90 是同一件事的四份报告)
|
|
10
|
+
- **报告**:国内版发「1+1」也直接失败,报错原文(插件转述上游):`workbuddy 上游失败:请求被上游拒绝(http 400)(code 11128,requestId …)。这次调用没通过渠道校验,可能不是官方客户端发出的。请用官方版本重新发送。…`。同一账号、同一模型、同一套请求头,换成极简 body 却稳定 200 —— 先排除了账号、额度、UA 与 conversation 三件套(issue #66 的成因)。
|
|
11
|
+
- **真实原因**:两个网关都会扫**每一条** `system` 消息的**开头**,命中一份「已知第三方 agent 系统提示」清单就回业务码 `11128`(`Illegal API invocation from an unapproved channel`)。用国内网关实测(账号 `ba217455…`、模型 `deepseek-v4.1-flash`、`stream:true`、其余请求字段固定),**同一账号同一模型**只换 system 开头:
|
|
12
|
+
- `You are an AI agent powered by DeepSeek Harness` → CN **400 / 11128**,国际 **200**;
|
|
13
|
+
- `You are Claude Code, Anthropic's official CLI for Claude.` → CN **400 / 11128**,国际 **400 / 11128**;
|
|
14
|
+
- `You are an AI agent powered by Kimi.`、`You are Cursor Agent…`、`You are Gemini CLI…` → 两区都 **200**;
|
|
15
|
+
- 同一段 DSH 提示,但最前面加一行 `You are a helpful assistant.` → 两区都 **200**。
|
|
16
|
+
|
|
17
|
+
- **规则精确到字符**(二分实测,不是推测):`'x'`×10956 同长度通过;DSH 提示前 **47** 个字符即 `You are an AI agent powered by DeepSeek Harness` 触发,**46** 个字符(少一个 `s`)通过;大小写不敏感(全小写同样 400);开头多一个空格、或把同一句放进提示中段/`user` 消息都通过;**每一条** system 消息都会被扫(第二条才命中同样 400)。
|
|
18
|
+
- **为什么此前没有暴露**:插件只转发宿主给的 system 提示,此前没有宿主提示命中过这份清单。issue #66 的 11128 是**请求头**上的 conversation id 门(缺三枚 UUID),与这次同码不同因,所以 4.12.0 的头修复对它无效;`displayTips` 那句「不是官方客户端发出的」在两次事件里都是**误导性文案**——网关的真正判据分别是「头缺 id」与「提示开头命中清单」。国际网关目前不拦 DSH 这句、但拦 Claude Code:同一份清单的两个不同子集,插件对两区用同一条规则。
|
|
19
|
+
- **修法:只做「开头」归一化,不改提示内容。** `prepareChatBody()` 在 `ensureSystemHead()` 之后新增 `neutralizeChannelFingerprints()`:遍历 `messages`,对 `role.trim().toLowerCase() === 'system'` 且 `content` 为字符串的消息,取 `content.replace(/^\s+/, '').toLowerCase()` 与 `UNAPPROVED_CHANNEL_OPENINGS`(当前两条:DeepSeek Harness、Claude Code)做前缀比对,命中则 `content = WORKBUDDY_FALLBACK_SYSTEM_PROMPT + '\n\n' + content`。未命中的 body 逐字节不动(`tools`、`reasoning_effort`、历史形状均不动);宿主提示原文一字不改,只在最前面多一行两个区都已接受的占位句 —— 与 `ensureSystemHead()` 用的是同一句,理由同样是「加一行中性的话,比改写宿主自己的提示代价小」。
|
|
20
|
+
- **实测修复**:完整 DSH 提示 + 50 个 tools + `reasoning_effort:'high'` + 真实的 `tool_calls`/`tool` 历史:改前 400,改后 200;只加一个换行(`'\n' + 提示`)当时也 200,但那是靠网关当前的宽松,不能当修法,所以用显式占位行。
|
|
21
|
+
- **代价与边界(如实登记)**:清单是**实测快照**,网关新增词条不会被提前发现,直到有 body 带上它;prepend 只在「命中仍在 offset 0」时有效;宿主若把这段文案放到 `user` 消息或提示中段,当前规则不触发(也无需触发)。这不是绕过额度或内容审核——`11140` 安全审核、`14018` 额度、`6004` 限流等路径一行未动。
|
|
22
|
+
|
|
23
|
+
- **issue #86:批量操作报「HTTP 405」时,提示「请确认插件是最新版本」把人引向错误的修法**
|
|
24
|
+
- **报告**:点「一键签到所有账号」后卡片报「批量操作失败 —— 没有执行:批量操作被拒绝(HTTP 405)。请确认插件是最新版本后重试。」,而插件**已是最新版本**。重启 DSH 后恢复。
|
|
25
|
+
- **真实原因:那条路由在宿主进程里根本没注册。** 405 是宿主 HTTP 层回的,不是插件回的。判定依据是两种 405 的形态完全不同:插件自己的拒绝是 `json(res, 405, {error:'method not allowed'})`,带 `Content-Type: application/json` 与 body;而实测 `POST /pool` 的 405 **没有 Content-Type、没有 body**。插件每个 handler 都回 JSON,所以「405 + 空 body」只可能来自宿主 HTTP 层,也就是路径没匹配上任何插件路由。
|
|
26
|
+
- **同一次排查里所有路由的实测**(同一进程内,`GET` / `POST`):`usage` 200/405、`benefit/claim` 405(带 JSON)/400、`models/refresh` 404/405、`probe` 404/405、`pool` 404/405、`oauth` 404/405、`transfer` 404/405。注册顺序是 usage → account-credits → accounts → checkin → benefit/claim → models/refresh → probe → pool → diag → oauth → transfer,所以断点落在 `benefit/claim` 与 `models/refresh` 之间。
|
|
27
|
+
- **这不是「插件版本旧」**:这些路由的引入时间都远早于出问题的构建(`pool` 是 3.0.0 / 09-30,`probe` 是 2.3.0 / 09-29,`models/refresh` 更早),任何已发布版本都包含它们。是那次启动时注册中途失败了;具体触发条件未复现,重启即恢复。**记在这里是为了不再把它误判成版本问题**——按「更新插件」去查会一无所获。
|
|
28
|
+
- **修法:客户端区分这两种 405。** `poolFailureText` 的第三层(按状态码兜底)新增一条前置判断:响应体不是 JSON 且状态是 405 时,改用新文案「批量操作接口没有应答(HTTP {status},无响应体)—— 宿主进程很可能还在跑旧版插件代码,这个接口没有注册上。请完全退出 DSH 再重新打开后重试。」四个调用点(批量签到/测试、行内测试、行内删除、福利领取)都传入 `bodyUnreadable: body === undefined`。
|
|
29
|
+
- **只有 405 走这个分支,这是刻意的**:客户端这里只会 POST,未注册的路径只可能以 405 回来;一个**没有 body 的 500/502 是真实的服务端故障**,把它改写成「重启试试」会让人放弃真正该查的东西。结构化 `reason` 仍然优先于两种状态码兜底——未来宿主若在不可解析的 body 旁带 `reason`,本地化的原因严格更有信息量。
|
|
15
30
|
|
|
16
31
|
### Tests
|
|
17
32
|
|
|
18
|
-
- **`tests/
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
33
|
+
- **`tests/upstream.spec.ts` 新增 4 条用例**,钉的是「发往上游的 system 内容」,不是函数里有没有那句 if:
|
|
34
|
+
- 完整 DSH 开头 + 后续行 → `messages[0].content === WORKBUDDY_FALLBACK_SYSTEM_PROMPT + '\n\n' + 原文`,`messages` 长度与 user 消息不动;
|
|
35
|
+
- 全小写 + 两个前导空格的开头同样归一化(网关现在容忍大小写与前导空白只是巧合,不能让它成为下一个失败点);
|
|
36
|
+
- 同一句出现在提示中段 → 原文**逐字节**保留(网关只看开头,多改一个字都是在动本可以正常工作的请求);
|
|
37
|
+
- 第二条 system(DSH 的 `developer` 目录更新经 `developer → system` 改写而来)命中时同样被归一化,而第一条原样保留——`ensureSystemHead()` 只看第一条,这条用例把两者的作用域分开钉住。
|
|
38
|
+
- **`tests/pool-state.spec.ts` 新增 4 条用例**,钉住「两种 405 不可混同」:空 body 的 405 走新文案;**带 JSON 的 405 仍走原文案**(否则会把一次真正的「方法不对」误诊成「重启」);无 body 的 500/502 **不**被劫持(防止把服务端故障改写成重启建议);带 `reason` 时仍优先结构化原因。
|
|
39
|
+
|
|
40
|
+
## 4.12.0 (2026-10-20)
|
|
41
|
+
|
|
42
|
+
> 版本号是 **minor**:有用户可见的行为变更(模型繁忙不再显示成「额度已用尽」,且会被宿主原地重试几次)。发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。
|
|
43
|
+
|
|
44
|
+
### Fixes
|
|
45
|
+
|
|
46
|
+
- **issue #84:模型繁忙(14003)在聊天窗口被显示成「当前请求的额度已用尽」,并因此一次就失败**
|
|
47
|
+
- **报告**:issue #84 的评论里先有「国际版用deepseek模型会提示额度不足,step模型可以正常调用。但是我用workbuddy国际版正常使用deepseek免费模型」,随后又被同一线程的第二位报告者(zhangxinhang)收窄到真正的缺陷:**DeepSeek 用的人太多,经常 `model_busy`,而插件对于 `model_busy`,聊天窗口似乎会显示为「当前请求的额度已用尽」**;并请求「对于这种模型繁忙,是否可以进行适当次数的重试」。两条都要修,且它们是同一个原因的两个后果。
|
|
48
|
+
- **根因是一行映射,不是额度**:`src/shim.ts` 的 `KIND_HOST_CODE` 把 `model_busy` 翻成了 `quota_exceeded`(issue #56/#63 的取舍,理由是把 429 挡在宿主的原地重试集合之外)。但宿主判定失败类别靠的是**对错误文本跑正则**:`classifyPiAiError()` 把 `type + " " + code + " " + message` 交给 `@deepseek-ai/dsh-llm` 的 `isQuotaExceededError()`,而它的 `(?:quota|usage[_\s-]+limit)[_\s-]+(?:exceeded|exhausted|reached)` 恰好匹配 `quota_exceeded` 这个**字面拼写**。于是类别落到 `QUOTA`,而 `@deepseek-ai/dsh-client-ui-chat` 的 `failureMessage()` 对 `QUOTA`/`ACCOUNT_QUOTA` **硬编码固定文案并丢弃插件自己的 message**——「当前请求的额度已用尽」其实**不是插件写的**,是宿主替插件写的。
|
|
49
|
+
- **后果比措辞严重**:一个**暂时**、**与账号无关**、**换模型即可绕开**的状况,被渲染成一个**终局**、**账号级**的结论;同时 `QUOTA` 不在宿主的重试集合里(`DEFAULT_RETRYABLE_CODES` = `EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT`),所以一次 14003 就直接结束这一轮——正是报告者要求补救的那件事。
|
|
50
|
+
- **修法:不再翻译 `model_busy`。** 不翻译之后同一份 wire body 的文本里只剩 `429`,宿主读作 `RATE_LIMIT`,于是一举两得:**(a)** `RATE_LIMIT` 不在 `failureMessage()` 的替换名单里,插件自己那句诚实的说明原样显示给用户;**(b)** `RATE_LIMIT` **在**宿主的默认重试集合里,`dsh-llm-retry` 按默认的 500 ms→10 s 指数退避**原地重试**——报告者要的「适当次数的重试」由宿主自己的机制完成,插件不新增重试循环,也不引入无界重试。
|
|
51
|
+
- **`soft_rate` 保留翻译,这正是关键对比**:它的 6004 重置时刻在**几小时后**,重试预算换不出答案,所以必须继续挡在重试集合外。**同一个状态码 429,两种相反的时间判断**——所以拼写跟的是**时钟**,而不是两者共用的状态码。
|
|
52
|
+
- **文案跟着改**:`model_busy` 的回复现在明确写出「这与积分、额度无关」,并说明「这类失败会被自动重试几次」。不加这两句,用户读到的「建议等待约 1 分钟」会让一个已经被重试过五次的请求看起来像插件什么都没做。
|
|
53
|
+
- **不是推断,是端到端验过**:用本地 `node:http` 服务器重放这个 shim 真实产出的错误体,驱动**真实的 pi-ai `stream()`**(宿主用的就是它)拿到 `errorMessage`,再跑**宿主自己的** `isQuotaExceededError()`。改前 `model_busy` 判 `QUOTA`,改后判 `RATE_LIMIT`;`hard_credit`(报文里真有 `Credits exhausted`)两条路径都仍是 `QUOTA`——真花光积分时宿主仍显示它的终局文案,这个修复没有把真额度问题一起放过。
|
|
54
|
+
- **同时确认不是这个原因的两件事**(都已实测排除,记在这里省下后来人重走):**a.** 与**请求的 User-Agent 通道**无关——CLI UA 与桌面 UA 各发 5 次,`deepseek-v4.1-flash` 200/200/200/200/429 与 200×5,`step-5-preview` 全 200,付费的 `deepseek-v4.1-flash-sg` 全 14018;**b.** 与**请求大小**无关——8k/16k/30k/50k 输入下 14003 时有时无、非单调,纯属瞬时繁忙。
|
|
55
|
+
- **一个仍存在的相邻陷阱(本轮未改,如实登记)**:国际版 `/v3/config` 的 28 个模型里,`deepseek-v4.1-flash`(`x0.00`,免费)与 `deepseek-v4.1-flash-sg`(`x0.03`,付费)**显示名完全相同**(都是 `Deepseek-V4.1-Flash`),说明与能力字段也逐字相同,选择器里只有 ` · x0.00` / ` · x0.03` 这个后缀能区分;零积分账号选中付费那一个就会拿到 14018,而那是**真的**额度不足,宿主显示「额度已用尽」是对的。`step-5-preview` 没有付费孪生,所以「deepseek 报额度不足、step 正常」也可能来自**选错了同名的付费孪生**。改名会影响用户已保存的模型身份与全部按 id 的 join,需要单独一轮评估,因此不在本版顺手改。
|
|
56
|
+
|
|
57
|
+
### Tests
|
|
58
|
+
|
|
59
|
+
- **`tests/issue84-model-busy-not-quota.spec.ts`:钉「宿主实际看到的那段文本」,而不是「映射表里没有那个键」**
|
|
60
|
+
- 关键断言不是实现细节,而是宿主的判定:测试用**真实的 pi-ai `stream()`**(宿主用的同一个客户端)打到**真实的 shim**(真的开 socket),把 pi-ai 产出的 `errorMessage` 交给**宿主自己的** `isQuotaExceededError()`。重新实现一份分类器只能证明两份正则今天一致;而决定文案的是宿主那份。
|
|
61
|
+
- 四条用例覆盖两种失败的两侧:`model_busy` **不是** quota 且文案里带「与积分、额度无关」;`hard_credit`(真实 14018 报文)**仍是** quota;`soft_rate`(真实 6004 报文)**仍是** quota 且保留上游的重置时刻——「不该改的那半边别一起扫掉」。
|
|
62
|
+
- **重试那条断言读的是 adapter 自己发布的策略**(`config.profiles()` 的 `retryPolicy`,也就是宿主经 `providerRetryPolicy` 读到的同一个对象),不是 `resolveRetryPolicy(undefined)` 的默认值:后者在 adapter 干脆不再发布策略时**依然会绿**。断言含 `RATE_LIMIT` 在内、`QUOTA` 不在内、`maxRetries` 有界(>0 且 ≤10,即「适当次数」而不是无界循环)。
|
|
63
|
+
- **变异验证**:把 `model_busy: 'quota_exceeded'` 加回 `KIND_HOST_CODE`,`issue84` 的第一条与 `shim-failover` 的对应用例**当场变红**;恢复后全绿。`shim.spec.ts` 的「只有 soft_rate 需要翻译」那条清单把 `model_busy` 也收了进去,且注释写明它会红在哪。
|
|
64
|
+
- **改了两条既有断言**:`tests/shim.spec.ts`(`model_busy` 现在也属于「按自己的名字拼写」那一组)与 `tests/shim-failover.spec.ts`(`"code":"quota_exceeded"` → `"code":"model_busy"`,并新增 `not.toContain('quota_exceeded')`)。两条都是原先在**钉住缺陷**的断言,不是回归。
|
|
22
65
|
|
|
23
66
|
## 4.11.0 (2026-10-20)
|
|
24
67
|
|
|
@@ -52,6 +95,26 @@
|
|
|
52
95
|
- **`tests/upstream-error-copy.spec.ts` 加了 4 条**:11133 且 `param` 指向档位时点名档位与处置;`param` 为空时**不**归因到档位;拒绝被上报(含被请求命名的 `modelId`,不是账号别名);非参数类失败不上报。
|
|
53
96
|
- **变异验证**:单独回退「层级映射墙」→ 1 条红;单独回退「adapter 名单判断」→ 2 条红;单独回退「显式默认档位」→ profile 那 2 条红。撤回其中任一层都有测试当场报错。全量 1536 passed / 2 skipped,`pnpm run typecheck` 与 `pnpm run build` 均干净。
|
|
54
97
|
|
|
98
|
+
## 4.10.1 (2026-10-20)
|
|
99
|
+
|
|
100
|
+
> 版本号是 **patch**:没有接口、配置项或请求结构的变更,发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。**但它是 issue #77 的正解**——4.9.3 起的 `detail=fast` 只去掉了上游调用,真正吃掉 60 秒的本地开销一直没动;本版把每次打开设置页的 `reg query` 子进程从约 **135 个**降到 **3 个以内**。
|
|
101
|
+
|
|
102
|
+
### Fixes
|
|
103
|
+
|
|
104
|
+
- **issue #77(第二轮):字段密钥的探测按「失败也记住」缓存,一次打开页面不再逐文件重跑注册表探测**
|
|
105
|
+
- **报告与归因**:4.9.3 加了 `detail=fast` 之后,用户在 4.9.4、4.10 上仍然等 60 s。其 CPU profile 指出:19.1 s 的请求里 **15.3 s 在 `spawnSync`**,来自约 **135 个同步 `reg query` 子进程**——`findWorkbuddyAppExecutable()` 每次请求被调用 45 次,每次查三个卸载 hive。
|
|
106
|
+
- **根因**:`readAtRestKey()` 只缓存**成功**。当凭据字段是 `$wbEncrypted` 而桌面端又不在候选目录里时,每次探测都会重跑整套发现;`scanAll()` 是**逐文件**调用的,一次打开页面又要读多次,两个乘数叠在一起。
|
|
107
|
+
- **第二个乘数**:`SCAN_SHARE_WINDOW_MS` 从扫描的**开始**时刻计窗——比窗口更慢的那次扫描**在完成时就已经过期**,永远无法服务它之后的读取。现在从**完成**时刻计窗,并单独保留 `invalidateScan()` 供写入路径强制刷新(写完再读不会看到旧列表)。
|
|
108
|
+
- **两处顺手的去重**:`findWorkbuddyAppExecutableWithSource()` 会在默认布局 → 注册表 → `findWorkbuddyAppExecutable()` 的回落链里对同一个答案问两遍注册表,现在只问一遍;失败结果带上过期时间缓存,所以「先不装、后装 App」的用户**不需要重启**就能被看见。
|
|
109
|
+
- **代价**:第一次真正的读取仍付一次探测的钱(3 次 `reg`),此后每一轮读取都是 0。
|
|
110
|
+
|
|
111
|
+
### Tests
|
|
112
|
+
|
|
113
|
+
- **`tests/issue77-at-rest-key-cache.spec.ts`:用「子进程次数」钉住,不靠计时措辞**
|
|
114
|
+
- `process.platform` 影子成 `win32` + `execFileSync` 在模块边界打桩,于是**任何宿主(含 CI 的 macOS/Linux)**都会真的走进 Windows 分支并把 `reg` 调用数出来:一次探测恰好 3 次、5 个加密凭据文件的一轮读取(`accounts` → `status` → `current` → `accounts`)仍是 3 次、失败结果被记住、过期后重新探测。
|
|
115
|
+
- **变异验证**:把修复回退后这两条断言分别报 **15 ≠ 3** 与 **9 ≠ 3**;三个用例在修复后全绿。写入后再读的用例在前修复/后修复两种状态下都绿——它守的是「共享窗口不得变成 TTL」,是回归护栏而非修复证据。
|
|
116
|
+
- 文件里的凭据夹具用**真实的加密字段形状** `{$wbEncrypted: 1, envelope: "<base64>"}`;写成嵌套对象会被 `isEncryptedFieldWrapper()` 判为「未加密」,让整条用例静默走错分支(写测试时踩到过,已在夹具注释里记下)。
|
|
117
|
+
|
|
55
118
|
## 4.10.0 (2026-10-19)
|
|
56
119
|
|
|
57
120
|
> 版本号是 **minor**:4.9.5 / 4.9.6 两个 patch 从未单独发布到 npm(npm 上仍是 4.9.4),本次一次性带上它们,并新增福利活动的完整链路。**发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。**
|
package/README.en.md
CHANGED
|
@@ -356,6 +356,10 @@ Marketplace screenshots and GitHub README badges are two separate mechanisms:
|
|
|
356
356
|
- **The executable is located from the bundle's own declaration, not guessed from the app name.** The WorkBuddy macOS bundles declare `CFBundleExecutable` as `Electron` (not `WorkBuddy`), so the plugin reads each bundle's `Info.plist` to learn the binary's name. Both the domestic `WorkBuddy.app` and the international `WorkBuddy AI.app` are candidates, and an app filed into a subdirectory of an applications folder (e.g. `/Applications/IDE/WorkBuddy.app`) is found by a one-level scan that confirms each candidate's bundle identifier before use — so a different Electron app can never be launched by mistake.
|
|
357
357
|
- **"Signed out" and "your sign-in cannot be read" are reported separately.** When an encrypted file cannot be read, the advice is to install the desktop app or point `WORKBUDDY_APP_EXECUTABLE` at it — **not** to sign in again, which cannot help: the credential is fine, the key is what is missing.
|
|
358
358
|
- **A 403 "request illegal" (code 11140) is a server-side policy refusal, not a sign-in problem.** The chat gateway sometimes refuses a request outright under its content policy; the body reads `{"code":11140,"msg":"request illegal","requestId":"…","displayMsg":{"en":"The content did not pass the safety review. Please adjust and retry."}}`. The plugin used to file this under the generic `client` error (HTTP 400), which DSH then displayed as an AUTH failure — it looked like a broken sign-in, but **signing in again cannot fix it**: the credentials are fine; it is this one request that was refused. The refusal now passes through as 403 with the official message, `code 11140`, and the `requestId` (give that to official support). The `error.code` written back to the host is the stable class `policy_reject` rather than the numeric `11140`: the host decides in-place retrial from that class, and a policy refusal is deterministic — retrying only burns the budget, and `11140` is not a word the host knows, and the per-model "test" reports "refused by the server's content policy" instead of "token refused". Two ways out: send the same prompt from the WorkBuddy desktop app with the same account (if the desktop app is refused too, the limit is on the account/content side), or switch region/account and retry.
|
|
359
|
+
- **A busy model is not an exhausted balance — but the international gateway lists two same-named models, and picking the paid one really is a credit problem.** 14003 (`model_busy`) is a **model-scoped**, **transient** throttle that has nothing to do with the account or its credits; switching model works around it. The plugin used to translate it to `quota_exceeded` on the way back to the host, and the host classifies failures by regexing the error text — `quota_exceeded` happens to match `@deepseek-ai/dsh-llm`'s `isQuotaExceededError`, so the class became `QUOTA` and the chat client **replaced the plugin's message with its own hard-coded 「Request quota exhausted.」** (that sentence was never the plugin's). Since 4.12.0 `model_busy` is no longer translated: the host reads `RATE_LIMIT`, the plugin's own wording survives, and the failure lands in the host's **in-place retry** set, so `dsh-llm-retry` retries it a bounded number of times with the default 500 ms→10 s backoff. **`soft_rate` (6004) keeps the old translation**: its reset is hours away, so the retry budget cannot produce an answer and must not be spent.
|
|
360
|
+
- **The other half of the report is a naming trap: the international roster has two 「Deepseek-V4.1-Flash」 entries.** Of the 28 models on `/v3/config`, `deepseek-v4.1-flash` (`x0.00`, **free**) and `deepseek-v4.1-flash-sg` (`x0.03`, **paid**) have **byte-identical names, descriptions, context windows and capability fields**; only the ` · x0.00` / ` · x0.03` suffix the plugin appends distinguishes them in the picker. A zero-credit account that selects the **paid** one gets 429 / code 14018 「Credits exhausted」 — that time the balance really is the problem, the host's wording is **correct**, and no retry can help. So when you see the quota message, check the multiplier suffix first: switch the international deepseek entry to the `x0.00` one, or use `step-5-preview` (`x0.00`, no paid twin). The only free models on the international gateway are `deepseek-v4.1-flash`, `hy3` and `step-5-preview`.
|
|
361
|
+
- The host's text-based classification order is part of the trap: a `401`/`403` digit **outranks** everything (yields `AUTH`), the `quota` wording **outranks** `429` (yields `QUOTA` and triggers the substitution above), and a `400` digit yields `INVALID_REQUEST`. An error body written back to the host therefore must not carry those literals casually — `tests/issue84-model-busy-not-quota.spec.ts` pins this layer by driving the real pi-ai client against the real shim and feeding the resulting text to the host's own `isQuotaExceededError()`.
|
|
362
|
+
|
|
359
363
|
|
|
360
364
|
- **Switching region makes the context "grow", and can hit `code 11115` immediately — the gateway is not over-charging you.** The same history is counted under different buckets in the two regions, and the root cause is how the host's bundled `@earendil-works/pi-ai` replays history. It evaluates `isSameModel` for every assistant message (`transform-messages.js:68`, keyed on provider **and** api **and** model id all being equal): for a message that fails, its thinking is **downgraded to ordinary content** (`:88–96` — not dropped), and content is what the gateways count, while thinking goes out in a different field. This plugin registers the domestic and international editions as **two provider ids** (`workbuddy` / `workbuddy-global`, see [How it works](#how-it-works)) that carry the **same model id** — so one region switch moves **all the other side's thinking into the content bucket at once**. That is the cause of issue #59's "the same conversation history is charged 1.374M by the international gateway and 708K by the domestic one (1.94×)"; switching back is the same formula read the other way, 0.696×.
|
|
361
365
|
- Three consequences you can see: a region switch can move the token count by ±30%–94%, and **how far depends only on the other side's thinking character count, not on total history length**; **switching models inside one region** triggers the same downgrade (the model leg of `isSameModel`), so the count can move without a region switch at all; and that jump lands on top of the host's current compaction threshold (`min(0.8 × window, window − 128000 − 65536)`), so a session that was comfortably under the limit can hit `code 11115 prompt is too long` the moment you switch region.
|
package/README.md
CHANGED
|
@@ -366,6 +366,9 @@ else { await envelope.response.text() /* 或者按 SSE 消费 */ }
|
|
|
366
366
|
- **App 可执行文件按 bundle 自身声明定位,不按 App 名猜**:WorkBuddy 的 macOS bundle 里 `CFBundleExecutable` 是 `Electron`(不是 `WorkBuddy`),因此插件读取 bundle 的 `Info.plist` 来决定二进制名。国内版 `WorkBuddy.app` 与国际版 `WorkBuddy AI.app` 都在候选内;App 被归入 `/Applications` 子目录(如 `/Applications/IDE/WorkBuddy.app`)时,插件还会向下扫一层并用 bundle id 确认身份后才使用——**不会**因为同名的其他 Electron 应用而误启动它。
|
|
367
367
|
- **「未登录」与「登录信息读不出来」是两件事**:加密文件读不出时,插件给出的建议是「安装桌面 App / 用 `WORKBUDDY_APP_EXECUTABLE` 指定其位置」,而**不是**「重新登录一次」——后者对这种情况无效(凭据本身是好的,缺的是密钥)。
|
|
368
368
|
- **403「request illegal」(code 11140)是服务端策略拒绝,不是登录问题**:聊天网关有时会按内容策略直接拒绝一次请求,响应体形如 `{"code":11140,"msg":"request illegal","requestId":"…","displayMsg":{"zh":"内容未通过安全审核,请调整后重试。"}}`。此前插件把它归为通用 `client` 错误返回 400,DSH 侧又显示成 AUTH 异常——看起来像「登录坏了」,但**重新登录解决不了它**:凭据本身是好的,被拒的是这一次请求。现在这类拒绝按 403 原样透传,消息里带官方文案、`code 11140` 和 `requestId`(联系官方支持时提供)。写回宿主的 `error.code` 是 `policy_reject` 这个稳定类别(而非数值 `11140`):宿主按类别决定要不要原地重试,而策略拒绝是确定性的——重试只会白烧预算,`11140` 也不是宿主认得的词;模型行的「测试」也显示「服务端按内容策略拒绝」而非「token 被拒」。遇到时的两个出口:在 WorkBuddy 桌面端用同一账号发同样的内容验证(桌面端同样被拒,说明限制在账号/内容侧);或切换区域/账号后重试。
|
|
369
|
+
- **「模型繁忙」不是「额度已用尽」——但国际版有两个同名模型,选错那个才是真的额度不够**:14003(`model_busy`)是**模型级**、**暂时**、**与账号和积分无关**的限流,换一个模型即可绕开;它以前被本插件写回宿主时译成了 `quota_exceeded`,而宿主按错误文本做正则分类,恰好命中 `@deepseek-ai/dsh-llm` 的 `isQuotaExceededError` → 类别 `QUOTA`,于是聊天客户端**用它自己写死的「当前请求的额度已用尽」替换掉插件的话**(那句话从来不是插件写的)。4.12.0 起不再翻译 `model_busy`,宿主读到的是 `RATE_LIMIT`:文案保留插件自己那句「这不是账号被限流,也与积分、额度无关」,并因此进入宿主的**原地重试**集合,由 `dsh-llm-retry` 按默认的 500 ms→10 s 退避重试几次(有界,非无限)。**`soft_rate`(6004)仍然照旧写成 `quota_exceeded`**:它的重置时刻在几小时后,重试预算换不出答案,必须继续挡在重试集合之外。
|
|
370
|
+
- **但请注意另一件事:国际版有两个「Deepseek-V4.1-Flash」**。`/v3/config` 的 28 个模型里,`deepseek-v4.1-flash`(`x0.00`,**免费**)与 `deepseek-v4.1-flash-sg`(`x0.03`,**付费**)的**名称、说明、上下文与能力字段逐字相同**,选择器里只有名字后面那个 ` · x0.00` / ` · x0.03` 能区分。零积分账号选中**付费**那一个时,网关返回 429 / code 14018「Credits exhausted」——这一次**是真的**额度不足,宿主的「额度已用尽」是**正确**的,重试也救不了。所以看到「额度已用尽」时先看模型名的乘数后缀:把国际版的那一个 deepseek 换成带 `x0.00` 的,或改用 `step-5-preview`(`x0.00`,没有付费孪生)。国际版上的免费模型只有 `deepseek-v4.1-flash`、`hy3`、`step-5-preview` 三个。
|
|
371
|
+
- 宿主那套「按文本正则分类」的判定顺序也是这个坑的一部分:`401`/`403` 数字**优先于**一切(会被判成 `AUTH`),`quota` 措辞**优先于** `429`(会被判成 `QUOTA` 并触发上面那段替换),`400` 数字会被判成 `INVALID_REQUEST`。写回宿主的错误体因此不能随意带上这些字面量——`tests/issue84-model-busy-not-quota.spec.ts` 用真实的 pi-ai 客户端打真实的 shim,再把产出的文本交给宿主自己的 `isQuotaExceededError()` 来钉住这一层。
|
|
369
372
|
|
|
370
373
|
- **切换区域会让上下文「变大」,甚至一换区就 `code 11115` 超限——不是网关多算你的钱**:同一份历史在两个区域的计费口径不同,根子在宿主内置的 `@earendil-works/pi-ai` 重放历史的方式。它给每条 assistant 消息算一次 `isSameModel`(`transform-messages.js:68`,判据是 provider **且** api **且** model id 三者全等):不等的消息,其 thinking 会被**降级成普通正文**(`:88–96`,并不是丢弃),而正文是网关会数的,thinking 走的是另一个字段(字段名两边都是 `reasoning_content`)。**这一点已由 issue #59 报告者的同区增量实测证实**:`reasoning` 不计费,且同一侧自己的工具结果块按本插件实测的 455.5 tok/1k 就吃掉了它全部计费量的 97.7%。本插件把国内版与国际版注册成**两条 provider 路由**(`workbuddy` / `workbuddy-global`,见[工作原理](#工作原理)),而它们是同一个 model id——于是**换一次区,另一侧产生的全部 thinking 就整批从 thinking 桶挪进正文桶**。这就是 issue #59 里「同一份对话历史,国际网关算 1.374M、国内网关算 708K(1.94×)」的成因;反方向切回来则是同一公式的 0.696×。
|
|
371
374
|
- 由此带来三个用户可见的后果:换区后 token 计数可能跳 ±30%~94%,**跳多少只取决于另一侧 thinking 的字符数,与历史总长无关**;在**同一个区域内换模型**也会触发同样的降级(`isSameModel` 的 model 那条腿),所以不换区也可能看到计数变化;跳变叠加在宿主当前的压缩阈值(`min(0.8×窗口, 窗口−128000−65536)`)上,就会让本来不超限的会话一换区立刻撞上 `code 11115 prompt is too long`。
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as WORKBUDDY_APP_EXECUTABLE_ENV, C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, at as findWorkbuddyAppExecutableWithSource, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, pt as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-
|
|
2
|
+
import { $ as WORKBUDDY_APP_EXECUTABLE_ENV, C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, at as findWorkbuddyAppExecutableWithSource, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, pt as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-CxoHaypp.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
|
@@ -1689,27 +1689,8 @@ window.__ModuleLoader__.load({
|
|
|
1689
1689
|
default: return;
|
|
1690
1690
|
}
|
|
1691
1691
|
}
|
|
1692
|
-
/**
|
|
1693
|
-
* The message to show for a failed pool request, from every signal available.
|
|
1694
|
-
*
|
|
1695
|
-
* The three-tier rule, in order, and why each tier exists:
|
|
1696
|
-
*
|
|
1697
|
-
* 1. the STRUCTURED cause, localized — the Host's `error` is an English
|
|
1698
|
-
* developer sentence, and echoing it put untranslated prose in a Chinese UI;
|
|
1699
|
-
* 2. the Host's raw text, for a cause this build does not know — still more
|
|
1700
|
-
* informative than a generic message, and it keeps a new upstream cause
|
|
1701
|
-
* visible rather than swallowed;
|
|
1702
|
-
* 3. a message keyed on the STATUS — every pool exit that carries no reason
|
|
1703
|
-
* (405/403/400 and the generic 500) otherwise reached the user as bare
|
|
1704
|
-
* English like `HTTP 405`.
|
|
1705
|
-
*
|
|
1706
|
-
* Extracted from the card for the same reason as {@link poolErrorText}: a rule
|
|
1707
|
-
* inside the browser-only `.tsx` cannot be tested, and the status tier in
|
|
1708
|
-
* particular had no guard at all — reverting it to `` `HTTP ${status}` `` left
|
|
1709
|
-
* the entire suite green.
|
|
1710
|
-
*/
|
|
1711
1692
|
function poolFailureText(t, input) {
|
|
1712
|
-
return poolErrorText(t, input.reason, input.error) ?? input.error ?? t("row.poolErrHttp", { status: String(input.status) });
|
|
1693
|
+
return poolErrorText(t, input.reason, input.error) ?? input.error ?? (input.bodyUnreadable === true && input.status === 405 ? t("row.poolErrRouteMissing") : t("row.poolErrHttp", { status: String(input.status) }));
|
|
1713
1694
|
}
|
|
1714
1695
|
/**
|
|
1715
1696
|
* Pool MEMBERS that can serve right now.
|
|
@@ -2157,7 +2138,8 @@ window.__ModuleLoader__.load({
|
|
|
2157
2138
|
if (!response.ok) throw new Error(poolFailureText(t, {
|
|
2158
2139
|
status: response.status,
|
|
2159
2140
|
reason: body?.reason,
|
|
2160
|
-
error: body?.error
|
|
2141
|
+
error: body?.error,
|
|
2142
|
+
bodyUnreadable: body === void 0
|
|
2161
2143
|
}));
|
|
2162
2144
|
if (!mounted.current) return;
|
|
2163
2145
|
if (action === "checkin") {
|
|
@@ -2226,7 +2208,8 @@ window.__ModuleLoader__.load({
|
|
|
2226
2208
|
if (!response.ok) throw new Error(poolFailureText(t, {
|
|
2227
2209
|
status: response.status,
|
|
2228
2210
|
reason: body?.reason,
|
|
2229
|
-
error: body?.error
|
|
2211
|
+
error: body?.error,
|
|
2212
|
+
bodyUnreadable: body === void 0
|
|
2230
2213
|
}));
|
|
2231
2214
|
if (!mounted.current) return;
|
|
2232
2215
|
const row = body?.rows?.[0];
|
|
@@ -2288,7 +2271,8 @@ window.__ModuleLoader__.load({
|
|
|
2288
2271
|
if (!response.ok) throw new Error(poolFailureText(t, {
|
|
2289
2272
|
status: response.status,
|
|
2290
2273
|
reason: body?.reason,
|
|
2291
|
-
error: body?.error
|
|
2274
|
+
error: body?.error,
|
|
2275
|
+
bodyUnreadable: body === void 0
|
|
2292
2276
|
}));
|
|
2293
2277
|
if (!mounted.current) return;
|
|
2294
2278
|
appendLog(t("row.poolLogBenefitClaimed", {
|
|
@@ -2342,7 +2326,8 @@ window.__ModuleLoader__.load({
|
|
|
2342
2326
|
if (!response.ok) throw new Error(poolFailureText(t, {
|
|
2343
2327
|
status: response.status,
|
|
2344
2328
|
reason: body?.reason,
|
|
2345
|
-
error: body?.error
|
|
2329
|
+
error: body?.error,
|
|
2330
|
+
bodyUnreadable: body === void 0
|
|
2346
2331
|
}));
|
|
2347
2332
|
if (!mounted.current) return;
|
|
2348
2333
|
appendLog(body?.removed === true ? t("row.poolRowRemoved", { accountName: accountName === "" ? t("row.accountUnnamed") : accountName }) : t("row.poolRowRemoveDesktopOnly", { accountName: accountName === "" ? t("row.accountUnnamed") : accountName }), body?.removed === true ? "ok" : "info");
|
|
@@ -7164,6 +7149,7 @@ window.__ModuleLoader__.load({
|
|
|
7164
7149
|
"row.poolErrUnavailable": "The account pool is not available in this build (or its batch route is missing). Restart the app; if it persists, the plugin needs reinstalling.",
|
|
7165
7150
|
"row.poolErrFailed": "The batch failed on the host side: {message}",
|
|
7166
7151
|
"row.poolErrHttp": "The batch was refused (HTTP {status}). Check the plugin is up to date and try again.",
|
|
7152
|
+
"row.poolErrRouteMissing": "The batch endpoint did not answer (HTTP {status}, no body) — the DSH Host is probably still running older plugin code, so this route is not registered. Quit DSH completely and start it again, then retry.",
|
|
7167
7153
|
"row.poolErrNoMembers": "No account is checked into this pool — the batch had nothing to run. Check at least one account and save.",
|
|
7168
7154
|
"row.poolErrNoLiveMembers": "Every account checked into this pool has lost its local sign-in, so the batch had nothing to run. Sign in again in the WorkBuddy desktop app, or save to drop them.",
|
|
7169
7155
|
"row.poolErrNoFreeModel": "This region has no zero-multiplier model to test with. Refresh the model catalog, or pick a target model yourself.",
|
|
@@ -7488,6 +7474,7 @@ window.__ModuleLoader__.load({
|
|
|
7488
7474
|
"row.poolErrUnavailable": "本版本没有可用的账号池(或其批量接口缺失)。请重启应用;若仍不行,插件可能需要重新安装。",
|
|
7489
7475
|
"row.poolErrFailed": "批量操作在宿主侧失败:{message}",
|
|
7490
7476
|
"row.poolErrHttp": "批量操作被拒绝(HTTP {status})。请确认插件是最新版本后重试。",
|
|
7477
|
+
"row.poolErrRouteMissing": "批量操作接口没有应答(HTTP {status},无响应体)—— 宿主进程很可能还在跑旧版插件代码,这个接口没有注册上。请完全退出 DSH 再重新打开后重试。",
|
|
7491
7478
|
"row.poolErrNoMembers": "本池没有勾选任何账号,批量操作没有可执行对象。请至少勾选一个账号并保存。",
|
|
7492
7479
|
"row.poolErrNoLiveMembers": "本池勾选的账号在本机都已不再登录,批量操作没有可执行对象。请在 WorkBuddy 桌面端重新登录,或保存以移除它们。",
|
|
7493
7480
|
"row.poolErrNoFreeModel": "本区域没有可用的免费(倍率为 0)模型。请先刷新模型目录,或自己指定一个目标模型。",
|
|
@@ -1559,6 +1559,67 @@ function billingHeaders(credential) {
|
|
|
1559
1559
|
*/
|
|
1560
1560
|
const WORKBUDDY_FALLBACK_SYSTEM_PROMPT = "You are a helpful assistant.";
|
|
1561
1561
|
/**
|
|
1562
|
+
* System-prompt openings the gateways fingerprint as "an unapproved channel".
|
|
1563
|
+
*
|
|
1564
|
+
* WHY THIS EXISTS (measured 2026-10-21 00:05 on the real gateways, CN account
|
|
1565
|
+
* `ba217455…`, `deepseek-v4.1-flash`, every other request field held fixed):
|
|
1566
|
+
* each gateway scans EVERY `system` message for a small set of known
|
|
1567
|
+
* third-party-agent prompt openings and refuses a hit with business code
|
|
1568
|
+
* `11128` / `Illegal API invocation from an unapproved channel` — the same code
|
|
1569
|
+
* the conversation-id gate (issue #66) and the `developer`-role gate use, so
|
|
1570
|
+
* the raw `displayTips` text ("可能不是官方客户端发出的") reads as a
|
|
1571
|
+
* client-identity problem. The actual failing rule is a CONTENT prefix match:
|
|
1572
|
+
*
|
|
1573
|
+
* system message begins with CN global
|
|
1574
|
+
* ----------------------------------------------------- ------------ ------------
|
|
1575
|
+
* "You are an AI agent powered by DeepSeek Harness" 400 / 11128 200
|
|
1576
|
+
* "You are Claude Code, Anthropic's official CLI…" 400 / 11128 400 / 11128
|
|
1577
|
+
* "You are an AI agent powered by Kimi." 200 200
|
|
1578
|
+
* "You are Cursor Agent…", "You are Gemini CLI…" 200 200
|
|
1579
|
+
* the same DSH prompt with "You are a helpful assistant."
|
|
1580
|
+
* prepended 200 200
|
|
1581
|
+
*
|
|
1582
|
+
* Matching is start-of-content only — the same sentence mid-prompt, in a
|
|
1583
|
+
* `user` message, or one leading space earlier all pass — and case-insensitive
|
|
1584
|
+
* (`you are an ai agent powered by deepseek harness` fails too). Prepending one
|
|
1585
|
+
* line is therefore enough to keep an ordinary DSH request off the list, and
|
|
1586
|
+
* the line is the placeholder {@link ensureSystemHead} already uses: adding a
|
|
1587
|
+
* neutral head costs less model behaviour than rewriting the host's own prompt.
|
|
1588
|
+
*
|
|
1589
|
+
* Not a nice rule to have to know, but the prompt was the only difference
|
|
1590
|
+
* between two requests the same account answered differently: the full DSH body
|
|
1591
|
+
* failed every time, the same body with the two leading lines swapped for
|
|
1592
|
+
* "You are a helpful assistant." returned 200. The list below is the measured
|
|
1593
|
+
* snapshot, not a theory of what the gate "means"; a gateway adding a new
|
|
1594
|
+
* opening is not caught until a body carries one, and the prepend only wins
|
|
1595
|
+
* while the gate keeps matching at offset 0.
|
|
1596
|
+
*/
|
|
1597
|
+
const UNAPPROVED_CHANNEL_OPENINGS = ["you are an ai agent powered by deepseek harness", "you are claude code, anthropic's official cli for claude"];
|
|
1598
|
+
/**
|
|
1599
|
+
* Move every `system` message off the content fingerprint above by prepending
|
|
1600
|
+
* the placeholder head.
|
|
1601
|
+
*
|
|
1602
|
+
* A no-op for a body with no system message and for every system message whose
|
|
1603
|
+
* content starts with anything else, so a request that the gateways already
|
|
1604
|
+
* answer keeps its prompt byte-for-byte — only the messages the gate would
|
|
1605
|
+
* refuse grow, by one short line.
|
|
1606
|
+
*/
|
|
1607
|
+
function neutralizeChannelFingerprints(obj) {
|
|
1608
|
+
const messages = obj["messages"];
|
|
1609
|
+
if (!Array.isArray(messages)) return;
|
|
1610
|
+
for (const value of messages) {
|
|
1611
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
1612
|
+
const message = value;
|
|
1613
|
+
const role = message["role"];
|
|
1614
|
+
if (typeof role !== "string" || role.trim().toLowerCase() !== "system") continue;
|
|
1615
|
+
const content = message["content"];
|
|
1616
|
+
if (typeof content !== "string") continue;
|
|
1617
|
+
const opening = content.replace(/^\s+/, "").toLowerCase();
|
|
1618
|
+
if (!UNAPPROVED_CHANNEL_OPENINGS.some((prefix) => opening.startsWith(prefix))) continue;
|
|
1619
|
+
message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1562
1623
|
* Make the conversation open with a system message, prepending the fallback
|
|
1563
1624
|
* when nothing else supplies one.
|
|
1564
1625
|
*
|
|
@@ -1579,12 +1640,6 @@ function ensureSystemHead(obj) {
|
|
|
1579
1640
|
});
|
|
1580
1641
|
}
|
|
1581
1642
|
/**
|
|
1582
|
-
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
1583
|
-
* force `stream: true` (the upstream rejects non-streaming), flatten
|
|
1584
|
-
* `tool_choice` (the upstream's field is a string; object forms return 400),
|
|
1585
|
-
* and guarantee a leading system message.
|
|
1586
|
-
*/
|
|
1587
|
-
/**
|
|
1588
1643
|
* The model id a prepared body asks for, when it carries one (issue #61).
|
|
1589
1644
|
*
|
|
1590
1645
|
* Lives beside {@link prepareChatBody} because it reads the same shape: the
|
|
@@ -1602,6 +1657,23 @@ function modelIdOfBody(bodyJson) {
|
|
|
1602
1657
|
return;
|
|
1603
1658
|
}
|
|
1604
1659
|
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream: force
|
|
1662
|
+
* `stream: true` (the upstream rejects non-streaming), flatten `tool_choice`
|
|
1663
|
+
* (the upstream's field is a string; object forms return 400), rewrite
|
|
1664
|
+
* `developer` to `system`, guarantee a leading system message, and move a
|
|
1665
|
+
* recognized third-party-agent system prompt off the gateways' content
|
|
1666
|
+
* fingerprint.
|
|
1667
|
+
*
|
|
1668
|
+
* The last step is not cosmetic: both gateways refuse a request whose `system`
|
|
1669
|
+
* message opens with one of the openings listed at
|
|
1670
|
+
* {@link UNAPPROVED_CHANNEL_OPENINGS} — business code 11128, surfaced to the
|
|
1671
|
+
* user as a "渠道校验" (channel verification) failure — which made every CN step
|
|
1672
|
+
* of a DSH session fail while a two-message probe on the same account returned
|
|
1673
|
+
* 200. Every rewrite in here is forced by a gateway rule, so a body the
|
|
1674
|
+
* upstream already answers keeps its prompt as close to byte-for-byte as these
|
|
1675
|
+
* rules allow.
|
|
1676
|
+
*/
|
|
1605
1677
|
function prepareChatBody(source) {
|
|
1606
1678
|
let body;
|
|
1607
1679
|
try {
|
|
@@ -1618,6 +1690,7 @@ function prepareChatBody(source) {
|
|
|
1618
1690
|
if (message["role"] === "developer") message["role"] = "system";
|
|
1619
1691
|
}
|
|
1620
1692
|
ensureSystemHead(obj);
|
|
1693
|
+
neutralizeChannelFingerprints(obj);
|
|
1621
1694
|
normalizeToolChoice(obj);
|
|
1622
1695
|
dropRefusedReasoning(obj);
|
|
1623
1696
|
return JSON.stringify(obj);
|
|
@@ -4337,7 +4410,7 @@ function generationOf(version) {
|
|
|
4337
4410
|
//#endregion
|
|
4338
4411
|
//#region src/version.ts
|
|
4339
4412
|
/** The npm package version this build was produced from. */
|
|
4340
|
-
const WORKBUDDY_CONNECT_VERSION = "4.
|
|
4413
|
+
const WORKBUDDY_CONNECT_VERSION = "4.13.0";
|
|
4341
4414
|
//#endregion
|
|
4342
4415
|
//#region src/host-heartbeat.ts
|
|
4343
4416
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -883,12 +883,6 @@ declare function regionOf(domain: string): WorkBuddyRegion;
|
|
|
883
883
|
* the same decision as admitting `workbuddy.cn`.
|
|
884
884
|
*/
|
|
885
885
|
declare function knownRegionOf(domain: string): WorkBuddyRegion | undefined;
|
|
886
|
-
/**
|
|
887
|
-
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
888
|
-
* force `stream: true` (the upstream rejects non-streaming), flatten
|
|
889
|
-
* `tool_choice` (the upstream's field is a string; object forms return 400),
|
|
890
|
-
* and guarantee a leading system message.
|
|
891
|
-
*/
|
|
892
886
|
/**
|
|
893
887
|
* The model id a prepared body asks for, when it carries one (issue #61).
|
|
894
888
|
*
|
|
@@ -898,6 +892,23 @@ declare function knownRegionOf(domain: string): WorkBuddyRegion | undefined;
|
|
|
898
892
|
* measurement that this id is needed for.
|
|
899
893
|
*/
|
|
900
894
|
declare function modelIdOfBody(bodyJson: string): string | undefined;
|
|
895
|
+
/**
|
|
896
|
+
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream: force
|
|
897
|
+
* `stream: true` (the upstream rejects non-streaming), flatten `tool_choice`
|
|
898
|
+
* (the upstream's field is a string; object forms return 400), rewrite
|
|
899
|
+
* `developer` to `system`, guarantee a leading system message, and move a
|
|
900
|
+
* recognized third-party-agent system prompt off the gateways' content
|
|
901
|
+
* fingerprint.
|
|
902
|
+
*
|
|
903
|
+
* The last step is not cosmetic: both gateways refuse a request whose `system`
|
|
904
|
+
* message opens with one of the openings listed at
|
|
905
|
+
* {@link UNAPPROVED_CHANNEL_OPENINGS} — business code 11128, surfaced to the
|
|
906
|
+
* user as a "渠道校验" (channel verification) failure — which made every CN step
|
|
907
|
+
* of a DSH session fail while a two-message probe on the same account returned
|
|
908
|
+
* 200. Every rewrite in here is forced by a gateway rule, so a body the
|
|
909
|
+
* upstream already answers keeps its prompt as close to byte-for-byte as these
|
|
910
|
+
* rules allow.
|
|
911
|
+
*/
|
|
901
912
|
declare function prepareChatBody(source: string): string;
|
|
902
913
|
/** Marker carried by {@link WorkBuddyCredentialRejectedError}; survives bundling. */
|
|
903
914
|
declare const CREDENTIAL_REJECTED_CODE = "WORKBUDDY_CREDENTIAL_REJECTED";
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as WORKBUDDY_APP_EXECUTABLE_ENV, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as lastRefusalFor, K as REFUSES_OFF_MODEL_IDS, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as withEffectiveOff, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as offRefused, Y as offDefaultFor, Z as recordRefusal, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as macosBundleExecutable, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as readAtRestKey, et as clearAtRestKeyCache, f as WorkBuddyCatalog, ft as workbuddyAppExecutableCandidates, g as ENCRYPTED_CREDENTIAL_CODE, gt as writeFileAtomic, h as fallbackModelsFor, ht as withFileLock, i as processStartTimeMs, it as findWorkbuddyAppExecutable, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as macosNestedAppBundles, m as deriveCatalog, mt as resolveDshHome, n as clearHostHeartbeat, nt as deriveAtRestKeyId, o as workbuddyHostHeartbeatPath, ot as isEncryptedFieldWrapper, p as applyContextBudgets, q as defaultOffModelIds, r as isHeartbeatProcessAlive, rt as fetchAtRestKeyPayload, s as writeHostHeartbeat, st as isWorkbuddyBundle, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as deriveAtRestKey, u as FALLBACK_WORKBUDDY_MODELS, ut as openEncryptedField, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-
|
|
1
|
+
import { $ as WORKBUDDY_APP_EXECUTABLE_ENV, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as lastRefusalFor, K as REFUSES_OFF_MODEL_IDS, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as withEffectiveOff, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as offRefused, Y as offDefaultFor, Z as recordRefusal, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as macosBundleExecutable, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as readAtRestKey, et as clearAtRestKeyCache, f as WorkBuddyCatalog, ft as workbuddyAppExecutableCandidates, g as ENCRYPTED_CREDENTIAL_CODE, gt as writeFileAtomic, h as fallbackModelsFor, ht as withFileLock, i as processStartTimeMs, it as findWorkbuddyAppExecutable, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as macosNestedAppBundles, m as deriveCatalog, mt as resolveDshHome, n as clearHostHeartbeat, nt as deriveAtRestKeyId, o as workbuddyHostHeartbeatPath, ot as isEncryptedFieldWrapper, p as applyContextBudgets, q as defaultOffModelIds, r as isHeartbeatProcessAlive, rt as fetchAtRestKeyPayload, s as writeHostHeartbeat, st as isWorkbuddyBundle, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as deriveAtRestKey, u as FALLBACK_WORKBUDDY_MODELS, ut as openEncryptedField, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-CxoHaypp.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -2213,18 +2213,33 @@ const KIND_STATUS = {
|
|
|
2213
2213
|
* and cross-provider failover cannot help a policy decision either. The code the
|
|
2214
2214
|
* host receives therefore names the class honestly.
|
|
2215
2215
|
*
|
|
2216
|
-
* `model_busy`
|
|
2217
|
-
*
|
|
2218
|
-
*
|
|
2219
|
-
*
|
|
2220
|
-
*
|
|
2221
|
-
*
|
|
2222
|
-
*
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2216
|
+
* `model_busy` used to get the same `quota_exceeded` translation, for what
|
|
2217
|
+
* looked like the same reason, and that was WRONG in a way users reported
|
|
2218
|
+
* (issue #84). The reasoning above assumed `quota_exceeded` only influenced
|
|
2219
|
+
* retryability. It does more than that: `isQuotaExceededError` is matched
|
|
2220
|
+
* against `type + " " + code + " " + message`, the match yields host code
|
|
2221
|
+
* `QUOTA`, and the chat client hard-codes its copy for `QUOTA`/`ACCOUNT_QUOTA`
|
|
2222
|
+
* — substituting 「当前请求的额度已用尽」 and DISCARDING this plugin's message.
|
|
2223
|
+
* So a loaded model was shown to the user as an exhausted balance: an
|
|
2224
|
+
* actionable, transient condition rendered as a terminal, account-level one.
|
|
2225
|
+
*
|
|
2226
|
+
* Leaving `model_busy` untranslated is the correct spelling, and it was
|
|
2227
|
+
* verified end to end rather than reasoned about: driving pi-ai against a
|
|
2228
|
+
* server replaying this shim's real bodies, `code: 'model_busy'` classifies as
|
|
2229
|
+
* `RATE_LIMIT` — which (a) is outside the substitution list, so the plugin's
|
|
2230
|
+
* own honest sentence is what the user reads, and (b) IS in the host's
|
|
2231
|
+
* in-place retry set, so `dsh-llm-retry` performs a bounded retry with the
|
|
2232
|
+
* default 500 ms→10 s backoff instead of failing the turn on one 14003. That
|
|
2233
|
+
* retry is what makes "the model is momentarily busy" recoverable, which is
|
|
2234
|
+
* exactly what a 14003 is: measured against the same credential and second,
|
|
2235
|
+
* five consecutive sends gave 200/200/200/200/429.
|
|
2236
|
+
*
|
|
2237
|
+
* `soft_rate` keeps the translation, and the contrast is the whole point: its
|
|
2238
|
+
* reset is hours away, so the retry budget cannot produce an answer and must
|
|
2239
|
+
* not be spent. One word, two opposite temporal claims — so the spelling
|
|
2240
|
+
* follows the clock, not the status code both share.
|
|
2241
|
+
*/
|
|
2242
|
+
const KIND_HOST_CODE = { soft_rate: "quota_exceeded" };
|
|
2228
2243
|
/**
|
|
2229
2244
|
* The `code` to write for an upstream failure, given its classified kind.
|
|
2230
2245
|
*
|
|
@@ -3250,7 +3265,7 @@ function createWorkBuddyShim(options) {
|
|
|
3250
3265
|
const meta = ["模型当前负载较高"];
|
|
3251
3266
|
if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
|
|
3252
3267
|
if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
|
|
3253
|
-
const message = `${lead}(${meta.join(",")})${note}
|
|
3268
|
+
const message = `${lead}(${meta.join(",")})${note}。这不是账号被限流,也与积分、额度无关——同一凭据下换一个模型即可正常使用;插件不会为此切换账号,因为该限制跟随模型本身。这类失败会被自动重试几次;若仍然失败,请等待约 1 分钟后重试,或切换到负载较低的模型`;
|
|
3254
3269
|
writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
|
|
3255
3270
|
return;
|
|
3256
3271
|
}
|
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.
|
|
5
|
+
"version": "4.13.0",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|