@huai_xin/dsh-workbuddy 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -160,7 +160,8 @@ dsh-workbuddy/
160
160
 
161
161
  - 按**能不能用**分两组:`可用模型` / `不可用模型`(探测判定 + 真实调用的自学习)。不可用分组默认折叠。
162
162
  - 每行:左侧圆点是台账颜色(绿=可用、黄=排队、红=不可用/无权限、灰=未检测),中间是模型名,右侧依次是**可用性标签 · 促销标签 · 繁忙程度 · 实时倍率**,最后是该模型的**启停开关**(停用的行会变淡)。
163
- - 点任意一行展开详情:描述、标识、上下文、最大输出、模态、思考档位、可关闭思考、倍率、繁忙程度、促销类型/时限/详情。所有标签共用一种样式,颜色只表示语义。
163
+ - 点任意一行展开详情:描述、标识、上下文、最大输出、模态、思考档位、倍率、繁忙程度、促销类型/时限/详情。所有标签共用一种样式,颜色只表示语义。
164
+ - **思考恒为开启**:上游只声明"可否关闭思考",却从不给出能兑现它的请求参数(实测见[协议文档 §4.3.1](docs/workbuddy-protocol.md)),所以详情里不放这一项 —— 能调的只有**档位**(低/中/高/超高/极致)。
164
165
  - 倍率取不到就显示「未知」(**不猜、不用旧值兜底**),`0` 显示「免费」标签。
165
166
  - 繁忙程度**只有大陆版有**(`POST /v2/chat/busy`;海外版 404,那一列整个不显示):空闲 / 繁忙 / 拥堵 / 未知四档,用词与官方前端一致(官方把 `normal` 与 `idle` 并成「空闲」),悬停给出官方的完整说法(轻微拥堵 / 极度拥堵)。
166
167
  - 工具栏:搜索框、「全部启用 / 全部停用」(**只作用于可用分组**,不可用分组自己手动调)、「⟳ 刷新列表」、「检测可用性」(手动,立即执行)。这两个按钮宽度固定,「检测中…」不会把 ⟳ 挤走。
