Skip to content

Commit be8b59d

Browse files
author
Tooru
committed
Add LM Studio model switch endpoint
1 parent b9e2c7d commit be8b59d

3 files changed

Lines changed: 261 additions & 1 deletion

File tree

gui/model-source.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,42 @@
1919
let openrouterProviderBackup = providerInput?.value || '';
2020
let openrouterMaxTokensBackup = maxTokensInput?.value || '';
2121
let inFlight = null;
22+
let activeLmStudioModel = '';
23+
24+
async function switchLmStudioModel(modelId) {
25+
if (!modelId) return;
26+
27+
lmstudioModelSelect.disabled = true;
28+
setLmStudioNote(`Loading '${modelId}' in LM Studio…`);
29+
30+
try {
31+
const response = await fetch('/models/lmstudio/switch', {
32+
method: 'POST',
33+
headers: { 'Content-Type': 'application/json' },
34+
body: JSON.stringify({ model_id: modelId })
35+
});
36+
37+
if (!response.ok) {
38+
let detail = '';
39+
try {
40+
const payload = await response.json();
41+
detail = payload?.detail || '';
42+
} catch {
43+
// ignore
44+
}
45+
throw new Error(detail || response.statusText || 'Unable to switch LM Studio model');
46+
}
47+
48+
activeLmStudioModel = modelId;
49+
} catch (error) {
50+
setLmStudioNote(error?.message || 'Unable to switch LM Studio model.');
51+
return;
52+
} finally {
53+
lmstudioModelSelect.disabled = false;
54+
}
55+
56+
applyLmStudioModelSelection();
57+
}
2258

2359
function getSource() {
2460
return sourceSelect.value === 'lmstudio' ? 'lmstudio' : 'openrouter';
@@ -104,6 +140,7 @@
104140

105141
lmstudioModelSelect.disabled = false;
106142
applyLmStudioModelSelection();
143+
activeLmStudioModel = lmstudioModelSelect.value;
107144
})
108145
.catch((error) => {
109146
lmstudioModelSelect.innerHTML = '';
@@ -120,6 +157,20 @@
120157
return inFlight;
121158
}
122159

160+
async function handleLmStudioModelChange() {
161+
const selected = lmstudioModelSelect.value;
162+
applyLmStudioModelSelection();
163+
164+
if (getSource() !== 'lmstudio') return;
165+
if (!selected) {
166+
activeLmStudioModel = '';
167+
return;
168+
}
169+
170+
if (selected === activeLmStudioModel) return;
171+
await switchLmStudioModel(selected);
172+
}
173+
123174
function applyModelSourceUI() {
124175
const isLmstudio = getSource() === 'lmstudio';
125176
openrouterFields.hidden = isLmstudio;
@@ -166,7 +217,7 @@
166217

167218
sourceSelect.addEventListener('change', applyModelSourceUI);
168219
sourceSelect.addEventListener('input', applyModelSourceUI);
169-
lmstudioModelSelect.addEventListener('change', applyLmStudioModelSelection);
220+
lmstudioModelSelect.addEventListener('change', handleLmStudioModelChange);
170221
applyModelSourceUI();
171222
}
172223

server/routes/router.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
import asyncio
44
import datetime as dt
55
import json
6+
import re
7+
import shutil
8+
import subprocess
69
from pathlib import Path
710
from typing import Any
811
from urllib.error import HTTPError, URLError
12+
from urllib.parse import urlparse
913
from urllib.request import Request, urlopen
1014

1115
import structlog
@@ -368,6 +372,108 @@ def list_lmstudio_models() -> dict[str, Any]:
368372
}
369373

370374

375+
_LMSTUDIO_MODEL_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/@:+-]*\Z")
376+
377+
378+
def _resolve_lms_path() -> str | None:
379+
resolved = shutil.which("lms")
380+
if resolved:
381+
return resolved
382+
fallback = Path.home() / ".lmstudio" / "bin" / "lms"
383+
if fallback.exists():
384+
return str(fallback)
385+
return None
386+
387+
388+
def _lmstudio_cli_instance_args(base_url: str) -> list[str]:
389+
trimmed = (base_url or "").strip()
390+
if not trimmed:
391+
return []
392+
if "://" not in trimmed:
393+
trimmed = f"http://{trimmed}"
394+
parsed = urlparse(trimmed)
395+
host = parsed.hostname or "127.0.0.1"
396+
port = parsed.port or 1234
397+
return ["--host", host, "--port", str(port)]
398+
399+
400+
def _truncate_cli_output(value: str, *, limit: int = 2000) -> str:
401+
cleaned = (value or "").strip()
402+
if len(cleaned) > limit:
403+
return f"{cleaned[:limit]}..."
404+
return cleaned
405+
406+
407+
class LMStudioModelSwitchRequest(BaseModel):
408+
model_id: str
409+
410+
411+
class LMStudioModelSwitchResponse(BaseModel):
412+
model_id: str
413+
message: str
414+
415+
416+
@router.post("/models/lmstudio/switch", response_model=LMStudioModelSwitchResponse, tags=["models"])
417+
def switch_lmstudio_model(
418+
request: LMStudioModelSwitchRequest,
419+
_: None = Depends(require_api_token),
420+
) -> LMStudioModelSwitchResponse:
421+
model_id = (request.model_id or "").strip()
422+
if not model_id:
423+
raise HTTPException(status_code=422, detail="model_id is required")
424+
if _LMSTUDIO_MODEL_ID_PATTERN.fullmatch(model_id) is None:
425+
raise HTTPException(status_code=400, detail="Invalid model_id format")
426+
427+
base_url = (settings.lmstudio_base_url or "").strip()
428+
if not base_url:
429+
raise HTTPException(status_code=500, detail="LM Studio base URL is not configured")
430+
431+
lms_path = _resolve_lms_path()
432+
if not lms_path:
433+
raise HTTPException(
434+
status_code=501,
435+
detail="LM Studio CLI 'lms' was not found. Install LM Studio or add 'lms' to PATH to enable model switching.",
436+
)
437+
438+
instance_args = _lmstudio_cli_instance_args(base_url)
439+
440+
try:
441+
unload = subprocess.run(
442+
[lms_path, "unload", "--all", *instance_args],
443+
capture_output=True,
444+
text=True,
445+
timeout=30,
446+
)
447+
except subprocess.TimeoutExpired as exc: # pragma: no cover
448+
raise HTTPException(status_code=504, detail="Timed out unloading LM Studio models") from exc
449+
450+
if unload.returncode != 0:
451+
detail = _truncate_cli_output(unload.stderr or unload.stdout)
452+
raise HTTPException(
453+
status_code=502,
454+
detail=f"Unable to unload LM Studio models: {detail or 'unknown error'}",
455+
)
456+
457+
try:
458+
load = subprocess.run(
459+
[lms_path, "load", model_id, "--exact", "-y", *instance_args],
460+
capture_output=True,
461+
text=True,
462+
timeout=600,
463+
)
464+
except subprocess.TimeoutExpired as exc: # pragma: no cover
465+
raise HTTPException(status_code=504, detail=f"Timed out loading '{model_id}' in LM Studio") from exc
466+
467+
if load.returncode != 0:
468+
detail = _truncate_cli_output(load.stderr or load.stdout)
469+
raise HTTPException(
470+
status_code=502,
471+
detail=f"Unable to load '{model_id}' in LM Studio: {detail or 'unknown error'}",
472+
)
473+
474+
return LMStudioModelSwitchResponse(model_id=model_id, message=f"Loaded '{model_id}' in LM Studio")
475+
476+
371477
class RetryApiErrorsResponse(BaseModel):
372478
retry_run_id: str
373479
original_run_id: str

