import { LMStudioClient as SDKClient, LLM } from '@lmstudio/sdk'; import { logError, logInfo } from '../utils'; export interface ILMStudioClient { load(modelKey: string, signal?: AbortSignal): Promise; unload(modelKey: string): Promise; listLoaded(): Promise; /** Like listLoaded() but caches the result for `ttlMs` to avoid hammering the SDK. */ listLoadedCached(ttlMs?: number): Promise; /** * Resolve a chat-ready handle for an already-loaded (or just-loaded) model. * * `options.refresh: true` drops the SDK + WebSocket so any disposed handle * sitting in the SDK's internal handle map is discarded. Use this after a * "Model is disposed!" or "lock() request could not be registered" error. */ getModelHandle(modelKey: string, options?: { refresh?: boolean }): Promise; isReachable(): Promise; setBaseUrl(httpBaseUrl: string): void; } export class LMStudioLifecycleError extends Error { constructor(message: string, public readonly cause?: unknown) { super(message); this.name = 'LMStudioLifecycleError'; } } export function httpToWebSocketUrl(httpBaseUrl: string): string | undefined { const trimmed = (httpBaseUrl || '').trim(); if (!trimmed) return undefined; try { const url = new URL(trimmed); if (url.protocol === 'http:') url.protocol = 'ws:'; else if (url.protocol === 'https:') url.protocol = 'wss:'; else if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return undefined; if (url.pathname.endsWith('/v1')) url.pathname = url.pathname.slice(0, -3); if (url.pathname.endsWith('/api')) url.pathname = url.pathname.slice(0, -4); const out = url.toString().replace(/\/+$/, ''); return out; } catch { return undefined; } } export class LMStudioClient implements ILMStudioClient { private _sdk: SDKClient | undefined; private _wsUrl: string | undefined; private _loadedCache: { value: string[]; expiresAt: number } | undefined; private static readonly DEFAULT_LOADED_CACHE_TTL_MS = 5000; constructor(httpBaseUrl: string) { this.setBaseUrl(httpBaseUrl); } setBaseUrl(httpBaseUrl: string): void { const ws = httpToWebSocketUrl(httpBaseUrl); if (ws !== this._wsUrl) { this._wsUrl = ws; this._sdk = undefined; this._loadedCache = undefined; } } private getSdk(): SDKClient { if (!this._sdk) { this._sdk = new SDKClient(this._wsUrl ? { baseUrl: this._wsUrl } : {}); } return this._sdk; } async load(modelKey: string, signal?: AbortSignal): Promise { try { await this.getSdk().llm.load(modelKey, signal ? { signal } : undefined); this._loadedCache = undefined; logInfo('LM Studio model loaded.', { modelKey }); } catch (e: any) { const msg = e?.message ?? String(e); throw new LMStudioLifecycleError(`Failed to load LM Studio model "${modelKey}": ${msg}`, e); } } async unload(modelKey: string): Promise { try { await this.getSdk().llm.unload(modelKey); this._loadedCache = undefined; logInfo('LM Studio model unloaded.', { modelKey }); } catch (e: any) { const msg = e?.message ?? String(e); throw new LMStudioLifecycleError(`Failed to unload LM Studio model "${modelKey}": ${msg}`, e); } } async listLoaded(): Promise { try { const items: any[] = await this.getSdk().llm.listLoaded(); return items .map((m) => m?.identifier ?? m?.modelKey ?? m?.path ?? null) .filter((id): id is string => typeof id === 'string' && id.length > 0); } catch (e: any) { const msg = e?.message ?? String(e); throw new LMStudioLifecycleError(`Failed to list loaded LM Studio models: ${msg}`, e); } } async listLoadedCached(ttlMs: number = LMStudioClient.DEFAULT_LOADED_CACHE_TTL_MS): Promise { const now = Date.now(); if (this._loadedCache && this._loadedCache.expiresAt > now) { return this._loadedCache.value.slice(); } try { const value = await this.listLoaded(); this._loadedCache = { value, expiresAt: now + ttlMs }; return value.slice(); } catch { return []; } } async getModelHandle(modelKey: string, options?: { refresh?: boolean }): Promise { try { if (options?.refresh) { // Recreate the SDK + WebSocket so the SDK's internal handle // cache is dropped. The next llm.model() call mints a fresh // handle instead of returning the disposed one from the // previous (aborted) prediction. this._sdk = undefined; this._loadedCache = undefined; logInfo('LM Studio SDK handle refresh requested — dropped cached SDK client.', { modelKey }); } return await this.getSdk().llm.model(modelKey); } catch (e: any) { const msg = e?.message ?? String(e); throw new LMStudioLifecycleError(`Failed to acquire LM Studio model handle "${modelKey}": ${msg}`, e); } } async isReachable(): Promise { try { await this.getSdk().llm.listLoaded(); return true; } catch (e: any) { logError('LM Studio not reachable.', { error: e?.message ?? String(e) }); return false; } } }