ltcai 10.8.0 → 10.9.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 (84) hide show
  1. package/README.md +54 -52
  2. package/docs/CHANGELOG.md +37 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/runtime/multi_agent.py +1 -1
  12. package/latticeai/__init__.py +1 -1
  13. package/latticeai/api/chat.py +9 -6
  14. package/latticeai/api/chat_agent_http.py +2 -1
  15. package/latticeai/api/chat_history.py +4 -2
  16. package/latticeai/api/chat_intents.py +14 -14
  17. package/latticeai/api/knowledge_graph.py +5 -4
  18. package/latticeai/api/local_files.py +25 -15
  19. package/latticeai/api/mcp.py +15 -11
  20. package/latticeai/api/memory.py +2 -1
  21. package/latticeai/api/models.py +39 -24
  22. package/latticeai/api/network_boundary.py +9 -4
  23. package/latticeai/api/portability.py +27 -19
  24. package/latticeai/api/project_sessions.py +7 -5
  25. package/latticeai/api/review_queue.py +12 -7
  26. package/latticeai/api/setup.py +10 -11
  27. package/latticeai/api/static_routes.py +62 -46
  28. package/latticeai/api/tools.py +7 -6
  29. package/latticeai/core/legacy_compatibility.py +1 -1
  30. package/latticeai/core/marketplace.py +1 -1
  31. package/latticeai/core/mcp_registry.py +20 -4
  32. package/latticeai/core/messages.py +222 -0
  33. package/latticeai/core/workspace_os_constants.py +1 -1
  34. package/latticeai/integrations/telegram_bot.py +20 -18
  35. package/latticeai/services/architecture_readiness.py +1 -1
  36. package/latticeai/services/model_loading.py +63 -27
  37. package/latticeai/services/product_readiness.py +1 -1
  38. package/package.json +3 -2
  39. package/scripts/check_current_release_docs.mjs +1 -1
  40. package/scripts/check_server_i18n.mjs +96 -0
  41. package/scripts/release_screen_claims.json +17 -8
  42. package/src-tauri/Cargo.lock +1 -1
  43. package/src-tauri/Cargo.toml +1 -1
  44. package/src-tauri/tauri.conf.json +1 -1
  45. package/static/app/asset-manifest.json +37 -37
  46. package/static/app/assets/{Act-CbQNsFgc.js → Act-CS9IeqUX.js} +1 -1
  47. package/static/app/assets/{AdminConsole-Bs8SR8fE.js → AdminConsole-3UkIEWGA.js} +1 -1
  48. package/static/app/assets/{Brain-BZIPsZlH.js → Brain-B22EmNqS.js} +1 -1
  49. package/static/app/assets/{BrainHome-BaH5MDce.js → BrainHome-CMDqgJF4.js} +2 -2
  50. package/static/app/assets/{BrainSignals-aDt-nSsW.js → BrainSignals-BeE8RJo3.js} +1 -1
  51. package/static/app/assets/{Capture-DDv940Bv.js → Capture-ZX9bQh68.js} +1 -1
  52. package/static/app/assets/{CommandPalette-Dq_MmPBl.js → CommandPalette-86m4FCcN.js} +1 -1
  53. package/static/app/assets/{Library-BkShbqmN.js → Library-Bhz5LUca.js} +1 -1
  54. package/static/app/assets/{LivingBrain-CXb8094H.js → LivingBrain-BSa0wpFG.js} +1 -1
  55. package/static/app/assets/{ProductFlow-BaiqFWLO.js → ProductFlow-IP4Q-5aQ.js} +1 -1
  56. package/static/app/assets/{ReviewCard-Be7E7X18.js → ReviewCard-BepjSDpN.js} +1 -1
  57. package/static/app/assets/{System-DCstbTxC.js → System-Bwx1h_jT.js} +1 -1
  58. package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
  59. package/static/app/assets/{bot-BR0eoeXY.js → bot-DQj0-LkM.js} +1 -1
  60. package/static/app/assets/{brain-BmWa1wea.js → brain-D8OEwmVj.js} +1 -1
  61. package/static/app/assets/{button-U1nU7xX4.js → button-CmTknyAP.js} +1 -1
  62. package/static/app/assets/{circle-pause-DG5KHjDL.js → circle-pause-yTCWRziJ.js} +1 -1
  63. package/static/app/assets/{circle-play-Chhcuq90.js → circle-play-Ccrva84R.js} +1 -1
  64. package/static/app/assets/{cpu-pkOSlT9h.js → cpu-CbJqWTlS.js} +1 -1
  65. package/static/app/assets/{download-CSRwz923.js → download-CkSzbzU-.js} +1 -1
  66. package/static/app/assets/{folder-open-DiMJ7W_7.js → folder-open-CKyjQ4PU.js} +1 -1
  67. package/static/app/assets/{hard-drive-iyLPAPdp.js → hard-drive-DAzk9um0.js} +1 -1
  68. package/static/app/assets/{index-BZRvGOM2.css → index-BfD-jhA9.css} +1 -1
  69. package/static/app/assets/{index-_ldE9TAX.js → index-CxOcwsHV.js} +3 -3
  70. package/static/app/assets/{input-e_r2hFAY.js → input-DcMETmZ7.js} +1 -1
  71. package/static/app/assets/{permissionCopy-9uOHktz3.js → permissionCopy-BVf13_25.js} +1 -1
  72. package/static/app/assets/{primitives-CnEAOmg2.js → primitives-CQV9Q2YM.js} +1 -1
  73. package/static/app/assets/search-CXGASMMH.js +1 -0
  74. package/static/app/assets/{share-2-DLDJJKmI.js → share-2-COWCHNZm.js} +1 -1
  75. package/static/app/assets/{shield-alert-BTtE-2EZ.js → shield-alert-BDrvilyK.js} +1 -1
  76. package/static/app/assets/{textarea-Bbzkcgqe.js → textarea-rUmsc8cP.js} +1 -1
  77. package/static/app/assets/{useFocusTrap-DJzQd-Za.js → useFocusTrap-Bi5UY_8v.js} +1 -1
  78. package/static/app/assets/{useQuery-6R_tqHRe.js → useQuery-C-AicB-3.js} +1 -1
  79. package/static/app/assets/{utils-XDsjcrhS.js → utils-BMwWg78e.js} +1 -1
  80. package/static/app/assets/{workspace-CwrG8LHq.js → workspace-Y93tls8P.js} +1 -1
  81. package/static/app/index.html +4 -4
  82. package/static/sw.js +1 -1
  83. package/static/app/assets/arrow-left-DC9Oquhs.js +0 -1
  84. package/static/app/assets/search-veep4n6f.js +0 -1
