n4nAI

API integration

Guide

Django admin for managing LLM prompts and API keys

Learn how to build a secure Django admin interface for managing LLM prompts and API keys, with models, encryption, and safe integration patterns.

n4n Team3 min read649 words

Audio narration

Coming soon — every post will get a voice note here.

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. Validate kwargs against a whitelist derived from string.Formatter().parse(pt.body).
  • Admin surface area: Django admin is powerful; put it behind SSO or IP allowlist. Disable list_editable on 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 model per prompt version. If a model is retired, fail loudly in run_prompt instead 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

  1. Models for PromptTemplate (versioned) and ProviderCredential (encrypted blob).
  2. Fernet key in environment; __str__ avoids key leak.
  3. Admin classes with password-input form and restricted permissions.
  4. Service function that interpolates, calls, and falls back.
  5. Invocation log for audit and token metering.
  6. 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.

Tagsdjangodjango-adminprompt-managementapi-keys

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →