Coverage for mcpgateway / routers / llm_admin_router.py: 99%
226 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +0000
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.db import LLMProviderType
22from mcpgateway.middleware.rbac import get_current_user_with_permissions, get_db, require_permission
23from mcpgateway.services.llm_provider_service import (
24 LLMModelNotFoundError,
25 LLMProviderNotFoundError,
26 LLMProviderService,
27)
28from mcpgateway.services.logging_service import LoggingService
30# Initialize logging
31logging_service = LoggingService()
32logger = logging_service.get_logger(__name__)
34# Create router
35llm_admin_router = APIRouter()
37# Initialize service
38llm_provider_service = LLMProviderService()
41# ---------------------------------------------------------------------------
42# LLM Providers Partial
43# ---------------------------------------------------------------------------
46@llm_admin_router.get("/providers/html", response_class=HTMLResponse)
47@require_permission("admin.system_config")
48async def get_providers_partial(
49 request: Request,
50 page: int = Query(1, ge=1, description="Page number"),
51 per_page: int = Query(50, ge=1, le=100, description="Items per page"),
52 db: Session = Depends(get_db),
53 current_user_ctx: dict = Depends(get_current_user_with_permissions),
54) -> HTMLResponse:
55 """Get HTML partial for LLM providers list.
57 Args:
58 request: FastAPI request object.
59 page: Page number.
60 per_page: Items per page.
61 db: Database session.
62 current_user_ctx: Authenticated user context.
64 Returns:
65 HTML partial for providers table.
66 """
68 providers, total = llm_provider_service.list_providers(
69 db=db,
70 page=page,
71 page_size=per_page,
72 )
74 # Create pagination info
75 total_pages = (total + per_page - 1) // per_page if per_page > 0 else 1
76 pagination = {
77 "total_items": total,
78 "page": page,
79 "page_size": per_page,
80 "total_pages": total_pages,
81 "has_next": page < total_pages,
82 "has_prev": page > 1,
83 }
85 # Prepare provider data
86 provider_data = []
87 for provider in providers:
88 provider_data.append(
89 {
90 "id": provider.id,
91 "name": provider.name,
92 "slug": provider.slug,
93 "description": provider.description,
94 "provider_type": provider.provider_type,
95 "api_base": provider.api_base,
96 "enabled": provider.enabled,
97 "health_status": provider.health_status,
98 "last_health_check": provider.last_health_check,
99 "model_count": len(provider.models),
100 "created_at": provider.created_at,
101 "updated_at": provider.updated_at,
102 }
103 )
105 return request.app.state.templates.TemplateResponse(
106 request,
107 "llm_providers_partial.html",
108 {
109 "request": request,
110 "providers": provider_data,
111 "provider_types": LLMProviderType.get_all_types(),
112 "pagination": pagination,
113 "root_path": request.scope.get("root_path", ""),
114 },
115 )
118# ---------------------------------------------------------------------------
119# LLM Models Partial
120# ---------------------------------------------------------------------------
123@llm_admin_router.get("/models/html", response_class=HTMLResponse)
124@require_permission("admin.system_config")
125async def get_models_partial(
126 request: Request,
127 provider_id: Optional[str] = Query(None, description="Filter by provider ID"),
128 page: int = Query(1, ge=1, description="Page number"),
129 per_page: int = Query(50, ge=1, le=100, description="Items per page"),
130 db: Session = Depends(get_db),
131 current_user_ctx: dict = Depends(get_current_user_with_permissions),
132) -> HTMLResponse:
133 """Get HTML partial for LLM models list.
135 Args:
136 request: FastAPI request object.
137 provider_id: Filter by provider ID.
138 page: Page number.
139 per_page: Items per page.
140 db: Database session.
141 current_user_ctx: Authenticated user context.
143 Returns:
144 HTML partial for models table.
145 """
147 models, total = llm_provider_service.list_models(
148 db=db,
149 provider_id=provider_id,
150 page=page,
151 page_size=per_page,
152 )
154 # Create pagination info
155 total_pages = (total + per_page - 1) // per_page if per_page > 0 else 1
156 pagination = {
157 "total_items": total,
158 "page": page,
159 "page_size": per_page,
160 "total_pages": total_pages,
161 "has_next": page < total_pages,
162 "has_prev": page > 1,
163 }
165 # Prepare model data with provider info
166 model_data = []
167 for model in models:
168 try:
169 provider = llm_provider_service.get_provider(db, model.provider_id)
170 provider_name = provider.name
171 provider_type = provider.provider_type
172 except LLMProviderNotFoundError:
173 provider_name = "Unknown"
174 provider_type = "unknown"
176 model_data.append(
177 {
178 "id": model.id,
179 "model_id": model.model_id,
180 "model_name": model.model_name,
181 "model_alias": model.model_alias,
182 "description": model.description,
183 "provider_id": model.provider_id,
184 "provider_name": provider_name,
185 "provider_type": provider_type,
186 "supports_chat": model.supports_chat,
187 "supports_streaming": model.supports_streaming,
188 "supports_function_calling": model.supports_function_calling,
189 "supports_vision": model.supports_vision,
190 "context_window": model.context_window,
191 "max_output_tokens": model.max_output_tokens,
192 "enabled": model.enabled,
193 "deprecated": model.deprecated,
194 "created_at": model.created_at,
195 "updated_at": model.updated_at,
196 }
197 )
199 # Get providers for dropdown
200 providers, _ = llm_provider_service.list_providers(db, enabled_only=True)
201 provider_options = [{"id": p.id, "name": p.name} for p in providers]
203 return request.app.state.templates.TemplateResponse(
204 request,
205 "llm_models_partial.html",
206 {
207 "request": request,
208 "models": model_data,
209 "providers": provider_options,
210 "selected_provider_id": provider_id,
211 "pagination": pagination,
212 "root_path": request.scope.get("root_path", ""),
213 },
214 )
217# ---------------------------------------------------------------------------
218# Provider Actions
219# ---------------------------------------------------------------------------
222@llm_admin_router.post("/providers/{provider_id}/state", response_class=HTMLResponse)
223@require_permission("admin.system_config")
224async def set_provider_state_html(
225 request: Request,
226 provider_id: str,
227 db: Session = Depends(get_db),
228 current_user_ctx: dict = Depends(get_current_user_with_permissions),
229) -> HTMLResponse:
230 """Set provider enabled state and return updated row.
232 Args:
233 request: FastAPI request object.
234 provider_id: Provider ID to update.
235 db: Database session.
236 current_user_ctx: Authenticated user context.
238 Returns:
239 Updated provider row HTML.
241 Raises:
242 HTTPException: If provider is not found.
243 """
244 try:
245 provider = llm_provider_service.set_provider_state(db, provider_id)
247 return request.app.state.templates.TemplateResponse(
248 request,
249 "llm_provider_row.html",
250 {
251 "request": request,
252 "provider": {
253 "id": provider.id,
254 "name": provider.name,
255 "slug": provider.slug,
256 "provider_type": provider.provider_type,
257 "api_base": provider.api_base,
258 "enabled": provider.enabled,
259 "health_status": provider.health_status,
260 "model_count": len(provider.models),
261 },
262 "root_path": request.scope.get("root_path", ""),
263 },
264 )
265 except LLMProviderNotFoundError as e:
266 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
269@llm_admin_router.post("/providers/{provider_id}/health")
270@require_permission("admin.system_config")
271async def check_provider_health(
272 request: Request,
273 provider_id: str,
274 db: Session = Depends(get_db),
275 current_user_ctx: dict = Depends(get_current_user_with_permissions),
276):
277 """Check provider health and return status JSON.
279 Args:
280 request: FastAPI request object.
281 provider_id: Provider ID to check.
282 db: Database session.
283 current_user_ctx: Authenticated user context.
285 Returns:
286 JSON with status, provider_id, latency_ms, and optional error.
288 Raises:
289 HTTPException: If provider is not found.
290 """
291 try:
292 health = await llm_provider_service.check_provider_health(db, provider_id)
294 return {
295 "status": health.status.value,
296 "provider_id": health.provider_id,
297 "latency_ms": int(health.response_time_ms) if health.response_time_ms else None,
298 "error": health.error,
299 }
300 except LLMProviderNotFoundError as e:
301 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
304@llm_admin_router.delete("/providers/{provider_id}", response_class=HTMLResponse)
305@require_permission("admin.system_config")
306async def delete_provider_html(
307 request: Request,
308 provider_id: str,
309 db: Session = Depends(get_db),
310 current_user_ctx: dict = Depends(get_current_user_with_permissions),
311) -> HTMLResponse:
312 """Delete provider and return empty response for row removal.
314 Args:
315 request: FastAPI request object.
316 provider_id: Provider ID to delete.
317 db: Database session.
318 current_user_ctx: Authenticated user context.
320 Returns:
321 Empty response for HTMX row removal.
323 Raises:
324 HTTPException: If provider is not found.
325 """
326 try:
327 llm_provider_service.delete_provider(db, provider_id)
328 return HTMLResponse(content="", status_code=200)
329 except LLMProviderNotFoundError as e:
330 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
333# ---------------------------------------------------------------------------
334# Model Actions
335# ---------------------------------------------------------------------------
338@llm_admin_router.post("/models/{model_id}/state", response_class=HTMLResponse)
339@require_permission("admin.system_config")
340async def set_model_state_html(
341 request: Request,
342 model_id: str,
343 db: Session = Depends(get_db),
344 current_user_ctx: dict = Depends(get_current_user_with_permissions),
345) -> HTMLResponse:
346 """Set model enabled state and return updated row.
348 Args:
349 request: FastAPI request object.
350 model_id: Model ID to update.
351 db: Database session.
352 current_user_ctx: Authenticated user context.
354 Returns:
355 Updated model row HTML.
357 Raises:
358 HTTPException: If model is not found.
359 """
360 try:
361 model = llm_provider_service.set_model_state(db, model_id)
363 try:
364 provider = llm_provider_service.get_provider(db, model.provider_id)
365 provider_name = provider.name
366 except LLMProviderNotFoundError:
367 provider_name = "Unknown"
369 return request.app.state.templates.TemplateResponse(
370 request,
371 "llm_model_row.html",
372 {
373 "request": request,
374 "model": {
375 "id": model.id,
376 "model_id": model.model_id,
377 "model_name": model.model_name,
378 "provider_name": provider_name,
379 "supports_streaming": model.supports_streaming,
380 "supports_function_calling": model.supports_function_calling,
381 "supports_vision": model.supports_vision,
382 "enabled": model.enabled,
383 "deprecated": model.deprecated,
384 },
385 "root_path": request.scope.get("root_path", ""),
386 },
387 )
388 except LLMModelNotFoundError as e:
389 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
392@llm_admin_router.delete("/models/{model_id}", response_class=HTMLResponse)
393@require_permission("admin.system_config")
394async def delete_model_html(
395 request: Request,
396 model_id: str,
397 db: Session = Depends(get_db),
398 current_user_ctx: dict = Depends(get_current_user_with_permissions),
399) -> HTMLResponse:
400 """Delete model and return empty response for row removal.
402 Args:
403 request: FastAPI request object.
404 model_id: Model ID to delete.
405 db: Database session.
406 current_user_ctx: Authenticated user context.
408 Returns:
409 Empty response for HTMX row removal.
411 Raises:
412 HTTPException: If model is not found.
413 """
414 try:
415 llm_provider_service.delete_model(db, model_id)
416 return HTMLResponse(content="", status_code=200)
417 except LLMModelNotFoundError as e:
418 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
421# ---------------------------------------------------------------------------
422# LLM API Info/Test Partial
423# ---------------------------------------------------------------------------
426@llm_admin_router.get("/api-info/html", response_class=HTMLResponse)
427@require_permission("admin.system_config")
428async def get_api_info_partial(
429 request: Request,
430 db: Session = Depends(get_db),
431 current_user_ctx: dict = Depends(get_current_user_with_permissions),
432) -> HTMLResponse:
433 """Get HTML partial for LLM API info and testing.
435 Args:
436 request: FastAPI request object.
437 db: Database session.
438 current_user_ctx: Authenticated user context.
440 Returns:
441 HTML partial for API info and testing.
442 """
443 # First-Party
444 from mcpgateway.config import settings
446 # Get enabled providers and models
447 providers, total_providers = llm_provider_service.list_providers(db, enabled_only=True)
448 models, total_models = llm_provider_service.list_models(db, enabled_only=True)
450 # Prepare model data with provider info
451 model_data = []
452 for model in models:
453 try:
454 provider = llm_provider_service.get_provider(db, model.provider_id)
455 model_data.append(
456 {
457 "model_id": model.model_id,
458 "model_name": model.model_name,
459 "provider": {"name": provider.name},
460 "supports_chat": model.supports_chat,
461 "supports_streaming": model.supports_streaming,
462 "supports_vision": model.supports_vision,
463 "supports_function_calling": model.supports_function_calling,
464 }
465 )
466 except LLMProviderNotFoundError:
467 continue
469 stats = {
470 "total_providers": total_providers,
471 "total_models": total_models,
472 }
474 return request.app.state.templates.TemplateResponse(
475 request,
476 "llm_api_info_partial.html",
477 {
478 "request": request,
479 "providers": providers,
480 "models": model_data,
481 "stats": stats,
482 "llmchat_enabled": settings.llmchat_enabled,
483 "root_path": request.scope.get("root_path", ""),
484 },
485 )
488# ---------------------------------------------------------------------------
489# LLM API Test (Admin) - No API Key Required
490# ---------------------------------------------------------------------------
493@llm_admin_router.post("/test")
494@require_permission("admin.system_config")
495async def admin_test_api(
496 request: Request,
497 db: Session = Depends(get_db),
498 current_user_ctx: dict = Depends(get_current_user_with_permissions),
499):
500 """Test LLM API without requiring an API key.
502 This endpoint allows admins to test LLM models directly without needing
503 to enter or have access to a virtual API key.
505 Args:
506 request: FastAPI request object.
507 db: Database session.
508 current_user_ctx: Authenticated user context.
510 Returns:
511 Test result with metrics.
513 Raises:
514 HTTPException: If test fails.
515 """
516 # Standard
517 import time
519 # First-Party
520 from mcpgateway.services.llm_proxy_service import LLMProxyService
521 from mcpgateway.utils.orjson_response import ORJSONResponse
523 body = orjson.loads(await request.body())
525 test_type = body.get("test_type", "models")
526 model_id = body.get("model_id")
527 message = body.get("message", "Hello! Please respond with a short greeting.")
528 max_tokens = body.get("max_tokens", 100)
530 start_time = time.time()
532 try:
533 if test_type == "models":
534 # List available models
535 models = llm_provider_service.get_gateway_models(db)
536 duration_ms = int((time.time() - start_time) * 1000)
538 model_list = [{"id": m.model_id, "owned_by": m.provider_name} for m in models]
540 return ORJSONResponse(
541 content={
542 "success": True,
543 "test_type": "models",
544 "data": {"object": "list", "data": model_list},
545 "metrics": {
546 "duration": duration_ms,
547 "modelCount": len(model_list),
548 },
549 }
550 )
552 elif test_type == "chat":
553 if not model_id:
554 raise HTTPException(
555 status_code=status.HTTP_400_BAD_REQUEST,
556 detail="model_id is required for chat test",
557 )
559 # First-Party
560 from mcpgateway.llm_schemas import ChatCompletionRequest, ChatMessage
562 # Create chat completion request
563 chat_request = ChatCompletionRequest(
564 model=model_id,
565 messages=[ChatMessage(role="user", content=message)],
566 max_tokens=max_tokens,
567 stream=False,
568 )
570 proxy_service = LLMProxyService()
571 response = await proxy_service.chat_completion(db, chat_request)
572 duration_ms = int((time.time() - start_time) * 1000)
574 # Extract assistant message
575 assistant_message = ""
576 if response.choices and len(response.choices) > 0: 576 ↛ 579line 576 didn't jump to line 579 because the condition on line 576 was always true
577 assistant_message = response.choices[0].message.content or ""
579 return ORJSONResponse(
580 content={
581 "success": True,
582 "test_type": "chat",
583 "data": response.model_dump(),
584 "assistant_message": assistant_message,
585 "metrics": {
586 "duration": duration_ms,
587 "promptTokens": response.usage.prompt_tokens if response.usage else 0,
588 "completionTokens": response.usage.completion_tokens if response.usage else 0,
589 "totalTokens": response.usage.total_tokens if response.usage else 0,
590 "responseModel": response.model,
591 },
592 }
593 )
595 else:
596 raise HTTPException(
597 status_code=status.HTTP_400_BAD_REQUEST,
598 detail=f"Unknown test type: {test_type}",
599 )
601 except HTTPException:
602 raise
603 except Exception as e:
604 duration_ms = int((time.time() - start_time) * 1000)
605 logger.error(f"Admin test failed: {e}")
606 return ORJSONResponse(
607 content={
608 "success": False,
609 "error": str(e),
610 "metrics": {"duration": duration_ms},
611 },
612 status_code=500,
613 )
616# ---------------------------------------------------------------------------
617# Provider Defaults and Model Discovery
618# ---------------------------------------------------------------------------
621@llm_admin_router.get("/provider-defaults")
622@require_permission("admin.system_config")
623async def get_provider_defaults(
624 request: Request,
625 current_user_ctx: dict = Depends(get_current_user_with_permissions),
626):
627 """Get default configuration for all provider types.
629 Args:
630 request: FastAPI request object.
631 current_user_ctx: Authenticated user context.
633 Returns:
634 Dictionary of provider type to default config.
635 """
636 return LLMProviderType.get_provider_defaults()
639@llm_admin_router.get("/provider-configs")
640@require_permission("admin.system_config")
641async def get_provider_configs(
642 request: Request,
643 current_user_ctx: dict = Depends(get_current_user_with_permissions),
644):
645 """Get provider-specific configuration definitions for UI rendering.
647 Args:
648 request: FastAPI request object.
649 current_user_ctx: Authenticated user context.
651 Returns:
652 Dictionary of provider configurations with field definitions.
653 """
654 # First-Party
655 from mcpgateway.llm_provider_configs import get_all_provider_configs
657 configs = get_all_provider_configs()
658 return {provider_type: config.model_dump() for provider_type, config in configs.items()}
661@llm_admin_router.post("/providers/{provider_id}/fetch-models")
662@require_permission("admin.system_config")
663async def fetch_provider_models(
664 request: Request,
665 provider_id: str,
666 db: Session = Depends(get_db),
667 current_user_ctx: dict = Depends(get_current_user_with_permissions),
668):
669 """Fetch available models from a provider's API.
671 Args:
672 request: FastAPI request object.
673 provider_id: Provider ID to fetch models from.
674 db: Database session.
675 current_user_ctx: Authenticated user context.
677 Returns:
678 List of available models from the provider.
680 Raises:
681 HTTPException: If provider is not found.
682 """
683 # Third-Party
684 import httpx
686 # First-Party
687 from mcpgateway.utils.services_auth import decode_auth
689 try:
690 provider = llm_provider_service.get_provider(db, provider_id)
691 except LLMProviderNotFoundError as e:
692 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
694 # Get provider defaults for model list support
695 defaults = LLMProviderType.get_provider_defaults()
696 provider_config = defaults.get(provider.provider_type, {})
698 if not provider_config.get("supports_model_list"):
699 return {
700 "success": False,
701 "error": f"Provider type '{provider.provider_type}' does not support model listing",
702 "models": [],
703 }
705 # Build API URL
706 base_url = provider.api_base or provider_config.get("api_base", "")
707 if not base_url:
708 return {
709 "success": False,
710 "error": "No API base URL configured",
711 "models": [],
712 }
714 models_endpoint = provider_config.get("models_endpoint", "/models")
715 url = f"{base_url.rstrip('/')}{models_endpoint}"
717 # Get API key if needed
718 headers = {"Content-Type": "application/json"}
719 if provider.api_key:
720 auth_data = decode_auth(provider.api_key)
721 api_key = auth_data.get("api_key")
722 if api_key: 722 ↛ 725line 722 didn't jump to line 725 because the condition on line 722 was always true
723 headers["Authorization"] = f"Bearer {api_key}"
725 try:
726 # First-Party
727 from mcpgateway.services.http_client_service import get_admin_timeout, get_http_client # pylint: disable=import-outside-toplevel
729 client = await get_http_client()
730 response = await client.get(url, headers=headers, timeout=get_admin_timeout())
731 response.raise_for_status()
732 data = response.json()
734 # Parse models based on provider type
735 models = []
736 if "data" in data:
737 # OpenAI-compatible format
738 for model in data["data"]:
739 model_id = model.get("id", "")
740 models.append(
741 {
742 "id": model_id,
743 "name": model.get("name", model_id),
744 "owned_by": model.get("owned_by", provider.provider_type),
745 "created": model.get("created"),
746 }
747 )
748 elif "models" in data: 748 ↛ 769line 748 didn't jump to line 769 because the condition on line 748 was always true
749 # Ollama native format or Cohere format
750 for model in data["models"]:
751 if isinstance(model, dict):
752 model_id = model.get("name", model.get("id", ""))
753 models.append(
754 {
755 "id": model_id,
756 "name": model_id,
757 "owned_by": provider.provider_type,
758 }
759 )
760 else:
761 models.append(
762 {
763 "id": str(model),
764 "name": str(model),
765 "owned_by": provider.provider_type,
766 }
767 )
769 return {
770 "success": True,
771 "models": models,
772 "count": len(models),
773 }
775 except httpx.HTTPStatusError as e:
776 return {
777 "success": False,
778 "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
779 "models": [],
780 }
781 except httpx.RequestError as e:
782 return {
783 "success": False,
784 "error": f"Connection error: {str(e)}",
785 "models": [],
786 }
787 except Exception as e:
788 return {
789 "success": False,
790 "error": str(e),
791 "models": [],
792 }
795@llm_admin_router.post("/providers/{provider_id}/sync-models")
796@require_permission("admin.system_config")
797async def sync_provider_models(
798 request: Request,
799 provider_id: str,
800 db: Session = Depends(get_db),
801 current_user_ctx: dict = Depends(get_current_user_with_permissions),
802):
803 """Sync models from provider API to database.
805 Fetches available models from the provider and creates model records
806 for any that don't already exist.
808 Args:
809 request: FastAPI request object.
810 provider_id: Provider ID to sync models for.
811 db: Database session.
812 current_user_ctx: Authenticated user context.
814 Returns:
815 Sync results with counts of added/skipped models.
816 """
817 # First-Party
818 from mcpgateway.llm_schemas import LLMModelCreate
820 # First fetch models from the provider
821 # NOTE: Must pass as kwargs - require_permission decorator only searches kwargs for user context
822 fetch_result = await fetch_provider_models(
823 request=request,
824 provider_id=provider_id,
825 db=db,
826 current_user_ctx=current_user_ctx,
827 )
829 if not fetch_result.get("success"):
830 return fetch_result
832 models = fetch_result.get("models", [])
833 if not models:
834 return {
835 "success": True,
836 "message": "No models found to sync",
837 "added": 0,
838 "skipped": 0,
839 }
841 # Get existing models for this provider
842 existing_models, _ = llm_provider_service.list_models(db, provider_id=provider_id)
843 existing_model_ids = {m.model_id for m in existing_models}
845 added = 0
846 skipped = 0
848 for model in models:
849 model_id = model.get("id", "")
850 if not model_id:
851 continue
853 if model_id in existing_model_ids:
854 skipped += 1
855 continue
857 # Create the model
858 try:
859 model_create = LLMModelCreate(
860 provider_id=provider_id,
861 model_id=model_id,
862 model_name=model.get("name", model_id),
863 description=f"Auto-synced from {model.get('owned_by', 'provider')}",
864 supports_chat=True,
865 supports_streaming=True,
866 enabled=True,
867 )
868 llm_provider_service.create_model(db, model_create)
869 added += 1
870 except Exception as e:
871 logger.warning(f"Failed to create model {model_id}: {e}")
872 skipped += 1
874 return {
875 "success": True,
876 "message": f"Synced models: {added} added, {skipped} skipped",
877 "added": added,
878 "skipped": skipped,
879 "total": len(models),
880 }