"""Administrator API for Cloud-managed LLM Provider profiles.""" from __future__ import annotations from collections.abc import Callable from uuid import uuid4 from fastapi import APIRouter, HTTPException, Query, Request, status from cloud.auth import AuthProvider, LLM_PROVIDERS_ADMIN_SCOPE, Principal from cloud.llm_providers import ( LlmProviderActiveConflictError, LlmProviderConflictError, LlmProviderService, LlmProviderValidationError, ) from cloud.observability import current_correlation_id from cloud.provider_secrets import ( ProviderSecretConfigurationError, ProviderSecretDecryptionError, ) from cloud.sdk.models import ( LlmProviderProfileActivateRequest, LlmProviderProfileCreateRequest, LlmProviderProfileListResponse, LlmProviderProfileResponse, LlmProviderProfileUpdateRequest, LlmProviderSettingsResponse, ) from cloud.user_auth import AuthAuditEvent from core.models import utc_now def create_llm_provider_router( *, service: LlmProviderService, repository, auth_provider: AuthProvider, csrf_validator: Callable[[Request, Principal], bool], version_prefix: str = "/v1", ) -> APIRouter: router = APIRouter(prefix=version_prefix, tags=["llm-provider-management"]) def authorize(request: Request) -> Principal: principal = auth_provider.authenticate(request) if principal is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized", headers={"WWW-Authenticate": "Bearer"}, ) if principal.must_change_password or not principal.has_scope( LLM_PROVIDERS_ADMIN_SCOPE ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"missing required scope: {LLM_PROVIDERS_ADMIN_SCOPE}", ) return principal def require_csrf(request: Request, principal: Principal) -> None: if not csrf_validator(request, principal): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="CSRF validation failed", ) @router.get("/planner/providers", response_model=LlmProviderProfileListResponse) def list_profiles(request: Request) -> LlmProviderProfileListResponse: authorize(request) settings, profiles = service.list_profiles() return LlmProviderProfileListResponse( settings=_settings_response(settings), items=[ _profile_response(profile, settings.active_profile_id) for profile in profiles ], ) @router.post( "/planner/providers", response_model=LlmProviderProfileResponse, status_code=status.HTTP_201_CREATED, ) def create_profile( payload: LlmProviderProfileCreateRequest, request: Request, ) -> LlmProviderProfileResponse: principal = authorize(request) require_csrf(request, principal) try: profile = service.create_profile( name=payload.name, provider_type=payload.provider_type, model=payload.model, base_url=payload.base_url, timeout_seconds=payload.timeout_seconds, api_key=payload.api_key.get_secret_value(), now=utc_now(), ) except LlmProviderValidationError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) ) from exc except LlmProviderConflictError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc) ) from exc except ProviderSecretConfigurationError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc) ) from exc _audit(repository, principal, profile.id, "llm_provider_create") return _profile_response(profile, None) @router.patch( "/planner/providers/{profile_id}", response_model=LlmProviderProfileResponse ) def update_profile( profile_id: str, payload: LlmProviderProfileUpdateRequest, request: Request, ) -> LlmProviderProfileResponse: principal = authorize(request) require_csrf(request, principal) try: profile = service.update_profile( profile_id, name=payload.name, provider_type=payload.provider_type, model=payload.model, base_url=payload.base_url, base_url_supplied="base_url" in payload.model_fields_set, timeout_seconds=payload.timeout_seconds, enabled=payload.enabled, api_key=( payload.api_key.get_secret_value() if payload.api_key is not None else None ), expected_revision=payload.expected_revision, now=utc_now(), ) except KeyError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Provider profile not found", ) from exc except LlmProviderValidationError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) ) from exc except LlmProviderActiveConflictError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc) ) from exc except LlmProviderConflictError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc) ) from exc except ProviderSecretConfigurationError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc) ) from exc settings = repository.get_llm_provider_settings() _audit(repository, principal, profile.id, "llm_provider_update") return _profile_response(profile, settings.active_profile_id) @router.post( "/planner/providers/{profile_id}/activate", response_model=LlmProviderSettingsResponse, ) def activate_profile( profile_id: str, payload: LlmProviderProfileActivateRequest, request: Request, ) -> LlmProviderSettingsResponse: principal = authorize(request) require_csrf(request, principal) try: settings = service.activate_profile( profile_id, expected_settings_revision=payload.expected_settings_revision, now=utc_now(), ) except KeyError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Provider profile not found", ) from exc except (LlmProviderConflictError, ProviderSecretDecryptionError) as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc) ) from exc except ProviderSecretConfigurationError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc) ) from exc _audit(repository, principal, profile_id, "llm_provider_activate") return _settings_response(settings) @router.delete( "/planner/providers/{profile_id}", status_code=status.HTTP_204_NO_CONTENT ) def delete_profile( profile_id: str, request: Request, expected_revision: int | None = Query(default=None, ge=1), ) -> None: principal = authorize(request) require_csrf(request, principal) try: service.delete_profile(profile_id, expected_revision=expected_revision) except KeyError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Provider profile not found", ) from exc except LlmProviderConflictError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc) ) from exc _audit(repository, principal, profile_id, "llm_provider_delete") return router def _profile_response( profile, active_profile_id: str | None ) -> LlmProviderProfileResponse: return LlmProviderProfileResponse( id=profile.id, name=profile.name, provider_type=profile.provider_type, model=profile.model, base_url=profile.base_url, timeout_seconds=profile.timeout_seconds, enabled=profile.enabled, revision=profile.revision, has_api_key=bool(profile.api_key_ciphertext), key_last_rotated_at=profile.key_last_rotated_at, created_at=profile.created_at, updated_at=profile.updated_at, active=profile.id == active_profile_id, ) def _settings_response(settings) -> LlmProviderSettingsResponse: return LlmProviderSettingsResponse( active_profile_id=settings.active_profile_id, revision=settings.revision, updated_at=settings.updated_at, ) def _audit(repository, principal: Principal, profile_id: str, action: str) -> None: repository.record_auth_audit( AuthAuditEvent( id=uuid4().hex, occurred_at=utc_now(), actor_principal_id=principal.id, target_user_id=None, action=action, outcome="success", correlation_id=current_correlation_id(), metadata={"provider_profile_id": profile_id}, ) )