@@ -15,6 +15,12 @@ from fastapi.responses import FileResponse
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from latticeai.api.knowledge_graph import create_knowledge_graph_router
18
+ from latticeai.core.messages import (
19
+ DEFAULT_LANGUAGE,
20
+ http_error,
21
+ resolve_language,
22
+ translate,
23
+ )
18
24
  from latticeai.services.local_knowledge import create_local_knowledge_router
19
25
  from latticeai.tools import local_list, local_read, local_write
20
26
 
@@ -234,20 +240,20 @@ def create_local_files_router(
234
240
  )
235
241
  target = Path(path).expanduser().resolve()
236
242
  if not target.exists() or not target.is_file():
237
- raise HTTPException(status_code=404, detail="File not found")
243
+ raise http_error(404, "common.file_not_found", resolve_language(request))
238
244
  return FileResponse(str(target))
239
245
 
240
246
  # ── v9.8.0 ingestion jobs API (frozen paths — consumed by the frontend) ───
241
247
  def _require_pipeline():
242
248
  if ingestion_pipeline is None or not ingestion_pipeline.available():
243
- raise HTTPException(status_code=503, detail="Knowledge Graph ingestion is disabled.")
249
+ raise http_error(503, "capture.ingestion_disabled", DEFAULT_LANGUAGE)
244
250
 
245
251
  def _ingestion_write_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
246
252
  header = request.headers.get("X-Workspace-Id")
247
253
  header = header.strip() if header and header.strip() else None
248
254
  supplied = [value for value in (body_workspace, header) if value]
249
255
  if len(set(supplied)) > 1:
250
- raise HTTPException(status_code=403, detail="Workspace selectors must match.")
256
+ raise http_error(403, "common.workspace_mismatch", resolve_language(request))
251
257
  requested = supplied[0] if supplied else None
252
258
  if workspace_service is None:
253
259
  return requested
