Add Themis application with custom widgets, views, and utilities

- Implemented custom form widgets for date, time, and datetime fields with DaisyUI styling.
- Created utility functions for formatting dates, times, and numbers according to user preferences.
- Developed views for profile settings, API key management, and notifications, including health check endpoints.
- Added URL configurations for Themis tests and main application routes.
- Established test cases for custom widgets to ensure proper functionality and integration.
- Defined project metadata and dependencies in pyproject.toml for package management.
This commit is contained in:
2026-03-21 02:00:18 +00:00
parent e99346d014
commit 99bdb4ac92
351 changed files with 65123 additions and 2 deletions

View File

@@ -0,0 +1 @@
default_app_config = "llm_manager.apps.LLMManagerConfig"

View File

@@ -0,0 +1,326 @@
"""
Admin configuration for LLM Manager — ported from Spelunker.
Adds system model actions for embedding, chat, and reranker models.
"""
from django.contrib import admin, messages
from django.db import transaction
from django.utils.html import format_html
from .models import LLMApi, LLMModel, LLMUsage
from .services import test_llm_api
@admin.register(LLMApi)
class LLMApiAdmin(admin.ModelAdmin):
list_display = (
"name",
"api_type",
"base_url",
"is_active",
"last_test_status",
"last_tested_at",
"supports_streaming",
"timeout_seconds",
"created_at",
)
list_filter = ("api_type", "is_active", "last_test_status", "supports_streaming")
search_fields = ("name", "base_url")
readonly_fields = (
"created_at",
"updated_at",
"last_tested_at",
"last_test_status",
"last_test_message",
)
actions = ["test_api_connection"]
fieldsets = (
("API Info", {"fields": ("name", "api_type", "base_url", "is_active")}),
("Security", {"fields": ("api_key",)}),
(
"Advanced",
{"fields": ("supports_streaming", "timeout_seconds", "max_retries", "created_by")},
),
(
"Test Status",
{"fields": ("last_tested_at", "last_test_status", "last_test_message")},
),
("Timestamps", {"fields": ("created_at", "updated_at")}),
)
def test_api_connection(self, request, queryset):
"""Test selected LLM API(s) and discover models."""
success_count = 0
failed_count = 0
total_added = 0
total_updated = 0
total_deactivated = 0
for api in queryset:
result = test_llm_api(api)
if result["success"]:
success_count += 1
total_added += result["models_added"]
total_updated += result["models_updated"]
total_deactivated += result["models_deactivated"]
self.message_user(request, f"{api.name}: {result['message']}", messages.SUCCESS)
else:
failed_count += 1
self.message_user(request, f"{api.name}: {result['error']}", messages.ERROR)
if success_count > 0:
summary = (
f"Tested {success_count} API(s). "
f"Total: {total_added} added, {total_updated} updated, "
f"{total_deactivated} deactivated."
)
self.message_user(request, summary, messages.SUCCESS)
if failed_count > 0:
self.message_user(
request,
f"Failed to test {failed_count} API(s). Check logs.",
messages.WARNING,
)
test_api_connection.short_description = "Test API Connection and Discover Models"
@admin.register(LLMModel)
class LLMModelAdmin(admin.ModelAdmin):
list_display = (
"name",
"api",
"model_type",
"vector_dimensions_display",
"context_window",
"input_cost_per_1k",
"system_embedding_badge",
"system_chat_badge",
"system_reranker_badge",
"is_active",
"created_at",
)
list_filter = (
"api",
"model_type",
"supports_cache",
"supports_vision",
"supports_multimodal",
"is_active",
"is_system_embedding_model",
"is_system_chat_model",
"is_system_reranker_model",
)
search_fields = ("name", "display_name", "api__name")
readonly_fields = (
"created_at",
"updated_at",
"is_system_embedding_model",
"is_system_chat_model",
"is_system_reranker_model",
)
actions = [
"set_as_system_embedding_model",
"set_as_system_chat_model",
"set_as_system_reranker_model",
]
fieldsets = (
("Model Info", {"fields": ("api", "name", "display_name", "model_type", "is_active")}),
(
"System Defaults",
{
"fields": (
"is_system_embedding_model",
"is_system_chat_model",
"is_system_reranker_model",
),
"classes": ("collapse",),
"description": (
"System default models are set via admin actions. "
"Only one model per type can be system default."
),
},
),
(
"Capabilities",
{
"fields": (
"context_window",
"max_output_tokens",
"vector_dimensions",
"supports_cache",
"supports_vision",
"supports_multimodal",
"supports_function_calling",
"supports_json_mode",
),
},
),
(
"Pricing",
{"fields": ("input_cost_per_1k", "output_cost_per_1k", "cached_cost_per_1k")},
),
("Timestamps", {"fields": ("created_at", "updated_at")}),
)
def vector_dimensions_display(self, obj):
if obj.model_type in ("embedding", "multimodal_embed") and obj.vector_dimensions:
return format_html(
'<span style="color: #0066cc; font-weight: bold;">{}</span>',
obj.vector_dimensions,
)
elif obj.model_type in ("embedding", "multimodal_embed"):
return format_html('<span style="color: #999;">Not set</span>')
return "-"
vector_dimensions_display.short_description = "Dimensions"
def system_embedding_badge(self, obj):
if obj.is_system_embedding_model and obj.model_type in ("embedding", "multimodal_embed"):
return format_html(
'<span style="background:#28a745;color:white;padding:3px 8px;'
'border-radius:3px;font-weight:bold;">SYSTEM DEFAULT</span>'
)
return ""
system_embedding_badge.short_description = "Embed Default"
def system_chat_badge(self, obj):
if obj.is_system_chat_model and obj.model_type == "chat":
return format_html(
'<span style="background:#007bff;color:white;padding:3px 8px;'
'border-radius:3px;font-weight:bold;">SYSTEM DEFAULT</span>'
)
return ""
system_chat_badge.short_description = "Chat Default"
def system_reranker_badge(self, obj):
if obj.is_system_reranker_model and obj.model_type == "reranker":
return format_html(
'<span style="background:#fd7e14;color:white;padding:3px 8px;'
'border-radius:3px;font-weight:bold;">SYSTEM DEFAULT</span>'
)
return ""
system_reranker_badge.short_description = "Reranker Default"
# --- System model actions -----------------------------------------------
def _set_system_model(self, request, queryset, model_type, field_name, label):
"""Generic helper for set-as-system-model admin actions."""
if queryset.count() != 1:
self.message_user(
request,
f"Please select exactly ONE model to set as system {label}.",
messages.ERROR,
)
return
new_model = queryset.first()
valid_types = [model_type]
if model_type == "embedding":
valid_types = ["embedding", "multimodal_embed"]
if new_model.model_type not in valid_types:
self.message_user(
request,
f'Only {label} models can be set as system {label}. '
f'"{new_model.name}" is type: {new_model.model_type}',
messages.ERROR,
)
return
if not new_model.is_active:
self.message_user(
request,
f'Cannot set inactive model "{new_model.name}" as system {label}.',
messages.ERROR,
)
return
with transaction.atomic():
LLMModel.objects.filter(**{field_name: True}).update(**{field_name: False})
setattr(new_model, field_name, True)
new_model.save(update_fields=[field_name])
self.message_user(
request,
f"{new_model.api.name}: {new_model.name} is now the system {label}.",
messages.SUCCESS,
)
def set_as_system_embedding_model(self, request, queryset):
self._set_system_model(request, queryset, "embedding", "is_system_embedding_model", "embedding model")
set_as_system_embedding_model.short_description = "Set as System Embedding Model"
def set_as_system_chat_model(self, request, queryset):
self._set_system_model(request, queryset, "chat", "is_system_chat_model", "chat model")
set_as_system_chat_model.short_description = "Set as System Chat Model"
def set_as_system_reranker_model(self, request, queryset):
self._set_system_model(request, queryset, "reranker", "is_system_reranker_model", "reranker model")
set_as_system_reranker_model.short_description = "Set as System Reranker Model"
def save_model(self, request, obj, form, change):
"""Ensure only ONE model per type is marked as system default."""
type_field_map = {
"embedding": "is_system_embedding_model",
"multimodal_embed": "is_system_embedding_model",
"chat": "is_system_chat_model",
"reranker": "is_system_reranker_model",
}
for mtype, field in type_field_map.items():
if getattr(obj, field, False) and obj.model_type == mtype:
LLMModel.objects.filter(**{field: True}).exclude(pk=obj.pk).update(**{field: False})
self.message_user(
request,
f"{obj.name} is now the system-wide {mtype} model.",
messages.SUCCESS,
)
elif getattr(obj, field, False) and obj.model_type != mtype:
setattr(obj, field, False)
super().save_model(request, obj, form, change)
@admin.register(LLMUsage)
class LLMUsageAdmin(admin.ModelAdmin):
list_display = (
"timestamp",
"user",
"model",
"input_tokens",
"output_tokens",
"cached_tokens",
"total_cost",
"session_id",
"purpose",
)
list_filter = ("model", "purpose", "timestamp")
search_fields = ("user__username", "session_id", "model__name")
readonly_fields = (
"user",
"model",
"timestamp",
"input_tokens",
"output_tokens",
"cached_tokens",
"total_cost",
"session_id",
"purpose",
"request_metadata",
)
date_hierarchy = "timestamp"
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False

View File

View File

@@ -0,0 +1,105 @@
"""
DRF serializers for LLM Manager.
"""
from rest_framework import serializers
from ..models import LLMApi, LLMModel, LLMUsage
class LLMApiSerializer(serializers.ModelSerializer):
model_count = serializers.SerializerMethodField()
class Meta:
model = LLMApi
fields = [
"id",
"name",
"api_type",
"base_url",
"is_active",
"supports_streaming",
"timeout_seconds",
"max_retries",
"last_tested_at",
"last_test_status",
"model_count",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"last_tested_at",
"last_test_status",
"created_at",
"updated_at",
]
def get_model_count(self, obj):
return obj.models.filter(is_active=True).count()
class LLMModelSerializer(serializers.ModelSerializer):
api_name = serializers.CharField(source="api.name", read_only=True)
class Meta:
model = LLMModel
fields = [
"id",
"api",
"api_name",
"name",
"display_name",
"model_type",
"context_window",
"max_output_tokens",
"vector_dimensions",
"supports_cache",
"supports_vision",
"supports_multimodal",
"supports_function_calling",
"supports_json_mode",
"input_cost_per_1k",
"output_cost_per_1k",
"cached_cost_per_1k",
"is_active",
"is_system_embedding_model",
"is_system_chat_model",
"is_system_reranker_model",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"is_system_embedding_model",
"is_system_chat_model",
"is_system_reranker_model",
"created_at",
"updated_at",
]
class LLMUsageSerializer(serializers.ModelSerializer):
model_name = serializers.CharField(source="model.name", read_only=True)
api_name = serializers.CharField(source="model.api.name", read_only=True)
username = serializers.CharField(source="user.username", read_only=True)
class Meta:
model = LLMUsage
fields = [
"id",
"user",
"username",
"model",
"model_name",
"api_name",
"timestamp",
"input_tokens",
"output_tokens",
"cached_tokens",
"total_cost",
"session_id",
"purpose",
"request_metadata",
]
read_only_fields = ["id", "timestamp", "total_cost"]

View File

@@ -0,0 +1,18 @@
"""
DRF API URL patterns for LLM Manager.
"""
from django.urls import path
from . import views
app_name = "llm-manager-api"
urlpatterns = [
path("apis/", views.api_list, name="api_list"),
path("apis/<uuid:pk>/", views.api_detail, name="api_detail"),
path("models/", views.model_list, name="model_list"),
path("models/<uuid:pk>/", views.model_detail, name="model_detail"),
path("models/system/", views.system_models, name="system_models"),
path("usage/", views.usage_list, name="usage_list"),
]

