ltcai 10.8.0 → 10.10.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 (94) hide show
  1. package/README.md +44 -63
  2. package/docs/CHANGELOG.md +69 -237
  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 +5 -3
  38. package/package.json +3 -2
  39. package/scripts/check_current_release_docs.mjs +4 -2
  40. package/scripts/check_server_i18n.mjs +96 -0
  41. package/scripts/release_screen_claims.json +31 -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-CC8SpNRm.js} +1 -1
  47. package/static/app/assets/{AdminConsole-Bs8SR8fE.js → AdminConsole-DGIY_Tb_.js} +1 -1
  48. package/static/app/assets/{Brain-BZIPsZlH.js → Brain-BDSC2Mol.js} +2 -2
  49. package/static/app/assets/BrainHome-BYgYp3ON.js +2 -0
  50. package/static/app/assets/BrainSignals-DYwVFmcO.js +1 -0
  51. package/static/app/assets/Capture-ClcKLsmI.js +1 -0
  52. package/static/app/assets/{CommandPalette-Dq_MmPBl.js → CommandPalette-DpwbyF1S.js} +1 -1
  53. package/static/app/assets/Library-D8VNtffM.js +1 -0
  54. package/static/app/assets/LivingBrain-CeWwc0NN.js +1 -0
  55. package/static/app/assets/ProductFlow-CS5CCM4t.js +1 -0
  56. package/static/app/assets/{ReviewCard-Be7E7X18.js → ReviewCard-Bap4yPfI.js} +1 -1
  57. package/static/app/assets/System-odPxU1YL.js +1 -0
  58. package/static/app/assets/arrow-left-J47Yde1U.js +1 -0
  59. package/static/app/assets/{bot-BR0eoeXY.js → bot-CmvhqTeF.js} +1 -1
  60. package/static/app/assets/brain-DLT9uuGq.js +1 -0
  61. package/static/app/assets/{button-U1nU7xX4.js → button-DyppYhFH.js} +1 -1
  62. package/static/app/assets/{circle-pause-DG5KHjDL.js → circle-pause-Bm5z7EBI.js} +1 -1
  63. package/static/app/assets/{circle-play-Chhcuq90.js → circle-play-GjKdH0Fg.js} +1 -1
  64. package/static/app/assets/{cpu-pkOSlT9h.js → cpu-CuVMv-F5.js} +1 -1
  65. package/static/app/assets/{download-CSRwz923.js → download-C9_946tf.js} +1 -1
  66. package/static/app/assets/{folder-open-DiMJ7W_7.js → folder-open-B3QKdxib.js} +1 -1
  67. package/static/app/assets/{hard-drive-iyLPAPdp.js → hard-drive-BwW9Hx4r.js} +1 -1
  68. package/static/app/assets/{index-_ldE9TAX.js → index-BrUQ-bF8.js} +3 -3
  69. package/static/app/assets/index-CYb18LtP.css +2 -0
  70. package/static/app/assets/{input-e_r2hFAY.js → input-Db2Hw2VH.js} +1 -1
  71. package/static/app/assets/{permissionCopy-9uOHktz3.js → permissionCopy-BpbIHRio.js} +1 -1
  72. package/static/app/assets/primitives-4B_Ynf7r.js +1 -0
  73. package/static/app/assets/search-YH-7-x5Y.js +1 -0
  74. package/static/app/assets/{share-2-DLDJJKmI.js → share-2-BgvRTDPJ.js} +1 -1
  75. package/static/app/assets/{shield-alert-BTtE-2EZ.js → shield-alert-DWFLyykG.js} +1 -1
  76. package/static/app/assets/{textarea-Bbzkcgqe.js → textarea-Bk4vEqzT.js} +1 -1
  77. package/static/app/assets/{useFocusTrap-DJzQd-Za.js → useFocusTrap-CGRXEg_p.js} +1 -1
  78. package/static/app/assets/{useQuery-6R_tqHRe.js → useQuery-CdPzp3_U.js} +1 -1
  79. package/static/app/assets/{utils-XDsjcrhS.js → utils-orU8igel.js} +3 -3
  80. package/static/app/assets/{workspace-CwrG8LHq.js → workspace-Brj2Ylb2.js} +1 -1
  81. package/static/app/index.html +4 -4
  82. package/static/sw.js +1 -1
  83. package/static/app/assets/BrainHome-BaH5MDce.js +0 -2
  84. package/static/app/assets/BrainSignals-aDt-nSsW.js +0 -1
  85. package/static/app/assets/Capture-DDv940Bv.js +0 -1
  86. package/static/app/assets/Library-BkShbqmN.js +0 -1
  87. package/static/app/assets/LivingBrain-CXb8094H.js +0 -1
  88. package/static/app/assets/ProductFlow-BaiqFWLO.js +0 -1
  89. package/static/app/assets/System-DCstbTxC.js +0 -1
  90. package/static/app/assets/arrow-left-DC9Oquhs.js +0 -1
  91. package/static/app/assets/brain-BmWa1wea.js +0 -1
  92. package/static/app/assets/index-BZRvGOM2.css +0 -2
  93. package/static/app/assets/primitives-CnEAOmg2.js +0 -1
  94. package/static/app/assets/search-veep4n6f.js +0 -1
