Source code for nlp_shap.backends.lmstudio.backend
"""LM Studio generative backend."""
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from ...backends.mock.generation import generation_record_from_snapshot
from ...domain.conversation import ConversationSnapshot
from ...domain.generation import GenerationRecord
from ...errors import BackendUnavailableError
from ...pipeline.config import BackendConfig
from .chat import snapshot_to_chat
from .models import resolve_model_key
[docs]
class LmStudioBackend:
"""Generate text through a local LM Studio API server."""
def __init__(self, config: BackendConfig) -> None:
self._config = config
self._client: Any | None = None
self._model: Any | None = None
self._init_lock = asyncio.Lock()
self._generate_lock = asyncio.Lock()
@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,
max_new_tokens: int,
temperature: float,
top_k: int,
) -> GenerationRecord:
"""Generate assistant text for ``snapshot`` via LM Studio."""
lms = _import_lmstudio()
chat = snapshot_to_chat(snapshot, lms.Chat)
prediction_config: dict[str, float | int] = {
"temperature": temperature,
"maxTokens": max_new_tokens,
}
if top_k > 0:
prediction_config["topKSampling"] = top_k
async with self._generation_slot():
model = await self._ensure_model()
try:
result = await model.respond(chat, config=prediction_config)
except Exception:
# Model may have crashed/unloaded; drop handle and 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_snapshot(text, snapshot)
[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) -> AsyncIterator[None]:
if self._config.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)
# 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 _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)