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 |
|
| 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 |
|
| 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-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**; +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-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
|
Retired endpoints (`frank-npu`, and every prior runtime) are in
|
||||||
the [runbook index](#runbook-index). The full swap history lives 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)
|
pages (`serverAppConfigs-docs/log.md` carries the wave plan and results)
|
||||||
- `tests/` — bats suite + Playwright e2e
|
- `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.
|
All edits flow through git. Never edit files on a remote machine directly.
|
||||||
|
|
||||||
1. **Source** the live config from the target machine
|
`main` is the only long-lived branch: no `develop`, no release branches, no
|
||||||
2. **Commit** the fetched version here (redact secrets into `.env`)
|
back-merges. Frank is the only dev; this repo and `dank/command-center` follow
|
||||||
3. **Edit** locally
|
the same regime.
|
||||||
4. **Test** — `./tests/run.sh` must pass
|
|
||||||
5. **Commit and push** — origin is Gitea on Dankclaw
|
1. **Branch fresh** — `git fetch`, then branch off the current `origin/main`
|
||||||
6. **Pull** on the target (`deploy.sh` does this)
|
(`<type>/<slug>`, type ∈ feat|fix|docs|chore). If main moves under you,
|
||||||
7. **Deploy** — `scripts/deploy.sh` plans and dispatches only changed services
|
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
|
## Deployment
|
||||||
|
|
||||||
@@ -231,8 +251,12 @@ archive.
|
|||||||
**STOP the production service BEFORE benchmarking** — these servers serve
|
**STOP the production service BEFORE benchmarking** — these servers serve
|
||||||
real users, and a bench beside a live service starves the GPU and corrupts
|
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
|
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
|
endpoints; a live llama.cpp server (frank-zealot) also reports honest
|
||||||
requires asking first.
|
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
|
Scratch ports are not used on LLM hosts — a
|
||||||
test process cannot share the single GPU anyway; validate against the service
|
test process cannot share the single GPU anyway; validate against the service
|
||||||
itself.
|
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`
|
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`),
|
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
|
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
|
returns nothing. **That header is a llama.cpp feature**: halogen flash-server
|
||||||
tokens, `inter_token` is first-token noise and the run-to-run spread swamps
|
0.16.2 answers `/v1/chat/completions` with only `date`, `server` and
|
||||||
every real difference.
|
`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
|
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
|
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
|
`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"
|
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 ⇒
|
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
|
the 2026-10-18 deploy RESERVES **11.0 GiB** (`reserving the KV pool, 401408
|
||||||
and the 2026-10-06 shape's 524,288 measured 14.6; the engine's preflight FORMULA
|
positions (about 11.0 GiB)`; the preflight formula bills 11.3 with block scratch
|
||||||
bills slightly more than the reservation (block scratch + ~115 MiB/slot of O(1)
|
+ ~115 MiB/slot of O(1) state), where 200,704 cost 5.5, 262,144 cost 7.2 and
|
||||||
state) — quote the reservation.
|
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
|
`HALOGEN_MAX_TOK=8192` is only the per-request DEFAULT (the
|
||||||
harness sends at most its `maxTokens` 45056); `HALOGEN_MAX_TOKENS_CAP=65536` +
|
harness sends at most its `maxTokens` 45056); `HALOGEN_MAX_TOKENS_CAP=65536` +
|
||||||
`HALOGEN_QUEUE_TIMEOUT=7200` are a coupled pair (FLAGS.md table).
|
`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".
|
"video".
|
||||||
|
|
||||||
**Memory ledger** (121.2 GiB unified; the referee is the boot ledger): weights
|
**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
|
62.9 (trunk 62.11 + vision 0.84) + KV **11.0** + working 5.7
|
||||||
(`3.8 + 8.4 × MAX_TOK/16384`) ≈ **79.4 GiB in all**, the ~1.0 GiB prompt cache
|
(`3.8 + 8.4 × MAX_TOK/16384`) = **79.6 GiB in all** (boot ledger, the
|
||||||
booked separately by the ledger, + `HALOGEN_HOST_RESERVE_GIB=20` → ~22 GiB
|
401,408-position deploy of 2026-10-18), the **1.3 GiB** prompt cache (12 entries
|
||||||
margin over baseline co-tenancy (the pool-equals-ctx boot printed 74.1 GiB with
|
× 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
|
~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
|
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
|
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
|
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
|
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
|
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 —
|
card.** The 24 DeltaNet layers bill a fixed recurrent state per SLOT — the
|
||||||
`RS buffer size = 452.25 MiB (3 cells, 32 layers, 3 seqs)` ≈ 150 MiB each — so
|
production ledger prints `RS buffer size = 150.75 MiB (3 cells, 32 layers,
|
||||||
pool arithmetic alone under-prices the shape.
|
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
|
**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
|
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,
|
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
|
and note Alertmanager has no persistence: a reboot re-stamps `startsAt` on an
|
||||||
alert that has been firing for weeks.
|
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**
|
**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
|
# 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
|
# 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).
|
# 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
|
# 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"
|
# 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
|
# 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
|
# 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
|
# 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
|
# (FLAGS.md) ⇒ the DEPLOYED 2026-10-18 boot RESERVES 11.0 GiB (`reserving the
|
||||||
# 7.2 and the 2026-10-06 shape's 524,288 measured 14.6. The engine's preflight
|
# KV pool, 401408 positions (about 11.0 GiB)`), where pool == ctx (200,704)
|
||||||
# formula bills a little more than the reservation (block scratch + ~115
|
# cost 5.5, 262,144 cost 7.2 and the 2026-10-06 shape's 524,288 measured 14.6.
|
||||||
# MiB/slot of O(1) state) — quote the RESERVATION. FLAGS.md law: "RAM the pool
|
# 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
|
# 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.
|
# 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
|
# 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
|
# 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
|
# mmap'd page cache beside the checkpoint, not a pinned charge — the boot
|
||||||
# ledger agrees: "mapped 51.2 GB, NOT pinned (Pin::None)"):
|
# 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 +
|
# weights 62.9 GiB locked (0.8 of them the vision tower) + KV 11.0 +
|
||||||
# working ~5.7 (HALOGEN_MAX_TOK=8192) ≈ 79.4 GiB engine-side, with the
|
# working 5.7 (HALOGEN_MAX_TOK=8192) = 79.6 GiB engine-side (the boot
|
||||||
# prompt cache (~1.0 at this ctx; 1.3 was measured at 262144) booked
|
# ledger of the 401408-position deploy, 2026-10-18), with the 1.3 GiB
|
||||||
# separately by the ledger; HALOGEN_HOST_RESERVE_GIB=20 keeps the host
|
# prompt cache (12 entries x 111 MiB at ctx 200704) booked separately by
|
||||||
# honest. Against the 121.2 GiB box that leaves ~22 GiB over the reserve for
|
# the ledger; HALOGEN_HOST_RESERVE_GIB=20 keeps the host honest. Against
|
||||||
# baseline co-tenancy (frank-zealot, ComfyUI, Caddy/observ ~10-15). The boot
|
# the 121.2 GiB box that leaves ~20 GiB over the reserve (MemAvailable
|
||||||
# ledger is the referee — read it after deploy and re-size if the engine
|
# read 22 GiB right after the raise) for baseline co-tenancy
|
||||||
# prints a clamp.
|
# (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
|
# HALOGEN_SPEC_ADAPT=0 is MANDATORY (card): the default adaptive policy
|
||||||
# turns the MTP head off for whole requests after low acceptance and
|
# turns the MTP head off for whole requests after low acceptance and
|
||||||
# HALVES decode as a result. Frank accepted the head always-on trade
|
# 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
|
# 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
|
# 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
|
# (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
|
# bill a fixed recurrent state per SLOT instead of per token — the PRODUCTION
|
||||||
# "RS buffer size = 452.25 MiB (3 cells, 32 layers, 3 seqs)" = 150.75 MiB a
|
# ledger prints "RS buffer size = 150.75 MiB (3 cells, 32 layers, 3 seqs)"
|
||||||
# slot — which is why pool arithmetic alone under-prices the shape.
|
# (~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_K=q8_0
|
||||||
CACHE_TYPE_V=q8_0
|
CACHE_TYPE_V=q8_0
|
||||||
# SPECULATIVE DECODING: OFF, MEASURED. The head is in the file and the drafter
|
# 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="" is the documented opt-out.
|
||||||
PRESENCE_PENALTY=1.0
|
PRESENCE_PENALTY=1.0
|
||||||
NGL=99
|
NGL=99
|
||||||
# Boot ledger (2026-10-18, this exact shape, nvidia-smi settled):
|
# Boot ledger (2026-10-18 PRODUCTION boot under the unit, verbosity 4):
|
||||||
# 17,876 MiB used / 6,249 MiB free of 24,123
|
# 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 KV 13,056.00 MiB (q8_0 K + q8_0 V, 786,432 positions)
|
||||||
# CUDA0 RS 452.25 MiB (3 recurrent-state cells)
|
# CUDA0 RS 150.75 MiB (3 cells, 32 layers, 3 seqs)
|
||||||
# compute 1,409 MiB CUDA + 1,201 MiB CUDA_Host, projector 595 + 248 MiB
|
# 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
|
# no clamp line, no cudaMalloc error
|
||||||
# Measured on the same boot: 167.2 t/s greedy decode (/completion, 512 tokens),
|
# Measured on this boot (/completion timings, greedy, ignore_eos, 512 tokens):
|
||||||
# 158.9 t/s single-stream chat, 280.2 t/s aggregate across THREE concurrent
|
# 168.7 t/s decode (three runs 168.4-168.7) and ~300 t/s aggregate across
|
||||||
# streams (93.8 each), 2,877 t/s prefill at 106,418 tokens. The 27B record it
|
# THREE concurrent streams (102.8 each). The trial arm's record (167.2 decode,
|
||||||
# replaces: 76.6 decode, 109.4 aggregate across two streams, 691.7 prefill at
|
# 280.2 aggregate, 2,877 t/s prefill at 106,418) stands; the 27B it replaces
|
||||||
# 166k — beaten on every axis. The FIRST request after a boot is a warmup
|
# gave 76.6 / 109.4 / 691.7 at 166k. The FIRST request after a boot is a
|
||||||
# artifact (37.7 t/s class); never report it.
|
# 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
|
# 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
|
# 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
|
# 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
|
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).
|
picker row below was re-keyed to it (see the next paragraph).
|
||||||
|
|
||||||
The `model` table carries exactly one decoration row, `frank-zealot`
|
The `model` table carries exactly two decoration rows, `frank-zealot` and
|
||||||
(context length 262,144 = the per-slot window since 2026-10-18; description
|
`frank-dragoon`. Both the context length and the description live **inside
|
||||||
re-keyed the same day to Ternary-Bonsai-2-9B PQ2_0 + MTP + Q8_0 vision mmproj —
|
JSON columns** — `params` holds `{"context": {"length": …}}` and `meta` holds
|
||||||
the row's `description` is live DB state, so a payload swap must re-key it in
|
`{"description": …}`; there is no `context_length` or `description` column, so
|
||||||
the database too, not only in the repo).
|
`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
|
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
|
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
|
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
|
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
|
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
|
slot parity was COMMITTED (`2b12716`) and **deployed the same evening**: the
|
||||||
redeployed: the live boot still reserves ONE 200,704-position pool (5.5 GiB),
|
boot now reserves `401408 positions (about 11.0 GiB)` and the engine-side total
|
||||||
host `frank-dragoon.env` included. When it lands, the reservation is ~10.8
|
is `79.6 GiB in all` (62.9 weights + 11.0 KV + 5.7 working),
|
||||||
GiB and the engine-side total ~79.4 GiB;
|
|
||||||
see [[frank-dragoon-flash-server-v2-native]].)
|
see [[frank-dragoon-flash-server-v2-native]].)
|
||||||
|
|
||||||
**The NPU came back with the flip.** `amdxdna` bound to `0000:c6:00.1` (Strix/
|
**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`
|
≤100k can). That is acceptable here because frank-dragoon carries the `slow`
|
||||||
(medium-thinking) role and the `security-reviewer` subagent, not the
|
(medium-thinking) role and the `security-reviewer` subagent, not the
|
||||||
interactive workhorse (that is frank-archon).
|
interactive workhorse (that is frank-archon).
|
||||||
Frank lifted exactly that concurrency limit on 2026-10-18 — see *Pool
|
Frank lifted exactly that concurrency limit on 2026-10-18 and the lift is
|
||||||
doubled for slot parity* below; that lift is COMMITTED but not yet deployed,
|
**deployed** (section below): the pool is again `slots × ctx`, so two
|
||||||
so the sentences above remain the live truth until the next redeploy.
|
maximum-length conversations fit the endpoint at once.
|
||||||
|
|
||||||
**Memory ledger** (121.2 GiB unified), from the boot ledgers. The 22:20 boot
|
**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,
|
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
|
`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.
|
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
|
`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 —
|
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
|
(`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
|
of the legal band, and exactly frank-zealot's pool number — parity, not
|
||||||
approximation.
|
approximation.
|
||||||
- Expected reservation **~10.8 GiB** at ~28 KiB/position (preflight formula,
|
- Measured reservation **11.0 GiB** — the boot ledger prints `reserving the KV
|
||||||
`scripts/frank-dragoon.env:70-72`); the measured points beside it are 5.5
|
pool, 401408 positions (about 11.0 GiB)`. The preflight KV-budget line bills
|
||||||
GiB at 200,704 (boot ledger 2026-10-18), 7.2 GiB reserved / 7.4 GiB billed
|
**11.3 GiB** (`~28 KiB/position incl. block scratch + ~115 MiB/slot`) plus
|
||||||
at 262,144, and 14.6 GiB at the 2026-10-06 shape's 524,288
|
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`).
|
(`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
|
- That RAM comes out of the n-gram table's page cache — FLAGS.md law: RAM the
|
||||||
pool takes is RAM that table loses.
|
pool takes is RAM that table loses.
|
||||||
|
|
||||||
**Deploy state as of 2026-10-18: NOT yet deployed.** The live container
|
**Deployed 2026-10-18** (`./scripts/deploy.sh dankclaw --service
|
||||||
(booted 2026-10-18 01:25) still runs `HALOGEN_KV_POOL_POSITIONS=200704` —
|
frank-dragoon`, Playwright gate 33/33). The boot ledger confirms the shape:
|
||||||
verified in its `flash_serve` `/proc/<pid>/environ` and in the host-deployed
|
`reserving the KV pool, 401408 positions (about 11.0 GiB)`,
|
||||||
`frank-dragoon.env` — and the 2026-10-18 boot ledger reserves `the KV pool,
|
`2 slots over ONE 401408-position KV pool, each request up to 200704 (it
|
||||||
200704 positions (about 5.5 GiB)` with `74.1 GiB in all`
|
reserves prompt + max_tokens)`, and the sum
|
||||||
(`/home/dank/logs/frank_dragoon.log`). Until the next
|
`62.9 GiB of weights locked in RAM (0.8 of them the vision tower's), 11.0 GiB
|
||||||
`./scripts/deploy.sh dankclaw --service frank-dragoon`, the boot ledger keeps
|
of KV pool, 5.7 GiB of working memory, 79.6 GiB in all`. Prompt cache ON (12
|
||||||
printing `2 slots over ONE 200704-position KV pool`; the first boot at the
|
entries × 111 MiB at ctx 200704 = the 1.3 GiB in the KV-budget line), n-gram
|
||||||
new pool must show `ONE 401408-position KV pool` and the ~10.8 GiB
|
47.68 GiB still disk-backed (`Pin::None`), no clamp line. Host `MemAvailable`
|
||||||
reservation — read that line before quoting 401,408 as live anywhere.
|
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)
|
## 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
|
probes; `response_format` unsupported; the chat template rejects
|
||||||
`reasoning_effort` outside `xhigh|medium|low` (the `models.yml` fold stays
|
`reasoning_effort` outside `xhigh|medium|low` (the `models.yml` fold stays
|
||||||
mandatory); unknown sampling params are a 400; the prompt cache is on by
|
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
|
[[frank-dragoon-flash-server-v2-swift15]] for the engine-flag detail that
|
||||||
carries verbatim, and [[deployment]] for the deploy/health gate.
|
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
|
**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
|
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
|
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
|
is unreachable because the window itself is smaller. (That pool == ctx
|
||||||
concurrency limit is superseded — on redeploy only — by *Pool doubled for slot
|
concurrency limit IS superseded: the doubling deployed the next day — see
|
||||||
parity* above; the window math stands, the pool never moves the ceiling.)
|
*Pool doubled for slot parity* above. The window math stands, though: the pool
|
||||||
|
never moves the per-request ceiling.)
|
||||||
**What it bought:** 1.7
|
**What it bought:** 1.7
|
||||||
GiB back as page cache for the 47.68 GiB n-gram table (9.1 GiB against the
|
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.
|
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.
|
**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
|
**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
|
`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
|
instead: the PRODUCTION ledger prints `RS buffer size = 150.75 MiB (3 cells,
|
||||||
each — so pool arithmetic alone under-prices the shape.
|
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`)
|
## 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
|
prefill and +12 % three-stream aggregate over single-stream speed, and keeps
|
||||||
6.2 GiB free. Frank confirmed this arm on 2026-10-18.
|
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 model buffer size = 2036.71 MiB # + 257.66 MiB CPU_Mapped (mmap)
|
||||||
CUDA0 RS buffer size = 452.25 MiB # 3 recurrent-state cells, 32 layers, 3 seqs
|
CUDA0 KV buffer size = 13056.00 MiB # q8_0 K + q8_0 V, 786,432 positions
|
||||||
compute 1,409 MiB CUDA + 1,201 MiB CUDA_Host
|
CUDA0 RS buffer size = 150.75 MiB # 3 cells, 32 layers, 3 seqs
|
||||||
mmproj 595 MiB + 248 MiB # charged AFTER the fit check
|
CUDA0 compute buffer = 1409.02 MiB # + 1088.87 MiB CUDA_Host, 2.84 output
|
||||||
nvidia-smi settled: 17,876 MiB used / 6,249 MiB free of 24,123
|
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
|
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`,
|
numbers from the 27B era) excludes the projector — **read settled `nvidia-smi`,
|
||||||
never the projection**.
|
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
|
- **168.7 t/s** decode — three runs of 512 tokens: 168.4 / 168.7 / 168.7
|
||||||
single-stream chat with the card's sampler.
|
(the trial arm printed 167.2; same shape, same noise band).
|
||||||
- **280.2 t/s aggregate** across three concurrent streams (≈93.8 each).
|
- **~300 t/s aggregate** across three concurrent 512-token streams
|
||||||
- **2,877 t/s prefill** at 106,418 prompt tokens.
|
(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,
|
- The 27B record it replaces: 76.6 decode, 109.4 aggregate across two streams,
|
||||||
691.7 prefill at 166k. Beaten on every axis.
|
691.7 prefill at 166k. Beaten on every axis.
|
||||||
- The **first request after a boot is a warmup artifact** (37.7 t/s class) —
|
- 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
|
## Contract traps
|
||||||
|
|
||||||
@@ -186,6 +194,21 @@ never the projection**.
|
|||||||
7771 — a live port with no unit behind it. `TimeoutStopSec=180` is now on the
|
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
|
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`.
|
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
|
## 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
|
a compaction request no longer serializes behind a live generation (the Gemma4
|
||||||
era's `--parallel 1` law).
|
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
|
## Fallback rungs
|
||||||
|
|
||||||
1. `UBATCH_SIZE=1024` — frees **1,306 MiB** measured on this shape (prefill
|
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
|
digest serving the NATIVE v2 conversion of the abliterated
|
||||||
Qwen3.8-Flash-Next, a ~62 GiB v2 .hgn that loads in no other engine,
|
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
|
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
|
401,408-position KV pool — 2 × ctx, raised and DEPLOYED 2026-10-18 — at ctx
|
||||||
committed 2026-10-18 but NOT yet deployed, vision ON)** and
|
200704, vision ON)** and
|
||||||
**llama.cpp on the PrismML fork (frank-zealot, port 7771: prism-ml
|
**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
|
Ternary-Bonsai-2-9B at **PQ2_0** — 9B qwen35 hybrid (8 full-attention + 24
|
||||||
Gated DeltaNet layers), TWO 200,704-token conversations, MTP disabled,
|
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
|
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
|
the publisher's own Q8_0 mmproj, on the RTX 3090 — and the custom
|
||||||
tensor type 142 + `prism.hadamard.*` metadata mean no other loader can open the
|
tensor type 141 + `prism.hadamard.*` metadata mean no other loader can open the
|
||||||
file)**.
|
file)**.
|
||||||
|
|
||||||
> 2026-08-22: the former `vllmScripts` repository is merged into the
|
> 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
|
E[flash-server container gfx1151, digest-pinned image
|
||||||
frank-dragoon, port 7770]
|
frank-dragoon, port 7770]
|
||||||
F[flash-server v2 native abliterated .hgn + ngram table + vision tower
|
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
|
G[llama.cpp PrismML fork CUDA build
|
||||||
frank-zealot, port 7771]
|
frank-zealot, port 7771]
|
||||||
H[Ternary-Bonsai-2-27B PQ2_0 GGUF + Q8_0 mmproj
|
H[Ternary-Bonsai-2-9B PQ2_0 GGUF + Q8_0 mmproj
|
||||||
2 x 200704 ctx, q8_0 K + q8_0 V, MTP off, mmproj vision]
|
3 x 262144 ctx, q8_0 K + q8_0 V, MTP off, mmproj vision]
|
||||||
E2 --> E --> F
|
E2 --> E --> F
|
||||||
E2 --> G --> H
|
E2 --> G --> H
|
||||||
end
|
end
|
||||||
@@ -94,13 +95,10 @@ frank-zealot, port 7771]
|
|||||||
`HALOGEN_CTX=200704` over `HALOGEN_KV_POOL_POSITIONS=401408` (**2 × ctx**):
|
`HALOGEN_CTX=200704` over `HALOGEN_KV_POOL_POSITIONS=401408` (**2 × ctx**):
|
||||||
**one pool behind two slots**, sized so TWO maximum-length requests coexist —
|
**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
|
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 =
|
in-flight context and **deployed the same evening**: the boot ledger reserves
|
||||||
7.2 GiB. **Deploy state 2026-10-18: the running container and the
|
`401408 positions (about 11.0 GiB)` with `79.6 GiB in all`
|
||||||
host-deployed env still carry the 200,704-position pool** (boot ledger
|
(`scripts/frank-dragoon.env:70-75`). It bought CONCURRENCY, not a longer
|
||||||
`reserving the KV pool, 200704 positions (about 5.5 GiB)`; host
|
window: the per-request ceiling stays 200,704, because 262,144 is the
|
||||||
`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
|
|
||||||
native wall and the engine refuses more without `HALOGEN_ROPE_YARN`.
|
native wall and the engine refuses more without `HALOGEN_ROPE_YARN`.
|
||||||
MTP head always on, sampler 1.0/0.95/20, coupled cap/timeout
|
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
|
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
|
since 2026-10-17**: halogen-flash-server 0.16.2 serving the **NATIVE v2
|
||||||
abliterated** conversion of orcarouter's pinned BF16
|
abliterated** conversion of orcarouter's pinned BF16
|
||||||
(`qwen38-flash-next-v2-abliterated.hgn`, 62.1 GiB) at ctx **200,704** over a
|
(`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
|
**401,408**-position pool (`HALOGEN_KV_POOL_POSITIONS=401408`, 11.0 GiB
|
||||||
pool behind both slots, raised to 2 × ctx on 2026-10-18 so TWO maximum-length
|
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**
|
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
|
in — engine-tool certified (NIAH 18/18, PPL 3.968, mean KL 0.155); MTP
|
||||||
always-on, vision ON, no server-side sampler change
|
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:
|
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
|
revert the swap commit — the 27B payload and the build-10754 fork tree are
|
||||||
both still on disk.
|
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