feat(local-runtime): model catalog and dashboard routes

A curated model catalog priced for the machine it's viewed on, and the
/api/local-models/* routes the desktop consumes.

- catalog: each model ships a quant ladder (Q8 down to Q4, best first)
  with exact sizes and sha256s pinned from Hugging Face LFS metadata.
  Selection picks the highest-quality build whose weights + 64K-floor KV
  fit GPU memory entirely; machines that can't get Q4 spilled to system
  RAM; refusal only when even Q4 exceeds GPU + RAM. Nothing below Q4
  ships — the quality loss is too severe for a first local-AI
  experience. Split-GGUF variants, vision projectors, and spec-decode
  draft models download as a unit, each file verified.
- routes: status (cheap, poll-safe), hardware, catalog (each row carries
  plain-language fit facts the UI shows verbatim), download jobs with
  aggregate byte progress that survive the pane unmounting, activate
  (start server, set as main model — the click is the opt-in), eject,
  delete (removes every staged file), and server on/off. Downloads run
  8 parallel ranged connections and verify sha256 before use; a running
  router only scans models at spawn, so staging changes bounce it.
- inventory: staged local models appear as a provider row in the model
  picker payload every surface consumes; no credential — a local server
  is authenticated by reachability.

Catalog reachability (repos, filenames, live-sha drift) is covered by an
opt-in network test gated on HERMES_TEST_NETWORK=1.
This commit is contained in:
emozilla 2026-08-14 01:24:36 -04:00
parent b95e38757a
commit aa4bf1ec2b
7 changed files with 1817 additions and 0 deletions

View File

@ -200,6 +200,17 @@ def build_models_payload(
excluded_providers=ctx.excluded_providers or [],
)
# Managed local runtime: staged GGUFs are selectable like any provider's
# models. list_authenticated_providers can't know about them (no
# credential, no custom_providers entry — the credential is
# reachability), so inject the row here where every picker surface
# inherits it. Present whenever models are staged; picking one routes
# through the llamacpp alias -> managed/detected server resolution.
local_row = _local_runtime_row(ctx)
if local_row is not None:
rows = [r for r in rows if str(r.get("slug", "")).lower() != "llamacpp"]
rows.append(local_row)
moa_row = _moa_provider_row(ctx.current_provider)
if moa_row is not None:
rows = [moa_row] + [r for r in rows if str(r.get("slug", "")).lower() != "moa"]
@ -825,6 +836,54 @@ def _apply_pricing(
row["unavailable_models"] = []
def _local_runtime_row(ctx: "ConfigContext") -> dict | None:
"""Build the ``llamacpp`` provider row from staged local models.
Present whenever GGUFs are staged in the managed models directory
downloaded models must be selectable even before the server is running
(selection starts it via the runtime_provider seam / activate flow).
Returns ``None`` when nothing is staged.
"""
try:
from hermes_cli.local_runtime.bootstrap import staged_model_ids
staged = staged_model_ids()
if not staged:
return None
current = (ctx.current_provider or "").strip().lower() in (
"llamacpp", "llama.cpp", "llama-cpp")
if not current:
# A LIVE session on the managed server reports provider "custom"
# (the resolution seam's label) with the managed base_url. Match
# on the endpoint so the picker still marks this row current —
# otherwise the session the user is chatting in shows no
# selection.
try:
from hermes_cli.local_runtime.endpoint import _state_endpoint
managed = _state_endpoint()
current = bool(
managed
and (ctx.current_base_url or "").strip().rstrip("/")
== managed["base_url"].rstrip("/"))
except Exception:
current = False
return {
"slug": "llamacpp",
"name": "Local (llama.cpp)",
"is_current": current,
"is_user_defined": False,
"models": staged,
"total_models": len(staged),
"source": "local-runtime",
"authenticated": True, # the credential is reachability
"auth_type": "local",
"warning": None,
}
except Exception:
return None
def _moa_provider_row(current_provider: str = "") -> dict | None:
"""Build the virtual ``moa`` provider row for model pickers.

View File

@ -0,0 +1,378 @@
"""Curated starter catalog for the managed local runtime.
Small and honest: every entry carries the estimator inputs (measured on
real GGUFs) so the picker can price a model BEFORE the user downloads
gigabytes. Once a file is on disk, profile_from_gguf() is the authority
and the catalog numbers are only used for the download decision. Entries
whose base config is gated upstream carry a same-family conservative
prior (commented) the GGUF header corrects it at load time.
Each model ships a QUANT LADDER (variants, best quality first). The ladder
floors at UD-Q4_K_XL below Q4 the quality loss is too severe to ship as
someone's first local-AI experience. Selection is hardware-aware: pick the
highest-quality variant that zero-spills at the 64K floor on this machine;
else Q4 spilled (priced honestly by the fit policy); refuse only when even
Q4 fails the physics check.
Validation lifecycle: rungs proven end-to-end on real hardware are marked
validated. Day-0 entries ship before that proof under the "day-0" tag
ensure_model_ready's touch generation still gates every first load at
runtime. The contract test requires every ladder floor to be Q4 AND
(validated OR day-0).
Multi-file models: variants may carry split-GGUF parts (llama-server loads
from the first part; all parts download together, each sha-verified).
Entries may carry an mmproj (vision projector) and a speculative-decode
draft model both download alongside the weights. Spec decode is enabled
only when the launch decision spills, where its speedup is largest.
sha256s are pinned from HF LFS metadata (the lfs oid IS the file sha256),
reviewed like a version bump parsed data, never executed commands.
This is deliberately not a live registry feed: entries are reviewed like a
version bump (the same policy governs vendor recipe ingestion parsed
data, never executed commands).
Vendor recipes overlay: a per-SKU recipes repo may SUPPLEMENT these
entries where applicable vendor SKUs only, never the base layer for
other platforms. A recipe may enrich identity (GGUF/quant/sha), perf
hints (-b/-ub, spec-decode), and sampling defaults; it never carries
context/slots/placement/serving flags (the fit policy owns those).
Resolution: exact SKU -> GPU-class bucket -> fit-only. Snapshot-synced,
reviewed like a tag bump.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import PurePosixPath
from hermes_cli.local_runtime.context_policy import FLOOR
from hermes_cli.local_runtime.estimator import (
HardwareBudget,
LayerKind,
ModelProfile,
ctx_bytes,
)
_GIB = 1 << 30
_PART_SUFFIX = re.compile(r"-\d{5}-of-\d{5}$")
@dataclass(frozen=True)
class AssetFile:
"""One downloadable file: repo-relative path, exact bytes, sha256.
``local`` overrides the on-disk name (repos reuse generic names like
mmproj-BF16.gguf across models). Non-model extras live under the
models dir's assets/ subdirectory so the router never lists them."""
path: str # repo-relative (may include a subdir)
size_bytes: int
sha256: str
local: str | None = None
@property
def local_name(self) -> str:
return self.local or PurePosixPath(self.path).name
@dataclass(frozen=True)
class QuantVariant:
"""One downloadable build of a model. Ordered best-quality-first in
CatalogEntry.variants. Split GGUFs list every part in files; the model
loads from the first part."""
quant: str # e.g. "UD-Q8_K_XL"
files: tuple # AssetFile, first = the load target
validated: bool = False # proven end-to-end on real hardware
@property
def model_id(self) -> str:
stem = PurePosixPath(self.files[0].path).name.removesuffix(".gguf")
return _PART_SUFFIX.sub("", stem)
@property
def size_bytes(self) -> int:
return sum(f.size_bytes for f in self.files)
@property
def weights_bytes(self) -> int:
"""Pre-download weights estimate: GGUF bytes ≈ tensor bytes + a
small header (<2%) a safe, slightly conservative stand-in until
profile_from_gguf reads the real table."""
return self.size_bytes
@dataclass(frozen=True)
class CatalogEntry:
id: str # stable family id (variant-independent)
display_name: str
description: str # one line, plain language
repo: str # HF repo
variants: tuple # QuantVariant, best first
# Estimator inputs (measured or config-derived; quant changes weights,
# never KV). Entries with gated upstream configs carry a conservative
# same-family prior — the GGUF header is the authority after download.
n_ctx_train: int
full_layers: int
recurrent_layers: int
per_layer_f16: int # KV bytes/token per full-attention layer
swa_layers: int = 0
swa_window: int = 0
moe: bool = False
mtp: bool = False # ships MTP heads (spec decode iff spilled)
mmproj: "AssetFile | None" = None # vision projector, downloads with model
draft: "AssetFile | None" = None # spec-decode draft model (e.g. DSpark)
sampling: dict = field(default_factory=dict) # INI long-form launch defaults
tags: tuple = field(default_factory=tuple)
def profile(self, variant: QuantVariant) -> ModelProfile:
layers = ([(LayerKind.FULL, self.per_layer_f16)] * self.full_layers
+ [(LayerKind.SWA, self.per_layer_f16)] * self.swa_layers
+ [(LayerKind.RECURRENT, 0)] * self.recurrent_layers)
return ModelProfile(
name=variant.model_id, weights_bytes=variant.weights_bytes,
embd_table_bytes=0, n_ctx_train=self.n_ctx_train,
layers=layers, swa_window=self.swa_window, moe=self.moe)
def download_files(self, variant: QuantVariant) -> tuple:
"""Everything a download job fetches for this variant, in order."""
extras = tuple(a for a in (self.mmproj, self.draft) if a is not None)
return tuple(variant.files) + extras
def download_bytes(self, variant: QuantVariant) -> int:
return sum(f.size_bytes for f in self.download_files(variant))
@dataclass(frozen=True)
class VariantChoice:
"""Selection result: which build this machine should download and why.
reason_key is a UI-copy discriminator, not display text."""
variant: QuantVariant
zero_spill: bool
reason_key: str # "best-fits" | "smallest-fits-spilled"
def select_variant(entry: CatalogEntry, budget: HardwareBudget) -> VariantChoice | None:
"""Best build for this hardware, per the ladder policy.
1. Highest-quality variant whose weights + 64K-floor KV zero-spill in
usable VRAM (quality is free when it fits take it).
2. Else Q4 spilled the floor guarantee stands, fit spills weights,
picker prices it honestly (slow beats lobotomized).
3. None: even Q4 fails physics (true refusal).
"""
floor_kv = None
for variant in entry.variants:
profile = entry.profile(variant)
if floor_kv is None:
floor_kv = ctx_bytes(profile, min(FLOOR, entry.n_ctx_train or FLOOR))
if variant.weights_bytes + floor_kv <= budget.usable_vram_bytes:
return VariantChoice(variant=variant, zero_spill=True,
reason_key="best-fits")
smallest = min(entry.variants, key=lambda v: v.size_bytes)
needed = smallest.weights_bytes + (floor_kv or 0)
if needed <= budget.usable_vram_bytes + budget.ram_available_bytes:
return VariantChoice(variant=smallest, zero_spill=False,
reason_key="smallest-fits-spilled")
return None
def _v(quant: str, *files, validated: bool = False) -> QuantVariant:
return QuantVariant(quant=quant,
files=tuple(AssetFile(*f) for f in files),
validated=validated)
# Ordered: recommended first. File bytes + sha256s pinned from HF LFS
# metadata.
CATALOG: tuple[CatalogEntry, ...] = (
CatalogEntry(
id="qwen3.6-27b",
display_name="Qwen3.6 27B",
description="Best all-round agent model; sees images; long context stays fast",
repo="unsloth/Qwen3.6-27B-GGUF",
variants=(
_v("UD-Q8_K_XL",
("Qwen3.6-27B-UD-Q8_K_XL.gguf", 35325163744,
"19a2f4733a863088bc06665bf307dca95f7d4370b4d8690340cdff9992fe48c6")),
_v("UD-Q6_K_XL",
("Qwen3.6-27B-UD-Q6_K_XL.gguf", 25636485344,
"8746881d40f280b1b6b858c656a347c754ed3d9cc8d2e1ad46b3635b87f611f8")),
_v("UD-Q5_K_XL",
("Qwen3.6-27B-UD-Q5_K_XL.gguf", 20038256864,
"ac310abf2895aa397121bad6c0be89466af41f0f1606a21c1131b110eeb19d0e")),
_v("UD-Q4_K_XL",
("Qwen3.6-27B-UD-Q4_K_XL.gguf", 17612564704,
"ff6941ded525b34eb159496762c29dd0ec6e71dc31b74d57e75d871a03eec259"),
validated=True),
),
n_ctx_train=262144,
full_layers=16, recurrent_layers=48, per_layer_f16=4096,
mmproj=AssetFile("mmproj-BF16.gguf", 931146304,
"05353347512982ee62317b9d8c89372bc815f4b4043580e7ef3ad411ec1a1cd3",
local="mmproj-Qwen3.6-27B-BF16.gguf"),
sampling={"temp": "1.0", "top-p": "0.95", "top-k": "20", "min-p": "0.0"},
tags=("recommended", "hybrid", "reasoning", "vision"),
),
CatalogEntry(
id="qwen3.6-35b-a3b",
display_name="Qwen3.6 35B-A3B",
description="Bigger mixture-of-experts with multi-token prediction; sees images",
repo="unsloth/Qwen3.6-35B-A3B-MTP-GGUF",
variants=(
_v("UD-Q8_K_XL",
("Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", 39099447584,
"6c6b816537abad90b250a0972b345466028d861ddfe316d5f0de31ca6440f781")),
_v("UD-Q6_K_XL",
("Qwen3.6-35B-A3B-UD-Q6_K_XL.gguf", 32611711264,
"35fce994cd36104a7dc1bd8a4bdf13778145664c00fdef6773aebc9246e5019c")),
_v("UD-Q5_K_XL",
("Qwen3.6-35B-A3B-UD-Q5_K_XL.gguf", 27159116064,
"9de9a9420f61a0bb59bb2ca1ea170a6a57f6821fa1deec915bcaef523730a919")),
# Validated on this repo's prior upload; upstream has since
# re-uploaded. Same model id + pipeline — re-verify at the
# next validation pass.
_v("UD-Q4_K_XL",
("Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf", 22853663008,
"55983c5a75a1ab969824077b3bb3de4146e82a9234072b48ad4e8f92ad3fe9f1"),
validated=True),
),
n_ctx_train=262144,
# Config-derived (Qwen/Qwen3.6-35B-A3B): 40 layers, 10 full-attn
# (interval 4) + 30 linear; KV heads 2 x head_dim 256.
full_layers=10, recurrent_layers=30, per_layer_f16=2048,
moe=True, mtp=True,
mmproj=AssetFile("mmproj-BF16.gguf", 902822528,
"da63cb47a76763c712393f8a017070188a304fa39f8aeea6edc629ed7b975cfa",
local="mmproj-Qwen3.6-35B-A3B-BF16.gguf"),
sampling={"temp": "1.0", "top-p": "0.95", "top-k": "20", "min-p": "0.0"},
tags=("hybrid", "moe", "mtp", "vision"),
),
CatalogEntry(
id="nemotron-3.5-lightning-30b",
display_name="Nemotron 3.5 Lightning 30B",
description="NVIDIA's fast tool-calling model; 1M-token context",
repo="unsloth/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-GGUF",
variants=(
_v("UD-Q8_K_XL",
("NVIDIA-Nemotron-3.5-Lightning-30B-A3B-UD-Q8_K_XL.gguf", 38615380032,
"48140cfb8bb6a38553275d334345f2638c119d979ab8b6d4afdc4b62d26d8219")),
_v("UD-Q6_K_XL",
("NVIDIA-Nemotron-3.5-Lightning-30B-A3B-UD-Q6_K_XL.gguf", 35004643392,
"36b8b0f882ec739f895fe56ab6b9b892e702ba33b7b368614ca4078331ddfc29")),
_v("UD-Q5_K_XL",
("NVIDIA-Nemotron-3.5-Lightning-30B-A3B-UD-Q5_K_XL.gguf", 30414829632,
"92dcaf682faf39fef906db7bad4000782001d089f28c4d6aecec6d6f1b4697ca")),
_v("UD-Q4_K_XL",
("NVIDIA-Nemotron-3.5-Lightning-30B-A3B-UD-Q4_K_XL.gguf", 25505724480,
"112bf957489a497c18f60bf8bd44ee1dfa05e87368b8ed7f68998a4e38f275c9")),
),
n_ctx_train=1048576,
# Prior from Nemotron-3-Nano (same hybrid family; base config gated
# upstream): 6 full-attn + 46 recurrent, ~1 KiB/tok/full-layer.
# Prior from the same hybrid family (measured: 1M ctx in ~3.2 GiB
# KV on the predecessor). GGUF header is
# the authority after download.
full_layers=6, recurrent_layers=46, per_layer_f16=1024,
moe=True, mtp=True,
sampling={"temp": "0.6", "top-p": "0.95", "min-p": "0.01"},
tags=("day-0", "long-context", "hybrid", "moe", "mtp"),
),
CatalogEntry(
id="muse-glimmer-30b",
display_name="Muse Glimmer 30B",
description="Meta's open vision model for coding and agent work",
repo="unsloth/Muse-Glimmer-30B-GGUF",
variants=(
_v("UD-Q8_K_XL",
("Muse-Glimmer-30B-UD-Q8_K_XL.gguf", 32300651040,
"e63bf23b7710ecdea2579e4b1de58980c4a2b446e8ecf48b782cfcefd2e31770")),
_v("UD-Q6_K_XL",
("Muse-Glimmer-30B-UD-Q6_K_XL.gguf", 26265362976,
"fb5f80d110c4fa932cc652e70873c0bd12c0954009038aa675e65086104c2739")),
_v("UD-Q5_K_XL",
("Muse-Glimmer-30B-UD-Q5_K_XL.gguf", 21789618976,
"97a66c4b41d9e778af7cdfa43508e08dbf765fb5049b740c69ad815e5191c637")),
_v("UD-Q4_K_XL",
("Muse-Glimmer-30B-UD-Q4_K_XL.gguf", 15878222368,
"82bece304887a313ece08400bc030f6066c7bff5b906b0cd40308ec8a409fd38")),
),
n_ctx_train=262144,
# Conservative dense prior (base config gated upstream): 30B-class
# dense, ~60 layers x 4 KiB/tok. Dense KV is the expensive shape —
# overestimating here keeps the zero-spill promise safe until the
# GGUF header corrects it.
full_layers=60, recurrent_layers=0, per_layer_f16=4096,
mmproj=AssetFile("mmproj-Muse-Glimmer-30B-BF16.gguf", 3849173728,
"d08cdcfa0b41d8e20554b52df404ba4f7b440d0bc502a90038508b6407df8ee1"),
sampling={"temp": "1.0", "top-p": "0.95", "top-k": "64"},
tags=("day-0", "vision", "dense"),
),
CatalogEntry(
id="deepseek-v4-flash",
display_name="DeepSeek V4 Flash",
description="Frontier-class model for machines with 128GB+ memory",
repo="unsloth/DeepSeek-V4-Flash-0731-GGUF",
variants=(
# Q8 is bit-lossless vs the official QAT checkpoint; Q4 keeps
# the MXFP4 experts bit-exact and only requants the other 4%.
_v("UD-Q8_K_XL",
("UD-Q8_K_XL/DeepSeek-V4-Flash-0731-UD-Q8_K_XL-00001-of-00005.gguf", 5257408,
"d13ce8f90855547bdaebe7312f531a1f2c4f822178d3103951f27fe884395cfa"),
("UD-Q8_K_XL/DeepSeek-V4-Flash-0731-UD-Q8_K_XL-00002-of-00005.gguf", 49215492960,
"3da2f2443063f83635986f9b67fa7e8e3d03c53b81a9a08d2007936612423610"),
("UD-Q8_K_XL/DeepSeek-V4-Flash-0731-UD-Q8_K_XL-00003-of-00005.gguf", 49700372160,
"7d622a7760d359ec9257b3493ad531e3bf0bfbe6f6533267e16e6dde8153ddce"),
("UD-Q8_K_XL/DeepSeek-V4-Flash-0731-UD-Q8_K_XL-00004-of-00005.gguf", 49466495968,
"6ed2bce452214f156b85e7c5f7d4fc242a3052f409d1b90a61422f60669c2de3"),
("UD-Q8_K_XL/DeepSeek-V4-Flash-0731-UD-Q8_K_XL-00005-of-00005.gguf", 13481997024,
"ea4727af4888fdca0fff796ec81ac2f3ebb43c310b2feb4798f41d82744b42ea")),
_v("UD-Q4_K_XL",
("UD-Q4_K_XL/DeepSeek-V4-Flash-0731-UD-Q4_K_XL-00001-of-00005.gguf", 5257408,
"d13ce8f90855547bdaebe7312f531a1f2c4f822178d3103951f27fe884395cfa"),
("UD-Q4_K_XL/DeepSeek-V4-Flash-0731-UD-Q4_K_XL-00002-of-00005.gguf", 48935523072,
"d5b61668950f4743aacd677675d7fcf7507dbe1db6d304e8ff97ed1f00827bee"),
("UD-Q4_K_XL/DeepSeek-V4-Flash-0731-UD-Q4_K_XL-00003-of-00005.gguf", 48980787136,
"9705db7e589f360685ca7bd48100b270d78d228d4f5aa980508f3b2778af5494"),
("UD-Q4_K_XL/DeepSeek-V4-Flash-0731-UD-Q4_K_XL-00004-of-00005.gguf", 49999168416,
"7f13a68e3ca64208454c4ba32cc2757c0cbe78e3e5576c3142bf7007ca97da42"),
("UD-Q4_K_XL/DeepSeek-V4-Flash-0731-UD-Q4_K_XL-00005-of-00005.gguf", 7174505088,
"ed0d93164d3784968d6ce40d6d201ba98337f16e7db1b31fe495b2b0f334cc09")),
),
n_ctx_train=1048576,
# Config-derived (deepseek-ai/DeepSeek-V4-Flash-0731): 43 layers,
# MLA compressed KV (rank 512 + 64 rope) ~1.15 KiB/tok/layer f16.
# Config shows sliding_window=128 with no per-layer map — priced
# all-full (conservative); GGUF header decides after download.
full_layers=43, recurrent_layers=0, per_layer_f16=1152,
moe=True,
draft=AssetFile("dspark-DeepSeek-V4-Flash-0731-Q8_0.gguf", 10896057440,
"2c7ac54b0b64a99df1f139a9f1371a00198265e1d6a614b77597d20a655a4249"),
sampling={"temp": "1.0", "top-p": "0.95", "min-p": "0.01"},
tags=("day-0", "long-context", "moe", "frontier"),
),
)
def catalog_by_id() -> dict[str, CatalogEntry]:
return {entry.id: entry for entry in CATALOG}
def find_variant(entry_id: str, model_id: str) -> QuantVariant | None:
entry = catalog_by_id().get(entry_id)
if entry is None:
return None
return next((v for v in entry.variants if v.model_id == model_id), None)
def find_entry_for_model(model_id: str) -> "tuple[CatalogEntry, QuantVariant] | None":
"""Locate the entry + variant that owns a staged model id."""
for entry in CATALOG:
for variant in entry.variants:
if variant.model_id == model_id:
return entry, variant
return None

View File

@ -0,0 +1,925 @@
"""Local-models dashboard routes — the desktop's window into the managed
llama.cpp runtime.
Everything here is designed for a first-run user on an RTX laptop: every
payload carries plain-language, pre-formatted facts the UI can show verbatim
(what will this model do ON THIS MACHINE, how big is the download, what is
the runtime doing right now), never raw internals the renderer would have to
interpret.
Long jobs (runtime install, model download) follow the repo's job pattern:
start-POST -> {job_id} -> GET poll with byte progress. Downloads are
sha256-verified; a hash mismatch deletes the file and reports it plainly.
"""
from __future__ import annotations
import json
import logging
import threading
import time
import urllib.request
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter()
_GIB = 1 << 30
_JOBS: Dict[str, Dict[str, Any]] = {}
_JOBS_LOCK = threading.Lock()
def _human_gb(n: int | float) -> str:
return f"{n / _GIB:.1f} GB"
def _job(kind: str, target: str, model_id: str | None = None) -> Dict[str, Any]:
job = {
"job_id": uuid.uuid4().hex[:12],
"kind": kind, # "runtime-install" | "model-download"
"target": target,
"model_id": model_id, # catalog id for downloads; None otherwise
"status": "running", # running | done | error
"phase": "starting", # human-readable step name
"detail": "",
"total_bytes": None,
"done_bytes": 0,
"started_at": time.time(),
"error": None,
}
with _JOBS_LOCK:
_JOBS[job["job_id"]] = job
return job
# ── fast download: ranged parallel streams ───────────────────
# One TCP stream to a CDN rarely fills a fast line; 8 ranged connections
# writing into a preallocated file saturate consumer gigabit. sha256 is
# computed in a sequential pass afterwards (NVMe read is seconds, and it
# keeps the hash independent of write ordering).
_DOWNLOAD_CONNECTIONS = 8
_CHUNK = 4 << 20
def _probe_range_support(url: str) -> int:
"""Total size when the server honors Range requests, else 0.
Auth-shaped failures raise with a plain-language message a 401/403
from the CDN means the repo is gated or the catalog entry names a
wrong repo, and the user deserves better than a bare status code.
"""
req = urllib.request.Request(url, headers={"Range": "bytes=0-0"})
try:
with urllib.request.urlopen(req, timeout=60) as r:
if r.status == 206:
content_range = r.headers.get("Content-Range", "")
if "/" in content_range:
return int(content_range.rsplit("/", 1)[1])
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
raise RuntimeError(
"The model host refused the download (gated or moved). "
"This is a catalog problem, not yours — please report it.") from exc
raise
except Exception: # noqa: BLE001
pass
return 0
def _model_id_for(gguf: Path) -> str:
"""Variant model id for a staged file (strips split-part suffixes)."""
import re
return re.sub(r"-\d{5}-of-\d{5}$", "", gguf.stem)
def _variant_files_on_disk(model_id: str) -> "list[Path]":
"""Every local file belonging to a staged model: all split parts plus
its catalog-declared assets (mmproj/draft) when present."""
from hermes_cli.local_runtime.bootstrap import assets_dir
from hermes_cli.local_runtime.catalog import find_entry_for_model
mdir = _models_dir()
files = [p for p in mdir.glob("*.gguf") if _model_id_for(p) == model_id]
hit = find_entry_for_model(model_id)
if hit is not None:
entry, _variant = hit
for asset in (entry.mmproj, entry.draft):
if asset is not None:
p = assets_dir() / asset.local_name
if p.exists():
files.append(p)
return files
def download_file(url: str, dest: Path, job: Dict[str, Any],
expected_sha256: str = "", *,
base_done: int = 0, keep_totals: bool = False) -> None:
"""Download url -> dest with byte progress on ``job``.
Ranged-parallel when the server supports it, single-stream fallback
otherwise. Verifies sha256 when given; a mismatch deletes the file and
raises with a plain-language message. Never leaves a .part behind.
Multi-file variants: ``base_done`` offsets the progress so this file's
bytes accumulate onto the files before it, and ``keep_totals=True``
stops the per-file size from overwriting the variant's total.
"""
import hashlib
import shutil
import threading as _threading
tmp = dest.with_suffix(".part")
dest.parent.mkdir(parents=True, exist_ok=True)
file_done = [0]
progress_lock = _threading.Lock()
def bump(n: int) -> None:
with progress_lock:
file_done[0] += n
job["done_bytes"] = base_done + file_done[0]
try:
total = _probe_range_support(url)
if total:
if not keep_totals:
job["total_bytes"] = total
# Preallocate so each worker writes at its own offset.
with open(tmp, "wb") as f:
f.truncate(total)
errors: list[Exception] = []
bounds = [(i * total // _DOWNLOAD_CONNECTIONS,
(i + 1) * total // _DOWNLOAD_CONNECTIONS - 1)
for i in range(_DOWNLOAD_CONNECTIONS)]
def fetch_range(start: int, end: int) -> None:
try:
req = urllib.request.Request(
url, headers={"Range": f"bytes={start}-{end}"})
with urllib.request.urlopen(req, timeout=120) as r, \
open(tmp, "r+b") as f:
f.seek(start)
while True:
chunk = r.read(_CHUNK)
if not chunk:
break
f.write(chunk)
bump(len(chunk))
except Exception as exc: # noqa: BLE001
errors.append(exc)
threads = [_threading.Thread(target=fetch_range, args=b, daemon=True,
name=f"lm-dl-{i}")
for i, b in enumerate(bounds)]
for t in threads:
t.start()
for t in threads:
t.join()
if errors:
raise errors[0]
if file_done[0] != total:
raise RuntimeError(
f"download incomplete ({file_done[0]} of {total} bytes)")
else:
# No range support: single stream, large chunks.
with urllib.request.urlopen(url, timeout=120) as r, open(tmp, "wb") as f:
length = int(r.headers.get("Content-Length") or 0)
if length and not keep_totals:
job["total_bytes"] = length
while True:
chunk = r.read(_CHUNK)
if not chunk:
break
f.write(chunk)
bump(len(chunk))
if expected_sha256:
job["phase"] = "verifying"
job["detail"] = "Checking file integrity"
digest = hashlib.sha256()
with open(tmp, "rb") as f:
for chunk in iter(lambda: f.read(16 << 20), b""):
digest.update(chunk)
if digest.hexdigest() != expected_sha256:
raise RuntimeError(
"Downloaded file failed its integrity check and was removed — try again")
shutil.move(str(tmp), str(dest))
except Exception:
tmp.unlink(missing_ok=True)
raise
def _models_dir() -> Path:
from hermes_cli.local_runtime.bootstrap import models_dir
return models_dir()
def _load_config() -> dict:
from hermes_cli.config import load_config
try:
return load_config()
except Exception: # noqa: BLE001
return {}
def _runtime_section() -> dict:
return (_load_config() or {}).get("local_runtime") or {}
# ── status: the one call the pane opens with ─────────────────
@router.get("/api/local-models/status")
async def local_models_status():
"""Cheap, immediate, never blocks on probes (responsiveness standard):
config state + installed runtime + staged models + supervisor state.
GPU facts come from /api/local-models/hardware (slower, polled)."""
from hermes_cli.local_runtime.binaries import (
default_tag,
installed_tags,
runtimes_root,
server_binary,
)
from hermes_cli.local_runtime.endpoint import _state_endpoint
section = _runtime_section()
configured_tag = section.get("tag") or default_tag()
have = installed_tags()
# The tag actually serving (boot ladder: configured if installed, else
# newest installed). Present tense for the pane header.
tag = configured_tag if configured_tag in have else (have[0] if have else configured_tag)
# A pending engine update exists when the user runs the local engine
# (enabled + something installed) and the configured tag — pinned or
# the Hermes-release default — is newer than anything on disk. The
# download is a button click, never automatic.
update_available = bool(
section.get("enabled") and have and configured_tag not in have)
runtime_installed = False
runtime_backend = None
root = runtimes_root() / tag
if root.exists():
for backend_dir in sorted(p for p in root.iterdir() if p.is_dir()):
try:
server_binary(backend_dir)
runtime_installed = True
runtime_backend = backend_dir.name
break
except Exception: # noqa: BLE001
continue
staged = []
mdir = _models_dir()
if mdir.exists():
from hermes_cli.local_runtime.bootstrap import staged_models
# Split models: report the whole variant's bytes, not one part's.
from hermes_cli.local_runtime.catalog import find_entry_for_model
for gguf in staged_models():
model_id = _model_id_for(gguf)
size = gguf.stat().st_size
hit = find_entry_for_model(model_id)
if hit is not None:
size = hit[1].size_bytes
staged.append({
"id": model_id,
"size_bytes": size,
"size_label": _human_gb(size),
})
running = _state_endpoint()
# Which staged models are resident right now (loaded in VRAM). Read
# from the live router when it's up; {} when down. Feeds the pane's
# Loaded pills and eject buttons.
loaded: Dict[str, str] = {}
if running is not None:
try:
import urllib.request as _url
req = _url.Request(
running["base_url"].rsplit("/v1", 1)[0] + "/models",
headers={"Authorization": f"Bearer {running.get('api_key', '')}"})
with _url.urlopen(req, timeout=3) as r:
data = json.loads(r.read())
loaded = {
m["id"]: m.get("status", {}).get("value", "unknown")
for m in data.get("data", [])
# Everything resident or becoming resident: 'loading' renders
# as its own state in the pane (a 20-GB load in flight is the
# single most important thing the pane can show).
if m.get("status", {}).get("value") in ("loaded", "ready", "loading")
}
except Exception as exc: # noqa: BLE001
# Never silent: an empty dict here renders as 'Not in memory'
# on a machine whose VRAM is visibly full.
logger.warning("loaded-models read failed: %r", exc)
loaded = {}
# The active main model, when it is one of ours (config authority: the
# same model.provider + model.default that /api/model/set writes).
active_model_id = None
try:
config = _load_config()
model_section = (config or {}).get("model") or {}
if str(model_section.get("provider", "")).strip().lower() in (
"llamacpp", "llama.cpp", "llama-cpp"):
active_model_id = str(
model_section.get("default") or model_section.get("name") or ""
).strip() or None
except Exception: # noqa: BLE001
pass
return {
"enabled": bool(section.get("enabled")),
"tag": tag,
"configured_tag": configured_tag,
"update_available": update_available,
"runtime_installed": runtime_installed,
"runtime_backend": runtime_backend,
"server_running": running is not None,
"server_base_url": (running or {}).get("base_url"),
"active_model_id": active_model_id,
"loaded_models": loaded,
"models": staged,
"models_dir": str(mdir),
}
# ── hardware: what this machine can do ───────────────────────
@router.get("/api/local-models/hardware")
async def local_models_hardware():
"""The budget as plain facts. Polled by the pane and the statusbar
resource item (throttled client-side)."""
from hermes_cli.local_runtime.hardware import probe_budget, _nvidia_vram, _ram_bytes
budget = probe_budget()
ram_total, ram_avail = _ram_bytes()
out = {
"uma": budget.uma,
"vram_total_bytes": budget.total_device_bytes,
"vram_usable_bytes": budget.usable_vram_bytes,
"ram_total_bytes": ram_total,
"ram_available_bytes": ram_avail,
"vram_label": _human_gb(budget.total_device_bytes),
"gpu_name": None,
"gpu_util_percent": None,
"vram_used_bytes": None,
}
# GPU identity + live utilization (NVIDIA; other vendors degrade to None
# and the UI hides those readouts).
try:
import subprocess
smi = subprocess.run(
["nvidia-smi", "--query-gpu=name,utilization.gpu,memory.used",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5)
if smi.returncode == 0 and smi.stdout.strip():
name, util, used_mib = (x.strip() for x in smi.stdout.strip().splitlines()[0].split(","))
out["gpu_name"] = name
out["gpu_util_percent"] = int(util)
out["vram_used_bytes"] = int(used_mib) << 20
except Exception: # noqa: BLE001
pass
return out
# ── catalog: priced for THIS machine before download ─────────
@router.get("/api/local-models/catalog")
async def local_models_catalog():
"""Every entry answers the user's three questions up front: how big is
the download, will it fit, and what context/speed shape will I get
computed from the catalog's measured numbers + this machine's
budget. Hardware-aware quant selection: the row advertises the BEST
build for this machine (highest quality that runs fully on the GPU at
the 64K floor; else the smallest that works, spilled and priced). No
entry is hidden; unaffordable models show WHY."""
from hermes_cli.local_runtime.catalog import CATALOG, select_variant
from hermes_cli.local_runtime.context_policy import initial_window
from hermes_cli.local_runtime.estimator import PhysicsRefusal
from hermes_cli.local_runtime.hardware import probe_budget
# Planning budget: price against machine capacity, not live-free VRAM.
# A loaded model must not make the catalog call every row unaffordable.
budget = probe_budget(planning=True)
mdir = _models_dir()
entries = []
for entry in CATALOG:
choice = select_variant(entry, budget)
# Any variant of this family already on disk counts as downloaded
# (split variants stage under their first part).
staged_ids = {_model_id_for(p) for p in mdir.glob("*.gguf")} if mdir.exists() else set()
downloaded_variant = next(
(v for v in entry.variants if v.model_id in staged_ids), None)
row: Dict[str, Any] = {
"id": entry.id,
"display_name": entry.display_name,
"description": entry.description,
"native_context": entry.n_ctx_train,
"native_context_label": f"{entry.n_ctx_train // 1024}K",
"tags": list(entry.tags),
"downloaded": downloaded_variant is not None,
"downloaded_model_id": downloaded_variant.model_id if downloaded_variant else None,
"downloaded_quant": downloaded_variant.quant if downloaded_variant else None,
"mtp": entry.mtp,
"vision": entry.mmproj is not None,
}
if choice is None:
smallest = min(entry.variants, key=lambda v: v.size_bytes)
smallest_total = entry.download_bytes(smallest)
row.update({
"fits": False,
"size_bytes": smallest_total,
"size_label": _human_gb(smallest_total),
"fit_summary": "Needs more memory than this machine has",
"fit_detail": (f"even the most compact build ({smallest.quant}, "
f"{_human_gb(smallest_total)}) exceeds GPU + system memory"),
})
entries.append(row)
continue
variant = choice.variant
profile = entry.profile(variant)
decision = initial_window(profile, budget)
download_total = entry.download_bytes(variant)
row.update({
"fits": True,
"model_id": variant.model_id,
"quant": variant.quant,
"quant_validated": variant.validated,
"size_bytes": download_total,
"size_label": _human_gb(download_total),
"variant_count": len(entry.variants),
})
if choice.reason_key == "best-fits":
best = entry.variants[0]
row["quant_reason"] = (
"Best quality build — runs fully on your GPU"
if variant.quant == best.quant
else f"Highest quality that runs fully on your GPU ({variant.quant})")
else:
row["quant_reason"] = (
f"Compact build sized for this machine ({variant.quant}) — "
"larger than GPU memory, runs slower")
if not isinstance(decision, PhysicsRefusal):
row["start_window"] = decision.window
row["start_window_label"] = f"{decision.window // 1024}K"
row["spilled"] = decision.spilled
if decision.window >= entry.n_ctx_train:
shape = f"runs at its full {row['native_context_label']} context"
else:
shape = (f"starts at {row['start_window_label']} and grows toward "
f"{row['native_context_label']} as you use it")
if decision.spilled:
shape += " (larger than your GPU memory — runs slower)"
row["fit_summary"] = shape
else:
row["fit_summary"] = row["quant_reason"]
entries.append(row)
return {"models": entries}
# ── runtime install (job) ────────────────────────────────────
class RuntimeInstallBody(BaseModel):
backend: Optional[str] = None # None/auto -> detect
@router.post("/api/local-models/runtime/install")
async def local_models_runtime_install(body: RuntimeInstallBody):
from hermes_cli.local_runtime.binaries import (
default_tag,
resolve_assets,
select_backend,
)
from hermes_cli.local_runtime.bootstrap import _detect_gpu_vendor
section = _runtime_section()
tag = section.get("tag") or default_tag()
backend = body.backend or section.get("backend", "auto")
if backend == "auto":
backend = select_backend(_detect_gpu_vendor())
# Resolve first so an impossible combination fails the POST, not the job.
try:
plan = resolve_assets(tag, backend)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc))
job = _job("runtime-install", f"llama.cpp {tag} ({backend})")
def _run():
try:
from hermes_cli.local_runtime.binaries import (
ensure_runtime_installed,
installed_tags,
prune_old_tags,
)
previous = installed_tags()
job["phase"] = "downloading"
job["detail"] = f"Fetching {len(plan.assets)} package(s) for {backend}"
ensure_runtime_installed(tag, backend)
# Engine update path: a server already running on an older tag
# moves to the new one now — the click was the consent. Fresh
# installs (no server) skip this; Use/boot handles their start.
restarted = False
try:
from hermes_cli.local_runtime.bootstrap import (
ensure_local_runtime,
get_supervisor,
shutdown_local_runtime,
)
sup = get_supervisor()
if sup is not None and previous and tag not in previous:
job["phase"] = "restarting"
job["detail"] = "Switching the running server to the new build"
shutdown_local_runtime()
ensure_local_runtime(_load_config(), force=True)
restarted = True
except Exception as exc: # noqa: BLE001
# The new build is installed either way; the next boot serves
# it. Never fail the job on the restart nicety.
logger.warning("post-update restart skipped: %s", exc)
# N-1 retention, only after the new tag verified: keep it and the
# newest previous build as the rollback pin target.
try:
keep = [tag] + [t for t in previous if t != tag][:1]
prune_old_tags(keep)
except Exception as exc: # noqa: BLE001
logger.warning("runtime prune skipped: %s", exc)
job["phase"] = "done"
job["status"] = "done"
job["detail"] = (f"llama.cpp {tag} ready ({backend})"
+ (" — server restarted on the new build" if restarted else ""))
except Exception as exc: # noqa: BLE001
logger.warning("runtime install failed: %s", exc)
job["status"] = "error"
job["error"] = str(exc)
threading.Thread(target=_run, daemon=True, name="lr-runtime-install").start()
return {"job_id": job["job_id"], "backend": backend, "tag": tag}
# ── model download (job with byte progress + sha256) ─────────
class ModelDownloadBody(BaseModel):
model_id: str
@router.post("/api/local-models/download")
async def local_models_download(body: ModelDownloadBody):
"""Accepts either a family id (downloads this machine's selected
variant) or an exact variant model_id."""
from hermes_cli.local_runtime.catalog import (
CATALOG,
catalog_by_id,
select_variant,
)
from hermes_cli.local_runtime.hardware import probe_budget
entry = catalog_by_id().get(body.model_id)
variant = None
if entry is not None:
# Same planning budget as the catalog — the user downloads exactly
# the build the row advertised.
choice = select_variant(entry, probe_budget(planning=True))
if choice is None:
raise HTTPException(status_code=409,
detail=f"no variant of {entry.id} fits this machine")
variant = choice.variant
else:
for candidate in CATALOG:
for v in candidate.variants:
if v.model_id == body.model_id:
entry, variant = candidate, v
break
if variant:
break
if entry is None or variant is None:
raise HTTPException(status_code=404, detail=f"unknown model {body.model_id}")
from hermes_cli.local_runtime.bootstrap import assets_dir, staged_model_ids
if variant.model_id in staged_model_ids():
return {"job_id": None, "already_downloaded": True, "model_id": variant.model_id}
# Everything this variant needs: split parts + mmproj/draft assets.
plan = [] # (url, dest, sha256, bytes)
for asset in variant.files:
plan.append((f"https://huggingface.co/{entry.repo}/resolve/main/{asset.path}",
_models_dir() / asset.local_name, asset.sha256, asset.size_bytes))
for asset in (entry.mmproj, entry.draft):
if asset is not None:
plan.append((f"https://huggingface.co/{entry.repo}/resolve/main/{asset.path}",
assets_dir() / asset.local_name, asset.sha256, asset.size_bytes))
total = sum(p[3] for p in plan)
job = _job("model-download", f"{entry.display_name} ({variant.quant})",
model_id=entry.id)
job["total_bytes"] = total
def _run():
try:
job["phase"] = "downloading"
job["detail"] = f"{entry.display_name}{_human_gb(total)}"
done_before = 0
for url, dest, sha, size in plan:
if dest.exists():
done_before += size
job["done_bytes"] = done_before
continue
download_file(url, dest, job, expected_sha256=sha,
base_done=done_before, keep_totals=True)
job["phase"] = "downloading"
done_before += size
job["done_bytes"] = done_before
job["phase"] = "done"
job["status"] = "done"
job["detail"] = f"{entry.display_name} ready"
# A running router only scans models at spawn —
# bounce it so the new model is servable
# immediately instead of 400ing until the next app restart.
try:
from hermes_cli.local_runtime.bootstrap import refresh_local_runtime
refresh_local_runtime()
except Exception: # noqa: BLE001
logger.debug("post-download runtime refresh skipped", exc_info=True)
except Exception as exc: # noqa: BLE001
logger.warning("model download failed: %s", exc)
job["status"] = "error"
job["error"] = str(exc)
threading.Thread(target=_run, daemon=True, name="lr-model-download").start()
return {"job_id": job["job_id"], "model_id": variant.model_id}
@router.delete("/api/local-models/models/{model_id}")
async def local_models_delete(model_id: str):
"""Remove a staged model: every split part plus its private assets.
A running router keeps serving from its spawn-time scan, so bounce it
off the request thread deleting the active file mid-serve is the
kind of stale state the refresh exists for."""
files = _variant_files_on_disk(model_id)
if not files:
raise HTTPException(status_code=404, detail="model not found")
for path in files:
path.unlink(missing_ok=True)
# Growth state dies with the model: a re-download starts back at its
# zero-spill window instead of inheriting a stale grown one.
try:
from hermes_cli.local_runtime.growth import clear_window_override
clear_window_override(model_id)
except Exception: # noqa: BLE001
logger.debug("window-override clear skipped", exc_info=True)
def _refresh():
try:
from hermes_cli.local_runtime.bootstrap import refresh_local_runtime
refresh_local_runtime()
except Exception: # noqa: BLE001
logger.debug("post-delete runtime refresh skipped", exc_info=True)
threading.Thread(target=_refresh, daemon=True, name="lr-post-delete").start()
return {"ok": True}
# ── server lifecycle: turn the engine on/off ─────────────────
class ServerActionBody(BaseModel):
action: str # "stop" | "start"
@router.post("/api/local-models/server")
async def local_models_server(body: ServerActionBody):
"""Turn the local engine off (stop the server, free ALL GPU memory,
and disable auto-start) or back on. The off switch is the whole-engine
counterpart of per-model eject and unlike eject it IS durable: the
user said off, so boots stay off until they say on."""
import asyncio
from hermes_cli.config import load_config, save_config
action = (body.action or "").strip().lower()
if action not in ("stop", "start"):
raise HTTPException(status_code=400, detail="action must be 'stop' or 'start'")
def _stop():
from hermes_cli.local_runtime.bootstrap import (
get_supervisor,
shutdown_local_runtime,
)
sup = get_supervisor()
if sup is not None:
shutdown_local_runtime()
else:
# Server owned by another process (or an orphan): best-effort
# terminate via the state file's pid, then clear the state.
endpoint = _state_endpoint()
if endpoint is not None:
try:
import psutil # type: ignore
from hermes_cli.local_runtime.supervisor import state_path
state = json.loads(state_path().read_text(encoding="utf-8"))
pid = int(state.get("pid") or 0)
if pid > 0 and psutil.pid_exists(pid):
psutil.Process(pid).terminate()
state_path().unlink(missing_ok=True)
except Exception: # noqa: BLE001
pass
config = load_config()
config.setdefault("local_runtime", {})["enabled"] = False
save_config(config)
def _start():
from hermes_cli.local_runtime.bootstrap import ensure_local_runtime
config = load_config()
config.setdefault("local_runtime", {})["enabled"] = True
save_config(config)
sup = ensure_local_runtime(config, force=True)
if sup is None and _state_endpoint() is None:
raise RuntimeError("The local server could not start — check the "
"runtime is installed")
try:
await asyncio.to_thread(_stop if action == "stop" else _start)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"ok": True, "action": action}
# ── activate: make a downloaded model THE model ──────────────
class ModelEjectBody(BaseModel):
model_id: str
@router.post("/api/local-models/eject")
async def local_models_eject(body: ModelEjectBody):
"""Free a loaded model's GPU memory now. Nothing reloads it except
demand the next message to it (residency v2: no automatic loading
exists anywhere)."""
from hermes_cli.local_runtime.bootstrap import get_supervisor
sup = get_supervisor()
if sup is not None:
try:
sup.unload_model(body.model_id)
return {"ok": True}
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=str(exc)) from exc
# Server owned by another process (or state-file only): drive the
# router directly with the persisted endpoint.
endpoint = _state_endpoint()
if endpoint is None:
raise HTTPException(status_code=409, detail="local server is not running")
try:
import urllib.request as _url
req = _url.Request(
endpoint["base_url"].rsplit("/v1", 1)[0] + "/models/unload",
data=json.dumps({"model": body.model_id}).encode(),
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {endpoint.get('api_key', '')}"},
method="POST")
with _url.urlopen(req, timeout=120):
pass
return {"ok": True}
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=str(exc)) from exc
class ModelActivateBody(BaseModel):
model_id: str # exact variant id (a staged .gguf stem)
@router.post("/api/local-models/activate")
async def local_models_activate(body: ModelActivateBody):
"""Make a downloaded model the default for new chats. Pure selection
(residency v2): a config write through the same machinery as
/api/model/set, plus making sure the server is up. NO model loading
models load on first inference, always; an empty router costs nothing.
Fast enough to be synchronous-feeling, but kept as a job for UI
continuity."""
# Split variants stage under their first part — resolve like the rest
# of the routes instead of assuming a single flat file.
from hermes_cli.local_runtime.bootstrap import staged_model_ids
if body.model_id not in staged_model_ids():
raise HTTPException(status_code=404, detail=f"{body.model_id} is not downloaded")
job = _job("model-activate", body.model_id, model_id=body.model_id)
def _run():
try:
from hermes_cli.config import load_config, save_config
from hermes_cli.local_runtime.bootstrap import (
ensure_local_runtime,
refresh_local_runtime,
)
job["phase"] = "starting-server"
job["detail"] = "Starting the local server"
config = load_config()
sup = ensure_local_runtime(config, force=True)
if sup is None:
from hermes_cli.local_runtime.endpoint import _state_endpoint as _se
if _se() is None:
raise RuntimeError(
"The local server could not start — check the runtime is installed")
# Self-heal a stale router: the model list is spawn-only, so a
# server started before this model finished downloading can't
# serve it. If the router doesn't know the model, bounce it.
if sup is not None:
try:
if body.model_id not in sup.models():
job["detail"] = "Refreshing the local server"
refresh_local_runtime()
except Exception: # noqa: BLE001
logger.debug("activate rescan check skipped", exc_info=True)
job["phase"] = "setting-default"
job["detail"] = "Making it your default"
config = load_config()
config.setdefault("local_runtime", {})["enabled"] = True
save_config(config)
from hermes_cli.web_deps import late
late("_apply_model_assignment_sync")(
"main", "llamacpp", body.model_id, "", "", "")
job["phase"] = "done"
job["status"] = "done"
job["detail"] = f"{body.model_id} is the default for new chats"
except Exception as exc: # noqa: BLE001
logger.warning("model activate failed: %s", exc)
job["status"] = "error"
job["error"] = str(exc)
threading.Thread(target=_run, daemon=True, name="lr-model-activate").start()
return {"job_id": job["job_id"]}
# ── job polling ──────────────────────────────────────────────
@router.get("/api/local-models/jobs")
async def local_models_jobs():
"""All recent jobs, running first — the pane and the app-level poller
rediscover in-flight work here after a remount or app restart."""
with _JOBS_LOCK:
jobs = sorted(_JOBS.values(),
key=lambda j: (j["status"] != "running", -j["started_at"]))
out = []
for job in jobs[:20]:
entry = dict(job)
if entry["total_bytes"]:
entry["percent"] = min(100, round(entry["done_bytes"] / entry["total_bytes"] * 100))
out.append(entry)
return {"jobs": out}
@router.get("/api/local-models/jobs/{job_id}")
async def local_models_job(job_id: str):
with _JOBS_LOCK:
job = _JOBS.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
out = dict(job)
if out["total_bytes"]:
out["percent"] = min(100, round(out["done_bytes"] / out["total_bytes"] * 100))
return out

View File

@ -0,0 +1,50 @@
"""Catalog integrity: every entry's files must exist on the host with
matching sha256s (HF LFS oids ARE the file hashes).
Network-marked (skipped in hermetic CI unless explicitly enabled) this is
the test that catches wrong repo names (the Nemotron 401), moved files, and
upstream re-uploads before a user's download does. Run before any catalog
commit:
HERMES_TEST_NETWORK=1 scripts/run_tests.sh tests/hermes_cli/test_catalog_reachability.py
"""
from __future__ import annotations
import json
import os
import urllib.request
import pytest
pytestmark = pytest.mark.skipif(
not os.environ.get("HERMES_TEST_NETWORK"),
reason="network test; set HERMES_TEST_NETWORK=1 to run",
)
def test_every_catalog_file_resolves():
from hermes_cli.local_runtime.catalog import CATALOG
problems = []
for entry in CATALOG:
url = f"https://huggingface.co/api/models/{entry.repo}/tree/main?recursive=true"
try:
with urllib.request.urlopen(url, timeout=30) as r:
files = {f["path"]: f.get("lfs") or {} for f in json.load(r)}
except Exception as exc: # noqa: BLE001
problems.append(f"{entry.id}: repo {entry.repo} unreachable ({exc})")
continue
for variant in entry.variants:
for asset in entry.download_files(variant):
if asset.path not in files:
problems.append(
f"{entry.id}/{variant.quant}: {asset.path} not in {entry.repo}")
continue
live_sha = files[asset.path].get("oid", "")
if live_sha and live_sha != asset.sha256:
problems.append(
f"{entry.id}/{variant.quant}: sha drift on {asset.path}"
f"catalog {asset.sha256[:12]} vs live {live_sha[:12]} "
f"(upstream re-uploaded; re-pin deliberately)")
assert not problems, "\n".join(problems)

View File

@ -0,0 +1,129 @@
"""Variant-selection contracts: the quant ladder picks the best build for
the hardware, per the design's 'offer a smaller quant' remedy run
proactively, bounded by the Q4 quality floor. Pure decision-table tests
over synthetic budgets."""
from __future__ import annotations
import pytest
from hermes_cli.local_runtime.catalog import (
CATALOG,
catalog_by_id,
find_entry_for_model,
select_variant,
)
from hermes_cli.local_runtime.estimator import HardwareBudget
GIB = 1 << 30
def budget(vram_gib: float, ram_gib: float = 64) -> HardwareBudget:
return HardwareBudget(usable_vram_bytes=int(vram_gib * GIB),
total_device_bytes=int(vram_gib * GIB),
ram_available_bytes=int(ram_gib * GIB))
def test_variants_ordered_best_first_and_floor_is_q4():
for entry in CATALOG:
sizes = [v.size_bytes for v in entry.variants]
assert sizes == sorted(sizes, reverse=True), f"{entry.id}: variants not best-first"
for v in entry.variants:
for asset in entry.download_files(v):
assert len(asset.sha256) == 64, f"{entry.id}/{v.quant}: unpinned {asset.path}"
# The quality floor: the ladder ends at Q4 — no Q3/Q2 builds ship
# (product decision, 2026-08-09). Every floor rung is either
# validated on real hardware or an explicit day-0 entry.
floor = entry.variants[-1]
assert floor.quant == "UD-Q4_K_XL", f"{entry.id}: ladder floor is {floor.quant}, not Q4"
assert floor.validated or "day-0" in entry.tags, (
f"{entry.id}: floor rung neither validated nor tagged day-0")
def test_split_variants_have_coherent_parts():
"""Multi-file variants: same model_id from every part, exact sizes,
first file is the load target."""
entry = catalog_by_id()["deepseek-v4-flash"]
for v in entry.variants:
assert len(v.files) >= 2, "deepseek ships split GGUFs"
assert "00001-of" in v.files[0].path, "first part must be the load target"
assert v.size_bytes == sum(f.size_bytes for f in v.files)
assert entry.draft is not None, "DSpark draft rides along"
def test_big_card_gets_best_quality():
"""A card with headroom takes the top rung — quality is free when it fits."""
entry = catalog_by_id()["muse-glimmer-30b"]
choice = select_variant(entry, budget(48))
assert choice is not None
assert choice.zero_spill
assert choice.variant.quant == entry.variants[0].quant # UD-Q8_K_XL
def test_quality_monotone_in_vram():
"""More VRAM never selects a smaller build."""
entry = catalog_by_id()["qwen3.6-27b"]
sizes = []
for vram in (8, 12, 16, 24, 32, 48):
choice = select_variant(entry, budget(vram))
assert choice is not None
sizes.append(choice.variant.size_bytes)
assert sizes == sorted(sizes), f"quality not monotone in VRAM: {sizes}"
def test_small_card_gets_q4_spilled_never_below():
"""8 GiB card + 27B: nothing zero-spills. The floor holds — the
selector offers Q4 spilled (priced honestly), never a sub-Q4 build."""
entry = catalog_by_id()["qwen3.6-27b"]
choice = select_variant(entry, budget(8))
assert choice is not None
assert not choice.zero_spill
assert choice.reason_key == "smallest-fits-spilled"
assert choice.variant.quant == "UD-Q4_K_XL"
def test_frontier_model_refused_on_consumer_card_offered_on_big_ram():
"""DeepSeek V4 Flash (161 GB at Q4): refused outright on a 32 GiB-RAM
desktop; offered spilled on a 192 GiB-RAM workstation. The catalog
carries frontier hardware honestly instead of hiding the model."""
entry = catalog_by_id()["deepseek-v4-flash"]
assert select_variant(entry, budget(32, ram_gib=32)) is None
big = select_variant(entry, budget(32, ram_gib=192))
assert big is not None and not big.zero_spill
def test_selection_accounts_for_kv_not_just_weights():
"""The zero-spill check prices weights + 64K-floor KV, not weights
alone: give a machine exactly enough VRAM for the Q8 weights of a
dense model and it must step down a rung."""
entry = catalog_by_id()["muse-glimmer-30b"]
q8 = entry.variants[0]
exactly_weights = HardwareBudget(
usable_vram_bytes=q8.size_bytes + (100 << 20),
total_device_bytes=q8.size_bytes + (100 << 20),
ram_available_bytes=64 * GIB)
choice = select_variant(entry, exactly_weights)
assert choice is not None
assert choice.variant.quant != q8.quant, "KV cost ignored — Q8 can't fit with floor KV"
def test_find_entry_for_model_resolves_split_ids():
hit = find_entry_for_model("DeepSeek-V4-Flash-0731-UD-Q4_K_XL")
assert hit is not None
entry, variant = hit
assert entry.id == "deepseek-v4-flash"
assert variant.quant == "UD-Q4_K_XL"
def test_hybrid_long_context_stays_cheap():
"""The reason Nemotron/Qwen3.6 headline the catalog: their priced
64K-floor KV must be a small fraction of a dense model's."""
from hermes_cli.local_runtime.catalog import FLOOR
from hermes_cli.local_runtime.estimator import ctx_bytes
dense = catalog_by_id()["muse-glimmer-30b"]
hybrid = catalog_by_id()["nemotron-3.5-lightning-30b"]
dense_kv = ctx_bytes(dense.profile(dense.variants[-1]), FLOOR)
hybrid_kv = ctx_bytes(hybrid.profile(hybrid.variants[-1]), FLOOR)
assert hybrid_kv * 5 < dense_kv, (
f"hybrid KV ({hybrid_kv:,}) should be >5x cheaper than dense ({dense_kv:,})")

View File

@ -0,0 +1,209 @@
"""Contract tests for the local-models dashboard routes (Rollout 4).
Real FastAPI TestClient against the real router; the runtime pieces
underneath are exercised against temp HERMES_HOME (autouse fixture). Network
downloads are stubbed at the urllib boundary never live."""
from __future__ import annotations
import io
import json
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
from hermes_cli import web_server
test_client = TestClient(web_server.app)
# Same auth pattern as the git-route tests: present the session token.
test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
return test_client
def test_local_models_routes_require_auth(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
from hermes_cli import web_server
unauth = TestClient(web_server.app)
assert unauth.get("/api/local-models/status").status_code == 401
def _write_fake_gguf(path: Path, size: int = 1024) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"GGUF" + b"\x00" * size)
# ── status ───────────────────────────────────────────────────
def test_status_shape_and_defaults(client):
r = client.get("/api/local-models/status")
assert r.status_code == 200
data = r.json()
# Contract: every key the pane's first paint needs, present and typed.
assert isinstance(data["enabled"], bool)
assert isinstance(data["tag"], str) and data["tag"].startswith("b")
assert isinstance(data["runtime_installed"], bool)
assert isinstance(data["server_running"], bool)
assert isinstance(data["models"], list)
def test_status_lists_staged_models_with_labels(client, tmp_path):
from hermes_cli.local_runtime.bootstrap import models_dir
_write_fake_gguf(models_dir() / "Some-Model.gguf", size=2048)
data = client.get("/api/local-models/status").json()
ids = [m["id"] for m in data["models"]]
assert "Some-Model" in ids
row = data["models"][ids.index("Some-Model")]
assert row["size_bytes"] > 0
assert row["size_label"].endswith("GB")
# ── hardware ─────────────────────────────────────────────────
def test_hardware_plain_facts(client):
data = client.get("/api/local-models/hardware").json()
assert isinstance(data["uma"], bool)
assert data["ram_total_bytes"] > 0
assert data["vram_total_bytes"] >= 0
# GPU fields are None-able (non-NVIDIA machines) but must exist.
assert "gpu_name" in data and "gpu_util_percent" in data and "vram_used_bytes" in data
# ── catalog ──────────────────────────────────────────────────
def test_catalog_prices_every_entry_for_this_machine(client):
data = client.get("/api/local-models/catalog").json()
assert len(data["models"]) >= 3
for row in data["models"]:
# The three user questions, answered on every row:
assert row["size_label"].endswith("GB") # how big
assert isinstance(row["fits"], bool) # will it fit
assert row["fit_summary"] # what shape
if row["fits"]:
assert row["start_window"] >= 1
assert row["start_window_label"].endswith("K")
else:
assert "memory" in row["fit_summary"].lower()
assert isinstance(row["downloaded"], bool)
def test_catalog_never_hides_unaffordable_models(client, monkeypatch):
"""Unaffordable entries stay visible with a plain reason — hiding them
is how users conclude the feature is broken."""
from hermes_cli.local_runtime.estimator import HardwareBudget
tiny = HardwareBudget(usable_vram_bytes=1 << 30, total_device_bytes=1 << 30,
ram_available_bytes=1 << 30)
monkeypatch.setattr("hermes_cli.local_runtime.hardware.probe_budget",
lambda **kw: tiny)
data = client.get("/api/local-models/catalog").json()
from hermes_cli.local_runtime.catalog import CATALOG
assert len(data["models"]) == len(CATALOG)
refused = [m for m in data["models"] if not m["fits"]]
assert refused, "a 1 GiB machine must refuse the 20 GB models"
for row in refused:
assert row["fit_detail"] or row["fit_summary"]
# ── downloads ────────────────────────────────────────────────
def test_download_unknown_model_404s(client):
r = client.post("/api/local-models/download", json={"model_id": "nope"})
assert r.status_code == 404
def test_download_job_lifecycle_with_sha_failure(client, monkeypatch):
"""Wrong-hash download must delete the file and error the job with a
human-readable message never leave a corrupt GGUF staged."""
class FakeResponse(io.BytesIO):
headers = {"Content-Length": "16"}
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr("urllib.request.urlopen",
lambda *a, **k: FakeResponse(b"not the real body"))
from hermes_cli.local_runtime.catalog import CATALOG
entry_id = CATALOG[0].id
r = client.post("/api/local-models/download", json={"model_id": entry_id})
assert r.status_code == 200
job_id = r.json()["job_id"]
assert job_id
deadline = time.time() + 10
status = None
while time.time() < deadline:
status = client.get(f"/api/local-models/jobs/{job_id}").json()
if status["status"] in ("done", "error"):
break
time.sleep(0.05)
assert status is not None and status["status"] == "error"
assert "integrity" in status["error"].lower()
from hermes_cli.local_runtime.bootstrap import models_dir
assert not (models_dir() / f"{entry_id}.gguf").exists()
assert not (models_dir() / f"{entry_id}.part").exists()
def test_download_already_downloaded_short_circuits(client, monkeypatch):
from hermes_cli.local_runtime.bootstrap import models_dir
from hermes_cli.local_runtime.catalog import CATALOG, select_variant
from hermes_cli.local_runtime.estimator import HardwareBudget
# Pin the budget so the selected variant is deterministic in the test.
budget = HardwareBudget(usable_vram_bytes=64 << 30, total_device_bytes=64 << 30,
ram_available_bytes=64 << 30)
monkeypatch.setattr("hermes_cli.local_runtime.hardware.probe_budget",
lambda **kw: budget)
choice = select_variant(CATALOG[0], budget)
assert choice is not None
_write_fake_gguf(models_dir() / choice.variant.files[0].local_name)
r = client.post("/api/local-models/download", json={"model_id": CATALOG[0].id})
assert r.status_code == 200
assert r.json()["already_downloaded"] is True
def test_delete_model(client):
from hermes_cli.local_runtime.bootstrap import models_dir
_write_fake_gguf(models_dir() / "Doomed.gguf")
assert client.delete("/api/local-models/models/Doomed").status_code == 200
assert not (models_dir() / "Doomed.gguf").exists()
assert client.delete("/api/local-models/models/Doomed").status_code == 404
# ── runtime install ──────────────────────────────────────────
def test_runtime_install_rejects_impossible_combo(client, monkeypatch):
"""Impossible platform/backend combos fail the POST itself with the
resolver's honest message — not a background job that dies silently.
(win-arm64-vulkan; the old cuda case became real upstream at ~b1036x.)"""
monkeypatch.setattr(
"hermes_cli.local_runtime.binaries._host_os_arch", lambda: ("win", "arm64"))
r = client.post("/api/local-models/runtime/install", json={"backend": "vulkan"})
assert r.status_code == 400
assert "arm64" in r.json()["detail"]
def test_job_poll_unknown_404s(client):
assert client.get("/api/local-models/jobs/deadbeef").status_code == 404

View File

@ -0,0 +1,67 @@
"""The llamacpp provider row in the model picker payload (Rollout 4).
Contract: staged local GGUFs appear as a selectable provider row in
build_models_payload the same payload /api/model/options and the desktop
picker consume whenever models are staged, without any credential."""
from __future__ import annotations
import pytest
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _stage(home, *names):
mdir = home / "models"
mdir.mkdir(exist_ok=True)
for name in names:
(mdir / f"{name}.gguf").write_bytes(b"GGUF" + b"\x00" * 64)
def test_no_staged_models_no_row(hermes_home):
from hermes_cli.inventory import _local_runtime_row, load_picker_context
assert _local_runtime_row(load_picker_context()) is None
def test_staged_models_make_a_selectable_row(hermes_home):
from hermes_cli.inventory import _local_runtime_row, load_picker_context
_stage(hermes_home, "Qwen3-4B-Instruct-2507-UD-Q8_K_XL", "Some-Other-Model")
row = _local_runtime_row(load_picker_context())
assert row is not None
assert row["slug"] == "llamacpp"
assert row["authenticated"] is True
assert "Qwen3-4B-Instruct-2507-UD-Q8_K_XL" in row["models"]
assert row["total_models"] == 2
def test_row_marks_current_when_config_points_at_llamacpp(hermes_home):
from hermes_cli.inventory import _local_runtime_row, load_picker_context
_stage(hermes_home, "M")
ctx = load_picker_context().with_overrides(current_provider="llamacpp")
row = _local_runtime_row(ctx)
assert row is not None and row["is_current"] is True
def test_full_payload_includes_local_row(hermes_home):
"""Through the REAL payload builder — the shape the desktop picker eats."""
from hermes_cli.inventory import build_models_payload, load_picker_context
_stage(hermes_home, "Local-Model-X")
payload = build_models_payload(
load_picker_context(),
probe_custom_providers=False,
probe_current_custom_provider=False,
)
slugs = [p["slug"] for p in payload["providers"]]
assert "llamacpp" in slugs
row = payload["providers"][slugs.index("llamacpp")]
assert row["models"] == ["Local-Model-X"]