Ai-Exocore / model_manager.py
ChoruYt's picture
Upload 22 files
dfa523b verified
Raw
History Blame Contribute Delete
23.2 kB
#!/usr/bin/env python3
"""
model_manager.py — Multi-model manager for local LLM API
Directory layout (new):
model/<ModelName>/ ← per-model directory
model/<ModelName>/chunks/ ← split GGUF shards (100MB each)
Commands:
list List all configured models + disk status
quants <name|repo> Query HuggingFace API — all GGUF files + sizes
use <name> [quant] Add model to active list (max 2 run at once)
use <name-quant> Combined format — e.g. use Qwen3.5-4B-IQ4_NL
use <name-quant.gguf> Full filename — e.g. use Qwen3.5-4B-IQ4_NL.gguf
remove <name> Remove model from active list
info Show full install.json config
Usage:
python model_manager.py list
python model_manager.py quants Qwen3.5-4B
python model_manager.py quants unsloth/Phi-4-mini-reasoning-GGUF
python model_manager.py use Qwen3.5-4B
python model_manager.py use DeepSeek-R1-8B
python model_manager.py use Qwen3.5-4B IQ4_NL
python model_manager.py remove DeepSeek-R1-8B
python model_manager.py info
"""
import os, sys, json, urllib.request, urllib.error
from typing import Optional
_HERE = os.path.dirname(os.path.abspath(__file__))
_CFG = os.path.join(_HERE, "install.json")
# ── Config helpers ────────────────────────────────────────────────────────────
def load_cfg() -> dict:
try:
with open(_CFG) as f:
return json.load(f)
except Exception as e:
print(f"[error] Cannot read install.json: {e}")
sys.exit(1)
def save_cfg(cfg: dict) -> None:
with open(_CFG, "w") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
f.write("\n")
def get_active_models(cfg: dict) -> list:
"""Return list of active model names. Supports both new (active_models[])
and legacy (active_model string) config format."""
v = cfg.get("active_models")
if isinstance(v, list):
return [x for x in v if x]
s = cfg.get("active_model", "")
return [s] if s else []
def model_dir_for(cfg: dict, name: str) -> str:
"""Return per-model directory: ./model/<name>/"""
base = cfg.get("model_dir", "./model")
return os.path.join(base, name)
def gguf_dir(cfg: dict) -> str:
"""Legacy helper — returns old model_gguf dir (for migration checks)."""
return cfg.get("gguf", {}).get("dir", "./model_gguf")
# ── Disk helpers ──────────────────────────────────────────────────────────────
def _file_status(cfg: dict, model_name: str) -> dict:
"""Return disk info for a model: file exists, size, chunks."""
mc = cfg.get("models", {}).get(model_name, {})
fn = mc.get("file", "")
d = model_dir_for(cfg, model_name)
fp = os.path.join(d, fn)
chk = os.path.join(d, "chunks")
status = {"file": fn, "exists": False, "size_gb": None, "chunks": 0, "chunk_total_gb": None}
if os.path.isfile(fp):
status["exists"] = True
status["size_gb"] = round(os.path.getsize(fp) / 1024**3, 2)
if os.path.isdir(chk):
base = os.path.splitext(fn)[0]
splits = [f for f in os.listdir(chk) if f.startswith(base) and f.endswith(".gguf")]
parts = [f for f in os.listdir(chk) if f.startswith(base) and f.endswith(".part")]
chunks = splits or parts
if chunks:
total = sum(os.path.getsize(os.path.join(chk, f)) for f in chunks)
status["chunks"] = len(chunks)
status["chunk_total_gb"] = round(total / 1024**3, 2)
return status
# ── HuggingFace API ───────────────────────────────────────────────────────────
def hf_list_gguf(repo: str, token: Optional[str] = None) -> list:
url = f"https://huggingface.co/api/models/{repo}/tree/main"
req = urllib.request.Request(url)
req.add_header("Accept", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
raise RuntimeError(f"HF API error {e.code}: {url}")
except Exception as e:
raise RuntimeError(f"Network error: {e}")
results = []
for entry in data:
path = entry.get("path", "")
if not path.endswith(".gguf"):
continue
if os.path.basename(path).startswith("mmproj"):
continue
size = entry.get("size", 0)
fname = os.path.basename(path)
quant = _parse_quant(fname)
bits = _quant_bits(quant)
results.append({
"name": fname,
"size_bytes": size,
"size_gb": round(size / 1024**3, 2),
"quant": quant,
"bits": bits,
})
results.sort(key=lambda x: x["size_bytes"])
return results
def _parse_quant(filename: str) -> str:
base = filename.replace(".gguf", "")
parts = base.split("-")
for i in range(len(parts) - 1, 0, -1):
candidate = "-".join(parts[i:])
if any(candidate.upper().startswith(p) for p in
("Q2","Q3","Q4","Q5","Q6","Q8","IQ","UD","BF","F1")):
return candidate
return base.split("-")[-1]
def _quant_bits(quant: str) -> str:
q = quant.upper()
if q.startswith("BF16") or q.startswith("F16"): return "16-bit"
if q.startswith("Q8") or q.startswith("UD-Q8"): return "8-bit"
if q.startswith("Q6") or q.startswith("UD-Q6"): return "6-bit"
if q.startswith("Q5") or q.startswith("UD-Q5"): return "5-bit"
if q.startswith("Q4") or q.startswith("IQ4") or q.startswith("UD-Q4"): return "4-bit"
if q.startswith("Q3") or q.startswith("IQ3") or q.startswith("UD-Q3"): return "3-bit"
if q.startswith("Q2") or q.startswith("IQ2") or q.startswith("UD-Q2"): return "2-bit"
if q.startswith("IQ1") or q.startswith("UD-IQ1"): return "1-bit"
return "?"
# ── Commands ──────────────────────────────────────────────────────────────────
def cmd_list(cfg: dict) -> None:
models = cfg.get("models", {})
actives = get_active_models(cfg)
print(f"\n{'Model':<20} {'Repo':<45} {'File':<40} {'Disk'}")
print("─" * 135)
for name, mc in models.items():
st = _file_status(cfg, name)
mark = "▶ " if name in actives else " "
idx = actives.index(name) + 1 if name in actives else 0
label = f"[{idx}]" if name in actives else " "
repo = mc.get("repo", "")
fn = mc.get("file", "")
note = mc.get("note", "")
if st["exists"]:
disk = f"✓ {st['size_gb']}GB"
elif st["chunks"] > 0:
disk = f"✓ {st['chunks']} chunks ({st['chunk_total_gb']}GB)"
else:
disk = "✗ not downloaded"
print(f"{mark}{label} {name:<16} {repo:<45} {fn:<40} {disk}")
if note:
print(f" {'':24} {note}")
model_base = cfg.get("model_dir", "./model")
print(f"\nModel dir : {os.path.abspath(model_base)}")
print(f"Active : {actives} (max 2 run simultaneously)")
print(f"\nAdd model : python model_manager.py use <ModelName>")
print(f"Remove model : python model_manager.py remove <ModelName>")
print(f"Change quant : python model_manager.py use <ModelName> <QUANT>")
print(f"List quants : python model_manager.py quants <ModelName>")
def cmd_quants(cfg: dict, target: str) -> list:
models = cfg.get("models", {})
if "/" in target:
repo = target
name = target.split("/")[-1]
elif target in models:
repo = models[target].get("repo", "")
name = target
if not repo:
print(f"[error] No repo configured for '{target}'")
sys.exit(1)
else:
matches = [n for n in models if target.lower() in n.lower()]
if len(matches) == 1:
name = matches[0]
repo = models[name].get("repo", "")
elif len(matches) > 1:
print(f"[error] Ambiguous: {matches}. Be more specific.")
sys.exit(1)
else:
print(f"[error] Unknown model '{target}'. Run: python model_manager.py list")
sys.exit(1)
print(f"\nQuerying HuggingFace: {repo} ...")
try:
files = hf_list_gguf(repo)
except RuntimeError as e:
print(f"[error] {e}")
sys.exit(1)
if not files:
print("[warn] No .gguf files found in this repo.")
return []
active_file = models.get(name, {}).get("file", "") if name in models else ""
by_bits: dict = {}
for f in files:
by_bits.setdefault(f["bits"], []).append(f)
bit_order = ["1-bit","2-bit","3-bit","4-bit","5-bit","6-bit","8-bit","16-bit","?"]
print(f"\n{'Bits':<8} {'Quantization':<25} {'Size':>7} {'File'}")
print("─" * 90)
for bits in bit_order:
if bits not in by_bits:
continue
for f in by_bits[bits]:
mark = " ◀ active" if f["name"] == active_file else ""
print(f" {bits:<6} {f['quant']:<25} {f['size_gb']:>5.2f}GB {f['name']}{mark}")
print(f"\nTo use a specific quant (any of these work):")
print(f" python model_manager.py use {name} IQ4_NL")
print(f" python model_manager.py use {name}-IQ4_NL")
print(f" python model_manager.py use {name}-IQ4_NL.gguf")
return files
def _split_model_quant(name: str, models: dict) -> tuple:
stem = name[:-5] if name.lower().endswith(".gguf") else name
for mname in sorted(models.keys(), key=len, reverse=True):
if stem.lower().startswith(mname.lower()):
remainder = stem[len(mname):]
if remainder.startswith("-") and len(remainder) > 1:
return mname, remainder[1:]
return name, None
def cmd_use(cfg: dict, model_name: str, quant: Optional[str] = None) -> None:
"""Add model to active_models list (max 2). If already active, update quant only."""
models = cfg.get("models", {})
if model_name not in models:
parsed_model, parsed_quant = _split_model_quant(model_name, models)
if parsed_model in models:
model_name = parsed_model
if parsed_quant and not quant:
quant = parsed_quant
else:
matches = [n for n in models if model_name.lower() in n.lower()]
if len(matches) == 1:
model_name = matches[0]
elif len(matches) > 1:
print(f"[error] Ambiguous name '{model_name}': {matches}")
sys.exit(1)
else:
print(f"[error] Unknown model '{model_name}'")
print(f" Known: {', '.join(models.keys())}")
sys.exit(1)
mc = models[model_name]
repo = mc.get("repo", "")
if quant:
print(f"Resolving {quant} from {repo} ...")
try:
files = hf_list_gguf(repo)
except RuntimeError as e:
print(f"[error] Cannot query HF API: {e}")
sys.exit(1)
q_up = quant.upper()
match = next((f for f in files if f["quant"].upper() == q_up), None)
if not match:
partials = [f for f in files if q_up in f["quant"].upper()]
if len(partials) == 1:
match = partials[0]
elif partials:
print(f"[warn] Multiple matches for '{quant}':")
for p in partials:
print(f" {p['quant']} {p['size_gb']}GB {p['name']}")
sys.exit(1)
else:
print(f"[error] Quant '{quant}' not found in {repo}")
print(f" Run: python model_manager.py quants {model_name}")
sys.exit(1)
old_file = mc.get("file", "")
mc["file"] = match["name"]
print(f" File : {old_file}{match['name']} ({match['size_gb']}GB)")
# Manage active_models list (max 2)
actives = get_active_models(cfg)
if model_name not in actives:
if len(actives) >= 2:
removed = actives.pop(0)
print(f" Removed '{removed}' from active list (max 2 slots)")
actives.append(model_name)
# Remove legacy field
cfg.pop("active_model", None)
cfg["active_models"] = actives
save_cfg(cfg)
st = _file_status(cfg, model_name)
print(f"\n✓ Active models: {actives}")
print(f" Added : {model_name}")
print(f" Repo : {repo}")
print(f" File : {mc.get('file', '')}")
if st["exists"]:
print(f" Disk : ✓ {st['size_gb']}GB — ready")
elif st["chunks"] > 0:
print(f" Disk : ✓ {st['chunks']} split chunks ({st['chunk_total_gb']}GB) — ready")
else:
d = model_dir_for(cfg, model_name)
print(f" Disk : ✗ Not downloaded")
print(f" Download: huggingface-cli download {repo} {mc.get('file','')} --local-dir {os.path.abspath(d)}/")
print(f"\nRestart server to apply: python app.py")
def cmd_remove(cfg: dict, model_name: str) -> None:
"""Remove a model from the active_models list."""
actives = get_active_models(cfg)
if model_name not in actives:
# Fuzzy match
matches = [n for n in actives if model_name.lower() in n.lower()]
if len(matches) == 1:
model_name = matches[0]
else:
print(f"[warn] '{model_name}' is not in active list: {actives}")
return
actives.remove(model_name)
cfg.pop("active_model", None)
cfg["active_models"] = actives
save_cfg(cfg)
print(f"✓ Removed '{model_name}' from active list.")
print(f" Active now: {actives}")
print(f" Restart server to apply: python app.py")
def cmd_info(cfg: dict) -> None:
print(json.dumps(cfg, indent=2, ensure_ascii=False))
def cmd_sync(cfg: dict, clean: bool = False) -> str:
"""
Check disk vs active models. If --clean, delete stale model files.
Prints READY if all active models are on disk, else DOWNLOAD.
"""
actives = get_active_models(cfg)
models = cfg.get("models", {})
if not actives:
print("[sync] No active_models configured")
print("DOWNLOAD")
return "DOWNLOAD"
all_ready = True
for active in actives:
if not active or active not in models:
print(f"[sync] Unknown model '{active}' — skipping")
all_ready = False
continue
mc = models[active]
expected_file = mc.get("file", "")
expected_base = os.path.splitext(expected_file)[0]
d = model_dir_for(cfg, active)
chunk_dir = os.path.join(d, "chunks")
# Catalogue everything in this model's dir
all_files: list = []
if os.path.isdir(d):
for f in os.listdir(d):
if f.endswith(".gguf"):
all_files.append((os.path.join(d, f), f == expected_file))
if os.path.isdir(chunk_dir):
for f in os.listdir(chunk_dir):
if f.endswith(".gguf") or f.endswith(".part"):
mine = f.startswith(expected_base + "-") or f.startswith(expected_base + ".")
all_files.append((os.path.join(chunk_dir, f), mine))
# Delete stale files (files that don't belong to expected_file)
stale = [p for p, mine in all_files if not mine]
if stale and clean:
for sp in stale:
try:
os.remove(sp)
print(f"[sync] Deleted stale: {os.path.basename(sp)}")
except Exception as e:
print(f"[sync] Warning — could not delete {os.path.basename(sp)}: {e}")
elif stale:
for sp in stale:
print(f"[sync] Stale (use --clean to remove): {os.path.basename(sp)}")
# Check if this model is ready
active_whole = os.path.join(d, expected_file)
active_ready = os.path.isfile(active_whole)
if not active_ready and os.path.isdir(chunk_dir):
chunks = [
f for f in os.listdir(chunk_dir)
if (f.startswith(expected_base + "-") or f.startswith(expected_base + "."))
and (f.endswith(".gguf") or f.endswith(".part"))
]
active_ready = len(chunks) > 0
if active_ready:
st = _file_status(cfg, active)
if st["exists"]:
print(f"[sync] {active}{expected_file} ({st['size_gb']}GB) — ready")
elif st["chunks"] > 0:
print(f"[sync] {active}{st['chunks']} chunks ({st['chunk_total_gb']}GB) — ready")
else:
print(f"[sync] {active}{expected_file} — not on disk")
all_ready = False
if all_ready:
print("READY")
return "READY"
else:
print("DOWNLOAD")
return "DOWNLOAD"
def _download_one(cfg: dict, active: str) -> bool:
"""Download a single model. Returns True on success."""
models = cfg.get("models", {})
if not active or active not in models:
print(f"[download] Unknown model '{active}'")
return False
mc = models[active]
repo = mc.get("repo", "")
file = mc.get("file", "")
d = model_dir_for(cfg, active)
if not repo or not file:
print(f"[download] Model '{active}' has no repo/file configured")
return False
os.makedirs(d, exist_ok=True)
out_path = os.path.join(d, file)
base = os.path.splitext(file)[0]
chunk_dir = os.path.join(d, "chunks")
if os.path.isfile(out_path):
sz = round(os.path.getsize(out_path) / 1024**3, 2)
print(f"[download] {active}: Already on disk — {file} ({sz}GB)")
return True
if os.path.isdir(chunk_dir):
chunks = [
f for f in os.listdir(chunk_dir)
if (f.startswith(base + "-") or f.startswith(base + "."))
and (f.endswith(".gguf") or f.endswith(".part"))
]
if chunks:
print(f"[download] {active}: Chunks already on disk ({len(chunks)} files) — skip")
return True
note = mc.get("note", "")
print(f"[download] Downloading {active}: {file}" + (f" ({note})" if note else ""))
print(f"[download] Repo : {repo}")
print(f"[download] Dest : {os.path.abspath(d)}/")
sys.stdout.flush()
try:
from huggingface_hub import hf_hub_download
except ImportError:
import subprocess
print("[download] Installing huggingface-hub ...", flush=True)
subprocess.run(
[sys.executable, "-m", "pip", "install", "-q", "--no-cache-dir", "huggingface-hub"],
capture_output=True)
from huggingface_hub import hf_hub_download
try:
path = hf_hub_download(repo_id=repo, filename=file, local_dir=d)
sz = round(os.path.getsize(path) / 1024**3, 2)
print(f"[download] {active}: Done — {file} ({sz}GB)")
return True
except Exception as e:
print(f"[error] {active}: Download failed: {e}", file=sys.stderr)
print(f" Manual: huggingface-cli download {repo} {file} --local-dir {os.path.abspath(d)}/",
file=sys.stderr)
return False
def cmd_download(cfg: dict) -> None:
"""Download all active models that are not yet on disk."""
actives = get_active_models(cfg)
if not actives:
print("[error] No active_models in install.json")
sys.exit(1)
any_failed = False
for active in actives:
ok = _download_one(cfg, active)
if not ok:
any_failed = True
if any_failed:
sys.exit(1)
# ── Flask-callable helpers (imported by app.py) ───────────────────────────────
def get_models_status(cfg: Optional[dict] = None) -> dict:
if cfg is None:
cfg = load_cfg()
models = cfg.get("models", {})
actives = get_active_models(cfg)
result = {}
for name, mc in models.items():
st = _file_status(cfg, name)
result[name] = {
"repo": mc.get("repo", ""),
"file": mc.get("file", ""),
"note": mc.get("note", ""),
"active": name in actives,
"active_slot": actives.index(name) + 1 if name in actives else None,
"on_disk": st["exists"] or st["chunks"] > 0,
"size_gb": st["size_gb"],
"chunks": st["chunks"],
"chunk_total_gb": st["chunk_total_gb"],
}
base = cfg.get("model_dir", "./model")
return {
"active_models": actives,
"models": result,
"model_dir": os.path.abspath(base),
}
def get_quants_for_model(name_or_repo: str, cfg: Optional[dict] = None) -> list:
if cfg is None:
cfg = load_cfg()
models = cfg.get("models", {})
if "/" in name_or_repo:
repo = name_or_repo
elif name_or_repo in models:
repo = models[name_or_repo].get("repo", "")
else:
matches = [n for n in models if name_or_repo.lower() in n.lower()]
repo = models[matches[0]].get("repo", "") if matches else name_or_repo
return hf_list_gguf(repo)
# ── CLI entry point ───────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
if not args:
print(__doc__)
sys.exit(0)
cmd = args[0].lower()
cfg = load_cfg()
if cmd == "list":
cmd_list(cfg)
elif cmd == "quants":
if len(args) < 2:
print("[error] Usage: python model_manager.py quants <ModelName|repo>")
sys.exit(1)
cmd_quants(cfg, args[1])
elif cmd == "use":
if len(args) < 2:
print("[error] Usage: python model_manager.py use <ModelName> [QUANT]")
sys.exit(1)
quant = args[2] if len(args) >= 3 else None
cmd_use(cfg, args[1], quant)
elif cmd == "remove":
if len(args) < 2:
print("[error] Usage: python model_manager.py remove <ModelName>")
sys.exit(1)
cmd_remove(cfg, args[1])
elif cmd == "info":
cmd_info(cfg)
elif cmd == "sync":
clean = "--clean" in args
cmd_sync(cfg, clean=clean)
elif cmd == "download":
cmd_download(cfg)
else:
print(f"[error] Unknown command: {cmd}")
print(" Commands: list | quants | use | remove | info | sync | download")
sys.exit(1)
if __name__ == "__main__":
main()