@@ -0,0 +1,268 @@
|
||||
"""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},
|
||||
)
|
||||
)
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
|
||||
|
||||
class TaskConstraintsModel(BaseModel):
|
||||
@@ -212,3 +212,54 @@ class TokenUsageEventResponse(BaseModel):
|
||||
output_tokens: int | None = None
|
||||
total_tokens: int
|
||||
occurred_at: datetime
|
||||
|
||||
|
||||
class LlmProviderProfileCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
provider_type: Literal["anthropic", "openai-compatible"]
|
||||
model: str = Field(min_length=1, max_length=200)
|
||||
base_url: str | None = Field(default=None, max_length=500)
|
||||
timeout_seconds: float = Field(gt=0, le=120)
|
||||
api_key: SecretStr
|
||||
|
||||
|
||||
class LlmProviderProfileUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
provider_type: Literal["anthropic", "openai-compatible"] | None = None
|
||||
model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
base_url: str | None = Field(default=None, max_length=500)
|
||||
timeout_seconds: float | None = Field(default=None, gt=0, le=120)
|
||||
enabled: bool | None = None
|
||||
api_key: SecretStr | None = None
|
||||
expected_revision: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class LlmProviderProfileActivateRequest(BaseModel):
|
||||
expected_settings_revision: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class LlmProviderProfileResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider_type: Literal["anthropic", "openai-compatible"]
|
||||
model: str
|
||||
base_url: str | None = None
|
||||
timeout_seconds: float
|
||||
enabled: bool
|
||||
revision: int
|
||||
has_api_key: bool
|
||||
key_last_rotated_at: datetime
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
active: bool
|
||||
|
||||
|
||||
class LlmProviderSettingsResponse(BaseModel):
|
||||
active_profile_id: str | None = None
|
||||
revision: int
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class LlmProviderProfileListResponse(BaseModel):
|
||||
settings: LlmProviderSettingsResponse
|
||||
items: list[LlmProviderProfileResponse]
|
||||
|
||||
Reference in New Issue
Block a user