dsh-connect-workbuddy 4.11.0 → 4.12.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 CHANGED
@@ -1,24 +1,30 @@
1
1
  # Changelog
2
2
 
3
- ## 4.10.1 (2026-10-20)
3
+ ## 4.12.0 (2026-10-20)
4
4
 
5
- > 版本号是 **patch**:没有接口、配置项或请求结构的变更,发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。**但它是 issue #77 的正解**——4.9.3 起的 `detail=fast` 只去掉了上游调用,真正吃掉 60 秒的本地开销一直没动;本版把每次打开设置页的 `reg query` 子进程从约 **135 个**降到 **3 个以内**。
5
+ > 版本号是 **minor**:有用户可见的行为变更(模型繁忙不再显示成「额度已用尽」,且会被宿主原地重试几次)。发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。
6
6
 
7
7
  ### Fixes
8
8
 
9
- - **issue #77(第二轮):字段密钥的探测按「失败也记住」缓存,一次打开页面不再逐文件重跑注册表探测**
10
- - **报告与归因**: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。
11
- - **根因**:`readAtRestKey()` 只缓存**成功**。当凭据字段是 `$wbEncrypted` 而桌面端又不在候选目录里时,每次探测都会重跑整套发现;`scanAll()` 是**逐文件**调用的,一次打开页面又要读多次,两个乘数叠在一起。
12
- - **第二个乘数**:`SCAN_SHARE_WINDOW_MS` 从扫描的**开始**时刻计窗——比窗口更慢的那次扫描**在完成时就已经过期**,永远无法服务它之后的读取。现在从**完成**时刻计窗,并单独保留 `invalidateScan()` 供写入路径强制刷新(写完再读不会看到旧列表)。
13
- - **两处顺手的去重**:`findWorkbuddyAppExecutableWithSource()` 会在默认布局 → 注册表 → `findWorkbuddyAppExecutable()` 的回落链里对同一个答案问两遍注册表,现在只问一遍;失败结果带上过期时间缓存,所以「先不装、后装 App」的用户**不需要重启**就能被看见。
14
- - **代价**:第一次真正的读取仍付一次探测的钱(3 次 `reg`),此后每一轮读取都是 0。
9
+ - **issue #84:模型繁忙(14003)在聊天窗口被显示成「当前请求的额度已用尽」,并因此一次就失败**
10
+ - **报告**:issue #84 的评论里先有「国际版用deepseek模型会提示额度不足,step模型可以正常调用。但是我用workbuddy国际版正常使用deepseek免费模型」,随后又被同一线程的第二位报告者(zhangxinhang)收窄到真正的缺陷:**DeepSeek 用的人太多,经常 `model_busy`,而插件对于 `model_busy`,聊天窗口似乎会显示为「当前请求的额度已用尽」**;并请求「对于这种模型繁忙,是否可以进行适当次数的重试」。两条都要修,且它们是同一个原因的两个后果。
11
+ - **根因是一行映射,不是额度**:`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**——「当前请求的额度已用尽」其实**不是插件写的**,是宿主替插件写的。
12
+ - **后果比措辞严重**:一个**暂时**、**与账号无关**、**换模型即可绕开**的状况,被渲染成一个**终局**、**账号级**的结论;同时 `QUOTA` 不在宿主的重试集合里(`DEFAULT_RETRYABLE_CODES` = `EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT`),所以一次 14003 就直接结束这一轮——正是报告者要求补救的那件事。
13
+ - **修法:不再翻译 `model_busy`。** 不翻译之后同一份 wire body 的文本里只剩 `429`,宿主读作 `RATE_LIMIT`,于是一举两得:**(a)** `RATE_LIMIT` 不在 `failureMessage()` 的替换名单里,插件自己那句诚实的说明原样显示给用户;**(b)** `RATE_LIMIT` **在**宿主的默认重试集合里,`dsh-llm-retry` 按默认的 500 ms→10 s 指数退避**原地重试**——报告者要的「适当次数的重试」由宿主自己的机制完成,插件不新增重试循环,也不引入无界重试。
14
+ - **`soft_rate` 保留翻译,这正是关键对比**:它的 6004 重置时刻在**几小时后**,重试预算换不出答案,所以必须继续挡在重试集合外。**同一个状态码 429,两种相反的时间判断**——所以拼写跟的是**时钟**,而不是两者共用的状态码。
15
+ - **文案跟着改**:`model_busy` 的回复现在明确写出「这与积分、额度无关」,并说明「这类失败会被自动重试几次」。不加这两句,用户读到的「建议等待约 1 分钟」会让一个已经被重试过五次的请求看起来像插件什么都没做。
16
+ - **不是推断,是端到端验过**:用本地 `node:http` 服务器重放这个 shim 真实产出的错误体,驱动**真实的 pi-ai `stream()`**(宿主用的就是它)拿到 `errorMessage`,再跑**宿主自己的** `isQuotaExceededError()`。改前 `model_busy` 判 `QUOTA`,改后判 `RATE_LIMIT`;`hard_credit`(报文里真有 `Credits exhausted`)两条路径都仍是 `QUOTA`——真花光积分时宿主仍显示它的终局文案,这个修复没有把真额度问题一起放过。
17
+ - **同时确认不是这个原因的两件事**(都已实测排除,记在这里省下后来人重走):**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 时有时无、非单调,纯属瞬时繁忙。
18
+ - **一个仍存在的相邻陷阱(本轮未改,如实登记)**:国际版 `/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,需要单独一轮评估,因此不在本版顺手改。
15
19
 
