Files
agentic-mobile-control/packages/cloud-platform/cloud/sdk/llm_provider_api.py
T
2026-07-14 00:31:47 +08:00

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