dsh-connect-workbuddy 4.13.0 → 4.13.2

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,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.13.2 (2026-10-21)
4
+
5
+ > 版本号是 **patch**:没有接口、配置项或请求结构的变更。发往上游的请求体**一个字节都没变**——4.13.0 的「渠道指纹归一化」照旧在跑,本版只改**网关已经拒绝之后**的那一段:把 11128 从「通用参数拒绝」里拆出来单独归类,并把**被拒的那条 system 提示开头**回显给读者。路由 id、模型 id、凭据、计费与换号规则一律未变(换号规则只有一处「不再多问一次」的减少,见下)。
6
+
7
+ ### Fixes
8
+
9
+ - **issue #87 / #88 / #89 / #90 的后半段:11128 仍被归进「通用参数拒绝」,读者拿到的还是那句「请按上面的提示调整后重试」,而「上面的提示」是网关的误导文案**
10
+ - **报告**:4.13.0 把「为什么被拒」查清了(网关按 system 提示**开头**匹配第三方 agent 清单),并让这类请求不再发出去。但**清单是实测快照**:网关新增词条时,请求照样被拒,而插件此时给出的仍然是那句通用回复——`workbuddy 上游失败:请求被上游拒绝(http 400)(code 11128,requestId …)。这次调用没通过渠道校验,可能不是官方客户端发出的。请用官方版本重新发送。该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果,请按上面的提示调整后重试`。四份报告里的一共五位用户(@huaizhuanghub、@ShiningYangYXN、@yuhang0529、@haohui421 等)在这句上花了大量时间:换账号、换区域、装官方客户端、改 UA。
11
+ - **真实原因:`11128` 这一族里有三道门共用一个业务码,而插件只认其中一道。** 网关的 `displayTips` 对**每一道**都写成同一句「这次调用没通过渠道校验,可能不是官方客户端发出的」,所以那句话从来不是判据,只是一句放之四海皆准的托词。本版要处理的那一道是**提示开头命中**(4.13.0 已定位),而另外两道是**请求头**缺 conversation id(issue #66 已修)与**首条消息不是 system**。三道门共用 `11128`,**只有 body 能区分**。
12
+ - **插件此前的三个错处,逐条对应**:
13
+ 1. **归类错了**:11128 落在 `client` 兜底类,读者拿到的是「按参数拒绝,换账号也没用」——这句本身没错,但**它没说是什么参数被拒**,于是提示词从来不在嫌疑名单上;而「不是官方客户端发出的」那句托词被原样转述,用户信了它。
14
+ 2. **证据没有回显**:被拒的证据在**我们刚发出去的请求**里,不在上游的回复里。插件手上明明有那几行字,却只转述了上游的托词。
15
+ 3. **探针那一侧也退化成通用失败**:探针用的是中性 system 提示(`probeRequestBody`),所以「测试能过、聊天被拒」正是这个闸的特征,而卡片当时把同一种拒绝印成「✗ 测试失败」——那一句在账号旁边,读者只会去换账号。
16
+ - **修法一:把「提示开头命中」这一道单独归类(`channel_reject`)**。判据是**两半都要**:body 里同时有 `"code":11128` **和** msg 的措辞(`unapproved channel` / `illegal api invocation`,大小写不敏感)。只看 code 会连另外两道门一起认领——那会让读者去改写一条根本没问题的 system 提示,比通用回复更糟;所以另外两道门(`conversation id missing`、`first message is not system prompt`)被**显式排除**。**只认 `msg`、绝不认 `displayTips`**:那句托词是全族共用的,认它等于把三道门混成一道。判据放在 `classifyUpstreamError()` 里**策略拒绝之后、429/14003 之前**,所以真正的内容拒绝(`11140`)仍走它更具体的回复。
17
+ - **修法二:回显被拒的那一行**。新增 `systemOpeningsOfBody()`:从**实际发出去的 body**(`prepareChatBody` 归一化之后的那一份)里取每条 system 消息的**第一行非空内容**,最多回显 3 条、每条截到 120 字符,取的是**上真实发送的字节**(所以命中已知指纹时回显的是插件加的那行占位语,不是宿主原文——这正是「证据」二字的含义)。读不出 body 时不编造,直接省略这段,但结论句仍然成立。
18
+ - **修法三:三条行为跟着这个归类走**,因为它们都属于「上游拒的是提示词,不是账号」这一件事:
19
+ - **不再换号**(`isFailoverWorthy`):网关读的是**每条 account 都会发的那同一句提示**,换到下一个账号只会晚一轮拿到同一个 11128,而每换一次就烧一次真实额度、还往一个从没出问题的凭据上记一笔假账。
20
+ - **不把账号踢出池**(`exclusionOf`):与 `policy-rejected` 同处理——测量照记(卡片要能解释发生了什么),但账号仍是候选。把它 bench 掉既浪费一个好账号,又把真正的解法(改提示词)藏起来。
21
+ - **探针与账号表用各自的说法**(`channel-rejected` 这个 outcome):卡片那一行的文案是「网关拦的是提示词本身,不是这个账号——某条 system 消息的开头命中了它的第三方 agent 过滤,换账号或换区域都会得到同一个答案(测试能过、聊天被拒,正是这个原因)」,语气用 `warn` 而不是 `bad`:账号本身没问题,一行 `bad` 会把读者送回「换账号」。
22
+ - **回复文本长什么样**(形状与其它失败一致,报告者会原样粘进 issue,所以外框不变):`workbuddy 上游失败:被网关的渠道校验拦截(不是账号、额度或网络问题)(http 400)(code 11128,requestId …)。命中的 system 提示开头:「You are an AI agent powered by DeepSeek Harness.」。该请求被 WorkBuddy 网关的渠道校验拦截,上游把它描述成「不是官方客户端发出的」,但那是误导性文案:网关匹配的是 system 提示的开头,与账号、积分、额度、网络都无关,换账号或换区域也不会改变结果。若这段开头来自宿主或其他插件,请修改该提示;插件已对已知的第三方 agent 指纹做归一化,新的指纹可反馈到 issue 里`
23
+ - **宿主那一侧也核过了(本版刻意**不**改的地方)**:这个失败写给宿主的 `code` 就是 `channel_reject`(**不翻译**),状态 400 ⇒ 宿主分类为 `INVALID_REQUEST`。这一类**不在**宿主的原地重试集合(`RATE_LIMIT` / `SERVER` / `TIMEOUT` / `TRANSPORT` / `EMPTY_RESPONSE`)里——这是对的:闸的判决对同一段提示是确定的,重试只是把同一串字节再发一遍。宿主 `failureMessage()` 只对 `QUOTA` / `ACCOUNT_QUOTA` / `AUTH` 等少数几类**丢掉插件自己的文案**,本类不在其中,所以上面那句原样到达读者。
24
+ - **代价与边界(如实登记)**:① 回显的是**提示开头**,不是整个提示——闸只看开头,其余内容无辜,不该被抄进 issue;② 只对**body 能区分**的那一道门生效,另外两道门(请求头缺 id、首条不是 system)维持原分类,它们各有各的修法;③ 提示开头若本身就是敏感内容,读者粘进 issue 时要注意(插件不做脱敏,**上游原文回显与 issue 传播是使用者自己的决定**);④ 4.13.0 的「已知指纹归一化」清单仍是实测快照,本版**不扩大**它——归一化是修复,单独归类是**兜底**,两者不互相替代。
25
+
26
+ ### Tests
27
+
28
+ - **`tests/issue90-channel-reject.spec.ts` 新增 24 条用例**(含 3 组):把「认得这道门」与「**认错**这道门」分开钉,后者才是本版的真正难点:
29
+ - **认门(6 条)**:真实 CN 网关的 11128 报文逐字节捕获 → 归为 `channel_reject`(修复前是 `client`);只认 `msg` 不认 `displayTips`(三道门共用的那句托词不足以认领任何一道);另外两道 11128 门**保持原分类**(认错会让读者去改一条没问题的提示);code 与措辞**两半都要**(换个业务码引同样的英文短语不是这道门);真正的内容拒绝(`11140`)仍走它更具体的回复;请求 id 里恰好含 `11128` 不被误认。
30
+ - **探针与账号池(2 条)**:探针把这个拒绝报成 `channel-rejected` 而不是 `failed`(后者在卡片上是「✗ 测试失败」,是唯一会引人去换账号的标签);`exclusionOf()` 对它返回 `undefined`——账号不被踢出池。
31
+ - **回显取什么(6 条)**:每条 system 消息各取第一行;**取的是归一化之后真正上线的字节**(已知指纹回显的是占位行,不是宿主原文);病态长行截到 120 字符而不是把整个提示倒出来;最多 3 条;读不出来的 body 一律返回 `[]` 且**不抛**(丢掉测量不是可以接受的代价);`content` 是数组而非字符串的消息被跳过。
32
+ - **渲染出来的回复(6 条)**:真的挂起 **shim**(真 body 准备 + 真失败渲染),只把上游应答换成固定报文:回复里有被拒的那一行、有 `code 11128`、有 `requestId`、有「换账号或换区域也不会改变结果」、有「误导性文案」,并且**没有**旧那句「换账号也不会改变结果,请按上面的提示调整后重试」;回显的是**归一化后**的内容;宿主没给 system 消息时回显插件补的那行占位语(`ensureSystemHead` 保证有头,所以这是离「没有提示可引」最近的一种);**没有把一次换号花掉**(failover 回调被调用 0 次,且回复不声称「已尝试 N 个账号」——那是 issue #78 的老坑);宿主面拿到的是 400 + `channel_reject`。
33
+ - **卡片文案(2 条)**:中英文都与「策略拒绝」「通用失败」不同(共用字符串会让「测试能过、聊天被拒」读成内容拒绝,而那有完全相反的下一步);**所有 `row.probe*` 文案里不含 `**` 粗体标记**——探针结论是当作纯文本渲染进 `<p>` 的,带标记就会在屏幕上显示成字面的星号。
34
+ - **宿主那一侧(2 条,issue #84 的做法)**:用**真实 pi-ai 客户端**打**真实 shim**,再问**宿主自己的判定函数**——`isQuotaExceededError()` 必须为假(为真则宿主会用自己的「额度已用尽」顶掉插件的句子,正是 issue #84 的那个坑),且文本里必须有「命中的 system 提示开头」与「换账号或换区域也不会改变结果」,**且不能有**「请用官方版本重新发送」那句托词。
35
+ - **变异验证**:`git checkout src/upstream.ts src/shim.ts` 回退修复后,本文件 **17 条**失败(归类回 `client`、回显消失、换号又发生、探针退回 `failed`、宿主侧被判成额度失败);仍然通过的 7 条正是护栏(另外两道 11128 门的原分类、`11140` 的原分类、账号不被踢出池、不浪费换号、卡片文案互不相同、文案不带 `**`)。
36
+
37
+ ## 4.13.1 (2026-10-21)
38
+
39
+ > 版本号是 **patch**:没有接口、配置项或请求结构的变更。发往上游的请求体**一个字节都没变**——修的是「请求根本没被发出去」这条本地失败路径:目录里没有 reasoning 元数据的模型,此前在组装请求时就被本地判定失败(`UNSUPPORTED_REASONING_EFFORT`),网络一个字节都没出。路由 id、模型 id、凭据、计费与换号规则一律未变。
40
+
41
+ ### Fixes
42
+
43
+ - **issue #90(后半段):选到「没有推理档位元数据」的模型时,消息发不出去,本地直接报 `UNSUPPORTED_REASONING_EFFORT`**
44
+ - **报告**:`#90` 里一位用户在国内区被 11128 挡住(本版 4.13.0 修的那件事),想切国际区绕开,结果撞上**另一个纯本地**的失败:`pi-ai provider "workbuddy-global" model "default-model" does not support reasoning effort "high"`,请求未出网。这条与 11128 相互独立,4.13.0 没有修它。
45
+ - **真实原因:默认档位是「按路由」定的,而能不能接受档位是「按模型」定的。** `profileFor()` 给整条路由钉了 `reasoning: WORKBUDDY_DEFAULT_EFFORT`(`'high'`,issue #83 的修法),`dsh-llm-pi-ai` 每次解析请求都用 `options.reasoningEffort ?? profile.reasoning`。而目录里没有 `reasoning` 块的模型(`toPiModel()` 据此产出 `reasoning: false`)在 pi-ai 眼里只支持 `['off']` 一个档位,继承来的 `'high'` 它接不了,于是 `resolveReasoningLevel()` 在**组装请求时**就抛错。
46
+ - **影响面(实测,不是推测)**:用真实凭据打真实网关取目录——国内区 34 个模型里 **6 个**无 reasoning 元数据(`default`、`glm-5.0`、`glm-4.7`、`glm-4.6`、`hunyuan-chat`、`hunyuan-image-alpha`),国际区 21 个里 **2 个**(`default-model`、`deep-model`);而两份**静态兜底目录**(`FALLBACK_WORKBUDDY_MODELS` / `_GLOBAL`,共 32 条)**全部**没有 reasoning 字段,所以在第一次目录刷新落地之前,**每个模型都会中**。
47
+ - **两道门,都得开**:①宿主 `dsh-llm` 的 `resolveCallWithInfo()` 看到「模型元数据里根本没有 `reasoning`」而调用方**显式**给了任何档位(**连 `'off'` 也算**)就拒;②`PiAiAdapter` 在隐式继承路由默认时于 `resolveReasoningLevel()` 拒。前者早于 adapter,所以只改派发路径修不掉显式 `'off'`。
48
+ - **引入时间**:`git log -S 'reasoning: WORKBUDDY_DEFAULT_EFFORT'` 只指向 `1801b01`(v4.11.0,issue #83 的修法)。在那之前路由默认是缺省的,pi-ai 会自己就近降级(`clampThinkingLevel()` 不抛),所以这个回归是 **4.11.0 起的**。
49
+ - **修法:在插件自己的 adapter 层做「按模型」的档位归一化,不动路由默认。** 新增 `EffortNormalizingAdapter extends PiAiAdapter`(`src/adapter.ts`),判据 `effortCapable(model)` = `catalog.current().some(info => info.id === model && workBuddyThinkingLevelMap(info) !== undefined)`——与 `toPiModel()` 用的是同一个判据,且读**活目录**,所以目录刷新后自动生效,不需要重建 adapter。对判据为假的模型:
50
+ - `resolveModel()` / `prepareCall()` 返回的模型信息补上 `reasoning: { efforts: [{ id: 'off', name: 'Off' }] }`——这正是 pi-ai 认为它唯一支持的档位,于是宿主的门①放行,`'off'` 也成为用户在界面上唯一能选到的档位(此前这类模型只有「Provider default」一个不可选项);
51
+ - `prepareCall().stream` 与 `stream()` 把 `options.reasoningEffort` 归一成 `'off'`,于是门②放行,而 `profileOptions()` 又把 `'off'` 翻成「不下发 reasoning 字段」,pi-ai 侧 `model.reasoning === false` 也保证 `openai-completions` 不会写 `reasoning_effort` ⇒ **上游收到的请求体与修复前完全一致**(修复前是「没收到」)。
52
+ - **一个必须记下的坑:注入的 `reasoning` 不能带 `defaultEffort`。** 宿主 `prepareCall()` 会把 `defaultEffort` **物化**进每一个「没带档位」的请求(`resolvedConfig.reasoningEffort = effective`),随后用 `callConfigEquals()` 逐字段比对调用方的 config,不一致就抛 `INVALID_PREPARED_CALL`。补 `defaultEffort: 'off'` 会让宿主给这类请求凭空加一个字段,正好撞上这条校验——所以只给 `efforts`,让宿主保持「调用方没给就是没给」。
53
+ - **刻意不改的行为**:显式请求一个该模型做不到的档位(例如给 `glm-5.0` 选 `high`)仍然被宿主拒,消息如实说明原因。那是宿主自 v4.11.0 之前就有的契约,静默降级会把一次真实的配置错误藏起来。同理,`profileFor()` 的路由默认**一个字符都没动**:issue #83 靠它才成立(缺省默认在 pi-ai 眼里等于 `off`,`/compact` 因此 100% 400 / 11133),`tests/issue83-profile-default.spec.ts` 的三条断言也照旧全绿。
54
+ - **代价与边界**:判据来自目录的 `reasoning` 元数据;上游哪天给 `glm-5.0` 补上元数据,插件下一次目录刷新就会自动改走正常档位路径(新测试第 6 条钉的就是这个)。反过来,若上游漏报元数据而模型其实支持档位,这类模型会停在 `off`——与 4.11.0 之前的行为一致,不会更糟。
55
+
56
+ ### Tests
57
+
58
+ - **`tests/issue90-unsupported-reasoning-effort.spec.ts` 新增 6 条用例**:真实 `Context` + 真实 `LlmRuntime` + 真实插件 `apply()`,只把网关调用替换成三帧 SSE 并录下请求体,钉的是「请求到底出没出去、body 里有没有 `reasoning_effort`」,不是函数里有没有那个 if:
59
+ - 无元数据模型不带档位 → 成功派发,body **不含** `reasoning_effort`(修复前:本地 `UNSUPPORTED_REASONING_EFFORT`,body 一个都没有);
60
+ - 显式 `'off'` → 同样成功(修复前宿主连 `'off'` 都拒);
61
+ - 显式 `'high'` → 仍被拒且 `code === 'UNSUPPORTED_REASONING_EFFORT'`、message 含 `does not support reasoning effort "high"`、**没有任何请求出网**;
62
+ - 有元数据模型不带档位 → body 里 `reasoning_effort === 'high'`(issue #83 的护栏,确保这次没把它修坏);
63
+ - `resolveModelInfo()` 对无元数据模型严格返回 `{ efforts: [{ id: 'off', name: 'Off' }] }`(**没有** `defaultEffort`),对有元数据模型仍是 `['low','high','max']` + `defaultEffort: 'high'`;
64
+ - 目录补上元数据后(改写 `liveConfig` + 发 `loader/volatile-update`)→ 档位自动回到 `high`,证明判据读的是活目录。
65
+ - **变异验证**:`git checkout src/adapter.ts` 回退修复后,6 条里 **4 条失败**(三条报 `+ "code": "UNSUPPORTED_REASONING_EFFORT"`,一条报 `expected undefined to deeply equal { efforts: [ { id: 'off', … } ] }`);仍然通过的 2 条正是「显式 high 必须被拒」与「有元数据模型档位必须上线」——它们在修复前后行为一致,是护栏而非修复证据。
66
+
3
67
  ## 4.13.0 (2026-10-21)
4
68
 
5
69
  > 版本号是 **minor**:本版有两块改动,其中**请求体**首次新增一条 system 提示的「渠道指纹」归一化(只对开头命中上游清单的 system 消息前置一行占位语,未命中的 body 逐字节不变),这是发往上游的内容变化;另一块是**报错文案**的 405 判断与文案修正。路由 id、模型 id、凭据、计费与换号规则一律未变。
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-CxoHaypp.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as WORKBUDDY_APP_EXECUTABLE_ENV, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, mt as hostPeersInfo, o as workbuddyHostHeartbeatPath, ot as findWorkbuddyAppExecutableWithSource, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-BJAL9pEe.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
@@ -902,6 +902,7 @@ window.__ModuleLoader__.load({
902
902
  case "model-busy": return retryDue(probe, nowMs, 12e4) ? void 0 : "model-busy";
903
903
  case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
904
904
  case "policy-rejected": return;
905
+ case "channel-rejected": return;
905
906
  default: return "unusable";
906
907
  }
907
908
  }
@@ -1016,6 +1017,7 @@ window.__ModuleLoader__.load({
1016
1017
  case "model-busy": return t("row.poolExcludedModelBusy");
1017
1018
  case "out-of-credit": return t("row.poolExcludedOutOfCredit");
1018
1019
  case "credential-rejected": return t("row.probeCredentialRejected");
1020
+ case "channel-rejected": return t("row.probeChannelRejected");
1019
1021
  case "not-found": return t("row.probeNotFound");
1020
1022
  case "unavailable": return t("row.probeUnavailable");
1021
1023
  default: return t("row.probeFailed");
@@ -1204,6 +1206,7 @@ window.__ModuleLoader__.load({
1204
1206
  "out-of-credit",
1205
1207
  "credential-rejected",
1206
1208
  "policy-rejected",
1209
+ "channel-rejected",
1207
1210
  "unavailable",
1208
1211
  "not-found",
1209
1212
  "failed"
@@ -4823,6 +4826,10 @@ window.__ModuleLoader__.load({
4823
4826
  text: t("row.probePolicyRejected"),
4824
4827
  tone: "bad"
4825
4828
  };
4829
+ case "channel-rejected": return {
4830
+ text: t("row.probeChannelRejected"),
4831
+ tone: "warn"
4832
+ };
4826
4833
  case "not-found": return {
4827
4834
  text: t("row.probeNotFound"),
4828
4835
  tone: "bad"
@@ -7211,6 +7218,7 @@ window.__ModuleLoader__.load({
7211
7218
  "row.probeOutOfCreditAt": "✗ Not enough credits · quota refreshes {at}",
7212
7219
  "row.probeCredentialRejected": "✗ WorkBuddy refused this account's token — sign in again or pick another account",
7213
7220
  "row.probePolicyRejected": "✗ WorkBuddy's server refused this request under its content policy — re-signing in won't help; try the desktop app or another region/account",
7221
+ "row.probeChannelRejected": "✗ The gateway refused the PROMPT itself, not this account — the opening of a system message matched its third-party-agent filter, so another account or region answers the same way",
7214
7222
  "row.probeNotFound": "✗ The upstream does not know this model",
7215
7223
  "row.probeUnavailable": "✗ The upstream could not be reached — no usable answer came back",
7216
7224
  "row.probeFailed": "✗ Test failed",
@@ -7536,6 +7544,7 @@ window.__ModuleLoader__.load({
7536
7544
  "row.probeOutOfCreditAt": "✗ 积分不足 · 积分包 {at} 刷新",
7537
7545
  "row.probeCredentialRejected": "✗ WorkBuddy 拒绝了这个账号的 token——请重新登录或换一个账号",
7538
7546
  "row.probePolicyRejected": "✗ WorkBuddy 服务端按内容策略拒绝了这次请求——重新登录无效;可在桌面端验证或切换区域/账号",
7547
+ "row.probeChannelRejected": "✗ 网关拦的是提示词本身,不是这个账号——某条 system 消息的开头命中了它的第三方 agent 过滤,换账号或换区域都会得到同一个答案(测试能过、聊天被拒,正是这个原因)",
7539
7548
  "row.probeNotFound": "✗ 上游不认识这个模型",
7540
7549
  "row.probeUnavailable": "✗ 连不上上游——上游没有给出可用答复",
7541
7550
  "row.probeFailed": "✗ 测试失败",
@@ -1281,6 +1281,44 @@ const POLICY_REJECT_MARKERS = ["request illegal", "\"code\":11140"];
1281
1281
  */
1282
1282
  const MODEL_BUSY_CODE = "\"code\":14003";
1283
1283
  /**
1284
+ * Business code of the gateway's CHANNEL gate (`11128`, "Illegal API invocation
1285
+ * from an unapproved channel").
1286
+ *
1287
+ * The quoted-key form for the same reason as the markers above: a request id is
1288
+ * hexadecimal and can contain the digits `11128` by coincidence.
1289
+ *
1290
+ * A DIFFERENT gate from the one {@link conversationHeaders} works around, which
1291
+ * also answers `11128` (`conversation id missing`). The code is the same
1292
+ * because the gateway reuses it for every rule in that family; the BODY is what
1293
+ * tells them apart, so {@link isChannelRejectBody} requires both halves.
1294
+ */
1295
+ const CHANNEL_REJECT_CODE = "\"code\":11128";
1296
+ /**
1297
+ * Wording that identifies the CHANNEL half of `11128` (the other halves being
1298
+ * `conversation id missing` and `first message is not system prompt`).
1299
+ *
1300
+ * Matched against the upstream's `msg`, NEVER against `displayTips`. That is not
1301
+ * a style choice: the tips are the SAME misleading sentence for every member of
1302
+ * the `11128` family — "这次调用没通过渠道校验,可能不是官方客户端发出的" — which is
1303
+ * why matching them would claim the conversation-id gate too and blame the
1304
+ * prompt's opening for a header problem this plugin already fixes.
1305
+ *
1306
+ * Lower-cased before the search. Two spellings are listed because the observed
1307
+ * bodies name the code and the phrase together
1308
+ * (`{"code":11128,"msg":"Illegal API invocation from an unapproved channel"}`),
1309
+ * and either half is enough to identify it.
1310
+ */
1311
+ const CHANNEL_REJECT_MARKERS = ["unapproved channel", "illegal api invocation"];
1312
+ /**
1313
+ * The other gates that answer the SAME business code.
1314
+ *
1315
+ * Excluded explicitly so a body that also carries the channel wording can never
1316
+ * be read as the prompt rule: a confidently wrong diagnosis is worse here than
1317
+ * the generic `client` reply, because it sends the user to rewrite a system
1318
+ * prompt that is not at fault.
1319
+ */
1320
+ const OTHER_CHANNEL_CODE_GATES = ["conversation id missing", "first message is not system prompt"];
1321
+ /**
1284
1322
  * Whether a failure body is the MODEL-scoped throttle rather than an account
1285
1323
  * limit.
1286
1324
  *
@@ -1300,6 +1338,72 @@ const MODEL_BUSY_CODE = "\"code\":14003";
1300
1338
  function isModelBusyBody(body) {
1301
1339
  return body.includes(MODEL_BUSY_CODE);
1302
1340
  }
1341
+ /**
1342
+ * Whether a failure body is the gateway's CHANNEL gate rather than the other
1343
+ * `11128` ({@link conversationHeaders}' `conversation id missing`).
1344
+ *
1345
+ * Both halves are required. The code alone would claim the conversation-id gate
1346
+ * too, and its reply would then blame the prompt's opening for a request-header
1347
+ * problem the plugin already fixed — a confidently wrong diagnosis is worse
1348
+ * here than the generic `client` reply, because it sends the user to rewrite a
1349
+ * system prompt that is not at fault.
1350
+ */
1351
+ function isChannelRejectBody(body) {
1352
+ if (!body.includes(CHANNEL_REJECT_CODE)) return false;
1353
+ const lower = body.toLowerCase();
1354
+ if (OTHER_CHANNEL_CODE_GATES.some((marker) => lower.includes(marker))) return false;
1355
+ return CHANNEL_REJECT_MARKERS.some((marker) => lower.includes(marker));
1356
+ }
1357
+ /**
1358
+ * The OPENINGS of the `system` messages in a prepared request body.
1359
+ *
1360
+ * This is the evidence the channel gate actually reacts to, and echoing it is
1361
+ * what turns "the upstream refused" into "the upstream refused because of this
1362
+ * line" — the reader can then fix the prompt or report the new opening, instead
1363
+ * of cycling accounts that cannot change a byte of it.
1364
+ *
1365
+ * Each entry is the first non-empty line, clipped: a fingerprint lives at the
1366
+ * START of the content, so the rest of a 10 KB system prompt is noise that
1367
+ * would bury the one sentence worth reading. At most three entries are kept —
1368
+ * the gate scans every `system` message, but a body with more than three of them
1369
+ * is already worth showing compactly.
1370
+ *
1371
+ * Never throws: a malformed body is the upstream's to reject, and losing the
1372
+ * failure measurement is not an acceptable price for reading an excerpt.
1373
+ */
1374
+ function systemOpeningsOfBody(bodyJson) {
1375
+ let parsed;
1376
+ try {
1377
+ parsed = JSON.parse(bodyJson);
1378
+ } catch {
1379
+ return [];
1380
+ }
1381
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
1382
+ const messages = parsed["messages"];
1383
+ if (!Array.isArray(messages)) return [];
1384
+ const openings = [];
1385
+ for (const value of messages) {
1386
+ if (openings.length >= 3) break;
1387
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
1388
+ const message = value;
1389
+ const role = message["role"];
1390
+ if (typeof role !== "string" || role.trim().toLowerCase() !== "system") continue;
1391
+ const content = message["content"];
1392
+ if (typeof content !== "string") continue;
1393
+ const opening = firstLineOf(content).replace(/\s+/gu, " ").trim();
1394
+ if (opening !== "") openings.push(clip(opening, SYSTEM_OPENING_LIMIT));
1395
+ }
1396
+ return openings;
1397
+ }
1398
+ /** Longest system-prompt opening echoed back to the user, in characters. */
1399
+ const SYSTEM_OPENING_LIMIT = 120;
1400
+ function firstLineOf(content) {
1401
+ const newline = content.indexOf("\n");
1402
+ return newline === -1 ? content : content.slice(0, newline);
1403
+ }
1404
+ function clip(text, limit) {
1405
+ return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
1406
+ }
1303
1407
  /** Classify an upstream failure from its HTTP status and body excerpt. */
1304
1408
  function classifyUpstreamError(status, body) {
1305
1409
  if (status === 402) return "hard_credit";
@@ -1308,6 +1412,7 @@ function classifyUpstreamError(status, body) {
1308
1412
  if (body.includes(CREDITS_EXHAUSTED_CODE)) return "hard_credit";
1309
1413
  for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
1310
1414
  for (const marker of POLICY_REJECT_MARKERS) if (body.includes(marker)) return "policy_reject";
1415
+ if (isChannelRejectBody(body)) return "channel_reject";
1311
1416
  if (isModelBusyBody(body)) return "model_busy";
1312
1417
  if (status === 429) return "soft_rate";
1313
1418
  if (status === 404) return "not_found";
@@ -4410,7 +4515,7 @@ function generationOf(version) {
4410
4515
  //#endregion
4411
4516
  //#region src/version.ts
4412
4517
  /** The npm package version this build was produced from. */
4413
- const WORKBUDDY_CONNECT_VERSION = "4.13.0";
4518
+ const WORKBUDDY_CONNECT_VERSION = "4.13.2";
4414
4519
  //#endregion
4415
4520
  //#region src/host-heartbeat.ts
4416
4521
  /**
@@ -4568,4 +4673,4 @@ async function clearHostHeartbeat() {
4568
4673
  await rm(workbuddyHostHeartbeatPath(), { force: true });
4569
4674
  }
4570
4675
  //#endregion
4571
- 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 };
4676
+ export { withEffectiveOff 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, defaultOffModelIds as J, systemOpeningsOfBody as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, recordRefusal as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, parseUpstreamModel as U, parseCreditMultiplier as V, prepareChatBody as W, offDefaultFor as X, lastRefusalFor as Y, offRefused as Z, WORKBUDDY_AUTH_FILENAME as _, writeFileAtomic as _t, readHostHeartbeat as a, findWorkbuddyAppExecutable as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, isWorkbuddyBundle as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, openEncryptedField as dt, WORKBUDDY_APP_EXECUTABLE_ENV as et, WorkBuddyCatalog as f, readAtRestKey as ft, ENCRYPTED_CREDENTIAL_CODE as g, withFileLock as gt, fallbackModelsFor as h, resolveDshHome as ht, processStartTimeMs as i, fetchAtRestKeyPayload as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, macosBundleExecutable as lt, deriveCatalog as m, hostPeersInfo as mt, clearHostHeartbeat as n, deriveAtRestKey as nt, workbuddyHostHeartbeatPath as o, findWorkbuddyAppExecutableWithSource as ot, applyContextBudgets as p, workbuddyAppExecutableCandidates as pt, REFUSES_OFF_MODEL_IDS as q, isHeartbeatProcessAlive as r, deriveAtRestKeyId as rt, writeHostHeartbeat as s, isEncryptedFieldWrapper as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, clearAtRestKeyCache as tt, FALLBACK_WORKBUDDY_MODELS as u, macosNestedAppBundles 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
@@ -609,7 +609,27 @@ type UpstreamErrorKind = 'hard_credit' |
609
609
  * retrying, or switch to a less busy model." Both halves are rendered to the
610
610
  * user; the second one is the actual remedy.
611
611
  */
612
- 'model_busy' | 'session_dead' | 'policy_reject' | 'not_found' | 'server' | 'client';
612
+ 'model_busy' | 'session_dead' | 'policy_reject' |
613
+ /**
614
+ * The gateway's channel gate refused the request itself (business code
615
+ * 11128, "Illegal API invocation from an unapproved channel").
616
+ *
617
+ * Deliberately its own kind, because its `displayTips` — "this call failed
618
+ * channel verification, possibly because it was not sent from an official
619
+ * client" — names the account or the client version, and NEITHER is the
620
+ * cause. The gate matches the OPENING of a `system` message against a list of
621
+ * known third-party-agent prompts, so the same credential answers 200 to a
622
+ * probe and 400 to a real chat request one second later (issues #87 / #88 /
623
+ * #89 / #90). Filing it as `client` was technically true and practically
624
+ * useless: the generic "refused on its parameters" sentence left the reader
625
+ * to try another account, which cannot change a byte of the prompt.
626
+ *
627
+ * NOT failover-worthy for the same reason: every account sends the same
628
+ * system prompt, so a second account answers the same 11128 one round trip
629
+ * later and each attempt teaches the pool a false fact about a credential
630
+ * that was never the problem.
631
+ */
632
+ 'channel_reject' | 'not_found' | 'server' | 'client';
613
633
  /** Reasoning capability as the upstream catalog declares it. */
614
634
  interface WorkBuddyReasoning {
615
635
  supportedEfforts?: readonly string[];
@@ -2034,8 +2054,16 @@ declare function probeRequestBody(modelId: string, inputTokens?: number): string
2034
2054
  * loaded, everywhere, for everyone; a 6004 says THIS ACCOUNT exceeded a volume
2035
2055
  * limit. Reporting both as `rate-limited` made the pool bench every account for
2036
2056
  * the cooldown of one, which is the outage issue #56 describes.
2057
+ *
2058
+ * `channel-rejected` is a third split, and it is the one that answers the
2059
+ * question the probe itself raises: the probe's own system prompt is a neutral
2060
+ * one-liner, so it can return `ok` on a model whose real chat requests are all
2061
+ * refused with `11128`. Filing that as `failed` (the generic bucket) made the
2062
+ * pair "test passes, chat fails, every account" look like a flaky upstream
2063
+ * instead of what it is — the gateway objecting to the PROMPT, not the account
2064
+ * (issues #87–#90).
2037
2065
  */
2038
- type WorkBuddyProbeOutcome = 'ok' | 'rate-limited' | 'model-busy' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' | 'unavailable' | 'not-found' | 'failed';
2066
+ type WorkBuddyProbeOutcome = 'ok' | 'rate-limited' | 'model-busy' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' | 'channel-rejected' | 'unavailable' | 'not-found' | 'failed';
2039
2067
  /**
2040
2068
  * One probe result, as the card renders it.
2041
2069
  *
@@ -2122,9 +2150,10 @@ declare function cooldownOf(input: {
2122
2150
  * answer means. The extra splits here are `credential-rejected`, which the
2123
2151
  * upstream signals with a non-JSON 401/403 edge page and which needs completely
2124
2152
  * different advice (re-auth, not "wait"), `policy-rejected`, a JSON 403
2125
- * content-policy refusal where re-auth advice would be actively misleading, and
2153
+ * content-policy refusal where re-auth advice would be actively misleading,
2126
2154
  * `model-busy`, the model-scoped 14003 throttle that must NOT be benched as an
2127
- * account failure (issue #56).
2155
+ * account failure (issue #56), and `channel-rejected`, the gateway's 11128 gate
2156
+ * on the PROMPT's opening, which no account can change.
2128
2157
  */
2129
2158
  declare function outcomeOfFailure(status: number, body: string): WorkBuddyProbeOutcome;
2130
2159
  /**
@@ -2822,7 +2851,17 @@ type WorkBuddyWebProbeOutcome = 'ok' | 'rate-limited' |
2822
2851
  * to learn what the first one already said. The remedy is the upstream's own:
2823
2852
  * wait about a minute, or switch to a less busy model.
2824
2853
  */
2825
- 'model-busy' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' | 'unavailable' | 'not-found' | 'failed';
2854
+ 'model-busy' | 'out-of-credit' | 'credential-rejected' | 'policy-rejected' |
2855
+ /**
2856
+ * The gateway's channel gate refused the request itself (upstream code 11128).
2857
+ *
2858
+ * Kept apart from `failed` because its whole point is the question it answers:
2859
+ * the probe passes and the chat fails on the SAME model and the SAME account,
2860
+ * because the gate matches the OPENING of a `system` message and the probe's
2861
+ * is a neutral one-liner. A generic failure would leave the user swapping
2862
+ * accounts that cannot change a byte of their prompt (issues #87–#90).
2863
+ */
2864
+ 'channel-rejected' | 'unavailable' | 'not-found' | 'failed';
2826
2865
  /** One model's probe result, as the card renders it. */
2827
2866
  interface WorkBuddyWebProbeResult {
2828
2867
  modelId: string;
package/lib/index.js CHANGED
@@ -1,11 +1,11 @@
1
- import { $ as WORKBUDDY_APP_EXECUTABLE_ENV, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as lastRefusalFor, K as REFUSES_OFF_MODEL_IDS, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as withEffectiveOff, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as offRefused, Y as offDefaultFor, Z as recordRefusal, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as macosBundleExecutable, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as readAtRestKey, et as clearAtRestKeyCache, f as WorkBuddyCatalog, ft as workbuddyAppExecutableCandidates, g as ENCRYPTED_CREDENTIAL_CODE, gt as writeFileAtomic, h as fallbackModelsFor, ht as withFileLock, i as processStartTimeMs, it as findWorkbuddyAppExecutable, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as macosNestedAppBundles, m as deriveCatalog, mt as resolveDshHome, n as clearHostHeartbeat, nt as deriveAtRestKeyId, o as workbuddyHostHeartbeatPath, ot as isEncryptedFieldWrapper, p as applyContextBudgets, q as defaultOffModelIds, r as isHeartbeatProcessAlive, rt as fetchAtRestKeyPayload, s as writeHostHeartbeat, st as isWorkbuddyBundle, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as deriveAtRestKey, u as FALLBACK_WORKBUDDY_MODELS, ut as openEncryptedField, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-CxoHaypp.js";
1
+ import { $ as withEffectiveOff, 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 defaultOffModelIds, K as systemOpeningsOfBody, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as recordRefusal, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as offDefaultFor, Y as lastRefusalFor, Z as offRefused, _ as WORKBUDDY_AUTH_FILENAME, _t as writeFileAtomic, a as readHostHeartbeat, at as findWorkbuddyAppExecutable, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as isWorkbuddyBundle, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as openEncryptedField, et as WORKBUDDY_APP_EXECUTABLE_ENV, f as WorkBuddyCatalog, ft as readAtRestKey, g as ENCRYPTED_CREDENTIAL_CODE, gt as withFileLock, h as fallbackModelsFor, ht as resolveDshHome, i as processStartTimeMs, it as fetchAtRestKeyPayload, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as macosBundleExecutable, m as deriveCatalog, n as clearHostHeartbeat, nt as deriveAtRestKey, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, pt as workbuddyAppExecutableCandidates, q as REFUSES_OFF_MODEL_IDS, r as isHeartbeatProcessAlive, rt as deriveAtRestKeyId, s as writeHostHeartbeat, st as isEncryptedFieldWrapper, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as clearAtRestKeyCache, u as FALLBACK_WORKBUDDY_MODELS, ut as macosNestedAppBundles, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BJAL9pEe.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";
5
5
  import { join } from "node:path";
6
6
  import { createAssistantMessageEventStream, createProvider, isContextOverflow } from "@earendil-works/pi-ai";
7
7
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
8
- import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
8
+ import { ReasoningEffortId, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
9
9
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
10
10
  import { createServer } from "node:http";
11
11
  import { Readable } from "node:stream";
@@ -645,6 +645,103 @@ function withContextRescue(api, resolve) {
645
645
  };
646
646
  }
647
647
  /**
648
+ * The tier {@link EffortNormalizingAdapter} pins a model to when the model
649
+ * carries no reasoning metadata of its own.
650
+ *
651
+ * `off` is the ONE tier pi-ai offers for such a model (`getSupportedThinkingLevels`
652
+ * returns `['off']` whenever `model.reasoning` is false), and the host's own
653
+ * translation of it is "send no reasoning field at all" (`profileOptions` turns
654
+ * `off` into `enabledReasoning = undefined`). So pinning it neither invents a
655
+ * capability nor changes one byte of the upstream request body.
656
+ */
657
+ const NO_REASONING_EFFORT = ReasoningEffortId("off");
658
+ /** What the host is told a reasoning-less model supports: exactly that tier. */
659
+ const NO_REASONING_EFFORTS = { efforts: [{
660
+ id: NO_REASONING_EFFORT,
661
+ name: "Off"
662
+ }] };
663
+ /**
664
+ * pi-ai adapter that keeps a model WITHOUT reasoning metadata dispatchable
665
+ * (issue #90, the `UNSUPPORTED_REASONING_EFFORT` half).
666
+ *
667
+ * THE FAILURE. `profileFor` pins one default tier per ROUTE
668
+ * (`reasoning: WORKBUDDY_DEFAULT_EFFORT`, issue #83), and `dsh-llm-pi-ai`
669
+ * resolves every request as `options.reasoningEffort ?? profile.reasoning`. For
670
+ * a model whose catalog entry carries no `reasoning` block at all — the live CN
671
+ * catalog has six of them (`default`, `glm-5.0`, `glm-4.7`, `glm-4.6`,
672
+ * `hunyuan-chat`, `hunyuan-image-alpha`), the live global one two
673
+ * (`default-model`, `deep-model`), and EVERY entry of both static fallback lists
674
+ * — the inherited `high` is not a tier that model has. `resolveReasoningLevel`
675
+ * then throws `UNSUPPORTED_REASONING_EFFORT` before the request is even built,
676
+ * so the model is unusable locally: no wire call, no upstream error, nothing to
677
+ * retry. Introduced by v4.11.0, which made the profile default explicit; before
678
+ * it the default was absent and pi-ai clamped instead of throwing.
679
+ *
680
+ * TWO GATES, BOTH CLOSED. The host checks the adapter's own model metadata
681
+ * first: `resolveCallWithInfo` rejects ANY explicitly requested tier on a model
682
+ * whose `reasoning` is absent, and `reasoningInfo` omits the field exactly for
683
+ * models without metadata — so even `off` was refused. Then `PiAiAdapter.streamWithSnapshot`
684
+ * rejects the route default it inherited. A fix at either seam alone still
685
+ * fails at the other.
686
+ *
687
+ * THE FIX. Advertise the one tier pi-ai will actually accept for such a model
688
+ * (`off`) and normalize the request to it at the dispatch seam, so
689
+ * `resolveReasoningLevel` sees a supported tier and `profileOptions` sends no
690
+ * reasoning field. Nothing about the wire body changes — pi-ai never emits
691
+ * `reasoning_effort` for a model whose descriptor has `reasoning: false`.
692
+ *
693
+ * WHAT STAYS STRICT. A tier the caller EXPLICITLY asks for on a model that
694
+ * cannot do it is still rejected, by the host, with an accurate message. That
695
+ * is the pre-v4.11.0 contract too, and it is not this bug: the bug is a request
696
+ * that named no tier being killed by the route's own default.
697
+ */
698
+ var EffortNormalizingAdapter = class extends PiAiAdapter {
699
+ /**
700
+ * Whether one exact model id currently carries reasoning metadata. Read live
701
+ * on every call because the catalog is refreshed behind the adapter
702
+ * (`invalidate`), and a model that gains metadata mid-session must stop being
703
+ * normalized at once.
704
+ */
705
+ effortCapable;
706
+ constructor(config, effortCapable) {
707
+ super(config);
708
+ this.effortCapable = effortCapable;
709
+ }
710
+ async resolveModel(provider, model, signal) {
711
+ return this.advertiseOffOnly(await super.resolveModel(provider, model, signal));
712
+ }
713
+ async prepareCall(provider, model, signal) {
714
+ const call = await super.prepareCall(provider, model, signal);
715
+ return {
716
+ model: this.advertiseOffOnly(call.model),
717
+ stream: (options) => call.stream(this.pinEffort(options))
718
+ };
719
+ }
720
+ stream(options) {
721
+ return super.stream(this.pinEffort(options));
722
+ }
723
+ /**
724
+ * Give a reasoning-less model the one tier it can dispatch at. No
725
+ * `defaultEffort` on purpose: the host materializes one into EVERY request
726
+ * that names no tier, and that would turn the host's own prepared-call config
727
+ * check (`callConfigEquals`) against callers that omit the field.
728
+ */
729
+ advertiseOffOnly(info) {
730
+ return info.reasoning === void 0 ? {
731
+ ...info,
732
+ reasoning: NO_REASONING_EFFORTS
733
+ } : info;
734
+ }
735
+ /** Replace an unusable tier with the one pi-ai accepts for this model. */
736
+ pinEffort(options) {
737
+ if (this.effortCapable(options.model)) return options;
738
+ return options.reasoningEffort === NO_REASONING_EFFORT ? options : {
739
+ ...options,
740
+ reasoningEffort: NO_REASONING_EFFORT
741
+ };
742
+ }
743
+ };
744
+ /**
648
745
  * Assemble the adapter. The provider's `getModels` reads the live catalog,
649
746
  * and every model's `baseUrl` is re-resolved per read so the shim's
650
747
  * ephemeral port applies from the first snapshot after startup.
@@ -707,13 +804,22 @@ function createWorkBuddyAdapter(options) {
707
804
  });
708
805
  const buildProfiles = () => new Map(modelIdentityKeysOf(providerId, modelIdentity).map((key) => [key, profileFor(key)]));
709
806
  let profiles = buildProfiles();
807
+ /**
808
+ * Whether one exact model id currently advertises ANY thinking tier.
809
+ *
810
+ * Same predicate `toPiModel` derives the descriptor's `reasoning` flag from,
811
+ * so the two can never disagree — and it reads the live catalog, so a refresh
812
+ * that adds (or drops) metadata for a model takes effect on the next request
813
+ * without an `invalidate` round trip.
814
+ */
815
+ const effortCapable = (model) => catalog.current().some((info) => info.id === model && workBuddyThinkingLevelMap(info) !== void 0);
710
816
  return {
711
- adapter: new PiAiAdapter({
817
+ adapter: new EffortNormalizingAdapter({
712
818
  profiles: () => profiles,
713
819
  auth: INERT_AUTH,
714
820
  resolveApiKey: async () => shim.token(),
715
821
  ...resolveAttachments === void 0 ? {} : { resolveAttachments }
716
- }),
822
+ }, effortCapable),
717
823
  invalidate: () => {
718
824
  profiles = buildProfiles();
719
825
  }
@@ -2170,6 +2276,7 @@ const KIND_STATUS = {
2170
2276
  model_busy: 429,
2171
2277
  session_dead: 401,
2172
2278
  policy_reject: 403,
2279
+ channel_reject: 400,
2173
2280
  not_found: 502,
2174
2281
  server: 502,
2175
2282
  client: 400
@@ -2238,6 +2345,13 @@ const KIND_STATUS = {
2238
2345
  * reset is hours away, so the retry budget cannot produce an answer and must
2239
2346
  * not be spent. One word, two opposite temporal claims — so the spelling
2240
2347
  * follows the clock, not the status code both share.
2348
+ *
2349
+ * `channel_reject` is not listed, and the reason is the same rule applied to a
2350
+ * third clock: the gate's verdict is DETERMINISTIC for a given prompt, so an
2351
+ * in-place retry would spend the budget re-sending bytes it already refused, and
2352
+ * cross-provider failover cannot help either (every account sends that same
2353
+ * prompt). Its 400 already places it outside the host's retry set, so `type`
2354
+ * alone, untranslated, is the honest spelling.
2241
2355
  */
2242
2356
  const KIND_HOST_CODE = { soft_rate: "quota_exceeded" };
2243
2357
  /**
@@ -2810,6 +2924,7 @@ function failoverReasonText(failure) {
2810
2924
  case "model_busy": return "该模型当前繁忙(不是账号问题)";
2811
2925
  case "session_dead": return "登录已失效";
2812
2926
  case "policy_reject": return "被服务端策略拒绝";
2927
+ case "channel_reject": return "被网关的渠道校验拦截(不是账号、额度或网络问题)";
2813
2928
  case "not_found": return "模型不可用";
2814
2929
  case "server": return "上游异常";
2815
2930
  case "client": return "请求被上游拒绝";
@@ -3141,7 +3256,7 @@ function createWorkBuddyShim(options) {
3141
3256
  * which another account may well survive.
3142
3257
  */
3143
3258
  function isFailoverWorthy(kind) {
3144
- return kind !== "client" && kind !== "model_busy";
3259
+ return kind !== "client" && kind !== "model_busy" && kind !== "channel_reject";
3145
3260
  }
3146
3261
  async function chatCompletions(req, res) {
3147
3262
  if (!isJsonContentType(req)) {
@@ -3269,6 +3384,18 @@ function createWorkBuddyShim(options) {
3269
3384
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
3270
3385
  return;
3271
3386
  }
3387
+ if (result.kind === "channel_reject") {
3388
+ const detail = result.detail;
3389
+ const meta = [];
3390
+ if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
3391
+ if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
3392
+ const stamp = meta.length === 0 ? "" : `(${meta.join(",")})`;
3393
+ const openings = systemOpeningsOfBody(prepared);
3394
+ const evidence = openings.length === 0 ? "" : `命中的 system 提示开头:${openings.map((opening) => `「${opening}」`).join("、")}。`;
3395
+ const message = `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${stamp}。${evidence}该请求被 WorkBuddy 网关的渠道校验拦截,上游把它描述成「不是官方客户端发出的」,但那是误导性文案:网关匹配的是 system 提示的开头,与账号、积分、额度、网络都无关,换账号或换区域也不会改变结果。若这段开头来自宿主或其他插件,请修改该提示;插件已对已知的第三方 agent 指纹做归一化,新的指纹可反馈到 issue 里`;
3396
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
3397
+ return;
3398
+ }
3272
3399
  const detail = result.detail;
3273
3400
  const meta = [];
3274
3401
  if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
@@ -3628,9 +3755,10 @@ function cooldownOf(input) {
3628
3755
  * answer means. The extra splits here are `credential-rejected`, which the
3629
3756
  * upstream signals with a non-JSON 401/403 edge page and which needs completely
3630
3757
  * different advice (re-auth, not "wait"), `policy-rejected`, a JSON 403
3631
- * content-policy refusal where re-auth advice would be actively misleading, and
3758
+ * content-policy refusal where re-auth advice would be actively misleading,
3632
3759
  * `model-busy`, the model-scoped 14003 throttle that must NOT be benched as an
3633
- * account failure (issue #56).
3760
+ * account failure (issue #56), and `channel-rejected`, the gateway's 11128 gate
3761
+ * on the PROMPT's opening, which no account can change.
3634
3762
  */
3635
3763
  function outcomeOfFailure(status, body) {
3636
3764
  if (status === 401 || status === 403) {
@@ -3642,6 +3770,7 @@ function outcomeOfFailure(status, body) {
3642
3770
  case "model_busy": return "model-busy";
3643
3771
  case "hard_credit": return "out-of-credit";
3644
3772
  case "session_dead": return "credential-rejected";
3773
+ case "channel_reject": return "channel-rejected";
3645
3774
  case "not_found": return "not-found";
3646
3775
  default: return status === 0 || status >= 500 ? "unavailable" : "failed";
3647
3776
  }
@@ -3990,6 +4119,7 @@ function exclusionOf(probe, nowMs) {
3990
4119
  case "model-busy": return retryDue(probe, nowMs, 12e4) ? void 0 : "model-busy";
3991
4120
  case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
3992
4121
  case "policy-rejected": return;
4122
+ case "channel-rejected": return;
3993
4123
  default: return "unusable";
3994
4124
  }
3995
4125
  }
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.13.0",
5
+ "version": "4.13.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",