generate_models_preset.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import argparse
  4. import math
  5. import os
  6. import re
  7. import shutil
  8. import struct
  9. import sys
  10. from dataclasses import dataclass
  11. from datetime import datetime
  12. from pathlib import Path
  13. DEFAULT_DENSE_CTX = "32768"
  14. DEFAULT_DENSE_GPU_LAYERS = "28"
  15. DEFAULT_MOE_CTX = "43008"
  16. DEFAULT_MOE_GPU_LAYERS = "999"
  17. DEFAULT_BATCH_SIZE = "2048"
  18. DEFAULT_MOE_BATCH_SIZE = "1024"
  19. DEFAULT_UBATCH_SIZE = "512"
  20. DEFAULT_PARALLEL = "4"
  21. DEFAULT_MOE_PARALLEL = "4"
  22. DEFAULT_ASR_PARALLEL = "1"
  23. DEFAULT_MIN_CTX = 512
  24. DEFAULT_CACHE_TYPE = "f16"
  25. LONG_CONTEXT_CACHE_TYPE = "q8_0"
  26. QUANT_PRIORITY = {
  27. "Q4_K_M": 0,
  28. "Q5_K_M": 1,
  29. "Q6_K": 2,
  30. "Q8_0": 3,
  31. "Q4_K_S": 4,
  32. "Q5_K_S": 5,
  33. "Q4_0": 6,
  34. "Q4_1": 7,
  35. "Q5_0": 8,
  36. "Q5_1": 9,
  37. "Q3_K_M": 10,
  38. "Q3_K_L": 11,
  39. "Q2_K": 12,
  40. "F16": 90,
  41. "BF16": 91,
  42. }
  43. GGUF_MAGIC = b"GGUF"
  44. GGUF_TYPE_UINT8 = 0
  45. GGUF_TYPE_INT8 = 1
  46. GGUF_TYPE_UINT16 = 2
  47. GGUF_TYPE_INT16 = 3
  48. GGUF_TYPE_UINT32 = 4
  49. GGUF_TYPE_INT32 = 5
  50. GGUF_TYPE_FLOAT32 = 6
  51. GGUF_TYPE_BOOL = 7
  52. GGUF_TYPE_STRING = 8
  53. GGUF_TYPE_ARRAY = 9
  54. GGUF_TYPE_UINT64 = 10
  55. GGUF_TYPE_INT64 = 11
  56. GGUF_TYPE_FLOAT64 = 12
  57. GGUF_SCALAR_FORMATS = {
  58. GGUF_TYPE_UINT8: "<B",
  59. GGUF_TYPE_INT8: "<b",
  60. GGUF_TYPE_UINT16: "<H",
  61. GGUF_TYPE_INT16: "<h",
  62. GGUF_TYPE_UINT32: "<I",
  63. GGUF_TYPE_INT32: "<i",
  64. GGUF_TYPE_FLOAT32: "<f",
  65. GGUF_TYPE_BOOL: "<?",
  66. GGUF_TYPE_UINT64: "<Q",
  67. GGUF_TYPE_INT64: "<q",
  68. GGUF_TYPE_FLOAT64: "<d",
  69. }
  70. MOE_CPU_OFFLOAD_LARGE_THRESHOLD_GIB = 20.0
  71. @dataclass(frozen=True)
  72. class ModelMetadata:
  73. architecture: str | None
  74. context_length: int | None
  75. block_count: int | None
  76. expert_count: int | None
  77. expert_used_count: int | None
  78. size_label: str | None
  79. general_name: str | None
  80. file_size_gib: float
  81. @dataclass(frozen=True)
  82. class ModelEntry:
  83. publisher: str
  84. model_dir: str
  85. model_path: Path
  86. mmproj_path: Path | None
  87. quant_key: str
  88. metadata: ModelMetadata
  89. @dataclass(frozen=True)
  90. class GenerateResult:
  91. output_path: Path
  92. backup_path: Path | None
  93. entry_count: int
  94. @dataclass(frozen=True)
  95. class ValueRange:
  96. minimum: int
  97. maximum: int
  98. def is_mmproj_file(path: Path) -> bool:
  99. name = path.name.lower()
  100. stem = path.stem.lower()
  101. return (
  102. name.startswith("mmproj")
  103. or ".mmproj-" in name
  104. or stem.endswith(".mmproj")
  105. or stem.startswith("mmproj")
  106. )
  107. def normalize_slug(value: str) -> str:
  108. lowered = value.lower()
  109. normalized = re.sub(r"[^a-z0-9]+", "-", lowered)
  110. normalized = re.sub(r"-{2,}", "-", normalized)
  111. return normalized.strip("-") or "model"
  112. def detect_quant_key(filename: str) -> str:
  113. upper_name = filename.upper()
  114. for quant_key in QUANT_PRIORITY:
  115. if quant_key in upper_name:
  116. return quant_key
  117. matches = re.findall(r"(Q\d(?:_[A-Z0-9]+)+|Q\d+|F16|BF16)", upper_name)
  118. return matches[0] if matches else "UNKNOWN"
  119. def normalize_quant_key(quant_key: str) -> str:
  120. return normalize_slug(quant_key)
  121. def quant_rank(quant_key: str) -> tuple[int, str]:
  122. return (QUANT_PRIORITY.get(quant_key, 50), quant_key)
  123. def read_gguf_string(handle) -> str:
  124. length = struct.unpack("<Q", handle.read(8))[0]
  125. return handle.read(length).decode("utf-8", errors="replace")
  126. def read_gguf_scalar(handle, value_type: int):
  127. fmt = GGUF_SCALAR_FORMATS.get(value_type)
  128. if fmt is None:
  129. raise ValueError(f"Unsupported GGUF scalar type: {value_type}")
  130. size = struct.calcsize(fmt)
  131. return struct.unpack(fmt, handle.read(size))[0]
  132. def read_gguf_value(handle, value_type: int):
  133. if value_type == GGUF_TYPE_STRING:
  134. return read_gguf_string(handle)
  135. if value_type == GGUF_TYPE_ARRAY:
  136. item_type = struct.unpack("<I", handle.read(4))[0]
  137. item_count = struct.unpack("<Q", handle.read(8))[0]
  138. return [read_gguf_value(handle, item_type) for _ in range(item_count)]
  139. return read_gguf_scalar(handle, value_type)
  140. def parse_gguf_metadata(model_path: Path) -> dict[str, object]:
  141. metadata: dict[str, object] = {}
  142. with model_path.open("rb") as handle:
  143. magic = handle.read(4)
  144. if magic != GGUF_MAGIC:
  145. raise ValueError(f"Not a GGUF file: {model_path}")
  146. version = struct.unpack("<I", handle.read(4))[0]
  147. if version not in {2, 3}:
  148. raise ValueError(f"Unsupported GGUF version {version}: {model_path}")
  149. tensor_count = struct.unpack("<Q", handle.read(8))[0]
  150. metadata_count = struct.unpack("<Q", handle.read(8))[0]
  151. for _ in range(metadata_count):
  152. key = read_gguf_string(handle)
  153. value_type = struct.unpack("<I", handle.read(4))[0]
  154. metadata[key] = read_gguf_value(handle, value_type)
  155. # Stop after metadata. Tensor table is not needed.
  156. _ = tensor_count
  157. return metadata
  158. def parse_param_billions(value: str) -> float | None:
  159. match = re.search(r"(\d+(?:\.\d+)?)\s*([kmbtq])", value.strip().lower())
  160. if not match:
  161. return None
  162. amount = float(match.group(1))
  163. scale = match.group(2)
  164. scale_map = {
  165. "k": 1e-6,
  166. "m": 1e-3,
  167. "b": 1.0,
  168. "t": 1e3,
  169. "q": 1e6,
  170. }
  171. return amount * scale_map[scale]
  172. def estimate_param_billions_from_name(value: str) -> float | None:
  173. match = re.search(r"(\d+(?:\.\d+)?)\s*b", value.lower())
  174. if not match:
  175. return None
  176. return float(match.group(1))
  177. def clamp_int(value: int, minimum: int, maximum: int) -> int:
  178. return max(minimum, min(value, maximum))
  179. def get_logical_cpu_count() -> int:
  180. return os.cpu_count() or 8
  181. def build_core_fields(*, batch_size: str, ubatch_size: str, parallel: str, mmap: str) -> dict[str, str]:
  182. return {
  183. "batch-size": batch_size,
  184. "ubatch-size": ubatch_size,
  185. "parallel": parallel,
  186. "kv-unified": "true",
  187. "kv-offload": "true",
  188. "mlock": "true",
  189. "mmap": mmap,
  190. "seed": "-1",
  191. "flash-attn": "true",
  192. }
  193. def build_sampling_defaults(family: str) -> dict[str, str]:
  194. if family == "moe":
  195. return {
  196. "temp": "0.7",
  197. "top-k": "40",
  198. "top-p": "0.95",
  199. "min-p": "0.0",
  200. "repeat-penalty": "1.0",
  201. }
  202. return {
  203. "temp": "0.8",
  204. "top-k": "40",
  205. "top-p": "0.95",
  206. "min-p": "0.05",
  207. "repeat-penalty": "1.0",
  208. }
  209. def build_model_metadata(model_path: Path) -> ModelMetadata:
  210. file_size_gib = model_path.stat().st_size / (1024 ** 3)
  211. try:
  212. raw = parse_gguf_metadata(model_path)
  213. except Exception:
  214. raw = {}
  215. architecture = raw.get("general.architecture")
  216. if not isinstance(architecture, str):
  217. architecture = None
  218. def get_arch_value(suffix: str) -> object | None:
  219. if architecture is None:
  220. return None
  221. return raw.get(f"{architecture}.{suffix}")
  222. context_length = get_arch_value("context_length")
  223. block_count = get_arch_value("block_count")
  224. expert_count = get_arch_value("expert_count")
  225. expert_used_count = get_arch_value("expert_used_count")
  226. size_label = raw.get("general.size_label")
  227. general_name = raw.get("general.name")
  228. return ModelMetadata(
  229. architecture=architecture,
  230. context_length=context_length if isinstance(context_length, int) else None,
  231. block_count=block_count if isinstance(block_count, int) else None,
  232. expert_count=expert_count if isinstance(expert_count, int) else None,
  233. expert_used_count=expert_used_count if isinstance(expert_used_count, int) else None,
  234. size_label=size_label if isinstance(size_label, str) else None,
  235. general_name=general_name if isinstance(general_name, str) else None,
  236. file_size_gib=file_size_gib,
  237. )
  238. def classify_model_family(entry: ModelEntry) -> str:
  239. architecture = (entry.metadata.architecture or "").lower()
  240. if entry.metadata.expert_count and entry.metadata.expert_count > 0:
  241. return "moe"
  242. if "moe" in architecture:
  243. return "moe"
  244. if entry.mmproj_path is not None:
  245. return "asr_multimodal"
  246. return "dense"
  247. def classify_moe_tier(entry: ModelEntry) -> str:
  248. metadata = entry.metadata
  249. score = 0
  250. if metadata.expert_count:
  251. score += 2
  252. if metadata.expert_used_count and metadata.expert_used_count >= 4:
  253. score += 1
  254. if metadata.file_size_gib >= MOE_CPU_OFFLOAD_LARGE_THRESHOLD_GIB:
  255. score += 2
  256. elif metadata.file_size_gib >= 12.0:
  257. score += 1
  258. if metadata.context_length and metadata.context_length >= 131072:
  259. score += 2
  260. elif metadata.context_length and metadata.context_length >= 65536:
  261. score += 1
  262. if metadata.block_count and metadata.block_count >= 80:
  263. score += 1
  264. if score >= 5:
  265. return "large"
  266. if score >= 3:
  267. return "medium"
  268. return "small"
  269. def choose_gpu_layers(entry: ModelEntry, fallback: str) -> str:
  270. block_count = entry.metadata.block_count or 0
  271. if block_count > 0:
  272. return str(block_count)
  273. return fallback
  274. def choose_context_size(entry: ModelEntry, family: str) -> str:
  275. max_context = entry.metadata.context_length or 0
  276. if max_context <= 0:
  277. defaults = {
  278. "dense": DEFAULT_DENSE_CTX,
  279. "asr_multimodal": "8192",
  280. "moe": DEFAULT_MOE_CTX,
  281. }
  282. return defaults[family]
  283. target_map = {
  284. "dense": int(DEFAULT_DENSE_CTX),
  285. "asr_multimodal": 8192,
  286. "moe": int(DEFAULT_MOE_CTX),
  287. }
  288. target = min(max_context, target_map[family])
  289. chosen = clamp_int(target, 1 if max_context < DEFAULT_MIN_CTX else DEFAULT_MIN_CTX, max_context)
  290. return str(chosen)
  291. def choose_cache_type(entry: ModelEntry, family: str) -> str:
  292. context_length = entry.metadata.context_length or 0
  293. if family == "moe" or context_length >= 65536:
  294. return LONG_CONTEXT_CACHE_TYPE
  295. return DEFAULT_CACHE_TYPE
  296. def choose_moe_cpu_layers(entry: ModelEntry) -> str | None:
  297. block_count = entry.metadata.block_count or 0
  298. if block_count <= 0:
  299. return None
  300. return "0"
  301. def build_dense_values(profile: str) -> dict[str, str]:
  302. _ = profile
  303. values = build_core_fields(
  304. batch_size=DEFAULT_BATCH_SIZE,
  305. ubatch_size=DEFAULT_UBATCH_SIZE,
  306. parallel=DEFAULT_PARALLEL,
  307. mmap="true",
  308. )
  309. values["n-gpu-layers"] = DEFAULT_DENSE_GPU_LAYERS
  310. values["threads"] = str(default_thread_pool_size())
  311. values["cache-type-k"] = DEFAULT_CACHE_TYPE
  312. values["cache-type-v"] = DEFAULT_CACHE_TYPE
  313. values.update(build_sampling_defaults("dense"))
  314. return values
  315. def build_asr_multimodal_values(entry: ModelEntry, profile: str) -> dict[str, str]:
  316. _ = profile
  317. values = build_core_fields(
  318. batch_size=DEFAULT_MOE_BATCH_SIZE,
  319. ubatch_size=DEFAULT_UBATCH_SIZE,
  320. parallel=DEFAULT_ASR_PARALLEL,
  321. mmap="true",
  322. )
  323. values["n-gpu-layers"] = choose_gpu_layers(entry, DEFAULT_DENSE_GPU_LAYERS)
  324. values["threads"] = str(default_thread_pool_size())
  325. values["ctx-size"] = choose_context_size(entry, "asr_multimodal")
  326. cache_type = choose_cache_type(entry, "asr_multimodal")
  327. values["cache-type-k"] = cache_type
  328. values["cache-type-v"] = cache_type
  329. values.update(build_sampling_defaults("dense"))
  330. return values
  331. def build_moe_values(entry: ModelEntry, profile: str) -> dict[str, str]:
  332. _ = profile
  333. values = build_core_fields(
  334. batch_size=DEFAULT_BATCH_SIZE,
  335. ubatch_size=DEFAULT_UBATCH_SIZE,
  336. parallel=DEFAULT_MOE_PARALLEL,
  337. mmap="true",
  338. )
  339. values["ctx-size"] = choose_context_size(entry, "moe")
  340. values["n-gpu-layers"] = choose_gpu_layers(entry, DEFAULT_MOE_GPU_LAYERS)
  341. values["threads"] = str(default_thread_pool_size())
  342. values["cache-type-k"] = LONG_CONTEXT_CACHE_TYPE
  343. values["cache-type-v"] = LONG_CONTEXT_CACHE_TYPE
  344. values.update(build_sampling_defaults("moe"))
  345. n_cpu_moe = choose_moe_cpu_layers(entry)
  346. if n_cpu_moe is not None:
  347. values["n-cpu-moe"] = n_cpu_moe
  348. return values
  349. def default_thread_pool_size() -> int:
  350. logical_cpu_count = get_logical_cpu_count()
  351. return max(1, math.floor(logical_cpu_count * 0.7))
  352. def scan_models(models_dir: Path, select_mode: str) -> list[ModelEntry]:
  353. models_dir = Path(models_dir)
  354. if not models_dir.is_dir():
  355. raise FileNotFoundError(f"Models directory not found: {models_dir}")
  356. grouped: dict[tuple[str, str], list[ModelEntry]] = {}
  357. for path in sorted(models_dir.rglob("*.gguf")):
  358. if is_mmproj_file(path):
  359. continue
  360. relative_parts = path.relative_to(models_dir).parts
  361. if len(relative_parts) < 3:
  362. publisher = relative_parts[0] if relative_parts else "unknown"
  363. model_dir = path.parent.name
  364. else:
  365. publisher = relative_parts[0]
  366. model_dir = relative_parts[1]
  367. mmproj_candidates = sorted(candidate for candidate in path.parent.glob("*.gguf") if is_mmproj_file(candidate))
  368. mmproj_path = mmproj_candidates[0] if mmproj_candidates else None
  369. entry = ModelEntry(
  370. publisher=publisher,
  371. model_dir=model_dir,
  372. model_path=path,
  373. mmproj_path=mmproj_path,
  374. quant_key=detect_quant_key(path.name),
  375. metadata=build_model_metadata(path),
  376. )
  377. grouped.setdefault((publisher, model_dir), []).append(entry)
  378. selected: list[ModelEntry] = []
  379. for _, entries in sorted(grouped.items()):
  380. if select_mode == "all":
  381. selected.extend(sorted(entries, key=lambda item: item.model_path.name.lower()))
  382. elif select_mode == "best":
  383. best_entry = min(
  384. entries,
  385. key=lambda item: (quant_rank(item.quant_key), item.model_path.name.lower()),
  386. )
  387. selected.append(best_entry)
  388. else:
  389. raise ValueError(f"Unsupported select mode: {select_mode}")
  390. if not selected:
  391. raise ValueError(f"No model GGUF files found under {models_dir}")
  392. return selected
  393. def build_preset_values(entry: ModelEntry, profile: str) -> dict[str, str]:
  394. if profile not in {"conservative", "smart"}:
  395. raise ValueError(f"Unsupported profile: {profile}")
  396. family = classify_model_family(entry)
  397. if family == "asr_multimodal":
  398. return build_asr_multimodal_values(entry, profile)
  399. if family == "moe":
  400. return build_moe_values(entry, profile)
  401. values = build_dense_values(profile)
  402. values["ctx-size"] = choose_context_size(entry, "dense")
  403. values["n-gpu-layers"] = choose_gpu_layers(entry, values["n-gpu-layers"])
  404. cache_type = choose_cache_type(entry, "dense")
  405. values["cache-type-k"] = cache_type
  406. values["cache-type-v"] = cache_type
  407. return values
  408. def build_value_ranges(entry: ModelEntry, values: dict[str, str]) -> dict[str, ValueRange]:
  409. ranges: dict[str, ValueRange] = {}
  410. context_length = entry.metadata.context_length or 0
  411. block_count = entry.metadata.block_count or 0
  412. thread_count = default_thread_pool_size()
  413. if "ctx-size" in values and context_length > 0:
  414. ranges["ctx-size"] = ValueRange(minimum=min(DEFAULT_MIN_CTX, context_length), maximum=context_length)
  415. if "n-gpu-layers" in values and block_count > 0:
  416. ranges["n-gpu-layers"] = ValueRange(minimum=0, maximum=block_count)
  417. if "threads" in values:
  418. ranges["threads"] = ValueRange(minimum=1, maximum=get_logical_cpu_count() or thread_count)
  419. if "batch-size" in values:
  420. ranges["batch-size"] = ValueRange(minimum=32, maximum=int(values["batch-size"]))
  421. if "ubatch-size" in values:
  422. ranges["ubatch-size"] = ValueRange(minimum=32, maximum=int(values["batch-size"]))
  423. if "parallel" in values:
  424. ranges["parallel"] = ValueRange(minimum=1, maximum=int(values["parallel"]))
  425. if "n-cpu-moe" in values and block_count > 0:
  426. ranges["n-cpu-moe"] = ValueRange(minimum=0, maximum=block_count)
  427. return ranges
  428. def format_value_comment(key: str, value: str, ranges: dict[str, ValueRange]) -> str | None:
  429. value_range = ranges.get(key)
  430. if value_range is None:
  431. return None
  432. return f"; {key} range = {value_range.minimum}..{value_range.maximum}; chosen = {value}"
  433. def make_alias(
  434. publisher: str,
  435. model_dir: str,
  436. model_file: str,
  437. alias_style: str,
  438. used_aliases: set[str],
  439. ) -> str:
  440. if alias_style == "filename":
  441. base = Path(model_file).stem
  442. elif alias_style == "section":
  443. base = f"{publisher}/{model_dir}:{detect_quant_key(model_file)}"
  444. elif alias_style == "short":
  445. base = Path(model_file).stem
  446. else:
  447. raise ValueError(f"Unsupported alias style: {alias_style}")
  448. if alias_style == "section":
  449. alias = base
  450. else:
  451. alias = normalize_slug(base)
  452. if alias_style == "short":
  453. alias = re.sub(r"-gguf$", "", alias)
  454. unique_alias = alias
  455. suffix = 2
  456. while unique_alias in used_aliases:
  457. if alias_style == "section":
  458. unique_alias = f"{alias}-{suffix}"
  459. else:
  460. unique_alias = f"{alias}-{suffix}"
  461. suffix += 1
  462. used_aliases.add(unique_alias)
  463. return unique_alias
  464. def render_ini(
  465. entries: list[ModelEntry],
  466. models_dir: Path,
  467. select_mode: str,
  468. profile: str,
  469. alias_style: str,
  470. backup_path: Path | None,
  471. ) -> str:
  472. generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  473. lines = [
  474. "; Auto-generated llama.cpp models preset",
  475. f"; generated-at = {generated_at}",
  476. f"; models-dir = {models_dir}",
  477. f"; select = {select_mode}",
  478. f"; profile = {profile}",
  479. f"; alias-style = {alias_style}",
  480. f"; backup = {backup_path if backup_path else 'none'}",
  481. "",
  482. "version = 1",
  483. "",
  484. ]
  485. used_aliases: set[str] = set()
  486. for entry in entries:
  487. alias = make_alias(
  488. publisher=entry.publisher,
  489. model_dir=entry.model_dir,
  490. model_file=entry.model_path.name,
  491. alias_style=alias_style,
  492. used_aliases=used_aliases,
  493. )
  494. values = build_preset_values(entry=entry, profile=profile)
  495. lines.append(f"[{alias}]")
  496. lines.append(f"model = {entry.model_path}")
  497. if entry.mmproj_path is not None:
  498. lines.append(f"mmproj = {entry.mmproj_path}")
  499. lines.append(f"alias = {alias}")
  500. ranges = build_value_ranges(entry, values)
  501. ordered_keys = [
  502. "ctx-size",
  503. "n-gpu-layers",
  504. "threads",
  505. "batch-size",
  506. "ubatch-size",
  507. "parallel",
  508. "temp",
  509. "top-k",
  510. "top-p",
  511. "min-p",
  512. "repeat-penalty",
  513. "cache-type-k",
  514. "cache-type-v",
  515. "kv-unified",
  516. "kv-offload",
  517. "mlock",
  518. "mmap",
  519. "seed",
  520. "flash-attn",
  521. ]
  522. optional_keys = ["n-cpu-moe", "cpu-moe"]
  523. sampling_keys = {"temp", "top-k", "top-p", "min-p", "repeat-penalty"}
  524. sampling_comment_written = False
  525. for key in ordered_keys:
  526. if key in sampling_keys and not sampling_comment_written:
  527. lines.append("; Heuristic sampling defaults / 经验采样默认值, not GGUF metadata-derived")
  528. sampling_comment_written = True
  529. comment = format_value_comment(key, values[key], ranges)
  530. if comment is not None:
  531. lines.append(comment)
  532. lines.append(f"{key} = {values[key]}")
  533. for key in optional_keys:
  534. if key in values:
  535. comment = format_value_comment(key, values[key], ranges)
  536. if comment is not None:
  537. lines.append(comment)
  538. lines.append(f"{key} = {values[key]}")
  539. lines.append("")
  540. return "\n".join(lines).rstrip() + "\n"
  541. def maybe_backup_file(output_path: Path, backup: bool) -> Path | None:
  542. if not backup or not output_path.exists():
  543. return None
  544. timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
  545. backup_path = output_path.with_name(f"{output_path.stem}.{timestamp}.bak{output_path.suffix}")
  546. shutil.copy2(output_path, backup_path)
  547. return backup_path
  548. def generate_preset_file(
  549. models_dir: Path,
  550. output_path: Path,
  551. select_mode: str,
  552. profile: str,
  553. alias_style: str,
  554. backup: bool,
  555. ) -> GenerateResult:
  556. entries = scan_models(models_dir=models_dir, select_mode=select_mode)
  557. output_path = Path(output_path)
  558. output_path.parent.mkdir(parents=True, exist_ok=True)
  559. backup_path = maybe_backup_file(output_path=output_path, backup=backup)
  560. ini_text = render_ini(
  561. entries=entries,
  562. models_dir=Path(models_dir),
  563. select_mode=select_mode,
  564. profile=profile,
  565. alias_style=alias_style,
  566. backup_path=backup_path,
  567. )
  568. output_path.write_text(ini_text, encoding="utf-8")
  569. return GenerateResult(
  570. output_path=output_path,
  571. backup_path=backup_path,
  572. entry_count=len(entries),
  573. )
  574. def build_parser() -> argparse.ArgumentParser:
  575. parser = argparse.ArgumentParser(
  576. description="Scan an LM Studio-style models directory and generate models-preset.ini"
  577. )
  578. parser.add_argument("--models-dir", required=True, help="LM Studio-style models root directory")
  579. parser.add_argument("--output", default="models-preset.ini", help="Output INI file path")
  580. parser.add_argument("--select", choices=["all", "best"], default="all")
  581. parser.add_argument("--profile", choices=["conservative", "smart"], default="smart")
  582. parser.add_argument("--alias-style", choices=["filename", "section", "short"], default="section")
  583. parser.add_argument("--backup", dest="backup", action="store_true", default=False)
  584. parser.add_argument("--no-backup", dest="backup", action="store_false")
  585. return parser
  586. def main(argv: list[str] | None = None) -> int:
  587. parser = build_parser()
  588. args = parser.parse_args(argv)
  589. try:
  590. result = generate_preset_file(
  591. models_dir=Path(args.models_dir),
  592. output_path=Path(args.output),
  593. select_mode=args.select,
  594. profile=args.profile,
  595. alias_style=args.alias_style,
  596. backup=args.backup,
  597. )
  598. except Exception as exc:
  599. print(f"ERROR: {exc}", file=sys.stderr)
  600. return 1
  601. print(f"Generated {result.entry_count} model preset section(s) at: {result.output_path}")
  602. if result.backup_path is not None:
  603. print(f"Backup created: {result.backup_path}")
  604. return 0
  605. if __name__ == "__main__":
  606. raise SystemExit(main())