
写在前面
DeepSeek Harness(DSH)是一个把"模型 + 工具 + 会话 + GUI"做成可插拔件(cordis plugin)的桌面 / Web Agent 运行时。本次任务是给它接入一个新模型——MiniMax-M3——作为除官方 DeepSeek-V4 之外的第二条 provider 路线。我把全过程跑下来,把"DSH 怎么把一个外部模型纳入它自己的体系"这个机制讲清楚。

整篇文章对应三个实际产出物:
~/.dsh/settings.yaml—— 用户侧配置~/.dsh/.credentials.yaml—— 用户侧密钥- 两个 vendor 补丁(
@earendil-works/pi-ai的openai-completions.js和@deepseek-ai/dsh-llm-pi-ai的lib/index.js)—— 给 pi-ai 加minimax方言 + 给 dsh-llm-pi-ai schema 加白名单
以及 6 个验证脚本,全部跑通:
verify.mjs(文本 + reasoning seam)gui.mjs(真实 GUI 路径 + 截图)verify-image.mjs(图片输入多模态)verify-tool-call.mjs(工具调用两轮 loop)verify-runtime.mjs+mock-server.mjs(cache / abort / timeout / headers)
1. DSH 的插件模型(先讲清,再讲改造)
DSH 的运行时是一个 cordis 容器。每一个能力都是一个独立的 npm 包,遵循 cordis 的 plugin 约定:
@deepseek-ai/dsh-llm → LLM runtime seam(ctx.llm.stream / listModels / resolveModelInfo)
@deepseek-ai/dsh-llm-deepseek → 原生 DeepSeek 适配器,注册路由 `deepseek-official`
@deepseek-ai/dsh-llm-pi-ai → 通用多 provider 适配器,封装了 @earendil-works/pi-ai
@deepseek-ai/dsh-credentials → 凭据引用解析(ctx.credentials.resolve(ref))
@deepseek-ai/dsh-credentials-local → 从 ~/.dsh/.credentials.yaml 读
@deepseek-ai/dsh-settings → settings seam(settings.mutate / namespace 注册)
@deepseek-ai/dsh-settings-file → 从 ~/.dsh/settings.yaml 读
@deepseek-ai/dsh-attachment → attachment seam(ctx.attachments.saveImage / readImage)
@deepseek-ai/dsh-attachment-local → 文件持久化 attachment store
路由(route)是 DSH 给 LLM provider 取的名字。新增一条 provider 路线 = 在 settings 里加一个 providers.<key> profile,schema 校验通过即注册成功;删除 = 清空 profile 即撤销。两件事由不同插件各司其职:dsh-llm-pi-ai 注册 models.streamSimple 的实现细节,dsh-credentials-local 决定密钥从哪个文件来。运行时(dsh-llm)不直接读 API key,而是每次 stream 时调 ctx.credentials.resolve(apiKeyEnv),按"请求解析"的方式取密钥。这样密钥零持久化漂移到进程环境,也不会泄漏到 LLM request 的任何字段之外。
更重要的是,DSH 的 web bundle 用 typert registry + client bundle 装载插件,客户端 plugins 通过 window.__DSH_BOOT__ 这个内联 JSON 数组声明依赖注入关系(inject: ['@deepseek-ai/cordis-connection', '…']),typert 客户端 runner 按依赖顺序加载。所以 web 端不需要重新构建就能热加载新插件——只要 settings.yaml 改动,hot-reload 就会把新 provider 推到客户端。
2. profile 的最小完备集
dsh-llm-pi-ai 的 PiAiProviderProfile schema(z.object)要求 hand-declared route 必须三件齐全:
| 字段 | 必须 | 含义 |
|---|---|---|
api |
✓ | 协议标识,MiniMax 是 OpenAI 兼容 → openai-completions |
baseURL |
✓ | 端点,https://api.minimaxi.com/v1 |
models |
✓ | 非空模型列表,至少一个 id |
apiKeyEnv |
推荐 | 凭据引用名,配合 dsh-credentials-local 用 MINIMAX_CN_API_KEY 解析 |
compat |
可选 | thinking 格式、是否支持 reasoning_effort 等协议方言开关 |
displayName |
可选 | 选择器里看到的名字 |

