feat: add trusted ticket intake backend

This commit is contained in:
hailin
2026-07-16 19:37:32 +08:00
parent b592dc219d
commit bcd6de4609
28 changed files with 1966 additions and 42 deletions

View File

@@ -1,4 +1,5 @@
from django.db import IntegrityError
from django.utils import timezone
from ninja import Router
from ninja.errors import HttpError
@@ -31,6 +32,14 @@ def serialize_provider(provider: ModelProvider) -> dict:
"default_timeout_seconds": provider.default_timeout_seconds,
"credential_set": bool(provider.api_key_encrypted),
"credential_masked": mask_secret(provider.api_key_encrypted),
"image_egress_approved": provider.image_egress_approved,
"image_egress_approved_by": (
provider.image_egress_approved_by.get_full_name()
or provider.image_egress_approved_by.username
if provider.image_egress_approved_by_id
else None
),
"image_egress_approved_at": provider.image_egress_approved_at,
"created_at": provider.created_at,
"updated_at": provider.updated_at,
}
@@ -65,7 +74,10 @@ def audit(user, action: str, target_type: str, target_id: str, snapshot: dict) -
@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()]
return [
serialize_provider(provider)
for provider in ModelProvider.objects.select_related("image_egress_approved_by")
]
@router.post("/providers", response=ModelProviderOut)
@@ -81,6 +93,9 @@ def create_provider(request, payload: ModelProviderIn):
api_key_encrypted=encrypted,
enabled=payload.enabled,
default_timeout_seconds=payload.default_timeout_seconds,
image_egress_approved=payload.image_egress_approved,
image_egress_approved_by=request.user if payload.image_egress_approved else None,
image_egress_approved_at=timezone.now() if payload.image_egress_approved else None,
created_by=request.user,
)
except CredentialEncryptionError as error:
@@ -97,6 +112,7 @@ def update_provider(request, provider_id: int, payload: ModelProviderUpdateIn):
provider = ModelProvider.objects.get(id=provider_id)
values = payload.dict(exclude_unset=True)
api_key = values.pop("api_key", None)
image_egress_approved = values.pop("image_egress_approved", None)
if api_key:
try:
provider.api_key_encrypted = encrypt_secret(api_key)
@@ -106,13 +122,22 @@ def update_provider(request, provider_id: int, payload: ModelProviderUpdateIn):
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)
if image_egress_approved is not None:
provider.image_egress_approved = image_egress_approved
provider.image_egress_approved_by = request.user if image_egress_approved else None
provider.image_egress_approved_at = timezone.now() if image_egress_approved else None
provider.save()
audit(
request.user,
"update",
"model_provider",
str(provider.id),
{"fields": sorted(values), "credential_replaced": bool(api_key)},
{
"fields": sorted(
[*values, *(["image_egress_approved"] if image_egress_approved is not None else [])]
),
"credential_replaced": bool(api_key),
},
)
return serialize_provider(provider)
@@ -133,6 +158,8 @@ def create_deployment(request, payload: ModelDeploymentIn):
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")
if payload.capability not in {"text_chat", "vision_chat"}:
raise HttpError(400, "Unknown model capability")
deployment = ModelDeployment.objects.create(**payload.dict())
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment.id)
audit(
@@ -150,6 +177,8 @@ def update_deployment(request, deployment_id: int, payload: ModelDeploymentUpdat
require_permission(request, "model.manage")
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id)
values = payload.dict(exclude_unset=True)
if "capability" in values and values["capability"] not in {"text_chat", "vision_chat"}:
raise HttpError(400, "Unknown model capability")
for field, value in values.items():
setattr(deployment, field, value)
deployment.save()

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.15 on 2026-07-16 11:13
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("modelhub", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="modelprovider",
name="image_egress_approved",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="modelprovider",
name="image_egress_approved_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="modelprovider",
name="image_egress_approved_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="approved_image_model_providers",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -10,6 +10,15 @@ class ModelProvider(models.Model):
api_key_encrypted = models.TextField(blank=True)
enabled = models.BooleanField(default=True)
default_timeout_seconds = models.PositiveIntegerField(default=60)
image_egress_approved = models.BooleanField(default=False)
image_egress_approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="approved_image_model_providers",
)
image_egress_approved_at = models.DateTimeField(null=True, blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,

View File

@@ -11,6 +11,7 @@ class ModelProviderIn(Schema):
api_key: str
enabled: bool = True
default_timeout_seconds: int = 60
image_egress_approved: bool = False
class ModelProviderUpdateIn(Schema):
@@ -19,6 +20,7 @@ class ModelProviderUpdateIn(Schema):
api_key: str | None = None
enabled: bool | None = None
default_timeout_seconds: int | None = None
image_egress_approved: bool | None = None
class ModelProviderOut(Schema):
@@ -29,6 +31,9 @@ class ModelProviderOut(Schema):
default_timeout_seconds: int
credential_set: bool
credential_masked: str
image_egress_approved: bool
image_egress_approved_by: str | None
image_egress_approved_at: datetime | None
created_at: datetime
updated_at: datetime
@@ -37,6 +42,7 @@ class ModelDeploymentIn(Schema):
provider_id: int
display_name: str
model_name: str
capability: str = "text_chat"
default_temperature: float = 0.2
max_output_tokens: int = 2048
enabled: bool = True
@@ -45,6 +51,7 @@ class ModelDeploymentIn(Schema):
class ModelDeploymentUpdateIn(Schema):
display_name: str | None = None
model_name: str | None = None
capability: str | None = None
default_temperature: float | None = None
max_output_tokens: int | None = None
enabled: bool | None = None

View File

@@ -1,5 +1,6 @@
from dataclasses import dataclass
from time import monotonic, sleep
from typing import Any
from django.utils import timezone
from openai import (
@@ -33,7 +34,7 @@ class ModelGatewayError(RuntimeError):
def _request_completion(
deployment: ModelDeployment,
messages: list[dict[str, str]],
messages: list[dict[str, Any]],
*,
temperature: float,
max_output_tokens: int,
@@ -79,7 +80,7 @@ def _error_details(error: Exception) -> tuple[str, str, bool]:
def execute_invocation(
invocation: ModelInvocation,
messages: list[dict[str, str]],
messages: list[dict[str, Any]],
*,
temperature: float | None = None,
max_output_tokens: int | None = None,
@@ -101,6 +102,7 @@ def execute_invocation(
"base_url": deployment.provider.base_url,
"deployment_id": deployment.id,
"model_name": deployment.model_name,
"capability": deployment.capability,
"temperature": temperature if temperature is not None else deployment.default_temperature,
"max_output_tokens": max_output_tokens or deployment.max_output_tokens,
}