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:
1
mnemosyne/llm_manager/__init__.py
Normal file
1
mnemosyne/llm_manager/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = "llm_manager.apps.LLMManagerConfig"
|
||||
326
mnemosyne/llm_manager/admin.py
Normal file
326
mnemosyne/llm_manager/admin.py
Normal 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
|
||||
0
mnemosyne/llm_manager/api/__init__.py
Normal file
0
mnemosyne/llm_manager/api/__init__.py
Normal file
105
mnemosyne/llm_manager/api/serializers.py
Normal file
105
mnemosyne/llm_manager/api/serializers.py
Normal 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"]
|
||||
18
mnemosyne/llm_manager/api/urls.py
Normal file
18
mnemosyne/llm_manager/api/urls.py
Normal 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"),
|
||||
]
|
||||
100
mnemosyne/llm_manager/api/views.py
Normal file
100
mnemosyne/llm_manager/api/views.py
Normal 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)
|
||||
7
mnemosyne/llm_manager/apps.py
Normal file
7
mnemosyne/llm_manager/apps.py
Normal 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"
|
||||
65
mnemosyne/llm_manager/encryption.py
Normal file
65
mnemosyne/llm_manager/encryption.py
Normal 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)
|
||||
82
mnemosyne/llm_manager/forms.py
Normal file
82
mnemosyne/llm_manager/forms.py
Normal 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"}),
|
||||
}
|
||||
0
mnemosyne/llm_manager/management/__init__.py
Normal file
0
mnemosyne/llm_manager/management/__init__.py
Normal 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."))
|
||||
130
mnemosyne/llm_manager/migrations/0001_initial.py
Normal file
130
mnemosyne/llm_manager/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
0
mnemosyne/llm_manager/migrations/__init__.py
Normal file
0
mnemosyne/llm_manager/migrations/__init__.py
Normal file
301
mnemosyne/llm_manager/models.py
Normal file
301
mnemosyne/llm_manager/models.py
Normal 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}"
|
||||
275
mnemosyne/llm_manager/services.py
Normal file
275
mnemosyne/llm_manager/services.py
Normal 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
|
||||
86
mnemosyne/llm_manager/tasks.py
Normal file
86
mnemosyne/llm_manager/tasks.py
Normal 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)}
|
||||
@@ -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 %}
|
||||
93
mnemosyne/llm_manager/templates/llm_manager/api_detail.html
Normal file
93
mnemosyne/llm_manager/templates/llm_manager/api_detail.html
Normal 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 %}
|
||||
39
mnemosyne/llm_manager/templates/llm_manager/api_form.html
Normal file
39
mnemosyne/llm_manager/templates/llm_manager/api_form.html
Normal 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 %}
|
||||
56
mnemosyne/llm_manager/templates/llm_manager/api_list.html
Normal file
56
mnemosyne/llm_manager/templates/llm_manager/api_list.html
Normal 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">← Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
132
mnemosyne/llm_manager/templates/llm_manager/dashboard.html
Normal file
132
mnemosyne/llm_manager/templates/llm_manager/dashboard.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
39
mnemosyne/llm_manager/templates/llm_manager/model_form.html
Normal file
39
mnemosyne/llm_manager/templates/llm_manager/model_form.html
Normal 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 %}
|
||||
68
mnemosyne/llm_manager/templates/llm_manager/model_list.html
Normal file
68
mnemosyne/llm_manager/templates/llm_manager/model_list.html
Normal 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">← Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
72
mnemosyne/llm_manager/templates/llm_manager/usage_list.html
Normal file
72
mnemosyne/llm_manager/templates/llm_manager/usage_list.html
Normal 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">← Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
mnemosyne/llm_manager/tests/__init__.py
Normal file
0
mnemosyne/llm_manager/tests/__init__.py
Normal file
154
mnemosyne/llm_manager/tests/test_api.py
Normal file
154
mnemosyne/llm_manager/tests/test_api.py
Normal 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)
|
||||
236
mnemosyne/llm_manager/tests/test_models.py
Normal file
236
mnemosyne/llm_manager/tests/test_models.py
Normal 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()
|
||||
162
mnemosyne/llm_manager/tests/test_views.py
Normal file
162
mnemosyne/llm_manager/tests/test_views.py
Normal 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)
|
||||
28
mnemosyne/llm_manager/urls.py
Normal file
28
mnemosyne/llm_manager/urls.py
Normal 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"),
|
||||
]
|
||||
209
mnemosyne/llm_manager/views.py
Normal file
209
mnemosyne/llm_manager/views.py
Normal 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})
|
||||
Reference in New Issue
Block a user