269 lines
9.7 KiB
Python
269 lines
9.7 KiB
Python
"""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},
|
|
)
|
|
)
|