@huai_xin/dsh-workbuddy 0.1.2 → 0.1.3

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,14 @@ 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
+ 即断流是间歇性的,常规探测不易复现)
196
251
  - `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
252
 
198
253
  ---
@@ -253,7 +308,7 @@ data: [DONE]
253
308
  | `contextWindow` | 仅部分模型有:`{defaultLength, supportedLengths:[…]}`(如 step-5: `[300000, 1000000]`) |
254
309
  | `maxOutputTokens` | 输出上限 |
255
310
  | `supportsImages` / `supportsReasoning` / `supportsToolCall` / `disabledMultimodal` | 能力位 |
256
- | `onlyReasoning` / `canDisableThinking` | 是否强制思考 / 可否关闭 |
311
+ | `onlyReasoning` / `canDisableThinking` | 是否强制思考 / 上游的"可否关闭思考"声明 —— **插件忽略它**,理由见 §4.3.1 |
257
312
  | `reasoning` | `{effort, summary, defaultEffort, supportedEfforts:[…]}`;`effort` 是默认档,`supportedEfforts` 才是可选集合 |
258
313
  | `relatedModels` | 关联模型映射,如 `{lite, reasoning, builtin-lite}` |
259
314
  | `credits` | 倍率字符串,如 `"x0.00 credits"` / `"x2.20 credits"` / `"x0.05"` |
@@ -263,8 +318,32 @@ data: [DONE]
263
318
  | `iconUrl` / `iconUrl_en` | 图标(base64 data-URI 或 HTTPS URL,按语言选) |
264
319
  | `AccountAttributes` ❓ | 出现在积分包里,模型侧未见 |
265
320
 
266
- ### 4.3 `/v3/config` 结构
321
+ #### 4.3.1 `canDisableThinking`:上游没给关闭参数,插件选择"忽略它、思考恒开"(2026-10-22 实测)
322
+
323
+ 三条结论都有原始响应与缓存对比支撑(`node test/live/reasoning-audit.mjs [cn|intl]`):
324
+
325
+ 1. **上游只发一个布尔位,不发参数名。** `reasoning` 块出现过的字段只有
326
+ `canDisableThinking` / `defaultEffort` / `effort` / `summary` / `supportedEfforts`;
327
+ 整条记录里像"开关"的字段只有 `canDisableThinking` 和 `disabledMultimodal`(后者是
328
+ 多模态能力,与思考无关)。也就是说:**"能不能关"它说了,"怎么关"它没说。**
329
+ 反向参数(`thinking:{type:'disabled'}` / `enable_thinking:false` /
330
+ `reasoning:{effort:'none'}` 之类)**一个都没验证过**。
331
+ 2. **两个部署各自独立声明,而且会变。** 同一天里:大陆版对 `glm-5.2`、`kimi-k3-1`、
332
+ `deepseek-v4-flash`、`deepseek-v4-pro`、`hy3` **完全不发**该字段,海外版对同样的 id
333
+ 一律发 `true`/`false`;大陆版过去发过、现在不发了 —— 说明它不是稳定契约。
334
+ 3. **插件的决定:忽略这个字段,思考恒开。** 既然"能关"无法兑现(没有参数),就不要在
335
+ 面板上宣称它 —— 于是:
336
+ - 目录解析不再读取该字段(`catalog.js`),合并、面板载荷、账号名单富化里也一并去掉;
337
+ - 面板详情不再有「可关闭思考」这一行(没有开关就不摆开关);
338
+ - **唯一能调的仍然是档位**(`reasoning.supportedEfforts` → 低/中/高/超高/极致);
339
+ - `thinking:{type:'enabled'}` 目前只对 deepseek 系发送(§4.5 表,📖✅ 已验证),
340
+ 其它族不发这个字段(它们本身就是 `onlyReasoning` 模型,默认就在思考);
341
+ - 缓存里把该字段当作**已废弃字段**处理:老快照读出来就丢掉,不再留痕。
342
+
343
+ 如果将来上游给出真正的关闭参数,正确做法是把它变成一个档位(例如「关闭」)交给
344
+ 选择器,而不是恢复一个布尔位。
267
345
 
346
+ ### 4.3 `/v3/config` 结构
268
347
  ```json
