ltcai 1.1.0 β†’ 1.3.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.
@@ -101,6 +101,13 @@ from latticeai.core.enterprise import (
101
101
  capability_registry,
102
102
  detect_edition,
103
103
  )
104
+ from latticeai.services.workspace_service import WorkspaceService
105
+ from latticeai.services.model_service import ModelService
106
+ from latticeai.services.chat_service import ChatService
107
+ from latticeai.api.workspace import create_workspace_router
108
+ from latticeai.api.health import create_health_router
109
+ from latticeai.api.models import create_models_router
110
+ from latticeai.api.mcp import create_mcp_router
104
111
  from latticeai.core.agent import (
105
112
  AgentState,
106
113
  AgentRunContext,
@@ -357,6 +364,9 @@ SSO_FILE = DATA_DIR / "sso_config.json"
357
364
  KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
358
365
  LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH) if ENABLE_GRAPH else None
359
366
  WORKSPACE_OS = WorkspaceOSStore(DATA_DIR)
367
+ # Service layer (latticeai.services) wraps the store with scope/permission
368
+ # guardrails; routers and the app assembly share this single instance.
369
+ WORKSPACE_SERVICE = WorkspaceService(WORKSPACE_OS)
360
370
 
361
371
  def _require_graph():
362
372
  if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
@@ -455,25 +465,7 @@ def save_sso_config(update: Dict[str, object]) -> Dict[str, object]:
455
465
  _sso_discovery_cache_url = ""
456
466
  return current
457
467
 
