# dsh-plugin-thinking-api

<div align="center">

[English](./README.md) · **简体中文**

</div>

<p align="center">
  DeepSeek Harness 插件：一键配置任意 OpenAI 兼容 API 并自动带思考模式，同时规避 <code>developer</code> 角色被第三方端点拒绝（<code>content_filter</code>）的问题。
</p>

<p align="center">
  <img src="https://badgen.net/badge/license/MIT/blue" alt="license">
  <img src="https://badgen.net/badge/node/%3E%3D20/green" alt="node">
  <img src="https://badgen.net/badge/dsh/0.1--0-rc--6%20%E2%80%93%200.2--1-rc--2/purple" alt="dsh">
  <img src="https://github.com/qjf44/dsh-plugin-thinking-api/actions/workflows/check.yml/badge.svg" alt="ci">
</p>

---

## 解决什么问题

一个插件解决三件事：

1. **任意 OpenAI 兼容 API 一键配置** —— 腾讯 CodeBuddy、自建 vLLM、各类中转站，一个配置块搞定，无需手写 `reasoningEfforts`、`thinkingFormat` 或 pi-ai provider 内部结构。
2. **开箱即用的思考模式** —— 模型上写 `thinking: true` 就自动获得思考档位。
3. **修复 `content_filter` / `developer` 角色 bug** —— 这是最隐蔽的一个。

### 它修复的 bug

当模型声明了 reasoning（思考）时，pi-ai 会把 system prompt 改写成 OpenAI 的 `developer` 角色——除非它把该端点识别为「非标厂商」。不在 pi-ai 内置白名单里的第三方 API（腾讯 CodeBuddy 就是典型）因此会收到 `developer` 消息，而很多端点会硬拒绝它并返回 `content_filter`；同样的请求换成 `system` 角色则完全正常。

官方 `dsh-llm-pi-ai` 适配器在组装模型时**丢弃**了 `compat.supportsDeveloperRole` 字段，所以单靠 `settings.yaml` 无法修复。本插件自己组装 pi-ai 模型，直接注入 `compat.supportsDeveloperRole: false`，强制走 `system` 角色。

### 为什么它好维护

插件**复用**官方 `PiAiAdapter` 类（由 `@deepseek-ai/dsh-llm-pi-ai` 导出）：它的流式输出、chunk 翻译、凭据解析、空闲超时看门狗、图片处理都随 DSH 升级自动演进。本插件只负责「模型 / Provider 组装」这一小层——把正确的 `compat` 写进去。

## 安装

在 profile 的 `package.json` 里加入依赖和 bundle 列表：

```jsonc
// ~/.dsh/profiles/<profile>/package.json
{
  "dependencies": {
    "dsh-plugin-thinking-api": "github:qjf44/dsh-plugin-thinking-api"
  },
  "dsh": {
    "profile": {
      "bundles": [
        // ...你已有的 bundles...
        "dsh-plugin-thinking-api"
      ]
    }
  }
}
```

重装依赖并重启 Harness。插件的 `cordis.patch.yml` 会自动注册自身（`insert: [{ id: thinking-api }]`），**无需**手动改 `cordis.patch.yml`。

## 配置

在 `~/.dsh/settings.yaml` 里加一个 `thinking-api` 区块：

```yaml
thinking-api:
  providers:
    codebuddy:                                   # 路由 id（任意唯一名称）
      displayName: CodeBuddy                     # 可选，选择器里显示的名字
      baseURL: https://copilot.tencent.com/v2
      apiKeyEnv: CODEBUDDY_API_KEY               # 环境变量名；通过 Web 模型页存储或 export
      thinkingFormat: deepseek                   # 可选，默认 deepseek
      models:
        deepseek-v4-pro:
          name: DeepSeek V4 Pro
          thinking: true                         # ← 开启思考模式
        deepseek-v4-flash:
          name: DeepSeek V4 Flash
          thinking: false
        deepseek-v4.1-flash:
          name: DeepSeek V4.1 Flash
          thinking: true
          input: [text, image]                   # ← 声明可看图（多模态），read_image 才可用
```

存储密钥（**不要把明文 key 写进配置**）：

```bash
# 方式一：在启动环境里导出
export CODEBUDDY_API_KEY=ck_xxxxxxxx

# 方式二：通过 Web 界面 → 模型页写入（credentials 服务）
```

重启后，从模型选择器里选你的 API 模型即可。

