ltcai 8.0.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 (183) hide show
  1. package/README.md +33 -72
  2. package/docs/CHANGELOG.md +52 -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/embeddings.py +9 -1
  10. package/lattice_brain/graph/schema.py +6 -0
  11. package/lattice_brain/runtime/multi_agent.py +1 -1
  12. package/latticeai/__init__.py +1 -1
  13. package/latticeai/api/computer_use.py +2 -0
  14. package/latticeai/api/memory.py +13 -0
  15. package/latticeai/app_factory.py +43 -162
  16. package/latticeai/brain/__init__.py +1 -1
  17. package/latticeai/core/config.py +10 -0
  18. package/latticeai/core/local_embeddings.py +8 -0
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/mcp_registry.py +140 -1
  21. package/latticeai/core/tool_registry.py +10 -0
  22. package/latticeai/core/workspace_graph_trace.py +132 -0
  23. package/latticeai/core/workspace_memory.py +75 -0
  24. package/latticeai/core/workspace_os.py +135 -1283
  25. package/latticeai/core/workspace_os_utils.py +132 -0
  26. package/latticeai/core/workspace_permissions.py +99 -0
  27. package/latticeai/core/workspace_plugins.py +97 -0
  28. package/latticeai/core/workspace_runs.py +612 -0
  29. package/latticeai/core/workspace_skills.py +114 -0
  30. package/latticeai/core/workspace_snapshots.py +195 -0
  31. package/latticeai/core/workspace_timeline.py +107 -0
  32. package/latticeai/runtime/audit_runtime.py +64 -0
  33. package/latticeai/runtime/config_runtime.py +4 -0
  34. package/latticeai/runtime/sso_runtime.py +52 -0
  35. package/latticeai/services/architecture_readiness.py +6 -5
  36. package/latticeai/services/memory_service.py +215 -7
  37. package/latticeai/services/model_capability_registry.py +68 -0
  38. package/latticeai/services/model_engines.py +603 -0
  39. package/latticeai/services/model_loading.py +482 -0
  40. package/latticeai/services/model_recommendation.py +5 -0
  41. package/latticeai/services/model_runtime.py +146 -837
  42. package/latticeai/services/product_readiness.py +19 -18
  43. package/package.json +2 -2
  44. package/scripts/build_frontend_assets.mjs +11 -2
  45. package/src-tauri/Cargo.lock +1 -1
  46. package/src-tauri/Cargo.toml +1 -1
  47. package/src-tauri/tauri.conf.json +1 -1
  48. package/static/app/asset-manifest.json +11 -102
  49. package/static/app/assets/Act-D9jIknFd.js +1 -0
  50. package/static/app/assets/Brain-CFOtWbPN.js +321 -0
  51. package/static/app/assets/{Capture-B9Tlhzqr.js → Capture-Q4WYzwr5.js} +1 -2
  52. package/static/app/assets/Library-C5Q2yWee.js +1 -0
  53. package/static/app/assets/System-BLbjdr1_.js +1 -0
  54. package/static/app/assets/core-CwxXejkd.js +1 -2
  55. package/static/app/assets/index-BqammyNu.js +16 -0
  56. package/static/app/assets/index-ty1iGgZu.css +2 -0
  57. package/static/app/assets/primitives-Br8uSfZ4.js +1 -0
  58. package/static/app/assets/{textarea-BZk6ybp5.js → textarea-BnhNs1_X.js} +1 -2
  59. package/static/app/index.html +3 -10
  60. package/static/app/theme-boot.js +8 -0
  61. package/static/sw.js +1 -1
  62. package/tools/__init__.py +2 -1
  63. package/tools/computer.py +21 -0
  64. package/docs/CARRYOVER_AUDIT_v3.6.0.md +0 -61
  65. package/docs/HANDOVER_v3.6.0.md +0 -46
  66. package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +0 -56
  67. package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +0 -49
  68. package/docs/V2_ARCHITECTURE.md +0 -561
  69. package/docs/V3_2_AUDIT.md +0 -82
  70. package/docs/V3_BACKEND_ARCHITECTURE.md +0 -138
  71. package/docs/V3_FRONTEND.md +0 -140
  72. package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +0 -65
  73. package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +0 -70
  74. package/docs/V4_1_VALIDATION_REPORT.md +0 -47
  75. package/docs/V4_2_BRAIN_CORE_ARCHITECTURE.md +0 -97
  76. package/docs/V4_2_STORAGE_MIGRATION_REPORT.md +0 -91
  77. package/docs/V4_2_VALIDATION_REPORT.md +0 -89
  78. package/docs/V4_3_2_DEADCODE_AUDIT_REPORT.md +0 -174
  79. package/docs/V4_3_2_DOCUMENTATION_CLEANUP_REPORT.md +0 -81
  80. package/docs/V4_3_2_GITHUB_VERCEL_CHECK_REPORT.md +0 -75
  81. package/docs/V4_3_2_GRAPH_UX_REPORT.md +0 -48
  82. package/docs/V4_3_2_INDEPENDENT_AUDIT_PACKAGE.md +0 -209
  83. package/docs/V4_3_2_PRODUCT_POLISH_REPORT.md +0 -57
  84. package/docs/V4_3_2_SELF_AUDIT_REPORT.md +0 -63
  85. package/docs/V4_3_2_VALIDATION_REPORT.md +0 -97
  86. package/docs/V4_3_3_VALIDATION_REPORT.md +0 -46
  87. package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +0 -69
  88. package/docs/V4_3_PRIVACY_AUDIT.md +0 -60
  89. package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +0 -53
  90. package/docs/V4_3_VALIDATION_REPORT.md +0 -58
  91. package/docs/V4_4_0_EXTRACTION_REPORT.md +0 -239
  92. package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +0 -49
  93. package/docs/V4_5_0_GRAPH_UX_REPORT.md +0 -34
  94. package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +0 -40
  95. package/docs/V4_5_0_ONBOARDING_REPORT.md +0 -31
  96. package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +0 -49
  97. package/docs/V4_5_0_VALIDATION_REPORT.md +0 -60
  98. package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +0 -33
  99. package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +0 -37
  100. package/docs/V4_5_1_NAVIGATION_REPORT.md +0 -37
  101. package/docs/V4_5_1_ONBOARDING_REPORT.md +0 -29
  102. package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +0 -61
  103. package/docs/V4_5_1_RC_ARTIFACTS.md +0 -44
  104. package/docs/V4_5_1_UX_REPORT.md +0 -45
  105. package/docs/V4_5_1_VALIDATION_REPORT.md +0 -55
  106. package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +0 -30
  107. package/docs/V4_6_0_LIVING_BRAIN_EXPERIENCE_REPORT.md +0 -72
  108. package/docs/V4_6_1_RELEASE_REFRESH_REPORT.md +0 -42
  109. package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +0 -42
  110. package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +0 -49
  111. package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +0 -62
  112. package/docs/V4_BRAIN_ARCHITECTURE.md +0 -322
  113. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +0 -555
  114. package/docs/V4_IMPLEMENTATION_PLAN.md +0 -470
  115. package/frontend/index.html +0 -24
  116. package/frontend/openapi.json +0 -15425
  117. package/frontend/src/App.tsx +0 -243
  118. package/frontend/src/api/client.ts +0 -580
  119. package/frontend/src/api/openapi.ts +0 -17892
  120. package/frontend/src/components/AdminAccessGate.tsx +0 -70
  121. package/frontend/src/components/FeedbackState.tsx +0 -45
  122. package/frontend/src/components/LanguageSwitcher.tsx +0 -23
  123. package/frontend/src/components/LivingBrain.tsx +0 -220
  124. package/frontend/src/components/ProductFlow.tsx +0 -171
  125. package/frontend/src/components/WorkspaceProfileSwitcher.tsx +0 -176
  126. package/frontend/src/components/onboarding/AnalysisScreen.tsx +0 -135
  127. package/frontend/src/components/onboarding/DownloadConsentPanel.tsx +0 -29
  128. package/frontend/src/components/onboarding/InstallScreen.tsx +0 -263
  129. package/frontend/src/components/onboarding/LanguageChooser.tsx +0 -23
  130. package/frontend/src/components/onboarding/LoginScreen.tsx +0 -131
  131. package/frontend/src/components/onboarding/ProductFlowScreens.tsx +0 -13
  132. package/frontend/src/components/onboarding/RecommendationScreen.tsx +0 -116
  133. package/frontend/src/components/onboarding/recommendationModel.ts +0 -189
  134. package/frontend/src/components/primitives.tsx +0 -392
  135. package/frontend/src/components/ui/badge.tsx +0 -27
  136. package/frontend/src/components/ui/button.tsx +0 -37
  137. package/frontend/src/components/ui/card.tsx +0 -22
  138. package/frontend/src/components/ui/input.tsx +0 -16
  139. package/frontend/src/components/ui/textarea.tsx +0 -16
  140. package/frontend/src/features/admin/AdminConsole.tsx +0 -334
  141. package/frontend/src/features/brain/BrainCarePanel.tsx +0 -254
  142. package/frontend/src/features/brain/BrainComposer.tsx +0 -85
  143. package/frontend/src/features/brain/BrainConversation.tsx +0 -688
  144. package/frontend/src/features/brain/BrainGraphLayer.tsx +0 -365
  145. package/frontend/src/features/brain/BrainHome.tsx +0 -624
  146. package/frontend/src/features/brain/BrainMemoryLayer.tsx +0 -45
  147. package/frontend/src/features/brain/BrainOverviewPanel.tsx +0 -149
  148. package/frontend/src/features/brain/BrainRelationshipLayer.tsx +0 -43
  149. package/frontend/src/features/brain/DepthEmergence.tsx +0 -53
  150. package/frontend/src/features/brain/brainData.ts +0 -246
  151. package/frontend/src/features/brain/graphLayout.ts +0 -37
  152. package/frontend/src/features/brain/types.ts +0 -150
  153. package/frontend/src/features/review/ReviewCard.tsx +0 -100
  154. package/frontend/src/features/review/ReviewInbox.tsx +0 -127
  155. package/frontend/src/features/review/reviewHelpers.ts +0 -69
  156. package/frontend/src/i18n.ts +0 -1303
  157. package/frontend/src/lib/utils.ts +0 -33
  158. package/frontend/src/main.tsx +0 -23
  159. package/frontend/src/pages/Act.tsx +0 -458
  160. package/frontend/src/pages/Ask.tsx +0 -14
  161. package/frontend/src/pages/Brain.tsx +0 -914
  162. package/frontend/src/pages/Capture.tsx +0 -258
  163. package/frontend/src/pages/Library.tsx +0 -486
  164. package/frontend/src/pages/System.tsx +0 -621
  165. package/frontend/src/routes.ts +0 -92
  166. package/frontend/src/store/appStore.ts +0 -94
  167. package/frontend/src/styles.css +0 -6579
  168. package/static/app/assets/Act-DOvf59ru.js +0 -2
  169. package/static/app/assets/Act-DOvf59ru.js.map +0 -1
  170. package/static/app/assets/Brain-C7_0mEiI.js +0 -322
  171. package/static/app/assets/Brain-C7_0mEiI.js.map +0 -1
  172. package/static/app/assets/Capture-B9Tlhzqr.js.map +0 -1
  173. package/static/app/assets/Library-BJPEEm5O.js +0 -2
  174. package/static/app/assets/Library-BJPEEm5O.js.map +0 -1
  175. package/static/app/assets/System-D6t9jo9V.js +0 -2
  176. package/static/app/assets/System-D6t9jo9V.js.map +0 -1
  177. package/static/app/assets/core-CwxXejkd.js.map +0 -1
  178. package/static/app/assets/index-C7g26IF6.css +0 -2
  179. package/static/app/assets/index-DbcEYJQ2.js +0 -17
  180. package/static/app/assets/index-DbcEYJQ2.js.map +0 -1
  181. package/static/app/assets/primitives-CD38lt4n.js +0 -2
  182. package/static/app/assets/primitives-CD38lt4n.js.map +0 -1
  183. package/static/app/assets/textarea-BZk6ybp5.js.map +0 -1