View File

@@ -0,0 +1,100 @@
"""
DRF API views for LLM Manager — FBVs per Red Panda Standards.
"""
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..models import LLMApi, LLMModel, LLMUsage
from .serializers import LLMApiSerializer, LLMModelSerializer, LLMUsageSerializer
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def api_list(request):
"""List all LLM APIs."""
apis = LLMApi.objects.all().order_by("name")
serializer = LLMApiSerializer(apis, many=True)
return Response(serializer.data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def api_detail(request, pk):
"""Get a specific LLM API."""
try:
api = LLMApi.objects.get(pk=pk)
except LLMApi.DoesNotExist:
return Response({"error": "Not found"}, status=status.HTTP_404_NOT_FOUND)
serializer = LLMApiSerializer(api)
return Response(serializer.data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def model_list(request):
"""List all LLM Models, optionally filtered by API or type."""
qs = LLMModel.objects.select_related("api").order_by("api__name", "name")
api_id = request.query_params.get("api")
model_type = request.query_params.get("type")
active_only = request.query_params.get("active", "").lower() in ("1", "true")
if api_id:
qs = qs.filter(api_id=api_id)
if model_type:
qs = qs.filter(model_type=model_type)
if active_only:
qs = qs.filter(is_active=True)
serializer = LLMModelSerializer(qs, many=True)
return Response(serializer.data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def model_detail(request, pk):
"""Get a specific LLM Model."""
try:
model = LLMModel.objects.select_related("api").get(pk=pk)
except LLMModel.DoesNotExist:
return Response({"error": "Not found"}, status=status.HTTP_404_NOT_FOUND)
serializer = LLMModelSerializer(model)
return Response(serializer.data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def system_models(request):
"""Get the current system default models."""
data = {}
embed = LLMModel.get_system_embedding_model()
chat = LLMModel.get_system_chat_model()
reranker = LLMModel.get_system_reranker_model()
if embed:
data["embedding"] = LLMModelSerializer(embed).data
if chat:
data["chat"] = LLMModelSerializer(chat).data
if reranker:
data["reranker"] = LLMModelSerializer(reranker).data
return Response(data)
@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def usage_list(request):
"""List usage records for current user, or create a new usage record."""
if request.method == "GET":
qs = (
LLMUsage.objects.filter(user=request.user)
.select_related("model", "model__api")
.order_by("-timestamp")[:100]
)
serializer = LLMUsageSerializer(qs, many=True)
return Response(serializer.data)
# POST — create a usage record
serializer = LLMUsageSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class LLMManagerConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "llm_manager"
verbose_name = "LLM Manager"

View File

@@ -0,0 +1,65 @@
"""
Fernet encryption field for LLM API keys.
Uses LLM_API_SECRETS_ENCRYPTION_KEY from settings if available,
otherwise derives a key from Django's SECRET_KEY (Themis pattern).
"""
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from django.conf import settings
from django.db import models
def _get_fernet():
"""
Get a Fernet cipher using the configured encryption key.
Checks for LLM_API_SECRETS_ENCRYPTION_KEY first, then falls
back to deriving a key from SECRET_KEY (Themis pattern).
"""
key = getattr(settings, "LLM_API_SECRETS_ENCRYPTION_KEY", None)
if key:
return Fernet(key.encode() if isinstance(key, str) else key)
# Fallback: derive from SECRET_KEY like Themis
secret = settings.SECRET_KEY.encode("utf-8")
digest = hashlib.sha256(secret).digest()
derived_key = base64.urlsafe_b64encode(digest)
return Fernet(derived_key)
class EncryptedCharField(models.CharField):
"""
CharField that transparently encrypts/decrypts values using Fernet.
Values are encrypted before saving to the database and decrypted
when read. Supports blank/null values gracefully.
"""
description = "Encrypted CharField for storing API secrets"
def get_prep_value(self, value):
"""Encrypt before saving to DB."""
if value is None or value == "":
return value
cipher = _get_fernet()
encrypted = cipher.encrypt(value.encode("utf-8"))
return base64.b64encode(encrypted).decode("utf-8")
def from_db_value(self, value, expression, connection):
"""Decrypt when loading from DB."""
if value is None or value == "":
return value
try:
cipher = _get_fernet()
encrypted = base64.b64decode(value)
return cipher.decrypt(encrypted).decode("utf-8")
except (InvalidToken, Exception):
return value
def to_python(self, value):
if isinstance(value, str) or value is None:
return value
return str(value)

View File

@@ -0,0 +1,82 @@
"""
Forms for LLM Manager — DaisyUI-styled widgets.
"""
from django import forms
from .models import LLMApi, LLMModel
class LLMApiForm(forms.ModelForm):
class Meta:
model = LLMApi
fields = [
"name",
"api_type",
"base_url",
"api_key",
"is_active",
"supports_streaming",
"timeout_seconds",
"max_retries",
]
widgets = {
"name": forms.TextInput(attrs={"class": "input input-bordered w-full"}),
"api_type": forms.Select(attrs={"class": "select select-bordered w-full"}),
"base_url": forms.URLInput(attrs={"class": "input input-bordered w-full"}),
"api_key": forms.PasswordInput(
attrs={"class": "input input-bordered w-full", "autocomplete": "off"},
render_value=True,
),
"is_active": forms.CheckboxInput(attrs={"class": "toggle toggle-primary"}),
"supports_streaming": forms.CheckboxInput(attrs={"class": "toggle toggle-primary"}),
"timeout_seconds": forms.NumberInput(attrs={"class": "input input-bordered w-full"}),
"max_retries": forms.NumberInput(attrs={"class": "input input-bordered w-full"}),
}
class LLMModelForm(forms.ModelForm):
class Meta:
model = LLMModel
fields = [
"api",
"name",
"display_name",
"model_type",
"context_window",
"max_output_tokens",
"vector_dimensions",
"supports_cache",
"supports_vision",
"supports_multimodal",
"supports_function_calling",
"supports_json_mode",
"input_cost_per_1k",
"output_cost_per_1k",
"cached_cost_per_1k",
"is_active",
]
widgets = {
"api": forms.Select(attrs={"class": "select select-bordered w-full"}),
"name": forms.TextInput(attrs={"class": "input input-bordered w-full"}),
"display_name": forms.TextInput(attrs={"class": "input input-bordered w-full"}),
"model_type": forms.Select(attrs={"class": "select select-bordered w-full"}),
"context_window": forms.NumberInput(attrs={"class": "input input-bordered w-full"}),
"max_output_tokens": forms.NumberInput(attrs={"class": "input input-bordered w-full"}),
"vector_dimensions": forms.NumberInput(attrs={"class": "input input-bordered w-full"}),
"supports_cache": forms.CheckboxInput(attrs={"class": "toggle toggle-primary"}),
"supports_vision": forms.CheckboxInput(attrs={"class": "toggle toggle-primary"}),
"supports_multimodal": forms.CheckboxInput(attrs={"class": "toggle toggle-primary"}),
"supports_function_calling": forms.CheckboxInput(attrs={"class": "toggle toggle-primary"}),
"supports_json_mode": forms.CheckboxInput(attrs={"class": "toggle toggle-primary"}),
"input_cost_per_1k": forms.NumberInput(
attrs={"class": "input input-bordered w-full", "step": "0.000001"}
),
"output_cost_per_1k": forms.NumberInput(
attrs={"class": "input input-bordered w-full", "step": "0.000001"}
),
"cached_cost_per_1k": forms.NumberInput(
attrs={"class": "input input-bordered w-full", "step": "0.000001"}
),
"is_active": forms.CheckboxInput(attrs={"class": "toggle toggle-primary"}),
}

View File

@@ -0,0 +1,138 @@
"""
Management command to load default LLM models for common providers.
Usage:
python manage.py load_default_llm_models
python manage.py load_default_llm_models --force # update existing models
"""
from decimal import Decimal
from django.core.management.base import BaseCommand
from llm_manager.models import LLMApi, LLMModel
DEFAULT_APIS = [
{
"name": "OpenAI",
"api_type": "openai",
"base_url": "https://api.openai.com/v1",
},
]
DEFAULT_MODELS = [
# ── Chat models ────────────────────────────────────────────────────
{
"api_name": "OpenAI",
"name": "gpt-4o",
"display_name": "GPT-4o",
"model_type": "chat",
"context_window": 128000,
"max_output_tokens": 16384,
"input_cost_per_1k": Decimal("0.0025"),
"output_cost_per_1k": Decimal("0.01"),
"supports_cache": True,
"cached_cost_per_1k": Decimal("0.00125"),
"supports_vision": True,
"supports_function_calling": True,
"supports_json_mode": True,
},
{
"api_name": "OpenAI",
"name": "gpt-4o-mini",
"display_name": "GPT-4o Mini",
"model_type": "chat",
"context_window": 128000,
"max_output_tokens": 16384,
"input_cost_per_1k": Decimal("0.00015"),
"output_cost_per_1k": Decimal("0.0006"),
"supports_cache": True,
"cached_cost_per_1k": Decimal("0.000075"),
"supports_vision": True,
"supports_function_calling": True,
"supports_json_mode": True,
},
# ── Embedding models ───────────────────────────────────────────────
{
"api_name": "OpenAI",
"name": "text-embedding-3-large",
"display_name": "Text Embedding 3 Large",
"model_type": "embedding",
"context_window": 8191,
"vector_dimensions": 3072,
"input_cost_per_1k": Decimal("0.00013"),
"output_cost_per_1k": Decimal("0"),
},
{
"api_name": "OpenAI",
"name": "text-embedding-3-small",
"display_name": "Text Embedding 3 Small",
"model_type": "embedding",
"context_window": 8191,
"vector_dimensions": 1536,
"input_cost_per_1k": Decimal("0.00002"),
"output_cost_per_1k": Decimal("0"),
},
]
class Command(BaseCommand):
help = "Load default LLM APIs and models."
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="Update existing model records with defaults.",
)
def handle(self, *args, **options):
force = options["force"]
# Create default APIs
api_map = {}
for api_data in DEFAULT_APIS:
api, created = LLMApi.objects.get_or_create(
name=api_data["name"],
defaults={
"api_type": api_data["api_type"],
"base_url": api_data["base_url"],
"is_active": True,
},
)
api_map[api_data["name"]] = api
if created:
self.stdout.write(self.style.SUCCESS(f"Created API: {api.name}"))
else:
self.stdout.write(self.style.WARNING(f"API already exists: {api.name}"))
# Create default models
for model_data in DEFAULT_MODELS:
api_name = model_data.pop("api_name")
api = api_map.get(api_name)
if not api:
self.stdout.write(self.style.ERROR(f"API '{api_name}' not found, skipping model."))
model_data["api_name"] = api_name # restore
continue
defaults = {k: v for k, v in model_data.items() if k != "name"}
model, created = LLMModel.objects.get_or_create(
api=api,
name=model_data["name"],
defaults=defaults,
)
if created:
self.stdout.write(self.style.SUCCESS(f" Created model: {model.name}"))
elif force:
for key, val in defaults.items():
setattr(model, key, val)
model.save()
self.stdout.write(self.style.SUCCESS(f" Updated model: {model.name}"))
else:
self.stdout.write(self.style.WARNING(f" Model exists: {model.name}"))
model_data["api_name"] = api_name # restore
self.stdout.write(self.style.SUCCESS("Default LLM models loaded."))

View File

@@ -0,0 +1,130 @@
# Generated by Django 5.2.12 on 2026-03-10 16:59
import django.db.models.deletion
import llm_manager.encryption
import uuid
from decimal import Decimal
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='LLMApi',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)),
('api_type', models.CharField(choices=[('openai', 'OpenAI Compatible'), ('azure', 'Azure OpenAI'), ('ollama', 'Ollama'), ('anthropic', 'Anthropic'), ('llama-cpp', 'Llama.cpp'), ('vllm', 'vLLM')], max_length=20)),
('base_url', models.URLField()),
('api_key', llm_manager.encryption.EncryptedCharField(blank=True, default='', max_length=500)),
('is_active', models.BooleanField(default=True)),
('supports_streaming', models.BooleanField(default=True)),
('timeout_seconds', models.PositiveIntegerField(default=60)),
('max_retries', models.PositiveIntegerField(default=3)),
('last_tested_at', models.DateTimeField(blank=True, help_text='Last time this API was tested', null=True)),
('last_test_status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed'), ('pending', 'Pending')], default='pending', help_text='Result of the last API test', max_length=20)),
('last_test_message', models.TextField(blank=True, help_text='Details from the last test (success message or error)')),
('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='llm_apis_created', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'LLM API',
'verbose_name_plural': 'LLM APIs',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='LLMModel',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('display_name', models.CharField(blank=True, max_length=200)),
('model_type', models.CharField(choices=[('chat', 'Chat/Completion'), ('embedding', 'Embedding'), ('vision', 'Vision'), ('audio', 'Audio'), ('reranker', 'Reranker'), ('multimodal_embed', 'Multimodal Embedding')], max_length=20)),
('context_window', models.PositiveIntegerField(help_text='Maximum context in tokens')),
('max_output_tokens', models.PositiveIntegerField(blank=True, null=True)),
('supports_cache', models.BooleanField(default=False)),
('supports_vision', models.BooleanField(default=False)),
('supports_function_calling', models.BooleanField(default=False)),
('supports_json_mode', models.BooleanField(default=False)),
('supports_multimodal', models.BooleanField(default=False, help_text='Flag models that accept image+text input')),
('vector_dimensions', models.PositiveIntegerField(blank=True, help_text='Embedding output dimensions (e.g., 4096)', null=True)),
('input_cost_per_1k', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='Cost per 1K input tokens in USD', max_digits=10)),
('output_cost_per_1k', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='Cost per 1K output tokens in USD', max_digits=10)),
('cached_cost_per_1k', models.DecimalField(blank=True, decimal_places=6, help_text='Cost per 1K cached tokens (if supported)', max_digits=10, null=True)),
('is_active', models.BooleanField(default=True)),
('is_system_embedding_model', models.BooleanField(default=False, help_text='Mark this as the system-wide embedding model. Only ONE embedding model should have this set to True.')),
('is_system_chat_model', models.BooleanField(default=False, help_text='Mark this as the system-wide chat model. Only ONE chat model should have this set to True.')),
('is_system_reranker_model', models.BooleanField(default=False, help_text='Mark this as the system-wide reranker model. Only ONE reranker model should have this set to True.')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('api', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='models', to='llm_manager.llmapi')),
],
options={
'ordering': ['api', 'name'],
},
),
migrations.CreateModel(
name='LLMUsage',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)),
('input_tokens', models.PositiveIntegerField(default=0)),
('output_tokens', models.PositiveIntegerField(default=0)),
('cached_tokens', models.PositiveIntegerField(default=0)),
('total_cost', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='Total cost in USD', max_digits=12)),
('session_id', models.CharField(blank=True, db_index=True, max_length=100)),
('purpose', models.CharField(choices=[('responder', 'RAG Responder'), ('reviewer', 'RAG Reviewer'), ('embeddings', 'Document Embeddings'), ('search', 'Vector Search'), ('reranking', 'Re-ranking'), ('multimodal_embed', 'Multimodal Embedding'), ('other', 'Other')], db_index=True, default='other', max_length=50)),
('request_metadata', models.JSONField(blank=True, help_text='Additional context (prompt, temperature, etc.)', null=True)),
('model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='usage_records', to='llm_manager.llmmodel')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='llm_usage', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-timestamp'],
},
),
migrations.AddIndex(
model_name='llmmodel',
index=models.Index(fields=['api', 'model_type', 'is_active'], name='llm_manager_api_id_140af0_idx'),
),
migrations.AddIndex(
model_name='llmmodel',
index=models.Index(fields=['is_system_embedding_model', 'model_type'], name='llm_manager_is_syst_39386f_idx'),
),
migrations.AddIndex(
model_name='llmmodel',
index=models.Index(fields=['is_system_chat_model', 'model_type'], name='llm_manager_is_syst_346eb3_idx'),
),
migrations.AddIndex(
model_name='llmmodel',
index=models.Index(fields=['is_system_reranker_model', 'model_type'], name='llm_manager_is_syst_cc73c6_idx'),
),
migrations.AlterUniqueTogether(
name='llmmodel',
unique_together={('api', 'name')},
),
migrations.AddIndex(
model_name='llmusage',
index=models.Index(fields=['-timestamp', 'user'], name='llm_manager_timesta_aa66fc_idx'),
),
migrations.AddIndex(
model_name='llmusage',
index=models.Index(fields=['-timestamp', 'model'], name='llm_manager_timesta_0b5c38_idx'),
),
migrations.AddIndex(
model_name='llmusage',
index=models.Index(fields=['purpose', '-timestamp'], name='llm_manager_purpose_37c32c_idx'),
),
migrations.AddIndex(
model_name='llmusage',
index=models.Index(fields=['session_id'], name='llm_manager_session_1da37d_idx'),
),
]