最初用户在 ~/.dsh/settings.yaml 里只写了 apiKeyEnv: MINIMAX_CN_API_KEY 这一行。三件必填字段(api / baseURL / models)全部缺,assertServiceable 在 installSettingsSection 注册时就会整个 namespace 直接拒绝——settings.mutate 返回 settings-rejected,settings seam 保留该 namespace 的"最后可用值",所以错误是静默路由缺席,而不是显式崩溃。这是 DSH 一个很值钱的设计:写错的 profile 不会让整个进程挂掉,只会让该 route 不存在;用户重新编辑保存即可恢复。
补全后:
llm-pi-ai:
providers:
minimax-cn:
apiKeyEnv: MINIMAX_CN_API_KEY
displayName: MiniMax (CN)
api: openai-completions
baseURL: https://api.minimaxi.com/v1
models:
- id: MiniMax-M3
name: MiniMax-M3
contextWindow: 1000000 # 1M 上下文,原生多模态
maxTokens: 65536
input: [text, image]
agent-default-model 同时改:
agent-default-model:
provider: minimax-cn
model: MiniMax-M3
reasoningEffort: high
~/.dsh/.credentials.yaml 加 MINIMAX_CN_API_KEY: sk-cp-…。
3. thinking 折叠成 reasoning 块:方言补丁
跑通第一波后我立即发现 M3 的输出里有 <think>… 标签混在正文里。这是因为:
- pi-ai 的
openai-completions默认按 URL 推测thinkingFormat——api.minimaxi.com不在已知列表里,落到"openai"fallback,发的是 OpenAI 的reasoning_effort字段; - MiniMax 不认
reasoning_effort,静默忽略; - 但 M3 默认开启 thinking(省略时为 adaptive),输出 content 里直接内联
<think>…。
直接发 reasoning_effort 没用;测试 MiniMax 各种参数后发现:
thinking.type: "disabled"✅ 关闭思考thinking.type: "adaptive"✅ 默认开启thinking.type: "enabled"❌ 报错 "invalid thinking.type"reasoning_split: true✅ 把思考拆到reasoning_content字段
也就是说 MiniMax 需要的 wire 形状,pi-ai 的现有 thinkingFormat 字典里没有任何一个匹配。最干净的修法是在 pi-ai 的 openai-completions.js 里加一个 minimax 方言:
else if (compat.thinkingFormat === "minimax" && model.reasoning) {
// MiniMax-M3 dialect: reasoning content is split into `reasoning_content`
// when `reasoning_split: true` is sent, so the harness can render it as
// a thinking block instead of inlining it into `content`. The `Off`
// effort closes the model's adaptive thinking entirely.
params.reasoning_split = true;
if (options?.reasoningEffort && model.thinkingLevelMap?.off !== null) {
params.thinking = { type: "adaptive" };
}
else if (model.thinkingLevelMap?.off !== null) {
params.thinking = { type: "disabled" };
}
}
加在 deepseek 分支之后。同时给 dsh-llm-pi-ai 的 SUPPORTED_THINKING_FORMATS 白名单加 'minimax'。settings.yaml 加 compat.thinkingFormat: minimax,模型声明 reasoningEfforts 让 model.reasoning === true(否则分支不触发)。
DSH 这里有个细节值得一记:模型上声明 reasoningEfforts 时 off: 不带值表示"提供 Off 档、选中时什么也不发"——dsh-llm-pi-ai 的 pi-ai thinkingFormat 用这套档位字典做 thinkingLevelMap.off !== null 判断,从而决定发 disabled 还是 adaptive。这跟 deepseek 方言的 off 行为完全错位:deepseek 的 off 是发送 thinking: {type: "disabled"}(MiniMax 支持 ✓),但 deepseek 的 on 档发送 thinking: {type: "enabled"}(MiniMax 报错 ✗)。所以 minimax 方言必须永远不发 enabled,档位映射到 adaptive / disabled 二元——所以"minimal / low / medium / high"四个非 off 档位之间实际行为差异由 MiniMax 服务端决定,profile 只是声明"这些档位可用"。

