Update LLM support for llama.cpp and enhance configuration management

- Added support for `llamacpp-models.yaml` in `.gitignore` and implemented logic to copy it in `gpu-rent.ps1` and `gpu-rent.sh`.
- Enhanced CLI to prompt for llama.cpp model presets during setup and execution, improving user experience.
- Updated configuration handling to include `llamacpp_models_manifest` and related functions for managing llama.cpp models.
- Improved documentation in `cli.md` and `llm.md` to reflect changes in llama.cpp integration and model management.
- Refactored provisioning logic to handle llama.cpp model downloads and configurations effectively.
This commit is contained in:
Leonid Pershin
2026-08-21 06:25:12 +03:00
parent 2ccb03f7d2
commit 64f93b4bf6
18 changed files with 607 additions and 32 deletions
+1
View File
@@ -4,6 +4,7 @@
!.env.example !.env.example
gpu-rent.vars gpu-rent.vars
ollama-models.yaml ollama-models.yaml
llamacpp-models.yaml
models.yaml models.yaml
extensions.yaml extensions.yaml
+1 -1
View File
@@ -55,7 +55,7 @@ gpu-rent up --yes --ollama
| `gpu-rent doctor` | Preflight **без** create. Exit ≠ 0 → сессию начинать нельзя | | `gpu-rent doctor` | Preflight **без** create. Exit ≠ 0 → сессию начинать нельзя |
| `gpu-rent flavors` | Скан `SCAN_POOLS` × `FLAVOR_PREFERENCE`, список в текущем регионе | | `gpu-rent flavors` | Скан `SCAN_POOLS` × `FLAVOR_PREFERENCE`, список в текущем регионе |
| `gpu-rent dry-run` | План без mutating-вызовов | | `gpu-rent dry-run` | План без mutating-вызовов |
| `gpu-rent up` / `up --yes` | Create/unshelve → bootstrap → optional LLM → **туннель** `:17801`. Ctrl+C = туннель off | | `gpu-rent up` / `up --yes` | Create/unshelve → bootstrap → optional LLM → **туннель** `:17801`. Без `--yes`: выбор flavor / data GB / preemptible, затем confirm. Ctrl+C = туннель off |
| `gpu-rent up -v` / `--verbose` | Полная таблица doctor на `up` (по умолчанию кратко) | | `gpu-rent up -v` / `--verbose` | Полная таблица doctor на `up` (по умолчанию кратко) |
| `gpu-rent up --ollama` / `--llamacpp` / `--llm …` | LLM рядом со SwarmUI | | `gpu-rent up --ollama` / `--llamacpp` / `--llm …` | LLM рядом со SwarmUI |
| `gpu-rent up --no-update` | Без `git pull` SwarmUI/extensions (только недостающие clone) | | `gpu-rent up --no-update` | Без `git pull` SwarmUI/extensions (только недостающие clone) |
+18 -7
View File
@@ -12,7 +12,7 @@
gpu-rent setup gpu-rent setup
``` ```
Выбери `ollama` или `llamacpp`, при Ollama — пресет моделей. Значение пишется в `gpu-rent.vars` (`LLM_RUNTIME=…`). Выбери `ollama` или `llamacpp`. Для **обоих** спросит пресет моделей. Значение пишется в `gpu-rent.vars` (`LLM_RUNTIME=…`).
### Вариант B — флаг на `up` ### Вариант B — флаг на `up`
@@ -103,14 +103,25 @@ Community abliterate-модели без гарантий безопасност
## llama.cpp ## llama.cpp
1. `LLM_RUNTIME=llamacpp` или `up --llamacpp` Манифест GGUF:
2. CLI ставит `llama-server` + systemd `gpu-rent-llamacpp`
3. Положи GGUF в `/mnt/swarm_data/llamacpp/models` (SFTP / `gpu-rent ssh`)
4. `systemctl restart gpu-rent-llamacpp` на VM
Без GGUF unit может стартовать, но API бесполезен — смотри `gpu-rent logs` / `journalctl -u gpu-rent-llamacpp`. | Файл | Роль |
| --- | --- |
| `llamacpp-models.example.yaml` | шаблон в git |
| `llamacpp-models.yaml` | URL на `.gguf` (gitignore) |
Параметры `-ngl` / `-c` ставятся по тому же GPU probe (full offload на mid+, меньше слоёв и ctx на low). На interactive `up` / `setup` после выбора `llamacpp` спрашивается пресет (как у Ollama). На `up` CLI скачивает GGUF в `/mnt/swarm_data/llamacpp/models`, затем ставит `llama-server` + systemd.
### Пресеты
| preset | что |
| --- | --- |
| **recommended** | Qwen2.5 7B abliterate Q4_K_M (~4.7 GB) |
| light | Qwen2.5 3B Instruct Q4_K_M |
| stock | официальный Qwen2.5 7B Instruct Q4_K_M |
| empty | только runtime |
Параметры `-ngl` / `-c` — по GPU probe. Опционально `HF_TOKEN` для gated HF. Вручную: положи GGUF в каталог models и `systemctl restart gpu-rent-llamacpp`.
--- ---
+1
View File
@@ -119,6 +119,7 @@ function Copy-IfMissing {
Copy-IfMissing (Join-Path $Root "models.example.yaml") (Join-Path $Root "models.yaml") "models.yaml" Copy-IfMissing (Join-Path $Root "models.example.yaml") (Join-Path $Root "models.yaml") "models.yaml"
Copy-IfMissing (Join-Path $Root "extensions.example.yaml") (Join-Path $Root "extensions.yaml") "extensions.yaml" Copy-IfMissing (Join-Path $Root "extensions.example.yaml") (Join-Path $Root "extensions.yaml") "extensions.yaml"
Copy-IfMissing (Join-Path $Root "ollama-models.example.yaml") (Join-Path $Root "ollama-models.yaml") "ollama-models.yaml" Copy-IfMissing (Join-Path $Root "ollama-models.example.yaml") (Join-Path $Root "ollama-models.yaml") "ollama-models.yaml"
Copy-IfMissing (Join-Path $Root "llamacpp-models.example.yaml") (Join-Path $Root "llamacpp-models.yaml") "llamacpp-models.yaml"
Copy-IfMissing (Join-Path $Root "gpu-rent.vars.example") (Join-Path $Root "gpu-rent.vars") "gpu-rent.vars" Copy-IfMissing (Join-Path $Root "gpu-rent.vars.example") (Join-Path $Root "gpu-rent.vars") "gpu-rent.vars"
Import-GpuRentVars (Join-Path $Root "gpu-rent.vars") Import-GpuRentVars (Join-Path $Root "gpu-rent.vars")
+4
View File
@@ -99,6 +99,10 @@ if [[ ! -f "$ROOT/ollama-models.yaml" && -f "$ROOT/ollama-models.example.yaml" ]
cp "$ROOT/ollama-models.example.yaml" "$ROOT/ollama-models.yaml" cp "$ROOT/ollama-models.example.yaml" "$ROOT/ollama-models.yaml"
echo "gpu-rent: created ollama-models.yaml" echo "gpu-rent: created ollama-models.yaml"
fi fi
if [[ ! -f "$ROOT/llamacpp-models.yaml" && -f "$ROOT/llamacpp-models.example.yaml" ]]; then
cp "$ROOT/llamacpp-models.example.yaml" "$ROOT/llamacpp-models.yaml"
echo "gpu-rent: created llamacpp-models.yaml"
fi
if [[ ! -f "$ROOT/gpu-rent.vars" && -f "$ROOT/gpu-rent.vars.example" ]]; then if [[ ! -f "$ROOT/gpu-rent.vars" && -f "$ROOT/gpu-rent.vars.example" ]]; then
cp "$ROOT/gpu-rent.vars.example" "$ROOT/gpu-rent.vars" cp "$ROOT/gpu-rent.vars.example" "$ROOT/gpu-rent.vars"
echo "gpu-rent: created gpu-rent.vars" echo "gpu-rent: created gpu-rent.vars"
+15
View File
@@ -0,0 +1,15 @@
# Copy to llamacpp-models.yaml (gitignored). Used when LLM_RUNTIME=llamacpp.
# url = direct HTTPS link to a .gguf (Hugging Face resolve/main/…).
# Empty models: [] → only llama-server, GGUF клади вручную на VM.
# Purpose: prompt-help beside SwarmUI (RU/EN).
models:
# Recommended: Qwen2.5 7B abliterate Q4_K_M (~4.7 GB)
- url: https://huggingface.co/bartowski/huihui-ai_Qwen2.5-7B-Instruct-abliterated-GGUF/resolve/main/huihui-ai_Qwen2.5-7B-Instruct-abliterated-Q4_K_M.gguf
default: true
# Lighter (~2 GB):
# - url: https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF/resolve/main/Qwen2.5-3B-Instruct-Q4_K_M.gguf
# Official stock 7B (more refusals):
# - url: https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf
+52
View File
@@ -436,6 +436,7 @@ def up(
except ValueError as exc: except ValueError as exc:
raise GpuRentError(str(exc)) from exc raise GpuRentError(str(exc)) from exc
asked_model_preset = False
if not yes and runtime == "none" and not llm and not ollama and not llamacpp: if not yes and runtime == "none" and not llm and not ollama and not llamacpp:
choice = typer.prompt( choice = typer.prompt(
"Поднять LLM рядом со SwarmUI? [none/ollama/llamacpp]", "Поднять LLM рядом со SwarmUI? [none/ollama/llamacpp]",
@@ -457,6 +458,53 @@ def up(
write_ollama_models_preset( write_ollama_models_preset(
cfg.ollama_models_manifest, preset.strip().lower() cfg.ollama_models_manifest, preset.strip().lower()
) )
asked_model_preset = True
elif runtime == "llamacpp":
from gpu_rent.llm_runtime import (
LLAMACPP_PRESET_HELP,
ensure_llamacpp_manifest_from_example,
write_llamacpp_models_preset,
)
ensure_llamacpp_manifest_from_example()
console.print(LLAMACPP_PRESET_HELP)
preset = typer.prompt(
"llama.cpp GGUF preset [recommended/light/stock/empty]",
default="recommended",
)
if preset.strip().lower() not in {"keep", "example"}:
write_llamacpp_models_preset(
cfg.llamacpp_models_manifest, preset.strip().lower()
)
asked_model_preset = True
# Runtime уже в vars (напр. llamacpp) — всё равно спросить модель, default=keep.
if not yes and not asked_model_preset and runtime == "llamacpp":
from gpu_rent.llm_runtime import (
LLAMACPP_PRESET_HELP,
ensure_llamacpp_manifest_from_example,
write_llamacpp_models_preset,
)
ensure_llamacpp_manifest_from_example()
console.print(LLAMACPP_PRESET_HELP)
preset = typer.prompt(
"llama.cpp GGUF preset [recommended/light/stock/empty/keep]",
default="keep",
)
key = preset.strip().lower()
if key not in {"keep", "example", ""}:
write_llamacpp_models_preset(cfg.llamacpp_models_manifest, key)
elif not yes and not asked_model_preset and runtime == "ollama":
ensure_ollama_manifest_from_example()
console.print(PRESET_HELP)
preset = typer.prompt(
"Ollama preset [recommended/light/stock/alt/empty/keep]",
default="keep",
)
key = preset.strip().lower()
if key not in {"keep", "example", ""}:
write_ollama_models_preset(cfg.ollama_models_manifest, key)
cfg = replace(cfg, llm_runtime=runtime) cfg = replace(cfg, llm_runtime=runtime)
if runtime != "none": if runtime != "none":
@@ -465,6 +513,9 @@ def up(
def confirm(msg: str) -> bool: def confirm(msg: str) -> bool:
return typer.confirm(msg) return typer.confirm(msg)
def ask(msg: str, default: str = "") -> str:
return typer.prompt(msg, default=default)
state = cmd_up( state = cmd_up(
cfg, cfg,
no_spot=no_spot, no_spot=no_spot,
@@ -473,6 +524,7 @@ def up(
adopt=adopt, adopt=adopt,
update=False if no_update else None, update=False if no_update else None,
confirm=confirm, confirm=confirm,
ask=None if yes else ask,
log=lambda m: console.print(m), log=lambda m: console.print(m),
) )
if no_tunnel: if no_tunnel:
+7
View File
@@ -14,6 +14,7 @@ from gpu_rent.paths import (
default_ssh_key_path, default_ssh_key_path,
env_path, env_path,
extensions_manifest_path, extensions_manifest_path,
llamacpp_models_manifest_path,
migrate_legacy_if_needed, migrate_legacy_if_needed,
models_manifest_path, models_manifest_path,
ollama_models_manifest_path, ollama_models_manifest_path,
@@ -84,6 +85,7 @@ class Config:
llm_runtime: str llm_runtime: str
ollama_models_manifest: Path ollama_models_manifest: Path
llamacpp_models_manifest: Path
ollama_local_port: int ollama_local_port: int
llamacpp_local_port: int llamacpp_local_port: int
@@ -157,6 +159,10 @@ def load_config(*, require_auth: bool = True) -> Config:
(os.environ.get("OLLAMA_MODELS_MANIFEST") or "").strip() (os.environ.get("OLLAMA_MODELS_MANIFEST") or "").strip()
or str(ollama_models_manifest_path()) or str(ollama_models_manifest_path())
).expanduser() ).expanduser()
llamacpp_manifest = Path(
(os.environ.get("LLAMACPP_MODELS_MANIFEST") or "").strip()
or str(llamacpp_models_manifest_path())
).expanduser()
try: try:
llm_runtime = normalize_runtime(os.environ.get("LLM_RUNTIME")) llm_runtime = normalize_runtime(os.environ.get("LLM_RUNTIME"))
@@ -205,6 +211,7 @@ def load_config(*, require_auth: bool = True) -> Config:
update_git=_as_bool(os.environ.get("UPDATE_GIT"), True), update_git=_as_bool(os.environ.get("UPDATE_GIT"), True),
llm_runtime=llm_runtime, llm_runtime=llm_runtime,
ollama_models_manifest=ollama_manifest, ollama_models_manifest=ollama_manifest,
llamacpp_models_manifest=llamacpp_manifest,
ollama_local_port=_as_int(os.environ.get("OLLAMA_LOCAL_PORT"), 17811), ollama_local_port=_as_int(os.environ.get("OLLAMA_LOCAL_PORT"), 17811),
llamacpp_local_port=_as_int(os.environ.get("LLAMACPP_LOCAL_PORT"), 17812), llamacpp_local_port=_as_int(os.environ.get("LLAMACPP_LOCAL_PORT"), 17812),
default_flavor_id=(os.environ.get("DEFAULT_FLAVOR_ID") or "").strip(), default_flavor_id=(os.environ.get("DEFAULT_FLAVOR_ID") or "").strip(),
+112 -23
View File
@@ -5,14 +5,19 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import unquote, urlparse
import yaml import yaml
from gpu_rent.paths import ollama_models_example_path, ollama_models_manifest_path from gpu_rent.paths import (
llamacpp_models_example_path,
llamacpp_models_manifest_path,
ollama_models_example_path,
ollama_models_manifest_path,
)
VALID_RUNTIMES = frozenset({"none", "ollama", "llamacpp"}) VALID_RUNTIMES = frozenset({"none", "ollama", "llamacpp"})
# Presets for setup / interactive up (prompt help: RU + low refusal).
OLLAMA_PRESETS: dict[str, list[str]] = { OLLAMA_PRESETS: dict[str, list[str]] = {
"recommended": ["huihui_ai/qwen2.5-abliterate:7b"], "recommended": ["huihui_ai/qwen2.5-abliterate:7b"],
"light": ["qwen2.5:3b"], "light": ["qwen2.5:3b"],
@@ -29,6 +34,26 @@ PRESET_HELP = (
"empty — только runtime, без pull" "empty — только runtime, без pull"
) )
LLAMACPP_PRESETS: dict[str, list[str]] = {
"recommended": [
"https://huggingface.co/bartowski/huihui-ai_Qwen2.5-7B-Instruct-abliterated-GGUF/resolve/main/huihui-ai_Qwen2.5-7B-Instruct-abliterated-Q4_K_M.gguf",
],
"light": [
"https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF/resolve/main/Qwen2.5-3B-Instruct-Q4_K_M.gguf",
],
"stock": [
"https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf",
],
"empty": [],
}
LLAMACPP_PRESET_HELP = (
"recommended — Qwen2.5 7B abliterate GGUF Q4_K_M (~4.7GB, мало отказов)\n"
"light — Qwen2.5 3B Instruct Q4_K_M (~2GB)\n"
"stock — официальный Qwen2.5 7B Instruct Q4_K_M\n"
"empty — только llama-server, GGUF положи вручную / правь llamacpp-models.yaml"
)
@dataclass(frozen=True) @dataclass(frozen=True)
class OllamaModelEntry: class OllamaModelEntry:
@@ -36,6 +61,13 @@ class OllamaModelEntry:
default: bool = False default: bool = False
@dataclass(frozen=True)
class LlamaCppModelEntry:
url: str
filename: str | None = None
default: bool = False
def normalize_runtime(value: str | None) -> str: def normalize_runtime(value: str | None) -> str:
raw = (value or "none").strip().lower().replace("-", "").replace("_", "") raw = (value or "none").strip().lower().replace("-", "").replace("_", "")
if raw in {"", "none", "off", "no", "0"}: if raw in {"", "none", "off", "no", "0"}:
@@ -54,7 +86,6 @@ def decide_runtime(
llamacpp_flag: bool, llamacpp_flag: bool,
from_config: str, from_config: str,
) -> str: ) -> str:
"""CLI flags win over config/vars."""
if ollama_flag and llamacpp_flag: if ollama_flag and llamacpp_flag:
raise ValueError("укажи только --ollama или --llamacpp, не оба") raise ValueError("укажи только --ollama или --llamacpp, не оба")
if ollama_flag: if ollama_flag:
@@ -93,6 +124,49 @@ def parse_ollama_models(path: Path) -> list[OllamaModelEntry]:
return out return out
def gguf_filename_from_url(url: str) -> str:
path = unquote(urlparse(url).path)
name = path.rsplit("/", 1)[-1] if path else ""
if name.lower().endswith(".gguf"):
return name
return "model.gguf"
def parse_llamacpp_models(path: Path) -> list[LlamaCppModelEntry]:
if not path.is_file():
return []
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
return []
items = raw.get("models")
if items is None:
return []
if not isinstance(items, list):
raise ValueError(f"{path}: models должен быть списком")
out: list[LlamaCppModelEntry] = []
for item in items:
if isinstance(item, str):
url = item.strip()
if url:
out.append(LlamaCppModelEntry(url=url))
continue
if not isinstance(item, dict):
continue
url = str(item.get("url") or "").strip()
if not url:
continue
fname = item.get("filename")
filename = str(fname).strip() if fname else None
out.append(
LlamaCppModelEntry(
url=url,
filename=filename or None,
default=bool(item.get("default")),
)
)
return out
def write_ollama_models_preset(path: Path, preset: str) -> None: def write_ollama_models_preset(path: Path, preset: str) -> None:
key = (preset or "recommended").strip().lower() key = (preset or "recommended").strip().lower()
if key not in OLLAMA_PRESETS: if key not in OLLAMA_PRESETS:
@@ -113,6 +187,26 @@ def write_ollama_models_preset(path: Path, preset: str) -> None:
path.write_text("\n".join(lines) + "\n", encoding="utf-8") path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def write_llamacpp_models_preset(path: Path, preset: str) -> None:
key = (preset or "recommended").strip().lower()
if key not in LLAMACPP_PRESETS:
raise ValueError(f"пресет {preset!r}; варианты: {', '.join(LLAMACPP_PRESETS)}")
urls = LLAMACPP_PRESETS[key]
lines = [
"# Локальный манифест llama.cpp GGUF (не коммить). Пример: llamacpp-models.example.yaml",
"# url = прямой HTTPS на .gguf. Пустой models: [] — без скачивания.",
"models:",
]
if not urls:
lines.append(" []")
else:
for i, url in enumerate(urls):
lines.append(f" - url: {url}")
if i == 0:
lines.append(" default: true")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def ensure_ollama_manifest_from_example() -> Path: def ensure_ollama_manifest_from_example() -> Path:
dest = ollama_models_manifest_path() dest = ollama_models_manifest_path()
if dest.is_file(): if dest.is_file():
@@ -125,6 +219,18 @@ def ensure_ollama_manifest_from_example() -> Path:
return dest return dest
def ensure_llamacpp_manifest_from_example() -> Path:
dest = llamacpp_models_manifest_path()
if dest.is_file():
return dest
example = llamacpp_models_example_path()
if example.is_file():
dest.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
else:
write_llamacpp_models_preset(dest, "recommended")
return dest
def llm_local_port(cfg: Any) -> int | None: def llm_local_port(cfg: Any) -> int | None:
runtime = normalize_runtime(getattr(cfg, "llm_runtime", "none")) runtime = normalize_runtime(getattr(cfg, "llm_runtime", "none"))
if runtime == "ollama": if runtime == "ollama":
@@ -144,23 +250,6 @@ def llm_remote_port(runtime: str) -> int | None:
def append_vars_llm_runtime(vars_file: Path, runtime: str) -> None: def append_vars_llm_runtime(vars_file: Path, runtime: str) -> None:
runtime = normalize_runtime(runtime) from gpu_rent.varsfile import upsert_vars
line = f"LLM_RUNTIME={runtime}\n"
if not vars_file.is_file(): upsert_vars(vars_file, {"LLM_RUNTIME": normalize_runtime(runtime)})
vars_file.write_text("# gpu-rent.vars — несекреты\n" + line, encoding="utf-8")
return
text = vars_file.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
out: list[str] = []
replaced = False
for row in lines:
if row.lstrip().startswith("LLM_RUNTIME="):
out.append(line if row.endswith("\n") else line.rstrip("\n"))
replaced = True
else:
out.append(row)
if not replaced:
if out and not out[-1].endswith("\n"):
out[-1] = out[-1] + "\n"
out.append(line)
vars_file.write_text("".join(out), encoding="utf-8")
+8
View File
@@ -56,6 +56,14 @@ def ollama_models_example_path() -> Path:
return app_root() / "ollama-models.example.yaml" return app_root() / "ollama-models.example.yaml"
def llamacpp_models_manifest_path() -> Path:
return app_root() / "llamacpp-models.yaml"
def llamacpp_models_example_path() -> Path:
return app_root() / "llamacpp-models.example.yaml"
def extensions_manifest_path() -> Path: def extensions_manifest_path() -> Path:
return app_root() / "extensions.yaml" return app_root() / "extensions.yaml"
+42
View File
@@ -372,6 +372,48 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
) )
elif runtime == "llamacpp": elif runtime == "llamacpp":
_stop_units("gpu-rent-ollama") _stop_units("gpu-rent-ollama")
from gpu_rent.llm_runtime import (
gguf_filename_from_url,
parse_llamacpp_models,
)
import os
entries = parse_llamacpp_models(cfg.llamacpp_models_manifest)
defaults = [e for e in entries if e.default]
if defaults:
log(
"llama.cpp preferred: "
f"{defaults[0].filename or gguf_filename_from_url(defaults[0].url)}"
)
if entries:
jobs = [
{
"url": e.url,
"filename": e.filename or gguf_filename_from_url(e.url),
}
for e in entries
]
put_text(
cfg, host, "/tmp/gpu-rent-llamacpp-models.json", json.dumps(jobs, indent=2)
)
hf = (
os.environ.get("HF_TOKEN")
or os.environ.get("HUGGING_FACE_HUB_TOKEN")
or ""
).strip()
if hf:
put_text(cfg, host, "/tmp/gpu-rent-hf.token", hf + "\n", mode=0o600)
log(f"llama.cpp: скачиваю {len(jobs)} GGUF из манифеста")
run_python(
cfg,
host,
_pkg_text("llamacpp_fetch.py"),
remote_path="/tmp/gpu-rent-llamacpp_fetch.py",
timeout=7200,
log=log,
)
else:
log("llamacpp-models.yaml пуст — GGUF skip (положи вручную)")
log("LLM: ставим/запускаем llama.cpp server") log("LLM: ставим/запускаем llama.cpp server")
run_script_sudo( run_script_sudo(
cfg, cfg,
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Download GGUF files for llama.cpp from a JSON job list. Stdlib only."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
JOBS = Path("/tmp/gpu-rent-llamacpp-models.json")
MODELS_DIR = Path("/mnt/swarm_data/llamacpp/models")
TOKEN_FILE = Path("/tmp/gpu-rent-hf.token")
def main() -> int:
if TOKEN_FILE.is_file():
try:
os.environ["HF_TOKEN"] = TOKEN_FILE.read_text(encoding="utf-8").strip()
finally:
try:
TOKEN_FILE.unlink(missing_ok=True)
except OSError:
pass
if not JOBS.is_file():
print("no jobs file", file=sys.stderr)
return 1
jobs = json.loads(JOBS.read_text(encoding="utf-8"))
if not isinstance(jobs, list) or not jobs:
print("llamacpp fetch: пустой список — skip")
return 0
MODELS_DIR.mkdir(parents=True, exist_ok=True)
token = (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip()
failed = 0
for i, job in enumerate(jobs, 1):
if not isinstance(job, dict):
continue
url = str(job.get("url") or "").strip()
name = str(job.get("filename") or "").strip()
if not url:
continue
if not name:
name = url.rstrip("/").rsplit("/", 1)[-1] or "model.gguf"
dest = MODELS_DIR / name
if dest.is_file() and dest.stat().st_size > 1_000_000:
print(f"[{i}/{len(jobs)}] уже есть {name} ({dest.stat().st_size} bytes)")
continue
print(f"[{i}/{len(jobs)}] download {name}")
partial = dest.with_suffix(dest.suffix + ".partial")
headers = {"User-Agent": "gpu-rent/1"}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=600) as resp, partial.open("wb") as out:
while True:
chunk = resp.read(1024 * 1024)
if not chunk:
break
out.write(chunk)
partial.replace(dest)
print(f"ok {name} ({dest.stat().st_size} bytes)")
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as exc:
failed += 1
print(f"FAIL {name}: {exc}", file=sys.stderr)
try:
partial.unlink(missing_ok=True)
except OSError:
pass
if failed:
return 1
print("llamacpp fetch ok")
return 0
if __name__ == "__main__":
sys.exit(main())
+25 -1
View File
@@ -33,7 +33,7 @@ from gpu_rent.inventory import (
pick_volume_type, pick_volume_type,
resolve_flavor, resolve_flavor,
) )
from gpu_rent.ux import print_up_preview from gpu_rent.ux import print_up_preview, prompt_server_plan
from gpu_rent.pools import best_offer, format_pool_scan, scan_pools from gpu_rent.pools import best_offer, format_pool_scan, scan_pools
from gpu_rent.lock import SessionLock from gpu_rent.lock import SessionLock
from gpu_rent.os_client import ( from gpu_rent.os_client import (
@@ -173,6 +173,7 @@ def cmd_up(
adopt: bool = False, adopt: bool = False,
update: bool | None = None, update: bool | None = None,
confirm: Callable[[str], bool] | None = None, confirm: Callable[[str], bool] | None = None,
ask: Callable[[str, str], str] | None = None,
log: Log = _log_default, log: Log = _log_default,
) -> SessionState: ) -> SessionState:
do_update = cfg.update_git if update is None else update do_update = cfg.update_git if update is None else update
@@ -291,6 +292,29 @@ def cmd_up(
print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log) print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log)
if not yes and ask is not None and flavor is None:
try:
plan = prompt_server_plan(
cfg,
flavors,
picked=picked,
spot=spot,
ask=ask,
confirm=confirm,
)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
picked = plan.flavor
spot = plan.spot
if plan.data_gb != cfg.data_volume_size_gb:
from dataclasses import replace
cfg = replace(cfg, data_volume_size_gb=plan.data_gb)
log(
f"выбрано: {'preemptible ' if spot else ''}{picked.name}, "
f"data {cfg.data_volume_size_gb} GB"
)
prompt = ( prompt = (
f"Создать {'preemptible ' if spot else ''}GPU {picked.name} " f"Создать {'preemptible ' if spot else ''}GPU {picked.name} "
f"в {cfg.gpu_rent_az}, образ {getattr(image, 'name', image.id)}, " f"в {cfg.gpu_rent_az}, образ {getattr(image, 'name', image.id)}, "
+27
View File
@@ -7,16 +7,21 @@ from collections.abc import Callable
from pathlib import Path from pathlib import Path
from gpu_rent.llm_runtime import ( from gpu_rent.llm_runtime import (
LLAMACPP_PRESET_HELP,
PRESET_HELP, PRESET_HELP,
append_vars_llm_runtime, append_vars_llm_runtime,
ensure_llamacpp_manifest_from_example,
ensure_ollama_manifest_from_example, ensure_ollama_manifest_from_example,
normalize_runtime, normalize_runtime,
write_llamacpp_models_preset,
write_ollama_models_preset, write_ollama_models_preset,
) )
from gpu_rent.paths import ( from gpu_rent.paths import (
app_root, app_root,
env_path, env_path,
extensions_manifest_path, extensions_manifest_path,
llamacpp_models_example_path,
llamacpp_models_manifest_path,
models_manifest_path, models_manifest_path,
ollama_models_example_path, ollama_models_example_path,
ollama_models_manifest_path, ollama_models_manifest_path,
@@ -59,6 +64,12 @@ def run_setup(
_copy_if_missing( _copy_if_missing(
ollama_models_example_path(), ollama_models_manifest_path(), "ollama-models.yaml", log ollama_models_example_path(), ollama_models_manifest_path(), "ollama-models.yaml", log
) )
_copy_if_missing(
llamacpp_models_example_path(),
llamacpp_models_manifest_path(),
"llamacpp-models.yaml",
log,
)
runtime = llm runtime = llm
if runtime is None: if runtime is None:
@@ -86,6 +97,22 @@ def run_setup(
else: else:
write_ollama_models_preset(ollama_models_manifest_path(), preset) write_ollama_models_preset(ollama_models_manifest_path(), preset)
log(f"ollama-models.yaml пресет={preset}") log(f"ollama-models.yaml пресет={preset}")
elif runtime == "llamacpp":
preset = ollama_preset # reuse --ollama-preset flag as generic LLM preset in setup
if preset is None and ask:
log(LLAMACPP_PRESET_HELP)
preset = ask(
"llama.cpp GGUF preset [recommended/light/stock/empty]",
"recommended",
)
if preset is None:
preset = "recommended"
if preset.strip().lower() in {"keep", "example", ""}:
ensure_llamacpp_manifest_from_example()
log("llamacpp-models.yaml из example")
else:
write_llamacpp_models_preset(llamacpp_models_manifest_path(), preset)
log(f"llamacpp-models.yaml пресет={preset}")
do_wd = install_watchdog do_wd = install_watchdog
if do_wd is None and confirm: if do_wd is None and confirm:
+90
View File
@@ -3,12 +3,15 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from typing import Any from typing import Any
from gpu_rent.config import Config from gpu_rent.config import Config
from gpu_rent.inventory import FlavorInfo, looks_like_gpu, rank_flavors, resolve_flavor from gpu_rent.inventory import FlavorInfo, looks_like_gpu, rank_flavors, resolve_flavor
Log = Callable[[str], None] Log = Callable[[str], None]
Ask = Callable[[str, str], str]
Confirm = Callable[[str], bool]
def list_ranked_flavors(flavors: list[Any], cfg: Config) -> list[FlavorInfo]: def list_ranked_flavors(flavors: list[Any], cfg: Config) -> list[FlavorInfo]:
@@ -61,6 +64,93 @@ def print_up_preview(
log(f"! {line}") log(f"! {line}")
@dataclass(frozen=True)
class ServerPlan:
flavor: FlavorInfo
data_gb: int
spot: bool
def prompt_server_plan(
cfg: Config,
flavors: list[Any],
*,
picked: FlavorInfo,
spot: bool,
ask: Ask,
confirm: Confirm | None = None,
) -> ServerPlan:
"""Interactive Selectel VM knobs before create (flavor / disk / spot)."""
ranked = list_ranked_flavors(flavors, cfg)
if not ranked:
return ServerPlan(flavor=picked, data_gb=cfg.data_volume_size_gb, spot=spot)
default_idx = 1
for i, info in enumerate(ranked, 1):
if info.id == picked.id:
default_idx = i
break
raw_idx = ask(
f"Flavor Selectel [1-{len(ranked)}] (Enter = рекомендация)",
str(default_idx),
).strip()
try:
idx = int(raw_idx)
except ValueError as exc:
raise ValueError(f"номер flavor: жду 1…{len(ranked)}, получили {raw_idx!r}") from exc
if idx < 1 or idx > len(ranked):
raise ValueError(f"номер flavor: жду 1…{len(ranked)}, получили {idx}")
chosen = ranked[idx - 1]
raw_gb = ask(
"Data disk GB (тариф 24/7; рост только вверх)",
str(cfg.data_volume_size_gb),
).strip()
try:
data_gb = int(raw_gb)
except ValueError as exc:
raise ValueError(f"Data disk GB: жду число, получили {raw_gb!r}") from exc
if data_gb < 20:
raise ValueError("Data disk GB: минимум 20")
spot_default = "Y" if spot else "n"
raw_spot = (
ask(
"Preemptible GPU (дешевле, могут усыпить ~24ч)? [Y/n]",
spot_default,
)
.strip()
.lower()
)
if raw_spot in {"", "y", "yes", "1", "true", "on"}:
use_spot = True
elif raw_spot in {"n", "no", "0", "false", "off"}:
use_spot = False
else:
raise ValueError(f"preemptible: жду Y/n, получили {raw_spot!r}")
changed = (
chosen.id != picked.id
or data_gb != cfg.data_volume_size_gb
or use_spot != spot
)
if confirm and changed and confirm("Запомнить flavor/disk/spot в gpu-rent.vars?"):
from gpu_rent.paths import vars_path
from gpu_rent.varsfile import upsert_vars
upsert_vars(
vars_path(),
{
"DEFAULT_FLAVOR_ID": chosen.id,
"DATA_VOLUME_SIZE_GB": str(data_gb),
"DEFAULT_SPOT": "true" if use_spot else "false",
},
)
return ServerPlan(flavor=chosen, data_gb=data_gb, spot=use_spot)
def resolve_and_preview( def resolve_and_preview(
cfg: Config, cfg: Config,
flavors: list[Any], flavors: list[Any],
+34
View File
@@ -41,6 +41,40 @@ def apply_vars_file(path: Path, *, override: bool = False) -> dict[str, str]:
return loaded return loaded
def upsert_vars(path: Path, updates: dict[str, str]) -> None:
"""Create or replace KEY=VALUE lines in a vars file (preserve comments/order)."""
if not updates:
return
if not path.is_file():
lines = ["# gpu-rent.vars — несекреты\n"]
for key, value in updates.items():
lines.append(f"{key}={value}\n")
path.write_text("".join(lines), encoding="utf-8")
return
text = path.read_text(encoding="utf-8")
rows = text.splitlines(keepends=True)
pending = dict(updates)
out: list[str] = []
for row in rows:
stripped = row.lstrip()
key = None
if "=" in stripped and not stripped.startswith("#"):
maybe = stripped.split("=", 1)[0].strip()
if maybe in pending:
key = maybe
if key is not None:
line = f"{key}={pending.pop(key)}\n"
out.append(line if row.endswith("\n") else line.rstrip("\n"))
else:
out.append(row)
if pending:
if out and not out[-1].endswith("\n"):
out[-1] = out[-1] + "\n"
for key, value in pending.items():
out.append(f"{key}={value}\n")
path.write_text("".join(out), encoding="utf-8")
def split_args(value: str | None) -> list[str]: def split_args(value: str | None) -> list[str]:
if not value or not value.strip(): if not value or not value.strip():
return [] return []
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
from gpu_rent.llm_runtime import (
gguf_filename_from_url,
parse_llamacpp_models,
write_llamacpp_models_preset,
)
def test_gguf_filename_from_url():
url = (
"https://huggingface.co/org/repo/resolve/main/"
"Qwen2.5-3B-Instruct-Q4_K_M.gguf"
)
assert gguf_filename_from_url(url) == "Qwen2.5-3B-Instruct-Q4_K_M.gguf"
def test_write_and_parse_llamacpp_preset(tmp_path: Path):
path = tmp_path / "llamacpp-models.yaml"
write_llamacpp_models_preset(path, "light")
entries = parse_llamacpp_models(path)
assert len(entries) == 1
assert entries[0].default is True
assert "Qwen2.5-3B" in entries[0].url
assert entries[0].url.startswith("https://")
def test_parse_llamacpp_empty(tmp_path: Path):
path = tmp_path / "llamacpp-models.yaml"
write_llamacpp_models_preset(path, "empty")
assert parse_llamacpp_models(path) == []
+61
View File
@@ -0,0 +1,61 @@
from gpu_rent.inventory import FlavorInfo
from gpu_rent.ux import prompt_server_plan
from gpu_rent.varsfile import parse_vars_file, upsert_vars
def _cfg_stub(monkeypatch, data_gb: int = 100):
monkeypatch.setenv("OS_AUTH_URL", "https://example.invalid/identity/v3")
monkeypatch.setenv("OS_USER_DOMAIN_NAME", "999")
monkeypatch.setenv("OS_USERNAME", "svc")
monkeypatch.setenv("OS_PASSWORD", "secret")
monkeypatch.setenv("OS_PROJECT_ID", "proj")
monkeypatch.setenv("OS_REGION_NAME", "ru-7")
monkeypatch.setenv("GPU_RENT_AZ", "ru-7a")
monkeypatch.setenv("DATA_VOLUME_SIZE_GB", str(data_gb))
from gpu_rent.config import load_config
return load_config(require_auth=True)
def test_prompt_server_plan_with_ranked(monkeypatch, tmp_path):
cfg = _cfg_stub(monkeypatch)
monkeypatch.setattr("gpu_rent.paths.app_root", lambda: tmp_path)
f1 = FlavorInfo(
id="a", name="small", vcpus=4, ram_mb=16384, disabled=False, extra={}, label="4090-24"
)
f2 = FlavorInfo(
id="b", name="big", vcpus=12, ram_mb=65536, disabled=False, extra={}, label="4090-48"
)
monkeypatch.setattr(
"gpu_rent.ux.list_ranked_flavors",
lambda flavors, cfg: [f1, f2],
)
answers = iter(["2", "200", "n"])
def ask(msg: str, default: str = "") -> str:
return next(answers)
remembered: list[bool] = []
plan = prompt_server_plan(
cfg,
["x"],
picked=f1,
spot=True,
ask=ask,
confirm=lambda m: remembered.append(True) or False,
)
assert plan.flavor.id == "b"
assert plan.data_gb == 200
assert plan.spot is False
assert remembered
def test_upsert_vars(tmp_path):
path = tmp_path / "gpu-rent.vars"
path.write_text("# c\nLLM_RUNTIME=none\n", encoding="utf-8")
upsert_vars(path, {"LLM_RUNTIME": "llamacpp", "DATA_VOLUME_SIZE_GB": "200"})
data = parse_vars_file(path)
assert data["LLM_RUNTIME"] == "llamacpp"
assert data["DATA_VOLUME_SIZE_GB"] == "200"