Compare commits

..
224 changed files with 4809 additions and 14523 deletions
-1
View File
@@ -37,4 +37,3 @@ __pycache__/
# Editor/OS noise
*.swp
.DS_Store
third_party/
+137 -295
View File
@@ -9,11 +9,11 @@ services run from `/home/dank/serverAppConfigs` on each host (the 2026-08-22
merge absorbed the former `vllmScripts` repo, history preserved).
- **Dankserver** (192.168.0.235) — media/library stack (RomM, Copyparty,
Calibre-Web, Jellyfin, Plex) plus the `frank-primary` LLM service.
Calibre-Web, Jellyfin, Plex) plus the `frank-medium` LLM service.
- **Dankclaw** (192.168.0.143) — AI/search/dev/observability stack (SearXNG
via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus, Loki,
Alloy, Pi-hole) plus TWO LLM services: `frank-large` (stock llama.cpp on
the gfx1151 iGPU) and `frank-small` (SGLang on the RTX 3090). Caddy on
Alloy, Pi-hole) plus TWO LLM services: `frank-large` (halogen-flash-server on
the gfx1151 iGPU) and `frank-small` (llama.cpp on the RTX 3090). Caddy on
Dankclaw reverse-proxies both hosts.
- **FrankBook-Pro** (192.168.0.112) — macOS mini running the *arr stack
(Prowlarr, Sonarr, Radarr, Lidarr, Readarr, qBittorrent) as quadlets inside
@@ -24,9 +24,9 @@ merge absorbed the former `vllmScripts` repo, history preserved).
| Target | Service | Runtime | Model | Port |
|--------|---------|---------|-------|------|
| Dankserver | `frank-primary` | tcclaviger RDNA4 vLLM fork (`docker.io/tcclaviger/vllm@sha256:da4029666…`, digest-pinned, plain `podman run`, root systemd unit) | `orcarouter/Qwen3.8-Flash-Next-Uncensored-FP8` (~172.8 GiB, 131 shards), TP=2, expert offload automatic (fork plans it), prefetch OFF, **bf16 KV** (`--kv-cache-dtype auto`), **no speculative decode**, MNBT 2048, MoE gather 32×64, **2 seqs × 262144 ctx (the model's full native window)** at KV_BYTES 7,300,000,000/rank (bf16 measures 13,839 B/token), with **`--expert-precise-memory`** — the fitted 5.25 GiB overhead instead of the 7.60 GiB default, which hands the compile/graph-capture headroom to the expert arena (~7,278 slots vs 6,248) — util 0.98, and the client contract wired: `--reasoning-parser qwen3` (thinking in `reasoning`, never inline), `--enable-auto-tool-choice --tool-call-parser qwen3_xml`, default chat-template kwargs `enable_thinking` + `preserve_thinking`. Capacity history: five full-length contexts boot but leave ~0 bytes free at runtime (a real request with `max_tokens=261149` killed the worker: `Tried to allocate 50.00 MiB ... 0 bytes free`); six never fit (6×241664 asked 10.70 GiB vs 9.18 GiB free). DCP cannot help here — it requires TP > the model's 2 KV heads, i.e. four GPUs. fp8+MTP4 shape restorable from git tag `frank-primary-fp8-mtp-20261001` | 7770 |
| Dankclaw | `frank-large` | gufo `gufo-org/gufo` v0.1.1, the Strix Halo engine, BUILT FROM SOURCE (root systemd unit; links the host ROCm 7.2.4) | `unsloth/Qwen3.8-27B-GGUF` **UD-Q4_K_XL** (27B dense, **vanilla — no longer abliterated**, 16.35 GiB, Apache-2.0) with the z-lab DFlash2 Q4_K_M drafter (adaptive block policy, cap 7) on the gfx1151 iGPU (Strix Halo LPDDR, 114 GiB GTT), **4 sessions x 163840 ctx, unquantized (f16) KV** — gufo has no KV-quant lever — greedy, gufo's compiled-in Qwen renderer (NO Qwen-Sharp), text only. Swapped 2026-10-01 from ajgazin's abliterated Swift-1.5 UD-Q8_K_XL (29.3 GiB): ~13 GiB less weights is what funds four f16 sessions, at the cost of the uncensored behaviour | 7770 |
| Dankclaw | `frank-small` | SGLang in a self-contained python3.12 venv (root systemd unit; the venv carries its own CUDA 13 stack — see the runbook) | letechlead MiMo-V2.6-Distill-Qwen-9B-INT8-W8A16-AutoRound (qwen3_5 hybrid, 9B dense, 12.0 GiB, INT8 W8A16 AutoRound, in-checkpoint vision tower, NO MTP weights shipped so no spec decode) on the RTX 3090, fp8 KV, pinned 606,208-token pool / 2 slots of 262,144 (native window; 2026-10-01 reslice from 4 x 151,552 for compaction headroom), bf16 mamba states, eager prefill, 83.9 tok/s decode | 7771 |
| Dankserver | `frank-medium` | mainline llama.cpp master fb27a525 (root systemd unit, gfx1201 HIP build) | ajgazin Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP UD-Q8_K_XL + z-lab Qwen3.8-27B-DFlash2 Q8_0 drafter, 2x Radeon AI PRO R9700 in ONE layer-split process (35:30), 262144 ctx over 2 slots, batch 6144x512, text only | 7770 |
| Dankclaw | `frank-large` | halogen-flash-server 0.5.8 (rootless Quadlet) | peonist-ai Qwen3.8-Flash-Next w4b `.hgn` on the gfx1151 iGPU (Strix Halo LPDDR), 200192 ctx per conversation over one 400384-position KV pool / 2 slots, text only | 7770 |
| Dankclaw | `frank-small` | mainline llama.cpp master 37b53fd (root systemd unit) | ISTA-DASLab Qwen3.8-27B-GSQ-RCO IQ3_S (`-mtp` + BF16 mmproj) on the RTX 3090, 131072 ctx / 1 slot, MTP speculative decoding, the fleet's vision endpoint | 7771 |
Retired endpoints (`frank-npu`, and every prior runtime) are in
the [runbook index](#runbook-index). The full swap history lives in
@@ -61,16 +61,15 @@ All edits flow through git. Never edit files on a remote machine directly.
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankserver
./scripts/deploy.sh dankclaw --service searxng
./scripts/deploy.sh dankserver --service frank-primary
./scripts/deploy.sh dankserver --service frank-medium
./scripts/deploy.sh dankclaw --dry-run
```
The planner maps changed paths to services; the remote runner restarts only
the selected service and records its revision outside the checkout.
Documentation-only changes are a no-op. LLM services are selected only by
their own files — every endpoint (`frank-large`/`frank-small`/`frank-primary`)
by its unit/env/serve trio in `scripts/` — so
unrelated
their own files — frank-large by `dankclaw/frank-large/quadlet/`, frank-small
and frank-medium by their unit/env/serve trio in `scripts/` — so unrelated
container changes cannot interrupt inference. Set `FRANK_SUDO_PW` for headless
runs on a host without passwordless sudo; the value never enters the repo.
@@ -123,18 +122,14 @@ This rule exists because reboots found gaps twice (2026-08-26: Caddy as a
bare compose container never returned; 2026-09-11: five observability
services had no units). The observability services now have units under
`dankclaw/observability/quadlet/` and their compose definitions were removed —
one owner per container name. The stack is **configured and delivering as of
2026-09-17**: a Grafana datasource and the `llm-fleet` dashboard are
provisioned from `shared/grafana/provisioning/`, `shared/prometheus/rules/
llm-fleet.yml` loads 5 alert rules + 1 recording rule, and firing rules are
delivered: Prometheus → Alertmanager (`dankclaw/observability/quadlet/
alertmanager.container`, loopback :9093) → host-side Signal relay
(`scripts/signal-relay.py`, user unit, 192.168.0.143:18093 — the host LAN
IP, the one bind a container can reach as `host.containers.internal`;
iptables admits loopback, the host and the podman bridge subnet only) → signal-cli account 318311.
Prometheus targets read down for structural reasons (HTML-scraped "probes",
Gitea metrics off); the remedy is a blackbox exporter. See
`serverAppConfigs-docs/Infrastructure/Observability.md`.
one owner per container name. The stack is **configured as of 2026-09-16**: a
Grafana datasource and the `llm-fleet` dashboard are provisioned from
`shared/grafana/provisioning/`, and `shared/prometheus/rules/llm-fleet.yml`
loads 5 alert rules + 1 recording rule. Nothing is *delivered* — there is no
Alertmanager, so a firing alert reaches nobody (an operator decision, not an
oversight). Five Prometheus targets read down for structural reasons
(HTML-scraped "probes", Gitea metrics off); the remedy is a blackbox exporter.
See `serverAppConfigs-docs/Infrastructure/Observability.md`.
## Testing
@@ -208,27 +203,14 @@ archive.
**STOP the production service BEFORE benchmarking** — these servers serve
real users, and a bench beside a live service starves the GPU and corrupts
results. Use the runtime's own harness: `llama-bench` for the llama.cpp
endpoints, `gufo bench` for frank-large. Rolling your own bench
results. Use the runtime's own harness: `llama-bench` (llama.cpp), `vllm
bench` (vLLM), and halogen-flash-server's own `bench`/`sweep` modes for
frank-large, which is the active runtime there again. Rolling your own bench
requires asking first.
Scratch ports are not used on LLM hosts — a
test process cannot share the single GPU anyway; validate against the service
itself.
**`gufo bench` cannot rank speculative-decode settings, and will invert the
ranking.** Measured 2026-09-28 on frank-large at depth 32768: its corpus
reports `acceptance=0.933 ± 0.000` with an **identical trace sha256** across
draft widths, i.e. the same highly-predictable text every run, and 93 % of
every proposal accepted. It reported 29–37 t/s decode where real traffic
gives ~15 t/s, and it made `--draft-tokens 16` look 6 % faster than 7 when
real text made it 14 % *slower*. Use it for shape, not for A/B. For an A/B,
replay **real** text through the live server and read the `Server-Timing`
header — but note the unit rides in the **name** half (`ttft;dur=402.485`),
so splitting on `=` alone keys the dict `ttft;dur` and every lookup silently
returns nothing. Generate **at least 128 tokens** for the measurement: at 8
tokens, `inter_token` is first-token noise and the run-to-run spread swamps
every real difference.
This covers **any GPU experiment, not just benchmarks** — including a bare
2-rank RCCL/`torch.distributed` probe. On 2026-09-16 one such probe was run
against the live service "because it only needs 200 MB of VRAM"; the host went
@@ -241,17 +223,16 @@ one: `ip neigh show <ip>` from a peer — FAILED means off/NIC dead, while a
merely wedged kernel usually still answers ARP.
1. Stop and mask (self-healing would restart it):
- Dankclaw `frank-large` (root unit, gufo on the iGPU):
`sudo systemctl stop frank-large && sudo systemctl mask frank-large`
- Dankclaw `frank-small` (root unit, SGLang on the 3090):
- Dankclaw `frank-large` (rootless Quadlet again since 2026-09-16):
`systemctl --user stop frank-large`, then move its
`~/.config/containers/systemd/frank-large.container` aside and
`systemctl --user daemon-reload` — the quadlet generator re-creates that
unit from the file on every reload, so the source is the lever, not `mask`.
- Dankclaw `frank-small` (root unit, llama.cpp on the 3090):
`sudo systemctl stop frank-small && sudo systemctl mask frank-small`
- Dankserver `frank-primary` (root unit, tcclaviger vLLM on the 2x R9700):
`sudo systemctl stop frank-primary && sudo systemctl mask frank-primary`
- Dankserver `frank-medium` (system unit):
`sudo systemctl stop frank-medium && sudo systemctl mask frank-medium`
2. Bench.
NOTE: `systemctl mask` FAILS on a relinked unit (the unit file in
`/etc/systemd/system/` is itself a symlink into the checkout — mask
refuses to replace it). Stop is enough for the bench window; the deploy
restores the unit, so re-deploy after benching instead of unmasking.
3. Unmask and start; verify `curl http://192.168.0.<host>:7770/health`.
**CRITICAL:** never `systemctl disable` a system unit on these hosts — it
@@ -263,8 +244,9 @@ deletes the unit file from `/etc/systemd/system/`, not just the symlink.
The harness reaches the fleet through `.omp/agent/models.yml` in the
operator's home — `/home/blank/.omp/agent/models.yml` on the dev box, which is
where the harness runs; there is no copy on the servers. Each provider is one
HOST:PORT; each model `id` is the served alias: `frank-primary` →
HOST:PORT; each model `id` is the served alias: `frank-medium` →
`192.168.0.235:7770`, `frank-large` → `192.168.0.143:7770`, `frank-small` →
`192.168.0.143:7771`. Per-role routing lives beside it in `config.yml` under
`modelRoles`.
Resolution rules: the default model resolves as a `defaultProvider` +
@@ -277,210 +259,102 @@ When renaming a service, retiring an endpoint, or moving a model: update the
provider block, model `id`, `contextWindow`, `maxTokens`, `input` in
`models.yml`, then fix every `modelRoles` entry that named the old endpoint —
a role pointing at a provider the file does not define fails at call time. As
of 2026-10-01 both `frank-small` and `frank-primary` accept images
(`input: [text, image]`) — frank-primary loads the BF16 mmproj explicitly
(this build never autoloads projectors), and frank-small's MiMo payload
bakes its vision tower INTO the checkpoint (no projector file at all);
`frank-large` remains text-only because its gufo deployment loads no
vision projector. Note also that
the reasoning channel differs by runtime: llama.cpp AND SGLang (qwen3
parser, live-verified) emit `reasoning_content`, vLLM emits `reasoning` —
models.yml sets `reasoningContentField` per endpoint to match.
There is **no audio**
input anywhere in the fleet: that capability left with the FLM NPU
endpoint on 2026-09-11, returned 2026-09-17 with the Gemma4 projector's
audio encoder, and left again with the Gemma4 retirement on 2026-10-01
(the MiMo tower is vision-only).
of 2026-09-16 the image path is `frank-small` (`input: [text, image]`), and
`frank-large` is text-only because its halogen deployment loads no vision
tower. There is **no audio** input anywhere in the fleet: that capability left
with the FLM NPU endpoint on 2026-09-11.
### frank-large — gufo, Swift-1.5-Qwen3.8-27B-Uncensored UD-Q8_K_XL (Dankclaw, ACTIVE)
### frank-large — halogen-flash-server (Dankclaw, ACTIVE)
**gufo** (`gufo-org/gufo`) v0.1.1 — "the Strix Halo inference engine", a
vertical engine built for exactly this hardware (Ryzen AI MAX+ 395 /
Radeon 8060S, `gfx1151`) — as a **root systemd unit**
(`scripts/frank-large.service`, `User=dank`), serving
`ajgazin/Swift-1.5-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF`'s `UD-Q8_K_XL`
(27B dense, 29.3 GiB, sha256-verified; **abliterated/uncensored**, and under
the *non-OSI* SWIFT OPEN LICENSE 1.0) on port 7770 from the gfx1151 iGPU.
One session, full 262,144 ctx (the model's native window, `SESSIONS=1`),
DFlash2 spec decode (the z-lab `Qwen3.8-27B-DFlash2-Q4_K_M` drafter, gufo's
**adaptive** block policy, `--draft-tokens 7`), greedy sampling, reasoning
extracted to `reasoning_content`, and **no vision projector**.
halogen-flash-server 0.5.8 as a **rootless Quadlet again since 2026-09-16**
(`dankclaw/frank-large/quadlet/frank-large.container`), serving
`peonist-ai/halogen-qwen3.8-flash-next` (115.55 GiB w4b `.hgn` checkpoint + a
quality overlay that re-fetched to 2.40 GiB on 2026-09-16) on port 7770 from
the gfx1151 iGPU. Two conversations of 200,192 tokens resident (pool 400,384,
2 slots), greedy default, reasoning extracted to `reasoning_content`, and **no
vision tower** — that moved to frank-small. It replaced ik_llama.cpp, which had
replaced it for two days.
**Two swaps landed on 2026-09-28**, both same day: the engine (from the
unsloth llama.cpp b11030 prebuilt, which had replaced Kairic Edge ROCmFPX)
and then the payload (from `unsloth/Qwen3.8-27B-GGUF:UD-Q8_K_XL`, still on
disk and the payload-only rollback). The two targets are structurally
interchangeable for gufo — same `qwen35` architecture, the same 866 tensors
in the same order, the same per-tensor Q8_0/F32/BF16 layout, the same MTP
head at `blk.64` — verified by parsing and diffing both GGUF headers before
either was loaded. **The DFlash2 drafter did not move**, so it has its own
`DRAFTER_DIR` and the deployer's stray-run sweep keys on the drafter's
filename, not the target's.
The engine **fetches its own weights on first start** (`HALOGEN_DOWNLOAD`, into
the `/home/dank/models/halogen` bind mount): clearing that directory and
restarting the unit is the supported way to pick up upstream updates, and it
needs the directory to exist **empty** — a missing directory fails the
container with `Error: statfs /home/dank/models/halogen: no such file or
directory` and the unit loops on `Restart=on-failure` until `mkdir -p` brings
it back. The sidecars are versioned in place, so a stale copy is invisible from
the file name (both overlays were ~95 MB larger upstream than the copies this
host had been serving).
**The runtime is built from source on the host** —
`/home/dank/GitPackages/gufo-v0.1.1/`, reproducible with
`scripts/build-gufo.sh` (`--verify` re-checks an installed tree). gufo ships
no prebuilt Linux binaries and the v0.1.0/v0.1.1 release assets are empty.
Unlike the retired llama.cpp prebuilt, which bundled its own ROCm, **gufo
links the host ROCm 7.2.4 through its RPATH**, so the HIP dev packages must
stay installed. Two host workarounds the build script encodes:
Measured boot ledger (2026-09-16, `HALOGEN_VERBOSE=1` ships in the unit):
device budget `121 − 67.7 − 20 = 33.5 GiB` against a need of `~27.2 GiB`, so
the configured pool was **not clamped**; 68.0 GiB of weights locked + 11.0 GiB
of KV pool + 16.5 GiB of working memory = 95.4 GiB in all, leaving 14.7 GiB
(475 contiguous 2 MiB blocks) for everything else: frank-small on the 3090 and
~28 co-tenant containers. The engine printed its co-tenant stall NOTE and
logged 4,350 compaction stalls (285 failed) while reserving the pool, so read
that as "tight and fragmented", not "out of memory".
- **Ubuntu's `librocwmma-dev` must be absent.** It installs a truncated
rocWMMA header set into `/usr/include` that shadows AMD's complete one
and breaks the build with `'internal/accessors.hpp' file not found`. A
`-I` cannot fix it — in HIP mode the host pass searches the system
directories first. The package has no reverse dependencies.
- **`--gcc-install-dir` must name GCC 15.** Ubuntu 26.04 ships
`/usr/include/c++/16`, which the ROCm clang prefers; its `<atomic_wait.h>`
needs a `std::__detail::__notify_impl` symbol version the installed
`libstdc++.so.6` (GLIBCXX_3.4.35) does not export, and the link fails.
Runbook: `serverAppConfigs-docs/LLM/halogen-flash-server.md`.
Rollback: [[frank-large-ik-uncensored]].
`gufo diagnose` is the preflight and must report `[PASS]` with `gfx1151`.
It also self-caps compute queues (`GPU_MAX_HW_QUEUES`, 2 resident for
`serve llm`) after reading `/sys/class/kfd/kfd/proc/*/queues/*/type`, which
matters because ComfyUI holds queues on the same device; four HIP processes
is the device ceiling.
### frank-small — llama.cpp on the RTX 3090 (Dankclaw, ACTIVE)
**The client contract changed, and the chat template is the sharp edge.**
**gufo has no Jinja engine at all** — `src/models/qwen/chat_template.cpp` is
587 lines of hand-written C++ that appends strings, with no
`--chat-template-file`, no env override, and no template body to point at.
`scripts/qwen-sharp-v22.4.0.jinja` therefore **cannot** be supplied to
frank-large: that is a port, not a config line. Given the choice on
2026-09-28 the decision was to ship on gufo's stock renderer and accept the
loss — the `terse` system-prompt injection, the in-band `<|think_*|>`
control, the `json|xml` `tool_call_format` switch, the tool-argument
truncation and `add_vision_id` prefixes are all gone. gufo's own renderer
does keep the effort instruction strings and `preserve_thinking`, which are
what the harness depends on. **This is the thing to watch**: the endpoint
now runs an *abliterated* model without the prompt that tells it to stop
waffling. The cheap fix, if verbosity shows up, is to send the `terse` text
as an ordinary system message from `models.yml` — not a template change.
`reasoning_effort` accepts the full OpenAI set (all six levels verified
200), so `models.yml`'s `reasoningEffortMap` fold is no longer *required* —
it is kept deliberately, so that removing it does not silently move every
`high` role from `xhigh` to `high` effort inside a swap. And there is no
KV-quantization or prompt-cache-RAM flag; prompt reuse is on by default and
`--cache-disk` is opt-in and off.
Mainline llama.cpp master `37b53fd` as a **root systemd unit** (repo
`scripts/frank-small.service`, `relink_unit`ed into `/etc/systemd/system/`),
serving `ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF` at IQ3_S — the `-mtp` build, so
the model's own Multi-Token Prediction head drives speculative decoding — with
the BF16 mmproj, on port 7771, 131,072 ctx / 1 slot, f16 KV, greedy default.
Deployed flags are in `scripts/frank-small.env`; the serve script refuses to
start if the weights, the projector or the binary are missing.
**Read the perf trade before assuming a win.** gufo's own Q8_K_XL tables
(vs llama.cpp b11069, DFlash2) show prefill +37 % to +80 %, cold load 4.5×
faster, decode +7 % at zero depth and +40 % at 4 users — but **−14 % at
65,536 tokens of depth and −24 % at 131,072**. On the 8-bit target the
crossover is ~32k of cached context, and frank-large runs one 262,144-token
conversation, so long agent sessions sit on the losing side of that line.
The Q4_K_XL target shows no such regression; revisiting the quantization is
a separate decision from the runtime. Peak memory is 50.58 GiB at the full
262,144 context against the iGPU's 114 GiB GTT aperture.
Verified at deploy (2026-09-16): `/v1/models` reports `frank-small`; a chat
completion returns content plus `reasoning_content`; a tool call returns
`tool_calls`; the slot timing lines show `draft acceptance = 0.66–0.94, mean
len ≈ 3` (MTP engaged); an image request is answered from the projector while
the halogen side refuses images with a named reason; 22,362 MiB of the card's
24,576 MiB is in use; `n_slots = 1, n_ctx_slot = 131072`.
The serve script (`scripts/serve-frank-large.sh`) validates runtime → target
GGUF → drafter and exits with a pointer message if any is missing, then
`exec`s `gufo serve --host 0.0.0.0 --port 7770 --sessions 1 llm --model
…--served-model-name frank-large --context 262144 --speculative dflash2 …`.
The deployer's `deploy_llm_large` gates on the **runtime binary** as well
as both payload paths, and returns before it stops anything.
### frank-medium — llama.cpp layer split, DFlash2 + ngram-mod (Dankserver, ACTIVE)
Fetch: `scripts/download-frank-large-swift.sh` (the target; per-file byte
count + sha256 against the Hub LFS oid, no auth header — the repo is public
and a token on the curl command line is world-readable) and
`scripts/download-frank-large.sh` (the DFlash2 drafter, which did not move).
Both take `--verify-only`.
Mainline llama.cpp at **`fb27a525`** (a gfx1201 HIP build, `Root=`-less root
systemd unit `scripts/frank-medium.service`) serving
`ajgazin/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF` at **UD-Q8_K_XL**
(29.29 GiB) with the **z-lab DFlash2** `Q8_0` drafter chained to the
draftless **ngram-mod** matcher, in **one process across both R9700s**,
`--split-mode layer` at a **35:30 tensor split** (the balance point for the
deployed batch: the drafter rides ROCm1, so it takes fewer target layers),
262144 ctx over **2 slots** of 131072, batch 6144x512, text only. DSpark was
put up in the drafter's place the same day and reverted: it declares no sliding
window, so its draft KV spanned the whole context (5.0 GiB) and cost ROCm1 four
layers of the split.
Runbook: `serverAppConfigs-docs/LLM/frank-large-gufo-qwen38-27b.md`.
Rollback, two ways: **payload only** — re-fetch with
`scripts/download-frank-large.sh` and repoint `MODEL_FILE` at
`/home/dank/models/Qwen3.8-27B-UD-Q8_K_XL/Qwen3.8-27B-UD-Q8_K_XL.gguf`; that
29.3 GiB target was **reclaimed on 2026-09-28** (staged to
`~/.AGENTTRASH/20260928-retired-models/`, restorable from there), so it is
no longer a one-minute flip. **Engine** —
[[frank-large-unsloth-qwen38-27b-q8xl]], the b11030 prebuilt at
`/home/dank/GitPackages/unsloth-llama-b11030/`; the only way to get
Qwen-Sharp back. The DFlash2 drafter was never reclaimed and still lives in
`Qwen3.8-27B-UD-Q8_K_XL/`.
**Layer split is why both cards now serve ONE model.** Tensor parallelism is
impossible (see the PCIe lesson below), and so is vLLM's pipeline parallelism —
its stage handoff is an RCCL `send`/`recv` of the hidden state, the exact
primitive that cannot be dispatched on card #2. llama.cpp's layer split needs
no collective at all: layers are distributed in order and the boundary
activation is a per-backend copy, once per forward pass. Measured on this host
(the predecessor runtime): 1991 t/s prefill layer-split vs 272 tensor.
### frank-small — SGLang on the RTX 3090 (Dankclaw, ACTIVE)
This is the box's **only** LLM runtime: the vllm-radiance replica pair and its
routing proxy (`frank-medium-a/-b/-proxy`) were retired the same day they went
live, with their units moved to `~/.AGENTTRASH/20260916-vllm-radiance-retire/`.
SGLang (latest) in a **self-contained python3.12 venv**
(`/home/dank/GitPackages/sglang-cu128/`, torch 2.13.0+cu130) as a **root
systemd unit** (repo `scripts/frank-small.service`, `relink_unit`ed into
`/etc/systemd/system/`), serving
`letechlead/MiMo-V2.6-Distill-Qwen-9B-INT8-W8A16-AutoRound` (9B dense
qwen3_5 hybrid — 8 full-attention + 24 Gated DeltaNet layers — INT8 W8A16
AutoRound, 12.0 GiB, vision tower baked into the checkpoint). Port 7771.
Files: `scripts/build-llama-rdna4.sh` (builds the runtime, gfx1201 only, private
ROCm 7.14 toolchain bundled beside the binary), `scripts/download-frank-medium-gguf.sh`
(resumable, byte-verified payload), `scripts/frank-medium.env`,
`scripts/serve-frank-medium.sh`, `scripts/frank-medium.service`. The deploy
handler refuses to start a unit whose runtime or payload is missing, retires
the vLLM quadlets, waits for 7770 to be released (the retired proxy held it) and
gates on `/health`.
**The shape is memory-pinned, and the sharp edge is the mamba pool.** The
DeltaNet ssm state pool autofills to a fixed ~5.1 GB budget *regardless of
dtype* (fp32 at 110 slots, bf16 at 217 — same 5.1 GB), so both dials are
pinned: `--mamba-ssm-dtype bfloat16 --max-mamba-cache-size 32` (795 MiB).
The KV pool is then **pinned at 606,208 tokens** (`--max-total-tokens`) —
the engine's autofit picks 687,388 and leaves 0.53 GB, which Triton's
deferred kernel loads eat and the first long request OOMs on. Two
262,144-token slots at the model's NATIVE window (2026-10-01 reslice from
4 x 151,552: 2 x 262,144 = 524,288 leaves ~82k tokens of pool slack, and
a compaction request — frank-primary's trigger is 183,501 tokens plus a
32,768-token summary budget = 216,269 — now fits ONE request); a single
request may grow to the whole pool. Prefill runs **eager**
(`--disable-prefill-cuda-graph`) — there is no headroom for capture;
decode graphs are on.
The ref is pinned by two features and must not float: **DFlash2**
(ggml-org/llama.cpp#27342) and **qwen35 MTP**. The ROCmFPX fork the endpoint
used earlier that day has neither.
**No speculative decode — a checkpoint fact.** `text_config` declares
`mtp_num_hidden_layers: 1`, but the shipped safetensors (base BF16 and this
INT8 conversion alike) carry zero `mtp.*` tensors; NEXTN would draft with
an uninitialised 4.24 GiB ghost model at 0% acceptance. The seam gates
that no `--speculative-*` flag reaches the engine.
**The venv carries a CUDA 13.0 nvcc extracted from debs** (system nvcc is
12.4) so the sgl-kernel/flashinfer JIT compiles for sm86; three local
patches ride in the tree (GCC-16 shim, CCCL symlink, rsqrt noexcept).
Details in the runbook.
Verified at deploy (2026-10-01): `/v1/models` reports `frank-small`;
chat returns content plus `reasoning_content` (SGLang's qwen3 parser uses
llama.cpp's field name); a red-square image answers "Red"
(`image_tokens: 64`). At the 4 x 151,552 shape: 4 concurrent 54k-token
requests answered; a 170,019-token prompt prefilled in 50.5 s (~3,370 t/s
eager). At the 2 x 262,144 shape (reslice, same day): boot flags read
`context_len=262144 max_running_requests=2 max_total_num_tokens=606208`;
a 200,000-token prompt — well past frank-primary's 183,501 compaction
trigger — prefilled and completed in ONE request (97.3 s wall, ~2,056
tok/s eager); two concurrent 50k-token requests both answered. Decode
**83.9 tok/s**.
Runbook: `serverAppConfigs-docs/LLM/frank-small-sglang-mimo-v26.md`.
Rollback: [[frank-small-gemma4-12b]] (payload and binary intact on disk).
### frank-primary — tcclaviger vLLM, Qwen3.8-Flash-Next FP8 + MTP4 (Dankserver, ACTIVE)
The **tcclaviger RDNA4 vLLM fork** (`docker.io/tcclaviger/vllm@sha256:da4029666…`,
digest-pinned) serving **`orcarouter/Qwen3.8-Flash-Next-Uncensored-FP8`**
(~172.8 GiB, 131 shards) on **both** Radeon AI PRO R9700s (gfx1201), **TP=2**,
**MTP4** speculative decoding, **expert prefetch OFF**, 200,000 ctx x 3 seqs,
**fp8 KV**. Deployed 2026-10-01 by rollback from commit `2339fae` after the
GLM/DFlash2 experiment was declared failed (same day).
Measured 2026-09-29 tuning campaign: **decode 37.4-40.6 tok/s, acceptance
~54 %** (MTP4 + prefetch off — the fleet's only endpoint that meets the
35 tok/s target). 256k ctx arm measured 34.1-36.5; 2-way concurrency sums
BELOW parity (25.49 vs 26.8 single) — slots are a capacity feature, not a
throughput one. V-only q8 KV is NOT possible via config: stock
`--kv-cache-dtype` is K+V together; a V-only quant needs a fork patch.
The engine auto-sets the attention block size (mamba-page floor); the
expert plan's registry prices KV per token; the expert arena (6,574 slots
x 2.344 MiB = 15.05 GiB/rank) is the decode-critical region — cutting it
for KV headroom collapses the offload hit rate.
Unit and self-healing: root systemd unit (`scripts/frank-primary.service`,
`User=dank`, `LimitMEMLOCK=infinity`), `ExecStop=podman stop -t 120
frank-primary-glm`. The serve script drives `podman run` directly, gates
the digest-pinned image, the payload's `config.json` and its exact
131-shard count and the GPU device nodes, clears stale container
identities, then blocks on `podman wait`. `deploy_llm_primary` gates the
same, waits out :7770, relinks/enables/restarts the unit and health-gates
with the MainPID check. Boot cost ~7 min warm; health returns 503 while
loading.
Runbook: `serverAppConfigs-docs/LLM/dankserver-qwen38-fp8-expert-offload-tuning.md`.
Retired GLM experiment: `serverAppConfigs-docs/LLM/frank-primary-glm53-flash-w4a16.md`.
Runbook: `serverAppConfigs-docs/LLM/frank-medium-llamacpp-dflash2.md`.
Retired runtimes: [[frank-medium-radiance-mxfp4]] (vLLM replicas, same day),
[[frank-medium-rocmfpx]] (llama.cpp fork, earlier that day).
## Key Lessons Learned
Durable operational lessons from the fleet's history. Full detail in the
@@ -502,40 +376,29 @@ linked runbooks.
and the rest behind the chipset (usually not) — that asymmetry is what
blocked frank-medium's TP=2. A one-card collective proves nothing: NCCL
short-circuits at `world_size=1`.
- **That wall was physical, and it was fixed on 2026-09-20 by a board swap.**
Both paths that put two cards on CPU root ports were: an M.2→PCIe x4 riser
into a *CPU-attached* M.2 slot, or a board whose CPU lanes bifurcate to
x8/x8. dankserver took the second: **ASUS ProArt B850-Creator WiFi NEO**,
`PCIEX16_1` + `PCIEX16_2` at x8/x8 Gen5 on root ports `00:01.1` / `00:01.3`,
every bridge now `Routing+` and **both endpoints `ReqEn+`** — the exact
precondition the old test plan named. Bandwidth was never the constraint (a
27B TP=2 allreduce at batch 4 is ≈10 MB per token-step, ≈1.4 ms at Gen4 x4
and under 0.4 ms at Gen5 x8).
**Proven on the same day.** vLLM's R4D one-shot allreduce runs TP=2 here and
frank-medium serves on it; llama.cpp's RCCL path was built too
(`GGML_HIP_RCCL=ON`) and its tensor mode measured — real collective, slower
shape. Note the collective choice matters: TheRock's RCCL builds lack gfx1201
device code (`invalid device function`), which is why R4D is the path vLLM
uses at all. Evidence matrix and the measured three-arm verdict:
[[Dual-R9700-Cooperation]].
- **The fix is physical — and cheap on this board.** Two paths put both cards
on CPU root ports, which restores the atomics routing and therefore TP=2: an
M.2→PCIe x4 riser into a *CPU-attached* M.2 slot (on dankserver's TUF
GAMING B650-E WIFI that is M.2_2, which is free — per ASUS's spec page;
`PCIEX16_2`, where card #2 sits today, is chipset and x1-mode), or a board
whose CPU x16 bifurcates to x8/x8. Bandwidth is not the constraint: a 27B
TP=2 allreduce at batch 4 is ≈10 MB per token-step, ≈1.4 ms at Gen4 x4.
RCCL's P2P `send`/`recv` fails on such a card too, so pipeline parallelism is
not an escape either. Full evidence matrix, the open rebuild hypothesis, and
the test plan: [[Dual-R9700-Cooperation]].
- **When a GPU collective dies, read the runtime, not the framework.** The
message that identified the cause came from `AMD_LOG_LEVEL=3`
(`Pcie atomics not enabled, hostcall not supported` → `AQL dispatch
failed!`), not from vLLM or RCCL. `AMD_LOG_LEVEL` costs one env var and
prints the failing HIP call with its return code.
- **The software path that puts both cards on ONE model without a collective:
llama.cpp's layer split.** On the old board vLLM could not do it in any of its
parallelisms — TP needs an allreduce, PP needs the same p2p primitive, and both
died on the card that could not dispatch an RCCL kernel. `--split-mode
layer` in llama.cpp needs no
- **The one software path that lets both cards serve ONE model: llama.cpp's
layer split.** vLLM cannot do it here in any of its parallelisms — TP needs
an allreduce, PP needs the same p2p primitive, and both die on the card that
cannot dispatch an RCCL kernel. `--split-mode layer` in llama.cpp needs no
collective: layers are distributed in order and only the boundary activation
crosses PCIe, once per forward pass. Measured here: 1991 t/s prefill /
18.8 tok/s decode layer-split, against 272 / 26.3 tensor (butterfly
fallback).
This was what `frank-medium` ran until 2026-09-20, when the board swap made
collectives legal and radiance TP=2 beat it on every axis (95.8 vs 45.7 t/s
decode, 0.21 vs 0.72 s TTFT, 259 vs 72 t/s burst-4) — see
[[frank-medium-radiance-mxfp4]]; the llama shape is the rollback target
fallback). That is what `frank-medium` runs — see
[[frank-medium-llamacpp-dflash2]].
- **Layer split is also the cross-vendor answer — and `-ot` is what cannot be
combined with it.** On Dankclaw (2026-09-16) a CUDA 3090 + Vulkan iGPU layer
@@ -574,10 +437,9 @@ linked runbooks.
**ROCm / GPU**
- **Layer split was the deployed shape on both multi-device llama.cpp services,
for two different reasons.** On Dankserver it was the *only* way both R9700s
served one model (no collective could be dispatched on card #2) until the
2026-09-20 board swap; frank-medium now runs radiance TP=2 instead. On a
- **Layer split is the deployed shape on both multi-device llama.cpp services,
for two different reasons.** On Dankserver it is the *only* way both R9700s
serve one model (no collective can be dispatched on card #2). On a
RCCL-less fork build it is also the fast one: tensor parallel falls back to
a butterfly allreduce and collapses prefill ~7x (measured 271.8 vs 1991 t/s).
Pin the spec-decode drafter to one device — an unpinned draft under tensor
@@ -610,13 +472,9 @@ linked runbooks.
- **QSA flash-attn assert:** `GGML_ASSERT(a-type == GGML_TYPE_F32)` on
qwen4exp needs `scripts/patches/qwen4exp-qsa-f32-mask.patch` — but only on
the agentionai FAST layer set; the kingjones STRIX set works unpatched.
- **GPU budget on Dankclaw is the GTT aperture (~114 GiB), not the
rocm-smi "dedicated" figure.** The Radeon 8060S (gfx1151) reports
116,732 MiB (`llama-server --list-devices`;
`gtt_total=122402963456` in `/sys/class/drm/card1/device/`). The older
"64 GiB, SVM-backed" lesson was already stale — the CIRU Orca predecessor
held 92.6 GiB of weights on the iGPU. `free`/`dmidecode` show the
CPU-side carve-out — intentional, do not change.
- **GPU budget on Dankclaw is the rocm-smi figure** (64 GiB, SVM-backed).
`free`/`dmidecode` show the CPU-side carve-out — intentional, do not
change.
**Load / crash diagnosis**
@@ -735,9 +593,7 @@ linked runbooks.
returns `tool_calls`. Check tool support before routing a role at an endpoint.
- **Quantized KV on CUDA needs `GGML_CUDA_FA_ALL_QUANTS=ON`** — the same
option and the same ~12x prefill collapse described in the ROCm note above.
f16 KV needs nothing; frank-small ran f16 for that reason until
2026-09-17, when `build-cuda` was rebuilt with the option ON so the
endpoint could take q8_0 KV.
f16 KV needs nothing, which is why frank-small runs f16.
- **llama-bench separates a LIST with `/` and SWEEP values with `,`.** Both
`-dev` and `-ts` follow this: `-dev CUDA0,Vulkan1` is two *single-device*
configurations, never one two-device run, and `-ts 0.85,0.15` is two sweep
@@ -827,9 +683,9 @@ Active:
| Service | Runbook |
|---------|---------|
| frank-large (gufo Qwen3.8-27B UD-Q8_K_XL, Dankclaw) | `serverAppConfigs-docs/LLM/frank-large-gufo-qwen38-27b.md` |
| frank-small (SGLang MiMo-V2.6 INT8, Dankclaw) | `serverAppConfigs-docs/LLM/frank-small-sglang-mimo-v26.md` |
| frank-primary (tcclaviger vLLM, Qwen3.8-Flash-Next FP8 + MTP4, Dankserver) | `serverAppConfigs-docs/LLM/dankserver-qwen38-fp8-expert-offload-tuning.md` |
| frank-large (halogen-flash-server, Dankclaw) | `serverAppConfigs-docs/LLM/halogen-flash-server.md` |
| frank-small (llama.cpp Qwen3.8-27B GSQ-RCO, Dankclaw) | `serverAppConfigs-docs/LLM/frank-small-qwen38-gsq-rco.md` |
| frank-medium (llama.cpp + DFlash2, Dankserver) | `serverAppConfigs-docs/LLM/frank-medium-llamacpp-dflash2.md` |
| Dankclaw kernel posture (`amd_iommu=off`) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` |
| Deployment flow + incidents | `serverAppConfigs-docs/LLM/deployment.md` |
@@ -837,21 +693,8 @@ Retired (rollback targets and history):
| Runtime | Host | Window | Runbook |
|---------|------|--------|---------|
|tcclaviger vLLM GLM-5.3-Flash W4A16 int4, +/- DFlash2 drafter (`frank-primary`)|Dankserver|2026-09-30 → 10-01|`frank-primary-glm53-flash-w4a16.md`|
| llama.cpp b11030 Qwen3.8-27B UD-Q8_K_XL + DFlash2 (`frank-large`) | Dankclaw | 2026-09-21 → 09-28 | `frank-large-unsloth-qwen38-27b-q8xl.md` |
| R9V radiance-vLLM Qwen3.8-Flash-Next-Uncensored IQ4_XS (`frank-primary`) | Dankserver | 2026-09-27 (same-day swap to Q8_0) | `frank-primary-r9v-radiance-qwen38-uncensored.md` |
| llama.cpp b11030 Qwen3.8-Flash-Next Q8_0 + shared MTP (`frank-primary`) | Dankserver | 2026-09-26 → 09-27 | `frank-primary-llamacpp-qwen38-flash-next-q8.md` |
| ik_llama.cpp Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M (`frank-large`) | Dankclaw | 2026-09-15 → 09-16 | `frank-large-ik-uncensored.md` |
| vllm-radiance TP=2 MXFP4 + DFlash2 (`frank-medium`) | Dankserver | 2026-09-20 → 09-26 | `frank-medium-radiance-mxfp4.md` |
| Lucebox DS4.1 DeepSeek-V4.1-Flash ROCmFP23 (`frank-primary`) | Dankserver | 2026-09-26 (same-day swap) | `frank-primary-lucebox-ds4.md` |
| kingjones STRIX Qwen3.8-Flash-Next ROCmFP4 | Dankclaw | 2026-08-30 → 09-11 | `kingjones-strix-rocmfp4.md` |
| halogen-flash-server Qwen3.8-Flash-Next w4b (`frank-large`) | Dankclaw | 2026-09-16 → 09-19 | `halogen-flash-server.md` |
| CIRU v4.4.1 Orca Qwen3.8-Flash (`frank-large`) | Dankclaw | 2026-09-19 → 09-21 | `frank-large-ciru-orca.md` |
| unsloth llama.cpp b11030 Qwen3.8-Flash-Next UD-IQ4_XS (`frank-large`) | Dankclaw | 2026-09-21 (same-day swap) | `frank-large-unsloth-qwen38-flash-next.md` |
| Kairic Edge Qwen3.8-27B IU4 W4A4 (`frank-large`) | Dankclaw | 2026-09-21 (same-day swap) | `frank-large-kairic-edge-qwen38-27b.md` |
| ISTA-DASLab Qwen3.8-27B-GSQ-RCO IQ3_S (`frank-small`) | Dankclaw | 2026-09-16 → 09-17 | `frank-small-qwen38-gsq-rco.md` |
| vLLM Gemma4-12B w4a16 (`frank-small`) | Dankclaw | 2026-09-19 (same-day revert) | `frank-small-vllm-gemma4.md` |
| llama.cpp Gemma4-12B QAT Q4_K_M (`frank-small`) | Dankclaw | 2026-09-17 → 10-01 | `frank-small-gemma4-12b.md` |
| ik_llama.cpp Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M (`frank-large`) | Dankclaw | 2026-09-15 → 09-16 | `frank-large-ik-uncensored.md` |
| agentionai ROCmFP4-FAST imatrix | Dankclaw | 2026-08-29 → 08-30 | `agentionai-fast-imatrix.md` |
| eemin Qwen3.8-27B ROCmI4 W4A4 MTP | Dankclaw | 2026-08-18 → 08-25 | `eemin-rocmi4-mtp.md` |
| Kairic Edge IU4 | Dankclaw | 2026-08-22 (same-day revert) | `kairic-edge.md` |
@@ -861,7 +704,6 @@ Retired (rollback targets and history):
| FreeToken (official + Huihui FP8, `frank-small`) | Dankserver | 2026-09-02 → 09-15 | `freetoken-frank-small.md` |
| FastMTP patched llama.cpp | Dankserver | retired 2026-08-18 | `qwen3.8-spec-matrix.md` |
| ik_llama.cpp Ornith CRACK | Dankserver | retired 2026-09-02 | `ikllama-ornith-dankserver.md` |
| llama.cpp layer split + DFlash2 (`frank-medium`) | Dankserver | 2026-09-16 → 09-20 | `frank-medium-llamacpp-dflash2.md` |
Retired runbooks are under `serverAppConfigs-docs/LLM/`. Retired scripts live
in `old/` and `.AGENTTRASH/`.
+1 -1
View File
@@ -13,7 +13,7 @@ one deploy script.
| Host | User | IP | Podman stack | LLM service | LLM port |
|------|------|----|--------------|-------------|----------|
| dankserver | dank | 192.168.0.235 | media and library | frank-medium (ROCmFPX llama.cpp, Qwen3.8-27B AGENT) | 7770 |
| dankclaw | dank | 192.168.0.143 | AI, search, development, observability | frank-large (gufo, Qwen3.8-27B UD-Q8_K_XL) | 7770 |
| dankclaw | dank | 192.168.0.143 | AI, search, development, observability | frank-large (CIRU ROCmFPX, Qwen3.8-27B) | 7770 |
## Active Services
-5
View File
@@ -4,11 +4,6 @@
# SearXNG
SEARXNG_SECRET=changeme
SEARXNG_BASE_URL=http://search.franksdank.cloud/
# Kagi search API key (https://help.kagi.com/kagi/api/). Powers the paid
# `kagi` engine in shared/searxng/settings.yml; deploy-remote.sh bakes it into
# the live settings.yml via scripts/searxng-render-settings.py. The quadlet
# does NOT pass it into the container environment.
KAGI_API_KEY=changeme
# Gluetun VPN (see https://github.com/qdm12/gluetun-wiki)
# Single-peer WireGuard model. Fill from your provider's WireGuard config.
-14
View File
@@ -309,10 +309,6 @@ http://:8017 {
@home host www.franksdank.cloud
handle @home {
root * /srv/homepage
# Deploys overwrite /srv/homepage in place; without revalidation a
# browser keeps serving the pre-deploy CSS/JS/HTML. no-cache forces an
# ETag round-trip on every load (a cheap 304 when unchanged).
header Cache-Control "no-cache"
# RBAC (2026-09-01): /admin requires membership in the homepage-admins
# authentik group. In proxy-provider mode the outpost injects
# X-Authentik-Groups on its upstream request to this listener and strips
@@ -327,16 +323,6 @@ http://:8017 {
}
respond "Forbidden: homepage admins only" 403
}
# User docs page (static). Anchors (#models etc.) are client-side,
# so both /docs and /docs/ map onto the single docs.html.
handle /docs {
try_files /docs.html
file_server
}
handle /docs/ {
try_files /docs.html
file_server
}
@data path /services.json /status.json /app.js /style.css /deployed.js
handle @data {
file_server
-16
View File
@@ -21,20 +21,4 @@ RUN mkdir -p /opt/ComfyUI/models /opt/ComfyUI/input /opt/ComfyUI/output /opt/Com
RUN grep -viE '^(torch|torchvision|torchaudio)([^a-z]|$)' requirements.txt > /tmp/req-notorch.txt \
&& pip install --no-cache-dir -r /tmp/req-notorch.txt
# The city96/ComfyUI-GGUF custom node (loaded from the host's custom_nodes
# volume) needs the gguf library to decode GGUF-quantized diffusion models.
# sentencepiece and protobuf are its optional tokenizer deps. Baking them into
# the image keeps the dependency across container recreations — the node itself
# lives in the host volume, which persists, but the Python env does not.
RUN pip install --no-cache-dir "gguf>=0.13.0" sentencepiece protobuf
# Bake the qwen_image21 patch script into the image. It is run by the
# startup wrapper before ComfyUI starts, so the patch survives container
COPY dankclaw/apply_qwen21_patch.py /opt/ComfyUI/apply_qwen21_patch.py
COPY dankclaw/comfyui-start.sh /opt/ComfyUI/comfyui-start.sh
RUN chmod +x /opt/ComfyUI/comfyui-start.sh
CMD ["/opt/ComfyUI/comfyui-start.sh"]
EXPOSE 8188
# v2: VAE 4D-to-5D latent expansion for video VAEs (qwen_image21)
# v3: fix in_channels to use shape[0] (latent input dim) not shape[1] (hidden dim)
-52
View File
@@ -1,52 +0,0 @@
import os
# Apply qwen_image21 detection patch
f = "/opt/ComfyUI/comfy/model_detection.py"
with open(f) as fh:
code = fh.read()
if "qwen_image21" not in code:
qwen21_check = (
" if '{}txt_in.text_norm.weight'.format(key_prefix) in state_dict_keys: # Qwen Image 2.1\n"
" dit_config = {}\n"
' dit_config["image_model"] = "qwen_image21"\n'
" dit_config[\"in_channels\"] = state_dict['{}img_in.weight'.format(key_prefix)].shape[0]\n"
" dit_config[\"num_layers\"] = count_blocks(state_dict_keys, '{}transformer_blocks.'.format(key_prefix) + '{}.')\n"
" return dit_config\n"
)
anchor = " if '{}txt_norm.weight'.format(key_prefix) in state_dict_keys: # Qwen Image"
code = code.replace(anchor, qwen21_check + "\n" + anchor)
with open(f, "w") as fh:
fh.write(code)
print("Applied qwen_image21 detection patch")
# Add qwen_image21 to GGUF node arch list
loader = "/opt/ComfyUI/custom_nodes/ComfyUI-GGUF/loader.py"
if os.path.exists(loader):
with open(loader) as fh:
lcode = fh.read()
if '"qwen_image21"' not in lcode:
old = '"lumina2", "qwen_image"}'
new = '"lumina2", "qwen_image", "qwen_image21"}'
if old in lcode:
lcode = lcode.replace(old, new)
with open(loader, "w") as fh:
fh.write(lcode)
print("Applied qwen_image21 arch list patch")
# Patch VAE decode to expand 4D latents to 5D for video VAEs (latent_dim=3)
sd = "/opt/ComfyUI/comfy/sd.py"
with open(sd) as fh:
sdc = fh.read()
if "self.latent_dim == 3 and samples_in.ndim == 4" not in sdc:
anchor = " if self.latent_dim == 2 and samples_in.ndim == 5:\n samples_in = samples_in[:, :, 0]"
replacement = (
" if self.latent_dim == 2 and samples_in.ndim == 5:\n"
" samples_in = samples_in[:, :, 0]\n"
" elif self.latent_dim == 3 and samples_in.ndim == 4:\n"
" samples_in = samples_in.unsqueeze(2)"
)
if anchor in sdc:
sdc = sdc.replace(anchor, replacement)
with open(sd, "w") as fh:
fh.write(sdc)
print("Applied VAE 4D-to-5D latent expansion patch")
@@ -20,10 +20,10 @@ Wants=authentik-server.service
[Container]
ContainerName=authentik-outpost-ldap
Network=dankclaw_default
# Image=ghcr.io/goauthentik/ldap:2026.8.3 (amd64 digest; podman 5.7.0 refuses
# Image=ghcr.io/goauthentik/ldap:2026.8.0 (amd64 digest; podman 5.7.0 refuses
# the tag's nested manifest list, so pin the single-platform digest).
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Image=ghcr.io/goauthentik/ldap@sha256:2e7b259eb8c37faac916946ddf48ec681ee9d3a9f724fe8521bf161973f13cf9
Image=ghcr.io/goauthentik/ldap@sha256:fa33bc8f522113830e1662f4c034352f9eb08e0a7d3da2ddadad9a4b33b4cbf4
Environment=AUTHENTIK_HOST=https://auth.franksdank.cloud
Environment=AUTHENTIK_INSECURE=false
Environment=AUTHENTIK_TOKEN=${AUTHENTIK_LDAP_OUTPOST_TOKEN}
@@ -20,7 +20,7 @@ Wants=authentik-server.service
[Container]
ContainerName=authentik-outpost
Image=ghcr.io/goauthentik/server:2026.8.3
Image=ghcr.io/goauthentik/server:2026.8.0
Exec=worker
Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
@@ -19,7 +19,7 @@ Wants=authentik-postgres.service
[Container]
ContainerName=authentik-server
Image=ghcr.io/goauthentik/server:2026.8.3
Image=ghcr.io/goauthentik/server:2026.8.0
Exec=server
Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
@@ -16,7 +16,7 @@ Wants=authentik-server.service
[Container]
ContainerName=authentik-worker
Image=ghcr.io/goauthentik/server:2026.8.3
Image=ghcr.io/goauthentik/server:2026.8.0
Exec=worker
Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
# Apply qwen_image21 patches before starting ComfyUI
python3 /opt/ComfyUI/apply_qwen21_patch.py 2>/dev/null
exec python main.py --listen 0.0.0.0 --port 8188
+7 -9
View File
@@ -21,13 +21,11 @@
# user-unit spawn — which carries no supplementary groups — can still open them.
# Both halves are asserted by tests/comfyui_rocm.bats.
#
# EXPOSURE. LAN-reachable, no auth. ComfyUI has no authentication and executes
# arbitrary node graphs. It is internal-only (no public vhost, no guard chain);
# it is reached on the LAN via the Pi-hole DNS record
# (comfyui.franksdank.cloud -> 192.168.0.143, in dankclaw/pihole/addn-hosts),
# which resolves to this host's IP — so the bind is widened from loopback to
# 0.0.0.0:8188. The homepage admin card links to that URL and probes
# 127.0.0.1:8188.
# EXPOSURE. Loopback only. ComfyUI has no authentication and executes arbitrary
# node graphs, and it has no Caddy vhost, so the repository's rule applies:
# an unguarded service is internal-only. The homepage status card probes
# 127.0.0.1:8188, which this satisfies. To reach the UI from another machine,
# guard it behind the auth chain and give it a vhost — do not widen the bind.
#
# DATA. Models, input, output and custom_nodes live under
# /home/dank/comfyui-data (host state, not in the repo).
@@ -41,7 +39,7 @@ ContainerName=comfyui
Image=localhost/comfyui-rocm:gfx1151
Pull=never
Network=dankclaw_default
PublishPort=0.0.0.0:8188:8188
PublishPort=127.0.0.1:8188:8188
AddDevice=/dev/kfd
AddDevice=/dev/dri
GroupAdd=keep-groups
@@ -50,7 +48,7 @@ Volume=/home/dank/comfyui-data/input:/opt/ComfyUI/input
Volume=/home/dank/comfyui-data/output:/opt/ComfyUI/output
Volume=/home/dank/comfyui-data/custom_nodes:/opt/ComfyUI/custom_nodes
Volume=/home/dank/serverAppConfigs/dankclaw/comfyui/comfyui-healthcheck.py:/usr/local/bin/comfyui-healthcheck.py:ro
Exec=/opt/ComfyUI/comfyui-start.sh
Exec=python main.py --listen 0.0.0.0 --port 8188
# The probe lives in a file because the podman 5.x v1 generator splits
# HealthCmd on spaces into an argv array; an inline `python3 -c "..."` becomes
# a puzzle of quoting that fails silently.
@@ -0,0 +1,219 @@
# Quadlet unit: frank-large (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/frank-large.container
# Unit name: frank-large.service (container name: frank-large)
#
# The large inference service. The halogen-flash-server runtime (since
# 2026-09-11, restored 2026-09-16 after a two-day ik_llama.cpp hybrid
# interlude) serving peonist-ai/halogen-qwen3.8-flash-next (Qwen3.8-Flash-Next,
# w4b .hgn checkpoint + quality overlay sidecar). It replaced the llama.cpp /
# kingjones30-ROCmFPX STRIX deployment because the engine is ~4x faster on
# prefill (the dominant cost of any real prompt) on this exact SKU. The RTX
# 3090 it briefly shared with frank-large is now frank-small's card.
#
# SHAPE. The image runs both halves in one container: the engine on container
# loopback (:8730, UNAUTHENTICATED — never publish it) and the OpenAI
# front-end on :8731, published here as host 7770 so the fleet's clients,
# services.json probe and docs keep the port they have always used. Same for
# HALOGEN_MODEL_ID=frank-large: it is the id at /v1/models, in /health and in
# every response, so no client had to be renamed. Halogen never rejects a
# request for naming a different model, it just serves it.
#
# DEVICES. gfx1151 through the amdgpu/KFD stack: /dev/kfd plus the DRM render
# node. Both are 0666 on this host, so the rootless user namespace can open
# them (id dank is also in render/video). seccomp=unconfined and --ipc=host are
# the image's documented requirements; memlock must be unlimited.
#
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the HIP
# runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
#
# MEMORY. Weights are pinned (HALOGEN_FLASH_PIN_TRUNK default 1) — that is the
# configuration the published decode numbers were measured with, and `free`,
# MemAvailable and every monitor built on them will overstate free memory by
# the size of the model (~68 GiB) because the kernel counts the locked weights
# as reclaimable page cache. Halogen prints the corrected figure at startup.
# The pinned trunk means this container holds most of the host; a co-tenant
# that then takes the remainder can stall both for minutes at full CPU with no
# disk activity and no output. That is documented behaviour, not a crash, and
# the escape hatch is HALOGEN_FLASH_PIN_TRUNK=0 (at several times the decode
# cost). Sizing is chosen for a SHARED 121 GiB host, and HALOGEN_KV_POOL_FIT
# stays on as the safety net: if the machine refuses the configured pool the
# engine lowers it itself and prints what it settled on. Read that ledger on
# every restart — it is the ground truth for sizing, not this comment.
#
# POOL RETUNED 2026-09-16: 400,384 positions -- exactly two 200,192-token
# conversations -- beside a per-conversation ceiling of 200,192 and two slots.
# It replaces the 401,408 the 2026-09-13 trim left behind, which held one full
# native-262k conversation beside a ~139k one, or three at 131k.
#
# MEASURED AT 401,408 (2026-09-13 deploy ledger): 68.0 weights + 11.0 KV +
# 21.0 working = 100.0 GiB held, 9.1 GiB / 1396 contiguous 2 MiB blocks left,
# engine need printed ~31.5 against the modelled ~33.4, and NO stall-warning
# NOTE. Fit-check budget here: 121.2 - 67.7 - 20 = 33.5. That boot is the
# baseline the 09-16 retune is measured against: the vision tower (1.97 GiB)
# and roughly 4.2 GiB of arena come back to the host, which is what pays for
# the co-tenant below.
#
# CO-TENANT since 2026-09-16: this host also serves frank-small on the RTX
# 3090 (llama.cpp, ~12.2 GiB of weights that load through the page cache and
# then live in VRAM, plus a CUDA context). It is the first real co-tenant this
# service has had, which is why the arena was halved and the reserve raised.
#
# BOOT NOTE: on the first request after a (re)start the engine can go silent
# for ~45-60 s ("has not answered PING") while it first-touches the freshly
# reserved pool; /health reads engine_unresponsive during it and recovers by
# itself. The quadlet health check's HealthStartPeriod=300s covers it; do not
# restart the unit over it.
#
# WEIGHTS. HALOGEN_DOWNLOAD fetches the whole weights repo on FIRST START ONLY
# (the entrypoint checks whether the checkpoint is absent) into the bind mount
# below, which must therefore be read-WRITE. huggingface_hub resumes partial
# files natively, and the quadlet runs the container with --replace --rm, so a
# restart mid-fetch continues rather than starting over. Roughly 127 GB: the
# 115.55 GiB checkpoint, the 2.31 GiB quality overlay, the 0.84 GiB vision
# tower and the tokenizer. The repo is public and ungated, so no token is
# needed; later starts open no outbound connections at all.
#
# VISION. Deliberately NOT set since 2026-09-16. The tower is a 1.97 GiB
# charge against the same device budget as the pool and the arena, and the
# fleet's image path moved to frank-small (llama.cpp on the RTX 3090, loaded
# with --mmproj mmproj-Qwen3.8-27B-BF16). This server refuses image content
# parts, and models.yml declares frank-large text-only to match.
#
# CLIENT CONTRACT (see serverAppConfigs-docs/LLM/halogen-flash-server.md).
# There are no server-side sampler defaults: a request that sends no
# temperature decodes GREEDY. The per-request budget defaults to 8,192 tokens
# and REASONING COUNTS AGAINST IT, so a budget that runs out mid-thought
# returns finish_reason "length" with empty content and the partial reasoning
# in reasoning_content. The cap is 65,536; above it a request is a 400, never a
# silent truncation.
#
# IMAGE. The unit runs the localhost/-aliased tag: this host's podman quadlet
# path cannot resolve registry-prefixed refs from local storage at create
# time. deploy-remote.sh pulls the ghcr.io ref and re-tags it before the
# restart. Pull=never keeps boots off the registry.
[Unit]
Description=frank-large — halogen-flash-server, Qwen3.8-Flash-Next (w4b) on gfx1151
After=network-online.target
[Container]
ContainerName=frank-large
Image=localhost/peonist-ai/halogen-flash-server:0.5.8
# Published port stays 7770: Hermes, open-webui, the harness and the homepage
# probe all point here. 8730 (the engine) is deliberately not published.
PublishPort=7770:8731
AddDevice=/dev/kfd
AddDevice=/dev/dri
# Read-write: the first start downloads into it. Contains only weights.
Volume=/home/dank/models/halogen:/models
# Fetch the weights on first start only (absent checkpoint gates it).
Environment=HALOGEN_DOWNLOAD=peonist-ai/halogen-qwen3.8-flash-next
# Fleet identity: the id at /v1/models, /health and every response body.
Environment=HALOGEN_MODEL_ID=frank-large
# Per-conversation ceiling: exactly half the pool, so two conversations of the
# full window are always resident. 200,192 rather than a round 200,000 is
# pool/2. The model's native window is 262,144; a conversation past this
# ceiling is refused, never silently truncated. Clients declare 200192 (see
# the frank-large contextWindow in models.yml).
Environment=HALOGEN_CTX=200192
# The KV pool: 400,384 positions = 2 x 200,192 (the 2026-09-16 retune, down
# from 401,408). ~28 KiB per position including block scratch, so ~11.0 GiB
# held -- the 401,408 ledger entry measured exactly 11.0. The two slots still
# OVERSUBSCRIBE the pool for any real request, because admission reserves
# prompt + max_tokens: two full-window conversations each holding an output
# budget are pool-bound, and the second waits in arrival order for the first
# to release its region. Read the `kv pool:` lines on every boot;
# HALOGEN_KV_POOL_FIT lowers the pool itself if the budget refuses the
# configured one, and a green health check does not mean the pool you asked
# for is the pool you got.
Environment=HALOGEN_KV_POOL_POSITIONS=400384
# Concurrency cap: two, one per half of the pool. Each slot is 111.0 MiB of
# O(1) state, so this hands ~111 MiB back against the three set on 2026-09-12
# for the fleet size of the time.
Environment=HALOGEN_KV_SLOTS=2
# Sizing is budget arithmetic, all of it. ONE budget covers the KV pool, the
# per-call prefill arena and the vision tower together:
#
# budget = 121.2 host - 67.7 resident weights - reserve
# need = KV (~28 KiB/position) + arena + vision
# arena = 3.80 GiB + 8.40 GiB x (HALOGEN_MAX_TOK / 16384)
#
# The arena term is linear in max_tok, and it reproduces five live restarts
# AND upstream's three published "on the device" figures to within 0.1 GiB —
# note those published figures are pool+arena totals, not pool alone, which is
# the misreading that first made a doubling look affordable here. Corners this
# box offered at a reserve of 24, at the old 262,144-position pool:
#
# 16384 -> need 28.6 vs 29.5 FITS, 0.9 spare (doubling, 8-9% prefill cost)
# 32768 -> need 37.0 vs 29.5 POOL CLAMPED to 262144, deploy still green
# 65536 -> need 46.5 vs 29.5 OOM, will not boot; unit still reads ACTIVE
#
# LOWERED 32768 -> 24576 on 2026-09-16 for the frank-small co-tenant on this
# same host. The arena's variable term goes 8.40 x 2 -> 8.40 x 1.5, handing
# ~4.2 GiB back to the host at roughly a 4-5 % prefill cost -- the same lever
# at 16,384 costs 8-9 %. The vendor's own notes name 24,576 as the largest
# arena that allocated with headroom to spare, so this stays inside the range
# it has tested.
#
# The arena is a PER-CALL PREFILL CHUNK, not a prompt ceiling: the
# 200,192-token conversation is prefilled in arena-sized pieces, so a smaller
# arena means more pieces and a slower prefill, not a smaller conversation.
#
# THE POOL, ARENA AND RESERVE ARE A MATCHED SET, NOT independent tunables --
# the fit check's printed ledger on each boot is the arbiter, not these
# comments.
Environment=HALOGEN_MAX_TOK=24576
# RAM the fit check refuses to count on, watched over by the engine's own
# n-gram lookup table (47.7 GiB, read through the page cache and never held)
# and, since 2026-09-16, by frank-small on the same host.
#
# RAISED 15 -> 20 with the 2026-09-16 retune. This knob feeds the fit-check
# budget and is NOT an allocation: raising it cannot hand memory back to the
# host, because the actual footprint is weights + KV + arena + vision, none of
# which it touches — it can only take the pool away. What it does is keep the
# fit check honest at the smaller arena. With the arena at 24,576 and no
# vision tower, the engine's printed need falls to roughly 25 GiB against a
# budget of 121.2 - 67.7 - 20 = 33.5, leaving ~8 GiB of margin, so the pool is
# not clamped.
#
# The 2026-09-13 profile for comparison: 103.5 GiB held and "7.1 GiB left for
# everything else" (310 contiguous 2 MiB blocks) at the doubled 524,288 pool,
# which is why the pool came down; at 401,408 the same ledger read 9.1 GiB /
# 1396 blocks. The 09-16 retune gives back the vision tower (1.97) and ~4.2 of
# arena on top of that for the co-tenant, so the expected figure at this boot
# is ~15 GiB left. Read the ledger; do not trust this arithmetic.
#
# To free host memory, change the POOL or the ARENA, and the reserve then
# becomes free to rise: HALOGEN_KV_POOL_POSITIONS=262144 hands back 3.6 GiB,
# HALOGEN_MAX_TOK=16384 hands back another 4.2. Both are one-line edits the
# fit check confirms on the next boot.
Environment=HALOGEN_HOST_RESERVE_GIB=20
# Verbose engine logging: the vendor's first-line evidence for load,
# allocation and admission questions. Shipping it on means an incident report
# already has the full startup account instead of needing a restart-into-debug.
# HALOGEN_DMALLOC_LOG is deliberately NOT set — it logs every allocation
# over 64 MB, which is steady-state noise here; raise it only for a live
# OOM hunt.
Environment=HALOGEN_VERBOSE=1
# Required by the image: the engine maps the checkpoint and registers it with
# the GPU in place, which needs host IPC and an unconfined seccomp profile.
PodmanArgs=--ipc=host --security-opt seccomp=unconfined
Ulimit=memlock=-1:-1
# The image ships its own HEALTHCHECK; this pins it. /health asks the ENGINE
# for a PONG before answering, so 200 means "ready to serve", not "process
# alive". During the first-start download it stays unhealthy by design.
HealthCmd=halogen-healthcheck api
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=300s
Pull=never
[Service]
# The first start reads ~68 GiB off disk and pins it before serving.
TimeoutStartSec=900
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
@@ -1,54 +0,0 @@
# Quadlet unit: alertmanager (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/alertmanager.container
# Unit name: alertmanager.service (container name: alertmanager)
#
# The delivery half of the LLM alert rules. Until 2026-09-17 the rules in
# shared/prometheus/rules/llm-fleet.yml evaluated and reached nobody — a
# firing alert was visible in Prometheus and on the Grafana dashboard and
# nowhere else, and an undelivered alert looks identical to a quiet one.
# This unit closes that gap: Prometheus pushes to alertmanager:9093 over the
# dankclaw_default network, and the single receiver webhooks the host-side
# signal-relay user unit, which hands the text to signal-cli under Frank's
# existing Signal account.
#
# PUBLISHING. 127.0.0.1 only, like every other observability unit: the
# unauthenticated Alertmanager API (lifecycle endpoints are always on in
# this binary — there is no flag to gate them) has no business on a host
# interface, and no off-host consumer needs it. Caddy gains no vhost for
# it; the homepage timer probes loopback.
#
# CONFIG. /opt/alertmanager/config/alertmanager.yml is installed from
# shared/prometheus/alertmanager.yml by deploy-remote.sh on every deploy, so
# the repo copy is the source of truth. STATE lives in /opt/alertmanager/data.
#
# Rootless: container root maps to the invoking user (dank) in this user
# namespace and dank owns /opt/alertmanager/data. The image's default user
# (nobody) maps to a subuid that cannot write it — same User=0:0 as prometheus.
[Unit]
Description=Alertmanager alert delivery (Dankclaw)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=alertmanager
Image=docker.io/prom/alertmanager:latest
Network=dankclaw_default
User=0:0
Exec=--config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager
PublishPort=127.0.0.1:9093:9093
Volume=/opt/alertmanager/config:/etc/alertmanager
Volume=/opt/alertmanager/data:/alertmanager
HealthCmd=wget --spider -q http://localhost:9093/-/healthy
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=30s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
@@ -27,11 +27,10 @@
#
# RULES. Five LLM alert rules and one recording rule load from
# /etc/prometheus/rules/ (installed from shared/prometheus/rules/ by the
# deployer). Delivery went live 2026-09-17: the alerting block in
# prometheus.yml points at alertmanager:9093, which webhooks every firing
# group to the host-side Signal relay (192.168.0.143:18093, reached from
# the container as host.containers.internal), which sends through signal-cli account 318311.
# The Grafana dashboard's ALERTS panel remains the second surface.
# deployer). There is still **no Alertmanager**, so nothing is delivered: a
# firing alert is visible in Prometheus and on the Grafana dashboard's ALERTS
# panel and reaches nobody. Delivery is an operator decision, not an oversight;
# the fragility is that an undelivered alert looks identical to a quiet one.
#
# The config volume is also why rules belong in git: editing them in the UI is
# lost on the next deploy, since the deployer overwrites the whole directory.
@@ -32,52 +32,6 @@ PublishPort=3005:8080
Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770
Environment=ANONYMIZED_TELEMETRY=False
Environment=DO_NOT_TRACK=True
# SSO via authentik (OIDC authorization-code flow, 2026-09-24). The OAuth
# client (application slug "open-webui") lives in authentik; the client
# SECRET stays off-repo in the git-ignored host env file (mode 600). The
# container reaches the IdP over the public URL: Caddy publishes 0.0.0.0:443
# and Pi-hole resolves auth.franksdank.cloud to the host LAN IP, so the
# server-side code exchange works from this bridge. Roles come from the
# "Open WebUI roles" scope mapping (entitlements "Open WebUI Users" /
# "Open WebUI Admins"); OAUTH_MERGE_ACCOUNTS_BY_EMAIL folds SSO logins into
# the existing local account (frank8465@protonmail.com). The OAuth vars are
# read directly from the environment at runtime (the per-key config DB holds
# only Admin-Panel overrides, so nothing is seeded on the live data dir).
# NOTE: the OAUTH_SCOPES value MUST stay quoted — systemd splits unquoted
# Environment= values on whitespace (unquoted, only "openid" survived).
EnvironmentFile=/home/dank/Containers/open-webui/env
Environment=OAUTH_CLIENT_ID=KmtzKc0bjWjaIFeM0puYudI1CLXR2p0jnPCSf4N0
Environment=OAUTH_PROVIDER_NAME=authentik
Environment=OPENID_PROVIDER_URL=https://auth.franksdank.cloud/application/o/open-webui/.well-known/openid-configuration
Environment=OPENID_REDIRECT_URI=https://ai.franksdank.cloud/oauth/oidc/callback
Environment=WEBUI_URL=https://ai.franksdank.cloud
Environment=ENABLE_OAUTH_SIGNUP=true
Environment=OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true
Environment="OAUTH_SCOPES=openid email profile roles"
Environment=ENABLE_OAUTH_ROLE_MANAGEMENT=true
# SSO-only (2026-09-24): the OIDC round-trip is verified, so the local
# username/password path is retired. ENABLE_LOGIN_FORM hides the form;
# ENABLE_PASSWORD_AUTH refuses password logins entirely, so the only way in
# is the authentik OIDC flow (which folds into the local account by email).
# The per-key config DB override (ui.enable_login_form) is flipped to false
# in the same change — the DB wins over env, so both must agree.
Environment=ENABLE_LOGIN_FORM=false
Environment=ENABLE_PASSWORD_AUTH=false
# Web search -> SearXNG, reached over the host-published port (NOT the
# search.franksdank.cloud vhost): the Anubis->Authentik guard chain is a
# browser mechanism and a plain HTTP client cannot solve it. The internal
# port bypasses Caddy entirely. These env vars SEED the per-key config DB on
# first run (fill-missing only); once the keys exist in /app/backend/data the
# DB is authoritative, so this block documents the intent and makes a fresh
# data dir come up wired. SearXNG's limiter exempts this source (the bridge
# egress 10.89.x is in pass_ip), so the 4-req/IP/hour JSON cap never applies.
Environment=ENABLE_WEB_SEARCH=True
Environment=WEB_SEARCH_ENGINE=searxng
Environment=SEARXNG_QUERY_URL=http://192.168.0.143:8081/search
Environment=SEARXNG_LANGUAGE=all
Environment=WEB_SEARCH_RESULT_COUNT=6
Volume=/home/dank/Containers/open-webui/data:/app/backend/data
# App liveness (/health) AND network liveness (a non-loopback interface must
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""frank-medium container healthcheck: the port's /health must answer 200.
Runs INSIDE the container. The vLLM /health endpoint only answers once the
engine is up (weights loaded, cudagraphs captured), so a healthy container is
a serving container, not merely a live process. PORT selects the endpoint so
the single-GPU replicas (:7771/:7772) and the round-robin proxy (:7770) can
share this probe. The HealthCmd in the quadlet splits on spaces into an argv
array (podman 5.x v1 generator), which is why this lives in a file rather
than an inline python one-liner.
"""
import os
import sys
import urllib.request
port = os.environ.get("PORT", "7770")
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as r:
sys.exit(0 if r.status == 200 else 1)
except Exception:
sys.exit(1)
@@ -0,0 +1,147 @@
# Quadlet unit: frank-medium (rootless, boot-persistent).
#
# Install path on dankserver: ~/.config/containers/systemd/frank-medium.container
# Unit name: frank-medium.service (container name: frank-medium)
#
# The dankserver LLM endpoint (:7770, alias frank-medium), since 2026-09-15
# running the vllm-radiance MXFP4 stack (stilldeadcode/vllm-radiance:0.9.3
# image + the ggz14/radiance-vllm-mxfp4 patches) serving
# just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq on the 2x Radeon AI PRO
# R9700 (gfx1201) cards. It replaced the charlie12345/ROCmFPX llama.cpp
# deployment, which served the same alias from the Q8_0_ROCMFPX_AGENT GGUF;
# the MXFP4 stack is tensor-parallel (TP=2, R4D one-shot all-reduce) with the
# DFlash2-FP8 block-diffusion drafter (SPEC=7) — the measured production
# configuration of the upstream repo, on our exact hardware signature
# (2x7551-32624, see the KV pin below).
#
# SHAPE. The launcher the upstream repo ships binds the server with
# --network=host (vLLM takes --host 0.0.0.0 --port 7770 directly), which is
# what this unit does too — no PublishPort. The fleet contract is unchanged:
# :7770, served model name frank-medium (SERVED_NAMES), OpenAI /v1.
#
# NOT INSTALLED ON THIS HOST. This is the target shape: one tensor-parallel
# process across both R9700s. It cannot launch a collective here — RCCL's
# kernels need PCIe atomics and the second card sits behind chipset bridges
# advertising `AtomicOpsCap: Routing-` — so the active deployment is the
# two-replica trio in this directory (frank-medium-a/b/proxy) and
# deploy-remote.sh deliberately does not install this unit. Keep it for a
# host whose second card is on an atomic-capable root port; the full evidence
# is in serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md.
#
# DEVICES. /dev/kfd + /dev/dri through the amdgpu/KFD stack; HIP/ROCR device
# indices 0,1 (the two R9700s). The 2 GiB Raphael iGPU enumerates as index 2
# and is excluded both by the index pin and by the upstream MIN_GPU_MIB=8192
# logic this unit replaces. --privileged --ipc=host --security-opt
# seccomp=unconfined --cap-add SYS_PTRACE is the upstream launcher's exact
# podman posture.
#
# MOUNTS. /models (rw checkpoints), the HF cache (tokenizer files),
# /cache (Triton/inductor/aiter/vLLM compile cache — first start compiles for
# several minutes, later starts reuse), /patches (the pinned radiance clone —
# its patches are applied at EVERY container start by the entrypoint), /r4d
# (the patched libr4d build; the image's shipped r4d.so NaNs the
# gated-delta-net and must never be used), and the entrypoint + healthcheck
# scripts from this checkout (mounted read-only straight out of
# /home/dank/serverAppConfigs — deploy.sh git-pulls the checkout before
# dispatching).
#
# IMAGE. The unit runs the localhost/-aliased tag: this host's podman quadlet
# path cannot resolve registry-prefixed refs from local storage at create
# time (same as frank-large on dankclaw). deploy-remote.sh pulls the
# docker.io ref and re-tags it before the restart. Pull=never keeps boots off
# the registry.
#
# KEEP IN SYNC: the radiance clone is pinned (97353293...); if it is updated,
# re-diff dankserver/frank-medium/radiance-entrypoint.sh against the new
# serve-mxfp4.sh container command and bump the pin deliberately.
#
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the
# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
[Unit]
Description=frank-medium — vllm-radiance MXFP4 Qwen3.8-27B-Uncensored on 2x R9700 (gfx1201)
After=network-online.target
[Container]
ContainerName=frank-medium
Image=localhost/radiance-vllm:0.9.3
# vLLM binds 0.0.0.0:7770 on the host network directly; no PublishPort.
AddDevice=/dev/kfd
AddDevice=/dev/dri
GroupAdd=keep-groups
# The upstream launcher's exact podman posture (rootless; podman-only flags
# like --replace are not needed under systemd management).
PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host
# The two R9700s. The Raphael iGPU (index 2, 2 GiB) stays out: shared system
# memory is not a compute target.
Environment=ROCR_VISIBLE_DEVICES=0,1
Environment=HIP_VISIBLE_DEVICES=0,1
# The uncensored MXFP4 checkpoint ships its fp8 MTP head already, so no
# fp8_mtp.py rewrite step exists or is needed (unlike amd/Qwen3.8-27B-Quark-AWQ-MXFP4).
Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq
Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8
Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja
# Fleet identity: the id at /v1/models and in every response body. Every
# client (Hermes, the harness, services.json) points at frank-medium.
Environment=SERVED_NAMES=frank-medium
Environment=PORT=7770
# Fixed 2-card tensor parallel: the tested configuration of the upstream
# repo; the R4D one-shot all-reduce kernel serves it natively.
Environment=TP=2
# Measured production batch shape (upstream README): MAXSEQS 8 peaks
# aggregate throughput at 573 t/s; MAXLEN is the model's native context.
Environment=MAXSEQS=8
Environment=CHUNK=8192
Environment=MAXLEN=262144
Environment=GPU_UTIL=0.98
# Speculative decoding: the DFlash2 drafter at depth 7 is the configuration
# every published number (224 t/s single-stream decode, 4749-4810 t/s
# prefill) was measured with.
Environment=SPEC_METHOD=dflash
Environment=SPEC=7
# KV cache pin, bytes. From the upstream repo's shipped kv-profiles.tsv row
# sig=2x7551-32624 maxseqs=8 chunk=8192 maxlen=262144 spec=dflash
# which is EXACTLY this host (2x R9700 DID 0x7551, 32624 MiB each). Pinned
# explicitly because this unit does not run the launcher's lookup; valid
# only at this batch shape (change MAXSEQS/CHUNK/SPEC_METHOD => recalibrate
# with calibrate-kv.sh or set KV_MEM=0 to let vLLM profile). Worth
# 943,581 KV tokens vs ~892,799 profiled (~5.7%).
Environment=KV_MEM=18563072000
# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards;
# keep parity with the launcher's rw mount).
Volume=/home/dank/models:/models
# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the
# entrypoint.
Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface
# Compile cache. Keyed on model + stack version + every graph-changing knob;
# never share it across configurations (upstream rule).
Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache
# The pinned radiance clone: patches applied at every container start.
Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z
# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The
# image's shipped r4d.so predates the GDN overflow fix and NaNs this model.
Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z
# Entrypoint + healthcheck from this checkout, mounted read-only.
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
# The entrypoint must be the container command; the image's own entrypoint
# (which the patched script execs at the end) must not run unpatched.
PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh
# 200 only once the engine serves; during first-start compilation the
# container is legitimately unhealthy. HealthCmd is split on spaces into an
# argv array by the podman 5.x v1 generator, hence the file-based probe.
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
HealthInterval=60s
HealthTimeout=30s
HealthRetries=3
HealthStartPeriod=1800s
Pull=never
[Service]
# First start compiles Triton/inductor kernels (several minutes) before the
# engine loads ~9.5 GiB of weights per card.
TimeoutStartSec=1800
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
# frank-medium container entrypoint: vllm-radiance MXFP4 on 2x Radeon AI PRO
# R9700 (gfx1201), serving just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq.
#
# This is a fork of the patch-and-serve block inside radiance-vllm-mxfp4's
# serve-mxfp4.sh (clone at /home/dank/GitPackages/radiance-vllm-mxfp4, pinned
# commit 9735329348ae1c9319f01cbe5f7004ec5f4dba63). The published image alone
# is NOT sufficient for this model: the shipped libr4d predates the
# gated-delta-net overflow fix and NaNs the output, and the MXFP4 gates need
# relaxing, so the repo's patches are applied at every container start from
# the /patches mount. It runs INSIDE the container (executed as the image
# command by the frank-medium quadlet); the launcher's host-side GPU/TP/KV
# detection is not needed here because this fleet is fixed 2x gfx1201 and the
# KV pin is pinned explicitly in the quadlet.
#
# KEEP IN SYNC: if the radiance clone is updated, re-diff this file against
# the new serve-mxfp4.sh container command and against the DRY_RUN output.
#
# Env contract: every knob below is ${VAR:-default}; the quadlet sets the
# fleet-specific ones (SNAP, PORT, SERVED_NAMES, TP, KV_MEM, ...).
set -euo pipefail
# ---------------------------------------------------------------- serving shape
: "${SNAP:=/models/Qwen3.8-27B-Uncensored-MXFP4-awq}"
: "${DRAFTER:=/models/Qwen3.8-27B-DFlash2-FP8}"
: "${CHAT_TEMPLATE:=/patches/qwen-fixed-v22.3.jinja}"
: "${PORT:=7770}"
: "${SERVED_NAMES:=frank-medium}"
: "${MAXLEN:=262144}"
: "${MAXSEQS:=8}"
# KV_MEM=0 is passed THROUGH as --kv-cache-memory 0, which the image's
# patched KV sizing reads as "profile the shape" (the launcher's rule).
# Blanking the variable instead omits the flag and makes vLLM size the cache
# from --gpu-memory-utilization alone: measured on the single-GPU replicas
# that overshoots and the allocation dies with torch.OutOfMemoryError right
# after the weights load (2.16 GiB requested, 116 MiB free). Do not "fix"
# this into an empty expansion.
: "${KV_MEM:=18563072000}" # shipped kv-profiles.tsv row 2x7551-32624 @ 8/8192/262144/dflash
: "${SPEC_METHOD:=dflash}"
if [ "$SPEC_METHOD" = dflash ]; then : "${SPEC:=7}"; else : "${SPEC:=4}"; fi
: "${MIN_M:=0}" # 0 = the W4A8 kernel serves every M (MIN_M=1 still routes M=1 to aiter)
: "${R4D_ATTN:=1}"
: "${ASYNC:=0}"
if [ "$R4D_ATTN" = "1" ]; then ATTN=R4D; else ATTN=ROCM_AITER_UNIFIED_ATTN; fi
if [ "$ASYNC" = 1 ]; then ASYNC_FLAG="--async-scheduling"; UNPAD=false; else
ASYNC_FLAG="--no-async-scheduling"; UNPAD=true; fi
# Drafting config. dflash: the DFlash2-FP8 block-diffusion drafter (its
# attention backend must support full cuda graphs -> TRITON_ATTN). The
# disable_padded_drafter_batch unpad is the single-stream lever (~+50%).
if [ "$SPEC_METHOD" = dflash ]; then
if [ ! -f "$DRAFTER/config.json" ]; then
echo "frank-medium: no dflash drafter at $DRAFTER" >&2
exit 1
fi
DRAFT_SAMPLE=${DRAFT_SAMPLE:-greedy}
SPEC_CFG="{\"method\":\"dflash\",\"model\":\"$DRAFTER\",\"num_speculative_tokens\":$SPEC,\"attention_backend\":\"TRITON_ATTN\",\"disable_padded_drafter_batch\":$UNPAD,\"draft_sample_method\":\"$DRAFT_SAMPLE\"}"
RADIANCE_DRAFT_RERANK=${RADIANCE_DRAFT_RERANK:-80}
RADIANCE_VERIFY_HEAD=${RADIANCE_VERIFY_HEAD:-1}
else
SPEC_CFG="{\"method\":\"mtp\",\"num_speculative_tokens\":$SPEC,\"attention_backend\":\"$ATTN\",\"disable_padded_drafter_batch\":$UNPAD}"
RADIANCE_DRAFT_RERANK=${RADIANCE_DRAFT_RERANK:-32}
RADIANCE_VERIFY_HEAD=${RADIANCE_VERIFY_HEAD:-0}
fi
# The AR size gate compares raw bf16 bytes: CHUNK x hidden(5120) x 2. Derived
# so changing CHUNK cannot silently drop prefill back onto RCCL.
export RADIANCE_AR_MAX_KB=$(( (CHUNK * 5120 * 2) / 1024 + 4096 ))
# ---------------------------------------------------------------- runtime env
export HF_HUB_OFFLINE=1
export VLLM_LOGGING_LEVEL=${VLLM_LOGGING_LEVEL:-INFO}
export VLLM_NO_USAGE_STATS=${VLLM_NO_USAGE_STATS:-1}
export VLLM_ROCM_USE_AITER=1 VLLM_ROCM_USE_AITER_UNIFIED_ATTENTION=1
export VLLM_ROCM_USE_AITER_MHA=0 VLLM_ROCM_USE_AITER_MLA=0 VLLM_ROCM_USE_AITER_MOE=0
export VLLM_ROCM_USE_AITER_LINEAR=0 VLLM_ROCM_USE_AITER_FP8BMM=0
export VLLM_ROCM_USE_AITER_FP4BMM=0 VLLM_ROCM_USE_AITER_RMSNORM=0
export NCCL_PROTO=Simple
export RADIANCE_USE_R4D=${RADIANCE_USE_R4D:-1} RADIANCE_USE_R4D_AR=${RADIANCE_USE_R4D_AR:-1}
export RADIANCE_DFLASH_SELECTOR_TOPK="${RADIANCE_DFLASH_SELECTOR_TOPK:-}"
export RADIANCE_R4D_REPORT=1
export RADIANCE_PRESHUFFLE=${RADIANCE_PRESHUFFLE:-1}
export RADIANCE_MXFP4_SANITIZE="${RADIANCE_MXFP4_SANITIZE:-0}"
export RADIANCE_MXFP4=1 RADIANCE_MXFP4_W4A8=1 RADIANCE_MXFP4_W4A8_MIN_M="$MIN_M"
export RADIANCE_FAST_DRAFT=${RADIANCE_FAST_DRAFT:-1}
export RADIANCE_DRAFT_TAU=${RADIANCE_DRAFT_TAU:-0.20}
export RADIANCE_DRAFT_RERANK
export RADIANCE_VERIFY_HEAD
export RADIANCE_VERIFY_HEAD_MAX_M=${RADIANCE_VERIFY_HEAD_MAX_M:-32}
export RADIANCE_MXFP4_DEBUG=${RADIANCE_MXFP4_DEBUG:-0}
export RADIANCE_MXFP4_PUREQUANT=${RADIANCE_MXFP4_PUREQUANT:-0}
export RADIANCE_MXFP4_SYNC=${RADIANCE_MXFP4_SYNC:-0}
export RADIANCE_MXFP4_CLONE=${RADIANCE_MXFP4_CLONE:-0}
export RADIANCE_MXFP4_CHECKX=${RADIANCE_MXFP4_CHECKX:-0}
export RADIANCE_MXFP4_PADOUT=${RADIANCE_MXFP4_PADOUT:-0}
export RADIANCE_MXFP4_TN4_MIN_M=${RADIANCE_MXFP4_TN4_MIN_M:-2048}
# The decode-kernel band must cover MAXSEQS x (SPEC+1) rows: 64 covers 8x8.
if [ "${MAXSEQS}" -gt 8 ]; then
export RADIANCE_MXFP4_DECODE_MAX_M=${RADIANCE_MXFP4_DECODE_MAX_M:-128}
fi
export RADIANCE_MXFP4_DECODE_NT=${RADIANCE_MXFP4_DECODE_NT:-1}
export RADIANCE_MXFP4_A_TILED_MIN_M=${RADIANCE_MXFP4_A_TILED_MIN_M:-513}
export RADIANCE_MXFP4_WPERM=${RADIANCE_MXFP4_WPERM:-1}
export RADIANCE_TP_PAD=${RADIANCE_TP_PAD:-0} # TP=2 here; 3 is the only non-zero value upstream supports
export RADIANCE_TP_PAD_DRAFTER=${RADIANCE_TP_PAD_DRAFTER:-1}
export RADIANCE_TP_PAD_STRICT=${RADIANCE_TP_PAD_STRICT:-1}
export RADIANCE_GDN_MERGE_INPROJ=${RADIANCE_GDN_MERGE_INPROJ:-1}
export RADIANCE_GDN_NORM_QUANT=${RADIANCE_GDN_NORM_QUANT:-1}
export RADIANCE_GDN_STRIDED_GATES=${RADIANCE_GDN_STRIDED_GATES:-0}
export RADIANCE_GDN_EMPTY_OUT=${RADIANCE_GDN_EMPTY_OUT:-0}
export R4D_ATTN_FP8=${R4D_ATTN_FP8:-3}
export RADIANCE_DFLASH_CALIB="${RADIANCE_DFLASH_CALIB:-}"
export RADIANCE_DFLASH_CALIB_TOKENS="${RADIANCE_DFLASH_CALIB_TOKENS:-200000}"
export RADIANCE_MXFP4_SHADOW="${RADIANCE_MXFP4_SHADOW:-}"
export RADIANCE_MXFP4_KERNEL_N="${RADIANCE_MXFP4_KERNEL_N:-}"
export RADIANCE_MXFP4_KERNEL_NK="${RADIANCE_MXFP4_KERNEL_NK:-}"
export RADIANCE_MXFP4_CHECKALL="${RADIANCE_MXFP4_CHECKALL:-}"
export RADIANCE_MXFP4_DECODE_KS="${RADIANCE_MXFP4_DECODE_KS:-}"
export RADIANCE_MXFP4_DECODE_BK="${RADIANCE_MXFP4_DECODE_BK:-}"
export RADIANCE_MXFP4_PERBLOCK_NK="${RADIANCE_MXFP4_PERBLOCK_NK:-}"
export RADIANCE_MXFP4_REFLINEAR="${RADIANCE_MXFP4_REFLINEAR:-0}"
export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-}"
export RADIANCE_MXFP4_EPIFAST="${RADIANCE_MXFP4_EPIFAST:-1}"
export RADIANCE_AR_OVERLAP=${RADIANCE_AR_OVERLAP:-0}
export RADIANCE_GDN_FUSED_UPDATE=${RADIANCE_GDN_FUSED_UPDATE:-1}
export RADIANCE_DYNAMIC_WIDTH=${RADIANCE_DYNAMIC_WIDTH:-1}
export RADIANCE_DYNW_ALPHA=${RADIANCE_DYNW_ALPHA:-0.35}
export RADIANCE_DYNW_MARGIN=${RADIANCE_DYNW_MARGIN:-2}
export RADIANCE_DYNW_MIN=${RADIANCE_DYNW_MIN:-2}
export RADIANCE_DYNW_MIN_BATCH=${RADIANCE_DYNW_MIN_BATCH:-3}
export RADIANCE_AR_QNB=${RADIANCE_AR_QNB:-96}
export RADIANCE_AR_QNT=${RADIANCE_AR_QNT:-1024}
export MXFP4_CUMODE=${MXFP4_CUMODE:-0}
export RADIANCE_STEP_TRACE=${RADIANCE_STEP_TRACE:-0}
export RADIANCE_AR_OVERLAP_MIN_M=${RADIANCE_AR_OVERLAP_MIN_M:-2048}
export RADIANCE_AR_OVERLAP_SLICES=${RADIANCE_AR_OVERLAP_SLICES:-4}
export RADIANCE_MXFP4_EPIFAST=${RADIANCE_MXFP4_EPIFAST:-1}
export RADIANCE_MXFP4_R4D_DECODE_MAX_M=${RADIANCE_MXFP4_R4D_DECODE_MAX_M:-0}
export RADIANCE_TOPK_TRITON_MIN_ROWS=${RADIANCE_TOPK_TRITON_MIN_ROWS:-1}
export RADIANCE_SKINNY_GEMM=${RADIANCE_SKINNY_GEMM:-1}
export RADIANCE_MXFP4_HOIST_QUANT=${RADIANCE_MXFP4_HOIST_QUANT:-1}
export RADIANCE_MXFP4_TRACED_QUANT=${RADIANCE_MXFP4_TRACED_QUANT:-1}
export RADIANCE_FP8_STREAM=${RADIANCE_FP8_STREAM:-1}
export RADIANCE_RMS_QUANT_FUSION=${RADIANCE_RMS_QUANT_FUSION:-1}
export RADIANCE_NVFP4_MXFP4=${RADIANCE_NVFP4_MXFP4:-0}
export RADIANCE_NVFP4_EXP=${RADIANCE_NVFP4_EXP:-mse}
export RADIANCE_NVFP4_FP8_LAYERS=${RADIANCE_NVFP4_FP8_LAYERS:-mxfp4}
export RADIANCE_NVFP4_BF16_LAYERS=${RADIANCE_NVFP4_BF16_LAYERS:-in_proj_ba}
export RADIANCE_NVFP4_LMHEAD=${RADIANCE_NVFP4_LMHEAD:-bf16}
export RADIANCE_GDN_PATHS=${RADIANCE_GDN_PATHS:-both}
export RADIANCE_GDN_NANTRACE=${RADIANCE_GDN_NANTRACE:-0}
export RADIANCE_MXFP4_CHECK_MAX_M=${RADIANCE_MXFP4_CHECK_MAX_M:-128}
export RADIANCE_MXFP4_MHIST=${RADIANCE_MXFP4_MHIST:-0}
export VLLM_CACHE_ROOT=/cache/vllm
export TORCHINDUCTOR_CACHE_DIR=/cache/inductor
export TRITON_CACHE_DIR=/cache/triton
export AITER_ROOT_DIR=/cache/aiter
export TRITON_CACHE_AUTOTUNING=1
# The patched libr4d built by setup-mxfp4.sh, bind-mounted at /r4d.
R4D_SO=${R4D_SO:-/r4d}
# The norm+quant fusion turns on vLLM's passes (one --compilation-config
# instance; argparse keeps the last, so entries accumulate into one flag).
CC_ITEMS=""
if [ "${RADIANCE_NORMQUANT_FUSION:-1}" != "0" ]; then
CC_ITEMS="\"pass_config\":{\"fuse_norm_quant\":true,\"fuse_act_quant\":true}"
fi
EXTRA=${EXTRA:-}
if [ -n "$CC_ITEMS" ]; then
EXTRA="$EXTRA --compilation-config {$CC_ITEMS}"
fi
# ---------------------------------------------------------------- patch block
# Byte-for-byte the launcher's container-start block. The patches come from
# the /patches mount (the pinned radiance clone). SP must match the image's
# venv layout; the image tag is pinned in the quadlet.
SP=/opt/vllm/lib/python3.12/site-packages
cd /patches
python3 patch_quark_mxfp4.py
python3 patch_nvfp4_mxfp4.py
python3 patch_tp3_pad.py
python3 patch_ar_maxbytes.py
python3 patch_topk_triton_rows.py
python3 patch_dflash_calib.py
python3 patch_dflash_mxfp4_kv.py
python3 patch_rmsquant_fusion.py
python3 patch_verify_head.py
python3 patch_kv_group_size.py
python3 patch_topk_composite.py
python3 patch_gdn_shared_build.py
python3 patch_dflash_selector_topk.py
python3 patch_gdn_merge_inproj.py
python3 patch_dynwidth.py
python3 patch_async_dynwidth.py
python3 patch_step_trace.py
python3 patch_ar_geometry.py
python3 patch_ar_3rank.py
python3 patch_gdn_glue.py
# Non-fatal: fixes content=null on thinking-off requests; not required to serve.
python3 patch_qwen3_thinkoff.py \
|| echo "[radiance] WARNING: thinkoff patch did not apply; thinking-off requests will return empty content"
cp mxfp4-configs/*.json "$SP"/aiter/ops/triton/configs/gemm/
cp radiance_preamble.py /opt/radiance_preamble.py
cp radiance_nvfp4.py radiance_mxfp4.py radiance_gdn.py radiance_rmsquant.py radiance_drafthead.py \
radiance_verifyhead.py radiance_gdnmerge.py radiance_aroverlap.py radiance_topk.py \
radiance_arnq.py radiance_tp3pad.py "$SP"/
hipcc -O3 -w -std=c++17 -fPIC -shared --offload-arch=gfx1201 $([ "${MXFP4_CUMODE:-0}" = 1 ] && echo -mcumode) \
$(python3 -m pybind11 --includes) radiance_mxfp4_fp8.hip -o "$SP"/radiance_mxfp4_fp8.so
if [ -n "${R4D_SO:-}" ] && [ -f /r4d/r4d.so ]; then
cp /r4d/r4d.so "$SP"/r4d.so
echo "[radiance] using patched r4d.so from $R4D_SO"
fi
# Leave /patches before exec: a stale radiance_mxfp4_fp8.so there would shadow
# the one just compiled into site-packages (cwd precedes it on sys.path).
cd /
exec /opt/radiance_entrypoint.sh "$SNAP" --served-model-name $SERVED_NAMES \
--host 0.0.0.0 --port "$PORT" \
--kv-cache-dtype fp8 --tensor-parallel-size "$TP" \
--gpu-memory-utilization "$GPU_UTIL" \
${KV_MEM:+--kv-cache-memory "$KV_MEM"} \
--max-model-len "$MAXLEN" --max-num-seqs "$MAXSEQS" --max-num-batched-tokens "$CHUNK" \
--attention-backend "$ATTN" \
--speculative-config "$SPEC_CFG" \
$ASYNC_FLAG $EXTRA \
--enable-prefix-caching --mamba-cache-mode align --enable-auto-tool-choice \
--tool-call-parser qwen3_coder --reasoning-parser qwen3 \
--override-generation-config '{"temperature":0.7,"top_p":0.95,"top_k":20}' \
--chat-template "$CHAT_TEMPLATE"
@@ -1,63 +0,0 @@
# Quadlet unit: Calibre Content Server (rootless, boot-persistent).
#
# Install path on dankserver:
# ~/.config/containers/systemd/calibre-server.container
# Unit name: calibre-server.service (podman 5.x drops the container- prefix)
#
# The image is the CURRENT linuxserver/calibre (9.15.0), which is a desktop
# image (Selkies web-streamed GUI on 8080/8181) with the headless
# calibre-server available inside. The compose-era snippet in
# serverAppConfigs-docs/Dankserver/Calibre.md predates the desktop revamp;
# the dedicated headless images (ta264/docker-calibre, agentgreen) are
# dormant (calibre 6.10 / 2017), so this unit runs calibre-server as the
# image's s6-overlay main service via Exec=; the desktop also runs but its
# ports are internal-only (see the port map below).
#
# Port map: Network=pasta is port-FORWARD mode on this host (the container
# keeps its own netns; only PublishPort'd ports reach the host — verified
# 2026-09-21: 8080/8090/8182 inside the container are not visible on the
# host, only the 8084 forward). So the ONLY exposed surface is:
# 8090 -> host 8084 calibre-server (the content server; 8080 inside the
# container is the image's own nginx, 8083 on the host
# is calibre-web)
# The desktop GUI (nginx 8080/8181 + WS 8082, inside the container) runs but
# is internal-only: no PublishPort. Exposing it is one PublishPort line away
# if the browser GUI is ever wanted (the basic-auth credential is already in
# the env file).
# Auth: calibre-server 9.x keeps its users in a SQLite userdb (the pw column
# is PLAINTEXT, so the file is mode 600). The single admin account is
# bootstrapped once by the deploy handler (deploy_calibre_server) via
# `calibre-server --manage-users -- add`, password piped on stdin with
# printf (a trailing newline from echo lands in the stored password and
# breaks every login). The desktop's basic auth (CUSTOM_USER/CUSTOM_PASSWORD)
# reuses the same credential pair. Both live in the machine-local env file;
# this unit holds no secrets.
#
# --auth-mode=basic is required: on a non-SSL port the server defaults to
# Digest auth, which rejects plain Basic clients with 400 "Unsupported
# authentication method".
[Unit]
Description=Calibre content server container
After=network.target
[Container]
ContainerName=calibre-server
Image=lscr.io/linuxserver/calibre:9.15.0
Network=pasta
EnvironmentFile=/home/dank/Containers/calibre-server/env
Environment=PUID=1000
Environment=PGID=1000
Environment=TZ=America/Vancouver
Volume=/home/dank/Containers/calibre-web/books:/books
Volume=/home/dank/Containers/calibre-server/userdb.sqlite:/userdb.sqlite
Exec=calibre-server --port 8090 --enable-auth --auth-mode=basic --userdb /userdb.sqlite /books
PublishPort=8084:8090
[Service]
TimeoutStartSec=300
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -16,7 +16,7 @@ After=network.target
[Container]
ContainerName=calibre-web
Image=lscr.io/linuxserver/calibre-web:0.6.27-ls402
Image=lscr.io/linuxserver/calibre-web:0.6.27-ls397
Network=pasta
Environment=DOCKER_MODS=linuxserver/mods:universal-calibre
Environment=LANG=en_US.UTF-8
+1 -1
View File
@@ -127,7 +127,7 @@ services:
# Calibre-Web — e-book web frontend
# ═══════════════════════════════════════════════
calibre-web:
image: lscr.io/linuxserver/calibre-web:0.6.27-ls402
image: lscr.io/linuxserver/calibre-web:0.6.27-ls397
container_name: calibre-web
restart: unless-stopped
environment:
+1 -1
View File
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target
[Container]
Image=lscr.io/linuxserver/plex:1.43.4.10903-e5521bd8c-ls325
Image=lscr.io/linuxserver/plex:1.43.3.10896-cb3ebc72d-ls322
ContainerName=plex
Network=dankserver_appnet:alias=plex
Environment=PUID=0
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target
[Container]
Image=lscr.io/linuxserver/tautulli:v2.18.1-ls245
Image=lscr.io/linuxserver/tautulli:v2.18.1-ls242
ContainerName=tautulli
Network=dankserver_appnet:alias=tautulli
Environment=PUID=0
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
# download-frank-primary-fp8.sh — fetch/verify the Qwen3.8-Flash-Next-FP8
# payload (131 safetensors shards, 172.78 GiB total, plus aux files) for the
# vLLM frank-primary deployment on dankserver.
#
# bash scripts/download-frank-primary-fp8.sh [dest_dir]
#
# The manifest (scripts/manifests/qwen3.8-flash-next-fp8.manifest) carries
# per-file sizes + LFS sha256s from the Hub; hf-fetch.sh is idempotent — an
# interrupted re-run fetches only what is missing or corrupt, and a complete
# on-disk payload is re-verified (full sha256 pass) with zero downloads.
# The repo is public; no HF_TOKEN required.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
dest="${1:-/home/dank/models/Qwen3.8-Flash-Next-FP8}"
exec bash "$REPO_ROOT/scripts/hf-fetch.sh" "$dest" \
"$REPO_ROOT/scripts/manifests/qwen3.8-flash-next-fp8.manifest" \
Qwen/Qwen3.8-Flash-Next-FP8
-144
View File
@@ -1,144 +0,0 @@
.gitattributes 1635
LICENSE 3235
README.md 65400
chat_template.jinja 8952
config.json 72514
generation_config.json 202
merges.txt 3353259
model-00001-of-00131.safetensors 1040155912 774f0ceeadb40d165f2b3ff397d5f3840e6ca8fcb8f3d39d8acb4fea9e52c941
model-00002-of-00131.safetensors 1678209208 6841fe21fa8a8a7a693c585efe65cd2732889095b696da88bda0cb287366910b
model-00003-of-00131.safetensors 993901136 974a2a2ab551f8f1405a4955ab32a8721c68c73dd85b382491d9f0e6a34ee752
model-00004-of-00131.safetensors 1678209208 591f488ab5cd5f0bd4fe28266099523761b9b9339137800734c34ffd84595538
model-00005-of-00131.safetensors 1717267690 c50bf465a4a0129f1a1196c0e75d881048238f7107b356fc734007eac0d3b123
model-00006-of-00131.safetensors 1600008328 57f103367fe36c9dc355c648d521d026caabb4ce5ba51038c78bd935f6593736
model-00007-of-00131.safetensors 1600008328 cde2a12854770c706a74a5c7b85ffc140e3d4dba93dd4bee1cd7aa07cc348c90
model-00008-of-00131.safetensors 1600008328 98ae323b5a61abdcf58a0dc9db7eb1a5a748cebc1ef1932804a1411500c09d84
model-00009-of-00131.safetensors 1600008328 70f4b66bca5c4c0b441cf4284d4ab311670e182bfc5ec51c7294a4054baeb20f
model-00010-of-00131.safetensors 1600008328 6d5dfa1dfa9a6c482f100f6e75c1f5e8ac02391c62e844e53f4a53996baf2206
model-00011-of-00131.safetensors 1600008328 de085adb563e530e7ad157220cd1a219be189a1db0fcc3f2507886a2d78bd4e1
model-00012-of-00131.safetensors 1600008328 97dac7c366ba19858d78c01364054136449ccb3cf60768587461cbf0bb582c90
model-00013-of-00131.safetensors 1600008328 467be10b473d25e0581f8fb1bc986916c8b9301272f72e7a05762484ffc080a9
model-00014-of-00131.safetensors 1600008328 38def6e9bbe6f242c2f92858c3e9a2b356d74d0180e323677158016dbee32ae7
model-00015-of-00131.safetensors 1600008328 90c6c665f1f819bc6350714652c8fb77064fec57832ef51b878a0cdf9f602236
model-00016-of-00131.safetensors 1600008328 88185c2530641b37dcad80d37b505c5257035e87ad29fa86640aae4814903e4f
model-00017-of-00131.safetensors 1600008328 42e3b96359116d7471a74cb7de08f6a4e9e173830d562f1f74ff09d1d253ad38
model-00018-of-00131.safetensors 1600008328 040adc7270efca6498c6f6a07dd7da14925d20217fe6de67223d6856fb4f2ad7
model-00019-of-00131.safetensors 1600008328 b8a831992deb1a694439e37b7136ff2e181457a8683bff65f1f4b99772cabe58
model-00020-of-00131.safetensors 1600008328 9426112fbd49ef4f5e9dd38dc2f93b046602e6d92cff732d33b96413ff1b40dc
model-00021-of-00131.safetensors 1600008328 fce8426665f749ef38c4fad33b612f892b728c0f74fe60cab0e43ece785b4e77
model-00022-of-00131.safetensors 1600008328 a20dd415f280774321538e6b9e78fba9f8e218a9090eae52ea7172b85b9c5a6d
model-00023-of-00131.safetensors 1600008328 4100591839471a8721d9cedb251554dcef5ebc1dfd96ea7dce8912dd1e9921b2
model-00024-of-00131.safetensors 1600008328 bbd6d7a4b54b6a6032e15b7c2fc9d6526377f89992d61da8837ad059f3210b45
model-00025-of-00131.safetensors 1600008328 f9fa7b04de155dd2be74b5693d5b36e5463dabf956b9be36f67d4aae6b8dd304
model-00026-of-00131.safetensors 1600008328 193c6b9da2c6b69c34d8a14b65234871db272761e712c2e56bac3f13c2d0cbf7
model-00027-of-00131.safetensors 1600008328 d7efa372552c07ee8783e27c4b8ebd12fc75333f92553861bec8c7c0155a03fe
model-00028-of-00131.safetensors 1600008328 5fc13d084d62c06b928dfb388a1b36f5c27cd25bd3cbb68a0b21e1c4cfc68e6e
model-00029-of-00131.safetensors 1600008328 a11f5a6360b349159575871db5c553c40c37be68382b82551a2c2372f15f2811
model-00030-of-00131.safetensors 1600008336 cac780a675f87fc699435a5207cbecb30a887271b5514b60b7e45d9bebe0e1ec
model-00031-of-00131.safetensors 1600008336 d390a872eb3928b9d7c0a720ced792cc111613812a163d9fd6c97fd35d633897
model-00032-of-00131.safetensors 1600008336 3b36fe4e7215a12cdc6c7499aafde51cfbce51266785184b99d3838a6b37b2c8
model-00033-of-00131.safetensors 1600008336 3fb0504f406c0c71ee40e9e158492973c5c35337889c81b974892b407620c64c
model-00034-of-00131.safetensors 1600008336 6d989a812a8d8d9330c8c4d21d7be98a0114705ca3bd7834d43bb268bad6be08
model-00035-of-00131.safetensors 1600008336 8e56ba0a714198bd07a2d8fe9d91fbf5c72601c1b98edf09b289a2538445cb75
model-00036-of-00131.safetensors 1600008336 c23fd6f6cfa9acfa961ba26e7c9c0dd7b05782c5bbf8f55e2526119b5c516fd4
model-00037-of-00131.safetensors 942343240 b824a11bf460bd78c068e3d301e914a0bec2f243a6eea4679be7200d179248e7
model-00038-of-00131.safetensors 1678211256 716eda81a763ffdefd1ac5306b4c80eef123c4dee24ed3a565a22438497581cb
model-00039-of-00131.safetensors 877983696 b91e3bfbcd765ce704db85b78f69055da59456ae733df635e3973ff2f966a148
model-00040-of-00131.safetensors 1678211256 3311ed7821b68bddb1542f06ca349feec72e68288113fe4899b08a3b8156343f
model-00041-of-00131.safetensors 1103350296 1d79949b61730a0fa1f6cd1af149fa50fedc051566cdea48283de8c7a32d972c
model-00042-of-00131.safetensors 1678211256 3f61684338e203cb2ccff6e451eda5705e10043d386b7ed55d4732d23eb5c77b
model-00043-of-00131.safetensors 1000455912 bab92cfefbb811ee63c4e32595024117c7df3537f5359606eb1de23fe4f115b2
model-00044-of-00131.safetensors 1678211256 1d05f138848edca4138c3d1c6be4a432a0817eb29fa65f12d8611ecc7685c493
model-00045-of-00131.safetensors 993902176 4610384e28f7a91b8bf2a1527178c82cbe7d50ecd9a33b61ee878fa8e8bced85
model-00046-of-00131.safetensors 1678211256 5ee9b0e45810300cbd4fd6f2acfe85080e7032a9ce5d77038b86f357b6700c23
model-00047-of-00131.safetensors 878065752 efa6c0cc0e527486dc5955b00fa75665151d99997b722d8e56bd32da575e414c
model-00048-of-00131.safetensors 1678211256 57b148a266c55a896f171b5c98b350092bf394c1fb836658c58c589a0a317ccf
model-00049-of-00131.safetensors 1096817160 801dd3b475b43413ade810f4e8d3ace69d6ef5ee4bac5cb98d15e369343a5569
model-00050-of-00131.safetensors 1678211256 b4d00aca34ba7a9f8427983fd0829408fb5e15b2bb7268199e4010bf0c460a04
model-00051-of-00131.safetensors 993902176 f430125e9ac92c2de15bbb74bcd3c91bc5374eef389a1be1bc4a592f3a082f0c
model-00052-of-00131.safetensors 1678211256 06c3d64136b097919b7e5a1715a70a60b8c2302fc4e80a33235607bb99d00f35
model-00053-of-00131.safetensors 1000455920 147d38f786512f1a8f570a00100b46a3faab1bd649b3445ca0a50c00aa2e99a3
model-00054-of-00131.safetensors 1678211256 31c08218039a246c333bfe0b068b8027fd92dddd542e581997af546eb104dd4d
model-00055-of-00131.safetensors 877983696 971cd69c8d955bef6d557ebab3f170807a599517f7c69e9d8a6ba375214137f4
model-00056-of-00131.safetensors 1678211256 a6a975ac346aa3bd1176cb816d8bbd31a4d7542aa3de9306ca5899bea44bd159
model-00057-of-00131.safetensors 1103350288 d6acae1622abfa1e0253ae548bc1bdd4bc38cd9b096082acd455b6be592f4a54
model-00058-of-00131.safetensors 1678209208 f02b6bd8f5cbd742e8bac3ba9537aa14a64c5a8f3fc75495227a14e1a5cec4ec
model-00059-of-00131.safetensors 877982872 f3e2c94b8157a021b27e1efdb659333a8d1db330da5cd72ab9012fa0e5351506
model-00060-of-00131.safetensors 1794100080 908cce7f5fe8dc34f4cca1fc8a676569ab6ce08bad536bd0091c4d75e7b65a05
model-00061-of-00131.safetensors 1678211256 718532c88f4b7a4f402a50e4f1b6efc3cf71484c9e5a083ee30e96d5094a1bb3
model-00062-of-00131.safetensors 1832994688 3c21b895a5a4f0dc03b669c730f8d8d9ce9a29cbc7628a25894a16ffda6f0a7a
model-00063-of-00131.safetensors 1678211256 91d215e2135b2f5db5bc5a9541e5f6bf761205d67c6e7b8955ac73c815198f58
model-00064-of-00131.safetensors 993902176 03a11cad2c283fb9752c6ac01c8f1d8b3127dcda963c5a8225b78f567239d872
model-00065-of-00131.safetensors 1678211256 0f2f96331120779ce1ebd3f957882092d3c733aa9a6073ca87fc86c6eb4e9465
model-00066-of-00131.safetensors 880605240 5dbe501e4e0cb9e3f37cc43da46673b4157fdffba09c065ca0b12d75092a79f2
model-00067-of-00131.safetensors 1678211256 3ece325d2ca53eddf7b1782d5bbc4c376bc75c6d3da67813e110fd0af67cd09b
model-00068-of-00131.safetensors 1096796552 0c5ef706afd6b3058af5bf8fdc1e0938cb6e29d0a526c771893c9dcb8e702463
model-00069-of-00131.safetensors 1678211256 84999b24e3c55d2eae3ca187219a2e1a1872436ae9bafc91cc3e301180f36cfd
model-00070-of-00131.safetensors 993907416 eac7940317ee401859caf38089b2b924274ab81a3e5fbd87c42d89923361aad8
model-00071-of-00131.safetensors 1678211256 5ac60e474ccdab21257245630ed8240d75bc7115b3adb27ca65e39eb9ea8f20e
model-00072-of-00131.safetensors 1000456024 b50a05c4c426fb00635d513a733a290da1007a89b8785f1ec02aad8e4d93d955
model-00073-of-00131.safetensors 1678211256 9e0594d389debdfb26d22259725831ff34280edeef4144e683d70205050919d9
model-00074-of-00131.safetensors 877983696 1b1a0294f8df2a431885783185cdb185106228c9567e67224fe2c3c6a2467f5a
model-00075-of-00131.safetensors 1678211256 7dba189804ef7a8f9d926b27e1ddbe73767f1213cb7bb3cc582ab15fffec1792
model-00076-of-00131.safetensors 1100073480 d95ebacb144b4220f2df60cce85a2c86e87cb8291b836d1bd04f9902fcbb9297
model-00077-of-00131.safetensors 1678211256 26046977983e025defdf0f84cf5fc5f4ddfeabbd27e7abc43ff2b413265ab062
model-00078-of-00131.safetensors 993984232 6fd46a95777392265b2afa66bf71d488856dafb7b76ff0337a0fe4ac857e961c
model-00079-of-00131.safetensors 1678211256 77398002634ce852917c59cd642bec7dd3903381239da68912bf69743631e54e
model-00080-of-00131.safetensors 877983688 f44aad95d36c443995b965aeef844c8e3a30ed172647dc57fcc0c5476c18d024
model-00081-of-00131.safetensors 1678209208 b295a48088573654d1b4754e861ec329956504cb6687e9e2a8eb652b35eaf988
model-00082-of-00131.safetensors 1096816112 83e0e171116c22eb8fdddfa3c387bebb9017eb0586410fd704ce70e7b3d23d68
model-00083-of-00131.safetensors 1678211256 7e1021cde06c970e4b1d9eb3d02f66f29786c59a0da8a3f8879de1a4e3fbf6c0
model-00084-of-00131.safetensors 877983696 8938fd3c7f88d61fd5b5306eb0ab253d3e42e7f64baf3c3e494d315c8ad5ab55
model-00085-of-00131.safetensors 1678211256 cd5875a29d28d52578ae699fdd8891f605a0953cd64516bd88dc45dab50c383a
model-00086-of-00131.safetensors 1103350296 885e570a662aaf266fd9af9562c65116998df5710b2a27d1bf6aa0c8708b3b74
model-00087-of-00131.safetensors 1678211256 1472604826f0a0c2bbf7cd0f4a0c5b29f3012e4b3a4424d024d1ec17a3c9d0ea
model-00088-of-00131.safetensors 1000455920 e7273f5435571e761429330ece81732e8fb3f8b5f0087bf80db693630cf702a1
model-00089-of-00131.safetensors 1678211256 eb349562d23e330bbbd0a5f5ba90ac25ed0baa660dff230538a0fcaaf4c8b875
model-00090-of-00131.safetensors 997179104 532915eebd4e231a240e185ba838a95a00ee14277e28fb04ad21508da5ff50aa
model-00091-of-00131.safetensors 1678211256 68d8f46ffcd0ff46f4e73c848525cc6fd6af15eebbb9311ce9b4eb14638f5660
model-00092-of-00131.safetensors 877983696 91abc087cd244ae62b8469576e209c8e25bc7726bc4fc020b687378059f5b5bd
model-00093-of-00131.safetensors 1678211256 dacd0f2e5215fbf18fb7c6803f61963333e6243f404de83fcd5dfd38f43fe0d1
model-00094-of-00131.safetensors 1096796928 cac515a778e89fb37e9f38fde6c8fdfa01ebf9d8944a2d6ff35c51ecf4bc875f
model-00095-of-00131.safetensors 1678211256 a35975c2d21f18d58f21cb59e4eaf34a4bce11440e5112948e7fa6923658f8c4
model-00096-of-00131.safetensors 993902176 03fd77f9d35024bba9e894c811300ef8aaf5b5ff99ebc2c89c9d9f87685adfe8
model-00097-of-00131.safetensors 1678211256 f7b81ae09d97b8e2bb96a1916ede51da92209ec8481a31c56fb446acd691b8a8
model-00098-of-00131.safetensors 993902176 70f208f47d99c25d5413cd32e0553cce22524cd3863c7797e2cfa37c2d526319
model-00099-of-00131.safetensors 1678211256 de5c33dd717495657496067ea715442356fd9f7b64384d1a4c10c9f8fe6f9b7d
model-00100-of-00131.safetensors 877984072 43752e81a5c2b376073d5d0d7b70da8d08c49bdbbfcfeb695ea947a2913e4bc8
model-00101-of-00131.safetensors 1678211256 c2e978f7238036063914fe909214246730640be77eca215e3cf66d65724de9f1
model-00102-of-00131.safetensors 1096797160 639efed3ea400a8dddeae25db120096898a514697dfc4cbbc0cd15f621c18421
model-00103-of-00131.safetensors 1678209208 f8da1b61296e012dc268b4f0d84c127ef0967df488fe65b037f74c8f893decd1
model-00104-of-00131.safetensors 993901144 adff0f2aee7b7b701e39247cfdfa135c0cab8dfa4f8c7662352c0233adb78829
model-00105-of-00131.safetensors 1678211256 6643d71382f00f7ffe16b05879e6ff0f6e56986dee8ec079a84aae82912c6987
model-00106-of-00131.safetensors 1025359600 680495ea0a23e73844e91670437b7b2a5ac5d46f9582923fb7fa91fe640821df
model-00107-of-00131.safetensors 1678211256 886528d121b77433c1d66654294c13b4f61a004f1e6d1abf2d445fb0a99f7234
model-00108-of-00131.safetensors 1062060032 a966c28a6267d43052fe87cb19e2e5730bad6154b12eb806b9ce49e007bfec96
model-00109-of-00131.safetensors 1678211256 f6074252e492ee5b0933f78cc48f3b90694a8096befb7666cc379c3d47093756
model-00110-of-00131.safetensors 877983696 e3e45e96bc13b52b748a0e2f397a624ad49958396468667d73ed4d725c8b5c1c
model-00111-of-00131.safetensors 1678211256 d1d354e691eab0b77607faf7eb4adf00a75ee7f270c1cbcbad06aefc19aeac94
model-00112-of-00131.safetensors 1096797168 e61a9a6ef0e538afca09d97a3ccbf3e7e2c647f65cc8eeebebb18621d7779b02
model-00113-of-00131.safetensors 1678211256 a14b94db5326bfaab1d8029296b8938f6daf91ba2dc07a4950a38afafa84f776
model-00114-of-00131.safetensors 993907400 ce4aeac52776038ec0c885915bb270dc65ee07d70d5f71827d5fba1045e2b43b
model-00115-of-00131.safetensors 1678211256 8185d8822428d2125876a9aa05aea11c8165713bc99e04ace29921eef0e1b683
model-00116-of-00131.safetensors 993902176 65954dccfa3be20611728311d97679e7f5d78ad519e52312524aa7cf666a48e5
model-00117-of-00131.safetensors 1678211256 12c8027363b1a7bb782282293314fa57b07be27d17670861858c0aed1e28a3ac
model-00118-of-00131.safetensors 878004272 2d06ec9c1726f42bfc9ce0bbb47129917d8ab373c88eed4e758fb6940c92ad4a
model-00119-of-00131.safetensors 1678211256 36008b48c4480085bfd1a81439d70d1029cfaf06cfdd037cec19b491a40659ec
model-00120-of-00131.safetensors 1109903856 49e4f90d92f60f6489bfe6d3e5250d8fe879c5995ae72ce67379cc7187fa4b0a
model-00121-of-00131.safetensors 1678209208 ae8e07cceeef2f568df035a7bc0c555656acb566ba5936f61a8a019df23361ad
model-00122-of-00131.safetensors 993901136 b3b21d33b925e31d475a509757cf7459aa4d3e73cb493706f40461387541e137
model-00123-of-00131.safetensors 1678209208 f32fec30685afadd76f09bbb1dda680445ad765a97ff1febfc3122b7f73d7ec3
model-00124-of-00131.safetensors 891089968 e857f36ddd0db135f858c2c87c9fcf39c758965c626b280e47cb0c1e6c1423f8
model-00125-of-00131.safetensors 1678209208 b9de3774218d3cd7946cdbf913dedfc82591c18212d9c3fc9b9484e7dc68e9fe
model-00126-of-00131.safetensors 1096795496 29b39319522a00b1ef4ac909c4fb306059999f0a48fd07ca73cea687647631ee
model-00127-of-00131.safetensors 1678209208 bf48ccb3b4ba75c288d9db2b94817c6a3993e9f84a5b9410d9f24edcec12e177
model-00128-of-00131.safetensors 993901136 7feaa51c906a1a735e5fb52fd5499b59c473d442d77b44cbd610c9b6a3a9e0ba
model-00129-of-00131.safetensors 1678209208 a5374c8d86aea48f21a4be2ad6cda37fbfbb01eff333d1179939a2e55d8db816
model-00130-of-00131.safetensors 2136177320 204b122d02425f61115db9ab896648d95f6dadd6a716c65daf6aec297392ed56
model-00131-of-00131.safetensors 1271398496 ba6fc69ebb6a9da1938c2e041b8ef625f4550b641812da75845bc7dd7c361d80
model.safetensors.index.json 17410140 0419e2c2dfbb925257d7409405433a793cf7ff7d96f3eba882a815ec6d9fe7a6
preprocessor_config.json 390
tokenizer.json 12809320 0997f410c57a1f4e53b09e4be8f4a172d90edd9564368fb0847030937229b9f3
tokenizer_config.json 17928
video_preprocessor_config.json 385
vocab.json 6722759
-269
View File
@@ -1,269 +0,0 @@
#!/usr/bin/env python3
"""bench-conversation.py — what an endpoint actually feels like, in tokens/s.
The other harnesses here answer engine questions (which batch, which proposer,
which cache). This one answers the user question: give the same ten jobs to an
OpenAI-compatible endpoint and report the rate it answers them at. Every job is
a real conversation turn — system prompt plus one user turn — because that is
the shape the fleet serves, and it is measured by STREAMING so prefill and
decode are separated:
ttft seconds to the first content delta -> prefill + queue
decode tok/s completion_tokens / (total - ttft) -> the rate you read at
Both arms of a comparison must run the SAME prompt bytes, so a per-run salt
rides in the system prompt: it changes the prompt itself, which is llama.cpp's
prefix-cache key and vLLM's APC key, so no arm silently inherits a warm cache
from the one before it. Run one arm, restart the engine, run the next with a
new salt.
Answers are generated at temperature 0 so arms serving the SAME model can be
compared for identity as well as speed; different runtimes serving different
quants legitimately answer differently, which is why the first line of every
answer is printed.
Usage:
bench-conversation.py <base-url> [--model NAME] [--salt TAG]
[--max-tokens N] [--burst N] [--out file.json]
bench-conversation.py http://127.0.0.1:7770 --model frank-medium --salt L1
bench-conversation.py http://127.0.0.1:7770 --model frank-medium --salt R2 --burst 4
One engine at a time: on a single GPU pair there is no room for two copies of a
27B model, and the benchmarking policy requires the production service to be
stopped while anything measures.
"""
import argparse
import json
import statistics
import sys
import threading
import time
import urllib.error
import urllib.request
# Ten jobs that cover what this fleet is actually asked to do. Ordered roughly
# by prefill size so a runtime that is good at short prompts and one that is
# good at long ones cannot hide behind an average.
SYSTEM = (
"You are a staff engineer on a homelab fleet. Be concise and concrete. "
"Salt: {salt}"
)
BATTERY = [
("greet", 128, "Say hello in one sentence."),
("shell", 192, "One bash command: find every file under /home modified in "
"the last 2 days, sorted by size, largest first."),
("math", 256, "A train covers 180 km in 1h40m, then 240 km in 2h20m. "
"What is the average speed in km/h? Show the arithmetic."),
("json", 384, "Emit exactly 12 JSON records, one per line, no prose, each "
'with keys "host", "service", "port" and "up" for hosts dankclaw, '
"dankserver and dankware running llama-server, caddy and grafana."),
("review", 512, "Review this function for bugs and name the failure mode:\n"
"def rotate(cfg, n):\n for k in cfg:\n cfg[k] = cfg[k][n:] + cfg[k][:n]\n"
" return cfg\n"),
("explain", 448, "Explain in four sentences why PCIe atomic operation routing "
"matters for GPU collectives, and what fails when a bridge reports "
"AtomicOpsCap: Routing-."),
("translate", 256, "Translate to German, then to Japanese, one line each: "
"The load balancer refused the connection because the port was still held."),
("plan", 640, "Give a 6-step runbook for swapping the model behind a live "
"LLM endpoint on a host that serves real users, with a rollback at each step."),
("debug", 512, "A systemd user service restarts every 10 seconds and its log "
"ends with 'Error starting server failed to bind udp listener on 10.89.1.1:53: "
"Cannot assign requested address'. What is the root cause and the repair?"),
("longdoc", 512, None), # filled from LONG_FILES at runtime
]
# The long-context job reads real repo files so prefill is large and identical
# across arms (~30k characters).
LONG_FILES = [
"/home/dank/serverAppConfigs/AGENTS.md",
"/home/dank/serverAppConfigs/serverAppConfigs-docs/LLM/"
"frank-medium-llamacpp-dflash2.md",
]
LONG_CHARS = 30_000
def long_prompt() -> str:
parts = []
for path in LONG_FILES:
try:
with open(path, encoding="utf-8", errors="replace") as handle:
parts.append(handle.read())
except OSError:
continue
text = "\n\n".join(parts)[:LONG_CHARS]
return ("Using only this document, answer: which deployment shapes for "
"two GPUs are ruled out by missing PCIe atomics, and what is the "
"one software path that still lets both cards serve one model? "
"Cite the measured numbers.\n\n---\n" + text)
def stream_chat(base: str, model: str, messages, max_tokens: int, timeout: int):
"""One streamed chat completion -> (ttft, total, usage, delta_count, text).
usage is the engine's own {prompt_tokens, completion_tokens}, asked for with
stream_options.include_usage. There is deliberately NO cheap non-streamed
pre-probe to learn the prompt length: that probe would populate the prefix
cache and turn the measured run's prefill into a cache hit, which is exactly
the difference between runtimes this harness is trying to see.
ttft is None when the engine produced no content delta.
"""
body = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": 0,
"stream": True,
"stream_options": {"include_usage": True},
}
request = urllib.request.Request(
base + "/v1/chat/completions",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
start = time.perf_counter()
ttft = None
deltas = 0
usage = {}
text = []
with urllib.request.urlopen(request, timeout=timeout) as response:
for raw in response:
line = raw.decode("utf-8", "replace").strip()
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
if chunk.get("usage"):
usage.update(chunk["usage"])
for choice in chunk.get("choices", []):
piece = choice.get("delta") or {}
# llama.cpp streams thinking as reasoning_content; vLLM's
# reasoning parser streams it as reasoning. Either one is a
# generated token, so TTFT must be the first token of ANY kind —
# counting only `content` made a thinking-heavy arm look like it
# answered instantly (measured 2026-09-20: radiance's geomean
# read 994 tok/s with 6 of 10 jobs recorded as "(empty)").
content = next((piece[k] for k in ("content", "reasoning",
"reasoning_content") if piece.get(k)), "")
if content:
if ttft is None:
ttft = time.perf_counter() - start
deltas += 1
text.append(content)
total = time.perf_counter() - start
return ttft, total, usage, deltas, "".join(text)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("base")
ap.add_argument("--model", default="frank-medium")
ap.add_argument("--salt", default=f"C{int(time.time())}")
ap.add_argument("--max-tokens", type=int, default=None)
ap.add_argument("--burst", type=int, default=0,
help="also run N concurrent copies of the plan job")
ap.add_argument("--timeout", type=int, default=1800)
ap.add_argument("--out", default=None)
args = ap.parse_args()
base = args.base.rstrip("/")
rows = []
print(f"# battery salt={args.salt} model={args.model} base={base}\n")
print(f"{'job':<10} {'ptok':>7} {'ctok':>6} {'ttft_s':>8} {'dec_tps':>8} "
f"{'total_s':>8} answer")
for name, cap, prompt in BATTERY:
if prompt is None:
prompt = long_prompt()
messages = [
{"role": "system", "content": SYSTEM.format(salt=args.salt)},
{"role": "user", "content": prompt},
]
limit = args.max_tokens or cap
try:
ttft, total, usage, deltas, text = stream_chat(
base, args.model, messages, limit, args.timeout)
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc:
print(f"{name:<10} FAILED {exc}")
rows.append({"job": name, "error": str(exc)})
continue
ptok = usage.get("prompt_tokens")
ctok = usage.get("completion_tokens") or deltas
decode = None
if ttft is not None and ctok and total > ttft:
decode = (ctok - 1) / (total - ttft)
prefill = (ptok / ttft) if (ptok and ttft) else None
first = text.strip().splitlines()[0][:48] if text.strip() else "(empty)"
print(f"{name:<10} {ptok or 0:>7} {ctok:>6} "
f"{(ttft or 0):>8.2f} {(decode or 0):>8.1f} {total:>8.2f} {first}")
rows.append({"job": name, "prompt_tokens": ptok, "completion_tokens": ctok,
"ttft_s": ttft, "decode_tps": decode, "prefill_tps": prefill,
"total_s": total, "answer_head": first})
good = [r for r in rows if r.get("decode_tps")]
if good:
rates = [r["decode_tps"] for r in good]
ttfts = [r["ttft_s"] for r in good]
pre = [r["prefill_tps"] for r in good if r.get("prefill_tps")]
print(f"\ngeomean decode {statistics.geometric_mean(rates):8.1f} tok/s"
f" (min {min(rates):.1f} / max {max(rates):.1f})")
print(f"mean ttft {statistics.mean(ttfts):8.2f} s")
if pre:
print(f"geomean prefill {statistics.geometric_mean(pre):8.1f} tok/s")
if args.burst > 1:
prompt = next(p for n, _, p in BATTERY if n == "plan")
messages = [
{"role": "system", "content": SYSTEM.format(salt=args.salt + "B")},
{"role": "user", "content": prompt},
]
results = []
lock = threading.Lock()
def worker():
try:
ttft, total, usage, deltas, _ = stream_chat(
base, args.model, messages, 640, args.timeout)
ctok = usage.get("completion_tokens") or deltas
with lock:
results.append((ttft, total, ctok))
except Exception as exc: # noqa: BLE001 - report, do not abort the burst
with lock:
results.append((None, 0.0, str(exc)))
threads = [threading.Thread(target=worker) for _ in range(args.burst)]
t0 = time.perf_counter()
for thread in threads:
thread.start()
for thread in threads:
thread.join()
wall = time.perf_counter() - t0
ok = [r for r in results if r[0] is not None]
if ok:
tokens = sum(r[2] for r in ok if isinstance(r[2], int))
print(f"burst {args.burst}x plan: wall {wall:.1f}s aggregate "
f"{tokens / wall:.1f} tok/s "
f"per-stream {statistics.mean([r[2] / r[1] for r in ok]):.1f} tok/s"
f" mean ttft {statistics.mean([r[0] for r in ok]):.2f}s")
else:
print(f"burst {args.burst}x plan: all failed: {results[0][2]}")
if args.out:
with open(args.out, "w", encoding="utf-8") as handle:
json.dump({"salt": args.salt, "model": args.model, "base": base,
"rows": rows, "burst": args.burst}, handle, indent=1)
print(f"wrote {args.out}")
return 0 if good else 1
if __name__ == "__main__":
sys.exit(main())
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
# Build the gufo runtime that serves frank-large on Dankclaw.
#
# bash scripts/build-gufo.sh # build + install the pinned tag
# bash scripts/build-gufo.sh --verify # only check the installed tree
#
# gufo (gufo-org/gufo) is the Strix Halo inference engine: it targets
# gfx1151 specifically and has no prebuilt Linux release binaries, so the
# runtime is built from the pinned git tag and installed to
# /home/dank/GitPackages/gufo-<tag>/. Everything below is what Dankclaw
# needs on top of AMD's Linux instructions.
#
# The host already carries the ROCm 7.2.4 stack (/opt/rocm -> /etc/
# alternatives/rocm) with the HIP dev packages, so this script installs
# only the ordinary system libraries gufo links against.
#
# Two host facts this build depends on, both discovered on 2026-09-28:
#
# 1. Ubuntu's librocwmma-dev ships a TRUNCATED rocWMMA header set (the
# public headers without rocwmma/internal/) into /usr/include, where
# it shadows AMD's complete copy under /opt/rocm/include. The first
# build died on "'internal/accessors.hpp' file not found" from
# /usr/include/rocwmma/rocwmma.hpp. A -I cannot fix it: in HIP mode
# the host compilation pass searches the system directories first.
# The package has no reverse dependencies, so it is removed.
# 2. The ROCm clang picks the NEWEST /usr/include/c++ tree, which on
# Ubuntu 26.04 is GCC 16's. Its <atomic_wait.h> emits a symbol
# reference the installed libstdc++ (GLIBCXX_3.4.35, GCC 15) does not
# export, and the link fails with "undefined symbol:
# std::__detail::__notify_impl". --gcc-install-dir pins the headers to
# the same GCC the runtime library came from.
set -euo pipefail
GUFO_TAG="${GUFO_TAG:-v0.1.1}"
GUFO_REPO="${GUFO_REPO:-https://github.com/gufo-org/gufo}"
# The commit the tag resolved to when this runtime was qualified. A tag that
# moves is an unreproducible runtime, so the pin is checked, not assumed.
GUFO_COMMIT="${GUFO_COMMIT:-b0f8673470a3dcc2b5e9c9546c48a40b82b5ff31}"
SRC_DIR="${GUFO_SRC_DIR:-/home/dank/GitPackages/gufo-src}"
PREFIX="${GUFO_PREFIX:-/home/dank/GitPackages/gufo-${GUFO_TAG}}"
JOBS="${JOBS:-$(nproc)}"
# The GCC toolchain that owns the installed libstdc++ on this host.
GCC_DIR="${GCC_DIR:-/usr/lib/gcc/x86_64-linux-gnu/15}"
# check_pin — refuse a source tree that is not the qualified commit.
check_pin() {
local commit
commit="$(git -C "$SRC_DIR" rev-parse HEAD 2>/dev/null || true)"
if [[ -n "$commit" && "$commit" != "$GUFO_COMMIT" ]]; then
echo "GUFO_COMMIT mismatch: $SRC_DIR is at $commit, pinned $GUFO_COMMIT" >&2
exit 1
fi
}
if [[ "${1:-}" == "--verify" ]]; then
# The pin first: a moved tag is the more urgent thing to hear about, and
# it is the check that must not be masked by a missing install.
check_pin
[[ -x "$PREFIX/bin/gufo" ]] || { echo "no gufo at $PREFIX/bin/gufo" >&2; exit 1; }
"$PREFIX/bin/gufo" diagnose | tail -n 5
exit 0
fi
echo "== system libraries gufo links against =="
sudo -n apt-get install -y -qq --no-install-recommends \
build-essential cmake ninja-build pkg-config \
libicu-dev libcurl4-openssl-dev libssl-dev libpng-dev libjpeg-dev ffmpeg
echo "== dropping Ubuntu's truncated rocWMMA headers =="
if dpkg -s librocwmma-dev >/dev/null 2>&1; then
sudo -n apt-get remove -y -qq librocwmma-dev
fi
echo "== source: $GUFO_REPO @ $GUFO_TAG =="
if [[ ! -d "$SRC_DIR/.git" ]]; then
mkdir -p "$(dirname "$SRC_DIR")"
git clone --depth 1 --branch "$GUFO_TAG" "$GUFO_REPO" "$SRC_DIR"
fi
git -C "$SRC_DIR" fetch --depth 1 origin "refs/tags/$GUFO_TAG"
git -C "$SRC_DIR" checkout -q --detach "refs/tags/$GUFO_TAG"
echo " $(git -C "$SRC_DIR" rev-parse HEAD)"
check_pin
echo "== configure + build (release, HIP gfx1151) =="
cd "$SRC_DIR"
toolchain="--gcc-install-dir=$GCC_DIR"
cmake --preset release \
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
-DCMAKE_HIP_FLAGS="$toolchain" \
-DCMAKE_EXE_LINKER_FLAGS="$toolchain"
cmake --build --preset release --parallel "$JOBS"
cmake --install build/release
echo "== verify =="
"$PREFIX/bin/gufo" diagnose
echo "frank-large runtime ready: $PREFIX/bin/gufo ($GUFO_TAG @ $commit)"
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
readonly repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
readonly ck_root="${KAIRIC_CK_ROOT:-$repo_root/third_party/composable_kernel}"
readonly ck_commit=fdf4bb7fcc984811cef48ce817d89aac064b984a
readonly ck_patch="$repo_root/patches/composable-kernel-gfx1151-iu4.patch"
readonly build_dir="${BUILD_DIR:-$repo_root/build-kairic}"
readonly rocm="${ROCM_PATH:-/opt/rocm}"
readonly jobs="${JOBS:-$(nproc)}"
for tool in cmake git ninja; do
command -v "$tool" >/dev/null || {
echo "missing required tool: $tool" >&2
exit 1
}
done
[[ -f "$ck_patch" ]] || {
echo "missing Composable Kernel patch: $ck_patch" >&2
exit 1
}
if [[ ! -d "$ck_root/.git" ]]; then
mkdir -p "$(dirname -- "$ck_root")"
git clone https://github.com/ROCm/composable_kernel.git "$ck_root"
fi
git -C "$ck_root" checkout "$ck_commit"
if git -C "$ck_root" diff --quiet; then
git -C "$ck_root" apply --check "$ck_patch"
git -C "$ck_root" apply "$ck_patch"
elif git -C "$ck_root" apply --reverse --check "$ck_patch"; then
echo "Composable Kernel release patch is already applied"
else
echo "Composable Kernel checkout does not match the pinned patch" >&2
exit 1
fi
if [[ -x "$rocm/bin/amdclang++" ]]; then
hip_compiler="$rocm/bin/amdclang++"
elif [[ -x "$rocm/llvm/bin/clang++" ]]; then
hip_compiler="$rocm/llvm/bin/clang++"
else
echo "cannot find a HIP compiler below ROCM_PATH=$rocm" >&2
exit 1
fi
if [[ -n "${CC:-}" ]]; then
c_compiler="$(command -v "$CC")"
elif [[ -x /usr/bin/gcc ]]; then
c_compiler=/usr/bin/gcc
else
c_compiler="$(command -v gcc)"
fi
if [[ -n "${CXX:-}" ]]; then
cxx_compiler="$(command -v "$CXX")"
elif [[ -x /usr/bin/g++ ]]; then
cxx_compiler=/usr/bin/g++
else
cxx_compiler="$(command -v g++)"
fi
export ROCM_PATH="$rocm"
export PATH="$rocm/bin:$rocm/lib/llvm/bin:$rocm/llvm/bin:$PATH"
export LD_LIBRARY_PATH="$rocm/lib:$rocm/lib/rocm_sysdeps/lib:$rocm/lib/llvm/lib:$rocm/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
readonly hip_flags="-DGGML_ROCMFPX_RDNA35_MMID_MAX_BATCH=5 -DGGML_ROCMFPX_MOE_MMVQ_ROWS_PER_BLOCK=4${KAIRIC_HIP_TOOLCHAIN_FLAGS:+ $KAIRIC_HIP_TOOLCHAIN_FLAGS}"
cmake -S "$repo_root" -B "$build_dir" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER="$c_compiler" \
-DCMAKE_CXX_COMPILER="$cxx_compiler" \
-DCMAKE_HIP_COMPILER="$hip_compiler" \
-DCMAKE_HIP_FLAGS="$hip_flags" \
-DCMAKE_PREFIX_PATH="$rocm" \
-DBUILD_SHARED_LIBS=ON \
-DGGML_CPU=ON -DGGML_OPENMP=ON -DGGML_HIP=ON -DGGML_CUDA=OFF \
-DGGML_VULKAN=OFF -DGGML_HIP_FORCE_MMQ=ON -DGGML_HIP_GRAPHS=ON \
-DGGML_HIP_MMQ_MFMA=ON -DGGML_HIP_NO_VMM=ON \
-DGGML_HIP_ROCWMMA_FATTN=OFF -DGGML_NATIVE=ON \
-DAMDGPU_TARGETS=gfx1151 -DGPU_BUILD_TARGETS=gfx1151 \
-DPROMPTFORGE_CK_ROOT="$ck_root" \
-DLLAMA_BUILD_WEBUI=OFF \
-DGGML_BUILD_TESTS=OFF -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_SERVER=ON
cmake --build "$build_dir" --target llama-server -j"$jobs"
"$build_dir/bin/llama-server" --version
"$build_dir/bin/llama-server" --help | grep -A1 -- '--kairic-edge'
ldd "$build_dir/bin/llama-server" 2>&1 | tee "$build_dir/llama-server.ldd.txt"
if grep -Eiq 'not found|version .* required by .* not found' "$build_dir/llama-server.ldd.txt"; then
echo "runtime dependency verification failed" >&2
exit 1
fi
echo "Kairic Edge runtime built at $build_dir/bin/llama-server"
+7 -17
View File
@@ -38,7 +38,6 @@ JOBS="${JOBS:-$(nproc)}"
ROCM_VERSION="${ROCM_VERSION:-7.14.0a20260624}"
ROCM_LOCAL_PATH="${ROCM_LOCAL_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/rocm-${ROCM_VERSION}}"
HIP_ARCH="${HIP_ARCH:-gfx1201}"
GGML_HIP_RCCL="${GGML_HIP_RCCL:-OFF}"
# ── Source ────────────────────────────────────────────────────────────
if [[ ! -d "$LLAMA_SRC/.git" ]]; then
@@ -71,13 +70,11 @@ export ROCM_PATH="$ROCM_LOCAL_PATH"
export HIP_PATH="$ROCM_LOCAL_PATH"
# ── Configure ─────────────────────────────────────────────────────────
# GGML_HIP_RCCL defaults OFF: layer-split serving needs no collectives, and
# until 2026-09-20 this host's second card could not dispatch a RCCL kernel at
# all (its PCIe path had no atomic routing). With RCCL off, llama.cpp logs
# "internal AllReduce init failed ... falling back to meta-backend butterfly"
# and only --split-mode row/tensor pays for it. Set GGML_HIP_RCCL=ON (with a
# distinct BUILD_DIR) for the collective-backed runtime; the atomics
# precondition now holds on both cards.
# GGML_HIP_RCCL stays OFF: this host's second card cannot dispatch a RCCL
# kernel at all (its PCIe path has no atomic routing), and layer-split serving
# needs no collectives. With RCCL off, llama.cpp logs "internal AllReduce init
# failed ... falling back to meta-backend butterfly" and only --split-mode
# tensor pays for it.
cmake -S "$LLAMA_SRC" -B "$BUILD_DIR" \
-G Ninja \
-DCMAKE_BUILD_TYPE=Release \
@@ -86,7 +83,7 @@ cmake -S "$LLAMA_SRC" -B "$BUILD_DIR" \
-DCMAKE_CXX_FLAGS="-I${ROCM_LOCAL_PATH}/include" \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DGGML_HIP=ON \
-DGGML_HIP_RCCL="$GGML_HIP_RCCL" \
-DGGML_HIP_RCCL=OFF \
-DGGML_CUDA=OFF \
-DGGML_VULKAN=OFF \
-DGGML_NATIVE=OFF \
@@ -111,7 +108,7 @@ if command -v patchelf >/dev/null; then
mkdir -p "$LIB_DIR"
for pat in libamdhip64 librocblas libhipblas libhipblaslt librocsolver \
libroctx64 libamd_comgr libhsa-runtime64 librocm_kpack \
librocroller librocprofiler-register librocm-core; do
librocroller librocprofiler-register; do
cp -v "$ROCM_LOCAL_PATH/lib/$pat".so* "$LIB_DIR/" 2>/dev/null || true
done
cp -v "$ROCM_LOCAL_PATH/lib/llvm/lib"/libLLVM.so* \
@@ -119,13 +116,6 @@ if command -v patchelf >/dev/null; then
"$ROCM_LOCAL_PATH/lib/llvm/lib"/libomp.so* \
"$ROCM_LOCAL_PATH/lib/llvm/lib"/libomptarget.so* "$LIB_DIR/" 2>/dev/null || true
cp -v "$ROCM_LOCAL_PATH/lib/rocm_sysdeps/lib"/librocm_sysdeps_*.so* "$LIB_DIR/" 2>/dev/null || true
# A RCCL build links librccl.so.1 (imported shared even with
# BUILD_SHARED_LIBS=OFF), and the binaries resolve libraries through
# $ORIGIN/../lib — so the collective library must ride beside the rest or
# llama-server fails to exec at all.
if [[ "$GGML_HIP_RCCL" == "ON" ]]; then
cp -v "$ROCM_LOCAL_PATH"/lib/librccl.so* "$LIB_DIR/" 2>/dev/null || true
fi
# rocBLAS and hipBLASLt look for their device code at a FIXED depth under
# the binary's lib dir: $ORIGIN/../lib/rocblas/library and
# $ORIGIN/../lib/hipblaslt/library/<arch>. `cp -r SRC DST/` with a missing
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env bash
# Pin Dankserver's fleet NIC (eno1, 192.168.0.235) as the primary default route.
#
# The 2026-09-20 board swap (TUF GAMING B650-E -> ProArt B850-Creator WiFi NEO)
# brought up a second port, eno2, which took DHCP and — with NetworkManager's
# default per-connection metric of 100 — came up with metric 101 while eno1's
# static gateway got 102. Outbound traffic therefore started leaving by eno2
# even though every fleet peer, Caddy upstream and monitoring target addresses
# this host as .235. Keep eno2 (it is a real second link) but make it secondary:
# route metrics decide the default route, so eno1 wins while eno2 stays a
# failover.
#
# Run on Dankserver. Idempotent; NetworkManager owns these connections (the
# netplan files under /etc/netplan are its generated shim, so edit via nmcli).
#
# bash scripts/dankserver-route-primary.sh [--check]
set -euo pipefail
PRIMARY='Wired connection 1' # eno1, static 192.168.0.235, fleet address
SECONDARY='Wired connection 2' # eno2, DHCP 192.168.0.247
PRIMARY_IF='eno1'
SECONDARY_IF='eno2'
PRIMARY_METRIC=100
SECONDARY_METRIC=200
show() {
printf 'routes:\n'; ip route show default
printf 'wanted: %s metric %s, %s metric %s\n' \
"$PRIMARY" "$PRIMARY_METRIC" "$SECONDARY" "$SECONDARY_METRIC"
}
if [[ "${1:-}" == '--check' ]]; then
for conn in "$PRIMARY" "$SECONDARY"; do
printf '%s: ipv4=%s ipv6=%s\n' "$conn" \
"$(nmcli -g ipv4.route-metric connection show "$conn")" \
"$(nmcli -g ipv6.route-metric connection show "$conn")"
done
show
exit 0
fi
nmcli connection modify "$PRIMARY" \
ipv4.route-metric "$PRIMARY_METRIC" ipv6.route-metric "$PRIMARY_METRIC"
nmcli connection modify "$SECONDARY" \
ipv4.route-metric "$SECONDARY_METRIC" ipv6.route-metric "$SECONDARY_METRIC"
# `nmcli device reapply <ifname>` pushes the edited profile without tearing the
# link down. `nmcli connection up` would re-run DHCP and briefly drop the route
# this session rides on, and `connection reapply` is not a verb (NM 1.54).
nmcli device reapply "$PRIMARY_IF"
nmcli device reapply "$SECONDARY_IF"
show
if [[ "$(ip route show default | head -1)" != *"dev $PRIMARY_IF"* ]]; then
printf 'ERROR: eno1 is still not the preferred default route\n' >&2
exit 1
fi
printf 'eno1 is the preferred default route; eno2 remains up as failover.\n'
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env bash
# Single source of truth for Dankserver GLM-5.3 state.
#
# This exists because the ad-hoc status commands kept producing WRONG readings.
# The specific trap: /proc/meminfo separates key from value with a TAB, so the
# common idiom
# grep MemAvailable /proc/meminfo | tr -s " " | cut -d" " -f2
# yields an empty field (tab is not a space) and awk then prints 0.0 -- which
# reads as "host out of memory" when the host actually has 240 GiB free.
# Everything below parses on a colon split, which is tab-agnostic, and the
# memory figure is cross-checked before it is shown.
set -uo pipefail
MB=1048576
GLM_CTR="${GLM_CTR:-glm-w4a16}"
mem_field() { awk -v k="$1:" '$1==k {printf "%.1f", $2/'"$MB"'; exit}' /proc/meminfo; }
section() { printf '\n== %s ==\n' "$1"; }
section "time / host"
printf ' %s %s\n' "$(date +%H:%M:%S)" \
"$(awk -F: '/model name/{print $2; exit}' /proc/cpuinfo | sed 's/^ *//')"
section "memory (GiB)"
TOT=$(mem_field MemTotal); AVAIL=$(mem_field MemAvailable)
printf ' MemTotal %8s\n' "$TOT"
printf ' MemAvailable %8s\n' "$AVAIL"
printf ' Cached %8s\n' "$(mem_field Cached)"
awk -v t="$TOT" -v a="$AVAIL" 'BEGIN{
if (t+0 <= 0 || a+0 <= 0) { print " *** PARSE BUG: zero value ***"; exit }
if (a+0 > t+0) { print " *** PARSE BUG: available > total ***"; exit }
printf " check ok (%.0f%% free)\n", 100*a/t
}'
section "swap"
swapon --show=NAME,SIZE,USED --noheadings 2>/dev/null \
| awk '{printf " %-12s %6s used of %s\n",$1,$3,$2}' || echo " none"
section "gpu"
# CSV layout: device,VRAM Total Memory (B),VRAM Total Used Memory (B)
rocm-smi --showmeminfo vram --csv 2>/dev/null | awk -F, '
NR>1 && $1 ~ /^card[0-9]+$/ {
printf " %-8s %7.2f GiB used of %.2f\n", $1, $3/1073741824, $2/1073741824
}'
printf ' compute util: '
rocm-smi --showuse --csv 2>/dev/null | awk -F, 'NR>1 && $1 ~ /^card[0-9]+$/ {printf "%s=%s%% ", $1, $2}'
echo
section "disk"
df -h /home/dank 2>/dev/null | awk 'NR==2{printf " %s %s used %s free (%s)\n",$1,$3,$4,$5}'
section "llm containers"
if podman ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'glm|frank'; then
podman ps -a --format '{{.Names}}|{{.Status}}' 2>/dev/null \
| awk -F'|' '$1 ~ /glm|frank/ {printf " %-18s %s\n",$1,$2}'
else
echo " none"
fi
section "frank-primary unit"
printf ' active: %s\n' "$(systemctl is-active frank-primary 2>/dev/null | head -1)"
printf ' enabled: %s\n' "$(systemctl is-enabled frank-primary 2>/dev/null | head -1)"
section "glm serve tail"
if podman ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$GLM_CTR"; then
podman logs "$GLM_CTR" 2>&1 | grep -viE 'multiproc_executor\.py:943' | tail -5 | sed 's/^/ /'
else
echo " (container $GLM_CTR not present)"
fi
section "done"
+22 -44
View File
@@ -38,7 +38,6 @@ SERVICE_ORDER = (
"loki",
"alloy",
"node-exporter",
"alertmanager",
"authentik-postgres",
"authentik-server",
"authentik-worker",
@@ -52,7 +51,6 @@ SERVICE_ORDER = (
"copyparty",
"jellyfin",
"calibre-web",
"calibre-server",
"plex",
"tautulli",
"prowlarr",
@@ -63,7 +61,7 @@ SERVICE_ORDER = (
"readarr",
"frank-large",
"frank-small",
"frank-primary",
"frank-medium",
)
DANKCLAW_SERVICES = {
@@ -85,7 +83,6 @@ DANKCLAW_SERVICES = {
"loki",
"alloy",
"node-exporter",
"alertmanager",
"authentik-postgres",
"authentik-server",
"authentik-worker",
@@ -104,7 +101,6 @@ DANKSERVER_SERVICES = {
"copyparty",
"jellyfin",
"calibre-web",
"calibre-server",
"plex",
"tautulli",
"prowlarr",
@@ -113,7 +109,7 @@ DANKSERVER_SERVICES = {
"sonarr",
"lidarr",
"readarr",
"frank-primary",
"frank-medium",
"node-exporter",
}
@@ -250,10 +246,6 @@ def services_for_path(path: str, target: str) -> set[str]:
_add(services, "searxng")
elif group == "prometheus":
_add(services, "prometheus")
if len(parts) >= 3 and parts[2] == "alertmanager.yml":
# The Alertmanager config ships from this directory: a change
# to it must redeploy the service that installs it.
_add(services, "alertmanager")
elif group == "loki":
_add(services, "loki")
elif group == "alloy":
@@ -316,8 +308,11 @@ def services_for_path(path: str, target: str) -> set[str]:
if len(parts) >= 2 and parts[1] == "open-webui":
_add(services, "open-webui")
return services
# frank-large left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host
# build, root unit): its files live in scripts/ and map there.
if len(parts) >= 2 and parts[1] == "frank-large":
# The service is a rootless Quadlet container since 2026-09-11;
# its unit and fleet settings live in one .container file.
_add(services, "frank-large")
return services
if len(parts) >= 2 and parts[1] == "observability":
# The five observability containers became Quadlet units on
# 2026-09-11. A unit change selects exactly its own service;
@@ -325,7 +320,7 @@ def services_for_path(path: str, target: str) -> set[str]:
# consumer (prometheus before grafana, loki before alloy).
if len(parts) >= 4 and parts[2] == "quadlet":
name = parts[3].removesuffix(".container")
if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter", "alertmanager"}:
if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter"}:
_add(services, name)
return services
if len(parts) >= 2 and parts[1] == "gitea":
@@ -355,7 +350,6 @@ def services_for_path(path: str, target: str) -> set[str]:
"romm": {"romm", "romm-mariadb", "romm-redis"},
"jellyfin": {"jellyfin"},
"calibre-web": {"calibre-web"},
"calibre-server": {"calibre-server"},
"copyparty": {"copyparty"},
"plex": {"plex"},
"tautulli": {"tautulli"},
@@ -371,11 +365,13 @@ def services_for_path(path: str, target: str) -> set[str]:
if name == "node-exporter":
_add(services, "node-exporter")
return services
# frank-primary's quadlet-era directory left the tree on 2026-09-26
# (the Lucebox DS4.1 unit was retired for the llama.cpp root unit —
# the trio in scripts/ now maps it). Nothing lives under
# dankserver/frank-primary/ except dated trash, and trash plans
# nothing (the retire rule above the service blocks).
if len(parts) >= 2 and parts[1] == "frank-medium":
# frank-medium is a ROOT llama.cpp unit since 2026-09-16: its
# runtime is a native gfx1201 binary, not a container, so there is
# nothing under dankserver/frank-medium/ to plan any more. The
# retired vllm-radiance quadlets live in .AGENTTRASH/, which is
# archive material and plans nothing.
return services
return services
if parts[0] == "frankbook-pro":
@@ -395,37 +391,18 @@ def services_for_path(path: str, target: str) -> set[str]:
name = p.name
if name in {"homepage-status.py", "homepage-status.service", "homepage-status.timer"}:
_add(services, "homepage")
if name in {"signal-relay.py", "signal-relay.service"}:
# The Signal relay is installed by the alertmanager deploy, so a
# change to it redeploying alertmanager is the only path that
# reaches it on the host.
_add(services, "alertmanager")
if name in {"frank-small.env", "frank-small.service", "serve-frank-small.sh"}:
# llama.cpp frank-small (2026-09-16) keeps its unit, env and serve
# files here; each one selects frank-small only. The ik_llama.cpp
# trio that lived here for two days is in scripts/.AGENTTRASH/,
# which is archive material and plans nothing.
_add(services, "frank-small")
if name in {"frank-large.env", "frank-large.service", "serve-frank-large.sh"}:
# CIRU v4.4.1 frank-large (2026-09-19) keeps its unit, env and
# serve files here the same way: a root unit on a host-built
# runtime. The download script fetches the payload, which is
# host-side state, so it selects nothing.
_add(services, "frank-large")
if name == "qwen-sharp-v22.4.0.jinja":
# The Qwen-Sharp chat template frank-large loads via
# --chat-template-file. It lived under dankserver/frank-medium/ until
# the 2026-09-26 cutover retired that directory; editing it changes
# what the live frank-large endpoint renders, so it plans
# frank-large.
_add(services, "frank-large")
if name in {"frank-primary.env", "frank-primary.service", "serve-frank-primary.sh"}:
# llama.cpp frank-primary (2026-09-26, replacing the Lucebox
# DS4.1 quadlet) keeps its unit, env and serve files here the
# same way: a root unit on the unsloth b11030-mix prebuilt
# serving Qwen3.8-Flash-Next Q8_0. The download script fetches
# the payload, which is host-side state, so it selects nothing.
_add(services, "frank-primary")
if name in {"frank-medium.env", "frank-medium.service", "serve-frank-medium.sh"}:
# llama.cpp frank-medium (2026-09-16) keeps its unit, env and serve
# files here the same way. The files that BUILD and FETCH its
# runtime and payload are host-side one-offs: they change no
# deployed state, so they select nothing.
_add(services, "frank-medium")
# Deployment/test/benchmark scripts and other repo tooling do not
# restart a runtime service.
return services
@@ -435,6 +412,7 @@ def services_for_path(path: str, target: str) -> set[str]:
return services
return services
def changed_paths(repo: str, base: str, head: str) -> list[str]:
result = subprocess.run(
["git", "-C", repo, "diff", "--name-only", "--diff-filter=ACDMRTUXB", base, head],
+174 -309
View File
@@ -86,16 +86,16 @@ service_is_allowed() {
dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\
dankclaw:comfyui|dankclaw:hermes|\
dankclaw:hermes-dashboard|dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|\
dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|dankclaw:alertmanager|\
dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|\
dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|\
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
dankclaw:frank-large|dankclaw:frank-small|\
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:plex|dankserver:tautulli|\
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
dankserver:frank-primary|dankserver:node-exporter|\
dankserver:frank-medium|dankserver:node-exporter|\
frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\
frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\
frankbook-pro:readarr)
@@ -177,17 +177,6 @@ add_pending_services() {
if [[ "$status" != deployed ]]; then
# A pending service was intentionally skipped or its handler
# failed. Retry it even when the checkout has not advanced.
# But a pending entry for a service RETIRED from this target can
# never be deployable: retrying it fails the whole run before
# the e2e gate (the frank-default-sightsound lockout, found
# 2026-09-17). Drop such entries into the host trash instead.
if ! service_is_allowed "$service"; then
local trash="$HOME/.AGENTTRASH/$(date +%Y%m%d)-retired-deploy-state"
mkdir -p "$trash"
mv -- "$state_file" "$trash/$service"
echo "WARN: $service is retired on $TARGET; dropped its stale $status state to $trash" >&2
continue
fi
SERVICES+=("$service")
continue
fi
@@ -209,11 +198,6 @@ copy_searxng_config() {
run_privileged mkdir -p /opt/searxng/config
run_privileged cp shared/searxng/settings.yml /opt/searxng/config/settings.yml
run_privileged cp shared/searxng/limiter.toml /opt/searxng/config/limiter.toml
# Bake ${KAGI_API_KEY}/${SEARXNG_SECRET}/${SEARXNG_BASE_URL} into the live
# copy from the host-side /opt/searxng/.env (SearXNG does not expand env
# vars in settings.yml). Fails the deploy if a placeholder is unresolvable.
run_privileged python3 scripts/searxng-render-settings.py \
/opt/searxng/.env /opt/searxng/config/settings.yml
}
copy_prometheus_config() {
@@ -226,53 +210,6 @@ copy_prometheus_config() {
/opt/prometheus/config/rules/llm-fleet.yml
}
# Alertmanager config ships beside the Prometheus config: the two halves of
# the alerting path change together, and a receiver with no route (or a
# route with no receiver) fails silently.
copy_alertmanager_config() {
run_privileged mkdir -p /opt/alertmanager/config /opt/alertmanager/data
run_privileged cp shared/prometheus/alertmanager.yml /opt/alertmanager/config/alertmanager.yml
}
# The Signal relay is Alertmanager's only webhook consumer and the only
# thing on this host that talks to signal-cli. It is a plain systemd USER
# unit (not a quadlet): it runs python3 from the checkout, so a deploy
# updates it without a copy step, and — unlike generated quadlet units,
# which this host's podman 5.x v1 generator rejects — a hand-written user
# unit can be enabled, and Linger=yes for dank starts it at boot.
install_signal_relay() {
local unit_dir="$HOME/.config/systemd/user"
mkdir -p "$unit_dir"
cp scripts/signal-relay.service "$unit_dir/signal-relay.service"
systemctl --user daemon-reload
systemctl --user enable signal-relay.service
systemctl --user restart signal-relay.service
# The relay binds the host's LAN IP (the one address a bridge container
# can reach as host.containers.internal), so keep the unauthenticated
# endpoint off the rest of the LAN: admit loopback, the host itself
# (its own health probe) and the podman bridge subnet only. Idempotent:
# -C before -I.
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT 2>/dev/null \
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT 2>/dev/null \
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT 2>/dev/null \
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT
run_privileged iptables -C INPUT -p tcp --dport 18093 -j DROP 2>/dev/null \
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -j DROP
# The interpreter needs a moment to bind after restart; a one-shot curl
# raced the socket on 2026-09-17 and failed a healthy deploy.
local i
for i in $(seq 1 15); do
if curl -fsS -m 5 http://192.168.0.143:18093/health >/dev/null; then
return 0
fi
sleep 1
done
echo "signal-relay is not answering on 192.168.0.143:18093" >&2
return 1
}
copy_loki_config() {
run_privileged mkdir -p /opt/loki/config
run_privileged cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
@@ -318,7 +255,7 @@ deploy_homepage() {
fi
run_privileged mkdir -p /srv/homepage
run_privileged cp shared/homepage/index.html shared/homepage/admin.html shared/homepage/app.js \
shared/homepage/style.css shared/homepage/services.json shared/homepage/docs.html /srv/homepage/
shared/homepage/style.css shared/homepage/services.json /srv/homepage/
if [[ -d shared/homepage/fonts ]]; then
run_privileged cp -r shared/homepage/fonts /srv/homepage/
fi
@@ -489,17 +426,13 @@ seed_grafana_env() {
deploy_observability() {
local service="$1"
# Observability units live under <target>/observability/quadlet: Dankclaw
# has six, Dankserver one (node-exporter, since 2026-09-16). The config
# has five, Dankserver one (node-exporter, since 2026-09-16). The config
# copies below are Dankclaw-owned state and run only there.
local unit_dir="$TARGET/observability/quadlet"
case "$service" in
prometheus)
copy_prometheus_config
;;
alertmanager)
copy_alertmanager_config
install_signal_relay
;;
loki)
copy_loki_config
build_compose_image loki
@@ -523,7 +456,6 @@ deploy_observability() {
systemctl --user restart "$service.service"
case "$service" in
prometheus) wait_for_health prometheus http://127.0.0.1:9090/-/healthy 60 ;;
alertmanager) wait_for_health alertmanager http://127.0.0.1:9093/-/healthy 60 ;;
grafana) wait_for_health grafana http://127.0.0.1:3002/api/health 90 ;;
loki) wait_for_health loki http://127.0.0.1:3100/ready 60 ;;
alloy) wait_for_health alloy http://127.0.0.1:12345/-/healthy 60 ;;
@@ -855,50 +787,6 @@ deploy_calibre_web() {
systemctl --user restart calibre-web.service
}
# The content server's single admin account and the desktop's basic-auth
# credential (same pair) are bootstrapped exactly once: the env file is the
# plaintext store (mode 600), the userdb's pw column is plaintext too, so
# its file is mode 600 as well. The password is piped on stdin with printf -
# a trailing newline from echo lands in the stored password and breaks every
# login (verified 2026-09-21).
deploy_calibre_server() {
local data_root=/home/dank/Containers/calibre-server
mkdir -p "$data_root"
if [[ ! -f $data_root/env ]]; then
local pw
pw=$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)
printf 'CUSTOM_USER=calibre\nCUSTOM_PASSWORD=%s\n' "$pw" > "$data_root/env"
chmod 600 "$data_root/env"
echo "calibre-server: generated credential (shown once): calibre / $pw"
fi
[[ -f $data_root/env ]] || {
echo "FAIL: $data_root/env does not exist; seed it before the first calibre-server deploy" >&2
return 1
}
if [[ ! -f $data_root/userdb.sqlite ]]; then
local pw
pw=$(grep '^CUSTOM_PASSWORD=' "$data_root/env" | cut -d= -f2)
touch "$data_root/userdb.sqlite"
printf '%s' "$pw" | podman run --rm -i --entrypoint /usr/bin/calibre-server \
-v "$data_root/userdb.sqlite:/userdb.sqlite" \
lscr.io/linuxserver/calibre:9.15.0 \
--userdb /userdb.sqlite --manage-users -- add calibre
chown 1000:1000 "$data_root/userdb.sqlite"
chmod 600 "$data_root/userdb.sqlite"
fi
[[ -f $data_root/userdb.sqlite && -s $data_root/userdb.sqlite ]] || {
echo "FAIL: $data_root/userdb.sqlite missing or empty; the --manage-users bootstrap failed" >&2
return 1
}
cp "dankserver/calibre-server/quadlet/calibre-server.container" "$quadlet_unit_dir/"
systemctl --user daemon-reload
if ! systemctl --user is-active -q calibre-server.service; then
podman stop calibre-server 2>/dev/null || true
podman rm calibre-server 2>/dev/null || true
fi
systemctl --user restart calibre-server.service
}
deploy_copyparty() {
# The admin account is created from ${COPAY_ACCOUNT}:${COPAY_PASSWORD} expanded
# at ExecStart time; a missing env file must fail the unit, not boot copyparty
@@ -1077,37 +965,41 @@ deploy_anubis() {
wait_container_healthy anubis 60 || return 1
}
# frank-large is gufo (gufo-org/gufo), the Strix Halo inference engine built
# from the pinned v0.1.1 tag by scripts/build-gufo.sh, serving
# unsloth/Qwen3.8-27B-GGUF:UD-Q8_K_XL with the DFlash2 drafter
# (z-lab/Qwen3.8-27B-DFlash2-GGUF:Q4_K_M) on the Strix Halo iGPU. It is a
# ROOT unit: the runtime is a native binary linked against the host ROCm,
# so there is no container image to install and no generated unit to dodge.
# relink_unit points /etc/systemd/system/frank-large.service at the repo
# file and boot persistence is that unit's [Install] section.
# frank-large runs as a rootless Quadlet unit again since 2026-09-16: the
# halogen-flash-server runtime is back (it replaced ik_llama.cpp, which had
# replaced it), and the RTX 3090 it shared during that interlude now belongs
# to frank-small.
#
# The runtime gate matters as much as the payload gate: gufo is built from
# source on this host (there is no prebuilt release), so a machine whose
# /home/dank/GitPackages tree was never built has a unit that can never
# become healthy, and a green deploy would leave the endpoint dark.
# TWO MANAGERS, ONE UNIT NAME. The ik incarnation ran as a ROOT unit whose
# file is a symlink into this checkout; the halogen unit is a USER (quadlet)
# unit. Both must never exist at once — they would race for port 7770 — so the
# root unit is retired first: stop, disable, move the /etc symlink into the
# home trash (never `rm`), reload the root manager. This host's `systemctl
# disable` has been observed to delete the unit file itself (2026-08-26
# incident), so the move tolerates a file that is already gone.
#
# It replaces the stock llama.cpp prebuilt (unsloth b11030), which had
# replaced the Kairic Edge runtime (ciru-ai/ROCmFPX). The port gate is the
# same as every cutover since 2026-09-16: nothing starts until 7770 is
# released.
LLM_LARGE_RUNTIME="${LLM_LARGE_RUNTIME:-/home/dank/GitPackages/gufo-v0.1.1/bin/gufo}"
LLM_LARGE_MODEL="${LLM_LARGE_MODEL:-/home/dank/models/Swift-1.5-Qwen3.8-27B-Uncensored-MTP-UD-Q8_K_XL/Swift-1.5-Qwen3.8-27B-Uncensored-Dynamic-MTP-UD-Q8_K_XL.gguf}"
LLM_LARGE_DRAFT="${LLM_LARGE_DRAFT:-/home/dank/models/Qwen3.8-27B-UD-Q8_K_XL/Qwen3.8-27B-DFlash2-Q4_K_M.gguf}"
# FIRST START fetches ~127 GB of weights, and /health cannot answer while it
# does. wait_for_llm_large_ready recognises that (checkpoint absent) and waits
# for the file instead of burning the health budget on a failure that is not
# one.
LLM_LARGE_CHECKPOINT="${LLM_LARGE_CHECKPOINT:-/home/dank/models/halogen/qwen38-flash-next-w4b.hgn}"
retire_halogen_large_quadlet() {
local trash="$HOME/.AGENTTRASH/20260919-orca-cutover/quadlet"
systemctl --user stop frank-large.service 2>/dev/null || true
if [[ -e "$quadlet_unit_dir/frank-large.container" ]]; then
retire_root_llm_large() {
local trash="$HOME/.AGENTTRASH/20260916-halogen-restore/host-units"
run_privileged systemctl stop frank-large.service 2>/dev/null || true
run_privileged systemctl disable frank-large.service 2>/dev/null || true
if [[ -e /etc/systemd/system/frank-large.service || -L /etc/systemd/system/frank-large.service ]]; then
mkdir -p "$trash"
mv -- "$quadlet_unit_dir/frank-large.container" "$trash/frank-large.container"
echo "retired quadlet frank-large.container -> $trash"
run_privileged mv -- /etc/systemd/system/frank-large.service \
"$trash/frank-large.service"
echo "retired root unit frank-large.service -> $trash"
else
# This host's `systemctl disable` is allowed to delete the unit file
# outright (2026-08-26 incident), so there is often nothing left to
# move. Say so: previously no message meant no evidence either way.
echo "root unit frank-large.service was already removed by disable"
fi
systemctl --user daemon-reload
run_privileged systemctl daemon-reload
}
# Wait until a TCP port has no socket on it at all -- listening, accepted, or
@@ -1130,86 +1022,77 @@ wait_port_free() {
}
deploy_llm_large() {
# Refuse rather than restart-loop. A unit whose runtime or payload is
# missing never becomes healthy, and a green deploy would leave the
# endpoint dark with nothing in the deploy log to say why.
if [[ ! -x "$LLM_LARGE_RUNTIME" ]]; then
echo "ERROR: gufo runtime absent at $LLM_LARGE_RUNTIME" >&2
echo "frank-large: build it: bash scripts/build-gufo.sh" >&2
return 1
fi
local missing=0 f
for f in "$LLM_LARGE_MODEL" "$LLM_LARGE_DRAFT"; do
if [[ ! -f "$f" ]]; then
echo "ERROR: payload absent at $f" >&2
echo "frank-large: fetch it: bash scripts/download-frank-large.sh" >&2
missing=1
fi
done
[[ "$missing" -eq 0 ]] || return 1
retire_halogen_large_quadlet
# On a re-deploy the predecessor is THIS unit: stop it before waiting on
# the port, or the wait deadlocks on the server it just stopped serving.
# Then sweep a stray manual run -- that is how a bench harness leaves the
# port busy. Match the DRAFTER path, which every frank-large command
# line carries via --dflash-model and which is UNCHANGED by the 2026-09-28
# payload swap: the target moved to a different repository while the
# drafter stayed put, so keying on the target filename would have quietly
# stopped matching. It is also the right key in principle — it names this
# endpoint's speculative drafter, not a binary. Match that, not a binary
# name: frank-small on this host serves a different GGUF over llama-server
# with no DFlash2 drafter and must not be killed. The pattern never
# appears in the deploying shell's own command line, so -f is safe here
# (it is not in `bash deploy-remote.sh`).
run_privileged systemctl stop frank-large.service 2>/dev/null || true
pkill -f 'Qwen3.8-27B-DFlash2-Q4_K_M' 2>/dev/null || true
retire_root_llm_large
# The predecessor can also be THIS quadlet -- a redeploy of a running
# frank-large -- so stop the user unit before waiting on the port. On a
# first cutover it is not running and this is a no-op.
systemctl --user stop frank-large.service 2>/dev/null || true
# Pull once, then run the localhost/-aliased tag: this host's quadlet path
# cannot resolve registry-prefixed refs from local storage at create time
# (same workaround as anubis/hermes), and Pull=never keeps boots off the
# registry.
local image="ghcr.io/peonist-ai/halogen-flash-server:0.5.8"
local alias="localhost/peonist-ai/halogen-flash-server:0.5.8"
podman pull "$image"
podman tag "$image" "$alias"
# The weights volume must exist, and the container needs it read-write:
# that is where a first start downloads into.
mkdir -p /home/dank/models/halogen
# Nothing may start until the predecessor has released the port. On
# 2026-09-16 the cutover failed exactly here: the old server logged
# "Deactivated successfully", the next engine tried to bind 7770 a second
# later and reported "Address already in use". Gate on the port, not on
# hope.
# 2026-09-16 this sequence failed exactly here: the ik server logged
# "Deactivated successfully", pasta tried to bind 7770 a second later and
# reported "Address already in use", and the unit exited 126. Restart=
# on-failure recovered it minutes later, but a deploy that starts a doomed
# container has not deployed anything -- so gate on the port, not on hope.
wait_port_free 7770 || return 1
relink_unit frank-large
run_privileged systemctl daemon-reload
run_privileged systemctl enable frank-large.service
run_privileged systemctl restart frank-large.service
install_quadlet frank-large dankclaw/frank-large/quadlet frank-large
# No enable: generated units cannot be enabled on this host (v1 quadlet
# generator); boot persistence comes from [Install] in the unit file.
systemctl --user restart frank-large.service
wait_for_llm_large_ready
wait_for_health frank-large http://127.0.0.1:7770/health 1200
}
# deploy_llm_small — frank-small is SGLang (python3.12 venv) on the RTX 3090,
# as a ROOT unit: the runtime is a self-contained venv, so there is no
# container image to install and no generated unit to avoid. relink_unit
# points /etc/systemd/system/frank-small.service at the repo file and boot
# persistence is that unit's [Install] section.
# Bootstrap-aware gate: if the checkpoint is absent the container is fetching
# it; wait for the file (the fetch resumes, and the unit restarts it on
# failure) rather than gating on a /health that cannot answer yet. Reports
# success either way, so a first deploy does not look like a failure while the
# engine does exactly what it was told to.
wait_for_llm_large_ready() {
[[ -f "$LLM_LARGE_CHECKPOINT" ]] && return 0
echo "frank-large: checkpoint absent at $LLM_LARGE_CHECKPOINT"
echo "frank-large: first start — the container is downloading ~127 GB from"
echo "frank-large: peonist-ai/halogen-qwen3.8-flash-next (resumes if interrupted)."
local i
for ((i = 1; i <= 1200; i++)); do
if [[ -f "$LLM_LARGE_CHECKPOINT" ]]; then
echo "frank-large: weights landed after approximately $((i * 2))s; gating on /health"
return 0
fi
sleep 2
done
echo "WARN: frank-large download still in progress after 40m — NOT a failure." >&2
echo "WARN: the unit keeps fetching; re-run this deploy (or re-check /health) later." >&2
return 0
}
# deploy_llm_small — frank-small is mainline llama.cpp on the RTX 3090, as a
# ROOT unit: the runtime is a native CUDA binary, so there is no container
# image to install and no generated unit to avoid. relink_unit points
# /etc/systemd/system/frank-small.service at the repo file and boot persistence
# is that unit's [Install] section.
#
# It is a SEPARATE endpoint on port 7771; frank-large keeps 7770. The 3090 is
# not shared: frank-large's engine runs on the gfx1151 iGPU.
LLM_SMALL_MODEL_DIR="${LLM_SMALL_MODEL_DIR:-/home/dank/models/MiMo-V2.6-Distill-Qwen-9B-INT8-W8A16-AutoRound}"
LLM_SMALL_RUNTIME="${LLM_SMALL_RUNTIME:-/home/dank/GitPackages/sglang-cu128/bin/sglang}"
# not shared: frank-large's halogen engine runs on the gfx1151 iGPU.
LLM_SMALL_MODEL="${LLM_SMALL_MODEL:-/home/dank/models/Gemma4-12B-QAT/Gemma4-12B-QAT-Uncensored-HauhauCS-Balanced-Q4_K_M.gguf}"
deploy_llm_small() {
# Refuse rather than restart-loop: a unit whose runtime or payload is
# missing never becomes healthy, and a green deploy would leave the
# endpoint dark with nothing in the log to say why.
if [[ ! -x "$LLM_SMALL_RUNTIME" ]]; then
echo "ERROR: SGLang runtime absent at $LLM_SMALL_RUNTIME" >&2
echo "frank-small: build the venv (python3.12 -m venv /home/dank/GitPackages/sglang-cu128" >&2
echo "frank-small: and pip install 'sglang[all]' into it) first." >&2
return 1
fi
if [[ ! -f "$LLM_SMALL_MODEL_DIR/model.safetensors.index.json" ]]; then
echo "ERROR: payload index absent at $LLM_SMALL_MODEL_DIR" >&2
echo "frank-small: run scripts/download-frank-small.sh first;" >&2
if [[ ! -f "$LLM_SMALL_MODEL" ]]; then
echo "ERROR: model absent at $LLM_SMALL_MODEL" >&2
echo "frank-small: fetch HauhauCS/Gemma4-12B-QAT-Uncensored-HauhauCS-Balanced (Q4_K_M, the mmproj and the MTP sidecar) first;" >&2
echo "frank-small: refusing to start a unit that cannot load." >&2
return 1
fi
local count
count="$(find "$LLM_SMALL_MODEL_DIR" -maxdepth 1 -name 'model-*-of-*.safetensors' | wc -l)"
if [[ "$count" -ne 8 ]]; then
echo "ERROR: payload shard count wrong: $LLM_SMALL_MODEL_DIR (found $count, want 8)" >&2
return 1
fi
relink_unit frank-small
run_privileged systemctl daemon-reload
run_privileged systemctl enable frank-small.service
@@ -1217,105 +1100,88 @@ deploy_llm_small() {
wait_for_health frank-small http://127.0.0.1:7771/health 900
}
# deploy_llm_primary — frank-primary is the tcclaviger RDNA4 vLLM fork
# (digest-pinned image, gfx1201) serving the orcarouter
# Qwen3.8-Flash-Next-Uncensored-FP8 payload (131 safetensors shards) with
# MTP4 spec decode and q8/q8 KV on the two R9700s as a ROOT unit whose serve
# script drives `podman run` directly (the image entrypoint execs
# `vllm serve`). 2026-09-29 cutover from the R9V radiance fork, whose
# launcher/shim/setup-state machinery is retired (state dirs stay on disk as
# rollback; pre-cutover tag frank-primary-r9v-q8-20260929). Same port and
# alias as the endpoints it replaced; the harness models.yml id is unchanged.
# deploy_llm_medium — frank-medium is mainline llama.cpp on the two R9700s as a
# ROOT unit since the 2026-09-16 cutover: the runtime is a native gfx1201 HIP
# binary, so there is no image to install and no generated unit to dodge. It
# replaces the vllm-radiance replica pair + routing proxy, which held this
# endpoint for a day and could never use both cards for one model (TP=2 needs
# an RCCL collective, and the second card cannot dispatch one).
#
# Gates: the pinned runtime image must be loadable from dank's rootless
# storage by its OCI manifest digest (the env file's FRANK_PRIMARY_IMAGE),
# the payload dir must carry config.json and the expected shard count
# (FRANK_PRIMARY_MODEL_SHARDS), and the unit env must be complete. Per-file
# sha256 verification was the fetch script's job at download time, never
# re-hashed here.
LLM_PRIMARY_CONTAINER=frank-primary-qwen
LLM_PRIMARY_LEGACY_CONTAINERS="frank-primary-r9v qwen-fp8 glm-probe"
# Layer split in ONE process is what the successor does instead: layers are
# distributed across the cards and only the activation at the boundary crosses
# PCIe, once per forward pass. That is the "pipeline parallelism" the endpoint
# wanted, with no collective anywhere in it.
LLM_MEDIUM_BIN="${LLM_MEDIUM_BIN:-/home/dank/GitPackages/llama.cpp-dflash2/build-rdna4-rocm714/bin/llama-server}"
LLM_MEDIUM_MODEL="${LLM_MEDIUM_MODEL:-/home/dank/models/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-UD-Q8_K_XL.gguf}"
LLM_MEDIUM_DRAFT="${LLM_MEDIUM_DRAFT:-/home/dank/models/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q8_0.gguf}"
LLM_MEDIUM_MMPROJ="${LLM_MEDIUM_MMPROJ:-/home/dank/models/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF/mmproj-BF16.gguf}"
deploy_llm_primary() {
# Refuse rather than restart-loop: a unit whose image or payload is
# missing never becomes healthy, and a green deploy would leave the
# endpoint dark with nothing in the log to say why.
local env_file="/home/dank/serverAppConfigs/scripts/frank-primary.env"
local image model_dir shards count
if [[ ! -f "$env_file" ]]; then
echo "ERROR: frank-primary env file absent: $env_file" >&2
return 1
fi
image="$(sed -n 's/^FRANK_PRIMARY_IMAGE=//p' "$env_file" | tail -1)"
model_dir="$(sed -n 's/^FRANK_PRIMARY_MODEL_DIR=//p' "$env_file" | tail -1)"
shards="$(sed -n 's/^FRANK_PRIMARY_MODEL_SHARDS=//p' "$env_file" | tail -1)"
if [[ -z "$image" || -z "$model_dir" || -z "$shards" ]]; then
echo "ERROR: frank-primary.env missing FRANK_PRIMARY_IMAGE/MODEL_DIR/MODEL_SHARDS" >&2
return 1
fi
# Capture podman's output BEFORE grepping: under set -o pipefail, the
# classic `podman images | grep -q` races (grep exits on match, podman
# dies SIGPIPE 141 writing the rest, pipefail calls it no-match — the
# 2026-09-30 deploy refusal).
local digests
digests="$(podman images --format '{{.RepoDigests}}')"
if ! grep -qF "${image#*@}" <<<"$digests"; then
echo "ERROR: pinned runtime image absent from rootless storage: $image" >&2
echo "frank-primary: fetch it (podman pull $image)" >&2
return 1
fi
if [[ ! -f "$model_dir/config.json" ]]; then
echo "ERROR: payload config missing: $model_dir/config.json" >&2
echo "frank-primary: re-run scripts/download-frank-primary.sh" >&2
return 1
fi
if [[ ! -f "$model_dir/model.safetensors.index.json" ]]; then
echo "ERROR: weight-map index missing: $model_dir/model.safetensors.index.json" >&2
echo "frank-primary: re-run scripts/download-frank-primary.sh" >&2
return 1
fi
count="$(find "$model_dir" -maxdepth 1 -name 'model-*-of-*.safetensors' 2>/dev/null | wc -l)"
if [[ "$count" != "$shards" ]]; then
echo "ERROR: payload shard count wrong: $model_dir (found $count, want $shards)" >&2
echo "frank-primary: re-run scripts/download-frank-primary.sh" >&2
return 1
fi
# A stale container with the same name makes `podman run --name` fail at
# start; remove any leftover from a failed previous attempt.
# A stopped unit can leave its container running: systemd's cgroup kill
# races conmon's --rm cleanup, and the orphan holds the port and GPUs.
# Stop the unit, then remove the containers BEFORE waiting on the port.
# The legacy identities (the retired R9V container, the ad hoc
# experiment container qwen-fp8, the retired glm-probe) hold the same
# GPUs and die here too.
run_privileged systemctl stop frank-primary.service 2>/dev/null || true
local ctr
for ctr in $LLM_PRIMARY_LEGACY_CONTAINERS "$LLM_PRIMARY_CONTAINER"; do
podman rm -f "$ctr" >/dev/null 2>&1 || true
# The three vLLM user units are retired HERE, not left to rot: the proxy
# published 7770 on the host network, so the llama-server below cannot bind
# until they are stopped and the port is released. Never `rm` — the unit files
# move into the home trash; the images stay in local storage.
retire_vllm_medium_quadlets() {
local trash="$HOME/.AGENTTRASH/20260916-vllm-radiance-retire" unit
systemctl --user stop frank-medium-a.service frank-medium-b.service \
frank-medium-proxy.service 2>/dev/null || true
for unit in frank-medium-a frank-medium-b frank-medium-proxy frank-medium; do
if [[ -e "$quadlet_unit_dir/$unit.container" ]]; then
mkdir -p "$trash"
mv -- "$quadlet_unit_dir/$unit.container" "$trash/$unit.container"
echo "retired quadlet $unit.container -> $trash"
fi
done
# Nothing starts until the port is released: the lucebox cutover
# (2026-09-26) and the frank-large cutover (2026-09-16) both failed
# exactly here when the next engine bound 7770 a second too early.
wait_port_free 7770 || return 1
relink_unit frank-primary
run_privileged systemctl daemon-reload
run_privileged systemctl enable frank-primary.service
run_privileged systemctl restart frank-primary.service
# Cold start: FP8 page-in plus torch compile/graph capture (~15 min cold,
# measured 2026-09-29). Parity with every predecessor on this port.
wait_for_health frank-primary http://127.0.0.1:7770/health 1800 || return 1
# False-green hardening (the 2026-09-26 lesson): a bare curl against the
# port cannot tell WHO answers — a stray probe holding :7770 satisfies
# it while the unit is stopped. The deploy is only green if the unit
# itself is active with a live MainPID behind the health answer.
local main_pid
main_pid="$(run_privileged systemctl show -p MainPID --value frank-primary.service)"
if [[ -z "$main_pid" || "$main_pid" == "0" ]]; then
echo "FAIL: :7770 answered but frank-primary.service has no MainPID — a stray process holds the port" >&2
return 1
systemctl --user daemon-reload
}
deploy_llm_medium() {
# Refuse rather than restart-loop. A unit whose runtime or payload is
# missing never becomes healthy, and a green deploy would leave the
# endpoint dark with nothing in the deploy log to say why.
local missing=0
if [[ ! -x "$LLM_MEDIUM_BIN" ]]; then
echo "ERROR: llama-server absent at $LLM_MEDIUM_BIN" >&2
echo "frank-medium: build it ON the host (gfx1201): bash scripts/build-llama-rdna4.sh" >&2
missing=1
fi
echo "frank-primary: health answered by unit MainPID $main_pid"
if [[ ! -f "$LLM_MEDIUM_MODEL" ]]; then
echo "ERROR: target model absent at $LLM_MEDIUM_MODEL" >&2
echo "frank-medium: fetch it: bash scripts/download-frank-medium-gguf.sh" >&2
missing=1
fi
if [[ ! -f "$LLM_MEDIUM_DRAFT" ]]; then
echo "ERROR: DFlash2 drafter absent at $LLM_MEDIUM_DRAFT" >&2
echo "frank-medium: fetch it: bash scripts/download-frank-medium-gguf.sh" >&2
missing=1
fi
if [[ ! -f "$LLM_MEDIUM_MMPROJ" ]]; then
echo "ERROR: vision projector absent at $LLM_MEDIUM_MMPROJ" >&2
echo "frank-medium: fetch it: bash scripts/download-frank-medium-gguf.sh" >&2
missing=1
fi
[[ "$missing" -eq 0 ]] || return 1
retire_vllm_medium_quadlets
# The thing holding 7770 is now THIS service: on a re-deploy the previous
# llama-server is still bound to it, so waiting for the port without
# stopping the unit deadlocks the deploy (measured 2026-09-16: "FAIL: port
# 7770 is still held after 90s", with the old server happily serving a
# scrape). Stop the unit first, then sweep a stray manual run of the same
# binary -- that is how a benchmark harness leaves the port busy. `pkill -x
# llama-server` matches the process name exactly and can never match the
# deploying shell; never `pkill -f` here.
run_privileged systemctl stop frank-medium.service 2>/dev/null || true
pkill -x llama-server 2>/dev/null || true
sleep 2
# pasta released the retired proxy's socket at container teardown, and a
# stopped engine lingers in TIME_WAIT just past that. Same gate as
# frank-large's cutover (2026-09-16 addressed-in-use failure).
wait_port_free 7770 || return 1
relink_unit frank-medium
run_privileged systemctl daemon-reload
run_privileged systemctl enable frank-medium.service
run_privileged systemctl restart frank-medium.service
wait_for_health frank-medium http://127.0.0.1:7770/health 900
}
wait_for_health() {
@@ -1504,16 +1370,15 @@ deploy_service() {
marreta) deploy_marreta ;;
frank-large) deploy_llm_large ;;
frank-small) deploy_llm_small ;;
frank-primary) deploy_llm_primary ;;
frank-medium) deploy_llm_medium ;;
hermes|hermes-dashboard|hermes-webui) deploy_hermes ;;
romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;;
jellyfin) deploy_jellyfin ;;
calibre-web) deploy_calibre_web ;;
calibre-server) deploy_calibre_server ;;
copyparty) deploy_copyparty ;;
plex) deploy_plex ;;
tautulli) deploy_tautulli ;;
prometheus|grafana|loki|alloy|node-exporter|alertmanager) deploy_observability "$service" ;;
prometheus|grafana|loki|alloy|node-exporter) deploy_observability "$service" ;;
comfyui) deploy_comfyui ;;
*) deploy_compose_service "$service" ;;
esac
+3 -4
View File
@@ -35,7 +35,7 @@ Options:
Examples:
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankclaw --service searxng
./scripts/deploy.sh dankserver --service frank-primary
./scripts/deploy.sh dankserver --service frank-medium
./scripts/deploy.sh dankclaw --dry-run
EOF
}
@@ -138,15 +138,14 @@ valid_service() {
dankclaw:gitea-server|dankclaw:homepage|\
dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\
dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\
dankclaw:alertmanager|\
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
dankclaw:frank-large|dankclaw:frank-small|\
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:plex|dankserver:tautulli|\
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|\
dankserver:frank-primary|\
dankserver:frank-medium|\
frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\
frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\
frankbook-pro:readarr)
-108
View File
@@ -1,108 +0,0 @@
#!/usr/bin/env bash
# Download and assemble jcbtc/Qwen3.8-Flash-CIRU-STRIX-Orca (the frank-large
# model) on Dankclaw.
#
# bash scripts/download-frank-large-orca.sh
# bash scripts/download-frank-large-orca.sh --verify-only
#
# The package is 136 GB of Hub-sized transport parts (HF caps new uploads at
# 50 GB, so the 79 GB main GGUF, the 52 GB PLE payload and the 4 GB MTP head
# ship as .partNofM fragments). assemble.py (from the repo) concatenates the
# parts, verifies every part and final SHA-256, and consumes the parts.
#
# Integrity: every part is checked against the per-part sha256 in parts.json
# (fetched from the repo, not trusted from disk), then the assembled files
# against SHA256SUMS. The repo is public (gated: false); an HF token at
# ~/.cache/huggingface/token is used when present to clear the anonymous
# rate limit on a 136 GB transfer.
#
# Layout after a successful run (MODEL_DIR):
# Qwen3.8-Flash-CIRU-STRIX-Orca.gguf 79,397,818,912 main model
# mtp/Qwen3.8-Flash-CIRU-STRIX-Orca-MTP-Q8_0.gguf 4,135,893,440 draft
# ple/ple.payload.bin 52,429,053,952 external PLE
# ple/ple.manifest.json, ple/ple.scale.bf16 PLE metadata
# vision/mmproj-Qwen3.8-Flash-Orca-F16.mmproj 904,004,320 projector
# assemble.py, parts.json, SHA256SUMS (kept for --verify-only)
set -euo pipefail
REPO="jcbtc/Qwen3.8-Flash-CIRU-STRIX-Orca"
MODEL_DIR="/home/dank/models/Qwen3.8-Flash-CIRU-STRIX-Orca"
BASE="https://huggingface.co/$REPO/resolve/main"
mode="download"
[[ "${1:-}" == "--verify-only" ]] && mode="verify"
token=""
auth=()
if [[ -r "$HOME/.cache/huggingface/token" ]]; then
token="$(cat "$HOME/.cache/huggingface/token")"
auth=(-H "Authorization: Bearer $token")
else
echo "no HF token at ~/.cache/huggingface/token; downloading anonymously"
fi
dl() { # dl <relpath>
local dest="$MODEL_DIR/$1"
mkdir -p "$(dirname "$dest")"
echo " fetching $1"
curl -fL -C - -o "$dest" --retry 10 --retry-delay 5 --retry-max-time 21600 \
"${auth[@]}" "$BASE/$1"
}
if [[ "$mode" == "download" ]]; then
# Manifest + small files first: the part hashes below come from the
# freshly fetched parts.json, so a stale/corrupt copy cannot pass.
dl "assemble.py"
dl "parts.json"
dl "SHA256SUMS"
dl "ple/ple.manifest.json"
dl "ple/ple.scale.bf16"
dl "vision/SHA256SUMS"
dl "vision/mmproj-Qwen3.8-Flash-Orca-F16.mmproj"
echo "fetching the five transport parts (136 GB)"
while IFS= read -r part; do
dl "$part"
done < <(python3 -c "
import json
for item in json.load(open('$MODEL_DIR/parts.json'))['files']:
for p in item['parts']:
print(p['path'])
")
fi
echo "verifying part sha256 (against the repo parts.json)"
python3 - "$MODEL_DIR" <<'PY'
import hashlib, json, sys
from pathlib import Path
root = Path(sys.argv[1])
manifest = json.loads((root / 'parts.json').read_text())
ok = True
def digest(p):
h = hashlib.sha256()
with p.open('rb') as f:
for data in iter(lambda: f.read(8 * 1024 * 1024), b''):
h.update(data)
return h.hexdigest()
for item in manifest['files']:
for p in item['parts']:
path = root / p['path']
if not path.exists():
print(f" MISSING {p['path']}"); ok = False; continue
if path.stat().st_size != p['bytes']:
print(f" BAD-SIZE {p['path']}"); ok = False; continue
if digest(path) != p['sha256']:
print(f" BAD-SHA {p['path']}"); ok = False; continue
print(f" OK {p['path']}")
sys.exit(0 if ok else 1)
PY
if [[ "$mode" == "download" ]]; then
echo "assembling (consumes verified parts)"
(cd "$MODEL_DIR" && python3 assemble.py)
fi
echo "verifying assembled files against SHA256SUMS"
(cd "$MODEL_DIR" && sha256sum -c SHA256SUMS)
(cd "$MODEL_DIR/vision" && sha256sum -c SHA256SUMS)
echo "frank-large payload complete: $MODEL_DIR"
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env bash
# Download the Q4_K_XL frank-large payload on Dankclaw:
# unsloth/Qwen3.8-27B-GGUF -> Qwen3.8-27B-UD-Q4_K_XL.gguf
#
# bash scripts/download-frank-large-q4.sh
# bash scripts/download-frank-large-q4.sh --verify-only
#
# Integrity: the file is checked against its declared byte count and sha256
# (the LFS oid from the Hub tree API, 2026-10-01). NO AUTH HEADER IS USED:
# the repo is public and ungated (gated: false), and a token on the curl
# command line is world-readable via /proc/<pid>/cmdline. If this ever needs
# a token, put it in a mode-600 file and pass it with `curl -K`.
#
# Layout after a successful run (MODEL_DIR):
# Qwen3.8-27B-UD-Q4_K_XL.gguf 17559178144 (16.35 GiB)
#
# WHY Q4 AND NOT THE Q8 SWIFT PAYLOAD: this is the vanilla (non-abliterated)
# Qwen3.8-27B at UD-Q4_K_XL, ~13 GiB smaller than the Swift UD-Q8_K_XL. Every
# GiB of weights saved is a GiB of f16 KV the endpoint can hold instead: the
# KV costs 64 KiB/token (16 full-attention layers x 4 KV heads x 256 head_dim
# x 2 (K,V) x 2 B), so 4 sessions x 163840 ctx = 640,000 tokens = 39.06 GiB.
#
# The speculative drafter is UNCHANGED and still fetched by its own script
# (scripts/download-frank-large.sh, z-lab's Hub repo). That name must not
# appear here at all — the frank-large seam greps THIS file for it to prove
# the drafter was not folded into the payload fetch.
#
# NOTE ON MTP: this payload has no baked-in MTP head (the Hub repo ships one
# separately as MTP/mtp-Qwen3.8-27B-Q4_0.gguf). frank-large does not need it —
# spec decode here is the block-diffusion drafter, not MTP.
set -euo pipefail
MODEL_DIR="/home/dank/models/Qwen3.8-27B-UD-Q4_K_XL"
REL="Qwen3.8-27B-UD-Q4_K_XL.gguf"
SIZE=17559178144
SHA=3f227079003add2511437e5b1e94812e363385225bf6a9b47b0054a72bc8b01e
URL="https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/${REL}"
mode="download"
[[ "${1:-}" == "--verify-only" ]] && mode="verify"
if [[ "$mode" == "download" ]]; then
mkdir -p "$MODEL_DIR"
if [[ -s "$MODEL_DIR/$REL" ]]; then
echo "already present: $MODEL_DIR/$REL"
else
echo "fetching $REL (17559178144 bytes) — this is ~16.4 GiB"
# -f so an HTTP error is a failure, not a 153-byte "artifact".
# -C - so an interrupted run resumes instead of restarting.
curl -fL -C - --retry 5 --retry-delay 3 -o "$MODEL_DIR/$REL" "$URL"
fi
fi
echo "verifying $MODEL_DIR against declared size + sha256"
python3 - "$MODEL_DIR" "$REL" "$SIZE" "$SHA" <<'PY'
import hashlib, os, sys
model_dir, rel, size, sha = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]
dest = os.path.join(model_dir, rel)
if not os.path.isfile(dest):
print(f" MISSING {rel}")
sys.exit(1)
actual = os.path.getsize(dest)
if actual != size:
print(f" SIZE MISMATCH {rel}: {actual} != {size}")
sys.exit(1)
h = hashlib.sha256()
with open(dest, "rb") as f:
for chunk in iter(lambda: f.read(1 << 24), b""):
h.update(chunk)
if h.hexdigest() != sha:
print(f" SHA256 MISMATCH {rel}: {h.hexdigest()}")
sys.exit(1)
print(f" OK {rel} ({actual} bytes, sha256 matches the Hub LFS oid)")
PY
echo "Q4_K_XL payload complete: $MODEL_DIR"
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
# Download the Swift-1.5 frank-large candidate on Dankclaw:
# ajgazin/Swift-1.5-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF
# Swift-1.5-Qwen3.8-27B-Uncensored-Dynamic-MTP-UD-Q8_K_XL.gguf
#
# bash scripts/download-frank-large-swift.sh
# bash scripts/download-frank-large-swift.sh --verify-only
#
# Integrity: the file is checked against its declared byte count and sha256
# (the LFS oid from the Hub, 2026-09-28). NO AUTH HEADER IS USED: the repo is
# public and ungated, and a token on the curl command line is world-readable
# via /proc/<pid>/cmdline. If this ever needs a token, put it in a mode-600
# file and pass it with `curl -K`.
#
# Layout after a successful run (MODEL_DIR):
# Swift-1.5-Qwen3.8-27B-Uncensored-Dynamic-MTP-UD-Q8_K_XL.gguf 31457990272
#
# This is the CANDIDATE payload, not the deployed one. frank-large still
# serves scripts/download-frank-large.sh's output; do not repoint
# frank-large.env at MODEL_DIR until the gufo load test passes.
set -euo pipefail
MODEL_DIR="/home/dank/models/Swift-1.5-Qwen3.8-27B-Uncensored-MTP-UD-Q8_K_XL"
REL="Swift-1.5-Qwen3.8-27B-Uncensored-Dynamic-MTP-UD-Q8_K_XL.gguf"
SIZE=31457990272
SHA=9076f99a2b1c0d232d5c169bb117e919ca21bafbe092be8fc8d9c4d645ed09c9
URL="https://huggingface.co/ajgazin/Swift-1.5-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF/resolve/main/${REL}"
mode="download"
[[ "${1:-}" == "--verify-only" ]] && mode="verify"
if [[ "$mode" == "download" ]]; then
mkdir -p "$MODEL_DIR"
if [[ -s "$MODEL_DIR/$REL" ]]; then
echo "already present: $MODEL_DIR/$REL"
else
echo "fetching $REL (31457990272 bytes) — this is ~29 GiB"
# -f so an HTTP error is a failure, not a 153-byte "artifact".
# -C - so an interrupted run resumes instead of restarting.
curl -fL -C - --retry 5 --retry-delay 3 -o "$MODEL_DIR/$REL" "$URL"
fi
fi
echo "verifying $MODEL_DIR against declared size + sha256"
python3 - "$MODEL_DIR" "$REL" "$SIZE" "$SHA" <<'PY'
import hashlib, os, sys
model_dir, rel, size, sha = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]
dest = os.path.join(model_dir, rel)
if not os.path.isfile(dest):
print(f" MISSING {rel}")
sys.exit(1)
actual = os.path.getsize(dest)
if actual != size:
print(f" SIZE MISMATCH {rel}: {actual} != {size}")
sys.exit(1)
h = hashlib.sha256()
with open(dest, "rb") as f:
for chunk in iter(lambda: f.read(1 << 24), b""):
h.update(chunk)
if h.hexdigest() != sha:
print(f" SHA256 MISMATCH {rel}: {h.hexdigest()}")
sys.exit(1)
print(f" OK {rel} ({actual} bytes, sha256 matches the Hub LFS oid)")
PY
echo "swift candidate payload complete: $MODEL_DIR"
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env bash
# Download unsloth/Qwen3.8-Flash-Next-GGUF:UD-IQ4_XS (the
# frank-large model) on Dankclaw.
#
# bash scripts/download-frank-large-unsloth.sh
# bash scripts/download-frank-large-unsloth.sh --verify-only
#
# The UD-IQ4_XS GGUF ships as three llama.cpp split parts (HF caps uploads at
# 50 GB). The parts ARE the canonical payload: the serve script points -m at
# part 1 and llama.cpp loads the 3-way split natively. Do NOT concatenate
# them into a single file to serve — part 1's header carries
# ggml.file_count=3, so a byte-correct concatenation still advertises itself
# as a split and the loader rejects the non-split filename.
# The MTP shared-Q8_0 head borrows the token embedding and the output
# projection from the main model at runtime (it is the 2.6 GB remainder of
# the 4B MTP layer). The F16 projector is fetched for a future vision flip;
# the serve script does not load it while ENABLE_VISION=0.
#
# Integrity: every part is checked against its LFS sha256 (fetched live from
# the Hub tree API, not trusted from disk). The repo is public (gated:
# false); an HF token at ~/.cache/huggingface/token is used when present to
# clear the anonymous rate limit on a 94 GB transfer.
#
# Layout after a successful run (MODEL_DIR):
# UD-IQ4_XS/Qwen3.8-Flash-Next-UD-IQ4_XS-0000{1,2,3}-of-00003.gguf the 3-way split
# mtp/mtp-Qwen3.8-Flash-Next-shared-Q8_0.gguf 2,786,568,256 draft head
# vision/mmproj-F16.gguf 904,004,000 projector
set -euo pipefail
REPO="unsloth/Qwen3.8-Flash-Next-GGUF"
MODEL_DIR="/home/dank/models/Qwen3.8-Flash-Next-UD-IQ4_XS"
BASE="https://huggingface.co/$REPO/resolve/main"
mode="download"
[[ "${1:-}" == "--verify-only" ]] && mode="verify"
token=""
auth=()
if [[ -r "$HOME/.cache/huggingface/token" ]]; then
token="$(tr -d '[:space:]' < "$HOME/.cache/huggingface/token")"
auth=(-H "Authorization: Bearer $token")
fi
dl() { # dl <relpath>
local rel="$1" dest="$MODEL_DIR/$rel"
[[ -s "$dest" ]] && return 0
mkdir -p "$(dirname "$dest")"
echo "fetching $rel"
curl -fL -C - "${auth[@]}" --retry 5 --retry-delay 3 -o "$dest" "$BASE/$rel"
}
# relpath size sha256 — the LFS oids from the Hub tree API (2026-09-21)
FILES=(
"UD-IQ4_XS/Qwen3.8-Flash-Next-UD-IQ4_XS-00001-of-00003.gguf 10946624 5ce89370720f8bf90890f439361282104c1aa1482d4013bb9a50923e758e71a4"
"UD-IQ4_XS/Qwen3.8-Flash-Next-UD-IQ4_XS-00002-of-00003.gguf 49835229856 577a38a2392b40ca2193cea502e1d92f60b8cd370675d308e0ec21885d9daaa7"
"UD-IQ4_XS/Qwen3.8-Flash-Next-UD-IQ4_XS-00003-of-00003.gguf 43836407744 d4634e6d84f0ebb0940be15c90d3790bf6464e3dea3a1cddc567dc0e83ad8833"
"MTP/mtp-Qwen3.8-Flash-Next-shared-Q8_0.gguf 2786568256 5ff54097406a905cf3a724c709124ceb0e3e10235ee862298969e91c96fa96e6"
"mmproj-F16.gguf 904004000 1f7b7f0b984cf065c604360c29c8098362ed61b290db0ff12c6f360bb1a8a980"
)
RELS=()
for line in "${FILES[@]}"; do
read -r rel size _ <<<"$line"
RELS+=("$rel")
if [[ "$mode" == "download" ]]; then
dl "$rel"
fi
done
echo "verifying parts against the live Hub tree API"
python3 - "$MODEL_DIR" "$REPO" "${RELS[@]}" <<'PY'
import hashlib, json, os, sys, urllib.request
model_dir, repo = sys.argv[1], sys.argv[2]
tree = json.load(urllib.request.urlopen(
f"https://huggingface.co/api/models/{repo}/tree/main?recursive=true"))
want = {}
for f in tree:
if f["type"] == "file" and f.get("lfs"):
want[f["path"]] = (f["lfs"]["size"], f["lfs"]["oid"])
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 24), b""):
h.update(chunk)
return h.hexdigest()
ok = True
expected = set(sys.argv[3:])
for rel in sorted(expected):
if rel not in want:
print(f"NOT IN HUB TREE {rel}"); ok = False; continue
size, oid = want[rel]
path = os.path.join(model_dir, rel)
if not os.path.isfile(path):
print(f"MISSING {rel}"); ok = False; continue
actual_size = os.path.getsize(path)
if actual_size != size:
print(f"SIZE MISMATCH {rel}: {actual_size} != {size}"); ok = False; continue
digest = sha256(path)
if digest != oid:
print(f"SHA256 MISMATCH {rel}"); ok = False; continue
print(f"ok {rel}")
sys.exit(0 if ok else 1)
PY
if [[ "$mode" == "download" ]]; then
mkdir -p "$MODEL_DIR/mtp" "$MODEL_DIR/vision"
[[ -e "$MODEL_DIR/mtp/mtp-Qwen3.8-Flash-Next-shared-Q8_0.gguf" ]] ||
mv "$MODEL_DIR/MTP/mtp-Qwen3.8-Flash-Next-shared-Q8_0.gguf" "$MODEL_DIR/mtp/"
[[ -e "$MODEL_DIR/vision/mmproj-F16.gguf" ]] || mv "$MODEL_DIR/mmproj-F16.gguf" "$MODEL_DIR/vision/"
fi
echo "frank-large payload complete: $MODEL_DIR"
-89
View File
@@ -1,89 +0,0 @@
#!/usr/bin/env bash
# Download the frank-large payload on Dankclaw:
# - unsloth/Qwen3.8-27B-GGUF:UD-Q8_K_XL (the 27B dense main GGUF)
# - z-lab/Qwen3.8-27B-DFlash2-GGUF:Q4_K_M (the DFlash2 drafter)
#
# bash scripts/download-frank-large.sh
# bash scripts/download-frank-large.sh --verify-only
#
# Integrity: every file is checked against its declared byte count and sha256
# (the values below are the LFS oids from the Hub, 2026-09-21). An HF token
# at ~/.cache/huggingface/token is used when present to clear the anonymous
# rate limit on a 31 GB transfer.
#
# Layout after a successful run (MODEL_DIR):
# Qwen3.8-27B-UD-Q8_K_XL.gguf 31457991680 main GGUF
# Qwen3.8-27B-DFlash2-Q4_K_M.gguf 1143006816 DFlash2 drafter
set -euo pipefail
MODEL_DIR="/home/dank/models/Qwen3.8-27B-UD-Q8_K_XL"
mode="download"
[[ "${1:-}" == "--verify-only" ]] && mode="verify"
token=""
auth=()
if [[ -r "$HOME/.cache/huggingface/token" ]]; then
token="$(tr -d '[:space:]' < "$HOME/.cache/huggingface/token")"
auth=(-H "Authorization: Bearer $token")
fi
dl() { # dl <url> <dest>
local url="$1" dest="$2"
[[ -s "$dest" ]] && return 0
mkdir -p "$(dirname "$dest")"
echo "fetching $dest"
curl -fL -C - "${auth[@]}" --retry 5 --retry-delay 3 -o "$dest" "$url"
}
# filename size sha256 url — the LFS oids from the Hub (2026-09-21)
FILES=(
"Qwen3.8-27B-UD-Q8_K_XL.gguf 31457991680 af36ecb6b5db1407953345b746c14ac93f0657dda413910b4348683a2d990377 https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-Q8_K_XL.gguf"
"Qwen3.8-27B-DFlash2-Q4_K_M.gguf 1143006816 1a25c56858e1ebe93f2718ac1d49d1151f9323325c1bbfd6209370f4db131ebd https://huggingface.co/z-lab/Qwen3.8-27B-DFlash2-GGUF/resolve/main/Qwen3.8-27B-DFlash2-Q4_K_M.gguf"
)
# Verify each file's byte count and sha256 (idempotent: a complete file is
# re-checked, not re-downloaded).
echo "verifying $MODEL_DIR against declared sizes + sha256"
python3 - "$MODEL_DIR" <<'PY'
import hashlib, os, sys
model_dir = sys.argv[1]
# (rel, size, sha256) triples — the LFS oids from the Hub (2026-09-21).
specs = {
"Qwen3.8-27B-UD-Q8_K_XL.gguf": (31457991680, "af36ecb6b5db1407953345b746c14ac93f0657dda413910b4348683a2d990377"),
"Qwen3.8-27B-DFlash2-Q4_K_M.gguf": (1143006816, "1a25c56858e1ebe93f2718ac1d49d1151f9323325c1bbfd6209370f4db131ebd"),
}
ok = True
for rel, (size, sha) in specs.items():
dest = os.path.join(model_dir, rel)
if not os.path.isfile(dest):
print(f" MISSING {rel}")
ok = False
continue
actual_size = os.path.getsize(dest)
if actual_size != size:
print(f" SIZE MISMATCH {rel}: {actual_size} != {size}")
ok = False
continue
h = hashlib.sha256()
with open(dest, "rb") as f:
for chunk in iter(lambda: f.read(1 << 24), b""):
h.update(chunk)
actual_sha = h.hexdigest()
if actual_sha != sha:
print(f" SHA256 MISMATCH {rel}: {actual_sha}")
ok = False
continue
print(f" OK {rel} ({actual_size} bytes)")
if not ok:
sys.exit(1)
PY
if [[ "$mode" == "download" ]]; then
for line in "${FILES[@]}"; do
read -r rel _ _ url <<<"$line"
dl "$url" "$MODEL_DIR/$rel"
done
fi
echo "frank-large payload complete: $MODEL_DIR"
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# the target GGUF, the BF16 vision projector and the DFlash2 draft model.
#
# bash scripts/download-frank-medium-gguf.sh
# bash scripts/download-frank-medium-gguf.sh --verify-only
#
# Both repositories are public (anonymous download; no token needed). If a
# token exists at ~/.cache/huggingface/token it is used, which keeps a 31 GB
# transfer clear of the anonymous rate limit. The script is resumable
# (curl -C -) and checks every artifact's exact byte count afterwards, so a
# truncated file fails here instead of load-crashing the server.
#
# WHY THESE TWO FILES:
# * Target: ajgazin/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF at
# UD-Q8_K_XL -- Unsloth Dynamic 3.0 quant (per-tensor types from Unsloth's
# own quant of the same size, on Unsloth's importance matrix). The file
# carries the model's MTP layer (`blk.64.nextn.*`), so llama.cpp can also
# draft from the model itself with no second copy in VRAM.
# * Draft: z-lab/Qwen3.8-27B-DFlash2-GGUF at Q8_0 -- the block-diffusion
# drafter (5 layers, hidden 5120, block_size 8, a 2048-token sliding
# window). Q8_0 is 1.92 GiB against BF16's 3.6 GiB and the drafter's job is
# throughput, not output quality (the target verifies every drafted token).
set -euo pipefail
MODEL_REPO="ajgazin/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF"
MODEL_DIR="/home/dank/models/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF"
MODEL_FILE="Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-UD-Q8_K_XL.gguf"
MODEL_SIZE=31457990112
# The BF16 vision projector from the same repo; llama-server autoloads any
# mmproj*.gguf beside the target, so it must land in MODEL_DIR.
MM_PROJ_FILE="mmproj-BF16.gguf"
MM_PROJ_SIZE=931145888
DRAFT_REPO="z-lab/Qwen3.8-27B-DFlash2-GGUF"
DRAFT_DIR="/home/dank/models/Qwen3.8-27B-DFlash2-GGUF"
DRAFT_FILE="Qwen3.8-27B-DFlash2-Q8_0.gguf"
DRAFT_SIZE=2056414816
mode="download"
[[ "${1:-}" == "--verify-only" ]] && mode="verify"
token=""
auth=()
if [[ -r "$HOME/.cache/huggingface/token" ]]; then
token="$(cat "$HOME/.cache/huggingface/token")"
auth=(-H "Authorization: Bearer $token")
else
echo "no HF token at ~/.cache/huggingface/token; downloading anonymously"
fi
dl() { # dl <repo> <dir> <file>
mkdir -p "$2"
echo " fetching $3"
curl -fL -C - -o "$2/$3" --retry 10 --retry-delay 5 --retry-max-time 21600 \
"${auth[@]}" "https://huggingface.co/$1/resolve/main/$3"
}
if [[ "$mode" == "download" ]]; then
echo "target: $MODEL_REPO"
dl "$MODEL_REPO" "$MODEL_DIR" "$MODEL_FILE"
echo "projector: $MODEL_REPO"
dl "$MODEL_REPO" "$MODEL_DIR" "$MM_PROJ_FILE"
echo "draft: $DRAFT_REPO"
dl "$DRAFT_REPO" "$DRAFT_DIR" "$DRAFT_FILE"
fi
echo "verifying sizes"
ok=1
check() { # check <path> <expected>
local act
act="$(stat -c%s "$1" 2>/dev/null || echo 0)"
if [[ "$act" -eq "$2" ]]; then
echo " OK $1 ($act bytes)"
else
echo " BAD $1 ($act bytes, want $2)" >&2
ok=0
fi
}
check "$MODEL_DIR/$MODEL_FILE" "$MODEL_SIZE"
check "$MODEL_DIR/$MM_PROJ_FILE" "$MM_PROJ_SIZE"
check "$DRAFT_DIR/$DRAFT_FILE" "$DRAFT_SIZE"
[[ -z "$token" ]] && echo "note: no token used; the byte counts above are the integrity check"
[[ "$ok" -eq 1 ]] || exit 1
echo "all artifacts present and sized correctly"
-88
View File
@@ -1,88 +0,0 @@
#!/usr/bin/env bash
# Payload inventory/verification for frank-primary:
# orcarouter/Qwen3.8-Flash-Next-Uncensored-FP8 (compressed-tensors FP8, 131
# shards + weight-map index, ~172.8 GiB) — the tcclaviger-stack payload that
# became frank-primary on 2026-09-29.
#
# bash scripts/download-frank-primary.sh --verify-only (the ONLY mode)
# bash scripts/download-frank-primary.sh --fetch
#
# THIS REPO IS GATED (401 anonymous — measured 2026-09-29): anonymous fetch
# is impossible and the tree API masks LFS hashes, so a byte-exact upstream
# manifest cannot be built without the authed endpoint. The payload on
# /home/dank/models was fetched with the operator's HF token (out of band:
# HF_TOKEN in the environment, never in argv) at bring-up time.
#
# Modes:
# --fetch resume any missing/truncated shard via `hf download` with
# HF_TOKEN from the environment (NEVER passed on the command
# line — a -H auth header is world-readable in
# /proc/<pid>/cmdline, the 2026-09-16 leak).
# --verify-only default: local integrity check — shard count vs the
# weight-map index, every file nonzero and its size equal to
# the index's declared total, plus sha256 of every shard
# (slow, ~10 min for 173 GiB).
set -euo pipefail
REPO="orcarouter/Qwen3.8-Flash-Next-Uncensored-FP8"
DEST="/home/dank/models/Qwen3.8-Flash-Next-Uncensored-FP8"
EXPECTED_SHARDS=131
mode="verify"
[[ "${1:-}" == "--fetch" ]] && mode="fetch"
if [[ "$mode" == "fetch" ]]; then
: "${HF_TOKEN:?--fetch needs HF_TOKEN in the environment (never argv)}"
export HF_TOKEN
mkdir -p "$DEST"
hf download "$REPO" --local-dir "$DEST"
fi
echo "verifying local payload: $DEST"
fail=0
# The weight-map index is the source of truth for the shard set.
INDEX="$DEST/model.safetensors.index.json"
if [[ ! -f "$INDEX" ]]; then
echo " BAD weight-map index missing: $INDEX" >&2
exit 1
fi
# Every index entry must exist, nonzero, and sized equal to the map's total.
python3 - "$INDEX" "$DEST" <<'PY' || fail=1
import json, os, sys
index, dest = sys.argv[1], sys.argv[2]
wm = json.load(open(index))["weight_map"]
files = sorted(set(wm.values()))
print(f" index maps {len(wm)} tensors across {len(files)} files")
bad = 0
total = 0
for f in files:
p = os.path.join(dest, f)
if not os.path.isfile(p) or os.path.getsize(p) == 0:
print(f" BAD {f}: missing or zero bytes" )
bad += 1
else:
total += os.path.getsize(p)
print(f" {'OK' if bad == 0 else 'FAIL'}: {len(files) - bad}/{len(files)} files present, {total/2**30:.1f} GiB total")
raise SystemExit(1 if bad else 0)
PY
# Shard count gate (what the serve script and deployer check).
n="$(find "$DEST" -maxdepth 1 -name 'model-*-of-*.safetensors' | wc -l)"
if [[ "$n" != "$EXPECTED_SHARDS" ]]; then
echo " BAD shard count: found $n, want $EXPECTED_SHARDS" >&2
fail=1
else
echo " OK shard count $n"
fi
if [[ "${2:-}" == "--hash" || "${VERIFY_HASH:-0}" == "1" ]]; then
echo "hashing every shard (slow)..."
cd "$DEST"
sha256sum -c --quiet model.safetensors.index.json 2>/dev/null || \
sha256sum model-*-of-00131.safetensors > /dev/null # presence check; content hash list printed on demand
fi
[[ "$fail" -eq 0 ]] || exit 1
echo "payload inventory OK: $EXPECTED_SHARDS shards + index present"
-93
View File
@@ -1,93 +0,0 @@
#!/usr/bin/env bash
# Download MiMo-V2.6-Distill-Qwen-9B-INT8-W8A16-AutoRound for frank-small
# (SGLang, RTX 3090). Public repo — no auth header on the command line.
#
# Per-file FRESH downloads, no resume: `curl -C -` once produced a 213 MB
# oversized file on a 31 GiB payload; a fresh -f fetch plus size+sha256 is
# the house rule.
#
# Usage:
# scripts/download-frank-small.sh [DEST_DIR] [--verify-only]
#
# LFS shards are verified against the Hub LFS sha256; non-LFS text files
# against their declared byte size. Idempotent: complete files are skipped.
set -euo pipefail
REPO="letechlead/MiMo-V2.6-Distill-Qwen-9B-INT8-W8A16-AutoRound"
BASE="https://huggingface.co/${REPO}/resolve/main"
DEST=""
VERIFY_ONLY=0
for arg in "$@"; do
case "$arg" in
--verify-only) VERIFY_ONLY=1 ;;
*) DEST="$arg" ;;
esac
done
DEST="${DEST:-/home/dank/models/MiMo-V2.6-Distill-Qwen-9B-INT8-W8A16-AutoRound}"
# name bytes sha256(- = non-LFS, size-only check)
FILES=(
"model-00001-of-00008.safetensors 2946296312 ddb6117c04cb6feaf7c265e5d75efadf21949037d73253a8e4521d40e164366d"
"model-00002-of-00008.safetensors 1402134792 2dc78d55d1d33d7b3b21dd6f8b4b817b62c2fb1376e6448b322428798c10e257"
"model-00003-of-00008.safetensors 1427365904 5c797d14e345309d67c78306b9de4aa01a21dbd92dd98e40b0ad69b2900e9203"
"model-00004-of-00008.safetensors 1420041856 7402f852e2c7a24ebf4694b98a220e71dfb2c93ba3776272cf2acfc65acd8126"
"model-00005-of-00008.safetensors 1410655648 991cbf2eda2ff91fc3ffbb15591d7fcac340fb10d9c33bd3da20010e2f3f4090"
"model-00006-of-00008.safetensors 1427627856 efc42448054cfb5e3061e154ba4539204088ce95176d689a95b58de885ef7eeb"
"model-00007-of-00008.safetensors 2035442408 75a101fc71ae6f2e1f5c4816f711d62cf1d7a0e02ef6e5182361b8c11e72b626"
"model-00008-of-00008.safetensors 822153664 0fb90cb50e2d336fbad58bd4c01880eeddbf537c1dca1cf0ae291195d5efbb4b"
"tokenizer.json 19989325 06b9509352d2af50381ab2247e083b80d32d5c0aba91c272ca9ff729b6a0e523"
"model.safetensors.index.json 106278 -"
"config.json 4608 -"
"generation_config.json 161 -"
"quantization_config.json 1654 -"
"chat_template.jinja 3916 -"
"tokenizer_config.json 1168 -"
"preprocessor_config.json 443 -"
"processor_config.json 1191 -"
"README.md 8448 -"
)
mkdir -p "$DEST"
status=0
for entry in "${FILES[@]}"; do
read -r name size sha <<<"$entry"
f="$DEST/$name"
have=0
if [[ -f "$f" ]] && [[ "$(stat -c%s "$f")" == "$size" ]]; then
if [[ "$sha" == "-" ]] || sha256sum -c --quiet <(printf '%s %s\n' "$sha" "$f"); then
have=1
fi
fi
if (( have )); then
echo "OK $name"
continue
fi
if (( VERIFY_ONLY )); then
echo "MISSING $name"
status=1
continue
fi
echo "FETCHING $name"
tmp="$f.part"
# no -C resume; -f + --fail-with-body so an HTTP error is not a success
curl -fsSL --fail-with-body -o "$tmp" "$BASE/$name"
mv "$tmp" "$f"
if [[ "$(stat -c%s "$f")" != "$size" ]]; then
echo "SIZE FAIL $name ($(stat -c%s "$f") != $size)"
status=1
continue
fi
if [[ "$sha" != "-" ]] && ! sha256sum -c --quiet <(printf '%s %s\n' "$sha" "$f"); then
echo "SHA FAIL $name"
status=1
continue
fi
echo "VERIFIED $name"
done
if (( status == 0 )); then
echo "DOWNLOAD_DONE $DEST"
else
echo "DOWNLOAD_FAILED $DEST" >&2
fi
exit "$status"
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env python3
"""Dump GGUF part headers: split metadata, tensor names/dims/types/bytes.
Reads ONLY the header region (metadata + tensor-info table); tensor data is
never touched, so a 50 GiB part scans in milliseconds. Split GGUFs are valid
GGUFs per part; part 1 of this payload is metadata-only (ntensors=0) and
parts 2-6 carry the tensors. Confirmed layout for this payload: GGUF v3 —
magic, u32 version, u64 ntensors, u64 nkv, then kvs (v3: u64-length strings),
then tensor infos (v3: u64-length names).
"""
import struct, sys, traceback
TYPE_SIZE = {0:4,1:2,2:1,3:1/2,4:4,5:8,6:2,7:1,8:0.4375,9:1.4375,10:1,11:0.5,
12:2,13:4/3,14:2,15:2,16:4,17:2,18:2,19:1,20:2,21:1,22:2,23:2,
24:2,25:1.0625,26:1,27:1.375,28:2,29:2,30:2,31:4,32:8,33:2.125,
34:2.5,35:2,36:2.375,37:1.5625,38:2.3125,39:2.1875,40:0,41:1.75,
42:1.75,43:1.8125,44:2,45:2.25,46:1.5,47:2.0625,48:2.625,49:1.0625,
50:2.3125,51:2.875,52:2,53:2.3125,54:2.75,55:2.5,56:2.875,57:2.25,
58:2,59:3.8125,60:2.8125,61:1.5625,62:1.5,63:0.5}
class P:
def __init__(self, f):
self.f = f
self.ver = 2
def rd(self, n):
b = self.f.read(n)
if len(b) != n:
raise EOFError(f"short read {len(b)}/{n}")
return b
def u8(self): return self.rd(1)[0]
def u32(self): return struct.unpack("<I", self.rd(4))[0]
def u64(self): return struct.unpack("<Q", self.rd(8))[0]
def f32(self): return struct.unpack("<f", self.rd(4))[0]
def f64(self): return struct.unpack("<d", self.rd(8))[0]
def s(self):
# GGUF v3 strings carry a u64 length; v2 used u32.
n = self.u64() if self.ver >= 3 else self.u32()
return self.rd(n).decode("utf-8", "replace")
def val(self, t):
# GGUF kv value types: 0 u8, 1 i8, 2 u16, 3 i16, 4 u32, 5 i32,
# 6 f32, 7 bool, 8 string, 9 array, 10 u64, 11 i64, 12 f64.
if t == 8: return self.s()
if t == 9:
et = self.u32(); n = self.u64()
return [self.val(et) for _ in range(n)]
if t == 0: return self.u8()
if t == 1: return struct.unpack("<b", self.rd(1))[0]
if t == 2: return struct.unpack("<H", self.rd(2))[0]
if t == 3: return struct.unpack("<h", self.rd(2))[0]
if t == 4: return self.u32()
if t == 5: return struct.unpack("<i", self.rd(4))[0]
if t == 6: return self.f32()
if t == 7: return self.u8()
if t == 10: return self.u64()
if t == 11: return struct.unpack("<q", self.rd(8))[0]
if t == 12: return self.f64()
raise ValueError(f"unknown kv type {t}")
def main(path):
tensors = []
kvs = {}
with open(path, "rb") as f:
p = P(f)
magic = p.rd(4)
assert magic == b"GGUF", magic
p.ver = p.u32()
ntens = p.u64()
nkv = p.u64()
for _ in range(nkv):
k = p.s()
kvs[k] = p.val(p.u32())
for _ in range(ntens):
name = p.s()
nd = p.u32()
dims = [p.u64() for _ in range(nd)]
t = p.u32()
off = p.u64()
numel = 1
for d in dims: numel *= d
tensors.append((name, dims, t, numel))
print(f"== {path} (gguf v{p.ver}, {ntens} tensors, {nkv} kv)")
for k in ("general.architecture", "general.name", "split.no", "split.count",
"split.tensors", "qwen4exp.expert_count", "qwen4exp.expert_used_count",
"qwen4exp.ngram.tensors"):
if k in kvs: print(f" kv {k} = {kvs[k]}")
ngram_kvs = {k: v for k, v in kvs.items() if "ngram" in k.lower()}
if ngram_kvs: print(" ngram kvs:", ngram_kvs)
big = []
for name, dims, t, numel in tensors:
big.append((numel * TYPE_SIZE.get(t, 0), name, dims, t, numel))
big.sort(reverse=True)
print(f" -- {min(12, len(big))} largest of {len(big)}:")
for bytes_, name, dims, t, numel in big[:12]:
print(f" {bytes_/2**30:9.3f} GiB {name} dims={dims} type={t} ({numel:,} elems)")
named = [(b, n, d, t) for b, n, d, t, e in big
if any(s in n.lower() for s in ("ngram", "ple", "token_embd", "exps"))]
if named:
print(" -- ngram/ple/embed/experts-named:")
for bytes_, name, dims, t in named[:20]:
print(f" {bytes_/2**30:9.3f} GiB {name} dims={dims} type={t}")
for pth in sys.argv[1:]:
try:
main(pth)
except Exception:
print(f"== {pth}: FAILED")
traceback.print_exc()
-63
View File
@@ -1,63 +0,0 @@
# frank-large — gufo (gufo-org/gufo) serving
# Swift-1.5-Qwen3.8-27B-Uncensored (abliterated) on the Strix Halo iGPU.
#
# These are the tunables read by scripts/serve-frank-large.sh. The flag
# list is owned by the serve script; this file only sets fleet values.
#
# DO NOT name a new tunable TEMP, HOST or GPU_MAX_HEAP_SIZE: the HIP
# runtime reads those bare (the 2026-08-13 TEMP incident). HOST is the one
# exception because the serve script itself consumes it for --host.
# The target payload (scripts/download-frank-large-q4.sh): the serve script
# refuses to start without the main GGUF and the DFlash2 drafter.
#
# 2026-10-01 swap (Frank): unsloth/Qwen3.8-27B-GGUF:UD-Q4_K_XL, 16.35 GiB,
# replacing ajgazin's abliterated Swift-1.5 UD-Q8_K_XL (29.3 GiB). Two
# consequences worth knowing at a glance:
# * this is the VANILLA (non-abliterated) Qwen3.8-27B — the endpoint is no
# longer uncensored, which was one of the reasons the Swift payload was
# chosen on 2026-09-28;
# * ~13 GiB of weights became available for KV, which is what makes four
# full-sized f16 sessions fit.
# gufo needs no template for either: its Qwen renderer is compiled in.
MODEL_DIR=/home/dank/models/Qwen3.8-27B-UD-Q4_K_XL
MODEL_FILE=/home/dank/models/Qwen3.8-27B-UD-Q4_K_XL/Qwen3.8-27B-UD-Q4_K_XL.gguf
# The DFlash2 drafter is UNCHANGED by the model swap and still lives beside
# the previous target, so it carries its own directory. It has a sliding
# window of 2048, which is what keeps its KV small (~40 MiB).
DRAFTER_DIR=/home/dank/models/Qwen3.8-27B-UD-Q8_K_XL
DRAFTER_FILE=/home/dank/models/Qwen3.8-27B-UD-Q8_K_XL/Qwen3.8-27B-DFlash2-Q4_K_M.gguf
# The gufo runtime, built from source by scripts/build-gufo.sh.
RUNTIME_DIR=/home/dank/GitPackages/gufo-v0.1.1
# Fleet endpoint: the same port and alias every frank-large has served, so
# no client, probe or models.yml entry changes address.
ALIAS=frank-large
HOST=0.0.0.0
PORT=7770
# Four sessions, 163840 context each (Frank, 2026-10-01: "4x 160k"). The
# context is per session and the KV is UNQUANTIZED (f16) — gufo has no KV
# quantization lever at all (execution_policy.hpp pins kv_cache_storage to
# kFp16), so "unquantized" is inherent rather than configured.
#
# The arithmetic that makes this fit: f16 KV costs 64 KiB/token on this model
# (16 full-attention layers x 4 KV heads x 256 head_dim x 2 (K,V) x 2 bytes;
# the other 48 layers are linear-attention with a per-SEQUENCE fp32 state of
# ~144 MiB, not a per-token cache). 4 x 163840 = 655,360 tokens = 40.0 GiB of
# KV, and each session's 163,840 tokens is exactly 10 GiB. Budget: weights
# 16.35 + KV 40.0 + drafter 1.07 + linear state 0.56 + runtime ~4 = ~62 GiB
# against the 114 GiB GTT — roughly 50 GiB of headroom, so the session count
# or the per-session context can both grow from here.
CONTEXT_SIZE=163840
SESSIONS=4
# DFlash2 spec decode (a block-diffusion drafter). gufo sizes the draft
# block itself; DRAFT_TOKENS caps the proposals per step (gufo's default is
# 7, chosen over llama.cpp's fixed depth 4 because gufo's adaptive policy
# shortens the block when acceptance drops) and DRAFT_POLICY is the block
# length controller.
DRAFT_TOKENS=7
DRAFT_POLICY=adaptive
-20
View File
@@ -1,20 +0,0 @@
[Unit]
Description=gufo (gufo-org/gufo, ROCm gfx1151) Qwen3.8-27B UD-Q8_K_XL, DFlash2 spec decode, 262144 ctx / 1 session, root systemd unit (dankclaw, frank-large, Strix Halo iGPU)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dank
Group=dank
# The engine pins staging buffers; keep the memlock budget unbounded so an
# RLIMIT smaller than the pinned set cannot fail the load. Parity with the
# other LLM units on this fleet.
LimitMEMLOCK=infinity
Environment="FRANK_LARGE_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-large.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-large.sh
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -1,7 +1,7 @@
# Tunables for frank-medium (orcarouter/Qwen3.8-27B-Uncensored-GGUF Q8_0 +
# z-lab/Qwen3.8-27B-DFlash2-GGUF Q4_K_M drafter), mainline llama.cpp on
# Dankserver, 2x AMD Radeon AI PRO R9700 (gfx1201). Since 2026-09-16 this is
# the SOLE LLM runtime on the box: the vllm-radiance replica pair and its
# Tunables for frank-medium (ajgazin/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP
# UD-Q8_K_XL + z-lab/Qwen3.8-27B-DFlash2-GGUF Q8_0 drafter), mainline llama.cpp
# on Dankserver, 2x AMD Radeon AI PRO R9700 (gfx1201). Since 2026-09-16 this
# is the SOLE LLM runtime on the box: the vllm-radiance replica pair and its
# routing proxy are retired.
#
# Hardware: two R9700 (Navi 48, RDNA4, 32,624 MiB each), no XGMI, different
@@ -31,17 +31,17 @@
# The ref is pinned by TWO features: DFlash2 (ggml-org/llama.cpp#27342 -- the
# drafter is detected from its GGUF) and qwen35 MTP. Do not float it.
#
# Model: orcarouter/Qwen3.8-27B-Uncensored-GGUF @ Q8_0, 27.0 GiB, arch qwen35
# (verified from the GGUF header 2026-09-19: 262144 trained window, 24 heads /
# 4 KV heads, nextn_predict_layers=1). The file ALSO carries the model's MTP
# head (blk.64.nextn.*), so --spec-type draft-mtp is available without a second
# copy in VRAM -- one SPEC_TYPE change, the drafter below stays on disk.
# Model: ajgazin/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF @ UD-Q8_K_XL,
# 29.29 GiB, arch qwen35, Unsloth Dynamic 3.0 (per-tensor types from Unsloth's
# own quant of the same size, on Unsloth's importance matrix). Abliteration is
# orcarouter's single refusal direction projected out of 131 tensors; the vision
# tower loads from mmproj-BF16.gguf (see NO_MMPROJ). The file also
# carries the model's MTP head (blk.64.nextn.*), so a file-level switch to
# --spec-type draft-mtp is available without a second copy in VRAM.
#
# Drafter: z-lab/Qwen3.8-27B-DFlash2-GGUF @ Q4_K_M, 1.06 GiB (operator call
# 2026-09-19: the Q4 rung -- the drafter's job is throughput, not output
# quality, and the target verifies every drafted token). 5 layers, hidden
# Drafter: z-lab/Qwen3.8-27B-DFlash2-GGUF @ Q8_0, 1.92 GiB: 5 layers, hidden
# 5120, a block-diffusion proposal (block_size 8) under a 2048-token sliding
# window (dflash.attention.sliding_window=2048, verified from the header).
# window. Decoding stays lossless -- the target verifies every drafted token.
# The window is what makes it cheap HERE: its KV is bounded at 2048 positions
# (~40 MiB) instead of spanning the context (see SIZING).
#
@@ -52,14 +52,10 @@
# in the attention layers only, plus ~0.16 GiB per sequence of f32 DeltaNet
# state at any depth.
#
# SIZING, the deployed shape (32:33 / ctx 327680 / 6144x512 / two slots /
# DFlash2 Q4_K_M), measured 2026-09-19 after the model swap (rocm-smi):
#
# card 0 (x16) 26.9 GiB used, 5.7 GiB free
# card 1 (x1) 27.8 GiB used, 4.8 GiB free
#
# The predecessor's measured table (rocm-smi, 2026-09-16, UD-Q8_K_XL target)
# stays as the calibration history:
# SIZING, measured (rocm-smi, 2026-09-16). Weights split across the two cards;
# the drafter AND its KV ride the second. The loader's compute buffers are part
# of what is seen below -- they are allocated at load, not per request, and they
# scale with --ubatch-size:
#
# CTX split batch slots drafter GPU 0 free GPU 1 free note (ubatch 1536)
# 262144 1,1 2048 1 DFlash2 8.64 GiB 4.43 GiB day's start
@@ -75,7 +71,7 @@
# batch block below):
#
# 262144 36,29 6144 512 2 DFlash2 6.01 GiB 7.32 GiB ubatch only
# 262144 35,30 6144 512 2 DFlash2 6.44 GiB 6.90 GiB
# 262144 35,30 6144 512 2 DFlash2 6.44 GiB 6.90 GiB <- deployed
#
# ONE -ts STEP IS ONE LAYER, AND LAYERS ARE NOT THE SAME SIZE. This is a hybrid
# stack -- 16 x (3 Gated DeltaNet + 1 Gated Attention) blocks -- so moving a
@@ -84,27 +80,36 @@
# were measured, which is why the balance is found by measurement rather than by
# interpolating between splits.
#
# WHY THE SPLIT IS 32:33 (operator call, 2026-09-19). The operator asked for
# the tensors BALANCED across the two cards, which for 65 layers is 32:33 --
# the most even split that exists. The predecessor's 35:30 skew existed to lean
# work onto the x16 card (ROCm0), because ROCm1's Gen4 x1 chipset path made it
# the 98 % utilization bottleneck; the measured trade of the balanced point is
# that the x1 card runs hotter while the x16 card idles a little more, and the
# operator accepted that for symmetry. Do not re-skew without asking.
# 262144 is the window the model ships (n_ctx_train) and it fits with room, so
# the fleet gets the whole thing; the arithmetic that used to cap this endpoint
# at 114688 was the vLLM predecessor's, not this runtime's. If a future change
# adds VRAM (a longer drafter, quantized KV, more slots) re-read the numbers
# rather than assuming, and prefer CTX_SIZE over --gpu-layers.
#
# THE TWO CARDS' PATHS ARE NOT SYMMETRIC (measured, 2026-09-17): ROCm0
# (03:00.0) hangs off CPU root port 00:01.1 at Gen4 x16 end to end (~31.5 GB/s);
# ROCm1 (0b:00.0) rides the B650 chipset through bridges that each negotiate
# Gen4 x1 (~1.97 GB/s). The layer-split boundary itself is not the cost
# (~5.2 MB of activation per 512-token ubatch rides the wire); the x1 card is
# COMPUTE-bound, not PCIe-bound.
# WHY THE SPLIT IS 35:30. Three effects decide it, and all three are measured:
# * THE DRAFTER AND ITS OUTPUT LAYERS RIDE ROCm1. It carries the drafter's
# weights, the draft graph and the last target layers, so it takes fewer
# than half the layer count.
# * THE UBATCH BUFFERS COST ROCm1 MORE THAN ROCm0, and shrinking them pays
# ROCm1 back more: 1536 -> 512 freed 1.30 GiB on ROCm0 but 2.41 GiB on
# ROCm1 at this shape. Read the other way, that is exactly why the smaller
# batch moved the balance toward ROCm1 -- and cost ROCm0 one layer.
# * THE LAYER GRANULARITY IS THE FLOOR ON HOW WELL ANY SPLIT CAN BALANCE. One
# -ts step is one layer -- ~0.43 GiB DeltaNet, ~1.43 GiB attention -- so the
# reachable minimum gap is comparable to one step rather than to zero. 35:30
# sits at 0.46 GiB, i.e. one DeltaNet layer's worth, against 1.31 GiB at
# 36:29.
# It also leaves the most room: 6.44 / 6.90 GiB free clears the operator's 1.5
# GiB-per-card floor by ~5 GiB, so no headroom has to be bought out of CTX_SIZE
# and the full window stays.
#
# A FAULT TO WATCH. A 39:26 split, at 1x batch / 1 slot, died on its eighth
# request: the amdgpu driver logged SDMA0 UTCL2 page faults and the engine
# surfaced `ROCm error: an illegal memory access was encountered` -> ggml_abort,
# i.e. a fault in the cross-card boundary copy, this layer split's hot path. It
# has not reproduced since, but the signature is worth recognising -- it is NOT
# an OOM, and no amount of headroom prevents it.
# has not reproduced (36:29 served this file's verification battery and the e2e
# gate, and 35:30 is the deployed shape), but the signature is worth recognising
# -- it is NOT an OOM, and no amount of headroom prevents it.
#
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE (HIP reads
# those bare vars; 2026-08-13 incident). llama.cpp takes --host/--port/--temp
@@ -114,15 +119,15 @@
LLAMA_BIN=/home/dank/GitPackages/llama.cpp-dflash2/build-rdna4-rocm714/bin/llama-server
# Payload, fetched with scripts/download-frank-medium-gguf.sh.
MODEL_DIR=/home/dank/models/Qwen3.8-27B-Uncensored-GGUF
MODEL_FILE=Qwen3.8-27B-Uncensored-Q8_0.gguf
# The f16 vision projector, same repo as the target. llama-server loads it via
# the explicit --mmproj flag (no autoload in this build); the serve script
# refuses to start without it when vision is enabled, so a missing file is a
# deploy error, not a text-only server that pretends to be multimodal.
MM_PROJ_FILE=mmproj-Qwen3.8-27B-Uncensored-f16.gguf
MODEL_DIR=/home/dank/models/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF
MODEL_FILE=Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-UD-Q8_K_XL.gguf
# The BF16 vision projector, same repo as the target. llama-server autoloads
# any mmproj*.gguf beside the model; the serve script refuses to start without
# it when vision is enabled, so a missing file is a deploy error, not a
# text-only server that pretends to be multimodal.
MM_PROJ_FILE=mmproj-BF16.gguf
DRAFT_MODEL_DIR=/home/dank/models/Qwen3.8-27B-DFlash2-GGUF
DRAFT_MODEL_FILE=Qwen3.8-27B-DFlash2-Q4_K_M.gguf
DRAFT_MODEL_FILE=Qwen3.8-27B-DFlash2-Q8_0.gguf
# Fleet identity. 7770 is the fleet's dankserver endpoint; the alias is the id
# at /v1/models and what every client asks for.
@@ -136,11 +141,17 @@ HIP_VISIBLE_DEVICES=0,1
DEVICES=ROCm0,ROCm1
SPLIT_MODE=layer
# 32:33 -- balanced (operator call 2026-09-19): 65 layers split as evenly as
# they can be, ROCm1 (the last-layers card, which also carries the drafter and
# the output layers) holding the extra one. See the WHY THE SPLIT IS 32:33
# block above for the trade against the predecessor's 35:30 x16-lean.
TENSOR_SPLIT=32,33
# 36:29 -- one layer over the 35:30 balance, moved to buy headroom on ROCm1,
# the card that carries the drafter, its KV, the output layers and the heavier
# compute buffers, now that the context grew to 327680 (see below). Fit at
# the deployed point (36:29, ctx 327680, batch 6144 x ubatch 512, two slots,
# DFlash2 + ngram-mod, mmproj on ROCm0): ROCm0 29.19 / ROCm1 26.04 GiB of
# 31.86, i.e. 2.67 / 5.82 GiB free and a 3.15 GiB gap BY CONSTRUCTION -- the
# balance point at this context is 34:31 (0.11 GiB gap) and the operator chose
# headroom on the drafter card over balance. See SIZING above for the
# measurement table; one -ts step is one LAYER, worth ~0.43 GiB (DeltaNet) or
# ~1.43 GiB (attention), so do not move it without re-measuring.
TENSOR_SPLIT=36,29
NGL=999
# Auto-fit has no business resizing a context we set explicitly.
@@ -149,9 +160,10 @@ FLASH_ATTN=on
# 327680 positions over TWO slots, so each conversation gets 163840 --
# --parallel DIVIDES --ctx-size, so more slots would shrink every
# conversation rather than grow the cache. 163840 is inside the checkpoint's
# trained 262144 window, so the whole pool is trained territory (no rope
# extrapolation, unlike the predecessor's 393216).
# conversation rather than grow the cache. The checkpoint's trained window is
# 262144 and the GGUF carries NO rope scaling (freq_base 1e7, no YaRN keys),
# so positions beyond 262144 extrapolate on the base rope: it runs, and 1.25x
# is a modest ask of it, but the extra 65k is untrained territory.
CTX_SIZE=327680
PARALLEL=2
@@ -199,32 +211,11 @@ THREAD_BATCH=8
# f16 K/V: an agentic checkpoint is the wrong place to spend KV precision.
CACHE_TYPE_K=f16
CACHE_TYPE_V=f16
# Context checkpoints: 0 -- no per-slot KV snapshots at all. The build's
# default is 32 (this binary's --help), so the 0 below is a deliberate
# deviation, not an inherited default: a checkpoint is a VRAM->host-RAM copy
# of part of a slot's KV, taken so a partially-matching prompt can be restored
# instead of re-prefilled. This endpoint trades that away -- its reuse comes
# from the slot prompt and from the RAM-side prompt cache below. Stated
# Context checkpoints: 0 is the build's default -- no per-slot KV snapshots, so
# a re-sent or rewound conversation re-prefills instead of rewinding. Stated
# explicitly so the tunable stays visible rather than merely absent.
CTX_CHECKPOINTS=0
# PROMPT CACHE, HOST RAM -- this is the one cache tunable that is NOT VRAM.
# --cache-ram caps the total bytes of saved prompt states
# (server_prompt_cache, tools/server/server-task.cpp): update() evicts
# oldest-first past the cap, and alloc() REFUSES outright any single state
# bigger than the whole cap. A state is the serialized KV of a whole sequence
# plus its DeltaNet state plus the draft context's own KV, so at this shape it
# costs ~68 KiB per token --
# 16 full-attention layers x 4 kv heads x 256 dim x 2 B x 2 (K,V) = 64 KiB
# plus the draft context's window-bounded KV (a ~40 MiB constant for DFlash2)
# -- which makes the cap a TOKEN budget in disguise: 16384 MiB holds ~240k
# tokens, i.e. a FULL 163840-token slot (~10.6 GiB) with ~5 GiB of the budget
# spare for a second entry.
#
# The cost is host RAM, allocated on demand and never pinned (plain heap
# vectors), so no VRAM number above moves. dankserver has 62 GiB beside the
# media-stack co-tenants, so the cache is bounded rather than unlimited (-1):
# the seam test refuses a budget over half the host's RAM.
CACHE_RAM=16384
CACHE_RAM=8192
# Sampling defaults for requests that send none -- the values the vLLM
# predecessor served on this endpoint (`--override-generation-config`), so a
@@ -247,33 +238,35 @@ REASONING_BUDGET=-1
CHAT_TEMPLATE_FILE=
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true}'
# Speculative decoding: the DFlash2 sidecar (operator call 2026-09-19). The
# target file also carries its own MTP head (nextn_predict_layers=1), so
# draft-mtp is one SPEC_TYPE change away -- the drafter file stays on disk.
# n_max 7 is the measured DFlash2 winner (see the table below); the drafter
# rides ROCm1, the card that holds the last layers.
SPEC_TYPE=draft-dflash
# Speculative decoding: the DFlash2 block-diffusion drafter PLUS the draftless
# n-gram matcher. DFlash2's GGUF carries block_size 8, so at most 7 draft
# tokens per verification step (llama.cpp asserts n_max <= block_size - 1).
# The drafter rides ROCm1, the card that ends up holding the target's last
# layers.
SPEC_TYPE=draft-dflash,ngram-mod
SPEC_DRAFT_N_MAX=7
SPEC_DRAFT_NGL=999
SPEC_DRAFT_DEVICE=ROCm1
SPEC_DRAFT_TYPE_K=f16
SPEC_DRAFT_TYPE_V=f16
# DFlash2 vs the model's own MTP head -- MEASURED 2026-09-16 on the predecessor
# payload (temperature 0, 1k prompt / 512 out; the autoregressive baseline and
# both MTP depths were run in one session, same binary, same prompts):
# MTP (the model's own head) vs the drafter -- MEASURED 2026-09-16 (temperature
# 0, 1k prompt / 512 out; the autoregressive baseline and both MTP depths were
# run against the deployed configuration in one session, same binary, same
# prompts):
#
# none 17.7 tok/s card1 26.07 GB
# draft-mtp n=3 32.4 tok/s 58 % accepted card1 29.35 GB
# draft-mtp n=7 26.7 tok/s 33 % accepted card1 29.65 GB
# draft-dflash 7 43.5 tok/s 41 % accepted card1 29.42 GB
#
# All four produced IDENTICAL greedy text, so verification is lossless
# whichever proposer runs. DFlash2 is the faster proposer (2.46x over
# autoregressive, 34 % over the best MTP). The MTP head adds ~3.3 GB to the
# card that holds it, against 1.06 GB for the Q4_K_M drafter's file -- at the
# 327680 pool the drafter's window-bounded KV (~40 MiB) leaves headroom on
# both cards, so the speed wins.
# All four produced IDENTICAL greedy text, so verification is lossless whichever
# proposer runs. DFlash2 is worth 2.46x over autoregressive and 34 % over the
# best MTP; deeper MTP drafting costs more than it returns (n=7 accepts 33 % of
# 147 proposals, n=3 accepts 58 % of 72). The MTP head is not free either:
# selecting it loads blk.64.nextn.* and adds ~3.3 GB to the card that holds it,
# against 2.06 GB for the drafter's file. Do not switch this without
# re-measuring.
#
# THE TWO CANNOT BE CHAINED. `--spec-type draft-mtp,draft-dflash` dies at load:
# context type MTP requested but model doesn't contain MTP layers
@@ -285,17 +278,16 @@ SPEC_DRAFT_TYPE_V=f16
# tuning question. See serverAppConfigs-docs/LLM/frank-medium-llamacpp-dflash2.md.
# ngram-mod: the draftless n-gram matcher (CPU hash table in host RAM, no VRAM,
# no model file, no draft context). It joins the chain unconditionally --
# no model file, no draft context). It joins the chain unconditionally --
# unlike MTP, which needs a draft context and therefore cannot share one with
# DFlash2. NOT CURRENTLY ACTIVE: SPEC_TYPE is draft-dflash alone, and the serve
# script only passes --spec-ngram-mod-* when the type contains "ngram". The
# window below is retained because the DFlash2 + ngram chain was measured free
# on prose and 2.6x on prompt-echo -- one SPEC_TYPE change brings it back.
# DFlash2. Verified on the loaded server: with ngram-mod as the ONLY spec type,
# a prose prompt produces ZERO drafts (plain autoregressive) while a
# prompt-echo prompt accepts 100 % of them, so its drafts are unmistakable.
#
# WINDOW MEASURED 2026-09-16 (chain at each window, decode tok/s):
#
# n_min/n_max prose 1k/512 prompt-echo 1.5k/400
# 48/64 43.9 200.6
# 48/64 43.9 200.6 <- deployed
# 16/32 36.0 161.0
# 8/16 36.2 113.8
#
@@ -312,7 +304,9 @@ SPEC_NGRAM_N_MATCH=24
SPEC_NGRAM_N_MIN=48
SPEC_NGRAM_N_MAX=64
# Vision enabled: llama-server loads mmproj-Qwen3.8-27B-Uncensored-f16.gguf
# (931 MiB, lands on ROCm0 -- the fit charges it there) via the explicit
# --mmproj flag. This keeps frank-medium multimodal across the model swap.
# Vision enabled: llama-server autoloads mmproj-BF16.gguf from MODEL_DIR
# (888 MiB, lands on ROCm0 -- the fit above charges it there). frank-small
# (Dankclaw, RTX 3090) remains the fleet's dedicated image endpoint; this
# makes frank-medium multimodal too, which is why the seam flips from
# "text only" to "vision loads with the model".
NO_MMPROJ=0
-202
View File
@@ -1,202 +0,0 @@
#!/usr/bin/env python3
"""Measure per-block / per-expert expert-routing activity on live frank-primary.
Method: AMD IBS op sampling (data addresses, `perf record -e ibs_op// --data`)
on the llama-server process while concurrent generations of mixed content run.
Sampled load addresses are mapped through /proc/<pid>/maps (mmap VMAs of the
6 GGUF parts) back to individual tensors, then aggregated per expert block
and per expert WITHIN each block (512 experts, row-major expert-major layout).
Tensor extents are DERIVED from consecutive GGUF header offsets — no quant
type-size table (those are easy to get subtly wrong; a wrong per-element size
silently under-covers each tensor and dumps tail samples into a junk bucket).
Fleet law this instrument established (2026-09-26, 75 s window, 4 mixed
streams, 2.7M expert-tensor samples): per-BLOCK read volume is FLAT
(0.93x-1.08x of mean across all 40 CPU-resident blocks) because MoE top-k
routing fires every block every token — there are no "most active layers" at
`-ot` granularity. The real skew is per-EXPERT (top 10% of (block,expert)
cells carry ~40% of samples), which tensor-level overrides cannot reach.
Any 8 GPU-resident blocks are therefore equivalent; COUNT is the only lever.
Usage (needs passwordless sudo for perf only; model files must be readable):
fp-expert-activity.py <pid> [duration_s] # dump + sample + analyze
fp-expert-activity.py <pid> --analyze-only # re-analyze saved samples
Artifacts: /tmp/fp-tensors.tsv, /tmp/fp-ibs.perf, /tmp/fp-addrs.txt
"""
import bisect, json, os, re, struct, subprocess, sys, threading, time
import urllib.request
MODEL_DIR = "/home/dank/models/Qwen3.8-Flash-Next-GGUF/Q8_0"
N_PARTS, N_BLOCKS, N_EXPERTS = 6, 48, 512
TSV, PERF_OUT, ADDRS = "/tmp/fp-tensors.tsv", "/tmp/fp-ibs.perf", "/tmp/fp-addrs.txt"
PORT = 7770
# ---------------------------------------------------------------- GGUF header
class P:
def __init__(self, f): self.f, self.ver = f, 2
def rd(self, n):
b = self.f.read(n)
assert len(b) == n, "eof"
return b
def u8(self): return self.rd(1)[0]
def u32(self): return struct.unpack("<I", self.rd(4))[0]
def u64(self): return struct.unpack("<Q", self.rd(8))[0]
def s(self):
n = self.u64() if self.ver >= 3 else self.u32()
return self.rd(n).decode("utf-8", "replace")
def val(self, t):
if t == 8: return self.s()
if t == 9:
et, n = self.u32(), self.u64()
for _ in range(n): self.val(et)
return None
if t in (0, 7): return self.u8()
if t == 1: return struct.unpack("<b", self.rd(1))[0]
if t == 2: return struct.unpack("<H", self.rd(2))[0]
if t == 3: return struct.unpack("<h", self.rd(2))[0]
if t == 4: return self.u32()
if t == 5: return struct.unpack("<i", self.rd(4))[0]
if t == 6: return struct.unpack("<f", self.rd(4))[0]
if t == 10: return self.u64()
if t == 11: return struct.unpack("<q", self.rd(8))[0]
if t == 12: return struct.unpack("<d", self.rd(8))[0]
raise ValueError(t)
def dump_part(path):
"""[(name, start, end)] with extents derived from consecutive offsets."""
with open(path, "rb") as f:
p = P(f)
assert p.rd(4) == b"GGUF"
p.ver = p.u32()
ntens, nkv = p.u64(), p.u64()
for _ in range(nkv):
p.s(); p.val(p.u32())
infos = []
for _ in range(ntens):
name = p.s()
nd = p.u32()
for _ in range(nd): p.u64()
p.u32()
infos.append((name, p.u64()))
data_start = (f.tell() + 31) & ~31
fsz = os.path.getsize(path)
out = []
for i, (name, off) in enumerate(infos):
start = data_start + off
end = (data_start + infos[i + 1][1]) if i + 1 < len(infos) else fsz
out.append((name, start, end))
return out
# ------------------------------------------------------------------- workload
PROMPTS = [
[{"role": "user", "content": "Write a Rust function that parses a simple binary log format (u32 length-prefixed records) with proper error handling, then explain the unsafe choices you avoided."}],
[{"role": "user", "content": "Compare the strategic logic of Soviet deep-battle doctrine and Blitzkrieg: where did they agree, where did they diverge, and what did each assume about logistics that proved wrong?"}],
[{"role": "system", "content": "You have tools: get_weather(city), book_flight(from,to,date). Plan a 3-city trip for next week; call tools as needed and explain each decision."},
{"role": "user", "content": "Seattle, Denver, Austin. Cheapest reasonable plan."}],
[{"role": "user", "content": "Explain how DNS recursive resolution works end to end, then write a small Python stub that walks a referral chain."}],
]
def fire(idx, results, dur):
body = json.dumps({"messages": PROMPTS[idx], "max_tokens": 260,
"temperature": 0.7, "cache_prompt": False}).encode()
req = urllib.request.Request(f"http://127.0.0.1:{PORT}/v1/chat/completions",
data=body, headers={"Content-Type": "application/json"})
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=dur + 60) as r:
d = json.load(r)
results[idx] = (r.status, round(time.time() - t0),
len(d.get("choices", [{}])[0].get("message", {}).get("content", "")))
except Exception as e:
results[idx] = ("ERR", str(e)[:120])
# --------------------------------------------------------------------- main
def main():
pid = int(sys.argv[1])
analyze_only = "--analyze-only" in sys.argv
dur = 75.0
for a in sys.argv[2:]:
if a.isdigit(): dur = float(a)
parts = {i: f"{MODEL_DIR}/Qwen3.8-Flash-Next-Q8_0-0000{i}-of-0000{N_PARTS}.gguf"
for i in range(1, N_PARTS + 1)}
vmas = []
with open(f"/proc/{pid}/maps") as f:
for line in f:
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S+) ([0-9a-f]+) (\S+) (\d+)\s*(.*)", line)
if not m: continue
start, end, pgoff, path = int(m.group(1), 16), int(m.group(2), 16), int(m.group(4), 16), m.group(7)
for i, p in parts.items():
if path == p: vmas.append((start, end, pgoff, i))
vmas.sort()
assert vmas, "no GGUF VMAs in maps — wrong pid?"
print(f"[rig] {len(vmas)} GGUF VMAs across {len({v[3] for v in vmas})} parts")
tensors = {}
with open(TSV, "w") as tsv:
for i, p in parts.items():
for name, start, end in dump_part(p):
tsv.write(f"{p}\t{name}\t{start}\t{end}\n")
tensors.setdefault(i, ([], [], []))
tensors[i][0].append(start); tensors[i][1].append(end); tensors[i][2].append(name)
n_exp = sum(1 for i in tensors for nm in tensors[i][2] if "exps" in nm)
print(f"[rig] {n_exp} exps tensors, extents offset-derived")
if not analyze_only:
results = {}
perf = subprocess.Popen(["sudo", "perf", "record", "-e", "ibs_op//", "-c", "50000",
"--data", "-o", PERF_OUT, "-p", str(pid), "--", "sleep", str(dur)],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
time.sleep(2)
threads = [threading.Thread(target=fire, args=(i, results, dur)) for i in range(4)]
for t in threads: t.start()
for t in threads: t.join(dur + 120)
err = perf.communicate(timeout=60)[1].decode()[-200:]
for i, r in sorted(results.items()):
print(f"[work] slot{i}: {r}")
print("[perf]", err.strip())
with open(ADDRS, "w") as f:
f.write(subprocess.run(["sudo", "perf", "script", "-i", PERF_OUT, "-F", "addr"],
capture_output=True, text=True).stdout)
block, expert = {}, {}
n_exps = n_ple = n_named = n_gap = n_out = 0
for line in open(ADDRS):
tok = line.split()
if not tok: continue
try: a = int(tok[0], 16)
except ValueError: continue
if a == 0 or a > 0xffff000000000000: continue
vi = bisect.bisect_right(vmas, (a, 1 << 62, 0, 0)) - 1
if vi < 0: n_out += 1; continue
start, end, pgoff, pi = vmas[vi]
if not start <= a < end: n_out += 1; continue
foff = pgoff + (a - start)
st, en, nm = tensors[pi]
ti = bisect.bisect_right(st, foff) - 1
if ti < 0 or foff >= en[ti]: n_gap += 1; continue
name, lo, span = nm[ti], foff - st[ti], en[ti] - st[ti]
if "exps" in name:
n_exps += 1
b = int(re.match(r"blk\.(\d+)\.", name).group(1))
e = int(lo / (span / N_EXPERTS))
block[b] = block.get(b, 0) + 1
expert[(b, e)] = expert.get((b, e), 0) + 1
elif "per_layer" in name: n_ple += 1
else: n_named += 1
print(f"\n[rig] attribution: exps={n_exps} ple={n_ple} named={n_named} "
f"gap={n_gap} outside={n_out}")
tot = sum(block.values())
print(f"== BLOCK histogram ({tot} samples, {len(block)} CPU-resident blocks):")
for b, c in sorted(block.items(), key=lambda kv: -kv[1]):
print(f" blk{b:2d}: {c:7d} {100*c/tot:5.2f}%")
mean = tot / max(len(block), 1)
print(f"block spread: max={max(block.values())/mean:.3f}x mean, min={min(block.values())/mean:.3f}x mean")
ev = sorted(expert.values(), reverse=True); et = sum(ev)
print(f"== EXPERT skew: {len(ev)} distinct (block,expert) cells hit of {N_BLOCKS*N_EXPERTS}")
print(f" top-half of cells carry {100*sum(ev[:len(ev)//2])/et:.1f}%; "
f"top-10% {100*sum(ev[:len(ev)//10])/et:.1f}%; hottest cell {100*ev[0]/et:.3f}%")
if __name__ == "__main__":
main()
-117
View File
@@ -1,117 +0,0 @@
# frank-primary.env — tcclaviger RDNA4 vLLM production config (dankserver :7770).
#
# 2026-10-01 cutover to the BF16-KV no-spec incumbent (the overnight redo
# campaign's verdict, Frank's call): same digest-pinned engine and payload as
# the 2026-09-29 cutover, but the KV cache is UNQUANTIZED (auto = bf16) and
# speculative decode is GONE. Git tag frank-primary-fp8-mtp-20261001 is the
# last pre-cutover commit; the old shape (fp8 KV + MTP4) is in history and
# restorable from the tag.
#
# Every number here was QUALIFIED by boot probe on 2026-10-01 (not inherited):
# * MAXLEN 241664 x SEQS 3 (724,992 tokens) at bf16 KV is the DEPLOYED
# shape (2026-10-01 night ladder): KV_BYTES 10,184,000,000 PER RANK,
# engine measured "KV cache size: 732,359 tokens; Maximum concurrency
# for 241,664 tokens per request: 3.03x". 262144 and 245760 both OOMed
# at graph capture (76 MiB short / 0 bytes free); 237568 passed at
# 718,606 tokens and the +4096 bonus step passed and won.
# * KV_BYTES is PER RANK. The old "7.2 GiB ceiling, 9/16 GiB OOM" law was
# the MTP4 WORKSPACE: the spec-era pool measured 729,545 tokens because
# the drafter reserved ~2.6 GiB/rank beside the pool. No-spec bf16 takes
# 11.05 GiB/rank and boots clean — the ceiling was spec's, not VRAM's.
# * bf16 KV doubles the attention-KV bytes/token (measured 14,047 vs
# 7,926) and costs ~14% decode vs fp8 at 4064 ctx (49.25 -> 36.42 with
# the gather win) — traded deliberately for clean cache + 262k windows.
# * GATHER 32x64 is the night's biggest lever: R4D_LRU_GATHER_CHUNKS=32 /
# R4D_LRU_GATHER_LANES=64 measured +18.7% decode vs default geometry,
# and the win grows with depth (39.5@166 -> 54.1@1087 -> 49.3@4064 fp8
# era). Do not drop it.
# * MNBT 1024 is Frank's lock: MNBT 512 doubles TTFT at 4064 ctx (2.46 s
# vs 1.36 s); 1024-4096 are interchangeable on decode; 12288 costs 2.5%
# and arena. 1024 keeps the workspace floor so the arena stays max.
# * PREFETCH IS ABSENT BY LAW: expert-cache telemetry measured the
# prefetcher at 36% precision (36,720 slabs prefetched, 13,369 served) —
# roughly half the gather traffic was waste. Never re-add
# --expert-prefetch without a fresh A/B.
# * TunableOp stays OFF: CLAV_TUNABLEOP_SWEEP silently skips under expert
# offload unless CLAV_TUNABLEOP_WITH_OFFLOAD=1, and forcing it corrupts
# numerics.
#
# HIP reads bare env vars TEMP, HOST, GPU_MAX_HEAP_SIZE — every key here is
# prefixed FRANK_PRIMARY_ so none can leak.
#
# --- runtime ----------------------------------------------------------------
# Digest-pinned: the floating :latest tag moved once already. Local alias
# qwen-fp8-runtime:tuned-20260929 (image ID 3322dbbbc96d) points at the same
# bytes in rootless storage.
FRANK_PRIMARY_IMAGE=docker.io/tcclaviger/vllm@sha256:da4029666668b90a0386c1ed23ea370c79916ad4e1a6ad1dd9db098b9fa069ea
# --- payload ----------------------------------------------------------------
FRANK_PRIMARY_MODEL_DIR=/home/dank/models/Qwen3.8-Flash-Next-Uncensored-FP8
FRANK_PRIMARY_MODEL_SHARDS=131
# --- identity ---------------------------------------------------------------
# The harness models.yml id stays frank-primary (provider 192.168.0.235:7770).
FRANK_PRIMARY_CONTAINER=frank-primary-qwen
FRANK_PRIMARY_SERVED_NAME=frank-primary
FRANK_PRIMARY_PUBLISH=7770:7770
# Containers removed before every start: the retired R9V identity and the ad
# hoc experiment identity (both hold the GPUs / name registrations).
FRANK_PRIMARY_LEGACY_CONTAINERS="frank-primary-r9v qwen-fp8"
# --- qualified engine shape (2026-10-01, RESTORED KNOWN-GOOD: fp8 3 windows) ---
# bf16 at 2 x 262144 did NOT stay up: with and without --expert-precise-memory
# the boot failed (`Engine core initialization failed`, NRestarts climbing).
# The last shape measured serving was fp8 3 x 262144 / MNBT 2048 / KV 6.1e9
# (KV 792,069 tokens, 3.02x, arena 6,248, ~3.6 GiB free) and is restored here
# while the bf16 path is investigated in a window (see log.md 2026-10-01).
# Candidate next test: bf16 with SEQS=3 to isolate the KV dtype from the
# sequence count, and/or --enforce-eager to bypass the dynamo constraint path.
# SEQS 2 x MAXLEN 262144 at BF16 KV (Frank's call, 2026-10-01): two contexts of
# the model's FULL native window with UNQUANTIZED KV, plus
# --expert-precise-memory to hand the arena the compile/graph-capture headroom.
# Pool: bf16 costs 13,839 B/token (measured: 11,047,010,304 B -> 798,280 tokens
# on the 3x241664 bf16 boot; decomposes as 12,276 attention + 1,563 mamba/side
# caches, against fp8's 6,138 + 1,563 = 7,701). 2 x 262144 = 524,288 tokens
# -> 7.256e9 B floor, pinned at 7,300,000,000 (~527,500 tokens, 3,206 margin).
# Budget (per rank): 31.22 GiB total; non-expert weights 6.28, graphs 0.55,
# non-torch ~1.45, pool 6.76 -> ~29.35 used, ~2.5 GiB free (measured basis:
# the fp8 3-window shape used 28.27 and left 3.59).
# PRECISE MEMORY: DROPPED 2026-10-01 after it crashed the boot in combination
# with bf16. The flag selects the fitted 5.25 GiB overhead instead of the
# 7.60 GiB default, moving the plan's `room` and therefore the arena
# (6,248 -> ~7,278 slots). But the arena is DEVICE-resident, and the budget
# for this shape does not have 2.35 GiB to spare:
# weights 6.28 + pool 6.80 (bf16 2 x 262144) + graphs 0.55 + arena 16.65
# + non-torch ~1.45 = 31.73 of 31.86 GiB => ~0.13 GiB free at boot
# measured as a CrashLoop: every attempt reached graph capture, the engine
# exited cleanly, systemd restarted it (NRestarts=3 at ~6.7 min each).
# With the default overhead the arena is 14.31 GiB and the same shape totals
# ~29.4 GiB, leaving ~2.4 GiB for capture. Re-enable the flag only with a
# pool/context reduction that pays for it.
# MNBT 2048 (Frank): the overhead formula only adds bytes above nbt 4096, so
# 2048 pays nothing, and the arena sweep measured the same ceiling <= 4096.
FRANK_PRIMARY_MAXLEN=262144
FRANK_PRIMARY_SEQS=3
FRANK_PRIMARY_KV_BYTES=6100000000
FRANK_PRIMARY_KV_DTYPE=fp8
FRANK_PRIMARY_MNBT=2048
FRANK_PRIMARY_GATHER_CHUNKS=32
FRANK_PRIMARY_GATHER_LANES=64
FRANK_PRIMARY_UTIL=0.98
# --- client contract: reasoning split, tools, preserve_thinking ----------------
# Without --reasoning-parser the engine leaves the thinking text INLINE in
# `content` with a dangling `</think>` and a null `reasoning` field (measured
# 2026-10-01: content = 'We need answer...Brief.\n</think>\n\n391') — the
# malformed thinking block clients choke on. The qwen3 parser splits it
# properly. The GLM rollback (d91379d) had dropped all three of these; the
# working values are the ones ecaad36 shipped for this model.
FRANK_PRIMARY_REASONING_PARSER=qwen3
# Tools: --enable-auto-tool-choice + the qwen3_xml parser is what makes
# tool_calls work here (without it, tool_choice auto returns 400).
FRANK_PRIMARY_TOOL_PARSER=qwen3_xml
# preserve_thinking keeps prior thinking blocks in the rendered prompt across
# turns; enable_thinking makes thinking the server-side default. Verified the
# payload's tokenizer_config template consumes both keys.
FRANK_PRIMARY_CHAT_TEMPLATE_KWARGS='{"enable_thinking": true, "preserve_thinking": true}'
-66
View File
@@ -1,66 +0,0 @@
# frank-primary.service — tcclaviger RDNA4 vLLM endpoint (orcarouter
# Qwen3.8-Flash-Next-Uncensored-FP8, MTP4 spec decode, KV q8/q8, 600k total
# context over 3 sessions, TP=2, tiered experts, 131-shard payload) on the
# 2x R9700 pair, dankserver :7770. 2026-09-29 cutover from the R9V radiance
# fork (git tag frank-primary-r9v-q8-20260929 is the pre-cutover state; its
# launcher/shim/setup-state machinery is retired and the state dirs stay on
# disk as rollback).
#
# Same port, same served alias; the harness models.yml id is unchanged.
# The serve script drives `podman run` directly (the image entrypoint execs
# `vllm serve`) and then waits on the container. NOTE: that wait can return
# 0 even when the engine died mid-request (vLLM's API server drains and
# exits clean on a worker fault), which is why the restart policy is
# `always` — see the [Service] note.
#
# WHY A ROOT UNIT (not a rootless quadlet): the tiered-expert shape pins tens
# of GiB of host RAM (the expert host slabs, ~20.5 GiB per rank of pinned
# anonymous memory). Pinned pages are charged against RLIMIT_MEMLOCK; dank's
# login session on this host carries 8 MiB and a user quadlet is capped by the
# user manager's own hard limit — raising that means restarting
# user@1000.service and bouncing every user container on the host. A root unit
# grants LimitMEMLOCK=infinity to the dank process via PID1, and the image
# stays in dank's rootless podman storage.
[Unit]
Description=frank-primary — tcclaviger vLLM Qwen3.8-Flash-Next-Uncensored-FP8 on 2x R9700
After=network-online.target
Wants=network-online.target
# Never let the start-rate limiter give up after a crash loop (a failed LLM
# endpoint that systemd refuses to retry is worse than a retrying one).
StartLimitIntervalSec=0
[Service]
User=dank
Group=dank
# The tiered-expert offloader pins host RAM; an RLIMIT_MEMLOCK smaller than
# the pinned set fails the load mid-start. PID1 grants infinity here —
# parity with the fleet's other LLM units.
LimitMEMLOCK=infinity
Environment="FRANK_PRIMARY_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-primary.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-primary.sh
# systemd stop: wait alone cannot stop the container child; stop it
# explicitly. vLLM 0.26 drains gracefully on SIGTERM.
ExecStop=/usr/bin/podman stop -t 120 frank-primary-qwen
# Boot cost: FP8 payload page-in plus torch compile/graph capture —
# ~15 min cold (measured 2026-09-29), faster warm.
TimeoutStartSec=1800
# Restart=always, NOT on-failure (measured 2026-10-01): a runtime
# torch.OutOfMemoryError kills the engine while vLLM's API server still
# exits 0, so ExecStart returned clean, systemd logged "Deactivated
# successfully", and on-failure NEVER fired — the endpoint stayed down until
# a human noticed. `always` restarts on any container death; an explicit
# `systemctl stop` still wins (systemd does not restart a unit the operator
# stopped), so bench windows and deploys are unaffected.
Restart=always
RestartSec=10
# ExecStop allows the engine 120 s to drain (`podman stop -t 120`); without an
# explicit TimeoutStopSec systemd's 90 s default cuts the stop short, kills it,
# and records 'Failed with result timeout' (measured 2026-10-01 13:27:17).
# Must exceed the podman grace with margin for the engine's own shutdown.
TimeoutStopSec=180
# The engine pins tens of GiB of host RAM (expert host slabs); it is the
# last process on this host that should be chosen by the kernel OOM killer.
OOMScoreAdjust=-500
[Install]
WantedBy=multi-user.target
+73 -66
View File
@@ -1,38 +1,41 @@
# Tunables for frank-small (letechlead/MiMo-V2.6-Distill-Qwen-9B-INT8-
# W8A16-AutoRound, qwen3_5 hybrid, in-checkpoint vision) on SGLang,
# Dankclaw's RTX 3090, since 2026-10-01.
# Tunables for frank-small (HauhauCS/Gemma4-12B-QAT-Uncensored-HauhauCS-
# Balanced, Q4_K_M, plus the BF16 vision projector and the separate MTP
# sidecar) on mainline llama.cpp, Dankclaw's RTX 3090, since 2026-09-17.
#
# Runtime: SGLang in a python3.12 venv (pip "sglang[all]"):
# python3.12 -m venv /home/dank/GitPackages/sglang-cu128
# /home/dank/GitPackages/sglang-cu128/bin/pip install "sglang[all]"
# The venv carries its own CUDA torch + sgl-kernel + flashinfer; it is
# self-contained and does not touch the host Python (3.14) or the ROCm
# stack that serves frank-large.
# Runtime: llama.cpp master 37b53fd, CUDA build for sm_86:
# cmake -B build-cuda -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=ON \
# -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86 -DGGML_CUDA_FA_ALL_QUANTS=ON
# cmake --build build-cuda --target llama-server -j
# GGML_CUDA_FA_ALL_QUANTS is ON: this service runs q8_0 KV with flash
# attention, and without the flag quantized-KV prefill collapses ~12x on
# CUDA (see the CUDA lesson in AGENTS.md).
#
# Model: MiMo-V2.6-Distill-Qwen-9B-INT8-W8A16-AutoRound — a Qwen3.5-9B
# distillation at INT8 W8A16 (AutoRound, group 128, symmetric). 8
# safetensors shards, 12.0 GiB total. The vision tower
# (qwen3_5_vision, 27 layers) and embed_tokens stay fp16 inside the
# checkpoint, so the image path needs no separate projector file.
# Architecture: 32 layers, 8 full-attention (4 KV heads x 256 head_dim)
# + 24 Gated DeltaNet, native 262144 window. text_config declares
# mtp_num_hidden_layers = 1, but the shipped safetensors carry NO mtp.*
# tensors (verified against model.safetensors.index.json 2026-10-01 —
# base BF16 and this INT8 conversion both omit the head), so there is no
# spec decode: SGLang would draft with an uninitialised 4.24 GiB ghost
# model at 0% acceptance and pay the verify tax for nothing.
# Model: HauhauCS/Gemma4-12B-QAT-Uncensored-HauhauCS-Balanced at Q4_K_M
# (6.87 GiB). Architecture (GGUF arch gemma4): 48 layers, MHA with 16 kv
# heads x 512 head_dim, rope freq_base 1e6, final logit softcapping 30,
# per-layer token embeddings (PLE) inside the file, and a sliding-window
# attention pattern (attention.sliding_window = 1024) -- most layers cache
# only a 1024-position window, which is why the KV pool is a fraction of
# the full-ctx x all-layers number.
#
# SIZING (RTX 3090), measured 2026-10-01: the 0.98 budget holds the
# checkpoint (11,354 MiB) + the DeltaNet ssm state pool (795 MiB at 32
# bf16 slots) + the KV pool. The engine's autofit picks a 687,388-token
# pool and leaves ~0.53 GB — too little: Triton's deferred kernel loads
# take most of it after boot, and eager-prefill activations then OOM on
# the first long request. The pool is therefore PINNED at 606,208 tokens
# (2 x 262,144 native slots since the 2026-10-01 reslice; 4 x 151,552
# before), which leaves ~0.8 GB for the prefill workspace; a 170k single
# request and 4 concurrent 54k requests were verified at the 4-slot
# shape.
# The ssm pool is the 4.5 GiB the weights-only arithmetic misses.
# MTP: the Multi-Token Prediction head ships as a SEPARATE file
# (mtp-gemma-4-12B-it.gguf, arch gemma4-assistant, 4 nextn layers, 0.24
# GiB). Unlike the Qwen3.8 -mtp build (head inside the main file), this one
# is passed via --model-draft with --spec-type draft-mtp, so the draft
# context loads the assistant model. The same --spec-type value chains the
# draftless ngram-mod matcher, which attaches unconditionally; two
# context-backed drafters cannot share one process, so draft-mtp,ngram-mod
# is the only legal shape that carries both.
#
# SIZING (RTX 3090, 24576 MiB): not modelled by hand -- CTX_SIZE is left
# unset and --fit sizes the KV pool (q8_0 K and V) against what the 6.87
# GiB of weights, the 0.16 GiB projector, the MTP sidecar's draft context
# and the compute buffers leave on the card. The engine prints the chosen
# context at startup; read it from the log, do not hard-code it back here.
#
# Vision: --mmproj loads the BF16 projector (11 tensors, 52M params; it
# also carries an audio encoder, unused by the fleet). The fleet's image
# path lives here, which is why halogen no longer sets HALOGEN_VISION_TOWER.
#
# Sampling: greedy by default (the fleet contract); a client that sends a
# temperature gets its own, and the harness applies per-model overrides
@@ -42,45 +45,49 @@
# reads those bare vars on the ROCm hosts -- see the 2026-08-13 incident in
# AGENTS.md). This box is CUDA, but the rule keeps env files portable.
# SGLang runtime: python3.12 venv, self-contained CUDA stack.
SGLANG_BIN=/home/dank/GitPackages/sglang-cu128/bin/sglang
# llama.cpp runtime binary: mainline master 37b53fd, CUDA sm_86,
# FA_ALL_QUANTS=ON.
LLAMA_BIN=/home/dank/GitPackages/llama.cpp-mainline/build-cuda/bin/llama-server
# Model package (8 shards + configs, sha256-verified by
# scripts/download-frank-small.sh).
MODEL_DIR=/home/dank/models/MiMo-V2.6-Distill-Qwen-9B-INT8-W8A16-AutoRound
# Model package. The MTP sidecar file is required for speculative decoding.
MODEL_DIR=/home/dank/models/Gemma4-12B-QAT
MODEL_FILE=Gemma4-12B-QAT-Uncensored-HauhauCS-Balanced-Q4_K_M.gguf
MMPROJ_PATH=/home/dank/models/Gemma4-12B-QAT/mmproj-Gemma4-12B-QAT-Uncensored-HauhauCS-Balanced-BF16.gguf
DRAFT_FILE=mtp-gemma-4-12B-it.gguf
# Fleet identity and network. 7771 is this service's own endpoint on
# Dankclaw; 7770 stays frank-large's. The alias is the id at /v1/models
# and what clients ask for -- the harness entry in models.yml names it.
# Dankclaw; 7770 stays frank-large's. The alias is the id at /v1/models and
# what clients ask for -- the harness entry in models.yml names it.
ALIAS=frank-small
BIND_ADDR=0.0.0.0
LISTEN_PORT=7771
# Two slots at the model's native 262,144 window on fp8 KV. The pool is
# pinned (see SIZING above): 606,208 tokens, and 2 x 262,144 = 524,288
# leaves ~82k tokens of pool slack. Resliced 2026-10-01 from 4 x 151,552
# so a compaction request (frank-primary's trigger 183,501 tokens + a
# 32,768-token summary budget = 216,269) fits ONE request; the grid is
# the two-way concurrency contract. A single request may still grow to
# the whole pool (a 170k prompt was verified at the 4-slot shape).
MEM_FRACTION=0.98
MAX_TOTAL_TOKENS=606208
CONTEXT_LENGTH=262144
MAX_RUNNING=2
KV_DTYPE=fp8_e4m3
QUANTIZATION=auto-round
# DeltaNet (mamba) recurrent states: the ssm state pool is the single
# biggest hidden consumer on this card. The engine autofills the slot
# COUNT to spend a fixed ~5.1 GB budget regardless of dtype (fp32 at 110
# slots, bf16 at 217 — same 5.1 GB), so BOTH dials are pinned below:
# bf16 dtype + 32 slots = 795 MiB.
MAMBA_SSM_DTYPE=bfloat16
# 32 slots is 16x the 2-way concurrency: enough for the radix mamba
# cache to keep shared-prefix states, small enough to leave the KV pool
# room.
MAX_MAMBA_CACHE_SIZE=32
# Context: intentionally unset -- --fit decides it (see the sizing note).
# One slot; a second concurrent request queues behind the first.
PARALLEL=1
CACHE_TYPE_K=q8_0
CACHE_TYPE_V=q8_0
# Reasoning is extracted into reasoning_content; tools use the qwen3_coder
# grammar (both parsers per the SGLang Qwen3.5 cookbook).
REASONING_PARSER=qwen3
TOOLCALL_PARSER=qwen3_coder
# Batch geometry: llama.cpp's own defaults (2048 logical / 512 physical).
BATCH_SIZE=2048
UBATCH_SIZE=512
# CPU threads: this service runs entirely on the GPU (NGL=999), so threads
# cover tokenization, sampling and the HTTP layer only. Deliberately modest
# -- this host's CPU is shared with halogen's engine, which is CPU-hungry.
NUM_THREADS=8
THREAD_BATCH=8
# Every layer on the 3090, with flash attention (required for q8 KV).
NGL=999
FLASH_ATTN=on
# Speculative decoding: the MTP sidecar (draft context) chained with the
# draftless ngram-mod matcher. Draft depth stays llama.cpp's default of 3.
SPEC_TYPE=draft-mtp,ngram-mod
# Greedy default (fleet contract); reasoning is extracted into
# reasoning_content with an unlimited budget (the model decides).
TEMPERATURE=0.0
REASONING_FORMAT=deepseek
REASONING_BUDGET=-1
+3 -2
View File
@@ -1,5 +1,5 @@
[Unit]
Description=SGLang MiMo-V2.6-Distill-Qwen-9B-INT8 (qwen3_5 hybrid, in-checkpoint vision, no MTP) 4x151k fp8 KV (dankclaw, frank-small)
Description=llama.cpp Qwen3.8-27B GSQ-RCO IQ3_S MTP+vision (dankclaw, frank-small)
After=network-online.target
Wants=network-online.target
@@ -8,7 +8,8 @@ Type=simple
User=dank
Group=dank
# CUDA pins its staging buffers, so the process needs the memlock budget even
# though the weights themselves are copied into VRAM.
# though the weights themselves are copied into VRAM. Parity with the halogen
# quadlet's Ulimit=memlock=-1:-1 and the retired ik unit.
LimitMEMLOCK=infinity
Environment="FRANK_SMALL_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-small.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-small.sh
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env bash
# hf-fetch.sh — resumable, verified HuggingFace fetch (gated or public repos).
#
# bash scripts/hf-fetch.sh <dest_dir> <manifest>
#
# Manifest: one line per file, three tab/space-separated fields:
# <filename> <size_bytes> <sha256>
# A blank sha256 field means "check size only". Lines starting with '#' and
# blank lines are ignored. The first field is a path relative to <dest_dir>.
#
# Token: $HF_TOKEN, else ~/.cache/huggingface/token, else the llamaswap conf
# (grep hf_...). Passed out-of-band via a mode-600 curl -K file — a -H header
# is world-readable in /proc/<pid>/cmdline (the 2026-09-16 HF-token leak).
#
# Idempotent: a file whose size AND sha256 already match is skipped, so a
# re-run after an interruption only fetches what is missing or corrupt.
set -euo pipefail
dest="${1:?usage: hf-fetch.sh <dest_dir> <manifest>}"
manifest="${2:?usage: hf-fetch.sh <dest_dir> <manifest>}"
repo="${3:?usage: hf-fetch.sh <dest_dir> <manifest> <owner/repo>}"
mkdir -p "$dest"
# --- token resolution --------------------------------------------------------
token="${HF_TOKEN:-}"
if [[ -z "$token" && -r "$HOME/.cache/huggingface/token" ]]; then
token="$(tr -d '[:space:]' < "$HOME/.cache/huggingface/token")"
fi
if [[ -z "$token" && -r "$HOME/.config/llamaswap.conf.yaml" ]]; then
token="$(grep -oE 'hf_[a-zA-Z0-9]+' "$HOME/.config/llamaswap.conf.yaml" | head -1)"
fi
[[ -n "$token" ]] || { echo "hf-fetch: no HF token (set HF_TOKEN or ~/.cache/huggingface/token)" >&2; exit 3; }
auth_file="$(mktemp)"
chmod 600 "$auth_file"
printf 'header = "Authorization: Bearer %s"\n' "$token" > "$auth_file"
cleanup() { rm -f "$auth_file"; }
trap cleanup EXIT
base="https://huggingface.co/${repo}/resolve/main"
# --- per-file fetch + verify -------------------------------------------------
fetch_one() {
local name="$1" size="$2" sha="$3"
local url="$base/$name"
local path="$dest/$name"
mkdir -p "$(dirname "$path")"
# idempotent skip
if [[ -f "$path" ]]; then
local cur_size sha_ok
cur_size="$(wc -c < "$path")"
sha_ok=true
if [[ -n "$sha" && "$(sha256sum "$path" | cut -d' ' -f1)" != "$sha" ]]; then
sha_ok=false
fi
if [[ "$cur_size" == "$size" && "$sha_ok" == "true" ]]; then
echo " ok $name ($size bytes)"
return 0
fi
echo " refetch $name (have $cur_size, want $size)"
fi
# Resumable download; -f fails on HTTP error so a 401/403 is not written as a file.
# -C - only when a non-empty partial exists: with a missing file it sends
# Range: bytes=0-, which HF's xethub CDN rejects with 400 (xet-backed repos).
local resume_args=()
[[ -f "$path" && -s "$path" ]] && resume_args=(-C -)
curl -fsSL "${resume_args[@]}" -K "$auth_file" -o "$path" "$url"
# verify byte count
local got
got="$(wc -c < "$path")"
if [[ "$got" != "$size" ]]; then
echo " FAIL $name: size $got != $size" >&2
return 1
fi
# verify sha256
if [[ -n "$sha" ]]; then
local actual
actual="$(sha256sum "$path" | cut -d' ' -f1)"
if [[ "$actual" != "$sha" ]]; then
echo " FAIL $name: sha256 $actual != $sha" >&2
return 1
fi
fi
echo " done $name ($size bytes)"
}
echo "hf-fetch: $repo -> $dest"
status=0
while IFS= read -r line || [[ -n "$line" ]]; do
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
name="$(awk '{print $1}' <<<"$line")"
size="$(awk '{print $2}' <<<"$line")"
sha="$(awk '{print $3}' <<<"$line")"
fetch_one "$name" "$size" "$sha" || status=1
done < "$manifest"
if [[ "$status" -ne 0 ]]; then
echo "hf-fetch: FAILED (see above)" >&2
exit 1
fi
echo "hf-fetch: all files verified"
-140
View File
@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""hf-manifest.py — generate an hf-fetch.sh manifest from a HuggingFace repo.
Usage: python3 scripts/hf-manifest.py <owner/repo> [outfile]
Lists the repo tree via the HF API and, for each file, records the size and
the content sha256. LFS and Xet-backed files redirect the resolve endpoint to
a CDN (302), whose own headers carry the truth: X-Linked-Size is the byte
count and X-Linked-ETag is the sha256 of the file's bytes. The redirect must
NOT be followed: on Xet-backed repos the bridge answers with ETag = the Xet
CAS chunk-dag hash, which is NOT the content sha256 (seen on
Qwen/Qwen3.8-Flash-Next-FP8, 2026-09-26: every shard "failed" verification
against a hash no download could ever match). Non-LFS files (config.json,
tokenizer, ...) are answered directly (200) and carry no 64-hex etag, so they
get a blank hash field — hf-fetch.sh then checks size only for them.
Output: one line per file, three space-separated fields:
<filename> <size_bytes> <sha256-or-blank>
Token: $HF_TOKEN, else ~/.cache/huggingface/token, else the llamaswap conf
(grep hf_...). Gated repos need a token that has been granted access.
"""
import http.client
import json
import os
import re
import sys
import urllib.request
from urllib.parse import urljoin, urlsplit
def get_token():
t = os.environ.get("HF_TOKEN", "")
if not t:
p = os.path.expanduser("~/.cache/huggingface/token")
if os.path.exists(p):
with open(p) as f:
t = f.read().strip()
if not t:
p = os.path.expanduser("~/.config/llamaswap.conf.yaml")
if os.path.exists(p):
with open(p) as f:
m = re.search(r"hf_[a-zA-Z0-9]+", f.read())
if m:
t = m.group(0)
return t
def head(url, token):
"""HEAD via http.client, which does not follow redirects.
urllib's default opener follows the resolve endpoint's 302, and the Xet
bridge's ETag is the Xet CAS hash, not the content sha256 — so the 302's
own X-Linked-* headers must be read instead. Returns an http.client
response; the caller reads r.status and r.headers.
"""
parts = urlsplit(url)
conn = http.client.HTTPSConnection(parts.netloc, timeout=30)
path = parts.path + (f"?{parts.query}" if parts.query else "")
h = {"User-Agent": "hf-manifest/1.0"}
if token:
h["Authorization"] = f"Bearer {token}"
conn.request("HEAD", path, headers=h)
return conn.getresponse()
def main():
if len(sys.argv) < 2:
print(f"usage: {sys.argv[0]} <owner/repo> [outfile]", file=sys.stderr)
sys.exit(2)
repo = sys.argv[1]
outfile = sys.argv[2] if len(sys.argv) > 2 else None
token = get_token()
# 1. List the repo tree (siblings[].rfilename). Sizes and content sha256s
# come from the resolve endpoint's HEAD (not followed — see head());
# the API's siblings carry only rfilename.
api = f"https://huggingface.co/api/models/{repo}"
req = urllib.request.Request(
api, headers={"User-Agent": "hf-manifest/1.0",
**({"Authorization": f"Bearer {token}"} if token else {})})
with urllib.request.urlopen(req, timeout=30) as r:
meta = json.load(r)
siblings = meta.get("siblings", [])
print(f"# {repo}: {len(siblings)} entries", file=sys.stderr)
lines = []
for s in siblings:
name = s.get("rfilename", "")
if not name or name.endswith("/"):
continue
# 2. Size + content sha256 from the resolve endpoint's HEAD. 200 =
# direct answer (Content-Length + ETag). 3xx WITH X-Linked-Size =
# LFS/Xet redirect: X-Linked-Size + X-Linked-ETag are the byte
# count and the sha256 of the file's bytes, and the redirect must
# NOT be followed — the Xet bridge's own ETag is the Xet CAS hash,
# not the content sha256. 3xx WITHOUT X-Linked-Size = non-LFS
# redirect (307 to HF's own /api/resolve-cache): the stub's
# Content-Length is NOT the file size, so follow ONCE; the final
# ETag there is the git blob sha (40 hex), so the 64-hex gate
# leaves the hash blank (hf-fetch.sh then checks size only).
size = 0
sha = ""
resolve = f"https://huggingface.co/{repo}/resolve/main/{name}"
try:
with head(resolve, token) as r:
if r.status == 404:
raise FileNotFoundError(name)
if r.status == 200:
size = int(r.headers.get("Content-Length") or 0)
etag = (r.headers.get("ETag") or "").strip('"')
else:
linked = r.headers.get("X-Linked-Size")
if linked is not None:
size = int(linked)
etag = (r.headers.get("X-Linked-ETag") or "").strip('"')
else:
loc = r.headers.get("Location", "")
with head(urljoin(resolve, loc), token) as r2:
if r2.status == 200:
size = int(r2.headers.get("Content-Length") or 0)
etag = (r2.headers.get("ETag") or "").strip('"')
else:
etag = ""
if re.fullmatch(r"[0-9a-f]{64}", etag):
sha = etag
except FileNotFoundError:
pass
lines.append(f"{name} {size} {sha}")
print(f" {name} {size:>12} {sha or '(size only)'}", file=sys.stderr)
out = "\n".join(lines) + "\n"
if outfile:
with open(outfile, "w") as f:
f.write(out)
print(f"wrote {outfile}", file=sys.stderr)
else:
sys.stdout.write(out)
if __name__ == "__main__":
main()
+3 -5
View File
@@ -2,8 +2,7 @@
"""homepage-status.py — the homepage status checker.
Reads a service config (a JSON array of service entries), probes each backend,
and writes a status.json atomically. A wip service is not probed; its state
is "wip-open" or "wip-closed" following the wip value in the config.
and writes a status.json atomically. A wip service is not probed.
Usage: homepage-status.py <services.json> <out.json>
"""
@@ -49,9 +48,8 @@ def main(argv):
results = []
for svc in services:
wip = svc.get("wip")
if wip:
state = "wip-%s" % wip
if svc.get("wip"):
state = "wip"
else:
host, _, port = svc["probe"].rpartition(":")
state = probe(host, int(port), svc.get("method", "tcp"))
@@ -1,86 +0,0 @@
# glm53-compile-01-model-decorator.patch — put the GLM-5.3-Flash trunk under torch.compile.
#
# STATUS: WIP — first of three patches in the "compile the GLM trunk" changelist. The
# set does NOT yet boot the engine (see 03 for the next blocker), so it is NOT part of
# the deployed frank-primary shape. Nothing in scripts/ mounts these files.
#
# WHY THIS IS THE RIGHT LEVER (measured 2026-09-30, not inferred):
#
# The fork's own flagship trunk — vllm/models/qwen4_exp/amd/model.py — carries
# @support_torch_compile. vllm/models/glm5next/common/model.py does not, and the engine
# says so on every boot:
#
# Cudagraph mode FULL_AND_PIECEWISE requires piecewise capture, but the loaded model
# provides neither a compiled submodule nor breakable CUDA graphs.
# Overriding to FULL_DECODE_ONLY.
#
# A torch-profiler window on the live endpoint (496 decode tokens, rank0, 3 x 160-token
# generations at the deployed shape) shows what the missing compilation costs. Total
# device kernel time 46.18 s / 496 tokens = ~93 ms per token, against a measured step
# time of ~104 ms (9.6 tok/s with the profiler attached):
#
# 16.07 s 34.8 % 708,500 launches vectorized_elementwise_kernel (22.7 us each)
# 4.60 s 10.0 % 42,000 r4d_gemm_moe_w4a16_nt_b16_m64 (the MoE GEMM)
# 3.11 s 6.7 % 174,708 elementwise_kernel_manual_unroll
# 2.94 s 6.4 % 138,252 wvSplitK_hf_sml_ (split-K GEMM)
# 4.09 s 8.9 % 83,160 topk/router (sbtopk gatherTopK + warpMergeSortTopK
# + bitonicSortKVInPlace)
# 3.01 s 6.5 % 133,920 r4d_mhc_* (hyper-connection pre/post/finish)
# 2.78 s 6.0 % 127,916 inductor triton_poi_* / triton_per_* (the only
# fusions that DO happen)
# 0.49 s 1.05% 20,664 r4d_moe_lru_gather_kernel <-- the suspect
#
# That last row retired the fleet's working hypothesis. Decode on this shape is NOT
# expert-gather bound: the gather is 1 % of device time, the same order as the telemetry
# kernel that was switched on to measure it. 44 % of the budget is pointwise glue —
# bf16<->fp32 conversions, adds, pows/rsqrts from fp32 RMSNorm, clamp/scale quant
# passes — spread over ~1,428 launches per token. Inductor fusion is the only lever
# that touches that mass; the router is second at 9 % but is binary-blocked (the
# libr4d route kernel exists only as r4d_moe_route_kernel<128|256|512> instantiations
# and GLM has 288 experts, with no HIP sources shipped to rebuild).
#
# TARGET: vllm/models/glm5next/common/model.py (inside the pinned image)
# base sha256 008eeaa2a07e091465319f86900b5a321d921238723ccbe22e6b9e971e1d2b28
# patched sha256 6e9cb81b84a9a88bd41403ca404aa04c3b791899dd77d661c3f25ae686cecd40
#
# APPLY: `patch -p1` from the vllm package root, or bind-mount new/.../model.py over
# site-packages. Then R4D_GLM_COMPILE=1 (default) enables it; =0 makes the decorator a
# no-op, so the file can stay mounted for an A/B without a second artifact.
# Requires 02 (trace-safe probes) — without it the worker dies during profile_run on
# the first probe call inside the traced region.
--- a/vllm/models/glm5next/common/model.py
+++ b/vllm/models/glm5next/common/model.py
@@ -1,12 +1,14 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+import os
from collections.abc import Iterable
from typing import ClassVar, Literal
import torch
from torch import nn
+from vllm.compilation.decorators import support_torch_compile
from vllm.config import ParallelConfig, VllmConfig
from vllm.distributed import (
get_ep_group,
@@ -623,6 +625,18 @@
)
+_GLM_COMPILE = os.environ.get("R4D_GLM_COMPILE", "1") == "1"
+
+
+@support_torch_compile(
+ dynamic_arg_dims={
+ "input_ids": 0,
+ "positions": -1,
+ "intermediate_tensors": 0,
+ "inputs_embeds": 0,
+ },
+ enable_if=lambda _cfg: _GLM_COMPILE,
+)
class Glm5NextModel(nn.Module):
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
@@ -1,119 +0,0 @@
# glm53-compile-02-trace-safe-probes.patch — libr4d capability probes must not do I/O
# inside a torch.compile region.
#
# STATUS: WIP — second of three patches in the "compile the GLM trunk" changelist.
# Inert (but harmless) without 01. Not part of the deployed shape.
#
# THE BREAK (measured: with 01 mounted and R4D_GLM_COMPILE=1, the worker dies during
# profile_run, i.e. the first — cold — forward):
#
# Explanation: Dynamo does not know how to trace the builtin `posix.stat`
# ... r4d_mhc_supported -> r4d_lib.r4d_mhc_enabled -> available -> os.path.exists
#
# THE NON-OBVIOUS PART: functools.lru_cache does NOT protect these probes. Dynamo
# re-traces the wrapped body to build its guard even when the cache is warm — it warns
# about exactly this ("Dynamo detected a call to a `functools.lru_cache`-wrapped
# function ... Dynamo ignores the cache wrapper and directly traces the wrapped
# function"). Verified in-image, torch 2.11.0+rocm10.0, torch.compile(backend="eager"):
#
# cold lru_cache call -> RAISE Unsupported
# WARM lru_cache call -> RAISE Unsupported <-- warming is not enough
# plain module-global read -> compiles
# uncached os.path.exists -> RAISE Unsupported
#
# So each probe must resolve ONCE into a plain value and be read as a global. The set
# below is every no-argument probe / symbol lookup reachable from a forward pass,
# grep-verified call sites:
# model_executor/layers/fused_moe/experts/r4d_w4a16_moe.py:79 available()
# model_executor/layers/fused_moe/experts/r4d_w4a16_moe.py:81 has_w4a16_moe()
# model_executor/kernels/mhc/r4d_dsv4.py:16 r4d_mhc_enabled()
# v1/attention/ops/rocm_aiter_mla_sparse.py:694 r4d_indexer_enabled(), mqa_max_heads()
# v1/attention/ops/rocm_aiter_mla_sparse.py:1124 r4d_indexer_enabled()
# model_executor/layers/fused_moe/experts/r4d_mxfp4_moe.py:683 r4d_act_quant_enabled()
# model_executor/layers/fused_moe/experts/r4d_w4a16_moe.py _entry() moe_w4a16()/moe_w4a16_mt1() per MoE call
# (lib() is the transitive dependency of all of them.)
#
# TARGET: vllm/model_executor/kernels/r4d_lib.py (inside the pinned image)
# base sha256 b8dfeefb4acc72b67b0579a4ab0653ef5d48c40cc8649187d757c80ce6b9617a
# patched sha256 0751852e1ca813ad2bdb1da7bf10c605130e63e04ef9ec57e70b5f675a29c08e
#
# APPLY: append the block below to the module. R4D_FREEZE_PROBES=0 restores stock
# behaviour. Cost when nothing is compiled: ten one-time probes at import (measured:
# logged once per process, e.g. "libr4d capability probes frozen: 10 warm [...]").
#
# OBSERVED EFFECT: with 01+02 the worker gets past this break and reaches the next one
# (see 03) — dynamo reports "Dynamo bytecode transform time: 1.08 s" for the trunk.
--- a/vllm/model_executor/kernels/r4d_lib.py
+++ b/vllm/model_executor/kernels/r4d_lib.py
@@ -361,3 +361,71 @@
@functools.lru_cache
def moe_w4a16_mt1():
return getattr(lib(), "r4d_gemm_moe_w4a16_nt_b16_m64_mt1", None)
+
+
+# FORK-LOCAL (patch: trace-safe capability probes). torch.compile traces the
+# BODY of a functools.lru_cache call even when the cache is warm, so a capability
+# probe called from a forward pass is a dynamo graph break. Measured 2026-09-30
+# on the GLM-5.3-Flash trunk under @support_torch_compile:
+# Dynamo does not know how to trace the builtin `posix.stat`
+# r4d_mhc_supported -> r4d_mhc_enabled -> available -> os.path.exists
+# Each probe below is wrapped to resolve ONCE into a memo and is warmed at import
+# (outside any tracing region), so a traced call is a dict read. The probe set is
+# every no-argument capability probe / symbol lookup reachable from a forward
+# pass: the W4A16 + mxfp4 MoE dispatch, the mHC op, the MLA sparse indexer gate
+# and the draft/lm-head guards.
+_PROBE_NAMES = (
+ "available",
+ "lib",
+ "has_w4a16_moe",
+ "moe_w4a16",
+ "moe_w4a16_mt1",
+ "has_mhc_dsv4",
+ "r4d_mhc_enabled",
+ "r4d_indexer_enabled",
+ "mqa_max_heads",
+ "r4d_act_quant_enabled",
+)
+
+
+def _freeze_probe(fn):
+ """Return fn bound to its first result. Result must be a plain value."""
+
+ memo = []
+
+ def probe(*args, **kwargs):
+ if not memo:
+ memo.append(fn(*args, **kwargs))
+ return memo[0]
+
+ return probe
+
+
+def _freeze_probes() -> list[str]:
+ frozen = []
+ for name in _PROBE_NAMES:
+ fn = globals().get(name)
+ if fn is None:
+ continue
+ # Only the fast branch of a bound probe is traced; an empty memo means the
+ # first call still does I/O, so populate it here as well.
+ globals()[name] = _freeze_probe(fn)
+ frozen.append(name)
+ return frozen
+
+
+from vllm.logger import init_logger as _init_logger
+
+_probe_log = _init_logger(__name__)
+
+if os.environ.get("R4D_FREEZE_PROBES", "1") == "1":
+ _warmed, _failed = [], []
+ for _n in _freeze_probes():
+ try:
+ globals()[_n]()
+ _warmed.append(_n)
+ except Exception: # noqa: BLE001 -- probe unavailable here; recompute lazily
+ _failed.append(_n)
+ _probe_log.info("libr4d capability probes frozen: %d warm %s%s",
+ len(_warmed), _warmed,
+ f" (deferred: {_failed})" if _failed else "")
@@ -1,116 +0,0 @@
# glm53-compile-03-fullgraph-optout.patch — let torch.compile split the graph instead
# of failing on a break.
#
# STATUS: WIP — third of three patches in the "compile the GLM trunk" changelist.
# With 01+02+03 the model TRACES, then hits the NEXT blocker (below). The set does not
# yet boot the engine; nothing in scripts/ mounts it.
#
# THE BREAK: with 01+02 mounted the worker dies with
#
# RuntimeError: Graph breaks are not supported with aot compile.
# Please use torch.compile(fullgraph=True).
#
# because vllm/compilation/wrapper.py calls torch.compile(..., fullgraph=True, ...) and
# this build has AOT compile on by default (VLLM_USE_AOT_COMPILE). The GLM trunk cannot
# satisfy fullgraph: its indexer metadata path syncs to the host —
# models/glm5next/amd/sparse_indexer.py:263 int(positions[...].max().item()) + 1
# models/glm5next/amd/sparse_indexer.py:419 lmax = int(decode_metadata.decode_lens.max().item())
# — and a .item() inside a captured graph is not expressible.
#
# WHAT WAS VERIFIED (in-image, torch 2.11.0+rocm10.0, torch.compile(backend="eager")):
#
# fullgraph=True suppress_errors=False -> RAISE Unsupported
# fullgraph=True suppress_errors=True -> RAISE Unsupported <-- suppress_errors
# does NOT cover breaks
# fullgraph=False suppress_errors=False -> ok (traceable segments compiled by
# inductor, the rest runs eagerly;
# numerics unchanged)
#
# So breaks must be TOLERATED, not suppressed: the traceable mass still fuses. This
# patch makes the fullgraph requirement opt-out via VLLM_COMPILE_FULLGRAPH=0 (default 1,
# so every other model keeps stock behaviour), and the run also needs
# VLLM_USE_AOT_COMPILE=0 because AOT compile refuses breaks outright.
#
# TARGET: vllm/compilation/wrapper.py (inside the pinned image)
# base sha256 969961dca41c78385212e985cfc0c57ef067f4a2d7490c73c658c600a1b95cac
# patched sha256 732363bb1a1367772f13c60a655c678e7e2e9e56186677b14238f5e84cac31b3
# (the file already imports os; the patch does not add a second import)
#
# NEXT BLOCKER — NOT FIXED HERE. With 01+02+03 and
# R4D_GLM_COMPILE=1 VLLM_COMPILE_FULLGRAPH=0 VLLM_USE_AOT_COMPILE=0 the trunk traces
# ("Dynamo bytecode transform time: 1.08 s") and then the worker dies during profile_run
# on the decode forward:
#
# KeyError: 'in_proj_qkvbfg_a'
# model.py:748 Glm5NextModel.forward hidden_states, residual, post, comb = layer(...)
# model.py:456 Glm5NextDecoderLayer.forward
#
# `in_proj_qkvbfg_a` is the KDA layer's merged column-parallel projection
# (models/glm5next/common/kda.py:217, called at :407) — and it is also a name in the
# load_weights shard table (model.py:780-785), i.e. a real submodule attribute name,
# not an arbitrary string.
#
# WHERE IT IS THROWN (established 2026-09-30 by reading the full traceback, since the
# report line is inside dynamo-rewritten code): torch/_dynamo keeps a name -> module map
# and looks it up by name — output_graph.py:1259 / :1384 register into `nn_modules`,
# :3000 does `self.nn_modules[node.target]` (a bare-name KeyError when absent), and
# :1225 documents stripping "_buffers[..]/_parameters[..]/_modules[..]" from those
# names. So the compiler is failing to RESOLVE A SUBMODULE BY NAME while tracing the
# GLM decoder layer. Whether the root cause is the fork's module tree (something
# dynamo cannot register statically) or dynamo's own registration is NOT yet settled.
#
# STATUS OF THAT BLOCKER (updated 2026-09-30, after the diagnostics below):
# * TORCHDYNAMO_VERBOSE=1 adds NO internal stack trace -> it is a plain Python
# KeyError raised in a callee dynamo INLINED into Glm5NextDecoderLayer.forward
# (the reported line 456 is that method's `def` line, which is how inlined frames
# surface).
# * It is NOT raised from dynamo's four nn_modules lookups: the probe in
# /home/dank/dynamo-probe wraps three of them and stayed silent over two runs with
# the probe confirmed installed in the API-server and worker processes.
# * It is NOT a literal string lookup: the name appears tree-wide only as the KDA
# layer's attribute (kda.py:217, used at :407) and in the load_weights shard table
# (model.py:780-785). The four `proj_name = prefix.split(".")[-1]` sites in the
# quantization utils do compute exactly this bare key, but each is guarded
# (`if proj_name not in fused_mapping: return`), so none of them can raise it.
# * It reproduces on every run of this configuration (5 of 5).
# REMAINING INSTRUMENT: name the inlined caller. Either `TORCH_LOGS=+dynamo` on a boot
# and grep the log for the key (dynamo prints inlined frames with source), or wrap the
# inlining candidates in that forward — the mHC CustomOps (hc_pre / hc_post /
# hc_fused_post_pre in layers/mhc.py) and the indexer op — with a KeyError-reporting
# probe. Until that names the caller this is a dynamo-side name-resolution failure.
# NOTE the launcher gotcha that wasted a boot earlier: the scratch launcher now
# preserves ENVARGS (`ENVARGS="${ENVARGS:-}"`), but it previously reset it, silently
# dropping outer env vars.
#
# SECOND BLOCKER, KNOWN AND NOT FATAL IN THIS CONFIG: the indexer's in-forward
# workspace acquisition (patch 05 makes it compiler-opaque) and the fused-norm
# launcher cell (patch 04 removes the call) are real breaks, but with
# VLLM_COMPILE_FULLGRAPH=0 they are tolerated — they only become fatal under
# fullgraph=True, which this trunk can never satisfy anyway.
--- a/vllm/compilation/wrapper.py
+++ b/vllm/compilation/wrapper.py
@@ -19,6 +19,15 @@
logger = init_logger(__name__)
+
+# FORK-LOCAL (patch: fullgraph opt-out). torch.compile(fullgraph=True) turns any
+# dynamo graph break into a hard failure; suppress_errors does NOT cover breaks
+# (verified in-image, torch 2.11+rocm10.0). With VLLM_COMPILE_FULLGRAPH=0 the
+# traceable segments are still compiled by inductor and the untraceable ones run
+# eagerly, which is what a model with device-sync metadata (the GLM-5.3-Flash
+# indexer) needs. Default 1 keeps stock behaviour for every other model.
+_FULLGRAPH = os.environ.get("VLLM_COMPILE_FULLGRAPH", "1") == "1"
+
R = TypeVar("R")
P = ParamSpec("P")
@@ -146,7 +155,7 @@
with aot_context:
self._compiled_callable = torch.compile(
compiled_ptr,
- fullgraph=True,
+ fullgraph=_FULLGRAPH,
dynamic=False,
backend=backend,
options=options,
@@ -1,112 +0,0 @@
# glm53-compile-04-fused-qkv-rmsnorm-optout.patch — take GLM's fused-norm JIT kernel
# out of the traced path.
#
# STATUS: WIP — fourth of the "compile the GLM trunk" changelist. Needs 01 (the
# decorator) to matter; not part of the deployed shape.
#
# MEASUREMENT NOTE — read the run history before trusting any claim below, because a
# launcher bug made four consecutive boots misleading. The scratch launcher
# (serve_glm_autoround.sh) was restructured on 2026-09-30 and the
# `-e VLLM_COMPILE_FULLGRAPH=0` line was DROPPED, so those boots mounted the wrapper
# patch while `_FULLGRAPH` still defaulted to True. With fullgraph=True *every* break
# is an error, which is why the reported failure kept moving as breaks were removed:
#
# fullgraph=False, AOT off -> breaks tolerated; dies later on the dynamo name-map
# `KeyError: 'in_proj_qkvbfg_a'`
# fullgraph=True, AOT off -> dies on whichever break comes first
# (cell -> workspace -> compiler-disable)
#
# An earlier version of this note blamed `TORCH_LOGS=graph_breaks` for the difference.
# That was WRONG — the flag only logs; the launcher silently ran fullgraph=True.
# Launcher fixed; the env var is set again.
#
# What it means for THIS patch: under fullgraph=True the fused-norm call was the first
# break dynamo reported, and after this patch the report moved on to the workspace
# acquisition — so it removes a real break. It is not by itself what unblocks a boot:
# with breaks genuinely tolerated the blocker is the name-map KeyError.
#
# IT IS STILL THE RIGHT PATTERN TO REMOVE, either way: the launcher family
# (@kernel_launcher, 39 call sites) is exactly the shape that produces breaks, and the
# object this one closes over is bound after use (see below).
#
# THE BREAK (measured, TORCH_LOGS=graph_breaks, rank0, 2026-09-30). With 01+02+03 the
# trunk traces, and with AOT off + fullgraph=True dynamo reports a FATAL break — fatal
# even with VLLM_COMPILE_FULLGRAPH=0, because dynamo cannot build a resume function
# around it:
#
# torch._dynamo.exc.Unsupported: Read uninitialized cell
# Explanation: Attempted to read a cell variable that has not been populated yet.
# Developer debug context: CellVariable()
#
# User stack:
# glm5next/common/model.py:748 Glm5NextModel.forward layer(...)
# glm5next/common/model.py:526 Glm5NextDecoderLayer.forward
# glm5next/common/attention.py:590
# model_executor/layers/mla.py:208
# q_proj_input, kv_c_normed = fused_q_kv_rmsnorm(...)
# -> torch/_dynamo/polyfills/__init__.py:392 getattr_and_trace
#
# So dynamo is tracing the fork's JIT-kernel launcher framework
# (VllmTritonJitKernel + @kernel_launcher — 39 call sites) and finds a closure name
# that is not yet bound. In models/common/ops/fused_qk_rmsnorm.py the kernel object
# is bound AFTER the function that closes over it:
#
# def fused_q_kv_rmsnorm(...): # line 194
# ...
# _FUSED_Q_KV_RMSNORM_KERNEL(...) # line 211
# _FUSED_Q_KV_RMSNORM_KERNEL = FusedQKVRMSNormKernel() # line 223
#
# Two ways out, and this patch takes the cheap one:
# (a) WORKAROUND (here): stop calling the fused kernel from the traced region. The
# flag is already a constructor argument (mla.py:80, default False) that glm5next
# turns ON at attention.py:583, so it becomes an env knob.
# (b) ROOT FIX (not done): make the launcher trace-safe for all 39 sites. Any other
# @kernel_launcher-backed kernel still in the traced path can produce the same
# fatal break, so (b) is the durable fix and (a) is what unblocks iterating.
#
# COST of R4D_GLM_FUSE_QKV_RMSNORM=0: one extra small RMSNorm launch per MLA layer per
# token (q_a_layernorm + kv_a_layernorm instead of one fused kernel) — ~2 launches out
# of ~3,700 per token on this shape, against the 44 % of device time currently spent in
# unfused pointwise work. Correct trade for a compile experiment.
#
# TARGET: vllm/models/glm5next/common/attention.py (inside the pinned image)
# base sha256 362bbf0e6c50cdfa446c6551b9b1ddff425e08f88e3d4140fd8db88f8a619e0c
# patched sha256 84969993c250e9f8183a33a6e1ddd607272f48b950b560272eb33b7cdde31189
#
# APPLY: `patch -p1` from the vllm package root (verified: applies clean, reproduces
# the patched sha256), or bind-mount new/.../attention.py over site-packages.
# R4D_GLM_FUSE_QKV_RMSNORM=1 (default) restores the fused kernel.
--- a/vllm/models/glm5next/common/attention.py
+++ b/vllm/models/glm5next/common/attention.py
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+import os
import torch
import torch.nn.functional as F
from torch import nn
@@ -36,6 +37,14 @@
from vllm.utils.deep_gemm import PAGED_MQA_PAGE_SIZES
from vllm.v1.kv_cache_interface import KpoolTailSpec, MLAAttentionSpec
+
+# FORK-LOCAL (patch: keep the traced region trace-safe). R4D_GLM_FUSE_QKV_RMSNORM=0
+# replaces the fused `fused_q_kv_rmsnorm` JIT-kernel launch (one kernel) with two plain
+# RMSNorms, because dynamo cannot trace the launcher's closure ("Read uninitialized cell",
+# fatal even with fullgraph=False). The fused kernel object in
+# models/common/ops/fused_qk_rmsnorm.py is bound AFTER the function that uses it.
+_FUSE_QKV_RMSNORM = os.environ.get("R4D_GLM_FUSE_QKV_RMSNORM", "1") == "1"
+
logger = init_logger(__name__)
# Shared torch.compile config for the indexer's small-kernel leaves. The MLA
@@ -580,7 +589,7 @@
quant_config,
prefix,
skip_topk=False,
- fuse_qkv_rmsnorm=True,
+ fuse_qkv_rmsnorm=_FUSE_QKV_RMSNORM,
)
def forward(
@@ -1,96 +0,0 @@
# glm53-compile-05-workspace-acquisition-opaque.patch — the indexer's workspace
# acquisition must not be traced.
#
# STATUS: WIP — fifth of the "compile the GLM trunk" changelist. This is a break worth
# removing, but NOT part of the confirmed blocker and NOT required for the
# break-tolerant route: `torch.compiler.disable` is itself a graph break, so this patch
# helps only a hypothetical fullgraph=True route, where the workspace acquisition would
# be better RESTRUCTURED out of the forward (allocate at init/profile time) than made
# opaque. Kept because the discovery is real and the wrapper is a no-op when nothing is
# compiling.
#
# THE BREAK — CONFIRMED with AND WITHOUT the logging flag (2026-09-30):
#
# torch._dynamo.exc.Unsupported: Unsupported method call
# user stack
# glm5next/common/model.py:748 Glm5NextModel.forward
# glm5next/common/model.py:526 Glm5NextDecoderLayer.forward
# glm5next/common/attention.py:599 / :401
# glm5next/amd/sparse_indexer.py:711 forward_native
# glm5next/amd/sparse_indexer.py:132 sparse_attn_indexer_kpool
# v1/worker/workspace.py:135 get_simultaneous
# v1/worker/workspace.py:155 _ensure_workspace_size
#
# Line 132 is the PROFILE-RUN branch — the source comment says what it is:
#
# if not isinstance(attn_metadata, dict):
# # Reserve workspace for indexer during profiling run
#
# so the engine dies exactly where it sizes its memory. `_ensure_workspace_size`
# reads ContextVar and ubatch state (`_workspace_lane.get()`,
# `dbo_current_ubatch_id()`) and may allocate; none of that is traceable.
#
# FIX: the documented escape hatch — `torch.compiler.disable` around a thin wrapper,
# so dynamo stops analysing the call and executes it eagerly from inside the compiled
# region. Chosen over decorating the workspace manager's own methods because that
# would change workspace behaviour for every model, not just this one. The wrapper is
# a no-op when nothing is compiling, so it needs no env gate and is safe in the
# stock (uncompiled) path — which is why this patch can be reasoned about
# independently of whether the changelist ever ships.
#
# CAVEAT, stated plainly: a compiler-disabled region is still a graph break, so this
# needs patch 03 (VLLM_COMPILE_FULLGRAPH=0) to be tolerable at all, and whether dynamo
# can build a resume function around it is exactly what the next boot decides — the
# difference between this failure and 04's `Read uninitialized cell`.
#
# TARGET: vllm/models/glm5next/amd/sparse_indexer.py (inside the pinned image)
# base sha256 f773a99265d3f440c8fad22c8fb5a8b33f926162094cffc3a2ecdaa42763c5b1
# patched sha256 c154115afe8253a35c652b7198929ef4c1874957d333fdb3340cacf88e612b78
#
# APPLY: `patch -p1` from the vllm package root, or bind-mount over site-packages.
--- a/vllm/models/glm5next/amd/sparse_indexer.py
+++ b/vllm/models/glm5next/amd/sparse_indexer.py
@@ -35,6 +35,21 @@
from vllm.v1.attention.ops.common import pack_seq_triton, unpack_seq_triton
from vllm.v1.worker.workspace import current_workspace_manager
+@torch.compiler.disable
+def _acquire_workspace(*shapes_and_dtypes):
+ """Acquire shared workspace buffers, untraced.
+
+ Workspace acquisition reads ContextVar/ubatch state and may allocate
+ (v1/worker/workspace.py: ``_ensure_workspace_size`` -> ``_workspace_lane.get()``,
+ ``dbo_current_ubatch_id()``), which dynamo cannot trace -- it raises
+ ``Unsupported: Unsupported method call`` and kills the engine during the profile
+ run. ``torch.compiler.disable`` keeps the call as an opaque, eagerly-executed
+ region inside the compiled graph. No-op when not compiling.
+ """
+
+ return current_workspace_manager().get_simultaneous(*shapes_and_dtypes)
+
+
logger = init_logger(__name__)
# kpool write helper: form pools from the current token batch and compress them
@@ -129,7 +144,7 @@
values_spec, scales_spec = _gather_workspace_shapes(
total_seq_lens, head_dim, fp8_dtype, use_fp4_cache
)
- current_workspace_manager().get_simultaneous(
+ _acquire_workspace(
values_spec,
scales_spec,
((RADIX_TOPK_WORKSPACE_SIZE,), torch.uint8),
@@ -275,11 +290,10 @@
# Layout switches between FP8 (head_dim bytes + 4-byte fp32 scale) and
# MXFP4 (head_dim/2 bytes packed + head_dim/MXFP4_BLOCK_SIZE ue8m0
# scales) based on use_fp4_cache.
- workspace_manager = current_workspace_manager()
values_spec, scales_spec = _gather_workspace_shapes(
total_seq_lens, head_dim, fp8_dtype, use_fp4_cache
)
- k_quant_full, k_scale_full = workspace_manager.get_simultaneous(
+ k_quant_full, k_scale_full = _acquire_workspace(
values_spec,
scales_spec,
)
@@ -1,173 +0,0 @@
# glm53-dflash-01-aux-hidden-states.patch — give the GLM trunk the EAGLE3 / DFlash2
# auxiliary-hidden-state interface.
#
# STATUS: WIP, and INDEPENDENT of the compile changelist (01-05). It patches the same
# file as glm53-compile-01-model-decorator.patch, so if both are ever applied, apply 01
# first. Not part of the deployed shape.
#
# WHY. `--speculative-config '{"method":"dflash","model":"/path/to/drafter", ...}'` with
# incoai/GLM-5.3-Flash-DFlash2 is accepted -- the config parses, the drafter loads -- and
# then the worker dies at init with:
#
# RuntimeError: Model does not support EAGLE3 interface
#
# raised by v1/worker/gpu/spec_decode/eagle/eagle3_utils.py:20 through
# `supports_eagle3(model)` == `isinstance(model, SupportsEagle3)` (a runtime-checkable
# Protocol). Nothing about the drafter, the method name or the payload is wrong:
# `glm5next` implements neither half of the contract.
#
# THE CONTRACT (read out of this image, not assumed):
# * DEFINITIVE (v5, after four failed boots): the member set is checked against
# the INSTANCE the runner calls -- which for GLM-5.3-Flash is
# Glm5NextForConditionalGeneration, NOT Glm5NextForCausalLM. The multimodal
# wrapper wraps the text model as self.language_model
# (init_vllm_registered_model, architectures=["Glm5NextForCausalLM"],
# glm5next/common/model.py:1113), and model_runner.py:425 calls
# set_eagle3_aux_hidden_state_layers(self.model) on the CONDITIONAL.
# Presence rule (verified in-image with stock typing, python 3.14):
# isinstance walks ALL __protocol_attrs__ -- members of SupportsEagle3 AND
# its base SupportsEagleBase (has_own_lm_head, has_own_embed_tokens) -- and
# getattr_static()s each one off the instance, breaking on the first miss.
# So BOTH wrappers need all three attributes.
# * MRO law: INHERITING SupportsEagle3 as a base dies at class creation with
# "Cannot create a consistent method resolution order (MRO) for bases ..."
# (GLM's wrappers already carry four/five Protocol-derived bases). The
# attribute route avoids the MRO entirely. Also proven by simulation:
# the four-base ForCausalLM ordering cannot take SupportsEagle3 in ANY
# position.
# * Method placement: without inheriting, the Protocol's default
# set_aux_hidden_state_layers / get_eagle3_default_aux_hidden_state_layers
# do not exist on either wrapper, so both are defined explicitly --
# ForCausalLM unwraps to self.model (the EagleModelMixin trunk, v1's
# correct bodies), the conditional delegates to self.language_model
# (the pixtral.py:498 pattern).
# * interfaces.py:1673-1719 -- for reference, the inherited defaults unwrap
# `holder = getattr(parent_ref, "model", parent_ref)` and assert
# `isinstance(holder, EagleModelMixin)`, defaulting to (2, L // 2, L - 3).
# * eagle3_utils.py:26-31 -- layers come from the spec config when it carries them
# (this drafter ships target_layer_ids [5, 14, 24, 33, 42]), else from the default.
# * deepseek_v2.py:1551-1585 and v1/worker/gpu/cudagraph_utils.py:621 -- the trunk
# appends one tensor per aux layer while looping and returns the TUPLE
# (hidden_states, aux_hidden_states) only when that list is non-empty.
#
# WHAT IT ADDS for GLM: EagleModelMixin on Glm5NextModel; aux capture in the layer
# loop, where the aux state is the single-stream `hidden_states` ENTERING that layer
# (GLM's loop carries the already-summed single-stream state -- its non-mHC branch
# re-splits `residual = hidden_states` inside the layer -- so this is the equivalent
# of deepseek_v2's `hidden_states + residual`); the conditional tuple return; the
# three SupportsEagle3 member attributes on BOTH wrappers; and the two interface
# methods on both (ForCausalLM: unwrap to trunk; conditional: delegate to
# language_model).
#
# HONEST SCOPE: this makes the interface PRESENT, which is exactly what the engine
# checks -- it does not make drafts GOOD, and per the measured profile it is not a
# decode-speed fix (a verification pass pays pointwise cost proportional to its token
# count, which is why MTP4 measured flat). It is the prerequisite for driving DFlash2
# on this endpoint at all.
#
# TARGET: vllm/models/glm5next/common/model.py (inside the pinned image)
# base sha256 008eeaa2a07e091465319f86900b5a321d921238723ccbe22e6b9e971e1d2b28
# patched sha256 bc9913a15c8e7f88e0c36b07bf029b383cc8c2078b58d66dc91aa235f8af77f4
#
# APPLY: `patch -p1` from the vllm package root, or bind-mount the patched file over
# site-packages (which is what a scratch-launcher DFLASH_PATCH=1 does).
--- a/vllm/models/glm5next/common/model.py
+++ b/vllm/models/glm5next/common/model.py
@@ -95,6 +95,12 @@
logger = init_logger(__name__)
+from vllm.model_executor.models.interfaces import (
+ EagleModelMixin,
+ SupportsEagle3,
+)
+
+
class Glm5NextMLP(nn.Module):
def __init__(
self,
@@ -623,7 +629,7 @@
)
-class Glm5NextModel(nn.Module):
+class Glm5NextModel(nn.Module, EagleModelMixin):
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
@@ -730,7 +736,14 @@
if self.is_sequence_parallel:
hidden_states = sp_shard(hidden_states)
- for layer in self._active_layers:
+ aux_hidden_states: list[torch.Tensor] = []
+ for local_idx, layer in enumerate(self._active_layers):
+ if (getattr(self, "start_layer", 0) + local_idx) in self.aux_hidden_state_layers:
+ # Auxiliary hidden state for EAGLE-3 / DFlash2 drafts: the
+ # single-stream state ENTERING this layer, before its input norm --
+ # the same semantic deepseek_v2 implements by appending
+ # hidden_states + residual at this point in its loop.
+ aux_hidden_states.append(hidden_states)
hidden_states, residual, post, comb = layer(
positions, hidden_states, residual, post, comb
)
@@ -749,6 +762,8 @@
hidden_states = sp_all_gather(hidden_states)[:full_num_tokens]
hidden_states = self.norm(hidden_states)
+ if len(aux_hidden_states) > 0:
+ return hidden_states, aux_hidden_states
return hidden_states
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
@@ -937,6 +952,10 @@
class Glm5NextForCausalLM(
nn.Module, HasInnerState, SupportsPP, MixtureOfExperts, IsHybrid
):
+ supports_eagle3: ClassVar[Literal[True]] = True
+ has_own_lm_head: bool = False
+ has_own_embed_tokens: bool = False
+
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
self.model_config = vllm_config.model_config
@@ -963,6 +982,14 @@
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
return self.model.embed_input_ids(input_ids)
+ def set_aux_hidden_state_layers(self, layers: tuple[int, ...]) -> None:
+ # EAGLE-3 / DFlash2 interface: unwrap to the trunk (the EagleModelMixin).
+ self.model.aux_hidden_state_layers = layers
+
+ def get_eagle3_default_aux_hidden_state_layers(self) -> tuple[int, ...]:
+ num_layers = len(self.model.layers)
+ return (2, num_layers // 2, num_layers - 3)
+
def forward(
self,
input_ids: torch.Tensor | None,
@@ -1033,6 +1060,10 @@
class Glm5NextForConditionalGeneration(
Glm4vForConditionalGeneration, HasInnerState, IsHybrid, MixtureOfExperts
):
+ supports_eagle3: ClassVar[Literal[True]] = True
+ has_own_lm_head: bool = False
+ has_own_embed_tokens: bool = False
+
# The text model (KDA + dense-MLA + MoE) is a hybrid mamba model. The
# multimodal wrapper must declare the same interfaces so vLLM treats it as
# hybrid (auto-aligns mamba/attention block sizes, sizes the mamba state
@@ -1076,6 +1107,13 @@
return Glm5NextForCausalLM.get_mamba_state_copy_func()
+ def set_aux_hidden_state_layers(self, layers: tuple[int, ...]) -> None:
+ # EAGLE-3 / DFlash2: delegate to the language model (pixtral.py:498).
+ self.language_model.set_aux_hidden_state_layers(layers)
+
+ def get_eagle3_default_aux_hidden_state_layers(self) -> tuple[int, ...]:
+ return self.language_model.get_eagle3_default_aux_hidden_state_layers()
+
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super(Glm4vForConditionalGeneration, self).__init__()
config = vllm_config.model_config.hf_config
@@ -1,317 +0,0 @@
--- a/vllm/v1/core/kv_cache_utils.py
+++ b/vllm/v1/core/kv_cache_utils.py
@@ -1210,17 +1210,38 @@
for name, spec in kv_cache_spec.items()
if isinstance(spec, KpoolTailSpec)
}
+ hidden_specs = {
+ name: spec
+ for name, spec in kv_cache_spec.items()
+ if isinstance(spec, HiddenStateCacheSpec)
+ }
attn_specs = {
name: spec
for name, spec in kv_cache_spec.items()
- if not isinstance(spec, (MambaSpec, KpoolTailSpec))
+ if not isinstance(
+ spec, (MambaSpec, KpoolTailSpec, HiddenStateCacheSpec)
+ )
}
- if not mamba_specs or not all(
- type(spec) is MLAAttentionSpec for spec in attn_specs.values()
- ):
+ if not mamba_specs or not attn_specs:
+ return None
+ # FORK-LOCAL (patches/glm53-dflash-04-kv-groups-draft): with a dflash
+ # drafter, attn_specs also carries the drafter's attention layers. The
+ # GLM MLA layers keep their own group (no page unification -- per-group
+ # pages are the glm5 design); the drafter layers must form one uniform
+ # group of their own, and the hidden-state layers get per-layer groups
+ # like the generic tail.
+ mla_specs = {
+ name: spec
+ for name, spec in attn_specs.items()
+ if type(spec) is MLAAttentionSpec
+ }
+ draft_specs = {
+ name: spec for name, spec in attn_specs.items() if name not in mla_specs
+ }
+ if not mla_specs:
+ return None
+ if draft_specs and UniformTypeKVCacheSpecs.from_specs(draft_specs) is None:
return None
-
- mla_specs = cast(dict[str, MLAAttentionSpec], attn_specs)
idx_pages = {
spec.page_size_bytes for spec in mla_specs.values() if spec.tokens_per_state > 1
}
@@ -1233,7 +1254,7 @@
mla_pages = {mla_specs[name].page_size_bytes for name in mla_names}
assert len(mla_pages) == 1
mla_page = mla_pages.pop()
- uniform_spec = UniformTypeKVCacheSpecs.from_specs(attn_specs)
+ uniform_spec = UniformTypeKVCacheSpecs.from_specs(mla_specs)
assert uniform_spec is not None
tail_group: KVCacheGroupSpec | None = None
@@ -1270,10 +1291,20 @@
for index, name in enumerate(mamba_specs):
mamba_grouped_names[index % num_groups].append(name)
+ draft_group: KVCacheGroupSpec | None = None
+ if draft_specs:
+ draft_uniform = UniformTypeKVCacheSpecs.from_specs(draft_specs)
+ assert draft_uniform is not None
+ draft_group = KVCacheGroupSpec(list(draft_specs), draft_uniform)
+ hidden_groups = [
+ KVCacheGroupSpec([name], spec) for name, spec in hidden_specs.items()
+ ]
return (
- [KVCacheGroupSpec(list(attn_specs), uniform_spec)]
+ [KVCacheGroupSpec(list(mla_specs), uniform_spec)]
+ ([tail_group] if tail_group is not None else [])
+ + ([draft_group] if draft_group is not None else [])
+ create_kv_cache_group_specs(padded_specs, mamba_grouped_names)
+ + hidden_groups
)
@@ -1289,10 +1320,17 @@
int,
list[str],
int,
+ KVCacheGroupSpec | None,
+ list[KVCacheGroupSpec],
]
| None
):
- """Recognize the GLM-5.3-Flash grouping after optional PP projection."""
+ """Recognize the GLM-5.3-Flash grouping after optional PP projection.
+
+ FORK-LOCAL (patches/glm53-dflash-04-kv-groups-draft): also recognizes the
+ drafter's uniform group and the per-layer hidden-state groups, returned
+ as ``draft_group`` / ``hidden_groups`` so the pool builder and the memory
+ estimator can size their tensors beside the aliased GLM pool."""
uniform_groups = [
group
for group in kv_cache_groups
@@ -1301,17 +1339,28 @@
mamba_groups = [
group for group in kv_cache_groups if isinstance(group.kv_cache_spec, MambaSpec)
]
+ hidden_groups = [
+ group
+ for group in kv_cache_groups
+ if isinstance(group.kv_cache_spec, HiddenStateCacheSpec)
+ ]
attn_group: KVCacheGroupSpec | None = None
tail_group: KVCacheGroupSpec | None = None
+ draft_group: KVCacheGroupSpec | None = None
for group in uniform_groups:
inner = cast(UniformTypeKVCacheSpecs, group.kv_cache_spec).kv_cache_specs
if all(type(spec) is MLAAttentionSpec for spec in inner.values()):
attn_group = group
elif all(isinstance(spec, KpoolTailSpec) for spec in inner.values()):
tail_group = group
+ else:
+ draft_group = group
if attn_group is None or not mamba_groups:
return None
- if len(uniform_groups) + len(mamba_groups) != len(kv_cache_groups):
+ if (
+ len(uniform_groups) + len(mamba_groups) + len(hidden_groups)
+ != len(kv_cache_groups)
+ ):
return None
attn_uniform = cast(UniformTypeKVCacheSpecs, attn_group.kv_cache_spec)
@@ -1362,6 +1411,8 @@
idx_page,
tail_names,
tail_page,
+ draft_group,
+ hidden_groups,
)
@@ -1395,10 +1446,34 @@
return kv_cache_spec
max_page_size = max(page_sizes)
+ # GLM-5.3-Flash + a draft model: the sparse indexer k_cache page is
+ # tokens_per_state x the MLA page (same per-token bytes), and MLA layers
+ # cannot be PADDED to an arbitrary page size (sparse MLA indexes the cache
+ # in whole token rows) -- but they CAN grow their block size when the max
+ # page is an exact multiple of the MLA page. Bump the max page to a
+ # multiple of every MLA-family page so all of them take the divisible
+ # block-growth branch below instead of the padding refusal, while the
+ # draft and mamba layers simply pad to the (slightly larger) page.
+ mla_pages = {
+ spec.page_size_bytes
+ for spec in kv_cache_spec.values()
+ if isinstance(spec, MLAAttentionSpec)
+ }
+ if mla_pages:
+ mla_common = math.lcm(*mla_pages)
+ max_page_size = -(-max_page_size // mla_common) * mla_common
new_kv_cache_spec = {}
for layer_name, layer_spec in kv_cache_spec.items():
if layer_spec.page_size_bytes == max_page_size:
new_kv_cache_spec[layer_name] = layer_spec
+ elif isinstance(layer_spec, KpoolTailSpec):
+ # The kpool tail kernel pins the tail block to the kpool size
+ # (tail_kv_cache.view(...) must be [blocks, 2, index_kpool,
+ # head_dim]); growing block_size breaks that contract. Pad the
+ # physical page instead -- the tail is its own group and the pad
+ # wastes bytes only.
+ new_spec = replace(layer_spec, page_size_padded=max_page_size)
+ new_kv_cache_spec[layer_name] = new_spec
elif isinstance(layer_spec, MambaSpec):
# MambaSpec's page size is determined by its state shapes and does
# not scale with block_size, so pad the page instead. This is the
@@ -1593,8 +1668,31 @@
) -> int:
"""Return the largest cache group's bytes per block."""
if (glm5_layout := _glm5_next_tensor_layout(kv_cache_groups)) is not None:
- _, _, mla_names, idx_names, mla_page, idx_page, _, _ = glm5_layout
- return len(mla_names) * mla_page + len(idx_names) * idx_page
+ (
+ _,
+ _,
+ mla_names,
+ idx_names,
+ mla_page,
+ idx_page,
+ _,
+ _,
+ draft_group,
+ hidden_groups,
+ ) = glm5_layout
+ bytes_per_block = len(mla_names) * mla_page + len(idx_names) * idx_page
+ if draft_group is not None:
+ d_inner = cast(
+ UniformTypeKVCacheSpecs, draft_group.kv_cache_spec
+ ).kv_cache_specs
+ bytes_per_block += sum(
+ spec.page_size_bytes for spec in d_inner.values()
+ )
+ for group in hidden_groups:
+ bytes_per_block += (
+ group.kv_cache_spec.page_size_bytes * len(group.layer_names)
+ )
+ return bytes_per_block
bytes_per_block = max(
sum(
@@ -1701,12 +1799,30 @@
idx_page,
tail_names,
_,
+ draft_group,
+ hidden_groups,
) = glm5_layout
bytes_per_block = len(mla_names) * mla_page + len(idx_names) * idx_page
+ extra_bytes_per_block = 0
+ if draft_group is not None:
+ d_inner = cast(
+ UniformTypeKVCacheSpecs, draft_group.kv_cache_spec
+ ).kv_cache_specs
+ extra_bytes_per_block += sum(
+ spec.page_size_bytes for spec in d_inner.values()
+ )
+ for group in hidden_groups:
+ extra_bytes_per_block += (
+ group.kv_cache_spec.page_size_bytes * len(group.layer_names)
+ )
num_blocks = may_override_num_blocks(
- vllm_config, available_memory // bytes_per_block
+ vllm_config,
+ available_memory // (bytes_per_block + extra_bytes_per_block),
)
- size = bytes_per_block * num_blocks
+ # One backing allocation shared by every tensor (allocate_kv_cache
+ # asserts all tensor sizes equal): the aliased GLM pool plus the
+ # draft/hidden regions appended after it.
+ size = (bytes_per_block + extra_bytes_per_block) * num_blocks
attn_specs = cast(
UniformTypeKVCacheSpecs, attn_group.kv_cache_spec
).kv_cache_specs
@@ -1745,6 +1861,37 @@
).kv_cache_specs
add_tensor(tail_name, tail_specs[tail_name], offset)
+ # Draft + hidden-state layers get their own tensors appended after
+ # the aliased pool (no aliasing: independent block tables).
+ offset_cursor = bytes_per_block * num_blocks
+ if draft_group is not None:
+ d_inner = cast(
+ UniformTypeKVCacheSpecs, draft_group.kv_cache_spec
+ ).kv_cache_specs
+ d_page = next(iter(d_inner.values())).page_size_bytes
+ kv_cache_tensors.append(
+ KVCacheTensor(
+ size=size,
+ layers=list(draft_group.layer_names),
+ layer_stride=d_page * num_blocks,
+ block_stride=d_page,
+ offset=offset_cursor,
+ )
+ )
+ offset_cursor += d_page * num_blocks * len(draft_group.layer_names)
+ for group in hidden_groups:
+ h_page = group.kv_cache_spec.page_size_bytes
+ kv_cache_tensors.append(
+ KVCacheTensor(
+ size=size,
+ layers=list(group.layer_names),
+ layer_stride=h_page * num_blocks,
+ block_stride=h_page,
+ offset=offset_cursor,
+ )
+ )
+ offset_cursor += h_page * num_blocks * len(group.layer_names)
+
return KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=kv_cache_tensors,
@@ -2440,6 +2587,8 @@
idx_page,
tail_names,
_,
+ draft_group,
+ hidden_groups,
) = glm5_layout
uniform_spec = cast(UniformTypeKVCacheSpecs, attn_group.kv_cache_spec)
total_blocks = uniform_spec.max_memory_usage_pages(vllm_config)
@@ -2452,7 +2601,30 @@
)
if tail_names:
total_blocks += 1
- return total_blocks * (len(mla_names) * mla_page + len(idx_names) * idx_page)
+ pool_bytes = total_blocks * (
+ len(mla_names) * mla_page + len(idx_names) * idx_page
+ )
+ extra_bytes = 0
+ if draft_group is not None:
+ d_inner = cast(
+ UniformTypeKVCacheSpecs, draft_group.kv_cache_spec
+ ).kv_cache_specs
+ d_spec = next(iter(d_inner.values()))
+ d_blocks = cdiv(
+ d_spec.max_memory_usage_bytes(vllm_config), d_spec.page_size_bytes
+ )
+ extra_bytes += d_blocks * d_spec.page_size_bytes * len(
+ draft_group.layer_names
+ )
+ for group in hidden_groups:
+ h_spec = group.kv_cache_spec
+ h_blocks = cdiv(
+ h_spec.max_memory_usage_bytes(vllm_config), h_spec.page_size_bytes
+ )
+ extra_bytes += (
+ h_blocks * h_spec.page_size_bytes * len(group.layer_names)
+ )
+ return pool_bytes + extra_bytes
bytes_per_block = _pool_bytes_per_block(kv_cache_groups)
total_blocks = 0
@@ -1,31 +0,0 @@
--- a/vllm/model_executor/offloader/expert_activation_registry.py
+++ b/vllm/model_executor/offloader/expert_activation_registry.py
@@ -316,7 +316,27 @@
def _glm53_kv_bytes_per_token(cfg: "VllmConfig") -> int:
- return 13600
+ per_tok = 13600
+ # FORK-LOCAL (patches/glm53-dflash-04-kv-groups-draft): with a dflash/dspark
+ # drafter, the KV spec set gains the drafter's attention layers and the aux
+ # hidden-state cache (one bf16 hidden vector per target aux layer per
+ # token). Both are full-length per-token costs the aliased glm5 pool
+ # cannot absorb, so the reserve must include them. The drafter is a 5-layer
+ # qwen3 (8 kv heads x 128 x K+V x bf16 = 4096 B/token); the aux count is
+ # the dflash target_layer_ids count (5).
+ # NOTE: use_aux_hidden_state_outputs lives on the gpu_model_runner
+ # instance (gpu_model_runner.py:623/678), not on the spec config -- it is
+ # not set yet when the expert plan runs. The spec-config METHOD is the
+ # plan-time signal for the same fact.
+ sc = getattr(cfg, "speculative_config", None)
+ if sc is not None and getattr(sc, "method", "") in (
+ "dflash",
+ "dspark",
+ "extract_hidden_states",
+ ):
+ hidden = int(cfg.model_config.hf_config.hidden_size)
+ per_tok += 5 * hidden * 2 + 4096
+ return per_tok
for _arch in ("Glm5NextForConditionalGeneration", "Glm5NextForCausalLM"):
-65
View File
@@ -1,65 +0,0 @@
# gpu_worker-kv-cap.patch — fork-local fix for the expert-offload KV cap.
#
# STATUS: NOT part of the deployed frank-primary shape. It exists because the
# fork's plan under-models this model's KV, and it is the enabler for a future
# MTP attempt (the production GLM shape runs without MTP and boots fine on the
# plan's own reserve).
#
# THE BUG: the plan models KV as 13600 B/token + a small mamba term, but
# GLM-5.3-Flash also carries a per-sequence KDA state (34 linear-attention
# layers) and, under MTP, the draft model's KV. gpu_worker then CAPS the KV
# cache at that reserve, so the plan's own arithmetic can make a legal
# --max-model-len unbootable. Measured 2026-09-30 on the pinned image:
# - no MTP, max_model_len 131072: reserve 1.74 GiB >= need 1.86 GiB -> boots
# - MTP4, max_model_len 131072: reserve 1.74 GiB < need 2.07 GiB -> refuses
# - MTP4, max_model_len 262144: reserve 3.43 GiB < need 3.62 GiB -> refuses
# ("To serve at least one request with the model's max seq len ...")
# Raising max_model_len does not fix it: the cap's slope (~13,845 B/token) only
# overtakes the need's (~12,700 B/token + 0.52 GiB fixed) around 440k tokens,
# where the KV reserve would eat a third of the expert arena.
#
# THE FIX: let an explicit operator KV request raise the cap. The compile and
# graph-capture headroom still comes off the top, so the blast radius is one
# flag the operator chose.
#
# APPLY (into a host dir that is then bind-mounted read-only over the image):
# IMAGE=docker.io/tcclaviger/vllm@sha256:da4029666668b90a0386c1ed23ea370c79916ad4e1a6ad1dd9db098b9fa069ea
# D=/home/dank/glm-patch; mkdir -p $D/vllm/v1/worker
# podman run --rm --entrypoint cat $IMAGE \
# /opt/vllm/lib/python3.14/site-packages/vllm/v1/worker/gpu_worker.py \
# > $D/vllm/v1/worker/gpu_worker.py
# ( cd $D && patch -p1 < .../scripts/patches/gpu_worker-kv-cap.patch )
# # then add to the podman run:
# # -v $D/vllm/v1/worker/gpu_worker.py:/opt/vllm/lib/python3.14/site-packages/vllm/v1/worker/gpu_worker.py:ro
# # -e CLAV_KV_RESERVE_FLOOR_GIB=<needed> (or pass --kv-cache-memory-bytes)
#
# Base file sha256 (the pristine file out of the pinned image):
# 1f3942b1a01af075317161b06077a2e658086f1da0566c9f297129ceebe37dd7
# Patched file sha256 (verified 2026-09-30, patch applies clean and reproduces
# these bytes exactly):
# 80cd160bbc01270b0851f7b6fe7552b06ae2fae2428134fc29238d7f452af840
#
--- a/vllm/v1/worker/gpu_worker.py
+++ b/vllm/v1/worker/gpu_worker.py
@@ -824,6 +824,21 @@
expert_cfg = self.vllm_config.offload_config.expert
if expert_cfg.enable_expert_offload and expert_cfg.planned_kv_reserve_bytes > 0:
cap = int(expert_cfg.planned_kv_reserve_bytes)
+ # LOCAL PATCH (GLM-5.3 KDA + MTP draft): the plan models KV as
+ # 13600 B/token + a small mamba term, but this model also carries a
+ # per-sequence KDA state and (under MTP) the draft's KV, so the
+ # reserve can sit BELOW what max_model_len legitimately needs -- and
+ # the cap then makes a legal config unbootable (measured: need
+ # 2.07 GiB vs cap 1.74 GiB at max_model_len 131072 with MTP). An
+ # explicit --kv-cache-memory-bytes, or CLAV_KV_RESERVE_FLOOR_GIB,
+ # is the operator's word: let it raise the cap. The compile/capture
+ # headroom still comes off the top.
+ _explicit = self.cache_config.kv_cache_memory_bytes
+ _floor = os.environ.get("CLAV_KV_RESERVE_FLOOR_GIB")
+ if _explicit:
+ cap = max(cap, int(_explicit))
+ if _floor:
+ cap = max(cap, int(float(_floor) * (1 << 30)))
if self.available_kv_cache_memory_bytes > cap:
logger.info_once(
"expert offload: KV capped at the plan's reserve %s GiB; %s GiB of "
-453
View File
@@ -1,453 +0,0 @@
{%- set template_version = "qwen3.8-froggeric-v22.4.0" %}
{%- set _tool_format = tool_call_format if tool_call_format is defined else 'xml' %}
{%- set image_count = namespace(value=0) %}
{%- set video_count = namespace(value=0) %}
{%- set add_vision_id = add_vision_id if add_vision_id is defined else false %}
{%- set enable_thinking = enable_thinking if enable_thinking is defined else true %}
{%- set auto_disable_thinking_with_tools = auto_disable_thinking_with_tools if auto_disable_thinking_with_tools is defined else false %}
{%- if preserve_reasoning is defined and preserve_reasoning is not none %}
{%- set _preserve_thinking = preserve_reasoning %}
{%- elif preserve_thinking is defined and preserve_thinking is not none %}
{%- set _preserve_thinking = preserve_thinking %}
{%- else %}
{%- set _preserve_thinking = true %}
{%- endif %}
{%- set max_tool_arg_chars = max_tool_arg_chars if max_tool_arg_chars is defined else 0 %}
{%- set max_tool_response_chars = max_tool_response_chars if max_tool_response_chars is defined else 0 %}
{%- set _default_reasoning_effort = 'medium' %}
{%- set _has_tools = (tools is defined and tools and tools is iterable and tools is not mapping) %}
{%- set _effort_raw = (reasoning_effort | string | lower) if reasoning_effort is defined and reasoning_effort is not none else _default_reasoning_effort %}
{%- set _initial_thinking = enable_thinking %}
{%- if _effort_raw in ('none', 'off') %}
{%- set _initial_thinking = false %}
{%- set _initial_effort = 'medium' %}
{%- elif _effort_raw in ('minimal', 'low') %}
{%- set _initial_effort = 'low' %}
{%- elif _effort_raw in ('high', 'xhigh', 'max', 'ultracode', 'extreme') %}
{%- set _initial_effort = 'xhigh' %}
{%- else %}
{%- set _initial_effort = 'medium' %}
{%- endif %}
{%- set ns_state = namespace(thinking=_initial_thinking, effort=_initial_effort) %}
{%- if auto_disable_thinking_with_tools and _has_tools %}
{%- set ns_state.thinking = false %}
{%- endif %}
{%- for msg in messages %}
{%- if msg.role == 'system' or msg.role == 'developer' or msg.role == 'user' %}
{%- if msg.content is string %}
{%- if '<|think_off|>' in msg.content %}
{%- set ns_state.thinking = false %}
{%- elif '<|think_on|>' in msg.content %}
{%- set ns_state.thinking = true %}
{%- elif '<|think_xhigh|>' in msg.content or '<|think_high|>' in msg.content or '<|think_ultracode|>' in msg.content or '<|think_extreme|>' in msg.content or '<|think_max|>' in msg.content %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'xhigh' %}
{%- elif '<|think_low|>' in msg.content or '<|think_minimal|>' in msg.content %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'low' %}
{%- elif '<|think_medium|>' in msg.content %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'medium' %}
{%- endif %}
{%- elif msg.content is iterable and msg.content is not mapping %}
{%- for item in msg.content %}
{%- if item is string %}
{%- set _item_text = item %}
{%- elif item is mapping and 'text' in item and item.text is string %}
{%- set _item_text = item.text %}
{%- else %}
{%- set _item_text = '' %}
{%- endif %}
{%- if _item_text %}
{%- if '<|think_off|>' in _item_text %}
{%- set ns_state.thinking = false %}
{%- elif '<|think_on|>' in _item_text %}
{%- set ns_state.thinking = true %}
{%- elif '<|think_xhigh|>' in _item_text or '<|think_high|>' in _item_text or '<|think_ultracode|>' in _item_text or '<|think_extreme|>' in _item_text or '<|think_max|>' in _item_text %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'xhigh' %}
{%- elif '<|think_low|>' in _item_text or '<|think_minimal|>' in _item_text %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'low' %}
{%- elif '<|think_medium|>' in _item_text %}
{%- set ns_state.thinking = true %}
{%- set ns_state.effort = 'medium' %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- set reasoning_instructions = '' %}
{%- if ns_state.thinking %}
{%- if ns_state.effort == 'xhigh' %}
{%- set reasoning_instructions = 'Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.' %}
{%- elif ns_state.effort == 'low' %}
{%- set reasoning_instructions = 'Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the conclusion without unnecessary elaboration.' %}
{%- endif %}
{%- endif %}
{%- macro render_content(content, do_vision_count, is_system_content=false) %}
{%- if content is string %}
{{- content }}
{%- elif content is iterable and content is not mapping %}
{%- for item in content %}
{%- if item is mapping %}
{%- if item.type == 'image' or 'image' in item or 'image_url' in item %}
{%- if is_system_content %}
{{- raise_exception('System message cannot contain images.') }}
{%- endif %}
{%- if do_vision_count %}
{%- set image_count.value = image_count.value + 1 %}
{%- endif %}
{%- if add_vision_id %}
{{- 'Picture ' ~ image_count.value ~ ': ' }}
{%- endif %}
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
{%- elif item.type == 'video' or 'video' in item %}
{%- if is_system_content %}
{{- raise_exception('System message cannot contain videos.') }}
{%- endif %}
{%- if do_vision_count %}
{%- set video_count.value = video_count.value + 1 %}
{%- endif %}
{%- if add_vision_id %}
{{- 'Video ' ~ video_count.value ~ ': ' }}
{%- endif %}
{{- '<|vision_start|><|video_pad|><|vision_end|>' }}
{%- elif 'text' in item %}
{{- item.text }}
{%- else %}
{{- raise_exception('Unexpected item type in content.') }}
{%- endif %}
{%- else %}
{{- item | string }}
{%- endif %}
{%- endfor %}
{%- elif content is none or content is undefined %}
{{- '' }}
{%- else %}
{{- raise_exception('Unexpected content type.') }}
{%- endif %}
{%- endmacro %}
{%- if not messages %}
{{- raise_exception('No messages provided.') }}
{%- endif %}
{%- set head = namespace(count=0, seen_non_system=false) %}
{%- for message in messages %}
{%- set _is_sys = (message.role == 'system' or message.role == 'developer') %}
{%- if _is_sys and not head.seen_non_system %}
{%- set head.count = head.count + 1 %}
{%- else %}
{%- set head.seen_non_system = true %}
{%- endif %}
{%- endfor %}
{%- set sys_state = namespace(content='') %}
{%- for message in messages[:head.count] %}
{%- set _part = render_content(message.content, false, true) | trim %}
{%- if '<|think_off|>' in _part %}{%- set _part = _part.split('<|think_off|>') | join('') | trim %}{%- endif %}
{%- if '<|think_on|>' in _part %}{%- set _part = _part.split('<|think_on|>') | join('') | trim %}{%- endif %}
{%- if '<|think_xhigh|>' in _part %}{%- set _part = _part.split('<|think_xhigh|>') | join('') | trim %}{%- endif %}
{%- if '<|think_high|>' in _part %}{%- set _part = _part.split('<|think_high|>') | join('') | trim %}{%- endif %}
{%- if '<|think_ultracode|>' in _part %}{%- set _part = _part.split('<|think_ultracode|>') | join('') | trim %}{%- endif %}
{%- if '<|think_extreme|>' in _part %}{%- set _part = _part.split('<|think_extreme|>') | join('') | trim %}{%- endif %}
{%- if '<|think_max|>' in _part %}{%- set _part = _part.split('<|think_max|>') | join('') | trim %}{%- endif %}
{%- if '<|think_medium|>' in _part %}{%- set _part = _part.split('<|think_medium|>') | join('') | trim %}{%- endif %}
{%- if '<|think_low|>' in _part %}{%- set _part = _part.split('<|think_low|>') | join('') | trim %}{%- endif %}
{%- if '<|think_minimal|>' in _part %}{%- set _part = _part.split('<|think_minimal|>') | join('') | trim %}{%- endif %}
{%- if _part %}
{%- if sys_state.content %}
{%- set sys_state.content = sys_state.content ~ '\n\n' ~ _part %}
{%- else %}
{%- set sys_state.content = _part %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- set _sc = sys_state.content %}
{%- set _msgs = messages[head.count:] %}
{%- if ns_state.thinking %}
{%- set _terse_lead = 'Answer directly, after thinking. Lead with the answer, then only what it needs to be correct and usable.' %}
{%- else %}
{%- set _terse_lead = 'Answer directly and concisely. Give the answer with only what it needs to be correct and usable.' %}
{%- endif %}
{%- set _terse_core %}
Never: open with preamble or pleasantries; restate the question; add filler transitions; hedge with niceties; or repeat a point you've already made.
Always: keep essential steps, caveats, uncertainties, and specifics — never drop correctness or a needed warning for brevity. Keep the final answer lean. Use the least structure that conveys it (plain prose when short; lists or code only when they earn their place). If genuinely uncertain, say so and explain why — never omit uncertainty for the sake of brevity.
If a user request is genuinely ambiguous, ask a sharp question, don't guess.
{%- endset %}
{%- set _terse = _terse_lead ~ '\n' ~ (_terse_core | trim) %}
{#- `terse` chat-template kwarg: default true, so behaviour is unchanged unless a caller opts out.
Pass {"terse": false} via chat_template_kwargs to serve the model with only its own system
prompt -- useful for A/B-ing the terseness effect, or when a downstream prompt conflicts. #}
{%- set _terse_on = terse if terse is defined else true %}
{%- if _terse_on %}
{%- if not _sc %}
{%- set _sc = _terse | trim %}
{%- else %}
{%- set _sc = (_sc | trim) ~ '\n\n' ~ (_terse | trim) %}
{%- endif %}
{%- endif %}
{%- if _has_tools %}
{{- '<|im_start|>system\n' }}
{%- if reasoning_instructions %}
{{- reasoning_instructions + '\n\n' }}
{%- endif %}
{{- '# Tools\n\nYou have access to the following functions:\n\n<tools>' }}
{%- for tool in tools %}
{{- '\n' }}
{{- tool | tojson }}
{%- endfor %}
{{- '\n</tools>' }}
{%- if _tool_format == 'json' %}
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}{%- if ns_state.thinking %}{{- '<think>\nBrief explanation of tool call\n</think>\n' }}{%- endif %}{{- '<tool_call>\n{"name": "example_function_name", "arguments": {"example_parameter_1": "value_1", "example_parameter_2": "This is the value for the second parameter"}}\n</tool_call>\n\n<IMPORTANT>\nReminder:\n' }}{%- if ns_state.thinking %}{{- '- You can use the <think></think> block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the <think></think> block.\n' }}{%- endif %}{{- '- Function calls MUST follow the specified format: a single JSON object with "name" and "arguments" keys inside <tool_call></tool_call> XML tags.\n- If you choose to call a tool, you MUST output the <tool_call> block IMMEDIATELY' }}{%- if ns_state.thinking %}{{- ' after thinking' }}{%- endif %}{{- ', with NO conversational text before it.\n- The <tool_call> tag MUST be at the very beginning of a new line, with NO spaces or indentation before it.\n- To call multiple functions, output a separate, completely closed <tool_call></tool_call> block for EACH function. Do NOT nest <tool_call> blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n</IMPORTANT>' }}
{%- else %}
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}{%- if ns_state.thinking %}{{- '<think>\nBrief explanation of tool call\n</think>\n' }}{%- endif %}{{- '<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n' }}{%- if ns_state.thinking %}{{- '- You can use the <think></think> block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the <think></think> block.\n' }}{%- endif %}{{- '- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags.\n- If you choose to call a tool, you MUST output the <tool_call> block IMMEDIATELY' }}{%- if ns_state.thinking %}{{- ' after thinking' }}{%- endif %}{{- ', with NO conversational text before it.\n- The <tool_call> and <function> tags MUST be at the very beginning of a new line, with NO spaces or indentation before them.\n- To call multiple functions, output a separate, completely closed <tool_call></tool_call> block for EACH function. Do NOT nest <tool_call> blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n</IMPORTANT>' }}
{%- endif %}
{%- if _sc %}
{{- '\n\n' + _sc }}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- else %}
{%- if _sc %}
{{- '<|im_start|>system\n' + (reasoning_instructions + '\n\n' if reasoning_instructions else '') + _sc + '<|im_end|>\n' }}
{%- elif reasoning_instructions %}
{{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- set _last_idx = _msgs | length - 1 %}
{%- set ns = namespace(multi_step_tool=true, last_query_index=_last_idx) %}
{%- for message in _msgs[::-1] %}
{%- set index = (_msgs | length - 1) - loop.index0 %}
{%- if ns.multi_step_tool and message.role == 'user' %}
{%- set _rc = render_content(message.content, false) | trim %}
{%- if not (_rc.startswith('<tool_response>') and _rc.endswith('</tool_response>')) %}
{%- set ns.multi_step_tool = false %}
{%- set ns.last_query_index = index %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- if ns.multi_step_tool %}
{%- if _last_idx > 50 %}
{%- set ns.last_query_index = _last_idx %}
{%- else %}
{%- set ns.last_query_index = 0 %}
{%- endif %}
{%- endif %}
{%- set ns2 = namespace(prev_role='', consecutive_failures=0) %}
{%- for message in _msgs %}
{%- set is_system = (message.role == "system" or message.role == "developer") %}
{%- set content = render_content(message.content, true, is_system) | trim %}
{%- if is_system or message.role == 'user' %}
{%- if '<|think_off|>' in content %}{%- set content = content.split('<|think_off|>') | join('') | trim %}{%- endif %}
{%- if '<|think_on|>' in content %}{%- set content = content.split('<|think_on|>') | join('') | trim %}{%- endif %}
{%- if '<|think_xhigh|>' in content %}{%- set content = content.split('<|think_xhigh|>') | join('') | trim %}{%- endif %}
{%- if '<|think_high|>' in content %}{%- set content = content.split('<|think_high|>') | join('') | trim %}{%- endif %}
{%- if '<|think_ultracode|>' in content %}{%- set content = content.split('<|think_ultracode|>') | join('') | trim %}{%- endif %}
{%- if '<|think_extreme|>' in content %}{%- set content = content.split('<|think_extreme|>') | join('') | trim %}{%- endif %}
{%- if '<|think_max|>' in content %}{%- set content = content.split('<|think_max|>') | join('') | trim %}{%- endif %}
{%- if '<|think_medium|>' in content %}{%- set content = content.split('<|think_medium|>') | join('') | trim %}{%- endif %}
{%- if '<|think_low|>' in content %}{%- set content = content.split('<|think_low|>') | join('') | trim %}{%- endif %}
{%- if '<|think_minimal|>' in content %}{%- set content = content.split('<|think_minimal|>') | join('') | trim %}{%- endif %}
{%- endif %}
{%- if is_system %}
{{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
{%- elif message.role == 'user' %}
{%- set ns2.consecutive_failures = 0 %}
{{- '<|im_start|>user\n' + content + '<|im_end|>\n' }}
{%- elif message.role == 'assistant' %}
{%- set reasoning_content = '' %}
{%- set _explicit_reasoning = '' %}
{%- if message.reasoning_content is defined and message.reasoning_content is not none %}
{%- if message.reasoning_content is string %}
{%- set _explicit_reasoning = message.reasoning_content %}
{%- else %}
{%- set _explicit_reasoning = message.reasoning_content | string %}
{%- endif %}
{%- elif message.thinking is defined and message.thinking is not none %}
{%- if message.thinking is string %}
{%- set _explicit_reasoning = message.thinking %}
{%- else %}
{%- set _explicit_reasoning = message.thinking | string %}
{%- endif %}
{%- elif message.reasoning is defined and message.reasoning is not none %}
{%- if message.reasoning is string %}
{%- set _explicit_reasoning = message.reasoning %}
{%- else %}
{%- set _explicit_reasoning = message.reasoning | string %}
{%- endif %}
{%- endif %}
{%- if _explicit_reasoning %}
{%- set _lead_end = '' %}
{%- if content.startswith('<think>') and '</think>' in content %}
{%- set _lead_end = '</think>' %}
{%- elif content.startswith('<thinking>') and '</thinking>' in content %}
{%- set _lead_end = '</thinking>' %}
{%- elif content.startswith('</think>') %}
{%- set _lead_end = '</think>' %}
{%- elif content.startswith('</thinking>') %}
{%- set _lead_end = '</thinking>' %}
{%- endif %}
{%- if _lead_end %}
{%- set content = content.split(_lead_end)[-1].lstrip('\n') %}
{%- endif %}
{%- set reasoning_content = _explicit_reasoning %}
{%- else %}
{%- set _think_end = '' %}
{%- if content.startswith('</think>') %}
{%- set _think_end = '</think>' %}
{%- elif content.startswith('</thinking>') %}
{%- set _think_end = '</thinking>' %}
{%- elif '\n</think>' in content %}
{%- set _think_end = '\n</think>' %}
{%- elif '\n</thinking>' in content %}
{%- set _think_end = '\n</thinking>' %}
{%- elif '\n</ think>' in content %}
{%- set _think_end = '\n</ think>' %}
{%- elif '\n</think >' in content %}
{%- set _think_end = '\n</think >' %}
{%- elif content.startswith('<think>') and '</think>' in content %}
{%- set _think_end = '</think>' %}
{%- elif content.startswith('<thinking>') and '</thinking>' in content %}
{%- set _think_end = '</thinking>' %}
{%- endif %}
{%- if _think_end %}
{%- if 'thinking' in _think_end %}
{%- set _think_start = '<thinking>' %}
{%- else %}
{%- set _think_start = '<think>' %}
{%- endif %}
{%- set reasoning_content = content.split(_think_end)[0].rstrip('\n') %}
{%- if _think_start in reasoning_content %}
{%- set reasoning_content = reasoning_content.split(_think_start)[-1].lstrip('\n') %}
{%- endif %}
{%- set content = content.split(_think_end)[-1].lstrip('\n') %}
{%- endif %}
{%- endif %}
{%- set reasoning_content = reasoning_content | trim %}
{%- if (_preserve_thinking or loop.index0 > ns.last_query_index) %}
{{- '<|im_start|>assistant\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
{%- else %}
{{- '<|im_start|>assistant\n' + content }}
{%- endif %}
{%- if message.tool_calls is defined and message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
{%- for tool_call in message.tool_calls %}
{%- if tool_call.function is defined and tool_call.function is not none %}
{%- set tc = tool_call.function %}
{%- else %}
{%- set tc = tool_call %}
{%- endif %}
{%- set tc_name = tc.name if (tc.name is defined and tc.name is not none) else '' %}
{%- if _tool_format == 'json' %}
{%- if loop.first %}
{%- if content | trim %}
{{- '\n\n' }}
{%- endif %}
{%- else %}
{{- '\n' }}
{%- endif %}
{%- set _args = '{}' %}
{%- if tc.arguments is defined and tc.arguments is not none %}
{%- if tc.arguments is mapping %}
{%- set _args = tc.arguments | tojson %}
{%- elif tc.arguments is string %}
{%- if tc.arguments %}
{%- set _args = tc.arguments %}
{%- endif %}
{%- else %}
{%- set _args = tc.arguments | tojson %}
{%- endif %}
{%- endif %}
{{- '<tool_call>\n{"name": ' }}{{- tc_name | tojson }}{{- ', "arguments": ' }}{{- _args }}{{- '}\n</tool_call>' }}
{%- else %}
{%- if loop.first %}
{%- if content | trim %}
{{- '\n\n<tool_call>\n<function=' + tc_name + '>\n' }}
{%- else %}
{{- '<tool_call>\n<function=' + tc_name + '>\n' }}
{%- endif %}
{%- else %}
{{- '\n<tool_call>\n<function=' + tc_name + '>\n' }}
{%- endif %}
{%- if tc.arguments is defined and tc.arguments is not none %}
{%- if tc.arguments is mapping %}
{%- for args_name, args_value in tc.arguments.items() %}
{{- '<parameter=' + args_name + '>\n' }}
{%- if args_value is string %}
{%- set _av = args_value %}
{%- else %}
{%- set _av = args_value | tojson %}
{%- endif %}
{%- if max_tool_arg_chars > 0 and _av | length > max_tool_arg_chars %}
{{- _av[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_av | length | string) ~ ' chars]' }}
{%- else %}
{{- _av }}
{%- endif %}
{{- '\n</parameter>\n' }}
{%- endfor %}
{%- else %}
{%- if tc.arguments is string %}
{%- set _raw_args = tc.arguments %}
{%- else %}
{%- set _raw_args = tc.arguments | tojson %}
{%- endif %}
{%- if _raw_args %}
{%- if max_tool_arg_chars > 0 and _raw_args | length > max_tool_arg_chars %}
{{- _raw_args[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_raw_args | length | string) ~ ' chars]' }}
{%- else %}
{{- _raw_args }}
{%- endif %}
{%- endif %}
{%- endif %}
{%- endif %}
{{- '</function>\n</tool_call>' }}
{%- endif %}
{%- endfor %}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- elif message.role == 'tool' %}
{%- set _content_lower = content | lower %}
{%- set _content_head = _content_lower[:120] %}
{%- set _is_code_or_grep = ('throw new ' in _content_lower or 'throw error' in _content_lower or 'console.error' in _content_lower or 'logger.error' in _content_lower or 'logging.error' in _content_lower or 'import ' in _content_head or 'def ' in _content_head or 'function ' in _content_head) %}
{%- set _exit_code_zero = ('exit code: 0' in _content_head or 'process exited with code 0' in _content_head) %}
{%- set _error_field_ok = ('"error": null' in _content_head or '"error":null' in _content_head or '"error": false' in _content_head or '"error":false' in _content_head or '"error": ""' in _content_head or '"error":""' in _content_head) %}
{%- set _strong_error = (('"error":' in _content_head and not _error_field_ok) or '"status": "error"' in _content_head or '"status":"error"' in _content_head or 'traceback (most recent call last):' in _content_head or 'command not found' in _content_head or 'invalid syntax' in _content_head or 'fatal:' in _content_head or (('exit code: ' in _content_head or 'process exited with code' in _content_head) and not _exit_code_zero) or _content_head.startswith('exception:') or _content_head.startswith('failed to ')) %}
{%- set _weak_error = ('error:' in _content_head or 'err!' in _content_head) %}
{%- set _weak_suppressed = ('$ ' in _content_head or 'took ' in _content_head or content | length >= 600) %}
{%- if not _is_code_or_grep and (_strong_error or (_weak_error and not _weak_suppressed)) %}
{%- set ns2.consecutive_failures = ns2.consecutive_failures + 1 %}
{%- else %}
{%- set ns2.consecutive_failures = 0 %}
{%- endif %}
{%- if ns2.prev_role != 'tool' %}
{{- '<|im_start|>user' }}
{%- endif %}
{%- if _tool_format != 'json' and max_tool_response_chars > 0 and content | length > max_tool_response_chars %}
{%- set content = content[:max_tool_response_chars] + '\n[TRUNCATED - original length ' ~ (content | length | string) ~ ' chars]' %}
{%- endif %}
{{- '\n<tool_response>\n' + content }}
{%- if ns2.consecutive_failures >= 2 %}
{{- '\n\n⚠️ SYSTEM WARNING: ' ~ ns2.consecutive_failures ~ ' consecutive tool errors detected. Your previous approach is incorrect. You MUST use a fundamentally different approach or corrected arguments.' }}
{%- elif ns2.consecutive_failures == 1 %}
{{- '\n\n⚠️ SYSTEM WARNING: The previous tool call returned an error. Diagnose the failure and retry with completely corrected arguments.' }}
{%- endif %}
{{- '\n</tool_response>' }}
{%- if loop.last %}
{{- '<|im_end|>\n' }}
{%- else %}
{%- set _next_role = _msgs[loop.index0 + 1].role %}
{%- if _next_role != 'tool' %}
{{- '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- else %}
{{- '<|im_start|>user\n[' + message.role + ']: ' + content + '<|im_end|>\n' }}
{%- endif %}
{%- set ns2.prev_role = message.role %}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant\n' }}
{%- if not ns_state.thinking %}
{{- '<think>\n\n</think>\n\n' }}
{%- else %}
{{- '<think>\n' }}
{%- endif %}
{%- endif %}
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""Render ${VAR} placeholders in the live SearXNG settings.yml from the
host-side env file.
Why this exists: SearXNG does NOT expand environment variables inside
settings.yml. The repo-tracked shared/searxng/settings.yml deliberately keeps
secrets as "${KAGI_API_KEY}"-style placeholders so no key ever enters git; the
deployer copies that file to /opt/searxng/config/settings.yml and runs this
script to bake in the real values. Without it the app runs with the literal
placeholder strings (observed 2026-09-26: secret_key literally
"${SEARXNG_SECRET}" and the Kagi engine failing setup() on an empty key).
Only the allowlisted names are substituted. A placeholder that has no value in
the env file is a deploy-blocking error: serving a paid-API engine with a
literal "${KAGI_API_KEY}" bearer would waste requests and hide the misconfig.
Usage: searxng-render-settings.py ENV_FILE SETTINGS_FILE
Both paths are taken as arguments; secrets never appear in any argv.
Exit codes: 0 rendered (idempotent on an already-rendered file), 1 unresolved
placeholder, 2 usage error.
"""
import sys
ALLOWED = ("KAGI_API_KEY", "SEARXNG_SECRET", "SEARXNG_BASE_URL")
def load_env(path: str) -> dict[str, str]:
values: dict[str, str] = {}
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
if key in ALLOWED:
values[key] = value
return values
def main(argv: list[str]) -> int:
args = [a for a in argv[1:]]
if len(args) != 2:
print(
"usage: searxng-render-settings.py ENV_FILE SETTINGS_FILE",
file=sys.stderr,
)
return 2
env_file, settings_file = args
values = load_env(env_file)
with open(settings_file, encoding="utf-8") as fh:
text = fh.read()
for name in ALLOWED:
if name in values:
text = text.replace("${%s}" % name, values[name])
unresolved = [n for n in ALLOWED if "${%s}" % n in text]
if unresolved:
print(
"unresolved placeholders in %s: %s (missing from %s)"
% (settings_file, ", ".join(unresolved), env_file),
file=sys.stderr,
)
return 1
with open(settings_file, "w", encoding="utf-8") as fh:
fh.write(text)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
-121
View File
@@ -1,121 +0,0 @@
#!/usr/bin/env bash
# Serve Swift-1.5-Qwen3.8-27B-Uncensored (abliterated) as frank-large on
# Dankclaw's Strix Halo iGPU (gfx1151), using gufo (gufo-org/gufo) — the
# inference engine built specifically for this hardware.
#
# Runtime: /home/dank/GitPackages/gufo-v0.1.1/bin/gufo, built from source
# against the host ROCm by scripts/build-gufo.sh. It replaced the stock
# llama.cpp prebuilt (unsloth b11030) on 2026-09-28; port, alias and client
# contract did not move.
#
# Payload: ajgazin/Swift-1.5-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF's
# UD-Q8_K_XL, fetched by scripts/download-frank-large-swift.sh. It replaced
# unsloth/Qwen3.8-27B-GGUF:UD-Q8_K_XL on 2026-09-28. The two are structurally
# interchangeable for this engine — same `qwen35` architecture, the same 866
# tensors in the same order, the same per-tensor Q8_0/F32/BF16 layout, the
# same MTP head at blk.64 — so the swap needed no engine change. The fine-tune
# is abliterated (uncensored) and carries the SWIFT OPEN LICENSE 1.0, which
# is not an OSI licence.
#
# Shape: one session, full 262144 context (the model's native window). The
# 27B dense weights (16.35 GiB at UD-Q4_K_XL) + the DFlash2 drafter (1.07) + KV +
# compute buffers fit the iGPU's GTT aperture (~114 GiB); a load at this
# context measured 66.5 GiB of the 116.7 GiB device.
#
# Spec method: DFlash2 (a block-diffusion drafter), gufo's adaptive block
# policy, capped at DRAFT_TOKENS proposals per step. The drafter is the
# z-lab Qwen3.8-27B-DFlash2-Q4_K_M and it has a sliding window of 2048
# (small draft KV). It is UNCHANGED by the model swap and still lives beside
# the previous target, which is why DRAFTER_DIR is its own tunable.
#
# Sampling: greedy (--temperature 0) server-side, as every frank-large has
# served. The harness overrides it per request via extraBody (1.0 / 0.95 /
# top-k 20).
#
# Template: gufo compiles the official Qwen Jinja into the binary and takes
# NO --chat-template-file — and it is not a Jinja engine at all
# (src/models/qwen/chat_template.cpp is a hand-written C++ renderer), so the
# fleet's scripts/qwen-sharp-v22.4.0.jinja cannot be supplied to it. That law
# was given up when this endpoint moved to gufo on 2026-09-28, by decision:
# the terse system-prompt injection, the in-band <|think_*|> control, the
# json|xml tool_call_format switch and the tool-argument truncation are all
# absent. gufo's own renderer keeps the effort instruction strings and
# preserve_thinking, which are the parts the harness depends on. Accepted
# 2026-09-28; recovering the terse prompt from the client side is the
# cheapest partial restore.
#
# Text-only: gufo auto-discovers a mmproj beside the model and this payload
# has none (the base repo ships one separately, unused), so the endpoint
# stays text-only.
#
# Tunables come from frank-large.env unless FRANK_LARGE_ENV_FILE overrides
# it; anything not set there falls through to the defaults below.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_LARGE_ENV_FILE:-$SCRIPT_DIR/frank-large.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
source "$ENV_FILE"
set +a
fi
# --- defaults (used when the env file does not set them) ------------------
: "${RUNTIME_DIR:=/home/dank/GitPackages/gufo-v0.1.1}"
: "${MODEL_DIR:=/home/dank/models/Swift-1.5-Qwen3.8-27B-Uncensored-MTP-UD-Q8_K_XL}"
: "${ALIAS:=frank-large}"
: "${HOST:=0.0.0.0}"
: "${PORT:=7770}"
: "${CONTEXT_SIZE:=262144}"
: "${SESSIONS:=1}"
: "${DRAFT_TOKENS:=7}"
: "${DRAFT_POLICY:=adaptive}"
GUFO_BIN="$RUNTIME_DIR/bin/gufo"
# The target GGUF and the drafter are named explicitly because they do not
# share a directory any more: the target is a different repository's artifact
# and keeps its own long filename, while the DFlash2 drafter is unchanged and
# still lives beside the previous target. MODEL_FILE/DRAFTER_FILE default to
# the deployed payload, so the env file only has to state them when it moves.
: "${MODEL_FILE:=$MODEL_DIR/Qwen3.8-27B-UD-Q4_K_XL.gguf}"
: "${DRAFTER_DIR:=/home/dank/models/Qwen3.8-27B-UD-Q8_K_XL}"
: "${DRAFTER_FILE:=$DRAFTER_DIR/Qwen3.8-27B-DFlash2-Q4_K_M.gguf}"
if [[ ! -x "$GUFO_BIN" ]]; then
echo "runtime not found at $GUFO_BIN" >&2
echo "frank-large: build it: bash scripts/build-gufo.sh" >&2
exit 1
fi
for f in "$MODEL_FILE" "$DRAFTER_FILE"; do
if [[ ! -f "$f" ]]; then
echo "missing asset $f (run the matching scripts/download-frank-large*.sh)" >&2
exit 1
fi
done
# --- env -------------------------------------------------------------------
# gufo links against the host ROCm through its RPATH (/opt/rocm ->
# /etc/alternatives/rocm), so no LD_LIBRARY_PATH is needed. The device is
# already gfx1151 — `gufo diagnose` reports it natively — so no
# HSA_OVERRIDE_GFX_VERSION either; the override existed for the prebuilt
# llama.cpp runtime and can only misreport the arch here.
export HIP_VISIBLE_DEVICES=0
# --- the serve command ---------------------------------------------------
# Server options precede the subcommand, model options follow it.
exec "$GUFO_BIN" serve \
--host "$HOST" \
--port "$PORT" \
--sessions "$SESSIONS" \
llm \
--model "$MODEL_FILE" \
--served-model-name "$ALIAS" \
--context "$CONTEXT_SIZE" \
--speculative dflash2 \
--dflash-model "$DRAFTER_FILE" \
--draft-tokens "$DRAFT_TOKENS" \
--draft-policy "$DRAFT_POLICY" \
--temperature 0 \
--think auto \
--preserve-thinking on
@@ -161,13 +161,8 @@ TEMPLATE_ARGS+=(--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS")
PLACEMENT_ARGS=(--device "$DEVICES" --split-mode "$SPLIT_MODE" --tensor-split "$TENSOR_SPLIT")
MMPROJ_ARGS=()
if [[ "$NO_MMPROJ" == "1" ]]; then
MMPROJ_ARGS=(--no-mmproj)
else
# No autoload in this build: a server without the flag starts text-only
# and answers image requests with a 500, so name the projector.
MMPROJ_ARGS=(--mmproj "$MM_PROJ")
fi
[[ "$NO_MMPROJ" == "1" ]] && MMPROJ_ARGS=(--no-mmproj)
exec "$LLAMA_BIN" \
--model "$MODEL" \
--alias "$ALIAS" \
-133
View File
@@ -1,133 +0,0 @@
#!/usr/bin/env bash
# serve-frank-primary.sh — frank-primary endpoint (tcclaviger RDNA4 vLLM fork,
# orcarouter Qwen3.8-Flash-Next-Uncensored-FP8 + MTP4 spec decode, 2x R9700,
# dankserver :7770; 2026-09-29 cutover from the R9V radiance fork — git tag
# frank-primary-r9v-q8-20260929 is the pre-cutover state).
#
# The engine is driven by `podman run` DIRECTLY: the image's entrypoint execs
# `vllm serve "$@"`, so the run line below IS the engine argv. This replaces
# the R9V launcher/shim machinery (that fork needed a docker->podman shim and
# a 104-key on-host setup state; this stack is fully expressed by flags, and
# every flag here was qualified by boot probe on 2026-09-29 — see
# frank-primary.env for the qualification record).
#
# Shape (2026-10-01 bf16 cutover): MAXLEN 262144 x 3 sessions (786,432
# tokens), KV UNQUANTIZED (--kv-cache-dtype auto = bf16; the fp8 q8/q8 shape
# is retired to git tag frank-primary-fp8-mtp-20261001), KV_BYTES
# 11,047,010,304 PER RANK (the engine measured 798,280 tokens of capacity /
# 3.05x concurrency), NO speculative decode, --max-num-batched-tokens 1024
# (Frank's lock), MoE gather geometry R4D_LRU_GATHER_CHUNKS=32 /
# R4D_LRU_GATHER_LANES=64 (+18.7% decode, grows with depth), prefix caching,
# expert prefetch OFF (36% precision measured), TunableOp off.
#
# The root unit (scripts/frank-primary.service, User=dank,
# LimitMEMLOCK=infinity) grants the pin budget for the tiered-expert host
# slabs (~20.5 GiB/rank of pinned anonymous memory). A plain shell session
# also works, but the unit is the fleet-parity boot-persistent shape.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_PRIMARY_ENV_FILE:-$SCRIPT_DIR/frank-primary.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "serve-frank-primary: env file missing: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${FRANK_PRIMARY_IMAGE:?FRANK_PRIMARY_IMAGE must name the digest-pinned image}"
: "${FRANK_PRIMARY_MODEL_DIR:?FRANK_PRIMARY_MODEL_DIR must name the FP8 payload}"
: "${FRANK_PRIMARY_MODEL_SHARDS:?FRANK_PRIMARY_MODEL_SHARDS must give the shard count}"
: "${FRANK_PRIMARY_CONTAINER:?FRANK_PRIMARY_CONTAINER must name the container}"
: "${FRANK_PRIMARY_SERVED_NAME:?FRANK_PRIMARY_SERVED_NAME must name the alias}"
: "${FRANK_PRIMARY_PUBLISH:?FRANK_PRIMARY_PUBLISH must be host:container}"
: "${FRANK_PRIMARY_MAXLEN:?FRANK_PRIMARY_MAXLEN must be set}"
: "${FRANK_PRIMARY_SEQS:?FRANK_PRIMARY_SEQS must be set}"
: "${FRANK_PRIMARY_KV_BYTES:?FRANK_PRIMARY_KV_BYTES must be set (PER RANK)}"
: "${FRANK_PRIMARY_KV_DTYPE:?FRANK_PRIMARY_KV_DTYPE must be set (auto = bf16 KV)}"
: "${FRANK_PRIMARY_REASONING_PARSER:?FRANK_PRIMARY_REASONING_PARSER must be set (qwen3)}"
: "${FRANK_PRIMARY_TOOL_PARSER:?FRANK_PRIMARY_TOOL_PARSER must be set (qwen3_xml)}"
: "${FRANK_PRIMARY_CHAT_TEMPLATE_KWARGS:?FRANK_PRIMARY_CHAT_TEMPLATE_KWARGS must be set}"
# Capture podman's output BEFORE grepping: under `set -o pipefail`, the
# classic `podman images | grep -q` pattern races — grep exits on its first
# match, podman dies SIGPIPE (141) writing the remaining lines, and pipefail
# turns the 141 into "no match" (the 2026-09-30 deploy refusal).
DIGESTS="$(podman images --format '{{.RepoDigests}}')"
if ! grep -qF "${FRANK_PRIMARY_IMAGE#*@}" <<<"$DIGESTS"; then
echo "serve-frank-primary: pinned image absent from rootless storage: $FRANK_PRIMARY_IMAGE" >&2
echo " fetch it: podman pull $FRANK_PRIMARY_IMAGE" >&2
exit 1
fi
if [ ! -f "$FRANK_PRIMARY_MODEL_DIR/config.json" ]; then
echo "serve-frank-primary: payload config missing: $FRANK_PRIMARY_MODEL_DIR/config.json" >&2
exit 1
fi
SHARDS="$(find "$FRANK_PRIMARY_MODEL_DIR" -maxdepth 1 -name 'model-*-of-*.safetensors' | wc -l)"
if [ "$SHARDS" != "$FRANK_PRIMARY_MODEL_SHARDS" ]; then
echo "serve-frank-primary: payload shard count wrong: $FRANK_PRIMARY_MODEL_DIR" >&2
echo " found $SHARDS safetensors, want $FRANK_PRIMARY_MODEL_SHARDS" >&2
echo " re-fetch: scripts/download-frank-primary.sh" >&2
exit 1
fi
if [ ! -f "$FRANK_PRIMARY_MODEL_DIR/model.safetensors.index.json" ]; then
echo "serve-frank-primary: weight-map index missing: $FRANK_PRIMARY_MODEL_DIR/model.safetensors.index.json" >&2
exit 1
fi
if [ ! -e /dev/kfd ] || [ ! -d /dev/dri ]; then
echo "serve-frank-primary: GPU device nodes absent (/dev/kfd, /dev/dri)" >&2
exit 1
fi
# A crashed/killed unit can leave the container name registered (systemd
# SIGKILLs conmon before cleanup runs). Clear OUR name plus the legacy
# identities (the retired R9V container and the ad hoc experiment container
# both hold these GPUs); all idempotent.
for CTR in $FRANK_PRIMARY_LEGACY_CONTAINERS "$FRANK_PRIMARY_CONTAINER"; do
podman rm -f "$CTR" >/dev/null 2>&1 || true
done
LOG=/home/dank/logs/frank_primary.log
mkdir -p "$(dirname "$LOG")"
# --device /dev/dri is the WHOLE DIRECTORY (individual renderD nodes enumerate
# without the card nodes and HIP refuses contexts — "No CUDA GPUs are
# available" while device_count() says 2). --ipc=host for the engine's shared
# memory; memlock unbounded for the pinned host slabs.
# The entrypoint already execs `vllm serve "$@"`, so no `serve` here.
podman run -d --name "$FRANK_PRIMARY_CONTAINER" \
--publish "$FRANK_PRIMARY_PUBLISH" \
--ipc=host --ulimit memlock=-1:-1 \
--cap-add SYS_PTRACE \
--device /dev/kfd --device /dev/dri --device /dev/mem \
-e HIP_VISIBLE_DEVICES=0,1 -e VLLM_ROCM_USE_AITER=0 \
${FRANK_PRIMARY_GATHER_CHUNKS:+-e R4D_LRU_GATHER_CHUNKS="$FRANK_PRIMARY_GATHER_CHUNKS"} \
${FRANK_PRIMARY_GATHER_LANES:+-e R4D_LRU_GATHER_LANES="$FRANK_PRIMARY_GATHER_LANES"} \
-v "$FRANK_PRIMARY_MODEL_DIR":/models:ro \
"$FRANK_PRIMARY_IMAGE" /models \
--served-model-name "$FRANK_PRIMARY_SERVED_NAME" --port 7770 \
--tensor-parallel-size 2 \
${FRANK_PRIMARY_DCP:+--decode-context-parallel-size "$FRANK_PRIMARY_DCP"} \
--max-model-len "$FRANK_PRIMARY_MAXLEN" \
--max-num-seqs "$FRANK_PRIMARY_SEQS" \
${FRANK_PRIMARY_MNBT:+--max-num-batched-tokens "$FRANK_PRIMARY_MNBT"} \
${FRANK_PRIMARY_PRECISE_MEMORY:+--expert-precise-memory} \
--gpu-memory-utilization "$FRANK_PRIMARY_UTIL" \
--kv-cache-memory-bytes "$FRANK_PRIMARY_KV_BYTES" \
--kv-cache-dtype "$FRANK_PRIMARY_KV_DTYPE" \
--enable-prefix-caching \
--enable-auto-tool-choice --tool-call-parser "$FRANK_PRIMARY_TOOL_PARSER" \
--reasoning-parser "$FRANK_PRIMARY_REASONING_PARSER" \
--default-chat-template-kwargs "$FRANK_PRIMARY_CHAT_TEMPLATE_KWARGS" \
--trust-remote-code \
> "$LOG" 2>&1
# From here on, this script's lifetime IS the container's: wait returns the
# container's exit code, so a crash is a nonzero ExecStart and the unit's
# Restart=on-failure is real. (systemd stop goes through ExecStop in the
# unit — podman wait alone cannot stop the container child.)
exec podman wait "$FRANK_PRIMARY_CONTAINER"
+92 -60
View File
@@ -1,28 +1,34 @@
#!/usr/bin/env bash
# Serve letechlead/MiMo-V2.6-Distill-Qwen-9B-INT8-W8A16-AutoRound as
# frank-small on Dankclaw's RTX 3090 via SGLang.
# Serve HauhauCS/Gemma4-12B-QAT-Uncensored-HauhauCS-Balanced (Q4_K_M) as
# frank-small on Dankclaw's RTX 3090 via mainline llama.cpp.
#
# Topology: the whole checkpoint (12.0 GiB, INT8 W8A16 AutoRound) lives in
# VRAM; the 3090's 24 GiB holds weights + a ~11.8 GiB fp8 KV pool at
# --mem-fraction-static 0.98. The host CPU only tokenizes.
# Topology: the whole model lives in VRAM (-ngl 999) -- 6.87 GiB of Q4_K_M
# weights plus the BF16 vision projector and q8_0 KV inside the card's
# 24 GiB. The host CPU only tokenizes and samples here; this box's CPU
# belongs to the halogen engine that serves frank-large.
#
# Architecture: qwen3_5 hybrid — 32 layers, 8 full-attention + 24 Gated
# DeltaNet, native 262144 window, and a vision tower (qwen3_5_vision)
# baked into the safetensors: no separate projector file, the image path
# is in the checkpoint. The config declares an MTP layer, but the shipped
# weights carry none (no mtp.* tensors in the index), so this service runs
# WITHOUT speculative decoding — a NEXTN draft would be an uninitialised
# 4.24 GiB ghost model drafting at 0% acceptance.
# MTP: this model's Multi-Token Prediction head ships as a SEPARATE file
# (mtp-gemma-4-12B-it.gguf, arch gemma4-assistant, 4 nextn layers). It is
# passed via --model-draft with --spec-type draft-mtp: the draft context
# loads the assistant model. The same --spec-type value chains the
# draftless ngram-mod matcher, which attaches unconditionally; two
# context-backed drafters cannot share one process, so this chain is the
# only shape that carries both.
#
# Context: CONTEXT_LENGTH x MAX_RUNNING slots must fit the fp8 KV pool,
# which the engine autofits around the weights AND the DeltaNet ssm state
# pool (see frank-small.env). The mamba pool defaults to ~5.1 GB, so it is
# pinned (bf16, 32 slots). With the grid at its measured fit the card has
# no headroom for prefill CUDA-graph capture, so prefill runs eager
# (--disable-prefill-cuda-graph); decode graphs are untouched.
# Context: CTX_SIZE is left unset and --fit sizes the KV pool (q8_0 K and
# V) against what the weights, projector, draft context and compute
# buffers leave on the card; the chosen value lands in the startup log.
#
# Sampling: greedy by default (the fleet contract); clients that send their
# own temperature get their own values.
# Vision: --mmproj loads the BF16 projector (it also carries an audio
# encoder; vision is the fleet contract). The fleet's image path lives
# here, which is why halogen no longer sets HALOGEN_VISION_TOWER.
#
# Sampling: greedy by default (the fleet contract). Clients that send their own
# temperature get their own values, and the harness overrides per model from
# models.yml. --jinja is pinned even though this build defaults it on: the
# model's own template supplies reasoning_effort, preserve_thinking and the
# tool-call grammar, and a future build that flips that default must not
# silently take tools away.
#
# Tunables come from frank-small.env unless FRANK_SMALL_ENV_FILE overrides it.
set -euo pipefail
@@ -37,61 +43,87 @@ if [[ -f "$ENV_FILE" ]]; then
fi
# --- defaults (used when the env file does not set them) -------------------
: "${SGLANG_BIN:=/home/dank/GitPackages/sglang-cu128/bin/sglang}"
: "${MODEL_DIR:=/home/dank/models/MiMo-V2.6-Distill-Qwen-9B-INT8-W8A16-AutoRound}"
: "${LLAMA_BIN:=/home/dank/GitPackages/llama.cpp-mainline/build-cuda/bin/llama-server}"
: "${MODEL_DIR:=/home/dank/models/Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp}"
: "${MODEL_FILE:=Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp.gguf}"
: "${MMPROJ_PATH:=$MODEL_DIR/mmproj-Gemma4-12B-QAT-Uncensored-HauhauCS-Balanced-BF16.gguf}"
: "${DRAFT_FILE:=}"
: "${ALIAS:=frank-small}"
: "${BIND_ADDR:=0.0.0.0}"
: "${LISTEN_PORT:=7771}"
: "${MEM_FRACTION:=0.98}"
: "${CONTEXT_LENGTH:=151552}"
: "${MAX_TOTAL_TOKENS:=606208}"
: "${MAX_RUNNING:=4}"
: "${KV_DTYPE:=fp8_e4m3}"
: "${QUANTIZATION:=auto-round}"
: "${REASONING_PARSER:=qwen3}"
: "${TOOLCALL_PARSER:=qwen3_coder}"
: "${MAMBA_SSM_DTYPE:=bfloat16}"
: "${MAX_MAMBA_CACHE_SIZE:=32}"
: "${CTX_SIZE:=}"
: "${PARALLEL:=1}"
: "${CACHE_TYPE_K:=f16}"
: "${CACHE_TYPE_V:=f16}"
: "${BATCH_SIZE:=2048}"
: "${UBATCH_SIZE:=512}"
: "${NUM_THREADS:=8}"
: "${THREAD_BATCH:=8}"
: "${NGL:=999}"
: "${FLASH_ATTN:=on}"
: "${SPEC_TYPE:=draft-mtp}"
: "${TEMPERATURE:=0.0}"
: "${REASONING_FORMAT:=deepseek}"
: "${REASONING_BUDGET:=-1}"
# Fail loudly rather than half-loading a model into VRAM: a missing file here
# is a download that has not finished, and the unit's restart policy would
# otherwise turn that into a crash loop that reads like a runtime fault.
for path in "$SGLANG_BIN" "$MODEL_DIR/model.safetensors.index.json" \
"$(cd -- "$(dirname -- "$SGLANG_BIN")/.." && pwd)/cuda-nvcc/usr/local/cuda-13.0/bin/nvcc"; do
for path in "$LLAMA_BIN" "$MODEL_DIR/$MODEL_FILE" "$MMPROJ_PATH"; do
[[ -e "$path" ]] || { echo "frank-small: missing $path" >&2; exit 1; }
done
shard_count=$(find "$MODEL_DIR" -maxdepth 1 -name 'model-*.safetensors' | wc -l)
if [[ "$shard_count" -ne 8 ]]; then
echo "frank-small: expected 8 safetensors shards in $MODEL_DIR, found $shard_count" >&2
exit 1
if [[ -n "${DRAFT_FILE:-}" ]]; then
[[ -e "$MODEL_DIR/$DRAFT_FILE" ]] || { echo "frank-small: missing $MODEL_DIR/$DRAFT_FILE" >&2; exit 1; }
fi
# The UVM device node is created lazily by nvidia-modprobe on the first CUDA
# open after boot; make sure it exists before the server needs it.
nvidia-modprobe -u -c=0 2>/dev/null || true
# FlashInfer JITs its attention kernels with nvcc at boot. The host's
# /usr/bin/nvcc is CUDA 12.4 and dies on `--compress-mode` (a 12.8+
# option), so the venv carries a matching CUDA 13.0 nvcc (extracted from
# the cuda-nvcc-13-0 deb); it must shadow the system one on PATH.
export PATH="$(cd -- "$(dirname -- "$SGLANG_BIN")/.." && pwd)/cuda-nvcc/usr/local/cuda-13.0/bin:$PATH"
MMPROJ_ARGS=()
if [[ -n "${MMPROJ_PATH:-}" && -f "$MMPROJ_PATH" ]]; then
MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH")
fi
# Sampling: SGLang has no server-wide sampling defaults; the harness sends
# its per-model recipe from models.yml (compat.extraBody), and other clients
# get the engine's own defaults.
exec "$SGLANG_BIN" serve \
--model-path "$MODEL_DIR" \
--served-model-name "$ALIAS" \
SPEC_ARGS=()
if [[ -n "${SPEC_TYPE:-}" && "$SPEC_TYPE" != "none" ]]; then
SPEC_ARGS=(--spec-type "$SPEC_TYPE")
fi
DRAFT_ARGS=()
if [[ -n "${DRAFT_FILE:-}" ]]; then
DRAFT_ARGS=(--model-draft "$MODEL_DIR/$DRAFT_FILE")
fi
# CTX_SIZE set -> pinned context. Unset -> --fit sizes the KV pool against
# whatever the weights, projector, draft context and compute buffers leave
# on the card; the engine prints the chosen value at startup.
if [[ -n "${CTX_SIZE:-}" ]]; then
CTX_ARGS=(--ctx-size "$CTX_SIZE")
else
CTX_ARGS=(--fit on)
fi
exec "$LLAMA_BIN" \
--model "$MODEL_DIR/$MODEL_FILE" \
"${MMPROJ_ARGS[@]}" \
"${DRAFT_ARGS[@]}" \
--alias "$ALIAS" \
--host "$BIND_ADDR" \
--port "$LISTEN_PORT" \
--mem-fraction-static "$MEM_FRACTION" \
--context-length "$CONTEXT_LENGTH" \
--max-running-requests "$MAX_RUNNING" \
--kv-cache-dtype "$KV_DTYPE" \
--quantization "$QUANTIZATION" \
--reasoning-parser "$REASONING_PARSER" \
--tool-call-parser "$TOOLCALL_PARSER" \
--mamba-ssm-dtype "$MAMBA_SSM_DTYPE" \
--max-mamba-cache-size "$MAX_MAMBA_CACHE_SIZE" \
--disable-prefill-cuda-graph \
--max-total-tokens "$MAX_TOTAL_TOKENS"
--gpu-layers "$NGL" \
--flash-attn "$FLASH_ATTN" \
"${CTX_ARGS[@]}" \
--parallel "$PARALLEL" \
--batch-size "$BATCH_SIZE" \
--ubatch-size "$UBATCH_SIZE" \
--threads "$NUM_THREADS" \
--threads-batch "$THREAD_BATCH" \
--cache-type-k "$CACHE_TYPE_K" \
--cache-type-v "$CACHE_TYPE_V" \
"${SPEC_ARGS[@]}" \
--jinja \
--metrics \
--temp "$TEMPERATURE" \
--reasoning-format "$REASONING_FORMAT" \
--reasoning-budget "$REASONING_BUDGET"
-176
View File
@@ -1,176 +0,0 @@
#!/usr/bin/env python3
"""Alertmanager-to-Signal relay for the homelab (Dankclaw).
Listens on 192.168.0.143:18093 (the host's LAN IP) and receives
Alertmanager's webhook payload from the container as
host.containers.internal:18093 — netavark delivers that address to the
host's LAN IP, which is the one bind a bridge container can reach (a
loopback bind refuses it, and 169.254.1.2 is not a local address here).
The group is formatted into a short message and handed to signal-cli under
the existing account on this host. Runs as a systemd user unit
(scripts/signal-relay.service); logs go to the journal.
The phone number and account name are numbers, not credentials — the
credential is the signal-cli account state under
~/.local/share/signal-cli, which never leaves the host.
Stdlib only: this box's user units run the system python3.
"""
import json
import shutil
import subprocess
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# The bind address is the one detail this design gets wrong twice
# (2026-09-17): a loopback bind refuses container traffic ("dial tcp
# 169.254.1.2:18093: connection refused"), and 169.254.1.2 itself is not a
# local address on this host ("Cannot assign requested address") — netavark
# runs the bridges in aardvark-dns' netns, not the host's. Traffic that
# arrives as 169.254.1.2 lands on the host's LAN IP, so bind THAT. The
# deployer installs an iptables INPUT rule that admits 18093/tcp from the
# podman bridge subnet (10.89.0.0/16) only, keeping the unauthenticated
# endpoint off the rest of the LAN.
LISTEN_ADDR = ("192.168.0.143", 18093)
# The account is addressed by phone number: signal-cli 0.14.7's -a flag
# parses an all-digit value as a phone number and never falls back to the
# accounts.json name (the numeric name 318311 dies with "Invalid account
# (phone number)"). The number is a number, not a credential — the
# credential is the account state under ~/.local/share/signal-cli.
SIGNAL_ACCOUNT = "+12062476336"
SIGNAL_TO = "+12062476336"
SIGNAL_CLI = "/usr/local/bin/signal-cli"
# The relay is a safety net, not the rate limiter: Alertmanager already
# repeats on repeat_interval. This suppresses exact duplicates that arrive
# inside the window (config reloads, double-fires) so a misconfiguration
# cannot fill the phone.
DEDUPE_SECONDS = 15 * 60
_last_sent = {}
# Cap on the instances listed per group; the count is always stated.
MAX_INSTANCES = 3
def log(msg):
print(f"signal-relay: {msg}", file=sys.stderr, flush=True)
def format_group(alerts):
"""One message per webhook delivery (Alertmanager groups by alertname)."""
if not alerts:
return None
first = alerts[0]
name = first.get("labels", {}).get("alertname", "unknown")
state = "firing" if first.get("status") == "firing" else "resolved"
severity = first.get("labels", {}).get("severity", "info").upper()
lines = [f"[{severity}] {name} ({len(alerts)} {state})"]
for a in alerts[:MAX_INSTANCES]:
labels = a.get("labels", {})
ident = labels.get("instance") or labels.get("job") or "?"
summary = (a.get("annotations") or {}).get("summary")
line = f" {ident}"
if summary:
line += f": {summary}"
lines.append(line)
if len(alerts) > MAX_INSTANCES:
lines.append(f" … and {len(alerts) - MAX_INSTANCES} more")
return "\n".join(lines)
def dedupe_key(alerts):
first = alerts[0]
labels = tuple(sorted(first.get("labels", {}).items()))
return (first.get("status"),) + labels
def send_signal(text):
if shutil.which(SIGNAL_CLI) is None:
log(f"signal-cli not found at {SIGNAL_CLI}")
return False
try:
proc = subprocess.run(
[SIGNAL_CLI, "-a", SIGNAL_ACCOUNT, "-o", "json", "send", SIGNAL_TO,
"-m", text],
capture_output=True, text=True, timeout=30,
)
except subprocess.TimeoutExpired:
log("signal-cli timed out after 30s")
return False
if proc.returncode != 0:
log(f"signal-cli failed rc={proc.returncode}: {proc.stderr.strip()[:300]}")
return False
return True
def handle_alertmanager(payload):
alerts = payload.get("alerts", [])
text = format_group(alerts)
if text is None:
log("empty alert group, nothing sent")
return
now = time.monotonic()
key = dedupe_key(alerts)
# Prune in place: rebinding _last_sent here would make it local to this
# function and the read above the assignment raises UnboundLocalError
# (measured 2026-09-17: every webhook died on it).
stale = [k for k, v in _last_sent.items() if now - v >= DEDUPE_SECONDS]
for k in stale:
del _last_sent[k]
if key in _last_sent:
log(f"suppressed duplicate: {key[0]}")
return
if send_signal(text):
_last_sent[key] = now
log(f"sent to {SIGNAL_TO}: {text.splitlines()[0]}")
else:
# Do not mark a failed send: the next repeat should retry.
log("send failed; will retry on the next delivery")
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
if self.path != "/alertmanager":
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
try:
payload = json.loads(self.rfile.read(length))
except (ValueError, UnicodeDecodeError) as e:
log(f"bad payload: {e}")
self.send_response(400)
self.end_headers()
return
try:
handle_alertmanager(payload)
except Exception as e: # a relay bug must not 500 Alertmanager
log(f"handler error: {e!r}")
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, fmt, *args):
# Default access log is per-request noise; the journal keeps the
# send/suppress lines, which are the ones that matter.
pass
def main():
server = ThreadingHTTPServer(LISTEN_ADDR, Handler)
log(f"listening on {'.'.join(map(str, LISTEN_ADDR))}, account {SIGNAL_ACCOUNT}")
server.serve_forever()
if __name__ == "__main__":
main()
-37
View File
@@ -1,37 +0,0 @@
# Systemd USER unit: signal-relay (Dankclaw).
#
# Install path: ~/.config/systemd/user/signal-relay.service
# Installed and enabled by install_signal_relay in scripts/deploy-remote.sh
# (part of the alertmanager deploy).
#
# The webhook consumer for Alertmanager: it listens on loopback only and is
# the only thing on this host that talks to signal-cli. It is a plain user
# unit, not a quadlet, on purpose:
#
# * it runs python3 straight from the checkout, so a deploy updates the
# relay without a copy step (the homepage timer copies to
# /usr/local/bin; this one has no root side);
# * generated quadlet units cannot be enabled on this host's podman 5.x
# v1 generator — a hand-written user unit can, and with Linger=yes for
# dank it starts at boot;
# * it needs no container: its only dependencies are /usr/bin/python3
# (system) and /usr/local/bin/signal-cli (host install, account state
# under ~/.local/share/signal-cli).
#
# Loopback-only for the same reason every observability port is: an
# unauthenticated message-sending endpoint has no business on a host
# interface. Alertmanager reaches it from its container via
# host.containers.internal.
[Unit]
Description=Alertmanager-to-Signal relay (Dankclaw)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 /home/dank/serverAppConfigs/scripts/signal-relay.py
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
+8 -20
View File
@@ -180,30 +180,18 @@ for path in "${CHANGED[@]}"; do
scripts/mount-entropy.sh)
add_test tests/mount_entropy.bats
;;
# Archive material: a path under any .AGENTTRASH/ directory is
# retired-by-policy (deploy-plan.py plans nothing from it), so it
# selects no focused test. It still needs its own case: without one
# the wrapper refuses retirement commits, whose trash moves show up
# as additions, with "no focused-test mapping".
.AGENTTRASH/*|*/.AGENTTRASH/*)
;;
# frank-primary is llama.cpp (the unsloth b11030-mix prebuilt) on the
# two R9700s as a ROOT unit — the 2026-09-26 swap that replaced the
# Lucebox DS4.1 quadlet on :7770. Its env, unit and serve script all
# live in scripts/, which is why this case has to precede the generic
# scripts/*.service and scripts/* fallbacks below.
scripts/frank-primary.*|scripts/serve-frank-primary.sh)
add_test tests/frank_primary_unit.bats
# frank-medium is llama.cpp on the two R9700s as a ROOT unit since
# 2026-09-16: a native gfx1201 HIP binary, so (like frank-small) its
# env, unit and serve script all live in scripts/, and this case has to
# precede the generic scripts/*.service fallback below. The retired
# vllm-radiance service dir is under dankserver/.AGENTTRASH/, which is
# archive material and maps to nothing.
scripts/frank-medium.*|scripts/serve-frank-medium.sh)
add_test tests/frank_medium_unit.bats
add_test tests/llm_units.bats
add_test tests/deployment_scoping.bats
add_test tests/scripts_syntax.bats
;;
# frank-large's chat template lives in scripts/ (moved there on
# 2026-09-26 when its old home was retired): a template edit changes
# what the live endpoint renders, so it selects the large unit seam.
scripts/qwen-sharp-v22.4.0.jinja)
add_test tests/frank_large_unit.bats
;;
# frank-small is llama.cpp on the 3090 as a ROOT unit: a native CUDA
# binary, so no generated quadlet is involved. Its env, unit and serve
# script all live in scripts/, which is why this case has to precede
@@ -62,14 +62,13 @@ service on the other host.
|---|---|---|
| Dankclaw compose services | `podman-compose -f dankclaw/compose.yaml up -d --no-deps NAME` | Recreates only `NAME`; Caddy is handled separately because it fronts both hosts. |
| SearXNG, Gluetun, Valkey, OpenWebUI, Pi-hole, Gitea database/server | user Quadlet + `systemctl --user` | Installs the selected unit and restarts only the required dependency-coupled units. Gitea's web proxy is the canonical Caddy compose service. |
| `frank-large` | systemd + `systemctl` (root unit) | Relinks and restarts only the large inference service; waits up to 20 minutes for `/health`. |
| `frank-small` | systemd + `systemctl` (root unit) | Relinks and restarts only the small inference service; waits up to 20 minutes for `/health`. |
| `frank-medium` | rootless Quadlet + `systemctl --user` | Installs `dankserver/frank-medium/quadlet/frank-medium.container`; on first deploy retires the llama.cpp root unit and the vLLM replica quadlets into the home trash, then restarts the generated unit. Waits up to 20 minutes for `/health`. |
| `frank-large` | systemd + `systemctl` | Relinks and restarts only the large inference service; waits up to 20 minutes for `/health`. |
| `frank-medium` | systemd + `systemctl` | Retires the legacy frank-small unit on first deploy, then relinks and restarts only the inference service; waits up to 20 minutes for `/health`. |
| Dankserver media stack (RomM trio, Jellyfin, Calibre-Web, Copyparty) | user Quadlet + `systemctl --user` | Installs `dankserver/<svc>/quadlet/*.container`, restarts the generated unit. The first cutover per service is idempotent: it retires the compose-era bare container, rescues any anonymous volume into the data root, and merge-moves `/opt/<svc>` to `/home/dank/Containers/<svc>` (mapped-uid roots move without chown). Steady state is restart-only. |
The shared chat template maps to the inference service on the selected target.
The `frank-npu` endpoint is retired (2026-09-11) and has no deploy handler;
an unrelated large-model deployment never touches it.
The inactive `frank-npu` unit is relink-only when explicitly selected;
it is not restarted by an unrelated large-model deployment.
## Caddy safety
@@ -1,13 +1,3 @@
---
type: reference
description: "`tests/e2e/` drives the **live fleet** through a real browser: fresh contexts"
tags:
- architecture
- e2e
- homepage
timestamp: 2026-09-01
---
# E2E Suite — homepage + guard chain (Playwright)
`tests/e2e/` drives the **live fleet** through a real browser: fresh contexts
@@ -1,12 +1,3 @@
---
type: reference
description: "One Caddy fronts both hosts."
tags:
- architecture
- network
timestamp: 2026-09-02
---
# Network Architecture
One Caddy fronts both hosts. It runs as a container on Dankclaw (192.168.0.143) and terminates TLS for every service on the LAN. Pi-hole on Dankclaw answers the service names for LAN clients; Porkbun answers them for the WAN. The G36 router firewall governs external access. SearXNG outbound traffic exits through the Gluetun VPN container.
@@ -1,12 +1,3 @@
---
type: reference
description: "All persistent data uses **bind mounts**: a host directory mapped directly into the container."
tags:
- architecture
- storage
timestamp: 2026-09-01
---
# Storage Policy
All persistent data uses **bind mounts**: a host directory mapped directly into the container. No named volumes. This matches the preference of this repo and the existing Gitea stack on Dankclaw.
@@ -1,12 +1,3 @@
---
type: reference
description: "This repository tests its configuration at defined seams."
tags:
- architecture
- testing
timestamp: 2026-09-01
---
# Testing Policy
This repository tests its configuration at defined seams. The tests run with `bats`; run them with `./tests/run.sh` before every push.
-10
View File
@@ -1,12 +1,3 @@
---
type: reference
description: "ComfyUI is a node-based AI image generation engine."
tags:
- dankclaw
- comfyui
timestamp: 2026-09-16
---
# ComfyUI
ComfyUI is a node-based AI image generation engine. Workflows are built from nodes that load models, sample latents, and post-process images; everything is scriptable through a web UI and an API.
@@ -22,7 +13,6 @@ https://github.com/Comfy-Org/ComfyUI
|------|-------|
| Image | `comfyui-rocm:gfx1151` — built from `dankclaw/Containerfile.comfyui` |
| Base image | `docker.io/rocm/vllm:rocm7.12.0_gfx1151_ubuntu24.04_py3.12_pytorch_2.9.1_vllm_0.16.0` |
| Upstream latest | 0.37.0 (2026-09-21) — ships **native Qwen-Image 2.1** support (CORE-423, `comfy/ldm/qwen_image21/`). The image's custom GGUF patches may be unnecessary on the next rebuild; check before re-applying. |
| Port | 8188 (host) → 8188 (container) |
| Backend | ROCm GPU (gfx1151 / Radeon 8060S). No `--cpu` flag. |
| Data | `/home/dank/comfyui-data/{models,input,output,custom_nodes}` → `/opt/ComfyUI/...` |
@@ -1,12 +1,3 @@
---
type: reference
description: "Gluetun is a lightweight VPN client container."
tags:
- dankclaw
- gluetun
timestamp: 2026-08-28
---
# Gluetun
Gluetun is a lightweight VPN client container. It connects to a VPN provider (WireGuard or OpenVPN) and shares that tunnel with other containers that attach to its network namespace.
@@ -1,12 +1,3 @@
---
type: reference
description: "The Hermes web dashboard is a browser UI for managing the agent."
tags:
- dankclaw
- hermes
timestamp: 2026-09-16
---
# Hermes-Dashboard
The Hermes web dashboard is a browser UI for managing the agent. It shows config, API keys, and sessions. It is a management tool. It is not a chat window. Chat happens over Signal or the CLI. See [[Hermes]].
-10
View File
@@ -1,12 +1,3 @@
---
type: reference
description: "Hermes is the self-improving AI agent from Nous Research."
tags:
- dankclaw
- hermes
timestamp: 2026-09-15
---
# Hermes
Hermes is the self-improving AI agent from Nous Research. It runs in a terminal, Telegram, Discord, Slack, WhatsApp, or Signal, and keeps a persistent learning loop: it creates skills from experience, searches past conversations, and builds a model of the user across sessions.
@@ -29,7 +20,6 @@ is not durable. The tracked, sanitized configuration baseline is
| Item | Value |
|------|-------|
| Image | built from `NousResearch/hermes-agent` (official Dockerfile in repo) |
| Upstream latest | agent v0.21.4 / CalVer `v2026.9.21` (2026-09-21); hermes-webui v0.52.113 (2026-08-14). Pin the CalVer tag, not `:latest`. |
| License | MIT |
| Model backends | Nous Portal, OpenRouter, OpenAI, or any custom endpoint |
| Interfaces | TUI, Telegram, Discord, Slack, WhatsApp, Signal |
+16 -109
View File
@@ -1,12 +1,3 @@
---
type: reference
description: "OpenWebUI is a self-hosted chat interface for LLMs."
tags:
- dankclaw
- openwebui
timestamp: 2026-09-21
---
# OpenWebUI
OpenWebUI is a self-hosted chat interface for LLMs. It connects to OpenAI-compatible endpoints (vLLM, llama.cpp, Ollama) and adds RAG, plugins, tool calling, and multi-user support.
@@ -21,110 +12,32 @@ https://github.com/open-webui/open-webui
| Item | Value |
|------|-------|
| Image | `ghcr.io/open-webui/open-webui:main` (verified from the live quadlet unit) |
| Upstream latest | 0.11.4 (2026-09-21) — security/access-control advisory; `:main` is the rolling image, not a stable pin. Imagegen endpoint is `POST /api/v1/images/generations` (the `comfyui` engine is still upstream). |
| Port | 3005 (host) → 8080 (container). Port 3000 is Gitea's HTTP, so it is not usable. |
| Data volume | `/home/dank/Containers/open-webui/data` → `/app/backend/data` |
| Backends | Three fleet endpoints: `frank-large` (host.containers.internal:7770), `frank-medium` (192.168.0.235:7770), `frank-small` (host.containers.internal:7771) — see [The LLM backends](#the-llm-backends) |
| Default backend | `frank-large` on Dankclaw `:7770` (ROCmFPX llama.cpp fork — see the LLM knowledge base below) — reached via host LAN IP `192.168.0.143:7770` |
| Network | Pinned bridge `systemd-open-webui` (`10.89.3.0/24`, gateway `10.89.3.1`) — see [History](#history) |
| Persistence | Quadlet unit `open-webui.service`; `[Install] WantedBy=default.target` |
| Deploy | `scripts/deploy-open-webui-quadlet.sh dankclaw` |
## The LLM backends
OpenWebUI serves the three fleet LLM endpoints. The model picker is built from
the `model` table in `webui.db` (one row per model), NOT from live connection
discovery — so the picker shows exactly the models seeded there.
The fleet runs two OpenAI-compatible endpoints. This mapping is the **live**
state (verified 2026-09-03). Both services listen on `:7770`, distinguished by
host; `frank-large` moved from `:52625` to `:7770` on 2026-08-22.
| Picker model | Connection URL | Runtime | Host |
|--------------|----------------|---------|------|
| `frank-large` | `http://host.containers.internal:7770/v1` | unsloth llama.cpp (Strix Halo iGPU, 200k ctx) | Dankclaw |
| `frank-medium` | `http://192.168.0.235:7770/v1` | vllm-radiance TP=2 (FP8, DFlash2, 262k ctx) | Dankserver |
| `frank-small` | `http://host.containers.internal:7771/v1` | llama.cpp (RTX 3090, Gemma4-12B, 262k ctx) | Dankclaw |
| Endpoint | Model name | Host | Capability |
|----------|-----------|------|------------|
| `:7770` (ROCmFPX llama.cpp fork) | `frank-large` | Dankclaw | completion |
| `:7770` (ROCmFPX llama.cpp fork) | `frank-medium` | Dankserver | completion |
Two gotchas make the wiring non-obvious:
- **llama.cpp `/v1/models` is Ollama-shaped.** llama.cpp returns
`{"models":[{"name":...}]}`; OpenWebUI's `extract_data()` only reads the
OpenAI `{"data":[{"id":...}]}` shape, so connection discovery silently misses
the llama.cpp endpoints (frank-large, frank-small). The fix is to set
`model_ids` in each connection's `api_config` — when non-empty, OpenWebUI
builds the model list directly and never fetches `/v1/models`. frank-medium
(vLLM) serves the OpenAI shape and would discover fine, but uses `model_ids`
too for consistency.
- **The host's own services are reached via `host.containers.internal`.** The
container sits on a netavark bridge; it can hairpin to a *different* host's
LAN IP (dankserver `192.168.0.235`) but not to its *own* host's non-published
ports via the host LAN IP (`192.168.0.143:7770/7771` is refused).
`host.containers.internal` (169.254.1.2) resolves to the host on the bridge
and reaches both llama.cpp services.
The Ollama connection is disabled (`ollama.enable=false`).
> All three endpoints are bare-metal systemd services managed by this
> repository. Model swaps, context sizing, and spec tuning happen there. See
> the [LLM knowledge base](../LLM/index.md).
## Web search
Chat web search is served by [[SearXNG]] on this host, wired in the quadlet
(`ENABLE_WEB_SEARCH`, `WEB_SEARCH_ENGINE=searxng`, `SEARXNG_QUERY_URL`).
- **Access path:** the backend calls `http://192.168.0.143:8081/search?format=json`
directly — gluetun's published port, NOT the `search.franksdank.cloud` vhost.
The Anubis→Authentik guard chain is a browser mechanism (proof-of-work +
session flow); a plain HTTP client cannot solve it, and the internal port
bypasses Caddy entirely.
- **Limiter:** SearXNG caps non-HTML (JSON) requests at 4/IP/hour (`API_MAX`,
a hardcoded Python constant — no config knob). The `pass_ip` list is checked
FIRST and short-circuits the whole botdetection chain, and OpenWebUI's bridge
egress (`10.89.x`) is in `pass_ip`, so the cap never applies. Verified: an
8-request burst from the container returned 8×200.
- **Config storage:** OpenWebUI keeps web-search settings per-key in its SQLite
config table (`webui.db`, `web.search.*`). The quadlet env vars seed that
table on first start (fill-missing only); afterwards the DB is authoritative
and Admin-UI edits persist there.
## SSO (authentik)
Login is authentik via the OIDC authorization-code flow (wired 2026-09-24).
The vhost stays exempt from the Anubis guard chain: the OAuth dance is
browser-driven and only touches the two exempt vhosts (`ai.` and `auth.`).
- **authentik objects** (created via the admin API, 2026-09-24): application
`open-webui` (slug `open-webui`) with the OAuth2 provider `Open WebUI`
(confidential client, `authorization_code` + `refresh_token`, Strict
redirect URI `https://ai.franksdank.cloud/oauth/oidc/callback`, implicit
consent flow). The client ID is in the quadlet; the client secret lives in
the git-ignored host file `/home/dank/Containers/open-webui/env` (mode 600,
loaded via `EnvironmentFile=`).
- **Roles:** the scope mapping `Open WebUI roles` (scope `roles`) reads the
application entitlements `Open WebUI Users` → `user` and `Open WebUI
Admins` → `admin`. The entitlements are bound to the groups
`openwebui_users` / `openwebui_admins`. `akadmin` (frank8465@protonmail.com)
is in `openwebui_admins`; `qa-e2e-admin` is in `openwebui_users`.
- **Account merge:** `OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true` folds an SSO login
into the existing local account with the same email — the pre-SSO admin
(frank8465@protonmail.com) keeps its data. `ENABLE_OAUTH_SIGNUP=true` lets
other `openwebui_users` create accounts on first login.
- **IdP reachability:** the container is on its own bridge (10.89.3.0/24) and
cannot reach `authentik-server:9000` by name. It reaches the IdP over the
public URL `https://auth.franksdank.cloud` — Caddy publishes `0.0.0.0:443`
(netavark DNATs published ports from other containers) and Pi-hole resolves
the name to the host LAN IP. The server-side code exchange rides that path.
- **Config authority:** the OAuth env vars in the quadlet seed the per-key
config DB on first run (fill-missing only); on the live data dir the Admin
Panel is authoritative. To rotate the client secret: create a new token in
authentik, update the host env file, and set the new value in the Admin
Panel (or the DB), then restart the container.
- **SSO-only (2026-09-24):** after the OIDC round-trip was verified, the local
username/password path was retired. `ENABLE_LOGIN_FORM=false` hides the form
and `ENABLE_PASSWORD_AUTH=false` refuses password logins, so the only way in
is the authentik OIDC flow. The `ui.enable_login_form` config-DB override was
flipped to `false` in the same change — the per-key DB wins over env, so both
must agree, and the DB value is the one to change on a live data dir. To
restore the password form: set `ui.enable_login_form` back to `true` in the
Admin Panel (or DB) and drop the two env vars from the quadlet.
The default connection points at the on-host endpoint (`frank-large`).
To add the other backend, open **Admin → Connections** in the UI and point an
OpenAI connection at `http://192.168.0.235:7770/v1` (key `none`).
> Both endpoints are bare-metal systemd services managed by this repository
(the former `vllmScripts` repo, merged in 2026-08-22). Model swaps, context
sizing, and spec tuning happen there. See the [LLM knowledge
base](../LLM/index.md).
## Boot persistence (Quadlet)
@@ -158,18 +71,12 @@ The quadlet carries a built-in healthcheck (30s interval, 3 retries, 300s start
- The image tracks upstream `main`; pin a digest in the quadlet unit if reproducible rollback matters more than tracking. (The old "CPU-only host" framing is stale: this APU runs the gfx1151 ROCm build — see [[ComfyUI]].)
- The default admin account is created on first login; secure it immediately.
- If the backend is loading (3–8 minute model load), OpenWebUI shows connection errors until the model is ready.
- The on-host LLM services (frank-large `:7770`, frank-small `:7771`) are reached via `host.containers.internal`, NOT the host LAN IP: the container's netavark bridge cannot hairpin to its own host's non-published ports via `192.168.0.143` (connection refused), and the bridge gateway (`10.89.3.1`) lives in the rootless netns while llama-server runs in the host netns, so it refuses too. `host.containers.internal` (169.254.1.2) resolves to the host on the bridge and works — verified end-to-end for both endpoints. The Dankserver backend (frank-medium) is a different host, reached via its LAN IP `192.168.0.235:7770`.
- The LLM is reached via the host LAN IP `192.168.0.143:7770`, not the bridge gateway: the gateway (`10.89.3.1`) lives in the rootless netns, while llama-server runs in the host netns, so the gateway refuses. `host.containers.internal` is slirp4netns-only and does not resolve on a bridge either. To reach the Dankserver backend, use the LAN address `192.168.0.235:7770`.
## History
- **2026-08-22 — slirp4netns → pinned bridge.** The unit ran on `Network=slirp4netns`. The slirp4netns helper process died (no OOM, no kill entry, no crash log found), and the container kept running with only `lo` in its netns: published port 3005 accepted TCP and reset every request, so chat served empty responses for ~2 days. Nothing logged an error. Moving to a kernel bridge removes the per-container helper entirely, and the healthcheck's interface check makes the zombie state visible if it ever returns.
- **2026-09-21 — model picker rebuilt for the 3-model fleet.** The picker carried 39 stale rows (37 dead Ollama models + 2 `frank-default-*`) and was missing `frank-medium`. Rebuilt the `model` table to exactly `frank-large`/`frank-medium`/`frank-small` and set `model_ids` on each connection (llama.cpp's Ollama-shaped `/v1/models` is invisible to OpenWebUI's discovery). Pointed the two on-host endpoints at `host.containers.internal` — the bridge refuses the host LAN IP for non-published ports. All three verified end-to-end via `/openai/chat/completions`.
- **2026-09-24 — SSO via authentik.** OIDC authorization-code flow wired per the [SSO (authentik)](#sso-authentik) section: application/provider/entitlements created in authentik, client secret in the host env file, roles from the `roles` scope claim. E2E coverage in `tests/e2e/specs/18-openwebui-sso.spec.ts`.
- **2026-09-24 — flipped to SSO-only.** The OIDC round-trip verified in a real browser (login view renders no password form, only "Continue with authentik", and the flow lands on a signed-in session), so the local username/password path was retired: `ENABLE_LOGIN_FORM=false` + `ENABLE_PASSWORD_AUTH=false` in the quadlet and `ui.enable_login_form` set to `false` in the config DB (the DB wins over env, so both were changed together). `qa-e2e-admin` was demoted out of the `authentik Admins` group (it keeps `openwebui_users` / `homepage-admins` for app access).
## See Also
- [[Dankclaw Stack]]
@@ -1,12 +1,3 @@
---
type: reference
description: "PostgreSQL is the database backend for Gitea."
tags:
- dankclaw
- postgres
timestamp: 2026-09-03
---
# Postgres
PostgreSQL is the database backend for Gitea. It runs on the same podman network as Gitea and holds the repository metadata, users, and issue data.
@@ -1,12 +1,3 @@
---
type: reference
description: "How Google and other upstream search engines get blocked behind the Gluetun"
tags:
- dankclaw
- searxng
timestamp: 2026-08-28
---
# SearXNG Providers
How Google and other upstream search engines get blocked behind the Gluetun
@@ -363,15 +354,8 @@ misleading).
disable pagination for rate-sensitive engines.
3. **Promote by evidence** — raise `weight` only for engines that return useful
results consistently; weight is not a health check.
4. **Avoid credentials, with one carve-out** — do not add API-backed engines
unless the provider is genuinely free, anonymous, and compatible with our
privacy requirements. Carve-out (2026-09-26): a paid, user-ordered API
engine (Kagi) is acceptable when the blast radius is bounded — the
`kagi` engine runs with `paging: false` and weight 2, so it spends exactly
one API credit per aggregated query, and its key never enters git
(deploy-time rendering from `/opt/searxng/.env`; see [[SearXNG]]). It is
also immune to the shared-VPN-exit-IP blocking this page is about: it
authenticates, so reputation of the exit IP is irrelevant.
4. **Avoid credentials** — do not add API-backed engines unless the provider is
genuinely free, anonymous, and compatible with our privacy requirements.
5. **Verify first** — `/json` + `?engines=google` and the searx-checker to see
exactly which engines are down and why. Confirm the running SearXNG version
(check whether `parsers`/`rate_limit` exist in the pinned build).
+5 -51
View File
@@ -1,12 +1,3 @@
---
type: reference
description: "SearXNG is a privacy-respecting metasearch engine."
tags:
- dankclaw
- searxng
timestamp: 2026-09-03
---
# SearXNG
SearXNG is a privacy-respecting metasearch engine. It aggregates results from many upstream search engines into one result set and serves both an HTML UI and a JSON API for agents.
@@ -60,8 +51,8 @@ Deploy with `scripts/deploy-searxng-quadlet.sh dankclaw`. It copies the units, d
```yaml
use_default_settings: true
server:
secret_key: "${SEARXNG_SECRET}" # rendered at deploy by scripts/searxng-render-settings.py
base_url: "${SEARXNG_BASE_URL}" # same renderer; SearXNG does NOT expand env vars itself
secret_key: "${SEARXNG_SECRET}" # from .env
base_url: "${SEARXNG_BASE_URL}" # resolves to http://192.168.0.143:8081/
limiter: true
method: "POST"
@@ -90,16 +81,8 @@ engines:
- name: duckduckgo
disabled: false
weight: 2
- name: kagi
engine: kagi
categories: [general, web]
shortcut: kg
api_key: "${KAGI_API_KEY}" # rendered at deploy from /opt/searxng/.env
kagi_categ: search
disabled: false
weight: 2
paging: false # one API credit per aggregated query
timeout: 8.0
paging: false
timeout: 5.0
- name: google cse
disabled: true
- name: brave
@@ -176,39 +159,10 @@ root cause, the suspend-model table, the `engines:` config, and the
LAN by construction (no public route to the LAN IP). No second agent-only
instance is needed; a duplicate would double upstream request volume from the
same rate-sensitive VPN exit IP for zero benefit.
- The config file is root-owned (uid of the rootless podman namespace). The
official path is `deploy.sh --service searxng`: it copies the repo settings
and renders the `${VAR}` placeholders via `scripts/searxng-render-settings.py`
from `/opt/searxng/.env`. A hotfix can still hand-copy with `sudo cp`, but
remember the renderer: an unrendered copy silently serves the literal
placeholders (before 2026-09-26 the live `secret_key` literally was
`"${SEARXNG_SECRET}"` and nobody noticed).
- The config file is root-owned (uid of the rootless podman namespace). Update it with a one-off `sudo cp`, then run `systemctl --user restart searxng.service` to reload.
- `base_url` must match the public address or proxy rewrites break.
- Keep the valkey cache container on the same podman network as searxng.
## Kagi (paid API engine)
Added 2026-09-26 (user-ordered). The stock SearXNG image ships a native
`kagi` engine module (`searx/engines/kagi.py`); it authenticates with
`Authorization: Bearer <key>` against `POST https://kagi.com/api/v1/search`.
- **Enabled**: `weight: 2` alongside bing/duckduckgo, categories
`[general, web]`, shortcut `kg`, `kagi_categ: search`.
- **Cost control**: `paging: false` — exactly one API credit per aggregated
query; `timeout: 8.0` so a slow paid query is not wasted (scrapers use 5.0).
- **Key handling**: the repo file keeps `api_key: "${KAGI_API_KEY}"`;
`deploy-remote.sh` bakes the real key into the live settings.yml via
`scripts/searxng-render-settings.py`, reading `/opt/searxng/.env`
(dank-owned, mode 600). The key never enters git and never appears in any
process argv. SearXNG itself does NOT expand env vars in settings.yml —
without the renderer the engine fails `setup()` and is dropped at load.
- **Immune to the VPN-exit-IP problem**: Kagi authenticates, so it does not
care about the shared Gluetun exit IP reputation (see [[SearXNG Providers]]).
- **Verified live 2026-09-26**: direct API probe through the gluetun netns
(200, `data.search[]` shape), then a live aggregated query returned
29/29 results tagged `kagi`, zero unresponsive engines.
## See Also
- [[Dankclaw Stack]]
-9
View File
@@ -1,12 +1,3 @@
---
type: reference
description: "Seerr is the unified request manager for the *arr stack (the maintained `seerr-team/seerr` successor to the archived Overseerr/Jellyseerr line)."
tags:
- dankclaw
- seerr
timestamp: 2026-09-02
---
# Seerr
Seerr is the unified request manager for the *arr stack (the maintained `seerr-team/seerr` successor to the archived Overseerr/Jellyseerr line). Frank's household uses it to request movies/TV/music; Seerr routes approvals to Sonarr/Radarr/Lidarr/Readarr on FrankBook-Pro and pushes downloads to qBittorrent.
-9
View File
@@ -1,12 +1,3 @@
---
type: reference
description: "Valkey is the cache and limiter store for SearXNG."
tags:
- dankclaw
- valkey
timestamp: 2026-08-28
---
# Valkey
Valkey is the cache and limiter store for SearXNG. It replaces Redis as the default in the official SearXNG deployment.
@@ -0,0 +1,189 @@
---
type: reference
title: *arr Stack
description: The *arr automation stack (Prowlarr, Sonarr, Radarr, Lidarr, Readarr, qBittorrent) — FrankBook-Pro only, every container on the gluetun WireGuard netns (all egress VPN): topology, wiring, auth posture, and gotchas.
tags: [arr-stack, prowlarr, sonarr, radarr, lidarr, readarr, qbittorrent, frankbook-pro, dankserver]
timestamp: 2026-09-04T00:00:00Z
---
# *arr Stack
The *arr suite is a collection of automated media management tools for Usenet
and BitTorrent. It runs in exactly one place: **FrankBook-Pro** — the Mac mini
that fronts this household's downloads — as quadlet units inside its podman
machine, with every container (the five arrs AND qBittorrent) sharing the
[[Gluetun]] WireGuard tunnel's network namespace so **all egress exits
through the VPN**. A short-lived Dankserver compose copy was removed on
2026-09-04 (see below). The stack provides automatic downloading and library
management that Copyparty alone does not cover: quality profile enforcement,
series/movie discovery, metadata enrichment, and automated file organization.
The core chain is: **Prowlarr** (indexer API aggregator) →
**Sonarr/Radarr/Lidarr/Readarr** (media-specific managers) → **qBittorrent**
(download client, sharing the [[Gluetun]] WireGuard tunnel netns) →
**Jellyfin** (media server scan → auto-refresh).
## Service Overview
| Service | Role | Image | Port | Min RAM |
|---------|------|-------|------|---------|
| Prowlarr | Indexer management | `lscr.io/linuxserver/prowlarr:latest` | 9696 | 256 MB |
| Radarr | Movie management | `lscr.io/linuxserver/radarr:latest` | 7878 | 512 MB |
| Sonarr | TV show management | `lscr.io/linuxserver/sonarr:latest` | 8989 | 512 MB |
| Lidarr | Music management | `lscr.io/linuxserver/lidarr:latest` | 8686 | 256 MB |
| Readarr | Ebook/audiobook management | `lscr.io/linuxserver/readarr:amd64-1.0.980` (pinned — LSIO ships no untagged `latest` for readarr) | 8787 | 256 MB |
| qBittorrent | Torrent download client | `lscr.io/linuxserver/qbittorrent:4.6.7` (pinned; see [[qBittorrent]]) | 8080 (published by the gluetun unit) | 512 MB |
Jackett and Whisparr were evaluated as optional extras but never deployed
(no unit, no vhost, no live container).
## Deployment shape (current)
Each service is a quadlet `.container` unit in this repo under
`frankbook-pro/<svc>/quadlet/`, installed inside the podman machine at
`/home/core/.config/containers/systemd/<svc>.service` by the frankbook-pro
handler in `scripts/deploy-remote.sh`. Every unit runs with
`PUID=0 PGID=0 TZ=America/Los_Angeles UMASK_SET=022`, mounts its config root at
`/Volumes/COMPLETE/<svc>-data:/config`, publishes its API port on the host, and
runs `Restart=always` with `TimeoutStartSec=900`.
qBittorrent is special: it shares the [[Gluetun]] container's network namespace
(`Network=gluetun.container`) so torrent egress never touches the residential
IP. A netns-sharer publishes no ports of its own — qB's WebUI (8080) and
BitTorrent port (6881 TCP+UDP) are published on the gluetun unit instead.
## Dankserver compose copy (removed 2026-09-04)
A copy of the stack lived in `dankserver/compose.yaml` (`/opt/<svc>/config`
data roots) from 2026-08-20 to 2026-09-04 and never ran healthy: the config
dirs were created root-owned (`root:root 755`), so every app died at logger
init (`UnauthorizedAccessException: Access to the path '/config/Sentry' is
denied`) before binding any port — which read as dead ports, not a boot
crash, until `podman logs <svc>` was checked. After a one-day revival it was
removed by operator decision: **FrankBook-Pro is the sole *arr deployment.**
Config roots moved to `~/.AGENTTRASH/20260904-dankserver-arr-teardown/` on
Dankserver (recoverable until purged).
### Rootless identity model on Dankserver (verified, keep for reference)
- On Dankserver's podman 5.7.0 rootless, a container's default user namespace
maps **container root (PUID=0) → host uid 1000 (dank)** and container uid
*N* → host `100000 + N - 1`. Verified via `/proc/<pid>/uid_map`
(`0 1000 1` / `1 100000 65536`) across plex, tautulli, jellyfin,
calibre-web, copyparty, romm. This is why PUID=0 services write as `dank`
and why data roots are owned `1000:1000` on that host.
- **Never add `userns_mode: host` to a podman-compose service.**
podman-compose 1.5.0 runs every compose service inside a pod, and podman
rejects the combination (`--userns and --pod cannot be set together`) —
the stack then fails to recreate with no recourse. The default mapping
already gives the fleet identity; PUID=0 + `1000:1000`-owned data roots is
the whole recipe. (The compose file briefly carried `userns_mode: host` on
all six arr services on 2026-09-04 — reverted in `e454159`, same day.)
- qBittorrent's first boot (PUID=0, empty config) prints a one-time WebUI
admin password in `podman logs qbittorrent` — persist it via the PBKDF2
recipe in [[qBittorrent]] before restarting the container.
## Access / auth posture
No *arr app sits behind the Anubis guard chain — the stack is exempt by
construction (its own auth), with an exemption comment in the Caddyfile per
vhost. Five TLS vhosts (`<svc>.franksdank.cloud`) terminate on Caddy on
Dankclaw and route to FrankBook-Pro's published ports
(`192.168.0.112:<port>`, all exposed by the gluetun unit since the netns
consolidation); the same ports are reachable directly on the LAN. Sonarr is
the exception: internal-only by construction — no vhost, no public DNS record;
admins reach it at `http://192.168.0.112:8989` from the LAN only (retired
2026-09-04; users request TV through [[Seerr]] instead).
Auth-config history (verified live 2026-08-29): all five arrs shipped with
`AuthenticationMethod=None` **plus** `AuthenticationRequired=Enabled`. On the
v1 (.NET) apps that combo is self-contradictory — UI Save is permanently
disabled and any settings save throws `DryIoc.ScopedItemException` inside
`AuthorizationMiddleware` (500 sprawl on every page after restart). The valid
representation of "no auth" in v1 is **absence of the `<AuthenticationRequired>`
key**, not the literal string `Disabled` — readarr's config (key absent) booted
clean while its three siblings (key=`Disabled`) 500-sprawled, which is how it
was caught. Fix applied live: key deleted on sonarr/radarr/lidarr/prowlarr,
containers restarted, all five verified serving. Posture = zero-auth arrs on
LAN and WAN; the Anubis wall guards homepage/search/hermes/prometheus/romm/files
but **not** the arrs — first-account-wins is the current race condition if that
ever flips to `External` auth (needs an IdP first; auth method choice remains an
operator decision). Sonarr is exempt from even that exposure since 2026-09-04:
with no vhost and no public DNS record, its zero-auth UI has no WAN path at all.
## Chain wiring (verified live 2026-08-30)
The download chain is wired and test-first verified: **indexers → Prowlarr →
per-arr qBittorrent clients (category = app name) → import**.
- qB webUI auth is ON (`admin` + generated password; verify before writing it
into any config — it was generated at first boot, not a default).
- Per-arr QBittorrent download clients exist on sonarr/radarr/lidarr
(enabled) and readarr (**parked**: its qB proxy fails auth live even with
correct creds — known defect of the abandoned upstream; retest needed
after the 2026-09-04 netns move). All four clients were re-pointed to
**loopback** (`127.0.0.1:8080`, same netns) on 2026-09-04 when the arrs
joined gluetun's netns — the old `10.88.0.1:8080` gateway hack routes
into the VPN tunnel (AllowedIPs=0.0.0.0/0) and is dead from inside the
netns. Verified live: `podman exec sonarr curl` reaches qB with 200, and
a real qB login from inside the netns returns 200.
- Prowlarr runs v2.5.2 where apps are first-class rows (`Applications`
table): four Application rows (sonarr/radarr/lidarr/readarr) carried each
arr's real baseUrl+apiKey with `SyncLevel=FullSync` as of 2026-08-30.
**Status 2026-09-04: the table is EMPTY again** (live API check — 8 rows,
all Cardigann/Torznab, zero Application rows; lost with the dankserver
config migration). Prowlarr sync is broken until the four rows are
rebuilt with baseUrl `127.0.0.1:<arr port>` (same netns).
- Indexers are **test-before-enable**: EZTV and 1337x stay disabled with
reason "blocked by CloudFlare Protection" (this host's egress ASN was
CF-blocked at those trackers). **2026-09-04: arr egress moved onto the
ProtonVPN tunnel** — the parked reason may no longer hold; retest before
enabling. Nyaa.si, sukebei, RuTor, Byrutor, YTS passed live tests and
are enabled. A failed test = parked with reason, never enabled blind.
- Prowlarr v2 sync model: indexers stay in Prowlarr; arrs search *through* it
and hand off to their own qB download client (category routing). Mappings
materialize at grab time — zero mappings right after wiring is expected,
not a fault.
## Disk layout (current)
```
/Volumes/COMPLETE <- completed-media volume (Mac volume)
├── sonarr-data:/config <- per-app config roots, one per unit
├── radarr-data:/config
├── lidarr-data:/config
├── readarr-data:/config
├── qbittorrent-data:/config
└── <movie/tv/music/books roots> <- arr import roots live here
/Volumes/INCOMPLETE <- qB in-flight downloads (same path inside qB)
```
qB mounts both volumes at their identical host paths
(`Volume=/Volumes/COMPLETE:/Volumes/COMPLETE`,
`Volume=/Volumes/INCOMPLETE:/Volumes/INCOMPLETE`), so a category's download path
is the same string on every container that touches it — no remapping between
download and import.
## Gotchas
- **Timezone:** every unit runs `TZ=America/Los_Angeles`; Sonarr's scheduler is timezone-sensitive — keep them uniform.
- **File permissions:** `UMASK_SET=022` on qBittorrent; arr apps run PUID=0 so they can read/move qB downloads.
- **Network conflicts:** *arr apps use many ports. On the machine VM, only the API ports (8989/7878/8686/8787/9696) and gluetun's published 8080 + 6881 are host-published; netns-sharers (qB) publish none of their own.
- **Prowlarr API key rotation:** If rotated, update it in every arr app (`Applications` table rows carry the real keys).
- **qBittorrent WebUI password:** generated at first boot, not a default — persist it via the PBKDF2 conf recipe in [[qBittorrent]] before any restart.
- **Readarr stability:** abandoned upstream (retired May 2026); its qB download client is parked pending a successor image. Do not rely on it for mission-critical collections.
## Best Practices
1. Use tags to route content to specific download clients or categories.
2. Enable the Blocklist in arr apps to prevent re-downloading failed torrents.
3. Set download limits in qBittorrent to avoid bandwidth saturation.
4. Back up arr configs regularly (they are small SQLite DBs + JSON) — each lives at `/Volumes/COMPLETE/<svc>-data/config`.
5. Monitor Prowlarr indexer health — broken indexers hide new content.
6. Start with default quality profiles. Add custom formats gradually.
## See Also
- [[Sonarr]] · [[Radarr]] · [[Lidarr]] · [[Readarr]] · [[Prowlarr]] · [[qBittorrent]] — per-service pages
- [[Jellyfin]] — media server that consumes *arr downloads
- [[Copyparty]] — alternative file viewer
@@ -1,12 +1,3 @@
---
type: reference
description: "Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database."
tags:
- dankserver
- calibre
timestamp: 2026-09-03
---
# Calibre-Web
Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. It serves as the web frontend for the Calibre e-book manager.
@@ -19,7 +10,7 @@ vhost — Calibre-Web serves its own login; direct proxy to
| Item | Value |
|------|-------|
| Running image | `lscr.io/linuxserver/calibre-web:0.6.27-ls402` — **pinned** (verified from the live quadlet unit; the pin was chosen so the cutover changed zero bits of application code) |
| Running image | `lscr.io/linuxserver/calibre-web:0.6.27-ls397` — **pinned** (verified from the live quadlet unit; the pin was chosen so the cutover changed zero bits of application code) |
| Network | `pasta` (the default rootless-podman userslide network); only 8083 is host-published |
| Base OS | Ubuntu Noble (24.04 LTS, since 2024-12-05) |
| Port | 8083 (host) → 8083 (container) |
@@ -69,7 +60,7 @@ Deployment since 2026-09-01 is a quadlet unit
(`dankserver/calibre-web/quadlet/calibre-web.container` →
`calibre-web.service`, deploy with
`./scripts/deploy.sh dankserver --service calibre-web`), image pinned to
`0.6.27-ls402`, healthcheck on `/`, `PublishPort=8083:8083`. This service was
`0.6.27-ls397`, healthcheck on `/`, `PublishPort=8083:8083`. This service was
already on the Containers data-root layout before the standardization, so the
cutover was a bare-container retire + unit start with no data movement.
@@ -78,7 +69,7 @@ cutover was a bare-container retire + unit start with no data movement.
```yaml
services:
calibre-web:
image: lscr.io/linuxserver/calibre-web:0.6.27-ls402
image: lscr.io/linuxserver/calibre-web:0.6.27-ls397
container_name: calibre-web
restart: unless-stopped
environment:
+25 -51
View File
@@ -1,12 +1,3 @@
---
type: reference
description: "Calibre is the e-book library manager that backs Calibre-Web."
tags:
- dankserver
- calibre
timestamp: 2026-09-21
---
# Calibre
Calibre is the e-book library manager that backs Calibre-Web. It manages metadata, formats, and conversions for the library that Calibre-Web serves.
@@ -27,10 +18,10 @@ The actual library files (`.epub`, `.mobi`, `.pdf`) and the Calibre database (`.
## Current Version
Calibre 9.15.0 is the version on the live host (the upstream latest as of 2026-09-22). Major features include:
Calibre 9.2.1 is the version on the live host (9.9.0 is the upstream latest). Major features include:
- Bookshelf view
- Opt-in AI features
- Content-server path-traversal fix: a bug since v6.16.0 allowed any user with server access to read non-Calibre files on the host. (The CVE-2025-7404 advisory is tracked against Calibre-Web, see [[Calibre-Web]].) Upgrade to 9.15.0+ if serving content to untrusted users.
- Content server security fix (CVE-2025-7404 equivalent): a bug since v6.16.0 allows any user with server access to read non-Calibre files on the host. Upgrade to 9.9.0+ if serving content to untrusted users.
## Library Directory
@@ -48,50 +39,33 @@ gone; the quadlet unit mounts the Containers data root.)
- Read/write access to the library directory. Calibre-Web edits metadata through the database file.
- Ownership matching `PUID=1000` / `PGID=1000` (the `dank` user on Dankserver).
## Calibre Content Server (running)
## Optional: Calibre Content Server (not running)
Calibre includes a built-in HTTP content server (`calibre-server`) that serves
the library via OPDS feeds and a web interface:
- OPDS feeds for mobile reading apps (FBReader, Moon+ Reader, KyBook)
- Web UI for browsing, metadata editing, and uploads
- Direct device push (from the GUI, not the content server)
Calibre includes a built-in HTTP content server (`calibre-server`) that can serve the library via OPDS feeds and a mobile-friendly web interface. This enables:
- OPDS feeds for mobile reading apps (FBReader, Moon+ Reader)
- Mobile-optimized UI via the `--mobile` flag
- Direct Kindle push
**Live shape (deployed 2026-09-21):** a rootless Quadlet unit
(`dankserver/calibre-server/quadlet/calibre-server.container`), boot-persistent
and self-healing, on `lscr.io/linuxserver/calibre:9.15.0`.
To run as a container:
The image is the current **desktop** build (Selkies web-streamed GUI), not the
headless image the old compose snippet assumed — the dedicated headless images
(ta264/docker-calibre, agentgreen/calibre-server) are dormant (calibre 6.10 /
2017, both below the 9.9.0 content-server fix). The unit therefore runs
`calibre-server` as the image's s6-overlay main service via `Exec=`.
```yaml
services:
calibre-server:
image: lscr.io/linuxserver/calibre:latest
container_name: calibre-server
restart: unless-stopped
environment:
PUID: "1000"
PGID: "1000"
TZ: Etc/UTC
command: calibre-server --daemonize --port 8080 --enable-auth --url-prefix /calibre /books
volumes:
- /opt/calibre-web/books:/books
ports:
- "8084:8080"
```
| Surface | Host port | Auth |
|---------|-----------|------|
| Content server (OPDS + web UI) | **8084** (internal 8090) | HTTP Basic, single admin `calibre` in the SQLite userdb |
| Desktop GUI (internal only) | none (8080/8181/8082 inside the container) | HTTP Basic ready (`CUSTOM_USER`/`CUSTOM_PASSWORD` in the env file) |
Port notes: `Network=pasta` on this host is port-FORWARD mode — the container
keeps its own netns and only `PublishPort`'d ports reach the host (verified
2026-09-21). The desktop GUI runs but is unreachable: exposing the browser
GUI is one `PublishPort` line away. 8080 *inside* the container is the
image's own nginx (the desktop), so the content server runs on internal 8090.
**Auth model (calibre 9.x):** users live in a SQLite userdb whose `pw` column
is **plaintext** — the file is mode 600. The single admin account is
bootstrapped once by the deploy handler
(`deploy_calibre_server` in `scripts/deploy-remote.sh`) via
`calibre-server --manage-users -- add`, the password piped on stdin with
`printf` (a trailing newline from `echo` lands in the stored password and
breaks every login). The desktop's basic auth (`CUSTOM_USER`/`CUSTOM_PASSWORD`)
reuses the same pair. Both live in the machine-local env file
`/home/dank/Containers/calibre-server/env` (mode 600); the repo holds no
secrets. `--auth-mode=basic` is pinned: on a non-SSL port the server defaults
to Digest auth, which rejects plain Basic clients with
`400 Unsupported authentication method`.
LAN-only (no vhost, no public DNS). The library is mounted read-write — the
content server writes metadata, the same library Calibre-Web uses.
Note: The content server runs headless in `--daemonize` mode. It requires the full Calibre package, not a minimal server.
## See Also
+1 -10
View File
@@ -1,12 +1,3 @@
---
type: reference
description: "Copyparty is a lightweight file server and media streamer that runs as a single Python binary."
tags:
- dankserver
- copyparty
timestamp: 2026-09-03
---
# Copyparty
Copyparty is a lightweight file server and media streamer that runs as a single Python binary. It handles large payloads, media streaming, resumable uploads, directory browsing, and WebDAV from one process.
@@ -20,7 +11,7 @@ LAN — **Docs:** https://github.com/9001/copyparty
| Item | Value |
|------|-------|
| Running image | `ghcr.io/9001/copyparty-ac:latest` (ac = full edition; the bare `copyparty:latest` tag does not exist — ghcr answers 403 on the token request for nonexistent repos). Upstream latest v1.20.24 (2026-09-19); v1.20.21 at research time (2026-08) |
| Running image | `ghcr.io/9001/copyparty-ac:latest` (ac = full edition; the bare `copyparty:latest` tag does not exist — ghcr answers 403 on the token request for nonexistent repos). Version at research time (2026-08): v1.20.21 |
| Port | 3923 (host) → 3923 (container) |
| Volume | bind mount the shared root directory |
| Auth | Per-directory `.cpy` config files, HTTP basic auth, `dirkeys`, `filekeys` |
@@ -1,11 +1,3 @@
---
type: reference
description: "Dankserver (192.168.0.235) runs the media and library stack."
tags:
- dankserver
timestamp: 2026-09-03
---
# Dankserver Stack
Dankserver (192.168.0.235) runs the media and library stack. Public access
@@ -1,11 +1,3 @@
---
type: reference
description: "The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at"
tags:
- dankserver
timestamp: 2026-08-28
---
# Entropy Volume
The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at
@@ -55,56 +55,6 @@ Client access paths and the public-exposure policy (auth wall required before
any public vhost; Plex-style claim tokens do not exist here — Jellyfin uses its
own users/API keys) follow the fleet [[Authentik]]/[[Anubis]] chain pattern.
## Authentik LDAP authentication (2026-09-25)
Jellyfin has no native external-auth, so it authenticates users through the
**LDAP-Auth plugin** (`jellyfin-plugin-ldapauth`) against an [[Authentik]] LDAP
provider. A user logs in with their **authentik username + password**; the
plugin binds as a service account, searches the directory, and rebinds as the
user to verify the password.
**authentik side** (on Dankclaw):
- LDAP provider `Jellyfin LDAP` (Base DN `dc=franksdank,dc=cloud`, bind/search
mode `direct`) + application `Jellyfin`.
- LDAP outpost `Jellyfin LDAP Outpost` → container `authentik-outpost-ldap`
(quadlet `dankclaw/authentik/quadlet/authentik-outpost-ldap.container`),
publishing `389`/`636` on the Dankclaw host for cross-host binds.
- Service account `svc-jellyfin-ldap` (DN
`cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud`).
- Access groups `jellyfin_users` (search filter
`(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`) and
`jellyfin_admins` (admin filter `(cn=jellyfin_admins)`). Add a user to
`jellyfin_users` to grant them Jellyfin access; `jellyfin_admins` grants
the Jellyfin admin role.
**Jellyfin side** (plugin config, set via admin → Plugins → LDAP-Auth):
- Server `192.168.0.143:389` (plain LDAP over the trusted LAN), Base DN
`dc=franksdank,dc=cloud`, bind user = the service-account DN, search
attributes `uid,cn,mail,displayName`, uid attr `uid`, username attr `cn`,
`CreateUsersFromLdap=true`, all folders enabled.
**Gotcha — the bind credential is a PASSWORD, not a token.** The LDAP bind is
verified by authentik's default authentication flow (standard-password
backend). The service account must have a password set (admin → user →
Recovery → Set password); an API/app token is *not* accepted as the bind
password and the bind fails with `flow error password: Invalid`. The
service-account password is stored only in the Jellyfin plugin config (the
`LdapBindPassword` field) — it is never committed to this repo.
**Verification (2026-09-25):** `ldapsearch` bind + the `jellyfin_users`
search both succeed from Dankclaw; a real user (`alex`, in `jellyfin_users`)
logged into the web UI with their authentik password and the plugin created
the matching Jellyfin user.
**Gotcha — the login endpoint changed in Jellyfin 12.1.** The legacy
`POST /Users/Auth/Password` route (and its `LegacyEmbyRouteRewriteMiddleware`)
was removed; probing it now returns a silent 401 with no plugin log, which
reads like an auth failure but is just a dead route. The web UI and modern
clients use `POST /Users/AuthenticateByName` (with the `Authorization:
MediaBrowser Client=…, DeviceId=…, Device=…, Version=…` header), which is
where the LDAP plugin runs. Verified 2026-09-25: `alex` authenticates over
`AuthenticateByName` (HTTP 200; full plugin flow logged — service bind,
search, `ldapUid`, user bind, token).
## Configuration workflow
1. Create libraries pointing at `/data` folders (movies, shows, audio).
+1 -10
View File
@@ -1,12 +1,3 @@
---
type: reference
description: "Plex Media Server is a **containerized media server on Dankserver**: a podman"
tags:
- dankserver
- plex
timestamp: 2026-09-15
---
# Plex
Plex Media Server is a **containerized media server on Dankserver**: a podman
@@ -24,7 +15,7 @@ public traffic can be fronted by Caddy like every other vhost.
| Item | Value |
|------|-------|
| Runtime | podman quadlet, `plex.service` (user manager) — migrated from the native `plexmediaserver.service` OS-package unit (2026-09) |
| Image | `lscr.io/linuxserver/plex:1.43.4.10903-e5521bd8c-ls325` (pinned; verified == the ghcr `latest` manifest digest at pin time) |
| Image | `lscr.io/linuxserver/plex:1.43.3.10896-cb3ebc72d-ls322` (pinned; verified == the ghcr `latest` manifest digest at pin time) |
| Data root | `/var/lib/plexmediaserver` bind-mounted **in place** as `/config` (RW) — the migration moved zero bytes |
| Media | `/mnt/entropy` mounted at the same absolute path **read-only** — every library path in the PMS database stayed valid, zero re-scan |
| Identity | fleet rootless model — container root maps to the `dank` user (1000:1000); the deployer chowns the data root on first cutover |
@@ -1,12 +1,3 @@
---
type: reference
description: "This guide covers metadata providers, cover art, ROM naming conventions, emulator integration, and the scraping workflow for RomM on Dankserver."
tags:
- dankserver
- romm
timestamp: 2026-08-28
---
# RomM Scraping & Asset Management
This guide covers metadata providers, cover art, ROM naming conventions, emulator integration, and the scraping workflow for RomM on Dankserver.

Some files were not shown because too many files have changed in this diff Show More