ltcai 11.1.0 → 11.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/README.md +47 -54
  2. package/docs/CHANGELOG.md +94 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  8. package/docs/ONBOARDING.md +1 -1
  9. package/docs/OPERATIONS.md +6 -2
  10. package/docs/PERMISSION_MODE.md +1 -1
  11. package/docs/TRUST_MODEL.md +1 -1
  12. package/docs/WHY_LATTICE.md +1 -1
  13. package/docs/architecture.md +6 -2
  14. package/docs/kg-schema.md +2 -2
  15. package/docs/v11.3.0_PLAN.md +202 -0
  16. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/gates.py +125 -0
  19. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  20. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  21. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  22. package/lattice_brain/graph/_kg_common/text.py +479 -0
  23. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  24. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  25. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  26. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  27. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  28. package/lattice_brain/graph/fusion.py +35 -4
  29. package/lattice_brain/graph/projection/__init__.py +42 -0
  30. package/lattice_brain/graph/projection/curation.py +500 -0
  31. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +81 -485
  32. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  33. package/lattice_brain/graph/retrieval/context.py +197 -0
  34. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  35. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  36. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  37. package/lattice_brain/graph/retrieval/signals.py +95 -0
  38. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  39. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  40. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  41. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  42. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  43. package/lattice_brain/graph/schema.py +9 -0
  44. package/lattice_brain/graph/store.py +9 -0
  45. package/lattice_brain/graph/vector_index/selector.py +32 -2
  46. package/lattice_brain/ingestion/__init__.py +130 -0
  47. package/lattice_brain/ingestion/_contract.py +90 -0
  48. package/lattice_brain/ingestion/constants.py +127 -0
  49. package/lattice_brain/ingestion/folder_scan.py +57 -0
  50. package/lattice_brain/ingestion/folders.py +258 -0
  51. package/lattice_brain/ingestion/hashing.py +26 -0
  52. package/lattice_brain/ingestion/jobs_api.py +107 -0
  53. package/lattice_brain/ingestion/models.py +80 -0
  54. package/lattice_brain/ingestion/pipeline.py +486 -0
  55. package/lattice_brain/ingestion/quality.py +209 -0
  56. package/lattice_brain/ingestion/routing.py +295 -0
  57. package/lattice_brain/multimodal/__init__.py +164 -0
  58. package/lattice_brain/multimodal/audio.py +77 -0
  59. package/lattice_brain/multimodal/common.py +118 -0
  60. package/lattice_brain/{multimodal.py → multimodal/images.py} +22 -262
  61. package/lattice_brain/multimodal/ports.py +169 -0
  62. package/lattice_brain/multimodal/video.py +410 -0
  63. package/lattice_brain/portability/__init__.py +90 -0
  64. package/lattice_brain/portability/_contract.py +42 -0
  65. package/lattice_brain/portability/backups.py +338 -0
  66. package/lattice_brain/portability/bundles.py +136 -0
  67. package/lattice_brain/portability/constants.py +93 -0
  68. package/lattice_brain/portability/fsops.py +138 -0
  69. package/lattice_brain/portability/service.py +41 -0
  70. package/lattice_brain/portability/sharing.py +714 -0
  71. package/lattice_brain/runtime/__init__.py +1 -1
  72. package/lattice_brain/runtime/multi_agent.py +1 -1
  73. package/lattice_brain/sealed_box.py +244 -0
  74. package/lattice_brain/synthesis.py +24 -1
  75. package/latticeai/__init__.py +1 -1
  76. package/latticeai/api/brain_intelligence.py +4 -0
  77. package/latticeai/api/chat.py +11 -0
  78. package/latticeai/api/chat_helpers.py +16 -3
  79. package/latticeai/api/chat_hybrid.py +32 -1
  80. package/latticeai/api/chronicle.py +63 -0
  81. package/latticeai/api/features.py +70 -0
  82. package/latticeai/api/local_files.py +102 -0
  83. package/latticeai/api/portability.py +39 -4
  84. package/latticeai/api/review_queue.py +126 -0
  85. package/latticeai/api/search.py +16 -2
  86. package/latticeai/core/agent/__init__.py +93 -0
  87. package/latticeai/core/agent/_contract.py +79 -0
  88. package/latticeai/core/agent/context.py +57 -0
  89. package/latticeai/core/agent/deps.py +125 -0
  90. package/latticeai/core/agent/execution.py +622 -0
  91. package/latticeai/core/agent/planning.py +145 -0
  92. package/latticeai/core/agent/recovery.py +157 -0
  93. package/latticeai/core/agent/runtime.py +210 -0
  94. package/latticeai/core/agent/verification.py +231 -0
  95. package/latticeai/core/config.py +4 -1
  96. package/latticeai/core/context_builder.py +6 -3
  97. package/latticeai/core/embedding_providers/__init__.py +151 -0
  98. package/latticeai/core/embedding_providers/base.py +199 -0
  99. package/latticeai/core/embedding_providers/captions.py +162 -0
  100. package/latticeai/core/embedding_providers/profiles.py +126 -0
  101. package/latticeai/core/embedding_providers/text.py +350 -0
  102. package/latticeai/core/embedding_providers/vision.py +352 -0
  103. package/latticeai/core/file_generation/__init__.py +115 -0
  104. package/latticeai/core/file_generation/bundles.py +76 -0
  105. package/latticeai/core/file_generation/extraction.py +154 -0
  106. package/latticeai/core/file_generation/inference.py +235 -0
  107. package/latticeai/core/file_generation/orchestration.py +152 -0
  108. package/latticeai/core/file_generation/prompting.py +117 -0
  109. package/latticeai/core/file_generation/repair.py +114 -0
  110. package/latticeai/core/file_generation/sanitize.py +61 -0
  111. package/latticeai/core/file_generation/validation.py +201 -0
  112. package/latticeai/core/legacy_compatibility.py +1 -1
  113. package/latticeai/core/marketplace.py +1 -1
  114. package/latticeai/core/messages.py +152 -0
  115. package/latticeai/core/model_compat.py +73 -2
  116. package/latticeai/core/workspace_os_constants.py +1 -1
  117. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  118. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  119. package/latticeai/integrations/telegram_bot/config.py +86 -0
  120. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  121. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  122. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  123. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  124. package/latticeai/models/model_providers.py +12 -4
  125. package/latticeai/models/router/__init__.py +88 -0
  126. package/latticeai/models/router/_contract.py +66 -0
  127. package/latticeai/models/router/branding.py +56 -0
  128. package/latticeai/models/router/catalog.py +69 -0
  129. package/latticeai/models/router/documents.py +199 -0
  130. package/latticeai/models/router/errors.py +37 -0
  131. package/latticeai/models/router/generation.py +258 -0
  132. package/latticeai/models/router/loading.py +291 -0
  133. package/latticeai/models/router/local_models.py +85 -0
  134. package/latticeai/models/router/registry.py +147 -0
  135. package/latticeai/runtime/build_phases/__init__.py +82 -0
  136. package/latticeai/runtime/build_phases/features.py +407 -0
  137. package/latticeai/runtime/build_phases/foundation.py +555 -0
  138. package/latticeai/runtime/build_phases/web.py +492 -0
  139. package/latticeai/runtime/chat_wiring.py +4 -0
  140. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  141. package/latticeai/runtime/router_registration.py +11 -0
  142. package/latticeai/runtime/runtime_context.py +1 -0
  143. package/latticeai/services/app_context.py +8 -0
  144. package/latticeai/services/architecture_readiness.py +48 -19
  145. package/latticeai/services/automation_intelligence.py +22 -2
  146. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  147. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  148. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  149. package/latticeai/services/brain_intelligence/constants.py +47 -0
  150. package/latticeai/services/brain_intelligence/digest.py +258 -0
  151. package/latticeai/services/brain_intelligence/health.py +331 -0
  152. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  153. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  154. package/latticeai/services/brain_intelligence/service.py +48 -0
  155. package/latticeai/services/chronicle.py +557 -0
  156. package/latticeai/services/command_center.py +10 -4
  157. package/latticeai/services/feature_toggles.py +502 -0
  158. package/latticeai/services/folder_watch.py +122 -1
  159. package/latticeai/services/hybrid_chat.py +56 -5
  160. package/latticeai/services/interop_bridges.py +978 -0
  161. package/latticeai/services/memory_service/__init__.py +52 -0
  162. package/latticeai/services/memory_service/_contract.py +100 -0
  163. package/latticeai/services/memory_service/brief.py +431 -0
  164. package/latticeai/services/memory_service/constants.py +57 -0
  165. package/latticeai/services/memory_service/maintenance.py +138 -0
  166. package/latticeai/services/memory_service/manager.py +186 -0
  167. package/latticeai/services/memory_service/proof.py +136 -0
  168. package/latticeai/services/memory_service/recall.py +225 -0
  169. package/latticeai/services/memory_service/service.py +48 -0
  170. package/latticeai/services/memory_service/stores.py +110 -0
  171. package/latticeai/services/model_capability_registry.py +434 -261
  172. package/latticeai/services/model_catalog.py +95 -61
  173. package/latticeai/services/model_recommendation.py +18 -11
  174. package/latticeai/services/model_runtime/__init__.py +322 -0
  175. package/latticeai/services/model_runtime/cloud.py +87 -0
  176. package/latticeai/services/model_runtime/download.py +282 -0
  177. package/latticeai/services/model_runtime/engines.py +341 -0
  178. package/latticeai/services/model_runtime/loading.py +178 -0
  179. package/latticeai/services/model_runtime/service.py +129 -0
  180. package/latticeai/services/model_runtime/state.py +131 -0
  181. package/latticeai/services/model_runtime/status.py +255 -0
  182. package/latticeai/services/multimodal_ports.py +26 -1
  183. package/latticeai/services/obsidian_bridge.py +16 -25
  184. package/latticeai/services/product_readiness.py +15 -7
  185. package/latticeai/services/search_service.py +149 -2
  186. package/latticeai/services/tool_dispatch.py +4 -0
  187. package/latticeai/setup/auto_setup.py +27 -30
  188. package/latticeai/setup/wizard/__init__.py +126 -0
  189. package/latticeai/setup/wizard/catalog.py +172 -0
  190. package/latticeai/setup/wizard/detect.py +323 -0
  191. package/latticeai/setup/wizard/install.py +348 -0
  192. package/latticeai/setup/wizard/paths.py +168 -0
  193. package/latticeai/setup/wizard/plans.py +74 -0
  194. package/latticeai/setup/wizard/recommend.py +320 -0
  195. package/package.json +6 -2
  196. package/scripts/bump_version.py +14 -0
  197. package/scripts/capture_release_evidence.mjs +33 -21
  198. package/scripts/check_current_release_docs.mjs +1 -1
  199. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  200. package/scripts/check_max_file_lines.mjs +102 -0
  201. package/scripts/check_release_evidence_bound.mjs +30 -15
  202. package/scripts/check_screenshot_pixel_delta.py +34 -4
  203. package/scripts/check_server_i18n.mjs +2 -0
  204. package/scripts/generate_rust_parity_fixtures.py +562 -0
  205. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  206. package/scripts/release_screen_claims.json +44 -2
  207. package/scripts/verify_hf_model_registry.py +253 -218
  208. package/src-tauri/Cargo.lock +361 -3
  209. package/src-tauri/Cargo.toml +6 -1
  210. package/src-tauri/src/backend.rs +349 -0
  211. package/src-tauri/src/folder.rs +33 -0
  212. package/src-tauri/src/main.rs +97 -399
  213. package/src-tauri/tauri.conf.json +1 -1
  214. package/static/app/asset-manifest.json +41 -37
  215. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  216. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  217. package/static/app/assets/{Brain-CzCsI1mi.js → Brain-C1HBN0Wf.js} +2 -2
  218. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  219. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  220. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  221. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  222. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  223. package/static/app/assets/Library-DwO3yZST.js +1 -0
  224. package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-Jn1GK0-S.js} +1 -1
  225. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  226. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  227. package/static/app/assets/System-DW8F-2xL.js +1 -0
  228. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  229. package/static/app/assets/{bot-4BvN07ux.js → bot-IM_E_Y12.js} +1 -1
  230. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  231. package/static/app/assets/{button-CDjtnAoU.js → button-COwyqfHM.js} +1 -1
  232. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  233. package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-DEM4A1Y5.js} +1 -1
  234. package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-C9djDuLd.js} +1 -1
  235. package/static/app/assets/{cpu-BIlWInHf.js → cpu-DFdo1gw-.js} +1 -1
  236. package/static/app/assets/{download-BtjXfL3z.js → download-SnJL6oqk.js} +1 -1
  237. package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-CqZeDkjE.js} +1 -1
  238. package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-j1jJXYYf.js} +1 -1
  239. package/static/app/assets/{index-vtEfYvQY.css → index-BLPb5lmE.css} +1 -1
  240. package/static/app/assets/index-_u5iUHDr.js +10 -0
  241. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  242. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  243. package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-BsyLxtao.js} +1 -1
  244. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  245. package/static/app/assets/search-BybIWPNd.js +1 -0
  246. package/static/app/assets/{share-2-D5zg_0fY.js → share-2-CVtZ_ewX.js} +1 -1
  247. package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-CBi2GNWM.js} +1 -1
  248. package/static/app/assets/{textarea-nEVIweKY.js → textarea-DNMpB5ih.js} +1 -1
  249. package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-C83t3GXF.js} +1 -1
  250. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  251. package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-Dcp1OChy.js} +1 -1
  252. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  253. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  254. package/static/app/index.html +4 -4
  255. package/static/sw.js +1 -1
  256. package/lattice_brain/graph/_kg_common.py +0 -1331
  257. package/lattice_brain/graph/discovery_index.py +0 -1141
  258. package/lattice_brain/graph/retrieval.py +0 -1120
  259. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  260. package/lattice_brain/ingestion.py +0 -1377
  261. package/lattice_brain/portability.py +0 -1210
  262. package/latticeai/core/agent.py +0 -1412
  263. package/latticeai/core/embedding_providers.py +0 -1196
  264. package/latticeai/core/file_generation.py +0 -1047
  265. package/latticeai/integrations/telegram_bot.py +0 -1390
  266. package/latticeai/models/router.py +0 -1007
  267. package/latticeai/runtime/build_phases.py +0 -1422
  268. package/latticeai/services/brain_intelligence.py +0 -967
  269. package/latticeai/services/memory_service.py +0 -1177
  270. package/latticeai/services/model_runtime.py +0 -1281
  271. package/latticeai/setup/wizard.py +0 -1277
  272. package/static/app/assets/Act-D0HWqtn0.js +0 -1
  273. package/static/app/assets/AdminConsole-D-QDW-A4.js +0 -1
  274. package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
  275. package/static/app/assets/BrainSignals-2dHQNkns.js +0 -1
  276. package/static/app/assets/Capture-CT8v1StE.js +0 -1
  277. package/static/app/assets/CommandPalette-DoLXC2KH.js +0 -1
  278. package/static/app/assets/Library-DDoxFE5c.js +0 -1
  279. package/static/app/assets/ProductFlow-DOYf7JIs.js +0 -1
  280. package/static/app/assets/ReviewCard-COQsqidK.js +0 -3
  281. package/static/app/assets/System-BRllvYXd.js +0 -1
  282. package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
  283. package/static/app/assets/brain-uMb_5hnO.js +0 -1
  284. package/static/app/assets/index-0AvoEBzJ.js +0 -10
  285. package/static/app/assets/input-B_5ZJ9oy.js +0 -1
  286. package/static/app/assets/primitives-CVwew78r.js +0 -1
  287. package/static/app/assets/search-DkhnOKZt.js +0 -1
  288. package/static/app/assets/utils-DcDMoZIe.js +0 -4
  289. package/static/app/assets/workspace-LtRRSKTf.js +0 -1
