test_lmstudio_wrapper_generator.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import importlib.util
  2. import json
  3. import struct
  4. import tempfile
  5. import unittest
  6. from pathlib import Path
  7. MODULE_PATH = (
  8. Path(__file__).resolve().parent.parent
  9. / "tools"
  10. / "lmstudio"
  11. / "wrapper_generator.py"
  12. )
  13. if MODULE_PATH.exists():
  14. spec = importlib.util.spec_from_file_location("wrapper_generator", MODULE_PATH)
  15. module = importlib.util.module_from_spec(spec)
  16. assert spec is not None and spec.loader is not None
  17. spec.loader.exec_module(module)
  18. gen = module
  19. else: # pragma: no cover
  20. gen = None
  21. class WrapperGeneratorTests(unittest.TestCase):
  22. def setUp(self):
  23. if gen is None:
  24. self.fail("tools.lmstudio.wrapper_generator is not available")
  25. self.official_model_yaml = """\
  26. model: qwen/qwen3.6-35b-a3b
  27. base:
  28. - key: lmstudio-community/qwen3.6-35b-a3b-gguf
  29. metadataOverrides:
  30. domain: llm
  31. architectures:
  32. - qwen35moe
  33. compatibilityTypes:
  34. - gguf
  35. reasoning: true
  36. config:
  37. operation:
  38. fields:
  39. - key: llm.prediction.promptTemplate
  40. value:
  41. type: jinja
  42. jinjaPromptTemplate:
  43. template: |
  44. {%- if add_generation_prompt %}
  45. {%- if enable_thinking is defined and enable_thinking is false %}
  46. {{- '<think>\\n\\n</think>\\n\\n' }}
  47. {%- else %}
  48. {{- '<think>\\n' }}
  49. {%- endif %}
  50. {%- endif %}
  51. customFields:
  52. - key: enableThinking
  53. effects:
  54. - type: setJinjaVariable
  55. variable: enable_thinking
  56. - key: preserveThinking
  57. effects:
  58. - type: setJinjaVariable
  59. variable: preserve_thinking
  60. """
  61. self.official_manifest = {
  62. "type": "model",
  63. "owner": "qwen",
  64. "name": "qwen3.6-35b-a3b",
  65. "dependencies": [
  66. {
  67. "type": "model",
  68. "purpose": "baseModel",
  69. "modelKeys": ["lmstudio-community/qwen3.6-35b-a3b-gguf"],
  70. }
  71. ],
  72. "revision": 1,
  73. }
  74. def _write_gguf_metadata(self, path: Path, metadata: dict[str, object]) -> None:
  75. type_string = 8
  76. type_bool = 7
  77. def write_string(value: str) -> bytes:
  78. raw = value.encode("utf-8")
  79. return struct.pack("<Q", len(raw)) + raw
  80. def write_value(value: object) -> tuple[int, bytes]:
  81. if isinstance(value, bool):
  82. return type_bool, struct.pack("<?", value)
  83. if isinstance(value, str):
  84. return type_string, write_string(value)
  85. raise TypeError(f"Unsupported GGUF test metadata value: {value!r}")
  86. payload = bytearray()
  87. payload.extend(b"GGUF")
  88. payload.extend(struct.pack("<I", 3))
  89. payload.extend(struct.pack("<Q", 0))
  90. payload.extend(struct.pack("<Q", len(metadata)))
  91. for key, value in metadata.items():
  92. value_type, raw_value = write_value(value)
  93. payload.extend(write_string(key))
  94. payload.extend(struct.pack("<I", value_type))
  95. payload.extend(raw_value)
  96. path.parent.mkdir(parents=True, exist_ok=True)
  97. path.write_bytes(payload)
  98. def test_build_wrapper_files_contains_reasoning_and_target_key(self):
  99. result = gen.build_wrapper_files(
  100. target_model_key="qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
  101. wrapper_owner="local",
  102. wrapper_name="qwen3.6-35b-a3b-hauhaucs-aggressive",
  103. official_model_yaml_text=self.official_model_yaml,
  104. official_manifest=self.official_manifest,
  105. )
  106. self.assertIn("reasoning: true", result["model.yaml"])
  107. self.assertIn("key: enableThinking", result["model.yaml"])
  108. self.assertIn("key: preserveThinking", result["model.yaml"])
  109. self.assertIn(
  110. "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
  111. result["model.yaml"],
  112. )
  113. manifest = json.loads(result["manifest.json"])
  114. self.assertEqual(manifest["owner"], "local")
  115. self.assertEqual(manifest["name"], "qwen3.6-35b-a3b-hauhaucs-aggressive")
  116. self.assertEqual(
  117. manifest["dependencies"][0]["modelKeys"],
  118. ["qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive"],
  119. )
  120. def test_dry_run_does_not_write_output(self):
  121. with tempfile.TemporaryDirectory() as tmpdir:
  122. output_dir = Path(tmpdir) / "wrapper"
  123. summary = gen.generate_wrapper(
  124. output_dir=output_dir,
  125. target_model_key="qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
  126. wrapper_owner="local",
  127. wrapper_name="qwen3.6-35b-a3b-hauhaucs-aggressive",
  128. official_model_yaml_text=self.official_model_yaml,
  129. official_manifest=self.official_manifest,
  130. dry_run=True,
  131. )
  132. self.assertFalse(output_dir.exists())
  133. self.assertTrue(summary["dry_run"])
  134. self.assertIn("model.yaml", summary["files"])
  135. self.assertIn("manifest.json", summary["files"])
  136. self.assertIn("README.md", summary["files"])
  137. def test_collect_model_jobs_skips_lmstudio_community_and_matches_template(self):
  138. with tempfile.TemporaryDirectory() as tmpdir:
  139. root = Path(tmpdir)
  140. models_root = root / "models"
  141. hub_root = root / "hub" / "models"
  142. output_root = root / "output"
  143. self._write_gguf_metadata(
  144. models_root / "alice" / "qwen-local" / "model.gguf",
  145. {
  146. "general.architecture": "qwen3",
  147. "general.name": "Qwen Local",
  148. "capabilities.reasoning": True,
  149. "capabilities.tools": True,
  150. },
  151. )
  152. self._write_gguf_metadata(
  153. models_root / "lmstudio-community" / "official" / "model.gguf",
  154. {
  155. "general.architecture": "qwen3",
  156. "general.name": "Skip Me",
  157. },
  158. )
  159. template_dir = hub_root / "qwen" / "qwen3.6-35b-a3b"
  160. template_dir.mkdir(parents=True, exist_ok=True)
  161. (template_dir / "model.yaml").write_text(
  162. self.official_model_yaml, encoding="utf-8"
  163. )
  164. (template_dir / "manifest.json").write_text(
  165. json.dumps(self.official_manifest, ensure_ascii=False, indent=2) + "\n",
  166. encoding="utf-8",
  167. )
  168. summary = gen.generate_wrappers_for_models(
  169. models_root=models_root,
  170. hub_root=hub_root,
  171. output_root=output_root,
  172. dry_run=True,
  173. )
  174. self.assertTrue(summary["dry_run"])
  175. self.assertEqual(len(summary["generated"]), 1)
  176. self.assertEqual(len(summary["skipped"]), 1)
  177. generated = summary["generated"][0]
  178. self.assertEqual(generated["owner"], "alice")
  179. self.assertEqual(generated["name"], "qwen-local")
  180. self.assertEqual(generated["model_key"], "model")
  181. self.assertIn("qwen3.6-35b-a3b", generated["template_path"])
  182. self.assertTrue(generated["capabilities"]["reasoning"])
  183. self.assertTrue(generated["capabilities"]["tools"])
  184. self.assertFalse((output_root / "alice" / "qwen-local").exists())
  185. skipped = summary["skipped"][0]
  186. self.assertEqual(skipped["reason"], "lmstudio-community")
  187. def test_generate_wrappers_writes_files_and_merges_capabilities(self):
  188. with tempfile.TemporaryDirectory() as tmpdir:
  189. root = Path(tmpdir)
  190. models_root = root / "models"
  191. hub_root = root / "hub" / "models"
  192. output_root = root / "hub" / "models"
  193. self._write_gguf_metadata(
  194. models_root / "alice" / "qwen-local" / "model.gguf",
  195. {
  196. "general.architecture": "qwen3",
  197. "general.name": "Qwen Local",
  198. "capabilities.reasoning": False,
  199. },
  200. )
  201. template_dir = hub_root / "qwen" / "qwen3.6-35b-a3b"
  202. template_dir.mkdir(parents=True, exist_ok=True)
  203. (template_dir / "model.yaml").write_text(
  204. self.official_model_yaml, encoding="utf-8"
  205. )
  206. (template_dir / "manifest.json").write_text(
  207. json.dumps(self.official_manifest, ensure_ascii=False, indent=2) + "\n",
  208. encoding="utf-8",
  209. )
  210. summary = gen.generate_wrappers_for_models(
  211. models_root=models_root,
  212. hub_root=hub_root,
  213. output_root=output_root,
  214. dry_run=False,
  215. )
  216. self.assertEqual(len(summary["generated"]), 1)
  217. output_dir = output_root / "alice" / "qwen-local"
  218. self.assertTrue(output_dir.exists())
  219. model_yaml = (output_dir / "model.yaml").read_text(encoding="utf-8")
  220. manifest = json.loads(
  221. (output_dir / "manifest.json").read_text(encoding="utf-8")
  222. )
  223. self.assertIn("model: alice/qwen-local", model_yaml)
  224. self.assertIn('base: "model"', model_yaml)
  225. self.assertIn("reasoning: false", model_yaml)
  226. self.assertIn("key: enableThinking", model_yaml)
  227. self.assertEqual(manifest["owner"], "alice")
  228. self.assertEqual(manifest["name"], "qwen-local")
  229. self.assertEqual(manifest["dependencies"][0]["modelKeys"], ["model"])
  230. if __name__ == "__main__":
  231. unittest.main()