Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user