@@ -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,13 +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
195
+ def configure_model_runtime(**deps) -> None:
196
+ """Wire app-owned runtime dependencies without importing server_app.
127
197
 
198
+ Explicit per-key assignment (no blanket globals().update) so wiring is
199
+ auditable and side effects are visible. Preserves exact public module
200
+ globals and prior behavior for all callers and shims.
201
+ Now uses STATE class for clean decomp.
202
+ """
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
128
212
 
129
- def configure_model_runtime(**deps) -> None:
130
- """Wire app-owned runtime dependencies without importing server_app."""
131
- globals().update({key: value for key, value in deps.items() if key in globals()})
213
+ STATE.sync_to_module_globals()
132
214
 
133
215
 
134
216
  # Catalog data + version-dedup helpers live in ``model_catalog``; re-exported
@@ -159,66 +241,30 @@ def _update_env_file(env_file: Path, key: str, value: str) -> None:
159
241
  env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
160
242
 
161
243
 
162
- LOCAL_SERVER_PROCESSES: Dict[str, subprocess.Popen] = {}
244
+ LOCAL_SERVER_PROCESSES = _LOCAL_SERVER_PROCESSES
163
245
  VLLM_METAL_ENV = Path.home() / ".venv-vllm-metal"
164
246
  VLLM_METAL_BIN = VLLM_METAL_ENV / "bin" / "vllm"
