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 +21 -0
- package/README.md +132 -0
- package/dist/discovery.d.ts +51 -0
- package/dist/discovery.js +240 -0
- package/dist/security.d.ts +3 -0
- package/dist/security.js +34 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.js +92 -0
- package/dist/tui.d.ts +8 -0
- package/dist/tui.js +197 -0
- package/package.json +61 -0
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
|
+
}
|
package/dist/security.js
ADDED
|
@@ -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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -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
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
|
+
}
|