Jelajahi Sumber

feat(lmstudio): 添加通用模型模板补全生成器功能

- 将 wrapper_generator.py 重构为单文件批处理工具,支持扫描本地模型目录中的 .gguf 文件
- 实现 GGUF 元数据读取和模型能力识别(reasoning、tools、vision)
- 添加模板匹配策略,基于架构家族自动匹配官方模型包作为参考模板
- 支持批量生成 model.yaml 和 manifest.json 文件,补全模型能力声明
- 新增 --dry-run 模式用于预览操作结果,支持显式指定模型根目录和输出目录
- 保留向后兼容的单模型生成模式,通过参数检测决定执行模式
- 添加完整的单元测试覆盖扫描、过滤、模板匹配和能力合并等核心功能
- 更新 README 文档说明新的使用方式和兼容模式
kekeZack 1 bulan lalu
induk
melakukan
ea8ed26b7b

+ 173 - 0
docs/specs/2026-06-09-lmstudio-generic-wrapper-generator-spec.md

@@ -0,0 +1,173 @@
+# LM Studio 通用模型模板补全生成器规格
+
+## 目标
+
+将 [tools/lmstudio/wrapper_generator.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\wrapper_generator.py) 重构为**单文件批处理工具**,专用于 `LM Studio` 本地模型目录。
+
+它需要扫描指定本地模型根目录中的 `.gguf`,读取可用元数据,跳过 `lmstudio-community` 相关模型,然后在 `~/.lmstudio/hub/models/<owner>/<name>/` 下生成或补全 `LM Studio` 可识别的普通模型包元数据,使界面和 API 更准确识别模型能力,例如:
+
+- 是否支持思考(reasoning)
+- 是否支持工具调用(tools)
+- 是否支持视觉(vision)
+
+## 关键判断
+
+本次工具不再局限于“复制一份官方 reasoning wrapper 并替换 base model key”。
+
+更接近的目标是:
+
+- 参考 `LM Studio` 官方普通模型包结构
+- 识别与本地 GGUF 最接近的官方模板
+- 用 GGUF 元数据优先、官方模板兜底的方式生成 `model.yaml` 与 `manifest.json`
+
+也就是说,`qwen/qwen3.6-35b-a3b` 这类官方模型包应被视为**模板参考样本**,不是固定默认输入;当前脚本中的:
+
+- `DEFAULT_OFFICIAL_MODEL_YAML`
+- `DEFAULT_OFFICIAL_MANIFEST`
+
+所体现的价值,是说明 `LM Studio` 官方普通模型包通常已经包含了能力声明、结构字段和模板组织方式。新工具应泛化这种思路,而不是继续绑定单一 Qwen 模型。
+
+## 范围
+
+- 仅支持 `LM Studio` 使用场景
+- 保持单文件实现
+- 输入扫描根目录支持显式指定,默认 `~/.lmstudio/models`
+- 模板与输出目录以 `~/.lmstudio/hub/models` 为核心
+- 直接写入 `~/.lmstudio/hub/models/<owner>/<name>/`
+- 支持 `--dry-run`
+- 不修改 GGUF 文件本身
+- 不覆盖或改写官方 `lmstudio-community` 模型包
+
+## 输入与命名
+
+### 输入参数
+
+- `--models-root`
+  - 本地模型根目录
+  - 默认 `~/.lmstudio/models`
+- `--hub-root`
+  - `LM Studio` Hub 模板根目录
+  - 默认 `~/.lmstudio/hub/models`
+- `--output-root`
+  - 输出根目录
+  - 默认 `~/.lmstudio/hub/models`
+- `--dry-run`
+  - 只输出汇总 JSON,不写文件
+
+### 模型命名
+
+输出目录中的 `<owner>/<name>` 采用以下规则:
+
+1. 优先从 GGUF 所在目录结构推断
+2. 若目录信息不足,再从 GGUF 元数据推断
+
+## 扫描与过滤
+
+工具需递归扫描 `--models-root` 下所有 `.gguf`。
+
+需要过滤或跳过:
+
+- 已明显属于 `lmstudio-community` 的模型来源
+- 无法推断出最基本模型身份的异常项
+- 无法匹配到任何合适官方模板的项
+
+未处理项应出现在 `--dry-run` 或最终汇总中,不能静默忽略。
+
+## 模板匹配策略
+
+模板匹配以 **GGUF 元数据中的架构/家族** 为主,例如:
+
+- `qwen`
+- `llama`
+- `mistral`
+- 其他可识别家族
+
+匹配逻辑应优先寻找 `~/.lmstudio/hub/models` 中最接近的**普通官方模型包**,而不是 reasoning wrapper。
+
+匹配结果至少需要提供:
+
+- 参考 `model.yaml`
+- 参考 `manifest.json`
+- 模板来源路径
+
+若匹配失败,应在结果中报告该模型未找到模板,不应凭空猜测完整模板内容。
+
+## 能力补全规则
+
+能力判断遵循以下优先级:
+
+1. **GGUF 元数据优先**
+2. **官方模板补全和兜底**
+
+重点补全的能力和结构包括:
+
+- `reasoning`
+- `vision`
+- `tools`
+- `architectures`
+- `compatibilityTypes`
+- `customFields`
+- 与能力相关的 `config.operation.fields`
+
+规则要求:
+
+- GGUF 已明确声明的能力,优先写入输出
+- GGUF 未声明但模板存在的能力,可沿用模板
+- 无法可靠判断的能力不应硬猜,应在汇总中标记为未确认
+
+## 输出文件
+
+每个识别成功的模型输出到:
+
+- `~/.lmstudio/hub/models/<owner>/<name>/model.yaml`
+- `~/.lmstudio/hub/models/<owner>/<name>/manifest.json`
+
+可选输出:
+
+- `README.md`
+- `summary.json`
+
+其中:
+
+- `model.yaml` 负责能力声明、元数据覆盖与模板配置
+- `manifest.json` 负责 `LM Studio` 模型包标识、依赖和修订信息
+
+## Dry Run
+
+`--dry-run` 模式不写入任何文件,只打印汇总 JSON。
+
+每条结果至少包含:
+
+- 源 GGUF 路径
+- 推断出的 `<owner>/<name>`
+- 匹配到的官方模板路径
+- 识别出的能力摘要
+- 目标输出目录
+- 是否会覆盖现有文件
+- 跳过原因或未匹配原因(如适用)
+
+## 验证要求
+
+最低自动验证要求:
+
+- 仍保留现有单模型生成测试的核心覆盖
+- 新增批量扫描测试
+- 新增模板匹配测试
+- 新增 `--dry-run` 不落盘测试
+- 新增过滤 `lmstudio-community` 的测试
+- 新增能力合并优先级测试
+
+手工验证要求:
+
+- 生成结果放入 `~/.lmstudio/hub/models` 后可被 `LM Studio` 识别
+- 在 UI 中模型能力标注更接近官方模型表现
+- 至少验证一个 reasoning 模型与一个非 reasoning 模型
+
+## 非目标
+
+本次不做以下事项:
+
+- 不修改 LM Studio 内部数据库或 `.internal` 状态
+- 不实现跨平台模型仓库通用模板系统
+- 不支持与 LM Studio 无关的其他推理前端
+- 不自动修补 GGUF 文件内容

