Most teams wire LLM calls directly into views and scatter prompt templates across the codebase. A structured Django admin for managing LLM prompts and API keys centralizes configuration, tightens access control, and makes iteration safe without a code deploy. This guide gives an actionable, ordered path from data modeling to safe inference calls, with the tradeoffs called out.
1. Model prompts and keys as explicit entities
The first step in any Django admin for managing LLM prompts and API keys is to stop treating them as environment variables and string literals. Promote them to first-class models. Prompts need versioning; credentials need isolation.
from django.db import models
class PromptTemplate(models.Model):
slug = models.SlugField(unique=True)
body = models.TextField(help_text="Use {var} for interpolation")
model = models.CharField(max_length=128, default="gpt-4o-mini")
version = models.PositiveIntegerField(default=1)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ("slug", "version")
ordering = ("slug", "-version")
class ProviderCredential(models.Model):
name = models.CharField(max_length=64, unique=True)
base_url = models.URLField(default="https://api.openai.com/v1")
encrypted_key = models.BinaryField()
created_at = models.DateTimeField(auto_now_add=True)
Keep PromptTemplate immutable per version. Bump version on edit; never update the body in place. This gives you a rollback path when a prompt change degrades output.
2. Encrypt API keys at rest
Storing API keys in a CharField or TextField is a breach waiting to happen. Django’s SECRET_KEY is for signing, not encryption. Generate a dedicated Fernet key and keep it in the environment.
# settings.py
from cryptography.fernet import Fernet
import os
CREDENTIAL_ENCRYPTION_KEY = os.environb[b"CREDENTIAL_ENCRYPTION_KEY"] # bytes
Wrap encrypt/decrypt in a small module:
# credentials.py
from cryptography.fernet import Fernet
from django.conf import settings
def encrypt_key(raw: str) -> bytes:
return Fernet(settings.CREDENTIAL_ENCRYPTION_KEY).encrypt(raw.encode())
def decrypt_key(blob: bytes) -> str:
return Fernet(settings.CREDENTIAL_ENCRYPTION_KEY).decrypt(blob).decode()
Pitfall: if you ever log ProviderCredential instances or print them in a shell, define __str__ to return only self.name. The BinaryField will not reveal the key, but a careless debug view might call decrypt_key and echo it.
3. Register admin with tight permissions
When you build a Django admin for managing LLM prompts and API keys, permissions are the first line of defense. Use a custom form with a password input for the key, and never show the encrypted blob in list_display.
from django import forms
from django.contrib import admin
from .models import PromptTemplate, ProviderCredential
from .credentials import encrypt_key
class CredentialForm(forms.ModelForm):
raw_key = forms.CharField(widget=forms.PasswordInput, required=False)
class Meta:
model = ProviderCredential
fields = ("name", "base_url", "raw_key")
@admin.register(ProviderCredential)
class ProviderCredentialAdmin(admin.ModelAdmin):
form = CredentialForm
list_display = ("name", "base_url", "created_at")
readonly_fields = ("encrypted_key",)
def save_model(self, request, obj, form, change):
raw = form.cleaned_data.get("raw_key")
if raw:
obj.encrypted_key = encrypt_key(raw)
super().save_model(request, obj, form, change)
For PromptTemplate, keep the admin simple but searchable:
@admin.register(PromptTemplate)
class PromptTemplateAdmin(admin.ModelAdmin):
list_display = ("slug", "version", "model", "created_at")
search_fields = ("slug", "body")
fieldsets = ((None, {"fields": ("slug", "version", "model", "body")}),)
Tradeoff: a custom form means you handle validation. Require base_url to end with /v1 if you target OpenAI-compatible APIs, but don’t hardcode—some gateways differ.
4. Interpolate and call the model
Write a service layer that pulls the latest prompt version, renders it, and calls the provider. Use the official OpenAI client with a configurable base_url so you can point at any compatible gateway.
from openai import OpenAI
from .models import PromptTemplate, ProviderCredential
from .credentials import decrypt_key
def run_prompt(slug, **kwargs):
pt = PromptTemplate.objects.filter(slug=slug).first()
if not pt:
raise ValueError(f"No prompt {slug}")
cred = ProviderCredential.objects.first()
client = OpenAI(
api_key=decrypt_key(cred.encrypted_key),
base_url=cred.base_url,
)
try:
rendered = pt.body.format(**kwargs)
except KeyError as e:
raise ValueError(f"Missing template var {e}")
resp = client.chat.completions.create(
model=pt.model,
messages=[{"role": "user", "content": rendered}],
temperature=0.2,
)
return resp.choices[0].message.content
If you route through a gateway like n4n.ai, it exposes one OpenAI-compatible endpoint covering 240+ models with automatic fallback when a provider is rate-limited, so the base_url and key management collapse to a single credential row.
5. Add fallback and metering
Providers degrade. Wrap the call in a loop over credentials, catching RateLimitError and APIConnectionError.
from openai import RateLimitError, APIConnectionError
def run_prompt_with_fallback(slug, **kwargs):
pt = PromptTemplate.objects.filter(slug=slug).first()
for cred in ProviderCredential.objects.all():
try:
client = OpenAI(api_key=decrypt_key(cred.encrypted_key), base_url=cred.base_url)
rendered = pt.body.format(**kwargs)
return client.chat.completions.create(model=pt.model, messages=[{"role":"user","content":rendered}])
except (RateLimitError, APIConnectionError):
continue
raise RuntimeError("All providers failed")
Tradeoff: fallback can double-spend tokens if the first call partially succeeded. Use per-token usage metering (many gateways report this in the response usage field) to reconcile cost, and make the call idempotent where the task allows.
6. Common pitfalls and tradeoffs
The tradeoffs around a Django admin for managing LLM prompts and API keys center on exposure versus convenience.
- Prompt injection via interpolation:
format(**kwargs)trusts caller keys. Validatekwargsagainst a whitelist derived fromstring.Formatter().parse(pt.body). - Admin surface area: Django admin is powerful; put it behind SSO or IP allowlist. Disable
list_editableon any credential field. - Key rotation: Keep a list of encryption keys in settings. On rotation, decrypt with old, re-encrypt with new, and store new key as primary.
- Model deprecation: Pin
modelper prompt version. If a model is retired, fail loudly inrun_promptinstead of silently swapping.
7. Audit and observability
Add a logging model to capture invocations. This is read-only in admin.
class PromptInvocation(models.Model):
prompt = models.ForeignKey(PromptTemplate, on_delete=models.SET_NULL, null=True)
tokens = models.PositiveIntegerField()
latency_ms = models.IntegerField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ("-created_at",)
Wrap run_prompt to insert a row with resp.usage.total_tokens and latency. Partition this table monthly; it grows quickly under load.
8. Testing the admin
Write tests that use Client to ensure the custom form encrypts and that raw_key is never rendered back.
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from .models import ProviderCredential
from .credentials import decrypt_key
class CredentialAdminTest(TestCase):
def test_encrypts_on_save(self):
User = get_user_model()
admin = User.objects.create_superuser("a", "a@b.c", "pw")
c = Client()
c.force_login(admin)
c.post("/admin/app/providercredential/add/", {
"name": "test", "base_url": "https://api.openai.com/v1", "raw_key": "sk-test"
})
obj = ProviderCredential.objects.get(name="test")
self.assertEqual(decrypt_key(obj.encrypted_key), "sk-test")
This catches regressions where a field rename leaks plaintext.
9. Ship checklist
- Models for
PromptTemplate(versioned) andProviderCredential(encrypted blob). - Fernet key in environment;
__str__avoids key leak. - Admin classes with password-input form and restricted permissions.
- Service function that interpolates, calls, and falls back.
- Invocation log for audit and token metering.
- Tests for encryption and admin flow.
A solid Django admin for managing LLM prompts and API keys reduces footguns and lets non-engineers tweak copy without a deploy. Treat it as production infrastructure, not a debug convenience.