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.
- package/README.md +20 -41
- package/docs/CHANGELOG.md +38 -874
- package/docs/DEVELOPMENT.md +26 -3
- package/docs/LEGACY_COMPATIBILITY.md +3 -1
- package/docs/TRUST_MODEL.md +26 -26
- package/docs/WHY_LATTICE.md +28 -25
- package/docs/kg-schema.md +6 -4
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/schema.py +6 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/computer_use.py +2 -0
- package/latticeai/api/memory.py +13 -0
- package/latticeai/app_factory.py +30 -28
- package/latticeai/brain/__init__.py +1 -1
- package/latticeai/core/config.py +10 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/tool_registry.py +10 -0
- package/latticeai/core/workspace_graph_trace.py +132 -0
- package/latticeai/core/workspace_memory.py +75 -0
- package/latticeai/core/workspace_os.py +135 -1283
- package/latticeai/core/workspace_os_utils.py +132 -0
- package/latticeai/core/workspace_permissions.py +99 -0
- package/latticeai/core/workspace_plugins.py +97 -0
- package/latticeai/core/workspace_runs.py +612 -0
- package/latticeai/core/workspace_skills.py +114 -0
- package/latticeai/core/workspace_snapshots.py +195 -0
- package/latticeai/core/workspace_timeline.py +107 -0
- package/latticeai/runtime/audit_runtime.py +64 -0
- package/latticeai/runtime/config_runtime.py +4 -0
- package/latticeai/runtime/sso_runtime.py +52 -0
- package/latticeai/services/architecture_readiness.py +3 -3
- package/latticeai/services/memory_service.py +215 -7
- package/latticeai/services/model_capability_registry.py +68 -0
- package/latticeai/services/model_engines.py +603 -0
- package/latticeai/services/model_loading.py +482 -0
- package/latticeai/services/model_recommendation.py +5 -0
- package/latticeai/services/model_runtime.py +141 -866
- package/latticeai/services/product_readiness.py +19 -18
- package/package.json +1 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-BOO66G-c.js → Act-D9jIknFd.js} +1 -1
- package/static/app/assets/{Brain-C6lEYiD7.js → Brain-CFOtWbPN.js} +1 -1
- package/static/app/assets/{Capture-TATXBRDw.js → Capture-Q4WYzwr5.js} +1 -1
- package/static/app/assets/{Library-DK4FIp8a.js → Library-C5Q2yWee.js} +1 -1
- package/static/app/assets/{System-Bgs6Ql7x.js → System-BLbjdr1_.js} +1 -1
- package/static/app/assets/index-BqammyNu.js +16 -0
- package/static/app/assets/index-ty1iGgZu.css +2 -0
- package/static/app/assets/{primitives-B70WOra0.js → primitives-Br8uSfZ4.js} +1 -1
- package/static/app/assets/{textarea-Czrd9gwM.js → textarea-BnhNs1_X.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +2 -1
- package/tools/computer.py +21 -0
- package/docs/CARRYOVER_AUDIT_v3.6.0.md +0 -61
- package/docs/HANDOVER_v3.6.0.md +0 -46
- package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +0 -56
- package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +0 -49
- package/docs/V2_ARCHITECTURE.md +0 -561
- package/docs/V3_2_AUDIT.md +0 -82
- package/docs/V3_BACKEND_ARCHITECTURE.md +0 -138
- package/docs/V3_FRONTEND.md +0 -146
- package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +0 -65
- package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +0 -70
- package/docs/V4_1_VALIDATION_REPORT.md +0 -47
- package/docs/V4_2_BRAIN_CORE_ARCHITECTURE.md +0 -97
- package/docs/V4_2_STORAGE_MIGRATION_REPORT.md +0 -91
- package/docs/V4_2_VALIDATION_REPORT.md +0 -89
- package/docs/V4_3_2_DEADCODE_AUDIT_REPORT.md +0 -174
- package/docs/V4_3_2_DOCUMENTATION_CLEANUP_REPORT.md +0 -81
- package/docs/V4_3_2_GITHUB_VERCEL_CHECK_REPORT.md +0 -75
- package/docs/V4_3_2_GRAPH_UX_REPORT.md +0 -48
- package/docs/V4_3_2_INDEPENDENT_AUDIT_PACKAGE.md +0 -209
- package/docs/V4_3_2_PRODUCT_POLISH_REPORT.md +0 -57
- package/docs/V4_3_2_SELF_AUDIT_REPORT.md +0 -63
- package/docs/V4_3_2_VALIDATION_REPORT.md +0 -97
- package/docs/V4_3_3_VALIDATION_REPORT.md +0 -46
- package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +0 -69
- package/docs/V4_3_PRIVACY_AUDIT.md +0 -60
- package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +0 -53
- package/docs/V4_3_VALIDATION_REPORT.md +0 -58
- package/docs/V4_4_0_EXTRACTION_REPORT.md +0 -239
- package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +0 -49
- package/docs/V4_5_0_GRAPH_UX_REPORT.md +0 -34
- package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +0 -40
- package/docs/V4_5_0_ONBOARDING_REPORT.md +0 -31
- package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +0 -49
- package/docs/V4_5_0_VALIDATION_REPORT.md +0 -60
- package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +0 -33
- package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +0 -37
- package/docs/V4_5_1_NAVIGATION_REPORT.md +0 -37
- package/docs/V4_5_1_ONBOARDING_REPORT.md +0 -29
- package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +0 -61
- package/docs/V4_5_1_RC_ARTIFACTS.md +0 -44
- package/docs/V4_5_1_UX_REPORT.md +0 -45
- package/docs/V4_5_1_VALIDATION_REPORT.md +0 -55
- package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +0 -30
- package/docs/V4_6_0_LIVING_BRAIN_EXPERIENCE_REPORT.md +0 -72
- package/docs/V4_6_1_RELEASE_REFRESH_REPORT.md +0 -42
- package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +0 -42
- package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +0 -49
- package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +0 -62
- package/docs/V4_BRAIN_ARCHITECTURE.md +0 -322
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +0 -555
- package/docs/V4_IMPLEMENTATION_PLAN.md +0 -470
- package/static/app/assets/index-Bvv79nre.js +0 -16
- 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
|
+
]
|