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.
- package/README.md +54 -52
- package/docs/CHANGELOG.md +37 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +9 -6
- package/latticeai/api/chat_agent_http.py +2 -1
- package/latticeai/api/chat_history.py +4 -2
- package/latticeai/api/chat_intents.py +14 -14
- package/latticeai/api/knowledge_graph.py +5 -4
- package/latticeai/api/local_files.py +25 -15
- package/latticeai/api/mcp.py +15 -11
- package/latticeai/api/memory.py +2 -1
- package/latticeai/api/models.py +39 -24
- package/latticeai/api/network_boundary.py +9 -4
- package/latticeai/api/portability.py +27 -19
- package/latticeai/api/project_sessions.py +7 -5
- package/latticeai/api/review_queue.py +12 -7
- package/latticeai/api/setup.py +10 -11
- package/latticeai/api/static_routes.py +62 -46
- package/latticeai/api/tools.py +7 -6
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +20 -4
- package/latticeai/core/messages.py +222 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/integrations/telegram_bot.py +20 -18
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/model_loading.py +63 -27
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +3 -2
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +96 -0
- package/scripts/release_screen_claims.json +17 -8
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-CbQNsFgc.js → Act-CS9IeqUX.js} +1 -1
- package/static/app/assets/{AdminConsole-Bs8SR8fE.js → AdminConsole-3UkIEWGA.js} +1 -1
- package/static/app/assets/{Brain-BZIPsZlH.js → Brain-B22EmNqS.js} +1 -1
- package/static/app/assets/{BrainHome-BaH5MDce.js → BrainHome-CMDqgJF4.js} +2 -2
- package/static/app/assets/{BrainSignals-aDt-nSsW.js → BrainSignals-BeE8RJo3.js} +1 -1
- package/static/app/assets/{Capture-DDv940Bv.js → Capture-ZX9bQh68.js} +1 -1
- package/static/app/assets/{CommandPalette-Dq_MmPBl.js → CommandPalette-86m4FCcN.js} +1 -1
- package/static/app/assets/{Library-BkShbqmN.js → Library-Bhz5LUca.js} +1 -1
- package/static/app/assets/{LivingBrain-CXb8094H.js → LivingBrain-BSa0wpFG.js} +1 -1
- package/static/app/assets/{ProductFlow-BaiqFWLO.js → ProductFlow-IP4Q-5aQ.js} +1 -1
- package/static/app/assets/{ReviewCard-Be7E7X18.js → ReviewCard-BepjSDpN.js} +1 -1
- package/static/app/assets/{System-DCstbTxC.js → System-Bwx1h_jT.js} +1 -1
- package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
- package/static/app/assets/{bot-BR0eoeXY.js → bot-DQj0-LkM.js} +1 -1
- package/static/app/assets/{brain-BmWa1wea.js → brain-D8OEwmVj.js} +1 -1
- package/static/app/assets/{button-U1nU7xX4.js → button-CmTknyAP.js} +1 -1
- package/static/app/assets/{circle-pause-DG5KHjDL.js → circle-pause-yTCWRziJ.js} +1 -1
- package/static/app/assets/{circle-play-Chhcuq90.js → circle-play-Ccrva84R.js} +1 -1
- package/static/app/assets/{cpu-pkOSlT9h.js → cpu-CbJqWTlS.js} +1 -1
- package/static/app/assets/{download-CSRwz923.js → download-CkSzbzU-.js} +1 -1
- package/static/app/assets/{folder-open-DiMJ7W_7.js → folder-open-CKyjQ4PU.js} +1 -1
- package/static/app/assets/{hard-drive-iyLPAPdp.js → hard-drive-DAzk9um0.js} +1 -1
- package/static/app/assets/{index-BZRvGOM2.css → index-BfD-jhA9.css} +1 -1
- package/static/app/assets/{index-_ldE9TAX.js → index-CxOcwsHV.js} +3 -3
- package/static/app/assets/{input-e_r2hFAY.js → input-DcMETmZ7.js} +1 -1
- package/static/app/assets/{permissionCopy-9uOHktz3.js → permissionCopy-BVf13_25.js} +1 -1
- package/static/app/assets/{primitives-CnEAOmg2.js → primitives-CQV9Q2YM.js} +1 -1
- package/static/app/assets/search-CXGASMMH.js +1 -0
- package/static/app/assets/{share-2-DLDJJKmI.js → share-2-COWCHNZm.js} +1 -1
- package/static/app/assets/{shield-alert-BTtE-2EZ.js → shield-alert-BDrvilyK.js} +1 -1
- package/static/app/assets/{textarea-Bbzkcgqe.js → textarea-rUmsc8cP.js} +1 -1
- package/static/app/assets/{useFocusTrap-DJzQd-Za.js → useFocusTrap-Bi5UY_8v.js} +1 -1
- package/static/app/assets/{useQuery-6R_tqHRe.js → useQuery-C-AicB-3.js} +1 -1
- package/static/app/assets/{utils-XDsjcrhS.js → utils-BMwWg78e.js} +1 -1
- package/static/app/assets/{workspace-CwrG8LHq.js → workspace-Y93tls8P.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/arrow-left-DC9Oquhs.js +0 -1
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
410
|
+
raise http_error(404, "ingestion.watch_not_found", resolve_language(request))
|
|
401
411
|
return result
|
|
402
412
|
|
|
403
413
|
@router.post("/local/write")
|
package/latticeai/api/mcp.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
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
|
|
279
|
+
raise http_error(400, "mcp.name_required", resolve_language(request))
|
|
276
280
|
if not req.package.strip():
|
|
277
|
-
raise
|
|
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
|
|
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
|
|
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
|
|
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")
|
package/latticeai/api/memory.py
CHANGED
|
@@ -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
|
|
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):
|
package/latticeai/api/models.py
CHANGED
|
@@ -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
|
|
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
|
|
310
|
-
|
|
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="
|
|
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
|
|
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
|
|
349
|
+
raise http_error(400, "models.name_empty", resolve_language(request))
|
|
344
350
|
|
|
345
351
|
if provider == "ollama":
|
|
346
352
|
try:
|
|
347
|
-
|
|
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
|
|
360
|
+
raise http_error(400, "models.ollama_missing", resolve_language(request))
|
|
353
361
|
try:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
372
|
+
raise http_error(408, "models.download_timeout", resolve_language(request))
|
|
360
373
|
if completed.returncode != 0:
|
|
361
|
-
raise HTTPException(
|
|
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
|
-
|
|
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
|
|
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
|
|
460
|
+
raise http_error(400, "models.unknown_provider", resolve_language(request))
|
|
443
461
|
if not req.key.strip():
|
|
444
|
-
raise
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|