Enhance LLM and SwarmUI integration with improved configuration options

- Updated `env.example` and `gpu-rent.vars.example` to include new variables for LLM runtime and SwarmUI options.
- Refactored CLI commands to support interactive selection of LLM runtime and workload type (SwarmUI, LLM, or both).
- Improved access link generation to handle cases where SwarmUI is disabled, providing clearer user feedback.
- Enhanced provisioning logic to conditionally bootstrap SwarmUI based on user configuration, allowing for LLM-only setups.
- Updated documentation across multiple files to reflect changes in LLM integration, CLI usage, and configuration management.
This commit is contained in:
Leonid Pershin
2026-08-21 06:44:50 +03:00
parent f93ac5a66a
commit 7ed6a99df2
25 changed files with 455 additions and 177 deletions
+21 -9
View File
@@ -40,17 +40,19 @@ def resolve_llm_runtime(cfg: Config) -> str:
def collect_access_links(cfg: Config, *, tunneled: bool) -> list[AccessLink]:
"""Build the list of user-facing endpoints (unit-tested)."""
port = cfg.swarmui_local_port
base = f"http://127.0.0.1:{port}"
links: list[AccessLink] = []
swarm = bool(getattr(cfg, "enable_swarmui", True))
if tunneled:
links.extend(
[
AccessLink("SwarmUI UI", base, "браузер"),
AccessLink("SwarmUI API", f"{base}/API/", "HTTP JSON"),
AccessLink("SwarmUI MCP", f"{base}/mcp", "Cursor mcp.json"),
]
)
if swarm:
port = cfg.swarmui_local_port
base = f"http://127.0.0.1:{port}"
links.extend(
[
AccessLink("SwarmUI UI", base, "браузер"),
AccessLink("SwarmUI API", f"{base}/API/", "HTTP JSON"),
AccessLink("SwarmUI MCP", f"{base}/mcp", "Cursor mcp.json"),
]
)
runtime = resolve_llm_runtime(cfg)
if runtime == "ollama":
o = cfg.ollama_local_port
@@ -82,6 +84,14 @@ def collect_access_links(cfg: Config, *, tunneled: bool) -> list[AccessLink]:
AccessLink("Models", f"http://127.0.0.1:{p}/v1/models", "list"),
]
)
if not links:
links.append(
AccessLink(
"Туннель",
"gpu-rent tunnel",
"нет сервисов — ENABLE_SWARMUI / LLM_RUNTIME",
)
)
else:
links.append(
AccessLink(
@@ -94,6 +104,8 @@ def collect_access_links(cfg: Config, *, tunneled: bool) -> list[AccessLink]:
def mcp_snippet_lines(cfg: Config) -> list[str]:
if not bool(getattr(cfg, "enable_swarmui", True)):
return ["# SwarmUI MCP skip (llm-only)"]
port = cfg.swarmui_local_port
return [
"{",
+14 -6
View File
@@ -24,14 +24,18 @@ def run_bootstrap(
update: bool = True,
light: bool = False,
) -> None:
if light:
skip_swarm = not bool(getattr(cfg, "enable_swarmui", True))
if skip_swarm:
log("bootstrap llm-only (data disk, без SwarmUI)")
elif light:
log("bootstrap SwarmUI (light: без apt)")
else:
log("bootstrap SwarmUI на VM (идемпотентно, без Docker)")
if update:
log("git update: SwarmUI on")
else:
log("git update: SwarmUI off (--no-update)")
if not skip_swarm:
if update:
log("git update: SwarmUI on")
else:
log("git update: SwarmUI off (--no-update)")
script = bootstrap_script()
out = run_script_sudo(
cfg,
@@ -43,9 +47,13 @@ def run_bootstrap(
"SWARM_USER": cfg.ssh_user,
"GPU_RENT_UPDATE_GIT": "1" if update else "0",
"GPU_RENT_BOOTSTRAP_LIGHT": "1" if light else "0",
"GPU_RENT_SKIP_SWARMUI": "1" if skip_swarm else "0",
},
log=log,
)
if "bootstrap ok" not in out:
raise CloudError(f"bootstrap не подтвердил успех:\n{out[-800:]}")
log("диски и systemd unit готовы; дальше seed, потом start swarmui")
if skip_swarm:
log("data disk готов — дальше LLM")
else:
log("диски и systemd unit готовы; дальше seed, потом start swarmui")
+95 -21
View File
@@ -303,10 +303,10 @@ def status() -> None:
def open(
llm: bool = typer.Option(False, "--llm", help="Открыть LLM API URL вместо SwarmUI"),
) -> None:
"""Открыть браузер на SwarmUI :17801 (или --llm на Ollama/llama.cpp)."""
"""Открыть браузер на SwarmUI :17801 (или --llm / llm-only на Ollama/llama.cpp)."""
cfg = load_config(require_auth=False)
if llm:
from gpu_rent.llm_runtime import normalize_runtime
use_llm = llm or not bool(getattr(cfg, "enable_swarmui", True))
if use_llm:
from gpu_rent.access_card import resolve_llm_runtime
runtime = resolve_llm_runtime(cfg)
@@ -398,18 +398,25 @@ def up(
),
ollama: bool = typer.Option(False, "--ollama", help="То же что --llm ollama"),
llamacpp: bool = typer.Option(False, "--llamacpp", help="То же что --llm llamacpp"),
no_swarm: bool = typer.Option(
False,
"--no-swarm",
"--llm-only",
help="Только LLM на GPU, без установки SwarmUI",
),
) -> None:
"""Create/unshelve GPU, bootstrap SwarmUI, по умолчанию туннель на :17801."""
"""Create/unshelve GPU; SwarmUI и/или LLM; по умолчанию туннель."""
try:
from dataclasses import replace
from gpu_rent.llm_runtime import (
append_vars_llm_runtime,
decide_runtime,
ensure_ollama_manifest_from_example,
write_ollama_models_preset,
)
from gpu_rent.paths import vars_path
from gpu_rent.prompts import MenuItem, prompt_menu
from gpu_rent.varsfile import upsert_vars
checks = run_doctor()
code = _print_checks(checks, quiet=not verbose)
@@ -427,33 +434,93 @@ def up(
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
enable_swarm = False if no_swarm else cfg.enable_swarmui
asked_model_preset = False
if not yes and runtime == "none" and not llm and not ollama and not llamacpp:
llm_flags = bool(llm or ollama or llamacpp or no_swarm)
if not yes and not llm_flags:
from gpu_rent.llm_runtime import (
llamacpp_preset_menu,
llm_runtime_menu,
ollama_preset_menu,
workload_menu,
)
from gpu_rent.prompts import prompt_menu
def _ask(msg: str, default: str = "") -> str:
return typer.prompt(msg, default=default)
default_stack = (
"llm"
if not cfg.enable_swarmui
else ("both" if runtime != "none" else "swarm")
)
try:
choice = prompt_menu(
"LLM рядом со SwarmUI",
llm_runtime_menu(),
default="none",
stack = prompt_menu(
"Что поднять на GPU",
workload_menu(),
default=default_stack,
ask=_ask,
show=log,
)
runtime = decide_runtime(
flag=choice, ollama_flag=False, llamacpp_flag=False, from_config="none"
)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
if runtime != "none" and typer.confirm("Запомнить LLM_RUNTIME в gpu-rent.vars?", default=True):
append_vars_llm_runtime(vars_path(), runtime)
if stack == "swarm":
enable_swarm = True
runtime = "none"
elif stack == "both":
enable_swarm = True
if runtime == "none":
try:
choice = prompt_menu(
"LLM runtime",
[
MenuItem("ollama", "Ollama (+ pull моделей)"),
MenuItem("llamacpp", "llama.cpp server (+ GGUF)"),
],
default="ollama",
ask=_ask,
show=log,
)
runtime = decide_runtime(
flag=choice,
ollama_flag=False,
llamacpp_flag=False,
from_config="none",
)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
else:
enable_swarm = False
if runtime == "none":
try:
choice = prompt_menu(
"LLM runtime",
[
MenuItem("ollama", "Ollama (+ pull моделей)"),
MenuItem("llamacpp", "llama.cpp server (+ GGUF)"),
],
default="llamacpp",
ask=_ask,
show=log,
)
runtime = decide_runtime(
flag=choice,
ollama_flag=False,
llamacpp_flag=False,
from_config="none",
)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
if typer.confirm("Запомнить стек в gpu-rent.vars?", default=True):
upsert_vars(
vars_path(),
{
"ENABLE_SWARMUI": "true" if enable_swarm else "false",
"LLM_RUNTIME": runtime,
},
)
if runtime == "ollama":
ensure_ollama_manifest_from_example()
try:
@@ -498,7 +565,6 @@ def up(
ollama_preset_menu,
write_llamacpp_models_preset,
)
from gpu_rent.prompts import prompt_menu
def _ask2(msg: str, default: str = "") -> str:
return typer.prompt(msg, default=default)
@@ -529,9 +595,17 @@ def up(
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
cfg = replace(cfg, llm_runtime=runtime)
if runtime != "none":
ok(f"LLM runtime: {runtime}")
if not enable_swarm and runtime == "none":
raise GpuRentError(
"llm-only требует --ollama / --llamacpp / --llm … "
"(или убери --no-swarm / ENABLE_SWARMUI=true)"
)
cfg = replace(cfg, llm_runtime=runtime, enable_swarmui=enable_swarm)
if enable_swarm:
ok("стек: SwarmUI" + (f" + {runtime}" if runtime != "none" else ""))
else:
ok(f"стек: llm-only ({runtime})")
def confirm(msg: str) -> bool:
return typer.confirm(msg)
+18
View File
@@ -31,6 +31,20 @@ def _as_bool(value: str | None, default: bool) -> bool:
return value.strip().lower() in {"1", "true", "yes", "on"}
def parse_enable_swarmui(raw: str | None, *, default: bool = True) -> bool:
"""ENABLE_SWARMUI / WORKLOAD=llm|swarm|both → whether to install SwarmUI."""
wl = (os.environ.get("WORKLOAD") or "").strip().lower()
if wl in {"llm", "llm-only", "llm_only", "llama", "ollama-only"}:
return False
if wl in {"swarm", "swarmui", "ui"}:
return True
if wl in {"both", "all", "full"}:
return True
if raw is None or str(raw).strip() == "":
return default
return _as_bool(str(raw), default)
def _as_int(value: str | None, default: int) -> int:
if value is None or value.strip() == "":
return default
@@ -84,6 +98,7 @@ class Config:
update_git: bool
llm_runtime: str
enable_swarmui: bool
ollama_models_manifest: Path
llamacpp_models_manifest: Path
ollama_local_port: int
@@ -169,6 +184,8 @@ def load_config(*, require_auth: bool = True) -> Config:
except ValueError:
llm_runtime = "none"
enable_swarmui = parse_enable_swarmui(os.environ.get("ENABLE_SWARMUI"), default=True)
def _dir(env_name: str, folder: str) -> Path:
raw = (os.environ.get(env_name) or "").strip()
return Path(raw).expanduser() if raw else (root / folder)
@@ -210,6 +227,7 @@ def load_config(*, require_auth: bool = True) -> Config:
swarmui_image=(os.environ.get("SWARMUI_IMAGE") or "").strip(),
update_git=_as_bool(os.environ.get("UPDATE_GIT"), True),
llm_runtime=llm_runtime,
enable_swarmui=enable_swarmui,
ollama_models_manifest=ollama_manifest,
llamacpp_models_manifest=llamacpp_manifest,
ollama_local_port=_as_int(os.environ.get("OLLAMA_LOCAL_PORT"), 17811),
+1 -1
View File
@@ -106,7 +106,7 @@ def install_units(cfg: Config, host: str, log: Log) -> None:
)
service = f"""[Unit]
Description=gpu-rent idle-killer (one shot)
After=network-online.target swarmui.service
After=network-online.target
[Service]
Type=oneshot
+12
View File
@@ -71,6 +71,12 @@ LLM_RUNTIME_LABELS: dict[str, str] = {
"llamacpp": "llama.cpp server (+ GGUF)",
}
WORKLOAD_LABELS: dict[str, str] = {
"swarm": "только SwarmUI",
"both": "SwarmUI + LLM",
"llm": "только LLM (без SwarmUI)",
}
def llm_runtime_menu() -> list:
from gpu_rent.prompts import MenuItem
@@ -78,6 +84,12 @@ def llm_runtime_menu() -> list:
return [MenuItem(k, f"{k}{LLM_RUNTIME_LABELS[k]}") for k in ("none", "ollama", "llamacpp")]
def workload_menu() -> list:
from gpu_rent.prompts import MenuItem
return [MenuItem(k, WORKLOAD_LABELS[k]) for k in ("swarm", "both", "llm")]
def ollama_preset_menu(*, include_keep: bool = False) -> list:
from gpu_rent.prompts import MenuItem
+57 -25
View File
@@ -440,33 +440,68 @@ def provision_vm(
server_id: str | None = None,
update: bool = True,
) -> None:
from gpu_rent.llm_runtime import normalize_runtime
from gpu_rent.state import load_state, save_state
swarm = bool(getattr(cfg, "enable_swarmui", True))
rt = normalize_runtime(cfg.llm_runtime)
if not swarm and rt == "none":
raise CloudError(
"llm-only: нужен LLM_RUNTIME=ollama|llamacpp (или --ollama / --llamacpp)"
)
restart = bool(update)
try:
if seed_extensions(cfg, host, log, update=update):
restart = True
except GpuRentError as exc:
log(f"extensions: {exc}")
raise
try:
if seed_autocomplete(cfg, host, log):
restart = True
except GpuRentError as exc:
log(f"autocomplete: {exc}")
seed_civitai(cfg, host, log)
push_tree(cfg, host, cfg.local_models_dir, f"{DATA}/Models", log, models=True)
push_tree(cfg, host, cfg.local_wildcards_dir, f"{DATA}/Data/Wildcards", log, models=False)
push_tree(cfg, host, cfg.local_workflows_dir, f"{DATA}/CustomWorkflows", log, models=False)
if cfg.pull_output:
pull_tree(cfg, host, f"{DATA}/Output", cfg.local_output_dir, log)
if swarm:
try:
if seed_extensions(cfg, host, log, update=update):
restart = True
except GpuRentError as exc:
log(f"extensions: {exc}")
raise
try:
if seed_autocomplete(cfg, host, log):
restart = True
except GpuRentError as exc:
log(f"autocomplete: {exc}")
seed_civitai(cfg, host, log)
push_tree(cfg, host, cfg.local_models_dir, f"{DATA}/Models", log, models=True)
push_tree(
cfg, host, cfg.local_wildcards_dir, f"{DATA}/Data/Wildcards", log, models=False
)
push_tree(
cfg,
host,
cfg.local_workflows_dir,
f"{DATA}/CustomWorkflows",
log,
models=False,
)
if cfg.pull_output:
pull_tree(cfg, host, f"{DATA}/Output", cfg.local_output_dir, log)
else:
log("SwarmUI: skip (llm-only)")
run_ssh(
cfg,
host,
"sudo -n systemctl stop swarmui 2>/dev/null; "
"sudo -n systemctl disable swarmui 2>/dev/null || true; "
"echo llm-only | sudo -n tee /mnt/swarm_data/.gpu-rent-llm-only >/dev/null",
check=False,
)
try:
probe_gpu(cfg, host, log)
except Exception as exc:
log(f"GPU probe: {exc}")
ensure_swarmui_running(cfg, host, log, restart=restart)
from gpu_rent.state import load_state, save_state
if swarm:
ensure_swarmui_running(cfg, host, log, restart=restart)
run_ssh(
cfg,
host,
"sudo -n rm -f /mnt/swarm_data/.gpu-rent-llm-only",
check=False,
)
try:
provision_llm(cfg, host, log)
@@ -474,7 +509,6 @@ def provision_vm(
st = load_state()
st.notes = dict(st.notes or {})
st.notes["llm_error"] = str(exc)[:500]
# Do not claim success — leave previous notes.llm_runtime or clear to none.
st.notes["llm_runtime"] = "none"
save_state(st)
raise CloudError(f"LLM runtime: {exc}") from exc
@@ -498,10 +532,8 @@ def provision_vm(
"⚠ idle-killer НЕ вооружён — GPU может тарифицироваться без авто-stop. "
"См. status / docs/setup.md"
)
log("SwarmUI слушает 127.0.0.1:7801 — gpu-rent tunnel")
from gpu_rent.llm_runtime import normalize_runtime
rt = normalize_runtime(cfg.llm_runtime)
if swarm:
log("SwarmUI слушает 127.0.0.1:7801 — gpu-rent tunnel")
if rt == "ollama":
log(f"Ollama API → localhost:{cfg.ollama_local_port} (туннель)")
elif rt == "llamacpp":
+20 -1
View File
@@ -95,7 +95,26 @@ mkdir -p \
"${DATA_ROOT}/dlbackend" \
"${DATA_ROOT}/Extensions" \
"${DATA_ROOT}/DLNodes" \
"${DATA_ROOT}/CustomWorkflows"
"${DATA_ROOT}/CustomWorkflows" \
"${DATA_ROOT}/ollama" \
"${DATA_ROOT}/llamacpp/models"
# LLM-only: data disk + tools, no SwarmUI clone / unit.
if [[ "${GPU_RENT_SKIP_SWARMUI:-0}" == "1" ]]; then
chown -R "${SWARM_USER}:${SWARM_USER}" "$DATA_ROOT"
date -u +"%Y-%m-%dT%H:%M:%SZ" >"$MARKER_DATA"
date -u +"%Y-%m-%dT%H:%M:%SZ" >"${DATA_ROOT}/.gpu-rent-llm-only"
chown "${SWARM_USER}:${SWARM_USER}" "$MARKER_DATA" "${DATA_ROOT}/.gpu-rent-llm-only"
systemctl stop swarmui 2>/dev/null || true
systemctl disable swarmui 2>/dev/null || true
if command -v nvidia-smi >/dev/null 2>&1; then
nvidia-smi -L || true
fi
log "bootstrap ok (llm-only; без SwarmUI)"
exit 0
fi
rm -f "${DATA_ROOT}/.gpu-rent-llm-only" 2>/dev/null || true
if [[ ! -d "${SWARM_ROOT}/.git" ]]; then
log "clone SwarmUI -> ${SWARM_ROOT}"
+4 -1
View File
@@ -236,7 +236,10 @@ def main() -> int:
return 0
swarm_url = str(creds.get("swarm_url") or "http://127.0.0.1:7801")
busy, detail = swarm_busy(swarm_url)
if (DATA / ".gpu-rent-llm-only").is_file():
busy, detail = False, "llm-only (swarm skip)"
else:
busy, detail = swarm_busy(swarm_url)
if busy:
write_ts(IDLE_SINCE, None)
log(f"busy: {detail}")
+24 -21
View File
@@ -86,27 +86,26 @@ def _bind_access(
log(f"SSH {cfg.ssh_user}@{ip}")
state.phase = "bootstrapping"
save_state(state)
if update:
swarm = bool(getattr(cfg, "enable_swarmui", True))
if swarm and update:
active = run_ssh(cfg, ip, "systemctl is-active swarmui 2>/dev/null || true", check=False).strip()
if active == "active":
log("systemctl stop swarmui перед git update")
run_ssh(cfg, ip, "sudo -n systemctl stop swarmui", timeout=120, check=False)
# Skip apt-heavy bootstrap when the VM already finished first-boot.
marker = run_ssh(
cfg,
ip,
"test -f /opt/swarmui/.gpu-rent-bootstrapped && echo yes || echo no",
check=False,
).strip()
if swarm:
marker_cmd = "test -f /opt/swarmui/.gpu-rent-bootstrapped && echo yes || echo no"
else:
marker_cmd = "test -f /mnt/swarm_data/.gpu-rent-ready && echo yes || echo no"
marker = run_ssh(cfg, ip, marker_cmd, check=False).strip()
if marker == "yes":
# VM marker survives stop; local bootstrapped is cleared on stop — still light.
if not state.bootstrapped:
log("маркер bootstrap на VM — лёгкий проход (локальный bootstrapped был сброшен)")
else:
log("bootstrap уже на VM — лёгкий проход (без apt)")
run_bootstrap(cfg, ip, log, update=update, light=True)
run_bootstrap(cfg, ip, log, update=update and swarm, light=True)
else:
run_bootstrap(cfg, ip, log, update=update, light=False)
run_bootstrap(cfg, ip, log, update=update and swarm, light=False)
provision_vm(
cfg,
ip,
@@ -115,17 +114,19 @@ def _bind_access(
server_id=getattr(server, "id", None) or state.server_id,
update=update,
)
try:
wait_backend_idle(cfg, ip, log)
except CloudError as exc:
log(f"ready: {exc}")
# Comfy venv + Backends.fds exist after Idle — sage/triton + ExtraArgs.
try:
if tune_swarm_perf(cfg, ip, log):
log("systemctl restart swarmui (perf ExtraArgs)")
run_ssh(cfg, ip, "sudo -n systemctl restart swarmui", timeout=120)
except Exception as exc:
log(f"perf tune: {exc}")
if swarm:
try:
wait_backend_idle(cfg, ip, log)
except CloudError as exc:
log(f"ready: {exc}")
try:
if tune_swarm_perf(cfg, ip, log):
log("systemctl restart swarmui (perf ExtraArgs)")
run_ssh(cfg, ip, "sudo -n systemctl restart swarmui", timeout=120)
except Exception as exc:
log(f"perf tune: {exc}")
else:
log("ready: llm-only (без ожидания SwarmUI Idle)")
try:
ensure_boot_snapshot(
conn,
@@ -138,6 +139,8 @@ def _bind_access(
notify_ready(cfg, log)
state.bootstrapped = True
state.phase = "ready_cloud"
state.notes = dict(state.notes or {})
state.notes["enable_swarmui"] = swarm
save_state(state)
return state
+18 -7
View File
@@ -72,16 +72,18 @@ def decide_watch(status: str | None, tunnel_alive: bool) -> WatchDecision:
def tunnel_forwards(cfg: Config) -> list[tuple[int, int]]:
"""List of (local_port, remote_port). SwarmUI always; LLM if configured.
Prefer live config (`LLM_RUNTIME`) over stale state.notes.
"""
pairs = [(cfg.swarmui_local_port, 7801)]
"""List of (local_port, remote_port). SwarmUI if enabled; LLM if configured."""
pairs: list[tuple[int, int]] = []
if bool(getattr(cfg, "enable_swarmui", True)):
pairs.append((cfg.swarmui_local_port, 7801))
runtime = normalize_runtime(cfg.llm_runtime)
if runtime == "ollama":
pairs.append((cfg.ollama_local_port, 11434))
elif runtime == "llamacpp":
pairs.append((cfg.llamacpp_local_port, 8080))
if not pairs:
# Failsafe: at least SwarmUI port so tunnel isn't empty.
pairs.append((cfg.swarmui_local_port, 7801))
return pairs
@@ -181,14 +183,23 @@ def run_tunnel(
log("watchdog: EXPIRED → unshelve + reconnect")
server = _start_forwarder(cfg, current_host, forwards)
swarm_url = f"http://127.0.0.1:{cfg.swarmui_local_port}"
swarm_on = bool(getattr(cfg, "enable_swarmui", True))
runtime = normalize_runtime(cfg.llm_runtime)
if swarm_on:
open_url = f"http://127.0.0.1:{cfg.swarmui_local_port}"
elif runtime == "ollama":
open_url = f"http://127.0.0.1:{cfg.ollama_local_port}"
elif runtime == "llamacpp":
open_url = f"http://127.0.0.1:{cfg.llamacpp_local_port}"
else:
open_url = f"http://127.0.0.1:{cfg.swarmui_local_port}"
from gpu_rent.access_card import print_access_card
print_access_card(cfg, tunneled=True, host=current_host)
if open_browser:
webbrowser.open(swarm_url)
webbrowser.open(open_url)
state = load_state()
state.phase = "ready_tunneled"