Scriberr builds each model backend's Python environment with uv at
container start. uv's default link mode reflinks or hardlinks out of
its cache, which fails on this overlayfs over ZFS combination and
reports it as a failure to clone a metadata file with errno 11,
resource temporarily unavailable. The wording points nowhere near the
cause.
The damage was partial and therefore easy to miss: WhisperX and
PyAnnote came up fine and the application served normally, while the
Parakeet and Sortformer backends were quietly absent. Setting
UV_LINK_MODE to copy trades a little disk and build time for
environments that actually materialise, and the occurrence count for
that error is zero on the following start.
The knob is exposed through the environment so it can be moved back to
the default if a future host does support reflinks.
Scriberr transcribes audio and video locally with WhisperX and
speaker diarization, and it lands on ana-ml2 rather than ana-docker
because the work is GPU-shaped: ana-docker offers eight cores already
shared with fifty containers and thirty-seven gigabytes of disk,
against ninety-six cores, terabytes on /tank and idle capacity on
GPU1. The reservation names device 1 explicitly, since GPU0 is fully
committed to the gen seat, and the container is confirmed to see that
card alone.
The image is built from source, which is not a preference. These are
Blackwell cards at sm_120; the published CUDA image covers Pascal
through Ada only, and the blackwell image the upstream README
documents has never been published at all. The path upstream actually
ships for sm_120 is Dockerfile.cuda.12.9, carrying CUDA 12.9 and cu128
torch, so that is what gets built. The compose header says so, because
the obvious cleanup is to swap in the published image and that would
silently drop the deployment to CPU.
Two configuration details are load-bearing and documented where
someone would go to change them. The application runs as uid 10001
rather than the usual 1000: that Dockerfile moves its user aside for
Ubuntu 24.04's own uid-1000 account and chowns /app accordingly, while
the entrypoint's remapping covers only the data directories, so at
1000 the process cannot open its database and restarts forever behind
a SQLite error that reads as though the machine were out of memory.
Secure cookies stay off while the service is reached over plain HTTP,
or sessions are dropped by the browser and login appears to loop for
no visible reason.
Storage is bind-mounted onto /tank because model weights run to
several gigabytes and the root pool on that host is nearly full.
Also adds the scriberr service alias to internal DNS, following the
existing alias convention so consumers name the service rather than
the box.
Operator: keep the benchmark. It has a named second use (brokkr-smithy-dev
wants gen vs a trained reward model once their tournament converges) and a
demonstrated first one -- it caught a seat that had been coin-flip-grade for
five weeks with nobody measuring it.
Harness promoted from scratch to tools/judge-bench/:
- paths de-hardcoded; runs from its own directory
- proper CLI: --models (REQUIRED), --repeats, --limit, --gateway.
Required on purpose: a stale default would silently benchmark a retired
seat, and the original default (selene-1-mini-8b) now 400s.
- README states the limitation rather than burying it: 24 items of the
author's own design, a screen and not a verdict. This harness scored the
same pair 83 vs 96 while brokkr's corpus ranking task scored it 47 (chance)
vs 94. Both honest; absolute scoring on designed items is an easier task
than ranking real text.
- records brokkr's technique, which is better than anything here: a control
constructed so the correct answer is DEFINITIONAL rather than judged cannot
inherit the designer's error (item vs itself, response vs its own
truncation, text vs its own clauses permuted). Add those before adding more
judged items.
Gateway: comment-only warning at the head of model_list. SEVEN aliases now
resolve to the same weights (chat-judge, classifier, gen, image-judge,
qwen-image-bench, summarizer, summarizer-large -> qwen3.8-27b-uncensored).
That is intended under ADR-0012, but it has a sharp edge brokkr flagged:
cross-checking a result against another alias measures NOTHING when they are
the same model -- agreement is an echo, not corroboration. The note names the
other current collisions (glm-5.2 x4, TTS x4, reranker x2), gives the
/model/info one-liner to check, and records that probes should resolve alias
-> backing at run start AND end because the response `model` field returns the
alias, so a swap is otherwise invisible.
Verified: config still parses, diff is comment-only, canonical re-synced.
Benchmarked selene against gen on selene's own job: 24 designed judge items
with checkable ground truth, pairwise + absolute modes, 3 repeats, on BOTH a
neutral JSON prompt and Selene's native Atla template. 288 calls, all free
local.
neutral JSON selene 20/24 (83%) gen 23/24 (96%)
native Atla selene 21/24 (88%) gen 22/24 (92%)
gen won on both templates and selene's BEST sat below gen's WORST. Selene was
given its own fine-tuned template as a fairness check; it gained one point,
not the three it needed.
Decisive defect: selene cannot emit "tie" -- 0/2 on both templates, forcing a
winner on every equivalent pair. For eval work that is the case that matters
most. gen returned tie correctly on the JSON template. Selene also compressed
the 1-5 scale (clustered at 2s and 4s) where gen used it fully. Selene's only
win was ~3x latency, unexercised at ~60 calls/day with zero queueing.
TWO NAMES, TWO DIFFERENT TREATMENTS, deliberately:
- chat-judge -> repointed to gen. It is a ROLE alias and ADR-0012 says
consumers bind the capability, not a concrete model. Sampler profile copied
from image-judge (temp 0, top_p 1.0, top_k 1, thinking off) so the served
config matches the benchmarked condition.
- selene-1-mini-8b -> REMOVED. It 404s. It was NOT aliased to gen. A
served-name is a contract about what the model IS; answering it with a
different model hides a material change behind a stable string. Operator
ruling: "never repoint a named model at a different model's endpoint --
that is intentionally misleading." Verified: the gateway now returns
HTTP 400 "Invalid model name" for it.
Reclaimed 17.2 GiB on ana-ml2 GPU 1 (free 1,818 -> 19,450 MiB) on a card that
had under 2 GiB of headroom. gen already runs on GPU 0, so the judge role
moved onto an existing seat rather than allocating anything new.
Canonical litellm config synced from the host; ana-ml2 README and
recommended-model-settings updated. compose.yaml kept for reference, not
deployed.
claude-bot holds write on vh/hrafn as of 2026-08-23, so the canonical copy of
the pipeline moved there and infra-ops maintains it directly instead of
routing patches through the repo holder. The files here are a verified mirror
(byte-identical to live at 22e0eb9d75a6).
Live state: run 9922 green, and both new content assertions executed rather
than merely existing --
verify 4/5 host tree matches shipped context (a99ce748a0c9...)
verify 5/5 image source matches host source (06f209fd0641...)
The CI-computed context hash matching on the host is the end-to-end proof
that the converge lands what CI ships. Its absence is what let the
frozen-source bug survive every green deploy.
Also records why the HEAD == GITHUB_SHA assertion was added and then removed:
it needed the git binary (run 9920, exit 127), and installing git flipped
actions/checkout@v4 from its node implementation to the git binary, which
died on a missing CA bundle (run 9921). A nice-to-have assertion changed the
checkout code path and broke a working pipeline; it guarded a hypothesis that
proved wrong, so it went rather than getting ca-certificates bolted on.
Root cause of nevermore-claude's report that v1.0.0 deployed green while the
host kept serving 0.1.0.
The staging dir was $compose_dir/.stage -- INSIDE the rsync target. So
`rsync -a --delete $compose_dir/.stage/ $compose_dir/` deleted .stage from
the destination (absent from the source listing) DURING the transfer,
destroying the source mid-copy. Reproduced exactly:
before: app.py="OLD" leftover.txt .stage/app.py="NEW"
after: app.py="OLD" leftover.txt GONE, .stage GONE
Deletion worked; the copy silently did not. So the directory looked
converged while host source stayed frozen at the first manual rsync, and
because the build's COPY inputs never changed, Docker full-cache-hit and
every SHA tag aliased one image. The provenance guarantee was false.
Nothing caught it because the verify steps asserted the marker, health, and
a 200 from /readyz -- all of which pass on a frozen host. None measured
content.
Fixes:
- stage at /tmp/hrafn-deploy-stage, outside the target
- CI computes context_sha256 over the shipped file list; the playbook
recomputes it on the host post-converge and fails on mismatch
- compare the running container's src/**/*.py against the host's, catching
a SHA tag naming layers the image does not contain
- checkout clean:true + assert HEAD == GITHUB_SHA so a reused runner
workspace fails the job rather than shipping a stale tree
Declined --no-cache: a cache hit is correct when the context is genuinely
unchanged, and the new assertions prove the property directly rather than
brute-forcing it.
The container-vs-host check compares only *.py -- `pip install .` generates
src/hrafn.egg-info/* inside the image and __pycache__ appears at runtime, so
a naive `find src -type f` compare false-fails on every healthy deploy.
Verified against the live container before shipping (12 host files, 18 in
container, 0 content differences).
The first CI run shipped clean but revealed a design gap in the playbook:
unpacking the context tarball in place overwrites tracked files and never
removes anything. Leftovers from the pre-CI hand-rsync (tests/, docs/,
ROADMAP.md, persistent-memory.md, CLAUDE.md, LICENSE) survived the deploy
and had to be cleaned off ana-docker by hand.
That is the same failure class that produced the mess in the first place:
a deploy that only ever adds cannot return the host to a known state.
- unpack to a staging dir, then rsync --delete onto the compose dir
- protect host-owned .env and .deployed by name
- add .env.example to the context tarball so converge does not delete it
- record in the workflow that the tar list is now AUTHORITATIVE: anything
omitted is removed from the host on the next deploy
Re-validated with `elway --dry-run` (9 steps, 3 verify, parses clean).
Not yet in vh/hrafn -- infra-ops has no write access there, so this is
offered to the repo holder rather than pushed.
hrafn was handed to infra-ops for uptime ownership with no CI deploy and
no commit provenance -- the image was always local/hrafn:v1 and the whole
working tree lived in the compose directory.
These two files fix both. They are authored here because infra-ops owns
hrafn's uptime, but they belong in vh/hrafn; claude-bot is not a
collaborator there, so they are handed to the repo holder rather than
committed directly.
- playbooks-deploy.yaml -> vh/hrafn playbooks/deploy.yaml
- gitea-workflows-deploy.yaml -> vh/hrafn .gitea/workflows/deploy.yaml
Design calls recorded in the README: the build context travels as one
tarball rather than per-file upload steps (nevermore's pattern fails open
when a new source file has no matching step), and the playbook refuses to
deploy unless .env exists at 0600 -- a guard prompted by it arriving 0644
with a live bearer token in it.
Validated with `elway --dry-run`, which caught a real interpolation bug
during authoring. No new Actions secrets needed.
nevermore-claude handed hrafn to infra-ops for uptime ownership. Intake:
verified health independently, brought it into the inventory, and fixed one
security defect found during the check.
- canonicalize stacks/hrafn/ (compose.yaml + .env.example + README)
- list hrafn in the ana-docker running-stacks table
- README records the load-bearing bits: shm_size 1gb is required for
Chromium, playwright and the base image are version-coupled, SSRF denies
private targets by default, and restart:unless-stopped does NOT act on
healthcheck failure
Security fix applied on the host, not in this tree: /opt/docker/compose/
hrafn/.env was 0644 with a live 57-char bearer token in it, readable by
every local account (verified by reading it as `nobody`). Tightened to 0600;
owner unchanged so the deploy path still works, confirmed via `docker
compose config` as lkraven. Container untouched and still healthy.
Known gaps recorded rather than silently accepted: no CI deploy, and the
image builds from an rsync'd working tree living in the compose directory,
so the running image has no commit provenance.
The filezilla container on vm-esh-nas shipped with no restart policy. It
was SIGTERMed during the 2026-08-18 host shutdown and never came back,
staying down four days while dockge/dozzle-agent/beszel-agent all
recovered on their own.
- add `restart: unless-stopped` (applied on the host, container recreated
and verified serving 200 on :5800 with all four /NAS mounts visible)
- promote the previously un-canonicalized compose into stacks/filezilla/
so the policy is version-controlled rather than host-only
- record the missing-restart-policy failure mode in the host README
The 0.55 configuration booted cleanly, passed every startup check, and then
killed the engine on an ordinary request of roughly 20k tokens:
torch.OutOfMemoryError allocating 614 MiB in the GDN causal_conv1d path with
443 MiB free. The container self-recovered via restart: unless-stopped.
Context length was not the cause. gpu-memory-utilization is not a hard
reservation. Weights and KV are reserved up front, but activation memory is
allocated on demand and the profile that sizes it uses dummy data at
max_num_batched_tokens. The profile predicted 3.62 GiB; the process sat about
1 GiB over its entire budget even at idle. With six vLLM containers sharing
GPU1 there was no slack left to grow into, and a modest allocation lost the
race. 0.52 leaves roughly 7.7 GiB free against 4.6 GiB at 0.55.
Records a measurement that governs how max-model-len can be chosen at all:
the KV pool varies between boots. Two consecutive boots at 0.52 produced
425,663 and 453,600 tokens, a 6.6 percent swing, because the pool is sized
from free memory at startup and that depends on what the other tenants hold
at that instant. 420,000 sits under the lower observation so the seat starts
in both conditions. Sizing to the higher one would boot today and refuse
tomorrow, which is why the value was measured across boots rather than
estimated from one.
Concurrency of about 1.0 to 1.08x means one request at full length. Shorter
requests still batch normally, since vLLM schedules by tokens rather than by
max-model-len.
Operator approved after real-use testing. The experimental standalone
container is retired and stacks/mog-sec is canonical again, with
restart: unless-stopped so the configuration survives a reboot.
Cutover verified against the container it replaces: KV pool 526,617 tokens
at 1.10x concurrency, identical; zero restarts; both gateway aliases
serving; DFlash2 confirmed drafting at k=7 with 231 draft tokens over 33
drafts; vision working at 2048x2048.
One variable was deliberately dropped rather than carried over. The previous
stack hardcoded PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True, the
validated container never set it, and the quant playbook records
expandable_segments corrupting retained tensors in another context. The
compose now defaults it empty via MOG_ALLOC_CONF. Promoting the stack as it
stood would have shipped a variable the tested configuration did not have.
The speculative config moves into a single MOG_SPEC_CONFIG carrying the
whole JSON, because the two shapes are not interchangeable: dflash requires
a model pointing at the drafter and MTP must not have one, so a
method-plus-tokens template cannot express both. Also parameterised:
MOG_DRAFT_MODEL, MOG_MM_PROCESSOR_KWARGS, MOG_MAX_NUM_BATCHED_TOKENS.
The mm-processor image cap is now mandatory rather than incidental. The
model's own preprocessor declares 4096x4096, which expands to 16384 image
tokens and kills startup on builds that enforce the image-token count check.
Adds the .env.example this stack never had, carrying the measured rationale
for each value and the one-line rollback.
Deployed for Eyra (meeting recorder) per the eyra-dev request. Serves
large-v3 (batch tier) + distil-large-v3 (low-latency tier) on :8204,
fp16, both resident, ~5.9 GB VRAM against 20 GB still free.
Sits alongside the existing parakeet stack (:8765) deliberately: parakeet
is a TDT/transducer returning bare {"text": ...} and has no no_speech_prob
concept, so it structurally cannot serve this consumer.
The load-bearing requirement -- segments[].no_speech_prob surviving
response_format=verbose_json -- is VERIFIED on both tiers.
Measured finding worth more than the deployment: no_speech_prob alone is a
WEAK hallucination gate on this stack. Pure silence and pink room tone both
produced the classic Whisper 'Thank you.' hallucination while no_speech_prob
stayed under 0.11 -- a conventional >0.6 threshold passes both through.
avg_logprob separates the same cases ~6x more decisively (-0.11 speech vs
-0.65/-0.72 non-speech) and compression_ratio splits 1.141 vs 0.556.
Consumers should gate on a composite, not no_speech_prob alone. Table in
the README.
VAD pinned OFF at the consumer's request (they VAD-gate upstream on the
capture edge). Consequence stated plainly in the README: with VAD off this
service will transcribe silence into text and is not defending itself.
Image pinned BY DIGEST rather than :latest-cuda, because the VAD-off
setting rides on _UNSTABLE_VAD_FILTER -- a variable upstream explicitly
marks unstable. A floating tag could rename it on any bump, silently
restoring VAD and moving no_speech_prob semantics under a calibrated gate
with no error and no log line.
Two deployment gotchas recorded: PRELOAD_MODELS only loads models already
cached (it does not download -- use POST /v1/models/{id}), and the bind-
mounted cache needs a hub/ subdir or every /v1/models call 500s with
CacheNotFound while /health still returns 200.
The multi-turn Gemma-4 CoT problem is solved. One MeroMero-v2 seat, one weight set,
two aliases: char-rp (prose) + char-rp-reasoning (streaming chain-of-thought).
The winning stack, traced from vLLM source by the four-arm brokkr/dwarf panel:
- vllm/vllm-openai:v0.26.0 — ships transformers 5.14.1 natively, below the
head_dim guard, so Gemma-4-31B loads with no pin and no custom image. It also
carries the #48217 streaming pre-arm fix.
- A patched chat template whose enable_thinking:true branch force-opens a BARE
<|channel> (not <|channel>thought\n -- full-open defeats _preprocess_feed's
injection). --chat-template override, no re-quant.
- Two served-names char-rp / char-rp-thinking; --reasoning-parser gemma4;
default enable_thinking:false. LiteLLM char-rp -> prose, char-rp-reasoning ->
the thinking served-name with enable_thinking:true.
Verified: streaming CoT split 6/6 direct on :8016 and 3/3 through the gateway;
char-rp prose clean on both transports with no trailing-token leak.
Two hard-won facts recorded in persistent-memory:
- STREAMING ONLY. Non-streaming can't split -- extract_reasoning never receives
prompt_token_ids so the pre-arm can't fire (a vLLM one-shot bug unchanged
across v0.24-0.27). Fine here: Lobe/OWUI stream. Upstream PR #49797 fixes
non-streaming too, landing ~v0.28.0 -- then it's a clean image bump.
- KEY-NAME TRAP: vLLM streams reasoning in delta.reasoning; LiteLLM normalizes
to delta.reasoning_content. I lost two false-negative test rounds to this.
Canonical: stacks/meromero-charrp/ (compose + patched_chat_template.jinja) and
stacks/litellm/conf/config.yaml. Rollback is the .env image line + dropping
--chat-template.
Stand up Open WebUI v0.11.0 parallel to lobe-chat (:3210) as an
operator-approved candidate replacement. Env-declarative config
(ENABLE_PERSISTENT_CONFIG=False = the deploy is the config source of
truth), model picker auto-tracks the LiteLLM gateway with no pins,
background task model pinned to summarizer, TTS routed direct at the
dots gateway (:8198).
Gates verified on the box: G1 (declarative config, both directions),
G2 (picker == live gateway roster, no hand-listing), G3 (models/sync
genuinely reconciles create+delete), G5 (task model pinned). G4 (TTS)
handed to tts-dev.
Credential posture (G6): fresh capped LiteLLM key open-webui-esh
(all-proxy-models, $50/1mo budget) rather than inheriting the uncapped
lobe-chat-esh key; signup locked off after admin creation. Secrets
vaulted under esh-docker-vm/open-webui-*.
Note: the API-key toggle env var is ENABLE_API_KEYS (plural); a var
only reaches the container if compose names it in environment:.
lobe-chat is untouched.
Operator: "pass everything through, lobe is a test surface for me." The picker
goes from 11 curated seats to all 25 chat-capable models the gateway serves,
paid passthroughs included.
Membership is derived from LiteLLM's declared `mode` rather than by taste:
chat (15) and completion (1) are in, and the nine GLM/frontier entries whose
mode is unset are chat models in practice. Out: audio_speech (ext-tts, tts-1,
tts-1-hd, gpt-4o-mini-tts), embedding (qwen3-embedding) and rerank (reranker,
reranker-a3-bge-v2-m3) — seven models that cannot answer a chat request at all,
so listing them would recreate the visible-but-dead entry this list just got
fixed for.
Every candidate was probed live from inside the container before being pinned,
which corrected an earlier claim in this file. char-rp and char-rp-reasoning
are NOT retired: they are configured and their upstream seat (ana-ml2:8016) is
refusing connections while under maintenance, which is a 500, not a 400. Both
are retained deliberately — dropping a name because its seat is bouncing makes
the list rot in the other direction. Only char-rp-fable is genuinely gone (400,
absent from model_info). The comment now spells out the 400-vs-500 distinction
so the next editor does not repeat the mistake.
Also corrects the credential-posture header, which still described the key as
scoped to free local models and cited the glm/kimi rejection as current
verification. The key now carries the all-proxy-models access group with no
budget ceiling, so that block had become a false security claim in the most
load-bearing comment in the file.
Two independent gates kept the new `sec` family out of Lobe, and only one of
them was visible from the symptom.
The picker never auto-discovers. `OPENAI_MODEL_LIST=-all,+<names>` clears
Lobe's built-in OpenAI catalogue and re-adds one model per `+name`, so anything
added to LiteLLM stays invisible until this list is edited and the container
bounced. That pin is deliberate — an unpinned picker offers models that fail on
click — but it means the list rots in both directions, and it had:
- `sec` / `sec-reasoning` missing (hosted_vllm/mog-sec-27b{,-thinking} on
ana-ml2:8019, added to config.yaml earlier today), and
- `char-rp-reasoning` / `char-rp-fable` still listed after being retired
upstream, i.e. two picker entries that 400 on click. Verified: a call to
char-rp-fable now returns 400 Bad Request.
The list is now curated to live, chat-capable, free-local seats — eleven, each
round-tripped through the container after the bounce. The paid family stays out
deliberately; that is now a picker decision rather than a key one.
Which is the other half of this commit: the `lobe-chat-esh` key is no longer
scoped to free local models. On the operator's instruction infra-ops swapped its
explicit array for the `all-proxy-models` access group, so it now reaches the
paid passthroughs with `max_budget: None`. The README documented the old posture
as current, which made it a security claim that was no longer true; it now
carries the change, what it costs, and the fact that the picker is the only
remaining gate.
Operator's call: the pen-test seat is reached as `sec` (non-thinking) and
`sec-reasoning` (thinking). Backend and served-model-names (mog-sec-27b[-thinking]
on :8019) are unchanged; only the gateway alias name changes. Old mog-sec /
mog-sec-reasoning now 404.
Autonomous overnight run under the operator's full-autonomy grant. End state:
fleet up, gen seat untouched, a new verified pen-test seat serving where fable was.
PPL on the orcarouter gen seat (fable downed to free GPU1 for a nospec probe,
probe torn down after): mean 7.07 / median 5.76, within noise of heresy 6.910 /
5.625 and identical to our recipe's usual 7.059. The gen-seat search is settled.
M.O.G.-SEC: chose Blackfrost-Research/M.O.G.-SEC-27B-1M-CTX-BF16 (rev deede677)
over the pre-made ModelOpt NVFP4, which was disqualified on W4A4 4-bit activations
(the AEON degradation mode, catastrophic on a 1M-context model), zero MTP tensors,
and ModelOpt format. Pulled, format-screened (P(<think>) 1.11e-05, clean), quanted
in-house to mixed NVFP4+FP8 (23.4 GB, MTP + vision preserved), and served in the
retired fable slot.
stacks/mog-sec ana-ml2 GPU1 :8019, KV 418,218 tok / 1.60x @ 262K
aliases mog-sec (non-thinking), mog-sec-reasoning (thinking)
gates surface 6/6, MTP 55.3%, format 0/15 leak, vision 7/3/1,
capability 4/4 (delivers offensive-security content)
Served at native 262K, NOT the card's 1M -- the 1M needs YaRN (absent from the
weights' config) plus the SGLang/DFlash2 path the repo ships a deployment kit for,
neither of which is our vLLM surface. A real 1M seat is a separate SGLang project.
Retired char-rp-reasoning + char-rp-fable (zero traffic, pointed at the downed
fable :8019; now 404 cleanly, not repointed -- a security model is not an RP model).
char-rp (meromero) untouched. Vision preprocessor built from the model's own
image_processor block, same trick as the MeroMero seat.
GPU0 seats (gen, meromero) were untouched and healthy throughout. The quant ran in
GPU1 free space with no production seat stopped except fable, which was replaced.
nevermore pinned LLAMA_SWAP_MODEL=granite-4.1-8b, an alias retired with the
granite seat on 2026-08-12. Every summarization call since then failed: 67
consecutive status=failure rows, 0 tokens, twice daily, entirely silently. The
briefing had been rendering with no LLM pass at all. Nothing alerts on
status=failure in the spend logs, so it took an unrelated question about
reranker VRAM to surface it.
It was also pinned to NEVERMORE_RERANK_MODEL=qwen3-reranker -- the incumbent
Brokkr R43 measured harming 80/90 fleet queries -- and was its ONLY caller,
while the production `reranker` alias sat at 0 calls for 4 days. The R43
cutover repointed the alias but never moved the consumer.
nevermore/.env LLAMA_SWAP_MODEL granite-4.1-8b -> summarizer
NEVERMORE_RERANK_MODEL qwen3-reranker -> reranker
(server-only; .env is excluded from the mirror both ways)
Verified against nevermore's exact call shape: summarizer returns clean content
with 0 reasoning chars at temperature 0.2 / max_tokens 4000; reranker scores
0.95 on-topic vs ~1e-5 off-topic; embedding returns dim-1024.
Retired alongside it:
vllm-rerank :8002 Qwen3-Reranker-0.6B + the qwen3-reranker alias
vllm-rerank-a4 :8014 gte-reranker-modernbert + its alias
vllm-granite :8004 Exited 8 days, dead service block
and vllm-rerank-a3 was promoted from a throwaway `docker run` into this stack
(the selection ledger's own open follow-up). Healthy in 55s. It keeps the
bake-off arm name so the ledger, memory and R43 record stay valid.
VLLM_VERSION is pinned latest -> v0.24.0. Every service in the stack shares that
one variable, so a bare `compose up -d` could have silently upgraded all of
them at once; both tags resolved to the same local image (4091d5593f77), so the
pin changed nothing at runtime.
GPU1 is down to 81,448 of 97,887 MiB -- 13.9 GB reclaimed tonight.
Correction: an earlier claim that A4 had no gateway alias was wrong. It did.
LiteLLM serves both config-defined and DB-defined models -- live showed 32
against config.yaml's 26 -- and grepping the file cannot see the difference.
/v1/models and /model/info (which flags db_model) are the ground truth. DB
models delete hot via POST /model/delete with no restart.
Left alone: reranker-a3-bge-v2-m3, a zero-call duplicate of `reranker` on the
same backend. It is Brokkr's cutover-verification handle -- redundant rather
than broken, and another agent's tooling is not mine to delete unilaterally.
Operator directive: lfm2.5-2.6b goes down permanently.
- stacks/vllm/compose.yaml vllm-lfm25 service removed (replaced by a
tombstone comment), pushed live to ana-ml2
- ana-ml2 container docker rm -f'd, 8,721 MiB freed on GPU1
(95,388 -> 86,667 of 97,887)
- litellm config lfm2.5-2.6b alias deleted, live + canonical,
28 -> 27 models
It was an EVAL-ONLY bake-off seat against granite-4.1-8b that never received
the operator ruling it was pending; the comparator was retired from the roster
on 2026-08-15; it was deliberately never wired into any default or fallback
routing chain; and spend logs show 0 calls in the 4-day window to 2026-08-21.
Weights stay in the shared HF cache -- nothing deleted from disk.
The gateway restart that makes the alias deletion take effect is HELD so it can
batch with a pending reranker change. Until then the name is still routable
in-memory and will error against a dead backend.
Auditing the three reranker seats while answering "why do we have three" turned
up a real problem. The design is one production, one rollback, one fallback --
but the traffic is backwards:
:8013 A3 bge-v2-m3 PRODUCTION, backs `reranker` 0 calls / 4 days
:8002 Qwen3-Reranker RETIRED incumbent, rollback only 7 calls, 12-hourly
:8014 A4 gte-modernbert "fallback" no alias at all
nevermore is hard-wired to the incumbent by name (NEVERMORE_RERANK_MODEL=
qwen3-reranker), so the R43 cutover never moved it -- the cutover repointed the
`reranker` alias and correctly left `qwen3-reranker` naming the Qwen model.
Brokkr R43 measured that model harming 80/90 fleet queries, so nevermore's
twice-daily rerank pass is likely degrading its own briefing.
Fix is one line in nevermore's .env plus a nevermore restart, and it must land
before :8002 is retired. Recorded in persistent-memory with the A4 alias also
noted as absent (global CLAUDE.md names reranker-a4-gte-modernbert; it does not
exist).
Agent sessions hand the operator URLs and they drown in terminal
scrollback. The Booth is the right home for them — it already has the one
property that decides adoption, which is that a session can publish with
mkdir and cp, no API key, no schema, no deploy — but everything in it dies
in 24h.
So: a booth containing `.forever` is never swept, and renders in its own
Kept lane at the top of the index. Opt-in per booth, so the ephemeral
default is untouched and nobody inherits a cleanup chore. `rm` the
sentinel and the board rejoins the sweep; the CLI verbs are sugar over
exactly that, which keeps the filesystem-is-the-state model honest.
The pin is deliberately NOT wired into is_expired(). That stays a pure age
question feeding the `expires_in` countdown; only sweep_once() honours the
sentinel. Keeping expiry arithmetic and reaper policy apart means they
cannot drift into each other.
Kept cards are visually separated per Australis: a 2px top edge in aurora
blue, the one accent border the system sanctions. They show "kept" instead
of a countdown, and they deliberately lose the one-click wipe button — a ×
next to the durable stuff is a footgun, so removing a kept board is a
two-step act.
`booth link <url> [description]` appends to the standing `links` board,
creating and keeping it on first use. Entries carry provenance (handle or
hostname, plus a timestamp) because a bare URL is unreadable three days
later. The append is one printf of one line to an O_APPEND fd — atomic
under PIPE_BUF on POSIX — which matters because many agents post to one
board and interleaved half-lines would be the obvious failure mode.
Seven tests cover the sentinel: detection, survival of a sweep that wipes
its neighbour, the deliberate is_expired/sweep_once split, the listing
flag, the sentinel not inflating item counts, and both lane-rendering
directions. Two of them originally asserted on the bare strings "Kept" and
"kept-grid", which passed for the wrong reason — those also appear in the
inlined stylesheet served on every page — so they now assert the full
class attribute. 55 pass.
Also corrects the Homepage card's description, which advertised a flat 24h
TTL that is no longer the whole story.
Removing the `background:` block from settings.yaml was not sufficient.
Homepage server-renders the wallpaper as an INLINE style on
`<div id="background">` and Next.js caches the rendered page, so the aurora
survived both the config removal and a container restart. Only a full
recreate clears that cache, and recreating this container costs an hour of
missing tab bar and i18n before it heals itself.
Adding `#background` to the canvas reset is deterministic and immediate,
and it also keeps the canvas correct if the setting is ever re-added by
accident. The existing selector missed it: the DOM is
body > div#__next > div#background, so `body > div` matched the Next.js
root, not the wallpaper layer.
Verified live rather than locally: the served page now reports no
background image, with all three canonical faces loaded and the group
eyebrows rendering as JetBrains Mono in Australis cyan.
The predecessor theme was ugly for two structural reasons, not one.
It did not use the design system's colours. It built a parallel OKLCH
palette "derived from the Australis philosophy" and swapped the canonical
typeface for Supreme -- a fork, not a theme. Every hex here is now copied
verbatim from ~/.claude/skills/australis-design/colors_and_type.css, and
build.py re-checks all 19 against that file at build time and warns on
drift so it cannot quietly fork again. Type is the canonical stack: Space
Grotesk / Inter / JetBrains Mono, vendored as latin-subset VARIABLE woff2
(one file per family, 102 KB total against 56 KB for three static Supreme
cuts, and no Google Fonts request at page load).
It also carried a generated full-bleed aurora image behind the entire
dashboard. Canon forbids exactly that -- "solid fills only on chrome, no
full-bleed photography, no decorative gradients", and the aurora motif
"never as a background fill behind text". The predecessor knew, said so in
its own header, and dialled the opacity down rather than dropping it. The
image is gone; the aurora survives as a 1px accent edge under the tab bar,
which is where canon sanctions it. The asset stays in images/ in case it
is ever revisited.
Direction is instrument panel. Group headings become the Australis mono
eyebrow with a hairline to the right edge -- canon calls the eyebrow a
system signature, and it turns the groups into register bands instead of
headings floating over a grid. Status stops shouting: the filled emerald
chips read louder than the service names they annotated, so they are now a
semantic dot plus a mono micro-label at tertiary contrast. Cards are
bordered and opaque, per canon's border-over-shadow rule for chrome.
Alignment, per operator feedback that pills and cards did not line up:
- The status cluster is centred on the service name's line rather than
parked in the card's top-right corner, where Homepage's `absolute top-0`
left it floating ~7px above the title's optical centre. The offsets
reconstruct the title line box and are documented as moving together.
- Descriptions get a two-line minimum, so the common one-line/two-line mix
bottom-aligns across a row. This is what made the grid look ragged.
useEqualHeights stays false: it inflated short cards to match a widget
card twice their height, which was the worse failure.
- The status dot is flex-centred rather than nudged with vertical-align,
so it stays centred if the type scale changes.
Retires the Skyfall sources and the Supreme faces; theme/ now has one
source of truth.
waterland-dev confirmed the mechanism: adoption marks a job failed on a
sidecar saying running/queued, or on a directory with no plate.png. The
pre-header-fix renders died 1.7s in with a source and no plate, so they
land in the second branch. Recorded so nobody investigates adopted
history as a live fault.
One update.sh run on irv-ml1 carried both open upstream PRs, per the
operator's green-light on the job-store fix:
- #5 (464dfc2) declares cupy-cuda12x[ctk] on the gpu extra and takes uv
out of the render path (sys.executable -m waterland.cli), retiring the
runtime prune trap at the source.
- #6 (b72425b) rehydrates the job index from the data volume at startup,
fixing the unbounded store growth reported from this side.
Verified after the update rather than assumed: healthy on backend cupy;
/api/jobs went 1 -> 16 against 16 directories on disk, so API and volume
agree for the first time; nothing wrongly reclaimed, correct since 16 is
under RETAIN=40 and adoption only makes them visible; a real 256^2 plate
render completes warm, so the kernel-cache volume survived the image swap.
A subsequent render took both counts to 17.
The image keeps its explicit [ctk] install and UV_NO_SYNC/UV_OFFLINE pins
even though both are now redundant. The header requirement is a property
of this slim base, not of the upstream extra, and the cost is measured
rather than assumed: uv sync satisfies it first, so the line reports
"Audited 1 package" and adds 0.3s to the build. The env pins are now
cheap defence-in-depth against any future path that re-enters uv.
Docs corrected in place: the README's upstream-finding section is now a
resolved-finding record, and the two "bounded ~500 MB" claims say which
commit made that bound hold across restarts rather than only within a
process. Comment-side changes pushed to the live compose dir; no restart
was needed for them.
Names for fleet hosts so addresses stop needing to be memorised. Built because
IPv6 makes that hopeless — and, more to the point, because v6 addresses are
derived rather than assigned, so they cannot reliably be written down once and
trusted either.
dns/internal.yaml source of truth: 38 hosts + 4 service aliases
scripts/dns-sync.py reconciles AdGuard resolvers against it
stacks/adguard-ana/ the colo's resolver, which did not exist
Naming is <host>.<site>.internal with sites ana/esh/nh3 (operator's call).
.internal is ICANN-reserved for this; .local is reserved for mDNS, which is
why searxng.pfi.local was a collision that merely happened to work.
Same posture as deploy-stack.sh: file is intent, resolvers are derived state,
you see a diff before anything changes. Every name is published to every
resolver, so the site label says where a host IS, not who knows about it.
Two properties that matter:
- Authority is scoped to the ZONE, not the resolver. ESH carries hand-made
esteban.net rewrites predating this; they are read, ignored and preserved.
Resolver-wide authority would have silently deleted them.
- Within .internal it IS authoritative, so UI-added names get removed. That is
the point — one place to look.
Colo gap closed: ana-docker had no resolver at all (hosts went straight to
1.1.1.1). Its AdGuard runs API on 8053 because 8080/3000 were taken, so the
port is carried per-site in the yaml rather than assumed by the script. It
ships with no blocklists — a false positive on a server network breaks
service-to-service calls for no upside.
Auth is a dedicated infra-ops AdGuard user, not the operator's account,
password vaulted at nh3-dev/adguard-infra-ops-password. Pre-change configs
backed up on each resolver. Both resolvers stayed answering across the restart.
searxng.pfi.local -> searxng.ana.internal, with the old Host() kept alongside
so nothing breaks mid-migration. matrix.pfi.local deliberately NOT migrated: a
Matrix server_name is baked into every user id, room id and signing key, so
renaming it rebuilds the homeserver's identity rather than changing a DNS name.
The v6 column is empty and correct — no fleet host has a global v6 address
yet. The file documents why addresses must be pinned statically before they go
in, since a record that silently stops matching is worse than no record.
Operator granted claude-bot read on vh/waterland; verified scoped correctly
(admin false, push false, pull true). Token is on irv-ml1 at
/root/.config/waterland-studio/git-credentials, 0600 root-owned, wired as a
REPO-SCOPED credential helper rather than a global one, and .git/config holds
no token so the remote stays clean in any diff or backup. The vh site-admin
token was used only for the initial clone and the grant itself and was never
written to disk on that host.
update.sh now runs end to end: fetch, rebuild, recreate, health. Verified the
kernel-cache volume survives a recreate (warm 256^2+anim render 6.6s straight
after) and the job store survives with all 16 directories intact.
Records an upstream finding surfaced by that check: JobStore._jobs is
memory-only and nothing scans the data dir at startup, so after a restart the
API lists only new jobs while old ones persist on disk — cosmetic — but the
RETAIN=40 eviction only sees in-memory jobs, so restart-orphaned directories
are never reclaimed. The handover's ~500 MB bound holds per process lifetime,
not across restarts. Reported to waterland-dev; upstream's call to fix.
Replaces a bare nohup on irv-ml1:8410 that would not have survived a reboot,
handed over by waterland-dev. Tracks vh/waterland @ main (PR #4 merged; main
HEAD is exactly the pinned 8025366).
Build context is a checkout at /opt/waterland-studio/src, deliberately OUTSIDE
the compose dir — deploy-stack.sh rsyncs stacks/<stack>/ with --delete and
would otherwise eat it. The Dockerfile is passed out-of-context.
Three landmines, all measured:
1. Both uv extras are load-bearing at build AND run. jobs.py shells the
renderer out as a literal with no --extra flags, so uv
would re-sync at runtime and prune cupy — silently dropping to the numpy
path at ~21x wall time. UV_NO_SYNC pins it; UV_OFFLINE makes any failure
loud instead of quietly slow.
2. cupy needs CUDA HEADERS for its NVRTC compile, not just the driver and the
wheel's runtime libs. The host has a system CUDA toolkit so the nohup
process found them by accident; a slim image does not, and every render
died 1.7s in with 'Failed to find CUDA headers' printed through argparse's
usage banner — which reads like a CLI bug, not a missing toolkit. Fixed
with cupy-cuda12x[ctk] (hundreds of MB, vs ~6 GB for a -devel base).
3. The A6000 is host device 1 but container device 0, since compose exposes
exactly one GPU. CUDA_VISIBLE_DEVICES_TARGET=0 inside; copying the host's
value selects a device that does not exist.
/root/.cupy is a volume because the NVRTC compile costs ~17s: verified at
23.3s cold vs 6.1s warm, and re-verified across a restart (23.2s on a fresh
cache volume, 6.0s once populated). Warm 256^2+anim beats the 7.4s recorded
against bare metal, so containerising cost nothing.
Job store seeded with the 4 jobs from the displaced instance. Serial by design
(one replica, one card) and unauthenticated, so it stays LAN/WireGuard-only.
Two operator corrections in one pass.
Stat values overshot: the previous commit took them from font-thin 13px to
bold 22px in heading white, which went from whisper to shout. A stat only has
to out-rank its own label, not the service name above it — now --text-md at
medium weight in cyan, which clears the label but sits below the card title
where it belongs.
Colour lift, staying inside the system rather than around it: Skyfall names
Aurora (blue, cyan, green) the PRIMARY families, 'used generously, in that
order', while Dawn (amber, red, violet) is semantic-only. So group markers now
cycle blue -> cyan -> green down the page — icons at full strength, names at
0.72 — service icons take a single cool wash, header resource icons go cyan,
and latency tags move to the info family so 'how fast' stops looking like
'is it alive'. No Dawn colour is used decoratively anywhere.
Also fixes selectors that never bound: Homepage emits docker-status-<state>,
not status-<state>, so the green pills up to now were stock colouring rather
than this file. Both forms are matched and the trap is commented.
README records the iteration loop that would have caught the overshoot: CSS is
served per-request, so it needs a reload, not a recreate and not the layout
warm-up — and candidate CSS can be injected into the live page for a
seconds-long feedback loop instead of a 10-minute one.
Stock Homepage builds each stat as a font-thin (weight 100) 13px value above a
font-bold 12px uppercase label, so the number you actually came to read is the
quietest thing in the card while its label shouts. Skyfall's rule is that
hierarchy comes emphatically from weight AND size, and that numbers are data.
Value now renders at --text-xl bold in tabular mono at --text-heading; label
drops to a --text-2xs tracked eyebrow at --text-faint. The well itself moves to
--surface-input, one step DOWN from the card it sits on, so stats read as inset
data rather than as another floating surface — a recess, so it takes the
hairline without the shadow.
Also drops .service-block from the generic .service-tag rule, which was what
pinned every number to --text-2xs in the first place.
Visible on Plex, Jellyfin, PaperlessNGX and Uptime Kuma.
The char-rp-reasoning seat on ana-ml2 GPU0 — NEO-CODE Heretic2 27B at modelopt
NVFP4 with a grafted BF16 MTP head, ~77 tok/s via qwen3_5_mtp spec-decode,
replacing the retired GGUF seat. It had been running untracked.
Includes conf/mtp-workaround/sitecustomize.py, which is not optional: vLLM
0.24.0 does not propagate modelopt exclude_modules to the spec-decode DRAFT
model, so the BF16 MTP head gets quantized and the engine dies at load. The
shim force-skips mtp.* in is_layer_skipped. Both the mount and PYTHONPATH are
load-bearing.
Adds the two files house convention expects and the directory lacked: a
.env.example naming every knob (all values are the compose defaults; the live
host overrides only the three VRAM ones) and a README that points at
docs/runbooks/heretic2-nvfp4-mtp-seat.md rather than duplicating it.
No secrets: API_KEY is empty by default and the real .env stays on the host.
Replaces the previous theme attempt, which was built on a misread: the ask was
to use Arbo as an IMAGE-GEN ENGINE for the background, with the operator's
Australis Skyfall design system supplying the palette.
theme/ holds the source — colors/layout/typography vendored verbatim from the
Skyfall handoff bundle, Supreme 400/500/700 woff2, the Homepage bindings in
skyfall.css.in, and build.py which inlines fonts + tokens into
conf/custom.css. custom.css is GENERATED; edit the .in file and rebuild.
The build exists because Homepage serves only custom.css and custom.js out of
its config dir, so a @font-face pointing at a vendored woff2 would 404 — the
face has to arrive as a data: URI. The background image takes the other route:
/app/public/images is a real static route, so compose.yaml now mounts
images/ there read-only and settings.yaml points at /images/.
Bindings map Skyfall's semantic layer onto Homepage's DOM: Sea surfaces, the
depth recipe (hairline AND two-layer shadow, never one alone), uppercase
eyebrow group headers, the sanctioned accent-rail on the active tab rather
than a glow, and semantic status colour so a green pill means the service is
actually serving.
Background generated by Arbo (irv-ml1:8201) workflow t2i-ui-background, job
13f0891f4e42, seed 26, flux2-klein-9b, 2048x1152 — abstract, no subject,
cool-temperature aurora. 1.6 MB PNG -> 22 KB WebP.
Two deviations are documented rather than hidden: Skyfall forbids imagery
behind body text (held at opacity 30 as mitigation), and service icons stay
full-colour vendor logos.
NOT DEPLOYED — live still runs the old theme. Prototype on :5199.
Ports Arbo's design tokens (irv-ml1:8201) into conf/custom.css — flat raven
ink #021425, card surface #112333 on #1B2E3D borders, Manrope, and mint
#2FFC89 reserved for signal so a green pill means the service is actually
serving. Values read off Arbo's running :root custom properties rather than
sampled from a screenshot. CSS rather than settings.yaml because Homepage's
color: setting only accepts built-in Tailwind ramps.
NOT YET DEPLOYED — live still runs the stock theme pending an A/B decision.
Prototype is at 10.0.50.45:5199; shots in ~/booth-data/homepage-cleanup/.
Promoting it also means dropping the background: block from settings.yaml.
Also corrects the previous commit's tab-bar claim. It is not a fixed few
minutes of warm-up: a fresh container was still tab-less at 4m30s twice, and
recovered on its own about an hour later. Cause remains unpinned; the README
now records the measured timing and the four ruled-out causes.
searxng: the healthcheck passed '--tries' and '--spider' as separate argv
entries, so wget consumed '--spider' as the value of '--tries'. Spider mode
never engaged and every 30s probe downloaded the response to disk; the
container's working directory had accumulated 295,287 healthz.N files since
April, and the directory scan to pick the next free filename is what
intermittently blew the 10s timeout and flapped the dashboard card to
UNHEALTHY. Restored '--tries=1'. The junk was in the writable layer, so the
recreate cleared it. Now healthy, fails=0, 200 in 0.16s.
seafile: none of the three services declared a restart policy, so Docker
defaulted them to 'no'. The daemon stopped all three within 200ms on
2026-05-06 and nothing brought them back — a three-month outage whose only
trace was an EXITED card. Exit 255 is what a container ignoring SIGTERM
reports when the daemon stops it, not a crash. Added restart: unless-stopped.
Stack is back up; mysql gates on its healthcheck as designed and seahub
started without the race. 302 -> login page.
Both stacks were running unmanaged on ana-docker and are now tracked here.
homepage: AI tab reordered by clickability per operator — chat frontends,
ComfyUI and the control plane on top; vLLM /docs seats and TTS endpoints
below. Corrects the previous commit's UNRESOLVED tab-bar section: it was
warm-up time after a recreate, not a defect.
UltraSeedbox had no layout: entry, and Homepage renders an untabbed group on
every tab — eight full-width bookmark bars repeated four times. Pinned to Main
with a row layout.
Uptime Kuma rendered twice: a manual services.yaml block under Monitoring plus
homepage.group=Apps on the container. Dropped the manual block, moved the
label to Monitoring, added homepage.siteMonitor. Adopted the previously
unmanaged uptimekuma stack into stacks/ so the label is version-controlled.
Column counts declared more columns than groups had members, leaving the last
row of several groups mostly empty. Columns now track member counts.
Also records an UNRESOLVED regression: since the container was recreated the
client render has lost its tab bar, wallpaper and i18n. Ruled out the config
changes (committed pre-cleanup config reproduces it) and v2.0.0 (v1.13.2
reproduces it). Server HTML still carries the tab markup, so the loss is
client-side. Details in the stack README.
Operator ruled 2026-08-18 on the 5-character PARK_API_KEY flagged during
the v1.0.0-beta.2 deploy: leave it. The henge is LAN/WG-internal and
never internet-exposed. Written down so the next audit does not re-raise
a question that has already been answered.
Operator-directed request from park-dev. Rebuilt from tag v1.0.0-beta.2
(commit 2c258f7) and redeployed; park-data volume preserved (28 items,
9 comments verified present after the recreate).
Build source is now exported per-tag to
~/deploy-src/stonehenge-park-v1.0.0-beta.2 rather than overwriting the
single mirror, so the previous tag's tree stays on the host as a
rollback. The mirror was never a git checkout, so the source comes from
`git archive <tag>` against a box that has the repo -- which also leaves
park-dev's working tree untouched.
Verification, and two things worth writing down:
- HEAD 401s on EVERY route, including /healthz and /. So park-dev's
suggested check `curl -sI .../ui/assets/favicon.svg` reports a false
failure. GET is 200 with content-type image/svg+xml; the packaging is
fine and all nine assets are in the wheel. App-wide and pre-existing,
not a beta.2 regression -- /healthz predates this release.
- /park/due-count returning 0 is not a data-loss signal; it counts what
is due now, and /park/due is empty across overdue/today/stale. Items
survived: GET /park returns all 28. README now says to check that
instead of the due counters.
Also corrected the stack README, which still told the reader to build on
nh3-docker and verify against 10.100.50.40 -- the host decommissioned for
this stack on 2026-08-13 and the one park-dev explicitly asked us not to
deploy to.
infra-ops aliased tts-1, tts-1-hd and gpt-4o-mini-tts onto ext-tts's upstream
and extended the lobe-chat-esh key allow-list 20 -> 23 models, so the manual
"set the TTS model to ext-tts, per browser" step this file described a few
hours ago is obsolete. Lobe's stock three-field payload now returns 200
audio/mpeg — verified from the host with this stack's own .env.
Adds the coupling that the fix introduces: the three new names are independent
LiteLLM DB rows carrying their own copy of the upstream URL, so a future
repoint of ext-tts must move all four or stock clients land on a dead engine
without any error on the gateway side.
Comment/doc only — no functional change, no redeploy.
Two claims in this stack's docs were reasoned from the wrong hop, and one of
them hid a dead feature since deploy. Re-measured from esh-docker-vm against
the live .env:
1. "An unknown `model` routes to the gateway default" — true of :8198, false of
the path Lobe takes. LiteLLM resolves the model name first, so Lobe's default
`tts-1` returns 403 (`key not allowed to access model`) and never reaches the
gateway. `ext-tts` returns 200 + audio. The endpoint inheriting
OPENAI_PROXY_URL is necessary but not sufficient: Settings -> TTS -> OpenAI
TTS model -> `ext-tts` is a required one-time step per browser, and removing
it needs a LiteLLM alias plus a key allow-list entry (both master-key, so
infra-ops).
2. "`response_format: mp3` ... set it in the UI" — not possible. Lobe's OpenAI
TTS client sends `{input, model, voice}` and nothing else (server bundle
chunks/29685.js), so format is not selectable from this stack at any level.
The deploy gets the fleet gateway's default (WAV, ~23.5 MB for a 245 s turn),
relabelled `audio/mpeg` by LiteLLM. That is tts-dev's fence, not this one's.
Comment/doc only — no functional change, so the host copy needs no redeploy.
HOMEPAGE_ALLOWED_HOSTS now carries the IP:port form; direct access to
http://10.0.50.45:5100/ returns 200 and the host-validation errors are gone
from the container log.
The .env was mode 644 holding the Plex and Jellyfin API keys; now 600. It is
root-owned, so editing it needs the infra-ops identity -- lkraven has only
password-sudo on that host.
Also records that Homepage renders client-side, so grepping the served HTML
to verify a config change is the wrong instrument (it gave a stale prerender
and then an empty page). GET /api/services is the honest check, and config
changes need a recreate rather than a restart.
Homepage on esh-docker-vm:5100 was the one stack whose config lived only on
the host, edited in place. Its version history was six hand-rolled
services.yaml.bak-* files. Now canonical here and deployed with
deploy-stack.sh like everything else; the .bak files are gone.
Corrections from the audit:
- ANA-Firewall described a 'Fortigate 81F'. It is a FortiGate-80F running
FortiOS 7.2.10, verified live against the device.
- NH3-Ansible pointed at 10.100.50.42 as an 'Ansible control node'. That host
is nh3-extdev, the manager/external-dev successor after nh3-ansible was
retired. Renamed and re-described.
- Dropped the UltraSeedbox layout group: nothing provides it, so it only ever
rendered empty.
Adds .env.example and a README documenting the two-path service model (docker
label discovery across five engines vs manual entries), the labels-only-apply-
on-recreate rule, and the foot-guns found: HOMEPAGE_ALLOWED_HOSTS matches
host AND port so a bare IP does not cover IP:port; :2375 is plaintext and
unauthenticated on all five engines; ping: cards can only be judged from the
dashboard host.
Verified after deploy via /api/services: 105 cards across 19 groups, both
corrections live, ana-docker discovery intact.
Root cause of the multi-day degeneration hunt, operator-confirmed: the AEON
NVFP4 W4A4 quant (sakamakismile/Qwen3.8-27B-AEON-ULTIMATE-UNCENSORED-NVFP4,
full W4A4 incl. attention) went degenerate ~15-20% of generations in real
multi-turn use and forced regenerates. MTP, prefix-caching, and the gateway
all merely AMPLIFIED it, which is why MTP-off, APC-off, and the vLLM #51113
fix each 'helped' a synthetic probe without fixing it -- three plausible
false root-causes, each passing one clean run then failing in real use.
The fix was the WEIGHTS: the in-house JonathanColetti/Heretic mixed
NVFP4+FP8 build (qwen38-27b-uncensored-nvfp4-mixed, FP8 attention not W4A4,
same base, same MTP) is coherent through long multi-turn with MTP ON. W4A4
*attention* was the defect; FP8 attention is not.
This commit:
- GEN_MODEL -> the mixed FP8-attn build (primary gen until DavidAU 3.8 lands)
- GEN_IMAGE pinned to vllm/vllm-openai:nightly-311b3513... (v0.27.2rc1.dev150,
carries #51113; pinned by sha so it does not drift on the next pull)
- AEON weights PURGED from /tank (no-good), safety-checked not-in-use first
- playbook 3.8: the stochastic-W4A4-degeneration lesson + isolate-weights-early
+ do-not-declare-a-fix-from-one-probe (it validated three non-fixes)
AEON is re-pullable from HF if ever needed, but the operator ruled it no-good.
The MTP-on + prefix-caching-off mitigation (63a3cb2) passed synthetic
7-turn probes but the operator still saw severe degeneration in real use.
A passing synthetic probe is NOT sufficient evidence -- it under-covers
real workloads (content distribution, conversation depth). Reverted to the
verified known-good: MTP off, prefix caching on (the 7bd38b3 state), ~half
decode speed but coherent. Operator is driving it to re-confirm.
Lesson reinforced (the recurring one this session): do not trust a
synthetic reproduction to VALIDATE a fix for a bug that only manifests in
the operator's real usage -- it validated a non-fix twice tonight.
The qwen3_5_mtp corruption (playbook 3.7) is gated on MTP x prefix-caching
TOGETHER (vllm#43559 / #47194), per both cross-frontier peers. Disabling
prefix caching (--no-enable-prefix-caching; vLLM V1 defaults it ON, so the
explicit --no- form is required) forces the GDN cache into a mode where the
partial-accept align-path bug is inert, so MTP can stay on.
Verified on our stack (AEON W4A4): MTP on + prefix-caching off -> the 7-turn
varied series stays coherent through 3.9k tokens, zero cross-turn bleed, at
104.6 tok/s / 53.6% acceptance -- the FULL MTP speedup restored (vs ~half
with MTP off), losing only prefix-cache reuse. All 7 aliases route.
Ruled out on the way: num_speculative_tokens=1 (corruption is
depth-independent, n=1 and n=2 both corrupt); switching to SGLang (vLLM /
SGLang / llama.cpp mainline all share the architectural GDN-rollback bug).
Proper upstream fix (#51113) is in main / v0.27.2rc0 only, not stable, so we
hold at APC-off rather than jump the fleet gateway to an RC.
Supersedes the MTP-off config from 7bd38b3.
Root cause of the long-hunted 'gen goes degenerate in conversation',
isolated 2026-08-16 and operator-confirmed. qwen3_5_mtp speculative
decoding corrupts Qwen3.8-27B output once cumulative multi-turn context
passes ~2,000 tokens: the draft head's bad tokens get accepted and the
reply degenerates into CONTEXT-BLEEDING (a 'describe durian' answer that
contained the Krebs-cycle and winter replies from earlier turns), then
collapses to a few words.
Isolation, each step measured on the varied 7-turn probe:
- not the gateway (identical input -> gateway == direct; echo intact)
- not presence_penalty (1.5/0.5/0.0 all collapse), not temperature
(1.0 collapses harder), not repetition (varied unrelated topics
collapse identically -> it is context length, not template-lock)
- model-INDEPENDENT across all three Qwen3.8-27B quants we serve
(AEON W4A4, unsloth FP8-attn, in-house mixed)
- Qwen3.6 (char-rp-reasoning) and Gemma-4 (char-rp) are CLEAN
- DECISIVE: same Qwen3.8 model + same conversation, MTP OFF -> coherent
through 4k+ tokens, no bleed. MTP is the cause.
Qwen3.6 runs the same qwen3_5_mtp method and is clean, so the 3.6 MTP
head/graft is fine and the 3.8 one is not (suspects: the bf16 MTP graft,
or spec depth 3). COST: ~half decode tok/s without spec decoding.
Accepted as known-good until the 3.8 MTP is fixed; first thing to try on
re-enable is num_speculative_tokens=1. Seat restored to AEON W4A4 (the
production choice); verified clean on the varied series after this change.
Operator: update LiteLLM to latest and repull; get rid of the spend-log
DB and cap its growth.
Upgrade: pinned v1.97.0 (latest stable point release; v1.98.0-rc.1 skipped
as a pre-release on the fleet gateway, v1.97.0-stable not yet cut). Image
pre-pulled, DB pg_dump'd (1.8GB gz, keys+config+schema) and .env backed up
before the Prisma migration, which applied cleanly.
DB was 6.08 GB, 6.02 GB of it LiteLLM_SpendLogs storing full prompt+
completion bodies (store_prompts_in_spend_logs: true). Purged via TRUNCATE
on the running 1.91 BEFORE the upgrade so the schema migration ran against
an empty table -- 6081 MB -> 16 MB, keys (32) and models (3) intact.
'Get rid of the db' read as the spend-log DATA, not the database: dropping
it would have destroyed every virtual key (incl. the Lobe key) and the
model config in the same DB.
Cap: store_prompts_in_spend_logs -> false (bodies no longer persisted;
lightweight cost/usage rows and cross-project spend tracking survive) plus
maximum_spend_logs_retention_period 7d / interval 1d as a hard age bound.
Verified post-upgrade: v1.97.0 running, liveliness 200, 31-model roster,
chat round-trip on master + scoped Lobe key, key scoping still enforced
(glm-5.2 blocked), ext-tts 200, and store_prompts confirmed off (a marked
prompt persisted 0 bodies; 4 lightweight rows). Rollback: .env
LITELLM_TAG=v1.91.0 + the 1.8GB dump, both on the host.
The gpt-5-mini calls were Lobe's System Agent -- a background model,
separate from the chat model, used for auto-naming conversations, history
summarization, translation, query rewrite, thread naming, and assistant
metadata. Its default is openai/gpt-5-mini, which our OpenAI provider (the
gateway) forwards verbatim; the scoped key blocks it, so every background
task 403'd and the log filled with 'Tried to access gpt-5-mini' while
auto-naming silently failed.
Set SYSTEM_AGENT to route all six documented keys (topic, translation,
agentMeta, queryRewrite, historyCompress, thread) at fleet models --
summarizer for the naming/summarize tasks (same seat as gen at temp 0),
gen where quality matters. Any key left unset falls back to the gpt-5-mini
default, so all six are explicit.
Notably this one IS env-configurable (SYSTEM_AGENT), unlike the per-model
output-token cap which is UI-only -- a mixed result on the
manageable-by-agent axis.
Verified against the running image, not docs. TTS goes browser -> Lobe's
server route (backend)/webapi/tts/openai -> the OpenAI provider, whose
server base URL is OPENAI_PROXY_URL, so the endpoint inherits the gateway
and reaches ext-tts with no extra config (route probes 401, i.e. exists).
But there are ZERO process.env.*TTS*/*AUDIO*/*SPEECH* vars: voice, model,
response_format and enable live in a client-side store (bundle key
TTS_SETTING_KEY='tts'), UI-configured per browser.
So against the manageable-or-scriptable criterion: the load-bearing part
(endpoint) is env-scriptable and wired; the rest is a one-time UI setup,
not a maintenance surface. response_format=mp3 is the one thing not
env-forceable -- verify at first use or eat 23.5MB/turn WAV.
tts-dev aliased the last two unaliased OpenAI voice names (ballad->emmie,
verse->donut, tts-stack c55bc3c). Verified: the full modern OpenAI voice set
returns 200, only a genuinely-unknown string 404s. A stock Lobe picker can
no longer produce a 404 or trip the router cooldown, so the voice string no
longer needs pinning; still pin mp3 for response size.