设计说明(dsh-opencode-session-id)

August 23, 2026 · View on GitHub

本文档记录插件的实现原理与设计取舍,供维护者参考;使用说明见 README

1. opencode 是怎么携带 session id 的(取自 opencode 1.18.21 源码)

opencode 构建 LLM 请求头时按 provider 分成两个分支(providerIDopencode 开头 → 网关分支):

headers: {
  ...(model.providerID.startsWith("opencode")
    ? {
        ...(projectId ? { "x-opencode-project": projectId } : {}),
        "x-opencode-session": sessionID,     // ← 网关分支:会话 ID 在这个头里
        "x-opencode-request": user.id,
        "x-opencode-client": flags.client,
        "User-Agent": `opencode/${version}`, // ← 指纹 UA
      }
    : {
        "x-session-affinity": sessionID,     // ← 非 opencode provider 才是这套
        "X-Session-Id": sessionID,
        ...(parentSessionID ? { "x-parent-session-id": parentSessionID } : {}),
        "User-Agent": `opencode/${version}`,
      }),
  ...model.headers,
}

结论:

  • 对 opencode 网关,会话 ID 走 x-opencode-session(外加 x-opencode-client / x-opencode-request / x-opencode-projectUser-Agent: opencode/<版本> 指纹头)。
  • x-session-affinity / X-Session-Id 是 opencode 对普通 OpenAI 兼容端点才用的。
  • opencode 原生会话 id 是 nanoid 风格(如 QBgzdhtO),非 session-<uuid> 格式。

另外,pi-ai 库(@earendil-works/pi-ai,opencode 自己的 AI 库)内部还有一套 sendSessionAffinityHeaders 门控的 affinity 头发射逻辑——默认关,且 dsh 的 llm-pi-ai adapter 配置门控刻意 withheld 了该开关,这正是"dsh 已有 options.sessionId 却发不出任何会话头"的根因。

2. dsh 侧的现状

dsh-agent-loop 把会话 id 放进 options.sessionIdllm-pi-ai 也会转发给 pi-ai;但 adapter 丢弃逐请求的 options.headers(只用 provider 静态 headers + attribution 头),且 compat 门控关着——所以实际请求头里什么都没有。本插件在不可能被绕过的层(实际 fetch)补上这一环。

3. 工作原理

  1. llm/stream waterfall 监听(dsh 官方 LLM 请求拦截点):把每个流式调用用 AsyncLocalStorage 绑定到它的会话 id——多个会话并发流式时每个请求也能拿到自己的 session id。
  2. 包装 globalThis.fetch:Node ≥ 18 下 openai SDK 和 @anthropic-ai/sdk 解析到的都是 undici 全局 fetch(SDK 在每次请求构造客户端时才解析 fetch,包装必然生效)。包装器只对命中 opencode 端点(host 后缀,或精确 baseURL 前缀,均可配置)的请求追加会话请求头,其余请求以完全相同的参数原样透传。

范围保证——只改请求头。 包装器不读、不改、不替换请求体(字节与流原样透传),不改 URL/method/signal/duplex/凭证等任何 fetch 选项,也不碰响应。llm/stream 监听器默认不改调用选项;仅 seedSessionId: true 时会给缺失 options.sessionId 的 opencode 路由补种(唯一 wire 效果仍是会话请求头)。

4. wire token:为什么是 nanoid(8) 哈希

为了让网关看到 opencode 同款格式,插件默认用 SHA-256 把 session-<uuid> 确定性映射成 8 字符 nanoid

  • 只哈希 uuid 部分session- 前缀(一个常量 label)不参与哈希(stripSessionPrefix 先剥离),token 只由 uuid 决定。例:session-e820d21d-…-a309f722a3bc → 0RpJJnxJ
  • 同一个会话 → 恒定 token,跨请求、跨重启不变,后台可稳定归因。
  • token 默认只用纯字母数字A-Za-z0-9,62 字符)。62 不是 2 的幂,直接取模会有偏差,故用拒绝采样(6 位值 ≥62 跳过再取下一个)保持均匀;SHA-256 块耗尽时以计数器续哈希。
  • 可用 nanoidAlphabet: "urlsafe" 回退到经典 64 字符表(含 _/-)。
  • 该哈希是单向的:后台只能看到 token,无法反推原始 session-<uuid>;dsh 侧靠 verbose 日志里的 → wire 映射回溯会话。
  • nanoidAlphabet / nanoidLength 配置后 token 会整体变化,旧的后台记录不再关联。

5. 限制

  • session id 取 options.sessionId(agent-loop 已按会话填好);无会话上下文时兜底 sessionIdEnv > DSH_SESSION_ID(web 部署下是该进程的启动会话)> 进程内随机 id。
  • 覆盖 openai-completions / openai-responses / anthropic-messages 等走 fetch 的协议;transport: websocket 不走 fetch,不在覆盖范围。
  • 全局 fetch 包装只在命中 opencode 端点时追加请求头,不做任何其它改动。