4. 调用链:从用户输入到 MiniMax API
把整个流程压平画下来:
GUI composer text input
│
▼ typert WebSocket RPC
▼
dclient → dapigateway → dsession → dapi
│
▼ 加载 dsh-llm/dsh-llm-pi-ai provider route
ctx.llm.stream({ provider:'minimax-cn', model:'MiniMax-M3', system, messages, tools, maxTokens, reasoningEffort, sessionId, signal })
│
├─▶ ctx.credentials.resolve('MINIMAX_CN_API_KEY')
│ → LocalCredentialProvider 从 ~/.dsh/.credentials.yaml 取 sk-cp-…
│
├─▶ profileOptions(profile, reasoning, apiKey)
│ → 把 reasoning 转 pi-ai 通用 reasoning 字段
│
├─▶ snapshot.models.streamSimple(model, ctx, options)
│ → pi-ai 内置 openai-completions.streamSimple
│ ├─▶ getCompat(model) → { thinkingFormat:'minimax', … }
│ ├─▶ buildParams(model, ctx, options, compat)
│ │ ├─ prompt_cache_key 不发(URL 不是 openai.com)
│ │ ├─ messages = convertMessages(...)(图片转 image_url base64 data URL)
│ │ └─ thinkingFormat==='minimax' 分支:
│ │ params.reasoning_split = true
│ │ params.thinking = { type: 'adaptive' }
│ │ → params 准备完毕
│ ├─▶ fetch 'https://api.minimaxi.com/v1/chat/completions'
│ │ Headers: Authorization: Bearer sk-cp-…
│ │ user-agent: deepseek-harness/0.1.0-rc.5 (+…)
│ │ content-type: application/json
│ │ ← 没有 x-deepseek-harness-*(pi-ai 路由不自动加)
│ ├─▶ 流式 SSE → 解析 chunks
│ │ choice.delta.reasoning_content → thinking block
│ │ choice.delta.content → text block
│ │ finish_reason:'stop' / 'tool_calls'
│ │ usage.{prompt_tokens, completion_tokens, cached_tokens}
│ └─▶ toStreamChunks(events, contextWindow)
│ → 翻译成 dsh-llm 协议:
│ block-start (blockType: 'reasoning' | 'text' | 'tool-call')
│ reasoning-delta / text-delta / tool-call-delta
│ block-end
│ usage
│ finish {kind: 'stop' | 'tool-calls' | 'max-tokens' | 'aborted' | 'error'}
│
└─▶ idleWatchdog(upstream, streamIdleTimeoutMs)
→ 每 5 分钟(默认)未读 chunk 就 TIMEOUT
关键设计点:
- 请求解析而非加载解析:
ctx.credentials.resolve('MINIMAX_CN_API_KEY')在每次 stream() 调用时执行,密钥不持久化进 env,也不进 LLM request body 或 logs。 - pi-ai 处理流、dsh-llm 不感知协议:
toStreamChunks把 pi-ai 内部事件统一翻译成 harness 的 chunk 协议(text-delta/reasoning-delta/tool-call-delta/usage/finish),下游 agent loop 只看翻译后的协议,pi-ai 升级或换协议时下游无需改动。 - catalog 路由 vs hand-declared 路由:pi-ai catalog 路由(OpenAI、Anthropic、Z.AI 等)直接复用 pi-ai 自带 catalog 的端点/协议/catalog,profile 只覆写鉴权和命名;hand-declared 路由(minimax-cn)profile 必须自填 api/baseURL/models。
- 错误码稳定:provider 返回 401/403 →
LlmError('AUTH'),上下文溢出 →LlmError('CONTEXT_WINDOW_EXCEEDED'),完成流但无 content →LlmError('EMPTY_RESPONSE')。上层 UI 看到的是稳定码,不被 MiniMax 错误文本细节绑死。 - idleWatchdog:5 分钟未读 chunk 触发
LlmError('TIMEOUT');上游 abort 触发LlmError('ABORTED')。实测 250ms 阈值能在 274ms 命中,ABORTED 在 0 text chunks 时立即 finish {kind:'aborted', failure:{code:'ABORTED'}}。
5. 实测数据(6 个脚本的真实跑通结果)
| 验证项 | 关键证据 |
|---|---|
| verify.mjs — 文本 + reasoning | blocks=[reasoning (342 字符), text (65 字符)]; usage cacheRead=198 tokens; responseId 真实有效; replayState.api=openai-completions |
| gui.mjs — 真实 DSH GUI | 模型选择器显示 "MiniMax-M3 High";新会话发 prompt 后渲染完整 thinking 块 + 干净正文;状态栏 1 轮·1 步·LLM 2.5s·145 tok/s·缓存命中 1%·输入 21.9K tok·输出 71 tok;0 INVALID_CREDENTIAL/NO_ADAPTER/HTTP_* 错误 |
| verify-image.mjs — 图片输入 | saveImage → AttachmentRef,readImage round-trip 132 字节一致;MiniMax 看到 64×64 RGB 图像,识别形状("矩形");usage cacheRead 222 tokens;reasoning + text 双 block 分离 |
| verify-tool-call.mjs — 工具调用 | Turn 1 决定调用 get_weather,args={"city":"深圳"},finish reason=tool-calls;Turn 2 喂回 tool result,M3 输出结构化总结(25°C/晴朗/微风)+ "工具验证通过";cache hit 520 tokens |
| verify-runtime.mjs #A — KV cache | 两轮 turn1/2 finish=stop,system prompt 1069 字符完整保留 |
| verify-runtime.mjs #A.2 — REAL MiniMax cache | turn1 cacheRead=128 → turn2 cacheRead=384(cache 复用显著增长,system+历史 prefix 命中) |
| verify-runtime.mjs #B — abort | 50ms 触发 abort → finish {kind:'aborted', failure:{code:'ABORTED'}}; 0 text chunks |
| verify-runtime.mjs #C — timeout | streamIdleTimeoutMs=250 → 274ms 内 TIMEOUT fire, failureCode=TIMEOUT |
| verify-runtime.mjs #D — headers | user-agent: deepseek-harness/0.1.0-rc.5 (+github URL) + Authorization Bearer ✓;无 x-deepseek-harness-user-id / -session-id(pi-ai 路由不自动加,这是与 dsh-llm-deepseek 的设计差异) |
6. 几个有值的细节
toPiAssistant 必须带 source。我在第一版 verify-tool-call.mjs 里手写 assistant message 没带 source: {kind:'model', provider, model},结果 dsh-llm-pi-ai 的 toPiAssistant 访问 source.kind 抛 "Cannot read properties of undefined",被 LlmRuntime 误包成 finish {kind:'error', code:'UNKNOWN'}。正确写法是用 createAssistantMessage({content, source}) helper 或显式声明 source。这是 dsh-llm-pi-ai 的小坑。
thinkingLevelMap.off !== null 的隐式 bug。deepseek 方言里用这个判断"off 档是否存在",但当 off 字段完全缺失时 thinkingLevelMap.off === undefined,undefined !== null 是 true,条件仍然通过。我的 minimax 分支复制了同款判断,也"宽容地"接受 off 字段缺失。如果未来要严格化,建议改成 in model.thinkingLevelMap && model.thinkingLevelMap.off !== null。
pi-ai 的 prompt_cache_key 默认不发。pi-ai 的 openai-completions 只在 baseUrl.includes("api.openai.com") 或 cacheRetention=='long' 时设置 prompt_cache_key。MiniMax 的 api.minimaxi.com 不在白名单,所以 dsh-llm-pi-ai 不会主动发 prompt_cache_key。MiniMax 服务端按 messages 内容 hash 自动 cache,cache_read tokens 在 usage 里仍然报。实测 turn1 cacheRead=128、turn2 cacheRead=384,cache 复用确实生效。

