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

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

View File

@@ -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", "管理用户", "系统治理"),

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.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,
}
}

View File

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

View File

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

View File

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