opencode-dynamic-custom-providers 2.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 b3nw
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,132 @@
1
+ # OpenCode Dynamic Custom Providers
2
+
3
+ This plugin extends OpenCode with dynamic model discovery for OpenAI-compatible providers, enriched with metadata from [models.dev](https://models.dev).
4
+
5
+ ## Features
6
+
7
+ ### 1. Automatic Model Discovery at Startup
8
+ The server plugin's `config` hook discovers models from any provider with a `baseURL` on every OpenCode startup. Models are always up-to-date without manual intervention.
9
+
10
+ ### 2. models.dev Metadata Enrichment
11
+ Discovered models are cross-referenced against the [models.dev](https://models.dev) catalog (the same source OpenCode uses natively) to enrich them with accurate context windows, output limits, costs, capabilities (tool calling, reasoning, temperature), and input/output modalities.
12
+
13
+ ### 3. `/add-provider` Slash Command
14
+ A TUI slash command for interactively adding new providers:
15
+ - Prompts for Provider ID, Base URL, and API Key
16
+ - Validates inputs and checks for duplicates
17
+ - Discovers models to confirm the endpoint works before saving
18
+ - Writes `dynamic: true` so models are re-discovered on each startup
19
+
20
+ ### 4. `/reload-models` Slash Command
21
+ A TUI slash command (also available as `/refresh-models`) that re-discovers models from all providers with a `baseURL` without restarting OpenCode. Clears the models.dev cache and updates the live config in one step.
22
+
23
+ ### 5. `refresh-models` Agent Tool
24
+ An in-session tool the agent can call to clear the models.dev metadata cache. Restart OpenCode after to re-discover all models with fresh metadata.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ opencode plugin opencode-dynamic-custom-providers
30
+ ```
31
+
32
+ ### Alternative: Install from GitHub
33
+ ```bash
34
+ opencode plugin git+ssh://git@github.com/b3nw/opencode-dynamic-custom-providers.git
35
+ ```
36
+
37
+ ### Alternative: Local Clone (for Development)
38
+ ```bash
39
+ git clone https://github.com/b3nw/opencode-dynamic-custom-providers
40
+ opencode plugin ./opencode-dynamic-custom-providers
41
+ ```
42
+
43
+ ## Configuration
44
+
45
+ ### Adding a Provider via TUI
46
+ Run `/add-provider` in the OpenCode TUI and follow the prompts. The provider will be added with `dynamic: true` so models are discovered automatically on each startup.
47
+
48
+ ### Adding a Provider Manually
49
+ Add a provider to `opencode.json` with a `baseURL`. Models will be discovered automatically:
50
+
51
+ ```json
52
+ {
53
+ "provider": {
54
+ "my-proxy": {
55
+ "name": "My Proxy",
56
+ "options": {
57
+ "baseURL": "https://api.proxy.com/v1"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ For explicit opt-in, set `"dynamic": true`:
65
+
66
+ ```json
67
+ {
68
+ "provider": {
69
+ "my-proxy": {
70
+ "name": "My Proxy",
71
+ "dynamic": true,
72
+ "options": {
73
+ "baseURL": "https://api.proxy.com/v1"
74
+ }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ ### API Key Authentication
81
+
82
+ API keys can be set in three ways:
83
+
84
+ 1. **Via the `/add-provider` TUI command** (stored securely via OpenCode's auth system)
85
+ 2. **In config** under `options.apiKey`:
86
+ ```json
87
+ {
88
+ "provider": {
89
+ "my-proxy": {
90
+ "options": {
91
+ "baseURL": "https://api.proxy.com/v1",
92
+ "apiKey": "sk-..."
93
+ }
94
+ }
95
+ }
96
+ }
97
+ ```
98
+ 3. **Via environment variable** using the pattern `OPENCODE_LOCAL_<PROVIDER_ID>_API_KEY`:
99
+ ```bash
100
+ export OPENCODE_LOCAL_MY_PROXY_API_KEY=sk-...
101
+ ```
102
+
103
+ ## How It Works
104
+
105
+ 1. On startup, the server plugin's `config` hook iterates all providers with a `baseURL`
106
+ 2. For each eligible provider (no models defined, or `dynamic: true`), it fetches `/v1/models`
107
+ 3. Each discovered model ID is cross-referenced against the models.dev catalog
108
+ 4. Matching models get enriched metadata (context window, costs, capabilities, modalities)
109
+ 5. Enriched models are injected into the live config before OpenCode loads providers
110
+ 6. The provider is set to use `@ai-sdk/openai-compatible` as the SDK package
111
+
112
+ ## Discovery Trigger
113
+
114
+ A provider is eligible for discovery when it has `options.baseURL` and either:
115
+ - Has `dynamic: true` set in config, **or**
116
+ - Has no `models` key (or empty models) in config
117
+
118
+ Providers that already have models defined in config are left unchanged unless `dynamic: true` is set.
119
+
120
+ ## Limitations
121
+ - **Startup latency**: Each dynamic provider adds a network request at startup (15s timeout per endpoint, plus models.dev fetch on first run)
122
+ - **models.dev coverage**: Models not in the models.dev catalog get sensible defaults (128k context window, 4096 output limit, text-only modalities)
123
+ - **Capabilities detection**: Endpoint-reported capabilities (`supported_parameters`, `capabilities`) are merged with models.dev data; neither source alone is complete for all proxies
124
+
125
+ ## Development
126
+
127
+ ```bash
128
+ git clone https://github.com/b3nw/opencode-dynamic-custom-providers
129
+ cd opencode-dynamic-custom-providers
130
+ npm install
131
+ npm run build
132
+ ```
@@ -0,0 +1,51 @@
1
+ export declare function normalizeModelId(id: string): string;
2
+ export declare function clearModelsDevCache(): void;
3
+ interface EndpointModelResponse {
4
+ id: string;
5
+ name?: string;
6
+ context_length?: number;
7
+ max_completion_tokens?: number;
8
+ max_output_tokens?: number;
9
+ max_model_len?: number;
10
+ context_window?: number;
11
+ input_cost?: number;
12
+ output_cost?: number;
13
+ capabilities?: {
14
+ tool_choice?: boolean;
15
+ function_calling?: boolean;
16
+ reasoning?: boolean;
17
+ vision?: boolean;
18
+ temperature?: boolean;
19
+ structured_output?: boolean;
20
+ };
21
+ supported_parameters?: string[];
22
+ }
23
+ export declare function fetchEndpointModels(baseURL: string, apiKey?: string): Promise<EndpointModelResponse[]>;
24
+ export interface EnrichedModel {
25
+ name: string;
26
+ family?: string;
27
+ release_date?: string;
28
+ status?: string;
29
+ attachment?: boolean;
30
+ reasoning?: boolean;
31
+ temperature?: boolean;
32
+ tool_call?: boolean;
33
+ cost?: {
34
+ input: number;
35
+ output: number;
36
+ cache_read?: number;
37
+ cache_write?: number;
38
+ };
39
+ limit: {
40
+ context: number;
41
+ input?: number;
42
+ output: number;
43
+ };
44
+ modalities?: {
45
+ input: string[];
46
+ output: string[];
47
+ };
48
+ }
49
+ export type DisplayStyle = "name" | "slug";
50
+ export declare function discoverAndEnrich(baseURL: string, apiKey?: string, displayStyle?: DisplayStyle): Promise<Record<string, EnrichedModel>>;
51
+ export {};
@@ -0,0 +1,240 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import { createRequire } from "node:module";
5
+ import { isValidUrl, sanitizeModelId } from "./security.js";
6
+ const require = createRequire(import.meta.url);
7
+ const { version: PKG_VERSION } = require("../package.json");
8
+ const MODELS_DEV_URL = "https://models.dev/api.json";
9
+ const CACHE_DIR = path.join(os.homedir() || "/tmp", ".cache", "opencode-dynamic-providers");
10
+ const CACHE_FILE = path.join(CACHE_DIR, "models-dev.json");
11
+ const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours
12
+ // ── models.dev cache + lookup ──
13
+ // Module-level singleton: built once per process on first access, then shared
14
+ // across all providers in a single startup cycle. Cleared by clearModelsDevCache()
15
+ // (e.g. via the reload-models TUI command) so the next discoverAndEnrich call
16
+ // rebuilds it with fresh data.
17
+ let lookupMap = null;
18
+ export function normalizeModelId(id) {
19
+ let normalized = id.toLowerCase();
20
+ if (normalized.startsWith("models/")) {
21
+ normalized = normalized.slice(7);
22
+ }
23
+ const slashIndex = normalized.lastIndexOf("/");
24
+ if (slashIndex !== -1) {
25
+ normalized = normalized.slice(slashIndex + 1);
26
+ }
27
+ return normalized.replaceAll(":", "-");
28
+ }
29
+ function readCacheEntry() {
30
+ try {
31
+ if (!fs.existsSync(CACHE_FILE))
32
+ return null;
33
+ const content = fs.readFileSync(CACHE_FILE, "utf-8");
34
+ return JSON.parse(content);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function writeCache(data) {
41
+ try {
42
+ if (!fs.existsSync(CACHE_DIR)) {
43
+ fs.mkdirSync(CACHE_DIR, { mode: 0o700, recursive: true });
44
+ }
45
+ fs.writeFileSync(CACHE_FILE, JSON.stringify({ timestamp: Date.now(), data }, null, 2), { mode: 0o600 });
46
+ }
47
+ catch {
48
+ // ignore write errors
49
+ }
50
+ }
51
+ async function fetchModelsDevCatalog() {
52
+ const entry = readCacheEntry();
53
+ const isFresh = entry && (Date.now() - entry.timestamp <= CACHE_TTL);
54
+ if (isFresh && entry)
55
+ return entry.data;
56
+ try {
57
+ const res = await fetch(MODELS_DEV_URL, {
58
+ headers: { "User-Agent": `opencode-dynamic-custom-providers/${PKG_VERSION}` },
59
+ signal: AbortSignal.timeout(10_000),
60
+ });
61
+ if (res.ok) {
62
+ const data = await res.json();
63
+ writeCache(data);
64
+ return data;
65
+ }
66
+ }
67
+ catch {
68
+ // Ignore fetch error, fall back to stale cache if available
69
+ }
70
+ if (entry) {
71
+ return entry.data;
72
+ }
73
+ return {};
74
+ }
75
+ function buildLookupMap(catalog) {
76
+ const map = new Map();
77
+ for (const provider of Object.values(catalog)) {
78
+ if (!provider.models)
79
+ continue;
80
+ for (const model of Object.values(provider.models)) {
81
+ const key = normalizeModelId(model.id);
82
+ if (!map.has(key)) {
83
+ map.set(key, model);
84
+ }
85
+ }
86
+ }
87
+ return map;
88
+ }
89
+ async function getLookupMap() {
90
+ if (lookupMap)
91
+ return lookupMap;
92
+ const catalog = await fetchModelsDevCatalog();
93
+ lookupMap = buildLookupMap(catalog);
94
+ return lookupMap;
95
+ }
96
+ export function clearModelsDevCache() {
97
+ lookupMap = null;
98
+ try {
99
+ if (fs.existsSync(CACHE_FILE))
100
+ fs.unlinkSync(CACHE_FILE);
101
+ }
102
+ catch {
103
+ // ignore
104
+ }
105
+ }
106
+ // ── endpoint discovery ──
107
+ export async function fetchEndpointModels(baseURL, apiKey) {
108
+ if (!isValidUrl(baseURL))
109
+ return [];
110
+ const url = new URL(baseURL);
111
+ const modelsPath = url.pathname.endsWith("/models")
112
+ ? url.pathname
113
+ : `${url.pathname.replace(/\/+$/, "")}/models`;
114
+ const modelsUrl = new URL(modelsPath, url.origin);
115
+ modelsUrl.search = url.search;
116
+ const headers = { "Content-Type": "application/json" };
117
+ if (apiKey) {
118
+ headers["Authorization"] = `Bearer ${apiKey}`;
119
+ }
120
+ const res = await fetch(modelsUrl.toString(), {
121
+ headers,
122
+ signal: AbortSignal.timeout(15_000),
123
+ });
124
+ if (!res.ok) {
125
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
126
+ }
127
+ const rawData = await res.json();
128
+ const data = Array.isArray(rawData)
129
+ ? rawData
130
+ : Array.isArray(rawData?.data)
131
+ ? rawData.data
132
+ : null;
133
+ if (!data) {
134
+ throw new Error("Unexpected response format from /v1/models");
135
+ }
136
+ return data.filter((item) => typeof item?.id === "string");
137
+ }
138
+ function parseEndpointCapabilities(item) {
139
+ let toolCall = false;
140
+ let reasoning = false;
141
+ let temperature = false;
142
+ if (item.capabilities) {
143
+ if (item.capabilities.function_calling || item.capabilities.tool_choice)
144
+ toolCall = true;
145
+ if (item.capabilities.reasoning)
146
+ reasoning = true;
147
+ if (item.capabilities.temperature)
148
+ temperature = true;
149
+ }
150
+ if (item.supported_parameters) {
151
+ if (item.supported_parameters.includes("tools") || item.supported_parameters.includes("tool_choice")) {
152
+ toolCall = true;
153
+ }
154
+ if (item.supported_parameters.includes("temperature")) {
155
+ temperature = true;
156
+ }
157
+ }
158
+ return { toolCall, reasoning, temperature };
159
+ }
160
+ function endpointContextWindow(item) {
161
+ const v = item.context_window ?? item.max_model_len ?? item.context_length;
162
+ return typeof v === "number" ? v : undefined;
163
+ }
164
+ function endpointMaxOutput(item) {
165
+ const v = item.max_completion_tokens ?? item.max_output_tokens;
166
+ return typeof v === "number" ? v : undefined;
167
+ }
168
+ export async function discoverAndEnrich(baseURL, apiKey, displayStyle = "slug") {
169
+ const rawModels = await fetchEndpointModels(baseURL, apiKey);
170
+ const lookup = await getLookupMap();
171
+ const result = {};
172
+ for (const item of rawModels) {
173
+ const id = sanitizeModelId(item.id);
174
+ const key = normalizeModelId(id);
175
+ const meta = lookup.get(key);
176
+ const caps = parseEndpointCapabilities(item);
177
+ const epContext = endpointContextWindow(item);
178
+ const epOutput = endpointMaxOutput(item);
179
+ const displayName = displayStyle === "slug"
180
+ ? id
181
+ : (item.name && item.name !== item.id ? item.name : undefined) ?? meta?.name ?? id;
182
+ const model = {
183
+ name: displayName,
184
+ limit: {
185
+ context: epContext ?? meta?.limit.context ?? 128_000,
186
+ input: meta?.limit.input,
187
+ output: epOutput ?? meta?.limit.output ?? 4096,
188
+ },
189
+ modalities: meta?.modalities ?? { input: ["text"], output: ["text"] },
190
+ };
191
+ // Capabilities: prefer models.dev when available, fall back to endpoint-detected
192
+ if (meta) {
193
+ model.reasoning = caps.reasoning || meta.reasoning;
194
+ model.temperature = caps.temperature || meta.temperature;
195
+ model.tool_call = caps.toolCall || meta.tool_call;
196
+ model.attachment = meta.attachment;
197
+ model.family = meta.family;
198
+ model.release_date = meta.release_date;
199
+ model.status = meta.status;
200
+ model.cost = {
201
+ input: meta.cost?.input ?? 0,
202
+ output: meta.cost?.output ?? 0,
203
+ cache_read: meta.cost?.cache_read,
204
+ cache_write: meta.cost?.cache_write,
205
+ };
206
+ }
207
+ else {
208
+ // No models.dev match — use whatever the endpoint told us, with safe defaults.
209
+ // tool_call defaults to false for unknown models to avoid sending tool calls
210
+ // to endpoints that don't support them.
211
+ if (caps.reasoning)
212
+ model.reasoning = true;
213
+ if (caps.temperature)
214
+ model.temperature = true;
215
+ if (caps.toolCall)
216
+ model.tool_call = true;
217
+ if (typeof item.input_cost === "number" || typeof item.output_cost === "number") {
218
+ model.cost = {
219
+ input: typeof item.input_cost === "number" ? item.input_cost : 0,
220
+ output: typeof item.output_cost === "number" ? item.output_cost : 0,
221
+ };
222
+ }
223
+ }
224
+ // Strip false/undefined booleans so OpenCode's fallback chain works cleanly
225
+ if (!model.reasoning)
226
+ delete model.reasoning;
227
+ if (!model.temperature)
228
+ delete model.temperature;
229
+ if (!model.attachment)
230
+ delete model.attachment;
231
+ if (model.family === undefined)
232
+ delete model.family;
233
+ if (model.release_date === undefined)
234
+ delete model.release_date;
235
+ if (model.status === undefined)
236
+ delete model.status;
237
+ result[id] = model;
238
+ }
239
+ return result;
240
+ }
@@ -0,0 +1,3 @@
1
+ export declare function isValidUrl(url: string): boolean;
2
+ export declare function sanitizeModelId(id: string): string;
3
+ export declare function sanitizeErrorMessage(error: unknown): string;
@@ -0,0 +1,34 @@
1
+ const VALID_PROTOCOLS = new Set(["http:", "https:"]);
2
+ export function isValidUrl(url) {
3
+ try {
4
+ const parsed = new URL(url);
5
+ return VALID_PROTOCOLS.has(parsed.protocol);
6
+ }
7
+ catch {
8
+ return false;
9
+ }
10
+ }
11
+ export function sanitizeModelId(id) {
12
+ return id.trim().replaceAll(/[^a-zA-Z0-9/_\-:.]/g, "_");
13
+ }
14
+ export function sanitizeErrorMessage(error) {
15
+ let message = error instanceof Error ? error.message : String(error);
16
+ // Strip query parameters from any URLs inside the error message
17
+ message = message.replaceAll(/https?:\/\/[^\s"'`<>]+/gi, (match) => {
18
+ try {
19
+ const url = new URL(match);
20
+ if (url.search) {
21
+ url.search = "";
22
+ return url.toString();
23
+ }
24
+ return match;
25
+ }
26
+ catch {
27
+ return match.split("?")[0];
28
+ }
29
+ });
30
+ return message
31
+ .replaceAll(/Bearer\s+[^\s]+/gi, "Bearer [REDACTED]")
32
+ .replaceAll(/api[_-]?key[=:]\s*[^\s&]+/gi, "api_key=[REDACTED]")
33
+ .replaceAll(/sk-[a-zA-Z0-9_-]+/g, "sk-[REDACTED]");
34
+ }
@@ -0,0 +1,24 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { type DisplayStyle } from "./discovery.js";
3
+ export declare const id = "opencode-dynamic-custom-providers";
4
+ export interface ProviderConfig {
5
+ name?: string;
6
+ npm?: string;
7
+ api?: string;
8
+ dynamic?: boolean;
9
+ displayStyle?: DisplayStyle;
10
+ options?: {
11
+ baseURL?: string;
12
+ apiKey?: string;
13
+ [key: string]: unknown;
14
+ };
15
+ models?: Record<string, unknown>;
16
+ [key: string]: unknown;
17
+ }
18
+ export declare function getApiKey(provider: ProviderConfig, providerId: string): string | undefined;
19
+ export declare const server: Plugin;
20
+ declare const _default: {
21
+ id: string;
22
+ server: Plugin;
23
+ };
24
+ export default _default;
package/dist/server.js ADDED
@@ -0,0 +1,92 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { discoverAndEnrich, clearModelsDevCache } from "./discovery.js";
3
+ import { isValidUrl, sanitizeErrorMessage } from "./security.js";
4
+ export const id = "opencode-dynamic-custom-providers";
5
+ function shouldDiscover(provider) {
6
+ const baseURL = provider.options?.baseURL;
7
+ if (!baseURL || !isValidUrl(baseURL))
8
+ return false;
9
+ if (provider.dynamic === true)
10
+ return true;
11
+ const models = provider.models;
12
+ return !models || Object.keys(models).length === 0;
13
+ }
14
+ export function getApiKey(provider, providerId) {
15
+ if (provider.options?.apiKey)
16
+ return provider.options.apiKey;
17
+ const envKey = `OPENCODE_LOCAL_${providerId.toUpperCase().replaceAll(/[^A-Z0-9]/g, "_")}_API_KEY`;
18
+ return process.env[envKey];
19
+ }
20
+ export const server = async ({ client }) => {
21
+ await client.app.log({
22
+ body: {
23
+ level: "info",
24
+ message: "Plugin initialized",
25
+ service: id,
26
+ },
27
+ });
28
+ return {
29
+ config: async (cfg) => {
30
+ if (!cfg.provider)
31
+ return;
32
+ for (const [providerId, providerConfig] of Object.entries(cfg.provider)) {
33
+ if (!shouldDiscover(providerConfig))
34
+ continue;
35
+ const baseURL = providerConfig.options.baseURL;
36
+ const apiKey = getApiKey(providerConfig, providerId);
37
+ try {
38
+ await client.app.log({
39
+ body: {
40
+ level: "info",
41
+ message: `Discovering models from ${providerId} at ${baseURL}`,
42
+ service: id,
43
+ },
44
+ });
45
+ const displayStyle = providerConfig.displayStyle ?? "slug";
46
+ const models = await discoverAndEnrich(baseURL, apiKey, displayStyle);
47
+ const count = Object.keys(models).length;
48
+ if (count === 0) {
49
+ await client.app.log({
50
+ body: {
51
+ level: "warn",
52
+ message: `No models discovered from ${providerId}`,
53
+ service: id,
54
+ },
55
+ });
56
+ continue;
57
+ }
58
+ providerConfig.models = { ...(providerConfig.models ?? {}), ...models };
59
+ providerConfig.npm = providerConfig.npm ?? "@ai-sdk/openai-compatible";
60
+ providerConfig.api = providerConfig.api ?? baseURL;
61
+ await client.app.log({
62
+ body: {
63
+ level: "info",
64
+ message: `Discovered ${count} model(s) from ${providerId}`,
65
+ service: id,
66
+ },
67
+ });
68
+ }
69
+ catch (error) {
70
+ await client.app.log({
71
+ body: {
72
+ level: "warn",
73
+ message: `Failed to discover models from ${providerId}: ${sanitizeErrorMessage(error)}`,
74
+ service: id,
75
+ },
76
+ });
77
+ }
78
+ }
79
+ },
80
+ tool: {
81
+ "refresh-models": tool({
82
+ description: "Clear the models.dev metadata cache and trigger re-discovery on next restart",
83
+ args: {},
84
+ async execute() {
85
+ clearModelsDevCache();
86
+ return "Model metadata cache cleared. Restart OpenCode to re-discover models from all dynamic providers.";
87
+ },
88
+ }),
89
+ },
90
+ };
91
+ };
92
+ export default { id, server };
package/dist/tui.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { TuiPlugin } from "@opencode-ai/plugin/tui";
2
+ export declare const id = "opencode-dynamic-custom-providers";
3
+ export declare const tui: TuiPlugin;
4
+ declare const _default: {
5
+ id: string;
6
+ tui: TuiPlugin;
7
+ };
8
+ export default _default;
package/dist/tui.js ADDED
@@ -0,0 +1,197 @@
1
+ import { discoverAndEnrich, clearModelsDevCache } from "./discovery.js";
2
+ import { isValidUrl, sanitizeErrorMessage } from "./security.js";
3
+ import { getApiKey } from "./server.js";
4
+ export const id = "opencode-dynamic-custom-providers";
5
+ function showPrompt(api, title, placeholder) {
6
+ return new Promise((resolve) => {
7
+ api.ui.dialog.replace(() => api.ui.DialogPrompt({
8
+ title,
9
+ placeholder,
10
+ onConfirm: (value) => resolve(value),
11
+ onCancel: () => resolve(null),
12
+ }), () => resolve(null));
13
+ });
14
+ }
15
+ function showConfirm(api, title, message) {
16
+ return new Promise((resolve) => {
17
+ api.ui.dialog.replace(() => api.ui.DialogConfirm({
18
+ title,
19
+ message,
20
+ onConfirm: () => resolve(true),
21
+ onCancel: () => resolve(false),
22
+ }), () => resolve(false));
23
+ });
24
+ }
25
+ function showSelect(api, title, options) {
26
+ return new Promise((resolve) => {
27
+ api.ui.dialog.replace(() => api.ui.DialogSelect({
28
+ title,
29
+ options: options.map((o) => ({
30
+ ...o,
31
+ onSelect: () => resolve(o.value),
32
+ })),
33
+ }), () => resolve(null));
34
+ });
35
+ }
36
+ export const tui = async (api) => {
37
+ api.command.register(() => [
38
+ {
39
+ title: "Reload Models",
40
+ value: "reload-models",
41
+ description: "Re-discover models from all dynamic providers without restarting",
42
+ slash: {
43
+ name: "reload-models",
44
+ aliases: ["refresh-models"],
45
+ },
46
+ onSelect: async () => {
47
+ const { data: config } = await api.client.config.get();
48
+ const providers = config?.provider ?? {};
49
+ const dynamicProviders = Object.entries(providers).filter(([, p]) => !!p.options?.baseURL && isValidUrl(p.options.baseURL));
50
+ if (dynamicProviders.length === 0) {
51
+ api.ui.toast({ message: "No dynamic providers configured.", variant: "warning" });
52
+ return;
53
+ }
54
+ api.ui.toast({ message: `Reloading models from ${dynamicProviders.length} provider(s)...`, variant: "info" });
55
+ clearModelsDevCache();
56
+ let totalModels = 0;
57
+ let failures = 0;
58
+ for (const [providerId, providerConfig] of dynamicProviders) {
59
+ const baseURL = providerConfig.options.baseURL;
60
+ const apiKey = getApiKey(providerConfig, providerId);
61
+ const displayStyle = providerConfig.displayStyle ?? "slug";
62
+ try {
63
+ const models = await discoverAndEnrich(baseURL, apiKey, displayStyle);
64
+ const count = Object.keys(models).length;
65
+ if (count > 0) {
66
+ providerConfig.models = { ...(providerConfig.models ?? {}), ...models };
67
+ totalModels += count;
68
+ }
69
+ else if (providerConfig.models && Object.keys(providerConfig.models).length > 0) {
70
+ api.ui.toast({
71
+ message: `${providerId} returned 0 models (keeping previous models). Check endpoint status.`,
72
+ variant: "warning",
73
+ });
74
+ }
75
+ }
76
+ catch (error) {
77
+ failures++;
78
+ api.ui.toast({
79
+ message: `Failed to reload ${providerId}: ${sanitizeErrorMessage(error)}`,
80
+ variant: "error",
81
+ });
82
+ }
83
+ }
84
+ if (totalModels > 0) {
85
+ await api.client.config.update({ config: { provider: providers } });
86
+ }
87
+ if (failures === 0) {
88
+ api.ui.toast({
89
+ message: `Reloaded ${totalModels} model(s) from ${dynamicProviders.length} provider(s).`,
90
+ variant: "success",
91
+ });
92
+ }
93
+ else if (totalModels > 0) {
94
+ api.ui.toast({
95
+ message: `Reloaded ${totalModels} model(s) with ${failures} failure(s). Check provider URLs/keys.`,
96
+ variant: "warning",
97
+ });
98
+ }
99
+ },
100
+ },
101
+ {
102
+ title: "Add Provider",
103
+ value: "add-provider",
104
+ description: "Add a new OpenAI-compatible custom provider with dynamic model discovery",
105
+ slash: {
106
+ name: "add-provider",
107
+ },
108
+ onSelect: async () => {
109
+ const rawProviderId = await showPrompt(api, "Provider ID", "e.g., my-proxy (alphanumeric only)");
110
+ if (!rawProviderId)
111
+ return;
112
+ const providerId = rawProviderId.trim();
113
+ if (!/^[a-zA-Z0-9_-]+$/.test(providerId)) {
114
+ api.ui.toast({ message: "Invalid Provider ID. Use alphanumeric, hyphens, or underscores only.", variant: "error" });
115
+ return;
116
+ }
117
+ const { data: config } = await api.client.config.get();
118
+ const providers = config?.provider ?? {};
119
+ if (providers[providerId]) {
120
+ const overwrite = await showConfirm(api, "Provider already exists", `A provider with ID '${providerId}' already exists. Overwrite it?`);
121
+ if (!overwrite)
122
+ return;
123
+ }
124
+ const rawBaseURL = await showPrompt(api, "Base URL", "https://api.example.com/v1");
125
+ if (!rawBaseURL)
126
+ return;
127
+ const baseURL = rawBaseURL.trim().replace(/\/+$/, "");
128
+ if (!isValidUrl(baseURL)) {
129
+ api.ui.toast({ message: "Invalid Base URL. Please enter a full URL (including https://).", variant: "error" });
130
+ return;
131
+ }
132
+ const rawApiKey = await showPrompt(api, "API Key (Optional)", "sk-...");
133
+ const apiKey = rawApiKey?.trim() || undefined;
134
+ const displayStyle = await showSelect(api, "Model Display Names", [
135
+ {
136
+ title: "Full Slug",
137
+ value: "slug",
138
+ description: "vertex/gemini-3.1-pro (best for proxies)",
139
+ },
140
+ {
141
+ title: "Friendly Name",
142
+ value: "name",
143
+ description: "Gemini 3.1 Pro (may be ambiguous)",
144
+ },
145
+ ]);
146
+ if (!displayStyle)
147
+ return;
148
+ api.ui.toast({ message: `Discovering models from ${baseURL}...`, variant: "info" });
149
+ try {
150
+ const models = await discoverAndEnrich(baseURL, apiKey, displayStyle);
151
+ const modelCount = Object.keys(models).length;
152
+ if (modelCount === 0) {
153
+ api.ui.toast({ message: "No models found at the given endpoint. Provider not added.", variant: "warning" });
154
+ return;
155
+ }
156
+ const providerEntry = {
157
+ name: providerId,
158
+ npm: "@ai-sdk/openai-compatible",
159
+ api: baseURL,
160
+ options: { baseURL },
161
+ dynamic: true,
162
+ displayStyle,
163
+ };
164
+ if (apiKey) {
165
+ let storedInAuthStore = false;
166
+ try {
167
+ await api.client.auth.set({
168
+ providerID: providerId,
169
+ auth: { type: "api", key: apiKey },
170
+ });
171
+ storedInAuthStore = true;
172
+ }
173
+ catch {
174
+ // Auth store unavailable — fall back to config file
175
+ }
176
+ if (!storedInAuthStore) {
177
+ providerEntry.options.apiKey = apiKey;
178
+ }
179
+ }
180
+ providers[providerId] = providerEntry;
181
+ await api.client.config.update({ config: { provider: providers } });
182
+ api.ui.toast({
183
+ message: `Provider '${providerId}' added (${modelCount} models discovered). Restart opencode to use it.`,
184
+ variant: "success",
185
+ });
186
+ }
187
+ catch (error) {
188
+ api.ui.toast({
189
+ message: `Failed to discover models: ${sanitizeErrorMessage(error)}`,
190
+ variant: "error",
191
+ });
192
+ }
193
+ },
194
+ },
195
+ ]);
196
+ };
197
+ export default { id, tui };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "opencode-dynamic-custom-providers",
3
+ "version": "2.0.0",
4
+ "description": "Dynamic model discovery for OpenAI-compatible providers in OpenCode with models.dev enrichment",
5
+ "type": "module",
6
+ "main": "./dist/server.js",
7
+ "types": "./dist/server.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/server.d.ts",
14
+ "default": "./dist/server.js"
15
+ },
16
+ "./server": {
17
+ "types": "./dist/server.d.ts",
18
+ "default": "./dist/server.js"
19
+ },
20
+ "./tui": {
21
+ "types": "./dist/tui.d.ts",
22
+ "default": "./dist/tui.js"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "test": "npm run build && node tests/security.test.mjs && node tests/discovery.test.mjs",
28
+ "prepare": "npm run build",
29
+ "prepublishOnly": "npm test"
30
+ },
31
+ "keywords": [
32
+ "opencode",
33
+ "plugin",
34
+ "ai",
35
+ "openai",
36
+ "dynamic",
37
+ "discovery",
38
+ "models.dev"
39
+ ],
40
+ "author": "b3nw",
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/b3nw/opencode-dynamic-custom-providers.git"
45
+ },
46
+ "dependencies": {
47
+ "@opencode-ai/plugin": "^1.14.0"
48
+ },
49
+ "devDependencies": {
50
+ "@opencode-ai/sdk": "^1.14.0",
51
+ "@opentui/solid": "^0.2.0",
52
+ "@types/node": "^20.0.0",
53
+ "typescript": "^5.0.0"
54
+ },
55
+ "peerDependencies": {
56
+ "@opencode-ai/plugin": "^1.14.0"
57
+ },
58
+ "engines": {
59
+ "opencode": ">=1.14.0"
60
+ }
61
+ }