@justsilver/opencode-providers 0.1.0-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 ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ 本项目所有值得注意的变更都记录在此文件。
4
+
5
+ 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
6
+ 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0-beta.0] - 2026-10-07
11
+
12
+ > 首个**预发布**版本:先发到 npm 的 `next` dist-tag 试装,稳定后再发 `latest`。
13
+ > 装上后不需要写 `opencode.json`,在 `/connect-providers` 里贴一次 API Key 即可用。
14
+
15
+ ### Added
16
+
17
+ - `registry.json` + `registry.schema.json`:自维护供应商注册表(共享 `models` 表 + provider `base` 引用 + 覆盖),带 `schemaVersion`
18
+ - server 入口 `plugin/opencode-providers/index.ts`:拉注册表(6h TTL + `ETag` + 失败沿用旧缓存)→ 注册 integration(`key`)与 provider(`activation: "auto"`)及其模型;**不 import 任何 `@opencode/*`**
19
+ - TUI 入口 `plugin/opencode-providers/tui.ts` + `view/connect.ts`:`/connect-providers` 命令(选供应商 → 贴 API Key / 切换账号),与内置 `/connect` 共用同一张凭据表。**无 JSX、不依赖 Solid**,因此 npm / 配置安装不会踩「双 Solid 运行时」
20
+ - `registry/` 纯逻辑:schema 校验 / `Model.Info` 映射 / 拉取缓存;`node --test` 覆盖(含「随仓注册表能通过校验」)
21
+ - `package.json`:npm 包形态(`type: module`;`exports["./server"]` / `["./tui"]`;`files` 白名单;`publishConfig.tag = next`),可 `"plugins": ["@justsilver/opencode-providers"]` 配置安装
22
+ - `install.sh` / `install.ps1` / `uninstall.sh` / `uninstall.ps1`:脚本安装(最新 Release 优先、回退 `main`、同文件系统原子替换);`--local` / `-Local` 从工作树部署
23
+ - `.github/workflows/ci.yml`(跑 `node --test` + 版本一致性)与 `release.yml`(tag 触发;预发布发 `next`,正式版必须手动勾选才发 `latest`;Release 内容取自本文件)
24
+
25
+ ### Changed
26
+
27
+ - 插件源码从 `.opencode/plugins/opencode-providers/` 移到 `plugin/opencode-providers/`:仓库自身不再是插件发现根,避免在仓库里跑 opencode 时与全局安装的同 id 副本相撞(`Duplicate plugin ID` → 面板里一条 `failed`)
28
+ - 安装后**通常无需重启**(插件目录被文件监视热重载),不再要求 `opencode service restart`
29
+
30
+ [Unreleased]: https://github.com/Just-Silver/opencode-providers/compare/v0.1.0-beta.0...HEAD
31
+ [0.1.0-beta.0]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.1.0-beta.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Just-Silver
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # opencode-providers
2
+
3
+ 给 [OpenCode](https://opencode.ai) 补上**模型目录里没有的供应商**:一份自维护的注册表 + 一个插件。
4
+ 装完插件、在 `/connect-providers` 里贴一次 API Key 就能用 —— **不需要写 `opencode.json`**。
5
+
6
+ ```
7
+ registry.json ← 唯一事实源:供应商 + 模型 + 参数(GitHub raw 托管)
8
+ registry.schema.json ← 给编辑器校验 registry.json
9
+ plugin/opencode-providers/ ← 插件源码(安装时整目录复制到 ~/.config/opencode/plugins/)
10
+ ├── index.ts ← server 入口:拉注册表 → 注册 integration/provider/models
11
+ ├── tui.tsx ← TUI 入口:注册 /connect-providers 命令
12
+ ├── registry/ ← 纯逻辑:schema 校验 / 元数据映射 / 拉取缓存
13
+ └── view/ ← /connect-providers 交互
14
+ ```
15
+
16
+ > 源码**故意不放在 `.opencode/plugins/` 下**:那样在本仓库里跑 opencode 时,仓库副本会和全局安装副本撞同一个插件 id,
17
+ > 被 supervisor 判为 `Duplicate plugin ID: opencode-providers`,在 `/plugins` 面板里显示成一条 `failed`(实际加载的是另一条,功能正常)。
18
+
19
+ ## 为什么需要它
20
+
21
+ OpenCode 的供应商清单来自 models.dev 目录,**目录里没有的供应商不会出现在 `/connect`**。
22
+ 想用自建/网关/小众供应商,原本只能在 `opencode.json` 里手写 `providers.<id>` 加一长串模型参数。
23
+ 本项目把这份数据搬到一份共享注册表里,由插件在运行期注册 —— 配置零增长,改注册表也不用改插件版本。
24
+
25
+ ## 安装
26
+
27
+ ### 方式 A:npm 包(推荐)
28
+
29
+ 在 `opencode.json(c)` 里写一行(不需要手写 provider/模型):
30
+
31
+ ```jsonc
32
+ {
33
+ "plugins": ["@justsilver/opencode-providers"]
34
+ }
35
+ ```
36
+
37
+ 想**试某个测试版**就把版本写全(试完再回到正式版):
38
+
39
+ ```jsonc
40
+ {
41
+ "plugins": ["@justsilver/opencode-providers@0.1.0-beta.0"]
42
+ }
43
+ ```
44
+
45
+ - 预发布版本发布在 npm 的 **`next`** dist-tag 上,正式版才发 `latest`。
46
+ - 升级 / 卸载(`<目标>` 就是配置里那串原样):`opencode plugin update @justsilver/opencode-providers` / `opencode plugin remove …`。
47
+ - 本插件的 TUI 入口**不含 JSX**、不依赖 Solid,所以配置安装不会踩「双 Solid 运行时」的坑。
48
+
49
+ ### 方式 B:安装脚本(零依赖备用路径)
50
+
51
+ ```bash
52
+ # Linux / macOS
53
+ ./install.sh
54
+ ```
55
+
56
+ ```powershell
57
+ # Windows
58
+ .\install.ps1
59
+ ```
60
+
61
+ 脚本把 `plugin/opencode-providers/` 整目录装到:
62
+
63
+ ```
64
+ ~/.config/opencode/plugins/opencode-providers/ # $XDG_CONFIG_HOME 优先
65
+ ```
66
+
67
+ 装完**通常无需重启**(插件目录被文件监视,覆盖后自动热重载);必要时 `opencode service restart`。
68
+ 卸载:`./uninstall.sh` / `.\uninstall.ps1`。
69
+
70
+ 开发时把**工作树**(含未提交改动)直接部署到全局插件目录:
71
+
72
+ ```bash
73
+ bash install.sh --local
74
+ ```
75
+ ```powershell
76
+ pwsh -NoProfile -File .\install.ps1 -Local
77
+ ```
78
+
79
+ ## 使用
80
+
81
+ ```
82
+ /connect-providers # 选供应商 → 贴 API Key
83
+ /models # 该供应商的模型随即出现在列表里
84
+ ```
85
+
86
+ 再运行一次可以添加第二个账号,或在已有账号之间切换。
87
+
88
+ **为什么 `/models` 里一开始看不到新供应商**:provider 声明为 `activation: "auto"`,
89
+ 只有拿到凭据(你在 `/connect-providers` 里存过 key)后才会出现在 `/models`。
90
+ 这样就算注册表里有几十家供应商,也只会显示你真正配了 key 的那些。
91
+
92
+ 凭据存在 opencode 自己的 SQLite 里(`opencode debug paths db`),和内置 `/connect` 完全一致。
93
+ API Key 只在你贴入时经过本插件的内存,不落任何本项目自己的文件。
94
+
95
+ ## 维护注册表
96
+
97
+ 改 `registry.json` 提交后,运行中的 opencode 最迟 6 小时(TTL)自动跟上;想立刻生效就重启服务。
98
+ 拉取使用 `ETag`,内容没变时不会重复下载;网络失败时继续沿用本地缓存,不会把供应商列表清空。
99
+
100
+ ### 结构
101
+
102
+ ```jsonc
103
+ {
104
+ "schemaVersion": 1,
105
+ "models": {
106
+ // 供应商无关的模型参数,写一份,被下面 provider 用 "base" 引用
107
+ "some-model": {
108
+ "name": "Some Model",
109
+ "limit": { "context": 131072, "output": 16384 },
110
+ "cost": { "input": 0.5, "output": 1.5, "cache_read": 0.05 },
111
+ "tools": true,
112
+ "input": ["text", "image"],
113
+ "output": ["text"]
114
+ }
115
+ },
116
+ "providers": {
117
+ "my-gateway": {
118
+ "name": "My Gateway",
119
+ "package": "@opencode/ai/providers/openai-compatible",
120
+ "baseURL": "https://llm.example.com/v1",
121
+ "keyLabel": "Paste API key",
122
+ "models": {
123
+ // key = 在 opencode 里使用的模型 ID;base 指向上面那份共享参数
124
+ "some-model": { "base": "some-model" },
125
+ // 覆盖任意字段;modelID 是发给上游的真实 ID
126
+ "some-model-fast": {
127
+ "base": "some-model",
128
+ "modelID": "some-model-2026-01",
129
+ "cost": { "input": 0.2, "output": 0.8 }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ ```
136
+
137
+ | 字段 | 说明 |
138
+ |---|---|
139
+ | `schemaVersion` | 当前为 `1`;插件只接受自己支持的版本,不匹配就整份拒绝(不会半注册) |
140
+ | `providers.<id>.package` | 运行时包,如 `@opencode/ai/providers/openai-compatible`(**不要**用旧的 `aisdk:` / `@ai-sdk/*` 写法) |
141
+ | `providers.<id>.baseURL` | API 端点;与 `settings` 合并后作为 provider `settings` |
142
+ | `providers.<id>.keyLabel` | `/connect-providers` 里 API Key 输入框的提示,默认 `Paste API key` |
143
+ | `providers.<id>.models` | key = 模型 ID(`provider/model` 里的 model 段);每项字段见下表 |
144
+ | 模型 `base` | 引用顶层 `models` 的 key,先铺共享参数再用本项覆盖 |
145
+ | 模型 `modelID` | 发给上游的真实模型/部署 ID,默认等于上面的 key |
146
+ | 模型 `limit` | `context` / `output` 必填(无 `base` 时),`input` 可选 |
147
+ | 模型 `cost` | 每百万 token 美元;`cache_read`/`cache_write` 可选 |
148
+ | 模型 `tools` / `input` / `output` | 能力:是否支持工具调用、输入/输出模态,默认 `true` / `["text"]` |
149
+ | 模型 `reasoningField` / `maxTokensField` | 映射到 `Model.Compatibility` |
150
+ | 模型 `variants` | `[{ "id": "high", "settings": {} }]` |
151
+ | 模型 `status` / `disabled` | 生命周期标记;`disabled: true` 不出现在 `/models` |
152
+
153
+ `registry.schema.json` 是同一份规则的 JSON Schema,编辑器可直接校验。
154
+
155
+ ## 范围之外
156
+
157
+ - **不做参数推断**:注册表写什么就是什么,插件不会去猜 `limit`/`cost`。
158
+ - **不调供应商 API**:不访问 `/v1/models`,也就没有运行期的额外网络与失败面。
159
+ - **不声明 `env` 认证**:本插件只走 `/connect`-式交互;opencode 的 env 是静默旁路且不在 `/connect` 里展示。
160
+ - 账号重命名/删除请用内置 `/connect`(同一个 integration,同一个凭据表)。
161
+
162
+ ## 开发
163
+
164
+ ```bash
165
+ node --test # 纯逻辑单测(Node ≥ 22 原生 TS,无需依赖)
166
+ node scripts/smoke-api.mjs --list # 真机冒烟场景(HTTP,不需要 TUI)
167
+ node scripts/smoke-api.mjs # 全跑:插件已加载 / 供应商已注册 / 凭据→模型→清理
168
+ ```
169
+
170
+ `smoke-api.mjs` 的鉴权自动读 `~/.local/state/opencode/service.json`;它的 `models` 场景会写入并删除一条
171
+ 临时凭据(label `smoke-throwaway`),且只对「当前没有任何凭据」的供应商生效。
172
+
173
+ 两个入口的打包/语法检查(两个入口都不 import 外部运行时依赖):
174
+
175
+ ```bash
176
+ npx --yes esbuild plugin/opencode-providers/index.ts --bundle --platform=node --format=esm \
177
+ --outfile=dist/providers-server.js
178
+
179
+ npx --yes esbuild plugin/opencode-providers/tui.ts --bundle --platform=node --format=esm \
180
+ --outfile=dist/providers-tui.js
181
+ ```
182
+
183
+ 改完插件文件**通常无需重启**:插件目录被文件监视,覆盖后自动热重载。
184
+ 验证是否真的注册成功(用 opencode 自己的 API,不需要看 TUI):
185
+
186
+ ```bash
187
+ opencode api get /api/plugin # 自己那条 state.status 必须是 active(多于 1 条 = 同 id 被发现两次)
188
+ opencode api get /api/integration # 注册的供应商(metadata.source = opencode-providers)
189
+ opencode api get /api/model # 只列可用供应商的模型;没配 key 时不会出现
190
+ ```
191
+
192
+ **发布策略(预发布优先)**:`package.json` 的 `version` 是版本单一来源;
193
+ 预发布(如 `0.1.0-beta.0`)发到 npm 的 **`next`** dist-tag,正式版必须手动确认才发 `latest`。
194
+ 流水线:整理 `CHANGELOG.md` 的版本小节 → `npm version <x.y.z[-beta.n]> --no-git-tag-version` → commit →
195
+ push tag `vX.Y.Z-beta.n`(或 Actions → Release → Run workflow 勾 `publish`;不勾只做 `npm pack --dry-run` 预检)。
196
+ **首个版本必须人工发一次**(OIDC/trusted publisher 挂不到尚不存在的包上),命令与核对项见
197
+ `docs/npm-distribution-and-testing.md` §5。
198
+
199
+ ## 疑难解答
200
+
201
+ | 现象 | 原因 / 处理 |
202
+ |---|---|
203
+ | `/plugins` 面板里本插件有**一条 `failed`** | 同一插件 id 被发现两次(例:脚本装到 `~/.config/opencode/plugins/` + 又在 `opencode.json` 里配了 npm 包,或某个项目的 `.opencode/plugins/` 里也有一份)。supervisor 保留首个、把后来者标成 `failed`,错误信息是 `Duplicate plugin ID: opencode-providers`。删掉多余的那份即可 |
204
+ | `/connect-providers` 说没有可用供应商 | 注册表没加载成功。看 server 日志里的 `[opencode-providers]`,并确认 `/api/plugin` 里自己那条 `state.status` 是 `active` |
205
+ | `/models` 里看不到新供应商 | 正常:`activation: "auto"`,在 `/connect-providers` 里存过 key 之后才会出现 |
206
+ | 改了插件代码没生效 | 覆盖的是**全局**那份(`~/.config/opencode/plugins/opencode-providers/`)。开发时用 `install.ps1 -Local` / `install.sh --local` 把工作树复制过去 |
207
+
208
+ 验证命令见上面的「开发」小节。
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@justsilver/opencode-providers",
3
+ "version": "0.1.0-beta.0",
4
+ "type": "module",
5
+ "description": "Register providers missing from the models.dev catalog in OpenCode V2 from a self-hosted registry — no opencode.json needed",
6
+ "license": "MIT",
7
+ "author": "Just-Silver",
8
+ "homepage": "https://github.com/Just-Silver/opencode-providers#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Just-Silver/opencode-providers.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/Just-Silver/opencode-providers/issues"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "plugin",
20
+ "provider",
21
+ "providers",
22
+ "models",
23
+ "gateway",
24
+ "llm"
25
+ ],
26
+ "main": "./plugin/opencode-providers/index.ts",
27
+ "exports": {
28
+ ".": "./plugin/opencode-providers/index.ts",
29
+ "./server": "./plugin/opencode-providers/index.ts",
30
+ "./tui": "./plugin/opencode-providers/tui.ts"
31
+ },
32
+ "files": [
33
+ "plugin/**/*.ts",
34
+ "!plugin/**/*.test.ts",
35
+ "CHANGELOG.md"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "tag": "next"
40
+ },
41
+ "scripts": {
42
+ "test": "node --test"
43
+ },
44
+ "devDependencies": {
45
+ "@opencode/plugin": "^2.0.24"
46
+ }
47
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Server entry — registers providers from the registry.
3
+ *
4
+ * IMPORTANT: this file deliberately imports **nothing** from `@opencode/*`.
5
+ * A locally installed server plugin does not get the plugin module injected
6
+ * (opencode 2.0.24 fails with `Cannot find package '@opencode/plugin'`), unlike
7
+ * `@opencode/plugin/tui` for TUI entries. Everything the runtime needs here is a
8
+ * plain value:
9
+ * - `Plugin.define` is the identity function, so a default export is enough
10
+ * - `Provider.ID.make` / `Integration.ID.make` are identity at runtime
11
+ * - `Provider.Info.empty(id)` is `{ id, name: id, activation: "auto", package: "" }`
12
+ * and every field is overridden below anyway
13
+ *
14
+ * It registers an integration (single `key` method) and a provider
15
+ * (`activation: "auto"`) per registry entry. Credentials are never touched:
16
+ * opencode injects them by matching `provider.integrationID` to the integration
17
+ * the key was stored under. No `env` method is declared on purpose — env is a
18
+ * silent, non-interactive path and this plugin is `/connect`-only.
19
+ */
20
+
21
+ import { buildProviderModels, buildProviderSettings } from "./registry/models.ts"
22
+ import { DEFAULT_REGISTRY_URL, loadRegistry } from "./registry/source.ts"
23
+ import type { RegistryCacheEntry } from "./registry/source.ts"
24
+
25
+ const PLUGIN_ID = "opencode-providers"
26
+ const CACHE_KEY = "registry-cache"
27
+
28
+ /** Tags integrations so the TUI entry can recognise its own. */
29
+ export const INTEGRATION_SOURCE = PLUGIN_ID
30
+
31
+ interface IntegrationRefLike {
32
+ id: string
33
+ name: string
34
+ }
35
+
36
+ interface IntegrationEditorLike {
37
+ update(id: string, update: (integration: IntegrationRefLike) => void): void
38
+ method: { update(input: { integrationID: string; method: { type: "key"; label?: string } }): void }
39
+ }
40
+
41
+ interface ProviderEditorLike {
42
+ add(input: { info: Record<string, unknown>; models: readonly Record<string, unknown>[] }): void
43
+ }
44
+
45
+ /** Only the members this plugin uses; see the file comment for why it is structural. */
46
+ interface SetupContextLike {
47
+ readonly options: Readonly<Record<string, unknown>>
48
+ readonly storage: {
49
+ get(key: string): Promise<unknown>
50
+ set(key: string, value: unknown): Promise<void>
51
+ }
52
+ readonly integration: { transform(callback: (editor: IntegrationEditorLike) => void): Promise<unknown> }
53
+ readonly provider: { transform(callback: (editor: ProviderEditorLike) => void): Promise<unknown> }
54
+ }
55
+
56
+ export default {
57
+ id: PLUGIN_ID,
58
+ async setup(ctx: SetupContextLike) {
59
+ // NOTE: discovered directory plugins are registered with `options: {}`, so this override is only
60
+ // reachable through a config `plugins` entry (which we deliberately do not use for TUI plugins).
61
+ // It exists for tests and forks; script installs always use DEFAULT_REGISTRY_URL.
62
+ const url = typeof ctx.options.registryUrl === "string" ? ctx.options.registryUrl : DEFAULT_REGISTRY_URL
63
+ const result = await loadRegistry({
64
+ url,
65
+ fetch: globalThis.fetch,
66
+ store: {
67
+ get: async () => asCacheEntry(await ctx.storage.get(CACHE_KEY)),
68
+ set: async (entry) => {
69
+ await ctx.storage.set(CACHE_KEY, entry)
70
+ },
71
+ },
72
+ })
73
+
74
+ if (!result.ok) {
75
+ console.error(`[${PLUGIN_ID}] registry unavailable (${url}): ${result.errors.join("; ")}`)
76
+ return
77
+ }
78
+ if (result.source === "stale-cache") {
79
+ console.warn(
80
+ `[${PLUGIN_ID}] registry refresh failed; serving cache from ${new Date(result.fetchedAt).toISOString()}`,
81
+ )
82
+ }
83
+
84
+ const { registry } = result
85
+ const entries = Object.entries(registry.providers)
86
+
87
+ await ctx.integration.transform((editor) => {
88
+ for (const [id, provider] of entries) {
89
+ editor.update(id, (integration) => {
90
+ integration.name = provider.name
91
+ // `metadata` is part of the integration ref and surfaces in `Integration.Info`,
92
+ // but the plugin-facing ref type only declares id/name.
93
+ ;(integration as IntegrationRefLike & { metadata?: Record<string, unknown> }).metadata = {
94
+ source: INTEGRATION_SOURCE,
95
+ ...(provider.keyLabel === undefined ? {} : { keyLabel: provider.keyLabel }),
96
+ }
97
+ })
98
+ editor.method.update({
99
+ integrationID: id,
100
+ method: { type: "key", label: provider.keyLabel ?? "Paste API key" },
101
+ })
102
+ }
103
+ })
104
+
105
+ await ctx.provider.transform((editor) => {
106
+ for (const [id, provider] of entries) {
107
+ const models = buildProviderModels(registry, id, provider)
108
+ if (models.length === 0) continue
109
+ const settings = buildProviderSettings(provider)
110
+ editor.add({
111
+ info: {
112
+ id,
113
+ name: provider.name,
114
+ activation: "auto",
115
+ integrationID: id,
116
+ package: provider.package,
117
+ ...(settings === undefined ? {} : { settings }),
118
+ ...(provider.headers === undefined ? {} : { headers: provider.headers }),
119
+ },
120
+ models,
121
+ })
122
+ }
123
+ })
124
+ },
125
+ }
126
+
127
+ function asCacheEntry(value: unknown): RegistryCacheEntry | undefined {
128
+ if (typeof value !== "object" || value === null) return undefined
129
+ const entry = value as Partial<RegistryCacheEntry>
130
+ if (typeof entry.fetchedAt !== "number" || typeof entry.body !== "string") return undefined
131
+ if (entry.etag !== undefined && typeof entry.etag !== "string") return undefined
132
+ return entry as RegistryCacheEntry
133
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Maps validated registry entries onto plain `Model.Info`-shaped objects.
3
+ *
4
+ * Pure and dependency-free so `node --test` can cover it without the OpenCode
5
+ * runtime. The server entry feeds the result straight to `ctx.provider.transform`.
6
+ */
7
+
8
+ import type { ProviderEntry, Registry } from "./schema.ts"
9
+ import { resolveModelSpec } from "./schema.ts"
10
+
11
+ export interface ModelInfoLike {
12
+ readonly id: string
13
+ readonly modelID: string
14
+ readonly providerID: string
15
+ readonly name: string
16
+ readonly family?: string
17
+ readonly compatibility?: Readonly<Record<string, unknown>>
18
+ readonly capabilities: {
19
+ readonly tools: boolean
20
+ readonly input: readonly string[]
21
+ readonly output: readonly string[]
22
+ }
23
+ readonly variants: ReadonlyArray<{ readonly id: string; readonly settings?: Readonly<Record<string, unknown>> }>
24
+ readonly time: { readonly released: number }
25
+ readonly cost: ReadonlyArray<{
26
+ readonly input: number
27
+ readonly output: number
28
+ readonly cache: { readonly read: number; readonly write: number }
29
+ }>
30
+ readonly status: "alpha" | "beta" | "deprecated" | "active"
31
+ readonly enabled: boolean
32
+ readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
33
+ }
34
+
35
+ export function buildProviderModels(
36
+ registry: Registry,
37
+ providerID: string,
38
+ provider: ProviderEntry,
39
+ ): ModelInfoLike[] {
40
+ const models: ModelInfoLike[] = []
41
+
42
+ for (const [id, spec] of Object.entries(provider.models)) {
43
+ const resolved = resolveModelSpec(registry, spec)
44
+ if (resolved === undefined) continue
45
+
46
+ const compatibility: Record<string, unknown> = {}
47
+ if (resolved.reasoningField !== undefined) compatibility.reasoningField = resolved.reasoningField
48
+ if (resolved.maxTokensField !== undefined) compatibility.maxTokensField = resolved.maxTokensField
49
+
50
+ models.push({
51
+ id,
52
+ modelID: spec.modelID ?? id,
53
+ providerID,
54
+ name: resolved.name ?? id,
55
+ ...(resolved.family === undefined ? {} : { family: resolved.family }),
56
+ ...(Object.keys(compatibility).length === 0 ? {} : { compatibility }),
57
+ capabilities: {
58
+ tools: resolved.tools ?? true,
59
+ input: resolved.input ?? ["text"],
60
+ output: resolved.output ?? ["text"],
61
+ },
62
+ variants: (resolved.variants ?? []).map((variant) =>
63
+ variant.settings === undefined ? { id: variant.id } : { id: variant.id, settings: variant.settings },
64
+ ),
65
+ time: { released: resolveReleased(resolved.releaseDate) },
66
+ cost:
67
+ resolved.cost === undefined
68
+ ? []
69
+ : [
70
+ {
71
+ input: resolved.cost.input,
72
+ output: resolved.cost.output,
73
+ cache: { read: resolved.cost.cache_read ?? 0, write: resolved.cost.cache_write ?? 0 },
74
+ },
75
+ ],
76
+ status: resolved.status ?? "active",
77
+ enabled: resolved.disabled !== true,
78
+ limit: {
79
+ context: resolved.limit.context,
80
+ ...(resolved.limit.input === undefined ? {} : { input: resolved.limit.input }),
81
+ output: resolved.limit.output,
82
+ },
83
+ })
84
+ }
85
+
86
+ return models
87
+ }
88
+
89
+ /** Provider settings = registry `settings` layered over `{ baseURL }`. */
90
+ export function buildProviderSettings(provider: ProviderEntry): Readonly<Record<string, unknown>> | undefined {
91
+ if (provider.baseURL === undefined && provider.settings === undefined) return undefined
92
+ return { ...(provider.baseURL === undefined ? {} : { baseURL: provider.baseURL }), ...provider.settings }
93
+ }
94
+
95
+ function resolveReleased(releaseDate: string | undefined): number {
96
+ if (releaseDate === undefined) return 0
97
+ const parsed = Date.parse(releaseDate)
98
+ return Number.isFinite(parsed) ? parsed : 0
99
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Registry schema + validation.
3
+ *
4
+ * Dependency-free and runtime-agnostic on purpose: the server entry, the TUI
5
+ * entry and `node --test` all import this module, so it must not touch
6
+ * `@opencode/*`, JSX or Node built-ins.
7
+ */
8
+
9
+ export const SUPPORTED_SCHEMA_VERSION = 1
10
+
11
+ export type ModelStatus = "alpha" | "beta" | "deprecated" | "active"
12
+
13
+ export interface Limit {
14
+ readonly context: number
15
+ readonly input?: number
16
+ readonly output: number
17
+ }
18
+
19
+ export interface Cost {
20
+ readonly input: number
21
+ readonly output: number
22
+ readonly cache_read?: number
23
+ readonly cache_write?: number
24
+ }
25
+
26
+ export interface Variant {
27
+ readonly id: string
28
+ readonly settings?: Readonly<Record<string, unknown>>
29
+ }
30
+
31
+ /** Provider-agnostic model facts. Shared by every provider through `base`. */
32
+ export interface RegistryModel {
33
+ readonly name?: string
34
+ readonly family?: string
35
+ readonly releaseDate?: string
36
+ readonly status?: ModelStatus
37
+ readonly disabled?: boolean
38
+ readonly limit: Limit
39
+ readonly cost?: Cost
40
+ readonly tools?: boolean
41
+ readonly input?: readonly string[]
42
+ readonly output?: readonly string[]
43
+ /** `Model.Compatibility.reasoningField` */
44
+ readonly reasoningField?: string
45
+ /** `Model.Compatibility.maxTokensField` */
46
+ readonly maxTokensField?: "max_tokens" | "max_completion_tokens"
47
+ readonly variants?: readonly Variant[]
48
+ }
49
+
50
+ /**
51
+ * A model as exposed by one provider. `id` is the map key; everything else is
52
+ * optional and layered over the shared model named by `base`.
53
+ */
54
+ export interface ModelSpec {
55
+ readonly base?: string
56
+ /** Model or deployment ID sent upstream. Defaults to the map key. */
57
+ readonly modelID?: string
58
+ readonly name?: string
59
+ readonly family?: string
60
+ readonly releaseDate?: string
61
+ readonly status?: ModelStatus
62
+ readonly disabled?: boolean
63
+ readonly limit?: Limit
64
+ readonly cost?: Cost
65
+ readonly tools?: boolean
66
+ readonly input?: readonly string[]
67
+ readonly output?: readonly string[]
68
+ readonly reasoningField?: string
69
+ readonly maxTokensField?: "max_tokens" | "max_completion_tokens"
70
+ readonly variants?: readonly Variant[]
71
+ }
72
+
73
+ export interface ProviderEntry {
74
+ readonly name: string
75
+ /** Runtime provider package, e.g. `@opencode/ai/providers/openai-compatible`. */
76
+ readonly package: string
77
+ readonly baseURL?: string
78
+ /** Extra provider settings merged over `{ baseURL }`. */
79
+ readonly settings?: Readonly<Record<string, unknown>>
80
+ readonly headers?: Readonly<Record<string, string>>
81
+ /** Label for the `/connect...` key prompt. Defaults to "Paste API key". */
82
+ readonly keyLabel?: string
83
+ readonly models: Readonly<Record<string, ModelSpec>>
84
+ }
85
+
86
+ export interface Registry {
87
+ readonly schemaVersion: number
88
+ readonly models: Readonly<Record<string, RegistryModel>>
89
+ readonly providers: Readonly<Record<string, ProviderEntry>>
90
+ }
91
+
92
+ export type ParseResult = { readonly ok: true; readonly registry: Registry } | { readonly ok: false; readonly errors: readonly string[] }
93
+
94
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
95
+ typeof value === "object" && value !== null && !Array.isArray(value)
96
+
97
+ const isPositiveInt = (value: unknown): value is number =>
98
+ typeof value === "number" && Number.isInteger(value) && value > 0
99
+
100
+ const isNonNegativeNumber = (value: unknown): value is number =>
101
+ typeof value === "number" && Number.isFinite(value) && value >= 0
102
+
103
+ const isStringArray = (value: unknown): value is string[] =>
104
+ Array.isArray(value) && value.every((item) => typeof item === "string")
105
+
106
+ const STATUSES: readonly ModelStatus[] = ["alpha", "beta", "deprecated", "active"]
107
+
108
+ function checkLimit(value: unknown, path: string, errors: string[]): value is Limit {
109
+ if (!isRecord(value)) {
110
+ errors.push(`${path}: must be an object`)
111
+ return false
112
+ }
113
+ let valid = true
114
+ if (!isPositiveInt(value.context)) {
115
+ errors.push(`${path}.context: must be a positive integer`)
116
+ valid = false
117
+ }
118
+ if (!isPositiveInt(value.output)) {
119
+ errors.push(`${path}.output: must be a positive integer`)
120
+ valid = false
121
+ }
122
+ if (value.input !== undefined && !isPositiveInt(value.input)) {
123
+ errors.push(`${path}.input: must be a positive integer when present`)
124
+ valid = false
125
+ }
126
+ return valid
127
+ }
128
+
129
+ function checkCost(value: unknown, path: string, errors: string[]): boolean {
130
+ if (!isRecord(value)) {
131
+ errors.push(`${path}: must be an object`)
132
+ return false
133
+ }
134
+ let valid = true
135
+ for (const field of ["input", "output"] as const) {
136
+ if (!isNonNegativeNumber(value[field])) {
137
+ errors.push(`${path}.${field}: must be a number >= 0`)
138
+ valid = false
139
+ }
140
+ }
141
+ for (const field of ["cache_read", "cache_write"] as const) {
142
+ if (value[field] !== undefined && !isNonNegativeNumber(value[field])) {
143
+ errors.push(`${path}.${field}: must be a number >= 0 when present`)
144
+ valid = false
145
+ }
146
+ }
147
+ return valid
148
+ }
149
+
150
+ function checkVariants(value: unknown, path: string, errors: string[]): boolean {
151
+ if (!Array.isArray(value)) {
152
+ errors.push(`${path}: must be an array`)
153
+ return false
154
+ }
155
+ let valid = true
156
+ value.forEach((variant, index) => {
157
+ if (!isRecord(variant) || typeof variant.id !== "string" || !variant.id) {
158
+ errors.push(`${path}[${index}].id: must be a non-empty string`)
159
+ valid = false
160
+ }
161
+ })
162
+ return valid
163
+ }
164
+
165
+ /** Validates the fields a model may declare; `requireLimit` is false for provider overrides. */
166
+ function checkModelFields(
167
+ value: Record<string, unknown>,
168
+ path: string,
169
+ errors: string[],
170
+ options: { readonly requireLimit: boolean },
171
+ ) {
172
+ if (value.limit !== undefined) checkLimit(value.limit, `${path}.limit`, errors)
173
+ else if (options.requireLimit) errors.push(`${path}.limit: required`)
174
+
175
+ if (value.cost !== undefined) checkCost(value.cost, `${path}.cost`, errors)
176
+ if (value.variants !== undefined) checkVariants(value.variants, `${path}.variants`, errors)
177
+
178
+ if (value.tools !== undefined && typeof value.tools !== "boolean") errors.push(`${path}.tools: must be a boolean`)
179
+ if (value.disabled !== undefined && typeof value.disabled !== "boolean")
180
+ errors.push(`${path}.disabled: must be a boolean`)
181
+ if (value.input !== undefined && !isStringArray(value.input)) errors.push(`${path}.input: must be an array of strings`)
182
+ if (value.output !== undefined && !isStringArray(value.output))
183
+ errors.push(`${path}.output: must be an array of strings`)
184
+
185
+ for (const field of ["name", "family", "releaseDate", "reasoningField"] as const) {
186
+ if (value[field] !== undefined && typeof value[field] !== "string") errors.push(`${path}.${field}: must be a string`)
187
+ }
188
+ if (value.modelID !== undefined && (typeof value.modelID !== "string" || !value.modelID))
189
+ errors.push(`${path}.modelID: must be a non-empty string`)
190
+ if (value.status !== undefined && !STATUSES.includes(value.status as ModelStatus))
191
+ errors.push(`${path}.status: must be one of ${STATUSES.join(", ")}`)
192
+ if (value.maxTokensField !== undefined && value.maxTokensField !== "max_tokens" && value.maxTokensField !== "max_completion_tokens")
193
+ errors.push(`${path}.maxTokensField: must be "max_tokens" or "max_completion_tokens"`)
194
+ }
195
+
196
+ export function parseRegistry(input: unknown): ParseResult {
197
+ const errors: string[] = []
198
+
199
+ if (!isRecord(input)) return { ok: false, errors: ["registry: must be an object"] }
200
+
201
+ if (input.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
202
+ errors.push(
203
+ `schemaVersion: unsupported value ${JSON.stringify(input.schemaVersion)} (this plugin supports ${SUPPORTED_SCHEMA_VERSION})`,
204
+ )
205
+ }
206
+
207
+ const shared: Record<string, RegistryModel> = {}
208
+ const rawModels = input.models ?? {}
209
+ if (!isRecord(rawModels)) {
210
+ errors.push("models: must be an object")
211
+ } else {
212
+ for (const [key, value] of Object.entries(rawModels)) {
213
+ const path = `models.${key}`
214
+ if (!isRecord(value)) {
215
+ errors.push(`${path}: must be an object`)
216
+ continue
217
+ }
218
+ checkModelFields(value, path, errors, { requireLimit: true })
219
+ shared[key] = value as unknown as RegistryModel
220
+ }
221
+ }
222
+
223
+ const providers: Record<string, ProviderEntry> = {}
224
+ const rawProviders = input.providers
225
+ if (!isRecord(rawProviders) || Object.keys(rawProviders).length === 0) {
226
+ errors.push("providers: must be a non-empty object")
227
+ } else {
228
+ for (const [id, value] of Object.entries(rawProviders)) {
229
+ const path = `providers.${id}`
230
+ if (!isRecord(value)) {
231
+ errors.push(`${path}: must be an object`)
232
+ continue
233
+ }
234
+ if (typeof value.name !== "string" || !value.name) errors.push(`${path}.name: must be a non-empty string`)
235
+ if (typeof value.package !== "string" || !value.package) errors.push(`${path}.package: must be a non-empty string`)
236
+ if (value.baseURL !== undefined && typeof value.baseURL !== "string") errors.push(`${path}.baseURL: must be a string`)
237
+ if (value.keyLabel !== undefined && typeof value.keyLabel !== "string") errors.push(`${path}.keyLabel: must be a string`)
238
+ if (value.settings !== undefined && !isRecord(value.settings)) errors.push(`${path}.settings: must be an object`)
239
+ if (value.headers !== undefined) {
240
+ if (!isRecord(value.headers) || !Object.values(value.headers).every((header) => typeof header === "string"))
241
+ errors.push(`${path}.headers: must be an object of strings`)
242
+ }
243
+
244
+ const models: Record<string, ModelSpec> = {}
245
+ if (!isRecord(value.models) || Object.keys(value.models).length === 0) {
246
+ errors.push(`${path}.models: must be a non-empty object`)
247
+ } else {
248
+ for (const [modelID, spec] of Object.entries(value.models)) {
249
+ const modelPath = `${path}.models.${modelID}`
250
+ if (!isRecord(spec)) {
251
+ errors.push(`${modelPath}: must be an object`)
252
+ continue
253
+ }
254
+ checkModelFields(spec, modelPath, errors, { requireLimit: spec.base === undefined })
255
+ if (spec.base !== undefined) {
256
+ if (typeof spec.base !== "string") errors.push(`${modelPath}.base: must be a string`)
257
+ else if (!(spec.base in shared)) errors.push(`${modelPath}.base: unknown shared model "${spec.base}"`)
258
+ }
259
+ models[modelID] = spec as unknown as ModelSpec
260
+ }
261
+ }
262
+
263
+ providers[id] = { ...(value as unknown as ProviderEntry), models }
264
+ }
265
+ }
266
+
267
+ if (errors.length > 0) return { ok: false, errors }
268
+ return { ok: true, registry: { schemaVersion: SUPPORTED_SCHEMA_VERSION, models: shared, providers } }
269
+ }
270
+
271
+ /** Layers a provider model spec over its shared model, shared first. */
272
+ export function resolveModelSpec(registry: Registry, spec: ModelSpec): RegistryModel | undefined {
273
+ const base = spec.base === undefined ? undefined : registry.models[spec.base]
274
+ if (spec.base !== undefined && base === undefined) return undefined
275
+ const { base: _base, modelID: _modelID, ...overrides } = spec
276
+ const model = { ...base, ...overrides } as RegistryModel
277
+ return model.limit === undefined ? undefined : model
278
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Registry loading: fetch + TTL + ETag + stale fallback.
3
+ *
4
+ * Pure logic with injected `fetch` / clock / cache so `node --test` can cover
5
+ * the failure paths. The server entry wires these to `ctx.storage` and the
6
+ * process `fetch`.
7
+ */
8
+
9
+ import type { Registry } from "./schema.ts"
10
+ import { parseRegistry } from "./schema.ts"
11
+
12
+ export const DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/Just-Silver/opencode-providers/main/registry.json"
13
+
14
+ /** Successful checks are trusted for 6h; a failed refresh keeps serving the cached copy. */
15
+ export const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000
16
+ export const DEFAULT_TIMEOUT_MS = 10_000
17
+
18
+ export interface RegistryCacheEntry {
19
+ readonly etag?: string
20
+ readonly fetchedAt: number
21
+ readonly body: string
22
+ }
23
+
24
+ export interface RegistryStore {
25
+ get(): Promise<RegistryCacheEntry | undefined>
26
+ set(entry: RegistryCacheEntry): Promise<void>
27
+ }
28
+
29
+ export interface LoadRegistryOptions {
30
+ readonly store: RegistryStore
31
+ readonly fetch: typeof globalThis.fetch
32
+ /** Defaults to `DEFAULT_REGISTRY_URL`. */
33
+ readonly url?: string
34
+ readonly now?: () => number
35
+ readonly ttlMs?: number
36
+ readonly timeoutMs?: number
37
+ }
38
+
39
+ export type RegistrySource = "cache" | "network" | "stale-cache"
40
+
41
+ export type LoadRegistryResult =
42
+ | { readonly ok: true; readonly registry: Registry; readonly source: RegistrySource; readonly fetchedAt: number }
43
+ | { readonly ok: false; readonly errors: readonly string[] }
44
+
45
+ const isCacheEntry = (value: unknown): value is RegistryCacheEntry =>
46
+ typeof value === "object" &&
47
+ value !== null &&
48
+ typeof (value as RegistryCacheEntry).fetchedAt === "number" &&
49
+ typeof (value as RegistryCacheEntry).body === "string" &&
50
+ ((value as RegistryCacheEntry).etag === undefined || typeof (value as RegistryCacheEntry).etag === "string")
51
+
52
+ export async function loadRegistry(options: LoadRegistryOptions): Promise<LoadRegistryResult> {
53
+ const url = options.url ?? DEFAULT_REGISTRY_URL
54
+ const now = options.now ?? Date.now
55
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
56
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
57
+
58
+ const cached = await readCache(options.store)
59
+
60
+ if (cached !== undefined && now() - cached.fetchedAt < ttlMs) {
61
+ const parsed = parseBody(cached.body)
62
+ if (parsed.ok) return { ok: true, registry: parsed.registry, source: "cache", fetchedAt: cached.fetchedAt }
63
+ }
64
+
65
+ let response: Response
66
+ try {
67
+ response = await options.fetch(url, {
68
+ headers: {
69
+ accept: "application/json",
70
+ "user-agent": "opencode-providers",
71
+ ...(cached?.etag === undefined ? {} : { "if-none-match": cached.etag }),
72
+ },
73
+ signal: AbortSignal.timeout(timeoutMs),
74
+ })
75
+ } catch (error) {
76
+ return fallback(cached, errorMessage(error))
77
+ }
78
+
79
+ if (response.status === 304 && cached !== undefined) {
80
+ const entry = { ...cached, fetchedAt: now() }
81
+ await writeCache(options.store, entry)
82
+ const parsed = parseBody(cached.body)
83
+ if (parsed.ok) return { ok: true, registry: parsed.registry, source: "cache", fetchedAt: entry.fetchedAt }
84
+ return { ok: false, errors: parsed.errors }
85
+ }
86
+
87
+ if (!response.ok) return fallback(cached, `registry request failed: ${response.status} ${response.statusText}`)
88
+
89
+ let body: string
90
+ try {
91
+ body = await response.text()
92
+ } catch (error) {
93
+ return fallback(cached, errorMessage(error))
94
+ }
95
+
96
+ const parsed = parseBody(body)
97
+ if (!parsed.ok) {
98
+ const stale = await staleFallback(cached, parsed.errors)
99
+ if (stale !== undefined) return stale
100
+ return { ok: false, errors: parsed.errors }
101
+ }
102
+
103
+ const etag = response.headers.get("etag") ?? undefined
104
+ const fetchedAt = now()
105
+ await writeCache(options.store, { ...(etag === undefined ? {} : { etag }), fetchedAt, body })
106
+ return { ok: true, registry: parsed.registry, source: "network", fetchedAt }
107
+ }
108
+
109
+ /** A cache-write failure must never break loading; the payload is served regardless. */
110
+ async function writeCache(store: RegistryStore, entry: RegistryCacheEntry): Promise<void> {
111
+ try {
112
+ await store.set(entry)
113
+ } catch {
114
+ // intentionally ignored
115
+ }
116
+ }
117
+
118
+ async function readCache(store: RegistryStore): Promise<RegistryCacheEntry | undefined> {
119
+ try {
120
+ const value = await store.get()
121
+ return isCacheEntry(value) ? value : undefined
122
+ } catch {
123
+ return undefined
124
+ }
125
+ }
126
+
127
+ function parseBody(body: string): { ok: true; registry: Registry } | { ok: false; errors: string[] } {
128
+ let json: unknown
129
+ try {
130
+ json = JSON.parse(body)
131
+ } catch (error) {
132
+ return { ok: false, errors: [`registry is not valid JSON: ${errorMessage(error)}`] }
133
+ }
134
+ const parsed = parseRegistry(json)
135
+ return parsed.ok ? { ok: true, registry: parsed.registry } : { ok: false, errors: [...parsed.errors] }
136
+ }
137
+
138
+ async function fallback(cached: RegistryCacheEntry | undefined, reason: string): Promise<LoadRegistryResult> {
139
+ const stale = await staleFallback(cached, [reason])
140
+ return stale ?? { ok: false, errors: [reason] }
141
+ }
142
+
143
+ async function staleFallback(
144
+ cached: RegistryCacheEntry | undefined,
145
+ errors: readonly string[],
146
+ ): Promise<LoadRegistryResult | undefined> {
147
+ if (cached === undefined) return undefined
148
+ const parsed = parseBody(cached.body)
149
+ if (!parsed.ok) return undefined
150
+ return { ok: true, registry: parsed.registry, source: "stale-cache", fetchedAt: cached.fetchedAt }
151
+ }
152
+
153
+ function errorMessage(error: unknown): string {
154
+ return error instanceof Error ? error.message : String(error)
155
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * TUI entry — registers `/connect-providers`.
3
+ *
4
+ * The command only *reads* state (integrations the server entry registered) and
5
+ * talks to the server over the client API, so the key still lands in opencode's
6
+ * credential table through the normal `/connect` path.
7
+ */
8
+
9
+ import { Plugin } from "@opencode/plugin/tui"
10
+ import { connectProviders } from "./view/connect.ts"
11
+
12
+ const PLUGIN_ID = "opencode-providers"
13
+
14
+ export default Plugin.define({
15
+ id: PLUGIN_ID,
16
+ setup(context) {
17
+ context.keymap.layer(() => ({
18
+ commands: [
19
+ {
20
+ id: `${PLUGIN_ID}.connect`,
21
+ title: "Connect providers",
22
+ slash: { name: "connect-providers" },
23
+ run: () => connectProviders(context),
24
+ },
25
+ ],
26
+ }))
27
+ },
28
+ })
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `/connect-providers` flow.
3
+ *
4
+ * Mirrors the built-in `/connect` interaction for the providers this plugin
5
+ * registers: pick a provider, then either add an account (paste an API key) or
6
+ * activate one that already exists. Everything goes through the normal server
7
+ * API, so credentials land in opencode's own store.
8
+ *
9
+ * Out of scope on purpose (use the built-in `/connect` for these): renaming and
10
+ * deleting accounts. Renaming needs a bound footer action; deletion needs a
11
+ * confirm binding. Both are cosmetic actions on the same records.
12
+ */
13
+
14
+ import type { IntegrationInfo } from "@opencode/client"
15
+ import type { Plugin } from "@opencode/plugin/tui"
16
+
17
+ type Context = Plugin.Context
18
+ type Connection = IntegrationInfo["connections"][number]
19
+
20
+ const INTEGRATION_SOURCE = "opencode-providers"
21
+ const ADD_ACCOUNT = "\u0000add-account"
22
+
23
+ export async function connectProviders(ctx: Context): Promise<void> {
24
+ const integrations = ownIntegrations(ctx).toSorted((a, b) => a.name.localeCompare(b.name))
25
+
26
+ if (integrations.length === 0) {
27
+ await ctx.ui.dialog.alert({
28
+ title: "Connect providers",
29
+ message:
30
+ "No providers from the registry are available. Check that the registry loaded (server log) and restart the service.",
31
+ })
32
+ return
33
+ }
34
+
35
+ const selected = await ctx.ui.dialog.select<string>({
36
+ title: "Connect providers",
37
+ options: integrations.map((integration) => ({
38
+ title: integration.name,
39
+ value: integration.id,
40
+ footer: footer(integration),
41
+ })),
42
+ })
43
+ if (selected === undefined) return
44
+
45
+ const integration = integrations.find((item) => item.id === selected)
46
+ if (integration === undefined) return
47
+
48
+ await openProvider(ctx, integration)
49
+ }
50
+
51
+ async function openProvider(ctx: Context, integration: IntegrationInfo): Promise<void> {
52
+ const credentials = credentialConnections(integration)
53
+ const active = credentials[0]
54
+
55
+ if (credentials.length === 0) {
56
+ await connectKey(ctx, integration)
57
+ return
58
+ }
59
+
60
+ const choice = await ctx.ui.dialog.select<string>({
61
+ title: integration.name,
62
+ current: active?.id,
63
+ options: [
64
+ {
65
+ title: "Add account",
66
+ value: ADD_ACCOUNT,
67
+ description: "Paste another API key; it becomes the active account",
68
+ },
69
+ ...credentials.toSorted((a, b) => a.label.localeCompare(b.label)).map((credential) => ({
70
+ title: credential.label,
71
+ value: credential.id,
72
+ category: "Accounts",
73
+ footer: credential.id === active?.id ? "active" : undefined,
74
+ })),
75
+ ],
76
+ })
77
+ if (choice === undefined) return
78
+
79
+ if (choice === ADD_ACCOUNT) {
80
+ await connectKey(ctx, integration)
81
+ return
82
+ }
83
+
84
+ const credential = credentials.find((item) => item.id === choice)
85
+ try {
86
+ await ctx.client.credential.activate({ credentialID: choice })
87
+ ctx.ui.toast.show({ variant: "success", message: `Activated ${credential?.label ?? choice}` })
88
+ } catch (error) {
89
+ ctx.ui.toast.show({ variant: "error", message: message(error) })
90
+ }
91
+ }
92
+
93
+ async function connectKey(ctx: Context, integration: IntegrationInfo): Promise<void> {
94
+ const description =
95
+ typeof integration.metadata?.keyLabel === "string" ? integration.metadata.keyLabel : "Enter your API key"
96
+
97
+ const key = await ctx.ui.dialog.prompt({
98
+ title: integration.name,
99
+ description,
100
+ placeholder: "sk-...",
101
+ })
102
+ if (key === undefined || key.trim() === "") return
103
+
104
+ try {
105
+ await ctx.client.integration.connect.key({ integrationID: integration.id, key: key.trim() })
106
+ ctx.data.location.integration.invalidate(ctx.location)
107
+ ctx.ui.toast.show({ variant: "success", message: `${integration.name} connected` })
108
+ } catch (error) {
109
+ ctx.ui.toast.show({ variant: "error", message: message(error) })
110
+ }
111
+ }
112
+
113
+ function ownIntegrations(ctx: Context): IntegrationInfo[] {
114
+ return (ctx.data.location.integration.list(ctx.location) ?? []).filter(
115
+ (integration) => integration.metadata?.source === INTEGRATION_SOURCE,
116
+ )
117
+ }
118
+
119
+ function credentialConnections(integration: IntegrationInfo): Array<Extract<Connection, { type: "credential" }>> {
120
+ return integration.connections.filter(
121
+ (connection): connection is Extract<Connection, { type: "credential" }> => connection.type === "credential",
122
+ )
123
+ }
124
+
125
+ function footer(integration: IntegrationInfo): string | undefined {
126
+ const pending = integration.connections.find((connection) => pendingUrl(connection) !== undefined)
127
+ if (pending) return "Sign in required →"
128
+ const credentials = credentialConnections(integration)
129
+ if (credentials.length === 0) return undefined
130
+ return credentials.map((credential) => credential.label).join(", ")
131
+ }
132
+
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
+ function message(error: unknown): string {
139
+ return error instanceof Error ? error.message : String(error)
140
+ }