Skip to content

Commit cd3e682

Browse files
committed
Normalize LMDeploy installer paths and prefer managed venv binary
1 parent 5e7d260 commit cd3e682

2 files changed

Lines changed: 364 additions & 3 deletions

File tree

backend/lmdeploy_installer.py

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
import asyncio
2+
import json
3+
import os
4+
import shutil
5+
import subprocess
6+
import sys
7+
from asyncio.subprocess import PIPE, STDOUT
8+
from datetime import datetime, timezone
9+
from typing import Any, Awaitable, Dict, Optional
10+
11+
from backend.logging_config import get_logger
12+
from backend.websocket_manager import websocket_manager
13+
14+
15+
def _utcnow() -> str:
16+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
17+
18+
19+
logger = get_logger(__name__)
20+
21+
_installer_instance: Optional["LMDeployInstaller"] = None
22+
23+
24+
def get_lmdeploy_installer() -> "LMDeployInstaller":
25+
global _installer_instance
26+
if _installer_instance is None:
27+
_installer_instance = LMDeployInstaller()
28+
return _installer_instance
29+
30+
31+
class LMDeployInstaller:
32+
"""Install or remove LMDeploy inside the runtime environment on demand."""
33+
34+
def __init__(
35+
self,
36+
*,
37+
log_path: Optional[str] = None,
38+
state_path: Optional[str] = None,
39+
base_dir: Optional[str] = None,
40+
) -> None:
41+
self._lock = asyncio.Lock()
42+
self._operation: Optional[str] = None
43+
self._operation_started_at: Optional[str] = None
44+
self._current_task: Optional[asyncio.Task] = None
45+
self._last_error: Optional[str] = None
46+
data_root = os.path.abspath("data")
47+
base_path = base_dir or os.path.join(data_root, "lmdeploy")
48+
self._base_dir = os.path.abspath(base_path)
49+
self._venv_path = os.path.join(self._base_dir, "venv")
50+
log_path = log_path or os.path.join(data_root, "logs", "lmdeploy_install.log")
51+
state_path = state_path or os.path.join(data_root, "configs", "lmdeploy_installer.json")
52+
self._log_path = os.path.abspath(log_path)
53+
self._state_path = os.path.abspath(state_path)
54+
self._ensure_directories()
55+
56+
def _ensure_directories(self) -> None:
57+
os.makedirs(self._base_dir, exist_ok=True)
58+
os.makedirs(os.path.dirname(self._log_path), exist_ok=True)
59+
os.makedirs(os.path.dirname(self._state_path), exist_ok=True)
60+
61+
def _venv_bin(self, executable: str) -> str:
62+
if os.name == "nt":
63+
exe = executable if executable.lower().endswith(".exe") else f"{executable}.exe"
64+
return os.path.join(self._venv_path, "Scripts", exe)
65+
return os.path.join(self._venv_path, "bin", executable)
66+
67+
def _venv_python(self) -> str:
68+
return self._venv_bin("python")
69+
70+
def _ensure_venv(self) -> None:
71+
python_path = self._venv_python()
72+
if os.path.exists(python_path):
73+
return
74+
os.makedirs(self._base_dir, exist_ok=True)
75+
try:
76+
subprocess.run([sys.executable, "-m", "venv", self._venv_path], check=True)
77+
except subprocess.CalledProcessError as exc:
78+
raise RuntimeError(f"Failed to create LMDeploy virtual environment: {exc}") from exc
79+
80+
def _load_state(self) -> Dict[str, Any]:
81+
if not os.path.exists(self._state_path):
82+
return {}
83+
try:
84+
with open(self._state_path, "r", encoding="utf-8") as handle:
85+
data = json.load(handle)
86+
return data if isinstance(data, dict) else {}
87+
except Exception as exc:
88+
logger.warning(f"Failed to load LMDeploy installer state: {exc}")
89+
return {}
90+
91+
def _save_state(self, state: Dict[str, Any]) -> None:
92+
tmp_path = f"{self._state_path}.tmp"
93+
with open(tmp_path, "w", encoding="utf-8") as handle:
94+
json.dump(state, handle, indent=2)
95+
os.replace(tmp_path, self._state_path)
96+
97+
def _detect_installed_version(self) -> Optional[str]:
98+
python_exe = self._venv_python()
99+
if not os.path.exists(python_exe):
100+
return None
101+
script = (
102+
"import importlib, sys\n"
103+
"try:\n"
104+
" from importlib import metadata\n"
105+
"except ImportError:\n"
106+
" import importlib_metadata as metadata\n"
107+
"try:\n"
108+
" print(metadata.version('lmdeploy'))\n"
109+
"except metadata.PackageNotFoundError:\n"
110+
" sys.exit(1)\n"
111+
)
112+
try:
113+
output = subprocess.check_output([python_exe, "-c", script], text=True).strip()
114+
return output or None
115+
except subprocess.CalledProcessError:
116+
return None
117+
except Exception as exc: # pragma: no cover
118+
logger.debug(f"Unable to determine LMDeploy version: {exc}")
119+
return None
120+
121+
def _resolve_binary_path(self) -> Optional[str]:
122+
override = os.getenv("LMDEPLOY_BIN")
123+
if override:
124+
override_path = os.path.abspath(os.path.expanduser(override))
125+
if os.path.exists(override_path):
126+
return override_path
127+
resolved_override = shutil.which(override)
128+
if resolved_override:
129+
return resolved_override
130+
131+
candidate = self._venv_bin("lmdeploy")
132+
if os.path.exists(candidate) and os.access(candidate, os.X_OK):
133+
return os.path.abspath(candidate)
134+
135+
resolved = shutil.which("lmdeploy")
136+
return resolved
137+
138+
def _update_installed_state(self, installed: bool, version: Optional[str] = None) -> None:
139+
state = self._load_state()
140+
if installed:
141+
state["installed_at"] = _utcnow()
142+
if version:
143+
state["installed_version"] = version
144+
state["venv_path"] = self._venv_path
145+
else:
146+
state["installed_version"] = None
147+
state["installed_at"] = None
148+
state["removed_at"] = _utcnow()
149+
state["venv_path"] = self._venv_path
150+
self._save_state(state)
151+
152+
def _refresh_state_from_environment(self) -> None:
153+
state = self._load_state()
154+
version = self._detect_installed_version()
155+
state["installed_version"] = version
156+
if version is None:
157+
state["removed_at"] = _utcnow()
158+
state["venv_path"] = self._venv_path
159+
self._save_state(state)
160+
161+
async def _run_pip(self, args: list[str], operation: str, ensure_venv: bool = True) -> int:
162+
if ensure_venv:
163+
self._ensure_venv()
164+
python_exe = self._venv_python()
165+
if not os.path.exists(python_exe):
166+
raise RuntimeError("LMDeploy virtual environment is missing; cannot run pip.")
167+
header = f"[{_utcnow()}] Starting LMDeploy {operation} via pip {' '.join(args)}\n"
168+
with open(self._log_path, "w", encoding="utf-8") as log_file:
169+
log_file.write(header)
170+
process = await asyncio.create_subprocess_exec(
171+
python_exe,
172+
"-m",
173+
"pip",
174+
*args,
175+
stdout=PIPE,
176+
stderr=STDOUT,
177+
)
178+
179+
async def _stream_output() -> None:
180+
if process.stdout is None:
181+
return
182+
with open(self._log_path, "a", encoding="utf-8", buffering=1) as log_file:
183+
while True:
184+
chunk = await process.stdout.readline()
185+
if not chunk:
186+
break
187+
text = chunk.decode("utf-8", errors="replace")
188+
log_file.write(text)
189+
await self._broadcast_log_line(text.rstrip("\n"))
190+
191+
await asyncio.gather(process.wait(), _stream_output())
192+
return process.returncode or 0
193+
194+
async def _broadcast_log_line(self, line: str) -> None:
195+
try:
196+
await websocket_manager.broadcast(
197+
{
198+
"type": "lmdeploy_install_log",
199+
"line": line,
200+
"timestamp": _utcnow(),
201+
}
202+
)
203+
except Exception as exc: # pragma: no cover
204+
logger.debug(f"Failed to broadcast LMDeploy log line: {exc}")
205+
206+
async def _set_operation(self, operation: str) -> None:
207+
self._operation = operation
208+
self._operation_started_at = _utcnow()
209+
self._last_error = None
210+
await websocket_manager.broadcast(
211+
{
212+
"type": "lmdeploy_install_status",
213+
"status": operation,
214+
"started_at": self._operation_started_at,
215+
}
216+
)
217+
218+
async def _finish_operation(self, success: bool, message: str = "") -> None:
219+
payload = {
220+
"type": "lmdeploy_install_status",
221+
"status": "completed" if success else "failed",
222+
"operation": self._operation,
223+
"message": message,
224+
"ended_at": _utcnow(),
225+
}
226+
await websocket_manager.broadcast(payload)
227+
self._operation = None
228+
self._operation_started_at = None
229+
230+
def _create_task(self, coro: Awaitable[Any]) -> None:
231+
loop = asyncio.get_running_loop()
232+
task = loop.create_task(coro)
233+
self._current_task = task
234+
235+
def _cleanup(fut: asyncio.Future) -> None:
236+
try:
237+
fut.result()
238+
except Exception as exc: # pragma: no cover - surfaced via status
239+
logger.error(f"LMDeploy installer task error: {exc}")
240+
finally:
241+
self._current_task = None
242+
243+
task.add_done_callback(_cleanup)
244+
245+
async def install(self, version: Optional[str] = None, force_reinstall: bool = False) -> Dict[str, Any]:
246+
async with self._lock:
247+
if self._operation:
248+
raise RuntimeError("Another LMDeploy installer operation is already running")
249+
await self._set_operation("install")
250+
args = ["install", "--upgrade"]
251+
if force_reinstall:
252+
args.append("--force-reinstall")
253+
package = "lmdeploy"
254+
if version:
255+
package = f"lmdeploy=={version}"
256+
args.append(package)
257+
258+
async def _runner():
259+
try:
260+
code = await self._run_pip(args, "install")
261+
if code != 0:
262+
raise RuntimeError(f"pip exited with status {code}")
263+
detected_version = self._detect_installed_version()
264+
self._update_installed_state(True, detected_version)
265+
await self._finish_operation(True, "LMDeploy installed")
266+
except Exception as exc:
267+
self._last_error = str(exc)
268+
self._refresh_state_from_environment()
269+
await self._finish_operation(False, str(exc))
270+
271+
self._create_task(_runner())
272+
return {"message": "LMDeploy installation started"}
273+
274+
async def remove(self) -> Dict[str, Any]:
275+
async with self._lock:
276+
if self._operation:
277+
raise RuntimeError("Another LMDeploy installer operation is already running")
278+
await self._set_operation("remove")
279+
args = ["uninstall", "-y", "lmdeploy"]
280+
281+
async def _runner():
282+
try:
283+
python_exists = os.path.exists(self._venv_python())
284+
if python_exists:
285+
code = await self._run_pip(args, "remove", ensure_venv=False)
286+
if code != 0:
287+
raise RuntimeError(f"pip exited with status {code}")
288+
shutil.rmtree(self._venv_path, ignore_errors=True)
289+
self._update_installed_state(False)
290+
await self._finish_operation(True, "LMDeploy removed")
291+
except Exception as exc:
292+
self._last_error = str(exc)
293+
self._refresh_state_from_environment()
294+
await self._finish_operation(False, str(exc))
295+
296+
self._create_task(_runner())
297+
return {"message": "LMDeploy removal started"}
298+
299+
def status(self) -> Dict[str, Any]:
300+
version = self._detect_installed_version()
301+
binary_path = self._resolve_binary_path()
302+
installed = version is not None and binary_path is not None
303+
state = self._load_state()
304+
return {
305+
"installed": installed,
306+
"version": version,
307+
"binary_path": binary_path,
308+
"venv_path": state.get("venv_path") or self._venv_path,
309+
"installed_at": state.get("installed_at"),
310+
"removed_at": state.get("removed_at"),
311+
"operation": self._operation,
312+
"operation_started_at": self._operation_started_at,
313+
"last_error": self._last_error,
314+
"log_path": self._log_path,
315+
}
316+
317+
def is_operation_running(self) -> bool:
318+
return self._operation is not None
319+
320+
def read_log_tail(self, max_bytes: int = 8192) -> str:
321+
if not os.path.exists(self._log_path):
322+
return ""
323+
with open(self._log_path, "rb") as log_file:
324+
log_file.seek(0, os.SEEK_END)
325+
size = log_file.tell()
326+
log_file.seek(max(0, size - max_bytes))
327+
data = log_file.read().decode("utf-8", errors="replace")
328+
if size > max_bytes:
329+
data = data.split("\n", 1)[-1]
330+
return data.strip()
331+

