feat: add openai-compatible model gateway

This commit is contained in:
hailin
2026-07-14 13:44:55 +08:00
parent 13b0768dfd
commit 11bab3e98f
22 changed files with 940 additions and 4 deletions

View File

@@ -15,6 +15,8 @@ POSTGRES_PORT=5432
CELERY_BROKER_URL=redis://redis:6379/0 CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/1 CELERY_RESULT_BACKEND=redis://redis:6379/1
AI_CREDENTIAL_ENCRYPTION_KEY=t8XuQv9e8WDoFv7RXT4-zdw1hQChGFKtr7E6ccRZAzU=
AI_RUN_RETENTION_DAYS=90
MINIO_ROOT_USER=minio MINIO_ROOT_USER=minio
MINIO_ROOT_PASSWORD=minio123 MINIO_ROOT_PASSWORD=minio123

View File

@@ -56,17 +56,33 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = (
"platform", "platform",
20, 20,
"agent.manage", "agent.manage",
is_enabled=False,
), ),
MenuSpec( MenuSpec(
"platform.models", "platform.models",
"模型与工具", "模型渠道",
"#/models", "#/modelhub/providers",
"settings", "settings",
"platform", "platform",
30, 30,
"model.manage", "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("system", "系统治理", icon="shield", sort_order=40),
MenuSpec( MenuSpec(

View File

@@ -21,7 +21,9 @@ PERMISSIONS: tuple[PermissionSpec, ...] = (
PermissionSpec("knowledge.manage", "管理知识库", "知识库"), PermissionSpec("knowledge.manage", "管理知识库", "知识库"),
PermissionSpec("agent.use", "使用 Agent", "AI 平台"), PermissionSpec("agent.use", "使用 Agent", "AI 平台"),
PermissionSpec("agent.manage", "管理 Agent", "AI 平台"), PermissionSpec("agent.manage", "管理 Agent", "AI 平台"),
PermissionSpec("agent.run.view", "查看 Agent 运行", "AI 平台"),
PermissionSpec("model.manage", "管理模型配置", "AI 平台"), PermissionSpec("model.manage", "管理模型配置", "AI 平台"),
PermissionSpec("skill.manage", "管理 Skill", "AI 平台"),
PermissionSpec("tool.manage", "管理 MCP 工具", "AI 平台"), PermissionSpec("tool.manage", "管理 MCP 工具", "AI 平台"),
PermissionSpec("system.user.view", "查看用户", "系统治理"), PermissionSpec("system.user.view", "查看用户", "系统治理"),
PermissionSpec("system.user.manage", "管理用户", "系统治理"), PermissionSpec("system.user.manage", "管理用户", "系统治理"),

View File

@@ -0,0 +1 @@

View File

@@ -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")

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ModelHubConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.modelhub"

View File

@@ -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}"

View File

@@ -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"
),
),
]

View File

@@ -0,0 +1 @@

View File

@@ -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"]),
]

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -0,0 +1 @@

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -1 +1,3 @@
from config.celery import app as celery_app
__all__ = ("celery_app",)

View File

@@ -21,6 +21,7 @@ INSTALLED_APPS = [
"apps.tickets", "apps.tickets",
"apps.solutions", "apps.solutions",
"apps.knowledge", "apps.knowledge",
"apps.modelhub",
] ]
MIDDLEWARE = [ 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_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1") 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,
}
}

View File

@@ -10,3 +10,7 @@ DATABASES = {
PASSWORD_HASHERS = [ PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher", "django.contrib.auth.hashers.MD5PasswordHasher",
] ]
AI_CREDENTIAL_ENCRYPTION_KEY = "t8XuQv9e8WDoFv7RXT4-zdw1hQChGFKtr7E6ccRZAzU="
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True

View File

@@ -5,6 +5,7 @@ from ninja import NinjaAPI
from apps.accounts.api import router as accounts_router from apps.accounts.api import router as accounts_router
from apps.audit.api import router as audit_router from apps.audit.api import router as audit_router
from apps.knowledge.api import router as knowledge_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.solutions.api import router as solutions_router
from apps.system.api import router as system_router from apps.system.api import router as system_router
from apps.tickets.api import router as tickets_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("/tickets", tickets_router)
api.add_router("/solutions", solutions_router) api.add_router("/solutions", solutions_router)
api.add_router("/knowledge", knowledge_router) api.add_router("/knowledge", knowledge_router)
api.add_router("/modelhub", modelhub_router)
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),

View File

@@ -12,6 +12,9 @@ dependencies = [
"django-cors-headers>=4.4,<5.0", "django-cors-headers>=4.4,<5.0",
"pydantic>=2.8,<3.0", "pydantic>=2.8,<3.0",
"python-dotenv>=1.0,<2.0", "python-dotenv>=1.0,<2.0",
"openai>=1.50,<3.0",
"cryptography>=43.0,<47.0",
"jsonschema>=4.23,<5.0",
] ]
[build-system] [build-system]