@huai_xin/dsh-workbuddy 0.1.7 → 0.1.9

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
@@ -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
 
@@ -175,8 +175,8 @@ POST <chatEndpoint>/v2/chat/completions
175
175
  | `max_tokens` | 否 | 实测 `128000` 可用 ✅;未知/别名模型按目录 `maxOutputTokens` |
176
176
  | `tools` | 否 | OpenAI function 形态,70 个工具 / 60 KB 实测通过 ✅ |
177
177
  | `prompt_cache_key` | 否 | 服务端前缀缓存键;缺失时缓存命中恒为 0 📖 |
178
- | `thinking` | 否 | `{"type":"enabled"}`,deepseek 系使用 📖✅ |
179
- | `reasoning_effort` | 否 | `low` / `medium` / `high` / `xhigh` / `max`,取值须在该模型支持列表内 ✅ 网关会校验(见下) |
178
+ | `thinking` | 否 | **本插件不再发送**(2026-10-23 实测:带不带它都不影响 ds 是否思考,见 §12.1) |
179
+ | `reasoning_effort` | 否 | `low` / `medium` / `high` / `xhigh` / `max`,取值须在该模型支持列表内 ✅ 网关会校验(见下);**这是唯一的思考控制**,按目录声明统一取值(调用方选择 → `defaultEffort` → `high` → 首档) |
180
180
  | `temperature` / `stop` | 否 | 直通 ✅ |
181
181
 
182
182
  **`reasoning_effort` 是真实生效的参数**(2026-10-22 实测,海外版 `deepseek-v4.1-flash`,免费模型,
@@ -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,72 @@ 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
+ #### 12.1 请求体逐字段对照(2026-10-23 补)
864
+
865
+ 官方 `providerData` 里还有 `thinking:{type:'disabled'}`(关闭思考时才发;开启时不发该字段)、
866
+ `reasoning_effort`(档位)。逐字段差异与影响判断:
867
+
868
+ | 字段 | 本插件 | 官方 CLI | 影响 |
869
+ | --- | --- | --- | --- |
870
+ | `model` / `messages` / `stream` / `tools` / `max_tokens` | 发 | 发 | 一致 ✓ |
871
+ | `stream_options.include_usage` | 发(0.1.8 起) | 发 | 已对齐 ✓ |
872
+ | `tool_choice` / `parallel_tool_calls` | 不发 | 发(配置值) | 服务端默认 `auto` / `true`,等价 ✓ |
873
+ | `temperature` / `top_p` / `frequency_penalty` / `presence_penalty` | **不发**(仅调用方给 temperature 时发) | **总是发**(模型配置值,缺省 `DEFAULT_TEMPERATURE`) | **真实差异**,但实测对"提前收尾"无影响(默认/0.2/0.5/0.7/0.3+top_p0.9 各 8 次:2/8、2/8、3/8、3/8,同一水平)→ 暂不跟随,避免擅自改变输出风格 |
874
+ | `reasoning_effort` | 发(目录档位,**所有模型同一套规则**:调用方选择 → `defaultEffort` → `high` → 首档;无档位不发) | 发(同左) | 一致 ✓ |
875
+ | `thinking` | **不发**(2026-10-23 起) | **开启时不发**;关闭时发 `{type:'disabled'}` | 已对齐 ✓。实测(两个部署各 2–3 次):带不带 `{type:'enabled'}`,`high` 档都是 0 思考字符;`max` 档不带时思考更多(INTL 384 vs 306、CN 476 vs 418)→ 该字段对"思不思考"没有作用 |
876
+ | `prompt_cache_key` | 发(会话级) | **不发** | 反向差异;实测对本问题无影响(去掉后 1/8 vs 2/8),保留以提高前缀缓存命中 ✓ |
877
+ | `stop` | 仅调用方给 | 不发 | 等价 ✓ |
878
+ | `store` / `prompt_cache_retention` | 不发 | 发(配置值,通常未设置) | 等价 ✓ |
879
+ | 请求头 | `Authorization` / `Accept` / `X-Domain` / `X-Product` / `X-Product-Code` / `User-Agent` | 同类内部头(`X-User-Id`/`X-Enterprise-Id`/`X-Tenant-Id`/`X-Department-Info`/`X-Domain`/`User-Agent`),且会剔除内部头再转发 | 我们的头是目录/聊天都验证过可用的最小集合 ✓ |
880
+
881
+ **结论:结构上不是"完全一样",但没有一处会造成"提前收尾"** —— 唯一的实质差异是官方总带采样参数,
882
+ 而实测它对这个问题无效。`refusal` 的含义见 §12.2。
883
+
884
+ #### 12.2 `finish_reason: "refusal"` 是什么
885
+
886
+ 官方把 `refusal` 与 `content_filter` 归为同一类 `stopReason:"refusal"`,它的来源(读官方 bundle):
887
+
888
+ 1. **输入侧内容安全拦截**:`prepareSensitiveUserPrompt` 判定 `deny` 时直接返回
889
+ `stopReason:"refusal"`(日志 `Sensitive user prompt blocked before history`)——**请求根本没发出去**;
890
+ 2. **模型/厂商主动拒绝**:`isContentPolicyRefusal(...)` 判定的内容策略拒绝;
891
+ 3. 本地命令执行失败等"请求没能完成"的收尾,也复用 `refusal`。
892
+
893
+ 所以 `refusal` ≠ 网络问题、≠ 截断,而是**"这次不回答"**。正确反应是**不重试**(重试会得到同样的拒绝),
894
+ 把原因告诉用户 —— 本插件现在正是这样(`INVALID_REQUEST` + 明确文案,见 §4.5)。
895
+
896
+ 三条结论:
897
+
898
+ 1. **我们和官方在"结束原因"上原本差一张表** —— 官方把 `refusal`/`content_filter` 单独归类、
899
+ 把 `error` 当服务错误;我们此前只有 `stop`/`length`/`tool_calls`,其余全落进 `stop`。
900
+ 后果就是:**上游拒绝或过滤时,表现和"模型自己说完了"完全一样** —— 已按上表对齐。
901
+ 2. **`stream_options.include_usage` 官方一直带**,我们漏了;补上后 `usage` 更可靠(诊断里的
902
+ `outputTokens` 也才有据可依)。
903
+ 3 . **`prompt_cache_key` 是反向差异**:官方不发,我们发。它只影响前缀缓存命中,不影响语义,
904
+ 留着(会话级稳定值,命中率实测有效)。
905
+
906
+ ---
907
+
841
908
  ## 附:本仓库可复用的抓取脚本
842
909
 
843
910
  | 脚本 | 用途 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huai_xin/dsh-workbuddy",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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
@@ -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
  }
