#!/usr/bin/env python3 """ model_manager.py — Multi-model manager for local LLM API Directory layout (new): model// ← per-model directory model//chunks/ ← split GGUF shards (100MB each) Commands: list List all configured models + disk status quants Query HuggingFace API — all GGUF files + sizes use [quant] Add model to active list (max 2 run at once) use Combined format — e.g. use Qwen3.5-4B-IQ4_NL use Full filename — e.g. use Qwen3.5-4B-IQ4_NL.gguf remove 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//""" 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 ") print(f"Remove model : python model_manager.py remove ") print(f"Change quant : python model_manager.py use ") print(f"List quants : python model_manager.py quants ") 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 ") sys.exit(1) cmd_quants(cfg, args[1]) elif cmd == "use": if len(args) < 2: print("[error] Usage: python model_manager.py use [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 ") 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()