165
247
  VLLM_METAL_PYTHON = VLLM_METAL_ENV / "bin" / "python"
166
248
  LMSTUDIO_BUNDLED_CLI = Path("/Applications/LM Studio.app/Contents/Resources/app/.webpack/lms")
167
249
 
168
250
  def windows_binary_candidates(binary: str) -> List[Path]:
169
- local_appdata = os.environ.get("LOCALAPPDATA", "")
170
- program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
171
- program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
172
- candidates = {
173
- "ollama": [
174
- Path(local_appdata) / "Programs" / "Ollama" / "ollama.exe" if local_appdata else None,
175
- Path(program_files) / "Ollama" / "ollama.exe",
176
- ],
177
- "lms": [
178
- Path(local_appdata) / "Programs" / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe" if local_appdata else None,
179
- Path(program_files) / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe",
180
- ],
181
- "nvidia-smi": [
182
- Path(program_files) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe",
183
- Path(program_files_x86) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe",
184
- ],
185
- }
186
- return [item for item in candidates.get(binary, []) if item is not None]
251
+ return _windows_binary_candidates(binary)
187
252
 
188
253
 
189
254
  def local_binary(binary: str) -> Optional[str]:
190
- found = shutil.which(binary)
191
- if found:
192
- return found
193
- if platform.system() == "Windows":
194
- for candidate in windows_binary_candidates(binary):
195
- if candidate.exists():
196
- return str(candidate)
197
- return None
255
+ return _local_binary(binary)
198
256
 
199
257
 
200
258
  def find_lmstudio_cli() -> Optional[str]:
201
- cli = local_binary("lms")
202
- if cli:
203
- return cli
204
- if LMSTUDIO_BUNDLED_CLI.exists():
205
- return str(LMSTUDIO_BUNDLED_CLI)
206
- return None
259
+ return _find_lmstudio_cli()
207
260
 
208
261
 
209
262
  def vllm_executable() -> Optional[str]:
210
- found = shutil.which("vllm")
211
- if found:
212
- return found
213
- if VLLM_METAL_BIN.exists():
214
- return str(VLLM_METAL_BIN)
215
- return None
263
+ return _vllm_executable()
216
264
 
217
265
 
218
266
  def vllm_metal_python() -> Optional[str]:
219
- if VLLM_METAL_PYTHON.exists():
220
- return str(VLLM_METAL_PYTHON)
221
- return None
267
+ return _vllm_metal_python()
222
268
 
223
269
 
224
270
  def _json_request(
@@ -252,35 +298,7 @@ def lmstudio_native_api_base() -> str:
252
298
 
253
299
 
254
300
  def ensure_lmstudio_server() -> None:
255
- base_url = lmstudio_native_api_base()
256
- try:
257
- _json_request(f"{base_url}/api/v1/models", headers={"Authorization": "Bearer lmstudio"}, timeout=2.5)
258
- return
259
- except Exception:
260
- pass
261
-
262
- cli = find_lmstudio_cli()
263
- if not cli:
264
- raise HTTPException(status_code=400, detail="LM Studio CLI를 찾지 못했습니다. LM Studio를 설치한 뒤 다시 시도하세요.")
265
-
266
- try:
267
- subprocess.Popen(
268
- [cli, "server", "start"],
269
- stdout=subprocess.DEVNULL,
270
- stderr=subprocess.DEVNULL,
271
- start_new_session=True,
272
- )
273
- except Exception as e:
274
- raise HTTPException(status_code=500, detail=f"LM Studio 서버 시작 실패: {e}")
275
-
276
- deadline = time.time() + 45
277
- while time.time() < deadline:
278
- try:
279
- _json_request(f"{base_url}/api/v1/models", headers={"Authorization": "Bearer lmstudio"}, timeout=2.5)
280
- return
281
- except Exception:
282
- time.sleep(1)
283
- raise HTTPException(status_code=500, detail="LM Studio Local Server를 자동으로 시작하지 못했습니다.")
301
+ return _ensure_lmstudio_server()
284
302
 
285
303
 
286
304
  _LMSTUDIO_MODELS_CACHE: List[Dict[str, object]] = []
@@ -415,18 +433,7 @@ def ensure_lmstudio_model(model_name: str) -> Dict[str, object]:
415
433
  }
416
434
 
417
435
  def engine_support_status(engine: str) -> Dict[str, object]:
418
- if engine != "vllm":
419
- return {"supported": True, "reason": None}
420
- is_apple_silicon = sys.platform == "darwin" and platform.machine() == "arm64"
421
- if sys.platform.startswith("win"):
422
- return {"supported": False, "reason": "vLLM은 Windows native 자동 설치보다 WSL2/Linux 환경을 권장합니다."}
423
- if sys.platform == "darwin" and not is_apple_silicon:
424
- return {"supported": False, "reason": "vLLM Metal 자동 설치는 Apple Silicon macOS에서만 지원됩니다."}
425
- if sys.version_info >= (3, 13) and is_apple_silicon:
426
- return {"supported": True, "reason": "현재 환경에서는 vLLM Metal 전용 런타임으로 설치합니다."}
427
- if sys.version_info >= (3, 13):
428
- return {"supported": False, "reason": "vLLM 설치는 현재 Python 3.13 이하 또는 별도 전용 런타임이 필요합니다."}
429
- return {"supported": True, "reason": None}
436
+ return _engine_support_status(engine)
430
437
 
431
438
  def hf_model_ready(repo_id: str, provider: str = "local_mlx") -> bool:
432
439
  model_dir = hf_model_dir(repo_id)
