Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e030486ce6 | ||
|
|
d89ae23195 | ||
|
|
f206ddfde5 | ||
|
|
bf29518a15 | ||
|
|
169dbe2f38 | ||
|
|
4a6d5b8191 | ||
|
|
49c4e198fd | ||
|
|
31266fb7f3 | ||
|
|
06a9cb60eb | ||
|
|
e7841bf9d6 | ||
|
|
c0759492e6 | ||
|
|
695a5f3281 |
No files matched your search
@@ -26,8 +26,8 @@ merge absorbed the former `vllmScripts` repo, history preserved).
|
||||
| Target | Service | Runtime | Model | Port |
|
||||
|--------|---------|---------|-------|------|
|
||||
| Dankserver | `frank-archon` | **radiance** (StillDeadcode, **pin 1.0.13** digest-pinned `stilldeadcode/radiance@sha256:4c3f484564…`, plain `podman run`, root systemd unit) | **Publisher payload** (swap 2026-10-15, MODEL_FILE-only): `StillDeadcode/qwen3.8-next-flash-fp8-iq4r-moe` → **`qwen3.8-next-flash-fp8-iq4r-moe.rad`** (121,969,901,568 B = **116,322 MiB**, Hub-LFS sha256 `0af5e962…` verified at fetch by `scripts/download-frank-archon-iq4r.sh`) — the OFFICIAL Qwen3.8-Flash-Next bf16 checkpoint rad-converted by the engine's author: routed experts as **4-bit codes into a 16-level non-uniform codebook** (128-pt Walsh–Hadamard rotation, GPTQ on a 10M-token calibration + 3 coordinate-descent sweeps, E4M3 scales per 64 weights), **10 whole bf16-protected experts/layer**, int8 W8A8 trunk/lm_head, fp8 HC mixtures, fp8 fixed-scale PLE n-gram (47.7 GiB), **MTP head + bf16 vision tower in the container**; card KL 0.0789 mean all-tokens / top-1 92.85 % vs BF16. **Base-alignment (censored) model** — the abliterated family lives on frank-dragoon. Shape carried VERBATIM from the 2026-10-13 record: **3 × 200,000 fp8-KV** (`--max-num-seqs 3`), **25,600 MiB slab + 5,120 MiB pool = 30,720 of the 30,969 MiB claimable**, 44 GiB/rank pinned host pool, PLE pinned to RAM, MTP depth 2, MNBT 2048, Froggeric v22.5 template. Decode/prefill ladders in the runbook are the **w4dn8 record** (the bar to beat; new-payload probes pending). Rollback: re-download the w4dn8 `.rad` from the HF mirror `frank8465/Swift-1.5-Qwen3.8-Flash-Next-GPTQ-W4A8-Radiance-AMD-R9700` (LFS sha256 `09dd9306…`) + revert the swap commit — the local copy was **purged 2026-10-17** (it was the last non-iq4r model on the host; the GLM-4.7 GGUF, the radiance kvcache tiers, and every stale compile cache went in the same purge, 763 GiB free). The fp8-native `.rad` + the raw Swift1.5 BF16 source were DELETED from disk 2026-10-15 (Frank's one-time rm, volume for this payload; rebuildable via the fetch/build scripts). | 7770 |
|
||||
| Dankclaw | `frank-dragoon` | **halogen-flash-server 0.16.2** (`ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…`, digest-pinned, plain `podman run` via `scripts/serve-frank-dragoon.sh`, root systemd unit) | Quat3rnion/halogen-qwen3.8-flash-next-v2-abliterated **`qwen38-flash-next-v2-abliterated.hgn`** (66,687,678,432 B ≈ 62.1 GiB resident, Hub LFS sha256 `b5bd925f…` verified at fetch — the **NATIVE 2026-10-11 v2 conversion** of orcarouter's pinned BF16, abliterated, NOT the writer transplant that carried this filename; the v2 `.hgn` format loads in NO other engine), **+ REQUIRED `qwen38-flash-next-ngram.hgn`** (51,200,246,144 B, peonist @5cc17cea) **+ vision tower** (897,916,416 B, @e053f488) — both **hardlinked** from the swift15 payload dir (the release publishes only the main file; the engine's own `niah` 18/18 + `ppl` 3.968 with mean KL 0.155 vs the swift15 logits certified the stock companions), **vision ON** (`image_url` parts only, ≥256 px probes), **MTP spec decode ALWAYS-ON (`HALOGEN_SPEC_ADAPT=0` — the adaptive default disables the drafter per request and HALVES decode)**, MTP head INSIDE the checkpoint (`HALOGEN_MTP_HEAD` unset), **TWO slots over ONE `HALOGEN_KV_POOL_POSITIONS=401408` pool (= 2 × ctx) at `HALOGEN_CTX=200704`** (pool RAISED from 200,704 on 2026-10-18 so BOTH slots can hold a maximum-length request at once; the parity play was frank-zealot's then-401,408 total in-flight context — zealot moved to 786,432 the same evening and dragoon's pool was NOT raised again, because every position is page cache the n-gram table loses; ~10.8 GiB reserved where 200,704 cost 5.5, 262,144 cost 7.2 and the 2026-10-06 pool measured 14.6; the ceiling does NOT move with it — 262,144 is the native wall and the engine refuses more without `HALOGEN_ROPE_YARN`), harness window 155,648 = ceiling − 45k estimate headroom, **server-side sampler 1.0/0.95/20** (peonist engine recipe — Quat3rnion publishes none; models.yml extraBody matches), coupled cap/timeout 65536/7200, prompt cache ON by default (warm 100k turn ≈ 2 s vs ≈ 88 s cold), **n-gram table disk-backed** (mmap page cache, `Pin::None`); measured vs the swift15 boot: cold prefill +12.6 %, decode 50.0 vs 46.8 t/s, NIAH 18/18 vs 17/18, PPL +2.3 %, thinking tokens ×1.9|7770|
|
||||
| Dankclaw | `frank-zealot` | **llama.cpp PrismML fork build** (vendor release `0.2.0-dev` build **10777**, commit `e3de71522`; root systemd unit, relinked — the ONLY loader for this payload, and since 2026-10-18 also the minimum build that pairs MTP with a quantized V cache) | `prism-ml/Ternary-Bonsai-2-9B-gguf` **PQ2_0 carrying the MTP head in the file** (2,675,410,720 B, Hub-LFS sha256 `c79fcdfa…` verified at fetch by `scripts/download-frank-zealot-bonsai9.sh`; `qwen35` hybrid: `block_count 33` = **32 trunk layers** (8 full-attention + 24 Gated DeltaNet at `full_attention_interval 4`) + the `blk.32` MTP block, 4 KV heads × 256 dim, native 262,144; `general.file_type 141` with **202 custom-type PQ2_0 tensors of 442** + `prism.hadamard.*` dequant metadata, which is what locks the runtime) + the publisher's own **Q8_0 mmproj** (624,229,664 B, sha256 `e0cb6f69…`; **Q8_0 is the publisher's requirement** for concurrent image requests — the BF16 tower can crash the server, and this endpoint runs three slots) for **vision**, on the RTX 3090, **THREE 262,144-token slots** (`--ctx-size 786432 --parallel 3` = every conversation at the trained window), **q8_0 K + q8_0 V** under flash attention (17 KiB/token over the 8 full layers = 13,056 MiB pool, ledger-exact; **f16 KV needs 26,112 MiB and cannot boot this card**; +452 MiB of recurrent state for the three DeltaNet cells), batch 4096 / **ubatch 2048** (measured CUDA optimum), `--log-verbosity 4`, **MTP OFF — measured, not because the head is absent**: the draft context bills its own f16 KV over the WHOLE pool (3,072 MiB) and at ubatch 2048 that squeeze halves decode (167.2 → 109.2 t/s); the documented MTP arm is `SPEC_TYPE=draft-mtp` + `UBATCH_SIZE=1024` → 213.5 t/s. Boot 17,876 used / 6,249 free; 167.2 t/s single-stream decode, 280.2 t/s aggregate across three concurrent streams, 2,877 t/s prefill at 106,418. Cutover 2026-10-18 from Ternary-Bonsai-2-**27B** PQ2_0 at 2 × 200,704 (payload + build 10754 tree on disk as rollback). | 7771 |
|
||||
| Dankclaw | `frank-dragoon` | **halogen-flash-server 0.16.2** (`ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…`, digest-pinned, plain `podman run` via `scripts/serve-frank-dragoon.sh`, root systemd unit) | Quat3rnion/halogen-qwen3.8-flash-next-v2-abliterated **`qwen38-flash-next-v2-abliterated.hgn`** (66,687,678,432 B ≈ 62.1 GiB resident, Hub LFS sha256 `b5bd925f…` verified at fetch — the **NATIVE 2026-10-11 v2 conversion** of orcarouter's pinned BF16, abliterated, NOT the writer transplant that carried this filename; the v2 `.hgn` format loads in NO other engine), **+ REQUIRED `qwen38-flash-next-ngram.hgn`** (51,200,246,144 B, peonist @5cc17cea) **+ vision tower** (897,916,416 B, @e053f488) — both **hardlinked** from the swift15 payload dir (the release publishes only the main file; the engine's own `niah` 18/18 + `ppl` 3.968 with mean KL 0.155 vs the swift15 logits certified the stock companions), **vision ON** (`image_url` parts only, ≥256 px probes), **MTP spec decode ALWAYS-ON (`HALOGEN_SPEC_ADAPT=0` — the adaptive default disables the drafter per request and HALVES decode)**, MTP head INSIDE the checkpoint (`HALOGEN_MTP_HEAD` unset), **TWO slots over ONE `HALOGEN_KV_POOL_POSITIONS=401408` pool (= 2 × ctx) at `HALOGEN_CTX=200704`** (pool RAISED from 200,704 and DEPLOYED 2026-10-18 so BOTH slots can hold a maximum-length request at once; the parity play was frank-zealot's then-401,408 total in-flight context — zealot moved to 786,432 the same evening and dragoon's pool was NOT raised again, because every position is page cache the n-gram table loses; **11.0 GiB reserved, measured on that deploy** (the preflight formula bills 11.3) where 200,704 cost 5.5, 262,144 cost 7.2 and the 2026-10-06 pool measured 14.6; the ceiling does NOT move with it — 262,144 is the native wall and the engine refuses more without `HALOGEN_ROPE_YARN`), harness window 155,648 = ceiling − 45k estimate headroom, **server-side sampler 1.0/0.95/20** (peonist engine recipe — Quat3rnion publishes none; models.yml extraBody matches), coupled cap/timeout 65536/7200, prompt cache ON by default (warm 100k turn ≈ 2 s vs ≈ 88 s cold), **n-gram table disk-backed** (mmap page cache, `Pin::None`); measured vs the swift15 boot: cold prefill +12.6 %, decode 50.0 vs 46.8 t/s, NIAH 18/18 vs 17/18, PPL +2.3 %, thinking tokens ×1.9|7770|
|
||||
| Dankclaw | `frank-zealot` | **llama.cpp PrismML fork build** (vendor release `0.2.0-dev` build **10777**, commit `e3de71522`; root systemd unit, relinked — the ONLY loader for this payload, and since 2026-10-18 also the minimum build that pairs MTP with a quantized V cache) | `prism-ml/Ternary-Bonsai-2-9B-gguf` **PQ2_0 carrying the MTP head in the file** (2,675,410,720 B, Hub-LFS sha256 `c79fcdfa…` verified at fetch by `scripts/download-frank-zealot-bonsai9.sh`; `qwen35` hybrid: `block_count 33` = **32 trunk layers** (8 full-attention + 24 Gated DeltaNet at `full_attention_interval 4`) + the `blk.32` MTP block, 4 KV heads × 256 dim, native 262,144; `general.file_type 141` with **202 custom-type PQ2_0 tensors of 442** + `prism.hadamard.*` dequant metadata, which is what locks the runtime) + the publisher's own **Q8_0 mmproj** (624,229,664 B, sha256 `e0cb6f69…`; **Q8_0 is the publisher's requirement** for concurrent image requests — the BF16 tower can crash the server, and this endpoint runs three slots) for **vision**, on the RTX 3090, **THREE 262,144-token slots** (`--ctx-size 786432 --parallel 3` = every conversation at the trained window), **q8_0 K + q8_0 V** under flash attention (17 KiB/token over the 8 full layers = 13,056 MiB pool, ledger-exact; **f16 KV needs 26,112 MiB and cannot boot this card**; +151 MiB of recurrent state for the three DeltaNet cells — the SPEC-ARMED arms print 452 MiB, because MTP depth 2 bills three seqs per slot), batch 4096 / **ubatch 2048** (measured CUDA optimum), `--log-verbosity 4`, **MTP OFF — measured, not because the head is absent**: the draft context bills its own f16 KV over the WHOLE pool (3,072 MiB) and at ubatch 2048 that squeeze halves decode (167.2 → 109.2 t/s); the documented MTP arm is `SPEC_TYPE=draft-mtp` + `UBATCH_SIZE=1024` → 213.5 t/s. Production boot 17,922 used / 6,203 free; **168.7 t/s** single-stream greedy decode (three runs, 168.4–168.7) and **~300 t/s** aggregate across three concurrent streams (102.8 each); 2,877 t/s prefill at 106,418 was the trial arm. Cutover 2026-10-18 from Ternary-Bonsai-2-**27B** PQ2_0 at 2 × 200,704 (payload + build 10754 tree on disk as rollback). | 7771 |
|
||||
|
||||
Retired endpoints (`frank-npu`, and every prior runtime) are in
|
||||
the [runbook index](#runbook-index). The full swap history lives in
|
||||
@@ -48,17 +48,37 @@ the [runbook index](#runbook-index). The full swap history lives in
|
||||
pages (`serverAppConfigs-docs/log.md` carries the wave plan and results)
|
||||
- `tests/` — bats suite + Playwright e2e
|
||||
|
||||
## Git Workflow
|
||||
## Git Workflow — trunk-based (fleet law, aligned 2026-10-18)
|
||||
|
||||
All edits flow through git. Never edit files on a remote machine directly.
|
||||
|
||||
1. **Source** the live config from the target machine
|
||||
2. **Commit** the fetched version here (redact secrets into `.env`)
|
||||
3. **Edit** locally
|
||||
4. **Test** — `./tests/run.sh` must pass
|
||||
5. **Commit and push** — origin is Gitea on Dankclaw
|
||||
6. **Pull** on the target (`deploy.sh` does this)
|
||||
7. **Deploy** — `scripts/deploy.sh` plans and dispatches only changed services
|
||||
`main` is the only long-lived branch: no `develop`, no release branches, no
|
||||
back-merges. Frank is the only dev; this repo and `dank/command-center` follow
|
||||
the same regime.
|
||||
|
||||
1. **Branch fresh** — `git fetch`, then branch off the current `origin/main`
|
||||
(`<type>/<slug>`, type ∈ feat|fix|docs|chore). If main moves under you,
|
||||
merge it into your branch — never rebase.
|
||||
2. **Source** the live config from the target machine
|
||||
3. **Commit** the fetched version here (redact secrets into `.env`)
|
||||
4. **Edit** locally
|
||||
5. **Test** — `./tests/run.sh` must pass
|
||||
6. **Push the branch and open a PR against `main`** — origin is Gitea on
|
||||
Dankclaw. Merge with `--no-ff`; **never merge a PR without an explicit,
|
||||
current OK from Frank.** **The moment the PR is open, open it in Frank's
|
||||
browser** (`xdg-open <PR-URL>` — a Firefox tab on the workstation); never
|
||||
make him hunt for it in Gitea. Releases: tag `vX.Y.Z` straight on `main`.
|
||||
7. **Pull** on the target (`deploy.sh` does this)
|
||||
8. **Deploy** — `scripts/deploy.sh` plans and dispatches only changed services
|
||||
|
||||
**Attribution footer (issues and PRs)** — every issue or PR body you open MUST end with:
|
||||
|
||||
```
|
||||
---
|
||||
cwd: <working directory> · session: <omp session id>
|
||||
```
|
||||
|
||||
Session id from `/status`; `cwd` is that session's own working directory — not the build checkout, when they differ (note the build worktree in the body instead). Machine-made artifacts stay attributable to the box and session that spawned them.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -231,8 +251,12 @@ 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; a live llama.cpp server also reports honest per-request numbers in the `/completion` `timings` object (prefill/decode rates, `draft_n`, `draft_n_accepted`), which is how frank-dragoon and frank-zealot were measured. Rolling your own bench
|
||||
requires asking first.
|
||||
endpoints; a live llama.cpp server (frank-zealot) also reports honest
|
||||
per-request numbers in its `/completion` `timings` object (prefill/decode
|
||||
rates, `draft_n`, `draft_n_accepted`), which is how frank-zealot was measured.
|
||||
halogen has NO `/completion` path at all — frank-dragoon's rates come from
|
||||
wall-clock around `/v1/chat/completions` plus the boot ledger. 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.
|
||||
@@ -250,9 +274,13 @@ 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.
|
||||
returns nothing. **That header is a llama.cpp feature**: halogen flash-server
|
||||
0.16.2 answers `/v1/chat/completions` with only `date`, `server` and
|
||||
`content-*` (verified 2026-10-18), and `/completion`, `/ping`, `/props` and
|
||||
`/tokenize` are all 404 on it — on frank-dragoon the A/B instrument is
|
||||
wall-clock around the POST plus `usage.prompt_tokens`. 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
|
||||
@@ -407,10 +435,10 @@ MAXIMUM-length conversations DO coexist. The PER-REQUEST ceiling stays
|
||||
`HALOGEN_CTX=200704`: the checkpoint's native window is 262,144 and the engine's
|
||||
own 400 text refuses to exceed it without `HALOGEN_ROPE_YARN`, so "more context"
|
||||
bought here is CONCURRENCY, not a longer single conversation. ~28 KiB/position ⇒
|
||||
the ledger reserves "about 10.8 GiB", where 200,704 cost 5.5, 262,144 cost 7.2
|
||||
and the 2026-10-06 shape's 524,288 measured 14.6; the engine's preflight FORMULA
|
||||
bills slightly more than the reservation (block scratch + ~115 MiB/slot of O(1)
|
||||
state) — quote the reservation.
|
||||
the 2026-10-18 deploy RESERVES **11.0 GiB** (`reserving the KV pool, 401408
|
||||
positions (about 11.0 GiB)`; the preflight formula bills 11.3 with block scratch
|
||||
+ ~115 MiB/slot of O(1) state), where 200,704 cost 5.5, 262,144 cost 7.2 and
|
||||
the 2026-10-06 shape's 524,288 measured 14.6 — quote the reservation.
|
||||
`HALOGEN_MAX_TOK=8192` is only the per-request DEFAULT (the
|
||||
harness sends at most its `maxTokens` 45056); `HALOGEN_MAX_TOKENS_CAP=65536` +
|
||||
`HALOGEN_QUEUE_TIMEOUT=7200` are a coupled pair (FLAGS.md table).
|
||||
@@ -428,10 +456,12 @@ stays mandatory. Thinking tokens COUNT against the budget (empty content +
|
||||
"video".
|
||||
|
||||
**Memory ledger** (121.2 GiB unified; the referee is the boot ledger): weights
|
||||
62.9 (trunk 62.11 + vision 0.84) + KV ~10.8 + working 5.7
|
||||
(`3.8 + 8.4 × MAX_TOK/16384`) ≈ **79.4 GiB in all**, the ~1.0 GiB prompt cache
|
||||
booked separately by the ledger, + `HALOGEN_HOST_RESERVE_GIB=20` → ~22 GiB
|
||||
margin over baseline co-tenancy (the pool-equals-ctx boot printed 74.1 GiB with
|
||||
62.9 (trunk 62.11 + vision 0.84) + KV **11.0** + working 5.7
|
||||
(`3.8 + 8.4 × MAX_TOK/16384`) = **79.6 GiB in all** (boot ledger, the
|
||||
401,408-position deploy of 2026-10-18), the **1.3 GiB** prompt cache (12 entries
|
||||
× 111 MiB at ctx 200704) booked separately by the ledger, + `HALOGEN_HOST_
|
||||
RESERVE_GIB=20` → ~20 GiB margin over baseline co-tenancy (`MemAvailable` read
|
||||
22 GiB right after the raise; the pool-equals-ctx boot printed 74.1 GiB with
|
||||
~30 GiB of margin). The 51.2 GB n-gram table rides as mmap'd page cache beside
|
||||
the checkpoint (ledger: `mapped 51.2 GB, NOT pinned (Pin::None)`) — which is why
|
||||
every pool position is a double cost: **RAM the pool takes is RAM that table
|
||||
@@ -494,9 +524,10 @@ so **q8_0 K + q8_0 V bills 17,408 B/token ≈ 17 KiB** = **13,056.00 MiB** at th
|
||||
786,432 pool (ledger-exact) — exactly HALF the 27B's 34,816 B/token, which is
|
||||
the whole reason three native windows fit where the 27B afforded only two
|
||||
shortened ones. **f16 K would bill 26,112 MiB and cannot boot on a 24,123 MiB
|
||||
card.** The 24 DeltaNet layers bill a fixed recurrent state per SLOT —
|
||||
`RS buffer size = 452.25 MiB (3 cells, 32 layers, 3 seqs)` ≈ 150 MiB each — so
|
||||
pool arithmetic alone under-prices the shape.
|
||||
card.** The 24 DeltaNet layers bill a fixed recurrent state per SLOT — the
|
||||
production ledger prints `RS buffer size = 150.75 MiB (3 cells, 32 layers,
|
||||
3 seqs)`, while the SPEC-ARMED arms print 452.25 MiB (MTP depth 2 bills three
|
||||
seqs per slot, so 9 cells) — so pool arithmetic alone under-prices the shape.
|
||||
|
||||
**The shape (2026-10-18):** `--ctx-size 786432 --parallel 3` = THREE
|
||||
conversations of 262,144, every one at the model's TRAINED window (Frank: "if
|
||||
@@ -1168,6 +1199,26 @@ linked runbooks.
|
||||
is broken". Diagnose with `lastError` from `/api/v1/targets`, not the alert list,
|
||||
and note Alertmanager has no persistence: a reboot re-stamps `startsAt` on an
|
||||
alert that has been firing for weeks.
|
||||
- **A leftover bench drop-in silently hijacks the next deploy, and no health
|
||||
gate sees it.** The sanctioned bench-window method — a transient
|
||||
`Environment=FRANK_<SVC>_ENV_FILE=/tmp/<arm>.env` drop-in, the arm env
|
||||
`source`ing the repo env and then overriding it — outlives the bench unless
|
||||
it is retired. On 2026-10-18 a leftover `frank-zealot.service.d/ptq1.conf` —
|
||||
the arm Frank had ordered at 02:36 and measured from 03:14 — made the cutover
|
||||
deploy boot a **different model at a different shape** (3 slots of 133,888,
|
||||
not 3 × 262,144) while `/health` answered 200, `/v1/models` echoed the alias
|
||||
(a flag, not a fact) and the Playwright gate passed 33/33. After deploying any
|
||||
recently-benched LLM service: `systemctl show <svc> -p DropInPaths` (must be
|
||||
empty), then `tr '\0' ' ' </proc/$(pidof llama-server)/cmdline`, then the boot
|
||||
ledger — and retire drop-ins to `~/.AGENTTRASH/<date>-<topic>/` +
|
||||
`daemon-reload`.
|
||||
- **A bench verdict that never reaches the repo is indistinguishable from a
|
||||
rogue arm.** Same window: PTQ1_0 served production for 24 minutes with real
|
||||
numbers (~48 t/s decode, 801 t/s prefill at 141k), but the figures lived only
|
||||
in `/tmp` env files and journald, so the next agent on the host read the
|
||||
surviving drop-in as an unfinished experiment and reported a hijack. Write the
|
||||
measurement into the runbook and the arm script's header in the session that
|
||||
takes it — `/tmp` and the journal are not records.
|
||||
|
||||
**Ops**
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# frank-tts — the cloned-voice speech façade (dankclaw/voiceservice/app.py).
|
||||
#
|
||||
# Standard library only, so there is nothing to pip-install and nothing to
|
||||
# compile: the image is a python runtime plus ffmpeg (the one binary the app
|
||||
# shells out to, for response_format=mp3) plus one COPY of app.py. Rebuild to
|
||||
# ship a new app.py.
|
||||
#
|
||||
# Build on dankclaw (deploy-remote.sh build_compose_image frank-tts does this,
|
||||
# including --format docker, which the fleet's other local builds also pass):
|
||||
# podman build --format docker -t franksdank/frank-tts:latest \
|
||||
# -f dankclaw/voiceservice/Containerfile .
|
||||
#
|
||||
# BASE PINNED BY DIGEST, like every other image this fleet runs from a
|
||||
# registry: a tag moves under a rebuild and a voice endpoint that changes
|
||||
# python or ffmpeg mid-week is a debug session nobody asked for. The digest is
|
||||
# the multi-arch INDEX of python:3.12-alpine as published 2026-10-18 (amd64 +
|
||||
# arm64 on linux); Dankclaw resolves the amd64 entry.
|
||||
#
|
||||
# WHY 3.12: the fleet's ceiling for this app is python <= 3.12 (single file,
|
||||
# no build step); the alpine flavour also keeps the image in the tens of MB
|
||||
# and ships busybox `wget`, which is what the quadlet unit's HealthCmd runs —
|
||||
# so no HEALTHCHECK belongs here (the probe lives in the unit, as in
|
||||
# alertmanager/prometheus; only distroless bases need it baked in).
|
||||
FROM docker.io/library/python:3.12-alpine@sha256:1b668429b3511ab407d8e00648891631b0b1a4d7e15e3ca70f38ab5b91ad4ab4
|
||||
|
||||
# ffmpeg: mp3 output. The app always asks the upstream for wav and re-encodes
|
||||
# on the way out, so ffmpeg is the only external program in the picture.
|
||||
RUN apk add --no-cache ffmpeg
|
||||
|
||||
# Unbuffered stdout: the unit's journal must show the "listening on" line (and
|
||||
# any backend error) the moment it happens, not when the buffer fills.
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
COPY dankclaw/voiceservice/app.py /app/app.py
|
||||
|
||||
# Non-root. The voice packs are bind-mounted read-only, so this user only ever
|
||||
# reads them — they must be world-readable (the pack builder writes 644/755).
|
||||
# The one thing it writes is its per-request temp dir under /tmp.
|
||||
RUN adduser -D -u 10000 -h /app -s /sbin/nologin shim
|
||||
USER shim
|
||||
|
||||
EXPOSE 7772
|
||||
CMD ["python3", "/app/app.py"]
|
||||
Executable
+413
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env python3
|
||||
"""frank-tts — the fleet's OpenAI-compatible speech façade (component: the shim).
|
||||
|
||||
One local endpoint that answers ``voice=<name>`` with audio in a cloned voice.
|
||||
Consumers (the omp harness, OpenWebUI, open-notebook) speak the OpenAI
|
||||
``POST /v1/audio/speech`` shape and never learn a file path: a voice is a
|
||||
directory under ``$VOICES_DIR`` and ``reference.wav`` inside it is what makes
|
||||
the name real. A name that has no reference is not a voice — the shim answers
|
||||
400 with the list that is, instead of silently narrating in a fallback voice
|
||||
(silent fallback is how a wrong-voice endpoint stays wrong for weeks).
|
||||
|
||||
Two backends sit behind the one surface, chosen by ``TTS_BACKEND``:
|
||||
|
||||
``omni-http``
|
||||
Forward to an OpenAI-shaped upstream TTS server (``$TTS_UPSTREAM``) and
|
||||
carry the cloned voice with the request as ``reference_audio`` (base64 of
|
||||
``reference.wav``). The upstream does the inference; this process only
|
||||
resolves the voice and, when asked, re-encodes to mp3 with ``ffmpeg``.
|
||||
|
||||
``llama-tts-cli``
|
||||
Run a local binary per request (``$LLAMA_TTS_BIN``), hand it the
|
||||
reference with ``--tts-speaker-file``, read the WAV it writes. ``nice -n
|
||||
19`` because this box also serves text and a voice request must never
|
||||
starve it.
|
||||
|
||||
Everything comes from the environment, read at REQUEST time (no
|
||||
import-time-only globals): the same module can be imported with an injected
|
||||
``Config`` or run with ``python3 app.py`` and the unit's env file.
|
||||
|
||||
Runtime law: single process, standard library only, no build step. Audio
|
||||
bytes never reach the log — request lines and error text only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
WAV_CONTENT_TYPE = "audio/wav"
|
||||
MP3_CONTENT_TYPE = "audio/mpeg"
|
||||
|
||||
# A voice name is a plain directory name. Slashes and `..` are refused here so
|
||||
# a client-supplied name can never escape $VOICES_DIR.
|
||||
VOICE_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|
||||
|
||||
|
||||
class ShimError(Exception):
|
||||
"""A failure with the HTTP status the client should see."""
|
||||
|
||||
def __init__(self, status, message):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
class Config:
|
||||
"""Every knob of the shim. Constructed from the environment per request by
|
||||
default, or injected into ``create_server`` for tests and embedding."""
|
||||
|
||||
def __init__(self, voices_dir, backend, upstream, upstream_model, llama_bin,
|
||||
llama_model, llama_mmproj, llama_lang, llama_threads, port,
|
||||
max_chars, timeout):
|
||||
self.voices_dir = Path(voices_dir)
|
||||
self.backend = backend
|
||||
self.upstream = upstream
|
||||
self.upstream_model = upstream_model
|
||||
self.llama_bin = llama_bin
|
||||
self.llama_model = llama_model
|
||||
self.llama_mmproj = llama_mmproj
|
||||
self.llama_lang = llama_lang
|
||||
self.llama_threads = llama_threads
|
||||
self.port = port
|
||||
self.max_chars = max_chars
|
||||
self.timeout = timeout
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, environ=None):
|
||||
env = os.environ if environ is None else environ
|
||||
return cls(
|
||||
voices_dir=env.get("VOICES_DIR", "/voices"),
|
||||
backend=(env.get("TTS_BACKEND") or "omni-http").strip().lower(),
|
||||
upstream=(env.get("TTS_UPSTREAM") or "http://host.containers.internal:8880").rstrip("/"),
|
||||
upstream_model=env.get("TTS_UPSTREAM_MODEL") or "voxcpm2",
|
||||
llama_bin=env.get("LLAMA_TTS_BIN") or "",
|
||||
llama_model=env.get("LLAMA_TTS_MODEL") or "",
|
||||
llama_mmproj=env.get("LLAMA_TTS_MMPROJ") or "",
|
||||
llama_lang=env.get("LLAMA_TTS_LANG") or "english",
|
||||
llama_threads=_int(env, "LLAMA_TTS_THREADS", 8),
|
||||
port=_int(env, "SHIM_PORT", 7772),
|
||||
max_chars=_int(env, "SHIM_MAX_CHARS", 15000),
|
||||
timeout=float(_int(env, "SHIM_TIMEOUT", 600)),
|
||||
)
|
||||
|
||||
|
||||
def _int(env, name, default):
|
||||
try:
|
||||
return int(str(env.get(name, "")).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def list_voices(voices_dir):
|
||||
"""Voice names in ``voices_dir``: directories that hold a ``reference.wav``.
|
||||
|
||||
A missing or unreadable directory is an empty fleet, not an error — the
|
||||
health endpoint must still answer so the unit reports its state.
|
||||
"""
|
||||
try:
|
||||
entries = sorted(p.name for p in voices_dir.iterdir()
|
||||
if p.is_dir() and (p / "reference.wav").is_file())
|
||||
except OSError:
|
||||
return []
|
||||
return entries
|
||||
|
||||
|
||||
def resolve_voice(voices_dir, voice):
|
||||
"""``$VOICES_DIR/<voice>/reference.wav`` for a valid name, else ``None``."""
|
||||
if not isinstance(voice, str) or not VOICE_NAME_RE.fullmatch(voice):
|
||||
return None
|
||||
reference = voices_dir / voice / "reference.wav"
|
||||
return reference if reference.is_file() else None
|
||||
|
||||
|
||||
def to_mp3(wav_bytes, timeout):
|
||||
"""Re-encode WAV bytes to mp3 with the image's ``ffmpeg`` (pipes, no temp
|
||||
file: the audio never lands on disk to be left behind)."""
|
||||
argv = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "pipe:0",
|
||||
"-f", "mp3", "pipe:1"]
|
||||
try:
|
||||
proc = subprocess.run(argv, input=wav_bytes, capture_output=True,
|
||||
timeout=timeout)
|
||||
except FileNotFoundError:
|
||||
raise ShimError(500, "response_format=mp3 needs the ffmpeg binary, which is not on PATH")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ShimError(502, "ffmpeg mp3 conversion timed out after %gs" % timeout)
|
||||
if proc.returncode != 0 or not proc.stdout:
|
||||
raise ShimError(502, "ffmpeg mp3 conversion failed (exit %s): %s"
|
||||
% (proc.returncode, _tail(proc.stderr)))
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _tail(raw):
|
||||
"""Last line of a subprocess' stderr, for an error message. Text only —
|
||||
audio bytes are never echoed, and never logged."""
|
||||
text = (raw or b"").decode("utf-8", "replace").strip()
|
||||
return text.splitlines()[-1][:300] if text else "no output"
|
||||
|
||||
|
||||
def _error(message, voices=None):
|
||||
body = {"error": {"message": message, "type": "invalid_request_error"}}
|
||||
if voices is not None:
|
||||
body["error"]["available_voices"] = voices
|
||||
return body
|
||||
|
||||
|
||||
class SpeechHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "frank-tts"
|
||||
sys_version = ""
|
||||
|
||||
# -- plumbing ----------------------------------------------------------
|
||||
def config(self):
|
||||
return getattr(self.server, "config", None) or Config.from_env()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
# One prefix for the journal. Request lines and outcomes only: a
|
||||
# synthesized text, a base64 reference voice and the audio bytes
|
||||
# itself never reach stderr, in success or in failure.
|
||||
sys.stderr.write("frank-tts: %s - %s\n"
|
||||
% (self.address_string(), fmt % args))
|
||||
|
||||
def log_error(self, fmt, *args):
|
||||
# The base class routes refused requests through a separate sink; keep
|
||||
# them on the same line format so the journal reads uniformly.
|
||||
self.log_message(fmt, *args)
|
||||
|
||||
def _json(self, status, obj, head=False):
|
||||
self._bytes(status, json.dumps(obj).encode("utf-8"), "application/json", head)
|
||||
|
||||
def _bytes(self, status, data, content_type, head=False):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
if not head:
|
||||
self.wfile.write(data)
|
||||
|
||||
def _drain(self):
|
||||
"""Read and discard a body this route will not answer. HTTP/1.1
|
||||
keep-alive makes an unread body the NEXT request line, so a client
|
||||
that reuses the connection would see a parse error it never sent."""
|
||||
if getattr(self, "_body_read", False):
|
||||
return # already consumed; a second read would block the response
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
return
|
||||
if length > 0:
|
||||
self.rfile.read(length)
|
||||
|
||||
def _body(self):
|
||||
"""Parse the JSON request body. Never returned to a log or an error."""
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
raise ValueError("Content-Length is not a number")
|
||||
raw = self.rfile.read(length) if length > 0 else b""
|
||||
self._body_read = True
|
||||
if not raw:
|
||||
raise ValueError("expected a JSON body")
|
||||
parsed = json.loads(raw.decode("utf-8"))
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("expected a JSON object")
|
||||
return parsed
|
||||
|
||||
# -- routes ------------------------------------------------------------
|
||||
def do_GET(self, head=False):
|
||||
# HEAD shares the route table: `wget --spider` (the fleet's probe
|
||||
# shape) asks for HEAD, and a 501 there would read as a dead service.
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
cfg = self.config()
|
||||
if path == "/health":
|
||||
self._json(200, {"status": "ok", "backend": cfg.backend,
|
||||
"voices": list_voices(cfg.voices_dir)}, head)
|
||||
elif path == "/v1/audio/voices":
|
||||
self._json(200, list_voices(cfg.voices_dir), head)
|
||||
else:
|
||||
self._json(404, _error("no such endpoint: %s" % path), head)
|
||||
|
||||
def do_HEAD(self):
|
||||
self.do_GET(head=True)
|
||||
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path != "/v1/audio/speech":
|
||||
self._drain()
|
||||
self._json(404, _error("no such endpoint: %s" % path))
|
||||
return
|
||||
cfg = self.config()
|
||||
try:
|
||||
body = self._body()
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
self._drain()
|
||||
self._json(400, _error("malformed request body: %s" % exc))
|
||||
return
|
||||
|
||||
text = body.get("input")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
self._json(400, _error("`input` must be a non-empty string"))
|
||||
return
|
||||
if len(text) > cfg.max_chars:
|
||||
self._json(414, _error("input is %d characters, over the %d limit "
|
||||
"(SHIM_MAX_CHARS); split it or raise the limit"
|
||||
% (len(text), cfg.max_chars)))
|
||||
return
|
||||
|
||||
fmt = str(body.get("response_format") or "wav").strip().lower()
|
||||
if fmt not in ("wav", "mp3"):
|
||||
self._json(400, _error("unsupported response_format %r; this "
|
||||
"endpoint serves wav and mp3" % fmt))
|
||||
return
|
||||
|
||||
# `voice_id` is the alias the omp harness forwards for `voice`.
|
||||
voice = body.get("voice") or body.get("voice_id")
|
||||
reference = resolve_voice(cfg.voices_dir, voice)
|
||||
if reference is None:
|
||||
available = list_voices(cfg.voices_dir)
|
||||
listed = ", ".join(available) if available else "(none: no voice pack with a reference.wav in %s)" % cfg.voices_dir
|
||||
self._json(400, _error("unknown voice %r; available voices: %s"
|
||||
% (voice, listed), available))
|
||||
return
|
||||
|
||||
model = str(body.get("model") or cfg.upstream_model)
|
||||
try:
|
||||
if cfg.backend == "omni-http":
|
||||
audio = self._speech_http(cfg, model, text, str(voice), reference)
|
||||
elif cfg.backend == "llama-tts-cli":
|
||||
audio = self._speech_cli(cfg, text, reference)
|
||||
else:
|
||||
raise ShimError(500, "unknown TTS_BACKEND %r (expected omni-http or llama-tts-cli)" % cfg.backend)
|
||||
if fmt == "mp3":
|
||||
audio = to_mp3(audio, cfg.timeout)
|
||||
except ShimError as exc:
|
||||
self._json(exc.status, _error(exc.message))
|
||||
return
|
||||
|
||||
content_type = MP3_CONTENT_TYPE if fmt == "mp3" else WAV_CONTENT_TYPE
|
||||
self._bytes(200, audio, content_type)
|
||||
self.log_message("speech voice=%s format=%s chars=%d -> %d bytes %s",
|
||||
voice, fmt, len(text), len(audio), content_type)
|
||||
|
||||
# -- backends ----------------------------------------------------------
|
||||
def _speech_http(self, cfg, model, text, voice, reference):
|
||||
"""Forward to the upstream with the cloned voice riding along as
|
||||
base64 ``reference_audio``. The upstream is asked for wav always; mp3
|
||||
is our own re-encode on the way out."""
|
||||
try:
|
||||
payload = {
|
||||
"model": model,
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": "wav",
|
||||
"reference_audio": base64.b64encode(reference.read_bytes()).decode("ascii"),
|
||||
}
|
||||
except OSError as exc:
|
||||
raise ShimError(500, "cannot read %s: %s" % (reference, exc))
|
||||
|
||||
url = cfg.upstream + "/v1/audio/speech"
|
||||
request = urllib.request.Request(
|
||||
url, data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=cfg.timeout) as response:
|
||||
audio = response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read(512).decode("utf-8", "replace").strip()
|
||||
raise ShimError(502, "upstream %s returned HTTP %d: %s"
|
||||
% (url, exc.code, detail or "no detail"))
|
||||
except urllib.error.URLError as exc:
|
||||
raise ShimError(502, "upstream %s unreachable: %s" % (url, exc.reason))
|
||||
except (TimeoutError, OSError) as exc:
|
||||
raise ShimError(502, "upstream %s failed: %s" % (url, exc))
|
||||
if not audio:
|
||||
raise ShimError(502, "upstream %s returned no audio" % url)
|
||||
return audio
|
||||
|
||||
def _speech_cli(self, cfg, text, reference):
|
||||
"""Run the local TTS binary once, in a temp dir that is always removed
|
||||
— also on failure, or a crash-looping binary fills /tmp."""
|
||||
if not cfg.llama_bin or not cfg.llama_model:
|
||||
raise ShimError(500, "TTS_BACKEND=llama-tts-cli needs LLAMA_TTS_BIN and LLAMA_TTS_MODEL")
|
||||
work = tempfile.TemporaryDirectory(prefix="frank-tts-")
|
||||
try:
|
||||
out_path = Path(work.name) / "speech.wav"
|
||||
argv = ["nice", "-n", "19", "env", "OMP_NUM_THREADS=%d" % cfg.llama_threads,
|
||||
cfg.llama_bin, "-m", cfg.llama_model]
|
||||
if cfg.llama_mmproj:
|
||||
argv += ["-mm", cfg.llama_mmproj]
|
||||
argv += ["--tts-lang", cfg.llama_lang,
|
||||
"--tts-speaker-file", str(reference),
|
||||
"-p", text,
|
||||
"--output", str(out_path)]
|
||||
try:
|
||||
proc = subprocess.run(argv, cwd=work.name, capture_output=True,
|
||||
timeout=cfg.timeout)
|
||||
except FileNotFoundError as exc:
|
||||
raise ShimError(500, "cannot run the TTS command: %s" % exc)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ShimError(502, "TTS binary timed out after %gs" % cfg.timeout)
|
||||
if proc.returncode != 0:
|
||||
raise ShimError(502, "TTS binary failed (exit %s): %s"
|
||||
% (proc.returncode, _tail(proc.stderr)))
|
||||
if not out_path.is_file():
|
||||
raise ShimError(502, "TTS binary exited 0 but wrote no %s" % out_path.name)
|
||||
return out_path.read_bytes()
|
||||
finally:
|
||||
work.cleanup()
|
||||
|
||||
|
||||
class SpeechServer(ThreadingHTTPServer):
|
||||
"""Threads so a health probe answers while a long synthesis runs; daemon
|
||||
threads so SIGTERM is never held hostage by a stuck request."""
|
||||
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
config = None
|
||||
|
||||
|
||||
def create_server(config=None, host="0.0.0.0", port=None):
|
||||
"""Bind the shim. ``config``/``port`` are the injectable seams: tests pass
|
||||
a Config with a temp VOICES_DIR and ``port=0`` for an ephemeral port."""
|
||||
cfg = Config.from_env() if config is None else config
|
||||
server = SpeechServer((host, cfg.port if port is None else port), SpeechHandler)
|
||||
server.config = cfg
|
||||
return server
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="frank-tts", description="OpenAI-compatible speech endpoint for cloned voice packs")
|
||||
parser.add_argument("--host", default="0.0.0.0",
|
||||
help="bind address (default: all interfaces)")
|
||||
parser.add_argument("--port", type=int, default=None,
|
||||
help="bind port (default: $SHIM_PORT, 0 = ephemeral)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
server = create_server(host=args.host, port=args.port)
|
||||
cfg = server.config
|
||||
bound = server.server_address[1]
|
||||
print("frank-tts: backend=%s voices_dir=%s listening on http://%s:%d"
|
||||
% (cfg.backend, cfg.voices_dir, args.host, bound), flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,71 @@
|
||||
# Quadlet unit: frank-tts (rootless, boot-persistent).
|
||||
#
|
||||
# Install path on dankclaw: ~/.config/containers/systemd/frank-tts.container
|
||||
# Unit name: frank-tts.service (container name: frank-tts)
|
||||
#
|
||||
# WHAT IT IS. The fleet's one OpenAI-compatible speech endpoint: consumers
|
||||
# (omp, OpenWebUI, open-notebook) POST /v1/audio/speech with voice=<name> and
|
||||
# get audio in that cloned voice. A voice is a directory under
|
||||
# /home/dank/voices holding reference.wav; the shim refuses any name without
|
||||
# one (400 + the list that does exist) rather than narrating in a fallback
|
||||
# voice nobody chose. The engine behind it is a separate decision —
|
||||
# TTS_BACKEND in scripts/frank-tts.env picks between forwarding to an
|
||||
# OpenAI-shaped upstream on host :8880 and running a local CLI binary per
|
||||
# request.
|
||||
#
|
||||
# IMAGE. Built locally from dankclaw/voiceservice/Containerfile by
|
||||
# deploy-remote.sh (build_compose_image), like loki/alloy: python 3.12-alpine
|
||||
# pinned by digest + ffmpeg, app.py copied in, running as uid 10000.
|
||||
# Pull=never and the localhost/ prefix are required — this host's quadlet
|
||||
# path cannot resolve a locally built tag from local storage otherwise.
|
||||
#
|
||||
# NETWORK. Bridge (dankclaw_default), NOT host: nothing here needs a shared
|
||||
# loopback namespace, and the comfyui/loki/authentik units are bridge too.
|
||||
# Consequences both ways: consumers reach it on the published 7772, and the
|
||||
# shim reaches the host-side TTS engine through host.containers.internal
|
||||
# (never the LAN IP — a bridge netns cannot route to the host's own address
|
||||
# for every bind shape, and the fleet's law is the gateway name). NOTE for
|
||||
# whoever starts the :8880 engine: host.containers.internal reaches the host
|
||||
# network namespace, so the engine must bind an address that namespace
|
||||
# accepts; a 127.0.0.1-only bind is not it (the 2026-09-17 signal-relay hop
|
||||
# needed the host LAN bind for the same reason).
|
||||
#
|
||||
# VOICES. /home/dank/voices is host state, never repo state, and is mounted
|
||||
# READ-ONLY: this endpoint has no business writing voice packs. Because the
|
||||
# container runs as uid 10000, the packs must be world-readable (644 files,
|
||||
# 755 directories), which is how the pack builder writes them.
|
||||
#
|
||||
# NO GPU. The shim is a façade: audio conversion via ffmpeg on the CPU and,
|
||||
# for the CLI backend, whatever CPU threads LLAMA_TTS_THREADS allows, at nice
|
||||
# 19. No /dev/kfd, no /dev/dri, no keep-groups — unlike comfyui, whose unit
|
||||
# carries them deliberately.
|
||||
[Unit]
|
||||
Description=frank-tts (cloned-voice OpenAI-compatible TTS facade)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=frank-tts
|
||||
Image=localhost/franksdank/frank-tts:latest
|
||||
Pull=never
|
||||
Network=dankclaw_default
|
||||
PublishPort=7772:7772
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts.env
|
||||
Volume=/home/dank/voices:/voices:ro
|
||||
# The probe is a probe, not a voice test: /health answers 200 with the backend
|
||||
# and the resolved voice list, so a mounted-but-empty /voices still reads
|
||||
# healthy while a dead app does not. busybox wget ships in the alpine base;
|
||||
# keep the command argument-only — the podman 5.x generator splits HealthCmd
|
||||
# on spaces (see the comfyui unit for the quoting trap).
|
||||
HealthCmd=wget --spider -q http://127.0.0.1:7772/health
|
||||
HealthInterval=30s
|
||||
HealthTimeout=10s
|
||||
HealthRetries=3
|
||||
HealthStartPeriod=30s
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -23,6 +23,15 @@
|
||||
# moving 17 % less weight per step can pay. That is an argument, not a
|
||||
# measurement: this script exists so the decision is made on OUR card, against
|
||||
# the PQ2_0 record (76.6 t/s single-stream decode, 1,258 t/s prefill at 35k).
|
||||
# MEASURED ON OUR CARD (2026-10-18). Frank ordered this shape at 02:36 — Q8
|
||||
# mmproj, batch 3072/ubatch 1536, two contexts — and the unit ran it as
|
||||
# production 03:14:39→03:38:57 (pid 191358, journal `type ptq1_0: 402 tensors`).
|
||||
# Its own print_timing lines: decode **48.0 / 47.6 t/s at 512 tokens** (47.9 at
|
||||
# 229, 49.0 at 451), 25–27 t/s per stream with two streams live, prefill 1,295
|
||||
# t/s at 30,637 → 943 at 97,856 → 801 at 141,365. The 1,201 MiB arrived and the
|
||||
# decode tax came with it: −37 % against the PQ2_0 record, so the Ampere warning
|
||||
# DOES generalize to GA102. Frank scratched the 27B plan at 03:33 for the 9B;
|
||||
# this payload is the 27B rollback, not a candidate.
|
||||
#
|
||||
# THE RUNTIME LOCK CARRIES OVER. Both packings are custom GGML tensor types and
|
||||
# stock llama.cpp rejects them ("It rejects PQ2_0 and PTQ1_0 as unknown types"
|
||||
|
||||
+15
-12
@@ -68,10 +68,11 @@
|
||||
# ceiling does NOT move with it: HALOGEN_CTX stays 200,704, because the
|
||||
# checkpoint's native window is 262,144 and the engine refuses to go past it
|
||||
# without HALOGEN_ROPE_YARN (its own 400 text says so). ~28 KiB/position
|
||||
# (FLAGS.md) ⇒ ~10.8 GiB, where pool == ctx (200,704) cost 5.5, 262,144 cost
|
||||
# 7.2 and the 2026-10-06 shape's 524,288 measured 14.6. The engine's preflight
|
||||
# formula bills a little more than the reservation (block scratch + ~115
|
||||
# MiB/slot of O(1) state) — quote the RESERVATION. FLAGS.md law: "RAM the pool
|
||||
# (FLAGS.md) ⇒ the DEPLOYED 2026-10-18 boot RESERVES 11.0 GiB (`reserving the
|
||||
# KV pool, 401408 positions (about 11.0 GiB)`), where pool == ctx (200,704)
|
||||
# cost 5.5, 262,144 cost 7.2 and the 2026-10-06 shape's 524,288 measured 14.6.
|
||||
# The preflight formula bills 11.3 (block scratch + ~115 MiB/slot of O(1)
|
||||
# state) — quote the RESERVATION. FLAGS.md law: "RAM the pool
|
||||
# takes is RAM that table loses" — the 47.68 GiB n-gram table surrenders this
|
||||
# much page cache, so the warm prompt-cache ratio is what to re-measure.
|
||||
# HALOGEN_KV_SLOTS=2 restores the w4b-era shape on this box: the MTP
|
||||
@@ -82,14 +83,16 @@
|
||||
# Memory ledger against the box's 121.2 GiB unified (the n-gram table is
|
||||
# mmap'd page cache beside the checkpoint, not a pinned charge — the boot
|
||||
# ledger agrees: "mapped 51.2 GB, NOT pinned (Pin::None)"):
|
||||
# weights ~62.9 GiB locked (0.8 of them the vision tower) + KV ~10.8 +
|
||||
# working ~5.7 (HALOGEN_MAX_TOK=8192) ≈ 79.4 GiB engine-side, with the
|
||||
# prompt cache (~1.0 at this ctx; 1.3 was measured at 262144) booked
|
||||
# separately by the ledger; HALOGEN_HOST_RESERVE_GIB=20 keeps the host
|
||||
# honest. Against the 121.2 GiB box that leaves ~22 GiB over the reserve for
|
||||
# baseline co-tenancy (frank-zealot, ComfyUI, Caddy/observ ~10-15). The boot
|
||||
# ledger is the referee — read it after deploy and re-size if the engine
|
||||
# prints a clamp.
|
||||
# weights 62.9 GiB locked (0.8 of them the vision tower) + KV 11.0 +
|
||||
# working 5.7 (HALOGEN_MAX_TOK=8192) = 79.6 GiB engine-side (the boot
|
||||
# ledger of the 401408-position deploy, 2026-10-18), with the 1.3 GiB
|
||||
# prompt cache (12 entries x 111 MiB at ctx 200704) booked separately by
|
||||
# the ledger; HALOGEN_HOST_RESERVE_GIB=20 keeps the host honest. Against
|
||||
# the 121.2 GiB box that leaves ~20 GiB over the reserve (MemAvailable
|
||||
# read 22 GiB right after the raise) for baseline co-tenancy
|
||||
# (frank-zealot, ComfyUI, Caddy/observ ~10-15). The boot ledger is the
|
||||
# referee — read it after deploy and re-size if the engine prints a
|
||||
# clamp.
|
||||
# HALOGEN_SPEC_ADAPT=0 is MANDATORY (card): the default adaptive policy
|
||||
# turns the MTP head off for whole requests after low acceptance and
|
||||
# HALVES decode as a result. Frank accepted the head always-on trade
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# frank-tts.env — tunables for **frank-tts**, the fleet's cloned-voice speech
|
||||
# endpoint on Dankclaw, host port **7772** (the LLM port family: 7770
|
||||
# frank-dragoon, 7771 frank-zealot, 7772 frank-tts). Read by the Quadlet unit
|
||||
# (EnvironmentFile=), which is why the paths below are CONTAINER paths.
|
||||
#
|
||||
# WHAT THIS IS. One OpenAI-compatible /v1/audio/speech façade in front of
|
||||
# whichever TTS engine wins the engine decision (dankclaw/voiceservice/app.py
|
||||
# is a single stdlib-only file in a locally built image). Consumers — omp's
|
||||
# TTS device, OpenWebUI, open-notebook — address a VOICE BY NAME
|
||||
# (voice=narrator1, or the harness' voice_id=narrator1) and never see a path:
|
||||
# a name is real exactly when /voices/<name>/reference.wav exists, and
|
||||
# anything else is a 400 that lists the names that are.
|
||||
#
|
||||
# THE PACKS. /home/dank/voices on the host, mounted read-only at /voices. One
|
||||
# directory per voice, and a directory is a PACK only when it carries BOTH
|
||||
# required members:
|
||||
# reference.wav 24 kHz mono PCM, 18-25 s of clean single-speaker audio,
|
||||
# plus the 16 kHz variant some engines want;
|
||||
# the engine artifact the pinned backend's own speaker file (embedding /
|
||||
# speaker weights). REQUIRED, beside the WAV and never
|
||||
# replacing it — a voice that cannot be reused by another
|
||||
# runtime is not a saved voice.
|
||||
# plus manifest.json for provenance: source file, offsets (always past the
|
||||
# first 10 s of that file, the source-material law), engine, sha256. Packs are
|
||||
# host state and stay out of this repo; they must be world-readable because the
|
||||
# container runs as uid 10000. Who enforces what: the shim gates on
|
||||
# reference.wav because that is the only engine-agnostic term, so the pack
|
||||
# builder is what refuses to emit a pack without its engine artifact — until a
|
||||
# winning engine pins the format and this gate can name the file.
|
||||
#
|
||||
# BACKEND SWITCH. TTS_BACKEND picks the shape and nothing else changes for the
|
||||
# consumer:
|
||||
# omni-http forward to an OpenAI-shaped upstream on the host (:8880),
|
||||
# carrying the voice along as base64 reference_audio. The
|
||||
# engine does the inference; the shim resolves the voice and
|
||||
# re-encodes to mp3 when asked.
|
||||
# llama-tts-cli run LLAMA_TTS_BIN once per request with
|
||||
# --tts-speaker-file <reference.wav>, nice 19 so a narration
|
||||
# never starves the text endpoints on this box. Needs
|
||||
# LLAMA_TTS_BIN + LLAMA_TTS_MODEL filled in; LLAMA_TTS_MMPROJ
|
||||
# only if that binary takes a projector.
|
||||
# The upstream address is host.containers.internal — the gateway name a bridge
|
||||
# container uses for the host. It is NOT the LAN IP and it is NOT
|
||||
# 127.0.0.1: a bridge netns has neither.
|
||||
VOICES_DIR=/voices
|
||||
TTS_BACKEND=omni-http
|
||||
TTS_UPSTREAM=http://host.containers.internal:8880
|
||||
TTS_UPSTREAM_MODEL=voxcpm2
|
||||
|
||||
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
|
||||
# configured": the endpoint answers a 500 that says so instead of running
|
||||
# `nice env -m ...` with an empty binary name.
|
||||
LLAMA_TTS_BIN=
|
||||
LLAMA_TTS_MODEL=
|
||||
LLAMA_TTS_MMPROJ=
|
||||
LLAMA_TTS_LANG=english
|
||||
LLAMA_TTS_THREADS=8
|
||||
|
||||
# The wire shape. 7772 is frank-tts's host port (the container's too), the next
|
||||
# slot in the fleet's model-endpoint family; the TTS engine upstream of it
|
||||
# keeps 8880, which is not a fleet endpoint.
|
||||
SHIM_PORT=7772
|
||||
# Longest single narration, in characters of `input`. Podcast-length text is
|
||||
# the consumer's job to chunk; one request over this limit is refused with 414
|
||||
# rather than silently truncated into a half-sentence of audio.
|
||||
SHIM_MAX_CHARS=15000
|
||||
# Seconds one request may take, covering both an upstream synthesis and a CLI
|
||||
# run. Voice is slow compared to text — a long paragraph on a CPU engine is
|
||||
# minutes, not milliseconds — and a client that gave up earlier would only
|
||||
# retry into a second synthesis competing for the same CPU.
|
||||
SHIM_TIMEOUT=600
|
||||
+20
-13
@@ -65,9 +65,11 @@ UBATCH_SIZE=2048
|
||||
# the ledger said exactly that. HALF the 27B's 34,816 B/token, which is the
|
||||
# whole reason three native windows fit. f16 would bill 26,112 MiB at this pool
|
||||
# (against a 24,123 MiB card) and dies at cudaMalloc; the 24 DeltaNet layers
|
||||
# bill a fixed recurrent state per SLOT instead of per token — the ledger's
|
||||
# "RS buffer size = 452.25 MiB (3 cells, 32 layers, 3 seqs)" = 150.75 MiB a
|
||||
# slot — which is why pool arithmetic alone under-prices the shape.
|
||||
# bill a fixed recurrent state per SLOT instead of per token — the PRODUCTION
|
||||
# ledger prints "RS buffer size = 150.75 MiB (3 cells, 32 layers, 3 seqs)"
|
||||
# (~50 MiB a cell), while the SPEC-ARMED arms print 452.25 MiB, because MTP
|
||||
# depth 2 bills three seqs per slot (9 cells) — which is why pool arithmetic
|
||||
# alone under-prices the shape.
|
||||
CACHE_TYPE_K=q8_0
|
||||
CACHE_TYPE_V=q8_0
|
||||
# SPECULATIVE DECODING: OFF, MEASURED. The head is in the file and the drafter
|
||||
@@ -103,18 +105,23 @@ SPEC_DRAFT_P_MIN=0
|
||||
# PRESENCE_PENALTY="" is the documented opt-out.
|
||||
PRESENCE_PENALTY=1.0
|
||||
NGL=99
|
||||
# Boot ledger (2026-10-18, this exact shape, nvidia-smi settled):
|
||||
# 17,876 MiB used / 6,249 MiB free of 24,123
|
||||
# Boot ledger (2026-10-18 PRODUCTION boot under the unit, verbosity 4):
|
||||
# 17,922 MiB used / 6,203 MiB free of 24,123 (nvidia-smi settled)
|
||||
# CUDA0 model 2,036.71 MiB + CPU_Mapped 257.66 MiB
|
||||
# CUDA0 KV 13,056.00 MiB (q8_0 K + q8_0 V, 786,432 positions)
|
||||
# CUDA0 RS 452.25 MiB (3 recurrent-state cells)
|
||||
# compute 1,409 MiB CUDA + 1,201 MiB CUDA_Host, projector 595 + 248 MiB
|
||||
# CUDA0 RS 150.75 MiB (3 cells, 32 layers, 3 seqs)
|
||||
# compute 1,409.02 MiB CUDA + 1,088.87 MiB CUDA_Host (+2.84 MiB output buf)
|
||||
# mmproj worst-case estimate 868.32 MiB (248.10 CUDA + 24.93 CPU compute
|
||||
# meta) — charged AFTER the engine's own fit check, as always
|
||||
# no clamp line, no cudaMalloc error
|
||||
# Measured on the same boot: 167.2 t/s greedy decode (/completion, 512 tokens),
|
||||
# 158.9 t/s single-stream chat, 280.2 t/s aggregate across THREE concurrent
|
||||
# streams (93.8 each), 2,877 t/s prefill at 106,418 tokens. The 27B record it
|
||||
# replaces: 76.6 decode, 109.4 aggregate across two streams, 691.7 prefill at
|
||||
# 166k — beaten on every axis. The FIRST request after a boot is a warmup
|
||||
# artifact (37.7 t/s class); never report it.
|
||||
# Measured on this boot (/completion timings, greedy, ignore_eos, 512 tokens):
|
||||
# 168.7 t/s decode (three runs 168.4-168.7) and ~300 t/s aggregate across
|
||||
# THREE concurrent streams (102.8 each). The trial arm's record (167.2 decode,
|
||||
# 280.2 aggregate, 2,877 t/s prefill at 106,418) stands; the 27B it replaces
|
||||
# gave 76.6 / 109.4 / 691.7 at 166k. The FIRST request after a boot is a
|
||||
# warmup artifact (37.7 t/s class) and a natural generation that stops early
|
||||
# makes a wall-clock aggregate read 4x low - measure with ignore_eos and read
|
||||
# the engine's own timings.
|
||||
# Fallback rung if the margin is ever eaten (desktop, co-tenant): q4_0 KV on the
|
||||
# 27B era measured 16,392 used / 7,733 free at 2 × 200,704; the same drop here
|
||||
# frees ~6.5 GiB at three windows. UBATCH_SIZE=1024 is the cheaper lever
|
||||
|
||||
@@ -53,11 +53,18 @@ Policy while candidate arms held the port): the candidate — Ternary-Bonsai-2-*
|
||||
PQ2_0 with the MTP head, 3 × 262,144 — was **deployed as production**, so the
|
||||
picker row below was re-keyed to it (see the next paragraph).
|
||||
|
||||
The `model` table carries exactly one decoration row, `frank-zealot`
|
||||
(context length 262,144 = the per-slot window since 2026-10-18; description
|
||||
re-keyed the same day to Ternary-Bonsai-2-9B PQ2_0 + MTP + Q8_0 vision mmproj —
|
||||
the row's `description` is live DB state, so a payload swap must re-key it in
|
||||
the database too, not only in the repo).
|
||||
The `model` table carries exactly two decoration rows, `frank-zealot` and
|
||||
`frank-dragoon`. Both the context length and the description live **inside
|
||||
JSON columns** — `params` holds `{"context": {"length": …}}` and `meta` holds
|
||||
`{"description": …}`; there is no `context_length` or `description` column, so
|
||||
`sqlite3` one-liners against those names fail. Re-keyed 2026-10-18:
|
||||
`frank-zealot` → 262,144 with the Bonsai-2-9B description, and `frank-dragoon`
|
||||
→ 200,704 (its `params` had kept the pre-lowering 262,144 window from
|
||||
2026-10-17 while its own description already said 200,704). These rows are
|
||||
live DB state, so a payload swap must re-key the database too, not only the
|
||||
repo. There is no `sqlite3` binary on Dankclaw — use `python3 -c` with the
|
||||
stdlib `sqlite3` module, and take a `Connection.backup()` snapshot first (the
|
||||
DB is in WAL and the app is live).
|
||||
|
||||
A third gate sits *after* the table, and it is the one that bites family
|
||||
accounts (2026-10-07): a model row is only visible to a non-admin user when
|
||||
|
||||
@@ -202,10 +202,9 @@ boot ledger is unchanged to the MiB — 62.9 GiB weights (0.8 of them vision) +
|
||||
GiB KV + 5.7 GiB working = 74.1 GiB, two slots over ONE 200,704-position pool, MTP
|
||||
depth 2, prompt cache ON, no clamp line — and Gitea, the Caddy edge and Grafana all
|
||||
answered 200. (Later the same day the pool DOUBLING to 401,408 positions for
|
||||
slot parity was COMMITTED — `2b12716` — but as of 2026-10-18 NOT yet
|
||||
redeployed: the live boot still reserves ONE 200,704-position pool (5.5 GiB),
|
||||
host `frank-dragoon.env` included. When it lands, the reservation is ~10.8
|
||||
GiB and the engine-side total ~79.4 GiB;
|
||||
slot parity was COMMITTED (`2b12716`) and **deployed the same evening**: the
|
||||
boot now reserves `401408 positions (about 11.0 GiB)` and the engine-side total
|
||||
is `79.6 GiB in all` (62.9 weights + 11.0 KV + 5.7 working),
|
||||
see [[frank-dragoon-flash-server-v2-native]].)
|
||||
|
||||
**The NPU came back with the flip.** `amdxdna` bound to `0000:c6:00.1` (Strix/
|
||||
|
||||
@@ -94,9 +94,9 @@ the pool: **two long conversations can no longer share the endpoint** (two of
|
||||
≤100k can). That is acceptable here because frank-dragoon carries the `slow`
|
||||
(medium-thinking) role and the `security-reviewer` subagent, not the
|
||||
interactive workhorse (that is frank-archon).
|
||||
Frank lifted exactly that concurrency limit on 2026-10-18 — see *Pool
|
||||
doubled for slot parity* below; that lift is COMMITTED but not yet deployed,
|
||||
so the sentences above remain the live truth until the next redeploy.
|
||||
Frank lifted exactly that concurrency limit on 2026-10-18 and the lift is
|
||||
**deployed** (section below): the pool is again `slots × ctx`, so two
|
||||
maximum-length conversations fit the endpoint at once.
|
||||
|
||||
**Memory ledger** (121.2 GiB unified), from the boot ledgers. The 22:20 boot
|
||||
printed `pinned 62.11 GiB in 1 range(s) in 1.6 s (41.0 GB/s)` trunk,
|
||||
@@ -115,7 +115,7 @@ that long prompts read.
|
||||
`HALOGEN_FLASH_PIN_TRUNK=0` is **rejected by the v2 loader** — the trunk
|
||||
stays pinned. Disk-backing the weights is not a lever on this format.
|
||||
|
||||
## Pool doubled for slot parity — committed 2026-10-18, awaiting redeploy
|
||||
## Pool doubled for slot parity — committed AND deployed 2026-10-18
|
||||
|
||||
`HALOGEN_KV_POOL_POSITIONS` **200,704 → 401,408** (commit `2b12716`), Frank's
|
||||
call: frank-dragoon's total in-flight context was **half** frank-zealot's —
|
||||
@@ -133,24 +133,34 @@ conversation had to stay under ~100k (commit message;
|
||||
(`scripts/serve-frank-dragoon.sh:85-95`); 401,408 is `slots × ctx`, the top
|
||||
of the legal band, and exactly frank-zealot's pool number — parity, not
|
||||
approximation.
|
||||
- Expected reservation **~10.8 GiB** at ~28 KiB/position (preflight formula,
|
||||
`scripts/frank-dragoon.env:70-72`); the measured points beside it are 5.5
|
||||
GiB at 200,704 (boot ledger 2026-10-18), 7.2 GiB reserved / 7.4 GiB billed
|
||||
at 262,144, and 14.6 GiB at the 2026-10-06 shape's 524,288
|
||||
- Measured reservation **11.0 GiB** — the boot ledger prints `reserving the KV
|
||||
pool, 401408 positions (about 11.0 GiB)`. The preflight KV-budget line bills
|
||||
**11.3 GiB** (`~28 KiB/position incl. block scratch + ~115 MiB/slot`) plus
|
||||
1.3 GiB of prompt cache. The measured points beside it: 5.5 GiB at 200,704,
|
||||
7.2 GiB at 262,144, and 14.6 GiB at the 2026-10-06 shape's 524,288
|
||||
(`scripts/frank-dragoon.env:49-52`, `:74-76`).
|
||||
- That RAM comes out of the n-gram table's page cache — FLAGS.md law: RAM the
|
||||
pool takes is RAM that table loses.
|
||||
|
||||
**Deploy state as of 2026-10-18: NOT yet deployed.** The live container
|
||||
(booted 2026-10-18 01:25) still runs `HALOGEN_KV_POOL_POSITIONS=200704` —
|
||||
verified in its `flash_serve` `/proc/<pid>/environ` and in the host-deployed
|
||||
`frank-dragoon.env` — and the 2026-10-18 boot ledger reserves `the KV pool,
|
||||
200704 positions (about 5.5 GiB)` with `74.1 GiB in all`
|
||||
(`/home/dank/logs/frank_dragoon.log`). Until the next
|
||||
`./scripts/deploy.sh dankclaw --service frank-dragoon`, the boot ledger keeps
|
||||
printing `2 slots over ONE 200704-position KV pool`; the first boot at the
|
||||
new pool must show `ONE 401408-position KV pool` and the ~10.8 GiB
|
||||
reservation — read that line before quoting 401,408 as live anywhere.
|
||||
**Deployed 2026-10-18** (`./scripts/deploy.sh dankclaw --service
|
||||
frank-dragoon`, Playwright gate 33/33). The boot ledger confirms the shape:
|
||||
`reserving the KV pool, 401408 positions (about 11.0 GiB)`,
|
||||
`2 slots over ONE 401408-position KV pool, each request up to 200704 (it
|
||||
reserves prompt + max_tokens)`, and the sum
|
||||
`62.9 GiB of weights locked in RAM (0.8 of them the vision tower's), 11.0 GiB
|
||||
of KV pool, 5.7 GiB of working memory, 79.6 GiB in all`. Prompt cache ON (12
|
||||
entries × 111 MiB at ctx 200704 = the 1.3 GiB in the KV-budget line), n-gram
|
||||
47.68 GiB still disk-backed (`Pin::None`), no clamp line. Host `MemAvailable`
|
||||
fell 26 → 22 GiB across the raise, which is the FLAGS.md collision being paid
|
||||
in page cache. **Warm reuse survived it — measured after the deploy.** One
|
||||
**101,987**-token wikitext prompt through `/v1/chat/completions` took **81.5 s**
|
||||
cold (1,251 t/s of prefill) and **0.3 s** on each of two repeats:
|
||||
`HALOGEN_CACHE_INPLACE=1` keeps the KV rows in the pool, so a warm turn prefills
|
||||
nothing and the doubling evicted nothing the cache needed. (The pre-raise
|
||||
136.7× figure was a `ttft` reading; this engine sends **no `Server-Timing`**
|
||||
header and has **no `/completion` path** — `/ping`, `/props`, `/tokenize` are
|
||||
404 too — so wall-clock around the POST plus `usage.prompt_tokens` is the only
|
||||
instrument available here.)
|
||||
|
||||
## Measured A/B — same battery, both boots (2026-10-17)
|
||||
|
||||
@@ -231,7 +241,12 @@ is mid-thought); vision via OpenAI `image_url` content parts only, ≥256 px
|
||||
probes; `response_format` unsupported; the chat template rejects
|
||||
`reasoning_effort` outside `xhigh|medium|low` (the `models.yml` fold stays
|
||||
mandatory); unknown sampling params are a 400; the prompt cache is on by
|
||||
default, so a warm 100k follow-up costs seconds. See
|
||||
default, so a warm 100k follow-up costs seconds (measured after the pool raise:
|
||||
**81.5 s cold vs 0.3 s warm** at 101,987 prompt tokens). The HTTP surface is
|
||||
thin — `/health`, `/metrics`, `/v1/models` and POST `/v1/chat/completions`
|
||||
answer, while `/ping`, `/props`, `/completion` and `/tokenize` are 404 and no
|
||||
response carries `Server-Timing`: llama.cpp's timing instruments do not exist
|
||||
on this engine, so measure wall-clock plus `usage.prompt_tokens`. See
|
||||
[[frank-dragoon-flash-server-v2-swift15]] for the engine-flag detail that
|
||||
carries verbatim, and [[deployment]] for the deploy/health gate.
|
||||
|
||||
@@ -307,9 +322,17 @@ restart; `webui.db.bak-20261017b` holds the pre-cutover row).
|
||||
**What the cut cost:** with pool == ctx one maximum-length request fills the
|
||||
pool, so two 200k conversations never coexist (two of ≤100 k do), and the
|
||||
harness window is now 155,648 — the 180 k compaction target of the 262,144 era
|
||||
is unreachable because the window itself is smaller. (The pool == ctx
|
||||
concurrency limit is superseded — on redeploy only — by *Pool doubled for slot
|
||||
parity* above; the window math stands, the pool never moves the ceiling.)
|
||||
is unreachable because the window itself is smaller. (That pool == ctx
|
||||
concurrency limit IS superseded: the doubling deployed the next day — see
|
||||
*Pool doubled for slot parity* above. The window math stands, though: the pool
|
||||
never moves the per-request ceiling.)
|
||||
**What it bought:** 1.7
|
||||
GiB back as page cache for the 47.68 GiB n-gram table (9.1 GiB against the
|
||||
2026-10-06 shape's 524,288 pool), and a smaller prompt-cache entry.
|
||||
|
||||
**Third production boot — the `401408` pool, deployed 2026-10-18.** Same ctx,
|
||||
same working set, same 111 MiB prompt-cache entry; the pool line reads
|
||||
`reserving the KV pool, 401408 positions (about 11.0 GiB)` and the total
|
||||
`79.6 GiB in all`. Ledger and the measured cost in
|
||||
*Pool doubled for slot parity* above; host `MemAvailable` after that boot:
|
||||
22 GiB.
|
||||
@@ -76,8 +76,10 @@ axis improved — decode, prefill, three-stream aggregate, and free VRAM.
|
||||
**13,056.00 MiB**, ledger-exact. That is exactly HALF the 27B's 34,816 B/token.
|
||||
**f16 KV would bill 26,112 MiB against a 24,123 MiB card** and dies at
|
||||
`cudaMalloc`. The 24 DeltaNet layers bill a fixed recurrent state per SLOT
|
||||
instead — `RS buffer size = 452.25 MiB (3 cells, 32 layers, 3 seqs)` ≈ 150 MiB
|
||||
each — so pool arithmetic alone under-prices the shape.
|
||||
instead: the PRODUCTION ledger prints `RS buffer size = 150.75 MiB (3 cells,
|
||||
32 layers, 3 seqs)` ≈ 50 MiB a cell, while the SPEC-ARMED arms printed
|
||||
452.25 MiB — MTP depth 2 bills three seqs per slot, so the drafter triples
|
||||
even the recurrent state. Pool arithmetic alone under-prices the shape.
|
||||
|
||||
## The shape (`scripts/frank-zealot.env`)
|
||||
|
||||
@@ -123,30 +125,36 @@ inputs plus 2–3 concurrent subagents — so the shipped arm takes the +17 %
|
||||
prefill and +12 % three-stream aggregate over single-stream speed, and keeps
|
||||
6.2 GiB free. Frank confirmed this arm on 2026-10-18.
|
||||
|
||||
## Boot ledger (deployed arm, verbosity 4)
|
||||
## Boot ledger (production boot 2026-10-18 13:19, under the unit, verbosity 4)
|
||||
|
||||
```
|
||||
CUDA0 KV buffer size = 13056.00 MiB # q8_0 K + q8_0 V, 786,432 positions
|
||||
CUDA0 RS buffer size = 452.25 MiB # 3 recurrent-state cells, 32 layers, 3 seqs
|
||||
compute 1,409 MiB CUDA + 1,201 MiB CUDA_Host
|
||||
mmproj 595 MiB + 248 MiB # charged AFTER the fit check
|
||||
nvidia-smi settled: 17,876 MiB used / 6,249 MiB free of 24,123
|
||||
CUDA0 model buffer size = 2036.71 MiB # + 257.66 MiB CPU_Mapped (mmap)
|
||||
CUDA0 KV buffer size = 13056.00 MiB # q8_0 K + q8_0 V, 786,432 positions
|
||||
CUDA0 RS buffer size = 150.75 MiB # 3 cells, 32 layers, 3 seqs
|
||||
CUDA0 compute buffer = 1409.02 MiB # + 1088.87 MiB CUDA_Host, 2.84 output
|
||||
mmproj worst-case (est.) = 868.32 MiB # 248.10 CUDA + 24.93 CPU, AFTER fit
|
||||
nvidia-smi settled: 17,922 MiB used / 6,203 MiB free of 24,123
|
||||
n_slots = 3, n_ctx_slot = 262144, kv_unified = false
|
||||
```
|
||||
|
||||
No clamp line, no `cudaMalloc` error. The engine's own projection (21,439-class
|
||||
numbers from the 27B era) excludes the projector — **read settled `nvidia-smi`,
|
||||
never the projection**.
|
||||
|
||||
## Measured (deployed arm)
|
||||
## Measured (production boot, `/completion` `timings`, greedy, `ignore_eos`)
|
||||
|
||||
- **167.2 t/s** greedy decode (512 tokens, `/completion` `timings`); 158.9 t/s
|
||||
single-stream chat with the card's sampler.
|
||||
- **280.2 t/s aggregate** across three concurrent streams (≈93.8 each).
|
||||
- **2,877 t/s prefill** at 106,418 prompt tokens.
|
||||
- **168.7 t/s** decode — three runs of 512 tokens: 168.4 / 168.7 / 168.7
|
||||
(the trial arm printed 167.2; same shape, same noise band).
|
||||
- **~300 t/s aggregate** across three concurrent 512-token streams
|
||||
(102.8 t/s per stream, round 1 and 2 both) — the trial arm's 280.2.
|
||||
- **2,877 t/s prefill at 106,418 tokens** — the trial arm; the production boot
|
||||
has not re-run that depth.
|
||||
- The 27B record it replaces: 76.6 decode, 109.4 aggregate across two streams,
|
||||
691.7 prefill at 166k. Beaten on every axis.
|
||||
- The **first request after a boot is a warmup artifact** (37.7 t/s class) —
|
||||
never report it.
|
||||
never report it. And never time a wall-clock aggregate on a generation that
|
||||
stops on its own: a 24-token countdown measured 72 t/s across three streams
|
||||
while the same shape with `ignore_eos` gave 300.
|
||||
|
||||
## Contract traps
|
||||
|
||||
@@ -186,6 +194,21 @@ never the projection**.
|
||||
7771 — a live port with no unit behind it. `TimeoutStopSec=180` is now on the
|
||||
unit (llama.cpp needs the drain time to release slots and the card); the
|
||||
hand-booted arm must be killed by PID, never `pkill -f llama-server`.
|
||||
- **A leftover arm drop-in hijacks the production boot, and no health check
|
||||
sees it.** The sanctioned bench method is a transient
|
||||
`Environment=FRANK_ZEALOT_ENV_FILE=/tmp/<arm>.env` drop-in, and an arm env
|
||||
`source`s the repo env and then overrides it. On 2026-10-18 a stale
|
||||
`frank-zealot.service.d/ptq1.conf` — written 03:29 for the 27B **PTQ1_0** arm
|
||||
Frank had ordered at 02:36 and measured from 03:14 (deploy ledger below) —
|
||||
was still installed, so the cutover deploy restarted
|
||||
the unit and booted THAT: `--model …/Bonsai2-PTQ1_0/Ternary-Bonsai-2-27B-PTQ1_0.gguf
|
||||
--ctx-size 401408 --parallel 3 --batch-size 3072 --ubatch-size 1536`, three
|
||||
slots of 133,888, 21,188 MiB used. `/health` was 200, `/v1/models` said
|
||||
`frank-zealot` (the alias is a flag, not a fact), and the Playwright gate
|
||||
passed 33/33 — none of those read the payload. **After deploying an endpoint
|
||||
that was recently benched: `systemctl show -p DropInPaths`, then
|
||||
`tr '\0' ' ' </proc/$pid/cmdline`, then the ledger — in that order.** Retire
|
||||
the drop-in to `~/.AGENTTRASH/<date>-<topic>/`, `daemon-reload`, restart.
|
||||
|
||||
## Compaction hub
|
||||
|
||||
@@ -197,6 +220,47 @@ the window ≈ 222,822 est, worst case ≈ 245,891 ≤ 262,144). With `--paralle
|
||||
a compaction request no longer serializes behind a live generation (the Gemma4
|
||||
era's `--parallel 1` law).
|
||||
|
||||
## Deploy ledger (2026-10-18)
|
||||
|
||||
- `./scripts/deploy.sh dankclaw --service frank-zealot` at 13:16 PDT; the e2e
|
||||
gate ran 33/33. The first boot was the hijacked arm above; the drop-in went
|
||||
to `~/.AGENTTRASH/20261018-zealot-ptq1-arm/` and the 13:19 restart is the
|
||||
ledger recorded here.
|
||||
- Live probes on the shipped boot: chat `27*59` → **1593**; `where_am_i` tool
|
||||
call fired with schema-correct empty arguments; 512 px solid-red PNG →
|
||||
**Red** at 278 prompt tokens with `process_mtmd` chunk encoding in the
|
||||
journal; `/props` reports `n_ctx 262144` and `presence_penalty 1.0`;
|
||||
`/metrics` answers 200 with 15 `llamacpp:*` families and
|
||||
`up{job="frank-zealot"}` is **1** in Prometheus for the first time in its
|
||||
retained history.
|
||||
- OpenWebUI picker rows re-keyed in the live DB (`Connection.backup()` snapshot
|
||||
`webui.db.bak-20261018` taken first; `frank-dragoon`'s `context.length` also
|
||||
corrected 262,144 → 200,704 — see [[OpenWebUI]]).
|
||||
- **What the bench window left behind is not garbage — the PTQ1_0 arm was
|
||||
ordered, measured, and then scratched by Frank himself.** The instruction at
|
||||
**02:36** was: run frank-zealot on `Ternary-Bonsai-2-27B-PTQ1_0.gguf`, the
|
||||
**Q8** mmproj, batch/ubatch **3072/1536**, and as much context as fits in
|
||||
**two** contexts. The unit booted it at **03:14** (journal:
|
||||
`type ptq1_0: 402 tensors`, `file type = PTQ1_0 - 1.75 bpw ternary`, mmproj
|
||||
loaded, 2 × 200,704) and served 24 minutes of traffic. Its own
|
||||
`print_timing` lines measure **~48 t/s** single-stream decode
|
||||
(48.0 / 47.9 / 47.6 / 49.0 t/s on 512 / 229 / 512 / 451-token generations),
|
||||
25–27 t/s per stream with two streams live, and prefill **1,295 t/s at
|
||||
30,637 → 943 at 97,856 → 801 t/s at 141,365** tokens. It bought the
|
||||
1,201 MiB the fetch script promised and paid for it on decode, exactly as the
|
||||
publisher's Ampere row warned (the PQ2_0 record for the same weights is
|
||||
76.6 t/s). At **03:33** Frank scratched the whole 27B plan and ordered the
|
||||
9B PQ2_0 + MTP shape deployed here; the arm process ended at 03:38.
|
||||
- The actual failure was **documentation, not measurement.** An approved
|
||||
payload that survives only as a systemd drop-in plus a journal window is,
|
||||
a day later, indistinguishable from a rogue arm — which is why the 13:16 boot
|
||||
read as a hijack. Payload decisions belong in this runbook the moment they
|
||||
are made. The evidence is still perishable: `/tmp/zealot-arm-current.env` and
|
||||
the 9B `/tmp/arm-*.jsonl` results die on reboot, and only the journal
|
||||
(`journalctl -u frank-zealot --since "2026-10-18 03:10"`) holds the PTQ1_0
|
||||
numbers. `/home/dank/models/Bonsai2-PTQ1_0/` (5,946,648,928 B + hardlinked
|
||||
mmproj) stays on disk as the 27B rollback.
|
||||
|
||||
## Fallback rungs
|
||||
|
||||
1. `UBATCH_SIZE=1024` — frees **1,306 MiB** measured on this shape (prefill
|
||||
|
||||
@@ -22,14 +22,15 @@ and Dankclaw runs two endpoints — **halogen-flash-server 0.16.2
|
||||
digest serving the NATIVE v2 conversion of the abliterated
|
||||
Qwen3.8-Flash-Next, a ~62 GiB v2 .hgn that loads in no other engine,
|
||||
MTP spec decode always-on (HALOGEN_SPEC_ADAPT=0), TWO slots over ONE
|
||||
200,704-position KV pool at ctx 200704 — the 2 × ctx = 401,408 raise is
|
||||
committed 2026-10-18 but NOT yet deployed, vision ON)** and
|
||||
401,408-position KV pool — 2 × ctx, raised and DEPLOYED 2026-10-18 — at ctx
|
||||
200704, vision ON)** and
|
||||
**llama.cpp on the PrismML fork (frank-zealot, port 7771: prism-ml
|
||||
Ternary-Bonsai-2-27B at **PQ2_0** — 27B qwen35 hybrid (16 full-attention + 48
|
||||
Gated DeltaNet layers), TWO 200,704-token conversations, MTP disabled,
|
||||
Ternary-Bonsai-2-9B at **PQ2_0** — 9B qwen35 hybrid (8 full-attention + 24
|
||||
Gated DeltaNet layers + the blk.32 MTP block), THREE 262,144-token
|
||||
conversations, MTP present in the file but OFF by measurement,
|
||||
q8_0 K + q8_0 V (f16 KV does not fit the RTX 3090 at this window), vision via
|
||||
the publisher's own Q8_0 Qwen3-VL mmproj, on the RTX 3090 — and the custom
|
||||
tensor type 142 + `prism.hadamard.*` metadata mean no other loader can open the
|
||||
the publisher's own Q8_0 mmproj, on the RTX 3090 — and the custom
|
||||
tensor type 141 + `prism.hadamard.*` metadata mean no other loader can open the
|
||||
file)**.
|
||||
|
||||
> 2026-08-22: the former `vllmScripts` repository is merged into the
|
||||
@@ -58,11 +59,11 @@ gfx1151 iGPU 114 GiB GTT]
|
||||
E[flash-server container gfx1151, digest-pinned image
|
||||
frank-dragoon, port 7770]
|
||||
F[flash-server v2 native abliterated .hgn + ngram table + vision tower
|
||||
ONE 200704-position KV pool at ctx 200704 (401408 committed, awaiting deploy), 2 slots, MTP always-on, image input]
|
||||
ONE 401408-position KV pool at ctx 200704 = 2 slots x ctx, MTP always-on, image input]
|
||||
G[llama.cpp PrismML fork CUDA build
|
||||
frank-zealot, port 7771]
|
||||
H[Ternary-Bonsai-2-27B PQ2_0 GGUF + Q8_0 mmproj
|
||||
2 x 200704 ctx, q8_0 K + q8_0 V, MTP off, mmproj vision]
|
||||
H[Ternary-Bonsai-2-9B PQ2_0 GGUF + Q8_0 mmproj
|
||||
3 x 262144 ctx, q8_0 K + q8_0 V, MTP off, mmproj vision]
|
||||
E2 --> E --> F
|
||||
E2 --> G --> H
|
||||
end
|
||||
@@ -94,13 +95,10 @@ frank-zealot, port 7771]
|
||||
`HALOGEN_CTX=200704` over `HALOGEN_KV_POOL_POSITIONS=401408` (**2 × ctx**):
|
||||
**one pool behind two slots**, sized so TWO maximum-length requests coexist —
|
||||
raised from 200,704 (5.5 GiB) on 2026-10-18 for frank-zealot parity on total
|
||||
in-flight context, ~10.8 GiB reserved against the card's 262,144/262,144 =
|
||||
7.2 GiB. **Deploy state 2026-10-18: the running container and the
|
||||
host-deployed env still carry the 200,704-position pool** (boot ledger
|
||||
`reserving the KV pool, 200704 positions (about 5.5 GiB)`; host
|
||||
`frank-dragoon.env` `HALOGEN_KV_POOL_POSITIONS=200704`) — the doubling lands
|
||||
on the next redeploy (commit `2b12716`).
|
||||
longer window: the per-request ceiling stays 200,704, because 262,144 is the
|
||||
in-flight context and **deployed the same evening**: the boot ledger reserves
|
||||
`401408 positions (about 11.0 GiB)` with `79.6 GiB in all`
|
||||
(`scripts/frank-dragoon.env:70-75`). It bought CONCURRENCY, not a longer
|
||||
window: the per-request ceiling stays 200,704, because 262,144 is the
|
||||
native wall and the engine refuses more without `HALOGEN_ROPE_YARN`.
|
||||
MTP head always on, sampler 1.0/0.95/20, coupled cap/timeout
|
||||
65536/7200, prompt cache ON, vision ON. Measured against the Swift1.5
|
||||
|
||||
@@ -314,8 +314,9 @@ The LLM inference fleet on the two hosts. Full index:
|
||||
since 2026-10-17**: halogen-flash-server 0.16.2 serving the **NATIVE v2
|
||||
abliterated** conversion of orcarouter's pinned BF16
|
||||
(`qwen38-flash-next-v2-abliterated.hgn`, 62.1 GiB) at ctx **200,704** over a
|
||||
**401,408**-position pool (`HALOGEN_KV_POOL_POSITIONS=401408`, ~10.8 GiB — one
|
||||
pool behind both slots, raised to 2 × ctx on 2026-10-18 so TWO maximum-length
|
||||
**401,408**-position pool (`HALOGEN_KV_POOL_POSITIONS=401408`, 11.0 GiB
|
||||
reserved — one pool behind both slots, raised to 2 × ctx and deployed
|
||||
2026-10-18 so TWO maximum-length
|
||||
requests coexist) and the stock peonist n-gram/vision companions **hardlinked**
|
||||
in — engine-tool certified (NIAH 18/18, PPL 3.968, mean KL 0.155); MTP
|
||||
always-on, vision ON, no server-side sampler change
|
||||
|
||||
@@ -2156,4 +2156,158 @@ every restart since the cutover.
|
||||
picker row (DB `description` + context length 262,144) re-keyed. Rollback:
|
||||
revert the swap commit — the 27B payload and the build-10754 fork tree are
|
||||
both still on disk.
|
||||
- Deployed 13:16 PDT (`deploy.sh dankclaw --service frank-zealot`, e2e 33/33)
|
||||
— and the first boot was **not the shipped payload**: a leftover
|
||||
`frank-zealot.service.d/ptq1.conf` from the bench window (written 03:29 for
|
||||
the 27B **PTQ1_0** arm Frank ordered at 02:36 and measured from 03:14 — see
|
||||
the record below) pointed
|
||||
`FRANK_ZEALOT_ENV_FILE` at `/tmp/zealot-arm-current.env`, so the unit booted
|
||||
that arm: 3 slots of 133,888, 21,188 MiB used, batch 3072/ubatch 1536.
|
||||
`/health` 200, `/v1/models` echoed the alias, Playwright 33/33 — nothing in
|
||||
the gate reads the payload. Drop-in moved to
|
||||
`~/.AGENTTRASH/20261018-zealot-ptq1-arm/`, `daemon-reload`, restart at 13:19:
|
||||
`n_slots = 3, n_ctx_slot = 262144`, KV 13,056.00 MiB, settled 17,922/6,203.
|
||||
- Production measures (engine `timings`, greedy, `ignore_eos`): **168.7 t/s**
|
||||
decode (168.4/168.7/168.7) and **~300 t/s** aggregate across three 512-token
|
||||
streams (102.8 each). My first battery reported 117 and 72 t/s: it timed
|
||||
natural generations that stopped at 24 tokens — wall-clock aggregate on an
|
||||
early EOS reads 4× low. `ignore_eos` + the engine's own numbers is the only
|
||||
honest way to time this.
|
||||
- Ledger correction: the shipped shape prints **RS 150.75 MiB** (3 cells), not
|
||||
the 452.25 MiB my first-pass notes carried — that figure belongs to the
|
||||
SPEC-ARMED arms, where MTP depth 2 bills three seqs per slot (9 cells). The
|
||||
drafter triples the recurrent state as well as billing 3,072 MiB of f16 KV.
|
||||
Corrected in AGENTS.md (topology row + architecture section), the env header
|
||||
and the runbook, with the production boot ledger replacing the trial's.
|
||||
- Verified live: chat `27*59` → 1593; `where_am_i` tool call fired; 512 px
|
||||
solid-red PNG → **Red** (278 prompt tokens, `process_mtmd` in the journal);
|
||||
`/props` `n_ctx 262144`, `presence_penalty 1.0`; `/metrics` 200 with 15
|
||||
`llamacpp:*` families and `up{job="frank-zealot"} = 1` — the first non-zero
|
||||
sample in that job's retained history.
|
||||
- OpenWebUI `model` rows re-keyed in the live DB (backup
|
||||
`webui.db.bak-20261018`): `frank-zealot` → 262,144 + the 9B description, and
|
||||
`frank-dragoon`'s `context.length` corrected 262,144 → **200,704** (it had
|
||||
kept the pre-lowering window from 2026-10-17 while its own description said
|
||||
200,704). Note the columns are `params`/`meta` JSON — the table has no
|
||||
`context_length`/`description` columns.
|
||||
|
||||
## 2026-10-18 — frank-zealot PTQ1_0 arm: measured, approved, and never written down
|
||||
|
||||
- **Correction to the entry above and to my own report.** The
|
||||
`frank-zealot.service.d/ptq1.conf` drop-in was NOT a half-finished experiment.
|
||||
The session archive (`01a14c93`) carries Frank's own instruction at **02:36**:
|
||||
replace frank-zealot with `Ternary-Bonsai-2-27B-PTQ1_0.gguf`, use the **Q8**
|
||||
mmproj, batch/ubatch **3072/1536**, "fit as much context as possible in 2
|
||||
contexts".
|
||||
- It was the production payload **03:14:39 → 03:38:57** (pid 191358; journal
|
||||
`type ptq1_0: 402 tensors`, `file type = PTQ1_0 — 1.75 bpw ternary`, mmproj
|
||||
loaded, 2 × 200,704) and served real traffic. Recovered from that process's
|
||||
own `print_timing` lines (17 timed tasks): single-stream greedy decode
|
||||
**48.0 and 47.6 t/s at 512 tokens, 47.9 at 229, 49.0 at 451**; 25–27 t/s per
|
||||
stream with two streams live; prefill **1,295 t/s @ 30,637 → 943 @ 97,856 →
|
||||
801 t/s @ 141,365** (one 176 t/s row at 97,024 is a concurrent-slot request,
|
||||
not a clean probe).
|
||||
- Against the PQ2_0 27B record (76.6 t/s decode, 691.7 t/s prefill at 166k):
|
||||
PTQ1_0 freed the **1,201 MiB** its fetch script promised and paid **−37 %
|
||||
decode** — the publisher's "dense ternary unpacking loses on Ampere" warning,
|
||||
now confirmed on GA102 — while prefilling deep prompts faster than that
|
||||
record ever managed. Frank scratched the whole 27B plan at **03:33** ("scratch
|
||||
the current frank-zealot plan completely… 9B… enable mtp… if you can possibly
|
||||
fit a third context, do it"), which is what shipped at 13:16.
|
||||
- The durable failure is mine: those numbers lived only in `/tmp` and the
|
||||
journal, never in the repo, so the next agent on the host could not tell an
|
||||
approved payload from a rogue drop-in and called it a hijack. The runbook
|
||||
deploy ledger now carries the measurement; `/home/dank/models/Bonsai2-PTQ1_0/`
|
||||
stays on disk as the 27B rollback and the drop-in stays trashed.
|
||||
|
||||
|
||||
## 2026-10-18 — frank-dragoon: the 401,408-position pool goes live; docs re-keyed to the measured boot
|
||||
|
||||
- `./scripts/deploy.sh dankclaw --service frank-dragoon` — e2e 33/33. The
|
||||
drop-in check the zealot incident bought ran FIRST: `DropInPaths` empty, so
|
||||
the repo env governed. Boot ledger: `reserving the KV pool, 401408 positions
|
||||
(about 11.0 GiB)`, `2 slots over ONE 401408-position KV pool, each request up
|
||||
to 200704`, and `62.9 GiB of weights locked … 11.0 GiB of KV pool, 5.7 GiB of
|
||||
working memory, 79.6 GiB in all`; prompt cache ON (12 entries × 111 MiB at
|
||||
ctx 200704 = 1.3 GiB), n-gram 47.68 GiB still `Pin::None`, no clamp line.
|
||||
- Warm reuse survived the raise, measured right after the deploy: one
|
||||
**101,987**-token wikitext prompt through `/v1/chat/completions` took
|
||||
**81.5 s** cold (1,251 t/s of prefill) and **0.3 s** on each of two repeats —
|
||||
`HALOGEN_CACHE_INPLACE=1` keeps the KV rows in the pool, so a warm turn
|
||||
prefills nothing and the doubling evicted nothing the cache needed. The RAM
|
||||
price is still real: host `MemAvailable` fell **26 → 22 GiB**, because every
|
||||
pool position is RAM the n-gram table's page cache loses.
|
||||
- Every document quoted **~10.8 GiB**, the ~28 KiB/position arithmetic; the
|
||||
engine reserves **11.0 GiB** and its preflight bills **11.3**. Re-keyed in
|
||||
AGENTS.md (topology row, KV-pool law, memory ledger), the `frank-dragoon.env`
|
||||
lore block, both index pages, the iommu page and the runbook, and every
|
||||
"committed but not yet deployed" clause was retired with the measurement.
|
||||
- The same sweep caught the LLM index still describing frank-zealot as
|
||||
**Bonsai-2-27B at 2 × 200,704** in its intro paragraph and topology diagram —
|
||||
the 9B swap of the same day had not reached those two spots. Re-keyed: 8
|
||||
full-attention + 24 Gated DeltaNet + the `blk.32` MTP block, 3 × 262,144,
|
||||
tensor type **141**.
|
||||
- PTQ1_0 provenance corrected (see the dedicated section above): the arm that
|
||||
hijacked the zealot deploy was a **repo-tracked, Frank-ordered, measured**
|
||||
candidate — `scripts/download-frank-zealot-bonsai2-ptq1.sh` is committed, and
|
||||
its header now carries the recovered numbers (≈48 t/s decode, 801 t/s prefill
|
||||
at 141k) and the verdict: −37 % decode against PQ2_0 for 1,201 MiB freed, then
|
||||
scratched by Frank's 03:33 order in favour of the 9B. Nothing is owed on it.
|
||||
- OpenWebUI: both decoration rows re-keyed in the live DB. The values live
|
||||
inside JSON columns — `params.context.length` and `meta.description` — not in
|
||||
fields named for them, and there is no `sqlite3` binary on the host, so
|
||||
`python3` stdlib + `Connection.backup()` (`webui.db.bak-20261018`). Zealot
|
||||
200,704 → **262,144** with the 9B description; dragoon 262,144 → **200,704**,
|
||||
which had kept the pre-lowering window while its own description said
|
||||
200,704.
|
||||
- Census of the halogen HTTP surface while doing it, because the fleet bench
|
||||
law quoted the wrong engine: frank-dragoon answers **`/health`, `/metrics`,
|
||||
`/v1/models`, `/v1/chat/completions`(POST)** and 404s `/ping`, `/props`,
|
||||
`/completion`, `/tokenize`; the chat response carries **no `Server-Timing`**
|
||||
header (`server: uvicorn`, only `date`/`content-*`). So the `/completion`
|
||||
`timings` object and the `ttft;dur=` header are **llama.cpp** instruments —
|
||||
frank-zealot's, not dragoon's. AGENTS.md's Benchmarking Policy re-keyed to
|
||||
say which engine owns which instrument.
|
||||
|
||||
## 2026-10-18 — frank-tts phase 1: the cloned-voice endpoint, named and tested (not deployed)
|
||||
|
||||
- Voice-over goal, phase 1 (Gitea issue #2). New service **frank-tts** on
|
||||
Dankclaw: `dankclaw/voiceservice/` (stdlib-only `app.py` + `Containerfile` +
|
||||
rootless quadlet), `scripts/frank-tts.env`, `tests/frank_tts_unit.bats`
|
||||
(12 tests). One OpenAI-shaped `POST /v1/audio/speech` surface; `voice=<name>`
|
||||
is honoured only when `/voices/<name>/reference.wav` exists, otherwise a 400
|
||||
listing the voices that are — no silent fallback voice. `voice_id` is the
|
||||
harness alias; `response_format=mp3` really is mp3; oversized input is a 414,
|
||||
not a half-narrated sentence; `/health` reports backend + voice list; no audio
|
||||
ever reaches the log. Two backends behind it (`TTS_BACKEND=omni-http` forwards
|
||||
to the host's `:8880` engine with the voice as base64 `reference_audio`;
|
||||
`llama-tts-cli` runs a binary per request at `nice -n 19`), both exercised
|
||||
through stubs — no GPU, no containers in the seam.
|
||||
- **Naming law applied (Frank's call):** treat it as a first-class model
|
||||
endpoint → alias-named `frank-tts` (TTS endpoint, so not `frank-asr`),
|
||||
**port 7772**, joining `7770 frank-dragoon` / `7771 frank-zealot`; the engine
|
||||
upstream keeps 8880 and is deliberately not a fleet endpoint. The internal
|
||||
knob names stay `SHIM_*` (they name the façade component, not the service).
|
||||
- **Voice-pack law recorded** in the env header: a pack needs `reference.wav`
|
||||
(24 kHz mono, 18–25 s) **and** the pinned engine's own speaker artifact —
|
||||
required, beside the WAV, never replacing it — plus `manifest.json` provenance
|
||||
whose offsets are always past the first 10 s of the source (the
|
||||
source-material law). Enforcement is split honestly: the shim gates on
|
||||
`reference.wav` (the only engine-agnostic term); the pack builder is what will
|
||||
refuse to emit a pack without its artifact.
|
||||
- Suite green: **384/384** (`./tests/run.sh`, wall 66 s). Landed as **PR #4**
|
||||
(branch `wt/20261017-181502` → `main`, merged as `49c4e19`) — the first PR on
|
||||
this repo through the API, which cost three 422s to get right: the merge
|
||||
endpoint takes **`{"Do":"merge"}`**, `Do` being a *string* naming the strategy.
|
||||
`DoMerge`/`merge_method` do not exist on this Gitea; swagger JSON is disabled
|
||||
on the instance, so the 422 messages are the schema. Law saved to the
|
||||
`gitea-api-pr-operations` skill.
|
||||
- Deliberately NOT in this phase: the `:8880` engine decision and its
|
||||
speaker-artifact format, the pack builder, deployer/homepage/Prometheus
|
||||
registration, the `models.yml` row that lets omp call it, and open-notebook.
|
||||
Harness fact established while writing the PR body: the **`tts` tool** will
|
||||
dispatch an `api: openai-speech` provider row to `POST <baseUrl>/audio/speech`
|
||||
(`{model, input, response_format, voice}`), but the live `speech.*` vocalizer is
|
||||
Kokoro-enum-only — so "the agent talks in my voice" needs that provider row or
|
||||
a `pi.registerProvider` extension, decided in phase 2.
|
||||
|
||||
Executable
+343
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: frank-tts, the cloned-voice speech façade on Dankclaw :7772.
|
||||
#
|
||||
# The contract this pins is the one every consumer (omp, OpenWebUI,
|
||||
# open-notebook) codes against: POST /v1/audio/speech with voice=<name> is
|
||||
# answered with audio in THAT voice pack, where "that voice pack" is exactly
|
||||
# $VOICES_DIR/<name>/reference.wav — the file that makes a name real. A name
|
||||
# without a reference must come back as a 400 naming the voices that do exist
|
||||
# (a silent fallback voice is how a wrong-voice endpoint stays wrong for
|
||||
# weeks), the harness' voice_id must work as an alias for voice, oversized text
|
||||
# must be refused rather than half-narrated, and the mp3 response_format must
|
||||
# really be mp3. Whatever engine sits behind it is a separate decision — so
|
||||
# both backends are driven here through stubs, never against a GPU.
|
||||
#
|
||||
# Everything runs against the real app.py over 127.0.0.1 ephemeral ports with
|
||||
# a temp VOICES_DIR: no Dankclaw, no network, no containers, no pkill — the
|
||||
# processes this file starts are recorded by pid and killed in teardown.
|
||||
|
||||
load helpers
|
||||
|
||||
setup() {
|
||||
APP="$REPO_ROOT/dankclaw/voiceservice/app.py"
|
||||
UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts.container"
|
||||
ENV_FILE="$REPO_ROOT/scripts/frank-tts.env"
|
||||
TEST_TMP="$(mktemp -d)"
|
||||
PIDS="$TEST_TMP/pids"
|
||||
: > "$PIDS"
|
||||
|
||||
# One real voice pack and one directory that LOOKS like a pack but has no
|
||||
# reference.wav — it must never be offered as a voice.
|
||||
VOICES="$TEST_TMP/voices"
|
||||
mkdir -p "$VOICES/narrator1" "$VOICES/nopack"
|
||||
python3 -c 'import sys; sys.stdout.buffer.write(b"RIFF" + bytes(range(256)) * 4)' \
|
||||
> "$VOICES/narrator1/reference.wav"
|
||||
printf 'a manifest is not a voice\n' > "$VOICES/nopack/manifest.json"
|
||||
|
||||
# The audio the stub upstream "synthesises": distinct bytes, so a response
|
||||
# can be proven to be the engine's output and not something the shim made
|
||||
# up on its way past.
|
||||
python3 -c 'import sys; sys.stdout.buffer.write(b"RIFF" + bytes(reversed(range(128))) * 3)' \
|
||||
> "$TEST_TMP/upstream.wav"
|
||||
|
||||
write_stub
|
||||
}
|
||||
|
||||
teardown() {
|
||||
# Kill only what this file started, by recorded pid.
|
||||
while IFS= read -r pid; do
|
||||
[ -n "$pid" ] && kill "$pid" 2>/dev/null || true
|
||||
done < "$PIDS"
|
||||
wait 2>/dev/null || true
|
||||
rm -rf "$TEST_TMP"
|
||||
}
|
||||
|
||||
# write_stub — the upstream TTS server arm A forwards to: records the JSON body
|
||||
# it was handed (so "did the reference voice actually travel?" is observable)
|
||||
# and answers with $STUB_WAV. Prints its bound port, chosen by the kernel.
|
||||
write_stub() {
|
||||
cat > "$TEST_TMP/stub_upstream.py" <<'PY'
|
||||
import json, os
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
class Stub(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get('content-length') or 0)
|
||||
with open(os.environ['STUB_CAPTURE'], 'w') as fh:
|
||||
json.dump(json.loads(self.rfile.read(length) or b'{}'), fh)
|
||||
with open(os.environ['STUB_WAV'], 'rb') as fh:
|
||||
audio = fh.read()
|
||||
self.send_response(200)
|
||||
self.send_header('content-type', 'audio/wav')
|
||||
self.send_header('content-length', str(len(audio)))
|
||||
self.end_headers()
|
||||
self.wfile.write(audio)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = HTTPServer(('127.0.0.1', 0), Stub)
|
||||
print(server.server_address[1], flush=True)
|
||||
server.serve_forever()
|
||||
PY
|
||||
}
|
||||
|
||||
start_stub() {
|
||||
: > "$TEST_TMP/stub.log"
|
||||
STUB_WAV="$TEST_TMP/upstream.wav" STUB_CAPTURE="$TEST_TMP/capture.json" \
|
||||
python3 "$TEST_TMP/stub_upstream.py" > "$TEST_TMP/stub.log" 2>&1 &
|
||||
echo "$!" >> "$PIDS"
|
||||
local waited=0
|
||||
while [ "$waited" -lt 100 ]; do
|
||||
STUB_PORT="$(head -n1 "$TEST_TMP/stub.log")"
|
||||
[ -n "$STUB_PORT" ] && return 0
|
||||
sleep 0.05
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
echo "stub upstream never bound a port:"
|
||||
cat "$TEST_TMP/stub.log"
|
||||
return 1
|
||||
}
|
||||
|
||||
# start_app — boot app.py on 127.0.0.1 with an ephemeral port and read that
|
||||
# port back off its own startup line: no port guessing, no race. The caller
|
||||
# exports the env the run needs first; app.py reads its knobs at request time.
|
||||
start_app() {
|
||||
: > "$TEST_TMP/app.log"
|
||||
python3 "$APP" --host 127.0.0.1 --port 0 > "$TEST_TMP/app.log" 2>&1 &
|
||||
echo "$!" >> "$PIDS"
|
||||
local waited=0
|
||||
while [ "$waited" -lt 200 ]; do
|
||||
APP_PORT="$(sed -nE 's|.*listening on http://127\.0\.0\.1:([0-9]+)|\1|p' "$TEST_TMP/app.log" | head -n1)"
|
||||
[ -n "$APP_PORT" ] && return 0
|
||||
sleep 0.05
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
echo "frank-tts never came up:"
|
||||
cat "$TEST_TMP/app.log"
|
||||
return 1
|
||||
}
|
||||
|
||||
# arm_omni — the shim forwarding to an OpenAI-shaped upstream (arm A).
|
||||
arm_omni() {
|
||||
start_stub
|
||||
export VOICES_DIR="$VOICES"
|
||||
export TTS_BACKEND=omni-http
|
||||
export TTS_UPSTREAM="http://127.0.0.1:$STUB_PORT"
|
||||
}
|
||||
|
||||
speech() {
|
||||
# speech <json-payload-inline> — the HTTP code lands in $output, the body
|
||||
# in $TEST_TMP/out.bin and the response headers in head.txt.
|
||||
local data="$1"
|
||||
run curl -sS -o "$TEST_TMP/out.bin" -D "$TEST_TMP/head.txt" -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' --data-binary "$data" \
|
||||
"http://127.0.0.1:$APP_PORT/v1/audio/speech"
|
||||
}
|
||||
|
||||
@test "app.py parses as python the runtime can import" {
|
||||
run env PYTHONPYCACHEPREFIX="$TEST_TMP/pycache" python3 -m py_compile "$APP"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
}
|
||||
|
||||
@test "voice=narrator1 returns the engine's audio with the pack's reference attached" {
|
||||
arm_omni
|
||||
start_app
|
||||
speech '{"model":"tts-1","input":"hello world","voice":"narrator1"}'
|
||||
[ "$status" -eq 0 ] || { cat "$TEST_TMP/app.log"; false; }
|
||||
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
|
||||
grep -qi '^content-type: audio/wav' "$TEST_TMP/head.txt"
|
||||
cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav"
|
||||
# The voice only arrives at the engine as base64 reference_audio — prove it
|
||||
# is the pack's bytes, not a path and not the engine's own default voice.
|
||||
python3 - "$TEST_TMP/capture.json" "$VOICES/narrator1/reference.wav" <<'PY'
|
||||
import base64, json, sys
|
||||
body = json.load(open(sys.argv[1]))
|
||||
reference = open(sys.argv[2], 'rb').read()
|
||||
assert body['voice'] == 'narrator1', body
|
||||
assert body['input'] == 'hello world', body
|
||||
assert body['response_format'] == 'wav', body
|
||||
assert base64.b64decode(body['reference_audio']) == reference, 'reference_audio is not the pack wav'
|
||||
PY
|
||||
# What a consumer discovers before it speaks: /health and the voice list.
|
||||
# A directory with a manifest but no reference.wav is not a voice.
|
||||
run curl -sS "http://127.0.0.1:$APP_PORT/v1/audio/voices"
|
||||
[ "$output" = '["narrator1"]' ]
|
||||
run curl -sS "http://127.0.0.1:$APP_PORT/health"
|
||||
[[ "$output" == *'"status": "ok"'* ]]
|
||||
[[ "$output" == *'"backend": "omni-http"'* ]]
|
||||
[[ "$output" == *'"voices": ["narrator1"]'* ]]
|
||||
}
|
||||
|
||||
@test "voice_id works as the alias the omp harness sends" {
|
||||
arm_omni
|
||||
start_app
|
||||
speech '{"model":"tts-1","input":"hello","voice_id":"narrator1"}'
|
||||
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
|
||||
cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav"
|
||||
python3 - "$TEST_TMP/capture.json" <<'PY'
|
||||
import json, sys
|
||||
body = json.load(open(sys.argv[1]))
|
||||
assert body['voice'] == 'narrator1', 'voice_id never became the upstream voice'
|
||||
assert 'voice_id' not in body, body
|
||||
PY
|
||||
}
|
||||
|
||||
@test "an unknown voice is a 400 naming the voices that exist" {
|
||||
arm_omni
|
||||
start_app
|
||||
speech '{"model":"tts-1","input":"hello","voice":"nosuch"}'
|
||||
[ "$output" = "400" ]
|
||||
grep -q 'narrator1' "$TEST_TMP/out.bin"
|
||||
# Refused before the engine saw anything: no audio, no upstream call.
|
||||
[ ! -s "$TEST_TMP/capture.json" ]
|
||||
}
|
||||
|
||||
@test "oversized input is refused outright, not narrated half-way" {
|
||||
arm_omni
|
||||
# The limit is a knob the operator sets, not a constant in the seam: the
|
||||
# app reads its knobs at request time, so exporting it before boot works.
|
||||
export SHIM_MAX_CHARS=10
|
||||
start_app
|
||||
python3 - "$TEST_TMP/payload.json" <<'PY'
|
||||
import json, sys
|
||||
json.dump({'model': 'tts-1', 'input': 'A' * 11, 'voice': 'narrator1'},
|
||||
open(sys.argv[1], 'w'))
|
||||
PY
|
||||
run curl -sS -o "$TEST_TMP/out.bin" -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' --data-binary "@$TEST_TMP/payload.json" \
|
||||
"http://127.0.0.1:$APP_PORT/v1/audio/speech"
|
||||
[ "$output" = "414" ]
|
||||
[ ! -s "$TEST_TMP/capture.json" ]
|
||||
}
|
||||
|
||||
@test "every other route is a 404, GET or POST" {
|
||||
arm_omni
|
||||
start_app
|
||||
run curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$APP_PORT/v1/audio/speeck"
|
||||
[ "$output" = "404" ]
|
||||
run curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H 'Content-Type: application/json' -d '{"input":"hi","voice":"narrator1"}' \
|
||||
"http://127.0.0.1:$APP_PORT/v1/speech"
|
||||
[ "$output" = "404" ]
|
||||
}
|
||||
|
||||
@test "an unrouted POST does not poison a reused connection" {
|
||||
# The shim speaks HTTP/1.1, so a client may keep one TCP connection alive
|
||||
# across requests. A body left unread on the way to a 404 is parsed as the
|
||||
# NEXT request line — the consumer would blame the following, perfectly
|
||||
# valid call. So: 404 first, then /health on the same connection.
|
||||
arm_omni
|
||||
start_app
|
||||
python3 - "$APP_PORT" <<'PY'
|
||||
import http.client, json, sys
|
||||
conn = http.client.HTTPConnection("127.0.0.1", int(sys.argv[1]), timeout=15)
|
||||
conn.request("POST", "/v1/audio/speeck",
|
||||
body=json.dumps({"input": "hi", "voice": "narrator1"}),
|
||||
headers={"Content-Type": "application/json"})
|
||||
first = conn.getresponse()
|
||||
assert first.status == 404, first.status
|
||||
first.read()
|
||||
conn.request("GET", "/health")
|
||||
second = conn.getresponse()
|
||||
assert second.status == 200, second.status
|
||||
assert json.loads(second.read())["status"] == "ok"
|
||||
conn.close()
|
||||
PY
|
||||
}
|
||||
|
||||
@test "the CLI backend gets the resolved reference as --tts-speaker-file" {
|
||||
# A fake binary standing in for llama-tts: it records its argv, writes a
|
||||
# WAV where --output points, and reports the temp dir it was asked to work
|
||||
# in so the cleanup promise is observable too.
|
||||
cat > "$TEST_TMP/fake-tts" <<'FAKE'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$@" > "$ARGS_FILE"
|
||||
prev=""
|
||||
for arg in "$@"; do
|
||||
if [ "$prev" = "--output" ]; then
|
||||
printf 'CLI-AUDIO' > "$arg"
|
||||
dirname "$arg" > "$WORKDIR_FILE"
|
||||
fi
|
||||
prev="$arg"
|
||||
done
|
||||
exit 0
|
||||
FAKE
|
||||
chmod +x "$TEST_TMP/fake-tts"
|
||||
printf 'CLI-AUDIO' > "$TEST_TMP/expected.bin"
|
||||
|
||||
export VOICES_DIR="$VOICES"
|
||||
export TTS_BACKEND=llama-tts-cli
|
||||
export LLAMA_TTS_BIN="$TEST_TMP/fake-tts"
|
||||
export LLAMA_TTS_MODEL="$TEST_TMP/model.bin"
|
||||
export ARGS_FILE="$TEST_TMP/argv.txt"
|
||||
export WORKDIR_FILE="$TEST_TMP/workdir.txt"
|
||||
start_app
|
||||
speech '{"model":"tts-1","input":"narrate this","voice":"narrator1"}'
|
||||
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
|
||||
grep -qi '^content-type: audio/wav' "$TEST_TMP/head.txt"
|
||||
cmp "$TEST_TMP/out.bin" "$TEST_TMP/expected.bin"
|
||||
# The speaker file must be the pack's reference under VOICES_DIR — that is
|
||||
# the whole product; anything else and the clone never happens.
|
||||
speaker="$(awk 'p { print; exit } $0 == "--tts-speaker-file" { p = 1 }' "$TEST_TMP/argv.txt")"
|
||||
[ "$speaker" = "$VOICES/narrator1/reference.wav" ]
|
||||
prompt="$(awk 'p { print; exit } $0 == "-p" { p = 1 }' "$TEST_TMP/argv.txt")"
|
||||
[ "$prompt" = "narrate this" ]
|
||||
grep -qx -- '--tts-lang' "$TEST_TMP/argv.txt"
|
||||
# A per-request temp dir that survives is how a TTS box fills its /tmp.
|
||||
workdir="$(cat "$TEST_TMP/workdir.txt")"
|
||||
[ -n "$workdir" ]
|
||||
[ ! -d "$workdir" ]
|
||||
}
|
||||
|
||||
@test "response_format=mp3 ships mp3, converted from the engine's wav" {
|
||||
arm_omni
|
||||
# A fake ffmpeg on PATH: whatever wav arrives on stdin is tapped (so the
|
||||
# conversion provably ran on the engine's audio) and mp3 bytes come out.
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
cat > "$TEST_TMP/bin/ffmpeg" <<'FF'
|
||||
#!/usr/bin/env bash
|
||||
cat > "$STDIN_TAP"
|
||||
printf 'MP3-BYTES'
|
||||
FF
|
||||
chmod +x "$TEST_TMP/bin/ffmpeg"
|
||||
export STDIN_TAP="$TEST_TMP/ffmpeg-in.wav"
|
||||
# The fake must reach the APP's PATH: the shim runs `ffmpeg` by name.
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
start_app
|
||||
speech '{"model":"tts-1","input":"hello","voice":"narrator1","response_format":"mp3"}'
|
||||
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
|
||||
grep -qi '^content-type: audio/mpeg' "$TEST_TMP/head.txt"
|
||||
printf 'MP3-BYTES' > "$TEST_TMP/expected-mp3.bin"
|
||||
cmp "$TEST_TMP/out.bin" "$TEST_TMP/expected-mp3.bin"
|
||||
cmp "$TEST_TMP/ffmpeg-in.wav" "$TEST_TMP/upstream.wav"
|
||||
# The upstream is still asked for wav: mp3 is this shim's re-encode.
|
||||
python3 - "$TEST_TMP/capture.json" <<'PY'
|
||||
import json, sys
|
||||
assert json.load(open(sys.argv[1]))['response_format'] == 'wav'
|
||||
PY
|
||||
}
|
||||
|
||||
@test "the shipped env file boots the shim with its declared backend" {
|
||||
# Not a grep of the env file: source it the way systemd's EnvironmentFile
|
||||
# would, point the two machine-local values at the sandbox, and require the
|
||||
# endpoint to actually speak. A typo, a shell-ism or an expansion systemd
|
||||
# cannot parse shows up here as a dead process, not a green grep.
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
arm_omni
|
||||
start_app
|
||||
speech '{"model":"tts-1","input":"hello","voice":"narrator1"}'
|
||||
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
|
||||
cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav"
|
||||
}
|
||||
|
||||
@test "the quadlet unit keeps boot persistence and a read-only voice mount" {
|
||||
grep -qE '^Restart=always$' "$UNIT"
|
||||
grep -qE '^RestartSec=[0-9]+$' "$UNIT"
|
||||
grep -qE '^WantedBy=default.target$' "$UNIT"
|
||||
grep -qE '^PublishPort=([0-9.]+:)?7772:7772$' "$UNIT"
|
||||
grep -qE '^Volume=/home/dank/voices:/voices:ro$' "$UNIT"
|
||||
}
|
||||
Reference in new issue
Block a user