#!/usr/bin/env python3 from __future__ import annotations import argparse import math import os import re import shutil import struct import sys from dataclasses import dataclass from datetime import datetime from pathlib import Path DEFAULT_DENSE_CTX = "32768" DEFAULT_DENSE_GPU_LAYERS = "28" DEFAULT_MOE_CTX = "43008" DEFAULT_MOE_GPU_LAYERS = "999" DEFAULT_BATCH_SIZE = "2048" DEFAULT_MOE_BATCH_SIZE = "1024" DEFAULT_UBATCH_SIZE = "512" DEFAULT_PARALLEL = "4" DEFAULT_MOE_PARALLEL = "4" DEFAULT_ASR_PARALLEL = "1" DEFAULT_MIN_CTX = 512 DEFAULT_CACHE_TYPE = "f16" LONG_CONTEXT_CACHE_TYPE = "q8_0" QUANT_PRIORITY = { "Q4_K_M": 0, "Q5_K_M": 1, "Q6_K": 2, "Q8_0": 3, "Q4_K_S": 4, "Q5_K_S": 5, "Q4_0": 6, "Q4_1": 7, "Q5_0": 8, "Q5_1": 9, "Q3_K_M": 10, "Q3_K_L": 11, "Q2_K": 12, "F16": 90, "BF16": 91, } GGUF_MAGIC = b"GGUF" GGUF_TYPE_UINT8 = 0 GGUF_TYPE_INT8 = 1 GGUF_TYPE_UINT16 = 2 GGUF_TYPE_INT16 = 3 GGUF_TYPE_UINT32 = 4 GGUF_TYPE_INT32 = 5 GGUF_TYPE_FLOAT32 = 6 GGUF_TYPE_BOOL = 7 GGUF_TYPE_STRING = 8 GGUF_TYPE_ARRAY = 9 GGUF_TYPE_UINT64 = 10 GGUF_TYPE_INT64 = 11 GGUF_TYPE_FLOAT64 = 12 GGUF_SCALAR_FORMATS = { GGUF_TYPE_UINT8: " bool: name = path.name.lower() stem = path.stem.lower() return ( name.startswith("mmproj") or ".mmproj-" in name or stem.endswith(".mmproj") or stem.startswith("mmproj") ) def normalize_slug(value: str) -> str: lowered = value.lower() normalized = re.sub(r"[^a-z0-9]+", "-", lowered) normalized = re.sub(r"-{2,}", "-", normalized) return normalized.strip("-") or "model" def detect_quant_key(filename: str) -> str: upper_name = filename.upper() for quant_key in QUANT_PRIORITY: if quant_key in upper_name: return quant_key matches = re.findall(r"(Q\d(?:_[A-Z0-9]+)+|Q\d+|F16|BF16)", upper_name) return matches[0] if matches else "UNKNOWN" def normalize_quant_key(quant_key: str) -> str: return normalize_slug(quant_key) def quant_rank(quant_key: str) -> tuple[int, str]: return (QUANT_PRIORITY.get(quant_key, 50), quant_key) def read_gguf_string(handle) -> str: length = struct.unpack(" dict[str, object]: metadata: dict[str, object] = {} with model_path.open("rb") as handle: magic = handle.read(4) if magic != GGUF_MAGIC: raise ValueError(f"Not a GGUF file: {model_path}") version = struct.unpack(" float | None: match = re.search(r"(\d+(?:\.\d+)?)\s*([kmbtq])", value.strip().lower()) if not match: return None amount = float(match.group(1)) scale = match.group(2) scale_map = { "k": 1e-6, "m": 1e-3, "b": 1.0, "t": 1e3, "q": 1e6, } return amount * scale_map[scale] def estimate_param_billions_from_name(value: str) -> float | None: match = re.search(r"(\d+(?:\.\d+)?)\s*b", value.lower()) if not match: return None return float(match.group(1)) def clamp_int(value: int, minimum: int, maximum: int) -> int: return max(minimum, min(value, maximum)) def get_logical_cpu_count() -> int: return os.cpu_count() or 8 def build_core_fields(*, batch_size: str, ubatch_size: str, parallel: str, mmap: str) -> dict[str, str]: return { "batch-size": batch_size, "ubatch-size": ubatch_size, "parallel": parallel, "kv-unified": "true", "kv-offload": "true", "mlock": "true", "mmap": mmap, "seed": "-1", "flash-attn": "true", } def build_sampling_defaults(family: str) -> dict[str, str]: if family == "moe": return { "temp": "0.7", "top-k": "40", "top-p": "0.95", "min-p": "0.0", "repeat-penalty": "1.0", } return { "temp": "0.8", "top-k": "40", "top-p": "0.95", "min-p": "0.05", "repeat-penalty": "1.0", } def build_model_metadata(model_path: Path) -> ModelMetadata: file_size_gib = model_path.stat().st_size / (1024 ** 3) try: raw = parse_gguf_metadata(model_path) except Exception: raw = {} architecture = raw.get("general.architecture") if not isinstance(architecture, str): architecture = None def get_arch_value(suffix: str) -> object | None: if architecture is None: return None return raw.get(f"{architecture}.{suffix}") context_length = get_arch_value("context_length") block_count = get_arch_value("block_count") expert_count = get_arch_value("expert_count") expert_used_count = get_arch_value("expert_used_count") size_label = raw.get("general.size_label") general_name = raw.get("general.name") return ModelMetadata( architecture=architecture, context_length=context_length if isinstance(context_length, int) else None, block_count=block_count if isinstance(block_count, int) else None, expert_count=expert_count if isinstance(expert_count, int) else None, expert_used_count=expert_used_count if isinstance(expert_used_count, int) else None, size_label=size_label if isinstance(size_label, str) else None, general_name=general_name if isinstance(general_name, str) else None, file_size_gib=file_size_gib, ) def classify_model_family(entry: ModelEntry) -> str: architecture = (entry.metadata.architecture or "").lower() if entry.metadata.expert_count and entry.metadata.expert_count > 0: return "moe" if "moe" in architecture: return "moe" if entry.mmproj_path is not None: return "asr_multimodal" return "dense" def classify_moe_tier(entry: ModelEntry) -> str: metadata = entry.metadata score = 0 if metadata.expert_count: score += 2 if metadata.expert_used_count and metadata.expert_used_count >= 4: score += 1 if metadata.file_size_gib >= MOE_CPU_OFFLOAD_LARGE_THRESHOLD_GIB: score += 2 elif metadata.file_size_gib >= 12.0: score += 1 if metadata.context_length and metadata.context_length >= 131072: score += 2 elif metadata.context_length and metadata.context_length >= 65536: score += 1 if metadata.block_count and metadata.block_count >= 80: score += 1 if score >= 5: return "large" if score >= 3: return "medium" return "small" def choose_gpu_layers(entry: ModelEntry, fallback: str) -> str: block_count = entry.metadata.block_count or 0 if block_count > 0: return str(block_count) return fallback def choose_context_size(entry: ModelEntry, family: str) -> str: max_context = entry.metadata.context_length or 0 if max_context <= 0: defaults = { "dense": DEFAULT_DENSE_CTX, "asr_multimodal": "8192", "moe": DEFAULT_MOE_CTX, } return defaults[family] target_map = { "dense": int(DEFAULT_DENSE_CTX), "asr_multimodal": 8192, "moe": int(DEFAULT_MOE_CTX), } target = min(max_context, target_map[family]) chosen = clamp_int(target, 1 if max_context < DEFAULT_MIN_CTX else DEFAULT_MIN_CTX, max_context) return str(chosen) def choose_cache_type(entry: ModelEntry, family: str) -> str: context_length = entry.metadata.context_length or 0 if family == "moe" or context_length >= 65536: return LONG_CONTEXT_CACHE_TYPE return DEFAULT_CACHE_TYPE def choose_moe_cpu_layers(entry: ModelEntry) -> str | None: block_count = entry.metadata.block_count or 0 if block_count <= 0: return None return "0" def build_dense_values(profile: str) -> dict[str, str]: _ = profile values = build_core_fields( batch_size=DEFAULT_BATCH_SIZE, ubatch_size=DEFAULT_UBATCH_SIZE, parallel=DEFAULT_PARALLEL, mmap="true", ) values["n-gpu-layers"] = DEFAULT_DENSE_GPU_LAYERS values["threads"] = str(default_thread_pool_size()) values["cache-type-k"] = DEFAULT_CACHE_TYPE values["cache-type-v"] = DEFAULT_CACHE_TYPE values.update(build_sampling_defaults("dense")) return values def build_asr_multimodal_values(entry: ModelEntry, profile: str) -> dict[str, str]: _ = profile values = build_core_fields( batch_size=DEFAULT_MOE_BATCH_SIZE, ubatch_size=DEFAULT_UBATCH_SIZE, parallel=DEFAULT_ASR_PARALLEL, mmap="true", ) values["n-gpu-layers"] = choose_gpu_layers(entry, DEFAULT_DENSE_GPU_LAYERS) values["threads"] = str(default_thread_pool_size()) values["ctx-size"] = choose_context_size(entry, "asr_multimodal") cache_type = choose_cache_type(entry, "asr_multimodal") values["cache-type-k"] = cache_type values["cache-type-v"] = cache_type values.update(build_sampling_defaults("dense")) return values def build_moe_values(entry: ModelEntry, profile: str) -> dict[str, str]: _ = profile values = build_core_fields( batch_size=DEFAULT_BATCH_SIZE, ubatch_size=DEFAULT_UBATCH_SIZE, parallel=DEFAULT_MOE_PARALLEL, mmap="true", ) values["ctx-size"] = choose_context_size(entry, "moe") values["n-gpu-layers"] = choose_gpu_layers(entry, DEFAULT_MOE_GPU_LAYERS) values["threads"] = str(default_thread_pool_size()) values["cache-type-k"] = LONG_CONTEXT_CACHE_TYPE values["cache-type-v"] = LONG_CONTEXT_CACHE_TYPE values.update(build_sampling_defaults("moe")) n_cpu_moe = choose_moe_cpu_layers(entry) if n_cpu_moe is not None: values["n-cpu-moe"] = n_cpu_moe return values def default_thread_pool_size() -> int: logical_cpu_count = get_logical_cpu_count() return max(1, math.floor(logical_cpu_count * 0.7)) def scan_models(models_dir: Path, select_mode: str) -> list[ModelEntry]: models_dir = Path(models_dir) if not models_dir.is_dir(): raise FileNotFoundError(f"Models directory not found: {models_dir}") grouped: dict[tuple[str, str], list[ModelEntry]] = {} for path in sorted(models_dir.rglob("*.gguf")): if is_mmproj_file(path): continue relative_parts = path.relative_to(models_dir).parts if len(relative_parts) < 3: publisher = relative_parts[0] if relative_parts else "unknown" model_dir = path.parent.name else: publisher = relative_parts[0] model_dir = relative_parts[1] mmproj_candidates = sorted(candidate for candidate in path.parent.glob("*.gguf") if is_mmproj_file(candidate)) mmproj_path = mmproj_candidates[0] if mmproj_candidates else None entry = ModelEntry( publisher=publisher, model_dir=model_dir, model_path=path, mmproj_path=mmproj_path, quant_key=detect_quant_key(path.name), metadata=build_model_metadata(path), ) grouped.setdefault((publisher, model_dir), []).append(entry) selected: list[ModelEntry] = [] for _, entries in sorted(grouped.items()): if select_mode == "all": selected.extend(sorted(entries, key=lambda item: item.model_path.name.lower())) elif select_mode == "best": best_entry = min( entries, key=lambda item: (quant_rank(item.quant_key), item.model_path.name.lower()), ) selected.append(best_entry) else: raise ValueError(f"Unsupported select mode: {select_mode}") if not selected: raise ValueError(f"No model GGUF files found under {models_dir}") return selected def build_preset_values(entry: ModelEntry, profile: str) -> dict[str, str]: if profile not in {"conservative", "smart"}: raise ValueError(f"Unsupported profile: {profile}") family = classify_model_family(entry) if family == "asr_multimodal": return build_asr_multimodal_values(entry, profile) if family == "moe": return build_moe_values(entry, profile) values = build_dense_values(profile) values["ctx-size"] = choose_context_size(entry, "dense") values["n-gpu-layers"] = choose_gpu_layers(entry, values["n-gpu-layers"]) cache_type = choose_cache_type(entry, "dense") values["cache-type-k"] = cache_type values["cache-type-v"] = cache_type return values def build_value_ranges(entry: ModelEntry, values: dict[str, str]) -> dict[str, ValueRange]: ranges: dict[str, ValueRange] = {} context_length = entry.metadata.context_length or 0 block_count = entry.metadata.block_count or 0 thread_count = default_thread_pool_size() if "ctx-size" in values and context_length > 0: ranges["ctx-size"] = ValueRange(minimum=min(DEFAULT_MIN_CTX, context_length), maximum=context_length) if "n-gpu-layers" in values and block_count > 0: ranges["n-gpu-layers"] = ValueRange(minimum=0, maximum=block_count) if "threads" in values: ranges["threads"] = ValueRange(minimum=1, maximum=get_logical_cpu_count() or thread_count) if "batch-size" in values: ranges["batch-size"] = ValueRange(minimum=32, maximum=int(values["batch-size"])) if "ubatch-size" in values: ranges["ubatch-size"] = ValueRange(minimum=32, maximum=int(values["batch-size"])) if "parallel" in values: ranges["parallel"] = ValueRange(minimum=1, maximum=int(values["parallel"])) if "n-cpu-moe" in values and block_count > 0: ranges["n-cpu-moe"] = ValueRange(minimum=0, maximum=block_count) return ranges def format_value_comment(key: str, value: str, ranges: dict[str, ValueRange]) -> str | None: value_range = ranges.get(key) if value_range is None: return None return f"; {key} range = {value_range.minimum}..{value_range.maximum}; chosen = {value}" def make_alias( publisher: str, model_dir: str, model_file: str, alias_style: str, used_aliases: set[str], ) -> str: if alias_style == "filename": base = Path(model_file).stem elif alias_style == "section": base = f"{publisher}/{model_dir}:{detect_quant_key(model_file)}" elif alias_style == "short": base = Path(model_file).stem else: raise ValueError(f"Unsupported alias style: {alias_style}") if alias_style == "section": alias = base else: alias = normalize_slug(base) if alias_style == "short": alias = re.sub(r"-gguf$", "", alias) unique_alias = alias suffix = 2 while unique_alias in used_aliases: if alias_style == "section": unique_alias = f"{alias}-{suffix}" else: unique_alias = f"{alias}-{suffix}" suffix += 1 used_aliases.add(unique_alias) return unique_alias def render_ini( entries: list[ModelEntry], models_dir: Path, select_mode: str, profile: str, alias_style: str, backup_path: Path | None, ) -> str: generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") lines = [ "; Auto-generated llama.cpp models preset", f"; generated-at = {generated_at}", f"; models-dir = {models_dir}", f"; select = {select_mode}", f"; profile = {profile}", f"; alias-style = {alias_style}", f"; backup = {backup_path if backup_path else 'none'}", "", "version = 1", "", ] used_aliases: set[str] = set() for entry in entries: alias = make_alias( publisher=entry.publisher, model_dir=entry.model_dir, model_file=entry.model_path.name, alias_style=alias_style, used_aliases=used_aliases, ) values = build_preset_values(entry=entry, profile=profile) lines.append(f"[{alias}]") lines.append(f"model = {entry.model_path}") if entry.mmproj_path is not None: lines.append(f"mmproj = {entry.mmproj_path}") lines.append(f"alias = {alias}") ranges = build_value_ranges(entry, values) ordered_keys = [ "ctx-size", "n-gpu-layers", "threads", "batch-size", "ubatch-size", "parallel", "temp", "top-k", "top-p", "min-p", "repeat-penalty", "cache-type-k", "cache-type-v", "kv-unified", "kv-offload", "mlock", "mmap", "seed", "flash-attn", ] optional_keys = ["n-cpu-moe", "cpu-moe"] sampling_keys = {"temp", "top-k", "top-p", "min-p", "repeat-penalty"} sampling_comment_written = False for key in ordered_keys: if key in sampling_keys and not sampling_comment_written: lines.append("; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived") sampling_comment_written = True comment = format_value_comment(key, values[key], ranges) if comment is not None: lines.append(comment) lines.append(f"{key} = {values[key]}") for key in optional_keys: if key in values: comment = format_value_comment(key, values[key], ranges) if comment is not None: lines.append(comment) lines.append(f"{key} = {values[key]}") lines.append("") return "\n".join(lines).rstrip() + "\n" def maybe_backup_file(output_path: Path, backup: bool) -> Path | None: if not backup or not output_path.exists(): return None timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") backup_path = output_path.with_name(f"{output_path.stem}.{timestamp}.bak{output_path.suffix}") shutil.copy2(output_path, backup_path) return backup_path def generate_preset_file( models_dir: Path, output_path: Path, select_mode: str, profile: str, alias_style: str, backup: bool, ) -> GenerateResult: entries = scan_models(models_dir=models_dir, select_mode=select_mode) output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) backup_path = maybe_backup_file(output_path=output_path, backup=backup) ini_text = render_ini( entries=entries, models_dir=Path(models_dir), select_mode=select_mode, profile=profile, alias_style=alias_style, backup_path=backup_path, ) output_path.write_text(ini_text, encoding="utf-8") return GenerateResult( output_path=output_path, backup_path=backup_path, entry_count=len(entries), ) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Scan an LM Studio-style models directory and generate models-preset.ini" ) parser.add_argument("--models-dir", required=True, help="LM Studio-style models root directory") parser.add_argument("--output", default="models-preset.ini", help="Output INI file path") parser.add_argument("--select", choices=["all", "best"], default="all") parser.add_argument("--profile", choices=["conservative", "smart"], default="smart") parser.add_argument("--alias-style", choices=["filename", "section", "short"], default="section") parser.add_argument("--backup", dest="backup", action="store_true", default=False) parser.add_argument("--no-backup", dest="backup", action="store_false") return parser def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) try: result = generate_preset_file( models_dir=Path(args.models_dir), output_path=Path(args.output), select_mode=args.select, profile=args.profile, alias_style=args.alias_style, backup=args.backup, ) except Exception as exc: print(f"ERROR: {exc}", file=sys.stderr) return 1 print(f"Generated {result.entry_count} model preset section(s) at: {result.output_path}") if result.backup_path is not None: print(f"Backup created: {result.backup_path}") return 0 if __name__ == "__main__": raise SystemExit(main())