Skip to main content

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.

PropertyValue
ASGI targetgateway_service.main:app
Run service namegateway (see run/run.sh)
Bind host / port0.0.0.0 / 8040 (GATEWAY_HOST / GATEWAY_PORT, legacy aliases MAIN_HOST / MAIN_PORT)
Route prefix/api_lis
warning

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.

TransportCredentialsOn failure
HTTPX-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
note

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).

  1. Accept → ws_auth → parse session_id (close 4003 on bad UUID).
  2. Receive the first text frame and validate it as UserPreferences JSON (close 4003 — "Expected UserPreferences JSON as first message" — on failure).
  3. Load the session; close 4004 if it is missing or not owned by the caller.
  4. Load the avatar (ml.avatars by uid); close 4004 if missing.
  5. Send a single session.start frame 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:

FieldSource
session_id, user_idrequest
avatar_idavatar.avatar_id (text slug)
avatar_uuidavatar.uid
system_promptavatar.system_prompt (or None if empty)
voice_clone_path, voice_clone_textavatar voice-clone fields
lora_nameavatar.lora_adapter_path (marked # STUB in code)
collection_nameavatar.collection_name
language_hint, memory_injection, memory_extraction, voice_outputclient UserPreferences
note

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:

CodeMeaning
4001Auth failure (bad/missing api_key, or missing user_id)
4003Malformed id or bad first frame
4004Session not found / not owned, or avatar not found (message relay)
1014Upstream 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.

ExceptionHTTP statusResponse bodyLogging
AppErrorexc.status (e.g. NotFoundError → 404){"error": code, "message": public_message}ERROR only when status ≥ 500
RepositoryError500{"error": "internal_error", "message": "An internal error occurred."}ERROR + traceback
Exception500{"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

SettingEnv var (aliases)DefaultPurpose
engine_http_urlCHAT_ENGINE_HTTP_URL (LIVE_HTTP_URL)http://localhost:8050Base URL probed by /health
engine_ws_urlCHAT_ENGINE_WS_URL (LIVE_WS_URL)ws://localhost:8050/message_pipelineUpstream WS for the message relay
stt_ws_urlSTT_WS_URLws://localhost:8010/ws/sttUpstream WS for the transcribe relay
tts_ws_urlTTS_WS_URLws://localhost:8014/ws/ttsUpstream WS for the voiceover relay
api_keyGATEWAY_API_TOKEN"" (empty)Shared API key; empty disables the key check
proxy_timeout_secGATEWAY_PROXY_TIMEOUT_SEC (MAIN_PROXY_TIMEOUT_SEC)120.0Declared but unused in gateway code
host / portGATEWAY_HOST / GATEWAY_PORT (MAIN_HOST / MAIN_PORT)0.0.0.0 / 8040Bind address and port
enable_db_on_startupENABLE_DB_ON_STARTUPtrueWhether the lifespan opens the DB pool
pool_max_sizeDB_POOL_MAX10AsyncConnectionPool 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.