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

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,38 @@
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.2-beta.0] - 2026-10-08
11
+
12
+ ### Added
13
+
14
+ - 注册表维护 CLI(`.opencode/skills/opencode-providers-registry/`)扩展:
15
+ - **复用共享模型(路线 C)**:`list`/`search`/`show` 现在暴露**顶层共享模型**(含未被引用的),新增模型前先 `search`,命中即用
16
+ `add-*/--base <lab>/<model>` 复用(不再逐项重问 limit/变体/模态);`add-*` 在参数与某共享模型相同时还会打一行软提示。
17
+ - **改**:`set-provider` / `set-model` / `set-shared-model`——字段补丁(只改传入字段)+ `--unset a,b` 清空。
18
+ - **删**:`remove-provider` / `remove-model` / `remove-shared-model`——带安全约束(不许删空 provider、不许删仍被 `base` 引用的共享模型)。
19
+ - **体检**:`check [--strict]`——揪出悬空 `base` 引用(会让运行期整家跳过)、孤儿共享模型、内联与共享重复、重复 baseURL、`input` 缺 text、空目录。
20
+ - CLI 拆分为入口 + `scripts/lib/` 子模块(`cli`/`store`/`spec`/`report`/`commands-{read,write}`),避免单文件膨胀。
21
+ - CI 增加 `registry.mjs check --strict` 一步。
22
+
23
+ ### Changed
24
+
25
+ - **文档按读者拆分**:`README.md` 收敛为使用者内容(安装 / 使用 / 疑难解答);新增 `CONTRIBUTING.md` 作开发者入口
26
+ (本地开发 / 架构要点 / 测试与验证 / 注册表维护 / 发版流程 / 约定);`AGENTS.md` 的发版与验证细节收敛为要点并指向
27
+ `CONTRIBUTING.md`。另按全局「文档标准与约束」落地:`AGENTS.md` 建立「文档地图」(文档 → 负责 / 不写,唯一索引),
28
+ `CONTRIBUTING.md` 只留一行链接;各文档顶部加职责声明;`docs/specs`、`docs/plans` 标记历史冻结。
29
+
30
+ ### Fixed
31
+
32
+ - **`/connect-providers` 强制刷新后模型 / 供应商列表不更新**:刷新成功只失效了 `integration` 列表,
33
+ 而 TUI 的模型选择器读的是 `ctx.data.location.model`,它**只被 `model.updated` 事件**驱动失效。
34
+ 服务端那条链(`Provider` → `Model` → `model.updated`)只从 `Integration.Event.Updated` / 凭据事件起跳,
35
+ 所以**给已有供应商加模型**时(integration 集合没变)链路不发 → 选择器一直显示旧模型
36
+ (新增一家供应商才正常,故只在「已有供应商加模型」时暴露)。
37
+ 改为刷新后由客户端主动失效并重取 **`integration` / `model` / `provider` 三件套**(`reloadCatalog`),
38
+ 不再依赖事件链。证据:`packages/core/src/provider.ts`(`Provider.notify` 只订阅 Integration/Credential)、
39
+ `packages/core/src/model.ts`(订阅 `Provider.Event.Updated` 后发 `model.updated`)、
40
+ `packages/tui/src/context/data.ts`(`model.updated` → 失效重取 `location.model`)
41
+
10
42
  ## [0.3.1] - 2026-10-07
11
43
 
12
44
  ### 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.0",
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",
@@ -134,9 +134,9 @@ async function doRefresh(ctx: Context): Promise<boolean> {
134
134
  return false
135
135
  }
136
136
  // `invalidate` alone only drops the sync marker — it does not refetch, so
137
- // `list()` would keep returning the stale integrations. `reloadIntegrations`
137
+ // `list()` would keep returning the stale integrations. `reloadCatalog`
138
138
  // also awaits the sync, so the reopened list shows the freshly registered set.
139
- await reloadIntegrations(ctx)
139
+ await reloadCatalog(ctx)
140
140
  ctx.ui.toast.show({
141
141
  variant: "success",
142
142
  message: `Registry refreshed: ${result.providers ?? 0} providers, ${result.models ?? 0} models`,
@@ -316,6 +316,27 @@ async function reloadIntegrations(ctx: Context): Promise<void> {
316
316
  }
317
317
  }
318
318
 
319
+ /**
320
+ * Drop every location collection a registry refresh can change, then re-read them.
321
+ *
322
+ * A refresh often adds models to an **existing** provider (the integration set stays the
323
+ * same). The server's `provider.updated` → `model.updated` chain only starts from an
324
+ * `Integration.Event.Updated` / credential change, so that path never reaches the TUI
325
+ * model list — the model selector would stay stale until restart. Invalidate the whole
326
+ * catalog here instead of relying on the event chain.
327
+ */
328
+ async function reloadCatalog(ctx: Context): Promise<void> {
329
+ const { integration, model, provider } = ctx.data.location
330
+ for (const collection of [integration, model, provider]) collection.invalidate(ctx.location)
331
+ await Promise.all(
332
+ [integration, model, provider].map((collection) =>
333
+ collection.sync(ctx.location).catch(() => {
334
+ // A failed sync still leaves `list()` readable; the next action retries.
335
+ }),
336
+ ),
337
+ )
338
+ }
339
+
319
340
  function ownIntegrations(ctx: Context): IntegrationInfo[] {
320
341
  return (ctx.data.location.integration.list(ctx.location) ?? []).filter(
321
342
  (integration) => integration.metadata?.source === INTEGRATION_SOURCE,