ltcai 1.3.0 → 1.5.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.
Files changed (44) hide show
  1. package/README.md +105 -79
  2. package/docs/CHANGELOG.md +109 -0
  3. package/docs/images/architecture.png +0 -0
  4. package/docs/images/graph.png +0 -0
  5. package/docs/images/hero.gif +0 -0
  6. package/docs/images/model-recommendation.png +0 -0
  7. package/docs/images/onboarding.png +0 -0
  8. package/docs/images/organization.png +0 -0
  9. package/docs/images/skills.png +0 -0
  10. package/docs/images/tmp_frames/frame_00.png +0 -0
  11. package/docs/images/tmp_frames/frame_01.png +0 -0
  12. package/docs/images/tmp_frames/frame_02.png +0 -0
  13. package/docs/images/tmp_frames/frame_03.png +0 -0
  14. package/docs/images/workspace.png +0 -0
  15. package/latticeai/__init__.py +1 -1
  16. package/latticeai/api/admin.py +17 -0
  17. package/latticeai/api/chat.py +786 -0
  18. package/latticeai/api/computer_use.py +294 -0
  19. package/latticeai/api/deps.py +15 -0
  20. package/latticeai/api/garden.py +34 -0
  21. package/latticeai/api/local_files.py +125 -0
  22. package/latticeai/api/models.py +16 -0
  23. package/latticeai/api/permissions.py +331 -0
  24. package/latticeai/api/setup.py +158 -0
  25. package/latticeai/api/static_routes.py +166 -0
  26. package/latticeai/api/tools.py +579 -0
  27. package/latticeai/api/workspace.py +11 -0
  28. package/latticeai/core/enterprise_admin.py +158 -0
  29. package/latticeai/core/workspace_os.py +1 -1
  30. package/latticeai/server_app.py +223 -4301
  31. package/latticeai/services/app_context.py +27 -0
  32. package/latticeai/services/model_catalog.py +289 -0
  33. package/latticeai/services/model_recommendation.py +183 -0
  34. package/latticeai/services/model_runtime.py +1721 -0
  35. package/latticeai/services/tool_dispatch.py +135 -0
  36. package/latticeai/services/upload_service.py +99 -0
  37. package/package.json +3 -3
  38. package/skills/SKILL_TEMPLATE.md +1 -1
  39. package/skills/code_review/SKILL.md +1 -1
  40. package/skills/data_analysis/SKILL.md +1 -1
  41. package/skills/file_edit/SKILL.md +1 -1
  42. package/skills/summarize_document/SKILL.md +1 -1
  43. package/skills/web_search/SKILL.md +1 -1
  44. package/static/scripts/chat.js +45 -0
