Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f4e736da8 | ||
|
|
1fcf30e9a1 | ||
|
|
e151aa6ffe | ||
|
|
feb7469f0b | ||
|
|
7a654ead91 | ||
|
|
2bfe6dbd29 | ||
|
|
20f2ec3ab2 | ||
|
|
5b7548dc7c | ||
|
|
ea7b05fb99 | ||
|
|
9def240a8e | ||
|
|
52a92f6508 | ||
|
|
63938e95c9 | ||
|
|
7b6d3f5802 | ||
|
|
386a41200f | ||
|
|
75033dacd7 | ||
|
|
1ee2e76033 | ||
|
|
4b47a1769d | ||
|
|
1932981f09 | ||
|
|
d984c10835 |
+52
-2
@@ -94,5 +94,55 @@ OMNIROUTE_WS_BRIDGE_SECRET=
|
||||
# Resolved automatically from the host by ./scripts/update.sh — leave blank.
|
||||
COMFYUI_PUID=
|
||||
COMFYUI_PGID=
|
||||
COMFYUI_VIDEO_GID=
|
||||
COMFYUI_RENDER_GID=
|
||||
|
||||
# Shared by every GPU-touching service (llama-server, llama-server-fast,
|
||||
# comfyui) for group_add: — resolved to real host GIDs by ./scripts/update.sh
|
||||
# rather than left as plain group names in docker-compose.yml, because Docker
|
||||
# resolves a *named* group_add entry against the container's own /etc/group,
|
||||
# not the host's, and fails unpredictably when the image doesn't define one
|
||||
# (worse with multiple GPU services racing on the same lookup at once — see
|
||||
# docs/research/rocm-gpu-pin-and-render-group.md and issue #5). Leave blank.
|
||||
HOST_VIDEO_GID=
|
||||
HOST_RENDER_GID=
|
||||
|
||||
# --- llama.cpp / fast model (second, always-resident instance — see
|
||||
# docs/research/fast-model-choice.md and issue #44) ---
|
||||
# Qwen3-4B-Instruct-2507: architecturally non-thinking (never emits
|
||||
# <think> blocks, unlike Qwen3-1.7B/0.6B which need a per-call toggle) —
|
||||
# picked specifically so it stays fast enough for qwen-code's Auto Mode
|
||||
# classifier (Stage 1 wants ~300ms). Same publisher (unsloth) as the main
|
||||
# model for consistency.
|
||||
LLAMA_FAST_MODEL_FILE=Qwen3-4B-Instruct-2507-UD-Q8_K_XL.gguf
|
||||
# Same reasoning as LLAMA_GPU_LAYERS above — full GPU offload, this model
|
||||
# is dense too.
|
||||
LLAMA_FAST_GPU_LAYERS=999
|
||||
# --ctx-size is the TOTAL across every LLAMA_FAST_PARALLEL slot, not per
|
||||
# request — same halving already called out for the main model above.
|
||||
# Was PARALLEL=2, silently halving this to 4096/slot — too small: a real
|
||||
# classifier call (hints + environment + recent tool-call history) hit
|
||||
# "exceeds the available context size (4096 tokens)" in practice, which
|
||||
# qwen-code surfaces as "Auto Mode couldn't classify this action
|
||||
# (Classifier stage 1 unavailable)" — see issue #5. Fixed by dropping to
|
||||
# a single slot instead of raising ctx-size (no extra VRAM, and this
|
||||
# service doesn't need concurrent classifier calls the way the main
|
||||
# model needs concurrent chat sessions) — the full 8192 now goes to the
|
||||
# one slot. If hints.allow/softDeny/hardDeny ever approach their
|
||||
# 50-entries-each ceiling, raise LLAMA_FAST_CTX_SIZE instead — qwen-code
|
||||
# caps those at 200 chars x 150 entries plus 40,000 chars of
|
||||
# historical-action context, which can exceed 8192 tokens worst-case.
|
||||
LLAMA_FAST_CTX_SIZE=8192
|
||||
LLAMA_FAST_PARALLEL=1
|
||||
|
||||
# --- ComfyUI diffusion model (Qwen-Image, FP8 — see docs/research/
|
||||
# image-generation-model-choice.md and issue #42) ---
|
||||
# Three files: diffusion weights, text encoder, VAE — all from the official
|
||||
# Comfy-Org FP8 split, chosen specifically because it's the only candidate
|
||||
# with a ComfyUI workflow pre-validated on this exact GPU (gfx1201/R9700).
|
||||
COMFYUI_DIFFUSION_MODEL_FILE=qwen_image_fp8_e4m3fn.safetensors
|
||||
COMFYUI_TEXT_ENCODER_FILE=qwen_2.5_vl_7b_fp8_scaled.safetensors
|
||||
COMFYUI_VAE_FILE=qwen_image_vae.safetensors
|
||||
|
||||
# --- RAG databases (qdrant + neo4j, see wayfinder notes) ---
|
||||
# No auth on qdrant (its default) — same trust boundary as llama-server:
|
||||
# ai-stack is not exposed off-box. Random, filled in automatically:
|
||||
NEO4J_PASSWORD=
|
||||
|
||||
@@ -4,3 +4,5 @@
|
||||
# to be committed to this repo.
|
||||
data/
|
||||
.leankg/
|
||||
.cache/
|
||||
.qwen/temp
|
||||
@@ -14,7 +14,7 @@ See the wayfinder map ([issue #1](https://git.arthurerlich.de/haylan/LLM-Server/
|
||||
|
||||
llama.cpp's own API is internal-only — everything routes through the AI gateway below.
|
||||
|
||||
Pointing Claude Code CLI, Kimi CLI, or OpenCode CLI at the local endpoint: see [`docs/coding-cli-setup.md`](docs/coding-cli-setup.md).
|
||||
Pointing Claude Code CLI, Kimi CLI, OpenCode CLI, or Qwen Code CLI at the local endpoint: see [`docs/coding-cli-setup/`](docs/coding-cli-setup/index.md).
|
||||
|
||||
**Known risk**: Qwen3.8-27B's tool-calling reliability against llama.cpp's Anthropic shim is not yet verified (open upstream parser bugs against its model lineage) — see `docs/research/qwen3.8-27b-tool-calling.md`.
|
||||
|
||||
@@ -26,15 +26,10 @@ An [AI gateway/proxy](https://git.arthurerlich.de/haylan/LLM-Server/issues/9) fr
|
||||
- Dashboard (key/provider management): LAN/host-only, never published to the internet — see `docs/network-access.md`.
|
||||
- Issuing a key for a new workload: [`docs/proxy-key-onboarding.md`](docs/proxy-key-onboarding.md).
|
||||
|
||||
Coding CLIs (see [`docs/coding-cli-setup.md`](docs/coding-cli-setup.md)) route through the gateway — llama-server has no published host port. **Not yet verified**: none of this has been smoke-tested on real hardware yet — see [issue #31](https://git.arthurerlich.de/haylan/LLM-Server/issues/31)'s tickets for the open items (provider registration, per-workload key minting).
|
||||
Coding CLIs (see [`docs/coding-cli-setup/`](docs/coding-cli-setup/index.md)) route through the gateway — llama-server has no published host port. **Not yet verified**: none of this has been smoke-tested on real hardware yet — see [issue #31](https://git.arthurerlich.de/haylan/LLM-Server/issues/31)'s tickets for the open items (provider registration, per-workload key minting).
|
||||
|
||||
**Note on this choice**: OmniRoute's own docs (`docs/security/STEALTH_GUIDE.md`, `MITM-TPROXY-DECRYPT.md`, `PUBLIC_CREDS.md` in its repo) describe shipped features for evading AI-provider client detection, system-wide HTTPS interception via a locally-installed root CA, and hiding credentials from secret scanners. None of that is used by this stack's configuration, but it's a real characteristic of the upstream project — see issue #31's Notes for the full research trail before extending this integration further.
|
||||
|
||||
### Web search
|
||||
|
||||
The gateway also fronts SearXNG-backed web search — see `docs/research/litellm-searxng-search.md` for the original research (still applicable — same standalone-endpoint pattern, see issue #31's #35).
|
||||
|
||||
## What's not here
|
||||
|
||||
- **Open WebUI** — this stack has no chat UI; every client is a coding CLI. Removed rather than kept idle.
|
||||
- **Gateway-level knowledgebase/memory** (`litellm-pgvector`, `pgvector-db`, a dedicated embedding model) — removed as unwanted, unrelated to OmniRoute's own lack of parity with it (see issue #31's #34). Superseded by OmniRoute's own built-in memory feature (opt-in via the dashboard, Settings → Memory): vector store is its bundled `sqlite-vec`, embeddings are a local ONNX model (Transformers.js, ~400MB, downloaded into the `omniroute-data` volume on first use) — no external services, no static config here.
|
||||
|
||||
+144
-10
@@ -5,12 +5,20 @@ services:
|
||||
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:
|
||||
- video
|
||||
- render
|
||||
- "${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, confirmed on real hardware
|
||||
# against llama-server-fast below). See the research doc above.
|
||||
environment:
|
||||
- GPU_MAX_HW_QUEUES=1
|
||||
volumes:
|
||||
- models:/models
|
||||
command: >
|
||||
@@ -39,6 +47,49 @@ services:
|
||||
- "lazytainer.group.llamaserver.inactiveTimeout=${LAZYTAINER_INACTIVE_TIMEOUT:-900}"
|
||||
- "lazytainer.group.llamaserver.minPacketThreshold=2"
|
||||
|
||||
llama-server-fast:
|
||||
image: ghcr.io/ggml-org/llama.cpp:server-rocm
|
||||
container_name: llama-server-fast
|
||||
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
|
||||
# See llama-server's identical setting above — same fix, same bug.
|
||||
environment:
|
||||
- GPU_MAX_HW_QUEUES=1
|
||||
volumes:
|
||||
- models:/models
|
||||
command: >
|
||||
-m /models/${LLAMA_FAST_MODEL_FILE:-Qwen3-4B-Instruct-2507-UD-Q8_K_XL.gguf}
|
||||
--host 0.0.0.0
|
||||
--port 8080
|
||||
--n-gpu-layers ${LLAMA_FAST_GPU_LAYERS:-999}
|
||||
--ctx-size ${LLAMA_FAST_CTX_SIZE:-8192}
|
||||
--parallel ${LLAMA_FAST_PARALLEL:-1}
|
||||
--flash-attn on
|
||||
--cache-type-k q8_0
|
||||
--cache-type-v q8_0
|
||||
--reasoning off
|
||||
--jinja
|
||||
# Second, always-resident llama.cpp instance — small non-thinking model
|
||||
# used as qwen-code's Auto Mode classifier fastModel, alongside the main
|
||||
# 27B instance above. See docs/research/fast-model-choice.md and #44.
|
||||
# Same ai-stack-only pattern as llama-server: no published host port.
|
||||
expose:
|
||||
- "8080"
|
||||
restart: unless-stopped
|
||||
networks: [ai-stack]
|
||||
labels:
|
||||
- "lazytainer.group.llamaserverfast.sleepMethod=stop"
|
||||
- "lazytainer.group.llamaserverfast.ports=8080"
|
||||
- "lazytainer.group.llamaserverfast.inactiveTimeout=${LAZYTAINER_INACTIVE_TIMEOUT:-900}"
|
||||
- "lazytainer.group.llamaserverfast.minPacketThreshold=2"
|
||||
|
||||
# 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
|
||||
@@ -60,6 +111,55 @@ services:
|
||||
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 test -f guard pattern as downloader above — fetches the second,
|
||||
# smaller model for llama-server-fast. See issue #44.
|
||||
downloader-fast:
|
||||
image: curlimages/curl:latest
|
||||
profiles: ["tools"]
|
||||
user: root
|
||||
volumes:
|
||||
- models:/models
|
||||
entrypoint: ["sh", "-c"]
|
||||
command:
|
||||
- >
|
||||
test -f /models/${LLAMA_FAST_MODEL_FILE:-Qwen3-4B-Instruct-2507-UD-Q8_K_XL.gguf} &&
|
||||
echo "already downloaded, skipping" ||
|
||||
curl -L --fail --create-dirs -o /models/${LLAMA_FAST_MODEL_FILE:-Qwen3-4B-Instruct-2507-UD-Q8_K_XL.gguf}
|
||||
https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/main/${LLAMA_FAST_MODEL_FILE:-Qwen3-4B-Instruct-2507-UD-Q8_K_XL.gguf}
|
||||
|
||||
# Fetches the three Qwen-Image FP8 files ComfyUI needs (diffusion model,
|
||||
# text encoder, VAE) — same test -f guard pattern as downloader/
|
||||
# downloader-fast above. See docs/research/image-generation-model-choice.md
|
||||
# and issue #42.
|
||||
#
|
||||
# ponytail: target paths assume ComfyUI's standard models/ layout under
|
||||
# BASE_STORAGE_PATH (/storage) — same "not independently confirmed against
|
||||
# the image's Dockerfile" caveat already 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/diffusion_models /storage/models/text_encoders /storage/models/vae &&
|
||||
(test -f /storage/models/diffusion_models/${COMFYUI_DIFFUSION_MODEL_FILE:-qwen_image_fp8_e4m3fn.safetensors} &&
|
||||
echo "diffusion model already downloaded, skipping" ||
|
||||
curl -L --fail --create-dirs -o /storage/models/diffusion_models/${COMFYUI_DIFFUSION_MODEL_FILE:-qwen_image_fp8_e4m3fn.safetensors}
|
||||
https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/${COMFYUI_DIFFUSION_MODEL_FILE:-qwen_image_fp8_e4m3fn.safetensors}) &&
|
||||
(test -f /storage/models/text_encoders/${COMFYUI_TEXT_ENCODER_FILE:-qwen_2.5_vl_7b_fp8_scaled.safetensors} &&
|
||||
echo "text encoder already downloaded, skipping" ||
|
||||
curl -L --fail --create-dirs -o /storage/models/text_encoders/${COMFYUI_TEXT_ENCODER_FILE:-qwen_2.5_vl_7b_fp8_scaled.safetensors}
|
||||
https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/${COMFYUI_TEXT_ENCODER_FILE:-qwen_2.5_vl_7b_fp8_scaled.safetensors}) &&
|
||||
(test -f /storage/models/vae/${COMFYUI_VAE_FILE:-qwen_image_vae.safetensors} &&
|
||||
echo "vae already downloaded, skipping" ||
|
||||
curl -L --fail --create-dirs -o /storage/models/vae/${COMFYUI_VAE_FILE:-qwen_image_vae.safetensors}
|
||||
https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/${COMFYUI_VAE_FILE:-qwen_image_vae.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
|
||||
@@ -70,23 +170,25 @@ services:
|
||||
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:
|
||||
- video
|
||||
- render
|
||||
- "${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
|
||||
# This image manages GPU-group access via GID env vars rather than
|
||||
# relying solely on group_add above (its own README asks for both) —
|
||||
# scripts/update.sh resolves these from the host, same pattern as
|
||||
# SEARXNG_LAN_IP.
|
||||
# This image also wants GID env vars directly (its own README asks
|
||||
# for both these and group_add above) — same HOST_VIDEO_GID/
|
||||
# HOST_RENDER_GID resolved by scripts/update.sh, shared with
|
||||
# llama-server/llama-server-fast now instead of comfyui-only vars.
|
||||
- PUID=${COMFYUI_PUID}
|
||||
- PGID=${COMFYUI_PGID}
|
||||
- VIDEO_GID=${COMFYUI_VIDEO_GID}
|
||||
- RENDER_GID=${COMFYUI_RENDER_GID}
|
||||
- VIDEO_GID=${HOST_VIDEO_GID}
|
||||
- RENDER_GID=${HOST_RENDER_GID}
|
||||
- BASE_STORAGE_PATH=/storage
|
||||
volumes:
|
||||
- comfyui-data:/storage
|
||||
@@ -118,6 +220,8 @@ services:
|
||||
depends_on:
|
||||
llama-server:
|
||||
condition: service_started
|
||||
llama-server-fast:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- omniroute-data:/app/data
|
||||
env_file: .env
|
||||
@@ -199,6 +303,34 @@ services:
|
||||
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]
|
||||
|
||||
networks:
|
||||
ai-stack:
|
||||
|
||||
@@ -206,3 +338,5 @@ volumes:
|
||||
models:
|
||||
omniroute-data:
|
||||
comfyui-data:
|
||||
qdrant-data:
|
||||
neo4j-data:
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# Pointing a coding-agent CLI at this stack
|
||||
|
||||
This stack routes through the [AI gateway](https://git.arthurerlich.de/haylan/LLM-Server/issues/9) (OmniRoute, see issue #31) rather than talking to llama.cpp directly — llama.cpp's own port is internal-only now (see `docker-compose.yml`). The gateway exposes:
|
||||
|
||||
- **OpenAI-compatible**: `http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1`
|
||||
- **Anthropic Messages API** (OmniRoute's own `/v1/messages` endpoint, translating to the OpenAI-compatible backend): `http://<ai-box>:${OMNIROUTE_PORT:-4000}`
|
||||
|
||||
Both serve the same underlying model — `Qwen3.8-27B-UD-Q4_K_XL.gguf`, registered in the gateway (naming is yours to pick when adding the llama-cpp provider connection — this doc assumes `qwen3.8-27b-local` for continuity) — behind whichever wire format the client speaks.
|
||||
|
||||
`<ai-box>` is this machine's LAN address, or `proxy-ai.home` if your local DNS resolves that hostname directly to the box — see `docs/network-access.md`. If you're running a coding CLI from this machine itself, `localhost` works too.
|
||||
|
||||
**Each CLI needs its own virtual key** — create one per docs/proxy-key-onboarding.md (omniroute's dashboard, `<workload>-<purpose>` naming, e.g. `claude-code-cli`, `kimi-cli`, `opencode-cli`). No budget set by default. These are the machine's interactive/high-priority workloads per `docs/proxy-request-priority.md`.
|
||||
|
||||
> **Read this before relying on it for real work.** Qwen3.8-27B's tool-calling has **documented, open llama.cpp upstream bugs** (parser fails on text before `<tool_call>`, tool calls emitted as inert XML inside thinking blocks — see `docs/research/qwen3.8-27b-tool-calling.md`). Every setup below inherits this risk identically, regardless of which CLI or wire format you use. Don't trust it for unattended multi-step agentic work until you've run the smoke test in [issue #5](https://git.arthurerlich.de/haylan/LLM-Server/issues/5) (and the proxy-specific smoke test in [issue #17](https://git.arthurerlich.de/haylan/LLM-Server/issues/17)).
|
||||
|
||||
## Claude Code CLI
|
||||
|
||||
Claude Code speaks the **Anthropic Messages API** — point it at the gateway's unified endpoint, not llama.cpp directly:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=http://<ai-box>:${OMNIROUTE_PORT:-4000}
|
||||
export ANTHROPIC_API_KEY=<claude-code-cli virtual key>
|
||||
claude
|
||||
```
|
||||
|
||||
Requires llama.cpp's `--jinja` flag (already set in `docker-compose.yml`) — without it, tool-use requests fail outright.
|
||||
|
||||
## Kimi CLI
|
||||
|
||||
Kimi CLI speaks plain **OpenAI Chat Completions**. Configure a provider block in its config file (`config.toml`):
|
||||
|
||||
```toml
|
||||
[providers.openai]
|
||||
type = "openai"
|
||||
base_url = "http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1"
|
||||
api_key = "<kimi-cli virtual key>"
|
||||
```
|
||||
|
||||
If Kimi CLI's response parsing gets confused by Qwen's `<think>...</think>` reasoning tags, check its `reasoning_key` setting — it's configurable for non-standard local server responses.
|
||||
|
||||
## OpenCode CLI
|
||||
|
||||
Confirmed project: **`anomalyco/opencode`** (renamed from `sst/opencode` — don't confuse with the unrelated `opencode-ai/opencode` Go TUI). Docs: https://opencode.ai/docs/
|
||||
|
||||
**Install**:
|
||||
```bash
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
**Config** (`opencode.json`, project root or `~/.config/opencode/opencode.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"aiproxy": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "AI proxy (local)",
|
||||
"options": {
|
||||
"baseURL": "http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1",
|
||||
"apiKey": "<opencode-cli virtual key>"
|
||||
},
|
||||
"models": {
|
||||
"qwen3.8-27b-local": {
|
||||
"name": "Qwen3.8-27B",
|
||||
"limit": { "context": 65536, "output": 8192 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Set `limit.context` to match whatever `LLAMA_CTX_SIZE` this stack is actually running with (`.env`), not a value assumed from the model card — OpenCode uses it for its own context-management bookkeeping, not the server.
|
||||
|
||||
Select the model with `aiproxy/qwen3.8-27b-local`.
|
||||
|
||||
**OpenCode-specific risks** (on top of the shared Qwen3.8-27B tool-calling risk above):
|
||||
- Requires llama.cpp's `--jinja` flag (already set) — without it, OpenCode's unconditional tool-call scaffolding gets a 500.
|
||||
- [anomalyco/opencode#20669](https://github.com/anomalyco/opencode/issues/20669) (closed as "not planned" — a live, unfixed risk): OpenCode's `bash` tool crashes if the model omits the optional `description` field on a tool call; some local backends return `finish_reason: tool_calls` with an empty array, which can hang the agent loop instead of stopping cleanly.
|
||||
- Thinking-mode handling (`options.reasoningEffort`) is undocumented for models that emit inline `<think>` tags rather than a native reasoning API field — expect no effect from that config on this model; untested.
|
||||
|
||||
## Summary
|
||||
|
||||
| CLI | Wire format | Endpoint | Config |
|
||||
|---|---|---|---|
|
||||
| Claude Code | Anthropic Messages | `http://<ai-box>:${OMNIROUTE_PORT:-4000}` | `ANTHROPIC_BASE_URL` env var |
|
||||
| Kimi CLI | OpenAI Chat Completions | `http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1` | `config.toml` provider block |
|
||||
| OpenCode | OpenAI Chat Completions | `http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1` | `opencode.json` provider block |
|
||||
|
||||
Further reading: `docs/research/qwen3.8-27b-tool-calling.md`, `docs/research/opencode-cli-setup.md`, `docs/proxy-key-onboarding.md`.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Claude Code CLI
|
||||
|
||||
[← back to overview](index.md)
|
||||
|
||||
Claude Code speaks the **Anthropic Messages API** — point it at the gateway's unified endpoint, not llama.cpp directly:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=http://<ai-box>:${OMNIROUTE_PORT:-4000}
|
||||
export ANTHROPIC_API_KEY=<claude-code-cli virtual key>
|
||||
claude
|
||||
```
|
||||
|
||||
Requires llama.cpp's `--jinja` flag (already set in `docker-compose.yml`) — without it, tool-use requests fail outright.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Pointing a coding-agent CLI at this stack
|
||||
|
||||
[← back to README](../../README.md)
|
||||
|
||||
This stack routes through the [AI gateway](https://git.arthurerlich.de/haylan/LLM-Server/issues/9) (OmniRoute, see issue #31) rather than talking to llama.cpp directly — llama.cpp's own port is internal-only now (see `docker-compose.yml`). The gateway exposes:
|
||||
|
||||
- **OpenAI-compatible**: `http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1`
|
||||
- **Anthropic Messages API** (OmniRoute's own `/v1/messages` endpoint, translating to the OpenAI-compatible backend): `http://<ai-box>:${OMNIROUTE_PORT:-4000}`
|
||||
|
||||
Both serve the same underlying model — `Qwen3.8-27B-UD-Q4_K_XL.gguf`, registered in the gateway (naming is yours to pick when adding the llama-cpp provider connection — these docs assume `qwen3.8-27b-local` for continuity) — behind whichever wire format the client speaks.
|
||||
|
||||
`<ai-box>` is this machine's LAN address, or `proxy-ai.home` if your local DNS resolves that hostname directly to the box — see `docs/network-access.md`. If you're running a coding CLI from this machine itself, `localhost` works too.
|
||||
|
||||
**Each CLI needs its own virtual key** — create one per docs/proxy-key-onboarding.md (omniroute's dashboard, `<workload>-<purpose>` naming, e.g. `claude-code-cli`, `kimi-cli`, `opencode-cli`). No budget set by default. These are the machine's interactive/high-priority workloads per `docs/proxy-request-priority.md`.
|
||||
|
||||
> **Read this before relying on it for real work.** Qwen3.8-27B's tool-calling has **documented, open llama.cpp upstream bugs** (parser fails on text before `<tool_call>`, tool calls emitted as inert XML inside thinking blocks — see `docs/research/qwen3.8-27b-tool-calling.md`). Every CLI below inherits this risk identically, regardless of wire format. Don't trust it for unattended multi-step agentic work until you've run the smoke test in [issue #5](https://git.arthurerlich.de/haylan/LLM-Server/issues/5) (and the proxy-specific smoke test in [issue #17](https://git.arthurerlich.de/haylan/LLM-Server/issues/17)).
|
||||
|
||||
## Per-CLI setup
|
||||
|
||||
- [Claude Code CLI](claude-code.md)
|
||||
- [Kimi CLI](kimi-cli.md)
|
||||
- [OpenCode CLI](opencode.md)
|
||||
- [Qwen Code CLI](qwen-code.md)
|
||||
|
||||
## Summary
|
||||
|
||||
| CLI | Wire format | Endpoint | Config |
|
||||
|---|---|---|---|
|
||||
| [Claude Code](claude-code.md) | Anthropic Messages | `http://<ai-box>:${OMNIROUTE_PORT:-4000}` | `ANTHROPIC_BASE_URL` env var |
|
||||
| [Kimi CLI](kimi-cli.md) | OpenAI Chat Completions | `http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1` | `config.toml` provider block |
|
||||
| [OpenCode](opencode.md) | OpenAI Chat Completions | `http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1` | `opencode.json` provider block |
|
||||
| [Qwen Code](qwen-code.md) | OpenAI Chat Completions (2 models: chat + `fastModel`) | `http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1` | `~/.qwen/settings.json` `modelProviders.openai` |
|
||||
|
||||
Further reading: `docs/research/qwen3.8-27b-tool-calling.md`, `docs/proxy-key-onboarding.md`.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Kimi CLI
|
||||
|
||||
[← back to overview](index.md)
|
||||
|
||||
Kimi CLI speaks plain **OpenAI Chat Completions**. Configure a provider block in its config file (`config.toml`):
|
||||
|
||||
```toml
|
||||
[providers.openai]
|
||||
type = "openai"
|
||||
base_url = "http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1"
|
||||
api_key = "<kimi-cli virtual key>"
|
||||
```
|
||||
|
||||
If Kimi CLI's response parsing gets confused by Qwen's `<think>...</think>` reasoning tags, check its `reasoning_key` setting — it's configurable for non-standard local server responses.
|
||||
@@ -0,0 +1,45 @@
|
||||
# OpenCode CLI
|
||||
|
||||
[← back to overview](index.md)
|
||||
|
||||
Confirmed project: **`anomalyco/opencode`** (renamed from `sst/opencode` — don't confuse with the unrelated `opencode-ai/opencode` Go TUI). Docs: https://opencode.ai/docs/
|
||||
|
||||
**Install**:
|
||||
```bash
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
**Config** (`opencode.json`, project root or `~/.config/opencode/opencode.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"aiproxy": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "AI proxy (local)",
|
||||
"options": {
|
||||
"baseURL": "http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1",
|
||||
"apiKey": "<opencode-cli virtual key>"
|
||||
},
|
||||
"models": {
|
||||
"qwen3.8-27b-local": {
|
||||
"name": "Qwen3.8-27B",
|
||||
"limit": { "context": 65536, "output": 8192 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Set `limit.context` to the *per-slot* context this stack actually serves — `LLAMA_CTX_SIZE / LLAMA_PARALLEL` from `.env` (262144 / 2 = 131072 by default), not raw `LLAMA_CTX_SIZE` and not a value assumed from the model card: llama.cpp divides `--ctx-size` across concurrent slots, so each request only gets one slot's share. OpenCode uses this for its own context-management bookkeeping, not the server.
|
||||
|
||||
Select the model with `aiproxy/qwen3.8-27b-local`.
|
||||
|
||||
**OpenCode-specific risks** (on top of the shared Qwen3.8-27B tool-calling risk — see [overview](index.md)):
|
||||
- Requires llama.cpp's `--jinja` flag (already set) — without it, OpenCode's unconditional tool-call scaffolding gets a 500.
|
||||
- [anomalyco/opencode#20669](https://github.com/anomalyco/opencode/issues/20669) (closed as "not planned" — a live, unfixed risk): OpenCode's `bash` tool crashes if the model omits the optional `description` field on a tool call; some local backends return `finish_reason: tool_calls` with an empty array, which can hang the agent loop instead of stopping cleanly.
|
||||
- Thinking-mode handling (`options.reasoningEffort`) is undocumented for models that emit inline `<think>` tags rather than a native reasoning API field — expect no effect from that config on this model; untested.
|
||||
|
||||
Further reading: `docs/research/opencode-cli-setup.md`.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Qwen Code CLI
|
||||
|
||||
[← back to overview](index.md)
|
||||
|
||||
Qwen Code speaks plain **OpenAI Chat Completions**, and — unlike the other CLIs — needs *two* models: the main chat model, and a `fastModel` for Auto Mode's action classifier (a separate, always-resident, always-fast instance so classification doesn't queue behind chat prefill; see `docker-compose.yml`'s `llama-server-fast` service and `docs/research/fast-model-choice.md`). Both are registered as separate providers in OmniRoute but reachable through the same gateway URL. Config lives in `~/.qwen/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "<main-model-provider-id-in-omniroute>",
|
||||
"name": "qwen3.8-27b-local",
|
||||
"envKey": "OMNIROUTE_API_KEY",
|
||||
"baseUrl": "http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1",
|
||||
"generationConfig": { "contextWindowSize": 131072 }
|
||||
},
|
||||
{
|
||||
"id": "<fast-model-provider-id-in-omniroute>",
|
||||
"name": "qwen3.8-27b-classifier",
|
||||
"envKey": "OMNIROUTE_API_KEY",
|
||||
"baseUrl": "http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1",
|
||||
"generationConfig": {
|
||||
"contextWindowSize": 8192,
|
||||
"extra_body": { "chat_template_kwargs": { "enable_thinking": false } }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"security": { "auth": { "selectedType": "openai" } },
|
||||
"model": {
|
||||
"name": "<main-model-provider-id-in-omniroute>",
|
||||
"baseUrl": "http://<ai-box>:${OMNIROUTE_PORT:-4000}/v1"
|
||||
},
|
||||
"fastModel": "<fast-model-provider-id-in-omniroute>"
|
||||
}
|
||||
```
|
||||
|
||||
- `envKey` names the environment variable Qwen Code reads the virtual key from — set `OMNIROUTE_API_KEY=<qwen-code-cli virtual key>` before launching. Both providers can share one virtual key (as above); split it into two if you want separate usage tracking for chat vs. classifier calls.
|
||||
- **`contextWindowSize` is per-slot, not `LLAMA_CTX_SIZE` itself** — llama.cpp divides `--ctx-size` across `LLAMA_PARALLEL` concurrent slots, and each request only gets one slot's share (same correction applies to OpenCode's `limit.context`). Compute it per model from `.env`:
|
||||
- Main model: `LLAMA_CTX_SIZE / LLAMA_PARALLEL` = `262144 / 2` = **131072**.
|
||||
- Fast model: `LLAMA_FAST_CTX_SIZE / LLAMA_FAST_PARALLEL` = `8192 / 1` = **8192**. Undersizing this one specifically breaks Auto Mode ("Classifier stage 1 unavailable") once `hints.allow`/`softDeny`/`hardDeny` entries and recent-action history push a classifier call past it — see the `LLAMA_FAST_CTX_SIZE` comment in `.env.example` before raising it instead of `LLAMA_FAST_PARALLEL`.
|
||||
- `enable_thinking: false` on the fast model matters: the fast model file (`Qwen3-4B-Instruct-2507`) is already non-thinking, but this also suppresses `<think>` output on any fast-model swap that isn't, keeping classifier responses parseable.
|
||||
- Qwen Code also recognizes `advisorModel`, `visionModel`, `compactionModel`, `imageModel` for other model roles — none are wired up in this stack; only `fastModel` is required.
|
||||
|
||||
## Web search via OmniRoute
|
||||
|
||||
Qwen Code's own built-in web search (`tools.webSearch.enabled`) has nothing to search with here — leave it `false`. Instead this stack's SearXNG-backed search (README §"Web search") is exposed through a thin stdio MCP wrapper around OmniRoute's `/v1/search` REST endpoint (that endpoint isn't itself MCP — OmniRoute's real MCP surface is admin-only/LOCAL_ONLY-gated). Save this as e.g. `~/.qwen/mcp-servers/omniroute-search/index.mjs` (needs `@modelcontextprotocol/sdk` and `zod`: `npm init -y && npm i @modelcontextprotocol/sdk zod` in that directory):
|
||||
|
||||
```js
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://proxy-ai.home";
|
||||
const API_KEY = process.env.OMNIROUTE_API_KEY;
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error("OMNIROUTE_API_KEY is not set in the environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = new McpServer({ name: "omniroute-search", version: "1.0.0" });
|
||||
|
||||
server.registerTool(
|
||||
"search",
|
||||
{
|
||||
description: "Web/news search via OmniRoute's /v1/search endpoint.",
|
||||
inputSchema: { query: z.string().describe("Search query") },
|
||||
},
|
||||
async ({ query }) => {
|
||||
const res = await fetch(`${BASE_URL}/v1/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) return { content: [{ type: "text", text: `HTTP ${res.status}: ${text}` }], isError: true };
|
||||
return { content: [{ type: "text", text }] };
|
||||
}
|
||||
);
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
```
|
||||
|
||||
Register it in `~/.qwen/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"omniroute-search": { "command": "node", "args": ["<path-to>/index.mjs"] }
|
||||
},
|
||||
"tools": { "webSearch": { "enabled": false } }
|
||||
}
|
||||
```
|
||||
|
||||
It reuses the same `OMNIROUTE_API_KEY` env var as the model providers above — the virtual key needs search permission in OmniRoute, not just chat-completions.
|
||||
|
||||
## Auto Mode tuning
|
||||
|
||||
Auto Mode's action classifier calls the fast model above — its own request can queue behind other stack traffic before the fast llama-server instance is warm, so the default classifier timeout is worth raising. And since this stack is a single trusted local proxy, it's reasonable to pre-approve requests to it rather than confirm every call:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"autoMode": {
|
||||
"classifier": { "timeouts": { "stage1Ms": 600000 } },
|
||||
"hints": { "allow": ["Requests to proxy-ai.home, my own local omniroute model proxy"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`hints.allow` entries are free-text descriptions the classifier matches against, not exact strings — capped at 150 entries/200 chars each (see the `LLAMA_FAST_CTX_SIZE` note above for why that ceiling matters).
|
||||
|
||||
Everything else in `~/.qwen/settings.json` (`hooks`, `security.auth`'s underlying tooling, editor prefs) is per-machine, not part of pointing at this stack — don't copy it wholesale between machines.
|
||||
@@ -4,7 +4,7 @@ This stack has no chat UI — every client is a coding CLI reaching the AI gatew
|
||||
|
||||
## llama.cpp's raw API stays LAN-only — deliberately
|
||||
|
||||
The inference API (port `${LLAMA_PORT:-8080}`) is **not** registered in NPM and is **not** reachable externally. It has no authentication of its own — putting it on the public internet would mean an unauthenticated inference endpoint. Coding-agent CLIs (Claude Code, Kimi, OpenCode — see `docs/coding-cli-setup.md`) don't reach it directly at all now; they go through the gateway below, same as everything else.
|
||||
The inference API (port `${LLAMA_PORT:-8080}`) is **not** registered in NPM and is **not** reachable externally. It has no authentication of its own — putting it on the public internet would mean an unauthenticated inference endpoint. Coding-agent CLIs (Claude Code, Kimi, OpenCode, Qwen Code — see `docs/coding-cli-setup/`) don't reach it directly at all now; they go through the gateway below, same as everything else.
|
||||
|
||||
If you later want external CLI access too, that's a deliberate scope change — see the map ([issue #1](https://git.arthurerlich.de/haylan/LLM-Server/issues/1)) before doing it, since it changes the security posture.
|
||||
|
||||
@@ -16,3 +16,9 @@ As of [issue #31](https://git.arthurerlich.de/haylan/LLM-Server/issues/31) (migr
|
||||
- The **dashboard** (`${OMNIROUTE_DASHBOARD_PORT:-20128}`) is never registered in NPM at all, and `docker-compose.yml` never publishes that port to the host either — it manages every workload's keys, so it doesn't belong on the public internet, same reasoning as LiteLLM's old `/ui`. Unlike LiteLLM, OmniRoute's split-port mode means this is structural (no network route exists) rather than an NPM path-deny rule that has to be maintained and could be misconfigured. Reach the dashboard only from the host itself or over SSH port-forward.
|
||||
|
||||
**Every gateway call already requires a valid API key** (Bearer token, see `docs/proxy-key-onboarding.md`), so no extra NPM-level auth is needed for the external hostname.
|
||||
|
||||
## RAG knowledge graph (Neo4j) — `knowledge.proxy-ai.home`
|
||||
|
||||
Set up as an NPM Proxy Host pointing at this machine's LAN IP on Neo4j's Browser port (`7474`, see `docker-compose.yml`'s `neo4j` service, [PR #50](https://git.arthurerlich.de/haylan/LLM-Server/pulls/50)). Internal-only, same as `proxy-ai.home` — no DMZ/external route, this is admin/dev tooling, not a client-facing endpoint. Bolt (`7687`, the actual query protocol) isn't proxied through NPM at all — clients on the LAN reach it directly at `<this-machine>:7687`.
|
||||
|
||||
Qdrant's dashboard (`6333`) stays on its raw LAN IP/port for now — no hostname assigned yet.
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# Which small model to run as the always-resident `fastModel` for qwen-code's Auto Mode classifier?
|
||||
|
||||
**Date:** 2026-09-06
|
||||
**Budget:** ≤7GB VRAM, resident concurrently alongside the existing Qwen3.8-27B instance on the single
|
||||
32GB R9700, via the same `llama.cpp:server-rocm` image already in `docker-compose.yml`.
|
||||
**Answer: Qwen3-4B-Instruct-2507, Q8_0 GGUF (~4.3GB weights).** The prior quick pass's tentative pick
|
||||
holds up under primary-source verification, for a more specific reason than "same tokenizer family":
|
||||
it is the only strong candidate in the shortlist that is *architecturally* non-thinking (no `<think>`
|
||||
code path exists at all, vs. models that are thinking-by-default and rely on a per-call
|
||||
`enable_thinking:false` toggle that llama.cpp does not cleanly expose). It does carry one directly
|
||||
relevant, documented llama.cpp bug — but that bug is closed, has a one-flag workaround, and is
|
||||
strictly less severe than the still-open Qwen3.5/Qwen3.8-lineage bugs already documented against the
|
||||
27B model in this repo.
|
||||
|
||||
## 1. What the classifier actually needs (grounding the requirement)
|
||||
|
||||
Per qwen-code's own docs, Auto Mode's permission gate is a two-stage LLM classifier:
|
||||
|
||||
- **Stage 1** — outputs only `{ shouldBlock: bool }`, ~300ms budget, thinking already disabled at the
|
||||
request level. If `shouldBlock` is `false`, the action proceeds immediately.
|
||||
- **Stage 2** — only runs when Stage 1 blocks; uses chain-of-thought review to downgrade false
|
||||
positives, ~3-5s budget.
|
||||
- Both stages use "your configured fast model (`/model --fast`)"; if none is configured, the full
|
||||
session model is used instead — which is the current, too-slow state this second model is meant to
|
||||
fix.
|
||||
|
||||
Source: [Qwen Code docs — Auto Mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/auto-mode/),
|
||||
[QwenLM/qwen-code docs/users/features/auto-mode.md](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/auto-mode.md).
|
||||
|
||||
A live qwen-code issue independently confirms the exact failure mode this repo already hit with the
|
||||
27B model — a model *thinking* inside the classifier path is a first-order latency problem, not a
|
||||
nice-to-have to tune later:
|
||||
|
||||
> "for a latency-sensitive permission gate, thinking should be disabled in every stage" — enabling it
|
||||
> "makes the review path slower and more expensive, which directly worsens the timeout problem."
|
||||
|
||||
That issue (timeouts tripping on slow inference) was closed by a PR that both loosened the stage
|
||||
timeout budgets *and* moved toward disabling thinking everywhere in the classifier.
|
||||
Source: [QwenLM/qwen-code issue #4676](https://github.com/QwenLM/qwen-code/issues/4676).
|
||||
|
||||
Takeaway for model selection: the request-level "don't think" instruction already exists in
|
||||
qwen-code's own classifier code. What matters is whether the **model + llama.cpp combination actually
|
||||
honors it reliably** — which is precisely where the 27B model failed (see
|
||||
[`qwen3.8-27b-tool-calling.md`](qwen3.8-27b-tool-calling.md)) and where several shortlist candidates
|
||||
have their own version of the same problem.
|
||||
|
||||
## 2. Candidates evaluated against primary sources
|
||||
|
||||
| Model | Params | GGUF size (quant) | Context | License | Thinking behavior | Tool-calling | Verdict |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **Qwen3-4B-Instruct-2507** | 4B | Q4_K_M 2.5GB / **Q8_0 4.28GB** | 262,144 native | Apache 2.0 | **Non-thinking only** — model card states it "does not generate `<think></think>` blocks in its output," full stop, no toggle needed | Yes, native `<tool_call>` format, BFCL-v3 61.9 | **Recommended** |
|
||||
| Qwen3-1.7B | 1.7B | ~1.1GB (Q4_K_M, typical) | 32,768 | Apache 2.0 | Thinking **on by default**; needs `enable_thinking:false` per call | Yes | Rejected — see §3 |
|
||||
| Qwen3-0.6B | 0.6B | ~0.4GB (Q4_K_M) | 32,768 | Apache 2.0 | Thinking on by default, same toggle issue as 1.7B | Yes, but weakest reasoning of the family | Rejected — undersized for reliability at this size, same toggle risk |
|
||||
| Llama-3.2-3B-Instruct | 3B | ~2GB (Q4_K_M, typical) | 128K | Llama 3.2 Community License — commercial use allowed, but text/EU carve-out language and an explicit >700M-MAU re-licensing clause | No thinking mode | Not natively documented on the model card fetched (no tool-call format called out) | Deprioritized — license has more fine print than Apache 2.0 for no clear benefit here |
|
||||
| Gemma-3-4b-it | 4B | Q4_K_M 2.49GB / Q8_0 4.13GB | 128K | Custom "Gemma" license (Google usage terms) | No documented thinking mode | Not documented on the model card fetched | Deprioritized — no confirmed native tool-calling story, non-Apache license |
|
||||
| Phi-4-mini-instruct | 3.8B | Q4_K_M 2.49GB / Q8_0 4.08GB | 128K | **MIT** | Not a reasoning model (that's the separate Phi-4-mini-**reasoning** model); no `<think>` tags by default | Yes — documented function-call format with dedicated tokens | Credible alternative — see §4 |
|
||||
| SmolLM3-3B | 3B | Q4_K_M ~1.9GB (typical) | 128K (64K trained + YaRN) | Apache 2.0 | **Thinking on by default** (`enable_thinking`), toggled via system-prompt flags | Yes (XML or Python-style tool calls) | Rejected — same thinking-by-default risk as Qwen3-1.7B |
|
||||
| Ministral-8B-Instruct-2410 | 8B | too large for budget at any useful quant with headroom | 128K | **Mistral Research License — commercial use requires contacting Mistral for a separate license** | Not documented as a reasoning model | Yes, documented function-calling with benchmark (31.6 vs Mistral-7B's 6.9) | Rejected — license restricts this repo's own dev-tooling use without contacting Mistral; also parameter count crowds the 7GB budget once Q8_0 + KV cache is counted |
|
||||
|
||||
Sources (fetched directly from each model's own HF card / GGUF repo unless noted):
|
||||
[Qwen/Qwen3-4B-Instruct-2507](https://huggingface.co/Qwen/Qwen3-4B-Instruct-2507),
|
||||
[unsloth/Qwen3-4B-Instruct-2507-GGUF](https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF),
|
||||
[Qwen/Qwen3-1.7B](https://huggingface.co/Qwen/Qwen3-1.7B),
|
||||
[Qwen/Qwen3-0.6B](https://huggingface.co/Qwen/Qwen3-0.6B),
|
||||
[meta-llama/Llama-3.2-3B-Instruct](https://huggingface.co/meta-llama/Llama-3.2-3B-Instruct),
|
||||
[google/gemma-3-4b-it](https://huggingface.co/google/gemma-3-4b-it),
|
||||
[bartowski/google_gemma-3-4b-it-GGUF](https://huggingface.co/bartowski/google_gemma-3-4b-it-GGUF),
|
||||
[microsoft/Phi-4-mini-instruct](https://huggingface.co/microsoft/Phi-4-mini-instruct),
|
||||
[bartowski/microsoft_Phi-4-mini-instruct-GGUF](https://huggingface.co/bartowski/microsoft_Phi-4-mini-instruct-GGUF),
|
||||
[HuggingFaceTB/SmolLM3-3B](https://huggingface.co/HuggingFaceTB/SmolLM3-3B),
|
||||
[mistralai/Ministral-8B-Instruct-2410](https://huggingface.co/mistralai/Ministral-8B-Instruct-2410).
|
||||
|
||||
**Confidence note:** file sizes for Qwen3-1.7B/0.6B, Llama-3.2-3B, and SmolLM3-3B GGUF quants above are
|
||||
typical/approximate — those repos weren't individually re-verified against a specific GGUF file tree
|
||||
since all three families were eliminated on architectural grounds (§3) before size mattered. Sizes for
|
||||
the two models actually compared head-to-head (Qwen3-4B-Instruct-2507, Phi-4-mini-instruct) and
|
||||
Gemma-3-4b-it were pulled directly from each quantizer's own repo page.
|
||||
|
||||
## 3. Why "thinking-by-default + per-call toggle" is disqualifying, not just a minor ding
|
||||
|
||||
This is the deciding architectural distinction, and it's exactly the failure this second model exists
|
||||
to avoid. Qwen's own llama.cpp docs page states the toggle problem directly:
|
||||
|
||||
> "the hard switch implemented in the chat template is not exposed in llama.cpp" for controlling
|
||||
> `enable_thinking` — the documented workaround is to supply "a custom chat template equivalent to
|
||||
> always `enable_thinking=False`" via `--chat-template-file`.
|
||||
Source: [Qwen — Run with llama.cpp](https://qwen.readthedocs.io/en/latest/run_locally/llama.cpp.html).
|
||||
|
||||
That means for Qwen3-1.7B, Qwen3-0.6B, and SmolLM3-3B — all thinking-on-by-default — reliably
|
||||
suppressing the reasoning phase in this llama.cpp/ROCm stack is not a request-body flag away; it needs
|
||||
a hand-maintained custom chat template file, which is exactly the kind of fragile, easy-to-silently-
|
||||
regress setup this task is trying to get away from (the 27B model's whole problem was reasoning_content
|
||||
being consumed before the answer). Qwen3-4B-Instruct-2507 has no such toggle to maintain in the first
|
||||
place — the model card states the non-thinking behavior as an unconditional property of the model, not
|
||||
a configurable default that has to be forced correctly on every request. This is a stronger claim than
|
||||
"same tokenizer family as the 27B" (the original quick-pass's reasoning) and is the actual basis for
|
||||
the recommendation.
|
||||
|
||||
## 4. The one documented risk specific to Qwen3-4B-Instruct-2507 — and why it doesn't change the pick
|
||||
|
||||
llama.cpp has its own closed, dated bug where server builds around **b8429** (March 2026)
|
||||
mis-detected Qwen3-Instruct-2507 models — the 4B included by name in the reporter's repro command — as
|
||||
thinking models, routing tool-call output into `reasoning_content` instead of `tool_calls`:
|
||||
|
||||
> "llama.cpp b8429 incorrectly detects Qwen3-Instruct-2507 models as thinking models (`thinking = 1`).
|
||||
> This causes tool calls to be captured as `reasoning_content` instead of being parsed into the
|
||||
> `tool_calls` array."
|
||||
|
||||
The documented, confirmed-working workaround is a single server flag:
|
||||
|
||||
```
|
||||
llama-server -hf unsloth/Qwen3-4B-Instruct-2507-GGUF:Q4_K_M --jinja --port 8222 --reasoning off
|
||||
```
|
||||
|
||||
which restores `thinking = 0` and correct `finish_reason: tool_calls` output. The issue is **closed**.
|
||||
Source: [ggml-org/llama.cpp issue #20809](https://github.com/ggml-org/llama.cpp/issues/20809).
|
||||
|
||||
This is worth flagging honestly against the recommendation, but it's materially different from the
|
||||
open, only-partially-fixed Qwen3.5/Qwen3.8-lineage parser bugs already documented in this repo's
|
||||
[`qwen3.8-27b-tool-calling.md`](qwen3.8-27b-tool-calling.md) (issues #21158, #20837 — both open at time
|
||||
of that research): this is a llama.cpp *server-side misdetection* bug with a one-flag fix, not an
|
||||
unresolved upstream grammar/parser defect in the Qwen3.5 architecture family itself. Concretely: add
|
||||
`--reasoning off` to this second llama-server instance's command regardless — it's a no-cost safety net
|
||||
whether or not the current `ghcr.io/ggml-org/llama.cpp:server-rocm` build still has the bug, and it
|
||||
directly targets the exact failure mode (reasoning_content eating the completion) that ruled out the
|
||||
27B model for this role in the first place.
|
||||
|
||||
## 5. VRAM math for the classifier role specifically
|
||||
|
||||
Qwen3-4B-Instruct-2507 is a plain (non-hybrid) transformer — every layer is standard GQA attention, so
|
||||
unlike the 27B model's Gated-DeltaNet hybrid, KV cache scales with *all* layers, not a fraction of them.
|
||||
From the model's own `config.json`:
|
||||
|
||||
- `num_hidden_layers`: 36, `num_key_value_heads`: 8, `head_dim`: 128
|
||||
Source: [Qwen/Qwen3-4B-Instruct-2507 config.json](https://huggingface.co/Qwen/Qwen3-4B-Instruct-2507/raw/main/config.json).
|
||||
|
||||
Per-token KV cache (fp16, both K and V):
|
||||
`36 layers × 2 (K+V) × 8 kv_heads × 128 head_dim × 2 bytes = 144 KiB/token`
|
||||
|
||||
The classifier transcript is bounded by design — qwen-code's own two-stage design keeps Stage 1 to a
|
||||
`{shouldBlock}`-only judgment and Stage 2 to a chain-of-thought review of one blocked action, not an
|
||||
open-ended agent session — so a context window in the low thousands of tokens is generous headroom,
|
||||
not a tight fit:
|
||||
|
||||
| Context | KV cache (fp16) | KV cache (q8_0, `--cache-type-k/v q8_0`) | Weights (Q8_0) | Total (q8_0 KV) | Headroom under 7GB |
|
||||
|---|---|---|---|---|---|
|
||||
| 4,096 tokens | ~0.56 GB | ~0.28 GB | 4.28 GB | **~4.56 GB** | ~2.4 GB |
|
||||
| 8,192 tokens | ~1.13 GB | ~0.56 GB | 4.28 GB | **~4.84 GB** | ~2.2 GB |
|
||||
| 32,768 tokens (generous ceiling) | ~4.5 GB | ~2.25 GB | 4.28 GB | **~6.53 GB** | ~0.5 GB (tight) |
|
||||
|
||||
At any context length actually needed for a permission-gate classifier (thousands, not tens of
|
||||
thousands, of tokens), Q8_0 weights plus q8_0 KV cache comfortably clears the 7GB ceiling with headroom
|
||||
to spare for the compute buffer and batch overhead — matching the same `--cache-type-k q8_0
|
||||
--cache-type-v q8_0` pattern this repo already uses for the 27B instance. There's no need to drop to
|
||||
Q4_K_M (2.5GB) unless a much larger classifier context is anticipated later; Q8_0 is the better default
|
||||
here since it's a small model where quantization loss matters proportionally more, and the VRAM budget
|
||||
comfortably affords the higher-precision quant.
|
||||
|
||||
## 6. What would change the answer
|
||||
|
||||
- **If Phi-4-mini-instruct's MIT license matters more than matching the 27B model's tokenizer/template
|
||||
family**, it's a legitimate second choice: confirmed non-thinking by default, confirmed native
|
||||
function-calling format, comparable Q8_0 size (4.08GB), and a license with zero commercial-use fine
|
||||
print (vs. Apache 2.0's still-permissive but slightly more conditional terms). It wasn't picked
|
||||
because it has no llama.cpp-specific tool-calling track record verified in this pass (no equivalent
|
||||
to the issue #20809 workaround search done for it), so its actual reliability on this exact
|
||||
`llama.cpp:server-rocm` stack is less directly evidenced than Qwen3-4B-Instruct-2507's.
|
||||
- **If the classifier transcript ever needs to grow well past ~8K tokens routinely**, drop to Q4_K_M
|
||||
(2.5GB) to keep well clear of the 7GB ceiling — the KV-cache math in §5 shows the crossover point.
|
||||
- **If llama.cpp's #20809 misdetection turns out to still reproduce** on the exact
|
||||
`ghcr.io/ggml-org/llama.cpp:server-rocm` build this repo pulls, the fix is the one-flag
|
||||
`--reasoning off` workaround already confirmed in that issue — not a reason to pick a different
|
||||
model, since every thinking-capable alternative in this shortlist has an equal-or-worse version of
|
||||
the same class of bug with less clean workarounds (custom chat-template files, per §3).
|
||||
|
||||
## Sources
|
||||
|
||||
- [Qwen Code docs — Auto Mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/auto-mode/)
|
||||
- [QwenLM/qwen-code — docs/users/features/auto-mode.md](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/auto-mode.md)
|
||||
- [QwenLM/qwen-code issue #4676](https://github.com/QwenLM/qwen-code/issues/4676)
|
||||
- [Qwen/Qwen3-4B-Instruct-2507](https://huggingface.co/Qwen/Qwen3-4B-Instruct-2507)
|
||||
- [Qwen/Qwen3-4B-Instruct-2507 config.json](https://huggingface.co/Qwen/Qwen3-4B-Instruct-2507/raw/main/config.json)
|
||||
- [unsloth/Qwen3-4B-Instruct-2507-GGUF](https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF)
|
||||
- [Qwen — Run with llama.cpp](https://qwen.readthedocs.io/en/latest/run_locally/llama.cpp.html)
|
||||
- [ggml-org/llama.cpp issue #20809](https://github.com/ggml-org/llama.cpp/issues/20809)
|
||||
- [Qwen/Qwen3-1.7B](https://huggingface.co/Qwen/Qwen3-1.7B)
|
||||
- [Qwen/Qwen3-0.6B](https://huggingface.co/Qwen/Qwen3-0.6B)
|
||||
- [meta-llama/Llama-3.2-3B-Instruct](https://huggingface.co/meta-llama/Llama-3.2-3B-Instruct)
|
||||
- [google/gemma-3-4b-it](https://huggingface.co/google/gemma-3-4b-it)
|
||||
- [bartowski/google_gemma-3-4b-it-GGUF](https://huggingface.co/bartowski/google_gemma-3-4b-it-GGUF)
|
||||
- [microsoft/Phi-4-mini-instruct](https://huggingface.co/microsoft/Phi-4-mini-instruct)
|
||||
- [bartowski/microsoft_Phi-4-mini-instruct-GGUF](https://huggingface.co/bartowski/microsoft_Phi-4-mini-instruct-GGUF)
|
||||
- [HuggingFaceTB/SmolLM3-3B](https://huggingface.co/HuggingFaceTB/SmolLM3-3B)
|
||||
- [mistralai/Ministral-8B-Instruct-2410](https://huggingface.co/mistralai/Ministral-8B-Instruct-2410)
|
||||
- [docs/research/qwen3.8-27b-tool-calling.md](qwen3.8-27b-tool-calling.md) (this repo — cross-referenced
|
||||
for the 27B model's own, still-open, tool-calling parser bugs)
|
||||
|
||||
## Confidence/uncertainty summary
|
||||
|
||||
- **High confidence:** Qwen3-4B-Instruct-2507's non-thinking-only status (direct model-card quote);
|
||||
qwen-code's two-stage classifier timing/design and its use of `/model --fast` (direct docs quote);
|
||||
the existence, exact symptom, and workaround of llama.cpp issue #20809 (direct issue quote); the
|
||||
KV-cache architecture math (computed directly from the model's own `config.json`, same method as the
|
||||
existing `qwen3.8-27b-quant.md` research in this repo).
|
||||
- **Medium confidence:** exact GGUF file sizes for Qwen3-1.7B, Qwen3-0.6B, Llama-3.2-3B-Instruct, and
|
||||
SmolLM3-3B — not individually re-verified against a specific quantizer's file tree since these were
|
||||
eliminated on architectural (thinking-toggle) grounds before size became the deciding factor; treat
|
||||
as typical/approximate, not exact.
|
||||
- **Low confidence / not independently verified:** whether the current
|
||||
`ghcr.io/ggml-org/llama.cpp:server-rocm` image (pulled fresh) still reproduces issue #20809's
|
||||
misdetection — the issue is closed but no changelog/PR diff was fetched to confirm the underlying
|
||||
detection logic was actually patched vs. the reporter simply adopting the `--reasoning off`
|
||||
workaround. Recommend a live smoke test (send one tool-calling request, confirm the response lands in
|
||||
`tool_calls` not `reasoning_content`, and time a trivial completion) before wiring this model in as
|
||||
the production `fastModel`, the same caveat this repo's `qwen3.8-27b-tool-calling.md` already flags
|
||||
for the 27B model.
|
||||
- Whether Phi-4-mini-instruct has a comparably clean llama.cpp tool-calling track record was **not**
|
||||
deep-dived (no issue-tracker search run against it) — it's flagged in §6 as a live alternative rather
|
||||
than fully evaluated, since Qwen3-4B-Instruct-2507's architectural non-thinking guarantee and
|
||||
same-family template consistency with the existing 27B deployment made it the clearer pick without
|
||||
needing that extra research pass.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Research: GPU pinned at 100% with two concurrent llama.cpp containers, and the intermittent "render" group startup error
|
||||
|
||||
**Question:** After adding `llama-server-fast` (#44), real-hardware testing on the R9700
|
||||
showed `rocm-smi` pinned at 100% GPU / ~73-101W whenever both `llama-server` and
|
||||
`llama-server-fast` run concurrently, dropping to 3% / ~25-60W the moment either one
|
||||
alone is stopped. Separately, `docker compose up` intermittently failed with
|
||||
`Error response from daemon: unable to find group render: no matching entries in group file`
|
||||
— confirmed new since the second GPU service was added. See issue #5's comment thread
|
||||
for the raw `rocm-smi`/`free -h` output this doc is diagnosing.
|
||||
|
||||
## GPU pin: root cause and fix
|
||||
|
||||
**Confirmed via #5's own data**: either container alone is fine (3% GPU, low power).
|
||||
The pin only appears with two concurrent HIP-context-holding processes on the same
|
||||
GPU. This matches `ROCm/ROCm#5706` (already flagged as a risk in map #1) — full
|
||||
comment thread confirms:
|
||||
|
||||
- Root cause: an AMD MES (Micro Engine Scheduler) firmware bug triggered by HIP
|
||||
hardware-queue creation, pinning the GPU at boost clock the moment ROCm
|
||||
initializes a queue. Not llama.cpp-specific — reproduced with vLLM and bare
|
||||
PyTorch ROCm too. Source: [ROCm/ROCm#5706](https://github.com/ROCm/ROCm/issues/5706)
|
||||
(`tcgu-amd`, AMD engineer, confirms MES firmware root cause; closed as
|
||||
"fixed" in March, but a report as recent as May 24 shows it recurring even on
|
||||
patched firmware/kernel).
|
||||
- **Validated workaround**: `GPU_MAX_HW_QUEUES=1` as a container env var. One
|
||||
report ran a controlled before/after on the exact image this stack uses
|
||||
(`ghcr.io/ggml-org/llama.cpp:server-rocm`, R9700/gfx1201):
|
||||
baseline 100% GPU / 95W → with the var set, 3% GPU / 22W, VRAM unchanged.
|
||||
Source: same thread, `interconnectedMe`'s comment.
|
||||
- **Semantics** (why this should apply to our two-container case, not just the
|
||||
single-process case tested above): `GPU_MAX_HW_QUEUES` is a **per-process**
|
||||
HIP runtime setting — it caps how many HSA/hardware queues *that process's*
|
||||
HIP runtime allocates, default higher (over-subscription is what causes the
|
||||
penalty). Source: [AMD ROCm workload-optimization docs](https://rocm.docs.amd.com/en/latest/how-to/rocm-for-ai/inference-optimization/workload.html).
|
||||
Since it's per-process, setting it on *each* container independently is the
|
||||
correct scope — it should reduce total concurrent hardware-queue creation
|
||||
across both processes, which is the trigger condition MES chokes on.
|
||||
**Caveat**: no primary source explicitly tested two concurrent containers
|
||||
both set to `GPU_MAX_HW_QUEUES=1` — this is a well-grounded extrapolation
|
||||
from confirmed per-process semantics and the same root-cause mechanism, not
|
||||
a directly-reproduced fix for our exact topology. Verify with `rocm-smi`
|
||||
after applying, both containers up.
|
||||
|
||||
## "unable to find group render" — a real Docker bug, not flaky hardware
|
||||
|
||||
This is a known, documented Docker limitation, not something specific to this
|
||||
stack: `group_add` by **name** requires Docker to resolve that name against
|
||||
the **container's own** `/etc/group` file — if the image doesn't define a
|
||||
`render` entry there (common for minimal/slim base images), resolution fails.
|
||||
Source: [docker/cli#4714](https://github.com/docker/cli/issues/4714)
|
||||
("`docker run --group-add` by name doesn't add group from host as
|
||||
documented") and [docker/compose#7277](https://github.com/docker/compose/issues/7277)
|
||||
(same "no matching entries in group file" error).
|
||||
|
||||
Confirms why it's now intermittent rather than always-broken: this repo's
|
||||
`docker-compose.yml` uses `group_add: [video, render]` (plain names) on
|
||||
**three** GPU services now (`llama-server`, `llama-server-fast`, `comfyui`).
|
||||
Docker Compose starts containers concurrently, and each does its own
|
||||
name-resolution lookup independently — with only one GPU service before #44,
|
||||
the resolution almost always won its race; with two (soon three, once
|
||||
`comfyui`'s downloader/model land per #46) the odds of losing that race and
|
||||
hitting the unresolved-name path go up. This is consistent with the user's
|
||||
own observation that it's new since the second GPU service.
|
||||
|
||||
**Fix, already precedented in this repo**: `scripts/update.sh` already
|
||||
resolves the host's real `video`/`render` **numeric GIDs** for the `comfyui`
|
||||
service (`COMFYUI_VIDEO_GID`/`COMFYUI_RENDER_GID`, passed as app-level env
|
||||
vars) — but `comfyui`'s own `group_add:` still uses plain names too, so it
|
||||
isn't actually protected by that either. The correct fix per the Docker
|
||||
issues above: use the resolved **numeric GIDs** in `group_add:` itself
|
||||
(Compose accepts numeric strings directly), not names, on all three GPU
|
||||
services. Numeric GIDs skip the name-resolution step entirely, eliminating
|
||||
both the flakiness and the race.
|
||||
|
||||
## Recommendation
|
||||
|
||||
1. Add `GPU_MAX_HW_QUEUES=1` to both `llama-server` and `llama-server-fast`'s
|
||||
`environment:` blocks. Verify with `rocm-smi` after redeploy, both
|
||||
containers up — this is the one part of this doc that's extrapolated
|
||||
rather than directly reproduced, so real confirmation matters here.
|
||||
2. Resolve host `video`/`render` GIDs once (generalize the existing
|
||||
`COMFYUI_VIDEO_GID`/`COMFYUI_RENDER_GID` pattern in `scripts/update.sh`
|
||||
to shared `HOST_VIDEO_GID`/`HOST_RENDER_GID` vars), and switch
|
||||
`group_add:` on all three GPU services (`llama-server`,
|
||||
`llama-server-fast`, `comfyui`) from `[video, render]` (names) to the
|
||||
resolved numeric GIDs. Removes the race entirely rather than reducing its
|
||||
odds.
|
||||
|
||||
## Sources
|
||||
|
||||
- [ROCm/ROCm#5706 — full comment thread](https://github.com/ROCm/ROCm/issues/5706)
|
||||
- [AMD ROCm — MI300/MI350 workload optimization docs (GPU_MAX_HW_QUEUES)](https://rocm.docs.amd.com/en/latest/how-to/rocm-for-ai/inference-optimization/workload.html)
|
||||
- [docker/cli#4714 — group_add by name doesn't work as documented](https://github.com/docker/cli/issues/4714)
|
||||
- [docker/compose#7277 — "no matching entries in group file"](https://github.com/docker/compose/issues/7277)
|
||||
- This repo's issue #5 (real-hardware `rocm-smi`/`free -h` evidence this doc diagnoses)
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Swap GPU residency between llama-server (Qwen) and comfyui — they never
|
||||
# run concurrently, VRAM doesn't fit both (see issue #38's map). Manual
|
||||
# invocation only, no auto-switching.
|
||||
#
|
||||
# Bypasses lazytainer entirely and drives docker compose directly — its
|
||||
# idle-stop can't be used for this. Root cause (see
|
||||
# docs/research/lazytainer-omniroute-idle-stop.md, issue #40): lazytainer's
|
||||
# packet-threshold detector is source-blind and can't tell OmniRoute's
|
||||
# periodic health-check pings apart from real traffic on the same port, so
|
||||
# it never reliably sleeps a service on its own. A scripted swap always
|
||||
# knows which service should go up/down, so it doesn't need that heuristic.
|
||||
#
|
||||
# llama-server-fast (the small classifier model, issue #44) is NOT part of
|
||||
# this swap — it's meant to stay always-resident. Worst case with comfyui up
|
||||
# is comfyui (~25GB, Qwen-Image FP8) + llama-server-fast (~5GB) ≈ 30GB,
|
||||
# still under the 32GB card but tight — unverified on real hardware, check
|
||||
# `docker compose ps` / VRAM usage after the first real swap.
|
||||
#
|
||||
# OmniRoute may show the just-stopped provider as errored/offline in its
|
||||
# dashboard for up to CREDENTIAL_HEALTH_CHECK_INTERVAL (default 5 min) after
|
||||
# a swap — cosmetic, not a functional problem (see the research doc above).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 {qwen|comfyui}" >&2
|
||||
echo " qwen - stop comfyui, start llama-server" >&2
|
||||
echo " comfyui - stop llama-server, start comfyui" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ $# -eq 1 ] || usage
|
||||
|
||||
case "$1" in
|
||||
qwen)
|
||||
from=comfyui
|
||||
to=llama-server
|
||||
;;
|
||||
comfyui)
|
||||
from=llama-server
|
||||
to=comfyui
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "==> stopping $from"
|
||||
docker compose stop "$from"
|
||||
|
||||
echo "==> starting $to"
|
||||
docker compose up -d "$to"
|
||||
|
||||
echo "==> status"
|
||||
docker compose ps
|
||||
+154
-8
@@ -4,6 +4,18 @@
|
||||
# exist, pulls, validates, rebuilds/re-pulls images, and recreates only what
|
||||
# changed — safe to run any time, including with nothing to do.
|
||||
#
|
||||
# Tunable config values (LLAMA_*, ports, timeouts — anything with a real
|
||||
# default in .env.example) are synced from .env.example every run. A value
|
||||
# already matching is left alone silently. A value that DIFFERS from the
|
||||
# server's current .env is a conflict: interactively, you're shown every
|
||||
# conflict on one screen (via gum) and choose which to accept — unpicked
|
||||
# keys keep the server's current value. Non-interactively (no TTY — cron,
|
||||
# CI, piped), any conflict is a hard error unless --force is passed, which
|
||||
# accepts every new value automatically. Secrets and host-resolved values
|
||||
# (blank in .env.example — OMNIROUTE_*_SECRET/_KEY/_SALT/_PASSWORD,
|
||||
# SEARXNG_LAN_IP, COMFYUI_PUID/PGID, HOST_VIDEO_GID/RENDER_GID) are never
|
||||
# touched by this — they keep going through set_if_blank as before.
|
||||
#
|
||||
# omniroute's own routing/provider config (llama-server, search) lives in
|
||||
# its dashboard, not a checked-in file like the old litellm-config.yaml —
|
||||
# see issue #31 and docs/proxy-key-onboarding.md.
|
||||
@@ -14,8 +26,135 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
FORCE=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) FORCE=true ;;
|
||||
*) echo "Usage: $0 [--force]" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Must run before anything else touches a file this script itself reads
|
||||
# (docker-compose.yml, .env.example, this script's own remaining lines) —
|
||||
# a self-updating script isn't guaranteed atomic against its own file
|
||||
# changing mid-run, so pulling later can execute a mix of old and new
|
||||
# script/compose content in one pass. Bit us for real: old GID-resolution
|
||||
# code ran, then this pulled in new var names docker-compose.yml now
|
||||
# requires, and nothing re-ran the (now-current) resolution step for
|
||||
# them — see issue #5's thread.
|
||||
echo "==> git pull"
|
||||
git pull --ff-only
|
||||
|
||||
[ -f .env ] || cp .env.example .env
|
||||
|
||||
echo "==> syncing tracked config values from .env.example"
|
||||
# ponytail: gum (charmbracelet/gum) is a single static binary. Vendored as
|
||||
# a release tarball under scripts/vendor/ (checked into git) for the R9700
|
||||
# box, which has no outbound internet access — the download fallback below
|
||||
# is only for other archs / when the vendored copy is missing or stale.
|
||||
# Cached under .cache/gum/ (gitignored) so repeat runs don't re-extract.
|
||||
GUM_VERSION="0.14.5"
|
||||
GUM_DIR="$(pwd)/.cache/gum"
|
||||
GUM_BIN="$GUM_DIR/gum"
|
||||
ensure_gum() {
|
||||
command -v gum >/dev/null 2>&1 && { echo "gum"; return; }
|
||||
[ -x "$GUM_BIN" ] && { echo "$GUM_BIN"; return; }
|
||||
mkdir -p "$GUM_DIR"
|
||||
local arch tmpdir vendored
|
||||
case "$(uname -m)" in
|
||||
x86_64) arch="x86_64" ;;
|
||||
aarch64|arm64) arch="arm64" ;;
|
||||
*) echo "no gum build for $(uname -m), falling back to plain prompts" >&2; echo ""; return ;;
|
||||
esac
|
||||
tmpdir="$(mktemp -d)"
|
||||
vendored="$(pwd)/scripts/vendor/gum_${GUM_VERSION}_Linux_${arch}.tar.gz"
|
||||
if [ -f "$vendored" ]; then
|
||||
tar -xz -C "$tmpdir" -f "$vendored"
|
||||
else
|
||||
local url="https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}/gum_${GUM_VERSION}_Linux_${arch}.tar.gz"
|
||||
if ! curl -fsSL "$url" | tar -xz -C "$tmpdir" 2>/dev/null; then
|
||||
echo "no vendored gum for $arch and couldn't download from $url (no internet egress? falling back to plain prompts)" >&2
|
||||
fi
|
||||
fi
|
||||
if [ -n "$(find "$tmpdir" -name gum -type f 2>/dev/null)" ]; then
|
||||
find "$tmpdir" -name gum -type f -exec cp {} "$GUM_BIN" \;
|
||||
chmod +x "$GUM_BIN" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$tmpdir"
|
||||
[ -x "$GUM_BIN" ] && echo "$GUM_BIN" || echo ""
|
||||
}
|
||||
|
||||
# Collect every key where .env.example has a real (non-blank) default:
|
||||
# missing from .env -> just add it (no conflict, nothing to decide);
|
||||
# present and identical -> leave alone silently; present and different ->
|
||||
# a conflict to resolve below.
|
||||
conflict_keys=()
|
||||
conflict_old=()
|
||||
conflict_new=()
|
||||
while IFS='=' read -r key value; do
|
||||
[ -n "$value" ] || continue
|
||||
if ! grep -qE "^${key}=" .env; then
|
||||
echo "${key}=${value}" >> .env
|
||||
continue
|
||||
fi
|
||||
current="$(grep -E "^${key}=" .env | head -1 | cut -d= -f2-)"
|
||||
if [ "$current" != "$value" ]; then
|
||||
conflict_keys+=("$key")
|
||||
conflict_old+=("$current")
|
||||
conflict_new+=("$value")
|
||||
fi
|
||||
done < <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=.+' .env.example)
|
||||
|
||||
if [ "${#conflict_keys[@]}" -gt 0 ]; then
|
||||
if [ "$FORCE" = true ]; then
|
||||
for i in "${!conflict_keys[@]}"; do
|
||||
key="${conflict_keys[$i]}"; new="${conflict_new[$i]}"
|
||||
sed -i "s|^${key}=.*|${key}=${new}|" .env
|
||||
echo "${key}: ${conflict_old[$i]} -> ${new} (--force)"
|
||||
done
|
||||
elif [ ! -t 0 ] || [ ! -t 1 ]; then
|
||||
echo "ERROR: ${#conflict_keys[@]} config value(s) in .env differ from .env.example, and this isn't an interactive terminal:" >&2
|
||||
for i in "${!conflict_keys[@]}"; do
|
||||
echo " ${conflict_keys[$i]}: ${conflict_old[$i]} (current) vs ${conflict_new[$i]} (.env.example)" >&2
|
||||
done
|
||||
echo "Re-run interactively to choose per-key, or pass --force to accept every new value." >&2
|
||||
exit 1
|
||||
else
|
||||
gum_bin="$(ensure_gum)"
|
||||
labels=()
|
||||
for i in "${!conflict_keys[@]}"; do
|
||||
labels+=("${conflict_keys[$i]}: ${conflict_old[$i]} -> ${conflict_new[$i]}")
|
||||
done
|
||||
if [ -n "$gum_bin" ]; then
|
||||
selected="$(printf '%s\n' "${labels[@]}" | "$gum_bin" choose --no-limit --selected "$(printf '%s\n' "${labels[@]}" | paste -sd,)" --header "Config differs from .env.example — selected keys take the new value, unselected keep the server's current value:")"
|
||||
else
|
||||
# ponytail: plain-bash fallback if gum couldn't be fetched (offline,
|
||||
# unsupported arch) — same one-screen-of-conflicts idea, cruder UI.
|
||||
echo "Config differs from .env.example. Enter space-separated numbers to KEEP the server's current value (all others take the new value), or press enter to take every new value:"
|
||||
for i in "${!conflict_keys[@]}"; do
|
||||
echo " $((i+1))) ${labels[$i]}"
|
||||
done
|
||||
read -r -p "> " keep_nums
|
||||
selected=""
|
||||
for i in "${!conflict_keys[@]}"; do
|
||||
case " $keep_nums " in
|
||||
*" $((i+1)) "*) ;;
|
||||
*) selected="${selected}${labels[$i]}"$'\n' ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
for i in "${!conflict_keys[@]}"; do
|
||||
key="${conflict_keys[$i]}"; new="${conflict_new[$i]}"
|
||||
if printf '%s\n' "$selected" | grep -qxF "${labels[$i]}"; then
|
||||
sed -i "s|^${key}=.*|${key}=${new}|" .env
|
||||
echo "${key}: ${conflict_old[$i]} -> ${new}"
|
||||
else
|
||||
echo "${key}: kept ${conflict_old[$i]} (server value)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Handles all three cases: the KEY=value line is missing entirely (.env
|
||||
# predates that var being added to .env.example — sed can't fix what isn't
|
||||
# there, so this appends it), present but blank, or already set.
|
||||
@@ -44,6 +183,7 @@ set_if_blank OMNIROUTE_STORAGE_ENCRYPTION_KEY "$(openssl rand -hex 32)"
|
||||
set_if_blank OMNIROUTE_MACHINE_ID_SALT "$(openssl rand -hex 16)"
|
||||
set_if_blank OMNIROUTE_CLI_SALT "$(openssl rand -hex 16)"
|
||||
set_if_blank OMNIROUTE_WS_BRIDGE_SECRET "$(openssl rand -hex 32)"
|
||||
set_if_blank NEO4J_PASSWORD "$(openssl rand -hex 16)"
|
||||
|
||||
echo "==> resolving SEARXNG_LAN_IP"
|
||||
# search.home is a LAN mDNS/local-DNS name — resolvable from this host, just
|
||||
@@ -56,27 +196,31 @@ else
|
||||
echo "SEARXNG_LAN_IP: couldn't resolve search.home from this host, set it manually if still blank."
|
||||
fi
|
||||
|
||||
echo "==> resolving ComfyUI host GID/UID"
|
||||
echo "==> resolving ComfyUI host UID"
|
||||
# yurisasc/comfyui-rocm7.1 wants these as env vars, not just group_add in
|
||||
# compose — resolve from this host, same pattern as SEARXNG_LAN_IP.
|
||||
set_if_blank COMFYUI_PUID "$(id -u)"
|
||||
set_if_blank COMFYUI_PGID "$(id -g)"
|
||||
|
||||
echo "==> resolving host video/render GIDs (shared by every GPU service)"
|
||||
# Numeric GIDs, not names, in docker-compose.yml's group_add: — Docker
|
||||
# resolves a *named* group_add entry against the container's own /etc/group,
|
||||
# not the host's, and fails unpredictably (worse with multiple GPU services
|
||||
# starting concurrently and racing on the same lookup) — see
|
||||
# docs/research/rocm-gpu-pin-and-render-group.md and issue #5.
|
||||
video_gid="$(getent group video 2>/dev/null | cut -d: -f3)"
|
||||
render_gid="$(getent group render 2>/dev/null | cut -d: -f3)"
|
||||
if [ -n "$video_gid" ]; then
|
||||
set_if_blank COMFYUI_VIDEO_GID "$video_gid"
|
||||
set_if_blank HOST_VIDEO_GID "$video_gid"
|
||||
else
|
||||
echo "COMFYUI_VIDEO_GID: no 'video' group on this host, set it manually if still blank."
|
||||
echo "HOST_VIDEO_GID: no 'video' group on this host, set it manually if still blank."
|
||||
fi
|
||||
if [ -n "$render_gid" ]; then
|
||||
set_if_blank COMFYUI_RENDER_GID "$render_gid"
|
||||
set_if_blank HOST_RENDER_GID "$render_gid"
|
||||
else
|
||||
echo "COMFYUI_RENDER_GID: no 'render' group on this host, set it manually if still blank."
|
||||
echo "HOST_RENDER_GID: no 'render' group on this host, set it manually if still blank."
|
||||
fi
|
||||
|
||||
echo "==> git pull"
|
||||
git pull --ff-only
|
||||
|
||||
echo "==> validating compose config"
|
||||
docker compose config -q
|
||||
|
||||
@@ -88,6 +232,8 @@ docker compose build --pull
|
||||
|
||||
echo "==> ensuring models are downloaded (skips already-present files)"
|
||||
docker compose --profile tools run --rm downloader
|
||||
docker compose --profile tools run --rm downloader-fast
|
||||
docker compose --profile tools run --rm downloader-comfyui
|
||||
|
||||
echo "==> bringing up omniroute"
|
||||
docker compose up -d --wait omniroute
|
||||
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user