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

1# -*- coding: utf-8 -*- 

2"""Location: ./mcpgateway/routers/llm_admin_router.py 

3Copyright 2025 

4SPDX-License-Identifier: Apache-2.0 

5 

6LLM Admin Router. 

7This module provides HTMX-based admin UI endpoints for LLM provider 

8and model management. 

9""" 

10 

11# Standard 

12from typing import Optional 

13 

14# Third-Party 

15from fastapi import APIRouter, Depends, HTTPException, Query, Request, status 

16from fastapi.responses import HTMLResponse 

17import orjson 

18from sqlalchemy.orm import Session 

19 

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 

30 

31# Initialize logging 

32logging_service = LoggingService() 

33logger = logging_service.get_logger(__name__) 

34 

35# Create router 

36llm_admin_router = APIRouter() 

37 

38# Initialize service 

39llm_provider_service = LLMProviderService() 

40 

41 

42# --------------------------------------------------------------------------- 

43# LLM Providers Partial 

44# --------------------------------------------------------------------------- 

45 

46 

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. 

57 

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. 

64 

65 Returns: 

66 HTML partial for providers table. 

67 """ 

68 

69 providers, total = llm_provider_service.list_providers( 

70 db=db, 

71 page=page, 

72 page_size=per_page, 

73 ) 

74 

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 } 

85 

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 ) 

105 

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 ) 

117 

118 

119# --------------------------------------------------------------------------- 

120# LLM Models Partial 

121# --------------------------------------------------------------------------- 

122 

123 

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. 

135 

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. 

143 

144 Returns: 

145 HTML partial for models table. 

146 """ 

147 

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 ) 

154 

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 } 

165 

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" 

176 

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 ) 

199 

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] 

203 

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 ) 

216 

217 

218# --------------------------------------------------------------------------- 

219# Provider Actions 

220# --------------------------------------------------------------------------- 

221 

222 

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. 

232 

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. 

238 

239 Returns: 

240 Updated provider row HTML. 

241 

242 Raises: 

243 HTTPException: If provider is not found. 

244 """ 

245 try: 

246 provider = llm_provider_service.set_provider_state(db, provider_id) 

247 

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)) 

268 

269 

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. 

279 

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. 

285 

286 Returns: 

287 JSON with status, provider_id, latency_ms, and optional error. 

288 

289 Raises: 

290 HTTPException: If provider is not found. 

291 """ 

292 try: 

293 health = await llm_provider_service.check_provider_health(db, provider_id) 

294 

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)) 

303 

304 

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. 

314 

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. 

320 

321 Returns: 

322 Empty response for HTMX row removal. 

323 

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)) 

332 

333 

334# --------------------------------------------------------------------------- 

335# Model Actions 

336# --------------------------------------------------------------------------- 

337 

338 

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. 

348 

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. 

354 

355 Returns: 

356 Updated model row HTML. 

357 

358 Raises: 

359 HTTPException: If model is not found. 

360 """ 

361 try: 

362 model = llm_provider_service.set_model_state(db, model_id) 

363 

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" 

369 

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)) 

391 

392 

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. 

402 

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. 

408 

409 Returns: 

410 Empty response for HTMX row removal. 

411 

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)) 

420 

421 

422# --------------------------------------------------------------------------- 

423# LLM API Info/Test Partial 

424# --------------------------------------------------------------------------- 

425 

426 

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. 

435 

436 Args: 

437 request: FastAPI request object. 

438 db: Database session. 

439 current_user_ctx: Authenticated user context. 

440 

441 Returns: 

442 HTML partial for API info and testing. 

443 """ 

444 # First-Party 

445 from mcpgateway.config import settings 

446 

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) 

450 

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 

469 

470 stats = { 

471 "total_providers": total_providers, 

472 "total_models": total_models, 

473 } 

474 

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 ) 

487 

488 

489# --------------------------------------------------------------------------- 

490# LLM API Test (Admin) - No API Key Required 

491# --------------------------------------------------------------------------- 

492 