View File

@@ -0,0 +1,34 @@
"""
Add 'bedrock' to LLMApi.api_type choices.
Django migrations track field changes including choices — this migration
updates the api_type field to include the new Amazon Bedrock option.
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("llm_manager", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="llmapi",
name="api_type",
field=models.CharField(
choices=[
("openai", "OpenAI Compatible"),
("azure", "Azure OpenAI"),
("ollama", "Ollama"),
("anthropic", "Anthropic"),
("llama-cpp", "Llama.cpp"),
("vllm", "vLLM"),
("bedrock", "Amazon Bedrock"),
],
max_length=20,
),
),
]

View File

@@ -0,0 +1,301 @@
"""
LLM Manager models — ported from Spelunker with Mnemosyne adaptations.
Changes from Spelunker:
- api_key uses EncryptedCharField with key derived from SECRET_KEY (Themis-style)
- LLMModel.model_type adds 'reranker' and 'multimodal_embed' choices
- LLMModel adds 'supports_multimodal' and 'vector_dimensions' fields
- LLMUsage.purpose adds Mnemosyne-specific choices
"""
import uuid
from decimal import Decimal
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from .encryption import EncryptedCharField
User = get_user_model()
class LLMApi(models.Model):
"""
Represents an LLM API provider (OpenAI-compatible, Arke proxy, etc.).
API keys are stored encrypted using Fernet symmetric encryption
derived from Django's SECRET_KEY.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=100, unique=True)
api_type = models.CharField(
max_length=20,
choices=[
("openai", "OpenAI Compatible"),
("azure", "Azure OpenAI"),
("ollama", "Ollama"),
("anthropic", "Anthropic"),
("llama-cpp", "Llama.cpp"),
("vllm", "vLLM"),
("bedrock", "Amazon Bedrock"),
],
)
base_url = models.URLField()
api_key = EncryptedCharField(max_length=500, blank=True, default="")
is_active = models.BooleanField(default=True)
supports_streaming = models.BooleanField(default=True)
timeout_seconds = models.PositiveIntegerField(default=60)
max_retries = models.PositiveIntegerField(default=3)
# Testing and validation fields
last_tested_at = models.DateTimeField(
null=True,
blank=True,
help_text="Last time this API was tested",
)
last_test_status = models.CharField(
max_length=20,
choices=[
("success", "Success"),
("failed", "Failed"),
("pending", "Pending"),
],
default="pending",
help_text="Result of the last API test",
)
last_test_message = models.TextField(
blank=True,
help_text="Details from the last test (success message or error)",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="llm_apis_created",
)
class Meta:
ordering = ["name"]
verbose_name = "LLM API"
verbose_name_plural = "LLM APIs"
def __str__(self):
return f"{self.name} ({self.api_type})"
class LLMModel(models.Model):
"""
Represents a specific LLM model provided by an API.
Mnemosyne additions over Spelunker:
- model_type adds 'reranker' and 'multimodal_embed'
- supports_multimodal flag for image+text capable models
- vector_dimensions for embedding output size
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
api = models.ForeignKey(LLMApi, on_delete=models.CASCADE, related_name="models")
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=200, blank=True)
model_type = models.CharField(
max_length=20,
choices=[
("chat", "Chat/Completion"),
("embedding", "Embedding"),
("vision", "Vision"),
("audio", "Audio"),
("reranker", "Reranker"),
("multimodal_embed", "Multimodal Embedding"),
],
)
context_window = models.PositiveIntegerField(
help_text="Maximum context in tokens"
)
max_output_tokens = models.PositiveIntegerField(null=True, blank=True)
supports_cache = models.BooleanField(default=False)
supports_vision = models.BooleanField(default=False)
supports_function_calling = models.BooleanField(default=False)
supports_json_mode = models.BooleanField(default=False)
# Mnemosyne additions
supports_multimodal = models.BooleanField(
default=False,
help_text="Flag models that accept image+text input",
)
vector_dimensions = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Embedding output dimensions (e.g., 4096)",
)
# Pricing
input_cost_per_1k = models.DecimalField(
max_digits=10,
decimal_places=6,
default=Decimal("0"),
help_text="Cost per 1K input tokens in USD",
)
output_cost_per_1k = models.DecimalField(
max_digits=10,
decimal_places=6,
default=Decimal("0"),
help_text="Cost per 1K output tokens in USD",
)
cached_cost_per_1k = models.DecimalField(
max_digits=10,
decimal_places=6,
null=True,
blank=True,
help_text="Cost per 1K cached tokens (if supported)",
)
is_active = models.BooleanField(default=True)
is_system_embedding_model = models.BooleanField(
default=False,
help_text=(
"Mark this as the system-wide embedding model. "
"Only ONE embedding model should have this set to True."
),
)
is_system_chat_model = models.BooleanField(
default=False,
help_text=(
"Mark this as the system-wide chat model. "
"Only ONE chat model should have this set to True."
),
)
is_system_reranker_model = models.BooleanField(
default=False,
help_text=(
"Mark this as the system-wide reranker model. "
"Only ONE reranker model should have this set to True."
),
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["api", "name"]
unique_together = [("api", "name")]
indexes = [
models.Index(fields=["api", "model_type", "is_active"]),
models.Index(fields=["is_system_embedding_model", "model_type"]),
models.Index(fields=["is_system_chat_model", "model_type"]),
models.Index(fields=["is_system_reranker_model", "model_type"]),
]
def __str__(self):
return f"{self.api.name}: {self.name}"
@classmethod
def get_system_embedding_model(cls):
"""Get the system-wide embedding model."""
return cls.objects.filter(
is_system_embedding_model=True,
is_active=True,
model_type__in=["embedding", "multimodal_embed"],
).first()
@classmethod
def get_system_chat_model(cls):
"""Get the system-wide chat model."""
return cls.objects.filter(
is_system_chat_model=True,
is_active=True,
model_type="chat",
).first()
@classmethod
def get_system_reranker_model(cls):
"""Get the system-wide reranker model."""
return cls.objects.filter(
is_system_reranker_model=True,
is_active=True,
model_type="reranker",
).first()
class LLMUsage(models.Model):
"""
Tracks token usage and cost for all LLM API calls.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, related_name="llm_usage"
)
model = models.ForeignKey(
LLMModel, on_delete=models.PROTECT, related_name="usage_records"
)
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
input_tokens = models.PositiveIntegerField(default=0)
output_tokens = models.PositiveIntegerField(default=0)
cached_tokens = models.PositiveIntegerField(default=0)
total_cost = models.DecimalField(
max_digits=12,
decimal_places=6,
default=Decimal("0"),
help_text="Total cost in USD",
)
session_id = models.CharField(max_length=100, blank=True, db_index=True)
purpose = models.CharField(
max_length=50,
choices=[
("responder", "RAG Responder"),
("reviewer", "RAG Reviewer"),
("embeddings", "Document Embeddings"),
("search", "Vector Search"),
("reranking", "Re-ranking"),
("multimodal_embed", "Multimodal Embedding"),
("other", "Other"),
],
default="other",
db_index=True,
)
request_metadata = models.JSONField(
null=True,
blank=True,
help_text="Additional context (prompt, temperature, etc.)",
)
class Meta:
ordering = ["-timestamp"]
indexes = [
models.Index(fields=["-timestamp", "user"]),
models.Index(fields=["-timestamp", "model"]),
models.Index(fields=["purpose", "-timestamp"]),
models.Index(fields=["session_id"]),
]
def save(self, *args, **kwargs):
if not self.total_cost or self.total_cost == 0:
self.total_cost = self.calculate_cost()
super().save(*args, **kwargs)
def calculate_cost(self):
"""Calculate cost based on token usage and model pricing."""
input_cost = (self.input_tokens / 1000) * float(self.model.input_cost_per_1k)
output_cost = (self.output_tokens / 1000) * float(
self.model.output_cost_per_1k
)
cached_cost = 0
if self.cached_tokens and self.model.cached_cost_per_1k:
cached_cost = (self.cached_tokens / 1000) * float(
self.model.cached_cost_per_1k
)
return Decimal(str(input_cost + output_cost + cached_cost))
def __str__(self):
return f"{self.model.name} - {self.timestamp} - ${self.total_cost}"