269
348
  {"code":0,"data":{
270
349
  "$schema":"product-schema.json",
@@ -320,7 +399,7 @@ Web 端 picker 的悬停卡片会显示简介、消耗速度、繁忙程度、
320
399
  | 名称(含「极致」「快速」等预设名) | 目录 | `name` ✅ |
321
400
  | 简介「优先深度与准确性…」 | 目录 | `descriptionZh` / `descriptionEn`,前端按 locale 选(`zh*` → `descriptionZh`)✅ |
322
401
  | 消耗速度 `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` → 低/中/高/超高/极致),不是服务端下发 ✅ |
402
+ | 思考档位(低/中/高/超高/极致) | 目录 | `reasoning.supportedEfforts`(可选集合)、`reasoning.defaultEffort`、`onlyReasoning`、`supportsReasoning`;**档位文案是前端 i18n**(`model.effort.{low,medium,high,xhigh,max}.label` → 低/中/高/超高/极致),不是服务端下发 ✅ |
324
403
  | 上下文窗口选择 | 目录 | `contextWindow.supportedLengths` + `maxInputTokens`(前端命名 `supportedContextWindows` / `defaultContextWindow`)✅ |
325
404
  | 徽章(如「限时免费」) | 目录 | `tags`,形如 `badge:限时免费:#FF0000`;另有 `isDefault`、`badges` / `tierBadges`(前端派生)✅ |
326
405
  | **繁忙程度**(空闲 / 轻微拥堵 / 极度拥堵 / 不可用) | **`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.3",
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,16 @@ 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
776
792
 
777
793
  const textBlock = () => {
778
794
  let block = blocks.find((candidate) => candidate.kind === 'text')
@@ -795,6 +811,8 @@ export class WorkBuddyAdapter {
795
811
  while (!streamEnded) {
796
812
  const result = await reader.read()
797
813
  if (result.done) break
814
+ receivedBytes += result.value?.byteLength ?? 0
815
+ lastDataAt = Date.now()
798
816
  buffer += decoder.decode(result.value, { stream: true })
799
817
  let newline
800
818
  while ((newline = buffer.indexOf('\n')) !== -1) {
@@ -826,12 +844,14 @@ export class WorkBuddyAdapter {
826
844
  const block = textBlock()
827
845
  if (block.text.length === 0) yield { type: 'block-start', index: block.index, blockType: 'text' }
828
846
  block.text += delta.content
847
+ lastDelta = '正文'
829
848
  yield { type: 'text-delta', index: block.index, text: delta.content }
830
849
  }
831
850
  if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
832
851
  const block = reasoningBlock()
833
852
  if (block.text.length === 0) yield { type: 'block-start', index: block.index, blockType: 'reasoning' }
834
853
  block.text += delta.reasoning_content
854
+ lastDelta = '思考'
835
855
  yield { type: 'reasoning-delta', index: block.index, text: delta.reasoning_content }
836
856
  }
837
857
  for (const call of delta?.tool_calls ?? []) {
@@ -845,6 +865,7 @@ export class WorkBuddyAdapter {
845
865
  }
846
866
  block.callId = callId
847
867
  if (typeof call.function?.name === 'string' && call.function.name.length > 0) block.name = call.function.name
868
+ lastDelta = '工具调用'
848
869
  const fragment = call.function?.arguments ?? ''
849
870
  block.text += fragment
850
871
  if (!block.announced) {
@@ -867,6 +888,7 @@ export class WorkBuddyAdapter {
867
888
  const promptTokens = data.usage.prompt_tokens ?? 0
868
889
  const cachedTokens = data.usage.prompt_tokens_details?.cached_tokens ?? data.usage.prompt_cache_hit_tokens ?? 0
869
890
  const reasoningTokens = data.usage.completion_tokens_details?.reasoning_tokens
891
+ outputTokens = data.usage.completion_tokens ?? 0
870
892
  yield {
871
893
  type: 'usage',
872
894
  usage: {
@@ -887,6 +909,11 @@ export class WorkBuddyAdapter {
887
909
  for (const index of toolOrder) {
888
910
  const block = [...toolCalls.values()].find((candidate) => candidate.index === index)
889
911
  if (block === undefined || !hasUsableToolName(block.name)) continue
912
+ // A call whose arguments never finished arriving cannot be executed: handed
913
+ // over, it becomes a parse error in the transcript instead of the truncation
914
+ // that actually happened. It is reported as `max-tokens` below instead, so
915
+ // the step can be continued rather than failed.
916
+ if (isTruncatedArguments(block.text)) continue
890
917
  blockCount += 1
891
918
  yield {
892
919
  type: 'block-end',
@@ -895,7 +922,7 @@ export class WorkBuddyAdapter {
895
922
  type: 'tool-call',
896
923
  id: block.callId,
897
924
  name: block.name,
898
- arguments: isTruncatedArguments(block.text) ? block.text : normalizeToolArguments(block.text)
925
+ arguments: normalizeToolArguments(block.text)
899
926
  }
900
927
  }
901
928
  }
@@ -911,15 +938,67 @@ export class WorkBuddyAdapter {
911
938
  }
912
939
 
913
940
  const argsTruncated = [...toolCalls.values()].some((block) => isTruncatedArguments(block.text))
941
+ // A stream that stops without the `[DONE]` sentinel was cut, not finished:
942
+ // upstream closed the connection mid-answer. Reporting that as a normal stop
943
+ // ends the turn silently — the model looks like it simply chose to stop — and
944
+ // the retry policy never sees a failure. It has to be an error so a truncated
945
+ // answer is retried and, failing that, surfaced.
946
+ if (!streamEnded && finishReason === undefined) {
947
+ const detail = describeCut({ receivedBytes, startedAt, lastDataAt, lastDelta })
948
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的响应在 finish_reason/[DONE] 之前中断(${detail}),按传输错误上报,可自动重试`)
949
+ yield {
950
+ type: 'finish',
951
+ reason: {
952
+ kind: 'error',
953
+ failure: {
954
+ message: `${this.region.provider}: 上游没有给出结束原因就断开了响应(${detail};可重试的传输错误)`,
955
+ code: 'TRANSPORT'
956
+ }
957
+ }
958
+ }
959
+ return
960
+ }
961
+ if (argsTruncated && !streamEnded) {
962
+ const detail = describeCut({ receivedBytes, startedAt, lastDataAt, lastDelta })
963
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的工具调用参数在传输中被截断(${detail}),按传输错误上报,可自动重试`)
964
+ yield {
965
+ type: 'finish',
966
+ reason: {
967
+ kind: 'error',
968
+ failure: {
969
+ message: `${this.region.provider}: 工具调用的参数在传输中被截断(${detail};可重试的传输错误)`,
970
+ code: 'TRANSPORT'
971
+ }
972
+ }
973
+ }
974
+ return
975
+ }
976
+ // Truncated arguments are checked before the empty-response rule: a step whose
977
+ // only tool call was cut short has no usable block to hand over, and calling
978
+ // that "empty" would hide what happened.
979
+ if (argsTruncated) {
980
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的工具调用参数被输出上限截断,本步按 max-tokens 上报(harness 可续跑)`)
981
+ yield { type: 'finish', reason: { kind: 'max-tokens' } }
982
+ return
983
+ }
914
984
  if (blockCount === 0) {
915
985
  yield { type: 'finish', reason: { kind: 'error', failure: { message: '模型返回了空响应', code: 'EMPTY_RESPONSE' } } }
916
986
  return
917
987
  }
