Source code for mllm_shap.backends.lmstudio.backend

"""LM Studio generative backend.

@domain backends
@see [[knowledge/architecture/backends]]
@tested-by [[tests/backends/test_lmstudio.py]]
"""

import asyncio
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, ClassVar

from ...domain.config import GenerationConfig
from ...domain.conversation import ConversationSnapshot
from ...domain.generation import GenerationRecord
from ...errors import BackendUnavailableError
from ...protocols.plugin import PluginOptions
from ...value.generation_records import generation_record_from_text
from .._chat import snapshot_has_audio
from .chat import snapshot_to_chat
from .models import resolve_model_key
from .options import LmStudioOptions, parse_lmstudio_options

if TYPE_CHECKING:
    from ...plugins.context import PluginContext

logger = logging.getLogger(__name__)


[docs] class LmStudioBackend: """Generate text through a local LM Studio API server.""" options_model: ClassVar[type[PluginOptions]] = PluginOptions """Accepts no ``explanation.plugin_options``.""" def __init__(self, context: "PluginContext") -> None: """Read ``context.config.backend`` and validate its LM Studio ``options``.""" self._config = context.config.backend self._options = parse_lmstudio_options(self._config.options) self._client: Any | None = None self._model: Any | None = None self._init_lock = asyncio.Lock() self._generate_lock = asyncio.Lock() self._warned_transcript = False @property def options(self) -> LmStudioOptions: """Return the validated LM Studio options.""" return self._options @property def model_id(self) -> str: """Return the configured LM Studio model identifier.""" return self._config.model_id
[docs] async def generate( self, snapshot: ConversationSnapshot, generation: GenerationConfig, ) -> GenerationRecord: """Generate assistant text for ``snapshot`` via LM Studio.""" if snapshot_has_audio(snapshot): self._check_audio() lms = _import_lmstudio() chat = snapshot_to_chat(snapshot, lms.Chat) prediction_config: dict[str, float | int] = { "temperature": generation.temperature, "maxTokens": generation.max_new_tokens, } if generation.top_k > 0: prediction_config["topKSampling"] = generation.top_k async with self._generation_slot(): model = await self._ensure_model() try: result = await model.respond(chat, config=prediction_config) except (ValueError, TypeError): raise except Exception as exc: if not _is_transient(exc): msg = "LM Studio generation request failed" raise BackendUnavailableError(msg) from exc # The connection dropped or the model unloaded: reload, retry once. self._model = None try: model = await self._ensure_model() result = await model.respond(chat, config=prediction_config) except Exception as retry_exc: msg = "LM Studio generation request failed" raise BackendUnavailableError(msg) from retry_exc text = _extract_text(result) return generation_record_from_text(text)
def _check_audio(self) -> None: """Refuse audio, or warn once that only its transcript is sent.""" if self._options.audio == "refuse": msg = ( "audio snapshots are not supported by the LM Studio backend (its SDK " "takes no audio input); set backend.options audio: transcript to " "score the text parts only" ) raise ValueError(msg) if not self._warned_transcript: self._warned_transcript = True logger.warning( "LM Studio backend sends the text parts only: the audio is not heard" )
[docs] async def aclose(self) -> None: """Close the underlying LM Studio client connection.""" if self._client is not None: await self._client.__aexit__(None, None, None) self._client = None self._model = None
@asynccontextmanager async def _generation_slot(self) -> AsyncGenerator[None]: if self._options.serialize_generate: async with self._generate_lock: yield else: yield async def _ensure_model(self) -> Any: async with self._init_lock: if self._model is not None: return self._model client = await self._connect_client() try: model_key = await resolve_model_key( client, self._config.model_id, self._options ) # ttl=None keeps the instance loaded (UI Idle TTL still applies). self._model = await client.llm.model(model_key, ttl=None) except BackendUnavailableError: await self.aclose() raise except Exception as exc: await self.aclose() msg = f"failed to load LM Studio model {self._config.model_id!r}" raise BackendUnavailableError(msg) from exc return self._model async def _connect_client(self) -> Any: if self._client is not None: return self._client lms = _import_lmstudio() host = self._config.api_host if not host: host = await lms.AsyncClient.find_default_local_api_host() if host is None or not await lms.AsyncClient.is_valid_api_host(host): msg = "LM Studio API server is not reachable on the local machine" raise BackendUnavailableError(msg) client = lms.AsyncClient(host) try: await client.__aenter__() except Exception as exc: msg = f"failed to connect to LM Studio API host {host!r}" raise BackendUnavailableError(msg) from exc self._client = client return client
def _is_transient(error: Exception) -> bool: """Return whether ``error`` looks like a dropped connection worth one retry.""" if isinstance(error, OSError): return True return any( "Websocket" in cls.__name__ or "Connection" in cls.__name__ for cls in type(error).__mro__ ) def _extract_text(result: object) -> str: content = getattr(result, "content", None) if isinstance(content, str): return content.strip() msg = "LM Studio prediction result did not include text content" raise BackendUnavailableError(msg) def _import_lmstudio() -> Any: try: import lmstudio as lms except ImportError as exc: msg = "lmstudio package is required for the LM Studio backend extra" raise BackendUnavailableError(msg) from exc _silence_lmstudio_sdk_logs() return lms def _silence_lmstudio_sdk_logs() -> None: """Drop LM Studio SDK websocket chatter (class-named loggers at INFO).""" # SDK uses type(self).__name__ as logger names (not under ``lmstudio.*``). for name in ( "_AsyncLMStudioWebsocket", "AsyncWebsocketHandler", "AsyncWebsocketThread", "_SyncLMStudioWebsocket", "SyncWebsocketHandler", ): logging.getLogger(name).setLevel(logging.WARNING)