View File

@@ -0,0 +1,275 @@
"""
Services for LLM API testing and model discovery.
Ported from Spelunker with Mnemosyne adaptations.
"""
import logging
from django.db import transaction
from django.utils import timezone
logger = logging.getLogger(__name__)
def test_llm_api(api):
"""
Test an LLM API connection and discover available models.
:param api: LLMApi instance to test.
:returns: dict with success, models_added/updated/deactivated, message/error.
"""
from .models import LLMModel
result = {
"success": False,
"models_added": 0,
"models_updated": 0,
"models_deactivated": 0,
"message": "",
"error": "",
}
logger.info("Testing LLM API: %s (%s) at %s", api.name, api.api_type, api.base_url)
try:
if api.api_type in ("openai", "vllm"):
discovered_models = _discover_openai_models(api)
elif api.api_type == "ollama":
discovered_models = _discover_ollama_models(api)
elif api.api_type == "bedrock":
discovered_models = _discover_bedrock_models(api)
else:
result["error"] = f"API type '{api.api_type}' is not yet supported for auto-discovery"
logger.warning(result["error"])
return result
if not discovered_models:
result["error"] = "No models discovered from API"
logger.warning("No models found for API %s", api.name)
return result
logger.info("Discovered %d models from %s", len(discovered_models), api.name)
discovered_model_names = {m["name"] for m in discovered_models}
with transaction.atomic():
for model_data in discovered_models:
model_name = model_data["name"]
try:
existing = LLMModel.objects.get(api=api, name=model_name)
existing.is_active = True
existing.display_name = model_data.get("display_name", model_name)
existing.model_type = model_data.get("model_type", "chat")
existing.context_window = model_data.get("context_window", 8192)
existing.max_output_tokens = model_data.get("max_output_tokens")
existing.supports_cache = model_data.get("supports_cache", False)
existing.supports_vision = model_data.get("supports_vision", False)
existing.supports_function_calling = model_data.get("supports_function_calling", False)
existing.supports_json_mode = model_data.get("supports_json_mode", False)
existing.save()
result["models_updated"] += 1
except LLMModel.DoesNotExist:
from decimal import Decimal
LLMModel.objects.create(
api=api,
name=model_name,
display_name=model_data.get("display_name", model_name),
model_type=model_data.get("model_type", "chat"),
context_window=model_data.get("context_window", 8192),
max_output_tokens=model_data.get("max_output_tokens"),
supports_cache=model_data.get("supports_cache", False),
supports_vision=model_data.get("supports_vision", False),
supports_function_calling=model_data.get("supports_function_calling", False),
supports_json_mode=model_data.get("supports_json_mode", False),
input_cost_per_1k=Decimal("0"),
output_cost_per_1k=Decimal("0"),
is_active=True,
)
result["models_added"] += 1
logger.info("Added new model: %s::%s", api.name, model_name)
# Deactivate models no longer available
for model in LLMModel.objects.filter(api=api, is_active=True):
if model.name not in discovered_model_names:
model.is_active = False
model.save(update_fields=["is_active"])
result["models_deactivated"] += 1
logger.warning("Deactivated missing model: %s::%s", api.name, model.name)
api.last_tested_at = timezone.now()
api.last_test_status = "success"
api.last_test_message = (
f"Added: {result['models_added']}, "
f"Updated: {result['models_updated']}, "
f"Deactivated: {result['models_deactivated']}"
)
api.save(update_fields=["last_tested_at", "last_test_status", "last_test_message"])
result["success"] = True
result["message"] = api.last_test_message
logger.info("API test successful: %s%s", api.name, result["message"])
except Exception as e:
result["error"] = f"API test failed: {e}"
api.last_tested_at = timezone.now()
api.last_test_status = "failed"
api.last_test_message = result["error"]
api.save(update_fields=["last_tested_at", "last_test_status", "last_test_message"])
logger.error("API test failed for %s: %s", api.name, e, exc_info=True)
return result
def _discover_openai_models(api):
"""Discover models from an OpenAI-compatible API."""
try:
from openai import OpenAI
except ImportError:
raise ImportError("openai package required for model discovery — pip install openai")
client = OpenAI(
api_key=api.api_key or "dummy",
base_url=api.base_url,
timeout=api.timeout_seconds,
max_retries=api.max_retries,
)
discovered = []
models_response = client.models.list()
for model in models_response.data:
model_id = model.id
discovered.append(
{
"name": model_id,
"display_name": model_id,
"model_type": _infer_model_type(model_id),
"context_window": _infer_context_window(model_id),
"max_output_tokens": None,
"supports_cache": False,
"supports_vision": any(
kw in model_id.lower() for kw in ("vision", "gpt-4-turbo", "gpt-4o")
),
"supports_function_calling": any(
kw in model_id.lower() for kw in ("gpt-4", "gpt-3.5-turbo")
),
"supports_json_mode": any(
kw in model_id.lower() for kw in ("gpt-4", "gpt-3.5-turbo")
),
}
)
return discovered
def _discover_ollama_models(api):
"""Discover models from an Ollama API."""
import requests
url = f"{api.base_url.rstrip('/')}/api/tags"
discovered = []
resp = requests.get(url, timeout=10)
resp.raise_for_status()
for model in resp.json().get("models", []):
name = model["name"]
discovered.append(
{
"name": name,
"display_name": name,
"model_type": "chat",
"context_window": 4096,
"max_output_tokens": None,
"supports_cache": False,
"supports_vision": False,
"supports_function_calling": False,
"supports_json_mode": False,
}
)
return discovered
def _discover_bedrock_models(api):
"""
Discover models from Amazon Bedrock via the Mantle OpenAI-compatible endpoint.
For Bedrock APIs, the base_url is the bedrock-runtime endpoint. We derive
the Mantle endpoint from the region to list models.
"""
import requests
# Extract region from base_url (e.g. https://bedrock-runtime.us-east-1.amazonaws.com)
base = api.base_url.rstrip("/")
region = "us-east-1"
if "bedrock-runtime." in base:
# Parse region from URL
parts = base.split("bedrock-runtime.")[1].split(".")
if parts:
region = parts[0]
# Use the Mantle endpoint for model listing (OpenAI-compatible)
mantle_url = f"https://bedrock-mantle.{region}.api.aws/v1/models"
headers = {}
if api.api_key:
headers["Authorization"] = f"Bearer {api.api_key}"
discovered = []
try:
resp = requests.get(mantle_url, headers=headers, timeout=api.timeout_seconds or 30)
resp.raise_for_status()
data = resp.json()
for model in data.get("data", []):
model_id = model.get("id", "")
discovered.append(
{
"name": model_id,
"display_name": model_id,
"model_type": _infer_model_type(model_id),
"context_window": _infer_context_window(model_id),
"max_output_tokens": None,
"supports_cache": False,
"supports_vision": any(
kw in model_id.lower() for kw in ("claude-3", "nova", "vision")
),
"supports_function_calling": False,
"supports_json_mode": False,
}
)
except Exception as exc:
logger.warning("Bedrock Mantle model discovery failed: %s", exc)
# Fallback: return empty list (user can manually add models)
return discovered
def _infer_model_type(model_id):
"""Infer model type from its identifier."""
lower = model_id.lower()
if any(kw in lower for kw in ("embed", "embedding")):
return "embedding"
if "rerank" in lower:
return "reranker"
return "chat"
def _infer_context_window(model_id):
"""Infer context window size from model identifier."""
m = model_id.lower()
if any(kw in m for kw in ("gpt-4-turbo", "gpt-4-1106", "gpt-4-0125", "gpt-4o")):
return 128000
if "gpt-4-32k" in m:
return 32768
if "gpt-4" in m:
return 8192
if "gpt-3.5-turbo-16k" in m:
return 16384
if "gpt-3.5-turbo" in m:
return 4096
if "claude-3" in m:
return 200000
if "claude-2" in m:
return 100000
if "32k" in m:
return 32768
if "16k" in m:
return 16384
return 8192

View File

@@ -0,0 +1,86 @@
"""
Celery tasks for LLM Manager — ported from Spelunker.
"""
import logging
from celery import shared_task
from .models import LLMApi
from .services import test_llm_api
logger = logging.getLogger(__name__)
@shared_task(name="llm_manager.validate_all_llm_apis")
def validate_all_llm_apis():
"""
Periodic task to validate all active LLM APIs and discover models.
Schedule via Celery Beat (e.g. hourly or daily).
"""
logger.info("Starting periodic LLM API validation")
active_apis = LLMApi.objects.filter(is_active=True)
if not active_apis.exists():
logger.info("No active APIs to validate")
return {"status": "completed", "tested": 0, "successful": 0, "failed": 0}
results = {
"status": "completed",
"tested": 0,
"successful": 0,
"failed": 0,
"models_added": 0,
"models_updated": 0,
"models_deactivated": 0,
"details": [],
}
for api in active_apis:
results["tested"] += 1
try:
result = test_llm_api(api)
if result["success"]:
results["successful"] += 1
results["models_added"] += result["models_added"]
results["models_updated"] += result["models_updated"]
results["models_deactivated"] += result["models_deactivated"]
else:
results["failed"] += 1
results["details"].append(
{
"api_name": api.name,
"success": result["success"],
"message": result.get("message") or result.get("error", ""),
}
)
except Exception as e:
results["failed"] += 1
results["details"].append(
{"api_name": api.name, "success": False, "message": str(e)}
)
logger.error("Unexpected error validating %s: %s", api.name, e, exc_info=True)
logger.info(
"Completed LLM API validation: %d/%d successful, %d failed",
results["successful"],
results["tested"],
results["failed"],
)
return results
@shared_task(name="llm_manager.validate_single_api")
def validate_single_api(api_id):
"""Validate a single LLM API by ID."""
try:
api = LLMApi.objects.get(id=api_id)
return test_llm_api(api)
except LLMApi.DoesNotExist:
msg = f"LLM API with id {api_id} not found"
logger.error(msg)
return {"success": False, "error": msg}
except Exception as e:
logger.error("Error validating API %s: %s", api_id, e, exc_info=True)
return {"success": False, "error": str(e)}

