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