Skip to content

Set up for development

How-to
  • contributor

LQ.AI is three services with no shared in-process code — api/ (FastAPI), gateway/ (the Inference Gateway), and web/ (a SvelteKit fork of OpenWebUI) — talking over HTTP through OpenAPI contracts. This page is the practical loop for running all three, plus the database, worker, and object-store services they depend on.

You don’t need the whole stack to read code or run the Python or Gateway test suites — but trying the app in a browser needs the website, API, and their supporting services together. docker compose up -d (or docker compose --profile local up -d for local Ollama, no external key) brings up all of it in one command; make run-dev/make stop-dev wrap the same bring-up and shutdown. Python dependencies for api/ and gateway/ are locked with uv (make install-api/make install-gateway); only relock uv.lock when deliberately changing dependencies. The dockerized web container serves a prebuilt bundle, not a live dev server, so editing frontend code needs Vite running natively on the host alongside it — see the exact values further down. Two things corrupt a shared dev stack: running Alembic migrations from the host against the live database, and docker compose down -v.

You need Git (git) and Docker with Compose:

Terminal window
git clone https://github.com/legalquants/lq-ai.git
cd lq-ai
cp .env.example .env
# Edit .env with at least one LLM provider API key — or use the local profile
docker compose up -d # Mode 1: bring-your-own-key providers
# OR
docker compose --profile local up -d # Mode 2: local Ollama, no external key

This starts postgres, redis, rustfs, gateway, api, ingest-worker, arq-worker, and web; the local profile adds ollama (docker-compose.yml). rustfs is the object store — it replaced a bundled MinIO (ADR 0036); the OBJECT_STORE_* environment variables are the current contract, with the old MINIO_* names still accepted as a fallback. make run-dev wraps the same bring-up command; make stop-dev stops the stack without touching volumes.

Both subsystems lock dependencies with uv, decided in ADR 0023 so the transitive dependency tree is deterministic and dependency pull requests at the Gateway’s security boundary carry concrete versions in the diff. For ordinary setup:

Terminal window
make install-api # or install-gateway
cd api && .venv/bin/uvicorn app.main:app --reload --port 8000

uv sync --frozen --extra dev creates .venv/ from the committed uv.lock without changing it. Only run uv lock in a subsystem when you are deliberately changing pyproject.toml, and commit the updated lockfile — CI gates on uv lock --check, so a pyproject.toml edit without a relocked uv.lock fails CI, not only review.

Run the Python test suite in Docker if you don’t want a local venv:

Terminal window
docker build -f api/Dockerfile.dev -t lq-ai-api-dev api/
docker run --rm lq-ai-api-dev python -m pytest tests/ -x

Dockerfile.dev installs the [dev] extras (pytest, respx, pytest-cov) on top of the production image; the production Dockerfile deliberately does not, to keep the runtime image’s surface area minimal.

Terminal window
make install-web # installs the committed package-lock.json
cd web && npm run dev

web’s Docker image, though, only builds a static SvelteKit bundle — there is no source bind-mount and no dev server wired into the compose service. Editing web/src and refreshing the browser shows nothing until you rebuild the image, which is a multi-minute loop per change. web/docs/frontend-dev.md documents the fix: run Vite natively on the host for hot-module reload, while keeping the dockerized web container running (republished on port 8080) because OpenWebUI’s own embedded backend, which the SPA depends on at boot, only ships inside that image. The one-time setup adds Vite’s origin to LQ_AI_CORS_ORIGINS and points web/.env at the api container; day to day, it’s WEB_HOST_PORT=8080 docker compose up -d … followed by npm run dev, then browsing to :5173 rather than :8080 or :3000. Before shipping a frontend change, build the production image at least once and click through it there — Vite dev and the static-adapter build can diverge.

Concretely, the one-time setup is two values. In the root .env, LQ_AI_CORS_ORIGINS=http://localhost:3000,http://localhost:5173, then recreate api (docker compose up -d api) so the container picks up the new value. In web/.env (create the file if needed), PUBLIC_LQ_AI_API_BASE_URL=http://localhost:8000/api/v1, because Vite only reads env files from web/, never the repository root. Each has its own failure mode: without the web container republished on :8080, npm run dev starts but the SPA redirects to its “Backend Required” error page, because dev mode hard-codes the embedded backend at <hostname>:8080; without web/.env, the client falls back to a relative /api/v1, which resolves against :5173 and 404s. Two things the Vite loop does not cover: a change under web/backend/ (OpenWebUI’s embedded backend, not LQ.AI’s api/) still needs docker compose build web, and npm run dev fetches Pyodide artifacts into web/static/pyodide before starting — a one-time cost per checkout, not per restart. To return to the fully dockerized stack, stop Vite and run docker compose up -d web without the override; .env’s default WEB_HOST_PORT=3000 takes over.

When deliberately changing a Web dependency, use npm install <package> (or npm uninstall) and commit both web/package.json and web/package-lock.json; use npm ci for ordinary setup.

Terminal window
make migrate # run Alembic migrations against the running api container
make migrate-status # show the current head

When a migration lands, rebuild api, arq-worker, and ingest-worker together. All three are built from the api image and pin the same revision; rebuilding one alone leaves the others crash-looping on a revision mismatch after the next restart.

Two more rules that corrupt a shared stack

Section titled “Two more rules that corrupt a shared stack”

Never docker compose down -v. It wipes named volumes, including Postgres data, object-store data, and any acceptance data that is expensive to recreate. Rebuild a single service instead: docker compose build web && docker compose up -d web.

Run both ruff format and ruff check before pushing — CI runs them as separate gates, and a format pass does not imply a lint pass.