> 📄 可直接复制的配置（CodeBuddy / 自建 vLLM / 任意 OpenAI 兼容中转站）：见 [`examples/settings.yaml`](./examples/settings.yaml)。

## Web 界面一键配置

插件还注册了 **设置 → 思考 API** 面板，内置一个统一的接入向导，不用手写 YAML 就能接入 API：

1. **来源** —— 选模板（CodeBuddy / DeepSeek / OpenRouter / …）或「自定义」。
2. **API 密钥** —— 粘贴一次即可。模板自带常用默认模型，填完 key 就能直接保存开始对话。
3. **模型**（可选）—— 点「获取模型列表」拉取端点模型，向导会按名称自动预判思考模型（每条都能用勾选框微调）；也可以手动添加模型 id。
   > 腾讯 CodeBuddy（`copilot.tencent.com`）不提供 OpenAI 兼容的 `/models` 端点，点「获取模型列表」时会自动填入模板预置模型（而非报 404 错误）；其他端点若 404 则需手动填写模型 id。
4. **保存** —— 完成。该 provider 会出现在模型选择器里，并走修复版适配器（`supportsDeveloperRole: false`）。

已接入的 provider 可以点「编辑」回填进向导修改，也可以删除。

> client 半通过 `exports["./client"]` 作为浏览器 bundle 被发现；修改插件源码后需重建 Web 产物，本 URL 才会加载新 bundle。

## 配置参考

### Provider（`providers.<id>` 下）

| 字段 | 类型 | 必填 | 默认 | 含义 |
|---|---|---|---|---|
| `baseURL` | string | ✅ | — | API 端点 base URL |
| `apiKeyEnv` | string | — | — | 存放 API key 的环境变量名 |
| `displayName` | string | — | 路由 id | 选择器里显示的名字 |
| `thinkingFormat` | enum | — | `deepseek` | `deepseek` \| `openai` \| `openrouter` \| `together` \| `baseten` \| `zai` \| `qwen` \| `qwen-chat-template` \| `chat-template` \| `string-thinking` \| `ant-ling` |
| `models` | dict | ✅ | — | 模型 id → 模型条目 |

### Model（`models.<id>` 下）

| 字段 | 类型 | 必填 | 默认 | 含义 |
|---|---|---|---|---|
| `name` | string | — | 模型 id | 显示名 |
| `thinking` | boolean | — | `false` | 是否开启思考档位 |
| `thinkingEfforts` | dict | — | 自动 | 自定义档位 → 线上参数映射，如 `{ off: null, high: high, max: xhigh }` |
| `contextWindow` | number | — | `262144` | 上下文窗口大小 |
| `maxTokens` | number | — | `32768` | 最大输出 token |
| `input` | array | — | `["text"]` | 输入模态：`["text"]` 或 `["text","image"]`。声明 `image` 后该模型才会被 DSH 认作视觉模型（`read_image` 才可用） |

`thinking: true` 且未给 `thinkingEfforts` 时，插件自动填充已验证可用的 DeepSeek 兼容档位（`off` / `high` / `max`，分别对应关闭 / `reasoning_effort: high` / `reasoning_effort: xhigh`）。需要更多档位（`low`/`medium`）或按 API 定制时，用 `thinkingEfforts` 显式覆盖。

> **关于 `input` / 图片输入**：DSH 的 `read_image` 会拒绝任何未声明 `image` 的路由（报 `model "..." does not declare image input`），而本插件默认把所有模型登记为纯文本。要让某个模型能看图，需显式声明 `input: [text, image]`，例如腾讯 CodeBuddy 的 `deepseek-v4.1-flash`（即官方原生多模态的 V4.1 Flash）。空数组与缺省同义，均回落为 `["text"]`。
>
> 注意这只是**声明**，不代表上游端点真的接受图片：声明后若端点拒收，会在真正传图时报错；反之若端点支持而你未声明，图片会在本地就被拦下、根本发不出去。

## 为什么不用内置 `llm-pi-ai`？

内置 `llm-pi-ai` 适配器本来就支持自定义 API，只是它无法表达 `supportsDeveloperRole`（它的 `compat` schema 没这个字段，解析器也会丢弃它），于是「白名单外 API + 思考模式」就会撞上 `developer` 角色拒绝。本插件存在的意义，就是补上这缺失的一个字段，其余全部复用。

## 版本号规则：插件版本号 = 所对齐的 DSH 版本号

