From 11bab3e98ff04e0c454ddd7338538fc037edb0f9 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 14 Jul 2026 13:44:55 +0800 Subject: [PATCH] feat: add openai-compatible model gateway --- .env.example | 2 + backend/apps/accounts/menus.py | 24 ++- backend/apps/accounts/permissions.py | 2 + backend/apps/modelhub/__init__.py | 1 + backend/apps/modelhub/api.py | 186 ++++++++++++++++++ backend/apps/modelhub/apps.py | 6 + backend/apps/modelhub/crypto.py | 40 ++++ .../apps/modelhub/migrations/0001_initial.py | 143 ++++++++++++++ backend/apps/modelhub/migrations/__init__.py | 1 + backend/apps/modelhub/models.py | 88 +++++++++ backend/apps/modelhub/schemas.py | 83 ++++++++ backend/apps/modelhub/services.py | 162 +++++++++++++++ backend/apps/modelhub/tasks.py | 13 ++ backend/apps/modelhub/tests/__init__.py | 1 + backend/apps/modelhub/tests/test_crypto.py | 23 +++ .../apps/modelhub/tests/test_modelhub_api.py | 88 +++++++++ backend/apps/modelhub/tests/test_services.py | 59 ++++++ backend/config/__init__.py | 2 + backend/config/settings/base.py | 11 ++ backend/config/settings/test.py | 4 + backend/config/urls.py | 2 + backend/pyproject.toml | 3 + 22 files changed, 940 insertions(+), 4 deletions(-) create mode 100644 backend/apps/modelhub/__init__.py create mode 100644 backend/apps/modelhub/api.py create mode 100644 backend/apps/modelhub/apps.py create mode 100644 backend/apps/modelhub/crypto.py create mode 100644 backend/apps/modelhub/migrations/0001_initial.py create mode 100644 backend/apps/modelhub/migrations/__init__.py create mode 100644 backend/apps/modelhub/models.py create mode 100644 backend/apps/modelhub/schemas.py create mode 100644 backend/apps/modelhub/services.py create mode 100644 backend/apps/modelhub/tasks.py create mode 100644 backend/apps/modelhub/tests/__init__.py create mode 100644 backend/apps/modelhub/tests/test_crypto.py create mode 100644 backend/apps/modelhub/tests/test_modelhub_api.py create mode 100644 backend/apps/modelhub/tests/test_services.py diff --git a/.env.example b/.env.example index 2b3c007..294d96f 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,8 @@ POSTGRES_PORT=5432 CELERY_BROKER_URL=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/1 +AI_CREDENTIAL_ENCRYPTION_KEY=t8XuQv9e8WDoFv7RXT4-zdw1hQChGFKtr7E6ccRZAzU= +AI_RUN_RETENTION_DAYS=90 MINIO_ROOT_USER=minio MINIO_ROOT_PASSWORD=minio123 diff --git a/backend/apps/accounts/menus.py b/backend/apps/accounts/menus.py index 1c09701..dda8e22 100644 --- a/backend/apps/accounts/menus.py +++ b/backend/apps/accounts/menus.py @@ -56,17 +56,33 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = ( "platform", 20, "agent.manage", - is_enabled=False, ), MenuSpec( "platform.models", - "模型与工具", - "#/models", + "模型渠道", + "#/modelhub/providers", "settings", "platform", 30, "model.manage", - is_enabled=False, + ), + MenuSpec( + "platform.skills", + "Skill 配置", + "#/skills", + "settings", + "platform", + 40, + "skill.manage", + ), + MenuSpec( + "platform.agent_runs", + "运行记录", + "#/agent-runs", + "audit", + "platform", + 50, + "agent.run.view", ), MenuSpec("system", "系统治理", icon="shield", sort_order=40), MenuSpec( diff --git a/backend/apps/accounts/permissions.py b/backend/apps/accounts/permissions.py index 4fbcc75..e5e8c52 100644 --- a/backend/apps/accounts/permissions.py +++ b/backend/apps/accounts/permissions.py @@ -21,7 +21,9 @@ PERMISSIONS: tuple[PermissionSpec, ...] = ( PermissionSpec("knowledge.manage", "管理知识库", "知识库"), PermissionSpec("agent.use", "使用 Agent", "AI 平台"), PermissionSpec("agent.manage", "管理 Agent", "AI 平台"), + PermissionSpec("agent.run.view", "查看 Agent 运行", "AI 平台"), PermissionSpec("model.manage", "管理模型配置", "AI 平台"), + PermissionSpec("skill.manage", "管理 Skill", "AI 平台"), PermissionSpec("tool.manage", "管理 MCP 工具", "AI 平台"), PermissionSpec("system.user.view", "查看用户", "系统治理"), PermissionSpec("system.user.manage", "管理用户", "系统治理"), diff --git a/backend/apps/modelhub/__init__.py b/backend/apps/modelhub/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/modelhub/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/modelhub/api.py b/backend/apps/modelhub/api.py new file mode 100644 index 0000000..c9f63c1 --- /dev/null +++ b/backend/apps/modelhub/api.py @@ -0,0 +1,186 @@ +from django.db import IntegrityError +from ninja import Router +from ninja.errors import HttpError + +from apps.accounts.api import require_permission +from apps.audit.models import OperationAuditLog +from apps.modelhub.crypto import CredentialEncryptionError, encrypt_secret, mask_secret +from apps.modelhub.models import ModelDeployment, ModelInvocation, ModelProvider +from apps.modelhub.schemas import ( + ModelDeploymentIn, + ModelDeploymentOut, + ModelDeploymentUpdateIn, + ModelInvocationOut, + ModelProviderIn, + ModelProviderOut, + ModelProviderUpdateIn, + ModelTestIn, +) +from apps.modelhub.services import create_model_test +from apps.modelhub.tasks import run_model_test + +router = Router(tags=["modelhub"]) + + +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), + "created_at": provider.created_at, + "updated_at": provider.updated_at, + } + + +def serialize_deployment(deployment: ModelDeployment) -> dict: + 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, + "enabled": deployment.enabled, + } + + +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", + ) + + +@router.get("/providers", response=list[ModelProviderOut]) +def list_providers(request): + require_permission(request, "model.manage") + return [serialize_provider(provider) for provider in ModelProvider.objects.all()] + + +@router.post("/providers", response=ModelProviderOut) +def create_provider(request, payload: ModelProviderIn): + require_permission(request, "model.manage") + if not 1 <= payload.default_timeout_seconds <= 300: + raise HttpError(400, "Timeout must be between 1 and 300 seconds") + try: + encrypted = encrypt_secret(payload.api_key) + provider = ModelProvider.objects.create( + name=payload.name, + base_url=payload.base_url.rstrip("/"), + api_key_encrypted=encrypted, + enabled=payload.enabled, + default_timeout_seconds=payload.default_timeout_seconds, + created_by=request.user, + ) + except CredentialEncryptionError as error: + raise HttpError(400, str(error)) from error + except IntegrityError as error: + raise HttpError(400, "Provider name already exists") 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.get(id=provider_id) + values = payload.dict(exclude_unset=True) + api_key = values.pop("api_key", None) + if api_key: + try: + provider.api_key_encrypted = encrypt_secret(api_key) + except CredentialEncryptionError as error: + raise HttpError(400, str(error)) from error + if "default_timeout_seconds" in values and not 1 <= values["default_timeout_seconds"] <= 300: + raise HttpError(400, "Timeout must be between 1 and 300 seconds") + for field, value in values.items(): + setattr(provider, field, value.rstrip("/") if field == "base_url" else value) + provider.save() + audit( + request.user, + "update", + "model_provider", + str(provider.id), + {"fields": sorted(values), "credential_replaced": bool(api_key)}, + ) + return serialize_provider(provider) + + +@router.get("/deployments", response=list[ModelDeploymentOut]) +def list_deployments(request, provider_id: int | None = None): + require_permission(request, "model.manage") + queryset = ModelDeployment.objects.select_related("provider") + if provider_id: + queryset = queryset.filter(provider_id=provider_id) + return [serialize_deployment(item) for item in queryset] + + +@router.post("/deployments", response=ModelDeploymentOut) +def create_deployment(request, payload: ModelDeploymentIn): + require_permission(request, "model.manage") + if not 0 <= payload.default_temperature <= 2: + raise HttpError(400, "Temperature must be between 0 and 2") + if not 1 <= payload.max_output_tokens <= 32768: + raise HttpError(400, "Max output tokens are out of range") + deployment = ModelDeployment.objects.create(**payload.dict()) + 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").get(id=deployment_id) + values = payload.dict(exclude_unset=True) + for field, value in values.items(): + setattr(deployment, field, value) + deployment.save() + audit( + request.user, + "update", + "model_deployment", + str(deployment.id), + {"fields": sorted(values)}, + ) + return serialize_deployment(deployment) + + +@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) + 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 invocation + + +@router.get("/model-tests/{public_id}", response=ModelInvocationOut) +def get_model_test(request, public_id: str): + require_permission(request, "model.manage") + return ModelInvocation.objects.get(public_id=public_id, invocation_type="model_test") diff --git a/backend/apps/modelhub/apps.py b/backend/apps/modelhub/apps.py new file mode 100644 index 0000000..0014cc8 --- /dev/null +++ b/backend/apps/modelhub/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModelHubConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.modelhub" diff --git a/backend/apps/modelhub/crypto.py b/backend/apps/modelhub/crypto.py new file mode 100644 index 0000000..9e56d4b --- /dev/null +++ b/backend/apps/modelhub/crypto.py @@ -0,0 +1,40 @@ +from cryptography.fernet import Fernet, InvalidToken +from django.conf import settings + + +class CredentialEncryptionError(ValueError): + pass + + +def _fernet() -> Fernet: + key = settings.AI_CREDENTIAL_ENCRYPTION_KEY + if not key: + raise CredentialEncryptionError("AI credential encryption key is not configured") + try: + return Fernet(key.encode("utf-8")) + except (TypeError, ValueError) as error: + raise CredentialEncryptionError("AI credential encryption key is invalid") from error + + +def encrypt_secret(value: str) -> str: + if not value: + raise CredentialEncryptionError("API key is required") + return _fernet().encrypt(value.encode("utf-8")).decode("utf-8") + + +def decrypt_secret(value: str) -> str: + if not value: + raise CredentialEncryptionError("API key is not configured") + try: + return _fernet().decrypt(value.encode("utf-8")).decode("utf-8") + except InvalidToken as error: + raise CredentialEncryptionError("Stored API key cannot be decrypted") from error + + +def mask_secret(value: str) -> str: + try: + plain = decrypt_secret(value) + except CredentialEncryptionError: + return "已配置" if value else "未配置" + suffix = plain[-4:] if len(plain) >= 4 else plain + return f"****{suffix}" diff --git a/backend/apps/modelhub/migrations/0001_initial.py b/backend/apps/modelhub/migrations/0001_initial.py new file mode 100644 index 0000000..9ee1c08 --- /dev/null +++ b/backend/apps/modelhub/migrations/0001_initial.py @@ -0,0 +1,143 @@ +# Generated by Django 5.2.15 on 2026-07-14 05:44 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ModelProvider", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=120, unique=True)), + ("base_url", models.URLField(max_length=500)), + ("api_key_encrypted", models.TextField(blank=True)), + ("enabled", models.BooleanField(default=True)), + ("default_timeout_seconds", models.PositiveIntegerField(default=60)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_model_providers", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="ModelDeployment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("display_name", models.CharField(max_length=120)), + ("model_name", models.CharField(max_length=160)), + ("capability", models.CharField(default="text_chat", max_length=40)), + ("default_temperature", models.FloatField(default=0.2)), + ("max_output_tokens", models.PositiveIntegerField(default=2048)), + ("enabled", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="deployments", + to="modelhub.modelprovider", + ), + ), + ], + options={ + "ordering": ["provider__name", "display_name"], + }, + ), + migrations.CreateModel( + name="ModelInvocation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ("invocation_type", models.CharField(default="model_test", max_length=40)), + ("status", models.CharField(default="queued", max_length=24)), + ("model_snapshot", models.JSONField(blank=True, default=dict)), + ("input_snapshot", models.JSONField(blank=True, default=dict)), + ("output_text", models.TextField(blank=True)), + ("input_tokens", models.PositiveIntegerField(blank=True, null=True)), + ("output_tokens", models.PositiveIntegerField(blank=True, null=True)), + ("duration_ms", models.PositiveIntegerField(default=0)), + ("retry_count", models.PositiveIntegerField(default=0)), + ("error_code", models.CharField(blank=True, max_length=80)), + ("error_message", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="model_invocations", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deployment", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="invocations", + to="modelhub.modeldeployment", + ), + ), + ], + options={ + "ordering": ["-created_at"], + "indexes": [ + models.Index( + fields=["status", "created_at"], name="modelhub_mo_status_bc8fa5_idx" + ), + models.Index( + fields=["invocation_type", "created_at"], + name="modelhub_mo_invocat_53d7b6_idx", + ), + ], + }, + ), + migrations.AddConstraint( + model_name="modeldeployment", + constraint=models.UniqueConstraint( + fields=("provider", "model_name"), name="unique_provider_model_name" + ), + ), + ] diff --git a/backend/apps/modelhub/migrations/__init__.py b/backend/apps/modelhub/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/modelhub/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/modelhub/models.py b/backend/apps/modelhub/models.py new file mode 100644 index 0000000..5d7cc6c --- /dev/null +++ b/backend/apps/modelhub/models.py @@ -0,0 +1,88 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class ModelProvider(models.Model): + name = models.CharField(max_length=120, unique=True) + base_url = models.URLField(max_length=500) + api_key_encrypted = models.TextField(blank=True) + enabled = models.BooleanField(default=True) + default_timeout_seconds = models.PositiveIntegerField(default=60) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_model_providers", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + + +class ModelDeployment(models.Model): + provider = models.ForeignKey( + ModelProvider, + on_delete=models.PROTECT, + related_name="deployments", + ) + display_name = models.CharField(max_length=120) + model_name = models.CharField(max_length=160) + capability = models.CharField(max_length=40, default="text_chat") + default_temperature = models.FloatField(default=0.2) + max_output_tokens = models.PositiveIntegerField(default=2048) + enabled = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["provider__name", "display_name"] + constraints = [ + models.UniqueConstraint( + fields=["provider", "model_name"], + name="unique_provider_model_name", + ) + ] + + +class ModelInvocation(models.Model): + public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + deployment = models.ForeignKey( + ModelDeployment, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="invocations", + ) + invocation_type = models.CharField(max_length=40, default="model_test") + status = models.CharField(max_length=24, default="queued") + model_snapshot = models.JSONField(default=dict, blank=True) + input_snapshot = models.JSONField(default=dict, blank=True) + output_text = models.TextField(blank=True) + input_tokens = models.PositiveIntegerField(null=True, blank=True) + output_tokens = models.PositiveIntegerField(null=True, blank=True) + duration_ms = models.PositiveIntegerField(default=0) + retry_count = models.PositiveIntegerField(default=0) + error_code = models.CharField(max_length=80, blank=True) + error_message = models.TextField(blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="model_invocations", + ) + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["status", "created_at"]), + models.Index(fields=["invocation_type", "created_at"]), + ] diff --git a/backend/apps/modelhub/schemas.py b/backend/apps/modelhub/schemas.py new file mode 100644 index 0000000..b8b6d2d --- /dev/null +++ b/backend/apps/modelhub/schemas.py @@ -0,0 +1,83 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from ninja import Schema + + +class ModelProviderIn(Schema): + name: str + base_url: str + api_key: str + enabled: bool = True + default_timeout_seconds: int = 60 + + +class ModelProviderUpdateIn(Schema): + name: str | None = None + base_url: str | None = None + api_key: str | None = None + enabled: bool | None = None + default_timeout_seconds: int | None = None + + +class ModelProviderOut(Schema): + id: int + name: str + base_url: str + enabled: bool + default_timeout_seconds: int + credential_set: bool + credential_masked: str + created_at: datetime + updated_at: datetime + + +class ModelDeploymentIn(Schema): + provider_id: int + display_name: str + model_name: str + default_temperature: float = 0.2 + max_output_tokens: int = 2048 + enabled: bool = True + + +class ModelDeploymentUpdateIn(Schema): + display_name: str | None = None + model_name: str | None = None + default_temperature: float | None = None + max_output_tokens: int | None = None + enabled: bool | None = None + + +class ModelDeploymentOut(Schema): + id: int + provider_id: int + provider_name: str + display_name: str + model_name: str + capability: str + default_temperature: float + max_output_tokens: int + enabled: bool + + +class ModelTestIn(Schema): + prompt: str = "请回复:模型连接正常" + + +class ModelInvocationOut(Schema): + public_id: UUID + deployment_id: int | None + status: str + model_snapshot: dict[str, Any] + output_text: str + input_tokens: int | None + output_tokens: int | None + duration_ms: int + retry_count: int + error_code: str + error_message: str + created_at: datetime + started_at: datetime | None + finished_at: datetime | None diff --git a/backend/apps/modelhub/services.py b/backend/apps/modelhub/services.py new file mode 100644 index 0000000..4c56de4 --- /dev/null +++ b/backend/apps/modelhub/services.py @@ -0,0 +1,162 @@ +from dataclasses import dataclass +from time import monotonic, sleep + +from django.utils import timezone +from openai import ( + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + OpenAI, + RateLimitError, +) + +from apps.modelhub.crypto import CredentialEncryptionError, decrypt_secret +from apps.modelhub.models import ModelDeployment, ModelInvocation + +TRANSIENT_ERRORS = (APIConnectionError, APITimeoutError, RateLimitError) + + +@dataclass(frozen=True) +class CompletionResult: + content: str + input_tokens: int | None + output_tokens: int | None + + +class ModelGatewayError(RuntimeError): + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +def _request_completion( + deployment: ModelDeployment, + messages: list[dict[str, str]], + *, + temperature: float, + max_output_tokens: int, +) -> CompletionResult: + provider = deployment.provider + client = OpenAI( + api_key=decrypt_secret(provider.api_key_encrypted), + base_url=provider.base_url, + timeout=min(max(provider.default_timeout_seconds, 1), 300), + ) + response = client.chat.completions.create( + model=deployment.model_name, + messages=messages, + temperature=temperature, + max_tokens=max_output_tokens, + ) + content = response.choices[0].message.content or "" + usage = response.usage + return CompletionResult( + content=content, + input_tokens=getattr(usage, "prompt_tokens", None) if usage else None, + output_tokens=getattr(usage, "completion_tokens", None) if usage else None, + ) + + +def _error_details(error: Exception) -> tuple[str, str, bool]: + if isinstance(error, AuthenticationError): + return "authentication_failed", "模型渠道鉴权失败", False + if isinstance(error, BadRequestError): + return "bad_request", "模型请求参数不被渠道接受", False + if isinstance(error, RateLimitError): + return "rate_limited", "模型渠道当前限流", True + if isinstance(error, APITimeoutError): + return "timeout", "模型请求超时", True + if isinstance(error, APIConnectionError): + return "connection_failed", "无法连接模型渠道", True + if isinstance(error, APIStatusError) and error.status_code >= 500: + return "provider_unavailable", "模型渠道暂时不可用", True + if isinstance(error, CredentialEncryptionError): + return "credential_unavailable", str(error), False + return "unexpected_error", "模型调用发生未预期错误", False + + +def execute_invocation( + invocation: ModelInvocation, + messages: list[dict[str, str]], + *, + temperature: float | None = None, + max_output_tokens: int | None = None, +) -> ModelInvocation: + deployment = invocation.deployment + if deployment is None or not deployment.enabled or not deployment.provider.enabled: + invocation.status = "failed" + invocation.error_code = "deployment_unavailable" + invocation.error_message = "模型配置或渠道未启用" + invocation.finished_at = timezone.now() + invocation.save(update_fields=["status", "error_code", "error_message", "finished_at"]) + return invocation + + invocation.status = "running" + invocation.started_at = timezone.now() + invocation.model_snapshot = { + "provider_id": deployment.provider_id, + "provider_name": deployment.provider.name, + "base_url": deployment.provider.base_url, + "deployment_id": deployment.id, + "model_name": deployment.model_name, + "temperature": temperature if temperature is not None else deployment.default_temperature, + "max_output_tokens": max_output_tokens or deployment.max_output_tokens, + } + invocation.save(update_fields=["status", "started_at", "model_snapshot"]) + started = monotonic() + result: CompletionResult | None = None + last_error: Exception | None = None + for attempt in range(2): + try: + result = _request_completion( + deployment, + messages, + temperature=temperature + if temperature is not None + else deployment.default_temperature, + max_output_tokens=max_output_tokens or deployment.max_output_tokens, + ) + invocation.retry_count = attempt + break + except Exception as error: # provider libraries expose several subclasses + code, message, transient = _error_details(error) + last_error = error + invocation.retry_count = attempt + if not transient or attempt == 1: + invocation.error_code = code + invocation.error_message = message + break + sleep(1) + + invocation.duration_ms = max(int((monotonic() - started) * 1000), 0) + invocation.finished_at = timezone.now() + if result is not None: + invocation.status = "succeeded" + invocation.output_text = result.content + invocation.input_tokens = result.input_tokens + invocation.output_tokens = result.output_tokens + invocation.error_code = "" + invocation.error_message = "" + else: + invocation.status = "failed" + if not invocation.error_code: + code, message, _ = _error_details(last_error or RuntimeError()) + invocation.error_code = code + invocation.error_message = message + invocation.save() + return invocation + + +def create_model_test( + deployment: ModelDeployment, + prompt: str, + user=None, +) -> ModelInvocation: + return ModelInvocation.objects.create( + deployment=deployment, + invocation_type="model_test", + input_snapshot={"messages": [{"role": "user", "content": prompt}]}, + created_by=user, + ) diff --git a/backend/apps/modelhub/tasks.py b/backend/apps/modelhub/tasks.py new file mode 100644 index 0000000..e3131ca --- /dev/null +++ b/backend/apps/modelhub/tasks.py @@ -0,0 +1,13 @@ +from celery import shared_task + +from apps.modelhub.models import ModelInvocation +from apps.modelhub.services import execute_invocation + + +@shared_task(name="apps.modelhub.tasks.run_model_test") +def run_model_test(invocation_id: int) -> None: + invocation = ModelInvocation.objects.select_related("deployment__provider").get( + id=invocation_id + ) + messages = invocation.input_snapshot.get("messages", []) + execute_invocation(invocation, messages) diff --git a/backend/apps/modelhub/tests/__init__.py b/backend/apps/modelhub/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/modelhub/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/modelhub/tests/test_crypto.py b/backend/apps/modelhub/tests/test_crypto.py new file mode 100644 index 0000000..df94f9f --- /dev/null +++ b/backend/apps/modelhub/tests/test_crypto.py @@ -0,0 +1,23 @@ +import pytest + +from apps.modelhub.crypto import ( + CredentialEncryptionError, + decrypt_secret, + encrypt_secret, + mask_secret, +) + + +def test_encrypts_masks_and_decrypts_secret(settings): + encrypted = encrypt_secret("sk-example-1234") + + assert encrypted != "sk-example-1234" + assert decrypt_secret(encrypted) == "sk-example-1234" + assert mask_secret(encrypted) == "****1234" + + +def test_missing_encryption_key_is_rejected(settings): + settings.AI_CREDENTIAL_ENCRYPTION_KEY = "" + + with pytest.raises(CredentialEncryptionError): + encrypt_secret("secret") diff --git a/backend/apps/modelhub/tests/test_modelhub_api.py b/backend/apps/modelhub/tests/test_modelhub_api.py new file mode 100644 index 0000000..c6608bd --- /dev/null +++ b/backend/apps/modelhub/tests/test_modelhub_api.py @@ -0,0 +1,88 @@ +from django.contrib.auth import get_user_model +from django.core.management import call_command + +from apps.modelhub.crypto import decrypt_secret +from apps.modelhub.models import ModelProvider +from apps.modelhub.services import CompletionResult + + +def login_admin(client): + call_command("bootstrap_governance") + user = get_user_model().objects.get(username="admin") + client.force_login(user) + return user + + +def create_provider(client): + return client.post( + "/api/modelhub/providers", + data={ + "name": "企业模型渠道", + "base_url": "https://models.example.com/v1/", + "api_key": "sk-secret-1234", + "default_timeout_seconds": 30, + }, + content_type="application/json", + ) + + +def test_admin_manages_provider_without_exposing_secret(client, db): + login_admin(client) + + response = create_provider(client) + + assert response.status_code == 200 + assert "api_key" not in response.json() + assert response.json()["credential_masked"] == "****1234" + provider = ModelProvider.objects.get() + assert provider.api_key_encrypted != "sk-secret-1234" + assert decrypt_secret(provider.api_key_encrypted) == "sk-secret-1234" + + updated = client.patch( + f"/api/modelhub/providers/{provider.id}", + data={"name": "新渠道名称", "api_key": ""}, + content_type="application/json", + ) + assert updated.status_code == 200 + provider.refresh_from_db() + assert decrypt_secret(provider.api_key_encrypted) == "sk-secret-1234" + + +def test_model_test_runs_through_recorded_invocation(client, db, monkeypatch): + login_admin(client) + provider_id = create_provider(client).json()["id"] + deployment = client.post( + "/api/modelhub/deployments", + data={ + "provider_id": provider_id, + "display_name": "支持模型", + "model_name": "support-model", + }, + content_type="application/json", + ) + assert deployment.status_code == 200 + + monkeypatch.setattr( + "apps.modelhub.services._request_completion", + lambda *args, **kwargs: CompletionResult("连接正常", 8, 4), + ) + response = client.post( + f"/api/modelhub/deployments/{deployment.json()['id']}/test", + data={"prompt": "测试连接"}, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["status"] == "succeeded" + assert response.json()["output_text"] == "连接正常" + assert response.json()["input_tokens"] == 8 + assert response.json()["output_tokens"] == 4 + + +def test_non_ai_admin_cannot_manage_modelhub(client, db): + user = get_user_model().objects.create_user(username="requester", password="password") + client.force_login(user) + + response = client.get("/api/modelhub/providers") + + assert response.status_code == 403 diff --git a/backend/apps/modelhub/tests/test_services.py b/backend/apps/modelhub/tests/test_services.py new file mode 100644 index 0000000..757ac5e --- /dev/null +++ b/backend/apps/modelhub/tests/test_services.py @@ -0,0 +1,59 @@ +from openai import APITimeoutError + +from apps.modelhub.crypto import encrypt_secret +from apps.modelhub.models import ModelDeployment, ModelInvocation, ModelProvider +from apps.modelhub.services import CompletionResult, execute_invocation + + +def create_invocation(): + provider = ModelProvider.objects.create( + name="模型渠道", + base_url="https://models.example.com/v1", + api_key_encrypted=encrypt_secret("secret"), + ) + deployment = ModelDeployment.objects.create( + provider=provider, + display_name="支持模型", + model_name="support-model", + ) + return ModelInvocation.objects.create( + deployment=deployment, + input_snapshot={"messages": [{"role": "user", "content": "hello"}]}, + ) + + +def test_transient_error_retries_once(db, monkeypatch): + invocation = create_invocation() + calls = [] + + def request(*args, **kwargs): + calls.append(1) + if len(calls) == 1: + raise APITimeoutError(request=None) + return CompletionResult("ok", 3, 1) + + monkeypatch.setattr("apps.modelhub.services._request_completion", request) + monkeypatch.setattr("apps.modelhub.services.sleep", lambda *_: None) + + execute_invocation(invocation, invocation.input_snapshot["messages"]) + + invocation.refresh_from_db() + assert len(calls) == 2 + assert invocation.status == "succeeded" + assert invocation.retry_count == 1 + + +def test_non_transient_error_fails_without_exposing_raw_error(db, monkeypatch): + invocation = create_invocation() + + def request(*args, **kwargs): + raise ValueError("secret provider detail") + + monkeypatch.setattr("apps.modelhub.services._request_completion", request) + + execute_invocation(invocation, invocation.input_snapshot["messages"]) + + invocation.refresh_from_db() + assert invocation.status == "failed" + assert invocation.error_code == "unexpected_error" + assert "secret provider detail" not in invocation.error_message diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 8b13789..3eb91a6 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -1 +1,3 @@ +from config.celery import app as celery_app +__all__ = ("celery_app",) diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 32dcd3c..f8e8de8 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -21,6 +21,7 @@ INSTALLED_APPS = [ "apps.tickets", "apps.solutions", "apps.knowledge", + "apps.modelhub", ] MIDDLEWARE = [ @@ -90,3 +91,13 @@ DJANGO_SUPERUSER_EMAIL = os.getenv("DJANGO_SUPERUSER_EMAIL", "admin@example.com" CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1") + +AI_CREDENTIAL_ENCRYPTION_KEY = os.getenv("AI_CREDENTIAL_ENCRYPTION_KEY", "") +AI_RUN_RETENTION_DAYS = int(os.getenv("AI_RUN_RETENTION_DAYS", "90")) + +CELERY_BEAT_SCHEDULE = { + "cleanup-expired-agent-runs": { + "task": "apps.agents.tasks.cleanup_expired_agent_runs", + "schedule": 86400.0, + } +} diff --git a/backend/config/settings/test.py b/backend/config/settings/test.py index c680009..c8bc914 100644 --- a/backend/config/settings/test.py +++ b/backend/config/settings/test.py @@ -10,3 +10,7 @@ DATABASES = { PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher", ] + +AI_CREDENTIAL_ENCRYPTION_KEY = "t8XuQv9e8WDoFv7RXT4-zdw1hQChGFKtr7E6ccRZAzU=" +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True diff --git a/backend/config/urls.py b/backend/config/urls.py index 47208cd..ee5aa8a 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -5,6 +5,7 @@ from ninja import NinjaAPI from apps.accounts.api import router as accounts_router from apps.audit.api import router as audit_router from apps.knowledge.api import router as knowledge_router +from apps.modelhub.api import router as modelhub_router from apps.solutions.api import router as solutions_router from apps.system.api import router as system_router from apps.tickets.api import router as tickets_router @@ -16,6 +17,7 @@ api.add_router("/audit", audit_router) api.add_router("/tickets", tickets_router) api.add_router("/solutions", solutions_router) api.add_router("/knowledge", knowledge_router) +api.add_router("/modelhub", modelhub_router) urlpatterns = [ path("admin/", admin.site.urls), diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3bde943..750a006 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,9 @@ dependencies = [ "django-cors-headers>=4.4,<5.0", "pydantic>=2.8,<3.0", "python-dotenv>=1.0,<2.0", + "openai>=1.50,<3.0", + "cryptography>=43.0,<47.0", + "jsonschema>=4.23,<5.0", ] [build-system]