Development - hugalafutro/model-hotel GitHub Wiki
This guide covers the complete development workflow for the Model Hotel multi-provider LLM gateway.
Docker and Docker Compose are the only hard requirement: the dev stack builds the frontend and the Go binary inside the image. The rest matter when you build or lint outside the container.
| Tool | Version | Purpose |
|---|---|---|
| Docker & Docker Compose | Latest | The dev stack, the test database, and production deployment |
| Go | 1.27+ | Backend runtime (required by go.mod) |
| Node.js | 24+ | Frontend build tooling (CI uses Node 24; the Dockerfile uses node:26-alpine) |
| pnpm | 12.11.2 | Frontend package manager (the packageManager version in web/package.json; self-switching is off, install it yourself) |
| PostgreSQL | 16 | Database (Compose uses postgres:16-alpine) |
| golangci-lint | v2.14+ | Go linting (CI pins v2.14) |
| Python 3 | 3.x | The i18n gate, the coverage scripts, and the pre-push gate |
One repository, three shipped deliverables: the Model Hotel gateway (cmd/server/ + web/), the
Front Desk HA control plane (cmd/frontdesk/ + frontdesk/web/), and the Bellhop Android
companion app (android/).
model-hotel/
├── cmd/
│ ├── server/ # Gateway entry point
│ │ ├── main.go # Server setup, middleware chain, graceful shutdown
│ │ ├── startup.go # Boot sequence
│ │ ├── background.go # Background loops (housekeeping, retention)
│ │ ├── discovery.go # Scheduled provider/model discovery
│ │ ├── middleware.go # Auth, CORS, security headers
│ │ ├── banner.go # Startup banner
│ │ ├── spa.go # SPA static file serving
│ │ └── static/ # Embedded frontend build output
│ └── frontdesk/ # Front Desk control plane entry point
├── internal/ # Backend packages (private; full list under Backend Development)
├── web/ # React + TypeScript dashboard
│ ├── src/
│ │ ├── api/ # API client (client.ts, http.ts, endpoints/) + types
│ │ ├── assets/ # Provider logos
│ │ ├── components/ # Reusable UI components
│ │ ├── context/ # React contexts (Theme, Toast, Event, Identity, Storage, Sidebar, QuotaModal)
│ │ ├── data/ # Static presets
│ │ ├── hooks/ # Custom React hooks
│ │ ├── i18n/ # i18next setup + 29 locale files (the repo is the source of truth)
│ │ ├── lib/ # Icon registry and shared helpers
│ │ ├── pages/ # Dashboard, Providers, Models, etc.
│ │ ├── test/ # Vitest setup and shared test helpers
│ │ └── utils/ # Formatting, SSE, model utils
│ ├── public/ # Static assets (favicon, icons)
│ ├── index.html # HTML entry point
│ ├── vite.config.ts # Vite configuration
│ ├── vitest.config.ts # Test + coverage configuration
│ ├── biome.json # Biome formatter/linter config
│ ├── eslint.config.js # ESLint config
│ ├── postcss.config.js # PostCSS pipeline (Tailwind 4 + autoprefixer)
│ └── package.json # Dependencies + scripts
├── web-shared/ # Frontend modules both SPAs import via @web-shared/*
├── frontdesk/web/ # Front Desk SPA (its own locales and tests)
├── android/ # Bellhop Android companion (Kotlin/Compose, Gradle)
├── deploy/ha/ # Front Desk + HA Compose stack
├── scripts/ # Git hooks, size gate, coverage and sharded test runners
├── tools/ # i18n translate/check, notices generator, dev tooling
├── wiki/ # This wiki (published by sync-wiki.yml)
├── .github/workflows/ # 12 workflows; ci.yml is the main pipeline
├── docker-compose.yml # Base stack (app + db)
├── compose.dev.yml # Dev overlay (Docker socket, DEBUG_LOG, ALLOW_EMBED)
├── docker-compose.test.yml # Ephemeral test database (port 5433)
├── Dockerfile # Multi-stage build (Node 26 -> Go 1.27 -> Alpine)
├── Dockerfile.frontdesk # Front Desk image
├── Makefile # Build/test/lint/docker/i18n targets
└── go.mod # Go module definition
The dev stack is the supported path. It builds the frontend and the Go binary inside the image, so nothing but Docker has to be installed to get a running gateway.
git clone https://github.com/hugalafutro/model-hotel.git
cd model-hotel
make setup # points core.hooksPath at scripts/ (git hooks)cp .env.example .envGenerate the required secrets and paste them into .env:
openssl rand -base64 32 # MASTER_KEY (AES-256-GCM key for provider API keys)
openssl rand -hex 16 # POSTGRES_PASSWORD
openssl rand -hex 16 # ADMIN_TOKEN (optional; one is generated on first boot if empty)MASTER_KEY and POSTGRES_PASSWORD are mandatory: Compose refuses to start without them.
Never commit .env, it is gitignored.
make docker-upThat runs docker compose -f docker-compose.yml -f compose.dev.yml up -d, which starts the app
and PostgreSQL 16 together. The dev overlay compose.dev.yml adds the pieces you want locally and
not in production: the Docker socket mounted read-only (container stats in the sidebar),
DEBUG_LOG=true, ALLOW_EMBED=true (so the dashboard loads in a workspace browser), and
proxy/observability variables read from .env. Every make docker-* target passes both files, so
the overlay is never accidentally dropped.
Open http://localhost:8081 (change with HOST_PORT in .env). If you left ADMIN_TOKEN
empty, the generated token is printed once to stdout on first boot: make docker-logs.
The frontend is embedded in the Go binary, so both Go and web/src/ changes need a rebuild:
make docker-build # down, then up -d --build
make docker-logs # follow logs from all services
make docker-down # stop the stackdocker compose restart app does not pick up frontend changes.
For fast UI iteration with hot module replacement:
cd web
pnpm install
pnpm dev # http://localhost:5173Note: The dashboard calls the API on its own origin (
API_BASEis empty inweb/src/api/http.ts) andvite.config.tsconfigures no API proxy, so the dev server serves the UI but cannot reach the backend. Use the Docker stack on:8081for anything that talks to the API.
make run builds bin/server and runs it on the host. The Compose database publishes no host
port, so the binary needs a PostgreSQL you can reach from the host: your own local install, or any
other instance. Point DATABASE_URL in .env at it, then:
go mod tidy
make run # make build + ./bin/serverThe binary listens on PORT, which defaults to :8080, not on HOST_PORT. HOST_PORT only maps
the container port in Compose.
The backend uses Go 1.27 with the module github.com/hugalafutro/model-hotel. All internal packages live under internal/ and are not importable by external modules.
Entry points: cmd/server/ (the gateway: middleware chain, discovery loops, graceful shutdown)
and cmd/frontdesk/ (the Front Desk control plane).
The 40 packages under internal/:
| Package | Responsibility |
|---|---|
admin |
Admin token management (SHA-256 hashed) |
adminauth |
Admin authentication HTTP surface shared by both control planes |
alert |
Outbound notifications for noteworthy gateway events (Apprise) |
anthropic |
Anthropic Messages wire format, inbound |
anthropicegress |
OpenAI to Anthropic translation, outbound |
api |
HTTP handlers and routing for the admin API (REST + SSE) |
audit |
Audit trail: one row per mutating admin action |
auth |
AES-256-GCM encryption of provider API keys, key caching |
authcookie |
Session auth over hardened cookies |
budget |
Per-period dollar budgets for virtual keys and users, summed from request_logs.cost_usd
|
clientip |
Client address resolution behind proxies |
config |
Environment configuration loading and defaults |
ctxkeys |
Type-safe context keys |
db |
PostgreSQL connection pool, migrations, test-database helpers |
debuglog |
log/slog wrapper honouring DEBUG_LOG
|
egress |
Pieces the vendor dialect translators share |
endpointtype |
The request_logs.endpoint_type vocabulary shared by the proxy and the admin API |
events |
SSE event bus (pub/sub) |
failover |
Failover group management, caching, circuit breaker |
frontdesk |
The HA Front Desk control plane |
gemini |
Google Gemini wire-format translation |
httpx |
HTTP response helpers shared by the admin surfaces |
jsonfault |
JSON decode failures rendered as safe diagnostics |
metrics |
Prometheus metrics on a private registry |
model |
Model entity, repository, discovery cache |
netguard |
SSRF defenses for outbound HTTP |
openairesponses |
OpenAI Responses API translation |
otelexport |
Wires the slog pipeline to an OpenTelemetry OTLP exporter |
paramrewrite |
Shared parameter rewriting for provider dialects |
provider |
Provider CRUD, API clients, model discovery |
proxy |
OpenAI-compatible /v1/chat/completions proxy |
pwned |
Have I Been Pwned range check for new passwords |
quota |
Cached provider quota/usage snapshots |
ratelimit |
Token-bucket rate limiting middleware |
settings |
Database-backed settings with cache and change subscriptions |
totp |
TOTP (RFC 6238) second factor |
user |
Dashboard user accounts for multi-user support |
util |
Helpers (Docker stats, system utilities) |
virtualkey |
Virtual key CRUD (SHA-256 hashed) |
webauthn |
WebAuthn/FIDO2 passkey credentials and sessions |
# Development run (builds then runs)
make run
# Build only
make build
./bin/serverBoth go through the Makefile so the version and commit are stamped into the binary. The binary
listens on PORT (default :8080) and needs a PostgreSQL reachable from the host, which the
Compose database is not.
# All tests (needs the test database, below)
make test
# Same packages, sharded across concurrent processes
make test-parallel
# Single package
go test ./internal/proxy/...
# CI-equivalent
go test -timeout 10m ./...-timeout is per package, not for the whole run, and internal/api is by far the largest:
roughly 1,450 tests against a real PostgreSQL. CI allows 10 minutes for it, and 20 for the
separate race-detector job.
Integration tests need PostgreSQL. Use the ephemeral test database:
make test-db-up # starts docker-compose.test.yml on port 5433
make test
make test-db-down # stop and remove itdocker-compose.test.yml runs a throwaway testdb with no volume and durability turned off
(fsync=off), which is why it is fast and why that config must never be copied into
docker-compose.yml. Port 5433 keeps it clear of the app's own database, so both can run at once.
Test patterns:
- Unit tests: no database, plain Go tests
- Integration tests:
db.SetupTestDB("<pkg>")inTestMaingives the package its own database (derived fromTEST_DATABASE_URL, dropped and recreated on every run); deferdb.CleanupTestDB("<pkg>"). Per-package databases are what let packages run concurrently without deadlocking each other. Seeinternal/db/testdb.go. - Handler tests:
newTestHandler(t)frominternal/api/handler_test_helpers_test.go, which truncates the tables and flushes every process-global cache fed by them.
TEST_DATABASE_URL defaults to the local test database; CI sets it to
postgres://modelhotel:...@localhost:5433/testdb?sslmode=disable.
⚠️ No skipped tests: Tests must pass or fail: nevert.Skip/it.skip/describe.skip/.only/it.todo, and no environment-gated skips that silently no-op in CI. If a test needs an external dependency (PostgreSQL,pg_dump, docker), CI must provide it rather than skip it. Theforbidigolinter enforces the Go half by banningt.Skip/t.Skipfoutright.
make lint # golangci-lint run ./...
make fmt # gci import ordering + go fmt
make size-check # file-size ratchetmake fmt runs gci (import grouping: standard, default, then this module) over internal/
and cmd/, then go fmt ./....
CI pins golangci-lint v2.14. .golangci.yml enables errcheck, govet, ineffassign,
staticcheck, unused, gosec, gocritic, revive, gocyclo, funlen, errorlint,
nilerr, bodyclose, forbidigo (the t.Skip ban) and nolintlint. Because govet runs as
part of it, there is no separate go vet pass in the pre-push hook.
Four ratchets that only ever tighten. Anything over the line gets split rather than exempted:
| Unit | Ceiling | Enforced by |
|---|---|---|
Production file (internal/, cmd/, web/src/, frontdesk/web/src/, web-shared/) |
800 lines | make size-check |
Test file (Go _test.go; TS under __tests__/ or an app's src/test/) |
2000 lines | make size-check |
| Go function (production only) | 200 lines | funlen |
| TS/TSX function (production only) | 500 lines |
max-lines-per-function in both ESLint configs |
Files already over a ceiling carry their current count in scripts/ci/size-allowlist.txt and may
only shrink. The list takes no new entries, and the gate compares it against the base commit's
copy, so loosening it fails the same way in CI as it does locally.
The backend uses internal/debuglog for structured logging. Control logging via the DEBUG_LOG environment variable:
# Enable debug logging
DEBUG_LOG=true ./bin/server
# Disable (production default)
DEBUG_LOG=false ./bin/serverLog levels: Debug, Info, Warn, Error. Debug records are emitted only when DEBUG_LOG=true,
or for the scopes named in DEBUG_LOG_SCOPES (see Request Logging).
Never use fmt.Println in production code.
cd web
pnpm installpnpm dev- Runs on
http://localhost:5173 - Hot module replacement enabled
- Serves the UI only:
API_BASEis empty, so API calls go to the dev server's own origin andvite.config.tssets up no proxy to the backend. Use the Docker stack on:8081to exercise the API.
pnpm build # tsc -b, then vite build
pnpm exec tsc -b # typecheck alone (what the pre-push hook runs)Output is written to web/dist/ and embedded into the Go binary at cmd/server/static/.
The frontend uses both ESLint and Biome. Both must pass in CI, and the pre-commit hook runs Biome over the staged files.
# ESLint (web/ plus web-shared/)
pnpm lint
# Biome format + lint, writing fixes
pnpm format:fix src/components/Foo.tsx
# Biome check only, no writes (this is what CI runs)
pnpm format
⚠️ Neverpnpm biomeorpnpm exec biome. Underpnpm execthe native lint worker is killed and Biome misreports it asLinter process terminated abnormally (possibly out of memory). It is not an out-of-memory condition: Biome checks a file in about 10ms and needs negligible RAM. Run it through theformat/format:fixpackage scripts, or call the binary directly as./node_modules/.bin/biome.
Run both from web/, with paths relative to web/:
- ✅
pnpm format:fix src/components/Foo.tsx- ❌
pnpm format:fix web/src/components/Foo.tsx
# Run tests once
pnpm test
# Watch mode
pnpm test:watch
# Full suite with coverage (about 85 seconds; run it once)
pnpm vitest run --coverageTests use Vitest with jsdom for DOM simulation. Test files live in a __tests__/ directory beside
the code they cover (web/src/components/__tests__/Foo.test.tsx), not alongside the source file.
Coverage output lands in web/coverage/ and by default contains only lcov.info. Add
--coverage.reporter=json-summary (or json) if you need a machine-readable total. CI shards the
suite three ways and merges the shard reports before checking the 90% threshold.
| Directory | Purpose |
|---|---|
src/api/ |
Fetch plumbing (http.ts), per-area endpoints (endpoints/), the assembled api object (client.ts), types (types.ts) |
src/components/ |
Reusable UI components |
src/pages/ |
Top-level pages (Dashboard, Providers, Models, etc.) |
src/context/ |
React contexts (Theme, Toast, Event, Identity, Storage, SidebarMode, QuotaModal) |
src/hooks/ |
Custom hooks |
src/i18n/ |
i18next setup and the 29 locale catalogs |
src/lib/ |
Icon registry and shared helpers |
src/data/ |
Static presets |
src/utils/ |
Helpers (formatting, SSE, model utils) |
Modules shared with the Front Desk SPA live in web-shared/ and are imported through the
@web-shared/* alias. They are linted and coverage-gated as part of web/.
Key patterns:
-
API client:
api/client.ts, a typed facade over thehttp.tsfetch wrapper -
Type safety: all API responses typed via
api/types.ts -
Server state: TanStack Query for fetching and caching; React Context (plus
useReducerwhere the state is complex) for cross-cutting UI state - Routing: React Router
-
Styling: Tailwind CSS v4 with a custom theme, themed through semantic
ui-*classes (below)
Themed UI styling goes through semantic classes defined in web/src/index.css, so a component
declares what an element is and index.css decides how it looks per UI style (clean-saas,
cyber-terminal, glassmorphism-lite) and per light/dark mode:
| Class | Variants |
|---|---|
ui-btn |
-primary, -secondary, -danger
|
ui-badge |
-neutral, -success, -error, -warning, -info, -accent, -orange, -purple, -cyan
|
ui-callout |
-warning (something the operator must not miss), -info (what a control does or sends where); inline notice boxes inside cards |
ui-card, ui-table, ui-tab, ui-toggle
|
(single look each) |
Rules of thumb: never write CSS that targets raw Tailwind utility classes to restyle one component
(such selectors break silently when the component's classes change; add or extend a semantic
variant instead), never put raw palette stacks on ui-btn/ui-badge elements, and have tests
assert the semantic class (toHaveClass("ui-badge-error")), never a color utility. index.css
rules are unlayered and beat Tailwind utilities regardless of specificity, so size and padding set
on a ui-* class cannot be overridden with px-*/text-*.
Every docker-* make target loads the compose files in COMPOSE_FILES. A gitignored Makefile.local can append to it (for example COMPOSE_FILES += -f tools/observability/compose.yml) so a local overlay survives make docker-build. docker-build also trims the Docker daemon's BuildKit cache (all projects on that daemon, not just this one) to its newest 20 GB, since each rebuild leaves about 5 GB behind otherwise.
docker-compose.yml defines two services, plus an optional commented-out apprise container for
outbound alerting:
| Service | Description | Ports | Volumes |
|---|---|---|---|
app |
Full Model Hotel application | ${HOST_PORT:-8081}:8080 |
./.data:/data, plus the Docker socket read-only (commented out in the base file, mounted by compose.dev.yml) |
db |
PostgreSQL 16 | not published to the host | ./.data/pgdata:/var/lib/postgresql/data |
Because db publishes no host port, a process outside Docker cannot reach it. That is why the
local-binary path in the Quick Start needs its own PostgreSQL.
Always go through the Makefile: every target passes both -f docker-compose.yml -f compose.dev.yml, and a raw docker compose command silently drops the dev overlay.
make docker-up # start
make docker-logs # follow logs from all services
make docker-down # stop
make docker-build # down, then up -d --buildThe frontend is embedded in the Go binary, so any change to web/src/ (or to Go code) needs a
full rebuild:
make docker-build
⚠️ Important:docker compose restart appdoes NOT pick up frontend changes. You must rebuild.
The dev stack is a test bed, not a deployment, so rebuild it whenever a change needs verifying. Do
say so in a shared checkout: someone else may be looking at the running instance. Front Desk
changes are a separate build (make ha-up, or make frontdesk-build for a local binary); the app
rebuild does not touch it.
Compose reads .env at the project root, but only the variables it interpolates. MASTER_KEY,
POSTGRES_PASSWORD, ADMIN_TOKEN, HOST_PORT, POSTGRES_USER, POSTGRES_DB, WEBAUTHN_RP_ID
and WEBAUTHN_RP_ORIGINS come from .env:
| Variable | Description | Default |
|---|---|---|
MASTER_KEY |
AES-256-GCM key for provider API keys | required |
POSTGRES_PASSWORD |
Database password | required |
ADMIN_TOKEN |
Admin auth token | Generated on first boot, printed once |
HOST_PORT |
Port published on the host | 8081 |
POSTGRES_USER |
Database user | modelhotel |
POSTGRES_DB |
Database name | modelhotel |
Others are pinned literally in docker-compose.yml (ALLOW_HTTP_PROVIDERS=false,
RATE_LIMIT_ENABLED=true, DEBUG_LOG=false, DATA_DIR=/data, CORS_ORIGINS), so setting them in
.env has no effect on the container: edit the compose file, or override them in the dev overlay.
compose.dev.yml already flips DEBUG_LOG to true and ALLOW_EMBED to true, and passes
KNOWN_PROXIES, TRUSTED_PROXIES, LOG_FORMAT, METRICS_TOKEN and the OTLP variables through
from .env.
.env.example lists every variable the binary itself reads. POSTGRES_HOST and PORT matter only
when running the binary outside Docker, since Compose sets both for the container.
The CI pipeline (.github/workflows/ci.yml) runs on pushes to master and on pull requests
targeting it:
| Job | Description |
|---|---|
Changed Surfaces |
Classifies what the push or PR touched; every job below is gated on its output |
Go Test |
go test -timeout 10m ./... against PostgreSQL 16, then a 90% coverage threshold (cmd/ and tools/ excluded) |
Go Race (N) |
scripts/ci/go-race-shard.sh N 3: go test -race over a third of every large package's tests plus a share of the small packages |
Go Lint |
golangci-lint v2.14 |
Go Vet |
go vet ./... |
Go Vulncheck |
govulncheck, which fails only on vulnerable functions the code actually calls |
i18n Check |
make i18n-check: locale parity, {{placeholder}} parity, plural forms, no non-allowlisted English |
Size Check |
make size-check, including the check that the allowlist only shrank since the base commit |
CrowdSec Hubtest |
Runs the contrib/crowdsec/ collection's cscli hubtest fixtures against a pinned hub commit and CrowdSec engine |
Workflow Lint |
actionlint over the workflow files |
Frontend Lint & Build |
pnpm run lint, pnpm run format (Biome check), pnpm run build
|
Frontend Test (shard n/3) |
The dashboard vitest suite in three parallel shards, each writing a blob report |
Frontend Coverage Gate |
Merges the shard reports and enforces the 90% line threshold |
Front Desk Web Test |
Lint, format check, build, and the Front Desk suite at a 90% threshold |
Coverage Diff Gate |
PR-only: fails if under 90% of the PR's changed lines are covered (backend and both frontends) |
Coverage Aggregate |
master-only: overall coverage, fails below 90%, publishes the shields.io badge JSON to the badges branch |
Third-Party Notices |
Reruns make notices and fails if THIRD-PARTY-NOTICES.md is stale |
Docker Build |
Builds the image, then scans it with Trivy for fixable HIGH/CRITICAL vulnerabilities |
Publish :dev image |
master-only: pushes the :dev images to GHCR and Docker Hub once every gate above has passed |
CI is surface-scoped: gating is per job (if:), never on.paths, so a docs-only change runs no
suites while every required check still reports. The gate fails open, so a bug there wastes a run
rather than waving an untested merge through. Adding a job means adding its gate.
Other workflows cover CodeQL, image scanning, the Android app and its releases, Docker publishing and pruning, the README/Docker Hub and compose-snippet syncs, PR labelling, and publishing this wiki.
make setup points core.hooksPath at scripts/, which installs two check hooks and three
index hooks.
scripts/pre-commit is formatting only, and aims to finish in under ten seconds:
- Refuses the commit if the branch is behind
origin/master -
gciimport ordering on staged.gofiles, re-staging anything it rewrites -
go veton the packages those files belong to -
biome check --writeon stagedweb/**TS/TSX/CSS files, re-staging anything it rewrites
scripts/pre-push is a scoped smoke test. Each step runs only if the surface it protects
changed: the README/DOCKERHUB.md sync guard and Docker Hub length limit, golangci-lint on
./cmd/... ./internal/..., pnpm lint plus tsc -b for web/, and a diff-coverage gate that
fails the push if under 90% of the changed lines are covered. The coverage gate runs whole test
suites and can take minutes; skip just that part with NO_COVERAGE_PREPUSH=1.
Never bypass either hook with --no-verify.
The index hooks keep a local tokensave code graph in step with git. They check nothing, and do nothing to tokensave when it is not installed:
-
scripts/reference-transactionrunstokensave branch addwhen a branch is created andtokensave branch gcwhen one is deleted. -
scripts/post-checkoutacts only when the checkout has a tokensave index. After every branch checkout it runstokensave branch add --if-enabledand thentokensave syncin the background, so each branch's index matches its files. It also prints a one-line warning when tokensave has cached a newer release than the one installed; runtokensave upgradeto clear it. -
scripts/post-commitcalls your globalpost-commithook, if you have one. Becausecore.hooksPathreplaces the global hooks directory, git would otherwise skip it; tokensave's global hook runstokensave syncafter each commit.
Locale files in web/src/i18n/locales/ are the single source of truth; translations are maintained in-repo. The workflow when adding user-facing strings:
- Add the key to
en.jsonAND to all 28 other locales. -
Translate the new keys by hand into each locale, keeping
{{placeholders}},<tags>, acronyms, and brand names verbatim. The quickest correct way is a one-off script that reusestools/i18n-translate/translate.py'sload_locale/set_path/save_localehelpers (preserves nesting + formatting). - Intentionally-English values (brand names, loanwords like "Failover", or a word genuinely identical in some language) belong in
tools/i18n-translate/allow-english.json.
make i18n-check is the CI gate: it runs offline (no network) and fails on missing keys, broken {{placeholder}} parity, or non-allowlisted English values. Translation corrections are welcome as plain PRs against the locale files.
- Make changes to backend or frontend code
-
Backend: Run targeted tests
go test ./internal/proxy/... -
Frontend: Run Biome + ESLint
cd web pnpm format:fix src/components/Changed.tsx pnpm lint -
Integration test: Run the full suite if changes affect multiple packages
make test-db-up go test -timeout 10m ./... -
Docker test: Rebuild and check in the container
make docker-build # Test via http://localhost:8081
-
Debug logging is already on in the dev stack (
compose.dev.ymlsetsDEBUG_LOG=true). Outside Docker:DEBUG_LOG=true ./bin/server.DEBUG_LOG_SCOPES=failover,resolveturns on Debug records for named scopes only. -
Check app logs via API (admin token required):
curl -H "Authorization: Bearer $TOKEN" "http://localhost:8081/api/logs/app?limit=50"
-
Database inspection:
docker compose -f docker-compose.yml -f compose.dev.yml exec db psql -U modelhotel -d modelhotel -
Test proxy endpoints: See "Dev Proxy Endpoints" in
AGENTS.mdfor curl examples, including how to get$TOKEN.
- React DevTools: Install browser extension for component inspection
- Network tab: Monitor API calls in browser dev tools
- Console logs: Check for JavaScript errors
-
SSE events: Monitor
/api/eventsfor real-time updates
| Issue | Solution |
|---|---|
| Biome reports "Linter process terminated abnormally" | You ran pnpm biome or pnpm exec biome. Use pnpm format / pnpm format:fix
|
| Biome fails with config errors | Run from web/, with paths relative to web/
|
| Tests fail with "connection refused" | Start the test database: make test-db-up
|
| Frontend changes not appearing | Rebuild: make docker-build (a restart does not rebuild) |
| Admin token lost |
.data/admin-token holds a SHA-256 hash only, so it cannot be read back. Delete the file and restart: a fresh token is generated and printed once to make docker-logs. To choose the token yourself, set ADMIN_TOKEN in .env before that restart, since the env var only seeds a first boot and is ignored while the file exists |
| Provider discovery fails | Check the logs, verify the provider key decrypts (a changed MASTER_KEY invalidates every stored key) |
| Commit refused as "behind master" |
git fetch origin && git merge origin/master, then retry |
- Open an issue to discuss large changes before implementation
-
Create a feature branch from
master(pull requests only, never push tomaster) -
Install the hooks with
make setupand let them run: never--no-verify - Write tests for new functionality; the diff-coverage gate wants 90% of your changed lines
- Update documentation and locales for user-facing changes
- Submit a pull request
All contributions are licensed under the MIT License. See CONTRIBUTING.md for the full terms.
# Build Docker image
docker build -t model-hotel:latest .
# Run with production environment
docker compose up -d-
Development:
DEBUG_LOG=trueandALLOW_EMBED=true, both set bycompose.dev.yml. A plain HTTP provider (a local Ollama, say) also needsALLOW_HTTP_PROVIDERS=true, which the base compose file pins tofalse, so add it to the overlay. -
Production: the base
docker-compose.ymldefaults are the production ones:DEBUG_LOG=false,ALLOW_HTTP_PROVIDERS=false,ALLOW_EMBED=false, rate limiting on.
Backups the application writes itself land in ./.data/backups/ (DATA_DIR plus backups).
./.data/pgdata/ is PostgreSQL's own data directory, not a backup location.
For a manual dump, use the PostgreSQL tools inside the db container:
# Backup
docker compose exec db pg_dump -U modelhotel modelhotel > backup.sql
# Restore
docker compose exec -T db psql -U modelhotel modelhotel < backup.sqlThe application also supports periodic backup with son/father/grandfather rotation via the Settings UI (Database Backup section). When enabled, backups are created automatically at a configurable interval and old backups are pruned according to daily/weekly/monthly retention tiers. See Configuration for the full list of backup settings.