mirror of
https://github.com/cloudstack-llc/mlx-knife.git
synced 2026-07-21 10:05:26 -04:00
ce46601d9d
• MXFP4 Quantization Support (MLX ≥0.29.0, MLX-LM ≥0.27.0) • GPT-OSS Reasoning Models with --hide-reasoning flag • Enhanced Show Command with improved quantization display • Documentation updates (README.md, TESTING.md) See CHANGELOG.md for complete technical details. Partial Issue #32 (GPT-OSS only, other reasoning models remain open).
1073 lines
42 KiB
Python
1073 lines
42 KiB
Python
# mlx_knife/cache_utils.py
|
|
|
|
import datetime
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Issue #31 hints reader
|
|
from .model_card import read_readme_front_matter, tokenizer_has_chat_template
|
|
|
|
DEFAULT_CACHE_ROOT = Path.home() / ".cache/huggingface"
|
|
CACHE_ROOT = Path(os.environ.get("HF_HOME", DEFAULT_CACHE_ROOT))
|
|
MODEL_CACHE = CACHE_ROOT / "hub"
|
|
|
|
# Global variable to track if warning was shown
|
|
_legacy_warning_shown = False
|
|
|
|
# Check for models in legacy location and warn user
|
|
_legacy_models = list(CACHE_ROOT.glob("models--*"))
|
|
_is_test_env = "test_cache" in str(CACHE_ROOT) or "PYTEST_CURRENT_TEST" in os.environ
|
|
if _legacy_models and not _legacy_warning_shown and not _is_test_env:
|
|
print(f"\n⚠️ Found {len(_legacy_models)} models in legacy location: {CACHE_ROOT}")
|
|
print(f" Please move them to: {MODEL_CACHE}")
|
|
print(f" Command: mv {CACHE_ROOT}/models--* {MODEL_CACHE}/")
|
|
print(" This warning will appear until models are moved.\n")
|
|
_legacy_warning_shown = True
|
|
|
|
|
|
def hf_to_cache_dir(hf_name: str) -> str:
|
|
if hf_name.startswith("models--"):
|
|
return hf_name
|
|
if "/" in hf_name:
|
|
org, model = hf_name.split("/", 1)
|
|
return f"models--{org}--{model}"
|
|
else:
|
|
return f"models--{hf_name}"
|
|
|
|
def cache_dir_to_hf(cache_name: str) -> str:
|
|
if cache_name.startswith("models--"):
|
|
remaining = cache_name[len("models--"):]
|
|
if "--" in remaining:
|
|
parts = remaining.split("--", 1)
|
|
return f"{parts[0]}/{parts[1]}"
|
|
else:
|
|
return remaining
|
|
return cache_name
|
|
|
|
def expand_model_name(model_name):
|
|
if "/" in model_name:
|
|
return model_name
|
|
mlx_candidate = f"mlx-community/{model_name}"
|
|
mlx_cache_dir = MODEL_CACHE / hf_to_cache_dir(mlx_candidate)
|
|
if mlx_cache_dir.exists():
|
|
return mlx_candidate
|
|
common_mlx_patterns = [
|
|
"Llama-", "Qwen", "Mistral", "Phi-", "Mixtral", "phi-", "deepseek"
|
|
]
|
|
for pattern in common_mlx_patterns:
|
|
if pattern in model_name:
|
|
return f"mlx-community/{model_name}"
|
|
return model_name
|
|
|
|
def find_matching_models(pattern):
|
|
"""Find models that match a partial pattern. Returns a list of (model_dir, hf_name) tuples."""
|
|
all_models = [d for d in MODEL_CACHE.iterdir() if d.name.startswith("models--")]
|
|
matches = []
|
|
|
|
for model_dir in all_models:
|
|
hf_name = cache_dir_to_hf(model_dir.name)
|
|
# Check if the pattern appears in the model name (case insensitive)
|
|
if pattern.lower() in hf_name.lower():
|
|
matches.append((model_dir, hf_name))
|
|
|
|
return matches
|
|
|
|
def hash_exists_in_local_cache(model_name, commit_hash):
|
|
"""Check if a specific commit hash exists in the local cache for a model.
|
|
|
|
Supports both full hashes and short hash prefixes (local resolution only).
|
|
|
|
Args:
|
|
model_name: Full model name (e.g., 'mlx-community/Phi-3-mini-4k-instruct-4bit')
|
|
commit_hash: Commit hash to check for (short or full)
|
|
|
|
Returns:
|
|
Full hash if exists in local cache, None otherwise
|
|
"""
|
|
base_cache_dir = MODEL_CACHE / hf_to_cache_dir(model_name)
|
|
if not base_cache_dir.exists():
|
|
return None
|
|
|
|
snapshots_dir = base_cache_dir / "snapshots"
|
|
if not snapshots_dir.exists():
|
|
return None
|
|
|
|
# Check for exact match first (full hash)
|
|
hash_dir = snapshots_dir / commit_hash
|
|
if hash_dir.exists():
|
|
return commit_hash
|
|
|
|
# Check for short hash match (local resolution)
|
|
if len(commit_hash) < 40:
|
|
for snapshot_dir in snapshots_dir.iterdir():
|
|
if snapshot_dir.is_dir() and snapshot_dir.name.startswith(commit_hash):
|
|
return snapshot_dir.name # Return full hash
|
|
|
|
return None
|
|
|
|
def resolve_single_model(model_spec):
|
|
"""
|
|
Resolve a model spec to a single model, supporting fuzzy matching.
|
|
Returns (model_path, model_name, commit_hash) or (None, None, None) if failed.
|
|
Prints appropriate error messages for ambiguous matches.
|
|
"""
|
|
# Parse the model spec (handles @commit_hash syntax)
|
|
model_name, commit_hash = parse_model_spec(model_spec)
|
|
|
|
# Try exact match first
|
|
base_cache_dir = MODEL_CACHE / hf_to_cache_dir(model_name)
|
|
if base_cache_dir.exists():
|
|
return get_model_path(model_spec)
|
|
|
|
# Extract the base name (without @commit_hash) for fuzzy matching
|
|
base_spec = model_spec.split('@')[0] if '@' in model_spec else model_spec
|
|
|
|
# Try fuzzy matching
|
|
matches = find_matching_models(base_spec)
|
|
|
|
if not matches:
|
|
print(f"No models found matching '{base_spec}'!")
|
|
return None, None, None
|
|
elif len(matches) == 1:
|
|
# Unambiguous match - use the found model with the original commit hash (if any)
|
|
found_model_dir, found_hf_name = matches[0]
|
|
if commit_hash:
|
|
resolved_spec = f"{found_hf_name}@{commit_hash}"
|
|
else:
|
|
resolved_spec = found_hf_name
|
|
return get_model_path(resolved_spec)
|
|
elif len(matches) > 1 and commit_hash:
|
|
# Issue #13: Hash-based disambiguation for ambiguous model names
|
|
for _model_dir, hf_name in matches:
|
|
resolved_hash = hash_exists_in_local_cache(hf_name, commit_hash)
|
|
if resolved_hash:
|
|
resolved_spec = f"{hf_name}@{resolved_hash}"
|
|
return get_model_path(resolved_spec)
|
|
|
|
# Hash not found in any candidate model
|
|
print(f"Hash '{commit_hash}' not found in any model matching '{base_spec}'")
|
|
print("Available models:")
|
|
for _, hf_name in sorted(matches, key=lambda x: x[1]):
|
|
print(f" {hf_name}")
|
|
return None, None, None
|
|
else:
|
|
# Multiple matches without hash - show error with suggestions
|
|
print(f"Multiple models match '{base_spec}'. Please be more specific:")
|
|
for _, hf_name in sorted(matches, key=lambda x: x[1]):
|
|
print(f" {hf_name}")
|
|
return None, None, None
|
|
|
|
def get_model_path(model_spec):
|
|
model_name, commit_hash = parse_model_spec(model_spec)
|
|
base_cache_dir = MODEL_CACHE / hf_to_cache_dir(model_name)
|
|
if not base_cache_dir.exists():
|
|
return None, model_name, commit_hash
|
|
if commit_hash:
|
|
hash_dir = base_cache_dir / "snapshots" / commit_hash
|
|
if hash_dir.exists():
|
|
return hash_dir, model_name, commit_hash
|
|
else:
|
|
return None, model_name, commit_hash
|
|
snapshots_dir = base_cache_dir / "snapshots"
|
|
if snapshots_dir.exists():
|
|
snapshots = [d for d in snapshots_dir.iterdir() if d.is_dir()]
|
|
if snapshots:
|
|
latest = max(snapshots, key=lambda x: x.stat().st_mtime)
|
|
return latest, model_name, latest.name
|
|
# Return base_cache_dir for corrupted models so rm_model can handle them
|
|
return base_cache_dir, model_name, commit_hash
|
|
|
|
def parse_model_spec(model_spec):
|
|
if "@" in model_spec:
|
|
model_name, commit_hash = model_spec.rsplit("@", 1)
|
|
model_name = expand_model_name(model_name)
|
|
return model_name, commit_hash
|
|
model_name = expand_model_name(model_spec)
|
|
return model_name, None
|
|
|
|
def get_model_size(model_path):
|
|
if not model_path.exists():
|
|
return "?"
|
|
total_size = 0
|
|
for file in model_path.rglob("*"):
|
|
if file.is_file():
|
|
total_size += file.stat().st_size
|
|
if total_size >= 1_000_000_000:
|
|
return f"{total_size / 1_000_000_000:.1f} GB"
|
|
elif total_size >= 1_000_000:
|
|
return f"{total_size / 1_000_000:.1f} MB"
|
|
else:
|
|
return f"{total_size / 1_000:.1f} KB"
|
|
|
|
def get_model_modified(model_path):
|
|
if not model_path.exists():
|
|
return "?"
|
|
mtime = model_path.stat().st_mtime
|
|
now = datetime.datetime.now()
|
|
modified = datetime.datetime.fromtimestamp(mtime)
|
|
diff = now - modified
|
|
if diff.days > 0:
|
|
return f"{diff.days} days ago"
|
|
elif diff.seconds > 3600:
|
|
hours = diff.seconds // 3600
|
|
return f"{hours} hours ago"
|
|
else:
|
|
minutes = diff.seconds // 60
|
|
return f"{minutes} minutes ago"
|
|
|
|
def detect_framework(model_path, hf_name):
|
|
"""Detect model framework with lenient hints (Issue #31)."""
|
|
# 1) org hint
|
|
if "mlx-community" in hf_name:
|
|
return "MLX"
|
|
|
|
# 2) README front matter: tags contains 'mlx' OR library_name == 'mlx'
|
|
try:
|
|
tags, pipeline, lib = read_readme_front_matter(Path(model_path))
|
|
if (lib and lib.lower() == "mlx") or (tags and any((t or '').lower() == "mlx" for t in tags)):
|
|
return "MLX"
|
|
except Exception:
|
|
pass
|
|
|
|
# 3) Fallback by file types
|
|
snapshots_dir = Path(model_path) / "snapshots"
|
|
if not snapshots_dir.exists():
|
|
return "Unknown"
|
|
has_gguf = any(snapshots_dir.glob("*/*.gguf"))
|
|
has_safetensors = any(snapshots_dir.glob("*/*.safetensors"))
|
|
has_pytorch_bin = any(snapshots_dir.glob("*/pytorch_model.bin"))
|
|
has_config = any(snapshots_dir.glob("*/*.json"))
|
|
total_size = get_model_size(Path(model_path))
|
|
try:
|
|
size_mb = float(total_size.replace(" GB", "000").replace(" MB", "").replace(" KB", "0").replace(" ", ""))
|
|
except Exception:
|
|
size_mb = 0
|
|
if has_gguf:
|
|
return "GGUF"
|
|
if size_mb < 10:
|
|
return "Tokenizer"
|
|
if (has_safetensors and has_config) or has_pytorch_bin:
|
|
return "PyTorch"
|
|
return "Unknown"
|
|
|
|
|
|
def detect_model_type(model_path, hf_name):
|
|
"""Detect model type with priority hints (Issue #31)."""
|
|
# 1) tokenizer chat_template
|
|
try:
|
|
if tokenizer_has_chat_template(Path(model_path)):
|
|
return "chat"
|
|
except Exception:
|
|
pass
|
|
|
|
# 2) README hints
|
|
try:
|
|
tags, pipeline, _ = read_readme_front_matter(Path(model_path))
|
|
tset = {t.lower() for t in (tags or [])}
|
|
if pipeline == "text-generation" or any(k in tset for k in {"chat", "instruct"}):
|
|
return "chat"
|
|
if pipeline == "sentence-similarity" or any(k in tset for k in {"embedding", "embeddings"}):
|
|
return "embedding"
|
|
except Exception:
|
|
pass
|
|
|
|
# 3) Fallback by name
|
|
name = str(hf_name).lower()
|
|
if "instruct" in name or "chat" in name:
|
|
return "chat"
|
|
if "embed" in name:
|
|
return "embedding"
|
|
return "base"
|
|
|
|
|
|
def get_quantization_info(model_path):
|
|
"""Extract quantization information from model config."""
|
|
try:
|
|
config_path = Path(model_path) / "config.json"
|
|
if not config_path.exists():
|
|
return None
|
|
with open(config_path) as f:
|
|
cfg = json.load(f)
|
|
return cfg.get("quantization")
|
|
except Exception:
|
|
return None
|
|
|
|
def get_model_hash(model_path):
|
|
snapshots_dir = model_path / "snapshots"
|
|
if not snapshots_dir.exists():
|
|
return "--------"
|
|
snapshots = [d for d in snapshots_dir.iterdir() if d.is_dir()]
|
|
if not snapshots:
|
|
return "--------"
|
|
latest = max(snapshots, key=lambda x: x.stat().st_mtime)
|
|
return latest.name[:8]
|
|
|
|
def is_model_healthy(model_spec):
|
|
"""Strict health check for 1.x (backport of #27 rules).
|
|
|
|
Rules:
|
|
- config.json must exist and be valid non-empty JSON object.
|
|
- If a safetensors or PyTorch index exists, all referenced shards must exist, be non-empty,
|
|
and not be Git LFS pointer files.
|
|
- Without an index: if multi-shard pattern files exist (model-XXXXX-of-YYYYY.*), require index (unhealthy without index).
|
|
Single-file weights (*.safetensors/*.bin/*.gguf) are allowed if non-empty and not LFS pointers.
|
|
- Any '.partial'/'partial' or '.tmp' artifacts anywhere => unhealthy.
|
|
- Recursive LFS pointer scan for suspiciously small files (<200B).
|
|
"""
|
|
|
|
# Resolve model path: accept direct directory paths or model specs
|
|
candidate = Path(str(model_spec))
|
|
if candidate.exists() and candidate.is_dir():
|
|
model_path = candidate
|
|
else:
|
|
model_path, _, _ = resolve_single_model(model_spec)
|
|
if not model_path:
|
|
return False
|
|
|
|
# 1) config.json must be valid, non-empty dict
|
|
config_path = model_path / "config.json"
|
|
if not config_path.exists():
|
|
return False
|
|
try:
|
|
with open(config_path) as f:
|
|
config_data = json.load(f)
|
|
if not isinstance(config_data, dict) or not config_data:
|
|
return False
|
|
except (OSError, json.JSONDecodeError):
|
|
return False
|
|
|
|
# 2) Fail fast on partial/tmp markers anywhere in the snapshot
|
|
for p in model_path.rglob("*"):
|
|
name = p.name.lower()
|
|
if ".partial" in name or name.endswith(".partial") or name.endswith(".tmp") or "partial" in name:
|
|
return False
|
|
|
|
# Helper: detect Git LFS pointer file
|
|
def _is_lfs_pointer(fp: Path) -> bool:
|
|
try:
|
|
if fp.stat().st_size >= 200:
|
|
return False
|
|
with open(fp, "rb") as f:
|
|
head = f.read(200)
|
|
return b"version https://git-lfs.github.com/spec/v1" in head
|
|
except Exception:
|
|
return False
|
|
|
|
# Helper: verify referenced shards
|
|
def _verify_shards(files: list[Path]) -> bool:
|
|
if not files:
|
|
return False
|
|
for f in files:
|
|
try:
|
|
if (not f.exists()) or f.stat().st_size == 0:
|
|
return False
|
|
if _is_lfs_pointer(f):
|
|
return False
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
# 3) Index-aware checks (safetensors or PyTorch)
|
|
st_index = model_path / "model.safetensors.index.json"
|
|
pt_index = model_path / "pytorch_model.bin.index.json"
|
|
if st_index.exists() or pt_index.exists():
|
|
index_files = [p for p in [st_index, pt_index] if p.exists()]
|
|
for idx in index_files:
|
|
try:
|
|
with open(idx) as f:
|
|
idx_data = json.load(f)
|
|
weight_map = idx_data.get("weight_map")
|
|
if not isinstance(weight_map, dict) or not weight_map:
|
|
return False
|
|
referenced = sorted(set(weight_map.values()))
|
|
shard_paths = [model_path / r for r in referenced]
|
|
if not _verify_shards(shard_paths):
|
|
return False
|
|
except (OSError, json.JSONDecodeError):
|
|
return False
|
|
# Also ensure no recursive LFS pointers elsewhere
|
|
ok, _ = check_lfs_corruption(model_path)
|
|
return ok
|
|
|
|
# 4) No index present — detect multi-shard pattern
|
|
# If pattern shards exist, require index (unhealthy without index by policy parity with 2.0)
|
|
import re
|
|
shard_re = re.compile(r"model-([0-9]{5})-of-([0-9]{5})\.(safetensors|bin)")
|
|
pattern_files = []
|
|
for f in model_path.glob("*"):
|
|
if f.is_file():
|
|
m = shard_re.match(f.name)
|
|
if m:
|
|
pattern_files.append((f, int(m.group(1)), int(m.group(2))))
|
|
if pattern_files:
|
|
# Even if complete by pattern, absence of index => unhealthy
|
|
return False
|
|
|
|
# 5) Single-file weights fallback (includes GGUF)
|
|
weight_files = list(model_path.rglob("*.safetensors")) + list(model_path.rglob("*.bin")) + list(model_path.rglob("*.gguf"))
|
|
# Exclude known pattern shards from consideration (handled above)
|
|
filtered_weights = []
|
|
for f in weight_files:
|
|
name = f.name
|
|
if shard_re.match(name):
|
|
continue
|
|
filtered_weights.append(f)
|
|
if not filtered_weights:
|
|
return False
|
|
for wf in filtered_weights:
|
|
if wf.stat().st_size == 0 or _is_lfs_pointer(wf):
|
|
return False
|
|
|
|
# Final recursive LFS scan
|
|
ok, _ = check_lfs_corruption(model_path)
|
|
return ok
|
|
|
|
def check_lfs_corruption(model_path):
|
|
"""Recursively scan for Git LFS pointer files (suspiciously small files)."""
|
|
corrupted_files = []
|
|
for file_path in model_path.rglob("*"):
|
|
try:
|
|
if file_path.is_file() and file_path.stat().st_size < 200:
|
|
with open(file_path, 'rb') as f:
|
|
header = f.read(200)
|
|
if b'version https://git-lfs.github.com/spec/v1' in header:
|
|
corrupted_files.append(str(file_path.relative_to(model_path)))
|
|
except Exception:
|
|
# Ignore unreadable files in corruption scan, keep conservative
|
|
continue
|
|
if corrupted_files:
|
|
return False, f"LFS pointers instead of files: {', '.join(corrupted_files)}"
|
|
return True, "No LFS corruption detected"
|
|
|
|
def check_model_health(model_spec):
|
|
model_path, model_name, commit_hash = resolve_single_model(model_spec)
|
|
if not model_path:
|
|
# resolve_single_model already printed the appropriate error message
|
|
return False
|
|
|
|
print(f"Checking model: {model_name}")
|
|
if commit_hash:
|
|
print(f"Hash: {commit_hash}")
|
|
|
|
# Use the robust health check
|
|
if is_model_healthy(model_spec):
|
|
print("\n[OK] Model is healthy and usable!")
|
|
return True
|
|
else:
|
|
# Detailed diagnosis for WHY it's unhealthy
|
|
print("\n[ERROR] Model is corrupted. Detailed diagnosis:")
|
|
|
|
# Check config.json
|
|
config_path = model_path / "config.json"
|
|
if not config_path.exists():
|
|
print(" - config.json missing")
|
|
else:
|
|
try:
|
|
with open(config_path) as f:
|
|
config_data = json.load(f)
|
|
if not isinstance(config_data, dict) or len(config_data) == 0:
|
|
print(" - config.json is empty or invalid")
|
|
else:
|
|
print(" - config.json found and valid")
|
|
except (OSError, json.JSONDecodeError):
|
|
print(" - config.json exists but contains invalid JSON")
|
|
|
|
# Check weight files (including gguf support like is_model_healthy)
|
|
weight_files = list(model_path.glob("*.safetensors")) + list(model_path.glob("*.bin")) + list(model_path.glob("*.gguf"))
|
|
if not weight_files:
|
|
weight_files = list(model_path.glob("**/*.safetensors")) + list(model_path.glob("**/*.bin")) + list(model_path.glob("**/*.gguf"))
|
|
|
|
if weight_files:
|
|
total_size = sum(f.stat().st_size for f in weight_files)
|
|
size_mb = total_size / (1024 * 1024)
|
|
print(f" - Model weights found ({len(weight_files)} files, {size_mb:.1f}MB)")
|
|
elif (model_path / "model.safetensors.index.json").exists():
|
|
# Check multi-file model
|
|
try:
|
|
with open(model_path / "model.safetensors.index.json") as f:
|
|
index = json.load(f)
|
|
if 'weight_map' in index:
|
|
referenced_files = set(index['weight_map'].values())
|
|
existing_files = [f for f in referenced_files if (model_path / f).exists()]
|
|
if existing_files:
|
|
total_size = sum((model_path / f).stat().st_size for f in existing_files)
|
|
size_mb = total_size / (1024 * 1024)
|
|
print(f" - Multi-file weights ({len(existing_files)}/{len(referenced_files)} files, {size_mb:.1f}MB)")
|
|
if len(existing_files) < len(referenced_files):
|
|
print(" - Incomplete multi-file model")
|
|
else:
|
|
print(" - Multi-file model index found but no weight files exist")
|
|
else:
|
|
print(" - Multi-file model index is invalid")
|
|
except Exception as e:
|
|
print(f" - Multi-file model index error: {e}")
|
|
else:
|
|
print(" - No model weights found (.safetensors, .bin, .gguf)")
|
|
|
|
# Check LFS corruption
|
|
lfs_ok, lfs_msg = check_lfs_corruption(model_path)
|
|
if not lfs_ok:
|
|
print(f" - {lfs_msg}")
|
|
else:
|
|
print(f" - {lfs_msg}")
|
|
|
|
# Show framework
|
|
framework = detect_framework(model_path.parent.parent, model_name)
|
|
print(f" - Framework: {framework}")
|
|
|
|
# Offer deletion for corrupted models
|
|
confirm = input("\nModel appears corrupted. Delete? [y/N] ")
|
|
if confirm.lower() == "y":
|
|
import errno
|
|
import shutil
|
|
try:
|
|
if commit_hash:
|
|
# Delete specific hash/snapshot
|
|
shutil.rmtree(model_path)
|
|
print(f"Hash {commit_hash} deleted.")
|
|
else:
|
|
# Delete entire model directory (go up from snapshots or use base_cache_dir)
|
|
if model_path.name.startswith("models--"):
|
|
# model_path is base_cache_dir (corrupted model case)
|
|
shutil.rmtree(model_path)
|
|
else:
|
|
# model_path is snapshot dir
|
|
model_base_dir = model_path.parent.parent
|
|
shutil.rmtree(model_base_dir)
|
|
print(f"Model {model_name} deleted.")
|
|
except PermissionError as e:
|
|
print(f"[ERROR] Permission denied: Cannot delete {e.filename}")
|
|
print(" Try running with appropriate permissions or manually delete the directory.")
|
|
except OSError as e:
|
|
if e.errno == errno.ENOTEMPTY:
|
|
print(f"[ERROR] Directory not empty: {e.filename}")
|
|
print(" Another process may be using this model.")
|
|
elif e.errno == errno.EACCES:
|
|
print(f"[ERROR] Access denied: {e.filename}")
|
|
else:
|
|
print(f"[ERROR] OS Error while deleting: {e}")
|
|
except Exception as e:
|
|
print(f"[ERROR] Unexpected error while deleting: {type(e).__name__}: {e}")
|
|
|
|
return False
|
|
|
|
def check_all_models_health():
|
|
models = [d for d in MODEL_CACHE.iterdir() if d.name.startswith("models--")]
|
|
if not models:
|
|
print("No models found in HuggingFace cache.")
|
|
return
|
|
print(f"Checking {len(models)} models for integrity...\n")
|
|
healthy_models = []
|
|
problematic_models = []
|
|
for model_dir in sorted(models, key=lambda x: x.stat().st_mtime, reverse=True):
|
|
hf_name = cache_dir_to_hf(model_dir.name)
|
|
model_hash = get_model_hash(model_dir)
|
|
print(f"{hf_name} ({model_hash})")
|
|
if is_model_healthy(hf_name):
|
|
healthy_models.append((hf_name, model_hash))
|
|
print(" [OK] Healthy\n")
|
|
else:
|
|
problematic_models.append((hf_name, model_hash))
|
|
print(" [ERROR] Problematic\n")
|
|
print("=" * 50)
|
|
print("Summary:")
|
|
print(f"[OK] Healthy models: {len(healthy_models)}")
|
|
print(f"[ERROR] Problematic models: {len(problematic_models)}")
|
|
if problematic_models:
|
|
print("\n[WARNING] Problematic models:")
|
|
for name, hash_id in problematic_models:
|
|
print(f" - {name} ({hash_id})")
|
|
print("\nRepair tips:")
|
|
print(" python mlx_knife.cli pull <model-name> # Re-download")
|
|
print(" python mlx_knife.cli rm <model-name> # Delete")
|
|
print(" python mlx_knife.cli health <model-name> # Show details")
|
|
return len(problematic_models) == 0
|
|
|
|
def list_models(show_all=False, framework_filter=None, show_health=False, single_model=None, verbose=False):
|
|
if single_model:
|
|
# Try exact match first
|
|
expanded_model = expand_model_name(single_model)
|
|
model_dir = MODEL_CACHE / hf_to_cache_dir(expanded_model)
|
|
|
|
if model_dir.exists():
|
|
models = [model_dir]
|
|
else:
|
|
# If exact match fails, do partial name matching
|
|
if not MODEL_CACHE.exists():
|
|
print(f"No models found matching '{single_model}' - cache directory doesn't exist yet.")
|
|
print("Use 'mlxk pull <model-name>' to download models first.")
|
|
return
|
|
all_models = [d for d in MODEL_CACHE.iterdir() if d.name.startswith("models--")]
|
|
matching_models = []
|
|
|
|
for model_dir in all_models:
|
|
hf_name = cache_dir_to_hf(model_dir.name)
|
|
# Check if the pattern appears in the model name (case insensitive)
|
|
if single_model.lower() in hf_name.lower():
|
|
matching_models.append(model_dir)
|
|
|
|
if not matching_models:
|
|
print(f"No models found matching '{single_model}'!")
|
|
return
|
|
|
|
models = matching_models
|
|
else:
|
|
if not MODEL_CACHE.exists():
|
|
print("No models found - cache directory doesn't exist yet.")
|
|
print("Use 'mlxk pull <model-name>' to download models first.")
|
|
return
|
|
models = [d for d in MODEL_CACHE.iterdir() if d.name.startswith("models--")]
|
|
if not models:
|
|
print("No models found in HuggingFace cache.")
|
|
return
|
|
if show_health:
|
|
if show_all:
|
|
print(f"{'NAME':<40} {'ID':<10} {'SIZE':<10} {'MODIFIED':<15} {'FRAMEWORK':<10} {'TYPE':<10} {'HEALTH':<8}")
|
|
else:
|
|
print(f"{'NAME':<40} {'ID':<10} {'SIZE':<10} {'MODIFIED':<15} {'HEALTH':<8}")
|
|
else:
|
|
if show_all:
|
|
print(f"{'NAME':<40} {'ID':<10} {'SIZE':<10} {'MODIFIED':<15} {'FRAMEWORK':<10} {'TYPE':<10}")
|
|
else:
|
|
print(f"{'NAME':<40} {'ID':<10} {'SIZE':<10} {'MODIFIED':<15}")
|
|
for m in sorted(models, key=lambda x: x.stat().st_mtime, reverse=True):
|
|
hf_name = cache_dir_to_hf(m.name)
|
|
size = get_model_size(m)
|
|
modified = get_model_modified(m)
|
|
model_hash = get_model_hash(m)
|
|
framework = detect_framework(m, hf_name)
|
|
model_type = detect_model_type(m, hf_name)
|
|
if framework_filter and framework.lower() != framework_filter:
|
|
continue
|
|
# Default (strict) list: show only MLX chat models
|
|
if not show_all and not framework_filter:
|
|
if framework != "MLX":
|
|
continue
|
|
if model_type != "chat":
|
|
continue
|
|
# Handle display name based on verbose flag
|
|
display_name = hf_name
|
|
if hf_name.startswith("mlx-community/") and not verbose:
|
|
# For MLX models, hide prefix unless verbose is set
|
|
display_name = hf_name[len("mlx-community/"):]
|
|
health_status = ""
|
|
if show_health:
|
|
health_status = "[OK]" if is_model_healthy(hf_name) else "[ERR]"
|
|
if show_all:
|
|
print(f"{display_name:<40} {model_hash:<10} {size:<10} {modified:<15} {framework:<10} {model_type:<10} {health_status:<8}")
|
|
else:
|
|
print(f"{display_name:<40} {model_hash:<10} {size:<10} {modified:<15} {health_status:<8}")
|
|
else:
|
|
if show_all:
|
|
print(f"{display_name:<40} {model_hash:<10} {size:<10} {modified:<15} {framework:<10} {model_type:<10}")
|
|
else:
|
|
print(f"{display_name:<40} {model_hash:<10} {size:<10} {modified:<15}")
|
|
|
|
def run_model(model_spec, prompt=None, interactive=False, temperature=0.7,
|
|
max_tokens=500, top_p=0.9, repetition_penalty=1.1, stream=True,
|
|
use_chat_template=True, hide_reasoning=False, verbose=False):
|
|
"""Run an MLX model with enhanced features.
|
|
|
|
Args:
|
|
model_spec: Model specification (name[@hash])
|
|
prompt: Input prompt (if None and not interactive, enters interactive mode)
|
|
interactive: Force interactive mode
|
|
temperature: Sampling temperature
|
|
max_tokens: Maximum tokens to generate
|
|
top_p: Top-p sampling parameter
|
|
repetition_penalty: Penalty for repeated tokens
|
|
stream: Whether to stream output
|
|
"""
|
|
model_path, model_name, commit_hash = resolve_single_model(model_spec)
|
|
if not model_path:
|
|
print(f"Use: mlxk pull {model_spec}")
|
|
sys.exit(1)
|
|
|
|
framework = detect_framework(model_path.parent.parent, model_name)
|
|
if framework != "MLX":
|
|
print(f"Model {model_name} is not MLX-compatible (Framework: {framework})!")
|
|
print("Use MLX-Community models: https://huggingface.co/mlx-community")
|
|
sys.exit(1)
|
|
|
|
# Try to use the enhanced runner (import module to allow monkeypatching in tests)
|
|
try:
|
|
from . import mlx_runner as _mr
|
|
|
|
_mr.run_model_enhanced(
|
|
model_path=str(model_path),
|
|
prompt=prompt,
|
|
interactive=interactive,
|
|
max_tokens=max_tokens,
|
|
temperature=temperature,
|
|
top_p=top_p,
|
|
repetition_penalty=repetition_penalty,
|
|
stream=stream,
|
|
use_chat_template=use_chat_template,
|
|
hide_reasoning=hide_reasoning,
|
|
verbose=verbose,
|
|
)
|
|
except ImportError:
|
|
# Fallback to subprocess if mlx_runner is not available
|
|
print("[WARNING] Enhanced runner not available, falling back to subprocess mode")
|
|
print(f"Running model: {model_name}")
|
|
if commit_hash:
|
|
print(f"Hash: {commit_hash}")
|
|
print(f"Cache path: {model_path}")
|
|
|
|
if interactive or prompt is None:
|
|
print("Interactive mode not supported in fallback mode")
|
|
prompt = prompt or "Hello"
|
|
|
|
print(f"Prompt: {prompt}\n")
|
|
os.system(f'python -m mlx_lm generate --model "{model_path}" --prompt "{prompt}"')
|
|
|
|
def show_model(model_spec, show_files=False, show_config=False):
|
|
"""Show detailed information about a specific model."""
|
|
model_path, model_name, commit_hash = resolve_single_model(model_spec)
|
|
|
|
if not model_path:
|
|
return False
|
|
|
|
# Basic information
|
|
print(f"Model: {model_name}")
|
|
print(f"Path: {model_path}")
|
|
|
|
if commit_hash:
|
|
print(f"Snapshot: {commit_hash}")
|
|
else:
|
|
# Show current snapshot hash
|
|
current_hash = model_path.name
|
|
print(f"Snapshot: {current_hash}")
|
|
|
|
# Size
|
|
size = get_model_size(model_path)
|
|
print(f"Size: {size}")
|
|
|
|
# Modified time
|
|
modified = get_model_modified(model_path)
|
|
print(f"Modified: {modified}")
|
|
|
|
# Framework / Type
|
|
framework = detect_framework(model_path.parent.parent, model_name)
|
|
model_type = detect_model_type(model_path.parent.parent, model_name)
|
|
print(f"Framework: {framework}")
|
|
print(f"Type: {model_type}")
|
|
|
|
# Quantization info (if available)
|
|
quant_info = get_quantization_info(model_path)
|
|
if quant_info:
|
|
if isinstance(quant_info, dict):
|
|
# Show main quantization config (compact format)
|
|
main_config = []
|
|
if "mode" in quant_info:
|
|
main_config.append(f"mode: {quant_info['mode']}")
|
|
if "bits" in quant_info:
|
|
main_config.append(f"{quant_info['bits']}-bit")
|
|
if "group_size" in quant_info:
|
|
main_config.append(f"group_size: {quant_info['group_size']}")
|
|
|
|
if main_config:
|
|
print(f"Quantization: {', '.join(main_config)}")
|
|
if "mode" in quant_info:
|
|
print(f" Advanced mode '{quant_info['mode']}' (requires MLX ≥0.29.0, MLX-LM ≥0.27.0)")
|
|
else:
|
|
print(f"Quantization: {quant_info}")
|
|
|
|
# Quantization and Precision info
|
|
config_path = model_path / "config.json"
|
|
quantization_info = None
|
|
precision_info = None
|
|
gguf_variants = []
|
|
|
|
if config_path.exists():
|
|
try:
|
|
with open(config_path) as f:
|
|
config_data = json.load(f)
|
|
|
|
# 1. Check for explicit quantization field (MLX style)
|
|
if "quantization" in config_data and isinstance(config_data["quantization"], dict):
|
|
quant = config_data["quantization"]
|
|
if "bits" in quant:
|
|
quantization_info = f"{quant['bits']}-bit"
|
|
precision_info = f"int{quant['bits']}"
|
|
if "group_size" in quant:
|
|
quantization_info += f" (group_size: {quant['group_size']})"
|
|
|
|
# 2. Check torch_dtype (HuggingFace standard)
|
|
elif "torch_dtype" in config_data:
|
|
dtype = config_data["torch_dtype"]
|
|
precision_info = dtype
|
|
# Check if model name suggests quantization
|
|
name_lower = model_name.lower()
|
|
if "4bit" in name_lower or "-4b" in name_lower:
|
|
quantization_info = "4-bit (inferred from name)"
|
|
elif "8bit" in name_lower or "-8b" in name_lower:
|
|
quantization_info = "8-bit (inferred from name)"
|
|
else:
|
|
quantization_info = "No quantization detected"
|
|
|
|
# 3. Special handling for GGUF files
|
|
gguf_files = sorted(list(model_path.glob("*.gguf")))
|
|
if gguf_files and not quantization_info:
|
|
# Collect all GGUF variants
|
|
gguf_variants = []
|
|
for f in gguf_files:
|
|
name = f.name
|
|
size_mb = f.stat().st_size / (1024 * 1024)
|
|
|
|
# Parse quantization type from filename
|
|
name_lower = name.lower()
|
|
if "q2_k" in name_lower:
|
|
variant_info = f"Q2_K (2-bit, {size_mb:.0f} MB)"
|
|
elif "q3_k_s" in name_lower:
|
|
variant_info = f"Q3_K_S (3-bit small, {size_mb:.0f} MB)"
|
|
elif "q3_k_m" in name_lower:
|
|
variant_info = f"Q3_K_M (3-bit medium, {size_mb:.0f} MB)"
|
|
elif "q3_k_l" in name_lower:
|
|
variant_info = f"Q3_K_L (3-bit large, {size_mb:.0f} MB)"
|
|
elif "q3_k" in name_lower:
|
|
variant_info = f"Q3_K (3-bit, {size_mb:.0f} MB)"
|
|
elif "q4_0" in name_lower:
|
|
variant_info = f"Q4_0 (4-bit, {size_mb:.0f} MB)"
|
|
elif "q4_k_s" in name_lower:
|
|
variant_info = f"Q4_K_S (4-bit small, {size_mb:.0f} MB)"
|
|
elif "q4_k_m" in name_lower:
|
|
variant_info = f"Q4_K_M (4-bit medium, {size_mb:.0f} MB)"
|
|
elif "q4_k" in name_lower:
|
|
variant_info = f"Q4_K (4-bit, {size_mb:.0f} MB)"
|
|
elif "q5_0" in name_lower:
|
|
variant_info = f"Q5_0 (5-bit, {size_mb:.0f} MB)"
|
|
elif "q5_k_s" in name_lower:
|
|
variant_info = f"Q5_K_S (5-bit small, {size_mb:.0f} MB)"
|
|
elif "q5_k_m" in name_lower:
|
|
variant_info = f"Q5_K_M (5-bit medium, {size_mb:.0f} MB)"
|
|
elif "q5_k" in name_lower:
|
|
variant_info = f"Q5_K (5-bit, {size_mb:.0f} MB)"
|
|
elif "q6_k" in name_lower:
|
|
variant_info = f"Q6_K (6-bit, {size_mb:.0f} MB)"
|
|
elif "q8_0" in name_lower:
|
|
variant_info = f"Q8_0 (8-bit, {size_mb:.0f} MB)"
|
|
else:
|
|
variant_info = f"{name} ({size_mb:.0f} MB)"
|
|
|
|
gguf_variants.append(variant_info)
|
|
|
|
if len(gguf_variants) > 1:
|
|
quantization_info = "Multiple GGUF variants available"
|
|
precision_info = "gguf (see variants below)"
|
|
elif len(gguf_variants) == 1:
|
|
quantization_info = gguf_variants[0].split(' (')[0]
|
|
precision_info = "gguf"
|
|
else:
|
|
quantization_info = "GGUF format (quantization unknown)"
|
|
precision_info = "gguf"
|
|
|
|
except (OSError, json.JSONDecodeError, KeyError):
|
|
pass
|
|
|
|
# Display quantization and precision info
|
|
if quantization_info:
|
|
print(f"Quantization: {quantization_info}")
|
|
else:
|
|
print("Quantization: Unknown (no info in config)")
|
|
|
|
if precision_info:
|
|
print(f"Precision: {precision_info}")
|
|
else:
|
|
print("Precision: Unknown")
|
|
|
|
# Display GGUF variants if available
|
|
if gguf_variants and len(gguf_variants) > 1:
|
|
print("\nAvailable GGUF variants:")
|
|
for variant in gguf_variants:
|
|
print(f" - {variant}")
|
|
|
|
# Health status
|
|
health_ok = is_model_healthy(model_name)
|
|
if health_ok:
|
|
print("Health: [OK]")
|
|
else:
|
|
print("Health: [ERROR] CORRUPTED")
|
|
# Check specific issues
|
|
issues = []
|
|
if not (model_path / "config.json").exists():
|
|
issues.append("config.json missing")
|
|
|
|
weight_files = list(model_path.glob("*.safetensors")) + list(model_path.glob("*.bin")) + list(model_path.glob("*.gguf"))
|
|
if not weight_files:
|
|
weight_files = list(model_path.glob("**/*.safetensors")) + list(model_path.glob("**/*.bin")) + list(model_path.glob("**/*.gguf"))
|
|
if not weight_files:
|
|
index_file = model_path / "model.safetensors.index.json"
|
|
if not index_file.exists():
|
|
issues.append("No model weights found")
|
|
|
|
lfs_ok, lfs_msg = check_lfs_corruption(model_path)
|
|
if not lfs_ok:
|
|
issues.append(lfs_msg)
|
|
|
|
if issues:
|
|
print(" Issues:")
|
|
for issue in issues:
|
|
print(f" - {issue}")
|
|
|
|
# Show files if requested
|
|
if show_files:
|
|
print("\nFiles:")
|
|
files = []
|
|
for file in sorted(model_path.rglob("*")):
|
|
if file.is_file():
|
|
relative_path = file.relative_to(model_path)
|
|
file_size = file.stat().st_size
|
|
if file_size >= 1_000_000_000:
|
|
size_str = f"{file_size / 1_000_000_000:.2f} GB"
|
|
elif file_size >= 1_000_000:
|
|
size_str = f"{file_size / 1_000_000:.2f} MB"
|
|
elif file_size >= 1_000:
|
|
size_str = f"{file_size / 1_000:.2f} KB"
|
|
else:
|
|
size_str = f"{file_size} B"
|
|
files.append((str(relative_path), size_str))
|
|
|
|
# Print files in a nice table format
|
|
if files:
|
|
max_name_len = max(len(f[0]) for f in files)
|
|
for file_path, file_size in files:
|
|
print(f" {file_path:<{max_name_len}} {file_size:>10}")
|
|
else:
|
|
print(" No files found")
|
|
|
|
# Show config if requested
|
|
if show_config:
|
|
config_path = model_path / "config.json"
|
|
if config_path.exists():
|
|
print("\nConfig:")
|
|
try:
|
|
with open(config_path) as f:
|
|
config_data = json.load(f)
|
|
print(json.dumps(config_data, indent=2))
|
|
except Exception as e:
|
|
print(f" Error reading config: {e}")
|
|
else:
|
|
print("\nConfig: Not found")
|
|
|
|
return True
|
|
|
|
def rm_model(model_spec, force=False):
|
|
original_spec = model_spec
|
|
|
|
# First try to resolve using fuzzy matching
|
|
resolved_path, resolved_name, resolved_hash = resolve_single_model(model_spec)
|
|
|
|
if not resolved_path:
|
|
# resolve_single_model already printed the error message for most cases
|
|
# But ensure we always provide feedback to the user
|
|
print(f"Model '{original_spec}' not found or corrupted.")
|
|
return
|
|
|
|
# Use the resolved model name for deletion
|
|
model_name = resolved_name
|
|
commit_hash = resolved_hash
|
|
|
|
|
|
# Confirm on auto-expansion (if the resolved name is different from input)
|
|
base_spec = original_spec.split("@")[0] if "@" in original_spec else original_spec
|
|
if base_spec != model_name and "/" not in base_spec:
|
|
confirm = input(f"Delete '{model_name}' (matched from '{base_spec}')? [Y/n] ")
|
|
if confirm.lower() == "n":
|
|
print("Delete aborted.")
|
|
return
|
|
|
|
base_cache_dir = MODEL_CACHE / hf_to_cache_dir(model_name)
|
|
# This should exist since resolve_single_model succeeded, but double-check
|
|
if not base_cache_dir.exists():
|
|
print(f"[ERROR] Model directory disappeared: {model_name}")
|
|
return
|
|
# Specific hash to delete?
|
|
if commit_hash:
|
|
hash_dir = base_cache_dir / "snapshots" / commit_hash
|
|
if not hash_dir.exists():
|
|
print(f"Hash {commit_hash} for model {model_name} not found!")
|
|
print("\nAvailable hashes:")
|
|
snapshots_dir = base_cache_dir / "snapshots"
|
|
if snapshots_dir.exists():
|
|
for snapshot in sorted(snapshots_dir.iterdir()):
|
|
if snapshot.is_dir():
|
|
print(f" {snapshot.name[:8]}")
|
|
return
|
|
if force:
|
|
confirm_delete = True
|
|
else:
|
|
confirm = input(f"Delete hash {commit_hash} of model {model_name}? [y/N] ")
|
|
confirm_delete = confirm.lower() == "y"
|
|
|
|
if confirm_delete:
|
|
# Issue #23 Fix: Delete entire model directory, not just the snapshot
|
|
# This prevents the double-execution problem where refs/ remain intact
|
|
shutil.rmtree(base_cache_dir)
|
|
print(f"{model_name}@{commit_hash} deleted")
|
|
|
|
# Clean up associated lock files
|
|
try:
|
|
_cleanup_model_locks(model_name, force)
|
|
except Exception as e:
|
|
print(f"Warning: Could not clean up cache files: {e}")
|
|
else:
|
|
print("Aborted.")
|
|
else:
|
|
# Delete entire model
|
|
if force:
|
|
confirm_delete = True
|
|
else:
|
|
confirm = input(f"Delete entire model {model_name} ({base_cache_dir})? [y/N] ")
|
|
confirm_delete = confirm.lower() == "y"
|
|
|
|
if confirm_delete:
|
|
shutil.rmtree(base_cache_dir)
|
|
print(f"Model {model_name} completely deleted.")
|
|
|
|
# Clean up associated lock files
|
|
try:
|
|
_cleanup_model_locks(model_name, force)
|
|
except Exception as e:
|
|
print(f"Warning: Could not clean up cache files: {e}")
|
|
else:
|
|
print("Aborted.")
|
|
|
|
|
|
def _cleanup_model_locks(model_name, force=False):
|
|
"""Clean up HuggingFace lock files for a deleted model.
|
|
|
|
Args:
|
|
model_name: The model name (e.g. 'microsoft/DialoGPT-small')
|
|
force: If True, delete without asking. If False, prompt user.
|
|
"""
|
|
locks_dir = MODEL_CACHE / ".locks" / hf_to_cache_dir(model_name)
|
|
|
|
if not locks_dir.exists():
|
|
return # No locks to clean up
|
|
|
|
# Count lock files
|
|
try:
|
|
lock_files = list(locks_dir.iterdir())
|
|
if not lock_files:
|
|
return # Empty directory
|
|
|
|
if force:
|
|
# Delete without asking
|
|
shutil.rmtree(locks_dir)
|
|
print(f"Cleaned up cache files ({len(lock_files)} files).")
|
|
else:
|
|
# Ask user
|
|
confirm = input("Clean up cache files? [Y/n] ")
|
|
if confirm.lower() != "n":
|
|
shutil.rmtree(locks_dir)
|
|
print(f"Cache files cleaned up ({len(lock_files)} files).")
|
|
else:
|
|
print("Cache files left intact.")
|
|
|
|
except Exception as e:
|
|
print(f"Warning: Could not clean up cache files: {e}")
|