|
|
@@ -1,19 +1,17 @@
|
|
|
#!/usr/bin/env python3
|
|
|
import argparse
|
|
|
import json
|
|
|
+import re
|
|
|
import struct
|
|
|
from datetime import datetime
|
|
|
from pathlib import Path
|
|
|
|
|
|
-DEFAULT_OFFICIAL_MODEL_YAML = Path(r"~\hub\models\qwen\qwen3.6-35b-a3b\model.yaml")
|
|
|
-DEFAULT_OFFICIAL_MANIFEST = Path(r"~\hub\models\qwen\qwen3.6-35b-a3b\manifest.json")
|
|
|
-DEFAULT_OUTPUT_ROOT = Path("artifacts/lmstudio-model-wrappers")
|
|
|
-DEFAULT_TARGET_MODEL_KEY = "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive"
|
|
|
-DEFAULT_WRAPPER_OWNER = "local"
|
|
|
-DEFAULT_WRAPPER_NAME = "qwen3.6-35b-a3b-hauhaucs-aggressive"
|
|
|
-DEFAULT_MODELS_ROOT = Path.home() / ".lmstudio" / "models"
|
|
|
-DEFAULT_HUB_ROOT = Path.home() / ".lmstudio" / "hub" / "models"
|
|
|
|
|
|
+DEFAULT_MODELS_ROOT = Path.home() / ".lmstudio" / "models"
|
|
|
+DEFAULT_OUTPUT_ROOT = Path.home() / ".lmstudio" / "hub" / "models"
|
|
|
+DEFAULT_INTERNAL_ROOT = Path.home() / ".lmstudio" / ".internal"
|
|
|
+DEFAULT_GGUF_METADATA_CACHE = DEFAULT_INTERNAL_ROOT / "gguf-metadata-cache.json"
|
|
|
+DEFAULT_MODEL_INDEX_CACHE = DEFAULT_INTERNAL_ROOT / "model-index-cache.json"
|
|
|
|
|
|
GGUF_VALUE_TYPES = {
|
|
|
0: ("<B", 1),
|
|
|
@@ -34,6 +32,10 @@ def _bool_text(value: bool) -> str:
|
|
|
return "true" if value else "false"
|
|
|
|
|
|
|
|
|
+def normalize_owner(owner: str) -> str:
|
|
|
+ return _slugify(owner)
|
|
|
+
|
|
|
+
|
|
|
def _read_struct(data: bytes, offset: int, fmt: str) -> tuple[object, int]:
|
|
|
size = struct.calcsize(fmt)
|
|
|
if offset + size > len(data):
|
|
|
@@ -67,417 +69,557 @@ def _read_gguf_value(data: bytes, offset: int, value_type: int) -> tuple[object,
|
|
|
return _read_struct(data, offset, fmt)
|
|
|
|
|
|
|
|
|
-def read_gguf_metadata(path: Path) -> dict[str, object]:
|
|
|
- data = path.read_bytes()
|
|
|
- if len(data) < 24 or data[:4] != b"GGUF":
|
|
|
- raise ValueError(f"Not a GGUF file: {path}")
|
|
|
+def _normalize_cached_metadata(metadata: dict[str, object]) -> dict[str, object]:
|
|
|
+ normalized: dict[str, object] = {}
|
|
|
+ architecture = metadata.get("arch")
|
|
|
+ if architecture is not None:
|
|
|
+ normalized["general.architecture"] = architecture
|
|
|
+ name = metadata.get("name")
|
|
|
+ if name is not None:
|
|
|
+ normalized["general.name"] = name
|
|
|
+ chat_template = metadata.get("chatTemplate")
|
|
|
+ if chat_template is not None:
|
|
|
+ normalized["tokenizer.chat_template"] = chat_template
|
|
|
+ parameters = metadata.get("parameters")
|
|
|
+ if parameters is not None:
|
|
|
+ normalized["general.parameter_count_label"] = str(parameters)
|
|
|
+ context_length = metadata.get("contextLength")
|
|
|
+ if context_length is not None:
|
|
|
+ normalized["general.context_length"] = str(context_length)
|
|
|
+ bos_token = metadata.get("bosToken")
|
|
|
+ if bos_token is not None:
|
|
|
+ normalized["tokenizer.ggml.bos_token"] = bos_token
|
|
|
+ eos_token = metadata.get("eosToken")
|
|
|
+ if eos_token is not None:
|
|
|
+ normalized["tokenizer.ggml.eos_token"] = eos_token
|
|
|
+ return normalized
|
|
|
+
|
|
|
+
|
|
|
+def load_gguf_metadata_cache(cache_path: Path = DEFAULT_GGUF_METADATA_CACHE) -> dict[str, dict[str, object]]:
|
|
|
+ if not cache_path.exists():
|
|
|
+ return {}
|
|
|
+ payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
|
+ entries = payload.get("json", {}).get("map", [])
|
|
|
+ cache_index: dict[str, dict[str, object]] = {}
|
|
|
+ for entry in entries:
|
|
|
+ if not isinstance(entry, list) or len(entry) != 2:
|
|
|
+ continue
|
|
|
+ file_path, cached_payload = entry
|
|
|
+ if not isinstance(file_path, str) or not isinstance(cached_payload, dict):
|
|
|
+ continue
|
|
|
+ metadata = cached_payload.get("metadata")
|
|
|
+ if isinstance(metadata, dict):
|
|
|
+ cache_index[file_path] = _normalize_cached_metadata(metadata)
|
|
|
+ return cache_index
|
|
|
|
|
|
- version, offset = _read_struct(data, 4, "<I")
|
|
|
- if int(version) not in (2, 3):
|
|
|
- raise ValueError(f"Unsupported GGUF version: {version}")
|
|
|
|
|
|
- _, offset = _read_struct(data, offset, "<Q")
|
|
|
- metadata_count, offset = _read_struct(data, offset, "<Q")
|
|
|
+def _read_exact(file_obj, size: int) -> bytes:
|
|
|
+ data = file_obj.read(size)
|
|
|
+ if len(data) != size:
|
|
|
+ raise ValueError("Unexpected end of GGUF metadata stream")
|
|
|
+ return data
|
|
|
|
|
|
- metadata: dict[str, object] = {}
|
|
|
- for _ in range(int(metadata_count)):
|
|
|
- key, offset = _read_gguf_string(data, offset)
|
|
|
- value_type, offset = _read_struct(data, offset, "<I")
|
|
|
- value, offset = _read_gguf_value(data, offset, int(value_type))
|
|
|
- metadata[key] = value
|
|
|
- return metadata
|
|
|
|
|
|
+def _read_struct_stream(file_obj, fmt: str) -> object:
|
|
|
+ size = struct.calcsize(fmt)
|
|
|
+ return struct.unpack(fmt, _read_exact(file_obj, size))[0]
|
|
|
|
|
|
-def extract_architecture_family(value: object) -> str:
|
|
|
- text = str(value or "").strip().lower()
|
|
|
- if not text:
|
|
|
- return ""
|
|
|
- head = []
|
|
|
- for char in text:
|
|
|
- if char.isalpha():
|
|
|
- head.append(char)
|
|
|
- continue
|
|
|
- if head:
|
|
|
- break
|
|
|
- return "".join(head) or text
|
|
|
|
|
|
+def _read_gguf_string_stream(file_obj) -> str:
|
|
|
+ length = int(_read_struct_stream(file_obj, "<Q"))
|
|
|
+ return _read_exact(file_obj, length).decode("utf-8", errors="replace")
|
|
|
|
|
|
-def infer_model_identity(
|
|
|
- models_root: Path, gguf_path: Path, metadata: dict[str, object]
|
|
|
-) -> tuple[str, str]:
|
|
|
- try:
|
|
|
- relative = gguf_path.relative_to(models_root)
|
|
|
- parts = list(relative.parts[:-1])
|
|
|
- except ValueError:
|
|
|
- parts = []
|
|
|
|
|
|
- owner = ""
|
|
|
- name = ""
|
|
|
- if len(parts) >= 2:
|
|
|
- owner = parts[0]
|
|
|
- name = parts[1]
|
|
|
- elif len(parts) == 1:
|
|
|
- owner = "local"
|
|
|
- name = parts[0]
|
|
|
+def _read_gguf_value_stream(file_obj, value_type: int) -> object:
|
|
|
+ if value_type == 8:
|
|
|
+ return _read_gguf_string_stream(file_obj)
|
|
|
+ if value_type == 9:
|
|
|
+ nested_type = int(_read_struct_stream(file_obj, "<I"))
|
|
|
+ count = int(_read_struct_stream(file_obj, "<Q"))
|
|
|
+ return [_read_gguf_value_stream(file_obj, nested_type) for _ in range(count)]
|
|
|
+ if value_type not in GGUF_VALUE_TYPES:
|
|
|
+ raise ValueError(f"Unsupported GGUF metadata value type: {value_type}")
|
|
|
+ fmt, _ = GGUF_VALUE_TYPES[value_type]
|
|
|
+ return _read_struct_stream(file_obj, fmt)
|
|
|
|
|
|
- if not owner:
|
|
|
- owner = "local"
|
|
|
|
|
|
- if not name:
|
|
|
- name = str(metadata.get("general.name") or gguf_path.stem)
|
|
|
+def read_gguf_metadata_streaming(path: Path) -> dict[str, object]:
|
|
|
+ with path.open("rb") as file_obj:
|
|
|
+ if _read_exact(file_obj, 4) != b"GGUF":
|
|
|
+ raise ValueError(f"Not a GGUF file: {path}")
|
|
|
|
|
|
- owner = owner.strip().replace("\\", "-").replace("/", "-")
|
|
|
- name = name.strip().replace("\\", "-").replace("/", "-")
|
|
|
- return owner, name
|
|
|
+ version = int(_read_struct_stream(file_obj, "<I"))
|
|
|
+ if version not in (2, 3):
|
|
|
+ raise ValueError(f"Unsupported GGUF version: {version}")
|
|
|
|
|
|
+ _ = _read_struct_stream(file_obj, "<Q")
|
|
|
+ metadata_count = int(_read_struct_stream(file_obj, "<Q"))
|
|
|
|
|
|
-def infer_model_key(gguf_path: Path) -> str:
|
|
|
- return gguf_path.stem
|
|
|
+ metadata: dict[str, object] = {}
|
|
|
+ for _ in range(metadata_count):
|
|
|
+ key = _read_gguf_string_stream(file_obj)
|
|
|
+ value_type = int(_read_struct_stream(file_obj, "<I"))
|
|
|
+ metadata[key] = _read_gguf_value_stream(file_obj, value_type)
|
|
|
+ return metadata
|
|
|
|
|
|
|
|
|
-def _metadata_bool(metadata: dict[str, object], keys: list[str]) -> bool | None:
|
|
|
- for key in keys:
|
|
|
- if key in metadata and isinstance(metadata[key], bool):
|
|
|
- return bool(metadata[key])
|
|
|
+def resolve_gguf_metadata(
|
|
|
+ gguf_path: Path,
|
|
|
+ cache_index: dict[str, dict[str, object]],
|
|
|
+) -> dict[str, object]:
|
|
|
+ resolved = gguf_path.resolve().as_posix()
|
|
|
+ cached = cache_index.get(resolved)
|
|
|
+ if cached is not None:
|
|
|
+ return dict(cached)
|
|
|
+ return read_gguf_metadata_streaming(gguf_path)
|
|
|
+
|
|
|
+
|
|
|
+def _slugify(text: str) -> str:
|
|
|
+ lowered = text.strip().lower()
|
|
|
+ lowered = lowered.replace("_", "-")
|
|
|
+ lowered = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", lowered)
|
|
|
+ lowered = lowered.replace("qwen3vl", "qwen3-vl")
|
|
|
+ lowered = lowered.replace("gemma4", "gemma-4")
|
|
|
+ lowered = re.sub(r"[^a-z0-9.]+", "-", lowered)
|
|
|
+ lowered = re.sub(r"-{2,}", "-", lowered)
|
|
|
+ return lowered.strip("-")
|
|
|
+
|
|
|
+
|
|
|
+def _extract_context_length(metadata: dict[str, object]) -> int | None:
|
|
|
+ for key in (
|
|
|
+ "qwen2.context_length",
|
|
|
+ "qwen.context_length",
|
|
|
+ "llama.context_length",
|
|
|
+ "gemma.context_length",
|
|
|
+ "general.context_length",
|
|
|
+ ):
|
|
|
+ value = metadata.get(key)
|
|
|
+ if value is None:
|
|
|
+ continue
|
|
|
+ text = str(value).strip()
|
|
|
+ if text.isdigit():
|
|
|
+ return int(text)
|
|
|
return None
|
|
|
|
|
|
|
|
|
-def extract_capabilities_from_metadata(
|
|
|
- metadata: dict[str, object],
|
|
|
-) -> dict[str, bool | None]:
|
|
|
- reasoning = _metadata_bool(
|
|
|
- metadata,
|
|
|
- [
|
|
|
- "capabilities.reasoning",
|
|
|
- "general.reasoning",
|
|
|
- "reasoning",
|
|
|
- ],
|
|
|
- )
|
|
|
- if reasoning is None:
|
|
|
- chat_template = metadata.get("tokenizer.chat_template")
|
|
|
- if isinstance(chat_template, str) and "enable_thinking" in chat_template:
|
|
|
- reasoning = True
|
|
|
+def _extract_params_string(metadata: dict[str, object], base_name: str) -> str | None:
|
|
|
+ for key in ("general.size_label", "general.parameter_count_label", "general.basename"):
|
|
|
+ value = metadata.get(key)
|
|
|
+ if isinstance(value, str):
|
|
|
+ match = re.search(r"(\d+(?:\.\d+)?B(?:-A\d+B)?)", value, re.IGNORECASE)
|
|
|
+ if match:
|
|
|
+ raw = match.group(1).upper()
|
|
|
+ return raw.split("-A")[0] if "-A" in raw else raw
|
|
|
+ match = re.search(r"(\d+(?:\.\d+)?B)(?:-A\d+B)?", base_name, re.IGNORECASE)
|
|
|
+ if match:
|
|
|
+ return match.group(1).upper()
|
|
|
+ return None
|
|
|
|
|
|
- vision = _metadata_bool(
|
|
|
- metadata,
|
|
|
- [
|
|
|
- "capabilities.vision",
|
|
|
- "general.vision",
|
|
|
- "vision",
|
|
|
- ],
|
|
|
+
|
|
|
+def _extract_base_name(metadata: dict[str, object]) -> str:
|
|
|
+ for key in ("general.basename", "general.name"):
|
|
|
+ value = metadata.get(key)
|
|
|
+ if isinstance(value, str) and value.strip():
|
|
|
+ text = value.strip()
|
|
|
+ if text.upper().endswith("-GGUF"):
|
|
|
+ text = text[:-5]
|
|
|
+ return text
|
|
|
+ return "local-model"
|
|
|
+
|
|
|
+
|
|
|
+def _detect_capabilities(chat_template: str) -> dict[str, bool]:
|
|
|
+ lowered = chat_template.lower()
|
|
|
+ has_reasoning = "enable_thinking" in lowered or "preserve_thinking" in lowered
|
|
|
+ has_tools = any(
|
|
|
+ token in lowered
|
|
|
+ for token in (
|
|
|
+ "<tools>",
|
|
|
+ "<tool_call>",
|
|
|
+ "<tool_response>",
|
|
|
+ "<|tool>",
|
|
|
+ "<tool|>",
|
|
|
+ "<|tool|>",
|
|
|
+ "<|tool_call>",
|
|
|
+ "<tool_call|>",
|
|
|
+ "<|tool_call|>",
|
|
|
+ "<|tool_response>",
|
|
|
+ "<tool_response|>",
|
|
|
+ "<|tool_response|>",
|
|
|
+ )
|
|
|
)
|
|
|
- tools = _metadata_bool(
|
|
|
- metadata,
|
|
|
- [
|
|
|
- "capabilities.tools",
|
|
|
- "general.tools",
|
|
|
- "tools",
|
|
|
- ],
|
|
|
+ has_vision = any(
|
|
|
+ token in lowered
|
|
|
+ for token in (
|
|
|
+ "<|vision_start|>",
|
|
|
+ "<image_soft_token>",
|
|
|
+ "<audio_soft_token>",
|
|
|
+ "<|image_pad|>",
|
|
|
+ "<|video_pad|>",
|
|
|
+ )
|
|
|
)
|
|
|
return {
|
|
|
- "reasoning": reasoning,
|
|
|
- "vision": vision,
|
|
|
- "tools": tools,
|
|
|
+ "reasoning": has_reasoning,
|
|
|
+ "vision": has_vision,
|
|
|
+ "tools": has_tools,
|
|
|
}
|
|
|
|
|
|
|
|
|
-def extract_capabilities_from_template(model_yaml_text: str) -> dict[str, bool | None]:
|
|
|
- lowered = model_yaml_text.lower()
|
|
|
- reasoning = None
|
|
|
- if "reasoning: true" in lowered or "key: enablethinking" in lowered:
|
|
|
- reasoning = True
|
|
|
- elif "reasoning: false" in lowered:
|
|
|
- reasoning = False
|
|
|
-
|
|
|
- vision = True if "vision: true" in lowered else None
|
|
|
- tools = True if "tools: true" in lowered else None
|
|
|
+def _architecture_defaults(architecture: str) -> dict[str, object]:
|
|
|
+ if architecture == "qwen35moe":
|
|
|
+ return {
|
|
|
+ "temperature": 0.6,
|
|
|
+ "top_k": 20,
|
|
|
+ "top_p": 0.95,
|
|
|
+ "min_p_checked": False,
|
|
|
+ "repeat_penalty_checked": False,
|
|
|
+ "presence_penalty_checked": False,
|
|
|
+ "presence_penalty": 0.0,
|
|
|
+ "reasoning_parsing": None,
|
|
|
+ "supports_preserve_thinking": True,
|
|
|
+ }
|
|
|
+ if architecture == "qwen35":
|
|
|
+ return {
|
|
|
+ "temperature": 1.0,
|
|
|
+ "top_k": 20,
|
|
|
+ "top_p": 0.95,
|
|
|
+ "min_p_checked": False,
|
|
|
+ "repeat_penalty_checked": False,
|
|
|
+ "presence_penalty_checked": True,
|
|
|
+ "presence_penalty": 1.5,
|
|
|
+ "reasoning_parsing": None,
|
|
|
+ "supports_preserve_thinking": False,
|
|
|
+ }
|
|
|
+ if architecture == "gemma4":
|
|
|
+ return {
|
|
|
+ "temperature": 1.0,
|
|
|
+ "top_k": 64,
|
|
|
+ "top_p": 0.95,
|
|
|
+ "min_p_checked": None,
|
|
|
+ "repeat_penalty_checked": None,
|
|
|
+ "presence_penalty_checked": None,
|
|
|
+ "presence_penalty": None,
|
|
|
+ "reasoning_parsing": {
|
|
|
+ "enabled": True,
|
|
|
+ "startString": "<|channel>thought",
|
|
|
+ "endString": "<channel|>",
|
|
|
+ },
|
|
|
+ "supports_preserve_thinking": False,
|
|
|
+ }
|
|
|
return {
|
|
|
- "reasoning": reasoning,
|
|
|
- "vision": vision,
|
|
|
- "tools": tools,
|
|
|
+ "temperature": 1.0,
|
|
|
+ "top_k": 40,
|
|
|
+ "top_p": 0.95,
|
|
|
+ "min_p_checked": None,
|
|
|
+ "repeat_penalty_checked": None,
|
|
|
+ "presence_penalty_checked": None,
|
|
|
+ "presence_penalty": None,
|
|
|
+ "reasoning_parsing": None,
|
|
|
+ "supports_preserve_thinking": False,
|
|
|
}
|
|
|
|
|
|
|
|
|
-def merge_capabilities(
|
|
|
- metadata_capabilities: dict[str, bool | None],
|
|
|
- template_capabilities: dict[str, bool | None],
|
|
|
-) -> dict[str, bool | None]:
|
|
|
- merged: dict[str, bool | None] = {}
|
|
|
- for key in ("reasoning", "vision", "tools"):
|
|
|
- merged[key] = (
|
|
|
- metadata_capabilities[key]
|
|
|
- if metadata_capabilities.get(key) is not None
|
|
|
- else template_capabilities.get(key)
|
|
|
- )
|
|
|
- return merged
|
|
|
-
|
|
|
+def _has_sibling_mmproj(gguf_path: Path) -> bool:
|
|
|
+ for sibling in gguf_path.parent.glob("*.gguf"):
|
|
|
+ if sibling == gguf_path:
|
|
|
+ continue
|
|
|
+ if "mmproj" in sibling.name.lower():
|
|
|
+ return True
|
|
|
+ return False
|
|
|
|
|
|
-def find_template_package(hub_root: Path, metadata: dict[str, object]) -> Path | None:
|
|
|
- family = extract_architecture_family(metadata.get("general.architecture"))
|
|
|
- if not family or not hub_root.exists():
|
|
|
- return None
|
|
|
|
|
|
- best_match: tuple[int, Path] | None = None
|
|
|
- for model_yaml_path in hub_root.rglob("model.yaml"):
|
|
|
- package_dir = model_yaml_path.parent
|
|
|
- manifest_path = package_dir / "manifest.json"
|
|
|
- if not manifest_path.exists():
|
|
|
- continue
|
|
|
- try:
|
|
|
- relative = package_dir.relative_to(hub_root)
|
|
|
- except ValueError:
|
|
|
- continue
|
|
|
- if not relative.parts:
|
|
|
- continue
|
|
|
+def collect_directory_capabilities(gguf_path: Path) -> dict[str, bool]:
|
|
|
+ return {
|
|
|
+ "has_sibling_mmproj": _has_sibling_mmproj(gguf_path),
|
|
|
+ }
|
|
|
|
|
|
- owner = relative.parts[0].lower()
|
|
|
- name = relative.parts[-1].lower()
|
|
|
- if owner == "lmstudio-community":
|
|
|
- continue
|
|
|
|
|
|
- score = 0
|
|
|
- if owner == family:
|
|
|
- score += 100
|
|
|
- elif family in owner:
|
|
|
- score += 70
|
|
|
- if family in name:
|
|
|
- score += 40
|
|
|
+def apply_directory_capability_overrides(
|
|
|
+ capabilities: dict[str, bool],
|
|
|
+ directory_capabilities: dict[str, bool],
|
|
|
+) -> dict[str, bool]:
|
|
|
+ augmented = dict(capabilities)
|
|
|
+ if directory_capabilities.get("has_sibling_mmproj"):
|
|
|
+ augmented["vision"] = True
|
|
|
+ return augmented
|
|
|
|
|
|
- if score <= 0:
|
|
|
- continue
|
|
|
- if best_match is None or score > best_match[0]:
|
|
|
- best_match = (score, package_dir)
|
|
|
|
|
|
- if best_match is None:
|
|
|
- return None
|
|
|
- return best_match[1]
|
|
|
+def build_virtual_model_profile(
|
|
|
+ metadata: dict[str, object],
|
|
|
+ *,
|
|
|
+ has_sibling_mmproj: bool = False,
|
|
|
+) -> dict[str, object]:
|
|
|
+ architecture = str(
|
|
|
+ metadata.get("general.architecture")
|
|
|
+ or metadata.get("general.arch")
|
|
|
+ or metadata.get("architecture")
|
|
|
+ or ""
|
|
|
+ ).strip()
|
|
|
+ chat_template = str(metadata.get("tokenizer.chat_template") or "")
|
|
|
+ base_name = _extract_base_name(metadata)
|
|
|
+ basename_slug = _slugify(base_name)
|
|
|
+ metadata_capabilities = _detect_capabilities(chat_template)
|
|
|
+ capabilities = apply_directory_capability_overrides(
|
|
|
+ metadata_capabilities,
|
|
|
+ {"has_sibling_mmproj": has_sibling_mmproj},
|
|
|
+ )
|
|
|
+ defaults = _architecture_defaults(architecture)
|
|
|
+ reasoning_enabled = capabilities["reasoning"]
|
|
|
+ needs_enable_thinking = reasoning_enabled and "enable_thinking" in chat_template.lower()
|
|
|
+ needs_preserve_thinking = needs_enable_thinking and bool(defaults["supports_preserve_thinking"])
|
|
|
|
|
|
+ return {
|
|
|
+ "architecture": architecture,
|
|
|
+ "base_name": base_name,
|
|
|
+ "basename_slug": basename_slug,
|
|
|
+ "params_string": _extract_params_string(metadata, base_name),
|
|
|
+ "context_length": _extract_context_length(metadata),
|
|
|
+ "chat_template": chat_template,
|
|
|
+ "capabilities": {
|
|
|
+ "reasoning": reasoning_enabled,
|
|
|
+ "vision": capabilities["vision"],
|
|
|
+ "tools": capabilities["tools"],
|
|
|
+ },
|
|
|
+ "needs_enable_thinking": needs_enable_thinking,
|
|
|
+ "needs_preserve_thinking": needs_preserve_thinking,
|
|
|
+ "defaults": defaults,
|
|
|
+ }
|
|
|
|
|
|
-def _find_line_index(lines: list[str], prefix: str) -> int | None:
|
|
|
- for index, line in enumerate(lines):
|
|
|
- if line.strip().startswith(prefix):
|
|
|
- return index
|
|
|
- return None
|
|
|
|
|
|
+def infer_virtual_model_slug(directory_name: str, metadata: dict[str, object]) -> str:
|
|
|
+ base_name = directory_name.strip() or _extract_base_name(metadata)
|
|
|
+ if base_name.upper().endswith("-GGUF"):
|
|
|
+ base_name = base_name[:-5]
|
|
|
+ return _slugify(base_name)
|
|
|
|
|
|
-def _find_section_bounds(lines: list[str], header: str) -> tuple[int, int] | None:
|
|
|
- for index, line in enumerate(lines):
|
|
|
- if line.strip() != header:
|
|
|
- continue
|
|
|
- indent = len(line) - len(line.lstrip(" "))
|
|
|
- end = len(lines)
|
|
|
- for probe in range(index + 1, len(lines)):
|
|
|
- candidate = lines[probe]
|
|
|
- stripped = candidate.strip()
|
|
|
- if not stripped:
|
|
|
- continue
|
|
|
- candidate_indent = len(candidate) - len(candidate.lstrip(" "))
|
|
|
- if candidate_indent <= indent:
|
|
|
- end = probe
|
|
|
- break
|
|
|
- return index, end
|
|
|
- return None
|
|
|
|
|
|
+def infer_model_key(models_root: Path, gguf_path: Path) -> str:
|
|
|
+ return "/".join(gguf_path.relative_to(models_root).parts)
|
|
|
|
|
|
-def _replace_base_block(lines: list[str], target_model_key: str) -> list[str]:
|
|
|
- result: list[str] = []
|
|
|
- index = 0
|
|
|
- replaced = False
|
|
|
- while index < len(lines):
|
|
|
- line = lines[index]
|
|
|
- if not replaced and line.strip().startswith("base:"):
|
|
|
- base_indent = len(line) - len(line.lstrip(" "))
|
|
|
- result.append(f'base: "{target_model_key}"')
|
|
|
- replaced = True
|
|
|
- index += 1
|
|
|
- while index < len(lines):
|
|
|
- candidate = lines[index]
|
|
|
- stripped = candidate.strip()
|
|
|
- if not stripped:
|
|
|
- index += 1
|
|
|
- continue
|
|
|
- candidate_indent = len(candidate) - len(candidate.lstrip(" "))
|
|
|
- if candidate_indent <= base_indent:
|
|
|
- break
|
|
|
- index += 1
|
|
|
- continue
|
|
|
- result.append(line)
|
|
|
- index += 1
|
|
|
- if not replaced:
|
|
|
- raise ValueError("Official model.yaml is missing a base declaration")
|
|
|
- return result
|
|
|
|
|
|
+def _build_base_block(model_key: str) -> list[str]:
|
|
|
+ return [
|
|
|
+ "base:",
|
|
|
+ f" - key: {model_key}",
|
|
|
+ " sources: []",
|
|
|
+ ]
|
|
|
|
|
|
-def _upsert_metadata_bool(lines: list[str], key: str, value: bool | None) -> list[str]:
|
|
|
- if value is None:
|
|
|
- return lines
|
|
|
|
|
|
- bounds = _find_section_bounds(lines, "metadataOverrides:")
|
|
|
- if bounds is None:
|
|
|
- insert_at = _find_line_index(lines, "config:")
|
|
|
- metadata_block = [
|
|
|
+def _append_metadata_overrides(lines: list[str], profile: dict[str, object]) -> None:
|
|
|
+ capabilities = profile["capabilities"]
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
"metadataOverrides:",
|
|
|
- f" {key}: {_bool_text(value)}",
|
|
|
+ " domain: llm",
|
|
|
+ " architectures:",
|
|
|
+ f" - {profile['architecture']}",
|
|
|
+ " compatibilityTypes:",
|
|
|
+ " - gguf",
|
|
|
]
|
|
|
- if insert_at is None:
|
|
|
- return lines + metadata_block
|
|
|
- return lines[:insert_at] + metadata_block + lines[insert_at:]
|
|
|
-
|
|
|
- start, end = bounds
|
|
|
- child_prefix = " "
|
|
|
- for index in range(start + 1, end):
|
|
|
- if lines[index].strip().startswith(f"{key}:"):
|
|
|
- lines[index] = f"{child_prefix}{key}: {_bool_text(value)}"
|
|
|
- return lines
|
|
|
-
|
|
|
- return lines[:end] + [f"{child_prefix}{key}: {_bool_text(value)}"] + lines[end:]
|
|
|
-
|
|
|
-
|
|
|
-def build_wrapper_model_yaml(
|
|
|
- official_model_yaml_text: str,
|
|
|
- target_model_key: str,
|
|
|
- wrapper_owner: str,
|
|
|
- wrapper_name: str,
|
|
|
- capabilities: dict[str, bool | None] | None = None,
|
|
|
-) -> str:
|
|
|
- lines = official_model_yaml_text.splitlines()
|
|
|
- model_index = _find_line_index(lines, "model:")
|
|
|
- if model_index is None:
|
|
|
- raise ValueError("Official model.yaml is missing a model declaration")
|
|
|
- lines[model_index] = f"model: {wrapper_owner}/{wrapper_name}"
|
|
|
+ )
|
|
|
+ if profile["params_string"]:
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ " paramsStrings:",
|
|
|
+ f" - {profile['params_string']}",
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ if profile["context_length"]:
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ " contextLengths:",
|
|
|
+ f" - {profile['context_length']}",
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ lines.append(f" vision: {_bool_text(bool(capabilities['vision']))}")
|
|
|
+ lines.append(f" reasoning: {_bool_text(bool(capabilities['reasoning']))}")
|
|
|
+ lines.append(f" trainedForToolUse: {_bool_text(bool(capabilities['tools']))}")
|
|
|
+
|
|
|
|
|
|
- lines = _replace_base_block(lines, target_model_key)
|
|
|
+def _append_operation_fields(lines: list[str], profile: dict[str, object]) -> None:
|
|
|
+ defaults = profile["defaults"]
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ "config:",
|
|
|
+ " operation:",
|
|
|
+ " fields:",
|
|
|
+ " - key: llm.prediction.temperature",
|
|
|
+ f" value: {defaults['temperature']}",
|
|
|
+ " - key: llm.prediction.topKSampling",
|
|
|
+ f" value: {defaults['top_k']}",
|
|
|
+ " - key: llm.prediction.topPSampling",
|
|
|
+ " value:",
|
|
|
+ " checked: true",
|
|
|
+ f" value: {defaults['top_p']}",
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ if defaults["min_p_checked"] is not None:
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ " - key: llm.prediction.minPSampling",
|
|
|
+ " value:",
|
|
|
+ f" checked: {_bool_text(bool(defaults['min_p_checked']))}",
|
|
|
+ " value: 0",
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ if defaults["repeat_penalty_checked"] is not None:
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ " - key: llm.prediction.repeatPenalty",
|
|
|
+ " value:",
|
|
|
+ f" checked: {_bool_text(bool(defaults['repeat_penalty_checked']))}",
|
|
|
+ " value: 1.0",
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ if defaults["presence_penalty_checked"] is not None:
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ " - key: llm.prediction.llama.presencePenalty",
|
|
|
+ " value:",
|
|
|
+ f" checked: {_bool_text(bool(defaults['presence_penalty_checked']))}",
|
|
|
+ f" value: {defaults['presence_penalty']}",
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ if defaults["reasoning_parsing"] is not None:
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ " - key: llm.prediction.reasoning.parsing",
|
|
|
+ " value:",
|
|
|
+ f" enabled: {_bool_text(bool(defaults['reasoning_parsing']['enabled']))}",
|
|
|
+ f" startString: \"{defaults['reasoning_parsing']['startString']}\"",
|
|
|
+ f" endString: \"{defaults['reasoning_parsing']['endString']}\"",
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ if profile["chat_template"]:
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ " - key: llm.prediction.promptTemplate",
|
|
|
+ " value:",
|
|
|
+ " type: jinja",
|
|
|
+ " jinjaPromptTemplate:",
|
|
|
+ " template: |",
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ for line in str(profile["chat_template"]).splitlines():
|
|
|
+ lines.append(f" {line}")
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ " stopStrings: []",
|
|
|
+ ]
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _append_custom_fields(lines: list[str], profile: dict[str, object]) -> None:
|
|
|
+ if not profile["needs_enable_thinking"]:
|
|
|
+ return
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ "customFields:",
|
|
|
+ " - key: enableThinking",
|
|
|
+ " displayName: Enable Thinking",
|
|
|
+ " description: Controls whether the model will think before replying",
|
|
|
+ " type: boolean",
|
|
|
+ " defaultValue: true",
|
|
|
+ " effects:",
|
|
|
+ " - type: setJinjaVariable",
|
|
|
+ " variable: enable_thinking",
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ if profile["needs_preserve_thinking"]:
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ " - key: preserveThinking",
|
|
|
+ " displayName: Preserve Thinking",
|
|
|
+ " description: Preserve reasoning content in all prior assistant turns instead of only the most recent one",
|
|
|
+ " type: boolean",
|
|
|
+ " defaultValue: false",
|
|
|
+ " effects:",
|
|
|
+ " - type: setJinjaVariable",
|
|
|
+ " variable: preserve_thinking",
|
|
|
+ ]
|
|
|
+ )
|
|
|
|
|
|
- if capabilities:
|
|
|
- for key in ("reasoning", "vision", "tools"):
|
|
|
- lines = _upsert_metadata_bool(lines, key, capabilities.get(key))
|
|
|
|
|
|
+def build_virtual_model_yaml(
|
|
|
+ publisher: str,
|
|
|
+ slug: str,
|
|
|
+ model_key: str,
|
|
|
+ profile: dict[str, object],
|
|
|
+) -> str:
|
|
|
+ normalized_publisher = normalize_owner(publisher)
|
|
|
+ lines = [
|
|
|
+ "# model.yaml is an open standard for defining cross-platform, composable AI models",
|
|
|
+ "# Learn more at https://modelyaml.org",
|
|
|
+ f"model: {normalized_publisher}/{slug}",
|
|
|
+ ]
|
|
|
+ lines.extend(_build_base_block(model_key))
|
|
|
+ _append_metadata_overrides(lines, profile)
|
|
|
+ _append_operation_fields(lines, profile)
|
|
|
+ _append_custom_fields(lines, profile)
|
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
|
|
|
-def build_wrapper_manifest(
|
|
|
- official_manifest: dict,
|
|
|
- target_model_key: str,
|
|
|
- wrapper_owner: str,
|
|
|
- wrapper_name: str,
|
|
|
+def build_virtual_model_manifest(
|
|
|
+ publisher: str,
|
|
|
+ slug: str,
|
|
|
+ model_key: str,
|
|
|
) -> dict:
|
|
|
- manifest = {
|
|
|
+ normalized_publisher = normalize_owner(publisher)
|
|
|
+ return {
|
|
|
"type": "model",
|
|
|
- "owner": wrapper_owner,
|
|
|
- "name": wrapper_name,
|
|
|
+ "owner": normalized_publisher,
|
|
|
+ "name": slug,
|
|
|
"dependencies": [
|
|
|
{
|
|
|
"type": "model",
|
|
|
"purpose": "baseModel",
|
|
|
- "modelKeys": [target_model_key],
|
|
|
+ "modelKeys": [model_key],
|
|
|
+ "sources": [],
|
|
|
}
|
|
|
],
|
|
|
- "revision": int(official_manifest.get("revision", 1)),
|
|
|
+ "revision": 1,
|
|
|
}
|
|
|
|
|
|
- source_dep = (official_manifest.get("dependencies") or [{}])[0]
|
|
|
- if source_dep.get("sources"):
|
|
|
- manifest["dependencies"][0]["sources"] = source_dep["sources"]
|
|
|
- return manifest
|
|
|
|
|
|
-
|
|
|
-def build_readme(
|
|
|
- target_model_key: str,
|
|
|
- wrapper_owner: str,
|
|
|
- wrapper_name: str,
|
|
|
-) -> str:
|
|
|
+def build_readme(publisher: str, slug: str, model_key: str) -> str:
|
|
|
+ normalized_publisher = normalize_owner(publisher)
|
|
|
return (
|
|
|
- f"# {wrapper_owner}/{wrapper_name}\n\n"
|
|
|
- "这个目录由 `wrapper_generator.py` 自动生成,用于补全 LM Studio 可识别的模型元数据。\n\n"
|
|
|
- "## 源模型\n\n"
|
|
|
- f"- 本地模型 key: `{target_model_key}`\n\n"
|
|
|
- "## 说明\n\n"
|
|
|
- "- 不修改 GGUF 文件本身\n"
|
|
|
- "- 复用 LM Studio 官方模型包结构\n"
|
|
|
- "- 用于补全模型能力标注与基础依赖关系\n"
|
|
|
+ f"# {normalized_publisher}/{slug}\n\n"
|
|
|
+ "这个目录由 `wrapper_generator.py` 自动生成,用于把本地 concrete GGUF 提升为 LM Studio 可识别的 virtual model。\n\n"
|
|
|
+ "## Base Model\n\n"
|
|
|
+ f"- `{model_key}`\n"
|
|
|
)
|
|
|
|
|
|
|
|
|
-def build_wrapper_files(
|
|
|
- target_model_key: str,
|
|
|
- wrapper_owner: str,
|
|
|
- wrapper_name: str,
|
|
|
- official_model_yaml_text: str,
|
|
|
- official_manifest: dict,
|
|
|
- capabilities: dict[str, bool | None] | None = None,
|
|
|
-) -> dict:
|
|
|
- model_yaml = build_wrapper_model_yaml(
|
|
|
- official_model_yaml_text=official_model_yaml_text,
|
|
|
- target_model_key=target_model_key,
|
|
|
- wrapper_owner=wrapper_owner,
|
|
|
- wrapper_name=wrapper_name,
|
|
|
- capabilities=capabilities,
|
|
|
- )
|
|
|
- manifest = build_wrapper_manifest(
|
|
|
- official_manifest=official_manifest,
|
|
|
- target_model_key=target_model_key,
|
|
|
- wrapper_owner=wrapper_owner,
|
|
|
- wrapper_name=wrapper_name,
|
|
|
- )
|
|
|
- readme = build_readme(
|
|
|
- target_model_key=target_model_key,
|
|
|
- wrapper_owner=wrapper_owner,
|
|
|
- wrapper_name=wrapper_name,
|
|
|
- )
|
|
|
+def build_virtual_model_files(
|
|
|
+ publisher: str,
|
|
|
+ slug: str,
|
|
|
+ model_key: str,
|
|
|
+ profile: dict[str, object],
|
|
|
+) -> dict[str, str]:
|
|
|
return {
|
|
|
- "model.yaml": model_yaml,
|
|
|
- "manifest.json": json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
|
|
- "README.md": readme,
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-def generate_wrapper(
|
|
|
- output_dir: Path,
|
|
|
- target_model_key: str,
|
|
|
- wrapper_owner: str,
|
|
|
- wrapper_name: str,
|
|
|
- official_model_yaml_text: str,
|
|
|
- official_manifest: dict,
|
|
|
- dry_run: bool,
|
|
|
- capabilities: dict[str, bool | None] | None = None,
|
|
|
-) -> dict:
|
|
|
- files = build_wrapper_files(
|
|
|
- target_model_key=target_model_key,
|
|
|
- wrapper_owner=wrapper_owner,
|
|
|
- wrapper_name=wrapper_name,
|
|
|
- official_model_yaml_text=official_model_yaml_text,
|
|
|
- official_manifest=official_manifest,
|
|
|
- capabilities=capabilities,
|
|
|
- )
|
|
|
-
|
|
|
- summary = {
|
|
|
- "generated_at": datetime.now().isoformat(),
|
|
|
- "dry_run": dry_run,
|
|
|
- "output_dir": str(output_dir),
|
|
|
- "wrapper_model": f"{wrapper_owner}/{wrapper_name}",
|
|
|
- "target_model_key": target_model_key,
|
|
|
- "files": sorted(files.keys()),
|
|
|
+ "model.yaml": build_virtual_model_yaml(
|
|
|
+ publisher=publisher,
|
|
|
+ slug=slug,
|
|
|
+ model_key=model_key,
|
|
|
+ profile=profile,
|
|
|
+ ),
|
|
|
+ "manifest.json": json.dumps(
|
|
|
+ build_virtual_model_manifest(
|
|
|
+ publisher=publisher,
|
|
|
+ slug=slug,
|
|
|
+ model_key=model_key,
|
|
|
+ ),
|
|
|
+ ensure_ascii=False,
|
|
|
+ indent=2,
|
|
|
+ )
|
|
|
+ + "\n",
|
|
|
+ "README.md": build_readme(
|
|
|
+ publisher=publisher,
|
|
|
+ slug=slug,
|
|
|
+ model_key=model_key,
|
|
|
+ ),
|
|
|
}
|
|
|
|
|
|
- if dry_run:
|
|
|
- return summary
|
|
|
-
|
|
|
- output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
- for name, content in files.items():
|
|
|
- (output_dir / name).write_text(content, encoding="utf-8")
|
|
|
- (output_dir / "summary.json").write_text(
|
|
|
- json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
|
|
|
- encoding="utf-8",
|
|
|
- )
|
|
|
- return summary
|
|
|
-
|
|
|
-
|
|
|
-def load_inputs(model_yaml_path: Path, manifest_path: Path) -> tuple[str, dict]:
|
|
|
- return (
|
|
|
- model_yaml_path.read_text(encoding="utf-8"),
|
|
|
- json.loads(manifest_path.read_text(encoding="utf-8")),
|
|
|
- )
|
|
|
-
|
|
|
|
|
|
def scan_gguf_files(models_root: Path) -> list[Path]:
|
|
|
if not models_root.exists():
|
|
|
@@ -485,200 +627,148 @@ def scan_gguf_files(models_root: Path) -> list[Path]:
|
|
|
return sorted(models_root.rglob("*.gguf"))
|
|
|
|
|
|
|
|
|
-def _skip_reason_for_model(
|
|
|
- models_root: Path, gguf_path: Path, owner: str
|
|
|
-) -> str | None:
|
|
|
- try:
|
|
|
- relative_parts = {
|
|
|
- part.lower() for part in gguf_path.relative_to(models_root).parts
|
|
|
- }
|
|
|
- except ValueError:
|
|
|
- relative_parts = {part.lower() for part in gguf_path.parts}
|
|
|
- if "lmstudio-community" in relative_parts or owner.lower() == "lmstudio-community":
|
|
|
+def should_skip_gguf(models_root: Path, gguf_path: Path) -> str | None:
|
|
|
+ relative_parts = [part.lower() for part in gguf_path.relative_to(models_root).parts]
|
|
|
+ if "lmstudio-community" in relative_parts:
|
|
|
return "lmstudio-community"
|
|
|
+ if "mmproj" in gguf_path.name.lower():
|
|
|
+ return "mmproj"
|
|
|
return None
|
|
|
|
|
|
|
|
|
-def collect_model_job(
|
|
|
+def collect_virtual_model_job(
|
|
|
models_root: Path,
|
|
|
- hub_root: Path,
|
|
|
output_root: Path,
|
|
|
gguf_path: Path,
|
|
|
-) -> dict:
|
|
|
- metadata = read_gguf_metadata(gguf_path)
|
|
|
- owner, name = infer_model_identity(models_root, gguf_path, metadata)
|
|
|
- skip_reason = _skip_reason_for_model(models_root, gguf_path, owner)
|
|
|
+ cache_index: dict[str, dict[str, object]],
|
|
|
+) -> dict[str, object]:
|
|
|
+ skip_reason = should_skip_gguf(models_root, gguf_path)
|
|
|
+ raw_publisher = gguf_path.relative_to(models_root).parts[0]
|
|
|
+ publisher = normalize_owner(raw_publisher)
|
|
|
if skip_reason:
|
|
|
return {
|
|
|
"source_gguf": str(gguf_path),
|
|
|
- "owner": owner,
|
|
|
- "name": name,
|
|
|
+ "publisher": publisher,
|
|
|
"reason": skip_reason,
|
|
|
}
|
|
|
|
|
|
- template_dir = find_template_package(hub_root, metadata)
|
|
|
- if template_dir is None:
|
|
|
- return {
|
|
|
- "source_gguf": str(gguf_path),
|
|
|
- "owner": owner,
|
|
|
- "name": name,
|
|
|
- "reason": "template-not-found",
|
|
|
- }
|
|
|
-
|
|
|
- output_dir = output_root / owner / name
|
|
|
- if output_dir == template_dir:
|
|
|
- return {
|
|
|
- "source_gguf": str(gguf_path),
|
|
|
- "owner": owner,
|
|
|
- "name": name,
|
|
|
- "reason": "output-conflicts-with-template",
|
|
|
- }
|
|
|
-
|
|
|
- official_model_yaml_text, official_manifest = load_inputs(
|
|
|
- template_dir / "model.yaml",
|
|
|
- template_dir / "manifest.json",
|
|
|
+ metadata = resolve_gguf_metadata(gguf_path, cache_index)
|
|
|
+ directory_capabilities = collect_directory_capabilities(gguf_path)
|
|
|
+ profile = build_virtual_model_profile(
|
|
|
+ metadata,
|
|
|
+ has_sibling_mmproj=bool(directory_capabilities["has_sibling_mmproj"]),
|
|
|
)
|
|
|
- metadata_capabilities = extract_capabilities_from_metadata(metadata)
|
|
|
- template_capabilities = extract_capabilities_from_template(official_model_yaml_text)
|
|
|
- capabilities = merge_capabilities(metadata_capabilities, template_capabilities)
|
|
|
-
|
|
|
- model_key = infer_model_key(gguf_path)
|
|
|
+ slug = infer_virtual_model_slug(gguf_path.parent.name, metadata)
|
|
|
+ model_key = infer_model_key(models_root, gguf_path)
|
|
|
+ output_dir = output_root / publisher / slug
|
|
|
return {
|
|
|
"source_gguf": str(gguf_path),
|
|
|
- "owner": owner,
|
|
|
- "name": name,
|
|
|
+ "publisher": publisher,
|
|
|
+ "slug": slug,
|
|
|
"model_key": model_key,
|
|
|
- "template_path": str(template_dir),
|
|
|
"output_dir": str(output_dir),
|
|
|
- "will_overwrite": any(
|
|
|
- (output_dir / file_name).exists()
|
|
|
- for file_name in ("model.yaml", "manifest.json", "README.md")
|
|
|
- ),
|
|
|
- "capabilities": capabilities,
|
|
|
- "official_model_yaml_text": official_model_yaml_text,
|
|
|
- "official_manifest": official_manifest,
|
|
|
+ "capabilities": profile["capabilities"],
|
|
|
+ "profile": profile,
|
|
|
+ "will_overwrite": any((output_dir / name).exists() for name in ("model.yaml", "manifest.json", "README.md")),
|
|
|
}
|
|
|
|
|
|
|
|
|
-def generate_wrappers_for_models(
|
|
|
+def write_virtual_model(output_dir: Path, publisher: str, slug: str, model_key: str, profile: dict[str, object]) -> None:
|
|
|
+ files = build_virtual_model_files(
|
|
|
+ publisher=publisher,
|
|
|
+ slug=slug,
|
|
|
+ model_key=model_key,
|
|
|
+ profile=profile,
|
|
|
+ )
|
|
|
+ output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
+ for name, content in files.items():
|
|
|
+ (output_dir / name).write_text(content, encoding="utf-8")
|
|
|
+
|
|
|
+
|
|
|
+def delete_model_index_cache(cache_path: Path | None = None) -> tuple[bool, str | None]:
|
|
|
+ if cache_path is None:
|
|
|
+ cache_path = DEFAULT_MODEL_INDEX_CACHE
|
|
|
+ if not cache_path.exists():
|
|
|
+ return False, None
|
|
|
+ try:
|
|
|
+ cache_path.unlink()
|
|
|
+ except OSError as exc:
|
|
|
+ return False, str(exc)
|
|
|
+ return True, None
|
|
|
+
|
|
|
+
|
|
|
+def generate_virtual_models_for_directory(
|
|
|
models_root: Path,
|
|
|
- hub_root: Path,
|
|
|
output_root: Path,
|
|
|
dry_run: bool,
|
|
|
-) -> dict:
|
|
|
- generated = []
|
|
|
- skipped = []
|
|
|
+ cache_path: Path = DEFAULT_GGUF_METADATA_CACHE,
|
|
|
+ model_index_cache_path: Path | None = None,
|
|
|
+) -> dict[str, object]:
|
|
|
+ generated: list[dict[str, object]] = []
|
|
|
+ skipped: list[dict[str, object]] = []
|
|
|
+ cache_index = load_gguf_metadata_cache(cache_path)
|
|
|
|
|
|
for gguf_path in scan_gguf_files(models_root):
|
|
|
- job = collect_model_job(models_root, hub_root, output_root, gguf_path)
|
|
|
+ job = collect_virtual_model_job(models_root, output_root, gguf_path, cache_index)
|
|
|
if "reason" in job:
|
|
|
skipped.append(job)
|
|
|
continue
|
|
|
|
|
|
- output_dir = Path(job["output_dir"])
|
|
|
summary_item = {
|
|
|
"source_gguf": job["source_gguf"],
|
|
|
- "owner": job["owner"],
|
|
|
- "name": job["name"],
|
|
|
+ "publisher": job["publisher"],
|
|
|
+ "slug": job["slug"],
|
|
|
"model_key": job["model_key"],
|
|
|
- "template_path": job["template_path"],
|
|
|
"output_dir": job["output_dir"],
|
|
|
- "will_overwrite": job["will_overwrite"],
|
|
|
"capabilities": job["capabilities"],
|
|
|
+ "will_overwrite": job["will_overwrite"],
|
|
|
}
|
|
|
+ generated.append(summary_item)
|
|
|
|
|
|
if not dry_run:
|
|
|
- generate_wrapper(
|
|
|
- output_dir=output_dir,
|
|
|
- target_model_key=job["model_key"],
|
|
|
- wrapper_owner=job["owner"],
|
|
|
- wrapper_name=job["name"],
|
|
|
- official_model_yaml_text=job["official_model_yaml_text"],
|
|
|
- official_manifest=job["official_manifest"],
|
|
|
- dry_run=False,
|
|
|
- capabilities=job["capabilities"],
|
|
|
+ write_virtual_model(
|
|
|
+ output_dir=Path(job["output_dir"]),
|
|
|
+ publisher=str(job["publisher"]),
|
|
|
+ slug=str(job["slug"]),
|
|
|
+ model_key=str(job["model_key"]),
|
|
|
+ profile=dict(job["profile"]),
|
|
|
)
|
|
|
|
|
|
- generated.append(summary_item)
|
|
|
+ if model_index_cache_path is None:
|
|
|
+ model_index_cache_path = DEFAULT_MODEL_INDEX_CACHE
|
|
|
+
|
|
|
+ deleted_model_index_cache = False
|
|
|
+ model_index_cache_delete_error = None
|
|
|
+ if not dry_run and generated:
|
|
|
+ deleted_model_index_cache, model_index_cache_delete_error = delete_model_index_cache(
|
|
|
+ model_index_cache_path
|
|
|
+ )
|
|
|
|
|
|
return {
|
|
|
"generated_at": datetime.now().isoformat(),
|
|
|
"dry_run": dry_run,
|
|
|
"models_root": str(models_root),
|
|
|
- "hub_root": str(hub_root),
|
|
|
"output_root": str(output_root),
|
|
|
+ "deleted_model_index_cache": deleted_model_index_cache,
|
|
|
+ "model_index_cache_path": str(model_index_cache_path),
|
|
|
+ "model_index_cache_delete_error": model_index_cache_delete_error,
|
|
|
"generated": generated,
|
|
|
"skipped": skipped,
|
|
|
}
|
|
|
|
|
|
|
|
|
-def _requested_legacy_single_mode(args: argparse.Namespace) -> bool:
|
|
|
- return any(
|
|
|
- [
|
|
|
- args.target_model_key,
|
|
|
- args.wrapper_owner,
|
|
|
- args.wrapper_name,
|
|
|
- args.official_model_yaml,
|
|
|
- args.official_manifest,
|
|
|
- args.output_dir,
|
|
|
- ]
|
|
|
- )
|
|
|
-
|
|
|
-
|
|
|
def main() -> int:
|
|
|
parser = argparse.ArgumentParser()
|
|
|
parser.add_argument("--models-root", default=str(DEFAULT_MODELS_ROOT))
|
|
|
- parser.add_argument("--hub-root", default=str(DEFAULT_HUB_ROOT))
|
|
|
- parser.add_argument("--output-root", default=str(DEFAULT_HUB_ROOT))
|
|
|
+ parser.add_argument("--output-root", default=str(DEFAULT_OUTPUT_ROOT))
|
|
|
parser.add_argument("--dry-run", action="store_true")
|
|
|
-
|
|
|
- parser.add_argument("--target-model-key", default="")
|
|
|
- parser.add_argument("--wrapper-owner", default="")
|
|
|
- parser.add_argument("--wrapper-name", default="")
|
|
|
- parser.add_argument("--official-model-yaml", default="")
|
|
|
- parser.add_argument("--official-manifest", default="")
|
|
|
- parser.add_argument("--output-dir", default="")
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
- if _requested_legacy_single_mode(args):
|
|
|
- target_model_key = args.target_model_key or DEFAULT_TARGET_MODEL_KEY
|
|
|
- wrapper_owner = args.wrapper_owner or DEFAULT_WRAPPER_OWNER
|
|
|
- wrapper_name = args.wrapper_name or DEFAULT_WRAPPER_NAME
|
|
|
- official_model_yaml_path = Path(
|
|
|
- args.official_model_yaml or DEFAULT_OFFICIAL_MODEL_YAML
|
|
|
- )
|
|
|
- official_manifest_path = Path(
|
|
|
- args.official_manifest or DEFAULT_OFFICIAL_MANIFEST
|
|
|
- )
|
|
|
- official_model_yaml_text, official_manifest = load_inputs(
|
|
|
- official_model_yaml_path,
|
|
|
- official_manifest_path,
|
|
|
- )
|
|
|
- if args.output_dir:
|
|
|
- output_dir = Path(args.output_dir)
|
|
|
- else:
|
|
|
- output_dir = (
|
|
|
- Path(args.output_root or DEFAULT_OUTPUT_ROOT)
|
|
|
- / wrapper_owner
|
|
|
- / wrapper_name
|
|
|
- )
|
|
|
- summary = generate_wrapper(
|
|
|
- output_dir=output_dir,
|
|
|
- target_model_key=target_model_key,
|
|
|
- wrapper_owner=wrapper_owner,
|
|
|
- wrapper_name=wrapper_name,
|
|
|
- official_model_yaml_text=official_model_yaml_text,
|
|
|
- official_manifest=official_manifest,
|
|
|
- dry_run=args.dry_run,
|
|
|
- )
|
|
|
- else:
|
|
|
- summary = generate_wrappers_for_models(
|
|
|
- models_root=Path(args.models_root).expanduser(),
|
|
|
- hub_root=Path(args.hub_root).expanduser(),
|
|
|
- output_root=Path(args.output_root).expanduser(),
|
|
|
- dry_run=args.dry_run,
|
|
|
- )
|
|
|
-
|
|
|
+ summary = generate_virtual_models_for_directory(
|
|
|
+ models_root=Path(args.models_root).expanduser(),
|
|
|
+ output_root=Path(args.output_root).expanduser(),
|
|
|
+ dry_run=args.dry_run,
|
|
|
+ )
|
|
|
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
|
return 0
|
|
|
|