Neohosseinism commited on
Commit
89bf59d
·
0 Parent(s):

Add gemma4-stack config, scripts, and docker-compose setup

Browse files
.env.example ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # Gemma 4 stack configuration. Copy a profile over this file, e.g.:
3
+ # cp profiles/dev-cpu.env .env
4
+ # then edit HF_TOKEN. Every value below has a sensible default in compose.
5
+ # =============================================================================
6
+
7
+ # --- Hugging Face (required: Gemma 4 is gated) -------------------------------
8
+ HF_TOKEN=
9
+
10
+ # --- Hardware / inference knobs (rendered into llama-swap config) ------------
11
+ NGL=0 # GPU layers to offload. 0 = pure CPU. 99 = all (GPU).
12
+ THREADS=4 # physical cores (this box = 4). Not hyperthreads.
13
+ CTX=8192 # context window per model
14
+
15
+ # --- Default model shown in Open WebUI (must match a llama-swap config key) --
16
+ DEFAULT_MODEL=gemma-e4b # gemma-e4b | gemma-12b | gemma-26b-a4b
17
+
18
+ # --- RAG models (Persian-tuned) ----------------------------------------------
19
+ TEI_EMBED_MODEL=BAAI/bge-m3 # Persian, FaMTEB top-2 (Hakim's HF repo is unpublished)
20
+ TEI_EMBED_POOLING=cls # bge-m3 uses cls pooling
21
+ TEI_RERANK_MODEL=BAAI/bge-reranker-v2-m3
22
+ TEI_DTYPE=float16 # float16 -> lighter Candle backend on CPU (saves RAM)
23
+
24
+ # --- Images (override for GPU via docker-compose.gpu.yml) --------------------
25
+ LLAMASWAP_IMAGE=ghcr.io/mostlygeek/llama-swap:cpu
26
+ TEI_IMAGE=ghcr.io/huggingface/text-embeddings-inference:cpu-1.9
27
+
28
+ # --- Host ports ---------------------------------------------------------------
29
+ WEBUI_PORT=3000
30
+ LLAMASWAP_PORT=8080
31
+ TEI_EMBED_PORT=8081
32
+ TEI_RERANK_PORT=8082
33
+
34
+ # --- Misc ---------------------------------------------------------------------
35
+ OPENAI_API_KEY=sk-local
36
+ TEI_API_KEY=x
37
+ WEBUI_NAME=Gemma 4 Local
.gitattributes ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ models/tei/**/blobs/* filter=lfs diff=lfs merge=lfs -text
2
+ *.gguf filter=lfs diff=lfs merge=lfs -text
3
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
4
+ *.wav filter=lfs diff=lfs merge=lfs -text
5
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
6
+ *.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # local config & secrets
2
+ .env
3
+
4
+ # rendered llama-swap config
5
+ llama-swap/config.yaml
6
+
7
+ # Open WebUI persistent data (db, vector store, uploads)
8
+ openwebui/data/
9
+
10
+ # bench output
11
+ scripts/bench-results/
12
+
13
+ # transient hf_hub download locks (0-byte)
14
+ *.lock
15
+
16
+ # python
17
+ __pycache__/
18
+ *.pyc
Makefile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Minimal helpers for the Gemma 4 stack. Run `make help`.
2
+ ENV ?= dev-cpu
3
+ ARGS ?=
4
+ COMPOSE = docker compose
5
+
6
+ .PHONY: help env models pull up up-gpu down ps logs test clean
7
+
8
+ help: ## Show this help
9
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
10
+ awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-10s\033[0m %s\n",$$1,$$2}'
11
+
12
+ env: ## Copy profiles/$(ENV).env -> .env (ENV=dev-cpu|cpu-server|cpu-server-xl|gpu)
13
+ cp profiles/$(ENV).env .env
14
+
15
+ models: ## Download required BF16 mmproj (use ARGS=--weights to also fetch GGUFs)
16
+ ./scripts/download-models.sh $(ARGS)
17
+
18
+ pull: ## Pre-pull docker images
19
+ $(COMPOSE) pull
20
+
21
+ up: ## Start the CPU stack
22
+ $(COMPOSE) up -d
23
+
24
+ up-gpu: ## Start the GPU stack (needs profiles/gpu.env)
25
+ $(COMPOSE) -f docker-compose.yml -f docker-compose.gpu.yml up -d
26
+
27
+ down: ## Stop the stack
28
+ $(COMPOSE) down
29
+
30
+ ps: ## Show service status
31
+ $(COMPOSE) ps
32
+
33
+ logs: ## Tail logs (SVC=llama-swap to filter)
34
+ $(COMPOSE) logs -f --tail=120 $(SVC)
35
+
36
+ test: ## Run the e2e smoke tests
37
+ ./scripts/e2e-test.sh
38
+
39
+ clean: ## Stop and remove containers + volumes
40
+ $(COMPOSE) down -v
README.md ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gemma 4 — local, CPU-first LLM stack (llama.cpp + llama-swap + TEI + Open WebUI)
2
+
3
+ A fully local stack: **Gemma 4** (text + **image** + **native audio**) served by **llama.cpp**
4
+ behind **llama-swap**, with **Persian-tuned RAG** (HF **TEI**: `bge-m3` embedder — top-2 on
5
+ the Persian FaMTEB benchmark — + `bge-reranker-v2-m3`) and **Open WebUI** as the front-end.
6
+ Profiled for CPU servers, with a GPU override.
7
+
8
+ > Embedder note: the Persian-SOTA **Hakim** (`MCINext/Hakim`) has no weights published in its
9
+ > HF repo yet (only a README), so the stack defaults to **`bge-m3`**. Swap `TEI_EMBED_MODEL`
10
+ > back to Hakim once its weights land.
11
+
12
+ ```
13
+ Open WebUI ──┬─► llama-swap ─► llama-server (Gemma 4 omni + BF16 mmproj) chat · vision · audio
14
+ ├─► TEI-embed (BAAI/bge-m3 · Persian) RAG embeddings
15
+ └─► TEI-rerank (BAAI/bge-reranker-v2-m3) RAG rerank
16
+ ```
17
+
18
+ ## What runs where
19
+
20
+ | Service | Image (CPU) | Internal URL | Host port |
21
+ |---|---|---|---|
22
+ | Open WebUI | `ghcr.io/open-webui/open-webui:main` | — | `3000` |
23
+ | llama-swap | `ghcr.io/mostlygeek/llama-swap:cpu` | `http://llama-swap:8080/v1` | `8080` |
24
+ | TEI embed | `…/text-embeddings-inference:cpu-1.9` | `http://tei-embed:80` | `8081` |
25
+ | TEI rerank | `…/text-embeddings-inference:cpu-1.9` | `http://tei-rerank:80` | `8082` |
26
+
27
+ ## Prerequisites
28
+ - Docker + `docker compose` v2 (`docker compose version`).
29
+ - A Hugging Face token with the **Gemma 4 license accepted** (the model is gated):
30
+ ```bash
31
+ pip install -U "huggingface_hub[cli]" && huggingface-cli login
32
+ ```
33
+
34
+ ## Quick start (this CPU dev box)
35
+ ```bash
36
+ cd gemma4-stack
37
+ cp profiles/dev-cpu.env .env # E4B default; edit HF_TOKEN=
38
+ ./scripts/download-models.sh # pulls the REQUIRED BF16 mmproj files
39
+ docker compose up -d # boots TEI + Open WebUI + llama-swap
40
+ ```
41
+ Open <http://localhost:3000>, create the first (admin) account.
42
+
43
+ **One-time in Admin UI:**
44
+ 1. **Settings → Models →** enable **Vision** on `gemma-e4b` (lets Open WebUI send images).
45
+ 2. **Admin → Functions → +** → paste `openwebui/functions/gemma4_audio_pipe.py` → **Save** → enable.
46
+ - In its valves, confirm `MODEL=gemma-e4b` and `LLAMASWAP_URL=http://llama-swap:8080/v1`.
47
+ 3. **Settings → Documents →** confirm embedding engine = **OpenAI**, model = your `TEI_EMBED_MODEL`,
48
+ **Hybrid Search ON**, and the external reranker URL is reachable.
49
+
50
+ ## Use it
51
+ - **Text/Persian chat** — pick `gemma-e4b`, chat.
52
+ - **Image** — attach a picture, ask about it (Vision toggle must be on).
53
+ - **Native audio** — pick **“Gemma 4 · Omni (audio)”**, attach a short clip (≤ ~30 s),
54
+ ask your question. Gemma *hears* it (no Whisper).
55
+ - **RAG** — upload Persian docs to a Knowledge / `#`-reference them; retrieval → rerank → answer.
56
+
57
+ ## Other machines / profiles
58
+ ```bash
59
+ cp profiles/cpu-server.env .env # 12B default; set THREADS=<physical cores>
60
+ docker compose up -d
61
+
62
+ cp profiles/gpu.env .env # GPU box
63
+ docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
64
+ ```
65
+ Switch the live model anytime from the model dropdown — llama-swap loads it on demand and
66
+ unloads the idle one (so RAM never doubles). Profiles: `gemma-e4b`, `gemma-12b`,
67
+ `gemma-26b-a4b` (image-only).
68
+
69
+ ## Test it yourself
70
+ **One command — runs all 7 checks (text · image · native audio · embeddings · rerank · UI) using the
71
+ bundled demo assets, no setup:**
72
+ ```bash
73
+ make test # expects: RESULT: 7 passed, 0 failed
74
+ ```
75
+ In the **browser** (port 3000): pick `gemma-e4b` and chat in Persian; attach `assets/test_image.png`
76
+ and ask about it; for audio, import the Pipe (Admin → Functions) then pick “Gemma 4 · Omni (audio)”
77
+ and attach `assets/test_audio_en.wav`.
78
+
79
+ ## Verify from the shell
80
+ ```bash
81
+ curl localhost:8080/v1/models # llama-swap lists the profiles
82
+ curl localhost:8081/v1/embeddings -d '{"model":"x","input":["سلام"]}' # embedder
83
+ curl localhost:8082/rerank -d '{"query":"پایتخت ایران","texts":["تهران","موز"]}' # reranker
84
+ # native audio (after: ffmpeg -i clip.any -ar 16000 -ac 1 clip.wav):
85
+ B64=$(base64 -w0 clip.wav); curl localhost:8080/v1/chat/completions -H 'Content-Type: application/json' \
86
+ -d '{"model":"gemma-e4b","messages":[{"role":"user","content":[{"type":"text","text":"این صدا را بنویس"},{"type":"input_audio","input_audio":{"data":"'"$B64"'","format":"wav"}}]}]}'
87
+ ```
88
+
89
+ ## Tuning (`scripts/bench.sh`)
90
+ `./scripts/download-models.sh --weights` then `./scripts/bench.sh /models/gemma-4-E4B-it-Q4_K_M.gguf`
91
+ to sweep `--threads`. Set the winner as `THREADS` in `.env`. Long context? add KV quant
92
+ (`-ctk q8_0 -ctv q8_0`) to the macro in `llama-swap/config.tmpl.yaml`.
93
+
94
+ ## Troubleshooting (the known traps)
95
+ - **Audio crashes / asserts** → keep `-b 2048 -ub 2048` (already in the macro). Default 512 crashes.
96
+ - **Audio is garbage/repetitive** → the mmproj must be **BF16** (we pin it). Q8_0/F16 break audio.
97
+ - **`llama-server` not found in llama-swap** → adjust the binary path in `config.tmpl.yaml` / `entrypoint.sh`.
98
+ - **`llama-swap` flags differ** → check `docker run --rm <llamaswap-image> llama-swap --help`; fix `entrypoint.sh`.
99
+ - **Audio “No audio found” in the Pipe** → Open WebUI's file-store API differs by version;
100
+ adapt `_resolve_local_path()` in the Pipe.
101
+ - **Embeddings fail / wrong model** → set every `RAG_*` env explicitly (Open WebUI doesn't inherit
102
+ `OPENAI_*`); verify in Admin → Documents. Hakim won't load? set `TEI_EMBED_MODEL=BAAI/bge-m3`
103
+ and `TEI_EMBED_POOLING=cls`.
104
+ - **Reranker freezes UI / format mismatch** → fall back to Open WebUI's built-in reranker: remove the
105
+ `RAG_RERANKING_ENGINE=external` + URL envs and set `RAG_RERANKING_MODEL=BAAI/bge-reranker-v2-m3`.
106
+
107
+ ## Operational notes (validated live on a 15 GB CPU box)
108
+ - **Gemma 4 is a reasoning model** — it emits `reasoning_content` before `content`. Give it a
109
+ generous `max_tokens` (≥256) or short prompts will return empty `content` (all budget spent
110
+ thinking). The Pipe/UI handle this; raw API callers should read both fields.
111
+ - **Send media via a file, not inline** — base64 audio/large images exceed the shell arg limit
112
+ (`argument list too long`). Use `curl -d @payload.json` (the e2e script and Pipe already do).
113
+ - **TEI memory on CPU**: the ONNX backend is heavy (bge-m3 ≈ 4.75 GB). Setting `TEI_DTYPE=float16`
114
+ makes ONNX bow out and TEI falls back to the lighter **Candle** backend (≈ 3.4 GB) — ~2.4 GB
115
+ saved, same 1024-dim Persian output. That's why `--dtype float16` is in the compose commands.
116
+ - **Tight-RAM tuning (this box)**: dev profile uses `CTX=4096`; E4B is loaded by local path
117
+ (`-m /models/...gguf`) so a studio pause/resume doesn't re-download 5 GB. E4B + bge-m3 +
118
+ reranker + UI ≈ 13–14 GB — fits 15 GB but with little headroom; **16–24 GB is comfortable**.
119
+ - **Verified end-to-end here**: Persian text ✓, image→“Red” ✓, native audio→exact transcript ✓,
120
+ Persian embeddings (1024-d) ✓, Persian rerank ordering ✓, Open WebUI ✓.
121
+
122
+ ## Layout
123
+ ```
124
+ docker-compose.yml · docker-compose.gpu.yml · .env.example
125
+ profiles/{dev-cpu,cpu-server,cpu-server-xl,gpu}.env
126
+ llama-swap/{config.tmpl.yaml,entrypoint.sh}
127
+ openwebui/functions/gemma4_audio_pipe.py
128
+ scripts/{download-models.sh,bench.sh}
129
+ ```
130
+ Full design rationale: `../.claude/plans/i-want-make-a-ancient-teapot.md`.
assets/test_audio_en.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:92cdf7c1bb8821f84413a9f619e24d76fc26473e56b2b3f2fa2d674b1c4c4dd1
3
+ size 27840
assets/test_audio_en.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:26093ab539a21e7bee0df70dcb9e5b107fbeb2cfb796542b0f3f6ade664f76f3
3
+ size 111438
assets/test_image.png ADDED

Git LFS Details

  • SHA256: b5c8938bfdcdaf7a2fc9653cbc29ec301c69d64be6e9a0de732bf7357604099e
  • Pointer size: 129 Bytes
  • Size of remote file: 1.5 kB
docker-compose.gpu.yml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # GPU override — layer on top of the base file:
3
+ # docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
4
+ #
5
+ # Swaps images to CUDA variants, offloads layers to the GPU, and reserves the
6
+ # GPU for the containers. Use with profiles/gpu.env (sets NGL, CTX, models).
7
+ #
8
+ # NOTE: keep --no-mmproj-offload in the llama-swap macro so the BF16 projector
9
+ # stays on CPU and spares VRAM for the model weights (weak-GPU friendly).
10
+ # =============================================================================
11
+
12
+ services:
13
+ llama-swap:
14
+ image: ${LLAMASWAP_IMAGE:-ghcr.io/mostlygeek/llama-swap:cuda}
15
+ deploy:
16
+ resources:
17
+ reservations:
18
+ devices:
19
+ - driver: nvidia
20
+ count: all
21
+ capabilities: [gpu]
22
+
23
+ tei-embed:
24
+ # Default CUDA image targets Turing+; pick an arch-specific tag if needed.
25
+ image: ${TEI_IMAGE:-ghcr.io/huggingface/text-embeddings-inference:1.9}
26
+ deploy:
27
+ resources:
28
+ reservations:
29
+ devices:
30
+ - driver: nvidia
31
+ count: all
32
+ capabilities: [gpu]
33
+
34
+ tei-rerank:
35
+ image: ${TEI_IMAGE:-ghcr.io/huggingface/text-embeddings-inference:1.9}
36
+ deploy:
37
+ resources:
38
+ reservations:
39
+ devices:
40
+ - driver: nvidia
41
+ count: all
42
+ capabilities: [gpu]
docker-compose.yml ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # Gemma 4 CPU-first stack — base (CPU) compose file.
3
+ #
4
+ # Services:
5
+ # llama-swap : OpenAI endpoint that hot-swaps Gemma 4 GGUF profiles
6
+ # tei-embed : HF Text-Embeddings-Inference — Persian embedder (Hakim/bge-m3)
7
+ # tei-rerank : HF Text-Embeddings-Inference — reranker (bge-reranker-v2-m3)
8
+ # openwebui : chat UI + RAG orchestrator + native-audio Pipe host
9
+ #
10
+ # Usage (CPU): docker compose up -d
11
+ # Usage (GPU): docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
12
+ #
13
+ # All tunables come from .env (copy one of profiles/*.env → .env first).
14
+ # =============================================================================
15
+
16
+ x-common: &common
17
+ restart: unless-stopped
18
+ networks: [stack]
19
+
20
+ services:
21
+ # ---------------------------------------------------------------------------
22
+ # LLM gateway: llama-swap renders its config from .env, then loads Gemma 4
23
+ # llama-server on demand (one model resident at a time, idle-unloaded by ttl).
24
+ # ---------------------------------------------------------------------------
25
+ llama-swap:
26
+ <<: *common
27
+ image: ${LLAMASWAP_IMAGE:-ghcr.io/mostlygeek/llama-swap:cpu}
28
+ container_name: llama-swap
29
+ entrypoint: ["/bin/sh", "/config/entrypoint.sh"]
30
+ environment:
31
+ # Rendered into config.yaml by entrypoint.sh (sed), per-machine:
32
+ NGL: ${NGL:-0} # GPU layers to offload (0 = pure CPU)
33
+ THREADS: ${THREADS:-4} # physical cores, not hyperthreads
34
+ CTX: ${CTX:-8192} # context window
35
+ HF_TOKEN: ${HF_TOKEN:-} # for gated Gemma 4 pulls via -hf
36
+ HF_HOME: /models/hf-cache
37
+ LLAMA_CACHE: /models/llama-cache # persist GGUFs fetched by -hf across restarts
38
+ volumes:
39
+ - ./llama-swap:/config
40
+ - ./models:/models
41
+ ports:
42
+ - "${LLAMASWAP_PORT:-8080}:8080"
43
+ healthcheck:
44
+ test: ["CMD-SHELL", "curl -fsS http://localhost:8080/v1/models || exit 1"]
45
+ interval: 30s
46
+ timeout: 5s
47
+ retries: 3
48
+ start_period: 40s
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # RAG embedder (Persian). Default Hakim (FaMTEB #1); fallback bge-m3.
52
+ # Container listens on :80. OpenAI-compatible /v1/embeddings + native /embed.
53
+ # ---------------------------------------------------------------------------
54
+ tei-embed:
55
+ <<: *common
56
+ image: ${TEI_IMAGE:-ghcr.io/huggingface/text-embeddings-inference:cpu-1.9}
57
+ container_name: tei-embed
58
+ command: >-
59
+ --model-id ${TEI_EMBED_MODEL:-BAAI/bge-m3}
60
+ --pooling ${TEI_EMBED_POOLING:-cls}
61
+ --dtype ${TEI_DTYPE:-float16}
62
+ --max-batch-tokens 4096
63
+ --max-client-batch-size 8
64
+ --auto-truncate
65
+ environment:
66
+ HF_TOKEN: ${HF_TOKEN:-}
67
+ volumes:
68
+ - ./models/tei:/data
69
+ ports:
70
+ - "${TEI_EMBED_PORT:-8081}:80"
71
+ healthcheck:
72
+ test: ["CMD-SHELL", "curl -fsS http://localhost:80/health || exit 1"]
73
+ interval: 30s
74
+ timeout: 5s
75
+ retries: 5
76
+ start_period: 60s
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # RAG reranker (cross-encoder). bge-reranker-v2-m3 covers Persian well.
80
+ # Exposes /rerank (Open WebUI external reranker points here).
81
+ # ---------------------------------------------------------------------------
82
+ tei-rerank:
83
+ <<: *common
84
+ image: ${TEI_IMAGE:-ghcr.io/huggingface/text-embeddings-inference:cpu-1.9}
85
+ container_name: tei-rerank
86
+ command: >-
87
+ --model-id ${TEI_RERANK_MODEL:-BAAI/bge-reranker-v2-m3}
88
+ --dtype ${TEI_DTYPE:-float16}
89
+ --max-batch-tokens 4096
90
+ --auto-truncate
91
+ environment:
92
+ HF_TOKEN: ${HF_TOKEN:-}
93
+ volumes:
94
+ - ./models/tei:/data
95
+ ports:
96
+ - "${TEI_RERANK_PORT:-8082}:80"
97
+ healthcheck:
98
+ test: ["CMD-SHELL", "curl -fsS http://localhost:80/health || exit 1"]
99
+ interval: 30s
100
+ timeout: 5s
101
+ retries: 5
102
+ start_period: 60s
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Front-end + RAG orchestrator. Talks to llama-swap (chat/vision) and the
106
+ # two TEI services (embed/rerank). Native audio handled by the imported Pipe.
107
+ # ---------------------------------------------------------------------------
108
+ openwebui:
109
+ <<: *common
110
+ image: ghcr.io/open-webui/open-webui:main
111
+ container_name: openwebui
112
+ environment:
113
+ # --- chat backend -------------------------------------------------------
114
+ OPENAI_API_BASE_URL: http://llama-swap:8080/v1
115
+ OPENAI_API_KEY: ${OPENAI_API_KEY:-sk-local}
116
+ ENABLE_OLLAMA_API: "false"
117
+ DEFAULT_MODELS: ${DEFAULT_MODEL:-gemma-e4b}
118
+ # --- RAG embeddings via TEI (set RAG_* explicitly; OWUI does not inherit
119
+ # OPENAI_* — see Open WebUI issues #8697 / #22084) ------------------
120
+ RAG_EMBEDDING_ENGINE: openai
121
+ RAG_OPENAI_API_BASE_URL: http://tei-embed:80/v1
122
+ RAG_OPENAI_API_KEY: ${TEI_API_KEY:-x}
123
+ RAG_EMBEDDING_MODEL: ${TEI_EMBED_MODEL:-MCINext/Hakim}
124
+ RAG_EMBEDDING_BATCH_SIZE: "16"
125
+ # --- hybrid retrieval + external reranker via TEI ----------------------
126
+ ENABLE_RAG_HYBRID_SEARCH: "true"
127
+ RAG_RERANKING_ENGINE: external
128
+ RAG_EXTERNAL_RERANKER_URL: http://tei-rerank:80/rerank
129
+ RAG_EXTERNAL_RERANKER_API_KEY: ${TEI_API_KEY:-x}
130
+ RAG_TOP_K: "8"
131
+ RAG_TOP_K_RERANKER: "4"
132
+ # --- native audio is the Pipe's job; turn STT off ----------------------
133
+ AUDIO_STT_ENGINE: ""
134
+ WEBUI_NAME: ${WEBUI_NAME:-Gemma 4 Local}
135
+ volumes:
136
+ - ./openwebui/data:/app/backend/data
137
+ # functions/ is mounted for convenience; import the Pipe via Admin → Functions
138
+ - ./openwebui/functions:/app/backend/data/functions-src:ro
139
+ ports:
140
+ - "${WEBUI_PORT:-3000}:8080"
141
+ depends_on:
142
+ - llama-swap
143
+ - tei-embed
144
+ - tei-rerank
145
+
146
+ networks:
147
+ stack:
148
+ driver: bridge
llama-swap/config.tmpl.yaml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # llama-swap config TEMPLATE. entrypoint.sh sed-substitutes ${NGL} ${THREADS}
3
+ # ${CTX} from the environment, then llama-swap resolves ${base} (macro) and
4
+ # ${PORT} (its own dynamic per-model port) at runtime. Do NOT pre-substitute
5
+ # ${base} or ${PORT}.
6
+ #
7
+ # Each "models:" key is the model name you pass in the OpenAI API "model" field
8
+ # (and what Open WebUI shows). ttl unloads an idle model so RAM frees for the
9
+ # next swap.
10
+ #
11
+ # Audio correctness (E4B / 12B): BF16 mmproj + -ub 2048 + --jinja are mandatory.
12
+ # Binary note: the llama-swap "unified" images ship llama-server on PATH. If your
13
+ # image differs, replace "llama-server" below with its absolute path.
14
+ # =============================================================================
15
+
16
+ healthCheckTimeout: 300
17
+ logLevel: info
18
+
19
+ macros:
20
+ base: >-
21
+ llama-server --host 0.0.0.0 --port ${PORT}
22
+ --jinja --no-mmproj-offload -ngl ${NGL}
23
+ --threads ${THREADS} -b 2048 -ub 2048
24
+ --temp 1.0 --top-k 64 --top-p 0.95
25
+ --metrics
26
+
27
+ models:
28
+ # ---- E4B: audio + image + text (dev default, ~7-8 GB) ---------------------
29
+ "gemma-e4b":
30
+ cmd: >-
31
+ ${base}
32
+ -m /models/gemma-4-E4B-it-Q4_K_M.gguf
33
+ --mmproj /models/mmproj-gemma-4-E4B-it-bf16.gguf
34
+ -c ${CTX}
35
+ ttl: 600
36
+
37
+ # ---- 12B: audio + image + text (server default, ~10-12 GB) ----------------
38
+ "gemma-12b":
39
+ cmd: >-
40
+ ${base}
41
+ -hf ggml-org/gemma-4-12B-it-GGUF:Q4_K_M
42
+ --mmproj /models/mmproj-gemma-4-12B-it-bf16.gguf
43
+ -c ${CTX}
44
+ ttl: 600
45
+
46
+ # ---- 26B-A4B MoE: image + text ONLY, no audio (~16-18 GB) -----------------
47
+ "gemma-26b-a4b":
48
+ cmd: >-
49
+ ${base}
50
+ -hf ggml-org/gemma-4-26B-A4B-it-GGUF:Q4_K_M
51
+ --mmproj /models/mmproj-gemma-4-26B-A4B-it-bf16.gguf
52
+ -c ${CTX}
53
+ ttl: 300
llama-swap/entrypoint.sh ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # Render llama-swap config from the template using env knobs, then launch.
3
+ # Substitutes only ${NGL} ${THREADS} ${CTX} — leaves ${base} and ${PORT} for
4
+ # llama-swap to resolve itself.
5
+ set -eu
6
+
7
+ NGL="${NGL:-0}"
8
+ THREADS="${THREADS:-4}"
9
+ CTX="${CTX:-8192}"
10
+
11
+ TMPL="/config/config.tmpl.yaml"
12
+ OUT="/tmp/config.yaml"
13
+
14
+ sed \
15
+ -e "s|\${NGL}|${NGL}|g" \
16
+ -e "s|\${THREADS}|${THREADS}|g" \
17
+ -e "s|\${CTX}|${CTX}|g" \
18
+ "$TMPL" > "$OUT"
19
+
20
+ echo "===================================================================="
21
+ echo "[entrypoint] rendered $OUT (NGL=$NGL THREADS=$THREADS CTX=$CTX)"
22
+ echo "===================================================================="
23
+ cat "$OUT"
24
+ echo "===================================================================="
25
+
26
+ BIN="$(command -v llama-swap || echo /app/llama-swap)"
27
+ echo "[entrypoint] launching: $BIN --config $OUT --listen 0.0.0.0:8080"
28
+ exec "$BIN" --config "$OUT" --listen 0.0.0.0:8080
profiles/cpu-server-xl.env ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Profile: large CPU server (>= 24 GiB RAM) for the 26B-A4B MoE.
2
+ # NOTE: 26B-A4B is IMAGE + TEXT only (no audio). Use gemma-12b if you need audio.
3
+ HF_TOKEN=
4
+
5
+ NGL=0
6
+ THREADS=24 # <-- physical cores
7
+ CTX=16384
8
+ DEFAULT_MODEL=gemma-26b-a4b
9
+
10
+ TEI_EMBED_MODEL=BAAI/bge-m3
11
+ TEI_EMBED_POOLING=cls
12
+ TEI_RERANK_MODEL=BAAI/bge-reranker-v2-m3
13
+ TEI_DTYPE=float16 # float16 -> lighter Candle backend on CPU (saves RAM)
14
+
15
+ LLAMASWAP_IMAGE=ghcr.io/mostlygeek/llama-swap:cpu
16
+ TEI_IMAGE=ghcr.io/huggingface/text-embeddings-inference:cpu-1.9
17
+
18
+ WEBUI_PORT=3000
19
+ LLAMASWAP_PORT=8080
20
+ TEI_EMBED_PORT=8081
21
+ TEI_RERANK_PORT=8082
22
+ OPENAI_API_KEY=sk-local
23
+ TEI_API_KEY=x
24
+ WEBUI_NAME=Gemma 4 Local (xl)
profiles/cpu-server.env ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Profile: a bigger CPU server (>= 16 GiB RAM, many cores).
2
+ # Default model 12B (audio+image+text). Set THREADS to the server's PHYSICAL cores.
3
+ HF_TOKEN=
4
+
5
+ NGL=0
6
+ THREADS=16 # <-- set to physical core count of the server
7
+ CTX=16384
8
+ DEFAULT_MODEL=gemma-12b
9
+
10
+ TEI_EMBED_MODEL=BAAI/bge-m3
11
+ TEI_EMBED_POOLING=cls
12
+ TEI_RERANK_MODEL=BAAI/bge-reranker-v2-m3
13
+ TEI_DTYPE=float16 # float16 -> lighter Candle backend on CPU (saves RAM)
14
+
15
+ LLAMASWAP_IMAGE=ghcr.io/mostlygeek/llama-swap:cpu
16
+ TEI_IMAGE=ghcr.io/huggingface/text-embeddings-inference:cpu-1.9
17
+
18
+ WEBUI_PORT=3000
19
+ LLAMASWAP_PORT=8080
20
+ TEI_EMBED_PORT=8081
21
+ TEI_RERANK_PORT=8082
22
+ OPENAI_API_KEY=sk-local
23
+ TEI_API_KEY=x
24
+ WEBUI_NAME=Gemma 4 Local (server)
profiles/dev-cpu.env ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Profile: this 15 GiB Lightning dev box (Xeon 8470, 4 phys cores, no GPU).
2
+ # Default model E4B (audio+image+text) — comfortably fits with TEI + Open WebUI.
3
+ HF_TOKEN=
4
+
5
+ NGL=0
6
+ THREADS=4
7
+ CTX=8192
8
+ DEFAULT_MODEL=gemma-e4b
9
+
10
+ TEI_EMBED_MODEL=BAAI/bge-m3
11
+ TEI_EMBED_POOLING=cls
12
+ TEI_RERANK_MODEL=BAAI/bge-reranker-v2-m3
13
+ TEI_DTYPE=float16 # float16 -> lighter Candle backend on CPU (saves RAM)
14
+
15
+ LLAMASWAP_IMAGE=ghcr.io/mostlygeek/llama-swap:cpu
16
+ TEI_IMAGE=ghcr.io/huggingface/text-embeddings-inference:cpu-1.9
17
+
18
+ WEBUI_PORT=3000
19
+ LLAMASWAP_PORT=8080
20
+ TEI_EMBED_PORT=8081
21
+ TEI_RERANK_PORT=8082
22
+ OPENAI_API_KEY=sk-local
23
+ TEI_API_KEY=x
24
+ WEBUI_NAME=Gemma 4 Local (dev)
profiles/gpu.env ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Profile: a (weak) GPU box. Use with the GPU override:
2
+ # cp profiles/gpu.env .env
3
+ # docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
4
+ #
5
+ # NGL=99 offloads all model layers; lower it (e.g. 20) if VRAM < model size.
6
+ # The BF16 mmproj stays on CPU (--no-mmproj-offload in the llama-swap macro).
7
+ HF_TOKEN=
8
+
9
+ NGL=99
10
+ THREADS=8
11
+ CTX=32768
12
+ DEFAULT_MODEL=gemma-12b
13
+
14
+ TEI_EMBED_MODEL=BAAI/bge-m3
15
+ TEI_EMBED_POOLING=cls
16
+ TEI_RERANK_MODEL=BAAI/bge-reranker-v2-m3
17
+ TEI_DTYPE=float16 # float16 -> lighter Candle backend on CPU (saves RAM)
18
+
19
+ # CUDA images (override base):
20
+ LLAMASWAP_IMAGE=ghcr.io/mostlygeek/llama-swap:cuda
21
+ TEI_IMAGE=ghcr.io/huggingface/text-embeddings-inference:1.9
22
+
23
+ WEBUI_PORT=3000
24
+ LLAMASWAP_PORT=8080
25
+ TEI_EMBED_PORT=8081
26
+ TEI_RERANK_PORT=8082
27
+ OPENAI_API_KEY=sk-local
28
+ TEI_API_KEY=x
29
+ WEBUI_NAME=Gemma 4 Local (gpu)
scripts/bench.sh ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Sweep llama-bench across thread counts on this machine to pick the best
3
+ # --threads for a given Gemma 4 GGUF. Results saved under scripts/bench-results/.
4
+ #
5
+ # ./bench.sh /models/gemma-4-E4B-it-Q4_K_M.gguf
6
+ # THREADS_LIST="2 4 8" ./bench.sh <gguf-path-inside-container>
7
+ #
8
+ # Requires the GGUF present in ./models (pre-download with download-models.sh
9
+ # --weights). Uses the prebuilt llama.cpp image (CPU).
10
+ set -euo pipefail
11
+ cd "$(dirname "$0")/.."
12
+
13
+ MODEL="${1:?usage: bench.sh /models/<file>.gguf (path is inside the container)}"
14
+ THREADS_LIST="${THREADS_LIST:-1 2 4 8}"
15
+ IMAGE="${LLAMA_IMAGE:-ghcr.io/ggml-org/llama.cpp:full}"
16
+ OUT="scripts/bench-results"; mkdir -p "$OUT"
17
+ STAMP="$(date +%Y%m%d-%H%M%S)"
18
+ REPORT="$OUT/bench-$STAMP.md"
19
+
20
+ echo "# llama-bench $(uname -n) $STAMP" | tee "$REPORT"
21
+ echo "model: $MODEL" | tee -a "$REPORT"
22
+ echo | tee -a "$REPORT"
23
+
24
+ for t in $THREADS_LIST; do
25
+ echo "## threads=$t" | tee -a "$REPORT"
26
+ docker run --rm -v "$PWD/models:/models" "$IMAGE" \
27
+ llama-bench -m "$MODEL" -t "$t" -p 512 -n 128 2>/dev/null \
28
+ | tee -a "$REPORT" || echo " (run failed for t=$t)" | tee -a "$REPORT"
29
+ echo | tee -a "$REPORT"
30
+ done
31
+
32
+ echo "Saved: $REPORT"
scripts/download-models.sh ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Download the Gemma 4 BF16 mmproj projectors (REQUIRED — must be pinned, never
3
+ # auto-fetched) and, optionally, the LLM GGUF weights. The LLM weights are also
4
+ # fetched automatically by llama-swap's `-hf ...:Q4_K_M` on first request, so by
5
+ # default we only pull the mmproj files.
6
+ #
7
+ # ./download-models.sh # mmproj only (fast, required)
8
+ # ./download-models.sh --weights # also pre-pull the Q4_K_M GGUFs
9
+ #
10
+ # Prereq: huggingface-cli logged in (Gemma is gated):
11
+ # pip install -U "huggingface_hub[cli]" && huggingface-cli login
12
+ # (and accept the Gemma 4 license once on huggingface.co)
13
+ set -euo pipefail
14
+
15
+ cd "$(dirname "$0")/.."
16
+ DEST="models"
17
+ mkdir -p "$DEST"
18
+
19
+ HF="$(command -v huggingface-cli || command -v hf || true)"
20
+ if [ -z "$HF" ]; then
21
+ echo "ERROR: huggingface-cli not found. Run: pip install -U 'huggingface_hub[cli]'" >&2
22
+ exit 1
23
+ fi
24
+
25
+ # repo : mmproj-filename : quant-filename
26
+ MODELS="
27
+ ggml-org/gemma-4-E4B-it-GGUF:mmproj-gemma-4-E4B-it-bf16.gguf:gemma-4-E4B-it-Q4_K_M.gguf
28
+ ggml-org/gemma-4-12B-it-GGUF:mmproj-gemma-4-12B-it-bf16.gguf:gemma-4-12B-it-Q4_K_M.gguf
29
+ ggml-org/gemma-4-26B-A4B-it-GGUF:mmproj-gemma-4-26B-A4B-it-bf16.gguf:gemma-4-26B-A4B-it-Q4_K_M.gguf
30
+ "
31
+
32
+ WEIGHTS=0
33
+ [ "${1:-}" = "--weights" ] && WEIGHTS=1
34
+
35
+ dl() { # repo file
36
+ echo ">> $1 :: $2"
37
+ "$HF" download "$1" "$2" --local-dir "$DEST" || \
38
+ echo " !! could not fetch $2 — verify the exact filename on the HF repo 'Files' tab" >&2
39
+ }
40
+
41
+ for row in $MODELS; do
42
+ repo="${row%%:*}"; rest="${row#*:}"
43
+ mmproj="${rest%%:*}"; quant="${rest#*:}"
44
+ dl "$repo" "$mmproj" # REQUIRED (BF16, pinned for audio)
45
+ [ "$WEIGHTS" = "1" ] && dl "$repo" "$quant"
46
+ done
47
+
48
+ echo
49
+ echo "Done. mmproj files:"
50
+ ls -lh "$DEST"/mmproj-*.gguf 2>/dev/null || echo " (none — check errors above)"
51
+ echo
52
+ echo "TEI embedder/reranker download themselves on first start into models/tei/."
scripts/e2e-test.sh ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # End-to-end smoke tests for the Gemma 4 stack. Exit non-zero if anything fails.
3
+ # ./scripts/e2e-test.sh
4
+ # Env overrides: LS=http://localhost:8080 EMB=http://localhost:8081
5
+ # RRK=http://localhost:8082 OWUI=http://localhost:3000
6
+ # MODEL=gemma-e4b TEST_WAV=/path/clip.wav TEST_IMG=/path/img.png
7
+ set -u
8
+
9
+ LS="${LS:-http://localhost:8080}"
10
+ EMB="${EMB:-http://localhost:8081}"
11
+ RRK="${RRK:-http://localhost:8082}"
12
+ OWUI="${OWUI:-http://localhost:3000}"
13
+ MODEL="${MODEL:-gemma-e4b}"
14
+ # Auto-use the bundled demo assets so `make test` needs no setup.
15
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
16
+ TEST_IMG="${TEST_IMG:-$ROOT/assets/test_image.png}"
17
+ TEST_WAV="${TEST_WAV:-$ROOT/assets/test_audio_en.wav}"
18
+
19
+ PASS=0; FAIL=0
20
+ ok(){ echo " ✅ $1"; PASS=$((PASS+1)); }
21
+ no(){ echo " ❌ $1"; FAIL=$((FAIL+1)); }
22
+ hdr(){ echo; echo "== $1 =="; }
23
+
24
+ jqget(){ python3 -c "import sys,json;d=json.load(sys.stdin);print(eval(sys.argv[1]))" "$1" 2>/dev/null; }
25
+
26
+ hdr "1) llama-swap lists model profiles"
27
+ MODELS=$(curl -fsS "$LS/v1/models" 2>/dev/null)
28
+ echo "$MODELS" | grep -q "$MODEL" && ok "model '$MODEL' present" || no "model '$MODEL' missing"
29
+
30
+ hdr "2) Gemma text chat (Persian)"
31
+ RESP=$(curl -fsS "$LS/v1/chat/completions" -H 'Content-Type: application/json' -d \
32
+ "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"به فارسی یک جمله کوتاه بنویس.\"}],\"max_tokens\":512,\"temperature\":1.0}" 2>/dev/null)
33
+ TXT=$(echo "$RESP" | jqget "(d['choices'][0]['message'].get('content') or d['choices'][0]['message'].get('reasoning_content') or '')")
34
+ [ -n "$TXT" ] && ok "got reply: ${TXT:0:60}" || no "no text reply ($RESP)"
35
+
36
+ hdr "3) Gemma vision (image)"
37
+ if [ -n "$TEST_IMG" ] && [ -f "$TEST_IMG" ]; then
38
+ python3 -c "import json,base64;b=base64.b64encode(open('$TEST_IMG','rb').read()).decode();json.dump({'model':'$MODEL','messages':[{'role':'user','content':[{'type':'text','text':'Describe this image in one short sentence.'},{'type':'image_url','image_url':{'url':'data:image/png;base64,'+b}}]}],'max_tokens':512},open('/tmp/e2e_img.json','w'))"
39
+ R=$(curl -s -m 600 "$LS/v1/chat/completions" -H 'Content-Type: application/json' -d @/tmp/e2e_img.json 2>/dev/null)
40
+ V=$(echo "$R" | jqget "(d['choices'][0]['message'].get('content') or d['choices'][0]['message'].get('reasoning_content') or '')")
41
+ [ -n "$V" ] && ok "vision reply: ${V:0:60}" || no "no vision reply ($R)"
42
+ else
43
+ echo " ⏭ skipped (set TEST_IMG=/path/img.png)"
44
+ fi
45
+
46
+ hdr "4) Gemma native audio (input_audio)"
47
+ if [ -n "$TEST_WAV" ] && [ -f "$TEST_WAV" ]; then
48
+ python3 -c "import json,base64;b=base64.b64encode(open('$TEST_WAV','rb').read()).decode();json.dump({'model':'$MODEL','messages':[{'role':'user','content':[{'type':'text','text':'Transcribe this audio exactly.'},{'type':'input_audio','input_audio':{'data':b,'format':'wav'}}]}],'max_tokens':512,'temperature':1.0},open('/tmp/e2e_aud.json','w'))"
49
+ R=$(curl -s -m 1500 "$LS/v1/chat/completions" -H 'Content-Type: application/json' -d @/tmp/e2e_aud.json 2>/dev/null)
50
+ A=$(echo "$R" | jqget "(d['choices'][0]['message'].get('content') or d['choices'][0]['message'].get('reasoning_content') or '')")
51
+ [ -n "$A" ] && ok "audio reply: ${A:0:80}" || no "no audio reply ($R)"
52
+ else
53
+ echo " ⏭ skipped (set TEST_WAV=/path/clip.wav)"
54
+ fi
55
+
56
+ hdr "5) TEI embeddings (Persian)"
57
+ R=$(curl -fsS "$EMB/v1/embeddings" -H 'Content-Type: application/json' -d \
58
+ '{"model":"x","input":["پایتخت ایران تهران است"]}' 2>/dev/null)
59
+ DIM=$(echo "$R" | jqget "len(d['data'][0]['embedding'])")
60
+ [ -n "$DIM" ] && [ "$DIM" -gt 0 ] 2>/dev/null && ok "embedding dim=$DIM" || no "no embedding ($R)"
61
+
62
+ hdr "6) TEI rerank (Persian relevance ordering)"
63
+ R=$(curl -fsS "$RRK/rerank" -H 'Content-Type: application/json' -d \
64
+ '{"query":"پایتخت ایران کجاست؟","texts":["تهران پایتخت ایران است.","موز یک میوه است."]}' 2>/dev/null)
65
+ TOP=$(echo "$R" | python3 -c "import sys,json;d=json.load(sys.stdin);d=sorted(d,key=lambda x:-x['score']);print(d[0]['index'])" 2>/dev/null)
66
+ [ "$TOP" = "0" ] && ok "relevant text ranked first" || no "rerank wrong/failed (top=$TOP, $R)"
67
+
68
+ hdr "7) Open WebUI reachable"
69
+ CODE=$(curl -s -o /dev/null -w "%{http_code}" "$OWUI/health" 2>/dev/null)
70
+ [ "$CODE" = "200" ] && ok "Open WebUI /health 200" || no "Open WebUI not healthy (HTTP $CODE)"
71
+
72
+ echo; echo "=================================================="
73
+ echo " RESULT: $PASS passed, $FAIL failed"
74
+ echo "=================================================="
75
+ [ "$FAIL" -eq 0 ]