Bladeren bron

feat(lmstudio): 重构wrapper生成器为批量虚拟模型生成工具

- 将wrapper_generator.py重命名为虚拟模型生成器,支持批量处理本地GGUF模型
- 实现扫描指定本地模型根目录中的.gguf文件,读取元数据并生成LM Studio可识别的virtual model定义
- 添加自动检测模型能力(reasoning、tools、vision)功能,通过分析tokenizer chat template实现
- 支持缓存机制,优先使用内部缓存避免重复解析GGUF文件
- 实现跳过lmstudio-community和mmproj相关模型的逻辑
- 生成符合LM Studio规范的model.yaml和manifest.json文件
- 添加dry-run模式支持预览生成结果
- 更新文档说明新的使用方式和参数配置
kekeZack 1 maand geleden
bovenliggende
commit
c19d8f0fed

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

@@ -2,7 +2,7 @@
 
 ## 目标
 
-将 [tools/lmstudio/wrapper_generator.py](E:\opencode\Fix-HauHasuCS-ThinkingMode-Toggle\tools\lmstudio\wrapper_generator.py) 重构为**单文件批处理工具**,专用于 `LM Studio` 本地模型目录。
+将 [tools/lmstudio/wrapper_generator.py](../../tools/lmstudio/wrapper_generator.py) 重构为**单文件批处理工具**,专用于 `LM Studio` 本地模型目录。
 
 它需要扫描指定本地模型根目录中的 `.gguf`,读取可用元数据,跳过 `lmstudio-community` 相关模型,然后在 `~/.lmstudio/hub/models/<owner>/<name>/` 下生成或补全 `LM Studio` 可识别的普通模型包元数据,使界面和 API 更准确识别模型能力,例如:
 

+ 453 - 147
tests/test_lmstudio_wrapper_generator.py

@@ -3,14 +3,11 @@ import json
 import struct
 import tempfile
 import unittest
+from unittest import mock
 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)
@@ -26,67 +23,14 @@ class WrapperGeneratorTests(unittest.TestCase):
         if gen is None:
             self.fail("tools.lmstudio.wrapper_generator is not available")
 
-        self.official_model_yaml = """\
-model: qwen/qwen3.6-35b-a3b
-base:
-  - key: lmstudio-community/qwen3.6-35b-a3b-gguf
-metadataOverrides:
-  domain: llm
-  architectures:
-    - qwen35moe
-  compatibilityTypes:
-    - gguf
-  reasoning: true
-config:
-  operation:
-    fields:
-      - key: llm.prediction.promptTemplate
-        value:
-          type: jinja
-          jinjaPromptTemplate:
-            template: |
-              {%- if add_generation_prompt %}
-                  {%- if enable_thinking is defined and enable_thinking is false %}
-                      {{- '<think>\\n\\n</think>\\n\\n' }}
-                  {%- else %}
-                      {{- '<think>\\n' }}
-                  {%- endif %}
-              {%- endif %}
-customFields:
-  - key: enableThinking
-    effects:
-      - type: setJinjaVariable
-        variable: enable_thinking
-  - key: preserveThinking
-    effects:
-      - type: setJinjaVariable
-        variable: preserve_thinking
-"""
-        self.official_manifest = {
-            "type": "model",
-            "owner": "qwen",
-            "name": "qwen3.6-35b-a3b",
-            "dependencies": [
-                {
-                    "type": "model",
-                    "purpose": "baseModel",
-                    "modelKeys": ["lmstudio-community/qwen3.6-35b-a3b-gguf"],
-                }
-            ],
-            "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}")
@@ -106,87 +50,202 @@ customFields:
         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",
-            wrapper_owner="local",
-            wrapper_name="qwen3.6-35b-a3b-hauhaucs-aggressive",
-            official_model_yaml_text=self.official_model_yaml,
-            official_manifest=self.official_manifest,
-        )
+    def test_detect_capabilities_from_cached_metadata(self):
+        metadata = {
+            "general.architecture": "qwen35moe",
+            "general.name": "Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive",
+            "general.basename": "Qwen3.6-35B-A3B",
+            "tokenizer.chat_template": (
+                "{%- if tools %}<tools>{%- endif %}"
+                "{%- if add_generation_prompt %}"
+                "{%- if enable_thinking is defined and enable_thinking is false %}"
+                "{{- '<think>\\n\\n</think>\\n\\n' }}"
+                "{%- else %}{{- '<think>\\n' }}{%- endif %}{%- endif %}"
+                "<|vision_start|><|image_pad|><|vision_end|>"
+            ),
+            "qwen2.context_length": "262144",
+        }
 
