Designing a Zero-Trust BYOK Multi-Provider AI Architecture in Django
How the DjangoPlay AI Assistant talks to five different AI providers, and why a user's own API key never touches the database.
CB
The DjangoPlay AI Assistant is the chat feature built into DjangoPlay. Under the hood, it's a Django app I called aicore, and it's just one part of a bigger app — there's also a usage dashboard and some admin tooling I won't cover here. This post is about one problem I had to solve: how do you let people chat with an AI model, using either your models or their own API key, without ever saving that key to a database?
Here's the short version of why this matters. Most apps that add an AI chat feature pick one AI company and stick with it. That's simple to build, but it means you're stuck with one vendor's pricing, and if that vendor has an outage, your chat feature goes down too. Some apps let you paste in your own API key instead — which is more flexible, but only if they don't then store that key in a database somewhere it could leak.
I wanted both: flexibility, and no stored keys. So the AI Assistant can talk to five different kinds of providers, and if you bring your own key, it's only ever used for a single request. Here's what happens when you send a message:
1. One Chat Box, Five Ways to Answer It
Every provider the assistant can talk to — whether I've configured it, or you've supplied it — plugs into the same simple interface. The rest of the app doesn't need to know or care which one is actually running:
- xAI's Grok for everyday reasoning
- OpenAI models I've configured on the platform
- Self-hosted engines — Ollama, vLLM, and similar tools — running on a server I point to
- OpenRouter's free model catalog, refreshed and cached on the server
- Your own key (BYOK) — you tell the browser which provider, which model, and which key to use, and it's used for that one message only. This is also how models outside the platform's own list get in, like Anthropic's Claude, by pointing at an OpenAI-compatible endpoint such as OpenRouter
When you start a new chat, the app picks a provider for you: your explicit choice first, then whatever I've set as the platform default. Your own key is never picked automatically — it's only used when you've actually supplied one.
2. Why Your API Key Never Reaches the Database
If I stored your API key in the database, I'd be taking on a risk I don't think is mine to take. A leaked database backup, an overly broad admin account, one careless log line — any of those could expose your key. So instead, your key is never written anywhere. It lives only for the duration of a single request, then it's gone.
Here's roughly what that looks like in code:
# aicore/services/byok.py
@dataclass(frozen=True)
class BYOKCredentials:
base_url: str
model: str
api_key: str
def decode_byok_header(raw_header_value: str | None) -> BYOKCredentials | None:
"""Reads your credentials from a request header, just for this
one request. Never saved, never logged, never cached."""
if not raw_header_value:
return None
decoded = base64.b64decode(raw_header_value, validate=True)
payload = json.loads(decoded.decode("utf-8"))
return build_credentials(
base_url=payload.get("base_url", ""),
model=payload.get("model", ""),
api_key=payload.get("api_key", ""),
)Your browser sends your key in a request header, this function reads it, and it's used to build a throwaway connection to your chosen provider. Once your reply has finished streaming back, that connection — and your key along with it — is discarded.
3. Watching the Reply Get Written, Word by Word
Instead of waiting for the AI to finish its whole answer before showing you anything, the AI Assistant streams each piece of the reply as soon as it's ready — similar to watching someone type in real time. Behind the scenes, this uses a web standard called Server-Sent Events, which is a much simpler tool than a full websocket connection when you only need the server talking to the browser, not the other way around.
A stream of events looks like this:
event: session
data: {"session_id": "5b1e-849c", "provider": "openrouter", "model": "meta-llama/llama-3.2-3b-instruct:free"}
event: token
data: {"text": "Hi"}
event: token
data: {"text": " there"}
event: done
data: {"session_id": "5b1e-849c"}There's a fourth event, error, that fires if something goes wrong partway through — so the chat window can show you a clear message instead of just hanging.
4. What Happens Before Your Message Reaches an AI Model
Before any message reaches an AI provider, it passes through a few quick checks:
- Are you allowed to use the assistant at all? Some accounts have it turned on, some don't.
- Are you sending messages too fast? There's a per-minute and a per-day limit.
- Does this message actually need DjangoPlay's product knowledge? A lightweight check looks at your message — something like "hi" or "thanks" skips loading the platform knowledge base into the prompt, since it clearly doesn't need it. Anything that looks like a real question gets the full context. When it's unsure, it includes the context rather than risk giving you a worse answer.
- Have you hit your monthly token budget? There are three separate budgets — one for platform-provided models, one for OpenRouter's free catalog, and one for BYOK — so heavy use in one doesn't eat into another.
Only once all of these pass does the app actually call an AI provider:
# aicore/services/rate_limit.py
def check_request_limits(*, user_id: int, rpm_limit: int, rpd_limit: int) -> None:
if _peek(rpm_key) >= rpm_limit:
raise AIRateLimitError("You're sending messages too fast.")
if _peek(rpd_key) >= rpd_limit:
raise AIRateLimitError("Daily AI chat limit reached.")
# ...records this request against both windowsEven BYOK requests go through the rate limits and budget checks. The tokens themselves are billed to your own key, not mine, but I still don't want the assistant to be usable as a free relay to hammer some arbitrary endpoint.
Want the Full Picture?
This post only covers how a single message travels through the system. For every setting, every supported provider, and the admin-side tooling, see the aicore documentation ↗.
What I'd Tell You If You're Building Something Similar
- Give every provider the same interface. Whether it's a vendor I've configured or a key you've supplied, the rest of the app calls the same method and never has to know the difference.
- Don't store what you don't have to. If a credential only needs to live for one request, don't give it a permanent home.
- Cheap checks first, expensive calls last. Skipping unnecessary context and enforcing rate limits before ever calling an AI provider saves money and keeps one bad actor from affecting everyone else.