ltcai 11.2.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 (247) hide show
  1. package/README.md +46 -53
  2. package/docs/CHANGELOG.md +61 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +6 -2
  8. package/docs/PERMISSION_MODE.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +2 -2
  12. package/docs/v11.3.0_PLAN.md +202 -0
  13. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  16. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  17. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  18. package/lattice_brain/graph/_kg_common/text.py +479 -0
  19. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  20. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  21. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  22. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  23. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  24. package/lattice_brain/graph/projection/__init__.py +42 -0
  25. package/lattice_brain/graph/projection/curation.py +500 -0
  26. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
  27. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  28. package/lattice_brain/graph/retrieval/context.py +197 -0
  29. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  30. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  31. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  32. package/lattice_brain/graph/retrieval/signals.py +95 -0
  33. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  34. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  35. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  36. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  37. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  38. package/lattice_brain/ingestion/__init__.py +130 -0
  39. package/lattice_brain/ingestion/_contract.py +90 -0
  40. package/lattice_brain/ingestion/constants.py +127 -0
  41. package/lattice_brain/ingestion/folder_scan.py +57 -0
  42. package/lattice_brain/ingestion/folders.py +258 -0
  43. package/lattice_brain/ingestion/hashing.py +26 -0
  44. package/lattice_brain/ingestion/jobs_api.py +107 -0
  45. package/lattice_brain/ingestion/models.py +80 -0
  46. package/lattice_brain/ingestion/pipeline.py +486 -0
  47. package/lattice_brain/ingestion/quality.py +209 -0
  48. package/lattice_brain/ingestion/routing.py +295 -0
  49. package/lattice_brain/multimodal/__init__.py +164 -0
  50. package/lattice_brain/multimodal/audio.py +77 -0
  51. package/lattice_brain/multimodal/common.py +118 -0
  52. package/lattice_brain/multimodal/images.py +498 -0
  53. package/lattice_brain/multimodal/ports.py +169 -0
  54. package/lattice_brain/multimodal/video.py +410 -0
  55. package/lattice_brain/portability/__init__.py +90 -0
  56. package/lattice_brain/portability/_contract.py +42 -0
  57. package/lattice_brain/portability/backups.py +338 -0
  58. package/lattice_brain/portability/bundles.py +136 -0
  59. package/lattice_brain/portability/constants.py +93 -0
  60. package/lattice_brain/portability/fsops.py +138 -0
  61. package/lattice_brain/portability/service.py +41 -0
  62. package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
  63. package/lattice_brain/runtime/__init__.py +1 -1
  64. package/lattice_brain/runtime/multi_agent.py +1 -1
  65. package/latticeai/__init__.py +1 -1
  66. package/latticeai/api/chronicle.py +63 -0
  67. package/latticeai/core/agent/__init__.py +93 -0
  68. package/latticeai/core/agent/_contract.py +79 -0
  69. package/latticeai/core/agent/context.py +57 -0
  70. package/latticeai/core/agent/deps.py +125 -0
  71. package/latticeai/core/agent/execution.py +622 -0
  72. package/latticeai/core/agent/planning.py +145 -0
  73. package/latticeai/core/agent/recovery.py +157 -0
  74. package/latticeai/core/agent/runtime.py +210 -0
  75. package/latticeai/core/agent/verification.py +231 -0
  76. package/latticeai/core/embedding_providers/__init__.py +151 -0
  77. package/latticeai/core/embedding_providers/base.py +199 -0
  78. package/latticeai/core/embedding_providers/captions.py +162 -0
  79. package/latticeai/core/embedding_providers/profiles.py +126 -0
  80. package/latticeai/core/embedding_providers/text.py +350 -0
  81. package/latticeai/core/embedding_providers/vision.py +352 -0
  82. package/latticeai/core/file_generation/__init__.py +115 -0
  83. package/latticeai/core/file_generation/bundles.py +76 -0
  84. package/latticeai/core/file_generation/extraction.py +154 -0
  85. package/latticeai/core/file_generation/inference.py +235 -0
  86. package/latticeai/core/file_generation/orchestration.py +152 -0
  87. package/latticeai/core/file_generation/prompting.py +117 -0
  88. package/latticeai/core/file_generation/repair.py +114 -0
  89. package/latticeai/core/file_generation/sanitize.py +61 -0
  90. package/latticeai/core/file_generation/validation.py +201 -0
  91. package/latticeai/core/legacy_compatibility.py +1 -1
  92. package/latticeai/core/marketplace.py +1 -1
  93. package/latticeai/core/messages.py +9 -0
  94. package/latticeai/core/workspace_os_constants.py +1 -1
  95. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  96. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  97. package/latticeai/integrations/telegram_bot/config.py +86 -0
  98. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  99. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  100. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  101. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  102. package/latticeai/models/router/__init__.py +88 -0
  103. package/latticeai/models/router/_contract.py +66 -0
  104. package/latticeai/models/router/branding.py +56 -0
  105. package/latticeai/models/router/catalog.py +69 -0
  106. package/latticeai/models/router/documents.py +199 -0
  107. package/latticeai/models/router/errors.py +37 -0
  108. package/latticeai/models/router/generation.py +258 -0
  109. package/latticeai/models/router/loading.py +291 -0
  110. package/latticeai/models/router/local_models.py +85 -0
  111. package/latticeai/models/router/registry.py +147 -0
  112. package/latticeai/runtime/build_phases/__init__.py +82 -0
  113. package/latticeai/runtime/build_phases/features.py +407 -0
  114. package/latticeai/runtime/build_phases/foundation.py +555 -0
  115. package/latticeai/runtime/build_phases/web.py +492 -0
  116. package/latticeai/runtime/runtime_context.py +1 -0
  117. package/latticeai/services/architecture_readiness.py +48 -19
  118. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  119. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  120. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  121. package/latticeai/services/brain_intelligence/constants.py +47 -0
  122. package/latticeai/services/brain_intelligence/digest.py +258 -0
  123. package/latticeai/services/brain_intelligence/health.py +331 -0
  124. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  125. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  126. package/latticeai/services/brain_intelligence/service.py +48 -0
  127. package/latticeai/services/chronicle.py +557 -0
  128. package/latticeai/services/memory_service/__init__.py +52 -0
  129. package/latticeai/services/memory_service/_contract.py +100 -0
  130. package/latticeai/services/memory_service/brief.py +431 -0
  131. package/latticeai/services/memory_service/constants.py +57 -0
  132. package/latticeai/services/memory_service/maintenance.py +138 -0
  133. package/latticeai/services/memory_service/manager.py +186 -0
  134. package/latticeai/services/memory_service/proof.py +136 -0
  135. package/latticeai/services/memory_service/recall.py +225 -0
  136. package/latticeai/services/memory_service/service.py +48 -0
  137. package/latticeai/services/memory_service/stores.py +110 -0
  138. package/latticeai/services/model_runtime/__init__.py +322 -0
  139. package/latticeai/services/model_runtime/cloud.py +87 -0
  140. package/latticeai/services/model_runtime/download.py +282 -0
  141. package/latticeai/services/model_runtime/engines.py +341 -0
  142. package/latticeai/services/model_runtime/loading.py +178 -0
  143. package/latticeai/services/model_runtime/service.py +129 -0
  144. package/latticeai/services/model_runtime/state.py +131 -0
  145. package/latticeai/services/model_runtime/status.py +255 -0
  146. package/latticeai/services/product_readiness.py +15 -7
  147. package/latticeai/setup/wizard/__init__.py +126 -0
  148. package/latticeai/setup/wizard/catalog.py +172 -0
  149. package/latticeai/setup/wizard/detect.py +323 -0
  150. package/latticeai/setup/wizard/install.py +348 -0
  151. package/latticeai/setup/wizard/paths.py +168 -0
  152. package/latticeai/setup/wizard/plans.py +74 -0
  153. package/latticeai/setup/wizard/recommend.py +320 -0
  154. package/package.json +6 -2
  155. package/scripts/bump_version.py +14 -0
  156. package/scripts/capture_release_evidence.mjs +33 -21
  157. package/scripts/check_current_release_docs.mjs +1 -1
  158. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  159. package/scripts/check_max_file_lines.mjs +102 -0
  160. package/scripts/check_release_evidence_bound.mjs +30 -15
  161. package/scripts/check_screenshot_pixel_delta.py +34 -4
  162. package/scripts/check_server_i18n.mjs +1 -0
  163. package/scripts/generate_rust_parity_fixtures.py +562 -0
  164. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  165. package/scripts/release_screen_claims.json +31 -2
  166. package/src-tauri/Cargo.lock +361 -3
  167. package/src-tauri/Cargo.toml +6 -1
  168. package/src-tauri/src/backend.rs +349 -0
  169. package/src-tauri/src/folder.rs +33 -0
  170. package/src-tauri/src/main.rs +97 -399
  171. package/src-tauri/tauri.conf.json +1 -1
  172. package/static/app/asset-manifest.json +41 -37
  173. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  174. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  175. package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
  176. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  177. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  178. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  179. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  180. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  181. package/static/app/assets/Library-DwO3yZST.js +1 -0
  182. package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
  183. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  184. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  185. package/static/app/assets/System-DW8F-2xL.js +1 -0
  186. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  187. package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
  188. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  189. package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
  190. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  191. package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
  192. package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
  193. package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
  194. package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
  195. package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
  196. package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
  197. package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
  198. package/static/app/assets/index-_u5iUHDr.js +10 -0
  199. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  200. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  201. package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
  202. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  203. package/static/app/assets/search-BybIWPNd.js +1 -0
  204. package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
  205. package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
  206. package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
  207. package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
  208. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  209. package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
  210. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  211. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  212. package/static/app/index.html +4 -4
  213. package/static/sw.js +1 -1
  214. package/lattice_brain/graph/_kg_common.py +0 -1331
  215. package/lattice_brain/graph/discovery_index.py +0 -1141
  216. package/lattice_brain/graph/retrieval.py +0 -1120
  217. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  218. package/lattice_brain/ingestion.py +0 -1525
  219. package/lattice_brain/multimodal.py +0 -1258
  220. package/latticeai/core/agent.py +0 -1465
  221. package/latticeai/core/embedding_providers.py +0 -1196
  222. package/latticeai/core/file_generation.py +0 -1047
  223. package/latticeai/integrations/telegram_bot.py +0 -1390
  224. package/latticeai/models/router.py +0 -1007
  225. package/latticeai/runtime/build_phases.py +0 -1450
  226. package/latticeai/services/brain_intelligence.py +0 -1083
  227. package/latticeai/services/memory_service.py +0 -1177
  228. package/latticeai/services/model_runtime.py +0 -1281
  229. package/latticeai/setup/wizard.py +0 -1310
  230. package/static/app/assets/Act-AWf0SAKp.js +0 -1
  231. package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
  232. package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
  233. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
  234. package/static/app/assets/Capture-CqOSzyPr.js +0 -1
  235. package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
  236. package/static/app/assets/Library-CX-bbhmK.js +0 -1
  237. package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
  238. package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
  239. package/static/app/assets/System-Bu2t5hn1.js +0 -1
  240. package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
  241. package/static/app/assets/brain-DJMoqrwx.js +0 -1
  242. package/static/app/assets/index-BpYkzcVm.js +0 -10
  243. package/static/app/assets/input-DSlJJxRs.js +0 -1
  244. package/static/app/assets/primitives-BCx6TvfG.js +0 -1
  245. package/static/app/assets/search-Cgy8cCFJ.js +0 -1
  246. package/static/app/assets/utils-zqPZJxdx.js +0 -4
  247. package/static/app/assets/workspace-DXTihhfU.js +0 -1
