The journalctl recipes, in both READMEs, with the parts that are not obvious from the man page: - quote the unit glob -- zsh expands `llm-router*` itself and errors - `-p warning` now filters, because the service emits journald priority prefixes when systemd owns its stderr; before, every line was PRIORITY 6 and severity could not be filtered at all - `--grep ' id=r9116d9'` pulls one request's every stage; `--grep 'chatcmpl-'` pivots from an energy_observations row back to the decision that made it - the drop-in for LLM_ROUTER_LOG_LEVEL=debug, so turning a running service up does not mean editing a tracked file And one thing that will otherwise waste an evening: the oneshot units buffer. poller.py and seed_energy.py print progress with plain print(), and Python block-buffers stdout when it is not a terminal, so a sweep that takes minutes prints nothing until it exits. PYTHONUNBUFFERED=1 on those units fixes it; noted rather than applied, since it only matters if you are watching. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
171 lines
7.3 KiB
Markdown
171 lines
7.3 KiB
Markdown
# Deploying the router
|
|
|
|
Three units. The dispatcher runs continuously; the poller runs on a timer and
|
|
is **not** optional — `freshness.stale_after_days` is 3 and
|
|
`freshness.exclude_stale` is true, so a catalog that goes unpolled for three
|
|
days marks every row stale and the router stops returning any candidate at
|
|
all.
|
|
|
|
| file | what it does |
|
|
|---|---|
|
|
| `llm-router.service` | the FastAPI dispatcher, on `127.0.0.1:8080` |
|
|
| `llm-router-poller.service` | one-shot: `poller.py` then `tier.py` |
|
|
| `llm-router-poller.timer` | fires the poller 2 min after boot, then every 2 h |
|
|
| `llm-router-seed.service` | one-shot: a small `seed_energy.py` reference sweep |
|
|
| `llm-router-seed.timer` | every 6 h — energy attribution drifts with pool load across hours, so the median has to span time rather than one sweep |
|
|
|
|
These are **user** units — no root, and they run as you with your own
|
|
`$HOME`. The tradeoff is that a user service does not inherit your shell
|
|
environment, so the API key has to come from a file.
|
|
|
|
## Install
|
|
|
|
```bash
|
|
# 1. The key. User units don't see your shell env, so .env is required.
|
|
cd /path/to/this/repo
|
|
echo "NEURALWATT_API_KEY=$NEURALWATT_API_KEY" > .env && chmod 600 .env
|
|
|
|
# 2. Install, pointing the units at wherever you actually cloned this.
|
|
# The shipped units say %h/llm-router; %h is systemd's expansion for your
|
|
# home directory, so only the part after it needs changing. Getting this
|
|
# wrong fails at start with status=200/CHDIR rather than anything obvious.
|
|
REPO=$(pwd)
|
|
mkdir -p ~/.config/systemd/user
|
|
for u in deploy/llm-router*.{service,timer}; do
|
|
sed "s|%h/llm-router|${REPO}|g" "$u" > ~/.config/systemd/user/"$(basename "$u")"
|
|
done
|
|
systemctl --user daemon-reload
|
|
systemctl --user enable --now llm-router.service llm-router-poller.timer llm-router-seed.timer
|
|
|
|
# 3. Survive logout/reboot (user units stop with your session otherwise)
|
|
loginctl enable-linger "$USER"
|
|
|
|
# 4. Check. The health endpoint reports whether cost/eco/proficiency
|
|
# actually have data behind them, which is otherwise silent.
|
|
curl -s localhost:8080/health | python -m json.tool
|
|
systemctl --user list-timers 'llm-router*'
|
|
```
|
|
|
|
## Operating it
|
|
|
|
```bash
|
|
systemctl --user status llm-router.service
|
|
journalctl --user -u 'llm-router*' -f # everything, live (quote the glob)
|
|
journalctl --user -u llm-router -f -o cat # the request log, message only
|
|
journalctl --user -u llm-router -p warning # fallbacks, retries, refusals
|
|
journalctl --user -u llm-router-poller.service # catalog refreshes
|
|
systemctl --user restart llm-router.service # after editing config.yaml
|
|
systemctl --user start llm-router-poller.service # force a refresh now
|
|
```
|
|
|
|
`config.yaml` is read once at startup, so weight and threshold changes need a
|
|
restart. The catalog is read per-request, so a poller run takes effect
|
|
immediately.
|
|
|
|
### Turning up the logs
|
|
|
|
`logging.level` in config.yaml is the documented setting, but flipping it means
|
|
editing a tracked file. For a running service use a drop-in instead:
|
|
|
|
```bash
|
|
systemctl --user edit llm-router # Environment="LLM_ROUTER_LOG_LEVEL=debug"
|
|
systemctl --user restart llm-router
|
|
```
|
|
|
|
`info` gives one `route` and one `dispatch` line per request — category, tier,
|
|
model chosen, cost, latency. `debug` adds every candidate that was dropped and
|
|
by which filter, plus the ranking with scores. No conversation text is logged
|
|
at any level.
|
|
|
|
Every line carries a trace id, and the `dispatch` line carries the provider's
|
|
completion id and the session fingerprint, both of which are columns in
|
|
`energy_observations`:
|
|
|
|
```bash
|
|
journalctl --user -u llm-router --grep ' id=r9116d9' # one request, all stages
|
|
journalctl --user -u llm-router --grep 'chatcmpl-abc123' # from a DB row back to its decision
|
|
```
|
|
|
|
Severity filtering works because the service prefixes its lines with journald
|
|
priorities when systemd owns its stderr (`SyslogLevelPrefix` is on by default).
|
|
A foreground `uvicorn` prints them clean, so the same binary is readable either
|
|
way.
|
|
|
|
**The oneshot units buffer.** `poller.py` and `seed_energy.py` print progress
|
|
with plain `print()`, and Python block-buffers stdout when it is not a
|
|
terminal, so their output arrives in one dump at exit rather than
|
|
progressively. Add `Environment="PYTHONUNBUFFERED=1"` to those units if you
|
|
want to watch a sweep as it runs.
|
|
|
|
## A note on the bind address
|
|
|
|
`--host 127.0.0.1` is deliberate. The service holds a billable API key and
|
|
has **no authentication of its own** — anything that reaches it can spend
|
|
your allowance. `ProtectHome=read-only` plus a `ReadWritePaths` exception for
|
|
the repo limits the blast radius on the filesystem, but nothing limits spend.
|
|
Putting this on a LAN address needs an auth layer first.
|
|
|
|
## Using an Ollama on another machine
|
|
|
|
The local LLM does the classifying; it does not have to be on the machine you
|
|
are typing on, and usually the GPU isn't. Most developers already have
|
|
WireGuard or a VPN back to a home lab, so the normal shape is router and
|
|
editor on the laptop, Ollama on the workstation.
|
|
|
|
On the **serving** host (the one with the GPU):
|
|
|
|
```bash
|
|
sudo mkdir -p /etc/systemd/system/ollama.service.d
|
|
sudo cp deploy/ollama-over-vpn.conf \
|
|
/etc/systemd/system/ollama.service.d/override.conf
|
|
# set OLLAMA_HOST to that host's own VPN address — `ip -4 -o addr show`
|
|
sudo nano /etc/systemd/system/ollama.service.d/override.conf
|
|
sudo systemctl daemon-reload && sudo systemctl restart ollama
|
|
```
|
|
|
|
On the **client** host, in `config.yaml`:
|
|
|
|
```yaml
|
|
classifier:
|
|
base_url: "http://<vpn-ip>:11434/v1"
|
|
verification:
|
|
base_url: "http://<vpn-ip>:11434" # same host, so `model` can stay null
|
|
```
|
|
|
|
Both must move together. `verification` speaks Ollama's native `/api/chat`
|
|
and used to derive its URL from the classifier's; it no longer does, so
|
|
pointing only the classifier across the tunnel leaves the verifier talking to
|
|
a `localhost` Ollama that may not exist. Config load refuses the combination
|
|
where `verification.model` is null and the two hosts differ, because that
|
|
failure is otherwise silent — the verifier 404s, catches it, and records no
|
|
sample while appearing to be enabled.
|
|
|
|
Bind Ollama to the **VPN address, not `0.0.0.0`**. It has no authentication of
|
|
any kind: anything that reaches the port can run inference, enumerate your
|
|
models and pull new ones. Same reasoning as the dispatcher's loopback bind
|
|
above.
|
|
|
|
A cloud endpoint works too — set `classifier.api_key_env` to the env var
|
|
holding its key. On a five-prompt comparison NeuralWatt's `deepseek-v4-flash`
|
|
classified in 1.02s mean against `qwen3.5`'s 11.58s on an RTX 6000. Local
|
|
inference is not free, it is unbilled.
|
|
|
|
## Pointing opencode at it
|
|
|
|
The repo-local `opencode.json` sets this up already, so running `opencode`
|
|
from inside a clone of this repo uses the router by default. To use it from
|
|
anywhere, merge the `provider.llm-router` block into
|
|
`~/.config/opencode/opencode.json` and set `"model": "llm-router/auto"`.
|
|
|
|
Two model names:
|
|
|
|
- `llm-router/auto` — normal routing; flex rows excluded, so nothing gets
|
|
held server-side during peak
|
|
- `llm-router/auto:batch` — admits flex rows, for overnight/async work
|
|
|
|
`limit.context` is declared as 782324, the largest effective window in the
|
|
routable catalog. The router hard-filters on the measured conversation size,
|
|
so a prompt too big for the smaller models simply won't be routed to them;
|
|
if it fits nothing, `/v1/chat/completions` returns a 422 naming the
|
|
constraint rather than truncating.
|