DeepSeek Harness 接 OpenCode Go:400 MissingSessionID 与模型补录
在 llm-pi-ai 里加一条 opencode-go 路由之后,你会同时撞上两个缺陷:每个请求都被 400 {"type":"MissingSessionID"} 拒绝,而 deepseek-v4.1-flash 在所有模型选择器里都找不到。 第一个的根因很干净——OpenCode Go 要求请求带 x-opencode-session 头来做按会话路由与 prompt cache 亲和,而 pi-ai 0.85.1 的 shipped dist 里连这个字符串都不存在:它对 sessionId 的唯一用途是 opt-in 的 x-session-affinity,由 compat.sendSessionAffinityHeaders 门控,opencode-go 的目录项一个都没设(#6224)。第二个是数据落后:models.dev 早已登记该模型,但选择器完全由已安装目录构建,而 0.85.1 的 opencode-go.json 里没有它(#6224)。好消息是两个都能在不 fork 的前提下绕开,而且其中「别拆路由」这一条有实测成本支撑——拆了会污染计费桶、还会让按精确 id 探测额度的插件失明。
先分诊:两个缺陷同一条路由,但修法互不相干
先确认你中的是哪一条(或两条都中),因为它们的修法完全不重叠。
| 判据 | 缺陷一:MissingSessionID | 缺陷二:模型选不到 |
|---|---|---|
| 症状 | 每次请求 400 | 选择器里没有该模型 |
| 报错文本 | Request is missing x-opencode-session… | 无报错,就是看不见 |
| 影响范围 | 该路由上所有模型 | 只有目录外的那个模型 |
| 是否与 key/网络有关 | 无关 | 无关 |
| 关键位置 | dist/api/openai-completions.js 的会话头逻辑 | dist/providers/data/opencode-go.json |
| 一行定性命令 | curl 直连对照(见下) | GET {baseURL}/models 数 id |
第一步,用直连对照把「网关要求」和「适配器没发」两件事分开。这是整条讨论里最有说服力的一组实测——同一个 key、同一个 UA,只改有没有那个头:
# 不带会话头:3/3 全部 400
curl -s -X POST https://opencode.ai/zen/go/v1/chat/completions \
-H "authorization: Bearer $OPENCODE_GO_API_KEY" \
-H "content-type: application/json" \
-d '{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}]}'
# 400 {"type":"MissingSessionID","message":"Error from provider (Console Go): Request is missing x-opencode-session and cannot be routed efficiently. Please see https://opencode.ai/docs/go/#where-can-i-use-it"}
# 带一个稳定的、每会话不透明的值:3/3 返回 200 与真实补全
curl -s -X POST https://opencode.ai/zen/go/v1/chat/completions \
-H "authorization: Bearer $OPENCODE_GO_API_KEY" \
-H "content-type: application/json" \
-H "x-opencode-session: $ANY_STABLE_OPAQUE_VALUE" \
-d '{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}]}'
结论:这个头就是唯一的判别变量,而且不要求任何特定格式——任何稳定的、每会话不透明的值都被接受。这也意味着修法不必纠结「生成算法」,只要保证「同会话同值、异会话异值」。
第二步,确认模型侧的数据差。直接问网关要它自己的模型表,再和已安装目录比:
curl -s -H "authorization: Bearer $OPENCODE_GO_API_KEY" \
https://opencode.ai/zen/go/v1/models | jq '.data | length'
# 37
已安装目录里只有 27 个,deepseek-v4.1-flash 不在其中——这就是「选不到」的全部原因。
缺陷一:会话头从来没被发出过(不是没过适配器,是适配器没写)
这一节的关键结论:会话身份已经被送进 pi-ai 了,是 pi-ai 那侧没有任何代码去使用它——因为 opencode-go 的目录项没有开那个开关。
1. 上游链路
- 宿主把会话身份转发进 pi-ai:
packages/llm/llm-pi-ai/src/adapter.ts:380-388; - 依赖声明:
packages/llm/llm-pi-ai/package.json:44写的是^0.85.1,实际装的也是 0.85.1; - 但 pi-ai 0.85.1 的 shipped dist 里
x-opencode-session出现次数为 0; - pi-ai 对
sessionId的唯一用途是 opt-in 的x-session-affinity,由compat.sendSessionAffinityHeaders门控(dist/api/openai-completions.js:557、dist/api/anthropic-messages.js:725); dist/providers/data/opencode-go.json里没有任何条目声明这个 compat 标志。
顺带一提,宿主自己的 compat 门控甚至会对 profile 条目扣留这个标志(packages/llm/llm-pi-ai/src/catalog.ts:255)。所以 opencode-go 路由上 400 是必然的,不是偶发。
2. 一个佐证:这是「一个适配器家族的距离」
官方 dsh-llm-deepseek 适配器是会盖会话身份的——x-deepseek-harness-session-id,产物在 lib/index.js:1666;而 pi-ai 适配器什么都没盖。所以缺的不是能力,是这一条路径上的实现。
3. 版本现状:next 也没有修
把已发布的 @deepseek-ai/dsh-llm-pi-ai@0.1.5-rc.2 tarball 解出来,与已安装的适配器逐字节 diff:完全一致。同时 @earendil-works/pi-ai 0.85.1(npm latest)里根本没有 x-opencode-session 这个字符串。升级解决不了这两个缺陷。
4. 修法一(不改代码,最省事):profile 写死静态头
dsh-llm-pi-ai 会把路由 profile 的 headers 原样透传进 pi-ai 的 stream options(编译产物 lib/index.js:1873,源码 config.ts:150-151),pi-ai 再把它并进客户端默认头(合并点在 adapter.ts:386-388)。所以:
llm-pi-ai:
providers:
opencode-go:
apiKeyEnv: OPENCODE_GO_API_KEY
headers:
x-opencode-session: <一个稳定的 uuid,每台机器一个>
x-deepseek-harness-session-id: <同一个 uuid> # DSH 自己的原生头
实测三条读数:没有该头时 400(3/3);带一个稳定的每会话值时 200(3/3);通过 harness 本身跑(dsh --profile headless,把该路由设为 agent 默认模型),打补丁前是 dsh: INVALID_REQUEST: 400: {"type":"MissingSessionID",…},打补丁后是 ok。
代价必须说清楚:所有会话共用同一个亲和 id,按会话的 prompt-cache 路由就没了。 如果你的使用场景是单会话长跑,这点损失可以接受;如果是多会话并行,建议走修法二。
5. 修法二(正确形状):在适配器边界派生这个头
会话身份其实已经到达 pi-ai 的 stream options 了,三个调用点都盖了(在已安装包上核实):
| 调用点 | 位置 |
|---|---|
| 主循环 | dsh-agent-loop lib/index.js:1215 |
| compaction | dsh-compaction-basic :298 |
| 会话标题 | dsh-session-title-llm :215 |
| 会话标题(首条提示) | dsh-session-title-first-prompt-llm —— 唯一漏掉的 |
所以修法可以完全落在适配器边界:包一层交给 streamSimple 的 headers,当路由指向 OpenCode 时补上 x-opencode-session,并让显式配置的头大小写不敏感地优先。
参考实现(两个提交、含测试)在报告人的 fork 分支上,形态是上游该采纳的那种:
https://github.com/mohamed-bashir-dev/deepseek-harness/compare/master...mohamed-bashir-dev:deepseek-harness:fix/llm-pi-ai-opencode-go
适配器规格测试覆盖四种情形:在 opencode-go 上发送派生头、在其他 provider 上不发送、部署配置的覆盖大小写不敏感、请求不含会话 id 时不发送。
6. 修法三(用户级本地补丁):直接改产物
把守卫打在打包后的 …/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js 上(即 adapter 边界那一层)。但要注意:npm i -g @deepseek-ai/dsh 会重建这棵目录树,所以必须配一个幂等的重打脚本。两条路的诚实成本:不支持、每次升级都要重打、而且修不了缺陷二。
7. 一个必须记住的坑:将来要删掉静态头
pi-ai 的 main 上已经有 src/providers/opencode-headers.ts 的 withOpenCodeSessionHeader,而且它用了 hasHeader() 短路。也就是说:
一旦某个 pi-ai 版本内置了派生逻辑:
你在 profile 里写死的静态 x-opencode-session 会「先被看到」→ 派生逻辑短路不执行
→ 真正的每会话 id 被硬编码值盖掉
→ 你会丢掉当初加这个头想拿到的路由/缓存收益
所以升级 pi-ai 之后,第一件事是把 profile 里的静态 x-opencode-session 删掉。
缺陷二:模型不在目录里,以及为什么「别拆路由」
这一节的关键结论:模型选不到纯粹是数据落后;补数据的正确位置是目录,而不是新开一条路由。
1. 为什么选择器里没有
选择器完全由已安装目录构建(catalog.ts:1-16 导入的就是那份 JSON),而 dist/providers/data/opencode-go.json 里没有 deepseek-v4.1-flash,所以它不可能出现在任何地方。
2. 修法一:profile 的 models 自己描述
profile 的 models 条目可以描述一个已安装目录不认识的模型(catalog.ts:1-6,解析在 catalog.ts:893-930)。照着 deepseek-v4-flash-vision-exp 那条 opencode-go 目录项的字段抄一份即可,改掉 id / name / cost。这是最推荐的做法:不动宿主文件、升级不丢。
3. 修法二:给已安装目录加一条
路径与内容:
node_modules/@earendil-works/pi-ai/dist/providers/data/opencode-go.json
在 openai-completions 下加一份 deepseek-v4-flash 的克隆,字段调成:
{
"id": "deepseek-v4.1-flash",
"name": "DeepSeek V4.1 Flash",
"api": "openai-completions",
"baseUrl": "https://opencode.ai/zen/go/v1",
"reasoning": true,
"thinkingLevels": ["low", "high", "max"],
"thinkingFormat": "deepseek",
"cost": { /* 按需调整 */ },
"limit": { "context": 1000000, "output": 384000 }
}
两个坑:文件必须是 UTF-8 不带 BOM(带 BOM 会让 JSON 模块解析失败);补丁会被全局重装重建,需要幂等重打脚本。
4. 为什么不要拆路由(实测代价)
「再开一条 opencode-go-v41 路由、把路由级 api 写死」确实能让模型可选,但形状是错的:provider 路由 id 是计费与设置的键。实测观察到的两个副作用:
| 副作用 | 表现 |
|---|---|
| 计费桶被拆开 | usage ledger 把同一个模型记成两个 provider 桶:opencode-go-v41/deepseek-v4.1-flash 单独 21 次调用、177k 输入 token,与 opencode-go/* 分离 |
| 额度插件失明 | 按精确 id 探测套餐的 @linxin666/dsh-usage(ids: ['opencode-go'])在新路由上不显示额度行,尽管额度本身是账号级的 |
把条目加进已安装目录,才能保持「一条路由、一个桶」。
5. 好消息:上游会自动补上
pi-ai 在发布时从 models.dev 重新生成这份目录,而 models.dev 的 opencode-go provider 已经列出 deepseek-v4.1-flash:
tool_call: true
reasoning: true
context: 1000000
output: 384000
provider.npm: 未设置 ⇒ 走 openai-completions
last_updated: 2026-09-10
所以下一个 @earendil-works/pi-ai 版本预计会自动带上它,本地目录补丁只是过渡。(pi 侧的报告是 earendil-works/pi#9737,按该仓库的新贡献者政策被自动关闭,但仍会进入每日维护者复核。)
6. 合入后的实测
两条都打完并重启 dsh 后:
- 单条
opencode-go路由在原有 27 个模型之外,正常提供deepseek-v4.1-flash; - 第一段用量:12 次调用、252k 输入 / 17k 输出 / 2.7M cache read —— prompt caching 健康;
- OpenCode Go 套餐探针仍在该路由上正确报告 rolling / weekly / monthly 三个窗口。
更深一层:discovery 短路与 per-model api 的结构性阻塞
这一节解释「为什么总是要打补丁」,以及要真正闭合「发现即可用」还缺什么。
1. 发现逻辑从不问网关
packages/llm/llm-pi-ai/src/discovery.ts:273-285:当路由已装目录时,discoverModels() 直接返回 catalogModels(provider),根本不访问网络。所以对 opencode-go 来说,GET {baseURL}/models(当时 37 个 id)永远不被咨询,目录里只有 27 个——每一个未来的 OpenCode 新模型都要再打一次目录补丁 + 再配一个守卫测试。
2. 一个只在 OpenCode 路由上跳过短路的补丁形状
本地补丁的做法(形状值得上游参考):
- 只对 OpenCode 路由跳过短路,查询
{baseURL}/models; - 列表按
openai-completions发送;baseUrl 要取「目录里api为openai-completions的那一条」,不能取第一条——anthropic 协议条目的 base 没有/v1,会 404; - 目录认识的 id 保留目录里的容量数据;
- 端点不可达时回退到目录列表;
- 其他 provider 一律不动。
合并后的实测结果:37 个 id、deepseek-v4.1-flash 存在、deepseek-v4-flash 仍保持 1000000 / 384000。
3. 但「发现得到」还推不出「能用」
即使在 discovery 侧做了实时查询,仍有结构性阻塞:resolveRouteModels() 里每个条目的协议按
request.api ?? base?.api ?? routeApi
解析(catalog.ts:888),路由级 api 会盖住模型自己的协议。于是在多协议的 opencode-go 路由上声明一个目录外的模型,会把它 anthropic-messages 与 openai-responses 的模型一起改协议;而不写路由级 api 的目录外 id 会直接以「needs an api」失败(catalog.ts:889-892)。
真正能闭合「发现即可用」的缝,是 profile schema 支持 per-model api,或者允许一条目录路由额外携带条目。 在那之前,用户要么单开一条路由(代价见上文),要么继续打目录补丁。
排查注意事项
- 先用
curl直连做单变量对照,再怀疑 DSH。实测「头」是唯一判别变量,3/3 vs 3/3 干净利落。 - 别把 400 当成 key 或网络问题。文本明确是
MissingSessionID,且与 UA、与模型无关(该路由上所有模型都中)。 - 注意「会话身份已送达但没人用」这个中间态。宿主确实转发了(
adapter.ts:380-388),是 pi-ai 侧没有消费方——别在宿主里瞎找。 - 静态头是止血不是修复。它会牺牲按会话的 prompt cache 亲和;且未来 pi-ai 内置派生逻辑后必须删掉,否则短路会让你亏掉收益。
- 改产物要配幂等重打脚本:
npm i -g @deepseek-ai/dsh会重建node_modules树。 - 别为拿一个模型去拆路由。实测会拆散计费桶,还会让按精确 id 探测额度的插件看不到额度。
- 改 JSON 目录时留意 BOM。UTF-8 带 BOM 会让 JSON 模块解析失败。
- 优先用 profile 的
models,而不是改宿主文件:前者升级不丢,后者每次升级都要重打。 - 升级后回看 profile:pi-ai 一旦内置会话头派生,记得删除静态
x-opencode-session。 - 参考实现的形态是对的:在适配器边界从
GenerateOptions.sessionId派生、显式配置优先且大小写不敏感、无会话 id 时不发送——这三点值得作为验收标准。
来源
- #6224 — [Bug] llm-pi-ai/opencode-go: missing x-opencode-session header and deepseek-v4.1-flash catalog entry
- #5654 — 模型发现侧的相关讨论
- 参考实现分支:
fix/llm-pi-ai-opencode-go(两个提交,含适配器规格测试与目录守卫测试) - pi 侧报告:
earendil-works/pi#9737
文中涉及的行号(adapter.ts:380-388 / :386-388、catalog.ts:1-16 / :255 / :888-892 / :893-930、config.ts:150-151、discovery.ts:273-285)、直连对照的三次 400/200、tarball 逐字节 diff、以及拆路由后的计费与额度插件表现,均来自该讨论贴中多位报告人的独立实测;「下一个 pi-ai 版本会自动带上该模型」为基于 models.dev 当前数据的预期,非既成事实。
接入第三方模型服务时,真正耗时间的往往不是「换一个 API 地址」,而是确认会话身份、缓存亲和、计费归属这些隐式契约有没有被逐层传下去。DSH Plugin Hub 提供插件市场、已安装插件列表、自定义安装、设置与系统日志五个界面:已安装列表标注每个插件的来源、版本与更新时间并可直接定位安装目录;系统日志页按分类与级别保留安装、卸载与诊断轨迹,支持导出全文。排查路由与适配器类问题时,先用它把环境与版本对齐,会比一上来就改配置省事得多。

常见问题
因为请求里从来没有 x-opencode-session。OpenCode Go 按这个头做「按会话路由 + prompt cache 亲和」,缺了就直接拒;而 pi-ai 0.85.1 的 shipped dist 里**连这个字符串都没有**——它对 sessionId 的唯一用途是 opt-in 的 x-session-affinity,由 compat.sendSessionAffinityHeaders 门控,而 opencode-go.json 的目录项一个这样的 compat 标志都没设。所以这条路由上 400 是必然的,不是你的 key 或网络问题。
可以,有三条路。① 最省事:在该路由的 profile 里写死一个静态头 headers: { x-opencode-session: <一个固定值> },请求立刻能通。代价是**所有会话共用一个亲和 id**,按会话的 prompt-cache 路由就没了。② 更正确:在适配器边界包一层,从 GenerateOptions.sessionId 派生这个头(主循环、compaction、会话标题三处都已经带上会话身份),并让显式配置的头大小写不敏感地优先。③ 或者等 pi-ai 新版本——但注意它一旦内置,你必须**删掉**写死的静态头,否则硬编码值会盖掉真正的每会话 id。
因为选择器完全由「已安装的目录」构建:catalog.ts 导入的就是 dist/providers/data/opencode-go.json,而这个文件里根本没有这个模型。models.dev 早就登记了它,但 pi-ai 的目录是发布时生成的,0.85.1 那一版比 models.dev 落后。三种补法:在 profile 的 models 里自己描述一个「目录不认识的模型」(catalog.ts:1-6,解析在 catalog.ts:893-930);或直接给已安装的 opencode-go.json 加一条目;别用「再开一条路由」的办法,理由见下文。
能选到,但形状是错的。provider 路由 id 是**计费与设置的键**:实测一旦拆路由,usage ledger 会把同一个模型记成两个 provider 桶(opencode-go-v41/deepseek-v4.1-flash 单独 21 次调用、177k 输入 token,与 opencode-go/* 分开),而按精确 id 探测套餐额度的插件(@linxin666/dsh-usage,ids: ['opencode-go'])在新路由上**显示不出额度行**——尽管额度其实是账号级的。把条目加进已安装目录,才能保持一条路由、一个桶。
两个。① 文件必须是 **UTF-8 不带 BOM**——带 BOM 会让 JSON 模块解析失败;② 补丁会被 npm i -g @deepseek-ai/dsh 重建掉(升级会换一份运行时目录),所以要么配一个幂等的重打脚本,要么尽量用 profile 级 models 覆盖。好消息是 pi-ai 发布时会从 models.dev 重新生成目录,而 models.dev 的 opencode-go **已经列出** deepseek-v4.1-flash(tool_call: true、reasoning: true、context 1000000、output 384000、provider.npm 未设置 ⇒ openai-completions),所以下一个 pi-ai 版本预计会自动带上它,本地补丁只是过渡。
相关术语
- x-opencode-session
- OpenCode Go 用于「按会话路由请求 + 做 prompt cache 亲和」的请求头。缺失时网关直接返回 `400 MissingSessionID`。它不要求特定格式——实测任何**稳定的、每会话不透明的值**都被接受,所以关键不是生成算法,而是「同一会话发同一个值、不同会话发不同的值」。— https://github.com/deepseek-ai/deepseek-harness/discussions/6224
- sendSessionAffinityHeaders(compat 开关)
- pi-ai 里控制「要不要把 `sessionId` 映射成会话亲和头」的 opt-in 标志。pi-ai 0.85.1 只认这一个会话相关开关,而 `opencode-go.json` 的目录项没有声明它,因此在 opencode-go 路由上 `sessionId` 被彻底忽略——这是「适配器明明拿到了会话身份却不发送」的直接原因。— https://github.com/deepseek-ai/deepseek-harness/discussions/6224
- discovery 短路(catalogModels 直返)
- `discovery.ts` 在路由装了目录时直接 `return catalogModels(provider)`、根本不访问网络的行为(`discovery.ts:273-285`)。后果是 `GET {baseURL}/models`(当时 37 个模型)永远不被咨询,而目录里只有 27 个——每一个新模型都需要再打一次目录补丁。— https://github.com/deepseek-ai/deepseek-harness/discussions/6224
- 路由级 api 覆盖模型级 api
- `resolveRouteModels()` 里每个条目最终协议按 `request.api ?? base?.api ?? routeApi` 解析(`catalog.ts:888`),也就是说**路由级 api 会盖住模型自己的协议**。在多协议的 `opencode-go` 路由上声明一个目录外的模型,会把该路由上 anthropic-messages 与 openai-responses 的模型一起改协议;没有路由级 api 的目录外 id 则直接以「needs an api」失败(`:889-892`)。— https://github.com/deepseek-ai/deepseek-harness/discussions/6224
来源
- #6224 — [Bug] llm-pi-ai/opencode-go: missing x-opencode-session header and deepseek-v4.1-flash catalog entry· deepseek-ai(GitHub Discussions)
- #5654 — 模型发现(discovery)侧的相关讨论· deepseek-ai(GitHub Discussions)