Coverage for mcpgateway / routers / llm_admin_router.py: 100%
227 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 00:56 +0100
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 00:56 +0100
1# -*- coding: utf-8 -*-
2"""Location: ./mcpgateway/routers/llm_admin_router.py
3Copyright 2025
4SPDX-License-Identifier: Apache-2.0
6LLM Admin Router.
7This module provides HTMX-based admin UI endpoints for LLM provider
8and model management.
9"""
11# Standard
12from typing import Optional
14# Third-Party
15from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
16from fastapi.responses import HTMLResponse
17import orjson
18from sqlalchemy.orm import Session
20# First-Party
21from mcpgateway.config import settings
22from mcpgateway.db import LLMProviderType
23from mcpgateway.middleware.rbac import get_current_user_with_permissions, get_db, require_permission
24from mcpgateway.services.llm_provider_service import (
25 LLMModelNotFoundError,
26 LLMProviderNotFoundError,
27 LLMProviderService,
28)
29from mcpgateway.services.logging_service import LoggingService
31# Initialize logging
32logging_service = LoggingService()
33logger = logging_service.get_logger(__name__)
35# Create router
36llm_admin_router = APIRouter()
38# Initialize service
39llm_provider_service = LLMProviderService()
42# ---------------------------------------------------------------------------
43# LLM Providers Partial
44# ---------------------------------------------------------------------------
47@llm_admin_router.get("/providers/html", response_class=HTMLResponse)
48@require_permission("admin.system_config")
49async def get_providers_partial(
50 request: Request,
51 page: int = Query(1, ge=1, description="Page number"),
52 per_page: int = Query(50, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
53 db: Session = Depends(get_db),
54 current_user_ctx: dict = Depends(get_current_user_with_permissions),
55) -> HTMLResponse:
56 """Get HTML partial for LLM providers list.
58 Args:
59 request: FastAPI request object.
60 page: Page number.
61 per_page: Items per page.
62 db: Database session.
63 current_user_ctx: Authenticated user context.
65 Returns:
66 HTML partial for providers table.
67 """
69 providers, total = llm_provider_service.list_providers(
70 db=db,
71 page=page,
72 page_size=per_page,
73 )
75 # Create pagination info
76 total_pages = (total + per_page - 1) // per_page if per_page > 0 else 1
77 pagination = {
78 "total_items": total,
79 "page": page,
80 "page_size": per_page,
81 "total_pages": total_pages,
82 "has_next": page < total_pages,
83 "has_prev": page > 1,
84 }
86 # Prepare provider data
87 provider_data = []
88 for provider in providers:
89 provider_data.append(
90 {
91 "id": provider.id,
92 "name": provider.name,
93 "slug": provider.slug,
94 "description": provider.description,
95 "provider_type": provider.provider_type,
96 "api_base": provider.api_base,
97 "enabled": provider.enabled,
98 "health_status": provider.health_status,
99 "last_health_check": provider.last_health_check,
100 "model_count": len(provider.models),
101 "created_at": provider.created_at,
102 "updated_at": provider.updated_at,
103 }
104 )
106 return request.app.state.templates.TemplateResponse(
107 request,
108 "llm_providers_partial.html",
109 {
110 "request": request,
111 "providers": provider_data,
112 "provider_types": LLMProviderType.get_all_types(),
113 "pagination": pagination,
114 "root_path": request.scope.get("root_path", ""),
115 },
116 )
119# ---------------------------------------------------------------------------
120# LLM Models Partial
121# ---------------------------------------------------------------------------
124@llm_admin_router.get("/models/html", response_class=HTMLResponse)
125@require_permission("admin.system_config")
126async def get_models_partial(
127 request: Request,
128 provider_id: Optional[str] = Query(None, description="Filter by provider ID"),
129 page: int = Query(1, ge=1, description="Page number"),
130 per_page: int = Query(50, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
131 db: Session = Depends(get_db),
132 current_user_ctx: dict = Depends(get_current_user_with_permissions),
133) -> HTMLResponse:
134 """Get HTML partial for LLM models list.
136 Args:
137 request: FastAPI request object.
138 provider_id: Filter by provider ID.
139 page: Page number.
140 per_page: Items per page.
141 db: Database session.
142 current_user_ctx: Authenticated user context.
144 Returns:
145 HTML partial for models table.
146 """
148 models, total = llm_provider_service.list_models(
149 db=db,
150 provider_id=provider_id,
151 page=page,
152 page_size=per_page,
153 )
155 # Create pagination info
156 total_pages = (total + per_page - 1) // per_page if per_page > 0 else 1
157 pagination = {
158 "total_items": total,
159 "page": page,
160 "page_size": per_page,
161 "total_pages": total_pages,
162 "has_next": page < total_pages,
163 "has_prev": page > 1,
164 }
166 # Prepare model data with provider info
167 model_data = []
168 for model in models:
169 try:
170 provider = llm_provider_service.get_provider(db, model.provider_id)
171 provider_name = provider.name
172 provider_type = provider.provider_type
173 except LLMProviderNotFoundError:
174 provider_name = "Unknown"
175 provider_type = "unknown"
177 model_data.append(
178 {
179 "id": model.id,
180 "model_id": model.model_id,
181 "model_name": model.model_name,
182 "model_alias": model.model_alias,
183 "description": model.description,
184 "provider_id": model.provider_id,
185 "provider_name": provider_name,
186 "provider_type": provider_type,
187 "supports_chat": model.supports_chat,
188 "supports_streaming": model.supports_streaming,
189 "supports_function_calling": model.supports_function_calling,
190 "supports_vision": model.supports_vision,
191 "context_window": model.context_window,
192 "max_output_tokens": model.max_output_tokens,
193 "enabled": model.enabled,
194 "deprecated": model.deprecated,
195 "created_at": model.created_at,
196 "updated_at": model.updated_at,
197 }
198 )
200 # Get providers for dropdown
201 providers, _ = llm_provider_service.list_providers(db, enabled_only=True)
202 provider_options = [{"id": p.id, "name": p.name} for p in providers]
204 return request.app.state.templates.TemplateResponse(
205 request,
206 "llm_models_partial.html",
207 {
208 "request": request,
209 "models": model_data,
210 "providers": provider_options,
211 "selected_provider_id": provider_id,
212 "pagination": pagination,
213 "root_path": request.scope.get("root_path", ""),
214 },
215 )
218# ---------------------------------------------------------------------------
219# Provider Actions
220# ---------------------------------------------------------------------------
223@llm_admin_router.post("/providers/{provider_id}/state", response_class=HTMLResponse)
224@require_permission("admin.system_config")
225async def set_provider_state_html(
226 request: Request,
227 provider_id: str,
228 db: Session = Depends(get_db),
229 current_user_ctx: dict = Depends(get_current_user_with_permissions),
230) -> HTMLResponse:
231 """Set provider enabled state and return updated row.
233 Args:
234 request: FastAPI request object.
235 provider_id: Provider ID to update.
236 db: Database session.
237 current_user_ctx: Authenticated user context.
239 Returns:
240 Updated provider row HTML.
242 Raises:
243 HTTPException: If provider is not found.
244 """
245 try:
246 provider = llm_provider_service.set_provider_state(db, provider_id)
248 return request.app.state.templates.TemplateResponse(
249 request,
250 "llm_provider_row.html",
251 {
252 "request": request,
253 "provider": {
254 "id": provider.id,
255 "name": provider.name,
256 "slug": provider.slug,
257 "provider_type": provider.provider_type,
258 "api_base": provider.api_base,
259 "enabled": provider.enabled,
260 "health_status": provider.health_status,
261 "model_count": len(provider.models),
262 },
263 "root_path": request.scope.get("root_path", ""),
264 },
265 )
266 except LLMProviderNotFoundError as e:
267 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
270@llm_admin_router.post("/providers/{provider_id}/health")
271@require_permission("admin.system_config")
272async def check_provider_health(
273 request: Request,
274 provider_id: str,
275 db: Session = Depends(get_db),
276 current_user_ctx: dict = Depends(get_current_user_with_permissions),
277):
278 """Check provider health and return status JSON.
280 Args:
281 request: FastAPI request object.
282 provider_id: Provider ID to check.
283 db: Database session.
284 current_user_ctx: Authenticated user context.
286 Returns:
287 JSON with status, provider_id, latency_ms, and optional error.
289 Raises:
290 HTTPException: If provider is not found.
291 """
292 try:
293 health = await llm_provider_service.check_provider_health(db, provider_id)
295 return {
296 "status": health.status.value,
297 "provider_id": health.provider_id,
298 "latency_ms": int(health.response_time_ms) if health.response_time_ms else None,
299 "error": health.error,
300 }
301 except LLMProviderNotFoundError as e:
302 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
305@llm_admin_router.delete("/providers/{provider_id}", response_class=HTMLResponse)
306@require_permission("admin.system_config")
307async def delete_provider_html(
308 request: Request,
309 provider_id: str,
310 db: Session = Depends(get_db),
311 current_user_ctx: dict = Depends(get_current_user_with_permissions),
312) -> HTMLResponse:
313 """Delete provider and return empty response for row removal.
315 Args:
316 request: FastAPI request object.
317 provider_id: Provider ID to delete.
318 db: Database session.
319 current_user_ctx: Authenticated user context.
321 Returns:
322 Empty response for HTMX row removal.
324 Raises:
325 HTTPException: If provider is not found.
326 """
327 try:
328 llm_provider_service.delete_provider(db, provider_id)
329 return HTMLResponse(content="", status_code=200)
330 except LLMProviderNotFoundError as e:
331 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
334# ---------------------------------------------------------------------------
335# Model Actions
336# ---------------------------------------------------------------------------
339@llm_admin_router.post("/models/{model_id}/state", response_class=HTMLResponse)
340@require_permission("admin.system_config")
341async def set_model_state_html(
342 request: Request,
343 model_id: str,
344 db: Session = Depends(get_db),
345 current_user_ctx: dict = Depends(get_current_user_with_permissions),
346) -> HTMLResponse:
347 """Set model enabled state and return updated row.
349 Args:
350 request: FastAPI request object.
351 model_id: Model ID to update.
352 db: Database session.
353 current_user_ctx: Authenticated user context.
355 Returns:
356 Updated model row HTML.
358 Raises:
359 HTTPException: If model is not found.
360 """
361 try:
362 model = llm_provider_service.set_model_state(db, model_id)
364 try:
365 provider = llm_provider_service.get_provider(db, model.provider_id)
366 provider_name = provider.name
367 except LLMProviderNotFoundError:
368 provider_name = "Unknown"
370 return request.app.state.templates.TemplateResponse(
371 request,
372 "llm_model_row.html",
373 {
374 "request": request,
375 "model": {
376 "id": model.id,
377 "model_id": model.model_id,
378 "model_name": model.model_name,
379 "provider_name": provider_name,
380 "supports_streaming": model.supports_streaming,
381 "supports_function_calling": model.supports_function_calling,
382 "supports_vision": model.supports_vision,
383 "enabled": model.enabled,
384 "deprecated": model.deprecated,
385 },
386 "root_path": request.scope.get("root_path", ""),
387 },
388 )
389 except LLMModelNotFoundError as e:
390 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
393@llm_admin_router.delete("/models/{model_id}", response_class=HTMLResponse)
394@require_permission("admin.system_config")
395async def delete_model_html(
396 request: Request,
397 model_id: str,
398 db: Session = Depends(get_db),
399 current_user_ctx: dict = Depends(get_current_user_with_permissions),
400) -> HTMLResponse:
401 """Delete model and return empty response for row removal.
403 Args:
404 request: FastAPI request object.
405 model_id: Model ID to delete.
406 db: Database session.
407 current_user_ctx: Authenticated user context.
409 Returns:
410 Empty response for HTMX row removal.
412 Raises:
413 HTTPException: If model is not found.
414 """
415 try:
416 llm_provider_service.delete_model(db, model_id)
417 return HTMLResponse(content="", status_code=200)
418 except LLMModelNotFoundError as e:
419 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
422# ---------------------------------------------------------------------------
423# LLM API Info/Test Partial
424# ---------------------------------------------------------------------------
427@llm_admin_router.get("/api-info/html", response_class=HTMLResponse)
428@require_permission("admin.system_config")
429async def get_api_info_partial(
430 request: Request,
431 db: Session = Depends(get_db),
432 current_user_ctx: dict = Depends(get_current_user_with_permissions),
433) -> HTMLResponse:
434 """Get HTML partial for LLM API info and testing.
436 Args:
437 request: FastAPI request object.
438 db: Database session.
439 current_user_ctx: Authenticated user context.
441 Returns:
442 HTML partial for API info and testing.
443 """
444 # First-Party
445 from mcpgateway.config import settings
447 # Get enabled providers and models
448 providers, total_providers = llm_provider_service.list_providers(db, enabled_only=True)
449 models, total_models = llm_provider_service.list_models(db, enabled_only=True)
451 # Prepare model data with provider info
452 model_data = []
453 for model in models:
454 try:
455 provider = llm_provider_service.get_provider(db, model.provider_id)
456 model_data.append(
457 {
458 "model_id": model.model_id,
459 "model_name": model.model_name,
460 "provider": {"name": provider.name},
461 "supports_chat": model.supports_chat,
462 "supports_streaming": model.supports_streaming,
463 "supports_vision": model.supports_vision,
464 "supports_function_calling": model.supports_function_calling,
465 }
466 )
467 except LLMProviderNotFoundError:
468 continue
470 stats = {
471 "total_providers": total_providers,
472 "total_models": total_models,
473 }
475 return request.app.state.templates.TemplateResponse(
476 request,
477 "llm_api_info_partial.html",
478 {
479 "request": request,
480 "providers": providers,
481 "models": model_data,
482 "stats": stats,
483 "llmchat_enabled": settings.llmchat_enabled,
484 "root_path": request.scope.get("root_path", ""),
485 },
486 )
489# ---------------------------------------------------------------------------
490# LLM API Test (Admin) - No API Key Required
491# ---------------------------------------------------------------------------
494@llm_admin_router.post("/test")
495@require_permission("admin.system_config")
496async def admin_test_api(
497 request: Request,
498 db: Session = Depends(get_db),
499 current_user_ctx: dict = Depends(get_current_user_with_permissions),
500):
501 """Test LLM API without requiring an API key.
503 This endpoint allows admins to test LLM models directly without needing
504 to enter or have access to a virtual API key.
506 Args:
507 request: FastAPI request object.
508 db: Database session.
509 current_user_ctx: Authenticated user context.
511 Returns:
512 Test result with metrics.
514 Raises:
515 HTTPException: If test fails.
516 """
517 # Standard
518 import time
520 # First-Party
521 from mcpgateway.services.llm_proxy_service import LLMProxyService
522 from mcpgateway.utils.orjson_response import ORJSONResponse
524 body = orjson.loads(await request.body())
526 test_type = body.get("test_type", "models")
527 model_id = body.get("model_id")
528 message = body.get("message", "Hello! Please respond with a short greeting.")
529 max_tokens = body.get("max_tokens", 100)
531 start_time = time.time()
533 try:
534 if test_type == "models":
535 # List available models
536 models = llm_provider_service.get_gateway_models(db)
537 duration_ms = int((time.time() - start_time) * 1000)
539 model_list = [{"id": m.model_id, "owned_by": m.provider_name} for m in models]
541 return ORJSONResponse(
542 content={
543 "success": True,
544 "test_type": "models",
545 "data": {"object": "list", "data": model_list},
546 "metrics": {
547 "duration": duration_ms,
548 "modelCount": len(model_list),
549 },
550 }
551 )
553 elif test_type == "chat":
554 if not model_id:
555 raise HTTPException(
556 status_code=status.HTTP_400_BAD_REQUEST,
557 detail="model_id is required for chat test",
558 )
560 # First-Party
561 from mcpgateway.llm_schemas import ChatCompletionRequest, ChatMessage
563 # Create chat completion request
564 chat_request = ChatCompletionRequest(
565 model=model_id,
566 messages=[ChatMessage(role="user", content=message)],
567 max_tokens=max_tokens,
568 stream=False,
569 )
571 proxy_service = LLMProxyService()
572 response = await proxy_service.chat_completion(db, chat_request)
573 duration_ms = int((time.time() - start_time) * 1000)
575 # Extract assistant message
576 assistant_message = ""
577 if response.choices and len(response.choices) > 0:
578 assistant_message = response.choices[0].message.content or ""
580 return ORJSONResponse(
581 content={
582 "success": True,
583 "test_type": "chat",
584 "data": response.model_dump(),
585 "assistant_message": assistant_message,
586 "metrics": {
587 "duration": duration_ms,
588 "promptTokens": response.usage.prompt_tokens if response.usage else 0,
589 "completionTokens": response.usage.completion_tokens if response.usage else 0,
590 "totalTokens": response.usage.total_tokens if response.usage else 0,
591 "responseModel": response.model,
592 },
593 }
594 )
596 else:
597 raise HTTPException(
598 status_code=status.HTTP_400_BAD_REQUEST,
599 detail=f"Unknown test type: {test_type}",
600 )
602 except HTTPException:
603 raise
604 except Exception as e:
605 duration_ms = int((time.time() - start_time) * 1000)
606 logger.error(f"Admin test failed: {e}")
607 return ORJSONResponse(
608 content={
609 "success": False,
610 "error": str(e),
611 "metrics": {"duration": duration_ms},
612 },
613 status_code=500,
614 )
617# ---------------------------------------------------------------------------
618# Provider Defaults and Model Discovery
619# ---------------------------------------------------------------------------
622@llm_admin_router.get("/provider-defaults")
623@require_permission("admin.system_config")
624async def get_provider_defaults(
625 request: Request,
626 current_user_ctx: dict = Depends(get_current_user_with_permissions),
627):
628 """Get default configuration for all provider types.
630 Args:
631 request: FastAPI request object.
632 current_user_ctx: Authenticated user context.
634 Returns:
635 Dictionary of provider type to default config.
636 """
637 return LLMProviderType.get_provider_defaults()
640@llm_admin_router.get("/provider-configs")
641@require_permission("admin.system_config")
642async def get_provider_configs(
643 request: Request,
644 current_user_ctx: dict = Depends(get_current_user_with_permissions),
645):
646 """Get provider-specific configuration definitions for UI rendering.
648 Args:
649 request: FastAPI request object.
650 current_user_ctx: Authenticated user context.
652 Returns:
653 Dictionary of provider configurations with field definitions.
654 """
655 # First-Party
656 from mcpgateway.llm_provider_configs import get_all_provider_configs
658 configs = get_all_provider_configs()
659 return {provider_type: config.model_dump() for provider_type, config in configs.items()}
662@llm_admin_router.post("/providers/{provider_id}/fetch-models")
663@require_permission("admin.system_config")
664async def fetch_provider_models(
665 request: Request,
666 provider_id: str,
667 db: Session = Depends(get_db),
668 current_user_ctx: dict = Depends(get_current_user_with_permissions),
669):
670 """Fetch available models from a provider's API.
672 Args:
673 request: FastAPI request object.
674 provider_id: Provider ID to fetch models from.
675 db: Database session.
676 current_user_ctx: Authenticated user context.
678 Returns:
679 List of available models from the provider.
681 Raises:
682 HTTPException: If provider is not found.
683 """
684 # Third-Party
685 import httpx
687 # First-Party
688 from mcpgateway.utils.services_auth import decode_auth
690 try:
691 provider = llm_provider_service.get_provider(db, provider_id)
692 except LLMProviderNotFoundError as e:
693 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
695 # Get provider defaults for model list support
696 defaults = LLMProviderType.get_provider_defaults()
697 provider_config = defaults.get(provider.provider_type, {})
699 if not provider_config.get("supports_model_list"):
700 return {
701 "success": False,
702 "error": f"Provider type '{provider.provider_type}' does not support model listing",
703 "models": [],
704 }
706 # Build API URL
707 base_url = provider.api_base or provider_config.get("api_base", "")
708 if not base_url:
709 return {
710 "success": False,
711 "error": "No API base URL configured",
712 "models": [],
713 }
715 models_endpoint = provider_config.get("models_endpoint", "/models")
716 url = f"{base_url.rstrip('/')}{models_endpoint}"
718 # Get API key if needed
719 headers = {"Content-Type": "application/json"}
720 if provider.api_key:
721 auth_data = decode_auth(provider.api_key)
722 api_key = auth_data.get("api_key")
723 if api_key:
724 headers["Authorization"] = f"Bearer {api_key}"
726 try:
727 # First-Party
728 from mcpgateway.services.http_client_service import get_admin_timeout, get_http_client # pylint: disable=import-outside-toplevel
730 client = await get_http_client()
731 response = await client.get(url, headers=headers, timeout=get_admin_timeout())
732 response.raise_for_status()
733 data = response.json()
735 # Parse models based on provider type
736 models = []
737 if "data" in data:
738 # OpenAI-compatible format
739 for model in data["data"]:
740 model_id = model.get("id", "")
741 models.append(
742 {
743 "id": model_id,
744 "name": model.get("name", model_id),
745 "owned_by": model.get("owned_by", provider.provider_type),
746 "created": model.get("created"),
747 }
748 )
749 elif "models" in data:
750 # Ollama native format or Cohere format
751 for model in data["models"]:
752 if isinstance(model, dict):
753 model_id = model.get("name", model.get("id", ""))
754 models.append(
755 {
756 "id": model_id,
757 "name": model_id,
758 "owned_by": provider.provider_type,
759 }
760 )
761 else:
762 models.append(
763 {
764 "id": str(model),
765 "name": str(model),
766 "owned_by": provider.provider_type,
767 }
768 )
770 return {
771 "success": True,
772 "models": models,
773 "count": len(models),
774 }
776 except httpx.HTTPStatusError as e:
777 return {
778 "success": False,
779 "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
780 "models": [],
781 }
782 except httpx.RequestError as e:
783 return {
784 "success": False,
785 "error": f"Connection error: {str(e)}",
786 "models": [],
787 }
788 except Exception as e:
789 return {
790 "success": False,
791 "error": str(e),
792 "models": [],
793 }
796@llm_admin_router.post("/providers/{provider_id}/sync-models")
797@require_permission("admin.system_config")
798async def sync_provider_models(
799 request: Request,
800 provider_id: str,
801 db: Session = Depends(get_db),
802 current_user_ctx: dict = Depends(get_current_user_with_permissions),
803):
804 """Sync models from provider API to database.
806 Fetches available models from the provider and creates model records
807 for any that don't already exist.
809 Args:
810 request: FastAPI request object.
811 provider_id: Provider ID to sync models for.
812 db: Database session.
813 current_user_ctx: Authenticated user context.
815 Returns:
816 Sync results with counts of added/skipped models.
817 """
818 # First-Party
819 from mcpgateway.llm_schemas import LLMModelCreate
821 # First fetch models from the provider
822 # NOTE: Must pass as kwargs - require_permission decorator only searches kwargs for user context
823 fetch_result = await fetch_provider_models(
824 request=request,
825 provider_id=provider_id,
826 db=db,
827 current_user_ctx=current_user_ctx,
828 )
830 if not fetch_result.get("success"):
831 return fetch_result
833 models = fetch_result.get("models", [])
834 if not models:
835 return {
836 "success": True,
837 "message": "No models found to sync",
838 "added": 0,
839 "skipped": 0,
840 }
842 # Get existing models for this provider
843 existing_models, _ = llm_provider_service.list_models(db, provider_id=provider_id)
844 existing_model_ids = {m.model_id for m in existing_models}
846 added = 0
847 skipped = 0
849 for model in models:
850 model_id = model.get("id", "")
851 if not model_id:
852 continue
854 if model_id in existing_model_ids:
855 skipped += 1
856 continue
858 # Create the model
859 try:
860 model_create = LLMModelCreate(
861 provider_id=provider_id,
862 model_id=model_id,
863 model_name=model.get("name", model_id),
864 description=f"Auto-synced from {model.get('owned_by', 'provider')}",
865 supports_chat=True,
866 supports_streaming=True,
867 enabled=True,
868 )
869 llm_provider_service.create_model(db, model_create)
870 added += 1
871 except Exception as e:
872 logger.warning(f"Failed to create model {model_id}: {e}")
873 skipped += 1
875 return {
876 "success": True,
877 "message": f"Synced models: {added} added, {skipped} skipped",
878 "added": added,
879 "skipped": skipped,
880 "total": len(models),
881 }