@@ -656,23 +660,30 @@ export class WorkBuddyAdapter {
656
660
  const maxTokens = positiveInt(options.maxTokens) ?? entry?.maxTokens
657
661
  if (maxTokens !== undefined) body.max_tokens = maxTokens
658
662
 
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.
665
- const isDeepSeek = model.startsWith('deepseek-')
666
- if (isDeepSeek) body.thinking = { type: 'enabled' }
667
- // `efforts` is optional: many catalog entries declare no level set, and
668
- // reading `Object.keys` off that `undefined` used to throw before the
669
- // request was even built.
663
+ // Thinking is pinned on for every model, with no per-family branch: the effort
664
+ // level is the only reasoning control, and it is picked from what the catalog
665
+ // declares rather than from the model's name —
666
+ // 1. the level the caller chose, when the model declares it;
667
+ // 2. otherwise the model's own `defaultEffort` from the catalog;
668
+ // 3. otherwise `high` when declared, else the first declared level;
669
+ // 4. no levels declared → send nothing and let the deployment decide.
670
+ //
671
+ // The `thinking` field is deliberately **not** sent for anyone. Measured
672
+ // 2026-10-23 on both deployments (`test/live/thinking-field-probe.mjs`): with
673
+ // `reasoning_effort` present, dropping `thinking:{type:'enabled'}` changes
674
+ // nothing at `high` (0 reasoning chars either way) and yields slightly more
675
+ // reasoning at `max` (384 vs 306 on INTL, 476 vs 418 on CN). The official client
676
+ // only sends that field when it turns thinking *off*, so the enabled form was
677
+ // never part of its contract.
678
+ //
679
+ // `efforts` is optional: many catalog entries declare no level set, and reading
680
+ // `Object.keys` off that `undefined` used to throw before the request was built.
670
681
  const levels = entry?.efforts === undefined ? [] : Object.keys(entry.efforts)
671
- if (options.reasoningEffort !== undefined && levels.includes(options.reasoningEffort)) {
672
- body.reasoning_effort = options.reasoningEffort
673
- } else if (isDeepSeek && levels.length > 0) {
674
- body.reasoning_effort = levels.includes('high') ? 'high' : levels[0]
675
- }
682
+ const preferred = levels.includes(options.reasoningEffort)
683
+ ? options.reasoningEffort
684
+ : (levels.includes(entry?.defaultEffort) ? entry.defaultEffort : undefined)
685
+ const effort = preferred ?? (levels.includes('high') ? 'high' : levels[0])
686
+ if (effort !== undefined) body.reasoning_effort = effort
676
687
  return body
677
688
  }