16
20
  ### Tests
17
21
 
18
- - **`tests/issue77-at-rest-key-cache.spec.ts`:用「子进程次数」钉住,不靠计时措辞**
19
- - `process.platform` 影子成 `win32` + `execFileSync` 在模块边界打桩,于是**任何宿主(含 CI 的 macOS/Linux)**都会真的走进 Windows 分支并把 `reg` 调用数出来:一次探测恰好 3 次、5 个加密凭据文件的一轮读取(`accounts` → `status` → `current` → `accounts`)仍是 3 次、失败结果被记住、过期后重新探测。
20
- - **变异验证**:把修复回退后这两条断言分别报 **15 ≠ 3** 与 **9 ≠ 3**;三个用例在修复后全绿。写入后再读的用例在前修复/后修复两种状态下都绿——它守的是「共享窗口不得变成 TTL」,是回归护栏而非修复证据。
21
- - 文件里的凭据夹具用**真实的加密字段形状** `{$wbEncrypted: 1, envelope: "<base64>"}`;写成嵌套对象会被 `isEncryptedFieldWrapper()` 判为「未加密」,让整条用例静默走错分支(写测试时踩到过,已在夹具注释里记下)。
22
+ - **`tests/issue84-model-busy-not-quota.spec.ts`:钉「宿主实际看到的那段文本」,而不是「映射表里没有那个键」**
23
+ - 关键断言不是实现细节,而是宿主的判定:测试用**真实的 pi-ai `stream()`**(宿主用的同一个客户端)打到**真实的 shim**(真的开 socket),把 pi-ai 产出的 `errorMessage` 交给**宿主自己的** `isQuotaExceededError()`。重新实现一份分类器只能证明两份正则今天一致;而决定文案的是宿主那份。
24
+ - 四条用例覆盖两种失败的两侧:`model_busy` **不是** quota 且文案里带「与积分、额度无关」;`hard_credit`(真实 14018 报文)**仍是** quota;`soft_rate`(真实 6004 报文)**仍是** quota 且保留上游的重置时刻——「不该改的那半边别一起扫掉」。
25
+ - **重试那条断言读的是 adapter 自己发布的策略**(`config.profiles()` 的 `retryPolicy`,也就是宿主经 `providerRetryPolicy` 读到的同一个对象),不是 `resolveRetryPolicy(undefined)` 的默认值:后者在 adapter 干脆不再发布策略时**依然会绿**。断言含 `RATE_LIMIT` 在内、`QUOTA` 不在内、`maxRetries` 有界(>0 且 ≤10,即「适当次数」而不是无界循环)。
26
+ - **变异验证**:把 `model_busy: 'quota_exceeded'` 加回 `KIND_HOST_CODE`,`issue84` 的第一条与 `shim-failover` 的对应用例**当场变红**;恢复后全绿。`shim.spec.ts` 的「只有 soft_rate 需要翻译」那条清单把 `model_busy` 也收了进去,且注释写明它会红在哪。
27
+ - **改了两条既有断言**:`tests/shim.spec.ts`(`model_busy` 现在也属于「按自己的名字拼写」那一组)与 `tests/shim-failover.spec.ts`(`"code":"quota_exceeded"` → `"code":"model_busy"`,并新增 `not.toContain('quota_exceeded')`)。两条都是原先在**钉住缺陷**的断言,不是回归。
22
28
 
23
29
  ## 4.11.0 (2026-10-20)
24
30
 
@@ -52,6 +58,26 @@
52
58
  - **`tests/upstream-error-copy.spec.ts` 加了 4 条**:11133 且 `param` 指向档位时点名档位与处置;`param` 为空时**不**归因到档位;拒绝被上报(含被请求命名的 `modelId`,不是账号别名);非参数类失败不上报。
53
59
  - **变异验证**:单独回退「层级映射墙」→ 1 条红;单独回退「adapter 名单判断」→ 2 条红;单独回退「显式默认档位」→ profile 那 2 条红。撤回其中任一层都有测试当场报错。全量 1536 passed / 2 skipped,`pnpm run typecheck` 与 `pnpm run build` 均干净。
