DeepSeek Harness 接 OpenCode Go:400 MissingSessionID 与模型补录

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSHOpenCode Gollm-pi-aix-opencode-session模型目录MissingSessionIDprompt caching
llm-pi-ai 加一条 opencode-go 路由后请求全被 400 MissingSessionID 拒绝,deepseek-v4.1-flash 也不在选择器里:前者因 pi-ai 0.85.1 不发 x-opencode-session,后者因目录落后 models.dev。本文给三条绕行与两条补丁。

在 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,只改有没有那个头:

bash
# 不带会话头: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"}
bash
# 带一个稳定的、每会话不透明的值: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"}]}'

结论:这个头就是唯一的判别变量,而且不要求任何特定格式——任何稳定的、每会话不透明的值都被接受。这也意味着修法不必纠结「生成算法」,只要保证「同会话同值、异会话异值」。

第二步,确认模型侧的数据差。直接问网关要它自己的模型表,再和已安装目录比:

bash
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)。所以:

yaml
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
compactiondsh-compaction-basic :298
会话标题dsh-session-title-llm :215
会话标题(首条提示)dsh-session-title-first-prompt-llm —— 唯一漏掉的

所以修法可以完全落在适配器边界:包一层交给 streamSimple 的 headers,当路由指向 OpenCode 时补上 x-opencode-session,并让显式配置的头大小写不敏感地优先。

参考实现(两个提交、含测试)在报告人的 fork 分支上,形态是上游该采纳的那种:

text
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() 短路。也就是说:

text
一旦某个 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. 修法二:给已安装目录加一条

路径与内容:

text
node_modules/@earendil-works/pi-ai/dist/providers/data/opencode-go.json

在 openai-completions 下加一份 deepseek-v4-flash 的克隆,字段调成:

jsonc
{
  "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:

text
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 路由上跳过短路的补丁形状

本地补丁的做法(形状值得上游参考):

  1. 只对 OpenCode 路由跳过短路,查询 {baseURL}/models;
  2. 列表按 openai-completions 发送;baseUrl 要取「目录里 api 为 openai-completions 的那一条」,不能取第一条——anthropic 协议条目的 base 没有 /v1,会 404;
  3. 目录认识的 id 保留目录里的容量数据;
  4. 端点不可达时回退到目录列表;
  5. 其他 provider 一律不动。

合并后的实测结果:37 个 id、deepseek-v4.1-flash 存在、deepseek-v4-flash 仍保持 1000000 / 384000。

3. 但「发现得到」还推不出「能用」

即使在 discovery 侧做了实时查询,仍有结构性阻塞:resolveRouteModels() 里每个条目的协议按

ts
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,或者允许一条目录路由额外携带条目。 在那之前,用户要么单开一条路由(代价见上文),要么继续打目录补丁。

排查注意事项

  1. 先用 curl 直连做单变量对照,再怀疑 DSH。实测「头」是唯一判别变量,3/3 vs 3/3 干净利落。
  2. 别把 400 当成 key 或网络问题。文本明确是 MissingSessionID,且与 UA、与模型无关(该路由上所有模型都中)。
  3. 注意「会话身份已送达但没人用」这个中间态。宿主确实转发了(adapter.ts:380-388),是 pi-ai 侧没有消费方——别在宿主里瞎找。
  4. 静态头是止血不是修复。它会牺牲按会话的 prompt cache 亲和;且未来 pi-ai 内置派生逻辑后必须删掉,否则短路会让你亏掉收益。
  5. 改产物要配幂等重打脚本:npm i -g @deepseek-ai/dsh 会重建 node_modules 树。
  6. 别为拿一个模型去拆路由。实测会拆散计费桶,还会让按精确 id 探测额度的插件看不到额度。
  7. 改 JSON 目录时留意 BOM。UTF-8 带 BOM 会让 JSON 模块解析失败。
  8. 优先用 profile 的 models,而不是改宿主文件:前者升级不丢,后者每次升级都要重打。
  9. 升级后回看 profile:pi-ai 一旦内置会话头派生,记得删除静态 x-opencode-session。
  10. 参考实现的形态是对的:在适配器边界从 GenerateOptions.sessionId 派生、显式配置优先且大小写不敏感、无会话 id 时不发送——这三点值得作为验收标准。

来源

文中涉及的行号(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 提供插件市场、已安装插件列表、自定义安装、设置与系统日志五个界面:已安装列表标注每个插件的来源、版本与更新时间并可直接定位安装目录;系统日志页按分类与级别保留安装、卸载与诊断轨迹,支持导出全文。排查路由与适配器类问题时,先用它把环境与版本对齐,会比一上来就改配置省事得多。

DSH Plugin Hub 插件市场:支持搜索、按名称/最近/Star/Fork 排序,以及全部/已安装/未安装筛选

常见问题

为什么 opencode-go 路由上的每个请求都是 400 `MissingSessionID`?

因为请求里从来没有 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 或网络问题。

不想改代码,能不能让 400 先消失?

可以,有三条路。① 最省事:在该路由的 profile 里写死一个静态头 headers: { x-opencode-session: <一个固定值> },请求立刻能通。代价是**所有会话共用一个亲和 id**,按会话的 prompt-cache 路由就没了。② 更正确:在适配器边界包一层,从 GenerateOptions.sessionId 派生这个头(主循环、compaction、会话标题三处都已经带上会话身份),并让显式配置的头大小写不敏感地优先。③ 或者等 pi-ai 新版本——但注意它一旦内置,你必须**删掉**写死的静态头,否则硬编码值会盖掉真正的每会话 id。

`deepseek-v4.1-flash` 为什么在模型选择器里完全看不到?

因为选择器完全由「已安装的目录」构建: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 加一条目;别用「再开一条路由」的办法,理由见下文。

开第二条路由(比如 opencode-go-v41)不行吗?能选到模型啊。

能选到,但形状是错的。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

来源