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..e680c68 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -7,150 +7,181 @@ import type { LMStudioModel } from '../types' const modelStatusCache = new ModelStatusCache() +// Match any provider key that looks like an LM Studio instance: +// lmstudio, lm-studio, lmstudio-remote, lm-studio-workstation, 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]) { + // 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), + } + + if (owner) { + modelConfig.organizationOwner = owner + } + + 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 { + chatModelsCount++ + modelConfig.modalities = { input: ["text", "image"], output: ["text"] } + if (model.capabilities?.includes('tool_use')) { + modelConfig.tool_call = true + } + } + + 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/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), 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..ffd8b66 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('192.168.1.100')) { + return { + ok: true, + json: async () => ({ + data: [{ id: 'remote-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-remote': { + npm: '@ai-sdk/openai-compatible', + name: 'LM Studio (remote)', + options: { baseURL: 'http://192.168.1.100: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-remote'].models).toMatchObject({ + 'remote-model': expect.objectContaining({ id: 'remote-model' }) + }) + }) + it('should handle LM Studio offline gracefully', async () => { mockFetch.mockRejectedValue(new Error('Connection refused'))