Open to AI engineering roles

Production

LLM Studio

A multi-user, ChatGPT-style AI platform — per-user tenancy, token streaming, multi-LLM routing, and daily quotas, Dockerized and CI'd.

  • Designed and built (solo)
  • 2025 – present
Application recording — a multi-model chat workspace with per-user history and model switching. Visit the live app →
tenancy isolationHow this was measured — Queries for chats and history are scoped to the authenticated owner in the repository layer, alongside route-level authentication.
per-user
LLM routingHow this was measured — Hosted models and a local option are routed behind one interface, with per-provider rate-limit and quota errors surfaced cleanly to the user instead of failing the request.
cloud + local

Context

"Wrap an LLM API in a chat UI" is a weekend project. Turning that into something multiple people can actually sign into is where the engineering lives: passwords hashed correctly, sessions that can be revoked, chat queries scoped to the authenticated owner, a budget on a shared key, and streaming through a reverse proxy.

Constraints

One shared model key powers everyone, so abuse control is mandatory. It ships on a $0 stack (a Hugging Face Docker Space + Neon Postgres), and the frontend is intentionally framework-free to keep the container tiny and the ChatGPT pixel-match tractable.

System architecture

A strictly layered FastAPI backend serving a vanilla-JS SPA: routes → services → an LLM router → a repository → the database. Tenancy is enforced at the repository layer, streaming is SSE tuned for proxies, and the same SQLAlchemy code runs over Postgres in production and SQLite in tests.

System map

Tap to inspect

  1. 01
  2. 02
  3. 03
  4. 04

Use the arrow keys, Home, or End to move between nodes. Press Enter or Space to pin a node's decision, and Escape to release it.

LLM Studio — quota is checked before the stream; tenancy is enforced at the repository.

Data flow

A request authenticates against a server-side session, the daily quota is checked before the model call, chat_service streams tokens over SSE, the router picks an OpenAI-compatible client for the chosen model, and the repository persists the turn scoped to the owner.

Key decisions

DB-backed sessions over stateless JWTs

Instead of: stateless JWTs

Argon2id hashing and an opaque server-side session token (HttpOnly, SameSite=Lax) give real revocation, session tracking, and clean expiry.

Trade-off accepted — A DB read per authenticated request — worth more than shaving a query for a chat app.

Tenancy enforced at the repository layer, not in routes

Instead of: ownership checks sprinkled across routes

Ownership is enforced where data is accessed: lists filter by owner, and upsert/delete silently refuse a chat owned by someone else — one auditable choke point.

Trade-off accepted — The 'silent skip' is less chatty than a 403; repository-level checks can be reviewed and tested together.

Quota checked before the stream, not after

Instead of: meter tokens after the call, no quota

Each user gets a daily budget keyed by user + date, atomically incremented and checked before the LLM call, so an over-quota user gets a clean 429 instead of burning an API call.

Trade-off accepted — A coarse per-message budget rather than per-token metering — simpler and enough to keep a shared free-tier key alive.

OpenAI-compatible routing over per-vendor SDKs

Instead of: a separate SDK per provider

One client factory returns an OpenAI-compatible client for cloud models (Gemini, Groq, Mistral, GLM, NVIDIA) and local Ollama, so adding a model never touches the streaming code.

Trade-off accepted — Providers must speak the OpenAI protocol.

SSE tuned for real proxies

Instead of: WebSockets, plain SSE

Streaming sends text/event-stream frames with Cache-Control: no-cache and X-Accel-Buffering: no so a reverse proxy doesn't buffer the token stream.

Trade-off accepted — One-directional SSE rather than WebSockets — exactly right for token streaming.

What failed

Millisecond-epoch timestamp columns were overflowing 32-bit INT4in Postgres (values near 2.1×10⁹) while SQLite silently tolerated it — the exact class of bug that's invisible locally and obvious against a real database. The columns are now BigInteger.

v1overflowtimestamp columns

Cause — ms-epoch values overflowed 32-bit INT4 in Postgres; SQLite silently tolerated it

v2BigIntegertimestamp columns

Fix — widened the columns; caught only by deploying against the real production database

Performance & cost

MetricValueMethod
tests passing31pytest + ruff gating CI, plus pip-audit + gitleaks + Dependabot.
database pathdualIdentical SQLAlchemy 2.0 code over Postgres (prod, pooled) and SQLite (local + tests), so tests need zero external services.
hardeningCSP + limitsSliding-window rate limits on auth/reset/upload, a strict CSP (object-src none, frame-ancestors none), HSTS, and structured per-request logging.
document grounding10 MB cap.txt/.md/.pdf/.docx uploads are extension- and size-limited, extracted, and truncated to a 200k-char cap before use as context.

Operations

Shipped as a single Docker container; sessions and usage live in Postgres. The ownership check is visible at the repository boundary:

repository (chat upsert)py
# a chat owned by someone else is silently refused — one auditable choke point
if existing and existing.owner_id not in (None, owner_id):
    continue  # never touch another tenant's row

What I'd do differently

A side-by-side compare mode (stream two models to the same prompt) is on the roadmap; the shipped app streams one model per request. I'd also move the coarse per-message quota to real per-token metering once usage justifies the added bookkeeping.

Evidence

The app is live and the auth, tenancy, and routing code are in the repository above.

Changelog

  1. End-to-end account flows (change/forgot password, delete account) and SEO/PWA metadata.

  2. A ChatGPT-parity UI overhaul and clearer provider rate-limit / quota error handling.

  3. Rebuilt a single-user chat app into a multi-user SaaS — per-user auth + history, Dockerized, CI/CD, hardened.