@@ -0,0 +1,282 @@
1
+ """Hugging Face model presence, download, and download progress.
2
+
3
+ Answers one question — "are the weights actually on this disk?" — and, when
4
+ they are not and the caller has consent, fetches them while emitting a progress
5
+ payload per file. The readiness check is deliberately format-aware: a GGUF
6
+ repo, an MLX 4-bit repo and a vLLM bf16 repo each prove completeness
7
+ differently.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ import logging
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from latticeai.core.quiet import quiet
19
+ from latticeai.models.router import hf_cache_model_dir, hf_model_dir
20
+ from latticeai.services.model_errors import ModelRuntimeError
21
+
22
+
23
+ def hf_model_ready(repo_id: str, provider: str = "local_mlx") -> bool:
24
+ model_dir = hf_model_dir(repo_id)
25
+ if provider in {"local_mlx", "vllm"} and (not model_dir.exists() or not model_dir.is_dir()):
26
+ hf_cache_repo = Path.home() / ".cache" / "huggingface" / "hub" / f"models--{repo_id.replace('/', '--')}"
27
+ if hf_cache_repo.exists() and any(hf_cache_repo.glob("snapshots/*")):
28
+ if provider == "vllm":
29
+ return True
30
+ return hf_cache_model_dir(repo_id) is not None
31
+ return False
32
+ if not model_dir.exists() or not model_dir.is_dir():
33
+ return False
34
+ if provider == "llamacpp":
35
+ return any(model_dir.rglob("*.gguf"))
36
+ has_config = (model_dir / "config.json").exists()
37
+ has_weights = any(model_dir.glob("*.safetensors")) or any(model_dir.glob("*.bin"))
38
+ has_tokenizer = (
39
+ (model_dir / "tokenizer.json").exists()
40
+ or (model_dir / "tokenizer.model").exists()
41
+ or (model_dir / "tokenizer_config.json").exists()
42
+ )
43
+ return has_config and has_weights and has_tokenizer
44
+
45
+
46
+ def model_download_progress_payload(
47
+ stage: str,
48
+ message: str,
49
+ *,
50
+ percent: Optional[float] = None,
51
+ detail: Optional[str] = None,
52
+ downloaded_bytes: Optional[int] = None,
53
+ total_bytes: Optional[int] = None,
54
+ eta_seconds: Optional[float] = None,
55
+ file: Optional[str] = None,
56
+ indeterminate: bool = False,
57
+ ) -> Dict[str, Any]:
58
+ payload: Dict[str, Any] = {
59
+ "stage": stage,
60
+ "message": message,
61
+ "indeterminate": indeterminate,
62
+ "ts": time.time(),
63
+ }
64
+ if percent is not None:
65
+ payload["percent"] = max(0, min(100, round(float(percent), 1)))
66
+ if detail:
67
+ payload["detail"] = detail
68
+ if downloaded_bytes is not None:
69
+ payload["downloaded_bytes"] = max(0, int(downloaded_bytes))
70
+ if total_bytes is not None:
71
+ payload["total_bytes"] = max(0, int(total_bytes))
72
+ if eta_seconds is not None:
73
+ payload["eta_seconds"] = max(0, round(float(eta_seconds)))
74
+ if file:
75
+ payload["file"] = file
76
+ return payload
77
+
78
+
79
+ def estimate_eta_seconds(started_at: float, percent: Optional[float]) -> Optional[float]:
80
+ if percent is None or percent <= 0 or percent >= 100:
81
+ return None
82
+ elapsed = max(0.0, time.time() - started_at)
83
+ return elapsed * (100.0 - percent) / percent
84
+
85
+
86
+ def hf_repo_files_with_sizes(repo_id: str) -> List[Dict[str, Any]]:
87
+ from huggingface_hub import HfApi
88
+
89
+ api = HfApi()
90
+ try:
91
+ info = api.model_info(repo_id, files_metadata=True)
92
+ files = []
93
+ for sibling in getattr(info, "siblings", []) or []:
94
+ name = str(getattr(sibling, "rfilename", "") or "").strip()
95
+ if not name or name.endswith("/"):
96
+ continue
97
+ files.append({"name": name, "size": int(getattr(sibling, "size", 0) or 0)})
98
+ if files:
99
+ return files
100
+ except TypeError:
101
+ quiet()
102
+ except Exception as e:
103
+ logging.warning("huggingface model_info failed for %s: %s", repo_id, e)
104
+
105
+ return [{"name": str(name), "size": 0} for name in api.list_repo_files(repo_id) if str(name).strip()]
106
+
107
+
108
+ def download_hf_model(
109
+ repo_id: str,
110
+ provider: str = "local_mlx",
111
+ progress_emit=None,
112
+ ) -> Dict[str, Any]:
113
+ if importlib.util.find_spec("huggingface_hub") is None:
114
+ raise ModelRuntimeError(status_code=400, detail="huggingface_hub가 없습니다. 먼저 MLX runtime 설치를 진행해 주세요.")
115
+
116
+ target_dir = hf_model_dir(repo_id)
117
+ if hf_model_ready(repo_id, provider):
118
+ cached_dir = hf_cache_model_dir(repo_id) if provider == "local_mlx" else None
119
+ resolved_dir = cached_dir or target_dir
120
+ if progress_emit:
121
+ progress_emit(model_download_progress_payload(
122
+ "download",
123
+ "이미 다운로드된 모델을 확인했습니다.",
124
+ percent=100,
125
+ downloaded_bytes=0,
126
+ total_bytes=0,
127
+ eta_seconds=0,
128
+ ))
129
+ return {"model": repo_id, "path": str(resolved_dir), "cached": True}
130
+
131
+ target_dir.mkdir(parents=True, exist_ok=True)
132
+ try:
133
+ from huggingface_hub import hf_hub_download
134
+
135
+ started_at = time.time()
136
+ all_files = hf_repo_files_with_sizes(repo_id)
137
+ if provider == "llamacpp":
138
+ ggufs = sorted(
139
+ [item for item in all_files if str(item["name"]).lower().endswith(".gguf")],
140
+ key=lambda item: str(item["name"]),
141
+ )
142
+ if not ggufs:
143
+ raise RuntimeError("GGUF 파일을 찾지 못했습니다.")
144
+ preference = ("q4_k_m", "q4_0", "q4_k_s", "q3_k_m", "q2_k")
145
+ selected_files = [
146
+ next(
147
+ (item for pref in preference for item in ggufs if pref in str(item["name"]).lower()),
148
+ ggufs[0],
149
+ )
150
+ ]
151
+ else:
152
+ selected_files = all_files
153
+
154
+ total_bytes = sum(int(item.get("size") or 0) for item in selected_files) or None
155
+ downloaded_bytes = 0
156
+ total_files = max(1, len(selected_files))
157
+ if progress_emit:
158
+ progress_emit(model_download_progress_payload(
159
+ "download",
160
+ "모델 파일 정보를 확인했습니다.",
161
+ percent=0,
162
+ downloaded_bytes=0,
163
+ total_bytes=total_bytes,
164
+ indeterminate=total_bytes is None,
165
+ ))
166
+
167
+ for index, item in enumerate(selected_files, start=1):
168
+ filename = str(item["name"])
169
+ size = int(item.get("size") or 0)
170
+ tqdm_class = None
171
+ if progress_emit:
172
+ current_percent = (
173
+ (downloaded_bytes / total_bytes) * 100 if total_bytes else ((index - 1) / total_files) * 100
174
+ )
175
+ progress_emit(model_download_progress_payload(
176
+ "download",
177
+ "모델 다운로드 중입니다.",
178
+ percent=current_percent,
179
+ detail=filename,
180
+ downloaded_bytes=downloaded_bytes,
181
+ total_bytes=total_bytes,
182
+ eta_seconds=estimate_eta_seconds(started_at, current_percent),
183
+ file=filename,
184
+ indeterminate=total_bytes is None and total_files <= 1,
185
+ ))
186
+ try:
187
+ from tqdm.auto import tqdm as base_tqdm
188
+
189
+ downloaded_before = downloaded_bytes
190
+ last_emit = {"at": 0.0, "percent": -1.0}
191
+
192
+ def emit_byte_progress(
193
+ done_bytes: float,
194
+ # Bound per iteration: this callback outlives the loop
195
+ # body when a download runs long, and late binding
196
+ # would report every file's progress against the last
197
+ # file's offsets.
198
+ downloaded_before: int = downloaded_before,
199
+ size: Any = size,
200
+ index: int = index,
201
+ last_emit: dict = last_emit,
202
+ filename: str = filename,
203
+ ) -> None:
204
+ done = max(0, int(done_bytes or 0))
205
+ if total_bytes:
206
+ aggregate = min(total_bytes, downloaded_before + done)
207
+ percent = (aggregate / total_bytes) * 100
208
+ else:
209
+ file_total = size or done
210
+ file_ratio = min(1.0, done / file_total) if file_total else 0.0
211
+ aggregate = downloaded_before + done
212
+ percent = ((index - 1) + file_ratio) / total_files * 100
213
+ now = time.time()
214
+ if percent < 100 and now - last_emit["at"] < 0.5 and percent - last_emit["percent"] < 0.3:
215
+ return
216
+ last_emit["at"] = now
217
+ last_emit["percent"] = percent
218
+ progress_emit(model_download_progress_payload(
219
+ "download",
220
+ "모델 다운로드 중입니다.",
221
+ percent=percent,
222
+ detail=filename,
223
+ downloaded_bytes=aggregate,
224
+ total_bytes=total_bytes,
225
+ eta_seconds=estimate_eta_seconds(started_at, percent),
226
+ file=filename,
227
+ indeterminate=total_bytes is None and total_files <= 1,
228
+ ))
229
+
230
+ class ProgressTqdm(base_tqdm):
231
+ def update(self, n=1):
232
+ result = super().update(n)
233
+ emit_byte_progress(float(getattr(self, "n", 0) or 0))
234
+ return result
235
+
236
+ tqdm_class = ProgressTqdm
237
+ except Exception:
238
+ tqdm_class = None
239
+ local_path = hf_hub_download(
240
+ repo_id=repo_id,
241
+ filename=filename,
242
+ local_dir=str(target_dir),
243
+ tqdm_class=tqdm_class,
244
+ )
245
+ if size <= 0:
246
+ try:
247
+ size = Path(local_path).stat().st_size
248
+ except OSError:
249
+ size = 0
250
+ downloaded_bytes += size
251
+ if progress_emit:
252
+ current_percent = (
253
+ (downloaded_bytes / total_bytes) * 100 if total_bytes else (index / total_files) * 100
254
+ )
255
+ progress_emit(model_download_progress_payload(
256
+ "download",
257
+ "모델 다운로드 중입니다.",
258
+ percent=current_percent,
259
+ detail=filename,
260
+ downloaded_bytes=downloaded_bytes,
261
+ total_bytes=total_bytes,
262
+ eta_seconds=estimate_eta_seconds(started_at, current_percent),
263
+ file=filename,
264
+ indeterminate=False,
265
+ ))
266
+
267
+ if progress_emit:
268
+ progress_emit(model_download_progress_payload(
269
+ "download",
270
+ "모델 다운로드가 완료되었습니다.",
271
+ percent=100,
272
+ downloaded_bytes=downloaded_bytes,
273
+ total_bytes=total_bytes or downloaded_bytes,
274
+ eta_seconds=0,
275
+ ))
276
+ except Exception as e:
277
+ raise ModelRuntimeError(status_code=500, detail=f"{repo_id} 다운로드 실패: {str(e)[-2000:]}")
278
+
279
+ if not hf_model_ready(repo_id, provider):
280
+ raise ModelRuntimeError(status_code=500, detail=f"{repo_id} 다운로드가 완료되지 않았습니다. 모델 파일을 찾지 못했습니다.")
281
+
282
+ return {"model": repo_id, "path": str(target_dir), "cached": False}
@@ -0,0 +1,341 @@
1
+ """Local engine discovery, server hand-off, and the LM Studio HTTP client.
2
+
3
+ Thin wrappers over ``latticeai.services.model_engines`` that keep the historical
4
+ ``model_runtime`` import path alive, plus the one piece of real logic that never
5
+ belonged in the engine layer: the LM Studio native API (list / download / load)
6
+ and its short-lived model cache.
7
+
8
+ ``_LMSTUDIO_MODELS_CACHE`` and ``_LMSTUDIO_MODELS_CACHE_TS`` are rebindable
9
+ module state and therefore live here and nowhere else — the package
10
+ ``__init__`` deliberately does not re-export them, because a
11
+ ``from … import`` copy would freeze at import time and quietly disagree with
12
+ the live value.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+ import json
19
+ import os
20
+ import shutil
21
+ import time
22
+ import urllib.error
23
+ import urllib.request
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS, AsyncOpenAI
28
+ from latticeai.services.model_engines import (
29
+ LOCAL_SERVER_PROCESSES as _LOCAL_SERVER_PROCESSES,
30
+ )
31
+ from latticeai.services.model_engines import (
32
+ engine_install_plan as _engine_install_plan,
33
+ )
34
+ from latticeai.services.model_engines import (
35
+ engine_support_status as _engine_support_status,
36
+ )
37
+ from latticeai.services.model_engines import (
38
+ ensure_llamacpp_server as _ensure_llamacpp_server,
39
+ )
40
+ from latticeai.services.model_engines import (
41
+ ensure_lmstudio_server as _ensure_lmstudio_server,
42
+ )
43
+ from latticeai.services.model_engines import (
44
+ ensure_ollama_server as _ensure_ollama_server,
45
+ )
46
+ from latticeai.services.model_engines import (
47
+ ensure_vllm_server as _ensure_vllm_server,
48
+ )
49
+ from latticeai.services.model_engines import (
50
+ find_lmstudio_cli as _find_lmstudio_cli,
51
+ )
52
+ from latticeai.services.model_engines import (
53
+ get_ollama_pulled_models as _get_ollama_pulled_models,
54
+ )
55
+ from latticeai.services.model_engines import (
56
+ get_openai_compatible_server_models as _get_openai_compatible_server_models,
57
+ )
58
+ from latticeai.services.model_engines import (
59
+ local_binary as _local_binary,
60
+ )
61
+ from latticeai.services.model_engines import (
62
+ pull_ollama_model_with_progress as _pull_ollama_model_with_progress,
63
+ )
64
+ from latticeai.services.model_engines import (
65
+ vllm_executable as _vllm_executable,
66
+ )
67
+ from latticeai.services.model_engines import (
68
+ vllm_metal_python as _vllm_metal_python,
69
+ )
70
+ from latticeai.services.model_engines import (
71
+ wait_for_openai_compatible_server as _wait_for_openai_compatible_server,
72
+ )
73
+ from latticeai.services.model_engines import (
74
+ windows_binary_candidates as _windows_binary_candidates,
75
+ )
76
+ from latticeai.services.model_errors import ModelRuntimeError
77
+
78
+
79
+ def _update_env_file(env_file: Path, key: str, value: str) -> None:
80
+ lines = []
81
+ found = False
82
+ if env_file.exists():
83
+ for line in env_file.read_text(encoding="utf-8").splitlines():
84
+ if line.startswith(f"{key}="):
85
+ lines.append(f"{key}={value}")
86
+ found = True
87
+ else:
88
+ lines.append(line)
89
+ if not found:
90
+ lines.append(f"{key}={value}")
91
+ env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
92
+
93
+
94
+ LOCAL_SERVER_PROCESSES = _LOCAL_SERVER_PROCESSES
95
+ VLLM_METAL_ENV = Path.home() / ".venv-vllm-metal"
96
+ VLLM_METAL_BIN = VLLM_METAL_ENV / "bin" / "vllm"
97
+ VLLM_METAL_PYTHON = VLLM_METAL_ENV / "bin" / "python"
98
+ LMSTUDIO_BUNDLED_CLI = Path("/Applications/LM Studio.app/Contents/Resources/app/.webpack/lms")
99
+
100
+ def windows_binary_candidates(binary: str) -> List[Path]:
101
+ return _windows_binary_candidates(binary)
102
+
103
+
104
+ def local_binary(binary: str) -> Optional[str]:
105
+ return _local_binary(binary)
106
+
107
+
108
+ def find_lmstudio_cli() -> Optional[str]:
109
+ return _find_lmstudio_cli()
110
+
111
+
112
+ def vllm_executable() -> Optional[str]:
113
+ return _vllm_executable()
114
+
115
+
116
+ def vllm_metal_python() -> Optional[str]:
117
+ return _vllm_metal_python()
118
+
119
+
120
+ def _json_request(
121
+ url: str,
122
+ *,
123
+ method: str = "GET",
124
+ payload: Optional[Dict[str, Any]] = None,
125
+ headers: Optional[Dict[str, str]] = None,
126
+ timeout: float = 10.0,
127
+ ) -> Dict[str, Any]:
128
+ data = None
129
+ req_headers = dict(headers or {})
130
+ if payload is not None:
131
+ data = json.dumps(payload).encode("utf-8")
132
+ req_headers.setdefault("Content-Type", "application/json")
133
+ req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
134
+ with urllib.request.urlopen(req, timeout=timeout) as res:
135
+ raw = res.read().decode("utf-8", errors="replace")
136
+ if not raw.strip():
137
+ return {}
138
+ return json.loads(raw)
139
+
140
+
141
+ def lmstudio_api_base() -> str:
142
+ return (os.getenv("LMSTUDIO_BASE_URL") or OPENAI_COMPATIBLE_PROVIDERS["lmstudio"]["base_url"]).rstrip("/")
143
+
144
+
145
+ def lmstudio_native_api_base() -> str:
146
+ base = lmstudio_api_base()
147
+ return base[:-3] if base.endswith("/v1") else base
148
+
149
+
150
+ def ensure_lmstudio_server() -> None:
151
+ return _ensure_lmstudio_server()
152
+
153
+
154
+ _LMSTUDIO_MODELS_CACHE: List[Dict[str, Any]] = []
155
+ _LMSTUDIO_MODELS_CACHE_TS: float = 0.0
156
+ _LMSTUDIO_MODELS_CACHE_TTL: float = 10.0
157
+
158
+
159
+ def get_lmstudio_models(*, force: bool = False) -> List[Dict[str, Any]]:
160
+ global _LMSTUDIO_MODELS_CACHE, _LMSTUDIO_MODELS_CACHE_TS
161
+ if not force and time.monotonic() - _LMSTUDIO_MODELS_CACHE_TS < _LMSTUDIO_MODELS_CACHE_TTL:
162
+ return _LMSTUDIO_MODELS_CACHE
163
+ try:
164
+ payload = _json_request(
165
+ f"{lmstudio_native_api_base()}/api/v1/models",
166
+ headers={"Authorization": f"Bearer {os.getenv('LMSTUDIO_API_KEY') or 'lmstudio'}"},
167
+ timeout=2.5,
168
+ )
169
+ except Exception:
170
+ return _LMSTUDIO_MODELS_CACHE
171
+ models = payload.get("models")
172
+ _LMSTUDIO_MODELS_CACHE = models if isinstance(models, list) else []
173
+ _LMSTUDIO_MODELS_CACHE_TS = time.monotonic()
174
+ return _LMSTUDIO_MODELS_CACHE
175
+
176
+
177
+ def _lmstudio_candidate_keys(model_name: str) -> List[str]:
178
+ raw = model_name.strip()
179
+ if not raw:
180
+ return []
181
+ slug = raw.split("/")[-1].lower()
182
+ slug = slug.replace("-gguf", "").replace("-awq", "")
183
+ parts = [p for p in slug.split("-") if p]
184
+ candidates = [raw.lower(), slug]
185
+ if parts:
186
+ candidates.append("-".join(parts[: min(4, len(parts))]))
187
+ return list(dict.fromkeys(candidates))
188
+
189
+
190
+ def _find_lmstudio_model_key(model_name: str, models: List[Dict[str, Any]]) -> Optional[str]:
191
+ if not models:
192
+ return None
193
+ candidate_keys = _lmstudio_candidate_keys(model_name)
194
+ exact = []
195
+ fuzzy = []
196
+ for item in models:
197
+ key = str(item.get("key") or "").strip()
198
+ display_name = str(item.get("display_name") or "").strip()
199
+ haystacks = [key.lower(), display_name.lower()]
200
+ if any(raw == key.lower() for raw in candidate_keys):
201
+ exact.append(key)
202
+ continue
203
+ if any(token and token in hay for token in candidate_keys for hay in haystacks):
204
+ fuzzy.append(key)
205
+ return next(iter(exact or fuzzy), None)
206
+
207
+
208
+ def ensure_lmstudio_model(model_name: str) -> Dict[str, Any]:
209
+ ensure_lmstudio_server()
210
+ auth_header = {"Authorization": f"Bearer {os.getenv('LMSTUDIO_API_KEY') or 'lmstudio'}"}
211
+ models = get_lmstudio_models()
212
+ found_key = _find_lmstudio_model_key(model_name, models)
213
+ model_key = found_key or model_name
214
+
215
+ if not found_key:
216
+ try:
217
+ job = _json_request(
218
+ f"{lmstudio_native_api_base()}/api/v1/models/download",
219
+ method="POST",
220
+ payload={"model": model_name},
221
+ headers=auth_header,
222
+ timeout=30,
223
+ )
224
+ except urllib.error.HTTPError as e:
225
+ detail = e.read().decode("utf-8", errors="replace")[-2000:]
226
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {detail or e.reason}")
227
+ except Exception as e:
228
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {e}")
229
+
230
+ status = str(job.get("status") or "")
231
+ job_id = str(job.get("job_id") or "")
232
+ if status not in {"completed", "already_downloaded"} and job_id:
233
+ deadline = time.time() + 3600
234
+ while time.time() < deadline:
235
+ polled = _json_request(
236
+ f"{lmstudio_native_api_base()}/api/v1/models/download/status/{job_id}",
237
+ headers=auth_header,
238
+ timeout=30,
239
+ )
240
+ polled_status = str(polled.get("status") or "")
241
+ if polled_status == "completed":
242
+ break
243
+ if polled_status == "failed":
244
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {polled}")
245
+ time.sleep(2)
246
+ else:
247
+ raise ModelRuntimeError(status_code=408, detail="LM Studio 모델 다운로드 시간이 초과되었습니다.")
248
+
249
+ models = get_lmstudio_models(force=True)
250
+ model_key = _find_lmstudio_model_key(model_name, models) or model_name
251
+
252
+ target = next((item for item in models if isinstance(item, dict) and item.get("key") == model_key), None)
253
+ loaded_instances = target.get("loaded_instances") if isinstance(target, dict) else None
254
+ if loaded_instances:
255
+ return {"provider": "lmstudio", "model": model_name, "resolved_model": model_key, "server_ready": True, "cached": True}
256
+
257
+ try:
258
+ loaded = _json_request(
259
+ f"{lmstudio_native_api_base()}/api/v1/models/load",
260
+ method="POST",
261
+ payload={"model": model_key, "context_length": 4096},
262
+ headers=auth_header,
263
+ timeout=120,
264
+ )
265
+ except urllib.error.HTTPError as e:
266
+ detail = e.read().decode("utf-8", errors="replace")[-2000:]
267
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 로드 실패: {detail or e.reason}")
268
+ except Exception as e:
269
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 로드 실패: {e}")
270
+
271
+ if str(loaded.get("status") or "") != "loaded":
272
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 로드 실패: {loaded}")
273
+
274
+ return {
275
+ "provider": "lmstudio",
276
+ "model": model_name,
277
+ "resolved_model": model_key,
278
+ "instance_id": loaded.get("instance_id"),
279
+ "server_ready": True,
280
+ "cached": False,
281
+ }
282
+
283
+ def engine_support_status(engine: str) -> Dict[str, Any]:
284
+ return _engine_support_status(engine)
285
+
286
+ def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict[str, Any]:
287
+ return _pull_ollama_model_with_progress(model_name, progress_emit)
288
+
289
+
290
+ def get_ollama_pulled_models() -> set:
291
+ return _get_ollama_pulled_models()
292
+
293
+
294
+ def get_openai_compatible_server_models(provider: str) -> List[str]:
295
+ return _get_openai_compatible_server_models(provider)
296
+
297
+
298
+ def ensure_ollama_server() -> None:
299
+ return _ensure_ollama_server()
300
+
301
+
302
+ def wait_for_openai_compatible_server(provider: str, model_name: Optional[str] = None, timeout: int = 45) -> bool:
303
+ return _wait_for_openai_compatible_server(provider, model_name=model_name, timeout=timeout)
304
+
305
+
306
+ def ensure_vllm_server(model_name: str) -> None:
307
+ return _ensure_vllm_server(model_name)
308
+
309
+
310
+ def ensure_llamacpp_server(model_name: str) -> None:
311
+ return _ensure_llamacpp_server(model_name)
312
+
313
+
314
+ def _safe_engine_install_plan(
315
+ engine: str,
316
+ *,
317
+ base_dir: Path,
318
+ ) -> Optional[Dict[str, Any]]:
319
+ try:
320
+ return _engine_install_plan(engine, base_dir=base_dir)
321
+ except Exception:
322
+ return None
323
+
324
+
325
+ def engine_installed(engine: str) -> bool:
326
+ if engine == "local_mlx":
327
+ return bool(
328
+ importlib.util.find_spec("mlx")
329
+ and (importlib.util.find_spec("mlx_vlm") or importlib.util.find_spec("mlx_lm"))
330
+ )
331
+ if engine == "ollama":
332
+ return local_binary("ollama") is not None
333
+ if engine == "vllm":
334
+ return vllm_metal_python() is not None or vllm_executable() is not None or importlib.util.find_spec("vllm") is not None
335
+ if engine == "lmstudio":
336
+ return find_lmstudio_cli() is not None or Path("/Applications/LM Studio.app").exists()
337
+ if engine == "llamacpp":
338
+ return shutil.which("llama-server") is not None
339
+ if engine in {"openai", "openrouter", "groq", "together", "xai"}:
340
+ return AsyncOpenAI is not None
341
+ return False