@@ -0,0 +1,579 @@
1
+ """Direct tool, upload, MCP, and knowledge utility routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ import logging
8
+ import shutil
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional
11
+
12
+ from fastapi import APIRouter, File, HTTPException, Request, UploadFile
13
+ from fastapi.responses import FileResponse
14
+ from pydantic import BaseModel
15
+
16
+ from latticeai.api.computer_use import create_computer_use_router
17
+ from latticeai.api.local_files import create_local_files_router
18
+ from latticeai.api.mcp import create_mcp_router
19
+ from latticeai.api.permissions import create_permissions_router
20
+ from latticeai.services.upload_service import process_uploaded_document
21
+ from latticeai.services.tool_dispatch import (
22
+ TOOL_GOVERNANCE,
23
+ TOOL_GOVERNANCE_DEFAULT as _TOOL_GOVERNANCE_DEFAULT,
24
+ check_tool_role as _check_tool_role,
25
+ get_tool_permission,
26
+ list_tool_permissions,
27
+ )
28
+ from p_reinforce import BRAIN_DIR
29
+ from tools import (
30
+ AGENT_ROOT,
31
+ ToolError,
32
+ build_project,
33
+ create_docx,
34
+ create_pdf,
35
+ create_pptx,
36
+ create_xlsx,
37
+ read_document,
38
+ deploy_project,
39
+ edit_file,
40
+ git_diff,
41
+ git_log,
42
+ git_show,
43
+ git_status,
44
+ grep,
45
+ inspect_html,
46
+ knowledge_save,
47
+ knowledge_search,
48
+ knowledge_tree,
49
+ list_dir,
50
+ network_status,
51
+ obsidian_save,
52
+ obsidian_search,
53
+ obsidian_tree,
54
+ preview_url,
55
+ read_file,
56
+ run_command,
57
+ search_files,
58
+ todo_read,
59
+ todo_write,
60
+ workspace_tree,
61
+ write_file,
62
+ )
63
+
64
+
65
+ class ToolPathRequest(BaseModel):
66
+ path: str = "."
67
+ approval_token: Optional[str] = None
68
+
69
+
70
+ class ToolWriteFileRequest(BaseModel):
71
+ path: str
72
+ content: str
73
+
74
+
75
+ class ToolRunCommandRequest(BaseModel):
76
+ command: str
77
+ cwd: Optional[str] = "."
78
+
79
+
80
+ class ToolScriptRequest(BaseModel):
81
+ cwd: Optional[str] = "."
82
+ script: str = "build"
83
+
84
+
85
+ class ToolSearchFilesRequest(BaseModel):
86
+ query: str
87
+ path: str = "."
88
+ max_results: int = 20
89
+
90
+
91
+ class ToolReadFileRequest(BaseModel):
92
+ path: str
93
+ offset: int = 0
94
+ limit: int = 0
95
+ line_numbers: bool = True
96
+
97
+
98
+ class ToolEditFileRequest(BaseModel):
99
+ path: str
100
+ old_string: str
101
+ new_string: str
102
+ replace_all: bool = False
103
+
104
+
105
+ class ToolGrepRequest(BaseModel):
106
+ pattern: str
107
+ path: str = "."
108
+ glob: Optional[str] = None
109
+ max_results: int = 50
110
+ case_insensitive: bool = False
111
+ context_lines: int = 0
112
+
113
+
114
+ class ToolTodoWriteRequest(BaseModel):
115
+ todos: List[Dict] = []
116
+
117
+
118
+ class ToolWorkspaceTreeRequest(BaseModel):
119
+ path: str = "."
120
+ max_depth: int = 3
121
+
122
+
123
+ class ToolClearHistoryRequest(BaseModel):
124
+ keep_last: int = 0
125
+
126
+
127
+ class ToolKnowledgeSaveRequest(BaseModel):
128
+ content: str
129
+ folder: str = "00_Raw"
130
+ title: Optional[str] = None
131
+
132
+
133
+ class ToolKnowledgeSearchRequest(BaseModel):
134
+ query: str
135
+ max_results: int = 5
136
+
137
+
138
+ class ToolDocxRequest(BaseModel):
139
+ title: str = ""
140
+ body: str = ""
141
+ filename: str = "document.docx"
142
+
143
+
144
+ class ToolXlsxRequest(BaseModel):
145
+ rows: List[List] = []
146
+ filename: str = "spreadsheet.xlsx"
147
+ sheet_name: str = "Sheet1"
148
+
149
+
150
+ class ToolPptxRequest(BaseModel):
151
+ title: str = ""
152
+ slides: List[Dict] = []
153
+ filename: str = "presentation.pptx"
154
+
155
+
156
+ class ToolPdfRequest(BaseModel):
157
+ title: str = ""
158
+ body: str = ""
159
+ filename: str = "document.pdf"
160
+
161
+
162
+ class ToolGitDiffRequest(BaseModel):
163
+ path: Optional[str] = None
164
+ cwd: Optional[str] = "."
165
+
166
+
167
+ class ToolGitLogRequest(BaseModel):
168
+ max_count: int = 5
169
+ cwd: Optional[str] = "."
170
+
171
+
172
+ class ToolGitShowRequest(BaseModel):
173
+ revision: str = "HEAD"
174
+ cwd: Optional[str] = "."
175
+
176
+
177
+ def create_tools_router(
178
+ *,
179
+ config,
180
+ data_dir: Path,
181
+ static_dir: Path,
182
+ model_router,
183
+ require_user,
184
+ require_admin,
185
+ get_current_user,
186
+ clear_history,
187
+ append_audit_event,
188
+ enforce_rate_limit,
189
+ bytes_match_extension,
190
+ classify_sensitive_message,
191
+ save_to_history,
192
+ enable_graph: bool,
193
+ knowledge_graph,
194
+ require_graph,
195
+ local_kg_watcher,
196
+ load_mcp_installs,
197
+ recommend_mcps,
198
+ install_mcp,
199
+ mcp_public_item,
200
+ ) -> APIRouter:
201
+ api_router = APIRouter()
202
+ CONFIG = config
203
+ DATA_DIR = data_dir
204
+ STATIC_DIR = static_dir
205
+ router = model_router
206
+ ENABLE_GRAPH = enable_graph
207
+ KNOWLEDGE_GRAPH = knowledge_graph
208
+ LOCAL_KG_WATCHER = local_kg_watcher
209
+ _require_graph = require_graph
210
+ _bytes_match_extension = bytes_match_extension
211
+ permissions_router, permission_gateway = create_permissions_router(
212
+ config=CONFIG,
213
+ data_dir=DATA_DIR,
214
+ require_user=require_user,
215
+ require_admin=require_admin,
216
+ get_current_user=get_current_user,
217
+ )
218
+
219
+ # ── Direct Tool API ───────────────────────────────────────────────────────────
220
+
221
+ def _tool_response(fn, *args):
222
+ try:
223
+ return {"status": "ok", "workspace": str(AGENT_ROOT), "result": fn(*args)}
224
+ except ToolError as exc:
225
+ raise HTTPException(status_code=400, detail=str(exc))
226
+
227
+
228
+ @api_router.post("/tools/list_dir")
229
+ async def tools_list_dir(req: ToolPathRequest, request: Request):
230
+ require_user(request)
231
+ return _tool_response(list_dir, req.path)
232
+
233
+
234
+ @api_router.post("/tools/workspace_tree")
235
+ async def tools_workspace_tree(req: ToolWorkspaceTreeRequest, request: Request):
236
+ require_user(request)
237
+ return _tool_response(workspace_tree, req.path, req.max_depth)
238
+
239
+
240
+ @api_router.post("/tools/read_file")
241
+ async def tools_read_file(req: ToolReadFileRequest, request: Request):
242
+ require_user(request)
243
+ try:
244
+ return {"status": "ok", "workspace": str(AGENT_ROOT),
245
+ "result": read_file(req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)}
246
+ except ToolError as exc:
247
+ raise HTTPException(status_code=400, detail=str(exc))
248
+
249
+
250
+ @api_router.post("/tools/write_file")
251
+ async def tools_write_file(req: ToolWriteFileRequest, request: Request):
252
+ require_user(request)
253
+ return _tool_response(write_file, req.path, req.content)
254
+
255
+
256
+ @api_router.post("/tools/edit_file")
257
+ async def tools_edit_file(req: ToolEditFileRequest, request: Request):
258
+ require_user(request)
259
+ try:
260
+ return {"status": "ok", "workspace": str(AGENT_ROOT),
261
+ "result": edit_file(req.path, req.old_string, req.new_string, replace_all=req.replace_all)}
262
+ except ToolError as exc:
263
+ raise HTTPException(status_code=400, detail=str(exc))
264
+
265
+
266
+ @api_router.post("/tools/search_files")
267
+ async def tools_search_files(req: ToolSearchFilesRequest, request: Request):
268
+ require_user(request)
269
+ return _tool_response(search_files, req.query, req.path, req.max_results)
270
+
271
+
272
+ @api_router.post("/tools/grep")
273
+ async def tools_grep(req: ToolGrepRequest, request: Request):
274
+ require_user(request)
275
+ try:
276
+ return {"status": "ok", "workspace": str(AGENT_ROOT),
277
+ "result": grep(
278
+ req.pattern,
279
+ path=req.path,
280
+ glob=req.glob,
281
+ max_results=req.max_results,
282
+ case_insensitive=req.case_insensitive,
283
+ context_lines=req.context_lines,
284
+ )}
285
+ except ToolError as exc:
286
+ raise HTTPException(status_code=400, detail=str(exc))
287
+
288
+
289
+ @api_router.post("/tools/todo_read")
290
+ async def tools_todo_read(request: Request):
291
+ require_user(request)
292
+ return _tool_response(todo_read)
293
+
294
+
295
+ @api_router.post("/tools/todo_write")
296
+ async def tools_todo_write(req: ToolTodoWriteRequest, request: Request):
297
+ require_user(request)
298
+ return _tool_response(todo_write, req.todos)
299
+
300
+
301
+ @api_router.post("/tools/clear_history")
302
+ async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
303
+ current_user = require_user(request)
304
+ result = clear_history(req.keep_last)
305
+ append_audit_event(
306
+ "history_delete",
307
+ user_email=current_user,
308
+ source="tools",
309
+ keep_last=req.keep_last,
310
+ removed=result.get("removed", 0),
311
+ kept=result.get("kept", 0),
312
+ )
313
+ return result
314
+
315
+
316
+ @api_router.post("/tools/inspect_html")
317
+ async def tools_inspect_html(req: ToolPathRequest, request: Request):
318
+ require_user(request)
319
+ return _tool_response(inspect_html, req.path)
320
+
321
+
322
+ @api_router.post("/tools/preview_url")
323
+ async def tools_preview_url(req: ToolPathRequest, request: Request):
324
+ require_user(request)
325
+ return _tool_response(preview_url, req.path)
326
+
327
+
328
+ @api_router.post("/tools/create_docx")
329
+ async def tools_create_docx(req: ToolDocxRequest, request: Request):
330
+ require_user(request)
331
+ return _tool_response(create_docx, req.title, req.body, req.filename)
332
+
333
+
334
+ @api_router.post("/tools/create_xlsx")
335
+ async def tools_create_xlsx(req: ToolXlsxRequest, request: Request):
336
+ require_user(request)
337
+ return _tool_response(create_xlsx, req.rows, req.filename, req.sheet_name)
338
+
339
+
340
+ @api_router.post("/tools/create_pptx")
341
+ async def tools_create_pptx(req: ToolPptxRequest, request: Request):
342
+ require_user(request)
343
+ return _tool_response(create_pptx, req.title, req.slides, req.filename)
344
+
345
+
346
+ @api_router.post("/tools/create_pdf")
347
+ async def tools_create_pdf(req: ToolPdfRequest, request: Request):
348
+ require_user(request)
349
+ return _tool_response(create_pdf, req.title, req.body, req.filename)
350
+
351
+
352
+ @api_router.post("/tools/read_document")
353
+ async def tools_read_document(req: ToolPathRequest, request: Request):
354
+ current_user = require_user(request)
355
+ if Path(req.path).expanduser().is_absolute():
356
+ permission_gateway.require_local_approval(
357
+ token=req.approval_token,
358
+ path=req.path,
359
+ action="read",
360
+ user_email=current_user,
361
+ )
362
+ return _tool_response(read_document, req.path)
363
+
364
+
365
+ @api_router.get("/tools/pdf_pages")
366
+ async def tools_pdf_pages(path: str, request: Request, approval_token: Optional[str] = None):
367
+ """Render PDF pages as base64 PNG images using pypdfium2 (Apache-2.0)."""
368
+ current_user = require_user(request)
369
+ permission_gateway.require_local_approval(
370
+ token=approval_token,
371
+ path=path,
372
+ action="read",
373
+ user_email=current_user,
374
+ )
375
+ target = Path(path).expanduser().resolve()
376
+ if not target.exists() or not target.is_file():
377
+ raise HTTPException(status_code=404, detail="File not found")
378
+ import pypdfium2 as pdfium
379
+ doc = None
380
+ try:
381
+ doc = pdfium.PdfDocument(str(target))
382
+ total = len(doc)
383
+ pages = []
384
+ for i in range(min(total, 20)): # 최대 20페이지
385
+ page = doc[i]
386
+ bitmap = page.render(scale=1.5)
387
+ pil_image = bitmap.to_pil()
388
+ buf = io.BytesIO()
389
+ pil_image.save(buf, format="PNG")
390
+ b64 = base64.b64encode(buf.getvalue()).decode()
391
+ pages.append({"page": i + 1, "b64": b64})
392
+ return {"total": total, "pages": pages}
393
+ except Exception as e:
394
+ raise HTTPException(status_code=500, detail=f"PDF 렌더링 실패: {e}")
395
+ finally:
396
+ if doc is not None:
397
+ try:
398
+ doc.close()
399
+ except Exception as e:
400
+ logging.warning("pypdfium2 doc close failed: %s", e)
401
+
402
+
403
+ @api_router.get("/tools/download")
404
+ async def tools_download(path: str, request: Request):
405
+ """Serve a generated file from agent workspace for download."""
406
+ require_user(request)
407
+ from urllib.parse import unquote
408
+ rel = unquote(path).lstrip("/")
409
+ target = (AGENT_ROOT / rel).resolve()
410
+ if AGENT_ROOT not in target.parents and target != AGENT_ROOT:
411
+ raise HTTPException(status_code=403, detail="경로가 작업 공간 밖입니다.")
412
+ if not target.exists() or not target.is_file():
413
+ raise HTTPException(status_code=404, detail="파일이 없습니다.")
414
+ return FileResponse(
415
+ path=target,
416
+ filename=target.name,
417
+ media_type="application/octet-stream",
418
+ )
419
+
420
+
421
+ @api_router.post("/upload/document")
422
+ async def upload_document(request: Request, file: UploadFile = File(...)):
423
+ current_user = require_user(request)
424
+ return await process_uploaded_document(
425
+ request=request,
426
+ file=file,
427
+ current_user=current_user,
428
+ enable_graph=ENABLE_GRAPH,
429
+ knowledge_graph=KNOWLEDGE_GRAPH,
430
+ bytes_match_extension=_bytes_match_extension,
431
+ classify_sensitive_message=classify_sensitive_message,
432
+ append_audit_event=append_audit_event,
433
+ enforce_rate_limit=enforce_rate_limit,
434
+ )
435
+
436
+
437
+ api_router.include_router(permissions_router)
438
+ api_router.include_router(create_local_files_router(
439
+ require_user=require_user,
440
+ tool_response=_tool_response,
441
+ permission_gateway=permission_gateway,
442
+ knowledge_graph=KNOWLEDGE_GRAPH,
443
+ require_graph=_require_graph,
444
+ static_dir=STATIC_DIR,
445
+ local_kg_watcher=LOCAL_KG_WATCHER,
446
+ ))
447
+ api_router.include_router(create_computer_use_router(
448
+ model_router=router,
449
+ require_user=require_user,
450
+ tool_response=_tool_response,
451
+ save_to_history=save_to_history,
452
+ ))
453
+
454
+ @api_router.post("/tools/knowledge_save")
455
+ async def tools_knowledge_save(req: ToolKnowledgeSaveRequest, request: Request):
456
+ require_user(request)
457
+ return _tool_response(knowledge_save, req.content, req.folder, req.title)
458
+
459
+
460
+ @api_router.post("/tools/knowledge_search")
461
+ async def tools_knowledge_search(req: ToolKnowledgeSearchRequest, request: Request):
462
+ require_user(request)
463
+ return _tool_response(knowledge_search, req.query, req.max_results)
464
+
465
+
466
+ @api_router.get("/tools/knowledge_tree")
467
+ async def tools_knowledge_tree(request: Request):
468
+ require_user(request)
469
+ return _tool_response(knowledge_tree)
470
+
471
+
472
+ @api_router.post("/tools/obsidian_save")
473
+ async def tools_obsidian_save(req: ToolKnowledgeSaveRequest, request: Request):
474
+ require_user(request)
475
+ return _tool_response(obsidian_save, req.content, req.folder, req.title)
476
+
477
+
478
+ @api_router.post("/tools/obsidian_search")
479
+ async def tools_obsidian_search(req: ToolKnowledgeSearchRequest, request: Request):
480
+ require_user(request)
481
+ return _tool_response(obsidian_search, req.query, req.max_results)
482
+
483
+
484
+ @api_router.get("/tools/obsidian_tree")
485
+ async def tools_obsidian_tree(request: Request):
486
+ require_user(request)
487
+ return _tool_response(obsidian_tree)
488
+
489
+
490
+ @api_router.get("/obsidian/status")
491
+ async def obsidian_status(request: Request):
492
+ require_user(request)
493
+ return {
494
+ "status": "ok",
495
+ "vault_root": str(BRAIN_DIR),
496
+ "folders": [path.name for path in BRAIN_DIR.iterdir() if path.is_dir()] if BRAIN_DIR.exists() else [],
497
+ "ocr_engine": shutil.which("tesseract") or None,
498
+ }
499
+
500
+
501
+ @api_router.get("/tools/git_status")
502
+ async def tools_git_status(request: Request):
503
+ require_user(request)
504
+ return _tool_response(git_status)
505
+
506
+
507
+ @api_router.post("/tools/git_diff")
508
+ async def tools_git_diff(req: ToolGitDiffRequest, request: Request):
509
+ require_user(request)
510
+ return _tool_response(git_diff, req.path, req.cwd)
511
+
512
+
513
+ @api_router.post("/tools/git_log")
514
+ async def tools_git_log(req: ToolGitLogRequest, request: Request):
515
+ require_user(request)
516
+ return _tool_response(git_log, req.max_count, req.cwd)
517
+
518
+
519
+ @api_router.post("/tools/git_show")
520
+ async def tools_git_show(req: ToolGitShowRequest, request: Request):
521
+ require_user(request)
522
+ return _tool_response(git_show, req.revision, req.cwd)
523
+
524
+
525
+ @api_router.post("/tools/run_command")
526
+ async def tools_run_command(req: ToolRunCommandRequest, request: Request):
527
+ require_admin(request)
528
+ return _tool_response(run_command, req.command, req.cwd)
529
+
530
+
531
+ @api_router.get("/tools/network_status")
532
+ async def tools_network_status(request: Request):
533
+ require_user(request)
534
+ return _tool_response(network_status)
535
+
536
+
537
+ @api_router.post("/tools/build_project")
538
+ async def tools_build_project(req: ToolScriptRequest, request: Request):
539
+ require_admin(request)
540
+ return _tool_response(build_project, req.cwd, req.script)
541
+
542
+
543
+ @api_router.post("/tools/deploy_project")
544
+ async def tools_deploy_project(req: ToolScriptRequest, request: Request):
545
+ require_admin(request)
546
+ return _tool_response(deploy_project, req.cwd, req.script)
547
+
548
+
549
+ @api_router.get("/tools/permissions")
550
+ async def tools_permissions(request: Request):
551
+ """Compact tool permission view (tool / risk / requires_approval / network).
552
+
553
+ A simpler authorization-layer summary derived from TOOL_GOVERNANCE.
554
+ Use /mcp/tools for the full 7-dimensional governance object.
555
+ """
556
+ require_user(request)
557
+ return {"status": "ok", "permissions": list_tool_permissions()}
558
+
559
+
560
+ # ── MCP / skills / plugins router (latticeai.api.mcp, v1.3.0) ────────────────
561
+ api_router.include_router(create_mcp_router(
562
+ require_user=require_user,
563
+ require_admin=require_admin,
564
+ append_audit_event=append_audit_event,
565
+ load_mcp_installs=load_mcp_installs,
566
+ recommend_mcps=recommend_mcps,
567
+ install_mcp=install_mcp,
568
+ mcp_public_item=mcp_public_item,
569
+ get_tool_permission=get_tool_permission,
570
+ tool_governance=TOOL_GOVERNANCE,
571
+ tool_governance_default=_TOOL_GOVERNANCE_DEFAULT,
572
+ check_tool_role=_check_tool_role,
573
+ tool_response=_tool_response,
574
+ require_graph=_require_graph,
575
+ knowledge_graph=KNOWLEDGE_GRAPH,
576
+ data_dir=DATA_DIR,
577
+ ))
578
+
579
+ return api_router
@@ -262,9 +262,20 @@ def create_workspace_router(
262
262
  require_user(request)
263
263
  env = await asyncio.to_thread(scan_environment)
264
264
  recommendations = get_recommendations(env)
265
+ # Tri-state, family-grouped catalog (recommended / compatible /
266
+ # not_recommended) for this machine, used by the onboarding model step.
267
+ catalog = None
268
+ try:
269
+ from auto_setup import probe as auto_setup_probe
270
+ from latticeai.services.model_recommendation import recommend_catalog
271
+ profile = await asyncio.to_thread(lambda: auto_setup_probe().to_json())
272
+ catalog = recommend_catalog(profile, engine="local_mlx")
273
+ except Exception as exc: # pragma: no cover - recommendation is best-effort
274
+ logging.warning("model recommendation catalog failed: %s", exc)
265
275
  payload = {
266
276
  "environment": env,
267
277
  "recommendations": recommendations,
278
+ "catalog": catalog,
268
279
  "default_local_model": LOCAL_MODEL,
269
280
  "default_public_model": PUBLIC_MODEL,
270
281
  }