Mubin Attar

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
Silent tour of the live app — a private, multi-model chat workspace: streaming answers, model switching, and per-user quotas. Visit the live app →

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, a hard guarantee that user A can never read user B's chats, a fair budget on a shared key, and streaming that survives 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, but cross-tenant access is structurally impossible.

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

Causems-epoch values overflowed 32-bit INT4 in Postgres; SQLite silently tolerated it

v2BigIntegertimestamp columns

Fixwidened 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 tenancy guarantee is one line at the choke point:

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.