200 lines
11 KiB
Python
Executable File
200 lines
11 KiB
Python
Executable File
#!/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-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=""):
|
|
args = ["--ctx-size", "65536", "--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 == "routine":
|
|
# Fits on the verified 10-GiB workstation. Explicit layers disable fit.
|
|
args += ["--n-gpu-layers", "99", "--fit", "off"]
|
|
else:
|
|
args += ["--n-gpu-layers", "auto", "--fit", "on", "--fit-target", "4096"]
|
|
args += ["--sleep-idle-seconds", "300", "--image-min-tokens", "1024",
|
|
"--cors-origins", "localhost"]
|
|
args += ["--flash-attn", "auto", "--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--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_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):
|
|
config.setdefault("model", {}).update(default="hermes-local", provider="custom",
|
|
base_url=LOCAL_URL, context_length=65536)
|
|
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)
|
|
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", help="Kommagetrennt: routine,large,compression")
|
|
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"], 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")
|
|
if options.select and options.select not in names:
|
|
parser.error("--select muss in --models enthalten sein")
|
|
if options.configure_hermes and "routine" not in names and "large" not in names:
|
|
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)
|
|
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()
|