ltcai 8.1.0 → 8.3.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 (123) hide show
  1. package/README.md +29 -48
  2. package/docs/CHANGELOG.md +70 -874
  3. package/docs/COMMUNITY_AND_PLUGINS.md +43 -0
  4. package/docs/DEVELOPMENT.md +26 -3
  5. package/docs/LEGACY_COMPATIBILITY.md +23 -1
  6. package/docs/ONBOARDING.md +38 -0
  7. package/docs/TRUST_MODEL.md +26 -26
  8. package/docs/WHY_LATTICE.md +29 -25
  9. package/docs/kg-schema.md +7 -5
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/ingest.py +40 -8
  12. package/lattice_brain/graph/schema.py +6 -0
  13. package/lattice_brain/runtime/agent_runtime.py +22 -37
  14. package/lattice_brain/runtime/multi_agent.py +1 -1
  15. package/lattice_brain/workflow.py +26 -1
  16. package/latticeai/__init__.py +1 -1
  17. package/latticeai/api/computer_use.py +2 -0
  18. package/latticeai/api/knowledge_graph.py +33 -0
  19. package/latticeai/api/local_files.py +2 -0
  20. package/latticeai/api/memory.py +13 -0
  21. package/latticeai/api/tools.py +1 -0
  22. package/latticeai/api/workflow_designer.py +5 -4
  23. package/latticeai/app_factory.py +30 -28
  24. package/latticeai/brain/__init__.py +1 -1
  25. package/latticeai/core/config.py +10 -0
  26. package/latticeai/core/legacy_compatibility.py +174 -0
  27. package/latticeai/core/marketplace.py +1 -1
  28. package/latticeai/core/tool_registry.py +10 -0
  29. package/latticeai/core/workspace_graph_trace.py +132 -0
  30. package/latticeai/core/workspace_memory.py +75 -0
  31. package/latticeai/core/workspace_os.py +135 -1283
  32. package/latticeai/core/workspace_os_utils.py +132 -0
  33. package/latticeai/core/workspace_permissions.py +99 -0
  34. package/latticeai/core/workspace_plugins.py +97 -0
  35. package/latticeai/core/workspace_runs.py +612 -0
  36. package/latticeai/core/workspace_skills.py +114 -0
  37. package/latticeai/core/workspace_snapshots.py +195 -0
  38. package/latticeai/core/workspace_timeline.py +107 -0
  39. package/latticeai/runtime/audit_runtime.py +64 -0
  40. package/latticeai/runtime/config_runtime.py +4 -0
  41. package/latticeai/runtime/sso_runtime.py +52 -0
  42. package/latticeai/services/architecture_readiness.py +37 -3
  43. package/latticeai/services/memory_service.py +215 -7
  44. package/latticeai/services/model_capability_registry.py +68 -0
  45. package/latticeai/services/model_engines.py +594 -0
  46. package/latticeai/services/model_loading.py +482 -0
  47. package/latticeai/services/model_recommendation.py +5 -0
  48. package/latticeai/services/model_runtime.py +131 -875
  49. package/latticeai/services/product_readiness.py +41 -18
  50. package/llm_router.py +7 -28
  51. package/mcp_registry.py +7 -24
  52. package/package.json +1 -1
  53. package/scripts/pts-claudecode-discord-bridge.mjs +2 -1
  54. package/src-tauri/Cargo.lock +1 -1
  55. package/src-tauri/Cargo.toml +1 -1
  56. package/src-tauri/tauri.conf.json +1 -1
  57. package/static/app/asset-manifest.json +11 -11
  58. package/static/app/assets/{Act-BOO66G-c.js → Act-D5mo4tE4.js} +1 -1
  59. package/static/app/assets/{Brain-C6lEYiD7.js → Brain-BVWyQw8A.js} +1 -1
  60. package/static/app/assets/{Capture-TATXBRDw.js → Capture-C1R6GT0t.js} +1 -1
  61. package/static/app/assets/{Library-DK4FIp8a.js → Library-C2wIxpTs.js} +1 -1
  62. package/static/app/assets/{System-Bgs6Ql7x.js → System-DE5GRyQR.js} +1 -1
  63. package/static/app/assets/index-CoiuIFFP.js +16 -0
  64. package/static/app/assets/index-ty1iGgZu.css +2 -0
  65. package/static/app/assets/{primitives-B70WOra0.js → primitives-BdsUNXa6.js} +1 -1
  66. package/static/app/assets/{textarea-Czrd9gwM.js → textarea-e7qaj6Hm.js} +1 -1
  67. package/static/app/index.html +2 -2
  68. package/static/sw.js +1 -1
  69. package/tools/__init__.py +2 -1
  70. package/tools/computer.py +21 -0
  71. package/docs/CARRYOVER_AUDIT_v3.6.0.md +0 -61
  72. package/docs/HANDOVER_v3.6.0.md +0 -46
  73. package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +0 -56
  74. package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +0 -49
  75. package/docs/V2_ARCHITECTURE.md +0 -561
  76. package/docs/V3_2_AUDIT.md +0 -82
  77. package/docs/V3_BACKEND_ARCHITECTURE.md +0 -138
  78. package/docs/V3_FRONTEND.md +0 -146
  79. package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +0 -65
  80. package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +0 -70
  81. package/docs/V4_1_VALIDATION_REPORT.md +0 -47
  82. package/docs/V4_2_BRAIN_CORE_ARCHITECTURE.md +0 -97
  83. package/docs/V4_2_STORAGE_MIGRATION_REPORT.md +0 -91
  84. package/docs/V4_2_VALIDATION_REPORT.md +0 -89
  85. package/docs/V4_3_2_DEADCODE_AUDIT_REPORT.md +0 -174
  86. package/docs/V4_3_2_DOCUMENTATION_CLEANUP_REPORT.md +0 -81
  87. package/docs/V4_3_2_GITHUB_VERCEL_CHECK_REPORT.md +0 -75
  88. package/docs/V4_3_2_GRAPH_UX_REPORT.md +0 -48
  89. package/docs/V4_3_2_INDEPENDENT_AUDIT_PACKAGE.md +0 -209
  90. package/docs/V4_3_2_PRODUCT_POLISH_REPORT.md +0 -57
  91. package/docs/V4_3_2_SELF_AUDIT_REPORT.md +0 -63
  92. package/docs/V4_3_2_VALIDATION_REPORT.md +0 -97
  93. package/docs/V4_3_3_VALIDATION_REPORT.md +0 -46
  94. package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +0 -69
  95. package/docs/V4_3_PRIVACY_AUDIT.md +0 -60
  96. package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +0 -53
  97. package/docs/V4_3_VALIDATION_REPORT.md +0 -58
  98. package/docs/V4_4_0_EXTRACTION_REPORT.md +0 -239
  99. package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +0 -49
  100. package/docs/V4_5_0_GRAPH_UX_REPORT.md +0 -34
  101. package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +0 -40
  102. package/docs/V4_5_0_ONBOARDING_REPORT.md +0 -31
  103. package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +0 -49
  104. package/docs/V4_5_0_VALIDATION_REPORT.md +0 -60
  105. package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +0 -33
  106. package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +0 -37
  107. package/docs/V4_5_1_NAVIGATION_REPORT.md +0 -37
  108. package/docs/V4_5_1_ONBOARDING_REPORT.md +0 -29
  109. package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +0 -61
  110. package/docs/V4_5_1_RC_ARTIFACTS.md +0 -44
  111. package/docs/V4_5_1_UX_REPORT.md +0 -45
  112. package/docs/V4_5_1_VALIDATION_REPORT.md +0 -55
  113. package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +0 -30
  114. package/docs/V4_6_0_LIVING_BRAIN_EXPERIENCE_REPORT.md +0 -72
  115. package/docs/V4_6_1_RELEASE_REFRESH_REPORT.md +0 -42
  116. package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +0 -42
  117. package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +0 -49
  118. package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +0 -62
  119. package/docs/V4_BRAIN_ARCHITECTURE.md +0 -322
  120. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +0 -555
  121. package/docs/V4_IMPLEMENTATION_PLAN.md +0 -470
  122. package/static/app/assets/index-Bvv79nre.js +0 -16
  123. package/static/app/assets/index-Dslqglia.css +0 -2