View File

@@ -0,0 +1,22 @@
{% extends "themis/base.html" %}
{% block title %}Delete {{ api.name }}{% endblock %}
{% block content %}
<div class="max-w-lg mx-auto mt-8">
<div class="card bg-base-200 shadow">
<div class="card-body">
<h2 class="card-title text-error">Delete LLM API</h2>
<p>Are you sure you want to delete <strong>{{ api.name }}</strong>?</p>
<p class="text-sm text-base-content/70">This will also remove all associated models. Usage records will be preserved.</p>
<div class="card-actions justify-end mt-4">
<a href="{% url 'llm_manager:api_detail' api.pk %}" class="btn btn-ghost">Cancel</a>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-error">Delete</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% extends "themis/base.html" %}
{% block title %}{{ api.name }} — LLM API{% endblock %}
{% block content %}
<div class="mb-6">
<div class="text-sm breadcrumbs">
<ul>
<li><a href="{% url 'llm_manager:dashboard' %}">LLM Manager</a></li>
<li><a href="{% url 'llm_manager:api_list' %}">APIs</a></li>
<li>{{ api.name }}</li>
</ul>
</div>
<div class="flex justify-between items-center mt-2">
<h1 class="text-2xl font-bold">{{ api.name }}</h1>
<div class="flex gap-2">
<form method="post" action="{% url 'llm_manager:api_test' api.pk %}">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-accent">Test Connection</button>
</form>
<a href="{% url 'llm_manager:api_edit' api.pk %}" class="btn btn-sm btn-primary">Edit</a>
<a href="{% url 'llm_manager:api_delete' api.pk %}" class="btn btn-sm btn-error btn-outline">Delete</a>
</div>
</div>
</div>
<!-- API details -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="card bg-base-200 shadow">
<div class="card-body">
<h2 class="card-title text-lg">Configuration</h2>
<div class="grid grid-cols-2 gap-2 text-sm">
<span class="font-semibold">Type:</span><span class="badge badge-ghost">{{ api.get_api_type_display }}</span>
<span class="font-semibold">Base URL:</span><span class="font-mono text-xs break-all">{{ api.base_url }}</span>
<span class="font-semibold">Active:</span><span>{% if api.is_active %}Yes{% else %}No{% endif %}</span>
<span class="font-semibold">Streaming:</span><span>{% if api.supports_streaming %}Yes{% else %}No{% endif %}</span>
<span class="font-semibold">Timeout:</span><span>{{ api.timeout_seconds }}s</span>
<span class="font-semibold">Max Retries:</span><span>{{ api.max_retries }}</span>
</div>
</div>
</div>
<div class="card bg-base-200 shadow">
<div class="card-body">
<h2 class="card-title text-lg">Test Status</h2>
<div class="grid grid-cols-2 gap-2 text-sm">
<span class="font-semibold">Status:</span>
<span>
{% if api.last_test_status == "success" %}<span class="badge badge-success">Success</span>
{% elif api.last_test_status == "failed" %}<span class="badge badge-error">Failed</span>
{% else %}<span class="badge badge-warning">Pending</span>{% endif %}
</span>
<span class="font-semibold">Last Tested:</span><span>{{ api.last_tested_at|default:"Never" }}</span>
</div>
{% if api.last_test_message %}
<div class="mt-2 text-xs bg-base-300 p-2 rounded">{{ api.last_test_message }}</div>
{% endif %}
</div>
</div>
</div>
<!-- Models -->
<div>
<div class="flex justify-between items-center mb-3">
<h2 class="text-xl font-semibold">Models ({{ models.count }})</h2>
<a href="{% url 'llm_manager:model_create' %}?api={{ api.pk }}" class="btn btn-sm btn-primary">Add Model</a>
</div>
{% if models %}
<div class="overflow-x-auto">
<table class="table table-zebra table-sm w-full">
<thead><tr><th>Name</th><th>Type</th><th>Context</th><th>Dims</th><th>Active</th><th>System</th></tr></thead>
<tbody>
{% for m in models %}
<tr>
<td><a href="{% url 'llm_manager:model_detail' m.pk %}" class="link link-primary">{{ m.name }}</a></td>
<td><span class="badge badge-ghost badge-sm">{{ m.get_model_type_display }}</span></td>
<td>{{ m.context_window|default:"—" }}</td>
<td>{{ m.vector_dimensions|default:"—" }}</td>
<td>{% if m.is_active %}<span class="badge badge-success badge-xs"></span>{% else %}<span class="badge badge-error badge-xs"></span>{% endif %}</td>
<td>
{% if m.is_system_embedding_model %}<span class="badge badge-sm badge-success">Embed</span>{% endif %}
{% if m.is_system_chat_model %}<span class="badge badge-sm badge-info">Chat</span>{% endif %}
{% if m.is_system_reranker_model %}<span class="badge badge-sm badge-warning">Rerank</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">No models for this API yet. Use "Test Connection" to auto-discover or add manually.</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "themis/base.html" %}
{% block title %}{% if is_edit %}Edit {{ api.name }}{% else %}Add LLM API{% endif %}{% endblock %}
{% block content %}
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="{% url 'llm_manager:dashboard' %}">LLM Manager</a></li>
<li><a href="{% url 'llm_manager:api_list' %}">APIs</a></li>
<li>{% if is_edit %}Edit {{ api.name }}{% else %}Add API{% endif %}</li>
</ul>
</div>
<div class="max-w-2xl">
<h1 class="text-2xl font-bold mb-4">{% if is_edit %}Edit {{ api.name }}{% else %}Add LLM API{% endif %}</h1>
<form method="post" class="space-y-4">
{% csrf_token %}
{% for field in form %}
<div class="form-control w-full">
<label class="label"><span class="label-text font-semibold">{{ field.label }}</span></label>
{{ field }}
{% if field.errors %}
<label class="label"><span class="label-text-alt text-error">{{ field.errors.0 }}</span></label>
{% endif %}
{% if field.help_text %}
<label class="label"><span class="label-text-alt">{{ field.help_text }}</span></label>
{% endif %}
</div>
{% endfor %}
<div class="flex gap-2 pt-4">
<button type="submit" class="btn btn-primary">{% if is_edit %}Save Changes{% else %}Create API{% endif %}</button>
<a href="{% if is_edit %}{% url 'llm_manager:api_detail' api.pk %}{% else %}{% url 'llm_manager:api_list' %}{% endif %}" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,56 @@
{% extends "themis/base.html" %}
{% block title %}LLM APIs{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">LLM APIs</h1>
<a href="{% url 'llm_manager:api_create' %}" class="btn btn-primary btn-sm">Add API</a>
</div>
{% if apis %}
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Base URL</th>
<th>Active</th>
<th>Streaming</th>
<th>Status</th>
<th>Models</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for api in apis %}
<tr>
<td><a href="{% url 'llm_manager:api_detail' api.pk %}" class="link link-primary font-semibold">{{ api.name }}</a></td>
<td><span class="badge badge-ghost">{{ api.get_api_type_display }}</span></td>
<td class="font-mono text-xs max-w-xs truncate">{{ api.base_url }}</td>
<td>{% if api.is_active %}<span class="badge badge-success badge-sm">Yes</span>{% else %}<span class="badge badge-error badge-sm">No</span>{% endif %}</td>
<td>{% if api.supports_streaming %}<span class="badge badge-info badge-sm">Yes</span>{% else %}—{% endif %}</td>
<td>
{% if api.last_test_status == "success" %}<span class="badge badge-success badge-sm">OK</span>
{% elif api.last_test_status == "failed" %}<span class="badge badge-error badge-sm">Failed</span>
{% else %}<span class="badge badge-warning badge-sm">Pending</span>{% endif %}
</td>
<td>{{ api.models.count }}</td>
<td>
<a href="{% url 'llm_manager:api_edit' api.pk %}" class="btn btn-xs btn-ghost">Edit</a>
<a href="{% url 'llm_manager:api_delete' api.pk %}" class="btn btn-xs btn-ghost text-error">Delete</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">No APIs configured. <a href="{% url 'llm_manager:api_create' %}" class="link">Add one now</a>.</div>
{% endif %}
<div class="mt-4">
<a href="{% url 'llm_manager:dashboard' %}" class="btn btn-ghost btn-sm">&larr; Dashboard</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,132 @@
{% extends "themis/base.html" %}
{% block title %}LLM Manager — Dashboard{% endblock %}
{% block content %}
<div class="mb-6">
<h1 class="text-3xl font-bold">LLM Manager</h1>
<p class="text-base-content/70 mt-1">Manage LLM APIs, models, and usage tracking.</p>
</div>
<!-- Stats cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div class="stat bg-base-200 rounded-box shadow">
<div class="stat-title">Active APIs</div>
<div class="stat-value text-primary">{{ api_count }}</div>
<div class="stat-actions"><a href="{% url 'llm_manager:api_list' %}" class="btn btn-sm btn-primary">Manage</a></div>
</div>
<div class="stat bg-base-200 rounded-box shadow">
<div class="stat-title">Active Models</div>
<div class="stat-value text-secondary">{{ model_count }}</div>
<div class="stat-actions"><a href="{% url 'llm_manager:model_list' %}" class="btn btn-sm btn-secondary">Browse</a></div>
</div>
<div class="stat bg-base-200 rounded-box shadow">
<div class="stat-title">Your API Calls</div>
<div class="stat-value text-accent">{{ usage_count }}</div>
<div class="stat-desc">
{{ total_input_tokens|default:"0" }} in / {{ total_output_tokens|default:"0" }} out tokens
</div>
<div class="stat-actions"><a href="{% url 'llm_manager:usage_list' %}" class="btn btn-sm btn-accent">History</a></div>
</div>
</div>
<!-- System models -->
<div class="mb-8">
<h2 class="text-xl font-semibold mb-3">System Default Models</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="card bg-base-200 shadow">
<div class="card-body">
<h3 class="card-title text-sm">Embedding</h3>
{% if system_embedding_model %}
<p class="font-mono text-sm">{{ system_embedding_model.name }}</p>
<p class="text-xs text-base-content/60">{{ system_embedding_model.api.name }}{% if system_embedding_model.vector_dimensions %} — {{ system_embedding_model.vector_dimensions }}d{% endif %}</p>
{% else %}
<p class="text-warning text-sm">Not configured</p>
{% endif %}
</div>
</div>
<div class="card bg-base-200 shadow">
<div class="card-body">
<h3 class="card-title text-sm">Chat</h3>
{% if system_chat_model %}
<p class="font-mono text-sm">{{ system_chat_model.name }}</p>
<p class="text-xs text-base-content/60">{{ system_chat_model.api.name }}</p>
{% else %}
<p class="text-warning text-sm">Not configured</p>
{% endif %}
</div>
</div>
<div class="card bg-base-200 shadow">
<div class="card-body">
<h3 class="card-title text-sm">Reranker</h3>
{% if system_reranker_model %}
<p class="font-mono text-sm">{{ system_reranker_model.name }}</p>
<p class="text-xs text-base-content/60">{{ system_reranker_model.api.name }}</p>
{% else %}
<p class="text-warning text-sm">Not configured</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Active APIs -->
<div class="mb-8">
<div class="flex justify-between items-center mb-3">
<h2 class="text-xl font-semibold">Active APIs</h2>
<a href="{% url 'llm_manager:api_create' %}" class="btn btn-sm btn-primary">Add API</a>
</div>
{% if active_apis %}
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead><tr><th>Name</th><th>Type</th><th>URL</th><th>Status</th><th>Last Tested</th></tr></thead>
<tbody>
{% for api in active_apis %}
<tr>
<td><a href="{% url 'llm_manager:api_detail' api.pk %}" class="link link-primary">{{ api.name }}</a></td>
<td><span class="badge badge-ghost">{{ api.get_api_type_display }}</span></td>
<td class="font-mono text-xs">{{ api.base_url }}</td>
<td>
{% if api.last_test_status == "success" %}
<span class="badge badge-success">OK</span>
{% elif api.last_test_status == "failed" %}
<span class="badge badge-error">Failed</span>
{% else %}
<span class="badge badge-warning">Pending</span>
{% endif %}
</td>
<td class="text-xs">{{ api.last_tested_at|default:"Never" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">No active APIs configured yet.</div>
{% endif %}
</div>
<!-- Recent usage -->
{% if recent_usage %}
<div>
<h2 class="text-xl font-semibold mb-3">Recent Usage</h2>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm w-full">
<thead><tr><th>Time</th><th>Model</th><th>In</th><th>Out</th><th>Cost</th><th>Purpose</th></tr></thead>
<tbody>
{% for u in recent_usage %}
<tr>
<td class="text-xs">{{ u.timestamp|date:"M d H:i" }}</td>
<td class="font-mono text-xs">{{ u.model.name }}</td>
<td>{{ u.input_tokens }}</td>
<td>{{ u.output_tokens }}</td>
<td>${{ u.total_cost|floatformat:4 }}</td>
<td><span class="badge badge-ghost badge-sm">{{ u.get_purpose_display }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "themis/base.html" %}
{% block title %}Delete {{ model.name }}{% endblock %}
{% block content %}
<div class="max-w-lg mx-auto mt-8">
<div class="card bg-base-200 shadow">
<div class="card-body">
<h2 class="card-title text-error">Delete LLM Model</h2>
<p>Are you sure you want to delete <strong class="font-mono">{{ model.name }}</strong> from <strong>{{ model.api.name }}</strong>?</p>
<p class="text-sm text-base-content/70">Usage records referencing this model will be preserved.</p>
<div class="card-actions justify-end mt-4">
<a href="{% url 'llm_manager:model_detail' model.pk %}" class="btn btn-ghost">Cancel</a>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-error">Delete</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,66 @@
{% extends "themis/base.html" %}
{% block title %}{{ model.name }} — LLM Model{% endblock %}
{% block content %}
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="{% url 'llm_manager:dashboard' %}">LLM Manager</a></li>
<li><a href="{% url 'llm_manager:model_list' %}">Models</a></li>
<li>{{ model.name }}</li>
</ul>
</div>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold font-mono">{{ model.name }}</h1>
<div class="flex gap-2">
<a href="{% url 'llm_manager:model_edit' model.pk %}" class="btn btn-sm btn-primary">Edit</a>
<a href="{% url 'llm_manager:model_delete' model.pk %}" class="btn btn-sm btn-error btn-outline">Delete</a>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="card bg-base-200 shadow">
<div class="card-body">
<h2 class="card-title text-lg">Details</h2>
<div class="grid grid-cols-2 gap-2 text-sm">
<span class="font-semibold">Display Name:</span><span>{{ model.display_name|default:"—" }}</span>
<span class="font-semibold">API:</span><span><a href="{% url 'llm_manager:api_detail' model.api.pk %}" class="link link-primary">{{ model.api.name }}</a></span>
<span class="font-semibold">Type:</span><span class="badge badge-ghost">{{ model.get_model_type_display }}</span>
<span class="font-semibold">Active:</span><span>{% if model.is_active %}Yes{% else %}No{% endif %}</span>
<span class="font-semibold">Context Window:</span><span>{{ model.context_window|default:"—" }} tokens</span>
<span class="font-semibold">Max Output:</span><span>{{ model.max_output_tokens|default:"—" }} tokens</span>
<span class="font-semibold">Dimensions:</span><span>{{ model.vector_dimensions|default:"—" }}</span>
</div>
</div>
</div>
<div class="card bg-base-200 shadow">
<div class="card-body">
<h2 class="card-title text-lg">Capabilities</h2>
<div class="flex flex-wrap gap-2">
{% if model.supports_cache %}<span class="badge badge-success">Cache</span>{% endif %}
{% if model.supports_vision %}<span class="badge badge-info">Vision</span>{% endif %}
{% if model.supports_multimodal %}<span class="badge badge-accent">Multimodal</span>{% endif %}
{% if model.supports_function_calling %}<span class="badge badge-secondary">Functions</span>{% endif %}
{% if model.supports_json_mode %}<span class="badge badge-warning">JSON Mode</span>{% endif %}
</div>
<h3 class="font-semibold mt-4 text-sm">Pricing (per 1K tokens)</h3>
<div class="grid grid-cols-2 gap-2 text-sm">
<span>Input:</span><span>${{ model.input_cost_per_1k }}</span>
<span>Output:</span><span>${{ model.output_cost_per_1k }}</span>
{% if model.cached_cost_per_1k %}<span>Cached:</span><span>${{ model.cached_cost_per_1k }}</span>{% endif %}
</div>
<h3 class="font-semibold mt-4 text-sm">System Defaults</h3>
<div class="flex flex-wrap gap-2">
{% if model.is_system_embedding_model %}<span class="badge badge-success">System Embedding</span>{% endif %}
{% if model.is_system_chat_model %}<span class="badge badge-info">System Chat</span>{% endif %}
{% if model.is_system_reranker_model %}<span class="badge badge-warning">System Reranker</span>{% endif %}
{% if not model.is_system_embedding_model and not model.is_system_chat_model and not model.is_system_reranker_model %}
<span class="text-base-content/50 text-sm">Not a system default</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "themis/base.html" %}
{% block title %}{% if is_edit %}Edit {{ model.name }}{% else %}Add LLM Model{% endif %}{% endblock %}
{% block content %}
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="{% url 'llm_manager:dashboard' %}">LLM Manager</a></li>
<li><a href="{% url 'llm_manager:model_list' %}">Models</a></li>
<li>{% if is_edit %}Edit {{ model.name }}{% else %}Add Model{% endif %}</li>
</ul>
</div>
<div class="max-w-2xl">
<h1 class="text-2xl font-bold mb-4">{% if is_edit %}Edit {{ model.name }}{% else %}Add LLM Model{% endif %}</h1>
<form method="post" class="space-y-4">
{% csrf_token %}
{% for field in form %}
<div class="form-control w-full">
<label class="label"><span class="label-text font-semibold">{{ field.label }}</span></label>
{{ field }}
{% if field.errors %}
<label class="label"><span class="label-text-alt text-error">{{ field.errors.0 }}</span></label>
{% endif %}
{% if field.help_text %}
<label class="label"><span class="label-text-alt">{{ field.help_text }}</span></label>
{% endif %}
</div>
{% endfor %}
<div class="flex gap-2 pt-4">
<button type="submit" class="btn btn-primary">{% if is_edit %}Save Changes{% else %}Create Model{% endif %}</button>
<a href="{% if is_edit %}{% url 'llm_manager:model_detail' model.pk %}{% else %}{% url 'llm_manager:model_list' %}{% endif %}" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends "themis/base.html" %}
{% block title %}LLM Models{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">LLM Models</h1>
<a href="{% url 'llm_manager:model_create' %}" class="btn btn-primary btn-sm">Add Model</a>
</div>
<!-- Filter by API -->
<div class="mb-4 flex gap-2 items-center">
<span class="font-semibold text-sm">Filter API:</span>
<a href="{% url 'llm_manager:model_list' %}" class="btn btn-xs {% if not selected_api %}btn-primary{% else %}btn-ghost{% endif %}">All</a>
{% for api in apis %}
<a href="{% url 'llm_manager:model_list' %}?api={{ api.pk }}" class="btn btn-xs {% if selected_api == api.pk|slugify %}btn-primary{% else %}btn-ghost{% endif %}">{{ api.name }}</a>
{% endfor %}
</div>
{% if models %}
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Name</th>
<th>API</th>
<th>Type</th>
<th>Context</th>
<th>Dims</th>
<th>$/1K In</th>
<th>$/1K Out</th>
<th>Active</th>
<th>System</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for m in models %}
<tr>
<td><a href="{% url 'llm_manager:model_detail' m.pk %}" class="link link-primary font-mono text-sm">{{ m.name }}</a></td>
<td class="text-sm">{{ m.api.name }}</td>
<td><span class="badge badge-ghost badge-sm">{{ m.get_model_type_display }}</span></td>
<td>{{ m.context_window|default:"—" }}</td>
<td>{{ m.vector_dimensions|default:"—" }}</td>
<td class="text-xs">${{ m.input_cost_per_1k }}</td>
<td class="text-xs">${{ m.output_cost_per_1k }}</td>
<td>{% if m.is_active %}<span class="badge badge-success badge-xs"></span>{% else %}<span class="badge badge-error badge-xs"></span>{% endif %}</td>
<td>
{% if m.is_system_embedding_model %}<span class="badge badge-sm badge-success">Embed</span>{% endif %}
{% if m.is_system_chat_model %}<span class="badge badge-sm badge-info">Chat</span>{% endif %}
{% if m.is_system_reranker_model %}<span class="badge badge-sm badge-warning">Rerank</span>{% endif %}
</td>
<td>
<a href="{% url 'llm_manager:model_edit' m.pk %}" class="btn btn-xs btn-ghost">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">No models found.</div>
{% endif %}
<div class="mt-4">
<a href="{% url 'llm_manager:dashboard' %}" class="btn btn-ghost btn-sm">&larr; Dashboard</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "themis/base.html" %}
{% block title %}Usage History{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Usage History</h1>
</div>
<!-- Totals -->
{% if totals %}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="stat bg-base-200 rounded-box shadow py-3">
<div class="stat-title text-xs">Input Tokens</div>
<div class="stat-value text-lg">{{ totals.total_input|default:"0" }}</div>
</div>
<div class="stat bg-base-200 rounded-box shadow py-3">
<div class="stat-title text-xs">Output Tokens</div>
<div class="stat-value text-lg">{{ totals.total_output|default:"0" }}</div>
</div>
<div class="stat bg-base-200 rounded-box shadow py-3">
<div class="stat-title text-xs">Cached Tokens</div>
<div class="stat-value text-lg">{{ totals.total_cached|default:"0" }}</div>
</div>
<div class="stat bg-base-200 rounded-box shadow py-3">
<div class="stat-title text-xs">Total Cost</div>
<div class="stat-value text-lg">${{ totals.total_cost|default:"0"|floatformat:4 }}</div>
</div>
</div>
{% endif %}
{% if usage_records %}
<div class="overflow-x-auto">
<table class="table table-zebra table-sm w-full">
<thead>
<tr>
<th>Timestamp</th>
<th>Model</th>
<th>API</th>
<th>Input</th>
<th>Output</th>
<th>Cached</th>
<th>Cost</th>
<th>Purpose</th>
<th>Session</th>
</tr>
</thead>
<tbody>
{% for u in usage_records %}
<tr>
<td class="text-xs">{{ u.timestamp|date:"Y-m-d H:i:s" }}</td>
<td class="font-mono text-xs">{{ u.model.name }}</td>
<td class="text-xs">{{ u.model.api.name }}</td>
<td>{{ u.input_tokens }}</td>
<td>{{ u.output_tokens }}</td>
<td>{{ u.cached_tokens }}</td>
<td class="text-xs">${{ u.total_cost|floatformat:4 }}</td>
<td><span class="badge badge-ghost badge-xs">{{ u.get_purpose_display }}</span></td>
<td class="font-mono text-xs max-w-[8rem] truncate">{{ u.session_id|default:"—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">No usage records yet.</div>
{% endif %}
<div class="mt-4">
<a href="{% url 'llm_manager:dashboard' %}" class="btn btn-ghost btn-sm">&larr; Dashboard</a>
</div>
{% endblock %}

View File

View File

@@ -0,0 +1,154 @@
"""
Tests for LLM Manager DRF API endpoints.
"""
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from llm_manager.models import LLMApi, LLMModel, LLMUsage
User = get_user_model()
class LLMApiEndpointTest(TestCase):
"""Tests for the LLM API endpoints."""
def setUp(self):
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.api = LLMApi.objects.create(
name="Test API",
api_type="openai",
base_url="https://api.example.com/v1",
)
def test_api_list(self):
resp = self.client.get(reverse("llm-manager-api:api_list"))
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.data), 1)
self.assertEqual(resp.data[0]["name"], "Test API")
def test_api_detail(self):
resp = self.client.get(reverse("llm-manager-api:api_detail", kwargs={"pk": self.api.pk}))
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data["name"], "Test API")
def test_api_not_found(self):
import uuid
resp = self.client.get(reverse("llm-manager-api:api_detail", kwargs={"pk": uuid.uuid4()}))
self.assertEqual(resp.status_code, 404)
def test_requires_auth(self):
self.client.force_authenticate(user=None)
resp = self.client.get(reverse("llm-manager-api:api_list"))
self.assertIn(resp.status_code, [401, 403])
class LLMModelEndpointTest(TestCase):
"""Tests for the LLM Model API endpoints."""
def setUp(self):
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.api = LLMApi.objects.create(
name="Test API",
api_type="openai",
base_url="https://api.example.com/v1",
)
self.model = LLMModel.objects.create(
api=self.api,
name="gpt-4o",
model_type="chat",
context_window=128000,
)
def test_model_list(self):
resp = self.client.get(reverse("llm-manager-api:model_list"))
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.data), 1)
def test_model_list_filter_type(self):
LLMModel.objects.create(
api=self.api,
name="embed-model",
model_type="embedding",
context_window=8191,
)
resp = self.client.get(reverse("llm-manager-api:model_list") + "?type=embedding")
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.data), 1)
self.assertEqual(resp.data[0]["model_type"], "embedding")
def test_model_detail(self):
resp = self.client.get(reverse("llm-manager-api:model_detail", kwargs={"pk": self.model.pk}))
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data["name"], "gpt-4o")
def test_system_models_empty(self):
resp = self.client.get(reverse("llm-manager-api:system_models"))
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, {})
def test_system_models_configured(self):
self.model.is_system_chat_model = True
self.model.save()
resp = self.client.get(reverse("llm-manager-api:system_models"))
self.assertEqual(resp.status_code, 200)
self.assertIn("chat", resp.data)
self.assertEqual(resp.data["chat"]["name"], "gpt-4o")
class LLMUsageEndpointTest(TestCase):
"""Tests for the LLM Usage API endpoints."""
def setUp(self):
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.api = LLMApi.objects.create(
name="Test API",
api_type="openai",
base_url="https://api.example.com/v1",
)
self.model = LLMModel.objects.create(
api=self.api,
name="gpt-4o",
model_type="chat",
context_window=128000,
input_cost_per_1k=Decimal("0.0025"),
output_cost_per_1k=Decimal("0.01"),
)
def test_usage_list_empty(self):
resp = self.client.get(reverse("llm-manager-api:usage_list"))
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, [])
def test_usage_create(self):
resp = self.client.post(
reverse("llm-manager-api:usage_list"),
{
"model": str(self.model.pk),
"input_tokens": 1000,
"output_tokens": 500,
"purpose": "other",
},
format="json",
)
self.assertEqual(resp.status_code, 201)
self.assertEqual(LLMUsage.objects.count(), 1)
def test_usage_list_returns_own_records(self):
other_user = User.objects.create_user(username="other", password="testpass123")
LLMUsage.objects.create(user=self.user, model=self.model, input_tokens=100, output_tokens=50)
LLMUsage.objects.create(user=other_user, model=self.model, input_tokens=200, output_tokens=100)
resp = self.client.get(reverse("llm-manager-api:usage_list"))
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.data), 1)

