ltcai 9.0.0 → 9.1.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 (187) hide show
  1. package/README.md +80 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +146 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +24 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +302 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +19 -9
  71. package/latticeai/core/agent_registry.py +1 -5
  72. package/latticeai/core/config.py +23 -3
  73. package/latticeai/core/context_builder.py +21 -3
  74. package/latticeai/core/invitations.py +1 -4
  75. package/latticeai/core/io_utils.py +9 -1
  76. package/latticeai/core/legacy_compatibility.py +24 -3
  77. package/latticeai/core/marketplace.py +1 -1
  78. package/latticeai/core/plugins.py +15 -0
  79. package/latticeai/core/policy.py +1 -1
  80. package/latticeai/core/realtime.py +38 -15
  81. package/latticeai/core/sessions.py +7 -2
  82. package/latticeai/core/timeutil.py +10 -0
  83. package/latticeai/core/tool_registry.py +90 -18
  84. package/latticeai/core/users.py +1 -5
  85. package/latticeai/core/workspace_graph_trace.py +31 -5
  86. package/latticeai/core/workspace_memory.py +3 -1
  87. package/latticeai/core/workspace_os.py +18 -7
  88. package/latticeai/core/workspace_os_utils.py +0 -5
  89. package/latticeai/core/workspace_permissions.py +2 -1
  90. package/latticeai/core/workspace_plugins.py +1 -1
  91. package/latticeai/core/workspace_runs.py +14 -5
  92. package/latticeai/core/workspace_skills.py +1 -1
  93. package/latticeai/core/workspace_snapshots.py +2 -1
  94. package/latticeai/core/workspace_timeline.py +2 -1
  95. package/latticeai/integrations/telegram_bot.py +94 -43
  96. package/latticeai/models/router.py +130 -36
  97. package/latticeai/runtime/access_runtime.py +62 -4
  98. package/latticeai/runtime/automation_runtime.py +13 -7
  99. package/latticeai/runtime/brain_runtime.py +19 -7
  100. package/latticeai/runtime/chat_wiring.py +2 -0
  101. package/latticeai/runtime/config_runtime.py +58 -26
  102. package/latticeai/runtime/context_runtime.py +16 -4
  103. package/latticeai/runtime/hooks_runtime.py +1 -1
  104. package/latticeai/runtime/model_wiring.py +33 -5
  105. package/latticeai/runtime/namespace_runtime.py +62 -72
  106. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  107. package/latticeai/runtime/review_wiring.py +1 -1
  108. package/latticeai/runtime/router_registration.py +34 -1
  109. package/latticeai/runtime/security_runtime.py +110 -13
  110. package/latticeai/runtime/stages.py +27 -0
  111. package/latticeai/server_app.py +5 -1
  112. package/latticeai/services/architecture_readiness.py +74 -5
  113. package/latticeai/services/brain_automation.py +3 -26
  114. package/latticeai/services/chat_service.py +205 -15
  115. package/latticeai/services/local_knowledge.py +423 -0
  116. package/latticeai/services/memory_service.py +55 -21
  117. package/latticeai/services/model_engines.py +48 -33
  118. package/latticeai/services/model_errors.py +17 -0
  119. package/latticeai/services/model_loading.py +28 -25
  120. package/latticeai/services/model_runtime.py +228 -162
  121. package/latticeai/services/p_reinforce.py +22 -3
  122. package/latticeai/services/platform_runtime.py +83 -23
  123. package/latticeai/services/product_readiness.py +19 -17
  124. package/latticeai/services/review_queue.py +12 -0
  125. package/latticeai/services/router_context.py +1 -0
  126. package/latticeai/services/run_executor.py +4 -9
  127. package/latticeai/services/search_service.py +203 -28
  128. package/latticeai/services/tool_dispatch.py +28 -5
  129. package/latticeai/services/triggers.py +53 -4
  130. package/latticeai/services/upload_service.py +13 -2
  131. package/latticeai/setup/__init__.py +25 -0
  132. package/latticeai/setup/auto_setup.py +857 -0
  133. package/latticeai/setup/wizard.py +1264 -0
  134. package/latticeai/tools/__init__.py +278 -0
  135. package/{tools → latticeai/tools}/commands.py +67 -7
  136. package/{tools → latticeai/tools}/computer.py +1 -1
  137. package/{tools → latticeai/tools}/documents.py +1 -1
  138. package/{tools → latticeai/tools}/filesystem.py +3 -3
  139. package/latticeai/tools/knowledge.py +178 -0
  140. package/{tools → latticeai/tools}/local_files.py +1 -1
  141. package/{tools → latticeai/tools}/network.py +0 -1
  142. package/local_knowledge_api.py +4 -342
  143. package/package.json +22 -2
  144. package/scripts/brain_quality_eval.py +3 -1
  145. package/scripts/bump_version.py +3 -0
  146. package/scripts/capture_release_evidence.mjs +180 -0
  147. package/scripts/check_current_release_docs.mjs +142 -0
  148. package/scripts/check_i18n_literals.mjs +107 -31
  149. package/scripts/check_openapi_drift.mjs +56 -0
  150. package/scripts/export_openapi.py +67 -7
  151. package/scripts/i18n_literal_allowlist.json +22 -24
  152. package/scripts/run_integration_tests.mjs +72 -10
  153. package/scripts/validate_release_artifacts.py +49 -7
  154. package/scripts/wheel_smoke.py +5 -0
  155. package/setup_wizard.py +3 -1260
  156. package/src-tauri/Cargo.lock +1 -1
  157. package/src-tauri/Cargo.toml +1 -1
  158. package/src-tauri/tauri.conf.json +1 -1
  159. package/static/app/asset-manifest.json +11 -11
  160. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  161. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  162. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  163. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  164. package/static/app/assets/System-80lHW0Ux.js +1 -0
  165. package/static/app/assets/index-BiMofBTM.js +17 -0
  166. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  167. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  168. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  169. package/static/app/index.html +2 -2
  170. package/static/sw.js +1 -1
  171. package/tools/__init__.py +21 -269
  172. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  173. package/latticeai/runtime/app_context_runtime.py +0 -13
  174. package/latticeai/runtime/tail_wiring.py +0 -21
  175. package/scripts/com.pts.claudecode.discord.plist +0 -31
  176. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  177. package/scripts/start-pts-claudecode-discord.sh +0 -51
  178. package/static/app/assets/Act-21lIXx2E.js +0 -1
  179. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  180. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  181. package/static/app/assets/Library-bFMtyni3.js +0 -1
  182. package/static/app/assets/System-K6krGCqn.js +0 -1
  183. package/static/app/assets/index-C4R3ws30.js +0 -17
  184. package/static/app/assets/index-ChSeOB02.css +0 -2
  185. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  186. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  187. package/tools/knowledge.py +0 -95
