@justsilver/opencode-providers 0.1.0-beta.2 → 0.2.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,62 @@
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-10-07
11
+
12
+ ### Added
13
+
14
+ - `/connect-providers` 弹窗新增**强制刷新**:footer 动作 + 快捷键 `ctrl+r`(与内置弹窗一致的键位提示),
15
+ 经 server RPC(纯 JSON Schema)绕过 6h TTL 重拉注册表并 `provider`/`integration` `reload()`;上游不可达时保留原有列表并报错
16
+ - `tests/setup.test.ts` 新用例:钉住 `ctx.options.registryUrl` —— 配置里把 `plugins` 写成对象条目传 `options` 即可换注册表地址,
17
+ 不用改源码(`schema/src/config/plugin.ts` 证明 config 条目支持 `options`)
18
+
19
+ ### Changed
20
+
21
+ - 评审修复:`revision` 对 CRLF/LF 归一(Windows 检出与 Linux CI 算出同一个哈希);路径段校验收紧(单段字段禁 `/`、`base` 必须恰为 `<lab>/<model>`);`add-provider` 缺 `--model-name` 时不再拿供应商名顶替模型名;`ctx.rpc` 缺失或刷新失败不再拖垮 provider 注册;强制刷新失败不再谎报「已重载」;stale 回落保留 `warnings`
22
+ - 模型**能力**改为技能**多选问出后显式写入**(CLI `--input text,image`);`tools` 不再写入注册表、默认继承上游
23
+ - **破坏性变更:注册表改为按供应商分文件 + 插件运行期聚合。** 源 = `registry/index.json`(manifest:`schemaVersion`/`revision`/`providers`)
24
+ + `registry/providers/<id>/{provider,models}.json` + 顶层共享模型 `registry/models/<lab>/<model>.json`;插件拉 manifest、按 `revision`
25
+ 决定是否重拉子文件,聚合成旧的 `{schemaVersion, models, providers}` 后写 kv(TTL / `ETag` / 失败沿用缓存语义不变)。
26
+ 插件默认地址改为 `.../registry/index.json`;旧的单文件 `registry/registry.json` 与 `registry/registry.schema.json` 已删除。
27
+ - **旧版本(≤ 0.1.0)拉旧地址会 404** → 沿用本地缓存(列表不清空,但不再更新),需升级到本版才能继续拿到更新。
28
+ - 维护脚本(`.opencode/skills/.../scripts/registry.mjs`)改写为操作分文件:新增 `sync`(重算 `revision` + 重写 `index.json`)、
29
+ `--root <注册表目录>`(原 `--registry`),`add-shared-model` 改 `--lab` + `--key`;`base`/`lab`/`key`/`id` 逐段校验(拒 Windows 非法字符)
30
+ - 清理死代码:去掉 `INTEGRATION_SOURCE` / `DEFAULT_TIMEOUT_MS` 的多余 `export`;删除 `/connect-providers` 里
31
+ 不可达的 OAuth「Sign in required」分支(注册表只声明 `key`,pending 连接不可能出现)
32
+ - 文档按源码复核:注册表缓存的**位置/TTL/重拉条件**(全局 `kv` 表、键含 URL、无后台定时器)、
33
+ **模型列表进 `/model` 的四层链路**、opencode 内核 TTL 对照;README 修正 `tui.ts` 文件名与「最迟 6 小时自动跟上」的错误说法
34
+
35
+ ## [0.1.0] - 2026-10-07
36
+
37
+ > 首个**正式版**(由 `0.1.0-beta.0` → `0.1.0-beta.2` 三个预发布版本稳定而来);npm 的 `latest` 指向它。
38
+
39
+ ### Added
40
+
41
+ - 自维护注册表 `registry/registry.json` + `registry/registry.schema.json`:共享 `models` 表 + provider `base` 引用/覆盖,
42
+ 带 `schemaVersion` —— 改数据**不需要**发插件版本
43
+ - 注册表收入两家真实供应商:**Command Code**(`https://api.commandcode.ai/provider/v1`)与
44
+ **R4 Coder**(`https://api.r4.codes/v1`)。二者共享 `deepseek-v4.1-flash`
45
+ (context 1048576 / output 393216,`reasoningEffort` 变体 low/high/max);Command Code 用 `modelID` 覆盖成 `deepseek/deepseek-v4.1-flash`
46
+ - server 入口:按注册表注册 integration(只声明 `key` 方法)与 provider(`activation: "auto"`)及其模型;
47
+ 拉取 6h TTL + `ETag` + 失败沿用旧缓存(`ctx.storage`)
48
+ - TUI 入口 `/connect-providers`:选供应商 → 贴 API Key / 切换账号,与内置 `/connect` 共用同一张凭据表
49
+ - 两种安装方式:npm 包(`"plugins": ["@justsilver/opencode-providers"]`)与安装脚本(整目录复制到
50
+ `~/.config/opencode/plugins/`,支持 `--local` / `-Local` 从工作树部署)
51
+ - 预发布优先的发版流水线:CD 走 OIDC(自动签名 provenance),预发布发 npm 的 `next`;
52
+ 正式版必须**手动** Run workflow 勾 `publish` + `stable`(该次运行发布到 `latest` 并补 tag + GitHub Release)
53
+ - `.opencode/skills/opencode-providers-registry`:维护注册表的技能(两条路由 + 最小字段铁律 + 协议 → package)
54
+
55
+ ### Fixed
56
+
57
+ - **`/connect-providers` 在输入框里"看不见"**:命令图层补 `mode: "global"`(图层默认 `base`,而提示框 push 的是
58
+ `composer`/补全时 `autocomplete` → 不可达,命令不进补全列表)
59
+ - 仓库自身不再是插件发现根(源码移到 `plugin/opencode-providers/`),避免在仓库里跑 opencode 时与全局安装**同 id 相撞**
60
+ (supervisor 会把后者标成 `failed` + `Duplicate plugin ID`)
61
+
62
+ ### Changed
63
+
64
+ - 注册表移出仓库根:`registry.json` → `registry/registry.json`(`$id` 同步);插件默认拉取地址与**缓存键**同步改为含 URL,换地址立即重拉
65
+
10
66
  ## [0.1.0-beta.2] - 2026-10-07
11
67
 
12
68
  ### Added
@@ -58,7 +114,8 @@
58
114
  - 插件源码从 `.opencode/plugins/opencode-providers/` 移到 `plugin/opencode-providers/`:仓库自身不再是插件发现根,避免在仓库里跑 opencode 时与全局安装的同 id 副本相撞(`Duplicate plugin ID` → 面板里一条 `failed`)
59
115
  - 安装后**通常无需重启**(插件目录被文件监视热重载),不再要求 `opencode service restart`