@@ -9,7 +9,9 @@ from __future__ import annotations
9
9
  from dataclasses import dataclass
10
10
  from typing import Any, Callable, Dict
11
11
 
12
- from fastapi import APIRouter, HTTPException, Request
12
+ from fastapi import APIRouter, Request
13
+
14
+ from latticeai.core.messages import http_error, resolve_language
13
15
 
14
16
 
15
17
  @dataclass(frozen=True)
@@ -48,7 +50,7 @@ def register_history_routes(router: APIRouter, deps: HistoryRouteDependencies) -
48
50
  **deps.scope_for_user(current_user),
49
51
  )
50
52
  if not messages:
51
- raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
53
+ raise http_error(404, "chat.conversation_not_found", resolve_language(request))
52
54
  return {"id": conversation_id, "messages": messages}
53
55
 
54
56
  @router.delete("/history/conversations/{conversation_id:path}")
@@ -31,9 +31,10 @@ from latticeai.core.file_generation import (
31
31
  repair_bundle_references,
32
32
  validate_project_bundle,
33
33
  )
34
+ from latticeai.core.messages import DEFAULT_LANGUAGE, translate
34
35
 
35
36
 
36
- def next_available_path(root: Path, target: str) -> str:
37
+ def next_available_path(root: Path, target: str, language: str = DEFAULT_LANGUAGE) -> str:
37
38
  """Return ``target`` unchanged, or a ``name_2.ext``-style variant when the
38
39
  file already exists in the workspace.
39
40
 
@@ -54,7 +55,7 @@ def next_available_path(root: Path, target: str) -> str:
54
55
  return str(Path(target).with_name(variant.name))
55
56
  raise HTTPException(
56
57
  status_code=409,
57
- detail=f"'{target}' 이름의 파일이 너무 많습니다. 다른 이름을 지정해 주세요.",
58
+ detail=translate("chat.file_name_collision", language, name=target),
58
59
  )
59
60
 
60
61
 
@@ -118,14 +119,10 @@ class ChatIntentController:
118
119
  except Exception as exc: # noqa: BLE001 — metrics must never break chat
119
120
  logging.warning("funnel metrics increment failed: %s", exc)
120
121
 
121
- def no_model_response(self) -> JSONResponse:
122
- detail = "No model loaded. Call /models/load first."
122
+ def no_model_response(self, language: str = DEFAULT_LANGUAGE) -> JSONResponse:
123
+ detail = translate("chat.no_model_loaded", language)
123
124
  if self.config.is_public:
124
- detail = (
125
- "No public model loaded. Set OPENAI_API_KEY and "
126
- f"LATTICEAI_PUBLIC_MODEL={self.public_model}, or call /models/load "
127
- "with an OpenAI-compatible model."
128
- )
125
+ detail = translate("models.public_model_missing", language, model=self.public_model)
129
126
  return JSONResponse(
130
127
  status_code=400,
131
128
  content={
@@ -249,6 +246,7 @@ class ChatIntentController:
249
246
  model_id: Optional[str],
250
247
  effective_email: Optional[str] = None,
251
248
  workspace_id: Optional[str] = None,
249
+ language: str = DEFAULT_LANGUAGE,
252
250
  ):
253
251
  # An explicit path ("report.txt") wins; otherwise infer one from an
254
252
  # explicit type keyword ("html 파일 만들어줘") so weak models never
@@ -266,19 +264,20 @@ class ChatIntentController:
266
264
  model_id=model_id,
267
265
  effective_email=effective_email,
268
266
  workspace_id=workspace_id,
267
+ language=language,
269
268
  )
270
269
  target_path = explicit_target or infer_file_target(req.message)
271
270
  if not target_path:
272
271
  return None
273
272
  # Never silently overwrite: an existing target gets a _2/_3 suffix so
274
273
  # regeneration is safe and repeatable without the proposal flow.
275
- deduped_path = next_available_path(self.agent_root, target_path)
274
+ deduped_path = next_available_path(self.agent_root, target_path, language)
276
275
  renamed = deduped_path != target_path
277
276
  target_path = deduped_path
278
277
  content = inline_file_action_content(req.message)
279
278
  generation_meta: Optional[Dict[str, Any]] = None
280
279
  if content is None and not model_id:
281
- return self.no_model_response()
280
+ return self.no_model_response(language)
282
281
  if content is None and model_id:
283
282
  # Model-agnostic pipeline: strict extension-aware prompt →
284
283
  # extraction → validation → one corrective retry → deterministic
@@ -302,7 +301,7 @@ class ChatIntentController:
302
301
  if content is None:
303
302
  raise HTTPException(
304
303
  status_code=400,
305
- detail="File content could not be generated.",
304
+ detail=translate("chat.file_generation_failed", language),
306
305
  )
307
306
  try:
308
307
  result = self.execute_tool(
@@ -395,6 +394,7 @@ class ChatIntentController:
395
394
  model_id: Optional[str],
396
395
  effective_email: Optional[str] = None,
397
396
  workspace_id: Optional[str] = None,
397
+ language: str = DEFAULT_LANGUAGE,
398
398
  ):
399
399
  """Artifact Loop: manifest → per-file generate/validate → bundle write.