从 `0.2.1-rc.2` 起，**本插件版本号与它对齐的 DSH 版本号完全相同**（此前是独立递增的 `0.1.x`）。看到 `dsh-plugin-thinking-api@0.2.1-rc.2`，就知道它对应 DSH `0.2.1-rc.2`，不用翻 changelog 猜。`check-docs` 会强制校验：插件版本必须等于 `peerDependencies` 里的最高 DSH 版本，或在该版本之上多一个补丁段（如 `0.2.1-rc.2.1`，用于纯插件侧修复），否则 CI 失败。

> `0.1.0` – `0.1.10` 属于旧规则（独立编号），`0.1.10` 是最后一个；它的全部内容都已包含在 `0.2.1-rc.2` 中。

## 支持的 DSH 版本

基于 DSH `0.1.0-rc.6` 至 `0.2.1-rc.2`（`@deepseek-ai/dsh-llm-pi-ai`）、pi-ai `0.82.1` – `1.x` 构建。插件对 `PiAiAdapter` 的构造器形状做了防御性依赖；若未来 DSH 改变该内部契约，插件会给出清晰报错而非静默出错——升级 DSH 前请先看本插件的 release notes。

**DSH 0.2.x。** pi-ai 1.3.0 新增 `baseten`、`chat-template`、`qwen-chat-template`、`ant-ling` 四个思考方言，`dsh-llm-pi-ai` 0.2.1 的 `SUPPORTED_THINKING_FORMATS` 已全部收录。v0.1.10 同步扩充插件的 `thinkingFormat` 枚举与默认档位表；`check-compat` 新增一项：把插件枚举与官方源码逐一比对，未来官方再加方言时自检会失败，而不是静默拒绝合法配置。

**DSH 0.1.7 兼容性。** 0.1.7 移除了 `SettingsProvider` 及其 `installSection()`，改用 `SettingsForms`：设置页由插件**导出的 `Config` schema** 自动投影，且只呈现标了 `.volatile()` 的字段。故 v0.1.9 做了四件事：

- 导出 `Config`（0.1.7 读 `fiber.runtime.Config` 来构建设置页）；
- 给 `providers` 标 `.volatile()`，并按 `volatile()` → `extra('volatile', true)` 三级降级探测——**只探测 `.volatile()` 是不够的**，见下；
- 在既有「≤0.1.1 导出函数」与「0.1.5/0.1.6 实例方法」两条分支之外，补上第三条 `SettingsForms` 分支；
- 解包 `.volatile()` 产生的 cosmokit `Volatile` 引用，使 `config.providers` 仍能按普通字典枚举。

漏掉 `.volatile()` 的后果：0.1.7 的 `describe()` 会整行跳过本插件，`mutate()` 抛 `has no volatile fields` —— 设置页静默消失，且 CodeBuddy 无法保存。漏掉 `Config` 导出则整个条目不可配置。

> **注意 profile 里残留的旧 schemastery。** DSH 按 **profile 的物理 `node_modules`** 解析插件依赖。若你的 profile 里还留着一个旧世代副本（例如 `~/.dsh/profiles/<name>/node_modules/@deepseek-ai/schemastery` 为 3.18.2，而 0.1.7 运行时自带 3.18.4），插件就会解析到 3.18.2。该版本**完全没有** `volatile` 逻辑，于是标记失效 → 0.1.7 的 `importLegacyDocument()` 只记一条 **warn** 就把 `settings.yaml` 的 `thinking-api` 段（含 CodeBuddy provider 目录）整段丢弃，**退出码仍为 0、无任何 error**。
>
> v0.1.9 的 `extra('volatile', true)` 兜底已能在这种环境下照常工作（3.18.4 的 `.volatile()` 实现本身就是这一行，两者等价），**因此不需要你手工删除该副本**，也能扛住 `pnpm install` 把它重新装回来。若仍想清理，删掉该目录即可——但**不要**把 3.18.4 反向拷进旧 profile：它需要 cosmokit ≥1.8.5（旧 profile 只有 1.8.3），会让 0.1.6 启动直接硬失败。

**DSH 0.1.5 兼容性。** 0.1.5 同时改动了多个内部契约，v0.1.2 及更早版本在其上会直接报错；v0.1.3–v0.1.5 依次修复（细节见 changelog）：

| 在 DSH 0.1.5 上的现象 | 根因 | 修复版本 |
| --- | --- | --- |
| 启动即 `Failed to load plugins` | `dsh.client.inject` 仍声明已被移除的 `@deepseek-ai/dsh-client-runtime` | v0.1.3 |
| 设置面板 `Cannot read properties of undefined (reading 'settings')` | `connection` 不再暴露 `.api`，取数须改走 `ctx.remote.*` | v0.1.4 |
| 模型选择器 `CodeBuddy 加载失败: Cannot read properties of undefined (reading 'get')` | 0.1.5 的 `pi-ai` `modelOf()` 会读 `profile.modelErrors`，而插件手组的 profile 缺该字段 | v0.1.5 |

