Skip to content

Commit e992e4c

Browse files
committed
Fix
1 parent cd3e682 commit e992e4c

16 files changed

Lines changed: 664 additions & 46 deletions

File tree

Dockerfile

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
################################################################################
22
# llama.cpp Studio - Multi-stage Docker build
33
################################################################################
4-
ARG BASE_IMAGE=nvidia/cuda:12.9.1-devel-ubuntu22.04
4+
ARG BASE_IMAGE=ubuntu:22.04
55

66
################################################################################
77
# Stage 1: Frontend Builder
@@ -31,7 +31,7 @@ FROM ${BASE_IMAGE} AS python-builder
3131

3232
ENV DEBIAN_FRONTEND=noninteractive
3333

34-
# Install build dependencies in one layer
34+
# Install build dependencies required for python wheels and runtime compilation (llama.cpp)
3535
RUN apt-get update && apt-get install -y --no-install-recommends \
3636
python3 \
3737
python3-venv \
@@ -50,6 +50,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
5050
curl \
5151
wget \
5252
ca-certificates \
53+
pciutils \
54+
lsb-release \
5355
&& rm -rf /var/lib/apt/lists/* \
5456
&& apt-get clean
5557

@@ -80,10 +82,17 @@ ENV DEBIAN_FRONTEND=noninteractive \
8082
VENV_PATH=/opt/venv \
8183
PYTHONPATH=/app
8284

83-
# Install runtime dependencies only
85+
# Install runtime dependencies (retain build toolchain for llama.cpp builds and GPU detection)
8486
RUN apt-get update && apt-get install -y --no-install-recommends \
8587
python3 \
8688
python3-venv \
89+
python3-pip \
90+
python3-dev \
91+
build-essential \
92+
cmake \
93+
git \
94+
pkg-config \
95+
ninja-build \
8796
curl \
8897
wget \
8998
ca-certificates \
@@ -98,6 +107,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
98107
mesa-vulkan-drivers \
99108
ocl-icd-libopencl1 \
100109
libnuma1 \
110+
pciutils \
111+
usbutils \
112+
lshw \
101113
# Optional: ROCm (fails gracefully if unavailable)
102114
&& (apt-get install -y --no-install-recommends rocminfo rocm-smi || echo "ROCm unavailable") \
103115
&& rm -rf /var/lib/apt/lists/* \
@@ -123,6 +135,7 @@ WORKDIR /app
123135
COPY backend/ ./backend/
124136
COPY migrate_db.py ./
125137
COPY --from=frontend-builder /build/dist ./frontend/dist
138+
COPY frontend/public ./frontend/public
126139

127140
# Create python symlink for compatibility
128141
RUN ln -sf /usr/bin/python3 /usr/bin/python

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ A professional AI model management platform for llama.cpp models and versions, d
4141
- **Real-time Updates**: WebSocket-based progress tracking and system monitoring
4242
- **Responsive Design**: Works on desktop and mobile devices
4343
- **System Status**: CPU, memory, disk, and GPU monitoring
44+
- **LMDeploy Installer**: Dedicated UI to install/remove LMDeploy at runtime with live logs
4445
- **Dark Mode**: Built-in theme support
4546

4647
## Quick Start
@@ -74,7 +75,7 @@ docker-compose -f docker-compose.rocm.yml up -d
7475

7576
Prebuilt images are pushed to GitHub Container Registry whenever the `publish-docker` workflow runs.
7677

77-
- `ghcr.io/<org-or-user>/llama-cpp-studio:latest` – standard image based on `nvidia/cuda:12.9.1-devel-ubuntu22.04`
78+
- `ghcr.io/<org-or-user>/llama-cpp-studio:latest` – standard image based on `ubuntu:22.04` with GPU tooling installed at runtime
7879

7980
Pull the image from GHCR:
8081

@@ -166,7 +167,7 @@ docker-compose up -d
166167

167168
### LMDeploy Requirement
168169

169-
Safetensors execution relies on [LMDeploy](https://github.com/InternLM/lmdeploy). The official Docker image installs `lmdeploy` (pinned in `requirements.txt`, currently `0.10.2`) during build so the CLI is already available at runtime. If you are running outside the container, install it manually (`pip install lmdeploy`) or point `LMDEPLOY_BIN` to a custom binary. The runtime uses `lmdeploy serve turbomind` to expose an OpenAI-compatible server on port `2001`.
170+
Safetensors execution relies on [LMDeploy](https://github.com/InternLM/lmdeploy), but the base image intentionally omits it to keep Docker builds lightweight (critical for GitHub Actions). Use the **LMDeploy** page in the UI to install or remove LMDeploy inside the running container—installs happen via `pip` at runtime and logs are streamed live. The installer creates a dedicated virtual environment under `/app/data/lmdeploy/venv`, so the package lives on the writable volume and can be removed by deleting that folder. If you are running outside the container, you can still `pip install lmdeploy` manually or point `LMDEPLOY_BIN` to a custom binary. The runtime uses `lmdeploy serve turbomind` to expose an OpenAI-compatible server on port `2001`.
170171

171172
## Usage
172173

@@ -260,7 +261,7 @@ Model names are shown in System Status after starting a model.
260261
- Run exactly one safetensors checkpoint at a time via LMDeploy
261262
- Configure tensor/pipeline parallelism, context length, temperature, and other runtime flags from the Model Library
262263
- Serves an OpenAI-compatible endpoint at `http://localhost:2001/v1/chat/completions`
263-
- CLI is preinstalled in the official Docker image; install it manually if running outside the container
264+
- Install LMDeploy on demand from the LMDeploy page (or manually via `pip`) before starting safetensors runtimes
264265
- Start/stop directly from the Safetensors panel; status is reported in System Status and the LMDeploy status chip
265266

266267
## Build Customization
@@ -282,7 +283,6 @@ Customize your build with:
282283
- **Custom CMake Flags**: Additional CMake configuration
283284
- **Compiler Flags**: CFLAGS and CXXFLAGS for optimization
284285
- **Git Patches**: Apply patches from GitHub PRs
285-
- **LMDeploy Version**: Override the `LMDEPLOY_VERSION` build arg to pin a specific CLI release inside the Docker image
286286

287287
### Example Build Configuration
288288

@@ -350,6 +350,12 @@ The system automatically detects NVLink topology and applies appropriate strateg
350350
- `POST /api/models/safetensors/{model_id}/lmdeploy/stop` - Stop LMDeploy runtime
351351
- `GET /api/models/safetensors/lmdeploy/status` - LMDeploy manager status
352352

353+
### LMDeploy Installer
354+
- `GET /api/lmdeploy/status` - Installer status (version, binary path, current operation)
355+
- `POST /api/lmdeploy/install` - Install LMDeploy via pip at runtime
356+
- `POST /api/lmdeploy/remove` - Remove LMDeploy from the runtime environment
357+
- `GET /api/lmdeploy/logs` - Tail the LMDeploy installer log
358+
353359
### llama.cpp Versions
354360
- `GET /api/llama-versions` - List installed versions
355361
- `GET /api/llama-versions/check-updates` - Check for updates

backend/main.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from contextlib import asynccontextmanager
1111

1212
from backend.database import init_db, LlamaVersion
13-
from backend.routes import models, llama_versions, status, gpu_info, llama_version_manager
13+
from backend.routes import models, llama_versions, status, gpu_info, llama_version_manager, lmdeploy
1414
from backend.websocket_manager import websocket_manager
1515
from backend.huggingface import set_huggingface_token
1616
from backend.unified_monitor import unified_monitor
@@ -24,7 +24,7 @@
2424
def ensure_data_directories():
2525
"""Ensure data directories exist and are writable"""
2626
data_dir = "/app/data"
27-
subdirs = ["models", "configs", "logs", "llama-cpp"]
27+
subdirs = ["models", "configs", "logs", "llama-cpp", "lmdeploy"]
2828

2929
try:
3030
# Ensure main data directory exists
@@ -127,56 +127,56 @@ async def lifespan(app: FastAPI):
127127
global llama_swap_manager
128128

129129
# Startup
130-
# Ensure data directories exist and are writable
131130
ensure_data_directories()
132-
133131
await init_db()
134-
135-
# Initialize configuration manager and update llama-swap config
136-
137-
# Initialize Hugging Face API key from environment variable if available
132+
138133
huggingface_api_key = os.getenv('HUGGINGFACE_API_KEY')
139134
if huggingface_api_key:
140135
set_huggingface_token(huggingface_api_key)
141136
logger.info("HuggingFace API key loaded from environment variable")
142-
143-
# Initialize and start llama-swap
137+
144138
from backend.llama_swap_manager import get_llama_swap_manager
145139
llama_swap_manager = get_llama_swap_manager()
146-
147-
try:
148-
await llama_swap_manager.start_proxy()
149-
logger.info("llama-swap proxy started on port 2000")
150-
except Exception as e:
151-
logger.error(f"Failed to start llama-swap: {e}")
152-
logger.warning("Multi-model serving unavailable")
153-
154-
# Clean stale database state (since llama-swap was not running)
155-
from backend.database import SessionLocal, RunningInstance, Model
140+
141+
from backend.database import SessionLocal, LlamaVersion, RunningInstance, Model
142+
session = SessionLocal()
143+
active_version = session.query(LlamaVersion).filter(LlamaVersion.is_active == True).first()
144+
session.close()
145+
146+
if active_version and active_version.binary_path:
147+
try:
148+
await llama_swap_manager.start_proxy()
149+
logger.info("llama-swap proxy started on port 2000")
150+
except Exception as e:
151+
logger.error(f"Failed to start llama-swap: {e}")
152+
logger.warning("Multi-model serving unavailable")
153+
else:
154+
logger.warning(
155+
"Skipping llama-swap start: no active llama.cpp version found. "
156+
"Install or activate a llama.cpp build to enable multi-model serving."
157+
)
158+
156159
db = SessionLocal()
157160
try:
158161
stale_instances = db.query(RunningInstance).all()
159162
if stale_instances:
160163
logger.info(f"Cleaning {len(stale_instances)} stale instances")
161164
for instance in stale_instances:
162-
# Update model status
163165
model = db.query(Model).filter(Model.id == instance.model_id).first()
164166
if model:
165167
model.is_active = False
166168
db.delete(instance)
167169
db.commit()
168170
finally:
169171
db.close()
170-
171-
# Register all downloaded models with llama-swap
172+
172173
try:
173174
await register_all_models_with_llama_swap()
174175
except Exception as e:
175176
logger.error(f"Failed to register models with llama-swap: {e}")
176-
177-
# Start unified monitoring
177+
178178
await unified_monitor.start_monitoring()
179-
179+
180180
yield
181181

182182
# Shutdown
@@ -226,6 +226,7 @@ async def lifespan(app: FastAPI):
226226
app.include_router(llama_version_manager.router, prefix="/api", tags=["llama-version-manager"])
227227
app.include_router(status.router, prefix="/api", tags=["status"])
228228
app.include_router(gpu_info.router, prefix="/api", tags=["gpu"])
229+
app.include_router(lmdeploy.router, prefix="/api", tags=["lmdeploy"])
229230

230231
# Include monitoring routes
231232
from backend.routes import unified_monitoring

backend/routes/lmdeploy.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from typing import Dict, Optional
2+
3+
from fastapi import APIRouter, HTTPException
4+
5+
from backend.lmdeploy_installer import get_lmdeploy_installer
6+
7+
router = APIRouter()
8+
9+
10+
@router.get("/lmdeploy/status")
11+
async def lmdeploy_installer_status() -> Dict:
12+
installer = get_lmdeploy_installer()
13+
return installer.status()
14+
15+
16+
@router.post("/lmdeploy/install")
17+
async def lmdeploy_install(request: Optional[Dict[str, str]] = None) -> Dict:
18+
installer = get_lmdeploy_installer()
19+
payload = request or {}
20+
version = payload.get("version")
21+
force_reinstall = bool(payload.get("force_reinstall"))
22+
try:
23+
return await installer.install(version=version, force_reinstall=force_reinstall)
24+
except RuntimeError as exc:
25+
raise HTTPException(status_code=409, detail=str(exc))
26+
27+
28+
@router.post("/lmdeploy/remove")
29+
async def lmdeploy_remove() -> Dict:
30+
installer = get_lmdeploy_installer()
31+
try:
32+
return await installer.remove()
33+
except RuntimeError as exc:
34+
raise HTTPException(status_code=409, detail=str(exc))
35+
36+
37+
@router.get("/lmdeploy/logs")
38+
async def lmdeploy_logs(max_bytes: int = 8192) -> Dict[str, str]:
39+
installer = get_lmdeploy_installer()
40+
max_bytes = max(1024, min(max_bytes, 1024 * 1024))
41+
return {"log": installer.read_log_tail(max_bytes)}
42+

backend/routes/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from backend.llama_swap_config import get_supported_flags
3737
from backend.logging_config import get_logger
3838
from backend.lmdeploy_manager import get_lmdeploy_manager
39+
from backend.lmdeploy_installer import get_lmdeploy_installer
3940
import psutil
4041

4142
router = APIRouter()
@@ -577,12 +578,14 @@ async def get_lmdeploy_config_endpoint(model_id: int, db: Session = Depends(get_
577578
max_context = manifest_entry.get("max_context_length") or metadata.get("max_context_length")
578579
config = (manifest_entry.get("lmdeploy") or {}).get("config") or get_default_lmdeploy_config(max_context)
579580
manager_status = get_lmdeploy_manager().status()
581+
installer_status = get_lmdeploy_installer().status()
580582
return {
581583
"config": config,
582584
"metadata": metadata,
583585
"tensor_summary": tensor_summary,
584586
"max_context_length": max_context,
585587
"manager": manager_status,
588+
"installer": installer_status,
586589
}
587590

588591

@@ -610,7 +613,21 @@ async def update_lmdeploy_config_endpoint(
610613
@router.get("/safetensors/lmdeploy/status")
611614
async def get_lmdeploy_status(db: Session = Depends(get_db)):
612615
"""Return LMDeploy runtime status and running instance info."""
616+
installer = get_lmdeploy_installer()
617+
installer_status = installer.status()
618+
if not installer_status.get("installed"):
619+
raise HTTPException(
620+
status_code=400,
621+
detail="LMDeploy is not installed. Install it from the LMDeploy page before starting a runtime.",
622+
)
623+
if installer_status.get("operation"):
624+
raise HTTPException(
625+
status_code=409,
626+
detail="An LMDeploy install/remove operation is still running. Try again once it finishes.",
627+
)
628+
613629
manager = get_lmdeploy_manager()
630+
installer = get_lmdeploy_installer()
614631
running_instance = db.query(RunningInstance).filter(RunningInstance.runtime_type == "lmdeploy").first()
615632
instance_payload = None
616633
if running_instance:
@@ -621,6 +638,7 @@ async def get_lmdeploy_status(db: Session = Depends(get_db)):
621638
}
622639
return {
623640
"manager": manager.status(),
641+
"installer": installer.status(),
624642
"running_instance": instance_payload
625643
}
626644

backend/routes/status.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from backend.database import get_db, RunningInstance
77
from backend.lmdeploy_manager import get_lmdeploy_manager
8+
from backend.lmdeploy_installer import get_lmdeploy_installer
89

910
router = APIRouter()
1011

@@ -42,6 +43,7 @@ async def get_system_status(db: Session = Depends(get_db)):
4243

4344
lmdeploy_manager = get_lmdeploy_manager()
4445
lmdeploy_status = lmdeploy_manager.status()
46+
installer_status = get_lmdeploy_installer().status()
4547

4648
return {
4749
"system": {
@@ -69,6 +71,7 @@ async def get_system_status(db: Session = Depends(get_db)):
6971
"port": 2001,
7072
"endpoint": "http://localhost:2001/v1/chat/completions",
7173
"running": lmdeploy_status.get("running"),
72-
"current_instance": lmdeploy_status.get("current_instance")
74+
"current_instance": lmdeploy_status.get("current_instance"),
75+
"installer": installer_status,
7376
}
7477
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from backend.lmdeploy_installer import LMDeployInstaller
6+
7+
8+
@pytest.mark.asyncio
9+
async def test_install_prevents_parallel_operations(tmp_path: Path, monkeypatch):
10+
installer = LMDeployInstaller(
11+
log_path=str(tmp_path / "lmdeploy.log"),
12+
state_path=str(tmp_path / "lmdeploy_state.json"),
13+
base_dir=str(tmp_path / "lmdeploy"),
14+
)
15+
16+
# Prevent the background task from executing pip in tests
17+
def prevent_task(coro):
18+
coro.close()
19+
20+
monkeypatch.setattr(installer, "_create_task", prevent_task)
21+
22+
result = await installer.install()
23+
assert result["message"].startswith("LMDeploy installation started")
24+
25+
with pytest.raises(RuntimeError):
26+
await installer.install()
27+
28+
29+
def test_status_reflects_detection(tmp_path: Path, monkeypatch):
30+
installer = LMDeployInstaller(
31+
log_path=str(tmp_path / "lmdeploy.log"),
32+
state_path=str(tmp_path / "lmdeploy_state.json"),
33+
base_dir=str(tmp_path / "lmdeploy"),
34+
)
35+
36+
monkeypatch.setattr(installer, "_detect_installed_version", lambda: "0.10.0")
37+
monkeypatch.setattr(installer, "_resolve_binary_path", lambda: "/opt/lmdeploy/bin/lmdeploy")
38+
39+
status = installer.status()
40+
assert status["installed"] is True
41+
assert status["version"] == "0.10.0"
42+
assert status["binary_path"].endswith("lmdeploy")
43+
assert status["venv_path"] == installer._venv_path
44+

0 commit comments

Comments
 (0)