918
988
  const reason = finishReason === 'tool_calls' || toolOrder.length > 0
919
989
  ? { kind: 'tool-calls' }
920
- : finishReason === 'length' || argsTruncated
990
+ : finishReason === 'length'
921
991
  ? { kind: 'max-tokens' }
922
992
  : { kind: 'stop' }
993
+ // Two endings are complete-looking but are not the model choosing to stop, and
994
+ // nothing downstream can tell them apart. Both get a line of their own:
995
+ // · the answer ran into the output budget (a truncation reads as a finish);
996
+ // · upstream closed with `[DONE]` but never said *why* the answer ended.
997
+ if (reason.kind === 'max-tokens') {
998
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的输出达到上限,本步被截断(输出 ${outputTokens ?? '未知'} tokens)`)
999
+ } else if (reason.kind === 'stop' && finishReason === undefined) {
1000
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的响应以 [DONE] 收尾但没有 finish_reason,按 stop 处理(输出 ${outputTokens ?? '未知'} tokens)`)
1001
+ }
923
1002
  yield { type: 'finish', reason }
924
1003
  }
925
1004
  }
@@ -927,3 +1006,21 @@ export class WorkBuddyAdapter {
927
1006
  function positiveInt(value) {
928
1007
  return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined
929
1008
  }
1009
+
1010
+ /**
1011
+ * Describe a stream that ended without its terminator.
1012
+ *
1013
+ * The rule is deliberately mechanical: a response is complete only when it carries
1014
+ * `finish_reason` (and the SSE stream reaches `[DONE]`) or delivers a tool call.
1015
+ * Anything else is a cut connection, reported as a retryable `TRANSPORT` failure.
1016
+ *
1017
+ * Why the *remote* closed the connection is not observable from here, so the report
1018
+ * carries what is: how much arrived, how long it took, how long the silence before
1019
+ * the cut, and what was being written at the time.
1020
+ */
1021
+ function describeCut({ receivedBytes, startedAt, lastDataAt, lastDelta }) {
1022
+ const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1)
1023
+ const silence = ((Date.now() - lastDataAt) / 1000).toFixed(1)
1024
+ const size = receivedBytes < 1024 ? `${receivedBytes} B` : `${(receivedBytes / 1024).toFixed(1)} kB`
1025
+ return `收到 ${size},用时 ${elapsed}s,最后一段数据在 ${silence}s 前,中断时正在输出${lastDelta}`
1026
+ }
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