@justsilver/opencode-providers 0.3.1 → 0.3.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.2-beta.1] - 2026-10-08
11
+
12
+ ### Fixed
13
+
14
+ - **强制刷新未携带 location → 模型列表不更新(真正的根因)**:provider / model 注册**按 location 隔离**
15
+ (同一个 `/api/model` 按请求目录返回不同结果)。`doRefresh` 调 RPC 时没传 location,于是只刷到服务端
16
+ **默认目录**(`/api/location` 返回的那个),用户所在目录的注册一直停在旧状态 —— 现象是「默认目录下
17
+ `/api/model` 有新模型、模型选择器里没有」。改为 RPC 调用**带上当前 location**(`{ location: { directory } }`),
18
+ 刷新与随之发出的事件都落在用户所在目录。
19
+ 另:客户端刷新后同时失效并重取 `integration` / `model` / `provider`(`reloadCatalog`),不再只刷 integration。
20
+ (`0.3.2-beta.0` 只做了后者,**未修好**。)
21
+
22
+ ## [0.3.2-beta.0] - 2026-10-08
23
+
24
+ ### Added
25
+
26
+ - 注册表维护 CLI(`.opencode/skills/opencode-providers-registry/`)扩展:
27
+ - **复用共享模型(路线 C)**:`list`/`search`/`show` 现在暴露**顶层共享模型**(含未被引用的),新增模型前先 `search`,命中即用
28
+ `add-*/--base <lab>/<model>` 复用(不再逐项重问 limit/变体/模态);`add-*` 在参数与某共享模型相同时还会打一行软提示。
29
+ - **改**:`set-provider` / `set-model` / `set-shared-model`——字段补丁(只改传入字段)+ `--unset a,b` 清空。
30
+ - **删**:`remove-provider` / `remove-model` / `remove-shared-model`——带安全约束(不许删空 provider、不许删仍被 `base` 引用的共享模型)。
31
+ - **体检**:`check [--strict]`——揪出悬空 `base` 引用(会让运行期整家跳过)、孤儿共享模型、内联与共享重复、重复 baseURL、`input` 缺 text、空目录。
32
+ - CLI 拆分为入口 + `scripts/lib/` 子模块(`cli`/`store`/`spec`/`report`/`commands-{read,write}`),避免单文件膨胀。
33
+ - CI 增加 `registry.mjs check --strict` 一步。
34
+
35
+ ### Changed
36
+
37
+ - **文档按读者拆分**:`README.md` 收敛为使用者内容(安装 / 使用 / 疑难解答);新增 `CONTRIBUTING.md` 作开发者入口
38
+ (本地开发 / 架构要点 / 测试与验证 / 注册表维护 / 发版流程 / 约定);`AGENTS.md` 的发版与验证细节收敛为要点并指向
39
+ `CONTRIBUTING.md`。另按全局「文档标准与约束」落地:`AGENTS.md` 建立「文档地图」(文档 → 负责 / 不写,唯一索引),
40
+ `CONTRIBUTING.md` 只留一行链接;各文档顶部加职责声明;`docs/specs`、`docs/plans` 标记历史冻结。
41
+
42
+ ### Fixed
43
+
44
+ - **`/connect-providers` 强制刷新后模型 / 供应商列表不更新**(**仅部分修复**):刷新成功只失效了 `integration` 列表,
45
+ 改为同时失效并重取 **`integration` / `model` / `provider`** 三件套(`reloadCatalog`)。
46
+ ⚠️ **本版没真正修好**:真根因是 RPC 未携带 location(见 `0.3.2-beta.1`)。
47
+ 本版残留的错误结论是「服务端事件链不发 `model.updated`」——实测该事件**会发**,但事件带的是被调用的那个 location。
48
+
10
49
  ## [0.3.1] - 2026-10-07
11
50
 
12
51
  ### Fixed