54
60
 
61
+ ## 4.10.1 (2026-10-20)
62
+
63
+ > 版本号是 **patch**:没有接口、配置项或请求结构的变更,发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。**但它是 issue #77 的正解**——4.9.3 起的 `detail=fast` 只去掉了上游调用,真正吃掉 60 秒的本地开销一直没动;本版把每次打开设置页的 `reg query` 子进程从约 **135 个**降到 **3 个以内**。
64
+
65
+ ### Fixes
66
+
67
+ - **issue #77(第二轮):字段密钥的探测按「失败也记住」缓存,一次打开页面不再逐文件重跑注册表探测**
68
+ - **报告与归因**: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。
69
+ - **根因**:`readAtRestKey()` 只缓存**成功**。当凭据字段是 `$wbEncrypted` 而桌面端又不在候选目录里时,每次探测都会重跑整套发现;`scanAll()` 是**逐文件**调用的,一次打开页面又要读多次,两个乘数叠在一起。
70
+ - **第二个乘数**:`SCAN_SHARE_WINDOW_MS` 从扫描的**开始**时刻计窗——比窗口更慢的那次扫描**在完成时就已经过期**,永远无法服务它之后的读取。现在从**完成**时刻计窗,并单独保留 `invalidateScan()` 供写入路径强制刷新(写完再读不会看到旧列表)。
71
+ - **两处顺手的去重**:`findWorkbuddyAppExecutableWithSource()` 会在默认布局 → 注册表 → `findWorkbuddyAppExecutable()` 的回落链里对同一个答案问两遍注册表,现在只问一遍;失败结果带上过期时间缓存,所以「先不装、后装 App」的用户**不需要重启**就能被看见。
72
+ - **代价**:第一次真正的读取仍付一次探测的钱(3 次 `reg`),此后每一轮读取都是 0。
73
+
74
+ ### Tests
75
+
76
+ - **`tests/issue77-at-rest-key-cache.spec.ts`:用「子进程次数」钉住,不靠计时措辞**
77
+ - `process.platform` 影子成 `win32` + `execFileSync` 在模块边界打桩,于是**任何宿主(含 CI 的 macOS/Linux)**都会真的走进 Windows 分支并把 `reg` 调用数出来:一次探测恰好 3 次、5 个加密凭据文件的一轮读取(`accounts` → `status` → `current` → `accounts`)仍是 3 次、失败结果被记住、过期后重新探测。
78
+ - **变异验证**:把修复回退后这两条断言分别报 **15 ≠ 3** 与 **9 ≠ 3**;三个用例在修复后全绿。写入后再读的用例在前修复/后修复两种状态下都绿——它守的是「共享窗口不得变成 TTL」,是回归护栏而非修复证据。
79
+ - 文件里的凭据夹具用**真实的加密字段形状** `{$wbEncrypted: 1, envelope: "<base64>"}`;写成嵌套对象会被 `isEncryptedFieldWrapper()` 判为「未加密」,让整条用例静默走错分支(写测试时踩到过,已在夹具注释里记下)。
80
+
55
81
  ## 4.10.0 (2026-10-19)
56
82
 
57
83
  > 版本号是 **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-BrIEnor4.js";
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-DEutghDy.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
@@ -4337,7 +4337,7 @@ function generationOf(version) {
4337
4337
  //#endregion
4338
4338
  //#region src/version.ts
4339
4339
  /** The npm package version this build was produced from. */
4340
- const WORKBUDDY_CONNECT_VERSION = "4.11.0";
4340
+ const WORKBUDDY_CONNECT_VERSION = "4.12.0";
4341
4341
  //#endregion
4342
4342
  //#region src/host-heartbeat.ts
4343
4343
  /**
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-BrIEnor4.js";
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-DEutghDy.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` gets the same `quota_exceeded` translation as `soft_rate`, and
2217
- * for a sharper version of the same reason: it arrives as a 429, and the host's
2218
- * in-place retry set is exactly where a request that is about to be refused
2219
- * again must not go. A model that is loaded for everyone will not clear inside
2220
- * one turn's backoff, so spending five attempts on it delays the honest answer
2221
- * without ever producing one. `type` still says `model_busy`, which is what a
2222
- * human reads and what routes the wording below.
2223
- */
2224
- const KIND_HOST_CODE = {
2225
- soft_rate: "quota_exceeded",
2226
- model_busy: "quota_exceeded"
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}。这不是账号被限流——同一凭据下换一个模型即可正常使用;插件不会为此切换账号,因为该限制跟随模型本身。建议等待约 1 分钟后重试,或切换到负载较低的模型`;
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.11.0",
5
+ "version": "4.12.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",