feat: add openai-compatible model gateway
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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", "管理用户", "系统治理"),
|
||||||
|
|||||||
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
|
||||||
@@ -1 +1,3 @@
|
|||||||
|
from config.celery import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ("celery_app",)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user