@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 +2 -1
- package/client/index.js +0 -9
- package/docs/ui-preview.png +0 -0
- package/docs/workbuddy-protocol.md +83 -4
- package/package.json +1 -1
- package/src/adapter.js +99 -2
- package/src/api.js +0 -1
- package/src/catalog-cache.js +11 -1
- package/src/catalog.js +5 -2
- package/src/index.js +23 -0
- package/src/official-list.js +1 -1
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)))
|
package/docs/ui-preview.png
CHANGED
|
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
|
-
|
|
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`、`
|
|
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
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:
|
|
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'
|
|
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,
|
package/src/catalog-cache.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/official-list.js
CHANGED
|
@@ -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', '
|
|
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
|
|