60
116
 
61
- [Unreleased]: https://github.com/Just-Silver/opencode-providers/compare/v0.1.0-beta.2...HEAD
117
+ [Unreleased]: https://github.com/Just-Silver/opencode-providers/compare/v0.1.0...HEAD
118
+ [0.1.0]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.1.0
62
119
  [0.1.0-beta.2]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.1.0-beta.2
63
120
  [0.1.0-beta.1]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.1.0-beta.1
64
121
  [0.1.0-beta.0]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.1.0-beta.0
package/README.md CHANGED
@@ -4,11 +4,12 @@
4
4
  装完插件、在 `/connect-providers` 里贴一次 API Key 就能用 —— **不需要写 `opencode.json`**。
5
5
 
6
6
  ```
7
- registry/registry.json ← 唯一事实源:供应商 + 模型 + 参数(GitHub raw 托管)
8
- registry/registry.schema.json ← 给编辑器校验 registry.json
7
+ registry/index.json ← manifest:schemaVersion / revision / providers[](CLI 自动维护)
8
+ registry/providers/<id>/ ← 每家:provider.json(供应商参数)+ models.json(该家模型)
9
+ registry/models/<lab>/<model>.json ← 顶层共享模型(多家用 base 引用)
9
10
  plugin/opencode-providers/ ← 插件源码(安装时整目录复制到 ~/.config/opencode/plugins/)
10
11
  ├── index.ts ← server 入口:拉注册表 → 注册 integration/provider/models
11
- ├── tui.tsx ← TUI 入口:注册 /connect-providers 命令
12
+ ├── tui.ts ← TUI 入口:注册 /connect-providers 命令(不写 JSX,见下)
12
13
  ├── registry/ ← 纯逻辑:schema 校验 / 元数据映射 / 拉取缓存
13
14
  └── view/ ← /connect-providers 交互
14
15
  ```
@@ -34,17 +35,28 @@ OpenCode 的供应商清单来自 models.dev 目录,**目录里没有的供应
34
35
  }
35
36
  ```
36
37
 
38
+ 等价的一条命令(帮你写进全局配置):`opencode plugin add @justsilver/opencode-providers`。
39
+
37
40
  想**试某个测试版**就把版本写全(试完再回到正式版):
38
41
 
39
42
  ```jsonc
40
43
  {
41
- "plugins": ["@justsilver/opencode-providers@0.1.0-beta.0"]
44
+ "plugins": ["@justsilver/opencode-providers@0.1.0-beta.2"]
42
45
  }