View File

@@ -0,0 +1,236 @@
"""
Tests for LLM Manager models: LLMApi, LLMModel, LLMUsage.
"""
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
from llm_manager.models import LLMApi, LLMModel, LLMUsage
User = get_user_model()
class LLMApiModelTest(TestCase):
"""Tests for the LLMApi model."""
def setUp(self):
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.api = LLMApi.objects.create(
name="Test API",
api_type="openai",
base_url="https://api.example.com/v1",
is_active=True,
created_by=self.user,
)
def test_str(self):
self.assertEqual(str(self.api), "Test API (openai)")
def test_default_values(self):
self.assertTrue(self.api.is_active)
self.assertTrue(self.api.supports_streaming)
self.assertEqual(self.api.timeout_seconds, 60)
self.assertEqual(self.api.max_retries, 3)
self.assertEqual(self.api.last_test_status, "pending")
def test_uuid_primary_key(self):
self.assertIsNotNone(self.api.pk)
self.assertEqual(len(str(self.api.pk)), 36) # UUID format
def test_unique_name(self):
with self.assertRaises(Exception):
LLMApi.objects.create(
name="Test API",
api_type="ollama",
base_url="http://localhost:11434",
)
class LLMApiEncryptionTest(TestCase):
"""Tests for API key encryption."""
def test_api_key_encrypted_at_rest(self):
"""API key should be encrypted in the database."""
api = LLMApi.objects.create(
name="Encrypted Test",
api_type="openai",
base_url="https://api.example.com/v1",
api_key="sk-test-secret-key-12345",
)
# Re-fetch from database
api_fresh = LLMApi.objects.get(pk=api.pk)
self.assertEqual(api_fresh.api_key, "sk-test-secret-key-12345")
def test_blank_api_key(self):
api = LLMApi.objects.create(
name="No Key",
api_type="ollama",
base_url="http://localhost:11434",
api_key="",
)
api_fresh = LLMApi.objects.get(pk=api.pk)
self.assertEqual(api_fresh.api_key, "")
class LLMModelModelTest(TestCase):
"""Tests for the LLMModel model."""
def setUp(self):
self.api = LLMApi.objects.create(
name="Test API",
api_type="openai",
base_url="https://api.example.com/v1",
)
self.model = LLMModel.objects.create(
api=self.api,
name="gpt-4o",
display_name="GPT-4o",
model_type="chat",
context_window=128000,
max_output_tokens=16384,
input_cost_per_1k=Decimal("0.0025"),
output_cost_per_1k=Decimal("0.01"),
)
def test_str(self):
self.assertEqual(str(self.model), "Test API: gpt-4o")
def test_unique_together(self):
"""Model name must be unique per API."""
with self.assertRaises(Exception):
LLMModel.objects.create(
api=self.api,
name="gpt-4o",
model_type="chat",
context_window=8192,
)
def test_model_types(self):
"""All model types should be creatable."""
for mtype in ["embedding", "vision", "audio", "reranker", "multimodal_embed"]:
m = LLMModel.objects.create(
api=self.api,
name=f"test-{mtype}",
model_type=mtype,
context_window=8192,
)
self.assertEqual(m.model_type, mtype)
def test_mnemosyne_fields(self):
"""Mnemosyne-specific fields: supports_multimodal, vector_dimensions."""
embed = LLMModel.objects.create(
api=self.api,
name="text-embedding-3-large",
model_type="embedding",
context_window=8191,
vector_dimensions=3072,
supports_multimodal=False,
)
self.assertEqual(embed.vector_dimensions, 3072)
self.assertFalse(embed.supports_multimodal)
def test_get_system_embedding_model(self):
embed = LLMModel.objects.create(
api=self.api,
name="embed-model",
model_type="embedding",
context_window=8191,
is_system_embedding_model=True,
)
result = LLMModel.get_system_embedding_model()
self.assertEqual(result.pk, embed.pk)
def test_get_system_chat_model(self):
self.model.is_system_chat_model = True
self.model.save()
result = LLMModel.get_system_chat_model()
self.assertEqual(result.pk, self.model.pk)
def test_get_system_reranker_model(self):
reranker = LLMModel.objects.create(
api=self.api,
name="reranker-model",
model_type="reranker",
context_window=8192,
is_system_reranker_model=True,
)
result = LLMModel.get_system_reranker_model()
self.assertEqual(result.pk, reranker.pk)
def test_get_system_model_returns_none(self):
"""Returns None when no system model is configured."""
self.assertIsNone(LLMModel.get_system_embedding_model())
self.assertIsNone(LLMModel.get_system_chat_model())
self.assertIsNone(LLMModel.get_system_reranker_model())
class LLMUsageModelTest(TestCase):
"""Tests for the LLMUsage model."""
def setUp(self):
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.api = LLMApi.objects.create(
name="Test API",
api_type="openai",
base_url="https://api.example.com/v1",
)
self.model = LLMModel.objects.create(
api=self.api,
name="gpt-4o",
model_type="chat",
context_window=128000,
input_cost_per_1k=Decimal("0.0025"),
output_cost_per_1k=Decimal("0.01"),
)
def test_cost_calculation(self):
"""Total cost is auto-calculated on save."""
usage = LLMUsage.objects.create(
user=self.user,
model=self.model,
input_tokens=1000,
output_tokens=500,
purpose="other",
)
# 1000/1000 * 0.0025 + 500/1000 * 0.01 = 0.0025 + 0.005 = 0.0075
self.assertAlmostEqual(float(usage.total_cost), 0.0075, places=4)
def test_cost_with_cached_tokens(self):
self.model.cached_cost_per_1k = Decimal("0.00125")
self.model.save()
usage = LLMUsage.objects.create(
user=self.user,
model=self.model,
input_tokens=1000,
output_tokens=500,
cached_tokens=2000,
purpose="responder",
)
# 0.0025 + 0.005 + 2000/1000 * 0.00125 = 0.0025 + 0.005 + 0.0025 = 0.01
self.assertAlmostEqual(float(usage.total_cost), 0.01, places=4)
def test_purpose_choices(self):
for purpose in ["responder", "reviewer", "embeddings", "search", "reranking", "multimodal_embed", "other"]:
usage = LLMUsage.objects.create(
user=self.user,
model=self.model,
input_tokens=100,
output_tokens=50,
purpose=purpose,
)
self.assertEqual(usage.purpose, purpose)
def test_protect_model_delete(self):
"""Deleting a model with usage records should raise ProtectedError."""
LLMUsage.objects.create(
user=self.user,
model=self.model,
input_tokens=100,
output_tokens=50,
)
from django.db.models import ProtectedError
with self.assertRaises(ProtectedError):
self.model.delete()

