Deployment¶
Lethe runs as a self-hosted Docker Compose stack of nine services. This page covers the service topology, model requirements, configuration, and development workflow. For the software architecture and iteration cycle, see their respective pages.
Operational characteristics:
- Multi-user queue --- requests are enqueued in a FIFO queue and processed one at a time. Callers receive immediate streaming responses with real-time queue position updates and heartbeats. The queue depth is configurable (
LETHE_QUEUE_SIZE, default 64). - API key authentication --- Bearer token auth with per-key concurrency limits (
LETHE_PER_KEY_MAX_QUEUED, default 3). When no keys are configured, auth is disabled for development. - GPU sharing --- the reasoning model (~17 GiB), embedding model (~2.4 GiB), and DeBERTa NLI (~0.4 GiB int8) share one GPU via ROCm. Peak VRAM: ~23.7 GiB of 24 GiB, with ~300 MiB margin managed by sub-batching and KV cache quantization.
- CPU services --- Whisper runs CPU-only (no VRAM).
- Session graph --- each session creates a fresh Neo4j graph that persists after completion. The graph is the audit trail of the agent's reasoning process.
- Streaming --- responses stream via Server-Sent Events with sequence-numbered events and resumable reconnection. Multi-hour sessions survive proxy drops and network interruptions. The client receives real-time iteration progress, and the full answer arrives after the assembly stage completes.
- Dual API --- both the Open Responses API (
/v1/responses) and OpenAI Chat Completions API (/v1/chat/completions) are supported, making Lethe compatible with any OpenAI-compatible client (Open WebUI, LobeChat, LibreChat, ChatBox, NextChat, AnythingLLM, etc.).
Service Topology¶
flowchart TB
subgraph client ["Client"]
App["OpenAI Client<br/><small>any compatible library</small>"]
end
subgraph stack ["Docker Compose Stack"]
Lethe["lethe<br/><small>:8000</small><br/>Python app server"]
LLM["llama-cpp<br/><small>:8080</small><br/>Reasoning LLM (GPU)"]
Embed["llama-cpp-embed<br/><small>:8090</small><br/>Embedding model (GPU)"]
Neo["neo4j<br/><small>:7474 :7687</small><br/>Graph database"]
SearX["searxng<br/><small>:8888</small><br/>Metasearch engine"]
Cloak1["cloak-1<br/><small>:9222</small><br/>Stealth Chromium"]
Cloak2["cloak-2<br/><small>:9223</small><br/>Stealth Chromium"]
Cloak3["cloak-3<br/><small>:9224</small><br/>Stealth Chromium"]
Whisper["whisper<br/><small>:9095</small><br/>Speech-to-text (CPU)"]
NLI["nli<br/><small>:8070</small><br/>DeBERTa NLI (GPU int8)"]
Tor1["tor-1<br/><small>:9050</small><br/>Tor SOCKS5"]
Tor2["tor-2<br/><small>:9050</small><br/>Tor SOCKS5"]
Tor3["tor-3<br/><small>:9050</small><br/>Tor SOCKS5"]
end
App -->|"HTTP"| Lethe
Lethe -->|"HTTP"| LLM
Lethe -->|"HTTP"| Embed
Lethe -->|"Bolt"| Neo
Lethe -->|"HTTP"| SearX
Lethe -->|"CDP"| Cloak1
Lethe -->|"CDP"| Cloak2
Lethe -->|"CDP"| Cloak3
Lethe -->|"HTTP"| Whisper
Lethe -->|"HTTP"| NLI
Cloak1 -->|"SOCKS5"| Tor1
Cloak2 -->|"SOCKS5"| Tor2
Cloak3 -->|"SOCKS5"| Tor3
All services share a Docker network. The Lethe app service depends on Neo4j, both llama.cpp instances, SearXNG, and CloakBrowser — it waits for their health checks before starting.
Services¶
lethe (Application Server)¶
| Property | Value |
|---|---|
| Port | 8000 |
| Image | Built from root Dockerfile |
| Base | python:3.12-slim |
| Entry | uv run lethe |
| Dependencies | ffmpeg (video pipeline) |
Exposes these endpoints:
POST /v1/responses— Open Responses API (resumable streaming, background execution)GET /v1/responses/{id}— Reconnect to an in-progress response streamPOST /v1/responses/{id}/cancel— Cancel a queued or in-progress responsePOST /v1/chat/completions— OpenAI Chat Completions compatibility shimGET /v1/queue— View current queue positions (scoped to caller's API key)POST /v1/reset— Clear session graph (also cancels queued items for that session)GET /v1/models— Lists available modelsGET /health— Health check (no auth required)
FIFO queue: requests are processed sequentially by a single worker. When the queue is full or per-key limits are reached, new requests receive HTTP 429.
llama-cpp (Reasoning LLM)¶
| Property | Value |
|---|---|
| Port | 8080 |
| Image | ghcr.io/ggml-org/llama.cpp (ROCm build) |
| GPU | AMD ROCm (configurable via HSA_OVERRIDE_GFX_VERSION) |
| Context | 32,768 tokens |
| KV cache | Q8_0 quantization (both K and V) |
| Format | Qwen3 hybrid reasoning (thinking mode) |
The reasoning model runs in thinking mode — it produces extended reasoning traces before the final structured JSON response. The thinking budget is dynamically adjusted by the budget manager based on task complexity.
llama-cpp-embed (Embedding Model)¶
| Property | Value |
|---|---|
| Port | 8090 |
| Image | Same llama.cpp ROCm image |
| GPU | Shared with reasoning model (same device) |
| Dimensions | 2560 |
| Context | 1,024 tokens |
| Pooling | Last token |
Embedding requests use instruct prefixes (document or query) following the
Qwen3 embedding convention. The CoalescingEmbedClient driver batches
concurrent single-embedding requests with a 50ms debounce window.
neo4j (Graph Database)¶
| Property | Value |
|---|---|
| Ports | 7474 (HTTP), 7687 (Bolt) |
| Image | neo4j:5.26-community |
| Auth | neo4j/lethe-dev (password) |
| Database | neo4j (default) |
Neo4j stores the task graph, evidence nodes, and edge relationships. At session start, only task nodes are cleared (reset_tasks) — evidence nodes and SIMILAR_TO edges persist across sessions, building cumulative knowledge. All graph algorithms (PPR, cycle detection, similarity sync) are implemented in application code using NumPy rather than via Neo4j's GDS plugin. This avoids a plugin dependency and keeps the deployment to the Community edition, at the cost of transferring node embeddings to the application for computation.
searxng (Metasearch)¶
| Property | Value |
|---|---|
| Port | 8888 → 8080 |
| Image | searxng/searxng |
| Config | config/searxng/settings.yml |
Custom configuration enables curated search engines across general, academic, discussion, video, and news categories. Rate limiting is disabled for development use.
CloakBrowser Pool (Stealth Browser × 3)¶
| Property | Value |
|---|---|
| Services | cloak-1 (:9222), cloak-2 (:9223), cloak-3 (:9224) |
| Image | cloakhq/cloakbrowser |
| Protocol | Chrome DevTools Protocol (CDP) |
| Tor | Each instance routes through its own Tor daemon (tor-1/2/3) |
Three stealth Chromium instances with independent Tor circuits provide
24 concurrent rendering slots (8 tabs × 3 instances). Playwright
connects via CDP through the BrowserPool round-robin distributor.
whisper (Speech-to-Text)¶
| Property | Value |
|---|---|
| Port | 9095 (localhost only) |
| Image | Built from config/whisper/Dockerfile |
| Binary | whisper.cpp server |
Multi-stage build compiles whisper.cpp from source with ffmpeg and OpenMP support. Transcribes audio extracted from video content during research. The whisper service is not in Lethe's depends_on chain --- if it starts slowly or is unavailable, video transcription silently degrades while text-based research continues unaffected.
nli (DeBERTa NLI)¶
| Property | Value |
|---|---|
| Port | 8070 (localhost only) |
| Image | Built from config/nli/Dockerfile (ROCm PyTorch base) |
| Model | DeBERTa-v3-large-mnli-fever-anli-ling-wanli (435M params) |
| Device | GPU (int8 via bitsandbytes, ~0.4 GiB VRAM) |
Bidirectional entailment classification for SIMILAR_TO edge enrichment. Each evidence pair is classified in both directions; the 6 raw probabilities reduce to 3 scores (support, contradiction, confidence) that feed the NLI kernel in PPR transitions. Runs on GPU with int8 quantization (bitsandbytes) and adaptive sub-batching that halves batch size on OOM. Highest-similarity edges are enriched first when the NLI cap per iteration is reached.
proxy-pool (Tor)¶
| Property | Value |
|---|---|
| Port | 9050 (SOCKS5, internal only) |
| Image | Built from config/proxy-pool/Dockerfile |
| Circuit isolation | Per-domain SOCKS auth |
Tor SOCKS5 proxy used by CloakBrowser and the fallback HTTP crawler for anonymized web fetching. SearXNG does not route through Tor --- it queries search engines directly. Circuit isolation prevents cross-domain tracking.
Models¶
Lethe requires two GGUF model files, placed in the .models/ directory
(gitignored):
| Model | File | Purpose | Recommended Quantization |
|---|---|---|---|
| Reasoning | Qwen 3.6 27B | Task decomposition, synthesis | Q4_K_XL or Q4_K_M |
| Embedding | Qwen3 Embedding 4B | All vector operations | Q4_K_M |
Model paths are configured via environment variables LLM_MODEL and
EMBEDDING_MODEL, which Docker Compose maps to volume mounts.
Configuration¶
All configuration is managed through environment variables with the LETHE_
prefix. The .env.example file documents every setting with defaults.
The most commonly adjusted deployment variables are:
| Variable | Default | Description |
|---|---|---|
LETHE_NEO4J_URI |
bolt://localhost:7687 |
Neo4j Bolt endpoint |
LETHE_LLM_URL |
http://localhost:8080 |
Reasoning model endpoint |
LETHE_EMBED_URL |
http://localhost:8090 |
Embedding model endpoint |
LETHE_SEARXNG_URL |
http://localhost:8888 |
SearXNG endpoint |
LETHE_BUDGET_ITERATIONS |
50 | Soft iteration target |
LETHE_WALL_CLOCK_TIMEOUT_S |
7200 | Wall-clock soft pressure input (seconds) |
For the complete parameter reference --- budget tuning, embedding algebra
thresholds, research engine settings, and validation rules --- see
Configuration. All settings are validated and frozen
at startup via Config.from_env(). Before each session, the server probes
/health on both model endpoints; if either is unreachable, the session
fails immediately with a clear error.
Development¶
Prerequisites¶
- Python 3.12+
- uv package manager
- Docker and Docker Compose (for the full stack)
- ROCm-compatible AMD GPU (for reasoning model) or adapted llama.cpp build
Setup¶
# Install Python dependencies
uv sync --all-extras
# Copy and configure environment
cp .env.example .env
# Edit .env with your Neo4j credentials and model paths
# Download GGUF models to .models/
# (see model table above for recommended files)
# Start infrastructure services
docker compose up -d
# Install Playwright browser (for local development outside Docker)
uv run playwright install chromium
Quality Gates¶
make check # Run all gates: lock + format + lint + typecheck + boundary-check + test
make fix # Auto-format and auto-fix lint issues
make test # Run unit tests only (excludes integration and LLM markers)
| Gate | Tool | What It Checks |
|---|---|---|
| Lock | uv lock --check |
Dependency lock file is current |
| Format | ruff format --check |
Code formatting |
| Lint | ruff check |
Style, imports, type annotations |
| Typecheck | pyright --strict |
Static type analysis |
| Boundary | make boundary-check |
Architectural import rules (P1-P11) |
| Test | pytest -x --tb=short |
Unit tests (non-integration, non-LLM) |
Project Layout¶
reasoning/
├── src/lethe/ # Main package (11 subpackages, 56 modules)
├── tests/ # 11 test modules (124 test cases)
├── config/ # Service configurations (SearXNG, whisper)
├── docs/ # This documentation (Zensical)
├── .env.example # Environment variable template
├── docker-compose.yaml # Full 9-service stack
├── Dockerfile # Lethe app container
├── Makefile # Quality gate automation
├── pyproject.toml # Package metadata and tool config
└── zensical.toml # Documentation site config
API Usage¶
Lethe exposes two compatible API endpoints:
- Open Responses API (
/v1/responses) — full-featured resumable streaming with background execution, queue position visibility, and cancellation. - Chat Completions API (
/v1/chat/completions) — standard OpenAI-compatible interface for any chat application (Open WebUI, LobeChat, LibreChat, ChatBox, NextChat, AnythingLLM, etc.).
Both endpoints require Bearer token authentication (unless LETHE_API_KEYS is empty).
Authentication¶
All API endpoints (except /health) require a Bearer token:
Generate keys for your .env:
Chat Completions API (Universal Compatibility)¶
This is the endpoint to use with Open WebUI and other chat applications:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="YOUR_KEY")
response = client.chat.completions.create(
model="lethe",
messages=[{"role": "user", "content": "Analyze Aave V3 security"}],
stream=True,
)
for chunk in response:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")
curl -N -X POST http://localhost:8000/v1/chat/completions \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "lethe",
"messages": [
{"role": "system", "content": "Format with academic citations"},
{"role": "user", "content": "Analyze the security model of Aave V3"}
],
"stream": true
}'
The Chat Completions shim auto-generates an isolated session_id per request. For session continuity, pass X-Session-Id header.
Creating a Response (Streaming)¶
import httpx
async with httpx.AsyncClient(timeout=httpx.Timeout(30, read=None)) as client:
async with client.stream(
"POST",
"http://localhost:8000/v1/responses",
json={
"model": "lethe",
"input": [
{"role": "user", "content": "Analyze the security model of Aave V3"}
],
"instructions": "Format with academic citations",
"stream": True,
"background": True,
"session_id": "my-session-001",
},
headers={"Authorization": "Bearer YOUR_KEY"},
) as resp:
async for line in resp.aiter_lines():
if line.startswith("data: "):
print(line[6:])
curl -N -X POST http://localhost:8000/v1/responses \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "lethe",
"input": [
{"role": "user", "content": "Analyze the security model of Aave V3"}
],
"stream": true,
"background": true,
"session_id": "my-session-001"
}'
Resuming a Disconnected Stream¶
If the connection drops, reconnect using the response ID and last seen sequence number:
curl -N -H "Authorization: Bearer YOUR_KEY" \
"http://localhost:8000/v1/responses/resp_abc123?stream=true&starting_after=15"
The server replays all events after sequence number 15, then continues streaming live events. This makes multi-hour sessions resilient to transient network failures.
Event Wire Format¶
Each SSE event includes a type and sequence number:
event: response.created
data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_...","status":"queued"}}
event: response.in_progress
data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_...","status":"in_progress"}}
event: lethe:progress
data: {"type":"lethe:progress","sequence_number":3,"detail":"Iteration 12/50 — processing evidence"}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":5,"delta":"...content..."}
event: response.completed
data: {"type":"response.completed","sequence_number":42,"response":{"id":"resp_...","status":"completed"}}
data: [DONE]
The lethe:progress events provide real-time visibility into the research
loop (iteration counts, headings being written, sources found). These use
the Open Responses vendor-prefix extension mechanism.
Non-Streaming (Synchronous)¶
Set "stream": false to block until the response completes:
curl -X POST http://localhost:8000/v1/responses \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "lethe",
"input": [{"role": "user", "content": "Analyze Aave V3 security"}],
"stream": false,
"session_id": "my-session-001"
}'
Message Role Semantics¶
The system processes the last user message as the research goal --- the content that drives task decomposition, embedding algebra, and convergence. System messages are separated and preserved as output formatting instructions for the final composition LLM call only; they never enter the embedding algebra or influence research navigation.
Streaming Resilience¶
The server emits SSE comment heartbeats (: ping) every 15 seconds during
idle periods. This prevents Nginx and other reverse proxies from killing
the connection due to read timeouts. Combined with sequence-numbered events
and the GET /v1/responses/{id} reconnection endpoint, multi-hour sessions
survive proxy restarts, network blips, and client reconnections without
losing any data.
Response state is held in-memory for 30 minutes after completion, providing a generous window for late reconnections. After TTL expiry, the response buffer is garbage-collected.
Concurrency¶
The server uses an in-memory FIFO queue with a configurable capacity (LETHE_QUEUE_SIZE, default 64). A single worker drains the queue sequentially — one research session at a time — ensuring exclusive access to the LLM, embedding model, and Neo4j database. Multiple clients can submit requests concurrently; each receives an immediate streaming response that shows queue position, then transitions to live progress when the job starts.
Per-key limits (LETHE_PER_KEY_MAX_QUEUED, default 3) prevent any single client from monopolizing the queue. When the queue is full, HTTP 429 is returned.
The GET /v1/queue, GET /v1/models, and GET /health endpoints are always available regardless of queue state.
Queue Management¶
Check queue status:
Response shows all queued items with positions and ownership:
{
"queue": [
{"position": 1, "response_id": "resp_abc...", "status": "in_progress", "own": true},
{"position": 2, "response_id": "resp_def...", "status": "queued", "own": false}
],
"total": 2
}
Cancel a queued or in-progress response:
curl -X POST -H "Authorization: Bearer YOUR_KEY" \
http://localhost:8000/v1/responses/resp_abc123/cancel
Open WebUI Configuration¶
To use Lethe with Open WebUI, add it as an OpenAI-compatible connection:
- Go to Admin Panel → Connections → OpenAI API
- Set Base URL:
http://lethe:8000/v1 - Set API Key: one of your
LETHE_API_KEYSvalues - The
lethemodel will appear in the model selector
Open WebUI uses the Chat Completions API, which Lethe fully supports including streaming, system prompts, and proper SSE formatting.
Nginx Reverse Proxy Configuration¶
When deploying Lethe behind Nginx, use the following configuration to support long-lived SSE streams:
location /v1/responses {
proxy_pass http://lethe:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Disable buffering — SSE events must flow immediately
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
add_header X-Accel-Buffering no;
# 24h read timeout — server heartbeats every 15s keep the connection alive;
# this is just a safety net for truly idle connections
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /v1/chat/completions {
proxy_pass http://lethe:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
add_header X-Accel-Buffering no;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /v1/ {
proxy_pass http://lethe:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
}
location /health {
proxy_pass http://lethe:8000;
proxy_read_timeout 5s;
}
Key points:
- proxy_buffering off and X-Accel-Buffering no ensure SSE events are not
buffered by Nginx and reach the client immediately.
- proxy_read_timeout 86400s (24 hours) prevents Nginx from killing the
connection during long research sessions. The server's 15-second heartbeats
ensure the connection is never truly idle from Nginx's perspective.
- proxy_http_version 1.1 with an empty Connection header enables HTTP
keep-alive between Nginx and the upstream, allowing proper SSE delivery.
Troubleshooting¶
| Symptom | Cause | Resolution |
|---|---|---|
| HTTP 429 on every request | Queue is full or per-key limit reached | Check GET /v1/queue for current state; increase LETHE_QUEUE_SIZE or LETHE_PER_KEY_MAX_QUEUED; cancel stalled jobs via POST /v1/responses/{id}/cancel |
| HTTP 401 | Invalid or missing API key | Set Authorization: Bearer <key> header; verify key is in LETHE_API_KEYS env var |
ConnectionRefusedError to Neo4j |
Neo4j not ready or wrong URI | Check docker compose logs neo4j for startup; verify LETHE_NEO4J_URI matches the Bolt port (7687) |
CUDA out of memory / ROCm allocation failure |
Total VRAM exceeds GPU capacity (~22.7 GB for LLM+embed+NLI) | Reduce context size in llama-cpp flags, or use a smaller quantization |
| Embedding requests timeout (300s) | Large batch with slow GPU, or llama-cpp-embed not responding | Check docker compose logs llama-cpp-embed; increase embed.timeout_s in config; verify GPU is assigned |
| Research returns no evidence | SearXNG or CloakBrowser not reachable | curl http://localhost:8888/search?q=test&format=json to test SearXNG; check CloakBrowser logs for CDP errors |
| PDF content missing from evidence | CloakBrowser attempting to render PDFs in Chrome viewer | Verify BrowserClient direct-HTTP PDF path is working; check the page URL is a valid PDF |
| Video transcription absent | whisper service not running or unreachable | Expected if whisper is slow to start; text-based research continues. Check docker compose logs whisper |
| Session terminates after 1--2 iterations | Convergence threshold too low or minimum iterations too low | Raise convergence_term_threshold (default 0.82) via LETHE_CONVERGENCE_TERM_THRESHOLD or raise LETHE_MIN_ITERS_CONVERGENCE (default 5); check if the question is too narrow |
For GPU compatibility beyond AMD ROCm, rebuild the llama.cpp images with
the appropriate backend (CUDA, Vulkan, Metal). The HSA_OVERRIDE_GFX_VERSION
environment variable in docker-compose.yaml is AMD-specific and should
be removed or replaced for other GPU vendors.