wrapper_generator.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777
  1. #!/usr/bin/env python3
  2. import argparse
  3. import json
  4. import re
  5. import struct
  6. from datetime import datetime
  7. from pathlib import Path
  8. DEFAULT_MODELS_ROOT = Path.home() / ".lmstudio" / "models"
  9. DEFAULT_OUTPUT_ROOT = Path.home() / ".lmstudio" / "hub" / "models"
  10. DEFAULT_INTERNAL_ROOT = Path.home() / ".lmstudio" / ".internal"
  11. DEFAULT_GGUF_METADATA_CACHE = DEFAULT_INTERNAL_ROOT / "gguf-metadata-cache.json"
  12. DEFAULT_MODEL_INDEX_CACHE = DEFAULT_INTERNAL_ROOT / "model-index-cache.json"
  13. GGUF_VALUE_TYPES = {
  14. 0: ("<B", 1),
  15. 1: ("<b", 1),
  16. 2: ("<H", 2),
  17. 3: ("<h", 2),
  18. 4: ("<I", 4),
  19. 5: ("<i", 4),
  20. 6: ("<f", 4),
  21. 7: ("<?", 1),
  22. 10: ("<Q", 8),
  23. 11: ("<q", 8),
  24. 12: ("<d", 8),
  25. }
  26. def _bool_text(value: bool) -> str:
  27. return "true" if value else "false"
  28. def normalize_owner(owner: str) -> str:
  29. return _slugify(owner)
  30. def _read_struct(data: bytes, offset: int, fmt: str) -> tuple[object, int]:
  31. size = struct.calcsize(fmt)
  32. if offset + size > len(data):
  33. raise ValueError("Unexpected end of GGUF metadata")
  34. value = struct.unpack_from(fmt, data, offset)[0]
  35. return value, offset + size
  36. def _read_gguf_string(data: bytes, offset: int) -> tuple[str, int]:
  37. length, offset = _read_struct(data, offset, "<Q")
  38. end = offset + int(length)
  39. if end > len(data):
  40. raise ValueError("Invalid GGUF string length")
  41. return data[offset:end].decode("utf-8", errors="replace"), end
  42. def _read_gguf_value(data: bytes, offset: int, value_type: int) -> tuple[object, int]:
  43. if value_type == 8:
  44. return _read_gguf_string(data, offset)
  45. if value_type == 9:
  46. nested_type, offset = _read_struct(data, offset, "<I")
  47. count, offset = _read_struct(data, offset, "<Q")
  48. values = []
  49. for _ in range(int(count)):
  50. item, offset = _read_gguf_value(data, offset, int(nested_type))
  51. values.append(item)
  52. return values, offset
  53. if value_type not in GGUF_VALUE_TYPES:
  54. raise ValueError(f"Unsupported GGUF metadata value type: {value_type}")
  55. fmt, _ = GGUF_VALUE_TYPES[value_type]
  56. return _read_struct(data, offset, fmt)
  57. def _normalize_cached_metadata(metadata: dict[str, object]) -> dict[str, object]:
  58. normalized: dict[str, object] = {}
  59. architecture = metadata.get("arch")
  60. if architecture is not None:
  61. normalized["general.architecture"] = architecture
  62. name = metadata.get("name")
  63. if name is not None:
  64. normalized["general.name"] = name
  65. chat_template = metadata.get("chatTemplate")
  66. if chat_template is not None:
  67. normalized["tokenizer.chat_template"] = chat_template
  68. parameters = metadata.get("parameters")
  69. if parameters is not None:
  70. normalized["general.parameter_count_label"] = str(parameters)
  71. context_length = metadata.get("contextLength")
  72. if context_length is not None:
  73. normalized["general.context_length"] = str(context_length)
  74. bos_token = metadata.get("bosToken")
  75. if bos_token is not None:
  76. normalized["tokenizer.ggml.bos_token"] = bos_token
  77. eos_token = metadata.get("eosToken")
  78. if eos_token is not None:
  79. normalized["tokenizer.ggml.eos_token"] = eos_token
  80. return normalized
  81. def load_gguf_metadata_cache(cache_path: Path = DEFAULT_GGUF_METADATA_CACHE) -> dict[str, dict[str, object]]:
  82. if not cache_path.exists():
  83. return {}
  84. payload = json.loads(cache_path.read_text(encoding="utf-8"))
  85. entries = payload.get("json", {}).get("map", [])
  86. cache_index: dict[str, dict[str, object]] = {}
  87. for entry in entries:
  88. if not isinstance(entry, list) or len(entry) != 2:
  89. continue
  90. file_path, cached_payload = entry
  91. if not isinstance(file_path, str) or not isinstance(cached_payload, dict):
  92. continue
  93. metadata = cached_payload.get("metadata")
  94. if isinstance(metadata, dict):
  95. cache_index[file_path] = _normalize_cached_metadata(metadata)
  96. return cache_index
  97. def _read_exact(file_obj, size: int) -> bytes:
  98. data = file_obj.read(size)
  99. if len(data) != size:
  100. raise ValueError("Unexpected end of GGUF metadata stream")
  101. return data
  102. def _read_struct_stream(file_obj, fmt: str) -> object:
  103. size = struct.calcsize(fmt)
  104. return struct.unpack(fmt, _read_exact(file_obj, size))[0]
  105. def _read_gguf_string_stream(file_obj) -> str:
  106. length = int(_read_struct_stream(file_obj, "<Q"))
  107. return _read_exact(file_obj, length).decode("utf-8", errors="replace")
  108. def _read_gguf_value_stream(file_obj, value_type: int) -> object:
  109. if value_type == 8:
  110. return _read_gguf_string_stream(file_obj)
  111. if value_type == 9:
  112. nested_type = int(_read_struct_stream(file_obj, "<I"))
  113. count = int(_read_struct_stream(file_obj, "<Q"))
  114. return [_read_gguf_value_stream(file_obj, nested_type) for _ in range(count)]
  115. if value_type not in GGUF_VALUE_TYPES:
  116. raise ValueError(f"Unsupported GGUF metadata value type: {value_type}")
  117. fmt, _ = GGUF_VALUE_TYPES[value_type]
  118. return _read_struct_stream(file_obj, fmt)
  119. def read_gguf_metadata_streaming(path: Path) -> dict[str, object]:
  120. with path.open("rb") as file_obj:
  121. if _read_exact(file_obj, 4) != b"GGUF":
  122. raise ValueError(f"Not a GGUF file: {path}")
  123. version = int(_read_struct_stream(file_obj, "<I"))
  124. if version not in (2, 3):
  125. raise ValueError(f"Unsupported GGUF version: {version}")
  126. _ = _read_struct_stream(file_obj, "<Q")
  127. metadata_count = int(_read_struct_stream(file_obj, "<Q"))
  128. metadata: dict[str, object] = {}
  129. for _ in range(metadata_count):
  130. key = _read_gguf_string_stream(file_obj)
  131. value_type = int(_read_struct_stream(file_obj, "<I"))
  132. metadata[key] = _read_gguf_value_stream(file_obj, value_type)
  133. return metadata
  134. def resolve_gguf_metadata(
  135. gguf_path: Path,
  136. cache_index: dict[str, dict[str, object]],
  137. ) -> dict[str, object]:
  138. resolved = gguf_path.resolve().as_posix()
  139. cached = cache_index.get(resolved)
  140. if cached is not None:
  141. return dict(cached)
  142. return read_gguf_metadata_streaming(gguf_path)
  143. def _slugify(text: str) -> str:
  144. lowered = text.strip().lower()
  145. lowered = lowered.replace("_", "-")
  146. lowered = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", lowered)
  147. lowered = lowered.replace("qwen3vl", "qwen3-vl")
  148. lowered = lowered.replace("gemma4", "gemma-4")
  149. lowered = re.sub(r"[^a-z0-9.]+", "-", lowered)
  150. lowered = re.sub(r"-{2,}", "-", lowered)
  151. return lowered.strip("-")
  152. def _extract_context_length(metadata: dict[str, object]) -> int | None:
  153. for key in (
  154. "qwen2.context_length",
  155. "qwen.context_length",
  156. "llama.context_length",
  157. "gemma.context_length",
  158. "general.context_length",
  159. ):
  160. value = metadata.get(key)
  161. if value is None:
  162. continue
  163. text = str(value).strip()
  164. if text.isdigit():
  165. return int(text)
  166. return None
  167. def _extract_params_string(metadata: dict[str, object], base_name: str) -> str | None:
  168. for key in ("general.size_label", "general.parameter_count_label", "general.basename"):
  169. value = metadata.get(key)
  170. if isinstance(value, str):
  171. match = re.search(r"(\d+(?:\.\d+)?B(?:-A\d+B)?)", value, re.IGNORECASE)
  172. if match:
  173. raw = match.group(1).upper()
  174. return raw.split("-A")[0] if "-A" in raw else raw
  175. match = re.search(r"(\d+(?:\.\d+)?B)(?:-A\d+B)?", base_name, re.IGNORECASE)
  176. if match:
  177. return match.group(1).upper()
  178. return None
  179. def _extract_base_name(metadata: dict[str, object]) -> str:
  180. for key in ("general.basename", "general.name"):
  181. value = metadata.get(key)
  182. if isinstance(value, str) and value.strip():
  183. text = value.strip()
  184. if text.upper().endswith("-GGUF"):
  185. text = text[:-5]
  186. return text
  187. return "local-model"
  188. def _detect_capabilities(chat_template: str) -> dict[str, bool]:
  189. lowered = chat_template.lower()
  190. has_reasoning = "enable_thinking" in lowered or "preserve_thinking" in lowered
  191. has_tools = any(
  192. token in lowered
  193. for token in (
  194. "<tools>",
  195. "<tool_call>",
  196. "<tool_response>",
  197. "<|tool>",
  198. "<tool|>",
  199. "<|tool|>",
  200. "<|tool_call>",
  201. "<tool_call|>",
  202. "<|tool_call|>",
  203. "<|tool_response>",
  204. "<tool_response|>",
  205. "<|tool_response|>",
  206. )
  207. )
  208. has_vision = any(
  209. token in lowered
  210. for token in (
  211. "<|vision_start|>",
  212. "<image_soft_token>",
  213. "<audio_soft_token>",
  214. "<|image_pad|>",
  215. "<|video_pad|>",
  216. )
  217. )
  218. return {
  219. "reasoning": has_reasoning,
  220. "vision": has_vision,
  221. "tools": has_tools,
  222. }
  223. def _architecture_defaults(architecture: str) -> dict[str, object]:
  224. if architecture == "qwen35moe":
  225. return {
  226. "temperature": 0.6,
  227. "top_k": 20,
  228. "top_p": 0.95,
  229. "min_p_checked": False,
  230. "repeat_penalty_checked": False,
  231. "presence_penalty_checked": False,
  232. "presence_penalty": 0.0,
  233. "reasoning_parsing": None,
  234. "supports_preserve_thinking": True,
  235. }
  236. if architecture == "qwen35":
  237. return {
  238. "temperature": 1.0,
  239. "top_k": 20,
  240. "top_p": 0.95,
  241. "min_p_checked": False,
  242. "repeat_penalty_checked": False,
  243. "presence_penalty_checked": True,
  244. "presence_penalty": 1.5,
  245. "reasoning_parsing": None,
  246. "supports_preserve_thinking": False,
  247. }
  248. if architecture == "gemma4":
  249. return {
  250. "temperature": 1.0,
  251. "top_k": 64,
  252. "top_p": 0.95,
  253. "min_p_checked": None,
  254. "repeat_penalty_checked": None,
  255. "presence_penalty_checked": None,
  256. "presence_penalty": None,
  257. "reasoning_parsing": {
  258. "enabled": True,
  259. "startString": "<|channel>thought",
  260. "endString": "<channel|>",
  261. },
  262. "supports_preserve_thinking": False,
  263. }
  264. return {
  265. "temperature": 1.0,
  266. "top_k": 40,
  267. "top_p": 0.95,
  268. "min_p_checked": None,
  269. "repeat_penalty_checked": None,
  270. "presence_penalty_checked": None,
  271. "presence_penalty": None,
  272. "reasoning_parsing": None,
  273. "supports_preserve_thinking": False,
  274. }
  275. def _has_sibling_mmproj(gguf_path: Path) -> bool:
  276. for sibling in gguf_path.parent.glob("*.gguf"):
  277. if sibling == gguf_path:
  278. continue
  279. if "mmproj" in sibling.name.lower():
  280. return True
  281. return False
  282. def collect_directory_capabilities(gguf_path: Path) -> dict[str, bool]:
  283. return {
  284. "has_sibling_mmproj": _has_sibling_mmproj(gguf_path),
  285. }
  286. def apply_directory_capability_overrides(
  287. capabilities: dict[str, bool],
  288. directory_capabilities: dict[str, bool],
  289. ) -> dict[str, bool]:
  290. augmented = dict(capabilities)
  291. if directory_capabilities.get("has_sibling_mmproj"):
  292. augmented["vision"] = True
  293. return augmented
  294. def build_virtual_model_profile(
  295. metadata: dict[str, object],
  296. *,
  297. has_sibling_mmproj: bool = False,
  298. ) -> dict[str, object]:
  299. architecture = str(
  300. metadata.get("general.architecture")
  301. or metadata.get("general.arch")
  302. or metadata.get("architecture")
  303. or ""
  304. ).strip()
  305. chat_template = str(metadata.get("tokenizer.chat_template") or "")
  306. base_name = _extract_base_name(metadata)
  307. basename_slug = _slugify(base_name)
  308. metadata_capabilities = _detect_capabilities(chat_template)
  309. capabilities = apply_directory_capability_overrides(
  310. metadata_capabilities,
  311. {"has_sibling_mmproj": has_sibling_mmproj},
  312. )
  313. defaults = _architecture_defaults(architecture)
  314. reasoning_enabled = capabilities["reasoning"]
  315. needs_enable_thinking = reasoning_enabled and "enable_thinking" in chat_template.lower()
  316. needs_preserve_thinking = needs_enable_thinking and bool(defaults["supports_preserve_thinking"])
  317. return {
  318. "architecture": architecture,
  319. "base_name": base_name,
  320. "basename_slug": basename_slug,
  321. "params_string": _extract_params_string(metadata, base_name),
  322. "context_length": _extract_context_length(metadata),
  323. "chat_template": chat_template,
  324. "capabilities": {
  325. "reasoning": reasoning_enabled,
  326. "vision": capabilities["vision"],
  327. "tools": capabilities["tools"],
  328. },
  329. "needs_enable_thinking": needs_enable_thinking,
  330. "needs_preserve_thinking": needs_preserve_thinking,
  331. "defaults": defaults,
  332. }
  333. def infer_virtual_model_slug(directory_name: str, metadata: dict[str, object]) -> str:
  334. base_name = directory_name.strip() or _extract_base_name(metadata)
  335. if base_name.upper().endswith("-GGUF"):
  336. base_name = base_name[:-5]
  337. return _slugify(base_name)
  338. def infer_model_key(models_root: Path, gguf_path: Path) -> str:
  339. return "/".join(gguf_path.relative_to(models_root).parts)
  340. def _build_base_block(model_key: str) -> list[str]:
  341. return [
  342. "base:",
  343. f" - key: {model_key}",
  344. " sources: []",
  345. ]
  346. def _append_metadata_overrides(lines: list[str], profile: dict[str, object]) -> None:
  347. capabilities = profile["capabilities"]
  348. lines.extend(
  349. [
  350. "metadataOverrides:",
  351. " domain: llm",
  352. " architectures:",
  353. f" - {profile['architecture']}",
  354. " compatibilityTypes:",
  355. " - gguf",
  356. ]
  357. )
  358. if profile["params_string"]:
  359. lines.extend(
  360. [
  361. " paramsStrings:",
  362. f" - {profile['params_string']}",
  363. ]
  364. )
  365. if profile["context_length"]:
  366. lines.extend(
  367. [
  368. " contextLengths:",
  369. f" - {profile['context_length']}",
  370. ]
  371. )
  372. lines.append(f" vision: {_bool_text(bool(capabilities['vision']))}")
  373. lines.append(f" reasoning: {_bool_text(bool(capabilities['reasoning']))}")
  374. lines.append(f" trainedForToolUse: {_bool_text(bool(capabilities['tools']))}")
  375. def _append_operation_fields(lines: list[str], profile: dict[str, object]) -> None:
  376. defaults = profile["defaults"]
  377. lines.extend(
  378. [
  379. "config:",
  380. " operation:",
  381. " fields:",
  382. " - key: llm.prediction.temperature",
  383. f" value: {defaults['temperature']}",
  384. " - key: llm.prediction.topKSampling",
  385. f" value: {defaults['top_k']}",
  386. " - key: llm.prediction.topPSampling",
  387. " value:",
  388. " checked: true",
  389. f" value: {defaults['top_p']}",
  390. ]
  391. )
  392. if defaults["min_p_checked"] is not None:
  393. lines.extend(
  394. [
  395. " - key: llm.prediction.minPSampling",
  396. " value:",
  397. f" checked: {_bool_text(bool(defaults['min_p_checked']))}",
  398. " value: 0",
  399. ]
  400. )
  401. if defaults["repeat_penalty_checked"] is not None:
  402. lines.extend(
  403. [
  404. " - key: llm.prediction.repeatPenalty",
  405. " value:",
  406. f" checked: {_bool_text(bool(defaults['repeat_penalty_checked']))}",
  407. " value: 1.0",
  408. ]
  409. )
  410. if defaults["presence_penalty_checked"] is not None:
  411. lines.extend(
  412. [
  413. " - key: llm.prediction.llama.presencePenalty",
  414. " value:",
  415. f" checked: {_bool_text(bool(defaults['presence_penalty_checked']))}",
  416. f" value: {defaults['presence_penalty']}",
  417. ]
  418. )
  419. if defaults["reasoning_parsing"] is not None:
  420. lines.extend(
  421. [
  422. " - key: llm.prediction.reasoning.parsing",
  423. " value:",
  424. f" enabled: {_bool_text(bool(defaults['reasoning_parsing']['enabled']))}",
  425. f" startString: \"{defaults['reasoning_parsing']['startString']}\"",
  426. f" endString: \"{defaults['reasoning_parsing']['endString']}\"",
  427. ]
  428. )
  429. if profile["chat_template"]:
  430. lines.extend(
  431. [
  432. " - key: llm.prediction.promptTemplate",
  433. " value:",
  434. " type: jinja",
  435. " jinjaPromptTemplate:",
  436. " template: |",
  437. ]
  438. )
  439. for line in str(profile["chat_template"]).splitlines():
  440. lines.append(f" {line}")
  441. lines.extend(
  442. [
  443. " stopStrings: []",
  444. ]
  445. )
  446. def _append_custom_fields(lines: list[str], profile: dict[str, object]) -> None:
  447. if not profile["needs_enable_thinking"]:
  448. return
  449. lines.extend(
  450. [
  451. "customFields:",
  452. " - key: enableThinking",
  453. " displayName: Enable Thinking",
  454. " description: Controls whether the model will think before replying",
  455. " type: boolean",
  456. " defaultValue: true",
  457. " effects:",
  458. " - type: setJinjaVariable",
  459. " variable: enable_thinking",
  460. ]
  461. )
  462. if profile["needs_preserve_thinking"]:
  463. lines.extend(
  464. [
  465. " - key: preserveThinking",
  466. " displayName: Preserve Thinking",
  467. " description: Preserve reasoning content in all prior assistant turns instead of only the most recent one",
  468. " type: boolean",
  469. " defaultValue: false",
  470. " effects:",
  471. " - type: setJinjaVariable",
  472. " variable: preserve_thinking",
  473. ]
  474. )
  475. def build_virtual_model_yaml(
  476. publisher: str,
  477. slug: str,
  478. model_key: str,
  479. profile: dict[str, object],
  480. ) -> str:
  481. normalized_publisher = normalize_owner(publisher)
  482. lines = [
  483. "# model.yaml is an open standard for defining cross-platform, composable AI models",
  484. "# Learn more at https://modelyaml.org",
  485. f"model: {normalized_publisher}/{slug}",
  486. ]
  487. lines.extend(_build_base_block(model_key))
  488. _append_metadata_overrides(lines, profile)
  489. _append_operation_fields(lines, profile)
  490. _append_custom_fields(lines, profile)
  491. return "\n".join(lines).rstrip() + "\n"
  492. def build_virtual_model_manifest(
  493. publisher: str,
  494. slug: str,
  495. model_key: str,
  496. ) -> dict:
  497. normalized_publisher = normalize_owner(publisher)
  498. return {
  499. "type": "model",
  500. "owner": normalized_publisher,
  501. "name": slug,
  502. "dependencies": [
  503. {
  504. "type": "model",
  505. "purpose": "baseModel",
  506. "modelKeys": [model_key],
  507. "sources": [],
  508. }
  509. ],
  510. "revision": 1,
  511. }
  512. def build_readme(publisher: str, slug: str, model_key: str) -> str:
  513. normalized_publisher = normalize_owner(publisher)
  514. return (
  515. f"# {normalized_publisher}/{slug}\n\n"
  516. "这个目录由 `wrapper_generator.py` 自动生成,用于把本地 concrete GGUF 提升为 LM Studio 可识别的 virtual model。\n\n"
  517. "## Base Model\n\n"
  518. f"- `{model_key}`\n"
  519. )
  520. def build_virtual_model_files(
  521. publisher: str,
  522. slug: str,
  523. model_key: str,
  524. profile: dict[str, object],
  525. ) -> dict[str, str]:
  526. return {
  527. "model.yaml": build_virtual_model_yaml(
  528. publisher=publisher,
  529. slug=slug,
  530. model_key=model_key,
  531. profile=profile,
  532. ),
  533. "manifest.json": json.dumps(
  534. build_virtual_model_manifest(
  535. publisher=publisher,
  536. slug=slug,
  537. model_key=model_key,
  538. ),
  539. ensure_ascii=False,
  540. indent=2,
  541. )
  542. + "\n",
  543. "README.md": build_readme(
  544. publisher=publisher,
  545. slug=slug,
  546. model_key=model_key,
  547. ),
  548. }
  549. def scan_gguf_files(models_root: Path) -> list[Path]:
  550. if not models_root.exists():
  551. return []
  552. return sorted(models_root.rglob("*.gguf"))
  553. def should_skip_gguf(models_root: Path, gguf_path: Path) -> str | None:
  554. relative_parts = [part.lower() for part in gguf_path.relative_to(models_root).parts]
  555. if "lmstudio-community" in relative_parts:
  556. return "lmstudio-community"
  557. if "mmproj" in gguf_path.name.lower():
  558. return "mmproj"
  559. return None
  560. def collect_virtual_model_job(
  561. models_root: Path,
  562. output_root: Path,
  563. gguf_path: Path,
  564. cache_index: dict[str, dict[str, object]],
  565. ) -> dict[str, object]:
  566. skip_reason = should_skip_gguf(models_root, gguf_path)
  567. raw_publisher = gguf_path.relative_to(models_root).parts[0]
  568. publisher = normalize_owner(raw_publisher)
  569. if skip_reason:
  570. return {
  571. "source_gguf": str(gguf_path),
  572. "publisher": publisher,
  573. "reason": skip_reason,
  574. }
  575. metadata = resolve_gguf_metadata(gguf_path, cache_index)
  576. directory_capabilities = collect_directory_capabilities(gguf_path)
  577. profile = build_virtual_model_profile(
  578. metadata,
  579. has_sibling_mmproj=bool(directory_capabilities["has_sibling_mmproj"]),
  580. )
  581. slug = infer_virtual_model_slug(gguf_path.parent.name, metadata)
  582. model_key = infer_model_key(models_root, gguf_path)
  583. output_dir = output_root / publisher / slug
  584. return {
  585. "source_gguf": str(gguf_path),
  586. "publisher": publisher,
  587. "slug": slug,
  588. "model_key": model_key,
  589. "output_dir": str(output_dir),
  590. "capabilities": profile["capabilities"],
  591. "profile": profile,
  592. "will_overwrite": any((output_dir / name).exists() for name in ("model.yaml", "manifest.json", "README.md")),
  593. }
  594. def write_virtual_model(output_dir: Path, publisher: str, slug: str, model_key: str, profile: dict[str, object]) -> None:
  595. files = build_virtual_model_files(
  596. publisher=publisher,
  597. slug=slug,
  598. model_key=model_key,
  599. profile=profile,
  600. )
  601. output_dir.mkdir(parents=True, exist_ok=True)
  602. for name, content in files.items():
  603. (output_dir / name).write_text(content, encoding="utf-8")
  604. def delete_model_index_cache(cache_path: Path | None = None) -> tuple[bool, str | None]:
  605. if cache_path is None:
  606. cache_path = DEFAULT_MODEL_INDEX_CACHE
  607. if not cache_path.exists():
  608. return False, None
  609. try:
  610. cache_path.unlink()
  611. except OSError as exc:
  612. return False, str(exc)
  613. return True, None
  614. def generate_virtual_models_for_directory(
  615. models_root: Path,
  616. output_root: Path,
  617. dry_run: bool,
  618. cache_path: Path = DEFAULT_GGUF_METADATA_CACHE,
  619. model_index_cache_path: Path | None = None,
  620. ) -> dict[str, object]:
  621. generated: list[dict[str, object]] = []
  622. skipped: list[dict[str, object]] = []
  623. cache_index = load_gguf_metadata_cache(cache_path)
  624. for gguf_path in scan_gguf_files(models_root):
  625. job = collect_virtual_model_job(models_root, output_root, gguf_path, cache_index)
  626. if "reason" in job:
  627. skipped.append(job)
  628. continue
  629. summary_item = {
  630. "source_gguf": job["source_gguf"],
  631. "publisher": job["publisher"],
  632. "slug": job["slug"],
  633. "model_key": job["model_key"],
  634. "output_dir": job["output_dir"],
  635. "capabilities": job["capabilities"],
  636. "will_overwrite": job["will_overwrite"],
  637. }
  638. generated.append(summary_item)
  639. if not dry_run:
  640. write_virtual_model(
  641. output_dir=Path(job["output_dir"]),
  642. publisher=str(job["publisher"]),
  643. slug=str(job["slug"]),
  644. model_key=str(job["model_key"]),
  645. profile=dict(job["profile"]),
  646. )
  647. if model_index_cache_path is None:
  648. model_index_cache_path = DEFAULT_MODEL_INDEX_CACHE
  649. deleted_model_index_cache = False
  650. model_index_cache_delete_error = None
  651. if not dry_run and generated:
  652. deleted_model_index_cache, model_index_cache_delete_error = delete_model_index_cache(
  653. model_index_cache_path
  654. )
  655. return {
  656. "generated_at": datetime.now().isoformat(),
  657. "dry_run": dry_run,
  658. "models_root": str(models_root),
  659. "output_root": str(output_root),
  660. "deleted_model_index_cache": deleted_model_index_cache,
  661. "model_index_cache_path": str(model_index_cache_path),
  662. "model_index_cache_delete_error": model_index_cache_delete_error,
  663. "generated": generated,
  664. "skipped": skipped,
  665. }
  666. def main() -> int:
  667. parser = argparse.ArgumentParser()
  668. parser.add_argument("--models-root", default=str(DEFAULT_MODELS_ROOT))
  669. parser.add_argument("--output-root", default=str(DEFAULT_OUTPUT_ROOT))
  670. parser.add_argument("--dry-run", action="store_true")
  671. args = parser.parse_args()
  672. summary = generate_virtual_models_for_directory(
  673. models_root=Path(args.models_root).expanduser(),
  674. output_root=Path(args.output_root).expanduser(),
  675. dry_run=args.dry_run,
  676. )
  677. print(json.dumps(summary, ensure_ascii=False, indent=2))
  678. return 0
  679. if __name__ == "__main__":
  680. raise SystemExit(main())