tests/test_external_endpoint_config.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,109 @@ def test_lmstudio_endpoint_fails_when_base_url_empty(monkeypatch) -> None:
327327
assert "not configured" in response.json()["detail"]
328328

329329

330+
def test_switch_lmstudio_model_invokes_lms_cli(monkeypatch) -> None:
331+
import sys
332+
from unittest.mock import MagicMock
333+
334+
from fastapi.testclient import TestClient
335+
336+
from server.api import create_app
337+
from server.config import get_settings
338+
339+
get_settings.cache_clear()
340+
341+
router_module = sys.modules["server.routes.router"]
342+
343+
mock_settings = MagicMock()
344+
mock_settings.lmstudio_base_url = "http://custom-lmstudio:7777/v1"
345+
monkeypatch.setattr(router_module, "settings", mock_settings)
346+
347+
monkeypatch.setattr(router_module, "_resolve_lms_path", lambda: "/fake/lms")
348+
349+
calls: list[list[str]] = []
350+
351+
class FakeResult:
352+
def __init__(self) -> None:
353+
self.returncode = 0
354+
self.stdout = ""
355+
self.stderr = ""
356+
357+
def fake_run(args: list[str], *, capture_output: bool, text: bool, timeout: int) -> FakeResult:
358+
assert capture_output is True
359+
assert text is True
360+
assert timeout > 0
361+
calls.append(args)
362+
return FakeResult()
363+
364+
monkeypatch.setattr(router_module.subprocess, "run", fake_run)
365+
366+
app = create_app()
367+
client = TestClient(app, raise_server_exceptions=False)
368+
369+
response = client.post(
370+
"/models/lmstudio/switch",
371+
json={"model_id": "liquid/lfm2.5-1.2b"},
372+
)
373+
374+
assert response.status_code == 200
375+
payload = response.json()
376+
assert payload["model_id"] == "liquid/lfm2.5-1.2b"
377+
378+
assert calls == [
379+
[
380+
"/fake/lms",
381+
"unload",
382+
"--all",
383+
"--host",
384+
"custom-lmstudio",
385+
"--port",
386+
"7777",
387+
],
388+
[
389+
"/fake/lms",
390+
"load",
391+
"liquid/lfm2.5-1.2b",
392+
"--exact",
393+
"-y",
394+
"--host",
395+
"custom-lmstudio",
396+
"--port",
397+
"7777",
398+
],
399+
]
400+
401+
402+
def test_switch_lmstudio_model_rejects_invalid_model_id(monkeypatch) -> None:
403+
import sys
404+
from unittest.mock import MagicMock
405+
406+
from fastapi.testclient import TestClient
407+
408+
from server.api import create_app
409+
from server.config import get_settings
410+
411+
get_settings.cache_clear()
412+
413+
router_module = sys.modules["server.routes.router"]
414+
415+
mock_settings = MagicMock()
416+
mock_settings.lmstudio_base_url = "http://custom-lmstudio:7777/v1"
417+
monkeypatch.setattr(router_module, "settings", mock_settings)
418+
419+
monkeypatch.setattr(router_module, "_resolve_lms_path", lambda: "/fake/lms")
420+
421+
app = create_app()
422+
client = TestClient(app, raise_server_exceptions=False)
423+
424+
response = client.post(
425+
"/models/lmstudio/switch",
426+
json={"model_id": "bad model"},
427+
)
428+
429+
assert response.status_code == 400
430+
assert "Invalid" in response.json()["detail"]
431+
432+
330433
# =============================================================================
331434
# Expert Questions LM Studio URL Configuration Tests
332435
# =============================================================================

0 commit comments

Comments
 (0)