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,482 @@
1
+ """Model loading, prepare and stream logic extracted from model_runtime for server decomp.
2
+
3
+ This moves the large prepare_and_load_model and stream functions out of the monolith.
4
+ Re-exports will be added in model_runtime for compat.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import queue
12
+ import subprocess
13
+ import threading
14
+ from pathlib import Path
15
+ from typing import AsyncIterator, Dict, Optional
16
+
17
+ from fastapi import HTTPException, Request
18
+
19
+ # Late imports to avoid circulars during extraction
20
+ def _get_model_runtime_deps():
21
+ from .model_runtime import (
22
+ _download_allowed,
23
+ _download_block,
24
+ _engine_install_block,
25
+ _friendly_model_runtime_error,
26
+ _ModelResolution,
27
+ _model_runtime_compatibility,
28
+ _smoke_test_loaded_model,
29
+ download_hf_model,
30
+ engine_installed,
31
+ ensure_engine_ready,
32
+ ensure_llamacpp_server,
33
+ ensure_lmstudio_model,
34
+ ensure_ollama_server,
35
+ ensure_vllm_server,
36
+ get_current_user,
37
+ get_lmstudio_models,
38
+ get_ollama_pulled_models,
39
+ get_user_api_key,
40
+ hf_model_dir,
41
+ hf_model_ready,
42
+ local_binary,
43
+ MODEL_ENGINE_ALIASES,
44
+ model_download_progress_payload,
45
+ normalize_local_model_request,
46
+ parse_model_ref,
47
+ pull_ollama_model_with_progress,
48
+ router,
49
+ )
50
+ return {
51
+ "_download_allowed": _download_allowed,
52
+ "_download_block": _download_block,
53
+ "_engine_install_block": _engine_install_block,
54
+ "_friendly_model_runtime_error": _friendly_model_runtime_error,
55
+ "_ModelResolution": _ModelResolution,
56
+ "_model_runtime_compatibility": _model_runtime_compatibility,
57
+ "_smoke_test_loaded_model": _smoke_test_loaded_model,
58
+ "download_hf_model": download_hf_model,
59
+ "engine_installed": engine_installed,
60
+ "ensure_engine_ready": ensure_engine_ready,
61
+ "ensure_llamacpp_server": ensure_llamacpp_server,
62
+ "ensure_lmstudio_model": ensure_lmstudio_model,
63
+ "ensure_ollama_server": ensure_ollama_server,
64
+ "ensure_vllm_server": ensure_vllm_server,
65
+ "get_current_user": get_current_user,
66
+ "get_lmstudio_models": get_lmstudio_models,
67
+ "get_ollama_pulled_models": get_ollama_pulled_models,
68
+ "get_user_api_key": get_user_api_key,
69
+ "hf_model_dir": hf_model_dir,
70
+ "hf_model_ready": hf_model_ready,
71
+ "local_binary": local_binary,
72
+ "MODEL_ENGINE_ALIASES": MODEL_ENGINE_ALIASES,
73
+ "model_download_progress_payload": model_download_progress_payload,
74
+ "normalize_local_model_request": normalize_local_model_request,
75
+ "parse_model_ref": parse_model_ref,
76
+ "pull_ollama_model_with_progress": pull_ollama_model_with_progress,
77
+ "router": router,
78
+ }
79
+
80
+
81
+ async def prepare_and_load_model(
82
+ model_id: str,
83
+ request: Request,
84
+ engine: Optional[str] = None,
85
+ user_email: Optional[str] = None,
86
+ adapter_path: Optional[str] = None,
87
+ draft_model_id: Optional[str] = None,
88
+ allow_download: bool = False,
89
+ ) -> Dict[str, object]:
90
+ deps = _get_model_runtime_deps()
91
+ model_id = deps["normalize_local_model_request"](model_id, engine)
92
+ if not model_id:
93
+ raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
94
+
95
+ resolution = deps["_ModelResolution"].from_request(
96
+ model_id,
97
+ engine=engine,
98
+ user_email=user_email or deps["get_current_user"](request),
99
+ engine_aliases=deps["MODEL_ENGINE_ALIASES"],
100
+ )
101
+
102
+ parsed_provider, parsed_model = deps["parse_model_ref"](model_id)
103
+ if parsed_provider == "mlx":
104
+ parsed_provider = "local_mlx"
105
+ compatibility = deps["_model_runtime_compatibility"](parsed_model, engine=parsed_provider)
106
+ if compatibility.get("supported") is False:
107
+ raise HTTPException(status_code=400, detail=compatibility)
108
+
109
+ local_engines = {"local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"}
110
+ install_result: Dict[str, object] = {}
111
+ download_result: Optional[Dict[str, object]] = None
112
+
113
+ if parsed_provider in local_engines:
114
+ if not deps["engine_installed"](parsed_provider) and not deps["_download_allowed"](allow_download):
115
+ deps["_engine_install_block"](parsed_provider)
116
+ install_result = deps["ensure_engine_ready"](parsed_provider)
117
+
118
+ if parsed_provider == "local_mlx":
119
+ explicit_path = Path(parsed_model).expanduser()
120
+ if not explicit_path.exists() and not deps["hf_model_ready"](parsed_model, "local_mlx"):
121
+ if not deps["_download_allowed"](allow_download):
122
+ deps["_download_block"](parsed_provider, parsed_model)
123
+ download_result = deps["download_hf_model"](parsed_model, "local_mlx")
124
+ elif parsed_provider == "ollama":
125
+ deps["ensure_ollama_server"]()
126
+ ollama = deps["local_binary"]("ollama")
127
+ if not ollama:
128
+ raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
129
+ if parsed_model not in deps["get_ollama_pulled_models"]():
130
+ if not deps["_download_allowed"](allow_download):
131
+ deps["_download_block"](parsed_provider, parsed_model)
132
+ completed = subprocess.run(
133
+ [ollama, "pull", parsed_model],
134
+ capture_output=True,
135
+ text=True,
136
+ timeout=900,
137
+ check=False,
138
+ )
139
+ if completed.returncode != 0:
140
+ raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "Ollama 모델 다운로드 실패")
141
+ download_result = {"provider": "ollama", "model": parsed_model, "returncode": completed.returncode}
142
+ elif parsed_provider == "vllm":
143
+ if not deps["hf_model_ready"](parsed_model, "vllm") and not deps["_download_allowed"](allow_download):
144
+ deps["_download_block"](parsed_provider, parsed_model)
145
+ deps["ensure_vllm_server"](parsed_model)
146
+ download_result = {"provider": "vllm", "model": parsed_model, "server_ready": True}
147
+ elif parsed_provider == "llamacpp":
148
+ if not deps["hf_model_ready"](parsed_model, "llamacpp") and not deps["_download_allowed"](allow_download):
149
+ deps["_download_block"](parsed_provider, parsed_model)
150
+ deps["ensure_llamacpp_server"](parsed_model)
151
+ download_result = {"provider": "llamacpp", "model": parsed_model, "server_ready": True}
152
+ elif parsed_provider == "lmstudio":
153
+ downloaded = {
154
+ str(item.get("key") or "").strip()
155
+ for item in deps["get_lmstudio_models"]()
156
+ if isinstance(item, dict)
157
+ }
158
+ if parsed_model not in downloaded and not deps["_download_allowed"](allow_download):
159
+ deps["_download_block"](parsed_provider, parsed_model)
160
+ ensured = deps["ensure_lmstudio_model"](parsed_model)
161
+ resolved_model = str(
162
+ ensured.get("instance_id")
163
+ or ensured.get("resolved_model")
164
+ or parsed_model
165
+ ).strip()
166
+ parsed_model = resolved_model
167
+ model_id = f"lmstudio:{resolved_model}"
168
+ download_result = ensured
169
+
170
+ effective_email = (user_email or deps["get_current_user"](request) or "").strip()
171
+ user_api_key = deps["get_user_api_key"](effective_email, parsed_provider) if parsed_provider != "local_mlx" else None
172
+ msg = await deps["router"].load_model(
173
+ model_id,
174
+ adapter_path,
175
+ draft_model_id=draft_model_id,
176
+ api_key_override=user_api_key,
177
+ owner=effective_email or None,
178
+ )
179
+ resolution.update_after_load(actual_current=deps["router"].current_model_id)
180
+ smoke_result: Dict[str, object] = {}
181
+ ready_to_chat = True
182
+ compat_status = "ok"
183
+ try:
184
+ smoke_result = await deps["_smoke_test_loaded_model"](resolution, api_key_override=user_api_key)
185
+ ready_to_chat = bool(smoke_result.get("ok"))
186
+ compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
187
+ except Exception as exc:
188
+ logging.warning("smoke test failed for %s: %s", resolution.load_id, exc)
189
+ compat_status = "unknown"
190
+ return {
191
+ "status": "ok",
192
+ "message": msg,
193
+ "model": model_id,
194
+ "current": deps["router"].current_model_id,
195
+ "engine": parsed_provider,
196
+ "installed_now": bool(install_result.get("installed_now")),
197
+ "download": download_result,
198
+ "resolution": resolution.to_dict(),
199
+ "downloaded": bool(download_result and not (isinstance(download_result, dict) and download_result.get("cached"))),
200
+ "loaded": True,
201
+ "ready_to_chat": ready_to_chat,
202
+ "compatibility_status": compat_status,
203
+ "smoke_test": smoke_result,
204
+ }
205
+
206
+
207
+ def sse_event(event: str, data: Dict[str, object]) -> str:
208
+ return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
209
+
210
+
211
+ async def prepare_and_load_model_stream(
212
+ model_id: str,
213
+ request: Request,
214
+ engine: Optional[str] = None,
215
+ user_email: Optional[str] = None,
216
+ allow_download: bool = False,
217
+ ) -> AsyncIterator[str]:
218
+ deps = _get_model_runtime_deps()
219
+ model_id = deps["normalize_local_model_request"](model_id, engine)
220
+ if not model_id:
221
+ raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
222
+
223
+ parsed_provider, parsed_model = deps["parse_model_ref"](model_id)
224
+ if parsed_provider == "mlx":
225
+ parsed_provider = "local_mlx"
226
+ compatibility = deps["_model_runtime_compatibility"](parsed_model, engine=parsed_provider)
227
+ if compatibility.get("supported") is False:
228
+ raise HTTPException(status_code=400, detail=compatibility)
229
+
230
+ work_queue: "queue.Queue[Dict[str, object]]" = queue.Queue()
231
+ work_result: Dict[str, object] = {}
232
+
233
+ def emit_progress(payload: Dict[str, object]) -> None:
234
+ work_queue.put({"kind": "progress", "data": payload})
235
+
236
+ def blocking_prepare() -> None:
237
+ try:
238
+ local_engines = {"local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"}
239
+ install_result: Dict[str, object] = {}
240
+ download_result: Optional[Dict[str, object]] = None
241
+ prepared_model_id = model_id
242
+ prepared_model_name = parsed_model
243
+
244
+ if parsed_provider in local_engines:
245
+ emit_progress(deps["model_download_progress_payload"](
246
+ "engine",
247
+ "실행 엔진을 확인하는 중입니다.",
248
+ percent=2,
249
+ indeterminate=True,
250
+ ))
251
+ if not deps["engine_installed"](parsed_provider) and not deps["_download_allowed"](allow_download):
252
+ deps["_engine_install_block"](parsed_provider)
253
+ install_result = deps["ensure_engine_ready"](parsed_provider)
254
+ emit_progress(deps["model_download_progress_payload"](
255
+ "engine",
256
+ "실행 엔진 준비가 완료되었습니다.",
257
+ percent=10,
258
+ indeterminate=False,
259
+ ))
260
+
261
+ if parsed_provider == "local_mlx":
262
+ explicit_path = Path(parsed_model).expanduser()
263
+ if explicit_path.exists():
264
+ download_result = {"model": parsed_model, "path": str(explicit_path), "cached": True}
265
+ emit_progress(deps["model_download_progress_payload"](
266
+ "download",
267
+ "로컬 모델 경로를 확인했습니다.",
268
+ percent=100,
269
+ detail=str(explicit_path),
270
+ eta_seconds=0,
271
+ ))
272
+ elif not deps["hf_model_ready"](parsed_model, "local_mlx"):
273
+ if not deps["_download_allowed"](allow_download):
274
+ deps["_download_block"](parsed_provider, parsed_model)
275
+ download_result = deps["download_hf_model"](parsed_model, "local_mlx", progress_emit=emit_progress)
276
+ else:
277
+ download_result = {"model": parsed_model, "path": str(deps["hf_model_dir"](parsed_model)), "cached": True}
278
+ emit_progress(deps["model_download_progress_payload"](
279
+ "download",
280
+ "이미 다운로드된 모델을 확인했습니다.",
281
+ percent=100,
282
+ eta_seconds=0,
283
+ ))
284
+ elif parsed_provider == "ollama":
285
+ emit_progress(deps["model_download_progress_payload"](
286
+ "engine",
287
+ "Ollama 서버를 확인하는 중입니다.",
288
+ percent=12,
289
+ indeterminate=True,
290
+ ))
291
+ deps["ensure_ollama_server"]()
292
+ if parsed_model not in deps["get_ollama_pulled_models"]():
293
+ if not deps["_download_allowed"](allow_download):
294
+ deps["_download_block"](parsed_provider, parsed_model)
295
+ download_result = deps["pull_ollama_model_with_progress"](parsed_model, progress_emit=emit_progress)
296
+ else:
297
+ download_result = {"provider": "ollama", "model": parsed_model, "cached": True}
298
+ emit_progress(deps["model_download_progress_payload"](
299
+ "download",
300
+ "이미 다운로드된 Ollama 모델을 확인했습니다.",
301
+ percent=100,
302
+ detail=parsed_model,
303
+ eta_seconds=0,
304
+ ))
305
+ elif parsed_provider == "vllm":
306
+ if not deps["hf_model_ready"](parsed_model, "vllm"):
307
+ if not deps["_download_allowed"](allow_download):
308
+ deps["_download_block"](parsed_provider, parsed_model)
309
+ download_result = deps["download_hf_model"](parsed_model, "vllm", progress_emit=emit_progress)
310
+ else:
311
+ download_result = {"provider": "vllm", "model": parsed_model, "cached": True}
312
+ emit_progress(deps["model_download_progress_payload"](
313
+ "download",
314
+ "이미 다운로드된 모델을 확인했습니다.",
315
+ percent=100,
316
+ detail=parsed_model,
317
+ eta_seconds=0,
318
+ ))
319
+ emit_progress(deps["model_download_progress_payload"](
320
+ "server",
321
+ "vLLM 서버를 시작하는 중입니다.",
322
+ percent=92,
323
+ indeterminate=True,
324
+ ))
325
+ deps["ensure_vllm_server"](parsed_model)
326
+ download_result = {**(download_result or {}), "provider": "vllm", "model": parsed_model, "server_ready": True}
327
+ elif parsed_provider == "llamacpp":
328
+ if not deps["hf_model_ready"](parsed_model, "llamacpp"):
329
+ if not deps["_download_allowed"](allow_download):
330
+ deps["_download_block"](parsed_provider, parsed_model)
331
+ download_result = deps["download_hf_model"](parsed_model, "llamacpp", progress_emit=emit_progress)
332
+ else:
333
+ download_result = {"provider": "llamacpp", "model": parsed_model, "cached": True}
334
+ emit_progress(deps["model_download_progress_payload"](
335
+ "download",
336
+ "이미 다운로드된 GGUF 모델을 확인했습니다.",
337
+ percent=100,
338
+ detail=parsed_model,
339
+ eta_seconds=0,
340
+ ))
341
+ emit_progress(deps["model_download_progress_payload"](
342
+ "server",
343
+ "llama.cpp 서버를 시작하는 중입니다.",
344
+ percent=92,
345
+ indeterminate=True,
346
+ ))
347
+ deps["ensure_llamacpp_server"](parsed_model)
348
+ download_result = {**(download_result or {}), "provider": "llamacpp", "model": parsed_model, "server_ready": True}
349
+ elif parsed_provider == "lmstudio":
350
+ downloaded = {
351
+ str(item.get("key") or "").strip()
352
+ for item in deps["get_lmstudio_models"]()
353
+ if isinstance(item, dict)
354
+ }
355
+ if parsed_model not in downloaded and not deps["_download_allowed"](allow_download):
356
+ deps["_download_block"](parsed_provider, parsed_model)
357
+ emit_progress(deps["model_download_progress_payload"](
358
+ "download",
359
+ "LM Studio 모델을 확인하는 중입니다.",
360
+ percent=35,
361
+ indeterminate=True,
362
+ ))
363
+ ensured = deps["ensure_lmstudio_model"](parsed_model)
364
+ resolved_model = str(
365
+ ensured.get("instance_id")
366
+ or ensured.get("resolved_model")
367
+ or parsed_model
368
+ ).strip()
369
+ prepared_model_name = resolved_model
370
+ prepared_model_id = f"lmstudio:{resolved_model}"
371
+ download_result = ensured
372
+ else:
373
+ emit_progress(deps["model_download_progress_payload"](
374
+ "engine",
375
+ "모델 연결을 준비하는 중입니다.",
376
+ percent=30,
377
+ indeterminate=True,
378
+ ))
379
+
380
+ work_result.update({
381
+ "model_id": prepared_model_id,
382
+ "parsed_provider": parsed_provider,
383
+ "parsed_model": prepared_model_name,
384
+ "install_result": install_result,
385
+ "download_result": download_result,
386
+ })
387
+ work_queue.put({"kind": "done"})
388
+ except HTTPException as exc:
389
+ work_queue.put({"kind": "error", "status_code": exc.status_code, "detail": exc.detail})
390
+ except Exception as exc:
391
+ logging.exception("model prepare stream worker failed")
392
+ work_queue.put({
393
+ "kind": "error",
394
+ "status_code": 500,
395
+ "detail": deps["_friendly_model_runtime_error"](exc, model_id=model_id, engine=parsed_provider),
396
+ })
397
+
398
+ worker = threading.Thread(target=blocking_prepare, daemon=True)
399
+ worker.start()
400
+
401
+ while True:
402
+ item = await asyncio.to_thread(work_queue.get)
403
+ kind = item.get("kind")
404
+ if kind == "progress":
405
+ yield sse_event("progress", item["data"])
406
+ elif kind == "error":
407
+ raise HTTPException(
408
+ status_code=int(item.get("status_code") or 500),
409
+ detail=item.get("detail") or "모델 준비에 실패했습니다.",
410
+ )
411
+ elif kind == "done":
412
+ break
413
+
414
+ prepared_model_id = str(work_result.get("model_id") or model_id)
415
+ prepared_provider = str(work_result.get("parsed_provider") or parsed_provider)
416
+ install_result = work_result.get("install_result") or {}
417
+ download_result = work_result.get("download_result")
418
+
419
+ yield sse_event("progress", deps["model_download_progress_payload"](
420
+ "load",
421
+ "모델을 메모리에 로드하는 중입니다.",
422
+ percent=96,
423
+ indeterminate=True,
424
+ ))
425
+
426
+ effective_email = (user_email or deps["get_current_user"](request) or "").strip()
427
+ user_api_key = deps["get_user_api_key"](effective_email, prepared_provider) if prepared_provider != "local_mlx" else None
428
+ msg = await deps["router"].load_model(
429
+ prepared_model_id,
430
+ None,
431
+ draft_model_id=None,
432
+ api_key_override=user_api_key,
433
+ owner=effective_email or None,
434
+ )
435
+ resolution_stream = deps["_ModelResolution"].from_request(
436
+ prepared_model_id,
437
+ engine=prepared_provider,
438
+ user_email=effective_email or None,
439
+ engine_aliases=deps["MODEL_ENGINE_ALIASES"],
440
+ )
441
+ resolution_stream.update_after_load(actual_current=deps["router"].current_model_id)
442
+ yield sse_event("progress", deps["model_download_progress_payload"](
443
+ "smoke_test",
444
+ "채팅 호환성 테스트 중입니다.",
445
+ percent=98,
446
+ indeterminate=True,
447
+ ))
448
+ smoke_result: Dict[str, object] = {}
449
+ ready_to_chat = True
450
+ compat_status = "ok"
451
+ try:
452
+ smoke_result = await deps["_smoke_test_loaded_model"](resolution_stream, api_key_override=user_api_key)
453
+ ready_to_chat = bool(smoke_result.get("ok"))
454
+ compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
455
+ except Exception as exc:
456
+ logging.warning("smoke test (stream) failed for %s: %s", resolution_stream.load_id, exc)
457
+ compat_status = "unknown"
458
+ result = {
459
+ "status": "ok",
460
+ "message": msg,
461
+ "model": prepared_model_id,
462
+ "current": deps["router"].current_model_id,
463
+ "engine": prepared_provider,
464
+ "installed_now": bool(isinstance(install_result, dict) and install_result.get("installed_now")),
465
+ "download": download_result,
466
+ "resolution": resolution_stream.to_dict(),
467
+ "downloaded": bool(download_result and not (isinstance(download_result, dict) and download_result.get("cached"))),
468
+ "loaded": True,
469
+ "ready_to_chat": ready_to_chat,
470
+ "compatibility_status": compat_status,
471
+ "smoke_test": smoke_result,
472
+ }
473
+ yield sse_event("progress", deps["model_download_progress_payload"](
474
+ "done",
475
+ "모델 준비가 완료되었습니다.",
476
+ percent=100,
477
+ eta_seconds=0,
478
+ ))
479
+ yield sse_event("done", result)
480
+
481
+
482
+ # To maintain exact public API, model_runtime will re-export these.
@@ -34,7 +34,12 @@ _APPLE_ONLY_ENGINES = {"local_mlx"}
34
34
  _FAMILY_ORDER = [
35
35
  "Gemma 4",
36
36
  "Qwen3-VL",
37
+ "Qwen2.5-VL",
37
38
  "Llama 4",
39
+ "Llama 3.2 Vision",
40
+ "Phi Vision",
41
+ "Moondream",
42
+ "Pixtral",
38
43
  ]
39
44
 
40
45
  _SIZE_RE = re.compile(r"([\d.]+)\s*(TB|GB|MB)", re.IGNORECASE)