Update configuration and documentation for LLM support and local watchdog

- Added `ollama-models.yaml` to .gitignore and implemented logic to copy it in gpu-rent.ps1 and gpu-rent.sh.
- Enhanced env.example to include new variables for LLM runtime options and local watchdog configuration.
- Updated CLI commands to support LLM options during setup and execution, including new flags for Ollama and llama.cpp.
- Improved documentation in cli.md and README.md to reflect changes in LLM integration and local watchdog functionality.
- Adjusted architecture and decisions documentation to clarify the role of LLMs and local watchdog in the system.
This commit is contained in:
Leonid Pershin
2026-08-21 05:29:23 +03:00
parent a9cf2e0f90
commit 2005b00175
43 changed files with 2258 additions and 197 deletions
+223 -10
View File
@@ -33,22 +33,26 @@ if sys.platform == "win32":
pass
app = typer.Typer(
no_args_is_help=True,
invoke_without_command=True,
pretty_exceptions_enable=False,
add_completion=False,
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent doctor. Ключи: docs/setup.md",
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent setup / doctor. Ключи: docs/setup.md",
)
console = Console(highlight=False, legacy_windows=False)
_DEBUG = False
@app.callback()
@app.callback(invoke_without_command=True)
def _root(
ctx: typer.Context,
debug: bool = typer.Option(False, "--debug", help="Показать traceback"),
) -> None:
global _DEBUG
_DEBUG = debug
if ctx.invoked_subcommand is None:
# Без подкоманды → interactive up (как «запуск без параметров»).
ctx.invoke(up)
def _die(exc: BaseException) -> None:
@@ -249,6 +253,20 @@ def status() -> None:
table.add_row("диск used/free", "нужен живой FIP + SSH-ключ")
table.add_row("idle-killer", "нужен SSH на живую VM")
from gpu_rent.local_watchdog import watchdog_status_lines
table.add_row("local-watchdog", "; ".join(watchdog_status_lines()))
from gpu_rent.llm_runtime import normalize_runtime
rt = normalize_runtime(cfg.llm_runtime)
noted = (state.notes or {}).get("llm_runtime")
if noted:
rt = f"{rt} (сессия: {noted})"
table.add_row(
"LLM",
f"{rt}; ollama :{cfg.ollama_local_port} / llamacpp :{cfg.llamacpp_local_port}",
)
if cfg.auth_ok:
try:
conn = connect(cfg)
@@ -271,10 +289,30 @@ def status() -> None:
@app.command()
def open() -> None:
"""Открыть браузер на http://127.0.0.1:17801. Туннель уже должен слушать порт."""
def open(
llm: bool = typer.Option(False, "--llm", help="Открыть LLM API URL вместо SwarmUI"),
) -> None:
"""Открыть браузер на SwarmUI :17801 (или --llm на Ollama/llama.cpp)."""
cfg = load_config(require_auth=False)
port = cfg.swarmui_local_port
if llm:
from gpu_rent.llm_runtime import normalize_runtime
runtime = normalize_runtime(cfg.llm_runtime)
state = load_state()
if (state.notes or {}).get("llm_runtime"):
try:
runtime = normalize_runtime(str(state.notes["llm_runtime"]))
except ValueError:
pass
if runtime == "ollama":
port = cfg.ollama_local_port
elif runtime == "llamacpp":
port = cfg.llamacpp_local_port
else:
console.print("[red]LLM не выбран[/red] (LLM_RUNTIME / gpu-rent setup)")
raise typer.Exit(1)
else:
port = cfg.swarmui_local_port
if not _port_open(port):
console.print(
f"[red]localhost:{port} молчит.[/red] Сначала `gpu-rent tunnel`, потом open."
@@ -285,6 +323,43 @@ def open() -> None:
console.print(url)
@app.command()
def setup(
llm: Optional[str] = typer.Option(None, "--llm", help="none|ollama|llamacpp"),
ollama_preset: Optional[str] = typer.Option(
None, "--ollama-preset", help="recommended|light|stock|alt|empty"
),
watchdog: Optional[bool] = typer.Option(
None, "--watchdog/--no-watchdog", help="Поставить local-watchdog"
),
yes: bool = typer.Option(False, "--yes", help="Без вопросов (дефолты)"),
) -> None:
"""Интерактивная установка: файлы конфига, LLM, опционально watchdog."""
try:
from gpu_rent.setup_wizard import run_setup
def confirm(msg: str) -> bool:
if yes:
return False if watchdog is False else bool(watchdog)
return typer.confirm(msg)
def ask(msg: str, default: str) -> str:
if yes:
return default
return typer.prompt(msg, default=default)
run_setup(
llm=llm if llm is not None else ("none" if yes else None),
ollama_preset=ollama_preset if ollama_preset is not None else ("recommended" if yes else None),
install_watchdog=watchdog if watchdog is not None else (False if yes else None),
confirm=None if yes and watchdog is None else confirm,
ask=None if yes and llm is not None else ask,
log=lambda m: console.print(m),
)
except Exception as exc:
_die(exc)
@app.command()
def up(
no_spot: bool = typer.Option(False, "--no-spot", help="Обычный сервер, не preemptible"),
@@ -306,15 +381,67 @@ def up(
"--no-update",
help="Не делать git pull SwarmUI и установленных extensions",
),
llm: Optional[str] = typer.Option(
None, "--llm", help="none|ollama|llamacpp (override LLM_RUNTIME)"
),
ollama: bool = typer.Option(False, "--ollama", help="То же что --llm ollama"),
llamacpp: bool = typer.Option(False, "--llamacpp", help="То же что --llm llamacpp"),
) -> None:
"""Create/unshelve GPU, bootstrap SwarmUI, по умолчанию туннель на :17801."""
try:
from dataclasses import replace
from gpu_rent.llm_runtime import (
PRESET_HELP,
append_vars_llm_runtime,
decide_runtime,
ensure_ollama_manifest_from_example,
write_ollama_models_preset,
)
from gpu_rent.paths import vars_path
checks = run_doctor()
code = _print_checks(checks)
if code != 0:
raise typer.Exit(1)
cfg = load_config(require_auth=True)
try:
runtime = decide_runtime(
flag=llm,
ollama_flag=ollama,
llamacpp_flag=llamacpp,
from_config=cfg.llm_runtime,
)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
if not yes and runtime == "none" and not llm and not ollama and not llamacpp:
choice = typer.prompt(
"Поднять LLM рядом со SwarmUI? [none/ollama/llamacpp]",
default="none",
)
try:
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 runtime == "ollama":
ensure_ollama_manifest_from_example()
console.print(PRESET_HELP)
preset = typer.prompt("Ollama preset", default="recommended")
if preset.strip().lower() not in {"keep", "example"}:
write_ollama_models_preset(
cfg.ollama_models_manifest, preset.strip().lower()
)
cfg = replace(cfg, llm_runtime=runtime)
if runtime != "none":
console.print(f"LLM runtime: {runtime}")
def confirm(msg: str) -> bool:
return typer.confirm(msg)
@@ -368,6 +495,7 @@ def stop(
@app.command()
def destroy(
i_understand_data_loss: bool = typer.Option(False, "--i-understand-data-loss"),
no_pull: bool = typer.Option(False, "--no-pull"),
) -> None:
"""stop + диски."""
if not i_understand_data_loss:
@@ -375,7 +503,7 @@ def destroy(
raise typer.Exit(1)
try:
cfg = load_config(require_auth=True)
cmd_stop(cfg, destroy_disks=True, log=lambda m: console.print(m))
cmd_stop(cfg, destroy_disks=True, no_pull=no_pull, log=lambda m: console.print(m))
except GpuRentError as exc:
_die(exc)
@@ -395,7 +523,7 @@ def ssh() -> None:
@app.command()
def logs() -> None:
"""cloud-init / journalctl на VM."""
"""cloud-init / journalctl -u swarmui на VM."""
try:
cfg = load_config(require_auth=True)
state = load_state()
@@ -404,9 +532,14 @@ def logs() -> None:
out = run_ssh(
cfg,
state.floating_ip,
"sudo -n tail -n 80 /var/log/cloud-init-output.log 2>/dev/null; "
"systemctl is-active swarmui 2>/dev/null || true",
"echo '=== cloud-init (tail) ==='; "
"sudo -n tail -n 60 /var/log/cloud-init-output.log 2>/dev/null || true; "
"echo; echo '=== systemctl swarmui ==='; "
"systemctl is-active swarmui 2>/dev/null || true; "
"echo; echo '=== journalctl -u swarmui ==='; "
"sudo -n journalctl -u swarmui -n 80 --no-pager 2>/dev/null || true",
check=False,
timeout=60,
)
console.print(out)
except GpuRentError as exc:
@@ -538,6 +671,86 @@ def resize_data(gb: int = typer.Option(..., "--gb", help="Новый разме
_die(exc)
watchdog_app = typer.Typer(
help=(
"Локальный сервис: если туннель умер без Ctrl+C / stop — "
"через grace удалить compute. Не путать с idle-killer на VM."
),
no_args_is_help=True,
)
app.add_typer(watchdog_app, name="watchdog")
def _apply_project_root(project: Optional[str]) -> None:
if not project:
return
import os
from pathlib import Path
root = Path(project).expanduser().resolve()
os.environ["GPU_RENT_ROOT"] = str(root)
try:
os.chdir(root)
except OSError:
pass
@watchdog_app.command("install")
def watchdog_install(
interval: int = typer.Option(5, "--interval", help="Минуты между тиками"),
) -> None:
"""Поставить Task Scheduler / systemd user / launchd."""
try:
from gpu_rent.local_watchdog import install_watchdog
install_watchdog(interval_minutes=interval, log=lambda m: console.print(m))
except Exception as exc:
console.print(f"[red]install fail:[/red] {exc}")
raise typer.Exit(1) from exc
@watchdog_app.command("uninstall")
def watchdog_uninstall() -> None:
"""Снять локальный watchdog."""
try:
from gpu_rent.local_watchdog import uninstall_watchdog
uninstall_watchdog(log=lambda m: console.print(m))
except Exception as exc:
console.print(f"[red]uninstall fail:[/red] {exc}")
raise typer.Exit(1) from exc
@watchdog_app.command("status")
def watchdog_status_cmd() -> None:
"""Состояние установки и local lease."""
from gpu_rent.local_watchdog import watchdog_status_lines
for line in watchdog_status_lines():
console.print(line)
@watchdog_app.command("tick")
def watchdog_tick(
project: Optional[str] = typer.Option(
None,
"--project",
help="Корень репо (для планировщика; выставляет GPU_RENT_ROOT)",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Только решение, без stop"),
) -> None:
"""Один тик (вызывает планировщик)."""
_apply_project_root(project)
try:
from gpu_rent.local_watchdog import run_tick
decision = run_tick(dry_run=dry_run, log=lambda m: console.print(m))
if decision.kind == "stop" and not dry_run:
raise typer.Exit(0)
except GpuRentError as exc:
_die(exc)
def _port_open(port: int) -> bool:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.4)