43
46
  ```
44
47
 
45
48
  - 预发布版本发布在 npm 的 **`next`** dist-tag 上,正式版才发 `latest`。
46
49
  - 升级 / 卸载(`<目标>` 就是配置里那串原样):`opencode plugin update @justsilver/opencode-providers` / `opencode plugin remove …`。
47
50
  - 本插件的 TUI 入口**不含 JSX**、不依赖 Solid,所以配置安装不会踩「双 Solid 运行时」的坑。
51
+ - 想指向**别的注册表**(自建/私有):配置改成对象条目传 options,不用改源码——
52
+
53
+ ```jsonc
54
+ {
55
+ "plugins": [
56
+ { "package": "@justsilver/opencode-providers", "options": { "registryUrl": "https://example.com/registry/index.json" } }
57
+ ]
58
+ }
59
+ ```
48
60
 
49
61
  ### 方式 B:安装脚本(零依赖备用路径)
50
62
 
@@ -89,6 +101,9 @@ pwsh -NoProfile -File .\install.ps1 -Local
89
101
 
90
102
  再运行一次可以添加第二个账号,或在已有账号之间切换。
91
103
 
104
+ 在 `/connect-providers` 弹窗里按 `Ctrl+R`,或点弹窗底部那行 **Force refresh**,
105
+ 即可绕过 6h TTL 立刻重拉注册表并重注册(上游拉不到时保留原有列表并报错,不会清空)。
106
+
92
107
  **为什么 `/models` 里一开始看不到新供应商**:provider 声明为 `activation: "auto"`,
93
108
  只有拿到凭据(你在 `/connect-providers` 里存过 key)后才会出现在 `/models`。
94
109
  这样就算注册表里有几十家供应商,也只会显示你真正配了 key 的那些。
@@ -98,63 +113,74 @@ API Key 只在你贴入时经过本插件的内存,不落任何本项目自己
98
113
 
99
114
  ## 维护注册表
100
115
 
101
- 改 `registry/registry.json` 提交后,运行中的 opencode 最迟 6 小时(TTL)自动跟上;想立刻生效就重启服务。
102
- 拉取使用 `ETag`,内容没变时不会重复下载;网络失败时继续沿用本地缓存,不会把供应商列表清空。
116
+ 改注册表源(`registry/index.json` / `registry/providers/**` / `registry/models/**`)提交后,**下一次这个插件被加载时**才会跟上:加载时若缓存已超过 6 小时(TTL)就重新拉取。
117
+ 插件**没有后台定时器**,所以一个连着跑很久的服务不会自己刷新——想立刻生效就在 `/connect-providers` 弹窗里按 `Ctrl+R`(强制刷新),或 `opencode service restart`。
118
+ 拉取先抓 manifest(`ETag`);`revision` 与缓存一致就不重拉子文件,变了才并发拉各分文件并重新聚合;网络失败时继续沿用本地缓存,不会把供应商列表清空。
103
119
 
104
120
  ### 结构
105
121
 
122
+ 源按供应商拆分(`registry/index.json` 是 manifest,其 `revision` 与 `providers` 由维护脚本自动维护):
123
+
124
+ ```
125
+ registry/
126
+ index.json # manifest:{ schemaVersion, revision, providers: ["my-gateway", …] }
127
+ providers/my-gateway/
128
+ provider.json # 供应商参数
129
+ models.json # 该家模型(key = 模型 ID)
130
+ models/some-lab/some-model.json # 顶层共享模型(可选;多家用 base 引用)
131
+ ```
132
+
133
+ `providers/<id>/provider.json`:
134
+
106
135
  ```jsonc
107
136
  {
108
- "schemaVersion": 1,
109
- "models": {
110
- // 供应商无关的模型参数,写一份,被下面 provider 用 "base" 引用
111
- "some-model": {
112
- "name": "Some Model",
113
- "limit": { "context": 131072, "output": 16384 },
114
- "cost": { "input": 0.5, "output": 1.5, "cache_read": 0.05 },
115
- "tools": true,
116
- "input": ["text", "image"],
117
- "output": ["text"]
118
- }
119
- },
120
- "providers": {
121
- "my-gateway": {
122
- "name": "My Gateway",
123
- "package": "@opencode/ai/providers/openai-compatible",
124
- "baseURL": "https://llm.example.com/v1",
125
- "keyLabel": "Paste API key",
126
- "models": {
127
- // key = 在 opencode 里使用的模型 ID;base 指向上面那份共享参数
128
- "some-model": { "base": "some-model" },
129
- // 覆盖任意字段;modelID 是发给上游的真实 ID
130
- "some-model-fast": {
131
- "base": "some-model",
132
- "modelID": "some-model-2026-01",
133
- "cost": { "input": 0.2, "output": 0.8 }
134
- }
135
- }
136
- }
137
- }
137
+ "name": "My Gateway",
138
+ "package": "@opencode/ai/providers/openai-compatible",
139
+ "baseURL": "https://llm.example.com/v1"
140
+ }
141
+ ```
142
+
143
+ `providers/<id>/models.json`(key = 在 opencode 里使用的模型 ID;`base` 指向共享模型;`modelID` 是发给上游的真实 ID):
144
+
145
+ ```jsonc
146
+ {
147
+ "some-model": { "base": "some-lab/some-model", "name": "Some Model", "limit": { "context": 131072, "output": 16384 } },
148
+ "some-model-fast": { "base": "some-lab/some-model", "modelID": "some-model-2026-01" }
149
+ }
150
+ ```
151
+
152
+ `registry/models/<lab>/<model>.json`(顶层共享模型,供应商无关):
153
+
154
+ ```jsonc
155
+ {
156
+ "name": "Some Model",
157
+ "family": "some-lab",
158
+ "limit": { "context": 131072, "output": 16384 },
159
+ "cost": { "input": 0.5, "output": 1.5, "cache_read": 0.05 },
160
+ "tools": true,
161
+ "input": ["text", "image"],
162
+ "output": ["text"]
138
163
  }
139
164
  ```
140
165
 
141
166
  | 字段 | 说明 |
142
167
  |---|---|
143
- | `schemaVersion` | 当前为 `1`;插件只接受自己支持的版本,不匹配就整份拒绝(不会半注册) |
144
- | `providers.<id>.package` | 运行时包,如 `@opencode/ai/providers/openai-compatible`(**不要**用旧的 `aisdk:` / `@ai-sdk/*` 写法) |
145
- | `providers.<id>.baseURL` | API 端点;与 `settings` 合并后作为 provider `settings` |
146
- | `providers.<id>.keyLabel` | `/connect-providers` 里 API Key 输入框的提示,默认 `Paste API key` |
147
- | `providers.<id>.models` | key = 模型 ID(`provider/model` 里的 model 段);每项字段见下表 |
148
- | 模型 `base` | 引用顶层 `models` 的 key,先铺共享参数再用本项覆盖 |
168
+ | manifest `schemaVersion` | 当前为 `1`;插件只接受自己支持的版本,不匹配就整份拒绝(不会半注册) |
169
+ | manifest `revision` | `providers/**` + `models/**` 全部文件内容的确定性哈希;子文件改动会连带它一起变,插件据此决定是否重拉 |
170
+ | `provider.json` `package` | 运行时包,如 `@opencode/ai/providers/openai-compatible`(**不要**用旧的 `aisdk:` / `@ai-sdk/*` 写法) |
171
+ | `provider.json` `baseURL` | API 端点;与 `settings` 合并后作为 provider `settings` |
172
+ | `provider.json` `keyLabel` | `/connect-providers` 里 API Key 输入框的提示,默认 `Paste API key`(技能 CLI 保持最小字段,不写它) |
173
+ | `models.json` 的 key | 模型 ID(`provider/model` 里的 model 段);每项字段见下表 |
174
+ | 模型 `base` | 引用顶层共享模型(值形如 `<lab>/<model>`,对应 `registry/models/<lab>/<model>.json`),先铺共享参数再用本项覆盖 |
149
175
  | 模型 `modelID` | 发给上游的真实模型/部署 ID,默认等于上面的 key |
150
176
  | 模型 `limit` | `context` / `output` 必填(无 `base` 时),`input` 可选 |
151
177
  | 模型 `cost` | 每百万 token 美元;`cache_read`/`cache_write` 可选 |
152
- | 模型 `tools` / `input` / `output` | 能力:是否支持工具调用、输入/输出模态,默认 `true` / `["text"]` |
178
+ | 模型 `tools` / `input` / `output` | 能力:是否支持工具调用、输入/输出模态,默认 `true` / `["text"]`;**`input` 要多模态必须显式写**(技能会用多选问你) |
153
179
  | 模型 `reasoningField` / `maxTokensField` | 映射到 `Model.Compatibility` |
154
180
  | 模型 `variants` | `[{ "id": "high", "settings": {} }]` |
155
181
  | 模型 `status` / `disabled` | 生命周期标记;`disabled: true` 不出现在 `/models` |
156
182
 
157
- `registry/registry.schema.json` 是同一份规则的 JSON Schema,编辑器可直接校验。
183
+ 字段的权威校验是插件里的 `parseRegistry`;维护时用技能 CLI 的 `validate`(组装 → schema → `index.json`/目录/`revision` 一致性)即可,不再随仓放 JSON Schema 文件。
158
184
 
159
185
  ## 范围之外
160
186
 
@@ -167,6 +193,7 @@ API Key 只在你贴入时经过本插件的内存,不落任何本项目自己
167
193
 
168
194
  ```bash
169
195
  node --test # 纯逻辑单测(Node ≥ 22 原生 TS,无需依赖)
196
+ node .opencode/skills/opencode-providers-registry/scripts/registry.mjs validate # 分文件注册表:组装 + schema + index/revision 一致
170
197
  node scripts/smoke-api.mjs --list # 真机冒烟场景(HTTP,不需要 TUI)
171
198
  node scripts/smoke-api.mjs # 全跑:插件已加载 / 供应商已注册 / 凭据→模型→清理
172
199
  ```
@@ -174,14 +201,14 @@ node scripts/smoke-api.mjs # 全跑:插件已加载 / 供应商已注
174
201
  `smoke-api.mjs` 的鉴权自动读 `~/.local/state/opencode/service.json`;它的 `models` 场景会写入并删除一条
175
202
  临时凭据(label `smoke-throwaway`),且只对「当前没有任何凭据」的供应商生效。
176
203
 
177
- 两个入口的打包/语法检查(两个入口都不 import 外部运行时依赖):
204
+ 两个入口的打包/语法检查(TUI 入口的 `@opencode/plugin/tui` 是运行时注入的,必须标 `--external`):
178
205
 
179
206
  ```bash
180
207
  npx --yes esbuild plugin/opencode-providers/index.ts --bundle --platform=node --format=esm \
181
208
  --outfile=dist/providers-server.js
182
209
 
183
210
  npx --yes esbuild plugin/opencode-providers/tui.ts --bundle --platform=node --format=esm \
184
- --outfile=dist/providers-tui.js
211
+ --external:@opencode/plugin/tui --outfile=dist/providers-tui.js
185
212
  ```
186
213
 
187
214
  改完插件文件**通常无需重启**:插件目录被文件监视,覆盖后自动热重载。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justsilver/opencode-providers",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.2.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",
@@ -10,22 +10,30 @@
10
10
  * - `Provider.ID.make` / `Integration.ID.make` are identity at runtime
11
11
  * - `Provider.Info.empty(id)` is `{ id, name: id, activation: "auto", package: "" }`
12
12
  * and every field is overridden below anyway
13
+ * - `Rpc.define` is the identity function and plain JSON Schema is accepted,
14
+ * so `rpc.ts` needs no `@opencode/*` either
13
15
  *
14
16
  * It registers an integration (single `key` method) and a provider
15
17
  * (`activation: "auto"`) per registry entry. Credentials are never touched:
16
18
  * opencode injects them by matching `provider.integrationID` to the integration
17
19
  * the key was stored under. No `env` method is declared on purpose — env is a
18
20
  * silent, non-interactive path and this plugin is `/connect`-only.
21
+ *
22
+ * The registry lives in a closure-local `state`, and both transforms read it at
23
+ * call time, so the force-refresh RPC can populate a registry that was
24
+ * unavailable at startup.
19
25
  */