package/README.md CHANGED
@@ -3,20 +3,7 @@
3
3
  给 [OpenCode](https://opencode.ai) 补上**模型目录里没有的供应商**:一份自维护的注册表 + 一个插件。
4
4
  装完插件、在 `/connect-providers` 里贴一次 API Key 就能用 —— **不需要写 `opencode.json`**。
5
5
 
6
- ```
7
- registry/index.json ← manifest:schemaVersion / revision / providers[](CLI 自动维护)
8
- registry/providers/<id>/ ← 每家:provider.json(供应商参数)+ models.json(该家模型)
9
- registry/models/<lab>/<model>.json ← 顶层共享模型(多家用 base 引用)
10
- plugin/opencode-providers/ ← 插件源码(npm 包的 server / tui 入口所在)
11
- ├── index.ts ← server 入口:拉注册表 → 注册 integration/provider/models
12
- ├── tui.ts ← TUI 入口:注册 /connect-providers 命令(不写 JSX,见下)
13
- ├── registry/ ← 纯逻辑:schema 校验 / 元数据映射 / 拉取缓存
14
- └── view/ ← /connect-providers 交互
15
- ```
16
-
17
- > 源码**故意不放在 `.opencode/plugins/` 下**:opencode 会自动发现该目录下的插件,
18
- > 那样在本仓库里跑 opencode 时就会和 npm/全局那份撞同一个插件 id,
19
- > 被 supervisor 判为 `Duplicate plugin ID: opencode-providers`,在 `/plugins` 面板里显示成一条 `failed`(实际加载的是另一条,功能正常)。
6
+ > **职责**(使用者):安装 / 使用 / 疑难解答。开发 / 架构 / 发版见 [CONTRIBUTING.md](./CONTRIBUTING.md)。
20
7
 
21
8
  ## 为什么需要它
22
9
 
@@ -37,17 +24,17 @@ OpenCode 的供应商清单来自 models.dev 目录,**目录里没有的供应
37
24
 
38
25
  等价的一条命令(帮你写进全局配置):`opencode plugin add @justsilver/opencode-providers`。
39
26
 
40
- 想**试某个测试版**就把版本写全(试完再回到正式版):
27
+ 想**固定某个版本**(例如先试预发布)就把版本写全(试完再回到不带版本、跟随 `latest`):
41
28
 
42
29
  ```jsonc
43
30
  {
44
- "plugins": ["@justsilver/opencode-providers@0.1.0-beta.2"]
31
+ "plugins": ["@justsilver/opencode-providers@0.3.1"]
45
32
  }
46
33
  ```
47
34
 
48
35
  - 预发布版本发布在 npm 的 **`next`** dist-tag 上,正式版才发 `latest`。
49
- - 升级 / 卸载(`<目标>` 就是配置里那串原样):`opencode plugin update @justsilver/opencode-providers` / `opencode plugin remove …`。
50
- - 本插件的 TUI 入口**不含 JSX**、不依赖 Solid,所以配置安装不会踩「双 Solid 运行时」的坑。
36
+ - 升级 / 卸载(`<目标>` 就是配置里那串原样):`opencode plugin update @justsilver/opencode-providers` / `opencode plugin remove @justsilver/opencode-providers`。
37
+ - 本插件的 TUI 入口**不含 JSX**、不依赖 Solid,所以配置安装不会踩「双 Solid 运行时」的坑(首帧后不刷新)。
51
38
  - 想指向**别的注册表**(自建/私有):配置改成对象条目传 options,不用改源码——
52
39
 
53
40
  ```jsonc
@@ -59,23 +46,6 @@ OpenCode 的供应商清单来自 models.dev 目录,**目录里没有的供应
59
46
  ```
60
47
 
61
48
  装完**通常无需重启**(插件被文件监视,覆盖后自动热重载);必要时 `opencode service restart`。
62
- 卸载:`opencode plugin remove @justsilver/opencode-providers`(或把 `plugins` 里那一行删掉)。
63
-
64
- ### 本地开发:直接跑工作树
65
-
66
- 不想先发版也能让 opencode 加载**当前工作树**——`plugins` 里给一个**绝对路径目录**即可
67
- (opencode 对绝对路径按「本地目录插件」处理;该目录需含 `index.ts` 与 `tui.ts`):
68
-
69
- ```jsonc
70
- {
71
- "plugins": [
72
- { "package": "E:/Code/Projects/Agent/opencode-providers/plugin/opencode-providers" }
73
- ]
74
- }
75
- ```
76
-
77
- 这样改完源码会被文件监视热重载,无需发布、也无需 `npm install`。
78
- (它和 npm 包**同 id**,别同时留着两条,否则 `/plugins` 面板会出现一条 `failed`。)
79
49
 
80
50
  ## 使用
81
51
 
@@ -87,11 +57,9 @@ OpenCode 的供应商清单来自 models.dev 目录,**目录里没有的供应
87
57
  - 触发方式与内置 `/connect` 相同:**在输入框敲 `/`,在补全菜单里选中它**(选中即执行);
88
58
  也可以在命令面板里搜 `Connect providers`(该命令注册了 `palette: true`)。
89
59
  手打全名再回车**不会**触发(与 `/connect` 一致),因为无参数命令只走补全菜单。
90
-
91
- 再运行一次可以添加第二个账号,或在已有账号之间切换。
92
-
93
- 在 `/connect-providers` 弹窗里按 `Ctrl+R`,或点弹窗底部那行 **Force refresh**,
94
- 即可绕过 6h TTL 立刻重拉注册表并重注册(上游拉不到时保留原有列表并报错,不会清空)。
60
+ - 再运行一次可以添加第二个账号,或在已有账号之间切换。
61
+ - 在 `/connect-providers` 弹窗里按 `Ctrl+R`,或点弹窗底部那行 **Force refresh**,
62
+ 即可绕过 6h TTL 立刻重拉注册表并重注册(上游拉不到时保留原有列表并报错,不会清空)。
95
63
 
96
64
  **为什么 `/models` 里一开始看不到新供应商**:provider 声明为 `activation: "auto"`,
97
65
  只有拿到凭据(你在 `/connect-providers` 里存过 key)后才会出现在 `/models`。
@@ -100,77 +68,6 @@ OpenCode 的供应商清单来自 models.dev 目录,**目录里没有的供应
100
68
  凭据存在 opencode 自己的 SQLite 里(`opencode debug paths db`),和内置 `/connect` 完全一致。
101
69
  API Key 只在你贴入时经过本插件的内存,不落任何本项目自己的文件。
102
70
 
103
- ## 维护注册表
104
-
105
- 改注册表源(`registry/index.json` / `registry/providers/**` / `registry/models/**`)提交后,**下一次这个插件被加载时**才会跟上:加载时若缓存已超过 6 小时(TTL)就重新拉取。
106
- 插件**没有后台定时器**,所以一个连着跑很久的服务不会自己刷新——想立刻生效就在 `/connect-providers` 弹窗里按 `Ctrl+R`(强制刷新),或 `opencode service restart`。
107
- 拉取先抓 manifest(`ETag`);`revision` 与缓存一致就不重拉子文件,变了才并发拉各分文件并重新聚合;网络失败时继续沿用本地缓存,不会把供应商列表清空。
108
-
109
- ### 结构
110
-
111
- 源按供应商拆分(`registry/index.json` 是 manifest,其 `revision` 与 `providers` 由维护脚本自动维护):
112
-
113
- ```
114
- registry/
115
- index.json # manifest:{ schemaVersion, revision, providers: ["my-gateway", …] }
116
- providers/my-gateway/
117
- provider.json # 供应商参数
118
- models.json # 该家模型(key = 模型 ID)
119
- models/some-lab/some-model.json # 顶层共享模型(可选;多家用 base 引用)
120
- ```
121
-
122
- `providers/<id>/provider.json`:
123
-
124
- ```jsonc
125
- {
126
- "name": "My Gateway",
127
- "package": "@opencode/ai/providers/openai-compatible",
128
- "baseURL": "https://llm.example.com/v1"
129
- }
130
- ```
131
-
132
- `providers/<id>/models.json`(key = 在 opencode 里使用的模型 ID;`base` 指向共享模型;`modelID` 是发给上游的真实 ID):
133
-
134
- ```jsonc
135
- {
136
- "some-model": { "base": "some-lab/some-model", "name": "Some Model", "limit": { "context": 131072, "output": 16384 } },
137
- "some-model-fast": { "base": "some-lab/some-model", "modelID": "some-model-2026-01" }
138
- }
139
- ```
140
-
141
- `registry/models/<lab>/<model>.json`(顶层共享模型,供应商无关):
142
-
143
- ```jsonc
144
- {
145
- "name": "Some Model",
146
- "family": "some-lab",
147
- "limit": { "context": 131072, "output": 16384 },
148
- "cost": { "input": 0.5, "output": 1.5, "cache_read": 0.05 },
149
- "tools": true,
150
- "input": ["text", "image"],
151
- "output": ["text"]
152
- }
153
- ```
154
-
155
- | 字段 | 说明 |
156
- |---|---|
157
- | manifest `schemaVersion` | 当前为 `1`;插件只接受自己支持的版本,不匹配就整份拒绝(不会半注册) |
158
- | manifest `revision` | `providers/**` + `models/**` 全部文件内容的确定性哈希;子文件改动会连带它一起变,插件据此决定是否重拉 |
159
- | `provider.json` `package` | 运行时包,如 `@opencode/ai/providers/openai-compatible`(**不要**用旧的 `aisdk:` / `@ai-sdk/*` 写法) |
160
- | `provider.json` `baseURL` | API 端点;与 `settings` 合并后作为 provider `settings` |
161
- | `provider.json` `keyLabel` | `/connect-providers` 里 API Key 输入框的提示,默认 `Paste API key`(技能 CLI 保持最小字段,不写它) |
162
- | `models.json` 的 key | 模型 ID(`provider/model` 里的 model 段);每项字段见下表 |
163
- | 模型 `base` | 引用顶层共享模型(值形如 `<lab>/<model>`,对应 `registry/models/<lab>/<model>.json`),先铺共享参数再用本项覆盖 |
164
- | 模型 `modelID` | 发给上游的真实模型/部署 ID,默认等于上面的 key |
165
- | 模型 `limit` | `context` / `output` 必填(无 `base` 时),`input` 可选 |
166
- | 模型 `cost` | 每百万 token 美元;`cache_read`/`cache_write` 可选 |
167
- | 模型 `tools` / `input` / `output` | 能力:是否支持工具调用、输入/输出模态,默认 `true` / `["text"]`;**`input` 要多模态必须显式写**(技能会用多选问你) |
168
- | 模型 `reasoningField` / `maxTokensField` | 映射到 `Model.Compatibility` |
169
- | 模型 `variants` | `[{ "id": "high", "settings": {} }]` |
170
- | 模型 `status` / `disabled` | 生命周期标记;`disabled: true` 不出现在 `/models` |
171
-
172
- 字段的权威校验是插件里的 `parseRegistry`;维护时用技能 CLI 的 `validate`(组装 → schema → `index.json`/目录/`revision` 一致性)即可,不再随仓放 JSON Schema 文件。
173
-
174
71
  ## 范围之外
175
72
 
176
73
  - **不做参数推断**:注册表写什么就是什么,插件不会去猜 `limit`/`cost`。
@@ -178,44 +75,6 @@ registry/
178
75
  - **不声明 `env` 认证**:本插件只走 `/connect`-式交互;opencode 的 env 是静默旁路且不在 `/connect` 里展示。
179
76
  - 不改注册表来源时**不需要** `opencode.json` 里的 `providers` 声明(那是另一条路线)。
180
77
 
181
- ## 开发
182
-
183
- ```bash
184
- node --test # 纯逻辑单测(Node ≥ 22 原生 TS,无需依赖)
185
- node .opencode/skills/opencode-providers-registry/scripts/registry.mjs validate # 分文件注册表:组装 + schema + index/revision 一致
186
- node scripts/smoke-api.mjs --list # 真机冒烟场景(HTTP,不需要 TUI)
187
- node scripts/smoke-api.mjs # 全跑:插件已加载 / 供应商已注册 / 凭据→模型→清理
188
- ```
189
-
190
- `smoke-api.mjs` 的鉴权自动读 `~/.local/state/opencode/service.json`;它的 `models` 场景会写入并删除一条
191
- 临时凭据(label `smoke-throwaway`),且只对「当前没有任何凭据」的供应商生效。
192
-
193
- 两个入口的打包/语法检查(TUI 入口的 `@opencode/plugin/tui` 是运行时注入的,必须标 `--external`):
194
-
195
- ```bash
196
- npx --yes esbuild plugin/opencode-providers/index.ts --bundle --platform=node --format=esm \
197
- --outfile=dist/providers-server.js
198
-
199
- npx --yes esbuild plugin/opencode-providers/tui.ts --bundle --platform=node --format=esm \
200
- --external:@opencode/plugin/tui --outfile=dist/providers-tui.js
201
- ```
202
-
203
- 改完插件文件**通常无需重启**:插件目录被文件监视,覆盖后自动热重载。
204
- 验证是否真的注册成功(用 opencode 自己的 API,不需要看 TUI):
205
-
206
- ```bash
207
- opencode api get /api/plugin # 自己那条 state.status 必须是 active(多于 1 条 = 同 id 被发现两次)
208
- opencode api get /api/integration # 注册的供应商(metadata.source = opencode-providers)
209
- opencode api get /api/model # 只列可用供应商的模型;没配 key 时不会出现
210
- ```
211
-
212
- **发布策略(预发布优先)**:`package.json` 的 `version` 是版本单一来源;
213
- 预发布(如 `0.1.0-beta.0`)发到 npm 的 **`next`** dist-tag,正式版必须手动确认才发 `latest`。
214
- 流水线:整理 `CHANGELOG.md` 的版本小节 → `npm version <x.y.z[-beta.n]> --no-git-tag-version` → commit →
215
- push tag `vX.Y.Z-beta.n`(或 Actions → Release → Run workflow 勾 `publish`;不勾只做 `npm pack --dry-run` 预检)。
216
- **首个版本必须人工发一次**(OIDC/trusted publisher 挂不到尚不存在的包上),命令与核对项见
217
- `docs/npm-distribution-and-testing.md` §5。
218
-
219
78
  ## 疑难解答
220
79
 
221
80
  | 现象 | 原因 / 处理 |
@@ -223,6 +82,12 @@ push tag `vX.Y.Z-beta.n`(或 Actions → Release → Run workflow 勾 `publish
223
82
  | `/plugins` 面板里本插件有**一条 `failed`** | 同一插件 id 被发现两次(例:`opencode.json` 里配了 npm 包,某个项目的 `.opencode/plugins/` 里又放了一份;或本地绝对路径与 npm 包同时留着)。supervisor 保留首个、把后来者标成 `failed`,错误信息是 `Duplicate plugin ID: opencode-providers`。删掉多余的那份即可 |
224
83
  | `/connect-providers` 说没有可用供应商 | 注册表没加载成功。看 server 日志里的 `[opencode-providers]`,并确认 `/api/plugin` 里自己那条 `state.status` 是 `active` |
225
84
  | `/models` 里看不到新供应商 | 正常:`activation: "auto"`,在 `/connect-providers` 里存过 key 之后才会出现 |
226
- | 改了插件代码没生效 | 改的是**工作树**,但 opencode 加载的是 npm 缓存里那份(`~/.cache/opencode/npm/@justsilver/opencode-providers@…/`)。开发时改用上面的「本地开发:直接跑工作树」把 `plugins` 指到工作树目录 |
85
+ | 注册表改了,`/models` 没跟上 | 插件**没有后台定时器**,只在加载时判一次 6h TTL。想立刻生效就在 `/connect-providers` 弹窗里按 `Ctrl+R`(Force refresh),或 `opencode service restart` |
86
+
87
+ ## 开发与贡献
88
+
89
+ 想改插件代码、维护注册表数据、或了解测试与发版流程:见 [CONTRIBUTING.md](./CONTRIBUTING.md)。
90
+
91
+ ## 许可
227
92
 
228
- 验证命令见上面的「开发」小节。
93
+ [MIT](./LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justsilver/opencode-providers",
3
- "version": "0.3.1",
3
+ "version": "0.3.2-beta.1",
4
4
  "type": "module",
5
5
  "description": "Register providers missing from the models.dev catalog in OpenCode V2 from a self-hosted registry — no opencode.json needed",
6
6
  "license": "MIT",
@@ -127,16 +127,22 @@ export function forceRefresh(ctx: Context): Promise<boolean> {
127
127
 
128
128
  async function doRefresh(ctx: Context): Promise<boolean> {
129
129
  try {
130
- const result = await ctx.client.rpc(registryRpc).refresh({})
130
+ // 注册是**按 location** 的:不限定 location 的 RPC 只会刷新服务端的**默认目录**,
131
+ // 用户所在的目录会一直停在旧的模型列表(且刷新出来的事件也落在默认目录)。
132
+ // 所以必须带上当前 location。
133
+ const directory = ctx.location?.directory
134
+ const result = await ctx.client
135
+ .rpc(registryRpc)
136
+ .refresh({}, directory === undefined ? undefined : { location: { directory } })
131
137
  if (!result.ok) {
132
138
  const detail = (result.errors ?? ["unknown error"]).join("; ")
133
139
  ctx.ui.toast.show({ variant: "error", message: `Registry refresh failed: ${detail}` })
134
140
  return false
135
141
  }
136
142
  // `invalidate` alone only drops the sync marker — it does not refetch, so
137
- // `list()` would keep returning the stale integrations. `reloadIntegrations`
143
+ // `list()` would keep returning the stale integrations. `reloadCatalog`
138
144
  // also awaits the sync, so the reopened list shows the freshly registered set.
139
- await reloadIntegrations(ctx)
145
+ await reloadCatalog(ctx)
140
146
  ctx.ui.toast.show({
141
147
  variant: "success",
142
148
  message: `Registry refreshed: ${result.providers ?? 0} providers, ${result.models ?? 0} models`,
@@ -316,6 +322,27 @@ async function reloadIntegrations(ctx: Context): Promise<void> {
316
322
  }
317
323
  }
318
324
 
325
+ /**
326
+ * Drop every location collection a registry refresh can change, then re-read them.
327
+ *
328
+ * A refresh often adds models to an **existing** provider (the integration set stays the
329
+ * same). The server's `provider.updated` → `model.updated` chain only starts from an
330
+ * `Integration.Event.Updated` / credential change, so that path never reaches the TUI
331
+ * model list — the model selector would stay stale until restart. Invalidate the whole
332
+ * catalog here instead of relying on the event chain.
333
+ */
334
+ async function reloadCatalog(ctx: Context): Promise<void> {
335
+ const { integration, model, provider } = ctx.data.location
336
+ for (const collection of [integration, model, provider]) collection.invalidate(ctx.location)
337
+ await Promise.all(
338
+ [integration, model, provider].map((collection) =>
339
+ collection.sync(ctx.location).catch(() => {
340
+ // A failed sync still leaves `list()` readable; the next action retries.
341
+ }),
342
+ ),
343
+ )
344
+ }
345
+
319
346
  function ownIntegrations(ctx: Context): IntegrationInfo[] {
320
347
  return (ctx.data.location.integration.list(ctx.location) ?? []).filter(
321
348
  (integration) => integration.metadata?.source === INTEGRATION_SOURCE,