@@ -271,7 +277,7 @@ def create_local_files_router(
271
277
  _require_pipeline()
272
278
  job = ingestion_pipeline.get_background_job(job_id)
273
279
  if job is None:
274
- raise HTTPException(status_code=404, detail="ingestion job not found")
280
+ raise http_error(404, "ingestion.job_not_found", resolve_language(request))
275
281
  return job.as_dict()
276
282
 
277
283
  @router.post("/api/ingestion/jobs/{job_id}/resume")
@@ -281,7 +287,7 @@ def create_local_files_router(
281
287
  _require_pipeline()
282
288
  job = ingestion_pipeline.get_background_job(job_id)
283
289
  if job is None:
284
- raise HTTPException(status_code=404, detail="ingestion job not found")
290
+ raise http_error(404, "ingestion.job_not_found", resolve_language(request))
285
291
  if job.status == "running":
286
292
  return {"status": "already_running", "job_id": job_id, "job": job.as_dict()}
287
293
  remaining = len(job.remaining_indices())
@@ -312,7 +318,7 @@ def create_local_files_router(
312
318
  workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
313
319
  path = (req.path or "").strip()
314
320
  if not path:
315
- raise HTTPException(status_code=400, detail="path is required.")
321
+ raise http_error(400, "common.path_required", resolve_language(request))
316
322
  if not req.approved:
317
323
  return permission_gateway.local_permission_response(path, "read", current_user)
318
324
  permission_gateway.require_local_approval(
@@ -339,16 +345,16 @@ def create_local_files_router(
339
345
  return summary
340
346
 
341
347
  # ── folder watch mode: opt-in, off by default (backlog #8) ────────────────
342
- def _require_folder_watch():
348
+ def _require_folder_watch(request: Request):
343
349
  _require_pipeline()
344
350
  if folder_watch is None:
345
- raise HTTPException(status_code=503, detail="Folder watch service is unavailable.")
351
+ raise http_error(503, "ingestion.watch_unavailable", resolve_language(request))
346
352
 
347
353
  @router.get("/api/ingestion/watch")
348
354
  async def folder_watch_status(request: Request):
349
355
  """Watch-mode status: stored opt-ins, poller state, last scan results."""
350
356
  require_user(request)
351
- _require_folder_watch()
357
+ _require_folder_watch(request)
352
358
  return folder_watch.status()
353
359
 
354
360
  @router.post("/api/ingestion/watch")
@@ -361,11 +367,11 @@ def create_local_files_router(
361
367
  watch owner's workspace scope).
362
368
  """
363
369
  current_user = permission_gateway.require_local_user(request)
364
- _require_folder_watch()
370
+ _require_folder_watch(request)
365
371
  workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
366
372
  path = (req.path or "").strip()
367
373
  if not path:
368
- raise HTTPException(status_code=400, detail="path is required.")
374
+ raise http_error(400, "common.path_required", resolve_language(request))
369
375
  if not req.approved:
370
376
  return permission_gateway.local_permission_response(path, "read", current_user)
371
377
  permission_gateway.require_local_approval(
@@ -381,7 +387,11 @@ def create_local_files_router(
381
387
  recursive=req.recursive,
382
388
  )
383
389
  if result.get("status") != "ok":
384
- raise HTTPException(status_code=400, detail=result.get("detail") or "watch enable failed")
390
+ raise HTTPException(
391
+ status_code=400,
392
+ detail=result.get("detail")
393
+ or translate("ingestion.watch_enable_failed", resolve_language(request)),
394
+ )
385
395
  return result
386
396
 
387
397
  @router.delete("/api/ingestion/watch")
@@ -392,12 +402,12 @@ def create_local_files_router(
392
402
  ):
393
403
  """Opt back out of watch mode (removes the stored consent record)."""
394
404
  require_user(request)
395
- _require_folder_watch()
405
+ _require_folder_watch(request)
396
406
  if not watch_id and not path:
397
- raise HTTPException(status_code=400, detail="watch_id or path is required.")
407
+ raise http_error(400, "ingestion.watch_selector_required", resolve_language(request))
398
408
  result = folder_watch.disable(watch_id=watch_id, path=path)
399
409
  if result.get("status") == "not_found":
400
- raise HTTPException(status_code=404, detail="watch not found")
410
+ raise http_error(404, "ingestion.watch_not_found", resolve_language(request))
401
411
  return result
402
412
 
403
413
  @router.post("/local/write")
@@ -11,6 +11,7 @@ import cycle.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import asyncio
14
15
  import json
15
16
  import logging
16
17
  from datetime import datetime
@@ -29,6 +30,7 @@ from latticeai.core.mcp_registry import (
29
30
  _get_combined_registry,
30
31
  install_skill,
31
32
  )
33
+ from latticeai.core.messages import http_error, resolve_language
32
34
  from latticeai.core.tool_registry import (
33
35
  KNOWLEDGE_WRITE_TOOLS,
34
36
  MCP_TOOL_DESCRIPTIONS,
@@ -130,7 +132,7 @@ def create_mcp_router(
130
132
  except PermissionError as exc:
131
133
  raise HTTPException(status_code=403, detail=str(exc)) from exc
132
134
 
133
- def _read_workspace(requested: Optional[str], user: Optional[str]) -> Optional[str]:
135
+ def _read_workspace(request: Request, requested: Optional[str], user: Optional[str]) -> Optional[str]:
134
136
  if workspace_service is not None:
135
137
  try:
136
138
  return workspace_service.resolve_read_scope(requested, user)
@@ -139,7 +141,7 @@ def create_mcp_router(
139
141
  workspace_id = requested or "personal"
140
142
  allowed = _allowed_graph_workspaces(user)
141
143
  if allowed is not None and workspace_id not in set(allowed):
142
- raise HTTPException(status_code=403, detail=f"workspace '{workspace_id}' is not readable")
144
+ raise http_error(403, "common.workspace_unreadable", resolve_language(request), workspace=workspace_id)
143
145
  return workspace_id
144
146
 
145
147
  @router.get("/mcp/tools")
@@ -204,7 +206,7 @@ def create_mcp_router(
204
206
  registry = await _get_combined_registry()
205
207
  item = next((e for e in registry if e["id"] == mcp_id), None)
206
208
  if not item or item.get("install_mode") != "connector":
207
- raise HTTPException(status_code=404, detail="커넥터를 찾을 수 없습니다.")
209
+ raise http_error(404, "mcp.connector_not_found", resolve_language(request))
208
210
  installed = load_mcp_installs().get("installed", {})
209
211
  public = mcp_public_item(item, installed)
210
212
  public["instructions"] = [
@@ -229,8 +231,10 @@ def create_mcp_router(
229
231
  if not settings_path.exists():
230
232
  return {"servers": []}
231
233
  try:
232
- with open(settings_path, "r", encoding="utf-8") as f:
233
- settings = json.load(f)
234
+ # Home may be a network mount, where a read is not the microsecond
235
+ # a local one is; keep it off the loop like every other file read.
236
+ raw = await asyncio.to_thread(settings_path.read_text, "utf-8")
237
+ settings = json.loads(raw)
234
238
  mcp_servers = settings.get("mcpServers", {})
235
239
  servers = []
236
240
  for name, cfg in mcp_servers.items():
@@ -272,9 +276,9 @@ def create_mcp_router(
272
276
  admin_email, _ = require_admin(request)
273
277
  append_audit_event("mcp_custom_add", user_email=admin_email, name=req.name, package=req.package)
274
278
  if not req.name.strip():
275
- raise HTTPException(status_code=400, detail="name은 필수입니다.")
279
+ raise http_error(400, "mcp.name_required", resolve_language(request))
276
280
  if not req.package.strip():
277
- raise HTTPException(status_code=400, detail="package는 필수입니다.")
281
+ raise http_error(400, "mcp.package_required", resolve_language(request))
278
282
  items = _load_custom_mcps()
279
283
  entry = {
280
284
  "id": f"custom:{req.name.strip().lower().replace(' ', '-')}",
@@ -302,7 +306,7 @@ def create_mcp_router(
302
306
  before = len(items)
303
307
  items = [e for e in items if e["id"] != mcp_id]
304
308
  if len(items) == before:
305
- raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.")
309
+ raise http_error(404, "mcp.item_not_found", resolve_language(request))
306
310
  _save_custom_mcps(items)
307
311
  return {"status": "ok"}
308
312
 
@@ -415,7 +419,7 @@ def create_mcp_router(
415
419
  # pipeline (provenance + hook lifecycle), not a direct store call.
416
420
  claimed_user = str(args.get("user_email") or "").strip()
417
421
  if current_user and claimed_user and claimed_user.lower() != current_user.strip().lower():
418
- raise HTTPException(status_code=403, detail="user_email must match the authenticated user.")
422
+ raise http_error(403, "common.user_mismatch", resolve_language(request))
419
423
  owner = current_user or claimed_user or None
420
424
  workspace_id = _write_workspace(args.get("workspace_id"), owner)
421
425
  raw = dict(args)
@@ -464,12 +468,12 @@ def create_mcp_router(
464
468
  if req.action in SCOPED_KNOWLEDGE_TOOLS:
465
469
  claimed_user = str(dispatch_args.get("user_email") or "").strip().lower()
466
470
  if claimed_user and claimed_user != current_user.strip().lower():
467
- raise HTTPException(status_code=403, detail="user_email must match the authenticated user.")
471
+ raise http_error(403, "common.user_mismatch", resolve_language(request))
468
472
  requested_workspace = dispatch_args.get("workspace_id")
469
473
  if req.action in KNOWLEDGE_WRITE_TOOLS:
470
474
  workspace_id = _write_workspace(requested_workspace, current_user)
471
475
  else:
472
- workspace_id = _read_workspace(requested_workspace, current_user)
476
+ workspace_id = _read_workspace(request, requested_workspace, current_user)
473
477
  dispatch_args["workspace_id"] = workspace_id
474
478
  dispatch_args["user_email"] = current_user
475
479
  enforce_tool_policy(req.action, dispatch_args, current_user=current_user, source="mcp")
@@ -12,6 +12,7 @@ from typing import Callable, List, Optional
12
12
  from fastapi import APIRouter, HTTPException, Request
13
13
  from pydantic import BaseModel
14
14
 
15
+ from latticeai.core.messages import http_error, resolve_language
15
16
  from latticeai.services.memory_service import MemoryService
16
17
 
17
18
 
@@ -96,7 +97,7 @@ def create_memory_router(
96
97
  try:
97
98
  return service.inspect(source, user_email=user, workspace_id=scope, limit=limit)
98
99
  except KeyError as exc:
99
- raise HTTPException(status_code=404, detail=f"Unknown memory source: {source}") from exc
100
+ raise http_error(404, "memory.unknown_source", resolve_language(request), source=source) from exc
100
101
 
101
102
  @router.post("/api/memory/recall")
102
103
  async def memory_recall(req: RecallRequest, request: Request):
@@ -20,6 +20,7 @@ from fastapi import APIRouter, HTTPException, Request
20
20
  from fastapi.responses import StreamingResponse
21
21
  from pydantic import BaseModel
22
22
 
23
+ from latticeai.core.messages import http_error, resolve_language, translate
23
24
  from latticeai.services.model_errors import ModelRuntimeError
24
25
 
25
26
 
@@ -140,7 +141,7 @@ def create_models_router(
140
141
  current_user = require_user(request)
141
142
  if REQUIRE_AUTH:
142
143
  if claimed_email and _normalized_identity(claimed_email) != _normalized_identity(current_user):
143
- raise HTTPException(status_code=403, detail="다른 사용자의 모델 자격 증명을 사용할 수 없습니다.")
144
+ raise http_error(403, "models.other_user_credentials", resolve_language(request))
144
145
  require_admin(request)
145
146
  return current_user
146
147
 
@@ -305,9 +306,14 @@ def create_models_router(
305
306
  async def engines_install(req: InstallEngineRequest, request: Request):
306
307
  _authorize_model_admin(request)
307
308
  try:
309
+ # `install_engine` shells out with a 900s timeout. On the event loop
310
+ # that is a fifteen-minute freeze of the entire server — no chat, no
311
+ # status, no UI — for one admin installing an engine.
308
312
  if req.confirmation_token:
309
- return install_engine(req.engine, confirmation_token=req.confirmation_token)
310
- return install_engine(req.engine)
313
+ return await asyncio.to_thread(
314
+ install_engine, req.engine, confirmation_token=req.confirmation_token
315
+ )
316
+ return await asyncio.to_thread(install_engine, req.engine)
311
317
  except ModelRuntimeError as exc:
312
318
  _raise_model_http(exc)
313
319
 
@@ -326,11 +332,11 @@ def create_models_router(
326
332
  if not req.allow_download:
327
333
  raise HTTPException(
328
334
  status_code=403,
329
- detail="Model downloads require explicit user consent (allow_download=true).",
335
+ detail=translate("models.download_consent_required", resolve_language(request)),
330
336
  )
331
337
  model_ref = normalize_local_model_request(req.model, None)
332
338
  if not model_ref:
333
- raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
339
+ raise http_error(400, "models.identifier_empty", resolve_language(request))
334
340
 
335
341
  if ":" in model_ref and model_ref.split(":", 1)[0].strip().lower() in {"ollama", "vllm", "lmstudio", "llamacpp", "local_mlx", "mlx"}:
336
342
  provider, model_name = model_ref.split(":", 1)
@@ -340,25 +346,36 @@ def create_models_router(
340
346
  provider, model_name = "local_mlx", model_ref
341
347
 
342
348
  if not model_name:
343
- raise HTTPException(status_code=400, detail="모델 이름이 비어 있습니다.")
349
+ raise http_error(400, "models.name_empty", resolve_language(request))
344
350
 
345
351
  if provider == "ollama":
346
352
  try:
347
- ensure_ollama_server()
353
+ # Starts the daemon and waits for it — blocking, like the pull
354
+ # below, and for the same reason it may not run on the loop.
355
+ await asyncio.to_thread(ensure_ollama_server)
348
356
  except ModelRuntimeError as exc:
349
357
  _raise_model_http(exc)
350
358
  ollama = local_binary("ollama")
351
359
  if not ollama:
352
- raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
360
+ raise http_error(400, "models.ollama_missing", resolve_language(request))
353
361
  try:
354
- completed = subprocess.run(
355
- [ollama, "pull", model_name],
356
- capture_output=True, text=True, timeout=900, check=False,
362
+ # A model pull is minutes of network I/O. Held on the event loop
363
+ # it stalled every other request — including the health check the
364
+ # UI uses to decide the server is alive — until the pull finished.
365
+ completed = await asyncio.to_thread(
366
+ lambda: subprocess.run(
367
+ [ollama, "pull", model_name],
368
+ capture_output=True, text=True, timeout=900, check=False,
369
+ )
357
370
  )
358
371
  except subprocess.TimeoutExpired:
359
- raise HTTPException(status_code=408, detail="모델 다운로드 시간이 초과되었습니다.")
372
+ raise http_error(408, "models.download_timeout", resolve_language(request))
360
373
  if completed.returncode != 0:
361
- raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "pull 실패")
374
+ raise HTTPException(
375
+ status_code=500,
376
+ detail=completed.stderr[-2000:]
377
+ or translate("models.pull_failed", resolve_language(request)),
378
+ )
362
379
  return {"provider": provider, "model": model_name, "returncode": completed.returncode}
363
380
 
364
381
  if provider == "lmstudio":
@@ -374,12 +391,13 @@ def create_models_router(
374
391
  if provider in {"vllm", "llamacpp", "local_mlx", "mlx"}:
375
392
  download_provider = "local_mlx" if provider == "mlx" else provider
376
393
  try:
377
- result = download_hf_model(model_name, download_provider)
394
+ # Multi-gigabyte Hugging Face download; same rule as the pull above.
395
+ result = await asyncio.to_thread(download_hf_model, model_name, download_provider)
378
396
  except ModelRuntimeError as exc:
379
397
  _raise_model_http(exc)
380
398
  return {"provider": provider, "model": model_name, "returncode": 0, **result}
381
399
 
382
- raise HTTPException(status_code=400, detail=f"{provider} 엔진 모델 다운로드는 아직 자동화되지 않았습니다.")
400
+ raise http_error(400, "models.download_not_automated", resolve_language(request), provider=provider)
383
401
 
384
402
  @router.post("/engines/prepare-model")
385
403
  async def engines_prepare_model(req: PrepareModelRequest, request: Request):
@@ -439,15 +457,15 @@ def create_models_router(
439
457
  from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS
440
458
  config = OPENAI_COMPATIBLE_PROVIDERS.get(req.provider)
441
459
  if not config:
442
- raise HTTPException(status_code=400, detail="알 수 없는 프로바이더입니다.")
460
+ raise http_error(400, "models.unknown_provider", resolve_language(request))
443
461
  if not req.key.strip():
444
- raise HTTPException(status_code=400, detail="API 키가 비어있습니다.")
462
+ raise http_error(400, "models.api_key_empty", resolve_language(request))
445
463
  current_user = require_user(request)
446
464
  if REQUIRE_AUTH and req.user_email and _normalized_identity(req.user_email) != _normalized_identity(current_user):
447
- raise HTTPException(status_code=403, detail="다른 사용자의 API 키를 설정할 권한이 없습니다.")
465
+ raise http_error(403, "models.other_user_api_key", resolve_language(request))
448
466
  target_email = str(_effective_email(current_user, req.user_email) or "").strip()
449
467
  if not target_email:
450
- raise HTTPException(status_code=400, detail="사용자 식별이 필요합니다. 로그인 후 다시 시도하세요.")
468
+ raise http_error(400, "models.sign_in_required", resolve_language(request))
451
469
  set_user_api_key(target_email, req.provider, req.key.strip())
452
470
  return {"ok": True, "provider": req.provider, "user_email": target_email, "scope": "user"}
453
471
 
@@ -502,10 +520,7 @@ def create_models_router(
502
520
  model_id = req.model_id
503
521
  requested_engine = req.engine or (model_id.split(":", 1)[0] if ":" in model_id else "local_mlx")
504
522
  if IS_PUBLIC_MODE and not ALLOW_LOCAL_MODELS and requested_engine in {"local_mlx", "mlx"}:
505
- raise HTTPException(
506
- status_code=400,
507
- detail="Public mode blocks local MLX model loading. Use openai:, openrouter:, groq:, together:, or set LATTICEAI_ALLOW_LOCAL_MODELS=true.",
508
- )
523
+ raise http_error(400, "models.public_mode_blocks_local", resolve_language(request))
509
524
  compatibility = model_runtime_compatibility(model_id, engine=requested_engine)
510
525
  if compatibility.get("supported") is False:
511
526
  raise HTTPException(status_code=400, detail=compatibility)
@@ -532,7 +547,7 @@ def create_models_router(
532
547
  _router.switch_model(model_id)
533
548
  return {"status": "ok", "current": _router.current_model_id}
534
549
  except KeyError:
535
- raise HTTPException(status_code=404, detail=f"Model '{model_id}' not loaded. Call /models/load first.")
550
+ raise http_error(404, "chat.model_not_loaded", resolve_language(request), model=model_id)
536
551
 
537
552
  @router.delete("/models/unload/{model_id:path}")
538
553
  async def unload_model(model_id: str, request: Request):
@@ -7,6 +7,7 @@ from typing import Any, Callable, Dict, Optional
7
7
  from fastapi import APIRouter, HTTPException, Request
8
8
  from pydantic import BaseModel, Field
9
9
 
10
+ from latticeai.core.messages import http_error, resolve_language, translate
10
11
  from latticeai.core.network_boundary import network_mode_catalog, normalize_network_mode
11
12
  from latticeai.services.cloud_egress_audit import record_cloud_egress
12
13
  from latticeai.services.cloud_token_guard import budget_for
@@ -164,12 +165,16 @@ def create_network_boundary_router(
164
165
  """
165
166
  user, scope = _scope(request, body.workspace_id)
166
167
  if knowledge_graph is None or not hasattr(knowledge_graph, "set_node_sensitivity"):
167
- raise HTTPException(status_code=501, detail="knowledge graph not available")
168
+ raise http_error(501, "common.graph_unavailable", resolve_language(request))
168
169
  result = knowledge_graph.set_node_sensitivity(
169
170
  body.node_id, local_only=bool(body.local_only), reason=body.reason
170
171
  )
171
172
  if not result.get("ok"):
172
- raise HTTPException(status_code=404, detail=result.get("reason") or "node not found")
173
+ raise HTTPException(
174
+ status_code=404,
175
+ detail=result.get("reason")
176
+ or translate("graph.node_not_found", resolve_language(request)),
177
+ )
173
178
  record_cloud_egress(
174
179
  node_ids=[body.node_id], token_estimate=0, mode="(policy)",
175
180
  provider="(local)", user_email=user, workspace_id=scope,
@@ -184,14 +189,14 @@ def create_network_boundary_router(
184
189
  workspace_id: Optional[str] = None,
185
190
  ):
186
191
  if policy_service is None:
187
- raise HTTPException(status_code=501, detail="hybrid policy service not configured")
192
+ raise http_error(501, "boundary.policy_not_configured", resolve_language(request))
188
193
  user, scope = _scope(request, workspace_id)
189
194
  return policy_service.resolve(user_email=user, workspace_id=scope)
190
195
 
191
196
  @router.post("/api/network-boundary/policy")
192
197
  async def set_hybrid_policy(body: SetHybridPolicyRequest, request: Request):
193
198
  if policy_service is None:
194
- raise HTTPException(status_code=501, detail="hybrid policy service not configured")
199
+ raise http_error(501, "boundary.policy_not_configured", resolve_language(request))
195
200
  user, scope = _scope(request, body.workspace_id)
196
201
  patch: Dict[str, Any] = {}
197
202
  for key in (
@@ -12,6 +12,8 @@ from typing import Any, Callable, Optional
12
12
  from fastapi import APIRouter, HTTPException, Request
13
13
  from pydantic import BaseModel
14
14
 
15
+ from latticeai.core.messages import http_error, resolve_language, translate
16
+
15
17
 
16
18
  class ImportRequest(BaseModel):
17
19
  artifact: dict
@@ -72,52 +74,52 @@ def create_portability_router(
72
74
  ) -> APIRouter:
73
75
  router = APIRouter()
74
76
 
75
- def _require_service():
77
+ def _require_service(request: Request):
76
78
  if service is None or not service.available():
77
- raise HTTPException(status_code=503, detail="Knowledge Graph is disabled.")
79
+ raise http_error(503, "common.graph_disabled", resolve_language(request))
78
80
 
79
81
  @router.get("/api/knowledge-graph/portability")
80
82
  async def portability_status(request: Request):
81
83
  require_user(request)
82
- _require_service()
84
+ _require_service(request)
83
85
  return service.snapshot_metadata()
84
86
 
85
87
  @router.get("/api/brain/storage")
86
88
  async def brain_storage_status(request: Request):
87
89
  require_user(request)
88
- _require_service()
90
+ _require_service(request)
89
91
  return service.storage_status()
90
92
 
91
93
  @router.get("/api/knowledge-graph/backup-health")
92
94
  async def backup_health(request: Request):
93
95
  require_user(request)
94
- _require_service()
96
+ _require_service(request)
95
97
  return service.backup_health()
96
98
 
97
99
  @router.get("/api/knowledge-graph/provenance")
98
100
  async def recent_provenance(request: Request, limit: int = 50, source_type: Optional[str] = None):
99
101
  """Recent ingestions (provenance trail) for the ingestion-sources UI."""
100
102
  require_admin(request)
101
- _require_service()
103
+ _require_service(request)
102
104
  return service.recent_ingestions(limit=limit, source_type=source_type)
103
105
 
104
106
  @router.post("/api/knowledge-graph/export")
105
107
  async def export_graph(request: Request):
106
108
  """Logical JSON export of the whole graph (read-only)."""
107
109
  require_admin(request)
108
- _require_service()
110
+ _require_service(request)
109
111
  return service.export()
110
112
 
111
113
  @router.post("/api/knowledge-graph/export-file")
112
114
  async def export_graph_file(request: Request):
113
115
  require_admin(request)
114
- _require_service()
116
+ _require_service(request)
115
117
  return service.export_to_file()
116
118
 
117
119
  @router.post("/api/knowledge-graph/import")
118
120
  async def import_graph(req: ImportRequest, request: Request):
119
121
  require_admin(request)
120
- _require_service()
122
+ _require_service(request)
121
123
  try:
122
124
  return service.import_data(req.artifact, mode=req.mode, dry_run=req.dry_run)
123
125
  except ValueError as exc:
@@ -126,13 +128,13 @@ def create_portability_router(
126
128
  @router.post("/api/knowledge-graph/backup")
127
129
  async def backup_graph(req: BackupRequest, request: Request):
128
130
  require_admin(request)
129
- _require_service()
131
+ _require_service(request)
130
132
  return service.backup(req.path)
131
133
 
132
134
  @router.post("/api/knowledge-graph/restore")
133
135
  async def restore_graph(req: RestoreRequest, request: Request):
134
136
  require_admin(request)
135
- _require_service()
137
+ _require_service(request)
136
138
  try:
137
139
  return service.restore(req.path, verify=req.verify, dry_run=req.dry_run, confirm=req.confirm)
138
140
  except (ValueError, FileNotFoundError) as exc:
@@ -141,7 +143,7 @@ def create_portability_router(
141
143
  @router.post("/api/knowledge-graph/archive")
142
144
  async def encrypted_archive(req: EncryptedArchiveRequest, request: Request):
143
145
  require_admin(request)
144
- _require_service()
146
+ _require_service(request)
145
147
  try:
146
148
  return service.encrypted_archive(req.path, passphrase=req.passphrase)
147
149
  except (ValueError, FileNotFoundError) as exc:
@@ -150,7 +152,7 @@ def create_portability_router(
150
152
  @router.post("/api/knowledge-graph/archive/inspect")
151
153
  async def inspect_encrypted_archive(req: EncryptedInspectRequest, request: Request):
152
154
  require_admin(request)
153
- _require_service()
155
+ _require_service(request)
154
156
  try:
155
157
  return service.inspect_encrypted_archive(req.path, passphrase=req.passphrase)
156
158
  except (ValueError, FileNotFoundError) as exc:
@@ -159,16 +161,22 @@ def create_portability_router(
159
161
  @router.post("/api/knowledge-graph/archive/verify")
160
162
  async def verify_encrypted_archive(req: EncryptedVerifyRequest, request: Request):
161
163
  require_admin(request)
162
- _require_service()
164
+ _require_service(request)
163
165
  result = service.verify_encrypted_archive(req.path, passphrase=req.passphrase)
164
166
  if not result.get("ok"):
165
- raise HTTPException(status_code=400, detail="; ".join(result.get("errors") or ["Archive verification failed."]))
167
+ raise HTTPException(
168
+ status_code=400,
169
+ detail="; ".join(
170
+ result.get("errors")
171
+ or [translate("portability.verification_failed", resolve_language(request))]
172
+ ),
173
+ )
166
174
  return result
167
175
 
168
176
  @router.post("/api/knowledge-graph/archive/import")
169
177
  async def import_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
170
178
  require_admin(request)
171
- _require_service()
179
+ _require_service(request)
172
180
  try:
173
181
  return service.import_encrypted_archive(
174
182
  req.path,
@@ -182,7 +190,7 @@ def create_portability_router(
182
190
  @router.post("/api/knowledge-graph/archive/restore")
183
191
  async def restore_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
184
192
  require_admin(request)
185
- _require_service()
193
+ _require_service(request)
186
194
  try:
187
195
  return service.restore_encrypted_archive(
188
196
  req.path,
@@ -196,7 +204,7 @@ def create_portability_router(
196
204
  @router.post("/api/brain/storage/postgres/docker")
197
205
  async def setup_postgres_docker(req: DockerPostgresRequest, request: Request):
198
206
  require_admin(request)
199
- _require_service()
207
+ _require_service(request)
200
208
  return service.postgres_docker_setup(
201
209
  consent=req.consent,
202
210
  dry_run=req.dry_run,
@@ -206,7 +214,7 @@ def create_portability_router(
206
214
  @router.post("/api/brain/storage/migrate-postgres")
207
215
  async def migrate_sqlite_to_postgres(req: SQLiteToPostgresRequest, request: Request):
208
216
  require_admin(request)
209
- _require_service()
217
+ _require_service(request)
210
218
  try:
211
219
  return service.migrate_sqlite_to_postgres(
212
220
  dsn=req.dsn,
@@ -11,9 +11,11 @@ from __future__ import annotations
11
11
 
12
12
  from typing import Any, Callable, List, Optional
13
13
 
14
- from fastapi import APIRouter, HTTPException, Request
14
+ from fastapi import APIRouter, Request
15
15
  from pydantic import BaseModel, Field
16
16
 
17
+ from latticeai.core.messages import http_error, resolve_language
18
+
17
19
 
18
20
  class CreateProjectRequest(BaseModel):
19
21
  title: str = Field(..., min_length=1, max_length=200)
@@ -65,7 +67,7 @@ def create_project_sessions_router(
65
67
  user = require_user(request)
66
68
  record = store.get(session_id, user_email=user, workspace_id=_scope(request))
67
69
  if record is None:
68
- raise HTTPException(status_code=404, detail="project not found")
70
+ raise http_error(404, "project.not_found", resolve_language(request))
69
71
  return record
70
72
 
71
73
  @router.patch("/api/projects/{session_id}")
@@ -80,7 +82,7 @@ def create_project_sessions_router(
80
82
  workspace_id=_scope(request, write=True),
81
83
  )
82
84
  if record is None:
83
- raise HTTPException(status_code=404, detail="project not found")
85
+ raise http_error(404, "project.not_found", resolve_language(request))
84
86
  return record
85
87
 
86
88
  @router.put("/api/projects/{session_id}/todos")
@@ -93,7 +95,7 @@ def create_project_sessions_router(
93
95
  workspace_id=_scope(request, write=True),
94
96
  )
95
97
  if record is None:
96
- raise HTTPException(status_code=404, detail="project not found")
98
+ raise http_error(404, "project.not_found", resolve_language(request))
97
99
  return record
98
100
 
99
101
  @router.delete("/api/projects/{session_id}")
@@ -103,7 +105,7 @@ def create_project_sessions_router(
103
105
  session_id, user_email=user, workspace_id=_scope(request, write=True)
104
106
  )
105
107
  if not removed:
106
- raise HTTPException(status_code=404, detail="project not found")
108
+ raise http_error(404, "project.not_found", resolve_language(request))
107
109
  return {"status": "deleted", "id": session_id}
108
110
 
109
111
  return router