@@ -0,0 +1,594 @@
1
+ """Engine server management, pull, install and support logic extracted from model_runtime monolith.
2
+
3
+ This module is the home for engine-specific server starting, model pulling and install flows.
4
+ model_runtime re-exports the names to keep exact legacy globals, callers and monkeypatching working.
5
+ Circular imports are avoided by late imports inside functions.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ import json
11
+ import logging
12
+ import os
13
+ import platform
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ import urllib.error
20
+ import urllib.request
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ from fastapi import HTTPException
25
+
26
+
27
+ def _progress_payload(*args, **kwargs) -> Dict[str, object]:
28
+ try:
29
+ from latticeai.services.model_runtime import model_download_progress_payload
30
+ except Exception:
31
+ return {}
32
+ return model_download_progress_payload(*args, **kwargs)
33
+
34
+
35
+ LOCAL_SERVER_PROCESSES: Dict[str, subprocess.Popen] = {}
36
+ VLLM_METAL_ENV = Path.home() / ".venv-vllm-metal"
37
+ VLLM_METAL_BIN = VLLM_METAL_ENV / "bin" / "vllm"
38
+ VLLM_METAL_PYTHON = VLLM_METAL_ENV / "bin" / "python"
39
+ LMSTUDIO_BUNDLED_CLI = Path("/Applications/LM Studio.app/Contents/Resources/app/.webpack/lms")
40
+
41
+
42
+ def local_binary(binary: str) -> Optional[str]:
43
+ found = shutil.which(binary)
44
+ if found:
45
+ return found
46
+ if platform.system() == "Windows":
47
+ for candidate in windows_binary_candidates(binary):
48
+ if candidate.exists():
49
+ return str(candidate)
50
+ return None
51
+
52
+
53
+ def windows_binary_candidates(binary: str) -> List[Path]:
54
+ local_appdata = os.environ.get("LOCALAPPDATA", "")
55
+ program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
56
+ program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
57
+ candidates = {
58
+ "ollama": [
59
+ Path(local_appdata) / "Programs" / "Ollama" / "ollama.exe" if local_appdata else None,
60
+ Path(program_files) / "Ollama" / "ollama.exe",
61
+ ],
62
+ "lms": [
63
+ Path(local_appdata) / "Programs" / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe" if local_appdata else None,
64
+ Path(program_files) / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe",
65
+ ],
66
+ "nvidia-smi": [
67
+ Path(program_files) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe",
68
+ Path(program_files_x86) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe",
69
+ ],
70
+ }
71
+ return [item for item in candidates.get(binary, []) if item is not None]
72
+
73
+
74
+ def find_lmstudio_cli() -> Optional[str]:
75
+ cli = local_binary("lms")
76
+ if cli:
77
+ return cli
78
+ if LMSTUDIO_BUNDLED_CLI.exists():
79
+ return str(LMSTUDIO_BUNDLED_CLI)
80
+ return None
81
+
82
+
83
+ def vllm_executable() -> Optional[str]:
84
+ found = shutil.which("vllm")
85
+ if found:
86
+ return found
87
+ if VLLM_METAL_BIN.exists():
88
+ return str(VLLM_METAL_BIN)
89
+ return None
90
+
91
+
92
+ def vllm_metal_python() -> Optional[str]:
93
+ if VLLM_METAL_PYTHON.exists():
94
+ return str(VLLM_METAL_PYTHON)
95
+ return None
96
+
97
+
98
+ def _json_request(
99
+ url: str,
100
+ *,
101
+ method: str = "GET",
102
+ headers: Optional[Dict[str, str]] = None,
103
+ payload: Optional[Dict[str, Any]] = None,
104
+ timeout: float = 10.0,
105
+ ) -> Dict[str, object]:
106
+ data = None
107
+ req_headers = dict(headers or {})
108
+ if payload is not None:
109
+ data = json.dumps(payload).encode("utf-8")
110
+ req_headers.setdefault("Content-Type", "application/json")
111
+ req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
112
+ with urllib.request.urlopen(req, timeout=timeout) as res:
113
+ raw = res.read().decode("utf-8", errors="replace")
114
+ if not raw.strip():
115
+ return {}
116
+ return json.loads(raw)
117
+
118
+
119
+ def lmstudio_api_base() -> str:
120
+ # late to avoid issues
121
+ try:
122
+ from latticeai.services.model_runtime import OPENAI_COMPATIBLE_PROVIDERS # type: ignore
123
+ prov = OPENAI_COMPATIBLE_PROVIDERS
124
+ except Exception:
125
+ prov = {}
126
+ return (os.getenv("LMSTUDIO_BASE_URL") or (prov.get("lmstudio", {}) or {}).get("base_url", "http://localhost:1234/v1")).rstrip("/")
127
+
128
+
129
+ def lmstudio_native_api_base() -> str:
130
+ base = lmstudio_api_base()
131
+ return base[:-3] if base.endswith("/v1") else base
132
+
133
+
134
+ def ensure_lmstudio_server() -> None:
135
+ base_url = lmstudio_native_api_base()
136
+ try:
137
+ _json_request(f"{base_url}/api/v1/models", headers={"Authorization": "Bearer lmstudio"}, timeout=2.5)
138
+ return
139
+ except Exception:
140
+ pass
141
+
142
+ cli = find_lmstudio_cli()
143
+ if not cli:
144
+ raise HTTPException(status_code=400, detail="LM Studio CLI를 찾지 못했습니다. LM Studio를 설치한 뒤 다시 시도하세요.")
145
+
146
+ try:
147
+ subprocess.Popen(
148
+ [cli, "server", "start"],
149
+ stdout=subprocess.DEVNULL,
150
+ stderr=subprocess.DEVNULL,
151
+ start_new_session=True,
152
+ )
153
+ except Exception as e:
154
+ raise HTTPException(status_code=500, detail=f"LM Studio 서버 시작 실패: {e}")
155
+
156
+ deadline = time.time() + 45
157
+ while time.time() < deadline:
158
+ try:
159
+ _json_request(f"{base_url}/api/v1/models", headers={"Authorization": "Bearer lmstudio"}, timeout=2.5)
160
+ return
161
+ except Exception:
162
+ time.sleep(1)
163
+ raise HTTPException(status_code=500, detail="LM Studio Local Server를 자동으로 시작하지 못했습니다.")
164
+
165
+
166
+ def ensure_ollama_server() -> None:
167
+ ollama = local_binary("ollama")
168
+ if not ollama:
169
+ raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
170
+ try:
171
+ probe = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=3, check=False)
172
+ if probe.returncode == 0:
173
+ return
174
+ except Exception:
175
+ pass
176
+ subprocess.Popen(
177
+ [ollama, "serve"],
178
+ stdout=subprocess.DEVNULL,
179
+ stderr=subprocess.DEVNULL,
180
+ start_new_session=True,
181
+ )
182
+ deadline = time.time() + 20
183
+ while time.time() < deadline:
184
+ try:
185
+ probe = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=3, check=False)
186
+ if probe.returncode == 0:
187
+ return
188
+ except Exception:
189
+ pass
190
+ time.sleep(0.5)
191
+ raise HTTPException(status_code=500, detail="Ollama 서버를 자동으로 시작하지 못했습니다.")
192
+
193
+
194
+ def get_openai_compatible_server_models(provider: str) -> List[str]:
195
+ from latticeai.services.model_runtime import OPENAI_COMPATIBLE_PROVIDERS, get_lmstudio_models
196
+
197
+ if provider == "lmstudio":
198
+ models = []
199
+ for item in get_lmstudio_models():
200
+ if not isinstance(item, dict):
201
+ continue
202
+ key = str(item.get("key") or "").strip()
203
+ loaded_instances = item.get("loaded_instances") or []
204
+ if loaded_instances:
205
+ instance_ids = [
206
+ str(instance.get("id") or "").strip()
207
+ for instance in loaded_instances
208
+ if isinstance(instance, dict) and instance.get("id")
209
+ ]
210
+ models.extend(instance_ids or ([key] if key else []))
211
+ return list(dict.fromkeys([model for model in models if model]))
212
+
213
+ config = OPENAI_COMPATIBLE_PROVIDERS.get(provider) or {}
214
+ base_url = os.getenv(config.get("base_url_env", "")) if config.get("base_url_env") else None
215
+ base_url = (base_url or config.get("base_url") or "").rstrip("/")
216
+ if not base_url:
217
+ return []
218
+
219
+ api_key = os.getenv(config.get("env_key", "")) or config.get("api_key_fallback") or provider
220
+ req = urllib.request.Request(
221
+ f"{base_url}/models",
222
+ headers={"Authorization": f"Bearer {api_key}"},
223
+ method="GET",
224
+ )
225
+ try:
226
+ with urllib.request.urlopen(req, timeout=2.5) as res:
227
+ payload = json.loads(res.read().decode("utf-8", errors="replace"))
228
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
229
+ return []
230
+
231
+ models = []
232
+ for item in payload.get("data") or []:
233
+ model_id = item.get("id") if isinstance(item, dict) else None
234
+ if model_id:
235
+ models.append(str(model_id))
236
+ return models
237
+
238
+
239
+ def wait_for_openai_compatible_server(provider: str, model_name: Optional[str] = None, timeout: int = 45) -> bool:
240
+ deadline = time.time() + timeout
241
+ while time.time() < deadline:
242
+ models = get_openai_compatible_server_models(provider)
243
+ if models and (not model_name or model_name in models):
244
+ return True
245
+ time.sleep(1)
246
+ return False
247
+
248
+
249
+ def ensure_vllm_server(model_name: str) -> None:
250
+ from latticeai.services.model_runtime import download_hf_model, hf_model_dir, hf_model_ready
251
+
252
+ served_models = get_openai_compatible_server_models("vllm")
253
+ if model_name in served_models:
254
+ return
255
+ vllm_bin = vllm_executable()
256
+ vllm_metal_py = vllm_metal_python()
257
+ if not vllm_bin and not vllm_metal_py and importlib.util.find_spec("vllm") is None:
258
+ raise HTTPException(status_code=400, detail="vLLM runtime이 설치되지 않았습니다.")
259
+
260
+ local_dir = hf_model_dir(model_name)
261
+ if not vllm_metal_py and not hf_model_ready(model_name, "vllm"):
262
+ download_hf_model(model_name, "vllm")
263
+
264
+ running = LOCAL_SERVER_PROCESSES.get("vllm")
265
+ if running and running.poll() is None:
266
+ running.terminate()
267
+ try:
268
+ running.wait(timeout=10)
269
+ except subprocess.TimeoutExpired:
270
+ running.kill()
271
+ elif served_models:
272
+ raise HTTPException(status_code=409, detail="다른 vLLM 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
273
+
274
+ running = LOCAL_SERVER_PROCESSES.get("vllm")
275
+ if running and running.poll() is None:
276
+ return
277
+
278
+ host_args = ["--host", "127.0.0.1", "--port", "8000"]
279
+ if vllm_metal_py:
280
+ command = [vllm_metal_py, "-m", "vllm_metal.server", "--model", model_name, *host_args]
281
+ elif vllm_bin:
282
+ command = [vllm_bin, "serve", str(local_dir), "--served-model-name", model_name, *host_args]
283
+ else:
284
+ command = [sys.executable, "-m", "vllm.entrypoints.openai.api_server", "--model", str(local_dir), "--served-model-name", model_name, *host_args]
285
+ LOCAL_SERVER_PROCESSES["vllm"] = subprocess.Popen(
286
+ command,
287
+ stdout=subprocess.DEVNULL,
288
+ stderr=subprocess.DEVNULL,
289
+ start_new_session=True,
290
+ )
291
+ if not wait_for_openai_compatible_server("vllm", model_name, timeout=90):
292
+ raise HTTPException(status_code=500, detail="vLLM 서버가 모델을 자동 로드하지 못했습니다.")
293
+
294
+
295
+ def ensure_llamacpp_server(model_name: str) -> None:
296
+ from latticeai.services.model_runtime import download_hf_model, hf_model_dir, hf_model_ready
297
+
298
+ served_models = get_openai_compatible_server_models("llamacpp")
299
+ if model_name in served_models:
300
+ return
301
+ running = LOCAL_SERVER_PROCESSES.get("llamacpp")
302
+ if running and running.poll() is None:
303
+ running.terminate()
304
+ try:
305
+ running.wait(timeout=10)
306
+ except subprocess.TimeoutExpired:
307
+ running.kill()
308
+ elif served_models:
309
+ raise HTTPException(status_code=409, detail="다른 llama.cpp 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
310
+ if not shutil.which("llama-server"):
311
+ raise HTTPException(status_code=400, detail="llama.cpp가 설치되지 않았습니다.")
312
+ if not hf_model_ready(model_name, "llamacpp"):
313
+ download_hf_model(model_name, "llamacpp")
314
+
315
+ gguf_files = sorted(hf_model_dir(model_name).rglob("*.gguf"))
316
+ if not gguf_files:
317
+ raise HTTPException(status_code=500, detail="다운로드된 GGUF 파일을 찾지 못했습니다.")
318
+
319
+ preferred = next((p for p in gguf_files if "q4_k_m" in p.name.lower()), None)
320
+ model_file = preferred or gguf_files[0]
321
+ LOCAL_SERVER_PROCESSES["llamacpp"] = subprocess.Popen(
322
+ [
323
+ "llama-server",
324
+ "-m",
325
+ str(model_file),
326
+ "--alias",
327
+ model_name,
328
+ "--host",
329
+ "127.0.0.1",
330
+ "--port",
331
+ "8080",
332
+ ],
333
+ stdout=subprocess.DEVNULL,
334
+ stderr=subprocess.DEVNULL,
335
+ start_new_session=True,
336
+ )
337
+ if not wait_for_openai_compatible_server("llamacpp", model_name, timeout=45):
338
+ raise HTTPException(status_code=500, detail="llama.cpp 서버가 모델을 자동 로드하지 못했습니다.")
339
+
340
+
341
+ def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict[str, object]:
342
+ ollama = local_binary("ollama")
343
+ if not ollama:
344
+ raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
345
+ if progress_emit:
346
+ progress_emit(_progress_payload(
347
+ "download",
348
+ "Ollama 모델 다운로드를 시작합니다.",
349
+ percent=0,
350
+ detail=model_name,
351
+ indeterminate=True,
352
+ ))
353
+ process = subprocess.Popen(
354
+ [ollama, "pull", model_name],
355
+ stdout=subprocess.PIPE,
356
+ stderr=subprocess.STDOUT,
357
+ text=True,
358
+ bufsize=1,
359
+ )
360
+ last_percent: Optional[float] = None
361
+ lines: List[str] = []
362
+ try:
363
+ assert process.stdout is not None
364
+ for raw_line in process.stdout:
365
+ for part in re.split(r"[\r\n]+", raw_line):
366
+ line = part.strip()
367
+ if not line:
368
+ continue
369
+ lines.append(line)
370
+ match = re.search(r"(\d{1,3}(?:\.\d+)?)\s*%", line)
371
+ if match:
372
+ last_percent = min(100.0, float(match.group(1)))
373
+ if progress_emit:
374
+ progress_emit(_progress_payload(
375
+ "download",
376
+ "Ollama 모델 다운로드 중입니다.",
377
+ percent=last_percent,
378
+ detail=line[-180:],
379
+ eta_seconds=None,
380
+ indeterminate=False,
381
+ ))
382
+ elif progress_emit:
383
+ progress_emit(_progress_payload(
384
+ "download",
385
+ "Ollama 모델 다운로드 중입니다.",
386
+ percent=last_percent,
387
+ detail=line[-180:],
388
+ eta_seconds=None,
389
+ indeterminate=last_percent is None,
390
+ ))
391
+ returncode = process.wait()
392
+ except Exception:
393
+ process.kill()
394
+ raise
395
+
396
+ if returncode != 0:
397
+ tail = "\n".join(lines[-12:])
398
+ raise HTTPException(status_code=500, detail=tail[-2000:] or "Ollama 모델 다운로드 실패")
399
+
400
+ if progress_emit:
401
+ progress_emit(_progress_payload(
402
+ "download",
403
+ "Ollama 모델 다운로드가 완료되었습니다.",
404
+ percent=100,
405
+ detail=model_name,
406
+ eta_seconds=0,
407
+ indeterminate=False,
408
+ ))
409
+ return {"provider": "ollama", "model": model_name, "returncode": returncode}
410
+
411
+
412
+ def get_ollama_pulled_models() -> set:
413
+ ollama = local_binary("ollama")
414
+ if not ollama:
415
+ return set()
416
+ try:
417
+ result = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=5, check=False)
418
+ pulled = set()
419
+ for line in result.stdout.splitlines()[1:]:
420
+ parts = line.split()
421
+ if parts:
422
+ pulled.add(parts[0])
423
+ return pulled
424
+ except Exception:
425
+ return set()
426
+
427
+
428
+ def engine_support_status(engine: str) -> Dict[str, object]:
429
+ if engine != "vllm":
430
+ return {"supported": True, "reason": None}
431
+ is_apple_silicon = sys.platform == "darwin" and platform.machine() == "arm64"
432
+ if sys.platform.startswith("win"):
433
+ return {"supported": False, "reason": "vLLM은 Windows native 자동 설치보다 WSL2/Linux 환경을 권장합니다."}
434
+ if sys.platform == "darwin" and not is_apple_silicon:
435
+ return {"supported": False, "reason": "vLLM Metal 자동 설치는 Apple Silicon macOS에서만 지원됩니다."}
436
+ if sys.version_info >= (3, 13) and is_apple_silicon:
437
+ return {"supported": True, "reason": "현재 환경에서는 vLLM Metal 전용 런타임으로 설치합니다."}
438
+ if sys.version_info >= (3, 13):
439
+ return {"supported": False, "reason": "vLLM 설치는 현재 Python 3.13 이하 또는 별도 전용 런타임이 필요합니다."}
440
+ return {"supported": True, "reason": None}
441
+
442
+
443
+ def install_engine(engine: str) -> Dict[str, Any]:
444
+ from latticeai.services.model_runtime import BASE_DIR, ENGINE_INSTALLERS, engine_installed
445
+
446
+ if engine not in ENGINE_INSTALLERS:
447
+ raise HTTPException(status_code=400, detail="지원하지 않는 엔진입니다.")
448
+ installer = ENGINE_INSTALLERS[engine]
449
+ required_binary = installer.get("requires_binary")
450
+ if required_binary and shutil.which(required_binary) is None:
451
+ raise HTTPException(status_code=400, detail=f"{required_binary}가 설치되어 있지 않아 자동 설치할 수 없습니다.")
452
+ command = installer["command"]
453
+ run_kwargs = {
454
+ "cwd": str(BASE_DIR),
455
+ "capture_output": True,
456
+ "text": True,
457
+ "timeout": 900,
458
+ "check": False,
459
+ }
460
+
461
+ if engine == "vllm" and sys.platform == "darwin" and platform.machine() == "arm64":
462
+ command = [
463
+ "/bin/bash",
464
+ "-lc",
465
+ "set -euo pipefail; "
466
+ "if [ ! -x /opt/homebrew/bin/python3.12 ]; then brew install python@3.12; fi; "
467
+ "/opt/homebrew/bin/python3.12 -m venv ~/.venv-vllm-metal; "
468
+ "~/.venv-vllm-metal/bin/pip install -U pip setuptools wheel; "
469
+ "~/.venv-vllm-metal/bin/pip install vllm-metal",
470
+ ]
471
+ try:
472
+ completed = subprocess.run(command, **run_kwargs)
473
+ except subprocess.TimeoutExpired:
474
+ raise HTTPException(status_code=408, detail="엔진 설치 시간이 초과되었습니다.")
475
+ result = {
476
+ "engine": engine,
477
+ "command": " ".join(command),
478
+ "returncode": completed.returncode,
479
+ "stdout": completed.stdout[-12000:],
480
+ "stderr": completed.stderr[-12000:],
481
+ "installed": engine_installed(engine),
482
+ }
483
+ ollama = local_binary("ollama")
484
+ if engine == "ollama" and completed.returncode == 0 and ollama:
485
+ already_up = False
486
+ try:
487
+ probe = subprocess.run([ollama, "list"], capture_output=True, timeout=2, check=False)
488
+ already_up = probe.returncode == 0
489
+ except Exception:
490
+ already_up = False
491
+ if already_up:
492
+ result["daemon_started"] = "already_running"
493
+ else:
494
+ try:
495
+ subprocess.Popen(
496
+ [ollama, "serve"],
497
+ stdout=subprocess.DEVNULL,
498
+ stderr=subprocess.DEVNULL,
499
+ start_new_session=True,
500
+ )
501
+ result["daemon_started"] = True
502
+ except Exception as exc:
503
+ logging.warning("ollama serve spawn failed: %s", exc)
504
+ result["daemon_started"] = False
505
+ return result
506
+
507
+
508
+ # --- Smoke test extracted for server decomp wave ---
509
+ async def _smoke_test_loaded_model(
510
+ resolution: Any,
511
+ *,
512
+ api_key_override: Optional[str] = None,
513
+ ) -> Dict[str, object]:
514
+ """로드 직후 짧은 채팅 테스트를 돌려 ready_to_chat 여부를 판정한다.
515
+
516
+ Cloud models are skipped to avoid cost.
517
+ Failures are swallowed.
518
+ """
519
+ # late imports to avoid circular and keep lattice_brain/latticeai clean
520
+ try:
521
+ from latticeai.services.model_runtime import (
522
+ router as _router,
523
+ _LOCAL_SMOKE_ENGINES,
524
+ _SMOKE_PROMPT,
525
+ )
526
+ from latticeai.core.model_compat import (
527
+ ensure_profile as _ensure_compat_profile,
528
+ fast_postprocess as _compat_fast_postprocess,
529
+ classify_smoke_response as _classify_smoke_response,
530
+ record_smoke_result as _record_smoke_result,
531
+ )
532
+ import asyncio
533
+ except Exception as e:
534
+ return {"ok": False, "reason": f"smoke import failed: {e}", "skipped": True}
535
+
536
+ if (getattr(resolution, "engine", "") or "").lower() not in _LOCAL_SMOKE_ENGINES:
537
+ profile = _ensure_compat_profile(getattr(resolution, "load_id", ""), getattr(resolution, "engine", ""))
538
+ return {
539
+ "ok": True,
540
+ "reason": "skipped (cloud model — smoke test would incur cost)",
541
+ "answer": None,
542
+ "profile": profile.to_dict(),
543
+ "skipped": True,
544
+ }
545
+ try:
546
+ text = await asyncio.wait_for(
547
+ _router.generate(
548
+ _SMOKE_PROMPT,
549
+ context=None,
550
+ max_tokens=128,
551
+ temperature=0.1,
552
+ ),
553
+ timeout=30,
554
+ )
555
+ except Exception as exc:
556
+ reason = str(exc)[:200] or "generation_failed"
557
+ profile = _record_smoke_result(
558
+ getattr(resolution, "load_id", ""), getattr(resolution, "engine", ""), False, reason, status="failed"
559
+ )
560
+ return {
561
+ "ok": False,
562
+ "status": "failed",
563
+ "reason": reason,
564
+ "answer": None,
565
+ "profile": profile.to_dict(),
566
+ }
567
+
568
+ profile = _ensure_compat_profile(getattr(resolution, "load_id", ""), getattr(resolution, "engine", ""))
569
+ cleaned = _compat_fast_postprocess(str(text or ""), profile.to_dict())
570
+ status, reason = _classify_smoke_response(cleaned)
571
+ ok = status != "failed"
572
+ profile = _record_smoke_result(
573
+ getattr(resolution, "load_id", ""), getattr(resolution, "engine", ""), ok, reason, status=status
574
+ )
575
+ return {
576
+ "ok": ok,
577
+ "status": status,
578
+ "reason": reason,
579
+ "answer": cleaned,
580
+ "profile": profile.to_dict(),
581
+ }
582
+
583
+
584
+ __all__ = [
585
+ "ensure_lmstudio_server",
586
+ "ensure_ollama_server",
587
+ "ensure_vllm_server",
588
+ "ensure_llamacpp_server",
589
+ "pull_ollama_model_with_progress",
590
+ "get_ollama_pulled_models",
591
+ "engine_support_status",
592
+ "install_engine",
593
+ "_smoke_test_loaded_model",
594
+ ]