From c749836abac22b0337c5551ce6786d1d280b1a11 Mon Sep 17 00:00:00 2001 From: Jonny Leaders Date: Tue, 24 Feb 2026 17:59:09 -0600 Subject: [PATCH 1/3] feat: support multiple LM Studio provider instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scans config.provider for all keys matching /^lm.?studio/i (e.g. lmstudio, lm-studio, lm-studio-wooden, lm-studio-alienware) and queries each one in parallel for model discovery. Previously only the exact key 'lmstudio' was handled, meaning 'lm-studio' and any additional named instances were silently ignored. - enhance-config: extract processHost(), add findLMStudioProviders() to scan all lm?studio provider keys; run all hosts in parallel via Promise.allSettled - config-hook: replace hardcoded lmstudio key check with isLMStudioProviderKey(); sum models across all providers for logging - type-guards: update isLMStudioProvider to match /^lm.?studio/i so chat.params hook correctly validates models on all instances - test: add multi-provider test; fix mockClear → mockReset to prevent mock bleed-through between tests Co-Authored-By: Claude Sonnet 4.6 --- src/plugin/config-hook.ts | 70 ++++---- src/plugin/enhance-config.ts | 261 +++++++++++++++------------- src/utils/validation/type-guards.ts | 9 +- test/plugin.test.ts | 48 ++++- 4 files changed, 221 insertions(+), 167 deletions(-) diff --git a/src/plugin/config-hook.ts b/src/plugin/config-hook.ts index 447096c..fd64361 100644 --- a/src/plugin/config-hook.ts +++ b/src/plugin/config-hook.ts @@ -1,18 +1,16 @@ import { ToastNotifier } from '../ui/toast-notifier' import { validateConfig } from '../utils/validation' -import { enhanceConfig } from './enhance-config' +import { enhanceConfig, isLMStudioProviderKey } from './enhance-config' import type { PluginInput } from '@opencode-ai/plugin' export function createConfigHook(client: PluginInput['client'], toastNotifier: ToastNotifier) { return async (config: any) => { - const initialModelCount = config?.provider?.lmstudio?.models ? Object.keys(config.provider.lmstudio.models).length : 0 - // Check if config is modifiable if (config && (Object.isFrozen?.(config) || Object.isSealed?.(config))) { console.warn("[opencode-lmstudio] Config object is frozen/sealed - cannot modify directly") return } - + const validation = validateConfig(config) if (!validation.isValid) { console.error("[opencode-lmstudio] Invalid config provided:", validation.errors) @@ -20,64 +18,58 @@ export function createConfigHook(client: PluginInput['client'], toastNotifier: T toastNotifier.error("Plugin configuration is invalid", "Configuration Error").catch(() => {}) return } - + if (validation.warnings.length > 0) { console.warn("[opencode-lmstudio] Config warnings:", validation.warnings) } - - // Ensure provider exists and wait for initial model discovery - // We wait with a timeout to ensure models are loaded before OpenCode reads the config - if (!config.provider?.lmstudio) { - // Quick check - try default port first with timeout + + // If no LM Studio providers are configured, do a quick check on the default port + // so we can pre-create the provider before the full enhanceConfig runs + const hasLMStudioProvider = config.provider && + Object.keys(config.provider).some(key => isLMStudioProviderKey(key)) + + if (!hasLMStudioProvider) { try { const response = await fetch("http://127.0.0.1:1234/v1/models", { method: "GET", - signal: AbortSignal.timeout(1000), // 1 second timeout for quick check + signal: AbortSignal.timeout(1000), }) - if (response.ok) { if (!config.provider) config.provider = {} - if (!config.provider.lmstudio) { - config.provider.lmstudio = { - npm: "@ai-sdk/openai-compatible", - name: "LM Studio (local)", - options: { - baseURL: "http://127.0.0.1:1234/v1", - }, - models: {}, - } + config.provider.lmstudio = { + npm: "@ai-sdk/openai-compatible", + name: "LM Studio (local)", + options: { baseURL: "http://127.0.0.1:1234/v1" }, + models: {}, } } } catch { // Ignore - will be handled by full enhanceConfig } } - - // Wait for initial model discovery with timeout (max 5 seconds) - // This ensures models are available when OpenCode reads the config - // We use Promise.race to avoid blocking too long, but we check if models were added - const startTime = Date.now() - const discoveryPromise = enhanceConfig(config, client, toastNotifier) - const timeoutMs = 5000 // 5 second timeout - + + // Wait for model discovery across all LM Studio providers (max 5 seconds) try { await Promise.race([ - discoveryPromise, - new Promise((resolve) => { - setTimeout(() => resolve(), timeoutMs) - }) + enhanceConfig(config, client, toastNotifier), + new Promise((resolve) => setTimeout(resolve, 5000)), ]) } catch (error) { console.error("[opencode-lmstudio] Config enhancement failed:", error) console.error("[opencode-lmstudio:DEBUG] Error stack:", error instanceof Error ? error.stack : String(error)) } - - const finalModelCount = config.provider?.lmstudio?.models ? Object.keys(config.provider.lmstudio.models).length : 0 - - if (finalModelCount === 0 && config.provider?.lmstudio) { + + // Report total models loaded across all LM Studio providers + const totalModels = config.provider + ? Object.entries(config.provider) + .filter(([key]) => isLMStudioProviderKey(key)) + .reduce((sum, [, p]: [string, any]) => sum + Object.keys(p?.models ?? {}).length, 0) + : 0 + + if (totalModels === 0 && hasLMStudioProvider) { console.warn("[opencode-lmstudio] No models discovered - LM Studio might be offline") - } else if (finalModelCount > 0) { - console.log(`[opencode-lmstudio] Loaded ${finalModelCount} models`) + } else if (totalModels > 0) { + console.log(`[opencode-lmstudio] Loaded ${totalModels} models`) } } } diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index 3f1ad10..7a7878b 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -7,150 +7,167 @@ import type { LMStudioModel } from '../types' const modelStatusCache = new ModelStatusCache() +// Match any provider key that looks like an LM Studio instance: +// lmstudio, lm-studio, lmstudio-wooden, lm-studio-alienware, etc. +const LM_STUDIO_KEY_RE = /^lm.?studio/i + +export function isLMStudioProviderKey(key: string): boolean { + return LM_STUDIO_KEY_RE.test(key) +} + +// Return all [key, provider] pairs in config that look like LM Studio providers +function findLMStudioProviders(config: any): [string, any][] { + if (!config.provider || typeof config.provider !== 'object') return [] + return Object.entries(config.provider).filter(([key]) => isLMStudioProviderKey(key)) +} + +// Discover models from a single host and merge them into the named provider in config +async function processHost( + config: any, + providerKey: string, + baseURL: string, +): Promise { + const provider = config.provider?.[providerKey] + if (!provider) return + + const isHealthy = await checkLMStudioHealth(baseURL) + if (!isHealthy) { + console.warn("[opencode-lmstudio] LM Studio appears to be offline", { baseURL }) + return + } + + let models: LMStudioModel[] + try { + models = await discoverLMStudioModels(baseURL) + } catch (error) { + console.warn("[opencode-lmstudio] Model discovery failed", { + baseURL, + error: error instanceof Error ? error.message : String(error), + }) + return + } + + if (models.length === 0) { + console.warn("[opencode-lmstudio] No models found in LM Studio. Please:", { + baseURL, + steps: [ + "1. Open LM Studio application", + "2. Download and load a model", + "3. Start the server", + ], + }) + return + } + + const existingModels = provider.models || {} + const discoveredModels: Record = {} + let chatModelsCount = 0 + let embeddingModelsCount = 0 + + for (const model of models) { + let modelKey = model.id + if (!/^[a-zA-Z0-9_-]+$/.test(modelKey)) { + modelKey = model.id.replace(/[^a-zA-Z0-9_-]/g, "_") + } + + if (!existingModels[modelKey] && !existingModels[model.id]) { + const modelType = categorizeModel(model.id) + const owner = extractModelOwner(model.id) + const modelConfig: any = { + id: model.id, + name: formatModelName(model), + } + + if (owner) { + modelConfig.organizationOwner = owner + } + + if (modelType === 'embedding') { + embeddingModelsCount++ + modelConfig.modalities = { input: ["text"], output: ["embedding"] } + } else if (modelType === 'chat') { + chatModelsCount++ + modelConfig.modalities = { input: ["text", "image"], output: ["text"] } + } + + discoveredModels[modelKey] = modelConfig + } + } + + if (Object.keys(discoveredModels).length > 0) { + config.provider[providerKey].models = { + ...existingModels, + ...discoveredModels, + } + + if (chatModelsCount === 0 && embeddingModelsCount > 0) { + console.warn("[opencode-lmstudio] Only embedding models found. To use chat models:", { + baseURL, + steps: [ + "1. Open LM Studio application", + "2. Download a chat model (e.g., llama-3.2-3b-instruct)", + "3. Load the model in LM Studio", + "4. Ensure server is running", + ], + }) + } + } + + // Warm up the cache with current model status + try { + await modelStatusCache.getModels(baseURL, async () => { + return await discoverLMStudioModels(baseURL).then(m => m.map(x => x.id)) + }) + } catch { + // Cache warming failed, not critical + } +} + export async function enhanceConfig( config: any, _client: PluginInput['client'], // client not used but kept for interface compatibility toastNotifier: ToastNotifier ): Promise { try { - let lmstudioProvider = config.provider?.lmstudio - let baseURL: string - - // If lmstudio provider exists, use its baseURL - if (lmstudioProvider) { - baseURL = normalizeBaseURL(lmstudioProvider.options?.baseURL || "http://127.0.0.1:1234") - } else { - // Try to auto-detect LM Studio + let lmstudioProviders = findLMStudioProviders(config) + + if (lmstudioProviders.length === 0) { + // No LM Studio providers configured — try auto-detect const detectedURL = await autoDetectLMStudio() if (!detectedURL) { return // No LM Studio found } - - // Auto-create lmstudio provider if detected - baseURL = detectedURL - if (!config.provider) { - config.provider = {} - } + + if (!config.provider) config.provider = {} config.provider.lmstudio = { npm: "@ai-sdk/openai-compatible", name: "LM Studio (local)", - options: { - baseURL: `${baseURL}/v1`, - }, + options: { baseURL: `${detectedURL}/v1` }, models: {}, } - lmstudioProvider = config.provider.lmstudio - } - - // Check health first - const isHealthy = await checkLMStudioHealth(baseURL) - if (!isHealthy) { - console.warn("[opencode-lmstudio] LM Studio appears to be offline", { baseURL }) - return + lmstudioProviders = [['lmstudio', config.provider.lmstudio]] } - // Try to discover models from LM Studio API - let models: LMStudioModel[] - try { - models = await discoverLMStudioModels(baseURL) - } catch (error) { - console.warn("[opencode-lmstudio] Model discovery failed", { - error: error instanceof Error ? error.message : String(error) + // Process all LM Studio providers in parallel + const results = await Promise.allSettled( + lmstudioProviders.map(([key, provider]) => { + const baseURL = normalizeBaseURL(provider.options?.baseURL || "http://127.0.0.1:1234") + return processHost(config, key, baseURL) }) - return - } - - if (models.length > 0) { - // Merge discovered models with configured models - const existingModels = lmstudioProvider.models || {} - const discoveredModels: Record = {} - let chatModelsCount = 0 - let embeddingModelsCount = 0 - - for (const model of models) { - // Use model ID as key directly for better readability, fallback to sanitized version - let modelKey = model.id - if (!/^[a-zA-Z0-9_-]+$/.test(modelKey)) { - modelKey = model.id.replace(/[^a-zA-Z0-9_-]/g, "_") - } - - // Only add if not already configured - if (!existingModels[modelKey] && !existingModels[model.id]) { - const modelType = categorizeModel(model.id) - const owner = extractModelOwner(model.id) - const modelConfig: any = { - id: model.id, - name: formatModelName(model), - } - - // Add owner if available - if (owner) { - modelConfig.organizationOwner = owner - } - - // Add additional metadata based on model type - if (modelType === 'embedding') { - embeddingModelsCount++ - modelConfig.modalities = { - input: ["text"], - output: ["embedding"] - } - } else if (modelType === 'chat') { - chatModelsCount++ - modelConfig.modalities = { - input: ["text", "image"], - output: ["text"] - } - } - - discoveredModels[modelKey] = modelConfig - } - } + ) - // Merge discovered models into config - if (Object.keys(discoveredModels).length > 0) { - if (!config.provider.lmstudio) { - return - } - - config.provider.lmstudio.models = { - ...existingModels, - ...discoveredModels, - } - - // Provide helpful guidance if no chat models are available - if (chatModelsCount === 0 && embeddingModelsCount > 0) { - console.warn("[opencode-lmstudio] Only embedding models found. To use chat models:", { - steps: [ - "1. Open LM Studio application", - "2. Download a chat model (e.g., llama-3.2-3b-instruct)", - "3. Load the model in LM Studio", - "4. Ensure server is running" - ] - }) - } + // Log any unexpected rejections (processHost handles its own errors, so this is a safety net) + results.forEach((result, index) => { + if (result.status === 'rejected') { + const [key] = lmstudioProviders[index] + console.error("[opencode-lmstudio] Unexpected error processing provider", { + providerKey: key, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }) } - } else { - console.warn("[opencode-lmstudio] No models found in LM Studio. Please:", { - steps: [ - "1. Open LM Studio application", - "2. Download and load a model", - "3. Start the server" - ] - }) - } - - // Warm up the cache with current model status - try { - await modelStatusCache.getModels(baseURL, async () => { - return await discoverLMStudioModels(baseURL).then(models => models.map(m => m.id)) - }) - } catch (error) { - // Cache warming failed, but not critical - } + }) } catch (error) { console.error("[opencode-lmstudio] Unexpected error in enhanceConfig:", error) toastNotifier.warning("Plugin configuration failed", "Configuration Error").catch(() => {}) } } - diff --git a/src/utils/validation/type-guards.ts b/src/utils/validation/type-guards.ts index caa5f14..9af4005 100644 --- a/src/utils/validation/type-guards.ts +++ b/src/utils/validation/type-guards.ts @@ -3,10 +3,11 @@ export function isPluginHookInput(input: any): input is { sessionID?: string; ag } export function isLMStudioProvider(provider: any): boolean { - return provider && - typeof provider === 'object' && - provider.info && - provider.info.id === 'lmstudio' + return provider && + typeof provider === 'object' && + provider.info && + typeof provider.info.id === 'string' && + /^lm.?studio/i.test(provider.info.id) } export function isValidModel(model: any): model is { id: string; [key: string]: any } { diff --git a/test/plugin.test.ts b/test/plugin.test.ts index ad05400..2fef43e 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -19,8 +19,8 @@ describe('LMStudio Plugin', () => { let pluginHooks: any beforeEach(async () => { - // Reset fetch mock - mockFetch.mockClear() + // Reset fetch mock (mockReset clears calls + queued responses + implementation) + mockFetch.mockReset() // Mock client mockClient = { @@ -178,6 +178,50 @@ describe('LMStudio Plugin', () => { }) }) + it('should discover models from multiple lm-studio providers', async () => { + // Route mock responses by URL so the test is resilient to call ordering + mockFetch.mockImplementation(async (url: string) => { + if (typeof url === 'string' && url.includes('wooden.local')) { + return { + ok: true, + json: async () => ({ + data: [{ id: 'wooden-model', object: 'model', created: 1234567890, owned_by: 'local' }] + }) + } + } + return { + ok: true, + json: async () => ({ + data: [{ id: 'local-model', object: 'model', created: 1234567890, owned_by: 'local' }] + }) + } + }) + + const config: any = { + provider: { + 'lm-studio': { + npm: '@ai-sdk/openai-compatible', + name: 'LM Studio (local)', + options: { baseURL: 'http://127.0.0.1:1234/v1' }, + }, + 'lm-studio-wooden': { + npm: '@ai-sdk/openai-compatible', + name: 'LM Studio (wooden)', + options: { baseURL: 'http://wooden.local:1234/v1' }, + }, + } + } + + await pluginHooks.config(config) + + expect(config.provider['lm-studio'].models).toMatchObject({ + 'local-model': expect.objectContaining({ id: 'local-model' }) + }) + expect(config.provider['lm-studio-wooden'].models).toMatchObject({ + 'wooden-model': expect.objectContaining({ id: 'wooden-model' }) + }) + }) + it('should handle LM Studio offline gracefully', async () => { mockFetch.mockRejectedValue(new Error('Connection refused')) From d04ce9b3b5f46e3c587394e4fc38bd96be573918 Mon Sep 17 00:00:00 2001 From: Jonny Leaders Date: Wed, 25 Feb 2026 00:47:57 -0600 Subject: [PATCH 2/3] feat: use /api/v0/models for accurate context length and capabilities Switch model discovery from /v1/models to LM Studio's /api/v0/models endpoint which exposes loaded_context_length, max_context_length, type, publisher, and capabilities per model. - Sets limit.context from loaded_context_length (the active window) falling back to max_context_length, fixing the 0% context usage display in opencode - Uses API-provided type field ('embeddings') instead of name heuristics for embedding detection - Uses publisher field for organizationOwner instead of name parsing - Sets tool_call: true for models advertising 'tool_use' capability - Health check continues to use /v1/models (broadly supported); discovery now uses /api/v0/models Co-Authored-By: Claude Sonnet 4.6 --- src/plugin/enhance-config.ts | 22 ++++++++++++++++++---- src/types/index.ts | 14 ++++++++++++-- src/utils/lmstudio-api.ts | 5 +++-- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index 7a7878b..8643b0a 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -71,8 +71,11 @@ async function processHost( } if (!existingModels[modelKey] && !existingModels[model.id]) { - const modelType = categorizeModel(model.id) - const owner = extractModelOwner(model.id) + // Prefer API-provided type over name-based heuristic + const isEmbedding = model.type === 'embeddings' || categorizeModel(model.id) === 'embedding' + const owner = model.publisher || extractModelOwner(model.id) + const contextLength = model.loaded_context_length ?? model.max_context_length + const modelConfig: any = { id: model.id, name: formatModelName(model), @@ -82,12 +85,23 @@ async function processHost( modelConfig.organizationOwner = owner } - if (modelType === 'embedding') { + if (contextLength) { + modelConfig.limit = { + context: contextLength, + // LM Studio doesn't expose a max output token limit, so estimate as 25% of context + output: Math.floor(contextLength * 0.25), + } + } + + if (isEmbedding) { embeddingModelsCount++ modelConfig.modalities = { input: ["text"], output: ["embedding"] } - } else if (modelType === 'chat') { + } else { chatModelsCount++ modelConfig.modalities = { input: ["text", "image"], output: ["text"] } + if (model.capabilities?.includes('tool_use')) { + modelConfig.tool_call = true + } } discoveredModels[modelKey] = modelConfig diff --git a/src/types/index.ts b/src/types/index.ts index 36fd813..965755c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,8 +2,18 @@ export interface LMStudioModel { id: string object: string - created: number - owned_by: string + created?: number + owned_by?: string + // Fields from /api/v0/models (LM Studio extended API) + type?: 'llm' | 'vlm' | 'embeddings' + publisher?: string + arch?: string + compatibility_type?: string + quantization?: string + state?: 'loaded' | 'not-loaded' + max_context_length?: number + loaded_context_length?: number + capabilities?: string[] } export interface LMStudioModelsResponse { diff --git a/src/utils/lmstudio-api.ts b/src/utils/lmstudio-api.ts index 62967e7..4c95d59 100644 --- a/src/utils/lmstudio-api.ts +++ b/src/utils/lmstudio-api.ts @@ -1,7 +1,8 @@ import type { LMStudioModel, LMStudioModelsResponse } from '../types' const DEFAULT_LM_STUDIO_URL = "http://127.0.0.1:1234" -const LM_STUDIO_MODELS_ENDPOINT = "/v1/models" +const LM_STUDIO_MODELS_ENDPOINT = "/api/v0/models" +const LM_STUDIO_HEALTH_ENDPOINT = "/v1/models" // Normalize base URL to ensure consistent format export function normalizeBaseURL(baseURL: string = DEFAULT_LM_STUDIO_URL): string { @@ -25,7 +26,7 @@ export function buildAPIURL(baseURL: string, endpoint: string = LM_STUDIO_MODELS // Check if LM Studio is accessible export async function checkLMStudioHealth(baseURL: string = DEFAULT_LM_STUDIO_URL): Promise { try { - const url = buildAPIURL(baseURL) + const url = buildAPIURL(baseURL, LM_STUDIO_HEALTH_ENDPOINT) const response = await fetch(url, { method: "GET", signal: AbortSignal.timeout(3000), From 9c66e0faa88e71189be7f47507bb3fdc48a63198 Mon Sep 17 00:00:00 2001 From: Jonny Leaders Date: Wed, 25 Feb 2026 00:51:03 -0600 Subject: [PATCH 3/3] chore: replace personal hostnames with generic examples in comments and tests Co-Authored-By: Claude Sonnet 4.6 --- src/plugin/enhance-config.ts | 2 +- test/plugin.test.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index 8643b0a..e680c68 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -8,7 +8,7 @@ import type { LMStudioModel } from '../types' const modelStatusCache = new ModelStatusCache() // Match any provider key that looks like an LM Studio instance: -// lmstudio, lm-studio, lmstudio-wooden, lm-studio-alienware, etc. +// lmstudio, lm-studio, lmstudio-remote, lm-studio-workstation, etc. const LM_STUDIO_KEY_RE = /^lm.?studio/i export function isLMStudioProviderKey(key: string): boolean { diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 2fef43e..ffd8b66 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -181,11 +181,11 @@ describe('LMStudio Plugin', () => { it('should discover models from multiple lm-studio providers', async () => { // Route mock responses by URL so the test is resilient to call ordering mockFetch.mockImplementation(async (url: string) => { - if (typeof url === 'string' && url.includes('wooden.local')) { + if (typeof url === 'string' && url.includes('192.168.1.100')) { return { ok: true, json: async () => ({ - data: [{ id: 'wooden-model', object: 'model', created: 1234567890, owned_by: 'local' }] + data: [{ id: 'remote-model', object: 'model', created: 1234567890, owned_by: 'local' }] }) } } @@ -204,10 +204,10 @@ describe('LMStudio Plugin', () => { name: 'LM Studio (local)', options: { baseURL: 'http://127.0.0.1:1234/v1' }, }, - 'lm-studio-wooden': { + 'lm-studio-remote': { npm: '@ai-sdk/openai-compatible', - name: 'LM Studio (wooden)', - options: { baseURL: 'http://wooden.local:1234/v1' }, + name: 'LM Studio (remote)', + options: { baseURL: 'http://192.168.1.100:1234/v1' }, }, } } @@ -217,8 +217,8 @@ describe('LMStudio Plugin', () => { expect(config.provider['lm-studio'].models).toMatchObject({ 'local-model': expect.objectContaining({ id: 'local-model' }) }) - expect(config.provider['lm-studio-wooden'].models).toMatchObject({ - 'wooden-model': expect.objectContaining({ id: 'wooden-model' }) + expect(config.provider['lm-studio-remote'].models).toMatchObject({ + 'remote-model': expect.objectContaining({ id: 'remote-model' }) }) })