View File

@@ -0,0 +1,162 @@
"""
Tests for LLM Manager views — FBV-based.
"""
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from llm_manager.models import LLMApi, LLMModel
User = get_user_model()
class LLMDashboardViewTest(TestCase):
"""Tests for the LLM Manager dashboard."""
def setUp(self):
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.client.login(username="testuser", password="testpass123")
def test_dashboard_requires_login(self):
self.client.logout()
resp = self.client.get(reverse("llm_manager:dashboard"))
self.assertEqual(resp.status_code, 302)
def test_dashboard_renders(self):
resp = self.client.get(reverse("llm_manager:dashboard"))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "LLM Manager")
class LLMApiViewTest(TestCase):
"""Tests for LLMApi CRUD views."""
def setUp(self):
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.client.login(username="testuser", password="testpass123")
self.api = LLMApi.objects.create(
name="Test API",
api_type="openai",
base_url="https://api.example.com/v1",
created_by=self.user,
)
def test_api_list(self):
resp = self.client.get(reverse("llm_manager:api_list"))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Test API")
def test_api_detail(self):
resp = self.client.get(reverse("llm_manager:api_detail", kwargs={"pk": self.api.pk}))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Test API")
def test_api_create_get(self):
resp = self.client.get(reverse("llm_manager:api_create"))
self.assertEqual(resp.status_code, 200)
def test_api_create_post(self):
resp = self.client.post(
reverse("llm_manager:api_create"),
{
"name": "New API",
"api_type": "ollama",
"base_url": "http://localhost:11434",
"is_active": True,
"supports_streaming": True,
"timeout_seconds": 60,
"max_retries": 3,
},
)
self.assertEqual(resp.status_code, 302)
self.assertTrue(LLMApi.objects.filter(name="New API").exists())
def test_api_edit(self):
resp = self.client.post(
reverse("llm_manager:api_edit", kwargs={"pk": self.api.pk}),
{
"name": "Updated API",
"api_type": "openai",
"base_url": "https://api.example.com/v2",
"is_active": True,
"supports_streaming": True,
"timeout_seconds": 30,
"max_retries": 5,
},
)
self.assertEqual(resp.status_code, 302)
self.api.refresh_from_db()
self.assertEqual(self.api.name, "Updated API")
def test_api_delete(self):
resp = self.client.post(reverse("llm_manager:api_delete", kwargs={"pk": self.api.pk}))
self.assertEqual(resp.status_code, 302)
self.assertFalse(LLMApi.objects.filter(pk=self.api.pk).exists())
class LLMModelViewTest(TestCase):
"""Tests for LLMModel CRUD views."""
def setUp(self):
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.client.login(username="testuser", password="testpass123")
self.api = LLMApi.objects.create(
name="Test API",
api_type="openai",
base_url="https://api.example.com/v1",
)
self.model = LLMModel.objects.create(
api=self.api,
name="gpt-4o",
model_type="chat",
context_window=128000,
)
def test_model_list(self):
resp = self.client.get(reverse("llm_manager:model_list"))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "gpt-4o")
def test_model_list_filter_by_api(self):
resp = self.client.get(reverse("llm_manager:model_list") + f"?api={self.api.pk}")
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "gpt-4o")
def test_model_detail(self):
resp = self.client.get(reverse("llm_manager:model_detail", kwargs={"pk": self.model.pk}))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "gpt-4o")
def test_model_create(self):
resp = self.client.post(
reverse("llm_manager:model_create"),
{
"api": str(self.api.pk),
"name": "gpt-4o-mini",
"model_type": "chat",
"context_window": 128000,
"input_cost_per_1k": "0.000150",
"output_cost_per_1k": "0.000600",
"is_active": True,
},
)
self.assertEqual(resp.status_code, 302)
self.assertTrue(LLMModel.objects.filter(name="gpt-4o-mini").exists())
def test_model_delete(self):
resp = self.client.post(reverse("llm_manager:model_delete", kwargs={"pk": self.model.pk}))
self.assertEqual(resp.status_code, 302)
self.assertFalse(LLMModel.objects.filter(pk=self.model.pk).exists())
class UsageListViewTest(TestCase):
"""Tests for the usage list view."""
def setUp(self):
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.client.login(username="testuser", password="testpass123")
def test_usage_list(self):
resp = self.client.get(reverse("llm_manager:usage_list"))
self.assertEqual(resp.status_code, 200)

