DSH 接入 OpenCode Go 报 400 MissingSessionID:一次从 LLM 语义层退到 fetch 传输层的排障记录
从「请求报错」到「根因」再到「插件方案」,完整记录一次模型接入的排障过程。
结论先行:OpenCode Go 要求每个请求带一个按会话稳定的
x-opencode-session头,DSH 的 LLM 层没有任何地方能加这个头,但传输层 可以------于是有了一个
llm/stream观察者 +AsyncLocalStorage+fetch中间件的零依赖插件。
报错原文如下:

一、事情的起因
我把 OpenCode Go 订阅的模型接进了 DeepSeek Harness(DSH)------在它的模型设置里配好 provider、填好 key、选好模型,然后发一条消息:
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"}
key 是刚充值有效的,一个 400 扔过来,先怀疑配置。于是开始排查------这一查,查出了整整一条「为什么 DSH 发不出这个头」的链路。
二、排查三步
第一步:直接把 key 打到端点,验证「配置对不对」
跳过 DSH,用 Node 的 fetch 直接打 OpenCode Go 的 OpenAI 兼容端点:
GET https://opencode.ai/zen/go/v1/models→ 200,key 有效,模型列表齐全;POST .../chat/completions→ 400 MissingSessionID。
结论:配置没错,是请求本身缺东西。报错信息已经把答案说了一半------缺 x-opencode-session 头。顺带一提,排查中 PowerShell 的 Invoke-WebRequest 和 curl.exe 都栽在 schannel/TLS 上(沙箱环境没有可用凭证),Node 的 fetch(自带 OpenSSL)完全不受影响------这也是后来所有探针脚本都用 Node 的原因。
第二步:逐条协议验证「谁缺头、谁不缺」
OpenCode Go 的资料显示它同时暴露三种协议(同一批模型,不同端点):
| 协议 | 端点(模型示例) | 无会话头 | 带会话头 |
|---|---|---|---|
openai-completions |
/zen/go/v1/chat/completions(deepseek-v4-flash、glm-5.3...) |
400 | 200 |
anthropic-messages |
/zen/go/v1/messages(qwen3.8-flash、minimax-m2.7...) |
400 | 200 |
openai-responses |
/zen/go/v1/responses(grok-4.6、gpt-5.6-luna...) |
400 | 200 |
三种协议、所有模型,没有会话头一律 400;带上立刻 200。 这不是某个模型的个别问题,是全路由的硬性要求。
第三步:值的格式敏感吗?
顺手测了会话头的值域:dsh-default、任意 UUID、session-xxx 全都 200,只有空值才 400。这为后面「兜底值」的设计提供了依据。
另外发现:OpenCode Go 还认识
x-deepseek-harness-session-id、session_id、x-session-id这三个别名,但拒绝x-session-affinity和x-client-request-id------这个细节决定了后面「靠 pi-ai 自带开关」这条路也走不通。
三、根因
- OpenCode Go(2026-09-05 起):不带稳定会话 id 的请求直接拒绝,会话 id 用于路由亲和与提示词缓存;
- DSH 的
llm-pi-ai适配器 :请求头只来自路由配置(headers: requestHeaders(profile.headers)),从不发送任何会话头。DSH 官方公告里也把自己列进了 OpenCode 的 "Known Problematic Clients":"We recognize its native header; the remaining work is to send it across all adapters"。
也就是说:这头必须在请求发出前补上,但 DSH 的 LLM 层没有补它的地方。
四、为什么「常规解法」都不行
解法 A:settings.yaml 里写死一个静态头
yaml
llm-pi-ai:
providers:
opencode-go:
apiKeyEnv: OPENCODE_GO_API_KEY
headers:
x-opencode-session: 一个固定的UUID
能跑,但所有会话共用一个 id,OpenCode 的按会话路由 / 提示词缓存优势全丢------而且「写死」本就不是好味道。
解法 B:改 DSH 源码
给 llm-pi-ai 的适配器加一行「把 options.sessionId 写成请求头」。最干净,但要求维护 fork、每次升级跟随 rebase。用户明确不想动源码。
解法 C:写插件?------先看 DSH 到底给了哪些缝
我逐一去查(这是这篇博文最值钱的部分),结论如下表:
| 想走的路 | 为什么不通 |
|---|---|
llm/stream waterfall 里改 options |
GenerateOptions 没有 headers 字段;且 loop 构造的请求 deep-frozen,改了直接抛异常 |
| 让 pi-ai 适配器采用调用方传的头 | 适配器写死 headers = requestHeaders(profile.headers),只认路由配置 |
用 registerAdapter 抢注 opencode-go 路由 |
对已占用路由抛 DUPLICATE_ADAPTER,all-or-nothing |
靠 pi-ai 的 sendSessionAffinityHeaders / sessionAffinityFormat |
DSH 把这些 compat 开关标记为 withhold,写进配置直接报错拒绝 ("not configurable here");而且它只会发 session_id/x-session-affinity,后者实测被 OpenCode 拒 |
用动态 Cordis 插件(cordis_define/cordis_run) |
动态插件 host 端 Builtins 只有 ctx/harness/console/btoa/atob/TextEncoder/TextDecoder------没有 fetch,连一个 HTTP 请求都发不出去 |
结论:LLM 这一层根本没有能注入 HTTP 头的缝。 有两条腿的解决方案(改源码、改库)都被排除了,那就只剩第三条腿------
五、可行路径:在 fetch 传输层动手
关键事实(查源码得到两行证据):
- pi-ai 把
options?.fetch透传给 OpenAI SDK:
js
// openai-completions.js
const client = createClient(model, context, apiKey, options?.headers, options?.fetch, cacheSessionId, compat)
...
return new OpenAI({ apiKey, baseURL: model.baseUrl, fetch, defaultHeaders: headers })
- DSH 的适配器不传
fetch,于是 SDK 用getDefaultFetch()在请求时惰性读全局 fetch:
js
// openai/internal/shims.js
function getDefaultFetch() {
if (typeof fetch !== 'undefined') return fetch
throw new Error('`fetch` is not defined as a global; ...')
}
所以:运行时包一层 globalThis.fetch,就能在同一进程里截住所有出站 LLM 请求。 剩下的问题只有一个------补的这个值,怎么做到「按会话稳定」?
答案在 DSH 自己身上:llm/stream waterfall 的 options.sessionId,就是 DSH 的会话 id(跨轮次 / 压缩 / 重试稳定,新会话 / fork / 子代理各不同),官方 DeepSeek 适配器已经在发的 x-deepseek-harness-session-id 就是它。两个挂载点一拼:
llm/stream waterfall ──AsyncLocalStorage 携带 options.sessionId──> pi-ai ──> fetch ──> opencode.ai
│ ▲
└── 仅 opencode / opencode-go 路由 补 x-opencode-session(仅白名单域名)
llm/stream观察者:把这段流的每次next()包进AsyncLocalStorage.run(会话 id);fetch中间件:只对白名单主机(opencode.ai)加头,其余流量一个字节不改;- 两个注册都是 fiber 级 effect,插件卸载时自动还原。
六、代码讲解:四个关键点
完整代码见文末「源码」一节,或直接看仓库:https://github.com/priatewang/dsh-ocg-session
-
为什么用
AsyncLocalStorage而不是一个全局变量:DSH 是并发进程,两个会话可能同时流式请求。ALS 把「值」绑定到异步执行链上,互不污染------这由测试 3(并发隔离)直接证明。 -
为什么
withValue包的是每次next(),而不是整个流 :HTTP 请求发生在适配器迭代到第一块的时候(pi-ai 惰性建连)。如果只在创建流时run一次,store 在真正 fetch 时已经不在了。逐个next()包装保证 fetch 发生时 store 一定活跃。 -
为什么一定要转发
return/throw:DSH 适配器在提前停止时会调用iterator.return()(比如上层取消)。不转发,流会被卡死而不是被干净地中止。 -
为什么配置带 1 秒缓存 :
fetch是进程级的,普通流量(web UI、npm 等)也走它。如果每次 fetch 都同步读一次开关文件,代价不可接受。缓存放行,开关文件的改动最多 1 秒后生效------「免重启开关」因此成立。
七、验证:13 项冒烟测试 + 真实链路对照
冒烟测试(对本机 echo server,无真实流量)
install
ok global fetch is patched
llm/stream value injection
ok session id reaches the wire
concurrent conversations stay isolated
ok first conversation keeps its own id
ok second conversation keeps its own id
scoping
ok traffic without a session falls back to the default id
ok a host outside the allow-list is untouched
ok another provider never gets the session id
header precedence
ok a caller-supplied value wins
uuid mode
ok uuid mode emits a uuid
ok the same session keeps its uuid
ok another session gets another uuid
runtime disable switch
ok disabled means no header at all
disposal
ok global fetch is restored
13 checks passed
真实链路对照实验(同一段代码,只切换 enabled)
1) plugin enabled -> POST https://opencode.ai/zen/go/v1/chat/completions -> 200
[ocg-session] opencode-go/deepseek-v4-flash session=session-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
[ocg-session] x-opencode-session=session-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -> opencode.ai
2) plugin disabled -> 同一代码路径 -> 400 MissingSessionID
启用 → 200;禁用 → 立刻回到原始的 400。这个对照实验是整篇最硬的一句话:这个头就是让请求能通的原因。
八、安装与使用
powershell
# 在能跑 dsh 的终端(源码 checkout 下即 pnpm dsh ...)
pnpm dsh plugin --profile web add ./dsh-ocg-session
# 然后完全重启 dsh web(bundle 只在启动时读取)
启动日志出现 [ocg-session] loaded: ... 即安装成功。运行时开关(免重启):
json
// C:\Users\xxx\.dsh\plugins\dsh-ocg-session.json
{ "enabled": false }
完整配置项:providers(路由键白名单)、hosts(主机白名单,匹配子域)、mode(session-id 用 DSH 会话 id / uuid 用按会话派生的随机 UUID)、fallback(无会话上下文时的兜底值)、debug。优先级:默认值 < 组合行 config < 开关文件。
九、局限与后续
- 依赖现状 :DSH 出站 LLM 请求走 Node 全局
fetch。将来版本若换网络栈,插件会静默失效------症状就是 400 复现,卸载即可。 - patch 的是进程级
globalThis.fetch:这是 Node 里唯一能补 HTTP 头的注入点,危害面被严格限制在hosts白名单内,且只新增一个头,不改其它任何内容。 - 模型探测请求不带该头 :实测
GET /v1/models不需要它(200)。 - 上游正在修 :DSH 官方 discussion #5495 跟踪「在所有适配器路径发送会话头」,内置之后本插件即可退役。参考实现方面,npm 上已有两个相同机制的第三方插件(
dsh-opencode-session-header、dsh-opencode-session)------本插件的价值在于零依赖、代码完全自有、可直接审计。 - OpenCode Go 的
gpt-5.6-luna(及muse-spark-*)还有独立的地区限制 (403unsupported_country_region_territory),与本文无关,但接模型时值得留意。
十、结语
这次排障最值得记录的,不是「加一个头」,而是先证明「能加在哪一层」:LLM 语义层的每个缝都被逐一检查并排除,最后落点在 fetch 传输层------一个被绝大多数上层方案忽略、但在 Node 里唯一成立的注入点。如果哪天你也要给某个网关「补一个它要求的头」,希望本文的排查路径(先打端点 → 逐协议验证 → 枚举扩展点 → 在传输层动手)能帮你少走弯路。
十一、源码
仓库地址:**https://github.com/priatewang/dsh-ocg-session**(MIT 许可,零依赖,纯 ESM)
项目结构:
dsh-ocg-session/
├── package.json # 零依赖,dsh.bundle.patch 声明
├── cordis.patch.yml # 插入 profile 层栈
├── lib/index.js # 插件本体(全文如下)
├── tests/smoke.mjs # 13 项冒烟测试
└── README.md
package.json
json
{
"name": "dsh-ocg-session",
"version": "0.1.0",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},
"files": ["lib", "cordis.patch.yml", "README.md"],
"engines": { "node": ">=20" },
"scripts": { "test": "node tests/smoke.mjs" },
"license": "MIT",
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
cordis.patch.yml
yaml
- insert:
- id: ocg-session
name: 'dsh-ocg-session'
lib/index.js
js
/**
* dsh-ocg-session --- attach a stable per-conversation `x-opencode-session`
* header to model calls routed to OpenCode / OpenCode Go.
*
* OpenCode's relay refuses a request without that header (`400
* MissingSessionID`), and DeepSeek Harness never sends it: the header is built
* inside pi-ai from the route profile alone, so nothing on the LLM seam can add
* it --- `llm/stream` options are deep-frozen and carry no headers, the pi-ai
* adapter ignores caller headers, `registerAdapter` refuses a route another
* adapter already owns, and pi-ai's own session-affinity switches are withheld
* from configuration.
*
* This plugin therefore works one layer down:
*
* 1. It listens on the `llm/stream` waterfall and drives each matching call's
* iteration inside an {@link AsyncLocalStorage} store holding the header
* value --- the DSH session id, which is stable across the turns,
* compactions and retries of one conversation and unique per conversation.
* 2. It wraps `globalThis.fetch` once, adding the header to requests whose host
* is on the allow-list and leaving every other request untouched.
*
* Both registrations are fiber-scoped effects, so stopping, updating or
* unloading the plugin restores the original `fetch` and removes the listener.
*
* @module dsh-ocg-session
*/
import { AsyncLocalStorage } from 'node:async_hooks'
import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
/** Cordis plugin name, matching the id in `cordis.patch.yml`. */
export const name = 'ocg-session'
/** The `llm` service declares the `llm/stream` waterfall this plugin listens on. */
export const inject = ['llm']
/** The request header OpenCode Go requires. */
export const HEADER = 'x-opencode-session'
/** How long a resolved configuration stays cached before the switch file is read again. */
const CONFIG_TTL_MS = 1000
/** Lowest-priority configuration layer; the row's config and the switch file override it. */
const DEFAULTS = {
enabled: true,
providers: ['opencode', 'opencode-go'],
hosts: ['opencode.ai'],
mode: 'session-id',
fallback: 'dsh-default',
debug: false,
}
/**
* Carries the header value from the `llm/stream` listener down to the wrapped
* `fetch` call. Node's AsyncLocalStorage keeps this per asynchronous execution
* chain, so two conversations streaming at once never see each other's value.
*/
const store = new AsyncLocalStorage()
/** `$DSH_HOME` when set, otherwise the conventional `~/.dsh`. */
function homeDir() {
const configured = process.env.DSH_HOME
return configured !== undefined && configured.length > 0 ? configured : join(homedir(), '.dsh')
}
/** Absolute path of the runtime switch file. */
function switchFile() {
const configured = process.env.DSH_OCG_SESSION_FILE
return configured !== undefined && configured.length > 0
? configured
: join(homeDir(), 'plugins', 'dsh-ocg-session.json')
}
/**
* Read the runtime switch file: a missing or unreadable file means "no
* overrides", and a JSON object overlays this plugin's configuration layer.
* @returns the parsed object, or an empty object when there is nothing usable.
*/
function readSwitchFile() {
try {
const parsed = JSON.parse(readFileSync(switchFile(), 'utf8'))
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
/** Keep a configured list of non-empty strings, falling back when it names none. */
function stringList(value, fallback) {
if (!Array.isArray(value)) return fallback
const kept = value.filter(entry => typeof entry === 'string' && entry.length > 0)
return kept.length === 0 ? fallback : kept
}
/**
* Resolve the effective configuration: defaults, then the composition row's
* config object, then the runtime switch file.
* @param config - the object the composition row handed to {@link apply}.
* @returns the resolved switches, normalized field by field.
*/
function mergeConfig(config) {
const layers = [DEFAULTS, config !== null && typeof config === 'object' ? config : {}, readSwitchFile()]
const pick = key => {
for (let index = layers.length - 1; index >= 0; index -= 1) {
const value = layers[index][key]
if (value !== undefined) return value
}
return undefined
}
const fallback = pick('fallback')
const mode = pick('mode')
return {
enabled: pick('enabled') !== false,
providers: stringList(pick('providers'), DEFAULTS.providers),
hosts: stringList(pick('hosts'), DEFAULTS.hosts),
mode: mode === 'uuid' ? 'uuid' : 'session-id',
fallback: typeof fallback === 'string' && fallback.length > 0 ? fallback : DEFAULTS.fallback,
debug: pick('debug') === true,
}
}
/** Lowercased hostname of a fetch target, or undefined when it is not a URL. */
function hostnameOf(input) {
try {
const url = typeof input === 'string' || input instanceof URL ? String(input) : input?.url
return new URL(url).hostname.toLowerCase()
} catch {
return undefined
}
}
/** Whether a hostname is on the allow-list, subdomains included. */
function hostAllowed(hostname, hosts) {
return hosts.some(host => hostname === host || hostname.endsWith(`.${host}`))
}
/**
* The header value for one call: the DSH session id itself, or a per-session
* random UUID. Calls without a session id (auxiliary hand-built requests and
* non-LLM traffic such as model discovery) use the configured fallback.
*/
function valueFor(options, config, uuids) {
const sessionId = options?.sessionId
if (config.mode !== 'uuid') return sessionId === undefined ? config.fallback : String(sessionId)
const key = sessionId === undefined ? '' : String(sessionId)
let value = uuids.get(key)
if (value === undefined) {
value = randomUUID()
uuids.set(key, value)
}
return value
}
/**
* Build the `[input, init]` pair a fetch call needs to carry one extra header,
* or undefined when the call must pass through untouched. The headers that
* would actually reach the wire are the merge base, per the fetch spec, and an
* explicit value written by the caller always wins.
*/
function withHeader(input, init, value, hosts) {
const request = typeof Request === 'function' && input instanceof Request ? input : undefined
const hostname = hostnameOf(request === undefined ? input : request.url)
if (hostname === undefined || !hostAllowed(hostname, hosts)) return undefined
const headers = new Headers(init?.headers ?? request?.headers)
if (headers.has(HEADER)) return undefined
headers.set(HEADER, value)
if (request !== undefined && (init === undefined || init === null)) {
return [new Request(request, { headers }), undefined]
}
return [input, { ...(init ?? {}), headers }]
}
/**
* Wrap a downstream chunk stream so every `next()` runs inside a store holding
* the header value. Wrapping the calls --- not the call that creates the stream ---
* is what keeps the store active while the adapter performs its HTTP request,
* and forwarding `return`/`throw` preserves early-stop semantics.
*/
function withValue(value, source) {
if (typeof source?.[Symbol.asyncIterator] !== 'function') return source
const iterator = source[Symbol.asyncIterator]()
return {
[Symbol.asyncIterator]() {
return this
},
next: () => store.run(value, () => iterator.next()),
return: result => iterator.return === undefined
? Promise.resolve({ done: true, value: result })
: store.run(value, () => iterator.return(result)),
throw: error => iterator.throw === undefined
? Promise.reject(error)
: store.run(value, () => iterator.throw(error)),
}
}
/**
* Install the plugin: patch `globalThis.fetch`, then observe the `llm/stream`
* waterfall. Both effects live and die with the plugin's fiber.
* @param ctx - the plugin's Cordis context.
* @param config - optional configuration from the composition row.
*/
export function apply(ctx, config = {}) {
const uuids = new Map()
let cachedAt = 0
let cached
/** Cached configuration, so ordinary fetch traffic never pays a file read. */
const resolve = () => {
const now = Date.now()
if (cached === undefined || now - cachedAt >= CONFIG_TTL_MS) {
cached = mergeConfig(config)
cachedAt = now
}
return cached
}
const log = (...args) => {
const logger = ctx.logger
if (logger !== undefined && typeof logger.info === 'function') logger.info(...args)
else console.log(...args)
}
const original = globalThis.fetch
if (typeof original === 'function') {
const patched = function patchedFetch(input, init) {
const resolved = resolve()
if (!resolved.enabled) return original.call(this, input, init)
const active = store.getStore()
const value = active === undefined ? resolved.fallback : active
let request
try {
request = withHeader(input, init, value, resolved.hosts)
} catch {
request = undefined
}
if (request === undefined) return original.call(this, input, init)
if (resolved.debug) log(`[ocg-session] ${HEADER}=${value} -> ${hostnameOf(request[0]?.url ?? request[0])}`)
return original.call(this, request[0], request[1])
}
globalThis.fetch = patched
ctx.effect(() => () => {
if (globalThis.fetch === patched) globalThis.fetch = original
}, 'ocg-session: restore global fetch')
}
ctx.on('llm/stream', (options, next) => {
const resolved = resolve()
if (!resolved.enabled) return next()
if (!resolved.providers.includes(String(options?.provider))) return next()
const value = valueFor(options, resolved, uuids)
if (resolved.debug) {
log(`[ocg-session] ${options.provider}/${options.model}`
+ ` session=${options?.sessionId ?? '(none)'} ${HEADER}=${value}`)
}
return withValue(value, next())
})
const resolved = resolve()
log(`[ocg-session] loaded: ${HEADER} providers=[${resolved.providers.join(', ')}]`
+ ` hosts=[${resolved.hosts.join(', ')}] mode=${resolved.mode} enabled=${resolved.enabled}`
+ ` switch=${switchFile()}`)
}
运行测试
powershell
cd dsh-ocg-session
node tests/smoke.mjs # 13 checks passed
文中所有代码与测试均在 DeepSeek Harness 0.1.5-rc.1 上验证通过。