#!/usr/bin/env python3 """Prepare the documented local model setup; never start services or send messages.""" import argparse import fcntl import hashlib import json import os from pathlib import Path import re import shlex import shutil import subprocess import tempfile import time ROOT = Path(__file__).resolve().parents[1] ALIASES = ["hermes-local", "qwen35-9b-local", "qwen35-4b-local", "qwen38-27b-local", "qwen36-27b-local"] LOCAL_URL = "http://127.0.0.1:8080/v1" def verify(path, item): if not path.is_file() or path.stat().st_size != item["size"]: return False with path.open("rb") as model: return hashlib.file_digest(model, "sha256").hexdigest() == item["sha256"] def ensure_model(group, model_dir, download=False): directory = model_dir / group["directory"] for item in group["files"]: path = directory / item["name"] if path.exists(): if not verify(path, item): raise ValueError(f"Prüfsumme falsch: {path}; vorhandene Datei bleibt unverändert") else: if not download: raise FileNotFoundError(f"Modell fehlt: {path}; mit --download bereitstellen") directory.mkdir(parents=True, exist_ok=True) partial = path.with_name(path.name + ".part") url = f'https://huggingface.co/{group["repo"]}/resolve/{group["revision"]}/{item["name"]}' subprocess.run(["curl", "-fL", "--retry", "3", "--connect-timeout", "15", "--max-time", "14400", "--continue-at", "-", url, "--output", str(partial)], check=True) if not verify(partial, item): raise ValueError(f"Download-Prüfsumme falsch: {partial}; nicht aktiviert") partial.replace(path) print(f"SHA256 OK: {path.name}", flush=True) return [directory / item["name"] for item in group["files"]] def write_config(path, text, replace=False): if path.exists(): if path.read_text() == text: return if not replace: print(f"Vorhandene Konfiguration beibehalten: {path}") return backup = path.with_name(path.name + f".before-setup-{time.time_ns()}") shutil.copy2(path, backup) backup.chmod(0o600) print(f"Sicherung: {backup}") path.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as pending: pending.write(text) staging = Path(pending.name) staging.chmod(0o600) staging.replace(path) def profile_config(name, paths, server_bin=""): context = 65536 args = ["--ctx-size", str(context), "--parallel", "1", "--jinja"] if name == "compression": args += ["--rope-scaling", "yarn", "--rope-scale", "2", "--yarn-orig-ctx", "32768", "--reasoning-budget", "0", "--no-reasoning-preserve", "--n-gpu-layers", "0", "--device", "none", "--no-op-offload", "--no-kv-offload", "--threads", "8", "--threads-batch", "8"] else: args += ["--reasoning", "auto", "--reasoning-budget", "128", "--no-reasoning-preserve", "--threads", "6", "--threads-batch", "16"] if name in ("routine", "fallback"): # Explicit full offload: fail instead of silently moving layers to CPU. args += ["--n-gpu-layers", "99", "--fit", "off"] else: args += ["--n-gpu-layers", "auto", "--fit", "on", "--fit-target", "4096"] if name == "routine": args += ["--no-mmproj-offload", "--batch-size", "512", "--ubatch-size", "128"] args += ["--sleep-idle-seconds", "300", "--image-min-tokens", "1024", "--cors-origins", "localhost"] cache = "q4_0" if name == "routine" else "q8_0" args += ["--flash-attn", "auto", "--cache-type-k", cache, "--cache-type-v", cache, "--no-ui"] settings = {"LLAMA_SERVER_BIN": server_bin, "LLAMA_MODEL": str(paths[0]), "LLAMA_MMPROJ": str(paths[1]) if len(paths) > 1 else "", "LLAMA_HF_REPO": "", "LLAMA_ALIAS": "qwen3-1.7b-compression" if name == "compression" else ",".join(ALIASES), "LLAMA_HOST": "127.0.0.1", "LLAMA_PORT": "8081" if name == "compression" else "8080", "LLAMA_PROFILE": "", "LLAMA_CONTEXT_TOKENS": str(context), "LLAMA_MAX_OUTPUT_TOKENS": "2048"} return ("# Generated from config/models.lock.json; no service started.\n" + "".join(f"{key}={shlex.quote(value)}\n" for key, value in settings.items()) + "LLAMA_ARGS=(\n " + shlex.join(args) + "\n)\n") def configure_hermes(config, compression=False, context=65536): if context < 64000: raise ValueError("Hermes requires at least 64000 context tokens") config.setdefault("model", {}).update(default="hermes-local", provider="custom", base_url=LOCAL_URL, context_length=context) config.setdefault("agent", {}).update(run_budget_seconds=1800, budget_warning_ratio=0.8) config.setdefault("providers", {}).setdefault("custom", {}).update( request_timeout_seconds=600, stale_timeout_seconds=180) entries = config.setdefault("custom_providers", []) matches = [entry for entry in entries if entry.get("base_url", "").rstrip("/") == LOCAL_URL] if not matches: matches = [{"name": "Local (127.0.0.1:8080)", "base_url": LOCAL_URL}] entries.extend(matches) for entry in matches: entry["model"] = "hermes-local" entry.setdefault("models", {}).update({name: {} for name in ALIASES}) entry.setdefault("extra_body", {}).update(max_tokens=2048, t_max_predict_ms=540000) auxiliary = config.setdefault("auxiliary", {}) auxiliary.setdefault("vision", {}).update(provider="main", model="hermes-local", base_url=LOCAL_URL, api_key="local-no-key", timeout=300, reasoning_effort="none", max_concurrency=1) if compression: auxiliary.setdefault("compression", {}).update(provider="custom", model="qwen3-1.7b-compression", base_url="http://127.0.0.1:8081/v1", api_key="local-no-key", timeout=300, reasoning_effort="none") config.setdefault("compression", {}).update(enabled=True, threshold=0.5, threshold_tokens=24000, target_ratio=0.2, protect_last_n=10, protect_first_n=3, context_timeout_seconds=120, context_total_ceiling_seconds=600) config.setdefault("compression", {})["threshold_tokens"] = min(24000, context * 3 // 8) return config def maintenance_config(text): for key, value in {"LLAMA_SERVICE": "llama-qwen.service", "LLAMA_URL": "http://127.0.0.1:8080", "MODEL_NAME": "hermes-local", "LLAMA_MAX_REQUEST_SECONDS": "600", "LLAMA_MAX_OUTPUT_TOKENS": "2048"}.items(): setting = f"{key}={shlex.quote(value)}" pattern = rf"^{key}=.*$" text = re.sub(pattern, lambda _: setting, text, flags=re.MULTILINE) if re.search(pattern, text, re.MULTILINE) else text + "\n" + setting + "\n" return text def unpin_local_jobs(path): if not path.exists(): return for job in json.loads(path.read_text()).get("jobs", []): if (job.get("model") in ALIASES and job.get("provider") == "custom" and job.get("base_url") in (None, "", LOCAL_URL)): subprocess.run(["hermes", "cron", "edit", job["id"], "--unpin"], check=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--download", action="store_true", help="Fehlende, fest versionierte GGUF-Dateien herunterladen") parser.add_argument("--models", default="routine,large,compression,fallback", help="Kommagetrennt: routine,large,compression,fallback") parser.add_argument("--server-bin", default="", help="Leer: verwalteter CUDA-Build oder llama-server im PATH") parser.add_argument("--model-dir", type=Path, default=Path.home() / ".local/share/hermes-stack/models") parser.add_argument("--config-dir", type=Path, default=Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "hermes-stack-maintenance") parser.add_argument("--hermes-config", type=Path, default=Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))) / "config.yaml") parser.add_argument("--select", choices=["routine", "large", "fallback"], help="Aktive llama.env vorbereiten; kein Dienststart") parser.add_argument("--configure-hermes", action="store_true", help="Gezielte Hermes-/Maintenance-Einstellungen mit Backup übernehmen") parser.add_argument("--unpin-local-cron", action="store_true", help="Lokale Cron-Modellbindungen über Hermes-CLI lösen") options = parser.parse_args() names = list(dict.fromkeys(options.models.split(","))) manifest = json.loads((ROOT / "config/models.lock.json").read_text())["models"] if not names or any(name not in manifest for name in names): parser.error("--models erlaubt nur routine,large,compression,fallback") if options.select and options.select not in names: parser.error("--select muss in --models enthalten sein") if options.configure_hermes and not any(n in names for n in ("routine", "large", "fallback")): parser.error("--configure-hermes benötigt ein Hauptmodell") if options.select or options.configure_hermes or options.unpin_local_cron: state = subprocess.check_output(["systemctl", "--user", "show", "hermes-gateway.service", "--property=ActiveState", "--value"], text=True).strip() if state not in ("inactive", "failed", ""): parser.error("Gateway zuerst mit systemctl --user stop hermes-gateway.service anhalten") # Import before downloads, so a missing dependency does not waste bandwidth. if options.configure_hermes: import yaml lock_path = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "hermes-stack-hermes-update.lock" with lock_path.open("w") as lock: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) profiles = {} for name in names: paths = ensure_model(manifest[name], options.model_dir, options.download) profiles[name] = profile_config(name, paths, options.server_bin) for name, text in profiles.items(): filename = "compression.env" if name == "compression" else f"llama-{name}.env" write_config(options.config_dir / filename, text) if options.select: source = options.config_dir / f"llama-{options.select}.env" write_config(options.config_dir / "llama.env", source.read_text(), replace=True) if options.configure_hermes: text = options.hermes_config.read_text() if options.hermes_config.exists() else "" config = yaml.safe_load(text) or {} configure_hermes(config, "compression" in names, 65536) write_config(options.hermes_config, yaml.safe_dump(config, allow_unicode=True, sort_keys=False, width=1000), replace=True) maintenance = options.config_dir / "config.env" text = maintenance.read_text() if maintenance.exists() else (ROOT / "config/config.env.example").read_text() write_config(maintenance, maintenance_config(text), replace=True) if options.unpin_local_cron: unpin_local_jobs(options.hermes_config.parent / "cron/jobs.json") print("Vorbereitung abgeschlossen. Keine Dienste gestartet. Siehe docs/NEW_WORKSTATION.md.") if __name__ == "__main__": main()