+ 144 - 2
tests/test_lmstudio_wrapper_generator.py

@@ -1,11 +1,16 @@
 import importlib.util
 import json
+import struct
 import tempfile
 import unittest
 from pathlib import Path
 
-
-MODULE_PATH = Path(__file__).resolve().parent.parent / "tools" / "lmstudio" / "wrapper_generator.py"
+MODULE_PATH = (
+    Path(__file__).resolve().parent.parent
+    / "tools"
+    / "lmstudio"
+    / "wrapper_generator.py"
+)
 if MODULE_PATH.exists():
     spec = importlib.util.spec_from_file_location("wrapper_generator", MODULE_PATH)
     module = importlib.util.module_from_spec(spec)
@@ -71,6 +76,36 @@ customFields:
             "revision": 1,
         }
 
+    def _write_gguf_metadata(self, path: Path, metadata: dict[str, object]) -> None:
+        type_string = 8
+        type_bool = 7
+
+        def write_string(value: str) -> bytes:
+            raw = value.encode("utf-8")
+            return struct.pack("<Q", len(raw)) + raw
+
+        def write_value(value: object) -> tuple[int, bytes]:
+            if isinstance(value, bool):
+                return type_bool, struct.pack("<?", value)
+            if isinstance(value, str):
+                return type_string, write_string(value)
+            raise TypeError(f"Unsupported GGUF test metadata value: {value!r}")
+
+        payload = bytearray()
+        payload.extend(b"GGUF")
+        payload.extend(struct.pack("<I", 3))
+        payload.extend(struct.pack("<Q", 0))
+        payload.extend(struct.pack("<Q", len(metadata)))
+
+        for key, value in metadata.items():
+            value_type, raw_value = write_value(value)
+            payload.extend(write_string(key))
+            payload.extend(struct.pack("<I", value_type))
+            payload.extend(raw_value)
+
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_bytes(payload)
+
     def test_build_wrapper_files_contains_reasoning_and_target_key(self):
         result = gen.build_wrapper_files(
             target_model_key="qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
@@ -115,6 +150,113 @@ customFields:
             self.assertIn("manifest.json", summary["files"])
             self.assertIn("README.md", summary["files"])
 
+    def test_collect_model_jobs_skips_lmstudio_community_and_matches_template(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+            models_root = root / "models"
+            hub_root = root / "hub" / "models"
+            output_root = root / "output"
+
+            self._write_gguf_metadata(
+                models_root / "alice" / "qwen-local" / "model.gguf",
+                {
+                    "general.architecture": "qwen3",
+                    "general.name": "Qwen Local",
+                    "capabilities.reasoning": True,
+                    "capabilities.tools": True,
+                },
+            )
+            self._write_gguf_metadata(
+                models_root / "lmstudio-community" / "official" / "model.gguf",
+                {
+                    "general.architecture": "qwen3",
+                    "general.name": "Skip Me",
+                },
+            )
+
+            template_dir = hub_root / "qwen" / "qwen3.6-35b-a3b"
+            template_dir.mkdir(parents=True, exist_ok=True)
+            (template_dir / "model.yaml").write_text(
+                self.official_model_yaml, encoding="utf-8"
+            )
+            (template_dir / "manifest.json").write_text(
+                json.dumps(self.official_manifest, ensure_ascii=False, indent=2) + "\n",
+                encoding="utf-8",
+            )
+
+            summary = gen.generate_wrappers_for_models(
+                models_root=models_root,
+                hub_root=hub_root,
+                output_root=output_root,
+                dry_run=True,
+            )
+
+            self.assertTrue(summary["dry_run"])
+            self.assertEqual(len(summary["generated"]), 1)
+            self.assertEqual(len(summary["skipped"]), 1)
+
+            generated = summary["generated"][0]
+            self.assertEqual(generated["owner"], "alice")
+            self.assertEqual(generated["name"], "qwen-local")
+            self.assertEqual(generated["model_key"], "model")
+            self.assertIn("qwen3.6-35b-a3b", generated["template_path"])
+            self.assertTrue(generated["capabilities"]["reasoning"])
+            self.assertTrue(generated["capabilities"]["tools"])
+            self.assertFalse((output_root / "alice" / "qwen-local").exists())
+
+            skipped = summary["skipped"][0]
+            self.assertEqual(skipped["reason"], "lmstudio-community")
+
+    def test_generate_wrappers_writes_files_and_merges_capabilities(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+            models_root = root / "models"
+            hub_root = root / "hub" / "models"
+            output_root = root / "hub" / "models"
+
+            self._write_gguf_metadata(
+                models_root / "alice" / "qwen-local" / "model.gguf",
+                {
+                    "general.architecture": "qwen3",
+                    "general.name": "Qwen Local",
+                    "capabilities.reasoning": False,
+                },
+            )
+
+            template_dir = hub_root / "qwen" / "qwen3.6-35b-a3b"
+            template_dir.mkdir(parents=True, exist_ok=True)
+            (template_dir / "model.yaml").write_text(
+                self.official_model_yaml, encoding="utf-8"
+            )
+            (template_dir / "manifest.json").write_text(
+                json.dumps(self.official_manifest, ensure_ascii=False, indent=2) + "\n",
+                encoding="utf-8",
+            )
+
+            summary = gen.generate_wrappers_for_models(
+                models_root=models_root,
+                hub_root=hub_root,
+                output_root=output_root,
+                dry_run=False,
+            )
+
+            self.assertEqual(len(summary["generated"]), 1)
+            output_dir = output_root / "alice" / "qwen-local"
+            self.assertTrue(output_dir.exists())
+
+            model_yaml = (output_dir / "model.yaml").read_text(encoding="utf-8")
+            manifest = json.loads(
+                (output_dir / "manifest.json").read_text(encoding="utf-8")
+            )
+
+            self.assertIn("model: alice/qwen-local", model_yaml)
+            self.assertIn('base: "model"', model_yaml)
+            self.assertIn("reasoning: false", model_yaml)
+            self.assertIn("key: enableThinking", model_yaml)
+            self.assertEqual(manifest["owner"], "alice")
+            self.assertEqual(manifest["name"], "qwen-local")
+            self.assertEqual(manifest["dependencies"][0]["modelKeys"], ["model"])
+
 
 if __name__ == "__main__":
     unittest.main()

+ 35 - 6
tools/lmstudio/README.md

@@ -21,15 +21,44 @@
 
 用途:
 
-- 基于官方 `Qwen3.6` 的 `model.yaml` / `manifest.json`
-- 生成一个面向本地模型的 `LM Studio wrapper`
-- 让本地模型具备 reasoning 元数据、UI reasoning 开关、原生 `/api/v1/chat` reasoning 能力
+- 扫描本地 `GGUF` 模型目录
+- 读取 `GGUF` 元数据并匹配 `LM Studio` 官方普通模型模板
+- 为本地模型生成或补全 `LM Studio` 可识别的 `model.yaml` / `manifest.json`
+- 让 `LM Studio` 更准确识别模型是否支持 reasoning、tools、vision 等能力
 
 适合场景:
 
-- 你有一个本地 GGUF
-- 模型本身支持 thinking 开关
-- 但 `LM Studio` 没把它识别成 reasoning-capable model
+- 你有一批本地 `GGUF`
+- 它们在 `LM Studio` 中能力标注不完整
+- 你希望直接向 `~/.lmstudio/hub/models/` 生成可识别的模型包元数据
+
+默认行为:
+
+- 默认扫描 `~/.lmstudio/models`
+- 默认参考 `~/.lmstudio/hub/models` 中现有官方模型包
+- 默认输出到 `~/.lmstudio/hub/models/<owner>/<name>/`
+- 自动跳过 `lmstudio-community`
+
+常用示例:
+
+```bash
+python tools/lmstudio/wrapper_generator.py --dry-run
+```
+
+- 只扫描并打印汇总 JSON
+- 不写入任何文件
+
+```bash
+python tools/lmstudio/wrapper_generator.py --models-root D:\Models\LMStudio
+```
+
+- 扫描指定本地模型根目录
+- 按默认规则直接写入 `~/.lmstudio/hub/models`
+
+兼容模式:
+
+- 仍保留旧的单模型生成方式
+- 只要显式传入 `--target-model-key`、`--official-model-yaml` 等旧参数,就按单模型模板生成逻辑执行
 
 ### [openai_reasoning_proxy.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\openai_reasoning_proxy.py)
 

+ 536 - 71
tools/lmstudio/wrapper_generator.py

@@ -1,62 +1,358 @@
 #!/usr/bin/env python3
 import argparse
 import json
+import struct
 from datetime import datetime
 from pathlib import Path
 
-
-DEFAULT_OFFICIAL_MODEL_YAML = Path(
-    r"G:\LMStudio\.lmstudio\hub\models\qwen\qwen3.6-35b-a3b\model.yaml"
-)
-DEFAULT_OFFICIAL_MANIFEST = Path(
-    r"G:\LMStudio\.lmstudio\hub\models\qwen\qwen3.6-35b-a3b\manifest.json"
-)
+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"
+
+
+GGUF_VALUE_TYPES = {
+    0: ("<B", 1),
+    1: ("<b", 1),
+    2: ("<H", 2),
+    3: ("<h", 2),
+    4: ("<I", 4),
+    5: ("<i", 4),
+    6: ("<f", 4),
+    7: ("<?", 1),
+    10: ("<Q", 8),
+    11: ("<q", 8),
+    12: ("<d", 8),
+}
+
+
+def _bool_text(value: bool) -> str:
+    return "true" if value else "false"
+
+
+def _read_struct(data: bytes, offset: int, fmt: str) -> tuple[object, int]:
+    size = struct.calcsize(fmt)
+    if offset + size > len(data):
+        raise ValueError("Unexpected end of GGUF metadata")
+    value = struct.unpack_from(fmt, data, offset)[0]
+    return value, offset + size
+
+
+def _read_gguf_string(data: bytes, offset: int) -> tuple[str, int]:
+    length, offset = _read_struct(data, offset, "<Q")
+    end = offset + int(length)
+    if end > len(data):
+        raise ValueError("Invalid GGUF string length")
+    return data[offset:end].decode("utf-8", errors="replace"), end
+
+
+def _read_gguf_value(data: bytes, offset: int, value_type: int) -> tuple[object, int]:
+    if value_type == 8:
+        return _read_gguf_string(data, offset)
+    if value_type == 9:
+        nested_type, offset = _read_struct(data, offset, "<I")
+        count, offset = _read_struct(data, offset, "<Q")
+        values = []
+        for _ in range(int(count)):
+            item, offset = _read_gguf_value(data, offset, int(nested_type))
+            values.append(item)
+        return values, offset
+    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(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}")
+
+    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")
+
+    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 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 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]
+
+    if not owner:
+        owner = "local"
+
+    if not name:
+        name = str(metadata.get("general.name") or gguf_path.stem)
+
+    owner = owner.strip().replace("\\", "-").replace("/", "-")
+    name = name.strip().replace("\\", "-").replace("/", "-")
+    return owner, name
+
+
+def infer_model_key(gguf_path: Path) -> str:
+    return gguf_path.stem
+
+
+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])
+    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
+
+    vision = _metadata_bool(
+        metadata,
+        [
+            "capabilities.vision",
+            "general.vision",
+            "vision",
+        ],
+    )
+    tools = _metadata_bool(
+        metadata,
+        [
+            "capabilities.tools",
+            "general.tools",
+            "tools",
+        ],
+    )
+    return {
+        "reasoning": reasoning,
+        "vision": vision,
+        "tools": tools,
+    }
 
 
-def build_wrapper_model_yaml(
-    official_model_yaml_text: str,
-    target_model_key: str,
-    wrapper_owner: str,
-    wrapper_name: str,
-) -> str:
-    lines = official_model_yaml_text.splitlines()
-    output = []
-    replaced_model = False
-    inserted_base = False
-    skipped_original_base = False
+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
 