View File

@@ -0,0 +1,28 @@
"""
URL patterns for LLM Manager — FBVs per Red Panda Standards.
"""
from django.urls import path
from . import views
app_name = "llm_manager"
urlpatterns = [
path("", views.dashboard, name="dashboard"),
# APIs
path("apis/", views.api_list, name="api_list"),
path("apis/create/", views.api_create, name="api_create"),
path("apis/<uuid:pk>/", views.api_detail, name="api_detail"),
path("apis/<uuid:pk>/edit/", views.api_edit, name="api_edit"),
path("apis/<uuid:pk>/delete/", views.api_delete, name="api_delete"),
path("apis/<uuid:pk>/test/", views.api_test, name="api_test"),
# Models
path("models/", views.model_list, name="model_list"),
path("models/create/", views.model_create, name="model_create"),
path("models/<uuid:pk>/", views.model_detail, name="model_detail"),
path("models/<uuid:pk>/edit/", views.model_edit, name="model_edit"),
path("models/<uuid:pk>/delete/", views.model_delete, name="model_delete"),
# Usage
path("usage/", views.usage_list, name="usage_list"),
]

View File

@@ -0,0 +1,209 @@
"""
Views for LLM Manager — FBVs per Red Panda Standards.
Rewrites Spelunker's CBVs (LLMApiListView, LLMModelListView, LLMUsageListView)
as function-based views.
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Count, Sum
from django.shortcuts import get_object_or_404, redirect, render
from .forms import LLMApiForm, LLMModelForm
from .models import LLMApi, LLMModel, LLMUsage
from .services import test_llm_api
@login_required
def dashboard(request):
"""LLM Manager dashboard with overview statistics."""
totals = LLMUsage.objects.filter(user=request.user).aggregate(
total_input=Sum("input_tokens"),
total_output=Sum("output_tokens"),
total_cost=Sum("total_cost"),
count=Count("id"),
)
context = {
"api_count": LLMApi.objects.filter(is_active=True).count(),
"model_count": LLMModel.objects.filter(is_active=True).count(),
"usage_count": totals["count"] or 0,
"total_input_tokens": totals["total_input"] or 0,
"total_output_tokens": totals["total_output"] or 0,
"total_cost": totals["total_cost"] or 0,
"recent_usage": (
LLMUsage.objects.filter(user=request.user)
.select_related("model", "model__api")
.order_by("-timestamp")[:10]
),
"active_apis": LLMApi.objects.filter(is_active=True),
"system_embedding_model": LLMModel.get_system_embedding_model(),
"system_chat_model": LLMModel.get_system_chat_model(),
"system_reranker_model": LLMModel.get_system_reranker_model(),
}
return render(request, "llm_manager/dashboard.html", context)
# --------------------------------------------------------------------------- #
# LLMApi CRUD
# --------------------------------------------------------------------------- #
@login_required
def api_list(request):
"""List all LLM APIs."""
apis = LLMApi.objects.all().order_by("name")
return render(request, "llm_manager/api_list.html", {"apis": apis})
@login_required
def api_create(request):
"""Create a new LLM API."""
if request.method == "POST":
form = LLMApiForm(request.POST)
if form.is_valid():
api = form.save(commit=False)
api.created_by = request.user
api.save()
messages.success(request, f"API '{api.name}' created.")
return redirect("llm_manager:api_detail", pk=api.pk)
else:
form = LLMApiForm()
return render(request, "llm_manager/api_form.html", {"form": form, "is_edit": False})
@login_required
def api_detail(request, pk):
"""View LLM API details with associated models."""
api = get_object_or_404(LLMApi, pk=pk)
models = api.models.all().order_by("name")
return render(request, "llm_manager/api_detail.html", {"api": api, "models": models})
@login_required
def api_edit(request, pk):
"""Edit an LLM API."""
api = get_object_or_404(LLMApi, pk=pk)
if request.method == "POST":
form = LLMApiForm(request.POST, instance=api)
if form.is_valid():
form.save()
messages.success(request, f"API '{api.name}' updated.")
return redirect("llm_manager:api_detail", pk=api.pk)
else:
form = LLMApiForm(instance=api)
return render(request, "llm_manager/api_form.html", {"form": form, "api": api, "is_edit": True})
@login_required
def api_delete(request, pk):
"""Delete an LLM API."""
api = get_object_or_404(LLMApi, pk=pk)
if request.method == "POST":
name = api.name
api.delete()
messages.success(request, f"API '{name}' deleted.")
return redirect("llm_manager:api_list")
return render(request, "llm_manager/api_confirm_delete.html", {"api": api})
@login_required
def api_test(request, pk):
"""Test an LLM API connection and discover models."""
api = get_object_or_404(LLMApi, pk=pk)
if request.method == "POST":
result = test_llm_api(api)
if result["success"]:
messages.success(request, f"API test successful: {result['message']}")
else:
messages.error(request, f"API test failed: {result['error']}")
return redirect("llm_manager:api_detail", pk=api.pk)
# --------------------------------------------------------------------------- #
# LLMModel views
# --------------------------------------------------------------------------- #
@login_required
def model_list(request):
"""List all LLM Models, optionally filtered by API."""
qs = LLMModel.objects.select_related("api").order_by("api__name", "name")
api_id = request.GET.get("api")
if api_id:
qs = qs.filter(api_id=api_id)
apis = LLMApi.objects.all().order_by("name")
return render(request, "llm_manager/model_list.html", {"models": qs, "apis": apis, "selected_api": api_id})
@login_required
def model_create(request):
"""Create a new LLM Model."""
if request.method == "POST":
form = LLMModelForm(request.POST)
if form.is_valid():
model = form.save()
messages.success(request, f"Model '{model.name}' created.")
return redirect("llm_manager:model_list")
else:
form = LLMModelForm()
api_id = request.GET.get("api")
if api_id:
form.initial["api"] = api_id
return render(request, "llm_manager/model_form.html", {"form": form, "is_edit": False})
@login_required
def model_detail(request, pk):
"""View LLM Model details."""
model = get_object_or_404(LLMModel.objects.select_related("api"), pk=pk)
return render(request, "llm_manager/model_detail.html", {"model": model})
@login_required
def model_edit(request, pk):
"""Edit an LLM Model."""
model = get_object_or_404(LLMModel, pk=pk)
if request.method == "POST":
form = LLMModelForm(request.POST, instance=model)
if form.is_valid():
form.save()
messages.success(request, f"Model '{model.name}' updated.")
return redirect("llm_manager:model_detail", pk=model.pk)
else:
form = LLMModelForm(instance=model)
return render(request, "llm_manager/model_form.html", {"form": form, "model": model, "is_edit": True})
@login_required
def model_delete(request, pk):
"""Delete an LLM Model."""
model = get_object_or_404(LLMModel, pk=pk)
if request.method == "POST":
name = model.name
model.delete()
messages.success(request, f"Model '{name}' deleted.")
return redirect("llm_manager:model_list")
return render(request, "llm_manager/model_confirm_delete.html", {"model": model})
# --------------------------------------------------------------------------- #
# Usage views
# --------------------------------------------------------------------------- #
@login_required
def usage_list(request):
"""List LLM usage history for the current user."""
qs = (
LLMUsage.objects.filter(user=request.user)
.select_related("model", "model__api")
.order_by("-timestamp")
)
totals = qs.aggregate(
total_input=Sum("input_tokens"),
total_output=Sum("output_tokens"),
total_cached=Sum("cached_tokens"),
total_cost=Sum("total_cost"),
)
return render(request, "llm_manager/usage_list.html", {"usage_records": qs[:100], "totals": totals})