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
|
@@ -14,7 +14,6 @@ import logging
|
|
|
14
14
|
import os
|
|
15
15
|
import platform
|
|
16
16
|
import queue
|
|
17
|
-
import re
|
|
18
17
|
import shutil
|
|
19
18
|
import subprocess
|
|
20
19
|
import sys
|
|
@@ -27,6 +26,14 @@ from typing import AsyncIterator, Dict, List, Optional
|
|
|
27
26
|
|
|
28
27
|
from fastapi import HTTPException, Request
|
|
29
28
|
|
|
29
|
+
def _missing_current_user(_request: Request) -> Optional[str]:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _missing_user_api_key(_email: Optional[str], _provider: str) -> Optional[str]:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
30
37
|
from latticeai.models.router import (
|
|
31
38
|
AsyncOpenAI,
|
|
32
39
|
HF_MODELS_ROOT,
|
|
@@ -37,36 +44,99 @@ from latticeai.models.router import (
|
|
|
37
44
|
parse_model_ref,
|
|
38
45
|
)
|
|
39
46
|
from latticeai.core.model_compat import (
|
|
40
|
-
SMOKE_PROMPT as _SMOKE_PROMPT,
|
|
41
|
-
classify_smoke_response as _classify_smoke_response,
|
|
42
|
-
ensure_profile as _ensure_compat_profile,
|
|
43
|
-
fast_postprocess as _compat_fast_postprocess,
|
|
44
47
|
friendly_model_runtime_error as _friendly_model_runtime_error,
|
|
45
48
|
model_runtime_compatibility as _model_runtime_compatibility,
|
|
46
|
-
record_smoke_result as _record_smoke_result,
|
|
47
49
|
)
|
|
48
50
|
from latticeai.core.model_resolution import ModelResolution as _ModelResolution
|
|
51
|
+
from .model_engines import (
|
|
52
|
+
ensure_lmstudio_server as _ensure_lmstudio_server,
|
|
53
|
+
ensure_ollama_server as _ensure_ollama_server,
|
|
54
|
+
ensure_vllm_server as _ensure_vllm_server,
|
|
55
|
+
ensure_llamacpp_server as _ensure_llamacpp_server,
|
|
56
|
+
find_lmstudio_cli as _find_lmstudio_cli,
|
|
57
|
+
get_openai_compatible_server_models as _get_openai_compatible_server_models,
|
|
58
|
+
pull_ollama_model_with_progress as _pull_ollama_model_with_progress,
|
|
59
|
+
get_ollama_pulled_models as _get_ollama_pulled_models,
|
|
60
|
+
engine_support_status as _engine_support_status,
|
|
61
|
+
install_engine as _install_engine,
|
|
62
|
+
local_binary as _local_binary,
|
|
63
|
+
vllm_executable as _vllm_executable,
|
|
64
|
+
vllm_metal_python as _vllm_metal_python,
|
|
65
|
+
wait_for_openai_compatible_server as _wait_for_openai_compatible_server,
|
|
66
|
+
windows_binary_candidates as _windows_binary_candidates,
|
|
67
|
+
LOCAL_SERVER_PROCESSES as _LOCAL_SERVER_PROCESSES,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Rebind extracted engines for legacy module globals
|
|
71
|
+
ensure_lmstudio_server = _ensure_lmstudio_server
|
|
72
|
+
ensure_ollama_server = _ensure_ollama_server
|
|
73
|
+
ensure_vllm_server = _ensure_vllm_server
|
|
74
|
+
ensure_llamacpp_server = _ensure_llamacpp_server
|
|
75
|
+
pull_ollama_model_with_progress = _pull_ollama_model_with_progress
|
|
76
|
+
get_ollama_pulled_models = _get_ollama_pulled_models
|
|
77
|
+
engine_support_status = _engine_support_status
|
|
78
|
+
install_engine = _install_engine
|
|
79
|
+
|
|
80
|
+
# Server decomp: proper ModelRuntimeState class for globals/wiring
|
|
81
|
+
class ModelRuntimeState:
|
|
82
|
+
"""Central object for all legacy globals. Module level names delegate for compat.
|
|
83
|
+
This is the clean wiring surface for future decomp.
|
|
84
|
+
"""
|
|
85
|
+
def __init__(self):
|
|
86
|
+
self.router = None
|
|
87
|
+
self.APP_MODE = "local"
|
|
88
|
+
self.DEFAULT_HOST = "127.0.0.1"
|
|
89
|
+
self.DEFAULT_PORT = 4825
|
|
90
|
+
self.DATA_DIR = Path.home() / ".latticeai"
|
|
91
|
+
self.BASE_DIR = Path.cwd()
|
|
92
|
+
self.ENABLE_TELEGRAM = False
|
|
93
|
+
self.ENABLE_GRAPH = True
|
|
94
|
+
self.AUTOLOAD_MODELS = False
|
|
95
|
+
self.MODEL_IDLE_UNLOAD_SECONDS = 0
|
|
96
|
+
self.ALLOW_LOCAL_MODELS = True
|
|
97
|
+
self.REQUIRE_AUTH = False
|
|
98
|
+
self.INVITE_GATE_ENABLED = False
|
|
99
|
+
self.ALLOW_PLAINTEXT_API_KEYS = False
|
|
100
|
+
self.CORS_ALLOW_NETWORK = False
|
|
101
|
+
self.PUBLIC_MODEL = "openai:gpt-4o-mini"
|
|
102
|
+
self.LOCAL_MODEL = "mlx-community/gemma-4-12b-it-4bit"
|
|
103
|
+
self.IS_PUBLIC_MODE = False
|
|
104
|
+
self.keyring = None
|
|
105
|
+
self.get_current_user = _missing_current_user
|
|
106
|
+
self.get_user_api_key = _missing_user_api_key
|
|
107
|
+
|
|
108
|
+
def sync_to_module_globals(self):
|
|
109
|
+
global router, APP_MODE, DEFAULT_HOST, DEFAULT_PORT, DATA_DIR, BASE_DIR
|
|
110
|
+
global ENABLE_TELEGRAM, ENABLE_GRAPH, AUTOLOAD_MODELS, MODEL_IDLE_UNLOAD_SECONDS
|
|
111
|
+
global ALLOW_LOCAL_MODELS, REQUIRE_AUTH, INVITE_GATE_ENABLED, ALLOW_PLAINTEXT_API_KEYS
|
|
112
|
+
global CORS_ALLOW_NETWORK, PUBLIC_MODEL, LOCAL_MODEL, IS_PUBLIC_MODE
|
|
113
|
+
global keyring, get_current_user, get_user_api_key
|
|
114
|
+
router = self.router
|
|
115
|
+
APP_MODE = self.APP_MODE
|
|
116
|
+
DEFAULT_HOST = self.DEFAULT_HOST
|
|
117
|
+
DEFAULT_PORT = self.DEFAULT_PORT
|
|
118
|
+
DATA_DIR = self.DATA_DIR
|
|
119
|
+
BASE_DIR = self.BASE_DIR
|
|
120
|
+
ENABLE_TELEGRAM = self.ENABLE_TELEGRAM
|
|
121
|
+
ENABLE_GRAPH = self.ENABLE_GRAPH
|
|
122
|
+
AUTOLOAD_MODELS = self.AUTOLOAD_MODELS
|
|
123
|
+
MODEL_IDLE_UNLOAD_SECONDS = self.MODEL_IDLE_UNLOAD_SECONDS
|
|
124
|
+
ALLOW_LOCAL_MODELS = self.ALLOW_LOCAL_MODELS
|
|
125
|
+
REQUIRE_AUTH = self.REQUIRE_AUTH
|
|
126
|
+
INVITE_GATE_ENABLED = self.INVITE_GATE_ENABLED
|
|
127
|
+
ALLOW_PLAINTEXT_API_KEYS = self.ALLOW_PLAINTEXT_API_KEYS
|
|
128
|
+
CORS_ALLOW_NETWORK = self.CORS_ALLOW_NETWORK
|
|
129
|
+
PUBLIC_MODEL = self.PUBLIC_MODEL
|
|
130
|
+
LOCAL_MODEL = self.LOCAL_MODEL
|
|
131
|
+
IS_PUBLIC_MODE = self.IS_PUBLIC_MODE
|
|
132
|
+
keyring = self.keyring
|
|
133
|
+
get_current_user = self.get_current_user
|
|
134
|
+
get_user_api_key = self.get_user_api_key
|
|
135
|
+
|
|
136
|
+
STATE = ModelRuntimeState()
|
|
137
|
+
STATE.sync_to_module_globals()
|
|
49
138
|
|
|
50
139
|
# Configured by server_app.configure_model_runtime during app assembly.
|
|
51
|
-
router = None
|
|
52
|
-
APP_MODE = "local"
|
|
53
|
-
DEFAULT_HOST = "127.0.0.1"
|
|
54
|
-
DEFAULT_PORT = 4825
|
|
55
|
-
DATA_DIR = Path.home() / ".latticeai"
|
|
56
|
-
BASE_DIR = Path.cwd()
|
|
57
|
-
ENABLE_TELEGRAM = False
|
|
58
|
-
ENABLE_GRAPH = True
|
|
59
|
-
AUTOLOAD_MODELS = False
|
|
60
|
-
MODEL_IDLE_UNLOAD_SECONDS = 0
|
|
61
|
-
ALLOW_LOCAL_MODELS = True
|
|
62
|
-
REQUIRE_AUTH = False
|
|
63
|
-
INVITE_GATE_ENABLED = False
|
|
64
|
-
ALLOW_PLAINTEXT_API_KEYS = False
|
|
65
|
-
CORS_ALLOW_NETWORK = False
|
|
66
|
-
PUBLIC_MODEL = "openai:gpt-4o-mini"
|
|
67
|
-
LOCAL_MODEL = "mlx-community/gemma-4-12b-it-4bit"
|
|
68
|
-
IS_PUBLIC_MODE = False
|
|
69
|
-
keyring = None
|
|
70
140
|
|
|
71
141
|
|
|
72
142
|
def _env_bool(key: str, default: bool = False) -> bool:
|
|
@@ -122,47 +192,25 @@ def _missing_user_api_key(_email: Optional[str], _provider: str) -> Optional[str
|
|
|
122
192
|
return None
|
|
123
193
|
|
|
124
194
|
|
|
125
|
-
get_current_user = _missing_current_user
|
|
126
|
-
get_user_api_key = _missing_user_api_key
|
|
127
|
-
|
|
128
|
-
|
|
129
195
|
def configure_model_runtime(**deps) -> None:
|
|
130
196
|
"""Wire app-owned runtime dependencies without importing server_app.
|
|
131
197
|
|
|
132
198
|
Explicit per-key assignment (no blanket globals().update) so wiring is
|
|
133
199
|
auditable and side effects are visible. Preserves exact public module
|
|
134
200
|
globals and prior behavior for all callers and shims.
|
|
201
|
+
Now uses STATE class for clean decomp.
|
|
135
202
|
"""
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
BASE_DIR = deps.get("BASE_DIR", BASE_DIR)
|
|
148
|
-
ENABLE_TELEGRAM = deps.get("ENABLE_TELEGRAM", ENABLE_TELEGRAM)
|
|
149
|
-
ENABLE_GRAPH = deps.get("ENABLE_GRAPH", ENABLE_GRAPH)
|
|
150
|
-
AUTOLOAD_MODELS = deps.get("AUTOLOAD_MODELS", AUTOLOAD_MODELS)
|
|
151
|
-
MODEL_IDLE_UNLOAD_SECONDS = deps.get("MODEL_IDLE_UNLOAD_SECONDS", MODEL_IDLE_UNLOAD_SECONDS)
|
|
152
|
-
ALLOW_LOCAL_MODELS = deps.get("ALLOW_LOCAL_MODELS", ALLOW_LOCAL_MODELS)
|
|
153
|
-
REQUIRE_AUTH = deps.get("REQUIRE_AUTH", REQUIRE_AUTH)
|
|
154
|
-
INVITE_GATE_ENABLED = deps.get("INVITE_GATE_ENABLED", INVITE_GATE_ENABLED)
|
|
155
|
-
ALLOW_PLAINTEXT_API_KEYS = deps.get("ALLOW_PLAINTEXT_API_KEYS", ALLOW_PLAINTEXT_API_KEYS)
|
|
156
|
-
CORS_ALLOW_NETWORK = deps.get("CORS_ALLOW_NETWORK", CORS_ALLOW_NETWORK)
|
|
157
|
-
PUBLIC_MODEL = deps.get("PUBLIC_MODEL", PUBLIC_MODEL)
|
|
158
|
-
LOCAL_MODEL = deps.get("LOCAL_MODEL", LOCAL_MODEL)
|
|
159
|
-
IS_PUBLIC_MODE = deps.get("IS_PUBLIC_MODE", IS_PUBLIC_MODE)
|
|
160
|
-
if "keyring" in deps:
|
|
161
|
-
keyring = deps["keyring"]
|
|
162
|
-
if "get_current_user" in deps:
|
|
163
|
-
get_current_user = deps["get_current_user"]
|
|
164
|
-
if "get_user_api_key" in deps:
|
|
165
|
-
get_user_api_key = deps["get_user_api_key"]
|
|
203
|
+
for key, value in deps.items():
|
|
204
|
+
if hasattr(STATE, key):
|
|
205
|
+
setattr(STATE, key, value)
|
|
206
|
+
elif key == "keyring":
|
|
207
|
+
STATE.keyring = value
|
|
208
|
+
elif key == "get_current_user":
|
|
209
|
+
STATE.get_current_user = value
|
|
210
|
+
elif key == "get_user_api_key":
|
|
211
|
+
STATE.get_user_api_key = value
|
|
212
|
+
|
|
213
|
+
STATE.sync_to_module_globals()
|
|
166
214
|
|
|
167
215
|
|
|
168
216
|
# Catalog data + version-dedup helpers live in ``model_catalog``; re-exported
|
|
@@ -193,66 +241,30 @@ def _update_env_file(env_file: Path, key: str, value: str) -> None:
|
|
|
193
241
|
env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
194
242
|
|
|
195
243
|
|
|
196
|
-
LOCAL_SERVER_PROCESSES
|
|
244
|
+
LOCAL_SERVER_PROCESSES = _LOCAL_SERVER_PROCESSES
|
|
197
245
|
VLLM_METAL_ENV = Path.home() / ".venv-vllm-metal"
|
|
198
246
|
VLLM_METAL_BIN = VLLM_METAL_ENV / "bin" / "vllm"
|
|
199
247
|
VLLM_METAL_PYTHON = VLLM_METAL_ENV / "bin" / "python"
|
|
200
248
|
LMSTUDIO_BUNDLED_CLI = Path("/Applications/LM Studio.app/Contents/Resources/app/.webpack/lms")
|
|
201
249
|
|
|
202
250
|
def windows_binary_candidates(binary: str) -> List[Path]:
|
|
203
|
-
|
|
204
|
-
program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
|
|
205
|
-
program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
|
206
|
-
candidates = {
|
|
207
|
-
"ollama": [
|
|
208
|
-
Path(local_appdata) / "Programs" / "Ollama" / "ollama.exe" if local_appdata else None,
|
|
209
|
-
Path(program_files) / "Ollama" / "ollama.exe",
|
|
210
|
-
],
|
|
211
|
-
"lms": [
|
|
212
|
-
Path(local_appdata) / "Programs" / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe" if local_appdata else None,
|
|
213
|
-
Path(program_files) / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe",
|
|
214
|
-
],
|
|
215
|
-
"nvidia-smi": [
|
|
216
|
-
Path(program_files) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe",
|
|
217
|
-
Path(program_files_x86) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe",
|
|
218
|
-
],
|
|
219
|
-
}
|
|
220
|
-
return [item for item in candidates.get(binary, []) if item is not None]
|
|
251
|
+
return _windows_binary_candidates(binary)
|
|
221
252
|
|
|
222
253
|
|
|
223
254
|
def local_binary(binary: str) -> Optional[str]:
|
|
224
|
-
|
|
225
|
-
if found:
|
|
226
|
-
return found
|
|
227
|
-
if platform.system() == "Windows":
|
|
228
|
-
for candidate in windows_binary_candidates(binary):
|
|
229
|
-
if candidate.exists():
|
|
230
|
-
return str(candidate)
|
|
231
|
-
return None
|
|
255
|
+
return _local_binary(binary)
|
|
232
256
|
|
|
233
257
|
|
|
234
258
|
def find_lmstudio_cli() -> Optional[str]:
|
|
235
|
-
|
|
236
|
-
if cli:
|
|
237
|
-
return cli
|
|
238
|
-
if LMSTUDIO_BUNDLED_CLI.exists():
|
|
239
|
-
return str(LMSTUDIO_BUNDLED_CLI)
|
|
240
|
-
return None
|
|
259
|
+
return _find_lmstudio_cli()
|
|
241
260
|
|
|
242
261
|
|
|
243
262
|
def vllm_executable() -> Optional[str]:
|
|
244
|
-
|
|
245
|
-
if found:
|
|
246
|
-
return found
|
|
247
|
-
if VLLM_METAL_BIN.exists():
|
|
248
|
-
return str(VLLM_METAL_BIN)
|
|
249
|
-
return None
|
|
263
|
+
return _vllm_executable()
|
|
250
264
|
|
|
251
265
|
|
|
252
266
|
def vllm_metal_python() -> Optional[str]:
|
|
253
|
-
|
|
254
|
-
return str(VLLM_METAL_PYTHON)
|
|
255
|
-
return None
|
|
267
|
+
return _vllm_metal_python()
|
|
256
268
|
|
|
257
269
|
|
|
258
270
|
def _json_request(
|
|
@@ -286,35 +298,7 @@ def lmstudio_native_api_base() -> str:
|
|
|
286
298
|
|
|
287
299
|
|
|
288
300
|
def ensure_lmstudio_server() -> None:
|
|
289
|
-
|
|
290
|
-
try:
|
|
291
|
-
_json_request(f"{base_url}/api/v1/models", headers={"Authorization": "Bearer lmstudio"}, timeout=2.5)
|
|
292
|
-
return
|
|
293
|
-
except Exception:
|
|
294
|
-
pass
|
|
295
|
-
|
|
296
|
-
cli = find_lmstudio_cli()
|
|
297
|
-
if not cli:
|
|
298
|
-
raise HTTPException(status_code=400, detail="LM Studio CLI를 찾지 못했습니다. LM Studio를 설치한 뒤 다시 시도하세요.")
|
|
299
|
-
|
|
300
|
-
try:
|
|
301
|
-
subprocess.Popen(
|
|
302
|
-
[cli, "server", "start"],
|
|
303
|
-
stdout=subprocess.DEVNULL,
|
|
304
|
-
stderr=subprocess.DEVNULL,
|
|
305
|
-
start_new_session=True,
|
|
306
|
-
)
|
|
307
|
-
except Exception as e:
|
|
308
|
-
raise HTTPException(status_code=500, detail=f"LM Studio 서버 시작 실패: {e}")
|
|
309
|
-
|
|
310
|
-
deadline = time.time() + 45
|
|
311
|
-
while time.time() < deadline:
|
|
312
|
-
try:
|
|
313
|
-
_json_request(f"{base_url}/api/v1/models", headers={"Authorization": "Bearer lmstudio"}, timeout=2.5)
|
|
314
|
-
return
|
|
315
|
-
except Exception:
|
|
316
|
-
time.sleep(1)
|
|
317
|
-
raise HTTPException(status_code=500, detail="LM Studio Local Server를 자동으로 시작하지 못했습니다.")
|
|
301
|
+
return _ensure_lmstudio_server()
|
|
318
302
|
|
|
319
303
|
|
|
320
304
|
_LMSTUDIO_MODELS_CACHE: List[Dict[str, object]] = []
|
|
@@ -449,18 +433,7 @@ def ensure_lmstudio_model(model_name: str) -> Dict[str, object]:
|
|
|
449
433
|
}
|
|
450
434
|
|
|
451
435
|
def engine_support_status(engine: str) -> Dict[str, object]:
|
|
452
|
-
|
|
453
|
-
return {"supported": True, "reason": None}
|
|
454
|
-
is_apple_silicon = sys.platform == "darwin" and platform.machine() == "arm64"
|
|
455
|
-
if sys.platform.startswith("win"):
|
|
456
|
-
return {"supported": False, "reason": "vLLM은 Windows native 자동 설치보다 WSL2/Linux 환경을 권장합니다."}
|
|
457
|
-
if sys.platform == "darwin" and not is_apple_silicon:
|
|
458
|
-
return {"supported": False, "reason": "vLLM Metal 자동 설치는 Apple Silicon macOS에서만 지원됩니다."}
|
|
459
|
-
if sys.version_info >= (3, 13) and is_apple_silicon:
|
|
460
|
-
return {"supported": True, "reason": "현재 환경에서는 vLLM Metal 전용 런타임으로 설치합니다."}
|
|
461
|
-
if sys.version_info >= (3, 13):
|
|
462
|
-
return {"supported": False, "reason": "vLLM 설치는 현재 Python 3.13 이하 또는 별도 전용 런타임이 필요합니다."}
|
|
463
|
-
return {"supported": True, "reason": None}
|
|
436
|
+
return _engine_support_status(engine)
|
|
464
437
|
|
|
465
438
|
def hf_model_ready(repo_id: str, provider: str = "local_mlx") -> bool:
|
|
466
439
|
model_dir = hf_model_dir(repo_id)
|
|
@@ -714,260 +687,31 @@ def download_hf_model(
|
|
|
714
687
|
|
|
715
688
|
|
|
716
689
|
def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict[str, object]:
|
|
717
|
-
|
|
718
|
-
if not ollama:
|
|
719
|
-
raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
|
|
720
|
-
started_at = time.time()
|
|
721
|
-
if progress_emit:
|
|
722
|
-
progress_emit(model_download_progress_payload(
|
|
723
|
-
"download",
|
|
724
|
-
"Ollama 모델 다운로드를 시작합니다.",
|
|
725
|
-
percent=0,
|
|
726
|
-
detail=model_name,
|
|
727
|
-
indeterminate=True,
|
|
728
|
-
))
|
|
729
|
-
process = subprocess.Popen(
|
|
730
|
-
[ollama, "pull", model_name],
|
|
731
|
-
stdout=subprocess.PIPE,
|
|
732
|
-
stderr=subprocess.STDOUT,
|
|
733
|
-
text=True,
|
|
734
|
-
bufsize=1,
|
|
735
|
-
)
|
|
736
|
-
last_percent: Optional[float] = None
|
|
737
|
-
lines: List[str] = []
|
|
738
|
-
try:
|
|
739
|
-
assert process.stdout is not None
|
|
740
|
-
for raw_line in process.stdout:
|
|
741
|
-
for part in re.split(r"[\r\n]+", raw_line):
|
|
742
|
-
line = part.strip()
|
|
743
|
-
if not line:
|
|
744
|
-
continue
|
|
745
|
-
lines.append(line)
|
|
746
|
-
match = re.search(r"(\d{1,3}(?:\.\d+)?)\s*%", line)
|
|
747
|
-
if match:
|
|
748
|
-
last_percent = min(100.0, float(match.group(1)))
|
|
749
|
-
if progress_emit:
|
|
750
|
-
progress_emit(model_download_progress_payload(
|
|
751
|
-
"download",
|
|
752
|
-
"Ollama 모델 다운로드 중입니다.",
|
|
753
|
-
percent=last_percent,
|
|
754
|
-
detail=line[-180:],
|
|
755
|
-
eta_seconds=estimate_eta_seconds(started_at, last_percent),
|
|
756
|
-
indeterminate=False,
|
|
757
|
-
))
|
|
758
|
-
elif progress_emit:
|
|
759
|
-
progress_emit(model_download_progress_payload(
|
|
760
|
-
"download",
|
|
761
|
-
"Ollama 모델 다운로드 중입니다.",
|
|
762
|
-
percent=last_percent,
|
|
763
|
-
detail=line[-180:],
|
|
764
|
-
eta_seconds=estimate_eta_seconds(started_at, last_percent),
|
|
765
|
-
indeterminate=last_percent is None,
|
|
766
|
-
))
|
|
767
|
-
returncode = process.wait()
|
|
768
|
-
except Exception:
|
|
769
|
-
process.kill()
|
|
770
|
-
raise
|
|
771
|
-
|
|
772
|
-
if returncode != 0:
|
|
773
|
-
tail = "\n".join(lines[-12:])
|
|
774
|
-
raise HTTPException(status_code=500, detail=tail[-2000:] or "Ollama 모델 다운로드 실패")
|
|
775
|
-
|
|
776
|
-
if progress_emit:
|
|
777
|
-
progress_emit(model_download_progress_payload(
|
|
778
|
-
"download",
|
|
779
|
-
"Ollama 모델 다운로드가 완료되었습니다.",
|
|
780
|
-
percent=100,
|
|
781
|
-
detail=model_name,
|
|
782
|
-
eta_seconds=0,
|
|
783
|
-
indeterminate=False,
|
|
784
|
-
))
|
|
785
|
-
return {"provider": "ollama", "model": model_name, "returncode": returncode}
|
|
690
|
+
return _pull_ollama_model_with_progress(model_name, progress_emit)
|
|
786
691
|
|
|
787
692
|
|
|
788
693
|
def get_ollama_pulled_models() -> set:
|
|
789
|
-
|
|
790
|
-
if not ollama:
|
|
791
|
-
return set()
|
|
792
|
-
try:
|
|
793
|
-
result = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=5, check=False)
|
|
794
|
-
pulled = set()
|
|
795
|
-
for line in result.stdout.splitlines()[1:]:
|
|
796
|
-
parts = line.split()
|
|
797
|
-
if parts:
|
|
798
|
-
pulled.add(parts[0])
|
|
799
|
-
return pulled
|
|
800
|
-
except Exception:
|
|
801
|
-
return set()
|
|
694
|
+
return _get_ollama_pulled_models()
|
|
802
695
|
|
|
803
696
|
|
|
804
697
|
def get_openai_compatible_server_models(provider: str) -> List[str]:
|
|
805
|
-
|
|
806
|
-
models = []
|
|
807
|
-
for item in get_lmstudio_models():
|
|
808
|
-
if not isinstance(item, dict):
|
|
809
|
-
continue
|
|
810
|
-
key = str(item.get("key") or "").strip()
|
|
811
|
-
loaded_instances = item.get("loaded_instances") or []
|
|
812
|
-
if loaded_instances:
|
|
813
|
-
instance_ids = [
|
|
814
|
-
str(instance.get("id") or "").strip()
|
|
815
|
-
for instance in loaded_instances
|
|
816
|
-
if isinstance(instance, dict) and instance.get("id")
|
|
817
|
-
]
|
|
818
|
-
models.extend(instance_ids or ([key] if key else []))
|
|
819
|
-
return list(dict.fromkeys([model for model in models if model]))
|
|
820
|
-
|
|
821
|
-
config = OPENAI_COMPATIBLE_PROVIDERS.get(provider) or {}
|
|
822
|
-
base_url = os.getenv(config.get("base_url_env", "")) if config.get("base_url_env") else None
|
|
823
|
-
base_url = (base_url or config.get("base_url") or "").rstrip("/")
|
|
824
|
-
if not base_url:
|
|
825
|
-
return []
|
|
826
|
-
|
|
827
|
-
api_key = os.getenv(config.get("env_key", "")) or config.get("api_key_fallback") or provider
|
|
828
|
-
req = urllib.request.Request(
|
|
829
|
-
f"{base_url}/models",
|
|
830
|
-
headers={"Authorization": f"Bearer {api_key}"},
|
|
831
|
-
method="GET",
|
|
832
|
-
)
|
|
833
|
-
try:
|
|
834
|
-
with urllib.request.urlopen(req, timeout=2.5) as res:
|
|
835
|
-
payload = json.loads(res.read().decode("utf-8", errors="replace"))
|
|
836
|
-
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
|
|
837
|
-
return []
|
|
838
|
-
|
|
839
|
-
models = []
|
|
840
|
-
for item in payload.get("data") or []:
|
|
841
|
-
model_id = item.get("id") if isinstance(item, dict) else None
|
|
842
|
-
if model_id:
|
|
843
|
-
models.append(str(model_id))
|
|
844
|
-
return models
|
|
698
|
+
return _get_openai_compatible_server_models(provider)
|
|
845
699
|
|
|
846
700
|
|
|
847
701
|
def ensure_ollama_server() -> None:
|
|
848
|
-
|
|
849
|
-
if not ollama:
|
|
850
|
-
raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
|
|
851
|
-
try:
|
|
852
|
-
probe = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=3, check=False)
|
|
853
|
-
if probe.returncode == 0:
|
|
854
|
-
return
|
|
855
|
-
except Exception:
|
|
856
|
-
pass
|
|
857
|
-
subprocess.Popen(
|
|
858
|
-
[ollama, "serve"],
|
|
859
|
-
stdout=subprocess.DEVNULL,
|
|
860
|
-
stderr=subprocess.DEVNULL,
|
|
861
|
-
start_new_session=True,
|
|
862
|
-
)
|
|
863
|
-
deadline = time.time() + 20
|
|
864
|
-
while time.time() < deadline:
|
|
865
|
-
try:
|
|
866
|
-
probe = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=3, check=False)
|
|
867
|
-
if probe.returncode == 0:
|
|
868
|
-
return
|
|
869
|
-
except Exception:
|
|
870
|
-
pass
|
|
871
|
-
time.sleep(0.5)
|
|
872
|
-
raise HTTPException(status_code=500, detail="Ollama 서버를 자동으로 시작하지 못했습니다.")
|
|
702
|
+
return _ensure_ollama_server()
|
|
873
703
|
|
|
874
704
|
|
|
875
705
|
def wait_for_openai_compatible_server(provider: str, model_name: Optional[str] = None, timeout: int = 45) -> bool:
|
|
876
|
-
|
|
877
|
-
while time.time() < deadline:
|
|
878
|
-
models = get_openai_compatible_server_models(provider)
|
|
879
|
-
if models and (not model_name or model_name in models):
|
|
880
|
-
return True
|
|
881
|
-
time.sleep(1)
|
|
882
|
-
return False
|
|
706
|
+
return _wait_for_openai_compatible_server(provider, model_name=model_name, timeout=timeout)
|
|
883
707
|
|
|
884
708
|
|
|
885
709
|
def ensure_vllm_server(model_name: str) -> None:
|
|
886
|
-
|
|
887
|
-
if model_name in served_models:
|
|
888
|
-
return
|
|
889
|
-
vllm_bin = vllm_executable()
|
|
890
|
-
vllm_metal_py = vllm_metal_python()
|
|
891
|
-
if not vllm_bin and not vllm_metal_py and importlib.util.find_spec("vllm") is None:
|
|
892
|
-
raise HTTPException(status_code=400, detail="vLLM runtime이 설치되지 않았습니다.")
|
|
893
|
-
|
|
894
|
-
local_dir = hf_model_dir(model_name)
|
|
895
|
-
if not vllm_metal_py and not hf_model_ready(model_name, "vllm"):
|
|
896
|
-
download_hf_model(model_name, "vllm")
|
|
897
|
-
|
|
898
|
-
running = LOCAL_SERVER_PROCESSES.get("vllm")
|
|
899
|
-
if running and running.poll() is None:
|
|
900
|
-
running.terminate()
|
|
901
|
-
try:
|
|
902
|
-
running.wait(timeout=10)
|
|
903
|
-
except subprocess.TimeoutExpired:
|
|
904
|
-
running.kill()
|
|
905
|
-
elif served_models:
|
|
906
|
-
raise HTTPException(status_code=409, detail="다른 vLLM 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
|
|
907
|
-
|
|
908
|
-
running = LOCAL_SERVER_PROCESSES.get("vllm")
|
|
909
|
-
if running and running.poll() is None:
|
|
910
|
-
return
|
|
911
|
-
|
|
912
|
-
_host_args = ["--host", "127.0.0.1", "--port", "8000"]
|
|
913
|
-
if vllm_metal_py:
|
|
914
|
-
command = [vllm_metal_py, "-m", "vllm_metal.server", "--model", model_name, *_host_args]
|
|
915
|
-
elif vllm_bin:
|
|
916
|
-
command = [vllm_bin, "serve", str(local_dir), "--served-model-name", model_name, *_host_args]
|
|
917
|
-
else:
|
|
918
|
-
command = [sys.executable, "-m", "vllm.entrypoints.openai.api_server", "--model", str(local_dir), "--served-model-name", model_name, *_host_args]
|
|
919
|
-
LOCAL_SERVER_PROCESSES["vllm"] = subprocess.Popen(
|
|
920
|
-
command,
|
|
921
|
-
stdout=subprocess.DEVNULL,
|
|
922
|
-
stderr=subprocess.DEVNULL,
|
|
923
|
-
start_new_session=True,
|
|
924
|
-
)
|
|
925
|
-
if not wait_for_openai_compatible_server("vllm", model_name, timeout=90):
|
|
926
|
-
raise HTTPException(status_code=500, detail="vLLM 서버가 모델을 자동 로드하지 못했습니다.")
|
|
710
|
+
return _ensure_vllm_server(model_name)
|
|
927
711
|
|
|
928
712
|
|
|
929
713
|
def ensure_llamacpp_server(model_name: str) -> None:
|
|
930
|
-
|
|
931
|
-
if model_name in served_models:
|
|
932
|
-
return
|
|
933
|
-
running = LOCAL_SERVER_PROCESSES.get("llamacpp")
|
|
934
|
-
if running and running.poll() is None:
|
|
935
|
-
running.terminate()
|
|
936
|
-
try:
|
|
937
|
-
running.wait(timeout=10)
|
|
938
|
-
except subprocess.TimeoutExpired:
|
|
939
|
-
running.kill()
|
|
940
|
-
elif served_models:
|
|
941
|
-
raise HTTPException(status_code=409, detail="다른 llama.cpp 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
|
|
942
|
-
if not shutil.which("llama-server"):
|
|
943
|
-
raise HTTPException(status_code=400, detail="llama.cpp가 설치되지 않았습니다.")
|
|
944
|
-
if not hf_model_ready(model_name, "llamacpp"):
|
|
945
|
-
download_hf_model(model_name, "llamacpp")
|
|
946
|
-
|
|
947
|
-
gguf_files = sorted(hf_model_dir(model_name).rglob("*.gguf"))
|
|
948
|
-
if not gguf_files:
|
|
949
|
-
raise HTTPException(status_code=500, detail="다운로드된 GGUF 파일을 찾지 못했습니다.")
|
|
950
|
-
|
|
951
|
-
preferred = next((p for p in gguf_files if "q4_k_m" in p.name.lower()), None)
|
|
952
|
-
model_file = preferred or gguf_files[0]
|
|
953
|
-
LOCAL_SERVER_PROCESSES["llamacpp"] = subprocess.Popen(
|
|
954
|
-
[
|
|
955
|
-
"llama-server",
|
|
956
|
-
"-m",
|
|
957
|
-
str(model_file),
|
|
958
|
-
"--alias",
|
|
959
|
-
model_name,
|
|
960
|
-
"--host",
|
|
961
|
-
"127.0.0.1",
|
|
962
|
-
"--port",
|
|
963
|
-
"8080",
|
|
964
|
-
],
|
|
965
|
-
stdout=subprocess.DEVNULL,
|
|
966
|
-
stderr=subprocess.DEVNULL,
|
|
967
|
-
start_new_session=True,
|
|
968
|
-
)
|
|
969
|
-
if not wait_for_openai_compatible_server("llamacpp", model_name, timeout=45):
|
|
970
|
-
raise HTTPException(status_code=500, detail="llama.cpp 서버가 모델을 자동 로드하지 못했습니다.")
|
|
714
|
+
return _ensure_llamacpp_server(model_name)
|
|
971
715
|
|
|
972
716
|
|
|
973
717
|
def engine_installed(engine: str) -> bool:
|
|
@@ -1184,68 +928,7 @@ def runtime_features() -> Dict:
|
|
|
1184
928
|
}
|
|
1185
929
|
|
|
1186
930
|
def install_engine(engine: str) -> Dict:
|
|
1187
|
-
|
|
1188
|
-
raise HTTPException(status_code=400, detail="지원하지 않는 엔진입니다.")
|
|
1189
|
-
installer = ENGINE_INSTALLERS[engine]
|
|
1190
|
-
required_binary = installer.get("requires_binary")
|
|
1191
|
-
if required_binary and shutil.which(required_binary) is None:
|
|
1192
|
-
raise HTTPException(status_code=400, detail=f"{required_binary}가 설치되어 있지 않아 자동 설치할 수 없습니다.")
|
|
1193
|
-
command = installer["command"]
|
|
1194
|
-
run_kwargs = {
|
|
1195
|
-
"cwd": str(BASE_DIR),
|
|
1196
|
-
"capture_output": True,
|
|
1197
|
-
"text": True,
|
|
1198
|
-
"timeout": 900,
|
|
1199
|
-
"check": False,
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
if engine == "vllm" and sys.platform == "darwin" and platform.machine() == "arm64":
|
|
1203
|
-
command = [
|
|
1204
|
-
"/bin/bash",
|
|
1205
|
-
"-lc",
|
|
1206
|
-
"set -euo pipefail; "
|
|
1207
|
-
"if [ ! -x /opt/homebrew/bin/python3.12 ]; then brew install python@3.12; fi; "
|
|
1208
|
-
"/opt/homebrew/bin/python3.12 -m venv ~/.venv-vllm-metal; "
|
|
1209
|
-
"~/.venv-vllm-metal/bin/pip install -U pip setuptools wheel; "
|
|
1210
|
-
"~/.venv-vllm-metal/bin/pip install vllm-metal",
|
|
1211
|
-
]
|
|
1212
|
-
try:
|
|
1213
|
-
completed = subprocess.run(command, **run_kwargs)
|
|
1214
|
-
except subprocess.TimeoutExpired:
|
|
1215
|
-
raise HTTPException(status_code=408, detail="엔진 설치 시간이 초과되었습니다.")
|
|
1216
|
-
result = {
|
|
1217
|
-
"engine": engine,
|
|
1218
|
-
"command": " ".join(command),
|
|
1219
|
-
"returncode": completed.returncode,
|
|
1220
|
-
"stdout": completed.stdout[-12000:],
|
|
1221
|
-
"stderr": completed.stderr[-12000:],
|
|
1222
|
-
"installed": engine_installed(engine),
|
|
1223
|
-
}
|
|
1224
|
-
ollama = local_binary("ollama")
|
|
1225
|
-
if engine == "ollama" and completed.returncode == 0 and ollama:
|
|
1226
|
-
# Skip if already running to avoid orphan daemons.
|
|
1227
|
-
already_up = False
|
|
1228
|
-
try:
|
|
1229
|
-
probe = subprocess.run([ollama, "list"], capture_output=True, timeout=2, check=False)
|
|
1230
|
-
already_up = probe.returncode == 0
|
|
1231
|
-
except Exception:
|
|
1232
|
-
already_up = False
|
|
1233
|
-
if already_up:
|
|
1234
|
-
result["daemon_started"] = "already_running"
|
|
1235
|
-
else:
|
|
1236
|
-
try:
|
|
1237
|
-
# Detach so the daemon survives this request but doesn't become our zombie.
|
|
1238
|
-
subprocess.Popen(
|
|
1239
|
-
[ollama, "serve"],
|
|
1240
|
-
stdout=subprocess.DEVNULL,
|
|
1241
|
-
stderr=subprocess.DEVNULL,
|
|
1242
|
-
start_new_session=True,
|
|
1243
|
-
)
|
|
1244
|
-
result["daemon_started"] = True
|
|
1245
|
-
except Exception as e:
|
|
1246
|
-
logging.warning("ollama serve spawn failed: %s", e)
|
|
1247
|
-
result["daemon_started"] = False
|
|
1248
|
-
return result
|
|
931
|
+
return _install_engine(engine)
|
|
1249
932
|
|
|
1250
933
|
|
|
1251
934
|
def _resolve_model_alias(model_id: str, engine: Optional[str] = None) -> str:
|
|
@@ -1335,58 +1018,9 @@ async def _smoke_test_loaded_model(
|
|
|
1335
1018
|
*,
|
|
1336
1019
|
api_key_override: Optional[str] = None,
|
|
1337
1020
|
) -> Dict[str, object]:
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
실패해도 예외를 던지지 않는다. 결과는 compat_cache에도 기록된다.
|
|
1342
|
-
"""
|
|
1343
|
-
if (resolution.engine or "").lower() not in _LOCAL_SMOKE_ENGINES:
|
|
1344
|
-
profile = _ensure_compat_profile(resolution.load_id, resolution.engine)
|
|
1345
|
-
return {
|
|
1346
|
-
"ok": True,
|
|
1347
|
-
"reason": "skipped (cloud model — smoke test would incur cost)",
|
|
1348
|
-
"answer": None,
|
|
1349
|
-
"profile": profile.to_dict(),
|
|
1350
|
-
"skipped": True,
|
|
1351
|
-
}
|
|
1352
|
-
try:
|
|
1353
|
-
text = await asyncio.wait_for(
|
|
1354
|
-
router.generate(
|
|
1355
|
-
_SMOKE_PROMPT,
|
|
1356
|
-
context=None,
|
|
1357
|
-
max_tokens=128,
|
|
1358
|
-
temperature=0.1,
|
|
1359
|
-
),
|
|
1360
|
-
timeout=30,
|
|
1361
|
-
)
|
|
1362
|
-
except Exception as exc: # pragma: no cover - generator may not exist on all engines
|
|
1363
|
-
reason = str(exc)[:200] or "generation_failed"
|
|
1364
|
-
profile = _record_smoke_result(
|
|
1365
|
-
resolution.load_id, resolution.engine, False, reason, status="failed"
|
|
1366
|
-
)
|
|
1367
|
-
return {
|
|
1368
|
-
"ok": False,
|
|
1369
|
-
"status": "failed",
|
|
1370
|
-
"reason": reason,
|
|
1371
|
-
"answer": None,
|
|
1372
|
-
"profile": profile.to_dict(),
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
profile = _ensure_compat_profile(resolution.load_id, resolution.engine)
|
|
1376
|
-
cleaned = _compat_fast_postprocess(str(text or ""), profile.to_dict())
|
|
1377
|
-
# item 3-3: ok / degraded / failed 3분류. degraded는 채팅은 가능하다.
|
|
1378
|
-
status, reason = _classify_smoke_response(cleaned)
|
|
1379
|
-
ok = status != "failed"
|
|
1380
|
-
profile = _record_smoke_result(
|
|
1381
|
-
resolution.load_id, resolution.engine, ok, reason, status=status
|
|
1382
|
-
)
|
|
1383
|
-
return {
|
|
1384
|
-
"ok": ok,
|
|
1385
|
-
"status": status,
|
|
1386
|
-
"reason": reason,
|
|
1387
|
-
"answer": cleaned,
|
|
1388
|
-
"profile": profile.to_dict(),
|
|
1389
|
-
}
|
|
1021
|
+
# Delegated to model_engines for server decomp
|
|
1022
|
+
from .model_engines import _smoke_test_loaded_model as _impl_smoke
|
|
1023
|
+
return await _impl_smoke(resolution, api_key_override=api_key_override)
|
|
1390
1024
|
|
|
1391
1025
|
|
|
1392
1026
|
async def prepare_and_load_model(
|
|
@@ -1398,123 +1032,17 @@ async def prepare_and_load_model(
|
|
|
1398
1032
|
draft_model_id: Optional[str] = None,
|
|
1399
1033
|
allow_download: bool = False,
|
|
1400
1034
|
) -> Dict[str, object]:
|
|
1401
|
-
|
|
1402
|
-
if not model_id:
|
|
1403
|
-
raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
|
|
1035
|
+
from .model_loading import prepare_and_load_model as _impl
|
|
1404
1036
|
|
|
1405
|
-
|
|
1406
|
-
resolution = _ModelResolution.from_request(
|
|
1037
|
+
return await _impl(
|
|
1407
1038
|
model_id,
|
|
1039
|
+
request,
|
|
1408
1040
|
engine=engine,
|
|
1409
|
-
user_email=user_email
|
|
1410
|
-
|
|
1411
|
-
)
|
|
1412
|
-
|
|
1413
|
-
parsed_provider, parsed_model = parse_model_ref(model_id)
|
|
1414
|
-
if parsed_provider == "mlx":
|
|
1415
|
-
parsed_provider = "local_mlx"
|
|
1416
|
-
compatibility = _model_runtime_compatibility(parsed_model, engine=parsed_provider)
|
|
1417
|
-
if compatibility.get("supported") is False:
|
|
1418
|
-
raise HTTPException(status_code=400, detail=compatibility)
|
|
1419
|
-
|
|
1420
|
-
local_engines = {"local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"}
|
|
1421
|
-
install_result: Dict[str, object] = {}
|
|
1422
|
-
download_result: Optional[Dict[str, object]] = None
|
|
1423
|
-
|
|
1424
|
-
if parsed_provider in local_engines:
|
|
1425
|
-
if not engine_installed(parsed_provider) and not _download_allowed(allow_download):
|
|
1426
|
-
_engine_install_block(parsed_provider)
|
|
1427
|
-
install_result = ensure_engine_ready(parsed_provider)
|
|
1428
|
-
|
|
1429
|
-
if parsed_provider == "local_mlx":
|
|
1430
|
-
explicit_path = Path(parsed_model).expanduser()
|
|
1431
|
-
if not explicit_path.exists() and not hf_model_ready(parsed_model, "local_mlx"):
|
|
1432
|
-
if not _download_allowed(allow_download):
|
|
1433
|
-
_download_block(parsed_provider, parsed_model)
|
|
1434
|
-
download_result = download_hf_model(parsed_model, "local_mlx")
|
|
1435
|
-
elif parsed_provider == "ollama":
|
|
1436
|
-
ensure_ollama_server()
|
|
1437
|
-
ollama = local_binary("ollama")
|
|
1438
|
-
if not ollama:
|
|
1439
|
-
raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
|
|
1440
|
-
if parsed_model not in get_ollama_pulled_models():
|
|
1441
|
-
if not _download_allowed(allow_download):
|
|
1442
|
-
_download_block(parsed_provider, parsed_model)
|
|
1443
|
-
completed = subprocess.run(
|
|
1444
|
-
[ollama, "pull", parsed_model],
|
|
1445
|
-
capture_output=True,
|
|
1446
|
-
text=True,
|
|
1447
|
-
timeout=900,
|
|
1448
|
-
check=False,
|
|
1449
|
-
)
|
|
1450
|
-
if completed.returncode != 0:
|
|
1451
|
-
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "Ollama 모델 다운로드 실패")
|
|
1452
|
-
download_result = {"provider": "ollama", "model": parsed_model, "returncode": completed.returncode}
|
|
1453
|
-
elif parsed_provider == "vllm":
|
|
1454
|
-
if not hf_model_ready(parsed_model, "vllm") and not _download_allowed(allow_download):
|
|
1455
|
-
_download_block(parsed_provider, parsed_model)
|
|
1456
|
-
ensure_vllm_server(parsed_model)
|
|
1457
|
-
download_result = {"provider": "vllm", "model": parsed_model, "server_ready": True}
|
|
1458
|
-
elif parsed_provider == "llamacpp":
|
|
1459
|
-
if not hf_model_ready(parsed_model, "llamacpp") and not _download_allowed(allow_download):
|
|
1460
|
-
_download_block(parsed_provider, parsed_model)
|
|
1461
|
-
ensure_llamacpp_server(parsed_model)
|
|
1462
|
-
download_result = {"provider": "llamacpp", "model": parsed_model, "server_ready": True}
|
|
1463
|
-
elif parsed_provider == "lmstudio":
|
|
1464
|
-
downloaded = {
|
|
1465
|
-
str(item.get("key") or "").strip()
|
|
1466
|
-
for item in get_lmstudio_models()
|
|
1467
|
-
if isinstance(item, dict)
|
|
1468
|
-
}
|
|
1469
|
-
if parsed_model not in downloaded and not _download_allowed(allow_download):
|
|
1470
|
-
_download_block(parsed_provider, parsed_model)
|
|
1471
|
-
ensured = ensure_lmstudio_model(parsed_model)
|
|
1472
|
-
resolved_model = str(
|
|
1473
|
-
ensured.get("instance_id")
|
|
1474
|
-
or ensured.get("resolved_model")
|
|
1475
|
-
or parsed_model
|
|
1476
|
-
).strip()
|
|
1477
|
-
parsed_model = resolved_model
|
|
1478
|
-
model_id = f"lmstudio:{resolved_model}"
|
|
1479
|
-
download_result = ensured
|
|
1480
|
-
|
|
1481
|
-
effective_email = (user_email or get_current_user(request) or "").strip()
|
|
1482
|
-
user_api_key = get_user_api_key(effective_email, parsed_provider) if parsed_provider != "local_mlx" else None
|
|
1483
|
-
msg = await router.load_model(
|
|
1484
|
-
model_id,
|
|
1485
|
-
adapter_path,
|
|
1041
|
+
user_email=user_email,
|
|
1042
|
+
adapter_path=adapter_path,
|
|
1486
1043
|
draft_model_id=draft_model_id,
|
|
1487
|
-
|
|
1488
|
-
owner=effective_email or None,
|
|
1044
|
+
allow_download=allow_download,
|
|
1489
1045
|
)
|
|
1490
|
-
# 피드백 #1/#2: 로드 직후 ModelResolution을 실제 current로 동기화하고 smoke test 수행.
|
|
1491
|
-
resolution.update_after_load(actual_current=router.current_model_id)
|
|
1492
|
-
smoke_result: Dict[str, object] = {}
|
|
1493
|
-
ready_to_chat = True
|
|
1494
|
-
compat_status = "ok"
|
|
1495
|
-
try:
|
|
1496
|
-
smoke_result = await _smoke_test_loaded_model(resolution, api_key_override=user_api_key)
|
|
1497
|
-
ready_to_chat = bool(smoke_result.get("ok"))
|
|
1498
|
-
# item 3-3: smoke 결과의 3분류(ok/degraded/failed)를 그대로 노출한다.
|
|
1499
|
-
compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
|
|
1500
|
-
except Exception as exc: # never break load on smoke test failures
|
|
1501
|
-
logging.warning("smoke test failed for %s: %s", resolution.load_id, exc)
|
|
1502
|
-
compat_status = "unknown"
|
|
1503
|
-
return {
|
|
1504
|
-
"status": "ok",
|
|
1505
|
-
"message": msg,
|
|
1506
|
-
"model": model_id,
|
|
1507
|
-
"current": router.current_model_id,
|
|
1508
|
-
"engine": parsed_provider,
|
|
1509
|
-
"installed_now": bool(install_result.get("installed_now")),
|
|
1510
|
-
"download": download_result,
|
|
1511
|
-
"resolution": resolution.to_dict(),
|
|
1512
|
-
"downloaded": bool(download_result and not (isinstance(download_result, dict) and download_result.get("cached"))),
|
|
1513
|
-
"loaded": True,
|
|
1514
|
-
"ready_to_chat": ready_to_chat,
|
|
1515
|
-
"compatibility_status": compat_status,
|
|
1516
|
-
"smoke_test": smoke_result,
|
|
1517
|
-
}
|
|
1518
1046
|
|
|
1519
1047
|
|
|
1520
1048
|
def sse_event(event: str, data: Dict[str, object]) -> str:
|
|
@@ -1528,269 +1056,16 @@ async def prepare_and_load_model_stream(
|
|
|
1528
1056
|
user_email: Optional[str] = None,
|
|
1529
1057
|
allow_download: bool = False,
|
|
1530
1058
|
) -> AsyncIterator[str]:
|
|
1531
|
-
|
|
1532
|
-
if not model_id:
|
|
1533
|
-
raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
|
|
1059
|
+
from .model_loading import prepare_and_load_model_stream as _impl
|
|
1534
1060
|
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
work_result: Dict[str, object] = {}
|
|
1544
|
-
|
|
1545
|
-
def emit_progress(payload: Dict[str, object]) -> None:
|
|
1546
|
-
work_queue.put({"kind": "progress", "data": payload})
|
|
1547
|
-
|
|
1548
|
-
def blocking_prepare() -> None:
|
|
1549
|
-
try:
|
|
1550
|
-
local_engines = {"local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"}
|
|
1551
|
-
install_result: Dict[str, object] = {}
|
|
1552
|
-
download_result: Optional[Dict[str, object]] = None
|
|
1553
|
-
prepared_model_id = model_id
|
|
1554
|
-
prepared_model_name = parsed_model
|
|
1555
|
-
|
|
1556
|
-
if parsed_provider in local_engines:
|
|
1557
|
-
emit_progress(model_download_progress_payload(
|
|
1558
|
-
"engine",
|
|
1559
|
-
"실행 엔진을 확인하는 중입니다.",
|
|
1560
|
-
percent=2,
|
|
1561
|
-
indeterminate=True,
|
|
1562
|
-
))
|
|
1563
|
-
if not engine_installed(parsed_provider) and not _download_allowed(allow_download):
|
|
1564
|
-
_engine_install_block(parsed_provider)
|
|
1565
|
-
install_result = ensure_engine_ready(parsed_provider)
|
|
1566
|
-
emit_progress(model_download_progress_payload(
|
|
1567
|
-
"engine",
|
|
1568
|
-
"실행 엔진 준비가 완료되었습니다.",
|
|
1569
|
-
percent=10,
|
|
1570
|
-
indeterminate=False,
|
|
1571
|
-
))
|
|
1572
|
-
|
|
1573
|
-
if parsed_provider == "local_mlx":
|
|
1574
|
-
explicit_path = Path(parsed_model).expanduser()
|
|
1575
|
-
if explicit_path.exists():
|
|
1576
|
-
download_result = {"model": parsed_model, "path": str(explicit_path), "cached": True}
|
|
1577
|
-
emit_progress(model_download_progress_payload(
|
|
1578
|
-
"download",
|
|
1579
|
-
"로컬 모델 경로를 확인했습니다.",
|
|
1580
|
-
percent=100,
|
|
1581
|
-
detail=str(explicit_path),
|
|
1582
|
-
eta_seconds=0,
|
|
1583
|
-
))
|
|
1584
|
-
elif not hf_model_ready(parsed_model, "local_mlx"):
|
|
1585
|
-
if not _download_allowed(allow_download):
|
|
1586
|
-
_download_block(parsed_provider, parsed_model)
|
|
1587
|
-
download_result = download_hf_model(parsed_model, "local_mlx", progress_emit=emit_progress)
|
|
1588
|
-
else:
|
|
1589
|
-
download_result = {"model": parsed_model, "path": str(hf_model_dir(parsed_model)), "cached": True}
|
|
1590
|
-
emit_progress(model_download_progress_payload(
|
|
1591
|
-
"download",
|
|
1592
|
-
"이미 다운로드된 모델을 확인했습니다.",
|
|
1593
|
-
percent=100,
|
|
1594
|
-
eta_seconds=0,
|
|
1595
|
-
))
|
|
1596
|
-
elif parsed_provider == "ollama":
|
|
1597
|
-
emit_progress(model_download_progress_payload(
|
|
1598
|
-
"engine",
|
|
1599
|
-
"Ollama 서버를 확인하는 중입니다.",
|
|
1600
|
-
percent=12,
|
|
1601
|
-
indeterminate=True,
|
|
1602
|
-
))
|
|
1603
|
-
ensure_ollama_server()
|
|
1604
|
-
if parsed_model not in get_ollama_pulled_models():
|
|
1605
|
-
if not _download_allowed(allow_download):
|
|
1606
|
-
_download_block(parsed_provider, parsed_model)
|
|
1607
|
-
download_result = pull_ollama_model_with_progress(parsed_model, progress_emit=emit_progress)
|
|
1608
|
-
else:
|
|
1609
|
-
download_result = {"provider": "ollama", "model": parsed_model, "cached": True}
|
|
1610
|
-
emit_progress(model_download_progress_payload(
|
|
1611
|
-
"download",
|
|
1612
|
-
"이미 다운로드된 Ollama 모델을 확인했습니다.",
|
|
1613
|
-
percent=100,
|
|
1614
|
-
detail=parsed_model,
|
|
1615
|
-
eta_seconds=0,
|
|
1616
|
-
))
|
|
1617
|
-
elif parsed_provider == "vllm":
|
|
1618
|
-
if not hf_model_ready(parsed_model, "vllm"):
|
|
1619
|
-
if not _download_allowed(allow_download):
|
|
1620
|
-
_download_block(parsed_provider, parsed_model)
|
|
1621
|
-
download_result = download_hf_model(parsed_model, "vllm", progress_emit=emit_progress)
|
|
1622
|
-
else:
|
|
1623
|
-
download_result = {"provider": "vllm", "model": parsed_model, "cached": True}
|
|
1624
|
-
emit_progress(model_download_progress_payload(
|
|
1625
|
-
"download",
|
|
1626
|
-
"이미 다운로드된 모델을 확인했습니다.",
|
|
1627
|
-
percent=100,
|
|
1628
|
-
detail=parsed_model,
|
|
1629
|
-
eta_seconds=0,
|
|
1630
|
-
))
|
|
1631
|
-
emit_progress(model_download_progress_payload(
|
|
1632
|
-
"server",
|
|
1633
|
-
"vLLM 서버를 시작하는 중입니다.",
|
|
1634
|
-
percent=92,
|
|
1635
|
-
indeterminate=True,
|
|
1636
|
-
))
|
|
1637
|
-
ensure_vllm_server(parsed_model)
|
|
1638
|
-
download_result = {**(download_result or {}), "provider": "vllm", "model": parsed_model, "server_ready": True}
|
|
1639
|
-
elif parsed_provider == "llamacpp":
|
|
1640
|
-
if not hf_model_ready(parsed_model, "llamacpp"):
|
|
1641
|
-
if not _download_allowed(allow_download):
|
|
1642
|
-
_download_block(parsed_provider, parsed_model)
|
|
1643
|
-
download_result = download_hf_model(parsed_model, "llamacpp", progress_emit=emit_progress)
|
|
1644
|
-
else:
|
|
1645
|
-
download_result = {"provider": "llamacpp", "model": parsed_model, "cached": True}
|
|
1646
|
-
emit_progress(model_download_progress_payload(
|
|
1647
|
-
"download",
|
|
1648
|
-
"이미 다운로드된 GGUF 모델을 확인했습니다.",
|
|
1649
|
-
percent=100,
|
|
1650
|
-
detail=parsed_model,
|
|
1651
|
-
eta_seconds=0,
|
|
1652
|
-
))
|
|
1653
|
-
emit_progress(model_download_progress_payload(
|
|
1654
|
-
"server",
|
|
1655
|
-
"llama.cpp 서버를 시작하는 중입니다.",
|
|
1656
|
-
percent=92,
|
|
1657
|
-
indeterminate=True,
|
|
1658
|
-
))
|
|
1659
|
-
ensure_llamacpp_server(parsed_model)
|
|
1660
|
-
download_result = {**(download_result or {}), "provider": "llamacpp", "model": parsed_model, "server_ready": True}
|
|
1661
|
-
elif parsed_provider == "lmstudio":
|
|
1662
|
-
downloaded = {
|
|
1663
|
-
str(item.get("key") or "").strip()
|
|
1664
|
-
for item in get_lmstudio_models()
|
|
1665
|
-
if isinstance(item, dict)
|
|
1666
|
-
}
|
|
1667
|
-
if parsed_model not in downloaded and not _download_allowed(allow_download):
|
|
1668
|
-
_download_block(parsed_provider, parsed_model)
|
|
1669
|
-
emit_progress(model_download_progress_payload(
|
|
1670
|
-
"download",
|
|
1671
|
-
"LM Studio 모델을 확인하는 중입니다.",
|
|
1672
|
-
percent=35,
|
|
1673
|
-
indeterminate=True,
|
|
1674
|
-
))
|
|
1675
|
-
ensured = ensure_lmstudio_model(parsed_model)
|
|
1676
|
-
resolved_model = str(
|
|
1677
|
-
ensured.get("instance_id")
|
|
1678
|
-
or ensured.get("resolved_model")
|
|
1679
|
-
or parsed_model
|
|
1680
|
-
).strip()
|
|
1681
|
-
prepared_model_name = resolved_model
|
|
1682
|
-
prepared_model_id = f"lmstudio:{resolved_model}"
|
|
1683
|
-
download_result = ensured
|
|
1684
|
-
else:
|
|
1685
|
-
emit_progress(model_download_progress_payload(
|
|
1686
|
-
"engine",
|
|
1687
|
-
"모델 연결을 준비하는 중입니다.",
|
|
1688
|
-
percent=30,
|
|
1689
|
-
indeterminate=True,
|
|
1690
|
-
))
|
|
1691
|
-
|
|
1692
|
-
work_result.update({
|
|
1693
|
-
"model_id": prepared_model_id,
|
|
1694
|
-
"parsed_provider": parsed_provider,
|
|
1695
|
-
"parsed_model": prepared_model_name,
|
|
1696
|
-
"install_result": install_result,
|
|
1697
|
-
"download_result": download_result,
|
|
1698
|
-
})
|
|
1699
|
-
work_queue.put({"kind": "done"})
|
|
1700
|
-
except HTTPException as exc:
|
|
1701
|
-
work_queue.put({"kind": "error", "status_code": exc.status_code, "detail": exc.detail})
|
|
1702
|
-
except Exception as exc:
|
|
1703
|
-
logging.exception("model prepare stream worker failed")
|
|
1704
|
-
work_queue.put({
|
|
1705
|
-
"kind": "error",
|
|
1706
|
-
"status_code": 500,
|
|
1707
|
-
"detail": _friendly_model_runtime_error(exc, model_id=model_id, engine=parsed_provider),
|
|
1708
|
-
})
|
|
1709
|
-
|
|
1710
|
-
worker = threading.Thread(target=blocking_prepare, daemon=True)
|
|
1711
|
-
worker.start()
|
|
1712
|
-
|
|
1713
|
-
while True:
|
|
1714
|
-
item = await asyncio.to_thread(work_queue.get)
|
|
1715
|
-
kind = item.get("kind")
|
|
1716
|
-
if kind == "progress":
|
|
1717
|
-
yield sse_event("progress", item["data"])
|
|
1718
|
-
elif kind == "error":
|
|
1719
|
-
raise HTTPException(
|
|
1720
|
-
status_code=int(item.get("status_code") or 500),
|
|
1721
|
-
detail=item.get("detail") or "모델 준비에 실패했습니다.",
|
|
1722
|
-
)
|
|
1723
|
-
elif kind == "done":
|
|
1724
|
-
break
|
|
1725
|
-
|
|
1726
|
-
prepared_model_id = str(work_result.get("model_id") or model_id)
|
|
1727
|
-
prepared_provider = str(work_result.get("parsed_provider") or parsed_provider)
|
|
1728
|
-
install_result = work_result.get("install_result") or {}
|
|
1729
|
-
download_result = work_result.get("download_result")
|
|
1730
|
-
|
|
1731
|
-
yield sse_event("progress", model_download_progress_payload(
|
|
1732
|
-
"load",
|
|
1733
|
-
"모델을 메모리에 로드하는 중입니다.",
|
|
1734
|
-
percent=96,
|
|
1735
|
-
indeterminate=True,
|
|
1736
|
-
))
|
|
1737
|
-
|
|
1738
|
-
effective_email = (user_email or get_current_user(request) or "").strip()
|
|
1739
|
-
user_api_key = get_user_api_key(effective_email, prepared_provider) if prepared_provider != "local_mlx" else None
|
|
1740
|
-
msg = await router.load_model(
|
|
1741
|
-
prepared_model_id,
|
|
1742
|
-
None,
|
|
1743
|
-
draft_model_id=None,
|
|
1744
|
-
api_key_override=user_api_key,
|
|
1745
|
-
owner=effective_email or None,
|
|
1746
|
-
)
|
|
1747
|
-
# 피드백 #1/#2: SSE에도 ModelResolution과 smoke test 결과를 같이 내려준다.
|
|
1748
|
-
resolution_stream = _ModelResolution.from_request(
|
|
1749
|
-
prepared_model_id,
|
|
1750
|
-
engine=prepared_provider,
|
|
1751
|
-
user_email=effective_email or None,
|
|
1752
|
-
engine_aliases=MODEL_ENGINE_ALIASES,
|
|
1753
|
-
)
|
|
1754
|
-
resolution_stream.update_after_load(actual_current=router.current_model_id)
|
|
1755
|
-
yield sse_event("progress", model_download_progress_payload(
|
|
1756
|
-
"smoke_test",
|
|
1757
|
-
"채팅 호환성 테스트 중입니다.",
|
|
1758
|
-
percent=98,
|
|
1759
|
-
indeterminate=True,
|
|
1760
|
-
))
|
|
1761
|
-
smoke_result: Dict[str, object] = {}
|
|
1762
|
-
ready_to_chat = True
|
|
1763
|
-
compat_status = "ok"
|
|
1764
|
-
try:
|
|
1765
|
-
smoke_result = await _smoke_test_loaded_model(resolution_stream, api_key_override=user_api_key)
|
|
1766
|
-
ready_to_chat = bool(smoke_result.get("ok"))
|
|
1767
|
-
# item 3-3: smoke 결과의 3분류(ok/degraded/failed)를 그대로 노출한다.
|
|
1768
|
-
compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
|
|
1769
|
-
except Exception as exc:
|
|
1770
|
-
logging.warning("smoke test (stream) failed for %s: %s", resolution_stream.load_id, exc)
|
|
1771
|
-
compat_status = "unknown"
|
|
1772
|
-
result = {
|
|
1773
|
-
"status": "ok",
|
|
1774
|
-
"message": msg,
|
|
1775
|
-
"model": prepared_model_id,
|
|
1776
|
-
"current": router.current_model_id,
|
|
1777
|
-
"engine": prepared_provider,
|
|
1778
|
-
"installed_now": bool(isinstance(install_result, dict) and install_result.get("installed_now")),
|
|
1779
|
-
"download": download_result,
|
|
1780
|
-
"resolution": resolution_stream.to_dict(),
|
|
1781
|
-
"downloaded": bool(download_result and not (isinstance(download_result, dict) and download_result.get("cached"))),
|
|
1782
|
-
"loaded": True,
|
|
1783
|
-
"ready_to_chat": ready_to_chat,
|
|
1784
|
-
"compatibility_status": compat_status,
|
|
1785
|
-
"smoke_test": smoke_result,
|
|
1786
|
-
}
|
|
1787
|
-
yield sse_event("progress", model_download_progress_payload(
|
|
1788
|
-
"done",
|
|
1789
|
-
"모델 준비가 완료되었습니다.",
|
|
1790
|
-
percent=100,
|
|
1791
|
-
eta_seconds=0,
|
|
1792
|
-
))
|
|
1793
|
-
yield sse_event("done", result)
|
|
1061
|
+
async for event in _impl(
|
|
1062
|
+
model_id,
|
|
1063
|
+
request,
|
|
1064
|
+
engine=engine,
|
|
1065
|
+
user_email=user_email,
|
|
1066
|
+
allow_download=allow_download,
|
|
1067
|
+
):
|
|
1068
|
+
yield event
|
|
1794
1069
|
|
|
1795
1070
|
|
|
1796
1071
|
CLOUD_VERIFY_CACHE: Dict[str, Dict] = {}
|