## 升级 DSH 后 CodeBuddy / 第三方 API 用不了？先跑自检

插件依赖 pi-ai 与 DSH 的若干内部契约（provider auth 形状、`PiAiAdapter` 构造器、`llm` 服务注册方法等）。这些契约**不在官方语义版本保证内**，所以每次升级 DSH（或 pi-ai）后，如果模型突然报 `Provider is not configured`、`content_filter`、`MISSING_CREDENTIAL` 之类，先跑一次兼容性自检：

```bash
# 在插件仓库目录下（DSH workspace 会自动向上查找；找不到时显式指定）
node scripts/check-compat.mjs --workspace ~/.workbuddy/binaries/node/workspace
```

它会逐项核对：插件能否在真实依赖下 import、`PiAiAdapter` 构造器形状、pi-ai provider auth 形状、`llm` 服务注册方法、settings/credentials 辅助函数。**全部 ✓ 才能继续用；有任何 ✗ 就说明需要升级插件**（报错信息会点名是哪个契约变了、去哪改）。

### 历史踩坑记录（2026-10-21，CodeBuddy 网关 11128「unapproved channel」）

现象：CodeBuddy / CodeBuddy2 的每一次请求都回
`400 {"code":11128,"msg":"Illegal API invocation from an unapproved channel"}`，
而用**同一把 key、同样结构的 body** 的 `curl` 却是 200。

真相：网关指纹校验的是 **system prompt 里的客户端身份句**，不是 User-Agent，
也不是消息角色。精确触发窗口是整句
`You are an AI agent powered by DeepSeek Harness.`（大小写不敏感）。单独拆开全部放行：
`You are an AI agent.`、`You are powered by DeepSeek Harness.`、
`You are an AI agent powered by Cursor.`，以及任何插入词打断 "powered by" 邻接的写法；
整句出现立刻 11128。

真实 key 的对照实验（除注明外全 200）：tools、1.3 MB 大 body、45 个工具、
图片 data-URI、含 assistant/tool 的多轮、各种可选字段。把真实 system prompt 喂进去
即使只截前 500 字符也 400；同一段文本放进 `user` 角色则 200。

两条值得记住的教训：

- **「我的 curl 通、插件不通」不等于插件有问题。** 这次唯一有区别的是**内容**，
  不是传输层。二分排查时，除了 header 和角色，也要二分 **body 文本本身**。
- **`developer` 角色被拒可能是假线索。** 它能复现**一模一样**的错误串，很像根因。
  它确实是一个真实故障模式（现在有防线），但不是用户当时踩的那个。

修复：三层纵深防御（都在 `buildProvider` 的 stream/streamSimple 包装层）——
compat `supportsDeveloperRole: false`、`onPayload` 钩子把 `developer` 改写成
`system`、以及 **fetch 层**改写身份句（fetch 是最后出口，所有代码路径必经）。
可观测性：每次请求把真实 wire 形态写入 `/tmp/dsh-thinking-api-wire.log`，
遇到 11128 则把完整请求体落盘到 `/tmp/dsh-thinking-api-wire-fail-<ts>.json`。

### 历史踩坑记录（2026-09-12，图片输入「静默失效」）

用户反馈「CodeBuddy 的 `deepseek-v4.1-flash` 读不了图」，排查结论是**插件的问题，不是模型的问题**：

- **现象**：对任意图片调用 `read_image` 一律报 `model "<id>" does not declare image input; switch to an image-capable model to read images`。注意这个拒绝发生在**发出网络请求之前**。
- **根因**：`buildModel()` 把 `input` 硬编码为 `['text']`，而 `dsh-llm-pi-ai` 会把它原样映射为 `inputModalities`；`dsh-tool-fs` 的 `read_image` 据此拒绝任何未声明 `image` 的路由。于是图片**在本地就被丢掉**，上游是原生多模态模型也白搭。模型条目 schema 里当时根本没有 `input` 字段，用户无法从 `settings.yaml` 绕过。
- **修复**：新增可选 `input` 字段（默认 `['text']` 保持行为不变），并把 `buildModel` 改为读取它；空数组与缺省同义（对齐官方 `declaredInput` 语义）。

