@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 +58 -1
- package/README.md +73 -46
- package/package.json +1 -1
- package/plugin/opencode-providers/index.ts +89 -24
- package/plugin/opencode-providers/registry/aggregate.ts +162 -0
- package/plugin/opencode-providers/registry/source.ts +113 -39
- package/plugin/opencode-providers/rpc.ts +41 -0
- package/plugin/opencode-providers/view/connect.ts +48 -9
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
|
|
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/
|
|
8
|
-
registry/
|
|
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.
|
|
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.
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
"
|
|
109
|
-
"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
148
|
-
|
|
|
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
|
-
`
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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: {
|
|
52
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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}]
|
|
121
|
+
`[${PLUGIN_ID}] rpc refresh unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
82
122
|
)
|
|
83
123
|
}
|
|
84
124
|
|
|
85
|
-
const {
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
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/
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
| {
|
|
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
|
|
82
|
+
if (parsed.ok) return success(parsed.registry, "cache", cached.fetchedAt)
|
|
64
83
|
}
|
|
65
84
|
|
|
66
|
-
let
|
|
85
|
+
let manifestBody: string
|
|
86
|
+
let manifestEtag: string | undefined
|
|
67
87
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
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, {
|
|
107
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
127
|
-
|
|
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
|
}
|