@@ -680,260 +687,31 @@ def download_hf_model(
680
687
 
681
688
 
682
689
  def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict[str, object]:
683
- ollama = local_binary("ollama")
684
- if not ollama:
685
- raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
686
- started_at = time.time()
687
- if progress_emit:
688
- progress_emit(model_download_progress_payload(
689
- "download",
690
- "Ollama 모델 다운로드를 시작합니다.",
691
- percent=0,
692
- detail=model_name,
693
- indeterminate=True,
694
- ))
695
- process = subprocess.Popen(
696
- [ollama, "pull", model_name],
697
- stdout=subprocess.PIPE,
698
- stderr=subprocess.STDOUT,
699
- text=True,
700
- bufsize=1,
701
- )
702
- last_percent: Optional[float] = None
703
- lines: List[str] = []
704
- try:
705
- assert process.stdout is not None
706
- for raw_line in process.stdout:
707
- for part in re.split(r"[\r\n]+", raw_line):
708
- line = part.strip()
709
- if not line:
710
- continue
711
- lines.append(line)
712
- match = re.search(r"(\d{1,3}(?:\.\d+)?)\s*%", line)
713
- if match:
714
- last_percent = min(100.0, float(match.group(1)))
715
- if progress_emit:
716
- progress_emit(model_download_progress_payload(
717
- "download",
718
- "Ollama 모델 다운로드 중입니다.",
719
- percent=last_percent,
720
- detail=line[-180:],
721
- eta_seconds=estimate_eta_seconds(started_at, last_percent),
722
- indeterminate=False,
723
- ))
724
- elif progress_emit:
725
- progress_emit(model_download_progress_payload(
726
- "download",
727
- "Ollama 모델 다운로드 중입니다.",
728
- percent=last_percent,
729
- detail=line[-180:],
730
- eta_seconds=estimate_eta_seconds(started_at, last_percent),
731
- indeterminate=last_percent is None,
732
- ))
733
- returncode = process.wait()
734
- except Exception:
735
- process.kill()
736
- raise
737
-
738
- if returncode != 0:
739
- tail = "\n".join(lines[-12:])
740
- raise HTTPException(status_code=500, detail=tail[-2000:] or "Ollama 모델 다운로드 실패")
741
-
742
- if progress_emit:
743
- progress_emit(model_download_progress_payload(
744
- "download",
745
- "Ollama 모델 다운로드가 완료되었습니다.",
746
- percent=100,
747
- detail=model_name,
748
- eta_seconds=0,
749
- indeterminate=False,
750
- ))
751
- return {"provider": "ollama", "model": model_name, "returncode": returncode}
690
+ return _pull_ollama_model_with_progress(model_name, progress_emit)
752
691
 
753
692
 
754
693
  def get_ollama_pulled_models() -> set:
755
- ollama = local_binary("ollama")
756
- if not ollama:
757
- return set()
758
- try:
759
- result = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=5, check=False)
760
- pulled = set()
761
- for line in result.stdout.splitlines()[1:]:
762
- parts = line.split()
763
- if parts:
764
- pulled.add(parts[0])
765
- return pulled
766
- except Exception:
767
- return set()
694
+ return _get_ollama_pulled_models()
768
695
 
769
696
 
770
697
  def get_openai_compatible_server_models(provider: str) -> List[str]:
771
- if provider == "lmstudio":
772
- models = []
773
- for item in get_lmstudio_models():
774
- if not isinstance(item, dict):
775
- continue
776
- key = str(item.get("key") or "").strip()
777
- loaded_instances = item.get("loaded_instances") or []
778
- if loaded_instances:
779
- instance_ids = [
780
- str(instance.get("id") or "").strip()
781
- for instance in loaded_instances
782
- if isinstance(instance, dict) and instance.get("id")
783
- ]
784
- models.extend(instance_ids or ([key] if key else []))
785
- return list(dict.fromkeys([model for model in models if model]))
786
-
787
- config = OPENAI_COMPATIBLE_PROVIDERS.get(provider) or {}
788
- base_url = os.getenv(config.get("base_url_env", "")) if config.get("base_url_env") else None
789
- base_url = (base_url or config.get("base_url") or "").rstrip("/")
790
- if not base_url:
791
- return []
792
-
793
- api_key = os.getenv(config.get("env_key", "")) or config.get("api_key_fallback") or provider
794
- req = urllib.request.Request(
795
- f"{base_url}/models",
796
- headers={"Authorization": f"Bearer {api_key}"},
797
- method="GET",
798
- )
799
- try:
800
- with urllib.request.urlopen(req, timeout=2.5) as res:
801
- payload = json.loads(res.read().decode("utf-8", errors="replace"))
802
- except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
803
- return []
804
-
805
- models = []
806
- for item in payload.get("data") or []:
807
- model_id = item.get("id") if isinstance(item, dict) else None
808
- if model_id:
809
- models.append(str(model_id))
810
- return models
698
+ return _get_openai_compatible_server_models(provider)
811
699
 
812
700
 
813
701
  def ensure_ollama_server() -> None:
814
- ollama = local_binary("ollama")
815
- if not ollama:
816
- raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
817
- try:
818
- probe = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=3, check=False)
819
- if probe.returncode == 0:
820
- return
821
- except Exception:
822
- pass
823
- subprocess.Popen(
824
- [ollama, "serve"],
825
- stdout=subprocess.DEVNULL,
826
- stderr=subprocess.DEVNULL,
827
- start_new_session=True,
828
- )
829
- deadline = time.time() + 20
830
- while time.time() < deadline:
831
- try:
832
- probe = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=3, check=False)
833
- if probe.returncode == 0:
834
- return
835
- except Exception:
836
- pass
837
- time.sleep(0.5)
838
- raise HTTPException(status_code=500, detail="Ollama 서버를 자동으로 시작하지 못했습니다.")
702
+ return _ensure_ollama_server()
839
703
 
840
704
 
841
705
  def wait_for_openai_compatible_server(provider: str, model_name: Optional[str] = None, timeout: int = 45) -> bool:
842
- deadline = time.time() + timeout
843
- while time.time() < deadline:
844
- models = get_openai_compatible_server_models(provider)
845
- if models and (not model_name or model_name in models):
846
- return True
847
- time.sleep(1)
848
- return False
706
+ return _wait_for_openai_compatible_server(provider, model_name=model_name, timeout=timeout)
849
707
 
850
708
 
851
709
  def ensure_vllm_server(model_name: str) -> None:
852
- served_models = get_openai_compatible_server_models("vllm")
853
- if model_name in served_models:
854
- return
855
- vllm_bin = vllm_executable()
856
- vllm_metal_py = vllm_metal_python()
857
- if not vllm_bin and not vllm_metal_py and importlib.util.find_spec("vllm") is None:
858
- raise HTTPException(status_code=400, detail="vLLM runtime이 설치되지 않았습니다.")
859
-
860
- local_dir = hf_model_dir(model_name)
861
- if not vllm_metal_py and not hf_model_ready(model_name, "vllm"):
862
- download_hf_model(model_name, "vllm")
863
-
864
- running = LOCAL_SERVER_PROCESSES.get("vllm")
865
- if running and running.poll() is None:
866
- running.terminate()
867
- try:
868
- running.wait(timeout=10)
869
- except subprocess.TimeoutExpired:
870
- running.kill()
871
- elif served_models:
872
- raise HTTPException(status_code=409, detail="다른 vLLM 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
873
-
874
- running = LOCAL_SERVER_PROCESSES.get("vllm")
875
- if running and running.poll() is None:
876
- return
877
-
878
- _host_args = ["--host", "127.0.0.1", "--port", "8000"]
879
- if vllm_metal_py:
880
- command = [vllm_metal_py, "-m", "vllm_metal.server", "--model", model_name, *_host_args]
881
- elif vllm_bin:
882
- command = [vllm_bin, "serve", str(local_dir), "--served-model-name", model_name, *_host_args]
883
- else:
884
- command = [sys.executable, "-m", "vllm.entrypoints.openai.api_server", "--model", str(local_dir), "--served-model-name", model_name, *_host_args]
885
- LOCAL_SERVER_PROCESSES["vllm"] = subprocess.Popen(
886
- command,
887
- stdout=subprocess.DEVNULL,
888
- stderr=subprocess.DEVNULL,
889
- start_new_session=True,
890
- )
891
- if not wait_for_openai_compatible_server("vllm", model_name, timeout=90):
892
- raise HTTPException(status_code=500, detail="vLLM 서버가 모델을 자동 로드하지 못했습니다.")
710
+ return _ensure_vllm_server(model_name)
893
711
 
894
712
 
895
713
  def ensure_llamacpp_server(model_name: str) -> None:
896
- served_models = get_openai_compatible_server_models("llamacpp")
897
- if model_name in served_models:
898
- return
899
- running = LOCAL_SERVER_PROCESSES.get("llamacpp")
900
- if running and running.poll() is None:
901
- running.terminate()
902
- try:
903
- running.wait(timeout=10)
904
- except subprocess.TimeoutExpired:
905
- running.kill()
906
- elif served_models:
907
- raise HTTPException(status_code=409, detail="다른 llama.cpp 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
908
- if not shutil.which("llama-server"):
909
- raise HTTPException(status_code=400, detail="llama.cpp가 설치되지 않았습니다.")
910
- if not hf_model_ready(model_name, "llamacpp"):
911
- download_hf_model(model_name, "llamacpp")
912
-
913
- gguf_files = sorted(hf_model_dir(model_name).rglob("*.gguf"))
914
- if not gguf_files:
915
- raise HTTPException(status_code=500, detail="다운로드된 GGUF 파일을 찾지 못했습니다.")
916
-
917
- preferred = next((p for p in gguf_files if "q4_k_m" in p.name.lower()), None)
918
- model_file = preferred or gguf_files[0]
919
- LOCAL_SERVER_PROCESSES["llamacpp"] = subprocess.Popen(
920
- [
921
- "llama-server",
922
- "-m",
923
- str(model_file),
924
- "--alias",
925
- model_name,
926
- "--host",
927
- "127.0.0.1",
928
- "--port",
929
- "8080",
930
- ],
931
- stdout=subprocess.DEVNULL,
932
- stderr=subprocess.DEVNULL,
933
- start_new_session=True,
934
- )
935
- if not wait_for_openai_compatible_server("llamacpp", model_name, timeout=45):
936
- raise HTTPException(status_code=500, detail="llama.cpp 서버가 모델을 자동 로드하지 못했습니다.")
714
+ return _ensure_llamacpp_server(model_name)
937
715
 
938
716
 
939
717
  def engine_installed(engine: str) -> bool:
@@ -1150,68 +928,7 @@ def runtime_features() -> Dict:
1150
928
  }
1151
929
 
1152
930
  def install_engine(engine: str) -> Dict:
1153
- if engine not in ENGINE_INSTALLERS:
1154
- raise HTTPException(status_code=400, detail="지원하지 않는 엔진입니다.")
1155
- installer = ENGINE_INSTALLERS[engine]
1156
- required_binary = installer.get("requires_binary")
1157
- if required_binary and shutil.which(required_binary) is None:
1158
- raise HTTPException(status_code=400, detail=f"{required_binary}가 설치되어 있지 않아 자동 설치할 수 없습니다.")
1159
- command = installer["command"]
1160
- run_kwargs = {
1161
- "cwd": str(BASE_DIR),
1162
- "capture_output": True,
1163
- "text": True,
1164
- "timeout": 900,
1165
- "check": False,
1166
- }
1167
-
1168
- if engine == "vllm" and sys.platform == "darwin" and platform.machine() == "arm64":
1169
- command = [
1170
- "/bin/bash",
1171
- "-lc",
1172
- "set -euo pipefail; "
1173
- "if [ ! -x /opt/homebrew/bin/python3.12 ]; then brew install python@3.12; fi; "
1174
- "/opt/homebrew/bin/python3.12 -m venv ~/.venv-vllm-metal; "
1175
- "~/.venv-vllm-metal/bin/pip install -U pip setuptools wheel; "
1176
- "~/.venv-vllm-metal/bin/pip install vllm-metal",
1177
- ]
1178
- try:
1179
- completed = subprocess.run(command, **run_kwargs)
1180
- except subprocess.TimeoutExpired:
1181
- raise HTTPException(status_code=408, detail="엔진 설치 시간이 초과되었습니다.")
1182
- result = {
1183
- "engine": engine,
1184
- "command": " ".join(command),
1185
- "returncode": completed.returncode,
1186
- "stdout": completed.stdout[-12000:],
1187
- "stderr": completed.stderr[-12000:],
1188
- "installed": engine_installed(engine),
1189
- }
1190
- ollama = local_binary("ollama")
1191
- if engine == "ollama" and completed.returncode == 0 and ollama:
1192
- # Skip if already running to avoid orphan daemons.
1193
- already_up = False
1194
- try:
1195
- probe = subprocess.run([ollama, "list"], capture_output=True, timeout=2, check=False)
1196
- already_up = probe.returncode == 0
1197
- except Exception:
1198
- already_up = False
1199
- if already_up:
1200
- result["daemon_started"] = "already_running"
1201
- else:
1202
- try:
1203
- # Detach so the daemon survives this request but doesn't become our zombie.
1204
- subprocess.Popen(
1205
- [ollama, "serve"],
1206
- stdout=subprocess.DEVNULL,
1207
- stderr=subprocess.DEVNULL,
1208
- start_new_session=True,
1209
- )
1210
- result["daemon_started"] = True
1211
- except Exception as e:
1212
- logging.warning("ollama serve spawn failed: %s", e)
1213
- result["daemon_started"] = False
1214
- return result
931
+ return _install_engine(engine)
1215
932
 
1216
933
 
1217
934
  def _resolve_model_alias(model_id: str, engine: Optional[str] = None) -> str:
@@ -1301,58 +1018,9 @@ async def _smoke_test_loaded_model(
1301
1018
  *,
1302
1019
  api_key_override: Optional[str] = None,
1303
1020
  ) -> Dict[str, object]:
1304
- """로드 직후 짧은 채팅 테스트를 돌려 ready_to_chat 여부를 판정한다.
1305
-
1306
- Cloud(OpenAI/Anthropic/OpenRouter ) 모델은 사용자 비용 발생 가능성 때문에 skip.
1307
- 실패해도 예외를 던지지 않는다. 결과는 compat_cache에도 기록된다.
1308
- """
1309
- if (resolution.engine or "").lower() not in _LOCAL_SMOKE_ENGINES:
1310
- profile = _ensure_compat_profile(resolution.load_id, resolution.engine)
1311
- return {
1312
- "ok": True,
1313
- "reason": "skipped (cloud model — smoke test would incur cost)",
1314
- "answer": None,
1315
- "profile": profile.to_dict(),
1316
- "skipped": True,
1317
- }
1318
- try:
1319
- text = await asyncio.wait_for(
1320
- router.generate(
1321
- _SMOKE_PROMPT,
1322
- context=None,
1323
- max_tokens=128,
1324
- temperature=0.1,
1325
- ),
1326
- timeout=30,
1327
- )
1328
- except Exception as exc: # pragma: no cover - generator may not exist on all engines
1329
- reason = str(exc)[:200] or "generation_failed"
1330
- profile = _record_smoke_result(
1331
- resolution.load_id, resolution.engine, False, reason, status="failed"
1332
- )
1333
- return {
1334
- "ok": False,
1335
- "status": "failed",
1336
- "reason": reason,
1337
- "answer": None,
1338
- "profile": profile.to_dict(),
1339
- }
1340
-
1341
- profile = _ensure_compat_profile(resolution.load_id, resolution.engine)
1342
- cleaned = _compat_fast_postprocess(str(text or ""), profile.to_dict())
1343
- # item 3-3: ok / degraded / failed 3분류. degraded는 채팅은 가능하다.
1344
- status, reason = _classify_smoke_response(cleaned)
1345
- ok = status != "failed"
1346
- profile = _record_smoke_result(
1347
- resolution.load_id, resolution.engine, ok, reason, status=status
1348
- )
1349
- return {
1350
- "ok": ok,
1351
- "status": status,
1352
- "reason": reason,
1353
- "answer": cleaned,
1354
- "profile": profile.to_dict(),
1355
- }
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)
1356
1024
 