493 

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. 

502 

503 This endpoint allows admins to test LLM models directly without needing 

504 to enter or have access to a virtual API key. 

505 

506 Args: 

507 request: FastAPI request object. 

508 db: Database session. 

509 current_user_ctx: Authenticated user context. 

510 

511 Returns: 

512 Test result with metrics. 

513 

514 Raises: 

515 HTTPException: If test fails. 

516 """ 

517 # Standard 

518 import time 

519 

520 # First-Party 

521 from mcpgateway.services.llm_proxy_service import LLMProxyService 

522 from mcpgateway.utils.orjson_response import ORJSONResponse 

523 

524 body = orjson.loads(await request.body()) 

525 

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) 

530 

531 start_time = time.time() 

532 

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) 

538 

539 model_list = [{"id": m.model_id, "owned_by": m.provider_name} for m in models] 

540 

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 ) 

552 

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 ) 

559 

560 # First-Party 

561 from mcpgateway.llm_schemas import ChatCompletionRequest, ChatMessage 

562 

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 ) 

570 

571 proxy_service = LLMProxyService() 

572 response = await proxy_service.chat_completion(db, chat_request) 

573 duration_ms = int((time.time() - start_time) * 1000) 

574 

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 "" 

579 

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 ) 

595 

596 else: 

597 raise HTTPException( 

598 status_code=status.HTTP_400_BAD_REQUEST, 

599 detail=f"Unknown test type: {test_type}", 

600 ) 

601 

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 ) 

615 

616 

617# --------------------------------------------------------------------------- 

618# Provider Defaults and Model Discovery 

619# --------------------------------------------------------------------------- 

620 

621 

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. 

629 

630 Args: 

631 request: FastAPI request object. 

632 current_user_ctx: Authenticated user context. 

633 

634 Returns: 

635 Dictionary of provider type to default config. 

636 """ 

637 return LLMProviderType.get_provider_defaults() 

638 

639 

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. 

647 

648 Args: 

649 request: FastAPI request object. 

650 current_user_ctx: Authenticated user context. 

651 

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 

657 

658 configs = get_all_provider_configs() 

659 return {provider_type: config.model_dump() for provider_type, config in configs.items()} 

660 

661 

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. 

671 

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. 

677 

678 Returns: 

679 List of available models from the provider. 

680 

681 Raises: 

682 HTTPException: If provider is not found. 

683 """ 

684 # Third-Party 

685 import httpx 

686 

687 # First-Party 

688 from mcpgateway.utils.services_auth import decode_auth 

689 

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)) 

694 

695 # Get provider defaults for model list support 

696 defaults = LLMProviderType.get_provider_defaults() 

697 provider_config = defaults.get(provider.provider_type, {}) 

698 

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 } 

705 

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 } 

714 

715 models_endpoint = provider_config.get("models_endpoint", "/models") 

716 url = f"{base_url.rstrip('/')}{models_endpoint}" 

717 

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}" 

725 

726 try: 

727 # First-Party 

728 from mcpgateway.services.http_client_service import get_admin_timeout, get_http_client # pylint: disable=import-outside-toplevel 

729 

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() 

734 

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 ) 

769 

770 return { 

771 "success": True, 

772 "models": models, 

773 "count": len(models), 

774 } 

775 

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 } 

794 

795 

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. 

805 

806 Fetches available models from the provider and creates model records 

807 for any that don't already exist. 

808 

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. 

814 

815 Returns: 

816 Sync results with counts of added/skipped models. 

817 """ 

818 # First-Party 

819 from mcpgateway.llm_schemas import LLMModelCreate 

820 

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 ) 

829 

830 if not fetch_result.get("success"): 

831 return fetch_result 

832 

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 } 

841 

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} 

845 

846 added = 0 

847 skipped = 0 

848 

849 for model in models: 

850 model_id = model.get("id", "") 

851 if not model_id: 

852 continue 

853 

854 if model_id in existing_model_ids: 

855 skipped += 1 

856 continue 

857 

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 

874 

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 }