ltcai 11.5.1 → 11.5.2
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 +85 -53
- package/docs/CHANGELOG.md +54 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/HYBRID_CLOUD_KG_STREAMING.md +8 -4
- package/docs/MULTI_AGENT_RUNTIME.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/WORKFLOW_DESIGNER.md +1 -1
- package/docs/architecture.md +4 -4
- package/docs/kg-schema.md +1 -1
- package/docs/v11.5.2_TIGHT_SHIP_PLAN.md +144 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/proactive.py +1 -1
- package/lattice_brain/graph/projection/v2_schema.py +0 -28
- package/lattice_brain/graph/vector_index/base.py +0 -3
- package/lattice_brain/graph/vector_index/brute_force.py +0 -4
- package/lattice_brain/graph/vector_index/hnsw.py +0 -3
- package/lattice_brain/graph/vector_index/quantized.py +0 -3
- package/lattice_brain/ingestion_jobs.py +0 -3
- package/lattice_brain/memory.py +0 -27
- package/lattice_brain/portability/sharing.py +0 -4
- package/lattice_brain/quality.py +0 -49
- package/lattice_brain/runtime/agent_runtime.py +1 -1
- package/lattice_brain/runtime/hooks.py +0 -13
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +0 -4
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +12 -12
- package/latticeai/api/auth.py +25 -2
- package/latticeai/api/chat.py +3 -3
- package/latticeai/api/chat_agent_http.py +3 -12
- package/latticeai/api/chat_helpers.py +10 -13
- package/latticeai/api/computer_use.py +31 -42
- package/latticeai/api/health.py +21 -1
- package/latticeai/api/local_files.py +14 -0
- package/latticeai/api/permissions.py +38 -12
- package/latticeai/api/search.py +24 -0
- package/latticeai/api/static_routes.py +4 -1
- package/latticeai/cli/runtime.py +7 -3
- package/latticeai/core/config.py +47 -3
- package/latticeai/core/csrf.py +28 -2
- package/latticeai/core/embedding_providers/__init__.py +3 -5
- package/latticeai/core/embedding_providers/base.py +1 -1
- package/latticeai/core/embedding_providers/text.py +1 -1
- package/latticeai/core/enterprise.py +1 -4
- package/latticeai/core/http_origin.py +146 -0
- package/latticeai/core/invitations.py +3 -2
- package/latticeai/core/io_utils.py +2 -11
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +5 -0
- package/latticeai/core/model_compat.py +2 -8
- package/latticeai/core/module_probe.py +37 -0
- package/latticeai/core/run_explain.py +5 -2
- package/latticeai/core/run_store.py +2 -2
- package/latticeai/core/security.py +13 -0
- package/latticeai/core/sessions.py +3 -2
- package/latticeai/core/sse.py +25 -0
- package/latticeai/core/users.py +0 -9
- package/latticeai/core/workspace_graph_trace.py +2 -8
- package/latticeai/core/workspace_os.py +0 -10
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_os_utils.py +34 -0
- package/latticeai/core/workspace_review_items.py +2 -8
- package/latticeai/core/workspace_runs.py +2 -8
- package/latticeai/core/workspace_skills.py +2 -7
- package/latticeai/models/router/documents.py +0 -35
- package/latticeai/models/router/registry.py +0 -4
- package/latticeai/runtime/access_runtime.py +16 -4
- package/latticeai/runtime/build_phases/features.py +2 -0
- package/latticeai/runtime/build_phases/web.py +2 -0
- package/latticeai/runtime/feature_toggle_wiring.py +12 -18
- package/latticeai/runtime/network_boundary_wiring.py +8 -19
- package/latticeai/runtime/permission_mode_wiring.py +6 -13
- package/latticeai/runtime/router_registration.py +4 -0
- package/latticeai/runtime/runtime_context.py +1 -13
- package/latticeai/runtime/service_singletons.py +55 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence/proposals.py +0 -5
- package/latticeai/services/change_proposals.py +2 -36
- package/latticeai/services/chronicle.py +4 -6
- package/latticeai/services/command_center.py +4 -4
- package/latticeai/services/evidence_actions.py +5 -2
- package/latticeai/services/hybrid_chat.py +2 -2
- package/latticeai/services/hybrid_policy.py +8 -57
- package/latticeai/services/mode_store.py +132 -0
- package/latticeai/services/model_capability_registry.py +0 -15
- package/latticeai/services/model_catalog.py +0 -7
- package/latticeai/services/model_loading.py +3 -2
- package/latticeai/services/model_runtime/__init__.py +0 -57
- package/latticeai/services/model_runtime/engines.py +27 -128
- package/latticeai/services/model_runtime/loading.py +2 -2
- package/latticeai/services/network_boundary_service.py +7 -44
- package/latticeai/services/permission_mode_service.py +8 -56
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/setup_detection.py +67 -1
- package/latticeai/services/tool_dispatch.py +0 -4
- package/latticeai/services/upload_service.py +7 -20
- package/latticeai/services/workspace_service.py +0 -6
- package/latticeai/setup/auto_setup.py +15 -26
- package/latticeai/setup/wizard/catalog.py +5 -2
- package/latticeai/setup/wizard/detect.py +6 -27
- package/latticeai/setup/wizard/paths.py +2 -5
- package/latticeai/setup/wizard/plans.py +2 -2
- package/package.json +2 -4
- package/scripts/bump_version.py +14 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_legacy_debt.mjs +1 -1
- package/scripts/generate_agent_loop_fixtures.py +87 -0
- package/scripts/generate_rust_parity_fixtures.py +3 -95
- package/scripts/parity_fixture_corpus_context.py +162 -0
- package/scripts/release_screen_claims.json +10 -0
- package/src-tauri/Cargo.lock +7 -7
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/backend.rs +76 -2
- package/src-tauri/src/main.rs +13 -3
- package/src-tauri/tauri.conf.json +2 -2
- package/static/app/asset-manifest.json +40 -40
- package/static/app/assets/{Act-Drs_jd-O.js → Act-DcQizkl1.js} +1 -1
- package/static/app/assets/{AdminConsole-BqNx5oF6.js → AdminConsole-cf4npybT.js} +1 -1
- package/static/app/assets/{Brain-C7bdNmz-.js → Brain-3VCSHFcn.js} +1 -1
- package/static/app/assets/{BrainHome-DcTnINcI.js → BrainHome-Qm8eaztx.js} +1 -1
- package/static/app/assets/{BrainSignals-Df1MRIPD.js → BrainSignals-DS9BtKOW.js} +1 -1
- package/static/app/assets/{Capture-Dgsxvnbx.js → Capture-DiQ219jW.js} +1 -1
- package/static/app/assets/{Chronicle-BrkTxJK1.js → Chronicle-BGvuAchH.js} +1 -1
- package/static/app/assets/{CommandPalette-DDziw7Oj.js → CommandPalette-Bqhm0Urn.js} +1 -1
- package/static/app/assets/{Library-CYxeuraA.js → Library-BV6NnF0a.js} +1 -1
- package/static/app/assets/{LivingBrain-CNp6mvCm.js → LivingBrain-GzenJchP.js} +1 -1
- package/static/app/assets/{ProductFlow-BVsowu3Z.js → ProductFlow-DEP6-vML.js} +1 -1
- package/static/app/assets/{ReviewCard-D1LbOfJS.js → ReviewCard-CNZ7XjWG.js} +1 -1
- package/static/app/assets/{System-DcRAq7wj.js → System-CieofHQa.js} +1 -1
- package/static/app/assets/arrow-left-kfsrk0mv.js +1 -0
- package/static/app/assets/{bot-BK2xQ9mN.js → bot-B_K1Tdmw.js} +1 -1
- package/static/app/assets/{brain-BZNztFBb.js → brain-DWyaV1L1.js} +1 -1
- package/static/app/assets/{button-CjueubVZ.js → button-aTn4s84A.js} +1 -1
- package/static/app/assets/circle-check-qqLug9nU.js +1 -0
- package/static/app/assets/{circle-pause-HRjSmpyY.js → circle-pause-xKgeGXkT.js} +1 -1
- package/static/app/assets/{circle-play-CMcKDVwu.js → circle-play-DkT6tYPX.js} +1 -1
- package/static/app/assets/{cpu-B_MyTfYr.js → cpu-85xYObUC.js} +1 -1
- package/static/app/assets/{download-CTy0EByV.js → download-B5Fm7YXo.js} +1 -1
- package/static/app/assets/{folder-open-DF6bLhTg.js → folder-open-kk2Xa52u.js} +1 -1
- package/static/app/assets/{hard-drive-CpWHG77C.js → hard-drive-DkA3zBW_.js} +1 -1
- package/static/app/assets/{index-Dd6abJHX.js → index-BMPdTmlY.js} +3 -3
- package/static/app/assets/{input-bVgIRN3s.js → input-B0nRf2jO.js} +1 -1
- package/static/app/assets/{link-2-bg6CG5Vk.js → link-2-Dwb4gnTc.js} +1 -1
- package/static/app/assets/{permissionCopy-DFTf1HDZ.js → permissionCopy-CQDUBrOZ.js} +1 -1
- package/static/app/assets/{primitives-D7D-sQ3T.js → primitives-SNp0LRJz.js} +1 -1
- package/static/app/assets/search-BcHqkjoy.js +1 -0
- package/static/app/assets/{share-2-BLzo7U4L.js → share-2-BsrxFglO.js} +1 -1
- package/static/app/assets/{shield-alert-BmngqnsJ.js → shield-alert-5BStfp2_.js} +1 -1
- package/static/app/assets/{textarea-DOSlAQQ3.js → textarea-Cg8IUA-k.js} +1 -1
- package/static/app/assets/{useFocusTrap-pZhgeee4.js → useFocusTrap-CYKvE46M.js} +1 -1
- package/static/app/assets/{useMutation-BMDwNk4I.js → useMutation-CSn9t1op.js} +1 -1
- package/static/app/assets/{useQuery-CSjKttRo.js → useQuery-CY2OI2uy.js} +1 -1
- package/static/app/assets/{utils-D3u_yv7B.js → utils-Ddol2RWD.js} +1 -1
- package/static/app/assets/{workspace-H3bMjYXC.js → workspace-BqDwOz_p.js} +1 -1
- package/static/app/index.html +3 -3
- package/static/sw.js +1 -1
- package/desktop/electron/README.md +0 -9
- package/desktop/electron/main.cjs +0 -58
- package/desktop/electron/preload.cjs +0 -5
- package/latticeai/core/graph_curator.py +0 -11
- package/latticeai/core/hooks.py +0 -11
- package/latticeai/core/local_embeddings.py +0 -104
- package/latticeai/core/multi_agent.py +0 -11
- package/latticeai/core/workflow_engine.py +0 -11
- package/latticeai/services/ingestion.py +0 -11
- package/latticeai/services/kg_portability.py +0 -11
- package/latticeai/services/multimodal_streaming.py +0 -129
- package/scripts/measure_brain_home_fill.mjs +0 -141
- package/static/app/assets/arrow-left-BHYmOTWU.js +0 -1
- package/static/app/assets/circle-check-CLeWJr67.js +0 -1
- package/static/app/assets/search-BgSVX6NG.js +0 -1
|
@@ -351,9 +351,6 @@ class BackgroundIngestionQueue:
|
|
|
351
351
|
def get(self, job_id: str) -> Optional[BackgroundIngestionJob]:
|
|
352
352
|
return self._jobs.get(job_id)
|
|
353
353
|
|
|
354
|
-
def list_pending(self) -> List[BackgroundIngestionJob]:
|
|
355
|
-
return [j for j in self._jobs.values() if j.status == "queued"]
|
|
356
|
-
|
|
357
354
|
def list_recent(self, limit: int = 20) -> List[BackgroundIngestionJob]:
|
|
358
355
|
"""Most recent jobs first (insertion order is schedule order)."""
|
|
359
356
|
limit = max(1, int(limit))
|
package/lattice_brain/memory.py
CHANGED
|
@@ -27,33 +27,6 @@ class BrainMemory:
|
|
|
27
27
|
def available(self) -> bool:
|
|
28
28
|
return self._pipeline is not None and self._pipeline.available()
|
|
29
29
|
|
|
30
|
-
def record_decision(
|
|
31
|
-
self,
|
|
32
|
-
title: str,
|
|
33
|
-
detail: str = "",
|
|
34
|
-
*,
|
|
35
|
-
user_email: Optional[str] = None,
|
|
36
|
-
workspace_id: Optional[str] = None,
|
|
37
|
-
conversation_id: Optional[str] = None,
|
|
38
|
-
decided_by: Optional[str] = None,
|
|
39
|
-
metadata: Optional[Dict[str, Any]] = None,
|
|
40
|
-
) -> Dict[str, Any]:
|
|
41
|
-
if not str(title or "").strip():
|
|
42
|
-
raise ValueError("a decision needs a title")
|
|
43
|
-
result = self._pipeline.ingest(
|
|
44
|
-
IngestionItem(
|
|
45
|
-
source_type="decision",
|
|
46
|
-
title=title.strip(),
|
|
47
|
-
text=detail,
|
|
48
|
-
owner=user_email,
|
|
49
|
-
workspace_id=workspace_id,
|
|
50
|
-
conversation_id=conversation_id,
|
|
51
|
-
metadata={"decided_by": decided_by or user_email, **(metadata or {})},
|
|
52
|
-
),
|
|
53
|
-
user_email=user_email,
|
|
54
|
-
)
|
|
55
|
-
return result.as_dict()
|
|
56
|
-
|
|
57
30
|
def record_experience(
|
|
58
31
|
self,
|
|
59
32
|
title: str,
|
|
@@ -167,10 +167,6 @@ class KGPortabilitySharingMixin(_Core):
|
|
|
167
167
|
quiet()
|
|
168
168
|
return result
|
|
169
169
|
|
|
170
|
-
def import_from_file(self, path, *, mode: str = "merge", dry_run: bool = False) -> Dict[str, Any]:
|
|
171
|
-
artifact = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
172
|
-
return self.import_data(artifact, mode=mode, dry_run=dry_run)
|
|
173
|
-
|
|
174
170
|
# ── selective subgraph share (opt-in prototype, off by default) ──────────
|
|
175
171
|
def share_status(self) -> Dict[str, Any]:
|
|
176
172
|
"""Honest read of the share surface. Safe to call while it is off.
|
package/lattice_brain/quality.py
CHANGED
|
@@ -375,15 +375,6 @@ class GraphEdgeQualityManager:
|
|
|
375
375
|
seen[key] = e.get("id")
|
|
376
376
|
return dups
|
|
377
377
|
|
|
378
|
-
def merge_duplicate_edges(self, edges: List[Dict]) -> List[Dict]:
|
|
379
|
-
# keep highest confidence
|
|
380
|
-
best: Dict[Any, Dict[str, Any]] = {}
|
|
381
|
-
for e in edges:
|
|
382
|
-
key = (e.get("source"), e.get("target"), e.get("type"))
|
|
383
|
-
if key not in best or e.get("confidence", 0) > best[key].get("confidence", 0):
|
|
384
|
-
best[key] = e
|
|
385
|
-
return list(best.values())
|
|
386
|
-
|
|
387
378
|
def compute_quality_metrics(self, edges: List[Dict]) -> Dict[str, float]:
|
|
388
379
|
if not edges:
|
|
389
380
|
return {"avg_conf": 0.0, "avg_evidence": 0.0, "dup_rate": 0.0}
|
|
@@ -532,46 +523,6 @@ class LatticeBrainQuality:
|
|
|
532
523
|
self.context_assembler = StructuredContextAssembler()
|
|
533
524
|
self.benchmark = RetrievalBenchmarkRunner()
|
|
534
525
|
|
|
535
|
-
def full_quality_pass(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
536
|
-
"""End-to-end quality pipeline (non-destructive)"""
|
|
537
|
-
result = {"status": "ok", "timestamp": time.time()}
|
|
538
|
-
|
|
539
|
-
# embedding
|
|
540
|
-
if "embeddings" in payload:
|
|
541
|
-
labels = [self.embed_labeller.label(e["id"], e.get("vector")) for e in payload["embeddings"]]
|
|
542
|
-
result["embedding_labels"] = [label.__dict__ for label in labels]
|
|
543
|
-
result["reindex_plan"] = self.embed_labeller.generate_reindex_plan()
|
|
544
|
-
|
|
545
|
-
# hybrid retrieval
|
|
546
|
-
if "retrieval" in payload:
|
|
547
|
-
fused = self.hybrid.fuse(payload["retrieval"]["query"], payload["retrieval"]["candidates"])
|
|
548
|
-
reranked = self.reranker.rerank(payload["retrieval"]["query"], fused)
|
|
549
|
-
result["retrieval"] = {"fused": fused, "reranked": reranked}
|
|
550
|
-
|
|
551
|
-
# memory
|
|
552
|
-
if "memories" in payload:
|
|
553
|
-
cands = self.memory_mgr.extract_candidates(payload["memories"])
|
|
554
|
-
cands = self.memory_mgr.score_candidates(cands, payload.get("query", ""))
|
|
555
|
-
cands = self.memory_mgr.dedupe(cands)
|
|
556
|
-
cands = self.memory_mgr.merge(cands)
|
|
557
|
-
cands = self.memory_mgr.detect_conflicts(cands)
|
|
558
|
-
cands = self.memory_mgr.apply_retention(cands)
|
|
559
|
-
result["memory_candidates"] = [c.__dict__ for c in cands]
|
|
560
|
-
|
|
561
|
-
# graph
|
|
562
|
-
if "graph_edges" in payload:
|
|
563
|
-
eqs = [self.graph_mgr.validate_edge(e) for e in payload["graph_edges"]]
|
|
564
|
-
result["graph_quality"] = [q.__dict__ for q in eqs]
|
|
565
|
-
result["graph_metrics"] = self.graph_mgr.compute_quality_metrics(payload["graph_edges"])
|
|
566
|
-
|
|
567
|
-
# context
|
|
568
|
-
if "context_items" in payload:
|
|
569
|
-
ctx = self.context_assembler.assemble(payload["context_items"])
|
|
570
|
-
ctx = self.context_assembler.apply_guardrails(ctx)
|
|
571
|
-
result["structured_context"] = ctx
|
|
572
|
-
|
|
573
|
-
return result
|
|
574
|
-
|
|
575
526
|
|
|
576
527
|
__all__ = [
|
|
577
528
|
"BM25Scorer",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
진입점: app_factory.py:AGENT_RUNTIME (wiring root), api/agents.py, RunExecutor.
|
|
9
9
|
|
|
10
10
|
Before this module the agent concern was spread across three places: the
|
|
11
|
-
:class:`~
|
|
11
|
+
:class:`~lattice_brain.runtime.multi_agent.MultiAgentOrchestrator` (role pipeline),
|
|
12
12
|
the :class:`~latticeai.services.platform_runtime.PlatformRuntime` (cross-system
|
|
13
13
|
wiring + an ad-hoc ``run_agent``), and ``api/agents.py`` (HTTP transport that
|
|
14
14
|
also owned orchestration + persistence + audit). The frontend reached past all
|
|
@@ -181,16 +181,6 @@ class HookResult:
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
|
|
184
|
-
def hook_context(kind: str, event: str = "", **kwargs: Any) -> HookContext:
|
|
185
|
-
"""Factory for a :class:`HookContext` (matches the public hook vocabulary)."""
|
|
186
|
-
return HookContext(kind, event, **kwargs)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def hook_result(**kwargs: Any) -> HookResult:
|
|
190
|
-
"""Factory for a :class:`HookResult`."""
|
|
191
|
-
return HookResult(**kwargs)
|
|
192
|
-
|
|
193
|
-
|
|
194
184
|
def dispatch_tool(
|
|
195
185
|
hooks: Any,
|
|
196
186
|
tool_name: str,
|
|
@@ -525,9 +515,6 @@ class HooksRegistry:
|
|
|
525
515
|
# Alias — descriptive name used by the wiring layer.
|
|
526
516
|
register_runtime_hook = register_hook
|
|
527
517
|
|
|
528
|
-
def unregister_hook(self, hook_id: str) -> None:
|
|
529
|
-
self._runtime_runners.pop(hook_id, None)
|
|
530
|
-
|
|
531
518
|
def has_runner(self, hook_id: str) -> bool:
|
|
532
519
|
return hook_id in self._runtime_runners
|
|
533
520
|
|
|
@@ -49,7 +49,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
49
49
|
from ..utils import now_iso as _now
|
|
50
50
|
from .contracts import multi_agent_contract
|
|
51
51
|
|
|
52
|
-
MULTI_AGENT_VERSION = "11.5.
|
|
52
|
+
MULTI_AGENT_VERSION = "11.5.2"
|
|
53
53
|
|
|
54
54
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
55
55
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -212,10 +212,6 @@ class RecipientIdentity:
|
|
|
212
212
|
"path": str(self._key_file),
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
def seal_to_self(self, plaintext: bytes) -> Dict[str, Any]:
|
|
216
|
-
"""Seal to this Brain's own key — the honest way to test a round trip."""
|
|
217
|
-
return seal(plaintext, recipient_public_key=self.public_key_b64)
|
|
218
|
-
|
|
219
215
|
def unseal(self, block: Dict[str, Any]) -> bytes:
|
|
220
216
|
return unseal(block, self._private)
|
|
221
217
|
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/admin.py
CHANGED
|
@@ -8,6 +8,8 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
8
8
|
from fastapi import APIRouter, Query, Request
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
|
+
from latticeai.api.workspace_scope import requested_workspace
|
|
12
|
+
from latticeai.core.http_origin import request_external_origin
|
|
11
13
|
from latticeai.core.messages import http_error, resolve_language
|
|
12
14
|
from latticeai.core.workspace_os import DEFAULT_WORKSPACE_ID
|
|
13
15
|
|
|
@@ -65,13 +67,6 @@ def create_admin_router(
|
|
|
65
67
|
) -> APIRouter:
|
|
66
68
|
router = APIRouter()
|
|
67
69
|
|
|
68
|
-
def _workspace_scope(request: Request) -> Optional[str]:
|
|
69
|
-
header = request.headers.get("X-Workspace-Id")
|
|
70
|
-
if header and header.strip():
|
|
71
|
-
return header.strip()
|
|
72
|
-
query = request.query_params.get("workspace_id")
|
|
73
|
-
return query.strip() if query and query.strip() else None
|
|
74
|
-
|
|
75
70
|
def _matches_scope(item: Dict[str, object], workspace_id: Optional[str]) -> bool:
|
|
76
71
|
if not workspace_id:
|
|
77
72
|
return True
|
|
@@ -81,11 +76,11 @@ def create_admin_router(
|
|
|
81
76
|
return str(item_scope or "") == str(workspace_id)
|
|
82
77
|
|
|
83
78
|
def _scoped_history(request: Request) -> List[Dict]:
|
|
84
|
-
scope =
|
|
79
|
+
scope = requested_workspace(request)
|
|
85
80
|
return [item for item in get_history() if _matches_scope(item, scope)]
|
|
86
81
|
|
|
87
82
|
def _scoped_audit_log(request: Request) -> List[Dict]:
|
|
88
|
-
scope =
|
|
83
|
+
scope = requested_workspace(request)
|
|
89
84
|
return [item for item in get_audit_log() if _matches_scope(item, scope)]
|
|
90
85
|
|
|
91
86
|
def _filter_audit_log(
|
|
@@ -427,9 +422,14 @@ def create_admin_router(
|
|
|
427
422
|
@router.get("/admin/invite-link")
|
|
428
423
|
async def admin_invite_link(request: Request):
|
|
429
424
|
require_admin(request)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
425
|
+
# The link is *mailed to a person*, so it has to name the door they can
|
|
426
|
+
# actually knock on. Behind the desktop gateway ``Host`` is the internal
|
|
427
|
+
# worker authority, which meant every invite emitted through the front
|
|
428
|
+
# door pointed at a port only the gateway can reach.
|
|
429
|
+
origin = request_external_origin(
|
|
430
|
+
request, fallback=f"http://localhost:{default_port}",
|
|
431
|
+
)
|
|
432
|
+
url = f"{origin}/?code={invite_code}" if invite_gate_enabled else f"{origin}/"
|
|
433
433
|
return {"invite_url": url, "invite_code": invite_code, "gate_enabled": invite_gate_enabled}
|
|
434
434
|
|
|
435
435
|
@router.get("/admin/sso")
|
package/latticeai/api/auth.py
CHANGED
|
@@ -13,6 +13,8 @@ from fastapi import APIRouter, Request
|
|
|
13
13
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
14
14
|
from pydantic import BaseModel
|
|
15
15
|
|
|
16
|
+
from latticeai.core.config import SSO_CALLBACK_PATH
|
|
17
|
+
from latticeai.core.http_origin import request_external_origin
|
|
16
18
|
from latticeai.core.messages import DEFAULT_LANGUAGE, http_error, resolve_language
|
|
17
19
|
from latticeai.core.oidc import (
|
|
18
20
|
OIDCValidationError,
|
|
@@ -56,6 +58,10 @@ class _SSOLoginState:
|
|
|
56
58
|
nonce: str
|
|
57
59
|
code_verifier: str
|
|
58
60
|
invite_authorized: bool
|
|
61
|
+
#: Exactly what was sent to ``authorize``. The token exchange must repeat
|
|
62
|
+
#: it byte for byte or the provider rejects the code, so it is carried
|
|
63
|
+
#: rather than recomputed from a second request that may differ.
|
|
64
|
+
redirect_uri: str = ""
|
|
59
65
|
|
|
60
66
|
|
|
61
67
|
# The nonce and PKCE verifier bind the callback/token to this login attempt.
|
|
@@ -88,6 +94,7 @@ def create_auth_router(
|
|
|
88
94
|
ensure_identity: Optional[Callable[[str, Dict], None]] = None,
|
|
89
95
|
invite_gate_enabled: bool = False,
|
|
90
96
|
invite_authorized: Optional[Callable[[Request], bool]] = None,
|
|
97
|
+
default_redirect_uri: Optional[str] = None,
|
|
91
98
|
verify_id_token: Callable[..., Dict] = _default_verify_id_token,
|
|
92
99
|
fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
|
|
93
100
|
) -> APIRouter:
|
|
@@ -169,6 +176,20 @@ def create_auth_router(
|
|
|
169
176
|
async def sso_config_endpoint():
|
|
170
177
|
return public_sso_config()
|
|
171
178
|
|
|
179
|
+
def _resolve_redirect_uri(request: Request, settings: Dict) -> str:
|
|
180
|
+
"""Where the provider should send the browser back to.
|
|
181
|
+
|
|
182
|
+
A configured URI is registered with the provider and is used verbatim.
|
|
183
|
+
Only the *built-in default* — which names the worker's own port, and is
|
|
184
|
+
therefore unreachable whenever a gateway fronts it — is replaced by the
|
|
185
|
+
front door this login actually arrived through.
|
|
186
|
+
"""
|
|
187
|
+
configured = str(settings.get("redirect_uri") or "")
|
|
188
|
+
if not default_redirect_uri or configured != default_redirect_uri:
|
|
189
|
+
return configured
|
|
190
|
+
origin = request_external_origin(request)
|
|
191
|
+
return f"{origin}{SSO_CALLBACK_PATH}" if origin else configured
|
|
192
|
+
|
|
172
193
|
@router.get("/auth/sso/login")
|
|
173
194
|
async def sso_login(request: Request):
|
|
174
195
|
lang = resolve_language(request)
|
|
@@ -193,16 +214,18 @@ def create_auth_router(
|
|
|
193
214
|
and invite_authorized(request)
|
|
194
215
|
)
|
|
195
216
|
)
|
|
217
|
+
redirect_uri = _resolve_redirect_uri(request, settings)
|
|
196
218
|
_sso_states[state] = _SSOLoginState(
|
|
197
219
|
issued_at=time.time(),
|
|
198
220
|
nonce=nonce,
|
|
199
221
|
code_verifier=code_verifier,
|
|
200
222
|
invite_authorized=invite_claim,
|
|
223
|
+
redirect_uri=redirect_uri,
|
|
201
224
|
)
|
|
202
225
|
params = urlencode({
|
|
203
226
|
"client_id": settings["client_id"],
|
|
204
227
|
"response_type": "code",
|
|
205
|
-
"redirect_uri":
|
|
228
|
+
"redirect_uri": redirect_uri,
|
|
206
229
|
"scope": settings.get("scopes") or "openid email profile",
|
|
207
230
|
"state": state,
|
|
208
231
|
"nonce": nonce,
|
|
@@ -228,7 +251,7 @@ def create_auth_router(
|
|
|
228
251
|
r = await c.post(discovery["token_endpoint"], data={
|
|
229
252
|
"grant_type": "authorization_code",
|
|
230
253
|
"code": code,
|
|
231
|
-
"redirect_uri": settings["redirect_uri"],
|
|
254
|
+
"redirect_uri": entry.redirect_uri or settings["redirect_uri"],
|
|
232
255
|
"client_id": settings["client_id"],
|
|
233
256
|
"client_secret": settings["client_secret"],
|
|
234
257
|
"code_verifier": entry.code_verifier,
|
package/latticeai/api/chat.py
CHANGED
|
@@ -42,7 +42,6 @@ from latticeai.api.chat_helpers import (
|
|
|
42
42
|
pair_user_history,
|
|
43
43
|
single_text_stream,
|
|
44
44
|
strip_generated_file_content,
|
|
45
|
-
workspace_scope_from_request,
|
|
46
45
|
)
|
|
47
46
|
from latticeai.api.chat_history import HistoryRouteDependencies, register_history_routes
|
|
48
47
|
from latticeai.api.chat_hybrid import (
|
|
@@ -51,6 +50,7 @@ from latticeai.api.chat_hybrid import (
|
|
|
51
50
|
)
|
|
52
51
|
from latticeai.api.chat_intents import ChatIntentController
|
|
53
52
|
from latticeai.api.chat_stream import stream_chat
|
|
53
|
+
from latticeai.api.workspace_scope import requested_workspace
|
|
54
54
|
from latticeai.core.messages import DEFAULT_LANGUAGE, resolve_language, translate
|
|
55
55
|
from latticeai.core.project_sessions import ProjectSessionStore
|
|
56
56
|
from latticeai.core.run_store import AgentRunStore
|
|
@@ -84,7 +84,7 @@ __all__ = [
|
|
|
84
84
|
"is_clear_command",
|
|
85
85
|
"format_network_status",
|
|
86
86
|
"strip_generated_file_content",
|
|
87
|
-
"
|
|
87
|
+
"requested_workspace",
|
|
88
88
|
"single_text_stream",
|
|
89
89
|
]
|
|
90
90
|
|
|
@@ -272,7 +272,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
272
272
|
)
|
|
273
273
|
effective_email = authenticated_identity(current_user, req.user_email, resolve_language(request))
|
|
274
274
|
workspace_id = write_workspace(
|
|
275
|
-
|
|
275
|
+
requested_workspace(request),
|
|
276
276
|
current_user,
|
|
277
277
|
)
|
|
278
278
|
history_user = chat_service.history_user(
|
|
@@ -22,12 +22,9 @@ from latticeai.api.chat_contracts import (
|
|
|
22
22
|
AgentRequest,
|
|
23
23
|
AgentResumeRequest,
|
|
24
24
|
)
|
|
25
|
-
from latticeai.api.chat_helpers import
|
|
26
|
-
_LANG_HINT,
|
|
27
|
-
detect_language,
|
|
28
|
-
workspace_scope_from_request,
|
|
29
|
-
)
|
|
25
|
+
from latticeai.api.chat_helpers import _LANG_HINT, detect_language
|
|
30
26
|
from latticeai.api.chat_stream import agent_live_stream
|
|
27
|
+
from latticeai.api.workspace_scope import requested_workspace
|
|
31
28
|
from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
|
|
32
29
|
from latticeai.core.messages import resolve_language
|
|
33
30
|
from latticeai.core.quiet import quiet
|
|
@@ -268,14 +265,8 @@ class AgentHTTPController:
|
|
|
268
265
|
current_user = self.require_user(request)
|
|
269
266
|
self.enforce_rate_limit(current_user, "agent")
|
|
270
267
|
effective_email = self.authenticated_identity(current_user, req.user_email, resolve_language(request))
|
|
271
|
-
header_workspace = workspace_scope_from_request(request)
|
|
272
|
-
if req.workspace_id and header_workspace and req.workspace_id != header_workspace:
|
|
273
|
-
raise HTTPException(
|
|
274
|
-
status_code=403,
|
|
275
|
-
detail="workspace_id must match X-Workspace-Id.",
|
|
276
|
-
)
|
|
277
268
|
req.workspace_id = self.write_workspace(
|
|
278
|
-
req.workspace_id
|
|
269
|
+
requested_workspace(request, body_workspace=req.workspace_id),
|
|
279
270
|
current_user,
|
|
280
271
|
)
|
|
281
272
|
req.user_email = effective_email
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
"""Pure chat helpers: language/intent detection, file-action parsing,
|
|
2
|
-
network-status formatting,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
network-status formatting, and recent-context assembly. Split out of the chat
|
|
3
|
+
router module so create_chat_router keeps only request wiring. chat.py
|
|
4
|
+
re-imports every name, so external import sites (tests, app_factory) are
|
|
5
|
+
unaffected.
|
|
6
|
+
|
|
7
|
+
Workspace-scope extraction used to live here too, as a verbatim copy of the
|
|
8
|
+
header/query derivation. 11.5.2 deleted it: the callers now use
|
|
9
|
+
:func:`latticeai.api.workspace_scope.requested_workspace`, so a request that
|
|
10
|
+
names two different workspaces is refused instead of having one selector
|
|
11
|
+
silently win.
|
|
6
12
|
"""
|
|
7
13
|
|
|
8
14
|
from __future__ import annotations
|
|
@@ -11,8 +17,6 @@ import json
|
|
|
11
17
|
import re
|
|
12
18
|
from typing import Any, AsyncIterator, Dict, List, Optional
|
|
13
19
|
|
|
14
|
-
from fastapi import Request
|
|
15
|
-
|
|
16
20
|
|
|
17
21
|
def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
|
|
18
22
|
"""Restrict history to one user's exchange.
|
|
@@ -406,13 +410,6 @@ def assess_answer_grounding(
|
|
|
406
410
|
}
|
|
407
411
|
|
|
408
412
|
|
|
409
|
-
def workspace_scope_from_request(request: Request) -> Optional[str]:
|
|
410
|
-
header = request.headers.get("X-Workspace-Id")
|
|
411
|
-
if header and header.strip():
|
|
412
|
-
return header.strip()
|
|
413
|
-
query = request.query_params.get("workspace_id")
|
|
414
|
-
return query.strip() if query and query.strip() else None
|
|
415
|
-
|
|
416
413
|
async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
|
|
417
414
|
yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
|
|
418
415
|
yield "data: [DONE]\n\n"
|
|
@@ -11,7 +11,9 @@ from fastapi.responses import StreamingResponse
|
|
|
11
11
|
from pydantic import BaseModel
|
|
12
12
|
|
|
13
13
|
from lattice_brain.runtime.hooks import dispatch_tool
|
|
14
|
+
from latticeai.api.workspace_scope import resolve_workspace_scope
|
|
14
15
|
from latticeai.core.agent import extract_action as _extract_agent_action
|
|
16
|
+
from latticeai.core.sse import sse_frame
|
|
15
17
|
from latticeai.services.tool_dispatch import enforce_tool_policy
|
|
16
18
|
from latticeai.tools import (
|
|
17
19
|
AGENT_ROOT,
|
|
@@ -121,34 +123,24 @@ def create_computer_use_router(
|
|
|
121
123
|
) -> APIRouter:
|
|
122
124
|
router = APIRouter()
|
|
123
125
|
|
|
124
|
-
def _requested_workspace(request: Request) -> Optional[str]:
|
|
125
|
-
header = request.headers.get("X-Workspace-Id")
|
|
126
|
-
if header and header.strip():
|
|
127
|
-
return header.strip()
|
|
128
|
-
query = request.query_params.get("workspace_id")
|
|
129
|
-
return query.strip() if query and query.strip() else None
|
|
130
|
-
|
|
131
126
|
def _write_workspace(request: Request, current_user: str) -> Optional[str]:
|
|
132
127
|
"""Resolve only the CU agent's durable write scope.
|
|
133
128
|
|
|
134
129
|
The other ``/cu`` actions remain host operations and do not acquire a
|
|
135
130
|
workspace dependency. A no-auth local request without an explicit
|
|
136
|
-
scope preserves its legacy unscoped history behavior
|
|
137
|
-
|
|
131
|
+
scope preserves its legacy unscoped history behavior — that is exactly
|
|
132
|
+
what ``allow_unscoped_anonymous`` names in the shared resolver;
|
|
133
|
+
explicit scopes and every authenticated request go through the normal
|
|
134
|
+
write gate.
|
|
138
135
|
"""
|
|
139
136
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
requested,
|
|
148
|
-
current_user or None,
|
|
149
|
-
)
|
|
150
|
-
except PermissionError as exc:
|
|
151
|
-
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
137
|
+
return resolve_workspace_scope(
|
|
138
|
+
request,
|
|
139
|
+
user=current_user,
|
|
140
|
+
workspace_service=workspace_service,
|
|
141
|
+
write=True,
|
|
142
|
+
allow_unscoped_anonymous=True,
|
|
143
|
+
)
|
|
152
144
|
|
|
153
145
|
def _audit_args(name: str, args: dict) -> dict:
|
|
154
146
|
"""Return audit-safe argument metadata without storing typed text."""
|
|
@@ -351,17 +343,14 @@ def create_computer_use_router(
|
|
|
351
343
|
task_lower = (req.task or "").lower()
|
|
352
344
|
url_match = re.search(r"(https?://[^\s]+|localhost:\d+[^\s]*|127\.0\.0\.1:\d+[^\s]*)", req.task or "")
|
|
353
345
|
|
|
354
|
-
def _send(event: str, data: dict) -> str:
|
|
355
|
-
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
356
|
-
|
|
357
346
|
if ("chrome" in task_lower or "크롬" in task_lower) and any(
|
|
358
347
|
word in task_lower for word in ["open", "열", "켜", "실행", "띄"]
|
|
359
348
|
):
|
|
360
|
-
yield
|
|
349
|
+
yield sse_frame("start", {"task": req.task, "max_steps": 1})
|
|
361
350
|
try:
|
|
362
351
|
if url_match:
|
|
363
352
|
url = url_match.group(1)
|
|
364
|
-
yield
|
|
353
|
+
yield sse_frame(
|
|
365
354
|
"action",
|
|
366
355
|
{"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}},
|
|
367
356
|
)
|
|
@@ -371,11 +360,11 @@ def create_computer_use_router(
|
|
|
371
360
|
lambda: computer_open_url(url, "Google Chrome"),
|
|
372
361
|
current_user=current_user,
|
|
373
362
|
)
|
|
374
|
-
yield
|
|
363
|
+
yield sse_frame("result", {"step": 1, "action": "computer_open_url", "result": result})
|
|
375
364
|
message = f"Google Chrome에서 {url}을 열었습니다."
|
|
376
365
|
action_name = "computer_open_url"
|
|
377
366
|
else:
|
|
378
|
-
yield
|
|
367
|
+
yield sse_frame(
|
|
379
368
|
"action",
|
|
380
369
|
{"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}},
|
|
381
370
|
)
|
|
@@ -385,26 +374,26 @@ def create_computer_use_router(
|
|
|
385
374
|
lambda: computer_open_app("Google Chrome"),
|
|
386
375
|
current_user=current_user,
|
|
387
376
|
)
|
|
388
|
-
yield
|
|
377
|
+
yield sse_frame("result", {"step": 1, "action": "computer_open_app", "result": result})
|
|
389
378
|
message = "Google Chrome을 열었습니다."
|
|
390
379
|
action_name = "computer_open_app"
|
|
391
380
|
_save_completed("user", req.task)
|
|
392
381
|
_save_completed("assistant", message)
|
|
393
|
-
yield
|
|
382
|
+
yield sse_frame("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
|
|
394
383
|
except HTTPException as exc:
|
|
395
|
-
yield
|
|
384
|
+
yield sse_frame("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc.detail)})
|
|
396
385
|
except (ToolError, PermissionError) as exc:
|
|
397
|
-
yield
|
|
386
|
+
yield sse_frame("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
|
|
398
387
|
return
|
|
399
388
|
|
|
400
389
|
if not model_router.current_model_id:
|
|
401
|
-
yield
|
|
390
|
+
yield sse_frame("error", {"error": "No model loaded."})
|
|
402
391
|
return
|
|
403
392
|
|
|
404
393
|
transcript: List[Dict[str, Any]] = []
|
|
405
394
|
last_screenshot_b64: Optional[str] = None
|
|
406
395
|
max_steps = max(1, min(req.max_steps, 20))
|
|
407
|
-
yield
|
|
396
|
+
yield sse_frame("start", {"task": req.task, "max_steps": max_steps})
|
|
408
397
|
|
|
409
398
|
for step in range(max_steps):
|
|
410
399
|
context = (
|
|
@@ -423,7 +412,7 @@ def create_computer_use_router(
|
|
|
423
412
|
try:
|
|
424
413
|
action = _extract_agent_action(str(raw))
|
|
425
414
|
except ValueError as exc:
|
|
426
|
-
yield
|
|
415
|
+
yield sse_frame("error", {"step": step + 1, "error": str(exc), "raw": str(raw)})
|
|
427
416
|
break
|
|
428
417
|
|
|
429
418
|
name = action.get("action")
|
|
@@ -432,10 +421,10 @@ def create_computer_use_router(
|
|
|
432
421
|
message = action.get("message", "작업을 완료했습니다.")
|
|
433
422
|
_save_completed("user", req.task)
|
|
434
423
|
_save_completed("assistant", message)
|
|
435
|
-
yield
|
|
424
|
+
yield sse_frame("final", {"message": message, "steps": transcript})
|
|
436
425
|
return
|
|
437
426
|
|
|
438
|
-
yield
|
|
427
|
+
yield sse_frame("action", {"step": step + 1, "action": name, "args": args})
|
|
439
428
|
try:
|
|
440
429
|
result = _dispatch(
|
|
441
430
|
name,
|
|
@@ -449,7 +438,7 @@ def create_computer_use_router(
|
|
|
449
438
|
result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
|
|
450
439
|
result_summary["screenshot_captured"] = True
|
|
451
440
|
transcript.append({"step": step + 1, "action": name, "args": args, "result": result_summary})
|
|
452
|
-
yield
|
|
441
|
+
yield sse_frame(
|
|
453
442
|
"screenshot",
|
|
454
443
|
{
|
|
455
444
|
"step": step + 1,
|
|
@@ -461,17 +450,17 @@ def create_computer_use_router(
|
|
|
461
450
|
else:
|
|
462
451
|
last_screenshot_b64 = None
|
|
463
452
|
transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
|
|
464
|
-
yield
|
|
453
|
+
yield sse_frame("result", {"step": step + 1, "action": name, "result": result})
|
|
465
454
|
except HTTPException as exc:
|
|
466
455
|
error_str = str(exc.detail)
|
|
467
456
|
transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
|
|
468
|
-
yield
|
|
457
|
+
yield sse_frame("tool_error", {"step": step + 1, "action": name, "error": error_str})
|
|
469
458
|
except (ToolError, PermissionError, KeyError, TypeError) as exc:
|
|
470
459
|
error_str = str(exc)
|
|
471
460
|
transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
|
|
472
|
-
yield
|
|
461
|
+
yield sse_frame("tool_error", {"step": step + 1, "action": name, "error": error_str})
|
|
473
462
|
|
|
474
|
-
yield
|
|
463
|
+
yield sse_frame("done", {"steps": len(transcript), "transcript": transcript})
|
|
475
464
|
|
|
476
465
|
return StreamingResponse(
|
|
477
466
|
_stream(),
|
package/latticeai/api/health.py
CHANGED
|
@@ -19,6 +19,7 @@ def create_health_router(
|
|
|
19
19
|
engine_status: Callable[[], List[dict]],
|
|
20
20
|
get_current_user: Callable[[Request], Optional[str]],
|
|
21
21
|
require_auth: bool,
|
|
22
|
+
externally_reachable: bool,
|
|
22
23
|
app_version: str,
|
|
23
24
|
app_mode: str,
|
|
24
25
|
) -> APIRouter:
|
|
@@ -29,9 +30,28 @@ def create_health_router(
|
|
|
29
30
|
if require_auth and not get_current_user(request):
|
|
30
31
|
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
31
32
|
|
|
33
|
+
def _access_posture() -> dict:
|
|
34
|
+
"""The two facts ``trusted_local_owner`` is computed from.
|
|
35
|
+
|
|
36
|
+
Stated on the *unauthenticated* part of ``/health`` on purpose: the Rust
|
|
37
|
+
front door polls this endpoint already, and it gates its own native
|
|
38
|
+
``/rust/*`` lanes on the answer. Those lanes read the store with no
|
|
39
|
+
credential at all, so without this they had no way to tell an
|
|
40
|
+
optional-auth loopback worker (where that is exactly right) from a
|
|
41
|
+
``LATTICEAI_REQUIRE_AUTH=true`` one (where it is a hole). Neither field
|
|
42
|
+
is a secret: an unauthenticated caller discovers both by being refused.
|
|
43
|
+
"""
|
|
44
|
+
return {
|
|
45
|
+
"require_auth": bool(require_auth),
|
|
46
|
+
"externally_reachable": bool(externally_reachable),
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
@router.get("/health")
|
|
33
50
|
async def health(request: Request):
|
|
34
|
-
base =
|
|
51
|
+
base = {
|
|
52
|
+
**svc.health_base(version=app_version, mode=app_mode),
|
|
53
|
+
"access": _access_posture(),
|
|
54
|
+
}
|
|
35
55
|
if not get_current_user(request) and require_auth:
|
|
36
56
|
return base
|
|
37
57
|
engines = await asyncio.to_thread(engine_status)
|