diff --git a/dankserver/frank-medium/frank-medium-healthcheck.py b/dankserver/frank-medium/frank-medium-healthcheck.py
deleted file mode 100755
index e792150..0000000
--- a/dankserver/frank-medium/frank-medium-healthcheck.py
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env python3
-"""frank-medium container healthcheck: the port's /health must answer 200.
-
-Runs INSIDE the container. The vLLM /health endpoint only answers once the
-engine is up (weights loaded, cudagraphs captured), so a healthy container is
-a serving container, not merely a live process. PORT selects the endpoint so
-the probe follows the unit's Environment=PORT rather than hard-coding the
-fleet port. The HealthCmd in the quadlet splits on spaces into an argv array
-(podman 5.x v1 generator), which is why this lives in a file rather than an
-inline python one-liner.
-"""
-import os
-import sys
-import urllib.request
-
-port = os.environ.get("PORT", "7770")
-try:
- with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as r:
- sys.exit(0 if r.status == 200 else 1)
-except Exception:
- sys.exit(1)
diff --git a/dankserver/frank-medium/quadlet/frank-medium.container b/dankserver/frank-medium/quadlet/frank-medium.container
deleted file mode 100644
index e72b860..0000000
--- a/dankserver/frank-medium/quadlet/frank-medium.container
+++ /dev/null
@@ -1,186 +0,0 @@
-# Quadlet unit: frank-medium (rootless, boot-persistent).
-#
-# Install path on dankserver: ~/.config/containers/systemd/frank-medium.container
-# Unit name: frank-medium.service (container name: frank-medium)
-#
-# The dankserver LLM endpoint (:7770, alias frank-medium): the vllm-radiance
-# stack (the 0.13.0-e060895 image, PR #51) serving
-# hugypufy/Swift-Qwen3.8-27B-PARO-MXFP6 (ParoQuant MXFP6, W4A8, 25.16 GiB)
-# on the 2x Radeon AI PRO R9700 (gfx1201) as ONE tensor-parallel process
-# (TP=2, R4D one-shot all-reduce) with the z-lab DFlash2 external drafter
-# (SPEC=7) and UNQUANTIZED bf16 KV (KV_DTYPE=auto).
-# The entrypoint is QUANTIZATION-AWARE: it reads the model's weight
-# quantization from config.json and serves the plain FP8 path (no MXFP4
-# patches, --quantization=fp8, ROCM_AITER unified attention), the MXFP4
-# patch-and-serve path, or the ParoQuant MXFP6 stack (PR #51's launch6_p
-# kernel). A model swap is now: change SNAP (+ SPEC_METHOD/SPEC), restart.
-# The MXFP4 checkpoint (just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq, the
-# 2026-09-20 to 2026-09-21 serving shape) is the on-disk rollback target.
-#
-# SHAPE. The launcher the upstream repo ships binds the server with
-# --network=host (vLLM takes --host 0.0.0.0 --port 7770 directly), which is
-# what this unit does too — no PublishPort. The fleet contract is unchanged:
-# :7770, served model name frank-medium (SERVED_NAMES), OpenAI /v1.
-#
-# TP=2 IS INSTALLABLE HERE NOW, and it won on measurement. Until 2026-09-20
-# this unit was archived un-installed because the second card sat behind B650
-# chipset bridges advertising `AtomicOpsCap: Routing-`, which stopped RCCL
-# kernel dispatch. The board swap (ProArt B850-Creator WiFi NEO) put both
-# cards on CPU GPP root ports — Gen5 x8 each, every bridge Routing+, both
-# endpoints ReqEn+ — and the collective-free R4D path runs at TP=2 without
-# complaint. The conversation battery (scripts/bench-conversation.py, salted,
-# same 10 prompts, 2026-09-20) measured this shape against the incumbent:
-# decode geomean 95.8 t/s vs 45.7, mean TTFT 0.21 s vs 0.72 s, and 259 t/s
-# aggregate against 4 concurrent streams vs 71.7. Evidence and the llama-side
-# caveats: serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md and
-# Dual-R9700-Cooperation.md.
-#
-# DEVICES. /dev/kfd + /dev/dri through the amdgpu/KFD stack; HIP/ROCR device
-# indices 0,1 (the two R9700s). The 2 GiB Raphael iGPU enumerates as index 2
-# and is excluded both by the index pin and by the upstream MIN_GPU_MIB=8192
-# logic this unit replaces. --privileged --ipc=host --security-opt
-# seccomp=unconfined --cap-add SYS_PTRACE is the upstream launcher's exact
-# podman posture.
-#
-# MOUNTS. /models (rw checkpoints), the HF cache (tokenizer files),
-# /cache (Triton/inductor/aiter/vLLM compile cache — first start compiles for
-# several minutes, later starts reuse), /patches (the pinned radiance clone —
-# its patches are applied at EVERY container start by the entrypoint), /r4d
-# (the patched libr4d build; the image's shipped r4d.so NaNs the
-# gated-delta-net and must never be used), and the entrypoint + healthcheck
-# scripts and the chat template from this checkout (mounted read-only
-# straight out of /home/dank/serverAppConfigs — deploy.sh git-pulls the
-# checkout before dispatching).
-#
-# IMAGE. The unit runs the localhost/-aliased tag: this host's podman quadlet
-# path cannot resolve registry-prefixed refs from local storage at create
-# time (same as frank-large on dankclaw). deploy-remote.sh pulls the
-# docker.io ref and re-tags it before the restart. Pull=never keeps boots off
-# the registry.
-#
-# KEEP IN SYNC: the radiance clone is pinned (e060895...); if it is updated,
-# re-diff dankserver/frank-medium/radiance-entrypoint.sh against the new
-# serve-mxfp4.sh container command and bump the pin deliberately.
-#
-# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the
-# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
-[Unit]
-Description=frank-medium — vllm-radiance ParoQuant MXFP6 Swift Qwen3.8-27B on 2x R9700 (gfx1201)
-After=network-online.target
-
-[Container]
-ContainerName=frank-medium
-Image=localhost/radiance-vllm:0.13.0-e060895
-# vLLM binds 0.0.0.0:7770 on the host network directly; no PublishPort.
-AddDevice=/dev/kfd
-AddDevice=/dev/dri
-GroupAdd=keep-groups
-# The upstream launcher's exact podman posture (rootless; podman-only flags
-# like --replace are not needed under systemd management).
-PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host
-# The two R9700s. The Raphael iGPU (index 2, 2 GiB) stays out: shared system
-# memory is not a compute target.
-Environment=ROCR_VISIBLE_DEVICES=0,1
-Environment=HIP_VISIBLE_DEVICES=0,1
-# Drafting: the external z-lab DFlash2-FP8 drafter (SPEC_METHOD=dflash). The
-# plain-FP8 checkpoint (rollback) carries its own single-layer MTP head, but
-# DFlash2 beat MTP self-speculation on decode, so the drafter serves either.
-# Chat template: the Qwen-Sharp variant (peculiar-ragdoll v22.5.0) — the terse
-# system prompt (opt out per-request with chat_template_kwargs {"terse":
-# false}), message.reasoning support (vLLM's thinking field), and the
-# tightened tool-call rendering. The predecessor (qwen-sharp-v22.4.0.jinja,
-# mounted beside it) remains as the rollback target: flip CHAT_TEMPLATE.
-Environment=SNAP=/models/Qwen3.8-27B-PARO-MXFP6
-Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8
-Environment=CHAT_TEMPLATE=/templates/qwen-sharp-v22.5.0.jinja
-# Fleet identity: the id at /v1/models and in every response body. Every
-# client (Hermes, the harness, services.json) points at frank-medium.
-Environment=SERVED_NAMES=frank-medium
-Environment=PORT=7770
-# Fixed 2-card tensor parallel: the tested configuration of the upstream
-# repo; the R4D one-shot all-reduce kernel serves it natively.
-Environment=TP=2
-# Measured production batch shape (upstream README): MAXSEQS 8 peaks
-# aggregate throughput at 573 t/s. MAXLEN is the served window (see KV_DTYPE).
-Environment=MAXSEQS=8
-Environment=CHUNK=8192
-Environment=MAXLEN=262144
-# MAXLEN is the whole pool shared across the MAXSEQS slots: 8 concurrent
-# sessions split 262,144 tokens between them, so a full-window request
-# excludes the others until it finishes (vLLM preempts rather than refuses).
-# The llama deployment it replaced advertised 327,680 across 2 slots.
-#
-# VISION WORKS (verified 2026-09-21: a solid red 48x48 PNG is named "Red").
-# config.json sets language_model_only=true and there is no separate mmproj,
-# but the Qwen3_5ForConditionalGeneration architecture carries the vision tower
-# through the Gemma4 merge path and it is not MXFP4-quantized, so vLLM serves
-# image input on either checkpoint. harness models.yml keeps frank-medium
-# input: [text, image].
-# 0.97, down from 0.98: the startup free-memory check (free >= util x 31.86
-# GiB) sat ~15 MiB inside the threshold on the 2026-09-26 boot (free 31.21
-# GiB vs required 31.22 GiB) — the worker's own HIP context plus the
-# amdgpu per-boot baseline is ~690 MiB at check time and that drifts a few
-# tens of MiB between boots, so 0.98 crash-looped on every restart. 0.97
-# buys ~160 MiB of headroom at the cost of ~5k KV tokens on the
-# auto-sized pool.
-Environment=GPU_UTIL=0.97
-# Speculative decoding: DFlash2 external drafter at depth 7. A/B tested
-# against MTP self-speculation (depth 8) on this FP8 checkpoint: DFlash2
-# wins on decode (100.0 vs 85.9 t/s geomean, 287.1 vs 179.7 t/s burst-4)
-# despite a lower accept length (4.24 vs 5.66) — the small separate drafter
-# steps faster than the target's own MTP layer applied autoregressively.
-Environment=SPEC_METHOD=dflash
-Environment=SPEC=7
-# KV cache: 0 = profile the shape (the documented plain-FP8 path's default;
-# the upstream docker-compose leaves the pin commented out). The measured pin
-# for this shape is 15989735424 (14.89 GiB, upstream, 2x R9700 32GB); after
-# the first healthy start, recalibrate with calibrate-kv.sh to use the extra
-# VRAM on the 34.2 GiB cards.
-Environment=KV_MEM=0
-# KV cache dtype: auto = UNQUANTIZED bf16 KV. The experiment: fp8 KV stacked on
-# MXFP4's already-quantized W4A8 weights compounds attention precision loss, so
-# clean KV should recover output quality. MXFP4's freed weights (19.37 vs 30.9
-# GB) buy the pool back — the full 262,144 native window still fits unquantized
-# (~290k-token f16 pool > 262,144). Flip to fp8 for the measured 943k-token pool.
-Environment=KV_DTYPE=auto
-# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards;
-# keep parity with the launcher's rw mount).
-Volume=/home/dank/models:/models
-# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the
-# entrypoint.
-Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface
-# Compile cache. Keyed on model + stack version + every graph-changing knob;
-# never share it across configurations (upstream rule).
-Volume=/home/dank/.radiance-cache-mxfp6-013-pq-nqft-fp8s-gnq:/cache
-# The pinned radiance clone: patches applied at every container start.
-Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z
-# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The
-# image's shipped r4d.so predates the GDN overflow fix and NaNs this model.
-Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z
-# Entrypoint + healthcheck from this checkout, mounted read-only.
-Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro
-Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
-Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/qwen-sharp-v22.4.0.jinja:/templates/qwen-sharp-v22.4.0.jinja:ro
-Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/qwen-sharp-v22.5.0.jinja:/templates/qwen-sharp-v22.5.0.jinja:ro
-# The entrypoint must be the container command; the image's own entrypoint
-# (which the patched script execs at the end) must not run unpatched.
-PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh
-# 200 only once the engine serves; during first-start compilation the
-# container is legitimately unhealthy. HealthCmd is split on spaces into an
-# argv array by the podman 5.x v1 generator, hence the file-based probe.
-HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
-HealthInterval=60s
-HealthTimeout=30s
-HealthRetries=3
-HealthStartPeriod=1800s
-Pull=never
-
-[Service]
-# First start compiles Triton/inductor kernels (several minutes) before the
-# engine loads ~9.5 GiB of weights per card.
-TimeoutStartSec=1800
-Restart=on-failure
-RestartSec=10
-
-[Install]
-WantedBy=default.target
diff --git a/dankserver/frank-medium/qwen-sharp-v22.5.0.jinja b/dankserver/frank-medium/qwen-sharp-v22.5.0.jinja
deleted file mode 100644
index 74465e7..0000000
--- a/dankserver/frank-medium/qwen-sharp-v22.5.0.jinja
+++ /dev/null
@@ -1,488 +0,0 @@
-{%- set template_version = "qwen3.8-froggeric-v22.5.0" %}
-{%- set _tool_format = tool_call_format if tool_call_format is defined else 'xml' %}
-{%- set image_count = namespace(value=0) %}
-{%- set video_count = namespace(value=0) %}
-{%- set add_vision_id = add_vision_id if add_vision_id is defined else false %}
-{%- set enable_thinking = enable_thinking if enable_thinking is defined else true %}
-{%- set auto_disable_thinking_with_tools = auto_disable_thinking_with_tools if auto_disable_thinking_with_tools is defined else false %}
-{%- if preserve_reasoning is defined and preserve_reasoning is not none %}
- {%- set _preserve_thinking = preserve_reasoning %}
-{%- elif preserve_thinking is defined and preserve_thinking is not none %}
- {%- set _preserve_thinking = preserve_thinking %}
-{%- else %}
- {%- set _preserve_thinking = true %}
-{%- endif %}
-{%- set max_tool_arg_chars = max_tool_arg_chars if max_tool_arg_chars is defined else 0 %}
-{%- set max_tool_response_chars = max_tool_response_chars if max_tool_response_chars is defined else 0 %}
-{%- set _default_reasoning_effort = 'medium' %}
-{%- set _has_tools = (tools is defined and tools and tools is iterable and tools is not mapping) %}
-{%- set _effort_raw = (reasoning_effort | string | lower) if reasoning_effort is defined and reasoning_effort is not none else _default_reasoning_effort %}
-{%- set _initial_thinking = enable_thinking %}
-{%- if _effort_raw in ('none', 'off') %}
- {%- set _initial_thinking = false %}
- {%- set _initial_effort = 'medium' %}
-{%- elif _effort_raw in ('minimal', 'low') %}
- {%- set _initial_effort = 'low' %}
-{%- elif _effort_raw in ('high', 'xhigh', 'max', 'ultracode', 'extreme') %}
- {%- set _initial_effort = 'xhigh' %}
-{%- else %}
- {%- set _initial_effort = 'medium' %}
-{%- endif %}
-{%- set ns_state = namespace(thinking=_initial_thinking, effort=_initial_effort) %}
-{%- if auto_disable_thinking_with_tools and _has_tools %}
- {%- set ns_state.thinking = false %}
-{%- endif %}
-{%- for msg in messages %}
- {%- if msg.role == 'system' or msg.role == 'developer' or msg.role == 'user' %}
- {%- if msg.content is string %}
- {%- if '<|think_off|>' in msg.content %}
- {%- set ns_state.thinking = false %}
- {%- elif '<|think_on|>' in msg.content %}
- {%- set ns_state.thinking = true %}
- {%- elif '<|think_xhigh|>' in msg.content or '<|think_high|>' in msg.content or '<|think_ultracode|>' in msg.content or '<|think_extreme|>' in msg.content or '<|think_max|>' in msg.content %}
- {%- set ns_state.thinking = true %}
- {%- set ns_state.effort = 'xhigh' %}
- {%- elif '<|think_low|>' in msg.content or '<|think_minimal|>' in msg.content %}
- {%- set ns_state.thinking = true %}
- {%- set ns_state.effort = 'low' %}
- {%- elif '<|think_medium|>' in msg.content %}
- {%- set ns_state.thinking = true %}
- {%- set ns_state.effort = 'medium' %}
- {%- endif %}
- {%- elif msg.content is iterable and msg.content is not mapping %}
- {%- for item in msg.content %}
- {%- if item is string %}
- {%- set _item_text = item %}
- {%- elif item is mapping and 'text' in item and item.text is string %}
- {%- set _item_text = item.text %}
- {%- else %}
- {%- set _item_text = '' %}
- {%- endif %}
- {%- if _item_text %}
- {%- if '<|think_off|>' in _item_text %}
- {%- set ns_state.thinking = false %}
- {%- elif '<|think_on|>' in _item_text %}
- {%- set ns_state.thinking = true %}
- {%- elif '<|think_xhigh|>' in _item_text or '<|think_high|>' in _item_text or '<|think_ultracode|>' in _item_text or '<|think_extreme|>' in _item_text or '<|think_max|>' in _item_text %}
- {%- set ns_state.thinking = true %}
- {%- set ns_state.effort = 'xhigh' %}
- {%- elif '<|think_low|>' in _item_text or '<|think_minimal|>' in _item_text %}
- {%- set ns_state.thinking = true %}
- {%- set ns_state.effort = 'low' %}
- {%- elif '<|think_medium|>' in _item_text %}
- {%- set ns_state.thinking = true %}
- {%- set ns_state.effort = 'medium' %}
- {%- endif %}
- {%- endif %}
- {%- endfor %}
- {%- endif %}
- {%- endif %}
-{%- endfor %}
-{%- set reasoning_instructions = '' %}
-{%- if ns_state.thinking %}
- {%- if ns_state.effort == 'xhigh' %}
- {%- set reasoning_instructions = 'Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.' %}
- {%- elif ns_state.effort == 'low' %}
- {%- set reasoning_instructions = 'Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the conclusion without unnecessary elaboration.' %}
- {%- endif %}
-{%- endif %}
-{%- macro render_content(content, do_vision_count, is_system_content=false) %}
- {%- if content is string %}
- {{- content }}
- {%- elif content is iterable and content is not mapping %}
- {%- for item in content %}
- {%- if item is mapping %}
- {%- if item.type == 'image' or 'image' in item or 'image_url' in item %}
- {%- if is_system_content %}
- {{- raise_exception('System message cannot contain images.') }}
- {%- endif %}
- {%- if do_vision_count %}
- {%- set image_count.value = image_count.value + 1 %}
- {%- endif %}
- {%- if add_vision_id %}
- {{- 'Picture ' ~ image_count.value ~ ': ' }}
- {%- endif %}
- {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
- {%- elif item.type == 'video' or 'video' in item or 'video_url' in item %}
- {%- if is_system_content %}
- {{- raise_exception('System message cannot contain videos.') }}
- {%- endif %}
- {%- if do_vision_count %}
- {%- set video_count.value = video_count.value + 1 %}
- {%- endif %}
- {%- if add_vision_id %}
- {{- 'Video ' ~ video_count.value ~ ': ' }}
- {%- endif %}
- {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
- {%- elif 'text' in item %}
- {{- item.text }}
- {%- else %}
- {{- raise_exception('Unexpected item type in content.') }}
- {%- endif %}
- {%- else %}
- {{- item | string }}
- {%- endif %}
- {%- endfor %}
- {%- elif content is none or content is undefined %}
- {{- '' }}
- {%- else %}
- {{- raise_exception('Unexpected content type.') }}
- {%- endif %}
-{%- endmacro %}
-{%- if not messages %}
- {{- raise_exception('No messages provided.') }}
-{%- endif %}
-{%- set head = namespace(count=0, seen_non_system=false) %}
-{%- for message in messages %}
- {%- set _is_sys = (message.role == 'system' or message.role == 'developer') %}
- {%- if _is_sys and not head.seen_non_system %}
- {%- set head.count = head.count + 1 %}
- {%- else %}
- {%- set head.seen_non_system = true %}
- {%- endif %}
-{%- endfor %}
-{%- set sys_state = namespace(content='') %}
-{%- for message in messages[:head.count] %}
- {%- set _part = render_content(message.content, false, true) | trim %}
- {%- if '<|think_off|>' in _part %}{%- set _part = _part.split('<|think_off|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_on|>' in _part %}{%- set _part = _part.split('<|think_on|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_xhigh|>' in _part %}{%- set _part = _part.split('<|think_xhigh|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_high|>' in _part %}{%- set _part = _part.split('<|think_high|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_ultracode|>' in _part %}{%- set _part = _part.split('<|think_ultracode|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_extreme|>' in _part %}{%- set _part = _part.split('<|think_extreme|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_max|>' in _part %}{%- set _part = _part.split('<|think_max|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_medium|>' in _part %}{%- set _part = _part.split('<|think_medium|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_low|>' in _part %}{%- set _part = _part.split('<|think_low|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_minimal|>' in _part %}{%- set _part = _part.split('<|think_minimal|>') | join('') | trim %}{%- endif %}
- {%- if _part %}
- {%- if sys_state.content %}
- {%- set sys_state.content = sys_state.content ~ '\n\n' ~ _part %}
- {%- else %}
- {%- set sys_state.content = _part %}
- {%- endif %}
- {%- endif %}
-{%- endfor %}
-{%- set _sc = sys_state.content %}
-{%- set _msgs = messages[head.count:] %}
-{%- if ns_state.thinking %}
- {%- set _terse_lead = 'Answer directly, after thinking. Lead with the answer, then only what it needs to be correct and usable.' %}
-{%- else %}
- {%- set _terse_lead = 'Answer directly and concisely. Give the answer with only what it needs to be correct and usable.' %}
-{%- endif %}
-{%- set _terse_core %}
-Never: open with preamble or pleasantries; restate the question; add filler transitions; hedge with niceties; or repeat a point you've already made.
-Always: keep essential steps, caveats, uncertainties, and specifics — never drop correctness or a needed warning for brevity. Keep the final answer lean. Use the least structure that conveys it (plain prose when short; lists or code only when they earn their place). If genuinely uncertain, say so and explain why — never omit uncertainty for the sake of brevity.
-If a user request is genuinely ambiguous, ask a sharp question, don't guess.
-{%- endset %}
-{%- set _terse = _terse_lead ~ '\n' ~ (_terse_core | trim) %}
-{#- `terse` chat-template kwarg: default true, so behaviour is unchanged unless a caller opts out.
- Pass {"terse": false} via chat_template_kwargs to serve the model with only its own system
- prompt -- useful for A/B-ing the terseness effect, or when a downstream prompt conflicts. #}
-{%- set _terse_on = terse if terse is defined else true %}
-{%- if _terse_on %}
- {%- if not _sc %}
- {%- set _sc = _terse | trim %}
- {%- else %}
- {%- set _sc = (_sc | trim) ~ '\n\n' ~ (_terse | trim) %}
- {%- endif %}
-{%- endif %}
-{#- Some runtimes (LM Studio's MLX backend) have no native tool-call parser and prepend their own
- protocol into the system content. Emitting ours as well gives the model two contradictory tool
- protocols and it follows one at random. Detect that marker and stand down; the
- `suppress_tool_instructions` kwarg overrides either way where a runtime can pass template kwargs. #}
-{%- set _runtime_tool_protocol = (_sc is defined and _sc and '[TOOL_REQUEST]' in _sc) %}
-{%- set _suppress_tools = suppress_tool_instructions if suppress_tool_instructions is defined else _runtime_tool_protocol %}
-{%- if _has_tools and not _suppress_tools %}
- {{- '<|im_start|>system\n' }}
- {%- if reasoning_instructions %}
- {{- reasoning_instructions + '\n\n' }}
- {%- endif %}
- {{- '# Tools\n\nYou have access to the following functions:\n\n' }}
- {%- for tool in tools %}
- {{- '\n' }}
- {{- tool | tojson }}
- {%- endfor %}
- {{- '\n' }}
- {%- if _tool_format == 'json' %}
- {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}
- {%- if ns_state.thinking %}
- {{- '\nBrief explanation of tool call\n\n' }}
- {%- endif %}
- {{- '\n{"name": "example_function_name", "arguments": {"example_parameter_1": "value_1", "example_parameter_2": "This is the value for the second parameter"}}\n\n\n\nReminder:\n' }}
- {%- if ns_state.thinking %}
- {{- '- You can use the block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the block.\n' }}
- {%- endif %}
- {{- '- Function calls MUST follow the specified format: a single JSON object with "name" and "arguments" keys inside XML tags.\n' }}
- {%- if ns_state.thinking %}
- {{- '- If you choose to call a tool, you MUST output the block IMMEDIATELY after thinking, with NO conversational text before it.\n' }}
- {%- else %}
- {{- '- If you choose to call a tool, you MUST output the block IMMEDIATELY, with NO conversational text before it.\n' }}
- {%- endif %}
- {{- '- The tag MUST be at the very beginning of a new line, with NO spaces or indentation before it.\n- To call multiple functions, output a separate, completely closed block for EACH function. Do NOT nest blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n' }}
- {%- else %}
- {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}
- {%- if ns_state.thinking %}
- {{- '\nBrief explanation of tool call\n\n' }}
- {%- endif %}
- {{- '\n\n\nvalue_1\n\n\nThis is the value for the second parameter\nthat can span\nmultiple lines\n\n\n\n\n\nReminder:\n' }}
- {%- if ns_state.thinking %}
- {{- '- You can use the block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the block.\n' }}
- {%- endif %}
- {{- '- Function calls MUST follow the specified format: an inner block must be nested within XML tags.\n' }}
- {%- if ns_state.thinking %}
- {{- '- If you choose to call a tool, you MUST output the block IMMEDIATELY after thinking, with NO conversational text before it.\n' }}
- {%- else %}
- {{- '- If you choose to call a tool, you MUST output the block IMMEDIATELY, with NO conversational text before it.\n' }}
- {%- endif %}
- {{- '- The and tags MUST be at the very beginning of a new line, with NO spaces or indentation before them.\n- To call multiple functions, output a separate, completely closed block for EACH function. Do NOT nest blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n' }}
- {%- endif %}
- {%- if _sc %}
- {{- '\n\n' + _sc }}
- {%- endif %}
- {{- '<|im_end|>\n' }}
-{%- else %}
- {%- if _sc %}
- {{- '<|im_start|>system\n' + (reasoning_instructions + '\n\n' if reasoning_instructions else '') + _sc + '<|im_end|>\n' }}
- {%- elif reasoning_instructions %}
- {{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
- {%- endif %}
-{%- endif %}
-{%- set _last_idx = _msgs | length - 1 %}
-{%- set ns = namespace(multi_step_tool=true, last_query_index=_last_idx) %}
-{%- for message in _msgs[::-1] %}
- {%- set index = (_msgs | length - 1) - loop.index0 %}
- {%- if ns.multi_step_tool and message.role == 'user' %}
- {%- set _rc = render_content(message.content, false) | trim %}
- {%- if not (_rc.startswith('') and _rc.endswith('')) %}
- {%- set ns.multi_step_tool = false %}
- {%- set ns.last_query_index = index %}
- {%- endif %}
- {%- endif %}
-{%- endfor %}
-{%- if ns.multi_step_tool %}
- {%- if _last_idx > 50 %}
- {%- set ns.last_query_index = _last_idx %}
- {%- else %}
- {%- set ns.last_query_index = 0 %}
- {%- endif %}
-{%- endif %}
-{%- set ns2 = namespace(prev_role='', consecutive_failures=0) %}
-{%- for message in _msgs %}
- {%- set is_system = (message.role == "system" or message.role == "developer") %}
- {%- set content = render_content(message.content, true, is_system) | trim %}
- {%- if is_system or message.role == 'user' %}
- {%- if '<|think_off|>' in content %}{%- set content = content.split('<|think_off|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_on|>' in content %}{%- set content = content.split('<|think_on|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_xhigh|>' in content %}{%- set content = content.split('<|think_xhigh|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_high|>' in content %}{%- set content = content.split('<|think_high|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_ultracode|>' in content %}{%- set content = content.split('<|think_ultracode|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_extreme|>' in content %}{%- set content = content.split('<|think_extreme|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_max|>' in content %}{%- set content = content.split('<|think_max|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_medium|>' in content %}{%- set content = content.split('<|think_medium|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_low|>' in content %}{%- set content = content.split('<|think_low|>') | join('') | trim %}{%- endif %}
- {%- if '<|think_minimal|>' in content %}{%- set content = content.split('<|think_minimal|>') | join('') | trim %}{%- endif %}
- {%- endif %}
- {%- if is_system %}
- {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
- {%- elif message.role == 'user' %}
- {%- set ns2.consecutive_failures = 0 %}
- {{- '<|im_start|>user\n' + content + '<|im_end|>\n' }}
- {%- elif message.role == 'assistant' %}
- {%- set reasoning_content = '' %}
- {%- set _explicit_reasoning = '' %}
- {%- if message.reasoning_content is defined and message.reasoning_content is not none %}
- {%- if message.reasoning_content is string %}
- {%- set _explicit_reasoning = message.reasoning_content %}
- {%- else %}
- {%- set _explicit_reasoning = message.reasoning_content | string %}
- {%- endif %}
- {%- elif message.thinking is defined and message.thinking is not none %}
- {%- if message.thinking is string %}
- {%- set _explicit_reasoning = message.thinking %}
- {%- else %}
- {%- set _explicit_reasoning = message.thinking | string %}
- {%- endif %}
- {%- elif message.reasoning is defined and message.reasoning is not none %}
- {%- if message.reasoning is string %}
- {%- set _explicit_reasoning = message.reasoning %}
- {%- else %}
- {%- set _explicit_reasoning = message.reasoning | string %}
- {%- endif %}
- {%- endif %}
- {%- if _explicit_reasoning %}
- {%- set _lead_end = '' %}
- {%- if content.startswith('') and '' in content %}
- {%- set _lead_end = '' %}
- {%- elif content.startswith('') and '' in content %}
- {%- set _lead_end = '' %}
- {%- elif content.startswith('') %}
- {%- set _lead_end = '' %}
- {%- elif content.startswith('') %}
- {%- set _lead_end = '' %}
- {%- endif %}
- {%- if _lead_end %}
- {%- set content = content.split(_lead_end)[-1].lstrip('\n') %}
- {%- endif %}
- {%- set reasoning_content = _explicit_reasoning %}
- {%- else %}
- {%- set _think_end = '' %}
- {%- if content.startswith('') %}
- {%- set _think_end = '' %}
- {%- elif content.startswith('') %}
- {%- set _think_end = '' %}
- {%- elif '\n' in content %}
- {%- set _think_end = '\n' %}
- {%- elif '\n' in content %}
- {%- set _think_end = '\n' %}
- {%- elif '\n think>' in content %}
- {%- set _think_end = '\n think>' %}
- {%- elif '\n' in content %}
- {%- set _think_end = '\n' %}
- {%- elif content.startswith('') and '' in content %}
- {%- set _think_end = '' %}
- {%- elif content.startswith('') and '' in content %}
- {%- set _think_end = '' %}
- {%- endif %}
- {%- if _think_end %}
- {%- if 'thinking' in _think_end %}
- {%- set _think_start = '' %}
- {%- else %}
- {%- set _think_start = '' %}
- {%- endif %}
- {%- set reasoning_content = content.split(_think_end)[0].rstrip('\n') %}
- {%- if _think_start in reasoning_content %}
- {%- set reasoning_content = reasoning_content.split(_think_start)[-1].lstrip('\n') %}
- {%- endif %}
- {%- set content = content.split(_think_end)[-1].lstrip('\n') %}
- {%- endif %}
- {%- endif %}
- {%- set reasoning_content = reasoning_content | trim %}
- {%- if (_preserve_thinking or loop.index0 > ns.last_query_index) %}
- {{- '<|im_start|>assistant\n\n' + reasoning_content + '\n\n\n' + content }}
- {%- else %}
- {{- '<|im_start|>assistant\n' + content }}
- {%- endif %}
- {%- if message.tool_calls is defined and message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
- {%- for tool_call in message.tool_calls %}
- {%- if tool_call.function is defined and tool_call.function is not none %}
- {%- set tc = tool_call.function %}
- {%- else %}
- {%- set tc = tool_call %}
- {%- endif %}
- {%- set tc_name = tc.name if (tc.name is defined and tc.name is not none) else '' %}
- {%- if _tool_format == 'json' %}
- {%- if loop.first %}
- {%- if content | trim %}
- {{- '\n\n' }}
- {%- endif %}
- {%- else %}
- {{- '\n' }}
- {%- endif %}
- {%- set _args = '{}' %}
- {%- if tc.arguments is defined and tc.arguments is not none %}
- {%- if tc.arguments is mapping %}
- {%- set _args = tc.arguments | tojson %}
- {%- elif tc.arguments is string %}
- {%- if tc.arguments %}
- {%- set _args = tc.arguments %}
- {%- endif %}
- {%- else %}
- {%- set _args = tc.arguments | tojson %}
- {%- endif %}
- {%- endif %}
- {{- '\n{"name": ' }}{{- tc_name | tojson }}{{- ', "arguments": ' }}{{- _args }}{{- '}\n' }}
- {%- else %}
- {%- if loop.first %}
- {%- if content | trim %}
- {{- '\n\n\n\n' }}
- {%- else %}
- {{- '\n\n' }}
- {%- endif %}
- {%- else %}
- {{- '\n\n\n' }}
- {%- endif %}
- {%- if tc.arguments is defined and tc.arguments is not none %}
- {%- if tc.arguments is mapping %}
- {%- for args_name, args_value in tc.arguments.items() %}
- {{- '\n' }}
- {%- if args_value is string %}
- {%- set _av = args_value %}
- {%- else %}
- {%- set _av = args_value | tojson %}
- {%- endif %}
- {%- if max_tool_arg_chars > 0 and _av | length > max_tool_arg_chars %}
- {{- _av[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_av | length | string) ~ ' chars]' }}
- {%- else %}
- {{- _av }}
- {%- endif %}
- {{- '\n\n' }}
- {%- endfor %}
- {%- else %}
- {%- if tc.arguments is string %}
- {%- set _raw_args = tc.arguments %}
- {%- else %}
- {%- set _raw_args = tc.arguments | tojson %}
- {%- endif %}
- {%- if _raw_args %}
- {%- if max_tool_arg_chars > 0 and _raw_args | length > max_tool_arg_chars %}
- {{- _raw_args[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_raw_args | length | string) ~ ' chars]' }}
- {%- else %}
- {{- _raw_args }}
- {%- endif %}
- {%- endif %}
- {%- endif %}
- {%- endif %}
- {{- '\n' }}
- {%- endif %}
- {%- endfor %}
- {%- endif %}
- {{- '<|im_end|>\n' }}
- {%- elif message.role == 'tool' %}
- {%- set _content_lower = content | lower %}
- {%- set _content_head = _content_lower[:120] %}
- {%- set _is_code_or_grep = ('throw new ' in _content_lower or 'throw error' in _content_lower or 'console.error' in _content_lower or 'logger.error' in _content_lower or 'logging.error' in _content_lower or 'import ' in _content_head or 'def ' in _content_head or 'function ' in _content_head) %}
- {%- set _exit_code_zero = ('exit code: 0' in _content_head or 'process exited with code 0' in _content_head) %}
- {%- set _error_field_ok = ('"error": null' in _content_head or '"error":null' in _content_head or '"error": false' in _content_head or '"error":false' in _content_head or '"error": ""' in _content_head or '"error":""' in _content_head) %}
- {%- set _strong_error = (('"error":' in _content_head and not _error_field_ok) or '"status": "error"' in _content_head or '"status":"error"' in _content_head or 'traceback (most recent call last):' in _content_head or 'command not found' in _content_head or 'invalid syntax' in _content_head or 'fatal:' in _content_head or (('exit code: ' in _content_head or 'process exited with code' in _content_head) and not _exit_code_zero) or _content_head.startswith('exception:') or _content_head.startswith('failed to ')) %}
- {%- set _weak_error = ('error:' in _content_head or 'err!' in _content_head) %}
- {%- set _weak_suppressed = ('$ ' in _content_head or 'took ' in _content_head or content | length >= 600) %}
- {%- if not _is_code_or_grep and (_strong_error or (_weak_error and not _weak_suppressed)) %}
- {%- set ns2.consecutive_failures = ns2.consecutive_failures + 1 %}
- {%- else %}
- {%- set ns2.consecutive_failures = 0 %}
- {%- endif %}
- {%- if ns2.prev_role != 'tool' %}
- {{- '<|im_start|>user' }}
- {%- endif %}
- {%- set _is_json_payload = (_tool_format == 'json' and content | trim | length > 0 and (content | trim)[:1] in ('{', '[')) %}
- {%- if not _is_json_payload and max_tool_response_chars > 0 and content | length > max_tool_response_chars %}
- {%- set content = content[:max_tool_response_chars] + '\n[TRUNCATED - original length ' ~ (content | length | string) ~ ' chars]' %}
- {%- endif %}
- {{- '\n\n' + content }}
- {%- if ns2.consecutive_failures >= 2 %}
- {{- '\n\n⚠️ SYSTEM WARNING: ' ~ ns2.consecutive_failures ~ ' consecutive tool errors detected. Your previous approach is incorrect. You MUST use a fundamentally different approach or corrected arguments.' }}
- {%- elif ns2.consecutive_failures == 1 %}
- {{- '\n\n⚠️ SYSTEM WARNING: The previous tool call returned an error. Diagnose the failure and retry with completely corrected arguments.' }}
- {%- endif %}
- {{- '\n' }}
- {%- if loop.last %}
- {{- '<|im_end|>\n' }}
- {%- else %}
- {%- set _next_role = _msgs[loop.index0 + 1].role %}
- {%- if _next_role != 'tool' %}
- {{- '<|im_end|>\n' }}
- {%- endif %}
- {%- endif %}
- {%- else %}
- {{- '<|im_start|>user\n[' + message.role + ']: ' + content + '<|im_end|>\n' }}
- {%- endif %}
- {%- set ns2.prev_role = message.role %}
-{%- endfor %}
-{%- if add_generation_prompt %}
- {{- '<|im_start|>assistant\n' }}
- {%- if not ns_state.thinking %}
- {{- '\n\n\n\n' }}
- {%- else %}
- {{- '\n' }}
- {%- endif %}
-{%- endif %}
\ No newline at end of file
diff --git a/dankserver/frank-medium/radiance-entrypoint.sh b/dankserver/frank-medium/radiance-entrypoint.sh
deleted file mode 100755
index d0c44c1..0000000
--- a/dankserver/frank-medium/radiance-entrypoint.sh
+++ /dev/null
@@ -1,362 +0,0 @@
-#!/usr/bin/env bash
-# frank-medium container entrypoint: vllm-radiance on 2x Radeon AI PRO R9700
-# (gfx1201). Quantization-aware (config.json quant_method): fp8 -> the plain
-# FP8 path; mxfp4/quark -> the MXFP4 patch-and-serve path; paroquant_mxfp6 ->
-# the ParoQuant W4A8 stack + the MXFP6 kernels (PR #51's launch6_p path).
-#
-# This is a fork of the patch-and-serve block inside radiance-vllm-mxfp4's
-# serve-mxfp4.sh (clone at /home/dank/GitPackages/radiance-vllm-mxfp4, pinned
-# commit 9735329348ae1c9319f01cbe5f7004ec5f4dba63). The published image alone
-# is NOT sufficient for this model: the shipped libr4d predates the
-# gated-delta-net overflow fix and NaNs the output, and the MXFP4 gates need
-# relaxing, so the repo's patches are applied at every container start from
-# the /patches mount. It runs INSIDE the container (executed as the image
-# command by the frank-medium quadlet); the launcher's host-side GPU/TP/KV
-# detection is not needed here because this fleet is fixed 2x gfx1201 and the
-# KV pin is pinned explicitly in the quadlet.
-#
-# KEEP IN SYNC: if the radiance clone is updated, re-diff this file against
-# the new serve-mxfp4.sh container command and against the DRY_RUN output.
-#
-# Env contract: every knob below is ${VAR:-default}; the quadlet sets the
-# fleet-specific ones (SNAP, PORT, SERVED_NAMES, TP, KV_MEM, ...).
-set -euo pipefail
-
-# ---------------------------------------------------------------- serving shape
-: "${SNAP:=/models/Qwen3.8-27B-Uncensored-MXFP4-awq}"
-: "${DRAFTER:=/models/Qwen3.8-27B-DFlash2-FP8}"
-: "${CHAT_TEMPLATE:=/patches/qwen-fixed-v22.3.jinja}"
-: "${PORT:=7770}"
-: "${SERVED_NAMES:=frank-medium}"
-: "${MAXLEN:=262144}"
-: "${MAXSEQS:=8}"
-# KV_MEM=0 is passed THROUGH as --kv-cache-memory 0, which the image's
-# patched KV sizing reads as "profile the shape" (the launcher's rule).
-# Blanking the variable instead omits the flag and makes vLLM size the cache
-# from --gpu-memory-utilization alone: measured on the single-GPU replicas
-# that overshoots and the allocation dies with torch.OutOfMemoryError right
-# after the weights load (2.16 GiB requested, 116 MiB free). Do not "fix"
-# this into an empty expansion.
-: "${KV_MEM:=18563072000}" # shipped kv-profiles.tsv row 2x7551-32624 @ 8/8192/262144/dflash
-: "${KV_DTYPE:=fp8}" # fp8 = quantized KV (2x the tokens); auto = bf16 KV (half the pool)
-: "${SPEC_METHOD:=dflash}"
-if [ "$SPEC_METHOD" = dflash ]; then : "${SPEC:=7}"; else : "${SPEC:=4}"; fi
-: "${MIN_M:=0}" # 0 = the W4A8 kernel serves every M (MIN_M=1 still routes M=1 to aiter)
-: "${R4D_ATTN:=1}"
-: "${ASYNC:=0}"
-: "${GPU_UTIL:=0.98}"
-
-# Quantization-aware: read the model's weight quantization from config.json.
-# fp8 -> the plain FP8 path (no MXFP4 patches, --quantization=fp8, MTP
-# self-speculation, ROCM_AITER unified attention with the R4D all-reduce).
-# mxfp4/quark (or unknown) -> the MXFP4 patch-and-serve path (the default).
-QUANT="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("quantization_config",{}).get("quant_method",""))' "$SNAP/config.json" 2>/dev/null || true)"
-IS_MXFP6=0
-case "$QUANT" in
- fp8) IS_MXFP4=0 ;;
- paroquant_mxfp6) IS_MXFP4=1; IS_MXFP6=1 ;;
- mxfp4|quark|"") IS_MXFP4=1 ;;
- *) IS_MXFP4=1; echo "[radiance] WARNING: quantization '$QUANT'; defaulting to the MXFP4 path" >&2 ;;
-esac
-# MXFP6 (ParoQuant) requires TP>=2 (rad_require_tp2 in the canonical launcher);
-# ${TP:-1} keeps the check set -u safe when the quadlet omits TP.
-if [ "$IS_MXFP6" = "1" ]; then
- [ "${TP:-1}" -ge 2 ] || { echo "frank-medium: paroquant_mxfp6 requires TP>=2 (got ${TP:-1})" >&2; exit 1; }
-fi
-
-if [ "$IS_MXFP4" = "0" ]; then ATTN=ROCM_AITER_UNIFIED_ATTN
-elif [ "$R4D_ATTN" = "1" ]; then ATTN=R4D
-else ATTN=ROCM_AITER_UNIFIED_ATTN; fi
-if [ "$ASYNC" = 1 ]; then ASYNC_FLAG="--async-scheduling"; UNPAD=false; else
- ASYNC_FLAG="--no-async-scheduling"; UNPAD=true; fi
-
-# Drafting config. dflash: the DFlash2-FP8 block-diffusion drafter (its
-# attention backend must support full cuda graphs -> TRITON_ATTN). The
-# disable_padded_drafter_batch unpad is the single-stream lever (~+50%).
-if [ "$SPEC_METHOD" = dflash ]; then
- if [ ! -f "$DRAFTER/config.json" ]; then
- echo "frank-medium: no dflash drafter at $DRAFTER" >&2
- exit 1
- fi
- DRAFT_SAMPLE=${DRAFT_SAMPLE:-greedy}
- SPEC_CFG="{\"method\":\"dflash\",\"model\":\"$DRAFTER\",\"num_speculative_tokens\":$SPEC,\"attention_backend\":\"TRITON_ATTN\",\"disable_padded_drafter_batch\":$UNPAD,\"draft_sample_method\":\"$DRAFT_SAMPLE\"}"
- RADIANCE_DRAFT_RERANK=${RADIANCE_DRAFT_RERANK:-80}
- RADIANCE_VERIFY_HEAD=${RADIANCE_VERIFY_HEAD:-1}
-else
- SPEC_CFG="{\"method\":\"mtp\",\"num_speculative_tokens\":$SPEC,\"attention_backend\":\"$ATTN\",\"disable_padded_drafter_batch\":$UNPAD}"
- RADIANCE_DRAFT_RERANK=${RADIANCE_DRAFT_RERANK:-32}
- RADIANCE_VERIFY_HEAD=${RADIANCE_VERIFY_HEAD:-0}
-fi
-
-# The AR size gate compares raw bf16 bytes: CHUNK x hidden(5120) x 2. Derived
-# so changing CHUNK cannot silently drop prefill back onto RCCL.
-export RADIANCE_AR_MAX_KB=$(( (CHUNK * 5120 * 2) / 1024 + 4096 ))
-
-# ---------------------------------------------------------------- runtime env
-export HF_HUB_OFFLINE=1
-export VLLM_LOGGING_LEVEL=${VLLM_LOGGING_LEVEL:-INFO}
-export VLLM_NO_USAGE_STATS=${VLLM_NO_USAGE_STATS:-1}
-export VLLM_ROCM_USE_AITER=1 VLLM_ROCM_USE_AITER_UNIFIED_ATTENTION=1
-export VLLM_ROCM_USE_AITER_MHA=0 VLLM_ROCM_USE_AITER_MLA=0 VLLM_ROCM_USE_AITER_MOE=0
-export VLLM_ROCM_USE_AITER_LINEAR=0 VLLM_ROCM_USE_AITER_FP8BMM=0
-export VLLM_ROCM_USE_AITER_FP4BMM=0 VLLM_ROCM_USE_AITER_RMSNORM=0
-export NCCL_PROTO=Simple
-export RADIANCE_USE_R4D=${RADIANCE_USE_R4D:-1} RADIANCE_USE_R4D_AR=${RADIANCE_USE_R4D_AR:-1}
-export RADIANCE_DFLASH_SELECTOR_TOPK="${RADIANCE_DFLASH_SELECTOR_TOPK:-}"
-export RADIANCE_R4D_REPORT=1
-export RADIANCE_PRESHUFFLE=${RADIANCE_PRESHUFFLE:-1}
-export RADIANCE_MXFP4_SANITIZE="${RADIANCE_MXFP4_SANITIZE:-0}"
-# ---------------------------------------------------------------- mxfp6 profile
-# The ParoQuant W4A8 stack (PR #51, the launch6_p kernel). Mirrors
-# paroquant/run_paroquant.sh's env. Runs BEFORE the MXFP4 base block below so
-# the base block's ${VAR:-default} guards preserve these values where they
-# conflict (GDN_MERGE_INPROJ=0, MXFP4_DECODE_MAX_M=64); the base block fills
-# the rest. Gated on IS_MXFP6 so the mxfp4/fp8 paths are untouched.
-if [ "$IS_MXFP6" = "1" ]; then
- export RADIANCE_PAROQUANT=1
- export RADIANCE_GDN_MERGE_INPROJ=${RADIANCE_GDN_MERGE_INPROJ:-0}
- export RADIANCE_GDN_FUSED_MAX_ITEMS=${RADIANCE_GDN_FUSED_MAX_ITEMS:-32}
- export RADIANCE_USE_R4D_AR_QUANT=${RADIANCE_USE_R4D_AR_QUANT:-1}
- export RADIANCE_FUSE_RMS_QUANT=${RADIANCE_FUSE_RMS_QUANT:-1}
- export RADIANCE_KV_GROUP_OPT=${RADIANCE_KV_GROUP_OPT:-1}
- export RADIANCE_MXFP4_DECODE_MAX_M=${RADIANCE_MXFP4_DECODE_MAX_M:-64}
- export RADIANCE_PQ_CHECKALL=${RADIANCE_PQ_CHECKALL:-}
- export RADIANCE_PQM_CHECKALL=${RADIANCE_PQM_CHECKALL:-}
- export RADIANCE_PQM_CHECK_MAX_M=${RADIANCE_PQM_CHECK_MAX_M:-128}
- export RADIANCE_PQ_CHECK_MAX_M=${RADIANCE_PQ_CHECK_MAX_M:-128}
- export RADIANCE_PQ_DECODE_MAX_M=${RADIANCE_PQ_DECODE_MAX_M:-64}
- export RADIANCE_PQ_WPERM=${RADIANCE_PQ_WPERM:-1}
- export RADIANCE_PQ_DECODE_NT=${RADIANCE_PQ_DECODE_NT:-1}
- export RADIANCE_PQ_ATILED=${RADIANCE_PQ_ATILED:-1}
- export RADIANCE_PQ_AT_LBK=${RADIANCE_PQ_AT_LBK:-128}
- export RADIANCE_PQ_AT_HOIST=${RADIANCE_PQ_AT_HOIST:-1}
- export RADIANCE_PQ_PTOK=${RADIANCE_PQ_PTOK:-1}
- export RADIANCE_PQ_FUSED_TOKQ=${RADIANCE_PQ_FUSED_TOKQ:-1}
- export RADIANCE_PQ_I8=${RADIANCE_PQ_I8:-0}
- export RADIANCE_PQ_PG=${RADIANCE_PQ_PG:-0}
- export RADIANCE_PQ_ZPE=${RADIANCE_PQ_ZPE:-0}
- export RADIANCE_PQ_PG_PRODUCER=${RADIANCE_PQ_PG_PRODUCER:-3}
- export RADIANCE_PQ_ROT_STREAM=${RADIANCE_PQ_ROT_STREAM:-1}
- export RADIANCE_PQ_ROT_STREAM2=${RADIANCE_PQ_ROT_STREAM2:-1}
- export RADIANCE_PQ_ROT_STREAM3=${RADIANCE_PQ_ROT_STREAM3:-0}
- export RADIANCE_PQ_AR_CHECK=${RADIANCE_PQ_AR_CHECK:-0}
- export RADIANCE_PQ_AR_FALLBACK=${RADIANCE_PQ_AR_FALLBACK:-0}
- export RADIANCE_PQ_ROT_V2=${RADIANCE_PQ_ROT_V2:-1}
- export RADIANCE_PQ_HIPCC_FLAGS=${RADIANCE_PQ_HIPCC_FLAGS:-}
- export RADIANCE_PQM_FUSED_TOKQ=${RADIANCE_PQM_FUSED_TOKQ:-1}
- export RADIANCE_PQM_SINGLE_LAUNCH=${RADIANCE_PQM_SINGLE_LAUNCH:-1}
- export RADIANCE_DFLASH_CAPTURE_DIR=${RADIANCE_DFLASH_CAPTURE_DIR:-}
-fi
-if [ "$IS_MXFP4" = "1" ]; then
- export RADIANCE_MXFP4=1 RADIANCE_MXFP4_W4A8=1 RADIANCE_MXFP4_W4A8_MIN_M="$MIN_M"
-else
- export RADIANCE_MXFP4=0 RADIANCE_MXFP4_W4A8=0 RADIANCE_USE_R4D_AR_QUANT=1
-fi
-export RADIANCE_FAST_DRAFT=${RADIANCE_FAST_DRAFT:-1}
-export RADIANCE_DRAFT_TAU=${RADIANCE_DRAFT_TAU:-0.20}
-export RADIANCE_DRAFT_RERANK
-export RADIANCE_VERIFY_HEAD
-export RADIANCE_VERIFY_HEAD_MAX_M=${RADIANCE_VERIFY_HEAD_MAX_M:-32}
-export RADIANCE_MXFP4_DEBUG=${RADIANCE_MXFP4_DEBUG:-0}
-export RADIANCE_MXFP4_PUREQUANT=${RADIANCE_MXFP4_PUREQUANT:-0}
-export RADIANCE_MXFP4_SYNC=${RADIANCE_MXFP4_SYNC:-0}
-export RADIANCE_MXFP4_CLONE=${RADIANCE_MXFP4_CLONE:-0}
-export RADIANCE_MXFP4_CHECKX=${RADIANCE_MXFP4_CHECKX:-0}
-export RADIANCE_MXFP4_PADOUT=${RADIANCE_MXFP4_PADOUT:-0}
-export RADIANCE_MXFP4_TN4_MIN_M=${RADIANCE_MXFP4_TN4_MIN_M:-2048}
-# The decode-kernel band must cover MAXSEQS x (SPEC+1) rows: 64 covers 8x8.
-if [ "${MAXSEQS}" -gt 8 ]; then
- export RADIANCE_MXFP4_DECODE_MAX_M=${RADIANCE_MXFP4_DECODE_MAX_M:-128}
-fi
-export RADIANCE_MXFP4_DECODE_NT=${RADIANCE_MXFP4_DECODE_NT:-1}
-export RADIANCE_MXFP4_A_TILED_MIN_M=${RADIANCE_MXFP4_A_TILED_MIN_M:-513}
-export RADIANCE_MXFP4_WPERM=${RADIANCE_MXFP4_WPERM:-1}
-export RADIANCE_TP_PAD=${RADIANCE_TP_PAD:-0} # TP=2 here; 3 is the only non-zero value upstream supports
-export RADIANCE_TP_PAD_DRAFTER=${RADIANCE_TP_PAD_DRAFTER:-1}
-export RADIANCE_TP_PAD_STRICT=${RADIANCE_TP_PAD_STRICT:-1}
-export RADIANCE_GDN_MERGE_INPROJ=${RADIANCE_GDN_MERGE_INPROJ:-1}
-export RADIANCE_GDN_NORM_QUANT=${RADIANCE_GDN_NORM_QUANT:-1}
-export RADIANCE_GDN_STRIDED_GATES=${RADIANCE_GDN_STRIDED_GATES:-0}
-export RADIANCE_GDN_EMPTY_OUT=${RADIANCE_GDN_EMPTY_OUT:-0}
-export R4D_ATTN_FP8=${R4D_ATTN_FP8:-3}
-export RADIANCE_DFLASH_CALIB="${RADIANCE_DFLASH_CALIB:-}"
-export RADIANCE_DFLASH_CALIB_TOKENS="${RADIANCE_DFLASH_CALIB_TOKENS:-200000}"
-export RADIANCE_MXFP4_SHADOW="${RADIANCE_MXFP4_SHADOW:-}"
-export RADIANCE_MXFP4_KERNEL_N="${RADIANCE_MXFP4_KERNEL_N:-}"
-export RADIANCE_MXFP4_KERNEL_NK="${RADIANCE_MXFP4_KERNEL_NK:-}"
-export RADIANCE_MXFP4_CHECKALL="${RADIANCE_MXFP4_CHECKALL:-}"
-export RADIANCE_MXFP4_DECODE_KS="${RADIANCE_MXFP4_DECODE_KS:-}"
-export RADIANCE_MXFP4_DECODE_BK="${RADIANCE_MXFP4_DECODE_BK:-}"
-export RADIANCE_MXFP4_PERBLOCK_NK="${RADIANCE_MXFP4_PERBLOCK_NK:-}"
-export RADIANCE_MXFP4_REFLINEAR="${RADIANCE_MXFP4_REFLINEAR:-0}"
-export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-}"
-export RADIANCE_MXFP4_EPIFAST="${RADIANCE_MXFP4_EPIFAST:-1}"
-export RADIANCE_AR_OVERLAP=${RADIANCE_AR_OVERLAP:-0}
-export RADIANCE_GDN_FUSED_UPDATE=${RADIANCE_GDN_FUSED_UPDATE:-1}
-export RADIANCE_DYNAMIC_WIDTH=${RADIANCE_DYNAMIC_WIDTH:-1}
-export RADIANCE_DYNW_ALPHA=${RADIANCE_DYNW_ALPHA:-0.35}
-export RADIANCE_DYNW_MARGIN=${RADIANCE_DYNW_MARGIN:-2}
-export RADIANCE_DYNW_MIN=${RADIANCE_DYNW_MIN:-2}
-export RADIANCE_DYNW_MIN_BATCH=${RADIANCE_DYNW_MIN_BATCH:-3}
-export RADIANCE_AR_QNB=${RADIANCE_AR_QNB:-96}
-export RADIANCE_AR_QNT=${RADIANCE_AR_QNT:-1024}
-export MXFP4_CUMODE=${MXFP4_CUMODE:-0}
-export RADIANCE_STEP_TRACE=${RADIANCE_STEP_TRACE:-0}
-export RADIANCE_AR_OVERLAP_MIN_M=${RADIANCE_AR_OVERLAP_MIN_M:-2048}
-export RADIANCE_AR_OVERLAP_SLICES=${RADIANCE_AR_OVERLAP_SLICES:-4}
-export RADIANCE_MXFP4_EPIFAST=${RADIANCE_MXFP4_EPIFAST:-1}
-export RADIANCE_MXFP4_R4D_DECODE_MAX_M=${RADIANCE_MXFP4_R4D_DECODE_MAX_M:-0}
-export RADIANCE_TOPK_TRITON_MIN_ROWS=${RADIANCE_TOPK_TRITON_MIN_ROWS:-1}
-export RADIANCE_SKINNY_GEMM=${RADIANCE_SKINNY_GEMM:-1}
-export RADIANCE_MXFP4_HOIST_QUANT=${RADIANCE_MXFP4_HOIST_QUANT:-1}
-export RADIANCE_MXFP4_TRACED_QUANT=${RADIANCE_MXFP4_TRACED_QUANT:-1}
-export RADIANCE_FP8_STREAM=${RADIANCE_FP8_STREAM:-1}
-export RADIANCE_RMS_QUANT_FUSION=${RADIANCE_RMS_QUANT_FUSION:-1}
-export RADIANCE_NVFP4_MXFP4=${RADIANCE_NVFP4_MXFP4:-0}
-export RADIANCE_NVFP4_EXP=${RADIANCE_NVFP4_EXP:-mse}
-export RADIANCE_NVFP4_FP8_LAYERS=${RADIANCE_NVFP4_FP8_LAYERS:-mxfp4}
-export RADIANCE_NVFP4_BF16_LAYERS=${RADIANCE_NVFP4_BF16_LAYERS:-in_proj_ba}
-export RADIANCE_NVFP4_LMHEAD=${RADIANCE_NVFP4_LMHEAD:-bf16}
-export RADIANCE_GDN_PATHS=${RADIANCE_GDN_PATHS:-both}
-export RADIANCE_GDN_NANTRACE=${RADIANCE_GDN_NANTRACE:-0}
-export RADIANCE_MXFP4_CHECK_MAX_M=${RADIANCE_MXFP4_CHECK_MAX_M:-128}
-export RADIANCE_MXFP4_MHIST=${RADIANCE_MXFP4_MHIST:-0}
-export VLLM_CACHE_ROOT=/cache/vllm
-export TORCHINDUCTOR_CACHE_DIR=/cache/inductor
-export TRITON_CACHE_DIR=/cache/triton
-export AITER_ROOT_DIR=/cache/aiter
-export TRITON_CACHE_AUTOTUNING=1
-# The patched libr4d built by setup-mxfp4.sh, bind-mounted at /r4d.
-R4D_SO=${R4D_SO:-/r4d}
-
-# The norm+quant fusion turns on vLLM's passes (one --compilation-config
-# instance; argparse keeps the last, so entries accumulate into one flag).
-CC_ITEMS=""
-if [ "${RADIANCE_NORMQUANT_FUSION:-1}" != "0" ]; then
- CC_ITEMS="\"pass_config\":{\"fuse_norm_quant\":true,\"fuse_act_quant\":true}"
-fi
-EXTRA=${EXTRA:-}
-if [ -n "$CC_ITEMS" ]; then
- EXTRA="$EXTRA --compilation-config {$CC_ITEMS}"
-fi
-
-if [ "$IS_MXFP4" = "1" ]; then
-# ---------------------------------------------------------------- patch block
-# Byte-for-byte the launcher's container-start block. The patches come from
-# the /patches mount (the pinned radiance clone). SP must match the image's
-# venv layout; the image tag is pinned in the quadlet. The mxfp6 branch
-# mirrors paroquant/run_paroquant.sh (17 patches, 10 modules, + the paroquant
-# kernel module + the sitecustomize registration); the mxfp4 branch keeps the
-# existing 20-patch / 11-module list untouched.
-SP=/opt/vllm/lib/python3.12/site-packages
-cd /patches
-if [ "$IS_MXFP6" = "1" ]; then
- python3 patch_quark_mxfp4.py
- python3 patch_ar_maxbytes.py
- python3 patch_topk_triton_rows.py
- python3 patch_dflash_calib.py
- python3 patch_dflash_mxfp4_kv.py
- python3 patch_rmsquant_fusion.py
- python3 patch_verify_head.py
- python3 patch_kv_group_size.py
- python3 patch_topk_composite.py
- python3 patch_gdn_shared_build.py
- python3 patch_async_dynwidth.py
- python3 patch_step_trace.py
- python3 patch_skinny_gemm.py
- python3 patch_dflash_selector_topk.py
- python3 patch_dynwidth.py
- python3 patch_ar_geometry.py
- python3 patch_gdn_merge_inproj.py
- # Non-fatal: fixes content=null on thinking-off requests; not required to serve.
- python3 patch_qwen3_thinkoff.py \
- || echo "[radiance] WARNING: thinkoff patch did not apply; thinking-off requests will return empty content"
- cp mxfp4-configs/*.json "$SP"/aiter/ops/triton/configs/gemm/
- cp radiance_preamble.py /opt/radiance_preamble.py
- cp radiance_mxfp4.py radiance_gemm.py radiance_gdn.py radiance_gdnmerge.py radiance_rmsquant.py \
- radiance_drafthead.py radiance_verifyhead.py radiance_aroverlap.py radiance_topk.py \
- radiance_arnq.py "$SP"/
- hipcc -O3 -w -std=c++17 -fPIC -shared --offload-arch=gfx1201 $(python3 -m pybind11 --includes) \
- radiance_mxfp4_fp8.hip -o "$SP"/radiance_mxfp4_fp8.so
- # ---- paroquant: build the MXFP6 kernel module and register the quant method ----
- # RADIANCE_PQ_HIPCC_FLAGS: extra compile flags for A/B builds of the kernel
- # module only (e.g. -DPQ_HW_CVT=0). Empty in prod.
- hipcc -O3 -w -std=c++17 -fPIC -shared --offload-arch=gfx1201 $(python3 -m pybind11 --includes) \
- ${RADIANCE_PQ_HIPCC_FLAGS:-} paroquant/radiance_paroquant.hip -o "$SP"/radiance_paroquant_kernel.so
- cp paroquant/radiance_paroquant.py paroquant/radiance_paroquant_mxfp4.py "$SP"/
- cp radiance_dflash_capture.py "$SP"/ 2>/dev/null || true
- # NB: appended to the STDLIB sitecustomize, not site-packages -- Ubuntu ships
- # /usr/lib/python3.12/sitecustomize.py and it shadows any site-packages one, so
- # a file dropped there is silently never imported. Guarded: an unguarded >>
- # appends another copy on every restart of an existing container.
- if ! grep -q radiance_paroquant /usr/lib/python3.12/sitecustomize.py; then
- printf "%s\n" \
- "try:" \
- " import radiance_paroquant # registers the paroquant quantization config" \
- " import radiance_paroquant_mxfp4 # and the MXFP4-weights variant (paroquant_mxfp4)" \
- " import radiance_dflash_capture # drafter training-data capture (inert unless RADIANCE_DFLASH_CAPTURE_DIR)" \
- "except Exception as e:" \
- " import sys" \
- " sys.stderr.write(\"[radiance.paroquant] registration failed: %r\\n\" % (e,))" \
- >> /usr/lib/python3.12/sitecustomize.py
- fi
-else
- python3 patch_quark_mxfp4.py
- python3 patch_nvfp4_mxfp4.py
- python3 patch_tp3_pad.py
- python3 patch_ar_maxbytes.py
- python3 patch_topk_triton_rows.py
- python3 patch_dflash_calib.py
- python3 patch_dflash_mxfp4_kv.py
- python3 patch_rmsquant_fusion.py
- python3 patch_verify_head.py
- python3 patch_kv_group_size.py
- python3 patch_topk_composite.py
- python3 patch_gdn_shared_build.py
- python3 patch_dflash_selector_topk.py
- python3 patch_gdn_merge_inproj.py
- python3 patch_dynwidth.py
- python3 patch_async_dynwidth.py
- python3 patch_step_trace.py
- python3 patch_ar_geometry.py
- python3 patch_ar_3rank.py
- python3 patch_gdn_glue.py
- # Non-fatal: fixes content=null on thinking-off requests; not required to serve.
- python3 patch_qwen3_thinkoff.py \
- || echo "[radiance] WARNING: thinkoff patch did not apply; thinking-off requests will return empty content"
- cp mxfp4-configs/*.json "$SP"/aiter/ops/triton/configs/gemm/
- cp radiance_preamble.py /opt/radiance_preamble.py
- cp radiance_nvfp4.py radiance_mxfp4.py radiance_gdn.py radiance_rmsquant.py radiance_drafthead.py \
- radiance_verifyhead.py radiance_gdnmerge.py radiance_aroverlap.py radiance_topk.py \
- radiance_arnq.py radiance_tp3pad.py "$SP"/
- cp radiance_dflash_capture.py "$SP"/ 2>/dev/null || true
- hipcc -O3 -w -std=c++17 -fPIC -shared --offload-arch=gfx1201 $([ "${MXFP4_CUMODE:-0}" = 1 ] && echo -mcumode) \
- $(python3 -m pybind11 --includes) radiance_mxfp4_fp8.hip -o "$SP"/radiance_mxfp4_fp8.so
-fi
-if [ -n "${R4D_SO:-}" ] && [ -f /r4d/r4d.so ]; then
- cp /r4d/r4d.so "$SP"/r4d.so
- echo "[radiance] using patched r4d.so from $R4D_SO"
-fi
-# Leave /patches before exec: a stale radiance_mxfp4_fp8.so there would shadow
-# the one just compiled into site-packages (cwd precedes it on sys.path).
-cd /
-fi
-# The weight-quantization flag. MXFP4 and paroquant_mxfp6 are auto-detected
-# from the checkpoint's quant_method; FP8 is passed explicitly (the documented
-# plain-FP8 path).
-QUANT_FLAG=""
-if [ "$IS_MXFP4" = "0" ]; then QUANT_FLAG="--quantization fp8"; fi
-exec /opt/radiance_entrypoint.sh "$SNAP" --served-model-name $SERVED_NAMES \
- --host 0.0.0.0 --port "$PORT" \
- --kv-cache-dtype "$KV_DTYPE" --tensor-parallel-size "$TP" \
- $QUANT_FLAG \
- --gpu-memory-utilization "$GPU_UTIL" \
- ${KV_MEM:+--kv-cache-memory "$KV_MEM"} \
- --max-model-len "$MAXLEN" --max-num-seqs "$MAXSEQS" --max-num-batched-tokens "$CHUNK" \
- --attention-backend "$ATTN" \
- --speculative-config "$SPEC_CFG" \
- $ASYNC_FLAG $EXTRA \
- --enable-prefix-caching --mamba-cache-mode align --enable-auto-tool-choice \
- --tool-call-parser qwen3_coder --reasoning-parser qwen3 \
- --override-generation-config '{"temperature":0.7,"top_p":0.95,"top_k":20}' \
- --chat-template "$CHAT_TEMPLATE"
diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py
index 9475e36..4c289f6 100755
--- a/scripts/deploy-plan.py
+++ b/scripts/deploy-plan.py
@@ -63,7 +63,6 @@ SERVICE_ORDER = (
"readarr",
"frank-large",
"frank-small",
- "frank-medium",
"frank-primary",
)
@@ -114,7 +113,6 @@ DANKSERVER_SERVICES = {
"sonarr",
"lidarr",
"readarr",
- "frank-medium",
"frank-primary",
"node-exporter",
}
@@ -373,16 +371,6 @@ def services_for_path(path: str, target: str) -> set[str]:
if name == "node-exporter":
_add(services, "node-exporter")
return services
- if len(parts) >= 2 and parts[1] == "frank-medium":
- # frank-medium is a CONTAINER again since 2026-09-20: the TP=2
- # radiance Quadlet, its entrypoint and its healthcheck all live
- # under this directory, and the container mounts the latter two
- # straight out of this checkout — so any file here changes deployed
- # state. (It was a root llama.cpp unit from 2026-09-16, whose trio
- # lived in scripts/; that shape is archived in
- # scripts/.AGENTTRASH/20260920-llama-layersplit/ and plans nothing.)
- _add(services, "frank-medium")
- return services
if len(parts) >= 2 and parts[1] == "frank-primary":
# frank-primary is the Lucebox DeepSeek V4 Quadlet (2026-09-26
# cutover, replacing frank-medium on :7770): its entrypoint and
@@ -426,6 +414,13 @@ def services_for_path(path: str, target: str) -> set[str]:
# runtime. The download script fetches the payload, which is
# host-side state, so it selects nothing.
_add(services, "frank-large")
+ if name == "qwen-sharp-v22.4.0.jinja":
+ # The Qwen-Sharp chat template frank-large loads via
+ # --chat-template-file. It lived under dankserver/frank-medium/ until
+ # the 2026-09-26 cutover retired that directory; editing it changes
+ # what the live frank-large endpoint renders, so it plans
+ # frank-large.
+ _add(services, "frank-large")
# Deployment/test/benchmark scripts and other repo tooling do not
# restart a runtime service.
return services
@@ -435,7 +430,6 @@ def services_for_path(path: str, target: str) -> set[str]:
return services
return services
-
def changed_paths(repo: str, base: str, head: str) -> list[str]:
result = subprocess.run(
["git", "-C", repo, "diff", "--name-only", "--diff-filter=ACDMRTUXB", base, head],
diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh
index 6e1fa0c..7e309ea 100755
--- a/scripts/deploy-remote.sh
+++ b/scripts/deploy-remote.sh
@@ -95,7 +95,7 @@ service_is_allowed() {
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
- dankserver:frank-medium|dankserver:frank-primary|dankserver:node-exporter|\
+ dankserver:frank-primary|dankserver:node-exporter|\
frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\
frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\
frankbook-pro:readarr)
@@ -1178,159 +1178,6 @@ deploy_llm_small() {
wait_for_health frank-small http://127.0.0.1:7771/health 900
}
-# deploy_llm_medium — frank-medium is the vllm-radiance stack as a
-# rootless Quadlet since the 2026-09-20 cutover: one tensor-parallel (TP=2)
-# engine across both R9700s, R4D one-shot all-reduce, DFlash2-FP8 drafter.
-# The entrypoint is quantization-aware (config.json quant_method): the plain
-# FP8 path, the MXFP4 patch-and-serve path, and the ParoQuant MXFP6 stack
-# (PR #51's launch6_p kernel). It replaced mainline llama.cpp's layer split,
-# which held the endpoint since 2026-09-16; the conversation battery measured
-# radiance at 2.1x decode and a third of the time-to-first-token with 4x the
-# concurrent throughput (scripts/bench-conversation.py, 2026-09-20).
-#
-# WHY THE PAIR IS BACK AND THE TRIO IS NOT: the 2026-09-16 cutover retired this
-# unit because TP=2 could not dispatch a collective on the second card (B650
-# chipset bridges, AtomicOpsCap: Routing-), and the two-replica + routing-proxy
-# trio was the workaround. The board swap cleared the atomics wall, so one
-# TP=2 process is both possible and the fastest shape; the replicas and their
-# affinity proxy stay retired.
-#
-# HOST ASSETS. Nothing here installs the image's payloads: the checkpoints, the
-# pinned radiance patch clone, the patched libr4d build and the Triton compile
-# cache live outside the checkout (see the unit file's mount comments). The
-# gates below refuse a deploy that would start a container guaranteed to die.
-LLM_MEDIUM_IMAGE="${LLM_MEDIUM_IMAGE:-ggz14/vllm-radiance-mxfp4:0.13.0-e060895}"
-LLM_MEDIUM_IMAGE_LOCAL="${LLM_MEDIUM_IMAGE_LOCAL:-localhost/radiance-vllm:0.13.0-e060895}"
-LLM_MEDIUM_SNAP="${LLM_MEDIUM_SNAP:-/home/dank/models/Qwen3.8-27B-PARO-MXFP6}"
-LLM_MEDIUM_DRAFTER="${LLM_MEDIUM_DRAFTER:-/home/dank/models/Qwen3.8-27B-DFlash2-FP8}"
-LLM_MEDIUM_PATCHES="${LLM_MEDIUM_PATCHES:-/home/dank/GitPackages/radiance-vllm-mxfp4}"
-LLM_MEDIUM_R4D="${LLM_MEDIUM_R4D:-/home/dank/.cache/radiance-libr4d/b9e42ab-rx6}"
-LLM_MEDIUM_CACHE="${LLM_MEDIUM_CACHE:-/home/dank/.radiance-cache-mxfp6-013-pq-nqft-fp8s-gnq}"
-
-# The three replica/proxy user units from the 2026-09-16 era are retired HERE,
-# never left to rot: they must not exist alongside the TP=2 unit (the proxy
-# published 7770 on the host network). Never `rm` — unit files move into the
-# home trash; the images stay in local storage. `frank-medium` itself is NOT in
-# the list: it is the unit this handler installs.
-retire_vllm_replica_quadlets() {
- local trash="$HOME/.AGENTTRASH/20260916-vllm-radiance-retire" unit
- systemctl --user stop frank-medium-a.service frank-medium-b.service \
- frank-medium-proxy.service 2>/dev/null || true
- for unit in frank-medium-a frank-medium-b frank-medium-proxy; do
- if [[ -e "$quadlet_unit_dir/$unit.container" ]]; then
- mkdir -p "$trash"
- mv -- "$quadlet_unit_dir/$unit.container" "$trash/$unit.container"
- echo "retired quadlet $unit.container -> $trash"
- fi
- done
- systemctl --user daemon-reload
-}
-
-# The llama.cpp root unit is retired HERE, into the home trash, never `rm`:
-# /etc/systemd/system/frank-medium.service is a relink_unit symlink into this
-# checkout, and the checkout pull that precedes this handler already moved the
-# repo file away, leaving the symlink dangling. Stop it while systemd still has
-# it loaded (an active unit stays loaded without its file), then take both the
-# symlink and its [Install] alias. Nothing can start it at boot afterwards.
-retire_llama_medium_unit() {
- local trash="$HOME/.AGENTTRASH/20260920-llama-layersplit-frank-medium"
- run_privileged systemctl stop frank-medium.service 2>/dev/null || true
- if [[ -L /etc/systemd/system/frank-medium.service ||
- -L /etc/systemd/system/multi-user.target.wants/frank-medium.service ]]; then
- mkdir -p "$trash"
- [[ -L /etc/systemd/system/frank-medium.service ]] &&
- run_privileged mv -- /etc/systemd/system/frank-medium.service \
- "$trash/etc-frank-medium.service.symlink"
- [[ -L /etc/systemd/system/multi-user.target.wants/frank-medium.service ]] &&
- run_privileged mv -- /etc/systemd/system/multi-user.target.wants/frank-medium.service \
- "$trash/etc-frank-medium.wants.symlink"
- echo "retired llama root unit frank-medium.service -> $trash"
- fi
- run_privileged systemctl daemon-reload
-}
-
-deploy_llm_medium() {
- # Refuse rather than restart-loop. A container whose image or payload is
- # missing never becomes healthy, and a green deploy would leave the endpoint
- # dark with nothing in the deploy log to say why.
- local missing=0
- # Model-aware gates: read the serving shape from the quadlet (the source
- # of truth), so a model swap is just "edit the quadlet + deploy". The
- # container /models mount maps to /home/dank/models on the host. The
- # drafter gate applies only to the dflash path; the patched libr4d gate
- # applies to the mxfp4/quark/paroquant_mxfp6 path (the plain FP8 path uses
- # the image's built-in r4d.so and skips the MXFP4 patches).
- local quadlet_file="dankserver/frank-medium/quadlet/frank-medium.container"
- local snap_c snap spec_method quant
- snap_c="$(sed -nE 's/^Environment=SNAP=//p' "$quadlet_file" | head -1)"
- spec_method="$(sed -nE 's/^Environment=SPEC_METHOD=//p' "$quadlet_file" | head -1)"
- snap="/home/dank/models${snap_c#/models}"
- quant="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("quantization_config",{}).get("quant_method",""))' "$snap/config.json" 2>/dev/null || true)"
- [[ -n "$snap_c" ]] || { echo "ERROR: cannot read SNAP from $quadlet_file" >&2; return 1; }
- if ! podman image exists "$LLM_MEDIUM_IMAGE_LOCAL"; then
- if podman image exists "$LLM_MEDIUM_IMAGE"; then
- podman tag "$LLM_MEDIUM_IMAGE" "$LLM_MEDIUM_IMAGE_LOCAL"
- else
- echo "ERROR: radiance image absent ($LLM_MEDIUM_IMAGE / $LLM_MEDIUM_IMAGE_LOCAL)" >&2
- echo "frank-medium: podman pull $LLM_MEDIUM_IMAGE && podman tag $LLM_MEDIUM_IMAGE $LLM_MEDIUM_IMAGE_LOCAL" >&2
- missing=1
- fi
- fi
- if [[ ! -f "$snap/config.json" ]]; then
- echo "ERROR: model checkpoint absent at $snap" >&2
- missing=1
- fi
- if [[ "$spec_method" == "dflash" && ! -f "$LLM_MEDIUM_DRAFTER/config.json" ]]; then
- echo "ERROR: DFlash2-FP8 drafter absent at $LLM_MEDIUM_DRAFTER" >&2
- missing=1
- fi
- # The patches clone is mounted and its scripts run at EVERY container start
- # on the MXFP4 path; the chat template is the fleet's reasoning-format
- # contract on both paths.
- if [[ ! -f "$LLM_MEDIUM_PATCHES/qwen-fixed-v22.3.jinja" ]]; then
- echo "ERROR: pinned patch clone / chat template absent at $LLM_MEDIUM_PATCHES" >&2
- echo "frank-medium: git clone https://github.com/ggz14/radiance-vllm-mxfp4 $LLM_MEDIUM_PATCHES" >&2
- missing=1
- fi
- # The image's shipped r4d.so predates the gated-delta-net overflow fix and
- # NaNs the MXFP4 model, so the patched build is not optional there. The
- # plain FP8 path uses the image's built-in r4d.so (no MXFP4 patches).
- case "$quant" in
- mxfp4|quark|paroquant_mxfp6)
- if [[ ! -f "$LLM_MEDIUM_R4D/r4d.so" ]]; then
- echo "ERROR: patched libr4d absent at $LLM_MEDIUM_R4D" >&2
- missing=1
- fi
- ;;
- esac
- [[ "$missing" -eq 0 ]] || return 1
- mkdir -p "$LLM_MEDIUM_CACHE"
-
- retire_vllm_replica_quadlets
- retire_llama_medium_unit
- # 7770 is a host-network bind: nothing new can be created until the old
- # holder released it (the same gate as the 2026-09-16 cutover, where the
- # retired proxy held the port and the new engine failed at create). Sweep a
- # stray manual llama-server too — that is how a bench harness leaves the
- # port busy. `pkill -x` matches the process name exactly and can never
- # match the deploying shell; never `pkill -f` here.
- pkill -x llama-server 2>/dev/null || true
- pkill -x VLLM::EngineCor 2>/dev/null || true
- sleep 2
- wait_port_free 7770 || return 1
-
- install_quadlet frank-medium dankserver/frank-medium/quadlet frank-medium
- # No enable: generated units cannot be enabled on this host (v1 quadlet
- # generator); boot persistence is the unit's [Install] WantedBy=default.target.
- systemctl --user restart frank-medium.service
- # Triton/inductor compile on a cold cache runs minutes before the engine
- # loads ~9.5 GiB per card; HealthStartPeriod in the unit is 1800s to match.
- wait_container_healthy frank-medium 1800 || {
- podman logs --tail 40 frank-medium 2>&1 || true
- return 1
- }
-}
-
# deploy_llm_primary — frank-primary is the Lucebox DeepSeek V4 Quadlet
# (the 2026-09-26 cutover that replaces frank-medium on :7770). The model
# is a 377 GiB GGUF and the image is a local build, so the gates are:
@@ -1356,13 +1203,12 @@ deploy_llm_primary() {
fi
[[ "$missing" -eq 0 ]] || return 1
- # Cutover: frank-primary takes :7770 from frank-medium. Stop and mask
- # the radiance unit (masking is mandatory: Restart=on-failure would
- # restart it and re-take the port). Retiring the radiance unit (moving
- # it to the home trash) happens after frank-primary is verified — this
- # handler only releases the port.
- systemctl --user stop frank-medium 2>/dev/null || true
- systemctl --user mask frank-medium
+ # Cutover COMPLETED 2026-09-26: frank-primary took :7770 and the radiance
+ # unit is retired. The host-side unit, its mask and the repo files were all
+ # moved to trash by the parent session, so there is nothing left to stop or
+ # mask here — masking a name that no longer exists would only resurrect it
+ # in the unit search path. The port gate stays, because 7770 is a
+ # host-network bind: nothing new can be created until the holder released it.
# 7770 is a host-network bind: nothing new can be created until the old
# holder released it (the same gate as the 2026-09-16 cutover).
sleep 2
@@ -1566,7 +1412,6 @@ deploy_service() {
marreta) deploy_marreta ;;
frank-large) deploy_llm_large ;;
frank-small) deploy_llm_small ;;
- frank-medium) deploy_llm_medium ;;
frank-primary) deploy_llm_primary ;;
hermes|hermes-dashboard|hermes-webui) deploy_hermes ;;
romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;;
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 72daada..efcc350 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -35,7 +35,7 @@ Options:
Examples:
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankclaw --service searxng
- ./scripts/deploy.sh dankserver --service frank-medium
+ ./scripts/deploy.sh dankserver --service frank-primary
./scripts/deploy.sh dankclaw --dry-run
EOF
}
@@ -146,7 +146,7 @@ valid_service() {
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|\
- dankserver:frank-medium|dankserver:frank-primary|\
+ dankserver:frank-primary|\
frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\
frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\
frankbook-pro:readarr)
diff --git a/scripts/download-frank-medium-gguf.sh b/scripts/download-frank-medium-gguf.sh
deleted file mode 100755
index d7840a4..0000000
--- a/scripts/download-frank-medium-gguf.sh
+++ /dev/null
@@ -1,109 +0,0 @@
-#!/usr/bin/env bash
-# Fetch the frank-medium payload: the target GGUF, the f16 vision projector and
-# the DFlash2 draft model.
-#
-# bash scripts/download-frank-medium-gguf.sh
-# bash scripts/download-frank-medium-gguf.sh --verify-only
-#
-# The target repo (orcarouter) is GATED: anonymous requests get a 401, so a
-# token is REQUIRED for it. The script reads ~/.cache/huggingface/token
-# (mode 600) or the HF_TOKEN environment variable, and passes it to curl
-# out-of-band via a mode-600 -K file (a -H header is world-readable in
-# /proc//cmdline -- the 2026-09-16 HF-token leak). The drafter repo
-# (z-lab) is public; the same token is used for it when present.
-#
-# WHY THESE FILES:
-# * Target: orcarouter/Qwen3.8-27B-Uncensored-GGUF at Q8_0 -- the
-# operator's requested quant (2026-09-19). Arch qwen35, 262144 trained
-# window, and the file carries the model's MTP layer (blk.64.nextn.*), so
-# llama.cpp can also draft from the model itself with no second copy in
-# VRAM.
-# * Draft: z-lab/Qwen3.8-27B-DFlash2-GGUF at Q4_K_M -- the block-diffusion
-# drafter (5 layers, hidden 5120, block_size 8, a 2048-token sliding
-# window). Q4_K_M is 1.06 GiB against Q8_0's 1.92 GiB and the drafter's
-# job is throughput, not output quality (the target verifies every drafted
-# token).
-#
-# The script is resumable (curl -C -) and checks every artifact's exact byte
-# count AND sha256, so a truncated or mis-fetched file fails here instead of
-# load-crashing the server.
-set -euo pipefail
-
-MODEL_REPO="orcarouter/Qwen3.8-27B-Uncensored-GGUF"
-MODEL_DIR="/home/dank/models/Qwen3.8-27B-Uncensored-GGUF"
-MODEL_FILE="Qwen3.8-27B-Uncensored-Q8_0.gguf"
-MODEL_SIZE=29047084416
-MODEL_SHA256=31756fca94beca71ea4b8706d6fdc896dab2a3c6376ab0c1863b98512a24f8d6
-# The f16 vision projector from the same repo; the serve script names it via
-# --mmproj (no autoload in this build), so it must land in MODEL_DIR.
-MM_PROJ_FILE="mmproj-Qwen3.8-27B-Uncensored-f16.gguf"
-MM_PROJ_SIZE=931145984
-MM_PROJ_SHA256=add205b7bfdb3f71f6da36b0a82aa20928dd829a920878c602628cdfbebc5288
-
-DRAFT_REPO="z-lab/Qwen3.8-27B-DFlash2-GGUF"
-DRAFT_DIR="/home/dank/models/Qwen3.8-27B-DFlash2-GGUF"
-DRAFT_FILE="Qwen3.8-27B-DFlash2-Q4_K_M.gguf"
-DRAFT_SIZE=1143006816
-DRAFT_SHA256=1a25c56858e1ebe93f2718ac1d49d1151f9323325c1bbfd6209370f4db131ebd
-
-mode="download"
-[[ "${1:-}" == "--verify-only" ]] && mode="verify"
-
-token="${HF_TOKEN:-}"
-if [[ -z "$token" && -r "$HOME/.cache/huggingface/token" ]]; then
- token="$(cat "$HOME/.cache/huggingface/token")"
-fi
-
-auth_file=""
-cleanup() { [[ -n "$auth_file" ]] && rm -f "$auth_file"; }
-trap cleanup EXIT
-
-dl() { # dl
- mkdir -p "$2"
- echo " fetching $3"
- local args=(-fL -C - -o "$2/$3" --retry 10 --retry-delay 5 --retry-max-time 21600)
- [[ -n "$auth_file" ]] && args+=(-K "$auth_file")
- curl "${args[@]}" "https://huggingface.co/$1/resolve/main/$3"
-}
-
-if [[ "$mode" == "download" ]]; then
- if [[ -z "$token" ]]; then
- echo "ERROR: the target repo is gated and no HF token is available" >&2
- echo " (set HF_TOKEN or write it to ~/.cache/huggingface/token, mode 600)" >&2
- exit 1
- fi
- auth_file="$(mktemp)"
- printf 'header = "Authorization: Bearer %s"\n' "$token" > "$auth_file"
- chmod 600 "$auth_file"
-
- echo "target: $MODEL_REPO (gated)"
- dl "$MODEL_REPO" "$MODEL_DIR" "$MODEL_FILE"
- echo "projector: $MODEL_REPO (gated)"
- dl "$MODEL_REPO" "$MODEL_DIR" "$MM_PROJ_FILE"
- echo "draft: $DRAFT_REPO"
- dl "$DRAFT_REPO" "$DRAFT_DIR" "$DRAFT_FILE"
-fi
-
-echo "verifying sizes and sha256"
-ok=1
-check() { # check
- local act act_sha
- act="$(stat -c%s "$1" 2>/dev/null || echo 0)"
- if [[ "$act" -ne "$2" ]]; then
- echo " BAD-SIZE $1 ($act bytes, want $2)" >&2
- ok=0
- return
- fi
- act_sha="$(sha256sum "$1" | cut -d' ' -f1)"
- if [[ "$act_sha" != "$3" ]]; then
- echo " BAD-SHA $1 ($act_sha)" >&2
- ok=0
- return
- fi
- echo " OK $1 ($act bytes)"
-}
-check "$MODEL_DIR/$MODEL_FILE" "$MODEL_SIZE" "$MODEL_SHA256"
-check "$MODEL_DIR/$MM_PROJ_FILE" "$MM_PROJ_SIZE" "$MM_PROJ_SHA256"
-check "$DRAFT_DIR/$DRAFT_FILE" "$DRAFT_SIZE" "$DRAFT_SHA256"
-[[ "$ok" -eq 1 ]] || exit 1
-echo "all artifacts present, sized and hashed correctly"
diff --git a/scripts/frank-medium-fp8.manifest b/scripts/frank-medium-fp8.manifest
deleted file mode 100644
index d08843d..0000000
--- a/scripts/frank-medium-fp8.manifest
+++ /dev/null
@@ -1,20 +0,0 @@
-# orcarouter/Qwen3.8-27B-Uncensored-FP8 — FP8 E4M3 block-wise (128x128), dynamic act-quant
-# 7 safetensors shards + config/tokenizer/processor. Sizes + LFS sha256 from the
-# HF tree API and X-Linked-ETag (2026-09-20).
-config.json 51350
-generation_config.json 202
-chat_template.jinja 8952
-merges.txt 3353259
-preprocessor_config.json 390
-tokenizer.json 12809320
-tokenizer_config.json 17928
-video_preprocessor_config.json 385
-vocab.json 6722759
-model.safetensors.index.json 156034
-model-00001-of-00007.safetensors 3969317448 141e200c1950c59bc42ae28744f50db8f6ea4c66ec3312b55f1ccad2b60af858
-model-00002-of-00007.safetensors 4939321704 e863e88f53ea5a737581638e41334e9f9871a93053c4251a2cebd4aa6aa1f7c0
-model-00003-of-00007.safetensors 4939863424 7cbf8db425114b9efcfae64364a60e15e223cc5cadb510b7a3a6d3b48c8617cf
-model-00004-of-00007.safetensors 4971328696 37338dc30b490663b641567a57565408fee5f1385e7860dff16dca7be07e1519
-model-00005-of-00007.safetensors 4955596064 fc6fb0dd3675e0cc7e83a454376d12b705a51000f6625cf13f85e384018fc5cb
-model-00006-of-00007.safetensors 4071440632 5fdcbaf80805a750970e076bcd37e6ebe4c3a3d93ce73c767d2854119947952a
-model-00007-of-00007.safetensors 3019999168 1bed352a0625d22dc606de47fd3563f24fd9fa73915c4ddffe7947c4b56c28bf
diff --git a/dankserver/frank-medium/qwen-sharp-v22.4.0.jinja b/scripts/qwen-sharp-v22.4.0.jinja
similarity index 100%
rename from dankserver/frank-medium/qwen-sharp-v22.4.0.jinja
rename to scripts/qwen-sharp-v22.4.0.jinja
diff --git a/scripts/serve-frank-large.sh b/scripts/serve-frank-large.sh
index bfbd20a..0c518fa 100755
--- a/scripts/serve-frank-large.sh
+++ b/scripts/serve-frank-large.sh
@@ -49,7 +49,7 @@ fi
: "${PARALLEL_SLOTS:=1}"
: "${SPEC_DRAFT_N_MAX:=4}"
: "${CACHE_RAM:=8192}"
-TEMPLATE_FILE="${TEMPLATE_FILE:-$SCRIPT_DIR/../dankserver/frank-medium/qwen-sharp-v22.4.0.jinja}"
+TEMPLATE_FILE="${TEMPLATE_FILE:-$SCRIPT_DIR/qwen-sharp-v22.4.0.jinja}"
SERVER_BIN="$RUNTIME_DIR/llama-server"
MODEL_FILE="$MODEL_DIR/Qwen3.8-27B-UD-Q8_K_XL.gguf"
diff --git a/scripts/swap-frank-medium.sh b/scripts/swap-frank-medium.sh
deleted file mode 100755
index 61eb040..0000000
--- a/scripts/swap-frank-medium.sh
+++ /dev/null
@@ -1,124 +0,0 @@
-#!/usr/bin/env bash
-# swap-frank-medium.sh — orchestrate a frank-medium model swap.
-#
-# A swap is four steps, and this script drives all of them:
-# 1. generate a manifest for the new HF repo (hf-manifest.py),
-# 2. download it to the host, resumable + byte/ sha256-verified (hf-fetch.sh),
-# 3. detect the checkpoint's weight quantization and point the quadlet at it
-# (SNAP + the matching SPEC_METHOD/SPEC + KV_MEM),
-# 4. hand off to deploy.sh (pull, restart, health gate).
-#
-# The entrypoint is QUANTIZATION-AWARE: it reads quant_method from the
-# checkpoint's config.json and serves the plain FP8 path (no MXFP4 patches,
-# --quantization=fp8, ROCM_AITER unified attention, MTP self-speculation) or
-# the MXFP4 patch-and-serve path (DFlash2 external drafter). So the ONLY repo
-# change a swap makes is the quadlet's four env vars — this script edits them.
-#
-# Usage:
-# scripts/swap-frank-medium.sh --repo --target \
-# [--generate-manifest] [--manifest ] [--spec N] [--kv MB] \
-# [--apply] [--deploy]
-#
-# --repo the Hugging Face repo to serve
-# --target the host dir the model lands in
-# (/home/dank/models/); the quadlet SNAP is
-# derived as /models/
-# --generate-manifest write scripts/frank-medium-.manifest from the
-# live repo tree (needs the host's HF token)
-# --manifest use an existing manifest instead of generating one
-# --spec N speculative tokens (default 8 for fp8, 7 for mxfp4)
-# --kv MB KV pool pin in MiB (default 0 = auto-size)
-# --apply edit the quadlet (SNAP + SPEC_METHOD + SPEC + KV_MEM)
-# --deploy run deploy.sh after --apply
-#
-# Without --apply the script stops after the download + quantization
-# detection and prints the quadlet lines to set. The download and the
-# quantization detection run on the host (the model dir and the token are
-# there); the quadlet edit runs in the repo.
-
-set -euo pipefail
-
-HOST="dank@192.168.0.235"
-QUADLET="dankserver/frank-medium/quadlet/frank-medium.container"
-MODELS_ROOT="/home/dank/models"
-
-repo="" target="" manifest="" spec="" kv=0 gen=0 apply=0 deploy=0
-
-while [ $# -gt 0 ]; do
- case "$1" in
- --repo) repo="$2"; shift 2 ;;
- --target) target="$2"; shift 2 ;;
- --manifest) manifest="$2"; shift 2 ;;
- --spec) spec="$2"; shift 2 ;;
- --kv) kv="$2"; shift 2 ;;
- --generate-manifest) gen=1; shift ;;
- --apply) apply=1; shift ;;
- --deploy) deploy=1; shift ;;
- *) echo "unknown arg: $1" >&2; exit 2 ;;
- esac
-done
-
-[[ -n "$repo" && -n "$target" ]] || {
- echo "usage: $0 --repo --target [--generate-manifest|--manifest ] [--spec N] [--kv MB] [--apply] [--deploy]" >&2
- exit 2;
-}
-
-name="$(basename "$target")"
-snap_c="/models/$name"
-
-# --- 1. manifest -------------------------------------------------------------
-if [[ "$gen" = 1 ]]; then
- manifest="scripts/frank-medium-${name}.manifest"
- echo "==> Generating manifest -> $manifest"
- scp -q scripts/hf-manifest.py "$HOST:/tmp/hf-manifest.py"
- ssh "$HOST" "python3 /tmp/hf-manifest.py '$repo' /tmp/swap-manifest"
- scp -q "$HOST:/tmp/swap-manifest" "$manifest"
-elif [[ -z "$manifest" ]]; then
- echo "ERROR: pass --generate-manifest or --manifest " >&2
- exit 2
-fi
-[[ -f "$manifest" ]] || { echo "ERROR: manifest not found: $manifest" >&2; exit 2; }
-
-# --- 2. download (resumable, verified) ---------------------------------------
-echo "==> Staging manifest + hf-fetch.sh on $HOST"
-scp -q "$manifest" "$HOST:/tmp/swap-manifest"
-scp -q scripts/hf-fetch.sh "$HOST:/tmp/swap-hf-fetch.sh"
-echo "==> Downloading $repo -> $target"
-ssh "$HOST" "bash /tmp/swap-hf-fetch.sh '$target' /tmp/swap-manifest '$repo'"
-
-# --- 3. detect the quantization on the host ----------------------------------
-quant="$(ssh "$HOST" "python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get(\"quantization_config\",{}).get(\"quant_method\",\"\"))' '$target/config.json' 2>/dev/null || true")"
-echo "==> Detected quantization: ${quant:-}"
-
-case "$quant" in
- fp8) spec_method=dflash; default_spec=7 ;;
- mxfp4|quark|"") spec_method=dflash; default_spec=7 ;;
- *) spec_method=dflash; default_spec=7
- echo "WARNING: unknown quant '$quant'; defaulting to the DFlash2 path" >&2 ;;
-esac
-[[ -n "$spec" ]] || spec="$default_spec"
-
-echo "==> Quadlet env vars for this checkpoint:"
-echo " Environment=SNAP=$snap_c"
-echo " Environment=SPEC_METHOD=$spec_method"
-echo " Environment=SPEC=$spec"
-echo " Environment=KV_MEM=$kv"
-
-# --- 4. apply the quadlet edit (in the repo) ---------------------------------
-if [[ "$apply" = 1 ]]; then
- echo "==> Editing $QUADLET"
- sed -i -E "s|^Environment=SNAP=.*|Environment=SNAP=$snap_c|" "$QUADLET"
- sed -i -E "s|^Environment=SPEC_METHOD=.*|Environment=SPEC_METHOD=$spec_method|" "$QUADLET"
- sed -i -E "s|^Environment=SPEC=.*|Environment=SPEC=$spec|" "$QUADLET"
- sed -i -E "s|^Environment=KV_MEM=.*|Environment=KV_MEM=$kv|" "$QUADLET"
- echo "==> Quadlet updated."
-fi
-
-# --- 5. deploy ----------------------------------------------------------------
-if [[ "$deploy" = 1 ]]; then
- [[ "$apply" = 1 ]] || { echo "ERROR: --deploy requires --apply" >&2; exit 2; }
- echo "==> Deploying (pull, restart, health gate)"
- ./scripts/deploy.sh dankserver --service frank-medium --skip-e2e
- echo "==> Next: run the capability battery"
- echo " python3 scripts/bench-conversation.py http://192.168.0.235:7770 --model frank-medium --salt ${name} --max-tokens 512 --burst 4"
-fi
diff --git a/scripts/test-changed.sh b/scripts/test-changed.sh
index 62b21f9..68752ac 100755
--- a/scripts/test-changed.sh
+++ b/scripts/test-changed.sh
@@ -180,17 +180,30 @@ for path in "${CHANGED[@]}"; do
scripts/mount-entropy.sh)
add_test tests/mount_entropy.bats
;;
- # frank-medium is a rootless quadlet again since the 2026-09-20
- # tensor-parallel cutover: the TP=2 radiance unit, its entrypoint and
- # its healthcheck all live under dankserver/frank-medium/, and the
- # container mounts the latter two straight out of the checkout. The
- # llama.cpp trio that held this endpoint before that date is archive
- # material in scripts/.AGENTTRASH/ and maps to nothing.
- dankserver/frank-medium/*)
- add_test tests/frank_medium_unit.bats
+ # Archive material: a path under any .AGENTTRASH/ directory is
+ # retired-by-policy (deploy-plan.py plans nothing from it), so it
+ # selects no focused test. It still needs its own case: without one
+ # the wrapper refuses retirement commits, whose trash moves show up
+ # as additions, with "no focused-test mapping".
+ .AGENTTRASH/*|*/.AGENTTRASH/*)
+ ;;
+ # frank-primary is the lucebox DS4.1 Quadlet (2026-09-26 cutover on
+ # :7770): its entrypoint and healthcheck live under
+ # dankserver/frank-primary/ and the container mounts them straight out
+ # of the checkout, so any file here changes deployed state. The
+ # radiance quadlet that held this endpoint before that date is archive
+ # material in dankserver/.AGENTTRASH/ and maps to nothing.
+ dankserver/frank-primary/*)
+ add_test tests/frank_primary_unit.bats
add_test tests/deployment_scoping.bats
add_test tests/docs_completeness.bats
;;
+ # frank-large's chat template lives in scripts/ (moved there on
+ # 2026-09-26 when its old home was retired): a template edit changes
+ # what the live endpoint renders, so it selects the large unit seam.
+ scripts/qwen-sharp-v22.4.0.jinja)
+ add_test tests/frank_large_unit.bats
+ ;;
# frank-small is llama.cpp on the 3090 as a ROOT unit: a native CUDA
# binary, so no generated quadlet is involved. Its env, unit and serve
# script all live in scripts/, which is why this case has to precede
diff --git a/shared/grafana/provisioning/dashboards/llm-fleet.json b/shared/grafana/provisioning/dashboards/llm-fleet.json
index ab097c4..d5319e3 100644
--- a/shared/grafana/provisioning/dashboards/llm-fleet.json
+++ b/shared/grafana/provisioning/dashboards/llm-fleet.json
@@ -1,15 +1,14 @@
{
"uid": "llm-fleet",
"title": "LLM fleet \u2014 capacity and reuse",
- "description": "The signals that explain the LLM endpoints under load. The fleet runs two runtimes and the panels read both: vllm:* for frank-medium (Dankserver, vllm-radiance TP=2 across both R9700s since 2026-09-20) and llamacpp:* for frank-small (Dankclaw, RTX 3090) and frank-large (Dankclaw, gfx1151 iGPU \u2014 the CIRU llama.cpp build does export metrics, verified 2026-09-20). The endpoint picker selects between them. See serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md for the numbers these panels are read against, and serverAppConfigs-docs/Infrastructure/Observability.md for the namespace split.",
+ "description": "The signals that explain the LLM endpoints under load. One metric namespace: llamacpp:* for frank-small (Dankclaw, RTX 3090) and frank-large (Dankclaw, gfx1151 iGPU). frank-primary (Dankserver, the Lucebox DS4.1 engine since the 2026-09-26 cutover) exports no metrics and has no panels here. The endpoint picker selects between them. See serverAppConfigs-docs/Infrastructure/Observability.md for the namespace split and the addressability rules.",
"tags": [
"llm",
- "frank-medium",
"capacity"
],
"timezone": "browser",
"schemaVersion": 39,
- "version": 1,
+ "version": 2,
"refresh": "30s",
"time": {
"from": "now-6h",
@@ -26,7 +25,7 @@
"uid": "prometheus"
},
"query": {
- "query": "label_values({__name__=~\"vllm:kv_cache_usage_perc|llamacpp:.*\"}, job)",
+ "query": "label_values({__name__=~\"llamacpp:.*\"}, job)",
"refId": "A"
},
"includeAll": true,
@@ -43,7 +42,7 @@
"id": 1,
"type": "timeseries",
"title": "Prefix cache reuse rate",
- "description": "rate(hits)/rate(queries) in tokens. On the llama.cpp side the prompt counter EXCLUDES cached tokens (its own HELP line), so that side divides by cached + prompt. High means pinned conversations are served from cache: measured 2026-09-20, an identical 13,021-token prompt went 2.30 s cold to 0.68 s warm with 9,888 tokens (76 %) reused. Reuse on frank-medium happens at 1,648-token block granularity (the attention block size is padded to the mamba page size), so shared prefixes shorter than two blocks re-prefill.",
+ "description": "rate(hits)/rate(queries) in tokens. The llama.cpp prompt counter EXCLUDES cached tokens (its own HELP line), so this divides by cached + prompt. High means pinned conversations are served from cache: measured 2026-09-20, an identical 13,021-token prompt went 2.30 s cold to 0.68 s warm with 9,888 tokens (76 %) reused.",
"gridPos": {
"h": 8,
"w": 12,
@@ -71,11 +70,6 @@
"expr": "rate(llamacpp:prompt_tokens_cached_total{job=~\"$job\"}[5m]) / clamp_min(rate(llamacpp:prompt_tokens_cached_total{job=~\"$job\"}[5m]) + rate(llamacpp:prompt_tokens_total{job=~\"$job\"}[5m]), 0.001)",
"legendFormat": "{{job}} reuse (llama.cpp)",
"refId": "A"
- },
- {
- "expr": "job:llm_vllm_prefix_cache_reuse:rate5m{job=~\"$job\"}",
- "legendFormat": "{{job}} reuse (vLLM)",
- "refId": "B"
}
]
},
@@ -83,7 +77,7 @@
"id": 2,
"type": "timeseries",
"title": "Speculative decoding (acceptance)",
- "description": "The one failure no client can see: the drafter silently stops and the endpoint keeps answering at autoregressive speed. llama.cpp: accepted tokens per draft step. vLLM: mean accepted length (1 + accepted/drafts) from the recording rule job:llm_spec_accept_length:rate5m \u2014 measured 3.0-4.3 at boot 2026-09-20 with the DFlash2 drafter.",
+ "description": "The one failure no client can see: the drafter silently stops and the endpoint keeps answering at autoregressive speed. Accepted tokens per draft step, and the mean accepted length (1 + accepted/drafts, the recording rule job:llm_spec_accept_length:rate5m) \u2014 1.0 means the draft round is pure overhead.",
"gridPos": {
"h": 8,
"w": 12,
@@ -137,50 +131,12 @@
"expr": "llamacpp:requests_processing{job=~\"$job\"}",
"legendFormat": "{{job}} processing",
"refId": "B"
- },
- {
- "expr": "vllm:num_requests_waiting{job=~\"$job\"}",
- "legendFormat": "{{job}} waiting (vLLM)",
- "refId": "C"
- },
- {
- "expr": "vllm:num_requests_running{job=~\"$job\"}",
- "legendFormat": "{{job}} running (vLLM)",
- "refId": "D"
}
]
},
{
"id": 4,
"type": "timeseries",
- "title": "KV pool fill (vLLM)",
- "description": "The gauge that was retired 2026-09-16 for want of a llama.cpp equivalent and came back when frank-medium moved to vLLM: vllm:kv_cache_usage_perc (the pool is auto-sized \u2014 943,581 tokens at boot 2026-09-20). Sustained high fill with a collapsing reuse panel is the thrash; preemptions are the visible symptom of it. The llama.cpp side has no fill gauge: its capacity wall is panel 3.",
- "gridPos": {
- "h": 8,
- "w": 12,
- "x": 12,
- "y": 8
- },
- "datasource": {
- "type": "prometheus",
- "uid": "prometheus"
- },
- "targets": [
- {
- "expr": "vllm:kv_cache_usage_perc{job=~\"$job\"}",
- "legendFormat": "{{job}} KV fill %",
- "refId": "A"
- },
- {
- "expr": "rate(vllm:num_preemptions_total{job=~\"$job\"}[5m])",
- "legendFormat": "{{job}} preemptions/s",
- "refId": "B"
- }
- ]
- },
- {
- "id": 5,
- "type": "timeseries",
"title": "Decode and prefill throughput",
"description": "llama.cpp: llamacpp:predicted_tokens_seconds is decode, llamacpp:prompt_tokens_seconds is prompt. vLLM: the token counters, rate'd. The series carry the job label of their scrape, so the endpoint picker selects between the runtimes.",
"gridPos": {
@@ -203,21 +159,11 @@
"expr": "llamacpp:prompt_tokens_seconds{job=~\"$job\"}",
"legendFormat": "{{job}} prefill tok/s (llama.cpp)",
"refId": "B"
- },
- {
- "expr": "rate(vllm:generation_tokens_total{job=~\"$job\"}[5m])",
- "legendFormat": "{{job}} decode tok/s (vLLM)",
- "refId": "C"
- },
- {
- "expr": "rate(vllm:prompt_tokens_total{job=~\"$job\"}[5m])",
- "legendFormat": "{{job}} prefill tok/s (vLLM)",
- "refId": "D"
}
]
},
{
- "id": 6,
+ "id": 5,
"type": "timeseries",
"title": "Decode steps and batch size",
"description": "llama.cpp-only signal \u2014 vLLM does not publish per-step counters. Steps per second and the busy-slot count per step (the effective batch).",
@@ -245,7 +191,7 @@
]
},
{
- "id": 7,
+ "id": 6,
"type": "timeseries",
"title": "Firing fleet alerts",
"description": "The LLM-fleet rules from shared/prometheus/rules/llm-fleet.yml. A firing alert is delivered to Signal: Prometheus -> Alertmanager (dankclaw quadlet) -> the host-side signal-relay user unit -> signal-cli, since 2026-09-17.",
@@ -266,49 +212,6 @@
"refId": "A"
}
]
- },
- {
- "id": 8,
- "type": "timeseries",
- "title": "Request latency p95 (vLLM)",
- "description": "vLLM-only \u2014 the histogram the p95 panel was retired for on 2026-09-16 (llama.cpp exports no latency histogram) is back with the radiance cutover. TTFT is what a fresh prefill costs; ITL is the per-token cadence an agent loop feels; e2e is the whole request.",
- "gridPos": {
- "h": 8,
- "w": 12,
- "x": 12,
- "y": 24
- },
- "datasource": {
- "type": "prometheus",
- "uid": "prometheus"
- },
- "fieldConfig": {
- "defaults": {
- "unit": "s",
- "custom": {
- "fillOpacity": 10,
- "lineWidth": 1
- }
- },
- "overrides": []
- },
- "targets": [
- {
- "expr": "histogram_quantile(0.95, sum by (le, job) (rate(vllm:time_to_first_token_seconds_bucket{job=~\"$job\"}[5m])))",
- "legendFormat": "{{job}} TTFT p95",
- "refId": "A"
- },
- {
- "expr": "histogram_quantile(0.95, sum by (le, job) (rate(vllm:inter_token_latency_seconds_bucket{job=~\"$job\"}[5m])))",
- "legendFormat": "{{job}} ITL p95",
- "refId": "B"
- },
- {
- "expr": "histogram_quantile(0.95, sum by (le, job) (rate(vllm:e2e_request_latency_seconds_bucket{job=~\"$job\"}[5m])))",
- "legendFormat": "{{job}} e2e p95",
- "refId": "C"
- }
- ]
}
]
}
diff --git a/shared/prometheus/prometheus.yml b/shared/prometheus/prometheus.yml
index a2a828f..94540ed 100644
--- a/shared/prometheus/prometheus.yml
+++ b/shared/prometheus/prometheus.yml
@@ -79,19 +79,11 @@ scrape_configs:
# this container, not the guarded vhosts: these are machine clients and
# Prometheus cannot solve an Anubis challenge.
#
- # Two metric namespaces, because the fleet runs two runtimes:
- #
- # * vllm:* — frank-medium (vllm-radiance TP=2 since 2026-09-20): prompt and
- # generation token counters, num_requests_waiting plus its reason label,
- # num_preemptions_total, the kv_cache_usage_perc fill gauge, the
- # prefix_cache hits/queries pair, the spec_decode drafts/accepted triple,
- # and real latency histograms (time_to_first_token, inter_token,
- # e2e_request_latency).
- # * llamacpp:* — frank-large and frank-small. Both take --metrics and both
- # were verified 2026-09-20 to export requests_deferred (the queue depth,
- # the only form llama.cpp shows the capacity wall in),
- # spec_decode_num_drafts_total, tokens_predicted_total, and the
- # prompt_tokens_cached_total / prompt_tokens_total reuse pair.
+ # One metric namespace: llamacpp:* — frank-large and frank-small, both
+ # llama.cpp with --metrics and both verified 2026-09-20 to export
+ # requests_deferred (the queue depth, the only form llama.cpp shows the
+ # capacity wall in), spec_decode_num_drafts_total, tokens_predicted_total,
+ # and the prompt_tokens_cached_total / prompt_tokens_total reuse pair.
#
# frank-large is scraped over host.containers.internal, not its own LAN address:
# this Prometheus is a bridge-networked container (Network=dankclaw_default),
@@ -102,14 +94,10 @@ scrape_configs:
# "dial tcp 192.168.0.143:7771") even though the endpoint was healthy and
# exporting: the target address was unreachable by construction, not the
# service down. Same host, same rule, so both local endpoints use the gateway.
- # frank-medium stays a LAN address — it is a different machine.
- - job_name: "frank-medium"
- metrics_path: "/metrics"
- static_configs:
- - targets: ["192.168.0.235:7770"]
- labels:
- service: frank-medium
-
+ # (2026-09-26) frank-medium is replaced by frank-primary (lucebox DS4.1),
+ # which exposes no metrics endpoint (/metrics -> 404), so there is no job
+ # for it here. The endpoint's liveness is covered by the homepage probe;
+ # the documented remedy is a blackbox exporter, which is not deployed.
- job_name: "frank-small"
metrics_path: "/metrics"
static_configs:
diff --git a/shared/prometheus/rules/llm-fleet.yml b/shared/prometheus/rules/llm-fleet.yml
index 7d1f62f..54a9932 100644
--- a/shared/prometheus/rules/llm-fleet.yml
+++ b/shared/prometheus/rules/llm-fleet.yml
@@ -1,32 +1,27 @@
# Alerting and recording rules for the LLM fleet.
#
-# Two metric namespaces, because the fleet runs two runtimes:
+# One metric namespace: llamacpp:* — frank-small and frank-large, both
+# llama.cpp with --metrics (the family verified from /metrics on 2026-09-16
+# and 2026-09-20). frank-primary (Dankserver, the Lucebox DS4.1 engine since
+# the 2026-09-26 cutover) answers 404 on /metrics, so nothing here selects
+# it; its liveness is covered by the homepage probe, and the documented
+# remedy is a blackbox exporter (not deployed).
#
-# * llamacpp:* — frank-small (Dankclaw, llama.cpp with --metrics).
-# * vllm:* — frank-medium (Dankserver, vllm-radiance TP=2 since 2026-09-20).
+# Every expression is keyed to a series that EXISTS. Nothing matches on
+# absence: when an endpoint's runtime leaves the fleet, its selectors are
+# deleted rather than left vacuous (the vllm: group went with frank-medium's
+# radiance runtime on 2026-09-26).
#
-# Every expression is keyed to a series that EXISTS, verified against
-# 192.168.0.235:7770/metrics and 192.168.0.143:7771/metrics on 2026-09-20.
-# Nothing here matches on absence: when frank-medium moved back to vLLM the
-# llamacpp: selectors narrowed to frank-small rather than going vacuous, and
-# the vllm: group below was added beside them, not in place of them.
+# What llamacpp:* can and cannot see:
#
-# What each namespace can and cannot see:
-#
-# * KV pressure. vLLM exports a fill gauge (vllm:kv_cache_usage_perc) and a
-# preemption counter; llama.cpp exports neither, so its capacity wall is
+# * KV pressure: llama.cpp exports no fill gauge, so the capacity wall is
# observed as queueing (llamacpp:requests_deferred).
-# * Prefix reuse. Both export a hits/queries pair counted in tokens, but the
-# units are NOT interchangeable: llama.cpp's prompt_tokens_total EXCLUDES
-# cached tokens (its own HELP line says so), so that side must divide by
-# cached + prompt to get a fraction, while vLLM divides hits by queries.
-# The two recording rules below keep that straight, and the collapse alert
-# watches both sides with one threshold.
-# * Speculative decoding. Both export drafts and accepted tokens, so
-# LlmSpecDecodeOff covers both runtimes.
-# * Latency histograms exist only on vLLM (e2e, TTFT, inter-token). No
-# p95 alert is defined for the llama.cpp side because there is nothing to
-# compute one from.
+# * Prefix reuse: counted in tokens, and prompt_tokens_total EXCLUDES
+# cached tokens (its own HELP line says so), so a reuse fraction divides
+# cached by cached + prompt.
+# * Speculative decoding: drafts and accepted tokens are both counters,
+# so LlmSpecDecodeOff watches the pair.
+# * No latency histograms exist, so there is no p95 alert.
#
# IMPORTANT: delivery. Since 2026-09-17 a firing alert reaches Frank on
# Signal: Prometheus pushes to Alertmanager (dankclaw/observability/quadlet/
@@ -37,32 +32,24 @@ groups:
- name: llm-fleet
interval: 30s
rules:
- # Recording rules so the dashboard and the collapse alert below share one
- # expression. Both namespaces export hits/queries pairs in TOKENS; the
- # rate of the quotient is the reuse rate. Kept as two records because the
- # series names differ and a vacuous joint query is worse than two honest
- # ones.
+ # Recording rules so the dashboard and the collapse alert below share
+ # one expression. llamacpp's prompt counter EXCLUDES cached tokens, so
+ # the reuse fraction is cached / (cached + prompt).
- record: job:llm_prefix_cache_reuse:rate5m
expr: |
rate(llamacpp:prompt_tokens_cached_total[5m])
/ clamp_min(rate(llamacpp:prompt_tokens_cached_total[5m])
+ rate(llamacpp:prompt_tokens_total[5m]), 0.001)
- - record: job:llm_vllm_prefix_cache_reuse:rate5m
- expr: |
- rate(vllm:prefix_cache_hits_total[5m])
- / clamp_min(rate(vllm:prefix_cache_queries_total[5m]), 0.001)
-
# Mean accepted draft length (1.0 = the drafter proposes and nothing is
- # taken, so the draft round is pure overhead). Measured 3.0-4.3 on
- # frank-medium at boot with the DFlash2 drafter, 2026-09-20.
+ # taken, so the draft round is pure overhead).
- record: job:llm_spec_accept_length:rate5m
expr: |
- 1 + rate(vllm:spec_decode_num_accepted_tokens_total[5m])
- / clamp_min(rate(vllm:spec_decode_num_drafts_total[5m]), 0.001)
+ 1 + rate(llamacpp:spec_decode_num_accepted_tokens_total[5m])
+ / clamp_min(rate(llamacpp:spec_decode_num_drafts_total[5m]), 0.001)
- # The capacity wall on the llama.cpp side, in the only form it exports:
- # work waiting for a slot. Past here the next large request queues.
+ # The capacity wall, in the only form llama.cpp exports: work waiting
+ # for a slot. Past here the next large request queues.
- alert: LlmRequestsQueueing
expr: llamacpp:requests_deferred > 0
for: 10m
@@ -75,51 +62,15 @@ groups:
fleet is genuinely busy or one endpoint is carrying it alone. With
--parallel 1 a second concurrent request always shows up here.
- # The capacity wall on the vLLM side, in its own two terms: requests the
- # scheduler has not admitted, and requests it admitted and then PREEMPTED
- # (a preemption throws away the KV and re-prefills, so its rate is the
- # cost of running the pool full). frank-medium auto-sizes its pool to
- # 943,581 tokens = 3.59 concurrent full 262,144-token conversations, so
- # saturation is four such conversations, not two.
- - alert: LlmRequestsWaiting
- expr: vllm:num_requests_waiting > 0
- for: 10m
- labels:
- severity: warning
- annotations:
- summary: "{{ $labels.job }}: vLLM has not admitted work for 10 minutes"
- description: >-
- The scheduler is holding requests back. Check the reason label on
- vllm:num_requests_waiting_by_reason (capacity = no KV room, defer =
- waiting for a better batch shape) and the preemption counter
- vllm:num_preemptions_total.
-
- - alert: LlmKvCacheSaturated
- expr: vllm:kv_cache_usage_perc > 0.95
- for: 10m
- labels:
- severity: warning
- annotations:
- summary: "{{ $labels.job }}: KV pool above 95 % for 10 minutes"
- description: >-
- The pool is full, which is where preemption and re-prefill start:
- decode slows before anything errors. The pool is auto-sized by the
- quadlet (no KV_CACHE_MEMORY_MB pin); the boot ledger prints the
- token count — `podman logs frank-medium | grep "GPU KV cache size"`
- — and it was 943,581 tokens on 2026-09-20.
-
# The thrash signature measured on 2026-09-16: requests keep arriving but
# almost none are served from cache, which is what several concurrent
- # 50-80k-token conversations do to this fleet. One threshold, two
- # namespaces, and a traffic floor per side so an idle endpoint cannot
- # fire it on a 0/0.
+ # 50-80k-token conversations do to this fleet. One threshold with a
+ # traffic floor so an idle endpoint cannot fire it on a 0/0.
- alert: LlmPrefixCacheCollapse
expr: >-
- (job:llm_prefix_cache_reuse:rate5m < 0.20
+ job:llm_prefix_cache_reuse:rate5m < 0.20
and rate(llamacpp:prompt_tokens_total[5m])
- + rate(llamacpp:prompt_tokens_cached_total[5m]) > 0.05)
- or (job:llm_vllm_prefix_cache_reuse:rate5m < 0.20
- and rate(vllm:prefix_cache_queries_total[5m]) > 1)
+ + rate(llamacpp:prompt_tokens_cached_total[5m]) > 0.05
for: 15m
labels:
severity: info
@@ -132,14 +83,11 @@ groups:
# The speculative path is the one failure no client can see: the server
# answers normally, just at half the speed, when the drafter is gone.
- # Drafts and accepts are counters in both runtimes, so a rate that stays
- # at zero while the endpoint is serving tokens means nothing is drafted.
+ # Drafts and accepts are counters, so a rate that stays at zero while
+ # the endpoint is serving tokens means nothing is drafted.
- alert: LlmSpecDecodeOff
- expr: >-
- (rate(llamacpp:spec_decode_num_drafts_total[5m]) == 0
- and rate(llamacpp:tokens_predicted_total[5m]) > 0.1)
- or (rate(vllm:spec_decode_num_drafts_total[5m]) == 0
- and rate(vllm:generation_tokens_total[5m]) > 0.1)
+ expr: rate(llamacpp:spec_decode_num_drafts_total[5m]) == 0
+ and rate(llamacpp:tokens_predicted_total[5m]) > 0.1
for: 15m
labels:
severity: warning
@@ -147,11 +95,9 @@ groups:
summary: "{{ $labels.job }}: serving without speculative decoding"
description: >-
Tokens are being produced but no draft has been proposed for 15
- minutes. frank-small (llama.cpp) should log "adding speculative
- implementation" at load; frank-medium (vllm-radiance) should be
- counting vllm:spec_decode_num_drafts_total and reporting 3+ mean
- accept length (job:llm_spec_accept_length:rate5m). Either way the
- endpoint is decoding autoregressively.
+ minutes. The llama.cpp load log should carry an "adding
+ speculative implementation" line; the endpoint is decoding
+ autoregressively.
# The endpoint, not a replica: one process serves each port now, so a
# restart of the unit is a scrape gap, not a failover.
@@ -163,10 +109,9 @@ groups:
annotations:
summary: "{{ $labels.job }}: scrape target down for 2 minutes"
description: >-
- Prometheus cannot reach the LLM endpoint. frank-medium is a
- rootless quadlet: `systemctl --user is-active frank-medium` on
- Dankserver, then `podman ps -a --filter name=frank-medium`.
- frank-small is a root unit: `systemctl is-active frank-small` on
- Dankclaw. All three endpoints are scraped: frank-large's CIRU
- llama.cpp build does export the llamacpp:* family (verified
- 2026-09-20), so this alert covers the whole fleet.
+ Prometheus cannot reach the LLM endpoint. frank-small and
+ frank-large are root units on Dankclaw: `systemctl is-active
+ frank-small` / `frank-large`. Both scraped endpoints export the
+ llamacpp:* family. frank-primary (Dankserver) is not scraped —
+ its runtime exports no metrics; check the homepage probe or
+ `systemctl --user is-active frank-primary` on Dankserver.
diff --git a/tests/changed_tests.bats b/tests/changed_tests.bats
index 19c730c..e3a2291 100644
--- a/tests/changed_tests.bats
+++ b/tests/changed_tests.bats
@@ -21,18 +21,22 @@ load helpers
[[ "$output" != *"tests/homepage_pages.bats"* ]]
}
-@test "changed-test wrapper maps the frank-medium dir to its focused tests" {
+@test "changed-test wrapper maps the frank-primary dir to its focused tests" {
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
- dankserver/frank-medium/quadlet/frank-medium.container
+ dankserver/frank-primary/quadlet/frank-primary.container
[ "$status" -eq 0 ]
- [[ "$output" == *"tests/frank_medium_unit.bats"* ]]
+ [[ "$output" == *"tests/frank_primary_unit.bats"* ]]
[[ "$output" == *"tests/deployment_scoping.bats"* ]]
[[ "$output" == *"tests/docs_completeness.bats"* ]]
[[ "$output" != *"tests/homepage_pages.bats"* ]]
- # The llama trio's old path must no longer select the container seam: the
- # mapping was removed with the runtime, and a stale case here would keep
- # firing tests for an archived service.
- run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths scripts/frank-medium.env
+ # The retired frank-medium tree must no longer select any LLM seam: the
+ # mappings were removed with the service, and a stale case would keep
+ # firing tests for an archived runtime. Both archive shapes — the radiance
+ # Quadlet era and the llama.cpp layersplit trio before it — map to nothing.
+ run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
+ dankserver/.AGENTTRASH/20260926-frank-medium-radiance/frank-medium.container \
+ scripts/.AGENTTRASH/20260920-llama-layersplit/serve-frank-medium.sh.llamacpp-layersplit
[ "$status" -eq 0 ]
[[ "$output" != *"tests/frank_medium_unit.bats"* ]]
+ [[ "$output" != *"tests/frank_primary_unit.bats"* ]]
}
diff --git a/tests/deployment_flow.bats b/tests/deployment_flow.bats
index c20c58a..5582197 100644
--- a/tests/deployment_flow.bats
+++ b/tests/deployment_flow.bats
@@ -67,9 +67,10 @@ load helpers
! grep -q 'no proxy was stopped or replaced' "$REPO_ROOT/scripts/deploy-remote.sh"
}
-@test "remote runner has separate handlers for both inference services" {
+@test "remote runner has a handler per inference service" {
grep -q 'deploy_llm_large' "$REPO_ROOT/scripts/deploy-remote.sh"
- grep -q 'deploy_llm_medium' "$REPO_ROOT/scripts/deploy-remote.sh"
+ grep -q 'deploy_llm_primary' "$REPO_ROOT/scripts/deploy-remote.sh"
+ grep -q 'deploy_llm_small' "$REPO_ROOT/scripts/deploy-remote.sh"
}
@test "inference restarts are absent from the top-level dispatcher" {
diff --git a/tests/deployment_scoping.bats b/tests/deployment_scoping.bats
index 6f950ce..9458ac3 100644
--- a/tests/deployment_scoping.bats
+++ b/tests/deployment_scoping.bats
@@ -46,25 +46,19 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
[ -z "$output" ]
}
-@test "deployment planner maps the frank-medium container dir to frank-medium only" {
- run python3 "$PLANNER" --target dankserver --paths \
- dankserver/frank-medium/quadlet/frank-medium.container \
- dankserver/frank-medium/radiance-entrypoint.sh \
- dankserver/frank-medium/frank-medium-healthcheck.py
- [ "$status" -eq 0 ]
- [ "$output" = "frank-medium" ]
-
- # The path is not the assertion: both retired shapes are still in the tree
- # as archive, and a rule matching either would restart a service nobody
- # runs. Assert the negatives explicitly, plus the llama trio's old paths,
- # whose mapping was removed with the runtime.
- run python3 "$PLANNER" --target dankserver --paths \
- dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-a.container
- [ "$status" -eq 0 ]
- [ -z "$output" ]
+@test "the retired frank-medium tree plans nothing, from any archive" {
+ # frank-medium left the live tree on 2026-09-26 — first the radiance
+ # Quadlet (and its vLLM replica archive before it), then the llama.cpp
+ # layersplit trio and its fetch tooling. Every shape is archive material
+ # now, and a planner rule matching any of them would restart a service
+ # nobody maintains.
run python3 "$PLANNER" --target dankserver --paths \
+ dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-a.container \
+ dankserver/.AGENTTRASH/20260926-frank-medium-radiance/frank-medium.container \
+ dankserver/.AGENTTRASH/20260926-frank-medium-radiance/radiance-entrypoint.sh \
scripts/.AGENTTRASH/20260920-llama-layersplit/frank-medium.env.llamacpp-layersplit \
- scripts/frank-medium.env
+ scripts/.AGENTTRASH/20260926-frank-medium-radiance/download-frank-medium-gguf.sh \
+ scripts/.AGENTTRASH/20260926-frank-medium-radiance/swap-frank-medium.sh
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@@ -202,7 +196,7 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
# The runner may restart inference only from the service-dispatch path. It
# must not contain a host-wide restart loop.
grep -q 'deploy_llm_large' "$REMOTE_DEPLOY"
- grep -q 'deploy_llm_medium' "$REMOTE_DEPLOY"
+ grep -q 'deploy_llm_primary' "$REMOTE_DEPLOY"
grep -q 'deploy_service' "$REMOTE_DEPLOY"
! grep -qE 'for .*frank-(small|large|npu)-?' "$REMOTE_DEPLOY"
}
diff --git a/tests/frank_medium_unit.bats b/tests/frank_medium_unit.bats
deleted file mode 100755
index f290440..0000000
--- a/tests/frank_medium_unit.bats
+++ /dev/null
@@ -1,219 +0,0 @@
-#!/usr/bin/env bats
-# Seam: the frank-medium vllm-radiance container on dankserver.
-# frank-medium became a CONTAINER again on 2026-09-20, when the board swap put
-# both R9700s on CPU root ports and a measured three-arm comparison retired the
-# mainline-llama.cpp layer split (scripts/.AGENTTRASH/20260920-llama-layersplit/).
-# What this seam pins is what the flags alone do not say: boot persistence for a
-# GENERATED unit (this host's v1 quadlet generator cannot enable units, so the
-# [Install] section is the only thing that survives a reboot), that BOTH cards
-# work on ONE model through tensor parallelism — the thing the pre-2026-09-20
-# hardware could not do — the KV pin's coupling to the batch shape it was
-# calibrated at, and that the pool still holds one full-window request.
-
-load helpers
-
-UNIT="$REPO_ROOT/dankserver/frank-medium/quadlet/frank-medium.container"
-ENTRY="$REPO_ROOT/dankserver/frank-medium/radiance-entrypoint.sh"
-HEALTH="$REPO_ROOT/dankserver/frank-medium/frank-medium-healthcheck.py"
-REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
-LLAMA_TRASH="$REPO_ROOT/scripts/.AGENTTRASH/20260920-llama-layersplit"
-REPLICA_TRASH="$REPO_ROOT/dankserver/.AGENTTRASH/20260916-vllm-radiance"
-
-# Measured constants, used by the fit arithmetic.
-CARD_BYTES=34208743424 # one R9700 as rocm-smi reports it (32624 MiB)
-KV_BYTES_PER_TOKEN=70656 # --kv-cache-memory 18563072000 => "262,720 tokens"
-env_value() {
- sed -nE "s/^Environment=$1=(-?[0-9]+)$/\1/p" "$UNIT" | head -n1
-}
-# GPU_UTIL is a fraction, not a count; the integer reader would drop it.
-env_ratio() {
- sed -nE "s/^Environment=$1=([0-9.]+)$/\1/p" "$UNIT" | head -n1
-}
-# SPEC_METHOD is a string (dflash|mtp), not a count; the integer reader drops it.
-env_str() {
- sed -nE "s/^Environment=$1=(.+)$/\1/p" "$UNIT" | head -n1
-}
-
-@test "frank-medium's unit, entrypoint and healthcheck exist" {
- [ -f "$UNIT" ]
- [ -x "$ENTRY" ]
- [ -f "$HEALTH" ]
-}
-
-@test "frank-medium is boot-persistent and self-healing" {
- # Generated units cannot be enabled on this host, so WantedBy in the file is
- # the whole boot story (AGENTS.md: Boot Persistence & Self-Healing).
- grep -q '^WantedBy=default.target$' "$UNIT"
- grep -q '^Restart=on-failure$' "$UNIT"
- grep -q '^RestartSec=' "$UNIT"
- # First start compiles Triton/inductor kernels before the engine loads ~9.5
- # GiB per card; a shorter timeout kills a healthy boot.
- grep -q '^TimeoutStartSec=1800$' "$UNIT"
- grep -q '^HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py$' "$UNIT"
- grep -q '^HealthStartPeriod=1800s$' "$UNIT"
- # Boots must not depend on the registry.
- grep -q '^Pull=never$' "$UNIT"
-}
-
-@test "both R9700s serve ONE model as one tensor-parallel process" {
- grep -q '^Environment=TP=2$' "$UNIT"
- grep -q '^Environment=ROCR_VISIBLE_DEVICES=0,1$' "$UNIT"
- grep -q '^Environment=HIP_VISIBLE_DEVICES=0,1$' "$UNIT"
- grep -q -- '--tensor-parallel-size "$TP"' "$ENTRY"
- # The iGPU (index 2, 2 GiB) must stay out of the visible set: a third rank
- # would take layers onto system RAM.
- ! grep -q '^Environment=.*VISIBLE_DEVICES=0,1,2' "$UNIT"
- # TP needs the all-reduce to actually run. R4D is the collective-free path
- # this host measured; the entrypoint must still select it, and the RCCL
- # fallback must not be re-disabled by the pre-board-swap workarounds.
- grep -q 'RADIANCE_USE_R4D_AR' "$ENTRY"
- grep -q -- '--attention-backend "$ATTN"' "$ENTRY"
- ! grep -q 'NCCL_P2P_DISABLE' "$UNIT"
- ! grep -q 'RCCL_MSCCL_ENABLE' "$UNIT"
-}
-
-@test "HIP never reads one of these tunables as its own setting" {
- # HIP reads bare TEMP, HOST and GPU_MAX_HEAP_SIZE (2026-08-13 incident:
- # TEMP=1.0 sent the JIT cache to path "1.0" and segfaulted every load).
- # Serve scripts export env files with `set -a`; a container inherits the
- # same hazard from Environment=.
- ! grep -qE '^Environment=(TEMP|HOST|GPU_MAX_HEAP_SIZE)=' "$UNIT"
-}
-
-@test "the speculative drafter matches the serving shape" {
- local spec_method spec maxseqs chunk
- spec_method="$(env_str SPEC_METHOD)"
- spec="$(env_value SPEC)"
- maxseqs="$(env_value MAXSEQS)"
- chunk="$(env_value CHUNK)"
- [ -n "$spec_method" ] && [ -n "$spec" ] && [ -n "$maxseqs" ] && [ -n "$chunk" ]
- # The chunk is the AR size gate's input (CHUNK x hidden x 2 bytes); a chunk
- # larger than this row's silently changes which all-reduce path runs.
- [ "$chunk" -le 8192 ]
- case "$spec_method" in
- dflash)
- # DFlash2 drafts at most 7 tokens per step; deeper is a different
- # drafter's contract and costs acceptance, not just VRAM.
- [ "$spec" -eq 7 ]
- # The MXFP4 / ParoQuant-MXFP6 decode kernel band covers MAXSEQS x
- # (SPEC+1) rows, and the shipped default covers 64 (the mxfp6 profile
- # block exports the same RADIANCE_MXFP4_DECODE_MAX_M=64). Exceeding it
- # without the entrypoint's >8 bump is a launch failure at the worst
- # moment (first concurrent request).
- [ $(( maxseqs * (spec + 1) )) -le 64 ]
- grep -q 'RADIANCE_MXFP4_DECODE_MAX_M' "$ENTRY"
- ;;
- mtp)
- # MTP self-speculation at depth 8 (the documented plain-FP8 path; the
- # model's own single-layer MTP head, applied autoregressively).
- [ "$spec" -eq 8 ]
- ;;
- *)
- false "unknown SPEC_METHOD: $spec_method"
- ;;
- esac
-}
-
-@test "the KV pool holds a full window and fits beside the weights" {
- local kv_mem maxseqs chunk maxlen spec kv_dtype
- kv_mem="$(env_value KV_MEM)"
- maxseqs="$(env_value MAXSEQS)"
- chunk="$(env_value CHUNK)"
- maxlen="$(env_value MAXLEN)"
- spec="$(env_value SPEC)"
- kv_dtype="$(env_str KV_DTYPE)"
- [ -n "$kv_mem" ] && [ -n "$maxseqs" ] && [ -n "$chunk" ] && [ -n "$maxlen" ]
- # KV_MEM=0 = profile the shape (the documented plain-FP8 path's default;
- # the upstream docker-compose leaves the pin commented out). The pool is
- # auto-sized to fit, so there is no pin to verify — only that the shape
- # is set and the model's weights fit beside an auto-sized pool.
- if [ "$kv_mem" -eq 0 ]; then
- # MXFP4 + unquantized (KV_DTYPE=auto -> bf16) KV: MXFP4's freed weights
- # (19.37 vs 30.9 GB) buy the pool back, so the full 262,144 native window
- # still fits clean (~290k f16 pool); vLLM boots or the pool is too small.
- [ "$maxseqs" -eq 8 ] && [ "$chunk" -eq 8192 ] && [ "$maxlen" -eq 262144 ]
- [ "$kv_dtype" = "auto" ]
- return 0
- fi
- # Pinned pool: the pin must hold at least ONE full-window request or the
- # endpoint refuses its own --max-model-len, and it must fit on one card
- # beside the weights.
- grep -q 'kv-profiles.tsv' "$UNIT"
- [ $(( kv_mem / KV_BYTES_PER_TOKEN )) -ge "$maxlen" ]
- # Weights per card: MXFP4 ~9.5 GiB (19 GB / 2), FP8 ~15.5 GiB (30.9 GB / 2).
- local weights util util_pct floor
- case "$(env_str SPEC_METHOD)" in
- dflash) weights=10200547328 ;;
- *) weights=16646128640 ;;
- esac
- util="$(env_ratio GPU_UTIL)"
- [ -n "$util" ]
- util_pct="${util#0.}"
- floor=$(( 1024 * 1024 * 1024 )) # 1 GiB per card
- [ $(( weights + kv_mem / 2 )) -le $(( CARD_BYTES * util_pct / 100 - floor )) ]
-}
-
-@test "the fleet endpoint contract survives the runtime swap" {
- grep -q '^Environment=PORT=7770$' "$UNIT"
- grep -q '^Environment=SERVED_NAMES=frank-medium$' "$UNIT"
- grep -q -- '--port "$PORT"' "$ENTRY"
- grep -q -- '--served-model-name $SERVED_NAMES' "$ENTRY"
- # Host network: vLLM binds the port itself, so no PublishPort (and no
- # pasta bind, which is what made the retired proxy's port replacement
- # fail at container create).
- grep -q -- '--network=host' "$UNIT"
- ! grep -q '^PublishPort=' "$UNIT"
- # Tools and reasoning are part of the harness contract; both parsers must
- # be selected, and thinking must land in its own field.
- grep -q -- '--enable-auto-tool-choice' "$ENTRY"
- grep -q -- '--tool-call-parser qwen3_coder' "$ENTRY"
- grep -q -- '--reasoning-parser qwen3' "$ENTRY"
- grep -q -- '--enable-prefix-caching' "$ENTRY"
- # Requests that send no sampler fields get the predecessor's defaults.
- grep -q '"temperature":0.7' "$ENTRY"
- grep -q '"top_p":0.95' "$ENTRY"
-}
-
-@test "the healthcheck proves the engine serves, not merely that a process lives" {
- grep -q '"/health"' "$HEALTH" || grep -q '/health' "$HEALTH"
- grep -q 'status == 200' "$HEALTH"
- # PORT-aware, and it must not hard-code a replica port.
- grep -q 'os.environ.get("PORT"' "$HEALTH"
- ! grep -qE '7771|7772' "$HEALTH"
-}
-
-@test "the deploy handler gates on host assets and installs the quadlet" {
- grep -q 'deploy_llm_medium' "$REMOTE_DEPLOY"
- grep -q 'install_quadlet frank-medium dankserver/frank-medium/quadlet frank-medium' "$REMOTE_DEPLOY"
- grep -q 'wait_container_healthy frank-medium' "$REMOTE_DEPLOY"
- grep -q 'wait_port_free 7770' "$REMOTE_DEPLOY"
- grep -q '\[\[ "$missing" -eq 0 \]\] || return 1' "$REMOTE_DEPLOY"
- # The image is only pulled when absent, and re-tagged to the name the unit
- # actually runs (the quadlet path cannot resolve registry refs at create).
- grep -q 'podman image exists "$LLM_MEDIUM_IMAGE_LOCAL"' "$REMOTE_DEPLOY"
- grep -q 'podman tag "$LLM_MEDIUM_IMAGE" "$LLM_MEDIUM_IMAGE_LOCAL"' "$REMOTE_DEPLOY"
- # The patched libr4d is not optional (the image's own build NaNs this model).
- grep -q 'patched libr4d absent' "$REMOTE_DEPLOY"
-}
-
-@test "a generated unit is never asked to enable, and the llama unit is gone" {
- # podman 5.x v1 generator: generated units cannot be enabled; asking makes
- # the deploy fail on a unit that is already boot-persistent via [Install].
- ! grep -q 'systemctl --user enable frank-medium' "$REMOTE_DEPLOY"
- # ...and the root llama unit must be retired, not relinked: relinking would
- # resurrect a service whose repo file no longer exists.
- ! grep -q 'relink_unit frank-medium' "$REMOTE_DEPLOY"
- grep -q 'retire_llama_medium_unit' "$REMOTE_DEPLOY"
-}
-
-@test "the llama layer split and the replica trio are archived, not deleted" {
- [ -f "$LLAMA_TRASH/frank-medium.service.llamacpp-layersplit" ]
- [ -f "$LLAMA_TRASH/frank-medium.env.llamacpp-layersplit" ]
- [ -f "$LLAMA_TRASH/serve-frank-medium.sh.llamacpp-layersplit" ]
- [ ! -f "$REPO_ROOT/scripts/frank-medium.env" ]
- [ -f "$REPLICA_TRASH/frank-medium-a.container" ]
- [ -f "$REPLICA_TRASH/frank-medium-proxy.container" ]
- # The replicas stay retired: the handler stops them but never installs them.
- grep -q 'systemctl --user stop frank-medium-a.service frank-medium-b.service' "$REMOTE_DEPLOY"
- ! grep -q 'install_quadlet frank-medium-a' "$REMOTE_DEPLOY"
-}
diff --git a/tests/frank_primary_unit.bats b/tests/frank_primary_unit.bats
new file mode 100755
index 0000000..3746526
--- /dev/null
+++ b/tests/frank_primary_unit.bats
@@ -0,0 +1,94 @@
+#!/usr/bin/env bats
+# Seam: frank-primary — the Dankserver LLM endpoint on :7770.
+#
+# This file asserts the LUCEBOX shape (the 2026-09-26 cutover that replaced
+# frank-medium): a rootless Quadlet running the lucebox DS4.1 engine on the
+# two R9700s, with the model mmapped out of /home/dank/models and the
+# entrypoint/healthcheck mounted straight out of this checkout.
+
+load helpers
+
+UNIT="$REPO_ROOT/dankserver/frank-primary/quadlet/frank-primary.container"
+EP="$REPO_ROOT/dankserver/frank-primary/frank-primary-entrypoint.sh"
+HC="$REPO_ROOT/dankserver/frank-primary/frank-primary-healthcheck.py"
+
+@test "the frank-primary quadlet file and its mounted scripts exist" {
+ [ -f "$UNIT" ]
+ [ -x "$EP" ]
+ [ -f "$HC" ]
+ # The healthcheck is python; a syntax error would leave the container
+ # permanently unhealthy with the failure hidden inside HealthCmd.
+ python3 -m py_compile "$HC"
+}
+
+@test "frank-primary is boot-persistent and self-healing" {
+ # Rootless Podman does not auto-start bare containers and restart:
+ # unless-stopped does not survive shutdown's explicit stop. The unit must
+ # carry both the boot symlink and a restart policy (2026-08-26 incident:
+ # a compose-only Caddy never came back after a reboot).
+ grep -q '^WantedBy=default.target' "$UNIT"
+ grep -qE '^Restart=(always|on-failure)' "$UNIT"
+}
+
+@test "frank-primary binds the host network and needs no published port" {
+ # luce_server binds 0.0.0.0:7770 directly, so the container takes the
+ # host netns. A PublishPort would be dead config.
+ grep -q -- '--network=host' "$UNIT"
+ ! grep -q '^PublishPort=' "$UNIT"
+}
+
+@test "frank-primary reaches both R9700s and hides the Raphael iGPU" {
+ # /dev/kfd + /dev/dri via AddDevice; HIP_VISIBLE_DEVICES=0,1 keeps the
+ # host's Raphael iGPU (index 2) out of the enumeration so the engine's
+ # device picks cannot land on it.
+ grep -q '^AddDevice=/dev/kfd' "$UNIT"
+ grep -q '^AddDevice=/dev/dri' "$UNIT"
+ grep -q '^Environment=HIP_VISIBLE_DEVICES=0,1$' "$UNIT"
+}
+
+@test "frank-primary never names a bare HIP-reserved env var" {
+ # The HIP runtime reads bare TEMP for its JIT cache path (the 2026-08-13
+ # incident: TEMP=1.0 sent the JIT cache to path "1.0" and segfaulted every
+ # load). HOST and GPU_MAX_HEAP_SIZE are the same class. The unit's
+ # Environment= lines must not define them; the entrypoint exports
+ # GPU_MAX_HW_QUEUES (explicitly not GPU_MAX_HEAP_SIZE).
+ ! grep -qE '^Environment=(TEMP|HOST|GPU_MAX_HEAP_SIZE)=' "$UNIT"
+ ! grep -qE '^(export )?(TEMP|HOST|GPU_MAX_HEAP_SIZE)=' "$EP"
+}
+
+@test "the unit mounts the entrypoint and healthcheck from the checkout" {
+ # deploy.sh git-pulls this checkout before dispatching, so a Volume
+ # reference is how an edit here reaches the container without an image
+ # rebuild. Absolute host paths, read-only.
+ grep -q 'Volume=/home/dank/serverAppConfigs/dankserver/frank-primary/frank-primary-entrypoint.sh:/usr/local/bin/frank-primary-entrypoint.sh:ro' "$UNIT"
+ grep -q 'Volume=/home/dank/serverAppConfigs/dankserver/frank-primary/frank-primary-healthcheck.py:/usr/local/bin/frank-primary-healthcheck.py:ro' "$UNIT"
+}
+
+@test "first-start patience is configured on both sides of the health check" {
+ # Cold start page-ins the dense weights and builds the graphs; the
+ # container must not be killed as unhealthy while that happens, and the
+ # deployer must wait the same order of magnitude.
+ grep -q '^HealthStartPeriod=1800s' "$UNIT"
+ grep -q '^TimeoutStartSec=1800$' "$UNIT"
+ grep -q 'wait_container_healthy frank-primary 1800' \
+ "$REPO_ROOT/scripts/deploy-remote.sh"
+}
+
+@test "the deployer refuses to start without image and complete payload" {
+ # A 377 GiB partial download must never be served: the handler gates on
+ # the image being present and the model file existing at its exact
+ # declared byte count (stat -c %s, never wc -c — wc read the whole stream
+ # for minutes on 2026-09-26).
+ grep -q 'deploy_llm_primary() {' "$REPO_ROOT/scripts/deploy-remote.sh"
+ grep -q 'podman image exists localhost/lucebox-rocm:gfx1201' \
+ "$REPO_ROOT/scripts/deploy-remote.sh"
+ grep -q 'want_size=405349859328' "$REPO_ROOT/scripts/deploy-remote.sh"
+ grep -q 'stat -c %s' "$REPO_ROOT/scripts/deploy-remote.sh"
+}
+
+@test "the deployer gates on the port actually being free" {
+ # 7770 is a host-network bind: the previous holder must release it before
+ # the new container can be created, or pasta's bind fails at create time
+ # and the failure hides behind Restart=.
+ grep -q 'wait_port_free 7770' "$REPO_ROOT/scripts/deploy-remote.sh"
+}
diff --git a/tests/observability_quadlet.bats b/tests/observability_quadlet.bats
index 829d03d..ea8d27a 100755
--- a/tests/observability_quadlet.bats
+++ b/tests/observability_quadlet.bats
@@ -315,19 +315,24 @@ PAIRS
@test "prometheus scrapes every LLM endpoint that exports metrics" {
# The LLM endpoints are the services whose behaviour this fleet argues about
- # most (KV pressure, prefix-cache reuse, queue depth). Since frank-medium
- # moved to radiance/vLLM on 2026-09-20 there are two metric namespaces, and
- # the three addresses differ by HOW each endpoint is reachable from a
- # container: netavark DNATs *published* container ports only, so
- # frank-medium — a rootless Quadlet on another host that publishes 7770 — is
- # its LAN address, while the two Dankclaw endpoints are HOST processes and
- # answer only as host.containers.internal. Addressing those two by LAN IP
- # left both targets red by construction until the 2026-09-20 /targets audit.
+ # most (KV pressure, prefix-cache reuse, queue depth). Only the two Dankclaw
+ # llama.cpp endpoints export metrics today: frank-primary (Dankserver, the
+ # Lucebox DS4.1 engine since the 2026-09-26 cutover) answers 404 on
+ # /metrics, so a job for it would be a permanently-down target — no job,
+ # and its LAN address must not sneak back in with one.
+ # The two scraped endpoints are HOST processes reached through
+ # host.containers.internal: this Prometheus is a bridge-networked
+ # container, and on this netavark host a container can only reach the
+ # host's LAN IP at a PUBLISHED port — a host-process bind like
+ # llama-server's 0.0.0.0:7770 is refused at 192.168.0.143:7770 while it
+ # answers at 169.254.1.2:7770 (why frank-small stayed red by construction
+ # for days until the 2026-09-20 /targets audit).
local cfg="$REPO_ROOT/shared/prometheus/prometheus.yml"
- grep -q '"192.168.0.235:7770"' "$cfg" || { echo "frank-medium not scraped"; return 1; }
+ ! grep -q '"192.168.0.235:7770"' "$cfg" || { echo "frank-primary is scraped but exports no metrics"; return 1; }
+
grep -q '"host.containers.internal:7771"' "$cfg" || { echo "frank-small not scraped"; return 1; }
grep -q '"host.containers.internal:7770"' "$cfg" || { echo "frank-large not scraped"; return 1; }
- for job in frank-medium frank-small frank-large; do
+ for job in frank-small frank-large; do
grep -q "job_name: \"$job\"" "$cfg" || { echo "no scrape job for $job"; return 1; }
done
# None of them is reached through a guarded vhost: Prometheus cannot solve
@@ -369,45 +374,30 @@ import re, yaml
d = yaml.safe_load(open('$rules'))
names = [r.get('alert') or r.get('record') for g in d['groups'] for r in g['rules']]
for want in ('job:llm_prefix_cache_reuse:rate5m',
- 'job:llm_vllm_prefix_cache_reuse:rate5m',
'job:llm_spec_accept_length:rate5m',
- 'LlmRequestsQueueing', 'LlmRequestsWaiting',
- 'LlmKvCacheSaturated', 'LlmPrefixCacheCollapse',
+ 'LlmRequestsQueueing', 'LlmPrefixCacheCollapse',
'LlmSpecDecodeOff', 'LlmEndpointDown'):
assert want in names, want
# Every rule must be keyed to a series that actually exists. A rule naming a
-# series nobody exports evaluates nothing forever and errors never — the failure
-# this seam exists for. Since 2026-09-20 two engines are scraped, so both
-# namespaces are legal, but each name must be on the measured list.
+# series nobody exports evaluates nothing forever and errors never — the
+# failure this seam exists for. Since the 2026-09-26 cutover the only scraped
+# LLM runtime is llama.cpp (frank-small, frank-large); the vllm: family left
+# with frank-medium's radiance runtime and must not survive in any expr.
exprs = [r.get('expr', '') for g in d['groups'] for r in g['rules']]
-# llama.cpp families measured from /metrics on 2026-09-16 (frank-small,
-# frank-large), vLLM families measured from /metrics on 2026-09-20
-# (frank-medium, radiance/vLLM). Anything else is a guess.
+# llama.cpp families measured from /metrics on 2026-09-16 and 2026-09-20
+# (frank-small, frank-large). Anything else is a guess.
live = {'llamacpp:prompt_tokens_total', 'llamacpp:prompt_tokens_cached_total',
'llamacpp:requests_deferred', 'llamacpp:requests_processing',
'llamacpp:spec_decode_num_drafts_total',
'llamacpp:spec_decode_num_accepted_tokens_total',
+ 'llamacpp:spec_decode_num_draft_tokens_total',
'llamacpp:tokens_predicted_total',
- 'vllm:kv_cache_usage_perc', 'vllm:num_requests_running',
- 'vllm:num_requests_waiting', 'vllm:num_preemptions_total',
- 'vllm:generation_tokens_total', 'vllm:prompt_tokens_total',
- 'vllm:spec_decode_num_accepted_tokens_total',
- 'vllm:spec_decode_num_draft_tokens_total',
- 'vllm:spec_decode_num_drafts_total',
- 'vllm:prefix_cache_hits_total', 'vllm:prefix_cache_queries_total',
- 'vllm:request_success_total'}
+ 'llamacpp:predicted_tokens_seconds', 'llamacpp:prompt_tokens_seconds',
+ 'llamacpp:n_decode_total', 'llamacpp:n_busy_slots_per_decode'}
used = {m for e in exprs
for m in re.findall(r'(?:llamacpp|vllm):[a-z_0-9]+', e)}
assert used <= live, used - live
-# Engine-independent rules must cover BOTH families, or the cutover silently
-# blinds one endpoint: the two rules that describe an engine-agnostic failure
-# name a series from each namespace.
-joined = '\n'.join(exprs)
-for pair in (('LlmSpecDecodeOff', 'llamacpp:spec_decode_num_drafts_total',
- 'vllm:spec_decode_num_drafts_total'),
- ('LlmPrefixCacheCollapse', 'llamacpp:prompt_tokens_total',
- 'vllm:prefix_cache_queries_total')):
- assert any(pair[1] in e and pair[2] in e for e in exprs), pair[0]
+assert not any(':vllm' in e for e in exprs), 'vllm: series in rules but no vllm endpoint is scraped'
print('rules ok:', len(names))
"
# A rule file that nothing loads evaluates nothing, and Prometheus reports