Adds qs-ask on the mediaserver — the Anthropic key lives there and nowhere else, so neither desktop holds a credential and there's one place to rotate it. The launcher pipes the question over the same SSH path the server monitor already uses; the query goes over stdin because `ssh host cmd arg` would re-parse arg through the remote shell. Claude answers first, Wikipedia is the fallback. The script is the feature gate: with no key it exits silently, which is the same signal as a failed call or an UNKNOWN reply, so the Wikipedia path stays the default with zero configuration. The card names whichever answered. Haiku 4.5, no thinking and no effort (effort errors on Haiku) — a one-sentence fact needs no reasoning tokens. ~$0.0005 per query. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
5.8 KiB
Nix
139 lines
5.8 KiB
Nix
{ config, pkgs, lib, ... }:
|
|
{
|
|
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
|
|
# Create symlink from home to storage
|
|
systemd.tmpfiles.rules = [
|
|
"L+ /home/fred/storage - - - - /mnt/storage"
|
|
];
|
|
|
|
# Basic system packages
|
|
environment.systemPackages = with pkgs; [
|
|
mergerfs
|
|
wget
|
|
util-linux
|
|
javaPackages.compiler.temurin-bin.jre-25
|
|
unzip
|
|
screen
|
|
yt-dlp
|
|
ghostty.terminfo
|
|
usbutils
|
|
lm_sensors
|
|
(pkgs.writeShellScriptBin "transcode-hevc" ''
|
|
export PATH="${pkgs.jellyfin-ffmpeg}/bin:${pkgs.coreutils}/bin:${pkgs.findutils}/bin:${pkgs.gnugrep}/bin:${pkgs.gawk}/bin:${pkgs.bc}/bin:${pkgs.curl}/bin:$PATH"
|
|
exec ${pkgs.bash}/bin/bash ${../scripts/transcode-hevc.sh} "$@"
|
|
'')
|
|
(pkgs.writeShellScriptBin "record-update" ''
|
|
export PATH="${pkgs.nvd}/bin:${pkgs.coreutils}/bin:${pkgs.gnugrep}/bin:${pkgs.gnused}/bin:$PATH"
|
|
exec ${pkgs.bash}/bin/bash ${../scripts/record-update.sh} "$@"
|
|
'')
|
|
# Stats stream for the quickshell server monitor on the desktops:
|
|
# `ssh mediaserver qs-stats` = top's 2s batch stream, interleaved with
|
|
# one @STAT line per tick (hottest coretemp across both sockets, WAN
|
|
# byte rates from eno1). Everything rides one SSH connection.
|
|
(pkgs.writeShellScriptBin "qs-stats" ''
|
|
# Single writer: the loop re-emits top's lines itself and injects a
|
|
# @STAT line at each frame header. top writing the pipe directly in
|
|
# parallel raced the injected lines (pipe writes aren't line-atomic)
|
|
# and spliced @STAT mid-frame, where the client parser never saw it.
|
|
C=${pkgs.coreutils}/bin
|
|
export LC_ALL=C
|
|
prev_rx=""
|
|
${pkgs.procps}/bin/top -b -d 2 -w 512 | while IFS= read -r line; do
|
|
printf '%s\n' "$line"
|
|
case $line in
|
|
"top - "*)
|
|
t=0
|
|
for h in /sys/class/hwmon/*; do
|
|
[ "$($C/cat "$h/name" 2>/dev/null)" = coretemp ] || continue
|
|
for f in "$h"/temp*_input; do
|
|
v=$($C/cat "$f" 2>/dev/null || echo 0)
|
|
[ "$v" -gt "$t" ] && t=$v
|
|
done
|
|
done
|
|
read -r rx tx < <(${pkgs.gawk}/bin/awk '$1 == "eno1:" {print $2, $10}' /proc/net/dev)
|
|
if [ -n "$prev_rx" ]; then
|
|
printf '@STAT temp=%s rxbps=%s txbps=%s\n' "$((t / 1000))" "$(( (rx - prev_rx) / 2 ))" "$(( (tx - prev_tx) / 2 ))"
|
|
fi
|
|
prev_rx=$rx
|
|
prev_tx=$tx
|
|
;;
|
|
esac
|
|
done
|
|
'')
|
|
# Instant-answer backend for the quickshell launcher on the desktops:
|
|
# `printf '%s' "question" | ssh mediaserver qs-ask`. The Anthropic key
|
|
# lives here and nowhere else, so the desktops hold no credential and
|
|
# there's a single place to rotate it.
|
|
#
|
|
# Reads the question from stdin — passing it as an ssh argv element would
|
|
# send it through the remote shell for a second round of word splitting.
|
|
#
|
|
# Haiku 4.5 is the cheapest model and ample for a one-line factual
|
|
# answer: ~$0.0005 a query at $1/$5 per million input/output tokens. No
|
|
# `thinking` and no `effort` — `effort` errors on Haiku 4.5, and a
|
|
# one-sentence fact needs no reasoning tokens.
|
|
#
|
|
# Set the key up once (fred's own file, no sudo needed):
|
|
# mkdir -p ~/.config/anthropic
|
|
# printf '%s' sk-ant-... > ~/.config/anthropic/api-key
|
|
# chmod 600 ~/.config/anthropic/api-key
|
|
#
|
|
# Prints nothing on a missing key, a failed call, or an UNKNOWN reply —
|
|
# the launcher reads silence as "fall back to Wikipedia".
|
|
(pkgs.writeShellScriptBin "qs-ask" ''
|
|
q=$(${pkgs.coreutils}/bin/cat)
|
|
[ -z "$q" ] && exit 0
|
|
key_file="$HOME/.config/anthropic/api-key"
|
|
[ -r "$key_file" ] || exit 0
|
|
key=$(${pkgs.coreutils}/bin/tr -d '\n' < "$key_file")
|
|
[ -z "$key" ] && exit 0
|
|
|
|
# jq builds the body, so a question containing quotes or backslashes
|
|
# can't break out of the JSON string.
|
|
body=$(${pkgs.jq}/bin/jq -n --arg q "$q" '{
|
|
model: "claude-haiku-4-5",
|
|
max_tokens: 300,
|
|
system: "Answer the question in one or two short sentences, under 240 characters. Lead with the specific fact asked for, including units. No preamble, no caveats, no markdown, no follow-up offers. If you do not know, or the answer depends on live data you do not have, reply with exactly: UNKNOWN",
|
|
messages: [ { role: "user", content: $q } ]
|
|
}')
|
|
|
|
ans=$(${pkgs.curl}/bin/curl -sf --max-time 10 https://api.anthropic.com/v1/messages \
|
|
-H 'content-type: application/json' \
|
|
-H "x-api-key: $key" \
|
|
-H 'anthropic-version: 2023-06-01' \
|
|
--data-raw "$body" \
|
|
| ${pkgs.jq}/bin/jq -r '[.content[]? | select(.type == "text") | .text] | join(" ")')
|
|
|
|
# UNKNOWN, a refusal (empty content), or any transport error: stay quiet
|
|
# so the launcher's Wikipedia path answers instead.
|
|
case "$ans" in ""|null|UNKNOWN*) exit 0 ;; esac
|
|
printf '%s' "$ans"
|
|
'')
|
|
];
|
|
|
|
# Basic networking
|
|
networking.useDHCP = lib.mkForce false;
|
|
|
|
# Allow fred to act as a remote Nix builder (trusted users can import
|
|
# unsigned store paths sent by the build client).
|
|
nix.settings.trusted-users = [ "root" "fred" ];
|
|
|
|
# Automatic daily system updates
|
|
system.autoUpgrade = {
|
|
enable = true;
|
|
flake = "git+https://forg.gregersen.it/rope/nixos";
|
|
dates = "05:15";
|
|
allowReboot = true;
|
|
};
|
|
|
|
# WAN exposure is controlled by nftables in services/router.nix +
|
|
# ports.toml (networking.firewall is disabled on this host).
|
|
services.openssh = {
|
|
enable = true;
|
|
settings = {
|
|
PermitRootLogin = "no";
|
|
PasswordAuthentication = false;
|
|
};
|
|
};
|
|
};
|
|
}
|