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

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

29 

30# Initialize logging 

31logging_service = LoggingService() 

32logger = logging_service.get_logger(__name__) 

33 

34# Create router 

35llm_admin_router = APIRouter() 

36 

37# Initialize service 

38llm_provider_service = LLMProviderService() 

39 

40 

41# --------------------------------------------------------------------------- 

42# LLM Providers Partial 

43# --------------------------------------------------------------------------- 

44 

45 

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. 

56 

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. 

63 

64 Returns: 

65 HTML partial for providers table. 

66 """ 

67 

68 providers, total = llm_provider_service.list_providers( 

69 db=db, 

70 page=page, 

71 page_size=per_page, 

72 ) 

73 

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 } 

84 

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 ) 

104 

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 ) 

116 

117 

118# --------------------------------------------------------------------------- 

119# LLM Models Partial 

120# --------------------------------------------------------------------------- 

121 

122 

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. 

134 

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. 

142 

143 Returns: 

144 HTML partial for models table. 

145 """ 

146 

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 ) 

153 

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 } 

164 

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" 

175 

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 ) 

198 

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] 

202 

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 ) 

215 

216 

217# --------------------------------------------------------------------------- 

218# Provider Actions 

219# --------------------------------------------------------------------------- 

220 

221 

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. 

231 

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. 

237 

238 Returns: 

239 Updated provider row HTML. 

240 

241 Raises: 

242 HTTPException: If provider is not found. 

243 """ 

244 try: 

245 provider = llm_provider_service.set_provider_state(db, provider_id) 

246 

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

267 

268 

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. 

278 

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. 

284 

285 Returns: 

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

287 

288 Raises: 

289 HTTPException: If provider is not found. 

290 """ 

291 try: 

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

293 

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

302 

303 

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. 

313 

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. 

319 

320 Returns: 

321 Empty response for HTMX row removal. 

322 

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

331 

332 

333# --------------------------------------------------------------------------- 

334# Model Actions 

335# --------------------------------------------------------------------------- 

336 

337 

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. 

347 

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. 

353 

354 Returns: 

355 Updated model row HTML. 

356 

357 Raises: 

358 HTTPException: If model is not found. 

359 """ 

360 try: 

361 model = llm_provider_service.set_model_state(db, model_id) 

362 

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" 

368 

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

390 

391 

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. 

401 

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. 

407 

408 Returns: 

409 Empty response for HTMX row removal. 

410 

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

419 

420 

421# --------------------------------------------------------------------------- 

422# LLM API Info/Test Partial 

423# --------------------------------------------------------------------------- 

424 

425 

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. 

434 

435 Args: 

436 request: FastAPI request object. 

437 db: Database session. 

438 current_user_ctx: Authenticated user context. 

439 

440 Returns: 

441 HTML partial for API info and testing. 

442 """ 

443 # First-Party 

444 from mcpgateway.config import settings 

445 

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) 

449 

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 

468 

469 stats = { 

470 "total_providers": total_providers, 

471 "total_models": total_models, 

472 } 

473 

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 ) 

486 

487 

488# --------------------------------------------------------------------------- 

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

490# --------------------------------------------------------------------------- 

491 

492 

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. 

501 

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

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

504 

505 Args: 

506 request: FastAPI request object. 

507 db: Database session. 

508 current_user_ctx: Authenticated user context. 

509 

510 Returns: 

511 Test result with metrics. 

512 

513 Raises: 

514 HTTPException: If test fails. 

515 """ 

516 # Standard 

517 import time 

518 

519 # First-Party 

520 from mcpgateway.services.llm_proxy_service import LLMProxyService 

521 from mcpgateway.utils.orjson_response import ORJSONResponse 

522 

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

524 

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) 

529 

530 start_time = time.time() 

531 

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) 

537 

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

539 

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 ) 

551 

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 ) 

558 

559 # First-Party 

560 from mcpgateway.llm_schemas import ChatCompletionRequest, ChatMessage 

561 

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 ) 

569 

570 proxy_service = LLMProxyService() 

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

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

573 

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

578 

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 ) 

594 

595 else: 

596 raise HTTPException( 

597 status_code=status.HTTP_400_BAD_REQUEST, 

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

599 ) 

600 

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 ) 

614 

615 

616# --------------------------------------------------------------------------- 

617# Provider Defaults and Model Discovery 

618# --------------------------------------------------------------------------- 

619 

620 

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. 

628 

629 Args: 

630 request: FastAPI request object. 

631 current_user_ctx: Authenticated user context. 

632 

633 Returns: 

634 Dictionary of provider type to default config. 

635 """ 

636 return LLMProviderType.get_provider_defaults() 

637 

638 

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. 

646 

647 Args: 

648 request: FastAPI request object. 

649 current_user_ctx: Authenticated user context. 

650 

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 

656 

657 configs = get_all_provider_configs() 

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

659 

660 

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. 

670 

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. 

676 

677 Returns: 

678 List of available models from the provider. 

679 

680 Raises: 

681 HTTPException: If provider is not found. 

682 """ 

683 # Third-Party 

684 import httpx 

685 

686 # First-Party 

687 from mcpgateway.utils.services_auth import decode_auth 

688 

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

693 

694 # Get provider defaults for model list support 

695 defaults = LLMProviderType.get_provider_defaults() 

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

697 

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 } 

704 

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 } 

713 

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

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

716 

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

724 

725 try: 

726 # First-Party 

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

728 

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

733 

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 ) 

768 

769 return { 

770 "success": True, 

771 "models": models, 

772 "count": len(models), 

773 } 

774 

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 } 

793 

794 

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. 

804 

805 Fetches available models from the provider and creates model records 

806 for any that don't already exist. 

807 

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. 

813 

814 Returns: 

815 Sync results with counts of added/skipped models. 

816 """ 

817 # First-Party 

818 from mcpgateway.llm_schemas import LLMModelCreate 

819 

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 ) 

828 

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

830 return fetch_result 

831 

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 } 

840 

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} 

844 

845 added = 0 

846 skipped = 0 

847 

848 for model in models: 

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

850 if not model_id: 

851 continue 

852 

853 if model_id in existing_model_ids: 

854 skipped += 1 

855 continue 

856 

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 

873 

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 }