DSH 接入 OpenCode Go 报 400 MissingSessionID:从 LLM 语义层退到 fetch 传输层的完整排障

DSH 接入 OpenCode Go 报 400 MissingSessionID:一次从 LLM 语义层退到 fetch 传输层的排障记录

从「请求报错」到「根因」再到「插件方案」,完整记录一次模型接入的排障过程。

结论先行:OpenCode Go 要求每个请求带一个按会话稳定的 x-opencode-session 头,

DSH 的 LLM 层没有任何地方能加这个头,但传输层 可以------于是有了一个

llm/stream 观察者 + AsyncLocalStorage + fetch 中间件的零依赖插件。

完整源码:https://github.com/priatewang/dsh-ocg-session

报错原文如下:


一、事情的起因

我把 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/models200,key 有效,模型列表齐全;
  • POST .../chat/completions400 MissingSessionID

结论:配置没错,是请求本身缺东西。报错信息已经把答案说了一半------缺 x-opencode-session 头。顺带一提,排查中 PowerShell 的 Invoke-WebRequestcurl.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-idsession_idx-session-id 这三个别名,但拒绝 x-session-affinityx-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 传输层动手

关键事实(查源码得到两行证据):

  1. 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 })
  1. 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

  1. 为什么用 AsyncLocalStorage 而不是一个全局变量:DSH 是并发进程,两个会话可能同时流式请求。ALS 把「值」绑定到异步执行链上,互不污染------这由测试 3(并发隔离)直接证明。

  2. 为什么 withValue 包的是每次 next(),而不是整个流 :HTTP 请求发生在适配器迭代到第一块的时候(pi-ai 惰性建连)。如果只在创建流时 run 一次,store 在真正 fetch 时已经不在了。逐个 next() 包装保证 fetch 发生时 store 一定活跃。

  3. 为什么一定要转发 return/throw :DSH 适配器在提前停止时会调用 iterator.return()(比如上层取消)。不转发,流会被卡死而不是被干净地中止。

  4. 为什么配置带 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(主机白名单,匹配子域)、modesession-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-headerdsh-opencode-session)------本插件的价值在于零依赖、代码完全自有、可直接审计。
  • OpenCode Go 的 gpt-5.6-luna(及 muse-spark-*)还有独立的地区限制 (403 unsupported_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 上验证通过。

项目地址:https://github.com/priatewang/dsh-ocg-session

相关推荐
weixin199701080161 小时前
《Mercari OTA 直连接入:日本二手电商出海的API通道与600次/分钟限流实战》(附Python源码)
开发语言·数据库·python
一木 之林1 小时前
第 3 节 在 AI 行业中,C/C++ 编程中的动态库和静态库
c语言·c++·后端
库玛西1 小时前
深度解构高并发利器:纯事件驱动 Reactor 模式的设计哲学与工程实践
服务器·开发语言·c++·笔记·tcp/ip
skywalkerch1 小时前
Spring 循环依赖与三级缓存
后端
lemon_sjdk1 小时前
JavaScript 常用关键字全解析
开发语言·javascript·ecmascript
linx2951 小时前
单元五 · 对称认知·下:编译与链接
linux·c语言·开发语言·c++·嵌入式硬件
萧瑟余晖2 小时前
Hibernate 实体映射与关联关系详解
后端·hibernate
2501_930472442 小时前
从物理机到 CVM 全流程落地:部署脚本的 8 个云化改造点(附代码对比)
开发语言·人工智能·架构·腾讯云·perl
stars3692 小时前
实验四 JSP内置对象的应用
后端