678
689
 
@@ -1005,9 +1016,38 @@ export class WorkBuddyAdapter {
1005
1016
  yield finishChunk({ kind: 'error', failure: { message: '模型返回了空响应', code: 'EMPTY_RESPONSE' } })
1006
1017
  return
1007
1018
  }
1008
- const reason = finishReason === 'tool_calls' || toolOrder.length > 0
1019
+ // `finish_reason` has more values than the three the OpenAI docs mention, and
1020
+ // the official CodeBuddy CLI maps the whole set (`mapFinishReasonToStopReason`
1021
+ // in its bundle): `refusal` / `content_filter` are their own outcome, not a
1022
+ // stop, and `error` means the provider gave up mid-answer. Mapping those to
1023
+ // `stop` is how a refusal and a real finish became indistinguishable here.
1024
+ const refusalReason = finishReason === 'refusal' || finishReason === 'content_filter'
1025
+ if (refusalReason) {
1026
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的上游拒绝/过滤了本次回答(finish_reason=${finishReason}),不重试`)
1027
+ yield finishChunk({
1028
+ kind: 'error',
1029
+ failure: {
1030
+ message: `${this.region.provider}: 上游拒绝或按内容策略过滤了本次回答(finish_reason=${finishReason}),请换个说法或换个模型`,
1031
+ code: 'INVALID_REQUEST'
1032
+ }
1033
+ })
1034
+ return
1035
+ }
1036
+ if (finishReason === 'error') {
1037
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的上游以 finish_reason=error 结束,按传输错误上报,可自动重试`)
1038
+ yield finishChunk({
1039
+ kind: 'error',
1040
+ failure: {
1041
+ message: `${this.region.provider}: 上游在回答中途返回了错误(finish_reason=error;可重试的传输错误)`,
1042
+ code: 'TRANSPORT'
1043
+ }
1044
+ })
1045
+ return
1046
+ }
1047
+ const toolReason = finishReason === 'tool_calls' || finishReason === 'tool_use' || finishReason === 'function_call'
1048
+ const reason = toolReason || toolOrder.length > 0
1009
1049
  ? { kind: 'tool-calls' }
1010
- : finishReason === 'length'
1050
+ : finishReason === 'length' || finishReason === 'max_tokens'
1011
1051
  ? { kind: 'max-tokens' }
1012
1052
  : { kind: 'stop' }
1013
1053
  // Two endings are complete-looking but are not the model choosing to stop, and
@@ -1018,6 +1058,10 @@ export class WorkBuddyAdapter {
1018
1058
  this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的输出达到上限,本步被截断(输出 ${outputTokens ?? '未知'} tokens)`)
1019
1059
  } else if (reason.kind === 'stop' && finishReason === undefined) {
1020
1060
  this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的响应以 [DONE] 收尾但没有 finish_reason,按 stop 处理(输出 ${outputTokens ?? '未知'} tokens)`)
1061
+ } else if (reason.kind === 'stop' && !['stop', 'end_turn', 'stop_sequence'].includes(finishReason)) {
1062
+ // An unknown value is worth seeing: the official client leaves the stop
1063
+ // reason at its default for these too, so nobody upstream has classified them.
1064
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 返回了未识别的 finish_reason=${finishReason},按 stop 处理`)
1021
1065
  }
1022
1066
  yield finishChunk(reason)
1023
1067
  }