@huai_xin/dsh-workbuddy 0.1.6 → 0.1.8
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 +9 -13
- package/docs/workbuddy-protocol.md +34 -0
- package/package.json +1 -1
- package/src/adapter.js +40 -24
package/README.md
CHANGED
|
@@ -306,6 +306,15 @@ node test/live/policy-probe.mjs # 两版白名单与准入结果对照
|
|
|
306
306
|
- **思考档位只认服务端声明的 `reasoning.supportedEfforts`**(两个源合并后国内 15/34、国际 21/28);只声明了默认档的模型不给档位列表 —— 与网页端行为一致。合并后仍无档位的,请求里不带 `reasoning_effort`,由服务端取默认。
|
|
307
307
|
- 实时**繁忙程度**只有大陆版有(`POST /v2/chat/busy`,海外版 404,`regions.js` 的 `hasBusy` 决定那一列是否显示)。它**不是可用性信号**:只对白名单内的活跃模型返回 `idle/normal/busy/congested`,其余一律 `unknown`(面板显示「未知」,不当成故障)。插件侧 50 秒缓存,与官方前端一致。
|
|
308
308
|
- 两版**条目数会变**:2026-10-22 实测大陆版 30 个(`ok` 22 / `dead` 8)、海外版 25 个(`ok` 13 / `denied` 12)。`gpt-5.3-codex` 只在账号名单里,两个 `/v3/config` 都没有它,倍率因此是「未知」。
|
|
309
|
+
- **长上下文下模型会"说话说一半就收尾"(上游行为,非插件问题)**:海外版 `deepseek-v4.1-flash` 在上下文很长时会不调用工具、只用一句话叙述下一步然后 `finish_reason: "stop"`(例如「我需要先读取该文件,才能准确修改。」)。实测(2026-10-22,`node test/live/long-context-probe.mjs`):
|
|
310
|
+
|
|
311
|
+
| 输入规模 | 正确调用工具 | 提前 `stop` |
|
|
312
|
+
| --- | --- | --- |
|
|
313
|
+
| 0k(1 条消息) | 2/2 | 0/2 |
|
|
314
|
+
| 30k 字符(613 条) | 2/2 | 0/2 |
|
|
315
|
+
| 89k 字符(1825 条) | 1/2 | **1/2** |
|
|
316
|
+
|
|
317
|
+
用户侧复现阈值约 **120k 上下文**起明显(其它模型未测)。判据是**换了上下文长度、其它一切不变**即可复现/消失,且流里**一个 tool-call 分片都没有**、`finish_reason` 就是 `stop` —— 所以既不是适配器丢帧(丢帧会看到 `finish_reason: "tool_calls"`),也不是采样(四档温度 16/16 正常)、不是工具 schema(70 个工具 / 单个 / 简化 schema 9/9 正常)。`stop` 是模型自己采到 EOS,属于上游质量问题;缓解手段只有换模型、把会话压短(harness 的压缩),或回一句"继续"。
|
|
309
318
|
|
|
310
319
|
## 上游行为与错误映射
|
|
311
320
|
|
|
@@ -353,19 +362,6 @@ node test/live/dump-stream.mjs <会话前缀> <seq,…> # 打印那一步
|
|
|
353
362
|
node test/live/sampling-probe.mjs intl 4 # 同一提示在各 temperature 下的"提前 stop"率
|
|
354
363
|
```
|
|
355
364
|
|
|
356
|
-
### 采样参数(默认不发,可按需覆盖)
|
|
357
|
-
|
|
358
|
-
插件默认**不发送** `temperature` / `top_p` / `top_k`(harness 没暴露温度设置,`options.temperature` 实际为空),因此走上游默认采样。`stop` 本质是模型采到 EOS,偏热的默认值更容易在"现在我来改……:"这种地方收尾,所以留了两个覆盖开关:
|
|
359
|
-
|
|
360
|
-
```powershell
|
|
361
|
-
$env:WORKBUDDY_TEMPERATURE = '0.3'
|
|
362
|
-
$env:WORKBUDDY_TOP_P = '0.95'
|
|
363
|
-
```
|
|
364
|
-
|
|
365
|
-
优先级:**调用方显式传入的 `temperature`** > 环境变量 > 不发 ✓(默认行为对所有人不变 ✓)。改完需重启 DSH ✓。
|
|
366
|
-
|
|
367
|
-
> 实测(2026-10-22,海外版免费档 `deepseek-v4.1-flash`):一个明确要求调用工具的短提示,在"不发 / 0.2 / 0.5 / 1.0"四档各跑 4 次,**16/16 都正确调用了工具** —— 单纯温度解释不了真实会话里的提前收尾(那次输入有 213k 缓存 token,`effort=max` 却只有 0 个思考 token,更像长上下文下的例行续写)。这个开关是留给真实流量验证用的,默认不改输出风格。
|
|
368
|
-
|
|
369
365
|
## 许可
|
|
370
366
|
|
|
371
367
|
MIT,见 [`LICENSE`](LICENSE)。再次说明:本项目为第三方非官方插件,与腾讯 / WorkBuddy 无关联。
|
|
@@ -394,6 +394,7 @@ data: [DONE]
|
|
|
394
394
|
| INTL scoped → `500`(openresty/APISIX,HTML 错误页) | 2026-10-21 起持续;`/v3/config` 正常 ✅ |
|
|
395
395
|
| `glm-5.1` 图片能力两个端点矛盾 | scoped 报 `false`、`/v3/config` 报 `true`,实测能看图(📖 参考实现据此写了覆盖白名单) |
|
|
396
396
|
| 目录包含渠道不服务的模型 | 见 `11102`(§6)✅ |
|
|
397
|
+
| 长上下文下"说话说一半就收尾"(`finish_reason: stop`,无工具调用) | 上游/模型行为,**不是插件问题**。实测:同一提示、同一套工具、只改前面的对话长度 —— 0k 2/2 正常、30k 1/2(一次 181 字后 `stop`)、89k 1/2;每次 finish 诊断块都标 `workbuddy-intl/deepseek-v4.1-flash`。判据:流里**没有任何 tool-call 分片**、`finish_reason` 就是 `stop`(若丢帧会是 `tool_calls` 且能看到分片)。已排除:采样(四档温度 16/16 正常)、工具 schema/数量(70 个 / 单个 / 简化 schema 9/9 正常)、提示词校验(海外版无此错误,请求体按原样转发)。复现:`node test/live/long-context-probe.mjs 2`;排查:`node test/live/toolcall-audit.mjs <会话前缀>`、`sampling-probe.mjs`、`tools-probe.mjs`。缓解只有换模型/压缩上下文/回一句"继续" —— 合法的 `stop` 不能当错误重试。 |
|
|
397
398
|
|
|
398
399
|
### 4.7 模型选择器里每一项元数据的来源
|
|
399
400
|
|
|
@@ -838,6 +839,39 @@ provider 与全部 HTTP 路由都没注册,设置面板只剩一个 `HTTP 404`
|
|
|
838
839
|
|
|
839
840
|
---
|
|
840
841
|
|
|
842
|
+
## 12. 与官方客户端、DSH 的请求处理对照(2026-10-22)
|
|
843
|
+
|
|
844
|
+
官方客户端不是网页版:`www.workbuddy.cn` / `www.workbuddy.ai` 的 bundle 里**没有**聊天逻辑
|
|
845
|
+
(只有站点壳,实测抓了入口 + 28 个懒加载 chunk,`chat/completions`、`finish_reason` 全部 0 命中)。
|
|
846
|
+
真正的客户端是 CLI:`@tencent-ai/codebuddy-code`(2.172.1,59 MB,`dist/codebuddy.js` 28 MB)。
|
|
847
|
+
下面这张表来自读它的 bundle(`mapFinishReasonToStopReason`、请求体组装处)。
|
|
848
|
+
|
|
849
|
+
| 方面 | 官方 CLI | 本插件 | DSH |
|
|
850
|
+
| --- | --- | --- | --- |
|
|
851
|
+
| 端点 | 自定义/默认 URL + `/chat/completions`(CN 默认即 `copilot.tencent.com` ✓ 我们早就在用) | CN `copilot.tencent.com/v2/chat/completions`,失败回退 `workbuddy.cn`;INTL `workbuddy.ai/v2/chat/completions` | 由适配器注册的 provider 决定 |
|
|
852
|
+
| `temperature` / `top_p` / `frequency_penalty` / `presence_penalty` | **总是发**(模型配置值,缺省用 `DEFAULT_TEMPERATURE`) | **不发**(仅当调用方传 `options.temperature`) | 类型里有 `LlmCallControls.temperature`,但没有设置入口 ⇒ 实际永远为空 |
|
|
853
|
+
| `max_tokens` | 总是发(模型配置) | 发(调用方 `maxTokens`,否则目录 `maxOutputTokens`) | 提供 `maxTokens` |
|
|
854
|
+
| `tool_choice` / `parallel_tool_calls` | 发 | 不发(走服务端默认 `auto`) | — |
|
|
855
|
+
| `stream_options: {include_usage:true}` | **发** | **2026-10-22 起补上**(此前不发,网关可能不回 `usage`) | — |
|
|
856
|
+
| `prompt_cache_key` | **不发** | 发(会话级,前缀缓存命中率才不为 0) | — |
|
|
857
|
+
| `finish_reason` 映射 | `content_filter`/`refusal`→`refusal`;`length`/`max_tokens`→`max_tokens`;`stop`/`end_turn`/`stop_sequence`/`tool_calls`/`tool_use`/`function_call`→`end_turn`;未知→不归类 | **2026-10-22 起对齐**:`refusal`/`content_filter`→不可重试的 `INVALID_REQUEST` 错误;`error`→`TRANSPORT`(可重试);`end_turn`/`stop_sequence`→`stop`;`tool_use`/`function_call`→`tool-calls`;未知值记日志后按 `stop` | 只有 `stop`/`tool-calls`/`max-tokens`/`aborted`/`error` 五类,**没有 refusal** |
|
|
858
|
+
| `finish_reason=error` | 抛 `provider_finish_reason_error`(`model_service`) | 现在报 `TRANSPORT`,交给重试 | — |
|
|
859
|
+
| 空流 / 占位帧 | `is_empty_stream`、placeholder-only 检测 | `EMPTY_RESPONSE` | — |
|
|
860
|
+
| 流空闲监控 | `setInterval` + `streamIdleTimeoutMs`(日志 `Stream idle monitor armed`) | **没有**(依赖 harness 自身超时) | 无 |
|
|
861
|
+
| 请求体改写 | 模型不支持工具时 `stripToolDefinitions`(连 `tool_choice` 一起去掉)、图片扁平化、自定义模型 URL 规整 | CN:系统提示词指纹改写(`Illegal API invocation`)+ 换会话标识重试一次 | — |
|
|
862
|
+
|
|
863
|
+
三条结论:
|
|
864
|
+
|
|
865
|
+
1. **我们和官方在"结束原因"上原本差一张表** —— 官方把 `refusal`/`content_filter` 单独归类、
|
|
866
|
+
把 `error` 当服务错误;我们此前只有 `stop`/`length`/`tool_calls`,其余全落进 `stop`。
|
|
867
|
+
后果就是:**上游拒绝或过滤时,表现和"模型自己说完了"完全一样** —— 已按上表对齐。
|
|
868
|
+
2. **`stream_options.include_usage` 官方一直带**,我们漏了;补上后 `usage` 更可靠(诊断里的
|
|
869
|
+
`outputTokens` 也才有据可依)。
|
|
870
|
+
3 . **`prompt_cache_key` 是反向差异**:官方不发,我们发。它只影响前缀缓存命中,不影响语义,
|
|
871
|
+
留着(会话级稳定值,命中率实测有效)。
|
|
872
|
+
|
|
873
|
+
---
|
|
874
|
+
|
|
841
875
|
## 附:本仓库可复用的抓取脚本
|
|
842
876
|
|
|
843
877
|
| 脚本 | 用途 |
|
package/package.json
CHANGED
package/src/adapter.js
CHANGED
|
@@ -643,6 +643,10 @@ export class WorkBuddyAdapter {
|
|
|
643
643
|
model,
|
|
644
644
|
messages,
|
|
645
645
|
stream: true,
|
|
646
|
+
// The official client asks for the usage frame explicitly; without it the
|
|
647
|
+
// gateway may omit `usage` on some routes, which costs the panel its token
|
|
648
|
+
// counts and the diagnostics their output-token line.
|
|
649
|
+
stream_options: { include_usage: true },
|
|
646
650
|
// Server-side prefix cache: without this field the hit rate is always 0.
|
|
647
651
|
prompt_cache_key: this.sessionId
|
|
648
652
|
}
|
|
@@ -651,20 +655,7 @@ export class WorkBuddyAdapter {
|
|
|
651
655
|
function: { name: tool.name, description: tool.description, parameters: tool.parameters }
|
|
652
656
|
}))
|
|
653
657
|
if (tools !== undefined && tools.length > 0) body.tools = tools
|
|
654
|
-
|
|
655
|
-
// except for an explicit override. `stop` is the model sampling its
|
|
656
|
-
// end-of-sequence token, so on a hot default it can end a turn right after
|
|
657
|
-
// "现在我来改……:" instead of calling the tool, and nothing in the harness
|
|
658
|
-
// exposes a temperature to tune that. These two variables let an operator try a
|
|
659
|
-
// cooler profile without changing the default behaviour:
|
|
660
|
-
// WORKBUDDY_TEMPERATURE=0.3 WORKBUDDY_TOP_P=0.95
|
|
661
|
-
// Measured 2026-10-22: a short tool-using prompt called the tool 16/16 times at
|
|
662
|
-
// the default and at 0.2/0.5/1.0, so sampling alone does not explain the early
|
|
663
|
-
// stops; the override exists to test the long-context case on real traffic.
|
|
664
|
-
const temperature = options.temperature ?? envNumber('WORKBUDDY_TEMPERATURE')
|
|
665
|
-
if (temperature !== undefined) body.temperature = temperature
|
|
666
|
-
const topP = envNumber('WORKBUDDY_TOP_P')
|
|
667
|
-
if (topP !== undefined) body.top_p = topP
|
|
658
|
+
if (options.temperature !== undefined) body.temperature = options.temperature
|
|
668
659
|
if (options.stop !== undefined && options.stop.length > 0) body.stop = options.stop
|
|
669
660
|
const maxTokens = positiveInt(options.maxTokens) ?? entry?.maxTokens
|
|
670
661
|
if (maxTokens !== undefined) body.max_tokens = maxTokens
|
|
@@ -1018,9 +1009,38 @@ export class WorkBuddyAdapter {
|
|
|
1018
1009
|
yield finishChunk({ kind: 'error', failure: { message: '模型返回了空响应', code: 'EMPTY_RESPONSE' } })
|
|
1019
1010
|
return
|
|
1020
1011
|
}
|
|
1021
|
-
|
|
1012
|
+
// `finish_reason` has more values than the three the OpenAI docs mention, and
|
|
1013
|
+
// the official CodeBuddy CLI maps the whole set (`mapFinishReasonToStopReason`
|
|
1014
|
+
// in its bundle): `refusal` / `content_filter` are their own outcome, not a
|
|
1015
|
+
// stop, and `error` means the provider gave up mid-answer. Mapping those to
|
|
1016
|
+
// `stop` is how a refusal and a real finish became indistinguishable here.
|
|
1017
|
+
const refusalReason = finishReason === 'refusal' || finishReason === 'content_filter'
|
|
1018
|
+
if (refusalReason) {
|
|
1019
|
+
this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的上游拒绝/过滤了本次回答(finish_reason=${finishReason}),不重试`)
|
|
1020
|
+
yield finishChunk({
|
|
1021
|
+
kind: 'error',
|
|
1022
|
+
failure: {
|
|
1023
|
+
message: `${this.region.provider}: 上游拒绝或按内容策略过滤了本次回答(finish_reason=${finishReason}),请换个说法或换个模型`,
|
|
1024
|
+
code: 'INVALID_REQUEST'
|
|
1025
|
+
}
|
|
1026
|
+
})
|
|
1027
|
+
return
|
|
1028
|
+
}
|
|
1029
|
+
if (finishReason === 'error') {
|
|
1030
|
+
this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的上游以 finish_reason=error 结束,按传输错误上报,可自动重试`)
|
|
1031
|
+
yield finishChunk({
|
|
1032
|
+
kind: 'error',
|
|
1033
|
+
failure: {
|
|
1034
|
+
message: `${this.region.provider}: 上游在回答中途返回了错误(finish_reason=error;可重试的传输错误)`,
|
|
1035
|
+
code: 'TRANSPORT'
|
|
1036
|
+
}
|
|
1037
|
+
})
|
|
1038
|
+
return
|
|
1039
|
+
}
|
|
1040
|
+
const toolReason = finishReason === 'tool_calls' || finishReason === 'tool_use' || finishReason === 'function_call'
|
|
1041
|
+
const reason = toolReason || toolOrder.length > 0
|
|
1022
1042
|
? { kind: 'tool-calls' }
|
|
1023
|
-
: finishReason === 'length'
|
|
1043
|
+
: finishReason === 'length' || finishReason === 'max_tokens'
|
|
1024
1044
|
? { kind: 'max-tokens' }
|
|
1025
1045
|
: { kind: 'stop' }
|
|
1026
1046
|
// Two endings are complete-looking but are not the model choosing to stop, and
|
|
@@ -1031,6 +1051,10 @@ export class WorkBuddyAdapter {
|
|
|
1031
1051
|
this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的输出达到上限,本步被截断(输出 ${outputTokens ?? '未知'} tokens)`)
|
|
1032
1052
|
} else if (reason.kind === 'stop' && finishReason === undefined) {
|
|
1033
1053
|
this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的响应以 [DONE] 收尾但没有 finish_reason,按 stop 处理(输出 ${outputTokens ?? '未知'} tokens)`)
|
|
1054
|
+
} else if (reason.kind === 'stop' && !['stop', 'end_turn', 'stop_sequence'].includes(finishReason)) {
|
|
1055
|
+
// An unknown value is worth seeing: the official client leaves the stop
|
|
1056
|
+
// reason at its default for these too, so nobody upstream has classified them.
|
|
1057
|
+
this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 返回了未识别的 finish_reason=${finishReason},按 stop 处理`)
|
|
1034
1058
|
}
|
|
1035
1059
|
yield finishChunk(reason)
|
|
1036
1060
|
}
|
|
@@ -1040,14 +1064,6 @@ function positiveInt(value) {
|
|
|
1040
1064
|
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined
|
|
1041
1065
|
}
|
|
1042
1066
|
|
|
1043
|
-
/** A numeric override from the environment, or `undefined` when unset/unusable. */
|
|
1044
|
-
function envNumber(name) {
|
|
1045
|
-
const raw = process.env[name]
|
|
1046
|
-
if (typeof raw !== 'string' || raw.trim().length === 0) return undefined
|
|
1047
|
-
const value = Number(raw)
|
|
1048
|
-
return Number.isFinite(value) ? value : undefined
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
1067
|
/**
|
|
1052
1068
|
* Describe a stream that ended without its terminator.
|
|
1053
1069
|
*
|