458
- class McpRecommendRequest(BaseModel):
459
- query: str
460
- limit: int = 5
461
-
462
- class McpInstallRequest(BaseModel):
463
- mcp_id: str
464
-
465
- class McpCustomRequest(BaseModel):
466
- name: str
467
- package: str
468
- description: str = ""
469
- category: str = "custom"
470
- icon: str = "πŸ”Œ"
471
- env_vars: List[Dict] = []
472
-
473
- class SkillInstallRequest(BaseModel):
474
- plugin: str
475
- skill: str
476
-
468
+ # MCP/skill request models moved to latticeai.api.mcp (v1.3.0).
477
469
  DEFAULT_VPC_CONFIG = {
478
470
  "provider": "AWS",
479
471
  "region": "ap-northeast-2",
@@ -727,6 +719,10 @@ def get_history():
727
719
  logging.warning("get_history failed: %s", e)
728
720
  return []
729
721
 
722
+ # Chat service seam: behaviour-preserving faΓ§ade for history access and
723
+ # Workspace-OS answer-trace recording used by the (unchanged) streaming chat path.
724
+ CHAT_SERVICE = ChatService(store=WORKSPACE_OS, get_history=get_history)
725
+
730
726
  def conversation_title(item: Dict) -> str:
731
727
  content = str(item.get("content") or "").strip()
732
728
  content = re.sub(r"\s+", " ", content)
@@ -1317,22 +1313,8 @@ async def admin_page():
1317
1313
  response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
1318
1314
  return response
1319
1315
 
1320
- @app.get("/workspace")
1321
- async def workspace_page(request: Request):
1322
- require_user(request)
1323
- workspace_path = STATIC_DIR / "workspace.html"
1324
- if not workspace_path.exists():
1325
- raise HTTPException(status_code=404, detail="Workspace OS UI not found.")
1326
- return ui_file_response(workspace_path)
1327
-
1328
-
1329
- @app.get("/onboarding")
1330
- async def onboarding_page(request: Request):
1331
- require_user(request)
1332
- workspace_path = STATIC_DIR / "workspace.html"
1333
- if not workspace_path.exists():
1334
- raise HTTPException(status_code=404, detail="Workspace OS UI not found.")
1335
- return ui_file_response(workspace_path)
1316
+ # /workspace and /onboarding UI pages are served by the workspace router
1317
+ # (latticeai.api.workspace), included below after its dependencies are defined.
1336
1318
 
1337
1319
  @app.get("/status")
1338
1320
  async def status():
@@ -1406,133 +1388,9 @@ class ChatRequest(BaseModel):
1406
1388
  image_data: Optional[str] = None # Base64 이미지 데이터 (VLM용)
1407
1389
 
1408
1390
 
1409
- class LoadModelRequest(BaseModel):
1410
- model_id: str # HuggingFace repo id λ˜λŠ” 둜컬 경둜
1411
- adapter_path: Optional[str] = None # LoRA adapter (선택)
1412
- draft_model_id: Optional[str] = None # Speculative decoding draft model (선택)
1413
- engine: Optional[str] = None
1414
- user_email: Optional[str] = None
1415
-
1416
-
1417
- class InstallEngineRequest(BaseModel):
1418
- engine: str
1419
-
1420
-
1421
- class SetApiKeyRequest(BaseModel):
1422
- provider: str
1423
- key: str
1424
- user_email: Optional[str] = None
1425
-
1426
-
1427
- class PullModelRequest(BaseModel):
1428
- model: str
1429
-
1430
- class PrepareModelRequest(BaseModel):
1431
- model: str
1432
- engine: Optional[str] = None
1433
- user_email: Optional[str] = None
1434
-
1435
- class VerifyCloudRequest(BaseModel):
1436
- force: bool = False
1437
- provider: Optional[str] = None
1438
-
1439
-
1440
- class WorkspaceOnboardingStepRequest(BaseModel):
1441
- step: str
1442
- status: str = "complete"
1443
- data: Dict = {}
1444
- error: str = ""
1445
-
1446
-
1447
- class WorkspaceOnboardingCompleteRequest(BaseModel):
1448
- data: Dict = {}
1449
-
1450
-
1451
- class WorkspaceSnapshotRequest(BaseModel):
1452
- name: str = "Workspace snapshot"
1453
-
1454
-
1455
- class WorkspaceSnapshotCompareRequest(BaseModel):
1456
- before_id: str
1457
- after_id: str
1458
-
1459
-
1460
- class WorkspaceMemoryRequest(BaseModel):
1461
- kind: str
1462
- content: str
1463
- tags: List[str] = []
1464
- memory_id: Optional[str] = None
1465
- metadata: Dict = {}
1466
-
1467
-
1468
- class WorkspaceAgentRunRequest(BaseModel):
1469
- agent_id: str = "agent:executor"
1470
- status: str = "ok"
1471
- input: str = ""
1472
- output: str = ""
1473
- timeline: List[Dict] = []
1474
- relationships: List[str] = []
1475
-
1476
-
1477
- class WorkspaceWorkflowRequest(BaseModel):
1478
- name: str
1479
- steps: List[Dict] = []
1480
- metadata: Dict = {}
1481
-
1482
-
1483
- class WorkspaceWorkflowEventRequest(BaseModel):
1484
- event_type: str
1485
- payload: Dict = {}
1486
-
1487
-
1488
- class WorkspaceComputerMemoryRequest(BaseModel):
1489
- enabled: bool = False
1490
- consent: Dict = {}
1491
- scopes: List[str] = []
1492
-
1493
-
1494
- class WorkspaceComputerActivityRequest(BaseModel):
1495
- activity: Dict = {}
1496
-
1497
-
1498
- class WorkspaceSkillActionRequest(BaseModel):
1499
- skill: str
1500
- plugin: Optional[str] = None
1501
- enabled: Optional[bool] = None
1502
- version: Optional[str] = None
1503
- metadata: Dict = {}
1504
-
1505
-
1506
- class WorkspaceVSCodeRequest(BaseModel):
1507
- action: str
1508
- file_path: Optional[str] = None
1509
- language: Optional[str] = None
1510
- content: str = ""
1511
- selection: str = ""
1512
- prompt: str = ""
1513
-
1514
-
1515
- class WorkspaceCreateRequest(BaseModel):
1516
- name: str
1517
- settings: Dict = {}
1391
+ # Model/engine request models moved to latticeai.api.models (v1.3.0).
1518
1392
 
1519
-
1520
- class WorkspaceUpdateRequest(BaseModel):
1521
- name: Optional[str] = None
1522
- settings: Optional[Dict] = None
1523
-
1524
-
1525
- class WorkspaceMemberRequest(BaseModel):
1526
- user_id: str
1527
- role: str = "member"
1528
-
1529
-
1530
- class WorkspaceMemberRoleRequest(BaseModel):
1531
- role: str
1532
-
1533
-
1534
- class WorkspaceActivateRequest(BaseModel):
1535
- workspace_id: str
1393
+ # Workspace request models moved to latticeai.api.workspace (v1.2.0 modularization).
1536
1394
 
1537
1395
 
1538
1396
  class GardenRequest(BaseModel):
@@ -1687,10 +1545,6 @@ class LocalWriteRequest(BaseModel):
1687
1545
  approval_token: Optional[str] = None
1688
1546
 
1689
1547
 
1690
- class McpCallRequest(BaseModel):
1691
- action: str
1692
- args: Dict = {}
1693
-
1694
1548
 
1695
1549
  class ToolGitDiffRequest(BaseModel):
1696
1550
  path: Optional[str] = None
@@ -1736,562 +1590,37 @@ def _workspace_graph():
1736
1590
  return KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None
1737
1591
 
1738
1592
 
1739
- def _workspace_scope(request: Request) -> Optional[str]:
1740
- """Resolve the active workspace for scoping from an optional header/query.
1741
-
1742
- Returns ``None`` when unset so the store falls back to the active workspace
1743
- (Personal by default), preserving pre-1.1 behaviour for legacy clients.
1744
- """
1745
- header = request.headers.get("X-Workspace-Id")
1746
- if header and header.strip():
1747
- return header.strip()
1748
- query = request.query_params.get("workspace_id")
1749
- return query.strip() if query and query.strip() else None
1750
-
1751
-
1752
- @app.get("/workspace/os")
1753
- async def workspace_os_summary(request: Request):
1754
- user = require_user(request)
1755
- summary = WORKSPACE_OS.summary()
1756
- summary["graph"] = _graph_stats_safe()
1757
- summary["models"] = _workspace_models_payload()
1758
- summary["workspace_registry"] = WORKSPACE_OS.list_workspaces(user_id=user or None)
1759
- summary["edition"] = capability_registry.describe()
1760
- return summary
1761
-
1762
-
1763
- @app.get("/workspace/onboarding/status")
1764
- async def workspace_onboarding_status(request: Request):
1765
- require_user(request)
1766
- return WORKSPACE_OS.onboarding_status(load_users(), _graph_stats_safe())
1767
-
1768
-
1769
- @app.post("/workspace/onboarding/step")
1770
- async def workspace_onboarding_step(req: WorkspaceOnboardingStepRequest, request: Request):
1771
- current_user = require_user(request)
1772
- return WORKSPACE_OS.update_onboarding_step(
1773
- req.step,
1774
- status=req.status,
1775
- data=req.data,
1776
- error=req.error,
1777
- user_email=current_user or None,
1778
- )
1779
-
1780
-
1781
- @app.post("/workspace/onboarding/complete")
1782
- async def workspace_onboarding_complete(req: WorkspaceOnboardingCompleteRequest, request: Request):
1783
- current_user = require_user(request)
1784
- append_audit_event("onboarding_complete", user_email=current_user, platform="AI Workspace OS")
1785
- return WORKSPACE_OS.complete_onboarding(req.data, user_email=current_user or None)
1786
-
1787
-
1788
- @app.get("/workspace/onboarding/hardware")
1789
- async def workspace_onboarding_hardware(request: Request):
1790
- require_user(request)
1791
- env = await asyncio.to_thread(scan_environment)
1792
- sysinfo = await local_sysinfo(request)
1793
- payload = {"environment": env, "sysinfo": sysinfo, "scanned_at": datetime.now().isoformat()}
1794
- WORKSPACE_OS.update_onboarding_step("hardware", status="complete", data=payload, user_email=get_current_user(request))
1795
- return payload
1796
-
1797
-
1798
- @app.get("/workspace/onboarding/model-recommendations")
1799
- async def workspace_onboarding_model_recommendations(request: Request):
1800
- require_user(request)
1801
- env = await asyncio.to_thread(scan_environment)
1802
- recommendations = get_recommendations(env)
1803
- payload = {
1804
- "environment": env,
1805
- "recommendations": recommendations,
1806
- "default_local_model": LOCAL_MODEL,
1807
- "default_public_model": PUBLIC_MODEL,
1808
- }
1809
- WORKSPACE_OS.update_onboarding_step("model_recommendation", status="complete", data=payload, user_email=get_current_user(request))
1810
- return payload
1811
-
1812
-
1813
- @app.get("/workspace/traces")
1814
- async def workspace_traces(request: Request, conversation_id: Optional[str] = None, limit: int = 50):
1815
- require_user(request)
1816
- return WORKSPACE_OS.list_traces(conversation_id=conversation_id, limit=limit, workspace_id=_workspace_scope(request))
1817
-
1818
-
1819
- @app.get("/workspace/indexing")
1820
- async def workspace_indexing_dashboard(request: Request):
1821
- require_user(request)
1822
- graph = _workspace_graph()
1823
- watcher_status = LOCAL_KG_WATCHER.status() if LOCAL_KG_WATCHER else {"available": False, "active": {}}
1824
- return WORKSPACE_OS.build_indexing_dashboard(graph, watcher_status)
1825
-
1826
-
1827
- @app.post("/workspace/indexing/{source_id}/pause")
1828
- async def workspace_indexing_pause(source_id: str, request: Request):
1829
- require_user(request)
1830
- _require_graph()
1831
- return WORKSPACE_OS.pause_indexing(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
1832
-
1833
-
1834
- @app.post("/workspace/indexing/{source_id}/resume")
1835
- async def workspace_indexing_resume(source_id: str, request: Request):
1836
- require_user(request)
1837
- _require_graph()
1838
- return WORKSPACE_OS.resume_indexing(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
1839
-
1840
-
1841
- @app.post("/workspace/indexing/{source_id}/remove")
1842
- async def workspace_indexing_remove(source_id: str, request: Request):
1843
- require_user(request)
1844
- _require_graph()
1845
- return WORKSPACE_OS.remove_index_source(KNOWLEDGE_GRAPH, source_id, LOCAL_KG_WATCHER)
1846
-
1847
-
1848
- @app.get("/workspace/snapshots")
1849
- async def workspace_snapshots(request: Request):
1850
- require_user(request)
1851
- return WORKSPACE_OS.list_snapshots(workspace_id=_workspace_scope(request))
1852
-
1853
-
1854
- @app.post("/workspace/snapshots")
1855
- async def workspace_snapshot_create(req: WorkspaceSnapshotRequest, request: Request):
1856
- current_user = require_user(request)
1857
- result = WORKSPACE_OS.create_snapshot(
1858
- name=req.name,
1859
- graph=_workspace_graph(),
1860
- history=get_history(),
1861
- settings=_workspace_settings_payload(),
1862
- models=_workspace_models_payload(),
1863
- workspace_id=_workspace_scope(request),
1864
- )
1865
- append_audit_event("workspace_snapshot", user_email=current_user, snapshot_id=result["snapshot"]["id"])
1866
- return result
1867
-
1868
-
1869
- @app.post("/workspace/snapshots/compare")
1870
- async def workspace_snapshot_compare(req: WorkspaceSnapshotCompareRequest, request: Request):
1871
- require_user(request)
1872
- try:
1873
- return WORKSPACE_OS.compare_snapshots(req.before_id, req.after_id)
1874
- except FileNotFoundError as exc:
1875
- raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1876
-
1877
-
1878
- @app.get("/workspace/snapshots/{snapshot_id}")
1879
- async def workspace_snapshot_get(snapshot_id: str, request: Request):
1880
- require_user(request)
1881
- try:
1882
- return WORKSPACE_OS.get_snapshot(snapshot_id)
1883
- except FileNotFoundError as exc:
1884
- raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1885
-
1886
-
1887
- @app.get("/workspace/snapshots/{snapshot_id}/{area}")
1888
- async def workspace_snapshot_area(snapshot_id: str, area: str, request: Request):
1889
- require_user(request)
1890
- try:
1891
- return WORKSPACE_OS.snapshot_view(snapshot_id, area)
1892
- except FileNotFoundError as exc:
1893
- raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1894
-
1895
-
1896
- @app.post("/workspace/snapshots/{snapshot_id}/export")
1897
- async def workspace_snapshot_export(snapshot_id: str, request: Request):
1898
- current_user = require_user(request)
1899
- try:
1900
- result = WORKSPACE_OS.export_snapshot(snapshot_id)
1901
- except FileNotFoundError as exc:
1902
- raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1903
- append_audit_event("workspace_snapshot_export", user_email=current_user, snapshot_id=snapshot_id, path=result.get("export_path"))
1904
- return result
1905
-
1906
-
1907
- @app.get("/workspace/time-machine")
1908
- async def workspace_time_machine(request: Request, limit: int = 100):
1909
- require_user(request)
1910
- return WORKSPACE_OS.timeline(get_audit_log(), limit=limit, workspace_id=_workspace_scope(request))
1911
-
1912
-
1913
- @app.get("/workspace/time-machine/{snapshot_id}/{area}")
1914
- async def workspace_time_machine_view(snapshot_id: str, area: str, request: Request):
1915
- require_user(request)
1916
- try:
1917
- return WORKSPACE_OS.snapshot_view(snapshot_id, area)
1918
- except FileNotFoundError as exc:
1919
- raise HTTPException(status_code=404, detail=f"Snapshot not found: {exc}") from exc
1920
-
1921
-
1922
- @app.get("/workspace/memories")
1923
- async def workspace_memories(request: Request, kind: Optional[str] = None):
1924
- current_user = require_user(request)
1925
- return WORKSPACE_OS.list_memories(user_email=current_user or None, kind=kind, workspace_id=_workspace_scope(request))
1926
-
1927
-
1928
- @app.get("/workspace/memories/search")
1929
- async def workspace_memory_search(q: str, request: Request, limit: int = 20):
1930
- current_user = require_user(request)
1931
- return WORKSPACE_OS.search_memories(q, user_email=current_user or None, limit=limit, workspace_id=_workspace_scope(request))
1932
-
1933
-
1934
- @app.post("/workspace/memories")
1935
- async def workspace_memory_upsert(req: WorkspaceMemoryRequest, request: Request):
1936
- current_user = require_user(request)
1937
- try:
1938
- record = WORKSPACE_OS.upsert_memory(
1939
- kind=req.kind,
1940
- content=req.content,
1941
- tags=req.tags,
1942
- memory_id=req.memory_id,
1943
- metadata=req.metadata,
1944
- user_email=current_user or None,
1945
- graph=_workspace_graph(),
1946
- workspace_id=_workspace_scope(request),
1947
- )
1948
- except ValueError as exc:
1949
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1950
- return {"memory": record}
1951
-
1952
-
1953
- @app.delete("/workspace/memories/{memory_id}")
1954
- async def workspace_memory_delete(memory_id: str, request: Request):
1955
- require_user(request)
1956
- try:
1957
- return WORKSPACE_OS.delete_memory(memory_id)
1958
- except FileNotFoundError as exc:
1959
- raise HTTPException(status_code=404, detail=f"Memory not found: {exc}") from exc
1960
-
1961
-
1962
- @app.get("/workspace/agents")
1963
- async def workspace_agents(request: Request):
1964
- require_user(request)
1965
- return WORKSPACE_OS.list_agents(workspace_id=_workspace_scope(request))
1966
-
1967
-
1968
- @app.post("/workspace/agents/runs")
1969
- async def workspace_agent_run(req: WorkspaceAgentRunRequest, request: Request):
1970
- current_user = require_user(request)
1971
- run = WORKSPACE_OS.record_agent_run(
1972
- agent_id=req.agent_id,
1973
- status=req.status,
1974
- input_text=req.input,
1975
- output_text=req.output,
1976
- timeline=req.timeline,
1977
- relationships=req.relationships,
1978
- user_email=current_user or None,
1979
- graph=_workspace_graph(),
1980
- workspace_id=_workspace_scope(request),
1981
- )
1982
- return {"run": run}
1983
-
1984
-
1985
- @app.get("/workspace/relationships/{node_id:path}")
1986
- async def workspace_relationships(node_id: str, request: Request, target_id: Optional[str] = None):
1987
- require_user(request)
1988
- _require_graph()
1989
- return WORKSPACE_OS.relationship_explorer(KNOWLEDGE_GRAPH, node_id, target_id=target_id)
1990
-
1991
-
1992
- @app.get("/workspace/computer-memory")
1993
- async def workspace_computer_memory(request: Request):
1994
- require_user(request)
1995
- return WORKSPACE_OS.load_state().get("computer_memory")
1996
-
1997
-
1998
- @app.post("/workspace/computer-memory")
1999
- async def workspace_computer_memory_config(req: WorkspaceComputerMemoryRequest, request: Request):
2000
- current_user = require_user(request)
2001
- try:
2002
- config = WORKSPACE_OS.configure_computer_memory(
2003
- enabled=req.enabled,
2004
- approved_by=current_user or None,
2005
- consent=req.consent,
2006
- scopes=req.scopes or None,
2007
- )
2008
- except PermissionError as exc:
2009
- raise HTTPException(status_code=403, detail=str(exc)) from exc
2010
- append_audit_event("computer_memory_config", user_email=current_user, enabled=req.enabled)
2011
- return {"computer_memory": config}
2012
-
2013
-
2014
- @app.post("/workspace/computer-memory/activity")
2015
- async def workspace_computer_memory_activity(req: WorkspaceComputerActivityRequest, request: Request):
2016
- require_user(request)
2017
- return WORKSPACE_OS.record_computer_activity(req.activity, graph=_workspace_graph())
2018
-
2019
-
2020
- @app.get("/workspace/workflows")
2021
- async def workspace_workflows(request: Request, q: str = ""):
2022
- require_user(request)
2023
- return WORKSPACE_OS.list_workflows(query=q, workspace_id=_workspace_scope(request))
2024
-
2025
-
2026
- @app.post("/workspace/workflows")
2027
- async def workspace_workflow_create(req: WorkspaceWorkflowRequest, request: Request):
2028
- current_user = require_user(request)
2029
- workflow = WORKSPACE_OS.create_workflow(
2030
- name=req.name,
2031
- steps=req.steps,
2032
- metadata=req.metadata,
2033
- user_email=current_user or None,
2034
- graph=_workspace_graph(),
2035
- workspace_id=_workspace_scope(request),
2036
- )
2037
- return {"workflow": workflow}
2038
-
2039
-
2040
- @app.post("/workspace/workflows/{workflow_id}/events")
2041
- async def workspace_workflow_event(workflow_id: str, req: WorkspaceWorkflowEventRequest, request: Request):
2042
- require_user(request)
2043
- try:
2044
- return {"workflow": WORKSPACE_OS.record_workflow_event(workflow_id, req.event_type, req.payload)}
2045
- except FileNotFoundError as exc:
2046
- raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
2047
-
2048
-
2049
- @app.get("/workspace/skills")
2050
- async def workspace_skills(request: Request):
2051
- require_user(request)
2052
- marketplace = []
2053
- try:
2054
- marketplace = await _fetch_skills_marketplace()
2055
- except Exception as exc:
2056
- logging.warning("workspace skills marketplace unavailable: %s", exc)
2057
- return WORKSPACE_OS.list_skill_registry(SKILLS_DIR, marketplace)
2058
-
2059
-
2060
- @app.post("/workspace/skills/install")
2061
- async def workspace_skill_install(req: WorkspaceSkillActionRequest, request: Request):
2062
- admin_email, _ = require_admin(request)
2063
- if req.plugin:
2064
- result = await install_skill(req.plugin, req.skill)
2065
- else:
2066
- result = {"status": "recorded", "skill": req.skill}
2067
- entry = WORKSPACE_OS.mark_skill_installed(req.skill, version=req.version or "local", metadata={"install_result": result, **req.metadata})
2068
- append_audit_event("skill_install", user_email=admin_email, plugin=req.plugin, skill=req.skill, workspace_os=True)
2069
- return {"skill": entry, "install": result}
2070
-
2071
-
2072
- @app.post("/workspace/skills/uninstall")
2073
- async def workspace_skill_uninstall(req: WorkspaceSkillActionRequest, request: Request):
2074
- admin_email, _ = require_admin(request)
2075
- removal = remove_skill_directory(SKILLS_DIR, req.skill)
2076
- entry = WORKSPACE_OS.mark_skill_uninstalled(req.skill)
2077
- append_audit_event("skill_uninstall", user_email=admin_email, skill=req.skill, workspace_os=True)
2078
- return {"skill": entry, "removal": removal}
2079
-
2080
-
2081
- @app.post("/workspace/skills/enable")
2082
- async def workspace_skill_enable(req: WorkspaceSkillActionRequest, request: Request):
2083
- require_user(request)
2084
- return {"skill": WORKSPACE_OS.set_skill_enabled(req.skill, True)}
2085
-
2086
-
2087
- @app.post("/workspace/skills/disable")
2088
- async def workspace_skill_disable(req: WorkspaceSkillActionRequest, request: Request):
2089
- require_user(request)
2090
- return {"skill": WORKSPACE_OS.set_skill_enabled(req.skill, False)}
2091
-
2092
-
2093
- @app.post("/workspace/skills/update")
2094
- async def workspace_skill_update(req: WorkspaceSkillActionRequest, request: Request):
2095
- admin_email, _ = require_admin(request)
2096
- if req.plugin:
2097
- result = await install_skill(req.plugin, req.skill)
2098
- else:
2099
- result = {"status": "version_recorded", "skill": req.skill}
2100
- entry = WORKSPACE_OS.mark_skill_installed(req.skill, version=req.version or "latest", metadata={"update_result": result, **req.metadata})
2101
- append_audit_event("skill_update", user_email=admin_email, plugin=req.plugin, skill=req.skill, workspace_os=True)
2102
- return {"skill": entry, "update": result}
2103
-
2104
-
2105
- @app.get("/workspace/audit-timeline")
2106
- async def workspace_audit_timeline(
2107
- request: Request,
2108
- user: Optional[str] = None,
2109
- event_type: Optional[str] = None,
2110
- model: Optional[str] = None,
2111
- since: Optional[str] = None,
2112
- until: Optional[str] = None,
2113
- limit: int = 100,
2114
- ):
2115
- require_admin(request)
2116
- return WORKSPACE_OS.filter_audit_timeline(
2117
- get_audit_log(),
2118
- user=user,
2119
- event_type=event_type,
2120
- model=model,
2121
- since=since,
2122
- until=until,
2123
- limit=limit,
2124
- )
2125
-
2126
-
2127
- @app.post("/workspace/vscode/send")
2128
- async def workspace_vscode_send(req: WorkspaceVSCodeRequest, request: Request):
2129
- current_user = require_user(request)
2130
- content = req.selection or req.content or req.prompt
2131
- workflow = WORKSPACE_OS.create_workflow(
2132
- name=f"VS Code: {req.action}",
2133
- steps=[
2134
- {"action": req.action, "file_path": req.file_path, "language": req.language},
2135
- {"action": "send_to_lattice", "chars": len(content or "")},
2136
- ],
2137
- metadata={
2138
- "file_path": req.file_path,
2139
- "language": req.language,
2140
- "content_preview": redact_secret_text(content or "")[:500],
2141
- },
2142
- user_email=current_user or None,
2143
- graph=_workspace_graph(),
2144
- )
2145
- if _workspace_graph() is not None and content:
2146
- try:
2147
- _workspace_graph().ingest_event(
2148
- "VSCodeWorkflow",
2149
- req.action,
2150
- user_email=current_user or None,
2151
- source="vscode",
2152
- metadata={
2153
- "file_path": req.file_path,
2154
- "language": req.language,
2155
- "chars": len(content),
2156
- "workflow_id": workflow["id"],
2157
- },
2158
- )
2159
- except Exception as exc:
2160
- logging.warning("vscode workflow graph ingest failed: %s", exc)
2161
- return {"status": "ok", "workflow": workflow}
2162
-
2163
-
2164
- # ── Organization Workspaces, membership, roles, and edition seam ─────────────────
2165
-
2166
- @app.get("/workspace/registry")
2167
- async def workspace_registry(request: Request):
2168
- user = require_user(request)
2169
- return WORKSPACE_OS.list_workspaces(user_id=user or None)
2170
-
2171
-
2172
- @app.get("/workspace/editions")
2173
- async def workspace_editions(request: Request):
2174
- require_user(request)
2175
- return capability_registry.describe()
2176
-
2177
-
2178
- @app.post("/workspace/activate")
2179
- async def workspace_activate(req: WorkspaceActivateRequest, request: Request):
2180
- user = require_user(request)
2181
- try:
2182
- return WORKSPACE_OS.set_active_workspace(req.workspace_id, user_id=user or None)
2183
- except FileNotFoundError as exc:
2184
- raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2185
- except PermissionError as exc:
2186
- raise HTTPException(status_code=403, detail=str(exc)) from exc
2187
-
2188
-
2189
- @app.post("/workspace/orgs")
2190
- async def workspace_org_create(req: WorkspaceCreateRequest, request: Request):
2191
- user = require_user(request)
2192
- try:
2193
- workspace = WORKSPACE_OS.create_organization_workspace(
2194
- name=req.name,
2195
- owner_user_id=user or None,
2196
- settings=req.settings,
2197
- )
2198
- except ValueError as exc:
2199
- raise HTTPException(status_code=400, detail=str(exc)) from exc
2200
- append_audit_event("workspace_created", user_email=user, workspace_id=workspace["workspace_id"])
2201
- return {"workspace": workspace}
2202
-
2203
-
2204
- @app.get("/workspace/orgs/{workspace_id}")
2205
- async def workspace_org_get(workspace_id: str, request: Request):
2206
- user = require_user(request)
2207
- try:
2208
- return {"workspace": WORKSPACE_OS.get_workspace(workspace_id, user_id=user or None)}
2209
- except FileNotFoundError as exc:
2210
- raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2211
-
2212
-
2213
- @app.get("/workspace/orgs/{workspace_id}/summary")
2214
- async def workspace_org_summary(workspace_id: str, request: Request):
2215
- user = require_user(request)
2216
- try:
2217
- return WORKSPACE_OS.workspace_summary(workspace_id, user_id=user or None)
2218
- except FileNotFoundError as exc:
2219
- raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2220
-
2221
-
2222
- @app.patch("/workspace/orgs/{workspace_id}")
2223
- async def workspace_org_update(workspace_id: str, req: WorkspaceUpdateRequest, request: Request):
2224
- user = require_user(request)
2225
- try:
2226
- workspace = WORKSPACE_OS.update_workspace(workspace_id, name=req.name, settings=req.settings, actor=user or None)
2227
- except FileNotFoundError as exc:
2228
- raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2229
- except PermissionError as exc:
2230
- raise HTTPException(status_code=403, detail=str(exc)) from exc
2231
- except ValueError as exc:
2232
- raise HTTPException(status_code=400, detail=str(exc)) from exc
2233
- append_audit_event("workspace_updated", user_email=user, workspace_id=workspace_id)
2234
- return {"workspace": workspace}
2235
-
2236
-
2237
- @app.post("/workspace/orgs/{workspace_id}/archive")
2238
- async def workspace_org_archive(workspace_id: str, request: Request):
2239
- user = require_user(request)
2240
- try:
2241
- workspace = WORKSPACE_OS.archive_workspace(workspace_id, actor=user or None)
2242
- except FileNotFoundError as exc:
2243
- raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2244
- except PermissionError as exc:
2245
- raise HTTPException(status_code=403, detail=str(exc)) from exc
2246
- except ValueError as exc:
2247
- raise HTTPException(status_code=400, detail=str(exc)) from exc
2248
- append_audit_event("workspace_archived", user_email=user, workspace_id=workspace_id)
2249
- return {"workspace": workspace}
2250
-
2251
-
2252
- @app.post("/workspace/orgs/{workspace_id}/members")
2253
- async def workspace_org_add_member(workspace_id: str, req: WorkspaceMemberRequest, request: Request):
2254
- user = require_user(request)
2255
- try:
2256
- workspace = WORKSPACE_OS.add_member(workspace_id, user_id=req.user_id, role=req.role, actor=user or None)
2257
- except FileNotFoundError as exc:
2258
- raise HTTPException(status_code=404, detail=f"Workspace not found: {exc}") from exc
2259
- except PermissionError as exc:
2260
- raise HTTPException(status_code=403, detail=str(exc)) from exc
2261
- except ValueError as exc:
2262
- raise HTTPException(status_code=400, detail=str(exc)) from exc
2263
- append_audit_event("workspace_member_added", user_email=user, workspace_id=workspace_id, member=req.user_id, role=req.role)
2264
- return {"workspace": workspace}
2265
-
2266
-
2267
- @app.patch("/workspace/orgs/{workspace_id}/members/{user_id}")
2268
- async def workspace_org_update_member(workspace_id: str, user_id: str, req: WorkspaceMemberRoleRequest, request: Request):
2269
- user = require_user(request)
2270
- try:
2271
- workspace = WORKSPACE_OS.update_member_role(workspace_id, user_id=user_id, role=req.role, actor=user or None)
2272
- except FileNotFoundError as exc:
2273
- raise HTTPException(status_code=404, detail=f"Not found: {exc}") from exc
2274
- except PermissionError as exc:
2275
- raise HTTPException(status_code=403, detail=str(exc)) from exc
2276
- except ValueError as exc:
2277
- raise HTTPException(status_code=400, detail=str(exc)) from exc
2278
- append_audit_event("workspace_member_role_updated", user_email=user, workspace_id=workspace_id, member=user_id, role=req.role)
2279
- return {"workspace": workspace}
2280
-
2281
-
2282
- @app.delete("/workspace/orgs/{workspace_id}/members/{user_id}")
2283
- async def workspace_org_remove_member(workspace_id: str, user_id: str, request: Request):
2284
- user = require_user(request)
2285
- try:
2286
- workspace = WORKSPACE_OS.remove_member(workspace_id, user_id=user_id, actor=user or None)
2287
- except FileNotFoundError as exc:
2288
- raise HTTPException(status_code=404, detail=f"Not found: {exc}") from exc
2289
- except PermissionError as exc:
2290
- raise HTTPException(status_code=403, detail=str(exc)) from exc
2291
- except ValueError as exc:
2292
- raise HTTPException(status_code=400, detail=str(exc)) from exc
2293
- append_audit_event("workspace_member_removed", user_email=user, workspace_id=workspace_id, member=user_id)
2294
- return {"workspace": workspace}
1593
+ # ── Workspace OS + Organization router (latticeai.api.workspace, v1.2.0) ──────
1594
+ app.include_router(create_workspace_router(
1595
+ service=WORKSPACE_SERVICE,
1596
+ require_user=require_user,
1597
+ require_admin=require_admin,
1598
+ get_current_user=get_current_user,
1599
+ append_audit_event=append_audit_event,
1600
+ graph_stats=_graph_stats_safe,
1601
+ workspace_models=_workspace_models_payload,
1602
+ workspace_settings=_workspace_settings_payload,
1603
+ get_history=get_history,
1604
+ get_audit_log=get_audit_log,
1605
+ require_graph=_require_graph,
1606
+ workspace_graph=_workspace_graph,
1607
+ knowledge_graph=KNOWLEDGE_GRAPH,
1608
+ local_kg_watcher=LOCAL_KG_WATCHER,
1609
+ load_users=load_users,
1610
+ scan_environment=scan_environment,
1611
+ local_sysinfo=local_sysinfo,
1612
+ get_recommendations=get_recommendations,
1613
+ fetch_skills_marketplace=_fetch_skills_marketplace,
1614
+ install_skill=install_skill,
1615
+ remove_skill_directory=remove_skill_directory,
1616
+ redact_secret_text=redact_secret_text,
1617
+ skills_dir=SKILLS_DIR,
1618
+ capability_registry=capability_registry,
1619
+ ui_file_response=ui_file_response,
1620
+ static_dir=STATIC_DIR,
1621
+ local_model=LOCAL_MODEL,
1622
+ public_model=PUBLIC_MODEL,
1623
+ ))
2295
1624
 
2296
1625
 
2297
1626
  # ── Health & Info ──────────────────────────────────────────────────────────────
@@ -4182,284 +3511,51 @@ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str
4182
3511
  results[model_ref] = record
4183
3512
  return results
4184
3513
 
4185
- @app.get("/health")
4186
- async def health(request: Request):
4187
- base = {
4188
- "status": "ok",
4189
- "version": APP_VERSION,
4190
- "mode": APP_MODE,
4191
- "platform": "AI Workspace OS",
4192
- }
4193
- if not get_current_user(request) and REQUIRE_AUTH:
4194
- return base
4195
- engines = await asyncio.to_thread(engine_status)
4196
- return {
4197
- **base,
4198
- "current_model": router.current_model_id,
4199
- "loaded_models": router.loaded_model_ids,
4200
- "device": "Apple Silicon MLX" if not IS_PUBLIC_MODE else "Public cloud/API runtime",
4201
- "features": runtime_features(),
4202
- "providers": router.detected_cloud_models(),
4203
- "engines": engines,
4204
- }
4205
-
4206
-
4207
- @app.get("/mode")
4208
- @app.get("/runtime_features")
4209
- async def mode():
4210
- return runtime_features()
4211
-
4212
-
4213
- @app.get("/engines")
4214
- async def engines():
4215
- return {"engines": await asyncio.to_thread(engine_status), "current": router.current_model_id}
4216
-
4217
-
4218
- @app.post("/engines/install")
4219
- async def engines_install(req: InstallEngineRequest, request: Request):
4220
- require_user(request)
4221
- return install_engine(req.engine)
4222
-
4223
- @app.post("/engines/verify-cloud")
4224
- async def engines_verify_cloud(req: VerifyCloudRequest, request: Request):
4225
- require_user(request)
4226
- results = await verify_cloud_models(force=req.force, provider_filter=req.provider)
4227
- return {"verified": results, "ttl_seconds": CLOUD_VERIFY_TTL_SECONDS}
4228
-
4229
-
4230
- @app.post("/engines/pull-model")
4231
- async def pull_ollama_model(req: PullModelRequest, request: Request):
4232
- require_user(request)
4233
- model_ref = normalize_local_model_request(req.model, None)
4234
- if not model_ref:
4235
- raise HTTPException(status_code=400, detail="λͺ¨λΈ μ‹λ³„μžκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€.")
4236
-
4237
- if ":" in model_ref and model_ref.split(":", 1)[0].strip().lower() in {"ollama", "vllm", "lmstudio", "llamacpp", "local_mlx", "mlx"}:
4238
- provider, model_name = model_ref.split(":", 1)
4239
- provider = provider.strip().lower()
4240
- model_name = model_name.strip()
4241
- else:
4242
- provider, model_name = "local_mlx", model_ref
4243
-
4244
- if not model_name:
4245
- raise HTTPException(status_code=400, detail="λͺ¨λΈ 이름이 λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€.")
4246
-
4247
- if provider == "ollama":
4248
- ensure_ollama_server()
4249
- ollama = local_binary("ollama")
4250
- if not ollama:
4251
- raise HTTPException(status_code=400, detail="Ollamaκ°€ μ„€μΉ˜λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
4252
- try:
4253
- completed = subprocess.run(
4254
- [ollama, "pull", model_name],
4255
- capture_output=True, text=True, timeout=900, check=False,
4256
- )
4257
- except subprocess.TimeoutExpired:
4258
- raise HTTPException(status_code=408, detail="λͺ¨λΈ λ‹€μš΄λ‘œλ“œ μ‹œκ°„μ΄ μ΄ˆκ³Όλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
4259
- if completed.returncode != 0:
4260
- raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "pull μ‹€νŒ¨")
4261
- return {"provider": provider, "model": model_name, "returncode": completed.returncode}
4262
-
4263
- if provider == "lmstudio":
4264
- raise HTTPException(
4265
- status_code=400,
4266
- detail=(
4267
- "LM Studio λͺ¨λΈμ€ Latticeμ—μ„œ Hugging Face둜 pullν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. "
4268
- "LM Studio μ•±μ—μ„œ λͺ¨λΈμ„ λ‹€μš΄λ‘œλ“œν•˜κ³  Local Serverλ₯Ό μΌ  λ’€ λͺ¨λΈμ„ λ‘œλ“œν•˜μ„Έμš”. "
4269
- "그러면 λͺ¨λΈ 선택창에 μ‹€μ œ /v1/models ν•­λͺ©μ΄ ν‘œμ‹œλ©λ‹ˆλ‹€."
4270
- ),
4271
- )
4272
-
4273
- if provider in {"vllm", "llamacpp", "local_mlx", "mlx"}:
4274
- download_provider = "local_mlx" if provider == "mlx" else provider
4275
- result = download_hf_model(model_name, download_provider)
4276
- return {"provider": provider, "model": model_name, "returncode": 0, **result}
4277
-
4278
- raise HTTPException(status_code=400, detail=f"{provider} μ—”μ§„ λͺ¨λΈ λ‹€μš΄λ‘œλ“œλŠ” 아직 μžλ™ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
4279
-
4280
-
4281
- @app.post("/engines/prepare-model")
4282
- async def engines_prepare_model(req: PrepareModelRequest, request: Request):
4283
- require_user(request)
4284
- return await prepare_and_load_model(
4285
- req.model,
4286
- request,
4287
- engine=req.engine,
4288
- user_email=req.user_email,
4289
- )
4290
-
4291
-
4292
- @app.post("/engines/prepare-model/stream")
4293
- async def engines_prepare_model_stream(req: PrepareModelRequest, request: Request):
4294
- require_user(request)
4295
-
4296
- async def event_stream():
4297
- try:
4298
- async for chunk in prepare_and_load_model_stream(
4299
- req.model,
4300
- request,
4301
- engine=req.engine,
4302
- user_email=req.user_email,
4303
- ):
4304
- yield chunk
4305
- except HTTPException as exc:
4306
- yield sse_event("error", {
4307
- "status_code": exc.status_code,
4308
- "detail": exc.detail or "λͺ¨λΈ 쀀비에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.",
4309
- })
4310
- except Exception as exc:
4311
- logging.exception("model prepare stream failed")
4312
- yield sse_event("error", {
4313
- "status_code": 500,
4314
- "detail": str(exc)[-1000:] or "λͺ¨λΈ 쀀비에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.",
4315
- })
4316
-
4317
- return StreamingResponse(
4318
- event_stream(),
4319
- media_type="text/event-stream",
4320
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
4321
- )
4322
-
4323
-
4324
- @app.post("/setup/set-api-key")
4325
- async def set_api_key(req: SetApiKeyRequest, request: Request):
4326
- from llm_router import OPENAI_COMPATIBLE_PROVIDERS
4327
- config = OPENAI_COMPATIBLE_PROVIDERS.get(req.provider)
4328
- if not config:
4329
- raise HTTPException(status_code=400, detail="μ•Œ 수 μ—†λŠ” ν”„λ‘œλ°”μ΄λ”μž…λ‹ˆλ‹€.")
4330
- if not req.key.strip():
4331
- raise HTTPException(status_code=400, detail="API ν‚€κ°€ λΉ„μ–΄μžˆμŠ΅λ‹ˆλ‹€.")
4332
- current_user = get_current_user(request)
4333
- if REQUIRE_AUTH and not current_user:
4334
- raise HTTPException(status_code=401, detail="인증이 ν•„μš”ν•©λ‹ˆλ‹€.")
4335
- # req.user_email 을 ν†΅ν•œ 타 계정 μœ„μ‘°λ₯Ό λ°©μ§€: κ΄€λ¦¬μžκ°€ μ•„λ‹ˆλ©΄ 본인 μ΄λ©”μΌλ§Œ ν—ˆμš©
4336
- if req.user_email and req.user_email != current_user:
4337
- users = load_users()
4338
- if get_user_role(current_user or "", users) != "admin":
4339
- raise HTTPException(status_code=403, detail="λ‹€λ₯Έ μ‚¬μš©μžμ˜ API ν‚€λ₯Ό μ„€μ •ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.")
4340
- target_email = (req.user_email or current_user or "").strip()
4341
- if not target_email:
4342
- raise HTTPException(status_code=400, detail="μ‚¬μš©μž 식별이 ν•„μš”ν•©λ‹ˆλ‹€. 둜그인 ν›„ λ‹€μ‹œ μ‹œλ„ν•˜μ„Έμš”.")
4343
- set_user_api_key(target_email, req.provider, req.key.strip())
4344
- return {"ok": True, "provider": req.provider, "user_email": target_email, "scope": "user"}
4345
-
4346
-
4347
- def _recommended_with_engine_options(items: List[Dict[str, object]]) -> List[Dict[str, object]]:
4348
- """ν”Όλ“œλ°± #1: μΆ”μ²œ λͺ¨λΈμ— 엔진별 선택지(engine_options)λ₯Ό λΆ™μ—¬ λ‚΄λ €μ€€λ‹€.
4349
-
4350
- ν”„λ‘ νŠΈμ—μ„œ μΆ”μ²œ μΉ΄λ“œλ₯Ό λˆ„λ₯΄λŠ” μˆœκ°„ μ–΄λŠ μ—”μ§„/μ‹€μ œ λͺ¨λΈλ‘œ λ‹€μš΄λ‘œλ“œ/λ‘œλ“œν• μ§€κ°€
4351
- 이미 ν™•μ •λ˜λ„λ‘ ν•œλ‹€.
4352
- """
4353
- out: List[Dict[str, object]] = []
4354
- for item in items:
4355
- base = {
4356
- "id": item["id"],
4357
- "name": item["name"],
4358
- "tag": item["tag"],
4359
- "size": item["size"],
4360
- "display_name": item.get("name") or item.get("id"),
4361
- }
4362
- short_id = str(item["id"]).lower()
4363
- aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
4364
- options: List[Dict[str, str]] = []
4365
- for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
4366
- real = aliases.get(engine_name)
4367
- if not real:
4368
- continue
4369
- options.append({
4370
- "engine": engine_name,
4371
- "model_id": real,
4372
- "load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
4373
- })
4374
- # μ–΄λŠ 엔진도 aliasκ°€ μ—†μœΌλ©΄ local_mlx μΉ΄νƒˆλ‘œκ·Έ 자체λ₯Ό μ‚¬μš©ν•œλ‹€.
4375
- if not options:
4376
- options.append({
4377
- "engine": "local_mlx",
4378
- "model_id": item["id"],
4379
- "load_id": item["id"],
4380
- })
4381
- base["engine_options"] = options
4382
- base["recommended_engine"] = options[0]["engine"]
4383
- out.append(base)
4384
- return out
4385
-
4386
-
4387
- @app.get("/models")
4388
- async def list_models():
4389
- """HuggingFace μΆ”μ²œ λͺ¨λΈ λͺ©λ‘ 및 λ‘œλ“œ μƒνƒœ λ°˜ν™˜"""
4390
- recommended = _recommended_with_engine_options(
4391
- list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", [])))
4392
- )
4393
- return {
4394
- "recommended": recommended,
4395
- "cloud": router.detected_cloud_models(),
4396
- "engines": await asyncio.to_thread(engine_status),
4397
- "loaded": router.loaded_model_ids,
4398
- "current": router.current_model_id,
4399
- "compat_profiles": _list_compat_profiles(),
4400
- }
4401
-
4402
-
4403
- @app.get("/models/compat-profiles")
4404
- async def list_model_compat_profiles(request: Request):
4405
- """ν”Όλ“œλ°± #3: Model Compatibility Layer μΊμ‹œ μƒνƒœλ₯Ό μ‘°νšŒν•œλ‹€."""
4406
- require_user(request)
4407
- return {"profiles": _list_compat_profiles()}
4408
-
4409
-
4410
- # ── Model Management ───────────────────────────────────────────────────────────
4411
-
4412
- @app.post("/models/load")
4413
- async def load_model(req: LoadModelRequest, request: Request):
4414
- """λͺ¨λΈ λ‘œλ“œ (이미 λ‘œλ“œλμœΌλ©΄ μΊμ‹œμ—μ„œ μ¦‰μ‹œ λ°˜ν™˜)"""
4415
- try:
4416
- model_id = req.model_id
4417
- requested_engine = req.engine or (model_id.split(":", 1)[0] if ":" in model_id else "local_mlx")
4418
- if IS_PUBLIC_MODE and not ALLOW_LOCAL_MODELS and requested_engine in {"local_mlx", "mlx"}:
4419
- raise HTTPException(
4420
- status_code=400,
4421
- detail="Public mode blocks local MLX model loading. Use openai:, openrouter:, groq:, together:, or set LATTICEAI_ALLOW_LOCAL_MODELS=true.",
4422
- )
4423
- return await prepare_and_load_model(
4424
- model_id,
4425
- request,
4426
- engine=req.engine,
4427
- user_email=req.user_email,
4428
- adapter_path=req.adapter_path,
4429
- draft_model_id=req.draft_model_id,
4430
- )
4431
- except HTTPException:
4432
- raise
4433
- except Exception as e:
4434
- raise HTTPException(status_code=500, detail=str(e))
4435
-
4436
-
4437
- @app.post("/models/switch/{model_id:path}")
4438
- async def switch_model(model_id: str, request: Request):
4439
- """이미 λ‘œλ“œλœ λͺ¨λΈ 쀑 ν™œμ„± λͺ¨λΈ μ „ν™˜ (μ¦‰μ‹œ, μž¬λ‘œλ“œ μ—†μŒ)"""
4440
- require_user(request)
4441
- try:
4442
- router.switch_model(model_id)
4443
- return {"status": "ok", "current": router.current_model_id}
4444
- except KeyError:
4445
- raise HTTPException(status_code=404, detail=f"Model '{model_id}' not loaded. Call /models/load first.")
4446
-
4447
-
4448
- @app.delete("/models/unload/{model_id:path}")
4449
- async def unload_model(model_id: str, request: Request):
4450
- """λͺ¨λΈ μ–Έλ‘œλ“œ β†’ λ©”λͺ¨λ¦¬ ν•΄μ œ"""
4451
- require_user(request)
4452
- router.unload_model(model_id)
4453
- return {"status": "ok", "unloaded": model_id}
3514
+ # ── Health / status / engine-summary router (latticeai.api.health, v1.2.0) ───
3515
+ # /health, /mode, /runtime_features, /engines(GET) now live in the health router.
3516
+ # Heavier engine mutation endpoints remain below in server_app.
3517
+ MODEL_SERVICE = ModelService(
3518
+ model_router=router,
3519
+ runtime_features=runtime_features,
3520
+ is_public=IS_PUBLIC_MODE,
3521
+ )
3522
+ app.include_router(create_health_router(
3523
+ model_service=MODEL_SERVICE,
3524
+ engine_status=engine_status,
3525
+ get_current_user=get_current_user,
3526
+ require_auth=REQUIRE_AUTH,
3527
+ app_version=APP_VERSION,
3528
+ app_mode=APP_MODE,
3529
+ ))
4454
3530
 
4455
3531
 
4456
- @app.delete("/models/unload-all")
4457
- async def unload_all_models(request: Request):
4458
- """λ‘œλ“œλœ λͺ¨λ“  λͺ¨λΈ μ–Έλ‘œλ“œ β†’ λ©”λͺ¨λ¦¬ ν•΄μ œ"""
4459
- require_user(request)
4460
- unloaded = router.loaded_model_ids
4461
- router.unload_all()
4462
- return {"status": "ok", "unloaded": unloaded}
3532
+ # ── Model / Engine router (latticeai.api.models, v1.3.0) ─────────────────────
3533
+ app.include_router(create_models_router(
3534
+ model_router=router,
3535
+ require_user=require_user,
3536
+ get_current_user=get_current_user,
3537
+ load_users=load_users,
3538
+ get_user_role=get_user_role,
3539
+ install_engine=install_engine,
3540
+ verify_cloud_models=verify_cloud_models,
3541
+ normalize_local_model_request=normalize_local_model_request,
3542
+ download_hf_model=download_hf_model,
3543
+ prepare_and_load_model=prepare_and_load_model,
3544
+ prepare_and_load_model_stream=prepare_and_load_model_stream,
3545
+ sse_event=sse_event,
3546
+ ensure_ollama_server=ensure_ollama_server,
3547
+ local_binary=local_binary,
3548
+ engine_status=engine_status,
3549
+ filter_lower_family_versions=filter_lower_family_versions,
3550
+ list_compat_profiles=_list_compat_profiles,
3551
+ set_user_api_key=set_user_api_key,
3552
+ engine_model_catalog=ENGINE_MODEL_CATALOG,
3553
+ model_engine_aliases=MODEL_ENGINE_ALIASES,
3554
+ cloud_verify_ttl_seconds=CLOUD_VERIFY_TTL_SECONDS,
3555
+ is_public_mode=IS_PUBLIC_MODE,
3556
+ allow_local_models=ALLOW_LOCAL_MODELS,
3557
+ require_auth=REQUIRE_AUTH,
3558
+ ))
4463
3559
 
4464
3560
 
4465
3561
  # ── Chat / Completion ──────────────────────────────────────────────────────────
@@ -4615,7 +3711,7 @@ async def chat(req: ChatRequest, request: Request):
4615
3711
  except Exception:
4616
3712
  pass
4617
3713
 
4618
- trace_seed = WORKSPACE_OS.build_graph_trace(
3714
+ trace_seed = CHAT_SERVICE.build_graph_trace(
4619
3715
  req.message,
4620
3716
  KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
4621
3717
  context,
@@ -4653,7 +3749,7 @@ async def chat(req: ChatRequest, request: Request):
4653
3749
  full_text += footnote
4654
3750
  session.update(graph_md, full_text, req.conversation_id)
4655
3751
  save_to_history("assistant", full_text, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4656
- trace_record = WORKSPACE_OS.record_trace(
3752
+ trace_record = CHAT_SERVICE.record_trace(
4657
3753
  question=req.message,
4658
3754
  response=full_text,
4659
3755
  conversation_id=req.conversation_id,
@@ -4679,7 +3775,7 @@ async def chat(req: ChatRequest, request: Request):
4679
3775
  result += footnote
4680
3776
  session.update(graph_md, result, req.conversation_id)
4681
3777
  save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4682
- trace_record = WORKSPACE_OS.record_trace(
3778
+ trace_record = CHAT_SERVICE.record_trace(
4683
3779
  question=req.message,
4684
3780
  response=str(result),
4685
3781
  conversation_id=req.conversation_id,
@@ -4716,7 +3812,7 @@ async def chat(req: ChatRequest, request: Request):
4716
3812
  result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
4717
3813
 
4718
3814
  save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4719
- trace_record = WORKSPACE_OS.record_trace(
3815
+ trace_record = CHAT_SERVICE.record_trace(
4720
3816
  question=req.message,
4721
3817
  response=str(result),
4722
3818
  conversation_id=req.conversation_id,
@@ -4820,12 +3916,12 @@ async def _stream_chat(
4820
3916
  yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
4821
3917
  history_user = get_history_user(req.user_email, req.user_nickname)
4822
3918
  save_to_history("assistant", full_response, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
4823
- trace_record = WORKSPACE_OS.record_trace(
3919
+ trace_record = CHAT_SERVICE.record_trace(
4824
3920
  question=req.message,
4825
3921
  response=full_response,
4826
3922
  conversation_id=req.conversation_id,
4827
3923
  user_email=effective_email or req.user_email,
4828
- trace=trace_seed or WORKSPACE_OS.build_graph_trace(
3924
+ trace=trace_seed or CHAT_SERVICE.build_graph_trace(
4829
3925
  req.message,
4830
3926
  KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
4831
3927
  context,
@@ -6103,324 +5199,24 @@ async def tools_permissions(request: Request):
6103
5199
  return {"status": "ok", "permissions": list_tool_permissions()}
6104
5200
 
6105
5201
 
6106
- @app.get("/mcp/tools")
6107
- async def mcp_tools():
6108
- installed = load_mcp_installs().get("installed", {})
6109
- registry = await _get_combined_registry()
6110
- tools = []
6111
- for name, description in MCP_TOOL_DESCRIPTIONS.items():
6112
- policy = TOOL_GOVERNANCE.get(name, _TOOL_GOVERNANCE_DEFAULT)
6113
- tools.append({
6114
- "name": name,
6115
- "description": description,
6116
- "permission": get_tool_permission(name),
6117
- "governance": {
6118
- "risk": policy["risk"],
6119
- "destructive": policy["destructive"],
6120
- "shell": policy["shell"],
6121
- "network": policy["network"],
6122
- "auto_approve": policy["auto_approve"],
6123
- "sandbox": policy["sandbox"],
6124
- "rollback": policy["rollback"],
6125
- },
6126
- })
6127
- return {
6128
- "status": "ok",
6129
- "workspace": str(AGENT_ROOT),
6130
- "installed_mcps": [mcp_public_item(item, installed) for item in registry],
6131
- "tools": tools,
6132
- }
6133
-
6134
-
6135
- @app.post("/mcp/recommend")
6136
- async def mcp_recommend(req: McpRecommendRequest, request: Request):
6137
- require_user(request)
6138
- return {"recommendations": await recommend_mcps(req.query, req.limit)}
6139
-
6140
-
6141
- @app.post("/mcp/install")
6142
- async def mcp_install(req: McpInstallRequest, request: Request):
6143
- admin_email, _ = require_admin(request)
6144
- append_audit_event("mcp_install", user_email=admin_email, mcp_id=req.mcp_id)
6145
- return await install_mcp(req.mcp_id)
6146
-
6147
-
6148
- @app.get("/mcp/installed")
6149
- async def mcp_installed(request: Request):
6150
- require_user(request)
6151
- installed = load_mcp_installs().get("installed", {})
6152
- registry = await _get_combined_registry()
6153
- return {"installed": [mcp_public_item(item, installed) for item in registry]}
6154
-
6155
-
6156
- @app.get("/mcp/connectors/{mcp_id}")
6157
- async def mcp_connector(mcp_id: str, request: Request):
6158
- require_user(request)
6159
- registry = await _get_combined_registry()
6160
- item = next((e for e in registry if e["id"] == mcp_id), None)
6161
- if not item or item.get("install_mode") != "connector":
6162
- raise HTTPException(status_code=404, detail="컀λ„₯ν„°λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
6163
- installed = load_mcp_installs().get("installed", {})
6164
- public = mcp_public_item(item, installed)
6165
- public["instructions"] = [
6166
- "Codex λ˜λŠ” ChatGPT μ•±μ˜ Connectors 섀정을 μ—½λ‹ˆλ‹€.",
6167
- f"{item['name']} ν•­λͺ©μ„ μ„ νƒν•˜κ³  계정을 μΈμ¦ν•©λ‹ˆλ‹€.",
6168
- "인증 ν›„ Lattice AIμ—μ„œ 이 MCPλ₯Ό λ‹€μ‹œ ν™œμ„±ν™”ν•˜λ©΄ μž‘μ—…μ— μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.",
6169
- ]
6170
- return public
6171
-
6172
-
6173
- @app.post("/mcp/registry/refresh")
6174
- async def mcp_registry_refresh(request: Request):
6175
- require_user(request)
6176
- mcp_registry._REMOTE_REGISTRY_FETCHED_AT = None
6177
- registry = await _get_combined_registry()
6178
- return {"status": "ok", "total": len(registry), "remote": len(mcp_registry._REMOTE_REGISTRY_CACHE)}
6179
-
6180
-
6181
- @app.get("/mcp/claude-code-servers")
6182
- async def mcp_claude_code_servers(request: Request):
6183
- """Read ~/.claude/settings.json mcpServers and return them as Lattice MCP items."""
6184
- require_user(request)
6185
- settings_path = Path.home() / ".claude" / "settings.json"
6186
- if not settings_path.exists():
6187
- return {"servers": []}
6188
- try:
6189
- with open(settings_path, "r", encoding="utf-8") as f:
6190
- settings = json.load(f)
6191
- mcp_servers = settings.get("mcpServers", {})
6192
- servers = []
6193
- for name, cfg in mcp_servers.items():
6194
- cmd = cfg.get("command", "")
6195
- args = cfg.get("args", [])
6196
- package = " ".join([cmd] + args) if args else cmd
6197
- env = cfg.get("env", {})
6198
- env_vars = [{"name": k, "value": v} for k, v in env.items()]
6199
- servers.append({
6200
- "id": f"claude-code:{name}",
6201
- "name": name,
6202
- "description": f"Claude Code MCP: {package}",
6203
- "package": package,
6204
- "icon": "πŸ€–",
6205
- "category": "Claude Code",
6206
- "source": "claude-code",
6207
- "installed": True,
6208
- "env_vars": env_vars,
6209
- })
6210
- return {"servers": servers}
6211
- except Exception as e:
6212
- logging.warning("mcp_claude_code_servers failed: %s", e)
6213
- return {"servers": []}
6214
-
6215
-
6216
- _CUSTOM_MCP_FILE = DATA_DIR / "custom_mcps.json"
6217
-
6218
- def _load_custom_mcps() -> List[Dict]:
6219
- if not _CUSTOM_MCP_FILE.exists():
6220
- return []
6221
- try:
6222
- with open(_CUSTOM_MCP_FILE, "r", encoding="utf-8") as f:
6223
- return json.load(f)
6224
- except Exception:
6225
- return []
6226
-
6227
- def _save_custom_mcps(items: List[Dict]):
6228
- with open(_CUSTOM_MCP_FILE, "w", encoding="utf-8") as f:
6229
- json.dump(items, f, ensure_ascii=False, indent=2)
6230
-
6231
-
6232
- @app.get("/mcp/custom")
6233
- async def mcp_custom_list(request: Request):
6234
- """Return user-added custom MCP entries."""
6235
- require_user(request)
6236
- return {"custom": _load_custom_mcps()}
6237
-
6238
-
6239
- @app.post("/mcp/custom")
6240
- async def mcp_custom_add(req: McpCustomRequest, request: Request):
6241
- """Save a custom MCP entry (admin-only)."""
6242
- admin_email, _ = require_admin(request)
6243
- append_audit_event("mcp_custom_add", user_email=admin_email, name=req.name, package=req.package)
6244
- if not req.name.strip():
6245
- raise HTTPException(status_code=400, detail="name은 ν•„μˆ˜μž…λ‹ˆλ‹€.")
6246
- if not req.package.strip():
6247
- raise HTTPException(status_code=400, detail="packageλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.")
6248
- items = _load_custom_mcps()
6249
- entry = {
6250
- "id": f"custom:{req.name.strip().lower().replace(' ', '-')}",
6251
- "name": req.name.strip(),
6252
- "package": req.package.strip(),
6253
- "description": req.description.strip(),
6254
- "category": req.category or "custom",
6255
- "icon": req.icon or "πŸ”Œ",
6256
- "env_vars": req.env_vars or [],
6257
- "install_mode": "npm",
6258
- "source": "custom",
6259
- "installed": False,
6260
- "added_at": datetime.now().isoformat(),
6261
- }
6262
- # overwrite if same id
6263
- items = [e for e in items if e["id"] != entry["id"]]
6264
- items.append(entry)
6265
- _save_custom_mcps(items)
6266
- return {"status": "ok", "entry": entry}
6267
-
6268
-
6269
- @app.delete("/mcp/custom/{mcp_id:path}")
6270
- async def mcp_custom_delete(mcp_id: str, request: Request):
6271
- """Remove a custom MCP entry."""
6272
- require_admin(request)
6273
- items = _load_custom_mcps()
6274
- before = len(items)
6275
- items = [e for e in items if e["id"] != mcp_id]
6276
- if len(items) == before:
6277
- raise HTTPException(status_code=404, detail="ν•­λͺ©μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
6278
- _save_custom_mcps(items)
6279
- return {"status": "ok"}
6280
-
6281
-
6282
- # ── Skills & Plugin Directory endpoints ───────────────────────────────────────
6283
-
6284
- @app.get("/skills/marketplace")
6285
- async def skills_marketplace(request: Request, category: Optional[str] = None, author: Optional[str] = None):
6286
- """Skills λ§ˆμΌ“ν”Œλ ˆμ΄μŠ€ (Anthropic Apache-2.0 + κ²€μ¦λœ μ„œλ“œνŒŒν‹° MIT/Apache-2.0)"""
6287
- require_user(request)
6288
- skills = await _fetch_skills_marketplace()
6289
- installed_names = {d.name for d in SKILLS_DIR.iterdir() if d.is_dir()} if SKILLS_DIR.exists() else set()
6290
- filtered = skills
6291
- if category:
6292
- filtered = [s for s in filtered if s.get("category", "").lower() == category.lower()]
6293
- if author:
6294
- filtered = [s for s in filtered if s.get("author", "").lower() == author.lower()]
6295
- return {
6296
- "skills": [{**s, "installed": s["skill"] in installed_names} for s in filtered],
6297
- "total": len(filtered),
6298
- "authors": sorted({s["author"] for s in skills}),
6299
- "categories": sorted({s["category"] for s in skills}),
6300
- }
6301
-
6302
-
6303
- @app.post("/skills/install")
6304
- async def skills_install(req: SkillInstallRequest, request: Request):
6305
- """skill을 둜컬 skills 디렉터리에 μ„€μΉ˜ (Apache-2.0 / MIT, κ΄€λ¦¬μž μ „μš©)"""
6306
- admin_email, _ = require_admin(request)
6307
- append_audit_event("skill_install", user_email=admin_email, plugin=req.plugin, skill=req.skill)
6308
- return await install_skill(req.plugin, req.skill)
6309
-
6310
-
6311
- @app.get("/skills/list")
6312
- async def skills_list(request: Request):
6313
- """λ‘œμ»¬μ— μ„€μΉ˜λœ skills λͺ©λ‘"""
6314
- require_user(request)
6315
- if not SKILLS_DIR.exists():
6316
- return {"skills": []}
6317
- skills = []
6318
- for skill_dir in sorted(SKILLS_DIR.iterdir()):
6319
- if not skill_dir.is_dir():
6320
- continue
6321
- skill_md = skill_dir / "SKILL.md"
6322
- if not skill_md.exists():
6323
- continue
6324
- lines = skill_md.read_text(encoding="utf-8").splitlines()
6325
- desc = next((l.split(":", 1)[1].strip() for l in lines if l.startswith("description:")), "")
6326
- comment = lines[0] if lines else ""
6327
- if "anthropics/claude-plugins-official" in comment:
6328
- source = "anthropic"
6329
- elif "Source:" in comment:
6330
- source = "third-party"
6331
- else:
6332
- source = "local"
6333
- skills.append({"name": skill_dir.name, "description": desc, "source": source})
6334
- return {"skills": skills, "total": len(skills)}
6335
-
6336
-
6337
- @app.post("/skills/marketplace/refresh")
6338
- async def skills_marketplace_refresh(request: Request):
6339
- """Skills λ§ˆμΌ“ν”Œλ ˆμ΄μŠ€ μΊμ‹œ κ°•μ œ κ°±μ‹ """
6340
- require_user(request)
6341
- mcp_registry._SKILLS_MARKETPLACE_FETCHED_AT = None
6342
- skills = await _fetch_skills_marketplace()
6343
- by_author = {}
6344
- for s in skills:
6345
- by_author[s["author"]] = by_author.get(s["author"], 0) + 1
6346
- return {"status": "ok", "total": len(skills), "by_author": by_author}
6347
-
6348
-
6349
- @app.get("/plugins/directory")
6350
- async def plugins_directory(
6351
- request: Request,
6352
- category: Optional[str] = None,
6353
- license: Optional[str] = None,
6354
- q: Optional[str] = None,
6355
- ):
6356
- """μ˜€ν”ˆμ†ŒμŠ€ ν”ŒλŸ¬κ·ΈμΈ 디렉터리 (Apache-2.0 / MIT / MIT-0, λŸ°νƒ€μž„ fetch)"""
6357
- require_user(request)
6358
- plugins = await _fetch_plugin_directory()
6359
- filtered = plugins
6360
- if category:
6361
- filtered = [p for p in filtered if p.get("category", "").lower() == category.lower()]
6362
- if license:
6363
- filtered = [p for p in filtered if p.get("license", "").lower() == license.lower()]
6364
- if q:
6365
- q_lower = q.lower()
6366
- filtered = [
6367
- p for p in filtered
6368
- if q_lower in p.get("name", "").lower()
6369
- or q_lower in p.get("description", "").lower()
6370
- or q_lower in p.get("author", "").lower()
6371
- ]
6372
- return {
6373
- "plugins": filtered,
6374
- "total": len(filtered),
6375
- "categories": sorted({p["category"] for p in plugins if p.get("category")}),
6376
- "licenses": sorted({p["license"] for p in plugins if p.get("license")}),
6377
- }
6378
-
6379
-
6380
- @app.post("/plugins/directory/refresh")
6381
- async def plugins_directory_refresh(request: Request):
6382
- """ν”ŒλŸ¬κ·ΈμΈ 디렉터리 μΊμ‹œ κ°•μ œ κ°±μ‹ """
6383
- require_user(request)
6384
- mcp_registry._PLUGIN_DIRECTORY_FETCHED_AT = None
6385
- plugins = await _fetch_plugin_directory()
6386
- by_license = {}
6387
- for p in plugins:
6388
- lic = p.get("license", "unknown")
6389
- by_license[lic] = by_license.get(lic, 0) + 1
6390
- return {"status": "ok", "total": len(plugins), "by_license": by_license}
6391
-
6392
-
6393
- @app.post("/mcp/call")
6394
- async def mcp_call(req: McpCallRequest, request: Request):
6395
- current_user = require_user(request)
6396
- args = req.args or {}
6397
- if req.action == "knowledge_graph_ingest":
6398
- _require_graph()
6399
- return KNOWLEDGE_GRAPH.ingest_message(
6400
- args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
6401
- args.get("content") or "",
6402
- user_email=args.get("user_email") or current_user,
6403
- user_nickname=args.get("user_nickname"),
6404
- source=args.get("source") or "mcp",
6405
- conversation_id=args.get("conversation_id"),
6406
- raw=args,
6407
- )
6408
- if req.action == "knowledge_graph_search":
6409
- _require_graph()
6410
- return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
6411
- if req.action == "knowledge_graph_graph":
6412
- _require_graph()
6413
- return KNOWLEDGE_GRAPH.graph(args.get("limit", 300))
6414
- if req.action == "knowledge_graph_context":
6415
- _require_graph()
6416
- return {
6417
- "context": KNOWLEDGE_GRAPH.context_for_query(
6418
- args.get("query") or args.get("q") or "",
6419
- args.get("limit", 6),
6420
- )
6421
- }
6422
- _check_tool_role(req.action, current_user)
6423
- return _tool_response(execute_tool, req.action, req.args or {})
5202
+ # ── MCP / skills / plugins router (latticeai.api.mcp, v1.3.0) ────────────────
5203
+ app.include_router(create_mcp_router(
5204
+ require_user=require_user,
5205
+ require_admin=require_admin,
5206
+ append_audit_event=append_audit_event,
5207
+ load_mcp_installs=load_mcp_installs,
5208
+ recommend_mcps=recommend_mcps,
5209
+ install_mcp=install_mcp,
5210
+ mcp_public_item=mcp_public_item,
5211
+ get_tool_permission=get_tool_permission,
5212
+ tool_governance=TOOL_GOVERNANCE,
5213
+ tool_governance_default=_TOOL_GOVERNANCE_DEFAULT,
5214
+ check_tool_role=_check_tool_role,
5215
+ tool_response=_tool_response,
5216
+ require_graph=_require_graph,
5217
+ knowledge_graph=KNOWLEDGE_GRAPH,
5218
+ data_dir=DATA_DIR,
5219
+ ))
6424
5220
 
6425
5221
 
6426
5222
  # ── P-Reinforce Knowledge Gardener ────────────────────────────────────────────