Skip to content
dsh.fish
Bundle

dsh-opencode-pin

Pins OpenCode Go (and OpenCode Zen) traffic to a chosen upstream route by keeping one stable x-opencode-session key, with an in-GUI upstream picker, a remembered key pool, and drift checks.

Source
wobushiyoushen0214
License
MIT
Updated
Updated 8 days ago

Readme

# dsh-opencode-pin · 把 OpenCode Go 的上游路由钉住

一个 DeepSeek Harness(DSH)Host 插件:**把所有模型调用钉在 OpenCode Go 套餐上**,并保证每个请求都带 **稳定的 `x-opencode-session`**,从而让 OpenCode 的「会话 → 上游提供商」绑定在整段对话里不漂移。

---

[![awesome · DSH plugin](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)

**源码**:<https://github.com/wobushiyoushen0214/dsh-opencode-pin>

## 安装

```sh
# 官方 CLI(插件市场里的同一条命令)
dsh plugin --profile web add dsh-opencode-pin

# DSH 内置插件管理器
plugin_manager { action: "install_bundle", target: "dsh-opencode-pin" }
```

装好后:**设置 → 内置插件 → 页签「OpenCode 上游钉选」**。也可以从插件市场的卡片一键安装(搜 "OpenCode 上游钉选" 或 `dsh-opencode-pin`)。

---

## 0. 当前状态(本机已实测)

| 项 | 结果 |
|---|---|
| 安装 | bundle `dsh-opencode-pin`,行 `opencode-pin`:`enabled` 且 `fiberPhase: active` |
| Config schema | `cordis_inspect_query Config.listConfigs` → `status: "schema"`,`packageDir` 指向 profile 链接;说明 `@deepseek-ai/schemastery` 的 peer 路由在重启后生效 |
| 路由钉(**已在跑**) | 会话日志只有两条 `request/header`:`initial muyuan/deepseek-v4.1-flash` → `resume opencode-go/deepseek-v4.1-flash`(effort `high`、maxTokens `384000`);重启后 13 条 `assistant/message` 全部由 `opencode-go` 服务,且**没有** provider 反复横跳 |
| 单测 | `node --test test/*.test.mjs` → 23 passed |
| 观测路由 | `GET /opencode-pin/status` 曾 404:已定位为注册时机 bug 并修复(见下),**需再重启一次生效** |

### 两个踩过的坑(都已在代码里修掉)

1. **`agent/request` 必须 `prepend` 才能钉住路由。** `dsh-agent` 自己的"会话模型选择"监听器也答 `agent/request`,它在外层 `await next()` 后用会话选择覆盖结果。插件原来注册在最内层,改写被吞掉,日志里表现为每步都 `muyuan -> opencode-go` 却钉不住;而且"会话选择 ≠ 最近 header"会被当成用户换模型,反复注入 `[model changed: …]` 通知。现在:`{ global: true, prepend: true }` + 通过 `sessionController.selectForNextRequest()` 把钉住的路由写成本会话 `model/selection`。
2. **`ctx.on('webServer', …)` 在 Cordis v4 里永不触发。** 服务就绪只以 `internal/service` 事件广播(`cordis/lib/index.js:849`)。而 `webServer` 由 web 端 bundle 挂载,插件在 profile bundle 之后激活时它可能还不存在 → 路由永远注册不上。正确写法是 `ctx.inject(['webServer'], (scoped) => …)`。
   现场对比(临时探针插件,两个路由同插件内并列):
   ```text
   /opencode-pin-probe/inject   200  {"ok":true,"via":"ctx.inject"}
   /opencode-pin-probe/event    404
   ```

> 插件 JS 代码改动**必须重启应用**才会加载新一代模块(ESM 模块缓存);只有 `cordis.patch.yml` 的配置改动走 HMR。

---

## 1. 为什么需要它(实测结论,非推测)

用 `scripts/evidence.mjs` 打真实请求(每次 1 token),OpenCode Go 会在响应头里明说自己用了哪个上游:

```text
x-opencode-endpoint-id:        novita-deepseek | luminal-dsv4.1flash | orcarouter-dsv4.1flash | ...
x-opencode-upstream-model-id:  deepseek/deepseek-v4.1-flash | deepseek-ai/DeepSeek-V4.1-Flash | ...
x-zen-model:                   deepseek-v4.1-flash
x-opencode-log-id:             98b1140f-5350-4bce-8c02-19e1afa1f438
```

三个关键事实:

1. **同一个模型 id 由多个上游端点服务**(`novita-deepseek`、`luminal-dsv4.1flash`、`orcarouter-dsv4.1flash` …),
   `x-opencode-upstream-model-id` 甚至分两种命名体系:`deepseek-ai/...`(官方权重那一路)与 `deepseek/...`(第三方托管)。这就是「降智」的来源——落到哪个上游不由你选。
2. **路由由 `x-opencode-session` 决定,而且一旦分配就黏在那个 key 上**:
   - 同一 key 连续请求 → 始终同一个端点;
   - 换新 key → 落到「当前默认」端点(会随时间/负载整体变化);
   - 官方文档明确要求:*"Send a stable session ID in `x-opencode-session` for each conversation so we can optimize routing and prompt caching."*
3. **不带这个头,请求直接被拒**:

   ```text
   HTTP 400  Request is missing x-opencode-session and cannot be routed efficiently.
   ```

而 DSH 只有部分路径会带 session:agent 主循环 / 子代理 / 标题 / 压缩都带,但
`@deepseek-ai/dsh-experimental-auto-review` 这类直接调 `ctx.llm.stream()` 的路径不带(OpenCode 官方文档的 "Known problematic clients" 里点名 DSH:*"Session information arrives on some model paths, but is missing on others."*)。
DSH 也不会把响应头暴露给插件,所以「现在到底落在哪个上游」平时是看不见的。

## 2. 它怎么做

| 机制 | 扩展点 | 作用 |
|---|---|---|
| 路由钉选 | `agent/request` waterfall | 在请求被**记入会话日志之前**把 provider/model 换成配置的目标(默认 `opencode-go/deepseek-v4.1-flash`)。主循环与每个子代理都覆盖,用量归属与可回放 header 保持正确。 |
| 会话兜底注入 | `llm/stream` waterfall | 任何到达 LLM seam 却没有 `sessionId` 的调用,补齐为「发起该调用的对话身份」;落在 OpenCode 路由上的调用可改为共享一个被探测挑出来的 key(`shared` / `hunt` 模式)。 |
| 会话选择对齐 | `sessionController.agents.selectForNextRequest()`(facade 上若将来提升也接受;都没有则退回纯 waterfall,并在状态里报 `selectionApi: false`) | 把钉住的路由写成本会话的 `model/selection`。`dsh-agent` 会把"会话选择 ≠ 最近请求 header"当成用户换了模型,反复注入 `[model changed: ...]` 通知——对齐后不会误报,UI 的模型标签也与实际一致。 |
| 上游观测与挑选 | 自带 1-token 探测 | 因为 Harness 不给插件响应头,插件自己发探测请求,读出 `x-opencode-endpoint-id`,在 `hunt` 模式下**挑一个落在偏好端点上的 key 并全程复用**——这就是字面意义上的「钉住上游提供商」。 |

`hunt` 模式的做法:先用候选 key 并行探测(默认 4 个,每个 1 token),若某个 key 的端点命中 `probe.prefer`(例如 `["luminal-*"]`),就把这个 key 固定下来给所有会话用;超过 `probe.recheckMs` 会重新验证一次,发现漂移就再挑一轮并告警。

### 钉住上游端点(本插件的主要用途)

实测结论(本机,跨 1 小时+、跨两次应用重启):

| key | 现在落到 | `x-opencode-upstream-model-id` |
|---|---|---|
| `probe-A` / `pin-live-a` | `luminal-dsv4.1flash` | `deepseek-ai/DeepSeek-V4.1-Flash`(官方权重命名那一路) |
| `pin-6` / `evidence-old` | `novita-deepseek` | `deepseek/deepseek-v4.1-flash` |
| `pin-1` | `orcarouter-dsv4.1flash` | `deepseek/deepseek-v4.1-flash` |
| 任何**新** key | 当前默认(观察期内从 luminal → novita → orcarouter 变过) | 随默认 |

两条关键规律:

1. **绑定黏在 key 上,且长期稳定**;换新 key 只会拿到"当前默认"。
2. 所以想要**非默认**端点(如 luminal),必须**复用当初落在那里、且一直黏着的旧 key** —— 这正是本插件 `probe.pool` / 记忆 key 的用途。

配置(`cordis.patch.yml` 里按行 id `opencode-pin` 覆盖,`config` 整体替换):

```yaml
- id: opencode-pin
  name: "dsh-opencode-pin"
  config:
    pin:
      route: false                 # 不钉 provider 路由,只钉上游端点
    session:
      mode: shared                 # 所有 opencode 请求共用一个 key ⇒ 同一个上游
      models: [deepseek-v4.1-flash]  # 只钉这个模型(留空=该路由所有模型)
      key: probe-A                 # ← 换这一行就换上游:
                                   #   probe-A / pin-live-a → luminal-dsv4.1flash
                                   #   pin-6 / evidence-old → novita-deepseek
                                   #   pin-1               → orcarouter-dsv4.1flash
                                   #   任意新 key          → 当前默认端点
    probe:
      prefer: ["luminal-*"]        # hunt 模式的偏好端点(通配 *)
      pool: [probe-A, pin-live-a, pin-6, evidence-old, pin-1]  # 优先复用的历史 key
```

### 哪个端点算「DeepSeek 官方」?

同一模型 `deepseek-v4.1-flash` 在三个端点上的线上指纹(实测):

| 端点 (`x-opencode-endpoint-id`) | `x-opencode-upstream-model-id` | 响应 `id` 形态 | 判断 |
|---|---|---|---|
| `luminal-dsv4.1flash` | **`deepseek-ai/DeepSeek-V4.1-Flash`** | `chatcmpl-ad9b3b1c` | HuggingFace 官方组织 `deepseek-ai/` 的仓库命名 ⇒ **官方权重那一路** |
| `novita-deepseek` | `deepseek/deepseek-v4.1-flash` | 32 位 hex | OpenRouter 风格 vendor slug ⇒ 第三方托管 |
| `orcarouter-dsv4.1flash` | `deepseek/deepseek-v4.1-flash` | 56 位 hex | 同上 |

另外:端点空间是按模型分配的 —— `deepseek-v4-pro` 只有 `orcarouter-dsv4pro`;`deepseek-v4-flash` 只有 `novita-deepseek`;唯一名字就叫 `deepseek` 的端点只服务 `deepseek-v4-flash-vision-exp`,`deepseek-v4.1-flash` **拿不到**它(4 个不同 key 抽样均未出现)。

所以:**在 opencode-go 体系内,「官方」的上限就是 `luminal-dsv4.1flash`**(本插件的 `key: probe-A` 正指向它)。想要**真正的 DeepSeek 官方 API 直连**(`api.deepseek.com`),应当改用 DSH 自带路由而不是 opencode-go:

| 路由 | 凭据 | 说明 |
|---|---|---|
| `deepseek-account` | DSH 账号授权(`x-dsh-auth-token`) | 登录即用;`inferenceOrigin` 默认 `https://api.deepseek.com`;失败以 `ACCOUNT_QUOTA` 呈现 |
| `deepseek-official` | `DEEPSEEK_API_KEY` | 需在 设置→模型 里填官方 API Key |

两条路由的默认目录都是 `deepseek-flash`(文本+图片,1M 上下文)与 `deepseek-v4-pro`(纯文本,1M),且模型 id 原样透传,任意官方模型 id 都能直接请求。

想让它**自愈**(定期校验绑定、漂移就自动换回一个命中的 key):把 `session.mode` 改成 `hunt`。hunt 会**先验证 `pool` 里的历史 key**(而不是只探新 key,因为新 key 拿不到非默认端点),命中即用,超过 `probe.recheckMs`(默认 30 分钟)重新校验,发现漂移会在日志里告警并重挑。

监控漂移:面板点「探测一轮」,或 `curl "localhost:19387/opencode-pin/probe?keys=probe-A"`,看 `endpoint` 是否还是你钉的那个。

> 注意:`session.mode: shared` 会让该模型的所有请求(含标题/压缩/子代理)共用这一个 key —— 这是"钉上游"的代价与目的。想让别的模型保持原样,用 `session.models` 限定范围。

## 3. 安装

在 DSH 里用插件管理器安装(不会手写 profile 文件,也不需要 pnpm):

```text
plugin_manager { action: "install_bundle", target: "/Users/lizhiwei/localProj/dsh-opencode-pin" }
```

安装后确认:

- `plugin_manager { action: "list_plugins" }` 里能看到行 `opencode-pin`(`application: applied` 表示已热挂载);
- `GET http://127.0.0.1:19387/opencode-pin/status` 返回 JSON;
- 日志里出现 `opencode-pin: route pin on (opencode-go/deepseek-v4.1-flash), session mode "conversation", hunt off`。

> 修改插件代码后需要重启应用才会加载新的 JS(`restart-required`);只改 `cordis.patch.yml` 的配置会走 HMR。

## 4. 配置

配置走标准 DSH 方式:在 profile 的 `cordis.patch.yml` 里按行 id 覆盖。**注意 patch 里的 `config` 是整体替换,不是深合并**,要改哪层就把那层写全。

```yaml
- id: opencode-pin
  name: "dsh-opencode-pin"
  config:
    pin:
      route: true                     # 强制所有调用走下面这个路由
      provider: opencode-go
      model: deepseek-v4.1-flash
      reasoningEffort: high           # 目标模型不支持调用方要的档位时用它兜底
      auxiliary: true                 # 标题/压缩/auto-review 这类绕过 agent loop 的调用也钉
      adoptSelection: true            # 同时把钉住的路由写成本会话的模型选择(UI 一致、不再注入"模型已切换"通知)
    session:
      mode: conversation              # conversation | shared | hunt
      routes: [opencode-go, opencode] # 只对这些路由改写 session key
      key: dsh-opencode-pin
    probe:
      prefer: ["luminal-*"]           # hunt 模式的偏好端点(空 = 不探测、不额外花 token)
      attempts: 4
      apiKeyRef: OPENCODE_GO_API_KEY
```

### `session.mode` 三档

| 模式 | `x-opencode-session` | 额外流量 | 适用 |
|---|---|---|---|
| `conversation`(默认) | 对话自己的 session id;缺失时补「发起对话的身份」 | 无 | 最小干预:修掉缺头的路径,保持官方建议的「每会话稳定」。**每个新对话仍可能被分配到不同上游。** |
| `shared` | 所有请求共用一个固定 key | 无 | 想彻底「全局一个上游」但又不想探测:所有对话用同一 key,首次分配后长期黏住。 |
| `hunt` | 探测挑出的、落在 `probe.prefer` 端点上的 key,全程复用 | 开启时每轮最多 `attempts` 个 1-token 请求(≈几百 token);命中后缓存 `recheckMs` | 真的要「钉住某个上游提供商」。 |

## 5. 在 GUI 里打开 & 验证

**打开方式:设置 → 插件 → 顶部页签「OpenCode 上游钉选」**(与官方「全部插件」「dshmarket」页签并列,`order: 70`)。这一页由 `client.js` 注册进 `settings.plugins.tab` 提供,显示:

- 钉住的路由(provider/model/reasoningEffort)、`pin.route` 开关状态、session key 模式
- `config schema` / `会话选择对齐` 两个能力是否可用(绿=可用,黄=降级)
- 当前钉住的 key 与它对应的上游端点(`preferred`)、见过的所有上游端点及次数
- 会话 → key 分配表、最近探测记录、最近决定
- 三个按钮:**刷新**、**探测一轮**(`/opencode-pin/probe`)、**重新钉选**(`/opencode-pin/hunt`)

### 日常怎么用(通常什么都不用做)

| 你想做的事 | 怎么做 |
|---|---|
| 确认上游钉住了 | 面板看 `preferred` / `rememberedKeys`,或 `curl "localhost:19387/opencode-pin/probe?keys=<你配的 key>"` |
| 换一个上游端点 | 改 `session.key` 一行(见上一节对照表),HMR 立即生效 |
| 确认在钉路由(可选) | 打开该页签看「钉住的路由」;或在会话里手动切到别的模型(如 `muyuan`),下一步会被自动拉回 `opencode-go`(`recentDecisions` 会出现一条 `route-pin`) |
| 看当前上游端点 | 页签点「探测一轮」,或 `curl "localhost:19387/opencode-pin/probe?keys=manual-1"`;看 `x-opencode-endpoint-id` |
| 钉到偏好端点 | profile 的 `cordis.patch.yml` 里按行 id `opencode-pin` 覆盖 `session.mode: hunt` + `probe.prefer: ["luminal-*"]`(配置整体替换,要写全) |
| 临时不钉 | 设置→插件里停用 bundle `dsh-opencode-pin`;或 patch 里设 `pin.route: false`(只补 session、不强制路由) |
| 只钉主循环、放弃标题/压缩 | patch 里设 `pin.auxiliary: false` |
| 换钉的目标模型 | patch 里设 `pin.provider` / `pin.model` / `pin.reasoningEffort` |

> 页签与面板需要 `dsh.client` 在启动时扫描进客户端模块图;加上主机侧的 `ctx.inject` 修复,**重启一次应用**后两者一起生效。面板如果显示「主机路由还不可用」,就是这一版还没被加载。

也可以直接用 HTTP(同样的数据,不需要页面):

```bash
# 1) 插件视角:当前 pin 的路由、每个对话分配到的 key、最近的决定与探测结果
curl -s http://127.0.0.1:19387/opencode-pin/status | jq

# 2) 现场探测若干候选 key,看各自会落到哪个上游
curl -s "http://127.0.0.1:19387/opencode-pin/probe?keys=check-a,check-b,check-c" | jq

# 3) 立刻重跑一轮 hunt(忽略缓存)
curl -s http://127.0.0.1:19387/opencode-pin/hunt | jq

# 4) 独立证据脚本:不依赖 Harness,直接看服务端路由行为
node scripts/evidence.mjs --keys 6
```

`/opencode-pin/status` 会给出 `preferred`(当前钉住的 key 与端点)、`endpoints`(累计见过的端点及次数)、`assignments`、`recentDecisions`、`recentProbes`。

## 6. 已知边界(重要)

- **客户端无法指定端点**:实测请求侧 `x-opencode-endpoint-id` 会被忽略,UA 也不影响路由。能影响路由的只有 session key,所以「钉」= 挑一个落在好端点上的 key 并复用,而不是点名某个上游。
- **路由是服务端策略**:端点的存在与默认落点会随时间变化(实测几分钟内默认端点就从 `orcarouter` 变到 `novita`)。被 key 黏住的绑定也会漂移,`hunt` 模式靠重新验证+重挑来应对,不是硬保证。
- **探测会消耗极少额度**:每个探测 `max_tokens: 1`、prompt 约 40 token。`probe.prefer` 为空时插件**完全不发探测**。
- **`shared`/`hunt` 会让所有对话共用路由身份**,与官方「每会话一个稳定 session」的措辞不完全一致;换来的是上游不随对话漂移。二选一时官方建议优先 `conversation`。
- **图片调用**:`plan` 会检查目标模型是否支持图片;`agent/request` 阶段拿不到消息内容,所以把 `pin.model` 指到纯文本模型时,带图的主循环请求会被该模型按文本投影(DSH 行为)。默认目标 `deepseek-v4.1-flash` 支持图片。
- **只对配置里 `session.routes` 的路由改写 session key**:其他适配器(含官方 DeepSeek API 那条会按 `sessionId` 反查会话的扩展)保持真实会话身份。
- **观测面需要 webServer**:没挂 HTTP 服务时插件照常钉路由,只是没有 `/opencode-pin/*` 这三个端点。
- **静态替代方案**(不想装插件时):直接在 `llm-pi-ai` 的 `opencode-go` 路由上写死请求头即可全局恒定;pi-ai 发现已有该头就不再注入自己的值:
  ```yaml
  opencode-go:
    headers:
      x-opencode-session: dsh-pinned
  ```
  代价是永远只有一个路由身份,且无法随端点变化重挑。

## 7. 开发

```bash
node --test test/*.test.mjs     # 27 项:决策逻辑、宿主接线、客户端页签冒烟
node scripts/evidence.mjs       # 打真实请求取证
```

目录:

```text
index.js            宿主半:两个 waterfall 监听 + 观测路由
client.js           客户端半:设置→插件里的页签(Settings › Plugins,slot settings.plugins.tab)
lib/plan.mjs        纯决策函数(可单测,无 Harness 依赖)
lib/probe.mjs       凭据解析 + 1-token 路由探测
cordis.patch.yml    bundle 行(含默认配置)
locale/{zh,en}.json 插件卡片的显示名/描述
scripts/evidence.mjs 取证脚本
test/plan.test.mjs   纯决策单测
test/plugin.test.mjs 宿主半接线测试(假 ctx,含重派发防递归)
test/client.test.mjs 客户端半冒烟测试(React shim,无需浏览器)
```

## 8. 卸载

```text
plugin_manager { action: "remove_bundle", target: "dsh-opencode-pin" }
```

Install

dsh plugin --profile web add github:wobushiyoushen0214/dsh-opencode-pin

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source