package/client/index.js CHANGED
@@ -106,8 +106,6 @@ window.__ModuleLoader__.load({
106
106
  detailRate: '倍率',
107
107
  detailDescription: '描述',
108
108
  detailModality: '模态',
109
- detailCanDisableThinking: '可关闭思考',
110
- detailNo: '否',
111
109
  freeModel: '免费',
112
110
  promoDiscount: '折扣',
113
111
  detailPromoKind: '促销类型',
@@ -120,7 +118,6 @@ window.__ModuleLoader__.load({
120
118
  busyBusyOfficial: '轻微拥堵',
121
119
  busyCongestedOfficial: '极度拥堵',
122
120
  busyUnknown: '未知',
123
- detailYes: '是',
124
121
  modalityText: '文本',
125
122
  modalityImage: '图像',
126
123
  noModels: '登录后这里会显示该账号可用的模型'
@@ -193,8 +190,6 @@ window.__ModuleLoader__.load({
193
190
  detailRate: 'Rate',
194
191
  detailDescription: 'Description',
195
192
  detailModality: 'Modality',
196
- detailCanDisableThinking: 'Thinking switch',
197
- detailNo: 'no',
198
193
  freeModel: 'free',
199
194
  promoDiscount: 'discount',
200
195
  detailPromoKind: 'Promotion',
@@ -207,7 +202,6 @@ window.__ModuleLoader__.load({
207
202
  busyBusyOfficial: 'slightly congested',
208
203
  busyCongestedOfficial: 'extremely congested',
209
204
  busyUnknown: 'unknown',
210
- detailYes: 'yes',
211
205
  modalityText: 'text',
212
206
  modalityImage: 'image',
213
207
  noModels: 'Models appear here once the account is signed in'
@@ -1004,9 +998,6 @@ window.__ModuleLoader__.load({
1004
998
  add(t('detailEfforts'), model.efforts.length === 0
1005
999
  ? undefined
1006
1000
  : h('span', { className: 'wb-tags' }, model.efforts.map((level) => detailTag(level))))
1007
- add(t('detailCanDisableThinking'), typeof model.canDisableThinking === 'boolean'
1008
- ? (model.canDisableThinking === true ? t('detailYes') : t('detailNo'))
1009
- : undefined)
1010
1001
  add(t('detailRate'), model.credits === undefined
1011
1002
  ? t('unknown')
1012
1003
  : (isFreeModel(model) ? detailTag(t('freeModel'), ' wb-tag-accent') : (formatRate(model.credits) ?? model.credits)))
Binary file
@@ -176,9 +176,56 @@ POST <chatEndpoint>/v2/chat/completions
176
176
  | `tools` | 否 | OpenAI function 形态,70 个工具 / 60 KB 实测通过 ✅ |
177
177
  | `prompt_cache_key` | 否 | 服务端前缀缓存键;缺失时缓存命中恒为 0 📖 |
178
178
  | `thinking` | 否 | `{"type":"enabled"}`,deepseek 系使用 📖✅ |
179
- | `reasoning_effort` | 否 | `low` / `medium` / `high` / `xhigh` / `max`,取值须在该模型支持列表内 📖 |
179
+ | `reasoning_effort` | 否 | `low` / `medium` / `high` / `xhigh` / `max`,取值须在该模型支持列表内 ✅ 网关会校验(见下) |
180
180
  | `temperature` / `stop` | 否 | 直通 ✅ |
181
181
 
182
+ **`reasoning_effort` 是真实生效的参数**(2026-10-22 实测,海外版 `deepseek-v4.1-flash`,免费模型,
183
+ 脚本 `test/live/effort-effect.mjs`,`ROUNDS` 可重复):
184
+
185
+ 1. **网关会校验它**:传一个目录里没有的值(`totally-bogus`)→ HTTP 400
186
+ `{"code":11133,"msg":"the request parameters were rejected by the model provider",
187
+ "extError":{"code":"invalid_parameter_value"}}`——说明字段确实被转发给模型服务商,不是被忽略 ✅
188
+ 2. **在一句话就能答的题上,三档看不出区别**:鸡兔同笼题 12 次采样,
189
+ `low` 241 字 / `high` 232 字 / `max` 214 字(单档内波动 44–81 字)——低档也会思考 200+ 字,
190
+ 所以差距被噪声淹没。
191
+ 3. **在需要多步推理的题上,档位单调分档**(三位数推理题,各 3 次):
192
+
193
+ | 档位 | 思考字数(均值) | 范围 | completion tokens(均值) |
194
+ | --- | --- | --- | --- |
195
+ | `low` | 982 | 674–1512 | 615 |
196
+ | `high` | 3605 | 1463–5759 | 1324 |
197
+ | `max` | 6115 | 5460–6461 | 2048(撞到 `max_tokens` 上限) |
198
+
199
+ → 档位有意义,但**题越简单越看不出来**;`max` 在难题上会一路顶到 `max_tokens`。
200
+
201
+ 4. **用量里有 `completion_tokens_details.reasoning_tokens`**,与思考文字数一致;免费模型返回
202
+ `credit: 0` ✅(这也是"免费"标签的判据之一)。
203
+
204
+ **不同厂商的请求结构由厂商自己校验,网关只做转发**(2026-10-22 实测,`test/live/request-structure.mjs`
205
+ 逐字段试探;免费模型优先,海外版免费档有 `deepseek-v4.1-flash` / `hy3` / `step-5-preview`):
206
+
207
+ | 变体 | deepseek-v4.1-flash | step-5-preview(阶跃) | hy3(混元) | glm-5.3-flash(智谱) |
208
+ | --- | --- | --- | --- | --- |
209
+ | 基线(不带档位) | 200,思考 **0 字** | 200,思考 596 字 | 200,思考 370 字 | 200,思考 **0 字** |
210
+ | `+ reasoning_effort:"<合法档>"` | 200,思考 **261 字** | 200,447 字 | 200,502 字 | 200,思考 **337 字** |
211
+ | `+ reasoning_effort:"totally-bogus"` | **400 `11133` 拒绝** | 200 静默忽略 | 200 静默忽略 | 200 静默忽略 |
212
+ | `+ thinking:{type:"enabled"}` | 200,仍 0 字 | 200 | 200 | 200,35 字 |
213
+ | `+ thinking:{type:"disabled"}` | 200 | 200,仍思考 379 字 | 200 | 200,0 字 |
214
+ | `+ thinking:{type:"nonsense"}` | 200 | 200 | 200 | 200 |
215
+ | `+ max_completion_tokens`(去掉 `max_tokens`) | 200 | — | 200 | 200 |
216
+ | `+ 未知字段` | 200 | 200 | 200 | 200 |
217
+
218
+ 结论:
219
+
220
+ 1. **只有 DeepSeek 系会校验 `reasoning_effort`**(非法值 400 `11133`);其余厂商静默忽略非法值。
221
+ 说明网关**不做统一校验/改写**,请求体基本按原样转给各厂商自己的 API ✅
222
+ 2. **`thinking` 字段实际上不是开关**:`enabled` / `disabled` / 乱填一律 200,思考量也没跟着变
223
+ (glm 那次 35 字的差异在噪声范围内)。所以插件只在官方客户端确实会发的 deepseek 系带这个字段。
224
+ 3. **真正决定"思不思考"的是有没有 `reasoning_effort`**:deepseek 与 glm **不带该字段 → 思考 0 字**
225
+ (完全不思考);hy3 / step 属于强制思考型(`onlyReasoning`),不带也会思考。
226
+ → 因此适配器对**任何声明了档位的模型**都必须带上档位(选中的,否则目录里的 `defaultEffort`,
227
+ 再次是 `high`),这正是"思考恒为开启"的落地方式;没声明档位的模型才不带 ✅
228
+
182
229
  **历史消息里的 `reasoning_content` 会原样回放**,服务端接受 ✅(不做回放也能跑通)。
183
230
 
184
231
  ### 3.2 响应(SSE)
@@ -193,6 +240,19 @@ data: [DONE]
193
240
  - `delta` 实测字段:`role` / `content` / `reasoning_content` / `function_call` / `refusal` / `tool_calls` / `extra_fields` ✅
194
241
  - 流式工具调用:**仅首个分片带真实 `id`**,后续分片只有 `index`,需按 index 归并 📖✅
195
242
  - 末帧 `finish_reason`:`stop` / `tool_calls` / `length` ✅
243
+ - **流一定以 `data: [DONE]` 收尾**;没有它、也没有 `finish_reason` 就断流 = 连接被中途掐断
244
+ (不是模型"决定停下")📖。插件据此判定:这种情况必须上报 `TRANSPORT` 错误交给重试策略,
245
+ **绝不能**当成正常 `stop` —— 曾经因此出现"看着还没说完就结束了,也不重试"的现象 ✅(已修)。
246
+ 判定与补救都有日志:断流时打一行带证据的告警(收到多少字节、用时多久、中断时在输出哪一类内容),
247
+ 重试时再由插件订阅 `session/event` 的 `llm/retry` 打一行"第 N/M 次重试(TRANSPORT)… 后重试"。
248
+ 频率实测工具:`node test/live/stream-cut-probe.mjs [cn|intl] [次数] [模型] [--long] [--selftest]`
249
+ (2026-10-22 实测:大陆版短问答 4/4 正常、海外版 `deepseek-v4.1-flash` 长回答 10/10 正常,
250
+ 即断流是间歇性的,常规探测不易复现)
251
+ - **finish 帧带一个 `response` 诊断块**(`kind`/`version`/`api`/`provider`/`model`/`responseId`/
252
+ `stopReason`/`outputTokens`/`blocks`)。它不是 harness 的契约字段(`dsh-llm` 的类型里没有
253
+ `response` / `stopReason`),纯属遥测:会话诊断里会原样显示,用来一眼回答"这一步是谁答的、
254
+ 上游叫它什么 id",不必再从 `request/header` 反推。`responseId` 取自上游流里的 `id`
255
+ (形如 `chatcmpl-…`),可以直接和上游日志对账;harness 不会据此改变行为 ✅
196
256
  - `usage`(末帧携带):`prompt_tokens` / `completion_tokens` / `total_tokens` / `prompt_tokens_details.cached_tokens` / `completion_tokens_details.reasoning_tokens` / `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` / `credit` ✅
197
257
 
198
258
  ---
@@ -253,7 +313,7 @@ data: [DONE]
253
313
  | `contextWindow` | 仅部分模型有:`{defaultLength, supportedLengths:[…]}`(如 step-5: `[300000, 1000000]`) |
254
314
  | `maxOutputTokens` | 输出上限 |
255
315
  | `supportsImages` / `supportsReasoning` / `supportsToolCall` / `disabledMultimodal` | 能力位 |
256
- | `onlyReasoning` / `canDisableThinking` | 是否强制思考 / 可否关闭 |
316
+ | `onlyReasoning` / `canDisableThinking` | 是否强制思考 / 上游的"可否关闭思考"声明 —— **插件忽略它**,理由见 §4.3.1 |
257
317
  | `reasoning` | `{effort, summary, defaultEffort, supportedEfforts:[…]}`;`effort` 是默认档,`supportedEfforts` 才是可选集合 |
258
318
  | `relatedModels` | 关联模型映射,如 `{lite, reasoning, builtin-lite}` |
259
319
  | `credits` | 倍率字符串,如 `"x0.00 credits"` / `"x2.20 credits"` / `"x0.05"` |
@@ -263,8 +323,32 @@ data: [DONE]
263
323
  | `iconUrl` / `iconUrl_en` | 图标(base64 data-URI 或 HTTPS URL,按语言选) |
264
324
  | `AccountAttributes` ❓ | 出现在积分包里,模型侧未见 |
265
325
 
266
- ### 4.3 `/v3/config` 结构
326
+ #### 4.3.1 `canDisableThinking`:上游没给关闭参数,插件选择"忽略它、思考恒开"(2026-10-22 实测)
327
+
328
+ 三条结论都有原始响应与缓存对比支撑(`node test/live/reasoning-audit.mjs [cn|intl]`):
329
+
330
+ 1. **上游只发一个布尔位,不发参数名。** `reasoning` 块出现过的字段只有
331
+ `canDisableThinking` / `defaultEffort` / `effort` / `summary` / `supportedEfforts`;
332
+ 整条记录里像"开关"的字段只有 `canDisableThinking` 和 `disabledMultimodal`(后者是
333
+ 多模态能力,与思考无关)。也就是说:**"能不能关"它说了,"怎么关"它没说。**
334
+ 反向参数(`thinking:{type:'disabled'}` / `enable_thinking:false` /
335
+ `reasoning:{effort:'none'}` 之类)**一个都没验证过**。
336
+ 2. **两个部署各自独立声明,而且会变。** 同一天里:大陆版对 `glm-5.2`、`kimi-k3-1`、
337
+ `deepseek-v4-flash`、`deepseek-v4-pro`、`hy3` **完全不发**该字段,海外版对同样的 id
338
+ 一律发 `true`/`false`;大陆版过去发过、现在不发了 —— 说明它不是稳定契约。
339
+ 3. **插件的决定:忽略这个字段,思考恒开。** 既然"能关"无法兑现(没有参数),就不要在
340
+ 面板上宣称它 —— 于是:
341
+ - 目录解析不再读取该字段(`catalog.js`),合并、面板载荷、账号名单富化里也一并去掉;
342
+ - 面板详情不再有「可关闭思考」这一行(没有开关就不摆开关);
343
+ - **唯一能调的仍然是档位**(`reasoning.supportedEfforts` → 低/中/高/超高/极致);
344
+ - `thinking:{type:'enabled'}` 目前只对 deepseek 系发送(§4.5 表,📖✅ 已验证),
345
+ 其它族不发这个字段(它们本身就是 `onlyReasoning` 模型,默认就在思考);
346
+ - 缓存里把该字段当作**已废弃字段**处理:老快照读出来就丢掉,不再留痕。
347
+
348
+ 如果将来上游给出真正的关闭参数,正确做法是把它变成一个档位(例如「关闭」)交给
349
+ 选择器,而不是恢复一个布尔位。
267
350
 
351
+ ### 4.3 `/v3/config` 结构
268
352
  ```json
269
353
  {"code":0,"data":{
270
354
  "$schema":"product-schema.json",
@@ -320,7 +404,7 @@ Web 端 picker 的悬停卡片会显示简介、消耗速度、繁忙程度、
320
404
  | 名称(含「极致」「快速」等预设名) | 目录 | `name` ✅ |
321
405
  | 简介「优先深度与准确性…」 | 目录 | `descriptionZh` / `descriptionEn`,前端按 locale 选(`zh*` → `descriptionZh`)✅ |
322
406
  | 消耗速度 `1.20x 倍率` | 目录 | `credits`,如 `"x1.20 credits"`;前端只取数字再加 `x`(`/(\d+(?:\.\d+)?)/` → `1.20x`)✅ |
323
- | 思考档位(低/中/高/超高/极致) | 目录 | `reasoning.supportedEfforts`(可选集合)、`reasoning.defaultEffort`、`onlyReasoning`、`canDisableThinking`、`supportsReasoning`;**档位文案是前端 i18n**(`model.effort.{low,medium,high,xhigh,max}.label` → 低/中/高/超高/极致),不是服务端下发 ✅ |
407
+ | 思考档位(低/中/高/超高/极致) | 目录 | `reasoning.supportedEfforts`(可选集合)、`reasoning.defaultEffort`、`onlyReasoning`、`supportsReasoning`;**档位文案是前端 i18n**(`model.effort.{low,medium,high,xhigh,max}.label` → 低/中/高/超高/极致),不是服务端下发 ✅ |
324
408
  | 上下文窗口选择 | 目录 | `contextWindow.supportedLengths` + `maxInputTokens`(前端命名 `supportedContextWindows` / `defaultContextWindow`)✅ |
325
409
  | 徽章(如「限时免费」) | 目录 | `tags`,形如 `badge:限时免费:#FF0000`;另有 `isDefault`、`badges` / `tierBadges`(前端派生)✅ |
326
410
  | **繁忙程度**(空闲 / 轻微拥堵 / 极度拥堵 / 不可用) | **`POST /v2/chat/busy`** | 见下 ✅ |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huai_xin/dsh-workbuddy",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "WorkBuddy model provider for DeepSeek Harness (international + China deployments)",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/adapter.js CHANGED
@@ -656,6 +656,12 @@ export class WorkBuddyAdapter {
656
656
  const maxTokens = positiveInt(options.maxTokens) ?? entry?.maxTokens
657
657
  if (maxTokens !== undefined) body.max_tokens = maxTokens
658
658
 
659
+ // Thinking is pinned on: there is no switch anywhere in the plugin. Upstream
660
+ // publishes `reasoning.canDisableThinking` without naming a request parameter
661
+ // that would act on it (protocol §4.3.1), so the effort level is the only
662
+ // reasoning control, and the explicit `thinking` field is sent where the
663
+ // official client is known to send it (deepseek family, §4.5) — the other
664
+ // families ignore it, whatever the value.
659
665
  const isDeepSeek = model.startsWith('deepseek-')
660
666
  if (isDeepSeek) body.thinking = { type: 'enabled' }
661
667
  // `efforts` is optional: many catalog entries declare no level set, and
@@ -773,6 +779,41 @@ export class WorkBuddyAdapter {
773
779
  let streamEnded = false
774
780
  const reader = response.body.getReader()
775
781
  const decoder = new TextDecoder()
782
+ // Evidence for the "the stream was cut" report below: how much arrived, how
783
+ // long it took, how long the silence before the cut was, and what was being
784
+ // written when it happened. The remote's own reason is not observable, so the
785
+ // report has to carry what *is*.
786
+ const startedAt = Date.now()
787
+ let receivedBytes = 0
788
+ let lastDataAt = startedAt
789
+ let lastDelta = '无内容'
790
+ /** Output tokens the last usage frame reported, for the truncation log. */
791
+ let outputTokens
792
+ /** The gateway's own response id, for correlating with upstream logs. */
793
+ let responseId
794
+ /**
795
+ * The finish chunk's diagnostics block.
796
+ *
797
+ * Not part of the harness contract — the other adapters attach their own and
798
+ * the session diagnostics show it verbatim, which is how a "who answered this,
799
+ * and what did upstream call it" question gets answered without reconstructing
800
+ * it from the request headers. Carries nothing the harness acts on.
801
+ */
802
+ const finishChunk = (reason) => ({
803
+ type: 'finish',
804
+ reason,
805
+ response: {
806
+ kind: 'workbuddy',
807
+ version: 1,
808
+ api: 'openai-completions',
809
+ provider: this.region.provider,
810
+ model: options.model,
811
+ ...(responseId === undefined ? {} : { responseId }),
812
+ stopReason: finishReason ?? reason.kind,
813
+ ...(outputTokens === undefined ? {} : { outputTokens }),
814
+ blocks: blocks.map((block) => ({ type: block.kind }))
815
+ }
816
+ })
776
817
 
777
818
  const textBlock = () => {
778
819
  let block = blocks.find((candidate) => candidate.kind === 'text')
@@ -795,6 +836,8 @@ export class WorkBuddyAdapter {
795
836
  while (!streamEnded) {
796
837
  const result = await reader.read()
797
838
  if (result.done) break
839
+ receivedBytes += result.value?.byteLength ?? 0
840
+ lastDataAt = Date.now()
798
841
  buffer += decoder.decode(result.value, { stream: true })
799
842
  let newline
800
843
  while ((newline = buffer.indexOf('\n')) !== -1) {
@@ -820,18 +863,21 @@ export class WorkBuddyAdapter {
820
863
  }
821
864
  const choice = data.choices?.[0]
822
865
  const delta = choice?.delta
866
+ if (responseId === undefined && typeof data.id === 'string' && data.id.length > 0) responseId = data.id
823
867
  if (typeof choice?.finish_reason === 'string' && choice.finish_reason.length > 0) finishReason = choice.finish_reason
824
868
 
825
869
  if (typeof delta?.content === 'string' && delta.content.length > 0) {
826
870
  const block = textBlock()
827
871
  if (block.text.length === 0) yield { type: 'block-start', index: block.index, blockType: 'text' }
828
872
  block.text += delta.content
873
+ lastDelta = '正文'
829
874
  yield { type: 'text-delta', index: block.index, text: delta.content }
830
875
  }
831
876
  if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
832
877
  const block = reasoningBlock()
833
878
  if (block.text.length === 0) yield { type: 'block-start', index: block.index, blockType: 'reasoning' }
834
879
  block.text += delta.reasoning_content
880
+ lastDelta = '思考'
835
881
  yield { type: 'reasoning-delta', index: block.index, text: delta.reasoning_content }
836
882
  }
837
883
  for (const call of delta?.tool_calls ?? []) {
@@ -845,6 +891,7 @@ export class WorkBuddyAdapter {
845
891
  }
846
892
  block.callId = callId
847
893
  if (typeof call.function?.name === 'string' && call.function.name.length > 0) block.name = call.function.name
894
+ lastDelta = '工具调用'
848
895
  const fragment = call.function?.arguments ?? ''
849
896
  block.text += fragment
850
897
  if (!block.announced) {
@@ -867,6 +914,7 @@ export class WorkBuddyAdapter {
867
914
  const promptTokens = data.usage.prompt_tokens ?? 0
868
915
  const cachedTokens = data.usage.prompt_tokens_details?.cached_tokens ?? data.usage.prompt_cache_hit_tokens ?? 0
869
916
  const reasoningTokens = data.usage.completion_tokens_details?.reasoning_tokens
917
+ outputTokens = data.usage.completion_tokens ?? 0
870
918
  yield {
871
919
  type: 'usage',
872
920
  usage: {
@@ -887,6 +935,11 @@ export class WorkBuddyAdapter {
887
935
  for (const index of toolOrder) {
888
936
  const block = [...toolCalls.values()].find((candidate) => candidate.index === index)
889
937
  if (block === undefined || !hasUsableToolName(block.name)) continue
938
+ // A call whose arguments never finished arriving cannot be executed: handed
939
+ // over, it becomes a parse error in the transcript instead of the truncation
940
+ // that actually happened. It is reported as `max-tokens` below instead, so
941
+ // the step can be continued rather than failed.
942
+ if (isTruncatedArguments(block.text)) continue
890
943
  blockCount += 1
891
944
  yield {
892
945
  type: 'block-end',
@@ -895,7 +948,7 @@ export class WorkBuddyAdapter {
895
948
  type: 'tool-call',
896
949
  id: block.callId,
897
950
  name: block.name,
898
- arguments: isTruncatedArguments(block.text) ? block.text : normalizeToolArguments(block.text)
951
+ arguments: normalizeToolArguments(block.text)
899
952
  }
900
953
  }
901
954
  }
@@ -911,19 +964,83 @@ export class WorkBuddyAdapter {
911
964
  }
912
965
 
913
966
  const argsTruncated = [...toolCalls.values()].some((block) => isTruncatedArguments(block.text))
967
+ // A stream that stops without the `[DONE]` sentinel was cut, not finished:
968
+ // upstream closed the connection mid-answer. Reporting that as a normal stop
969
+ // ends the turn silently — the model looks like it simply chose to stop — and
970
+ // the retry policy never sees a failure. It has to be an error so a truncated
971
+ // answer is retried and, failing that, surfaced.
972
+ if (!streamEnded && finishReason === undefined) {
973
+ const detail = describeCut({ receivedBytes, startedAt, lastDataAt, lastDelta })
974
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的响应在 finish_reason/[DONE] 之前中断(${detail}),按传输错误上报,可自动重试`)
975
+ yield finishChunk({
976
+ kind: 'error',
977
+ failure: {
978
+ message: `${this.region.provider}: 上游没有给出结束原因就断开了响应(${detail};可重试的传输错误)`,
979
+ code: 'TRANSPORT'
980
+ }
981
+ })
982
+ return
983
+ }
984
+ if (argsTruncated && !streamEnded) {
985
+ const detail = describeCut({ receivedBytes, startedAt, lastDataAt, lastDelta })
986
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的工具调用参数在传输中被截断(${detail}),按传输错误上报,可自动重试`)
987
+ yield finishChunk({
988
+ kind: 'error',
989
+ failure: {
990
+ message: `${this.region.provider}: 工具调用的参数在传输中被截断(${detail};可重试的传输错误)`,
991
+ code: 'TRANSPORT'
992
+ }
993
+ })
994
+ return
995
+ }
996
+ // Truncated arguments are checked before the empty-response rule: a step whose
997
+ // only tool call was cut short has no usable block to hand over, and calling
998
+ // that "empty" would hide what happened.
999
+ if (argsTruncated) {
1000
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的工具调用参数被输出上限截断,本步按 max-tokens 上报(harness 可续跑)`)
1001
+ yield finishChunk({ kind: 'max-tokens' })
1002
+ return
1003
+ }
914
1004
  if (blockCount === 0) {
915
- yield { type: 'finish', reason: { kind: 'error', failure: { message: '模型返回了空响应', code: 'EMPTY_RESPONSE' } } }
1005
+ yield finishChunk({ kind: 'error', failure: { message: '模型返回了空响应', code: 'EMPTY_RESPONSE' } })
916
1006
  return
917
1007
  }
918
1008
  const reason = finishReason === 'tool_calls' || toolOrder.length > 0
919
1009
  ? { kind: 'tool-calls' }
920
- : finishReason === 'length' || argsTruncated
1010
+ : finishReason === 'length'
921
1011
  ? { kind: 'max-tokens' }
922
1012
  : { kind: 'stop' }
923
- yield { type: 'finish', reason }
1013
+ // Two endings are complete-looking but are not the model choosing to stop, and
1014
+ // nothing downstream can tell them apart. Both get a line of their own:
1015
+ // · the answer ran into the output budget (a truncation reads as a finish);
1016
+ // · upstream closed with `[DONE]` but never said *why* the answer ended.
1017
+ if (reason.kind === 'max-tokens') {
1018
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的输出达到上限,本步被截断(输出 ${outputTokens ?? '未知'} tokens)`)
1019
+ } else if (reason.kind === 'stop' && finishReason === undefined) {
1020
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的响应以 [DONE] 收尾但没有 finish_reason,按 stop 处理(输出 ${outputTokens ?? '未知'} tokens)`)
1021
+ }
1022
+ yield finishChunk(reason)
924
1023
  }
925
1024
  }
926
1025
 
927
1026
  function positiveInt(value) {
928
1027
  return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined
929
1028
  }
1029
+
1030
+ /**
1031
+ * Describe a stream that ended without its terminator.
1032
+ *
1033
+ * The rule is deliberately mechanical: a response is complete only when it carries
1034
+ * `finish_reason` (and the SSE stream reaches `[DONE]`) or delivers a tool call.
1035
+ * Anything else is a cut connection, reported as a retryable `TRANSPORT` failure.
1036
+ *
1037
+ * Why the *remote* closed the connection is not observable from here, so the report
1038
+ * carries what is: how much arrived, how long it took, how long the silence before
1039
+ * the cut, and what was being written at the time.
1040
+ */
1041
+ function describeCut({ receivedBytes, startedAt, lastDataAt, lastDelta }) {
1042
+ const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1)
1043
+ const silence = ((Date.now() - lastDataAt) / 1000).toFixed(1)
1044
+ const size = receivedBytes < 1024 ? `${receivedBytes} B` : `${(receivedBytes / 1024).toFixed(1)} kB`
1045
+ return `收到 ${size},用时 ${elapsed}s,最后一段数据在 ${silence}s 前,中断时正在输出${lastDelta}`
1046
+ }
package/src/api.js CHANGED
@@ -261,7 +261,6 @@ export function registerWorkbuddyApi(ctx, adapters, options = {}) {
261
261
  credits: entry.credits,
262
262
  efforts: entry.efforts === undefined ? [] : Object.keys(entry.efforts),
263
263
  defaultEffort: entry.defaultEffort,
264
- canDisableThinking: entry.canDisableThinking,
265
264
  thinking: entry.thinking === true,
266
265
  contextWindow: entry.contextWindow,
267
266
  maxTokens: entry.maxTokens,
@@ -42,8 +42,18 @@ const MODALITY_ORDER = ['text', 'image']
42
42
  * ago is not a fallback — it is wrong. The cache stores no copy, and a cached
43
43
  * entry therefore reports no rate at all; the panel shows 未知 rather than a
44
44
  * stale number.
45
+ *
46
+ * `promotion` is a live *statement* rather than a measurement, and the cache is
47
+ * append-only (a field another read omits is never cleared), so keeping it would
48
+ * let an old claim outlive the payload that made it: the deployment's
49
+ * `modelPromotions` block moves with each campaign, and a cached badge can
50
+ * advertise a promotion that ended.
51
+ *
52
+ * `canDisableThinking` is retired — the plugin pins thinking on and never reads
53
+ * the flag (protocol §4.3.1). It stays in this set only so that snapshots written
54
+ * before the retirement drop it on read instead of leaving a dead field behind.
45
55
  */
46
- const VOLATILE_FIELDS = new Set(['credits'])
56
+ const VOLATILE_FIELDS = new Set(['credits', 'promotion', 'canDisableThinking'])
47
57
 
48
58
  function cacheDir() {
49
59
  const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.length > 0
package/src/catalog.js CHANGED
@@ -199,7 +199,11 @@ export function entryFromMeta(id, meta) {
199
199
  input: meta?.supportsImages === true ? ['text', 'image'] : ['text'],
200
200
  ...(levels.length === 0 ? {} : { efforts: Object.fromEntries(levels.map((level) => [level, level])) }),
201
201
  ...(declared === undefined ? {} : { defaultEffort: declared }),
202
- ...(reasoning?.canDisableThinking === undefined ? {} : { canDisableThinking: reasoning.canDisableThinking === true }),
202
+ // `reasoning.canDisableThinking` is deliberately ignored. Upstream publishes
203
+ // the flag without naming any parameter that would act on it (measured
204
+ // 2026-10-22, protocol §4.3.1), and it also stops sending the flag for models
205
+ // it used to describe, so the plugin pins thinking on instead of advertising a
206
+ // switch it cannot honour. The effort level is the only reasoning control.
203
207
  ...(id.startsWith('deepseek-') ? { thinking: true } : {}),
204
208
  // `/v3/config` publishes which tier a model needs; that is a free, static
205
209
  // entitlement signal (see `account-info.js`), so it is carried along.
@@ -306,7 +310,6 @@ export function mergeEntries(entries, extra) {
306
310
  ...(entry.contextWindow === undefined && other.contextWindow !== undefined ? { contextWindow: other.contextWindow } : {}),
307
311
  ...(entry.maxTokens === undefined && other.maxTokens !== undefined ? { maxTokens: other.maxTokens } : {}),
308
312
  ...(entry.defaultEffort === undefined && other.defaultEffort !== undefined ? { defaultEffort: other.defaultEffort } : {}),
309
- ...(entry.canDisableThinking === undefined && other.canDisableThinking !== undefined ? { canDisableThinking: other.canDisableThinking } : {}),
310
313
  ...(entry.promotion === undefined && other.promotion !== undefined ? { promotion: other.promotion } : {}),
311
314
  ...(levels.length === 0 ? {} : { efforts: Object.fromEntries(levels.map((level) => [level, level])) }),
312
315
  // `official` is only ever set by the account-scoped payload (see
package/src/index.js CHANGED
@@ -267,6 +267,29 @@ export function apply(ctx) {
267
267
  }, AVAILABILITY_BOOT_DELAY_MS)
268
268
  availabilityTimer.unref?.()
269
269
 
270
+ /**
271
+ * Retry log.
272
+ *
273
+ * The retry itself belongs to `@deepseek-ai/dsh-llm-retry`, which appends
274
+ * `llm/retry` to the session; nothing prints it, so a step that failed three
275
+ * times and then succeeded looks identical to one that succeeded first try.
276
+ * Logging the decision keeps a truncated answer visible even when the retry
277
+ * hides it — which is exactly the case that used to be reported as a clean stop.
278
+ */
279
+ ctx.effect(() => {
280
+ const byProvider = new Map(Object.values(REGIONS).map((region) => [region.provider, region]))
281
+ const off = ctx.on('session/event', (_session, event) => {
282
+ if (event?.type !== 'llm/retry') return
283
+ const region = byProvider.get(event.data?.provider)
284
+ if (region === undefined) return
285
+ const failure = event.data?.failure ?? {}
286
+ const delay = typeof event.data?.delayMs === 'number' ? `${(event.data.delayMs / 1000).toFixed(1)}s` : '未知延迟'
287
+ const message = String(failure.message ?? '').replace(/\s+/g, ' ').slice(0, 200)
288
+ ctx.logger.warn(`[workbuddy] ${region.id}: 第 ${event.data?.retry ?? '?'}/${event.data?.maxRetries ?? '?'} 次重试(${failure.code ?? '未知'})${message.length > 0 ? ` — ${message}` : ''};${delay} 后重试`)
289
+ })
290
+ return () => { if (typeof off === 'function') off() }
291
+ }, 'dsh-workbuddy: retry log')
292
+
270
293
  ctx.effect(() => () => {
271
294
  ctx.logger.info(`[workbuddy] 定时器全部撤销(实例 ${instance})`)
272
295
  clearTimeout(bootTimer)
@@ -132,7 +132,7 @@ export function writeOfficialList(regionId, models, options = {}) {
132
132
  * current. A model whose rate cannot be read right now reads 「未知」 — which is
133
133
  * why the console capture keeps the other fields only.
134
134
  */
135
- const ENRICHABLE = ['name', 'descriptionZh', 'descriptionEn', 'contextWindow', 'maxTokens', 'defaultEffort', 'canDisableThinking', 'requiredUserType']
135
+ const ENRICHABLE = ['name', 'descriptionZh', 'descriptionEn', 'contextWindow', 'maxTokens', 'defaultEffort', 'requiredUserType']
136
136
 
137
137
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
138
138