ltcai 4.3.0 → 4.3.1
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 +19 -17
- package/bin/ltcai.js +6 -2
- package/docs/CHANGELOG.md +33 -3
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +16 -22
- package/frontend/openapi.json +11 -1
- package/frontend/src/App.tsx +15 -1
- package/frontend/src/api/client.ts +19 -1
- package/frontend/src/api/openapi.ts +10 -0
- package/frontend/src/pages/Act.tsx +63 -2
- package/frontend/src/pages/Library.tsx +9 -3
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +3 -3
- package/lattice_brain/storage/sqlite.py +15 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +3 -1
- package/latticeai/api/models.py +66 -18
- package/latticeai/brain/projection.py +12 -2
- package/latticeai/brain/retrieval.py +10 -0
- package/latticeai/brain/store.py +6 -1
- package/latticeai/core/config.py +3 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/product_hardening.py +2 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/agent_runtime.py +52 -12
- package/latticeai/services/model_runtime.py +83 -2
- package/ltcai_cli.py +14 -3
- package/package.json +3 -2
- package/requirements.txt +17 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/main.rs +257 -25
- package/src-tauri/tauri.conf.json +20 -1
- package/static/app/asset-manifest.json +3 -3
- package/static/app/assets/{index-RiJTJliG.js → index-BhPuj8rT.js} +45 -45
- package/static/app/assets/index-BhPuj8rT.js.map +1 -0
- package/static/app/index.html +1 -1
- package/static/app/assets/index-RiJTJliG.js.map +0 -1
package/latticeai/api/models.py
CHANGED
|
@@ -62,6 +62,7 @@ class LoadModelRequest(BaseModel):
|
|
|
62
62
|
user_email: Optional[str] = None
|
|
63
63
|
adapter_path: Optional[str] = None
|
|
64
64
|
draft_model_id: Optional[str] = None
|
|
65
|
+
allow_download: bool = False
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
class InstallEngineRequest(BaseModel):
|
|
@@ -82,6 +83,7 @@ class PrepareModelRequest(BaseModel):
|
|
|
82
83
|
model: str
|
|
83
84
|
engine: Optional[str] = None
|
|
84
85
|
user_email: Optional[str] = None
|
|
86
|
+
allow_download: bool = False
|
|
85
87
|
|
|
86
88
|
|
|
87
89
|
class VerifyCloudRequest(BaseModel):
|
|
@@ -127,9 +129,57 @@ def create_models_router(
|
|
|
127
129
|
REQUIRE_AUTH = require_auth
|
|
128
130
|
_list_compat_profiles = list_compat_profiles
|
|
129
131
|
|
|
130
|
-
def _recommended_with_engine_options(
|
|
132
|
+
def _recommended_with_engine_options(
|
|
133
|
+
items: List[Dict[str, object]],
|
|
134
|
+
engines: Optional[List[Dict[str, object]]] = None,
|
|
135
|
+
loaded_ids: Optional[List[str]] = None,
|
|
136
|
+
current_id: Optional[str] = None,
|
|
137
|
+
) -> List[Dict[str, object]]:
|
|
138
|
+
engine_lookup = {str(engine.get("id") or ""): engine for engine in engines or []}
|
|
139
|
+
model_lookup: Dict[str, Dict[str, object]] = {}
|
|
140
|
+
for engine in engines or []:
|
|
141
|
+
engine_id = str(engine.get("id") or "")
|
|
142
|
+
for model in engine.get("models") or []:
|
|
143
|
+
if isinstance(model, dict):
|
|
144
|
+
model_lookup[str(model.get("id") or "")] = {**model, "_engine": engine_id}
|
|
145
|
+
loaded = set(loaded_ids or [])
|
|
131
146
|
out: List[Dict[str, object]] = []
|
|
132
147
|
for item in items:
|
|
148
|
+
short_id = str(item["id"]).lower()
|
|
149
|
+
aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
|
|
150
|
+
options: List[Dict[str, str]] = []
|
|
151
|
+
for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
|
|
152
|
+
real = aliases.get(engine_name)
|
|
153
|
+
if not real:
|
|
154
|
+
continue
|
|
155
|
+
options.append({
|
|
156
|
+
"engine": engine_name,
|
|
157
|
+
"model_id": real,
|
|
158
|
+
"load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
|
|
159
|
+
})
|
|
160
|
+
if not options:
|
|
161
|
+
options.append({"engine": "local_mlx", "model_id": item["id"], "load_id": item["id"]})
|
|
162
|
+
recommended_engine = options[0]["engine"]
|
|
163
|
+
load_id = options[0]["load_id"]
|
|
164
|
+
engine_info = engine_lookup.get(recommended_engine) or {}
|
|
165
|
+
model_info = model_lookup.get(load_id) or model_lookup.get(str(item["id"])) or {}
|
|
166
|
+
pulled = bool(model_info.get("pulled"))
|
|
167
|
+
is_loaded = load_id in loaded or str(item["id"]) in loaded or current_id in {load_id, str(item["id"])}
|
|
168
|
+
engine_installed = bool(engine_info.get("installed"))
|
|
169
|
+
pullable = bool(item.get("pullable", True))
|
|
170
|
+
download_required = bool(pullable and not pulled and not is_loaded)
|
|
171
|
+
if is_loaded:
|
|
172
|
+
load_status = "loaded"
|
|
173
|
+
unavailable_reason = None
|
|
174
|
+
elif not engine_installed:
|
|
175
|
+
load_status = "unavailable"
|
|
176
|
+
unavailable_reason = f"{engine_info.get('name') or recommended_engine} runtime is not installed."
|
|
177
|
+
elif download_required:
|
|
178
|
+
load_status = "download_required"
|
|
179
|
+
unavailable_reason = "Model files are not present locally. Downloads are opt-in and never start from token/model presence alone."
|
|
180
|
+
else:
|
|
181
|
+
load_status = "ready"
|
|
182
|
+
unavailable_reason = None
|
|
133
183
|
base = {
|
|
134
184
|
"id": item["id"],
|
|
135
185
|
"name": item["name"],
|
|
@@ -144,23 +194,15 @@ def create_models_router(
|
|
|
144
194
|
"run_location": item.get("run_location"),
|
|
145
195
|
"internet_requirement": item.get("internet_requirement"),
|
|
146
196
|
"source_display_order": item.get("source_display_order"),
|
|
197
|
+
"pulled": pulled,
|
|
198
|
+
"download_required": download_required,
|
|
199
|
+
"load_available": is_loaded or (engine_installed and not download_required),
|
|
200
|
+
"load_status": load_status,
|
|
201
|
+
"unavailable_reason": unavailable_reason,
|
|
147
202
|
}
|
|
148
|
-
short_id = str(item["id"]).lower()
|
|
149
|
-
aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
|
|
150
|
-
options: List[Dict[str, str]] = []
|
|
151
|
-
for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
|
|
152
|
-
real = aliases.get(engine_name)
|
|
153
|
-
if not real:
|
|
154
|
-
continue
|
|
155
|
-
options.append({
|
|
156
|
-
"engine": engine_name,
|
|
157
|
-
"model_id": real,
|
|
158
|
-
"load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
|
|
159
|
-
})
|
|
160
|
-
if not options:
|
|
161
|
-
options.append({"engine": "local_mlx", "model_id": item["id"], "load_id": item["id"]})
|
|
162
203
|
base["engine_options"] = options
|
|
163
|
-
base["recommended_engine"] =
|
|
204
|
+
base["recommended_engine"] = recommended_engine
|
|
205
|
+
base["recommended_load_id"] = load_id
|
|
164
206
|
out.append(base)
|
|
165
207
|
return out
|
|
166
208
|
|
|
@@ -232,6 +274,7 @@ def create_models_router(
|
|
|
232
274
|
require_user(request)
|
|
233
275
|
return await prepare_and_load_model(
|
|
234
276
|
req.model, request, engine=req.engine, user_email=req.user_email,
|
|
277
|
+
allow_download=req.allow_download,
|
|
235
278
|
)
|
|
236
279
|
|
|
237
280
|
@router.post("/engines/prepare-model/stream")
|
|
@@ -242,6 +285,7 @@ def create_models_router(
|
|
|
242
285
|
try:
|
|
243
286
|
async for chunk in prepare_and_load_model_stream(
|
|
244
287
|
req.model, request, engine=req.engine, user_email=req.user_email,
|
|
288
|
+
allow_download=req.allow_download,
|
|
245
289
|
):
|
|
246
290
|
yield chunk
|
|
247
291
|
except HTTPException as exc:
|
|
@@ -287,10 +331,13 @@ def create_models_router(
|
|
|
287
331
|
|
|
288
332
|
@router.get("/models")
|
|
289
333
|
async def list_models():
|
|
334
|
+
engines = await asyncio.to_thread(engine_status)
|
|
290
335
|
recommended = _recommended_with_engine_options(
|
|
291
|
-
list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", [])))
|
|
336
|
+
list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", []))),
|
|
337
|
+
engines=engines,
|
|
338
|
+
loaded_ids=_router.loaded_model_ids,
|
|
339
|
+
current_id=_router.current_model_id,
|
|
292
340
|
)
|
|
293
|
-
engines = await asyncio.to_thread(engine_status)
|
|
294
341
|
return {
|
|
295
342
|
"recommended": recommended,
|
|
296
343
|
"cloud": _router.detected_cloud_models(),
|
|
@@ -319,6 +366,7 @@ def create_models_router(
|
|
|
319
366
|
return await prepare_and_load_model(
|
|
320
367
|
model_id, request, engine=req.engine, user_email=req.user_email,
|
|
321
368
|
adapter_path=req.adapter_path, draft_model_id=req.draft_model_id,
|
|
369
|
+
allow_download=req.allow_download,
|
|
322
370
|
)
|
|
323
371
|
except HTTPException:
|
|
324
372
|
raise
|
|
@@ -94,17 +94,24 @@ class KnowledgeGraphProjectionMixin:
|
|
|
94
94
|
"""
|
|
95
95
|
if KGStoreV2 is None or _exec_script is None:
|
|
96
96
|
return
|
|
97
|
+
self._v2_projection_available = False
|
|
97
98
|
try:
|
|
98
99
|
self._backup_before_v2_flip()
|
|
99
100
|
with self._connect() as conn:
|
|
100
101
|
conn.execute("BEGIN")
|
|
101
102
|
stale = self._projection_version(conn) != _PROJECTION_VERSION
|
|
103
|
+
# Reconstruction views are non-authoritative. Recreate them on
|
|
104
|
+
# every startup so older SQLite rename migrations cannot strand
|
|
105
|
+
# a view against a temporary table such as edges_v2_old.
|
|
106
|
+
for stmt in (
|
|
107
|
+
"DROP VIEW IF EXISTS kgv2_edges",
|
|
108
|
+
"DROP VIEW IF EXISTS kgv2_nodes",
|
|
109
|
+
):
|
|
110
|
+
conn.execute(stmt)
|
|
102
111
|
if stale:
|
|
103
112
|
# The projection is non-authoritative; drop it so init_schema
|
|
104
113
|
# recreates the tables with the current normalized columns.
|
|
105
114
|
for stmt in (
|
|
106
|
-
"DROP VIEW IF EXISTS kgv2_edges",
|
|
107
|
-
"DROP VIEW IF EXISTS kgv2_nodes",
|
|
108
115
|
"DROP TABLE IF EXISTS edges_v2",
|
|
109
116
|
"DROP TABLE IF EXISTS nodes_v2",
|
|
110
117
|
):
|
|
@@ -128,6 +135,9 @@ class KnowledgeGraphProjectionMixin:
|
|
|
128
135
|
(_V2_WRITE_MASTER_KEY, _V2_WRITE_MASTER_KEY, mastered_at),
|
|
129
136
|
)
|
|
130
137
|
conn.execute(f"PRAGMA user_version={_KG_DB_FORMAT_VERSION}")
|
|
138
|
+
conn.execute("SELECT 1 FROM kgv2_nodes LIMIT 1").fetchone()
|
|
139
|
+
conn.execute("SELECT 1 FROM kgv2_edges LIMIT 1").fetchone()
|
|
140
|
+
self._v2_projection_available = True
|
|
131
141
|
except Exception as e:
|
|
132
142
|
logging.warning("knowledge_graph: v2 schema init/backfill skipped: %s", e)
|
|
133
143
|
|
|
@@ -879,6 +879,16 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
879
879
|
if isinstance(storage_capabilities, dict)
|
|
880
880
|
else "bruteforce-cosine"
|
|
881
881
|
),
|
|
882
|
+
"vector_search_mode": (
|
|
883
|
+
(storage_capabilities.get("metadata") or {}).get("vector_mode")
|
|
884
|
+
if isinstance(storage_capabilities, dict)
|
|
885
|
+
else "fallback"
|
|
886
|
+
),
|
|
887
|
+
"sqlite_vec_ann_available": (
|
|
888
|
+
bool((storage_capabilities.get("metadata") or {}).get("sqlite_vec_ann_available"))
|
|
889
|
+
if isinstance(storage_capabilities, dict)
|
|
890
|
+
else False
|
|
891
|
+
),
|
|
882
892
|
},
|
|
883
893
|
"source_items": len(source_items),
|
|
884
894
|
"indexed_items": sum(vector_counts.values()),
|
package/latticeai/brain/store.py
CHANGED
|
@@ -53,10 +53,15 @@ class KnowledgeGraphStore(
|
|
|
53
53
|
self._embedding_model = (
|
|
54
54
|
embedder if embedder is not None else LocalEmbeddingModel()
|
|
55
55
|
)
|
|
56
|
+
self._v2_projection_available = False
|
|
56
57
|
self._init_db()
|
|
57
58
|
# Read graph queries from the v2 projection (kgv2_* views) when available.
|
|
58
59
|
# Toggle off (e.g. in tests) to compare against the legacy tables.
|
|
59
|
-
self._read_from_v2 =
|
|
60
|
+
self._read_from_v2 = (
|
|
61
|
+
KGStoreV2 is not None
|
|
62
|
+
and _READ_FROM_V2_DEFAULT
|
|
63
|
+
and self._v2_projection_available
|
|
64
|
+
)
|
|
60
65
|
|
|
61
66
|
def _read_tables(self) -> tuple:
|
|
62
67
|
"""Return (nodes_table, edges_table) for read queries.
|
package/latticeai/core/config.py
CHANGED
|
@@ -157,6 +157,8 @@ class Config:
|
|
|
157
157
|
if packaged_static.exists():
|
|
158
158
|
static_dir = packaged_static
|
|
159
159
|
|
|
160
|
+
default_sso_redirect = f"http://localhost:{port}/auth/sso/callback"
|
|
161
|
+
|
|
160
162
|
return cls(
|
|
161
163
|
app_mode=app_mode,
|
|
162
164
|
is_public=is_public,
|
|
@@ -196,7 +198,7 @@ class Config:
|
|
|
196
198
|
sso_discovery_url=_value(env, "OIDC_DISCOVERY_URL", ""),
|
|
197
199
|
sso_client_id=_value(env, "OIDC_CLIENT_ID", ""),
|
|
198
200
|
sso_client_secret=_value(env, "OIDC_CLIENT_SECRET", ""),
|
|
199
|
-
sso_redirect_uri=_value(env, "OIDC_REDIRECT_URI",
|
|
201
|
+
sso_redirect_uri=_value(env, "OIDC_REDIRECT_URI", default_sso_redirect),
|
|
200
202
|
sso_provider_name=_value(env, "OIDC_PROVIDER_NAME", "SSO"),
|
|
201
203
|
discord_permission_webhook=_value(env, "LATTICEAI_DISCORD_PERMISSION_WEBHOOK", ""),
|
|
202
204
|
discord_bot_token=_value(env, "LATTICEAI_DISCORD_BOT_TOKEN", ""),
|
|
@@ -14,7 +14,7 @@ from datetime import datetime
|
|
|
14
14
|
from typing import Any, Callable, Dict, List, Optional
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
MULTI_AGENT_VERSION = "4.3.
|
|
17
|
+
MULTI_AGENT_VERSION = "4.3.1"
|
|
18
18
|
|
|
19
19
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
20
20
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -12,6 +12,7 @@ import shutil
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any, Dict, Mapping, Optional
|
|
14
14
|
|
|
15
|
+
from latticeai import __version__
|
|
15
16
|
from latticeai.core.config import Config
|
|
16
17
|
|
|
17
18
|
|
|
@@ -160,7 +161,7 @@ def build_product_hardening_status(
|
|
|
160
161
|
identity = device_identity.describe()
|
|
161
162
|
data_dir = Path(config.data_dir)
|
|
162
163
|
return {
|
|
163
|
-
"version":
|
|
164
|
+
"version": __version__,
|
|
164
165
|
"startup": {
|
|
165
166
|
"local_only_default": default_startup_local_only(config, env=env),
|
|
166
167
|
"host": config.host,
|
|
@@ -19,7 +19,7 @@ from pathlib import Path
|
|
|
19
19
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
WORKSPACE_OS_VERSION = "4.3.
|
|
22
|
+
WORKSPACE_OS_VERSION = "4.3.1"
|
|
23
23
|
|
|
24
24
|
# Workspace types separate single-user Personal workspaces from shared
|
|
25
25
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -50,6 +50,10 @@ def _now() -> str:
|
|
|
50
50
|
return datetime.now().isoformat(timespec="seconds")
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
class AgentRuntimeUnavailable(RuntimeError):
|
|
54
|
+
"""Raised when a product run would otherwise persist simulation output."""
|
|
55
|
+
|
|
56
|
+
|
|
53
57
|
class AgentRuntime:
|
|
54
58
|
def __init__(
|
|
55
59
|
self,
|
|
@@ -60,6 +64,7 @@ class AgentRuntime:
|
|
|
60
64
|
append_audit_event: Callable[..., None],
|
|
61
65
|
max_retries_cap: int = 5,
|
|
62
66
|
hooks: Any = None,
|
|
67
|
+
allow_simulation_runs: bool = False,
|
|
63
68
|
):
|
|
64
69
|
self._store = store
|
|
65
70
|
self._orchestrator_factory = orchestrator_factory
|
|
@@ -69,6 +74,7 @@ class AgentRuntime:
|
|
|
69
74
|
# Lifecycle hooks registry (optional). When present, ``start`` fires the
|
|
70
75
|
# ``pre_run`` / ``post_run`` hooks; a blocking ``pre_run`` aborts the run.
|
|
71
76
|
self._hooks = hooks
|
|
77
|
+
self._allow_simulation_runs = bool(allow_simulation_runs)
|
|
72
78
|
self._run_executor: Any = None
|
|
73
79
|
|
|
74
80
|
def attach_executor(self, executor: Any) -> None:
|
|
@@ -85,6 +91,7 @@ class AgentRuntime:
|
|
|
85
91
|
"default_pipeline": list(CORE_PIPELINE),
|
|
86
92
|
"max_retries_cap": self._max_retries_cap,
|
|
87
93
|
"execution_mode": self._execution_mode(),
|
|
94
|
+
"simulation_runs_allowed": self._allow_simulation_runs,
|
|
88
95
|
"cancellation": (
|
|
89
96
|
"cooperative; running synchronous model/tool calls finish their current step before a cancelled status is persisted"
|
|
90
97
|
if self._run_executor is not None else
|
|
@@ -107,6 +114,7 @@ class AgentRuntime:
|
|
|
107
114
|
def health(self) -> Dict[str, Any]:
|
|
108
115
|
checks: Dict[str, Any] = {}
|
|
109
116
|
ok = True
|
|
117
|
+
ready = True
|
|
110
118
|
try:
|
|
111
119
|
self._store.list_agents(workspace_id=None)
|
|
112
120
|
checks["run_store"] = {"status": "ok"}
|
|
@@ -114,12 +122,42 @@ class AgentRuntime:
|
|
|
114
122
|
ok = False
|
|
115
123
|
checks["run_store"] = {"status": "error", "detail": str(exc)}
|
|
116
124
|
try:
|
|
117
|
-
self._orchestrator_factory(None, None)
|
|
118
|
-
|
|
125
|
+
orchestrator = self._orchestrator_factory(None, None)
|
|
126
|
+
mode = getattr(orchestrator, "mode", "simulation")
|
|
127
|
+
if mode == "simulation":
|
|
128
|
+
if self._allow_simulation_runs:
|
|
129
|
+
checks["orchestrator"] = {
|
|
130
|
+
"status": "ok",
|
|
131
|
+
"mode": mode,
|
|
132
|
+
"detail": "Simulation runs are explicitly enabled for this non-product runtime.",
|
|
133
|
+
}
|
|
134
|
+
else:
|
|
135
|
+
ready = False
|
|
136
|
+
checks["orchestrator"] = {
|
|
137
|
+
"status": "unavailable",
|
|
138
|
+
"mode": mode,
|
|
139
|
+
"detail": "No LLM-backed model is loaded; product execution API refuses simulation runs.",
|
|
140
|
+
}
|
|
141
|
+
else:
|
|
142
|
+
checks["orchestrator"] = {"status": "ok", "mode": mode}
|
|
119
143
|
except Exception as exc: # pragma: no cover - defensive
|
|
120
144
|
ok = False
|
|
121
145
|
checks["orchestrator"] = {"status": "error", "detail": str(exc)}
|
|
122
|
-
return {
|
|
146
|
+
return {
|
|
147
|
+
"status": "ok" if ok and ready else "unavailable" if ok else "degraded",
|
|
148
|
+
"ready": bool(ok and ready),
|
|
149
|
+
"checks": checks,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
def _live_orchestrator(self, user_email: Optional[str], scope: Optional[str]) -> Any:
|
|
153
|
+
orchestrator = self._orchestrator_factory(user_email or None, scope)
|
|
154
|
+
mode = getattr(orchestrator, "mode", "simulation")
|
|
155
|
+
if mode == "simulation" and not self._allow_simulation_runs:
|
|
156
|
+
raise AgentRuntimeUnavailable(
|
|
157
|
+
"Agent execution is unavailable because no LLM-backed model is loaded. "
|
|
158
|
+
"Simulation mode is disabled in the product execution API so it cannot be recorded as real success."
|
|
159
|
+
)
|
|
160
|
+
return orchestrator
|
|
123
161
|
|
|
124
162
|
# ── roster + status ───────────────────────────────────────────────────
|
|
125
163
|
def _roster(self, runs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
@@ -163,16 +201,21 @@ class AgentRuntime:
|
|
|
163
201
|
listing = {"agents": [], "runs": [], "error": str(exc)}
|
|
164
202
|
runs = list(listing.get("runs") or [])
|
|
165
203
|
active = sum(1 for r in runs if str(r.get("status")) in _ACTIVE_STATUSES)
|
|
204
|
+
health = self.health()
|
|
205
|
+
orchestrator_status = (health.get("checks") or {}).get("orchestrator") or {}
|
|
206
|
+
ready = bool(health.get("ready"))
|
|
166
207
|
return {
|
|
167
208
|
"runtime": {
|
|
168
|
-
"ready":
|
|
209
|
+
"ready": ready,
|
|
169
210
|
"version": MULTI_AGENT_VERSION,
|
|
170
211
|
"execution_mode": self._execution_mode(),
|
|
212
|
+
"mode": orchestrator_status.get("mode", "unknown"),
|
|
213
|
+
"unavailable_reason": None if ready else orchestrator_status.get("detail"),
|
|
171
214
|
"default_pipeline": list(CORE_PIPELINE),
|
|
172
215
|
"total_runs": len(runs),
|
|
173
216
|
"active_runs": active,
|
|
174
217
|
},
|
|
175
|
-
"health":
|
|
218
|
+
"health": health,
|
|
176
219
|
"roles": self.roles(),
|
|
177
220
|
"agents": self._roster(runs),
|
|
178
221
|
"runs": runs[:25],
|
|
@@ -288,11 +331,8 @@ class AgentRuntime:
|
|
|
288
331
|
user_email=user_email,
|
|
289
332
|
scope=scope,
|
|
290
333
|
)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
mode = getattr(orchestrator, "mode", "simulation")
|
|
294
|
-
except Exception:
|
|
295
|
-
mode = "simulation"
|
|
334
|
+
orchestrator = self._live_orchestrator(user_email, scope)
|
|
335
|
+
mode = getattr(orchestrator, "mode", "llm")
|
|
296
336
|
run = self._store.record_agent_run(
|
|
297
337
|
agent_id=ROLE_AGENT_IDS.get("executor", "agent:executor"),
|
|
298
338
|
status="queued",
|
|
@@ -349,7 +389,7 @@ class AgentRuntime:
|
|
|
349
389
|
started_at=run.get("started_at") or _now(),
|
|
350
390
|
)
|
|
351
391
|
try:
|
|
352
|
-
orchestrator = self.
|
|
392
|
+
orchestrator = self._live_orchestrator(user_email, scope)
|
|
353
393
|
result = orchestrator.run(
|
|
354
394
|
goal,
|
|
355
395
|
user_email=user_email or None,
|
|
@@ -452,7 +492,7 @@ class AgentRuntime:
|
|
|
452
492
|
)
|
|
453
493
|
raise PermissionError(pre_dispatch.get("block_reason") or "Agent run blocked by a pre_run hook.")
|
|
454
494
|
|
|
455
|
-
orchestrator = self.
|
|
495
|
+
orchestrator = self._live_orchestrator(user_email, scope)
|
|
456
496
|
result = orchestrator.run(
|
|
457
497
|
goal,
|
|
458
498
|
user_email=user_email or None,
|
|
@@ -66,6 +66,51 @@ IS_PUBLIC_MODE = False
|
|
|
66
66
|
keyring = None
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def _env_bool(key: str, default: bool = False) -> bool:
|
|
70
|
+
raw = os.getenv(key)
|
|
71
|
+
if raw is None:
|
|
72
|
+
return default
|
|
73
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _download_allowed(allow_download: bool = False) -> bool:
|
|
77
|
+
return bool(allow_download) or _env_bool("LATTICEAI_ALLOW_MODEL_DOWNLOADS", default=False) or bool(AUTOLOAD_MODELS)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _download_block(provider: str, model_name: str) -> None:
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=409,
|
|
83
|
+
detail={
|
|
84
|
+
"status": "unavailable",
|
|
85
|
+
"capability": "model_download",
|
|
86
|
+
"provider": provider,
|
|
87
|
+
"model": model_name,
|
|
88
|
+
"reason": (
|
|
89
|
+
"Model files are not present locally. Lattice AI does not start "
|
|
90
|
+
"outbound model downloads by default, and token/model presence "
|
|
91
|
+
"alone never authorizes network activity."
|
|
92
|
+
),
|
|
93
|
+
"action": "Use the explicit pull/prepare flow with download consent, or set LATTICEAI_ALLOW_MODEL_DOWNLOADS=true.",
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _engine_install_block(engine: str) -> None:
|
|
99
|
+
raise HTTPException(
|
|
100
|
+
status_code=409,
|
|
101
|
+
detail={
|
|
102
|
+
"status": "unavailable",
|
|
103
|
+
"capability": "engine_install",
|
|
104
|
+
"engine": engine,
|
|
105
|
+
"reason": (
|
|
106
|
+
"The requested local runtime is not installed. Lattice AI does not "
|
|
107
|
+
"run package-manager or installer commands from Model Load by default."
|
|
108
|
+
),
|
|
109
|
+
"action": "Install the runtime explicitly from Library/System setup, or enable explicit download/install consent for this request.",
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
69
114
|
def _missing_current_user(_request: Request) -> Optional[str]:
|
|
70
115
|
return None
|
|
71
116
|
|
|
@@ -1307,6 +1352,7 @@ async def prepare_and_load_model(
|
|
|
1307
1352
|
user_email: Optional[str] = None,
|
|
1308
1353
|
adapter_path: Optional[str] = None,
|
|
1309
1354
|
draft_model_id: Optional[str] = None,
|
|
1355
|
+
allow_download: bool = False,
|
|
1310
1356
|
) -> Dict[str, object]:
|
|
1311
1357
|
model_id = normalize_local_model_request(model_id, engine)
|
|
1312
1358
|
if not model_id:
|
|
@@ -1329,11 +1375,15 @@ async def prepare_and_load_model(
|
|
|
1329
1375
|
download_result: Optional[Dict[str, object]] = None
|
|
1330
1376
|
|
|
1331
1377
|
if parsed_provider in local_engines:
|
|
1378
|
+
if not engine_installed(parsed_provider) and not _download_allowed(allow_download):
|
|
1379
|
+
_engine_install_block(parsed_provider)
|
|
1332
1380
|
install_result = ensure_engine_ready(parsed_provider)
|
|
1333
1381
|
|
|
1334
1382
|
if parsed_provider == "local_mlx":
|
|
1335
1383
|
explicit_path = Path(parsed_model).expanduser()
|
|
1336
1384
|
if not explicit_path.exists() and not hf_model_ready(parsed_model, "local_mlx"):
|
|
1385
|
+
if not _download_allowed(allow_download):
|
|
1386
|
+
_download_block(parsed_provider, parsed_model)
|
|
1337
1387
|
download_result = download_hf_model(parsed_model, "local_mlx")
|
|
1338
1388
|
elif parsed_provider == "ollama":
|
|
1339
1389
|
ensure_ollama_server()
|
|
@@ -1341,6 +1391,8 @@ async def prepare_and_load_model(
|
|
|
1341
1391
|
if not ollama:
|
|
1342
1392
|
raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
|
|
1343
1393
|
if parsed_model not in get_ollama_pulled_models():
|
|
1394
|
+
if not _download_allowed(allow_download):
|
|
1395
|
+
_download_block(parsed_provider, parsed_model)
|
|
1344
1396
|
completed = subprocess.run(
|
|
1345
1397
|
[ollama, "pull", parsed_model],
|
|
1346
1398
|
capture_output=True,
|
|
@@ -1352,12 +1404,23 @@ async def prepare_and_load_model(
|
|
|
1352
1404
|
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "Ollama 모델 다운로드 실패")
|
|
1353
1405
|
download_result = {"provider": "ollama", "model": parsed_model, "returncode": completed.returncode}
|
|
1354
1406
|
elif parsed_provider == "vllm":
|
|
1407
|
+
if not hf_model_ready(parsed_model, "vllm") and not _download_allowed(allow_download):
|
|
1408
|
+
_download_block(parsed_provider, parsed_model)
|
|
1355
1409
|
ensure_vllm_server(parsed_model)
|
|
1356
1410
|
download_result = {"provider": "vllm", "model": parsed_model, "server_ready": True}
|
|
1357
1411
|
elif parsed_provider == "llamacpp":
|
|
1412
|
+
if not hf_model_ready(parsed_model, "llamacpp") and not _download_allowed(allow_download):
|
|
1413
|
+
_download_block(parsed_provider, parsed_model)
|
|
1358
1414
|
ensure_llamacpp_server(parsed_model)
|
|
1359
1415
|
download_result = {"provider": "llamacpp", "model": parsed_model, "server_ready": True}
|
|
1360
1416
|
elif parsed_provider == "lmstudio":
|
|
1417
|
+
downloaded = {
|
|
1418
|
+
str(item.get("key") or "").strip()
|
|
1419
|
+
for item in get_lmstudio_models()
|
|
1420
|
+
if isinstance(item, dict)
|
|
1421
|
+
}
|
|
1422
|
+
if parsed_model not in downloaded and not _download_allowed(allow_download):
|
|
1423
|
+
_download_block(parsed_provider, parsed_model)
|
|
1361
1424
|
ensured = ensure_lmstudio_model(parsed_model)
|
|
1362
1425
|
resolved_model = str(
|
|
1363
1426
|
ensured.get("instance_id")
|
|
@@ -1399,7 +1462,7 @@ async def prepare_and_load_model(
|
|
|
1399
1462
|
"installed_now": bool(install_result.get("installed_now")),
|
|
1400
1463
|
"download": download_result,
|
|
1401
1464
|
"resolution": resolution.to_dict(),
|
|
1402
|
-
"downloaded":
|
|
1465
|
+
"downloaded": bool(download_result and not (isinstance(download_result, dict) and download_result.get("cached"))),
|
|
1403
1466
|
"loaded": True,
|
|
1404
1467
|
"ready_to_chat": ready_to_chat,
|
|
1405
1468
|
"compatibility_status": compat_status,
|
|
@@ -1416,6 +1479,7 @@ async def prepare_and_load_model_stream(
|
|
|
1416
1479
|
request: Request,
|
|
1417
1480
|
engine: Optional[str] = None,
|
|
1418
1481
|
user_email: Optional[str] = None,
|
|
1482
|
+
allow_download: bool = False,
|
|
1419
1483
|
) -> AsyncIterator[str]:
|
|
1420
1484
|
model_id = normalize_local_model_request(model_id, engine)
|
|
1421
1485
|
if not model_id:
|
|
@@ -1446,6 +1510,8 @@ async def prepare_and_load_model_stream(
|
|
|
1446
1510
|
percent=2,
|
|
1447
1511
|
indeterminate=True,
|
|
1448
1512
|
))
|
|
1513
|
+
if not engine_installed(parsed_provider) and not _download_allowed(allow_download):
|
|
1514
|
+
_engine_install_block(parsed_provider)
|
|
1449
1515
|
install_result = ensure_engine_ready(parsed_provider)
|
|
1450
1516
|
emit_progress(model_download_progress_payload(
|
|
1451
1517
|
"engine",
|
|
@@ -1466,6 +1532,8 @@ async def prepare_and_load_model_stream(
|
|
|
1466
1532
|
eta_seconds=0,
|
|
1467
1533
|
))
|
|
1468
1534
|
elif not hf_model_ready(parsed_model, "local_mlx"):
|
|
1535
|
+
if not _download_allowed(allow_download):
|
|
1536
|
+
_download_block(parsed_provider, parsed_model)
|
|
1469
1537
|
download_result = download_hf_model(parsed_model, "local_mlx", progress_emit=emit_progress)
|
|
1470
1538
|
else:
|
|
1471
1539
|
download_result = {"model": parsed_model, "path": str(hf_model_dir(parsed_model)), "cached": True}
|
|
@@ -1484,6 +1552,8 @@ async def prepare_and_load_model_stream(
|
|
|
1484
1552
|
))
|
|
1485
1553
|
ensure_ollama_server()
|
|
1486
1554
|
if parsed_model not in get_ollama_pulled_models():
|
|
1555
|
+
if not _download_allowed(allow_download):
|
|
1556
|
+
_download_block(parsed_provider, parsed_model)
|
|
1487
1557
|
download_result = pull_ollama_model_with_progress(parsed_model, progress_emit=emit_progress)
|
|
1488
1558
|
else:
|
|
1489
1559
|
download_result = {"provider": "ollama", "model": parsed_model, "cached": True}
|
|
@@ -1496,6 +1566,8 @@ async def prepare_and_load_model_stream(
|
|
|
1496
1566
|
))
|
|
1497
1567
|
elif parsed_provider == "vllm":
|
|
1498
1568
|
if not hf_model_ready(parsed_model, "vllm"):
|
|
1569
|
+
if not _download_allowed(allow_download):
|
|
1570
|
+
_download_block(parsed_provider, parsed_model)
|
|
1499
1571
|
download_result = download_hf_model(parsed_model, "vllm", progress_emit=emit_progress)
|
|
1500
1572
|
else:
|
|
1501
1573
|
download_result = {"provider": "vllm", "model": parsed_model, "cached": True}
|
|
@@ -1516,6 +1588,8 @@ async def prepare_and_load_model_stream(
|
|
|
1516
1588
|
download_result = {**(download_result or {}), "provider": "vllm", "model": parsed_model, "server_ready": True}
|
|
1517
1589
|
elif parsed_provider == "llamacpp":
|
|
1518
1590
|
if not hf_model_ready(parsed_model, "llamacpp"):
|
|
1591
|
+
if not _download_allowed(allow_download):
|
|
1592
|
+
_download_block(parsed_provider, parsed_model)
|
|
1519
1593
|
download_result = download_hf_model(parsed_model, "llamacpp", progress_emit=emit_progress)
|
|
1520
1594
|
else:
|
|
1521
1595
|
download_result = {"provider": "llamacpp", "model": parsed_model, "cached": True}
|
|
@@ -1535,6 +1609,13 @@ async def prepare_and_load_model_stream(
|
|
|
1535
1609
|
ensure_llamacpp_server(parsed_model)
|
|
1536
1610
|
download_result = {**(download_result or {}), "provider": "llamacpp", "model": parsed_model, "server_ready": True}
|
|
1537
1611
|
elif parsed_provider == "lmstudio":
|
|
1612
|
+
downloaded = {
|
|
1613
|
+
str(item.get("key") or "").strip()
|
|
1614
|
+
for item in get_lmstudio_models()
|
|
1615
|
+
if isinstance(item, dict)
|
|
1616
|
+
}
|
|
1617
|
+
if parsed_model not in downloaded and not _download_allowed(allow_download):
|
|
1618
|
+
_download_block(parsed_provider, parsed_model)
|
|
1538
1619
|
emit_progress(model_download_progress_payload(
|
|
1539
1620
|
"download",
|
|
1540
1621
|
"LM Studio 모델을 확인하는 중입니다.",
|
|
@@ -1643,7 +1724,7 @@ async def prepare_and_load_model_stream(
|
|
|
1643
1724
|
"installed_now": bool(isinstance(install_result, dict) and install_result.get("installed_now")),
|
|
1644
1725
|
"download": download_result,
|
|
1645
1726
|
"resolution": resolution_stream.to_dict(),
|
|
1646
|
-
"downloaded":
|
|
1727
|
+
"downloaded": bool(download_result and not (isinstance(download_result, dict) and download_result.get("cached"))),
|
|
1647
1728
|
"loaded": True,
|
|
1648
1729
|
"ready_to_chat": ready_to_chat,
|
|
1649
1730
|
"compatibility_status": compat_status,
|
package/ltcai_cli.py
CHANGED
|
@@ -246,9 +246,15 @@ def main() -> None:
|
|
|
246
246
|
|
|
247
247
|
os.chdir(app_dir)
|
|
248
248
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
if not args.tunnel and os.getenv("LATTICEAI_TUNNEL", "").lower() in (
|
|
250
|
+
"1",
|
|
251
|
+
"true",
|
|
252
|
+
"yes",
|
|
253
|
+
):
|
|
254
|
+
print(
|
|
255
|
+
" LATTICEAI_TUNNEL is ignored during default local startup; "
|
|
256
|
+
"restart with --tunnel to expose this server."
|
|
257
|
+
)
|
|
252
258
|
|
|
253
259
|
# --tunnel forces 0.0.0.0 so cloudflared can reach the server
|
|
254
260
|
if args.tunnel and args.host == "127.0.0.1":
|
|
@@ -257,6 +263,11 @@ def main() -> None:
|
|
|
257
263
|
os.environ.setdefault("LATTICEAI_CORS_ALLOW_NETWORK", "true")
|
|
258
264
|
os.environ.setdefault("LATTICEAI_REQUIRE_AUTH", "true")
|
|
259
265
|
|
|
266
|
+
# Keep the app config in sync with CLI flags. ``Config.from_env`` is the
|
|
267
|
+
# source of truth for /mode, /health.features, SSO defaults, and routers.
|
|
268
|
+
os.environ["LATTICEAI_HOST"] = str(args.host)
|
|
269
|
+
os.environ["LATTICEAI_PORT"] = str(args.port)
|
|
270
|
+
|
|
260
271
|
tunnel_url: str | None = None
|
|
261
272
|
if args.tunnel:
|
|
262
273
|
print()
|