-    for line in lines:
-        stripped = line.strip()
+    vision = True if "vision: true" in lowered else None
+    tools = True if "tools: true" in lowered else None
+    return {
+        "reasoning": reasoning,
+        "vision": vision,
+        "tools": tools,
+    }
 
-        if not replaced_model and stripped.startswith("model: "):
-            output.append(f"model: {wrapper_owner}/{wrapper_name}")
-            replaced_model = True
+
+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 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
 
-        if not inserted_base and stripped.startswith("base:"):
-            output.append(f'base: "{target_model_key}"')
-            inserted_base = True
-            skipped_original_base = True
+        owner = relative.parts[0].lower()
+        name = relative.parts[-1].lower()
+        if owner == "lmstudio-community":
             continue
 
-        if skipped_original_base:
-            if line.startswith("metadataOverrides:"):
-                skipped_original_base = False
-                output.append(line)
+        score = 0
+        if owner == family:
+            score += 100
+        elif family in owner:
+            score += 70
+        if family in name:
+            score += 40
+
+        if score <= 0:
             continue
+        if best_match is None or score > best_match[0]:
+            best_match = (score, package_dir)
 
-        output.append(line)
+    if best_match is None:
+        return None
+    return best_match[1]
 
-    if not replaced_model:
-        raise ValueError("Official model.yaml is missing a model declaration")
-    if not inserted_base:
+
+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 _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 _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 _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 = [
+            "metadataOverrides:",
+            f"  {key}: {_bool_text(value)}",
+        ]
+        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}"
+
+    lines = _replace_base_block(lines, target_model_key)
 
