Files
2026-09-18 15:26:19 +08:00

686 lines
25 KiB
Python

from datetime import date, timedelta
from math import ceil
from django.conf import settings
from django.db.models import Count, Q
from django.utils import timezone
from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_login, require_permission
from apps.audit.models import OperationAuditLog
from apps.modelhub.crypto import mask_secret
from apps.modelhub.governance import (
ModelGovernanceError,
create_deployment_config,
create_provider_config,
deployment_impact,
provider_impact,
remove_deployment,
remove_provider,
restore_deployment,
restore_provider,
update_deployment_config,
update_provider_config,
)
from apps.modelhub.models import ModelDeployment, ModelInvocation, ModelProvider
from apps.modelhub.schemas import (
DeletionImpactOut,
LifecycleResultOut,
ModelDeploymentIn,
ModelDeploymentOptionOut,
ModelDeploymentOut,
ModelDeploymentPageOut,
ModelDeploymentUpdateIn,
ModelInvocationDiagnosticsOut,
ModelInvocationOut,
ModelInvocationPageOut,
ModelProviderIn,
ModelProviderOut,
ModelProviderPageOut,
ModelProviderUpdateIn,
ModelTestIn,
)
from apps.modelhub.services import create_model_test, create_structured_output_test
from apps.modelhub.structured_output import (
CHAT_CAPABILITIES,
StructuredOutputError,
validate_mode_for_capability,
)
from apps.modelhub.tasks import run_model_test
router = Router(tags=["modelhub"])
def governance_http_error(error: ModelGovernanceError) -> HttpError:
return HttpError(error.status, f"{error.code}: {error}")
def structured_http_error(error: StructuredOutputError) -> HttpError:
return HttpError(400, f"{error.code}: {error}")
def require_superuser(request):
user = require_login(request)
if not user.is_superuser:
raise HttpError(403, "superuser_required: 仅超级管理员可执行生命周期操作")
return user
def require_any_permission(request, *codes: str):
user = require_login(request)
if user.is_superuser or any(user.has_perm(f"system.{code}") for code in codes):
return user
raise HttpError(403, "Permission denied")
def _user_label(user) -> str | None:
if not user:
return None
return user.get_full_name() or user.username
def _provider_status(provider: ModelProvider) -> str:
if provider.archived_at:
return "archived"
if not provider.enabled:
return "disabled"
if not provider.api_key_encrypted:
return "credential_missing"
return "ready"
def _deployment_status(deployment: ModelDeployment) -> str:
if deployment.archived_at:
return "archived"
if deployment.provider.archived_at:
return "provider_archived"
if not deployment.enabled:
return "disabled"
provider_status = _provider_status(deployment.provider)
if provider_status != "ready":
return f"provider_{provider_status}"
if (
deployment.capability in CHAT_CAPABILITIES
and deployment.structured_output_test_status != "passed"
):
return "unverified"
return "ready"
def serialize_provider(provider: ModelProvider) -> dict:
return {
"id": provider.id,
"name": provider.name,
"base_url": provider.base_url,
"enabled": provider.enabled,
"default_timeout_seconds": provider.default_timeout_seconds,
"credential_set": bool(provider.api_key_encrypted),
"credential_masked": mask_secret(provider.api_key_encrypted),
"image_egress_approved": provider.image_egress_approved,
"image_egress_approved_by": _user_label(provider.image_egress_approved_by),
"image_egress_approved_at": provider.image_egress_approved_at,
"credential_updated_at": provider.credential_updated_at,
"credential_updated_by": _user_label(provider.credential_updated_by),
"archived_at": provider.archived_at,
"archived_by": _user_label(provider.archived_by),
"deployment_count": (
provider.deployment_count
if hasattr(provider, "deployment_count")
else provider.deployments.count()
),
"active_deployment_count": (
provider.active_deployment_count
if hasattr(provider, "active_deployment_count")
else provider.deployments.filter(archived_at__isnull=True).count()
),
"effective_status": _provider_status(provider),
"created_at": provider.created_at,
"updated_at": provider.updated_at,
}
def _reference_summary(deployment: ModelDeployment) -> dict[str, int]:
return {
"agent_version_count": (
deployment.agent_version_count
if hasattr(deployment, "agent_version_count")
else deployment.agent_versions.count()
),
"step_binding_count": (
deployment.step_binding_count
if hasattr(deployment, "step_binding_count")
else deployment.agent_step_bindings.count()
),
"run_step_count": (
deployment.run_step_count
if hasattr(deployment, "run_step_count")
else deployment.agent_run_steps.count()
),
"invocation_count": (
deployment.invocation_count
if hasattr(deployment, "invocation_count")
else deployment.invocations.count()
),
}
def serialize_deployment(deployment: ModelDeployment, metrics: dict | None = None) -> dict:
if metrics is None:
from apps.agents.structured_metrics import build_structured_output_metrics
metrics = build_structured_output_metrics(deployment_id=deployment.id)
return {
"id": deployment.id,
"provider_id": deployment.provider_id,
"provider_name": deployment.provider.name,
"display_name": deployment.display_name,
"model_name": deployment.model_name,
"capability": deployment.capability,
"default_temperature": deployment.default_temperature,
"max_output_tokens": deployment.max_output_tokens,
"structured_output_mode": deployment.structured_output_mode,
"structured_output_test_status": deployment.structured_output_test_status,
"structured_output_verified_at": deployment.structured_output_verified_at,
"structured_output_test_message": deployment.structured_output_test_message,
"structured_output_metrics": metrics,
"enabled": deployment.enabled,
"effective_status": _deployment_status(deployment),
"provider_effective_status": _provider_status(deployment.provider),
"reference_summary": _reference_summary(deployment),
"archived_at": deployment.archived_at,
"archived_by": _user_label(deployment.archived_by),
"archived_via_provider": deployment.archived_via_provider,
}
def _diagnostics_expires_at(invocation: ModelInvocation):
return invocation.created_at + timedelta(
days=settings.MODEL_INVOCATION_DIAGNOSTIC_RETENTION_DAYS
)
def serialize_invocation(
invocation: ModelInvocation,
*,
include_diagnostics: bool = False,
) -> dict:
snapshot = invocation.model_snapshot or {}
deployment = invocation.deployment
expires_at = _diagnostics_expires_at(invocation)
available = (
invocation.diagnostics_scrubbed_at is None and expires_at > timezone.now()
)
summary = {
key: snapshot.get(key)
for key in (
"provider_id",
"provider_name",
"deployment_id",
"model_name",
"capability",
"structured_output",
)
if key in snapshot
}
return {
"public_id": invocation.public_id,
"deployment_id": invocation.deployment_id,
"deployment_display_name": deployment.display_name if deployment else None,
"provider_name": (
deployment.provider.name if deployment else snapshot.get("provider_name")
),
"invocation_type": invocation.invocation_type,
"status": invocation.status,
"model_snapshot": snapshot if include_diagnostics else {},
"model_snapshot_summary": summary,
"output_text": invocation.output_text if include_diagnostics and available else "",
"input_tokens": invocation.input_tokens,
"output_tokens": invocation.output_tokens,
"duration_ms": invocation.duration_ms,
"retry_count": invocation.retry_count,
"error_code": invocation.error_code,
"error_message": invocation.error_message[:500],
"created_at": invocation.created_at,
"started_at": invocation.started_at,
"finished_at": invocation.finished_at,
"diagnostics_scrubbed_at": invocation.diagnostics_scrubbed_at,
"diagnostics_expires_at": expires_at,
"diagnostics_available": available,
}
def audit(user, action: str, target_type: str, target_id: str, snapshot: dict) -> None:
OperationAuditLog.objects.create(
user=user if getattr(user, "is_authenticated", False) else None,
module="modelhub",
action=action,
target_type=target_type,
target_id=target_id,
request_snapshot=snapshot,
result="success",
)
def _page(queryset, page: int, page_size: int):
page = max(page, 1)
page_size = min(max(page_size, 1), 100)
total = queryset.count()
start = (page - 1) * page_size
return list(queryset[start : start + page_size]), {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": ceil(total / page_size) if total else 0,
}
@router.get("/providers", response=ModelProviderPageOut)
def list_providers(
request,
q: str = "",
enabled: str = "all",
archived: str = "active",
page: int = 1,
page_size: int = 20,
):
require_permission(request, "model.manage")
queryset = ModelProvider.objects.select_related(
"image_egress_approved_by", "credential_updated_by", "archived_by"
).annotate(
deployment_count=Count("deployments", distinct=True),
active_deployment_count=Count(
"deployments",
filter=Q(deployments__archived_at__isnull=True),
distinct=True,
),
)
if q:
queryset = queryset.filter(Q(name__icontains=q) | Q(base_url__icontains=q))
if enabled in {"true", "false"}:
queryset = queryset.filter(enabled=enabled == "true")
if archived == "active":
queryset = queryset.filter(archived_at__isnull=True)
elif archived == "archived":
queryset = queryset.filter(archived_at__isnull=False)
rows, pagination = _page(queryset, page, page_size)
return {"items": [serialize_provider(item) for item in rows], **pagination}
@router.post("/providers", response=ModelProviderOut)
def create_provider(request, payload: ModelProviderIn):
require_permission(request, "model.manage")
try:
provider = create_provider_config(payload.dict(), request.user)
except ModelGovernanceError as error:
raise governance_http_error(error) from error
audit(request.user, "create", "model_provider", str(provider.id), {"name": provider.name})
return serialize_provider(provider)
@router.patch("/providers/{provider_id}", response=ModelProviderOut)
def update_provider(request, provider_id: int, payload: ModelProviderUpdateIn):
require_permission(request, "model.manage")
provider = ModelProvider.objects.select_related(
"image_egress_approved_by", "credential_updated_by", "archived_by"
).get(id=provider_id)
try:
provider, snapshot = update_provider_config(
provider, payload.dict(exclude_unset=True), request.user
)
except ModelGovernanceError as error:
raise governance_http_error(error) from error
audit(request.user, "update", "model_provider", str(provider.id), snapshot)
return serialize_provider(provider)
@router.get("/providers/{provider_id}/deletion-impact", response=DeletionImpactOut)
def get_provider_deletion_impact(request, provider_id: int):
require_superuser(request)
return provider_impact(ModelProvider.objects.get(id=provider_id))
@router.delete("/providers/{provider_id}", response=LifecycleResultOut)
def delete_provider(request, provider_id: int):
user = require_superuser(request)
try:
result, impact = remove_provider(provider_id, user)
except ModelGovernanceError as error:
raise governance_http_error(error) from error
audit(user, result, "model_provider", str(provider_id), {"impact": impact})
return {"result": result, "impact": impact}
@router.post("/providers/{provider_id}/restore", response=ModelProviderOut)
def restore_provider_api(request, provider_id: int):
user = require_superuser(request)
try:
provider = restore_provider(provider_id)
except ModelGovernanceError as error:
raise governance_http_error(error) from error
audit(user, "restore", "model_provider", str(provider_id), {"enabled": False})
return serialize_provider(provider)
@router.get("/deployments", response=ModelDeploymentPageOut)
def list_deployments(
request,
provider_id: int | None = None,
capability: str = "",
effective_status: str = "",
structured_test_status: str = "",
archived: str = "active",
q: str = "",
page: int = 1,
page_size: int = 20,
):
require_permission(request, "model.manage")
queryset = ModelDeployment.objects.select_related(
"provider", "archived_by"
).annotate(
agent_version_count=Count("agent_versions", distinct=True),
step_binding_count=Count("agent_step_bindings", distinct=True),
run_step_count=Count("agent_run_steps", distinct=True),
invocation_count=Count("invocations", distinct=True),
)
if provider_id:
queryset = queryset.filter(provider_id=provider_id)
if capability:
queryset = queryset.filter(capability=capability)
if structured_test_status:
queryset = queryset.filter(
structured_output_test_status=structured_test_status
)
if q:
queryset = queryset.filter(
Q(display_name__icontains=q)
| Q(model_name__icontains=q)
| Q(provider__name__icontains=q)
)
if archived == "active":
queryset = queryset.filter(archived_at__isnull=True)
elif archived == "archived":
queryset = queryset.filter(archived_at__isnull=False)
if effective_status:
if effective_status == "archived":
queryset = queryset.filter(archived_at__isnull=False)
elif effective_status == "provider_archived":
queryset = queryset.filter(
archived_at__isnull=True,
provider__archived_at__isnull=False,
)
elif effective_status == "disabled":
queryset = queryset.filter(archived_at__isnull=True, enabled=False)
elif effective_status == "provider_disabled":
queryset = queryset.filter(
archived_at__isnull=True,
enabled=True,
provider__archived_at__isnull=True,
provider__enabled=False,
)
elif effective_status == "unverified":
queryset = queryset.filter(
archived_at__isnull=True,
provider__archived_at__isnull=True,
enabled=True,
provider__enabled=True,
capability__in=CHAT_CAPABILITIES,
).exclude(structured_output_test_status="passed")
elif effective_status == "ready":
queryset = queryset.filter(
archived_at__isnull=True,
provider__archived_at__isnull=True,
enabled=True,
provider__enabled=True,
).exclude(provider__api_key_encrypted="").filter(
Q(capability__in={"embedding", "rerank"})
| Q(structured_output_test_status="passed")
)
rows, pagination = _page(queryset, page, page_size)
from apps.agents.structured_metrics import build_deployment_structured_output_metrics
metrics = build_deployment_structured_output_metrics([item.id for item in rows])
return {
"items": [serialize_deployment(item, metrics[item.id]) for item in rows],
**pagination,
}
@router.get("/deployment-options", response=list[ModelDeploymentOptionOut])
def list_deployment_options(request):
require_any_permission(request, "agent.manage", "model.manage")
queryset = ModelDeployment.objects.select_related("provider").filter(
archived_at__isnull=True,
provider__archived_at__isnull=True,
enabled=True,
provider__enabled=True,
).exclude(provider__api_key_encrypted="")
items = [item for item in queryset if _deployment_status(item) == "ready"]
return [
{
"id": item.id,
"provider_id": item.provider_id,
"provider_name": item.provider.name,
"display_name": item.display_name,
"model_name": item.model_name,
"capability": item.capability,
"default_temperature": item.default_temperature,
"max_output_tokens": item.max_output_tokens,
"structured_output_mode": item.structured_output_mode,
"structured_output_test_status": item.structured_output_test_status,
"structured_output_verified_at": item.structured_output_verified_at,
"structured_output_test_message": item.structured_output_test_message,
"enabled": item.enabled,
}
for item in items
]
@router.post("/deployments", response=ModelDeploymentOut)
def create_deployment(request, payload: ModelDeploymentIn):
require_permission(request, "model.manage")
try:
deployment = create_deployment_config(payload.dict())
except ModelGovernanceError as error:
raise governance_http_error(error) from error
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment.id)
audit(
request.user,
"create",
"model_deployment",
str(deployment.id),
{"model_name": deployment.model_name},
)
return serialize_deployment(deployment)
@router.patch("/deployments/{deployment_id}", response=ModelDeploymentOut)
def update_deployment(request, deployment_id: int, payload: ModelDeploymentUpdateIn):
require_permission(request, "model.manage")
deployment = ModelDeployment.objects.select_related("provider", "archived_by").get(
id=deployment_id
)
try:
deployment, snapshot = update_deployment_config(
deployment, payload.dict(exclude_unset=True)
)
except ModelGovernanceError as error:
raise governance_http_error(error) from error
audit(request.user, "update", "model_deployment", str(deployment.id), snapshot)
return serialize_deployment(deployment)
def _ensure_testable(deployment: ModelDeployment):
if deployment.archived_at or deployment.provider.archived_at:
raise HttpError(
400, "model_config_archived: 归档模型不允许编辑、测试或启用"
)
@router.post("/deployments/{deployment_id}/test", response=ModelInvocationOut)
def test_deployment(request, deployment_id: int, payload: ModelTestIn):
require_permission(request, "model.manage")
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id)
_ensure_testable(deployment)
if deployment.capability not in CHAT_CAPABILITIES:
raise HttpError(400, "Embedding 或重排模型请通过对应能力入口验证")
invocation = create_model_test(deployment, payload.prompt, request.user)
run_model_test.delay(invocation.id)
invocation.refresh_from_db()
audit(
request.user,
"test",
"model_deployment",
str(deployment.id),
{"invocation_id": str(invocation.public_id)},
)
return serialize_invocation(invocation, include_diagnostics=True)
@router.post(
"/deployments/{deployment_id}/structured-output-test",
response=ModelInvocationOut,
)
def test_structured_output(request, deployment_id: int):
require_permission(request, "model.manage")
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id)
_ensure_testable(deployment)
if deployment.capability not in CHAT_CAPABILITIES:
raise HttpError(400, "该模型能力不适用结构化输出测试")
try:
validate_mode_for_capability(
deployment.capability, deployment.structured_output_mode
)
except StructuredOutputError as error:
raise structured_http_error(error) from error
deployment.structured_output_test_status = "untested"
deployment.structured_output_verified_at = None
deployment.structured_output_test_message = "结构化输出测试执行中"
deployment.save(
update_fields=[
"structured_output_test_status",
"structured_output_verified_at",
"structured_output_test_message",
"updated_at",
]
)
invocation = create_structured_output_test(deployment, request.user)
run_model_test.delay(invocation.id)
invocation.refresh_from_db()
audit(
request.user,
"structured_output_test",
"model_deployment",
str(deployment.id),
{
"invocation_id": str(invocation.public_id),
"mode": deployment.structured_output_mode,
},
)
return serialize_invocation(invocation, include_diagnostics=True)
@router.get("/deployments/{deployment_id}/deletion-impact", response=DeletionImpactOut)
def get_deployment_deletion_impact(request, deployment_id: int):
require_superuser(request)
return deployment_impact(ModelDeployment.objects.get(id=deployment_id))
@router.delete("/deployments/{deployment_id}", response=LifecycleResultOut)
def delete_deployment(request, deployment_id: int):
user = require_superuser(request)
try:
result, impact = remove_deployment(deployment_id, user)
except ModelGovernanceError as error:
raise governance_http_error(error) from error
audit(user, result, "model_deployment", str(deployment_id), {"impact": impact})
return {"result": result, "impact": impact}
@router.post("/deployments/{deployment_id}/restore", response=ModelDeploymentOut)
def restore_deployment_api(request, deployment_id: int):
user = require_superuser(request)
try:
deployment = restore_deployment(deployment_id)
except ModelGovernanceError as error:
raise governance_http_error(error) from error
audit(user, "restore", "model_deployment", str(deployment_id), {"enabled": False})
return serialize_deployment(deployment)
@router.get("/invocations", response=ModelInvocationPageOut)
def list_invocations(
request,
deployment_id: int | None = None,
provider_id: int | None = None,
invocation_type: str = "",
status: str = "",
date_from: date | None = None,
date_to: date | None = None,
error_code: str = "",
page: int = 1,
page_size: int = 20,
):
require_permission(request, "model.manage")
queryset = ModelInvocation.objects.select_related("deployment__provider")
if deployment_id:
queryset = queryset.filter(deployment_id=deployment_id)
if provider_id:
queryset = queryset.filter(deployment__provider_id=provider_id)
if invocation_type:
queryset = queryset.filter(invocation_type=invocation_type)
if status:
queryset = queryset.filter(status=status)
if date_from:
queryset = queryset.filter(created_at__date__gte=date_from)
if date_to:
queryset = queryset.filter(created_at__date__lte=date_to)
if error_code:
queryset = queryset.filter(error_code=error_code)
rows, pagination = _page(queryset, page, page_size)
return {"items": [serialize_invocation(item) for item in rows], **pagination}
@router.get("/invocations/{public_id}", response=ModelInvocationOut)
def get_invocation(request, public_id: str):
require_permission(request, "model.manage")
invocation = ModelInvocation.objects.select_related("deployment__provider").get(
public_id=public_id
)
return serialize_invocation(invocation)
@router.get(
"/invocations/{public_id}/diagnostics",
response=ModelInvocationDiagnosticsOut,
)
def get_invocation_diagnostics(request, public_id: str):
require_permission(request, "model.invocation.view")
invocation = ModelInvocation.objects.get(public_id=public_id)
expires_at = _diagnostics_expires_at(invocation)
available = (
invocation.diagnostics_scrubbed_at is None and expires_at > timezone.now()
)
if not available:
raise HttpError(
410, "diagnostics_expired: 模型调用诊断内容已超过保留期"
)
return {
"public_id": invocation.public_id,
"input_snapshot": invocation.input_snapshot,
"output_text": invocation.output_text,
"diagnostics_scrubbed_at": invocation.diagnostics_scrubbed_at,
"diagnostics_expires_at": expires_at,
"diagnostics_available": True,
}
@router.get("/model-tests/{public_id}", response=ModelInvocationOut)
def get_model_test(request, public_id: str):
require_permission(request, "model.manage")
invocation = ModelInvocation.objects.select_related("deployment__provider").get(
public_id=public_id,
invocation_type__in={"model_test", "structured_output_test"},
)
return serialize_invocation(invocation, include_diagnostics=True)