Initial public ModelForge release
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import require_admin, require_operator, require_viewer
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.acquisition import (
|
||||
ArtifactJobResponse,
|
||||
ArtifactSetResponse,
|
||||
DiscoveryCandidate,
|
||||
DiscoverySearchRequest,
|
||||
DownloadPlanCreate,
|
||||
DownloadPlanResponse,
|
||||
UpstreamRefreshRequest,
|
||||
UpstreamSnapshotResponse,
|
||||
)
|
||||
from modelforge_api.providers.huggingface import OfficialHuggingFaceProvider
|
||||
from modelforge_api.services.acquisition import AcquisitionService
|
||||
from modelforge_api.settings import Settings, get_settings
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1",
|
||||
tags=["artifact-acquisition"],
|
||||
dependencies=[Depends(require_viewer)],
|
||||
)
|
||||
|
||||
|
||||
def get_acquisition_service(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> AcquisitionService:
|
||||
token = settings.hf_token.get_secret_value() if settings.hf_token else None
|
||||
provider = OfficialHuggingFaceProvider(token=token, timeout=settings.hf_timeout_seconds)
|
||||
return AcquisitionService(session, settings, provider)
|
||||
|
||||
|
||||
Service = Annotated[AcquisitionService, Depends(get_acquisition_service)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/discovery/search",
|
||||
response_model=list[DiscoveryCandidate],
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def search(request: DiscoverySearchRequest, service: Service) -> list[DiscoveryCandidate]:
|
||||
return service.search(request)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/models/{model_id}/refresh-upstream",
|
||||
response_model=UpstreamSnapshotResponse,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def refresh_upstream(
|
||||
model_id: uuid.UUID, request: UpstreamRefreshRequest, service: Service
|
||||
) -> UpstreamSnapshotResponse:
|
||||
return service.refresh_model(model_id, request.revision)
|
||||
|
||||
|
||||
@router.get("/models/{model_id}/upstream", response_model=UpstreamSnapshotResponse)
|
||||
def model_upstream(model_id: uuid.UUID, service: Service) -> UpstreamSnapshotResponse:
|
||||
return service.latest_snapshot(model_id)
|
||||
|
||||
|
||||
@router.get("/revisions/{revision_id}/artifact-sets", response_model=list[ArtifactSetResponse])
|
||||
def artifact_sets(revision_id: uuid.UUID, service: Service) -> list[ArtifactSetResponse]:
|
||||
return service.artifact_sets(revision_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/download-plans",
|
||||
response_model=DownloadPlanResponse,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def create_download_plan(request: DownloadPlanCreate, service: Service) -> DownloadPlanResponse:
|
||||
return service.create_plan(request)
|
||||
|
||||
|
||||
@router.get("/download-plans/{plan_id}", response_model=DownloadPlanResponse)
|
||||
def download_plan(plan_id: uuid.UUID, service: Service) -> DownloadPlanResponse:
|
||||
return service.plan_response(plan_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/download-plans/{plan_id}/approve",
|
||||
response_model=DownloadPlanResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def approve_download_plan(plan_id: uuid.UUID, service: Service) -> DownloadPlanResponse:
|
||||
return service.approve_plan(plan_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/download-plans/{plan_id}/execute",
|
||||
response_model=ArtifactJobResponse,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def execute_download_plan(plan_id: uuid.UUID, service: Service) -> ArtifactJobResponse:
|
||||
return service.execute_plan(plan_id)
|
||||
|
||||
|
||||
@router.get("/artifact-jobs", response_model=list[ArtifactJobResponse])
|
||||
def artifact_jobs(
|
||||
service: Service, limit: Annotated[int, Query(ge=1, le=100)] = 100
|
||||
) -> list[ArtifactJobResponse]:
|
||||
return service.jobs()[:limit]
|
||||
|
||||
|
||||
@router.get("/artifact-jobs/{job_id}", response_model=ArtifactJobResponse)
|
||||
def artifact_job(job_id: uuid.UUID, service: Service) -> ArtifactJobResponse:
|
||||
return service.job(job_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/artifact-jobs/{job_id}/cancel",
|
||||
response_model=ArtifactJobResponse,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def cancel_artifact_job(job_id: uuid.UUID, service: Service) -> ArtifactJobResponse:
|
||||
return service.cancel(job_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/artifact-jobs/{job_id}/retry",
|
||||
response_model=ArtifactJobResponse,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def retry_artifact_job(job_id: uuid.UUID, service: Service) -> ArtifactJobResponse:
|
||||
return service.retry(job_id)
|
||||
@@ -0,0 +1,371 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict, deque
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import Admin
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.acquisition import (
|
||||
AgentArtifactJobLease,
|
||||
AgentJobComplete,
|
||||
AgentJobControl,
|
||||
AgentJobFailure,
|
||||
AgentJobProgress,
|
||||
ArtifactJobResponse,
|
||||
)
|
||||
from modelforge_api.domain.agent_protocol import (
|
||||
EnrollmentRequest,
|
||||
EnrollmentResponse,
|
||||
EnrollmentTokenCreate,
|
||||
EnrollmentTokenCreated,
|
||||
EnrollmentTokenSummary,
|
||||
HeartbeatRequest,
|
||||
InventoryReport,
|
||||
NodeCredentialCreated,
|
||||
NodeManagementUpdate,
|
||||
ObservationAck,
|
||||
TelemetryReport,
|
||||
)
|
||||
from modelforge_api.domain.node_decommission import (
|
||||
NodeDecommissionExecute,
|
||||
NodeDecommissionPreview,
|
||||
NodeDecommissionResult,
|
||||
)
|
||||
from modelforge_api.domain.runtime import (
|
||||
AgentRuntimeProbeComplete,
|
||||
AgentRuntimeProbeControl,
|
||||
AgentRuntimeProbeFailure,
|
||||
AgentRuntimeProbeLease,
|
||||
AgentRuntimeProbeProgress,
|
||||
RuntimeProbeResponse,
|
||||
)
|
||||
from modelforge_api.persistence.models import ComputeNode, NodeCredential
|
||||
from modelforge_api.providers.huggingface import OfficialHuggingFaceProvider
|
||||
from modelforge_api.services.acquisition import AcquisitionService
|
||||
from modelforge_api.services.node_agent import NodeAgentService, NodeAuthenticationEvidence
|
||||
from modelforge_api.services.node_decommission import NodeDecommissionService
|
||||
from modelforge_api.services.runtime import RuntimeService
|
||||
from modelforge_api.settings import Settings, get_settings
|
||||
|
||||
router = APIRouter(tags=["node-agent"])
|
||||
|
||||
|
||||
class ActionResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
|
||||
|
||||
class AttemptLimiter:
|
||||
def __init__(self, limit: int = 10, window_seconds: int = 60) -> None:
|
||||
self.limit = limit
|
||||
self.window_seconds = window_seconds
|
||||
self.attempts: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
def check(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
bucket = self.attempts[key]
|
||||
while bucket and bucket[0] <= now - self.window_seconds:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= self.limit:
|
||||
raise HTTPException(status_code=429, detail="enrollment rate limit exceeded")
|
||||
bucket.append(now)
|
||||
|
||||
|
||||
enrollment_limiter = AttemptLimiter()
|
||||
|
||||
|
||||
def get_agent_service(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> NodeAgentService:
|
||||
return NodeAgentService(session, settings)
|
||||
|
||||
|
||||
Service = Annotated[NodeAgentService, Depends(get_agent_service)]
|
||||
|
||||
|
||||
def get_decommission_service(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> NodeDecommissionService:
|
||||
return NodeDecommissionService(session)
|
||||
|
||||
|
||||
DecommissionService = Annotated[NodeDecommissionService, Depends(get_decommission_service)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/node-enrollments",
|
||||
response_model=EnrollmentTokenCreated,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_enrollment(
|
||||
request: EnrollmentTokenCreate, service: Service, _admin: Admin, response: Response
|
||||
) -> EnrollmentTokenCreated:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return service.create_enrollment(request)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/node-enrollments", response_model=list[EnrollmentTokenSummary])
|
||||
def list_enrollments(service: Service, _admin: Admin) -> list[EnrollmentTokenSummary]:
|
||||
return [
|
||||
EnrollmentTokenSummary(
|
||||
id=str(item.id),
|
||||
created_at=item.created_at,
|
||||
expires_at=item.expires_at,
|
||||
used_at=item.used_at,
|
||||
revoked_at=item.revoked_at,
|
||||
)
|
||||
for item in service.repository.enrollments()
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/api/v1/admin/node-enrollments/{enrollment_id}", response_model=ActionResponse)
|
||||
def revoke_enrollment(enrollment_id: uuid.UUID, service: Service, _admin: Admin) -> ActionResponse:
|
||||
service.revoke_enrollment(enrollment_id)
|
||||
return ActionResponse()
|
||||
|
||||
|
||||
@router.patch("/api/v1/admin/hardware/nodes/{node_id}", response_model=ActionResponse)
|
||||
def update_node(
|
||||
node_id: uuid.UUID,
|
||||
request: NodeManagementUpdate,
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
) -> ActionResponse:
|
||||
service.update_node(node_id, request)
|
||||
return ActionResponse()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/hardware/nodes/{node_id}/decommission/preview",
|
||||
response_model=NodeDecommissionPreview,
|
||||
)
|
||||
def preview_node_decommission(
|
||||
node_id: uuid.UUID, service: DecommissionService, _admin: Admin
|
||||
) -> NodeDecommissionPreview:
|
||||
return service.preview(node_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/hardware/nodes/{node_id}/decommission",
|
||||
response_model=NodeDecommissionResult,
|
||||
)
|
||||
def execute_node_decommission(
|
||||
node_id: uuid.UUID,
|
||||
request: NodeDecommissionExecute,
|
||||
service: DecommissionService,
|
||||
_admin: Admin,
|
||||
) -> NodeDecommissionResult:
|
||||
return service.execute(node_id, request)
|
||||
|
||||
|
||||
@router.delete("/api/v1/admin/hardware/nodes/{node_id}/credential", response_model=ActionResponse)
|
||||
def revoke_credential(node_id: uuid.UUID, service: Service, _admin: Admin) -> ActionResponse:
|
||||
service.revoke_credential(node_id)
|
||||
return ActionResponse()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/hardware/nodes/{node_id}/credential/rotate",
|
||||
response_model=NodeCredentialCreated,
|
||||
)
|
||||
def rotate_credential(
|
||||
node_id: uuid.UUID, service: Service, _admin: Admin, response: Response
|
||||
) -> NodeCredentialCreated:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return service.rotate_credential(node_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/agent/enroll",
|
||||
response_model=EnrollmentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def enroll(
|
||||
request: EnrollmentRequest, http_request: Request, service: Service, response: Response
|
||||
) -> EnrollmentResponse:
|
||||
enrollment_limiter.check(http_request.client.host if http_request.client else "unknown")
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return service.enroll(request)
|
||||
|
||||
|
||||
def authenticated_node(
|
||||
request: Request,
|
||||
service: Service,
|
||||
authorization: Annotated[str | None, Header(alias="Authorization")] = None,
|
||||
) -> tuple[NodeCredential, ComputeNode]:
|
||||
evidence = getattr(request.state, "node_authentication", None)
|
||||
if isinstance(evidence, NodeAuthenticationEvidence):
|
||||
return service.reuse_authentication(evidence, authorization)
|
||||
return service.authenticate(authorization)
|
||||
|
||||
|
||||
NodeIdentity = tuple[NodeCredential, ComputeNode]
|
||||
AgentIdentity = Annotated[NodeIdentity, Depends(authenticated_node)]
|
||||
|
||||
|
||||
def get_agent_acquisition_service(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> AcquisitionService:
|
||||
token = settings.hf_token.get_secret_value() if settings.hf_token else None
|
||||
return AcquisitionService(
|
||||
session,
|
||||
settings,
|
||||
OfficialHuggingFaceProvider(token=token, timeout=settings.hf_timeout_seconds),
|
||||
actor_type="node_agent",
|
||||
actor_id="authenticated-node",
|
||||
)
|
||||
|
||||
|
||||
AgentAcquisition = Annotated[AcquisitionService, Depends(get_agent_acquisition_service)]
|
||||
|
||||
|
||||
def get_agent_runtime_service(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> RuntimeService:
|
||||
return RuntimeService(
|
||||
session,
|
||||
settings,
|
||||
actor_type="runtime_worker",
|
||||
actor_id="authenticated-node",
|
||||
)
|
||||
|
||||
|
||||
AgentRuntime = Annotated[RuntimeService, Depends(get_agent_runtime_service)]
|
||||
|
||||
|
||||
@router.post("/api/v1/agent/heartbeat", response_model=ObservationAck)
|
||||
def heartbeat(
|
||||
request: HeartbeatRequest, service: Service, identity: AgentIdentity
|
||||
) -> ObservationAck:
|
||||
_credential, node = identity
|
||||
return service.heartbeat(node, request)
|
||||
|
||||
|
||||
@router.put("/api/v1/agent/inventory", response_model=ObservationAck)
|
||||
def publish_inventory(
|
||||
request: InventoryReport, service: Service, identity: AgentIdentity
|
||||
) -> ObservationAck:
|
||||
_credential, node = identity
|
||||
return service.publish_inventory(node, request)
|
||||
|
||||
|
||||
@router.put("/api/v1/agent/telemetry", response_model=ObservationAck)
|
||||
def publish_telemetry(
|
||||
request: TelemetryReport, service: Service, identity: AgentIdentity
|
||||
) -> ObservationAck:
|
||||
_credential, node = identity
|
||||
return service.publish_telemetry(node, request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/agent/artifact-jobs/next",
|
||||
response_model=AgentArtifactJobLease | None,
|
||||
)
|
||||
def claim_artifact_job(
|
||||
acquisition: AgentAcquisition, identity: AgentIdentity
|
||||
) -> AgentArtifactJobLease | None:
|
||||
_credential, node = identity
|
||||
return acquisition.claim_next(node)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/agent/artifact-jobs/{job_id}/progress",
|
||||
response_model=AgentJobControl,
|
||||
)
|
||||
def artifact_job_progress(
|
||||
job_id: uuid.UUID,
|
||||
request: AgentJobProgress,
|
||||
acquisition: AgentAcquisition,
|
||||
identity: AgentIdentity,
|
||||
) -> AgentJobControl:
|
||||
_credential, node = identity
|
||||
return acquisition.progress(job_id, node, request)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/agent/artifact-jobs/{job_id}/complete",
|
||||
response_model=ArtifactJobResponse,
|
||||
)
|
||||
def artifact_job_complete(
|
||||
job_id: uuid.UUID,
|
||||
request: AgentJobComplete,
|
||||
acquisition: AgentAcquisition,
|
||||
identity: AgentIdentity,
|
||||
) -> ArtifactJobResponse:
|
||||
_credential, node = identity
|
||||
return acquisition.complete(job_id, node, request)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/agent/artifact-jobs/{job_id}/fail",
|
||||
response_model=ArtifactJobResponse,
|
||||
)
|
||||
def artifact_job_fail(
|
||||
job_id: uuid.UUID,
|
||||
request: AgentJobFailure,
|
||||
acquisition: AgentAcquisition,
|
||||
identity: AgentIdentity,
|
||||
) -> ArtifactJobResponse:
|
||||
_credential, node = identity
|
||||
return acquisition.fail(job_id, node, request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/agent/runtime-probes/next",
|
||||
response_model=AgentRuntimeProbeLease | None,
|
||||
)
|
||||
def claim_runtime_probe(
|
||||
runtime: AgentRuntime, identity: AgentIdentity
|
||||
) -> AgentRuntimeProbeLease | None:
|
||||
_credential, node = identity
|
||||
return runtime.claim_next(node)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/agent/runtime-probes/{probe_id}/progress",
|
||||
response_model=AgentRuntimeProbeControl,
|
||||
)
|
||||
def runtime_probe_progress(
|
||||
probe_id: uuid.UUID,
|
||||
request: AgentRuntimeProbeProgress,
|
||||
runtime: AgentRuntime,
|
||||
identity: AgentIdentity,
|
||||
) -> AgentRuntimeProbeControl:
|
||||
_credential, node = identity
|
||||
return runtime.progress(probe_id, node, request)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/agent/runtime-probes/{probe_id}/complete",
|
||||
response_model=RuntimeProbeResponse,
|
||||
)
|
||||
def runtime_probe_complete(
|
||||
probe_id: uuid.UUID,
|
||||
request: AgentRuntimeProbeComplete,
|
||||
runtime: AgentRuntime,
|
||||
identity: AgentIdentity,
|
||||
) -> RuntimeProbeResponse:
|
||||
_credential, node = identity
|
||||
return runtime.complete(probe_id, node, request)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/agent/runtime-probes/{probe_id}/fail",
|
||||
response_model=RuntimeProbeResponse,
|
||||
)
|
||||
def runtime_probe_fail(
|
||||
probe_id: uuid.UUID,
|
||||
request: AgentRuntimeProbeFailure,
|
||||
runtime: AgentRuntime,
|
||||
identity: AgentIdentity,
|
||||
) -> RuntimeProbeResponse:
|
||||
_credential, node = identity
|
||||
return runtime.fail(probe_id, node, request)
|
||||
@@ -0,0 +1,273 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import require_viewer
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.schemas import (
|
||||
CapabilityContractResponse,
|
||||
CapabilityEstateResponse,
|
||||
InstallationDependencyResponse,
|
||||
ModelInstallationRationaleResponse,
|
||||
ProjectBindingResponse,
|
||||
ProjectResponse,
|
||||
)
|
||||
from modelforge_api.persistence.models import (
|
||||
ArtifactSet,
|
||||
Capability,
|
||||
CapabilityContract,
|
||||
CapabilityDeployment,
|
||||
CapabilityEvaluationRun,
|
||||
CapabilityResourceEnvelope,
|
||||
Model,
|
||||
ModelRevision,
|
||||
Project,
|
||||
ProjectBinding,
|
||||
ProjectFitEvidence,
|
||||
ResidencyAllocation,
|
||||
RuntimeProfile,
|
||||
ServiceClient,
|
||||
)
|
||||
from modelforge_api.services.manifest_registry import ManifestRegistry, get_manifest_registry
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1", tags=["registry"], dependencies=[Depends(require_viewer)]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/capabilities", response_model=list[CapabilityContractResponse])
|
||||
def list_capabilities(
|
||||
registry: ManifestRegistry = Depends(get_manifest_registry),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[CapabilityContractResponse]:
|
||||
responses = []
|
||||
for contract in registry.capabilities():
|
||||
stable = session.execute(
|
||||
select(CapabilityDeployment)
|
||||
.join(
|
||||
CapabilityContract,
|
||||
CapabilityContract.id == CapabilityDeployment.capability_contract_id,
|
||||
)
|
||||
.join(Capability, Capability.id == CapabilityContract.capability_id)
|
||||
.where(
|
||||
Capability.key == contract.capability,
|
||||
CapabilityContract.version == contract.version,
|
||||
CapabilityDeployment.status == "stable",
|
||||
CapabilityDeployment.production.is_(True),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
responses.append(
|
||||
CapabilityContractResponse(
|
||||
key=contract.capability,
|
||||
version=contract.version,
|
||||
description=contract.description,
|
||||
contract=contract,
|
||||
stable_deployment=(
|
||||
{
|
||||
"id": str(stable.id),
|
||||
"status": stable.status,
|
||||
"health": stable.health_status,
|
||||
"production": stable.production,
|
||||
}
|
||||
if stable
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return responses
|
||||
|
||||
|
||||
@router.get("/capability-estate", response_model=list[CapabilityEstateResponse])
|
||||
def capability_estate(
|
||||
registry: ManifestRegistry = Depends(get_manifest_registry),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[CapabilityEstateResponse]:
|
||||
responses: list[CapabilityEstateResponse] = []
|
||||
for manifest in registry.capabilities():
|
||||
contract = session.scalar(
|
||||
select(CapabilityContract)
|
||||
.join(Capability, Capability.id == CapabilityContract.capability_id)
|
||||
.where(Capability.key == manifest.capability, CapabilityContract.version == manifest.version)
|
||||
)
|
||||
deployment = None
|
||||
model = None
|
||||
revision = None
|
||||
profile = None
|
||||
envelope = None
|
||||
latest_evaluation = None
|
||||
if contract:
|
||||
deployment = session.scalar(
|
||||
select(CapabilityDeployment)
|
||||
.where(CapabilityDeployment.capability_contract_id == contract.id)
|
||||
.order_by(
|
||||
CapabilityDeployment.production.desc(),
|
||||
CapabilityDeployment.created_at.desc(),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if deployment:
|
||||
artifact_set = session.get(ArtifactSet, deployment.artifact_set_id)
|
||||
revision = session.get(ModelRevision, artifact_set.revision_id) if artifact_set else None
|
||||
model = session.get(Model, revision.model_id) if revision else None
|
||||
profile = session.get(RuntimeProfile, deployment.runtime_profile_id)
|
||||
envelope = session.scalar(
|
||||
select(CapabilityResourceEnvelope).where(
|
||||
CapabilityResourceEnvelope.capability_deployment_id == deployment.id
|
||||
)
|
||||
)
|
||||
latest_evaluation = session.scalar(
|
||||
select(CapabilityEvaluationRun)
|
||||
.where(CapabilityEvaluationRun.capability_deployment_id == deployment.id)
|
||||
.order_by(CapabilityEvaluationRun.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
operational_state = (
|
||||
deployment.status
|
||||
if deployment
|
||||
else ("blocked" if manifest.estate.stability == "blocked" else "not_deployed")
|
||||
)
|
||||
responses.append(
|
||||
CapabilityEstateResponse(
|
||||
capability=manifest.capability,
|
||||
version=manifest.version,
|
||||
category=manifest.estate.category,
|
||||
purpose=manifest.estate.purpose,
|
||||
declared_stability=manifest.estate.stability,
|
||||
operational_state=operational_state,
|
||||
current_deployment_id=deployment.id if deployment else None,
|
||||
model=model.display_name if model else None,
|
||||
revision=revision.resolved_commit_sha if revision else None,
|
||||
runtime=profile.runtime_type if profile else None,
|
||||
node=str(deployment.compute_node_id) if deployment else None,
|
||||
resource_class=manifest.estate.resource_class,
|
||||
measured_required_vram_bytes=envelope.required_vram_bytes if envelope else None,
|
||||
consumers=manifest.estate.consumers,
|
||||
privacy_class=manifest.privacy.classification,
|
||||
evaluation_type=manifest.estate.evaluation_type,
|
||||
evaluation_state=latest_evaluation.status if latest_evaluation else "not_evaluated",
|
||||
)
|
||||
)
|
||||
return responses
|
||||
|
||||
|
||||
@router.get(
|
||||
"/models/installation-rationale",
|
||||
response_model=list[ModelInstallationRationaleResponse],
|
||||
)
|
||||
def model_installation_rationale(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[ModelInstallationRationaleResponse]:
|
||||
responses: list[ModelInstallationRationaleResponse] = []
|
||||
for model in session.scalars(select(Model).order_by(Model.display_name)).all():
|
||||
revisions = session.scalars(
|
||||
select(ModelRevision).where(ModelRevision.model_id == model.id)
|
||||
).all()
|
||||
revision_ids = [item.id for item in revisions]
|
||||
sets = (
|
||||
session.scalars(select(ArtifactSet).where(ArtifactSet.revision_id.in_(revision_ids))).all()
|
||||
if revision_ids
|
||||
else []
|
||||
)
|
||||
installed_sets = [item for item in sets if item.availability == "local"]
|
||||
dependencies: list[InstallationDependencyResponse] = []
|
||||
for artifact_set in installed_sets:
|
||||
deployments = session.scalars(
|
||||
select(CapabilityDeployment).where(
|
||||
CapabilityDeployment.artifact_set_id == artifact_set.id
|
||||
)
|
||||
).all()
|
||||
for deployment in deployments:
|
||||
contract = session.get(CapabilityContract, deployment.capability_contract_id)
|
||||
capability = session.get(Capability, contract.capability_id) if contract else None
|
||||
if not contract or not capability:
|
||||
continue
|
||||
projects = session.scalars(
|
||||
select(Project)
|
||||
.join(ProjectBinding, ProjectBinding.project_id == Project.id)
|
||||
.where(
|
||||
ProjectBinding.capability_contract_id == contract.id,
|
||||
ProjectBinding.deprecated_at.is_(None),
|
||||
)
|
||||
.distinct()
|
||||
).all()
|
||||
active_projects = session.scalars(
|
||||
select(Project)
|
||||
.join(ProjectBinding, ProjectBinding.project_id == Project.id)
|
||||
.join(ServiceClient, ServiceClient.project_binding_id == ProjectBinding.id)
|
||||
.where(
|
||||
ProjectBinding.capability_contract_id == contract.id,
|
||||
ServiceClient.status == "active",
|
||||
)
|
||||
.distinct()
|
||||
).all()
|
||||
project_fit_ids = session.scalars(
|
||||
select(ProjectFitEvidence.id)
|
||||
.join(
|
||||
ProjectBinding,
|
||||
ProjectBinding.id == ProjectFitEvidence.project_binding_id,
|
||||
)
|
||||
.where(ProjectBinding.capability_contract_id == contract.id)
|
||||
).all()
|
||||
evaluations = session.scalars(
|
||||
select(CapabilityEvaluationRun).where(
|
||||
CapabilityEvaluationRun.capability_deployment_id == deployment.id
|
||||
)
|
||||
).all()
|
||||
residency = session.scalar(
|
||||
select(ResidencyAllocation).where(
|
||||
ResidencyAllocation.capability_deployment_id == deployment.id
|
||||
)
|
||||
)
|
||||
dependencies.append(
|
||||
InstallationDependencyResponse(
|
||||
capability=capability.key,
|
||||
version=contract.version,
|
||||
deployment_id=deployment.id,
|
||||
channel=deployment.channel,
|
||||
production=deployment.production,
|
||||
project_consumers=[item.key for item in projects],
|
||||
active_project_consumers=[item.key for item in active_projects],
|
||||
project_fit_evidence_ids=list(project_fit_ids),
|
||||
evaluation_run_ids=[item.id for item in evaluations],
|
||||
last_used_at=residency.last_used_at if residency else None,
|
||||
)
|
||||
)
|
||||
blockers = []
|
||||
if dependencies:
|
||||
blockers.append("capability_deployment_dependency")
|
||||
if any(item.production for item in dependencies):
|
||||
blockers.append("production_dependency")
|
||||
responses.append(
|
||||
ModelInstallationRationaleResponse(
|
||||
model_id=model.id,
|
||||
display_name=model.display_name,
|
||||
upstream_source=model.upstream_source,
|
||||
installed=bool(installed_sets),
|
||||
installed_bytes=sum(item.total_size_bytes for item in installed_sets),
|
||||
dependencies=dependencies,
|
||||
can_delete=bool(installed_sets) and not blockers,
|
||||
deletion_blockers=blockers,
|
||||
)
|
||||
)
|
||||
return responses
|
||||
|
||||
|
||||
@router.get("/projects", response_model=list[ProjectResponse])
|
||||
def list_projects(
|
||||
registry: ManifestRegistry = Depends(get_manifest_registry),
|
||||
) -> list[ProjectResponse]:
|
||||
return [
|
||||
ProjectResponse(
|
||||
**project.project.model_dump(),
|
||||
notes=project.notes,
|
||||
bindings=[
|
||||
ProjectBindingResponse(
|
||||
capability=capability,
|
||||
contract_version=binding.contract_version,
|
||||
binding=binding,
|
||||
)
|
||||
for capability, binding in project.bindings.items()
|
||||
],
|
||||
)
|
||||
for project in registry.projects()
|
||||
]
|
||||
@@ -0,0 +1,387 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import Admin, require_viewer
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.capability_evaluation import (
|
||||
CapabilityAdvisorResponse,
|
||||
CapabilityEvaluationRunCreate,
|
||||
CapabilityEvaluationRunResponse,
|
||||
CapabilityEvaluationSuiteCreate,
|
||||
CapabilityEvaluationSuiteResponse,
|
||||
)
|
||||
from modelforge_api.domain.evaluation import (
|
||||
AdvisorPolicyResponse,
|
||||
AdvisorPolicyUpdate,
|
||||
AdvisorRecommendationCreate,
|
||||
AdvisorRecommendationDismiss,
|
||||
AdvisorRecommendationResponse,
|
||||
DiscoveryCandidateAssessmentCreate,
|
||||
DiscoveryCandidateAssessmentResponse,
|
||||
EmbeddingMigrationCreate,
|
||||
EmbeddingMigrationResponse,
|
||||
EvaluationCaseDefinitionResponse,
|
||||
EvaluationCaseResultResponse,
|
||||
EvaluationComparisonCreate,
|
||||
EvaluationComparisonResponse,
|
||||
EvaluationRunComplete,
|
||||
EvaluationRunCreate,
|
||||
EvaluationRunResponse,
|
||||
EvaluationSuiteCreate,
|
||||
EvaluationSuiteResponse,
|
||||
MigrationUpdate,
|
||||
ModelComparisonCreate,
|
||||
ModelComparisonResponse,
|
||||
RerankingCaseResultResponse,
|
||||
RerankingRunComplete,
|
||||
RerankingRunCreate,
|
||||
RerankingRunResponse,
|
||||
RetrievalCandidatePoolCreate,
|
||||
RetrievalCandidatePoolResponse,
|
||||
RetrievalPipelineIdentityCreate,
|
||||
RetrievalPipelineIdentityResponse,
|
||||
)
|
||||
from modelforge_api.services.capability_evaluation import CapabilityEvaluationService
|
||||
from modelforge_api.services.evaluation import EvaluationService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1",
|
||||
tags=["project-evaluation"],
|
||||
dependencies=[Depends(require_viewer)],
|
||||
)
|
||||
|
||||
|
||||
def service(session: Annotated[Session, Depends(get_session)]) -> EvaluationService:
|
||||
return EvaluationService(session)
|
||||
|
||||
|
||||
Service = Annotated[EvaluationService, Depends(service)]
|
||||
|
||||
|
||||
def capability_service(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> CapabilityEvaluationService:
|
||||
return CapabilityEvaluationService(session)
|
||||
|
||||
|
||||
CapabilityService = Annotated[CapabilityEvaluationService, Depends(capability_service)]
|
||||
|
||||
|
||||
@router.get("/capability-advisor", response_model=list[CapabilityAdvisorResponse])
|
||||
def capability_advisor(svc: CapabilityService) -> list[CapabilityAdvisorResponse]:
|
||||
return svc.advisor()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/capability-evaluation-suites",
|
||||
response_model=list[CapabilityEvaluationSuiteResponse],
|
||||
)
|
||||
def capability_suites(svc: CapabilityService) -> list[CapabilityEvaluationSuiteResponse]:
|
||||
return svc.suites()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/capability-evaluation-suites",
|
||||
response_model=CapabilityEvaluationSuiteResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_capability_suite(
|
||||
request: CapabilityEvaluationSuiteCreate, svc: CapabilityService, _admin: Admin
|
||||
) -> CapabilityEvaluationSuiteResponse:
|
||||
return svc.create_suite(request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/capability-evaluation-runs",
|
||||
response_model=list[CapabilityEvaluationRunResponse],
|
||||
)
|
||||
def capability_runs(svc: CapabilityService) -> list[CapabilityEvaluationRunResponse]:
|
||||
return svc.runs()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/capability-evaluation-runs",
|
||||
response_model=CapabilityEvaluationRunResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_capability_run(
|
||||
request: CapabilityEvaluationRunCreate, svc: CapabilityService, _admin: Admin
|
||||
) -> CapabilityEvaluationRunResponse:
|
||||
return svc.create_run(request)
|
||||
|
||||
|
||||
@router.get("/evaluation-suites", response_model=list[EvaluationSuiteResponse])
|
||||
def suites(svc: Service) -> list[EvaluationSuiteResponse]:
|
||||
return svc.suites()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/evaluation-suites",
|
||||
response_model=EvaluationSuiteResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_suite(
|
||||
request: EvaluationSuiteCreate, svc: Service, _admin: Admin
|
||||
) -> EvaluationSuiteResponse:
|
||||
return svc.create_suite(request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/evaluation-suites/{suite_id}/revisions/{revision_id}/cases",
|
||||
response_model=list[EvaluationCaseDefinitionResponse],
|
||||
)
|
||||
def suite_cases(
|
||||
suite_id: uuid.UUID, revision_id: uuid.UUID, svc: Service
|
||||
) -> list[EvaluationCaseDefinitionResponse]:
|
||||
return svc.suite_cases(suite_id, revision_id)
|
||||
|
||||
|
||||
@router.get("/evaluation-runs", response_model=list[EvaluationRunResponse])
|
||||
def runs(svc: Service) -> list[EvaluationRunResponse]:
|
||||
return svc.runs()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/evaluation-runs", response_model=EvaluationRunResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def create_run(request: EvaluationRunCreate, svc: Service, _admin: Admin) -> EvaluationRunResponse:
|
||||
return svc.create_run(request)
|
||||
|
||||
|
||||
@router.get("/evaluation-runs/{run_id}", response_model=EvaluationRunResponse)
|
||||
def run(run_id: uuid.UUID, svc: Service) -> EvaluationRunResponse:
|
||||
return svc.run(run_id)
|
||||
|
||||
|
||||
@router.post("/evaluation-runs/{run_id}/complete", response_model=EvaluationRunResponse)
|
||||
def complete_run(
|
||||
run_id: uuid.UUID, request: EvaluationRunComplete, svc: Service, _admin: Admin
|
||||
) -> EvaluationRunResponse:
|
||||
return svc.complete_run(run_id, request)
|
||||
|
||||
|
||||
@router.get("/evaluation-runs/{run_id}/cases", response_model=list[EvaluationCaseResultResponse])
|
||||
def case_results(run_id: uuid.UUID, svc: Service) -> list[EvaluationCaseResultResponse]:
|
||||
return svc.case_results(run_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/retrieval-candidate-pools",
|
||||
response_model=RetrievalCandidatePoolResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_candidate_pool(
|
||||
request: RetrievalCandidatePoolCreate, svc: Service, _admin: Admin
|
||||
) -> RetrievalCandidatePoolResponse:
|
||||
return svc.create_candidate_pool(request)
|
||||
|
||||
|
||||
@router.get("/retrieval-candidate-pools", response_model=list[RetrievalCandidatePoolResponse])
|
||||
def candidate_pools(svc: Service) -> list[RetrievalCandidatePoolResponse]:
|
||||
return svc.candidate_pools()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/retrieval-pipeline-identities",
|
||||
response_model=RetrievalPipelineIdentityResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_pipeline_identity(
|
||||
request: RetrievalPipelineIdentityCreate, svc: Service, _admin: Admin
|
||||
) -> RetrievalPipelineIdentityResponse:
|
||||
return svc.create_pipeline_identity(request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/retrieval-pipeline-identities", response_model=list[RetrievalPipelineIdentityResponse]
|
||||
)
|
||||
def pipeline_identities(svc: Service) -> list[RetrievalPipelineIdentityResponse]:
|
||||
return svc.pipeline_identities()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reranking-runs",
|
||||
response_model=RerankingRunResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_reranking_run(
|
||||
request: RerankingRunCreate, svc: Service, _admin: Admin
|
||||
) -> RerankingRunResponse:
|
||||
return svc.create_reranking_run(request)
|
||||
|
||||
|
||||
@router.get("/reranking-runs", response_model=list[RerankingRunResponse])
|
||||
def reranking_runs(svc: Service) -> list[RerankingRunResponse]:
|
||||
return svc.reranking_runs()
|
||||
|
||||
|
||||
@router.post("/reranking-runs/{run_id}/complete", response_model=RerankingRunResponse)
|
||||
def complete_reranking_run(
|
||||
run_id: uuid.UUID, request: RerankingRunComplete, svc: Service, _admin: Admin
|
||||
) -> RerankingRunResponse:
|
||||
return svc.complete_reranking_run(run_id, request)
|
||||
|
||||
|
||||
@router.get("/reranking-runs/{run_id}/cases", response_model=list[RerankingCaseResultResponse])
|
||||
def reranking_case_results(run_id: uuid.UUID, svc: Service) -> list[RerankingCaseResultResponse]:
|
||||
return svc.reranking_case_results(run_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/discovery-assessments",
|
||||
response_model=DiscoveryCandidateAssessmentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_discovery_assessment(
|
||||
request: DiscoveryCandidateAssessmentCreate, svc: Service, _admin: Admin
|
||||
) -> DiscoveryCandidateAssessmentResponse:
|
||||
return svc.create_discovery_assessment(request)
|
||||
|
||||
|
||||
@router.get("/discovery-assessments", response_model=list[DiscoveryCandidateAssessmentResponse])
|
||||
def discovery_assessments(svc: Service) -> list[DiscoveryCandidateAssessmentResponse]:
|
||||
return svc.discovery_assessments()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/evaluation-comparisons",
|
||||
response_model=EvaluationComparisonResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def compare(
|
||||
request: EvaluationComparisonCreate, svc: Service, _admin: Admin
|
||||
) -> EvaluationComparisonResponse:
|
||||
return svc.compare(request)
|
||||
|
||||
|
||||
@router.get("/evaluation-comparisons", response_model=list[EvaluationComparisonResponse])
|
||||
def comparisons(svc: Service) -> list[EvaluationComparisonResponse]:
|
||||
return svc.comparisons()
|
||||
|
||||
|
||||
@router.get("/evaluation-comparisons/{comparison_id}", response_model=EvaluationComparisonResponse)
|
||||
def comparison(comparison_id: uuid.UUID, svc: Service) -> EvaluationComparisonResponse:
|
||||
return svc.comparison(comparison_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/model-comparisons",
|
||||
response_model=ModelComparisonResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_model_comparison(
|
||||
request: ModelComparisonCreate, svc: Service, _admin: Admin
|
||||
) -> ModelComparisonResponse:
|
||||
return svc.create_model_comparison(request)
|
||||
|
||||
|
||||
@router.get("/model-comparisons", response_model=list[ModelComparisonResponse])
|
||||
def model_comparisons(svc: Service) -> list[ModelComparisonResponse]:
|
||||
return svc.model_comparisons()
|
||||
|
||||
|
||||
@router.get("/model-comparisons/{comparison_id}", response_model=ModelComparisonResponse)
|
||||
def model_comparison(comparison_id: uuid.UUID, svc: Service) -> ModelComparisonResponse:
|
||||
return svc.model_comparison(comparison_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/model-comparisons/{comparison_id}/recommendations",
|
||||
response_model=AdvisorRecommendationResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_recommendation(
|
||||
comparison_id: uuid.UUID,
|
||||
request: AdvisorRecommendationCreate,
|
||||
svc: Service,
|
||||
_admin: Admin,
|
||||
) -> AdvisorRecommendationResponse:
|
||||
return svc.recommend(comparison_id, request)
|
||||
|
||||
|
||||
@router.get("/recommendations", response_model=list[AdvisorRecommendationResponse])
|
||||
def recommendations(svc: Service) -> list[AdvisorRecommendationResponse]:
|
||||
return svc.recommendations()
|
||||
|
||||
|
||||
@router.get("/recommendations/{recommendation_id}", response_model=AdvisorRecommendationResponse)
|
||||
def recommendation(recommendation_id: uuid.UUID, svc: Service) -> AdvisorRecommendationResponse:
|
||||
return svc.recommendation(recommendation_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/recommendations/{recommendation_id}/dismiss",
|
||||
response_model=AdvisorRecommendationResponse,
|
||||
)
|
||||
def dismiss_recommendation(
|
||||
recommendation_id: uuid.UUID,
|
||||
request: AdvisorRecommendationDismiss,
|
||||
svc: Service,
|
||||
_admin: Admin,
|
||||
) -> AdvisorRecommendationResponse:
|
||||
return svc.dismiss_recommendation(recommendation_id, request)
|
||||
|
||||
|
||||
@router.get("/admin/advisor-policies/current", response_model=AdvisorPolicyResponse)
|
||||
def advisor_policy(svc: Service, _admin: Admin) -> AdvisorPolicyResponse:
|
||||
return svc.advisor_policy()
|
||||
|
||||
|
||||
@router.patch("/admin/advisor-policies/current", response_model=AdvisorPolicyResponse)
|
||||
def update_advisor_policy(
|
||||
request: AdvisorPolicyUpdate, svc: Service, _admin: Admin
|
||||
) -> AdvisorPolicyResponse:
|
||||
return svc.update_advisor_policy(request)
|
||||
|
||||
|
||||
@router.get("/embedding-migrations", response_model=list[EmbeddingMigrationResponse])
|
||||
def migrations(svc: Service) -> list[EmbeddingMigrationResponse]:
|
||||
return svc.migrations()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/embedding-migrations",
|
||||
response_model=EmbeddingMigrationResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_migration(
|
||||
project_id: uuid.UUID, request: EmbeddingMigrationCreate, svc: Service, _admin: Admin
|
||||
) -> EmbeddingMigrationResponse:
|
||||
return svc.create_migration(project_id, request)
|
||||
|
||||
|
||||
@router.get("/embedding-migrations/{migration_id}", response_model=EmbeddingMigrationResponse)
|
||||
def migration(migration_id: uuid.UUID, svc: Service) -> EmbeddingMigrationResponse:
|
||||
return svc.migration(migration_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/embedding-migrations/{migration_id}/state", response_model=EmbeddingMigrationResponse
|
||||
)
|
||||
def update_migration(
|
||||
migration_id: uuid.UUID, request: MigrationUpdate, svc: Service, _admin: Admin
|
||||
) -> EmbeddingMigrationResponse:
|
||||
return svc.update_migration(migration_id, request)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/embedding-migrations/{migration_id}/start", response_model=EmbeddingMigrationResponse
|
||||
)
|
||||
def start_migration(
|
||||
migration_id: uuid.UUID, svc: Service, _admin: Admin
|
||||
) -> EmbeddingMigrationResponse:
|
||||
return svc.start_migration(migration_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/embedding-migrations/{migration_id}/cancel", response_model=EmbeddingMigrationResponse
|
||||
)
|
||||
def cancel_migration(
|
||||
migration_id: uuid.UUID, svc: Service, _admin: Admin
|
||||
) -> EmbeddingMigrationResponse:
|
||||
return svc.cancel_migration(migration_id)
|
||||
@@ -0,0 +1,82 @@
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import require_operator, require_viewer
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.hardware import AcceleratorState, HardwareState, NodeState
|
||||
from modelforge_api.services.hardware_factory import build_hardware_service
|
||||
from modelforge_api.services.hardware_inventory import HardwareInventoryService, HardwareRefreshBusy
|
||||
from modelforge_api.settings import Settings, get_settings
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/hardware", tags=["hardware"], dependencies=[Depends(require_viewer)]
|
||||
)
|
||||
|
||||
|
||||
def get_hardware_service(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> HardwareInventoryService:
|
||||
return build_hardware_service(session, settings)
|
||||
|
||||
|
||||
Service = Annotated[HardwareInventoryService, Depends(get_hardware_service)]
|
||||
|
||||
|
||||
@router.get("", response_model=HardwareState)
|
||||
def hardware_overview(service: Service) -> HardwareState:
|
||||
return service.state()
|
||||
|
||||
|
||||
@router.get("/nodes", response_model=list[NodeState])
|
||||
def list_nodes(service: Service) -> list[NodeState]:
|
||||
return service.state().nodes
|
||||
|
||||
|
||||
@router.get("/nodes/{node_id}", response_model=NodeState)
|
||||
def get_node(node_id: uuid.UUID, service: Service) -> NodeState:
|
||||
node = next((item for item in service.state().nodes if item.id == str(node_id)), None)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="compute node not found")
|
||||
return node
|
||||
|
||||
|
||||
@router.get("/accelerators", response_model=list[AcceleratorState])
|
||||
def list_accelerators(service: Service) -> list[AcceleratorState]:
|
||||
return [accelerator for node in service.state().nodes for accelerator in node.accelerators]
|
||||
|
||||
|
||||
@router.get("/accelerators/{accelerator_id}", response_model=AcceleratorState)
|
||||
def get_accelerator(accelerator_id: uuid.UUID, service: Service) -> AcceleratorState:
|
||||
accelerator = next(
|
||||
(
|
||||
item
|
||||
for node in service.state().nodes
|
||||
for item in node.accelerators
|
||||
if item.id == str(accelerator_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if accelerator is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="accelerator not found")
|
||||
return accelerator
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=HardwareState,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def refresh_hardware(service: Service) -> HardwareState:
|
||||
try:
|
||||
return service.refresh()
|
||||
except HardwareRefreshBusy as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"hardware inventory failed: {type(exc).__name__}",
|
||||
) from exc
|
||||
@@ -0,0 +1,109 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api import __version__
|
||||
from modelforge_api.api.authorization import require_viewer
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.enums import HealthStatus
|
||||
from modelforge_api.domain.release import (
|
||||
CURRENT_AGENT_PROTOCOL_VERSION,
|
||||
MINIMUM_POSTGRES_MAJOR,
|
||||
MINIMUM_UPGRADE_SOURCE,
|
||||
PRODUCT_NAME,
|
||||
RELEASE_CHANNEL,
|
||||
SUPPORTED_AGENT_PROTOCOL_VERSIONS,
|
||||
SUPPORTED_SCHEMA_REVISIONS,
|
||||
TARGET_SCHEMA_REVISION,
|
||||
build_identity,
|
||||
)
|
||||
from modelforge_api.domain.schemas import (
|
||||
HealthResponse,
|
||||
ReadinessResponse,
|
||||
ReleaseCompatibility,
|
||||
ReleaseInfo,
|
||||
SystemMetadata,
|
||||
)
|
||||
from modelforge_api.persistence.models import CapabilityDeployment
|
||||
from modelforge_api.services.manifest_registry import ManifestRegistry, get_manifest_registry
|
||||
from modelforge_api.settings import Settings, get_settings
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["system"])
|
||||
|
||||
|
||||
@router.get("/health/live", response_model=HealthResponse)
|
||||
def liveness() -> HealthResponse:
|
||||
return HealthResponse(version=__version__)
|
||||
|
||||
|
||||
@router.get("/health/ready", response_model=ReadinessResponse)
|
||||
def readiness(registry: ManifestRegistry = Depends(get_manifest_registry)) -> ReadinessResponse:
|
||||
registry.capabilities()
|
||||
registry.projects()
|
||||
registry.candidates()
|
||||
registry.benchmarks()
|
||||
registry.policies()
|
||||
return ReadinessResponse(
|
||||
status=HealthStatus.HEALTHY,
|
||||
checks={"manifests": HealthStatus.HEALTHY},
|
||||
version=__version__,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system",
|
||||
response_model=SystemMetadata,
|
||||
dependencies=[Depends(require_viewer)],
|
||||
)
|
||||
def system_metadata(
|
||||
settings: Settings = Depends(get_settings),
|
||||
session: Session = Depends(get_session),
|
||||
) -> SystemMetadata:
|
||||
production_count = int(
|
||||
session.scalar(
|
||||
select(func.count(CapabilityDeployment.id)).where(
|
||||
CapabilityDeployment.status == "stable",
|
||||
CapabilityDeployment.production.is_(True),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return SystemMetadata(
|
||||
name=PRODUCT_NAME,
|
||||
version=__version__,
|
||||
environment=settings.env,
|
||||
release_channel=RELEASE_CHANNEL,
|
||||
production_inference_available=production_count > 0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/version", response_model=ReleaseInfo)
|
||||
def release_info(settings: Settings = Depends(get_settings)) -> ReleaseInfo:
|
||||
"""Build identity and compatibility for the running process.
|
||||
|
||||
Unauthenticated on purpose: an operator diagnosing a deployment needs to know which build is
|
||||
answering before they have credentials for it, and everything here is already implied by the
|
||||
image they are running. Nothing configuration-derived or sensitive is exposed.
|
||||
"""
|
||||
|
||||
identity = build_identity(
|
||||
source_commit=settings.build_commit,
|
||||
built_at=settings.build_timestamp,
|
||||
image_digest=settings.build_image_digest,
|
||||
)
|
||||
return ReleaseInfo(
|
||||
name=PRODUCT_NAME,
|
||||
version=identity.version,
|
||||
release_channel=identity.channel,
|
||||
source_commit=identity.source_commit,
|
||||
built_at=identity.built_at,
|
||||
image_digest=identity.image_digest,
|
||||
compatibility=ReleaseCompatibility(
|
||||
schema_revision=TARGET_SCHEMA_REVISION,
|
||||
supported_schema_revisions=list(SUPPORTED_SCHEMA_REVISIONS),
|
||||
agent_protocol_version=CURRENT_AGENT_PROTOCOL_VERSION,
|
||||
supported_agent_protocol_versions=list(SUPPORTED_AGENT_PROTOCOL_VERSIONS),
|
||||
minimum_upgrade_source=MINIMUM_UPGRADE_SOURCE,
|
||||
minimum_postgres_major=MINIMUM_POSTGRES_MAJOR,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import Admin, require_viewer
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.lifecycle_contracts import (
|
||||
ApprovalDecision,
|
||||
ApprovalPolicyCreate,
|
||||
ApprovalPolicyResponse,
|
||||
ApprovalRequestCreate,
|
||||
ApprovalRequestResponse,
|
||||
CanaryObservation,
|
||||
CleanupExecutionCreate,
|
||||
CleanupPlanCreate,
|
||||
CleanupPlanResponse,
|
||||
LifecycleEventResponse,
|
||||
LifecycleOperationResponse,
|
||||
LifecycleSubjectCreate,
|
||||
LifecycleSubjectResponse,
|
||||
PlanExecutionCreate,
|
||||
PromotionPlanCreate,
|
||||
PromotionPlanResponse,
|
||||
RetentionPolicyCreate,
|
||||
RetentionPolicyResponse,
|
||||
)
|
||||
from modelforge_api.services.lifecycle import LifecycleService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/admin/lifecycle",
|
||||
tags=["lifecycle"],
|
||||
dependencies=[Depends(require_viewer)],
|
||||
)
|
||||
|
||||
|
||||
def get_lifecycle_service(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> LifecycleService:
|
||||
return LifecycleService(session)
|
||||
|
||||
|
||||
Service = Annotated[LifecycleService, Depends(get_lifecycle_service)]
|
||||
|
||||
|
||||
@router.get("/approval-policies", response_model=list[ApprovalPolicyResponse])
|
||||
def approval_policies(service: Service, _admin: Admin) -> list[ApprovalPolicyResponse]:
|
||||
return service.policies()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/approval-policies",
|
||||
response_model=ApprovalPolicyResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_approval_policy(
|
||||
request: ApprovalPolicyCreate, service: Service, _admin: Admin
|
||||
) -> ApprovalPolicyResponse:
|
||||
return service.create_policy(request)
|
||||
|
||||
|
||||
@router.get("/subjects", response_model=list[LifecycleSubjectResponse])
|
||||
def subjects(service: Service, _admin: Admin) -> list[LifecycleSubjectResponse]:
|
||||
return service.subjects()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subjects",
|
||||
response_model=LifecycleSubjectResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_subject(
|
||||
request: LifecycleSubjectCreate, service: Service, _admin: Admin
|
||||
) -> LifecycleSubjectResponse:
|
||||
return service.create_subject(request)
|
||||
|
||||
|
||||
@router.get("/approval-requests", response_model=list[ApprovalRequestResponse])
|
||||
def approval_requests(service: Service, _admin: Admin) -> list[ApprovalRequestResponse]:
|
||||
return service.approval_requests()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/approval-requests",
|
||||
response_model=ApprovalRequestResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def request_approval(
|
||||
request: ApprovalRequestCreate, service: Service, _admin: Admin
|
||||
) -> ApprovalRequestResponse:
|
||||
return service.request_approval(request)
|
||||
|
||||
|
||||
@router.get("/approval-requests/{approval_id}", response_model=ApprovalRequestResponse)
|
||||
def approval_request(
|
||||
approval_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> ApprovalRequestResponse:
|
||||
return service.approval(approval_id)
|
||||
|
||||
|
||||
@router.post("/approval-requests/{approval_id}/approve", response_model=ApprovalRequestResponse)
|
||||
def approve_request(
|
||||
approval_id: uuid.UUID, request: ApprovalDecision, service: Service, _admin: Admin
|
||||
) -> ApprovalRequestResponse:
|
||||
return service.decide_approval(approval_id, request, "approve")
|
||||
|
||||
|
||||
@router.post("/approval-requests/{approval_id}/reject", response_model=ApprovalRequestResponse)
|
||||
def reject_request(
|
||||
approval_id: uuid.UUID, request: ApprovalDecision, service: Service, _admin: Admin
|
||||
) -> ApprovalRequestResponse:
|
||||
return service.decide_approval(approval_id, request, "reject")
|
||||
|
||||
|
||||
@router.post("/approval-requests/{approval_id}/revoke", response_model=ApprovalRequestResponse)
|
||||
def revoke_request(
|
||||
approval_id: uuid.UUID, request: ApprovalDecision, service: Service, _admin: Admin
|
||||
) -> ApprovalRequestResponse:
|
||||
return service.decide_approval(approval_id, request, "revoke")
|
||||
|
||||
|
||||
@router.get("/promotion-plans", response_model=list[PromotionPlanResponse])
|
||||
def promotion_plans(service: Service, _admin: Admin) -> list[PromotionPlanResponse]:
|
||||
return service.plans()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/promotion-plans",
|
||||
response_model=PromotionPlanResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_promotion_plan(
|
||||
request: PromotionPlanCreate, service: Service, _admin: Admin
|
||||
) -> PromotionPlanResponse:
|
||||
return service.create_plan(request)
|
||||
|
||||
|
||||
@router.get("/promotion-plans/{plan_id}", response_model=PromotionPlanResponse)
|
||||
def promotion_plan(plan_id: uuid.UUID, service: Service, _admin: Admin) -> PromotionPlanResponse:
|
||||
return service.plan(plan_id)
|
||||
|
||||
|
||||
@router.post("/promotion-plans/{plan_id}/approve", response_model=PromotionPlanResponse)
|
||||
def approve_promotion_plan(
|
||||
plan_id: uuid.UUID, request: ApprovalDecision, service: Service, _admin: Admin
|
||||
) -> PromotionPlanResponse:
|
||||
return service.approve_plan(plan_id, request)
|
||||
|
||||
|
||||
@router.post("/promotion-plans/{plan_id}/execute", response_model=LifecycleOperationResponse)
|
||||
def execute_promotion_plan(
|
||||
plan_id: uuid.UUID, request: PlanExecutionCreate, service: Service, _admin: Admin
|
||||
) -> LifecycleOperationResponse:
|
||||
return service.execute_plan(plan_id, request)
|
||||
|
||||
|
||||
@router.get("/operations", response_model=list[LifecycleOperationResponse])
|
||||
def operations(service: Service, _admin: Admin) -> list[LifecycleOperationResponse]:
|
||||
return service.operations()
|
||||
|
||||
|
||||
@router.get("/operations/{operation_id}", response_model=LifecycleOperationResponse)
|
||||
def operation(
|
||||
operation_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> LifecycleOperationResponse:
|
||||
return service.operation(operation_id)
|
||||
|
||||
|
||||
@router.post("/operations/{operation_id}/canary", response_model=LifecycleOperationResponse)
|
||||
def observe_canary(
|
||||
operation_id: uuid.UUID, request: CanaryObservation, service: Service, _admin: Admin
|
||||
) -> LifecycleOperationResponse:
|
||||
return service.observe_canary(operation_id, request)
|
||||
|
||||
|
||||
@router.post("/operations/{operation_id}/commit", response_model=LifecycleOperationResponse)
|
||||
def commit_operation(
|
||||
operation_id: uuid.UUID, request: ApprovalDecision, service: Service, _admin: Admin
|
||||
) -> LifecycleOperationResponse:
|
||||
return service.commit_operation(operation_id, request)
|
||||
|
||||
|
||||
@router.post("/operations/{operation_id}/rollback", response_model=LifecycleOperationResponse)
|
||||
def rollback_operation(
|
||||
operation_id: uuid.UUID, request: ApprovalDecision, service: Service, _admin: Admin
|
||||
) -> LifecycleOperationResponse:
|
||||
return service.rollback_operation(operation_id, request)
|
||||
|
||||
|
||||
@router.post("/reconcile", response_model=dict[str, int])
|
||||
def reconcile(service: Service, _admin: Admin) -> dict[str, int]:
|
||||
return {"reconciled_operations": service.reconcile_incomplete()}
|
||||
|
||||
|
||||
@router.get("/retention-policies", response_model=list[RetentionPolicyResponse])
|
||||
def retention_policies(service: Service, _admin: Admin) -> list[RetentionPolicyResponse]:
|
||||
return service.retention_policies()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/retention-policies",
|
||||
response_model=RetentionPolicyResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_retention_policy(
|
||||
request: RetentionPolicyCreate, service: Service, _admin: Admin
|
||||
) -> RetentionPolicyResponse:
|
||||
return service.create_retention_policy(request)
|
||||
|
||||
|
||||
@router.get("/cleanup-plans", response_model=list[CleanupPlanResponse])
|
||||
def cleanup_plans(service: Service, _admin: Admin) -> list[CleanupPlanResponse]:
|
||||
return service.cleanup_plans()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cleanup-plans",
|
||||
response_model=CleanupPlanResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_cleanup_plan(
|
||||
request: CleanupPlanCreate, service: Service, _admin: Admin
|
||||
) -> CleanupPlanResponse:
|
||||
return service.create_cleanup_plan(request)
|
||||
|
||||
|
||||
@router.get("/cleanup-plans/{plan_id}", response_model=CleanupPlanResponse)
|
||||
def cleanup_plan(plan_id: uuid.UUID, service: Service, _admin: Admin) -> CleanupPlanResponse:
|
||||
return service.cleanup_plan(plan_id)
|
||||
|
||||
|
||||
@router.post("/cleanup-plans/{plan_id}/execute", response_model=CleanupPlanResponse)
|
||||
def execute_cleanup_plan(
|
||||
plan_id: uuid.UUID, request: CleanupExecutionCreate, service: Service, _admin: Admin
|
||||
) -> CleanupPlanResponse:
|
||||
return service.execute_cleanup(plan_id, request)
|
||||
|
||||
|
||||
@router.get("/events", response_model=list[LifecycleEventResponse])
|
||||
def lifecycle_events(
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
|
||||
) -> list[LifecycleEventResponse]:
|
||||
return service.events(limit)
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import Admin, require_viewer
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.migration_contracts import (
|
||||
BatchReport,
|
||||
CutoverOperationResponse,
|
||||
CutoverPrepare,
|
||||
CutoverReport,
|
||||
MigrationBatchResponse,
|
||||
MigrationEventResponse,
|
||||
MigrationPlanCreate,
|
||||
MigrationPlanResponse,
|
||||
MigrationValidationPolicyCreate,
|
||||
MigrationValidationPolicyResponse,
|
||||
PreflightReport,
|
||||
ReconciliationReport,
|
||||
RollbackReport,
|
||||
ShadowReport,
|
||||
StateAction,
|
||||
ValidationReport,
|
||||
ValidationSnapshotResponse,
|
||||
)
|
||||
from modelforge_api.services.migration_engine import MigrationEngineService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/admin/migrations",
|
||||
tags=["migrations"],
|
||||
dependencies=[Depends(require_viewer)],
|
||||
)
|
||||
|
||||
|
||||
def get_service(session: Annotated[Session, Depends(get_session)]) -> MigrationEngineService:
|
||||
return MigrationEngineService(session)
|
||||
|
||||
|
||||
Service = Annotated[MigrationEngineService, Depends(get_service)]
|
||||
|
||||
|
||||
@router.get("/validation-policies", response_model=list[MigrationValidationPolicyResponse])
|
||||
def validation_policies(service: Service, _admin: Admin) -> list[MigrationValidationPolicyResponse]:
|
||||
return service.validation_policies()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/validation-policies",
|
||||
response_model=MigrationValidationPolicyResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_validation_policy(
|
||||
request: MigrationValidationPolicyCreate, service: Service, _admin: Admin
|
||||
) -> MigrationValidationPolicyResponse:
|
||||
return service.create_validation_policy(request)
|
||||
|
||||
|
||||
@router.get("/plans", response_model=list[MigrationPlanResponse])
|
||||
def plans(service: Service, _admin: Admin) -> list[MigrationPlanResponse]:
|
||||
return service.plans()
|
||||
|
||||
|
||||
@router.post("/plans", response_model=MigrationPlanResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_plan(
|
||||
request: MigrationPlanCreate, service: Service, _admin: Admin
|
||||
) -> MigrationPlanResponse:
|
||||
return service.create_plan(request)
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}", response_model=MigrationPlanResponse)
|
||||
def plan(plan_id: uuid.UUID, service: Service, _admin: Admin) -> MigrationPlanResponse:
|
||||
return service.plan(plan_id)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/preflight", response_model=MigrationPlanResponse)
|
||||
def preflight(
|
||||
plan_id: uuid.UUID, request: PreflightReport, service: Service, _admin: Admin
|
||||
) -> MigrationPlanResponse:
|
||||
return service.preflight(plan_id, request)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/backfill/start", response_model=MigrationPlanResponse)
|
||||
def start_backfill(
|
||||
plan_id: uuid.UUID, request: StateAction, service: Service, _admin: Admin
|
||||
) -> MigrationPlanResponse:
|
||||
return service.start_backfill(plan_id, request)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/backfill/pause", response_model=MigrationPlanResponse)
|
||||
def pause_backfill(
|
||||
plan_id: uuid.UUID, request: StateAction, service: Service, _admin: Admin
|
||||
) -> MigrationPlanResponse:
|
||||
return service.pause_backfill(plan_id, request)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/batches", response_model=MigrationBatchResponse)
|
||||
def record_batch(
|
||||
plan_id: uuid.UUID, request: BatchReport, service: Service, _admin: Admin
|
||||
) -> MigrationBatchResponse:
|
||||
return service.record_batch(plan_id, request)
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}/batches", response_model=list[MigrationBatchResponse])
|
||||
def batches(plan_id: uuid.UUID, service: Service, _admin: Admin) -> list[MigrationBatchResponse]:
|
||||
return service.batches(plan_id)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/validation", response_model=ValidationSnapshotResponse)
|
||||
def validate_target(
|
||||
plan_id: uuid.UUID, request: ValidationReport, service: Service, _admin: Admin
|
||||
) -> ValidationSnapshotResponse:
|
||||
return service.validate(plan_id, request)
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}/validation", response_model=list[ValidationSnapshotResponse])
|
||||
def validation_snapshots(
|
||||
plan_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> list[ValidationSnapshotResponse]:
|
||||
return service.validation_snapshots(plan_id)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/shadow/start", response_model=MigrationPlanResponse)
|
||||
def start_shadow(
|
||||
plan_id: uuid.UUID, request: StateAction, service: Service, _admin: Admin
|
||||
) -> MigrationPlanResponse:
|
||||
return service.start_shadow(plan_id, request)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/shadow/complete", response_model=MigrationPlanResponse)
|
||||
def complete_shadow(
|
||||
plan_id: uuid.UUID, request: ShadowReport, service: Service, _admin: Admin
|
||||
) -> MigrationPlanResponse:
|
||||
return service.complete_shadow(plan_id, request)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/cutover/prepare", response_model=CutoverOperationResponse)
|
||||
def prepare_cutover(
|
||||
plan_id: uuid.UUID, request: CutoverPrepare, service: Service, _admin: Admin
|
||||
) -> CutoverOperationResponse:
|
||||
return service.prepare_cutover(plan_id, request)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/cutover/report", response_model=CutoverOperationResponse)
|
||||
def report_cutover(
|
||||
plan_id: uuid.UUID, request: CutoverReport, service: Service, _admin: Admin
|
||||
) -> CutoverOperationResponse:
|
||||
return service.report_cutover(plan_id, request)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/rollback", response_model=CutoverOperationResponse)
|
||||
def rollback(
|
||||
plan_id: uuid.UUID, request: RollbackReport, service: Service, _admin: Admin
|
||||
) -> CutoverOperationResponse:
|
||||
return service.rollback(plan_id, request)
|
||||
|
||||
|
||||
@router.post("/reconcile", response_model=CutoverOperationResponse)
|
||||
def reconcile(
|
||||
request: ReconciliationReport, service: Service, _admin: Admin
|
||||
) -> CutoverOperationResponse:
|
||||
return service.reconcile(request)
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/cancel", response_model=MigrationPlanResponse)
|
||||
def cancel(
|
||||
plan_id: uuid.UUID, request: StateAction, service: Service, _admin: Admin
|
||||
) -> MigrationPlanResponse:
|
||||
return service.cancel(plan_id, request)
|
||||
|
||||
|
||||
@router.get("/cutovers", response_model=list[CutoverOperationResponse])
|
||||
def cutovers(
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
plan_id: Annotated[uuid.UUID | None, Query()] = None,
|
||||
) -> list[CutoverOperationResponse]:
|
||||
return service.operations(plan_id)
|
||||
|
||||
|
||||
@router.get("/events", response_model=list[MigrationEventResponse])
|
||||
def events(
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
plan_id: Annotated[uuid.UUID | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
|
||||
) -> list[MigrationEventResponse]:
|
||||
return service.events(plan_id, limit)
|
||||
@@ -0,0 +1,237 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import Admin, require_viewer
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.observability import (
|
||||
AlertAction,
|
||||
AlertHistoryResponse,
|
||||
AlertResponse,
|
||||
AlertRuleCreate,
|
||||
AlertRuleResponse,
|
||||
CapacitySnapshotResponse,
|
||||
IncidentResponse,
|
||||
MaintenanceWindowCreate,
|
||||
MaintenanceWindowResponse,
|
||||
OperationsOverview,
|
||||
SLIDefinitionResponse,
|
||||
SLOEvaluationResponse,
|
||||
SLOPolicyCreate,
|
||||
SLOPolicyResponse,
|
||||
TrendResponse,
|
||||
metrics,
|
||||
)
|
||||
from modelforge_api.persistence.models import (
|
||||
AlertHistoryEvent,
|
||||
IncidentTimelineEvent,
|
||||
OperationalIncident,
|
||||
)
|
||||
from modelforge_api.services.observability import ObservabilityService
|
||||
|
||||
router = APIRouter(tags=["operations"], dependencies=[Depends(require_viewer)])
|
||||
|
||||
|
||||
def get_service(session: Annotated[Session, Depends(get_session)]) -> ObservabilityService:
|
||||
return ObservabilityService(session)
|
||||
|
||||
|
||||
Service = Annotated[ObservabilityService, Depends(get_service)]
|
||||
|
||||
|
||||
@router.get("/metrics", response_class=Response)
|
||||
def prometheus_metrics(service: Service, _admin: Admin) -> Response:
|
||||
"""Admin-isolated Prometheus exposition; process metrics survive DB failure."""
|
||||
try:
|
||||
content = service.prometheus()
|
||||
metrics.gauge("modelforge_observability_degraded", {}, 0)
|
||||
except SQLAlchemyError:
|
||||
service.session.rollback()
|
||||
metrics.gauge("modelforge_observability_degraded", {}, 1)
|
||||
content = metrics.render()
|
||||
return Response(content=content, media_type="text/plain; version=0.0.4; charset=utf-8")
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/operations/overview", response_model=OperationsOverview)
|
||||
def overview(service: Service, _admin: Admin) -> OperationsOverview:
|
||||
return service.overview()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/admin/operations/slis", response_model=list[SLIDefinitionResponse]
|
||||
)
|
||||
def sli_definitions(service: Service, _admin: Admin) -> list[SLIDefinitionResponse]:
|
||||
return service.definitions()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/admin/operations/slo-policies", response_model=list[SLOPolicyResponse]
|
||||
)
|
||||
def slo_policies(service: Service, _admin: Admin) -> list[SLOPolicyResponse]:
|
||||
return service.policies()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/operations/slo-policies",
|
||||
response_model=SLOPolicyResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_slo_policy(
|
||||
request: SLOPolicyCreate, service: Service, _admin: Admin
|
||||
) -> SLOPolicyResponse:
|
||||
return service.create_policy(request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/admin/operations/slo-evaluations", response_model=list[SLOEvaluationResponse]
|
||||
)
|
||||
def slo_evaluations(
|
||||
service: Service, _admin: Admin, limit: int = Query(default=100, ge=1, le=1000)
|
||||
) -> list[SLOEvaluationResponse]:
|
||||
return service.evaluations(limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/operations/slo-evaluations/run",
|
||||
response_model=list[SLOEvaluationResponse],
|
||||
)
|
||||
def evaluate_slos(service: Service, _admin: Admin) -> list[SLOEvaluationResponse]:
|
||||
return service.evaluate_slos()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/admin/operations/alert-rules", response_model=list[AlertRuleResponse]
|
||||
)
|
||||
def alert_rules(service: Service, _admin: Admin) -> list[AlertRuleResponse]:
|
||||
return service.rules()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/operations/alert-rules",
|
||||
response_model=AlertRuleResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_alert_rule(
|
||||
request: AlertRuleCreate, service: Service, _admin: Admin
|
||||
) -> AlertRuleResponse:
|
||||
return service.create_rule(request)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/operations/alerts", response_model=list[AlertResponse])
|
||||
def alerts(
|
||||
service: Service, _admin: Admin, limit: int = Query(default=200, ge=1, le=1000)
|
||||
) -> list[AlertResponse]:
|
||||
return service.alerts(limit)
|
||||
|
||||
|
||||
@router.post("/api/v1/admin/operations/alerts/evaluate", response_model=list[AlertResponse])
|
||||
def evaluate_alerts(service: Service, _admin: Admin) -> list[AlertResponse]:
|
||||
return service.evaluate_alerts()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/operations/alerts/{alert_id}/acknowledge", response_model=AlertResponse
|
||||
)
|
||||
def acknowledge_alert(
|
||||
alert_id: uuid.UUID, request: AlertAction, service: Service, _admin: Admin
|
||||
) -> AlertResponse:
|
||||
return service.acknowledge(alert_id, request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/admin/operations/alerts/{alert_id}/history",
|
||||
response_model=list[AlertHistoryResponse],
|
||||
)
|
||||
def alert_history(
|
||||
alert_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> list[AlertHistoryResponse]:
|
||||
rows = service.session.scalars(
|
||||
select(AlertHistoryEvent)
|
||||
.where(AlertHistoryEvent.alert_id == alert_id)
|
||||
.order_by(AlertHistoryEvent.occurred_at)
|
||||
)
|
||||
return [AlertHistoryResponse.model_validate(item) for item in rows]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/admin/operations/maintenance-windows",
|
||||
response_model=list[MaintenanceWindowResponse],
|
||||
)
|
||||
def maintenance_windows(service: Service, _admin: Admin) -> list[MaintenanceWindowResponse]:
|
||||
return service.maintenance_windows()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/operations/maintenance-windows",
|
||||
response_model=MaintenanceWindowResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_maintenance_window(
|
||||
request: MaintenanceWindowCreate, service: Service, _admin: Admin
|
||||
) -> MaintenanceWindowResponse:
|
||||
return service.create_maintenance_window(request)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/operations/capacity", response_model=list[CapacitySnapshotResponse])
|
||||
def capacity(
|
||||
service: Service, _admin: Admin, limit: int = Query(default=500, ge=1, le=5000)
|
||||
) -> list[CapacitySnapshotResponse]:
|
||||
return service.capacity(limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/operations/capacity/collect", response_model=list[CapacitySnapshotResponse]
|
||||
)
|
||||
def collect_capacity(service: Service, _admin: Admin) -> list[CapacitySnapshotResponse]:
|
||||
return service.collect_capacity()
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/operations/capacity/{node_id}/trend", response_model=TrendResponse)
|
||||
def capacity_trend(
|
||||
node_id: uuid.UUID,
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
hours: int = Query(default=24, ge=1, le=2160),
|
||||
) -> TrendResponse:
|
||||
return service.capacity_trend(node_id, hours)
|
||||
|
||||
|
||||
@router.post("/api/v1/admin/operations/retention/run", response_model=dict[str, int])
|
||||
def apply_retention(service: Service, _admin: Admin) -> dict[str, int]:
|
||||
return service.apply_retention()
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/operations/incidents", response_model=list[IncidentResponse])
|
||||
def incidents(service: Service, _admin: Admin) -> list[IncidentResponse]:
|
||||
rows = service.session.scalars(
|
||||
select(OperationalIncident).order_by(OperationalIncident.last_seen_at.desc())
|
||||
)
|
||||
return [IncidentResponse.model_validate(item) for item in rows]
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/operations/incidents/{incident_id}/timeline")
|
||||
def incident_timeline(
|
||||
incident_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = service.session.scalars(
|
||||
select(IncidentTimelineEvent)
|
||||
.where(IncidentTimelineEvent.incident_id == incident_id)
|
||||
.order_by(IncidentTimelineEvent.occurred_at)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": str(item.id),
|
||||
"alert_id": str(item.alert_id) if item.alert_id else None,
|
||||
"event_type": item.event_type,
|
||||
"relation": item.relation,
|
||||
"summary": item.summary,
|
||||
"occurred_at": item.occurred_at,
|
||||
}
|
||||
for item in rows
|
||||
]
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Operator-only recovery API.
|
||||
|
||||
Backup creation, verification and restore *planning* are safe control-plane operations. Restore
|
||||
*execution* is deliberately restricted: it can only run against an isolated destination that is
|
||||
not this control plane's own database, and replacing a production database stays an operator
|
||||
runbook/CLI action rather than a remote call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import Admin, require_viewer
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.recovery import (
|
||||
ArtifactRecoveryCreate,
|
||||
ArtifactRecoveryResponse,
|
||||
BackupCapacityEstimate,
|
||||
BackupSetCreate,
|
||||
BackupSetResponse,
|
||||
RecoveryAssetResponse,
|
||||
RecoveryDashboard,
|
||||
RecoveryPolicyCreate,
|
||||
RecoveryPolicyResponse,
|
||||
RestoreAdvanceRequest,
|
||||
RestoreOperationEventResponse,
|
||||
RestoreOperationResponse,
|
||||
RestorePlanCreate,
|
||||
RestorePlanResponse,
|
||||
)
|
||||
from modelforge_api.services.recovery import RecoveryService
|
||||
from modelforge_api.settings import Settings, get_settings
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/admin/recovery",
|
||||
tags=["recovery"],
|
||||
dependencies=[Depends(require_viewer)],
|
||||
)
|
||||
|
||||
|
||||
def get_service(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> RecoveryService:
|
||||
return RecoveryService(session, settings)
|
||||
|
||||
|
||||
Service = Annotated[RecoveryService, Depends(get_service)]
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=RecoveryDashboard)
|
||||
def dashboard(service: Service, _admin: Admin) -> RecoveryDashboard:
|
||||
return service.dashboard()
|
||||
|
||||
|
||||
@router.get("/policies", response_model=list[RecoveryPolicyResponse])
|
||||
def policies(service: Service, _admin: Admin) -> list[RecoveryPolicyResponse]:
|
||||
return service.policies()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/policies",
|
||||
response_model=RecoveryPolicyResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_policy(
|
||||
request: RecoveryPolicyCreate, service: Service, _admin: Admin
|
||||
) -> RecoveryPolicyResponse:
|
||||
return service.create_policy(request)
|
||||
|
||||
|
||||
@router.get("/assets", response_model=list[RecoveryAssetResponse])
|
||||
def assets(service: Service, _admin: Admin) -> list[RecoveryAssetResponse]:
|
||||
return service.assets()
|
||||
|
||||
|
||||
@router.get("/capacity", response_model=BackupCapacityEstimate)
|
||||
def capacity(service: Service, _admin: Admin) -> BackupCapacityEstimate:
|
||||
return service.estimate_capacity()
|
||||
|
||||
|
||||
@router.get("/backups", response_model=list[BackupSetResponse])
|
||||
def backups(
|
||||
service: Service, _admin: Admin, limit: int = Query(default=100, ge=1, le=1000)
|
||||
) -> list[BackupSetResponse]:
|
||||
return service.backups(limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/backups", response_model=BackupSetResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def create_backup(
|
||||
request: BackupSetCreate, service: Service, _admin: Admin
|
||||
) -> BackupSetResponse:
|
||||
return service.create_backup(request)
|
||||
|
||||
|
||||
@router.get("/backups/{backup_set_id}", response_model=BackupSetResponse)
|
||||
def backup(backup_set_id: uuid.UUID, service: Service, _admin: Admin) -> BackupSetResponse:
|
||||
return service.backup(backup_set_id)
|
||||
|
||||
|
||||
@router.post("/backups/{backup_set_id}/verify", response_model=BackupSetResponse)
|
||||
def verify_backup(
|
||||
backup_set_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> BackupSetResponse:
|
||||
return service.verify_backup(backup_set_id)
|
||||
|
||||
|
||||
@router.post("/retention/run", response_model=dict[str, int])
|
||||
def apply_retention(service: Service, _admin: Admin) -> dict[str, int]:
|
||||
return service.apply_retention()
|
||||
|
||||
|
||||
@router.get("/restore-plans", response_model=list[RestorePlanResponse])
|
||||
def restore_plans(
|
||||
service: Service, _admin: Admin, limit: int = Query(default=100, ge=1, le=1000)
|
||||
) -> list[RestorePlanResponse]:
|
||||
return service.restore_plans(limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/restore-plans", response_model=RestorePlanResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def create_restore_plan(
|
||||
request: RestorePlanCreate, service: Service, _admin: Admin
|
||||
) -> RestorePlanResponse:
|
||||
return service.create_restore_plan(request)
|
||||
|
||||
|
||||
@router.get("/restore-plans/{plan_id}", response_model=RestorePlanResponse)
|
||||
def restore_plan(plan_id: uuid.UUID, service: Service, _admin: Admin) -> RestorePlanResponse:
|
||||
return service.restore_plan(plan_id)
|
||||
|
||||
|
||||
@router.post("/restore-plans/{plan_id}/preflight", response_model=RestorePlanResponse)
|
||||
def preflight(plan_id: uuid.UUID, service: Service, _admin: Admin) -> RestorePlanResponse:
|
||||
return service.preflight(plan_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/restore-plans/{plan_id}/start",
|
||||
response_model=RestoreOperationResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def start_restore(
|
||||
plan_id: uuid.UUID,
|
||||
request: RestoreAdvanceRequest,
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
) -> RestoreOperationResponse:
|
||||
return service.start_restore(plan_id, request)
|
||||
|
||||
|
||||
@router.get("/restore-operations", response_model=list[RestoreOperationResponse])
|
||||
def restore_operations(
|
||||
service: Service, _admin: Admin, limit: int = Query(default=100, ge=1, le=1000)
|
||||
) -> list[RestoreOperationResponse]:
|
||||
return service.restore_operations(limit)
|
||||
|
||||
|
||||
@router.get("/restore-operations/{operation_id}", response_model=RestoreOperationResponse)
|
||||
def restore_operation(
|
||||
operation_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> RestoreOperationResponse:
|
||||
return service.restore_operation(operation_id)
|
||||
|
||||
|
||||
@router.post("/restore-operations/{operation_id}/advance", response_model=RestoreOperationResponse)
|
||||
def advance_restore(
|
||||
operation_id: uuid.UUID,
|
||||
request: RestoreAdvanceRequest,
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
) -> RestoreOperationResponse:
|
||||
return service.advance_restore(operation_id, request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/restore-operations/{operation_id}/events",
|
||||
response_model=list[RestoreOperationEventResponse],
|
||||
)
|
||||
def restore_events(
|
||||
operation_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> list[RestoreOperationEventResponse]:
|
||||
return service.restore_events(operation_id)
|
||||
|
||||
|
||||
@router.get("/artifact-recoveries", response_model=list[ArtifactRecoveryResponse])
|
||||
def artifact_recoveries(
|
||||
service: Service, _admin: Admin, limit: int = Query(default=100, ge=1, le=1000)
|
||||
) -> list[ArtifactRecoveryResponse]:
|
||||
return service.artifact_recoveries(limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/artifact-recoveries",
|
||||
response_model=ArtifactRecoveryResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def plan_artifact_recovery(
|
||||
request: ArtifactRecoveryCreate, service: Service, _admin: Admin
|
||||
) -> ArtifactRecoveryResponse:
|
||||
return service.plan_artifact_recovery(request)
|
||||
|
||||
|
||||
@router.get("/fingerprint", response_model=dict[str, Any])
|
||||
def fingerprint(service: Service, _admin: Admin) -> dict[str, Any]:
|
||||
"""Bounded semantic fingerprint of the live control plane; contains no secret material."""
|
||||
|
||||
return service.fingerprint()
|
||||
@@ -0,0 +1,291 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import require_admin, require_operator, require_viewer
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.registry import (
|
||||
ArtifactCreate,
|
||||
ArtifactResponse,
|
||||
CapacityDecision,
|
||||
DerivedArtifactCreate,
|
||||
DerivedArtifactResponse,
|
||||
ModelCreate,
|
||||
ModelResponse,
|
||||
ModelUpdate,
|
||||
Page,
|
||||
RevisionCreate,
|
||||
RevisionResponse,
|
||||
StorageRootCreate,
|
||||
StorageRootObservation,
|
||||
StorageRootResponse,
|
||||
StorageRootUpdate,
|
||||
VerifyResponse,
|
||||
)
|
||||
from modelforge_api.services.registry import RegistryService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1", tags=["model-registry"], dependencies=[Depends(require_viewer)]
|
||||
)
|
||||
|
||||
|
||||
def get_registry_service(session: Annotated[Session, Depends(get_session)]) -> RegistryService:
|
||||
return RegistryService(session)
|
||||
|
||||
|
||||
Service = Annotated[RegistryService, Depends(get_registry_service)]
|
||||
|
||||
|
||||
def page_values[T: BaseModel](items: list[T], page: int, page_size: int) -> Page[T]:
|
||||
total = len(items)
|
||||
start = (page - 1) * page_size
|
||||
return Page(
|
||||
items=items[start : start + page_size],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/models", response_model=Page[ModelResponse])
|
||||
def list_models(
|
||||
service: Service,
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
search: str | None = None,
|
||||
lifecycle: str | None = None,
|
||||
source_type: str | None = None,
|
||||
) -> Page[ModelResponse]:
|
||||
return service.list_models(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
lifecycle=lifecycle,
|
||||
source_type=source_type,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/models",
|
||||
response_model=ModelResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def create_model(request: ModelCreate, service: Service) -> ModelResponse:
|
||||
return service.create_model(request)
|
||||
|
||||
|
||||
@router.get("/models/{model_id}", response_model=ModelResponse)
|
||||
def get_model(model_id: uuid.UUID, service: Service) -> ModelResponse:
|
||||
return service.get_model(model_id)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/models/{model_id}",
|
||||
response_model=ModelResponse,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def update_model(model_id: uuid.UUID, request: ModelUpdate, service: Service) -> ModelResponse:
|
||||
return service.update_model(model_id, request)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/models/{model_id}/deprecate",
|
||||
response_model=ModelResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def deprecate_model(model_id: uuid.UUID, service: Service) -> ModelResponse:
|
||||
return service.deprecate_model(model_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/models/{model_id}/archive",
|
||||
response_model=ModelResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def archive_model(model_id: uuid.UUID, service: Service) -> ModelResponse:
|
||||
return service.archive_model(model_id)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/models/{model_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def delete_model(model_id: uuid.UUID, service: Service) -> Response:
|
||||
service.delete("model", model_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/models/{model_id}/revisions", response_model=Page[RevisionResponse])
|
||||
def list_revisions(
|
||||
model_id: uuid.UUID,
|
||||
service: Service,
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
) -> Page[RevisionResponse]:
|
||||
return page_values(service.revisions(model_id), page, page_size)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/models/{model_id}/revisions",
|
||||
response_model=RevisionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def create_revision(
|
||||
model_id: uuid.UUID, request: RevisionCreate, service: Service
|
||||
) -> RevisionResponse:
|
||||
return service.create_revision(model_id, request)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/revisions/{revision_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def delete_revision(revision_id: uuid.UUID, service: Service) -> Response:
|
||||
service.delete("model_revision", revision_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/revisions/{revision_id}/artifacts", response_model=Page[ArtifactResponse])
|
||||
def list_artifacts(
|
||||
revision_id: uuid.UUID,
|
||||
service: Service,
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
) -> Page[ArtifactResponse]:
|
||||
return page_values(service.artifacts(revision_id), page, page_size)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/revisions/{revision_id}/artifacts",
|
||||
response_model=ArtifactResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def create_artifact(
|
||||
revision_id: uuid.UUID, request: ArtifactCreate, service: Service
|
||||
) -> ArtifactResponse:
|
||||
return service.create_artifact(revision_id, request)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/artifacts/{artifact_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def delete_artifact(artifact_id: uuid.UUID, service: Service) -> Response:
|
||||
service.delete("model_artifact", artifact_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/artifacts/{artifact_id}/verify",
|
||||
response_model=VerifyResponse,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def verify_artifact(
|
||||
artifact_id: uuid.UUID, location_id: uuid.UUID, service: Service
|
||||
) -> VerifyResponse:
|
||||
return service.verify_artifact(artifact_id, location_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/revisions/{revision_id}/derived-artifacts", response_model=Page[DerivedArtifactResponse]
|
||||
)
|
||||
def list_derived_artifacts(
|
||||
revision_id: uuid.UUID,
|
||||
service: Service,
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
) -> Page[DerivedArtifactResponse]:
|
||||
return page_values(service.derived(revision_id), page, page_size)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/derived-artifacts",
|
||||
response_model=DerivedArtifactResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def create_derived_artifact(
|
||||
request: DerivedArtifactCreate, service: Service
|
||||
) -> DerivedArtifactResponse:
|
||||
return service.create_derived(request)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/derived-artifacts/{artifact_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def delete_derived_artifact(artifact_id: uuid.UUID, service: Service) -> Response:
|
||||
service.delete("derived_artifact", artifact_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/storage-roots", response_model=list[StorageRootResponse])
|
||||
def list_storage_roots(
|
||||
service: Service, node_id: uuid.UUID | None = None
|
||||
) -> list[StorageRootResponse]:
|
||||
return service.storage_roots(node_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/storage-roots",
|
||||
response_model=StorageRootResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def create_storage_root(request: StorageRootCreate, service: Service) -> StorageRootResponse:
|
||||
return service.create_storage_root(request)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/storage-roots/{root_id}",
|
||||
response_model=StorageRootResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def update_storage_root(
|
||||
root_id: uuid.UUID, request: StorageRootUpdate, service: Service
|
||||
) -> StorageRootResponse:
|
||||
return service.update_storage_root(root_id, request)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/storage-roots/{root_id}/observations",
|
||||
response_model=StorageRootResponse,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def observe_storage_root(
|
||||
root_id: uuid.UUID, request: StorageRootObservation, service: Service
|
||||
) -> StorageRootResponse:
|
||||
return service.observe_storage_root(root_id, request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/storage-roots/{root_id}/capacity",
|
||||
response_model=CapacityDecision,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def check_capacity(root_id: uuid.UUID, requested_bytes: int, service: Service) -> CapacityDecision:
|
||||
return service.check_capacity(root_id, requested_bytes)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/storage-roots/{root_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def delete_storage_root(root_id: uuid.UUID, service: Service) -> Response:
|
||||
service.delete("storage_root", root_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import require_admin, require_operator, require_viewer
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.runtime import (
|
||||
CompatibilityAssessmentCreate,
|
||||
CompatibilityAssessmentResponse,
|
||||
DeploymentCandidateResponse,
|
||||
ExecutionApprovalCreate,
|
||||
ExecutionApprovalResponse,
|
||||
RuntimeEnvironmentCreate,
|
||||
RuntimeEnvironmentResponse,
|
||||
RuntimeProbeCreate,
|
||||
RuntimeProbeResponse,
|
||||
RuntimeProfileCreate,
|
||||
RuntimeProfileResponse,
|
||||
)
|
||||
from modelforge_api.services.runtime import RuntimeService
|
||||
from modelforge_api.settings import Settings, get_settings
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1", tags=["runtime-plane"], dependencies=[Depends(require_viewer)]
|
||||
)
|
||||
|
||||
|
||||
def get_runtime_service(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> RuntimeService:
|
||||
return RuntimeService(session, settings)
|
||||
|
||||
|
||||
Service = Annotated[RuntimeService, Depends(get_runtime_service)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/runtime-environments",
|
||||
response_model=RuntimeEnvironmentResponse,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def create_environment(
|
||||
request: RuntimeEnvironmentCreate, service: Service
|
||||
) -> RuntimeEnvironmentResponse:
|
||||
return service.create_environment(request)
|
||||
|
||||
|
||||
@router.get("/runtime-environments", response_model=list[RuntimeEnvironmentResponse])
|
||||
def environments(service: Service) -> list[RuntimeEnvironmentResponse]:
|
||||
return service.environments()
|
||||
|
||||
|
||||
@router.get("/runtime-environments/{environment_id}", response_model=RuntimeEnvironmentResponse)
|
||||
def environment(environment_id: uuid.UUID, service: Service) -> RuntimeEnvironmentResponse:
|
||||
return service.environment(environment_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/runtime-profiles",
|
||||
response_model=RuntimeProfileResponse,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def create_profile(request: RuntimeProfileCreate, service: Service) -> RuntimeProfileResponse:
|
||||
return service.create_profile(request)
|
||||
|
||||
|
||||
@router.get("/runtime-profiles", response_model=list[RuntimeProfileResponse])
|
||||
def profiles(service: Service) -> list[RuntimeProfileResponse]:
|
||||
return service.profiles()
|
||||
|
||||
|
||||
@router.get("/runtime-profiles/{profile_id}", response_model=RuntimeProfileResponse)
|
||||
def profile(profile_id: uuid.UUID, service: Service) -> RuntimeProfileResponse:
|
||||
return service.profile(profile_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/artifact-sets/{artifact_set_id}/compatibility-assessments",
|
||||
response_model=CompatibilityAssessmentResponse,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def assess(
|
||||
artifact_set_id: uuid.UUID,
|
||||
request: CompatibilityAssessmentCreate,
|
||||
service: Service,
|
||||
) -> CompatibilityAssessmentResponse:
|
||||
profile = service.repo.profile(request.runtime_profile_id)
|
||||
if not profile or profile.artifact_set_id != artifact_set_id:
|
||||
from modelforge_api.services.registry import RegistryConflict
|
||||
|
||||
raise RegistryConflict("runtime profile belongs to another artifact set")
|
||||
return service.assess(request)
|
||||
|
||||
|
||||
@router.get("/compatibility-assessments", response_model=list[CompatibilityAssessmentResponse])
|
||||
def assessments(
|
||||
service: Service,
|
||||
artifact_set_id: Annotated[uuid.UUID | None, Query()] = None,
|
||||
model_id: Annotated[uuid.UUID | None, Query()] = None,
|
||||
compute_node_id: Annotated[uuid.UUID | None, Query()] = None,
|
||||
runtime_profile_id: Annotated[uuid.UUID | None, Query()] = None,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
) -> list[CompatibilityAssessmentResponse]:
|
||||
return service.assessments(
|
||||
artifact_set_id=artifact_set_id,
|
||||
model_id=model_id,
|
||||
compute_node_id=compute_node_id,
|
||||
runtime_profile_id=runtime_profile_id,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/compatibility-assessments/{assessment_id}",
|
||||
response_model=CompatibilityAssessmentResponse,
|
||||
)
|
||||
def assessment(assessment_id: uuid.UUID, service: Service) -> CompatibilityAssessmentResponse:
|
||||
return service.assessment(assessment_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/artifact-sets/{artifact_set_id}/execution-approvals",
|
||||
response_model=ExecutionApprovalResponse,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def approve_execution(
|
||||
artifact_set_id: uuid.UUID,
|
||||
request: ExecutionApprovalCreate,
|
||||
service: Service,
|
||||
) -> ExecutionApprovalResponse:
|
||||
return service.approve(artifact_set_id, request)
|
||||
|
||||
|
||||
@router.get("/execution-approvals", response_model=list[ExecutionApprovalResponse])
|
||||
def approvals(
|
||||
service: Service,
|
||||
artifact_set_id: Annotated[uuid.UUID | None, Query()] = None,
|
||||
) -> list[ExecutionApprovalResponse]:
|
||||
return service.approvals(artifact_set_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/runtime-probes",
|
||||
response_model=RuntimeProbeResponse,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def create_probe(request: RuntimeProbeCreate, service: Service) -> RuntimeProbeResponse:
|
||||
return service.create_probe(request)
|
||||
|
||||
|
||||
@router.get("/runtime-probes", response_model=list[RuntimeProbeResponse])
|
||||
def probes(service: Service) -> list[RuntimeProbeResponse]:
|
||||
return service.probes()
|
||||
|
||||
|
||||
@router.get("/runtime-probes/{probe_id}", response_model=RuntimeProbeResponse)
|
||||
def probe(probe_id: uuid.UUID, service: Service) -> RuntimeProbeResponse:
|
||||
return service.probe(probe_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/runtime-probes/{probe_id}/cancel",
|
||||
response_model=RuntimeProbeResponse,
|
||||
dependencies=[Depends(require_operator)],
|
||||
)
|
||||
def cancel_probe(probe_id: uuid.UUID, service: Service) -> RuntimeProbeResponse:
|
||||
return service.cancel(probe_id)
|
||||
|
||||
|
||||
@router.get("/deployment-candidates", response_model=list[DeploymentCandidateResponse])
|
||||
def deployment_candidates(service: Service) -> list[DeploymentCandidateResponse]:
|
||||
return service.candidates()
|
||||
|
||||
|
||||
@router.get("/deployment-candidates/{candidate_id}", response_model=DeploymentCandidateResponse)
|
||||
def deployment_candidate(candidate_id: uuid.UUID, service: Service) -> DeploymentCandidateResponse:
|
||||
return service.candidate(candidate_id)
|
||||
@@ -0,0 +1,582 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modelforge_api.api.authorization import Admin, require_viewer
|
||||
from modelforge_api.api.routes.agent import AgentIdentity
|
||||
from modelforge_api.db import get_session
|
||||
from modelforge_api.domain.serving import (
|
||||
AgentServingJobComplete,
|
||||
AgentServingJobFailure,
|
||||
AgentServingJobLease,
|
||||
AgentServingStateAck,
|
||||
AgentServingStateReport,
|
||||
CapabilityDeploymentResponse,
|
||||
CapabilityExperimentCreate,
|
||||
CapabilityExperimentResponse,
|
||||
CapabilityPromotionCreate,
|
||||
CoResidencyEvidenceCreate,
|
||||
CoResidencyEvidenceResponse,
|
||||
EmbeddingInvokeRequest,
|
||||
EmbeddingInvokeResponse,
|
||||
GatewayRequestResponse,
|
||||
OCRInvokeRequest,
|
||||
OCRInvokeResponse,
|
||||
OpenAIEmbeddingItem,
|
||||
OpenAIEmbeddingRequest,
|
||||
OpenAIEmbeddingResponse,
|
||||
OpenAIUsage,
|
||||
PlacementPlanRequest,
|
||||
PlacementPlanResponse,
|
||||
ProductionApprovalCreate,
|
||||
ProductionApprovalResponse,
|
||||
ProjectFitEvidenceCreate,
|
||||
ProjectFitEvidenceResponse,
|
||||
ProjectIntegrationResponse,
|
||||
RerankingInvokeRequest,
|
||||
RerankingInvokeResponse,
|
||||
ResidencyPolicyUpdate,
|
||||
SchedulerBudgetResponse,
|
||||
SchedulerMetricsResponse,
|
||||
SchedulerPolicyResponse,
|
||||
SchedulerPolicyUpdate,
|
||||
ServiceClientCreate,
|
||||
ServiceClientCreated,
|
||||
ServiceClientResponse,
|
||||
SpeechTranscriptionInvokeRequest,
|
||||
SpeechTranscriptionInvokeResponse,
|
||||
StableEmbeddingInvokeResponse,
|
||||
VisionEmbeddingInvokeRequest,
|
||||
VisionEmbeddingInvokeResponse,
|
||||
)
|
||||
from modelforge_api.persistence.models import ServiceClient
|
||||
from modelforge_api.services.manifest_registry import ManifestRegistry, get_manifest_registry
|
||||
from modelforge_api.services.serving import (
|
||||
CapabilityAuthenticationEvidence,
|
||||
ServingError,
|
||||
ServingService,
|
||||
)
|
||||
from modelforge_api.services.transient_payloads import RedisPayloadStore
|
||||
from modelforge_api.settings import Settings, get_settings
|
||||
|
||||
router = APIRouter(tags=["capability-serving"])
|
||||
|
||||
|
||||
@lru_cache
|
||||
def payload_store() -> RedisPayloadStore:
|
||||
return RedisPayloadStore(get_settings().redis_url)
|
||||
|
||||
|
||||
def get_serving_service(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
manifests: Annotated[ManifestRegistry, Depends(get_manifest_registry)],
|
||||
) -> ServingService:
|
||||
return ServingService(session, settings, manifests, payload_store())
|
||||
|
||||
|
||||
Service = Annotated[ServingService, Depends(get_serving_service)]
|
||||
|
||||
|
||||
def _authenticate_capability_client(
|
||||
request: Request,
|
||||
service: ServingService,
|
||||
authorization: str | None,
|
||||
capability: str,
|
||||
) -> ServiceClient:
|
||||
evidence = getattr(request.state, "capability_authentication", None)
|
||||
if isinstance(evidence, CapabilityAuthenticationEvidence):
|
||||
return service.reuse_authentication(evidence, authorization, capability)
|
||||
return service.authenticate(authorization, capability)
|
||||
|
||||
|
||||
def authenticate_rag_embedding_client(
|
||||
request: Request,
|
||||
service: Service,
|
||||
authorization: Annotated[str | None, Header(alias="Authorization")] = None,
|
||||
) -> ServiceClient:
|
||||
return _authenticate_capability_client(request, service, authorization, "rag.embedding@1")
|
||||
|
||||
|
||||
def authenticate_rag_reranking_client(
|
||||
request: Request,
|
||||
service: Service,
|
||||
authorization: Annotated[str | None, Header(alias="Authorization")] = None,
|
||||
) -> ServiceClient:
|
||||
return _authenticate_capability_client(request, service, authorization, "rag.reranking@1")
|
||||
|
||||
|
||||
def authenticate_document_ocr_client(
|
||||
request: Request,
|
||||
service: Service,
|
||||
authorization: Annotated[str | None, Header(alias="Authorization")] = None,
|
||||
) -> ServiceClient:
|
||||
return _authenticate_capability_client(request, service, authorization, "document.ocr@1")
|
||||
|
||||
|
||||
def authenticate_vision_embedding_client(
|
||||
request: Request,
|
||||
service: Service,
|
||||
authorization: Annotated[str | None, Header(alias="Authorization")] = None,
|
||||
) -> ServiceClient:
|
||||
return _authenticate_capability_client(request, service, authorization, "vision.embedding@1")
|
||||
|
||||
|
||||
def authenticate_speech_transcription_client(
|
||||
request: Request,
|
||||
service: Service,
|
||||
authorization: Annotated[str | None, Header(alias="Authorization")] = None,
|
||||
) -> ServiceClient:
|
||||
return _authenticate_capability_client(
|
||||
request, service, authorization, "speech.transcription@1"
|
||||
)
|
||||
|
||||
|
||||
RagEmbeddingClient = Annotated[ServiceClient, Depends(authenticate_rag_embedding_client)]
|
||||
RagRerankingClient = Annotated[ServiceClient, Depends(authenticate_rag_reranking_client)]
|
||||
DocumentOcrClient = Annotated[ServiceClient, Depends(authenticate_document_ocr_client)]
|
||||
VisionEmbeddingClient = Annotated[ServiceClient, Depends(authenticate_vision_embedding_client)]
|
||||
SpeechTranscriptionClient = Annotated[
|
||||
ServiceClient, Depends(authenticate_speech_transcription_client)
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/deployment-candidates/{candidate_id}/production-approvals",
|
||||
response_model=ProductionApprovalResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def approve_production(
|
||||
candidate_id: uuid.UUID,
|
||||
request: ProductionApprovalCreate,
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
) -> ProductionApprovalResponse:
|
||||
return service.approve_production(candidate_id, request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/admin/production-approvals",
|
||||
response_model=list[ProductionApprovalResponse],
|
||||
)
|
||||
def production_approvals(service: Service, _admin: Admin) -> list[ProductionApprovalResponse]:
|
||||
return service.approvals()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/deployment-candidates/{candidate_id}/promote",
|
||||
response_model=CapabilityDeploymentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def promote_candidate(
|
||||
candidate_id: uuid.UUID,
|
||||
request: CapabilityPromotionCreate,
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
) -> CapabilityDeploymentResponse:
|
||||
return service.promote(candidate_id, request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/capability-deployments",
|
||||
response_model=list[CapabilityDeploymentResponse],
|
||||
dependencies=[Depends(require_viewer)],
|
||||
)
|
||||
def capability_deployments(service: Service) -> list[CapabilityDeploymentResponse]:
|
||||
return service.deployments()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/deployment-candidates/{candidate_id}/experiments",
|
||||
response_model=CapabilityExperimentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_capability_experiment(
|
||||
candidate_id: uuid.UUID,
|
||||
request: CapabilityExperimentCreate,
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
) -> CapabilityExperimentResponse:
|
||||
return service.create_experiment(candidate_id, request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/capability-experiments",
|
||||
response_model=list[CapabilityExperimentResponse],
|
||||
dependencies=[Depends(require_viewer)],
|
||||
)
|
||||
def capability_experiments(service: Service) -> list[CapabilityExperimentResponse]:
|
||||
return service.experiments()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/capability-experiments/{experiment_id}/deactivate",
|
||||
response_model=CapabilityExperimentResponse,
|
||||
)
|
||||
def deactivate_capability_experiment(
|
||||
experiment_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> CapabilityExperimentResponse:
|
||||
return service.deactivate_experiment(experiment_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/capability-deployments/{deployment_id}/unload",
|
||||
response_model=CapabilityDeploymentResponse,
|
||||
)
|
||||
def unload_deployment(
|
||||
deployment_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> CapabilityDeploymentResponse:
|
||||
return service.request_unload(deployment_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/capability-deployments/{deployment_id}/drain",
|
||||
response_model=CapabilityDeploymentResponse,
|
||||
)
|
||||
def drain_deployment(
|
||||
deployment_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> CapabilityDeploymentResponse:
|
||||
return service.request_unload(deployment_id, drain=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/service-clients",
|
||||
response_model=ServiceClientCreated,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_service_client(
|
||||
request: ServiceClientCreate,
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
response: Response,
|
||||
) -> ServiceClientCreated:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return service.create_client(request)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/service-clients", response_model=list[ServiceClientResponse])
|
||||
def service_clients(service: Service, _admin: Admin) -> list[ServiceClientResponse]:
|
||||
return service.clients()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/admin/service-clients/{client_id}/credential",
|
||||
response_model=ServiceClientResponse,
|
||||
)
|
||||
def revoke_service_credential(
|
||||
client_id: uuid.UUID, service: Service, _admin: Admin
|
||||
) -> ServiceClientResponse:
|
||||
return service.revoke_client_credential(client_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/service-clients/{client_id}/credential/rotate",
|
||||
response_model=ServiceClientCreated,
|
||||
)
|
||||
def rotate_service_credential(
|
||||
client_id: uuid.UUID, service: Service, _admin: Admin, response: Response
|
||||
) -> ServiceClientCreated:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return service.rotate_client_credential(client_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/project-integrations",
|
||||
response_model=list[ProjectIntegrationResponse],
|
||||
dependencies=[Depends(require_viewer)],
|
||||
)
|
||||
def project_integrations(service: Service) -> list[ProjectIntegrationResponse]:
|
||||
return service.project_integrations()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/project-fit-evidence",
|
||||
response_model=ProjectFitEvidenceResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def record_project_fit_evidence(
|
||||
request: ProjectFitEvidenceCreate, service: Service, _admin: Admin
|
||||
) -> ProjectFitEvidenceResponse:
|
||||
return service.record_project_fit(request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/scheduler",
|
||||
response_model=list[SchedulerBudgetResponse],
|
||||
dependencies=[Depends(require_viewer)],
|
||||
)
|
||||
def scheduler_overview(service: Service) -> list[SchedulerBudgetResponse]:
|
||||
return service.scheduler_overview()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/scheduler/co-residency",
|
||||
response_model=list[CoResidencyEvidenceResponse],
|
||||
dependencies=[Depends(require_viewer)],
|
||||
)
|
||||
def co_residency_matrix(service: Service) -> list[CoResidencyEvidenceResponse]:
|
||||
return service.co_residency_matrix()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/scheduler/co-residency-evidence",
|
||||
response_model=CoResidencyEvidenceResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def record_co_residency_evidence(
|
||||
request: CoResidencyEvidenceCreate, service: Service, _admin: Admin
|
||||
) -> CoResidencyEvidenceResponse:
|
||||
return service.record_co_residency_evidence(request)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/scheduler/policy", response_model=SchedulerPolicyResponse)
|
||||
def scheduler_policy(service: Service, _admin: Admin) -> SchedulerPolicyResponse:
|
||||
return service.scheduler_policy()
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/scheduler/metrics", response_model=SchedulerMetricsResponse)
|
||||
def scheduler_metrics(service: Service, _admin: Admin) -> SchedulerMetricsResponse:
|
||||
return service.scheduler_metrics()
|
||||
|
||||
|
||||
@router.put("/api/v1/admin/scheduler/policy", response_model=SchedulerPolicyResponse)
|
||||
def update_scheduler_policy(
|
||||
request: SchedulerPolicyUpdate, service: Service, _admin: Admin
|
||||
) -> SchedulerPolicyResponse:
|
||||
return service.update_scheduler_policy(request)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/admin/scheduler/placements/{deployment_id}/dry-run",
|
||||
response_model=PlacementPlanResponse,
|
||||
)
|
||||
def dry_run_placement(
|
||||
deployment_id: uuid.UUID,
|
||||
request: PlacementPlanRequest,
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
) -> PlacementPlanResponse:
|
||||
return service.dry_run_placement(deployment_id, request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/admin/scheduler/placements",
|
||||
response_model=list[PlacementPlanResponse],
|
||||
)
|
||||
def placement_history(
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
||||
) -> list[PlacementPlanResponse]:
|
||||
return service.placement_history(limit)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/admin/capability-deployments/{deployment_id}/residency-policy",
|
||||
response_model=CapabilityDeploymentResponse,
|
||||
)
|
||||
def update_residency_policy(
|
||||
deployment_id: uuid.UUID,
|
||||
request: ResidencyPolicyUpdate,
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
) -> CapabilityDeploymentResponse:
|
||||
return service.update_residency_policy(deployment_id, request)
|
||||
|
||||
|
||||
@router.get("/api/v1/gateway/requests", response_model=list[GatewayRequestResponse])
|
||||
def gateway_requests(
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
||||
) -> list[GatewayRequestResponse]:
|
||||
return service.request_history(limit)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/latency-traces", response_model=list[GatewayRequestResponse])
|
||||
def latency_traces(
|
||||
service: Service,
|
||||
_admin: Admin,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
||||
) -> list[GatewayRequestResponse]:
|
||||
"""Return bounded span summaries; request content and input digests are excluded."""
|
||||
return service.request_history(limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/capabilities/rag.embedding@1/invoke",
|
||||
response_model=StableEmbeddingInvokeResponse,
|
||||
)
|
||||
def invoke_embedding(
|
||||
request: EmbeddingInvokeRequest,
|
||||
service: Service,
|
||||
client: RagEmbeddingClient,
|
||||
) -> StableEmbeddingInvokeResponse:
|
||||
return StableEmbeddingInvokeResponse.model_validate(service.invoke(request, client))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/capabilities/rag.reranking@1/invoke",
|
||||
response_model=RerankingInvokeResponse,
|
||||
)
|
||||
def invoke_reranking(
|
||||
request: RerankingInvokeRequest,
|
||||
service: Service,
|
||||
client: RagRerankingClient,
|
||||
) -> RerankingInvokeResponse:
|
||||
return service.invoke_reranking(request, client)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/capabilities/document.ocr@1/invoke",
|
||||
response_model=OCRInvokeResponse,
|
||||
)
|
||||
def invoke_ocr(
|
||||
request: OCRInvokeRequest,
|
||||
service: Service,
|
||||
client: DocumentOcrClient,
|
||||
) -> OCRInvokeResponse:
|
||||
result, request_id, execution = service.invoke_modality(
|
||||
"document.ocr", request.model_dump(mode="json"), client
|
||||
)
|
||||
return OCRInvokeResponse(request_id=request_id, execution=execution, **result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/capabilities/vision.embedding@1/invoke",
|
||||
response_model=VisionEmbeddingInvokeResponse,
|
||||
)
|
||||
def invoke_vision_embedding(
|
||||
request: VisionEmbeddingInvokeRequest,
|
||||
service: Service,
|
||||
client: VisionEmbeddingClient,
|
||||
) -> VisionEmbeddingInvokeResponse:
|
||||
result, request_id, execution = service.invoke_modality(
|
||||
"vision.embedding", request.model_dump(mode="json"), client
|
||||
)
|
||||
return VisionEmbeddingInvokeResponse(
|
||||
request_id=request_id,
|
||||
execution=execution,
|
||||
dimension=int(result["dimension"]),
|
||||
embedding_space_id=result["embedding_space_id"],
|
||||
data=result["vectors"],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/capabilities/speech.transcription@1/invoke",
|
||||
response_model=SpeechTranscriptionInvokeResponse,
|
||||
)
|
||||
def invoke_speech_transcription(
|
||||
request: SpeechTranscriptionInvokeRequest,
|
||||
service: Service,
|
||||
client: SpeechTranscriptionClient,
|
||||
) -> SpeechTranscriptionInvokeResponse:
|
||||
result, request_id, execution = service.invoke_modality(
|
||||
"speech.transcription", request.model_dump(mode="json"), client
|
||||
)
|
||||
return SpeechTranscriptionInvokeResponse(
|
||||
request_id=request_id,
|
||||
execution=execution,
|
||||
**result,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/capability-experiments/{route_key}/invoke",
|
||||
response_model=EmbeddingInvokeResponse,
|
||||
)
|
||||
def invoke_embedding_experiment(
|
||||
route_key: str,
|
||||
request: EmbeddingInvokeRequest,
|
||||
service: Service,
|
||||
client: RagEmbeddingClient,
|
||||
) -> EmbeddingInvokeResponse:
|
||||
route = service.repo.experiment_route(route_key)
|
||||
deployment = (
|
||||
service.repo.deployment(route.capability_deployment_id)
|
||||
if route and route.status == "active"
|
||||
else None
|
||||
)
|
||||
if not deployment:
|
||||
raise ServingError(404, "EXPERIMENT_NOT_FOUND", "capability experiment is unavailable")
|
||||
return service.invoke(
|
||||
request,
|
||||
client,
|
||||
deployment=deployment,
|
||||
experiment_route=route_key,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/v1/embeddings", response_model=OpenAIEmbeddingResponse)
|
||||
def openai_embeddings(
|
||||
request: OpenAIEmbeddingRequest,
|
||||
service: Service,
|
||||
client: RagEmbeddingClient,
|
||||
) -> OpenAIEmbeddingResponse:
|
||||
native = service.invoke(EmbeddingInvokeRequest(input=request.input), client)
|
||||
return OpenAIEmbeddingResponse(
|
||||
data=[
|
||||
OpenAIEmbeddingItem(index=index, embedding=embedding)
|
||||
for index, embedding in enumerate(native.data)
|
||||
],
|
||||
usage=OpenAIUsage(
|
||||
prompt_tokens=native.usage.input_tokens,
|
||||
total_tokens=native.usage.input_tokens,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/agent/serving-jobs/next",
|
||||
response_model=AgentServingJobLease | None,
|
||||
)
|
||||
def claim_serving_job(
|
||||
service: Service,
|
||||
identity: AgentIdentity,
|
||||
wait_seconds: Annotated[float, Query(ge=0.0, le=5.0)] = 0.0,
|
||||
) -> AgentServingJobLease | None:
|
||||
_credential, node = identity
|
||||
return service.claim_next(node, wait_seconds=wait_seconds)
|
||||
|
||||
|
||||
@router.post("/api/v1/agent/serving-jobs/{job_id}/complete")
|
||||
def complete_serving_job(
|
||||
job_id: uuid.UUID,
|
||||
request: AgentServingJobComplete,
|
||||
service: Service,
|
||||
identity: AgentIdentity,
|
||||
) -> dict[str, str]:
|
||||
_credential, node = identity
|
||||
service.complete_job(job_id, node, request)
|
||||
return {"status": "accepted"}
|
||||
|
||||
|
||||
@router.post("/api/v1/agent/serving-jobs/{job_id}/fail")
|
||||
def fail_serving_job(
|
||||
job_id: uuid.UUID,
|
||||
request: AgentServingJobFailure,
|
||||
service: Service,
|
||||
identity: AgentIdentity,
|
||||
) -> dict[str, str]:
|
||||
_credential, node = identity
|
||||
service.fail_job(job_id, node, request)
|
||||
return {"status": "accepted"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/agent/serving-state",
|
||||
response_model=AgentServingStateAck,
|
||||
)
|
||||
def report_serving_state(
|
||||
request: AgentServingStateReport,
|
||||
service: Service,
|
||||
identity: AgentIdentity,
|
||||
) -> AgentServingStateAck:
|
||||
_credential, node = identity
|
||||
return service.report_state(node, request)
|
||||
Reference in New Issue
Block a user