20
26
 
21
27
  import { buildProviderModels, buildProviderSettings } from "./registry/models.ts"
22
28
  import { DEFAULT_REGISTRY_URL, loadRegistry } from "./registry/source.ts"
23
- import type { RegistryCacheEntry } from "./registry/source.ts"
29
+ import type { RegistryCacheEntry, RegistryStore } from "./registry/source.ts"
30
+ import type { Registry } from "./registry/schema.ts"
31
+ import { registryRpc, type RefreshSummary } from "./rpc.ts"
24
32
 
25
33
  const PLUGIN_ID = "opencode-providers"
26
34
 
27
- /** Tags integrations so the TUI entry can recognise its own. */
28
- export const INTEGRATION_SOURCE = PLUGIN_ID
35
+ /** Tags integrations so the TUI entry can recognise its own (TUI side keeps its own copy of this string). */
36
+ const INTEGRATION_SOURCE = PLUGIN_ID
29
37
 
30
38
  interface IntegrationRefLike {
31
39
  id: string
@@ -48,8 +56,21 @@ interface SetupContextLike {
48
56
  get(key: string): Promise<unknown>
49
57
  set(key: string, value: unknown): Promise<void>
50
58
  }
51
- readonly integration: { transform(callback: (editor: IntegrationEditorLike) => void): Promise<unknown> }
52
- readonly provider: { transform(callback: (editor: ProviderEditorLike) => void): Promise<unknown> }
59
+ readonly integration: {
60
+ transform(callback: (editor: IntegrationEditorLike) => void): Promise<unknown>
61
+ reload(): Promise<unknown>
62
+ }
63
+ readonly provider: {
64
+ transform(callback: (editor: ProviderEditorLike) => void): Promise<unknown>
65
+ reload(): Promise<unknown>
66
+ }
67
+ /** Absent on hosts/versions without the RPC domain; force refresh then degrades to unavailable. */
68
+ readonly rpc?: {
69
+ register(
70
+ definition: unknown,
71
+ handlers: Record<string, (input: unknown, context: unknown) => Promise<unknown>>,
72
+ ): Promise<unknown>
73
+ }
53
74
  }
54
75
 
