ltcai 1.2.0 β†’ 1.4.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.
@@ -0,0 +1,386 @@
1
+ """MCP / skills / plugins API router.
2
+
3
+ Extracted from ``server_app.py`` in v1.3.0. Paths and schemas unchanged:
4
+ ``/mcp/*``, ``/skills/*``, ``/plugins/directory*``, and ``/mcp/call``.
5
+
6
+ Registry/tool symbols are imported directly from their owning modules
7
+ (``mcp_registry``, ``tools``, ``latticeai.core.tool_registry``); server_app-defined
8
+ helpers (auth, audit, tool governance/dispatch, KG) are injected, so there is no
9
+ import cycle.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Dict, List, Optional
19
+
20
+ from fastapi import APIRouter, HTTPException, Request
21
+ from pydantic import BaseModel
22
+
23
+ import mcp_registry
24
+ from mcp_registry import (
25
+ _get_combined_registry,
26
+ _fetch_skills_marketplace,
27
+ _fetch_plugin_directory,
28
+ install_skill,
29
+ SKILLS_DIR,
30
+ )
31
+ from latticeai.core.tool_registry import MCP_TOOL_DESCRIPTIONS
32
+ from tools import AGENT_ROOT, execute_tool
33
+
34
+
35
+ class McpRecommendRequest(BaseModel):
36
+ query: str
37
+ limit: int = 5
38
+
39
+
40
+ class McpInstallRequest(BaseModel):
41
+ mcp_id: str
42
+
43
+
44
+ class McpCustomRequest(BaseModel):
45
+ name: str
46
+ package: str
47
+ description: str = ""
48
+ category: str = "custom"
49
+ icon: str = "πŸ”Œ"
50
+ env_vars: List[Dict] = []
51
+
52
+
53
+ class SkillInstallRequest(BaseModel):
54
+ plugin: str
55
+ skill: str
56
+
57
+
58
+ class McpCallRequest(BaseModel):
59
+ action: str
60
+ args: Dict = {}
61
+
62
+
63
+ def create_mcp_router(
64
+ *,
65
+ require_user: Callable[[Request], str],
66
+ require_admin: Callable[[Request], Any],
67
+ append_audit_event: Callable[..., None],
68
+ load_mcp_installs: Callable[[], Dict],
69
+ recommend_mcps: Callable[..., Any],
70
+ install_mcp: Callable[..., Any],
71
+ mcp_public_item: Callable[[Dict, Dict], Dict],
72
+ get_tool_permission: Callable[..., Any],
73
+ tool_governance: Dict,
74
+ tool_governance_default: Any,
75
+ check_tool_role: Callable[[str, str], None],
76
+ tool_response: Callable[..., Any],
77
+ require_graph: Callable[[], Any],
78
+ knowledge_graph: Any,
79
+ data_dir: Path,
80
+ ) -> APIRouter:
81
+ router = APIRouter()
82
+
83
+ # Bind injected deps to the names the moved handler bodies expect.
84
+ TOOL_GOVERNANCE = tool_governance
85
+ _TOOL_GOVERNANCE_DEFAULT = tool_governance_default
86
+ _check_tool_role = check_tool_role
87
+ _tool_response = tool_response
88
+ _require_graph = require_graph
89
+ KNOWLEDGE_GRAPH = knowledge_graph
90
+
91
+ _CUSTOM_MCP_FILE = data_dir / "custom_mcps.json"
92
+
93
+ def _load_custom_mcps() -> List[Dict]:
94
+ if not _CUSTOM_MCP_FILE.exists():
95
+ return []
96
+ try:
97
+ with open(_CUSTOM_MCP_FILE, "r", encoding="utf-8") as f:
98
+ return json.load(f)
99
+ except Exception:
100
+ return []
101
+
102
+ def _save_custom_mcps(items: List[Dict]):
103
+ with open(_CUSTOM_MCP_FILE, "w", encoding="utf-8") as f:
104
+ json.dump(items, f, ensure_ascii=False, indent=2)
105
+
106
+ @router.get("/mcp/tools")
107
+ async def mcp_tools():
108
+ installed = load_mcp_installs().get("installed", {})
109
+ registry = await _get_combined_registry()
110
+ tools = []
111
+ for name, description in MCP_TOOL_DESCRIPTIONS.items():
112
+ policy = TOOL_GOVERNANCE.get(name, _TOOL_GOVERNANCE_DEFAULT)
113
+ tools.append({
114
+ "name": name,
115
+ "description": description,
116
+ "permission": get_tool_permission(name),
117
+ "governance": {
118
+ "risk": policy["risk"],
119
+ "destructive": policy["destructive"],
120
+ "shell": policy["shell"],
121
+ "network": policy["network"],
122
+ "auto_approve": policy["auto_approve"],
123
+ "sandbox": policy["sandbox"],
124
+ "rollback": policy["rollback"],
125
+ },
126
+ })
127
+ return {
128
+ "status": "ok",
129
+ "workspace": str(AGENT_ROOT),
130
+ "installed_mcps": [mcp_public_item(item, installed) for item in registry],
131
+ "tools": tools,
132
+ }
133
+
134
+ @router.post("/mcp/recommend")
135
+ async def mcp_recommend(req: McpRecommendRequest, request: Request):
136
+ require_user(request)
137
+ return {"recommendations": await recommend_mcps(req.query, req.limit)}
138
+
139
+ @router.post("/mcp/install")
140
+ async def mcp_install(req: McpInstallRequest, request: Request):
141
+ admin_email, _ = require_admin(request)
142
+ append_audit_event("mcp_install", user_email=admin_email, mcp_id=req.mcp_id)
143
+ return await install_mcp(req.mcp_id)
144
+
145
+ @router.get("/mcp/installed")
146
+ async def mcp_installed(request: Request):
147
+ require_user(request)
148
+ installed = load_mcp_installs().get("installed", {})
149
+ registry = await _get_combined_registry()
150
+ return {"installed": [mcp_public_item(item, installed) for item in registry]}
151
+
152
+ @router.get("/mcp/connectors/{mcp_id}")
153
+ async def mcp_connector(mcp_id: str, request: Request):
154
+ require_user(request)
155
+ registry = await _get_combined_registry()
156
+ item = next((e for e in registry if e["id"] == mcp_id), None)
157
+ if not item or item.get("install_mode") != "connector":
158
+ raise HTTPException(status_code=404, detail="컀λ„₯ν„°λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
159
+ installed = load_mcp_installs().get("installed", {})
160
+ public = mcp_public_item(item, installed)
161
+ public["instructions"] = [
162
+ "Codex λ˜λŠ” ChatGPT μ•±μ˜ Connectors 섀정을 μ—½λ‹ˆλ‹€.",
163
+ f"{item['name']} ν•­λͺ©μ„ μ„ νƒν•˜κ³  계정을 μΈμ¦ν•©λ‹ˆλ‹€.",
164
+ "인증 ν›„ Lattice AIμ—μ„œ 이 MCPλ₯Ό λ‹€μ‹œ ν™œμ„±ν™”ν•˜λ©΄ μž‘μ—…μ— μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.",
165
+ ]
166
+ return public
167
+
168
+ @router.post("/mcp/registry/refresh")
169
+ async def mcp_registry_refresh(request: Request):
170
+ require_user(request)
171
+ mcp_registry._REMOTE_REGISTRY_FETCHED_AT = None
172
+ registry = await _get_combined_registry()
173
+ return {"status": "ok", "total": len(registry), "remote": len(mcp_registry._REMOTE_REGISTRY_CACHE)}
174
+
175
+ @router.get("/mcp/claude-code-servers")
176
+ async def mcp_claude_code_servers(request: Request):
177
+ """Read ~/.claude/settings.json mcpServers and return them as Lattice MCP items."""
178
+ require_user(request)
179
+ settings_path = Path.home() / ".claude" / "settings.json"
180
+ if not settings_path.exists():
181
+ return {"servers": []}
182
+ try:
183
+ with open(settings_path, "r", encoding="utf-8") as f:
184
+ settings = json.load(f)
185
+ mcp_servers = settings.get("mcpServers", {})
186
+ servers = []
187
+ for name, cfg in mcp_servers.items():
188
+ cmd = cfg.get("command", "")
189
+ args = cfg.get("args", [])
190
+ package = " ".join([cmd] + args) if args else cmd
191
+ env = cfg.get("env", {})
192
+ env_vars = [{"name": k, "value": v} for k, v in env.items()]
193
+ servers.append({
194
+ "id": f"claude-code:{name}",
195
+ "name": name,
196
+ "description": f"Claude Code MCP: {package}",
197
+ "package": package,
198
+ "icon": "πŸ€–",
199
+ "category": "Claude Code",
200
+ "source": "claude-code",
201
+ "installed": True,
202
+ "env_vars": env_vars,
203
+ })
204
+ return {"servers": servers}
205
+ except Exception as e:
206
+ logging.warning("mcp_claude_code_servers failed: %s", e)
207
+ return {"servers": []}
208
+
209
+ @router.get("/mcp/custom")
210
+ async def mcp_custom_list(request: Request):
211
+ """Return user-added custom MCP entries."""
212
+ require_user(request)
213
+ return {"custom": _load_custom_mcps()}
214
+
215
+ @router.post("/mcp/custom")
216
+ async def mcp_custom_add(req: McpCustomRequest, request: Request):
217
+ """Save a custom MCP entry (admin-only)."""
218
+ admin_email, _ = require_admin(request)
219
+ append_audit_event("mcp_custom_add", user_email=admin_email, name=req.name, package=req.package)
220
+ if not req.name.strip():
221
+ raise HTTPException(status_code=400, detail="name은 ν•„μˆ˜μž…λ‹ˆλ‹€.")
222
+ if not req.package.strip():
223
+ raise HTTPException(status_code=400, detail="packageλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.")
224
+ items = _load_custom_mcps()
225
+ entry = {
226
+ "id": f"custom:{req.name.strip().lower().replace(' ', '-')}",
227
+ "name": req.name.strip(),
228
+ "package": req.package.strip(),
229
+ "description": req.description.strip(),
230
+ "category": req.category or "custom",
231
+ "icon": req.icon or "πŸ”Œ",
232
+ "env_vars": req.env_vars or [],
233
+ "install_mode": "npm",
234
+ "source": "custom",
235
+ "installed": False,
236
+ "added_at": datetime.now().isoformat(),
237
+ }
238
+ items = [e for e in items if e["id"] != entry["id"]]
239
+ items.append(entry)
240
+ _save_custom_mcps(items)
241
+ return {"status": "ok", "entry": entry}
242
+
243
+ @router.delete("/mcp/custom/{mcp_id:path}")
244
+ async def mcp_custom_delete(mcp_id: str, request: Request):
245
+ """Remove a custom MCP entry."""
246
+ require_admin(request)
247
+ items = _load_custom_mcps()
248
+ before = len(items)
249
+ items = [e for e in items if e["id"] != mcp_id]
250
+ if len(items) == before:
251
+ raise HTTPException(status_code=404, detail="ν•­λͺ©μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
252
+ _save_custom_mcps(items)
253
+ return {"status": "ok"}
254
+
255
+ # ── Skills & Plugin Directory ─────────────────────────────────────────
256
+
257
+ @router.get("/skills/marketplace")
258
+ async def skills_marketplace(request: Request, category: Optional[str] = None, author: Optional[str] = None):
259
+ require_user(request)
260
+ skills = await _fetch_skills_marketplace()
261
+ installed_names = {d.name for d in SKILLS_DIR.iterdir() if d.is_dir()} if SKILLS_DIR.exists() else set()
262
+ filtered = skills
263
+ if category:
264
+ filtered = [s for s in filtered if s.get("category", "").lower() == category.lower()]
265
+ if author:
266
+ filtered = [s for s in filtered if s.get("author", "").lower() == author.lower()]
267
+ return {
268
+ "skills": [{**s, "installed": s["skill"] in installed_names} for s in filtered],
269
+ "total": len(filtered),
270
+ "authors": sorted({s["author"] for s in skills}),
271
+ "categories": sorted({s["category"] for s in skills}),
272
+ }
273
+
274
+ @router.post("/skills/install")
275
+ async def skills_install(req: SkillInstallRequest, request: Request):
276
+ admin_email, _ = require_admin(request)
277
+ append_audit_event("skill_install", user_email=admin_email, plugin=req.plugin, skill=req.skill)
278
+ return await install_skill(req.plugin, req.skill)
279
+
280
+ @router.get("/skills/list")
281
+ async def skills_list(request: Request):
282
+ require_user(request)
283
+ if not SKILLS_DIR.exists():
284
+ return {"skills": []}
285
+ skills = []
286
+ for skill_dir in sorted(SKILLS_DIR.iterdir()):
287
+ if not skill_dir.is_dir():
288
+ continue
289
+ skill_md = skill_dir / "SKILL.md"
290
+ if not skill_md.exists():
291
+ continue
292
+ lines = skill_md.read_text(encoding="utf-8").splitlines()
293
+ desc = next((l.split(":", 1)[1].strip() for l in lines if l.startswith("description:")), "")
294
+ comment = lines[0] if lines else ""
295
+ if "anthropics/claude-plugins-official" in comment:
296
+ source = "anthropic"
297
+ elif "Source:" in comment:
298
+ source = "third-party"
299
+ else:
300
+ source = "local"
301
+ skills.append({"name": skill_dir.name, "description": desc, "source": source})
302
+ return {"skills": skills, "total": len(skills)}
303
+
304
+ @router.post("/skills/marketplace/refresh")
305
+ async def skills_marketplace_refresh(request: Request):
306
+ require_user(request)
307
+ mcp_registry._SKILLS_MARKETPLACE_FETCHED_AT = None
308
+ skills = await _fetch_skills_marketplace()
309
+ by_author = {}
310
+ for s in skills:
311
+ by_author[s["author"]] = by_author.get(s["author"], 0) + 1
312
+ return {"status": "ok", "total": len(skills), "by_author": by_author}
313
+
314
+ @router.get("/plugins/directory")
315
+ async def plugins_directory(
316
+ request: Request,
317
+ category: Optional[str] = None,
318
+ license: Optional[str] = None,
319
+ q: Optional[str] = None,
320
+ ):
321
+ require_user(request)
322
+ plugins = await _fetch_plugin_directory()
323
+ filtered = plugins
324
+ if category:
325
+ filtered = [p for p in filtered if p.get("category", "").lower() == category.lower()]
326
+ if license:
327
+ filtered = [p for p in filtered if p.get("license", "").lower() == license.lower()]
328
+ if q:
329
+ q_lower = q.lower()
330
+ filtered = [
331
+ p for p in filtered
332
+ if q_lower in p.get("name", "").lower()
333
+ or q_lower in p.get("description", "").lower()
334
+ or q_lower in p.get("author", "").lower()
335
+ ]
336
+ return {
337
+ "plugins": filtered,
338
+ "total": len(filtered),
339
+ "categories": sorted({p["category"] for p in plugins if p.get("category")}),
340
+ "licenses": sorted({p["license"] for p in plugins if p.get("license")}),
341
+ }
342
+
343
+ @router.post("/plugins/directory/refresh")
344
+ async def plugins_directory_refresh(request: Request):
345
+ require_user(request)
346
+ mcp_registry._PLUGIN_DIRECTORY_FETCHED_AT = None
347
+ plugins = await _fetch_plugin_directory()
348
+ by_license = {}
349
+ for p in plugins:
350
+ lic = p.get("license", "unknown")
351
+ by_license[lic] = by_license.get(lic, 0) + 1
352
+ return {"status": "ok", "total": len(plugins), "by_license": by_license}
353
+
354
+ @router.post("/mcp/call")
355
+ async def mcp_call(req: McpCallRequest, request: Request):
356
+ current_user = require_user(request)
357
+ args = req.args or {}
358
+ if req.action == "knowledge_graph_ingest":
359
+ _require_graph()
360
+ return KNOWLEDGE_GRAPH.ingest_message(
361
+ args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
362
+ args.get("content") or "",
363
+ user_email=args.get("user_email") or current_user,
364
+ user_nickname=args.get("user_nickname"),
365
+ source=args.get("source") or "mcp",
366
+ conversation_id=args.get("conversation_id"),
367
+ raw=args,
368
+ )
369
+ if req.action == "knowledge_graph_search":
370
+ _require_graph()
371
+ return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
372
+ if req.action == "knowledge_graph_graph":
373
+ _require_graph()
374
+ return KNOWLEDGE_GRAPH.graph(args.get("limit", 300))
375
+ if req.action == "knowledge_graph_context":
376
+ _require_graph()
377
+ return {
378
+ "context": KNOWLEDGE_GRAPH.context_for_query(
379
+ args.get("query") or args.get("q") or "",
380
+ args.get("limit", 6),
381
+ )
382
+ }
383
+ _check_tool_role(req.action, current_user)
384
+ return _tool_response(execute_tool, req.action, req.args or {})
385
+
386
+ return router
@@ -0,0 +1,307 @@
1
+ """Model / engine API router.
2
+
3
+ Extracted from ``server_app.py`` in v1.3.0. Paths and schemas unchanged:
4
+ ``/models*``, ``/engines*`` (install/verify-cloud/pull-model/prepare-model[/stream]),
5
+ ``/setup/set-api-key``.
6
+
7
+ Mirrors the established router-factory convention: the heavy provider/runtime
8
+ helpers (engine_status, prepare_and_load_model, download_hf_model,
9
+ verify_cloud_models, …) remain owned by server_app for now and are injected here
10
+ as callables, so this module has no import cycle and adds no import-time
11
+ side effects.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ import subprocess
19
+ from typing import Any, Callable, Dict, List, Optional
20
+
21
+ from fastapi import APIRouter, HTTPException, Request
22
+ from fastapi.responses import StreamingResponse
23
+ from pydantic import BaseModel
24
+
25
+
26
+ class LoadModelRequest(BaseModel):
27
+ model_id: str
28
+ engine: Optional[str] = None
29
+ user_email: Optional[str] = None
30
+ adapter_path: Optional[str] = None
31
+ draft_model_id: Optional[str] = None
32
+
33
+
34
+ class InstallEngineRequest(BaseModel):
35
+ engine: str
36
+
37
+
38
+ class SetApiKeyRequest(BaseModel):
39
+ provider: str
40
+ key: str
41
+ user_email: Optional[str] = None
42
+
43
+
44
+ class PullModelRequest(BaseModel):
45
+ model: str
46
+
47
+
48
+ class PrepareModelRequest(BaseModel):
49
+ model: str
50
+ engine: Optional[str] = None
51
+ user_email: Optional[str] = None
52
+
53
+
54
+ class VerifyCloudRequest(BaseModel):
55
+ force: bool = False
56
+ provider: Optional[str] = None
57
+
58
+
59
+ def create_models_router(
60
+ *,
61
+ model_router: Any,
62
+ require_user: Callable[[Request], str],
63
+ get_current_user: Callable[[Request], Optional[str]],
64
+ load_users: Callable[[], Dict],
65
+ get_user_role: Callable[..., str],
66
+ install_engine: Callable[[str], Dict],
67
+ verify_cloud_models: Callable[..., Any],
68
+ normalize_local_model_request: Callable[..., str],
69
+ download_hf_model: Callable[..., Dict],
70
+ prepare_and_load_model: Callable[..., Any],
71
+ prepare_and_load_model_stream: Callable[..., Any],
72
+ sse_event: Callable[[str, Dict], str],
73
+ ensure_ollama_server: Callable[[], None],
74
+ local_binary: Callable[[str], Optional[str]],
75
+ engine_status: Callable[[], List[Dict]],
76
+ filter_lower_family_versions: Callable[[List[Dict]], List[Dict]],
77
+ list_compat_profiles: Callable[[], Any],
78
+ set_user_api_key: Callable[..., None],
79
+ engine_model_catalog: Dict,
80
+ model_engine_aliases: Dict,
81
+ cloud_verify_ttl_seconds: int,
82
+ is_public_mode: bool,
83
+ allow_local_models: bool,
84
+ require_auth: bool,
85
+ ) -> APIRouter:
86
+ router = APIRouter()
87
+ # Bind injected deps to the names the moved handler bodies expect.
88
+ _router = model_router
89
+ ENGINE_MODEL_CATALOG = engine_model_catalog
90
+ MODEL_ENGINE_ALIASES = model_engine_aliases
91
+ CLOUD_VERIFY_TTL_SECONDS = cloud_verify_ttl_seconds
92
+ IS_PUBLIC_MODE = is_public_mode
93
+ ALLOW_LOCAL_MODELS = allow_local_models
94
+ REQUIRE_AUTH = require_auth
95
+ _list_compat_profiles = list_compat_profiles
96
+
97
+ def _recommended_with_engine_options(items: List[Dict[str, object]]) -> List[Dict[str, object]]:
98
+ out: List[Dict[str, object]] = []
99
+ for item in items:
100
+ base = {
101
+ "id": item["id"],
102
+ "name": item["name"],
103
+ "tag": item["tag"],
104
+ "size": item["size"],
105
+ "display_name": item.get("name") or item.get("id"),
106
+ }
107
+ short_id = str(item["id"]).lower()
108
+ aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
109
+ options: List[Dict[str, str]] = []
110
+ for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
111
+ real = aliases.get(engine_name)
112
+ if not real:
113
+ continue
114
+ options.append({
115
+ "engine": engine_name,
116
+ "model_id": real,
117
+ "load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
118
+ })
119
+ if not options:
120
+ options.append({"engine": "local_mlx", "model_id": item["id"], "load_id": item["id"]})
121
+ base["engine_options"] = options
122
+ base["recommended_engine"] = options[0]["engine"]
123
+ out.append(base)
124
+ return out
125
+
126
+ # ── Engines ───────────────────────────────────────────────────────────
127
+
128
+ @router.post("/engines/install")
129
+ async def engines_install(req: InstallEngineRequest, request: Request):
130
+ require_user(request)
131
+ return install_engine(req.engine)
132
+
133
+ @router.post("/engines/verify-cloud")
134
+ async def engines_verify_cloud(req: VerifyCloudRequest, request: Request):
135
+ require_user(request)
136
+ results = await verify_cloud_models(force=req.force, provider_filter=req.provider)
137
+ return {"verified": results, "ttl_seconds": CLOUD_VERIFY_TTL_SECONDS}
138
+
139
+ @router.post("/engines/pull-model")
140
+ async def pull_ollama_model(req: PullModelRequest, request: Request):
141
+ require_user(request)
142
+ model_ref = normalize_local_model_request(req.model, None)
143
+ if not model_ref:
144
+ raise HTTPException(status_code=400, detail="λͺ¨λΈ μ‹λ³„μžκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€.")
145
+
146
+ if ":" in model_ref and model_ref.split(":", 1)[0].strip().lower() in {"ollama", "vllm", "lmstudio", "llamacpp", "local_mlx", "mlx"}:
147
+ provider, model_name = model_ref.split(":", 1)
148
+ provider = provider.strip().lower()
149
+ model_name = model_name.strip()
150
+ else:
151
+ provider, model_name = "local_mlx", model_ref
152
+
153
+ if not model_name:
154
+ raise HTTPException(status_code=400, detail="λͺ¨λΈ 이름이 λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€.")
155
+
156
+ if provider == "ollama":
157
+ ensure_ollama_server()
158
+ ollama = local_binary("ollama")
159
+ if not ollama:
160
+ raise HTTPException(status_code=400, detail="Ollamaκ°€ μ„€μΉ˜λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
161
+ try:
162
+ completed = subprocess.run(
163
+ [ollama, "pull", model_name],
164
+ capture_output=True, text=True, timeout=900, check=False,
165
+ )
166
+ except subprocess.TimeoutExpired:
167
+ raise HTTPException(status_code=408, detail="λͺ¨λΈ λ‹€μš΄λ‘œλ“œ μ‹œκ°„μ΄ μ΄ˆκ³Όλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
168
+ if completed.returncode != 0:
169
+ raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "pull μ‹€νŒ¨")
170
+ return {"provider": provider, "model": model_name, "returncode": completed.returncode}
171
+
172
+ if provider == "lmstudio":
173
+ raise HTTPException(
174
+ status_code=400,
175
+ detail=(
176
+ "LM Studio λͺ¨λΈμ€ Latticeμ—μ„œ Hugging Face둜 pullν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. "
177
+ "LM Studio μ•±μ—μ„œ λͺ¨λΈμ„ λ‹€μš΄λ‘œλ“œν•˜κ³  Local Serverλ₯Ό μΌ  λ’€ λͺ¨λΈμ„ λ‘œλ“œν•˜μ„Έμš”. "
178
+ "그러면 λͺ¨λΈ 선택창에 μ‹€μ œ /v1/models ν•­λͺ©μ΄ ν‘œμ‹œλ©λ‹ˆλ‹€."
179
+ ),
180
+ )
181
+
182
+ if provider in {"vllm", "llamacpp", "local_mlx", "mlx"}:
183
+ download_provider = "local_mlx" if provider == "mlx" else provider
184
+ result = download_hf_model(model_name, download_provider)
185
+ return {"provider": provider, "model": model_name, "returncode": 0, **result}
186
+
187
+ raise HTTPException(status_code=400, detail=f"{provider} μ—”μ§„ λͺ¨λΈ λ‹€μš΄λ‘œλ“œλŠ” 아직 μžλ™ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
188
+
189
+ @router.post("/engines/prepare-model")
190
+ async def engines_prepare_model(req: PrepareModelRequest, request: Request):
191
+ require_user(request)
192
+ return await prepare_and_load_model(
193
+ req.model, request, engine=req.engine, user_email=req.user_email,
194
+ )
195
+
196
+ @router.post("/engines/prepare-model/stream")
197
+ async def engines_prepare_model_stream(req: PrepareModelRequest, request: Request):
198
+ require_user(request)
199
+
200
+ async def event_stream():
201
+ try:
202
+ async for chunk in prepare_and_load_model_stream(
203
+ req.model, request, engine=req.engine, user_email=req.user_email,
204
+ ):
205
+ yield chunk
206
+ except HTTPException as exc:
207
+ yield sse_event("error", {
208
+ "status_code": exc.status_code,
209
+ "detail": exc.detail or "λͺ¨λΈ 쀀비에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.",
210
+ })
211
+ except Exception as exc:
212
+ logging.exception("model prepare stream failed")
213
+ yield sse_event("error", {
214
+ "status_code": 500,
215
+ "detail": str(exc)[-1000:] or "λͺ¨λΈ 쀀비에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.",
216
+ })
217
+
218
+ return StreamingResponse(
219
+ event_stream(),
220
+ media_type="text/event-stream",
221
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
222
+ )
223
+
224
+ @router.post("/setup/set-api-key")
225
+ async def set_api_key(req: SetApiKeyRequest, request: Request):
226
+ from llm_router import OPENAI_COMPATIBLE_PROVIDERS
227
+ config = OPENAI_COMPATIBLE_PROVIDERS.get(req.provider)
228
+ if not config:
229
+ raise HTTPException(status_code=400, detail="μ•Œ 수 μ—†λŠ” ν”„λ‘œλ°”μ΄λ”μž…λ‹ˆλ‹€.")
230
+ if not req.key.strip():
231
+ raise HTTPException(status_code=400, detail="API ν‚€κ°€ λΉ„μ–΄μžˆμŠ΅λ‹ˆλ‹€.")
232
+ current_user = get_current_user(request)
233
+ if REQUIRE_AUTH and not current_user:
234
+ raise HTTPException(status_code=401, detail="인증이 ν•„μš”ν•©λ‹ˆλ‹€.")
235
+ if req.user_email and req.user_email != current_user:
236
+ users = load_users()
237
+ if get_user_role(current_user or "", users) != "admin":
238
+ raise HTTPException(status_code=403, detail="λ‹€λ₯Έ μ‚¬μš©μžμ˜ API ν‚€λ₯Ό μ„€μ •ν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.")
239
+ target_email = (req.user_email or current_user or "").strip()
240
+ if not target_email:
241
+ raise HTTPException(status_code=400, detail="μ‚¬μš©μž 식별이 ν•„μš”ν•©λ‹ˆλ‹€. 둜그인 ν›„ λ‹€μ‹œ μ‹œλ„ν•˜μ„Έμš”.")
242
+ set_user_api_key(target_email, req.provider, req.key.strip())
243
+ return {"ok": True, "provider": req.provider, "user_email": target_email, "scope": "user"}
244
+
245
+ # ── Models ────────────────────────────────────────────────────────────
246
+
247
+ @router.get("/models")
248
+ async def list_models():
249
+ recommended = _recommended_with_engine_options(
250
+ list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", [])))
251
+ )
252
+ return {
253
+ "recommended": recommended,
254
+ "cloud": _router.detected_cloud_models(),
255
+ "engines": await asyncio.to_thread(engine_status),
256
+ "loaded": _router.loaded_model_ids,
257
+ "current": _router.current_model_id,
258
+ "compat_profiles": _list_compat_profiles(),
259
+ }
260
+
261
+ @router.get("/models/compat-profiles")
262
+ async def list_model_compat_profiles(request: Request):
263
+ require_user(request)
264
+ return {"profiles": _list_compat_profiles()}
265
+
266
+ @router.post("/models/load")
267
+ async def load_model(req: LoadModelRequest, request: Request):
268
+ try:
269
+ model_id = req.model_id
270
+ requested_engine = req.engine or (model_id.split(":", 1)[0] if ":" in model_id else "local_mlx")
271
+ if IS_PUBLIC_MODE and not ALLOW_LOCAL_MODELS and requested_engine in {"local_mlx", "mlx"}:
272
+ raise HTTPException(
273
+ status_code=400,
274
+ detail="Public mode blocks local MLX model loading. Use openai:, openrouter:, groq:, together:, or set LATTICEAI_ALLOW_LOCAL_MODELS=true.",
275
+ )
276
+ return await prepare_and_load_model(
277
+ model_id, request, engine=req.engine, user_email=req.user_email,
278
+ adapter_path=req.adapter_path, draft_model_id=req.draft_model_id,
279
+ )
280
+ except HTTPException:
281
+ raise
282
+ except Exception as e:
283
+ raise HTTPException(status_code=500, detail=str(e))
284
+
285
+ @router.post("/models/switch/{model_id:path}")
286
+ async def switch_model(model_id: str, request: Request):
287
+ require_user(request)
288
+ try:
289
+ _router.switch_model(model_id)
290
+ return {"status": "ok", "current": _router.current_model_id}
291
+ except KeyError:
292
+ raise HTTPException(status_code=404, detail=f"Model '{model_id}' not loaded. Call /models/load first.")
293
+
294
+ @router.delete("/models/unload/{model_id:path}")
295
+ async def unload_model(model_id: str, request: Request):
296
+ require_user(request)
297
+ _router.unload_model(model_id)
298
+ return {"status": "ok", "unloaded": model_id}
299
+
300
+ @router.delete("/models/unload-all")
301
+ async def unload_all_models(request: Request):
302
+ require_user(request)
303
+ unloaded = _router.loaded_model_ids
304
+ _router.unload_all()
305
+ return {"status": "ok", "unloaded": unloaded}
306
+
307
+ return router