ltcai 1.2.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.
- package/README.md +12 -0
- package/docs/CHANGELOG.md +41 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/mcp.py +386 -0
- package/latticeai/api/models.py +307 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +49 -616
- package/package.json +1 -1
package/latticeai/server_app.py
CHANGED
|
@@ -106,6 +106,8 @@ from latticeai.services.model_service import ModelService
|
|
|
106
106
|
from latticeai.services.chat_service import ChatService
|
|
107
107
|
from latticeai.api.workspace import create_workspace_router
|
|
108
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
|
|
109
111
|
from latticeai.core.agent import (
|
|
110
112
|
AgentState,
|
|
111
113
|
AgentRunContext,
|
|
@@ -463,25 +465,7 @@ def save_sso_config(update: Dict[str, object]) -> Dict[str, object]:
|
|
|
463
465
|
_sso_discovery_cache_url = ""
|
|
464
466
|
return current
|
|
465
467
|
|
|
466
|
-
|
|
467
|
-
query: str
|
|
468
|
-
limit: int = 5
|
|
469
|
-
|
|
470
|
-
class McpInstallRequest(BaseModel):
|
|
471
|
-
mcp_id: str
|
|
472
|
-
|
|
473
|
-
class McpCustomRequest(BaseModel):
|
|
474
|
-
name: str
|
|
475
|
-
package: str
|
|
476
|
-
description: str = ""
|
|
477
|
-
category: str = "custom"
|
|
478
|
-
icon: str = "π"
|
|
479
|
-
env_vars: List[Dict] = []
|
|
480
|
-
|
|
481
|
-
class SkillInstallRequest(BaseModel):
|
|
482
|
-
plugin: str
|
|
483
|
-
skill: str
|
|
484
|
-
|
|
468
|
+
# MCP/skill request models moved to latticeai.api.mcp (v1.3.0).
|
|
485
469
|
DEFAULT_VPC_CONFIG = {
|
|
486
470
|
"provider": "AWS",
|
|
487
471
|
"region": "ap-northeast-2",
|
|
@@ -1404,36 +1388,7 @@ class ChatRequest(BaseModel):
|
|
|
1404
1388
|
image_data: Optional[str] = None # Base64 μ΄λ―Έμ§ λ°μ΄ν° (VLMμ©)
|
|
1405
1389
|
|
|
1406
1390
|
|
|
1407
|
-
|
|
1408
|
-
model_id: str # HuggingFace repo id λλ λ‘컬 κ²½λ‘
|
|
1409
|
-
adapter_path: Optional[str] = None # LoRA adapter (μ ν)
|
|
1410
|
-
draft_model_id: Optional[str] = None # Speculative decoding draft model (μ ν)
|
|
1411
|
-
engine: Optional[str] = None
|
|
1412
|
-
user_email: Optional[str] = None
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
class InstallEngineRequest(BaseModel):
|
|
1416
|
-
engine: str
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
class SetApiKeyRequest(BaseModel):
|
|
1420
|
-
provider: str
|
|
1421
|
-
key: str
|
|
1422
|
-
user_email: Optional[str] = None
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
class PullModelRequest(BaseModel):
|
|
1426
|
-
model: str
|
|
1427
|
-
|
|
1428
|
-
class PrepareModelRequest(BaseModel):
|
|
1429
|
-
model: str
|
|
1430
|
-
engine: Optional[str] = None
|
|
1431
|
-
user_email: Optional[str] = None
|
|
1432
|
-
|
|
1433
|
-
class VerifyCloudRequest(BaseModel):
|
|
1434
|
-
force: bool = False
|
|
1435
|
-
provider: Optional[str] = None
|
|
1436
|
-
|
|
1391
|
+
# Model/engine request models moved to latticeai.api.models (v1.3.0).
|
|
1437
1392
|
|
|
1438
1393
|
# Workspace request models moved to latticeai.api.workspace (v1.2.0 modularization).
|
|
1439
1394
|
|
|
@@ -1590,10 +1545,6 @@ class LocalWriteRequest(BaseModel):
|
|
|
1590
1545
|
approval_token: Optional[str] = None
|
|
1591
1546
|
|
|
1592
1547
|
|
|
1593
|
-
class McpCallRequest(BaseModel):
|
|
1594
|
-
action: str
|
|
1595
|
-
args: Dict = {}
|
|
1596
|
-
|
|
1597
1548
|
|
|
1598
1549
|
class ToolGitDiffRequest(BaseModel):
|
|
1599
1550
|
path: Optional[str] = None
|
|
@@ -3578,251 +3529,33 @@ app.include_router(create_health_router(
|
|
|
3578
3529
|
))
|
|
3579
3530
|
|
|
3580
3531
|
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
raise HTTPException(status_code=400, detail="λͺ¨λΈ μ΄λ¦μ΄ λΉμ΄ μμ΅λλ€.")
|
|
3609
|
-
|
|
3610
|
-
if provider == "ollama":
|
|
3611
|
-
ensure_ollama_server()
|
|
3612
|
-
ollama = local_binary("ollama")
|
|
3613
|
-
if not ollama:
|
|
3614
|
-
raise HTTPException(status_code=400, detail="Ollamaκ° μ€μΉλμ§ μμμ΅λλ€.")
|
|
3615
|
-
try:
|
|
3616
|
-
completed = subprocess.run(
|
|
3617
|
-
[ollama, "pull", model_name],
|
|
3618
|
-
capture_output=True, text=True, timeout=900, check=False,
|
|
3619
|
-
)
|
|
3620
|
-
except subprocess.TimeoutExpired:
|
|
3621
|
-
raise HTTPException(status_code=408, detail="λͺ¨λΈ λ€μ΄λ‘λ μκ°μ΄ μ΄κ³Όλμμ΅λλ€.")
|
|
3622
|
-
if completed.returncode != 0:
|
|
3623
|
-
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "pull μ€ν¨")
|
|
3624
|
-
return {"provider": provider, "model": model_name, "returncode": completed.returncode}
|
|
3625
|
-
|
|
3626
|
-
if provider == "lmstudio":
|
|
3627
|
-
raise HTTPException(
|
|
3628
|
-
status_code=400,
|
|
3629
|
-
detail=(
|
|
3630
|
-
"LM Studio λͺ¨λΈμ Latticeμμ Hugging Faceλ‘ pullνμ§ μμ΅λλ€. "
|
|
3631
|
-
"LM Studio μ±μμ λͺ¨λΈμ λ€μ΄λ‘λνκ³ Local Serverλ₯Ό μΌ λ€ λͺ¨λΈμ λ‘λνμΈμ. "
|
|
3632
|
-
"κ·Έλ¬λ©΄ λͺ¨λΈ μ νμ°½μ μ€μ /v1/models νλͺ©μ΄ νμλ©λλ€."
|
|
3633
|
-
),
|
|
3634
|
-
)
|
|
3635
|
-
|
|
3636
|
-
if provider in {"vllm", "llamacpp", "local_mlx", "mlx"}:
|
|
3637
|
-
download_provider = "local_mlx" if provider == "mlx" else provider
|
|
3638
|
-
result = download_hf_model(model_name, download_provider)
|
|
3639
|
-
return {"provider": provider, "model": model_name, "returncode": 0, **result}
|
|
3640
|
-
|
|
3641
|
-
raise HTTPException(status_code=400, detail=f"{provider} μμ§ λͺ¨λΈ λ€μ΄λ‘λλ μμ§ μλνλμ§ μμμ΅λλ€.")
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
@app.post("/engines/prepare-model")
|
|
3645
|
-
async def engines_prepare_model(req: PrepareModelRequest, request: Request):
|
|
3646
|
-
require_user(request)
|
|
3647
|
-
return await prepare_and_load_model(
|
|
3648
|
-
req.model,
|
|
3649
|
-
request,
|
|
3650
|
-
engine=req.engine,
|
|
3651
|
-
user_email=req.user_email,
|
|
3652
|
-
)
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
@app.post("/engines/prepare-model/stream")
|
|
3656
|
-
async def engines_prepare_model_stream(req: PrepareModelRequest, request: Request):
|
|
3657
|
-
require_user(request)
|
|
3658
|
-
|
|
3659
|
-
async def event_stream():
|
|
3660
|
-
try:
|
|
3661
|
-
async for chunk in prepare_and_load_model_stream(
|
|
3662
|
-
req.model,
|
|
3663
|
-
request,
|
|
3664
|
-
engine=req.engine,
|
|
3665
|
-
user_email=req.user_email,
|
|
3666
|
-
):
|
|
3667
|
-
yield chunk
|
|
3668
|
-
except HTTPException as exc:
|
|
3669
|
-
yield sse_event("error", {
|
|
3670
|
-
"status_code": exc.status_code,
|
|
3671
|
-
"detail": exc.detail or "λͺ¨λΈ μ€λΉμ μ€ν¨νμ΅λλ€.",
|
|
3672
|
-
})
|
|
3673
|
-
except Exception as exc:
|
|
3674
|
-
logging.exception("model prepare stream failed")
|
|
3675
|
-
yield sse_event("error", {
|
|
3676
|
-
"status_code": 500,
|
|
3677
|
-
"detail": str(exc)[-1000:] or "λͺ¨λΈ μ€λΉμ μ€ν¨νμ΅λλ€.",
|
|
3678
|
-
})
|
|
3679
|
-
|
|
3680
|
-
return StreamingResponse(
|
|
3681
|
-
event_stream(),
|
|
3682
|
-
media_type="text/event-stream",
|
|
3683
|
-
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
3684
|
-
)
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
@app.post("/setup/set-api-key")
|
|
3688
|
-
async def set_api_key(req: SetApiKeyRequest, request: Request):
|
|
3689
|
-
from llm_router import OPENAI_COMPATIBLE_PROVIDERS
|
|
3690
|
-
config = OPENAI_COMPATIBLE_PROVIDERS.get(req.provider)
|
|
3691
|
-
if not config:
|
|
3692
|
-
raise HTTPException(status_code=400, detail="μ μ μλ νλ‘λ°μ΄λμ
λλ€.")
|
|
3693
|
-
if not req.key.strip():
|
|
3694
|
-
raise HTTPException(status_code=400, detail="API ν€κ° λΉμ΄μμ΅λλ€.")
|
|
3695
|
-
current_user = get_current_user(request)
|
|
3696
|
-
if REQUIRE_AUTH and not current_user:
|
|
3697
|
-
raise HTTPException(status_code=401, detail="μΈμ¦μ΄ νμν©λλ€.")
|
|
3698
|
-
# req.user_email μ ν΅ν ν κ³μ μμ‘°λ₯Ό λ°©μ§: κ΄λ¦¬μκ° μλλ©΄ λ³ΈμΈ μ΄λ©μΌλ§ νμ©
|
|
3699
|
-
if req.user_email and req.user_email != current_user:
|
|
3700
|
-
users = load_users()
|
|
3701
|
-
if get_user_role(current_user or "", users) != "admin":
|
|
3702
|
-
raise HTTPException(status_code=403, detail="λ€λ₯Έ μ¬μ©μμ API ν€λ₯Ό μ€μ ν κΆνμ΄ μμ΅λλ€.")
|
|
3703
|
-
target_email = (req.user_email or current_user or "").strip()
|
|
3704
|
-
if not target_email:
|
|
3705
|
-
raise HTTPException(status_code=400, detail="μ¬μ©μ μλ³μ΄ νμν©λλ€. λ‘κ·ΈμΈ ν λ€μ μλνμΈμ.")
|
|
3706
|
-
set_user_api_key(target_email, req.provider, req.key.strip())
|
|
3707
|
-
return {"ok": True, "provider": req.provider, "user_email": target_email, "scope": "user"}
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
def _recommended_with_engine_options(items: List[Dict[str, object]]) -> List[Dict[str, object]]:
|
|
3711
|
-
"""νΌλλ°± #1: μΆμ² λͺ¨λΈμ μμ§λ³ μ νμ§(engine_options)λ₯Ό λΆμ¬ λ΄λ €μ€λ€.
|
|
3712
|
-
|
|
3713
|
-
νλ‘ νΈμμ μΆμ² μΉ΄λλ₯Ό λλ₯΄λ μκ° μ΄λ μμ§/μ€μ λͺ¨λΈλ‘ λ€μ΄λ‘λ/λ‘λν μ§κ°
|
|
3714
|
-
μ΄λ―Έ νμ λλλ‘ νλ€.
|
|
3715
|
-
"""
|
|
3716
|
-
out: List[Dict[str, object]] = []
|
|
3717
|
-
for item in items:
|
|
3718
|
-
base = {
|
|
3719
|
-
"id": item["id"],
|
|
3720
|
-
"name": item["name"],
|
|
3721
|
-
"tag": item["tag"],
|
|
3722
|
-
"size": item["size"],
|
|
3723
|
-
"display_name": item.get("name") or item.get("id"),
|
|
3724
|
-
}
|
|
3725
|
-
short_id = str(item["id"]).lower()
|
|
3726
|
-
aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
|
|
3727
|
-
options: List[Dict[str, str]] = []
|
|
3728
|
-
for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
|
|
3729
|
-
real = aliases.get(engine_name)
|
|
3730
|
-
if not real:
|
|
3731
|
-
continue
|
|
3732
|
-
options.append({
|
|
3733
|
-
"engine": engine_name,
|
|
3734
|
-
"model_id": real,
|
|
3735
|
-
"load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
|
|
3736
|
-
})
|
|
3737
|
-
# μ΄λ μμ§λ aliasκ° μμΌλ©΄ local_mlx μΉ΄νλ‘κ·Έ μ체λ₯Ό μ¬μ©νλ€.
|
|
3738
|
-
if not options:
|
|
3739
|
-
options.append({
|
|
3740
|
-
"engine": "local_mlx",
|
|
3741
|
-
"model_id": item["id"],
|
|
3742
|
-
"load_id": item["id"],
|
|
3743
|
-
})
|
|
3744
|
-
base["engine_options"] = options
|
|
3745
|
-
base["recommended_engine"] = options[0]["engine"]
|
|
3746
|
-
out.append(base)
|
|
3747
|
-
return out
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
@app.get("/models")
|
|
3751
|
-
async def list_models():
|
|
3752
|
-
"""HuggingFace μΆμ² λͺ¨λΈ λͺ©λ‘ λ° λ‘λ μν λ°ν"""
|
|
3753
|
-
recommended = _recommended_with_engine_options(
|
|
3754
|
-
list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", [])))
|
|
3755
|
-
)
|
|
3756
|
-
return {
|
|
3757
|
-
"recommended": recommended,
|
|
3758
|
-
"cloud": router.detected_cloud_models(),
|
|
3759
|
-
"engines": await asyncio.to_thread(engine_status),
|
|
3760
|
-
"loaded": router.loaded_model_ids,
|
|
3761
|
-
"current": router.current_model_id,
|
|
3762
|
-
"compat_profiles": _list_compat_profiles(),
|
|
3763
|
-
}
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
@app.get("/models/compat-profiles")
|
|
3767
|
-
async def list_model_compat_profiles(request: Request):
|
|
3768
|
-
"""νΌλλ°± #3: Model Compatibility Layer μΊμ μνλ₯Ό μ‘°ννλ€."""
|
|
3769
|
-
require_user(request)
|
|
3770
|
-
return {"profiles": _list_compat_profiles()}
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
# ββ Model Management βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
3774
|
-
|
|
3775
|
-
@app.post("/models/load")
|
|
3776
|
-
async def load_model(req: LoadModelRequest, request: Request):
|
|
3777
|
-
"""λͺ¨λΈ λ‘λ (μ΄λ―Έ λ‘λλμΌλ©΄ μΊμμμ μ¦μ λ°ν)"""
|
|
3778
|
-
try:
|
|
3779
|
-
model_id = req.model_id
|
|
3780
|
-
requested_engine = req.engine or (model_id.split(":", 1)[0] if ":" in model_id else "local_mlx")
|
|
3781
|
-
if IS_PUBLIC_MODE and not ALLOW_LOCAL_MODELS and requested_engine in {"local_mlx", "mlx"}:
|
|
3782
|
-
raise HTTPException(
|
|
3783
|
-
status_code=400,
|
|
3784
|
-
detail="Public mode blocks local MLX model loading. Use openai:, openrouter:, groq:, together:, or set LATTICEAI_ALLOW_LOCAL_MODELS=true.",
|
|
3785
|
-
)
|
|
3786
|
-
return await prepare_and_load_model(
|
|
3787
|
-
model_id,
|
|
3788
|
-
request,
|
|
3789
|
-
engine=req.engine,
|
|
3790
|
-
user_email=req.user_email,
|
|
3791
|
-
adapter_path=req.adapter_path,
|
|
3792
|
-
draft_model_id=req.draft_model_id,
|
|
3793
|
-
)
|
|
3794
|
-
except HTTPException:
|
|
3795
|
-
raise
|
|
3796
|
-
except Exception as e:
|
|
3797
|
-
raise HTTPException(status_code=500, detail=str(e))
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
@app.post("/models/switch/{model_id:path}")
|
|
3801
|
-
async def switch_model(model_id: str, request: Request):
|
|
3802
|
-
"""μ΄λ―Έ λ‘λλ λͺ¨λΈ μ€ νμ± λͺ¨λΈ μ ν (μ¦μ, μ¬λ‘λ μμ)"""
|
|
3803
|
-
require_user(request)
|
|
3804
|
-
try:
|
|
3805
|
-
router.switch_model(model_id)
|
|
3806
|
-
return {"status": "ok", "current": router.current_model_id}
|
|
3807
|
-
except KeyError:
|
|
3808
|
-
raise HTTPException(status_code=404, detail=f"Model '{model_id}' not loaded. Call /models/load first.")
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
@app.delete("/models/unload/{model_id:path}")
|
|
3812
|
-
async def unload_model(model_id: str, request: Request):
|
|
3813
|
-
"""λͺ¨λΈ μΈλ‘λ β λ©λͺ¨λ¦¬ ν΄μ """
|
|
3814
|
-
require_user(request)
|
|
3815
|
-
router.unload_model(model_id)
|
|
3816
|
-
return {"status": "ok", "unloaded": model_id}
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
@app.delete("/models/unload-all")
|
|
3820
|
-
async def unload_all_models(request: Request):
|
|
3821
|
-
"""λ‘λλ λͺ¨λ λͺ¨λΈ μΈλ‘λ β λ©λͺ¨λ¦¬ ν΄μ """
|
|
3822
|
-
require_user(request)
|
|
3823
|
-
unloaded = router.loaded_model_ids
|
|
3824
|
-
router.unload_all()
|
|
3825
|
-
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
|
+
))
|
|
3826
3559
|
|
|
3827
3560
|
|
|
3828
3561
|
# ββ Chat / Completion ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -5466,324 +5199,24 @@ async def tools_permissions(request: Request):
|
|
|
5466
5199
|
return {"status": "ok", "permissions": list_tool_permissions()}
|
|
5467
5200
|
|
|
5468
5201
|
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
"rollback": policy["rollback"],
|
|
5488
|
-
},
|
|
5489
|
-
})
|
|
5490
|
-
return {
|
|
5491
|
-
"status": "ok",
|
|
5492
|
-
"workspace": str(AGENT_ROOT),
|
|
5493
|
-
"installed_mcps": [mcp_public_item(item, installed) for item in registry],
|
|
5494
|
-
"tools": tools,
|
|
5495
|
-
}
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
@app.post("/mcp/recommend")
|
|
5499
|
-
async def mcp_recommend(req: McpRecommendRequest, request: Request):
|
|
5500
|
-
require_user(request)
|
|
5501
|
-
return {"recommendations": await recommend_mcps(req.query, req.limit)}
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
@app.post("/mcp/install")
|
|
5505
|
-
async def mcp_install(req: McpInstallRequest, request: Request):
|
|
5506
|
-
admin_email, _ = require_admin(request)
|
|
5507
|
-
append_audit_event("mcp_install", user_email=admin_email, mcp_id=req.mcp_id)
|
|
5508
|
-
return await install_mcp(req.mcp_id)
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
@app.get("/mcp/installed")
|
|
5512
|
-
async def mcp_installed(request: Request):
|
|
5513
|
-
require_user(request)
|
|
5514
|
-
installed = load_mcp_installs().get("installed", {})
|
|
5515
|
-
registry = await _get_combined_registry()
|
|
5516
|
-
return {"installed": [mcp_public_item(item, installed) for item in registry]}
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
@app.get("/mcp/connectors/{mcp_id}")
|
|
5520
|
-
async def mcp_connector(mcp_id: str, request: Request):
|
|
5521
|
-
require_user(request)
|
|
5522
|
-
registry = await _get_combined_registry()
|
|
5523
|
-
item = next((e for e in registry if e["id"] == mcp_id), None)
|
|
5524
|
-
if not item or item.get("install_mode") != "connector":
|
|
5525
|
-
raise HTTPException(status_code=404, detail="컀λ₯ν°λ₯Ό μ°Ύμ μ μμ΅λλ€.")
|
|
5526
|
-
installed = load_mcp_installs().get("installed", {})
|
|
5527
|
-
public = mcp_public_item(item, installed)
|
|
5528
|
-
public["instructions"] = [
|
|
5529
|
-
"Codex λλ ChatGPT μ±μ Connectors μ€μ μ μ½λλ€.",
|
|
5530
|
-
f"{item['name']} νλͺ©μ μ ννκ³ κ³μ μ μΈμ¦ν©λλ€.",
|
|
5531
|
-
"μΈμ¦ ν Lattice AIμμ μ΄ MCPλ₯Ό λ€μ νμ±ννλ©΄ μμ
μ μ¬μ©ν μ μμ΅λλ€.",
|
|
5532
|
-
]
|
|
5533
|
-
return public
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
@app.post("/mcp/registry/refresh")
|
|
5537
|
-
async def mcp_registry_refresh(request: Request):
|
|
5538
|
-
require_user(request)
|
|
5539
|
-
mcp_registry._REMOTE_REGISTRY_FETCHED_AT = None
|
|
5540
|
-
registry = await _get_combined_registry()
|
|
5541
|
-
return {"status": "ok", "total": len(registry), "remote": len(mcp_registry._REMOTE_REGISTRY_CACHE)}
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
@app.get("/mcp/claude-code-servers")
|
|
5545
|
-
async def mcp_claude_code_servers(request: Request):
|
|
5546
|
-
"""Read ~/.claude/settings.json mcpServers and return them as Lattice MCP items."""
|
|
5547
|
-
require_user(request)
|
|
5548
|
-
settings_path = Path.home() / ".claude" / "settings.json"
|
|
5549
|
-
if not settings_path.exists():
|
|
5550
|
-
return {"servers": []}
|
|
5551
|
-
try:
|
|
5552
|
-
with open(settings_path, "r", encoding="utf-8") as f:
|
|
5553
|
-
settings = json.load(f)
|
|
5554
|
-
mcp_servers = settings.get("mcpServers", {})
|
|
5555
|
-
servers = []
|
|
5556
|
-
for name, cfg in mcp_servers.items():
|
|
5557
|
-
cmd = cfg.get("command", "")
|
|
5558
|
-
args = cfg.get("args", [])
|
|
5559
|
-
package = " ".join([cmd] + args) if args else cmd
|
|
5560
|
-
env = cfg.get("env", {})
|
|
5561
|
-
env_vars = [{"name": k, "value": v} for k, v in env.items()]
|
|
5562
|
-
servers.append({
|
|
5563
|
-
"id": f"claude-code:{name}",
|
|
5564
|
-
"name": name,
|
|
5565
|
-
"description": f"Claude Code MCP: {package}",
|
|
5566
|
-
"package": package,
|
|
5567
|
-
"icon": "π€",
|
|
5568
|
-
"category": "Claude Code",
|
|
5569
|
-
"source": "claude-code",
|
|
5570
|
-
"installed": True,
|
|
5571
|
-
"env_vars": env_vars,
|
|
5572
|
-
})
|
|
5573
|
-
return {"servers": servers}
|
|
5574
|
-
except Exception as e:
|
|
5575
|
-
logging.warning("mcp_claude_code_servers failed: %s", e)
|
|
5576
|
-
return {"servers": []}
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
_CUSTOM_MCP_FILE = DATA_DIR / "custom_mcps.json"
|
|
5580
|
-
|
|
5581
|
-
def _load_custom_mcps() -> List[Dict]:
|
|
5582
|
-
if not _CUSTOM_MCP_FILE.exists():
|
|
5583
|
-
return []
|
|
5584
|
-
try:
|
|
5585
|
-
with open(_CUSTOM_MCP_FILE, "r", encoding="utf-8") as f:
|
|
5586
|
-
return json.load(f)
|
|
5587
|
-
except Exception:
|
|
5588
|
-
return []
|
|
5589
|
-
|
|
5590
|
-
def _save_custom_mcps(items: List[Dict]):
|
|
5591
|
-
with open(_CUSTOM_MCP_FILE, "w", encoding="utf-8") as f:
|
|
5592
|
-
json.dump(items, f, ensure_ascii=False, indent=2)
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
@app.get("/mcp/custom")
|
|
5596
|
-
async def mcp_custom_list(request: Request):
|
|
5597
|
-
"""Return user-added custom MCP entries."""
|
|
5598
|
-
require_user(request)
|
|
5599
|
-
return {"custom": _load_custom_mcps()}
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
@app.post("/mcp/custom")
|
|
5603
|
-
async def mcp_custom_add(req: McpCustomRequest, request: Request):
|
|
5604
|
-
"""Save a custom MCP entry (admin-only)."""
|
|
5605
|
-
admin_email, _ = require_admin(request)
|
|
5606
|
-
append_audit_event("mcp_custom_add", user_email=admin_email, name=req.name, package=req.package)
|
|
5607
|
-
if not req.name.strip():
|
|
5608
|
-
raise HTTPException(status_code=400, detail="nameμ νμμ
λλ€.")
|
|
5609
|
-
if not req.package.strip():
|
|
5610
|
-
raise HTTPException(status_code=400, detail="packageλ νμμ
λλ€.")
|
|
5611
|
-
items = _load_custom_mcps()
|
|
5612
|
-
entry = {
|
|
5613
|
-
"id": f"custom:{req.name.strip().lower().replace(' ', '-')}",
|
|
5614
|
-
"name": req.name.strip(),
|
|
5615
|
-
"package": req.package.strip(),
|
|
5616
|
-
"description": req.description.strip(),
|
|
5617
|
-
"category": req.category or "custom",
|
|
5618
|
-
"icon": req.icon or "π",
|
|
5619
|
-
"env_vars": req.env_vars or [],
|
|
5620
|
-
"install_mode": "npm",
|
|
5621
|
-
"source": "custom",
|
|
5622
|
-
"installed": False,
|
|
5623
|
-
"added_at": datetime.now().isoformat(),
|
|
5624
|
-
}
|
|
5625
|
-
# overwrite if same id
|
|
5626
|
-
items = [e for e in items if e["id"] != entry["id"]]
|
|
5627
|
-
items.append(entry)
|
|
5628
|
-
_save_custom_mcps(items)
|
|
5629
|
-
return {"status": "ok", "entry": entry}
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
@app.delete("/mcp/custom/{mcp_id:path}")
|
|
5633
|
-
async def mcp_custom_delete(mcp_id: str, request: Request):
|
|
5634
|
-
"""Remove a custom MCP entry."""
|
|
5635
|
-
require_admin(request)
|
|
5636
|
-
items = _load_custom_mcps()
|
|
5637
|
-
before = len(items)
|
|
5638
|
-
items = [e for e in items if e["id"] != mcp_id]
|
|
5639
|
-
if len(items) == before:
|
|
5640
|
-
raise HTTPException(status_code=404, detail="νλͺ©μ μ°Ύμ μ μμ΅λλ€.")
|
|
5641
|
-
_save_custom_mcps(items)
|
|
5642
|
-
return {"status": "ok"}
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
# ββ Skills & Plugin Directory endpoints βββββββββββββββββββββββββββββββββββββββ
|
|
5646
|
-
|
|
5647
|
-
@app.get("/skills/marketplace")
|
|
5648
|
-
async def skills_marketplace(request: Request, category: Optional[str] = None, author: Optional[str] = None):
|
|
5649
|
-
"""Skills λ§μΌνλ μ΄μ€ (Anthropic Apache-2.0 + κ²μ¦λ μλνν° MIT/Apache-2.0)"""
|
|
5650
|
-
require_user(request)
|
|
5651
|
-
skills = await _fetch_skills_marketplace()
|
|
5652
|
-
installed_names = {d.name for d in SKILLS_DIR.iterdir() if d.is_dir()} if SKILLS_DIR.exists() else set()
|
|
5653
|
-
filtered = skills
|
|
5654
|
-
if category:
|
|
5655
|
-
filtered = [s for s in filtered if s.get("category", "").lower() == category.lower()]
|
|
5656
|
-
if author:
|
|
5657
|
-
filtered = [s for s in filtered if s.get("author", "").lower() == author.lower()]
|
|
5658
|
-
return {
|
|
5659
|
-
"skills": [{**s, "installed": s["skill"] in installed_names} for s in filtered],
|
|
5660
|
-
"total": len(filtered),
|
|
5661
|
-
"authors": sorted({s["author"] for s in skills}),
|
|
5662
|
-
"categories": sorted({s["category"] for s in skills}),
|
|
5663
|
-
}
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
@app.post("/skills/install")
|
|
5667
|
-
async def skills_install(req: SkillInstallRequest, request: Request):
|
|
5668
|
-
"""skillμ λ‘컬 skills λλ ν°λ¦¬μ μ€μΉ (Apache-2.0 / MIT, κ΄λ¦¬μ μ μ©)"""
|
|
5669
|
-
admin_email, _ = require_admin(request)
|
|
5670
|
-
append_audit_event("skill_install", user_email=admin_email, plugin=req.plugin, skill=req.skill)
|
|
5671
|
-
return await install_skill(req.plugin, req.skill)
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
@app.get("/skills/list")
|
|
5675
|
-
async def skills_list(request: Request):
|
|
5676
|
-
"""λ‘컬μ μ€μΉλ skills λͺ©λ‘"""
|
|
5677
|
-
require_user(request)
|
|
5678
|
-
if not SKILLS_DIR.exists():
|
|
5679
|
-
return {"skills": []}
|
|
5680
|
-
skills = []
|
|
5681
|
-
for skill_dir in sorted(SKILLS_DIR.iterdir()):
|
|
5682
|
-
if not skill_dir.is_dir():
|
|
5683
|
-
continue
|
|
5684
|
-
skill_md = skill_dir / "SKILL.md"
|
|
5685
|
-
if not skill_md.exists():
|
|
5686
|
-
continue
|
|
5687
|
-
lines = skill_md.read_text(encoding="utf-8").splitlines()
|
|
5688
|
-
desc = next((l.split(":", 1)[1].strip() for l in lines if l.startswith("description:")), "")
|
|
5689
|
-
comment = lines[0] if lines else ""
|
|
5690
|
-
if "anthropics/claude-plugins-official" in comment:
|
|
5691
|
-
source = "anthropic"
|
|
5692
|
-
elif "Source:" in comment:
|
|
5693
|
-
source = "third-party"
|
|
5694
|
-
else:
|
|
5695
|
-
source = "local"
|
|
5696
|
-
skills.append({"name": skill_dir.name, "description": desc, "source": source})
|
|
5697
|
-
return {"skills": skills, "total": len(skills)}
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
@app.post("/skills/marketplace/refresh")
|
|
5701
|
-
async def skills_marketplace_refresh(request: Request):
|
|
5702
|
-
"""Skills λ§μΌνλ μ΄μ€ μΊμ κ°μ κ°±μ """
|
|
5703
|
-
require_user(request)
|
|
5704
|
-
mcp_registry._SKILLS_MARKETPLACE_FETCHED_AT = None
|
|
5705
|
-
skills = await _fetch_skills_marketplace()
|
|
5706
|
-
by_author = {}
|
|
5707
|
-
for s in skills:
|
|
5708
|
-
by_author[s["author"]] = by_author.get(s["author"], 0) + 1
|
|
5709
|
-
return {"status": "ok", "total": len(skills), "by_author": by_author}
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
@app.get("/plugins/directory")
|
|
5713
|
-
async def plugins_directory(
|
|
5714
|
-
request: Request,
|
|
5715
|
-
category: Optional[str] = None,
|
|
5716
|
-
license: Optional[str] = None,
|
|
5717
|
-
q: Optional[str] = None,
|
|
5718
|
-
):
|
|
5719
|
-
"""μ€νμμ€ νλ¬κ·ΈμΈ λλ ν°λ¦¬ (Apache-2.0 / MIT / MIT-0, λ°νμ fetch)"""
|
|
5720
|
-
require_user(request)
|
|
5721
|
-
plugins = await _fetch_plugin_directory()
|
|
5722
|
-
filtered = plugins
|
|
5723
|
-
if category:
|
|
5724
|
-
filtered = [p for p in filtered if p.get("category", "").lower() == category.lower()]
|
|
5725
|
-
if license:
|
|
5726
|
-
filtered = [p for p in filtered if p.get("license", "").lower() == license.lower()]
|
|
5727
|
-
if q:
|
|
5728
|
-
q_lower = q.lower()
|
|
5729
|
-
filtered = [
|
|
5730
|
-
p for p in filtered
|
|
5731
|
-
if q_lower in p.get("name", "").lower()
|
|
5732
|
-
or q_lower in p.get("description", "").lower()
|
|
5733
|
-
or q_lower in p.get("author", "").lower()
|
|
5734
|
-
]
|
|
5735
|
-
return {
|
|
5736
|
-
"plugins": filtered,
|
|
5737
|
-
"total": len(filtered),
|
|
5738
|
-
"categories": sorted({p["category"] for p in plugins if p.get("category")}),
|
|
5739
|
-
"licenses": sorted({p["license"] for p in plugins if p.get("license")}),
|
|
5740
|
-
}
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
@app.post("/plugins/directory/refresh")
|
|
5744
|
-
async def plugins_directory_refresh(request: Request):
|
|
5745
|
-
"""νλ¬κ·ΈμΈ λλ ν°λ¦¬ μΊμ κ°μ κ°±μ """
|
|
5746
|
-
require_user(request)
|
|
5747
|
-
mcp_registry._PLUGIN_DIRECTORY_FETCHED_AT = None
|
|
5748
|
-
plugins = await _fetch_plugin_directory()
|
|
5749
|
-
by_license = {}
|
|
5750
|
-
for p in plugins:
|
|
5751
|
-
lic = p.get("license", "unknown")
|
|
5752
|
-
by_license[lic] = by_license.get(lic, 0) + 1
|
|
5753
|
-
return {"status": "ok", "total": len(plugins), "by_license": by_license}
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
@app.post("/mcp/call")
|
|
5757
|
-
async def mcp_call(req: McpCallRequest, request: Request):
|
|
5758
|
-
current_user = require_user(request)
|
|
5759
|
-
args = req.args or {}
|
|
5760
|
-
if req.action == "knowledge_graph_ingest":
|
|
5761
|
-
_require_graph()
|
|
5762
|
-
return KNOWLEDGE_GRAPH.ingest_message(
|
|
5763
|
-
args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
|
|
5764
|
-
args.get("content") or "",
|
|
5765
|
-
user_email=args.get("user_email") or current_user,
|
|
5766
|
-
user_nickname=args.get("user_nickname"),
|
|
5767
|
-
source=args.get("source") or "mcp",
|
|
5768
|
-
conversation_id=args.get("conversation_id"),
|
|
5769
|
-
raw=args,
|
|
5770
|
-
)
|
|
5771
|
-
if req.action == "knowledge_graph_search":
|
|
5772
|
-
_require_graph()
|
|
5773
|
-
return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
|
|
5774
|
-
if req.action == "knowledge_graph_graph":
|
|
5775
|
-
_require_graph()
|
|
5776
|
-
return KNOWLEDGE_GRAPH.graph(args.get("limit", 300))
|
|
5777
|
-
if req.action == "knowledge_graph_context":
|
|
5778
|
-
_require_graph()
|
|
5779
|
-
return {
|
|
5780
|
-
"context": KNOWLEDGE_GRAPH.context_for_query(
|
|
5781
|
-
args.get("query") or args.get("q") or "",
|
|
5782
|
-
args.get("limit", 6),
|
|
5783
|
-
)
|
|
5784
|
-
}
|
|
5785
|
-
_check_tool_role(req.action, current_user)
|
|
5786
|
-
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
|
+
))
|
|
5787
5220
|
|
|
5788
5221
|
|
|
5789
5222
|
# ββ P-Reinforce Knowledge Gardener ββββββββββββββββββββββββββββββββββββββββββββ
|