55
76
  export default {
@@ -61,32 +82,67 @@ export default {
61
82
  const url = typeof ctx.options.registryUrl === "string" ? ctx.options.registryUrl : DEFAULT_REGISTRY_URL
62
83
  // Cache per URL: switching the registry URL must not keep serving the old body for a TTL.
63
84
  const cacheKey = `registry-cache:${url}`
64
- const result = await loadRegistry({
65
- url,
66
- fetch: globalThis.fetch,
67
- store: {
68
- get: async () => asCacheEntry(await ctx.storage.get(cacheKey)),
69
- set: async (entry) => {
70
- await ctx.storage.set(cacheKey, entry)
71
- },
85
+ const store: RegistryStore = {
86
+ get: async () => asCacheEntry(await ctx.storage.get(cacheKey)),
87
+ set: async (entry) => {
88
+ await ctx.storage.set(cacheKey, entry)
72
89
  },
73
- })
90
+ }
74
91
 
75
- if (!result.ok) {
76
- console.error(`[${PLUGIN_ID}] registry unavailable (${url}): ${result.errors.join("; ")}`)
77
- return
92
+ // Closure-local: repeated `setup` calls (config hot reload) must not share state.
93
+ const state: { registry?: Registry } = {}
94
+
95
+ // Force refresh: bypass the TTL, re-register, and tell the TUI what it got.
96
+ // A `stale-cache` result means upstream was unreachable, so it counts as a
97
+ // failure and leaves `state` (and the registered providers) untouched.
98
+ const refresh = async (): Promise<RefreshSummary> => {
99
+ const result = await loadRegistry({ url, fetch: globalThis.fetch, store, force: true })
100
+ if (!result.ok) return { ok: false, errors: [...result.errors] }
101
+ if (result.source === "stale-cache") {
102
+ return { ok: false, errors: ["registry refresh failed; serving the cached copy"] }
103
+ }
104
+ state.registry = result.registry
105
+ await ctx.integration.reload()
106
+ await ctx.provider.reload()
107
+ return {
108
+ ok: true,
109
+ providers: Object.keys(result.registry.providers).length,
110
+ models: countModels(result.registry),
111
+ source: result.source,
112
+ fetchedAt: result.fetchedAt,
113
+ }
78
114
  }
79
- if (result.source === "stale-cache") {
115
+
116
+ try {
117
+ await ctx.rpc?.register(registryRpc, { refresh })
118
+ } catch (error) {
119
+ // Refresh being unavailable must never block provider registration.
80
120
  console.warn(
81
- `[${PLUGIN_ID}] registry refresh failed; serving cache from ${new Date(result.fetchedAt).toISOString()}`,
121
+ `[${PLUGIN_ID}] rpc refresh unavailable: ${error instanceof Error ? error.message : String(error)}`,
82
122
  )
83
123
  }
84
124
 
85
- const { registry } = result
86
- const entries = Object.entries(registry.providers)
125
+ const result = await loadRegistry({ url, fetch: globalThis.fetch, store })
126
+ if (!result.ok) {
127
+ console.error(`[${PLUGIN_ID}] registry unavailable (${url}): ${result.errors.join("; ")}`)
128
+ } else {
129
+ if (result.source === "stale-cache") {
130
+ console.warn(
131
+ `[${PLUGIN_ID}] registry refresh failed; serving cache from ${new Date(result.fetchedAt).toISOString()}`,
132
+ )
133
+ }
134
+ if (result.warnings.length > 0) {
135
+ console.warn(`[${PLUGIN_ID}] skipped registry entries: ${result.warnings.join("; ")}`)
136
+ }
137
+ state.registry = result.registry
138
+ }
87
139
 
140
+ // Always register both transforms (no-op while `state.registry` is empty), so a
141
+ // later force refresh can recover from a registry that was unavailable at boot.
88
142
  await ctx.integration.transform((editor) => {
89
- for (const [id, provider] of entries) {
143
+ const registry = state.registry
144
+ if (registry === undefined) return
145
+ for (const [id, provider] of Object.entries(registry.providers)) {
90
146
  editor.update(id, (integration) => {
91
147
  integration.name = provider.name
92
148
  // `metadata` is part of the integration ref and surfaces in `Integration.Info`,
@@ -104,7 +160,9 @@ export default {
104
160
  })
105
161
 
106
162
  await ctx.provider.transform((editor) => {
107
- for (const [id, provider] of entries) {
163
+ const registry = state.registry
164
+ if (registry === undefined) return
165
+ for (const [id, provider] of Object.entries(registry.providers)) {
108
166
  const models = buildProviderModels(registry, id, provider)
109
167
  if (models.length === 0) continue
110
168
  const settings = buildProviderSettings(provider)
@@ -125,10 +183,17 @@ export default {
125
183
  },
126
184
  }
127
185
 
186
+ function countModels(registry: Registry): number {
187
+ let total = 0
188
+ for (const provider of Object.values(registry.providers)) total += Object.keys(provider.models).length
189
+ return total
190
+ }
191
+
128
192
  function asCacheEntry(value: unknown): RegistryCacheEntry | undefined {
129
193
  if (typeof value !== "object" || value === null) return undefined
130
194
  const entry = value as Partial<RegistryCacheEntry>
131
195
  if (typeof entry.fetchedAt !== "number" || typeof entry.body !== "string") return undefined
132
196
  if (entry.etag !== undefined && typeof entry.etag !== "string") return undefined
197
+ if (entry.revision !== undefined && typeof entry.revision !== "string") return undefined
133
198
  return entry as RegistryCacheEntry
134
- }
199
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Split-registry aggregation.
3
+ *
4
+ * The registry source is spread across `providers/<id>/{provider,models}.json`
5
+ * and `models/<lab>/<model>.json`; the plugin pulls the manifest, then every
6
+ * child file, and reassembles one `Registry`-shaped object for `parseRegistry`.
7
+ *
8
+ * Dependency-free and runtime-agnostic on purpose: the server entry, the TUI
9
+ * entry and `node --test` all import this module, so it must not touch
10
+ * `@opencode/*`, JSX or Node built-ins. Reads are injected so tests can fake
11
+ * the network.
12
+ */
13
+
14
+ import { SUPPORTED_SCHEMA_VERSION, parseRegistry } from "./schema.ts"
15
+
16
+ export interface Manifest {
17
+ readonly schemaVersion: number
18
+ readonly revision: string
19
+ readonly providers: readonly string[]
20
+ }
21
+
22
+ export type ParseManifestResult =
23
+ | { readonly ok: true; readonly manifest: Manifest }
24
+ | { readonly ok: false; readonly errors: readonly string[] }
25
+
26
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
27
+ typeof value === "object" && value !== null && !Array.isArray(value)
28
+
29
+ export function parseManifest(input: unknown): ParseManifestResult {
30
+ const errors: string[] = []
31
+ if (!isRecord(input)) return { ok: false, errors: ["manifest: must be an object"] }
32
+
33
+ if (input.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
34
+ errors.push(
35
+ `schemaVersion: unsupported value ${JSON.stringify(input.schemaVersion)} (this plugin supports ${SUPPORTED_SCHEMA_VERSION})`,
36
+ )
37
+ }
38
+ if (typeof input.revision !== "string" || !input.revision) errors.push("revision: must be a non-empty string")
39
+
40
+ let providers: string[] = []
41
+ if (!Array.isArray(input.providers) || !input.providers.every((id) => typeof id === "string" && id.length > 0)) {
42
+ errors.push("providers: must be an array of non-empty strings")
43
+ } else {
44
+ providers = input.providers as string[]
45
+ }
46
+
47
+ if (errors.length > 0) return { ok: false, errors }
48
+ return {
49
+ ok: true,
50
+ manifest: { schemaVersion: SUPPORTED_SCHEMA_VERSION, revision: input.revision as string, providers },
51
+ }
52
+ }
53
+
54
+ export interface AggregatedRegistry {
55
+ readonly schemaVersion: number
56
+ readonly models: Record<string, unknown>
57
+ readonly providers: Record<string, Record<string, unknown>>
58
+ }
59
+
60
+ export type BuildRegistryResult =
61
+ | { readonly ok: true; readonly registry: AggregatedRegistry; readonly warnings: readonly string[] }
62
+ | { readonly ok: false; readonly errors: readonly string[]; readonly warnings: readonly string[] }
63
+
64
+ /** Extracts the `base` references a provider's models declare. */
65
+ function basesOf(models: Record<string, unknown>): string[] {
66
+ const bases: string[] = []
67
+ for (const spec of Object.values(models)) {
68
+ if (isRecord(spec) && typeof spec.base === "string" && spec.base) bases.push(spec.base)
69
+ }
70
+ return bases
71
+ }
72
+
73
+ /**
74
+ * Fetches every provider + referenced shared model, validates each provider in
75
+ * isolation, and skips the ones that fail (a single bad provider must not
76
+ * poison the rest). Returns `ok:false` only when no provider survives.
77
+ */
78
+ export async function buildRegistry(
79
+ manifest: Manifest,
80
+ readJson: (relativePath: string) => Promise<unknown>,
81
+ ): Promise<BuildRegistryResult> {
82
+ const warnings: string[] = []
83
+
84
+ const loaded = new Map<string, { provider: Record<string, unknown>; models: Record<string, unknown> }>()
85
+ await Promise.all(
86
+ manifest.providers.map(async (id) => {
87
+ try {
88
+ const provider = await readJson(`providers/${id}/provider.json`)
89
+ if (!isRecord(provider)) {
90
+ warnings.push(`${id}: provider.json must be an object`)
91
+ return
92
+ }
93
+ const models = await readJson(`providers/${id}/models.json`)
94
+ if (!isRecord(models)) {
95
+ warnings.push(`${id}: models.json must be an object`)
96
+ return
97
+ }
98
+ loaded.set(id, { provider, models })
99
+ } catch (error) {
100
+ warnings.push(`${id}: ${error instanceof Error ? error.message : String(error)}`)
101
+ }
102
+ }),
103
+ )
104
+
105
+ const bases = new Set<string>()
106
+ for (const entry of loaded.values()) for (const base of basesOf(entry.models)) bases.add(base)
107
+
108
+ const fetched: Record<string, unknown> = {}
109
+ await Promise.all(
110
+ [...bases].map(async (base) => {
111
+ try {
112
+ const model = await readJson(`models/${base}.json`)
113
+ if (!isRecord(model)) {
114
+ warnings.push(`shared model ${base}: must be an object`)
115
+ return
116
+ }
117
+ fetched[base] = model
118
+ } catch (error) {
119
+ warnings.push(`shared model ${base}: ${error instanceof Error ? error.message : String(error)}`)
120
+ }
121
+ }),
122
+ )
123
+
124
+ // Keep only shared models that pass the schema on their own, so the final
125
+ // aggregate cannot be rejected by one malformed shared file.
126
+ const shared: Record<string, unknown> = {}
127
+ for (const [base, model] of Object.entries(fetched)) {
128
+ const probe = parseRegistry({
129
+ schemaVersion: SUPPORTED_SCHEMA_VERSION,
130
+ models: { [base]: model },
131
+ providers: { __probe__: { name: "probe", package: "probe", models: { probe: { base } } } },
132
+ })
133
+ if (probe.ok) shared[base] = model
134
+ else warnings.push(`shared model ${base}: ${probe.errors.join("; ")}`)
135
+ }
136
+
137
+ const providers: Record<string, Record<string, unknown>> = {}
138
+ for (const id of manifest.providers) {
139
+ const entry = loaded.get(id)
140
+ if (!entry) continue
141
+
142
+ const candidateModels: Record<string, unknown> = {}
143
+ for (const base of basesOf(entry.models)) if (base in shared) candidateModels[base] = shared[base]
144
+
145
+ const candidate = {
146
+ schemaVersion: SUPPORTED_SCHEMA_VERSION,
147
+ models: candidateModels,
148
+ providers: { [id]: { ...entry.provider, models: entry.models } },
149
+ }
150
+ const parsed = parseRegistry(candidate)
151
+ if (!parsed.ok) {
152
+ warnings.push(`${id}: ${parsed.errors.join("; ")}`)
153
+ continue
154
+ }
155
+ providers[id] = { ...entry.provider, models: entry.models }
156
+ }
157
+
158
+ if (Object.keys(providers).length === 0) {
159
+ return { ok: false, errors: ["no providers could be loaded"], warnings }
160
+ }
161
+ return { ok: true, registry: { schemaVersion: SUPPORTED_SCHEMA_VERSION, models: shared, providers }, warnings }
162
+ }
@@ -1,23 +1,33 @@
1
1
  /**
2
- * Registry loading: fetch + TTL + ETag + stale fallback.
2
+ * Registry loading: manifest fetch + TTL + ETag + revision short-circuit +
3
+ * force refresh + stale fallback.
4
+ *
5
+ * The remote source is split across many files (`index.json` manifest +
6
+ * `providers/**` + `models/**`); this module fetches the manifest, and only
7
+ * when its `revision` moved does it pull the child files through
8
+ * `buildRegistry`. The cache holds the *aggregated* body, so downstream
9
+ * (`parseRegistry`, `models.ts`) is unchanged.
3
10
  *
4
11
  * Pure logic with injected `fetch` / clock / cache so `node --test` can cover
5
12
  * the failure paths. The server entry wires these to `ctx.storage` and the
6
13
  * process `fetch`.
7
14
  */
8
15
 
16
+ import { buildRegistry, parseManifest } from "./aggregate.ts"
9
17
  import type { Registry } from "./schema.ts"
10
18
  import { parseRegistry } from "./schema.ts"
11
19
 
12
20
  export const DEFAULT_REGISTRY_URL =
13
- "https://raw.githubusercontent.com/Just-Silver/opencode-providers/main/registry/registry.json"
21
+ "https://raw.githubusercontent.com/Just-Silver/opencode-providers/main/registry/index.json"
14
22
 
15
23
  /** Successful checks are trusted for 6h; a failed refresh keeps serving the cached copy. */
16
- export const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000
17
- export const DEFAULT_TIMEOUT_MS = 10_000
24
+ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000
25
+ const DEFAULT_TIMEOUT_MS = 10_000
18
26
 
19
27
  export interface RegistryCacheEntry {
20
28
  readonly etag?: string
29
+ /** Manifest revision the cached body was built from. */
30
+ readonly revision?: string
21
31
  readonly fetchedAt: number
22
32
  readonly body: string
23
33
  }
@@ -35,12 +45,20 @@ export interface LoadRegistryOptions {
35
45
  readonly now?: () => number
36
46
  readonly ttlMs?: number
37
47
  readonly timeoutMs?: number
48
+ /** Bypass the TTL and revalidate now (used by force refresh). */
49
+ readonly force?: boolean
38
50
  }
39
51
 
40
52
  export type RegistrySource = "cache" | "network" | "stale-cache"
41
53
 
42
54
  export type LoadRegistryResult =
43
- | { readonly ok: true; readonly registry: Registry; readonly source: RegistrySource; readonly fetchedAt: number }
55
+ | {
56
+ readonly ok: true
57
+ readonly registry: Registry
58
+ readonly source: RegistrySource
59
+ readonly fetchedAt: number
60
+ readonly warnings: readonly string[]
61
+ }
44
62
  | { readonly ok: false; readonly errors: readonly string[] }
45
63
 
46
64
  const isCacheEntry = (value: unknown): value is RegistryCacheEntry =>
@@ -48,7 +66,8 @@ const isCacheEntry = (value: unknown): value is RegistryCacheEntry =>
48
66
  value !== null &&
49
67
  typeof (value as RegistryCacheEntry).fetchedAt === "number" &&
50
68
  typeof (value as RegistryCacheEntry).body === "string" &&
51
- ((value as RegistryCacheEntry).etag === undefined || typeof (value as RegistryCacheEntry).etag === "string")
69
+ ((value as RegistryCacheEntry).etag === undefined || typeof (value as RegistryCacheEntry).etag === "string") &&
70
+ ((value as RegistryCacheEntry).revision === undefined || typeof (value as RegistryCacheEntry).revision === "string")
52
71
 
53
72
  export async function loadRegistry(options: LoadRegistryOptions): Promise<LoadRegistryResult> {
54
73
  const url = options.url ?? DEFAULT_REGISTRY_URL
@@ -58,53 +77,108 @@ export async function loadRegistry(options: LoadRegistryOptions): Promise<LoadRe
58
77
 
59
78
  const cached = await readCache(options.store)
60
79
 
61
- if (cached !== undefined && now() - cached.fetchedAt < ttlMs) {
80
+ if (!options.force && cached !== undefined && now() - cached.fetchedAt < ttlMs) {
62
81
  const parsed = parseBody(cached.body)
63
- if (parsed.ok) return { ok: true, registry: parsed.registry, source: "cache", fetchedAt: cached.fetchedAt }
82
+ if (parsed.ok) return success(parsed.registry, "cache", cached.fetchedAt)
64
83
  }
65
84
 
66
- let response: Response
85
+ let manifestBody: string
86
+ let manifestEtag: string | undefined
67
87
  try {
68
- response = await options.fetch(url, {
69
- headers: {
70
- accept: "application/json",
71
- "user-agent": "opencode-providers",
72
- ...(cached?.etag === undefined ? {} : { "if-none-match": cached.etag }),
73
- },
74
- signal: AbortSignal.timeout(timeoutMs),
75
- })
88
+ const headers: Record<string, string> = { accept: "application/json", "user-agent": "opencode-providers" }
89
+ if (cached?.etag !== undefined) headers["if-none-match"] = cached.etag
90
+
91
+ const response = await options.fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) })
92
+
93
+ if (response.status === 304 && cached !== undefined) {
94
+ const parsed = parseBody(cached.body)
95
+ if (parsed.ok) {
96
+ const entry = { ...cached, fetchedAt: now() }
97
+ await writeCache(options.store, entry)
98
+ return success(parsed.registry, "cache", entry.fetchedAt)
99
+ }
100
+ // The cached body is unusable: we cannot rebuild from an empty 304, so
101
+ // refetch the manifest unconditionally (RF2).
102
+ const unconditional = await options.fetch(url, {
103
+ headers: { accept: "application/json", "user-agent": "opencode-providers" },
104
+ signal: AbortSignal.timeout(timeoutMs),
105
+ })
106
+ if (!unconditional.ok)
107
+ return fallback(cached, `registry request failed: ${unconditional.status} ${unconditional.statusText}`)
108
+ manifestBody = await unconditional.text()
109
+ manifestEtag = unconditional.headers.get("etag") ?? undefined
110
+ } else if (response.ok) {
111
+ manifestBody = await response.text()
112
+ manifestEtag = response.headers.get("etag") ?? undefined
113
+ } else {
114
+ return fallback(cached, `registry request failed: ${response.status} ${response.statusText}`)
115
+ }
76
116
  } catch (error) {
77
117
  return fallback(cached, errorMessage(error))
78
118
  }
79
119
 
80
- if (response.status === 304 && cached !== undefined) {
81
- const entry = { ...cached, fetchedAt: now() }
82
- await writeCache(options.store, entry)
83
- const parsed = parseBody(cached.body)
84
- if (parsed.ok) return { ok: true, registry: parsed.registry, source: "cache", fetchedAt: entry.fetchedAt }
85
- return { ok: false, errors: parsed.errors }
120
+ let manifestInput: unknown
121
+ try {
122
+ manifestInput = JSON.parse(manifestBody)
123
+ } catch (error) {
124
+ const errors = [`registry manifest is not valid JSON: ${errorMessage(error)}`]
125
+ const stale = await staleFallback(cached, errors)
126
+ return stale ?? { ok: false, errors }
86
127
  }
87
128
 
88
- if (!response.ok) return fallback(cached, `registry request failed: ${response.status} ${response.statusText}`)
129
+ const manifest = parseManifest(manifestInput)
130
+ if (!manifest.ok) {
131
+ const stale = await staleFallback(cached, manifest.errors)
132
+ return stale ?? { ok: false, errors: [...manifest.errors] }
133
+ }
89
134
 
90
- let body: string
91
- try {
92
- body = await response.text()
93
- } catch (error) {
94
- return fallback(cached, errorMessage(error))
135
+ // Short-circuit: same revision and a healthy cached aggregate → no child
136
+ // fetches. A corrupt cached body falls through and is rebuilt.
137
+ if (cached !== undefined && cached.revision !== undefined && cached.revision === manifest.manifest.revision) {
138
+ const parsed = parseBody(cached.body)
139
+ if (parsed.ok) {
140
+ const entry = {
141
+ ...cached,
142
+ ...(manifestEtag === undefined ? {} : { etag: manifestEtag }),
143
+ fetchedAt: now(),
144
+ }
145
+ await writeCache(options.store, entry)
146
+ return success(parsed.registry, "cache", entry.fetchedAt)
147
+ }
95
148
  }
96
149
 
97
- const parsed = parseBody(body)
98
- if (!parsed.ok) {
99
- const stale = await staleFallback(cached, parsed.errors)
100
- if (stale !== undefined) return stale
101
- return { ok: false, errors: parsed.errors }
150
+ const built = await buildRegistry(manifest.manifest, async (relativePath) => {
151
+ const childUrl = new URL(relativePath, url).toString()
152
+ const response = await options.fetch(childUrl, {
153
+ headers: { accept: "application/json", "user-agent": "opencode-providers" },
154
+ signal: AbortSignal.timeout(timeoutMs),
155
+ })
156
+ if (!response.ok) throw new Error(`registry request failed: ${response.status} ${response.statusText}`)
157
+ return JSON.parse(await response.text())
158
+ })
159
+
160
+ if (!built.ok) {
161
+ const stale = await staleFallback(cached, built.errors)
162
+ return stale ?? { ok: false, errors: [...built.errors] }
102
163
  }
103
164
 
104
- const etag = response.headers.get("etag") ?? undefined
105
165
  const fetchedAt = now()
106
- await writeCache(options.store, { ...(etag === undefined ? {} : { etag }), fetchedAt, body })
107
- return { ok: true, registry: parsed.registry, source: "network", fetchedAt }
166
+ await writeCache(options.store, {
167
+ ...(manifestEtag === undefined ? {} : { etag: manifestEtag }),
168
+ revision: manifest.manifest.revision,
169
+ fetchedAt,
170
+ body: JSON.stringify(built.registry),
171
+ })
172
+ return success(built.registry, "network", fetchedAt, built.warnings)
173
+ }
174
+
175
+ function success(
176
+ registry: Registry,
177
+ source: RegistrySource,
178
+ fetchedAt: number,
179
+ warnings: readonly string[] = [],
180
+ ): LoadRegistryResult {
181
+ return { ok: true, registry, source, fetchedAt, warnings }
108
182
  }
109
183
 
110
184
  /** A cache-write failure must never break loading; the payload is served regardless. */
@@ -148,9 +222,9 @@ async function staleFallback(
148
222
  if (cached === undefined) return undefined
149
223
  const parsed = parseBody(cached.body)
150
224
  if (!parsed.ok) return undefined
151
- return { ok: true, registry: parsed.registry, source: "stale-cache", fetchedAt: cached.fetchedAt }
225
+ return { ok: true, registry: parsed.registry, source: "stale-cache", fetchedAt: cached.fetchedAt, warnings: [...errors] }
152
226
  }
153
227
 
154
228
  function errorMessage(error: unknown): string {
155
229
  return error instanceof Error ? error.message : String(error)
156
- }
230
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * RPC definition for the server entry's force refresh.
3
+ *
4
+ * Plain JSON Schema — no `effect`, no `@opencode/*` — so `index.ts` stays free
5
+ * of `@opencode/*` imports (a locally installed server plugin does not get the
6
+ * module injected). `Rpc.define` is the identity function and the core parser
7
+ * accepts plain JSON Schema, so `ctx.rpc.register(registryRpc, …)` can take this
8
+ * object as-is.
9
+ */
10
+
11
+ export const registryRpc = {
12
+ id: "opencode-providers",
13
+ methods: {
14
+ refresh: {
15
+ input: { type: "object", properties: {}, additionalProperties: false },
16
+ output: {
17
+ type: "object",
18
+ properties: {
19
+ ok: { type: "boolean" },
20
+ providers: { type: "number" },
21
+ models: { type: "number" },
22
+ source: { type: "string" },
23
+ fetchedAt: { type: "number" },
24
+ errors: { type: "array", items: { type: "string" } },
25
+ },
26
+ required: ["ok"],
27
+ additionalProperties: false,
28
+ },
29
+ },
30
+ },
31
+ events: {},
32
+ } as const
33
+
34
+ export interface RefreshSummary {
35
+ readonly ok: boolean
36
+ readonly providers?: number
37
+ readonly models?: number
38
+ readonly source?: string
39
+ readonly fetchedAt?: number
40
+ readonly errors?: readonly string[]
41
+ }
@@ -14,6 +14,8 @@
14
14
  import type { IntegrationInfo } from "@opencode/client"
15
15
  import type { Plugin } from "@opencode/plugin/tui"
16
16
 
17
+ import { registryRpc } from "../rpc.ts"
18
+
17
19
  type Context = Plugin.Context
18
20
  type Connection = IntegrationInfo["connections"][number]
19
21
 
@@ -24,11 +26,19 @@ export async function connectProviders(ctx: Context): Promise<void> {
24
26
  const integrations = ownIntegrations(ctx).toSorted((a, b) => a.name.localeCompare(b.name))
25
27
 
26
28
  if (integrations.length === 0) {
27
- await ctx.ui.dialog.alert({
29
+ // Nothing to connect yet — offer the refresh here rather than making the user
30
+ // restart the service and wait out the 6h TTL.
31
+ const confirmed = await ctx.ui.dialog.confirm({
28
32
  title: "Connect providers",
29
33
  message:
30
- "No providers from the registry are available. Check that the registry loaded (server log) and restart the service.",
34
+ "No providers from the registry are available. Force a refresh to re-fetch the registry, then run /connect-providers again.",
35
+ label: { confirm: "Force refresh" },
31
36
  })
37
+ if (confirmed) {
38
+ if (await forceRefresh(ctx)) {
39
+ ctx.ui.toast.show({ variant: "info", message: "Reloaded. Run /connect-providers again to connect a provider." })
40
+ }
41
+ }
32
42
  return
33
43
  }
34
44
 
@@ -39,6 +49,14 @@ export async function connectProviders(ctx: Context): Promise<void> {
39
49
  value: integration.id,
40
50
  footer: footer(integration),
41
51
  })),
52
+ actions: [
53
+ {
54
+ title: "Force refresh",
55
+ bind: "ctrl+r",
56
+ selection: "none",
57
+ onTrigger: () => void forceRefresh(ctx),
58
+ },
59
+ ],
42
60
  })
43
61
  if (selected === undefined) return
44
62
 
@@ -48,6 +66,32 @@ export async function connectProviders(ctx: Context): Promise<void> {
48
66
  await openProvider(ctx, integration)
49
67
  }
50
68
 
69
+ /**
70
+ * Re-fetch the registry through the server RPC (bypasses the TTL) and refresh the
71
+ * provider list. Returns whether it succeeded. A failure only surfaces as an
72
+ * error toast: the previously registered providers stay usable, so nothing is
73
+ * invalidated.
74
+ */
75
+ export async function forceRefresh(ctx: Context): Promise<boolean> {
76
+ try {
77
+ const result = await ctx.client.rpc(registryRpc).refresh({})
78
+ if (!result.ok) {
79
+ const detail = (result.errors ?? ["unknown error"]).join("; ")
80
+ ctx.ui.toast.show({ variant: "error", message: `Registry refresh failed: ${detail}` })
81
+ return false
82
+ }
83
+ ctx.data.location.integration.invalidate(ctx.location)
84
+ ctx.ui.toast.show({
85
+ variant: "success",
86
+ message: `Registry refreshed: ${result.providers ?? 0} providers, ${result.models ?? 0} models`,
87
+ })
88
+ return true
89
+ } catch (error) {
90
+ ctx.ui.toast.show({ variant: "error", message: message(error) })
91
+ return false
92
+ }
93
+ }
94
+
51
95
  async function openProvider(ctx: Context, integration: IntegrationInfo): Promise<void> {
52
96
  const credentials = credentialConnections(integration)
53
97
  const active = credentials[0]
@@ -123,18 +167,13 @@ function credentialConnections(integration: IntegrationInfo): Array<Extract<Conn
123
167
  }
124
168
 
125
169
  function footer(integration: IntegrationInfo): string | undefined {
126
- const pending = integration.connections.find((connection) => pendingUrl(connection) !== undefined)
127
- if (pending) return "Sign in required →"
170
+ // Key-only by design (the registry only ever declares a `key` method), so there is
171
+ // no OAuth "pending" connection to advertise here — only existing account labels.
128
172
  const credentials = credentialConnections(integration)
129
173
  if (credentials.length === 0) return undefined
130
174
  return credentials.map((credential) => credential.label).join(", ")
131
175
  }
132
176
 
133
- function pendingUrl(connection: Connection): string | undefined {
134
- const status = (connection as { readonly status?: { readonly url?: string } }).status
135
- return status?.url
136
- }
137
-
138
177
  function message(error: unknown): string {
139
178
  return error instanceof Error ? error.message : String(error)
140
179
  }