dsh-pylon-opencode-free
自动生成并维护 OpenCode Go 的免费档 provider(opencode-go-free):
启动时问一次网关,把免费模型清单如实写进 profile —— 新增的补上,下架的删掉。
为什么需要它:DSH 没有"运行时拉取模型"这回事
一个 provider 的模型清单只有两个来源(dsh-llm-pi-ai/lib/index.js:642):
const configured = request.models ?? []
const entries = configured.length > 0 ? configured : [...catalogDefaults]
if (entries.length === 0) invalid(provider,
"resolves no models; the installed catalog does not describe this route,
so its models must be listed in configuration")
- 内置 catalog —— 按 provider 名字索引(
@earendil-works/pi-ai/dist/providers/data/<name>.json);或
- 配置里的
models: 列表。
没有第三条路。网关的 /v1/models 只是我们自己拿来看的,DSH 运行时不会去拉。
这解释了两件事:
| |
|---|
官方 opencode-go 为什么"填个 key 就能用" | 它的名字在内置 catalog 里(data/opencode-go.json) |
opencode-go-free 为什么必须手写清单 | 这个名字不在 catalog 里 ⇒ 只能写进配置 |
而网关会变,所以那份清单需要有人维护。这个插件就是那个人。
工作方式
启动时(syncOnStart: true):
GET <baseURL>/models(带 OPENCODE_GO_API_KEY,只读接口);
- 按免费判据筛出免费模型;
- 与 profile 里现有的清单比对;
- 有变化才写:新增的补上,下架的删掉;
- 写完读回核对,不对就整份盖回。
不碰路由。 路由指向哪个模型是路由自己的事。provider 的职责是如实反映网关 ——
一个已下架的模型留在 provider 里等于假装它还能用,代价是派发在更晚、更贵的地方失败。
免费判据:白名单,不是名字
/v1/models 不发布免费标志(只返回 id、零元数据),所以判据只能自己维护。
这个判据在本插件里换过三次,每一次都被真实账单推翻:
| 判据 | 怎么被推翻的 |
|---|
KNOWN_FREE = ['space-bunny']("实测例外") | 实测是真的,但保质期 8 天。网关 10-07 前后开始对它计费,它留在免费 provider 里,10-15 一天 $0.59、累计 $1.64 |
"命名带 -free 就算免费" | 官方定价表打脸:Step 5 Preview 定价 $1.00 / $2.70,而它的 API id 正是 step-5-preview-free |
| 现在:只认官方文档标 Free 的 | — |
名单是每次同步现场去文档取的,不是抄死的
插件启动时会去 https://opencode.ai/v2/docs/console/go 现场读一遍:
取模型清单里名字含独立 Free 词的,归一成 id 形状,再和网关的实时 /v1/models 对号——
两边都对得上才算数。取不到就退回静态白名单(src/free-provider.ts 的 VERIFIED_FREE),
不会因为网络问题就不同步。
为什么必须现场取:名单变得比想象中快。2026-10-16 实测,一小时之内
Cat Jam Free 从名单消失、Beam Free 出现,网关清单同步跟着变。
静态白名单只能保证"不误收计费的",保证不了"不漏收新免费的"。
两条日志要留意:
- 文档里有免费显示名、但网关清单里找不到对应 id 的 → 不猜,点名报出来(显示名与 API id 不保证一一对应)
- 网关上名字含
free、但文档没有列为免费的 → 点名警告(step-5-preview-free 就是这一类,官方定价 $1.00 / $2.70)
故意往"漏"的方向错
白名单会过期,而过期的后果有两种,代价严重不对称:
- 免费模型没被收进来 → 少用一个免费模型(看得见,代价为零)
- 计费模型被收进来 → 安静地花钱(这次就是这样)
所以策略是"不确定就不收",并且大声报出被排除的那些:
名字像 -free、却不在白名单里的模型(step-5-preview-free 那一类)会在日志里点名。
想加模型:先去上面那个表确认它写着 Free,再用配置里的 freeAllowlist 加进来。
不要凭"它看起来免费"往里加。
另一条独立证据:网关请求日志
免费与否过硬的证据是:这个模型成功调用过、且 cost 恰好为 0。
网关导出的请求日志每条都带 cost,那就是账本本身。本插件带了一个工具读它:
node --experimental-strip-types tools/classify-from-log.ts <导出的日志.json>
它会按 计费 / 免费(观测) / 样本不足 / 未知 分类。注意两个刻意的取舍:
成功调用收过钱就是铁证(哪怕大部分调用是 0);而成功次数少于 3 次不下结论
(step-5-preview-free 就是只有 1 次成功、cost 为 0 —— 一次零花费什么都证明不了)。
⚠️ "观测免费"仍然只是观测:space-bunny 就经历过"观测免费 → 后来开始计费"。
它用来交叉验证文档,不能当长期判据。
安全性
写路径:原子写 + 四条铁律(src/patch-guard.ts)
- 写前备份,落在 profile 之外,并读回校验 —— 备份进同一个 profile 会跟正本一起
被同一次故障毁掉;不读回校验的备份等于没有备份;
- 写入是原子的:临时文件 → fsync → rename。
writeFileSync 是"先 O_TRUNC 再写",
ENOSPC / EIO / 文件被锁时它会抛,而那时文件已经被截断了;
- 写后读回,必须逐字节等于刚写出的那一份 ——
next 本来就在手里,这个断言零成本,
且一次盖住"内容被换掉但条目数不变"这类所有形状;
- 任何一步失败都先回滚,并且回滚结果由读回比对算出,不是"调用过了"。
这不是假想的风险:2026-10-08 一次写配置返回成功,而 cordis.patch.yml
从 34 条目 / 63,570 B 变成 3 条目 / 661 B,没有任何环节发现。
这套安全网是被一次对抗性审查打穿后重写的
第一版只做了"备份 → 写 → 比条目数 → 少了就盖回"。审查用故障注入证明它有四个洞,
每一条现在都有对应的回归断言:
| 洞 | 第一版的行为 | 现在 |
|---|
| 写入抛错(文件已被截断) | 异常穿出去,回滚调用数 = 0,日志说"不影响宿主启动" | 整段包在 try 语义里,失败必回滚;原子写让"失败"根本不碰正本 |
| 回滚自己失败 | recovered 是字面量 true,无条件打印"已整份盖回" | 由读回比对算出;没恢复成功就明说"配置可能停在损坏态" |
| 校验不与刚写出的内容比对 | 只查"条目数变少 / 邻居模型数 / 非空" ⇒ 内容被换掉但计数不变轻松过关 | 第一位判据是 after === next |
bytes 采了不用 | 63,570 → 661 这条最刺眼的信号被浪费 | 纳入判定(低于写前 50% 即报警) |
另外补上:网关 id 的形状校验(审查实测出 YAML 注入:id 带换行能把顶层条目从 2 变 4
而守卫放行)、空 models: 块的缩进兜底(原来 repeat(-1) 抛 RangeError、
每次启动都抛且没有拒绝出口)、以及"models 块里有不认识的行就拒绝重写"
(原来注释会被静默丢弃,而顶层条目数与模型数全不变,守卫一个字都看不见)。
两个"不写"的情形
- 拉取失败(超时 / 5xx)→ 不写。一次没有答案的提问不构成"全部下架"的证据。
- 选出 0 个免费模型 → 不写。这条不是保守策略,是硬约束:DSH 对模型数为 0 的
provider 直接判非法(上面那段代码)。写进去不是"少了几个模型",是整份配置加载失败。
不在 settings 路径上新增消费点
本插件直接写 profile patch 文件,不经过 settings.mutate。DSH 的 HMR 用 chokidar
精确盯着 profile patch(dsh-hmr/lib/index.js:"Exact-path watching for live profile patch
files outside Cordis module roots"),所以写完即热加载。
并且 Config 里没有 volatile 字段 ⇒ dsh-settings 不会给这个条目建表单。
2026-10-08 正是"给 Config schema 加了 volatile 字段"让 DSH 冷启动直接起不来。
配置
- id: dsh-pylon-opencode-free
name: 'dsh-pylon-opencode-free'
config:
enabled: true
providerId: opencode-go-free
siblingId: opencode-go
api: openai-completions
baseURL: https://opencode.ai/zen/go/v1
sessionHeaderName: x-opencode-session
sessionHeaderValue: dsh-in-process-subagent
syncOnStart: true
# 每次同步去官方文档现场取免费名单(推荐开)。取不到自动退回下面的白名单。
freeListFromDocs: true
docsUrl: https://opencode.ai/v2/docs/console/go
# 兜底白名单。只在 freeListFromDocs 关掉、或文档取不到时生效。
freeAllowlist:
- longcat-2.5-preview-free
- beam-free
apiKeyEnv 与缩进从 siblingId 指的兄弟 provider 抄,不写死 ——
写死缩进是这类生成器最典型的退化方式(手下一次调整文件结构就会拼错位置,
而且 YAML 照样能解析,没人会发现)。
附带工具
| 工具 | 作用 |
|---|
tools/free-list-from-docs.ts | 读官方文档解析出当前免费名单,再和网关实时清单对号。名单会变(实测一小时内 Cat Jam Free 消失、Beam Free 出现),所以要定期重跑。 |
tools/classify-from-log.ts | 读网关请求日志判定谁真的收过钱:成功调用 cost > 0 就是铁证;成功不足 3 次不下结论。 |
tools/dryrun-check.ts | 只读试算:下一次同步会新增/删掉什么,一个字节都不写。 |
测试
node tools\_run-suites.mjs → 3/3 suites exited 0
| 套件 | 覆盖 |
|---|
tools/free-provider.test.ts | 解析 / 比对 / 渲染;幂等;对真 profile 的只读往返 |
tools/sync.test.ts | 同步路径 + 安全网,内存 IO 做故障注入(含"写入把文件截断") |
tools/e2e-temp-profile.ts | 真 patch-guard + 真文件系统 + 临时 profile:备份位置、no-op、回滚 |
三条都只碰 <tmp>/…,绝不碰操作者的真 ~/.dsh。
验证过的行为
- 原子 rename 仍会触发热加载(审查要求实测、不能假定)。依据是监听器自己的代码:
dsh-hmr/lib/index.js 把 add / change / unlink 三个事件绑到同一个 handler,
且配置监听默认开 awaitWriteFinish: true。所以 rename 无论被 chokidar 报成哪一种,
都会触发一次刷新。
- 真 DSH 冷启动已验证:插件被加载、apply 跑通、只收免费档(从网关 37 个 id 筛出 4 个)、
备份落在 profile 之外、二次运行逐字节 no-op 且不新增备份。
已知限制
- provider 块字段漂移只报不修:
api / baseURL / headers.x-opencode-session 被改坏时,
本插件只写 models 块,不会重写整块 —— 它会打一条明确的警告,但不修复。
- 模型条目里的元数据刻意不动:
contextWindow / maxTokens 是逐字保留的,
本插件不认识、也不该改操作员填好的值。所以日志说的是"模型清单已与网关一致",
不是"整份配置与网关一致"。
- 跨进程没有锁:同进程内(含热重载导致的重复
apply())不会交错、是安全的;
但两个宿主共享一个 DSH home 时,read-modify-write 之间没有 mtime/hash 检查。
- 备份轮转窗口是全目录共享的:多个 profile 共用一个 backupDir 时,
一个 profile 的健康备份可能被另一个挤掉(备份名已含毫秒 + pid,但仍是按 patch 文件名分组)。
- 免费判据靠命名(见上),会漏不带
-free 的免费模型。
- 新增模型只写
id + name 骨架 —— contextWindow / maxTokens / input
没有现成来源(实测那 4 个免费模型一个都不在内置 catalog 里),需要时人工补。
填错比不填更危险:填大了会被派发超出真实上限的请求。
- 本插件只维护 provider 条目,不修路由、不碰额度闸门。