diff --git a/.gitignore b/.gitignore index 1661645..addedbe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ !.env.example gpu-rent.vars ollama-models.yaml +llamacpp-models.yaml models.yaml extensions.yaml diff --git a/docs/cli.md b/docs/cli.md index 7aea212..c335c90 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -55,7 +55,7 @@ gpu-rent up --yes --ollama | `gpu-rent doctor` | Preflight **без** create. Exit ≠ 0 → сессию начинать нельзя | | `gpu-rent flavors` | Скан `SCAN_POOLS` × `FLAVOR_PREFERENCE`, список в текущем регионе | | `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 --ollama` / `--llamacpp` / `--llm …` | LLM рядом со SwarmUI | | `gpu-rent up --no-update` | Без `git pull` SwarmUI/extensions (только недостающие clone) | diff --git a/docs/llm.md b/docs/llm.md index 00e2db2..a0f5aee 100644 --- a/docs/llm.md +++ b/docs/llm.md @@ -12,7 +12,7 @@ gpu-rent setup ``` -Выбери `ollama` или `llamacpp`, при Ollama — пресет моделей. Значение пишется в `gpu-rent.vars` (`LLM_RUNTIME=…`). +Выбери `ollama` или `llamacpp`. Для **обоих** спросит пресет моделей. Значение пишется в `gpu-rent.vars` (`LLM_RUNTIME=…`). ### Вариант B — флаг на `up` @@ -103,14 +103,25 @@ Community abliterate-модели без гарантий безопасност ## llama.cpp -1. `LLM_RUNTIME=llamacpp` или `up --llamacpp` -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: -Без 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`. --- diff --git a/gpu-rent.ps1 b/gpu-rent.ps1 index 0997229..775d99f 100644 --- a/gpu-rent.ps1 +++ b/gpu-rent.ps1 @@ -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 "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 "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" Import-GpuRentVars (Join-Path $Root "gpu-rent.vars") diff --git a/gpu-rent.sh b/gpu-rent.sh index 0f9915f..ce1bf42 100644 --- a/gpu-rent.sh +++ b/gpu-rent.sh @@ -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" echo "gpu-rent: created ollama-models.yaml" 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 cp "$ROOT/gpu-rent.vars.example" "$ROOT/gpu-rent.vars" echo "gpu-rent: created gpu-rent.vars" diff --git a/llamacpp-models.example.yaml b/llamacpp-models.example.yaml new file mode 100644 index 0000000..16a27be --- /dev/null +++ b/llamacpp-models.example.yaml @@ -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 diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py index 9caf6a8..7f0fd5d 100644 --- a/src/gpu_rent/cli.py +++ b/src/gpu_rent/cli.py @@ -436,6 +436,7 @@ def up( except ValueError as 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: choice = typer.prompt( "Поднять LLM рядом со SwarmUI? [none/ollama/llamacpp]", @@ -457,6 +458,53 @@ def up( write_ollama_models_preset( 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) if runtime != "none": @@ -465,6 +513,9 @@ def up( def confirm(msg: str) -> bool: return typer.confirm(msg) + def ask(msg: str, default: str = "") -> str: + return typer.prompt(msg, default=default) + state = cmd_up( cfg, no_spot=no_spot, @@ -473,6 +524,7 @@ def up( adopt=adopt, update=False if no_update else None, confirm=confirm, + ask=None if yes else ask, log=lambda m: console.print(m), ) if no_tunnel: diff --git a/src/gpu_rent/config.py b/src/gpu_rent/config.py index dc5f22f..b48dd68 100644 --- a/src/gpu_rent/config.py +++ b/src/gpu_rent/config.py @@ -14,6 +14,7 @@ from gpu_rent.paths import ( default_ssh_key_path, env_path, extensions_manifest_path, + llamacpp_models_manifest_path, migrate_legacy_if_needed, models_manifest_path, ollama_models_manifest_path, @@ -84,6 +85,7 @@ class Config: llm_runtime: str ollama_models_manifest: Path + llamacpp_models_manifest: Path ollama_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() or str(ollama_models_manifest_path()) ).expanduser() + llamacpp_manifest = Path( + (os.environ.get("LLAMACPP_MODELS_MANIFEST") or "").strip() + or str(llamacpp_models_manifest_path()) + ).expanduser() try: 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), llm_runtime=llm_runtime, ollama_models_manifest=ollama_manifest, + llamacpp_models_manifest=llamacpp_manifest, ollama_local_port=_as_int(os.environ.get("OLLAMA_LOCAL_PORT"), 17811), llamacpp_local_port=_as_int(os.environ.get("LLAMACPP_LOCAL_PORT"), 17812), default_flavor_id=(os.environ.get("DEFAULT_FLAVOR_ID") or "").strip(), diff --git a/src/gpu_rent/llm_runtime.py b/src/gpu_rent/llm_runtime.py index 5974fbb..2564010 100644 --- a/src/gpu_rent/llm_runtime.py +++ b/src/gpu_rent/llm_runtime.py @@ -5,14 +5,19 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import Any +from urllib.parse import unquote, urlparse 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"}) -# Presets for setup / interactive up (prompt help: RU + low refusal). OLLAMA_PRESETS: dict[str, list[str]] = { "recommended": ["huihui_ai/qwen2.5-abliterate:7b"], "light": ["qwen2.5:3b"], @@ -29,6 +34,26 @@ PRESET_HELP = ( "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) class OllamaModelEntry: @@ -36,6 +61,13 @@ class OllamaModelEntry: default: bool = False +@dataclass(frozen=True) +class LlamaCppModelEntry: + url: str + filename: str | None = None + default: bool = False + + def normalize_runtime(value: str | None) -> str: raw = (value or "none").strip().lower().replace("-", "").replace("_", "") if raw in {"", "none", "off", "no", "0"}: @@ -54,7 +86,6 @@ def decide_runtime( llamacpp_flag: bool, from_config: str, ) -> str: - """CLI flags win over config/vars.""" if ollama_flag and llamacpp_flag: raise ValueError("укажи только --ollama или --llamacpp, не оба") if ollama_flag: @@ -93,6 +124,49 @@ def parse_ollama_models(path: Path) -> list[OllamaModelEntry]: 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: key = (preset or "recommended").strip().lower() 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") +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: dest = ollama_models_manifest_path() if dest.is_file(): @@ -125,6 +219,18 @@ def ensure_ollama_manifest_from_example() -> Path: 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: runtime = normalize_runtime(getattr(cfg, "llm_runtime", "none")) 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: - runtime = normalize_runtime(runtime) - line = f"LLM_RUNTIME={runtime}\n" - if not vars_file.is_file(): - 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") + from gpu_rent.varsfile import upsert_vars + + upsert_vars(vars_file, {"LLM_RUNTIME": normalize_runtime(runtime)}) diff --git a/src/gpu_rent/paths.py b/src/gpu_rent/paths.py index 019aaf0..3e91575 100644 --- a/src/gpu_rent/paths.py +++ b/src/gpu_rent/paths.py @@ -56,6 +56,14 @@ def ollama_models_example_path() -> Path: 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: return app_root() / "extensions.yaml" diff --git a/src/gpu_rent/provision.py b/src/gpu_rent/provision.py index fa0f191..fe3f1aa 100644 --- a/src/gpu_rent/provision.py +++ b/src/gpu_rent/provision.py @@ -372,6 +372,48 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None: ) elif runtime == "llamacpp": _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") run_script_sudo( cfg, diff --git a/src/gpu_rent/remote/llamacpp_fetch.py b/src/gpu_rent/remote/llamacpp_fetch.py new file mode 100644 index 0000000..0e7ebca --- /dev/null +++ b/src/gpu_rent/remote/llamacpp_fetch.py @@ -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()) diff --git a/src/gpu_rent/session.py b/src/gpu_rent/session.py index 4af50f4..47e7c5a 100644 --- a/src/gpu_rent/session.py +++ b/src/gpu_rent/session.py @@ -33,7 +33,7 @@ from gpu_rent.inventory import ( pick_volume_type, 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.lock import SessionLock from gpu_rent.os_client import ( @@ -173,6 +173,7 @@ def cmd_up( adopt: bool = False, update: bool | None = None, confirm: Callable[[str], bool] | None = None, + ask: Callable[[str, str], str] | None = None, log: Log = _log_default, ) -> SessionState: 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) + 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 = ( f"Создать {'preemptible ' if spot else ''}GPU {picked.name} " f"в {cfg.gpu_rent_az}, образ {getattr(image, 'name', image.id)}, " diff --git a/src/gpu_rent/setup_wizard.py b/src/gpu_rent/setup_wizard.py index b69fd7c..69dbadd 100644 --- a/src/gpu_rent/setup_wizard.py +++ b/src/gpu_rent/setup_wizard.py @@ -7,16 +7,21 @@ from collections.abc import Callable from pathlib import Path from gpu_rent.llm_runtime import ( + LLAMACPP_PRESET_HELP, PRESET_HELP, append_vars_llm_runtime, + ensure_llamacpp_manifest_from_example, ensure_ollama_manifest_from_example, normalize_runtime, + write_llamacpp_models_preset, write_ollama_models_preset, ) from gpu_rent.paths import ( app_root, env_path, extensions_manifest_path, + llamacpp_models_example_path, + llamacpp_models_manifest_path, models_manifest_path, ollama_models_example_path, ollama_models_manifest_path, @@ -59,6 +64,12 @@ def run_setup( _copy_if_missing( 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 if runtime is None: @@ -86,6 +97,22 @@ def run_setup( else: write_ollama_models_preset(ollama_models_manifest_path(), 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 if do_wd is None and confirm: diff --git a/src/gpu_rent/ux.py b/src/gpu_rent/ux.py index 11e4777..22a3208 100644 --- a/src/gpu_rent/ux.py +++ b/src/gpu_rent/ux.py @@ -3,12 +3,15 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass from typing import Any from gpu_rent.config import Config from gpu_rent.inventory import FlavorInfo, looks_like_gpu, rank_flavors, resolve_flavor Log = Callable[[str], None] +Ask = Callable[[str, str], str] +Confirm = Callable[[str], bool] def list_ranked_flavors(flavors: list[Any], cfg: Config) -> list[FlavorInfo]: @@ -61,6 +64,93 @@ def print_up_preview( 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( cfg: Config, flavors: list[Any], diff --git a/src/gpu_rent/varsfile.py b/src/gpu_rent/varsfile.py index 83cc4e1..86347e2 100644 --- a/src/gpu_rent/varsfile.py +++ b/src/gpu_rent/varsfile.py @@ -41,6 +41,40 @@ def apply_vars_file(path: Path, *, override: bool = False) -> dict[str, str]: 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]: if not value or not value.strip(): return [] diff --git a/tests/test_llamacpp_models.py b/tests/test_llamacpp_models.py new file mode 100644 index 0000000..bd9f640 --- /dev/null +++ b/tests/test_llamacpp_models.py @@ -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) == [] diff --git a/tests/test_server_plan.py b/tests/test_server_plan.py new file mode 100644 index 0000000..e46a087 --- /dev/null +++ b/tests/test_server_plan.py @@ -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"