Files

352 lines
14 KiB
Python

"""Binary acquisition for the managed llama.cpp runtime.
llama.cpp publishes per-tag assets (rolling ``bNNNN`` tags, no semver).
Backends are dlopen'd plugins, so a runtime = CPU/base zip + backend zip
extracted into one directory, plus the cudart runtime zip on Windows CUDA
(end users have no CUDA toolkit). We pin the tag in config, sha256-verify
every download, and keep the previous tag for rollback (N-1).
Layout: ``$HERMES_HOME/runtimes/llamacpp/<tag>/<backend>/<binaries>``
with a ``manifest.json`` recording zips, sha256s, and the verified
llama-server version string.
"""
from __future__ import annotations
import hashlib
import json
import logging
import platform
import shutil
import subprocess
import urllib.request
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
RELEASE_URL = "https://github.com/ggml-org/llama.cpp/releases/download/{tag}/{asset}"
# Windows CUDA zips ship per CUDA major; the runtime zip must be paired with
# its cudart zip so end users need no toolkit. 13.3 verified on 13.1 and
# 13.2 drivers.
_WIN_CUDA_VERSION = "13.3"
# arm64 Windows CUDA prebuilts landed upstream (~b1036x) on CUDA 13.4 —
# verified against live asset lists (b10362, b10630, b10679). Tags at or before
# b10290 don't have them; resolution succeeds and the download 404s
# honestly on such tags, which only arises if a user pins backward.
_WIN_CUDA_VERSION_ARM64 = "13.4"
# Fallback when the config section is missing entirely (deep-merge normally
# guarantees the key). Single source: DEFAULT_CONFIG owns the shipped tag.
def default_tag() -> str:
from hermes_cli.config_defaults import DEFAULT_CONFIG
return DEFAULT_CONFIG["local_runtime"]["tag"]
class BinaryResolutionError(RuntimeError):
"""No usable asset combination for this platform/backend."""
@dataclass
class AssetPlan:
"""The exact zips one runtime install needs, in extraction order."""
tag: str
backend: str # cuda | metal | vulkan | hip | cpu
assets: list[str] = field(default_factory=list)
@property
def install_dir(self) -> Path:
return runtimes_root() / self.tag / self.backend
def runtimes_root() -> Path:
"""Machine-scoped, deliberately NOT profile-scoped. Engine binaries,
presets, and server state describe this machine's hardware and its one
managed server (stable port) — a second profile re-downloading the
engine or fighting over the port would be the bug. Profile-scoped
things (which model is the default, enabled) live in each profile's
config.yaml as ever."""
from hermes_constants import get_default_hermes_root
return get_default_hermes_root() / "runtimes" / "llamacpp"
def installed_tags() -> list[str]:
"""Tags with a verified install (manifest carries verified_version),
newest first by release number. The boot ladder and the update check
both read installed-ness from here — one resolver, every caller."""
root = runtimes_root()
if not root.exists():
return []
found: list[str] = []
for entry in root.iterdir():
if not entry.is_dir() or entry.name == "downloads":
continue
for manifest in entry.glob("*/manifest.json"):
try:
if json.loads(manifest.read_text(encoding="utf-8")).get("verified_version"):
found.append(entry.name)
break
except (json.JSONDecodeError, OSError):
continue
def _release_number(tag: str) -> int:
digits = "".join(ch for ch in tag if ch.isdigit())
return int(digits) if digits else 0
return sorted(set(found), key=_release_number, reverse=True)
def _host_os_arch() -> tuple[str, str]:
"""(os, arch) normalized to release-asset vocabulary.
PITFALL: PROCESSOR_ARCHITECTURE lies under x64 emulation on
ARM64 Windows. platform.machine() reads the same env on some Pythons, so
on Windows prefer PROCESSOR_IDENTIFIER's text when present.
"""
system = platform.system().lower()
os_name = {"windows": "win", "darwin": "macos", "linux": "ubuntu"}.get(system, system)
machine = platform.machine().lower()
arch = "arm64" if machine in ("arm64", "aarch64") else "x64"
if os_name == "win":
import os as _os
ident = _os.environ.get("PROCESSOR_IDENTIFIER", "")
if "armv8" in ident.lower() or "arm " in ident.lower():
arch = "arm64"
return os_name, arch
def select_backend(gpu_vendor: str | None, os_name: str | None = None) -> str:
"""Backend choice per design: CUDA if NVIDIA, Metal on macOS, Vulkan if
a non-NVIDIA GPU is present, else CPU. ``--list-devices`` validates the
choice post-install; the supervisor's touch generation is ground truth."""
if os_name is None:
os_name, _ = _host_os_arch()
if os_name == "macos":
return "metal"
vendor = (gpu_vendor or "").lower()
if "nvidia" in vendor:
return "cuda"
if vendor in ("amd", "intel") or "radeon" in vendor or "arc" in vendor:
return "vulkan"
return "cpu"
def resolve_assets(tag: str, backend: str, os_name: str | None = None,
arch: str | None = None) -> AssetPlan:
"""Compose the asset list for (tag, backend, platform).
Raises BinaryResolutionError for combinations the release does not ship
(a platform/backend pair upstream publishes no artifact for). Callers
fall back down the backend ladder: cuda -> vulkan -> cpu.
"""
host_os, host_arch = _host_os_arch()
os_name = os_name or host_os
arch = arch or host_arch
plan = AssetPlan(tag=tag, backend=backend)
if os_name == "macos":
# macOS tarballs are unified (Metal built in).
plan.assets = [f"llama-{tag}-bin-macos-{arch}.tar.gz"]
return plan
if os_name == "ubuntu":
if backend == "cuda":
# No prebuilt Linux CUDA zips at current tags — Linux CUDA users
# build from source or use vulkan; resolver is honest about it.
raise BinaryResolutionError(
f"no prebuilt linux CUDA asset at {tag}; use vulkan/cpu or a source build")
suffix = {"vulkan": f"vulkan-{arch}", "hip": f"rocm-7.2-{arch}",
"cpu": arch}.get(backend)
if suffix is None:
raise BinaryResolutionError(f"unsupported linux backend {backend}")
plan.assets = [f"llama-{tag}-bin-ubuntu-{suffix}.tar.gz"]
return plan
if os_name == "win":
if backend == "cuda":
cuda_ver = _WIN_CUDA_VERSION_ARM64 if arch == "arm64" else _WIN_CUDA_VERSION
plan.assets = [
f"llama-{tag}-bin-win-cuda-{cuda_ver}-{arch}.zip",
f"cudart-llama-bin-win-cuda-{cuda_ver}-{arch}.zip",
]
elif backend == "vulkan":
if arch == "arm64":
raise BinaryResolutionError(f"no win-vulkan-arm64 asset at {tag}")
plan.assets = [f"llama-{tag}-bin-win-vulkan-x64.zip"]
elif backend == "hip":
plan.assets = [f"llama-{tag}-bin-win-hip-radeon-x64.zip"]
elif backend == "cpu":
plan.assets = [f"llama-{tag}-bin-win-cpu-{arch}.zip"]
else:
raise BinaryResolutionError(f"unsupported windows backend {backend}")
return plan
raise BinaryResolutionError(f"unsupported platform {os_name}-{arch}")
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 22), b""):
h.update(chunk)
return h.hexdigest()
def _download(url: str, dest: Path,
progress: "Callable[[int, int], None] | None" = None) -> None:
"""Stream url -> dest. ``progress(done_bytes, total_bytes)`` ticks per
chunk (total 0 when the server sends no Content-Length) — a several-
hundred-MB archive on a slow line must never look hung."""
logger.info("downloading %s", url)
tmp = dest.with_suffix(dest.suffix + ".part")
with urllib.request.urlopen(url, timeout=120) as r, open(tmp, "wb") as f:
total = int(r.headers.get("Content-Length") or 0)
done = 0
while True:
chunk = r.read(1 << 20)
if not chunk:
break
f.write(chunk)
done += len(chunk)
if progress is not None:
progress(done, total)
tmp.replace(dest)
def _extract(archive: Path, dest: Path,
progress: "Callable[[int, int], None] | None" = None) -> None:
"""Extract member by member so ``progress(done, total)`` can tick in
uncompressed bytes — big archives take real time on laptop disks."""
if archive.name.endswith(".zip"):
with zipfile.ZipFile(archive) as z:
members = z.infolist()
total = sum(m.file_size for m in members)
done = 0
for m in members:
z.extract(m, dest)
done += m.file_size
if progress is not None:
progress(done, total)
else:
import tarfile
with tarfile.open(archive) as t:
members = t.getmembers()
total = sum(m.size for m in members)
done = 0
for m in members:
t.extract(m, dest, filter="data")
done += m.size
if progress is not None:
progress(done, total)
def server_binary(install_dir: Path) -> Path:
"""Locate llama-server within an extracted runtime (zips differ in
whether they nest a build/bin directory)."""
names = ("llama-server.exe", "llama-server")
for name in names:
direct = install_dir / name
if direct.exists():
return direct
for name in names:
hits = sorted(install_dir.rglob(name))
if hits:
return hits[0]
raise BinaryResolutionError(f"llama-server not found under {install_dir}")
def verify_install(install_dir: Path, tag: str) -> str:
"""Run --version; require the tag's build number in the output.
(The binary prints the tag WITHOUT the 'b' prefix.)"""
exe = server_binary(install_dir)
out = subprocess.run([str(exe), "--version"], capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=60, cwd=str(exe.parent))
text = (out.stdout + out.stderr).strip()
if tag.lstrip("b") not in text:
raise BinaryResolutionError(
f"version check failed for {exe}: expected {tag}, got: {text[:120]}")
return text.splitlines()[0] if text else ""
def prune_old_tags(keep: list[str]) -> None:
"""Retain only the tags in ``keep`` (current + previous — N-1 rollback).
The shared ``downloads/`` archive cache is not a tag and always survives."""
root = runtimes_root()
if not root.exists():
return
for entry in root.iterdir():
if entry.is_dir() and entry.name != "downloads" and entry.name not in keep:
shutil.rmtree(entry, ignore_errors=True)
logger.info("pruned old runtime %s", entry.name)
def ensure_runtime_installed(tag: str, backend: str,
expected_sha256: dict[str, str] | None = None,
progress: "Callable[[str, int, int, str], None] | None" = None) -> Path:
"""Idempotent: resolve, download, verify, extract, version-check.
``expected_sha256`` maps asset name -> hash when the catalog pins them;
without pins the computed hash is recorded in the manifest (trust on
first download, verified on every reinstall).
``progress(stage, done_bytes, total_bytes, label)`` ticks through the
slow parts — stage is "download" | "extract" | "verify", label is the
asset counter ("1/2") when the plan has several archives.
Returns the install directory containing llama-server.
"""
plan = resolve_assets(tag, backend)
install_dir = plan.install_dir
manifest_path = install_dir / "manifest.json"
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest.get("verified_version"):
return install_dir
except (json.JSONDecodeError, OSError):
pass # damaged manifest -> reinstall
install_dir.mkdir(parents=True, exist_ok=True)
downloads = runtimes_root() / "downloads"
downloads.mkdir(parents=True, exist_ok=True)
recorded: dict[str, str] = {}
n_assets = len(plan.assets)
for i, asset in enumerate(plan.assets, 1):
label = f"{i}/{n_assets}" if n_assets > 1 else ""
archive = downloads / asset
if not archive.exists():
_download(RELEASE_URL.format(tag=tag, asset=asset), archive,
progress=(lambda d, t, _l=label: progress("download", d, t, _l))
if progress is not None else None)
if progress is not None:
progress("verify", 0, 0, label)
digest = _sha256(archive)
expected = (expected_sha256 or {}).get(asset)
if expected and digest != expected:
archive.unlink(missing_ok=True)
raise BinaryResolutionError(
f"sha256 mismatch for {asset}: expected {expected}, got {digest}")
recorded[asset] = digest
_extract(archive, install_dir,
progress=(lambda d, t, _l=label: progress("extract", d, t, _l))
if progress is not None else None)
if progress is not None:
progress("verify", 0, 0, "")
version = verify_install(install_dir, tag)
manifest_path.write_text(json.dumps({
"tag": tag, "backend": plan.backend, "assets": recorded,
"verified_version": version,
}, indent=2), encoding="utf-8")
logger.info("installed llama.cpp %s (%s): %s", tag, backend, version)
return install_dir