diff --git a/README.md b/README.md index 0e2394d..770f181 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Add the plugin to your `opencode.json`: { "$schema": "https://opencode.ai/config.json", "plugin": [ + // For dev testing, you can replace this with file:$YOUR_LOCAL_PLUGIN_REPO_PATH + // Example: file:/Users/you/workspace/opencode-lmstudio "opencode-lmstudio@latest" ], "provider": { @@ -77,6 +79,40 @@ You can also manually configure the provider with specific models: The plugin will automatically discover and add any additional models available in LM Studio that aren't already configured. +### Filter Models by `modelTypes` + +Configure `modelTypes` under `provider.lmstudio.options.modelTypes`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + "opencode-lmstudio@latest" + ], + "provider": { + "lmstudio": { + "npm": "@ai-sdk/openai-compatible", + "name": "LM Studio (local)", + "options": { + "baseURL": "http://127.0.0.1:1234/v1", + "modelTypes": ["unknown"] + } + } + } +} +``` + +Allowed values: + +- `chat` +- `embedding` +- `unknown` + +Current behavior: + +- `modelTypes` is derived from the LM Studio model `id`, not a dedicated API type field. +- `unknown` means the model `id` did not match the plugin's current `chat` or `embedding` rules. + ## How It Works 1. On OpenCode startup, the plugin's `config` hook is called @@ -99,4 +135,3 @@ MIT ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. - diff --git a/package-lock.json b/package-lock.json index 1b0c537..c90cabd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-lmstudio", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-lmstudio", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@opencode-ai/plugin": "^1.0.166" diff --git a/src/index.ts b/src/index.ts index af0678e..b08462b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1 @@ -export { LMStudioPlugin } from './plugin' \ No newline at end of file +export { LMStudioPlugin } from './plugin' diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index 3f1ad10..596b10d 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -3,9 +3,25 @@ import { ToastNotifier } from '../ui/toast-notifier' import { categorizeModel, formatModelName, extractModelOwner } from '../utils' import { normalizeBaseURL, checkLMStudioHealth, discoverLMStudioModels, autoDetectLMStudio } from '../utils/lmstudio-api' import type { PluginInput } from '@opencode-ai/plugin' -import type { LMStudioModel } from '../types' +import type { LMStudioModel, ModelType } from '../types' const modelStatusCache = new ModelStatusCache() +const allowedModelTypes = ['chat', 'embedding', 'unknown'] as const + +function getConfiguredModelTypes(lmstudioProvider: any): Set | null { + const modelTypes = lmstudioProvider?.options?.modelTypes ?? lmstudioProvider?.options?.model_types + + if (!Array.isArray(modelTypes) || modelTypes.length === 0) { + return null + } + + return new Set( + modelTypes.filter((modelType: unknown): modelType is ModelType => + typeof modelType === 'string' && + (allowedModelTypes as readonly string[]).includes(modelType) + ) + ) +} export async function enhanceConfig( config: any, @@ -64,6 +80,7 @@ export async function enhanceConfig( // Merge discovered models with configured models const existingModels = lmstudioProvider.models || {} const discoveredModels: Record = {} + const configuredModelTypes = getConfiguredModelTypes(lmstudioProvider) let chatModelsCount = 0 let embeddingModelsCount = 0 @@ -77,6 +94,11 @@ export async function enhanceConfig( // Only add if not already configured if (!existingModels[modelKey] && !existingModels[model.id]) { const modelType = categorizeModel(model.id) + + if (configuredModelTypes && !configuredModelTypes.has(modelType)) { + continue + } + const owner = extractModelOwner(model.id) const modelConfig: any = { id: model.id, @@ -153,4 +175,3 @@ export async function enhanceConfig( toastNotifier.warning("Plugin configuration failed", "Configuration Error").catch(() => {}) } } - diff --git a/src/utils/validation/validate-config.ts b/src/utils/validation/validate-config.ts index 6c8adc5..6b8bd7c 100644 --- a/src/utils/validation/validate-config.ts +++ b/src/utils/validation/validate-config.ts @@ -1,5 +1,31 @@ import type { ValidationResult } from './validation-result' +const allowedModelTypes = new Set(['chat', 'embedding', 'unknown']) +const allowedModelTypesList = Array.from(allowedModelTypes).join(', ') + +function formatInvalidModelTypeError(modelType: unknown): string { + const value = String(modelType) + const suggestion = value === 'embedded' ? ' Did you mean "embedding"?' : '' + return `LM Studio provider options.modelTypes contains invalid value: ${value}. Allowed values: ${allowedModelTypesList}.${suggestion}` +} + +function validateModelTypesOption( + errors: string[], + modelTypes: unknown, + optionPath: string +): void { + if (!Array.isArray(modelTypes)) { + errors.push(`LM Studio provider ${optionPath} must be an array`) + return + } + + for (const modelType of modelTypes) { + if (typeof modelType !== 'string' || !allowedModelTypes.has(modelType)) { + errors.push(formatInvalidModelTypeError(modelType)) + } + } +} + export function validateConfig(config: any): ValidationResult { const errors: string[] = [] const warnings: string[] = [] @@ -34,6 +60,12 @@ export function validateConfig(config: any): ValidationResult { } else if (!isValidURL(lmstudio.options.baseURL)) { warnings.push('LM Studio provider baseURL may be invalid') } + + if (lmstudio.options.modelTypes !== undefined) { + validateModelTypesOption(errors, lmstudio.options.modelTypes, 'options.modelTypes') + } else if (lmstudio.options.model_types !== undefined) { + validateModelTypesOption(errors, lmstudio.options.model_types, 'options.model_types') + } } // Validate models configuration @@ -58,4 +90,3 @@ function isValidURL(url: string): boolean { return false } } - diff --git a/test/plugin.test.ts b/test/plugin.test.ts index ad05400..b4fc11e 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -178,6 +178,220 @@ describe('LMStudio Plugin', () => { }) }) + it('should keep baseline behavior when modelTypes is not configured', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { id: 'text-embedding-model', object: 'model', created: 1234567890, owned_by: 'local' }, + { id: 'llama-chat-model', object: 'model', created: 1234567890, owned_by: 'local' }, + { id: 'custom-model', object: 'model', created: 1234567890, owned_by: 'local' } + ] + }) + }) + + const config: any = { + provider: { + lmstudio: { + npm: '@ai-sdk/openai-compatible', + name: 'LM Studio (local)', + options: { baseURL: 'http://127.0.0.1:1234/v1' }, + models: {} + } + } + } + + await pluginHooks.config(config) + + expect(config.provider.lmstudio.models).toEqual({ + 'text-embedding-model': expect.objectContaining({ + id: 'text-embedding-model', + modalities: { + input: ['text'], + output: ['embedding'] + } + }), + 'llama-chat-model': expect.objectContaining({ + id: 'llama-chat-model', + modalities: { + input: ['text', 'image'], + output: ['text'] + } + }), + 'custom-model': expect.objectContaining({ + id: 'custom-model' + }) + }) + }) + + it('should filter discovered models using options.modelTypes', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { id: 'text-embedding-model', object: 'model', created: 1234567890, owned_by: 'local' }, + { id: 'llama-chat-model', object: 'model', created: 1234567890, owned_by: 'local' }, + { id: 'custom-model', object: 'model', created: 1234567890, owned_by: 'local' } + ] + }) + }) + + const config: any = { + provider: { + lmstudio: { + npm: '@ai-sdk/openai-compatible', + name: 'LM Studio (local)', + options: { + baseURL: 'http://127.0.0.1:1234/v1', + modelTypes: ['chat'] + }, + models: {} + } + } + } + + await pluginHooks.config(config) + + expect(config.provider.lmstudio.models).toEqual({ + 'llama-chat-model': expect.objectContaining({ + id: 'llama-chat-model', + modalities: { + input: ['text', 'image'], + output: ['text'] + } + }) + }) + }) + + it('should filter discovered models using unknown model type derived from model id', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { id: 'text-embedding-model', object: 'model', created: 1234567890, owned_by: 'local' }, + { id: 'llama-chat-model', object: 'model', created: 1234567890, owned_by: 'local' }, + { id: 'custom-model', object: 'model', created: 1234567890, owned_by: 'local' } + ] + }) + }) + + const config: any = { + provider: { + lmstudio: { + npm: '@ai-sdk/openai-compatible', + name: 'LM Studio (local)', + options: { + baseURL: 'http://127.0.0.1:1234/v1', + modelTypes: ['unknown'] + }, + models: {} + } + } + } + + await pluginHooks.config(config) + + expect(config.provider.lmstudio.models).toEqual({ + 'custom-model': expect.objectContaining({ + id: 'custom-model' + }) + }) + }) + + it('should reject invalid options.modelTypes values', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const config: any = { + provider: { + lmstudio: { + npm: '@ai-sdk/openai-compatible', + name: 'LM Studio (local)', + options: { + baseURL: 'http://127.0.0.1:1234/v1', + modelTypes: ['llm'] + }, + models: {} + } + } + } + + await pluginHooks.config(config) + + expect(consoleSpy).toHaveBeenCalledWith( + '[opencode-lmstudio] Invalid config provided:', + expect.arrayContaining([ + 'LM Studio provider options.modelTypes contains invalid value: llm. Allowed values: chat, embedding, unknown.' + ]) + ) + + consoleSpy.mockRestore() + }) + + it('should suggest embedding when embedded is used in options.modelTypes', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const config: any = { + provider: { + lmstudio: { + npm: '@ai-sdk/openai-compatible', + name: 'LM Studio (local)', + options: { + baseURL: 'http://127.0.0.1:1234/v1', + modelTypes: ['embedded'] + }, + models: {} + } + } + } + + await pluginHooks.config(config) + + expect(consoleSpy).toHaveBeenCalledWith( + '[opencode-lmstudio] Invalid config provided:', + expect.arrayContaining([ + 'LM Studio provider options.modelTypes contains invalid value: embedded. Allowed values: chat, embedding, unknown. Did you mean "embedding"?' + ]) + ) + + consoleSpy.mockRestore() + }) + + it('should keep backward compatibility for legacy options.model_types', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { id: 'text-embedding-model', object: 'model', created: 1234567890, owned_by: 'local' }, + { id: 'llama-chat-model', object: 'model', created: 1234567890, owned_by: 'local' } + ] + }) + }) + + const config: any = { + provider: { + lmstudio: { + npm: '@ai-sdk/openai-compatible', + name: 'LM Studio (local)', + options: { + baseURL: 'http://127.0.0.1:1234/v1', + model_types: ['embedding'] + }, + models: {} + } + } + } + + await pluginHooks.config(config) + + expect(config.provider.lmstudio.models).toEqual({ + 'text-embedding-model': expect.objectContaining({ + id: 'text-embedding-model', + modalities: { + input: ['text'], + output: ['embedding'] + } + }) + }) + }) + it('should handle LM Studio offline gracefully', async () => { mockFetch.mockRejectedValue(new Error('Connection refused')) @@ -369,4 +583,4 @@ describe('LMStudio Plugin', () => { consoleSpy.mockRestore() }) }) -}) \ No newline at end of file +})