Qwen3-Embedding is a causal model; its card pools the last token. With cls every
input embedded as its first token's state ("the cat sat on the mat" and "the stock
market crashed today" at cosine 1.0). A CPU side server with --pooling last
reproduced the card's example scores within 0.002 without a manual EOS. Honcho's
stored vectors and sift's embed cache are re-embedded / flushed after the switch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
356 lines
14 KiB
Nix
356 lines
14 KiB
Nix
{ inputs, pkgs, lib, config, ... }:
|
|
let
|
|
# Unstable instantiated with unfree allowed: CUDA is unfree, and legacyPackages does not
|
|
# inherit the host's nixpkgs.config (straylight's Vulkan build never hit this).
|
|
pkgsUnstable = import inputs.nixpkgs-unstable {
|
|
system = pkgs.stdenv.hostPlatform.system;
|
|
config = { allowUnfree = true; cudaSupport = true; };
|
|
};
|
|
# llama.cpp with the CUDA backend, from unstable to match straylight's build (same model
|
|
# files, same router semantics). Built once on straylight and shipped with dixie-push
|
|
# --closure, or pulled from the cache when Dixie's link allows.
|
|
llama-cpp = pkgsUnstable.llama-cpp.override { cudaSupport = true; };
|
|
modelsDir = "/var/lib/llama-server/models";
|
|
|
|
# Dixie is the fleet's helper tier: everything that is latency-sensitive, small, and was
|
|
# starving on straylight's CPU (2026-09-21). One RTX 3060 (12 GB) today; card 2 joins
|
|
# when its fans are replaced.
|
|
#
|
|
# Router: the three models that must never be swapped out — approval guardian / Honcho
|
|
# deriver (9B), Honcho dialectic (4B, non-thinking), Honcho embeddings — resident together:
|
|
# 5.6 + 2.5 + 0.7 GB weights, ~3 GB left for KV. Vision does NOT go here: 5 GB + 1.2 GB
|
|
# mmproj does not fit beside them, so it gets its own on-demand process below.
|
|
llamaModelsIni = pkgs.writeText "llama-models.ini" ''
|
|
version = 1
|
|
[*]
|
|
jinja = true
|
|
flash-attn = on
|
|
cache-type-k = q8_0
|
|
cache-type-v = q8_0
|
|
n-gpu-layers = 999
|
|
; three resident models share 12 GB: keep contexts modest
|
|
ctx-size = 16384
|
|
parallel = 2
|
|
; one KV pool per model shared by its slots, so a single long request can use all 16k
|
|
; (split slots capped each at 8k: Honcho dialectic prompts of 11-17k all failed)
|
|
kv-unified = true
|
|
cache-ram = 512
|
|
|
|
; Ornith 1.5 9B, refusal-ablated. Hermes auxiliary model (approval guardian, title,
|
|
; web_extract) for all agents and Honcho's deriver/summary/dream model.
|
|
[ornith-1.5-9b-uncensored]
|
|
model = ${modelsDir}/ornith-1.5-9b-uncensored/Ornith-1.5-9B-uncensored.Q4_K_M.gguf
|
|
; serves the deriver, all dialectic levels and three agents' approval guardians at once:
|
|
; 8 slots over one 96k pool so tiny guardian/title calls still find a slot while Honcho
|
|
; catch-up (deriver + dreams + dialectic) holds 6 (at 2 x 16k, concurrent calls hit "failed to find
|
|
; free space in the KV cache" and stalled). 96k overflowed ~every 2-3 min under
|
|
; load on 2026-09-23 (39 events 00:50-04:28; each fails ALL in-flight slots with
|
|
; "Context size has been exceeded"). KV is ~20 KB/token here (9B process was 7.5 GB
|
|
; at 96k incl. 5.5 GB weights), so 128k costs ~0.65 GB of the ~2.5 GB free.
|
|
ctx-size = 131072
|
|
parallel = 8
|
|
; One scheduler step = up to batch-size prompt tokens, and every decoding slot gets one
|
|
; token per step. At the 2048 default a step was ~2.4 s while Honcho's 10-28k prompts
|
|
; prefilled, so 16-token guardian calls hit p90 10 s. The GPU already computes in
|
|
; 512-token ubatches, so 512 keeps prefill throughput and makes steps ~0.6 s.
|
|
batch-size = 512
|
|
; Runaway guard carried over from straylight: helper tasks need short answers.
|
|
reasoning-budget = 2048
|
|
n-predict = 4096
|
|
; stock template raises on tool-loop requests with no plain user turn (Honcho dialectic)
|
|
chat-template-file = ${./ornith-9b-chat-template.jinja}
|
|
; Qwen thinking-mode sampling (llama-server's 0.8 default made the Honcho deriver drop
|
|
; ~1 in 3 batches, measured 2026-09-22). Clients that send their own temperature win.
|
|
temp = 0.6
|
|
top-p = 0.95
|
|
top-k = 20
|
|
min-p = 0
|
|
|
|
; honcho-dialectic (Qwen3-4B-Instruct-2507) retired 2026-09-22: fast but often wrong in a
|
|
; dialectic benchmark, where the 9B answered correctly. All dialectic levels use the 9B now;
|
|
; the 4B's VRAM went to the 9B's KV pool. File stays on the stick under qwen3-4b-instruct-2507/.
|
|
|
|
; Honcho embeddings (1024-dim; EMBEDDING_VECTOR_DIMENSIONS=1024 on rift).
|
|
[honcho-embed]
|
|
model = ${modelsDir}/qwen3-embedding-0.6b/Qwen3-Embedding-0.6B-Q8_0.gguf
|
|
embedding = true
|
|
; Qwen3-Embedding is causal: pool the last token (its model card does). cls returned the
|
|
; first token's state, so every text embedded by its first token alone (cos 1.0 across
|
|
; unrelated texts, 2026-09-29). llama.cpp appends the EOS itself; last reproduces the
|
|
; card's example scores within 0.002.
|
|
pooling = last
|
|
; A whole input must fit in one physical batch AND one slot (batch-size silently caps
|
|
; ubatch; ctx is split across slots). Bigger batches cost compute buffers fast on a
|
|
; shared 12 GB card: 8192/slot took 6.6 GB and 4096/slot did not fit beside the 9B and
|
|
; dialectic. So 2048/slot here, and rift's EMBEDDING_MAX_INPUT_TOKENS=1536 makes Honcho
|
|
; chunk below that (its token estimate is not Qwen's tokenizer, hence the margin).
|
|
batch-size = 2048
|
|
ubatch-size = 2048
|
|
ctx-size = 4096
|
|
parallel = 2
|
|
'';
|
|
|
|
# Vision (auxiliary.vision for all agents): its own server on :11433, loaded on first
|
|
# request and dropped again after an idle hour so the router's three keep their VRAM.
|
|
visionModelsIni = pkgs.writeText "llama-vision.ini" ''
|
|
version = 1
|
|
[*]
|
|
jinja = true
|
|
flash-attn = on
|
|
n-gpu-layers = 999
|
|
ctx-size = 16384
|
|
parallel = 1
|
|
sleep-idle-seconds = 3600
|
|
|
|
[qwen3-vl-8b-abliterated]
|
|
model = ${modelsDir}/qwen3-vl-8b-abliterated/Qwen3-VL-8B-Instruct-abliterated-v2.0.Q4_K_M.gguf
|
|
mmproj = ${modelsDir}/qwen3-vl-8b-abliterated/Qwen3-VL-8B-Instruct-abliterated-v2.0.mmproj-f16.gguf
|
|
'';
|
|
|
|
llamaService = { description, port, preset, modelsMax }: {
|
|
inherit description;
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network-online.target" "var-lib-llama\\x2dserver-models.mount" ];
|
|
wants = [ "network-online.target" ];
|
|
environment.HOME = "/var/lib/llama-server";
|
|
serviceConfig = {
|
|
ExecStart = ''
|
|
${llama-cpp}/bin/llama-server \
|
|
--host 0.0.0.0 \
|
|
--port ${toString port} \
|
|
--models-preset ${preset} \
|
|
--models-max ${toString modelsMax} \
|
|
--metrics \
|
|
--timeout 7200
|
|
'';
|
|
User = "llama-server";
|
|
Group = "llama-server";
|
|
StateDirectory = "llama-server";
|
|
WorkingDirectory = "/var/lib/llama-server";
|
|
SupplementaryGroups = [ "video" ];
|
|
# Own slice, above default CPUWeight (set in analysis.nix) so the
|
|
# analysis.slice workloads never starve the helper-tier router.
|
|
Slice = "llama.slice";
|
|
Restart = "on-failure";
|
|
RestartSec = "10s";
|
|
TimeoutStartSec = "600";
|
|
};
|
|
};
|
|
|
|
# USB AX9L (mt7921u) is primary; internal CNVi (iwlwifi/wlo1) is fallback.
|
|
# Connection profiles stay on-box (PSK); this only selects which radio is up.
|
|
usbWifiMac = "74:19:f8:17:5e:53";
|
|
usbWifiConn = "ATTjkThnDS";
|
|
internalWifiConn = "ATTjkThnDS-iwlwifi";
|
|
internalWifiIf = "wlo1";
|
|
wifiUsbFailover = pkgs.writeShellScript "wifi-usb-failover" ''
|
|
set -eu
|
|
nmcli=${pkgs.networkmanager}/bin/nmcli
|
|
logger=${pkgs.util-linux}/bin/logger
|
|
systemd_run=${pkgs.systemd}/bin/systemd-run
|
|
tr=${pkgs.coreutils}/bin/tr
|
|
|
|
USB_MAC="${usbWifiMac}"
|
|
USB_CONN="${usbWifiConn}"
|
|
FALLBACK_CONN="${internalWifiConn}"
|
|
INTERNAL_IF="${internalWifiIf}"
|
|
|
|
log() { "$logger" -t wifi-usb-failover -- "$*"; }
|
|
|
|
iface_mac() {
|
|
"$tr" 'A-F' 'a-f' < "/sys/class/net/$1/address" 2>/dev/null || true
|
|
}
|
|
|
|
usb_iface() {
|
|
for n in /sys/class/net/*; do
|
|
n=''${n##*/}
|
|
[ "$n" = "lo" ] && continue
|
|
[ "$(iface_mac "$n")" = "$USB_MAC" ] && { echo "$n"; return 0; }
|
|
done
|
|
return 1
|
|
}
|
|
|
|
is_usb_iface() {
|
|
[ "$(iface_mac "$1")" = "$USB_MAC" ]
|
|
}
|
|
|
|
demote_internal() {
|
|
"$nmcli" device set "$INTERNAL_IF" autoconnect no >/dev/null 2>&1 || true
|
|
"$nmcli" connection down "$FALLBACK_CONN" >/dev/null 2>&1 || true
|
|
"$nmcli" device disconnect "$INTERNAL_IF" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
promote_internal() {
|
|
if usb_iface >/dev/null 2>&1; then
|
|
log "USB still present; not failing over"
|
|
return 0
|
|
fi
|
|
log "USB absent; bringing up $FALLBACK_CONN on $INTERNAL_IF"
|
|
"$nmcli" device set "$INTERNAL_IF" autoconnect yes >/dev/null 2>&1 || true
|
|
"$nmcli" connection up "$FALLBACK_CONN" ifname "$INTERNAL_IF" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
settle() {
|
|
i=0
|
|
while [ "$i" -lt 20 ]; do
|
|
if usb_iface >/dev/null 2>&1; then
|
|
u=$(usb_iface)
|
|
log "settle: USB $u present; demoting internal"
|
|
demote_internal
|
|
if ${pkgs.iproute2}/bin/ip -4 -o addr show dev "$u" | ${pkgs.gnugrep}/bin/grep -q ' inet '; then
|
|
return 0
|
|
fi
|
|
"$nmcli" connection up "$USB_CONN" ifname "$u" >/dev/null 2>&1 || true
|
|
return 0
|
|
fi
|
|
i=$((i + 1))
|
|
sleep 1
|
|
done
|
|
log "settle: no USB after 20s; failing over"
|
|
promote_internal
|
|
}
|
|
|
|
IFACE=''${1-}
|
|
ACTION=''${2-}
|
|
|
|
case "$IFACE" in
|
|
boot|settle) settle; exit 0 ;;
|
|
after-usb-down) sleep 3; promote_internal; exit 0 ;;
|
|
esac
|
|
|
|
case "$ACTION" in
|
|
up)
|
|
if is_usb_iface "$IFACE"; then
|
|
log "USB $IFACE up; demoting $INTERNAL_IF"
|
|
demote_internal
|
|
fi
|
|
;;
|
|
down)
|
|
if is_usb_iface "$IFACE"; then
|
|
log "USB $IFACE down; scheduling failover check"
|
|
"$systemd_run" --quiet --collect --unit=wifi-usb-failover-down \
|
|
--property=Type=oneshot "$0" after-usb-down >/dev/null 2>&1 || true
|
|
fi
|
|
;;
|
|
esac
|
|
exit 0
|
|
'';
|
|
in
|
|
{
|
|
imports = [
|
|
./hardware-configuration.nix
|
|
./disk-config.nix
|
|
./analysis.nix
|
|
inputs.tether.nixosModules.tether
|
|
../../configs/metrics/exporters.nix
|
|
];
|
|
|
|
config = {
|
|
# Headless. The base configuration.nix has no desktop; nothing GUI is added here.
|
|
boot.loader.systemd-boot.enable = true;
|
|
boot.loader.efi.canTouchEfiVariables = true;
|
|
# 6.12 LTS: mt7921u (the AX9L USB Wi-Fi) is in-tree from 5.18; avoids the 6.17/6.18
|
|
# mt7921 monitor-mode regression for good measure.
|
|
boot.kernelPackages = pkgs.linuxPackages_6_12;
|
|
|
|
# NVIDIA, compute only: no X, no modesetting needed for CUDA.
|
|
hardware.graphics.enable = true;
|
|
services.xserver.videoDrivers = [ "nvidia" ];
|
|
hardware.nvidia = {
|
|
open = false;
|
|
modesetting.enable = false;
|
|
nvidiaPersistenced = true;
|
|
package = config.boot.kernelPackages.nvidiaPackages.production;
|
|
};
|
|
hardware.enableRedistributableFirmware = true;
|
|
|
|
# Power cap for the 3060(s): thermal hour on 2026-09-21 held 75 °C at 140 W, case closed.
|
|
systemd.services.nvidia-power-cap = {
|
|
description = "Cap GPU power (thermals in the Aurora chassis)";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "nvidia-persistenced.service" ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
ExecStart = "${config.hardware.nvidia.package.bin}/bin/nvidia-smi -pl 140";
|
|
};
|
|
};
|
|
|
|
# Headless and Wi-Fi only: there is no console to add a key from after install, so the
|
|
# keys go in with the system. Same set the live ISO used (~/src/dixie-iso).
|
|
users.users.kyle.openssh.authorizedKeys.keyFiles = [ ./authorized_keys.pub ];
|
|
users.users.root.openssh.authorizedKeys.keyFiles = [ ./authorized_keys.pub ];
|
|
|
|
users.users.llama-server = { isSystemUser = true; group = "llama-server"; home = "/var/lib/llama-server"; };
|
|
users.groups.llama-server = { };
|
|
|
|
# Model weights live on the USB cache stick (ext4, label dixie-cache), populated from
|
|
# straylight with dixie-push. Both servers wait for the mount.
|
|
fileSystems."/mnt/cache" = {
|
|
device = "/dev/disk/by-label/dixie-cache";
|
|
fsType = "ext4";
|
|
options = [ "noatime" "nofail" "x-systemd.device-timeout=30s" ];
|
|
};
|
|
fileSystems."/var/lib/llama-server/models" = {
|
|
device = "/mnt/cache/models";
|
|
fsType = "none";
|
|
options = [ "bind" "nofail" "x-systemd.requires=/mnt/cache" ];
|
|
};
|
|
|
|
systemd.services.llama-server = llamaService {
|
|
description = "llama.cpp router: helper tier (9B guardian/deriver/dialectic, embeddings)";
|
|
port = 11434; preset = llamaModelsIni; modelsMax = 2;
|
|
};
|
|
systemd.services.llama-vision = llamaService {
|
|
description = "llama.cpp vision server (qwen3-vl-8b, on demand)";
|
|
port = 11433; preset = visionModelsIni; modelsMax = 1;
|
|
};
|
|
|
|
services.tether = {
|
|
enable = true;
|
|
listen = "0.0.0.0:11435";
|
|
upstream = "http://127.0.0.1:11434";
|
|
};
|
|
|
|
# Wi-Fi only host. NetworkManager comes from the base config; the AX9L (mt7921u) is
|
|
# primary and the internal CNVi radio is fallback. Connection profiles are copied
|
|
# from the live-USB session at install time (see the install notes), not stored here.
|
|
networking.networkmanager.wifi.backend = "wpa_supplicant";
|
|
networking.networkmanager.dispatcherScripts = [
|
|
{ source = wifiUsbFailover; type = "basic"; }
|
|
];
|
|
systemd.services.wifi-usb-failover-boot = {
|
|
description = "Prefer USB Wi-Fi; fail over to internal CNVi if the dongle is absent";
|
|
after = [ "NetworkManager.service" ];
|
|
wants = [ "NetworkManager.service" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
ExecStart = "${wifiUsbFailover} boot";
|
|
};
|
|
};
|
|
|
|
# Reachable only over the tailnet: 11434 (router), 11433 (vision), 11435
|
|
# (tether), 9835 (nvidia), 5432 (dropsonde Postgres) on tailscale0, and SSH
|
|
# from the LAN (base config opens 22) for bring-up.
|
|
networking.firewall.interfaces.tailscale0.allowedTCPPorts = [
|
|
11433 11434 11435 9835 5432
|
|
];
|
|
|
|
services.prometheus.exporters.nvidia-gpu = {
|
|
enable = true;
|
|
listenAddress = "0.0.0.0";
|
|
port = 9835;
|
|
openFirewall = false;
|
|
};
|
|
|
|
environment.systemPackages = with pkgs; [
|
|
llama-cpp
|
|
pciutils usbutils lm_sensors dmidecode nvme-cli smartmontools ethtool iw wpa_supplicant
|
|
htop tmux git jq curl rsync nvtopPackages.nvidia
|
|
];
|
|
|
|
};
|
|
}
|