dsh-plugin-opencode-session
给 DeepSeek Harness (DSH) 用的常驻插件(Profile Bundle):为所有发往 opencode.ai 的模型请求注入按会话稳定的 x-opencode-session 请求头,修复 OpenCode Go 报的
400 {"type":"MissingSessionID","message":"Error from provider (Console Go): Request is missing x-opencode-session and cannot be routed efficiently."}
仓库:https://github.com/vancezhong/dsh-plugin-opencode-session
为什么需要它
- OpenCode Go 要求每个请求带
x-opencode-session(见 opencode 文档 · Where can I use it?),否则 400。文档的 "Known Problematic Clients" 里就点了 DSH:会话头只在部分模型路径上发出。
- DSH 的 pi-ai 适配器(
@deepseek-ai/dsh-llm-pi-ai)只有在 pi-ai 的 compat 开关 sendSessionAffinityHeaders 打开时才发会话亲和头;该开关在 openai-completions 协议下默认关闭,而且 DSH 的 llm-pi-ai 配置 schema(compatProfile)里根本没有这个字段,因此从配置面上无法打开。
- 于是
api: openai-completions 的 OpenCode Go 路由一个会话头都不发 → 400。
它怎么工作
插件只包一层——pi-ai 适配器决定请求 HTTP 头的那一处边界 streamWithSnapshot(options, snapshot):
- 命中
opencode.ai(按 baseURL 判断;没有 baseURL 时按路由名/显示名兜底)时,把该次调用的 snapshot 复制一份,profile 的 headers 里加上 x-opencode-session = <DSH 会话 id>(就是 pi-ai 用来做 prompt cache 亲和的那个 id),再交给原实现;
- 不修改适配器实例、不修改
options(agent loop 构造的 options 是深冻结的)、不修改共享的 snapshot;
- 保留路由上已有的其它自定义 header,并去掉同名大小写变体,避免重复发送;
- 覆盖所有流式调用(主对话、标题生成、压缩等),不止 agent loop;
- 任何内部异常都退化为「原样放行」,绝不会因为插件把某次请求弄挂;
- 停止/卸载时删除包装,恢复原始方法。
另外注册一个只读工具 opencode_session_status:报告当前包装了几个 LLM 适配器、最近几次注入的 provider/model/sessionId/baseURL、以及最近见到的 provider 报错。该工具是可选增强,@deepseek-ai/dsh-tools 不存在时插件照常工作。
安装
前提:pnpm 在 PATH 上(dsh plugin 是 pnpm 转发器)。没有的话先 corepack enable pnpm 或 npm i -g pnpm。
# 从 GitHub(本仓库)
dsh plugin --profile web add github:vancezhong/dsh-plugin-opencode-session
# 等价的显式形式(不需要 SSH key,推荐给别人时用这条)
dsh plugin --profile web add https://github.com/vancezhong/dsh-plugin-opencode-session
# npm 上(发布后)
dsh plugin --profile web add dsh-plugin-opencode-session
# 或从本地 tarball
dsh plugin --profile web add file:/绝对路径/dsh-plugin-opencode-session-1.0.0.tgz
然后重启该 profile(关掉再启动 DSH),插件即随进程常驻。
add 会自动把这个包追加到 profile 的 dsh.profile.bundles——因为本包在 package.json 里声明了
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
DSH 启动时会应用这个 bundle 自带的 patch 层,把下面这行挂进组合树,所以不需要手工改 profile 配置:
- insert:
- id: opencode-session
name: 'dsh-plugin-opencode-session'
如果你把这个包改名/加 scope(例如 @you/dsh-plugin-opencode-session),记得同步改 cordis.patch.yml 里的 name,两者必须一致。
手工安装(不用 pnpm)也可以:把本包放进 <DSH_HOME>/profiles/<profile>/node_modules/,在 profile 的 package.json 里同时加进 dependencies 和 dsh.profile.bundles,再重启。
验证
# 1. 组合树里出现这一行,且 bundle 能解析(解析不到会直接抛错)
dsh --profile web --dump-config | grep -A2 opencode-session
# 2. 重启后,在任意会话里让 agent 调用状态工具
opencode_session_status
期望看到 wrappedAdapters ≥ 1、recentInjections 里有一条 baseURL: https://opencode.ai/zen/go/v1、recentRequestErrors 为空。
卸载 / 回滚
dsh plugin --profile web remove dsh-plugin-opencode-session
然后重启。remove 会同时把它从 dsh.profile.bundles 里摘掉,组合树恢复原样。
分享给别人
三种方式,任选其一:
-
发到 npm(最省事)
cd dsh-plugin-opencode-session
npm publish --access public
别人一条命令:
dsh plugin --profile web add dsh-plugin-opencode-session
(若名字被占用,改成你的 scope 名,并同步改 cordis.patch.yml 的 name。)
-
发到 GitHub:本仓库就在 https://github.com/vancezhong/dsh-plugin-opencode-session,别人直接
dsh plugin --profile web add https://github.com/vancezhong/dsh-plugin-opencode-session
纯 JS,无需构建,也不需要 SSH key。想自己 fork 一份就换成自己的地址。
-
离线/内网:npm pack 生成 tgz 发给对方,对方用 file: 安装(见上)。
分享时最需要交代的两句:装完要重启 DSH;对方的 OpenCode Go 路由 baseURL 必须指向 https://opencode.ai/...(否则按路由名兜底匹配 opencode)。
和 dsh-opencode-session-header 的关系
npm 上已有一个解决同一问题的包 dsh-opencode-session-header(作者 beihzb)。它走的是另一条路:用 llm/stream 瀑布 + AsyncLocalStorage 把会话 id 带到 globalThis.fetch,再在 fetch 中间件里打头。
| dsh-opencode-session-header | 本包 |
|---|
| 注入位置 | 全局 fetch(进程级补丁) | pi-ai 适配器的 profile 头(不碰全局) |
| 依赖 DSH 内部结构 | 否(只依赖 llm/stream + fetch) | 是(streamWithSnapshot 形状,已随 0.1.5-rc.2 验证) |
| 其它 provider | 不受影响(按 host 白名单) | 不受影响(按 baseURL/路由名) |
| 运行时开关 | 有(JSON 文件热读) | 无 |
| 状态查询工具 | 无 | 有(opencode_session_status) |
两个包不要同时装进同一个 profile——功能重复(虽然都注入同一个会话 id,不会互相破坏,但没必要)。二选一即可。
开发
node test/smoke.mjs # 用假 ctx 驱动插件模块:注入 / 不污染共享快照 / 其它 provider 不动 / 卸载还原
# 更接近真实:从已安装的 profile 解析模块,用真正的 @deepseek-ai/cordis 挂载一遍
node test/mount.mjs
DSH_PROFILE_DIR="$HOME/.dsh/profiles/web" node test/mount.mjs
改了代码后要重新打包并重装。注意:file: 依赖的 spec 字符串(tarball 路径)没变时,pnpm 会认为锁文件已同步而跳过重装,所以要「先 remove 再 add」(或者每次 bump 版本号让 tarball 文件名变化):
npm pack # 产出 dsh-plugin-opencode-session-<version>.tgz
cd "$DSH_HOME/profiles/web"
corepack pnpm remove dsh-plugin-opencode-session
corepack pnpm add file:/绝对路径/dsh-plugin-opencode-session-<version>.tgz
(dsh plugin --profile web add <spec> 本身就是「在 profile 目录跑 pnpm add + 同步 dsh.profile.bundles」,两种写法等价;前提是 pnpm 在 PATH 上。)
本机当前的安装是 file: 指向 ~/Desktop/dsh-plugin-opencode-session/dsh-plugin-opencode-session-1.0.0.tgz 的依赖。已安装的 node_modules 副本是自包含的(pnpm 内容寻址存储),删掉 tarball 不影响已装好的运行;但如果之后在那个 profile 里再跑 pnpm install,需要 tarball 还在原处——或者改用 npm / GitHub 源重新安装。
已知限制
- 只影响发往
opencode.ai 的路由;其它 provider 完全不受影响。
- 依赖 pi-ai 适配器内部的
streamWithSnapshot 形状。上游若改动这个形状,插件会退化为「不注入」(状态工具里 wrappedAdapters 会显示 0),不会破坏请求。
- 它修的是「会话头缺失」这一个问题;如果网关返回别的错误(额度、鉴权等),状态工具里的
recentRequestErrors 会给出原始报错。
MIT License.