@@ -1,1007 +0,0 @@
1
- """
2
- LLM Router — mlx-vlm 기반 Gemma 4 최적화 및 추측 디코딩(Speculative Decoding) 코어
3
- """
4
-
5
- import asyncio
6
- import base64
7
- import gc
8
- import io
9
- import json
10
- import os
11
- import re
12
- import threading
13
- import time
14
- from dataclasses import dataclass
15
- from pathlib import Path
16
-
17
- # Default Gemma 4 assistant drafting to MTP without overriding an operator's
18
- # explicit MLX runtime choice.
19
- os.environ.setdefault("MLX_VLM_DRAFT_KIND", "mtp")
20
-
21
- from concurrent.futures import ThreadPoolExecutor
22
- from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
23
-
24
- from PIL import Image
25
-
26
- from latticeai.core.quiet import quiet
27
-
28
- # Cloud provider catalog data lives in .model_providers; re-exported here so
29
- # ``from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS`` (and the
30
- # model_runtime re-export chain) resolve unchanged after the split.
31
- from latticeai.models.model_providers import ( # noqa: F401
32
- MODEL_SOURCE_BY_FAMILY,
33
- OPENAI_COMPATIBLE_PROVIDERS,
34
- PROVIDER_MODEL_CATALOG,
35
- )
36
-
37
- # Optional dependencies. Each is aliased on import and then re-exported as
38
- # `Any`, so "installed" and "absent" are the same declared type and every
39
- # call site keeps its historical name.
40
- try:
41
- from openai import AsyncOpenAI as _AsyncOpenAI
42
- except Exception:
43
- _AsyncOpenAI = None # type: ignore[assignment,misc]
44
- AsyncOpenAI: Any = _AsyncOpenAI
45
-
46
- # 추론 전용 싱글 스레드 워커 (GPU 스트림 보호용)
47
- executor = ThreadPoolExecutor(max_workers=1)
48
-
49
- try:
50
- import mlx.core as _mx
51
- except Exception as e:
52
- _mx = None # type: ignore[assignment]
53
- print(f"⚠️ MLX core unavailable: {e}")
54
- mx: Any = _mx
55
-
56
- try:
57
- from mlx_vlm import load as _vlm_load
58
- VLM_AVAILABLE = True
59
- print("✅ MLX-VLM is ready for multimodal models.")
60
- except Exception as e:
61
- _vlm_load = None # type: ignore[assignment]
62
- VLM_AVAILABLE = False
63
- print(f"⚠️ MLX-VLM unavailable: {e}")
64
- vlm_load: Any = _vlm_load
65
-
66
- try:
67
- from mlx_lm import load as _lm_load
68
- LM_AVAILABLE = True
69
- print("✅ MLX-LM is ready for text fallback models.")
70
- except Exception as e:
71
- _lm_load = None # type: ignore[assignment]
72
- LM_AVAILABLE = False
73
- print(f"⚠️ MLX-LM unavailable: {e}")
74
- lm_load: Any = _lm_load
75
-
76
- BRAND_NAME = "Lattice AI"
77
- LEGACY_BRAND_PATTERNS = [
78
- (re.compile(r"\bconnect\s+ai\b", re.IGNORECASE), BRAND_NAME),
79
- (re.compile(r"\bconnect-ai\b", re.IGNORECASE), BRAND_NAME),
80
- (re.compile(r"\bconnectai\b", re.IGNORECASE), BRAND_NAME),
81
- (re.compile(r"커넥트\s*AI", re.IGNORECASE), BRAND_NAME),
82
- ]
83
-
84
- SYSTEM_PROMPT = """You are Lattice AI, a powerful local AI assistant running on Apple Silicon.
85
- Your product name and identity are Lattice AI.
86
- Never identify yourself as Connect AI, ConnectAI, connect-ai, or 커넥트 AI.
87
- If context or old chat history mentions those names, treat them only as legacy aliases for Lattice AI.
88
- You are a Vision-Language Model (VLM). If an image is provided, analyze it.
89
- Be concise and respond in the user's language."""
90
-
91
- # Appended ONLY when retrieved context exists (review 2026-07-25 Wave 2.3):
92
- # grounded answers should cite their sources and admit gaps. Advisory prompt
93
- # guidance — grounding assessment stays annotation-only and never blocks.
94
- CITATION_INSTRUCTION = """The Context section above contains retrieved sources.
95
- Ground your claims in those sources and cite them inline as [1], [2], ... matching the order they appear in the Context.
96
- If the context does not cover the question, say so instead of inventing sources.
97
- Never cite a source that is not in the Context."""
98
-
99
-
100
- def _compose_system(base: str, context: str) -> str:
101
- """Compose the system prompt with optional retrieved context.
102
-
103
- Byte-compatible with the historical prompt when ``context`` is empty:
104
- the return value is exactly ``base``. When context exists, the Context
105
- block plus :data:`CITATION_INSTRUCTION` are appended.
106
- """
107
- if not context:
108
- return base
109
- return f"{base}\n\nContext:\n{context}\n\n{CITATION_INSTRUCTION}"
110
-
111
-
112
- def normalize_branding(text: Optional[str]) -> str:
113
- if not text:
114
- return ""
115
- normalized = str(text)
116
- for pattern, replacement in LEGACY_BRAND_PATTERNS:
117
- normalized = pattern.sub(replacement, normalized)
118
- return normalized
119
-
120
-
121
- class ModelStreamError(RuntimeError):
122
- """A backend failed mid-stream. This is an error, never model output.
123
-
124
- Streaming backends used to hand their failure to the caller as a chunk of
125
- text (``"⚠️ Error: ..."``), which every consumer then treated as the
126
- model's answer: it was echoed to the client as content and persisted as a
127
- successful turn. The failure now travels as this typed exception instead.
128
-
129
- The MLX generators run on a worker thread that cannot raise into the
130
- consuming coroutine, so the thread puts an instance on the chunk queue and
131
- :meth:`LLMRouter._drain_stream_queue` re-raises it. The SSE endpoints
132
- (``latticeai.api.chat_stream.stream_chat`` and the document stream in
133
- ``latticeai.api.chat_documents``) already wrap their ``async for`` in
134
- ``except Exception`` and emit an ``error`` frame plus a ``[stream_error]``
135
- marker on the persisted answer, so the stream framing is unchanged.
136
- """
137
-
138
-
139
- def _stream_failure(stage: str, exc: BaseException) -> ModelStreamError:
140
- """Envelope a backend exception for transport across the chunk queue.
141
-
142
- ``raise ... from exc`` is unavailable on the worker thread (nothing there
143
- consumes the traceback), so the cause is attached explicitly and stays
144
- visible in logs when the consumer re-raises.
145
- """
146
- error = ModelStreamError(f"{stage}: {exc}")
147
- error.__cause__ = exc
148
- return error
149
-
150
-
151
- # Returns a display payload whose `source_display_order` value is a list,
152
- # so the value type is Any rather than str.
153
- def source_metadata_for_model(
154
- provider: str, model: Dict[str, Any], *, local_server: bool
155
- ) -> Dict[str, Any]:
156
- family = str(model.get("family") or "")
157
- country, company = MODEL_SOURCE_BY_FAMILY.get(family, ("미상", provider.title()))
158
- if local_server:
159
- execution_method = "내 컴퓨터에서만 실행"
160
- internet_requirement = "모델을 다운로드할 때만 인터넷 필요; 실행 중에는 필요 없음"
161
- else:
162
- execution_method = "인터넷 연결 후 사용"
163
- internet_requirement = "내 파일이 인터넷으로 전송될 수 있음"
164
- return {
165
- "source_country": country,
166
- "source_company": company,
167
- "execution_method": execution_method,
168
- "internet_requirement": internet_requirement,
169
- "model_name": model.get("name") or model.get("id") or "",
170
- "source_display_order": [
171
- "source_country",
172
- "source_company",
173
- "execution_method",
174
- "internet_requirement",
175
- "model_name",
176
- ],
177
- }
178
-
179
- @dataclass
180
- class CloudModel:
181
- provider: str
182
- model: str
183
- client: Any # AsyncOpenAI when the optional dependency is installed
184
- cache_key: str
185
-
186
- def parse_model_ref(model_id: str) -> tuple[str, str]:
187
- """Return (provider, model). Unprefixed refs stay local MLX."""
188
- if model_id.startswith("cloud:"):
189
- _, provider, model = model_id.split(":", 2)
190
- return provider, model
191
- if ":" in model_id:
192
- provider, model = model_id.split(":", 1)
193
- if provider in OPENAI_COMPATIBLE_PROVIDERS:
194
- return provider, model
195
- if provider in {"local_mlx", "mlx"}:
196
- return "local_mlx", model
197
- if model_id.startswith("local_mlx:"):
198
- return "local_mlx", model_id.split(":", 1)[1] # pragma: no cover — dead: a "local_mlx:" ref always has a ":" and returned above
199
- return "local_mlx", model_id
200
-
201
- HF_MODELS_ROOT = Path.home() / ".ltcai" / "hf-models"
202
-
203
- def hf_model_dir(repo_id: str) -> Path:
204
- return HF_MODELS_ROOT / repo_id.replace("/", "__")
205
-
206
- def hf_cache_model_dir(repo_id: str) -> Optional[Path]:
207
- """Return a usable Hugging Face cache snapshot for an already-downloaded model."""
208
- cache_root = Path.home() / ".cache" / "huggingface" / "hub" / f"models--{repo_id.replace('/', '--')}"
209
- snapshots = cache_root / "snapshots"
210
- if not snapshots.exists():
211
- return None
212
- candidates = sorted(
213
- (item for item in snapshots.iterdir() if item.is_dir()),
214
- key=lambda item: item.stat().st_mtime,
215
- reverse=True,
216
- )
217
- for snapshot in candidates:
218
- if _looks_like_hf_model_dir(snapshot):
219
- return snapshot
220
- return None
221
-
222
- def _looks_like_hf_model_dir(path: Path) -> bool:
223
- if not path.exists() or not path.is_dir():
224
- return False
225
- has_config = (path / "config.json").exists()
226
- has_weights = any(path.glob("*.safetensors")) or any(path.glob("*.bin"))
227
- has_tokenizer = (
228
- (path / "tokenizer.json").exists()
229
- or (path / "tokenizer.model").exists()
230
- or (path / "tokenizer_config.json").exists()
231
- )
232
- return has_config and has_weights and has_tokenizer
233
-
234
- def _resolve_local_hf_model(model_id: str) -> str:
235
- explicit_path = Path(model_id).expanduser()
236
- if explicit_path.exists():
237
- return str(explicit_path)
238
- local_dir = hf_model_dir(model_id)
239
- if _looks_like_hf_model_dir(local_dir):
240
- return str(local_dir)
241
- cached_dir = hf_cache_model_dir(model_id)
242
- if cached_dir is not None:
243
- return str(cached_dir)
244
- return model_id
245
-
246
- def _is_gemma4_model_id(model_id: str) -> bool:
247
- raw = str(model_id or "").lower()
248
- return bool(re.search(r"gemma[-_/ ]?4|gemma4", raw))
249
-
250
-
251
- def _local_model_type(path_or_model_id: str) -> Optional[str]:
252
- raw = str(path_or_model_id or "").strip()
253
- candidates = []
254
- explicit = Path(raw).expanduser()
255
- if raw and explicit.exists():
256
- candidates.append(explicit / "config.json")
257
- candidates.append(hf_model_dir(raw) / "config.json")
258
- for config_path in candidates:
259
- try:
260
- if config_path.exists():
261
- data = json.loads(config_path.read_text(encoding="utf-8"))
262
- model_type = str(data.get("model_type") or "").strip().lower()
263
- if model_type:
264
- return model_type
265
- except Exception as e:
266
- print(f"⚠️ Model config read skipped for {config_path}: {e}")
267
- return None
268
-
269
-
270
- def ensure_mlx_runtime() -> None:
271
- global mx, vlm_load, lm_load, VLM_AVAILABLE, LM_AVAILABLE
272
- if mx is not None and (vlm_load is not None or lm_load is not None):
273
- return
274
- errors = []
275
- try:
276
- import mlx.core as mlx_core
277
- mx = mlx_core
278
- mx.set_default_device(mx.gpu)
279
- except Exception as e:
280
- errors.append(f"mlx: {e}")
281
- mx = None
282
-
283
- try:
284
- from mlx_vlm import load as mlx_vlm_load
285
- vlm_load = mlx_vlm_load
286
- VLM_AVAILABLE = True
287
- except Exception as e:
288
- vlm_load = None
289
- VLM_AVAILABLE = False
290
- errors.append(f"mlx-vlm: {e}")
291
-
292
- try:
293
- from mlx_lm import load as mlx_lm_load
294
- lm_load = mlx_lm_load
295
- LM_AVAILABLE = True
296
- except Exception as e:
297
- lm_load = None
298
- LM_AVAILABLE = False
299
- errors.append(f"mlx-lm: {e}")
300
-
301
- if mx is None or (vlm_load is None and lm_load is None):
302
- raise RuntimeError(f"MLX runtime is not available after install: {'; '.join(errors)}")
303
-
304
- def _mlx_sampler(temperature: float):
305
- """Build an MLX sampler callable for the given temperature.
306
-
307
- Lattice v2.2 keeps local execution on MLX-VLM only. Returning ``None`` lets
308
- MLX-VLM use its bundled default sampler without pulling another generation
309
- package into the runtime contract.
310
- """
311
- _ = temperature
312
- return
313
-
314
- class LLMRouter:
315
- def __init__(self):
316
- # A local entry is (model, tokenizer, draft_model, loader_kind); a
317
- # cloud entry is a CloudModel. `_unpack_local_cache` splits them.
318
- self._cache: Dict[str, Any] = {}
319
- self._current: Optional[str] = None
320
- self._last_used: Dict[str, float] = {}
321
- self._max_local_models = max(1, int(os.getenv("LATTICEAI_MAX_LOCAL_MODELS", "1")))
322
- # Guards the mutable model registry (_cache/_current/_last_used).
323
- # Reentrant because the eviction path nests: _enforce_local_model_limit
324
- # → unload_model → _release_memory. Never held across the heavy
325
- # ``run_in_executor`` load (only the sync insert/read is guarded), so a
326
- # long model load can't block a concurrent switch/unload from acquiring.
327
- self._lock = threading.RLock()
328
-
329
- @property
330
- def current_model_id(self) -> Optional[str]:
331
- with self._lock:
332
- return self._current
333
-
334
- @property
335
- def loaded_model_ids(self) -> List[str]:
336
- with self._lock:
337
- return list(self._cache.keys())
338
-
339
- def switch_model(self, model_id: str) -> None:
340
- with self._lock:
341
- if model_id not in self._cache:
342
- raise KeyError(model_id)
343
- self._current = model_id
344
- self._touch(model_id)
345
-
346
- def unload_model(self, model_id: str) -> None:
347
- with self._lock:
348
- self._cache.pop(model_id, None)
349
- self._last_used.pop(model_id, None)
350
- if self._current == model_id:
351
- self._current = next(iter(self._cache), None)
352
- self._release_memory()
353
-
354
- def unload_all(self) -> None:
355
- with self._lock:
356
- self._cache.clear()
357
- self._last_used.clear()
358
- self._current = None
359
- self._release_memory()
360
-
361
- def unload_idle_models(self, idle_seconds: int) -> List[str]:
362
- if idle_seconds <= 0:
363
- return []
364
- now = time.monotonic()
365
- unloaded = []
366
- with self._lock:
367
- for model_id, last_used in list(self._last_used.items()):
368
- if now - last_used >= idle_seconds:
369
- self.unload_model(model_id)
370
- unloaded.append(model_id)
371
- return unloaded
372
-
373
- def model_memory_policy(self) -> Dict[str, object]:
374
- with self._lock:
375
- return {
376
- "max_local_models": self._max_local_models,
377
- "loaded_count": len(self._cache),
378
- "last_used": dict(self._last_used),
379
- }
380
-
381
- def _touch(self, model_id: Optional[str] = None) -> None:
382
- model_id = model_id or self._current
383
- if model_id:
384
- self._last_used[model_id] = time.monotonic()
385
-
386
- def _is_local_model(self, model_id: str) -> bool:
387
- cached = self._cache.get(model_id)
388
- return cached is not None and not isinstance(cached, CloudModel)
389
-
390
- def _enforce_local_model_limit(self, incoming_key: str) -> None:
391
- with self._lock:
392
- local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
393
- while len(local_ids) >= self._max_local_models:
394
- victim = min(local_ids, key=lambda model_id: self._last_used.get(model_id, 0))
395
- if victim == incoming_key:
396
- break
397
- print(f"🧹 Unloading local model to stay within memory policy: {victim}")
398
- self.unload_model(victim)
399
- local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
400
-
401
- def _release_memory(self) -> None:
402
- gc.collect()
403
- if mx is not None and hasattr(mx, "clear_cache"):
404
- try:
405
- mx.clear_cache()
406
- except Exception as e:
407
- print(f"⚠️ MLX cache clear skipped: {e}")
408
-
409
- async def load_model(
410
- self,
411
- model_id: str,
412
- adapter_path: Optional[str] = None,
413
- draft_model_id: Optional[str] = None,
414
- api_key_override: Optional[str] = None,
415
- owner: Optional[str] = None,
416
- ) -> str:
417
- provider, provider_model = parse_model_ref(model_id)
418
- if provider != "local_mlx":
419
- return self._load_cloud_model(provider, provider_model, api_key_override=api_key_override, owner=owner)
420
-
421
- ensure_mlx_runtime()
422
- if mx is None or (vlm_load is None and lm_load is None):
423
- raise RuntimeError("MLX is not available in this process. Run on Apple Silicon with Metal access.")
424
-
425
- cache_key = f"{model_id}_{draft_model_id}" if draft_model_id else model_id
426
- with self._lock:
427
- if cache_key in self._cache:
428
- self._current = cache_key
429
- self._touch(cache_key)
430
- return f"Cached: {cache_key}"
431
-
432
- self._enforce_local_model_limit(cache_key)
433
- print(f"⏳ Loading local model stack: {cache_key}...")
434
- loop = asyncio.get_event_loop()
435
- target_model_id = _resolve_local_hf_model(model_id)
436
- target_draft_model_id = _resolve_local_hf_model(draft_model_id) if draft_model_id else None
437
-
438
- def _load():
439
- mx.set_default_device(mx.gpu)
440
- is_gemma4 = _is_gemma4_model_id(model_id)
441
- model_type = _local_model_type(target_model_id) or _local_model_type(model_id)
442
- loader_kind = "mlx_vlm"
443
-
444
- try:
445
- if vlm_load is None:
446
- raise RuntimeError("MLX-VLM is not installed.")
447
- print(f"🔄 Loading Target (VLM Mode): {target_model_id}...")
448
- model, tokenizer = vlm_load(target_model_id)
449
- except Exception as vlm_error:
450
- if not (is_gemma4 and model_type != "gemma4_unified" and lm_load is not None):
451
- raise
452
- print(f"⚠️ Gemma 4 MLX-VLM load failed; retrying MLX-LM text path: {vlm_error}")
453
- print(f"🔄 Loading Target (LM Mode): {target_model_id}...")
454
- model, tokenizer = lm_load(target_model_id)
455
- loader_kind = "mlx_lm"
456
-
457
- draft_model = None
458
- if target_draft_model_id:
459
- if loader_kind == "mlx_vlm":
460
- print(f"🔄 Loading Assistant (VLM Mode): {target_draft_model_id}...")
461
- draft_model, _ = vlm_load(target_draft_model_id)
462
- elif lm_load is not None:
463
- print(f"🔄 Loading Assistant (LM Mode): {target_draft_model_id}...")
464
- draft_model, _ = lm_load(target_draft_model_id)
465
- print("✅ Assistant Ready.")
466
-
467
- return model, tokenizer, draft_model, loader_kind
468
-
469
- try:
470
- # Use the dedicated single-thread executor to ensure MLX GPU streams match during inference
471
- model, tokenizer, draft_model, loader_kind = await loop.run_in_executor(executor, _load)
472
- with self._lock:
473
- self._cache[cache_key] = (model, tokenizer, draft_model, loader_kind)
474
- self._current = cache_key
475
- self._touch(cache_key)
476
- print(f"✅ Fully Loaded: {cache_key} ({loader_kind})")
477
- return f"Success: {cache_key} ({loader_kind})"
478
- except Exception as e:
479
- print(f"❌ Load Error: {e}")
480
- raise e
481
-
482
- def _load_cloud_model(self, provider: str, model: str, api_key_override: Optional[str] = None, owner: Optional[str] = None) -> str:
483
- if AsyncOpenAI is None:
484
- raise RuntimeError("openai package is not installed. Add it to requirements.txt and install dependencies.")
485
- config = OPENAI_COMPATIBLE_PROVIDERS.get(provider)
486
- if not config:
487
- raise RuntimeError(f"Unsupported cloud provider: {provider}")
488
-
489
- api_key = api_key_override or os.getenv(config["env_key"]) or config.get("api_key_fallback")
490
- if not api_key:
491
- raise RuntimeError(f"Missing API key env var: {config['env_key']}")
492
-
493
- base_url = os.getenv(config.get("base_url_env", "")) if config.get("base_url_env") else None
494
- base_url = base_url or config.get("base_url")
495
- # base_url is passed only when configured: an explicit None is not
496
- # the same as omitting the argument.
497
- client = (
498
- AsyncOpenAI(api_key=api_key, base_url=base_url)
499
- if base_url
500
- else AsyncOpenAI(api_key=api_key)
501
- )
502
-
503
- cache_owner = owner or "global"
504
- cache_key = f"{provider}:{model}::{cache_owner}"
505
- with self._lock:
506
- self._cache[cache_key] = CloudModel(
507
- provider=provider, model=model, client=client, cache_key=cache_key
508
- )
509
- self._current = cache_key
510
- self._touch(cache_key)
511
- return f"Cloud provider ready: {cache_key}"
512
-
513
- def detected_cloud_models(self) -> List[Dict[str, str]]:
514
- local_server_providers = {"ollama", "vllm", "lmstudio", "llamacpp"}
515
- items: List[Dict[str, Any]] = []
516
- for provider, config in OPENAI_COMPATIBLE_PROVIDERS.items():
517
- has_key = bool(os.getenv(config["env_key"]) or config.get("api_key_fallback"))
518
- provider_models = PROVIDER_MODEL_CATALOG.get(provider) or [{
519
- "id": config["default_model"],
520
- "name": f"{provider.title()} · {config['default_model']}",
521
- "family": provider.title(),
522
- }]
523
- for model in provider_models:
524
- model_id = model["id"]
525
- local_server = provider in local_server_providers
526
- items.append({
527
- "id": f"{provider}:{model_id}",
528
- "name": model.get("name") or f"{provider.title()} · {model_id}",
529
- "provider": provider,
530
- "family": model.get("family"),
531
- "tag": "local-server" if local_server else "cloud",
532
- "available": has_key,
533
- "requires": config["env_key"] if not has_key else None,
534
- **source_metadata_for_model(provider, model, local_server=local_server),
535
- })
536
- custom = os.getenv("LATTICEAI_CLOUD_MODELS") or ""
537
- for raw in [item.strip() for item in custom.split(",") if item.strip()]:
538
- provider, custom_model = parse_model_ref(raw)
539
- if provider != "local_mlx" and provider in OPENAI_COMPATIBLE_PROVIDERS:
540
- config = OPENAI_COMPATIBLE_PROVIDERS[provider]
541
- items.append({
542
- "id": f"{provider}:{custom_model}",
543
- "name": f"{provider.title()} · {custom_model}",
544
- "provider": provider,
545
- "tag": "cloud",
546
- "available": bool(os.getenv(config["env_key"]) or config.get("api_key_fallback")),
547
- "requires": None,
548
- **source_metadata_for_model(
549
- provider,
550
- {
551
- "id": custom_model,
552
- "name": f"{provider.title()} · {custom_model}",
553
- "family": provider.title(),
554
- },
555
- local_server=provider in local_server_providers,
556
- ),
557
- })
558
- return items
559
-
560
- def _is_cloud_current(self) -> bool:
561
- with self._lock:
562
- return bool(self._current and isinstance(self._cache.get(self._current), CloudModel))
563
-
564
- def _local_server_error_hint(self, cloud: CloudModel, error: Exception) -> str:
565
- raw = str(error)
566
- if cloud.provider == "lmstudio":
567
- base_url = os.getenv("LMSTUDIO_BASE_URL") or OPENAI_COMPATIBLE_PROVIDERS["lmstudio"]["base_url"]
568
- return (
569
- f"LM Studio 연결 실패: {raw}\n\n"
570
- f"- LM Studio의 Developer/Local Server를 켜고 모델을 로드했는지 확인하세요.\n"
571
- f"- Lattice가 보는 주소는 {base_url} 입니다. 포트가 다르면 LMSTUDIO_BASE_URL을 맞춰주세요.\n"
572
- f"- 모델 선택창에는 LM Studio /v1/models에서 감지된 모델만 표시됩니다."
573
- )
574
- return raw
575
-
576
- def _build_prompt(self, message: str, context: Optional[str], tokenizer) -> str:
577
- context = normalize_branding(context)
578
- system = _compose_system(SYSTEM_PROMPT, context)
579
- if hasattr(tokenizer, "apply_chat_template"):
580
- try:
581
- msgs = [{"role": "system", "content": system}, {"role": "user", "content": message}]
582
- return tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
583
- except Exception:
584
- quiet()
585
- return f"<|im_start|>system\n{system}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
586
-
587
- def _build_vlm_prompt(self, model, processor, message: str, context: Optional[str], num_images: int) -> str:
588
- context = normalize_branding(context)
589
- system = _compose_system(SYSTEM_PROMPT, context)
590
- try:
591
- from mlx_vlm import apply_chat_template
592
-
593
- return apply_chat_template(
594
- processor,
595
- model.config,
596
- [
597
- {"role": "system", "content": system},
598
- {"role": "user", "content": message},
599
- ],
600
- add_generation_prompt=True,
601
- num_images=num_images,
602
- )
603
- except Exception as e:
604
- print(f"⚠️ VLM chat template fallback: {e}")
605
- return self._build_prompt(message, context, processor)
606
-
607
- def _unpack_local_cache(self, cached: Any) -> Tuple[Any, Any, Any, str]:
608
- model, tokenizer, draft_model = cached[:3]
609
- loader_kind = str(cached[3]) if len(cached) > 3 else "mlx_vlm"
610
- return model, tokenizer, draft_model, loader_kind
611
-
612
- def _model_snapshot(self, model_id: Optional[str] = None) -> tuple[Optional[str], object | None]:
613
- """Return an immutable request-scoped view of a loaded model.
614
-
615
- Generation must never change ``_current``: that value is a UI/default
616
- preference shared by every request. Capturing the cache entry while
617
- holding the registry lock prevents concurrent requests from selecting
618
- or restoring each other's models.
619
- """
620
- with self._lock:
621
- selected = model_id or self._current
622
- if not selected:
623
- return None, None
624
- cached = self._cache.get(selected)
625
- if cached is None:
626
- raise ValueError(f"Model '{selected}' is not loaded. Load it first via /models/load.")
627
- self._touch(selected)
628
- return selected, cached
629
-
630
- async def generate_as(
631
- self,
632
- model_id: str | None,
633
- message: str,
634
- context: Optional[str] = None,
635
- max_tokens: int = 4096,
636
- temperature: float = 0.2,
637
- image_data: Optional[str] = None,
638
- ) -> str:
639
- """Generate with a request-scoped model without changing the default."""
640
- _selected, cached = self._model_snapshot(model_id)
641
- if cached is None:
642
- return "No model."
643
- return await self._generate_cached(cached, message, context, max_tokens, temperature, image_data)
644
-
645
- async def generate(
646
- self,
647
- message: str,
648
- context: Optional[str] = None,
649
- max_tokens: int = 4096,
650
- temperature: float = 0.2,
651
- image_data: Optional[str] = None,
652
- ) -> str:
653
- return await self.generate_as(None, message, context, max_tokens, temperature, image_data)
654
-
655
- async def _generate_cached(
656
- self,
657
- cached: object,
658
- message: str,
659
- context: Optional[str],
660
- max_tokens: int,
661
- temperature: float,
662
- image_data: Optional[str],
663
- ) -> str:
664
- if isinstance(cached, CloudModel):
665
- return await self._cloud_generate(cached, message, context, max_tokens, temperature)
666
-
667
- model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
668
- use_vlm = loader_kind == "mlx_vlm"
669
- prompt = (
670
- self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
671
- if use_vlm
672
- else self._build_prompt(message, context, tokenizer)
673
- )
674
-
675
- loop = asyncio.get_event_loop()
676
-
677
- def _gen():
678
- import mlx.core as mx # type: ignore[no-redef]
679
-
680
- mx.set_default_device(mx.gpu) # type: ignore[arg-type]
681
- if use_vlm:
682
- from mlx_vlm import generate as vlm_gen
683
- return vlm_gen(model, tokenizer, prompt=prompt, image=self._prep_image(image_data) if image_data else None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
684
- from mlx_lm import generate as lm_gen
685
- return lm_gen(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
686
- result = await loop.run_in_executor(executor, _gen)
687
- # mlx-vlm might return a GenerationResult object; extract the text
688
- if hasattr(result, "text"):
689
- return normalize_branding(result.text)
690
- return normalize_branding(str(result))
691
-
692
- async def _cloud_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> str:
693
- context = normalize_branding(context)
694
- system = _compose_system(SYSTEM_PROMPT, context)
695
- try:
696
- response = await cloud.client.chat.completions.create(
697
- model=cloud.model,
698
- messages=[
699
- {"role": "system", "content": system},
700
- {"role": "user", "content": message},
701
- ],
702
- max_tokens=max_tokens,
703
- temperature=temperature,
704
- )
705
- except Exception as e:
706
- raise RuntimeError(self._local_server_error_hint(cloud, e)) from e
707
- return normalize_branding(response.choices[0].message.content or "")
708
-
709
- async def stream_generate_as(
710
- self,
711
- model_id: str | None,
712
- message: str,
713
- context: Optional[str] = None,
714
- max_tokens: int = 4096,
715
- temperature: float = 0.2,
716
- image_data: Optional[str] = None,
717
- ) -> AsyncIterator[str]:
718
- """Stream with a request-scoped model without changing the default."""
719
- _selected, cached = self._model_snapshot(model_id)
720
- if cached is None:
721
- yield "No model."
722
- return
723
- if isinstance(cached, CloudModel):
724
- async for chunk in self._cloud_stream_generate(cached, message, context, max_tokens, temperature):
725
- yield chunk
726
- return
727
-
728
- model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
729
- use_vlm = loader_kind == "mlx_vlm"
730
- prompt = (
731
- self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
732
- if use_vlm
733
- else self._build_prompt(message, context, tokenizer)
734
- )
735
- loop = asyncio.get_event_loop()
736
- queue: "asyncio.Queue[Any]" = asyncio.Queue()
737
-
738
- def _stream():
739
- import mlx.core as mx # type: ignore[no-redef]
740
-
741
- mx.set_default_device(mx.gpu) # type: ignore[arg-type]
742
- try:
743
- if use_vlm:
744
- from mlx_vlm import stream_generate as vlm_stream
745
- gen = vlm_stream(model, tokenizer, prompt=prompt, image=self._prep_image(image_data) if image_data else None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
746
- else:
747
- from mlx_lm import stream_generate as lm_stream
748
- gen = lm_stream(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
749
-
750
- for chunk in gen:
751
- text = chunk.text if hasattr(chunk, "text") else (chunk[0] if isinstance(chunk, tuple) else str(chunk))
752
- loop.call_soon_threadsafe(queue.put_nowait, text)
753
- except Exception as exc:
754
- loop.call_soon_threadsafe(
755
- queue.put_nowait, _stream_failure("MLX chat stream failed", exc)
756
- )
757
- finally:
758
- loop.call_soon_threadsafe(queue.put_nowait, None)
759
-
760
- loop.run_in_executor(executor, _stream)
761
- async for chunk in self._drain_stream_queue(queue):
762
- yield chunk
763
-
764
- @staticmethod
765
- async def _drain_stream_queue(queue: "asyncio.Queue[Any]") -> AsyncIterator[str]:
766
- """Yield worker-thread chunks until the terminator; raise failures.
767
-
768
- ``None`` terminates the stream. A :class:`ModelStreamError` on the
769
- queue is a backend failure envelope, not model text, so it is raised
770
- into the consuming coroutine — callers must never be able to mistake
771
- it for an answer.
772
- """
773
- while True:
774
- chunk = await queue.get()
775
- if chunk is None:
776
- return
777
- if isinstance(chunk, ModelStreamError):
778
- raise chunk
779
- yield normalize_branding(chunk)
780
-
781
- async def stream_generate(
782
- self,
783
- message: str,
784
- context: Optional[str] = None,
785
- max_tokens: int = 4096,
786
- temperature: float = 0.2,
787
- image_data: Optional[str] = None,
788
- ) -> AsyncIterator[str]:
789
- async for chunk in self.stream_generate_as(
790
- None, message, context, max_tokens, temperature, image_data
791
- ):
792
- yield chunk
793
-
794
- async def _cloud_stream_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> AsyncIterator[str]:
795
- context = normalize_branding(context)
796
- system = _compose_system(SYSTEM_PROMPT, context)
797
- try:
798
- stream = await cloud.client.chat.completions.create(
799
- model=cloud.model,
800
- messages=[
801
- {"role": "system", "content": system},
802
- {"role": "user", "content": message},
803
- ],
804
- max_tokens=max_tokens,
805
- temperature=temperature,
806
- stream=True,
807
- )
808
- except Exception as exc:
809
- # Same invariant as the MLX path: a backend that never produced a
810
- # token failed, and that is an error — not the model's answer.
811
- raise ModelStreamError(self._local_server_error_hint(cloud, exc)) from exc
812
- async for event in stream:
813
- if not event.choices:
814
- continue
815
- delta = event.choices[0].delta.content
816
- if delta:
817
- yield normalize_branding(delta)
818
-
819
- def _prep_image(self, image_data: Optional[str]) -> Optional[Image.Image]:
820
- if not image_data:
821
- return None
822
- try:
823
- image = Image.open(io.BytesIO(base64.b64decode(image_data))).convert("RGB")
824
- print(f"🖼️ VLM image decoded: {image.width}x{image.height}")
825
- return image
826
- except Exception as e:
827
- print(f"⚠️ VLM image decode failed: {e}")
828
- return None
829
-
830
- # ── Document Generation Pipeline ──────────────────────────────────────
831
-
832
- async def generate_document(
833
- self,
834
- message: str,
835
- system_prompt: str,
836
- *,
837
- max_tokens: int = 8192,
838
- temperature: float = 0.3,
839
- ) -> str:
840
- """Generate a document using a specialized system prompt with graph context."""
841
- return await self.generate_document_as(
842
- None,
843
- message,
844
- system_prompt,
845
- max_tokens=max_tokens,
846
- temperature=temperature,
847
- )
848
-
849
- async def generate_document_as(
850
- self,
851
- model_id: str | None,
852
- message: str,
853
- system_prompt: str,
854
- *,
855
- max_tokens: int = 8192,
856
- temperature: float = 0.3,
857
- ) -> str:
858
- """Generate a document with a request-scoped model."""
859
- _selected, cached = self._model_snapshot(model_id)
860
- if cached is None:
861
- return "No model loaded."
862
-
863
- if isinstance(cached, CloudModel):
864
- return await self._cloud_generate_document(cached, message, system_prompt, max_tokens, temperature)
865
-
866
- model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
867
- if hasattr(tokenizer, "apply_chat_template"):
868
- try:
869
- msgs = [
870
- {"role": "system", "content": system_prompt},
871
- {"role": "user", "content": message},
872
- ]
873
- prompt = tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
874
- except Exception:
875
- prompt = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
876
- else:
877
- prompt = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
878
-
879
- loop = asyncio.get_event_loop()
880
- def _gen():
881
- import mlx.core as mx # type: ignore[no-redef]
882
-
883
- mx.set_default_device(mx.gpu) # type: ignore[arg-type]
884
- if loader_kind == "mlx_vlm":
885
- from mlx_vlm import generate as vlm_gen
886
- return vlm_gen(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
887
- from mlx_lm import generate as lm_gen
888
- return lm_gen(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
889
- result = await loop.run_in_executor(executor, _gen)
890
- if hasattr(result, "text"):
891
- return normalize_branding(result.text)
892
- return normalize_branding(str(result))
893
-
894
- async def _cloud_generate_document(self, cloud: CloudModel, message: str, system_prompt: str, max_tokens: int, temperature: float) -> str:
895
- try:
896
- response = await cloud.client.chat.completions.create(
897
- model=cloud.model,
898
- messages=[
899
- {"role": "system", "content": system_prompt},
900
- {"role": "user", "content": message},
901
- ],
902
- max_tokens=max_tokens,
903
- temperature=temperature,
904
- )
905
- except Exception as e:
906
- raise RuntimeError(self._local_server_error_hint(cloud, e)) from e
907
- return normalize_branding(response.choices[0].message.content or "")
908
-
909
- async def stream_generate_document(
910
- self,
911
- message: str,
912
- system_prompt: str,
913
- *,
914
- max_tokens: int = 8192,
915
- temperature: float = 0.3,
916
- ) -> AsyncIterator[str]:
917
- """Stream document generation with specialized system prompt."""
918
- async for chunk in self.stream_generate_document_as(
919
- None,
920
- message,
921
- system_prompt,
922
- max_tokens=max_tokens,
923
- temperature=temperature,
924
- ):
925
- yield chunk
926
-
927
- async def stream_generate_document_as(
928
- self,
929
- model_id: str | None,
930
- message: str,
931
- system_prompt: str,
932
- *,
933
- max_tokens: int = 8192,
934
- temperature: float = 0.3,
935
- ) -> AsyncIterator[str]:
936
- """Stream a document with a request-scoped model."""
937
- _selected, cached = self._model_snapshot(model_id)
938
- if cached is None:
939
- yield "No model loaded."
940
- return
941
-
942
- if isinstance(cached, CloudModel):
943
- async for chunk in self._cloud_stream_document(cached, message, system_prompt, max_tokens, temperature):
944
- yield chunk
945
- return
946
-
947
- model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
948
- if hasattr(tokenizer, "apply_chat_template"):
949
- try:
950
- msgs = [
951
- {"role": "system", "content": system_prompt},
952
- {"role": "user", "content": message},
953
- ]
954
- prompt = tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
955
- except Exception:
956
- prompt = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
957
- else:
958
- prompt = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
959
-
960
- loop = asyncio.get_event_loop()
961
- queue: "asyncio.Queue[Any]" = asyncio.Queue()
962
-
963
- def _stream():
964
- import mlx.core as mx # type: ignore[no-redef]
965
-
966
- mx.set_default_device(mx.gpu) # type: ignore[arg-type]
967
- try:
968
- if loader_kind == "mlx_vlm":
969
- from mlx_vlm import stream_generate as vlm_stream
970
- gen = vlm_stream(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
971
- else:
972
- from mlx_lm import stream_generate as lm_stream
973
- gen = lm_stream(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
974
- for chunk in gen:
975
- text = chunk.text if hasattr(chunk, "text") else (chunk[0] if isinstance(chunk, tuple) else str(chunk))
976
- loop.call_soon_threadsafe(queue.put_nowait, text)
977
- except Exception as exc:
978
- loop.call_soon_threadsafe(
979
- queue.put_nowait, _stream_failure("MLX document stream failed", exc)
980
- )
981
- finally:
982
- loop.call_soon_threadsafe(queue.put_nowait, None)
983
-
984
- loop.run_in_executor(executor, _stream)
985
- async for chunk in self._drain_stream_queue(queue):
986
- yield chunk
987
-
988
- async def _cloud_stream_document(self, cloud: CloudModel, message: str, system_prompt: str, max_tokens: int, temperature: float) -> AsyncIterator[str]:
989
- try:
990
- stream = await cloud.client.chat.completions.create(
991
- model=cloud.model,
992
- messages=[
993
- {"role": "system", "content": system_prompt},
994
- {"role": "user", "content": message},
995
- ],
996
- max_tokens=max_tokens,
997
- temperature=temperature,
998
- stream=True,
999
- )
1000
- except Exception as exc:
1001
- raise ModelStreamError(self._local_server_error_hint(cloud, exc)) from exc
1002
- async for event in stream:
1003
- if not event.choices:
1004
- continue
1005
- delta = event.choices[0].delta.content
1006
- if delta:
1007
- yield normalize_branding(delta)