400
400
 
@@ -405,8 +405,8 @@ class ChatIntentController:
405
405
  standard ``artifacts[]`` contract, and downloadable as a zip.
406
406
  """
407
407
  if not model_id:
408
- return self.no_model_response()
409
- project_dir = next_available_path(self.agent_root, manifest["name"])
408
+ return self.no_model_response(language)
409
+ project_dir = next_available_path(self.agent_root, manifest["name"], language)
410
410
  bundle_files = [str(f["path"]) for f in manifest["files"]]
411
411
 
412
412
  async def _generate(context: str) -> str:
@@ -19,6 +19,7 @@ from latticeai.api.workspace_scope import (
19
19
  resolve_workspace_scope,
20
20
  workspace_scope_from_request,
21
21
  )
22
+ from latticeai.core.messages import http_error, resolve_language
22
23
  from latticeai.core.quiet import quiet
23
24
 
24
25
 
@@ -456,9 +457,9 @@ def create_knowledge_graph_router(
456
457
  async def knowledge_graph_neighbors(node_id: str, request: Request):
457
458
  kg, allowed = _scoped(request)
458
459
  if not node_id:
459
- raise HTTPException(status_code=400, detail="node_id required")
460
+ raise http_error(400, "graph.node_id_required", resolve_language(request))
460
461
  if allowed is not None and not _filter_scoped(kg, [{"id": node_id}], allowed):
461
- raise HTTPException(status_code=404, detail="node not found")
462
+ raise http_error(404, "graph.node_not_found", resolve_language(request))
462
463
  payload = kg.neighbors(node_id)
463
464
  if allowed is not None:
464
465
  neighbors = _filter_scoped(kg, payload.get("neighbors", []), allowed)
@@ -476,13 +477,13 @@ def create_knowledge_graph_router(
476
477
  current_user = require_user(request)
477
478
  if current_user and req.user_email:
478
479
  if current_user.strip().lower() != req.user_email.strip().lower():
479
- raise HTTPException(status_code=403, detail="user_email must match the authenticated user.")
480
+ raise http_error(403, "common.user_mismatch", resolve_language(request))
480
481
  effective_user = current_user or req.user_email or None
481
482
  kg = graph()
482
483
  workspace_id = _write_workspace(request, current_user)
483
484
  event_type = (req.type or "").strip().lower()
484
485
  if event_type not in {"message", "ai_response", "note"}:
485
- raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
486
+ raise http_error(400, "graph.unsupported_type", resolve_language(request))
486
487
  role = req.role or ("assistant" if event_type == "ai_response" else "user")
487
488
  if ingestion_pipeline is not None:
488
489
  source_type = "chat_message" if event_type in {"message", "ai_response"} else "note"
@@ -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 (