opencode-models-discovery 0.6.0 → 0.7.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
@@ -2,23 +2,27 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/opencode-models-discovery.svg?color=blue)](https://www.npmjs.com/package/opencode-models-discovery)
4
4
  [![npm downloads](https://img.shields.io/npm/dt/opencode-models-discovery.svg)](https://www.npmjs.com/package/opencode-models-discovery)
5
+ [![release](https://github.com/yuhp/opencode-models-discovery/actions/workflows/release.yml/badge.svg)](https://github.com/yuhp/opencode-models-discovery/actions/workflows/release.yml)
6
+ [![license](https://img.shields.io/github/license/yuhp/opencode-models-discovery)](https://github.com/yuhp/opencode-models-discovery/blob/main/LICENSE)
7
+ [![OpenCode](https://img.shields.io/badge/OpenCode-%3E%3D1.4.0-blueviolet)](https://opencode.ai)
5
8
 
6
- > Forked from [opencode-lmstudio](https://github.com/nicktasios/opencode-lmstudio) and expanded to support **any OpenAI-compatible provider**.
9
+ > A universal OpenCode plugin for dynamic model discovery across **any OpenAI-compatible provider**.
7
10
 
8
- OpenCode plugin for auto-discovery of OpenAI-compatible models with dynamic provider configuration.
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.
9
12
 
10
13
  ## Features
11
14
 
12
- - **Multi-Provider Support**: Works with any OpenAI-compatible provider (LM Studio, Ollama, LocalAI, etc.)
13
- - **Dynamic Model Discovery**: Queries provider's `/v1/models` endpoint to discover available models
14
- - **Auto-Injection**: Automatically adds unconfigured models to provider configuration
15
- - **Smart Model Formatting**: Optional formatting for better readability when explicitly enabled
16
- - **Organization Owner Extraction**: Extracts and sets `organizationOwner` field from model IDs
17
- - **Health Check Monitoring**: Verifies providers are accessible before attempting operations
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
17
+ - **Auto-Injection**: Automatically adds unconfigured models into OpenCode provider config
18
+ - **Provider Filtering**: Include or exclude specific providers from discovery
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
21
+ - **Smart Model Formatting**: Optional human-friendly display names for discovered models
22
+ - **Organization Owner Extraction**: Extracts and sets `organizationOwner` from model IDs when available
23
+ - **Health Check Monitoring**: Verifies providers are accessible before attempting discovery
18
24
  - **Model Merging**: Intelligently merges discovered models with existing configuration
19
- - **Comprehensive Caching**: Reduces API calls with intelligent caching system
20
- - **Error Handling**: Smart error categorization with auto-fix suggestions
21
- - **Configurable Discovery**: Enable/disable discovery and filter providers
25
+ - **Error Handling**: Smart error categorization with actionable suggestions
22
26
 
23
27
  ## Installation
24
28
 
@@ -74,8 +78,7 @@ The plugin configuration is placed in the `plugin` array using tuple format `["p
74
78
  "excludeRegex": []
75
79
  },
76
80
  "discovery": {
77
- "enabled": true,
78
- "ttl": 15000
81
+ "enabled": true
79
82
  },
80
83
  "smartModelName": false
81
84
  }]
@@ -85,6 +88,67 @@ The plugin configuration is placed in the `plugin` array using tuple format `["p
85
88
 
86
89
  Set `smartModelName` to `true` if you want discovered models to use human-friendly display names instead of the raw `model_id`. (e.g., "Qwen3 30B A3B" instead of "qwen/qwen3-30b-a3b")
87
90
 
91
+ #### Provider-Level Discovery Overrides
92
+
93
+ Each provider can override discovery behavior through `provider.<name>.options.modelsDiscovery`:
94
+
95
+ | Option | Type | Description |
96
+ |--------|------|-------------|
97
+ | `provider.<name>.options.modelsDiscovery.enabled` | `boolean` | Override global discovery and provider filters for a single provider |
98
+ | `provider.<name>.options.modelsDiscovery.models.includeRegex` | `string[]` | Provider-specific model include filter |
99
+ | `provider.<name>.options.modelsDiscovery.models.excludeRegex` | `string[]` | Provider-specific model exclude filter |
100
+ | `provider.<name>.options.modelsDiscovery.smartModelName` | `boolean` | Override global `smartModelName` for a single provider |
101
+
102
+ Priority rules:
103
+
104
+ 1. `provider.<name>.options.modelsDiscovery.enabled` overrides global `discovery.enabled` and `providers.include/exclude`
105
+ 2. If a provider defines its own `modelsDiscovery.models` filters, those filters replace global `models.includeRegex/excludeRegex` for that provider
106
+ 3. If a provider does not define its own model filters, global `models.includeRegex/excludeRegex` are used
107
+ 4. `provider.<name>.options.modelsDiscovery.smartModelName` overrides global `smartModelName`
108
+
109
+ ```json
110
+ {
111
+ "plugin": [
112
+ ["opencode-models-discovery", {
113
+ "providers": {
114
+ "include": ["ollama"]
115
+ },
116
+ "models": {
117
+ "includeRegex": ["^qwen/"]
118
+ },
119
+ "discovery": {
120
+ "enabled": false
121
+ },
122
+ "smartModelName": false
123
+ }]
124
+ ],
125
+ "provider": {
126
+ "lmstudio": {
127
+ "npm": "@ai-sdk/openai-compatible",
128
+ "name": "LM Studio",
129
+ "options": {
130
+ "baseURL": "http://127.0.0.1:1234/v1",
131
+ "modelsDiscovery": {
132
+ "enabled": true,
133
+ "models": {
134
+ "includeRegex": ["^gpt-"]
135
+ },
136
+ "smartModelName": true
137
+ }
138
+ },
139
+ "models": {}
140
+ }
141
+ }
142
+ }
143
+ ```
144
+
145
+ In this example:
146
+
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`
151
+
88
152
  #### Provider Filtering
89
153
 
90
154
  Control which providers are discovered:
@@ -239,7 +303,7 @@ This means providers using `@ai-sdk/anthropic` with OpenAI-compatible backends (
239
303
 
240
304
  ## Logging
241
305
 
242
- When available, the plugin writes logs through OpenCode's structured server log API via `client.app.log(...)` using the service name `opencode-model-discovery`.
306
+ When available, the plugin writes logs through OpenCode's structured server log API via `client.app.log(...)` using the service name `opencode-models-discovery`.
243
307
 
244
308
  If structured logging is unavailable in the runtime, the plugin falls back to prefixed `console.*` output. Key log categories are emitted through metadata such as `plugin`, `config`, `discovery`, `event`, and `filtering` to make local debugging easier with `opencode --print-logs`.
245
309
 
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.6.0",
4
+ "version": "0.7.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",
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export { ModelDiscoveryPlugin, LMStudioPlugin } from './plugin'
1
+ export { ModelDiscoveryPlugin } from './plugin'
@@ -21,7 +21,7 @@ export class ModelLoadingMonitor {
21
21
  progress: 0
22
22
  })
23
23
 
24
- console.info(`[opencode-model-discovery] Started monitoring model loading`, { modelId, baseURL })
24
+ console.info(`[opencode-models-discovery] Started monitoring model loading`, { modelId, baseURL })
25
25
 
26
26
  // Clear any existing interval
27
27
  this.stopMonitoring(modelId)
@@ -68,7 +68,7 @@ export class ModelLoadingMonitor {
68
68
  const duration = Date.now() - (state.startTime || Date.now())
69
69
  this.updateState(modelId, 'loaded', 100, 0)
70
70
  this.stopMonitoring(modelId)
71
- console.info(`[opencode-model-discovery] Model loading completed`, {
71
+ console.info(`[opencode-models-discovery] Model loading completed`, {
72
72
  modelId,
73
73
  duration: `${duration}ms`,
74
74
  totalModels: this.loadingStates.size
@@ -111,11 +111,11 @@ export class ModelLoadingMonitor {
111
111
  // Log state changes
112
112
  if (currentState.status !== status) {
113
113
  if (status === 'loaded') {
114
- console.info(`[opencode-model-discovery] Model loading completed`, { modelId })
114
+ console.info(`[opencode-models-discovery] Model loading completed`, { modelId })
115
115
  } else if (status === 'error') {
116
- console.warn(`[opencode-model-discovery] Model loading failed`, { modelId, error })
116
+ console.warn(`[opencode-models-discovery] Model loading failed`, { modelId, error })
117
117
  } else if (status === 'loading') {
118
- console.info(`[opencode-model-discovery] Model loading started`, { modelId })
118
+ console.info(`[opencode-models-discovery] Model loading started`, { modelId })
119
119
  }
120
120
  }
121
121
  }
@@ -138,4 +138,4 @@ export class ModelLoadingMonitor {
138
138
  }
139
139
  this.loadingStates.clear()
140
140
  }
141
- }
141
+ }
@@ -20,7 +20,7 @@ export function createConfigHook(
20
20
  const validation = validateConfig(config)
21
21
  if (!validation.isValid) {
22
22
  logger.error('Invalid config provided', { errors: validation.errors })
23
- toastNotifier.error("Plugin configuration is invalid", "Configuration Error").catch(() => {})
23
+ toastNotifier.error("Plugin configuration is invalid", "Configuration Error").catch(() => { })
24
24
  return
25
25
  }
26
26
 
@@ -28,10 +28,6 @@ export function createConfigHook(
28
28
  logger.warn('Config warnings', { warnings: validation.warnings })
29
29
  }
30
30
 
31
- if (pluginConfig.discovery?.enabled === false) {
32
- logger.info('Discovery disabled by configuration')
33
- return
34
- }
35
31
 
36
32
  const discoveryPromise = enhanceConfig(
37
33
  config,
@@ -1,15 +1,12 @@
1
- import { ModelStatusCache } from '../cache/model-status-cache'
2
1
  import { ToastNotifier } from '../ui/toast-notifier'
3
2
  import { categorizeModel, formatModelName, extractModelOwner } from '../utils'
4
3
  import { normalizeBaseURL, checkProviderHealth, discoverModelsFromProvider, autoDetectOpenAICompatibleProvider, canDiscoverModels } from '../utils/openai-compatible-api'
5
- import { getProviderFilter, getDiscoveryConfig, getModelRegexFilter, shouldDiscoverModel, shouldDiscoverProvider } from '../types/plugin-config'
4
+ import { getProviderFilter, getDiscoveryConfig, getModelRegexFilter, getProviderModelRegexFilter, shouldDiscoverModel, shouldDiscoverProviderWithOverride } from '../types/plugin-config'
6
5
  import type { PluginLogger } from './logger'
7
6
  import type { PluginInput } from '@opencode-ai/plugin'
8
7
  import type { OpenAIModel } from '../types'
9
8
  import type { PluginConfig } from '../types/plugin-config'
10
9
 
11
- const modelStatusCache = new ModelStatusCache()
12
-
13
10
  interface DiscoveredProvider {
14
11
  name: string
15
12
  baseURL: string
@@ -23,23 +20,24 @@ export async function enhanceConfig(
23
20
  pluginConfig: PluginConfig,
24
21
  logger: PluginLogger
25
22
  ): Promise<void> {
26
- modelStatusCache.invalidateAll()
27
-
28
23
  try {
29
24
  const providers = config.provider || {}
30
25
  const openAICompatibleProviders: DiscoveredProvider[] = []
31
26
  const providerFilter = getProviderFilter(pluginConfig)
32
27
  const modelRegexFilter = getModelRegexFilter(pluginConfig, logger.child({ category: 'filtering' }))
33
28
  const discoveryConfig = getDiscoveryConfig(pluginConfig)
29
+ const globalDiscoveryEnabled = discoveryConfig.enabled
34
30
 
35
31
  for (const [providerName, providerConfig] of Object.entries(providers)) {
36
32
  const p = providerConfig as any
37
-
33
+ const providerDiscoveryConfig = p.options?.modelsDiscovery ?? {}
34
+
38
35
  if (!canDiscoverModels(p)) {
39
36
  continue
40
37
  }
41
38
 
42
- if (!shouldDiscoverProvider(providerName, providerFilter)) {
39
+ if (!shouldDiscoverProviderWithOverride(providerName, providerFilter, globalDiscoveryEnabled, providerDiscoveryConfig)) {
40
+ logger.debug(`Provider ${providerName} model discovery disabled by configuration`)
43
41
  continue
44
42
  }
45
43
 
@@ -81,11 +79,18 @@ export async function enhanceConfig(
81
79
  let chatModelsCount = 0
82
80
  let embeddingModelsCount = 0
83
81
 
82
+ const hasProviderModelRegexFilter = !!providerDiscoveryConfig.models?.includeRegex?.length || !!providerDiscoveryConfig.models?.excludeRegex?.length
83
+ const providerModelRegexFilter = getProviderModelRegexFilter(providerDiscoveryConfig, logger.child({ category: 'filtering' }))
84
+ let smartModelNameEnabled = providerDiscoveryConfig.smartModelName
85
+ if (smartModelNameEnabled === undefined) {
86
+ smartModelNameEnabled = pluginConfig.smartModelName
87
+ }
88
+
84
89
  for (const model of models) {
85
90
  const modelKey = model.id
86
-
87
91
  if (!existingModels[modelKey]) {
88
- if (!shouldDiscoverModel(model.id, modelRegexFilter)) {
92
+ const activeModelRegexFilter = hasProviderModelRegexFilter ? providerModelRegexFilter : modelRegexFilter
93
+ if (!shouldDiscoverModel(model.id, activeModelRegexFilter)) {
89
94
  continue
90
95
  }
91
96
 
@@ -93,7 +98,7 @@ export async function enhanceConfig(
93
98
  const owner = extractModelOwner(model.id)
94
99
  const modelConfig: any = {
95
100
  id: model.id,
96
- name: pluginConfig.smartModelName ? formatModelName(model) : model.id,
101
+ name: smartModelNameEnabled ? formatModelName(model) : model.id,
97
102
  }
98
103
 
99
104
  if (owner) {
@@ -154,33 +159,10 @@ export async function enhanceConfig(
154
159
  }
155
160
  }
156
161
 
157
- try {
158
- for (const [providerName, providerConfig] of Object.entries(providers)) {
159
- const p = providerConfig as any
160
- if (!canDiscoverModels(p) || !shouldDiscoverProvider(providerName, providerFilter)) {
161
- continue
162
- }
163
- if (p.options?.baseURL) {
164
- const baseURL = normalizeBaseURL(p.options.baseURL)
165
- if (discoveryConfig.ttl) {
166
- modelStatusCache.setTTL(baseURL, discoveryConfig.ttl)
167
- }
168
- if (!modelStatusCache.isValid(baseURL)) {
169
- await modelStatusCache.getModels(baseURL, async () => {
170
- return await discoverModelsFromProvider(baseURL).then(models => models.map(m => m.id))
171
- })
172
- }
173
- }
174
- }
175
- } catch (error) {
176
- logger.warn('Model status cache refresh failed', {
177
- error: error instanceof Error ? error.message : String(error),
178
- })
179
- }
180
162
  } catch (error) {
181
163
  logger.error('Unexpected error in enhanceConfig', {
182
164
  error: error instanceof Error ? error.message : String(error),
183
165
  })
184
- toastNotifier.warning("Plugin configuration failed", "Configuration Error").catch(() => {})
166
+ toastNotifier.warning("Plugin configuration failed", "Configuration Error").catch(() => { })
185
167
  }
186
168
  }
@@ -13,9 +13,9 @@ export const ModelDiscoveryPlugin: Plugin = async (input: PluginInput, options?:
13
13
  if (!client || typeof client !== 'object') {
14
14
  logger.error('Invalid client provided to plugin')
15
15
  return {
16
- config: async () => {},
17
- event: async () => {},
18
- "chat.params": async () => {}
16
+ config: async () => { },
17
+ event: async () => { },
18
+ "chat.params": async () => { }
19
19
  }
20
20
  }
21
21
 
@@ -36,4 +36,4 @@ export const ModelDiscoveryPlugin: Plugin = async (input: PluginInput, options?:
36
36
  }
37
37
  }
38
38
 
39
- export const LMStudioPlugin = ModelDiscoveryPlugin
39
+ //export const LMStudioPlugin = ModelDiscoveryPlugin
@@ -22,61 +22,5 @@ export interface ModelLoadingState {
22
22
  error?: string
23
23
  }
24
24
 
25
- export interface ModelValidationError {
26
- type: 'offline' | 'not_found' | 'network' | 'permission' | 'timeout' | 'unknown'
27
- severity: 'low' | 'medium' | 'high' | 'critical'
28
- message: string
29
- canRetry: boolean
30
- autoFixAvailable: boolean
31
- }
32
-
33
- export interface AutoFixSuggestion {
34
- action: string
35
- command?: string
36
- steps?: string[]
37
- automated: boolean
38
- }
39
-
40
- export interface SimilarModel {
41
- model: string
42
- similarity: number
43
- reason: string
44
- }
45
-
46
- export interface CacheStats {
47
- size: number
48
- entries: Array<{
49
- baseURL: string
50
- age: number
51
- modelCount: number
52
- ttl: number
53
- }>
54
- }
55
-
56
- export interface ModelValidationResult {
57
- status: 'success' | 'error'
58
- model: string
59
- availableModels: string[]
60
- message: string
61
- errorCategory?: string
62
- severity?: string
63
- canRetry?: boolean
64
- autoFixAvailable?: boolean
65
- autoFixSuggestions?: AutoFixSuggestion[]
66
- steps?: string[]
67
- similarModels?: Array<{
68
- model: string
69
- similarity: number
70
- reason: string
71
- }>
72
- cacheInfo?: {
73
- age: number
74
- valid: boolean
75
- totalCacheEntries: number
76
- }
77
- performanceHint?: string
78
- }
79
-
80
25
  export type LMStudioModel = OpenAIModel
81
26
  export type LMStudioModelsResponse = OpenAIModelsResponse
82
- export type LMStudioValidationResult = ModelValidationResult
@@ -9,7 +9,15 @@ export interface PluginConfig {
9
9
  }
10
10
  discovery?: {
11
11
  enabled?: boolean
12
- ttl?: number
12
+ }
13
+ smartModelName?: boolean
14
+ }
15
+
16
+ export interface ProviderDiscoveryConfig {
17
+ enabled?: boolean
18
+ models?: {
19
+ includeRegex?: string[]
20
+ excludeRegex?: string[]
13
21
  }
14
22
  smartModelName?: boolean
15
23
  }
@@ -21,7 +29,6 @@ export interface ProviderFilter {
21
29
 
22
30
  export interface DiscoveryConfig {
23
31
  enabled: boolean
24
- ttl: number
25
32
  }
26
33
 
27
34
  export interface ModelRegexFilter {
@@ -31,7 +38,6 @@ export interface ModelRegexFilter {
31
38
 
32
39
  export const DEFAULT_DISCOVERY_CONFIG: DiscoveryConfig = {
33
40
  enabled: true,
34
- ttl: 15000,
35
41
  }
36
42
 
37
43
  export function shouldDiscoverProvider(
@@ -54,10 +60,30 @@ export function getProviderFilter(config: PluginConfig): ProviderFilter {
54
60
  export function getDiscoveryConfig(config: PluginConfig): DiscoveryConfig {
55
61
  return {
56
62
  enabled: config.discovery?.enabled ?? DEFAULT_DISCOVERY_CONFIG.enabled,
57
- ttl: config.discovery?.ttl ?? DEFAULT_DISCOVERY_CONFIG.ttl,
58
63
  }
59
64
  }
60
65
 
66
+ export function shouldDiscoverProviderWithOverride(
67
+ providerName: string,
68
+ filter: ProviderFilter,
69
+ globalEnabled: boolean,
70
+ providerConfig: ProviderDiscoveryConfig
71
+ ): boolean {
72
+ if (providerConfig.enabled === true) {
73
+ return true
74
+ }
75
+
76
+ if (providerConfig.enabled === false) {
77
+ return false
78
+ }
79
+
80
+ if (!globalEnabled) {
81
+ return false
82
+ }
83
+
84
+ return shouldDiscoverProvider(providerName, filter)
85
+ }
86
+
61
87
  function toRegExp(pattern: string, logger?: PluginLogger): RegExp | null {
62
88
  try {
63
89
  return new RegExp(pattern)
@@ -65,7 +91,7 @@ function toRegExp(pattern: string, logger?: PluginLogger): RegExp | null {
65
91
  if (logger) {
66
92
  logger.warn('Ignoring invalid model regex', { category: 'filtering', pattern })
67
93
  } else {
68
- console.warn(`[opencode-model-discovery] Ignoring invalid model regex: ${pattern}`)
94
+ console.warn(`[opencode-models-discovery] Ignoring invalid model regex: ${pattern}`)
69
95
  }
70
96
  return null
71
97
  }
@@ -78,6 +104,13 @@ export function getModelRegexFilter(config: PluginConfig, logger?: PluginLogger)
78
104
  }
79
105
  }
80
106
 
107
+ export function getProviderModelRegexFilter(config: ProviderDiscoveryConfig, logger?: PluginLogger): ModelRegexFilter {
108
+ return {
109
+ includeRegex: (config.models?.includeRegex || []).map((pattern) => toRegExp(pattern, logger)).filter((pattern): pattern is RegExp => pattern !== null),
110
+ excludeRegex: (config.models?.excludeRegex || []).map((pattern) => toRegExp(pattern, logger)).filter((pattern): pattern is RegExp => pattern !== null),
111
+ }
112
+ }
113
+
81
114
  export function shouldDiscoverModel(modelId: string, filter: ModelRegexFilter): boolean {
82
115
  if (filter.includeRegex.length > 0) {
83
116
  return filter.includeRegex.some((pattern) => pattern.test(modelId))
@@ -1,4 +1,4 @@
1
- // UI notification system for LM Studio plugin
1
+ // UI notification system for the model discovery plugin
2
2
  export class ToastNotifier {
3
3
  private client: any // OpenCode client
4
4
 
@@ -10,7 +10,7 @@ export class ToastNotifier {
10
10
  async success(message: string, title?: string, duration?: number): Promise<void> {
11
11
  try {
12
12
  if (!this.client?.tui?.showToast) {
13
- console.warn('[opencode-model-discovery] Toast API not available (client.tui.showToast missing)')
13
+ console.warn('[opencode-models-discovery] Toast API not available (client.tui.showToast missing)')
14
14
  return
15
15
  }
16
16
  await this.client.tui.showToast({
@@ -22,7 +22,7 @@ export class ToastNotifier {
22
22
  }
23
23
  })
24
24
  } catch (error) {
25
- console.error(`[opencode-model-discovery] Failed to show success toast`, error)
25
+ console.error(`[opencode-models-discovery] Failed to show success toast`, error)
26
26
  }
27
27
  }
28
28
 
@@ -30,7 +30,7 @@ export class ToastNotifier {
30
30
  async error(message: string, title?: string, duration?: number): Promise<void> {
31
31
  try {
32
32
  if (!this.client?.tui?.showToast) {
33
- console.warn('[opencode-model-discovery] Toast API not available (client.tui.showToast missing)')
33
+ console.warn('[opencode-models-discovery] Toast API not available (client.tui.showToast missing)')
34
34
  return
35
35
  }
36
36
  await this.client.tui.showToast({
@@ -42,7 +42,7 @@ export class ToastNotifier {
42
42
  }
43
43
  })
44
44
  } catch (error) {
45
- console.error(`[opencode-model-discovery] Failed to show error toast`, error)
45
+ console.error(`[opencode-models-discovery] Failed to show error toast`, error)
46
46
  }
47
47
  }
48
48
 
@@ -50,7 +50,7 @@ export class ToastNotifier {
50
50
  async warning(message: string, title?: string, duration?: number): Promise<void> {
51
51
  try {
52
52
  if (!this.client?.tui?.showToast) {
53
- console.warn('[opencode-model-discovery] Toast API not available (client.tui.showToast missing)')
53
+ console.warn('[opencode-models-discovery] Toast API not available (client.tui.showToast missing)')
54
54
  return
55
55
  }
56
56
  await this.client.tui.showToast({
@@ -62,7 +62,7 @@ export class ToastNotifier {
62
62
  }
63
63
  })
64
64
  } catch (error) {
65
- console.error(`[opencode-model-discovery] Failed to show warning toast`, error)
65
+ console.error(`[opencode-models-discovery] Failed to show warning toast`, error)
66
66
  }
67
67
  }
68
68
 
@@ -70,7 +70,7 @@ export class ToastNotifier {
70
70
  async info(message: string, title?: string, duration?: number): Promise<void> {
71
71
  try {
72
72
  if (!this.client?.tui?.showToast) {
73
- console.warn('[opencode-model-discovery] Toast API not available (client.tui.showToast missing)')
73
+ console.warn('[opencode-models-discovery] Toast API not available (client.tui.showToast missing)')
74
74
  return
75
75
  }
76
76
  await this.client.tui.showToast({
@@ -82,7 +82,7 @@ export class ToastNotifier {
82
82
  }
83
83
  })
84
84
  } catch (error) {
85
- console.error(`[opencode-model-discovery] Failed to show info toast`, error)
85
+ console.error(`[opencode-models-discovery] Failed to show info toast`, error)
86
86
  }
87
87
  }
88
88
 
@@ -90,7 +90,7 @@ export class ToastNotifier {
90
90
  async progress(message: string, title?: string, progress?: number): Promise<void> {
91
91
  try {
92
92
  if (!this.client?.tui?.showToast) {
93
- console.warn('[opencode-model-discovery] Toast API not available (client.tui.showToast missing)')
93
+ console.warn('[opencode-models-discovery] Toast API not available (client.tui.showToast missing)')
94
94
  return
95
95
  }
96
96
  await this.client.tui.showToast({
@@ -102,7 +102,7 @@ export class ToastNotifier {
102
102
  }
103
103
  })
104
104
  } catch (error) {
105
- console.error(`[opencode-model-discovery] Failed to show progress toast`, error)
105
+ console.error(`[opencode-models-discovery] Failed to show progress toast`, error)
106
106
  }
107
107
  }
108
108
 
@@ -115,7 +115,7 @@ export class ToastNotifier {
115
115
  }): Promise<void> {
116
116
  try {
117
117
  if (!this.client?.tui?.showToast) {
118
- console.warn('[opencode-model-discovery] Toast API not available (client.tui.showToast missing)')
118
+ console.warn('[opencode-models-discovery] Toast API not available (client.tui.showToast missing)')
119
119
  return
120
120
  }
121
121
  await this.client.tui.showToast({
@@ -127,7 +127,7 @@ export class ToastNotifier {
127
127
  }
128
128
  })
129
129
  } catch (error) {
130
- console.error(`[opencode-model-discovery] Failed to show detailed toast`, error)
130
+ console.error(`[opencode-models-discovery] Failed to show detailed toast`, error)
131
131
  }
132
132
  }
133
- }
133
+ }
@@ -1,5 +1,3 @@
1
- import type { ModelValidationError, AutoFixSuggestion, SimilarModel } from '../types'
2
-
3
1
  export { formatModelName, extractModelOwner } from './format-model-name'
4
2
 
5
3
  // Categorize models by type
@@ -16,201 +14,3 @@ export function categorizeModel(modelId: string): 'chat' | 'embedding' | 'unknow
16
14
  }
17
15
  return 'unknown'
18
16
  }
19
-
20
- // Enhanced model similarity matching
21
- export function findSimilarModels(targetModel: string, availableModels: string[]): SimilarModel[] {
22
- const target = targetModel.toLowerCase()
23
- const targetTokens = target.split(/[-_\s]/).filter(Boolean)
24
-
25
- return availableModels
26
- .map(model => {
27
- const candidate = model.toLowerCase()
28
- const candidateTokens = candidate.split(/[-_\s]/).filter(Boolean)
29
-
30
- let similarity = 0
31
- const reasons: string[] = []
32
-
33
- // Exact match gets highest score
34
- if (candidate === target) {
35
- similarity = 1.0
36
- reasons.push("Exact match")
37
- }
38
-
39
- // Check for common model family prefixes
40
- const targetPrefix = targetTokens[0]
41
- const candidatePrefix = candidateTokens[0]
42
- if (targetPrefix && candidatePrefix && targetPrefix === candidatePrefix) {
43
- similarity += 0.5
44
- reasons.push(`Same family: ${targetPrefix}`)
45
- }
46
-
47
- // Check for common suffixes (quantization levels, sizes)
48
- const commonSuffixes = ['3b', '7b', '13b', '70b', 'q4', 'q8', 'instruct', 'chat', 'base']
49
- for (const suffix of commonSuffixes) {
50
- if (target.includes(suffix) && candidate.includes(suffix)) {
51
- similarity += 0.2
52
- reasons.push(`Shared suffix: ${suffix}`)
53
- }
54
- }
55
-
56
- // Token overlap score
57
- const commonTokens = targetTokens.filter(token => candidateTokens.includes(token))
58
- if (commonTokens.length > 0) {
59
- similarity += (commonTokens.length / Math.max(targetTokens.length, candidateTokens.length)) * 0.3
60
- reasons.push(`Common tokens: ${commonTokens.join(', ')}`)
61
- }
62
-
63
- return {
64
- model,
65
- similarity: Math.min(similarity, 1.0),
66
- reason: reasons.join(", ")
67
- }
68
- })
69
- .filter(item => item.similarity > 0.1) // Only include models with some similarity
70
- .sort((a, b) => b.similarity - a.similarity)
71
- .slice(0, 5) // Top 5 suggestions
72
- }
73
-
74
- // Retry logic with exponential backoff
75
- export async function retryWithBackoff<T>(
76
- operation: () => Promise<T>,
77
- maxRetries: number = 3,
78
- baseDelay: number = 1000
79
- ): Promise<{ success: boolean; result?: T; error?: string }> {
80
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
81
- try {
82
- const result = await operation()
83
- return { success: true, result }
84
- } catch (error) {
85
- if (attempt === maxRetries) {
86
- return {
87
- success: false,
88
- error: error instanceof Error ? error.message : String(error)
89
- }
90
- }
91
-
92
- const delay = baseDelay * Math.pow(2, attempt)
93
- console.warn(`[opencode-model-discovery] Retrying operation after ${delay}ms`, {
94
- attempt: attempt + 1,
95
- maxRetries: maxRetries + 1,
96
- error: error instanceof Error ? error.message : String(error)
97
- })
98
- await new Promise(resolve => setTimeout(resolve, delay))
99
- }
100
- }
101
- return { success: false, error: "Max retries exceeded" }
102
- }
103
-
104
- // Smart error categorization
105
- export function categorizeError(error: any, context: { baseURL: string; modelId: string }): ModelValidationError {
106
- const errorStr = String(error).toLowerCase()
107
- const { baseURL, modelId } = context
108
-
109
- // Network/connection issues
110
- if (errorStr.includes('econnrefused') || errorStr.includes('fetch failed') || errorStr.includes('network')) {
111
- return {
112
- type: 'offline',
113
- severity: 'critical',
114
- message: `Cannot connect to provider at ${baseURL}. Ensure the server is running and accessible.`,
115
- canRetry: true,
116
- autoFixAvailable: true
117
- }
118
- }
119
-
120
- // Timeout issues
121
- if (errorStr.includes('timeout') || errorStr.includes('aborted')) {
122
- return {
123
- type: 'timeout',
124
- severity: 'medium',
125
- message: `Request timed out. This might happen with large models or slow systems.`,
126
- canRetry: true,
127
- autoFixAvailable: false
128
- }
129
- }
130
-
131
- // Model not found
132
- if (errorStr.includes('404') || errorStr.includes('not found')) {
133
- return {
134
- type: 'not_found',
135
- severity: 'high',
136
- message: `Model '${modelId}' not found. Check if model is available in the provider.`,
137
- canRetry: false,
138
- autoFixAvailable: false
139
- }
140
- }
141
-
142
- // Permission issues
143
- if (errorStr.includes('401') || errorStr.includes('403') || errorStr.includes('unauthorized')) {
144
- return {
145
- type: 'permission',
146
- severity: 'high',
147
- message: `Authentication or permission issue. Check your provider configuration.`,
148
- canRetry: false,
149
- autoFixAvailable: false
150
- }
151
- }
152
-
153
- // Unknown errors
154
- return {
155
- type: 'unknown',
156
- severity: 'medium',
157
- message: `Unexpected error: ${errorStr}`,
158
- canRetry: true,
159
- autoFixAvailable: false
160
- }
161
- }
162
-
163
- // Generate auto-fix suggestions
164
- export function generateAutoFixSuggestions(errorCategory: ModelValidationError): AutoFixSuggestion[] {
165
- const suggestions: AutoFixSuggestion[] = []
166
-
167
- switch (errorCategory.type) {
168
- case 'offline':
169
- suggestions.push({
170
- action: "Check if provider server is running",
171
- steps: [
172
- "1. Verify the server application is running",
173
- "2. Verify the server is started",
174
- "3. Check the server URL and port",
175
- "4. Ensure the server is not blocked by firewall"
176
- ],
177
- automated: false
178
- })
179
- suggestions.push({
180
- action: "Try alternative ports",
181
- steps: [
182
- "1. Check if provider is running on a different port",
183
- "2. Common ports: 1234 (LM Studio), 8080 (LocalAI), 11434 (Ollama)",
184
- "3. Update your OpenCode configuration with the correct port"
185
- ],
186
- automated: false
187
- })
188
- break
189
-
190
- case 'not_found':
191
- suggestions.push({
192
- action: "Check model availability",
193
- steps: [
194
- "1. Verify the model is available in your provider",
195
- "2. Download or load the model if needed",
196
- "3. Ensure the model is properly installed"
197
- ],
198
- automated: false
199
- })
200
- break
201
-
202
- case 'timeout':
203
- suggestions.push({
204
- action: "Increase timeout or use smaller model",
205
- steps: [
206
- "1. Try a smaller model version",
207
- "2. Increase request timeout in OpenCode settings",
208
- "3. Close other applications to free up system resources"
209
- ],
210
- automated: false
211
- })
212
- break
213
- }
214
-
215
- return suggestions
216
- }
@@ -1,122 +0,0 @@
1
- import type { CacheStats } from '../types'
2
-
3
- // Model Status Cache for reducing API calls
4
- export class ModelStatusCache {
5
- private cache = new Map<string, {
6
- models: string[]
7
- timestamp: number
8
- ttl: number
9
- }>()
10
-
11
- private readonly DEFAULT_TTL = 15000 // 15 seconds (reduced for better freshness)
12
- private readonly MAX_CACHE_SIZE = 50 // Prevent memory leaks
13
-
14
- // Get cached model status or fetch fresh data
15
- async getModels(baseURL: string, fetchFn: () => Promise<string[]>): Promise<string[]> {
16
- const now = Date.now()
17
- const cached = this.cache.get(baseURL)
18
-
19
- // Return cached data if still valid
20
- if (cached && (now - cached.timestamp) < cached.ttl) {
21
- return cached.models
22
- }
23
-
24
- // Fetch fresh data
25
- try {
26
- const models = await fetchFn()
27
-
28
- // Update cache with new data
29
- this.cache.set(baseURL, {
30
- models: [...models], // Create copy to prevent mutations
31
- timestamp: now,
32
- ttl: this.DEFAULT_TTL
33
- })
34
-
35
- // Prevent cache from growing too large
36
- if (this.cache.size > this.MAX_CACHE_SIZE) {
37
- this.cleanup()
38
- }
39
-
40
-
41
- return models
42
- } catch (error) {
43
- // If we have stale cached data, return it as fallback but mark as potentially invalid
44
- if (cached) {
45
- console.warn(`[opencode-model-discovery] Using stale cache data due to fetch error`, {
46
- baseURL,
47
- age: now - cached.timestamp,
48
- error: error instanceof Error ? error.message : String(error)
49
- })
50
- // Invalidate cache if it's very old (> 5x TTL)
51
- if (now - cached.timestamp > cached.ttl * 5) {
52
- this.invalidate(baseURL)
53
- }
54
- return cached.models
55
- }
56
- throw error
57
- }
58
- }
59
-
60
- // Invalidate cache for specific URL
61
- invalidate(baseURL: string): void {
62
- this.cache.delete(baseURL)
63
- }
64
-
65
- // Invalidate entire cache
66
- invalidateAll(): void {
67
- this.cache.clear()
68
- }
69
-
70
- // Force refresh for specific URL (useful after model changes)
71
- async forceRefresh(baseURL: string, fetchFn: () => Promise<string[]>): Promise<string[]> {
72
- this.invalidate(baseURL)
73
- return this.getModels(baseURL, fetchFn)
74
- }
75
-
76
- // Get cache statistics
77
- getStats(): CacheStats {
78
- const now = Date.now()
79
- return {
80
- size: this.cache.size,
81
- entries: Array.from(this.cache.entries()).map(([baseURL, data]) => ({
82
- baseURL,
83
- age: now - data.timestamp,
84
- modelCount: data.models.length,
85
- ttl: data.ttl
86
- }))
87
- }
88
- }
89
-
90
- // Cleanup old entries to prevent memory leaks
91
- private cleanup(): void {
92
- const now = Date.now()
93
- const toDelete: string[] = []
94
-
95
- for (const [baseURL, data] of this.cache.entries()) {
96
- // Delete entries older than 5x TTL or if cache is too large
97
- if (now - data.timestamp > data.ttl * 5 || this.cache.size > this.MAX_CACHE_SIZE) {
98
- toDelete.push(baseURL)
99
- }
100
- }
101
-
102
- toDelete.forEach(baseURL => this.cache.delete(baseURL))
103
-
104
- if (toDelete.length > 0) {
105
- }
106
- }
107
-
108
- // Configure TTL for specific use cases
109
- setTTL(baseURL: string, ttl: number): void {
110
- const cached = this.cache.get(baseURL)
111
- if (cached) {
112
- cached.ttl = ttl
113
- }
114
- }
115
-
116
- // Check if cache entry exists and is valid
117
- isValid(baseURL: string): boolean {
118
- const cached = this.cache.get(baseURL)
119
- const now = Date.now()
120
- return cached !== undefined && (now - cached.timestamp) < cached.ttl
121
- }
122
- }
@@ -1,10 +0,0 @@
1
- import { ModelStatusCache } from '../cache/model-status-cache'
2
- import { fetchModelsDirect } from '../utils/openai-compatible-api'
3
-
4
- const modelStatusCache = new ModelStatusCache()
5
-
6
- export function getLoadedModels(baseURL: string): Promise<string[]> {
7
- return modelStatusCache.getModels(baseURL, async () => {
8
- return await fetchModelsDirect(baseURL)
9
- })
10
- }