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 的「会话 → 上游提供商」绑定在整段对话里不漂移。
---
[](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
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-opencode-pin from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.