x-deepseek-harness-* 是 deepseek 路由专属。dsh-llm-deepseek 给每次请求加 x-deepseek-harness-user-id 和 x-deepseek-harness-session-id;dsh-llm-pi-ai 只发通用 user-agent。如果上游按这些 header 做请求归因或区分,minimax-cn 路由需要单独加 headers。
patched vendor 的脆弱性。两个 vendor 改动(pi-ai 的 minimax 分支、dsh-llm-pi-ai 的白名单)都会被 pnpm install 覆盖。短期够用;长期方案两条路:① 给 upstream pi-ai 提 PR 加 minimax 方言;② 在 dsh-llm-pi-ai 的 apply() 加 pre-apply 钩子,monkey patch openAICompletionsApi 而不动 vendor。
7. 总结:DSH 把"接入一个新模型"做成了什么
它做成了三件事:
- 配置文件(
settings.yaml一个providers.<key>块), - 密钥引用(
.credentials.yaml一行), - 可选的协议方言补丁(如果新模型的 wire 格式不在 pi-ai 字典里)。
不再需要改 web 端、不再需要改 CLI、不再需要改 UI。web 端通过 window.__DSH_BOOT__ 的插件图自动加载所有 client-side 客户端(typert),settings 的 hot-reload 直接把新 provider 推到前端选择器;CLI / 各 agent loop / 各 subagent 全部通过 ctx.llm.stream 与 LLM seam 解耦;protocol translation 在 dsh-llm-pi-ai 的 toStreamChunks 里统一处理。
这套插件架构让"接入 MiniMax"这件事的用户感知面改动最小化:用户只看到一个新 provider 出现在 Models 页和会话模型选择器里。底层有 4 个 vendor 文件改动、2 个用户配置改动、6 个验证脚本——但这些都不影响 DSH 其他任何代码路径。这是 cordis plugin architecture + settings hot-reload + 协议转译三件套共同给出的工程结果。

以上,既然看到这里了,如果觉得不错,随手点个赞、在看、转发三连吧,如果想第一时间收到推送,也可以给我个星标⭐~ 谢谢你看我的文章,我们,下次再见。