feat: add openai-compatible model gateway
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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", "管理用户", "系统治理"),
|
||||
|
||||
1
backend/apps/modelhub/__init__.py
Normal file
1
backend/apps/modelhub/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
186
backend/apps/modelhub/api.py
Normal file
186
backend/apps/modelhub/api.py
Normal 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")
|
||||
6
backend/apps/modelhub/apps.py
Normal file
6
backend/apps/modelhub/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ModelHubConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.modelhub"
|
||||
40
backend/apps/modelhub/crypto.py
Normal file
40
backend/apps/modelhub/crypto.py
Normal 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}"
|
||||
143
backend/apps/modelhub/migrations/0001_initial.py
Normal file
143
backend/apps/modelhub/migrations/0001_initial.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
1
backend/apps/modelhub/migrations/__init__.py
Normal file
1
backend/apps/modelhub/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
88
backend/apps/modelhub/models.py
Normal file
88
backend/apps/modelhub/models.py
Normal 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"]),
|
||||
]
|
||||
83
backend/apps/modelhub/schemas.py
Normal file
83
backend/apps/modelhub/schemas.py
Normal 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
|
||||
162
backend/apps/modelhub/services.py
Normal file
162
backend/apps/modelhub/services.py
Normal 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,
|
||||
)
|
||||
13
backend/apps/modelhub/tasks.py
Normal file
13
backend/apps/modelhub/tasks.py
Normal 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)
|
||||
1
backend/apps/modelhub/tests/__init__.py
Normal file
1
backend/apps/modelhub/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
23
backend/apps/modelhub/tests/test_crypto.py
Normal file
23
backend/apps/modelhub/tests/test_crypto.py
Normal 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")
|
||||
88
backend/apps/modelhub/tests/test_modelhub_api.py
Normal file
88
backend/apps/modelhub/tests/test_modelhub_api.py
Normal 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
|
||||
59
backend/apps/modelhub/tests/test_services.py
Normal file
59
backend/apps/modelhub/tests/test_services.py
Normal 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
|
||||
Reference in New Issue
Block a user