dsh-connect-workbuddy 4.10.1 → 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,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.12.0 (2026-10-20)
4
+
5
+ > 版本号是 **minor**:有用户可见的行为变更(模型繁忙不再显示成「额度已用尽」,且会被宿主原地重试几次)。发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。
6
+
7
+ ### Fixes
8
+
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,需要单独一轮评估,因此不在本版顺手改。
19
+
20
+ ### Tests
21
+
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')`)。两条都是原先在**钉住缺陷**的断言,不是回归。
28
+
29
+ ## 4.11.0 (2026-10-20)
30
+
31
+ > 版本号是 **minor**:有行为变更(对实测拒绝 `off` 的那 9 个模型,卡片上的「可关闭思考」变为不可勾选)。发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。
32
+
33
+ ### Fixes
34
+
35
+ - **issue #83:给「实测拒绝 off」的模型勾上「可关闭思考」后,该模型的每一次 `/compact` 必定 400 / 11133**
36
+ - **报告链路**:用户在卡片上给 `deepseek-v4.1-flash` 之类**实测拒绝 `off`** 的模型勾了「可关闭思考」→ `withEffectiveOff()` 用保存的勾选覆盖 `canDisableThinking` → `workBuddyThinkingLevelMap()` 把 `off` 映成 `"off"` → pi-ai 的 `openai-completions` 在**调用方没传 `reasoningEffort`** 时用 `model.thinkingLevelMap?.off` 兜底 → 压缩摘要请求(`summarizeWithLlm`)恰好**不传**档位 → 每次 `/compact` 都带 `reasoning_effort:"off"` → 两个网关都回 400 / 11133,界面只说 `Compaction could not produce a useful summary`。
37
+ - **多写出来的那一条**:`REFUSES_OFF_MODEL_IDS` 一直被当作「种子」而不是「墙」,3.5.0 的说明写着「用户勾选即生效」。但它与同一条 changelog 里写下的取舍原则相反:**提供一个必定失败的档位,代价是该模型每一次请求;误藏一个本来可用的档位,代价只是一个选项**。而且这次失败的不只是「某个请求」——pi-ai 会把这个档位当成**所有未指定档位请求的默认值**。
38
+ - **三道防线(独立失效点,各自有测试)**:
39
+ 1. **层级映射墙**:`withEffectiveOff()` 对名单内 id 恒置 `canDisableThinking: false`(`offRefused()` 与 `offDefaultFor()` 同口径),`workBuddyThinkingLevelMap()` 再查一次名单,所以 `off` 根本不会被提供,也不依赖调用方先盖章。
40
+ 2. **请求体兜底**:`prepareChatBody()` 新增 `dropRefusedReasoning()`——名单内模型的 `reasoning_effort: "off"` 在发往上游前被摘掉。这一层守的是**目录建好之后才变**的情形:账号池换号会把 body 指向另一个账号的模型,`primary-model` 还是个由网关解析的别名。
41
+ 3. **显式默认档位**:`dsh-llm-pi-ai` 按 `options.reasoningEffort ?? profile.reasoning` 解析档位,此前 `profile.reasoning` 是 `undefined`,等于把默认值交给 pi-ai 的层级映射——也就是交给那个 `off`。现在 profile 显式声明 `reasoning: 'high'`(`WORKBUDDY_DEFAULT_EFFORT`),所有注册键一致。
42
+ - **只摘 `off`**:其他档位在 issue #34 的实测里全部 200;body 里**本来就没有**档位时不补任何值——插件不替模型决定该怎么思考。
43
+ - **列表外模型不受影响**:名单按**精确 id** 匹配,`grok-4.7` 这类未记录的模型仍然由用户勾选与自身声明决定,修复不会顺手拿走一个本来能用的档位。
44
+ - **错误信息可诊断**:`parseUpstreamErrorDetail()` 现在读 `extError.param` / `extError.value`;当被拒参数与推理档位有关时,回复直接点名「上游拒绝了推理档位「off」(reasoning_effort)」并给出「取消可关闭思考勾选」的处置建议,而不是只说「请求被上游拒绝」。`param` 为空时(issue #78 的那类 body)**不猜**,原有文案原样保留。
45
+ - **观测记录**:`reportRefusal` 回调把「某模型确实被拒了 off」记进一个有界环形缓冲(20 条,进程内、不落盘)。这是压缩路径唯一能把事实送到界面上的通路——它只把失败当布尔值,正文被丢掉。
46
+
47
+ ### Features
48
+
49
+ - **卡片上如实标注拒绝 `off` 的行**:名单内模型(以及进程内实测被拒的模型)的「可关闭思考」显示为**勾选但禁用**,旁边多一枚 `拒绝 off` 标记,tooltip 说明「实测两个网关都回 400 / code 11133,勾选后所有未指定档位的请求(尤其压缩摘要)都会失败」。**不禁用、不隐藏**这个勾选框:旧版本可能已经把勾存下来了,藏起来等于把用户保存的状态和失败原因一起藏掉。判定值 `offRefused` / `refusesOff` 由宿主给出,卡片不自行推断。
50
+
51
+ ### Tests
52
+
53
+ - **`tests/issue83-off-refusal.spec.ts`:钉请求体,而不是钉函数返回值**
54
+ - 用**真实 pi-ai `openai-completions`** 经**真实 adapter** 构建请求,注入 `fetch` 捕获 body,再断言 `reasoning_effort` 不在其中——「盖了章」不等于「没发出去」,只有 body 能证明。模型描述符取自 adapter 自己的目录(声明 `canDisableThinking: true`,与上游对这 9 个 id 的声明一致),所以手写描述符绕过的 profile 级修复在这里藏不住。
55
+ - 另外三条守 `prepareChatBody`:名单内摘除、名单外保留、其他档位一格不动、原本没有档位就不补。
56
+ - **`tests/issue83-profile-default.spec.ts`**:profile 的默认档位是 `'high'` 而不是 `undefined`/`'off'`,且**每一个注册键**都一致(issue #75 的身份键也算)。
57
+ - **`tests/off-thinking.spec.ts` / `off-thinking-runtime.spec.ts` / `off-thinking-card.spec.tsx` 改了三条断言**:此前它们断言「用户勾选优先于名单」,而那正是 issue #83 的成因。现在改为断言墙生效、名单外仍然尊重勾选、以及卡片对宿主标记的行渲染成「勾选但禁用 + 有标记」。
58
+ - **`tests/upstream-error-copy.spec.ts` 加了 4 条**:11133 且 `param` 指向档位时点名档位与处置;`param` 为空时**不**归因到档位;拒绝被上报(含被请求命名的 `modelId`,不是账号别名);非参数类失败不上报。
59
+ - **变异验证**:单独回退「层级映射墙」→ 1 条红;单独回退「adapter 名单判断」→ 2 条红;单独回退「显式默认档位」→ profile 那 2 条红。撤回其中任一层都有测试当场报错。全量 1536 passed / 2 skipped,`pnpm run typecheck` 与 `pnpm run build` 均干净。
60
+
3
61
  ## 4.10.1 (2026-10-20)
4
62
 
5
63
  > 版本号是 **patch**:没有接口、配置项或请求结构的变更,发往上游的请求体、路由 id、模型 id、凭据、计费与换号规则一律未变。**但它是 issue #77 的正解**——4.9.3 起的 `detail=fast` 只去掉了上游调用,真正吃掉 60 秒的本地开销一直没动;本版把每次打开设置页的 `reg query` 子进程从约 **135 个**降到 **3 个以内**。
package/README.en.md CHANGED
@@ -24,7 +24,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
24
24
  - **Dual parallel providers (CN + international)** — the CN side registers as the `workbuddy` provider (`GLM-5.3`, `DeepSeek-V4-Pro`, `Kimi-K3`, `MiniMax-M3`, `Hy3`, etc.), the international side as `workbuddy-global` (`GPT-5.6`, `Gemini-3.5-Flash`, `GLM-5.3`, `Kimi-K3`, etc.). **A region's models appear in the DSH model picker when that region has an account**: with accounts on both sides, both rosters are selectable at once, and different sessions can each pick a side without interfering. **A region with no local sign-in lists no models** — it cannot serve a single request, so listing them would offer nothing but failures. Sign in once for that region and its models come back on "detect accounts again" or after a restart; the provider itself stays registered either way, because its settings card is how you sign in (re-detection lives in the pool block). Model names carry the upstream credit multiplier (e.g. `GLM-5.3 · x0.79`), matching WorkBuddy's own model menu.
25
25
  - **Tabbed settings card** — the card's top carries a "Domestic / Global" tab bar; each tab holds its own account pool and model management. Account, directory, selection, and unsaved drafts are fully isolated per tab — switching accounts or refreshing models on one tab never touches the other side's runtime catalog or sessions.
26
26
  - **Switch either provider off on its own** — each tab carries an on/off checkbox (both checked by default). Unchecking one **withdraws that provider from DSH's model picker entirely** (it is not merely a hidden tab): its adapter route and its "Settings → Models" directory entry are both pulled, and startup no longer sends pointless requests for it. Account, credits, directory, and selection are all kept, and re-checking restores everything. The typical use is "I have no use for the international side" — switch it off and keep the picker clean. Note: a session that had already selected a model from the switched-off provider will report `NO_ADAPTER` on its next call; the card states this plainly on the switched-off tab instead of failing silently.
27
- - **Model management** — refresh the full catalog from upstream and enable or disable each model individually; refresh is a draft operation that only takes effect on save. Upstream also reports credit multiplier, context/output limits, and reasoning efforts; **image input is decided by a vendor-verified table**: a refresh pre-checks only models the vendor's own documentation calls natively multimodal, leaves documented text-only models unchecked, and treats anything without a vendor source as unverified and unchecked rather than guessing. Upstream's `supportsImages` is a *platform* declaration, not the model's capability, so it does not drive that decision at all. You can still tick any box by hand, and your selection is kept locally. **Every model row also carries a “Can disable” (`off`) checkbox**: it decides whether that model gets the “turn thinking off entirely” level. The default comes from a reviewed built-in table (the 9 models that claim the capability but answer HTTP 400 for it start unticked), and you can change it per model — **untick** for a newly refusing upstream model, **tick** for one the table wrongly hides or you have verified works; your ticks are saved on first save and recomputed no further.
27
+ - **Model management** — refresh the full catalog from upstream and enable or disable each model individually; refresh is a draft operation that only takes effect on save. Upstream also reports credit multiplier, context/output limits, and reasoning efforts; **image input is decided by a vendor-verified table**: a refresh pre-checks only models the vendor's own documentation calls natively multimodal, leaves documented text-only models unchecked, and treats anything without a vendor source as unverified and unchecked rather than guessing. Upstream's `supportsImages` is a *platform* declaration, not the model's capability, so it does not drive that decision at all. You can still tick any box by hand, and your selection is kept locally. **Every model row also carries a “Can disable” (`off`) checkbox**: it decides whether that model gets the “turn thinking off entirely” level. The default comes from a reviewed built-in table (the 9 models that claim the capability but answer HTTP 400 for it start unticked), and you can change it per model — **untick** for a model you do not need the level on. **The 9 tabled models cannot be ticked at all** (their row is marked "refuses off" with an explanatory tooltip): the level is not merely an extra option — pi-ai uses it as the DEFAULT for every request that names no tier, and a compaction summary is exactly such a request, so one tick made every `/compact` for that model fail with HTTP 400 / code 11133 (issue #83). Models outside the table are still decided entirely by your selection and their own declaration; your ticks are saved on first save and recomputed no further.
28
28
  - **Model availability testing, including cooldown recovery times** — working out when a limited model frees up used to mean guessing through repeated failures. Now every model row has a **Test** button that gives you a direct answer for whether the model works right now and, if not, when it comes back. **It sends a real-volume request (about 25k input tokens)**, rather than "ping it and see if it answers" — because the upstream throttles on request **SIZE** (measured: about 20k tokens pass, about 30k are refused), so a small request looks fine while every request in a long conversation may be refused. That is exactly where "the test passes but I am limited" came from. **Where the recovery time comes from**: the upstream's 429 carries **no** `Retry-After` header (and no `X-RateLimit-*`), but it writes the reset time into the response **body** — `{"code":6004,"msg":"…将在 2026-09-30 02:30:30 UTC+8 重置…"}`. The plugin parses that time and converts it using the UTC offset the message states, so the result does not depend on your machine's timezone; the card then shows “✗ Rate limited · usable again after 2026/9/30 02:30:30”. When none of the sources states a time, the plugin **never invents a countdown** and says plainly that no time was given. A rate limit deliberately does **not** borrow the monthly refresh time, which would tell you to wait weeks for a limit that clears in seconds. Probes run in the Host, so **the credential never reaches the page**, and results are not written to settings — your saved selection is untouched.
29
29
 
30
30
  **Two things to know.** ① **It really does spend credits** — one measured probe reported `credit` 0.02 (a refused one 429s and costs almost nothing). So testing is **manual and per-model only**: there is deliberately no “Test selected” batch button, because sweeping the whole list from one click is an uncontrolled spend — and the Host enforces the same rule, **accepting a single model only** and refusing multi-model requests. ② The upstream advertises a 1M context yet throttles far below it, and the plugin advertises each model's **native** window to DSH (no longer clamped to 200K as of 3.5.0), so DSH compacts very late — hitting the throttle in a long session follows directly from that. To make DSH compact sooner, set a “DSH context budget” cap on the model's row (200K / 500K / the model's native window).
@@ -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
@@ -31,7 +31,7 @@
31
31
  - **双供应商并行接入(国内版 + 国际版)** —— 国内版注册为 `workbuddy` provider(`GLM-5.3`、`DeepSeek-V4-Pro`、`Kimi-K3`、`MiniMax-M3`、`Hy3` 等),国际版注册为 `workbuddy-global` provider(`GPT-5.6`、`Gemini-3.5-Flash`、`GLM-5.3`、`Kimi-K3` 等)。**有账号的区域其模型会出现在 DSH 模型选择器里**:两边都有账号时两组同时可选,不同会话可以各选一边,互不干扰。**本机没有账号的区域不列出任何模型**(该区域一个请求也发不出去,列出来只会点到就失败);只要在该区域登录一次,「重新检测账号」或重启后模型就会自动出现——provider 本身始终注册着,因为它的设置卡片正是登录的入口(重新检测在账号池区块里)。模型名内嵌上游积分倍率(如 `GLM-5.3 · x0.79`),与 WorkBuddy 自身模型菜单一致。
32
32
  - **插件卡片 tab 切换** —— 设置卡片顶部为「国内版 / 国际版」两个 tab,各含独立的账号池与模型管理;每个 tab 的账号、目录、勾选与未保存草稿完全隔离——在一个 tab 里切账号或刷新模型,不会触碰另一边的运行时目录与会话。
33
33
  - **可单独关闭任一版本供应商** —— 每个 tab 右侧有一个勾选框(默认都勾选)。**取消勾选即把该供应商从 DSH 模型选择器里彻底撤掉**(不是只藏起 tab):它的路由与「设置 → 模型」页的目录条目一并摘除,启动时也不再为它发无用请求。账号、积分、目录与勾选**全部保留**,重新勾选即完整恢复。典型用法是「我根本用不到国际版」——关掉它,让选择器干净。注意:若某会话此前选中的正是被关闭供应商的模型,该会话再调用会报 `NO_ADAPTER`,卡片会在关闭态的 tab 上明确提示这一点,不会静默失败。
34
- - **模型管理** —— 从上游刷新完整模型目录,逐项勾选启用或禁用;刷新是草稿操作,点保存才生效。上游同时给出积分倍率、上下文/输出上限与推理档位;**图片输入按厂商核验表判定**:刷新时只有厂商文档明确写为原生多模态的模型才自动勾选,文档写明纯文本的不勾,**没有厂商资料的一律按「未核实」不猜**(上游的 `supportsImages` 是平台侧声明,不等于模型能看图,不参与判定)。用户仍可随时手动勾选,勾选状态保存在本地。**每个模型行还有「可关闭思考」(`off`)勾选框**:决定要不要为该模型提供「完全关闭思考」这一档位。默认由内置复核名单给出(上游声称可关闭、实测却对它返回 HTTP 400 的 9 个模型默认不勾),你可以按模型改勾/取消——**取消**用于上游新出现的拒绝模型,**勾上**用于名单误伤、或你已确认可用的模型;首次保存即写入你的勾选,之后不再重算。
34
+ - **模型管理** —— 从上游刷新完整模型目录,逐项勾选启用或禁用;刷新是草稿操作,点保存才生效。上游同时给出积分倍率、上下文/输出上限与推理档位;**图片输入按厂商核验表判定**:刷新时只有厂商文档明确写为原生多模态的模型才自动勾选,文档写明纯文本的不勾,**没有厂商资料的一律按「未核实」不猜**(上游的 `supportsImages` 是平台侧声明,不等于模型能看图,不参与判定)。用户仍可随时手动勾选,勾选状态保存在本地。**每个模型行还有「可关闭思考」(`off`)勾选框**:决定要不要为该模型提供「完全关闭思考」这一档位。默认由内置复核名单给出(上游声称可关闭、实测却对它返回 HTTP 400 的 9 个模型默认不勾),你可以按模型改勾/取消——**取消**用于当前不需要该档位的模型;**名单内那 9 个模型不可勾选**(行上标「拒绝 off」,悬停有说明):该档位被勾上后不只是多一个选项,pi-ai 会把它当作**所有未指定档位请求的默认值**,压缩摘要请求正属于这一类,勾一次等于让该模型的每一次 `/compact` 都 400 / 11133(issue #83)。名单外的模型仍完全由你的勾选与自身声明决定;首次保存即写入你的勾选,之后不再重算。
35
35
  - **模型可用性测试(含冷却恢复时间)** —— 限量模型的冷却时间过去只能靠反复试错去猜。现在每个模型行有一个「测试」按钮,直接告诉你它此刻能否使用、不能用时何时恢复。**它发的是一条真实体积的请求(约 25k 输入 token)**,而不是「发个 ping 看通不通」——因为上游的限流**按请求体积触发**(实测约 20k 能过、约 30k 被拒),小请求看着正常、长对话里却可能每次都被拒,那正是「测试通过、实际被限」的来源。**恢复时间的来源**:上游的 429 **不带** `Retry-After` 响应头(也没有 `X-RateLimit-*`),但它把重置时间写在**响应体**里——`{"code":6004,"msg":"…将在 2026-09-30 02:30:30 UTC+8 重置…"}`。插件从响应体解析该时间(并按其声明的 UTC 偏移换算,不受你本机时区影响),因此卡片能直接显示「✗ 被限流 · 2026/9/30 02:30:30 之后可再用」。三处来源都没说时,插件**不编造倒计时**,如实显示「上游未给出何时恢复」。限流**不会**去借月周期包的刷新时间(那会让人为几秒的限流去等几周)。探测在后台发起,**凭据不会传到页面**;结果不写入设置,也不改变你已保存的勾选。
36
36
 
37
37
  **两点需要知道**:① **它真的花钱**——实测一次报 `credit` 0.02(被限流时直接 429,几乎不花)。因此**只能按单个模型手工点**,没有「测试已勾选」这类批量按钮:一次覆盖整个列表就是一次不可控支出(服务端也据此**只接受单个模型**,多模型请求会被拒)。② 上游虽宣称 1M 上下文,但节流线远低于此;插件按模型的**原生窗口**向 DSH 宣称(3.5.0 起不再默认压到 200K),所以 DSH 很晚才压缩对话——长会话撞限流是这条机制的直接结果。想让 DSH 更早压缩,就在模型行上手动设一个「DSH 上下文预算」上限(可选 200K / 500K / 模型原生窗口)。
@@ -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 { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-Cq4_pJ_l.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
package/lib/client.js CHANGED
@@ -3901,6 +3901,8 @@ window.__ModuleLoader__.load({
3901
3901
  .dsm-workbuddy-model-image input{margin:0;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
3902
3902
  .dsm-workbuddy-model-off{display:inline-flex;align-items:center;gap:5px;flex:none;cursor:pointer;color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:11px;line-height:16px}
3903
3903
  .dsm-workbuddy-model-off input{margin:0;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
3904
+ .dsm-workbuddy-model-off-blocked{cursor:not-allowed;opacity:.75}
3905
+ .dsm-workbuddy-model-off-flag{margin-left:2px;padding:0 4px;border-radius:4px;background:var(--dsw-alias-label-caution-background,#4a3a1f);color:var(--dsw-alias-label-caution,#e8b34b);font-size:10px;line-height:14px;white-space:nowrap}
3904
3906
  .dsm-workbuddy-model-copy{display:flex;align-items:baseline;gap:8px;min-width:0}
3905
3907
  .dsm-workbuddy-model-name{display:inline-flex;align-items:baseline;gap:7px;color:var(--dsw-alias-label-primary,#e6e6e6);font-size:13px;font-weight:500;line-height:19px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3906
3908
  .dsm-workbuddy-model-name-rate{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-weight:400;line-height:16px;flex:none}
@@ -5390,6 +5392,7 @@ window.__ModuleLoader__.load({
5390
5392
  * "reset" control: once an answer matches the rule, the two are
5391
5393
  * indistinguishable in effect, and the tooltip already names the override.
5392
5394
  */
5395
+ const offBlocked = (model) => model.reasoning?.offRefused === true || model.reasoning?.refusesOff === true;
5393
5396
  const toggleOff = (modelId) => {
5394
5397
  editDraft((current) => {
5395
5398
  const next = new Set(current.offIds);
@@ -5971,16 +5974,23 @@ window.__ModuleLoader__.load({
5971
5974
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelImage") })]
5972
5975
  }),
5973
5976
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5974
- className: "dsm-workbuddy-model-off",
5975
- title: t("row.modelOffHint"),
5976
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5977
- type: "checkbox",
5978
- checked: activeOffIds.has(model.id),
5979
- disabled: !canWrite || saving,
5980
- onChange: () => {
5981
- toggleOff(model.id);
5982
- }
5983
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelOff") })]
5977
+ className: `dsm-workbuddy-model-off${offBlocked(model) ? " dsm-workbuddy-model-off-blocked" : ""}`,
5978
+ title: offBlocked(model) ? t("row.modelOffRefusedHint") : t("row.modelOffHint"),
5979
+ children: [
5980
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5981
+ type: "checkbox",
5982
+ checked: activeOffIds.has(model.id),
5983
+ disabled: !canWrite || saving || offBlocked(model),
5984
+ onChange: () => {
5985
+ toggleOff(model.id);
5986
+ }
5987
+ }),
5988
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelOff") }),
5989
+ offBlocked(model) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5990
+ className: "dsm-workbuddy-model-off-flag",
5991
+ children: t("row.modelOffRefused")
5992
+ }) : null
5993
+ ]
5984
5994
  }),
5985
5995
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
5986
5996
  className: "dsm-workbuddy-context-budget",
@@ -7190,6 +7200,8 @@ window.__ModuleLoader__.load({
7190
7200
  "row.modelImageUnverified": "Image input unverified: no vendor source confirms this model reads images, so a refresh leaves it unchecked rather than guessing. Tick it yourself if you have verified it works.",
7191
7201
  "row.modelOff": "Can disable",
7192
7202
  "row.modelOffHint": "Offer the “off” level for this model",
7203
+ "row.modelOffRefused": "refuses off",
7204
+ "row.modelOffRefusedHint": "Measured to reject the “off” level with HTTP 400 (code 11133) on both gateways. With this ticked, every request that carries no reasoning tier — compaction summaries above all — would fail, so it cannot be enabled here.",
7193
7205
  "row.modelReasoning": "Reasoning: {efforts}",
7194
7206
  "row.modelUnknown": "Unknown",
7195
7207
  "row.modelCapabilityPending": "Only capabilities advertised by WorkBuddy are shown.",
@@ -7512,6 +7524,8 @@ window.__ModuleLoader__.load({
7512
7524
  "row.modelImageUnverified": "图片输入未核实:没有厂商资料确认该模型能读图,刷新时按「不猜」处理,不会自动勾选。如果你验证过确实可用,可以手动勾上。",
7513
7525
  "row.modelOff": "可关闭思考",
7514
7526
  "row.modelOffHint": "为这个模型提供「关闭思考」档位",
7527
+ "row.modelOffRefused": "拒绝 off",
7528
+ "row.modelOffRefusedHint": "实测在两个网关上都拒绝「关闭思考」档位(HTTP 400 / code 11133)。勾选后,所有未指定推理档位的请求(尤其是压缩摘要)都会失败,因此这里不能启用。",
7515
7529
  "row.modelReasoning": "推理强度:{efforts}",
7516
7530
  "row.modelUnknown": "未知",
7517
7531
  "row.modelCapabilityPending": "仅展示 WorkBuddy 接口明确公布的模型能力。",
@@ -962,6 +962,140 @@ function clearAtRestKeyCache() {
962
962
  cachedAppExecutable = void 0;
963
963
  }
964
964
  //#endregion
965
+ //#region src/off-thinking.ts
966
+ /**
967
+ * Ids observed to REFUSE `reasoning_effort: "off"` with HTTP 400 despite
968
+ * declaring `canDisableThinking: true` (issue #34).
969
+ *
970
+ * Keyed by exact model id and deliberately NOT by region: `deepseek-v4.1-flash`
971
+ * refuses on BOTH gateways, and the split tracks the model family rather than
972
+ * the gateway. The cost is asymmetric — offering a level that always fails
973
+ * costs the user every request for that model, while hiding one that would have
974
+ * worked costs a single option — so the table errs towards hiding, and
975
+ * {@link withEffectiveOff} enforces it as a HARD wall rather than a seed
976
+ * (issue #83: a tick used to reopen the failing level, and with no
977
+ * `reasoningEffort` passed the level became the default for compaction too).
978
+ * See {@link offRefused} for why the checkbox must not win.
979
+ *
980
+ * `deepseek-v4-pro` was measured on the CN gateway only; it is listed because
981
+ * its sibling does refuse on both, and the same asymmetry argues for hiding.
982
+ */
983
+ const REFUSES_OFF_MODEL_IDS = /* @__PURE__ */ new Set([
984
+ "deepseek-v4.1-flash",
985
+ "deepseek-v4.1-flash-sg",
986
+ "deepseek-v4-pro",
987
+ "primary-model",
988
+ "gpt-6-astra",
989
+ "gpt-5.6-sol",
990
+ "gpt-5.6-terra",
991
+ "gpt-5.6-luna",
992
+ "gemini-3.5-flash"
993
+ ]);
994
+ /**
995
+ * Whether `off` should be offered for a model when the user has expressed no
996
+ * opinion: the upstream's declaration, minus the ids known to refuse it.
997
+ *
998
+ * Only an explicit `canDisableThinking: true` enables the level; absence is
999
+ * "unknown" and stays unoffered, matching how the rest of the plugin treats an
1000
+ * undeclared capability.
1001
+ */
1002
+ function offDefaultFor(info) {
1003
+ return info.reasoning?.canDisableThinking === true && !REFUSES_OFF_MODEL_IDS.has(info.id);
1004
+ }
1005
+ /**
1006
+ * The model ids the `off` level should be offered for by default (issue #34).
1007
+ *
1008
+ * The user's checkbox is a plain set: ticked = offered, unticked = withdrawn.
1009
+ * This is the seed — what the set is filled with the first time it is read —
1010
+ * so a profile that has never saved shows the correct default immediately,
1011
+ * without requiring a save.
1012
+ *
1013
+ * Only an explicit `canDisableThinking: true` enables the level; absence is
1014
+ * "unknown" and stays unoffered, matching how the rest of the plugin treats an
1015
+ * undeclared capability.
1016
+ */
1017
+ function defaultOffModelIds(models) {
1018
+ return models.filter(offDefaultFor).map((model) => model.id);
1019
+ }
1020
+ /** How many observations are kept, newest first. Bounded so a refusal storm cannot grow memory. */
1021
+ const REFUSAL_RING = 20;
1022
+ /**
1023
+ * The refusals this process has observed, newest first.
1024
+ *
1025
+ * Process-scoped rather than persisted: it is EVIDENCE ("what did the upstream
1026
+ * actually say"), and a stale record from another install would promote a model
1027
+ * into the refusal table on the strength of a request the current build never
1028
+ * sent. The card reads it; nothing sends from it.
1029
+ */
1030
+ const observedRefusals = [];
1031
+ /** Record one refusal, keeping the newest {@link REFUSAL_RING}. */
1032
+ function recordRefusal(report) {
1033
+ observedRefusals.unshift(report);
1034
+ if (observedRefusals.length > REFUSAL_RING) observedRefusals.length = REFUSAL_RING;
1035
+ }
1036
+ /** The most recent refusal naming `modelId`, or `undefined` when none did. */
1037
+ function lastRefusalFor(modelId) {
1038
+ return observedRefusals.find((report) => report.modelId === modelId);
1039
+ }
1040
+ /**
1041
+ * Whether a model may offer `off` AT ALL, no matter what the user asked for.
1042
+ *
1043
+ * This is the hard wall in front of {@link REFUSES_OFF_MODEL_IDS} (issue #83).
1044
+ * The table above used to be a seed only: the checkbox wrote the selection
1045
+ * straight into `canDisableThinking`, so ticking a model known to refuse `off`
1046
+ * reopened exactly the failure the table exists to prevent — and not once per
1047
+ * "off" turn either. pi-ai's `openai-completions` reads
1048
+ * `model.thinkingLevelMap?.off` whenever the caller passes NO
1049
+ * `reasoningEffort` at all, and a compaction summary is such a caller: it never
1050
+ * carries an effort. So one tick made every `/compact` for that model answer
1051
+ * HTTP 400 / code 11133, with the surface showing only "could not produce a
1052
+ * useful summary".
1053
+ *
1054
+ * The asymmetry that writes the table also writes this test: a level that
1055
+ * always fails costs the user EVERY request for that model, while a level that
1056
+ * was hidden needlessly costs one option. So the refusal list wins over the
1057
+ * checkbox, not the other way round.
1058
+ *
1059
+ * The table is keyed by EXACT id, so anything that pins a different id at
1060
+ * request time (aliases such as `primary-model`) is out of its reach; those are
1061
+ * covered where the request is actually built — see
1062
+ * {@link normalizePreparedReasoning}.
1063
+ */
1064
+ function offRefused(modelId) {
1065
+ return REFUSES_OFF_MODEL_IDS.has(modelId);
1066
+ }
1067
+ /**
1068
+ * Stamp the user's saved `off` selection onto a model list, for the RUNTIME
1069
+ * catalog.
1070
+ *
1071
+ * The runtime model descriptor is built by `workBuddyThinkingLevelMap`, which
1072
+ * reads only the model's own declaration — so the corrected answer has to be
1073
+ * written onto the model before it reaches the adapter. Doing it here keeps the
1074
+ * adapter a pure "declared capability in, level map out" function and keeps the
1075
+ * table next to the rule it feeds.
1076
+ *
1077
+ * This OVERWRITES the declared field rather than adding a sibling, which is
1078
+ * safe because the declared value has exactly one consumer (that same level
1079
+ * map) and is never persisted where the card can see it: `toWebModel` drops
1080
+ * `canDisableThinking`, and the card's `lastCatalog` is written from the raw
1081
+ * upstream discovery, never from this stamped list. A model that declares no
1082
+ * reasoning is returned untouched — there is no level to correct.
1083
+ *
1084
+ * A refusal-table id is stamped `false` REGARDLESS of the selection
1085
+ * ({@link offRefused}): the tick stays in the saved set — the card shows it and
1086
+ * the hint explains it — but it can no longer be turned into a request that
1087
+ * fails.
1088
+ */
1089
+ function withEffectiveOff(models, selection) {
1090
+ return models.map((model) => model.reasoning === void 0 ? model : {
1091
+ ...model,
1092
+ reasoning: {
1093
+ ...model.reasoning,
1094
+ canDisableThinking: selection.has(model.id) && !offRefused(model.id)
1095
+ }
1096
+ });
1097
+ }
1098
+ //#endregion
965
1099
  //#region src/upstream.ts
966
1100
  /**
967
1101
  * WorkBuddy (CodeBuddy / copilot.tencent.com) upstream client: chat streaming,
@@ -1218,6 +1352,14 @@ function parseUpstreamErrorDetail(body) {
1218
1352
  if (typeof raw.requestId === "string") detail.requestId = raw.requestId;
1219
1353
  if (displayMsg !== void 0) detail.displayMsg = displayMsg;
1220
1354
  if (tips !== void 0) detail.tips = tips;
1355
+ const ext = raw.extError;
1356
+ if (typeof ext === "object" && ext !== null && !Array.isArray(ext)) {
1357
+ const extRaw = ext;
1358
+ const param = extRaw["param"];
1359
+ if (typeof param === "string" && param !== "") detail.rejectedParam = param;
1360
+ const value = extRaw["value"];
1361
+ if (typeof value === "string" && value !== "") detail.rejectedTier = value;
1362
+ }
1221
1363
  return detail;
1222
1364
  }
1223
1365
  /**
@@ -1477,8 +1619,36 @@ function prepareChatBody(source) {
1477
1619
  }
1478
1620
  ensureSystemHead(obj);
1479
1621
  normalizeToolChoice(obj);
1622
+ dropRefusedReasoning(obj);
1480
1623
  return JSON.stringify(obj);
1481
1624
  }
1625
+ /**
1626
+ * Remove `reasoning_effort: "off"` when it would only ever fail (issue #83).
1627
+ *
1628
+ * The refusal table is keyed by exact model id, but the model a request
1629
+ * actually names can change AFTER the catalog was built: account-pool failover
1630
+ * re-points the body at another account's model, and `primary-model` is an
1631
+ * alias whose concrete id the gateway resolves. A tick on such a row therefore
1632
+ * reaches this plugin's prepared body as a bare `"off"` — exactly the value the
1633
+ * table says the upstream answers with HTTP 400 / code 11133.
1634
+ *
1635
+ * Stripped HERE because this is the last thing the plugin controls before the
1636
+ * upstream counts the body, and because the test is {@link offRefused} — the
1637
+ * same single source of truth the catalog stamping uses, so the two cannot
1638
+ * drift apart.
1639
+ *
1640
+ * Only the literal `"off"` is touched. Every other effort value returned 200 on
1641
+ * every model measured for issue #34, and a body naming no effort is left
1642
+ * alone: deleting a field the caller never sent would be inventing an opinion
1643
+ * about how the model should think.
1644
+ */
1645
+ function dropRefusedReasoning(obj) {
1646
+ const effort = obj["reasoning_effort"];
1647
+ if (typeof effort !== "string" || effort.trim().toLowerCase() !== "off") return;
1648
+ const model = obj["model"];
1649
+ if (typeof model !== "string" || !offRefused(model)) return;
1650
+ delete obj["reasoning_effort"];
1651
+ }
1482
1652
  /** Rewrite OpenAI `tool_choice` spellings into the upstream's string form. */
1483
1653
  function normalizeToolChoice(obj) {
1484
1654
  const suppress = () => {
@@ -4167,7 +4337,7 @@ function generationOf(version) {
4167
4337
  //#endregion
4168
4338
  //#region src/version.ts
4169
4339
  /** The npm package version this build was produced from. */
4170
- const WORKBUDDY_CONNECT_VERSION = "4.10.1";
4340
+ const WORKBUDDY_CONNECT_VERSION = "4.12.0";
4171
4341
  //#endregion
4172
4342
  //#region src/host-heartbeat.ts
4173
4343
  /**
@@ -4325,4 +4495,4 @@ async function clearHostHeartbeat() {
4325
4495
  await rm(workbuddyHostHeartbeatPath(), { force: true });
4326
4496
  }
4327
4497
  //#endregion
4328
- export { isEncryptedFieldWrapper as $, workbuddyAccountId as A, modelIdOfBody as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, regionOf as G, parseReasoning as H, classifyUpstreamError as I, deriveAtRestKey as J, WORKBUDDY_APP_EXECUTABLE_ENV as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, findWorkbuddyAppExecutableWithSource as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, parseUpstreamModel as U, parseCreditMultiplier as V, prepareChatBody as W, fetchAtRestKeyPayload as X, deriveAtRestKeyId as Y, findWorkbuddyAppExecutable as Z, WORKBUDDY_AUTH_FILENAME as _, readHostHeartbeat as a, workbuddyAppExecutableCandidates as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, withFileLock as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, isWorkbuddyBundle as et, WorkBuddyCatalog as f, ENCRYPTED_CREDENTIAL_CODE as g, fallbackModelsFor as h, processStartTimeMs as i, readAtRestKey as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, writeFileAtomic as lt, deriveCatalog as m, clearHostHeartbeat as n, macosNestedAppBundles as nt, workbuddyHostHeartbeatPath as o, hostPeersInfo as ot, applyContextBudgets as p, clearAtRestKeyCache as q, isHeartbeatProcessAlive as r, openEncryptedField as rt, writeHostHeartbeat as s, resolveDshHome as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, macosBundleExecutable as tt, FALLBACK_WORKBUDDY_MODELS as u, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };
4498
+ export { WORKBUDDY_APP_EXECUTABLE_ENV as $, workbuddyAccountId as A, modelIdOfBody as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, regionOf as G, parseReasoning as H, classifyUpstreamError as I, lastRefusalFor as J, REFUSES_OFF_MODEL_IDS as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, withEffectiveOff as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, parseUpstreamModel as U, parseCreditMultiplier as V, prepareChatBody as W, offRefused as X, offDefaultFor as Y, recordRefusal as Z, WORKBUDDY_AUTH_FILENAME as _, readHostHeartbeat as a, findWorkbuddyAppExecutableWithSource as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, macosBundleExecutable as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, readAtRestKey as dt, clearAtRestKeyCache as et, WorkBuddyCatalog as f, workbuddyAppExecutableCandidates as ft, ENCRYPTED_CREDENTIAL_CODE as g, writeFileAtomic as gt, fallbackModelsFor as h, withFileLock as ht, processStartTimeMs as i, findWorkbuddyAppExecutable as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, macosNestedAppBundles as lt, deriveCatalog as m, resolveDshHome as mt, clearHostHeartbeat as n, deriveAtRestKeyId as nt, workbuddyHostHeartbeatPath as o, isEncryptedFieldWrapper as ot, applyContextBudgets as p, hostPeersInfo as pt, defaultOffModelIds as q, isHeartbeatProcessAlive as r, fetchAtRestKeyPayload as rt, writeHostHeartbeat as s, isWorkbuddyBundle as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, deriveAtRestKey as tt, FALLBACK_WORKBUDDY_MODELS as u, openEncryptedField as ut, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };
package/lib/index.d.ts CHANGED
@@ -615,6 +615,15 @@ interface WorkBuddyReasoning {
615
615
  supportedEfforts?: readonly string[];
616
616
  defaultEffort?: string;
617
617
  canDisableThinking?: boolean;
618
+ /**
619
+ * An OBSERVED refusal of the `off` tier for this model (issue #83).
620
+ *
621
+ * Never declared by the upstream: it is stamped from this plugin's own
622
+ * refusal record, and it outlives the catalog that witnessed it so the card
623
+ * can keep showing the badge after a refresh. `undefined` means "never
624
+ * observed", which is not the same claim as "accepts".
625
+ */
626
+ refusesOff?: boolean;
618
627
  }
619
628
  /** One CLI-usable model, carrying everything the plugin card displays. */
620
629
  interface WorkBuddyUpstreamModel {
@@ -825,6 +834,17 @@ interface UpstreamErrorDetail {
825
834
  * clause losing its own meaning.
826
835
  */
827
836
  tips?: string;
837
+ /**
838
+ * The request parameter the upstream named as rejected, from
839
+ * `extError.param` (issue #83).
840
+ *
841
+ * Kept because the generic sentences cannot say WHICH parameter was at fault:
842
+ * a 11133 caused by `reasoning_effort` reads exactly like a model that is
843
+ * unavailable, and the fix (untick one checkbox) lives nowhere in the reply.
844
+ */
845
+ rejectedParam?: string;
846
+ /** The offending value of {@link rejectedParam}, when the body carries one. */
847
+ rejectedTier?: string;
828
848
  }
829
849
  /**
830
850
  * Region for a login domain; an empty domain means CN (matching upstream tooling).
@@ -1145,6 +1165,95 @@ declare class WorkBuddyCatalog {
1145
1165
  set(models: readonly WorkBuddyModelInfo[]): void;
1146
1166
  }
1147
1167
  //#endregion
1168
+ //#region src/off-thinking.d.ts
1169
+ /**
1170
+ * Ids observed to REFUSE `reasoning_effort: "off"` with HTTP 400 despite
1171
+ * declaring `canDisableThinking: true` (issue #34).
1172
+ *
1173
+ * Keyed by exact model id and deliberately NOT by region: `deepseek-v4.1-flash`
1174
+ * refuses on BOTH gateways, and the split tracks the model family rather than
1175
+ * the gateway. The cost is asymmetric — offering a level that always fails
1176
+ * costs the user every request for that model, while hiding one that would have
1177
+ * worked costs a single option — so the table errs towards hiding, and
1178
+ * {@link withEffectiveOff} enforces it as a HARD wall rather than a seed
1179
+ * (issue #83: a tick used to reopen the failing level, and with no
1180
+ * `reasoningEffort` passed the level became the default for compaction too).
1181
+ * See {@link offRefused} for why the checkbox must not win.
1182
+ *
1183
+ * `deepseek-v4-pro` was measured on the CN gateway only; it is listed because
1184
+ * its sibling does refuse on both, and the same asymmetry argues for hiding.
1185
+ */
1186
+ declare const REFUSES_OFF_MODEL_IDS: ReadonlySet<string>;
1187
+ /** The two fields this rule needs; both are declared by the model itself. */
1188
+ interface WorkBuddyOffCapability {
1189
+ id: string;
1190
+ reasoning?: WorkBuddyReasoning;
1191
+ }
1192
+ /**
1193
+ * Whether `off` should be offered for a model when the user has expressed no
1194
+ * opinion: the upstream's declaration, minus the ids known to refuse it.
1195
+ *
1196
+ * Only an explicit `canDisableThinking: true` enables the level; absence is
1197
+ * "unknown" and stays unoffered, matching how the rest of the plugin treats an
1198
+ * undeclared capability.
1199
+ */
1200
+ declare function offDefaultFor(info: WorkBuddyOffCapability): boolean;
1201
+ /**
1202
+ * The model ids the `off` level should be offered for by default (issue #34).
1203
+ *
1204
+ * The user's checkbox is a plain set: ticked = offered, unticked = withdrawn.
1205
+ * This is the seed — what the set is filled with the first time it is read —
1206
+ * so a profile that has never saved shows the correct default immediately,
1207
+ * without requiring a save.
1208
+ *
1209
+ * Only an explicit `canDisableThinking: true` enables the level; absence is
1210
+ * "unknown" and stays unoffered, matching how the rest of the plugin treats an
1211
+ * undeclared capability.
1212
+ */
1213
+ declare function defaultOffModelIds(models: readonly WorkBuddyOffCapability[]): string[];
1214
+ /** One OBSERVED refusal of a reasoning tier, as the upstream reported it. */
1215
+ interface WorkBuddyRefusalReport {
1216
+ /** The rejected parameter (`extError.param`), e.g. `reasoning_effort`. */
1217
+ param: string;
1218
+ /** The rejected value (`extError.value`), when the body carried one. */
1219
+ tier?: string;
1220
+ /** Upstream business code, e.g. 11133. */
1221
+ code?: number;
1222
+ /** Epoch milliseconds of the observation. */
1223
+ at: number;
1224
+ /**
1225
+ * The model the refused REQUEST named — not the account's route alias.
1226
+ *
1227
+ * Taken from the body (`modelIdOfBody`), so it is the concrete id the card
1228
+ * lists; the badge would otherwise never attach, since failover re-points the
1229
+ * body at whatever the serving account routes.
1230
+ */
1231
+ modelId?: string;
1232
+ }
1233
+ /**
1234
+ * Stamp the user's saved `off` selection onto a model list, for the RUNTIME
1235
+ * catalog.
1236
+ *
1237
+ * The runtime model descriptor is built by `workBuddyThinkingLevelMap`, which
1238
+ * reads only the model's own declaration — so the corrected answer has to be
1239
+ * written onto the model before it reaches the adapter. Doing it here keeps the
1240
+ * adapter a pure "declared capability in, level map out" function and keeps the
1241
+ * table next to the rule it feeds.
1242
+ *
1243
+ * This OVERWRITES the declared field rather than adding a sibling, which is
1244
+ * safe because the declared value has exactly one consumer (that same level
1245
+ * map) and is never persisted where the card can see it: `toWebModel` drops
1246
+ * `canDisableThinking`, and the card's `lastCatalog` is written from the raw
1247
+ * upstream discovery, never from this stamped list. A model that declares no
1248
+ * reasoning is returned untouched — there is no level to correct.
1249
+ *
1250
+ * A refusal-table id is stamped `false` REGARDLESS of the selection
1251
+ * ({@link offRefused}): the tick stays in the saved set — the card shows it and
1252
+ * the hint explains it — but it can no longer be turned into a request that
1253
+ * fails.
1254
+ */
1255
+ declare function withEffectiveOff<T extends WorkBuddyOffCapability>(models: readonly T[], selection: ReadonlySet<string>): T[];
1256
+ //#endregion
1148
1257
  //#region src/shim.d.ts
1149
1258
  /** Minimal logger surface the plugin context already provides. */
1150
1259
  interface ShimLogger {
@@ -1219,6 +1328,18 @@ interface WorkBuddyShimOptions {
1219
1328
  * it produces the same reply as before this option existed.
1220
1329
  */
1221
1330
  failoverShortfall?: WorkBuddyFailoverShortfall;
1331
+ /**
1332
+ * Told about every refused request the plugin can attribute to a MODEL, right
1333
+ * before the failure is rendered (issue #83).
1334
+ *
1335
+ * Separate from the rendered reply because the caller that hit the refusal is
1336
+ * not always the user: compaction reads the failure as a boolean and shows
1337
+ * "could not produce a useful summary", so the one fact that explains it (this
1338
+ * model refuses `off`) never reaches any surface. This callback is how the
1339
+ * card learns it happened. Must not throw: a bookkeeping failure must never
1340
+ * replace the upstream's own failure.
1341
+ */
1342
+ reportRefusal?: (refusal: WorkBuddyRefusalReport) => void;
1222
1343
  /**
1223
1344
  * Called before the FIRST attempt of every chat request, so the plugin can
1224
1345
  * point the store at the account the pool's ranking says should serve.
@@ -1365,7 +1486,6 @@ interface ContextRescue {
1365
1486
  }
1366
1487
  //#endregion
1367
1488
  //#region src/adapter.d.ts
1368
- /** Provider route this bundle owns for the domestic (CN) gateway. */
1369
1489
  declare const WORKBUDDY_PROVIDER = "workbuddy";
1370
1490
  /** Provider route this bundle owns for the international gateway. */
1371
1491
  declare const WORKBUDDY_GLOBAL_PROVIDER = "workbuddy-global";
@@ -1450,10 +1570,15 @@ declare function workBuddyDisplayName(info: WorkBuddyModelInfo): string;
1450
1570
  *
1451
1571
  * The value read here is already the EFFECTIVE one: the runtime catalog is
1452
1572
  * stamped by `withEffectiveOff()` before it reaches the adapter, so this is the
1453
- * user's `off` selection when they made one (and the upstream declaration,
1454
- * minus `REFUSES_OFF_MODEL_IDS`, when they never did). Absence is "unknown" and
1455
- * stays unoffered, matching how the rest of the plugin treats undeclared
1456
- * capability.
1573
+ * user's `off` selection when they made one — except for the ids on
1574
+ * `REFUSES_OFF_MODEL_IDS`, which the stamp forces to `false` no matter what the
1575
+ * user asked for (issue #83: this level, once ticked, also became the IMPLICIT
1576
+ * default for every request that carries no effort at all, which is how
1577
+ * compaction went to 100% failure). The table is re-checked here as well so the
1578
+ * wall does not depend on every caller remembering to stamp first.
1579
+ *
1580
+ * Absence is "unknown" and stays unoffered, matching how the rest of the plugin
1581
+ * treats undeclared capability.
1457
1582
  */
1458
1583
  declare function workBuddyThinkingLevelMap(info: WorkBuddyModelInfo): WorkBuddyThinkingLevelMap | undefined;
1459
1584
  /**
@@ -1520,68 +1645,6 @@ declare function imageDefaultFor(info: {
1520
1645
  supportsImages?: boolean;
1521
1646
  }): boolean;
1522
1647
  //#endregion
1523
- //#region src/off-thinking.d.ts
1524
- /**
1525
- * Ids observed to REFUSE `reasoning_effort: "off"` with HTTP 400 despite
1526
- * declaring `canDisableThinking: true` (issue #34).
1527
- *
1528
- * Keyed by exact model id and deliberately NOT by region: `deepseek-v4.1-flash`
1529
- * refuses on BOTH gateways, and the split tracks the model family rather than
1530
- * the gateway. The cost is asymmetric — offering a level that always fails
1531
- * costs the user every request for that model, while hiding one that would have
1532
- * worked costs a single option — so the table errs towards hiding. A user who
1533
- * knows better can override any entry (see {@link effectiveOff}).
1534
- *
1535
- * `deepseek-v4-pro` was measured on the CN gateway only; it is listed because
1536
- * its sibling does refuse on both, and the same asymmetry argues for hiding.
1537
- */
1538
- declare const REFUSES_OFF_MODEL_IDS: ReadonlySet<string>;
1539
- /** The two fields this rule needs; both are declared by the model itself. */
1540
- interface WorkBuddyOffCapability {
1541
- id: string;
1542
- reasoning?: WorkBuddyReasoning;
1543
- }
1544
- /**
1545
- * Whether `off` should be offered for a model when the user has expressed no
1546
- * opinion: the upstream's declaration, minus the ids known to refuse it.
1547
- *
1548
- * Only an explicit `canDisableThinking: true` enables the level; absence is
1549
- * "unknown" and stays unoffered, matching how the rest of the plugin treats an
1550
- * undeclared capability.
1551
- */
1552
- declare function offDefaultFor(info: WorkBuddyOffCapability): boolean;
1553
- /**
1554
- * The model ids the `off` level should be offered for by default (issue #34).
1555
- *
1556
- * The user's checkbox is a plain set: ticked = offered, unticked = withdrawn.
1557
- * This is the seed — what the set is filled with the first time it is read —
1558
- * so a profile that has never saved shows the correct default immediately,
1559
- * without requiring a save.
1560
- *
1561
- * Only an explicit `canDisableThinking: true` enables the level; absence is
1562
- * "unknown" and stays unoffered, matching how the rest of the plugin treats an
1563
- * undeclared capability.
1564
- */
1565
- declare function defaultOffModelIds(models: readonly WorkBuddyOffCapability[]): string[];
1566
- /**
1567
- * Stamp the user's saved `off` selection onto a model list, for the RUNTIME
1568
- * catalog.
1569
- *
1570
- * The runtime model descriptor is built by `workBuddyThinkingLevelMap`, which
1571
- * reads only the model's own declaration — so the corrected answer has to be
1572
- * written onto the model before it reaches the adapter. Doing it here keeps the
1573
- * adapter a pure "declared capability in, level map out" function and keeps the
1574
- * table next to the rule it feeds.
1575
- *
1576
- * This OVERWRITES the declared field rather than adding a sibling, which is
1577
- * safe because the declared value has exactly one consumer (that same level
1578
- * map) and is never persisted where the card can see it: `toWebModel` drops
1579
- * `canDisableThinking`, and the card's `lastCatalog` is written from the raw
1580
- * upstream discovery, never from this stamped list. A model that declares no
1581
- * reasoning is returned untouched — there is no level to correct.
1582
- */
1583
- declare function withEffectiveOff<T extends WorkBuddyOffCapability>(models: readonly T[], selection: ReadonlySet<string>): T[];
1584
- //#endregion
1585
1648
  //#region src/at-rest.d.ts
1586
1649
  /**
1587
1650
  * WorkBuddy desktop "at-rest" credential decryption.
@@ -2514,6 +2577,28 @@ interface WorkBuddyWebModel {
2514
2577
  * level). Same shape as `supportsImages` above.
2515
2578
  */
2516
2579
  canDisableThinking?: boolean;
2580
+ /**
2581
+ * This model is on the reviewed `off`-refusal table (`src/off-thinking.ts`),
2582
+ * so ticking its checkbox cannot take effect (issue #83).
2583
+ *
2584
+ * The verdict, derived on the host — not a stored preference, and not
2585
+ * something the card has to recompute. It exists so the card can explain a
2586
+ * tick that did not stick instead of leaving the user staring at a checkbox
2587
+ * that looks saved. Like every other `reasoning` field it rides along in
2588
+ * the saved `lastCatalog` entry, where it is ignored on load: the verdict is
2589
+ * always recomputed from `id`.
2590
+ */
2591
+ offRefused?: boolean;
2592
+ /**
2593
+ * A REQUEST really carried `reasoning_effort: "off"` for this model and the
2594
+ * upstream answered HTTP 400 / code 11133 (issue #83).
2595
+ *
2596
+ * Row-level, DERIVED on write from the last refusal's `modelIdOfBody` —
2597
+ * never typed in by hand. Merged rather than replaced when the saved
2598
+ * catalog is refreshed, so the badge survives the full-table overwrite a
2599
+ * model refresh performs.
2600
+ */
2601
+ refusesOff?: boolean;
2517
2602
  };
2518
2603
  description?: string;
2519
2604
  }
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as isEncryptedFieldWrapper, 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 deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, 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-Cq4_pJ_l.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";
@@ -138,6 +138,13 @@ function claimableBenefits(benefits, nowMs) {
138
138
  * @module dsh-connect-workbuddy/adapter
139
139
  */
140
140
  /** Provider route this bundle owns for the domestic (CN) gateway. */
141
+ /**
142
+ * The tier a request gets when it names none — compaction summaries, above all
143
+ * (issue #83). See the `reasoning` field in `profileFor` for why an absent
144
+ * default is not "no opinion" but "whatever pi-ai's level map says", which is
145
+ * how `off` became the silent default for `/compact`.
146
+ */
147
+ const WORKBUDDY_DEFAULT_EFFORT = "high";
141
148
  const WORKBUDDY_PROVIDER = "workbuddy";
142
149
  /** Provider route this bundle owns for the international gateway. */
143
150
  const WORKBUDDY_GLOBAL_PROVIDER = "workbuddy-global";
@@ -307,16 +314,21 @@ function workBuddyDisplayName(info) {
307
314
  *
308
315
  * The value read here is already the EFFECTIVE one: the runtime catalog is
309
316
  * stamped by `withEffectiveOff()` before it reaches the adapter, so this is the
310
- * user's `off` selection when they made one (and the upstream declaration,
311
- * minus `REFUSES_OFF_MODEL_IDS`, when they never did). Absence is "unknown" and
312
- * stays unoffered, matching how the rest of the plugin treats undeclared
313
- * capability.
317
+ * user's `off` selection when they made one — except for the ids on
318
+ * `REFUSES_OFF_MODEL_IDS`, which the stamp forces to `false` no matter what the
319
+ * user asked for (issue #83: this level, once ticked, also became the IMPLICIT
320
+ * default for every request that carries no effort at all, which is how
321
+ * compaction went to 100% failure). The table is re-checked here as well so the
322
+ * wall does not depend on every caller remembering to stamp first.
323
+ *
324
+ * Absence is "unknown" and stays unoffered, matching how the rest of the plugin
325
+ * treats undeclared capability.
314
326
  */
315
327
  function workBuddyThinkingLevelMap(info) {
316
328
  const supported = info.reasoning?.supportedEfforts?.filter((effort) => THINKING_LEVELS.includes(effort));
317
329
  if (supported === void 0 || supported.length === 0) return void 0;
318
330
  const map = Object.fromEntries(THINKING_LEVELS.map((level) => [level, supported.includes(level) ? level : null]));
319
- if (info.reasoning?.canDisableThinking === true) map.off = "off";
331
+ if (info.reasoning?.canDisableThinking === true && !offRefused(info.id)) map.off = "off";
320
332
  else map.off = null;
321
333
  return map;
322
334
  }
@@ -673,6 +685,20 @@ function createWorkBuddyAdapter(options) {
673
685
  retryPolicy: resolveRetryPolicy(void 0, "dsh-connect-workbuddy retryPolicy"),
674
686
  configuredMaxTokens: /* @__PURE__ */ new Map(),
675
687
  modelErrors: /* @__PURE__ */ new Map(),
688
+ /**
689
+ * The default tier, pinned to the SAME value pi-ai's level map would fill in
690
+ * for an absent one (issue #83).
691
+ *
692
+ * `dsh-llm-pi-ai` resolves every request's tier as
693
+ * `options.reasoningEffort ?? profile.reasoning`, so an absent default here
694
+ * means "whatever pi-ai fills in" — and for a model whose map carried
695
+ * `off → "off"`, that was `"off"`, i.e. every compaction summary asked a
696
+ * model like `deepseek-v4.1-flash` to disable thinking, which it answers with
697
+ * 400 / 11133. Nothing structurally prevents a level or a catalog snapshot
698
+ * from disagreeing with the adapter's own map, so the default must never be
699
+ * that. This is the same value the host asks for explicitly on normal turns.
700
+ */
701
+ reasoning: WORKBUDDY_DEFAULT_EFFORT,
676
702
  ...REQUEST_IMAGE_BUDGETS,
677
703
  piProvider: key === providerId ? provider : {
678
704
  ...provider,
@@ -2187,18 +2213,33 @@ const KIND_STATUS = {
2187
2213
  * and cross-provider failover cannot help a policy decision either. The code the
2188
2214
  * host receives therefore names the class honestly.
2189
2215
  *
2190
- * `model_busy` gets the same `quota_exceeded` translation as `soft_rate`, and
2191
- * for a sharper version of the same reason: it arrives as a 429, and the host's
2192
- * in-place retry set is exactly where a request that is about to be refused
2193
- * again must not go. A model that is loaded for everyone will not clear inside
2194
- * one turn's backoff, so spending five attempts on it delays the honest answer
2195
- * without ever producing one. `type` still says `model_busy`, which is what a
2196
- * human reads and what routes the wording below.
2197
- */
2198
- const KIND_HOST_CODE = {
2199
- soft_rate: "quota_exceeded",
2200
- model_busy: "quota_exceeded"
2201
- };
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" };
2202
2243
  /**
2203
2244
  * The `code` to write for an upstream failure, given its classified kind.
2204
2245
  *
@@ -2721,6 +2762,46 @@ function describeAccount(credential) {
2721
2762
  * turn in this product reads Chinese, and an English clause inside Chinese prose
2722
2763
  * was the previous build's most visible rough edge.
2723
2764
  */
2765
+ /**
2766
+ * Name the rejected PARAMETER when the upstream refused a reasoning tier, or
2767
+ * `undefined` when it is any other failure (issue #83).
2768
+ *
2769
+ * The compaction path used to surface "Compaction could not produce a useful
2770
+ * summary" and nothing else, so a 11133 caused by `reasoning_effort: "off"` was
2771
+ * indistinguishable from a model that was simply unavailable — the reporter had
2772
+ * to read the raw body to learn that one parameter was at fault. When the body
2773
+ * names the offending parameter (`extError.param`) the reply says so; this is a
2774
+ * refusal the plugin can explain without guessing, because it is the union of
2775
+ * two facts the upstream stated in the response.
2776
+ */
2777
+ /**
2778
+ * Turn a failure into a refusal report for {@link WorkBuddyShimOptions.reportRefusal},
2779
+ * or `undefined` when it is not a tier refusal worth recording.
2780
+ *
2781
+ * Only refusals the upstream attributed to a PARAMETER are reported: a plain
2782
+ * 500 or a quota error says nothing about `off`, and recording it would pin a
2783
+ * "refuses off" badge on a model that never refused anything.
2784
+ */
2785
+ function refusedTierReport(failure, modelId) {
2786
+ if (failure.ok || failure.kind !== "client") return void 0;
2787
+ const param = failure.detail?.rejectedParam;
2788
+ if (param === void 0 || !param.includes("reasoning")) return void 0;
2789
+ const tier = failure.detail?.rejectedTier;
2790
+ return {
2791
+ param,
2792
+ ...tier === void 0 ? {} : { tier },
2793
+ ...failure.detail?.upstreamCode === void 0 ? {} : { code: failure.detail.upstreamCode },
2794
+ ...modelId === void 0 ? {} : { modelId },
2795
+ at: Date.now()
2796
+ };
2797
+ }
2798
+ function reasoningTierRefusal(failure) {
2799
+ if (failure.ok || failure.kind !== "client") return void 0;
2800
+ const param = failure.detail?.rejectedParam;
2801
+ if (param === void 0 || !param.includes("reasoning")) return void 0;
2802
+ const tier = failure.detail?.rejectedTier;
2803
+ return tier === void 0 ? `上游拒绝了推理档位参数(${param})` : `上游拒绝了推理档位「${tier}」(${param})`;
2804
+ }
2724
2805
  function failoverReasonText(failure) {
2725
2806
  if (failure.ok) return "不可用";
2726
2807
  switch (failure.kind) {
@@ -2948,6 +3029,7 @@ function createWorkBuddyShim(options) {
2948
3029
  const logger = options.logger;
2949
3030
  const failoverAccount = options.failoverAccount;
2950
3031
  const failoverShortfall = options.failoverShortfall;
3032
+ const reportRefusedTier = options.reportRefusal ?? (() => {});
2951
3033
  const prepareAccount = options.prepareAccount;
2952
3034
  const onAccountFailure = options.onAccountFailure;
2953
3035
  const onRequestBody = options.onRequestBody;
@@ -3165,6 +3247,8 @@ function createWorkBuddyShim(options) {
3165
3247
  const note = attempts > 1 ? `(已尝试 ${attempts} 个账号)` : "";
3166
3248
  const shortfall = failoverShortfall !== void 0 && failoverAccount !== void 0 && isFailoverWorthy(result.kind) ? await failoverShortfall().catch(() => void 0) : void 0;
3167
3249
  const why = shortfall === void 0 ? "" : `:${shortfall}`;
3250
+ const refusedReport = refusedTierReport(result, requestModelId);
3251
+ if (refusedReport !== void 0) reportRefusedTier(refusedReport);
3168
3252
  if (result.kind === "policy_reject") {
3169
3253
  const detail = result.detail;
3170
3254
  const lead = detail?.displayMsg ?? clippedUpstreamText(result.message, 200);
@@ -3181,7 +3265,7 @@ function createWorkBuddyShim(options) {
3181
3265
  const meta = ["模型当前负载较高"];
3182
3266
  if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
3183
3267
  if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
3184
- const message = `${lead}(${meta.join(",")})${note}。这不是账号被限流——同一凭据下换一个模型即可正常使用;插件不会为此切换账号,因为该限制跟随模型本身。建议等待约 1 分钟后重试,或切换到负载较低的模型`;
3268
+ const message = `${lead}(${meta.join(",")})${note}。这不是账号被限流,也与积分、额度无关——同一凭据下换一个模型即可正常使用;插件不会为此切换账号,因为该限制跟随模型本身。这类失败会被自动重试几次;若仍然失败,请等待约 1 分钟后重试,或切换到负载较低的模型`;
3185
3269
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
3186
3270
  return;
3187
3271
  }
@@ -3190,8 +3274,9 @@ function createWorkBuddyShim(options) {
3190
3274
  if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
3191
3275
  if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
3192
3276
  const stamp = meta.length === 0 ? "" : `(${meta.join(",")})`;
3193
- const receipt = detail?.tips ?? detail?.displayMsg ?? clippedUpstreamText(result.message, 400);
3194
- const advice = result.kind === "client" ? "该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果,请按上面的提示调整后重试" : shortfall === void 0 ? "" : "建议换一个模型继续,或稍后等待该账号的额度恢复";
3277
+ const refusedTier = reasoningTierRefusal(result);
3278
+ const receipt = refusedTier ?? detail?.tips ?? detail?.displayMsg ?? clippedUpstreamText(result.message, 400);
3279
+ const advice = refusedTier !== void 0 ? "这不是账号或网络问题:该模型实测拒绝「off」推理档位,请到模型卡片里取消该行的「可关闭思考」勾选后重试" : result.kind === "client" ? "该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果,请按上面的提示调整后重试" : shortfall === void 0 ? "" : "建议换一个模型继续,或稍后等待该账号的额度恢复";
3195
3280
  const body = advice === "" ? receipt : `${receipt.replace(/[。.]$/u, "")}。${advice}`;
3196
3281
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${why}${stamp}。${body}`, hostErrorCode(result.kind));
3197
3282
  return;
@@ -5185,7 +5270,9 @@ function toWebModel(model, budgets) {
5185
5270
  ...model.reasoning === void 0 ? {} : { reasoning: {
5186
5271
  ...model.reasoning.supportedEfforts === void 0 ? {} : { supportedEfforts: [...model.reasoning.supportedEfforts] },
5187
5272
  ...model.reasoning.defaultEffort === void 0 ? {} : { defaultEffort: model.reasoning.defaultEffort },
5188
- ...model.reasoning.canDisableThinking === void 0 ? {} : { canDisableThinking: model.reasoning.canDisableThinking }
5273
+ ...model.reasoning.canDisableThinking === void 0 ? {} : { canDisableThinking: model.reasoning.canDisableThinking },
5274
+ ...offRefused(model.id) ? { offRefused: true } : {},
5275
+ ...model.reasoning.refusesOff === true ? { refusesOff: true } : {}
5189
5276
  } }
5190
5277
  };
5191
5278
  }
@@ -5352,7 +5439,13 @@ async function workBuddyWebStatus(deps, region, options = {}) {
5352
5439
  showCreditsInMainUi,
5353
5440
  creditPosition,
5354
5441
  accounts: accounts.map(toWebAccount),
5355
- models: deps.displayModels(region).map((model) => toWebModel(model, deps.contextBudgets(region))),
5442
+ models: deps.displayModels(region).map((model) => toWebModel(model, deps.contextBudgets(region))).map((model) => model.reasoning === void 0 || lastRefusalFor(model.id) === void 0 ? model : {
5443
+ ...model,
5444
+ reasoning: {
5445
+ ...model.reasoning,
5446
+ refusesOff: true
5447
+ }
5448
+ }),
5356
5449
  enabledModelIds: [...deps.enabledModelIds(region)],
5357
5450
  imageModelIds: [...deps.imageModelIds(region)],
5358
5451
  offModelIds: [...deps.offModelIds(region)]
@@ -6011,85 +6104,6 @@ function imageDefaultFor(info) {
6011
6104
  return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
6012
6105
  }
6013
6106
  //#endregion
6014
- //#region src/off-thinking.ts
6015
- /**
6016
- * Ids observed to REFUSE `reasoning_effort: "off"` with HTTP 400 despite
6017
- * declaring `canDisableThinking: true` (issue #34).
6018
- *
6019
- * Keyed by exact model id and deliberately NOT by region: `deepseek-v4.1-flash`
6020
- * refuses on BOTH gateways, and the split tracks the model family rather than
6021
- * the gateway. The cost is asymmetric — offering a level that always fails
6022
- * costs the user every request for that model, while hiding one that would have
6023
- * worked costs a single option — so the table errs towards hiding. A user who
6024
- * knows better can override any entry (see {@link effectiveOff}).
6025
- *
6026
- * `deepseek-v4-pro` was measured on the CN gateway only; it is listed because
6027
- * its sibling does refuse on both, and the same asymmetry argues for hiding.
6028
- */
6029
- const REFUSES_OFF_MODEL_IDS = /* @__PURE__ */ new Set([
6030
- "deepseek-v4.1-flash",
6031
- "deepseek-v4.1-flash-sg",
6032
- "deepseek-v4-pro",
6033
- "primary-model",
6034
- "gpt-6-astra",
6035
- "gpt-5.6-sol",
6036
- "gpt-5.6-terra",
6037
- "gpt-5.6-luna",
6038
- "gemini-3.5-flash"
6039
- ]);
6040
- /**
6041
- * Whether `off` should be offered for a model when the user has expressed no
6042
- * opinion: the upstream's declaration, minus the ids known to refuse it.
6043
- *
6044
- * Only an explicit `canDisableThinking: true` enables the level; absence is
6045
- * "unknown" and stays unoffered, matching how the rest of the plugin treats an
6046
- * undeclared capability.
6047
- */
6048
- function offDefaultFor(info) {
6049
- return info.reasoning?.canDisableThinking === true && !REFUSES_OFF_MODEL_IDS.has(info.id);
6050
- }
6051
- /**
6052
- * The model ids the `off` level should be offered for by default (issue #34).
6053
- *
6054
- * The user's checkbox is a plain set: ticked = offered, unticked = withdrawn.
6055
- * This is the seed — what the set is filled with the first time it is read —
6056
- * so a profile that has never saved shows the correct default immediately,
6057
- * without requiring a save.
6058
- *
6059
- * Only an explicit `canDisableThinking: true` enables the level; absence is
6060
- * "unknown" and stays unoffered, matching how the rest of the plugin treats an
6061
- * undeclared capability.
6062
- */
6063
- function defaultOffModelIds(models) {
6064
- return models.filter(offDefaultFor).map((model) => model.id);
6065
- }
6066
- /**
6067
- * Stamp the user's saved `off` selection onto a model list, for the RUNTIME
6068
- * catalog.
6069
- *
6070
- * The runtime model descriptor is built by `workBuddyThinkingLevelMap`, which
6071
- * reads only the model's own declaration — so the corrected answer has to be
6072
- * written onto the model before it reaches the adapter. Doing it here keeps the
6073
- * adapter a pure "declared capability in, level map out" function and keeps the
6074
- * table next to the rule it feeds.
6075
- *
6076
- * This OVERWRITES the declared field rather than adding a sibling, which is
6077
- * safe because the declared value has exactly one consumer (that same level
6078
- * map) and is never persisted where the card can see it: `toWebModel` drops
6079
- * `canDisableThinking`, and the card's `lastCatalog` is written from the raw
6080
- * upstream discovery, never from this stamped list. A model that declares no
6081
- * reasoning is returned untouched — there is no level to correct.
6082
- */
6083
- function withEffectiveOff(models, selection) {
6084
- return models.map((model) => model.reasoning === void 0 ? model : {
6085
- ...model,
6086
- reasoning: {
6087
- ...model.reasoning,
6088
- canDisableThinking: selection.has(model.id)
6089
- }
6090
- });
6091
- }
6092
- //#endregion
6093
6107
  //#region src/index.ts
6094
6108
  /** Stable Cordis plugin name. */
6095
6109
  const name = "dsh-connect-workbuddy";
@@ -6428,6 +6442,9 @@ function apply(ctx, config) {
6428
6442
  onUpstreamUsage: (usage) => {
6429
6443
  pendingUsage?.(usage);
6430
6444
  },
6445
+ reportRefusal: (report) => {
6446
+ recordRefusal(report);
6447
+ },
6431
6448
  prepareAccount: () => applyPoolSelection(region),
6432
6449
  failoverAccount: (triedAccountIds) => failoverAccountFor(region, triedAccountIds),
6433
6450
  failoverShortfall: () => explainPoolShortfall(region),
@@ -6449,7 +6466,13 @@ function apply(ctx, config) {
6449
6466
  const state = regionStateOf(value, region);
6450
6467
  const roster = state.lastCatalog?.length ? state.lastCatalog : liveModels.length ? liveModels : fallbackModelsFor(region);
6451
6468
  const declared = withImageSelection(deriveCatalog(roster, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), new Set(state.imageModelIds ?? []));
6452
- return withEffectiveOff(declared, new Set(savedOffModelIds(state, declared)));
6469
+ return withEffectiveOff(declared, new Set(savedOffModelIds(state, declared))).map((model) => model.reasoning === void 0 || lastRefusalFor(model.id) === void 0 ? model : {
6470
+ ...model,
6471
+ reasoning: {
6472
+ ...model.reasoning,
6473
+ refusesOff: true
6474
+ }
6475
+ });
6453
6476
  };
6454
6477
  /**
6455
6478
  * The region's `off` selection as a Set (issue #34).
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.10.1",
5
+ "version": "4.12.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",