backend/lmdeploy_manager.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import os
33
import shlex
4+
import shutil
45
from datetime import datetime
56
from typing import Optional, Dict, Any
67

@@ -57,7 +58,8 @@ async def start(self, model_entry: Dict[str, Any], config: Dict[str, Any]) -> Di
5758
raise FileNotFoundError(f"Model directory not found at {model_dir}")
5859
model_dir_abs = os.path.abspath(model_dir)
5960

60-
command = self._build_command(model_dir_abs, config)
61+
binary = self._resolve_binary()
62+
command = self._build_command(binary, model_dir_abs, config)
6163
env = os.environ.copy()
6264
env.setdefault("LMDEPLOY_LOG_DIR", os.path.dirname(self._log_path))
6365
os.makedirs(os.path.dirname(self._log_path), exist_ok=True)
@@ -127,10 +129,38 @@ def status(self) -> Dict[str, Any]:
127129
"started_at": self._started_at,
128130
"current_instance": self._current_instance if running else None,
129131
"health": self._last_health_status,
132+
"binary_path": self._current_binary_path(),
130133
"log_path": self._log_path,
131134
}
132135

133-
def _build_command(self, model_dir: str, config: Dict[str, Any]) -> list:
136+
def _current_binary_path(self) -> Optional[str]:
137+
try:
138+
return self._resolve_binary()
139+
except FileNotFoundError:
140+
return None
141+
142+
def _resolve_binary(self) -> str:
143+
try:
144+
from backend.lmdeploy_installer import get_lmdeploy_installer
145+
146+
installer_binary = get_lmdeploy_installer().status().get("binary_path")
147+
if installer_binary and os.path.exists(installer_binary):
148+
return installer_binary
149+
except Exception as exc:
150+
logger.debug(f"Failed to resolve LMDeploy binary via installer status: {exc}")
151+
152+
resolved = shutil.which(self.binary_path)
153+
if resolved:
154+
return resolved
155+
156+
candidate = os.path.expanduser(self.binary_path)
157+
if os.path.isabs(candidate) and os.path.exists(candidate):
158+
return candidate
159+
raise FileNotFoundError(
160+
"LMDeploy binary not found in PATH. Install LMDeploy from the LMDeploy page or set LMDEPLOY_BIN."
161+
)
162+
163+
def _build_command(self, binary: str, model_dir: str, config: Dict[str, Any]) -> list:
134164
"""Convert stored config into lmdeploy CLI arguments."""
135165
tensor_parallel = max(1, int(config.get("tensor_parallel") or 1))
136166
pipeline_parallel = max(1, int(config.get("pipeline_parallel") or 1))
@@ -142,7 +172,7 @@ def _build_command(self, model_dir: str, config: Dict[str, Any]) -> list:
142172
)
143173

144174
command = [
145-
self.binary_path,
175+
binary,
146176
"serve",
147177
"api_server",
148178
model_dir,

0 commit comments

Comments
 (0)