1357
1025
 
1358
1026
  async def prepare_and_load_model(
@@ -1364,123 +1032,17 @@ async def prepare_and_load_model(
1364
1032
  draft_model_id: Optional[str] = None,
1365
1033
  allow_download: bool = False,
1366
1034
  ) -> Dict[str, object]:
1367
- model_id = normalize_local_model_request(model_id, engine)
1368
- if not model_id:
1369
- raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
1035
+ from .model_loading import prepare_and_load_model as _impl
1370
1036
 
1371
- # 피드백 #1: ModelResolution을 모든 단계가 공유한다.
1372
- resolution = _ModelResolution.from_request(
1037
+ return await _impl(
1373
1038
  model_id,
1039
+ request,
1374
1040
  engine=engine,
1375
- user_email=user_email or get_current_user(request),
1376
- engine_aliases=MODEL_ENGINE_ALIASES,
1377
- )
1378
-
1379
- parsed_provider, parsed_model = parse_model_ref(model_id)
1380
- if parsed_provider == "mlx":
1381
- parsed_provider = "local_mlx"
1382
- compatibility = _model_runtime_compatibility(parsed_model, engine=parsed_provider)
1383
- if compatibility.get("supported") is False:
1384
- raise HTTPException(status_code=400, detail=compatibility)
1385
-
1386
- local_engines = {"local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"}
1387
- install_result: Dict[str, object] = {}
1388
- download_result: Optional[Dict[str, object]] = None
1389
-
1390
- if parsed_provider in local_engines:
1391
- if not engine_installed(parsed_provider) and not _download_allowed(allow_download):
1392
- _engine_install_block(parsed_provider)
1393
- install_result = ensure_engine_ready(parsed_provider)
1394
-
1395
- if parsed_provider == "local_mlx":
1396
- explicit_path = Path(parsed_model).expanduser()
1397
- if not explicit_path.exists() and not hf_model_ready(parsed_model, "local_mlx"):
1398
- if not _download_allowed(allow_download):
1399
- _download_block(parsed_provider, parsed_model)
1400
- download_result = download_hf_model(parsed_model, "local_mlx")
1401
- elif parsed_provider == "ollama":
1402
- ensure_ollama_server()
1403
- ollama = local_binary("ollama")
1404
- if not ollama:
1405
- raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
1406
- if parsed_model not in get_ollama_pulled_models():
1407
- if not _download_allowed(allow_download):
1408
- _download_block(parsed_provider, parsed_model)
1409
- completed = subprocess.run(
1410
- [ollama, "pull", parsed_model],
1411
- capture_output=True,
1412
- text=True,
1413
- timeout=900,
1414
- check=False,
1415
- )
1416
- if completed.returncode != 0:
1417
- raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "Ollama 모델 다운로드 실패")
1418
- download_result = {"provider": "ollama", "model": parsed_model, "returncode": completed.returncode}
1419
- elif parsed_provider == "vllm":
1420
- if not hf_model_ready(parsed_model, "vllm") and not _download_allowed(allow_download):
1421
- _download_block(parsed_provider, parsed_model)
1422
- ensure_vllm_server(parsed_model)
1423
- download_result = {"provider": "vllm", "model": parsed_model, "server_ready": True}
1424
- elif parsed_provider == "llamacpp":
1425
- if not hf_model_ready(parsed_model, "llamacpp") and not _download_allowed(allow_download):
1426
- _download_block(parsed_provider, parsed_model)
1427
- ensure_llamacpp_server(parsed_model)
1428
- download_result = {"provider": "llamacpp", "model": parsed_model, "server_ready": True}
1429
- elif parsed_provider == "lmstudio":
1430
- downloaded = {
1431
- str(item.get("key") or "").strip()
1432
- for item in get_lmstudio_models()
1433
- if isinstance(item, dict)
1434
- }
1435
- if parsed_model not in downloaded and not _download_allowed(allow_download):
1436
- _download_block(parsed_provider, parsed_model)
1437
- ensured = ensure_lmstudio_model(parsed_model)
1438
- resolved_model = str(
1439
- ensured.get("instance_id")
1440
- or ensured.get("resolved_model")
1441
- or parsed_model
1442
- ).strip()
1443
- parsed_model = resolved_model
1444
- model_id = f"lmstudio:{resolved_model}"
1445
- download_result = ensured
1446
-
1447
- effective_email = (user_email or get_current_user(request) or "").strip()
1448
- user_api_key = get_user_api_key(effective_email, parsed_provider) if parsed_provider != "local_mlx" else None
1449
- msg = await router.load_model(
1450
- model_id,
1451
- adapter_path,
1041
+ user_email=user_email,
1042
+ adapter_path=adapter_path,
1452
1043
  draft_model_id=draft_model_id,
1453
- api_key_override=user_api_key,
1454
- owner=effective_email or None,
1044
+ allow_download=allow_download,
1455
1045
  )
1456
- # 피드백 #1/#2: 로드 직후 ModelResolution을 실제 current로 동기화하고 smoke test 수행.
1457
- resolution.update_after_load(actual_current=router.current_model_id)
1458
- smoke_result: Dict[str, object] = {}
1459
- ready_to_chat = True
1460
- compat_status = "ok"
1461
- try:
1462
- smoke_result = await _smoke_test_loaded_model(resolution, api_key_override=user_api_key)
1463
- ready_to_chat = bool(smoke_result.get("ok"))
1464
- # item 3-3: smoke 결과의 3분류(ok/degraded/failed)를 그대로 노출한다.
1465
- compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
1466
- except Exception as exc: # never break load on smoke test failures
1467
- logging.warning("smoke test failed for %s: %s", resolution.load_id, exc)
1468
- compat_status = "unknown"
1469
- return {
1470
- "status": "ok",
1471
- "message": msg,
1472
- "model": model_id,
1473
- "current": router.current_model_id,
1474
- "engine": parsed_provider,
1475
- "installed_now": bool(install_result.get("installed_now")),
1476
- "download": download_result,
1477
- "resolution": resolution.to_dict(),
1478
- "downloaded": bool(download_result and not (isinstance(download_result, dict) and download_result.get("cached"))),
1479
- "loaded": True,
1480
- "ready_to_chat": ready_to_chat,
1481
- "compatibility_status": compat_status,
1482
- "smoke_test": smoke_result,
1483
- }
1484
1046
 
1485
1047
 
1486
1048
  def sse_event(event: str, data: Dict[str, object]) -> str:
@@ -1494,269 +1056,16 @@ async def prepare_and_load_model_stream(
1494
1056
  user_email: Optional[str] = None,
1495
1057
  allow_download: bool = False,
1496
1058
  ) -> AsyncIterator[str]:
1497
- model_id = normalize_local_model_request(model_id, engine)
1498
- if not model_id:
1499
- raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
1059
+ from .model_loading import prepare_and_load_model_stream as _impl
1500
1060
 
1501
- parsed_provider, parsed_model = parse_model_ref(model_id)
1502
- if parsed_provider == "mlx":
1503
- parsed_provider = "local_mlx"
1504
- compatibility = _model_runtime_compatibility(parsed_model, engine=parsed_provider)
1505
- if compatibility.get("supported") is False:
1506
- raise HTTPException(status_code=400, detail=compatibility)
1507
-
1508
- work_queue: "queue.Queue[Dict[str, object]]" = queue.Queue()
1509
- work_result: Dict[str, object] = {}
1510
-
1511
- def emit_progress(payload: Dict[str, object]) -> None:
1512
- work_queue.put({"kind": "progress", "data": payload})
1513
-
1514
- def blocking_prepare() -> None:
1515
- try:
1516
- local_engines = {"local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"}
1517
- install_result: Dict[str, object] = {}
1518
- download_result: Optional[Dict[str, object]] = None
1519
- prepared_model_id = model_id
1520
- prepared_model_name = parsed_model
1521
-
1522
- if parsed_provider in local_engines:
1523
- emit_progress(model_download_progress_payload(
1524
- "engine",
1525
- "실행 엔진을 확인하는 중입니다.",
1526
- percent=2,
1527
- indeterminate=True,
1528
- ))
1529
- if not engine_installed(parsed_provider) and not _download_allowed(allow_download):
1530
- _engine_install_block(parsed_provider)
1531
- install_result = ensure_engine_ready(parsed_provider)
1532
- emit_progress(model_download_progress_payload(
1533
- "engine",
1534
- "실행 엔진 준비가 완료되었습니다.",
1535
- percent=10,
1536
- indeterminate=False,
1537
- ))
1538
-
1539
- if parsed_provider == "local_mlx":
1540
- explicit_path = Path(parsed_model).expanduser()
1541
- if explicit_path.exists():
1542
- download_result = {"model": parsed_model, "path": str(explicit_path), "cached": True}
1543
- emit_progress(model_download_progress_payload(
1544
- "download",
1545
- "로컬 모델 경로를 확인했습니다.",
1546
- percent=100,
1547
- detail=str(explicit_path),
1548
- eta_seconds=0,
1549
- ))
1550
- elif not hf_model_ready(parsed_model, "local_mlx"):
1551
- if not _download_allowed(allow_download):
1552
- _download_block(parsed_provider, parsed_model)
1553
- download_result = download_hf_model(parsed_model, "local_mlx", progress_emit=emit_progress)
1554
- else:
1555
- download_result = {"model": parsed_model, "path": str(hf_model_dir(parsed_model)), "cached": True}
1556
- emit_progress(model_download_progress_payload(
1557
- "download",
1558
- "이미 다운로드된 모델을 확인했습니다.",
1559
- percent=100,
1560
- eta_seconds=0,
1561
- ))
1562
- elif parsed_provider == "ollama":
1563
- emit_progress(model_download_progress_payload(
1564
- "engine",
1565
- "Ollama 서버를 확인하는 중입니다.",
1566
- percent=12,
1567
- indeterminate=True,
1568
- ))
1569
- ensure_ollama_server()
1570
- if parsed_model not in get_ollama_pulled_models():
1571
- if not _download_allowed(allow_download):
1572
- _download_block(parsed_provider, parsed_model)
1573
- download_result = pull_ollama_model_with_progress(parsed_model, progress_emit=emit_progress)
1574
- else:
1575
- download_result = {"provider": "ollama", "model": parsed_model, "cached": True}
1576
- emit_progress(model_download_progress_payload(
1577
- "download",
1578
- "이미 다운로드된 Ollama 모델을 확인했습니다.",
1579
- percent=100,
1580
- detail=parsed_model,
1581
- eta_seconds=0,
1582
- ))
1583
- elif parsed_provider == "vllm":
1584
- if not hf_model_ready(parsed_model, "vllm"):
1585
- if not _download_allowed(allow_download):
1586
- _download_block(parsed_provider, parsed_model)
1587
- download_result = download_hf_model(parsed_model, "vllm", progress_emit=emit_progress)
1588
- else:
1589
- download_result = {"provider": "vllm", "model": parsed_model, "cached": True}
1590
- emit_progress(model_download_progress_payload(
1591
- "download",
1592
- "이미 다운로드된 모델을 확인했습니다.",
1593
- percent=100,
1594
- detail=parsed_model,
1595
- eta_seconds=0,
1596
- ))
1597
- emit_progress(model_download_progress_payload(
1598
- "server",
1599
- "vLLM 서버를 시작하는 중입니다.",
1600
- percent=92,
1601
- indeterminate=True,
1602
- ))
1603
- ensure_vllm_server(parsed_model)
1604
- download_result = {**(download_result or {}), "provider": "vllm", "model": parsed_model, "server_ready": True}
1605
- elif parsed_provider == "llamacpp":
1606
- if not hf_model_ready(parsed_model, "llamacpp"):
1607
- if not _download_allowed(allow_download):
1608
- _download_block(parsed_provider, parsed_model)
1609
- download_result = download_hf_model(parsed_model, "llamacpp", progress_emit=emit_progress)
1610
- else:
1611
- download_result = {"provider": "llamacpp", "model": parsed_model, "cached": True}
1612
- emit_progress(model_download_progress_payload(
1613
- "download",
1614
- "이미 다운로드된 GGUF 모델을 확인했습니다.",
1615
- percent=100,
1616
- detail=parsed_model,
1617
- eta_seconds=0,
1618
- ))
1619
- emit_progress(model_download_progress_payload(
1620
- "server",
1621
- "llama.cpp 서버를 시작하는 중입니다.",
1622
- percent=92,
1623
- indeterminate=True,
1624
- ))
1625
- ensure_llamacpp_server(parsed_model)
1626
- download_result = {**(download_result or {}), "provider": "llamacpp", "model": parsed_model, "server_ready": True}
1627
- elif parsed_provider == "lmstudio":
1628
- downloaded = {
1629
- str(item.get("key") or "").strip()
1630
- for item in get_lmstudio_models()
1631
- if isinstance(item, dict)
1632
- }
1633
- if parsed_model not in downloaded and not _download_allowed(allow_download):
1634
- _download_block(parsed_provider, parsed_model)
1635
- emit_progress(model_download_progress_payload(
1636
- "download",
1637
- "LM Studio 모델을 확인하는 중입니다.",
1638
- percent=35,
1639
- indeterminate=True,
1640
- ))
1641
- ensured = ensure_lmstudio_model(parsed_model)
1642
- resolved_model = str(
1643
- ensured.get("instance_id")
1644
- or ensured.get("resolved_model")
1645
- or parsed_model
1646
- ).strip()
1647
- prepared_model_name = resolved_model
1648
- prepared_model_id = f"lmstudio:{resolved_model}"
1649
- download_result = ensured
1650
- else:
1651
- emit_progress(model_download_progress_payload(
1652
- "engine",
1653
- "모델 연결을 준비하는 중입니다.",
1654
- percent=30,
1655
- indeterminate=True,
1656
- ))
1657
-
1658
- work_result.update({
1659
- "model_id": prepared_model_id,
1660
- "parsed_provider": parsed_provider,
1661
- "parsed_model": prepared_model_name,
1662
- "install_result": install_result,
1663
- "download_result": download_result,
1664
- })
1665
- work_queue.put({"kind": "done"})
1666
- except HTTPException as exc:
1667
- work_queue.put({"kind": "error", "status_code": exc.status_code, "detail": exc.detail})
1668
- except Exception as exc:
1669
- logging.exception("model prepare stream worker failed")
1670
- work_queue.put({
1671
- "kind": "error",
1672
- "status_code": 500,
1673
- "detail": _friendly_model_runtime_error(exc, model_id=model_id, engine=parsed_provider),
1674
- })
1675
-
1676
- worker = threading.Thread(target=blocking_prepare, daemon=True)
1677
- worker.start()
1678
-
1679
- while True:
1680
- item = await asyncio.to_thread(work_queue.get)
1681
- kind = item.get("kind")
1682
- if kind == "progress":
1683
- yield sse_event("progress", item["data"])
1684
- elif kind == "error":
1685
- raise HTTPException(
1686
- status_code=int(item.get("status_code") or 500),
1687
- detail=item.get("detail") or "모델 준비에 실패했습니다.",
1688
- )
1689
- elif kind == "done":
1690
- break
1691
-
1692
- prepared_model_id = str(work_result.get("model_id") or model_id)
1693
- prepared_provider = str(work_result.get("parsed_provider") or parsed_provider)
1694
- install_result = work_result.get("install_result") or {}
1695
- download_result = work_result.get("download_result")
1696
-
1697
- yield sse_event("progress", model_download_progress_payload(
1698
- "load",
1699
- "모델을 메모리에 로드하는 중입니다.",
1700
- percent=96,
1701
- indeterminate=True,
1702
- ))
1703
-
1704
- effective_email = (user_email or get_current_user(request) or "").strip()
1705
- user_api_key = get_user_api_key(effective_email, prepared_provider) if prepared_provider != "local_mlx" else None
1706
- msg = await router.load_model(
1707
- prepared_model_id,
1708
- None,
1709
- draft_model_id=None,
1710
- api_key_override=user_api_key,
1711
- owner=effective_email or None,
1712
- )
1713
- # 피드백 #1/#2: SSE에도 ModelResolution과 smoke test 결과를 같이 내려준다.
1714
- resolution_stream = _ModelResolution.from_request(
1715
- prepared_model_id,
1716
- engine=prepared_provider,
1717
- user_email=effective_email or None,
1718
- engine_aliases=MODEL_ENGINE_ALIASES,
1719
- )
1720
- resolution_stream.update_after_load(actual_current=router.current_model_id)
1721
- yield sse_event("progress", model_download_progress_payload(
1722
- "smoke_test",
1723
- "채팅 호환성 테스트 중입니다.",
1724
- percent=98,
1725
- indeterminate=True,
1726
- ))
1727
- smoke_result: Dict[str, object] = {}
1728
- ready_to_chat = True
1729
- compat_status = "ok"
1730
- try:
1731
- smoke_result = await _smoke_test_loaded_model(resolution_stream, api_key_override=user_api_key)
1732
- ready_to_chat = bool(smoke_result.get("ok"))
1733
- # item 3-3: smoke 결과의 3분류(ok/degraded/failed)를 그대로 노출한다.
1734
- compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
1735
- except Exception as exc:
1736
- logging.warning("smoke test (stream) failed for %s: %s", resolution_stream.load_id, exc)
1737
- compat_status = "unknown"
1738
- result = {
1739
- "status": "ok",
1740
- "message": msg,
1741
- "model": prepared_model_id,
1742
- "current": router.current_model_id,
1743
- "engine": prepared_provider,
1744
- "installed_now": bool(isinstance(install_result, dict) and install_result.get("installed_now")),
1745
- "download": download_result,
1746
- "resolution": resolution_stream.to_dict(),
1747
- "downloaded": bool(download_result and not (isinstance(download_result, dict) and download_result.get("cached"))),
1748
- "loaded": True,
1749
- "ready_to_chat": ready_to_chat,
1750
- "compatibility_status": compat_status,
1751
- "smoke_test": smoke_result,
1752
- }
1753
- yield sse_event("progress", model_download_progress_payload(
1754
- "done",
1755
- "모델 준비가 완료되었습니다.",
1756
- percent=100,
1757
- eta_seconds=0,
1758
- ))
1759
- 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
1760
1069
 
1761
1070
 
1762
1071
  CLOUD_VERIFY_CACHE: Dict[str, Dict] = {}