@@ -1,8 +1,9 @@
1
1
  """Engine server management, pull, install and support logic extracted from model_runtime monolith.
2
2
 
3
3
  This module is the home for engine-specific server starting, model pulling and install flows.
4
- model_runtime re-exports the names to keep exact legacy globals, callers and monkeypatching working.
5
- Circular imports are avoided by late imports inside functions.
4
+ ``model_runtime`` exposes compatibility callables, while application configuration
5
+ is passed explicitly through the bound runtime service. Circular imports are
6
+ avoided by late imports inside functions.
6
7
  """
7
8
  from __future__ import annotations
8
9
 
@@ -21,8 +22,8 @@ import urllib.request
21
22
  from pathlib import Path
22
23
  from typing import Any, Dict, List, Optional
23
24
 
24
- from fastapi import HTTPException
25
-
25
+ from latticeai.services.model_errors import ModelRuntimeError
26
+ from latticeai.services.model_catalog import ENGINE_INSTALLERS
26
27
  from latticeai.services.process_audit import (
27
28
  CommandConfirmationError,
28
29
  append_process_audit_event,
@@ -148,7 +149,7 @@ def ensure_lmstudio_server() -> None:
148
149
 
149
150
  cli = find_lmstudio_cli()
150
151
  if not cli:
151
- raise HTTPException(status_code=400, detail="LM Studio CLI를 찾지 못했습니다. LM Studio를 설치한 뒤 다시 시도하세요.")
152
+ raise ModelRuntimeError(status_code=400, detail="LM Studio CLI를 찾지 못했습니다. LM Studio를 설치한 뒤 다시 시도하세요.")
152
153
 
153
154
  try:
154
155
  subprocess.Popen(
@@ -158,7 +159,7 @@ def ensure_lmstudio_server() -> None:
158
159
  start_new_session=True,
159
160
  )
160
161
  except Exception as e:
161
- raise HTTPException(status_code=500, detail=f"LM Studio 서버 시작 실패: {e}")
162
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 서버 시작 실패: {e}")
162
163
 
163
164
  deadline = time.time() + 45
164
165
  while time.time() < deadline:
@@ -167,13 +168,13 @@ def ensure_lmstudio_server() -> None:
167
168
  return
168
169
  except Exception:
169
170
  time.sleep(1)
170
- raise HTTPException(status_code=500, detail="LM Studio Local Server를 자동으로 시작하지 못했습니다.")
171
+ raise ModelRuntimeError(status_code=500, detail="LM Studio Local Server를 자동으로 시작하지 못했습니다.")
171
172
 
172
173
 
173
174
  def ensure_ollama_server() -> None:
174
175
  ollama = local_binary("ollama")
175
176
  if not ollama:
176
- raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
177
+ raise ModelRuntimeError(status_code=400, detail="Ollama가 설치되지 않았습니다.")
177
178
  try:
178
179
  probe = subprocess.run([ollama, "list"], capture_output=True, text=True, timeout=3, check=False)
179
180
  if probe.returncode == 0:
@@ -195,7 +196,7 @@ def ensure_ollama_server() -> None:
195
196
  except Exception:
196
197
  pass
197
198
  time.sleep(0.5)
198
- raise HTTPException(status_code=500, detail="Ollama 서버를 자동으로 시작하지 못했습니다.")
199
+ raise ModelRuntimeError(status_code=500, detail="Ollama 서버를 자동으로 시작하지 못했습니다.")
199
200
 
200
201
 
201
202
  def get_openai_compatible_server_models(provider: str) -> List[str]:
@@ -262,7 +263,7 @@ def ensure_vllm_server(model_name: str) -> None:
262
263
  vllm_bin = vllm_executable()
263
264
  vllm_metal_py = vllm_metal_python()
264
265
  if not vllm_bin and not vllm_metal_py and importlib.util.find_spec("vllm") is None:
265
- raise HTTPException(status_code=400, detail="vLLM runtime이 설치되지 않았습니다.")
266
+ raise ModelRuntimeError(status_code=400, detail="vLLM runtime이 설치되지 않았습니다.")
266
267
 
267
268
  local_dir = hf_model_dir(model_name)
268
269
  if not vllm_metal_py and not hf_model_ready(model_name, "vllm"):
@@ -276,7 +277,7 @@ def ensure_vllm_server(model_name: str) -> None:
276
277
  except subprocess.TimeoutExpired:
277
278
  running.kill()
278
279
  elif served_models:
279
- raise HTTPException(status_code=409, detail="다른 vLLM 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
280
+ raise ModelRuntimeError(status_code=409, detail="다른 vLLM 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
280
281
 
281
282
  running = LOCAL_SERVER_PROCESSES.get("vllm")
282
283
  if running and running.poll() is None:
@@ -296,7 +297,7 @@ def ensure_vllm_server(model_name: str) -> None:
296
297
  start_new_session=True,
297
298
  )
298
299
  if not wait_for_openai_compatible_server("vllm", model_name, timeout=90):
299
- raise HTTPException(status_code=500, detail="vLLM 서버가 모델을 자동 로드하지 못했습니다.")
300
+ raise ModelRuntimeError(status_code=500, detail="vLLM 서버가 모델을 자동 로드하지 못했습니다.")
300
301
 
301
302
 
302
303
  def ensure_llamacpp_server(model_name: str) -> None:
@@ -313,15 +314,15 @@ def ensure_llamacpp_server(model_name: str) -> None:
313
314
  except subprocess.TimeoutExpired:
314
315
  running.kill()
315
316
  elif served_models:
316
- raise HTTPException(status_code=409, detail="다른 llama.cpp 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
317
+ raise ModelRuntimeError(status_code=409, detail="다른 llama.cpp 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
317
318
  if not shutil.which("llama-server"):
318
- raise HTTPException(status_code=400, detail="llama.cpp가 설치되지 않았습니다.")
319
+ raise ModelRuntimeError(status_code=400, detail="llama.cpp가 설치되지 않았습니다.")
319
320
  if not hf_model_ready(model_name, "llamacpp"):
320
321
  download_hf_model(model_name, "llamacpp")
321
322
 
322
323
  gguf_files = sorted(hf_model_dir(model_name).rglob("*.gguf"))
323
324
  if not gguf_files:
324
- raise HTTPException(status_code=500, detail="다운로드된 GGUF 파일을 찾지 못했습니다.")
325
+ raise ModelRuntimeError(status_code=500, detail="다운로드된 GGUF 파일을 찾지 못했습니다.")
325
326
 
326
327
  preferred = next((p for p in gguf_files if "q4_k_m" in p.name.lower()), None)
327
328
  model_file = preferred or gguf_files[0]
@@ -342,13 +343,13 @@ def ensure_llamacpp_server(model_name: str) -> None:
342
343
  start_new_session=True,
343
344
  )
344
345
  if not wait_for_openai_compatible_server("llamacpp", model_name, timeout=45):
345
- raise HTTPException(status_code=500, detail="llama.cpp 서버가 모델을 자동 로드하지 못했습니다.")
346
+ raise ModelRuntimeError(status_code=500, detail="llama.cpp 서버가 모델을 자동 로드하지 못했습니다.")
346
347
 
347
348
 
348
349
  def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict[str, object]:
349
350
  ollama = local_binary("ollama")
350
351
  if not ollama:
351
- raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
352
+ raise ModelRuntimeError(status_code=400, detail="Ollama가 설치되지 않았습니다.")
352
353
  if progress_emit:
353
354
  progress_emit(_progress_payload(
354
355
  "download",
@@ -402,7 +403,7 @@ def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict
402
403
 
403
404
  if returncode != 0:
404
405
  tail = "\n".join(lines[-12:])
405
- raise HTTPException(status_code=500, detail=tail[-2000:] or "Ollama 모델 다운로드 실패")
406
+ raise ModelRuntimeError(status_code=500, detail=tail[-2000:] or "Ollama 모델 다운로드 실패")
406
407
 
407
408
  if progress_emit:
408
409
  progress_emit(_progress_payload(
@@ -447,15 +448,17 @@ def engine_support_status(engine: str) -> Dict[str, object]:
447
448
  return {"supported": True, "reason": None}
448
449
 
449
450
 
450
- def _engine_install_command(engine: str) -> tuple[list[str], str, bool]:
451
- from latticeai.services.model_runtime import BASE_DIR, ENGINE_INSTALLERS
452
-
451
+ def _engine_install_command(
452
+ engine: str,
453
+ *,
454
+ base_dir: Optional[Path] = None,
455
+ ) -> tuple[list[str], str, bool]:
453
456
  if engine not in ENGINE_INSTALLERS:
454
- raise HTTPException(status_code=400, detail="지원하지 않는 엔진입니다.")
457
+ raise ModelRuntimeError(status_code=400, detail="지원하지 않는 엔진입니다.")
455
458
  installer = ENGINE_INSTALLERS[engine]
456
459
  required_binary = installer.get("requires_binary")
457
460
  if required_binary and shutil.which(required_binary) is None:
458
- raise HTTPException(status_code=400, detail=f"{required_binary}가 설치되어 있지 않아 자동 설치할 수 없습니다.")
461
+ raise ModelRuntimeError(status_code=400, detail=f"{required_binary}가 설치되어 있지 않아 자동 설치할 수 없습니다.")
459
462
  command = list(installer["command"])
460
463
 
461
464
  if engine == "vllm" and sys.platform == "darwin" and platform.machine() == "arm64":
@@ -469,11 +472,15 @@ def _engine_install_command(engine: str) -> tuple[list[str], str, bool]:
469
472
  "~/.venv-vllm-metal/bin/pip install vllm-metal",
470
473
  ]
471
474
  requires_admin = bool(command and command[0] in {"apt", "apt-get", "dnf", "pacman"})
472
- return command, str(BASE_DIR), requires_admin
475
+ return command, str(base_dir or Path.cwd()), requires_admin
473
476
 
474
477
 
475
- def engine_install_plan(engine: str) -> Dict[str, Any]:
476
- command, cwd, requires_admin = _engine_install_command(engine)
478
+ def engine_install_plan(
479
+ engine: str,
480
+ *,
481
+ base_dir: Optional[Path] = None,
482
+ ) -> Dict[str, Any]:
483
+ command, cwd, requires_admin = _engine_install_command(engine, base_dir=base_dir)
477
484
  return command_plan(
478
485
  command,
479
486
  name=f"engine:{engine}",
@@ -484,16 +491,21 @@ def engine_install_plan(engine: str) -> Dict[str, Any]:
484
491
  )
485
492
 
486
493
 
487
- def install_engine(engine: str, confirmation_token: Optional[str] = None) -> Dict[str, Any]:
494
+ def install_engine(
495
+ engine: str,
496
+ confirmation_token: Optional[str] = None,
497
+ *,
498
+ base_dir: Optional[Path] = None,
499
+ ) -> Dict[str, Any]:
488
500
  from latticeai.services.model_runtime import engine_installed
489
501
 
490
- command, cwd, _requires_admin = _engine_install_command(engine)
491
- plan = engine_install_plan(engine)
502
+ command, cwd, _requires_admin = _engine_install_command(engine, base_dir=base_dir)
503
+ plan = engine_install_plan(engine, base_dir=base_dir)
492
504
  try:
493
505
  require_command_confirmation(command, confirmation_token, cwd=cwd, purpose="engine_install")
494
506
  except CommandConfirmationError as exc:
495
507
  append_process_audit_event("engine_install", plan=plan, status="denied", error=str(exc))
496
- raise HTTPException(
508
+ raise ModelRuntimeError(
497
509
  status_code=403,
498
510
  detail={
499
511
  "status": "confirmation_required",
@@ -514,7 +526,7 @@ def install_engine(engine: str, confirmation_token: Optional[str] = None) -> Dic
514
526
  completed = subprocess.run(command, **run_kwargs)
515
527
  except subprocess.TimeoutExpired:
516
528
  append_process_audit_event("engine_install", plan=plan, status="timeout")
517
- raise HTTPException(status_code=408, detail="엔진 설치 시간이 초과되었습니다.")
529
+ raise ModelRuntimeError(status_code=408, detail="엔진 설치 시간이 초과되었습니다.")
518
530
  except Exception as exc:
519
531
  append_process_audit_event("engine_install", plan=plan, status="error", error=str(exc))
520
532
  raise
@@ -576,6 +588,7 @@ async def _smoke_test_loaded_model(
576
588
  resolution: Any,
577
589
  *,
578
590
  api_key_override: Optional[str] = None,
591
+ model_router: Any = None,
579
592
  ) -> Dict[str, object]:
580
593
  """로드 직후 짧은 채팅 테스트를 돌려 ready_to_chat 여부를 판정한다.
581
594
 
@@ -585,7 +598,6 @@ async def _smoke_test_loaded_model(
585
598
  # late imports to avoid circular and keep lattice_brain/latticeai clean
586
599
  try:
587
600
  from latticeai.services.model_runtime import (
588
- router as _router,
589
601
  _LOCAL_SMOKE_ENGINES,
590
602
  _SMOKE_PROMPT,
591
603
  )
@@ -599,6 +611,9 @@ async def _smoke_test_loaded_model(
599
611
  except Exception as e:
600
612
  return {"ok": False, "reason": f"smoke import failed: {e}", "skipped": True}
601
613
 
614
+ if model_router is None:
615
+ return {"ok": False, "reason": "model router is not configured", "skipped": True}
616
+
602
617
  if (getattr(resolution, "engine", "") or "").lower() not in _LOCAL_SMOKE_ENGINES:
603
618
  profile = _ensure_compat_profile(getattr(resolution, "load_id", ""), getattr(resolution, "engine", ""))
604
619
  return {
@@ -610,7 +625,7 @@ async def _smoke_test_loaded_model(
610
625
  }
611
626
  try:
612
627
  text = await asyncio.wait_for(
613
- _router.generate(
628
+ model_router.generate(
614
629
  _SMOKE_PROMPT,
615
630
  context=None,
616
631
  max_tokens=128,
@@ -0,0 +1,17 @@
1
+ """Transport-neutral model runtime failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class ModelRuntimeError(RuntimeError):
9
+ """A model operation failure with an API-safe status and detail payload."""
10
+
11
+ def __init__(self, status_code: int, detail: Any):
12
+ self.status_code = int(status_code)
13
+ self.detail = detail
14
+ super().__init__(str(detail))
15
+
16
+
17
+ __all__ = ["ModelRuntimeError"]
@@ -11,13 +11,15 @@ import logging
11
11
  import queue
12
12
  import subprocess
13
13
  import threading
14
+ from functools import partial
14
15
  from pathlib import Path
15
- from typing import AsyncIterator, Dict, Optional
16
+ from typing import Any, AsyncIterator, Dict, Optional
16
17
 
17
- from fastapi import HTTPException, Request
18
+ from .model_errors import ModelRuntimeError
18
19
 
19
- # Late imports to avoid circulars during extraction
20
- def _get_model_runtime_deps():
20
+ # Late imports to avoid circulars during extraction. The state argument is
21
+ # mandatory so this module cannot fall back to ambient process configuration.
22
+ def _get_model_runtime_deps(runtime_state: Any):
21
23
  from .model_runtime import (
22
24
  _download_allowed,
23
25
  _download_block,
@@ -33,10 +35,8 @@ def _get_model_runtime_deps():
33
35
  ensure_lmstudio_model,
34
36
  ensure_ollama_server,
35
37
  ensure_vllm_server,
36
- get_current_user,
37
38
  get_lmstudio_models,
38
39
  get_ollama_pulled_models,
39
- get_user_api_key,
40
40
  hf_model_dir,
41
41
  hf_model_ready,
42
42
  local_binary,
@@ -45,27 +45,26 @@ def _get_model_runtime_deps():
45
45
  normalize_local_model_request,
46
46
  parse_model_ref,
47
47
  pull_ollama_model_with_progress,
48
- router,
49
48
  )
50
49
  return {
51
- "_download_allowed": _download_allowed,
50
+ "_download_allowed": partial(_download_allowed, state=runtime_state),
52
51
  "_download_block": _download_block,
53
52
  "_engine_install_block": _engine_install_block,
54
53
  "_friendly_model_runtime_error": _friendly_model_runtime_error,
55
54
  "_ModelResolution": _ModelResolution,
56
55
  "_model_runtime_compatibility": _model_runtime_compatibility,
57
- "_smoke_test_loaded_model": _smoke_test_loaded_model,
56
+ "_smoke_test_loaded_model": partial(_smoke_test_loaded_model, state=runtime_state),
58
57
  "download_hf_model": download_hf_model,
59
58
  "engine_installed": engine_installed,
60
- "ensure_engine_ready": ensure_engine_ready,
59
+ "ensure_engine_ready": partial(ensure_engine_ready, state=runtime_state),
61
60
  "ensure_llamacpp_server": ensure_llamacpp_server,
62
61
  "ensure_lmstudio_model": ensure_lmstudio_model,
63
62
  "ensure_ollama_server": ensure_ollama_server,
64
63
  "ensure_vllm_server": ensure_vllm_server,
65
- "get_current_user": get_current_user,
64
+ "get_current_user": runtime_state.get_current_user,
66
65
  "get_lmstudio_models": get_lmstudio_models,
67
66
  "get_ollama_pulled_models": get_ollama_pulled_models,
68
- "get_user_api_key": get_user_api_key,
67
+ "get_user_api_key": runtime_state.get_user_api_key,
69
68
  "hf_model_dir": hf_model_dir,
70
69
  "hf_model_ready": hf_model_ready,
71
70
  "local_binary": local_binary,
@@ -74,23 +73,25 @@ def _get_model_runtime_deps():
74
73
  "normalize_local_model_request": normalize_local_model_request,
75
74
  "parse_model_ref": parse_model_ref,
76
75
  "pull_ollama_model_with_progress": pull_ollama_model_with_progress,
77
- "router": router,
76
+ "router": runtime_state.router,
78
77
  }
79
78
 
80
79
 
81
80
  async def prepare_and_load_model(
82
81
  model_id: str,
83
- request: Request,
82
+ request: Any,
84
83
  engine: Optional[str] = None,
85
84
  user_email: Optional[str] = None,
86
85
  adapter_path: Optional[str] = None,
87
86
  draft_model_id: Optional[str] = None,
88
87
  allow_download: bool = False,
88
+ *,
89
+ runtime_state: Any,
89
90
  ) -> Dict[str, object]:
90
- deps = _get_model_runtime_deps()
91
+ deps = _get_model_runtime_deps(runtime_state)
91
92
  model_id = deps["normalize_local_model_request"](model_id, engine)
92
93
  if not model_id:
93
- raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
94
+ raise ModelRuntimeError(status_code=400, detail="모델 식별자가 비어 있습니다.")
94
95
 
95
96
  resolution = deps["_ModelResolution"].from_request(
96
97
  model_id,
@@ -104,7 +105,7 @@ async def prepare_and_load_model(
104
105
  parsed_provider = "local_mlx"
105
106
  compatibility = deps["_model_runtime_compatibility"](parsed_model, engine=parsed_provider)
106
107
  if compatibility.get("supported") is False:
107
- raise HTTPException(status_code=400, detail=compatibility)
108
+ raise ModelRuntimeError(status_code=400, detail=compatibility)
108
109
 
109
110
  local_engines = {"local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"}
110
111
  install_result: Dict[str, object] = {}
@@ -125,7 +126,7 @@ async def prepare_and_load_model(
125
126
  deps["ensure_ollama_server"]()
126
127
  ollama = deps["local_binary"]("ollama")
127
128
  if not ollama:
128
- raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
129
+ raise ModelRuntimeError(status_code=400, detail="Ollama가 설치되지 않았습니다.")
129
130
  if parsed_model not in deps["get_ollama_pulled_models"]():
130
131
  if not deps["_download_allowed"](allow_download):
131
132
  deps["_download_block"](parsed_provider, parsed_model)
@@ -137,7 +138,7 @@ async def prepare_and_load_model(
137
138
  check=False,
138
139
  )
139
140
  if completed.returncode != 0:
140
- raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "Ollama 모델 다운로드 실패")
141
+ raise ModelRuntimeError(status_code=500, detail=completed.stderr[-2000:] or "Ollama 모델 다운로드 실패")
141
142
  download_result = {"provider": "ollama", "model": parsed_model, "returncode": completed.returncode}
142
143
  elif parsed_provider == "vllm":
143
144
  if not deps["hf_model_ready"](parsed_model, "vllm") and not deps["_download_allowed"](allow_download):
@@ -210,22 +211,24 @@ def sse_event(event: str, data: Dict[str, object]) -> str:
210
211
 
211
212
  async def prepare_and_load_model_stream(
212
213
  model_id: str,
213
- request: Request,
214
+ request: Any,
214
215
  engine: Optional[str] = None,
215
216
  user_email: Optional[str] = None,
216
217
  allow_download: bool = False,
218
+ *,
219
+ runtime_state: Any,
217
220
  ) -> AsyncIterator[str]:
218
- deps = _get_model_runtime_deps()
221
+ deps = _get_model_runtime_deps(runtime_state)
219
222
  model_id = deps["normalize_local_model_request"](model_id, engine)
220
223
  if not model_id:
221
- raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
224
+ raise ModelRuntimeError(status_code=400, detail="모델 식별자가 비어 있습니다.")
222
225
 
223
226
  parsed_provider, parsed_model = deps["parse_model_ref"](model_id)
224
227
  if parsed_provider == "mlx":
225
228
  parsed_provider = "local_mlx"
226
229
  compatibility = deps["_model_runtime_compatibility"](parsed_model, engine=parsed_provider)
227
230
  if compatibility.get("supported") is False:
228
- raise HTTPException(status_code=400, detail=compatibility)
231
+ raise ModelRuntimeError(status_code=400, detail=compatibility)
229
232
 
230
233
  work_queue: "queue.Queue[Dict[str, object]]" = queue.Queue()
231
234
  work_result: Dict[str, object] = {}
@@ -385,7 +388,7 @@ async def prepare_and_load_model_stream(
385
388
  "download_result": download_result,
386
389
  })
387
390
  work_queue.put({"kind": "done"})
388
- except HTTPException as exc:
391
+ except ModelRuntimeError as exc:
389
392
  work_queue.put({"kind": "error", "status_code": exc.status_code, "detail": exc.detail})
390
393
  except Exception as exc:
391
394
  logging.exception("model prepare stream worker failed")
@@ -404,7 +407,7 @@ async def prepare_and_load_model_stream(
404
407
  if kind == "progress":
405
408
  yield sse_event("progress", item["data"])
406
409
  elif kind == "error":
407
- raise HTTPException(
410
+ raise ModelRuntimeError(
408
411
  status_code=int(item.get("status_code") or 500),
409
412
  detail=item.get("detail") or "모델 준비에 실패했습니다.",
410
413
  )