-        self.assertIn("reasoning: true", result["model.yaml"])
-        self.assertIn("key: enableThinking", result["model.yaml"])
-        self.assertIn("key: preserveThinking", result["model.yaml"])
-        self.assertIn(
-            "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
-            result["model.yaml"],
-        )
+        profile = gen.build_virtual_model_profile(metadata)
 
-        manifest = json.loads(result["manifest.json"])
-        self.assertEqual(manifest["owner"], "local")
-        self.assertEqual(manifest["name"], "qwen3.6-35b-a3b-hauhaucs-aggressive")
-        self.assertEqual(
-            manifest["dependencies"][0]["modelKeys"],
-            ["qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive"],
-        )
+        self.assertEqual(profile["architecture"], "qwen35moe")
+        self.assertEqual(profile["basename_slug"], "qwen3.6-35b-a3b")
+        self.assertEqual(profile["params_string"], "35B")
+        self.assertEqual(profile["context_length"], 262144)
+        self.assertTrue(profile["capabilities"]["reasoning"])
+        self.assertTrue(profile["capabilities"]["vision"])
+        self.assertTrue(profile["capabilities"]["tools"])
+        self.assertTrue(profile["needs_enable_thinking"])
+        self.assertTrue(profile["needs_preserve_thinking"])
+
+    def test_prefers_internal_cache_before_touching_gguf(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+            gguf_path = root / "models" / "publisher-a" / "Fancy-Model-GGUF" / "model.gguf"
+            self._write_gguf_metadata(
+                gguf_path,
+                {
+                    "general.architecture": "qwen35",
+                    "general.name": "Wrong Fallback Name",
+                    "general.basename": "Wrong Fallback Name",
+                    "tokenizer.chat_template": "",
+                    "qwen2.context_length": "4096",
+                },
+            )
+            cache_path = root / ".internal" / "gguf-metadata-cache.json"
+            cache_path.parent.mkdir(parents=True, exist_ok=True)
+            cache_payload = {
+                "json": {
+                    "map": [
+                        [
+                            gguf_path.resolve().as_posix(),
+                            {
+                                "mtimeMs": gguf_path.stat().st_mtime * 1000,
+                                "metadata": {
+                                    "arch": "qwen35moe",
+                                    "name": "Cached Fancy Model",
+                                    "chatTemplate": "<tools>{{ '<think>\\n' }}",
+                                    "parameters": "35B-A3B",
+                                    "contextLength": 262144,
+                                },
+                            },
+                        ]
+                    ]
+                }
+            }
+            cache_path.write_text(json.dumps(cache_payload, ensure_ascii=False), encoding="utf-8")
+
+            with mock.patch.object(gen, "read_gguf_metadata_streaming", side_effect=AssertionError("stream parser should not run on cache hit")):
+                metadata = gen.resolve_gguf_metadata(
+                    gguf_path=gguf_path,
+                    cache_index=gen.load_gguf_metadata_cache(cache_path),
+                )
+
+            self.assertEqual(metadata["general.architecture"], "qwen35moe")
+            self.assertEqual(metadata["general.name"], "Cached Fancy Model")
+            self.assertEqual(metadata["general.parameter_count_label"], "35B-A3B")
+            self.assertEqual(metadata["general.context_length"], "262144")
 
-    def test_dry_run_does_not_write_output(self):
+    def test_stream_parser_is_used_when_cache_missing(self):
         with tempfile.TemporaryDirectory() as tmpdir:
-            output_dir = Path(tmpdir) / "wrapper"
-            summary = gen.generate_wrapper(
-                output_dir=output_dir,
-                target_model_key="qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
-                wrapper_owner="local",
-                wrapper_name="qwen3.6-35b-a3b-hauhaucs-aggressive",
-                official_model_yaml_text=self.official_model_yaml,
-                official_manifest=self.official_manifest,
+            gguf_path = Path(tmpdir) / "model.gguf"
+            self._write_gguf_metadata(
+                gguf_path,
+                {
+                    "general.architecture": "gemma4",
+                    "general.name": "Streaming Model",
+                    "general.basename": "Streaming Model",
+                    "tokenizer.chat_template": "{{ '<think>\\n' }}",
+                    "qwen2.context_length": "8192",
+                },
+            )
+
+            with mock.patch.object(Path, "read_bytes", side_effect=AssertionError("full file read should not be used")):
+                metadata = gen.resolve_gguf_metadata(
+                    gguf_path=gguf_path,
+                    cache_index={},
+                )
+
+            self.assertEqual(metadata["general.architecture"], "gemma4")
+            self.assertEqual(metadata["general.name"], "Streaming Model")
+
+    def test_skips_mmproj_when_filename_contains_mmproj_not_only_prefix(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+            models_root = root / "models"
+            output_root = root / "hub" / "models"
+            gguf_path = (
+                models_root
+                / "publisher-a"
+                / "Vision-Model-GGUF"
+                / "Vision-Model-it.mmproj-Q8_0.gguf"
+            )
+            self._write_gguf_metadata(
+                gguf_path,
+                {
+                    "general.architecture": "qwen3vl",
+                    "general.name": "Vision Model",
+                    "general.basename": "Vision Model",
+                    "tokenizer.chat_template": "<|vision_start|><|image_pad|><|vision_end|>",
+                },
+            )
+
+            summary = gen.generate_virtual_models_for_directory(
+                models_root=models_root,
+                output_root=output_root,
                 dry_run=True,
             )
 
-            self.assertFalse(output_dir.exists())
-            self.assertTrue(summary["dry_run"])
-            self.assertIn("model.yaml", summary["files"])
-            self.assertIn("manifest.json", summary["files"])
-            self.assertIn("README.md", summary["files"])
+            self.assertEqual(summary["generated"], [])
+            self.assertEqual(len(summary["skipped"]), 1)
+            self.assertEqual(summary["skipped"][0]["reason"], "mmproj")
 
-    def test_collect_model_jobs_skips_lmstudio_community_and_matches_template(self):
+    def test_sibling_mmproj_marks_model_as_vision_capable(self):
         with tempfile.TemporaryDirectory() as tmpdir:
             root = Path(tmpdir)
             models_root = root / "models"
-            hub_root = root / "hub" / "models"
-            output_root = root / "output"
+            output_root = root / "hub" / "models"
 
+            model_dir = models_root / "publisher-a" / "Gemma4-Model-GGUF"
             self._write_gguf_metadata(
-                models_root / "alice" / "qwen-local" / "model.gguf",
+                model_dir / "Gemma4-Model-Q4_K_P.gguf",
                 {
-                    "general.architecture": "qwen3",
-                    "general.name": "Qwen Local",
-                    "capabilities.reasoning": True,
-                    "capabilities.tools": True,
+                    "general.architecture": "gemma4",
+                    "general.name": "Gemma4 Model",
+                    "general.basename": "Gemma4 Model",
+                    "tokenizer.chat_template": "{% if enable_thinking is defined and enable_thinking is false %}{{ '' }}{% endif %}",
+                    "general.context_length": "262144",
                 },
             )
             self._write_gguf_metadata(
-                models_root / "lmstudio-community" / "official" / "model.gguf",
+                model_dir / "mmproj-Gemma4-Model-f16.gguf",
                 {
-                    "general.architecture": "qwen3",
-                    "general.name": "Skip Me",
+                    "general.architecture": "clip",
+                    "general.name": "Gemma4 mmproj",
+                    "general.basename": "Gemma4 mmproj",
+                    "tokenizer.chat_template": "",
                 },
             )
 
-            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"
+            summary = gen.generate_virtual_models_for_directory(
+                models_root=models_root,
+                output_root=output_root,
+                dry_run=True,
             )
-            (template_dir / "manifest.json").write_text(
-                json.dumps(self.official_manifest, ensure_ascii=False, indent=2) + "\n",
-                encoding="utf-8",
+
+            self.assertEqual(len(summary["generated"]), 1)
+            generated = summary["generated"][0]
+            self.assertTrue(generated["capabilities"]["vision"])
+
+    def test_dry_run_scans_models_root_and_skips_lmstudio_community(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+            models_root = root / "models"
+            output_root = root / "hub" / "models"
+
+            self._write_gguf_metadata(
+                models_root / "publisher-a" / "Fancy-Model-GGUF" / "model.gguf",
+                {
+                    "general.architecture": "qwen35",
+                    "general.name": "Fancy Model",
+                    "general.basename": "Fancy Model",
+                    "tokenizer.chat_template": "<tools>{% if enable_thinking is defined and enable_thinking is false %}{{ '<think>\\n\\n</think>\\n\\n' }}{% else %}{{ '<think>\\n' }}{% endif %}<|vision_start|><|image_pad|><|vision_end|>",
+                    "qwen2.context_length": "262144",
+                },
+            )
+            self._write_gguf_metadata(
+                models_root / "lmstudio-community" / "Qwen3.5-9B-GGUF" / "model.gguf",
+                {
+                    "general.architecture": "qwen35",
+                    "general.name": "Qwen3.5-9B",
+                    "general.basename": "Qwen3.5-9B",
+                    "tokenizer.chat_template": "{{ '<think>\\n' }}",
+                    "qwen2.context_length": "262144",
+                },
             )
 
-            summary = gen.generate_wrappers_for_models(
+            summary = gen.generate_virtual_models_for_directory(
                 models_root=models_root,
-                hub_root=hub_root,
                 output_root=output_root,
                 dry_run=True,
             )
@@ -196,66 +255,313 @@ customFields:
             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.assertEqual(generated["publisher"], "publisher-a")
+            self.assertEqual(generated["model_key"], "publisher-a/Fancy-Model-GGUF/model.gguf")
+            self.assertEqual(generated["output_dir"], str(output_root / "publisher-a" / "fancy-model"))
             self.assertTrue(generated["capabilities"]["reasoning"])
+            self.assertTrue(generated["capabilities"]["vision"])
             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):
+    def test_generate_virtual_model_writes_official_style_files(self):
         with tempfile.TemporaryDirectory() as tmpdir:
             root = Path(tmpdir)
             models_root = root / "models"
-            hub_root = root / "hub" / "models"
             output_root = root / "hub" / "models"
+            model_index_cache = root / ".internal" / "model-index-cache.json"
+            model_index_cache.parent.mkdir(parents=True, exist_ok=True)
+            model_index_cache.write_text("cached", encoding="utf-8")
 
             self._write_gguf_metadata(
-                models_root / "alice" / "qwen-local" / "model.gguf",
+                models_root / "publisher-b" / "Fancy-Moe-Model-GGUF" / "Fancy-Moe-Model-Q4_K_M.gguf",
                 {
-                    "general.architecture": "qwen3",
-                    "general.name": "Qwen Local",
-                    "capabilities.reasoning": False,
+                    "general.architecture": "qwen35moe",
+                    "general.name": "Fancy Moe Model",
+                    "general.basename": "Fancy Moe Model",
+                    "tokenizer.chat_template": (
+                        "{%- if tools %}<tools>{%- endif %}"
+                        "{%- if add_generation_prompt %}"
+                        "{%- if enable_thinking is defined and enable_thinking is false %}"
+                        "{{- '<think>\\n\\n</think>\\n\\n' }}"
+                        "{%- else %}{{- '<think>\\n' }}{%- endif %}{%- endif %}"
+                        "<|vision_start|><|image_pad|><|vision_end|>"
+                    ),
+                    "qwen2.context_length": "262144",
                 },
             )
 
-            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(
+            summary = gen.generate_virtual_models_for_directory(
                 models_root=models_root,
-                hub_root=hub_root,
                 output_root=output_root,
                 dry_run=False,
+                model_index_cache_path=model_index_cache,
             )
 
             self.assertEqual(len(summary["generated"]), 1)
-            output_dir = output_root / "alice" / "qwen-local"
+            output_dir = output_root / "publisher-b" / "fancy-moe-model"
             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")
+            manifest = json.loads((output_dir / "manifest.json").read_text(encoding="utf-8"))
+
+            self.assertIn("model: publisher-b/fancy-moe-model", model_yaml)
+            self.assertIn("base:", model_yaml)
+            self.assertIn("key: publisher-b/Fancy-Moe-Model-GGUF/Fancy-Moe-Model-Q4_K_M.gguf", model_yaml)
+            self.assertIn("sources:", model_yaml)
+            self.assertIn("reasoning: true", model_yaml)
+            self.assertIn("vision: true", model_yaml)
+            self.assertIn("trainedForToolUse: true", model_yaml)
+            self.assertIn("key: enableThinking", model_yaml)
+            self.assertIn("key: preserveThinking", model_yaml)
+
+            self.assertEqual(manifest["owner"], "publisher-b")
+            self.assertEqual(manifest["name"], "fancy-moe-model")
+            self.assertEqual(
+                manifest["dependencies"][0]["modelKeys"],
+                ["publisher-b/Fancy-Moe-Model-GGUF/Fancy-Moe-Model-Q4_K_M.gguf"],
+            )
+            self.assertIn("sources", manifest["dependencies"][0])
+            self.assertEqual(manifest["dependencies"][0]["sources"], [])
+
+    def test_dry_run_does_not_delete_model_index_cache(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+            models_root = root / "models"
+            output_root = root / "hub" / "models"
+            model_index_cache = root / ".internal" / "model-index-cache.json"
+            model_index_cache.parent.mkdir(parents=True, exist_ok=True)
+            model_index_cache.write_text("cached", encoding="utf-8")
+
+            self._write_gguf_metadata(
+                models_root / "publisher-a" / "Fancy-Model-GGUF" / "model.gguf",
+                {
+                    "general.architecture": "qwen35",
+                    "general.name": "Fancy Model",
+                    "general.basename": "Fancy Model",
+                    "tokenizer.chat_template": "{% if enable_thinking is defined and enable_thinking is false %}{{ '' }}{% endif %}",
+                    "qwen2.context_length": "262144",
+                },
+            )
+
+            summary = gen.generate_virtual_models_for_directory(
+                models_root=models_root,
+                output_root=output_root,
+                dry_run=True,
+                model_index_cache_path=model_index_cache,
+            )
+
+            self.assertFalse(summary["deleted_model_index_cache"])
+            self.assertTrue(model_index_cache.exists())
+
+    def test_generation_deletes_internal_model_index_cache_after_write(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+            models_root = root / "models"
+            output_root = root / "hub" / "models"
+            model_index_cache = root / ".internal" / "model-index-cache.json"
+            model_index_cache.parent.mkdir(parents=True, exist_ok=True)
+            model_index_cache.write_text("cached", encoding="utf-8")
+
+            self._write_gguf_metadata(
+                models_root / "publisher-a" / "Fancy-Model-GGUF" / "model.gguf",
+                {
+                    "general.architecture": "qwen35",
+                    "general.name": "Fancy Model",
+                    "general.basename": "Fancy Model",
+                    "tokenizer.chat_template": "{% if enable_thinking is defined and enable_thinking is false %}{{ '' }}{% endif %}",
+                    "qwen2.context_length": "262144",
+                },
+            )
+
+            summary = gen.generate_virtual_models_for_directory(
+                models_root=models_root,
+                output_root=output_root,
+                dry_run=False,
+                model_index_cache_path=model_index_cache,
+            )
+
+            self.assertTrue(summary["deleted_model_index_cache"])
+            self.assertEqual(summary["model_index_cache_path"], str(model_index_cache))
+            self.assertFalse(model_index_cache.exists())
+
+    def test_owner_is_normalized_for_lmstudio_manifest_and_model_id(self):
+        metadata = {
+            "general.architecture": "qwen35moe",
+            "general.name": "Fancy Moe Model",
+            "general.basename": "Fancy Moe Model",
+            "tokenizer.chat_template": (
+                "{% if enable_thinking is defined and enable_thinking is false %}"
+                "{{ '<think>\\n\\n</think>\\n\\n' }}"
+                "{% endif %}"
+            ),
+            "qwen2.context_length": "262144",
+        }
+
+        profile = gen.build_virtual_model_profile(metadata)
+        files = gen.build_virtual_model_files(
+            publisher="HauhauCS",
+            slug="fancy-moe-model",
+            model_key="HauhauCS/Fancy-Moe-Model-GGUF/Fancy-Moe-Model-Q4_K_M.gguf",
+            profile=profile,
+        )
+        manifest = json.loads(files["manifest.json"])
+
+        self.assertIn("model: hauhaucs/fancy-moe-model", files["model.yaml"])
+        self.assertEqual(manifest["owner"], "hauhaucs")
+        self.assertEqual(manifest["dependencies"][0]["sources"], [])
+
+    def test_collect_job_uses_normalized_owner_for_output_dir_and_model_id(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+            models_root = root / "models"
+            output_root = root / "hub" / "models"
+            gguf_path = (
+                models_root
+                / "HauhauCS"
+                / "Fancy-Moe-Model-GGUF"
+                / "Fancy-Moe-Model-Q4_K_M.gguf"
+            )
+            self._write_gguf_metadata(
+                gguf_path,
+                {
+                    "general.architecture": "qwen35moe",
+                    "general.name": "Fancy Moe Model",
+                    "general.basename": "Fancy Moe Model",
+                    "tokenizer.chat_template": (
+                        "{% if enable_thinking is defined and enable_thinking is false %}"
+                        "{{ '<think>\\n\\n</think>\\n\\n' }}"
+                        "{% endif %}"
+                    ),
+                    "qwen2.context_length": "262144",
+                },
             )
 
-            self.assertIn("model: alice/qwen-local", model_yaml)
-            self.assertIn('base: "model"', model_yaml)
+            job = gen.collect_virtual_model_job(
+                models_root=models_root,
+                output_root=output_root,
+                gguf_path=gguf_path,
+                cache_index={},
+            )
+
+            self.assertEqual(job["publisher"], "hauhaucs")
+            self.assertEqual(
+                job["output_dir"],
+                str(output_root / "hauhaucs" / "fancy-moe-model"),
+            )
+
+    def test_nothink_variant_disables_reasoning_and_custom_field(self):
+        metadata = {
+            "general.architecture": "qwen35",
+            "general.name": "Generic Model NoThink",
+            "general.basename": "Generic Model",
+            "tokenizer.chat_template": "<tools><|vision_start|><|image_pad|><|vision_end|>",
+            "qwen2.context_length": "262144",
+        }
+
+        profile = gen.build_virtual_model_profile(metadata)
+        files = gen.build_virtual_model_files(
+            publisher="publisher-c",
+            slug="generic-model-nothink",
+            model_key="publisher-c/Generic-Model-GGUF/Generic-Model-NoThink-Q4_K_M.gguf",
+            profile=profile,
+        )
+
+        self.assertIn("reasoning: false", files["model.yaml"])
+        self.assertNotIn("key: enableThinking", files["model.yaml"])
+        self.assertNotIn("key: preserveThinking", files["model.yaml"])
+
+    def test_nothink_directory_forces_reasoning_off_even_if_template_contains_think(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+            models_root = root / "models"
+            output_root = root / "hub" / "models"
+            model_index_cache = root / ".internal" / "model-index-cache.json"
+            model_index_cache.parent.mkdir(parents=True, exist_ok=True)
+            model_index_cache.write_text("cached", encoding="utf-8")
+            gguf_path = (
+                models_root
+                / "publisher-b"
+                / "Generic-Model-NoThink-GGUF"
+                / "Generic-Model-NoThink-Q4_K_M.gguf"
+            )
+            self._write_gguf_metadata(
+                gguf_path,
+                {
+                    "general.architecture": "qwen35",
+                    "general.name": "Generic Model NoThink",
+                    "general.basename": "Generic Model",
+                    "tokenizer.chat_template": "{{ '<think>\\n\\n</think>\\n\\n' }}<tools>",
+                    "qwen2.context_length": "262144",
+                },
+            )
+
+            summary = gen.generate_virtual_models_for_directory(
+                models_root=models_root,
+                output_root=output_root,
+                dry_run=False,
+                model_index_cache_path=model_index_cache,
+            )
+
+            self.assertEqual(len(summary["generated"]), 1)
+            output_dir = output_root / "publisher-b" / "generic-model-nothink"
+            model_yaml = (output_dir / "model.yaml").read_text(encoding="utf-8")
             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"])
+            self.assertNotIn("key: enableThinking", model_yaml)
+
+    def test_static_think_block_without_enable_thinking_is_not_reasoning_capable(self):
+        metadata = {
+            "general.architecture": "qwen35",
+            "general.name": "Static Think Model",
+            "general.basename": "Static Think Model",
+            "tokenizer.chat_template": "{{ '<think>\\n\\n</think>\\n\\n' }}<tools>",
+            "qwen2.context_length": "262144",
+        }
+
+        profile = gen.build_virtual_model_profile(metadata)
+
+        self.assertFalse(profile["capabilities"]["reasoning"])
+        self.assertFalse(profile["needs_enable_thinking"])
+
+    def test_gemma4_pipe_style_tool_tokens_are_detected(self):
+        metadata = {
+            "general.architecture": "gemma4",
+            "general.name": "Gemma4 Tool Model",
+            "general.basename": "Gemma4 Tool Model",
+            "tokenizer.chat_template": (
+                "{% if tools %}{{ '<|tool>' }}{% endif %}"
+                "{{ '<|tool_call>' }}"
+                "{{ '<|tool_response>' }}"
+            ),
+            "general.context_length": "262144",
+        }
+
+        profile = gen.build_virtual_model_profile(metadata)
+
+        self.assertTrue(profile["capabilities"]["tools"])
+
+    def test_profile_inference_uses_metadata_and_directory_augments_vision(self):
+        metadata = {
+            "general.architecture": "gemma4",
+            "general.name": "Augmented Vision Model",
+            "general.basename": "Augmented Vision Model",
+            "tokenizer.chat_template": "{% if enable_thinking is defined and enable_thinking is false %}{{ '' }}{% endif %}",
+            "general.context_length": "262144",
+        }
+
+        profile_without_mmproj = gen.build_virtual_model_profile(metadata)
+        profile_with_mmproj = gen.build_virtual_model_profile(
+            metadata,
+            has_sibling_mmproj=True,
+        )
+
+        self.assertFalse(profile_without_mmproj["capabilities"]["vision"])
+        self.assertTrue(profile_with_mmproj["capabilities"]["vision"])
+        self.assertEqual(
+            profile_without_mmproj["capabilities"]["tools"],
+            profile_with_mmproj["capabilities"]["tools"],
+        )
 
 
 if __name__ == "__main__":

+ 12 - 16
tools/lmstudio/README.md

@@ -22,22 +22,23 @@
 用途:
 
 - 扫描本地 `GGUF` 模型目录
-- 读取 `GGUF` 元数据并匹配 `LM Studio` 官方普通模型模板
-- 为本地模型生成或补全 `LM Studio` 可识别的 `model.yaml` / `manifest.json`
+- 读取非 `lmstudio-community` 的 concrete `GGUF` 元数据
+- 为这些 concrete model 生成 `LM Studio` 可识别的 virtual model 定义
 - 让 `LM Studio` 更准确识别模型是否支持 reasoning、tools、vision 等能力
 
 适合场景:
 
 - 你有一批本地 `GGUF`
 - 它们在 `LM Studio` 中能力标注不完整
-- 你希望直接向 `~/.lmstudio/hub/models/` 生成可识别的模型包元数据
+- 你希望直接向 `~/.lmstudio/hub/models/` 生成可识别的 virtual model 元数据
 
 默认行为:
 
 - 默认扫描 `~/.lmstudio/models`
-- 默认参考 `~/.lmstudio/hub/models` 中现有官方模型包
+- 默认输出到 `~/.lmstudio/hub/models/<publisher>/<slug>/`
 - 默认输出到 `~/.lmstudio/hub/models/<owner>/<name>/`
 - 自动跳过 `lmstudio-community`
+- 自动跳过 `mmproj-*.gguf`
 
 常用示例:
 
@@ -55,11 +56,6 @@ 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)
 
 用途:
@@ -103,13 +99,13 @@ python tools/lmstudio/wrapper_generator.py --models-root D:\Models\LMStudio
 
 ## 什么时候用哪个
 
-| 需求 | 用哪个 |
-| --- | --- |
-| 生成本地模型 wrapper | `wrapper_generator.py` |
-| 快速做一个 reasoning 翻译代理 | `openai_reasoning_proxy.py` |
-| 验证 LM Studio 原生与兼容层差异 | `probes/lmstudio_thinking_probe.py` |
-| 批量试 OpenAI reasoning 字段 | `probes/lmstudio_openai_reasoning_probe.py` |
-| 测 llama.cpp 路由器或兼容层 | `probes/llama_cpp_openai_probe.py` |
+| 需求                            | 用哪个                                        |
+| ------------------------------- | --------------------------------------------- |
+| 生成本地模型 wrapper            | `wrapper_generator.py`                      |
+| 快速做一个 reasoning 翻译代理   | `openai_reasoning_proxy.py`                 |
+| 验证 LM Studio 原生与兼容层差异 | `probes/lmstudio_thinking_probe.py`         |
+| 批量试 OpenAI reasoning 字段    | `probes/lmstudio_openai_reasoning_probe.py` |
+| 测 llama.cpp 路由器或兼容层     | `probes/llama_cpp_openai_probe.py`          |
 
 ## 和 services 的区别
 

+ 576 - 486
tools/lmstudio/wrapper_generator.py

@@ -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