opencode-models-discovery 0.7.0 → 0.8.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/README.md CHANGED
@@ -8,19 +8,18 @@
8
8
 
9
9
  > A universal OpenCode plugin for dynamic model discovery across **any OpenAI-compatible provider**.
10
10
 
11
- Originally inspired by [opencode-lmstudio](https://github.com/nicktasios/opencode-lmstudio), this project has been fully refactored into a general-purpose model discovery plugin with richer configuration controls for providers, models, naming, caching, and discovery behavior.
11
+ Originally inspired by [opencode-lmstudio](https://github.com/nicktasios/opencode-lmstudio), this project has been fully refactored into a general-purpose model discovery plugin with richer configuration controls for providers, models, naming, and discovery behavior.
12
12
 
13
13
  ## Features
14
14
 
15
15
  - **Universal Provider Support**: Works with any OpenAI-compatible provider (LM Studio, Ollama, LocalAI, gateways, and more)
16
- - **Dynamic Model Discovery**: Queries each provider's `/v1/models` endpoint to discover available models
16
+ - **Dynamic Model Discovery**: Queries each provider's configured models endpoint to discover available models
17
17
  - **Auto-Injection**: Automatically adds unconfigured models into OpenCode provider config
18
18
  - **Provider Filtering**: Include or exclude specific providers from discovery
19
19
  - **Model Filtering**: Use regex rules to precisely control which discovered models are injected
20
- - **Configurable Discovery**: Control discovery behavior with enable/disable switches and TTL-based caching
20
+ - **Configurable Discovery**: Control discovery behavior with global and provider-level enable/disable switches
21
21
  - **Smart Model Formatting**: Optional human-friendly display names for discovered models
22
22
  - **Organization Owner Extraction**: Extracts and sets `organizationOwner` from model IDs when available
23
- - **Health Check Monitoring**: Verifies providers are accessible before attempting discovery
24
23
  - **Model Merging**: Intelligently merges discovered models with existing configuration
25
24
  - **Error Handling**: Smart error categorization with actionable suggestions
26
25
 
@@ -43,11 +42,16 @@ Add the plugin to your `opencode.json`:
43
42
  "opencode-models-discovery@latest"
44
43
  ],
45
44
  "provider": {
46
- "ollama": {
45
+ "deepseek": {
47
46
  "npm": "@ai-sdk/openai-compatible",
48
- "name": "Ollama (local)",
47
+ "name": "DeepSeek",
49
48
  "options": {
50
- "baseURL": "http://127.0.0.1:11434/v1"
49
+ "baseURL": "https://api.deepseek.com",
50
+ "apiKey": "YOUR_DEEPSEEK_API_KEY",
51
+ "modelsDiscovery": {
52
+ "enabled": true,
53
+ "endpoint": "/models"
54
+ }
51
55
  }
52
56
  },
53
57
  "lmstudio": {
@@ -63,6 +67,8 @@ Add the plugin to your `opencode.json`:
63
67
 
64
68
  ### Configuration
65
69
 
70
+ The plugin still supports global configuration in the `plugin` array, but for new setups it is recommended to prefer `provider.<name>.options.modelsDiscovery` for provider-specific behavior. This keeps discovery rules close to the provider they affect and avoids older global rules unintentionally changing newer providers.
71
+
66
72
  The plugin configuration is placed in the `plugin` array using tuple format `["plugin-name", { config }]`:
67
73
 
68
74
  ```json
@@ -95,10 +101,19 @@ Each provider can override discovery behavior through `provider.<name>.options.m
95
101
  | Option | Type | Description |
96
102
  |--------|------|-------------|
97
103
  | `provider.<name>.options.modelsDiscovery.enabled` | `boolean` | Override global discovery and provider filters for a single provider |
104
+ | `provider.<name>.options.modelsDiscovery.endpoint` | `string` | Provider-specific models endpoint path. Defaults to `/v1/models` |
98
105
  | `provider.<name>.options.modelsDiscovery.models.includeRegex` | `string[]` | Provider-specific model include filter |
99
106
  | `provider.<name>.options.modelsDiscovery.models.excludeRegex` | `string[]` | Provider-specific model exclude filter |
100
107
  | `provider.<name>.options.modelsDiscovery.smartModelName` | `boolean` | Override global `smartModelName` for a single provider |
101
108
 
109
+ Recommended approach for new configurations:
110
+
111
+ 1. Keep global plugin config minimal, or use it only as a broad default
112
+ 2. Put endpoint, enablement, and model filtering rules on each provider
113
+ 3. Use provider-level overrides whenever a provider does not follow the usual `/v1/models` convention
114
+
115
+ If `provider.<name>.options.modelsDiscovery.endpoint` is omitted, the plugin uses `/v1/models`.
116
+
102
117
  Priority rules:
103
118
 
104
119
  1. `provider.<name>.options.modelsDiscovery.enabled` overrides global `discovery.enabled` and `providers.include/exclude`
@@ -110,16 +125,9 @@ Priority rules:
110
125
  {
111
126
  "plugin": [
112
127
  ["opencode-models-discovery", {
113
- "providers": {
114
- "include": ["ollama"]
115
- },
116
- "models": {
117
- "includeRegex": ["^qwen/"]
118
- },
119
128
  "discovery": {
120
129
  "enabled": false
121
- },
122
- "smartModelName": false
130
+ }
123
131
  }]
124
132
  ],
125
133
  "provider": {
@@ -130,6 +138,7 @@ Priority rules:
130
138
  "baseURL": "http://127.0.0.1:1234/v1",
131
139
  "modelsDiscovery": {
132
140
  "enabled": true,
141
+ "endpoint": "/v1/models",
133
142
  "models": {
134
143
  "includeRegex": ["^gpt-"]
135
144
  },
@@ -137,6 +146,70 @@ Priority rules:
137
146
  }
138
147
  },
139
148
  "models": {}
149
+ },
150
+ "deepseek": {
151
+ "npm": "@ai-sdk/openai-compatible",
152
+ "name": "DeepSeek",
153
+ "options": {
154
+ "baseURL": "https://api.deepseek.com",
155
+ "apiKey": "sk-example-deepseek-key",
156
+ "modelsDiscovery": {
157
+ "enabled": true,
158
+ "endpoint": "/models",
159
+ "smartModelName": true
160
+ }
161
+ },
162
+ "models": {}
163
+ }
164
+ }
165
+ }
166
+ ```
167
+
168
+ In this example:
169
+
170
+ 1. `lmstudio` explicitly enables discovery and uses the default `/v1/models` endpoint
171
+ 2. `lmstudio` limits discovery to models matching `^gpt-`
172
+ 3. `deepseek` explicitly enables discovery but uses `"/models"` instead of `/v1/models`
173
+ 4. The API key uses an example placeholder and should be replaced in real configs
174
+
175
+ #### Provider-First Example
176
+
177
+ This is the recommended style for newer configs, especially when different providers need different discovery paths:
178
+
179
+ ```json
180
+ {
181
+ "$schema": "https://opencode.ai/config.json",
182
+ "plugin": [
183
+ ["opencode-models-discovery", {
184
+ "smartModelName": false
185
+ }]
186
+ ],
187
+ "provider": {
188
+ "ollama": {
189
+ "npm": "@ai-sdk/openai-compatible",
190
+ "name": "Ollama",
191
+ "options": {
192
+ "baseURL": "http://127.0.0.1:11434/v1",
193
+ "modelsDiscovery": {
194
+ "enabled": true,
195
+ "models": {
196
+ "includeRegex": ["^qwen/"]
197
+ }
198
+ }
199
+ }
200
+ },
201
+ "deepseek": {
202
+ "npm": "@ai-sdk/openai-compatible",
203
+ "name": "DeepSeek",
204
+ "options": {
205
+ "baseURL": "https://api.deepseek.com",
206
+ "apiKey": "YOUR_DEEPSEEK_API_KEY",
207
+ "modelsDiscovery": {
208
+ "enabled": true,
209
+ "endpoint": "/models",
210
+ "smartModelName": true
211
+ }
212
+ }
140
213
  }
141
214
  }
142
215
  }
@@ -144,10 +217,10 @@ Priority rules:
144
217
 
145
218
  In this example:
146
219
 
147
- 1. Global discovery is disabled
148
- 2. `lmstudio` is still discovered because `modelsDiscovery.enabled` is `true`
149
- 3. `lmstudio` uses `^gpt-` instead of the global `^qwen/` filter
150
- 4. `lmstudio` uses smart model names even though the global setting is `false`
220
+ 1. The global plugin config only keeps a shared default
221
+ 2. `ollama` uses the default discovery path derived from its `/v1` baseURL
222
+ 3. `deepseek` does not rely on `/v1/models` and explicitly uses `"/models"`
223
+ 4. Each provider can evolve independently without changing global include or endpoint rules
151
224
 
152
225
  #### Provider Filtering
153
226
 
@@ -199,8 +272,8 @@ Regex filtering only applies to auto-discovered models. Models already explicitl
199
272
 
200
273
  1. On OpenCode startup, the plugin's `config` hook is called
201
274
  2. The plugin iterates through all configured providers
202
- 3. For each provider, it checks if the baseURL contains `/v1/` (supports any npm package)
203
- 4. For each accessible provider, it queries the `/v1/models` endpoint
275
+ 3. For each provider, it checks whether it is OpenAI-compatible by npm, by a `/v1` baseURL, by an explicit discovery endpoint override, or by a forced provider-level discovery override
276
+ 4. For each accessible provider, it queries the configured models endpoint, defaulting to `/v1/models`
204
277
  5. Discovered models are automatically merged into the provider's configuration
205
278
  6. The enhanced configuration is used for the current session
206
279
 
@@ -217,6 +290,7 @@ The plugin supports any OpenAI-compatible provider. Here are the most common one
217
290
  | **Text Generation WebUI** | 5000 | OpenAI-compatible extension | `@ai-sdk/openai-compatible` |
218
291
  | **FastChat (Vicuna)** | 8001 | Multi-model serving | `@ai-sdk/openai-compatible` |
219
292
  | **vLLM** | 8000 | High-performance inference | `@ai-sdk/openai-compatible` |
293
+ | **DeepSeek** | Cloud | OpenAI-compatible API with `/models` discovery endpoint | `@ai-sdk/openai-compatible` |
220
294
  | **CLIProxyAPI** | 8317 | A LLM proxy server | `@ai-sdk/anthropic` (with `/v1` backend) & `@ai-sdk/openai-compatible` |
221
295
 
222
296
  #### Anthropic API with Custom Backend
@@ -248,17 +322,21 @@ Cloud services with OpenAI-compatible APIs are also supported:
248
322
 
249
323
  ### Provider Detection
250
324
 
251
- The plugin identifies OpenAI-compatible providers using **two detection methods**:
325
+ The plugin identifies OpenAI-compatible providers using these detection signals:
252
326
 
253
327
  1. **Strict Detection**: `npm === "@ai-sdk/openai-compatible"`
254
328
  2. **URL-based Detection**: `baseURL` contains `/v1/` pattern
329
+ 3. **Endpoint Override Detection**: `options.modelsDiscovery.endpoint` is configured
330
+
331
+ In addition, `options.modelsDiscovery.enabled === true` can force discovery even when the provider does not match the detection rules above.
255
332
 
256
- A provider is considered discoverable if **either** condition matches.
333
+ A provider is considered discoverable if it matches any detection signal above, or if discovery is explicitly forced on.
257
334
 
258
335
  #### Examples of Supported Configurations
259
336
 
260
337
  ```json
261
338
  {
339
+ "plugin": ["opencode-models-discovery"],
262
340
  "provider": {
263
341
  "ollama": {
264
342
  "npm": "@ai-sdk/openai-compatible",
@@ -271,6 +349,7 @@ A provider is considered discoverable if **either** condition matches.
271
349
 
272
350
  ```json
273
351
  {
352
+ "plugin": ["opencode-models-discovery"],
274
353
  "provider": {
275
354
  "ollama-anthropic": {
276
355
  "npm": "@ai-sdk/anthropic",
@@ -283,23 +362,51 @@ A provider is considered discoverable if **either** condition matches.
283
362
 
284
363
  ```json
285
364
  {
365
+ "plugin": [
366
+ ["opencode-models-discovery", {
367
+ "smartModelName": false
368
+ }]
369
+ ],
286
370
  "provider": {
287
371
  "lmstudio": {
288
372
  "npm": "@ai-sdk/openai-compatible",
289
373
  "name": "LM Studio",
290
- "options": { "baseURL": "http://127.0.0.1:1234/v1" }
374
+ "options": {
375
+ "baseURL": "http://127.0.0.1:1234/v1",
376
+ "modelsDiscovery": {
377
+ "enabled": true
378
+ }
379
+ }
380
+ }
381
+ }
382
+ }
383
+ ```
384
+
385
+ ```json
386
+ {
387
+ "plugin": ["opencode-models-discovery"],
388
+ "provider": {
389
+ "deepseek": {
390
+ "npm": "@ai-sdk/openai-compatible",
391
+ "name": "DeepSeek",
392
+ "options": {
393
+ "baseURL": "https://api.deepseek.com",
394
+ "modelsDiscovery": {
395
+ "endpoint": "/models"
396
+ }
397
+ }
291
398
  }
292
399
  }
293
400
  }
294
401
  ```
295
402
 
296
- This means providers using `@ai-sdk/anthropic` with OpenAI-compatible backends (like Ollama's Anthropic compatibility mode) are also supported, as long as the `baseURL` contains `/v1/`.
403
+ This means providers using `@ai-sdk/anthropic` with OpenAI-compatible backends are also supported when the `baseURL` contains `/v1/`, when a provider-specific discovery endpoint is configured, or when provider-level discovery is explicitly forced on. It also means providers like DeepSeek can be discovered from a non-`/v1` baseURL as long as the models endpoint is configured explicitly.
297
404
 
298
405
  ## Requirements
299
406
 
300
407
  - OpenCode with plugin support
301
408
  - At least one OpenAI-compatible provider running locally or remotely
302
- - Provider server API accessible (e.g., `http://127.0.0.1:11434/v1`)
409
+ - Provider server API accessible, using either a `/v1`-style base URL or an explicitly configured models endpoint such as `/models`
303
410
 
304
411
  ## Logging
305
412
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-models-discovery",
4
- "version": "0.7.0",
4
+ "version": "0.8.0",
5
5
  "description": "OpenCode plugin for auto-discovery of OpenAI-compatible models with dynamic provider configuration",
6
6
  "type": "module",
7
7
  "main": "./src/index.ts",
@@ -1,6 +1,6 @@
1
1
  import { ToastNotifier } from '../ui/toast-notifier'
2
2
  import { categorizeModel, formatModelName, extractModelOwner } from '../utils'
3
- import { normalizeBaseURL, checkProviderHealth, discoverModelsFromProvider, autoDetectOpenAICompatibleProvider, canDiscoverModels } from '../utils/openai-compatible-api'
3
+ import { normalizeBaseURL, discoverModelsFromProvider, autoDetectOpenAICompatibleProvider, canDiscoverModels } from '../utils/openai-compatible-api'
4
4
  import { getProviderFilter, getDiscoveryConfig, getModelRegexFilter, getProviderModelRegexFilter, shouldDiscoverModel, shouldDiscoverProviderWithOverride } from '../types/plugin-config'
5
5
  import type { PluginLogger } from './logger'
6
6
  import type { PluginInput } from '@opencode-ai/plugin'
@@ -31,8 +31,10 @@ export async function enhanceConfig(
31
31
  for (const [providerName, providerConfig] of Object.entries(providers)) {
32
32
  const p = providerConfig as any
33
33
  const providerDiscoveryConfig = p.options?.modelsDiscovery ?? {}
34
+ const modelsEndpoint = providerDiscoveryConfig.endpoint ?? '/v1/models'
35
+ const forceDiscoveryEnabled = providerDiscoveryConfig.enabled === true
34
36
 
35
- if (!canDiscoverModels(p)) {
37
+ if (!forceDiscoveryEnabled && !canDiscoverModels(p)) {
36
38
  continue
37
39
  }
38
40
 
@@ -52,24 +54,19 @@ export async function enhanceConfig(
52
54
 
53
55
  const apiKey = p.options?.apiKey
54
56
 
55
- const isHealthy = await checkProviderHealth(baseURL, apiKey)
56
- if (!isHealthy) {
57
- // Provider offline - silent, this is normal for health checks
58
- continue
59
- }
60
-
61
57
  let models: OpenAIModel[]
62
- try {
63
- models = await discoverModelsFromProvider(baseURL, apiKey)
64
- } catch (error) {
58
+ const discovery = await discoverModelsFromProvider(baseURL, apiKey, modelsEndpoint)
59
+ if (!discovery.ok) {
65
60
  logger.warn('Provider model discovery failed', {
66
61
  provider: providerName,
67
62
  baseURL,
68
- error: error instanceof Error ? error.message : String(error),
63
+ endpoint: modelsEndpoint,
69
64
  })
70
65
  continue
71
66
  }
72
67
 
68
+ models = discovery.models
69
+
73
70
  if (models.length === 0) {
74
71
  continue
75
72
  }
@@ -15,6 +15,7 @@ export interface PluginConfig {
15
15
 
16
16
  export interface ProviderDiscoveryConfig {
17
17
  enabled?: boolean
18
+ endpoint?: string
18
19
  models?: {
19
20
  includeRegex?: string[]
20
21
  excludeRegex?: string[]
@@ -2,6 +2,11 @@ import type { OpenAIModel, OpenAIModelsResponse } from '../types'
2
2
 
3
3
  const OPENAI_COMPATIBLE_MODELS_ENDPOINT = "/v1/models"
4
4
 
5
+ export interface ModelsDiscoveryResult {
6
+ ok: boolean
7
+ models: OpenAIModel[]
8
+ }
9
+
5
10
  export function normalizeBaseURL(baseURL: string): string {
6
11
  let normalized = baseURL.replace(/\/+$/, '')
7
12
  if (normalized.endsWith('/v1')) {
@@ -15,27 +20,13 @@ export function buildAPIURL(baseURL: string, endpoint: string = OPENAI_COMPATIBL
15
20
  return `${normalized}${endpoint}`
16
21
  }
17
22
 
18
- export async function checkProviderHealth(baseURL: string, apiKey?: string): Promise<boolean> {
19
- try {
20
- const url = buildAPIURL(baseURL)
21
- const headers: Record<string, string> = {}
22
- if (apiKey) {
23
- headers["Authorization"] = `Bearer ${apiKey}`
24
- }
25
- const response = await fetch(url, {
26
- method: "GET",
27
- headers: Object.keys(headers).length > 0 ? headers : undefined,
28
- signal: AbortSignal.timeout(3000),
29
- })
30
- return response.ok
31
- } catch {
32
- return false
33
- }
34
- }
35
-
36
- export async function discoverModelsFromProvider(baseURL: string, apiKey?: string): Promise<OpenAIModel[]> {
23
+ export async function discoverModelsFromProvider(
24
+ baseURL: string,
25
+ apiKey?: string,
26
+ endpoint: string = OPENAI_COMPATIBLE_MODELS_ENDPOINT
27
+ ): Promise<ModelsDiscoveryResult> {
37
28
  try {
38
- const url = buildAPIURL(baseURL)
29
+ const url = buildAPIURL(baseURL, endpoint)
39
30
  const headers: Record<string, string> = {
40
31
  "Content-Type": "application/json",
41
32
  }
@@ -49,19 +40,22 @@ export async function discoverModelsFromProvider(baseURL: string, apiKey?: strin
49
40
  })
50
41
 
51
42
  if (!response.ok) {
52
- return []
43
+ return { ok: false, models: [] }
53
44
  }
54
45
 
55
46
  const data = (await response.json()) as OpenAIModelsResponse
56
- return data.data ?? []
57
- } catch (error) {
58
- throw new Error(`Failed to discover models: ${error instanceof Error ? error.message : String(error)}`)
47
+ return {
48
+ ok: true,
49
+ models: data.data ?? [],
50
+ }
51
+ } catch {
52
+ return { ok: false, models: [] }
59
53
  }
60
54
  }
61
55
 
62
- export async function fetchModelsDirect(baseURL: string): Promise<string[]> {
56
+ export async function fetchModelsDirect(baseURL: string, endpoint: string = OPENAI_COMPATIBLE_MODELS_ENDPOINT): Promise<string[]> {
63
57
  try {
64
- const url = buildAPIURL(baseURL)
58
+ const url = buildAPIURL(baseURL, endpoint)
65
59
  const response = await fetch(url, {
66
60
  method: "GET",
67
61
  signal: AbortSignal.timeout(3000),
@@ -87,8 +81,8 @@ export async function autoDetectOpenAICompatibleProvider(): Promise<{ name: stri
87
81
  for (const candidate of candidates) {
88
82
  for (const port of candidate.ports) {
89
83
  const baseURL = `http://127.0.0.1:${port}`
90
- const isHealthy = await checkProviderHealth(baseURL)
91
- if (isHealthy) {
84
+ const discovery = await discoverModelsFromProvider(baseURL)
85
+ if (discovery.ok) {
92
86
  return { name: candidate.name, baseURL }
93
87
  }
94
88
  }
@@ -108,8 +102,14 @@ export function hasOpenAICompatibleURL(provider: any): boolean {
108
102
  return /\/v1(\/|$)/.test(baseURL)
109
103
  }
110
104
 
105
+ export function hasModelsDiscoveryEndpoint(provider: any): boolean {
106
+ if (!provider || typeof provider !== 'object') return false
107
+ const endpoint = provider.options?.modelsDiscovery?.endpoint
108
+ return typeof endpoint === 'string' && endpoint.length > 0
109
+ }
110
+
111
111
  export function canDiscoverModels(provider: any): boolean {
112
- return isOpenAICompatibleProvider(provider) || hasOpenAICompatibleURL(provider)
112
+ return isOpenAICompatibleProvider(provider) || hasOpenAICompatibleURL(provider) || hasModelsDiscoveryEndpoint(provider)
113
113
  }
114
114
 
115
115
  export function isValidModel(model: any): model is { id: string; [key: string]: any } {
@@ -117,4 +117,4 @@ export function isValidModel(model: any): model is { id: string; [key: string]:
117
117
  typeof model === 'object' &&
118
118
  typeof model.id === 'string' &&
119
119
  model.id.length > 0
120
- }
120
+ }
@@ -13,8 +13,9 @@ export function validateConfig(config: any): ValidationResult {
13
13
  if (config.provider && typeof config.provider === 'object') {
14
14
  for (const [providerName, providerConfig] of Object.entries(config.provider)) {
15
15
  const p = providerConfig as any
16
+ const forceDiscoveryEnabled = p.options?.modelsDiscovery?.enabled === true
16
17
 
17
- if (canDiscoverModels(p)) {
18
+ if (forceDiscoveryEnabled || canDiscoverModels(p)) {
18
19
  if (!p.options?.baseURL) {
19
20
  warnings.push(`Provider '${providerName}' missing baseURL`)
20
21
  }
@@ -30,4 +31,4 @@ export function validateConfig(config: any): ValidationResult {
30
31
  errors,
31
32
  warnings
32
33
  }
33
- }
34
+ }