-    return "\n".join(output).rstrip() + "\n"
+    if capabilities:
+        for key in ("reasoning", "vision", "tools"):
+            lines = _upsert_metadata_bool(lines, key, capabilities.get(key))
+
+    return "\n".join(lines).rstrip() + "\n"
 
 
 def build_wrapper_manifest(
@@ -92,17 +388,13 @@ def build_readme(
 ) -> str:
     return (
         f"# {wrapper_owner}/{wrapper_name}\n\n"
-        "This wrapper exposes LM Studio reasoning metadata for a local HauhauCS GGUF.\n\n"
-        "## Source Model\n\n"
-        f"- Local model key: `{target_model_key}`\n\n"
-        "## Intent\n\n"
-        "- Preserve the original HauhauCS weights unchanged\n"
-        "- Reuse the official Qwen3.6 reasoning-capable model wrapper behavior\n"
-        "- Keep the wrapper portable and separate from LM Studio `.internal` state\n\n"
-        "## Next Steps\n\n"
-        "- Place or link this wrapper into an LM Studio-recognized local model package location\n"
-        "- Reload LM Studio model metadata\n"
-        "- Validate the wrapper via UI reasoning toggle and `/api/v1/chat`\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"
     )
 
 
@@ -112,12 +404,14 @@ def build_wrapper_files(
     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,
@@ -145,6 +439,7 @@ def generate_wrapper(
     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,
@@ -152,6 +447,7 @@ def generate_wrapper(
         wrapper_name=wrapper_name,
         official_model_yaml_text=official_model_yaml_text,
         official_manifest=official_manifest,
+        capabilities=capabilities,
     )
 
     summary = {
@@ -183,37 +479,206 @@ def load_inputs(model_yaml_path: Path, manifest_path: Path) -> tuple[str, dict]:
     )
 
 
+def scan_gguf_files(models_root: Path) -> list[Path]:
+    if not models_root.exists():
+        return []
+    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":
+        return "lmstudio-community"
+    return None
+
+
+def collect_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)
+    if skip_reason:
+        return {
+            "source_gguf": str(gguf_path),
+            "owner": owner,
+            "name": name,
+            "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_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)
+    return {
+        "source_gguf": str(gguf_path),
+        "owner": owner,
+        "name": name,
+        "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,
+    }
+
+
+def generate_wrappers_for_models(
+    models_root: Path,
+    hub_root: Path,
+    output_root: Path,
+    dry_run: bool,
+) -> dict:
+    generated = []
+    skipped = []
+
+    for gguf_path in scan_gguf_files(models_root):
+        job = collect_model_job(models_root, hub_root, output_root, gguf_path)
+        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"],
+            "model_key": job["model_key"],
+            "template_path": job["template_path"],
+            "output_dir": job["output_dir"],
+            "will_overwrite": job["will_overwrite"],
+            "capabilities": job["capabilities"],
+        }
+
+        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"],
+            )
+
+        generated.append(summary_item)
+
+    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),
+        "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("--target-model-key", default=DEFAULT_TARGET_MODEL_KEY)
-    parser.add_argument("--wrapper-owner", default=DEFAULT_WRAPPER_OWNER)
-    parser.add_argument("--wrapper-name", default=DEFAULT_WRAPPER_NAME)
-    parser.add_argument("--official-model-yaml", default=str(DEFAULT_OFFICIAL_MODEL_YAML))
-    parser.add_argument("--official-manifest", default=str(DEFAULT_OFFICIAL_MANIFEST))
-    parser.add_argument("--output-dir", default="")
-    parser.add_argument("--output-root", default=str(DEFAULT_OUTPUT_ROOT))
+    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("--dry-run", action="store_true")
-    args = parser.parse_args()
 
-    official_model_yaml_text, official_manifest = load_inputs(
-        Path(args.official_model_yaml),
-        Path(args.official_manifest),
-    )
+    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 args.output_dir:
-        output_dir = Path(args.output_dir)
+    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:
-        output_dir = Path(args.output_root) / args.wrapper_owner / args.wrapper_name
+        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_wrapper(
-        output_dir=output_dir,
-        target_model_key=args.target_model_key,
-        wrapper_owner=args.wrapper_owner,
-        wrapper_name=args.wrapper_name,
-        official_model_yaml_text=official_model_yaml_text,
-        official_manifest=official_manifest,
-        dry_run=args.dry_run,
-    )
     print(json.dumps(summary, ensure_ascii=False, indent=2))
     return 0