Files
LLM-Server/docker-compose.yml
T
haylanandClaude Bot 8ae32102f9 feat(open-webui): add chat+image frontend, bypass omniroute for comfyui
OmniRoute's built-in comfyui provider hardcodes a CheckpointLoaderSimple
(single-file-checkpoint) workflow with no override (confirmed by
reading open-sse/handlers/imageGeneration/providers/comfyUI.ts
upstream), so it can't drive Qwen-Image 2.1's split unet/text-encoder/
vae GGUF loader chain. Open WebUI's own ComfyUI image backend is fully
generic instead — any exported workflow_api.json with arbitrary node
IDs mapped to parameters — so it can point straight at comfyui:8188.

Chat still goes through omniroute like every other client, via a
per-workload key (OPENWEBUI_OMNIROUTE_KEY, minted by hand same as
always). Published on host port 8008 per user request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 23:28:44 +02:00

449 lines
21 KiB
YAML

services:
llama-server:
image: ghcr.io/ggml-org/llama.cpp:server-rocm
container_name: llama-server
devices:
- /dev/kfd
- /dev/dri
# Numeric GIDs, not names — see HOST_VIDEO_GID/HOST_RENDER_GID in
# .env.example and docs/research/rocm-gpu-pin-and-render-group.md.
group_add:
- "${HOST_VIDEO_GID:?run scripts/update.sh first to resolve this}"
- "${HOST_RENDER_GID:?run scripts/update.sh first to resolve this}"
security_opt:
- seccomp=unconfined
ipc: host
# Caps this process's HIP hardware-queue allocation — works around
# ROCm/ROCm#5706 (GPU pinned at 100%/boost-clock whenever two
# concurrent HIP contexts touch this card). See the research doc above.
environment:
- GPU_MAX_HW_QUEUES=1
volumes:
- models:/models
command: >
-m /models/${LLAMA_MODEL_FILE:-Qwen3.8-27B-UD-Q4_K_XL.gguf}
--host 0.0.0.0
--port 8080
--n-gpu-layers ${LLAMA_GPU_LAYERS:-999}
--ctx-size ${LLAMA_CTX_SIZE:-262144}
--parallel ${LLAMA_PARALLEL:-2}
--flash-attn on
--cache-type-k q8_0
--cache-type-v q8_0
--cache-reuse 256
--jinja
# --cache-reuse 256: reuse cached KV for any matching prompt chunk of at
# least 256 tokens (KV-shift, no reprocessing) instead of reprefilling
# from scratch every request. Directly targets the actual root cause
# behind the OmniRoute non-ping SSE timeout, not just the symptom — see
# docs/research/omniroute-non-ping-sse-stream-timeout.md. Pairs with
# OmniRoute's promptCacheAffinityEnabled (dashboard default), which keeps
# a conversation's requests pinned to the same slot so there's a matching
# prefix to reuse.
# No published host port: llama-server is reached only via the omniroute
# gateway on the ai-stack docker network now — see issue #15. Its
# unauthenticated API no longer needs to be LAN-reachable directly.
expose:
- "8080"
restart: unless-stopped
networks: [ai-stack]
labels:
# ponytail: idle-timeout tuning lives here, not in a separate lazytainer config file —
# one place to look. Raise LAZYTAINER_INACTIVE_TIMEOUT if 15 min proves too eager.
- "lazytainer.group.llamaserver.sleepMethod=stop"
- "lazytainer.group.llamaserver.ports=8080"
- "lazytainer.group.llamaserver.inactiveTimeout=${LAZYTAINER_INACTIVE_TIMEOUT:-900}"
- "lazytainer.group.llamaserver.minPacketThreshold=2"
# Dedicated backend for qwen-code's tool-call harmfulness classifier
# (fastModel in ~/.qwen/settings.json). Was aliased onto llama-server's own
# 27B connection — every classification call then queued behind whatever
# heavy generation was already running on that model's 2 GPU slots (issue
# tracker: OmniRoute semaphore/pr-agent investigation).
#
# Tried CPU-only first (own process avoids the GPU queue entirely) — too
# slow in practice: real classification calls blew past OmniRoute's 60s
# timeout and retry-looped (504→499→504...). Moved to GPU instead.
#
# Context sizing: qwen-code's classifier transcript is hard-capped in its
# own source (MAX_TRANSCRIPT_MESSAGES=40, MAX_HISTORICAL_ACTION_CHARS=4000
# per message, packages/core/src/permissions/classifier-transcript.ts) —
# worst case is ~40-50K tokens, nowhere near the 131072 originally set in
# settings.json (that number was copied from the main model's entry, not
# a real qwen-code requirement). 65536 ctx gives ~1.5x margin over that
# worst case. Qwen3-4B-Instruct-2507 is still the model choice — smallest
# Qwen3 with long native context (262144) without RoPE-scaling, in case
# that margin ever needs to grow.
#
# VRAM: weights+KV math (~4.9GiB) predicted comfortable headroom in the
# ~6.1GiB free on the R9700, but measured live it actually used ~5.85GiB —
# left only ~700MB free, too tight. Dropping --batch-size/--ubatch-size
# barely moved it (~768MB free) — wrong lever. Actual cause: llama-server
# runs with --flash-attn on but this service was missing it — without
# flash attention the unfused attention compute buffer at 65536 ctx is
# much larger (roughly O(n^2) intermediate buffers vs flash-attn's fused,
# near-linear workspace), dwarfing the naive weights+KV estimate. Added
# --flash-attn on to match llama-server; re-verify with rocm-smi after
# deploy before trusting any of these numbers again. GPU_MAX_HW_QUEUES=1
# carried over from llama-server's comment above — same ROCm/ROCm#5706
# clock-pinning bug applies now that two HIP contexts (this +
# llama-server) share the card.
#
# --reasoning off is a no-cost safety net, not a confirmed-needed fix:
# ggml-org/llama.cpp#20809 (closed) documents some server builds
# misdetecting Qwen3-Instruct-2507 models as thinking models, routing
# tool-call output into reasoning_content instead of tool_calls — exactly
# the failure mode that ruled out the 27B model for this role in the
# first place. Whether the current image build still has it was never
# independently confirmed (see docs/research/fast-model-choice.md §4/§6).
qwen-classifier:
image: ghcr.io/ggml-org/llama.cpp:server-rocm
container_name: qwen-classifier
devices:
- /dev/kfd
- /dev/dri
group_add:
- "${HOST_VIDEO_GID:?run scripts/update.sh first to resolve this}"
- "${HOST_RENDER_GID:?run scripts/update.sh first to resolve this}"
security_opt:
- seccomp=unconfined
ipc: host
environment:
- GPU_MAX_HW_QUEUES=1
volumes:
- models:/models
command: >
-m /models/${LLAMA_CLASSIFIER_MODEL_FILE:-Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf}
--host 0.0.0.0
--port 8080
--n-gpu-layers 28
--ctx-size 65536
--parallel 1
--batch-size 512
--ubatch-size 128
--flash-attn on
--reasoning off
--cache-type-k q4_0
--cache-type-v q4_0
--jinja
expose:
- "8080"
restart: unless-stopped
networks: [ai-stack]
# ponytail: one-off downloader, not a standing service — run via
# `docker compose --profile tools run --rm downloader`. Folded into
# scripts/update.sh, which runs this every time; the `test -f` guard is
# what makes that safe to re-run without re-downloading. Keeps the model
# files inside the named `models` volume instead of a host bind-mount.
downloader:
image: curlimages/curl:latest
profiles: ["tools"]
# ponytail: named volume is created root-owned; curl_user (uid 100) can't
# write into it otherwise, so run as root for this one-off job.
user: root
volumes:
- models:/models
entrypoint: ["sh", "-c"]
command:
- >
test -f /models/${LLAMA_MODEL_FILE:-Qwen3.8-27B-UD-Q4_K_XL.gguf} &&
echo "already downloaded, skipping" ||
curl -L --fail --create-dirs -o /models/${LLAMA_MODEL_FILE:-Qwen3.8-27B-UD-Q4_K_XL.gguf}
https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/${LLAMA_MODEL_FILE:-Qwen3.8-27B-UD-Q4_K_XL.gguf}
# Same pattern as downloader above, separate service so this one small
# file doesn't get re-checked/re-pulled by the big model's job.
downloader-classifier:
image: curlimages/curl:latest
profiles: ["tools"]
user: root
volumes:
- models:/models
entrypoint: ["sh", "-c"]
command:
- >
test -f /models/${LLAMA_CLASSIFIER_MODEL_FILE:-Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf} &&
echo "already downloaded, skipping" ||
curl -L --fail --create-dirs -o /models/${LLAMA_CLASSIFIER_MODEL_FILE:-Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf}
https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/main/${LLAMA_CLASSIFIER_MODEL_FILE:-Qwen3-4B-Instruct-2507-UD-Q4_K_XL.gguf}
# Fetches the three Qwen-Image 2.1 (uncensored GGUF) files ComfyUI needs
# (diffusion model, text encoder, VAE) — same test -f guard pattern as
# downloader above. See docs/research/image-generation-model-choice.md and
# issue #42.
#
# GGUF weights go in models/unet (ComfyUI-GGUF's own convention), not
# models/diffusion_models (that was the old FP8-safetensors layout) — see
# github.com/city96/ComfyUI-GGUF. Text encoder/VAE stay safetensors, loaded
# by ComfyUI's normal CLIPLoader/VAELoader, so their folders are unchanged.
#
# ponytail: target paths otherwise assume ComfyUI's standard models/ layout
# under BASE_STORAGE_PATH (/storage) — same "not independently confirmed
# against the image's Dockerfile" caveat flagged on the comfyui service
# below. If ComfyUI doesn't pick these up, check its actual models root
# first.
downloader-comfyui:
image: curlimages/curl:latest
profiles: ["tools"]
user: root
volumes:
- comfyui-data:/storage
entrypoint: ["sh", "-c"]
command:
- >
mkdir -p /storage/models/unet /storage/models/text_encoders /storage/models/vae &&
(test -f /storage/models/unet/${COMFYUI_DIFFUSION_MODEL_FILE:-qwen-image-2.1-UC-Q4_K_M.gguf} &&
echo "diffusion model already downloaded, skipping" ||
curl -L --fail --create-dirs -o /storage/models/unet/${COMFYUI_DIFFUSION_MODEL_FILE:-qwen-image-2.1-UC-Q4_K_M.gguf}
https://huggingface.co/abenzerps/Qwen-Image-2.1-Uncensored-GGUF/resolve/main/${COMFYUI_DIFFUSION_MODEL_FILE:-qwen-image-2.1-UC-Q4_K_M.gguf}) &&
(test -f /storage/models/text_encoders/${COMFYUI_TEXT_ENCODER_FILE:-qwen3vl_8b_int8_convrot.safetensors} &&
echo "text encoder already downloaded, skipping" ||
curl -L --fail --create-dirs -o /storage/models/text_encoders/${COMFYUI_TEXT_ENCODER_FILE:-qwen3vl_8b_int8_convrot.safetensors}
https://huggingface.co/abenzerps/Qwen-Image-2.1-Uncensored-GGUF/resolve/main/text_encoders/${COMFYUI_TEXT_ENCODER_FILE:-qwen3vl_8b_int8_convrot.safetensors}) &&
(test -f /storage/models/vae/${COMFYUI_VAE_FILE:-qwen_image_2.1_vae_bf16.safetensors} &&
echo "vae already downloaded, skipping" ||
curl -L --fail --create-dirs -o /storage/models/vae/${COMFYUI_VAE_FILE:-qwen_image_2.1_vae_bf16.safetensors}
https://huggingface.co/abenzerps/Qwen-Image-2.1-Uncensored-GGUF/resolve/main/vae/${COMFYUI_VAE_FILE:-qwen_image_2.1_vae_bf16.safetensors})
# Local image generation — see issue #38 (wayfinder map). yurisasc's image
# is gfx1201-tuned specifically (R9700's arch), unlike the official/AMD
# ComfyUI image which doesn't pin RDNA4 support — see
# docs/research/image-generation-options.md.
#
# Model is Qwen-Image 2.1 GGUF (uncensored variant, see .env.example) —
# needs the ComfyUI-GGUF custom node (github.com/city96/ComfyUI-GGUF) for
# its "Unet Loader (GGUF)" node. Not confirmed pre-installed in this image;
# if missing, exec in and `git clone` it into custom_nodes, then pip
# install its requirements.txt and restart the container.
comfyui:
image: yurisasc/comfyui-rocm7.1:latest
container_name: comfyui
devices:
- /dev/kfd
- /dev/dri
# Numeric GIDs, not names — see HOST_VIDEO_GID/HOST_RENDER_GID in
# .env.example and docs/research/rocm-gpu-pin-and-render-group.md.
group_add:
- "${HOST_VIDEO_GID:?run scripts/update.sh first to resolve this}"
- "${HOST_RENDER_GID:?run scripts/update.sh first to resolve this}"
security_opt:
- seccomp=unconfined
ipc: host
environment:
- HSA_OVERRIDE_GFX_VERSION=12.0.1
- PYTORCH_ROCM_ARCH=gfx1201
volumes:
- comfyui-data:/storage
# yurisasc/comfyui-rocm7.1 has no entrypoint/CMD of its own (just drops
# into bash) and doesn't read PUID/PGID/VIDEO_GID/RENDER_GID or
# BASE_STORAGE_PATH — confirmed by inspecting the image directly, so
# this command is required, not optional, or the container exits 0
# immediately and restart-loops. Runs as root (image's only user);
# group_add above is what actually grants /dev/kfd + /dev/dri access.
#
# --base-directory fully replaces ComfyUI's custom_nodes/input/output/
# user/temp dirs with ones under /storage (confirmed in folder_paths.py)
# rather than adding to the image's baked-in ones — so first boot must
# seed /storage/custom_nodes from the image's copy (ComfyUI-Manager
# lives there) or it's gone, and main.py hard-crashes if custom_nodes
# doesn't exist yet at all.
entrypoint: ["sh", "-c"]
command:
- >
mkdir -p /storage/custom_nodes /storage/input /storage/output /storage/user &&
[ -z "$(ls -A /storage/custom_nodes)" ] && cp -r /workspace/ComfyUI/custom_nodes/. /storage/custom_nodes/ || true;
exec python main.py --listen 0.0.0.0 --port 8188 --base-directory /storage
#
# Published host port (unlike llama-server's ai-stack-only pattern):
# ComfyUI's own UI is meant to be reachable directly too, for a planned
# external nginx reverse-proxy route to comfy.home — not just through
# OmniRoute. Still also reachable at http://comfyui:8188 internally on
# ai-stack, which is the URL to register as OmniRoute's comfyui
# provider (dashboard or POST /api/providers, per docs/proxy-key-onboarding.md
# — same undocumented-in-repo manual flow already used for llama-server).
ports:
- "8138:8188"
restart: unless-stopped
networks: [ai-stack]
# Replaces litellm — see issue #31 (wayfinder map) for the full migration
# rationale/findings. No static config.yaml equivalent: provider routing
# (llama-server, searxng-search) is registered once through the dashboard
# or POST /api/providers after first boot, not checked into this repo —
# see docs/proxy-key-onboarding.md.
omniroute:
image: diegosouzapw/omniroute:latest
container_name: omniroute
depends_on:
llama-server:
condition: service_started
volumes:
- omniroute-data:/app/data
env_file: .env
environment:
# Split-port mode: dashboard and API are fully separate ports (unlike
# LiteLLM's single :4000 for both /v1 and /ui) — both published
# directly below, unlike the old :4000-only host mapping.
- API_HOST=0.0.0.0
- API_PORT=${OMNIROUTE_API_PORT:-20129}
- DASHBOARD_PORT=${OMNIROUTE_DASHBOARD_PORT:-20128}
# Required to register llama-server/searxng-search as providers —
# their base URLs are LAN/container-internal addresses, blocked by
# default (SSRF guard against public-provider spoofing).
- OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true
- OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS=true
# Required (production) per docs/reference/ENVIRONMENT.md — shared
# secret for the internal Codex Responses WebSocket bridge. Missed on
# first pass; docker-compose config validated fine without it, but
# the docs are explicit this one's required, not optional.
- OMNIROUTE_WS_BRIDGE_SECRET=${OMNIROUTE_WS_BRIDGE_SECRET}
# Default heap (1024MB) is dashboard-only sized per OmniRoute's own
# Docker guide — every client here is a coding CLI, which needs the
# larger figure the guide recommends. Paired with mem_limit below.
- OMNIROUTE_MEMORY_MB=8192
# Default 300000 (5 min) per OmniRoute's own docs, but this deployment
# had it dialed down elsewhere (dashboard) to ~95s — too tight for a
# contended local llama-server: large-context prefill under multiple
# concurrent slots can outrun that before the first SSE token arrives,
# so OmniRoute cancels a request that was actually still working (see
# LLAMA_PARALLEL above for the other half of this fix). Raised here so
# it's tracked in git instead of a dashboard-only setting.
- STREAM_IDLE_TIMEOUT_MS=${OMNIROUTE_STREAM_IDLE_TIMEOUT_MS:-180000}
# Different timer than STREAM_IDLE_TIMEOUT_MS above — that one only
# bounds gaps *between* SSE chunks once streaming has started.
# REQUEST_TIMEOUT_MS bounds the wait for the *first* non-ping SSE
# event, and it's what was still firing ("Stream produced no non-ping
# SSE event within 95000ms") the morning after the timeout above was
# raised — see docs/research/omniroute-non-ping-sse-stream-timeout.md.
# Default 600000 (10 min) per OmniRoute's own docs, but the effective
# deadline is remaining budget after retries/cooldowns eat into it, not
# a flat timer, so raised well past the default for headroom.
- REQUEST_TIMEOUT_MS=${OMNIROUTE_REQUEST_TIMEOUT_MS:-1800000}
# Same reasoning as litellm's extra_hosts entry below — ai-stack's bridge
# network can't resolve search.home on its own.
extra_hosts:
- "search.home:${SEARXNG_LAN_IP}"
ports:
- "${OMNIROUTE_API_PORT:-20129}:${OMNIROUTE_API_PORT:-20129}"
- "${OMNIROUTE_DASHBOARD_PORT:-20128}:${OMNIROUTE_DASHBOARD_PORT:-20128}"
# 10+ GiB ceiling per OmniRoute's Docker guide, matching
# OMNIROUTE_MEMORY_MB=8192 above.
mem_limit: 10g
# SQLite WAL needs time to checkpoint back into the main DB file on
# shutdown — the Docker guide's --stop-timeout 40 equivalent.
stop_grace_period: 40s
restart: unless-stopped
networks: [ai-stack]
# ponytail: TCP-connect check, not an HTTP /healthz GET — the image has
# no python3/curl/wget (confirmed live, `which` found only node), and
# OmniRoute's own Docker guide already treats a bare TCP probe on this
# port as an acceptable liveness check, not just the HTTP one. Simpler
# and avoids depending on /healthz's exact path/response shape.
healthcheck:
test:
- CMD-SHELL
- node -e "require('net').connect(${OMNIROUTE_API_PORT:-20129},'localhost').on('connect',function(){this.end();process.exit(0)}).on('error',()=>process.exit(1))"
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
lazytainer:
image: ghcr.io/vmorganp/lazytainer:master
container_name: lazytainer
# NOT network_mode: host — lazytainer identifies its own container by
# matching os.Hostname() against the Docker container-ID list
# (vmorganp/Lazytainer, configureFromLabels()); under host networking the
# container inherits the host's hostname instead of its own ID, so that
# match always fails and it panics with "Could not determine container ID
# of lazytainer" on every start. Host networking also can't see traffic
# to llama-server:8080 anyway — that port only exists on the ai-stack
# bridge network (no host port published, see issue #15 above). Joining
# ai-stack instead fixes both: hostname becomes the real container ID,
# and it's on the same network as the traffic it's watching.
networks: [ai-stack]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
depends_on:
- llama-server
# RAG vector store — see docs/agents/... (wayfinder). Dashboard UI published
# directly like comfyui above, not gatewayed through omniroute (it isn't an
# LLM provider).
qdrant:
image: qdrant/qdrant:latest
container_name: qdrant
volumes:
- qdrant-data:/qdrant/storage
ports:
- "6333:6333"
restart: unless-stopped
networks: [ai-stack]
# RAG graph store, native vector index too (can absorb qdrant's job later
# if the two-DB split proves unnecessary — see wayfinder notes).
neo4j:
image: neo4j:5-community
container_name: neo4j
environment:
- NEO4J_AUTH=neo4j/${NEO4J_PASSWORD:?run scripts/update.sh first to resolve this}
volumes:
- neo4j-data:/data
ports:
- "7474:7474" # browser UI
- "7687:7687" # bolt
restart: unless-stopped
networks: [ai-stack]
# Chat + image-generation frontend. Talks to ComfyUI directly for images —
# NOT through omniroute: OmniRoute's built-in `comfyui` provider hardcodes
# a CheckpointLoaderSimple (single-file-checkpoint) workflow in
# open-sse/handlers/imageGeneration/providers/comfyUI.ts with no override,
# so it can't drive Qwen-Image 2.1's split unet/text-encoder/vae GGUF
# loader chain. Open WebUI's ComfyUI backend is fully generic instead —
# you upload the exact workflow_api.json exported from ComfyUI's own
# graph editor and map arbitrary node IDs to parameters.
#
# Chat backend still goes through omniroute (OpenAI-compatible), same
# gateway every other client uses — mint OPENWEBUI_OMNIROUTE_KEY by hand
# per docs/proxy-key-onboarding.md, same as any other workload.
#
# ponytail: only wires the base ComfyUI URL + chat backend via env vars.
# COMFYUI_WORKFLOW/COMFYUI_WORKFLOW_NODES env vars exist but are reported
# flaky upstream — set the actual workflow JSON by hand in Settings >
# Images in the web UI instead, once you've exported it from ComfyUI.
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
depends_on:
comfyui:
condition: service_started
omniroute:
condition: service_started
environment:
- OPENAI_API_BASE_URL=http://omniroute:${OMNIROUTE_API_PORT:-20129}/v1
- OPENAI_API_KEY=${OPENWEBUI_OMNIROUTE_KEY:-}
- ENABLE_IMAGE_GENERATION=True
- IMAGE_GENERATION_ENGINE=comfyui
- COMFYUI_BASE_URL=http://comfyui:8188
- WEBUI_AUTH=True
volumes:
- open-webui-data:/app/backend/data
ports:
- "8008:8080"
restart: unless-stopped
networks: [ai-stack]
networks:
ai-stack:
volumes:
models:
omniroute-data:
comfyui-data:
qdrant-data:
neo4j-data:
open-webui-data: