Gateway Service
The gateway is the single public entry point of the LIS. It authenticates callers, exposes REST CRUD for sessions and memory facts, and acts as an authenticated bidirectional WebSocket relay to three upstream services. It is an ASGI app (gateway_service.main:app) built by build_gateway_app and served on port 8040.
Purpose and port
The gateway follows a hexagonal layout: transport (api/) → application services → ports.py Protocols → adapters (WebSocket clients in services/, psycopg repositories in db/). All REST and WebSocket routers mount under the prefix /api_lis; only GET /health sits outside it.
| Property | Value |
|---|---|
| ASGI target | gateway_service.main:app |
| Run service name | gateway (see run/run.sh) |
| Bind host / port | 0.0.0.0 / 8040 (GATEWAY_HOST / GATEWAY_PORT, legacy aliases MAIN_HOST / MAIN_PORT) |
| Route prefix | /api_lis |
Routes mount under /api_lis, not /api. Some legacy prose in the repository (README.md, ARCHITECTURE.md, LIS_DB_USAGE.md) still cites /api/...; the code in api/app.py uses /api_lis.
Layer map
gateway_service/
├── api/ Transport: FastAPI assembly, auth deps, routers (all mounted under /api_lis)
│ ├── app.py create_gateway_app: lifespan, CORS, logging, exception handlers, router mounts, GET /health
│ ├── dependencies.py Auth: _check_api_key, get_current_user (HTTP), ws_auth (WebSocket)
│ ├── session/sessions.py REST session router: list/create/patch/delete + read messages
│ ├── memory/memory.py REST memory router (prefix "/memory"): list + soft-delete facts
│ └── conversation/… WebSocket router: /sessions/{id}/message, /transcribe, /voiceover
├── application/ Transport-agnostic business services + Protocol ports
│ ├── session_service.py SessionService: session rules, ownership checks, message read
│ ├── memory_service.py MemoryService: thin facade over the memory repository
│ ├── relay_service.py RelayService: resolves session + avatar, builds upstream config, relays
│ └── ports.py Protocol interfaces (repos + upstream WS clients)
├── services/ Outbound WebSocket adapters to upstream services
│ ├── conversation_engine.py ConversationEngineClient (message relay)
│ ├── stt.py STTClient (transcribe relay)
│ ├── tts.py TTSClient (voiceover relay)
│ └── ws.py build_url + bidirectional relay helper
├── db/ Persistence: async pool, Pydantic row models, repositories
│ ├── engine.py Module-global psycopg AsyncConnectionPool + get_conn
│ ├── models.py Avatar, Session, ConversationHistory, MemoryFact
│ └── repositories/ avatar (read-only), session (CRUD), message (read-only), memory (read + soft-delete)
├── errors.py AppError hierarchy + pg_to_repo_errors decorator
├── payloads.py Wire models (request/response + upstream session.start config)
├── config.py Re-export of GATEWAY_CONFIG / DB_CONFIG / APP_CONFIG
├── container.py Dependency wiring (build_gateway_service / build_gateway_app)
└── main.py ASGI entrypoint
Responsibilities
The gateway has three jobs.
- Authentication. Every REST endpoint requires a valid user identity; WebSocket endpoints authenticate from query parameters. A shared API key is checked when configured.
- REST API. CRUD over the caller's sessions (
lis.sessions), read access to conversation history (lis.conversation_history), and read + soft-delete over memory facts (lis.memory_facts). - WebSocket relay. Authenticated, bidirectional relays to the conversation engine, the STT service, and the TTS service. The gateway resolves session ownership and avatar data from the database, injects the resolved configuration, then relays frames verbatim in both directions.
The gateway owns and writes lis.sessions. It reads ml.avatars and lis.conversation_history and reads plus soft-deletes lis.memory_facts. There is no users table and no account endpoint — user identity is a trusted UUID from the request. See Database.
Authentication
Authentication is header-based for HTTP and query-parameter-based for WebSocket. Both paths share _check_api_key, which is fail-open: when the configured key (app.state.api_key, from GATEWAY_API_TOKEN) is empty, the API-key check is skipped entirely.
| Transport | Credentials | On failure |
|---|---|---|
| HTTP | X-Api-Key header (only if a key is configured), X-User-ID header (UUID) | 401 bad/absent key; 400 missing X-User-ID; 400 non-UUID X-User-ID |
| WebSocket | ?api_key= (only if configured), ?user_id= (UUID) | close 4001 bad/missing key; close 4001 missing user_id; close 4003 non-UUID user_id |
require_api_key is defined in dependencies.py but is not attached to any route. HTTP endpoints are guarded by get_current_user, which performs both the API-key check and the X-User-ID validation.
The WebSocket router calls websocket.accept() before ws_auth, so a failing socket is accepted and then closed with a status code rather than rejected at the handshake. For the full credential and close-code reference, see Authentication.
Relay flows
RelayService performs pre-relay orchestration for each of the three WebSocket endpoints, then hands the socket to the matching upstream client. All identifiers are relative to the /api_lis prefix.
Message → conversation engine
WS /api_lis/sessions/{session_id}/message relays to the conversation engine (default ws://localhost:8050/message_pipeline).
- Accept →
ws_auth→ parsesession_id(close4003on bad UUID). - Receive the first text frame and validate it as
UserPreferencesJSON (close4003— "Expected UserPreferences JSON as first message" — on failure). - Load the session; close
4004if it is missing or not owned by the caller. - Load the avatar (
ml.avatarsbyuid); close4004if missing. - Send a single
session.startframe upstream, then relay bidirectionally.
The session.start payload is a ConversationSessionConfig serialized with model_dump(mode="json") (UUIDs become strings). The gateway resolves and injects:
| Field | Source |
|---|---|
session_id, user_id | request |
avatar_id | avatar.avatar_id (text slug) |
avatar_uuid | avatar.uid |
system_prompt | avatar.system_prompt (or None if empty) |
voice_clone_path, voice_clone_text | avatar voice-clone fields |
lora_name | avatar.lora_adapter_path (marked # STUB in code) |
collection_name | avatar.collection_name |
language_hint, memory_injection, memory_extraction, voice_output | client UserPreferences |
elevenlabs_voice_id is not injected. ConversationSessionConfig has no such field and relay_service.py never sets it, contrary to some legacy docs.
Transcribe → STT
WS /api_lis/transcribe is a near pass-through relay to the STT service (default ws://localhost:8010/ws/stt). It takes an optional ?language_hint=. Before connecting, the gateway appends user_id and client_type=transcribe (plus language_hint when provided) to the upstream URL. It resolves no session or avatar.
Voiceover → TTS
WS /api_lis/voiceover relays to the TTS service (default ws://localhost:8014/ws/tts). It requires ?session_id= (close 4003 if missing/invalid). The session is loaded and ownership enforced (close 4004 if missing or not owned).
The avatar lookup here is best-effort: if the avatar row is missing the relay still proceeds with voice_clone_path/voice_clone_text as None. The TTS client intercepts the client's first frame — if it is a JSON synthesis.start event, it injects reference_audio_path/reference_text from the avatar voice-clone fields only when those keys are absent (client-provided values win). A non-JSON first text frame is forwarded verbatim; a first binary frame is forwarded as bytes; an immediate disconnect returns without connecting.
Relay mechanics
The bidirectional relay (ws.relay) runs client_to_upstream and upstream_to_client as two concurrent tasks, waits for FIRST_COMPLETED, cancels the pending task, and closes both sockets. Upstream connection failure is fail-closed: all three clients catch websockets.exceptions.WebSocketException / OSError and close the client socket with code 1014 ("Upstream connection failed").
WebSocket close codes used across the relays:
| Code | Meaning |
|---|---|
4001 | Auth failure (bad/missing api_key, or missing user_id) |
4003 | Malformed id or bad first frame |
4004 | Session not found / not owned, or avatar not found (message relay) |
1014 | Upstream connection failed |
See WebSocket Protocol for the full frame and event reference, and HTTP Endpoints for the REST surface.
Error handling
create_gateway_app registers three exception handlers; routers themselves contain no try/except for these cases.
| Exception | HTTP status | Response body | Logging |
|---|---|---|---|
AppError | exc.status (e.g. NotFoundError → 404) | {"error": code, "message": public_message} | ERROR only when status ≥ 500 |
RepositoryError | 500 | {"error": "internal_error", "message": "An internal error occurred."} | ERROR + traceback |
Exception | 500 | {"error": "internal_error", "message": "An internal error occurred."} | ERROR + traceback |
AppError subclasses map to fixed statuses: NotFoundError (404), UnauthorizedError (401), ForbiddenError (403), ConflictError (409), ValidationError (422), InternalError (500), ServiceUnavailableError (503).
Repository methods are wrapped by the @pg_to_repo_errors decorator, which converts psycopg exceptions into the RepositoryError family before they reach the handler:
UndefinedTable → TableNotFoundError
UniqueViolation / ForeignKeyViolation → ConstraintViolationError
OperationalError → DBUnavailableError
(any other Exception) → RepositoryError
All of these render as a generic HTTP 500. This wrapping is applied only in the gateway's repositories.
Dependency wiring
container.py performs all construction. build_gateway_service instantiates the four repositories, the three upstream WebSocket clients (from GATEWAY_CONFIG URLs), and the three application services; build_gateway_app then calls create_gateway_app to assemble the FastAPI app.
session_service, memory_service, relay_service, app = build_gateway_app(
enable_db=True,
api_key=None, # falls back to GATEWAY_CONFIG.api_key
)
Every repository and upstream client is an optional constructor argument, so tests can inject fakes and run with enable_db=False. The DB pool lifecycle is tied to the app lifespan: open_pool() on startup and close_pool() on shutdown, only when enable_db is True (from APP_CONFIG.enable_db_on_startup, default true).
GET /health performs a 2s httpx GET to {engine_http_url}/health and returns status="ok" only when the conversation engine reports status == "ok", otherwise "degraded". Only the conversation engine is probed — STT, TTS, and the DB pool are not checked.
Configuration
| Setting | Env var (aliases) | Default | Purpose |
|---|---|---|---|
engine_http_url | CHAT_ENGINE_HTTP_URL (LIVE_HTTP_URL) | http://localhost:8050 | Base URL probed by /health |
engine_ws_url | CHAT_ENGINE_WS_URL (LIVE_WS_URL) | ws://localhost:8050/message_pipeline | Upstream WS for the message relay |
stt_ws_url | STT_WS_URL | ws://localhost:8010/ws/stt | Upstream WS for the transcribe relay |
tts_ws_url | TTS_WS_URL | ws://localhost:8014/ws/tts | Upstream WS for the voiceover relay |
api_key | GATEWAY_API_TOKEN | "" (empty) | Shared API key; empty disables the key check |
proxy_timeout_sec | GATEWAY_PROXY_TIMEOUT_SEC (MAIN_PROXY_TIMEOUT_SEC) | 120.0 | Declared but unused in gateway code |
host / port | GATEWAY_HOST / GATEWAY_PORT (MAIN_HOST / MAIN_PORT) | 0.0.0.0 / 8040 | Bind address and port |
enable_db_on_startup | ENABLE_DB_ON_STARTUP | true | Whether the lifespan opens the DB pool |
pool_max_size | DB_POOL_MAX | 10 | AsyncConnectionPool max size (min size fixed at 0) |
Database connection settings (PGHOST, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE, PGCHANNELBINDING) are documented with the shared data layer in Database.