教训：**「模型不支持」和「路由没声明」是两回事。** 排查视觉问题时，先看本地能力声明（`inputModalities`），再怀疑上游端点——前者会把图拦在本地，后者的报错来自服务端，两者的报错位置和修复方式完全不同。

另一个值得记住的部署陷阱：profile 里的 `file:` 依赖是**拷贝**而非软链。只改插件仓库源码、不重跑 `pnpm install`，profile 里那份旧代码纹丝不动，改动静默不生效。

### 历史踩坑记录（2026-09-11，DSH 0.1.1-rc.2 → 0.1.5-rc.1）

0.1.5 一次挪动了三个契约，且各自只在**不同层面**才暴露——这正是「服务能启动」这类检查抓不到它们的原因：

- **客户端预加载（`Failed to load plugins`）。** 0.1.5 移除了 `@deepseek-ai/dsh-client-runtime`，但插件仍在 `dsh.client.inject` 里声明它，加载器找不到模块，整条插件图一起失败。`createSnapshotStore` 也迁到了内置 seed 模块 `@deepseek-ai/dsh-client-store`。修复：从 inject 去掉 runtime，改从 seed 模块导入 store。
- **渲染层取数（`reading 'settings'`）。** `connection` 不再携带 `.api`，settings/credentials/llm 的读取须改走 cordis 命名空间服务 `ctx.remote.settings` / `ctx.remote.credentials` / `ctx.remote.llm`。注意客户端 bundle 是按内容哈希按请求下发的，这一半**刷新页面即生效，无需重启宿主**。
- **宿主 profile 形状（`reading 'get'`）。** 0.1.5 的 `pi-ai` `modelOf()` 会无条件执行 `profile.modelErrors.get(model)`。插件是手工组 profile 的（无法直接复用官方 `resolveProfiles`——它没有 `userAgent` / `compat.supportsDeveloperRole` 这两个钩子），而这个手组对象早于该字段存在，于是 `modelCatalog` 枚举时整个 provider 组抛错。单看 `listModels` 走的是 `getModels()`、本身安全，所以该组**能列出**、只在选择器解析每个模型详情时才失败。修复：补上 `modelErrors: new Map()` 以及官方 profile 同样携带的图片预算默认值。**这一半是宿主代码，必须重启 harness 才生效。**

教训：DSH 升级后要在三个层面分别验证（启动、设置面板、模型选择器），不能只看服务有没有起来。

### 历史踩坑记录（2026-08-18，DSH rc.6 → rc.7）

升级后 codebuddy 请求 100% 报 `PI_AI_ERROR: Provider is not configured: codebuddy`，根因是 **pi-ai 0.82.1 改了 provider auth 契约**：

- 旧（0.82.1 之前）：`auth: { name, resolve }`（顶层 `resolve`）。
- 新（0.82.1 起）：`resolveProviderAuth` 只认 `auth.apiKey.resolve`，顶层 `resolve` 被当作「无认证方式」→ `getAuth` 返回空 → 上述报错。

修复：`buildProvider` 把 auth 组装成 `{ apiKey: { name, resolve } }`，与官方 `dsh-llm-pi-ai` 的 `routeAuth`/`harnessApiKeyAuth` 形态一致。插件现在启动时会自检该契约，版本不匹配会在启动时直接报错，而不是等你发消息。

另一个坑：**GUI 向导编辑 provider 时 key 留空会抹掉已有的 `apiKeyEnv`**，导致升级/重配后密钥引用丢失（报 `MISSING_CREDENTIAL` 或认证失效）。已修复：编辑模式 key 留空表示「不改」，沿用原有 `apiKeyEnv`。

## 参与贡献

欢迎提交 bug 和反馈——请用 [bug 报告模板](https://github.com/qjf44/dsh-plugin-thinking-api/issues/new/choose) 建 issue（模板会要 DSH 版本、插件版本和 check-compat 输出，能覆盖九成故障）。

提 PR 前先跑本地检查：

```bash
npm run check                 # 语法（lib/index.mjs、lib/client.js、scripts/check-compat.mjs）
node scripts/check-compat.mjs --workspace ~/.workbuddy/binaries/node/workspace   # 对真实 DSH 做契约自检
```

每次 push/PR 时 CI 会跑 `npm run check` 加一个 npm 包体检 job（tarball 文件清单、版本号是否已发布）——见 [`.github/workflows/check.yml`](.github/workflows/check.yml)。

## License

[MIT](./LICENSE)
