Compare commits

...
282 Commits
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 3d3c96e422 Displace the newest lowest-band request when the gateway queue is full
Release / build (push) Successful in 1m9s
CI / test (push) Successful in 3m50s
Release / notes (push) Successful in 3s
A total bound across bands (admission.queue_capacity_total, default half of
all bands' capacity together); reached, a request is not refused while a
lower band has requests queued, and the newest of the lowest band gets a 503
with Retry-After instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:07:53 +02:00
Ilker ManapandClaude Opus 5.5 623cdb5ba3 Sign releases, and install only signed ones once a key is trusted
CI / test (push) Successful in 4m6s
ed25519 over checksums.txt: llmgw release keygen makes the pair, CI signs
with the RELEASE_SIGNING_KEY secret when it is set, and release.Install
refuses an unsigned or wrongly signed release when the build embeds a
trusted key (internal/release/trusted.pub, empty for now) or
LLMGW_RELEASE_KEY names one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:03:48 +02:00
Ilker ManapandClaude Opus 5.5 561667c9be Name only the replicas that would make room in headroom
CI / test (push) Successful in 3m58s
Candidates tried on the way to finding no room were listed as whose replicas
a placement would stop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:01:40 +02:00
Ilker ManapandClaude Opus 5.5 cf0178bf05 Test that a restart borrows a lower class's room and returns it
CI / test (push) Successful in 3m41s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:47:12 +02:00
Ilker ManapandClaude Opus 5.5 f288f3c423 Set capacity quotas, class, owner and reservation from the admin pages
Release / build (push) Successful in 1m9s
CI / test (push) Successful in 3m49s
Release / notes (push) Successful in 3s
The quotas page gains a capacity section over the same API methods the CLI
uses; a model's card sets its class, owner, memory and cores, applying only
what changed. Headroom counts the replicas a model could get by preempting
what its class outranks, and the card and ctl model target use it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:40:45 +02:00
Ilker ManapandClaude Opus 5.5 5cf0003e74 Release v0.10.5
Release / notes (push) Successful in 3s
Release / build (push) Successful in 1m12s
CI / test (push) Successful in 3m53s
Collects rc.1 to rc.14: the mirror page in tabs with a card per model,
one line per record in every admin table, reserved CPU cores and required
memory, capacity classes and preemption, tenant capacity quotas, sizing by
measured throughput, and node sensor readings. Adds `llmgw upgrade` for the
mirror host, which `ctl upgrade` does not reach, and says so when it ends.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:26:19 +02:00
Ilker ManapandClaude Opus 5.5 d10fad064e Mark facility steps 1-5 deferred; node sensors collected
CI / test (push) Successful in 3m45s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:25:50 +02:00
Ilker ManapandClaude Opus 5.5 cd10e086c8 Read nodes' heat and power: GPUs, CPUs, board inlet, RAPL
Release / build (push) Successful in 1m10s
CI / test (push) Successful in 3m51s
Release / notes (push) Successful in 3s
Every agent samples, at most every ten seconds: nvidia-smi's temperature,
HBM temperature, power draw, enforced limit and throttle reasons per GPU;
hwmon's CPU package and board inlet temperatures; and CPU package power
from RAPL's energy counters. Sent with heartbeats, published as metrics
and shown on the node page; unmeasured sensors are absent, never zero.
Observe-only, step 0 of docs/facility-aware-placement.md, which now
describes the agent source and what these readings can and cannot say.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:49:08 +02:00
Ilker ManapandClaude Opus 5.5 8724dc027a Plan facility-aware placement: rack power, temperature and cooling
CI / test (push) Successful in 3m41s
A design document for step 5 of resource allocation, deferred: location
labels and rack spreading, power budgets with estimated then measured draw,
thermal and cooling conditions with hysteresis, opt-in shedding through
preemption, pluggable facility sources, and a rollout that is useful at
each step and safe when data is missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:25:31 +02:00
Ilker ManapandClaude Opus 5.5 e7a8105aa4 Log a preempted replica's stop with the grace it had
Release / build (push) Successful in 1m7s
Release / notes (push) Successful in 2s
CI / test (push) Successful in 3m47s
It said "stopping a drained replica without a replacement after=5m0s" when
it had been given the 30-second preemption grace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:41:43 +02:00
Ilker ManapandClaude Opus 5.5 d0e9813ce9 Of equally idle victims, try the smallest first
Release / build (push) Successful in 1m11s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m55s
Idle times are unknown after a control plane restart, which left the choice
between a 1-core and a 4-core replica to map order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:23:08 +02:00
Ilker ManapandClaude Opus 5.5 0e73f37128 Stop only the replicas that make the room
Release / build (push) Successful in 1m4s
CI / test (push) Successful in 4m32s
Release / notes (push) Successful in 3s
Candidates were added in order until the model fitted, and every one added
was stopped, including those tried first that freed room on the wrong node.
A set that works is now pruned to the replicas it needs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:21:58 +02:00
Ilker ManapandClaude Opus 5.5 78e41460f9 Scale and place by what a replica was measured to serve
CI / test (push) Successful in 4m24s
Release / notes (push) Successful in 3s
Release / build (push) Successful in 1m8s
Stored ctl bench results reach the reconciler: requests per replica past
the measured SLO concurrency scale a model up, a replica is taken back only
if the rest stay under 70% of theirs, and among profiles of equal priority
the most measured tokens per GPU or core is tried first. ctl model target
sizes a model for a throughput against the room there is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:21:08 +02:00
Ilker ManapandClaude Opus 5.5 9dc1b62fdc Guarantee and limit capacity per part of the tenancy tree
Release / build (push) Successful in 1m8s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m51s
A model has an owner (org, team or project). Quotas guarantee a scope
GPUs, cores and memory and may limit it; what a scope holds is summed from
its models' replicas and those beneath it. A replica within its owner's
guarantee is not taken for another scope; what is held beyond a guarantee
is borrowed and taken back for a scope within its own. A limit stops a
model before it starts, and headroom stops there too. ctl quota and ctl
model owner set them; metrics and a CapacityAtLimit alert report them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:15:38 +02:00
Ilker ManapandClaude Opus 5.5 ac24e204a6 Add capacity classes and preempt lower ones to place a model
Release / build (push) Successful in 1m7s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m46s
A model's class (critical, standard, opportunistic; unset follows its
scaling) decides what may be stopped to make room for it: a lower class
for a higher, and within a class only what another grew into under load,
for a model below its minimum. Victims are tried on a copy of the
inventory and stopped only if they make the room. Preempted replicas are
drained with a short grace and their model is held back until the room is
used. ctl model resources and class move under ctl model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:02:59 +02:00
Ilker ManapandClaude Opus 5.5 990d82d801 Reserve CPU cores and require memory for CPU models
Release / build (push) Successful in 1m5s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m47s
A profile's cpu_cores is reserved out of its node's CPUs by placement, as
memory is, and becomes the engine's thread count. Agents report their CPU
count in registration and heartbeats. New CPU models must declare memory;
model views show reserved against measured memory with warnings, and
ctl model resources sets both for a stored model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:43:13 +02:00
Ilker ManapandClaude Opus 5.5 26151ec25a Format web.go
Release / build (push) Successful in 1m4s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:02:32 +02:00
Ilker ManapandClaude Opus 5.5 235bf33dcd Keep every admin table to one line per record
CI / test (push) Failing after 31s
Release / notes (push) Successful in 4s
Release / build (push) Successful in 1m4s
Headers never wrap, free text is clipped with the whole on hover, full-width
rows are only notes and forms. The cluster page hides columns empty on every
row and shortens node names; People shows two memberships and a count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:02:18 +02:00
Ilker ManapandClaude Opus 5.5 7099043c75 Keep each held row on one line
Release / build (push) Successful in 1m8s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m53s
The replica count, its nodes and the mode selector sit side by side instead
of stacking in a fixed-width cell, and node names drop the prefix every node
shares, with the full name on hover.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:50:02 +02:00
Ilker ManapandClaude Opus 5.5 a32ea0e5ab Say on each held row how its model runs and where
Release / notes (push) Successful in 3s
CI / test (push) Successful in 4m0s
Release / build (push) Successful in 1m8s
Ready of wanted, how many are starting, and the nodes, from the fleet data
the card already reads, so the list answers it without a click.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:38:49 +02:00
Ilker ManapandClaude Opus 5.5 d7f1c1d545 Open a held model to its replicas, nodes and room for more
Release / build (push) Successful in 1m6s
CI / test (push) Successful in 3m48s
Release / notes (push) Successful in 3s
The Held tab's model name opens a card: each replica with its node, state,
age, CPU and memory, the room for more found by dry-running the scheduler
on a copy of the inventory, and a form for the always-running and
under-load replica counts. An always-running count placement cannot meet
is refused with the reason. Headroom is also on the admin API.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:19:31 +02:00
Ilker ManapandClaude Opus 5.5 2ae12ae1fe Split the mirror page into Held, Explore and Fetch tabs
CI / test (push) Successful in 3m45s
Release / notes (push) Successful in 3s
Release / build (push) Successful in 58s
One long page made every visit wait on Hugging Face for a list it might not
show. Each tab now has its own address and does only its own work; only
Held reloads while a download runs, and a refused fetch returns to the form
it came from.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 08:31:28 +02:00
Ilker ManapandClaude Opus 5.5 62657f4c2a Release v0.10.4: CPU threads, tracing, and the rest of the review
Release / notes (push) Successful in 3s
CI / test (push) Successful in 4m57s
Release / build (push) Successful in 4m11s
CPU replicas start on half the machine's CPUs, not all of them: two busy
llama.cpp servers each spinning on all 8 vCPUs fell to 0.8 tok/s apiece,
where at 4 threads they keep 5.5. llamacpp.threads sets it per node.

Inference requests can be traced over OTLP/HTTP (tracing.endpoint), with
admission, schedule and upstream spans and traceparent sent to the engine.

Security: onboarding no longer puts the cluster root in the system trust
store and removes it where it was; the mirror's mutual-TLS API can be
limited to named certificates (--api-client); init.log no longer keeps the
administrator's password; a warning when auth is off on a public listen;
the recorded actor is sanitised and sudo-aware; Ray's dashboard is off, and
the remaining engine gaps are documented.

reconciler.go and agent.go are split by topic, the status mapping of each
failure is tested, and the plan's checklist is brought up to date.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 04:00:35 +02:00
Ilker ManapandClaude Opus 5.5 adb1b3cf2a Add replicas under load, up to a model's max, and take them back when quiet
Release / notes (push) Successful in 2s
CI / test (push) Successful in 3m48s
Release / build (push) Successful in 1m2s
A model's max never did anything: desiredFor returned the minimum, or one
for an on-demand model someone was asking for, whatever the queue. Six small
CPU nodes run out of slots quickly, and a model with room to grow stayed at
its minimum while requests waited.

The router already scrapes each engine's running and waiting requests. The
control plane now reads them per model on every tick, alongside when each
replica was last used, and the reconciler keeps how many replicas load has
added above a model's base. One or more requests waiting per ready replica,
held for a minute, adds one; ten minutes with nothing waiting removes one of
those again. It never goes above max or below the base, moves one step at a
time, and does not add while the last addition is still placing or starting
— on this fleet a replica is minutes of weights and gigabytes of memory, and
a spike should not leave a pile of replicas behind it. Decisions are logged
with the load behind them.

Nothing live changes until a model's max is raised above its min.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:18:07 +02:00
Ilker ManapandClaude Opus 5.5 bd12b0909a Serve a model from the registry with one command
CI / test (push) Successful in 3m50s
Release / build (push) Successful in 1m8s
Release / notes (push) Successful in 3s
Adding a model took four steps and a YAML file — plan it, fetch it into the
mirror, write its catalog entry with a memory figure somebody estimated,
scale it — and whether it would load was found out at the end.

`llmgw ctl model serve OWNER/REPO` does it as one job in the control plane,
where the registry, the mirror, the catalog and the fleet already are:

- the registry: the GGUF file set matching --quant (Q4_K_M by default) or
  --file, leaving out vision projectors and importance matrices, and naming
  what the repository does have when nothing matches; gated repositories are
  refused;
- the mirror: fetched at the repository's revision, with progress, and each
  file's sha256 compared with what the registry published — the mirror is
  the one place a corrupted download is caught once rather than loaded on
  every node;
- the check: whether the nodes' llama.cpp loads the architecture, and the
  memory it needs from its own header;
- the catalog, then a wait for a replica that stops early, with the reason,
  when the model is parked or fits on no node;
- a smoke test: one short request to the replica, with its engine key, and
  the answer and tokens per second.

ctl starts the job and prints each step as it changes; a download of hours
does not depend on the terminal. --on-demand adds the model without starting
a replica. Jobs are kept in memory until the control plane restarts.

The catalog name a repository suggests moved into the registry package, so
the mirror page and this agree on it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:05:13 +02:00
Ilker ManapandClaude Opus 5.5 116e75f8cb Upgrade the cluster with one command, over the agents' own streams
Release / build (push) Successful in 1m5s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m42s
Every upgrade so far has been a script of mine copying a binary to each
machine over ssh, restarting it and polling until it came back — which is
the job, but not something the product could do, and which needed ssh into
every node.

`sudo llmgw ctl upgrade VERSION` on the control plane does it. This machine
first: the release for its platform, checked against the release's
checksums, the old binary kept beside it, llmgw-server restarted, and a wait
until every node that was ready has reconnected. Then each node in turn: the
control plane tells its agent the version over the stream it already holds;
the agent fetches and checks the release itself, swaps it in with a rename,
answers, and exits with status 75 so systemd starts the new binary. Engines
keep running, as for any agent restart, and are adopted again.

A node is done when it is back on the new version, ready, with as many ready
replicas as before; one that is not within the timeout stops the run before
the next node, so a bad release reaches one machine. Nodes already on the
version are skipped, so running it again resumes.

The fetch-and-verify is one package now, shared with onboarding's idea of
what a release version looks like.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 02:54:36 +02:00
Ilker ManapandClaude Opus 5.5 3bf4b7b709 Tell clients how much of their rate limit is left
CI / test (push) Successful in 3m49s
Release / notes (push) Successful in 3s
Release / build (push) Successful in 1m4s
SDKs and editors pace themselves from OpenAI's x-ratelimit-* headers, and
the gateway sent none: only Retry-After, and only on the refusal they would
have avoided. A limit can be set at several levels of the tenancy tree, each
counted in its own pool, so the headers report the one with least left —
the one that will refuse first — after this request's own charge, on
refusals and successes alike. The windows slide, so the reset figure is the
next minute boundary, when a whole window has moved on. A limit that is not
set is not advertised.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 02:42:21 +02:00
Ilker ManapandClaude Opus 5.5 4a652fdcc1 Size a llama.cpp model's memory from its own shape, not its file size
The serve form asked for weights plus half again plus two gigabytes. That
ignores the one thing that varies most: the KV cache. At the same 16k pool,
the models on the live mirror need from 0.44 GB of cache (Qwen2.5-1.5B, two
KV heads) to 5.25 GB (Gemma-2-9B, 256-wide heads) — so the flat guess
overcounted the small models, which on 7 GB nodes means refusing placements
that fit, and undercounted the ones with wide or many KV heads, which means
an engine killed for memory.

The GGUF header already carries the shape. The reader now also takes each
architecture's block_count, head counts, embedding length and key/value
lengths, reading past general.* until the tokenizer — and a header whose
tokenizer comes first and runs past the fetched bytes still returns what it
has rather than failing. The estimate is weights, plus the f16 KV cache for
the context pool (llama.cpp's --ctx-size, which --parallel divides rather
than multiplies), plus a gigabyte and five percent for the engine. It reuses
the header the servability check already fetched, and the form says what it
is made of. Checked against all twelve GGUF models on the live mirror, whose
shapes read back as their published architectures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 02:40:00 +02:00
Ilker ManapandClaude Opus 5.5 f8572fb5ee Follow one request from its id to the replica that served it
"Why was this request slow?" had no answer. Nothing tied a request to the
replica that served it, the time admission held it went out in a response
header and was never kept, and the only per-request log lines were for
disconnects, early stream ends and upstream retries.

Every request now has an id: the client's X-Request-Id when it is a plain
string of reasonable length, otherwise one the gateway makes. It is set on
the response before anything else, so a request refused as malformed can be
named too, and forwarded to the engine. One access log line per request
carries it with the model asked for and served, the replica, status, time in
the queue, time to the first token, total time, tokens, and the key,
project and user.

Usage records gain the id, the replica, queue time and time to first token,
tried against a restored copy of the live store before being shipped: 591
rows kept, columns and index in place. `llmgw ctl request ID` reads one back
through the admin API.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 02:36:45 +02:00
Ilker ManapandClaude Opus 5.5 91fe24a105 Close what a second security review found still open
Release / notes (push) Successful in 4s
CI / test (push) Successful in 3m50s
Release / build (push) Successful in 1m8s
A review of v0.10.4-rc.4, and a look at the live network, found:

- Alertmanager listening on every interface: from any node its API took
  silences, with no login. Bound to loopback on the monitoring host, and the
  monitoring README now installs it that way; Prometheus beside it is its only
  client, and one Alertmanager needs no cluster port.
- Login and signup unbounded in memory. Each attempt runs argon2 with 64 MiB,
  and nothing limited how many ran at once; the per-address throttle kept one
  entry per address ever tried. Hashing now takes one of a few slots and
  waits for one otherwise. The throttle expires entries whose window has
  passed when it fills, and if it is still full it refuses new addresses:
  evicting live ones instead would let a flood of made-up addresses reset the
  throttle on the address actually being guessed.
- Metric labels taken from the request. A refused request counted the model
  name it asked for, so any key holder could mint a series per request.
  Requests are counted under the catalog's name, or "unknown".
- Two in code written this week. A SQLite backup made its snapshot with the
  default mode in the listable backup directory, a full copy of the store
  readable while the backup ran; it is now made in a private directory, and
  the unit sets UMask=0077. And archive links were checked by name only, so
  "a -> ." then "a/a/b -> ../.." passed and a file written through them landed
  above the install directory, as root. Archives now unpack through os.Root,
  and a link may only point at a file beside it — which every llama.cpp
  b10991 archive, CPU and CUDA, was checked to follow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 02:25:07 +02:00
Ilker ManapandClaude Opus 5.5 9bf8256577 Keep backups where the control plane can see them
Release / notes (push) Successful in 2s
CI / test (push) Successful in 3m35s
Release / build (push) Successful in 1m1s
The first backup on the live control plane was written, and the control
plane reported none: /var/backups/llmgw already existed, made by an earlier
move to PostgreSQL for its own dumps, owned by postgres and 0700. MkdirAll
leaves an existing directory as it is, so the archive landed where the
llmgw user could not list it, and llmgw_backup_last_success_timestamp_seconds
said 0.

Opening that directory up would have exposed the dumps already in it, so
backups get their own, /var/backups/llmgw-server. And `llmgw server backup`
now warns when the directory it wrote to is one the control plane cannot
list, rather than leaving that to be found from an alert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 01:50:33 +02:00
Ilker ManapandClaude Opus 5.5 1da4a2e1ef Back up the store and the certificate authority, and alert when it stops
Release / build (push) Successful in 1m1s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m32s
There were no backups: docs/install.md gave a pg_dump command, and nothing
ran it. The store holds every key, person, quota and usage record, the models
added at runtime, and now the secret each engine's key is derived from; the
certificate authority's key sits in /etc/llmgw/pki beside it. Losing the disk
lost all of it.

`llmgw server backup` writes both to one archive: pg_dump's custom format, or
a VACUUM INTO snapshot for SQLite, plus /etc/llmgw. The archive holds the CA's
key, so it is 0600; the directory is listable, so the control plane — not
root — can see when the newest was written. pg_dump is given the connection
through its environment, since a DSN carries the password and a process's
arguments are readable by every user. Written beside its name and renamed,
so an interrupted backup is never the newest; the newest 14 are kept, and
nothing the command did not write is ever removed.

llmgw-backup.timer runs it nightly and catches up after downtime. The control
plane publishes the newest archive's time, and BackupTooOld fires past 26
hours — or when there has never been one. The install guide now covers
restoring, and says to copy archives off the machine, since they share its
disk.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 01:44:32 +02:00
Ilker ManapandClaude Opus 5.5 b661bfead8 Wait for what tests check instead of sleeping and hoping
CI / test (push) Successful in 3m23s
CI's test job has failed intermittently — two of three runs on some days —
while every run here passed, including six repetitions on one CPU under the
race detector with eight packages at once. The difference is a shared,
loaded Linux runner, and the tests most exposed to it are the ones that
sleep a fixed time and then assert:

- admission: idle admission bounded at 50ms of wall clock against a 1ms
  tick, and a queued request checked for after a 50ms sleep;
- scrape: a 10ms scrape timeout, which on a slow runner reports the engine
  unreachable and then the violation a second time, in a test that counts
  one;
- supervisor: log lines, three lines of output and a removed state file
  each checked after a fixed sleep, and an exited worker bounded at 1.5s;
- agent: a heartbeat checked for after four intervals.

Each now waits for its condition up to a deadline of seconds, and the two
wall-clock bounds are set against what they exist to distinguish — the
admission queue's tick raised to ten seconds, the exited worker bounded
below the five-second grace it must not wait out — rather than against how
fast this laptop is.

Without the CI logs this cannot be proven to be the failure; these are the
tests that fail when the machine is slow, and they no longer do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 01:36:55 +02:00
Ilker ManapandClaude Opus 5.5 37a80a9b44 Park a model that fails every time it starts, and say so everywhere
Release / build (push) Successful in 1m3s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m34s
On the live cluster kev-0.5b — an architecture the nodes' llama.cpp did not
know — was started 155 times in eight hours, failing the same way each time,
and phi-4, which fitted on no node, logged "no placement" 250 times in two
days. Neither showed up anywhere an operator looked: the backoff never gave
up, no metric said a model was failing or unplaceable, and the one alert for
a model with nothing serving could not fire, because its series was published
only while a replica was ready.

After five failures in a row a model is parked: not placed again until its
definition changes — a new source or profile — or `llmgw ctl retry` is run.
Status, the model list and the admin API show it with its last error. The
placement warning is logged when its reason changes or every ten minutes.

A request for a parked or unplaceable model is answered at once with why,
rather than "loading; retry shortly", which a client retries forever. Each
request still clears the previous placement result and triggers a fresh
attempt, so a model that fits once room is made is not refused on a stale
answer; the router waits for that attempt, not for its full activation
window.

Metrics now cover model health (desired, ready at 0 for what should run,
consecutive start failures, parked, unplaceable), engine installs per node,
leadership, and certificate expiry read from disk at each scrape, with alerts
for each. `ctl status` counts a model against what it should have now, so an
on-demand model that was asked for and fitted nowhere no longer reads as
satisfied.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 01:21:57 +02:00
Ilker ManapandClaude Opus 5.5 5e1b6ecea0 Read a queued change as a success in ctl
ctl accepted only 200, so the first real `ctl restart --all` printed
"Error: 202 Accepted" over a request that had queued nine restarts, all of
which then happened. Any 2xx is now a success, and its body is read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 01:14:12 +02:00
Ilker ManapandClaude Opus 5.5 5dac1854c2 Publish small fixes as release candidates of the next version
Release / build (push) Successful in 1m1s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m30s
Every fix so far became its own patch release, and the natural alternative —
v0.10.3-a — sorts before v0.10.3 by SemVer's rules: a hyphen marks a
pre-release, so every tool that orders versions would take the fix for the
older one. A release candidate of the next version, v0.10.4-rc.1, sorts
after v0.10.3 and before v0.10.4, which is what it is.

CI already builds any v* tag. Its release notes now come from a
"## Unreleased" section in CHANGELOG.md, where changes gather until the
version they belong to is released, and the release is marked as a
pre-release. Onboarding, in Go and in the shell script, counts a candidate
as a release and downloads it rather than copying the operator's binary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 01:00:11 +02:00
Ilker ManapandClaude Opus 5.5 ad6d2d348d Restart replicas without taking them out of service first
There was no way to restart a running replica. A running engine picks up an
engine key, an edited profile or a new engine version only when it starts,
so after v0.10.3 the nine replicas already running stayed without a key —
reachable from any node — with nothing to do about it short of draining
nodes, which on a small CPU fleet has nowhere to move a model to.

`llmgw ctl restart REPLICA... | --model M | --all` queues replicas to be
replaced. A replica being replaced keeps serving but stops counting toward
its model, so the reconciler places a replacement beside it; once that is
ready the old one is drained and stopped as a node drain would. Replicas of
one model go one at a time, so a model never needs the memory of every
replica twice. Where no second copy fits anywhere, the old replica is
stopped after the drain grace period and the new one starts in its place,
and the log says so, since that is the one case with an outage.

`ctl replicas` shows "(restart queued)" and "(restart replacing)" while it
happens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 00:59:26 +02:00
Ilker ManapandClaude Opus 5.5 611fddd236 Describe v0.10.3
Release / build (push) Successful in 1m4s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m39s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 00:38:55 +02:00
Ilker ManapandClaude Opus 5.5 9853936e0b Give every engine a key only the router holds
Engines listen on every address their node has, with no key. Checked on the
live cluster: from a neighbouring node, a request straight to an engine's
port was answered — past the gateway's authentication, quotas and
accounting, by anyone who could reach the node.

Each replica's engine is now started with a key and refuses requests without
it. The key is derived from the replica id with an HMAC over a secret kept
in the store, rather than stored per replica: the router has to send the
right key to replicas this process never started — adopted after a restart,
or placed by the other control plane — and a function of a shared secret
gives that without a table to keep in step. A key taken from one node opens
that replica only. The secret is made on first use and copied with the rest
of the store when moving to PostgreSQL; a new one would lock the router out
of every running engine at the moment of the move.

The key reaches llama.cpp and vLLM through their environment
(LLAMA_API_KEY, VLLM_API_KEY) rather than their command line, which every
user on the node can read; SGLang only takes it as an argument. The agent's
readiness check, the router's proxy and its metrics scrape send it, the
client's own Authorization still never reaches the engine, and `ctl bench`
fetches the key of the replica it is pointed at from the admin API.

Engines started before this keep running without a key until their replica
is next started. With no store there is nowhere to keep the secret, and
engines start without one, with a warning.

Also formats two files that went in unformatted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 00:38:29 +02:00
Ilker ManapandClaude Opus 5.5 d167686cf1 Stop a browser page, or a model definition, from running code on the nodes
The admin listener has no login. Reaching it is meant to mean holding the
control plane, so it binds to loopback and an operator reaches it over an
ssh tunnel — and so does every page open in that operator's browser. The
API decoded JSON whatever the Content-Type said, so a page could POST a
text/plain model definition, no preflight needed. A model's profile env
reached an engine the agent runs as root, unfiltered: LD_PRELOAD in a
definition was code execution on every node. A page that rebound its own
name to 127.0.0.1 could also read pprof's heap, which holds API keys.

The admin listener now answers only when Host is an address or localhost,
refuses requests a browser marks as cross-site (Origin, Sec-Fetch-Site),
and takes changes only as application/json, which no page can send across
origins without a preflight nobody grants. ctl sends that on every change,
bodiless DELETEs included; curl and ctl are otherwise unaffected.

Profile env that changes what runs rather than how — the dynamic loader's
LD_*, PATH, Python's PYTHON* start-up variables, BASH_ENV and their like —
is refused when a model is defined, and dropped again by the engine adapter
before the process starts, whatever sent the profile. None of the live
catalog's models sets any.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 00:29:52 +02:00
Ilker ManapandClaude Opus 5.5 97dc49cbeb Keep the mirror's management API and its downloads off the network behind it
Checked on the live cluster: from any node, the mirror's management API on
:8082 answered — list, fetch, delete — with no credentials. Unset
--api-allow meant anyone. It now means this machine only; the control plane
is let in with --api-allow, which `server init` already wrote for the
mirrors it sets up, or by mutual TLS.

A fetch went to whatever URL it was given, and the file server on :8081
then handed the result to anyone: "fetch http://169.254.169.254/..." read a
cloud's instance metadata through the mirror, and a URL on the LAN read
whatever answered there. Downloads started through the API now go to http
and https only, and are refused at dial time — after resolution, on every
redirect — when the address is loopback, private, link-local, CGNAT or
multicast. --allow-private-sources lets them through for a mirror that
really does copy from a server on its own network. `llmgw mirror add`, run
by someone on the mirror itself, is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 00:27:27 +02:00
Ilker ManapandClaude Opus 5.5 3a26277c32 Refuse names that leave their directory, on the mirror and the agent
Two places built a path from a name somebody else supplied and checked only
for slashes.

The mirror refused "a/b" as a model name but took "..", which is a single
path element too: a fetch for model ".." wrote beside the mirror root rather
than inside it. Model, version and file names now have to be one element
that does not start with a dot — the mirror's own checksum files are dotted
— on the fetch path and the API alike.

The agent took the engine version from the control plane as given and made
a directory of it under /opt/llmgw/engines, and a RemoveAll before replacing
one. A version of "../x" would have done both outside the install root, as
root. The agent now installs only versions that look like one — letters,
digits and ._+- — whatever the control plane sent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 00:24:48 +02:00
Ilker ManapandClaude Opus 5.5 7062645bcb Describe v0.10.2
CI / test (push) Failing after 4m10s
Release / build (push) Successful in 3m45s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 22:37:28 +02:00
Ilker ManapandClaude Opus 5.5 bb90a927aa Print only what must outlive init's screen, and drop bubbles
Once server init's screen closed, the whole run was printed again under the
frame that had just shown it. Now the password and the next steps are, the
whole run goes to /var/lib/llmgw/init.log (0600: it holds the password), and
it is printed in full only when init fails. Seen in a draft of the install
video, where it scrolled the screen away.

The spinner and the text field are written here instead of taken from
bubbles, whose packages query the terminal's colours as they load. bubbletea
does the same deliberately, so the query stays; the dependency goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 22:37:28 +02:00
Ilker ManapandClaude Opus 5.5 49c446271c Describe v0.10.1
Release / build (push) Successful in 1m1s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m39s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 22:17:33 +02:00
Ilker ManapandClaude Opus 5.5 b5f412f9df Put the mirror on its own machine in two commands
mirror init sets a machine up as the model mirror — the user, /srv/models,
and a unit serving files on :8081 and the management API on :8082 — and
server init --mirror points the control plane at it instead of running one.
Before, that took a unit written by hand and an edit to server.yaml.

The management API can fetch and delete models and answered anyone who
could reach it. mirror serve --api-allow limits it to the addresses given
and the machine itself; mirror init passes the control plane's. The unit
text is shared with the one server init writes for a mirror beside it.

With --no-node the starter model is still fetched and catalogued, unpinned,
so it starts on the first node added, and the init form shows the mirror in
use instead of offering to run one.

Tried on three empty VMs: the mirror answered the control plane and refused
the node, and the starter model came up on the node when it was added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 22:17:24 +02:00
Ilker ManapandClaude Opus 5.5 a1b5daceb8 Let server init ask for what it needs, and node add take several nodes
In a terminal and without --admin, server init shows a short form: the
administrator's email, the other names nodes will use (with the addresses
it found), SQLite or PostgreSQL, and the mirror, node and starter model.
Its own lines then fill in as a list — read from the writer init already
uses, so nothing in init changed — with downloads updating in place and the
password in a box until the end. The whole run, password included, is
printed again when the screen closes, so it is in the terminal's history.
The last screen says how to add a node and to back up the authority's key.

node add takes several addresses and adds them in turn, with a summary.
Suggested fabrics carry what the adapters report them to be, so a name
given for one node is used for the next on the same fabric without asking.

Tried on empty VMs: init from the form through to the starter model
serving, and two nodes added to that cluster in one command. The first
attempt at the form lost its keystrokes to the terminal colour query in a
pseudo-terminal that never answers it; a real terminal does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:54:18 +02:00
Ilker ManapandClaude Opus 5.5 7071c67487 Show which requests each node's agent answers
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:31:05 +02:00
Ilker ManapandClaude Opus 5.5 7bb360fc78 Measure two nodes' fabric rail by rail: llmgw ctl fabric-test
doctor checks a node alone, and every check can pass while two nodes still
cannot reach each other. ctl fabric-test A B asks B's agent for an
ib_write_bw server and A's for the client, adapter by adapter as NCCL pairs
them, one rail at a time, and grades each against its link's rate. It goes
through the agents' streams, so there is no SSH between nodes; the agent
installs perftest the first time, and names the RoCE v2 GID itself.

The result is read from perftest's table by its heading, not a column
number. Agents now list the requests they answer beyond the usual
(Register.features), and the control plane refuses a test on one too old to
answer instead of waiting minutes for it. The forwarding dispatcher carries
the new capability, and its test says so: a missing forward would have made
the command quietly unavailable in production.

Not yet run over real InfiniBand or RoCE; this cluster has neither.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:29:23 +02:00
Ilker ManapandClaude Opus 5.5 af6f8e023e Describe v0.10.0
Release / notes (push) Successful in 3s
CI / test (push) Successful in 4m24s
Release / build (push) Successful in 3m41s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:06:42 +02:00
Ilker ManapandClaude Opus 5.5 590e5b9f3e Ask a node only for engines it can install; doctor says when AVX2 is hidden
A control plane of this release asks every node for vLLM. An agent of the
last one does not know how to install it, and answered "unsupported", so a
cluster half way through its upgrade showed every node as failing. Agents
now say which engines they can install (Register.installable_engines), and
are asked for no others; an agent that says nothing is asked for llama.cpp
alone, as it always was.

doctor reports the CPU's vector extensions, and warns when AVX2 is missing,
with the fix for a VM: the test node for node add was one, on QEMU's default
CPU model, where llama.cpp runs several times slower and nothing else looks
wrong. node add shows it, since it runs doctor.

The changelog's unreleased section was two releases' worth of sections one
after the other, with two lists of fixes and the upgrade notes in the
middle. It is one entry now: largest changes first, the fixes together, and
every upgrade note at the end, with vLLM's default install and how to keep
your own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:03:03 +02:00
Ilker ManapandClaude Opus 5.5 2a16c26575 Add a node with one command: llmgw node add, with a terminal screen
Adding a node was deploy/bootstrap/onboard-node.sh, which is not installed
anywhere and wanted the node's name, the control plane's address and a
dozen flags. It is now in the binary, run on the control plane:

    sudo llmgw node add ubuntu@10.0.0.21

The node id is the machine's hostname, and the address its agent dials is
this machine's on the route to it, checked against the server certificate.
It installs llmgw there (the release for its architecture, fetched and
checked by the node; uname rather than dpkg, so RHEL works), runs doctor
there and stops on a problem before changing anything, issues and installs
the certificate, writes agent.yaml and the unit, starts the agent, and waits
for it to join and install llama.cpp. The authority is read first, so a
missing one is said before the node is touched.

The steps are events. In a terminal they draw a checklist (bubbletea) that
asks what a script cannot: which fabric each group of active adapters is on,
grouped by the subnet manager or RoCE network they report, and whether to go
on past a problem. Elsewhere, one line per step.

Found by running it on an empty VM: ssh's "Permanently added" warning on
stderr was read as the machine's hostname, so standard error is now kept
apart; and a node added again while cordoned never counted as joined, so
joining now means a heartbeat since the restart.

llmgw node remove forgets a lost node, which otherwise stays listed until
the control plane restarts. ctl nodes no longer lists vLLM as skipped on
every CPU node. The quick install, install and onboarding docs use it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:33:47 +02:00
Ilker ManapandClaude Opus 5.5 c8da06988f Add a quick install guide: six steps from empty servers to a model
install.md explains every choice and is long for someone who wants the
commands. quick-install.md has only those, and what to check after each:
the binary, server init, onboarding GPU nodes (with doctor first and the
script fetched from the release tag, since it is not installed anywhere),
a model from the mirror's explorer, keys and editors, and Caddy. Every
button and path it names was checked against the pages. quickstart.md is
now said to be the from-source, one-workstation path it is; README and the
about page point at the new guide.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 18:13:05 +02:00
Ilker ManapandClaude Opus 5.5 4c5a2fd6da Install vLLM on every GPU node that can run it, as llama.cpp is
vLLM was installed by hand and named in each agent's configuration. The
agent now installs it: a Python 3.12 environment built by uv, which is
itself a GitHub release checked against its checksum, from PyPI resolved as
it stood a day after that vLLM was published and installed with
--require-hashes. The resolved list is kept in the environment. The
environment is built relocatable under a scratch name and renamed into place
once it runs, like an archive.

The default is 0.30.0, whose flags were checked against the ones llmgw
builds. It is built for CUDA 13, so it needs driver 580 and a Turing GPU or
newer, and 25 GB free while it installs; a node lacking one says which, and a
node with no GPU skips it without being shown as a fault. gcc comes with it
because Triton compiles its launcher at the first model load. SGLang
installs the same way when the control plane names a version. llmgw doctor
reports whether vLLM can be installed on the node.

Checked by installing 0.30.0 this way on a test node: 7.7 GB, torch 2.13 for
CUDA 13.0, importing cleanly; the node has no GPU, so vLLM was not started.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:15:54 +02:00
Ilker ManapandClaude Opus 5.5 e751c89937 Serve the project's website at /about, with its fonts in the binary
The website was a file built by hand outside the repository. It is now a
template in internal/web: every gateway serves it at /about, without a key,
linked from the landing page's footer, and the published site is rendered
from the same template.

The fonts are vendored (Latin subsets, OFL) so the page sends nobody's
browser to Google. The proxy configurations it shows are copies of
deploy/proxy, kept equal by a test. A development build says so instead of
linking to release files that do not exist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:44:45 +02:00
Ilker ManapandClaude Opus 5.5 80e0dd252f Document what changed since v0.9.2, and fabrics for the sites that have them
The changelog's unreleased section had stopped at refusing unloadable
weights; it now covers the rest: telling independent fabrics apart,
their subnet managers and maps, NVLink on every build and across nodes,
the multi-node launch fixes and vLLM's new default, doctor, node load,
editors and the Responses API, the chat and mirror pages, a person's own
use, the admin pages, the new metrics, and how to upgrade.

docs/fabrics.md is new, for a site with InfiniBand or NVLink: what
llmgw does with them, what each node and fabric needs first, declaring
fabrics, checking a node with doctor, what the pages and metrics show
and which to alert on, what an engine is given, and where to look when
a replica spanning nodes will not run. It says plainly that none of it
has yet run on such hardware.

install.md, node-onboarding.md, onboarding.md, the README and the
example agent configuration no longer say the portable binary cannot
see NVLink or that a fabric's name cannot be checked, and describe the
account page and connecting an editor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:14:58 +02:00
Ilker ManapandClaude Opus 5.5 f989e67e1a Connect an editor from the account page, and serve the Responses API
The account page makes a key for Continue, Codex or Cline and shows
what the tool needs with the key, this gateway's address and a served
model in it: Continue's assistant file, written to its own place under
~/.continue/assistants so the person's config.yaml is not touched;
Codex's provider and profile, added to ~/.codex/config.toml without
changing its default model; and for Cline, whose settings live in VS
Code's secret storage, the four fields to paste. Continue and Codex get
a one-line command whose script, served without a session, holds no
secret - the key comes from the environment, never a URL - keeps a copy
of whatever it replaces, and leaves the rest of the file as it was.

POST /v1/responses translates the Responses API, which Codex speaks, to
chat completions and back: instructions, messages, function tools and
Codex's free-form custom tools, their calls and outputs, and usage, as a
response object or as its stream of typed events. It goes down the same
path as any request - quotas, admission, routing, usage. Server-side
tools and stored responses have no equivalent and are ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:04:17 +02:00
Ilker ManapandClaude Opus 5.5 b7e8e1a3a1 Let the chat be stopped, timed, tuned and kept across a reload
While a reply streams the Send button is Stop, which aborts the request
- and with it the one to the engine - keeping what had arrived. Under
each reply: its tokens, from the engine's own count (the page's turn now
asks for include_usage), tokens a second, time to the first token and in
all. A settings fold takes a system prompt, sent before every turn, and
a temperature, which the gateway holds to 0-2 whatever the page sends.

The conversation, the prompt and the temperature are kept in the
browser's storage, so a reload puts them back - the model's turns
rendered again by the server as when they came - and "New conversation"
clears them. The gateway still keeps no conversation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:54:23 +02:00
Ilker ManapandClaude Opus 5.5 0823e5d743 Fit the pages to a phone, and drop an empty Fabrics heading
On a phone every admin page was wider than the screen - its tables set
the width - so the browser zoomed the whole page out until nothing on it
could be read. Under 760px a table now scrolls sideways in its own box,
charts stack, form rows fold, and the menus wrap; every page measured at
390px wide now fits.

The cluster page drew a Fabrics heading and its note on a cluster with
no fabric at all.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:24:56 +02:00
Ilker ManapandClaude Opus 5.5 b18c9c3378 Start a mirror download where it was asked for
Fetch on a repository opened in the explorer posted a form, and the
page that came back had closed the repository and lost the list's place.
The page's script now sends it asking for JSON: the outcome appears
under the button, and only the mirror's own table is fetched again,
every five seconds while anything in it is downloading. Without script
the form posts as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:21:21 +02:00
Ilker ManapandClaude Opus 5.5 4dba2a54ea Tighten the People page
An account's "add to" picker and its button sit on one line; suspend
and remove, the two things done least and the two drawn in red on every
row, are in a menu behind "⋯"; and the box for a new project under
every team, and for a new team under every organization, is folded
behind "+ project" and "+ team" until wanted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:18:38 +02:00
Ilker ManapandClaude Opus 5.5 ac7f5063b6 Give every choice-link one pill, and stop the NVLink page breaking them
The usage and organization pages drew their choices as filled green
chips, the newer pages as outlined pills. They are all the outlined
pill now.

The NVLink page's GPU squares were styled as .chip, the class those
choices already used, which shrank the usage page's filters into
1.7rem squares wherever the stylesheet loaded. They are .gpu-chip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:15:44 +02:00
Ilker ManapandClaude Opus 5.5 45e3f52da3 Launch multi-node vLLM its own way by default, not through Ray
An empty launch_mode meant ray: a Ray head and joins on every node, a
dependency to install, and a path never checked against a real cluster.
It now means mp - every node runs vllm serve with its rank and the
leader's address, the workers headless - which is how vLLM documents a
multi-node launch today and needs nothing but vLLM. ray is still there
for a vLLM too old for mp's flags. The example agent configuration,
doctor and the reference say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:13:59 +02:00
Ilker ManapandClaude Opus 5.5 20479a2d5f Find multi-node NVLink domains, place across them, and show them as racks
A GB200 NVL72 rack or an NVLink Switch system is a scale-up island: its
GPUs reach each other over NVLink whatever node they are in. Each GPU
now reports its fabric from nvidia-smi -q - state, status, ClusterUUID,
CliqueId, bandwidth - at registration and once a minute after, since a
fabric manager restarting drops every card out at once; and each node
reports nvidia-imex and its channels, without which the domain's GPUs
cannot share memory across nodes. A single machine's NVSwitch, which
reports a cluster of zeros, is no domain.

A replica spanning nodes is placed inside one domain first, on nodes
whose every card is joined and whose IMEX is up, and its shards are
told NCCL_MNNVL_ENABLE=1; InfiniBand is the fallback.

/admin/nvlink draws each domain as a rack - the NVLink Switch band on
top, light running down a backplane, trays of GPU chips that glow when
joined, pulse red when not, turn amber on degraded bandwidth - and
lists what keeps a tray out. A node's page shows its GPU-by-GPU NVLink
matrix, each card's NUMA node and nearest adapter, and its domain and
IMEX. doctor checks joining, bandwidth, IMEX and its channel; metrics
llmgw_gpu_nvlink_joined and llmgw_imex_up; /api/v1/nvlink.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:07:59 +02:00
Ilker ManapandClaude Opus 5.5 5bbc619a31 Map every InfiniBand fabric, draw it, and place replicas by it
The control plane asks one node on each physical fabric to run
ibnetdiscover every five minutes and parses the text itself, so a
format the parser has not met is a control plane upgrade, not one on
every node. From the map it works out the switch tiers (leaf, spine,
core, by distance from the adapters), which adapter is which node's,
which cables trained below the rate the rest of their kind run at, and
whether the rails line up: every node's Nth adapter on its own leaf.

A page per fabric (/admin/fabrics) draws it: switches in their tiers,
adapters fanned out under their leaves, cables coloured by rate and
bundled, degraded ones marching red with a marker, and the cluster's
own adapters live - down ports glowing red, and particles running each
cable as fast as its adapter is sending and receiving, from the
counters. It zooms, pans, fades what a hovered switch or adapter does
not touch, opens a panel of detail on a click, colours by rail, finds a
node, and refreshes every fifteen seconds. The data is also at
/api/v1/fabrics/{id}/topology.

Placement reads the map: a replica spanning nodes takes nodes of one
leaf together when it can, and nodes whose cable trained short last.

Static assets are named by their content, as the stylesheet already
was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:46:23 +02:00
Ilker ManapandClaude Opus 5.5 23648fa19a Watch each fabric's subnet managers, and keep a fabric one through a failover
A fabric's identity was its master subnet manager, which changes on a
failover: for the minute its ports took to agree, one fabric read as
two, and a name covering it as two separate fabrics. Each agent now
also lists every subnet manager on a fabric (saquery SMIR, through one
port per master, once a minute); managers listed together are one
fabric, named by the lowest GUID among them, so the identity survives
the master changing.

The same list says what a fabric can survive. The cluster page's new
subnet manager table, /api/v1/fabrics, doctor and metrics
(llmgw_fabric_master_sm, llmgw_fabric_subnet_managers,
llmgw_fabric_problems) show each fabric's master, priority and
standbys, and say when a fabric has no standby, its ports disagree on
the master, ports are up with no subnet manager (INIT), or the master
changed in the last hour. Every change of master a port reports is
kept, and a fabric's last day of them is listed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:47:03 +02:00
Ilker ManapandClaude Opus 5.5 77fe801892 Tell independent fabrics apart by their subnet manager, not their name
A fabric was what agent.yaml called it. With several independent
InfiniBand fabrics at a site, two given one name would have had a
replica spread across both, starting and then hanging on its first
collective. Each active InfiniBand port now reports the master subnet
manager it sees (sminfo, asked once a minute), which every port on one
fabric shares and ports on separate fabrics cannot; a RoCE port reports
the IP network of its RoCE v2 address. Ports also carry their node and
port GUIDs.

Placement divides a declared fabric by that physical identity and forms
a replica on one part only; a node whose adapters for one name are on
two fabrics is refused, and a node that cannot say is kept apart from
those that can. /api/v1/fabrics and the cluster page list each physical
fabric with its subnet manager, and say when one name covers two
fabrics, two names one fabric, an adapter is up but undeclared, or a
node cannot be checked. doctor reports each port's subnet manager, and
an agent on a node with adapters installs infiniband-diags when sminfo
is missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:29:16 +02:00
Ilker ManapandClaude Opus 5.5 51f1606b53 Resolve the management address from the servers list too
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 05:07:15 +02:00
Ilker ManapandClaude Opus 5.5 1b1ca67e35 Add llmgw doctor: what a node's GPUs, NVLink and fabric need, checked
For machines nobody here can log into. It reads the GPUs and their
nvidia-smi topology, NVSwitch and the fabric manager, every InfiniBand
and RoCE port with its counters and RoCE v2 GID, the RDMA and
GPUDirect kernel modules, the agent's declared fabrics against the
adapters, the management interface NCCL will bootstrap over, the
agent unit's memlock limit and the engines installed - and says what
is wrong and how to fix it, most serious first, with the NCCL
environment a shard on each fabric would be given. --json writes every
fact it concluded from, to send back. It exits non-zero when something
fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 03:51:09 +02:00
Ilker ManapandClaude Opus 5.5 4d9284ce9f Find each node's management interface, and lift the agent's memlock limit
The agent's defaults said eth0 before normalize looked for the interface
holding the management address, so it never looked: a node on ens3 or
bond0 had NCCL and Gloo bootstrap over an interface it does not have. It
is now found, with eth0 only as the last resort.

The agent unit sets LimitMEMLOCK=infinity. RDMA pins what it registers,
and under systemd's 8 MB default an engine without the capability that
lets root ignore the limit fails its first collective over InfiniBand.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:44:59 +02:00
Ilker ManapandClaude Opus 5.5 3ba36e23a6 Choose a shard's GPUs by NVLink, nearness to the fabric and NUMA
GPUs were chosen by growing one card's NVLink peers, which took cards
each joined to that one but not to each other, and knew nothing of the
fabric. Every set of the right size is now scored - a node's handful of
cards makes that cheap - by NVLink pairs within it, then for a
multi-node shard by how near each card is to one of the fabric's
adapters (PIX before SYS), then by NUMA nodes spanned, with the lowest
indices breaking ties. A node without topology still gets the lowest
indices.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:19:52 +02:00
Ilker ManapandClaude Opus 5.5 9ee21dc814 Fix what a multi-node launch would have got wrong on real hardware
vLLM's mp launch gave workers no rank and the leader's address as their
own VLLM_HOST_IP; every node now runs serve with --nnodes, --node-rank,
--master-addr and --master-port, and the workers --headless, as vLLM
documents it.

NCCL_SOCKET_IFNAME and GLOO_SOCKET_IFNAME were the control plane's one
interface name for every node; the agent already worked out its own -
the one holding its management address - and now uses it.

On RoCE, NCCL's default GID index is a v1 link-local entry on most hosts,
which does not route. The agent finds the RoCE v2 entry holding an IPv4
address and passes it as NCCL_IB_GID_INDEX; InfiniBand gets none.

A worker whose only process needs the leader - a Ray join - started
without waiting for it, since the wait was honoured for sidecars alone;
it found no head, exited and failed the replica. It now waits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:17:31 +02:00
Ilker ManapandClaude Opus 5.5 93bd16f00e Watch InfiniBand ports while the agent runs, not only when it starts
A port's state was read once, at registration: one that went down later
left its node on the fabric, offered multi-node work that would hang on
its first collective, until somebody restarted the agent. The agent now
re-reads every IB and RoCE port on each heartbeat - state, physical
state, rate and the error and data counters - and when the fabrics it
can carry traffic on change, says so once; the control plane replaces
the node's fabrics, which is what placement reads.

The ports are exported as llmgw_ib_port_active (with state and
phys_state, so an unplugged cable reads differently from a missing
subnet manager), llmgw_ib_port_rate_gbps and llmgw_ib_port_counter_total,
and a node's page lists them with the fabric each carries and any error
counter that is not zero.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:13:54 +02:00
Ilker ManapandClaude Opus 5.5 bf2d171e89 Read NVLink and GPU-to-adapter distance from nvidia-smi topo
The release binary is built without cgo, so without NVML, and NVML was
the only source of NVLink peers: on every NVLink machine the portable
agent reported none, and tensor-parallel groups were chosen by GPU index.
Discovery now reads `nvidia-smi topo -m` after either path, for the
NVLink peers (kept from NVML where it gave them), each card's NUMA node,
and how far each network adapter is from it - PIX, PXB, PHB, NODE or
SYS - which travels to the control plane as nic_paths for placement to
use. Both the NICn-with-legend layout of newer drivers and the
adapter-named columns of older ones are read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:09:26 +02:00
Ilker ManapandClaude Opus 5.5 22bb098247 Group the mirror's table by model family, not by exact name
Grouped by name, the four qwens in the mirror were four groups of one
and the table looked unchanged. A family is the letters a name starts
with - qwen2.5-coder-7b and qwen3-8b are qwen, llama-3.1-8b is llama -
and a family of two or more models gets a heading with how many and how
much disk; a family of one stays a plain row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:44:55 +02:00
Ilker ManapandClaude Opus 5.5 06ea02dd57 Group what the mirror holds by model
Each model is a heading - how many versions, how much disk, and whether
one is downloading, failed or in use - with its versions beneath it.
Sorted by state alone, versions of one model were scattered down the
table around unrelated downloads. A model with a download or a failure
still comes first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:39:44 +02:00
Ilker ManapandClaude Opus 5.5 7c5938effe Keep node names on one line in the node table
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:31:36 +02:00
Ilker ManapandClaude Opus 5.5 9ae526302b Label chart axes in binary sizes and small percents, and windows in words
A bytes axis read "20.0BB" - compact's billion with a B after it - and
a quiet node's CPU axis read 0% all the way up. Sizes are now binary like
every other on these pages, a percent under ten keeps a decimal, and a
window of an hour is "the last hour" rather than "1h0m0s".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:30:01 +02:00
Ilker ManapandClaude Opus 5.5 fe0875ee05 Say why an engine's memory can exceed its node's RAM in use
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:26:54 +02:00
Ilker ManapandClaude Opus 5.5 daed835d88 Measure each node's CPU and memory, and give every node a page
The nodes are CPU machines, and nothing measured how busy one was: the
heartbeat carried GPU memory and disk, and llmgw_node_memory_bytes is the
total. The agent now reads /proc between heartbeats - CPU across all
cores, load averages, memory used and available, and each engine's
process group's cores and resident memory - and sends it with the
heartbeat. Nothing new runs on the nodes and Prometheus needs no new
target: the control plane exports it with the rest of the fleet, as
llmgw_node_cpu_utilization_percent, llmgw_node_cpus, llmgw_node_load,
llmgw_node_memory_used_bytes, llmgw_node_memory_available_bytes,
llmgw_shard_cpu_cores and llmgw_shard_memory_bytes. A measurement more
than a minute old is left out rather than repeated.

The cluster page's node table shows CPU and RAM as bars, and each node
links to a page of its own: current CPU, RAM, load and disk, the engines
on it with their cores and memory, and an hour of CPU, RAM, load,
per-model cores and memory and queue depth from Prometheus. It refreshes
in place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:21:23 +02:00
Ilker ManapandClaude Opus 5.5 b06fb9f51f Lead the cluster page with its health and refresh it in place
Capacity, nodes, models and replicas come first, then a rule, then
traffic, people, what is being run and usage. The people and per-model
bar charts, which repeated the tables under them, are gone.

The page swaps its content every few seconds with htmx instead of
reloading, so a reader keeps their place, and it stops while the tab is
hidden. Without script the meta refresh still reloads it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:56:27 +02:00
Ilker ManapandClaude Opus 5.5 1f27935570 gofmt the account page's handler
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:52:32 +02:00
Ilker ManapandClaude Opus 5.5 16a1e8b4d8 Show a person their own use and what is left of their budgets
The account page (was "Your keys") shows the last thirty days of the
person's own use - requests, tokens, spend and tokens per day - and, for
each place they can spend, what they used there and a bar for every
budget that applies: their own, a per-member one, or a project's, team's
or organization's shared with others, named as such. Bars turn amber past
four-fifths and red when spent. It sits under the keys now, above the
password form.

Spend is read from the usage table for the pool each limit counts
against, from the start of its period.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:51:13 +02:00
Ilker ManapandClaude Opus 5.5 6b1f8b0827 Render a finished chat reply as markdown
A reply streams as text as before; once it ends, the server renders it
with the model card's rules - raw HTML dropped, dangerous links emptied,
images as links - and the page places that. Headings, lists, tables and
code blocks read as they were written.

Maths outside code is set aside before the markdown, so its underscores
and asterisks are not taken for emphasis, and marked for the page to
typeset. KaTeX no longer searches the reply for delimiters, which took
"$5 and $10" for a formula. A reply that cannot be rendered stays
plain text, with the old search.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:44:18 +02:00
Ilker ManapandClaude Opus 5.5 9b663eb23c Name people and projects in usage reports, and total a usage legend
The cluster page's usage table showed session:usr-... and proj-... ids;
it now shows the person's email, whether it came through a key or the
chat page, and the project's path or "personal". Times read "3 hours
ago" rather than "3h7m25s ago", everywhere.

The usage page's legend gave each line's latest slice, which was 0
beside a table of hundreds; it gives the window's total, and the table's
numbers are right-aligned like everywhere else.

The PostgreSQL placeholder rewriting skips line comments. An apostrophe
in one opened a string that swallowed every placeholder after it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:37:21 +02:00
Ilker ManapandClaude Opus 5.5 ff86e52126 Open a repository beneath its row in the mirror's listing
Clicking a repository in the explorer shows its card and files in a row
beneath it, fetched from /admin/mirror/repo, and a second click closes
it; the list stays where it was. The link still leads to the
repository's own page without script or with a modifier key.

A repository's files now take one line each, and an importance matrix
published beside the quantisations is marked as not a model rather than
offered for fetching.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:11:38 +02:00
Ilker ManapandClaude Opus 5.5 3fea39feb9 Show a Hugging Face repository's model card on the mirror page
Opening a repository shows its model card, rendered from markdown, with
its base model, license and languages above it; the listing names each
repository's base model and license under its name. The card is a
stranger's text: raw HTML and dangerous links are dropped, images are
shown as links rather than loaded, and relative links point into the
repository.

Adds github.com/yuin/goldmark for the markdown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:03:19 +02:00
Ilker ManapandClaude Opus 5.5 02fa639657 Open the mirror's registry on what is current, not an empty search box
The mirror page lists Hugging Face's trending models without anything
typed, with views for what is new this month and what is most
downloaded, filters for task and size, and each repository's parameter
count, architecture, context length and age from one registry call.
"Only what these nodes can run" is on by default and leaves out
architectures the nodes' llama.cpp cannot load, and adapters, saying how
many it hid. Listings are remembered for ten minutes.

The stylesheet is now linked by a hash of its content. It is cached for
a day, so under a fixed name a deploy's new styles reached people a day
after its markup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:56:58 +02:00
Ilker ManapandClaude Opus 5.5 5aa5675a9c Show a model loading in the chat, and send once it is up
A message to a model that was not running waited 45 seconds and came
back as "the model is loading; retry shortly". The page now asks for
the model with /chat/wake, shows where it stands from
/chat/model-status - waiting for a node, downloading with bytes and a
percentage, starting the engine - and sends the message when it answers.

Also passes the nodes' llama.cpp build to the pages, which the mirror's
checks for loadable weights needed and were never given.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:52:31 +02:00
Ilker ManapandClaude Opus 5.5 f13b18149a Refuse weights the nodes' llama.cpp cannot load, before fetching them
kev-0.5b declared architecture "ggmlc", which llama.cpp does not know.
It was fetched, catalogued and placed, and failed on every node — while
the answer was in the first kilobytes of the file.

internal/gguf reads a GGUF header (v1-v3) up to general.architecture,
over HTTP with a Range request, and judges it against the architectures
llama.cpp b10991 lists in LLM_ARCH_NAMES (152, copied from the source at
that tag); a vision projector is not a model on its own. An unknown
build is not refused, only not vouched for, and a header that cannot be
fetched does not refuse anything.

Checked in three places: the registry listing (from the architecture
Hugging Face reports with expand[]=gguf, so one request per repository)
shows it and withholds the fetch button; a fetch reads the header and
refuses unless "fetch anyway"; and the model service refuses to add
such a model, which covers the serve form and ctl model add.

Tried on real files: the mirror's qwen2.5-1.5b (qwen2) and bge-m3
(bert), and Qwen2.5-0.5B on Hugging Face through its redirect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:44:36 +02:00
Ilker ManapandClaude Opus 5.5 c609805e4b Line up how each mirrored model runs
The run mode sat after a tag repeating the model's name, so each selector started where the name ended and long names wrapped the row. The column is now Runs: one fixed-width selector per model, with the catalog's name above it only when it differs from the mirror's. Sizes no longer wrap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:44:36 +02:00
Ilker ManapandClaude Opus 5.5 bf94cd1667 Set how a model runs from the mirror page
Every mirrored version was in use on the cluster, so the serve link — hidden for a version already served — appeared nowhere, and changing a model between always on and on demand needed the CLI. Each model using a file now shows how it runs and offers always on, on demand (15 minutes idle) or stopped, through the model service's Scale; models declared in server.yaml show their mode and where to change it. The serve form asks the same question.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:34:41 +02:00
Ilker ManapandClaude Opus 5.5 d8cefd8b21 Say there are no organizations rather than 404
With every organization removed, /org answered an administrator with a 404 while the menu kept offering it. An administrator now gets a page saying there are none and where to make one, and the menu offers Organizations only when there is one to show (to a manager, one they manage).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:23:19 +02:00
Ilker ManapandClaude Opus 5.5 e493b2f53d Give an organization's managers a page of their own
A member of an organization can be its manager (users join --org X
--role manager, or the star on the people page). /org shows a manager
their organization: usage over time by team, project or person, its
teams and projects with limits and 30-day spend, and its members, each
once with every place they belong. Read-only; administrators see every
organization. Only what was spent in the organization's projects is
shown — UsageBucketsIn filters by where a request was spent.

Found on the way:
- Usage by org, team and project, and the quotas page's spend, joined
  through api_keys and so dropped every chat-page request, which has no
  key row. They now read usage.project_id; keys no longer move between
  projects, so the two agree.
- /org was not exempt from API-key auth and answered browsers with a
  JSON error. A router test now drives the session pages through the
  middleware; it fails without the fix.

Membership queries, org-scoped usage and the reworked joins pass the
PostgreSQL store tests on 16.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:23:12 +02:00
Ilker ManapandClaude Opus 5.5 098886e0e4 Let a person belong to many places, or to none
A person was in exactly one project, chosen at approval, and could not
use the gateway before that. People belong to several teams and to
none, so:

- memberships: a person is a member of any number of organizations,
  teams and projects, or of none; a member of a team or organization
  may use every project in it. users join/leave, and the people page
  adds and takes out from either side. Approval no longer needs a
  project.
- personal keys: a key may have an owner and no project. /profile asks
  what a new key spends against; keys create --for does the same.
  auth.personal sets default limits for personal use or turns it off.
- limits for people: --scope user holds a person across every key,
  project and the chat page; --per-member on an org, team or project
  gives each member their own share there. Resolution folds the tree,
  each per-member limit for the key's owner, and the owner's own, each
  in its own pool; the quotas page shows and sets both.
- the chat page asks what to spend against and checks it per message.
- usage records who spent it, so a person's budget survives a restart,
  personal use and chat included; restore no longer drops rows with no
  project.

Leaving a project revokes the keys made there unless another membership
still reaches it. A personal key keeps no threads or notes, which are
per project, and is refused rather than dropped into the anonymous pool.

Migration 0010 turns each person's project into a membership, adds
usage.user_id and backfills it, and makes api_keys.project_id nullable.
SQLite cannot drop NOT NULL, so the table is rebuilt; that needs foreign
keys off, which only works outside a transaction, so migrations marked
for it run on one connection with foreign_key_check before commit.
Tested on a populated SQLite store with claims, and on a copy of the
cluster's PostgreSQL store: 5 people became members, 17 keys and 3
claims intact, 145 of 577 usage rows and all 69 chat rows attributed.
The PostgreSQL store tests, including new membership cases, pass on 16.

users move is gone; join and leave replace it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:03:46 +02:00
Ilker ManapandClaude Opus 5.5 429c05a319 Show on the quotas page the limits that actually apply
A branch with no limit of its own read "—" in every column while its
organization held it to sixty requests a minute. Each cell now shows
the strictest value from above, muted and marked with an arrow, naming
its source on hover and in the form's placeholders. A larger value set
under a stricter one says what caps it, for rate and token limits as
well as budgets; only budgets were checked before.

The table follows the people page: indented tree, tinted organizations,
spend with a meter that turns red past 90%, a form that opens and saves
without reloading (htmx, falling back to plain links), and the
explanations folded under "How limits combine".

Rows are built from the store's Tree, so a project sits under its team
by id. Matching the start of its path put projects under the wrong team
when two organizations shared a name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:19:49 +02:00
Ilker ManapandClaude Opus 5.5 be9f45aa94 Write the bar chart's latency the way its table does
The ranked bars said "8825 ms" beside a table that said "8.8 s". One formatter, humanLatency, now serves both, with a test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:11:27 +02:00
Ilker ManapandClaude Opus 5.5 c1926be869 Tidy the dashboard and the forms around it
- Numeric column headers are right-aligned like their values; the
  stylesheet only aligned the cells, so "REQUESTS" sat nowhere near its
  numbers in every table that has them.
- Ranked bars are an HTML grid instead of a fixed-width SVG: the value
  had 96 units and "34.2k tok  114 req" did not fit, so the figure a
  reader came for was the one cut off. Labels ellipsise in CSS and give
  their full text on hover.
- Mean latency reads as 5 ms, 8.8 s or 1m 51s rather than 110695 ms.
- The "What the cluster is doing" charts sat between the Models heading
  and the models table; they now come before it.
- A fleet without GPUs shows its CPU capacity — nodes ready, memory,
  disk free for weights — instead of "No GPUs have been reported", and
  a node with no GPUs shows "—" rather than 0/0.
- The profile's forms are a form's width rather than the page's, and
  the quotas page's price warning is a boxed banner rather than a line
  of red text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:08:16 +02:00
Ilker ManapandClaude Opus 5.5 5e1494f76d Give the menu the new token after a password change
The profile page rendered after a password change carries the CSRF
token set on that same response, but the menu's sign-out form was left
without one — and it is the first form on the page. The e2e test that
signs in, changes the password and uses the next form caught it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:57:18 +02:00
Ilker ManapandClaude Opus 5.5 c9cd25efc2 Draw one menu on every page
Each page wrote its own nav: the same links in a different order, some
missing, the current page sometimes listed and sometimes not. A shared
"nav" template now draws it everywhere — Cluster, People, Quotas, Usage,
Mirror for an administrator, then Chat, Your keys and Sign out — with
the current page marked by aria-current. Pages rendered outside the
account pages (the dashboard, node logs) get it through the request
context their admin wrapper sets. The admin listener's own page keeps
its metrics/pprof/api links.

The chat and key pages said "project proj-3kq7…"; they now show the
project's path, and the key page no longer repeats the email.

A test renders several admin pages and requires the same menu on each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:55:36 +02:00
Ilker ManapandClaude Opus 5.5 c22c3a309b Describe v0.9.2
CI / test (push) Successful in 2m14s
Release / notes (push) Successful in 3s
Release / build (push) Successful in 3m1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:50:12 +02:00
Ilker ManapandClaude Opus 5.5 42fa602247 Make a new control plane serve a model by itself
server init now also:
- runs a model mirror beside the control plane (/srv/models, bytes on
  :8081 at an address nodes reach, management API on loopback so it
  needs no certificate) and adds it to server.yaml;
- makes the machine a node of its own control plane: issues its agent
  certificate, writes agent.yaml labelled as the control plane, starts
  llmgw-agent, and waits for it to register and install llama.cpp;
- checks for NVIDIA GPUs with nvidia-smi, and reports a card on the PCI
  bus whose driver does not answer rather than ignoring it;
- fetches Qwen2.5 0.5B (Q4_K_M) into the mirror and adds it to the
  catalog pinned to this node: a llama.cpp profile per GPU class found,
  with a CPU profile as the fallback, then waits for it to serve.

--no-mirror, --no-node and --no-model leave these out. Every step looks
before it acts, so a rerun finishes what an interrupted one started.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:49:55 +02:00
Ilker ManapandClaude Opus 5.5 5501f77805 Describe v0.9.1
CI / test (push) Successful in 2m22s
Release / build (push) Successful in 3m1s
Release / notes (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:37:43 +02:00
Ilker ManapandClaude Opus 5.5 75d69a6b9c Show the mirror one line per model version
Each held file took two table rows — seven columns and its address
beneath — so one sharded model filled the screen. A version is now one
line: model and version, the file or "N files", total size, and the
catalog models that use it. The address is behind a "copy address"
button (a set's is its directory, trailing slash included) and the
checksum is in the file's tooltip.

The model service reports which models name each source, so a file in
use shows who uses it and is not offered for serving a second time;
removing one warns that the catalog uses it. Downloads and failures
stay one line per file, since each has its own progress or retry. The
header counts versions, not files, and the nav calls /admin/users
"people" everywhere.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:36:36 +02:00
Ilker ManapandClaude Opus 5.5 beae60a76e Describe v0.9.0
Release / build (push) Successful in 3m1s
Release / notes (push) Successful in 2s
CI / test (push) Successful in 2m22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:27:54 +02:00
Ilker ManapandClaude Opus 5.5 3104f4b706 Manage people and the tenancy tree, from the web and the CLI
Nothing in the tree could be renamed, moved or removed, and an approved
person stayed in the project they were first put in — from the web or
the CLI. Now:

- /admin/users is the tree, each project opening onto its people and
  keys, with approve, move, suspend, reinstate and remove for people,
  revoke for keys, and add, rename, move and remove at every level,
  plus a search over every account. Forms post and get the page back,
  so it works with no script; htmx 2.0.11 (0BSD, embedded, checked
  against npm's integrity hash) swaps only the changed part, keeping
  open projects open.
- llmgw org tree/add/rename/move/remove, users move/delete, and
  keys list --all.
- Projects, teams and organizations are named by id, path
  (Acme/platform/gateway) or unique name; users by id or email. With a
  single project --project can be left out, instead of defaulting to
  "default", which a tree built from the web does not have.

Moving a person moves the keys they made; operator keys stay with the
project. Only empty things are removed, and never a project that had a
key, since usage history refers to it.

Also: keys init no longer reparents an existing team or project;
reinstating an account with no project is refused (a rejected signup
used to come back active and fail at its first key); rejecting a
signup removes it; keys limits --budget takes whole units like the
quotas page.

Checked in headless Chrome: a move from an open project updates the
page in place, keeps the project open, and the search still filters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:26:39 +02:00
Ilker ManapandClaude Opus 5.5 5141e4b138 Describe v0.8.8
CI / test (push) Successful in 2m26s
Release / build (push) Successful in 3m1s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:47:55 +02:00
Ilker ManapandClaude Opus 5.5 a764cfacbc Find the PostgreSQL a machine has, rather than assume its names
server init --postgres failed on Rocky with postgresql-setup not found.
The PostgreSQL project's packages (PGDG) provide "postgresql-server",
so dnf can satisfy that name with postgresql16-server — whose unit is
postgresql-16.service, whose tools are in /usr/pgsql-16/bin and whose
setup script is postgresql-16-setup. The code assumed the distribution's
names for all three.

The server unit is now picked from systemd's postgresql* unit files, its
data directory read from the unit's own PGDATA, and initdb run directly
as postgres when that directory is empty, with password logins over TCP.
psql, initdb and the restart use what was found. The unit names and
PGDATA values in the tests are the ones Rocky's and PGDG's packages ship.

Not yet handled: a server initialised with ident for TCP logins, one on a
port other than 5432, and several PGDG versions where the highest is not
the one on 5432.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:47:55 +02:00
Ilker ManapandClaude Opus 5.5 2e0465f10a Describe v0.8.7
CI / test (push) Successful in 2m21s
Release / build (push) Successful in 3m1s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:35:28 +02:00
Ilker ManapandClaude Opus 5.5 cba65f5be7 Move a control plane to PostgreSQL with server init --postgres
Installs PostgreSQL with the machine's own package manager when it has
none (initdb with password auth on the RHEL family, whose default is
ident), creates the llmgw role and database with a generated password
kept in /etc/llmgw/secrets/store-dsn, and checks the address connects.
On a machine already on SQLite it stops the gateway, copies the store
so accounts and keys survive, points auth.store at the new address and
starts it again. A move that fails starts the gateway on SQLite again.

server.yaml is edited as text so its comments survive, and kept beside
it. A PostgreSQL init did not install is not resized: it may serve
something else. It refuses to move one PostgreSQL store into another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:33:03 +02:00
Ilker ManapandClaude Opus 5.5 6dba6bd93a Run a control plane when there is nothing else to run
A configuration with no static endpoints and no models was refused as
having nothing to serve. That is every new cluster: under systemd the
unit restarted every two seconds and nothing ever listened on 8080.

With no endpoints a router-only process has nothing it could serve, so
such a configuration is now a control plane with an empty catalog.
Static endpoints without profiled models remain router-only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 13:06:29 +02:00
Ilker ManapandClaude Opus 5.5 40c3d83110 Describe v0.8.6
Release / build (push) Successful in 2m56s
Release / notes (push) Successful in 2s
CI / test (push) Successful in 2m8s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:55:46 +02:00
Ilker ManapandClaude Opus 5.5 069d334652 Ship Caddy and nginx configurations for the client port
install.md said the client port belongs behind a reverse proxy and gave
nothing to put there. deploy/proxy/ has both, attached to every release.

What they get right is what a first attempt gets wrong: streamed replies
are not buffered, the read timeout outlasts a model's silence before its
first token (nginx's 60s default is a 504), the body limit matches the
router's, and X-Forwarded-Proto and -Host are set by the proxy rather
than passed through — llmgw marks session cookies Secure from the first
and builds the landing page's base URL from the second.

The Caddyfile was validated and run against a streaming upstream: events
arrive as sent, and forged forwarded headers are replaced. nginx uses
`listen ... http2` because `http2 on;` needs 1.25.1 and Ubuntu 22.04 and
Debian 12 ship older.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:55:46 +02:00
Ilker ManapandClaude Opus 5.5 4865a96ec2 Describe v0.8.5
Release / build (push) Successful in 2m57s
CI / test (push) Successful in 2m14s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:44:32 +02:00
Ilker ManapandClaude Opus 5.5 2a381ad669 Set up a control plane with one command
`llmgw server init` does what install.md's steps 2 and 3 asked of the
operator: the llmgw user and directories, a certificate authority, the
control plane's certificate for every address the machine has, a
configuration, the default project, an administrator with a generated
password, and the systemd unit — then starts it and waits for /health.

Every step looks before it acts, so a second run changes nothing, and
--host later reissues the certificate with the old names kept. It never
overwrites server.yaml or an authority, and leaves alone certificates
issued by an authority kept on another machine.

The configuration install.md showed could not start: an empty catalog
without control.enabled is refused as having nothing to serve. The one
init writes sets it, and a test holds that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:44:27 +02:00
Ilker ManapandClaude Opus 5.5 746d4852af Describe v0.8.4
Release / build (push) Successful in 2m56s
Release / notes (push) Successful in 2s
CI / test (push) Failing after 2m6s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:27:47 +02:00
Ilker ManapandClaude Opus 5.5 33e678ab84 Show a node's logs on the admin page
Seeing what a node did meant ssh through a jump host, to a machine whose
address nobody remembers, to read a journal only root may open. When an
engine install failed on a new node today, the reason was one line — and it
was four steps away from the page that said the install had failed.

Each node's row on the cluster page now links to its logs: the agent's own,
and every engine running there, chosen from the list the node itself sends.
The page can follow, refreshing every five seconds, and is on both the admin
listener and /admin. `llmgw ctl logs NODE [--source SHARD] [-n N]` reads the
same.

They travel over the stream the agent already holds, so they need nothing
the control plane does not already have: a request goes down, the lines come
back, and a reply that does not come in ten seconds is an agent too old to
answer. The agent keeps its last 2000 lines in memory as well as writing them
wherever it did before; the engines' last few hundred lines were already kept
and sent only when one crashed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:27:47 +02:00
Ilker ManapandClaude Opus 5.5 a821a05840 Describe v0.8.3
CI / test (push) Successful in 2m13s
Release / build (push) Successful in 2m56s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:14:22 +02:00
Ilker ManapandClaude Opus 5.5 a40fc53e09 Wait for a new machine's own apt, and retry a failed install sooner
Found by building a node from nothing. Its agent tried to install libgomp1
while cloud-init and unattended-upgrades were running apt, and failed with
"Unable to lock directory /var/lib/apt/lists/": DPkg::Lock::Timeout covers
dpkg's lock, not the one `apt-get update` takes on its lists. The node was
correctly kept out of placement and said why, and then sat idle for the ten
minutes until the next pass.

A package command that fails only on a held lock is now retried for up to
ten minutes, reported as "waiting for another apt-get to finish" rather than
as a failure. A pass that does fail is retried after a minute, backing off to
ten.

The weights directory is created when the agent starts rather than by the
first download, so a new node reports its disk from its first heartbeat.

Onboarding takes several control plane addresses, comma separated, so a new
node gets the same failover list as the rest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:14:22 +02:00
Ilker ManapandClaude Opus 5.5 8097a5616d Describe v0.8.2
CI / test (push) Successful in 2m11s
Release / build (push) Successful in 2m56s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 11:50:12 +02:00
Ilker ManapandClaude Opus 5.5 f5d6210b86 Install engines on any glibc system, with its own package manager
The llama.cpp build is published from Ubuntu but is not Ubuntu's in any way
that matters: it needs glibc 2.34 and GCC 12's libstdc++, which Debian 12
and RHEL 10 have and RHEL 9 does not. Refusing every system not called
"ubuntu" turned away the first two for their name. The build is now tried
everywhere, and ldd decides.

What ldd says is made readable. "/lib64/libstdc++.so.6: version
`GLIBCXX_3.4.30' not found" is a system too old for the build, and is
reported as "libstdc++.so.6 is too old (needs GLIBCXX_3.4.30)", once —
rather than as a missing library that is plainly installed.

Packages are named per family — libgomp1 for Debian's, libgomp for Red
Hat's — and installed with whichever of apt-get, dnf, yum or zypper the
machine has, decided from ID_LIKE rather than a list of distribution names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 11:10:06 +02:00
Ilker ManapandClaude Opus 5.5 7d97c6b74f Install a node's engines on the node, instead of trusting what is there
A node ran whatever llama-server somebody had once unpacked on it. Onboarding
checked an engine only when told its path, installed none, and a node with
nothing on it registered as ready. The one time that went wrong it went wrong
invisibly: the binary was there, executable, and could not start for want of
libgomp1, while the node reported healthy.

The control plane now says which engines every node has, at which version —
`engines:` in server.yaml, defaulting to the llama.cpp build this release was
checked against — and each agent converges on it. What only the node can
know, the node works out: the build for its processor and driver (CUDA with
its runtime when an NVIDIA driver is loaded, CPU otherwise), the OS packages
that build needs from its own package sources, and whether the result runs.
Every archive is checked against the sha256 GitHub publishes for it and
unpacked refusing any path or link that leaves the install directory. ldd
names a missing library outright; running `--version` proves the rest. Only
then is a directory marked installed, and it is checked again every ten
minutes, because an unrelated upgrade can remove a library.

An engine is reported only once installed and seen to run, or when the
agent's configuration names it on purpose — never because something happened
to be on PATH. The agent now says when it has probed, so an empty list means
"has none" rather than "too old to say": a node rebuilt from nothing is no
longer a candidate for every model at the moment it can run none of them.
Placement's refusal carries the node's own words, "installing: downloading
llama-b10991-bin-ubuntu-x64.tar.gz (16 MB)", and so do `ctl nodes` and the
cluster page, along with a warning for a CPU without AVX2 — the VM CPU type
that makes everything five times slower and nothing look wrong.

Agents also report their OS, architecture, vector extensions and NVIDIA
driver. Onboarding installs llmgw itself from the CI release for the node's
architecture, checked against the release's checksums, rather than copying
whatever binary the operator's machine had.

vLLM and SGLang are not installed yet: they are Python environments of
several gigabytes, not an archive, and still have to be named in the agent's
configuration.

Also fixes a race on the weights fingerprint, which two heartbeat loops touch
during a failover.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 10:59:59 +02:00
Ilker ManapandClaude Opus 5.5 fa7f90880d Put the changelog on the release page
CI / test (push) Successful in 1m42s
Every release on Gitea was a list of binaries and nothing else. Gitea makes
an empty release for each pushed tag, and the workflow only ever attached
files to it, so what had changed was written in CHANGELOG.md and nowhere a
release was looked at.

A job after the build now copies the tag's section of the changelog onto its
release, and fails if there is none — a version nobody described should say
so where it is seen. Run by hand, it syncs every version the changelog has,
which is how the releases made before this get their notes. The changelog
stays the one place notes are written; this copies it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 10:32:58 +02:00
Ilker ManapandClaude Opus 5.5 f90daa1ee9 Describe v0.8.1
Release / build (push) Successful in 2m57s
CI / test (push) Successful in 2m21s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 10:22:23 +02:00
Ilker ManapandClaude Opus 5.5 20deb22dfa Let a node describe itself instead of being described by hand
CI / test (push) Successful in 1m47s
Every agent file on the cluster named its own node, its own address, the
path to llama-server and two weight directories — different on every node,
kept true by hand, and not true: node05 carried "ram: 32g" beside a measured
31 GB, looked for weights in an empty directory while fetching them into
another, and the other four never looked in theirs at all, so four nodes
reported no disk. Growing a node's RAM would have gone unnoticed, because
memory was read once, at registration.

The agent now finds these out. Its id is the hostname, which is also the
certificate's common name. Its address is the one on the route to the
control plane, which the kernel picks for a UDP dial without sending
anything. llama-server is looked for on PATH and then where release archives
are unpacked. A missing nvidia-smi is a node without NVIDIA cards rather
than a failed discovery — which also cut discovery short before InfiniBand
on every CPU node not told to skip GPUs. Fetched weights land in
/var/lib/llmgw-weights by default, and wherever they land is always looked
in.

Memory is read on every heartbeat, and installed engines are probed again
each minute and sent when they change, so a VM given more RAM or a node
given vLLM becomes a candidate without anyone restarting its agent. The
control plane logs both changes, since "the node now qualifies" is the
answer to why a model placed an hour after it was asked for.

What is written still wins. The agent file shrinks to the control plane's
addresses and the TLS files, the same on every node; onboarding writes only
the overrides it was given. What cannot be found stays configuration: which
fabric an adapter is on, because OpenSM's default prefix is the same on all
of them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 10:13:42 +02:00
Ilker ManapandClaude Opus 5.5 2cae92d113 Offer a model that loads on demand in the chat picker
The picker listed what had an endpoint, and a model at min 0 has none until
somebody asks for it. On this page a message is the asking, so an on-demand
model could never be started from here: mistral-nemo-12b-instruct sat in the
catalog, served by nothing, and absent from the one place someone went to
try it.

Such a model is now offered, marked "not loaded", with a note saying the
first message fetches the weights and starts the engine, and that until it
is up the reply is "the model is loading". A model stopped outright — max 0 —
is still left out, because nothing would load it and offering it would only
produce an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 10:13:42 +02:00
Ilker ManapandClaude Opus 5 b56130ace3 Link the usage report from the cluster page
CI / test (push) Successful in 2m0s
A report nobody can reach from the page they land on is a report nobody
reads. It sits beside accounts, on the client port only — the admin
listener's copy of this page serves pprof and has no session to authorize
anybody.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:12:38 +02:00
Ilker ManapandClaude Opus 5 07809490e3 Show usage over time, per organization, team, project and person
CI / test (push) Has been cancelled
Usage could be read as a total — per key, per user, per model, per group —
and the cluster's own charts covered the fleet's hardware. What nobody could
see was when a group used it. "Research doubled its traffic three weeks ago"
is not a question a column of totals can be asked, and it is the question
somebody asks before a quota conversation.

/admin/usage draws requests, tokens and spend over a chosen window, grouped
by any level of the tenancy tree or by person. Person is not a level of that
tree — one person holds keys in several projects — so the grouping is its
own type rather than store.Scope with a fourth value nothing else could use.

Three things learned from the charts already here. Buckets are gap-filled
per group, because a line drawn straight across an absence claims traffic
that did not happen, and because the chart sums its tail by timestamp and
can only do that if every series shares a clock. Series are ranked by total
rather than by peak — a counter's question is "who used the most", and
ranking by peak would colour the team that made one burst over the team that
ran steadily all week. And the spend chart is replaced by a sentence when no
model has a price, rather than drawn flat along the bottom.

The bucket query groups by ordinal and was planned against the cluster's
PostgreSQL before being believed, with a test alongside the others that run
there: three reports have shipped working on SQLite and failing on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:11:31 +02:00
Ilker ManapandClaude Opus 5 f80eef9fa6 Stop the chat page asking for more tokens than the model allows
CI / test (push) Successful in 1m49s
The page imposes its own ceiling on a turn, so a playground request cannot
occupy a replica for ten minutes. It sent that ceiling regardless of the
model, and a model declaring a smaller max_output then answered every turn
with "max_tokens is 2048, but this model allows at most 1024" — the gateway
refusing its own page, over a number the person never chose and has no way
to change.

The ceiling is the page's to impose on itself and the model's limit is not
negotiable, so it sends the smaller of the two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 00:48:07 +02:00
Ilker ManapandClaude Opus 5 c848b2c73a Offer a model once in the chat picker, not once per name
CI / test (push) Successful in 1m46s
An endpoint advertises every name a client may address it by — the model's
own and all of its aliases — because that is what the router matches a
request against. The picker took those strings as they came, so one model
appeared as several: "coder", "coder-7b" and "qwen2.5-coder-7b-instruct"
were three choices, and choosing between them is a question with no answer.

Each name is now resolved through the catalog and the model listed once,
under its catalog name — the one that also appears in the model list, the
usage table and the logs.

/v1/models still lists the aliases separately, which is deliberate: there a
client is discovering which ids it may send, and every alias is one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 00:24:16 +02:00
Ilker ManapandClaude Opus 5 4535bf362f Price a request by the model that serves it
CI / test (push) Successful in 1m38s
The price table is written against the catalog. The name reaching the
accountant was the one the caller typed, so every request made by an alias
missed the table and fell to the default price — the same request charged
two different rates depending on which of a model's names the client
happened to be given.

With no default price that is zero, which is silent. With one it is a wrong
number, which is worse: a budget spent at the rate of whatever the default
says rather than at the model's own.

The router already resolves the alias, two lines above, to decide whether
the key is allowed the model at all. It now passes the resolved name to the
accountant as well, in the ordinary path and in the budget fallback.

The test charges a real request through an aliased name and compares the
figure with arithmetic it states itself, because the first version of it
asserted "more than the fallback" and passed with the bug in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 00:15:40 +02:00
Ilker ManapandClaude Opus 5 d8fb198aa5 Make the install guide describe the system that exists
Read start to finish as somebody standing up a new cluster, it sent them
down a path we no longer take.

The mirror was absent from all 821 lines, and the page still said that
distributing weights was "a known gap, not an oversight" — so the reader
would have gone away to solve, by hand, the problem the binary in front of
them solves. It is step 4 now, before the nodes, because where a node
fetches from is the difference between minutes and a day, and on a customer
network the nodes often have no route out at all.

Two of the listed gaps had been closed and were still listed: a fetch does
report its progress to the control plane, and there is an HA pattern. A
gap somebody believes in is worse than one they discover.

Added what this week made obvious was missing. Upgrading, including the
zero-byte copy that ssh -n silently produces and the size check that is the
only thing between that and a fleet on a truncated binary. Backing up, in
the order that matters, and the fact that API keys are hashed and do not
come back. Pricing in the first configuration, because a budget without a
price is displayed, counted against nothing, and never binds. And scale,
bench and the source check, none of which were written down anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 00:01:39 +02:00
Ilker ManapandClaude Opus 5 03e38e7dda Carry the profile fingerprint to the agent and back
CI / test (push) Successful in 1m39s
The stale-profile flag could not fire where it matters. The control plane
keeps no record of a running replica across its own restart — it rebuilds
them from what the agents report — and adoption left the fingerprint empty,
so every replica read as unknown afterwards. That is precisely the moment
the catalog is most likely to have been edited: change the configuration,
restart the server, and nothing would ever say the fleet disagrees with it.

The fingerprint now travels with StartShard, the agent keeps it beside the
pool and fabric it already echoes back, and adoption takes the replica's
version from the agent rather than from the catalog it is about to be
compared against. An agent too old to report it sends nothing, which still
reads as unknown rather than as matching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 23:22:44 +02:00
Ilker ManapandClaude Opus 5 40f5c930c4 Say when a replica is running an edited profile, and check a source from the web too
CI / test (push) Successful in 1m43s
Two defects, both of the same kind: something the CLI knew and the rest of
the system did not.

A profile can be edited while a replica is running against the old one. The
reconciler deliberately leaves it alone — churning a replica that is serving
costs more than the imperfection — but there was no way to see it, so the
cluster could disagree with its own configuration indefinitely and read as
healthy. Profile now carries a fingerprint, a replica records the one it was
placed with, and both the API and `ctl replicas` say "stale profile" when
they differ. An empty fingerprint is not stale: it means a replica adopted
from an older agent, which is unknown rather than wrong.

Adding a model from the admin page skipped the source check `ctl model add`
has done all along, so a typo or a mirror that is down became a replica
failing on a node minutes later, with an error naming the node. The check
moves out of the CLI into a package both use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 22:42:26 +02:00
Ilker ManapandClaude Opus 5 832d0a652c Say when a budget cannot be spent against
CI / test (push) Successful in 1m45s
A team was given a budget of a million, the quota page showed it, and after
sixty requests it read zero percent used. Not because the counting was wrong:
cost comes from a price table, no model had a price, and every request
therefore cost nothing. The number was a limit in appearance and bound on
nothing.

An empty price table is the ordinary case — self-hosted models have no
per-token price, which the schema already says. Showing somebody a budget
without mentioning it is not.

The page says so, and says it precisely: rate and token limits are unaffected,
because they count requests and tokens rather than money. A warning that read
as "no quota works here" would be worse than the silence it replaces — those
two were enforcing perfectly well, as twenty served and ten refused had just
shown.

AnythingPriced is on the accountant rather than the price table, because what a
caller is asking is whether the budgets it is about to display mean anything,
and the accountant is what would enforce them. A model priced at zero is not
priced; a fallback price prices everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 21:56:56 +02:00
Ilker ManapandClaude Opus 5 dfbb7557dd Serve a model made of several files
CI / test (push) Successful in 1m42s
The mirror would happily hold a four-shard model that nothing could serve. A
catalog entry names one source, the agent fetched one URL, and so a version of
several files had no way to be described — which is every safetensors model,
and every GGUF too large to ship whole.

A source ending in a slash names a version rather than a file. The agent fetches
every file in it into one directory and hands the engine the directory: vLLM
reads the shards and the config out of the folder, and llama.cpp is given the
first part and finds the rest beside it.

The trailing slash is the whole signal. A flag beside the source would be a
second place for the two to disagree, and a URL already distinguishes a
directory from a file everywhere else.

The mirror answers a version with a manifest instead of a file listing. A plain
file server gives HTML there, and something that has to know what a version
consists of would be left parsing it. It is on the byte listener, not the
management API: fetching weights is the one thing every node does, and making
it depend on the administrative port would undo the split that keeps that port
closeable.

Built from the directory rather than a file written beside it. A manifest on
disk is a second record of what is there, and the two disagree the first time
somebody copies a file in by hand — which the mirror explicitly allows.

The whole set or nothing. A directory with three of four shards loads as a
broken model rather than failing to load, so a completion marker is written
last and an interrupted fetch starts the missing files again rather than being
found and served. Files already there at the right size are not fetched twice.

Progress is reported under the version's address, not each file's: the shards
waiting on it asked for the version, and progress under a file's own URL
reaches nobody.

A filename from the manifest is refused if it contains a separator. The mirror
is a place an operator copies files into by hand, so what it reports is not
automatically safe to join onto a path.

An HTML listing where a manifest was expected says so. "invalid character '<'"
sends somebody looking at the wrong thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 21:07:26 +02:00
Ilker ManapandClaude Opus 5 c95cfbe426 Keep a year of metrics, and watch the disk they share with the models
CI / test (push) Successful in 1m48s
Where to put them turned out not to be a question about capacity. Measured on
this cluster: 2,359 series at 41 samples a second, about 7 MB a day, so a year
of history costs roughly 3 GB against 900 GB free. Retention was the package
default of fifteen days, which answers none of the questions this data is for —
what capacity looked like last quarter, whether it was always this slow, how
much the fleet grew.

A year, with a size cap as well. The time limit is the intent; the cap is what
stops Prometheus becoming the thing that fills the disk if somebody adds a
fleet and the series count grows tenfold.

The part that matters is what shares that disk. The model mirror and the
metrics database are on one filesystem, so a mirror that fills it stops
Prometheus writing — monitoring goes quiet exactly when the disk is the
problem, and the alert that would have said so is the one that died.

Nothing was watching it. node_exporter arrives with the Prometheus package and
was already running on that machine, unscraped, while llmgw's own disk metrics
cover only its nodes — and the mirror is not one. It is a target now, and the
alert fires at fifteen percent rather than five, because what is being
protected is the ability to be told.

No remote write and no second tier. At two thousand series that is
infrastructure for a problem this cluster does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:57:06 +02:00
Ilker ManapandClaude Opus 5 9ef02dc9ec Give the charts to both copies of the dashboard
CI / test (push) Successful in 1m48s
The page is served twice — on the admin listener, and at /admin behind a
session — and the history source reached only the second. The first version
built it after the admin listener's copy was already registered and passed that
one an empty NavExtras, so the page an operator reaches by SSH tunnel had no
charts and no explanation.

Built before either is registered, and handed to both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:29:19 +02:00
Ilker ManapandClaude Opus 5 c6df0659af Put the cluster's own charts on its own admin page
CI / test (push) Successful in 1m50s
The dashboard charted what the cluster had been billed for — requests, tokens,
spend, all from llmgw's own store — and nothing about whether it was healthy.
Saturation, queue depth, cache occupancy and replica counts were numbers in a
table at the instant the page loaded, with no way to see that one of them had
been climbing for an hour.

Four line charts now, read back from wherever the metrics were scraped to.

Read back rather than kept twice. llmgw publishes these already; what a gauge
cannot do is remember, and a ring buffer in this process would be a rival copy
of the same numbers with its own retention, lost on every restart and
disagreeing with the one an operator has on screen elsewhere. monitoring.prometheus
is empty by default and the panels are then absent rather than empty.

One axis per chart, always. Two measures of different scale belong in two
charts: a second y-axis lets any two lines be made to cross wherever the author
likes and the reader cannot tell it happened.

Four colours, handed out in fixed order, never cycled. A fifth series is summed
into "other" and drawn in grey — dropping it would make a chart that does not
add up, and inventing a hue for it would put two lines on the page that nobody
can tell apart. The fold is by timestamp, not by position: the tail's series do
not share sample times, and adding them by index slides points sideways onto
each other's clock.

A legend for every chart with more than one line, because colour alone is not
an encoding a reader can rely on.

A flat zero is drawn along the bottom rather than left blank: nothing happening
is a real answer, and an empty frame reads as a broken chart. A single sample
is not a line and says so instead of putting a dot in the corner.

One failed query fails all four panels. Three drawn and one blank invites
reading the blank one as "nothing is happening there".

NaN is dropped rather than plotted. Prometheus sends it as a string for a
sample it has no value for, Go's ParseFloat accepts it happily, and one NaN
coordinate is an SVG path that does not draw at all — taking the rest of the
line with it.

Axis maxima are rounded to a number a person would pick. An axis topping out at
0.8347 makes every gridline unreadable, and the shape is what a chart is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:25:56 +02:00
Ilker ManapandClaude Opus 5 b921fa0d6b Watch the cluster, and prove the alerts fire
CI / test (push) Successful in 1m45s
Prometheus, Alertmanager and Grafana, with every rule written against a series
this system actually publishes. Nothing here is aimed at a metric that might
exist.

One scrape target for the whole fleet. Agents publish nothing of their own —
the control plane gathers what they report and republishes it — which is worth
writing down before somebody goes looking for an exporter to install on each
node.

It scrapes metrics_listen, not the admin listener. That one is loopback because
it also serves pprof, and pprof can dump the heap where API keys sit in
plaintext. Metrics are the one thing on it something else genuinely needs, so
they get a port of their own rather than becoming a reason to open the other.
The README shows how to confirm the new port carries nothing else; it answers
404 to both pprof and the admin API.

Nothing is sent anywhere. Alertmanager groups and holds and has no receiver:
where an alert should go is a decision with credentials attached, and a
monitoring system that quietly starts mailing people is worse than one that is
visibly not wired up. critical already routes separately so it can be pointed
somewhere else without touching the rest.

The rules encode what the metrics mean rather than round numbers. A saturation
of 1 is the healthy limit measured by ctl bench, not the point of failure, so
the alert waits five minutes above it. A thirty-second p99 first token is the
threshold because this is CPU inference and would be absurd on a GPU. A model
that is deliberately stopped publishes no series at all, so "nothing serving"
cannot fire for one nobody asked for — which is what lets an on-demand
catalogue sit quiet.

Inhibitions, because a node that is down explains every replica on it and
reporting both buries the cause.

The dashboard is provisioned from a file rather than clicked together, so it
survives a reinstall and can be read in a diff.

Checked by firing one: cordoning a node moved llmgw_node_up to 0, the rule went
pending and then firing after its two minutes, the alert arrived at Alertmanager
with the node in its labels, and uncordoning resolved it. An alerting system
nobody has seen fire is an assumption.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:06:47 +02:00
Ilker ManapandClaude Opus 5 8fd4c1392d Stop handing the same memory to two models
CI / test (push) Successful in 1m46s
Placement compared a profile's memory requirement against the node's total, so
a 31 GB machine accepted two models that each declared 15: each was asked about
on its own, and nothing recorded that the first had claimed anything. GPUs were
never handed out twice this way — they are tracked as claimed indices — and
memory was, because the allocation carried no figure for it.

An allocation records what its profile asked for, and eligibility subtracts
what a node already owes before comparing. The rejection names both numbers,
because "needs 15" beside a 31 GB machine reads as a bug in the scheduler
rather than a full node.

Declared, not measured. A profile that says 15 is treated as holding 15 whether
the process has touched that much yet or not — the alternative is deciding
against what a model has grown into so far, which is wrong every time one is
still loading.

Zero is nobody said, and claims nothing. Counting it would let one undeclared
model make a whole node look full.

Adoption after a restart re-reads the figure from the catalog rather than from
the agent: what a profile asked for is not something a running process reports,
and a control plane that forgot every claim on restart would put a second large
model onto a node already full of the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 17:44:26 +02:00
Ilker ManapandClaude Opus 5 d998ea7ef5 Let a model say what it is for
CI / test (push) Successful in 1m42s
A gateway serving nine models offered nine names and nothing to choose between
them. /v1/models is the only view most clients ever have, and it listed ids.

Models carry a description now: written when the model is added, shown on the
dashboard and sent to clients in /v1/models.

Written rather than derived. A registry offers a pipeline tag and a wall of
README, and neither answers the question somebody picking from a list is
asking — which of these should I send this request to. "General chat, strong
Swedish; slower than the 8B" is not in any metadata field.

It is optional, and absent means absent rather than an empty string in the
response: a model nobody described should read as undescribed, not as described
with nothing.

On the dashboard it sits under its row rather than in a column. A sentence in a
table cell either wraps every other column into a stripe or gets cut off.

Co-Authored-By: Claude Opus 5 (1M context) <moreply@anthropic.com>
2026-09-27 16:51:09 +02:00
Ilker ManapandClaude Opus 5 e0ea4f6be9 Fill in the memory a model needs, instead of leaving the field blank
CI / test (push) Successful in 1m41s
Serving a model from the mirror page left "Needs RAM (GB)" optional and empty.
Empty writes no constraint, and a CPU profile has no card to be short of, so
every ready node became a candidate — and a 4.68 GB model went onto a 7.9 GB
machine and left it 3.5 GB for a KV pool. Placement was not wrong; it was never
told what the model needed.

The mirror knows the size of the weights, so the field arrives filled in:
weights plus half again for the KV pool, plus two for the engine. The half is
there because the cache grows with the same things the weights do, layers and
heads. It is an estimate and the field is still editable — what it is not is
blank.

A 1.12 GB model asks for 3 GB, so the small nodes stay usable. Pinning every
model to the one large machine would be a different way of getting placement
wrong.

An unknown size fills in nothing rather than the floor of two, which would
exclude no node while reading as though somebody had measured it.

And when the estimate is larger than any node in the fleet, the page says so,
with the largest node's memory. Better read there than discovered by a node
being OOM-killed twenty minutes into a load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 16:38:49 +02:00
Ilker ManapandClaude Opus 5 2acd794022 Stop placing engines on nodes that do not have them
CI / test (push) Successful in 1m42s
A node's registry holds an adapter for every engine whether or not its binary
exists, the agent never said which were installed, and placement carried an
Engine field it had never once read. So a vLLM replica was as happily placed on
a node with no vLLM: the node registered healthy, the shard failed minutes
later with "executable file not found" in the engine's own words, and the
reconciler tried again.

That is how fifteen gigabytes of safetensors reached this cluster's mirror
before anybody noticed it had no card and no vLLM to run them with.

Agents now probe what is installed and report it — the protocol has carried
engine_versions since it was written and nothing ever filled it in. Placement
filters on the key set, `ctl nodes` has a column, and the mirror page's serve
warning is a fact instead of a guess about cards, which would have waved
through a fleet of eight-GPU nodes that never had vLLM.

Probing resolves binaries and runs none of them. The first version asked each
engine for --version, which is fine for an engine and not fine for the wrapper
scripts people put in front of one: a script that shifts past flags it does not
recognise and then execs the server will start an inference server, at agent
startup, on every node. The agent's own test suite installs exactly such a
script and caught it. So presence is a path lookup, and the version is left to
`ctl engine-check`, which is run by somebody who meant to run the engine.

SGLang is the exception and has to execute, because a module has no path to
look up — but the command is llmgw's own one-line import rather than the
engine's launcher. Its exit code is the answer: python3 is on almost every
machine, and treating a failed import as "installed" reported SGLang on every
node in a fleet that had none, which is the failure being fixed, inverted.

Probed once, not per reconnect, and bounded by the context that registration
waits on: an agent that cannot reach the control plane is worse than one
reporting an incomplete list.

A node that reports no engines at all is not excluded anywhere — by placement,
or by the page. That is what every agent older than this reports, and emptying
a fleet on a control plane upgrade is a worse failure than the one being fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 16:11:55 +02:00
Ilker ManapandClaude Opus 5 e034862b79 Serve a mirrored model from the list, instead of writing YAML for it
CI / test (push) Successful in 1m35s
Fetching a model put it on the mirror and left the operator to hand-write a
catalogue entry and run `ctl model add` on the control plane — for a source the
page was already displaying, with an engine decided by the file extension it
had just shown them.

A present row now offers to serve it. The form opens on that row with the name,
the mirror's own URL and the engine filled in, and asks for the two things
nobody can infer: the context pool and how many replicas.

What it refuses is the point.

A version of several files is refused with the reason. A catalogue entry names
one source, so an entry for a sharded model is an entry for one shard, which
loads as a broken model rather than failing to load. Nothing said so before —
the mirror happily holds a four-shard model that no catalogue entry can
describe.

Safetensors on a fleet with no GPU is warned about before anything is added.
This is the failure we hit today: fifteen gigabytes of shards arrived, and only
then did looking at a node show no card, no vLLM and no SGLang. The control
plane is not told which engines a node has installed, so the check is on cards,
which it does know — a warning rather than a refusal, because a card can be
added tomorrow.

A name already declared in configuration is refused here too, matching what the
model service would say anyway, but before the form is filled in rather than
after.

Without a store there is no serve action at all: a model added at runtime would
not survive a restart, and offering it then refusing is worse than not offering.

The mirror page's dependencies became a struct. Three are optional interfaces
and two are the same shape, which at a call site is a swap nobody notices until
a page shows the wrong thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 15:54:20 +02:00
Ilker ManapandClaude Opus 5 a588646b7c Fetch a sharded model in one action, not one shard at a time
CI / test (push) Successful in 1m39s
The explorer listed every weight file with its own button. For a repository of
alternatives that is right — q4_k_m or q8_0, you want one — and for a sharded
model it is fifteen clicks to fetch one thing, with nothing stopping you from
losing count and leaving a version with fourteen of its parts.

Files are grouped into sets now. A run of "-00002-of-00004" is one row and one
button; everything else stays its own row.

Not "fetch all the weights", which would be wrong half the time: two safetensors
files are halves of one thing and useless apart, two GGUF files are usually
alternatives and fetching both spends gigabytes on a copy nobody asked for. The
set is the unit that is whole.

A repository can hold both the split and the joined copy of the same
quantisation — the real Qwen coder repos do — so a shard set keeps the -of-
notation in its name rather than collapsing onto the name of the whole file
beside it.

A set publishes no single checksum. Several files have several, and showing one
of them invites comparing it against the wrong thing.

The form posts every source in the set, and a failure part way says how many
already started: a set that fails on its fourth shard has three arriving, and
reading that as "nothing happened" sends somebody to fetch them all again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 15:32:27 +02:00
Ilker ManapandClaude Opus 5 ed530133b6 Find a model in the registry instead of pasting a URL into a box
CI / test (push) Successful in 1m41s
Mirroring anything meant knowing a repository existed, knowing which of its
thirty files were the weights, and assembling a resolve URL by hand. The mirror
page had a text box for that URL and no way to find one.

It now searches Hugging Face and lists a repository's weight files with their
sizes and published checksums, each with a button that fills the fetch form.

Only weight files. A repository is mostly a README and a tokenizer, and listing
those buries the four entries somebody came for.

The size shown is the LFS size, not the plain one. For an LFS-stored file —
every weights file — the plain field is the size of the pointer, so the other
one reports a gigabyte model as 133 bytes, and that is the number somebody
would then plan disk against.

The source URL is pinned to the commit the listing came from rather than to a
branch, so what the mirror fetches is what was on screen. A repository
re-uploaded under the same branch otherwise leaves two different sets of
weights with identical provenance and no way to tell them apart. The version
defaults to that revision for the same reason.

The registry's own failures are reported on their own line. It is somebody
else's server on the other side of the internet, and it being down says nothing
about the mirror — which is what the page is for and has to keep working
regardless. With no registry configured there is no search box at all, rather
than one that can only time out.

Gating decodes from a bool or a string, because the field is documented as the
first and is the second in practice: strict decoding fails the whole response,
so one gated model would hide the other nineteen in a page of results.

And a bug this made obvious: downloads were keyed by model and version, so the
second shard of a sharded model was refused as "already fetching" until the
first had finished. A fifteen-shard model had to be pulled one file at a time,
each waiting on the last. Keyed by file now — which is what the single-flight
was ever protecting, since the race is two writers on one .part. A version made
of several files reports as the worst of them: it is not present until every
shard is, and one shard failing makes the version unusable however many others
arrived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 15:18:57 +02:00
Ilker ManapandClaude Opus 5 f999ee2fb2 Make an organization's budget bound the sum of its teams
CI / test (push) Successful in 1m40s
Resolution took the most restrictive value along the chain and enforced only
that one. For a single caller's ceiling that is right: a team cannot raise what
its organization set. For a pool it is not, because two levels are two pools.

An organization given 1000 with four teams given 500 each spent 2000. Each team
filled its own counter, the organization's value lost to the tighter one and
was never looked at again. The number was written, displayed, reported against
— and enforced by nothing.

Every level that sets a limit is now checked against its own counter, and
settled onto all of them, so an organization's pool fills from its teams'
spending rather than only from requests that somehow named it. A team still
stops before its organization when its own pool runs out first, which is what a
sub-allocation is for.

Effective carries the chain as well as the collapsed values. The collapsed ones
are still what a header or a profile page should show — they are the ceiling a
single caller faces — they are just not enough to enforce with.

Rate limits charge as they check, deliberately, so that two concurrent requests
cannot both see room. With several levels that means a refusal at one has
already taken from the levels before it, so those are handed back. Without the
refund a team sitting at its organization's ceiling would quietly eat its own
rate limit with requests that were never served.

Budgets are checked before any of that, and only read, so a refusal on a budget
charges nothing at all.

Status reports the fullest pool rather than the smallest number. A team inside
an organization with the same budget is bounded by whichever is nearer its
ceiling, and that is the organization as soon as a second team spends anything;
a header showing the team's would disagree with the refusal after it.

A cluster default joins the chain as a key-level entry. Left out, it would be
reported as the ceiling and then never checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 15:08:30 +02:00
Ilker ManapandClaude Opus 5 5726252825 Open the quota form on the branch, instead of asking which branch again
CI / test (push) Successful in 1m44s
The tree was rendered and then, underneath it, a form with a dropdown asking
which of those rows you meant. The tree already said. Picking a branch, reading
its current numbers, scrolling past the tree and selecting the same branch from
a list is three steps to do one thing, and two of them are a chance to pick the
wrong row.

A quota belongs to a branch, so the form opens on it: each row links to
?edit=scope:id, that row renders its form inline, pre-filled with what is set
there now and naming the branch in full.

By link rather than by disclosure on every row: a collapsed form per branch
would put a form in the markup for every group on a page whose job is to be
read as a tree. One is open at a time, and the rest keep their link.

Clearing moved into that form as a second submit, so a row that is not being
edited has one control and not two.

Saving closes it. Re-rendering the form over the row it just changed hides the
result and invites submitting the same thing twice.

The note about a quota being shared came out of the footer: the open form says
it, at the point where somebody is about to do it.

The dropdown's parsing went with it rather than being left unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 14:54:02 +02:00
Ilker ManapandClaude Opus 5 55262418c9 Put quotas somewhere they can be seen and changed
CI / test (push) Successful in 1m35s
A quota could be enforced and reported, and set nowhere but a shell. Nothing in
the browser showed one at all — not what a team may spend, not what it has
spent, not that it had no limit. In practice that means quotas nobody sets.

/admin/quotas lists the whole tree, whether or not a group has a limit: the one
without a quota is the one most likely to need one, so it cannot be the row the
page leaves out. Spend over the last thirty days sits beside the budget, with
the fraction used.

Setting a quota here is a change of position. It was kept to the shell because
raising a budget is a financial act and a stolen admin session should not
perform one. That argument holds for exactly one row on this page: resolution
takes the most restrictive value along the chain, so raising a team's or a
project's limit cannot exceed what the organization allows. Only an
organization's own limit is bounded by nothing. Every change is logged with who
made it and what it was before.

Per-key limits stay on the CLI. A key belongs to a person and is already
bounded by its project; letting an admin session reach individual keys would
put every caller's throughput one click away, and the page refuses a key scope
rather than rendering it and failing later.

A limit an ancestor already caps lower is marked as written-but-unreachable.
Resolution means a project given more than its team is not a bigger budget, it
is a number nobody will reach — and without saying so the page would show it as
though it applied, with the first sign being a refusal that disagrees with the
screen.

The group is chosen, not typed. Ids became random in the previous commit so two
organizations could both have a "platform" team, which also made them
impossible to remember and a typo impossible to notice: it would write a quota
onto nothing and report success.

A blank field is no limit of that kind, not zero — zero is a limit of nothing,
which refuses every request. Clearing removes the row rather than writing nulls
across it: both resolve the same, only one tells a reader nobody has set a limit
here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 14:44:13 +02:00
Ilker ManapandClaude Opus 5 fc1b47976d Let an admin build the tenancy tree, instead of sending them to a shell
CI / test (push) Successful in 1m35s
An account is approved into a project, a project belongs to a team, a team to
an organization — and until now the only way to make any of them was `llmgw
keys init` on the control plane. So the first thing to do after installing was
leave the interface you were already in, and an approval form with an empty
dropdown gave no hint why.

The accounts page now creates all three.

Creating a container is not granting access: nobody can use one until an
account is approved into it, which is the step already on this page. Quotas stay
on the CLI. Making somewhere to put people is structural; raising what they may
spend is financial, and a stolen admin session should be able to do the first
and not the second.

Ids are random now instead of slugs of the name, which fixes a bug that only
appears when you have a second organization — which is exactly what this
feature invites. Team ids were slugs in one flat namespace and PutTeam upserts
on id, so creating "platform" under a second organization moved the first one's
team into it. Nothing failed. The team changed owner.

Create* is separate from Put* rather than replacing it. The upserts exist so
`keys init` can be re-run while provisioning a machine; a person filling in a
form means something else by a name that already exists, and gets told.

A duplicate is refused among siblings only. Two companies both having a
"platform" team is ordinary, and refusing the second would be the flat namespace
again wearing a better error message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 12:36:13 +02:00
Ilker ManapandClaude Opus 5 a83493bddb Show what a group spent, and fix the reports that never ran on PostgreSQL
Quotas can be written on an organization, a team or a project, and nothing
could show what any of them had used: usage came back per key, per user and per
model only. A quota whose consumption cannot be seen is one nobody reviews
until the day it refuses somebody — and since the previous commit made a group
quota a shared pool, it is now a number several people are spending against
without being able to look at it.

`llmgw ctl usage --by team` sums every key beneath each group, with the budget
that level sets beside it.

Beside it, and only when that level sets one. A team bounded by its
organization's budget has none of its own, and printing the organization's
against each of its teams would read as though each had that much — the exact
misreading this work exists to remove.

Joined through api_keys rather than read from usage.project_id: that column
records where the key was when the request was served, while the quota is
written against the tree. They agree until a key is moved, and a report that
disagrees with the limit shown beside it is the useless one.

Three existing reports were fixed on the way, all failing on PostgreSQL and all
passing in tests, because the tests run on SQLite:

  column "k.name" must appear in the GROUP BY clause    (UsageSince)
  column "us.email" must appear in the GROUP BY clause  (UsageByUser)
  column "usage.ts" must appear in the GROUP BY clause  (UsageBuckets)

SQLite picks an arbitrary row for an ungrouped column and carries on; PostgreSQL
refuses. The first two were selecting names they had not grouped. The third
grouped "ts / ?" while selecting "(ts / ?) * ?", which PostgreSQL matches as
text and so saw a bare ts — it is grouped by ordinal now, the same expression by
construction. The dashboard's usage charts have been empty on the cluster this
whole time, with the reason only in the log.

All four queries were run against the cluster's own PostgreSQL before this was
committed, because passing on SQLite is what they were already doing.

The grouping level picks from a fixed table of levels rather than being
interpolated, so an unknown one is an error and not a fragment of SQL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 12:26:14 +02:00
Ilker ManapandClaude Opus 5 ebce41e22d Stop calling the PostgreSQL store SQLite, and stop guessing where it is
Two things that were only ever confusing, and one of them stopped a command
working on the cluster.

The store type was named SQLite and kept the name when PostgreSQL was added, so
OpenPostgres returned a *SQLite and all seventy-odd methods on the production
store were spelled after the database it was not running on. Nothing broke;
people did. Earlier today I added three methods to *SQLite for a table that on
this cluster only ever lives in PostgreSQL, and the name did not once look
wrong. It is a field, which is all it ever was.

`llmgw users` and `llmgw keys` defaulted --store to a SQLite path. On a
deployment whose store is PostgreSQL every one of them failed with "unable to
open database file" while the right answer sat in auth.store in the config file
beside them; the conclusion an operator draws is that the tool is broken, and
they are not far off. They now ask the server's own configuration before
falling back, and the error names which of the three sources chose the address
— the failure was never the path, it was which one picked it.

The single-machine default still applies last, so a deployment with no config
file keeps working with no flags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 12:20:40 +02:00
Ilker ManapandClaude Opus 5 3febb6ef63 Count a group's quota against the group, not against each of its keys
The tenancy tree handed limits down and never pooled what was spent against
them. A limit written on a team was inherited by every key beneath it, and then
enforced with the key as the counter — so a project given a budget of 1000 and
ten keys permitted ten thousand, and a team given 60 RPM got 60 per member.

That is "each of you may spend this much", which is a useful thing to be able
to say. It is not what somebody writing a budget on a team means, and nothing
in the system said which one you were getting.

Resolution now records which scope set each value that won, and the counter is
keyed by that scope. The value and the counter come from the same place, so a
project tightening its team's limit is enforced against the project's own pool
rather than eating into a quota another project never agreed to.

Each limit is owned separately, because they resolve separately: a team budget
with a per-key rate limit — the group's money, the individual's throughput — is
the ordinary arrangement and has to stay expressible.

Cluster defaults are owned by the key. Read as a shared pool, one default would
become a single cluster-wide rate limit that every key in every organization
competes for: not a default, an outage.

Restoring after a restart had to change with it. Per-key totals rebuilt a
counter named after the key while the check read one named after the team, so
the control plane came back believing the team had spent nothing — a budget
that reset on every restart, for exactly the scopes where it matters most.
SpendSinceByScope returns each key's place in the tree and the accountant folds
it into every scope that might own a budget.

Counters are namespaced by level throughout. A bare key id in one path and a
"key:"-prefixed one in another are two counters for the same key, which is how
the restore was silently doing nothing.

Still missing, and worth naming: no usage report rolls up by project, team or
org, so a shared quota can now be enforced but not yet shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 12:10:46 +02:00
Ilker ManapandClaude Opus 5 3aa250428a Keep what a benchmark found out, instead of leaving it in a terminal
`ctl bench` has always been able to work out what one replica can serve — it
ramps concurrency until the SLO breaks and reports the last level that held.
It printed that and nothing kept it, so the one number saying what a replica is
worth lived in whoever ran it's scrollback. Everything downstream then had to
guess: admission applies a single global default for healthy queue depth, and
sizing a model against real demand had nothing to size against.

`ctl bench --save` records it; `ctl capacity` reads it back.

Keyed by model and profile. Capacity belongs to the hardware and the engine
arguments as much as to the weights, so the same model on CPU and on an H100
are two numbers, and one key would overwrite the other on every run.

The profile is looked up from the replica rather than asked for, because the
control plane already knows which profile answers on which endpoint and a
measurement filed under the wrong one is worse than none. A target that is not
a replica — the gateway, usually — is refused with that said: benchmarking the
whole path is worth doing and says nothing about the hardware.

The conclusion is stored, not the ramp. Every level and its latencies are worth
reading once while judging whether the run was sound; keeping them would grow
without bound as a model is re-benchmarked.

Re-measuring replaces. A second run is a better answer to the same question,
not another data point, and keeping both would leave a reader choosing between
numbers with no basis for the choice.

The SLO and the date are stored with it. Capacity means nothing without the
target it was measured against, and hardware and engine builds change — a
measurement is an observation with a date on it, not a fact about the model.

A failed save is reported and not fatal: the run has already cost the GPU time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:51:58 +02:00
Ilker ManapandClaude Opus 5 707d774c76 Change a model's replica count without editing a file on the control plane
The count is scaling.min, and until now the only way to change it was to edit
server.yaml on the host and restart — or, for a model added at runtime, to
remove it and add it back, which means having its whole definition to hand.

`llmgw ctl scale <model> --min N` instead. Only the scaling block moves:
everything else is read back from what was stored and written out untouched, so
changing a number cannot quietly become a redefinition.

The catalog is rebuilt and validated before anything is written, as in Add. A
max below a min is refused with the stored definition untouched — persisting a
rejected change would come back on the next restart as a catalog that does not
build.

The answer reports the scaling in force rather than the request. The catalog
fills in a max that was left out, and a caller echoing its own request would
print a number the cluster is not using.

A model declared in configuration is refused, for the reason Remove already
refuses one: the YAML is read on every start, so the new count would hold until
the next restart and then silently revert. A cluster disagreeing with its own
config file is worse than an error saying where to edit.

The help says what --max does, because it is not what it looks like: a model
with a minimum runs exactly that many and nothing scales it up under load, and
an on-demand model loads one replica however high its max.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:25:05 +02:00
Ilker ManapandClaude Opus 5 10e323132c Let a control plane whose models all live in the database start
Control plane mode was inferred from the config file alone: a model with
profiles meant "control plane", nothing meant "router only". So a deployment
that manages its catalog at runtime could not start at all — the file declares
no models, the process refuses for want of anything to serve, and the stored
models it would have loaded are never read. A cluster unmanageable at runtime
because it is managed at runtime.

control.enabled says so outright. AgentListen cannot carry the signal: it has a
default, so every deployment would silently become a control plane.

The inference stays, so nothing already working has to add a flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:24:49 +02:00
Ilker ManapandClaude Opus 5 cd7eedf09e Give the mirror a page, so managing it is not a curl command
CI / test (push) Successful in 1m49s
/admin/mirror, behind the same admin session as the other admin pages. What is
held, what is arriving with its byte count, what failed and why; start a fetch,
retry a failed one, remove a version.

Not mounted at all without a mirror configured. The right page for a deployment
with no mirror is no page, rather than an empty one pointing at nothing.

It reloads itself only while something is downloading. A reload at any other
moment takes with it whatever is half-typed into the fetch form, and there is
nothing to watch when nothing is moving.

A failed row offers Retry, not Remove. A fetch that failed left nothing on disk,
so the remove could only answer "the mirror holds nothing for this" — a button
whose single outcome is a confusing error. Starting the same download again is
what somebody looking at that row wants, and the mirror allows it: only a
download still running is refused.

An unreachable mirror is said outright instead of rendering as an empty table.
The two look nearly identical and mean opposite things: one is a mirror waiting
to be filled, the other is a machine to go and look at.

Dashboard takes NavExtras rather than a second bool. Two adjacent booleans at a
call site get swapped silently and are discovered by somebody clicking the
wrong link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 10:17:12 +02:00
Ilker ManapandClaude Opus 5 744a283cf1 Keep the checksum that finishing the download used to take away
All() preferred the disk entry wholesale, and a directory listing has no
checksum in it. So the sha256 a fetch computed was reported while the bytes
were still arriving and vanished the moment the file landed — it disappeared
exactly when it became verifiable, which is the one moment it is for.

It is the field an operator compares against what upstream published. A mirror
is the single place in this system where a corrupted file is copied to every
node instead of being caught once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 10:17:01 +02:00
Ilker ManapandClaude Opus 5 7e6fb0059a Let the control plane call the mirror instead of somebody SSHing to it
The mirror has had a management API since it was built, and the only thing
driving it was curl on the mirror's own machine. This is the other half: a
client, so the operations belong to whatever has a reason to perform them.

The mirror's refusals travel as their own sentence rather than a status code.
Putting the reason in the body was deliberate on the server side, and a client
that kept only the number would undo it — "the mirror said 400" sends somebody
to the wrong machine, "the source URL answered 404" sends them to the right one.

NextProtos is cleared for the same reason the mirror's server clears it. That
configuration is shaped for the agent stream, which pins h2; a transport
carrying a pinned protocol it is not set up to speak negotiates h2 and then
talks HTTP/1.1 over it, and fails with an error mentioning neither ALPN nor
the mirror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 10:16:54 +02:00
Ilker ManapandClaude Opus 5 afb3797e57 Document the mirror that exists rather than the one you assemble
The page described nginx and a curl loop, which was accurate when the mirror
was a directory somebody kept by hand. It is a subcommand now, so it says so:
the two listeners and why they are two, the API and what each endpoint answers,
and the certificates coming from the authority the fleet already has.

nginx stays, further down. It serves a directory perfectly well and anyone
already running it should not be told to install something to replace it.

The systemd unit spells out --base in a comment because it is the flag that
goes wrong quietly: it ends up in catalog entries, and a wrong one makes
`mirror unused` call files unreferenced while they are in use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 09:43:02 +02:00
Ilker ManapandClaude Opus 5 dde2bcab19 Stop asserting an ordering the supervisor does not promise
The test started a sidecar and an engine, each a shell appending its name to a
file, and demanded the sidecar's line come first. The supervisor spawns the
sidecar first, but spawning is not sequencing: without WaitForLeaderPort
nothing holds the engine back, so two shells race and the scheduler picks the
winner. It failed under full-suite load, and lately on its own.

The wait that is a real guarantee already has its own test, so the fix is to
check what this one is actually for — that both processes run under one shard,
that the shard's pid is the engine's rather than the sidecar's, and that their
output is distinguishable in a single log tail. Renamed to say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 09:36:40 +02:00
Ilker ManapandClaude Opus 5 16ec585411 Let the control plane ask the mirror for a model instead of pushing one at it
The mirror holds the weights, so the mirror does the downloading. A control
plane that had to pull the bytes itself and push them here would carry every
model twice over the network, through the one machine least able to spare the
bandwidth.

Two listeners, and the split is the point. Weights stay on plain HTTP where
every node can reach them: that path is eight nodes pulling forty gigabytes,
and putting a client certificate in front of it would mean re-certifying every
agent's fetcher in order to change how models are administered. Management gets
its own port, which can be given the fleet's CA — the same tlsconf the agent
stream uses, so this is a configuration rather than a new idea.

A fetch answers 202, not 200. The bytes are not here yet, and a caller that
read it as "done" would point a catalog at a file still arriving, which every
node would then copy. Progress is asked for rather than waited on, because no
client between here and the operator will hold a connection open for an hour.

A failed fetch is remembered with its reason. Forgetting it would leave the
control plane waiting on a model that is never coming, with nothing to say why.
Status falls back to reading the disk, so a restart does not report a model
that is plainly there as unknown.

Removal takes the whole version: a model split across files is useless with one
of them missing, and half a version on disk looks present and will not load. It
refuses while a download is running, because the fetch would carry on writing
and the file would reappear, looking like the delete had silently failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 09:34:48 +02:00
Ilker ManapandClaude Opus 5 b40bc481cf Manage the model mirror with a command instead of a runbook
CI / test (push) Successful in 1m36s
The mirror was a directory and a discipline written down in a README. This
makes the three operations that matter into commands, on the mirror host,
where `llmgw keys` already sets the precedent of operator tooling that works
on files rather than through the API.

The gateway still never writes to the mirror. That property was chosen
deliberately — a mirror writable by the thing reading from it is a way to
replace a model with one every node then fetches without complaint — and a
person running a command does not take it away. `serve` states it rather than
inheriting it, so a handler added later cannot quietly make the mirror
writable.

`add` downloads to a temporary name and renames, for the reason the agent's
fetcher does: a mirror is copied to every node, so a partial file here is a
partial file everywhere. It verifies the length the server promised, reports a
checksum — the mirror is the one place a corrupt file reaches every node
rather than being caught once — and refuses to replace an existing version,
which is the whole point of the layout.

`unused` refuses to answer when it cannot read the catalog. An empty catalog
and an unreachable one produce the same list, everything, and the obvious next
step after that list is deletion. It compares by path rather than by spelling,
because a base typed with a different scheme or a trailing slash would
otherwise report a file in use as unused.

`remove` checks the catalog first and says to retire the entry before deleting
the file, because a node that has to refetch a deleted file cannot, and that
failure arrives later, on the node, looking like something else.

Verified end to end: Range requests answer 206, which is what makes an
interrupted fetch on a node resume; writes answer 405; a traversal attempt
answers 404.

Note: internal/agent/supervisor's TestSidecarRunsBeforeEngine fails under full
suite load on main as well as here — it asserts that one shell's echo lands
before another's, which the supervisor does not promise. Everything else
passes. Left alone rather than fixed in a commit about mirrors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 09:03:07 +02:00
Ilker ManapandClaude Opus 5 e86457cf02 Make the model mirror a file you can copy, not an idea you have to have
CI / test (push) Successful in 1m39s
"Set up a local mirror" was advice. This is the nginx config, the directory
layout and the reason the layout has a date in it — a repository that changes
upstream changes what you are running, and the first sign is a prompt that
worked last week answering differently this week.

It says what the mirror is not, because both mistakes are easy. It is not a
caching proxy: nothing populates it on demand, deliberately, since a mirror
that reaches the internet at the least convenient moment fails in a way that
looks like llmgw being broken. And it is not needed at all if you already run
a shared filesystem — put the models there, use a path, and nothing is fetched.

It also says when a mirror stops being enough, and that llmgw is positioned to
distribute between nodes because it already knows which of them hold which
weights. That is not built, and the page says so rather than implying it is
coming.

Every behaviour it relies on was checked against the source: an http:// source
is fetched and a path is not, a killed download resumes, a partial file is
never visible under the model's name, and a shared filesystem is not counted
once per node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 08:20:50 +02:00
Ilker ManapandClaude Opus 5 b7a97468e0 Answer the two questions asked before a gateway is allowed near anything
CI / test (push) Successful in 1m34s
Neither had a page. "We have GPUs, now what" was left to the reader, and what
llmgw stores was scattered across four documents and a changelog — which is
the same as not being written down when somebody is doing a security review.

docs/data.md describes the behaviour and leaves the legal mapping to whoever
holds the obligation. Every claim in it was checked against the source, and it
names where to look for each: usage rows carry no text field, keys are stored
hashed, conversations are off until a named project turns them on and cannot be
turned on without a retention window.

It also states the egress exactly, which took reading every outbound call to
say honestly: nothing on the serving path leaves the cluster, and the two
things that can reach outside — fetching weights, and ctl plan reading a model's
architecture — are both started by an operator. Weight fetching points wherever
you tell it, including an internal mirror.

docs/choosing-models.md is the opinion llmgw itself does not have. Three jobs
rather than one model, with the third — embeddings — being the one that gets
forgotten and the one that decides whether the deployment can answer questions
about your company or only questions in general.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 08:16:08 +02:00
Ilker ManapandClaude Opus 5 f2f375d824 Stop the text-search comment naming one language
CI / test (push) Successful in 1m41s
The choice is unchanged and now better argued: a deployment does not announce
which language its notes are in, so any stemmer picked here is the wrong one
somewhere — and applying the wrong one does not fail loudly, it quietly stops
finding things.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 08:09:49 +02:00
Ilker ManapandClaude Opus 5 2bf7de1a43 Give two control planes a shared address, and a probe that means something
CI / test (push) Successful in 1m36s
The last piece of HA, and it is a deployment pattern rather than code: llmgw
picks one leader and agents follow it, but client traffic arrives in front of
both and llmgw is not in front of anything.

Writing the pattern found a bug in the pattern. The first draft said to
health-check `/health` and claimed a standby still routes to replicas the
leader placed, so sending it traffic was fine. It does not. A standby's
reconciler places nothing and it holds no agent streams to adopt from, so it
publishes an empty endpoint list — a load balancer following that advice would
have sent half the traffic to something that answers every request with 503.

So there is now a `/ready` that means "this process has somewhere to send a
request": 503 on a standby, 503 on a leader whose replicas have all failed,
200 when there is something to serve. `/health` keeps meaning the process is
up, which is a different and less useful question. Both are open, because a
load balancer has no key.

There is a gap during failover where neither passes — three seconds when the
leader stops cleanly, up to the lease TTL when it does not — and the document
says so rather than implying otherwise. Traffic cannot go to a control plane
that has nothing to route to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 07:52:54 +02:00
Ilker ManapandClaude Opus 5 a35e8796d2 Carry a download's progress to where somebody is looking at it
CI / test (push) Successful in 1m28s
The agent has always logged a percentage as it fetches weights, with a comment
saying a replica pulling a hundred gigabytes and one that is wedged look
identical from the control plane. They still did: the log is on the node, and
the node is not where anyone is looking when a replica has been starting for
ten minutes.

Progress now rides on the shard status and appears under the replica in `ctl
replicas` — a line rather than a column, because most replicas are not
downloading anything and a column would be empty for all of them.

It is deliberately not a state transition. Treating each chunk as one would
re-mark the shard as starting on every report and reset the timer that decides
when a slow start has become a failed one, so an hour-long download would keep
a doomed replica alive indefinitely. It arrives on its own method for that
reason, and a real transition clears it — otherwise a finished download sits in
the listing at ninety-nine percent under a shard that has been serving for an
hour.

Watchers are keyed by source rather than by shard, because a fetch is
single-flight: eight shards starting together produce one download and all
eight are waiting on it.

No percentage is invented when the server sent no length. One that stops moving
is worse than a byte count that keeps rising, so the field is absent and the
listing says "so far".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 07:49:08 +02:00
Ilker ManapandClaude Opus 5 100f10ac17 Generate the configuration reference, and fail when it goes stale
CI / test (push) Successful in 1m41s
There was no reference. Four narrative documents explain how to install and
operate llmgw, and the only way to learn that `lease_ttl` exists was to read
the one that happened to mention it — or the struct.

Written by hand it would have been wrong within a fortnight. Six settings
arrived in the last two weeks alone: store, servers, lease_ttl, the two
retention windows, min_disk_gb. A reference nobody maintains is worse than
none, because it is the same list with the setting you came to look up quietly
missing from it.

So it is derived from the structs, with the field comments as the descriptions
— which means the only way for it to be wrong is for the code to be wrong, and
it makes the comments worth writing twice over. 1163 lines, 24 blocks, 87 keys.

Two tests keep that true after everyone has forgotten this exists. One
regenerates and compares, so adding a setting without regenerating is a
failure rather than an omission — checked by adding one and watching it fail.
The other walks the tree for koanf tags and fails on a configuration file
that is in neither the source list nor the short list of structs an operator
never writes, because a list is a thing to forget.

The generator finds the repository from its own path rather than the working
directory, so `go generate ./...` produces the same document from anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 07:43:30 +02:00
Ilker ManapandClaude Opus 5 8e57171c4f Let ctl ask a question instead of printing the whole fleet
CI / test (push) Successful in 1m42s
Five nodes fit in a table. Fifty do not, and this is built for people who
already have racks of GPUs — so the flags are how a question gets asked:
which nodes on this fabric, which of this GPU class, which are not ready,
which replicas have a shard on this machine.

Filtered here rather than in the API, so --json is narrowed the same way the
table is. A server-side filter would be two implementations of one predicate,
and the JSON one is the one nobody would notice had drifted.

An empty result says which case it is. A filter that matched nothing and an
empty fleet call for opposite next steps — one is a cluster to look at, the
other is a word to correct — and printing "no nodes" for both makes a mistyped
fabric name read as an outage.

A state matches on prefix because states are few and distinct and somebody
scanning a fleet types quickly. A pool matches either half, because it is
fabric/class and both are worth narrowing by. A replica is found from any of
its nodes, since one spanning two machines is on both.

--label takes a bare key as "carries this at all", which is how you ask for
every node with a role without caring which. The flag's help said so before
the parser did; the test caught them disagreeing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 07:30:51 +02:00
Ilker ManapandClaude Opus 5 3a4260c8c5 Cut v0.8.0
CI / test (push) Successful in 2m6s
Release / build (push) Successful in 2m56s
The store stops being a file on one machine, which is what stood between this
and more than one control plane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 07:26:54 +02:00
Ilker ManapandClaude Opus 5 b9c2be6cc2 Record what failover actually did
CI / test (push) Successful in 1m36s
Three seconds to move leadership, six for every agent to arrive, and every
replica adopted rather than restarted. Also what it did not do: the public
address returned 502 while the standby served correctly on its own port,
because nothing in front of the two moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 19:39:55 +02:00
Ilker ManapandClaude Opus 5 4ba84fa90e Let an agent find the leader instead of being told one address
CI / test (push) Successful in 1m32s
The second half of leadership. A standby that refuses to place but still holds
an agent's stream is worse than useless: commands travel down the stream the
agent opened, so the leader's placements fail with "no agent connected" for a
node that is plainly connected.

So a standby refuses the stream, before reading the opening message, and says
why. And an agent takes a list rather than an address, trying each in turn — a
standby refusing, a stopped control plane refusing the connection and a crashed
one never answering are the same thing from a node, and the answer to all three
is somewhere else.

`server` and `servers` are both accepted, with `server` first when both are
given: every agent configuration written before there could be a second control
plane names one address, and an operator adding a standby expects the one
already working to stay preferred. With a single address the behaviour is
exactly what it was, including the backoff — which is skipped only while there
is another address that has not just been tried, because the delay exists to
stop hammering one control plane and the next one has not been hammered.

Removed a check that asked whether one field was empty, which is how the first
run of these tests failed: the list was populated and `server` was not, and an
agent configured entirely correctly was refused by its own validation.

The tests run a real agent against a real service over h2c. The first attempt
used a plain HTTP server, and the refusal arrived as an HTTP/2 framing error
that said nothing about why — which is exactly what an operator would have seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 19:30:06 +02:00
Ilker ManapandClaude Opus 5 ade689fc8e Refuse to run the destructive store tests against a real database
CI / test (push) Successful in 1m47s
These TRUNCATE every table before each test. Pointed at a deployment that is a
silent and complete data loss — every key, every user, every conversation.

Not hypothetical. I did it: an SSH tunnel to the production database had been
opened minutes earlier for a perfectly good reason, and the same DSN was then
handed to the test suite. Twenty-one users, nine API keys, eighteen sessions,
four hundred and sixty-one usage records and three runtime-added models, gone
between one command and the next. Recovered only because the pre-migration
SQLite file was still there.

The guard is the database name. A test database has to say so, because a check
that could be satisfied by accident would not have stopped this one — the DSN
was entirely correct for what it had been opened for, and catastrophic for
what it was then used for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 18:40:12 +02:00
Ilker ManapandClaude Opus 5 5b5e61cb97 Stop a second control plane from placing work behind the first's back
CI / test (push) Successful in 1m35s
The first thing HA needs that a shared store does not give. Two control planes
against one store both reconcile: both see a model wanting two replicas and
none running, and both place. The model ends up with twice what it asked for,
each replica holding GPUs the other's inventory believes are free — and nothing
crashes to say so.

A lease in a table rather than a lock held by a connection. PostgreSQL has
advisory locks and SQLite has nothing like them, and this store keeps one set
of queries for both; a row with an expiry works everywhere, and it survives a
leader being powered off rather than asked to stop, which is the case that
matters. Taking and renewing are the same statement, so the two cannot disagree
about what a held lease looks like, and it is one statement, so two processes
racing cannot both believe they won.

The owner id is random rather than the hostname. A hostname would let a
restarted process match its own stale lease and believe it had been leading all
along — including through the gap that made it restart, during which somebody
else may have taken over and started placing.

A store that cannot be reached is not evidence that somebody else is leading,
so an unreachable store keeps the claim. Two leaders is worse than one, but no
leader means nothing is placed and nothing is evicted, and a brief database
blip should not stop a cluster healing itself.

Unset means leader. Every deployment that existed before this, every
single-control-plane install and every test acts unconditionally, with nothing
to configure.

The same seven assertions run against both backends, which is how the test
harness was caught inheriting a lease between subtests — it passed on SQLite,
where each subtest gets a fresh file, and failed only on PostgreSQL.

What this does not yet do is move agents. A standby holding an agent's stream
is one the leader cannot command, so failover is degraded rather than clean
until agents know more than one address.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 18:24:06 +02:00
Ilker ManapandClaude Opus 5 fa60b81f37 Document moving to PostgreSQL, and when it is worth it
CI / test (push) Successful in 1m36s
The section leads with why SQLite is still the default rather than with how to
leave it: one file, no server, and a request path that barely touches it. Two
things change that, and both are recent — conversations and notes read and
write on every turn, and a second control plane is impossible while the store
is a file on one machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 16:29:42 +02:00
Ilker ManapandClaude Opus 5 b6de469351 Move a store to another one without reissuing every key
CI / test (push) Has been cancelled
Keys are stored hashed, so a migration that drops them cannot recreate them:
every person and every script needs a new one issued, and the old ones cannot
even be looked up to say which they were. `llmgw keys move-store` copies
instead.

Found by running it against the real cluster, twice, after the gateway had
already been stopped.

The first order came from the order the migrations create tables in, and
users is created in a later migration than api_keys even though api_keys
references it — so the copy died three tables in. The order is now checked by
a test that derives the foreign keys from the migrations rather than restating
them, so a new reference fails there instead of during somebody's migration.
Checked by putting the wrong order back and watching it fail.

Then key_claims. Its sealed column is emptied the moment a claim is used, so
every claimed row holds an empty blob in a NOT NULL column. An empty blob
scans as a typed nil slice and a genuine NULL as an untyped one — a
distinction this side can see and pgx cannot, since it encodes any nil slice
as NULL. Restored before the insert, which is the difference between a copy
that completes and one that stops four tables in.

One transaction per table rather than one for the whole copy: a single
transaction holding every row of a large usage table is how a migration runs
out of memory on the machine least able to spare it. A failure therefore
leaves a partial copy, which is why the destination must have been empty —
starting again is dropping and recreating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 16:28:38 +02:00
Ilker ManapandClaude Opus 5 a76ebdb79a Run the store on PostgreSQL, chosen by what the address looks like
CI / test (push) Successful in 3m11s
A path is still SQLite and a postgres:// URL is PostgreSQL, so every existing
configuration keeps working untouched and moving is one line changed rather
than a new setting to find. An address of the form @/path/to/file reads the
real one out of that file, which is how a DSN carrying a password stays out of
a configuration that gets copied, diffed and pasted into issues.

The driver is pgx, which is pure Go. That is not a preference: the whole binary
is built with CGO_ENABLED=0 so it runs on a node whose libc is older than the
builder's, and a cgo driver would take that away from every deployment,
including the ones still on SQLite.

Tested against a real PostgreSQL, which found two things nothing else could.
The schema_migrations table is created in Go rather than in a migration file,
so it escaped the port and still said INTEGER — 64 bits in SQLite, 32 in
PostgreSQL, and a millisecond timestamp does not fit, so the very first
migration failed to record that it had run. And the memory migration uses an
FTS5 virtual table with three triggers keeping it in step, which has no
counterpart: PostgreSQL derives a tsvector from the row, so there is nothing
to keep in step and no trigger to forget.

That leaves exactly one query that is not the same sentence in both dialects —
the note search, MATCH against @@ — and it is written out twice rather than
hidden behind a shared spelling that would mean something subtly different in
each. Everything else is one set of queries.

The text search uses the 'simple' configuration rather than English. These are
an agent's own notes, in whatever language it chose, and stemming English
rules over Turkish would quietly stop finding things.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 16:18:10 +02:00
Ilker ManapandClaude Opus 5 ccc9aa8b76 Route every query through a dialect, before there is a second one
CI / test (push) Successful in 2m30s
Groundwork for PostgreSQL, with no PostgreSQL in it yet: the store still opens
SQLite and behaves exactly as it did.

One set of queries rather than one per database. The SQL here is almost
entirely portable — ON CONFLICT upserts work in both, and the only construct
that genuinely differs is the placeholder — so two copies of every query would
buy nothing and drift, one query at a time, discovered in production.

The rewrite is a wrapper around the handle rather than a change at each call
site. There are sixty of those and sixty chances to forget one; this way the
only route to the driver goes through the rewrite. Making it a wrapper found
six places that reached past it, including all three manual transactions and
two prepared statements — every one of which would have worked on SQLite and
failed on PostgreSQL, on a path nobody exercises before deploying.

The rewriter skips quoted sections. No query here contains a question mark in
a literal today; this is so that adding one is a test failure rather than a
statement that silently binds the wrong argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 16:10:35 +02:00
Ilker ManapandClaude Opus 5 539bdac152 Say where the fetch progress actually goes
CI / test (push) Successful in 2m38s
The gap list said a fetch "reports no progress". It does — the agent logs a
percentage as it downloads, with a comment saying a replica pulling a hundred
gigabytes and one that is wedged look identical from the control plane.

That comment describes the real gap, which is narrower and more useful than
the one written down: the progress exists, it just never leaves the node, so
the operator looking at ctl or the dashboard still cannot tell those two
apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 14:43:47 +02:00
Ilker ManapandClaude Opus 5 7dd1de6537 Cut v0.7.0
CI / test (push) Successful in 3m19s
Release / build (push) Successful in 2m55s
Seventeen commits since v0.6.0, and not a phase of the roadmap — which is why
the table leaves the phase column empty rather than inventing one. What landed
is what a cluster needs to be lived with rather than demonstrated: somewhere
to keep a conversation, somewhere to keep a note, and an honest answer about
the disk underneath both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 14:20:43 +02:00
ilker c815b629e2 Merge pull request 'agenta memory eklendi' (#1) from deniz/llmgw:memory into main
CI / test (push) Successful in 2m30s
Reviewed-on: #1
2026-09-24 08:07:16 +02:00
deniz 2eb79f3400 agenta memory eklendi
CI / test (pull_request) Successful in 2m31s
2026-09-24 04:04:03 +03:00
Ilker ManapandClaude Opus 5 45e913bd10 Refresh disk and weights while the agent runs, not once when it starts
CI / test (push) Successful in 1m46s
Found by answering "where can we see this live". `ctl weights` was offering a
file for deletion that I had deleted two days earlier, and the node's disk
figure was the one it had when its agent started. Discover() is called at
registration and nowhere else, so both were frozen.

Disk is a fact about a moment, not about a machine. Sampled once, the figure
is wrong on exactly the node worth watching — the one busy filling up — and it
stays wrong until that agent restarts. The heartbeat already refreshes GPU
memory for the same reason, with a comment about noticing a foreign process
"after registration"; disk belonged beside it and I did not put it there.

The stale list cut both ways. A model fetched after registration was invisible
until a restart, so placement could not prefer the node that had just spent ten
minutes downloading it, and the disk check could not see the space it used.

Storage goes in every heartbeat, being a few numbers per filesystem. The file
list goes only when a fingerprint of paths and sizes changes, with a flag
saying the list is meant — without that, an empty list would be ambiguous
between "this node now holds nothing" and "nothing to report", and the second
reading would empty a node's inventory five seconds after it registered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:57:25 +02:00
Ilker ManapandClaude Opus 5 cf20570ff1 Correct what the status section says is missing
CI / test (push) Successful in 1m39s
Two of the four items were already built. A model falls through to its
fallbacks on no capacity as well as on a spent budget, and the per-request
routing profile header exists — it is ignored under authentication on purpose,
because a header that picks a routing profile is a header that promotes a
client into the top priority band.

Checked against the code rather than against memory, which is how both got
onto the list in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:49:53 +02:00
Ilker ManapandClaude Opus 5 21a502035f Document conversations, starting with what is not kept
CI / test (push) Successful in 1m35s
The section opens on the default rather than on the feature, because the
default is the part someone deploying this needs to be sure of: nothing anyone
types is stored until a project asks, and the command to ask is an act rather
than a setting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:58:53 +02:00
Ilker ManapandClaude Opus 5 eaf3382a6d Give an operator a way to turn conversation storage on
CI / test (push) Successful in 1m44s
SetThreadRetention existed and nothing called it, so the feature was
unreachable: a project could never opt in and every request carrying a thread
was refused.

A command rather than a configuration key, because it is a decision about data
rather than about deployment. Someone has to choose to keep what a team types,
and choosing it should look like an act rather than like a default nobody
read.

--retain-hours is required. Without a number the command would create exactly
the thing the design avoids: a conversation store with no expiry, holding
everything a team ever typed because nobody chose one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:54:31 +02:00
Ilker ManapandClaude Opus 5 d26a30471e Let people forget a conversation, and make retention actually expire things
CI / test (push) Successful in 1m38s
Two halves of keeping the promise that storing makes.

DELETE /v1/threads/{id} is a client endpoint, not an admin one: the person who
said the thing should be able to unsay it, and making them ask an operator
would mean the operator reads it first. Deleting a thread in another project
answers exactly as deleting one that never existed does — two answers would
let a caller map which ids are real in projects they cannot read, and this one
would do it by trying to destroy them.

And something now runs the pruner. Without it "we keep conversations for 24
hours" was a comment rather than a promise: the rows would accumulate and the
number a project chose would describe nothing. Hourly, and once at startup,
because a gateway that has been down for a week should not wait another hour
before honouring a window that expired while it was off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:52:06 +02:00
Ilker ManapandClaude Opus 5 9eb321ae3e Let a conversation continue on a different model than it started on
CI / test (push) Has been cancelled
The request path half. A caller sets x-llmgw-thread and sends only the new
turn; llmgw puts the stored history in front of it, sends that to whichever
model is serving, and stores the exchange afterwards.

A header rather than a body field because some SDKs drop fields they do not
know and all of them let a caller set a header.

Capturing the reply is opt-in and bounded. This is the hottest path in the
system, so a request without a thread accumulates nothing at all, and one with
a thread stops accumulating at a cap rather than growing to it — a gateway
that kept every reply in memory would be one long answer away from trouble. A
reply is truncated on a rune boundary rather than dropped: a shortened turn
replays as a shortened turn, while nothing at all replays as the assistant
having said nothing, and invalid UTF-8 would break the next turn's body for
good rather than for one turn.

The streamed path sets the captured reply on every exit, not only the one that
looks like the end. A stream can also finish with the client gone or the
engine stalled, and what the assistant said up to that point is still what it
said.

A thread on a project that keeps no conversations is refused. The e2e test
asserted that and got a cheerful 200: the request was served, nothing was
stored, and the caller would have discovered it only when the model had no
idea what they were referring to — which reads as the model being broken. It
now asks whether storage is on before doing anything else.

History is spliced after the model is finally settled, because a budget
fallback may have changed it and trimming is measured against the context of
the model that will actually serve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:50:40 +02:00
Ilker ManapandClaude Opus 5 cebf9906d2 Splice a stored conversation into a request, trimming only what must go
CI / test (push) Successful in 1m39s
The part that makes a thread work across models: the history goes in front of
what the caller just said, because a client using a thread sends the new turn
rather than the conversation again.

Trimming has three rules, and each is there because the alternative fails
quietly. The oldest stored turns go first, so a conversation that outgrew the
model now serving it stays usable — after a budget fallback that model was not
the caller's choice, and a permanently unusable thread would be a strange way
to punish them for running out of budget. The caller's own messages are never
dropped: answering something they did not ask is worse than refusing. And a
system message is kept wherever it sits, because losing one silently changes
what the model is for while it carries on answering.

A model that declares no context length has nothing trimmed. Guessing a limit
for it would drop turns for no reason and the caller would never learn why the
model forgot.

The body is rebuilt through a map so every field the client sent survives.
Forwarding fields llmgw does not understand is how engine-specific options
reach an engine at all, and a thread must not be what starts dropping them.
An empty history returns the body byte for byte, so a request without a thread
takes exactly the path it took before this existed.

Not yet wired to the request path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:10:07 +02:00
Ilker ManapandClaude Opus 5 b0592670a4 Give projects somewhere to keep a conversation, off unless they ask
CI / test (push) Successful in 1m39s
The storage half of continuing a thread on a different model than it started
on. Nothing uses it yet.

Until now llmgw stored no message content anywhere: usage rows carry token
counts, costs and latencies, never text, and even its own chat page keeps the
conversation in a JavaScript variable that a refresh discards. That property
is worth more than the feature, so it is the default and stays the default.
A project stores nothing until someone turns it on for that project.

Retention is a number of hours rather than a boolean, because a thread store
with no expiry is a transcript of everything a team ever typed, kept forever
because nobody chose a number. Turning it on means saying for how long. The
window measures idleness rather than age — expiring a conversation someone is
still having would be a strange way to keep that promise.

Threads are scoped to a project and the scope is part of every lookup, not a
check afterwards. Reading a thread from another project and reading one that
does not exist return the same error: two errors would let a caller learn
which ids are real in projects they cannot read. Writing across projects is
refused for the same reason in the other direction — append, then read your
own reply back.

Deleting a thread takes its messages with it, which the test checks by
counting rows rather than by trusting the cascade. Someone has to be able to
say "forget that", and a retention window is no answer to a message sent by
mistake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:05:34 +02:00
Ilker ManapandClaude Opus 5 c9c841387a Check the prompt fits the model that will actually serve it
CI / test (push) Successful in 1m40s
The context check ran against the model the caller asked for. The budget
fallback then swapped in a different one and nothing looked again, so a prompt
sized for a 32k model could be sent to a 512-token one. Measured before the
fix: 10002 prompt tokens delivered to a model declaring 512.

The caller sees that as a truncated reply or an engine error naming a model
they never chose, which is a worse outcome than the refusal the fallback
exists to avoid — it fails later and explains less.

Candidates that cannot hold the prompt are now skipped, and a fallback further
down the list that can hold it is used instead. When none fit, the request is
refused as it was before the fallback existed.

The history is deliberately not trimmed to make it fit. Dropping a caller's
messages to stay under a limit they never asked about would be a silent loss,
inside a request that was already being quietly moved to another model.

The tests drive the real request path, because a unit test on the fallback
would have asserted the same wrong thing the code did. Both were checked
against the unfixed code and fail there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:01:23 +02:00
Ilker ManapandClaude Opus 5 1b7e11b91e Document reclaiming disk, and what it deliberately will not reclaim
CI / test (push) Successful in 1m49s
A command that deletes data needs its limits written down more than its
syntax. The section says plainly that llmgw only takes back files it fetched
itself, and then says the uncomfortable half out loud: that this means it will
not reclaim the storage most likely to be filling your disk, because weights
you placed yourself are yours to remove.

Also records the dash in the DISK column as "not asked" rather than "no
space", which is the reading that matters when a node will not schedule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:59:16 +02:00
Ilker ManapandClaude Opus 5 e6bc7859dc Carry the delete capability through the indirection that hid it
CI / test (push) Successful in 1m36s
`ctl weights --collect` answered "this control plane cannot ask nodes to
delete files" against a real cluster, with the whole suite green.

The controller is handed a dispatcherRef rather than the agent service —
an indirection that exists to break a construction cycle, and one that
satisfies the dispatcher interface by forwarding each method by hand. The
controller reaches past that interface by type assertion to find capabilities
beyond scheduling, and an assertion against dispatcherRef fails for anything
nobody remembered to forward.

Nothing could have caught it below the wiring. The dispatchers the tests use
do not implement the capability either, so its absence there is the expected
case and looks identical to this bug. The test therefore asserts on the
production types themselves: that the forwarding dispatcher carries the
capability, and that the service it forwards to has one to carry. Checked by
deleting the method and watching it fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:56:36 +02:00
Ilker ManapandClaude Opus 5 774e134ed1 Refuse a node with no room, and take back only what llmgw put there
CI / test (push) Successful in 1m39s
Two halves of the same problem: nothing stopped a model being placed on a node
that could not hold it, and nothing ever removed the weights of a model that
had been deleted from the catalog.

Placement now has a disk check, shaped exactly like the memory one it sits
beside, with one addition: it is skipped on a node that already holds the
weights. A node holding the file needs no room for it, and without that the
node best placed to run a model — because it already has it — is the one most
likely to be rejected, since holding the file is what used the space.

Reclaiming is the dangerous half, so the whole design is about what it will
not do. llmgw deletes only files it fetched itself, and that is a calculation
rather than a heuristic: the cache name is a hash of the source, so the set of
wanted files can be computed from the catalog exactly. Anything without that
name was put there by an operator — scp, a shared mount, a copy by hand — and
is none of this system's business however unreferenced it looks. A model that
took a week to copy must not be removable by a command someone ran to tidy up.

The node checks every path again before unlinking it, and that duplication is
the point. The control plane knows the catalog; the node knows its disk. A
control plane that is wrong, newer than the agent, or tampered with must not be
able to talk a node into deleting something, so the agent refuses anything
outside its own cache directory or without the cache name — including a path
that tries to traverse out of it.

Listing is the default and deleting takes --collect. The summary says "asked to
remove", because each node decides, and one that refuses a path it cannot prove
it fetched is this working rather than failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:51:05 +02:00
Ilker ManapandClaude Opus 5 753f4e3265 Let a node say how much room it has left
CI / test (push) Failing after 1m31s
Disk was the one resource nothing reported. `statfs` was already being called
on every model directory, but only to ask whether the filesystem was a network
one — the free space it returns in the same struct was thrown away. So the
only way to find out why a node was filling up was to ssh to it, which is what
this system exists to avoid.

Reported per filesystem rather than per configured directory. Free space
belongs to the disk, not the path: two model directories on one disk are one
pool of bytes, and counting them separately would let the control plane add
24 GB to 24 GB and place a 40 GB model on a node that has 24.

Shared filesystems are summed separately from local ones and left out of a
node's own total. Space on a filer belongs to everyone mounting it, so twenty
nodes would otherwise each report the same free terabyte, and a scheduler
would place twenty models into room for one.

Nothing reported means nothing is known, everywhere: an agent too old to send
storage and a node with no model directories both send nothing, and both must
read as unknown rather than as a full disk. `ctl nodes` and the dashboard show
a dash, the metric is absent rather than zero, and a missing directory — which
is what an unmounted filer looks like — is skipped rather than reported as
having no space.

Shown as free out of total, because the first is useless without the second:
8 GB free is comfortable on a 10 TB filer and nearly full on a 10 GB root
partition, and only the second stops a model being placed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:38:57 +02:00
Ilker ManapandClaude Opus 5 b5177b70f6 Stop tool-check blaming the engine for what the model did
CI / test (push) Successful in 1m37s
It had one verdict for a failed attempt — "This is the engine, not the model.
Choosing a bigger model will not help" — and that verdict was wrong, in a way
that sends someone off rebuilding llama.cpp when the fix is a one-line catalog
change.

Measured on the cluster. The engine renders the tool prompt correctly: the
<tools> block and the instruction to wrap the call in <tool_call> are both in
the prompt, which I confirmed through the engine's own /apply-template. The
coder models simply do not use that wrapper — qwen2.5-coder-3b puts the call
in a markdown fence, qwen2.5-coder-7b invents <function-call> — so the parser,
which looks for <tool_call>, has nothing to find and leaves it in the content.

qwen2.5-3b-instruct, same engine, same build, same flags, returns a populated
tool_calls array. So it was the model all along, and the advice against trying
another one was the most expensive sentence in the output.

A failed attempt is now two verdicts, told apart by whether the reply used the
wrapper its template asked for. Wrapped and unparsed is genuinely the engine's
parser. Unwrapped is the model, and the fix is one trained for the format
rather than a bigger one from the same family.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 08:31:09 +02:00
Ilker ManapandClaude Opus 5 b58d67602d Stop the install guide contradicting itself about weights
CI / test (push) Successful in 1m41s
It said "there is no distribution mechanism yet" and then, two paragraphs
later, documented the distribution mechanism. The known-gaps list — the part
someone reads at 2am — still said "no cache, no fetch, no progress". Fetching
landed in this cycle; both sentences were written before it and left standing.

node-onboarding.md carried the same claim.

What is actually still missing is narrower and worth naming precisely: a fetch
reports no progress, so a first start on a large model looks like a hang, and
every node fetches its own copy, so eight nodes on a new model are eight
downloads from one origin.

Also: the dashboard now shows who is using the cluster and what they are
running, which the sentence describing it did not mention; and one sentence
about idle timeouts had lost the words that made it a sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 06:04:00 +02:00
Ilker ManapandClaude Opus 5 7de8bacfc0 Stop reading JSON with a greedy regex, and stop set -e hiding the empty case
CI / test (push) Successful in 1m54s
Release / build (push) Successful in 2m39s
My first fix was wrong about why the release could not be created. Gitea
materialises a release row for every pushed tag, so the lookup should have
found one; the create that followed hit a release that already existed.

The lookup failed because of how it read the id. `sed 's/.*"id":\([0-9]*\).*/\1/'`
is greedy, so `.*"id":` matches the last occurrence in the document, not the
first — "id" appears again inside the nested author object — and it does not
match `"id": 13` with a space after the colon at all. Checked against this
instance: for v0.5.0, whose id is 12, the old pattern returns 1.

It now greps the first "id", tolerates the whitespace, and re-reads the
release after creating rather than parsing the create response, so a 409
resolves to the release that was already there instead of to an error.

`set -e` was hiding the empty case: a command substitution in an assignment
fails the script, and grep exits non-zero when it matches nothing, so the
"no release yet" branch could never be reached — the job would simply stop.
Both extractions are guarded, and both paths are exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:33:33 +02:00
Ilker ManapandClaude Opus 5 4815a50c94 Ask for the scope creating a release actually needs, and say when it is refused
CI / test (push) Has been cancelled
The first v0.6.0 run built every binary, including the NVML one, and then
stopped at "could not find or create the release". Two things were wrong.

`releases: write` is not enough to create one. Creating a release can create
its tag, which is a code-unit operation; `contents` is the scope that covers
code and releases together.

And the step threw away the answer. It piped the response through sed looking
for an id and, finding none, reported only that it had failed — so a refusal,
a malformed request and an unreachable server all produced the same line. It
now prints the status code and the body, which is how the above was diagnosed
at all: by reading a log, not by knowing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:32:44 +02:00
Ilker ManapandClaude Opus 5 a2b3f63a0d Say what v0.6.0 is, and let the release actually publish it
CI / test (push) Successful in 1m56s
Release / build (push) Failing after 2m31s
The changelog had two "Unreleased" headings. The second sat below v0.5.0 and
was the detail behind it — phases 2 and 3, written while they were in progress
and never folded in when the release was cut — so it claimed as unreleased
work that had been shipped for two days. It is now a section of v0.5.0. The
status table said v0.5.0 was planned, which its own release notes disprove.

The first "Unreleased" becomes v0.6.0: eighty commits, of which phase 4 is the
headline and the rest is what a cluster needs before anyone but its operator
is let near it.

Two things would have stopped the release workflow publishing anything:

Gitea gives every job a built-in GITEA_TOKEN, but what it may do depends on
the instance's default mode, and under the restricted default it is read-only
on releases. The job now asks for `releases: write`, which is correct under
either default. An upload that is refused now fails the job — without `-f`,
curl treats a 403 body as a successful transfer, so the release would end up
with some of its files, or none, and a green tick either way.

`workflow_dispatch` is offered but the upload assumed a tag. Fired from a
branch it would have created a release named "main".

README's status paragraph still said authentication, budgets and the control
plane were missing. It now says what is actually left, including the part
worth saying out loud: the InfiniBand, NVML and NCCL paths have never run on
a machine that has any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:29:15 +02:00
Ilker ManapandClaude Opus 5 0cac9d6986 Make the landing page look like something worth having a key for
CI / test (push) Successful in 1m30s
It is the only page a stranger sees, and until they have a key it is the
whole product: a stack of headings and shell snippets said "internal tool
you probably should not be here".

Now it opens with what this is and a diagram of why it exists — one address,
many GPUs, fabrics that cannot see each other — then the base URL as a
headline rather than a line of prose with a colon in it, the route to a key
as three cards, and the client examples as tabs instead of a pile.

The tabs are radio buttons and a stylesheet. No JavaScript decides what is on
the page, so someone who arrives with scripting off still gets every example;
the only script is the copy buttons, which exist only where the clipboard API
does and so cannot leave a dead control behind.

The page says exactly what it said before, including that it lists no models
and why. The diagram names no model, no node and no count: fabrics are drawn
because llmgw keeps them apart, not because this install has two.

Tests cover the parts that are easy to break later: every example lives in
the page itself rather than being fetched per tab, the reflected base URL —
now in five places rather than one — still cannot write markup, and the
diagram carries a label for anyone not looking at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:14:04 +02:00
Ilker ManapandClaude Opus 5 f8bb78103f Stop the ranked charts from spilling over the tables below them
CI / test (push) Successful in 1m32s
The bars were wrapped in <div class="chart">, and .chart was already the
fixed-height box the time-series charts are drawn in: 120px, with a border
and a background. A chart that grows a row at a time does not fit in a cage,
so the SVG overflowed it and landed on the table underneath.

Renamed to .ranked, which is a wrapper rather than the mark itself, and
capped at the design width so a wide window scales the box instead of
inflating the 12px labels.

The test reads the class back out of the markup and checks the stylesheet
does not cap it, because the collision was invisible in both files on their
own — each rule was reasonable, and only the shared name was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:06:53 +02:00
Ilker ManapandClaude Opus 5 eb9e9b348c Show who is using the cluster and what they are running
CI / test (push) Successful in 1m28s
The dashboard could say what the cluster was made of and not who was
spending it. Usage was per key, which is the right unit for revoking
something and the wrong one for the question actually being asked: one
person holds a laptop key, a CI key and a notebook key, and adding three
rows up by eye is a chore that gets skipped.

Per person now, joined through the keys they own, and per model — counted
by the model that answered rather than the one requested, since those differ
whenever a fallback serves and what actually ran is the question. Headline
figures are stat tiles with a sparkline: a single current value is a number,
and plotting one is how a chart ends up with a single bar in it. The
sparkline says only rising, falling or flat, which is the one thing the
number cannot.

Ranked horizontal bars, because the labels are email addresses and model
names and the label is the point — the question is which person, not what
shape the distribution has. Rotated or truncated axis labels say the chart
matters more than reading it. Trimmed where drawn and complete on hover, so
the plot keeps its geometry and a reader can still find out whose row it is.

The palette is validated rather than chosen: every adjacent pair clears the
colour-vision separation threshold, and the dark values are the same hues
re-stepped for the dark surface instead of an automatic flip. Run through a
validator, because that is the only way anyone can honestly claim a palette
is colourblind-safe.

Which turned up a bug in the existing chart code. cssVar is an allowlist of
four names that silently returns --accent for anything else, and callers
wrap it in var() themselves — so every new series would have rendered in one
colour with accent-coloured text. Caught by a test written from the rule
that chart text wears text tokens, never the series colour.

A key with no owner is labelled "operator-issued keys" rather than unknown:
it predates accounts and is doing its job, and "unknown" would imply
something is wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:38:21 +02:00
Ilker ManapandClaude Opus 5 28967edab3 Serve a spent budget something it can afford
CI / test (push) Successful in 1m38s
The accounting layer has always exempted a model that costs nothing, with a
comment saying it is "what lets a key with no budget left still be served a
cheap fallback". That described a caller nobody had written: the budget was
checked once, against the requested model, and a refusal was the end of it,
so the exemption could never fire.

A caller that has run out is now offered the first model in its fallbacks it
can still afford. The allowlist still applies — running out of money is not
a way to reach a model a key was never allowed — and a model with no
fallback is refused exactly as before, or a budget would stop meaning
anything. Only a spent budget falls back: a rate limit is answered by
waiting a second, and moving the request elsewhere would spend someone
else's capacity to avoid a delay nobody asked to avoid.

Which uncovered a header that has never worked. x-llmgw-served-model was set
in finish, and finish runs after the proxy has written the response — so the
one mechanism for telling a client that something else answered has been
announcing it to nobody since it was written. Nothing failed, because the
only thing asserting it was a comment. It is now set before dispatch, for
the capacity fallback as well as this one.

Finding that took bisecting a test I had written wrong: it polled for a 429
that never came, because the budget had run out and the fallback had served
the request — the feature working, reported as the feature failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:26:03 +02:00
Ilker ManapandClaude Opus 5 c232f41a66 Put the fleet view in the dashboard, so nothing else is needed to see it
CI / test (push) Successful in 1m32s
The metrics were exported and the only way to look at them was to stand up
Prometheus beside the binary. For a project whose whole shape is one static
binary with no dependencies, that is the wrong default: the common case is
somebody wanting to know how full their cluster is, and that should not cost
a monitoring stack.

/admin now shows capacity per GPU class with a usage bar, free whole nodes
per fabric — the number that decides whether a replica spanning nodes can
land, since free cards on the wrong fabric are not capacity for a model that
needs this one — and which replicas are holding hardware nobody is asking
for. Nodes gain their memory and how many model files they can see.

Taken from the same Summary that ctl status uses rather than derived in the
page, because two implementations of the same arithmetic drift apart and the
one on the screen is the one people would believe.

A faulty card counts as neither used nor free, matching the scheduler.
Counting it free is how a cluster looks like it has room it does not, and a
page that disagreed with placement about that would be worse than no page.

Prometheus stays supported and is now the answer to a narrower question:
history, alerting, several clusters at once. The dashboard shows what is
true now and keeps nothing, which is the honest division between them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:15:30 +02:00
Ilker ManapandClaude Opus 5 1ea067d08e Give metrics a door that does not also open onto pprof
CI / test (push) Successful in 1m38s
The metrics existed and nothing could record them. They shared a listener
with pprof, the management API and the dashboard, and pprof can dump the
process heap where API keys sit in plaintext — so a fleet could be monitored
or keep its heap private, and not both. That is not a choice anyone should
have to make, and it is why every previous answer to "how do I scrape this"
was a tunnel.

metrics_listen serves metrics and health and nothing else, so it can be
reached from wherever Prometheus lives. Metrics stay on the admin listener
too: adding a door is not the same as moving one, and anything already
scraping keeps working.

While there: admin_listen defaulted to :9090, every interface, with a
comment beside it saying not to expose it. A default that contradicts its
own documentation is a footgun, so it is loopback now and someone who wants
otherwise says so.

The handler is nil until the registry is built, and says so rather than
serving an empty page. Ordering has already caught this exact area once —
a collector registered after AdminHandler published nothing at all, silently
— and an empty scrape target looks like a working one.

deploy/monitoring/prometheus.yml has a starting configuration and the alerts
worth having: a node that has stopped reporting, a model that should be
serving and is not, a card kept out of placement by a fault, and a fleet
holding models nobody is asking for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:12:10 +02:00
Ilker ManapandClaude Opus 5 ad37bee081 Drive on-demand loading end to end, not just per component
CI / test (push) Successful in 1m30s
On-demand loading is four components agreeing: the router notices nothing is
serving, the control plane decides the model may be started, the reconciler
places it, and the endpoint list carries it back. Each half was tested
alone, and the wiring between them is where this session's real bugs have
been — a metrics collector registered after the registry was built, an
adoption path that never health-checked — neither of which failed a unit
test.

Three tests through the real request path: one request loads a model that
was not running and is answered by it, an idle one is stopped with the node
still heartbeating so it is eviction rather than node loss, and an unknown
model still gets the unknown-model error, because on-demand must not turn
every typo into "loading".

Writing them was itself the check. The first version heartbeated once and
then slept through an UnreachableAfter of 300 milliseconds, so the node went
quiet and nothing was placed — which looks exactly like the feature failing.
A wrong assertion too: with an on-demand-only cluster and nothing loaded,
naming what is served means naming nothing, and "no model is currently being
served" is the honest answer rather than a lost error message.

This matters more now that there is no cluster to deploy to. Live
verification found several things today that no unit test would have, and
these tests are what has to replace it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:52:49 +02:00
Ilker ManapandClaude Opus 5 9b32531ce3 Stop an idle model to make room for one somebody asked for
CI / test (push) Successful in 1m29s
On-demand loading worked right up to the moment the cluster was full and
then stopped meaning anything: the model somebody had just asked for could
not be placed, and the cards were held by a model nobody had asked for in an
hour. That is the only situation in which any of it matters.

Deliberately narrow. Only idle replicas of other on-demand models — a model
at min 1 is a promise that it stays up, and breaking it to serve a different
request would make every guarantee in the catalog conditional on load. Only
single-node replicas, since evicting a multi-node one frees several machines
and costs tens of minutes to rebuild. One at a time, because freeing
everything that qualifies would empty a cluster for a request that needed
one node. And never a replica with no reported usage: after a router restart
that is all of them, and unknown is not idle.

Writing the test found a gap the feature had rather than a bug in the test.
Two on-demand models on a cluster with room for one would take turns
evicting each other, at a model load per cycle — worse for both than one of
them waiting. A replica is now safe from eviction for a few minutes after it
starts serving, which bounds that.

Which in turn found a latent bug: deriveState stamped ReadyAt with
time.Now() inside a package that has an injectable clock, so the new
lifetime rule could not be tested at all — the fake clock said three minutes
and the wall clock said eight months. Invisible while nothing compared
ReadyAt to anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:51:07 +02:00
Ilker ManapandClaude Opus 5 7fe59bd5fb Load a model when someone asks for it, and stop it when nobody does
CI / test (push) Successful in 1m29s
The feature the idle tracking was for. A cluster with more models than GPUs
can now hold every model in its catalog and only the wanted ones in its
cards: min 0 with a max above it means load on demand, and once nobody has
asked for idle_timeout the replicas stop and the cards go back.

No new field, because min 0 max 1 already reads correctly — keep none
running, allow up to one. Which made min 0 max 0 meaningful for the first
time: configured and deliberately not running. normalize() used to fill a
zero max in, on the reasonable ground that a model with no maximum cannot
run; that would now silently turn every switched-off model into an
on-demand one, including the ones already written that way in this
cluster's own configuration. A model with no scaling stanza stays off for
the same reason — defaulting it to on-demand would let any client request
start a 70B nobody intended to run.

The one real decision is what happens to the request that triggers a load.
Refusing makes the feature useless, since the first request for every model
fails; waiting without a bound turns a request into a job, and a client that
budgeted thirty seconds holds a connection for ten minutes. So it waits
briefly and then says what is happening — 503 with Retry-After and "the
model is loading", which is a different answer from "no endpoint serves
this" and produces different client behaviour. An unknown model still gets
the unknown-model error, or on-demand would turn every typo into "loading"
and the useful message would disappear.

Use keeps a model alive rather than only the request that started it,
without which the timer measures time since the load and a busy model is
evicted on schedule however hard it is working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:44:00 +02:00
Ilker ManapandClaude Opus 5 b619546516 Stop trusting hardware facts that were never checked
CI / test (push) Successful in 1m32s
Three places where the system took the infrastructure's word for something
and would have failed silently on real hardware.

A declared fabric adapter that is not on the node. Fabric membership has to
be configuration — OpenSM's default subnet prefix is identical on every
fabric, so a matching prefix proves nothing — but taking it entirely on
trust was a different mistake. A typo, or a card that enumerated
differently after a reboot, produced NCCL_IB_HCA naming an adapter that
does not exist.

An adapter with no ACTIVE port. Physically linked with no subnet manager
sits in INIT: it looks healthy to anything counting adapters, carries no
data, survives a reboot, and looks like a software problem. Discovery had
recorded port state all along and placement only ever asked whether a
fabric had any adapters listed.

Both end the same way — the replica starts, passes its health checks, and
hangs on its first all-reduce, which is the failure the fabric invariant
exists to prevent arriving through a door nobody was watching. The agent
now compares declared against discovered, where both are visible for the
only time, and does not advertise a fabric that cannot carry traffic. The
reason is logged on the machine that knows it, because the operator who
typed the adapter name is the only person who can fix it. The fabric's rate
is the slowest active link, since a collective runs at the speed of its
slowest.

And NVLink topology, which no published binary could see. NVML is its only
source, the bindings need cgo, and CGO_ENABLED=0 is deliberate so the
portable binary runs on an older libc. The cost was silent: tensor groups
fall back to the lowest free indices, which is right on an SXM board with
NVSwitch and wrong on a PCIe box where bridges pair specific cards — a
group straddling two of them runs several times slower and nothing says so.
Releases now also publish an nvml build for those nodes, and
llmgw_gpu_nvlink_peers makes the difference visible: a flat zero across a
fleet means the topology is unknown rather than absent.

A test that declared mlx5_0 and mlx5_1 on a node whose sysfs held neither
had been passing, because nothing compared the two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:29:15 +02:00
Ilker ManapandClaude Opus 5 3a4cc0c719 Register the fleet collector before the registry is built
CI / test (push) Successful in 1m31s
42fd0aa deployed and published nothing. RegisterMetrics appends to a slice
that AdminHandler reads when it constructs the registry, and I called it
after — a comment on the function says "called before AdminHandler, because
the registry is built there", written by me, immediately above the code
that did not.

Nothing failed. The build was clean, the tests passed, the endpoint
returned 200 with fourteen kilobytes of metrics, and not one of them was a
node. Only grepping the live endpoint for what was supposed to be there
showed it.

So there is now a test that a registered collector reaches /metrics, which
is the assertion that was missing: everything around it was tested and the
wiring between them was not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:27:39 +02:00
Ilker ManapandClaude Opus 5 42fd0aa31e Export what the cluster is made of, not only what it is serving
CI / test (push) Successful in 1m28s
The router published twenty-two metric families about requests, endpoints,
admission and scoring — everything about traffic and nothing about the
machines carrying it. There was no way to graph GPU usage, watch a node go
unhealthy, see a fabric fill up, or answer "was the cluster full at the
time", which is the first question anyone asks after an incident. The
agents exported nothing at all.

Exported by the control plane rather than by each agent. It already holds
every number: hardware arrives at registration and state on each heartbeat,
so this is a map read away. One scrape target for a fleet of hundreds, no
Prometheus configuration per node, no firewall rule per machine, and
nothing new running on the nodes. On the same /metrics as the traffic
figures, because correlating "the cluster was full" with "requests were
queueing" should not need two systems.

A collector rather than gauges kept current by a ticker: the numbers are
derived from state that already exists, so computing them when asked is
simpler than maintaining a copy that can drift, and nothing runs between
scrapes.

GPU utilisation is new, and is the number people actually watch. Memory
used says a model is loaded; this says whether it is doing anything, and a
fleet at ninety percent occupancy and two percent utilisation is a fleet
holding models nobody is asking for.

Adding it caught two things. utilization.gpu goes last in the nvidia-smi
query and is optional, because a driver too old to know it returns a
shorter row — requiring it made an llmgw upgrade silently stop discovering
GPUs on an older node, which is far worse than not knowing how busy a card
is. And I gave the new protobuf field number 14, which ecc_errors already
had; buf lint is why that was a moment rather than a corrupted wire format.

Absent is kept distinct from zero throughout, for the same reason as
everywhere else here: an unreported utilisation shown as a flat zero reads
as an idle fleet rather than a missing measurement, and a replica nothing
has reported would be the most idle thing in the cluster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:09:27 +02:00
Ilker ManapandClaude Opus 5 943c23a412 Fetch a model's weights when the simple arrangement needs it
CI / test (push) Successful in 1m26s
The default arrangement is the simple one: every node keeps its own copy.
So a source naming a remote file is now fetched before the engine is
launched, rather than left to fail as a missing path minutes into a load.

Eight shards of a tensor-parallel replica start at the same instant and
want the same file; they produce one download. An interrupted fetch resumes
instead of starting over, which matters at forty gigabytes, and the
download is written beside its target and renamed only when complete — a
partial file under the model's name would be found by the next scan,
reported as present, and handed to an engine that fails on it.

The exotic arrangements need no code, which is the point. Weights on a
Lustre mount or an image exported over the fabric are simply present, and
present means nothing is fetched. The escape hatch from a distribution
mechanism is a distribution mechanism that checks first.

Wiring it up caught a regression in my own design. A bare model id —
"Qwen/Qwen2.5-7B-Instruct" — is a documented source that vLLM and SGLang
resolve themselves, and treating every non-remote source as a local path
made every existing vLLM profile fail. Only an absolute or explicitly
relative path is now checked as a file; anything else is passed through
untouched. Three agent tests failed on the same change for a better reason:
they used "/models/x", and the new check is exactly the one that catches
that.

Whole repositories of shards are deliberately not fetched here. vLLM and
SGLang do that well already, and a second snapshot downloader would be work
spent arriving back where we started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 08:46:03 +02:00
Ilker ManapandClaude Opus 5 c13c567cc5 Know where the weights are, without owning the storage
CI / test (push) Successful in 1m32s
Storage will be different at every installation — Lustre, NFS over RDMA, an
image exported with SRP or NVMe-oF, local NVMe, an rsync — so llmgw observes
it rather than managing it. An agent told which directories to look in
reports what it can see; how the files got there is nobody's business here,
and a node told nothing reports nothing and behaves exactly as every node
did before this existed.

That fact buys two things. Placement prefers a node that already holds the
model over one that must first move tens of gigabytes, as a preference and
never a filter: fetching them is a valid answer, and refusing to place would
turn a slow start into no start at all. And ctl status shows what each
fabric holds.

Per fabric because fabrics are not connected to one another. Weights in
ib-a are not weights in ib-b — the only path between them is the management
network — so a model seeded into one fabric leaves the rest of the cluster
unable to serve it without a slow copy, and nothing said so before now. One
shared filer counts the same as twenty local copies, because "can this
fabric serve it" is the question being asked.

Three distinctions the tests hold to, because each is a way to get this
quietly wrong. A node that was never asked to look must rank with one that
looked and has nothing, or enabling the feature on a single node would
drain every other. An empty directory is not weights, because a
half-finished download looks exactly like one and would send placement to a
node whose engine then fails to load. And local storage is never mistaken
for shared: a filer read as local costs an unnecessary copy, while local
read as shared would have the cluster believe a model is everywhere when it
is on one node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 08:21:16 +02:00
Ilker ManapandClaude Opus 5 bd134a7ab9 Make the planned entry use an engine that can serve the precision
CI / test (push) Successful in 1m39s
Reading the tool's own output against the real fleet: it chose q8_0 as the
best fit and emitted a vLLM profile with no quantisation. q8_0 is a GGUF
quantisation that only llama.cpp serves, and from a file rather than a
repository — so following that entry loads full-precision weights, which
the same output had just listed as not fitting one card. The planner would
have caused the out-of-memory it exists to prevent, from a recommendation
that looked right.

Choosing a precision and choosing an engine turn out to be one decision, so
a Precision now carries the engine that serves it, the quantisation flag
where one is needed, and whether it lives in a file. A GGUF fit prints a
path and says to download it; a vLLM fit prints the repository and passes
--quantization so the engine does not quietly load bf16.

The arithmetic was right and the recommendation was wrong, which is the
failure mode worth having tests for: everything reported a number, and the
numbers agreed with each other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:48:20 +02:00
Ilker ManapandClaude Opus 5 88a5949f7c Stop offering the same fit once per fabric
CI / test (push) Has been cancelled
Running the planner against the real fleet produced twenty-six rows for a
32B, of which thirteen were duplicates: two fabrics of identical cards, and
every single-node fit listed once for each.

A replica on one node does not span anything, so which fabric its node
happens to be attached to changes nothing about whether the model fits. A
multi-node fit is still listed per fabric, because there the fabric is the
constraint and which one has room is the thing the operator needs told.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:47:30 +02:00
Ilker ManapandClaude Opus 5 300e298053 Work out whether a model fits the cluster, and how to split it
CI / test (push) Has been cancelled
llmgw ctl model plan Qwen/Qwen2.5-Coder-32B-Instruct

The arithmetic this replaces is the last thing between having GPUs and
serving a model on them. Choosing tp, gpu_class and a context that will
actually fit means knowing the layer count, the KV head count, what the
weights weigh at a chosen quantisation and how much of a card the runtime
keeps — and getting it wrong is not an error message, it is an
out-of-memory twenty minutes into a load. I computed those by hand for
every model on this cluster.

Both halves are read rather than retyped: the architecture from the model's
own config.json and parameter count, the hardware from the running fleet.
Attention heads in place of KV heads is the classic slip and is worth a
factor of five on a model with grouped-query attention, so it is not a
number anyone should be entering.

Every candidate is listed, not one answer. With cards to spare you want the
full-precision fit; with one card free you want to know a quantised one
exists. Choosing between those is not the planner's job.

The tests are calibrated against files actually downloaded here — llama.cpp
reported 14.62 GiB and 8.37 GiB for the 14B at q8_0 and q4_K_M, and the
model reproduces both — and they caught two errors in my own arithmetic.
The overhead was charged per rank from the whole model's weights rather
than each rank's share, and it reserved a tenth of a card that
usableFraction had already reserved. Between them, Llama-3.3-70B at bf16
across two 80 GB cards came out as not fitting, which is a configuration
people run every day. Slack reserved in two places, each unaware of the
other, reads as caution and behaves as a bug.

Pools are grouped by class and fabric together, because a replica may never
span two fabrics: eight free cards split across two of them are not eight
cards for a model that needs eight, and merging them would plan something
that starts, passes its health checks and hangs on its first all-reduce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:46:53 +02:00
Ilker ManapandClaude Opus 5 ea4dd37f80 Document installing from nothing
CI / test (push) Successful in 1m24s
Written for whoever is standing up the cluster with root on some servers and
nothing installed. Control plane, certificate authority, first node, first
model, first request.

It leads with what llmgw does not install — drivers, CUDA, engines, weights
— because a node missing any of them registers, reports ready, and fails
every replica placed on it, and the reason is three logs away. The useful
order is to make a node able to serve before onboarding it, which is the
opposite of what the tooling's convenience suggests.

The failures it warns about are ones this cluster actually hit: llama-server
present and unable to start for want of libgomp1 while the node reported
healthy; a Proxmox VM on the default kvm64 CPU, where inference is six to
nine times slower and nothing looks broken because every number is simply
small; requests dying at exactly sixty seconds on a liveness timeout that is
a generation budget in disguise for non-streaming calls.

Verified rather than recalled. Every command it prints was checked against
the binary, every config key against the struct tags, and its own
verification and troubleshooting commands were run against the live cluster.
That caught two errors in my draft: the health endpoint is /health and
returns an empty body, not /healthz, and the Go version needed is 1.26.

The known-gaps section is there so a reader finds them on a Tuesday
afternoon rather than at 2am: weights and engines are theirs to distribute,
there is one control plane and no HA, and multi-node replicas have never run
on real InfiniBand — only against simulated hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:33:49 +02:00
Ilker ManapandClaude Opus 5 d07dddb1bb Say plainly whether a model is serving, and why not
CI / test (push) Successful in 1m28s
Reported after watching a model appear in ctl models before it could answer
anything. Checked: clients are not told early — /v1/models listed it only
once a replica was ready, and the chat picker reads the same source. The
operator view showing it is deliberate, because a model coming up is
exactly what someone running the cluster wants to see.

But the report pointed at something real. That view distinguished "a
replica is coming up" from "nothing could be placed" by a parenthetical —
"0/1 ready (1 total)" against "0/1 ready" — and those need opposite
responses: one means wait, the other means look at explain-placement. On
four models the difference is findable. On forty it is invisible, and a
model that will never start reads exactly like one that is thirty seconds
away.

SERVING now answers the question that was being asked of the replica counts
— can a client use this right now — and STATUS says which of ready,
starting, not placed or stopped is the case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:34:04 +02:00
Ilker ManapandClaude Opus 5 331de9b495 Add and remove models from the command line
CI / test (push) Successful in 1m30s
The last piece of not restarting a control plane to try a model:

  llmgw ctl model add -f qwen-coder-32b.yaml
  llmgw ctl model remove qwen-coder-32b

The file is the same YAML a model takes in server.yaml, read by the same
koanf parser through the same tags, so an entry moves between the two by
being copied and a field that works in one cannot silently mean nothing in
the other.

Wired only when there is a store. A control plane without one says so
rather than accepting a model that vanishes on the next restart, which is
the kind of success that wastes an afternoon. At startup the catalog is
rebuilt from configuration plus what was stored, so models added before a
restart are still there after it — without that the whole feature would be
a slower way to lose work.

Every failure adding a model is the definition being wrong — a collision, a
missing fallback, a profile that cannot be placed — so they are 400s. A 500
would send someone to the server's logs for a mistake in their own file.

The actor comes from the caller and is kept with the row, so a catalog that
has grown over months can say where each entry came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:27:53 +02:00
Ilker ManapandClaude Opus 5 277add5ccb Keep runtime models, so an added model outlives a restart
CI / test (push) Successful in 1m25s
The catalog can change while the cluster runs; this is where a change goes
so it is still there tomorrow. Models declared in configuration and models
added at runtime are two sources on purpose: config-as-code stays
declarative and under review, and the database holds what someone added to
try it.

Configuration wins every conflict, and the direction matters. A request for
a reviewed production model must never be answered by something added from
a laptop, so a stored model colliding with a declared name is refused when
it is added. A declared model cannot be removed at runtime either — it
would return on the next restart, leaving the cluster disagreeing with its
own config file until then, which is a worse answer than refusing and
saying where to edit.

The whole catalog is built before anything is written. A model naming a
missing fallback, or colliding, or malformed in any of the ways catalog.New
refuses, is rejected rather than persisted and then skipped forever after —
a model that looks added and never appears is the worst outcome available.
Removal is checked the same way, because another model may fall back to the
one being removed, and a fallback pointing at nothing is discovered during
the outage it was meant to cover.

At startup each stored model is validated on its own against what is
already accepted, so one bad row is skipped and named rather than taking
the cluster with it. The declared models are precisely the ones that must
survive that.

Catalog types now carry json tags mirroring their koanf ones, so a stored
definition is the same shape as the YAML someone writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:26:23 +02:00
Ilker ManapandClaude Opus 5 1c57a4e378 Let the catalog change while the cluster is running
CI / test (push) Successful in 1m29s
Adding a model was an operator procedure with downtime: edit a file on the
control plane, restart it, wait out the settling window, watch replicas
churn while adoption catches up. I did that eight times in a day, and one
of the restarts cost two minutes of outage while stalled replicas timed
out. None of it was necessary. The reconciler already compares desired
against actual on every pass, so a new catalog is simply a different
desired.

A Catalog stays immutable — New is the only way to build one and it refuses
an invalid one — and what changes is which immutable catalog is current.
Readers take the pointer and finish their operation against it, so a swap
mid-request is invisible rather than a race. The router is told rather than
asked, the same shape as the endpoint list and for the same reason: a push
happens once per change, a pull would happen once per request.

The router still seeds from configuration as well as being pushed to. A
router with a static endpoint list and no control plane is a supported mode
and has nothing to push it a catalog; without seeding it would resolve no
model at all.

Which turned up a bug that was latent while catalogs were fixed.
reconcileModel only ever visits models that are in the catalog, so a
replica whose model had been removed was visited by nothing: it kept its
GPUs, kept counting, and kept running an engine for a name no client could
ask for. The endpoint publisher skipped such a replica with the comment
"the reconciler will tear it down", which was not true. It is now, and the
comment says what actually happens instead of what was assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 01:08:21 +02:00
Ilker ManapandClaude Opus 5 9d8cd5e284 Track which replicas are actually being used, and summarise the fleet
CI / test (push) Successful in 1m28s
Two things a cluster with many nodes and many GPUs needs that were missing,
and they are the same thing seen from two distances.

Idle tracking. The control plane knew what was placed and whether it was
healthy, never whether anyone wanted it — so on a fleet with more models
than cards there was no way to answer the first question anyone asks, which
is what can I stop. The router is the only thing that knows, so it now
reports it: a timestamp written in Acquire, where a request already meets an
endpoint under a lock already held, and collected once a tick as a map. That
shape is the point. Per-request notification would be a firehose carrying
one bit — the newest timestamp wins — and this costs the same on four
replicas as on four hundred.

It travels through an interface in the direction opposite EndpointSink, for
the same reason that one exists: the control plane decides what should run,
the router knows whether anyone wants it, and neither should need to be in
the other's process to say so.

Unknown is kept distinct from idle throughout. A router that has just
restarted knows nothing, and a zero timestamp would be the oldest idle
replica in every comparison — so one restart would nominate an entire fleet
for eviction. Absent from the map, "-" in the table, negative in the JSON,
and IdleFor returns a second value rather than a duration anything could
act on by mistake.

ctl status. A per-node listing is right for four nodes and wrong for two
hundred: how full am I, what is broken, what is holding cards nobody is
using are aggregates, and reading them off a long table is a chore, which
means it gets skipped. Capacity per GPU class with usage bars, free whole
nodes per fabric — the number that decides whether a multi-node replica can
land, since free cards on the wrong fabric are not capacity for a model that
spans nodes — replica and model counts, models that cannot be placed, and
what has gone unasked-for. Derived from the same state the tables show, so
it cannot disagree with them.

Unhealthy GPUs count as neither used nor free, because counting them free is
how a cluster looks like it has room it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:52:34 +02:00
Ilker ManapandClaude Opus 5 1431426db0 Finish adopting an engine instead of stopping at "it is alive"
CI / test (push) Successful in 1m26s
Adoption set a shard to Starting and started a liveness poll, and that was
all. Nothing health-checked it, so nothing ever moved it to Ready. The
control plane then waited out the profile's startup timeout and killed a
perfectly healthy engine so it could start an identical one.

An agent restart exists precisely so that engines are not interrupted. It
was costing every model on the node a full reload, minutes of it, which is
the failure adoption was written to prevent.

Measured on node05 while deploying today's other changes: the engine
answered /health the whole time and the replica sat in Starting for three
minutes until the timeout reaped it. I had seen this earlier in the session
and worked around it by killing the engine to force a clean restart, which
made the symptom go away and left the cause in place.

An adopted shard now takes the same two paths a freshly started one does: a
rank that serves HTTP is health-checked, a worker is judged by staying
alive. Run in the background, because recovery happens before the agent
connects and a slow check must not delay registration. An engine that will
not answer is left for the control plane to replace rather than killed
locally, which would race the decision it already has a timeout for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:58:53 +02:00
Ilker ManapandClaude Opus 5 d71d785598 Report node memory even when GPU discovery fails
CI / test (push) Successful in 1m31s
Deploying the memory-aware placement showed node01 to node04 reporting 7G
and node05 reporting nothing, on a machine whose /proc/meminfo plainly says
32 GB. node05 has no nvidia-smi, so GPU discovery returns an error, Discover
returns with it, and the memory read — which has nothing to do with cards —
never happened.

That is the node whose memory matters most, because a CPU profile is what a
GPU-less node will be asked to run, and min_memory_gb would have excluded it
from everything.

Read first now, before any path that can return early. The test fails
against the old order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:50:53 +02:00
Ilker ManapandClaude Opus 5 aa2a1fe08f Scale the settling window in the e2e tests instead of leaving them failing
CI / test (push) Successful in 1m26s
4f9fef2 was pushed with test/e2e red, the second time in this session I
printed a success line from an unconditional echo rather than reading the
output above it. The check now gates the commit rather than narrating it.

The failure was the change working as designed: a minute of settling
against tests that wait fifteen seconds. Those tests register the whole
fleet up front, so the window is scaled alongside every other interval
there rather than switched off — the settling behaviour is worth exercising
end to end, not excluding from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:38:09 +02:00
Ilker ManapandClaude Opus 5 4f9fef2a98 Make settling outlast an agent reconnect backoff
CI / test (push) Has been cancelled
The window worked on the cluster and settled on the wrong answer. After a
restart node05 registered in one second and the other four arrived
seventeen seconds later, because a disconnected agent reconnects on a
backoff that reaches thirty seconds. A five-second steady period called a
fleet of one settled and resumed placement while four nodes were still
coming — the exact situation the window exists to prevent, reached by being
impatient about it.

Nothing was misplaced this time only because the model that wanted placing
needed two nodes on a fabric and could not have it. With a single-node
model it would have landed on node05 and stayed there.

A minute now, and steady for half of it rather than a quarter. Adoption is
unaffected, so a restart still recovers everything already running
immediately; only new placement waits.

The test that covers it drives Reconcile the way the ticker does, because
the node count is noticed on a pass rather than the instant a node appears
— which the first version of the test got wrong and hid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:36:35 +02:00
Ilker ManapandClaude Opus 5 84896c0ad2 Add ctl tool-check, so an afternoon becomes one command
CI / test (push) Successful in 1m32s
The catalog now declares whether a model supports tools, which makes it a
claim. This is the claim that can be checked, and the one worth checking:
the gateway forwards a tools array to any model, and an engine that does
not return structured tool_calls looks exactly like a model that ignores
its tools. Every OpenAI-compatible client then behaves as though the model
is broken.

It separates the two diagnoses, which have the same symptom and different
fixes. The model ignoring its tools is fixed by choosing another model. The
model making the call and the engine handing it back as prose is fixed by
changing engine build, and no larger model will help — which is what
today's investigation established only after eliminating the gateway, the
model size, two chat templates and tool_choice in turn.

Against the live engine it says:

  the model called the tool and the engine did not parse it.
    {"name": "get_weather", "arguments": {"city": "Istanbul"}}
  This is the engine, not the model.

engine-check asks whether an engine still accepts our flags; this asks
whether it behaves. Flags were the failure we had seen, so they were the
failure we checked for. Non-zero exit, so a rollout can be gated on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:30:02 +02:00
Ilker ManapandClaude Opus 5 c115681259 Meter a project nobody set limits on
CI / test (push) Has been cancelled
Unlimited was the default. Creating a project without limits is the easy
path and the common one — the "default" project on this cluster is one, and
the person using it is metered by nothing at all. The first sign of that is
the bill or the outage.

auth.default_rpm and default_tpm now apply to a key no scope gives a limit
to. Zero keeps the old behaviour, which is still a reasonable choice; the
point is that it becomes a choice rather than an omission.

Applied after the chain rather than folded in as its widest scope, because
a default is a fallback and not a ceiling: a project deliberately given 600
rpm must keep it, and tightening would silently cap it at the default. An
explicit limit wins in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:28:28 +02:00
Ilker ManapandClaude Opus 5 7e65cdf5c9 Fix the adminapi tests the settling window broke
CI / test (push) Successful in 1m36s
e05d5d4 was pushed with this suite failing. The full run was in front of me
and I printed "green" from an unconditional echo instead of reading it,
which is worse than not having looked.

The failure was real and not a surprise: explain-placement had no
rejections to show, because placement had not run yet. These tests build
the whole fleet before anything starts, which is exactly the state settling
waits for, so they opt out rather than simulate a startup they are not
about.

A negative SettleFor now means off, distinct from zero meaning "use the
default" — a test that wants no window must be able to say so without that
reading as "unset".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:20:12 +02:00
Ilker ManapandClaude Opus 5 e05d5d48ee Do not place on a fleet that has not reported in yet
CI / test (push) Has been cancelled
A control plane that has just started knows nothing, and its first
reconcile tick runs before any agent has registered. "needs 2 nodes on one
fabric, but no node reports a fabric" in the log is that, and it looked
harmless because adoption catches up.

What is not harmless is placing on the fraction of the fleet that happened
to report first. After one restart both replicas of qwen2.5-0.5b landed on
node04 — the only node registered at that instant — when four were coming,
and nothing ever moved them, because the desired count was met.

Placement now waits, and leaves by either of two doors, because either
alone is wrong. Waiting for the window to expire would cost that much
downtime on every restart even when the whole fleet reconnected in a
second; waiting only for the node count to stop changing would hang forever
on a cluster whose nodes never all come back. So: the count holds steady for
a quarter of the window, or the window expires.

Only placement waits. Node expiry, stuck-replica expiry, draining and
reaping all stay correct on a partial fleet, and adoption in particular must
not wait — it is how a restart recovers what is already running, and
delaying it would leave live engines unaccounted for exactly while the
reconciler decides what to build.

The window is paid once per process. A node joining a running cluster is an
ordinary event, not a reason to stop placing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:18:29 +02:00
Ilker ManapandClaude Opus 5 db448a9e87 Stop using a liveness timeout as a generation budget
CI / test (push) Successful in 1m23s
response_header sounds like a check that the endpoint is alive, and for a
streaming request it is: headers arrive before generation starts. For a
non-streaming request the engine sends nothing until the completion is
finished, so the same setting is the total generation budget wearing a
liveness timeout's name.

At 60s that was invisible on GPUs and a hard cap of roughly a hundred
tokens on qwen2.5-coder-14b at 1.7 tokens a second. A request for 120
tokens failed at exactly one minute, and neither the setting's name nor the
error said why.

Two transports now: the short header timeout for streaming, where it still
catches a sick endpoint, and a generation budget for non-streaming,
defaulting to ten minutes. One number could not mean both things.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:15:00 +02:00
Ilker ManapandClaude Opus 5 3081c6f69b Place on nodes that can actually hold the model
CI / test (push) Successful in 1m23s
Placement had no idea how much memory a node has or a model needs. A CPU
profile has no card to be free, so eligibleNodes accepted every ready node:
Qwen2.5-Coder 14B, whose working set is about 24 GB, would have been placed
just as happily on one of the 8 GB nodes, which reports itself perfectly
healthy right up until the OOM killer arrives.

node_selector was yesterday's stopgap and it is the operator restating by
hand something the system can know. Agents now report MemTotal, profiles
declare min_memory_gb, and placement filters on it, naming what was short —
"has 8 GB of memory, needs 24" is actionable where "no candidates were
considered" is not. The label selector stays, because "this node is for
coding models" is a real thing to want to say that memory does not express.

Two deliberate non-exclusions. A profile with no requirement fits anywhere,
which is every profile that exists today. And a node that reports no memory
is still usable: an agent older than the field says nothing, and letting a
control plane upgrade quietly empty half a fleet is a worse failure than the
one this prevents.

ctl nodes grows a MEM column showing "-" rather than 0 for a node that did
not say, because an old agent is not a small machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:12:44 +02:00
Ilker ManapandClaude Opus 5 e9fe3ad379 Know how big a model's context is, and what it can do
CI / test (push) Successful in 1m32s
Three things the gateway did not know about its own models, all of which
surfaced this week as somebody else's bug.

Context length. Nothing read it, /v1/models did not publish it, and an
over-long prompt was not refused: it was admitted, scheduled, given a
replica and a slot, and rejected by the engine in whatever shape that
engine chose. A client sizing prompts from /v1/models had nothing to read.
It is now declared per model, published when known, and checked before
authentication work or a queue slot is spent — with a ten percent margin,
because the estimate is bytes over four and being a few percent out must
not turn a valid request into a refusal.

Capabilities. The gateway forwards a tools array to any model, and one that
cannot use them answers in prose; every OpenAI-compatible client then
behaves as though the model is broken, and the only way to find out was to
try it. Declared rather than discovered, because no engine will say — which
is why these are claims, and why the next commit makes engine-check verify
the tool-calling one instead of trusting it.

And a bug found while wiring the first: the token estimate was taken from
the routing prefix. That prefix stops at 2 KB by design, so every prompt
above it estimated as 512 tokens however long it really was, and TPM limits
undercounted exactly the requests that consume the most. Prefix routing
wants a bounded head of the conversation; a token estimate wants all of it.
They are now separate functions, and structured multimodal content counts
rather than measuring zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:07:49 +02:00
Ilker ManapandClaude Opus 5 ea87021b5c Correct what the previous commit claimed about --jinja
CI / test (push) Successful in 1m30s
9fc098f said passing --jinja fixes tool calling. It does not. llama.cpp
build 10991 has jinja enabled by default — "--jinja, --no-jinja ... (default:
enabled)" — so the flag changed nothing, and tool calls came back as text
before and after it in exactly the same way.

The flag is worth keeping for a different reason, and the comment now says
that one: it pins how prompts are built, so an engine upgrade cannot change
it underneath a running fleet. Older builds do default to the generic
template.

What actually happens is that llama.cpp renders the tools correctly — the
prompt from /apply-template carries the <tools> block and the instruction to
reply in <tool_call> tags — and qwen2.5-coder answers with a markdown JSON
block instead. 3B and 7B both, and tool_choice "required" does not constrain
it either. That is the model, not the plumbing, and no flag here will fix
it.

Recording it because a wrong explanation in a commit message is worse than
no explanation: the next person to see tool calls arriving as text would
have checked --jinja, found it already set, and had nowhere to go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:30:22 +02:00
Ilker ManapandClaude Opus 5 9fc098fd5b Start llama.cpp with the model's own chat template
CI / test (push) Successful in 1m25s
Tool calling did not work, and it looked like the model's fault. Asked to
use a tool, qwen2.5-coder produced exactly the right call —
{"name": "list_files", "arguments": {"path": "."}} — as plain reply text,
with tool_calls null and finish_reason "stop". Every OpenAI-compatible
client, opencode among them, sees a model that ignores the tools it was
given.

The cause is that llama-server falls back to a generic chat template unless
--jinja is passed, and the generic one neither formats the tool definitions
the way the model was trained to read them nor parses what comes back into
tool_calls. It affects ordinary chat too, for the same reason.

A profile can still pass --no-jinja, because a model whose bundled template
is broken has to be serviceable without editing the adapter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:20:55 +02:00
Ilker ManapandClaude Opus 5 c174e46902 Read the filename from a path comment inside the block
CI / test (push) Successful in 1m35s
The commonest convention of all, and the one the extractor missed. Asked
for a two-file Go server, qwen2.5-coder-3b opened a plain ```go fence and
wrote "// cmd/server/main.go" as the first line of the file — not on the
fence, not above it. Both files downloaded as snippet-1.go and
snippet-2.go, which is the feature failing at the first real use of it.

Found by running it rather than by reading it: the unit tests covered the
conventions I had thought of, and passed throughout.

Only the first line or two, only when the line is a comment holding one
token that looks like a path. "// this reads config.json at startup" is a
sentence, "// Package main implements the server." is a doc comment, and a
path comment further down the file is just a comment. The markers cover the
languages people actually ask for, including block comments closed on the
same line.

The comment stays in the body. People write a path at the top of a file
anyway, and editing model output to make a filename look tidier is a
surprise nobody asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:12:00 +02:00
Ilker ManapandClaude Opus 5 45d0dee470 Let a reply full of code be downloaded as a zip
CI / test (push) Successful in 1m25s
Asking a coding model for anything real produces a small tree, and the only
way to get it out of the page was to select each block and paste it
somewhere. A reply containing fenced code now offers a zip, with the
directory structure the model described.

Server-side, because the interesting part is the filenames. They are written
by a language model and they go into an archive that somebody extracts with
a tool that trusts archives, so "../../.ssh/authorized_keys" and
"/etc/cron.d/x" are inputs to expect rather than hope against. archive/zip
and a sanitiser with tests beat string handling in a browser. An absolute
path is made relative — it cannot escape once the leading separator is gone,
and refusing a file over a leading slash throws away work. Anything that
still escapes after cleaning is rejected rather than repaired, because
silently turning ../../evil.go into evil.go puts a file somewhere the reader
did not expect; it gets a generated name instead.

Naming follows what models actually do: on the fence (```go:cmd/main.go,
```main.go, ```go main.go, title="x"), or on the line above it as a heading
or comment. Only the two lines immediately above, so a sentence mentioning
main.go is not read as a filename. A block nobody named still comes back
under a generated name, because losing code because it was unlabelled is
worse than naming it badly, and two blocks claiming one path both survive —
models show a file and then show it again with one line changed.

Nothing is stored: the reply arrives, the zip goes back, neither is kept.
The endpoint sits behind the same session guard and CSRF check as
/chat/send.

Two of these tests asserted the wrong thing when first written — that an
absolute path must be refused, and that hyphens are stripped from the
archive name. Both were corrected to what the code should do rather than
the code changed to match them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:06:07 +02:00
Ilker ManapandClaude Opus 5 0a98d01361 Say when a chat reply was cut off, and stop cutting it so early
CI / test (push) Successful in 1m20s
Reported as the model not completing a request and stopping. It did
complete — it ran out of room. The chat page caps a turn at max_tokens and
then never looked at finish_reason, so a reply that hit the cap ended
mid-word and looked exactly like one that was finished.

The silence was the real defect. A reader has no way to tell "the model had
nothing more to say" from "the page stopped it", and the only clue is that
the last sentence ends abruptly.

512 was also too low. It was chosen when every model here was a 0.5B
answering in a paragraph; it is about a screenful of code, so a coding
model gets cut off inside the first function it writes. 2048 now, which is
still a bound — the reason for having one has not changed.

The notice is appended beside the reply, never into it: that element holds
model output placed with textContent, and building markup in the same node
is how the careful handling everywhere else gets undone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:03:07 +02:00
Ilker ManapandClaude Opus 5 634f87ba15 Provision nodes with the host CPU model
CI / test (push) Successful in 1m26s
Proxmox defaults a VM to kvm64, which hides AVX, AVX2, FMA and even SSE4.2
from the guest. llama.cpp then loads libggml-cpu-x64.so — its scalar
baseline — instead of the backend built for the host's instruction set, and
an inference node is not slightly slower but unusable.

Measured on node05, an 8-core VM on a Xeon E5-2680 v4, same weights, same
thread count, the only change being --cpu host:

  Qwen2.5-Coder     kvm64   host
  1.5B q4_K_M        5.80  39.09  tok/s
  3B   q4_K_M        2.90  20.51
  7B   q4_K_M        1.33   9.88

Prompt processing went from 7.6 to 143.8, a factor of nineteen.

This cost an afternoon because nothing looks broken. The node registers, the
engine starts, the replies are correct; every number is just small, and the
conclusion that suggests itself is that CPU inference is hopeless rather
than that the VM cannot multiply efficiently. A 14B model at 1.7 tokens a
second was nearly written off on that basis.

The trade is live migration, which a VM pinned to the host CPU cannot do.
For a node whose job is arithmetic that is the right way round, and --cpu
overrides it where it is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:42:48 +02:00
Ilker ManapandClaude Opus 5 8a894af1ca Let a profile say which nodes it is for
CI / test (push) Successful in 1m35s
Agents have reported labels since the beginning and nothing read them. That
was survivable while every CPU profile was a 0.5B model that fits anywhere,
and stops being survivable with a 14B one: a CPU profile has no card to be
free, so eligibleNodes accepts every ready node, and placing Qwen2.5-Coder
14B on one of the 8 GB nodes would OOM a machine that reported itself
perfectly healthy.

Nothing in the inventory says how much memory a node has, and inferring it
would be guessing at the wrong layer — the amount that matters is the
model's working set, which the scheduler does not know either. So the
operator says which nodes a profile is for, with the labels that already
exist:

  node_selector:
    ram: 32g

A rejection names the label it did not find, because "no candidates were
considered" is not something anyone can act on and "does not carry ram=32g"
is. An absent selector constrains nothing, which is every profile that
exists today, and there is a test that says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:36:10 +02:00
Ilker ManapandClaude Opus 5 aa4da3264d Make adding a node one command, and check the engine actually runs
CI / test (push) Successful in 1m26s
Building node05 by hand turned up three things worth fixing, in the order
they bit.

Onboarding died on "Host key verification failed" — an error about ssh, in
the middle of a script whose entire job is to talk to a machine created
sixty seconds ago. It now uses accept-new, which learns an unknown host key
and still refuses a changed one; the risk being guarded against is a
substituted machine, not a new one.

Then ssh started refusing connections on a node that had answered a moment
before: cloud-init regenerates the host keys and restarts sshd partway
through a new machine's first minute. The agent was registered and healthy
throughout. Onboarding now waits for ssh rather than failing on it, which
is also what makes running the two halves back to back safe.

Then llama-server: present, executable, and unable to start for want of
libgomp1. The node registered ready and would have stayed ready until a
replica was placed on it and the shard died with a missing shared library
three logs away. The docs already warned about this shape of failure;
nothing checked for it. Onboarding now runs a configured engine and says
plainly when it will not start.

add-node.sh drives both halves. The reason is not keystrokes: the VM name,
the hostname, the node id and the certificate's CN must all be the same
string, and the address appears in three places. Split across two commands
run minutes apart, that agreement is held together by whoever is typing.
Here it is stated once, and the VMID defaults to the last octet of the
address so qm list and ctl nodes stay in the same order. The two halves
remain separately runnable, because needing the hypervisor and needing the
CA really are different access.

Verified by building a throwaway node06 with it: 45 seconds from nothing to
registered, and the engine check caught that node06 had no llama-server.
Destroyed afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:07:56 +02:00
Ilker ManapandClaude Opus 5 082d52bc97 Stop shipping a placeholder that looks like a value, and say what is served
CI / test (push) Successful in 1m23s
A user copied the curl from the profile page and sent it as it stood. Every
other field in that snippet is filled in for them — the base URL, the key
they had just been issued — so "model": "MODEL" reads as a value rather
than a blank, and they got:

  no endpoint serves "MODEL"

which is true and useless. It reads the same whether the name was a typo,
a placeholder left in from the documentation, or a real outage, and it
never says what would have worked.

The message now lists what is being served, capped at ten with a count for
the rest, and says plainly when nothing is. That list is what /v1/models
already returns to the same caller, so it discloses nothing new.

The profile page's example now names a model that is actually running,
real ones before simulated — the point of an example someone can run is
that it runs, and sending a first request to the sim engine would answer
in Greek letters. The landing and claim pages keep a placeholder, because
they are served without a key and deliberately say nothing about what is
here; theirs is <model-name>, which cannot be mistaken for a value, with a
line saying where to get the real one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:23:19 +02:00
Ilker ManapandClaude Opus 5 6f3bde099e Let a multi-node replica be adopted one agent at a time
CI / test (push) Successful in 1m20s
A replica spanning two nodes is reported by two agents, registering
separately about a second apart. Adoption assumed one call carried a whole
replica, so the first half was adopted with WantShards=2 and immediately
failed for "replica has 1 of 2 shards" — and when the second node
registered, its half was condemned as unclaimed because the replica was
already written off.

big-model therefore could not survive a control plane restart. Seen after
today's deploy: adopted, failed, both halves stopped, rebuilt from
scratch, backing off 9s, 20s, 38s, and never becoming ready. The first
reconcile tick also runs before any agent has reconnected, which is the
harmless-looking "no node reports a fabric" that precedes it.

Two changes. Adopt merges a group into a replica it is still assembling
instead of treating it as a competing claim, and takes the endpoint from
whichever group carries rank 0 — the leader's agent is not necessarily the
first to register. deriveState leaves a replica that is short of shards
while still assembling in Starting rather than Failed, which puts it under
the startup timeout that already exists: a sibling that never arrives
still fails, one second later than it used to rather than one second
before its sibling reports in. AdoptedAt is cleared once the replica is
whole, so losing a shard after that is a failure again.

The existing multi-node adoption test passed throughout, because it hands
both shards to a single Adopt call — which no caller makes. Same shape as
the agentsvc bug in c97b671: a test that calls the function under test
directly cannot tell you how the caller calls it. The new test registers
one node at a time, and fails without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:15:00 +02:00
Ilker ManapandClaude Opus 5 72b03a1a1c Say which model is simulated instead of letting it look broken
CI / test (push) Successful in 1m31s
big-model runs on the sim engine, whose whole vocabulary is the Greek
alphabet, cycled at a fixed rate until max_tokens. That is what it is for:
with no H100s it is the only thing exercising the TP8 two-node path, the
fabric invariant and rebuild-after-node-loss. The output is the point.

What was wrong is that nothing said so. /v1/models returned it with
owned_by "llmgw", identical to the real model, and the chat picker sorted
purely by name — so "big-model" led the list, was preselected by the
browser, and anyone who typed a question and pressed Send got Greek
letters back with no explanation. Reported as the model spitting out crap,
which is a fair reading of what it looks like from outside.

Simulated is derived from the profiles rather than configured: an operator
who adds a sim profile and forgets a flag would be shipping placeholder
text under a real model's name. A model counts as simulated only when
every profile is, because one real profile means the request can still
come back as inference.

The marker on /v1/models is owned_by "llmgw-sim", which is already what
the sim engine calls itself on its own /v1/models — the gateway was
flattening a distinction that existed one layer down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:11:56 +02:00
Ilker ManapandClaude Opus 5 cbf276a41b Render maths in the chat, without letting the model write markup
CI / test (push) Successful in 1m20s
Replies are plain text, so a model asked about an integral answers with
\int_0^1 x^2 dx and the reader gets the source.

KaTeX is vendored into the binary rather than loaded from a CDN. This gateway
makes no outbound requests of its own and is expected to run where it could
not make one; a chat page that silently loses its maths on an air-gapped
cluster would be worse than one that never had it. 600 KB of JS, CSS and WOFF2
fonts, which the stripped binary absorbs without changing its rounded size.

The ordering is the safety argument, and there is a test for it rather than a
comment alone. The reply is put in place with textContent exactly as before,
and only then walked for maths: renderMathInElement replaces the spans it
recognises, so nothing the model wrote is ever parsed as HTML and the markup
that appears is KaTeX's. trust stays false — KaTeX's default, and what keeps
\href and \includegraphics out of a reply — and throwOnError is off so one
malformed expression does not take the rest with it.

Rendered once the reply is complete. Typesetting each chunk would try to set
half an expression on every token and flicker through the whole answer.

The static handler now serves a small embedded asset tree rather than one
file, so it moved to its own embed: a file server over the tree that also
carries the templates would serve those too. Directory listings are refused —
nothing links to one, so the only requests are from somebody looking around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:03:46 +02:00
Ilker ManapandClaude Opus 5 3b67b1fe42 Stop the dashboard linking to pages that are not where it is
CI / test (push) Successful in 1m25s
Clicking "metrics" on the web dashboard answered "no api key was provided".

The page is served twice and its navigation assumed one of them. Metrics,
pprof and the management API exist on the admin listener; account
administration, the profile and the chat exist on the client-facing port. The
nav offered the first set in both places, so on the public port those links
fell through to the API-key middleware — which is a far more confusing way to
say "that is not here" than a 404 would have been.

Each page now offers what is actually reachable from it, and the public one
says where the rest lives rather than pretending it does not exist, including
why that port is not exposed: pprof can dump the process heap, and the heap
holds every API key in plaintext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:57:58 +02:00
Ilker ManapandClaude Opus 5 f68fbf7c49 Check llmgw's engine flags against real vLLM and llama.cpp
CI / test (push) Successful in 1m25s
The launch arguments had been read from documentation and never from an
engine. Now they have been:

  vllm 0.29.0    --host --port --tensor-parallel-size --served-model-name
                 --distributed-executor-backend      all accepted
  llama.cpp b10991  --model --host --port --metrics --alias --parallel
                 --n-gpu-layers                      all accepted

Getting there found three faults in the checker rather than in llmgw.

It checked every command's flags against the engine's help, but a shard can be
several programs — vLLM under Ray is `ray start` and then `vllm serve` — and
they have different vocabularies. It reported --head and --num-gpus as
missing, which is true of vllm and irrelevant, because ray is what receives
them. Each command is now checked against its own help, and one that cannot be
run is reported as skipped rather than failed.

vLLM does not give up its whole help for --help: it prints a summary of
section names and expects --help=all. Checked against the summary, all five
flags looked missing. The warning about truncated help did not fire, because
the summary happens to mention --help= while listing how to ask for more.

And vLLM refuses to print anything at all on a machine with no accelerator
unless VLLM_TARGET_DEVICE says what to assume. Asking an engine about its
flags should not require the hardware it runs on.

Two things remain unchecked and are written down as such: the Ray sidecar,
because vLLM 0.29 does not install Ray, and SGLang, because it is not
installed anywhere. `launch_mode: mp` avoids Ray entirely and is worth
preferring until someone checks it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:52:11 +02:00
Ilker ManapandClaude Opus 5 5b425c32e5 Ship binaries with releases, and ask engines whether our flags still exist
CI / test (push) Successful in 1m31s
A release was a git tag. Installing llmgw on a node meant having a Go
toolchain, and the onboarding script copied a binary from wherever it was run —
which works and quietly makes whoever runs it the build server, with the fleet
ending up on whatever was in their working tree. Tags now build static
linux/amd64, linux/arm64 and darwin binaries and attach them. CGO_ENABLED=0 is
the point: it has to run on a node whose libc is older than the builder's, and
llmgw has no C dependency because modernc.org/sqlite is pure Go precisely so
this stays true. Verified: "not a dynamic executable", runs on a node, reports
its version.

`ctl engine-check` closes the other half of the phase 4 gap. llmgw's launch
arguments are read from each engine's documentation, and engines rename flags
between versions; when that happens the failure arrives minutes into a model
load, in the engine's own error message, on a node nobody was watching. It
builds the widest command llmgw would ever produce — multi-node, since a flag
that only appears there is the one nobody notices has gone — and checks each
flag against the engine's own --help. No GPU: a CLI prints its help anywhere.
Verified against llama.cpp b10991, all seven flags accepted.

Flags are matched on a word boundary, so --port is not satisfied by
--port-range. Without that, a renamed flag would sail through the check that
exists to catch renamed flags.

It also warns when the help text looks truncated, which is how the first run
went: llama.cpp's help cut to 400 of its 725 lines reported five flags as
missing that the running cluster uses.

TestSidecarRunsBeforeEngine was flaky under a full run, and worse than flaky —
its wait loop fell through on timeout, so a slow machine turned the ordering
assertion into a no-op and the failure surfaced later as something else. It now
asserts, with a deadline that suits a machine running every other package at
the same time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:44:29 +02:00
Ilker ManapandClaude Opus 5 7b4acd9284 A replica missing a shard is not ready
CI / test (push) Successful in 1m23s
Restarting the control plane over a running two-node replica adopted half of
it. Every shard it still had was healthy, so it was reported Ready — with one
shard of two and no endpoint at all, because the missing one was rank 0. The
router was handed a replica nothing could route to.

The cause is that a replica had no idea how many shards it should have. It
could tell "every shard I have is ready" and nothing else, which is the same
sentence whether or not half of it is gone. It now carries the count from its
profile, set at placement and again at adoption, and a replica short of shards
fails rather than pretending.

Onboarding also wrote no engine paths, so a node onboarded by the script could
not find llama-server and failed every shard placed on it with "executable
file not found". The script now takes them, and says so when none are given —
a node with no engine paths can run the sim engine and nothing else.

The cluster now runs both at once: two-node simulated replicas on each fabric,
and real llama.cpp inference on the CPU, which is a better test bed than
either alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:43:51 +02:00
Ilker ManapandClaude Opus 5 c97b671ef9 Prove the multi-node path on a simulated cluster
CI / test (push) Successful in 1m29s
Phase 4 had been written and never run. The hardware is not coming, so the
cluster is simulated instead — four nodes across two fabrics, eight GPUs each,
built with the onboarding scripts from the previous commit.

`sim` is now an engine adapter, not just a standalone command. Its leader binds
a rendezvous port and does not serve until every worker has joined; its workers
serve nothing at all. That is the part stub processes could never exercise, and
it is why this path survived four phases untested: the command line was
checked, the sequence was not.

Measured on the cluster:

- a 2-node replica on each fabric, node01+node02 on ib-a and node03+node04 on
  ib-b, four GPUs from each
- a request served through one of them
- only the leader listening — node02 had no port open at all, which is
  ServesHTTP working end to end rather than in a unit test
- node02 killed; at t+60s it was declared lost, the ib-a replica failed and was
  reaped; at t+80s a replacement was ready on ib-b, the fabric that could still
  host it, while node01 sat idle rather than being paired across fabrics

The invariant held under the one condition that tests it: when a replica could
not be rebuilt where it was, it moved to a fabric that could hold all of it
rather than spanning two.

What this does not prove is written into the plan next to what it does. The
GPUs are an inventory, the fabric is a sysfs-shaped directory, and the engine
speaks no NCCL — so the launch sequence is now exercised while the launch
arguments for vLLM and SGLang are still read from documentation rather than
from a running engine.

docs/node-onboarding.md is the procedure, including how to run all of this on
machines that have neither GPUs nor InfiniBand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:34:23 +02:00
Ilker ManapandClaude Opus 5 c1ac2f022f Onboard a node with a script, and find two examples that never worked
New nodes had been joined by hand, four times, by me. There was no procedure,
so there was nothing to be wrong — and nothing to be right either. Two scripts
now: provision-node.sh makes the VM on Proxmox, onboard-node.sh gives it an
identity. They are separate because they are run by different people with
different access, and the second must run where the certificate authority is,
which is the one part that cannot be delegated.

Running them turned up why phase 4 was never verified. FabricAttachment
carried no koanf tags, so the `name:` key the shipped example documents bound
to nothing and `rate_gbps` was never read at all. An agent configured exactly
as instructed refused to start with "fabrics[0]: name is required" — naming
the key the operator had just written. Every fabric in this project had been
configured in a Go test, where the tags do not matter.

The server example did not load either: its models had no source, so it failed
validation the moment anything tried to parse it.

Both examples are now parsed by a test. A published example that has never
been loaded is a guess, and these two had been guesses since they were
written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:28:36 +02:00
Ilker ManapandClaude Opus 5 a075ff4120 Add approximate prefix routing, and find out it did nothing
CI / test (push) Successful in 1m19s
Tier 1 of plan §7.5: a radix tree of request prefixes that estimates which
replica has already prefilled one. Bytes, not tokens — needing the engine's
exact tokenizer is what makes tier 2 a separate project.

It went in inert, and the decision metric is what caught it: 144 lookups, zero
hits. The cause was a rule I had written and tested for. A node's label is the
first request that created it, so the second request for the same system
prompt diverges in its last few bytes — a new question after the same
preamble, the single most common shape there is. Requiring the whole label to
match threw away the 700 bytes they shared. What matters is the shared head,
because that is what the engine cached, and a partial label match now counts.
Two unit tests asserted the wrong rule and have been inverted.

With that fixed, measured end to end against engines whose caches are too
small for the working set: 22.5% cache hits without, 81.7% with, and the
breakdown shows the misses are exactly the first round, when the tree is
empty.

The scenario matters as much as the number. With a handful of prompts and a
large cache, scattering is free — every replica ends up holding everything —
and prefix routing can only add constraint. The first version of the test
measured that case and showed a loss, which was a true statement about a
situation the feature is not for.

Two things keep it from being the disaster prefix routing usually is. The
imbalance guard, which needs both an absolute and a relative gap before it
fires, because either alone misfires — absolute on an idle cluster, relative
on a busy one. And it is a scorer rather than a filter: affinity is a vote the
queue and inflight scorers can outvote, not a rule.

The tree records the replica the picker actually chose, not the one the scorer
proposed, and a node prefers the replica it has seen most rather than most
recently — a count is stable under a randomizing picker, where recency just
chases whatever the picker last did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:13:19 +02:00
Ilker ManapandClaude Opus 5 de21649ad6 Add an in-browser chat, without putting a key in the browser
CI / test (push) Successful in 1m13s
So someone can try a model without setting up a client, and — more often —
tell whether a problem is the gateway or their code.

The page carries a session, and the server turns that into the identity an API
key would have produced, resolved from the person's project. The request then
goes down the ordinary path by being handed to the same handler /v1 uses:
admission, model allowlist, rate limits, budget, accounting. There is exactly
one request path, so there is no second place for its rules to drift — and
without that, a playground is a hole in every limit the rest of the gateway
enforces. The allowlist test posts a forbidden model and expects 403 for that
reason.

The body is rebuilt rather than forwarded, so the page cannot set fields it has
no business setting: a stream:false that would hang the reader, a max_tokens
large enough to occupy a replica for an hour. One turn is capped at 512 tokens.

The picker offers what is actually served and allowed, not what is catalogued,
because offering a model with no replica produces a 503 the reader cannot
interpret.

Inline vanilla JavaScript, for the same reason the charts are hand-drawn SVG.
It streams, because on CPU inference ten seconds is normal and a chat that
hangs and then dumps a paragraph feels broken. Replies are written with
textContent throughout: model output and user input have no business being
parsed as markup. Its POSTs carry the CSRF token in a header, which a
cross-site form cannot set at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:05:30 +02:00
Ilker ManapandClaude Opus 5 859f08da9b Approve accounts from the browser, and draw the traffic
CI / test (push) Successful in 1m19s
Two things the dashboard was missing: it could not act, and it was a column of
numbers.

Account administration moves to /admin/users — approving into a project,
suspending, reinstating. That is the work an operator does constantly and it
should not need a terminal. Three things stay on the CLI, and the line is not
arbitrary: promoting someone to admin, resetting a password, and issuing a key
on another person's behalf each turn a stolen admin session into more than it
was — permanent access, or somebody else's account. Approving and suspending
do not. Suspending signs the account out everywhere, or suspension is advisory
until the session happens to expire, and an admin cannot suspend themselves,
which is not a thing an interface should let you do by misclicking.

The charts are inline SVG generated in Go. A charting library would mean a
JavaScript toolchain in CI and a megabyte of bundle for what is a list of
rectangles, and the single-binary goal rules that out. Hourly over a day: fine
enough to show a working day, coarse enough that one slow minute is not an
incident. Empty hours are filled in, because a chart with hours missing where
nothing happened reads as a chart with hours missing from the data.

The SVG is marked as trusted HTML, so everything that reaches it is either a
number this package computed or escaped explicitly, and a bar's colour can only
be one of four names — a colour that could be anything is a way to write CSS
into every page the chart appears on. There are tests for both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:01:42 +02:00
Ilker ManapandClaude Opus 5 e519dc587c Serve the cluster dashboard over the web, without serving pprof with it
CI / test (push) Successful in 1m21s
Reaching it meant an SSH tunnel, because the page lived on the admin listener
and that listener is bound to loopback. Opening the port was never the way to
fix that: it also serves pprof, and pprof can dump the process heap, where
every API key sits in plaintext. Exposing the port would have been exposing
the key store.

So the page moves rather than the port. The same dashboard is now mounted at
/admin on the client-facing port, behind the session and CSRF machinery that
already guards /profile, gated on a flag on the account. The admin listener
keeps pprof and metrics and stays on loopback.

One bit, not a role system: it grants looking. Everything that changes the
cluster or issues a credential stays on the CLI, where it needs a shell rather
than a session — which keeps the worst case of a stolen admin cookie to "saw
the cluster" rather than "drained your fleet".

Someone without the flag gets 404, not 403. A 403 confirms the page is there
and that admin accounts exist here, which is free reconnaissance; 404 is what
every other unknown path returns. The profile page does not show a link to a
door the visitor cannot open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:46:45 +02:00
Ilker ManapandClaude Opus 5 058ee382fa An agent reporting nothing is reporting something
CI / test (push) Successful in 1m18s
The previous commit taught the reconciler to let go of shards an agent says
it is not running. It made no difference on the cluster, because the service
returned early when the agent's list was empty — which is precisely the case
that carries the news.

So the fix was dead code in production while its unit tests passed, and they
passed because they called the reconciler directly. The test added here goes
through the real stream instead, and asserts only that an agent running
nothing reaches the reconciler at all.

That is the lesson worth keeping: a test that calls the function under test
directly cannot tell you the caller never calls it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:36:35 +02:00
Ilker ManapandClaude Opus 5 aef1c143c5 Let go of shards an agent says it is not running
CI / test (push) Successful in 1m19s
Adoption handled "the agent has something we do not know about". The mirror
image was missing: "we think the agent has something it does not".

Both follow from the same rule — the fleet is authoritative about what is
running (plan §6.5) — and without the second half an engine that dies while
its agent survives leaves the control plane believing a replica is Ready
forever. Measured on the cluster: the engine was killed, the agent restarted
and registered with nothing running, and the replica stayed Ready pointing at
a dead port for two and a half minutes until I stopped watching. Traffic
survived, because the breaker took the endpoint out and retries covered it —
24 requests, none failed — so the only symptom was a cluster quietly running a
third short.

A registration follows a fresh connection, so anything dispatched over a
previous one is not running by definition; there is no window where a shard is
legitimately started but unreported.

The previous commit's disown rule was too broad, which this turned up: when a
known replica had a live shard it disowned everything the agent reported for
it, including the shards we already knew. A reconnecting agent would have been
told to stop the replica that was serving. It now disowns only what it does
not recognise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:30:49 +02:00
Ilker ManapandClaude Opus 5 555e4678cb Ship the systemd units, and stop the plan lying about what is done
CI / test (push) Successful in 1m16s
Phase 3 had a block duplicated into itself, including an unchecked
'EndpointSource becomes dynamic' sitting directly under the checked line
saying it was wired. Several phase 5 items had shipped without being ticked.

The units were only ever on the machines and in a scratch directory, which
makes them undocumented operational knowledge rather than part of the project.
KillMode=process in particular is not obvious and not optional: without it
systemd takes the engines down with the agent, turning a thirty-second upgrade
into a model reload on every node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:04:44 +02:00
Ilker ManapandClaude Opus 5 378383dd69 Align the bench table, which a tabwriter cannot
CI / test (push) Successful in 1m16s
Rows are printed as each concurrency level finishes, and a tabwriter lays out
only what has been flushed — so it aligned the header and the first row to
each other and every row after that to nothing. Fixed columns instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:59:55 +02:00
Ilker ManapandClaude Opus 5 5c837c531b Pin down where CPU replicas land, and where they deliberately do not
CI / test (push) Successful in 1m15s
Two tests for behaviour that looked wrong on the cluster and was not. Three
replicas all landed on one node, which is correct when it is the only node
registered: spreading is a preference and a model left unplaced to preserve a
preference would be worse. Over successive ticks with somewhere to go, they
spread.

What neither test asserts, because it is not true: nothing rebalances them
afterwards. A replica that moved on its own would interrupt the traffic it is
serving, and v1 has no autoscaler to decide it is worth it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:57:12 +02:00
Ilker ManapandClaude Opus 5 8a062fec35 Stop reusing replica ids, and stop handing out ports strays still hold
The previous commit's adoption fix made a deeper problem visible instead of
fixing it. Replica ids were the model name and a counter, and the counter
restarted with the process — so a control plane that came back placed a
replica called llama-1 while an engine started before the restart, still
running on a different node, also called itself llama-1.

Two nodes then claimed the same shard id. Adoption handed it back and forth
between them, every placement landed on a port an orphan was still bound to,
and the engine failed to bind, which failed the replica, which placed another
one on the same port. On the real cluster that loop ran for minutes:

    couldn't bind HTTP server socket, hostname: 0.0.0.0, port: 20002

Ids now carry four random bytes, which costs nothing and makes the collision
impossible. The "already known" branch in adoption goes back to meaning what
it says: an agent reconnecting, not a name clash across restarts.

Ports a stray still holds are also reserved now. Stopping is not instant and
an engine keeps its socket until it exits, so the allocator has to know about
sockets no replica claims — otherwise the same loop reappears in the window
between telling an agent to stop something and it actually stopping.

Two of the tests written for the previous commit had quietly become vacuous
under the new ids, asserting things about a literal llama-1 that no longer
exists. They capture the id now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:54:57 +02:00
Ilker ManapandClaude Opus 5 05bdc7d6d4 Add ctl bench, and fix two holes it walked into
`ctl bench` measures what a replica can actually serve, so weight,
target_waiting and kv_ceiling stop being numbers somebody typed. Concurrency
climbs until the SLO breaks and the last level that met it is the capacity:
peak throughput arrives long after latency has become unusable, so throughput
alone overstates what the hardware can serve. Queue depth and KV usage are
read from the engine while it is still meeting the target, which is exactly
what admission should treat as healthy.

Prompts are unique per request by default. With one repeated prompt the
engine's cache answers most of the prefill and the measurement describes the
cache rather than the hardware; --shared-prefix asks that question
deliberately, and it now produces byte-identical prompts, which the first
version did not — it dropped the random header and still picked words at
random, so the cache missed and the flag quietly measured the thing it exists
to avoid.

Running it against the cluster turned up two real defects:

A replica stuck in Starting never timed out. Every other way out of that state
needs the agent to report something, and when it never does the replica sits
there holding GPUs and counting towards the model's replica count while
serving nothing. Two of them had been like that for eight minutes past a
two-minute startup timeout, and nothing would ever have moved them.

They were stuck because adoption lost to a replica we had only placed. Replica
ids come from the model name and a counter, so a control plane that restarts
and places before its agents reconnect builds a different replica under the
same id on a freshly allocated port; the agent's report of the engine that was
actually serving was then discarded as already-known. The cluster pointed at
port 20004 while the engine answered on 20000. The fleet is authoritative
about what is running, so what we merely placed now loses to what an agent
reports — unless our copy has a shard that has actually come up, in which case
two things really are running and the stray is the one we did not ask for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:51:37 +02:00
Ilker ManapandClaude Opus 5 ec943f9587 Stop a one-in-sixty-two test from failing once in a while
CI / test (push) Successful in 1m14s
TestRejections built a "wrong" key by replacing a valid one's last character
with X. Keys are base62, so one in sixty-two already ends in X and the wrong
key was the right one, which authenticated and failed the assertion.

It passed locally all day and failed in CI, which is how long a flake of that
shape takes to look like a real bug — long enough that the instinct on seeing
it is to go looking in the authenticator.

The replacement is now chosen to differ from what it replaces, with a test on
the helper itself: a mutation that can return its input turns the test it
serves into one that quietly checks nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:42:20 +02:00
Ilker ManapandClaude Opus 5 79e459d9ff Let people change their own password, and operators reset a lost one
CI / test (push) Failing after 1m11s
The account system shipped without either, which made the first forgotten
password an unanswerable support request.

Changing it requires the current one. A session is what an attacker gets from
a borrowed laptop or a stolen cookie; the password is what they do not have,
and without the check the first thing they would do is take the account.

Every session is closed, including the browser doing the changing — leaving
the others open would mean a password change does nothing about the reason
someone is changing it. A new session is then opened for the browser in front
of us, so the person who just proved their password is not thrown back to the
sign-in page. That response also carries a new CSRF cookie, so the forms it
renders take the new token rather than the one the request arrived with; there
is a test that posts a form from that exact page.

Guessing the current password is bounded the same way sign-in is, because
someone holding a stolen session should not get unlimited attempts at the
password that would let them keep it.

There is no self-service reset: this gateway sends no email, and a reset link
with nowhere to send it is not a reset. `llmgw users reset-password` is the
operator's answer, and it closes their sessions too — if someone lost their
password, the reason might be that somebody else has it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:39:11 +02:00
Ilker ManapandClaude Opus 5 216fc9bb03 Let people sign up, be approved, and issue their own keys
CI / test (push) Successful in 1m9s
Every key needed an operator with a shell. This adds the login system that
made that necessary, which is the thing I argued against building until it was
asked for — so it is built as if it will be attacked, because a public login
form is.

Signing up produces an account that can do nothing. Approval is what makes it
work and stays on the CLI: having a shell on the control plane is what makes
someone an administrator, and that is a smaller thing to get right than roles
and an admin UI that mints credentials.

The details that matter:

Passwords are argon2id, where a slow hash is correct — the input is chosen by
a human and has a dictionary, which is the opposite of an API key. Both kinds
of authentication now live in this codebase and they are in separate packages
so neither set of assumptions leaks into the other. Length is the only rule:
composition rules push people towards Password1! and cost more entropy than
they buy.

Signup never says whether an address already has an account, and sign-in
answers the same way for a wrong password and a missing account — verifying
against a dummy hash when there is no user, so a stopwatch cannot enumerate
accounts. Account state is checked after the password, not before, so guessing
an address reveals nothing about it. Guessing is bounded per address rather
than per IP: an attacker has many addresses to come from and one to guess
against, and limiting by IP locks out an office behind one NAT.

Sessions are server-side, so signing out and suspending take effect
immediately rather than whenever a token expires; suspension is checked in the
same query that resolves the session, because a second round trip is a check
somebody eventually forgets. Every state-changing form carries a CSRF token
verified against a hash in the session — a cookie authenticates the browser,
not the page that asked.

Key ownership is enforced in the UPDATE rather than by reading first, so a
guessed key id from another account changes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:32:35 +02:00
Ilker ManapandClaude Opus 5 8d35faf6f8 Give the landing page somewhere to send people
CI / test (push) Successful in 56s
The page explained the API, said keys come from an operator, and left the
reader there. Someone who reaches the address without a key had no route to
getting one, which is where onboarding stopped.

`landing.contact` is that route, because the gateway cannot guess who to ask.
An address becomes a mailto link, an http(s) URL stays a link, and anything
else — a channel, a queue, a person — is shown as text. Other schemes are
never linked: this string comes from configuration, configuration is not
always written by whoever runs the gateway, and a javascript: href on a page
served to every visitor is a short path to a stored XSS. Escaping the text
does not make a scheme safe.

Unconfigured, it now says something rather than nothing, and the page explains
that a key arrives as a one-time link so the reader knows what to expect
before it lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:22:42 +02:00
Ilker ManapandClaude Opus 5 3ed211b2f4 Onboard users with one-time links instead of pasted keys
CI / test (push) Successful in 2m43s
Issuing a key worked; getting it to the person it was issued for did not. The
operator had a secret on their terminal and no way to send it that did not
leave a bearer credential in a chat log forever.

`keys create --claim` prints a link instead. GET shows a button and consumes
nothing; POST reveals the key and burns the link. That split is not politeness
— chat clients, mail scanners and link previewers fetch URLs the moment they
are posted, so a GET that consumed the claim would mean the first thing to
unfurl the link destroys the key before anyone reads it.

A pending claim has to hold the key somewhere, which would otherwise put a
working credential in the database and undo what hashing bought. So the key is
sealed with a key derived from the link's own token, and the token is never
stored — only its hash. A database leak yields two useless hashes and a blob
nothing in the database can open. HKDF rather than the raw token, so the value
used for lookup can never become the value that decrypts.

Consuming is one UPDATE moving claimed_at off NULL, so two people opening the
same link produce one key rather than two copies. A used link, an expired one
and a token that was never real all render identically, because saying which
confirms that a token was once valid. The page is no-store, no-referrer and
noindex: the URL is itself the credential.

`GET /me` reports a key's own limits and spend, so nobody has to ask an
operator to read a dashboard they cannot reach. Outside /v1, because it is not
part of the OpenAI API.

docs/onboarding.md is written for whoever operates this, including the parts
that are deliberately absent — no self-service, no SSO, and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:11:28 +02:00
Ilker ManapandClaude Opus 5 cd3233ffe8 Do not leave the key store world-readable
CI / test (push) Successful in 1m1s
SQLite creates its file with the process umask, which on a stock system means
every account on the machine can read it. What is in there is not a working
credential — keys are stored hashed — but it is the whole tenancy tree and
every request anyone made, and that should not depend on an operator
remembering to chmod it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:01:41 +02:00
Ilker ManapandClaude Opus 5 1839832b8b Add a landing page and a read-only cluster dashboard
CI / test (push) Successful in 1m2s
Pasting the gateway's address into a browser returned 404, which looks like a
broken endpoint rather than one that tells you how to use it.

Two pages, and the split between them is a security boundary rather than a
layout choice. The landing page is served without a key — it has to be,
because the person reading it is the one who does not have one yet — so it
contains no data at all: it says how to connect, not what is here. Model names
stay behind /v1/models. It reconstructs the address from the forwarded headers
so the copy-pasteable example names the address the client actually used
rather than the internal one; those headers are reflected into displayed text
and never used for a decision.

The dashboard shows nodes, models, replicas, placement failures and usage, and
lives on the admin listener only. That listener is loopback-bound and also
serves pprof, which can dump the process heap — where API keys sit in
plaintext — so reaching the page from elsewhere is an SSH tunnel's job. It is
read-only: anything that mints a credential stays in `llmgw keys`, where it
needs a shell rather than a browser session this project has no way to
authenticate.

Mounting the landing page uncovered a hole in the exemption list. An entry
ending in "/" was a prefix rule, and "/" ends in "/", so exempting the landing
page would have exempted every path there is — authentication still installed,
still logging, and letting the whole API through. "/" is now exact, with a
test enumerating what must and must not be exempt.

Server-rendered templates and one stylesheet, embedded. The single-binary goal
rules out a JavaScript toolchain and nothing on either page needs one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:58:05 +02:00
Ilker ManapandClaude Opus 5 0b93fb2883 Enforce budgets and rate limits, and write usage off the request path
CI / test (push) Successful in 1m7s
The second half of phase 5. A key now has a cost, a quota and a bill.

Rate limits use a sliding window rather than a fixed one. A fixed window lets
a caller send its whole minute's allowance in the last second of one window
and again in the first second of the next, so the observed rate is twice the
configured limit exactly when the system is least able to take it. Checking
and charging are one operation, or two concurrent requests both see room and
both take it.

Token limits are checked against an estimate, because the real count only
exists once the request has run; the difference is charged afterwards, so a
caller who consistently underestimates is still bounded, one window late. A
request with no max_tokens gets a modest assumption rather than zero —
assuming zero would let an unbounded request through every token limit.

Limits are checked before admission, not after: a request over its quota
should be refused rather than queued, where it would occupy a band's capacity
to no purpose. All refusals are 429, but a spent budget carries no Retry-After
because waiting does not help until the period rolls over, and saying
otherwise turns one client into a retry loop.

Usage goes to a buffered channel and is written in batches. Record never
blocks: if the buffer fills — a database stalled long enough to lose eight
thousand records — records are dropped and counted, because losing accounting
is bad and turning a stalled database into an outage is worse. A clean
shutdown drains what is queued; those are requests that were already served.
Failures are logged at error, since silently losing billing data is the kind
of thing discovered at the end of a month.

Requests are accounted whatever their outcome. A request that used a GPU and
then failed still used it, and a key that could spend nothing by failing would
be a way around a budget.

Budgets are counted in memory and restored from the store at startup, so a
restart does not hand every key a fresh allowance. The budget period travels
with the reservation rather than being re-derived, because a monthly budget
credited to a daily counter is a budget that never binds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:52:36 +02:00
Ilker ManapandClaude Opus 5 45c8cd23a3 Close the gateway: API keys, tenancy and model allowlists
CI / test (push) Successful in 2m34s
Until now anything that could reach the client port could use every model in
the catalog. A key now resolves to an identity, and that identity decides what
it may do.

Authentication wraps the whole mux rather than checking inside each handler,
so adding a route cannot add an unauthenticated one by omission. Health stays
open, because a load balancer has no key and a health check that needs one
reports the gateway down.

Nothing on the request path reads the database. A key is resolved once and
answered from memory until the entry expires; rejections are cached too, so a
client looping with a bad key cannot turn into a database read per request.
The price is that a revocation takes up to cache_ttl to take effect, which is
the trade stated in the configuration next to the knob. A store failure is
not an authentication answer: it is never cached as one and returns 503, not
401 — sending someone hunting for a key problem that does not exist wastes the
one thing an incident is short of. A revoked key is refused with the same
message as an unknown one, because saying otherwise confirms the key was real.

With auth on the band comes from the key and the band header is ignored. The
header was trusted only while there was nothing to consult, and it let any
client promote itself into the top priority band. Tenant fairness within a
band is now per key rather than per session, so one caller cannot take a band
by opening many sessions.

Allowlists are decided per model, not per string: a model's canonical name and
every alias name the same thing, so either form works in an allowlist and no
alias can reach a model that was not allowed. The first version of that test
passed without the resolution being there at all, which is why it now checks
all four combinations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:44:33 +02:00
Ilker ManapandClaude Opus 5 29617f8cb1 Add the store: tenancy, keys and usage on SQLite
Phase 5 begins. This is the persistence layer for the things the cluster
cannot reconstruct — who may call, what they are allowed, what they used.
Everything else llmgw knows is still derived from the fleet, which cannot be
wrong about itself the way a database can (§6.5).

SQLite behind an interface. The plan justified that by write volume, which was
the wrong argument: the write path is batched anyway, and a 500-row
transaction every 200ms is nothing for either engine. The real argument is the
failure mode. Postgres is a second service, and a gateway that stops serving
because its accounting database is unreachable is worse than one that buffers
some accounting. Embedded means the store shares the gateway's fate instead of
adding an independent way to be down. That reasoning stops holding at two
instances, where budgets have to be counted somewhere shared — so the
interface is here from the start, the SQL is portable, and migrations are per
dialect with shared version numbers.

Keys are stored as sha256 and nothing else, so a database leak hands over no
working credential. The fast hash is deliberate: the input is 256 random bits
with no dictionary to attack, so argon2 would buy nothing and charge tens of
milliseconds per request. That assumption is written down next to the code
that depends on it.

Two things the schema now says that it did not: budgets are integer
micro-units, because repeated addition of floats drifts; and an empty model
allowlist forbids everything while an absent one permits everything, which the
resolver has to keep distinct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:37:40 +02:00
Ilker ManapandClaude Opus 5 41cf894c72 Tick the phase 1 and 1.5 boxes their releases already earned
The code shipped in v0.2.0 and v0.3.0 and both exit criteria were measured;
only the checkboxes were left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:27:03 +02:00
Ilker ManapandClaude Opus 5 f84e3b642a Record the llama.cpp work in the plan and changelog
CI / test (push) Successful in 58s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:19:55 +02:00
Ilker ManapandClaude Opus 5 f19db242ae Stop naming a metric llama.cpp does not have, and say so when it happens
Deployed against real llama.cpp, every scrape failed. The mapping named
llamacpp:kv_cache_usage_ratio; build b10991 serves fifteen metric families
and none is a cache ratio. Because KV usage was mandatory, that one absent
name threw away the two metrics the engine does publish, and the router
scheduled to all three replicas blind while the cluster looked healthy.

A mapping that leaves a metric unnamed now means the engine does not publish
it, and the field stays zero. Naming one the engine does not serve is still a
violation, so this does not become a way to tolerate a typo. Running and
waiting can never be omitted: an engine that reports neither what it is doing
nor what is queued cannot be scheduled against at all. For llama.cpp this
costs the KV half of the saturation signal, which is a real loss softened by
its fixed slot count — queue depth already says when it is full.

The second half is why it took a deployment to notice. A contract violation
was logged per scrape at debug level, so at the default level nothing said
anything. It is now a warning, once, with a matching line when it clears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:18:27 +02:00
Ilker ManapandClaude Opus 5 501db3d3e8 Add the llama.cpp engine, and let a profile ask for no GPU
llama.cpp is what makes a cluster with no cards useful: it serves the same
OpenAI API on CPU, so the whole path — placement, supervision, scraping,
scheduling, admission — can be exercised end to end on ordinary machines.

Three things had to give way for it.

A profile may now omit its GPU class, and that is how it says it runs on the
CPU. Only engines that can actually serve without a card may omit it; for
vLLM an omitted class stays an error, because otherwise a typo becomes a
replica placed on a machine with no hardware and diagnosed minutes later
during model loading. Placement spreads CPU replicas instead of bin packing
them, since unlike GPUs there is no allocation to stop two of them sharing a
machine's cores.

Metric mappings are now per engine rather than per cluster. llama.cpp calls
the same three numbers requests_processing, requests_deferred and
kv_cache_usage_ratio, and a single global mapping would leave half a mixed
fleet unmeasurable. An empty mapping still defaults to vLLM's names; one that
names its own Running metric is taken as a complete statement, so the fields
it leaves blank stay blank rather than being filled with names that engine
cannot have.

The adapter refuses multi-node and tensor-parallel profiles. llama.cpp splits
across local GPUs by a different mechanism than this adapter drives, and
accepting either would produce a replica that starts and then serves from one
machine while the catalog believes it has several.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:14:05 +02:00
Ilker ManapandClaude Opus 5 ec1b6b04df Fix an agent that could neither explain itself nor stop
CI / test (push) Successful in 54s
Two bugs found while verifying mTLS against real machines.

session cancelled its own derived context and then asked that context
whether the agent was shutting down. The answer was always yes, so every
stream error read as a clean close: the log said err=<nil>, the backoff
reset, and an agent whose certificate the control plane refused hammered it
forever at the base interval with nothing to diagnose. It now keeps the
parent context to answer that question, and a refusal reaches the operator
naming both the certificate and the claim.

Cancelling the context also did not stop the agent. HTTP/2 watches the
context while the request body is being written, and a bidirectional stream
spends its life blocked on the read side instead, so the read never returned.
Measured on a deployed node: `systemctl stop llmgw-agent` took 90 seconds and
ended in SIGKILL, on every restart. Closing the response when the context
ends is what actually unblocks it.

TestAgentStopsWhenContextEnds hangs reliably without the second fix, which is
the only reason to trust that it tests it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:03:21 +02:00
Ilker ManapandClaude Opus 5 1e3cafdc86 Add a private CA and mutual TLS for the agent stream
A node's identity was whatever the agent said it was. With mutual TLS the
control plane compares the client certificate's common name against the
claimed node id and refuses the connection when they disagree, so identity
becomes something a node proves rather than something it picks.

A public authority cannot supply these certificates: Let's Encrypt and its
peers stopped issuing the clientAuth usage, and Go verifies client chains
against it. Hence internal/tlsconf/ca.go, a small authority whose key lives
on one machine and whose root is trusted everywhere, and `llmgw ctl cert`
over it so issuing is reproducible instead of remembered openssl.

The server certificate's hosts matter more than they look: hostname
verification reads subject alternative names and ignores the common name
entirely, so a certificate without the address agents dial — bare IP
included — is rejected no matter what its subject says. There is a test for
exactly that, because it is the failure everyone hits once.

requireIdentity stays wired to false until the configuration lands, so this
commit changes no running behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 07:28:56 +02:00
ilkerandClaude Opus 5 e8d90cf375 Treat a zombie as dead, and test fairness where it is deterministic
CI / test (push) Successful in 52s
CI went red on Linux with two failures that never appear on macOS. Both were
worth the trip.

The first is a real bug. processAlive used kill(pid, 0), which succeeds on a
zombie — a process that has exited and is only waiting to be reaped. That
happens for real whenever an engine's parent dies before it does and nothing
reaps the orphan, which in a container is the normal case: PID 1 is usually the
workload rather than an init. The supervisor would then report a dead adopted
engine as running forever, and hold its GPUs against every future placement.
Zombies are now detected through procfs on Linux, where the answer exists, and
the check degrades to its old behaviour elsewhere rather than to something worse.

The second was a bad test. TestTenantFairnessWithinBand asserted on the order
goroutines sent to a channel after being admitted, which is the order the Go
scheduler woke them, not the order the dispatcher chose. Under load it measured
the runtime. The rotation itself lives in bandQueue.pop, which is pure, so it is
tested there — including what happens when a tenant runs out mid-rotation and
when a cancelled waiter is removed. The controller-level test now asserts the
thing that is actually stable: nobody is starved.

Verified with three consecutive `go test -race ./...` runs in a Linux container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 06:34:08 +02:00
558 changed files with 100335 additions and 886 deletions

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
# Attaches binaries to a release when a version tag is pushed.
#
# Without this a release is a git tag, and installing llmgw on a node means
# having a Go toolchain and the source. The onboarding script copies a binary
# from wherever it is run, which works and quietly makes whoever runs it the
# build server.
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
# The built-in token every job is given carries whatever the instance's
# default mode allows, and under the restricted default that is read-only
# on releases — so the build would succeed and the publish would be
# refused. `contents` covers code and releases together, which is what
# creating a release needs: `releases: write` alone was refused, because
# creating one can create its tag.
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
# Tags are what the version string is built from, and a shallow clone
# has none of them.
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Build
run: |
set -euo pipefail
version="${GITHUB_REF_NAME:-$(git describe --tags --always)}"
mkdir -p dist
# CGO_ENABLED=0 is the whole point: the binary has to run on a node
# whose libc is older than the builder's, and llmgw has no C
# dependency — modernc.org/sqlite is pure Go precisely so this stays
# true.
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
os="${target%/*}"; arch="${target#*/}"
name="llmgw-${version}-${os}-${arch}"
echo "building $name"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build \
-trimpath \
-ldflags "-s -w -X gitea.mynodes.xyz/ilker/llmgw/internal/version.Version=${version}" \
-o "dist/${name}" ./cmd/llmgw
(cd dist && sha256sum "${name}" >> checksums.txt)
done
# The reverse proxy configurations, so a release page carries
# everything install.md asks for and the repository is not needed.
for f in deploy/proxy/Caddyfile deploy/proxy/nginx.conf; do
cp "$f" dist/
(cd dist && sha256sum "$(basename "$f")" >> checksums.txt)
done
# An NVML build for nodes with NVLink, which the portable binary
# cannot serve.
#
# NVML is the only source of NVLink topology, its bindings need cgo,
# and cgo is exactly what CGO_ENABLED=0 above exists to avoid. So the
# portable binary silently falls back to taking the lowest free GPU
# indices — right on an SXM box with NVSwitch, and wrong on a PCIe
# box where NVLink bridges pair specific cards: a tensor group
# straddling two bridges runs several times slower and nothing says
# so. Shipping only the portable build meant nobody could have that
# topology without building it themselves.
#
# Dynamically linked against the host's libc by necessity, so it is
# published beside the portable one rather than instead of it.
if command -v gcc >/dev/null; then
nvml_name="llmgw-${version}-linux-amd64-nvml"
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build \
-tags nvml -trimpath \
-ldflags "-s -w -X gitea.mynodes.xyz/ilker/llmgw/internal/version.Version=${version}" \
-o "dist/${nvml_name}" ./cmd/llmgw
(cd dist && sha256sum "${nvml_name}" >> checksums.txt)
else
echo "no C toolchain; skipping the nvml build" >&2
fi
# Proof the artifact runs at all before it is published. It cannot
# test the cross-compiled ones, which is why the native build is
# checked rather than assumed.
./dist/llmgw-${version}-linux-amd64 --version
cat dist/checksums.txt
# Signed when the repository has a signing key, so a machine that trusts
# it installs nothing the server alone could have put there. Without the
# secret nothing is signed and releases are checked on their checksums,
# as before; a build that trusts a key refuses those, so the secret has
# to be in place before trusted.pub is filled in.
- name: Sign the checksums
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME:-$(git describe --tags --always)}"
if [ -z "${RELEASE_SIGNING_KEY:-}" ]; then
echo "no RELEASE_SIGNING_KEY secret; releases are not signed"
exit 0
fi
./dist/llmgw-${version}-linux-amd64 release sign dist/checksums.txt
echo "signed: dist/checksums.txt.sig"
- name: Attach to the release
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
# Gitea gives every job a GITEA_TOKEN, so this is not the usual
# "you forgot to add a secret" — an empty one means the job was denied
# a token, which is an instance setting rather than a repository one.
if [ -z "${TOKEN:-}" ]; then
echo "The job was given no GITEA_TOKEN, so nothing can be uploaded."
echo "Check Settings -> Actions -> General; the binaries are in dist/."
exit 1
fi
# workflow_dispatch can be fired from a branch, where GITHUB_REF_NAME
# is the branch name. Uploading then would create a release called
# "main". The binaries are still built and kept as a check that the
# release build works; only the publishing is a tag's business.
if [ "${GITHUB_REF_TYPE:-}" != "tag" ]; then
echo "not a tag (ref=${GITHUB_REF_NAME}); built, not published"
exit 0
fi
tag="${GITHUB_REF_NAME}"
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
# Gitea materialises a release row for every pushed tag, so this
# nearly always finds one and the create below is for a tag pushed
# some other way.
#
# The id is read with grep rather than sed because sed's .* is greedy:
# "id" appears again inside the nested author object, so the old
# pattern took the author's id when it matched at all — and it did not
# match "id": 13 with a space, which is what actually happened. The
# first "id" in a release object is the release's own.
release_id() {
curl -sS -H "Authorization: token $TOKEN" "$api/releases/tags/$tag" \
| grep -o '"id"[[:space:]]*:[[:space:]]*[0-9]*' \
| head -1 | grep -o '[0-9]*$'
}
id=$(release_id || true)
if [ -z "$id" ]; then
echo "no release for $tag yet; creating one"
out=$(curl -sS -w '\n%{http_code}' -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
"$api/releases")
echo "create returned $(printf '%s' "$out" | tail -1)"
# Re-read rather than parsing the create response: a 409 means it
# was there all along, which is a success for our purposes.
id=$(release_id || true)
[ -n "$id" ] || printf '%s\n' "$out" | head -5 >&2
fi
[ -n "$id" ] || { echo "could not find or create the release" >&2; exit 1; }
echo "release id $id"
for f in dist/*; do
echo "uploading $(basename "$f")"
curl -sS -f -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" \
"$api/releases/$id/assets?name=$(basename "$f")" >/dev/null \
|| { echo "upload of $(basename "$f") was refused" >&2; exit 1; }
done
echo "attached $(ls dist | wc -l) files to $tag"
# The release page says what changed, copied from CHANGELOG.md. Gitea makes
# an empty release for every tag and the job above only attaches files, so
# every release until this existed was a list of binaries and nothing else.
#
# After the build, because that is what guarantees the release exists. Run by
# hand from a branch it syncs every version in the changelog, which is how
# the releases made before this got their notes.
notes:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Copy the changelog onto the release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_INSTANCE: ${{ github.server_url }}
GITEA_REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
python3 scripts/gitea/release_notes.py --tag "${GITHUB_REF_NAME}"
else
python3 scripts/gitea/release_notes.py
fi
+1520 -6
View File
File diff suppressed because it is too large. Load diff
+65 -7
View File
@@ -1,11 +1,23 @@
# llmgw
A single-binary LLM gateway for clusters with multiple InfiniBand fabrics and
heterogeneous GPUs.
A single-binary LLM gateway for clusters with multiple InfiniBand fabrics,
NVLink domains and heterogeneous GPUs.
It serves LLMs running on your own GPU servers (vLLM / SGLang) behind one
OpenAI-compatible endpoint. Design document: [llmgw-plan.md](llmgw-plan.md).
## Getting started
Starting from nothing — no packages installed, no drivers, no nodes — follow
[docs/install.md](docs/install.md): it builds a control plane, adds a node and
serves a model, and says plainly which parts llmgw does not install for you.
| Guide | For |
|---|---|
| [docs/install.md](docs/install.md) | Standing up a cluster from scratch |
| [docs/node-onboarding.md](docs/node-onboarding.md) | Adding nodes to one that exists |
| [docs/onboarding.md](docs/onboarding.md) | Giving people accounts and keys |
## Status
Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
@@ -17,13 +29,58 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
| v0.3.0 | 1.5 | Admission control (priority bands) | released |
| v0.4.0 | 2 | Agent: GPU/IB discovery, engine supervision | released |
| v0.5.0 | 3 | Control plane: catalog, placement, reconciler, ctl | released |
| v0.6.0 | 4 | Multi-node placement and fabric awareness | planned |
| v0.6.0 | 4 | Multi-node placement and fabric awareness | released |
| v0.7.0 | — | Conversations, agent memory, disk management | released |
| v0.8.0 | — | PostgreSQL, leader election, agent failover | released |
| v0.9.0 | — | Managing people and the tenancy tree | released |
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
As of v0.3.0 the router serves traffic and protects it: a batch workload no
longer starves interactive requests. Still missing before v1.0.0: authentication,
rate limits and budgets, and the control plane that replaces the static endpoint
list.
As of v0.6.0 a cluster runs unattended: agents report their hardware, the
control plane places replicas — including ones larger than a single machine —
models load when someone asks for them and stop when nobody does, and keys,
budgets and rate limits decide who may use any of it.
Before v1.0.0: tracing, reference documentation, and filtering on `ctl nodes`
and `ctl replicas` for fleets too large to read a whole table of. Two items
that were on this list are not gaps: a model falls through to its fallbacks
when there is no capacity as well as when a budget is spent, and routing
profiles are chosen by the key rather than by a request header on purpose —
a header would let any client promote itself into the top priority band.
One thing is worth saying plainly:
multi-node replicas are proven on a simulated cluster and on real hardware
without a fabric. The InfiniBand, NVLink and NCCL paths — telling fabrics apart
by their subnet managers, mapping them, finding NVLink domains, launching across
nodes — are written against the formats and failure modes those tools actually
have and tested against them, but they have not yet run on a machine that has
them. On such a machine, run `llmgw doctor --json` first and keep the report;
[docs/fabrics.md](docs/fabrics.md) says what to look at after that.
## Documentation
- [docs/quick-install.md](docs/quick-install.md) — installing on servers, in
six steps: start here
- [docs/install.md](docs/install.md) — the same from nothing, with every option
explained: control plane, nodes, engines, weights, first request
- [docs/quickstart.md](docs/quickstart.md) — one workstation, from source
- [docs/fabrics.md](docs/fabrics.md) — InfiniBand fabrics and NVLink: what
llmgw needs of them, checking a node with `llmgw doctor`, and what to look at
when a replica spanning nodes will not run
- [docs/choosing-models.md](docs/choosing-models.md) — which models to run,
how many of each, and where the weights should live
- [docs/data.md](docs/data.md) — what llmgw stores, what it does not, and
what leaves the machine; written for a security review
- [docs/reference.md](docs/reference.md) — every configuration key, generated
from the structs and checked by a test, so it cannot describe a setting that
does not exist or miss one that does
- [deploy/mirror/](deploy/mirror/) — a model mirror, which is the cheapest
and most useful thing to build around llmgw
- [deploy/proxy/](deploy/proxy/) — Caddy and nginx in front of the client
port, with streaming and the forwarded headers right
- [deploy/ha/](deploy/ha/) — running two control planes, and the one health
check that has to be right
- [docs/node-onboarding.md](docs/node-onboarding.md),
[docs/onboarding.md](docs/onboarding.md) — adding machines, and adding people
## Building
@@ -42,6 +99,7 @@ llmgw ctl replicas --model llama # replicas and their shards
llmgw ctl explain-placement llama # why a model has not been placed
llmgw ctl drain gpu-a-01 # move work off a node, one replica at a time
llmgw ctl cordon gpu-a-01 # stop new placement, leave what runs
sudo llmgw doctor # on a node: what its GPUs, NVLink and fabric need
```
`ctl` talks to the admin port (`:9090` by default), not the client-facing one.
+2775 -101
View File
File diff suppressed because it is too large. Load diff
+488
View File
@@ -23,6 +23,10 @@ message AgentMessage {
Register register = 1;
Heartbeat heartbeat = 2;
ShardStatus shard = 3;
LogsReply logs = 4;
FabricScanReply fabric_scan = 5;
FabricTestReply fabric_test = 6;
UpgradeReply upgrade = 7;
}
}
@@ -33,6 +37,12 @@ message ControlMessage {
StopShard stop = 2;
DrainShard drain = 3;
Ack ack = 4;
RemoveWeights remove_weights = 5;
Provision provision = 6;
LogsRequest logs = 7;
FabricScanRequest fabric_scan = 8;
FabricTestRequest fabric_test = 9;
UpgradeAgent upgrade = 10;
}
}
@@ -67,6 +77,103 @@ message Register {
// — so the new engines fail to allocate, with an error naming the wrong
// process (plan §6.5).
repeated RunningShard running_shards = 9;
// memory_total_mb is the machine's RAM, and 0 means the agent did not say —
// which is what every agent older than this field reports, and is treated as
// no constraint rather than as a node with no memory.
//
// Placement needs it because a CPU profile has no card to be free, so every
// ready node looks like a candidate: a 14B model whose working set is 24 GB
// would be placed just as happily on a node with 8 GB.
int64 memory_total_mb = 10;
// weights are the model files this node can see, from the directories its
// configuration names.
//
// How they got there is the operator's business and deliberately outside
// llmgw's: a shared filesystem, an image exported over the fabric, rsync, or
// a copy made by hand all look identical here. What matters to placement is
// only whether they are present, because a node without them either cannot
// start the replica at all or must first move tens of gigabytes.
//
// Empty means the node was not asked to look, which is not the same as having
// nothing — an agent with no model directories configured behaves exactly as
// every agent did before this existed, and constrains nothing.
repeated LocalWeights weights = 11;
// storage is the disk behind those weights, one entry per filesystem.
//
// Reported so an operator can see a node filling up before a fetch dies at
// ninety percent, and so placement can refuse a node that cannot hold a
// model rather than discovering it during the download.
//
// Empty means the node was not asked to look, exactly as for weights, and
// constrains nothing.
repeated Storage storage = 12;
// host is what the machine is, so the control plane can have the mirror
// fetch software built for it rather than assuming every node matches the
// first.
Host host = 13;
// engines_probed says engine_versions is an answer. Without it an empty map
// meant "an agent too old to say" and constrained nothing — so a freshly
// built node with no engine installed yet was a candidate for every model.
bool engines_probed = 14;
// cpu_count is the CPUs the agent may run on. Placement reserves cores
// for CPU replicas out of it, the way it reserves memory out of
// memory_total_mb; zero, from an older agent, reserves nothing.
int32 cpu_count = 18;
// imex is the service that lets a multi-node NVLink domain share memory.
IMEX imex = 15;
// installable_engines are the engines this agent knows how to install
// itself. The control plane asks it for no others. Empty is an agent older
// than the field, which could install llama.cpp and nothing else: asked for
// vLLM, it answered "unsupported", and every node of a cluster half way
// through an upgrade showed as failing.
repeated string installable_engines = 16;
// features are the requests this agent answers beyond the ones every agent
// does: "fabric-test". An agent that does not know a request ignores it, so
// without this the control plane would wait minutes for an answer that is
// never coming.
repeated string features = 17;
}
// Host describes the operating system and processor of a node.
message Host {
// From /etc/os-release: "ubuntu", "24.04", "noble".
string os_id = 1;
string os_version = 2;
string os_codename = 3;
// Debian's spelling, which is what package indexes are keyed by: "amd64",
// "arm64".
string arch = 4;
// The vector extensions inference cares about, of those the CPU has. A VM
// given QEMU's default CPU model has none of them, and runs, and is five
// times slower, and nothing says why.
repeated string cpu_flags = 5;
// nvidia_driver is the driver version, empty when there is none. A card
// without a driver is not a GPU anything can use.
string nvidia_driver = 6;
}
// Storage is one filesystem a node keeps weights on.
//
// Per filesystem rather than per configured directory: free space belongs to
// the disk, and two model directories on one disk are one pool of bytes.
message Storage {
// path is one directory on this filesystem, so the operator recognises it.
string path = 1;
// free_bytes is what a non-root process can write — not the root reserve,
// which is space the agent cannot have.
int64 free_bytes = 2;
int64 total_bytes = 3;
// shared marks a filesystem the node did not fill. Free space on one is
// shared with every other node using it, so two nodes each reporting 500 GB
// free on the same filer have 500 GB between them and not a terabyte.
bool shared = 4;
}
// RunningShard describes an engine already running on a node.
@@ -94,6 +201,13 @@ message RunningShard {
string leader_addr = 13;
int32 leader_port = 14;
// profile_fp is the fingerprint of the profile this shard was started from,
// echoed back from StartShard. The control plane keeps no record of a running
// replica across its own restart, so without this a shard adopted after a
// restart could not be compared with the catalog — which is exactly when the
// catalog is most likely to have been edited.
string profile_fp = 15;
}
message GPU {
@@ -114,6 +228,21 @@ message GPU {
repeated string problems = 12;
int32 temperature_c = 13;
int32 ecc_errors = 14;
// utilization_percent is how busy the card was over the last sample period.
// Zero when the driver is too old to report it, which is not the same as
// idle: the metric is simply absent rather than wrong.
int32 utilization_percent = 15;
// nic_paths is how far each network adapter is from this card, by HCA
// name: PIX, PXB, PHB, NODE or SYS, from nvidia-smi topo.
map<string, string> nic_paths = 16;
// The card's multi-node NVLink membership, from nvidia-smi -q: state is
// Completed once the fabric manager has joined it; cluster_uuid and
// clique_id name the domain it joined.
string fabric_state = 17;
string fabric_status = 18;
string cluster_uuid = 19;
string clique_id = 20;
string fabric_bandwidth = 21;
}
message FabricAttachment {
@@ -126,12 +255,227 @@ message FabricAttachment {
}
// Heartbeat says the node is alive and reports anything that changed cheaply.
// LocalWeights is one model file or directory a node can see.
message LocalWeights {
// path is what an engine would be given, so it can be compared against a
// model's source without interpretation.
string path = 1;
int64 size_bytes = 2;
// shared marks weights on a filesystem the node did not have to fill —
// reported so a fabric with a shared filer is not mistaken for one where
// every node happens to have fetched its own copy.
bool shared = 3;
}
// NodeLoad is a node's use of its own CPU and memory, and each engine's share.
//
// Carried on the heartbeat rather than scraped from the node: the control
// plane already exports the fleet from one place, and a node exporter per
// machine would be a scrape target, a port and a firewall rule each.
message NodeLoad {
// cpu_percent is the whole machine, 0 to 100 however many cores it has.
double cpu_percent = 1;
int32 cpu_count = 2;
// The kernel's run-queue averages over 1, 5 and 15 minutes.
double load1 = 3;
double load5 = 4;
double load15 = 5;
// memory_used_bytes is total less available: what the machine could not
// hand to a new process without evicting something.
int64 memory_used_bytes = 6;
int64 memory_available_bytes = 7;
repeated ShardLoad shards = 8;
// What the machine's own sensors say; zero is not measured. cpu_temp_c is
// the hottest CPU sensor, cpu_power_w the CPU packages' draw from RAPL,
// inlet_temp_c the board's inlet or ambient sensor where it has one.
double cpu_temp_c = 9;
double cpu_power_w = 10;
double inlet_temp_c = 11;
repeated GPUSensor gpus = 12;
}
// GPUSensor is one card's heat, power, and what is holding it back.
message GPUSensor {
int32 index = 1;
double temp_c = 2;
double memory_temp_c = 3;
double power_w = 4;
double power_limit_w = 5;
// throttle names what is holding the clocks down now: sw_power_cap,
// hw_slowdown, sw_thermal, hw_thermal, power_brake.
repeated string throttle = 6;
}
// ShardLoad is one engine process group's use.
message ShardLoad {
string shard_id = 1;
// cpu_cores is in cores, the way top counts a process: 2.5 is two and a
// half cores busy, which a percentage of the machine would hide on a large
// one.
double cpu_cores = 2;
int64 memory_rss_bytes = 3;
}
// IBPort is one InfiniBand or RoCE port's state and counters.
message IBPort {
string hca = 1;
int32 port = 2;
// state is ACTIVE, INIT, DOWN...; phys_state tells a cable unplugged
// (Polling, Disabled) from a link up with no subnet manager (LinkUp and INIT).
string state = 3;
string phys_state = 4;
int32 rate_gbps = 5;
// link_layer is InfiniBand or Ethernet (RoCE).
string link_layer = 6;
// counters are cumulative, by sysfs name; data counters in bytes.
map<string, uint64> counters = 7;
// Identity: the adapter's node GUID and the port's own, as the fabric
// knows them.
string node_guid = 8;
string port_guid = 9;
// The subnet manager this port sees as master, on InfiniBand. Every port on
// one physical fabric sees the same one, which is what tells separate
// fabrics apart when their names are only what somebody typed.
string sm_guid = 10;
int32 sm_lid = 11;
int32 sm_priority = 12;
string sm_state = 13;
// sm_error says why sm_guid is empty on an active InfiniBand port:
// infiniband-diags missing, or no subnet manager answering.
string sm_error = 14;
// network is, on RoCE, the IP network of the RoCE v2 address: RoCE has no
// subnet manager, and the network is what its fabric is.
string network = 15;
string gid_prefix = 16;
// subnet_managers is every subnet manager the fabric's subnet
// administrator lists — master and standbys — asked through one port per
// fabric, so it is on that port only. It is what keeps a fabric one fabric
// through a failover: the master changes, the list does not.
repeated SubnetManager subnet_managers = 17;
string subnet_managers_error = 18;
}
// SubnetManager is one subnet manager on a fabric.
message SubnetManager {
string guid = 1;
int32 lid = 2;
int32 priority = 3;
// state is master, standby, discovering or not active.
string state = 4;
}
// IMEX is the nvidia-imex service and its channels.
message IMEX {
// state is systemd's word for it, empty where it is not installed.
string state = 1;
int32 channels = 2;
}
// GPUFabricState is one GPU's NVLink domain membership.
message GPUFabricState {
int32 index = 1;
string state = 2;
string status = 3;
string cluster_uuid = 4;
string clique_id = 5;
string bandwidth = 6;
}
message Heartbeat {
string node_id = 1;
// gpu_memory_used_mb, indexed by GPU, catches a foreign process appearing on
// a card after registration.
repeated int32 gpu_memory_used_mb = 2;
repeated string new_problems = 3;
// storage for the same reason the GPU memory is here: it changes while the
// agent runs, and a figure sampled once at registration is a figure that is
// wrong on exactly the node worth watching — the one busy filling up.
//
// Always sent, because it is a few numbers per filesystem.
repeated Storage storage = 4;
// weights only when they changed, because the list is long enough that
// sending it every few seconds would be paying for nothing most of the time.
//
// weights_changed is what says the list is meant: without it an empty list
// would be ambiguous between "this node now holds nothing" and "nothing to
// report", and the first would quietly empty a node's inventory.
bool weights_changed = 5;
repeated LocalWeights weights = 6;
// memory_total_mb for the same reason again: RAM is added to a virtual
// machine while it runs, or across a reboot the agent does not notice, and a
// figure read once at registration kept a node that had grown to 32 GB
// looking like the 7 GB machine it used to be. 0 means not read.
int64 memory_total_mb = 7;
// engine_versions only when they changed, like weights. An engine installed
// after the agent started is otherwise invisible to placement until
// somebody restarts the agent — which is a step nobody knows to take.
bool engines_changed = 8;
map<string, string> engine_versions = 9;
// provision is where installing the node's software stands, sent when it
// changed. The flag makes an empty list mean "nothing to install" rather
// than "nothing to say", as for weights.
bool provision_changed = 10;
repeated ProvisionStatus provision = 11;
// load is how busy the machine is, sampled over the interval since the
// previous heartbeat. Absent on the first one, which has nothing to take a
// difference from, and from an agent on a system without /proc.
NodeLoad load = 12;
// ib_ports is every InfiniBand or RoCE port as it stands now, re-read on
// each heartbeat: a port that goes down after the agent started used to go
// unnoticed until the agent restarted.
repeated IBPort ib_ports = 13;
// fabrics is the fabrics this node can now carry traffic on, sent only when
// that changed since registration or the last heartbeat that said so — a
// port going down takes its fabric away, and one coming back returns it.
bool fabrics_changed = 14;
repeated FabricAttachment fabrics = 15;
// gpu_fabric is each GPU's NVLink domain membership as it stands now, sent
// once a minute: a fabric manager restarting drops every card out of its
// domain, and the node has to stop being offered work that spans it.
repeated GPUFabricState gpu_fabric = 16;
IMEX imex = 17;
// cpu_count as in Register, for a VM given more CPUs while it runs.
int32 cpu_count = 18;
}
// Provision is the software a node should have: which engines, at which
// versions. Nothing more.
//
// Everything else is the node's to work out, because only the node knows it:
// which build fits its hardware, where that build is published, what its
// operating system is missing, and whether the result actually runs. The agent
// converges on this — installs what is absent, checks what is there — and
// reports each engine through ProvisionStatus. Nothing is taken on trust from
// what happens to be on the machine: an engine is offered to placement only
// once the agent has installed it and seen it run.
message Provision {
repeated EngineWant engines = 1;
}
// EngineWant names one engine at one version.
message EngineWant {
string engine = 1; // "llamacpp"
string version = 2; // "b10991"
}
// ProvisionStatus is where one engine's installation stands on a node.
message ProvisionStatus {
string engine = 1;
string version = 2;
string variant = 3;
// "installing", "ready", "failed", or "unsupported" when no build fits.
string state = 4;
// detail says what is happening or what went wrong, in words for a person.
string detail = 5;
}
// ShardStatus reports a shard's lifecycle transition.
@@ -146,6 +490,15 @@ message ShardStatus {
// fails to load this is the only diagnosis available.
repeated string last_output = 7;
// fetch is how far a download has got, when the shard is waiting on one.
//
// The agent logs this already, on the node, where nobody is looking. From
// the control plane a replica pulling a hundred gigabytes and one that is
// wedged are the same thing — a shard that has been starting for ten
// minutes — and telling them apart is the difference between waiting and
// going to look.
FetchProgress fetch = 8;
enum State {
STATE_UNSPECIFIED = 0;
STATE_PENDING = 1;
@@ -158,6 +511,16 @@ message ShardStatus {
}
}
// FetchProgress is a download in flight.
message FetchProgress {
string source = 1;
int64 bytes_done = 2;
// bytes_total is 0 when the server did not say how large the file is, which
// is a real answer: a percentage would then be invented, and an invented
// percentage that stops moving is worse than a byte count that keeps rising.
int64 bytes_total = 3;
}
// StartShard launches one shard. It is idempotent by shard_id: the control
// plane retries, and a repeat must not produce a second process.
message StartShard {
@@ -184,6 +547,17 @@ message StartShard {
// are sent so the agent can echo them back in RunningShard after a restart.
string pool = 13;
string fabric = 14;
// profile_fp identifies the version of the profile, so the agent can say
// which one a running shard was started from. The agent never interprets it.
string profile_fp = 15;
// api_key is what the engine demands of every request, derived by the
// control plane from the replica id. The router sends it; nothing else
// that can reach the node's ports has it.
string api_key = 16;
// cpu_threads is how many CPU threads a shard with no GPU computes on:
// the cores placement reserved for it. Zero leaves it to the agent.
int32 cpu_threads = 17;
}
message EngineProfile {
@@ -200,6 +574,17 @@ message EngineProfile {
string served_model_name = 11;
}
// RemoveWeights deletes cached model files a node no longer needs.
//
// The paths are computed by the control plane from the catalog, but the agent
// checks them again before unlinking anything: it deletes only files inside its
// own cache directory whose names this system generated. A control plane that
// is wrong, compromised or simply newer than the agent must not be able to
// talk it into deleting a model an operator spent a week copying there.
message RemoveWeights {
repeated string paths = 1;
}
// StopShard terminates a shard and frees its GPUs.
message StopShard {
string shard_id = 1;
@@ -216,3 +601,106 @@ message Ack {
bool ok = 2;
string error = 3;
}
// LogsRequest asks a node for the recent lines of one of its logs.
//
// Over the stream the agent already holds, because that is the one way into a
// node every deployment has: the admin page otherwise means ssh through a jump
// host to a machine whose address nobody remembers, to read a journal only
// root may open.
message LogsRequest {
// request_id pairs the reply with its request; several may be in flight.
string request_id = 1;
// source is "agent" for the agent's own log, or a shard id for that
// engine's output.
string source = 2;
int32 lines = 3;
}
// LogsReply answers a LogsRequest.
message LogsReply {
string request_id = 1;
repeated string lines = 2;
// sources are every log this node can show, so a page can offer them
// without knowing what runs there.
repeated string sources = 3;
// error says why the lines are absent: an unknown source, say.
string error = 4;
}
// FabricScanRequest asks a node to map the fabric one of its ports is on,
// with ibnetdiscover: every switch, every adapter and every link between
// them. One node per fabric is asked, every few minutes; a fabric looks the
// same from all of its ports.
message FabricScanRequest {
string request_id = 1;
string hca = 2;
int32 port = 3;
}
// FabricScanReply is ibnetdiscover's output as it printed it. Parsed by the
// control plane, so a format the parser does not yet know is a control plane
// upgrade rather than one on every node.
message FabricScanReply {
string request_id = 1;
string output = 2;
string error = 3;
int64 took_ms = 4;
}
// FabricTestRequest asks a node for one side of an RDMA bandwidth test
// between two nodes: ib_write_bw, from perftest, over one adapter. The
// server side is asked first and answers when the test is over; the client
// dials it. What it proves is what a replica spanning nodes needs and nothing
// on one node can show: that these two adapters reach each other through the
// fabric, and how fast.
message FabricTestRequest {
string request_id = 1;
// role is "server" or "client".
string role = 2;
string hca = 3;
int32 port = 4;
// peer is the server's management address, for the client. perftest
// exchanges its connection details over TCP before any RDMA.
string peer = 5;
// tcp_port is that exchange's port, the same on both sides.
int32 tcp_port = 6;
int32 seconds = 7;
}
// FabricTestReply is what one side measured.
message FabricTestReply {
string request_id = 1;
// gbps is the average bandwidth, in gigabits a second.
double gbps = 2;
// output is the end of the tool's output, for when the number is not
// enough.
string output = 3;
string error = 4;
int64 took_ms = 5;
}
// UpgradeAgent tells an agent to replace its own binary with a release and
// restart.
//
// The agent fetches the release for its own platform and checks it against
// the release's checksums before replacing anything, keeps the old binary
// beside the new, then exits so systemd starts the new one. Engines keep
// running throughout, as for any agent restart, and are adopted on the way
// back. Over the stream the agent already holds, so upgrading a fleet needs
// no ssh into it.
message UpgradeAgent {
string request_id = 1;
// version must be a release: v0.10.4 or v0.10.4-rc.2.
string version = 2;
// releases is the base URL release files are downloaded from.
string releases = 3;
}
// UpgradeReply says whether the new binary is in place. Sent before the
// agent exits; an empty error means it will be back on the new version.
message UpgradeReply {
string request_id = 1;
string version = 2;
string error = 3;
}
+112
View File
@@ -0,0 +1,112 @@
#!/bin/sh
# add-node.sh — build a node and put it in the fleet, in one command.
#
# provision-node.sh and onboard-node.sh stay separate because the two halves
# need different access: one the hypervisor, one the certificate authority.
# That is a real boundary and this script does not erase it — it drives both
# where one person holds both, which is the common case and was the case here.
#
# The reason to have it is not keystrokes. The VM name, the hostname, the node
# id and the certificate's common name must all be the same string, and the
# address appears in three places; split across two invocations run minutes
# apart, that agreement is maintained by whoever is typing. Here it is stated
# once.
set -eu
usage() {
cat >&2 <<'USAGE'
usage: add-node.sh --name NODE --ip ADDR/CIDR --server URL [options]
--name NODE node id, hostname and VM name; also the certificate's CN
--ip ADDR/CIDR e.g. 10.0.0.145/24
--server URL control plane, e.g. https://10.0.0.140:7443
--vmid N Proxmox VMID (default: the last octet of --ip)
--cores N (default 4)
--cpu TYPE QEMU CPU model (default host; see provision-node.sh)
--memory MB (default 8192)
--disk GB (default 20)
--fabric NAME InfiniBand fabric; repeatable as NAME:hca1,hca2.
Omit for a node with no fabric.
--llamacpp-bin P path to llama-server on the node
--vllm-bin P path to vllm
--sglang-python P path to the python that has sglang
--binary PATH llmgw binary to install (default: the one on PATH)
--pki DIR certificate authority (default /etc/llmgw/pki)
--ssh-user U (default ilker)
--provision-only build the VM and stop
--onboard-only skip provisioning; the machine already exists
USAGE
exit 2
}
NAME= IP= SERVER= VMID= CORES=4 MEMORY=8192 DISK=20 CPU=
BINARY= PKI=/etc/llmgw/pki SSH_USER=ilker
PROVISION=1 ONBOARD=1
set -- "$@"
PASS_THROUGH=
while [ $# -gt 0 ]; do
case "$1" in
--name) NAME="$2"; shift 2;;
--ip) IP="$2"; shift 2;;
--server) SERVER="$2"; shift 2;;
--vmid) VMID="$2"; shift 2;;
--cores) CORES="$2"; shift 2;;
--cpu) CPU="$2"; shift 2;;
--memory) MEMORY="$2"; shift 2;;
--disk) DISK="$2"; shift 2;;
--binary) BINARY="$2"; shift 2;;
--pki) PKI="$2"; shift 2;;
--ssh-user) SSH_USER="$2"; shift 2;;
--provision-only) ONBOARD=0; shift;;
--onboard-only) PROVISION=0; shift;;
# Everything the onboarding step understands and this one does not need to.
--fabric|--llamacpp-bin|--vllm-bin|--sglang-python|--ib-root|--nvidia-smi|--mgmt-addr|--mgmt-if)
PASS_THROUGH="$PASS_THROUGH $1 $2"; shift 2;;
-h|--help) usage;;
*) echo "unknown option: $1" >&2; usage;;
esac
done
[ -n "$NAME" ] && [ -n "$IP" ] && [ -n "$SERVER" ] || usage
# The address without its prefix, which is what onboarding and ssh want.
ADDR="${IP%%/*}"
# The last octet as the VMID by default: it keeps `qm list` and `ctl nodes` in
# the same order, and one fewer number to choose is one fewer to get wrong.
[ -n "$VMID" ] || VMID="${ADDR##*.}"
here() { dirname "$0"; }
PROVISION_SH="$(here)/provision-node.sh"
ONBOARD_SH="$(here)/onboard-node.sh"
[ -x "$PROVISION_SH" ] || PROVISION_SH="$(command -v llmgw-provision-node || true)"
[ -x "$ONBOARD_SH" ] || ONBOARD_SH="$(command -v llmgw-onboard-node || true)"
if [ "$PROVISION" = 1 ]; then
[ -x "$PROVISION_SH" ] || { echo "cannot find provision-node.sh" >&2; exit 1; }
echo "== 1/2 building $NAME as VM $VMID"
# shellcheck disable=SC2086
"$PROVISION_SH" --vmid "$VMID" --name "$NAME" --ip "$IP" \
--cores "$CORES" --memory "$MEMORY" --disk "$DISK" ${CPU:+--cpu "$CPU"}
fi
if [ "$ONBOARD" = 0 ]; then
echo "$NAME built. Onboard it from the CA host when you are ready."
exit 0
fi
[ -x "$ONBOARD_SH" ] || { echo "cannot find onboard-node.sh" >&2; exit 1; }
echo
echo "== 2/2 onboarding $NAME"
# Onboarding waits for ssh itself, which is what makes running the two halves
# back to back safe: cloud-init is still regenerating host keys and restarting
# sshd for the first minute of a new machine's life.
# shellcheck disable=SC2086
"$ONBOARD_SH" --node "$NAME" --addr "$ADDR" --server "$SERVER" \
--pki "$PKI" --ssh-user "$SSH_USER" \
${BINARY:+--binary "$BINARY"} $PASS_THROUGH
echo
echo "Check it with: llmgw ctl nodes"
echo
echo "The agent installs its engines itself; the node takes no model until they"
echo "are installed and seen to run. 'llmgw ctl nodes' shows where that stands."
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env bash
#
# Give a machine an identity in an llmgw cluster.
#
# Run on the host that holds the certificate authority, because that is the one
# thing that cannot be delegated: this script issues the credential by which the
# control plane will believe the node is who it says it is.
#
# What it does, in the order it matters:
# 1. issue a client certificate whose common name is the node id
# 2. copy the CA root, the certificate and its key to the machine
# 3. trust the root system-wide, so other tooling there stops complaining
# 4. install the binary, the configuration and the unit
# 5. start the agent
#
# The node id, the VM name, the hostname and the certificate common name are all
# the same string. They do not have to be, and every time they are not, someone
# spends an afternoon working out which of the four a message is talking about.
set -euo pipefail
usage() {
cat >&2 <<USAGE
usage: $0 --node ID --addr HOST --server URL [options]
--node ID node id, hostname and certificate common name
--addr HOST how to reach the machine over ssh
--server URL the control plane's agent endpoint, e.g. https://10.0.0.140:7443;
several, comma separated, for a node that fails over
--fabric NAME InfiniBand fabric this node is attached to; repeatable as
--fabric NAME:hca1,hca2 . Omit for a node with no fabric,
which can host single-node replicas and will never be given
a multi-node one.
--mgmt-addr ADDR address engines are reachable on (default: the node's
address on the route to --server, found by the agent)
--mgmt-if NAME interface NCCL bootstraps over (default: the one holding
that address)
--llamacpp-bin P use this llama-server instead of the one the agent installs
--vllm-bin P use this vllm instead of the one the agent installs on a
GPU node (also set engines.vllm: "" in server.yaml)
--sglang-python P python interpreter of an SGLang virtualenv you installed
--nvidia-smi PATH where nvidia-smi lives, when it is not on PATH. Containerized
driver installs routinely put it somewhere else.
--ib-root DIR the sysfs InfiniBand directory (default /sys/class/infiniband)
--pki DIR where the authority lives (default /etc/llmgw/pki)
--binary PATH llmgw binary to install (default: the one running this).
Releases carry static linux/amd64 and linux/arm64 builds;
downloading one is better than copying whatever happens to
be on the machine you are running this from.
--releases URL where release binaries are downloaded from (default the
project's Gitea releases)
--ssh-user NAME (default ilker)
USAGE
exit 2
}
NODE= ADDR= SERVER= MGMT_OVERRIDE= MGMT_IF_OVERRIDE=
NVIDIA_SMI= IB_ROOT=/sys/class/infiniband
LLAMACPP_BIN= VLLM_BIN= SGLANG_PYTHON=
SSH_WAIT=180
PKI=/etc/llmgw/pki BINARY= SSH_USER=ilker
RELEASES=https://gitea.mynodes.xyz/ilker/llmgw/releases/download
FABRIC_NAME= FABRIC_HCAS="mlx5_0,mlx5_1"
while [ $# -gt 0 ]; do
case "$1" in
--node) NODE="$2"; shift 2;;
--addr) ADDR="$2"; shift 2;;
--server) SERVER="$2"; shift 2;;
--fabric) FABRIC_NAME="${2%%:*}"; [ "${2#*:}" != "$2" ] && FABRIC_HCAS="${2#*:}"; shift 2;;
--mgmt-addr) MGMT_OVERRIDE="$2"; shift 2;;
--mgmt-if) MGMT_IF_OVERRIDE="$2"; shift 2;;
--llamacpp-bin) LLAMACPP_BIN="$2"; shift 2;;
--vllm-bin) VLLM_BIN="$2"; shift 2;;
--sglang-python) SGLANG_PYTHON="$2"; shift 2;;
--nvidia-smi) NVIDIA_SMI="$2"; shift 2;;
--ib-root) IB_ROOT="$2"; shift 2;;
--pki) PKI="$2"; shift 2;;
--binary) BINARY="$2"; shift 2;;
--releases) RELEASES="$2"; shift 2;;
--ssh-user) SSH_USER="$2"; shift 2;;
*) usage;;
esac
done
[ -n "$NODE" ] && [ -n "$ADDR" ] && [ -n "$SERVER" ] || usage
BINARY="${BINARY:-$(command -v llmgw)}"
[ -x "$BINARY" ] || { echo "no llmgw binary; pass --binary" >&2; exit 1; }
[ -f "$PKI/ca-key.pem" ] || { echo "no authority in $PKI; run 'llmgw ctl cert init-ca' first" >&2; exit 1; }
# accept-new, not "no": a machine provisioned a minute ago has a host key
# nobody has seen, and refusing it stopped onboarding dead with "Host key
# verification failed" — an error about ssh, in the middle of a script whose
# whole job is to talk to a new machine. accept-new learns an unknown key and
# still refuses a changed one, which is the distinction that matters: the risk
# being guarded against is a substituted machine, not a new one.
SSHOPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
SSH="ssh $SSHOPTS $SSH_USER@$ADDR"
say() { printf ' %s\n' "$*"; }
echo "onboarding $NODE at $ADDR"
# Waiting rather than failing: cloud-init restarts sshd after it regenerates
# the host keys, so a machine that answered a moment ago refuses connections
# for a few seconds. Observed while onboarding node05 — the agent was already
# registered and healthy while ssh was refusing.
wait_for_ssh() {
deadline=$(( $(date +%s) + SSH_WAIT ))
until $SSH true 2>/dev/null; do
[ "$(date +%s)" -lt "$deadline" ] || {
echo "cannot reach $SSH_USER@$ADDR over ssh after ${SSH_WAIT}s" >&2
exit 1
}
sleep 5
done
}
wait_for_ssh
# 1. The certificate. Issued fresh every time: re-onboarding a machine should
# not depend on finding an old key, and the old one keeps working until it
# is revoked, which is a separate decision.
say "issuing a certificate"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
"$BINARY" ctl cert issue-client "$NODE" --dir "$PKI" --out "$TMP" >/dev/null
# 2 and 3. Identity. The root is trusted by the agent's configuration only,
# never system-wide: it signs whatever the control plane asks, and every
# TLS client on the machine would take it for any site.
say "installing the certificate"
scp -q $SSHOPTS "$PKI/ca.pem" "$TMP/$NODE.pem" "$TMP/$NODE-key.pem" "$SSH_USER@$ADDR:/tmp/"
$SSH "set -e
sudo install -d -m 0755 /etc/llmgw
sudo install -m 0644 /tmp/ca.pem /etc/llmgw/ca.pem
sudo install -m 0644 /tmp/$NODE.pem /etc/llmgw/agent.pem
sudo install -m 0600 /tmp/$NODE-key.pem /etc/llmgw/agent-key.pem
sudo chown root:root /etc/llmgw/*
if [ -f /usr/local/share/ca-certificates/llmgw-ca.crt ]; then
sudo rm -f /usr/local/share/ca-certificates/llmgw-ca.crt
sudo update-ca-certificates --fresh >/dev/null 2>&1 || true
fi
rm -f /tmp/ca.pem /tmp/$NODE.pem /tmp/$NODE-key.pem"
# 4. The binary, the configuration and the unit.
# The node fetches the release CI built for this version, for its own
# architecture, and checks it against the release's checksums — rather than
# being handed whatever binary is on the machine running this script. A build
# that is not a release (a developer's, with commits past the tag) has no such
# file, and is copied as before.
TAG="$("$BINARY" --version 2>/dev/null | awk '{print $NF}')"
if printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$'; then
say "installing llmgw $TAG from the release"
$SSH "set -e
arch=\$(dpkg --print-architecture)
name=llmgw-$TAG-linux-\$arch
cd /tmp && rm -f \$name checksums.txt
curl -fsSL -o \$name '$RELEASES/$TAG/'\$name
curl -fsSL -o checksums.txt '$RELEASES/$TAG/checksums.txt'
grep \" \$name\\\$\" checksums.txt | sha256sum -c - >/dev/null
sudo install -m 0755 \$name /usr/local/bin/llmgw
rm -f \$name checksums.txt"
else
say "installing the binary (${TAG:-unversioned}, not a release)"
scp -q $SSHOPTS "$BINARY" "$SSH_USER@$ADDR:/tmp/llmgw"
$SSH 'sudo install -m 0755 /tmp/llmgw /usr/local/bin/llmgw && rm -f /tmp/llmgw'
fi
say "writing the configuration"
NODE_IS_HOSTNAME=no
[ "$($SSH hostname 2>/dev/null)" = "$NODE" ] && NODE_IS_HOSTNAME=yes
# Only what the node cannot find out for itself. Its name is its hostname, its
# address is the one on the route to the control plane, llama-server is looked
# for on PATH and where release archives are unpacked, GPUs are there when
# nvidia-smi is, and memory, disk and engines are re-read while it runs. Every
# setting written here is one that has to be kept true by hand — so an option
# below is written only when it was given, as an override.
{
echo "# $NODE, written by onboard-node.sh."
# Several addresses, comma separated, are every control plane the node may
# fail over to; a standby refuses it and it moves on to the next.
if [ "${SERVER#*,}" != "$SERVER" ]; then
echo "servers:"
printf '%s\n' "$SERVER" | tr ',' '\n' | sed 's/^ *//; s/^/ - "/; s/$/"/'
else
echo "server: \"$SERVER\""
fi
echo
echo "tls:"
echo " ca: /etc/llmgw/ca.pem"
echo " cert: /etc/llmgw/agent.pem"
echo " key: /etc/llmgw/agent-key.pem"
# The certificate's common name is --node, and the control plane refuses a
# stream whose node id differs from it. Said explicitly only when the
# hostname would say something else.
[ "$NODE_IS_HOSTNAME" = yes ] || echo "node_id: $NODE"
[ -n "$MGMT_OVERRIDE" ] && echo "mgmt_addr: \"$MGMT_OVERRIDE\""
[ -n "$MGMT_IF_OVERRIDE" ] && echo "mgmt_interface: $MGMT_IF_OVERRIDE"
if [ -n "$FABRIC_NAME" ]; then
# Fabric membership is the one thing that cannot be found: OpenSM's default
# subnet prefix is the same on every fabric, so a matching prefix proves
# nothing and a wrong guess hangs the first all-reduce.
echo "fabrics:"
echo " - name: $FABRIC_NAME"
echo " hcas: [$(echo "$FABRIC_HCAS" | sed 's/,/, /g')]"
fi
if [ -n "$NVIDIA_SMI" ] || [ "$IB_ROOT" != /sys/class/infiniband ]; then
echo "discovery:"
[ -n "$NVIDIA_SMI" ] && echo " nvidia_smi: \"$NVIDIA_SMI\""
[ "$IB_ROOT" != /sys/class/infiniband ] && echo " ib_root: $IB_ROOT"
fi
if [ -n "$LLAMACPP_BIN$VLLM_BIN$SGLANG_PYTHON" ]; then
echo "engines:"
[ -n "$LLAMACPP_BIN" ] && printf ' llamacpp:\n bin: "%s"\n' "$LLAMACPP_BIN"
[ -n "$VLLM_BIN" ] && printf ' vllm:\n bin: "%s"\n' "$VLLM_BIN"
[ -n "$SGLANG_PYTHON" ] && printf ' sglang:\n python: "%s"\n' "$SGLANG_PYTHON"
fi
true
} > "$TMP/agent.yaml"
scp -q "$TMP/agent.yaml" "$SSH_USER@$ADDR:/tmp/agent.yaml"
cat > "$TMP/llmgw-agent.service" <<'UNIT'
[Unit]
Description=llmgw node agent
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
# Root, because the agent launches engines and needs device access. Engines
# outlive a restart of this unit and are adopted again on the next start, so
# KillMode must not take the whole process group with it.
KillMode=process
Restart=on-failure
RestartSec=2s
# RDMA pins the buffers it registers, and NCCL registers a great deal: with
# systemd's default 8 MB lock limit, an engine that drops the capability that
# lets root ignore it fails its first collective over InfiniBand.
LimitMEMLOCK=infinity
StateDirectory=llmgw-agent
[Install]
WantedBy=multi-user.target
UNIT
scp -q "$TMP/llmgw-agent.service" "$SSH_USER@$ADDR:/tmp/"
# An engine path that is wrong, or right but unrunnable, is the failure this
# node type is most likely to have, and it is invisible: the agent registers
# healthy and every shard placed on it dies with "executable file not found"
# or a missing shared library. Checked here because it is cheap here and
# expensive later — llama-server on node05 was present, executable, and could
# not start for want of libgomp1, and the node reported ready throughout.
check_engine() {
[ -n "$2" ] || return 0
if $SSH "test -x '$2'" 2>/dev/null; then
if $SSH "'$2' --version >/dev/null 2>&1 || '$2' --help >/dev/null 2>&1"; then
say "$1: $2 runs"
return 0
fi
ENGINE_PROBLEMS="$ENGINE_PROBLEMS
$1 at $2 exists but will not run. Usually a missing shared library:
ssh $SSH_USER@$ADDR ldd $2 | grep 'not found'"
return 0
fi
ENGINE_PROBLEMS="$ENGINE_PROBLEMS
$1 is configured at $2, which is not an executable on $NODE."
}
ENGINE_PROBLEMS=
if [ -n "$LLAMACPP_BIN$VLLM_BIN$SGLANG_PYTHON" ]; then
say "checking the engines"
check_engine llamacpp "$LLAMACPP_BIN"
check_engine vllm "$VLLM_BIN"
check_engine sglang "$SGLANG_PYTHON"
fi
say "starting the agent"
$SSH 'set -e
sudo install -m 0644 -o root -g root /tmp/agent.yaml /etc/llmgw/agent.yaml
sudo install -m 0644 /tmp/llmgw-agent.service /etc/systemd/system/llmgw-agent.service
rm -f /tmp/agent.yaml /tmp/llmgw-agent.service
sudo systemctl daemon-reload
sudo systemctl enable --now llmgw-agent >/dev/null
sudo systemctl restart llmgw-agent'
echo
if [ -z "$LLAMACPP_BIN$VLLM_BIN$SGLANG_PYTHON" ]; then
say "the agent now installs the engines the control plane asks for: the"
say "build for this machine, checked against its publisher's checksum and run"
say "before the node is offered any model. 'llmgw ctl nodes' shows progress."
fi
if [ -n "$ENGINE_PROBLEMS" ]; then
echo "$NODE is onboarded but an engine will not run:$ENGINE_PROBLEMS"
echo
echo "The node will register as ready regardless. Fix this before a replica"
echo "is placed on it, or the shard fails and the reason is three logs away."
echo
fi
echo "$NODE onboarded. It should appear in 'llmgw ctl nodes' within a few seconds."
echo "If it does not, the control plane's log says why — a certificate whose"
echo "common name does not match the claimed node id is refused by design."
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
#
# Create a VM on Proxmox for a new llmgw node.
#
# Run on the Proxmox host. It does the half of onboarding that is about
# hardware; onboard-node.sh does the half that is about identity, and the two
# are separate because they are done by different people with different access.
#
# Idempotent only in the sense that it refuses to touch an existing VMID. A node
# that already exists is not something a provisioning script should have an
# opinion about.
set -euo pipefail
usage() {
cat >&2 <<USAGE
usage: $0 --vmid N --name NAME --ip ADDR [options]
--vmid N Proxmox VM id; must be free
--name NAME VM name; use the node id, so the machine, the hostname and
the certificate common name are the same string
--ip ADDR address with prefix, e.g. 10.0.0.144/24
--gw ADDR gateway (default 10.0.0.1)
--cores N (default 4)
--cpu TYPE QEMU CPU model (default host)
--memory MB (default 8192)
--disk GB (default 20)
--bridge NAME (default vmbr0)
--storage NAME (default local-lvm)
--image PATH cloud image (default Ubuntu 24.04 in the ISO store)
--user NAME cloud-init user (default ilker)
--sshkeys PATH authorized_keys to install (default /root/.ssh/authorized_keys)
USAGE
exit 2
}
VMID= NAME= IP= GW=10.0.0.1 CORES=4 MEMORY=8192 DISK=20
# host, not Proxmox's kvm64 default. kvm64 hides AVX, AVX2, FMA and even
# SSE4.2 from the guest, and an inference node without vector instructions is
# not slightly slower, it is unusable: llama.cpp falls back to its scalar
# backend and a 1.5B model dropped from 39 tokens a second to 5.8, with prompt
# processing 19x worse. It cost an afternoon to find because nothing is broken
# — every number is simply small.
#
# The trade is live migration: a VM pinned to the host CPU cannot move to a
# machine with a different one. For a node whose job is arithmetic, that is
# the right way round.
CPU=host
BRIDGE=vmbr0 STORAGE=local-lvm USER=ilker
IMAGE=/var/lib/vz/template/iso/noble-server-cloudimg-amd64.img
SSHKEYS=/root/.ssh/authorized_keys
while [ $# -gt 0 ]; do
case "$1" in
--vmid) VMID="$2"; shift 2;;
--name) NAME="$2"; shift 2;;
--ip) IP="$2"; shift 2;;
--gw) GW="$2"; shift 2;;
--cores) CORES="$2"; shift 2;;
--cpu) CPU="$2"; shift 2;;
--memory) MEMORY="$2"; shift 2;;
--disk) DISK="$2"; shift 2;;
--bridge) BRIDGE="$2"; shift 2;;
--storage) STORAGE="$2"; shift 2;;
--image) IMAGE="$2"; shift 2;;
--user) USER="$2"; shift 2;;
--sshkeys) SSHKEYS="$2"; shift 2;;
*) usage;;
esac
done
[ -n "$VMID" ] && [ -n "$NAME" ] && [ -n "$IP" ] || usage
if qm status "$VMID" >/dev/null 2>&1; then
echo "VM $VMID already exists. Pick a free id; this script will not adopt one." >&2
exit 1
fi
[ -f "$IMAGE" ] || { echo "no cloud image at $IMAGE" >&2; exit 1; }
echo "creating $NAME as VM $VMID at $IP"
qm create "$VMID" \
--name "$NAME" --cores "$CORES" --memory "$MEMORY" --cpu "$CPU" \
--net0 "virtio,bridge=$BRIDGE" --ostype l26 --agent enabled=1
# Imported rather than installed: a cloud image plus cloud-init is the whole
# reason a node takes a minute to build instead of an afternoon.
qm importdisk "$VMID" "$IMAGE" "$STORAGE" >/dev/null
qm set "$VMID" --scsihw virtio-scsi-pci --scsi0 "$STORAGE:vm-$VMID-disk-0,discard=on,ssd=1" >/dev/null
qm disk resize "$VMID" scsi0 "${DISK}G" >/dev/null
qm set "$VMID" --ide2 "$STORAGE:cloudinit" --boot order=scsi0 >/dev/null
qm set "$VMID" \
--ipconfig0 "ip=$IP,gw=$GW" \
--nameserver "$GW 8.8.8.8" \
--ciuser "$USER" \
--sshkeys "$SSHKEYS" >/dev/null
qm start "$VMID"
echo "started. cloud-init takes a minute; then run onboard-node.sh from the CA host."
+69 -19
View File
@@ -4,51 +4,101 @@
# keep serving if the agent restarts: the router talks to them directly, and
# orphan adoption picks them up again on the next start.
node_id: gpu-a-01 # defaults to the hostname
# Everything a node can find out about itself, it does: its id is its hostname,
# its address is the one on the route to the control plane, llama-server is
# looked for on PATH and in /opt/llama.cpp, GPUs are there when nvidia-smi is,
# weights are fetched into /var/lib/llmgw-weights and looked for there, and
# memory, disk and installed engines are re-read while the agent runs. So a
# plain CPU node needs only the two blocks below, and the same file on every
# node. What follows them are overrides, for when the defaults are wrong.
server: "http://llmgw-server.internal:7443"
# The agent's half of mutual TLS. The certificate's common name must be this
# node's id — its hostname, unless node_id says otherwise — because the control
# plane compares the two and refuses the stream when they disagree:
#
# llmgw ctl cert issue-client gpu-a-01 --dir /etc/llmgw/pki
#
# With this set, `server` above must be an https:// address — an agent holding
# certificates and dialling http:// is refused at startup rather than connecting
# in the clear without noticing.
#
# tls:
# ca: /etc/llmgw/ca.pem
# cert: /etc/llmgw/agent.pem
# key: /etc/llmgw/agent-key.pem
# ---- overrides ---------------------------------------------------------------
# node_id: gpu-a-01 # when it is not the hostname
# The address engines are reachable on, and the address NCCL bootstraps over.
# Never a fabric address: NCCL rendezvouses over TCP before moving to RDMA.
mgmt_addr: "10.0.0.11"
mgmt_interface: eth0
# mgmt_addr: "10.0.0.11"
# mgmt_interface: eth0
labels:
rack: r12
# Free-form, for a profile's node_selector. Nothing needs them; do not restate
# what is measured (memory, GPUs, engines), because a label goes stale.
# labels:
# rack: r12
heartbeat_interval: 5s
reconnect_base: 1s
reconnect_max: 30s
# Fabric labelling is explicit and there is no way around it. OpenSM's default
# subnet prefix is fe80:: on every fabric, so a matching prefix proves nothing
# and a wrong guess produces a replica that starts, passes health checks and
# then hangs on its first all-reduce (plan §5.2).
# Each fabric this node's adapters are on, by name. The name is checked against
# what the adapters report — the subnet managers every port on one fabric sees —
# so two independent fabrics given one name are placed as two and the cluster
# page says so; give each its own. The rate is measured, not read from here.
#
# A node with no fabrics is its own single-node island: it can host single-node
# replicas and will never be given a multi-node one.
# replicas and will never be given a multi-node one. docs/fabrics.md has more.
fabrics:
- name: ib-a
hcas: [mlx5_0, mlx5_1, mlx5_2, mlx5_3]
rate_gbps: 200
discovery:
# Worth setting where the driver lives outside the default paths, which
# containerized driver installs often do.
nvidia_smi: ""
ib_root: /sys/class/infiniband
# NVML is the only source of NVLink topology, which is what keeps a
# tensor-parallel group off the PCIe path inside a node. It needs a binary
# built with `-tags nvml`; otherwise this silently falls back to nvidia-smi.
# NVLink topology comes from `nvidia-smi topo -m` on every build; a binary
# built with `-tags nvml` reads it from NVML instead, when this is set.
prefer_nvml: true
skip_gpus: false
# Where this node keeps model weights besides /var/lib/llmgw-weights, which is
# where fetched weights land and is always looked in. Storage differs at
# every installation and llmgw deliberately does not own it: a Lustre mount, an
# image exported over the fabric with SRP or NVMe-oF, local NVMe, or a copy
# made by hand all look identical from here.
#
# What it buys is placement preferring a node that already holds the model over
# one that must first move tens of gigabytes. That matters most where fabrics
# are isolated from one another, because weights in one fabric are not weights
# in another and the only path between them is the management network.
#
# model_dirs:
# - /var/lib/llmgw-models
# - /mnt/lustre/models
# The agent installs the engines the control plane names (see `engines:` in
# server.yaml) under /opt/llmgw/engines: the build for this machine — CUDA when
# an NVIDIA driver is loaded — checked against its publisher's checksum, with
# the OS packages it needs, and run before the node takes any model. llama.cpp
# is installed this way today. Naming a binary below opts that engine out and
# uses yours instead; vLLM and SGLang still have to be named until the agent
# installs them too.
# engines_dir: /opt/llmgw/engines
engines:
vllm:
bin: /opt/venvs/vllm/bin/vllm
# Which multi-node mechanism works depends on the vLLM version, and the
# flags have changed more than once. Check the version you deploy.
launch_mode: ray # "ray" or "mp"
ray_bin: /opt/venvs/vllm/bin/ray
# How a replica spanning nodes is launched. mp, the default, is what vLLM
# documents today and needs nothing but vLLM; ray is for a vLLM too old
# for mp's multi-node flags, and needs Ray on every node.
launch_mode: mp # "mp" or "ray"
# ray_bin: /opt/venvs/vllm/bin/ray
sglang:
# SGLang is launched as a module, which is its documented entry point.
python: /opt/venvs/sglang/bin/python
+162 -6
View File
@@ -7,12 +7,92 @@
# __ for nesting: LLMGW_ROUTER__RETRIES=3
listen: ":8080" # client-facing OpenAI API
admin_listen: ":9090" # metrics, health, pprof — do not expose this publicly
admin_listen: "127.0.0.1:9090" # dashboard, management API, pprof — keep it local
# Metrics only, on their own port, so Prometheus can reach them without pprof
# being reachable too. Empty means off; metrics stay on admin_listen either way.
# metrics_listen: "0.0.0.0:9091"
# Where node agents connect. Separate from the client port so the fleet-facing
# surface can be firewalled independently. Agents speak plaintext HTTP/2 today,
# so this must stay on a trusted network until mTLS lands (plan §5.6).
# surface can be firewalled independently.
agent_listen: ":7443"
# Mutual TLS on the agent port (plan §5.6). With it, a node's identity is taken
# from its certificate's common name and an agent claiming a different node id
# is refused. Without it the port is plaintext HTTP/2 and anyone who can reach
# it can claim to be any node, so it must stay on a trusted network.
#
# These certificates come from llmgw's own authority, not a public one: public
# authorities stopped issuing the clientAuth usage, so no Let's Encrypt
# certificate can authenticate an agent.
#
# llmgw ctl cert init-ca --dir /etc/llmgw/pki
# llmgw ctl cert issue-server llmgw-ctl --host 10.0.0.140 --dir /etc/llmgw/pki
#
# Every address an agent might dial has to appear in --host, bare IP addresses
# included: hostname verification reads subject alternative names and ignores
# the common name entirely.
#
# This covers the fleet-facing port only. The client-facing API above is public
# and belongs behind a publicly trusted certificate.
#
# agent_tls:
# ca: /etc/llmgw/ca.pem
# cert: /etc/llmgw/server.pem
# key: /etc/llmgw/server-key.pem
# The public page at "/". Someone who reaches this address without a key needs a
# route to getting one, and the gateway cannot guess who to send them to.
#
# landing:
# contact: "platform@example.com" # or an https:// URL, or "#platform on Slack"
# notice: "Internal use only."
# API keys on the client-facing port (plan §7.9). Off by default, because a
# gateway on a trusted network with no keys issued still has to serve — but
# nothing else protects that port, so turning it on is what makes it safe to
# expose.
#
# llmgw keys init --store /var/lib/llmgw/llmgw.db
# llmgw keys create --store /var/lib/llmgw/llmgw.db --project default "laptop"
#
# The key is printed once. Only its hash is stored, so there is nothing to
# recover it from.
#
# auth:
# enabled: true
# store: /var/lib/llmgw/llmgw.db
# # How long a resolved key is trusted before being read again. This is the
# # price of keeping the database off the request path: it bounds how long a
# # revoked key keeps working.
# cache_ttl: 30s
# # How long a rejected key is remembered as rejected, so a client looping
# # with a bad key cannot become a database read per request.
# negative_ttl: 5s
#
# # Serve the signup form. What it creates is a pending account that can do
# # nothing until `llmgw users approve` places it in a project, so this is not
# # a way in — it only moves key creation from you to the person who needs one.
# # Off, there is no form and you issue keys with `llmgw keys create --claim`.
# signup_open: true
#
# # What each model costs, in micro-units per thousand tokens. A unit is
# # whatever you decide: self-hosted models have no per-token price, so it can
# # be GPU-seconds, a notional rate, or 1000 everywhere — which turns budgets
# # into plain token quotas.
# #
# # A model priced at zero is exempt from budget checks, which is what lets a
# # key with nothing left still be served a cheap fallback.
# default_price: { prompt_per_1k: 0, completion_per_1k: 0 }
# pricing:
# llama-3.3-70b: { prompt_per_1k: 1000, completion_per_1k: 3000 }
# qwen2.5-32b: { prompt_per_1k: 400, completion_per_1k: 1200 }
#
# # Usage rows are written in batches off the request path. Beyond buffer_size
# # they are dropped rather than blocking a request: losing some accounting is
# # bad, turning a stalled database into an outage is worse.
# flush_interval: 200ms
# batch_size: 500
# buffer_size: 8192
router:
scrape_interval: 2s
scrape_timeout: 1s
@@ -33,6 +113,39 @@ router:
failure_threshold: 3
default_profile: chat # chat | batch | long-context | debug
# Approximate prefix routing (plan §7.5 tier 1): send a request to the replica
# that has probably already prefilled its prefix.
#
# It earns its keep when the working set does not fit in one replica's cache.
# With a handful of prompts and a large cache, scattering is free — every
# replica ends up holding everything — and this only adds constraint. Measured
# with sim-engines whose caches were too small for the working set: cache hits
# went from 22.5% to 81.7%.
#
# Watch llmgw_prefix_decisions_total to tune it. A high
# load_balance_override count means the guard is doing its job and affinity is
# not getting a chance; a high cache_miss_min_load means match_ratio or
# min_match is too strict for your traffic.
prefix:
enabled: false
# The shortest match worth acting on, in bytes. Below this the overlap is
# coincidence — two English prompts share "The".
min_match: 64
# The share of the prompt that must match. A short shared header on a very
# long prompt saves almost no prefill.
match_ratio: 0.3
# The imbalance guard, and both must trip before affinity is abandoned.
# Absolute alone fires on an idle cluster where one replica has two requests
# and another none; relative alone fires on a busy one where 200 against 180
# is a rounding error. Without the guard the cache hit rate rises while p99
# collapses.
balance_abs: 8
balance_rel: 1.5
# An engine evicts on its own schedule, so an old record may describe a
# cache that is gone.
ttl: 10m
max_nodes: 100000
session_header: x-session-id # affinity key; falls back to the `user` field
# Ask engines for usage even when the client did not, so token accounting
# works regardless of client behaviour. The extra chunk is dropped on the way
@@ -52,9 +165,9 @@ router:
# traffic, because an engine queue is FIFO and knows nothing about tenants.
admission:
enabled: true
# Until authentication lands, the band comes from a header and is trusted.
# Do not expose this port to callers who should not be able to promote
# themselves.
# With auth on, the key decides the band and this header is ignored. With
# auth off the header is trusted, so any client can promote itself into the
# top band — do not expose the port in that configuration.
band_header: x-llmgw-band
default_band: gold
# A band waits once pool saturation passes its ceiling, so the cluster
@@ -84,6 +197,13 @@ router:
session_load_factor: 1.25
# How aggressively the control plane reconciles.
# The engines every node installs, and at which version. Unset keeps the
# versions this release was checked against (llama.cpp b10991). Each agent
# picks the build for its own hardware and installs it itself; changing a
# version here reaches every node on its next connect.
# engines:
# llamacpp: b10991
control:
interval: 5s
base_backoff: 10s
@@ -96,15 +216,51 @@ control:
port_range: [20000, 20999]
mgmt_interface: eth0
# Each model needs a source — a weights path or a HuggingFace id — and at least
# one profile if the control plane is to place it. Without profiles the model is
# catalogued but never started, which is the router-only mode below.
models:
- name: llama-3.3-70b
source: meta-llama/Llama-3.3-70B-Instruct
aliases: [llama]
fallbacks: [qwen2.5-32b]
routing_profile: chat
scaling: { min: 1, max: 4 }
profiles:
# Two nodes of four H100s each. A multi-node profile requires a fabric,
# and a replica never spans two of them.
- name: tp8
engine: vllm
gpu_class: h100-80g
tp: 8
nodes: 2
gpus_per_node: 4
priority: 100
weight: 8
startup_timeout: 30m
# Where a whole node is free, one node is cheaper to schedule and faster
# to start than two.
- name: tp4-a100
engine: vllm
gpu_class: a100-40g
tp: 4
priority: 50
weight: 3
startup_timeout: 20m
- name: qwen2.5-32b
source: Qwen/Qwen2.5-32B-Instruct
aliases: [qwen]
routing_profile: batch
scaling: { min: 1, max: 2 }
profiles:
- name: tp2
engine: sglang
gpu_class: h100-80g
tp: 2
priority: 100
weight: 2
startup_timeout: 15m
# A static replica list, used only when no model declares a profile. That is the
# router-only mode for testing against sim-engines; with the control plane
+87
View File
@@ -0,0 +1,87 @@
# Two control planes
llmgw handles the hard half of this itself: only one control plane places work,
and agents find whichever one is leading. What it does not do is move client
traffic, because that happens in front of both and llmgw is not in front of
anything.
This directory is the missing half. It is a pattern rather than a product:
substitute your own load balancer if you have one.
## What llmgw does on its own
**One leader.** Leadership is a lease in the shared store. A second control
plane starts, sees the lease, and stands by — it places nothing, evicts
nothing and drains nothing. Start one by accident and your cluster is
unharmed.
**Agents follow.** A standby refuses agent streams, so agents move to the
leader by themselves. Give each agent both addresses:
```yaml
servers:
- "https://ctl-1.internal:7443"
- "https://ctl-2.internal:7443"
```
Measured on a cluster: leadership moved three seconds after the leader was
stopped, every agent had arrived six seconds later, and every replica was
adopted rather than restarted — the engines never stopped serving.
## What it does not do
Client traffic. During that same failover the standby served inference
correctly on its own port while the public address returned 502, because
nothing in front of the two had moved.
So something has to. The options, in the order most people should consider
them:
**A load balancer you already run.** Health-check `/ready` on each control
plane and send traffic to whichever answers. This is the right answer if you
have one, and it needs nothing else from this directory.
**A floating address.** `keepalived.conf.example` here moves one IP between
the two machines. Fewer moving parts than a load balancer and no extra hop,
at the cost of one machine carrying all the traffic.
**DNS.** Works, eventually. A record with a short TTL is a failover measured
in whatever your resolvers actually honour rather than what you set, which on
a bad day is minutes. Fine for a cluster people use interactively, wrong for
one an agent is driving.
## Check `/ready`, not `/health`
This is the one thing to get right, and the obvious choice is the wrong one.
`/health` says the process is up. It says nothing about whether it can serve,
and a standby is exactly that case: alive, answering, and holding no endpoints
at all — its reconciler placed nothing and it has no agent streams to adopt
from. A load balancer checking `/health` would send it half the traffic, and
half the traffic would be a 503.
`/ready` says this process has somewhere to send a request. It is 503 on a
standby, 503 on a leader whose replicas have all failed, and 200 when there is
something to serve — which is the question a load balancer is actually asking.
Both are open, because a load balancer has no key.
During a failover neither passes for about the length of the lease handover:
three seconds when the leader is stopped cleanly, up to the lease TTL when it
is not. That gap is real and there is no way to have it both ways — traffic
cannot go to a control plane that has nothing to route to.
Not the admin port, for a different reason: it serves pprof, and pprof can
dump the process heap, where API keys sit in plaintext.
## Both control planes need the same store
Leadership lives in it, and so do keys, budgets and conversations. Two control
planes on two SQLite files are two clusters that happen to share a name.
```yaml
auth:
store: "@/etc/llmgw/secrets/store-dsn"
```
See [docs/install.md](../../docs/install.md) for moving an existing
installation onto PostgreSQL without reissuing every key.
+65
View File
@@ -0,0 +1,65 @@
# A floating address for two control planes.
#
# One IP that lives on whichever machine is up. Clients and the reverse proxy
# in front of llmgw point at the address, not at either host, so a failover is
# invisible to them.
#
# This is not a load balancer: all traffic goes to the holder. That is usually
# the right trade for a control plane, which serves far less than the nodes
# it schedules.
#
# Copy to /etc/keepalived/keepalived.conf on both machines, changing state,
# priority and interface.
global_defs {
enable_script_security
script_user nobody
}
# The address moves only while llmgw can actually serve, not merely while the
# machine is powered on.
#
# /ready, not /health. /health is 200 on a standby, which is up, answering and
# holding no endpoints at all — so the address would sit on a host that refuses
# every request. /ready is 200 only when there is somewhere to send one.
#
# Not the admin port either: it serves pprof, which can dump the heap that
# holds every API key.
vrrp_script check_llmgw {
script "/usr/bin/curl -fsS --max-time 2 -o /dev/null http://127.0.0.1:8080/ready"
interval 2
timeout 3
# Two failures before giving up the address, so one slow moment does not
# move it. Two successes before taking it back, so a flapping gateway does
# not drag the address with it.
fall 2
rise 2
weight -40
}
vrrp_instance llmgw {
# MASTER on the machine you would rather serve; BACKUP on the other.
state MASTER
interface eth0
# Any number both machines agree on and nothing else on the segment uses.
virtual_router_id 51
# The higher one wins while both are healthy. The weight above is enough
# to invert this when the preferred machine's gateway is not answering.
priority 100
advert_int 1
authentication {
auth_type PASS
# Change this. It stops another host on the segment claiming the
# address, which is otherwise a single unicast packet away.
auth_pass CHANGEME
}
virtual_ipaddress {
10.0.0.150/24
}
track_script {
check_llmgw
}
}
+262
View File
@@ -0,0 +1,262 @@
# A model mirror
One machine that holds the model files, so nodes fetch over your own network
instead of over the internet.
This is the highest-value piece of infrastructure around llmgw and the cheapest.
Eight nodes each pulling forty gigabytes from Hugging Face is a day's work, and
on plenty of corporate networks it is not possible at all. From a mirror on the
same switch it is minutes.
llmgw needs nothing special for this: a model's `source` is a URL, and the URL
can be yours.
## What it is
An HTTP server serving a directory. That is the whole design.
```
/srv/models/
qwen2.5-14b-instruct/
2026-03/
q4_k_m.gguf
bge-m3/
2026-01/
model.safetensors
```
The date in the path is the point of the layout, not decoration. A model
repository that changes upstream changes what you are running, and the first
sign is a prompt that worked last week answering differently this week with
nobody able to say why. Pin it here and that cannot happen.
## Running it
`llmgw` is the mirror. Same binary as the control plane and the agents, one
subcommand:
```
sudo mkdir -p /srv/models && sudo chown llmgw:llmgw /srv/models
llmgw mirror serve --dir /srv/models --base http://models.internal:8081
```
There is a unit in `deploy/systemd/llmgw-mirror.service`. Set `--base` to the
address nodes reach this machine on — not `localhost`, and not a name only this
host knows itself by. It is the string that ends up in catalog entries, and a
wrong one makes `mirror unused` report files as unreferenced when they are in
use. The next step after that list is deletion.
### Two listeners
`--listen :8081` serves the weights. Read-only, unauthenticated, Range requests
supported so an interrupted fetch on a node resumes rather than restarts. This
is the path eight nodes pull forty gigabytes from.
`--api-listen :8082` is the management API. Separate on purpose: putting a
client certificate in front of the byte path would mean re-certifying every
agent's fetcher in order to change how models are administered.
The API is unauthenticated unless you give it the fleet's CA, and it says so at
startup rather than leaving you to find out. Anyone who can reach that port can
start downloads and delete models, so either keep it on a trusted network or
hand it certificates:
```
llmgw mirror serve --dir /srv/models --base http://models.internal:8081 \
--tls-ca /etc/llmgw/ca.pem \
--tls-cert /etc/llmgw/mirror.pem \
--tls-key /etc/llmgw/mirror-key.pem
```
That is the same CA the agents already use, so the certificates come from the
authority you have:
```
llmgw ctl cert issue-server mirror --host models.internal --host 10.0.0.146
llmgw ctl cert issue-client control-plane
```
`--host` has to cover every address the API is called on, because that is what
a client verifies against. Both sides are checked — the mirror will not accept a
caller without a certificate from this CA, the same rule the agent stream
follows.
### nginx instead
If you already run nginx, it serves a directory perfectly well and you do not
need `mirror serve` for the bytes:
```nginx
server {
listen 80;
server_name models.internal;
root /srv/models;
# Read-only. This is deliberate: a mirror that can be written to over HTTP
# is a way to replace a model with another one, and the nodes would fetch
# it without complaint.
location / {
autoindex on;
# Large files, and the client is a machine with a resumable fetcher.
# Sendfile and no buffering keep memory flat regardless of size.
sendfile on;
tcp_nopush on;
}
}
```
`autoindex` is there so a person can browse what is available; llmgw does not
need it.
## Filling it
From the mirror itself:
```
llmgw mirror add \
--model qwen2.5-14b-instruct --version 2026-03 \
"https://huggingface.co/<repo>/resolve/<revision>/q4_k_m.gguf"
```
Use a revision rather than `main` in the source URL. `main` is what moves.
It downloads to a temporary name and renames when complete, so an interrupted
download never leaves something a catalog could be pointed at, and it prints the
sha256 when it lands. Compare that against what upstream publishes — Hugging
Face gives it as `lfs.oid` from
`https://huggingface.co/api/models/<repo>/tree/<revision>`. A mirror is the one
place in this system where a corrupt file is copied to every node instead of
being caught once.
It refuses to overwrite a version that is already there. Add a new one beside
it: that refusal is what makes the old weights still available when the new ones
turn out to be worse.
## Driving it from elsewhere
The same fetch over the API, so the control plane — or anything else — can ask
for a model without anyone logging in:
```
curl -X POST http://models.internal:8082/v1/models -d '{
"source": "https://huggingface.co/<repo>/resolve/<revision>/q4_k_m.gguf",
"model": "qwen2.5-14b-instruct",
"version": "2026-03"
}'
```
The mirror does the downloading. A control plane that pulled the bytes itself
and pushed them here would carry every model twice over the network, through the
one machine least able to spare the bandwidth.
It answers `202` with the URL a catalog entry should use, and the bytes are not
here yet. Poll for them:
| | |
|---|---|
| `GET /v1/models` | everything held or arriving, and the base to build URLs from |
| `POST /v1/models` | fetch this source into model/version |
| `GET /v1/models/{model}/{version}` | `fetching` with a byte count, `present`, or `failed` with the reason |
| `DELETE /v1/models/{model}/{version}` | remove a version; refuses while it is downloading |
| `GET /v1/health` | up *and* able to read its own directory |
A failed fetch stays `failed` with what went wrong, rather than disappearing:
otherwise the thing waiting on that model waits forever with nothing to say why.
Asking twice for the same download joins the one already running instead of
starting a rival writer on the same file.
`DELETE` takes the whole version, never one file of it. A model split across
several files is useless with one missing, and half a version on disk looks
present and will not load.
## Retiring a model
```
llmgw mirror unused # what no catalog entry refers to
llmgw mirror remove --model <m> --version <v> --file <f>
```
`unused` compares against every `source` in the running catalog, so the answer
is what can go rather than what looks old. It refuses to answer when it cannot
reach the control plane: an empty catalog and an unreachable one produce the
same list — everything — and the obvious next step after that list is deletion.
Retire the catalog entry first, let the fleet settle, then remove. A node that
has to refetch a file you deleted cannot, and the failure arrives later, on the
node, looking like something else.
## From the control plane's admin page
Configure the mirror on the control plane and the same operations appear at
`/admin/mirror`, behind the admin session the other admin pages use:
```yaml
mirror:
api: https://models.internal:8082
tls:
ca: /etc/llmgw/ca.crt
cert: /etc/llmgw/control-plane.crt
key: /etc/llmgw/control-plane.key
```
Leave the block out and the page is not mounted at all, which is the right page
for a deployment with no mirror — not an empty one pointing at nothing.
The page lists what is held, what is arriving with its byte count, and what
failed with the reason; it starts a fetch, retries a failed one, and removes a
version. It reloads itself only while something is downloading, because a reload
at any other moment takes with it whatever is half-typed in the form.
Two things it deliberately will not do. It cannot point the control plane at a
different mirror — that is configuration, and changing it needs access to the
host rather than a session. And it does not write the catalog for you: it shows
the URL an entry should use, and putting it there is still a deliberate act,
because that is the moment a model starts being served.
The address is a mistake worth naming: `api` is the **management** port, not the
one nodes fetch bytes from. They are two listeners so this one can be locked
down without re-certifying every agent's fetcher.
## Pointing llmgw at it
```yaml
- name: qwen2.5-14b-instruct
source: http://models.internal/qwen2.5-14b-instruct/2026-03/q4_k_m.gguf
scaling: { min: 2, max: 2 }
profiles:
- name: gpu
engine: vllm
gpu_class: h100-80g
tp: 1
```
Each node fetches once, in the background, before its engine starts — and
`llmgw ctl replicas` shows how far along it is while it does. A killed download
resumes rather than restarting, and a partial file is never visible under the
model's name, so an interrupted fetch cannot be handed to an engine.
## What this is not
It is not a cache. Nothing populates it on its own: a model is there because
somebody asked for it, by hand or over the API, and one nobody asked for is not
there. That is on purpose. A mirror that fetched on demand would reach the
internet at the least convenient moment, and in a network where that is blocked
it would fail in a way that looks like llmgw being broken.
It is also not a replacement for a shared filesystem. If you already run one
that every node mounts, put the models on it and use a path as the `source` —
nothing is fetched at all, and llmgw notices the filesystem is shared and will
not count the same free space once per node.
## When this stops being enough
Somewhere above ten or twenty nodes, one mirror serving a forty gigabyte file
to all of them at once becomes the bottleneck instead of the fix. At that point
the options are a filer, a second mirror per rack, or distribution between the
nodes themselves.
llmgw knows which nodes already hold which weights — it is in `ctl status` as
"N models on X/Y nodes" — so distributing between them is a feature it is
positioned to add. It has not been built, because a mirror is enough for
everyone who has needed it so far.
+184
View File
@@ -0,0 +1,184 @@
# Watching a cluster
Prometheus, Alertmanager and Grafana, configured for what llmgw actually
publishes. Every alert here is written against a series this system emits;
nothing is aimed at a metric that might exist.
## What it is
One scrape target for the whole fleet.
Agents publish no metrics of their own. The control plane gathers what they
report — node state, memory, disk, engine queue depth, KV occupancy — and
republishes all of it, so a single endpoint describes every machine. That is
worth knowing before going looking for an exporter on each node.
## The port it scrapes
`metrics_listen`, not `admin_listen`.
The admin listener is bound to loopback for one reason: it also serves pprof,
and pprof can dump the process heap, where API keys sit in plaintext. Metrics
are the one thing on that listener something else genuinely needs to reach, so
they get a port of their own instead of becoming an argument for opening the
other one.
```yaml
# /etc/llmgw/server.yaml
metrics_listen: ":9091"
```
Confirm it carries nothing else:
```
curl -s -o /dev/null -w '%{http_code}\n' http://<control-plane>:9091/debug/pprof/ # 404
curl -s -o /dev/null -w '%{http_code}\n' http://<control-plane>:9091/api/v1/nodes # 404
```
## Installing
On Ubuntu 24.04 the first two come from the archive; Grafana needs its own
repository.
```
apt-get install prometheus prometheus-alertmanager
install -m 0644 prometheus.yml /etc/prometheus/prometheus.yml
install -m 0644 llmgw-rules.yml /etc/prometheus/rules/llmgw.yml
install -m 0644 alertmanager.yml /etc/prometheus/alertmanager.yml
# Alertmanager answers on loopback only. Its API takes silences from anyone who
# can reach it, with no login, and silencing BackupTooOld or ModelParked is how
# something that should be noticed is not. Prometheus beside it is its only
# client; one Alertmanager needs no cluster port.
echo 'ARGS="--web.listen-address=127.0.0.1:9093 --cluster.listen-address="' \
> /etc/default/prometheus-alertmanager
# Prometheus itself is read-only as installed: its admin and lifecycle APIs
# stay off (no --web.enable-admin-api, no --web.enable-lifecycle). It still
# answers any query from anyone who reaches :9090, which says which models and
# nodes there are and how busy each is. Its readers are the control plane
# (monitoring.prometheus) and Grafana beside it; to keep it to those, allow
# the control plane and drop the rest, e.g. with nftables:
# nft add rule inet filter input tcp dport 9090 ip saddr != { 127.0.0.1, CONTROL_PLANE_IP } drop
promtool check config /etc/prometheus/prometheus.yml
systemctl restart prometheus prometheus-alertmanager
install -m 0644 grafana/datasource.yaml /etc/grafana/provisioning/datasources/llmgw.yaml
install -m 0644 grafana/dashboards.yaml /etc/grafana/provisioning/dashboards/llmgw.yaml
install -m 0644 -o grafana -g grafana grafana/llmgw-overview.json \
/var/lib/grafana/dashboards/llmgw.json
systemctl restart grafana-server
```
The dashboard is provisioned from a file rather than clicked together, so it
survives a reinstall and can be reviewed in a diff.
## Where to run it
Not on the control plane. Monitoring that dies with the thing it monitors tells
you nothing at the moment you need it, and the control plane is also the busiest
machine in a small cluster.
Anywhere else on the management network will do. The mirror is a reasonable
host: it has the disk, and serving weights is bursty rather than constant.
## Nothing is sent anywhere
Alertmanager groups and holds alerts and has no receiver.
Where an alert should go — an address, a Slack workspace, a webhook — is a
decision with credentials attached, and a monitoring system that quietly starts
mailing people is worse than one that is visibly not wired up. Alerts are
visible in Prometheus, and at `:9093` on the monitoring host itself (`ssh -L
9093:127.0.0.1:9093 <host>` to open its page); add a receiver to `alertmanager.yml` to
make them travel. `critical` already routes separately so it can be pointed
somewhere else without touching the rest.
## Check that it fires
An alerting system nobody has seen fire is an assumption.
```
llmgw ctl cordon <node> # work already there keeps running
# ~2 minutes later
ssh <host> curl -s http://127.0.0.1:9093/api/v2/alerts | jq '.[].labels'
llmgw ctl uncordon <node> # the alert resolves on its own
```
Cordoning is the cheapest real test: it changes a metric the rules read, stops
nothing, and reverses in one command.
## What the alerts assume
**A stopped model publishes no series.** `llmgw_model_replicas_ready` exists
only for models with a replica placed, so `== 0` cannot fire for one that is
deliberately off. That is what makes an on-demand catalogue quiet here.
**Saturation of 1 is the healthy limit, not failure.** It is the worse of queue
depth and KV occupancy, measured by `ctl bench` rather than guessed. The alert
allows five minutes above it before saying anything.
**Latencies are in seconds.** These thresholds are written for CPU inference. A
p99 first token of thirty seconds is the alerting point here and would be
absurd on a GPU; change it when the hardware changes.
**Disk is only reported by nodes that hold weights.** A node with no model
directory reports nothing rather than zero, and the rule divides free by total
so a node that reports neither never fires.
## Where the metrics live
On the machine that scrapes them, in Prometheus's own store, and that is the
right answer at this size. Measured on this cluster: 2,359 series at 41 samples
a second, which compresses to roughly 7 MB a day — about 3 GB for a year.
```
# /etc/default/prometheus
ARGS="--storage.tsdb.retention.time=365d --storage.tsdb.retention.size=20GB"
```
A year because the questions worth asking of this data are seasonal: what did
capacity look like last quarter, was it always this slow, how much did the
fleet grow. Fifteen days answers none of them.
Both limits, not one. The time limit is the intent; the size cap is what stops
Prometheus becoming the thing that fills the disk if the series count grows
tenfold after somebody adds a fleet.
No remote write, no object store, no second tier. At two thousand series that
is infrastructure for a problem this cluster does not have, and every one of
those has its own failure modes to learn.
### The part that is not about capacity
The disk is not the constraint — 900 GB free against 3 GB a year. What matters
is what shares it.
On this cluster the model mirror and the metrics database are on the same
filesystem. A mirror that fills it stops Prometheus writing, so monitoring goes
quiet at exactly the moment the disk is the problem, and the alert that would
have said so is the one that died.
Two ways out, in order of how much they cost:
- Watch it, which is now done. `prometheus-node-exporter` arrives with the
Prometheus package and was running, unscraped, on this machine — so the disk
holding both was watched by nothing. It is a scrape target now, and
`MonitoringDiskFilling` fires at fifteen percent rather than five, because
the thing being protected is the ability to be told.
- Separate them: a volume of its own for `/var/lib/prometheus`, or monitoring
on its own machine. Worth doing before the mirror holds a few hundred
gigabytes; not worth doing today at seven percent used.
## What is not here
No exporter on the llmgw nodes, so no host CPU, load average or network for
them. The control plane publishes memory, disk and liveness for every node,
which is what placement decides on; the rest is a `node_exporter` away if
somebody wants it. The monitoring host itself is scraped, for the reason above.
No probe of the mirror. Its `/v1/health` is JSON rather than Prometheus text,
and checking it properly means `blackbox_exporter`.
No recording rules. At this size every query in the dashboard runs fast enough
raw, and a recording rule is a second definition of a number to keep in step
with the first.
+41
View File
@@ -0,0 +1,41 @@
# Alertmanager for the llmgw cluster.
#
# Nothing is sent anywhere yet, on purpose. Where an alert should go — an
# address, a Slack workspace, a webhook — is a decision with credentials
# attached, and a monitoring system that quietly starts mailing people is worse
# than one that is visibly not wired up. Alerts group and fire here and are
# visible at :9093 and in Prometheus; add a receiver below to make them travel.
global:
resolve_timeout: 5m
route:
# By alert and by what it is about, so five nodes going quiet in the same
# network blip arrive as one notification rather than five.
group_by: ['alertname', 'node', 'model']
group_wait: 30s
group_interval: 5m
# Long, because a repeat is a reminder and not news. A cluster with one thing
# wrong should not produce a message an hour.
repeat_interval: 12h
receiver: none
routes:
# Critical gets its own branch so it can be pointed somewhere else later
# without touching the rest: the difference between "a node is draining"
# and "nothing is serving" is who should be woken.
- matchers: [severity="critical"]
receiver: none
group_wait: 10s
repeat_interval: 4h
receivers:
- name: none
inhibit_rules:
# A node that is down explains every replica on it. Reporting both says the
# same thing twice and buries whichever is the actual cause.
- source_matchers: [alertname="NodeNotReady"]
target_matchers: [alertname="EndpointMetricsStale"]
equal: ['node']
- source_matchers: [alertname="ControlPlaneUnreachable"]
target_matchers: [severity="warning"]
+11
View File
@@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: llmgw
orgId: 1
folder: llmgw
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
+11
View File
@@ -0,0 +1,11 @@
# Provisioned rather than clicked in, so the dashboard below can reference it by
# a fixed uid and the whole setup survives a reinstall of the machine.
apiVersion: 1
datasources:
- name: Prometheus
uid: llmgw-prom
type: prometheus
access: proxy
url: http://localhost:9090
isDefault: true
editable: false
@@ -0,0 +1,492 @@
{
"uid": "llmgw-overview",
"title": "llmgw \u2014 cluster",
"tags": [
"llmgw"
],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-6h",
"to": "now"
},
"panels": [
{
"id": 1,
"title": "Nodes ready",
"type": "stat",
"description": "Nodes taking work. A node that is known but draining or cordoned is not counted.",
"gridPos": {
"x": 0,
"y": 0,
"w": 4,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "count(llmgw_node_up == 1)",
"legendFormat": "ready",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "auto",
"colorMode": "value"
}
},
{
"id": 2,
"title": "Replicas serving",
"type": "stat",
"description": "Across every model. A model deliberately stopped contributes nothing and is not a fault.",
"gridPos": {
"x": 4,
"y": 0,
"w": 4,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum(llmgw_model_replicas_ready)",
"legendFormat": "ready",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "auto",
"colorMode": "value"
}
},
{
"id": 3,
"title": "Models with something serving",
"type": "stat",
"description": "",
"gridPos": {
"x": 8,
"y": 0,
"w": 4,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "count(llmgw_model_replicas_ready > 0)",
"legendFormat": "models",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "auto",
"colorMode": "value"
}
},
{
"id": 4,
"title": "Requests per second",
"type": "timeseries",
"description": "Failing is counted separately rather than as a percentage, so a quiet cluster reads as quiet instead of as 0% of nothing.",
"gridPos": {
"x": 12,
"y": 0,
"w": 12,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(llmgw_requests_total[5m]))",
"legendFormat": "all",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
},
{
"refId": "B",
"expr": "sum(rate(llmgw_requests_total{status=~\"5..\"}[5m]))",
"legendFormat": "failing",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
}
},
{
"id": 5,
"title": "Replicas ready, by model",
"type": "timeseries",
"description": "Only models with a replica placed appear. One that is stopped on purpose publishes no series.",
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 7
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "llmgw_model_replicas_ready",
"legendFormat": "{{model}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
}
},
{
"id": 6,
"title": "Pool saturation",
"type": "timeseries",
"description": "The worse of queue depth and KV occupancy. 1 is the limit measured as healthy, not the point of failure.",
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 7
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "llmgw_pool_saturation",
"legendFormat": "{{model}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"max": 2,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
}
},
{
"id": 7,
"title": "Time to first token",
"type": "timeseries",
"description": "As the router sees it. On CPU these are seconds, not milliseconds; compare against what ctl bench measured.",
"gridPos": {
"x": 0,
"y": 11,
"w": 12,
"h": 7
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.5, sum by (model, le) (rate(llmgw_ttft_seconds_bucket[5m])))",
"legendFormat": "{{model}} p50",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
},
{
"refId": "B",
"expr": "histogram_quantile(0.99, sum by (model, le) (rate(llmgw_ttft_seconds_bucket[5m])))",
"legendFormat": "{{model}} p99",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
}
},
{
"id": 8,
"title": "Tokens per second",
"type": "timeseries",
"description": "",
"gridPos": {
"x": 12,
"y": 11,
"w": 12,
"h": 7
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum by (direction) (rate(llmgw_tokens_total[5m]))",
"legendFormat": "{{direction}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
}
},
{
"id": 9,
"title": "Engine queue depth",
"type": "timeseries",
"description": "Requests the engine has accepted and not started. Rising here is the first sign of saturation.",
"gridPos": {
"x": 0,
"y": 18,
"w": 8,
"h": 6
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "llmgw_endpoint_waiting",
"legendFormat": "{{replica}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
}
},
{
"id": 10,
"title": "KV cache occupancy",
"type": "timeseries",
"description": "",
"gridPos": {
"x": 8,
"y": 18,
"w": 8,
"h": 6
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "llmgw_endpoint_kv_cache_usage",
"legendFormat": "{{replica}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit"
},
"overrides": []
}
},
{
"id": 11,
"title": "Model disk free",
"type": "timeseries",
"description": "The filesystems holding weights. A node that cannot hold the next model is skipped by placement rather than failing.",
"gridPos": {
"x": 16,
"y": 18,
"w": 8,
"h": 6
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "llmgw_node_disk_free_bytes",
"legendFormat": "{{node}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
}
},
{
"id": 12,
"title": "Seconds since each node last reported",
"type": "timeseries",
"description": "Heartbeats are every 5s. A line that climbs is an agent going quiet; the control plane stops placing there at 15s.",
"gridPos": {
"x": 0,
"y": 24,
"w": 12,
"h": 6
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "llmgw_node_last_seen_seconds",
"legendFormat": "{{node}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
}
},
{
"id": 13,
"title": "Replicas by state",
"type": "timeseries",
"description": "",
"gridPos": {
"x": 12,
"y": 24,
"w": 12,
"h": 6
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum by (state) (llmgw_replicas)",
"legendFormat": "{{state}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
}
}
]
}
+264
View File
@@ -0,0 +1,264 @@
# Alerts for llmgw. Every expression here is against a series this cluster
# actually publishes; nothing is written against a metric that might exist.
groups:
- name: llmgw-fleet
rules:
- alert: ControlPlaneUnreachable
expr: up{job="llmgw"} == 0
for: 1m
labels: { severity: critical }
annotations:
summary: "The control plane is not answering"
description: >-
Nothing has scraped 10.0.0.140:9091 for a minute. Engines already
running keep serving — the router is in the same process, so if
this is down, so is the gateway.
- alert: NodeNotReady
expr: llmgw_node_up == 0
for: 2m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} is {{ $labels.state }}"
description: >-
The node is known but not taking work. Two minutes, because a node
restarting its agent is briefly not ready and is not a problem.
- alert: AgentGoneQuiet
expr: llmgw_node_last_seen_seconds > 60
for: 1m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} has not reported for {{ $value | humanizeDuration }}"
description: >-
Heartbeats are every 5s. A minute of silence is the network or the
agent, and the control plane stages expiry at 15s unreachable and
60s lost — so this fires around the point it stops placing there.
- alert: ModelHasNothingServing
expr: llmgw_model_replicas_ready == 0
for: 10m
labels: { severity: critical }
annotations:
summary: "{{ $labels.model }} has no replica ready"
description: >-
The model should be running and nothing is serving it. Ten minutes,
because a large model on CPU legitimately takes minutes to load. The
series is published at 0 for every model that should be running —
before v0.10.4 it was published only while a replica was ready, so
this could not fire at all. A model deliberately stopped, or an
on-demand one nobody is asking for, publishes nothing.
- alert: ModelParked
expr: llmgw_model_parked == 1
labels: { severity: critical }
annotations:
summary: "{{ $labels.model }} failed every time it started and is no longer retried"
description: >-
Five replicas in a row failed at start. Before this existed a model
whose weights its engine could not load was started 155 times over
eight hours. `llmgw ctl status` has the last error; once it is dealt
with, `llmgw ctl retry <model>`. Changing the model's definition
also retries it.
- alert: ModelFailingToStart
expr: llmgw_model_start_failures >= 2
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.model }} has failed to start {{ $value }} times in a row"
description: >-
On its way to being parked. The engine's own words are in
`llmgw ctl logs <node> --source <shard>` for the next attempt, or in
the control plane's log for the last one.
- alert: ModelCannotBePlaced
expr: llmgw_model_unplaceable == 1
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $labels.model }} is wanted and fits on no node"
description: >-
Short of replicas, and the last attempt found nowhere with the
memory, cards or engine it needs. `llmgw ctl explain-placement
<model>` says what each node lacks. For an on-demand model, requests
for it are told this rather than asked to retry.
- alert: ModelPreempted
expr: increase(llmgw_model_preempted_total[15m]) > 0
labels: { severity: info }
annotations:
summary: "{{ $labels.model }} ({{ $labels.class }}) gave up replicas to a higher class"
description: >-
Capacity ran short and a model of a higher class took this one's
room. Expected now and then; if it keeps firing for the same model,
the cluster is too small for what is asked of it at these classes.
`llmgw ctl models` shows who it gave way to.
- alert: CapacityAtLimit
expr: llmgw_capacity_holds >= on(scope, resource) llmgw_capacity_limit
for: 15m
labels: { severity: info }
annotations:
summary: "{{ $labels.scope }} holds all the {{ $labels.resource }} it may"
description: >-
Its models get no further replicas, under load or not, until
something of theirs stops or the limit is raised: `llmgw ctl quota
list`, `llmgw ctl quota set`.
- alert: EngineInstallFailed
expr: llmgw_node_engine_install{state="failed"} == 1
for: 15m
labels: { severity: warning }
annotations:
summary: "{{ $labels.engine }} {{ $labels.version }} will not install on {{ $labels.node }}"
description: >-
The node takes no model needing it until it does. The agent retries
on its own; `llmgw ctl nodes` and the node's logs on the cluster
page say why it failed.
- alert: ControlPlaneLeadership
expr: sum(llmgw_control_plane_leader) != 1
for: 2m
labels: { severity: critical }
annotations:
summary: "{{ $value }} control planes believe they are placing work"
description: >-
None means nothing is placed or replaced; two means both are, each
into GPUs the other believes free. The lease is in the store: check
it is reachable from every control plane.
- alert: CertificateExpiresSoon
expr: (llmgw_certificate_expiry_timestamp_seconds - time()) < 30 * 86400
labels: { severity: warning }
annotations:
summary: "{{ $labels.cert }} ({{ $labels.subject }}) expires in {{ $value | humanizeDuration }}"
description: >-
Certificates last a year and nothing renews them. When the agent
server's runs out every agent refuses to connect at once. Reissue
with `llmgw ctl cert` and restart the control plane.
- alert: CertificateExpiresVerySoon
expr: (llmgw_certificate_expiry_timestamp_seconds - time()) < 7 * 86400
labels: { severity: critical }
annotations:
summary: "{{ $labels.cert }} ({{ $labels.subject }}) expires in {{ $value | humanizeDuration }}"
description: See CertificateExpiresSoon; a week is not long.
- alert: BackupTooOld
expr: time() - llmgw_backup_last_success_timestamp_seconds > 26 * 3600
labels: { severity: warning }
annotations:
summary: "The newest llmgw backup is {{ $value | humanizeDuration }} old"
description: >-
llmgw-backup.timer runs nightly. Also fires when there has never
been a backup (the series is 0 then). `journalctl -u
llmgw-backup.service` on the control plane says why the last one
failed; docs/install.md, "Backing up", says how to set it up.
- alert: PoolSaturated
expr: llmgw_pool_saturation > 1
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.model }} is over its healthy limit"
description: >-
Saturation is the worse of queue depth and KV occupancy; 1 is the
limit measured as healthy, not the point of failure. Sustained above
it means requests are queueing — add a replica or lower the load.
- alert: EndpointBreakerOpen
expr: llmgw_endpoint_breaker_open == 1
for: 2m
labels: { severity: warning }
annotations:
summary: "{{ $labels.replica }} has been taken out of rotation"
description: >-
The router stopped sending to this replica after repeated failures.
Traffic is going elsewhere; if it is the only replica, the model is
down.
- alert: EndpointMetricsStale
expr: llmgw_endpoint_metrics_stale == 1
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.replica }} is being routed to blind"
description: >-
Its queue depth and KV usage are old, so the scheduler is choosing
with stale numbers. The replica may be serving perfectly well; the
decisions about it are the problem.
- alert: NodeDiskFilling
expr: llmgw_node_disk_free_bytes / llmgw_node_disk_total_bytes < 0.10
for: 15m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} has {{ $value | humanizePercentage }} of its model disk free"
description: >-
Weights are tens of gigabytes. A node that cannot hold the next one
is skipped by placement rather than failing, so this is quiet until
somebody wonders why a model will not land there.
- name: monitoring-host
rules:
- alert: MonitoringDiskFilling
expr: >-
node_filesystem_avail_bytes{job="monitoring-host",fstype!~"tmpfs|overlay|squashfs"}
/ node_filesystem_size_bytes{job="monitoring-host",fstype!~"tmpfs|overlay|squashfs"} < 0.15
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $labels.mountpoint }} on the monitoring host is {{ $value | humanizePercentage }} free"
description: >-
This filesystem holds the model mirror and the metrics database at
once. Filling it stops new weights arriving and stops Prometheus
writing — so monitoring goes quiet at the moment it is most needed,
which is why this fires at fifteen percent rather than five.
- alert: MonitoringDiskAlmostFull
expr: >-
node_filesystem_avail_bytes{job="monitoring-host",fstype!~"tmpfs|overlay|squashfs"}
/ node_filesystem_size_bytes{job="monitoring-host",fstype!~"tmpfs|overlay|squashfs"} < 0.05
for: 5m
labels: { severity: critical }
annotations:
summary: "{{ $labels.mountpoint }} on the monitoring host has {{ $value | humanizePercentage }} left"
- name: llmgw-traffic
rules:
# These need traffic through the gateway to have any series at all. A
# counter with no observations publishes nothing, so a quiet cluster
# simply has no data here rather than a rule that is always false.
- alert: HighErrorRate
expr: >-
sum by (model) (rate(llmgw_requests_total{status=~"5.."}[5m]))
/ sum by (model) (rate(llmgw_requests_total[5m])) > 0.05
for: 5m
labels: { severity: critical }
annotations:
summary: "{{ $labels.model }} is failing {{ $value | humanizePercentage }} of requests"
- alert: SlowFirstToken
expr: >-
histogram_quantile(0.99,
sum by (model, le) (rate(llmgw_ttft_seconds_bucket[10m]))) > 30
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $labels.model }} takes {{ $value | humanizeDuration }} to first token at p99"
description: >-
On CPU this is expected under load and is why the threshold is
thirty seconds rather than one. Compare with what ctl bench measured
before treating it as a fault.
- alert: RequestsBeingRejected
expr: sum by (band) (rate(llmgw_admission_rejected_total[5m])) > 0
for: 5m
labels: { severity: warning }
annotations:
summary: "Admission is turning away {{ $labels.band }} traffic"
description: >-
Working as designed when the cluster is full: the lowest band is
held first. Worth knowing about because the callers being refused
do not know why.
+42
View File
@@ -0,0 +1,42 @@
# Prometheus for the llmgw cluster.
#
# One scrape target for the whole fleet. Agents publish nothing of their own —
# the control plane gathers what they report and republishes it, so node health,
# disk and endpoint state all arrive here from 10.0.0.140.
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
cluster: mynodes
alerting:
alertmanagers:
- static_configs:
- targets: ['localhost:9093']
rule_files:
- /etc/prometheus/rules/llmgw.yml
scrape_configs:
# The control plane's dedicated metrics port. Not its admin listener: that one
# is loopback because it also serves pprof, and pprof can dump the heap where
# API keys sit in plaintext.
- job_name: llmgw
static_configs:
- targets: ['10.0.0.140:9091']
labels:
instance: control-plane
- job_name: prometheus
static_configs:
- targets: ['localhost:9090']
# The machine this runs on. It came with the Prometheus package and was not
# being scraped, which left the disk holding both the model mirror and the
# metrics themselves watched by nothing — so a mirror that filled it would
# take monitoring down and the alert that would have warned did not exist.
- job_name: monitoring-host
static_configs:
- targets: ['localhost:9100']
labels:
instance: monitoring
+41
View File
@@ -0,0 +1,41 @@
# llmgw behind Caddy: the client-facing port (8080) on a public name, with a
# certificate Caddy obtains and renews by itself.
#
# Replace llm.example.com, point its DNS at this machine, open 80 and 443, and
# run: caddy run --config /etc/caddy/Caddyfile
#
# Only 8080 goes through here. 7443 (agents) is mutual TLS with the cluster's
# own authority and must reach llmgw directly; 9090 and 9091 stay private.
llm.example.com {
# The router reads at most 32 MiB of body (router.max_body_bytes). Refusing
# more here gives the client a 413 before the upload, not after it.
request_body {
max_size 32MB
}
reverse_proxy 127.0.0.1:8080 {
# Tokens are written the moment they are generated. Without this a
# streamed reply can arrive in bursts, and a client measuring time to
# first token measures the proxy instead.
flush_interval -1
# Caddy sets X-Forwarded-Proto and X-Forwarded-Host itself and, with
# no trusted_proxies, discards whatever the client sent. llmgw relies
# on that: the proto decides whether session cookies are Secure, and
# the host is the base URL the landing page tells people to use.
# Behind a load balancer of your own, name it in trusted_proxies.
transport http {
# A long generation can be silent for a while before the first
# token. llmgw has its own limits (timeouts.*); this must not be
# the shorter one.
response_header_timeout 15m
}
}
# No `encode` on purpose: compressing a stream buffers it.
# Access log to stderr, which under systemd is the journal.
log
}
+39
View File
@@ -0,0 +1,39 @@
# A reverse proxy in front of llmgw
The client-facing port (8080) is plain HTTP. On anything but a trusted network,
put it behind a proxy that holds a publicly trusted certificate.
| File | For |
| --- | --- |
| [Caddyfile](Caddyfile) | Caddy — obtains and renews its own certificate; the least work |
| [nginx.conf](nginx.conf) | nginx, with a certificate from certbot |
Both are also attached to every release.
The `/about` page shows both, from copies in `internal/web/proxy`; a test fails
when those differ from these, so change them here and copy them over.
Four things both files get right, and any other proxy has to as well:
- **Streaming is not buffered.** Replies are server-sent events; a buffering
proxy delivers them in one burst at the end.
- **The read timeout is longer than a model's silence.** nginx's default of 60
seconds turns a slow first token into a 504.
- **`X-Forwarded-Proto` and `X-Forwarded-Host` are set by the proxy**, replacing
whatever the client sent. llmgw uses the first to mark session cookies
`Secure` and the second for the base URL on its landing page.
- **The body limit matches llmgw's** (32 MiB, `router.max_body_bytes`), so an
oversized request is refused before it is uploaded.
Only port 8080 is proxied. Agents reach 7443 directly — it is mutual TLS with
the cluster's own authority, and a proxy terminating it would take away the
node identity it carries. 9090 (admin, pprof) stays on loopback; 9091
(metrics) goes to your monitoring host only.
With a proxy on the same machine, bind llmgw to loopback so nothing reaches it
around the proxy:
```yaml
# /etc/llmgw/server.yaml
listen: "127.0.0.1:8080"
```
+63
View File
@@ -0,0 +1,63 @@
# llmgw behind nginx: the client-facing port (8080) on a public name.
#
# Put this in /etc/nginx/sites-available/llmgw (or conf.d/llmgw.conf), replace
# llm.example.com, and get a certificate — certbot --nginx -d llm.example.com
# fills in the ssl_ lines below. Then: nginx -t && systemctl reload nginx
#
# Only 8080 goes through here. 7443 (agents) is mutual TLS with the cluster's
# own authority and must reach llmgw directly; 9090 and 9091 stay private.
upstream llmgw {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name llm.example.com;
return 301 https://$host$request_uri;
}
server {
# `listen ... http2` rather than `http2 on;`: the latter needs nginx
# 1.25.1, and Ubuntu 22.04 and Debian 12 ship older ones. Newer nginx
# accepts this form with a deprecation warning.
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name llm.example.com;
ssl_certificate /etc/letsencrypt/live/llm.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/llm.example.com/privkey.pem;
# The router reads at most 32 MiB of body (router.max_body_bytes).
client_max_body_size 32m;
location / {
proxy_pass http://llmgw;
proxy_http_version 1.1;
proxy_set_header Connection "";
# Set, never appended: llmgw decides from X-Forwarded-Proto whether
# session cookies are Secure, and builds the base URL the landing page
# shows from X-Forwarded-Host. A value passed through from the client
# would be the client's choice.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Streamed replies: tokens go out as they are generated. Buffering
# turns a stream into one late burst, and gzip buffers too.
proxy_buffering off;
proxy_cache off;
gzip off;
# nginx's default is 60 seconds between two reads from upstream. A
# large model can think longer than that before its first token, and
# the client then sees a 504 for a request that was about to succeed.
# llmgw has its own limits (timeouts.*); this must not be the shorter.
proxy_read_timeout 15m;
proxy_send_timeout 15m;
}
}
+34
View File
@@ -0,0 +1,34 @@
# systemd units
Two roles, two units. Copy the one you need to `/etc/systemd/system/`, put the
matching configuration in `/etc/llmgw/`, and `systemctl enable --now` it.
## The control plane — `llmgw-server.service`
Runs unprivileged. It needs to read its own TLS key, so that file must belong to
the `llmgw` user and nothing else:
```
useradd --system --home-dir /var/lib/llmgw --shell /usr/sbin/nologin llmgw
chown llmgw:llmgw /etc/llmgw/server-key.pem
chmod 0400 /etc/llmgw/server-key.pem
```
`StateDirectory=llmgw` creates `/var/lib/llmgw` with the right owner, which is
where the store belongs. `ProtectSystem=strict` makes the rest of the filesystem
read-only, so anything else it writes to has to be named explicitly.
## The agent — `llmgw-agent.service`
Runs as root, because it launches engines and needs device access.
`KillMode=process` is the line that matters and the reason this file exists
rather than being generated. Without it, systemd kills the whole control group
on stop — including the engines, which hold a loaded model and are serving
traffic. Engines are meant to outlive an agent restart: the router talks to them
directly, and the agent adopts them again when it comes back. Taking them down
with the agent turns a thirty-second upgrade into a full model reload on every
node.
systemd will log `Found left-over process ... in control group while starting
unit. Ignoring.` on the next start. That is the mechanism working, not a fault.
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=llmgw node agent
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
# Root, because the agent launches engines and needs device access. Engines
# outlive a restart of this unit and are adopted again on the next start, so
# KillMode must not take the whole process group with it.
KillMode=process
Restart=on-failure
RestartSec=2s
# RDMA pins the buffers it registers, and NCCL registers a great deal: with
# systemd's default 8 MB lock limit, an engine that drops the capability that
# lets root ignore it fails its first collective over InfiniBand.
LimitMEMLOCK=infinity
StateDirectory=llmgw-agent
[Install]
WantedBy=multi-user.target
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=llmgw backup: the store and /etc/llmgw, to /var/backups/llmgw-server
After=llmgw-server.service postgresql.service
[Service]
Type=oneshot
# Nothing it writes is for anyone else: the archive holds the CA's key and a
# copy of the store.
UMask=0077
# Root, because the archive includes /etc/llmgw/secrets and the certificate
# authority's key, which the llmgw user deliberately cannot read all of.
ExecStart=/usr/local/bin/llmgw server backup --config /etc/llmgw/server.yaml --keep 14
Nice=10
IOSchedulingClass=idle
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Back up llmgw every night
[Timer]
OnCalendar=*-*-* 03:15:00
# A machine that was off at 03:15 backs up when it next starts, rather than
# waiting another day with a gap nobody notices until the alert.
Persistent=true
RandomizedDelaySec=10m
[Install]
WantedBy=timers.target
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=llmgw model mirror
After=network-online.target
Wants=network-online.target
[Service]
# --base is what a catalog entry will say, so it has to be the address nodes
# reach this machine on — not localhost, and not the name only this host knows
# itself by. Getting it wrong makes `mirror unused` report files as unreferenced
# when they are in use, and the next step after that list is deletion.
ExecStart=/usr/local/bin/llmgw mirror serve \
--dir /srv/models \
--listen :8081 \
--api-listen :8082 \
--base http://models.internal:8081
User=llmgw
Group=llmgw
Restart=on-failure
RestartSec=2s
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
# The one writable path. The mirror serves reads to the whole fleet and writes
# only what its own API was asked to fetch, so nothing else needs to be.
ReadWritePaths=/srv/models
[Install]
WantedBy=multi-user.target
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=llmgw control plane
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/llmgw server --config /etc/llmgw/server.yaml
User=llmgw
Group=llmgw
Restart=on-failure
RestartSec=2s
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
StateDirectory=llmgw
[Install]
WantedBy=multi-user.target
+149
View File
@@ -0,0 +1,149 @@
# Choosing what to run
llmgw serves whatever you give it and has no opinion about which models those
are. This page has an opinion, because "we have GPUs, now what" is the question
that stalls most of these projects — and the usual first answer, one large
model, is the wrong shape.
Model releases move faster than documentation. Treat the families named here as
a starting point and check what is current before committing; the reasoning is
what should outlive the names.
## Three jobs, not one model
The requests a company actually makes fall into three groups with different
needs, and no single model serves all three well.
**Interactive** — chat, autocomplete, short questions. Most of your traffic.
Here latency beats quality: a person waits two seconds and does not wait
twenty. A 7–14B class model, always resident.
**Heavy** — long document analysis, code generation, anything that needs
reasoning. Quality beats latency, and a queue is acceptable. A 30–70B class
model, loaded on demand:
```yaml
scaling: { min: 0, max: 1, idle_timeout: 30m }
```
llmgw starts it when somebody asks and stops it when nobody has, so it costs
nothing while it is not in use. The first request waits for the load, and the
client is told that rather than refused.
**Embeddings** — and this is the one that gets forgotten. Most "let us chat
with our documents" projects are retrieval projects, and retrieval runs on an
embedding model. It is small, it is cheap, and it carries the highest request
volume of the three: every document chunk once, every query forever. llmgw
routes `/v1/embeddings` like any other endpoint.
A deployment with the first two and not the third can answer questions from
what the model already knew. It cannot answer questions about your company.
## Swedish and English
The first thing to know is that this combination is harder than either alone.
Open models are strongest in English by a wide margin. Swedish is served —
better than most languages its size, because the Nordic languages appear in
multilingual training sets — but the gap is real, and it widens as models get
smaller. **A 7B model that feels fine in English can feel noticeably worse in
Swedish**, which means the interactive tier may need to be larger than an
English-only deployment would choose. Test before deciding; do not assume the
English benchmark transfers.
Two things matter more than the model's headline multilingual score:
**Cross-lingual retrieval.** A Swedish company writes internal documents in
Swedish and reads technical documentation in English. Somebody will ask a
question in Swedish whose answer is in an English document. An embedding model
that handles both languages separately may still fail to connect them, and it
fails silently — the model gives a confident answer from the wrong source, and
nobody notices because the answer reads well. Test this explicitly: ask in one
language, check that documents in the other are retrieved.
**Swedish compounds.** *Kundtjänstmedarbetare* is one token-hostile word where
English would use three. This hurts keyword search more than dense retrieval,
so if you run hybrid search, expect the lexical half to underperform on Swedish
and weight accordingly.
## How to actually decide
Benchmarks will not settle this. Take twenty real documents and twenty real
questions from the company — in both languages, including at least a few where
the question and the answer are in different languages — and compare candidates
on those. An afternoon of this is worth more than a week of reading leaderboards,
and it is the only way to find the failure modes that matter to you.
Do the same for the embedding model, separately. A good generation model on top
of bad retrieval produces fluent, confident, wrong answers, which is the worst
failure this stack has: it does not look like a failure.
## How many replicas
Not "how many employees". **Concurrency**, which is far lower than headcount
because people spend most of their time reading rather than waiting on a model.
Two hundred people generate perhaps five to fifteen concurrent requests at
peak, for interactive use.
vLLM batches continuously, so one replica on a capable card serves many
concurrent requests. Which means the replica count is usually decided by
**redundancy rather than throughput**:
| Tier | Replicas | Why |
|---|---|---|
| Embeddings | 2 | Not for capacity. If retrieval stops, every document feature stops. |
| Interactive | 2 | So a node can be rebooted during working hours. |
| Heavy | 0–1, on demand | Loaded when asked for, stopped when idle. |
Then measure rather than guess:
```
llmgw ctl bench <model>
```
It climbs concurrency until the SLO breaks and reports the last level that met
it. Peak throughput arrives long after latency has become unusable, so
throughput alone overstates what the hardware can serve.
## Where the weights live
The single highest-value piece of infrastructure here is **a local mirror**: one
internal HTTP server holding the model files. [deploy/mirror/](../deploy/mirror/)
has the nginx config and the directory layout. Hugging Face is slow, rate
limited, and blocked outright on plenty of corporate networks. Eight nodes each
pulling forty gigabytes from the internet is a day; from a mirror on the same
switch it is minutes. Point a model's `source` at it and llmgw fetches from
there.
**Pin the version.** Put it in the path — `/models/qwen2.5-14b/2026-03/q4_k_m.gguf`
— so a repository that changes upstream cannot change what you are running.
Without this, a prompt that worked last week gives a different answer this week
and nobody can explain why.
**Quantisation.** For llama.cpp, `q4_k_m` is the practical default: the quality
difference from `q8_0` is smaller than most people expect and the memory
difference is a factor of two. For vLLM on modern cards, FP8 or AWQ. Run
`llmgw ctl plan <model>` to see whether a given precision fits before
downloading anything.
**Disk.** Budget three to five times what you first estimate. Several
quantisations, several versions, and partial downloads all accumulate.
`llmgw ctl nodes` shows free space per node once `model_dirs` is configured,
and `llmgw ctl weights` reclaims files llmgw downloaded and no model needs —
never files you placed yourself.
Under ten nodes, let each keep its own copy; it is simpler and the disk is
cheap. Above that, consider a shared filesystem — llmgw notices one and will
not count the same free terabyte once per node.
## Before anyone starts using it
Set budgets and limits on day one, not later:
```
llmgw keys limits --scope project --id <project> --rpm 60 --budget 50
```
The reason is not technical. A resource that appears free and unlimited becomes,
three months later, a cost nobody can account for — and introducing limits at
that point is a much harder conversation than having had them from the start.
+133
View File
@@ -0,0 +1,133 @@
# What llmgw stores, and what leaves the machine
Written for the review that happens before a gateway is allowed near anyone's
documents. It describes what the software does. Mapping that onto an
obligation — GDPR, a sector rule, an internal policy — is work for whoever
holds the obligation; this page exists so that work starts from facts rather
than from a vendor's summary.
Everything below is checkable in the source, and the places to look are named.
## The short version
By default llmgw stores **no prompt or completion text anywhere**. It stores
who made a request, when, how many tokens, and what it cost.
Two features change that, and both are off until somebody turns them on for a
named project, and neither can be turned on without also saying for how long.
Nothing on the serving path leaves the cluster.
## What is stored, exactly
**Usage records**, one per request: the key, the project, the model asked for,
the model that answered, prompt and completion token counts, cost, HTTP status
and latency. No message content. The row is in `internal/store/store.go` as
`Usage` and there is no field for text in it.
**API keys**, as SHA-256 hashes. The key itself is shown once, at creation, and
never stored — so a leaked database hands over no working credential, and
neither llmgw nor an administrator can recover a key somebody lost. A prefix is
kept so a key can be identified in a listing without revealing it.
**People and tenancy**: accounts, the organisation/team/project tree, and the
limits attached to it. Passwords are hashed.
**Sessions** for the browser pages, as tokens.
**Conversations and agent notes** — only for a project that asked. See below.
## What is not stored
Prompts. Completions. Anything the model was shown or produced, unless the
project turned on conversations or notes.
The chat page in llmgw's own web interface keeps the conversation in a
JavaScript variable in the browser. Refresh it and the conversation is gone,
because it was never anywhere else.
## Conversations and notes, when they are turned on
Two features keep text, and both work the same way:
- **Conversations** (`x-llmgw-thread`) let a client send only its new turn while
llmgw replays the history — including to a different model than the one the
conversation started on.
- **Agent notes** (`/v1/memory`, and MCP tools) let an agent keep something for
itself across conversations.
Both are **off for every project** until an operator runs `llmgw keys threads`
or `llmgw keys memory` for that project by name. A request carrying a thread id
against a project that has not is refused with an explanation, not quietly
served without its history.
Both **require a retention window**. There is no way to turn either on without
saying how many hours, because a store with no expiry is a transcript of
everything a team ever typed, kept forever because nobody chose a number. The
window is measured from the last turn, so an active conversation does not
expire underneath it, and a background sweep removes what has passed it.
Both are **scoped to a project**, and the scope is part of every lookup rather
than a check afterwards. Reading a conversation that belongs to another project
returns the same answer as reading one that does not exist, so a leaked id
cannot be used to discover what exists elsewhere.
`DELETE /v1/threads/{id}` removes a conversation and its messages immediately.
It is a client endpoint, not an administrative one: the person who said the
thing can unsay it without asking somebody else to read it first.
Turning retention off stops new storage, and existing conversations go at the
next hourly sweep.
## What leaves the machine
**Nothing on the serving path.** A request arrives, is routed to an engine on
your own hardware, and the answer comes back. The router talks to engines, the
engines talk to nothing, the agents talk to the control plane. All of it is
inside the cluster.
Two things can reach outside, both started by an operator rather than by a
request:
**Fetching weights.** When a model's `source` is a URL, the agent downloads it.
Point that at an internal mirror and nothing leaves at all — which is also the
faster arrangement, because eight nodes pulling forty gigabytes from the
internet is a day's work and from a local mirror it is minutes. A `source` that
is a path on disk is never fetched.
**`llmgw ctl plan`.** Reads a model's architecture from huggingface.co to work
out whether it fits your hardware. An operator command run at planning time,
not part of serving, and not run by anything automatically.
There is no telemetry, no licence check, and no call home. The binary is
static, built with `CGO_ENABLED=0`, and runs in a network with no route to the
internet as long as the weights are already on disk.
## What the software cannot do for you
**The admin port serves pprof**, and pprof can dump the process heap — where
API keys sit in plaintext while they are being checked. Bind it to loopback and
reach it over SSH. The default does this; a deployment that changes it should
know why.
**The store holds everything.** With conversations turned on it holds text as
well as accounting, so where it lives, who can read it and how it is backed up
are decisions with the same weight as the ones above. The PostgreSQL DSN
belongs in a file readable only by the user llmgw runs as, which is why the
configuration takes `@/path/to/file` rather than the address itself.
**Model weights are yours.** llmgw reclaims only files it downloaded, and it
identifies those by a name it generated. Anything you put on a node it will not
touch, whatever the catalog says.
## Where to look
| Claim | Where |
|---|---|
| Usage rows carry no text | `internal/store/store.go`, type `Usage` |
| Keys are stored hashed | `internal/store/keys.go` |
| Conversations are off by default | `internal/store/threads.go`, `ThreadRetention` |
| Retention is required | `llmgw keys threads --help` |
| Scope is part of the lookup | `internal/store/threads.go`, `Thread` |
| Deletion is a client endpoint | `internal/router/threads.go`, `handleDeleteThread` |
| Weight fetching is the only serving-path egress | `internal/agent/weights/fetch.go` |
+210
View File
@@ -0,0 +1,210 @@
# InfiniBand fabrics and NVLink
For a site whose GPU nodes are joined by InfiniBand or RoCE, by NVLink, or both
— several independent fabrics, NVL72 racks, eight-GPU HGX boards. It says what
llmgw does with them, what it needs from you before it can, how to check a node
in one command, and where to look when a replica spanning nodes will not run.
Read [install.md](install.md) first; this is the part of it that only matters
once there is a fabric.
> **Where this stands.** Every piece here was written against the real formats
> of `sminfo`, `saquery`, `ibnetdiscover`, `nvidia-smi topo -m` and
> `nvidia-smi -q`, and tested against them — but none of it has yet run on a
> site with the hardware. Run `llmgw doctor --json` on two or three nodes first
> and keep the reports: if a parser meets a format it does not know, that report
> is what fixes it, and the fix is a control plane upgrade, not one per node.
## What llmgw does with them
**Two kinds of interconnect.** InfiniBand or RoCE joins nodes (scale-out).
NVLink joins GPUs — inside one machine on an HGX board, or across the nodes of a
rack on an NVL72 or an NVLink Switch system (scale-up). A replica spanning nodes
is placed, in order of preference:
1. inside one **multi-node NVLink domain**, if any has enough usable nodes:
tensor parallelism between its nodes then runs over NVLink;
2. on one **physical InfiniBand or RoCE fabric**, preferring nodes under one
leaf switch and putting nodes with a degraded cable last.
It is never split across two fabrics or two NVLink domains. Within a node, the
GPUs a shard gets are the set with the most NVLink pairs, then the nearest to
the fabric's adapters, then on one NUMA node.
**A fabric has a name and an identity.** The name is what `agent.yaml` says; the
identity is what the adapters report — on InfiniBand the subnet managers every
port on one fabric sees, on RoCE the IP network of its RoCE v2 address. The name
is how you address a fabric; the identity is how llmgw catches the name being
wrong: two independent fabrics given one name are placed as two, and the
cluster page says so.
## Before a node joins
On every GPU node:
| What | Why | Check |
|---|---|---|
| NVIDIA driver | everything | `nvidia-smi` |
| `nvidia-fabricmanager`, on an NVSwitch machine | without it CUDA does not start on those GPUs | `systemctl is-active nvidia-fabricmanager` |
| `rdma-core`, `ib_uverbs` loaded | user-space RDMA at all | `ls /sys/class/infiniband`, `lsmod \| grep ib_uverbs` |
| `nvidia_peermem` loaded | GPUDirect RDMA; without it NCCL copies every transfer through host memory | `lsmod \| grep nvidia_peermem` |
| `infiniband-diags` | tells llmgw which fabric a port is on; the agent installs it if missing | `which sminfo` |
On an NVL72 or NVLink Switch system, also:
| What | Why | Check |
|---|---|---|
| `nvidia-imex` running, every node of the domain in `/etc/nvidia-imex/nodes_config.cfg` | GPUs in different nodes share memory through it | `systemctl is-active nvidia-imex` |
| an IMEX channel (`options nvidia NVreg_CreateImexChannel0=1`) | an engine opens one | `ls /dev/nvidia-caps-imex-channels` |
| every GPU joined to its domain | the fabric manager does this | `nvidia-smi -q \| grep -A6 Fabric` shows `State: Completed` |
On each InfiniBand fabric:
- **A subnet manager, and a standby.** Without one, ports come up to `INIT` and
stay there; without a standby, the fabric keeps its routes if the master
stops but cannot recover from any change after. llmgw watches this but does
not run one.
- **Its own name** in `agent.yaml` on every node attached to it. Optionally, a
distinct `subnet_prefix` in each subnet manager's configuration, which makes
GIDs unique across fabrics; llmgw does not need it.
## Declaring the fabrics
In `/etc/llmgw/agent.yaml`, the adapters on each fabric, by name:
```yaml
fabrics:
- name: ib-compute
hcas: [mlx5_0, mlx5_1, mlx5_2, mlx5_3, mlx5_6, mlx5_7, mlx5_8, mlx5_9]
- name: ib-storage
hcas: [mlx5_4]
```
or `--fabric ib-compute:mlx5_0,mlx5_1,…` when onboarding ([node-onboarding.md](node-onboarding.md)).
An adapter belongs to one fabric; a node may be on several. A replica on
`ib-compute` is given only `ib-compute`'s adapters (`NCCL_IB_HCA`).
The rate is measured, not taken from the file: a fabric's rate on a node is the
slowest of its active adapters, because a collective runs at the speed of its
slowest link.
The agent checks the declaration against the adapters on every heartbeat. An
adapter that is missing, or whose port is down, takes that fabric off the node
within seconds — the node stops being offered multi-node work on it — and
returns it when the port comes back.
## Checking a node: `llmgw doctor`
```
sudo llmgw doctor # uses /etc/llmgw/agent.yaml when present
sudo llmgw doctor --json > $(hostname)-doctor.json
```
It reads the node and says what is wrong and how to fix it, most serious first,
and exits non-zero when anything fails:
```
FAIL nvlink this machine has NVSwitch but nvidia-fabricmanager is inactive: CUDA will not initialise on its GPUs
→ systemctl enable --now nvidia-fabricmanager (its version must match the driver's)
WARN fabric nvidia_peermem is not loaded: GPUDirect RDMA is off, and NCCL copies every transfer through host memory
→ modprobe nvidia_peermem
OK gpu 8 GPUs: 8× NVIDIA H100 80GB HBM3
OK nvlink NVLink groups: [0 1 2 3 4 5 6 7]
OK fabric mlx5_0/1 ACTIVE at 400 Gb/s (InfiniBand), subnet manager 0x248a07030030f0c0 (LID 1, SMINFO_MASTER)
OK fabric subnet managers of 0x248a07030030f0c0's fabric: 0x248a07030030f0c0 (master, priority 15), 0x248a07030030f1d4 (standby, priority 14)
OK agent fabric ib-compute: mlx5_0,mlx5_1,… usable at 400 Gb/s
OK agent NCCL bootstraps over ens3 (10.1.0.21)
```
followed by each GPU's NVLink peers and nearest adapter, each port's state,
rate and errors, and the NCCL environment a shard on each fabric would be given.
`--json` writes every fact it read as well as what it concluded — send that
file when the report is not enough.
## Checking two nodes: `llmgw ctl fabric-test`
Every check on one node can pass while two nodes still cannot reach each other
— a cable into the wrong switch, a partition key, a RoCE network with no route
— and a replica spanning them then hangs on its first all-reduce. Before
placing one, on the control plane:
```
llmgw ctl fabric-test gpu01 gpu02
```
```
gpu01 → gpu02 on ib-compute, 41s
RAIL LINK MEASURED VERDICT DETAIL
mlx5_0 → mlx5_0 400 Gb/s 388.2 Gb/s ok 97% of 400 Gb/s
mlx5_1 → mlx5_1 400 Gb/s 241.0 Gb/s slow 60% of 400 Gb/s: check the cable, and the card's PCIe width and NUMA node
mlx5_2 → mlx5_2 400 Gb/s 0.0 Gb/s fail gpu01: ib_write_bw: Couldn't connect to 10.1.0.22:18517
```
It runs `ib_write_bw` (perftest, installed on a node the first time) from each
of the first node's adapters on the fabric to the same adapter on the second —
mlx5_0 to mlx5_0, the way NCCL pairs them — one rail at a time, through the
agents, so no SSH between nodes is needed. Each rail is graded against its
link's rate: **ok** at 80% or more, **slow** at 50%, **fail** below that or when
nothing got through. `--fabric` picks one when the nodes share several,
`--seconds` the length of each rail's run. The traffic is real: run it when the
fabric is quiet.
It measures RDMA between adapters, not GPUDirect or NCCL itself: a pass says
the fabric carries traffic between these two nodes at its rate, which is what
a first all-reduce needs and what nothing on one node can show.
## Watching them
- **Cluster page, Fabrics.** Each declared fabric and what it physically is;
a table of subnet managers per fabric — master, priority, standbys, the last
change of master — and every disagreement: one name on two fabrics, two names
on one, an adapter up but undeclared, ports up with no subnet manager, a
fabric with no standby, a recent failover.
- **`/admin/fabrics`.** Each fabric's map, from `ibnetdiscover` run by one of
its nodes every five minutes: switches in their tiers, adapters under their
leaves, cables coloured by rate, degraded ones in red, rail alignment, and
the cluster's own adapters live with their traffic. Click anything for its
detail.
- **`/admin/nvlink`.** Each multi-node NVLink domain as a rack of trays: which
GPUs are joined, which node's IMEX is down, which link runs degraded.
- **A node's page.** Its ports with state, rate, errors and what they are
physically on; its GPU-by-GPU NVLink matrix; its load.
- **Metrics**, for alerts:
| Metric | Alert when |
|---|---|
| `llmgw_ib_port_active` | a declared port is 0 |
| `llmgw_ib_port_counter_total{counter="symbol_error"}` and `link_downed` | rising |
| `llmgw_ib_port_rate_gbps` | below its usual rate |
| `llmgw_fabric_subnet_managers{state="standby"}` | absent or 0 |
| `llmgw_fabric_master_sm` | its `sm_guid` changes (a failover) |
| `llmgw_fabric_problems{level="fail"}` | above 0 |
| `llmgw_gpu_nvlink_joined` | 0 |
| `llmgw_imex_up` | 0 |
## What an engine is given
For a shard on an InfiniBand or RoCE fabric:
| Variable | Value |
|---|---|
| `NCCL_IB_HCA` | that fabric's adapters on this node, and no others |
| `NCCL_IB_DISABLE` | `0` (`1` for a single-node shard) |
| `NCCL_SOCKET_IFNAME`, `GLOO_SOCKET_IFNAME` | the node's management interface: `mgmt_interface`, or the one holding its management address |
| `NCCL_IB_GID_INDEX` | on RoCE only: the RoCE v2 entry with an IPv4 address |
| `NCCL_MNNVL_ENABLE`, `NCCL_CUMEM_ENABLE` | `1`, for a replica inside a multi-node NVLink domain |
A profile's `env` overrides any of them. vLLM launches a multi-node replica
with its own multiprocessing (`engines.vllm.launch_mode: mp`, the default):
every node runs `vllm serve` with its rank and the leader's address, the
workers headless. `launch_mode: ray` is there for a vLLM too old for that.
## When a replica spanning nodes will not run
| It | Look at |
|---|---|
| is not placed | `llmgw ctl explain-placement <model>`: a fabric split by identity, a node whose IMEX is down, a node refused because its adapters are cabled to two fabrics |
| starts and hangs on its first collective | `llmgw ctl fabric-test` between two of its nodes; the cluster page's fabric problems; `doctor` on each node — a port down, the wrong interface, RoCE without a v2 GID |
| runs, slowly | `nvidia_peermem`; the map for a degraded cable; the node page's NVLink matrix for a group that crossed PCIe |
| worked yesterday | the subnet manager table for a failover; `link_downed` on the node's ports |
+303
View File
@@ -0,0 +1,303 @@
# Facility-aware placement: rack power, temperature and cooling
**Status: step 0, reading the nodes' own sensors, is built; steps 1–5 are
deferred** until the rack, PDU and power information of a real site is at
hand — without it they would be designed against guesses.
This is the design for step 5 of resource allocation, after reserved CPU cores
and memory (1), capacity classes and preemption (2), tenant capacity quotas (3)
and sizing by measured throughput (4), all of which shipped in v0.10.5. Of
what follows, only the node sensors (rollout step 0) exist in the code: they
are read, published and shown, and change no decision.
## Why
In a large installation the scarce thing is often not a GPU but the room it
sits in. Three failures are invisible to placement today:
- **A rack goes over its power budget.** Every node in it reports healthy;
placement sees free cards and fills them; the breaker, or the facility's
power cap, decides the rest. A rack with A and B feeds is worse: it runs
fine until one feed fails and the other cannot carry the load.
- **Hot hardware is slow hardware.** GPUs throttle long before they fail. A
replica placed in a rack whose inlet air is too warm serves fewer tokens per
second than its measured capacity says, and nothing connects the two.
- **Correlated failure.** Two replicas of a model on two nodes in the same rack
or on the same PDU are one failure away from zero. Placement spreads replicas
across nodes, not across racks.
The goal is for placement to treat facility limits the way it already treats
memory and cores: reserve against a budget, refuse what does not fit, say why,
and never let missing data stop the cluster.
## What it builds on
| Already in place | Used for |
|---|---|
| Node labels, reported by agents, matched by `node_selector` | Where a node is: its rack, row, PDU |
| `inventory.Allocation` carries GPUs, memory and cores per shard | Adding watts, summed per rack and PDU |
| Placement rejections (`explain-placement`, headroom's "why not") | Naming the rack that is full or hot |
| Headroom: the scheduler dry-run on a copy of the inventory | Room that respects power budgets, for free |
| Preemption with classes, minimums and a dry run (step 2) | Shedding load from a hot rack in the right order |
| Measured throughput per profile (step 4) | Tokens per watt as a placement preference |
| Node sensors, read by every agent (step 0, built) | Heat and power with no facility integration: see below |
| `internal/promq`, a Prometheus client | Reading facility metrics that DCIM exporters publish |
## Concepts
**Location.** Each node has a place in a tree: site, room, row, rack. Power has
its own tree beside it: a rack draws from one or more PDUs, and each PDU from a
feed. Both are read from node labels with well-known keys:
```yaml
# agent.yaml
labels:
llmgw.io/site: sto1
llmgw.io/room: hall-b
llmgw.io/row: "12"
llmgw.io/rack: r12-07
llmgw.io/pdu: r12-07-a,r12-07-b # both feeds, for a dual-fed rack
```
A node without these labels is its own rack of one: placement behaves as it
does today.
**Domain.** A rack, a row, a PDU, a room: anything with a budget or a
condition. Domains are declared in a facility file (below) or discovered from
node labels; a label naming a domain the file does not declare creates it with
no budget and no condition.
**Budget.** A domain may have a power cap in watts. Placement reserves each
replica's estimated draw against every domain its node belongs to and refuses a
placement that would take any of them past its cap less a safety margin. For a
dual-fed rack the cap that matters is what one feed can carry alone, since that
is what the rack must survive.
**Condition.** A domain has a thermal and cooling condition: `ok`, `warm`, `hot`
or `unknown`, derived from readings against thresholds with hysteresis.
Conditions change what placement prefers and allows; they are not amounts to
fill.
**Reading.** A measured value with a time: inlet temperature, power draw, a
cooling unit's alarm state. Readings older than their staleness limit are
treated as absent.
## Estimating a replica's draw
What placement reserves has to be known before the replica runs, so it is an
estimate first and a measurement later:
1. **Declared.** A profile may say `power_watts`. The operator's figure wins.
2. **Derived.** Otherwise GPU profiles take each card's power limit from NVML
(the agent already reads the cards; the power limit is one more field) plus
a per-node share for CPU, memory and fans; CPU profiles take their reserved
cores times a per-core figure for the node's CPU model.
3. **Measured.** Once replicas run, the agent reports their cards' actual draw
(NVML) and, where the BMC allows, the node's (Redfish or IPMI DCMI). A
domain's headroom is computed from the larger of what is reserved and what
is measured, so an estimate that was too low is corrected by the room it
stops offering, not by an outage.
## Decisions
### Power
- **Hard limit.** A placement that would take any domain past `cap × (1 −
margin)` is rejected, with the domain and the figures: *rack r12-07: 14.2 of
16.0 kW reserved, replica needs ~2.8, margin 10%*.
- **Feeds.** For a domain with two feeds the cap is the smaller feed's rating
times its derating (typically 80%), not the sum.
- **Already over.** A domain found over its cap, because the estimate was low
or the cap was lowered, gets no new placements, and an alert. It is not
emptied automatically; shedding power is the facility's decision, and
stopping inference to do it is ours only if asked (see shedding below).
### Temperature and cooling
Thresholds are per domain with defaults, and each condition needs to hold for a
while before it changes, in both directions, so a single reading neither
starts nor ends anything:
| Condition | Enters when | Placement |
|---|---|---|
| `ok` | below the warm threshold for 5 min | normal |
| `warm` | inlet above the warm threshold for 2 min, GPU temperatures near throttling, or any GPU thermally throttled | allowed, scored down: other domains first |
| `hot` | inlet above the hot threshold for 2 min, a cooling unit in alarm, or a sustained share of the domain's GPUs thermally throttled | no new placements, as a cordon would, but automatic and reversible |
| `unknown` | no fresh reading | as `ok`, and reported as unknown |
**Shedding** (optional, off by default): a domain `hot` for longer than its
shed delay has load moved off it using step 2's machinery. The lowest class goes
first, and replicas above their model's minimum go before any at it. A critical
minimum is never touched, and moves are rate limited to one replica per domain
per interval. The replica is placed elsewhere first where there is room, so
shedding moves work rather than dropping it. GPU power capping (`nvidia-smi
-pl`) is a gentler alternative worth measuring against shedding: less
throughput, no restart.
### Spreading
Replicas of one model are spread across racks, and across PDUs, the way they
are spread across nodes today. This is a preference, dropped when it is the only
thing standing in the way, as node spreading already is.
### Scoring
Placement today filters, then takes the first candidate in a fixed order
(fewest free GPUs for GPU profiles, fewest replicas for CPU ones, nodes holding
the weights first). Facility signals are better as a score than as more
filters. Candidates that pass the hard limits are ordered by:
1. condition: `ok` before `warm`;
2. power headroom left in the rack after this replica, more first, so load
levels across racks;
3. the existing order.
Once measurements include power, step 4's profile preference can use tokens per
watt instead of tokens per GPU where both are known.
## Where the data comes from
A `FacilitySource` interface, implemented several ways, chosen in
`server.yaml`:
```go
type FacilitySource interface {
// Domains is what the source knows: budgets, thresholds, members.
Domains(ctx) ([]Domain, error)
// Readings is the latest value of each signal, per domain.
Readings(ctx) ([]Reading, error)
}
```
- **Static file.** Domains, caps and thresholds, written by hand or generated
from a DCIM export. Enough for power budgets with no live data at all.
- **Prometheus.** DCIM, PDU (SNMP exporter) and BMC (Redfish exporter)
readings usually end up in Prometheus. The source runs configured instant
queries and maps their labels onto domains:
```yaml
facility:
source: prometheus
prometheus: http://monitoring:9090
signals:
inlet_temp_c:
query: max by (rack) (dcim_rack_inlet_temperature_celsius)
domain_label: rack
stale_after: 2m
power_watts:
query: sum by (pdu) (pdu_outlet_power_watts)
domain_label: pdu
cooling_alarm:
query: max by (row) (crac_alarm)
domain_label: row
```
- **Agents (built, step 0).** What each node reads about itself, every ten
seconds at most, sent with its heartbeat:
- **GPUs, from nvidia-smi:** temperature, memory (HBM) temperature where the
driver reports it, power draw, the enforced power limit, and what is
holding the clocks down now — `sw_power_cap`, `hw_slowdown`, `sw_thermal`,
`hw_thermal`, `power_brake`.
- **CPUs, from hwmon** (what lm_sensors reads, without it installed): the
package temperature, or the hottest core; and the board's inlet or ambient
sensor where it has one (`SYSTIN`, `Inlet`, `Ambient`).
- **CPU power, from RAPL:** the packages' energy counters over time,
subdomains not counted twice, counter wrap handled.
A machine without a sensor reports nothing for it, never a zero; a VM has
none of them. Published as `llmgw_gpu_temperature_celsius`,
`llmgw_gpu_memory_temperature_celsius`, `llmgw_gpu_power_watts`,
`llmgw_gpu_power_limit_watts`, `llmgw_gpu_throttled{reason}`,
`llmgw_node_cpu_temperature_celsius`, `llmgw_node_cpu_power_watts` and
`llmgw_node_inlet_temperature_celsius`, and on each node's page.
What they can and cannot say:
- **Throttling is the strongest signal there is.** A temperature is a proxy;
a card holding its clocks down is the fact that invalidates its measured
capacity, and it needs no threshold to be chosen.
- **Measured power** is GPU draw plus CPU packages; memory, disks, fans and
power-supply losses are not in it, so a node's draw is that plus an
estimated remainder. A rack's is its llmgw nodes' plus a declared figure
for everything else in it: switches and storage report nothing here.
- **CPU and GPU temperatures follow the load, not the room.** A hot rack is
inferred from many nodes running hot at comparable load, or from
throttling, not from one node's CPU. Only a board inlet sensor reads the
air, and most boards have none.
- **No budget comes from a sensor.** A reading is a draw; the cap it is
measured against still comes from the facility.
- **Simulated.** Readings from a script, for tests and for rehearsing a
thermal event on a cluster without facility data.
## Safety
- **Missing data never blocks.** A source that is down, a reading that is stale,
a node without location labels: placement behaves as it does today, and says
the data is missing (a metric, a line on the cluster page). Facility
awareness can make placement more careful, never impossible.
- **Hysteresis and hold times** on every condition, so a noisy sensor does not
flap a rack between allowed and forbidden.
- **Observe-only first.** A mode where every decision is computed, logged and
shown ("would have refused: rack r12-07 hot") but nothing is enforced, run
for long enough to trust the thresholds before turning enforcement on.
- **Manual override.** `llmgw ctl facility hold RACK` and `release RACK`, for the
facility team to mark a domain unavailable, or to clear a condition they know
to be a sensor fault.
- **Shedding is opt-in**, per domain or cluster-wide, and rate limited.
## What people see
- `explain-placement` and headroom name the domain: *rack r12-07 is hot (inlet
31°C for 6 min)*, *pdu r12-07-a: 7.1 of 7.4 kW*.
- `llmgw ctl facility` lists domains with budget, reserved, measured and
condition. The cluster page gets a rack summary beside the nodes table.
- Metrics: `llmgw_domain_power_budget_watts`, `_reserved_watts`,
`_measured_watts`, `llmgw_domain_condition{condition}`,
`llmgw_domain_reading_age_seconds`; alerts for a domain near its cap, a
domain hot, a domain over its cap, and facility data gone stale.
## Rollout
Each step ships on its own and is useful without the next:
0. **Node sensors, observe-only (built).** GPU heat, power and throttling,
CPU heat and power, inlet air where the board has a sensor; published and
shown, no decision changed.
1. **Location and spreading.** Well-known labels, domains from labels, replicas
spread across racks and PDUs, locations on the node views. No facility data
needed.
2. **Power budgets from a static file.** Caps and margins, estimated draw per
replica (declared or derived), hard limit in placement and headroom.
3. **Readings, observe-only.** The Prometheus source beside the agents',
conditions with hysteresis (throttling first among them), measured power
folded into headroom; decisions shown, not enforced.
4. **Thermal enforcement.** `warm` scored down, `hot` refused, manual hold and
release.
5. **Shedding and tokens per watt.** Opt-in shedding through step 2's
preemption; measured power in profile preference.
## Testing
- The simulated source drives unit tests of every condition transition, the
hysteresis, staleness, and the placement outcomes, with mutation checks as
for steps 1–4.
- Property tests: whatever the readings, placement never exceeds a declared
cap with fresh data, and never refuses everything because data is missing.
- On the PoC cluster, with no facility data: step 1 with labelled VMs, step 2
with a static file of made-up caps, steps 3–4 with the simulated source
replaying a thermal event.
- Before enforcement anywhere real: a period in observe-only mode against the
site's actual data.
## Open questions
- **What the sites have.** Which DCIM, which PDUs, which BMCs, and whether any
of it already reaches a Prometheus. The source design assumes Prometheus is
the common ground; a site without it needs another source.
- **Who owns the numbers.** Caps, deratings and thresholds belong to the
facility team. Whether they edit llmgw's file, or llmgw reads theirs.
- **Phase balance.** Three-phase PDUs have per-phase limits; whether placement
should see phases or only the PDU total.
- **Cooling granularity.** Cooling is often per row or per aisle, not per rack;
the domain tree allows either, but the defaults should match what sites
actually measure.
- **Power capping instead of shedding.** Whether to lower GPU power limits in a
warm rack rather than move replicas, and how that interacts with measured
capacity from step 4, which would no longer hold at the lower limit.
+1171
View File
File diff suppressed because it is too large. Load diff
+209
View File
@@ -0,0 +1,209 @@
# Adding a node
Written for whoever runs the cluster.
## The short version
On the control plane:
```
sudo llmgw node add ubuntu@10.0.0.145
```
That is all a machine that already exists needs — bare metal, or a VM you
built. [install.md](install.md#5-adding-the-node) says what it does. On
Proxmox, the machine itself can be built first by the script below, on the
hypervisor.
## The two halves
Two scripts, because the two halves of onboarding are done by different people
with different access: one needs the hypervisor, the other needs the
certificate authority.
## 1. The machine
On the Proxmox host:
```
llmgw-provision-node --vmid 144 --name llmgw-node04 --ip 10.0.0.144/24
```
Name it after the node id. **The VM name, the hostname, the node id and the
certificate's common name are all the same string.** They do not have to be, and
every time they are not, somebody spends an afternoon working out which of the
four a message is talking about.
It imports a cloud image and lets cloud-init do the rest, which is why a node
takes a minute rather than an afternoon. It refuses to touch an existing VMID:
adopting a machine is not a decision a provisioning script should make.
Releases carry static `linux/amd64` and `linux/arm64` binaries. Download one and
pass it with `--binary` rather than copying whatever happens to be on the
machine you are running onboarding from — otherwise whoever runs the script
quietly becomes the build server, and the fleet ends up running whatever was in
their working tree.
## 2. The identity
On the control plane, which holds the certificate authority — this is the
part that cannot be delegated, because it issues the credential by which the
control plane believes the node is who it says it is:
```
sudo llmgw node add ilker@10.0.0.144 --fabric ib-b:mlx5_0,mlx5_1
```
It checks the machine with `llmgw doctor`, issues a client certificate whose
common name is the node id (the hostname), installs it along with the CA root,
trusts that root system-wide, installs the binary and the unit, starts the
agent, and waits for the node to join.
If it does not join, the agent's log on the node says why. A certificate whose
common name does not match the claimed node id is refused by design — that
check is what makes a node's identity something it proves rather than
something it picks.
**It needs SSH from the control plane to the node.** Provisioning copies the
hypervisor's `authorized_keys` into the new machine; with `ssh -A` to the
control plane and `sudo --preserve-env=SSH_AUTH_SOCK`, the same key that
reaches the hypervisor reaches the node.
`deploy/bootstrap/onboard-node.sh` is the script this replaced, for a machine
holding an authority but not a current llmgw.
## The CPU model matters, and its default is wrong
Nodes are provisioned with `--cpu host`. Proxmox's default, `kvm64`, hides
AVX, AVX2, FMA and even SSE4.2 from the guest, and llama.cpp then loads its
scalar backend instead of the one built for the host's instruction set.
Measured on node05, an 8-core VM on a Xeon E5-2680 v4, same weights, same
threads, the only change being the CPU model:
| Qwen2.5-Coder | kvm64 tok/s | host tok/s |
| -------------- | ----------: | ---------: |
| 1.5B q4_K_M | 5.80 | 39.09 |
| 3B q4_K_M | 2.90 | 20.51 |
| 7B q4_K_M | 1.33 | 9.88 |
Prompt processing is worse still: 7.6 tokens a second against 143.8, a factor
of nineteen.
It is worth calling out because nothing looks broken. The node registers, the
engine starts, replies are correct — every number is simply small, and the
obvious conclusion is that CPU inference is hopeless rather than that the VM
cannot multiply efficiently.
The trade is live migration: a VM pinned to the host CPU cannot move to a
machine with a different one. For a node whose job is arithmetic that is the
right way round, and `--cpu` overrides it where it is not.
## Engines
The script installs none, and does not need to: once the agent is connected,
the control plane tells it which engines to have and it installs them itself
— llama.cpp on every node, vLLM on every node with an NVIDIA GPU it can run
on, SGLang where the control plane names a version. Each is checked to run
before the node offers it, so a node never advertises an engine that cannot
start. [install.md](install.md#engines) says what vLLM needs of the node.
`--llamacpp-bin`, `--vllm-bin` and `--sglang-python` are for an engine you
installed yourself. For the agent to use yours rather than install its own,
also set that engine's version to `""` under `engines` in `server.yaml`.
Weights are the other thing a node needs. llmgw fetches them for a model whose
`source` is a URL, one copy per node, which covers the simple case; anything
larger — a shared filesystem, a cache — stays the operator's arrangement, and
llmgw is content to find the files already there.
The `sim` engine needs neither, which is what makes it useful for exercising
everything else.
## Checking an engine after an upgrade
Engines rename and remove flags between versions, and when that happens the
failure arrives minutes into a model load, in the engine's own error message, on
a node nobody was watching.
```
llmgw ctl engine-check vllm
llmgw ctl engine-check llamacpp --help-output "$(llama-server --help 2>&1)"
```
It builds the widest command llmgw would ever produce — a multi-node one, since
a flag that only appears there is exactly the one nobody notices has gone — and
checks every flag against the engine's own `--help`. No GPU required: a CLI
prints its help on any machine.
Some programs do not give up their whole help for `--help`. vLLM prints a
summary of section names and expects `--help=all`; checking against the summary
reports every flag as missing, confidently and wrongly. `engine-check` knows
that one. If you add an engine, check that what you get back is the full list.
vLLM also refuses to do anything at all on a machine with no accelerator,
including print its help, unless `VLLM_TARGET_DEVICE` says what to assume.
`engine-check` sets it.
Give it the **whole** help. Truncating llama.cpp's 725 lines to 400 reports five
flags as missing that the running cluster uses; the command warns when the text
it was given does not document `--help` itself, which is the cheapest sign it
was cut short.
## Fabrics
`--fabric` is how a node says which InfiniBand fabric it is attached to. The
name is checked against what the adapters report — every port on one fabric
sees the same subnet managers — so two independent fabrics given one name are
placed as two, and the cluster page says so; give each fabric its own name.
The agent installs `infiniband-diags`, which that check needs, if it is missing.
Before a node with a fabric joins, run `sudo llmgw doctor` on it: it checks the
subnet manager, GPUDirect RDMA, the fabric manager and IMEX on NVLink machines,
and the declared fabrics against the adapters. [fabrics.md](fabrics.md) has the
rest.
Omit it for a node with no fabric. Such a node is its own island: it can host
single-node replicas and will never be given a multi-node one.
Two adapters per fabric is the default; `--fabric ib-b:mlx5_2,mlx5_3` names them
explicitly. An adapter must never appear under two fabrics — the agent refuses
that configuration: a replica given adapters on two fabrics starts, passes its
health checks, and hangs on its first all-reduce.
## Where the driver is
```
--nvidia-smi /opt/nvidia/bin/nvidia-smi
--ib-root /sys/class/infiniband
```
Both default to the usual places. Containerized driver installs routinely put
`nvidia-smi` somewhere else, and an agent that cannot find it registers with no
GPUs — which looks exactly like a node that has none.
## Removing a node
```
llmgw ctl drain llmgw-node04 # move its replicas off, one at a time
llmgw ctl nodes # wait until it holds none
```
Then stop the agent. Draining brings each replacement up before stopping the
original, which is the difference between a rolling move and an outage.
The node's certificate stays valid until it expires. There is no revocation list
— if a machine is decommissioned rather than retired, reissue the authority or
accept that a certificate naming a node that no longer exists can still connect
and claim to be it.
## Testing without hardware
Every part of this works on machines with no GPUs and no InfiniBand. Point
`--nvidia-smi` at a script that prints the CSV discovery asks for, point
`--ib-root` at a sysfs-shaped directory, and use `engine: sim` in the catalog.
The simulated engine rendezvouses for real: rank 0 binds the dist-init port and
does not serve until every worker has joined, and workers serve nothing. That
exercises the multi-node launch sequence, the fabric constraint, and failure and
rebuild — which is most of what goes wrong — without exercising NCCL, which is
the rest.
+443
View File
@@ -0,0 +1,443 @@
# Onboarding a user
Written for whoever operates an llmgw deployment: the person with a shell on the
control plane machine.
There are two ways a person gets a key. Pick one and put it in the config.
| | Self-service | Operator-issued |
|---|---|---|
| Config | `auth.signup_open: true` | `auth.signup_open: false` |
| They do | sign up, wait, then make their own keys | wait for a link |
| You do | approve once | issue every key |
| Good for | more than a handful of people | a few, or machines |
Self-service does not mean open. A signup produces an account that can do
nothing at all; **approval is what makes it work**, and approval needs a shell
here rather than a session cookie, because it is the step that decides who gets
to spend the cluster.
Everything below runs as the user the server runs as, so the files it creates
stay readable by it:
```
sudo -u llmgw llmgw <command> --store /var/lib/llmgw/llmgw.db
```
## Self-service
Turn on the form:
```yaml
auth:
enabled: true
store: /var/lib/llmgw/llmgw.db
signup_open: true
```
The landing page then links to it, and the rest is:
```
llmgw users list --pending --store …
ID EMAIL STATE ADMIN MEMBER OF SIGNED UP
usr-k4mq… ayse@example.com pending - on their own 4m12s ago
llmgw users approve ayse@example.com --by ilker --store …
llmgw users approve ayse@example.com --project Acme/ml/training --store … # or straight into a project
```
That is the whole operator loop. From then on they sign in at `/login`, and
their account page (`/profile`) is where they create keys, see when each was
last used, revoke the ones they are done with, and see their own use over the
last thirty days against every budget that applies to them. A key is shown once
on the page that creates it and never again.
### Connecting an editor
The account page's **Connect your editor** makes a key for Continue, Codex or
Cline and shows what the tool needs with that key, this gateway's address and a
model already in it:
- **Continue** — an assistant file of its own, `~/.continue/assistants/llmgw.yaml`,
so their `config.yaml` is not touched.
- **Codex** — a provider and a profile in `~/.codex/config.toml`, run with
`codex --profile llmgw`; their default model stays as it was. Codex speaks the
Responses API, which the gateway translates at `/v1/responses`.
- **Cline** — four settings to paste into its own screen, since it keeps them
in VS Code's secret storage where no file reaches.
For Continue and Codex there is a one-line command,
`curl -fsSL <gateway>/setup/<tool>?model=… | sh`. Its script is served without
signing in and holds no secret — the key comes from the environment — and it
keeps a copy of anything it replaces. Revoking the key disconnects the tool.
How well an editor's agent mode works depends on the model: it needs one that
calls tools well, which the coder models do and small general ones often do not.
**This gateway sends no email.** There is no "your account was approved" message
and no password reset, so tell people what to expect: they sign up, they tell
you, you approve, they sign in. If that loop is too manual for your size, it is
the thing to automate — not the approval step.
To close an account:
```
llmgw users suspend usr-k4mq… --store …
```
which closes their sessions immediately. Their **API keys keep working** — those
are separate credentials, and revoking them is a separate step:
```
llmgw keys list --project default --store …
llmgw keys revoke key-… --store …
```
That separation is deliberate: a suspended person's running jobs should not all
fail at once without someone deciding they should.
## Operator-issued
With `signup_open: false` there is no form, and you issue keys yourself. This is
also the path for things that are not people — CI, a notebook server, a script.
### Decide where the key belongs
Keys hang off a project, projects off a team, teams off an organization. Limits
can be set at any of those levels and compose downward by taking the **most
restrictive** value — a key cannot raise what its team set, and a team cannot
raise what its organization set. That is the only rule under which a budget
means anything.
If you have no structure yet, one of each is fine:
```
llmgw org add org mynodes
llmgw org add team default --in mynodes
llmgw org add project default --in mynodes/default
llmgw org tree --people
```
Everything that takes an organization, team or project — here, in `users` and
in `keys` — accepts its id, its path (`mynodes/default/default`) or its name
when the name is unique, so an id never has to be copied off a page. When there
is only one project, `--project` can be left out.
The tree is not fixed once made:
```
llmgw org rename project Acme/platform/gateway api-gateway
llmgw org move project Acme/platform/gateway --to Scania/ml
llmgw org remove team Acme/old # only when nothing is in it
llmgw users delete ayse@example.com --yes # signs out, revokes their keys
llmgw keys list --all # every key, where it spends, and whose
```
A project that ever had a key cannot be removed, because usage history refers
to it — rename it instead.
### Where people belong
A person can be a member of any number of organizations, teams and projects, or
of none:
```
llmgw users join ayse@example.com --team Acme/ml # every project in ml
llmgw users join ayse@example.com --project Scania/research/vision
llmgw users leave ayse@example.com --project Acme/ml/training
```
A member of a team or organization may use every project in it. Leaving revokes
the keys the person made in projects they can no longer reach; a project they
still reach through another membership keeps them.
Someone who is a member of nothing still uses the gateway, on their own:
personal keys, and the chat page, within their personal limits. When they make
a key on `/profile` they choose what it spends against — one of their projects,
or "Personal". An operator can do the same:
```
llmgw keys create laptop --for ayse@example.com # personal
llmgw keys create ci --for ayse@example.com --project Acme/ml/training
```
Personal use can be limited by default, or turned off so people use the gateway
only through their projects:
```yaml
auth:
personal:
rpm: 20
tpm: 50000
budget: 10 # whole units
period: month
# disabled: true
```
A personal key has no project, so it keeps no conversations or agent notes;
those are kept per project.
### Organization managers
A member of an organization can be made its manager:
```
llmgw users join boss@acme.com --org Acme --role manager
```
or with the star on their organization membership on `/admin/users`. A manager
gets an **Organization** link and a page at `/org`: usage over time by team,
project or person, the teams and projects with their limits and spend, and every
member. Only what was spent in that organization's projects is on it; what its
people do elsewhere is not. The page is read-only — a manager is not an
administrator of the cluster, and limits and memberships stay with those who
are.
`llmgw keys init` still works for provisioning scripts and is idempotent, but it
will no longer move a team or project that already exists under another parent;
it says so and stops.
Split it when you want separate limits or separate reporting, not before.
### Issue the key as a one-time link
```
llmgw keys create --store … \
--project default \
--band gold \
--base-url https://llmgw.example \
--claim \
"ayse-laptop"
https://llmgw.example/claim/3mKq…
```
Send the link, not the key.
A key pasted into a chat window stays in that chat window forever, searchable by
everyone who joins the channel later. A link works once and then expires, so the
worst case is someone opening it before the intended person — which you find out
about, because they will tell you the link is dead.
The name is for you, not the user: it appears in `keys list` and is how you will
know which key to revoke.
**Why the link is safe to store.** The key is sealed with the link's own token,
and the token is never written down — only its hash is. A database leak yields
two useless hashes and a blob nothing in the database can open.
Without `--claim` the key is printed to stdout instead, for when you are
provisioning something that is not a person:
```
llmgw keys create --store … --project ci "github-runner" > /run/secrets/llmgw-key
```
## Limits
Limits are optional. A key with none is unlimited, which is the right default on
a private cluster and the wrong one the moment the gateway faces anything else.
```
# Everyone in this project
llmgw keys limits --store … --scope project --id default \
--rpm 60 --tpm 200000
# One key, tighter
llmgw keys limits --store … --scope key --id key-mpmip77twpt7qaxc \
--rpm 10
# Restrict which models
llmgw keys limits --store … --scope project --id default \
--models qwen-0.5b,llama-3.3-70b
```
An **absent** model list permits everything. An **empty** one permits nothing —
`--models ""` is a way to suspend a project without revoking its keys.
Allowlists compose by intersection: a key cannot add a model its team withheld.
A model's canonical name and any of its aliases name the same thing, so either
form works.
Budgets are given in whole units, as the quotas page shows them, and what a unit
means is yours to decide. Self-hosted models have no per-token price, so it can
be GPU-seconds, a notional rate, or one unit per thousand tokens everywhere —
a price of 1 000 000 in `auth.pricing`, which is in micro-units — and a budget
becomes a plain quota of thousands of tokens.
Models are priced in the server's `auth.pricing` section; a model priced at zero
is exempt from budget checks, which is what lets a key with nothing left still be
served a cheap fallback.
```
llmgw keys limits --store … --scope project --id Acme/platform/gateway \
--budget 50 --period month # 50 units a month
```
Two more kinds of limit follow people rather than the tree:
```
# A person, everywhere: every key they hold, every project, the chat page
llmgw keys limits --scope user --id ayse@example.com --budget 25
# Each member of a project separately — a fair share, not one shared pool
llmgw keys limits --scope project --id Acme/ml/training --per-member --rpm 20
```
A request is held to all of them at once: the key's own, its project's, team's
and organization's, each per-member limit on those for its owner, and the
owner's own. The strictest wins, and each is counted in its own pool.
## The landing page
Someone who reaches the gateway without a key sees the landing page. Tell it who
to send them to, or it can only shrug:
```yaml
landing:
contact: "platform@example.com" # or an https:// URL, or "#platform on Slack"
notice: "Internal use only."
```
An address becomes a mailto link, an `http(s)` URL stays a link, anything else is
shown as text. Other schemes are never linked — configuration is not always
written by whoever runs the gateway, and a `javascript:` href on a page served to
every visitor is a short path to a stored XSS.
## What the user sees
They open the link, press the button, and get the key once. The page shows them
the base URL and a working `curl`, and points at two endpoints:
- `GET /v1/models` — what this key may use
- `GET /me` — its limits and what it has spent
`/me` is there so nobody has to ask you "why was I rate limited" or "how much
have I used". It is outside `/v1` because it is not part of the OpenAI API.
## Rotation and revocation
```
llmgw keys list --store … --project default
```
`LAST USED` is written in batches rather than per request, so it lags by up to a
minute. It is the column that tells you whether a key is still in use, which is
what makes rotation safe:
**To rotate**, issue the new key, let both work while the user switches, watch
`LAST USED` on the old one stop moving, then revoke it.
```
llmgw keys revoke --store … key-mpmip77twpt7qaxc
```
Revocation is a soft delete: the key stops working, the row stays, and its usage
history keeps its subject. A running server keeps accepting a revoked key until
its cache expires — `auth.cache_ttl`, 30 seconds by default. That is the price of
keeping the database off the request path, and it is the number to lower if you
need revocation to bite faster.
## Passwords
People change their own from `/profile`. It asks for the current one — a session
is what someone gets from a borrowed laptop, and the password is what they do not
have — and it signs every other browser out, which is the point if they are
changing it because something went wrong.
There is no self-service reset, because this gateway sends no email and a reset
link with nowhere to send it is not a reset. When someone loses theirs:
```
llmgw users reset-password usr-k4mq… --password "a temporary passphrase" --store …
```
Tell them out of band and have them change it from their profile. Their sessions
are closed — if they lost the password, the reason might be that someone else has
it. Their API keys keep working; those are separate credentials.
## Trying a model in the browser
`/chat` is a playground for anyone signed in. It exists so somebody can see
whether a model works without setting up a client, and — more often — so they
can tell whether a problem is the gateway or their code.
**The browser never holds a key.** The page carries a session; the server turns
that into the same identity an API key would produce, resolved from the person's
project, and the request goes down the ordinary path: admission, model
allowlist, rate limits, budget, accounting. Anything else would make the page a
hole in every limit the rest of the gateway enforces.
Usage from the page is attributed to `session:<user-id>` rather than a key, so
it is billed to the right project and visibly did not come from a credential
somebody holds. One turn is capped at 512 tokens: a playground should not be the
thing that ties up a replica for ten minutes, and anyone who wants a longer
answer has an API key for that.
Nothing is stored. The conversation lives in the page and is gone when it is
closed.
## Seeing the cluster
The dashboard — nodes, replicas, models, why something did not get placed, usage
per key — is served twice, and the difference matters.
On the **admin listener** (`127.0.0.1:9090` by default) it needs no
authentication: having a shell on the machine is the credential.
```
ssh -L 9090:127.0.0.1:9090 <control-plane> # then http://127.0.0.1:9090/
```
That listener also serves pprof, which can dump the process heap — where API
keys sit in plaintext. **Never expose that port.** It is bound to loopback for
that reason.
On the **client-facing port** the same page is at `/admin`, behind a session and
an admin flag, so it can be reached from a browser without the port carrying
pprof along with it:
```
llmgw users promote usr-k4mq… --store …
```
Someone without the flag gets a 404 rather than a 403, because 403 confirms the
page exists.
## Managing accounts from the browser
`/admin/users` is the tree: organizations, teams and projects, each project
opening onto its members and its keys. From there an admin approves people
(into a project or on their own), adds them to and takes them out of
organizations, teams and projects, suspends and removes them, revokes keys, and
adds, renames, moves and removes organizations, teams and projects. `/admin/quotas`
sets limits on the tree, per member of any part of it, and per person. A searchable list of every account is at the
bottom. The page works as plain forms; with scripts on, a change updates it in
place and the projects you had open stay open.
Three things are deliberately not there:
| | Why |
|---|---|
| `llmgw users promote` | An admin who can make admins turns a stolen session into permanent access |
| `llmgw users reset-password` | Setting someone's password is taking over their account |
| `llmgw keys create` | Issuing a credential on another person's behalf |
Each of those turns a stolen admin cookie into more than it was, and each is
rare enough that a shell is no hardship. Cordon, drain and everything else that
touches the cluster are also CLI-only for the same reason: the dashboard shows
the cluster, it does not steer it.
Every administrative action is logged with who did it, including the ones that
were refused.
## What this does not do
- **No email.** No approval notice, no self-service password reset, no address
verification. The loop is manual on purpose at this size; if it stops being
the right size, that is the thing to automate.
- **No SSO.** Accounts are local, with local passwords.
- **No roles.** There is one flag, `is_admin`, and it grants looking at the
dashboard. Everything else an administrator does is the CLI, and having a
shell here is what makes someone one.
- **Rate limits are per gateway instance.** With two instances each counts its own,
so the effective limit doubles. v1 is single-instance by design.
+202
View File
@@ -0,0 +1,202 @@
# Quick install
From empty servers to a model answering requests, in six steps. This page
has only the commands and what to check after each; [install.md](install.md)
explains every choice.
## What you need
| | |
|---|---|
| **One control plane machine** | Linux, x86-64 or ARM64. Debian 12, Ubuntu 22.04+ or RHEL 10. No GPU needed; 2 GB RAM, 20 GB disk. |
| **GPU nodes** (optional to start) | The same Linux, with the NVIDIA driver installed. vLLM needs driver 580 or newer and a Turing GPU or newer (not V100). |
| **root** | on the control plane, and `sudo` without a password on every node. |
| **SSH** | from the control plane to each node, as a user with passwordless `sudo` there. |
| **Network** | nodes reach the control plane on 7443 and 8081; users reach it on 8080. The machines need internet access to download engines and models. |
You do not install llama.cpp, vLLM, Python or CUDA. The nodes do that
themselves once they join.
## 1. Put llmgw on the control plane
```
VERSION=$(curl -fsSL https://gitea.mynodes.xyz/api/v1/repos/ilker/llmgw/releases/latest \
| grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
U=https://gitea.mynodes.xyz/ilker/llmgw/releases/download/$VERSION
curl -fsSLO $U/llmgw-$VERSION-linux-amd64 # -linux-arm64 on ARM
curl -fsSL $U/checksums.txt | sha256sum -c --ignore-missing
sudo install -m0755 llmgw-$VERSION-linux-amd64 /usr/local/bin/llmgw
llmgw --version
```
`sha256sum` must print `OK`. If it does not, the download is damaged; do not
install it.
## 2. Set up the control plane
```
sudo llmgw server init
```
It asks for your email and a few choices, then sets up everything on this
machine, in a few minutes. In a script, give them as flags instead:
`sudo llmgw server init --admin you@example.com`.
- the service, its certificates and its configuration;
- your administrator account. **The password is printed once. Copy it now.**
- a model mirror, which downloads model files once for the whole cluster;
- this machine as a first node, and a small model (Qwen2.5 0.5B) on it.
If nodes will reach this machine by a name or address it does not know itself
(a DNS name, a NAT address), add `--host` for each:
```
sudo llmgw server init --admin you@example.com --host llm.example.com
```
It is safe to run again. It keeps what exists and finishes what is missing.
**The mirror on its own machine**, when it wants a larger disk than the
control plane has: first on that machine, with llmgw installed as in step 1,
```
sudo llmgw mirror init --control-plane <control plane address>
```
then on the control plane `sudo llmgw server init --mirror http://<mirror
address>:8082`. The mirror serves model files to every node, and takes
orders only from the control plane. Untick "This machine as a node" in the
form (or add `--no-node`) to keep models off the control plane; the starter
model then starts on the first node you add.
**Check:** open `http://<control-plane>:8080/chat`, sign in with the printed
password, and ask something. The small model answers. Change the password on
the account page.
## 3. Add the GPU nodes
On the control plane:
```
sudo llmgw node add ubuntu@10.0.0.21
```
Several at once: `sudo llmgw node add ubuntu@10.0.0.21 ubuntu@10.0.0.22 ubuntu@10.0.0.23`.
Replace `ubuntu` with the user you log in to the node as, and `10.0.0.21` with
its address. That user needs `sudo` without a password on the node.
It logs in over SSH and, while you watch:
1. installs llmgw on the node, the release for its processor, checksum checked;
2. runs `llmgw doctor` there, and stops if the node has a problem. Each problem
comes with the command that fixes it. Fix it and run the same command again;
3. gives the node its certificate and configuration, and starts its agent;
4. waits for the node to join and install llama.cpp.
The node's name in the cluster is its hostname. vLLM installs by itself after
that, in a few more minutes on a GPU node (about 8 GB). The cluster page
(`/admin`) shows its progress, or why the node cannot run it.
**InfiniBand.** When the node's adapters are up, `node add` shows them grouped
by the fabric they are on and asks for each fabric's name. Give every node on
the same fabric the same name. In a script, give it instead:
```
sudo llmgw node add ubuntu@10.0.0.21 --fabric ib-a:mlx5_0,mlx5_1,mlx5_2,mlx5_3
```
Once two nodes on a fabric are in, check they reach each other at the link's
rate before placing anything across them:
```
llmgw ctl fabric-test gpu01 gpu02
```
**If it cannot log in.** `sudo` runs SSH as root, with root's keys. To use your
own keys instead, log in to the control plane with `ssh -A` and keep your
agent through sudo:
```
sudo --preserve-env=SSH_AUTH_SOCK llmgw node add ubuntu@10.0.0.21
```
**Check:** `llmgw ctl nodes` lists the node. Running `node add` again on the
same node is safe: it renews its certificate and configuration, and keeps what
runs there.
A machine retired for good is removed with `llmgw node remove <name>` once its
agent is stopped.
## 4. Add a model
Open `/admin/mirror` and choose **Explore models**. It lists what is popular on
Hugging Face, showing only what your nodes can run.
1. Click a model to read its card.
2. Pick one of its files, usually a `q4_k_m` for a first try, and click
**Fetch**. It is downloaded into the mirror once, for every node.
3. When the download finishes, click **serve** on its row in the table above.
4. In the form, choose how it runs: **always on**, or **on demand**, which
loads it on the first request and stops it after 15 minutes unused. Click
**Add to the catalog**.
The model is placed on a node that fits it, and its status on `/admin` turns
`ready`.
**Check:** the model appears in the list on `/chat`. Ask it something.
## 5. Give people access
**A key for a program:** each person makes their own on the account page
(`/profile`). The key is shown once.
```
curl http://<control-plane>:8080/v1/chat/completions \
-H "Authorization: Bearer $LLMGW_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "<model-name>", "messages": [{"role": "user", "content": "hello"}]}'
```
Any OpenAI client works. Only the base URL changes:
`http://<control-plane>:8080/v1`.
**An editor:** the account page connects Continue, Codex or Cline. Pick the
tool and a model, and it gives a one-line command that writes the
configuration.
**Other people's accounts:** they sign up at `/signup`, and you approve them on
the people page (`/admin/users`).
## 6. Put it behind HTTPS
Port 8080 is plain HTTP. Before users outside a trusted network use it, put a
reverse proxy in front with a real certificate. With Caddy, which gets the
certificate by itself:
```
sudo apt-get install -y caddy # Debian and Ubuntu
curl -fsSL $U/Caddyfile | sed 's/llm.example.com/YOUR.DOMAIN/' | sudo tee /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Keep 7443 (nodes) and 8081 (mirror) on the internal network. They never go
through the proxy. There is an nginx configuration too:
[deploy/proxy/](../deploy/proxy/).
## When something does not work
| What you see | What to do |
|---|---|
| `node add` stops at `join` | `sudo journalctl -u llmgw-agent -n 50` on the node. Usually it cannot reach port 7443 on the control plane. |
| `node add` says the address is not in the certificate | Run `sudo llmgw server init --host <that address>`, then `node add` again. |
| A node says it cannot run vLLM | The reason is on `/admin`: the driver is older than 580, the GPU is older than Turing, or there is less than 25 GB free. |
| A model shows `not placed` | `llmgw ctl explain-placement <model>` names each node and why it was not used. |
| A reply stops after 60 seconds behind nginx | Use the nginx configuration from the release. Its timeouts are set for slow models. |
| Anything else about a node | `sudo llmgw doctor` on it. `--json` writes everything it read, to send to whoever helps you. |
## Next
- [install.md](install.md): the same steps with every option explained.
- [fabrics.md](fabrics.md): InfiniBand and NVLink.
- [onboarding.md](onboarding.md): accounts, projects, budgets and keys.
+375
View File
@@ -0,0 +1,375 @@
# Quickstart: one machine, one model, ten minutes
Written for a developer who wants to see llmgw answer a request on a single
workstation, built from source, with no systemd and no `sudo`. **To install
llmgw on servers, read [quick-install.md](quick-install.md) instead.**
What you end up with: an OpenAI-compatible endpoint on `localhost:8080` in front
of a real model served by llama.cpp.
## Two ways to run
Steps 1 to 4 use the simple one, **router-only**: you start the engines yourself
and tell the router where they are with `--endpoint`. No certificates, no node
agent.
Step 5 uses the **control plane and node agent**, the same architecture as a real
cluster on one machine: the agent starts and supervises the engine, and the
control plane places the model.
## 1. Build
```
git clone https://gitea.mynodes.xyz/ilker/llmgw && cd llmgw
make build
make test
./bin/llmgw --version
```
`make build` writes `./bin/llmgw`. Go 1.26 or later is the only requirement.
## 2. Check the path with a fake engine
Before involving a GPU, prove the router works. `sim-engine` is an
OpenAI-compatible fake that streams placeholder words.
```
./bin/llmgw sim-engine --listen :8001 --model llama &
./bin/llmgw server --endpoint e1=http://127.0.0.1:8001=llama &
curl -N localhost:8080/v1/chat/completions \
-H 'content-type: application/json' \
-H 'x-session-id: demo' \
-d '{"model":"llama","messages":[{"role":"user","content":"hello"}],"stream":true}'
```
Chunks of `alpha beta gamma ...` ending in `data: [DONE]` mean the router is
fine. Stop both before continuing (see [Stopping](#stopping)).
## 3. Serve a real model
Two processes, two ports. Clients only ever talk to the router; the router
knows where the engine is because you tell it with `--endpoint`:
```
curl :8080 -> llmgw router :8080 -> llama serve :8082 -> model
(client) (the gateway) (the engine)
```
Requests go to 8080. Port 8082 is the engine's own, and you only touch it to
check that the engine is up.
llmgw does not install engines or weights. You need:
- a `llama-server` build from llama.cpp (CUDA build if you want the GPU), and
- a `.gguf` file.
Check the card first with `nvidia-smi`. An 8 GB card holds a 1B model at bf16
(2.4 GB) or a 9B model at `q4_k_m` (5.4 GB).
Start the engine on its own port. The model name given to `--alias` is the name
clients will use:
```
llama serve \
-m /path/to/Llama-3.2-1B-Instruct-BF16.gguf \
--host 0.0.0.0 --port 8082 -ngl 99 --alias llama
```
`-ngl 99` offloads every layer to the GPU. Confirm it is up and knows the model:
```
curl -s localhost:8082/v1/models
```
Then point the router at it. The format is `id=URL=model`:
```
./bin/llmgw server --endpoint e1=http://127.0.0.1:8082=llama
```
## 4. Ask it something
Streaming:
```
curl -N localhost:8080/v1/chat/completions \
-H 'content-type: application/json' \
-H 'x-session-id: demo' \
-d '{"model":"llama","messages":[{"role":"user","content":"hello"}],"stream":true}'
```
One reply at once:
```
curl -s localhost:8080/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"llama","messages":[{"role":"user","content":"hello"}],"max_tokens":100}'
```
Also useful: `curl -s localhost:8080/v1/models` and
`curl -s localhost:8080/health`. `llmgw ctl` does not work in this mode: it
talks to the control plane's admin API, which router-only mode does not have,
so `ctl status` answers `404`.
On the machine this was written on, a 1B bf16 model on an RTX 4070 laptop GPU
answered at roughly 85 tokens per second.
## Several replicas of one model
Give the router more than one `--endpoint` with the same model name and it
spreads requests across them. `x-session-id` keeps a conversation on the same
replica so its cache is reused:
```
./bin/llmgw server \
--endpoint e1=http://127.0.0.1:8082=llama \
--endpoint e2=http://127.0.0.1:8083=llama
```
## Stopping
```
pkill -f "llmgw server"
pkill -f "llmgw sim-engine"
```
Stop the engine the way you started it (Ctrl-C in its terminal).
## What goes wrong
**`listening on :8080: bind: address already in use`.** An earlier router is
still running. `ss -ltnp | grep 8080` names the process; stop it first. This is
the usual result of moving from step 2 to step 3 without stopping the fake
engine's router.
**`/v1/models` on the engine returns an empty list.** The engine was started
without `-m`, so it is a router with nothing to load. Restart it with a model.
**`error: nothing to serve`.** `llmgw server` with no `--endpoint` and no model
carrying `profiles` has nothing to do. In router-only mode, always pass at least
one `--endpoint`.
**Requests fail with a model-not-found error.** The name in the request must
match the name after the last `=` in `--endpoint`, and it must be a name the
engine actually serves (its `--alias`).
**Answers are poor.** A 1B model is a plumbing test, not a chat assistant. Swap
in a larger `.gguf`; nothing else changes.
## 5. Let llmgw run the engine (control plane and node agent)
Steps 2 and 3 start the engine by hand. Here a **node agent** starts and
supervises llama.cpp for you, and a control plane decides where the model runs.
Still one machine, still no `sudo`. Stop everything from steps 2 and 3 first:
the GPU has to be free and port 8080 unused.
```
curl :8080 -> llmgw server (control plane + router) <- llmgw agent (this node)
|
starts llama-server :20000
```
The agent dials the control plane over mutual TLS, so this needs a small private
certificate authority. Everything goes in a scratch directory, `devcluster/`,
which is in `.gitignore`.
### Certificates
```
mkdir -p devcluster/pki
./bin/llmgw ctl cert init-ca --dir devcluster/pki
./bin/llmgw ctl cert issue-server llmgw-ctl --host 127.0.0.1 --host localhost \
--dir devcluster/pki --out devcluster
./bin/llmgw ctl cert issue-client llmgw-node01 --dir devcluster/pki --out devcluster
```
The name given to `issue-client` becomes the certificate's common name and must
equal `node_id` in the agent config. The control plane refuses an agent whose
claimed id differs from its certificate.
### The engine wrapper
The agent starts the engine as `<bin> --model ... --port ...`. A `llama` binary
that wants `llama serve ...` refuses every flag, so put a wrapper in front of it:
```
cat > devcluster/llama-server <<'EOF'
#!/bin/sh
exec /path/to/llama serve "$@"
EOF
chmod +x devcluster/llama-server
./bin/llmgw ctl engine-check llamacpp --bin devcluster/llama-server
```
`engine-check` should end with `every flag llmgw builds is accepted`. If your
build already ships a `llama-server` binary, point `bin` at that and skip the
wrapper.
### Control plane config: `devcluster/server.yaml`
```yaml
listen: "127.0.0.1:8080"
admin_listen: "127.0.0.1:9090"
agent_listen: "127.0.0.1:7443"
agent_tls:
ca: devcluster/pki/ca.pem
cert: devcluster/server.pem
key: devcluster/server-key.pem
models:
- name: llama
source: /path/to/Llama-3.2-1B-Instruct-BF16.gguf
routing_profile: chat
context_length: 8192
scaling: { min: 1, max: 1 }
profiles:
- name: gpu
engine: llamacpp
gpu_class: 4070-8g
weight: 1
priority: 100
startup_timeout: 5m
```
Three things in there are easy to get wrong:
- **The model must have at least one profile.** That is what switches the control
plane on. With `models: []` the server starts as a plain router and exits with
`nothing to serve`.
- **`gpu_class` decides CPU or GPU.** Without it the replica is placed in the CPU
pool and runs on the CPU. Get the class name from `./bin/llmgw agent inventory`
(the `CLASS` column).
- **There is no `auth:` block.** Leave it out for a local trial; with auth on,
requests need an API key.
### Agent config: `devcluster/agent.yaml`
```yaml
node_id: llmgw-node01
server: "https://127.0.0.1:7443"
mgmt_addr: "127.0.0.1"
tls:
ca: devcluster/pki/ca.pem
cert: devcluster/llmgw-node01.pem
key: devcluster/llmgw-node01-key.pem
engines:
llamacpp:
bin: /absolute/path/to/devcluster/llama-server
supervisor:
work_dir: devcluster/agent-work
```
### Run it
Two terminals:
```
./bin/llmgw server --config devcluster/server.yaml
./bin/llmgw agent --config devcluster/agent.yaml
```
The model is not placed at once. The control plane waits about 35 seconds for the
fleet to settle after it starts, then places the replica, and the agent then
loads the weights. Watch it with:
```
./bin/llmgw ctl nodes
./bin/llmgw ctl models
./bin/llmgw ctl replicas
./bin/llmgw ctl explain-placement llama
```
`STATUS ready` in `ctl models` means it is serving. `nvidia-smi` should now show
the model in GPU memory. The same curl from step 4 works against port 8080.
`ctl` works here, unlike in router-only mode, because there is a control plane
for it to talk to.
Stop it with Ctrl-C in each terminal. The agent leaves the engine running when
the agent itself stops, so if you restart the agent and the GPU stays full,
`pgrep -af "llama serve"` will show the engine still up.
## 6. Memory: conversations and notes
Off unless you ask. llmgw otherwise stores nothing anyone typed. On a single
machine with no API keys, one block in the server config turns on two things:
```yaml
threads:
store: devcluster/data/threads.db
retain_hours: 24
```
Restart the server after adding it. `retain_hours` is required: everything kept
expires that long after it was last written.
Everything kept this way sits in one shared pool with no owner, and anyone who
can reach the port and knows an id can read it. That is fine on one machine and
wrong on a shared network. With auth on, threads and notes are per project
instead and the block is ignored; see [install.md](install.md).
### Conversations (threads)
Send the same `x-llmgw-thread` id and only the new turn. llmgw puts the stored
history in front of it:
```
curl -s localhost:8080/v1/chat/completions \
-H 'content-type: application/json' -H 'x-llmgw-thread: demo' \
-d '{"model":"llama","messages":[{"role":"user","content":"My name is Deniz."}]}'
curl -s localhost:8080/v1/chat/completions \
-H 'content-type: application/json' -H 'x-llmgw-thread: demo' \
-d '{"model":"llama","messages":[{"role":"user","content":"What is my name?"}]}'
curl -X DELETE localhost:8080/v1/threads/demo # forget it
```
Without the block, a request carrying `x-llmgw-thread` is refused with
`this gateway keeps no conversations` rather than answered without its history.
### Notes an agent keeps for itself
Notes outlive any one conversation. Each is a key, some text and optional tags,
kept per agent; name the agent with `x-llmgw-agent` (default `default`), so two
agents using the same key do not read each other's notes.
```
curl -X PUT localhost:8080/v1/memory/favorite-color \
-H 'content-type: application/json' \
-d '{"text":"The user likes dark green.","tags":["preference"]}'
curl 'localhost:8080/v1/memory?q=green' # search by words
curl 'localhost:8080/v1/memory?tag=preference' # or by tag
curl localhost:8080/v1/memory/favorite-color
curl -X DELETE localhost:8080/v1/memory/favorite-color
```
The same notes are offered to an agent as tools over MCP at
`http://localhost:8080/mcp`: `remember`, `recall` and `forget`. Add that URL to
your agent's MCP server list (HTTP transport; how you set request headers such
as `x-llmgw-agent` depends on the client). llmgw does not put these tools into
chat requests on its own, so an agent that is not given the server never sees
memory.
Limits: a key is up to 200 bytes, a note's text up to 16 KiB, at most 16 tags,
1000 notes per agent. Tags are lowercase letters, digits, `-` and `_`.
## Where this stops
Router-only mode (steps 2 to 4) has no authentication, no placement and no model
management: you start and restart engines yourself. Step 5 adds placement, model
management and a node agent, and can add authentication as `install.md`
describes. For a real cluster, with several nodes, systemd and a persistent
certificate authority, follow [install.md](install.md).
One caveat found while writing this page: `install.md` recommends starting with
an empty `models: []` and adding models later with `ctl model add`. The control
plane only turns on when the config declares a model with `profiles`, so that
first start does not produce a control plane.
+1482
View File
File diff suppressed because it is too large. Load diff
+51 -2
View File
@@ -6,31 +6,80 @@ require (
connectrpc.com/connect v1.21.0
github.com/NVIDIA/go-nvml v0.13.4-0
github.com/cespare/xxhash/v2 v2.3.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/jackc/pgx/v5 v5.11.0
github.com/knadh/koanf/parsers/yaml v1.1.1
github.com/knadh/koanf/providers/env/v2 v2.0.1
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.3.6
github.com/mattn/go-isatty v0.0.24
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.3
github.com/prometheus/common v0.71.0
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.8.6
go.opentelemetry.io/otel v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0
go.opentelemetry.io/otel/sdk v1.46.0
go.opentelemetry.io/otel/trace v1.46.0
golang.org/x/crypto v0.57.0
golang.org/x/net v0.59.0
golang.org/x/sync v0.23.0
google.golang.org/protobuf v1.36.12
modernc.org/sqlite v1.59.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/grpc v1.83.1 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+144 -2
View File
@@ -2,19 +2,69 @@ connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs=
connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
github.com/NVIDIA/go-nvml v0.13.4-0 h1:o3jp9u2x1R9ShFE3v+Aesp55XOSIQFMJz/VGNUcJaNE=
github.com/NVIDIA/go-nvml v0.13.4-0/go.mod h1:id63qwpoDWpFXwnwM6psDCSqW4BmNu6mWpr4YeQtPGo=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
@@ -29,12 +79,29 @@ github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
@@ -43,13 +110,42 @@ github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
@@ -57,14 +153,60 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI=
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+400
View File
@@ -0,0 +1,400 @@
package account
import (
"context"
"errors"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func newStore(t *testing.T) *store.DB {
t.Helper()
s, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "a.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
ctx := context.Background()
for _, err := range []error{
s.PutOrg(ctx, store.Org{ID: "org", Name: "Org"}),
s.PutTeam(ctx, store.Team{ID: "team", OrgID: "org", Name: "Team"}),
s.PutProject(ctx, store.Project{ID: "proj", TeamID: "team", Name: "Proj"}),
} {
if err != nil {
t.Fatal(err)
}
}
return s
}
func newService(t *testing.T) (*Service, *store.DB) {
t.Helper()
s := newStore(t)
return New(s, DefaultConfig(), slog.New(slog.DiscardHandler)), s
}
const goodPassword = "correct horse battery staple"
func TestSignupLeavesAnAccountWaiting(t *testing.T) {
// A public signup form that produced a working account would be a way in.
svc, st := newService(t)
ctx := context.Background()
if err := svc.SignUp(ctx, "Ayse@Example.COM", "Ayşe", goodPassword); err != nil {
t.Fatal(err)
}
u, err := st.UserByEmail(ctx, "ayse@example.com")
if err != nil {
t.Fatal(err)
}
if u.State != store.UserPending {
t.Errorf("a new account is %q", u.State)
}
if u.Email != "ayse@example.com" {
t.Errorf("the address was not normalized: %q", u.Email)
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); !errors.Is(err, ErrNotApproved) {
t.Errorf("a pending account signed in: %v", err)
}
}
func TestSignupDoesNotRevealExistingAccounts(t *testing.T) {
// A form that says "this address already has an account" is an account
// enumerator anyone can run, and the person signing up learns nothing they
// need: either way they wait for approval.
svc, _ := newService(t)
ctx := context.Background()
if err := svc.SignUp(ctx, "ayse@example.com", "A", goodPassword); err != nil {
t.Fatal(err)
}
if err := svc.SignUp(ctx, "ayse@example.com", "Someone Else", goodPassword); err != nil {
t.Errorf("the second signup reported %v, which confirms the address is taken", err)
}
}
func TestApprovalIsWhatMakesAnAccountWork(t *testing.T) {
svc, st := newService(t)
ctx := context.Background()
must(t, svc.SignUp(ctx, "ayse@example.com", "A", goodPassword))
u, _ := st.UserByEmail(ctx, "ayse@example.com")
must(t, st.ApproveUser(ctx, u.ID, "proj", "ilker", time.Now()))
token, csrf, err := svc.SignIn(ctx, "ayse@example.com", goodPassword)
if err != nil {
t.Fatal(err)
}
if token == "" || csrf == "" {
t.Fatal("sign-in produced no session")
}
_, got, err := svc.Resolve(ctx, token)
if err != nil {
t.Fatal(err)
}
if ok, err := st.CanUseProject(ctx, got.ID, "proj"); err != nil || !ok {
t.Errorf("the approved account is not a member of the project it was approved into: %v", err)
}
}
func TestApprovalNeedNotNameAProject(t *testing.T) {
// A person need not belong to anything to use the gateway: approved into
// nothing, they work on their own within their personal limits.
svc, st := newStoreAndUser(t)
ctx := context.Background()
if err := st.ApproveUser(ctx, "usr-1", "", "ilker", time.Now()); err != nil {
t.Fatalf("approving with no project: %v", err)
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); err != nil {
t.Errorf("an account approved into nothing cannot sign in: %v", err)
}
}
func newStoreAndUser(t *testing.T) (*Service, *store.DB) {
t.Helper()
svc, st := newService(t)
hash, _ := HashPassword(goodPassword)
must(t, st.CreateUser(context.Background(), store.User{
ID: "usr-1", Email: "ayse@example.com", Name: "A",
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
}))
return svc, st
}
func TestSuspensionTakesEffectOnOpenSessions(t *testing.T) {
// Leaving existing sessions open would make suspension advisory.
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
token, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword)
if err != nil {
t.Fatal(err)
}
if _, _, err := svc.Resolve(ctx, token); err != nil {
t.Fatal(err)
}
must(t, st.SetUserState(ctx, "usr-1", store.UserSuspended))
if _, _, err := svc.Resolve(ctx, token); !errors.Is(err, store.ErrNoSession) {
t.Errorf("a suspended account kept its session: %v", err)
}
}
func TestSignOutEndsTheSession(t *testing.T) {
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
token, _, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
must(t, svc.SignOut(ctx, token))
if _, _, err := svc.Resolve(ctx, token); !errors.Is(err, store.ErrNoSession) {
t.Errorf("the session survived sign-out: %v", err)
}
}
func TestAWrongPasswordAndAMissingAccountAreIndistinguishable(t *testing.T) {
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
_, _, wrong := svc.SignIn(ctx, "ayse@example.com", "wrong password here")
_, _, missing := svc.SignIn(ctx, "nobody@example.com", "wrong password here")
if !errors.Is(wrong, ErrBadCredentials) || !errors.Is(missing, ErrBadCredentials) {
t.Fatalf("wrong=%v missing=%v", wrong, missing)
}
}
func TestGuessingIsBounded(t *testing.T) {
// Without this a public login form is an open brute-force target.
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
cfg := DefaultConfig()
cfg.MaxAttempts = 3
svc = New(st, cfg, slog.New(slog.DiscardHandler))
for i := range 3 {
if _, _, err := svc.SignIn(ctx, "ayse@example.com", "nope"); !errors.Is(err, ErrBadCredentials) {
t.Fatalf("attempt %d: %v", i, err)
}
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", "nope"); !errors.Is(err, ErrTooManyTries) {
t.Errorf("guessing was not bounded: %v", err)
}
// And a correct password is refused too while the window is open, or the
// limit would be trivially bypassed by interleaving.
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); !errors.Is(err, ErrTooManyTries) {
t.Errorf("the window did not apply to a correct password: %v", err)
}
}
func TestASuccessfulSignInClearsTheWindow(t *testing.T) {
// Otherwise a person who mistypes their password a few times is locked out
// for the rest of the window even after getting it right.
st := newStore(t)
hash, _ := HashPassword(goodPassword)
ctx := context.Background()
must(t, st.CreateUser(ctx, store.User{
ID: "usr-1", Email: "a@example.com", Name: "A",
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
}))
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
cfg := DefaultConfig()
cfg.MaxAttempts = 3
svc := New(st, cfg, slog.New(slog.DiscardHandler))
for range 2 {
_, _, _ = svc.SignIn(ctx, "a@example.com", "nope")
}
if _, _, err := svc.SignIn(ctx, "a@example.com", goodPassword); err != nil {
t.Fatal(err)
}
for range 3 {
if _, _, err := svc.SignIn(ctx, "a@example.com", "nope"); errors.Is(err, ErrTooManyTries) {
t.Fatal("the window was not cleared by a successful sign-in")
}
}
}
func TestCSRFTokensAreCheckedAgainstTheSession(t *testing.T) {
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
token, csrf, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
sess, _, _ := svc.Resolve(ctx, token)
if !svc.CheckCSRF(sess, csrf) {
t.Error("the session's own token was rejected")
}
for _, bad := range []string{"", "wrong", csrf + "x", csrf[:len(csrf)-1]} {
if svc.CheckCSRF(sess, bad) {
t.Errorf("%q was accepted as a csrf token", bad)
}
}
// A token from another session must not work in this one.
other, otherCSRF, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
_ = other
if svc.CheckCSRF(sess, otherCSRF) {
t.Error("another session's token was accepted")
}
}
func TestPasswordsAreHashedNotStored(t *testing.T) {
svc, st := newService(t)
ctx := context.Background()
must(t, svc.SignUp(ctx, "ayse@example.com", "A", goodPassword))
u, _ := st.UserByEmail(ctx, "ayse@example.com")
if strings.Contains(u.PasswordHash, goodPassword) {
t.Fatal("the password is in the database")
}
if !strings.HasPrefix(u.PasswordHash, "$argon2id$") {
t.Errorf("hash format: %q", u.PasswordHash)
}
ok, err := VerifyPassword(goodPassword, u.PasswordHash)
if err != nil || !ok {
t.Errorf("the hash does not verify: %v %v", ok, err)
}
if ok, _ := VerifyPassword("something else entirely", u.PasswordHash); ok {
t.Error("a wrong password verified")
}
}
func TestTwoHashesOfOnePasswordDiffer(t *testing.T) {
// A salt that did not vary would make the table a rainbow-table lookup.
a, _ := HashPassword(goodPassword)
b, _ := HashPassword(goodPassword)
if a == b {
t.Fatal("hashing is not salted")
}
}
func TestShortPasswordsAreRefused(t *testing.T) {
if _, err := HashPassword("short"); !errors.Is(err, ErrWeakPassword) {
t.Errorf("a five-character password was accepted: %v", err)
}
svc, _ := newService(t)
if err := svc.SignUp(context.Background(), "a@example.com", "A", "short"); !errors.Is(err, ErrWeakPassword) {
t.Errorf("signup accepted it: %v", err)
}
}
func TestMalformedHashesAreRejectedRatherThanMatched(t *testing.T) {
for _, bad := range []string{"", "plaintext", "$argon2id$", "$bcrypt$v=19$m=1,t=1,p=1$aa$bb"} {
if ok, err := VerifyPassword(goodPassword, bad); ok || err == nil {
t.Errorf("VerifyPassword(%q) = %v, %v", bad, ok, err)
}
}
}
func TestObviousNonAddressesAreRefused(t *testing.T) {
svc, _ := newService(t)
ctx := context.Background()
for _, bad := range []string{"", "ayse", "ayse@", "@example.com", "a b@example.com", "a@b", "a@@b.com"} {
if err := svc.SignUp(ctx, bad, "A", goodPassword); !errors.Is(err, ErrInvalidEmail) {
t.Errorf("SignUp(%q) = %v", bad, err)
}
}
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func TestChangingAPasswordNeedsTheOldOne(t *testing.T) {
// A session is what an attacker gets from a borrowed laptop or a stolen
// cookie. The password is what they do not have, and without this check the
// first thing they would do is take the account.
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
u, _ := st.UserByID(ctx, "usr-1")
if err := svc.ChangePassword(ctx, st, u, "not the password", "a brand new passphrase"); !errors.Is(err, ErrBadCredentials) {
t.Errorf("a wrong current password was accepted: %v", err)
}
// And the old one still works.
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); err != nil {
t.Errorf("the password changed anyway: %v", err)
}
}
func TestChangingAPasswordSignsOutEverywhere(t *testing.T) {
// Leaving other sessions open would mean a password change does nothing
// about the reason someone is changing it.
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
laptop, _, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
phone, _, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
u, _ := st.UserByID(ctx, "usr-1")
must(t, svc.ChangePassword(ctx, st, u, goodPassword, "a brand new passphrase"))
for name, token := range map[string]string{"laptop": laptop, "phone": phone} {
if _, _, err := svc.Resolve(ctx, token); !errors.Is(err, store.ErrNoSession) {
t.Errorf("the %s session survived the change: %v", name, err)
}
}
// The new password works and the old one does not.
if _, _, err := svc.SignIn(ctx, "ayse@example.com", "a brand new passphrase"); err != nil {
t.Errorf("the new password does not work: %v", err)
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); !errors.Is(err, ErrBadCredentials) {
t.Errorf("the old password still works: %v", err)
}
}
func TestANewPasswordMustActuallyBeNew(t *testing.T) {
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
u, _ := st.UserByID(ctx, "usr-1")
if err := svc.ChangePassword(ctx, st, u, goodPassword, goodPassword); !errors.Is(err, ErrSamePassword) {
t.Errorf("changing a password to itself: %v", err)
}
if err := svc.ChangePassword(ctx, st, u, goodPassword, "short"); !errors.Is(err, ErrWeakPassword) {
t.Errorf("a short new password was accepted: %v", err)
}
}
func TestGuessingTheCurrentPasswordIsBounded(t *testing.T) {
// Someone with a stolen session should not get unlimited attempts at the
// password that would let them keep it.
st := newStore(t)
ctx := context.Background()
hash, _ := HashPassword(goodPassword)
must(t, st.CreateUser(ctx, store.User{
ID: "usr-1", Email: "a@example.com", Name: "A",
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
}))
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
cfg := DefaultConfig()
cfg.MaxAttempts = 3
svc := New(st, cfg, slog.New(slog.DiscardHandler))
u, _ := st.UserByID(ctx, "usr-1")
for range 3 {
_ = svc.ChangePassword(ctx, st, u, "wrong", "a brand new passphrase")
}
if err := svc.ChangePassword(ctx, st, u, "wrong", "a brand new passphrase"); !errors.Is(err, ErrTooManyTries) {
t.Errorf("guessing was unbounded: %v", err)
}
}
+126
View File
@@ -0,0 +1,126 @@
// Package account is the human half of authentication: people sign up, an
// operator approves them, and they issue their own API keys from a profile page
// (plan §7.9).
//
// This is deliberately a different mechanism from the one in internal/auth.
// That one authenticates machines holding 256-bit random keys; this one
// authenticates people holding passwords they chose. The two have opposite
// requirements — a fast hash is correct there and wrong here — and keeping them
// apart is what stops one set of assumptions leaking into the other.
package account
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"runtime"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2 parameters. OWASP's current floor for argon2id, which costs roughly
// 50ms per attempt on ordinary hardware — slow enough to make an offline
// dictionary attack expensive, fast enough that a login does not feel broken.
//
// They are stored with each hash, so raising them later leaves existing
// passwords working until their owners next sign in.
const (
argonTime = 3
argonMemory = 64 * 1024 // KiB
argonThreads = 4
argonKeyLen = 32
saltLen = 16
)
// MinPasswordLen is the only password rule.
//
// Length is the one requirement that reliably buys entropy. Composition rules —
// a digit, a symbol, a capital — push people towards Password1! and produce
// less entropy than they cost, so there are none.
const MinPasswordLen = 12
// ErrWeakPassword says a password is too short to accept.
var ErrWeakPassword = fmt.Errorf("a password must be at least %d characters", MinPasswordLen)
// ErrBadHash says a stored hash could not be parsed.
var ErrBadHash = errors.New("unreadable password hash")
// HashPassword derives a storable hash.
func HashPassword(password string) (string, error) {
if len([]rune(password)) < MinPasswordLen {
return "", ErrWeakPassword
}
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
sum := idKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64 := base64.RawStdEncoding.EncodeToString
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, argonThreads, b64(salt), b64(sum)), nil
}
// VerifyPassword reports whether a password matches a stored hash.
//
// The comparison is constant time. The parameters come from the hash rather
// than from the constants above, so a hash written under older settings still
// verifies.
func VerifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, ErrBadHash
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version {
return false, ErrBadHash
}
var memory, time uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return false, ErrBadHash
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, ErrBadHash
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, ErrBadHash
}
got := idKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// dummyHash is verified against when no account matches, so a login attempt for
// an address that does not exist costs the same time as one that does.
//
// Without it, a stopwatch enumerates every account on the system.
var dummyHash string
func init() {
h, err := HashPassword(strings.Repeat("x", MinPasswordLen))
if err != nil {
panic("account: cannot build the dummy hash: " + err.Error())
}
dummyHash = h
}
// argonSlots bounds how many hashes run at once. Each takes 64 MiB, and the
// login and signup forms are on the client-facing port: without a bound, a
// few hundred parallel attempts at random addresses asked for gigabytes at
// once, and the per-address throttle does not see them — each address is new.
// Beyond the bound, attempts wait their turn instead.
var argonSlots = make(chan struct{}, max(2, runtime.NumCPU()/2))
func idKey(password, salt []byte, time, memory uint32, threads uint8, keyLen uint32) []byte {
argonSlots <- struct{}{}
defer func() { <-argonSlots }()
return argon2.IDKey(password, salt, time, memory, threads, keyLen)
}
+350
View File
@@ -0,0 +1,350 @@
package account
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base32"
"errors"
"log/slog"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// What a sign-in or sign-up can fail with.
var (
ErrBadCredentials = errors.New("that email and password do not match an account")
ErrNotApproved = errors.New("this account is waiting for approval")
ErrSuspended = errors.New("this account has been suspended")
ErrTooManyTries = errors.New("too many attempts; try again shortly")
ErrInvalidEmail = errors.New("that does not look like an email address")
)
// Store is what the service needs from persistence.
type Store interface {
CreateUser(ctx context.Context, u store.User) error
UserByEmail(ctx context.Context, email string) (store.User, error)
UserByID(ctx context.Context, id string) (store.User, error)
TouchLogin(ctx context.Context, id string, at time.Time) error
CreateSession(ctx context.Context, userID, token, csrf string, ttl time.Duration) error
SessionByToken(ctx context.Context, token string) (store.Session, store.User, error)
DeleteSession(ctx context.Context, token string) error
TouchSession(ctx context.Context, token string, at time.Time) error
}
// Config tunes the service.
type Config struct {
// SignupOpen decides whether anyone may create an account. Off, the form is
// not served at all; accounts are made with `llmgw users create`.
SignupOpen bool
// SessionTTL is how long a sign-in lasts.
SessionTTL time.Duration
// MaxAttempts and AttemptWindow bound password guessing per address.
MaxAttempts int
AttemptWindow time.Duration
}
// DefaultConfig returns sensible values.
func DefaultConfig() Config {
return Config{
SignupOpen: true,
SessionTTL: store.SessionTTL,
MaxAttempts: 10,
AttemptWindow: 15 * time.Minute,
}
}
func (c *Config) normalize() {
d := DefaultConfig()
if c.SessionTTL <= 0 {
c.SessionTTL = d.SessionTTL
}
if c.MaxAttempts <= 0 {
c.MaxAttempts = d.MaxAttempts
}
if c.AttemptWindow <= 0 {
c.AttemptWindow = d.AttemptWindow
}
}
// Service is signup, sign-in and sessions.
type Service struct {
store Store
cfg Config
log *slog.Logger
now func() time.Time
mu sync.Mutex
attempts map[string]*attemptWindow
}
type attemptWindow struct {
count int
since time.Time
}
// New builds the service.
func New(s Store, cfg Config, log *slog.Logger) *Service {
cfg.normalize()
if log == nil {
log = slog.Default()
}
return &Service{store: s, cfg: cfg, log: log, now: time.Now, attempts: map[string]*attemptWindow{}}
}
// SignupOpen reports whether the form should be served.
func (s *Service) SignupOpen() bool { return s.cfg.SignupOpen }
// SignUp creates a pending account.
//
// It returns nil whether or not the address was already taken. A signup form
// that says "this address already has an account" is an account enumerator that
// anyone can run, and the person signing up learns nothing they need: either
// way, what happens next is that they wait for approval.
func (s *Service) SignUp(ctx context.Context, email, name, password string) error {
email = store.NormalizeEmail(email)
if !validEmail(email) {
return ErrInvalidEmail
}
hash, err := HashPassword(password)
if err != nil {
return err
}
if name = strings.TrimSpace(name); name == "" {
name = email
}
err = s.store.CreateUser(ctx, store.User{
ID: randomID("usr"), Email: email, Name: name,
PasswordHash: hash, State: store.UserPending, CreatedAt: s.now(),
})
if errors.Is(err, store.ErrEmailTaken) {
// Logged so an operator can see it; not told to the caller.
s.log.Info("signup for an address that already has an account")
return nil
}
if err != nil {
return err
}
s.log.Info("a new account is waiting for approval", "email", email)
return nil
}
// SignIn checks a password and opens a session, returning the session token and
// the CSRF token that must accompany the forms in it.
func (s *Service) SignIn(ctx context.Context, email, password string) (token, csrf string, err error) {
email = store.NormalizeEmail(email)
if !s.allowAttempt(email) {
return "", "", ErrTooManyTries
}
u, err := s.store.UserByEmail(ctx, email)
if errors.Is(err, store.ErrNotFound) {
// Verify anyway, against a hash that cannot match. Returning early here
// would let a stopwatch enumerate every account on the system.
_, _ = VerifyPassword(password, dummyHash)
return "", "", ErrBadCredentials
}
if err != nil {
return "", "", err
}
ok, err := VerifyPassword(password, u.PasswordHash)
if err != nil || !ok {
return "", "", ErrBadCredentials
}
// The password was right, so this was not an attack.
s.clearAttempts(email)
// State is checked after the password, on purpose. Checking first would
// tell anyone who guesses an address whether it has a pending account.
switch u.State {
case store.UserPending:
return "", "", ErrNotApproved
case store.UserSuspended:
return "", "", ErrSuspended
}
if token, err = store.NewSessionToken(); err != nil {
return "", "", err
}
if csrf, err = store.NewSessionToken(); err != nil {
return "", "", err
}
if err := s.store.CreateSession(ctx, u.ID, token, csrf, s.cfg.SessionTTL); err != nil {
return "", "", err
}
if err := s.store.TouchLogin(ctx, u.ID, s.now()); err != nil {
s.log.Warn("could not record a sign-in", "err", err)
}
return token, csrf, nil
}
// Resolve turns a session cookie into the person holding it.
func (s *Service) Resolve(ctx context.Context, token string) (store.Session, store.User, error) {
if token == "" {
return store.Session{}, store.User{}, store.ErrNoSession
}
sess, u, err := s.store.SessionByToken(ctx, token)
if err != nil {
return store.Session{}, store.User{}, err
}
if err := s.store.TouchSession(ctx, token, s.now()); err != nil {
s.log.Debug("could not touch a session", "err", err)
}
return sess, u, nil
}
// SignOut closes one session.
func (s *Service) SignOut(ctx context.Context, token string) error {
if token == "" {
return nil
}
return s.store.DeleteSession(ctx, token)
}
// CheckCSRF compares a submitted token against the session's.
//
// A cookie authenticates the browser, not the page that asked. Without this, any
// site the person visits can post a form to ours and have the browser attach the
// session automatically.
func (s *Service) CheckCSRF(sess store.Session, submitted string) bool {
if submitted == "" || len(sess.CSRFHash) == 0 {
return false
}
return subtle.ConstantTimeCompare(store.HashKey(submitted), sess.CSRFHash) == 1
}
// allowAttempt bounds password guessing per address.
//
// Per address rather than per IP: an attacker has many addresses to come from
// and only one to guess against, and limiting by IP would lock out everyone
// behind one office NAT the moment somebody fat-fingers their password.
func (s *Service) allowAttempt(email string) bool {
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
w, ok := s.attempts[email]
if !ok && len(s.attempts) >= maxTrackedAddresses {
// Full of addresses nobody has signed in as for a while: forget the
// ones whose window has passed. Only expiring, never evicting early —
// dropping a live window is how an attacker spraying random addresses
// would buy unlimited guesses at the one they care about.
for k, v := range s.attempts {
if now.Sub(v.since) > s.cfg.AttemptWindow {
delete(s.attempts, k)
}
}
if len(s.attempts) >= maxTrackedAddresses {
// Still full, so somebody is spraying addresses faster than they
// expire. New ones wait; those already tracked are unaffected.
return false
}
}
if !ok || now.Sub(w.since) > s.cfg.AttemptWindow {
s.attempts[email] = &attemptWindow{count: 1, since: now}
return true
}
w.count++
return w.count <= s.cfg.MaxAttempts
}
// maxTrackedAddresses bounds the throttle's memory. Unbounded, it grew by one
// entry per address tried and shrank only on a successful sign-in.
const maxTrackedAddresses = 50000
func (s *Service) clearAttempts(email string) {
s.mu.Lock()
delete(s.attempts, email)
s.mu.Unlock()
}
// validEmail is deliberately crude. A real check is delivery, which this does
// not do; this only rejects what is obviously not an address so the database
// does not fill with them.
func validEmail(s string) bool {
at := strings.IndexByte(s, '@')
if at <= 0 || at != strings.LastIndexByte(s, '@') || at == len(s)-1 {
return false
}
if strings.ContainsAny(s, " \t\r\n<>\"") {
return false
}
return strings.Contains(s[at+1:], ".")
}
func randomID(prefix string) string {
var buf [10]byte
_, _ = rand.Read(buf[:])
return prefix + "-" + strings.ToLower(
base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]))
}
// ErrSamePassword says the new password is the old one.
var ErrSamePassword = errors.New("that is the password you already have")
// PasswordChanger is the extra storage a password change needs.
type PasswordChanger interface {
SetPassword(ctx context.Context, userID, hash string) error
DeleteUserSessions(ctx context.Context, userID string) error
}
// ChangePassword replaces a person's password and signs them out everywhere.
//
// The current password is required even though the caller already holds a
// session. A session is what an attacker gets from a borrowed laptop or a stolen
// cookie; the password is what they do not have, and without this check the
// first thing they would do is take the account.
//
// Every session is closed, this browser's included. Leaving the others open
// would mean a password change does nothing about the reason someone is
// changing it. The caller re-issues a session for the browser in front of them.
func (s *Service) ChangePassword(ctx context.Context, pw PasswordChanger,
u store.User, current, next string,
) error {
if !s.allowAttempt("pw:" + u.ID) {
return ErrTooManyTries
}
ok, err := VerifyPassword(current, u.PasswordHash)
if err != nil || !ok {
return ErrBadCredentials
}
s.clearAttempts("pw:" + u.ID)
if same, _ := VerifyPassword(next, u.PasswordHash); same {
return ErrSamePassword
}
hash, err := HashPassword(next)
if err != nil {
return err
}
if err := pw.SetPassword(ctx, u.ID, hash); err != nil {
return err
}
if err := pw.DeleteUserSessions(ctx, u.ID); err != nil {
return err
}
s.log.Info("a password was changed", "user", u.ID)
return nil
}
// OpenSession starts a session for a person who has just proved who they are by
// some other means — immediately after changing their password, which closed
// the one they were using.
func (s *Service) OpenSession(ctx context.Context, userID string) (token, csrf string, err error) {
if token, err = store.NewSessionToken(); err != nil {
return "", "", err
}
if csrf, err = store.NewSessionToken(); err != nil {
return "", "", err
}
if err := s.store.CreateSession(ctx, userID, token, csrf, s.cfg.SessionTTL); err != nil {
return "", "", err
}
return token, csrf, nil
}
+33
View File
@@ -0,0 +1,33 @@
package account
import (
"fmt"
"testing"
"time"
)
func TestSprayingAddressesCannotGrowTheThrottleOrEscapeIt(t *testing.T) {
now := time.Unix(0, 0)
s := &Service{cfg: DefaultConfig(), attempts: map[string]*attemptWindow{}, now: func() time.Time { return now }}
// The target is being guessed at.
for i := 0; i < s.cfg.MaxAttempts; i++ {
s.allowAttempt("victim@example.com")
}
// Then the table is flooded with made-up addresses.
for i := 0; i < maxTrackedAddresses+100; i++ {
s.allowAttempt(fmt.Sprintf("x%d@example.com", i))
}
if n := len(s.attempts); n > maxTrackedAddresses {
t.Errorf("the table grew to %d", n)
}
if s.allowAttempt("victim@example.com") {
t.Error("flooding the table reset the target's throttle")
}
// Once the window passes, everything expires and sign-ins work again.
now = now.Add(s.cfg.AttemptWindow + time.Second)
if !s.allowAttempt("new@example.com") {
t.Error("a new address was refused after the flood expired")
}
}
+421
View File
@@ -0,0 +1,421 @@
package accounting
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Refusals a key can hit. They are distinguished so the client is told which
// limit it hit, and so retrying is only suggested where retrying helps.
var (
ErrRateLimited = errors.New("request rate limit exceeded")
ErrTokenLimited = errors.New("token rate limit exceeded")
ErrBudgetSpent = errors.New("budget exhausted")
)
// LimitError carries which limit was hit and, where it makes sense, when to
// come back.
type LimitError struct {
Err error
// RetryAfter is zero when waiting does not help, which is the case for a
// spent budget: it will not refill until the period rolls over, and telling
// a client to retry in a second would be a lie.
RetryAfter time.Duration
}
func (e *LimitError) Error() string { return e.Err.Error() }
func (e *LimitError) Unwrap() error { return e.Err }
// Accountant enforces limits and records what was used.
//
// One object because the three halves are not independent: a budget check needs
// the price, the price needs the model, and the record closes the loop by
// feeding the counters that the next check reads.
type Accountant struct {
pricing *Pricing
budgets *Budgets
requests *Limiter
tokens *Limiter
recorder *Recorder
log *slog.Logger
}
// New builds an accountant.
func New(pricing *Pricing, recorder *Recorder, log *slog.Logger) *Accountant {
if log == nil {
log = slog.Default()
}
return &Accountant{
pricing: pricing,
budgets: NewBudgets(),
requests: NewLimiter(),
tokens: NewLimiter(),
recorder: recorder,
log: log,
}
}
// RestoreBudgets seeds the in-memory counters from the store.
//
// Called at startup. A restart must not reset a budget, which it would if the
// counters only ever lived in memory.
func (a *Accountant) RestoreBudgets(ctx context.Context, s store.Store, period store.Period) error {
start := PeriodStart(period, time.Now())
if start.IsZero() {
return nil
}
rows, err := s.SpendSinceByScope(ctx, start)
if err != nil {
return fmt.Errorf("restoring budgets: %w", err)
}
// Every scope a key sits under, not just the key. A budget owned by a team
// is one counter fed by every key beneath it, and restoring only per-key
// totals would bring the control plane back up believing the team had spent
// nothing — which is a budget that resets on every restart, for exactly the
// scopes where it matters most.
// And every pool a person is in: their own, and each per-member limit of
// the project, team and organization they spent it in.
spend := make(map[string]int64, len(rows)*8)
for _, r := range rows {
owners := []store.LimitOwner{
{Scope: store.ScopeKey, ID: r.KeyID},
{Scope: store.ScopeProject, ID: r.ProjectID},
{Scope: store.ScopeTeam, ID: r.TeamID},
{Scope: store.ScopeOrg, ID: r.OrgID},
{Scope: store.ScopeUser, ID: r.UserID},
}
if r.UserID != "" && r.ProjectID != "" {
owners = append(owners,
store.MemberOwner(store.ScopeProjectMember, r.ProjectID, r.UserID),
store.MemberOwner(store.ScopeTeamMember, r.TeamID, r.UserID),
store.MemberOwner(store.ScopeOrgMember, r.OrgID, r.UserID))
}
for _, o := range owners {
if o.ID == "" || strings.HasPrefix(o.ID, "/") {
continue
}
if k := o.CounterKey(); k != "" {
spend[k] += r.Micros
}
}
}
a.budgets.Restore(period, spend)
a.log.Info("restored budgets from the store",
"keys", len(rows), "counters", len(spend), "since", start)
return nil
}
// Reservation is what Check returns: the estimate it charged, so Record can
// settle the difference against the real figure.
// pool is one level's counter and the period its budget runs over.
type pool struct {
Counter string
Period store.Period
}
type Reservation struct {
KeyID string
// Budgets and Tokens are every counter the check went through, carried so
// the settlement lands on the same ones. Every, not the tightest: an
// organization's pool only fills up if each team's spend is added to it
// as well as to the team's own.
//
// Derived again at settlement would mean re-resolving the limits, and a
// budget checked against the team and settled against the key is a budget
// that never fills up.
Budgets []pool
Tokens []string
EstimatedTokens int64
Price Price
// Period is the key's budget period, carried from the check so the record
// counts against the same window the check read. Deriving it later would
// mean guessing, and a monthly budget credited to a daily counter is a
// budget that never binds.
Period store.Period
}
// Check decides whether a request may proceed.
//
// It runs before scheduling and touches no database: every counter it reads is
// in memory. The token limit is checked against an estimate, because the real
// figure only exists once the request has run; Record settles the difference.
func (a *Accountant) Check(p auth.Principal, model string, estimatedTokens int64) (Reservation, error) {
price := a.pricing.For(model)
lim := p.Limits
// Every level that set a limit, each against its own pool. A quota written
// on a team is one pool the team shares, and the organization above it is a
// second pool that the team's spending also fills — both have to hold, or
// an organization given 1000 with four teams given 500 spends 2000.
chain := levels(lim, p.KeyID)
res := Reservation{
KeyID: p.KeyID, EstimatedTokens: estimatedTokens,
Price: price, Period: lim.BudgetPeriod,
}
for _, l := range chain {
if l.Budget != nil {
res.Budgets = append(res.Budgets, pool{l.Counter, l.Period})
}
if l.TPM != nil {
res.Tokens = append(res.Tokens, l.Counter)
}
}
if p.KeyID == "" {
return res, nil
}
// Budget first. It is the limit whose refusal cannot be retried away, so
// telling the client about it first is more useful than reporting a rate
// limit it would hit again in a second.
//
// A model that costs nothing is exempt, which is what lets a key with no
// budget left still be served a cheap fallback (plan §7.7).
// Budgets first, and all of them. Exhausted only reads, so the order costs
// nothing and a refusal here charges no counter.
if !price.Zero() {
for _, l := range chain {
if l.Budget != nil && a.budgets.Exhausted(l.Counter, l.Period, *l.Budget) {
return res, &LimitError{Err: ErrBudgetSpent}
}
}
}
// Rate limits charge as they check — deliberately, so two concurrent
// requests cannot both see room. With several levels that means a refusal
// at one has already taken from the levels before it, so those are handed
// back. Without the refund a team at its ceiling would quietly eat its
// organization's rate limit with requests that were never served.
if err := a.allowAll(chain, func(l level) (string, int, int64, bool) {
return l.Counter, valInt(l.RPM), 1, l.RPM != nil
}, a.requests); err != nil {
return res, &LimitError{Err: ErrRateLimited, RetryAfter: time.Second}
}
if err := a.allowAll(chain, func(l level) (string, int, int64, bool) {
return l.Counter, valInt(l.TPM), estimatedTokens, l.TPM != nil
}, a.tokens); err != nil {
// The request was not charged against the request-rate limit in vain:
// it was, and that is correct — it did arrive.
return res, &LimitError{Err: ErrTokenLimited, RetryAfter: time.Second}
}
return res, nil
}
// level is one scope's limits with the counter they are enforced against.
type level struct {
Counter string
Period store.Period
RPM *int
TPM *int
Budget *int64
}
// levels turns a resolved chain into the pools a request must pass.
//
// Falls back to the collapsed values when the chain is empty, which is what a
// principal assembled by hand looks like — in tests, and anywhere that builds
// limits without walking the tree.
func levels(e store.Effective, keyID string) []level {
if len(e.Chain) == 0 {
var out []level
if e.RPM != nil || e.TPM != nil || e.Budget != nil {
out = append(out, level{
Counter: counterFor(e.BudgetOwner, keyID),
Period: e.BudgetPeriod, Budget: e.Budget,
})
out[0].RPM, out[0].TPM = e.RPM, e.TPM
// One entry, so a hand-built principal with different owners per
// limit still counts each where it said to.
if r, b := counterFor(e.RPMOwner, keyID), out[0].Counter; r != b && e.RPM != nil {
out[0].RPM = nil
out = append(out, level{Counter: r, RPM: e.RPM})
}
if tp, b := counterFor(e.TPMOwner, keyID), out[0].Counter; tp != b && e.TPM != nil {
out[0].TPM = nil
out = append(out, level{Counter: tp, TPM: e.TPM})
}
}
return out
}
out := make([]level, 0, len(e.Chain))
for _, l := range e.Chain {
out = append(out, level{
Counter: store.LimitOwner{Scope: l.Scope, ID: l.ScopeID}.CounterKey(),
Period: l.BudgetPeriod, RPM: l.RPM, TPM: l.TPM, Budget: l.Budget,
})
}
return out
}
// allowAll charges every level that has this limit, refunding on a refusal.
func (a *Accountant) allowAll(chain []level,
pick func(level) (key string, limit int, n int64, has bool), lim *Limiter,
) error {
var charged []struct {
key string
n int64
}
for _, l := range chain {
key, limit, n, has := pick(l)
if !has {
continue
}
if !lim.Allow(key, limit, n) {
for _, c := range charged {
lim.Charge(c.key, -c.n)
}
return errRefused
}
charged = append(charged, struct {
key string
n int64
}{key, n})
}
return nil
}
var errRefused = errors.New("refused")
func valInt(v *int) int {
if v == nil {
return 0
}
return *v
}
// Record settles a finished request: it reconciles the token estimate, adds to
// the budget, and queues the usage row.
func (a *Accountant) Record(res Reservation, u store.Usage) {
if res.KeyID == "" {
return
}
actual := u.PromptTokens + u.CompletionTokens
// The estimate was charged up front; charge the difference now. A caller
// whose requests are consistently larger than estimated is still bounded,
// one window later than with a perfect estimate.
// Every level the check went through, so an organization's pool fills up
// from its teams' spending rather than only from requests that named it.
for _, c := range res.Tokens {
a.tokens.Charge(c, actual-res.EstimatedTokens)
}
u.CostMicros = res.Price.Cost(u.PromptTokens, u.CompletionTokens)
for _, b := range res.Budgets {
a.budgets.Add(b.Counter, b.Period, u.CostMicros)
}
if a.recorder != nil {
a.recorder.Record(u)
}
}
// Status returns what a key has used, for headers and `ctl`.
func (a *Accountant) Status(p auth.Principal) (spent int64, rpm, tpm float64) {
if p.KeyID == "" {
return 0, 0, 0
}
// The pool that will refuse them, which is not always the one with the
// smallest number on it. A team given 2000 inside an organization given the
// same is bounded by whichever is nearer its ceiling, and that is the
// organization as soon as a second team spends anything. A header that
// reported the team's would disagree with the refusal that follows it.
for _, l := range levels(p.Limits, p.KeyID) {
if l.Budget != nil {
if v := a.budgets.Spent(l.Counter, l.Period); v > spent {
spent = v
}
}
if l.RPM != nil {
if v := a.requests.Rate(l.Counter); v > rpm {
rpm = v
}
}
if l.TPM != nil {
if v := a.tokens.Rate(l.Counter); v > tpm {
tpm = v
}
}
}
return spent, rpm, tpm
}
// counterFor is the counter a limit owned by this scope should use.
//
// Falls back to the key's own counter when no scope owns the limit, which
// happens when nothing in the chain set one.
//
// Namespaced even then. A bare key id here and a "key:"-prefixed one in the
// restore path would be two different counters for the same key, so a budget
// would check against an empty one and survive every restart — which is how
// this was wrong when it was first written.
func counterFor(o store.LimitOwner, keyID string) string {
if k := o.CounterKey(); k != "" {
return k
}
return keyCounter(keyID)
}
// keyCounter is a key's own counter, in the same namespace as every other.
func or(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
func keyCounter(keyID string) string {
if keyID == "" {
return ""
}
return store.LimitOwner{Scope: store.ScopeKey, ID: keyID}.CounterKey()
}
// AnythingPriced reports whether a budget can ever be spent against.
//
// On the accountant rather than making callers reach for the price table: what
// they are asking is whether the budgets they are about to display mean
// anything, and the accountant is what would enforce them.
func (a *Accountant) AnythingPriced() bool { return a.pricing.AnythingPriced() }
// Headroom is the tightest of a principal's request and token limits: how
// much is left of each, and when the minute it is counted over rolls on.
type Headroom struct {
RequestLimit, RequestsLeft int
TokenLimit, TokensLeft int
Reset time.Duration
}
// Headroom reports what a principal has left, after whatever Check just
// charged. Of several levels the one with least left is the one that will
// refuse first, so that is the one reported. A limit of 0 means none is set.
func (a *Accountant) Headroom(p auth.Principal) Headroom {
var h Headroom
firstReq, firstTok := true, true
for _, l := range levels(p.Limits, p.KeyID) {
if l.RPM != nil && *l.RPM > 0 {
left := max(0, *l.RPM-int(a.requests.Rate(l.Counter)))
if firstReq || left < h.RequestsLeft {
h.RequestLimit, h.RequestsLeft, firstReq = *l.RPM, left, false
}
}
if l.TPM != nil && *l.TPM > 0 {
left := max(0, *l.TPM-int(a.tokens.Rate(l.Counter)))
if firstTok || left < h.TokensLeft {
h.TokenLimit, h.TokensLeft, firstTok = *l.TPM, left, false
}
}
}
// The windows slide, so room comes back continuously; the next minute
// boundary is when a whole window's worth has moved on, and is the figure
// a client pacing itself can act on.
now := time.Now()
h.Reset = now.Truncate(windowLen).Add(windowLen).Sub(now)
return h
}
+660
View File
@@ -0,0 +1,660 @@
package accounting
import (
"context"
"errors"
"log/slog"
"path/filepath"
"sync"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func intp(v int) *int { return &v }
func i64p(v int64) *int64 { return &v }
func principal(id string, tune func(*store.Effective)) auth.Principal {
e := store.Effective{KeyID: id, ProjectID: "proj"}
if tune != nil {
tune(&e)
}
return auth.Principal{KeyID: id, ProjectID: "proj", Limits: e}
}
func pricing(t *testing.T) *Pricing {
t.Helper()
return NewPricing(map[string]Price{
"paid": {PromptPer1k: 1000, CompletionPer1k: 3000},
"free": {},
}, Price{})
}
func TestCostRoundsUpSoSmallRequestsAreNotFree(t *testing.T) {
// Rounding down would make a stream of tiny requests cost nothing, which is
// exactly the traffic shape a quota exists to bound.
p := Price{PromptPer1k: 1000, CompletionPer1k: 3000}
if got := p.Cost(1, 0); got != 1 {
t.Errorf("one prompt token cost %d", got)
}
if got := p.Cost(1000, 1000); got != 4000 {
t.Errorf("a thousand of each cost %d, want 4000", got)
}
if got := p.Cost(0, 0); got != 0 {
t.Errorf("an empty request cost %d", got)
}
}
func TestZeroPricedModelsStayAvailableWhenTheBudgetIsSpent(t *testing.T) {
// This is what makes budget fallback work: a key with nothing left can
// still be served something that costs nothing.
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
p := principal("k1", func(e *store.Effective) {
e.Budget = i64p(100)
e.BudgetPeriod = store.PeriodMonth
})
res, err := a.Check(p, "paid", 10)
if err != nil {
t.Fatal(err)
}
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 1000, CompletionTokens: 0})
if _, err := a.Check(p, "paid", 10); !errors.Is(err, ErrBudgetSpent) {
t.Errorf("a spent budget still served a paid model: %v", err)
}
if _, err := a.Check(p, "free", 10); err != nil {
t.Errorf("a spent budget refused a free model: %v", err)
}
}
func TestBudgetRefusalDoesNotSuggestRetrying(t *testing.T) {
// A budget does not refill until the period rolls over, so a Retry-After of
// a second would be a lie that turns one client into a retry loop.
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
p := principal("k1", func(e *store.Effective) {
e.Budget = i64p(1)
e.BudgetPeriod = store.PeriodDay
})
res, _ := a.Check(p, "paid", 10)
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 5000})
_, err := a.Check(p, "paid", 10)
var le *LimitError
if !errors.As(err, &le) {
t.Fatalf("expected a limit error, got %v", err)
}
if le.RetryAfter != 0 {
t.Errorf("a spent budget suggested retrying in %v", le.RetryAfter)
}
}
func TestRateLimitsBind(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
p := principal("k1", func(e *store.Effective) { e.RPM = intp(3) })
for i := range 3 {
if _, err := a.Check(p, "free", 1); err != nil {
t.Fatalf("request %d was refused: %v", i, err)
}
}
if _, err := a.Check(p, "free", 1); !errors.Is(err, ErrRateLimited) {
t.Errorf("the fourth request was allowed: %v", err)
}
}
func TestTokenLimitsUseTheEstimateAndThenSettle(t *testing.T) {
// The real token count only exists once the request has run, so the check
// has to work on an estimate. A caller that consistently underestimates
// must still end up bounded.
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
p := principal("k1", func(e *store.Effective) { e.TPM = intp(1000) })
// Claims 10 tokens, actually uses 900.
res, err := a.Check(p, "free", 10)
if err != nil {
t.Fatal(err)
}
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 400, CompletionTokens: 500})
// The settled figure is what the next check sees, so a second request of
// the same shape cannot fit.
if _, err := a.Check(p, "free", 200); !errors.Is(err, ErrTokenLimited) {
t.Errorf("the underestimate was never settled: %v", err)
}
}
func TestNoLimitIsNotALimitOfZero(t *testing.T) {
// The distinction the store keeps with a nil pointer. Getting it wrong here
// would silently forbid every request for every key with no limits set.
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
p := principal("k1", nil)
for range 50 {
if _, err := a.Check(p, "paid", 10_000); err != nil {
t.Fatalf("a key with no limits was refused: %v", err)
}
}
}
func TestAnUnauthenticatedRequestIsNotAccounted(t *testing.T) {
// With auth off there is no key to charge, and Check must not refuse.
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
res, err := a.Check(auth.Principal{}, "paid", 1000)
if err != nil {
t.Fatal(err)
}
a.Record(res, store.Usage{PromptTokens: 1000})
if spent, _, _ := a.Status(auth.Principal{}); spent != 0 {
t.Errorf("an anonymous request was charged %d", spent)
}
}
func TestBudgetSurvivesARestart(t *testing.T) {
// The counters live in memory; the store is what makes them survive.
ctx := context.Background()
path := filepath.Join(t.TempDir(), "acct.db")
s, err := store.OpenSQLite(ctx, path)
if err != nil {
t.Fatal(err)
}
defer s.Close()
must(t, s.PutOrg(ctx, store.Org{ID: "o", Name: "o"}))
must(t, s.PutTeam(ctx, store.Team{ID: "t", OrgID: "o", Name: "t"}))
must(t, s.PutProject(ctx, store.Project{ID: "proj", TeamID: "t", Name: "p"}))
_, k, _ := store.NewKey("k1", "n", "proj")
k.CreatedAt = time.Now()
must(t, s.CreateKey(ctx, k))
must(t, s.WriteUsage(ctx, []store.Usage{
{At: time.Now(), KeyID: "k1", ProjectID: "proj", Model: "paid",
ServedModel: "paid", CostMicros: 5000, Status: 200},
}))
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
must(t, a.RestoreBudgets(ctx, s, store.PeriodMonth))
p := principal("k1", func(e *store.Effective) {
e.Budget = i64p(5000)
e.BudgetPeriod = store.PeriodMonth
})
if _, err := a.Check(p, "paid", 10); !errors.Is(err, ErrBudgetSpent) {
t.Errorf("the budget was reset by the restart: %v", err)
}
}
func TestBudgetsResetAtThePeriodBoundary(t *testing.T) {
b := NewBudgets()
now := time.Date(2026, 1, 31, 23, 59, 0, 0, time.UTC)
b.now = func() time.Time { return now }
b.Add("k1", store.PeriodMonth, 900)
if got := b.Spent("k1", store.PeriodMonth); got != 900 {
t.Fatalf("spent %d", got)
}
now = now.Add(2 * time.Minute) // into February
if got := b.Spent("k1", store.PeriodMonth); got != 0 {
t.Errorf("the counter carried %d across the month boundary", got)
}
}
func TestDailyAndMonthlyBoundariesDiffer(t *testing.T) {
b := NewBudgets()
now := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC)
b.now = func() time.Time { return now }
b.Add("daily", store.PeriodDay, 100)
b.Add("monthly", store.PeriodMonth, 100)
now = now.Add(24 * time.Hour)
if got := b.Spent("daily", store.PeriodDay); got != 0 {
t.Errorf("the daily counter carried %d across a day", got)
}
if got := b.Spent("monthly", store.PeriodMonth); got != 100 {
t.Errorf("the monthly counter lost %d within a month", 100-got)
}
}
func TestTheWindowDoesNotAllowTwiceTheLimit(t *testing.T) {
// The reason for a sliding window. With a fixed one, a caller sends its
// whole allowance at the end of a window and again at the start of the
// next, so the observed rate is double the limit exactly when the system is
// least able to take it.
l := NewLimiter()
base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
now := base.Add(59 * time.Second)
l.now = func() time.Time { return now }
const limit = 60
sent := 0
for range limit {
if l.Allow("k", limit, 1) {
sent++
}
}
if sent != limit {
t.Fatalf("the first burst only managed %d", sent)
}
// One second later, in the next fixed window.
now = base.Add(61 * time.Second)
second := 0
for range limit {
if l.Allow("k", limit, 1) {
second++
}
}
// Some allowance returns as the old window slides out of view, but nowhere
// near the whole of it.
if second > limit/2 {
t.Errorf("a second burst of %d passed immediately after %d; the window is not sliding",
second, sent)
}
}
func TestLimiterIsSafeUnderConcurrency(t *testing.T) {
// Checking and charging have to be one operation, or two concurrent
// requests both see room and both take it.
l := NewLimiter()
const limit = 100
var wg sync.WaitGroup
var mu sync.Mutex
allowed := 0
for range 500 {
wg.Add(1)
go func() {
defer wg.Done()
if l.Allow("k", limit, 1) {
mu.Lock()
allowed++
mu.Unlock()
}
}()
}
wg.Wait()
if allowed != limit {
t.Errorf("%d requests passed a limit of %d", allowed, limit)
}
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// teamMember is a key whose budget and rate limit were set on its team, which
// is the arrangement an enterprise actually asks for: a group gets a quota and
// the people in it share it.
func teamMember(keyID, teamID string, tune func(*store.Effective)) auth.Principal {
e := store.Effective{KeyID: keyID, ProjectID: "proj", TeamID: teamID}
owner := store.LimitOwner{Scope: store.ScopeTeam, ID: teamID}
e.BudgetOwner, e.RPMOwner, e.TPMOwner = owner, owner, owner
if tune != nil {
tune(&e)
}
return auth.Principal{KeyID: keyID, ProjectID: "proj", Limits: e}
}
func TestATeamBudgetIsOnePoolRatherThanOnePerMember(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
budget := func(e *store.Effective) {
e.Budget = i64p(3000)
e.BudgetPeriod = store.PeriodMonth
}
first := teamMember("k1", "t1", budget)
second := teamMember("k2", "t1", budget)
// One member spends the lot.
res, err := a.Check(first, "paid", 1000)
if err != nil {
t.Fatal(err)
}
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 1000, CompletionTokens: 1000})
// The second must now be refused. Counted per key it would have its own
// untouched 3000 — a team budget of 3000 with ten keys would permit thirty
// thousand, which is the bug this change exists for.
if _, err := a.Check(second, "paid", 10); !errors.Is(err, ErrBudgetSpent) {
t.Errorf("the team's budget should be shared; the second member got %v", err)
}
}
func TestATeamRateLimitIsSharedToo(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
rpm := func(e *store.Effective) { e.RPM = intp(1) }
if _, err := a.Check(teamMember("k1", "t1", rpm), "free", 1); err != nil {
t.Fatal(err)
}
if _, err := a.Check(teamMember("k2", "t1", rpm), "free", 1); !errors.Is(err, ErrRateLimited) {
t.Error("a rate limit set on the team should be shared by its keys")
}
}
func TestTwoTeamsDoNotShareACounter(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
rpm := func(e *store.Effective) { e.RPM = intp(1) }
if _, err := a.Check(teamMember("k1", "t1", rpm), "free", 1); err != nil {
t.Fatal(err)
}
// Pooling is the point; pooling the wrong things is the danger. One team
// exhausting another's quota would be worse than the bug being fixed.
if _, err := a.Check(teamMember("k2", "t2", rpm), "free", 1); err != nil {
t.Errorf("a second team should have its own counter, got %v", err)
}
}
func TestAKeyLimitStaysPerKey(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
perKey := func(id string) auth.Principal {
return principal(id, func(e *store.Effective) {
e.RPM = intp(1)
e.RPMOwner = store.LimitOwner{Scope: store.ScopeKey, ID: id}
})
}
if _, err := a.Check(perKey("k1"), "free", 1); err != nil {
t.Fatal(err)
}
// The tree still has to be able to say "each of you may do this much".
// Sharing a group pool must not become the only thing expressible.
if _, err := a.Check(perKey("k2"), "free", 1); err != nil {
t.Errorf("a limit owned by the key should not be shared, got %v", err)
}
}
func TestTheDifferentLimitsCanBeOwnedByDifferentScopes(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
// A team budget with a per-key rate limit is the ordinary arrangement: the
// group's money, the individual's throughput.
mk := func(id string) auth.Principal {
e := store.Effective{KeyID: id, TeamID: "t1"}
e.Budget, e.BudgetPeriod = i64p(3000), store.PeriodMonth
e.BudgetOwner = store.LimitOwner{Scope: store.ScopeTeam, ID: "t1"}
e.RPM = intp(1)
e.RPMOwner = store.LimitOwner{Scope: store.ScopeKey, ID: id}
return auth.Principal{KeyID: id, Limits: e}
}
res, err := a.Check(mk("k1"), "paid", 1000)
if err != nil {
t.Fatal(err)
}
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 1000, CompletionTokens: 1000})
// k2's rate limit is its own and untouched; the budget is the team's and
// gone. The budget is checked first, so that is what comes back.
if _, err := a.Check(mk("k2"), "paid", 10); !errors.Is(err, ErrBudgetSpent) {
t.Errorf("expected the shared budget to bite, got %v", err)
}
}
func TestStatusReportsTheCounterThatWillRefuseYou(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
budget := func(e *store.Effective) {
e.Budget = i64p(9000)
e.BudgetPeriod = store.PeriodMonth
}
res, err := a.Check(teamMember("k1", "t1", budget), "paid", 1000)
if err != nil {
t.Fatal(err)
}
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 1000, CompletionTokens: 1000})
// A member who has spent nothing themselves still sees the team's spend,
// because that is the number that will stop them. A header disagreeing with
// the refusal it precedes is worse than no header.
spent, _, _ := a.Status(teamMember("k2", "t1", budget))
if spent == 0 {
t.Error("a member should see the pool's spend, not their own")
}
}
func TestATeamBudgetSurvivesARestart(t *testing.T) {
// The per-key restore could not do this: it rebuilt a counter named after
// the key, while the check reads one named after the team. The control
// plane came back believing the team had spent nothing — a budget that
// resets on every restart, for exactly the scope where it matters most.
ctx := context.Background()
s, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "team.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
must(t, s.PutOrg(ctx, store.Org{ID: "o", Name: "o"}))
must(t, s.PutTeam(ctx, store.Team{ID: "t1", OrgID: "o", Name: "t"}))
must(t, s.PutProject(ctx, store.Project{ID: "proj", TeamID: "t1", Name: "p"}))
for _, id := range []string{"k1", "k2"} {
_, k, _ := store.NewKey(id, "n", "proj")
k.CreatedAt = time.Now()
must(t, s.CreateKey(ctx, k))
}
// Two members of one team, 2500 each: neither alone exceeds a 4000 budget,
// together they do.
must(t, s.WriteUsage(ctx, []store.Usage{
{At: time.Now(), KeyID: "k1", ProjectID: "proj", Model: "paid",
ServedModel: "paid", CostMicros: 2500, Status: 200},
{At: time.Now(), KeyID: "k2", ProjectID: "proj", Model: "paid",
ServedModel: "paid", CostMicros: 2500, Status: 200},
}))
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
must(t, a.RestoreBudgets(ctx, s, store.PeriodMonth))
p := teamMember("k1", "t1", func(e *store.Effective) {
e.Budget = i64p(4000)
e.BudgetPeriod = store.PeriodMonth
})
if _, err := a.Check(p, "paid", 10); !errors.Is(err, ErrBudgetSpent) {
t.Errorf("the team's spend should have been restored and pooled: %v", err)
}
}
// inTree is a key under org/team/project, with whatever each level sets.
func inTree(keyID string, chain ...store.Limits) auth.Principal {
e := store.Effective{KeyID: keyID, Chain: chain}
// The collapsed values still exist for headers and the profile page; they
// are not what enforcement reads any more.
for _, l := range chain {
if l.Budget != nil && (e.Budget == nil || *l.Budget < *e.Budget) {
e.Budget, e.BudgetPeriod = l.Budget, l.BudgetPeriod
e.BudgetOwner = store.LimitOwner{Scope: l.Scope, ID: l.ScopeID}
}
}
return auth.Principal{KeyID: keyID, Limits: e}
}
func orgBudget(id string, v int64) store.Limits {
return store.Limits{Scope: store.ScopeOrg, ScopeID: id,
Budget: i64p(v), BudgetPeriod: store.PeriodMonth}
}
func teamBudget(id string, v int64) store.Limits {
return store.Limits{Scope: store.ScopeTeam, ScopeID: id,
Budget: i64p(v), BudgetPeriod: store.PeriodMonth}
}
func TestTheSumOfTeamsCannotExceedTheOrganization(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
// Each request costs 800. The organization allows 2000 and each of its four
// teams allows 2000, so no team comes close to its own ceiling — but the
// organization's fills after three. Taking only the most restrictive value,
// the organization's number was never looked at and all four went through.
//
// A budget stops spending once it is reached rather than refusing the
// request that would cross it, so the third is served and the fourth is not.
spend := func(team string) error {
p := inTree(team+"-k", orgBudget("o", 2000), teamBudget(team, 2000))
res, err := a.Check(p, "paid", 1000)
if err != nil {
return err
}
a.Record(res, store.Usage{KeyID: team + "-k", PromptTokens: 200, CompletionTokens: 200})
return nil
}
total := 0
for _, team := range []string{"t1", "t2", "t3", "t4"} {
if err := spend(team); err == nil {
total++
}
}
if total != 3 {
t.Errorf("expected the organization to stop the fourth team, %d got through", total)
}
}
func TestATeamStillStopsBeforeItsOrganizationDoes(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
p := func() auth.Principal {
return inTree("k1", orgBudget("o", 100_000), teamBudget("t1", 800))
}
res, err := a.Check(p(), "paid", 1000)
if err != nil {
t.Fatal(err)
}
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 200, CompletionTokens: 200})
// The narrower pool binds first, which is what it is for. Enforcing every
// level must not stop the tighter one working.
if _, err := a.Check(p(), "paid", 10); !errors.Is(err, ErrBudgetSpent) {
t.Errorf("the team's own budget should still bite, got %v", err)
}
}
func TestSpendingFillsEveryPoolAboveIt(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
p := inTree("k1", orgBudget("o", 5000), teamBudget("t1", 5000))
res, err := a.Check(p, "paid", 1000)
if err != nil {
t.Fatal(err)
}
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 200, CompletionTokens: 200})
// Settled onto both, or the organization's pool would only ever fill from
// requests that named it directly — which is none of them.
if got := a.budgets.Spent("org:o", store.PeriodMonth); got == 0 {
t.Error("the organization's pool did not fill")
}
if got := a.budgets.Spent("team:t1", store.PeriodMonth); got == 0 {
t.Error("the team's pool did not fill")
}
}
func TestARefusalHandsBackWhatTheNarrowerLevelsTook(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
// The team allows plenty; the organization allows one request a minute.
chain := []store.Limits{
{Scope: store.ScopeOrg, ScopeID: "o", RPM: intp(1)},
{Scope: store.ScopeTeam, ScopeID: "t1", RPM: intp(100)},
}
if _, err := a.Check(inTree("k1", chain...), "free", 1); err != nil {
t.Fatal(err)
}
before := a.requests.Rate("team:t1")
if _, err := a.Check(inTree("k2", chain...), "free", 1); !errors.Is(err, ErrRateLimited) {
t.Fatalf("the organization should have refused the second, got %v", err)
}
// A rate limiter charges as it checks, so a refusal further along has
// already taken from the levels before it. Left there, a team at its
// organization's ceiling would quietly eat its own rate limit with requests
// that were never served.
if after := a.requests.Rate("team:t1"); after != before {
t.Errorf("the refused request stayed charged to the team: %v -> %v", before, after)
}
}
func TestAChainWithNoLimitsAnywhereAllowsEverything(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
for i := 0; i < 5; i++ {
if _, err := a.Check(inTree("k1"), "paid", 1000); err != nil {
t.Fatalf("nothing is limited, but request %d was refused: %v", i, err)
}
}
}
func TestStatusReportsTheFullestPoolNotTheSmallestNumber(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
chain := func(team string) auth.Principal {
return inTree(team+"-k", orgBudget("o", 9000), teamBudget(team, 9000))
}
// Another team spends; this one has spent nothing of its own.
res, err := a.Check(chain("t1"), "paid", 1000)
if err != nil {
t.Fatal(err)
}
a.Record(res, store.Usage{KeyID: "t1-k", PromptTokens: 200, CompletionTokens: 200})
spent, _, _ := a.Status(chain("t2"))
// The organization's pool is the one nearer its ceiling, so it is the one
// that will refuse them. Reporting the team's nil would disagree with the
// refusal that eventually follows it.
if spent == 0 {
t.Error("a member should see the fullest pool that bounds them")
}
}
func TestAnEmptyPriceTableMeansNoBudgetCanEverBind(t *testing.T) {
// The ordinary case for self-hosted models, and the reason a budget on a
// team can read as a limit while binding on nothing.
if NewPricing(nil, Price{}).AnythingPriced() {
t.Error("nothing priced should say so")
}
if NewPricing(map[string]Price{"m": {}}, Price{}).AnythingPriced() {
t.Error("a model priced at zero is not priced")
}
if !NewPricing(map[string]Price{"m": {PromptPer1k: 1}}, Price{}).AnythingPriced() {
t.Error("one priced model is enough for a budget to move")
}
if !NewPricing(nil, Price{CompletionPer1k: 1}).AnythingPriced() {
t.Error("a fallback price prices everything")
}
var nilPricing *Pricing
if nilPricing.AnythingPriced() {
t.Error("no price table at all is not priced")
}
}
func TestHeadroomIsTheTightestLimitAfterThisRequest(t *testing.T) {
// A project allowing 100 a minute under a team allowing 3: the team is
// what will refuse, so the team is what a client is told about.
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
p := principal("k1", func(e *store.Effective) {
e.Chain = []store.Limits{
{Scope: store.ScopeProject, ScopeID: "proj", RPM: intp(100), TPM: intp(50000)},
{Scope: store.ScopeTeam, ScopeID: "team", RPM: intp(3)},
}
})
if _, err := a.Check(p, "m", 10); err != nil {
t.Fatal(err)
}
h := a.Headroom(p)
if h.RequestLimit != 3 || h.RequestsLeft != 2 {
t.Errorf("requests %d left of %d; expected 2 of the team's 3", h.RequestsLeft, h.RequestLimit)
}
if h.TokenLimit != 50000 || h.TokensLeft != 50000-10 {
t.Errorf("tokens %d left of %d", h.TokensLeft, h.TokenLimit)
}
if h.Reset <= 0 || h.Reset > time.Minute {
t.Errorf("reset %v", h.Reset)
}
if none := a.Headroom(principal("k2", nil)); none.RequestLimit != 0 || none.TokenLimit != 0 {
t.Errorf("a key with no limits advertised some: %+v", none)
}
}
+101
View File
@@ -0,0 +1,101 @@
package accounting
import (
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// PeriodStart is when the current budget period began.
//
// Periods are anchored to UTC rather than to a per-key start date, so every
// key's day and month boundary is the same instant. A key whose period started
// when it was created would make "spend this month" mean something different
// for every key, and make the restore query unanswerable in one pass.
func PeriodStart(p store.Period, now time.Time) time.Time {
u := now.UTC()
switch p {
case store.PeriodDay:
return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
case store.PeriodMonth:
return time.Date(u.Year(), u.Month(), 1, 0, 0, 0, 0, time.UTC)
default:
return time.Time{}
}
}
// Budgets tracks spend per key in memory, so a budget check costs no query.
//
// The database catches up through the batch writer. That means the in-memory
// figure leads the stored one, which is the right direction: a budget that only
// counted what had already been written would let a burst overspend by however
// much the writer was behind.
type Budgets struct {
now func() time.Time
mu sync.Mutex
spend map[string]counter
}
type counter struct {
micros int64
since time.Time
}
// NewBudgets builds an empty tracker.
func NewBudgets() *Budgets {
return &Budgets{now: time.Now, spend: map[string]counter{}}
}
// Restore seeds the counters from what the store already recorded.
//
// A restart must not reset a budget, which it would if the counter lived only
// in memory. The caller passes the spend since each period's start.
func (b *Budgets) Restore(period store.Period, spend map[string]int64) {
start := PeriodStart(period, b.now())
b.mu.Lock()
defer b.mu.Unlock()
for key, micros := range spend {
b.spend[key] = counter{micros: micros, since: start}
}
}
// Spent returns a key's spend in the current period, resetting it if the period
// has rolled over.
func (b *Budgets) Spent(keyID string, period store.Period) int64 {
start := PeriodStart(period, b.now())
b.mu.Lock()
defer b.mu.Unlock()
return b.currentLocked(keyID, start).micros
}
// Add records spend.
func (b *Budgets) Add(keyID string, period store.Period, micros int64) {
if micros == 0 {
return
}
start := PeriodStart(period, b.now())
b.mu.Lock()
defer b.mu.Unlock()
c := b.currentLocked(keyID, start)
c.micros += micros
b.spend[keyID] = c
}
// Exhausted reports whether a key has spent its budget.
func (b *Budgets) Exhausted(keyID string, period store.Period, budget int64) bool {
return b.Spent(keyID, period) >= budget
}
// currentLocked returns the counter, rolled over if the period changed.
func (b *Budgets) currentLocked(keyID string, start time.Time) counter {
c, ok := b.spend[keyID]
if !ok || c.since.Before(start) {
// Either never seen, or the period rolled over while the process was
// running. Both start from zero.
c = counter{since: start}
b.spend[keyID] = c
}
return c
}
+97
View File
@@ -0,0 +1,97 @@
// Package accounting turns finished requests into spend, enforces what a key
// is allowed, and gets both into the store without touching the request path
// (plan §7.9.3, §7.9.4).
package accounting
import (
"strings"
"sync"
)
// Price is what one model costs, in micro-units per thousand tokens.
//
// Micro-units rather than a float because spend is accumulated by repeated
// addition, and repeated addition of floats drifts. What a unit means is the
// operator's: self-hosted models have no per-token price, so it may be
// GPU-seconds, a notional rate, or 1000 everywhere — which turns a budget into
// a token quota.
type Price struct {
PromptPer1k int64 `koanf:"prompt_per_1k"`
CompletionPer1k int64 `koanf:"completion_per_1k"`
}
// Zero reports whether this model spends nothing.
//
// It matters beyond arithmetic: a zero-cost model stays available to a key
// whose budget is spent, which is what makes the plan's budget fallback work
// (§7.7).
func (p Price) Zero() bool { return p.PromptPer1k == 0 && p.CompletionPer1k == 0 }
// Cost computes what a request spent, rounding up.
//
// Up rather than to nearest: rounding down would make a stream of tiny requests
// free, which is exactly the shape of traffic a quota exists to bound.
func (p Price) Cost(promptTokens, completionTokens int64) int64 {
return divUp(promptTokens*p.PromptPer1k, 1000) + divUp(completionTokens*p.CompletionPer1k, 1000)
}
func divUp(n, d int64) int64 {
if n <= 0 {
return 0
}
return (n + d - 1) / d
}
// Pricing resolves a model name to its price.
type Pricing struct {
mu sync.RWMutex
byModel map[string]Price
fallback Price
}
// NewPricing builds a price table. Names are matched case-insensitively,
// because a model name reaches us from whatever the client typed.
func NewPricing(byModel map[string]Price, fallback Price) *Pricing {
table := make(map[string]Price, len(byModel))
for name, p := range byModel {
table[strings.ToLower(name)] = p
}
return &Pricing{byModel: table, fallback: fallback}
}
// For returns a model's price, or the fallback.
func (p *Pricing) For(model string) Price {
p.mu.RLock()
defer p.mu.RUnlock()
if v, ok := p.byModel[strings.ToLower(model)]; ok {
return v
}
return p.fallback
}
// AnythingPriced reports whether any model costs anything.
//
// A budget is spend per period, and spend is computed from a price table. With
// no prices every request costs nothing, so a budget written on a team is a
// number that can never move — displayed as a limit, reported as zero percent
// used, and binding on nothing.
//
// Self-hosted models have no per-token price, so an empty table is the ordinary
// case rather than a misconfiguration. What is not ordinary is showing somebody
// a budget without saying so.
func (p *Pricing) AnythingPriced() bool {
if p == nil {
return false
}
p.mu.RLock()
defer p.mu.RUnlock()
if !p.fallback.Zero() {
return true
}
for _, price := range p.byModel {
if !price.Zero() {
return true
}
}
return false
}
+130
View File
@@ -0,0 +1,130 @@
package accounting
import (
"sync"
"time"
)
// Limiter enforces per-key rate limits.
//
// A sliding window rather than a fixed one. A fixed window lets a caller send
// its whole minute's allowance in the last second of one window and again in
// the first second of the next, so the observed rate is twice the configured
// limit exactly when the system is least able to take it.
//
// The window is approximated from two buckets, which is the usual compromise:
// exact enough that the doubling cannot happen, and bounded memory per key
// rather than a timestamp per request.
type Limiter struct {
now func() time.Time
mu sync.Mutex
keys map[string]*window
}
type window struct {
// current and previous are counts in this minute and the one before.
current, previous int64
start time.Time
}
// NewLimiter builds an empty limiter.
func NewLimiter() *Limiter {
return &Limiter{now: time.Now, keys: map[string]*window{}}
}
const windowLen = time.Minute
// Allow charges n against a key's limit and reports whether it fits.
//
// Charging and checking are one operation on purpose: doing them separately
// lets two concurrent requests both see room and both take it.
func (l *Limiter) Allow(key string, limit int, n int64) bool {
if limit <= 0 {
// No limit configured. Zero would be a limit of zero, which is why
// callers pass a nil pointer rather than 0 for "unset".
return true
}
now := l.now()
l.mu.Lock()
defer l.mu.Unlock()
w := l.windowLocked(key, now)
if w.rate(now)+float64(n) > float64(limit) {
return false
}
w.current += n
return true
}
// Charge records usage that was already allowed, for reconciliation.
//
// Token limits are checked against an estimate before the request runs, because
// the real figure only exists once it has. The difference is charged here, so a
// caller whose requests are consistently larger than estimated is still bounded
// — one window later than it would be with a perfect estimate.
func (l *Limiter) Charge(key string, n int64) {
if n == 0 {
return
}
now := l.now()
l.mu.Lock()
defer l.mu.Unlock()
w := l.windowLocked(key, now)
w.current += n
if w.current < 0 {
w.current = 0
}
}
// Rate returns a key's current observed rate.
func (l *Limiter) Rate(key string) float64 {
now := l.now()
l.mu.Lock()
defer l.mu.Unlock()
return l.windowLocked(key, now).rate(now)
}
func (l *Limiter) windowLocked(key string, now time.Time) *window {
w, ok := l.keys[key]
if !ok {
w = &window{start: now.Truncate(windowLen)}
l.keys[key] = w
}
w.roll(now)
return w
}
// roll advances the window to now.
func (w *window) roll(now time.Time) {
start := now.Truncate(windowLen)
switch {
case start.Equal(w.start):
return
case start.Sub(w.start) == windowLen:
w.previous, w.current = w.current, 0
default:
// More than one window has passed with no traffic.
w.previous, w.current = 0, 0
}
w.start = start
}
// rate weights the previous window by how much of it is still in view.
func (w *window) rate(now time.Time) float64 {
elapsed := now.Sub(w.start)
if elapsed >= windowLen {
return float64(w.current)
}
remaining := 1 - float64(elapsed)/float64(windowLen)
return float64(w.previous)*remaining + float64(w.current)
}
// Forget drops a key's window. Used when a key is revoked; keeping it would
// leak memory for every key that ever called.
func (l *Limiter) Forget(key string) {
l.mu.Lock()
delete(l.keys, key)
l.mu.Unlock()
}
+126
View File
@@ -0,0 +1,126 @@
package accounting
import (
"context"
"errors"
"log/slog"
"path/filepath"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// peopleStore has Acme/ml/training with two members, ayse and deniz.
func peopleStore(t *testing.T) (*store.DB, string) {
t.Helper()
ctx := context.Background()
s, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "p.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
org, _ := s.CreateOrg(ctx, "Acme")
team, _ := s.CreateTeam(ctx, org, "ml")
proj, _ := s.CreateProject(ctx, team, "training")
for _, id := range []string{"ayse", "deniz"} {
if err := s.CreateUser(ctx, store.User{ID: id, Email: id + "@x", Name: id,
PasswordHash: "h", State: store.UserPending, CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
if err := s.ApproveUser(ctx, id, proj, "test", time.Now()); err != nil {
t.Fatal(err)
}
}
return s, proj
}
// keyFor makes a key for a person and resolves it the way authentication does.
func keyFor(t *testing.T, s *store.DB, id, user, project string) auth.Principal {
t.Helper()
ctx := context.Background()
_, k, _ := store.NewKey(id, id, project)
if err := s.CreateUserKey(ctx, user, k); err != nil {
t.Fatal(err)
}
e, err := s.Resolve(ctx, id)
if err != nil {
t.Fatalf("resolving %s: %v", id, err)
}
return auth.Principal{KeyID: id, UserID: e.UserID, ProjectID: e.ProjectID, Limits: e}
}
func TestAPersonsLimitIsOnePoolAcrossTheirKeys(t *testing.T) {
s, proj := peopleStore(t)
if err := s.PutLimits(context.Background(),
store.Limits{Scope: store.ScopeUser, ScopeID: "ayse", RPM: intp(2)}); err != nil {
t.Fatal(err)
}
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
personal := keyFor(t, s, "k-personal", "ayse", "")
inProject := keyFor(t, s, "k-project", "ayse", proj)
// Two requests, one on each key, use up the person's two a minute.
for _, p := range []auth.Principal{personal, inProject} {
if _, err := a.Check(p, "free", 1); err != nil {
t.Fatalf("%s: %v", p.KeyID, err)
}
}
if _, err := a.Check(personal, "free", 1); !errors.Is(err, ErrRateLimited) {
t.Errorf("a third request across her keys was allowed: %v", err)
}
// Deniz is somebody else.
if _, err := a.Check(keyFor(t, s, "k-deniz", "deniz", proj), "free", 1); err != nil {
t.Errorf("another person was limited by hers: %v", err)
}
}
func TestAPerMemberLimitCountsEachMemberApart(t *testing.T) {
s, proj := peopleStore(t)
if err := s.PutLimits(context.Background(),
store.Limits{Scope: store.ScopeProjectMember, ScopeID: proj, RPM: intp(1)}); err != nil {
t.Fatal(err)
}
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
ayse := keyFor(t, s, "k-a", "ayse", proj)
deniz := keyFor(t, s, "k-d", "deniz", proj)
if _, err := a.Check(ayse, "free", 1); err != nil {
t.Fatal(err)
}
if _, err := a.Check(ayse, "free", 1); !errors.Is(err, ErrRateLimited) {
t.Errorf("her second request passed a one-a-minute per-member limit: %v", err)
}
// Her share is hers: Deniz still has his.
if _, err := a.Check(deniz, "free", 1); err != nil {
t.Errorf("one member's use counted against another: %v", err)
}
// And outside the project it does not apply at all.
if _, err := a.Check(keyFor(t, s, "k-a-own", "ayse", ""), "free", 1); err != nil {
t.Errorf("a project's per-member limit held her personal key: %v", err)
}
}
func TestAPersonsBudgetSurvivesARestart(t *testing.T) {
s, _ := peopleStore(t)
ctx := context.Background()
if err := s.PutLimits(ctx, store.Limits{Scope: store.ScopeUser, ScopeID: "ayse",
Budget: i64p(1000), BudgetPeriod: store.PeriodMonth}); err != nil {
t.Fatal(err)
}
// Spent before the restart, personally and on the chat page.
if err := s.WriteUsage(ctx, []store.Usage{
{At: time.Now(), KeyID: "k-old", UserID: "ayse", CostMicros: 600, Model: "paid", ServedModel: "paid"},
{At: time.Now(), KeyID: "session:ayse", UserID: "ayse", CostMicros: 500, Model: "paid", ServedModel: "paid"},
}); err != nil {
t.Fatal(err)
}
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
if err := a.RestoreBudgets(ctx, s, store.PeriodMonth); err != nil {
t.Fatal(err)
}
if _, err := a.Check(keyFor(t, s, "k-new", "ayse", ""), "paid", 1); !errors.Is(err, ErrBudgetSpent) {
t.Errorf("a restart forgot what she had spent: %v", err)
}
}
+189
View File
@@ -0,0 +1,189 @@
package accounting
import (
"context"
"log/slog"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// RecorderConfig tunes the batch writer.
type RecorderConfig struct {
// FlushInterval and BatchSize decide when a batch is written. Whichever
// comes first.
FlushInterval time.Duration
BatchSize int
// Capacity bounds the buffer. Beyond it, records are dropped rather than
// blocking a request — see Record.
Capacity int
}
// DefaultRecorderConfig returns sensible values.
func DefaultRecorderConfig() RecorderConfig {
return RecorderConfig{FlushInterval: 200 * time.Millisecond, BatchSize: 500, Capacity: 8192}
}
func (c *RecorderConfig) normalize() {
d := DefaultRecorderConfig()
if c.FlushInterval <= 0 {
c.FlushInterval = d.FlushInterval
}
if c.BatchSize <= 0 {
c.BatchSize = d.BatchSize
}
if c.Capacity <= 0 {
c.Capacity = d.Capacity
}
}
// Toucher is the part of the authenticator the recorder flushes alongside
// usage, so last-used timestamps ride the same transaction schedule.
type Toucher interface {
FlushTouches(ctx context.Context) error
}
// Recorder batches usage records into the store.
//
// The buffered channel is the whole design. A request must never wait on a
// database write, so Record is a non-blocking send and the writer drains it on
// its own schedule.
type Recorder struct {
cfg RecorderConfig
store store.Store
touch Toucher
log *slog.Logger
ch chan store.Usage
done chan struct{}
wg sync.WaitGroup
mu sync.Mutex
dropped int64
written int64
}
// NewRecorder builds a recorder. Start begins writing.
func NewRecorder(s store.Store, touch Toucher, cfg RecorderConfig, log *slog.Logger) *Recorder {
cfg.normalize()
if log == nil {
log = slog.Default()
}
return &Recorder{
cfg: cfg, store: s, touch: touch, log: log,
ch: make(chan store.Usage, cfg.Capacity),
done: make(chan struct{}),
}
}
// Record queues one request's accounting.
//
// Never blocks. If the buffer is full — a database stalled long enough to fill
// eight thousand records — the record is dropped and counted. Losing some
// accounting is bad; making every request wait on a stalled database is worse,
// because it converts a reporting problem into an outage.
func (r *Recorder) Record(u store.Usage) {
select {
case r.ch <- u:
default:
r.mu.Lock()
r.dropped++
n := r.dropped
r.mu.Unlock()
if n == 1 || n%1000 == 0 {
r.log.Warn("dropping usage records: the store is not keeping up",
"dropped_total", n)
}
}
}
// Start runs the writer until ctx ends.
func (r *Recorder) Start(ctx context.Context) {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer close(r.done)
r.run(ctx)
}()
}
// Stop waits for the writer to drain and finish.
func (r *Recorder) Stop() { r.wg.Wait() }
func (r *Recorder) run(ctx context.Context) {
t := time.NewTicker(r.cfg.FlushInterval)
defer t.Stop()
batch := make([]store.Usage, 0, r.cfg.BatchSize)
for {
select {
case <-ctx.Done():
// Drain what is already queued before leaving. These are completed
// requests that have already been served; throwing them away would
// mean a clean shutdown silently loses the last moments of billing.
r.drain(&batch)
r.flush(context.WithoutCancel(ctx), batch)
return
case u := <-r.ch:
batch = append(batch, u)
if len(batch) >= r.cfg.BatchSize {
r.flush(ctx, batch)
batch = batch[:0]
}
case <-t.C:
r.drain(&batch)
if len(batch) > 0 {
r.flush(ctx, batch)
batch = batch[:0]
}
if err := r.touchFlush(ctx); err != nil {
r.log.Warn("failed to write last-used timestamps", "err", err)
}
}
}
}
// drain moves everything queued into the batch without blocking.
func (r *Recorder) drain(batch *[]store.Usage) {
for len(*batch) < r.cfg.BatchSize {
select {
case u := <-r.ch:
*batch = append(*batch, u)
default:
return
}
}
}
func (r *Recorder) flush(ctx context.Context, batch []store.Usage) {
if len(batch) == 0 {
return
}
if err := r.store.WriteUsage(ctx, batch); err != nil {
// The records are gone either way; what matters is that this is loud.
// Silently losing billing data is the kind of thing discovered at the
// end of a month.
r.log.Error("failed to write usage records", "count", len(batch), "err", err)
return
}
r.mu.Lock()
r.written += int64(len(batch))
r.mu.Unlock()
}
func (r *Recorder) touchFlush(ctx context.Context) error {
if r.touch == nil {
return nil
}
return r.touch.FlushTouches(ctx)
}
// Stats reports what the recorder has done, for metrics and tests.
func (r *Recorder) Stats() (written, dropped int64) {
r.mu.Lock()
defer r.mu.Unlock()
return r.written, r.dropped
}
+210
View File
@@ -0,0 +1,210 @@
package accounting
import (
"context"
"errors"
"log/slog"
"sync"
"sync/atomic"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// fakeStore records what was written and can be made slow or broken.
type fakeStore struct {
store.Store
mu sync.Mutex
batches [][]store.Usage
block chan struct{}
fail atomic.Bool
touched atomic.Int64
}
func (f *fakeStore) WriteUsage(_ context.Context, rows []store.Usage) error {
if f.block != nil {
<-f.block
}
if f.fail.Load() {
return errors.New("store is down")
}
f.mu.Lock()
defer f.mu.Unlock()
f.batches = append(f.batches, append([]store.Usage(nil), rows...))
return nil
}
func (f *fakeStore) rows() []store.Usage {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Usage
for _, b := range f.batches {
out = append(out, b...)
}
return out
}
func (f *fakeStore) batchCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.batches)
}
type fakeToucher struct{ n *atomic.Int64 }
func (f fakeToucher) FlushTouches(context.Context) error {
f.n.Add(1)
return nil
}
func TestRecordsAreBatchedNotWrittenOneByOne(t *testing.T) {
// The whole point of the buffer: a hundred requests must not be a hundred
// transactions.
f := &fakeStore{}
r := NewRecorder(f, nil, RecorderConfig{FlushInterval: 20 * time.Millisecond, BatchSize: 500},
slog.New(slog.DiscardHandler))
ctx, cancel := context.WithCancel(context.Background())
r.Start(ctx)
for i := range 100 {
r.Record(store.Usage{KeyID: "k", PromptTokens: int64(i)})
}
waitFor(t, time.Second, func() bool { return len(f.rows()) == 100 })
if n := f.batchCount(); n > 5 {
t.Errorf("100 records took %d transactions", n)
}
cancel()
r.Stop()
}
func TestRecordNeverBlocksARequest(t *testing.T) {
// A stalled database must not become an outage. Losing accounting is bad;
// making every request wait on the store is worse.
f := &fakeStore{block: make(chan struct{})}
r := NewRecorder(f, nil, RecorderConfig{
FlushInterval: time.Millisecond, BatchSize: 1, Capacity: 8,
}, slog.New(slog.DiscardHandler))
ctx, cancel := context.WithCancel(context.Background())
r.Start(ctx)
defer func() { close(f.block); cancel(); r.Stop() }()
done := make(chan struct{})
go func() {
defer close(done)
for range 1000 {
r.Record(store.Usage{KeyID: "k"})
}
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Record blocked while the store was stalled")
}
_, dropped := r.Stats()
if dropped == 0 {
t.Error("the overflow was not counted, so nobody would ever know")
}
}
func TestAShutdownDoesNotLoseWhatIsAlreadyQueued(t *testing.T) {
// Those are completed requests that were already served. Throwing them away
// means a clean shutdown silently loses the last moments of billing.
f := &fakeStore{}
r := NewRecorder(f, nil, RecorderConfig{FlushInterval: time.Hour, BatchSize: 10_000},
slog.New(slog.DiscardHandler))
ctx, cancel := context.WithCancel(context.Background())
r.Start(ctx)
for range 50 {
r.Record(store.Usage{KeyID: "k"})
}
cancel()
r.Stop()
if got := len(f.rows()); got != 50 {
t.Errorf("a clean shutdown wrote %d of 50 queued records", got)
}
}
func TestAFailedWriteIsLoudRatherThanSilent(t *testing.T) {
// Silently losing billing data is the kind of thing discovered at the end
// of a month.
f := &fakeStore{}
f.fail.Store(true)
logged := &countingHandler{}
r := NewRecorder(f, nil, RecorderConfig{FlushInterval: 10 * time.Millisecond, BatchSize: 1},
slog.New(logged))
ctx, cancel := context.WithCancel(context.Background())
r.Start(ctx)
r.Record(store.Usage{KeyID: "k"})
waitFor(t, time.Second, func() bool { return logged.count(slog.LevelError) > 0 })
cancel()
r.Stop()
if written, _ := r.Stats(); written != 0 {
t.Errorf("a failed write was counted as %d written", written)
}
}
func TestLastUsedRidesTheSameSchedule(t *testing.T) {
var n atomic.Int64
f := &fakeStore{}
r := NewRecorder(f, fakeToucher{n: &n}, RecorderConfig{FlushInterval: 10 * time.Millisecond},
slog.New(slog.DiscardHandler))
ctx, cancel := context.WithCancel(context.Background())
r.Start(ctx)
waitFor(t, time.Second, func() bool { return n.Load() > 0 })
cancel()
r.Stop()
}
type countingHandler struct {
mu sync.Mutex
levels []slog.Level
}
func (c *countingHandler) Enabled(context.Context, slog.Level) bool { return true }
func (c *countingHandler) WithAttrs([]slog.Attr) slog.Handler { return c }
func (c *countingHandler) WithGroup(string) slog.Handler { return c }
func (c *countingHandler) Handle(_ context.Context, r slog.Record) error {
c.mu.Lock()
c.levels = append(c.levels, r.Level)
c.mu.Unlock()
return nil
}
func (c *countingHandler) count(l slog.Level) int {
c.mu.Lock()
defer c.mu.Unlock()
n := 0
for _, got := range c.levels {
if got == l {
n++
}
}
return n
}
func waitFor(t *testing.T, limit time.Duration, pred func() bool) {
t.Helper()
deadline := time.Now().Add(limit)
for time.Now().Before(deadline) {
if pred() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("condition never became true")
}
+619 -95
View File
@@ -3,14 +3,21 @@ package agent
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"math/rand/v2"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"slices"
"sync"
"time"
@@ -20,74 +27,13 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1/llmgwv1connect"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// Config configures the agent.
type Config struct {
NodeID string `koanf:"node_id"`
Server string `koanf:"server"`
Hostname string `koanf:"hostname"`
MgmtAddr string `koanf:"mgmt_addr"`
Labels map[string]string `koanf:"labels"`
// MgmtInterface is the ethernet interface NCCL bootstraps over.
MgmtInterface string `koanf:"mgmt_interface"`
// Fabrics labels this node's HCAs. It is explicit on purpose: OpenSM's
// default subnet prefix is the same on every fabric, so nothing can be
// inferred from the wire (plan §5.2).
Fabrics []inventory.FabricAttachment `koanf:"fabrics"`
HeartbeatInterval time.Duration `koanf:"heartbeat_interval"`
// ReconnectBase is the first reconnect delay; it backs off from there.
ReconnectBase time.Duration `koanf:"reconnect_base"`
ReconnectMax time.Duration `koanf:"reconnect_max"`
Discovery discovery.Options `koanf:"discovery"`
Engines engines.Config `koanf:"engines"`
Superv supervisor.Config `koanf:"supervisor"`
Version string
}
// DefaultConfig returns sensible defaults.
func DefaultConfig() Config {
return Config{
MgmtInterface: "eth0",
HeartbeatInterval: 5 * time.Second,
ReconnectBase: time.Second,
ReconnectMax: 30 * time.Second,
Superv: supervisor.DefaultConfig(),
// NVML gives NVLink topology, so prefer it when this build has it.
Discovery: discovery.Options{PreferNVML: true},
}
}
func (c *Config) normalize() error {
d := DefaultConfig()
if c.NodeID == "" {
return errors.New("node_id is required")
}
if c.Server == "" {
return errors.New("server is required")
}
if c.MgmtInterface == "" {
c.MgmtInterface = d.MgmtInterface
}
if c.HeartbeatInterval <= 0 {
c.HeartbeatInterval = d.HeartbeatInterval
}
if c.ReconnectBase <= 0 {
c.ReconnectBase = d.ReconnectBase
}
if c.ReconnectMax < c.ReconnectBase {
c.ReconnectMax = max(c.ReconnectBase, d.ReconnectMax)
}
return nil
}
// Agent runs on a GPU node.
type Agent struct {
cfg Config
@@ -96,11 +42,74 @@ type Agent struct {
reg *engines.Registry
http connectHTTPClient
// engineOnce probes the configured engines once before registering.
//
// Once, because probing SGLang imports it in a Python interpreter — seconds
// on a cold cache — and a reconnect is not a reason to pay that again.
// Later changes are found by the slower re-probe the heartbeat starts.
engineOnce sync.Once
// configuredVers are the engines the configuration names and that answer.
// Guarded by mu once the first probe has run.
configuredVers map[string]string
// installedVers are the engines this agent installed and saw run.
// Guarded by mu.
installedVers map[string]string
// prov installs the engines the control plane asks for. provWants is what
// it asked for, nil until it has; provStatus is where each stands and
// provSent the fingerprint of what the control plane last heard. All
// guarded by mu. provKick starts a pass without waiting for the timer.
logRing *logRing
prov *provision.Installer
provWants []provision.Want
provStatus map[string]provision.Status
provSent string
provKick chan struct{}
// enginesSent is the fingerprint of the engines the control plane last
// heard about, from registration or a heartbeat. Guarded by mu.
enginesSent string
// lastEngineProbe and probingEngines pace the re-probe. Guarded by mu.
lastEngineProbe time.Time
probingEngines bool
mu sync.Mutex
inv discovery.Inventory
// shardReplica remembers which replica a shard belongs to, so a status
// report can name both. The control plane keys on the replica.
shardReplica map[string]string
fetcher *weights.Fetcher
// lastWeights is the fingerprint of the file list most recently sent, so a
// heartbeat carries the list only when it changed. Guarded by mu: during a
// failover the old stream's heartbeat loop can still be running when the
// new one's starts.
lastWeights string
// fabricsSent is what the control plane was last told about fabrics.
fabricsSent string
// sm caches which subnet manager each InfiniBand port sees, asked once
// a minute: a management datagram per port per heartbeat is more than
// an answer that changes on a failover is worth.
sm map[string]*discovery.SMInfo
smErr map[string]string
smAsked time.Time
// nvlAsked is when the GPUs' NVLink fabric state was last read.
nvlAsked time.Time
// sms is every subnet manager on a fabric, asked through one port per
// master, keyed by that port; smsErr says why a port could not ask.
sms map[string][]discovery.SMRecord
smsErr map[string]string
// load measures the machine between heartbeats.
load *discovery.LoadSampler
// sensors reads heat and power: CPU and board from hwmon and RAPL, GPUs
// from nvidia-smi.
sensors *discovery.SensorSampler
// fetchWatchers are the shards waiting on each source, so progress on one
// download reaches every shard that asked for it. A fetch is single-flight
// — eight shards starting together produce one download — and all eight
// are waiting, so all eight should say so.
fetchWatchers map[string][]func(weights.Progress)
}
// connectHTTPClient is the HTTP client connect uses; an interface so tests can
@@ -117,6 +126,11 @@ func New(cfg Config, httpClient connectHTTPClient, log *slog.Logger) (*Agent, er
if log == nil {
log = slog.Default()
}
// Everything the agent logs also goes to a ring the admin page can read,
// so seeing what a node did does not mean ssh and a journal only root may
// open.
ring := newLogRing(agentLogLines)
log = slog.New(teeHandler{log.Handler(), slog.NewTextHandler(ring, nil)})
if httpClient == nil {
httpClient = http.DefaultClient
}
@@ -126,12 +140,19 @@ func New(cfg Config, httpClient connectHTTPClient, log *slog.Logger) (*Agent, er
if err != nil {
return nil, err
}
return &Agent{
a := &Agent{
cfg: cfg, log: log, sup: sup,
reg: engines.NewRegistry(cfg.Engines),
http: httpClient,
shardReplica: map[string]string{},
}, nil
provStatus: map[string]provision.Status{},
logRing: ring,
provKick: make(chan struct{}, 1),
load: discovery.NewLoadSampler(),
sensors: discovery.NewSensorSampler(),
}
a.prov = &provision.Installer{Root: cfg.EnginesDir, Log: log, OnStatus: a.provisionStatus}
return a, nil
}
// Supervisor exposes the process supervisor, for tests and `ctl logs`.
@@ -150,17 +171,39 @@ func (a *Agent) Run(ctx context.Context) error {
a.log.Error("hardware discovery failed", "err", err)
}
a.recoverOrphans()
// Made now rather than by the first download, so a new node reports its
// disk from its first heartbeat instead of only once it holds a model.
if err := os.MkdirAll(a.cfg.WeightsCache, 0o755); err != nil {
a.log.Warn("cannot create the weights directory", "dir", a.cfg.WeightsCache, "err", err)
}
go a.provisionLoop(ctx)
go a.ensureDiags(ctx)
attempt := 0
next := 0
for ctx.Err() == nil {
err := a.session(ctx)
server := a.cfg.Servers[next%len(a.cfg.Servers)]
err := a.session(ctx, server)
if ctx.Err() != nil {
break
}
// On to the next address whatever went wrong. A standby refuses, a
// stopped control plane refuses the connection, and a crashed one
// never answers — from here they are the same thing, and trying
// somewhere else is the answer to all three.
next++
attempt++
delay := a.reconnectDelay(attempt)
// No wait when there is somewhere else to try: the delay exists to
// stop hammering one control plane, and the next address has not been
// hammered. With a single address this is the old behaviour exactly.
if len(a.cfg.Servers) > 1 && next%len(a.cfg.Servers) != 0 {
delay = 0
}
a.log.Warn("disconnected from the control plane, retrying",
"err", err, "in", delay.Round(time.Millisecond), "attempt", attempt)
"err", err, "server", server,
"next", a.cfg.Servers[next%len(a.cfg.Servers)],
"in", delay.Round(time.Millisecond), "attempt", attempt)
select {
case <-ctx.Done():
@@ -225,11 +268,91 @@ func (a *Agent) recoverOrphans() {
a.mu.Unlock()
a.log.Info("adopted a running engine",
"shard", o.ShardID, "replica", o.ReplicaID, "pid", o.PID)
_ = sh
go a.settleAdopted(sh, o)
}
}
}
// settleAdopted brings an adopted engine to Ready, or lets it fail.
//
// Adoption stopped at "the process is alive", which left the shard in Starting
// with nothing that would ever move it. The control plane then waited out the
// profile's startup timeout and killed a perfectly healthy engine so it could
// start an identical one — so an agent restart, which exists precisely so that
// engines are not interrupted, cost every model on the node a full reload.
//
// Measured on node05: the engine answered /health the whole time, and the
// replica sat in Starting for three minutes until the timeout reaped it.
//
// Run in the background because recovery happens before the agent connects, and
// a slow health check must not delay registration — the control plane needs to
// hear about these shards promptly whatever state they are in.
func (a *Agent) settleAdopted(sh *supervisor.Shard, o supervisor.Orphan) {
ctx, cancel := context.WithTimeout(context.Background(), a.cfg.AdoptReadyTimeout)
defer cancel()
eng, err := a.reg.Get(o.Spec.Profile.Engine)
if err != nil {
a.log.Warn("cannot judge an adopted engine; leaving it to the control plane",
"shard", o.ShardID, "err", err)
return
}
// The same two paths a freshly started shard takes: a rank that serves HTTP
// is health-checked, a worker is judged by staying alive.
if eng.ServesHTTP(o.Spec) {
base := fmt.Sprintf("http://127.0.0.1:%d", o.Spec.HTTPPort)
err = a.sup.WaitReady(ctx, sh, base, o.Spec.Profile.ServedModelName, a.cfg.AdoptReadyTimeout)
} else {
err = a.sup.WaitAlive(ctx, sh)
}
if err != nil {
// Not killed here. An adopted engine that will not answer is the
// control plane's decision to replace, and it has the timeout for it;
// killing it locally would race that.
a.log.Warn("an adopted engine did not become ready",
"shard", o.ShardID, "replica", o.ReplicaID, "err", err)
return
}
a.log.Info("adopted engine is serving",
"shard", o.ShardID, "replica", o.ReplicaID)
}
// weights builds the fetcher lazily, so an agent that never starts a shard
// never needs a cache directory.
func (a *Agent) weights() *weights.Fetcher {
a.mu.Lock()
defer a.mu.Unlock()
if a.fetcher != nil {
return a.fetcher
}
cache := a.cfg.WeightsCache
if cache == "" && len(a.cfg.ModelDirs) > 0 {
cache = a.cfg.ModelDirs[0]
}
a.fetcher = &weights.Fetcher{
CacheDir: cache,
Log: a.log,
OnProgress: func(p weights.Progress) {
a.log.Info("fetching weights",
"source", p.Source, "percent", p.Percent(),
"bytes", p.BytesDone, "total", p.BytesTotal)
// And upward, to whoever is watching from the control plane. The
// log line above is on the node, which is where nobody is looking
// when a replica has been starting for ten minutes.
a.mu.Lock()
watchers := slices.Clone(a.fetchWatchers[p.Source])
a.mu.Unlock()
for _, w := range watchers {
if w != nil {
w(p)
}
}
},
}
return a.fetcher
}
// runningShards describes what this agent is supervising, for registration.
func (a *Agent) runningShards() []*llmgwv1.RunningShard {
var out []*llmgwv1.RunningShard
@@ -255,6 +378,7 @@ func (a *Agent) runningShards() []*llmgwv1.RunningShard {
Profile: spec.Profile.Name,
Pool: spec.Pool,
Fabric: spec.Fabric,
ProfileFp: spec.ProfileFP,
LeaderAddr: a.cfg.MgmtAddr,
LeaderPort: int32(spec.LeaderPort),
})
@@ -275,17 +399,30 @@ func int32Slice(v []int) []int32 {
// scan refreshes the node's hardware inventory.
func (a *Agent) scan(ctx context.Context) error {
inv, err := discovery.Discover(ctx, a.cfg.Discovery, a.log)
// Carried from the agent's own configuration rather than duplicated into
// the discovery block, so an operator names the directories once.
opts := a.cfg.Discovery
opts.ModelDirs = a.cfg.ModelDirs
inv, err := discovery.Discover(ctx, opts, a.log)
host := discovery.DiscoverHost()
host.MinComputeCapability = discovery.MinComputeCapability(inv.GPUs)
a.mu.Lock()
a.inv = inv
a.prov.Host = host
a.mu.Unlock()
a.log.Info("this node", "os", host.OSID+" "+host.OSVersion, "arch", host.Arch,
"cpu_flags", host.CPUFlags, "nvidia_driver", host.NVIDIADriver)
return err
}
// session runs one connection until it breaks.
func (a *Agent) session(ctx context.Context) error {
client := llmgwv1connect.NewAgentServiceClient(a.http, a.cfg.Server)
func (a *Agent) session(ctx context.Context, server string) error {
client := llmgwv1connect.NewAgentServiceClient(a.http, server)
// parent is kept because the derived context is cancelled from inside this
// function; asking the derived one whether we are shutting down would
// always say yes, and every stream error would read as a clean close.
parent := ctx
ctx, cancel := context.WithCancel(ctx)
defer cancel()
@@ -296,13 +433,36 @@ func (a *Agent) session(ctx context.Context) error {
inv := a.inv
a.mu.Unlock()
// Declared fabric membership against what the node actually has. A fabric
// whose adapters are absent or have no active port is not advertised at
// all, so the control plane will not place a multi-node replica on it —
// rather than placing one that starts, passes health checks and hangs on
// its first all-reduce.
reg := agentsvc.RegisterFromInventory(
a.cfg.NodeID, a.cfg.Hostname, a.cfg.MgmtAddr, a.cfg.Version,
a.cfg.Labels, inv, a.cfg.Fabrics)
a.cfg.Labels, inv, a.usableFabrics(inv))
// Every registration reports what is already running, not just the first
// one: a reconnect may be a control plane restart, and it has to recover
// the same way (plan §6.5).
// What this node can actually run, so the control plane stops placing
// engines that are not here. The registry holds an adapter for every
// engine whether or not its binary exists, so without this a node with no
// vLLM accepted a vLLM shard and failed it minutes later.
reg.EngineVersions = a.engines(parent)
reg.EnginesProbed = true
reg.InstallableEngines = provision.Installable()
reg.Features = []string{FeatureFabricTest}
reg.RunningShards = a.runningShards()
a.mu.Lock()
h := a.prov.Host
// A new stream is a control plane that knows nothing of where installs
// stand, so the next heartbeat tells it again.
a.provSent = ""
a.mu.Unlock()
reg.Host = &llmgwv1.Host{
OsId: h.OSID, OsVersion: h.OSVersion, OsCodename: h.OSCodename,
Arch: h.Arch, CpuFlags: h.CPUFlags, NvidiaDriver: h.NVIDIADriver,
}
if err := stream.Send(&llmgwv1.AgentMessage{
Msg: &llmgwv1.AgentMessage_Register{Register: reg},
@@ -310,6 +470,22 @@ func (a *Agent) session(ctx context.Context) error {
return fmt.Errorf("registering: %w", err)
}
// Closing the response is what actually unblocks Receive below. Cancelling
// the context is not enough: HTTP/2 watches it while the request body is
// being written, and a bidirectional stream spends its life blocked on the
// read side instead. Without this an agent told to stop keeps reading until
// something kills it — which for systemd means the full stop timeout on
// every restart.
watchDone := make(chan struct{})
defer close(watchDone)
go func() {
select {
case <-ctx.Done():
_ = stream.CloseResponse()
case <-watchDone:
}
}()
// Sends are serialized through this channel: connect streams are not safe
// for concurrent writes, and heartbeats race with status reports.
out := make(chan *llmgwv1.AgentMessage, 32)
@@ -325,9 +501,18 @@ func (a *Agent) session(ctx context.Context) error {
msg, err := stream.Receive()
if err != nil {
cancel()
if errors.Is(err, io.EOF) || ctx.Err() != nil {
switch {
case parent.Err() != nil:
// The agent is stopping; the stream ending is the consequence.
return nil
case errors.Is(err, io.EOF):
// The control plane closed the stream, which a restart does.
// Reconnect immediately rather than backing off.
return nil
}
// Everything else is worth both reporting and backing off from. A
// refused certificate arrives here, and an agent that cannot say
// why it is not registering is one an operator cannot fix.
return err
}
a.handle(ctx, msg, out)
@@ -364,12 +549,180 @@ func (a *Agent) heartbeat() *llmgwv1.AgentMessage {
a.mu.Unlock()
hb := &llmgwv1.Heartbeat{NodeId: a.cfg.NodeID}
// Read every time; it is one small file. Memory is added to a running VM,
// and a node that grew from 7 GB to 32 GB has to be seen to have grown
// without anybody restarting anything.
hb.MemoryTotalMb = int64(discovery.MemoryTotalMB())
hb.CpuCount = int32(runtime.NumCPU())
a.reprobeEngines()
a.mu.Lock()
if vers := a.engineReportLocked(); a.configuredVers != nil &&
enginesFingerprint(vers) != a.enginesSent {
a.enginesSent = enginesFingerprint(vers)
hb.EnginesChanged = true
hb.EngineVersions = vers
}
if fp := provisionFingerprint(a.provStatus); fp != a.provSent {
a.provSent = fp
hb.ProvisionChanged = true
for _, e := range slices.Sorted(maps.Keys(a.provStatus)) {
st := a.provStatus[e]
hb.Provision = append(hb.Provision, &llmgwv1.ProvisionStatus{
Engine: st.Engine, Version: st.Version, Variant: st.Variant,
State: st.State, Detail: st.Detail,
})
}
}
a.mu.Unlock()
for _, g := range inv.GPUs {
hb.GpuMemoryUsedMb = append(hb.GpuMemoryUsedMb, int32(g.MemoryUsedMB))
}
// Rescanned here rather than reused from registration. Disk is not a fact
// about a machine, it is a fact about a moment: a node that had 200 GB when
// its agent started can be full an hour later, and it is precisely the node
// busy filling up whose figure matters.
for _, st := range discovery.ScanStorage(a.cfg.ModelDirs) {
hb.Storage = append(hb.Storage, &llmgwv1.Storage{
Path: st.Path, FreeBytes: st.FreeBytes,
TotalBytes: st.TotalBytes, Shared: st.Shared,
})
}
// The file list is longer and changes rarely, so it goes only when it has.
// The flag is what makes an empty list mean "this node now holds nothing"
// rather than "nothing to report" — without it, the first heartbeat after
// a model was deleted would be indistinguishable from silence.
weights := discovery.ScanWeights(a.cfg.ModelDirs)
fp := weightsFingerprint(weights)
a.mu.Lock()
changed := fp != a.lastWeights
a.lastWeights = fp
a.mu.Unlock()
if changed {
hb.WeightsChanged = true
for _, w := range weights {
hb.Weights = append(hb.Weights, &llmgwv1.LocalWeights{
Path: w.Path, SizeBytes: w.SizeBytes, Shared: w.Shared,
})
}
}
hb.Load = a.sampleLoad()
a.sampleIB(hb)
a.sampleNVLink(hb)
return &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Heartbeat{Heartbeat: hb}}
}
// sampleLoad measures the machine and each running engine since the last
// heartbeat. Nil on the first, which has nothing to compare with.
func (a *Agent) sampleLoad() *llmgwv1.NodeLoad {
if a.load == nil {
return nil
}
pids := map[string]int{}
for _, st := range a.sup.Statuses() {
if st.PID > 0 {
pids[st.ID] = st.PID
}
}
l, ok := a.load.Sample(pids)
if !ok {
return nil
}
out := &llmgwv1.NodeLoad{
CpuPercent: l.CPUPercent, CpuCount: int32(l.CPUCount),
Load1: l.Load1, Load5: l.Load5, Load15: l.Load15,
MemoryUsedBytes: l.MemUsed, MemoryAvailableBytes: l.MemAvail,
}
for _, sh := range l.Shards {
out.Shards = append(out.Shards, &llmgwv1.ShardLoad{
ShardId: sh.ID, CpuCores: sh.CPUCores, MemoryRssBytes: sh.RSSBytes,
})
}
if a.sensors != nil {
se := a.sensors.Sample(context.Background())
out.CpuTempC, out.CpuPowerW, out.InletTempC = se.CPUTempC, se.CPUPowerW, se.InletTempC
for _, g := range se.GPUs {
out.Gpus = append(out.Gpus, &llmgwv1.GPUSensor{
Index: int32(g.Index), TempC: g.TempC, MemoryTempC: g.MemoryTempC,
PowerW: g.PowerW, PowerLimitW: g.PowerLimitW, Throttle: g.Throttle,
})
}
}
return out
}
// weightsFingerprint is enough to notice a model appearing, disappearing or
// finishing its download.
//
// Paths and sizes, because a fetch in progress has the same name as the file
// it will become — under a different name until it is renamed, but a shared
// filesystem can also grow a file in place.
func weightsFingerprint(ws []discovery.LocalWeights) string {
h := sha256.New()
for _, w := range ws {
fmt.Fprintf(h, "%s:%d\n", w.Path, w.SizeBytes)
}
return hex.EncodeToString(h.Sum(nil))
}
// removeWeights deletes cached model files the control plane says are no
// longer needed.
//
// Every path is checked here as well as there, and that duplication is the
// point. The control plane computes these from the catalog, but it is the node
// that holds the files, and a node must not be talkable into deleting a model
// an operator spent a week copying onto it — not by a control plane that is
// wrong, not by one that is newer than this agent, not by one that has been
// tampered with. So this deletes only what it can prove llmgw itself wrote:
// inside its own cache directory, under a name this system generates.
//
// Failures are logged and skipped rather than reported upward. A file that is
// already gone is the expected outcome of a retry, and one that will not
// delete is a disk to look at, not a reason to fail a command whose purpose
// was to free space.
func (a *Agent) removeWeights(paths []string) {
cache := a.cfg.WeightsCache
if cache == "" && len(a.cfg.ModelDirs) > 0 {
cache = a.cfg.ModelDirs[0]
}
if cache == "" {
a.log.Warn("asked to remove weights with no cache directory configured")
return
}
cacheAbs, err := filepath.Abs(cache)
if err != nil {
a.log.Warn("cannot resolve the cache directory", "dir", cache, "err", err)
return
}
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
continue
}
// Directly inside the cache directory, not merely under it: a nested
// path would let ".." or a symlinked subdirectory reach elsewhere, and
// nothing this package writes is nested anyway.
if filepath.Dir(abs) != cacheAbs {
a.log.Warn("refused to remove a file outside the weights cache", "path", p)
continue
}
if !weights.Owned(abs) {
a.log.Warn("refused to remove a file llmgw did not fetch", "path", p)
continue
}
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
a.log.Warn("could not remove cached weights", "path", p, "err", err)
continue
}
a.log.Info("removed cached weights no model asks for", "path", p)
}
}
func (a *Agent) handle(ctx context.Context, msg *llmgwv1.ControlMessage, out chan<- *llmgwv1.AgentMessage) {
switch m := msg.Msg.(type) {
case *llmgwv1.ControlMessage_Start:
@@ -380,6 +733,49 @@ func (a *Agent) handle(ctx context.Context, msg *llmgwv1.ControlMessage, out cha
if err := a.sup.Drain(m.Drain.GetShardId()); err != nil {
a.log.Warn("draining failed", "shard", m.Drain.GetShardId(), "err", err)
}
case *llmgwv1.ControlMessage_RemoveWeights:
a.removeWeights(m.RemoveWeights.GetPaths())
case *llmgwv1.ControlMessage_Provision:
a.setProvision(m.Provision)
case *llmgwv1.ControlMessage_Upgrade:
go a.upgrade(ctx, m.Upgrade, out)
case *llmgwv1.ControlMessage_Logs:
reply := a.logsReply(m.Logs)
// Off the receive loop: a full send queue must not stop commands being
// read.
go func() {
select {
case out <- &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Logs{Logs: reply}}:
case <-ctx.Done():
}
}()
case *llmgwv1.ControlMessage_FabricScan:
// A scan takes seconds to minutes: off the receive loop entirely.
go func(req *llmgwv1.FabricScanRequest) {
start := time.Now()
reply := &llmgwv1.FabricScanReply{RequestId: req.GetRequestId()}
text, err := scanFabric(ctx, req.GetHca(), int(req.GetPort()))
if err != nil {
reply.Error = err.Error()
} else {
reply.Output = text
}
reply.TookMs = time.Since(start).Milliseconds()
select {
case out <- &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_FabricScan{FabricScan: reply}}:
case <-ctx.Done():
}
}(m.FabricScan)
case *llmgwv1.ControlMessage_FabricTest:
// Seconds of traffic, and a package install the first time: off the
// receive loop too.
go func(req *llmgwv1.FabricTestRequest) {
reply := a.fabricTest(ctx, req)
select {
case out <- &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_FabricTest{FabricTest: reply}}:
case <-ctx.Done():
}
}(m.FabricTest)
case *llmgwv1.ControlMessage_Ack:
if !m.Ack.GetOk() {
a.log.Warn("control plane rejected a message",
@@ -392,6 +788,42 @@ func (a *Agent) handle(ctx context.Context, msg *llmgwv1.ControlMessage, out cha
//
// It runs in its own goroutine because model loading takes minutes, and the
// stream has to keep carrying heartbeats and other commands meanwhile.
// watchFetch registers interest in a source's download progress and returns
// the function that stops watching.
//
// Keyed by source rather than by shard because a fetch is single-flight: eight
// shards starting together produce one download, and every one of them is
// waiting on it.
func (a *Agent) watchFetch(source string, fn func(weights.Progress)) (stop func()) {
a.mu.Lock()
if a.fetchWatchers == nil {
a.fetchWatchers = map[string][]func(weights.Progress){}
}
a.fetchWatchers[source] = append(a.fetchWatchers[source], fn)
i := len(a.fetchWatchers[source]) - 1
a.mu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
a.mu.Lock()
defer a.mu.Unlock()
w := a.fetchWatchers[source]
if i < len(w) {
// Cleared rather than removed, so the indexes other watchers
// hold stay valid. The slice is dropped when the last one goes.
w[i] = nil
}
for _, f := range w {
if f != nil {
return
}
}
delete(a.fetchWatchers, source)
})
}
}
func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<- *llmgwv1.AgentMessage) {
shardID, replicaID := m.GetShardId(), m.GetReplicaId()
@@ -399,18 +831,21 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
a.shardReplica[shardID] = replicaID
a.mu.Unlock()
report := func(st supervisor.State, errMsg string, tail []string) {
msg := &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Shard{Shard: &llmgwv1.ShardStatus{
NodeId: a.cfg.NodeID, ReplicaId: replicaID, ShardId: shardID,
State: agentsvc.StateToWire(st), Error: errMsg, LastOutput: tail,
}}}
send := func(s *llmgwv1.ShardStatus) {
s.NodeId, s.ReplicaId, s.ShardId = a.cfg.NodeID, replicaID, shardID
select {
case out <- msg:
case out <- &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Shard{Shard: s}}:
case <-ctx.Done():
}
}
report := func(st supervisor.State, errMsg string, tail []string) {
send(&llmgwv1.ShardStatus{
State: agentsvc.StateToWire(st), Error: errMsg, LastOutput: tail,
})
}
spec, timeout := agentsvc.ShardSpecFromStart(m)
a.localizeSpec(&spec)
eng, err := a.reg.Get(spec.Profile.Engine)
if err != nil {
@@ -429,6 +864,34 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
return
}
// The weights, before anything is launched. A source that is already a
// path is checked and used; one that names a remote file is fetched once
// and shared by every shard that wants it.
//
// Checked here rather than left to the engine because the engine's answer
// arrives minutes into a load, in its own words, on a node nobody is
// watching — and because eight shards starting together would otherwise
// each fetch the same file.
//
// Progress is reported upward while it runs, so the control plane can tell
// a download from a hang.
stop := a.watchFetch(spec.Profile.ModelPath, func(p weights.Progress) {
send(&llmgwv1.ShardStatus{
State: agentsvc.StateToWire(supervisor.Starting),
Fetch: &llmgwv1.FetchProgress{
Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal,
},
})
})
if path, err := a.weights().Ensure(ctx, spec.Profile.ModelPath); err != nil {
stop()
report(supervisor.Failed, err.Error(), nil)
return
} else {
stop()
spec.Profile.ModelPath = path
}
cmds, err := eng.Commands(spec)
if err != nil {
report(supervisor.Failed, err.Error(), nil)
@@ -536,23 +999,84 @@ func gpuInfo(inv discovery.Inventory, indices []int) []engines.GPUInfo {
return out
}
// HTTP2Client returns the client the agent should use to reach the control
// plane over plaintext HTTP/2.
// Client returns the client the agent reaches the control plane with.
//
// connect's bidirectional streams require HTTP/2, and an agent talking to a
// control plane over a trusted network has no TLS to negotiate it with. Go's
// default transport will not speak HTTP/2 without TLS, so it has to be asked
// explicitly.
// Both branches speak HTTP/2, which connect's bidirectional streams require and
// which Go will not do by default in either case: without TLS it has to be
// asked explicitly, and with TLS it has to be negotiated through ALPN.
//
// mTLS is plan §5.6's target and replaces this; until then the stream must not
// cross an untrusted network.
func HTTP2Client(timeout time.Duration) *http.Client {
return &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{Timeout: timeout}).DialContext(ctx, network, addr)
// The plaintext branch exists for local runs against sim-engines. Anything
// using it must stay on a trusted network, because nothing there stops a node
// from claiming to be another one.
func Client(cfg tlsconf.Config, timeout time.Duration) (*http.Client, error) {
if !cfg.Enabled() {
return &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{Timeout: timeout}).DialContext(ctx, network, addr)
},
},
},
}, nil
}
// ServerName is left to the transport, which takes it from the address
// being dialled. Hardcoding it here would break the moment the control
// plane moved.
tc, err := cfg.ClientConfig("")
if err != nil {
return nil, err
}
return &http.Client{Transport: &http2.Transport{TLSClientConfig: tc}}, nil
}
// localizeSpec fills in what only this node knows.
//
// The interface NCCL bootstraps over is this node's, not the cluster's: the
// control plane sends one name for every node, and a node whose management
// network is on ens3 rather than eth0 would have NCCL bind nothing. And on
// RoCE, which GID entry routes is a property of these adapters.
func (a *Agent) localizeSpec(spec *engines.ShardSpec) {
if a.cfg.MgmtInterface != "" {
spec.MgmtInterface = a.cfg.MgmtInterface
}
if len(spec.HCAs) == 0 {
return
}
a.mu.Lock()
hcas := a.inv.HCAs
a.mu.Unlock()
want := map[string]bool{}
for _, h := range spec.HCAs {
want[h] = true
}
for _, h := range hcas {
if !want[h.Name] {
continue
}
for _, p := range h.Ports {
if p.Active() && p.RoCEv2GID >= 0 {
idx := p.RoCEv2GID
spec.GIDIndex = &idx
return
}
}
}
}
// ResolveManagement is the address and interface this node's agent would use,
// found the way normalize finds them, for a tool that reports them.
func ResolveManagement(c Config) (addr, iface string) {
addr, iface = c.MgmtAddr, c.MgmtInterface
server := c.Server
if server == "" && len(c.Servers) > 0 {
server = c.Servers[0]
}
if addr == "" {
addr = addrToward(server)
}
if iface == "" {
iface = interfaceOf(addr)
}
return addr, iface
}
+327 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
@@ -12,6 +13,7 @@ import (
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
"syscall"
@@ -21,6 +23,8 @@ import (
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
@@ -34,8 +38,10 @@ type statusRecorder struct {
ch chan status
mu sync.Mutex
calls []adoptCall
adopted []reconciler.AdoptedShard
unclaim []string // shard ids to report back as unclaimed
fetches []fetchReport
}
type status struct {
@@ -53,9 +59,29 @@ func (r *statusRecorder) OnShardStatus(replicaID, shardID string, st supervisor.
}
}
func (r *statusRecorder) Adopt(shards []reconciler.AdoptedShard) []reconciler.AdoptedShard {
// fetches records download progress, so a test can assert that a fetch is
// visible from the control plane rather than only in the node's log.
func (r *statusRecorder) OnFetchProgress(replicaID, shardID, source string, done, total int64) {
r.mu.Lock()
defer r.mu.Unlock()
r.fetches = append(r.fetches, fetchReport{replicaID, shardID, source, done, total})
}
func (r *statusRecorder) fetchReports() []fetchReport {
r.mu.Lock()
defer r.mu.Unlock()
return append([]fetchReport(nil), r.fetches...)
}
type fetchReport struct {
replicaID, shardID, source string
done, total int64
}
func (r *statusRecorder) Adopt(nodeID string, shards []reconciler.AdoptedShard) []reconciler.AdoptedShard {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, adoptCall{nodeID: nodeID, shards: shards})
r.adopted = append(r.adopted, shards...)
var unclaimed []reconciler.AdoptedShard
@@ -67,6 +93,19 @@ func (r *statusRecorder) Adopt(shards []reconciler.AdoptedShard) []reconciler.Ad
return unclaimed
}
// adoptCall records that the service reached the reconciler at all, which an
// empty shard list used to prevent.
type adoptCall struct {
nodeID string
shards []reconciler.AdoptedShard
}
func (r *statusRecorder) adoptCalls() []adoptCall {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.calls)
}
func (r *statusRecorder) adoptedShards() []reconciler.AdoptedShard {
r.mu.Lock()
defer r.mu.Unlock()
@@ -208,7 +247,7 @@ func startWith(t *testing.T, log *slog.Logger, cfgFns ...func(*Config)) *harness
inv := inventory.NewStore()
rec := newRecorder()
svc := agentsvc.New(inv, rec, log)
svc := agentsvc.New(inv, rec, false, log)
path, handler := svc.Handler()
mux := http.NewServeMux()
@@ -285,8 +324,17 @@ func (h *harness) waitRegistered(t *testing.T) {
}
// shardCommand builds a single-node vLLM shard on a free port.
//
// The model path is a real file. It used to be "/models/x", which stopped
// working once the agent began checking that weights exist before launching an
// engine — the check doing exactly what it is for, on a test that was relying
// on nobody looking.
func shardCommand(t *testing.T, shardID string) reconciler.ShardCommand {
t.Helper()
modelPath := filepath.Join(t.TempDir(), "model.gguf")
if err := os.WriteFile(modelPath, []byte("weights"), 0o644); err != nil {
t.Fatal(err)
}
return reconciler.ShardCommand{
ReplicaID: "rep-1", ShardID: shardID, NodeID: "n1",
Spec: engines.ShardSpec{
@@ -294,7 +342,7 @@ func shardCommand(t *testing.T, shardID string) reconciler.ShardCommand {
GPUIndices: []int{0}, HTTPPort: freePort(t),
Profile: engines.Profile{
Name: "p", Engine: "vllm", TP: 1,
ModelPath: "/models/x", ServedModelName: "test-model",
ModelPath: modelPath, ServedModelName: "test-model",
},
},
StartupTimeout: 15 * time.Second,
@@ -312,9 +360,15 @@ func freePort(t *testing.T) int {
}
func TestRegistrationCarriesInventory(t *testing.T) {
// The node must actually have the adapters it declares. It used to declare
// mlx5_0 and mlx5_1 on a node whose sysfs held neither, which passed
// because nothing compared the two — and that is the gap a real cluster
// would have found as a replica hanging on its first all-reduce.
ibRoot := ibTreeWithActivePorts(t, "mlx5_0", "mlx5_1")
h := start(t, func(c *Config) {
c.Hostname = "gpu-a-01"
c.Labels = map[string]string{"rack": "r12"}
c.Discovery.IBRoot = ibRoot
c.Fabrics = []inventory.FabricAttachment{
{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}, RateGbps: 200},
}
@@ -346,11 +400,16 @@ func TestHeartbeatKeepsNodeAlive(t *testing.T) {
n, _ := h.inv.Get("n1")
first := n.LastSeen
time.Sleep(200 * time.Millisecond)
n, _ = h.inv.Get("n1")
if !n.LastSeen.After(first) {
t.Error("heartbeats are not refreshing LastSeen; the node would be declared lost")
// Waited for rather than slept through: four heartbeat intervals was not
// always enough on a loaded runner for one to have landed.
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if n, _ = h.inv.Get("n1"); n.LastSeen.After(first) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Error("heartbeats are not refreshing LastSeen; the node would be declared lost")
}
func TestStartAndStopShardEndToEnd(t *testing.T) {
@@ -598,3 +657,264 @@ func TestUnclaimedShardIsStopped(t *testing.T) {
func processAliveForTest(pid int) bool {
return syscall.Kill(pid, 0) == nil
}
func TestAShardWithMissingWeightsFailsBeforeLaunching(t *testing.T) {
// The commonest setup mistake on a fresh node. Left to the engine it
// surfaces minutes into a load, in the engine's own words, on a machine
// nobody is watching — so it is caught here, where the message can say
// what is actually wrong.
a := &Agent{cfg: Config{WeightsCache: t.TempDir()}, log: slog.New(slog.DiscardHandler)}
_, err := a.weights().Ensure(t.Context(), filepath.Join(t.TempDir(), "not-there.gguf"))
if err == nil {
t.Fatal("a missing weights file was accepted")
}
if !strings.Contains(err.Error(), "not-there.gguf") {
t.Errorf("the error does not name the file: %v", err)
}
}
func TestTheCacheDefaultsToTheFirstModelDirectory(t *testing.T) {
// A node that has said where its models live has already answered this;
// asking twice is a second thing to get out of step.
dir := t.TempDir()
a := &Agent{cfg: Config{ModelDirs: []string{dir, "/elsewhere"}}, log: slog.New(slog.DiscardHandler)}
if got := a.weights().CacheDir; got != dir {
t.Errorf("cache = %q, want the first model directory %q", got, dir)
}
}
// ibTreeWithActivePorts builds a sysfs InfiniBand tree with the named adapters,
// each with one active port, shaped like the real thing.
func ibTreeWithActivePorts(t *testing.T, names ...string) string {
t.Helper()
root := t.TempDir()
for _, name := range names {
dir := filepath.Join(root, name, "ports", "1")
if err := os.MkdirAll(filepath.Join(dir, "gids"), 0o755); err != nil {
t.Fatal(err)
}
for file, content := range map[string]string{
"state": "4: ACTIVE",
"phys_state": "5: LinkUp",
"rate": "200 Gb/sec (4X HDR)",
"link_layer": "InfiniBand",
"lid": "0x1a",
"gids/0": "fec0:0000:0000:000a:0002:c903:00a0:1234",
} {
if err := os.WriteFile(filepath.Join(dir, file), []byte(content+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
return root
}
func TestAFabricWhoseAdaptersAreAbsentIsNotAdvertised(t *testing.T) {
// The failure this prevents: a typo, or a card that enumerated differently
// after a reboot, produced NCCL_IB_HCA naming an adapter that is not there.
// The replica starts, passes its health checks, and hangs on its first
// all-reduce — the exact case the fabric invariant exists to prevent,
// arriving through a door nobody was watching.
h := start(t, func(c *Config) {
c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0")
c.Fabrics = []inventory.FabricAttachment{
{Fabric: "ib-typo", HCAs: []string{"mlx5_9"}, RateGbps: 200},
}
})
n, ok := h.inv.Get("n1")
if !ok {
t.Fatal("node missing from inventory")
}
if n.HasFabric("ib-typo") {
t.Errorf("a fabric with no usable adapter was advertised: %+v", n.Fabrics)
}
}
func TestAFabricRateIsTheSlowestActiveLink(t *testing.T) {
// A collective runs at the speed of its slowest link, so reporting the
// fastest would have the scheduler believe a fabric is quicker than it is.
root := t.TempDir()
for name, rate := range map[string]string{"mlx5_0": "200", "mlx5_1": "100"} {
dir := filepath.Join(root, name, "ports", "1")
if err := os.MkdirAll(filepath.Join(dir, "gids"), 0o755); err != nil {
t.Fatal(err)
}
for file, content := range map[string]string{
"state": "4: ACTIVE", "phys_state": "5: LinkUp",
"rate": rate + " Gb/sec (4X HDR)", "link_layer": "InfiniBand",
"lid": "0x1a", "gids/0": "fec0::1",
} {
if err := os.WriteFile(filepath.Join(dir, file), []byte(content+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
h := start(t, func(c *Config) {
c.Discovery.IBRoot = root
c.Fabrics = []inventory.FabricAttachment{
{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}},
}
})
n, _ := h.inv.Get("n1")
for _, f := range n.Fabrics {
if f.Fabric == "ib-a" && f.RateGbps != 100 {
t.Errorf("fabric rate = %d, want the slower link's 100", f.RateGbps)
}
}
}
func TestAPortGoingDownIsToldToTheControlPlaneOnce(t *testing.T) {
// Checked only at registration, a port going down went unnoticed until
// the agent restarted, and the node went on being offered multi-node
// work that would hang on its first collective.
root := ibTreeWithActivePorts(t, "mlx5_0")
h := start(t, func(c *Config) {
c.Discovery.IBRoot = root
c.Fabrics = []inventory.FabricAttachment{{Fabric: "ib-a", HCAs: []string{"mlx5_0"}}}
})
if n, _ := h.inv.Get("n1"); !n.HasFabric("ib-a") {
t.Fatalf("the fabric should be advertised at registration: %+v", n.Fabrics)
}
hb := h.ag.heartbeat().GetHeartbeat()
if hb.GetFabricsChanged() || len(hb.GetIbPorts()) != 1 || hb.GetIbPorts()[0].GetState() != "ACTIVE" {
t.Fatalf("a quiet heartbeat should carry the port and no change: %+v", hb)
}
if err := os.WriteFile(filepath.Join(root, "mlx5_0", "ports", "1", "state"), []byte("1: DOWN\n"), 0o644); err != nil {
t.Fatal(err)
}
hb = h.ag.heartbeat().GetHeartbeat()
if !hb.GetFabricsChanged() || len(hb.GetFabrics()) != 0 {
t.Errorf("the port going down should withdraw the fabric: changed=%v fabrics=%v",
hb.GetFabricsChanged(), hb.GetFabrics())
}
if hb = h.ag.heartbeat().GetHeartbeat(); hb.GetFabricsChanged() {
t.Error("the change should be sent once, not on every heartbeat")
}
}
func TestAShardUsesThisNodesInterfaceAndRoCEIndex(t *testing.T) {
// The control plane sends one interface name for every node; a node whose
// management network is on another had NCCL bind nothing. And which RoCE
// GID entry routes is a property of this node's adapters.
a := &Agent{cfg: Config{MgmtInterface: "ens3"}}
a.inv.HCAs = []discovery.HCA{{Name: "mlx5_0", Ports: []discovery.Port{
{Num: 1, State: "ACTIVE", LinkLayer: discovery.LinkEthernet, RoCEv2GID: 3}}}}
spec := engines.ShardSpec{MgmtInterface: "eth0", HCAs: []string{"mlx5_0"}}
a.localizeSpec(&spec)
if spec.MgmtInterface != "ens3" {
t.Errorf("interface = %q", spec.MgmtInterface)
}
if spec.GIDIndex == nil || *spec.GIDIndex != 3 {
t.Errorf("GID index = %v", spec.GIDIndex)
}
ib := engines.ShardSpec{HCAs: []string{"mlx5_1"}}
a.inv.HCAs = append(a.inv.HCAs, discovery.HCA{Name: "mlx5_1", Ports: []discovery.Port{
{Num: 1, State: "ACTIVE", LinkLayer: discovery.LinkInfiniBand, RoCEv2GID: -1}}})
a.localizeSpec(&ib)
if ib.GIDIndex != nil {
t.Error("an InfiniBand shard was given a GID index")
}
}
func TestTheManagementInterfaceIsFoundNotAssumed(t *testing.T) {
// It was defaulted to eth0 before it could be looked for, so it never
// was: a node on ens3 or bond0 had NCCL bootstrap over nothing.
cfg := DefaultConfig()
cfg.Server = "http://127.0.0.1:7443"
cfg.NodeID = "n1"
cfg.MgmtAddr = "127.0.0.1"
if err := cfg.normalize(); err != nil {
t.Fatal(err)
}
if cfg.MgmtInterface == "" || cfg.MgmtInterface == "eth0" {
t.Errorf("interface = %q, want the loopback interface holding 127.0.0.1", cfg.MgmtInterface)
}
}
func TestEachFabricsSubnetManagersAreAskedOnce(t *testing.T) {
// Two adapters on one fabric see one master. Its managers are listed
// through one of them — the same list twice is a query too many — and
// every port says which master it sees.
oldSM, oldSMs := querySM, querySMs
t.Cleanup(func() { querySM, querySMs = oldSM, oldSMs })
querySM = func(string, int) (*discovery.SMInfo, error) {
return &discovery.SMInfo{GUID: "0x01", LID: 1, State: "SMINFO_MASTER"}, nil
}
lists := 0
querySMs = func(string, int) ([]discovery.SMRecord, error) {
lists++
return []discovery.SMRecord{{GUID: "0x01", State: "master"}, {GUID: "0x02", State: "standby"}}, nil
}
h := start(t, func(c *Config) {
c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0", "mlx5_1")
c.Fabrics = []inventory.FabricAttachment{{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}}}
})
hb := h.ag.heartbeat().GetHeartbeat()
withList := 0
for _, p := range hb.GetIbPorts() {
if p.GetSmGuid() != "0x01" {
t.Errorf("%s sees master %q", p.GetHca(), p.GetSmGuid())
}
if len(p.GetSubnetManagers()) > 0 {
withList++
}
}
if lists != 1 || withList != 1 {
t.Errorf("listed %d times, carried on %d ports; want once, on one", lists, withList)
}
}
func TestAFabricScanIsAskedForAndAnswered(t *testing.T) {
// One node per fabric maps it; the control plane asks and parses.
old := scanFabric
t.Cleanup(func() { scanFabric = old })
var asked string
scanFabric = func(_ context.Context, hca string, port int) (string, error) {
asked = fmt.Sprintf("%s/%d", hca, port)
return "Switch\t36 \"S-00000000000000a1\"\t\t# \"MF0;leaf-a:QM8700/U1\" enhanced port 0 lid 1 lmc 0\n", nil
}
h := start(t)
r, err := h.svc.ScanFabric(t.Context(), "n1", "mlx5_0", 1)
if err != nil {
t.Fatal(err)
}
if asked != "mlx5_0/1" || !strings.Contains(r.GetOutput(), "leaf-a") || r.GetError() != "" {
t.Errorf("asked %q, got %+v", asked, r)
}
}
func TestABandwidthTestIsRunOnTheAdapterAskedFor(t *testing.T) {
oldRun, oldHave := runWriteBW, haveWriteBW
t.Cleanup(func() { runWriteBW, haveWriteBW = oldRun, oldHave })
haveWriteBW = func() bool { return true }
var got discovery.WriteBW
runWriteBW = func(_ context.Context, w discovery.WriteBW) (float64, string, error) {
got = w
return 388.2, " #bytes #iterations BW peak[Gb/sec] BW average[Gb/sec]\n", nil
}
h := start(t, func(c *Config) { c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0") })
r, err := h.svc.FabricTest(t.Context(), "n1", &llmgwv1.FabricTestRequest{
Role: "client", Hca: "mlx5_0", Port: 1, Peer: "10.0.0.2", TcpPort: 18515, Seconds: 3})
if err != nil {
t.Fatal(err)
}
if r.GetGbps() != 388.2 || r.GetError() != "" {
t.Errorf("reply %+v", r)
}
if got.HCA != "mlx5_0" || got.Peer != "10.0.0.2" || got.TCPPort != 18515 || got.GIDIndex != -1 {
t.Errorf("ran %+v", got)
}
// An adapter the node does not have is said so, without running anything.
got = discovery.WriteBW{}
r, _ = h.svc.FabricTest(t.Context(), "n1", &llmgwv1.FabricTestRequest{Role: "server", Hca: "mlx5_9", Port: 1, Seconds: 1})
if !strings.Contains(r.GetError(), "no port mlx5_9/1") || got.HCA != "" {
t.Errorf("reply %+v, ran %+v", r, got)
}
}
+286
View File
@@ -0,0 +1,286 @@
package agent
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// Config configures the agent.
type Config struct {
// NodeID defaults to the hostname. It must equal the common name of the
// agent's certificate, so set it only where the two differ.
NodeID string `koanf:"node_id"`
// Server is the control plane, and Servers is the same thing when there is
// more than one. Both are accepted: every configuration written before
// there could be a second control plane names one address, and none of
// them should have to change.
//
// The order is tried in order, and a refusal moves to the next. Only the
// leader accepts an agent, so a standby is skipped in the time it takes to
// be told so — which is what makes failover a reconnect rather than an
// edit to every node's configuration.
Server string `koanf:"server"`
Servers []string `koanf:"servers"`
// Hostname defaults to the machine's.
Hostname string `koanf:"hostname"`
// MgmtAddr is the address engines are reached on. It defaults to this
// machine's address on the route to the control plane, which is the one
// the control plane and the router can reach.
MgmtAddr string `koanf:"mgmt_addr"`
// Labels are free-form facts about a node for a profile's node_selector.
// Nothing needs them: memory, GPUs, engines and disk are measured, and a
// label restating one of those goes stale the day the machine changes.
Labels map[string]string `koanf:"labels"`
// MgmtInterface is the ethernet interface NCCL bootstraps over. It
// defaults to the interface holding MgmtAddr, then eth0.
MgmtInterface string `koanf:"mgmt_interface"`
// Fabrics labels this node's HCAs. It is explicit on purpose: OpenSM's
// default subnet prefix is the same on every fabric, so nothing can be
// inferred from the wire (plan §5.2).
Fabrics []inventory.FabricAttachment `koanf:"fabrics"`
HeartbeatInterval time.Duration `koanf:"heartbeat_interval"`
// ReconnectBase is the first reconnect delay; it backs off from there.
ReconnectBase time.Duration `koanf:"reconnect_base"`
ReconnectMax time.Duration `koanf:"reconnect_max"`
// AdoptReadyTimeout bounds how long an adopted engine is given to answer a
// health check before the agent stops waiting on it.
//
// Shorter than a startup timeout on purpose: this engine is already
// running, so it is not loading a model, it is either serving or it is not.
AdoptReadyTimeout time.Duration `koanf:"adopt_ready_timeout"`
// TLS authenticates both ends of the control-plane stream. Leaving it unset
// keeps the plaintext behaviour, which only belongs on a trusted network.
TLS tlsconf.Config `koanf:"tls"`
// ModelDirs are the directories this node keeps model weights in, beyond
// WeightsCache, which is always looked in. Storage differs at every installation,
// so llmgw observes it rather than owning it: a Lustre mount, an image
// exported over the fabric, local NVMe and a copy made by hand all look
// identical from here, and placement only needs to know whether the file
// is present.
ModelDirs []string `koanf:"model_dirs"`
// EnginesDir is where the agent installs engines, one directory per
// build. Defaults to /opt/llmgw/engines.
EnginesDir string `koanf:"engines_dir"`
// WeightsCache is where a remote source is downloaded to. Defaults to the
// first entry of ModelDirs, because a node that says where its models live
// has already answered this question, and otherwise to
// /var/lib/llmgw-weights.
//
// Fetching is the simple arrangement: every node keeps its own copy. The
// exotic ones need no code — weights on a shared filer or an image exported
// over the fabric are simply present, and present means nothing is fetched.
WeightsCache string `koanf:"weights_cache"`
Discovery discovery.Options `koanf:"discovery"`
Engines engines.Config `koanf:"engines"`
Superv supervisor.Config `koanf:"supervisor"`
Version string
}
// DefaultConfig returns sensible defaults.
func DefaultConfig() Config {
// No MgmtInterface: normalize finds the one holding the management
// address, and falls back to eth0 only when it cannot. Defaulted here, it
// was never found — every node said eth0 unless told otherwise, and a node
// on ens3 or bond0 had NCCL bootstrap over an interface it does not have.
return Config{
HeartbeatInterval: 5 * time.Second,
ReconnectBase: time.Second,
ReconnectMax: 30 * time.Second,
AdoptReadyTimeout: 60 * time.Second,
Superv: supervisor.DefaultConfig(),
// NVML gives NVLink topology, so prefer it when this build has it.
Discovery: discovery.Options{PreferNVML: true},
}
}
func (c *Config) normalize() error {
d := DefaultConfig()
if err := c.TLS.Validate(); err != nil {
return err
}
if err := c.normalizeServerURL(); err != nil {
return err
}
// Everything below is found rather than written. A file that has to say
// which machine it is on, and at what address, is a file that differs on
// every node — and a copy taken from one node to the next registers the
// second as the first.
host, _ := os.Hostname()
if c.NodeID == "" {
c.NodeID = host
}
if c.NodeID == "" {
return errors.New("node_id is not set and the hostname could not be read")
}
if c.Hostname == "" {
c.Hostname = host
}
if c.MgmtAddr == "" {
c.MgmtAddr = addrToward(c.Server)
}
if c.MgmtInterface == "" {
c.MgmtInterface = interfaceOf(c.MgmtAddr)
}
if c.MgmtInterface == "" {
c.MgmtInterface = "eth0"
}
// The directory downloads land in is always one that is looked in. When
// they were two settings, a node could fill its disk with fetched weights
// that placement never saw — which is what every node here was doing.
if c.WeightsCache == "" && len(c.ModelDirs) > 0 {
c.WeightsCache = c.ModelDirs[0]
}
if c.WeightsCache == "" {
c.WeightsCache = DefaultWeightsCache
}
if !slices.Contains(c.ModelDirs, c.WeightsCache) {
c.ModelDirs = append(c.ModelDirs, c.WeightsCache)
}
if c.HeartbeatInterval <= 0 {
c.HeartbeatInterval = d.HeartbeatInterval
}
if c.ReconnectBase <= 0 {
c.ReconnectBase = d.ReconnectBase
}
if c.AdoptReadyTimeout <= 0 {
c.AdoptReadyTimeout = d.AdoptReadyTimeout
}
if c.ReconnectMax < c.ReconnectBase {
c.ReconnectMax = max(c.ReconnectBase, d.ReconnectMax)
}
return nil
}
// normalizeServerURL makes the configured address a URL connect can use, and
// refuses the combination that would quietly undo the certificates: a scheme
// that says plaintext while TLS is configured. Silently connecting anyway would
// mean a cluster believing it has mutual authentication when it has none.
func (c *Config) normalizeServerURL() error {
// One list, however it was written. Server first when both are given,
// because an operator adding a standby to an existing file expects the
// one already there to stay preferred.
all := make([]string, 0, 1+len(c.Servers))
if c.Server != "" {
all = append(all, c.Server)
}
all = append(all, c.Servers...)
seen := map[string]struct{}{}
out := make([]string, 0, len(all))
for _, s := range all {
fixed, err := normalizeOne(s, c.TLS.Enabled())
if err != nil {
return err
}
// A duplicate would be tried twice in a row on failover, which reads
// as the rotation not working.
if _, dup := seen[fixed]; dup {
continue
}
seen[fixed] = struct{}{}
out = append(out, fixed)
}
if len(out) == 0 {
return errors.New("server is required")
}
c.Servers = out
c.Server = out[0]
return nil
}
// DefaultWeightsCache is where fetched weights go when nothing says otherwise.
const DefaultWeightsCache = "/var/lib/llmgw-weights"
// addrToward is this machine's address on the route to the control plane.
//
// The address engines are reached on is whichever one the control plane can
// reach, and the kernel already knows which that is: a UDP "connection" picks
// the source address for the route without sending a packet. Asking the
// interfaces instead would have to guess among docker bridges, VPNs and
// link-local addresses.
func addrToward(server string) string {
u, err := url.Parse(server)
if err != nil || u.Host == "" {
return ""
}
host := u.Hostname()
port := u.Port()
if port == "" {
port = "443"
}
conn, err := net.Dial("udp", net.JoinHostPort(host, port))
if err != nil {
return ""
}
defer conn.Close()
if a, ok := conn.LocalAddr().(*net.UDPAddr); ok {
return a.IP.String()
}
return ""
}
// interfaceOf names the interface that holds an address, or "" when none does.
func interfaceOf(addr string) string {
ip := net.ParseIP(addr)
if ip == nil {
return ""
}
ifaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, ifc := range ifaces {
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.Equal(ip) {
return ifc.Name
}
}
}
return ""
}
// normalizeOne applies the scheme rules to a single address.
func normalizeOne(addr string, secure bool) (string, error) {
switch {
case strings.HasPrefix(addr, "https://"):
if !secure {
return "", fmt.Errorf("%s is an https:// address but no tls certificates are configured", addr)
}
case strings.HasPrefix(addr, "http://"):
if secure {
return "", fmt.Errorf("tls is configured but %s is an http:// address; use https://", addr)
}
case strings.Contains(addr, "://"):
return "", fmt.Errorf("server %q must be an http:// or https:// address", addr)
case secure:
return "https://" + addr, nil
default:
return "http://" + addr, nil
}
return addr, nil
}
+41 -5
View File
@@ -21,11 +21,15 @@ type Options struct {
NvidiaSMI string `koanf:"nvidia_smi"`
// IBRoot is the sysfs InfiniBand directory.
IBRoot string `koanf:"ib_root"`
// PreferNVML uses the NVML backend when this binary has one. It is the only
// source of NVLink topology.
// PreferNVML uses the NVML backend when this binary has one. NVLink
// topology is read from nvidia-smi topo -m on every build; NVML, where it
// is used, is the more exact source of the peers.
PreferNVML bool `koanf:"prefer_nvml"`
// SkipGPUs omits GPU discovery, for nodes that have none.
// SkipGPUs omits GPU discovery. A node without nvidia-smi is already
// found to have none, so this is for one whose driver is broken.
SkipGPUs bool `koanf:"skip_gpus"`
// ModelDirs are scanned for weights. Empty means do not look.
ModelDirs []string `koanf:"-"`
}
// Discover builds the node's inventory.
@@ -39,6 +43,19 @@ func Discover(ctx context.Context, opts Options, log *slog.Logger) (Inventory, e
log = slog.Default()
}
inv := Inventory{Source: "none"}
// First, because it has nothing to do with GPUs and every path below can
// return early. Read after them, a node with no nvidia-smi reported no
// memory at all: GPU discovery fails, Discover returns, and the fact that
// has nothing to do with cards goes with it. That is exactly the node whose
// memory matters most, since a CPU profile is what it will be asked to run.
inv.MemoryTotalMB = MemoryTotalMB()
// Also before the GPU paths, and for the same reason: which weights a node
// holds has nothing to do with its cards, and a node with none is exactly
// where a CPU profile will look for them.
inv.Weights = ScanWeights(opts.ModelDirs)
// And the disk they sit on. A node can hold a model today and be unable to
// take the next one, which the file list alone never shows.
inv.Storage = ScanStorage(opts.ModelDirs)
if !opts.SkipGPUs {
switch {
@@ -48,8 +65,7 @@ func Discover(ctx context.Context, opts Options, log *slog.Logger) (Inventory, e
inv.GPUs, inv.Source = gpus, "nvml"
break
}
log.Warn("NVML discovery failed, falling back to nvidia-smi; "+
"NVLink topology will be unavailable", "err", err)
log.Warn("NVML discovery failed, falling back to nvidia-smi", "err", err)
fallthrough
default:
gpus, err := DiscoverGPUsSMI(ctx, opts.NvidiaSMI)
@@ -58,6 +74,26 @@ func Discover(ctx context.Context, opts Options, log *slog.Logger) (Inventory, e
}
inv.GPUs, inv.Source = gpus, "nvidia-smi"
}
// NVLink and which adapter sits nearest each card, for either path:
// NVML knows NVLink but not the adapters, nvidia-smi's query knows
// neither. A failure costs the topology and nothing else — the cards
// are still cards.
if len(inv.GPUs) > 0 {
topo, err := DiscoverTopology(ctx, opts.NvidiaSMI)
if err != nil {
log.Warn("could not read the GPU topology; tensor-parallel groups "+
"will be chosen without NVLink", "err", err)
} else {
topo.Apply(inv.GPUs)
}
// Multi-node NVLink: which domain each card has joined.
if fab, err := DiscoverGPUFabric(ctx, opts.NvidiaSMI); err != nil {
log.Warn("could not read the GPUs' NVLink fabric state", "err", err)
} else {
ApplyFabric(inv.GPUs, fab)
}
inv.IMEX = DiscoverIMEX(ctx)
}
}
hcas, err := DiscoverIB(opts.IBRoot)
+18
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"os"
"path/filepath"
"runtime"
"testing"
)
@@ -99,3 +100,20 @@ func TestNVMLSupportedMatchesBuild(t *testing.T) {
}
}
}
func TestMemoryIsReportedEvenWhenGPUDiscoveryFails(t *testing.T) {
// A node with no nvidia-smi is exactly the node whose memory matters, since
// a CPU profile is what it will be asked to run. Reading memory after GPU
// discovery meant that node reported none: the GPU error returns early and
// takes a fact that has nothing to do with cards with it. Seen on node05,
// which has 32 GB and was reporting "-".
inv, err := Discover(t.Context(), Options{NvidiaSMI: "/nonexistent/nvidia-smi"}, nil)
if err == nil {
t.Fatal("GPU discovery was expected to fail with no nvidia-smi")
}
// On Linux this is a real number; elsewhere there is no /proc/meminfo and 0
// is the honest answer. Either way it must not be lost to the GPU error.
if runtime.GOOS == "linux" && inv.MemoryTotalMB <= 0 {
t.Errorf("memory was not reported alongside a GPU failure: %d", inv.MemoryTotalMB)
}
}
+118
View File
@@ -0,0 +1,118 @@
package discovery
import (
"fmt"
"sort"
"strings"
)
// FabricCheck is one declared fabric measured against what the node actually
// has.
type FabricCheck struct {
Fabric string
// Declared are the adapters the configuration names for it.
Declared []string
// Usable are the declared adapters that exist and have an active port.
Usable []string
// RateGbps is the slowest active port among them, because a collective
// runs at the speed of its slowest link.
RateGbps int
// Problems are the declared adapters that cannot carry traffic, and why.
Problems []string
}
// OK reports whether this fabric can carry a collective at all.
func (c FabricCheck) OK() bool { return len(c.Usable) > 0 }
// CheckFabrics measures declared fabric membership against discovered adapters.
//
// Fabric membership has to be configuration — OpenSM's default subnet prefix is
// the same on every fabric, so a matching prefix proves nothing — but taking it
// entirely on trust was a mistake of a different kind. A typo, or a card that
// enumerated differently after a reboot, produced NCCL_IB_HCA naming an adapter
// that does not exist; and an adapter whose cable is out, or whose subnet
// manager is down, is present in sysfs and carries nothing.
//
// Either way the replica starts, passes its health checks, and hangs on its
// first all-reduce — which is the failure the whole fabric invariant exists to
// prevent, arriving through a door nobody was watching. Declared and real are
// now compared where both are visible, which is only here.
func CheckFabrics(declared map[string][]string, discovered []HCA) []FabricCheck {
byName := make(map[string]HCA, len(discovered))
for _, h := range discovered {
byName[h.Name] = h
}
names := make([]string, 0, len(declared))
for f := range declared {
names = append(names, f)
}
sort.Strings(names)
out := make([]FabricCheck, 0, len(names))
for _, fabric := range names {
c := FabricCheck{Fabric: fabric, Declared: declared[fabric]}
for _, want := range declared[fabric] {
hca, found := byName[want]
if !found {
c.Problems = append(c.Problems, fmt.Sprintf(
"%s is not on this node (found: %s)", want, listNames(discovered)))
continue
}
rate, active := activeRate(hca)
if !active {
c.Problems = append(c.Problems, fmt.Sprintf(
"%s has no active port (%s)", want, portStates(hca)))
continue
}
c.Usable = append(c.Usable, want)
if c.RateGbps == 0 || rate < c.RateGbps {
c.RateGbps = rate
}
}
out = append(out, c)
}
return out
}
// activeRate returns the fastest active port's rate on an adapter.
//
// Only ACTIVE ports carry traffic. A port that is physically linked but has no
// subnet manager sits in INIT, looks healthy to anything counting adapters, and
// passes no data — which is precisely the case worth catching, because it
// survives a reboot and looks like a software problem.
func activeRate(h HCA) (int, bool) {
best, ok := 0, false
for _, p := range h.Ports {
if !strings.EqualFold(p.State, "ACTIVE") {
continue
}
ok = true
if p.RateGbps > best {
best = p.RateGbps
}
}
return best, ok
}
func portStates(h HCA) string {
if len(h.Ports) == 0 {
return "no ports"
}
var parts []string
for _, p := range h.Ports {
parts = append(parts, fmt.Sprintf("port %d %s/%s", p.Num, p.State, p.PhysState))
}
return strings.Join(parts, ", ")
}
func listNames(hcas []HCA) string {
if len(hcas) == 0 {
return "none"
}
var names []string
for _, h := range hcas {
names = append(names, h.Name)
}
return strings.Join(names, ", ")
}
+114
View File
@@ -0,0 +1,114 @@
package discovery
import (
"strings"
"testing"
)
func hca(name string, ports ...Port) HCA { return HCA{Name: name, Ports: ports} }
func active(rate int) Port {
return Port{Num: 1, State: "ACTIVE", PhysState: "LinkUp", RateGbps: rate, LinkLayer: LinkInfiniBand}
}
func TestADeclaredAdapterThatIsNotThereIsReported(t *testing.T) {
// A typo, or a card that enumerated differently after a reboot. Taken on
// trust it becomes NCCL_IB_HCA naming an adapter that does not exist, and
// the replica hangs on its first all-reduce.
got := CheckFabrics(
map[string][]string{"ib-a": {"mlx5_0", "mlx5_9"}},
[]HCA{hca("mlx5_0", active(200))},
)
if len(got) != 1 {
t.Fatalf("got %d checks", len(got))
}
c := got[0]
if len(c.Usable) != 1 || c.Usable[0] != "mlx5_0" {
t.Errorf("usable = %v, want only the adapter that exists", c.Usable)
}
if len(c.Problems) != 1 || !strings.Contains(c.Problems[0], "mlx5_9") {
t.Errorf("problems = %v", c.Problems)
}
// The message names what is actually present, because the operator is
// looking at a typo and cannot see the node.
if !strings.Contains(c.Problems[0], "mlx5_0") {
t.Errorf("the problem does not say what was found: %q", c.Problems[0])
}
}
func TestAnAdapterWithNoActivePortCannotCarryTraffic(t *testing.T) {
// A port that is physically linked but has no subnet manager sits in INIT.
// It looks healthy to anything counting adapters and passes no data, which
// is the case worth catching: it survives a reboot and looks like software.
got := CheckFabrics(
map[string][]string{"ib-a": {"mlx5_0"}},
[]HCA{hca("mlx5_0", Port{Num: 1, State: "INIT", PhysState: "LinkUp", RateGbps: 200})},
)
c := got[0]
if c.OK() {
t.Error("a fabric with no active port was reported usable")
}
if len(c.Problems) != 1 || !strings.Contains(c.Problems[0], "INIT") {
t.Errorf("the problem does not say what state the port is in: %v", c.Problems)
}
}
func TestTheRateIsTheSlowestActiveLink(t *testing.T) {
// A collective runs at the speed of its slowest link, so reporting the
// fastest would have the scheduler believe a fabric is quicker than it is.
got := CheckFabrics(
map[string][]string{"ib-a": {"fast", "slow"}},
[]HCA{hca("fast", active(400)), hca("slow", active(100))},
)
if got[0].RateGbps != 100 {
t.Errorf("rate = %d, want 100", got[0].RateGbps)
}
}
func TestAnAdapterWithSeveralPortsUsesItsBestActiveOne(t *testing.T) {
// A dual-port card with one cable in is a perfectly good adapter, and
// judging it by the dead port would take a working fabric out of service.
got := CheckFabrics(
map[string][]string{"ib-a": {"mlx5_0"}},
[]HCA{hca("mlx5_0",
Port{Num: 1, State: "DOWN", PhysState: "Disabled"},
active(200),
)},
)
if !got[0].OK() {
t.Fatalf("a card with one live port was rejected: %+v", got[0])
}
if got[0].RateGbps != 200 {
t.Errorf("rate = %d, want the active port's 200", got[0].RateGbps)
}
}
func TestAFabricWithNothingUsableIsNotOK(t *testing.T) {
got := CheckFabrics(
map[string][]string{"ib-dead": {"mlx5_0"}},
[]HCA{hca("mlx5_0", Port{Num: 1, State: "DOWN", PhysState: "Polling"})},
)
if got[0].OK() {
t.Error("a fabric whose only adapter is down was reported usable")
}
}
func TestANodeWithNoFabricsDeclaredProducesNoChecks(t *testing.T) {
// Every node without InfiniBand, which must stay entirely unaffected.
if got := CheckFabrics(nil, []HCA{hca("mlx5_0", active(200))}); len(got) != 0 {
t.Errorf("got %d checks for a node that declared none", len(got))
}
}
func TestChecksComeBackInAStableOrder(t *testing.T) {
// Map iteration would otherwise reorder log lines between restarts, which
// makes a diff of two registrations unreadable.
declared := map[string][]string{"ib-c": {"m"}, "ib-a": {"m"}, "ib-b": {"m"}}
got := CheckFabrics(declared, nil)
if len(got) != 3 {
t.Fatalf("got %d", len(got))
}
if got[0].Fabric != "ib-a" || got[1].Fabric != "ib-b" || got[2].Fabric != "ib-c" {
t.Errorf("order = %s, %s, %s", got[0].Fabric, got[1].Fabric, got[2].Fabric)
}
}
+27
View File
@@ -20,6 +20,14 @@ type GPU struct {
// MemoryUsedMB is what is already allocated. Non-zero on an idle node means
// something other than our engines is using the card (plan §5.1).
MemoryUsedMB int `json:"memory_used_mb"`
// UtilizationPercent is how busy the card was over the last sample period.
//
// The number people actually watch, and the one nothing collected: memory
// used says a model is loaded, and this says whether it is doing anything.
// A fleet of cards at ninety percent occupancy and two percent utilisation
// is a fleet holding models nobody is asking for, which is invisible
// without it.
UtilizationPercent int `json:"utilization_percent,omitempty"`
// ComputeCapability gates quantization choices: an FP8 profile must not
// land on a card below 8.9 (plan §6.2).
ComputeCapability string `json:"compute_capability"`
@@ -30,6 +38,13 @@ type GPU struct {
// NVLinkPeers are the indices of GPUs reachable over NVLink. Within a node
// a TP group should stay inside one NVLink domain.
NVLinkPeers []int `json:"nvlink_peers,omitempty"`
// NICPaths is how far each network adapter is from this GPU, by HCA name:
// PIX, PXB, PHB, NODE or SYS, as nvidia-smi topo reports it. What decides
// which adapter a GPU's share of a multi-node job should go through.
NICPaths map[string]string `json:"nic_paths,omitempty"`
// Fabric is the GPU's multi-node NVLink membership, nil where the
// driver reports none.
Fabric *GPUFabric `json:"fabric,omitempty"`
// Health carries anything that should keep the GPU out of placement.
Health GPUHealth `json:"health"`
}
@@ -100,6 +115,18 @@ type Inventory struct {
// Source names the backend that produced the GPU list, so a consumer can
// tell "no GPUs" from "discovery did not work".
Source string `json:"source"`
// MemoryTotalMB is the machine's RAM. Zero means unknown, which is what an
// agent older than this field reports, and is treated as no constraint.
MemoryTotalMB int `json:"memory_total_mb,omitempty"`
// Weights are the model files this node can see. Empty means it was not
// asked to look.
Weights []LocalWeights `json:"weights,omitempty"`
// Storage is the disk behind those weights, one entry per filesystem.
// Empty means the same thing it means for Weights: not asked.
Storage []Storage `json:"storage,omitempty"`
// IMEX is the service a multi-node NVLink domain needs, read where the
// node has GPUs.
IMEX IMEX `json:"imex"`
}
// Classes returns the distinct GPU classes present, sorted.
+160
View File
@@ -0,0 +1,160 @@
package discovery
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// GPUFabric is a GPU's place in a multi-node NVLink domain, as the driver
// reports it: the NVL72 rack, or the NVLink Switch system, it belongs to.
type GPUFabric struct {
// State is Completed once the fabric manager has joined the GPU to its
// domain; In Progress, Not Started or Not Supported otherwise.
State string `json:"state"`
// Status is Success, or the reason the join failed.
Status string `json:"status,omitempty"`
// ClusterUUID and CliqueID name the domain: GPUs sharing both can reach
// each other over NVLink whatever node they are in.
ClusterUUID string `json:"cluster_uuid,omitempty"`
CliqueID string `json:"clique_id,omitempty"`
// Bandwidth is Full, or how the domain's NVLink is degraded.
Bandwidth string `json:"bandwidth,omitempty"`
}
// Domain is the NVLink domain the GPU is joined to, empty when it is in none
// that crosses nodes: a card with no fabric, one not joined, or one whose
// cluster is all zeros, which is what a single machine's NVSwitch reports.
func (f *GPUFabric) Domain() string {
if f == nil || !strings.EqualFold(f.State, "Completed") {
return ""
}
u := strings.ToLower(strings.TrimSpace(f.ClusterUUID))
if u == "" || strings.Trim(u, "0-") == "" {
return ""
}
return u + "/" + f.CliqueID
}
// ParseGPUFabric reads the Fabric sections of `nvidia-smi -q`, by the PCI
// address each GPU's section starts with.
func ParseGPUFabric(out string) map[string]GPUFabric {
res := map[string]GPUFabric{}
var bus string
fabricIndent := -1
var cur *GPUFabric
flush := func() {
if bus != "" && cur != nil && cur.State != "" {
res[bus] = *cur
}
cur, fabricIndent = nil, -1
}
for _, line := range strings.Split(out, "\n") {
trim := strings.TrimSpace(line)
indent := len(line) - len(strings.TrimLeft(line, " \t"))
if strings.HasPrefix(line, "GPU ") && indent == 0 {
flush()
bus = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(line, "GPU ")))
continue
}
if trim == "Fabric" {
flush()
cur, fabricIndent = &GPUFabric{}, indent
continue
}
if cur == nil {
continue
}
if trim != "" && indent <= fabricIndent {
flush()
continue
}
k, v, ok := strings.Cut(trim, ":")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if strings.EqualFold(v, "N/A") {
v = ""
}
switch k {
case "State":
cur.State = v
case "Status":
cur.Status = v
case "ClusterUUID", "Cluster UUID":
cur.ClusterUUID = v
case "CliqueId", "Clique ID":
cur.CliqueID = v
case "Bandwidth":
cur.Bandwidth = v
}
}
flush()
return res
}
// DiscoverGPUFabric runs `nvidia-smi -q` for the Fabric sections. Nothing
// is reported on a driver or a card without them.
func DiscoverGPUFabric(ctx context.Context, binary string) (map[string]GPUFabric, error) {
if binary == "" {
if _, err := exec.LookPath("nvidia-smi"); err != nil {
return nil, nil
}
binary = "nvidia-smi"
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, binary, "-q").Output()
if err != nil {
return nil, err
}
return ParseGPUFabric(string(out)), nil
}
// ApplyFabric puts each GPU's fabric on it, matched by PCI address.
func ApplyFabric(gpus []GPU, fab map[string]GPUFabric) {
for i := range gpus {
if f, ok := fab[strings.ToLower(gpus[i].PCIBusID)]; ok {
f := f
gpus[i].Fabric = &f
}
}
}
// IMEX is the service that lets GPUs in different nodes of one NVLink
// domain share memory: without it, multi-node NVLink carries nothing.
type IMEX struct {
// State is systemd's word for nvidia-imex: active, inactive, or empty
// where it is not installed.
State string `json:"state,omitempty"`
// Channels is how many IMEX channels exist; a process needs one.
Channels int `json:"channels"`
}
// DiscoverIMEX reads the IMEX service and its channels.
func DiscoverIMEX(ctx context.Context) IMEX {
var m IMEX
if entries, err := os.ReadDir("/dev/nvidia-caps-imex-channels"); err == nil {
m.Channels = len(entries)
}
if _, err := exec.LookPath("systemctl"); err == nil {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, _ := exec.CommandContext(ctx, "systemctl", "is-active", "nvidia-imex").Output()
m.State = strings.TrimSpace(string(out))
if m.State == "inactive" {
// "inactive" is also what systemd says of a unit that does not
// exist; the binary says which.
if _, err := exec.LookPath("nvidia-imex"); err != nil {
if _, err := os.Stat(filepath.Join("/usr/bin", "nvidia-imex")); err != nil {
m.State = ""
}
}
}
}
return m
}
@@ -0,0 +1,77 @@
package discovery
import "testing"
// smiQ is an excerpt of `nvidia-smi -q` on a GB200 tray: two GPUs joined to
// one NVL72 domain, then a PCIe card that has no fabric at all.
const smiQ = `
==============NVSMI LOG==============
Timestamp : Mon Sep 29 10:00:00 2026
Driver Version : 570.86.15
Attached GPUs : 3
GPU 00000008:01:00.0
Product Name : NVIDIA GB200
Minor Number : 0
Fabric
State : Completed
Status : Success
CliqueId : 32766
ClusterUUID : 76B1AD76-7c24-4b6b-9f6c-2a1e0d8c11aa
Health
Bandwidth : Full
Processes : None
GPU 00000009:01:00.0
Product Name : NVIDIA GB200
Fabric
State : In Progress
Status : N/A
CliqueId : N/A
ClusterUUID : N/A
Processes : None
GPU 0000000A:01:00.0
Product Name : NVIDIA L40S
Fabric
State : N/A
Status : N/A
`
func TestAGPUsNVLinkDomainIsRead(t *testing.T) {
fab := ParseGPUFabric(smiQ)
a, ok := fab["00000008:01:00.0"]
if !ok {
t.Fatalf("fabric = %+v", fab)
}
if a.State != "Completed" || a.CliqueID != "32766" || a.Bandwidth != "Full" {
t.Errorf("GB200 = %+v", a)
}
if got := a.Domain(); got != "76b1ad76-7c24-4b6b-9f6c-2a1e0d8c11aa/32766" {
t.Errorf("domain = %q", got)
}
// Still joining: no domain yet, and said as such.
b := fab["00000009:01:00.0"]
if b.State != "In Progress" || b.Domain() != "" {
t.Errorf("joining GPU = %+v", b)
}
// A card with no fabric reports nothing rather than an empty one.
if _, ok := fab["0000000a:01:00.0"]; ok {
t.Error("a card without a fabric was given one")
}
gpus := []GPU{{Index: 0, PCIBusID: "00000008:01:00.0"}, {Index: 1, PCIBusID: "0000000A:01:00.0"}}
ApplyFabric(gpus, fab)
if gpus[0].Fabric == nil || gpus[1].Fabric != nil {
t.Errorf("applied = %+v %+v", gpus[0].Fabric, gpus[1].Fabric)
}
}
func TestASingleMachinesNVSwitchIsNoDomain(t *testing.T) {
// An eight-GPU HGX reports its fabric joined, with a cluster of zeros: its
// NVLink ends at the chassis.
f := &GPUFabric{State: "Completed", ClusterUUID: "00000000-0000-0000-0000-000000000000", CliqueID: "0"}
if f.Domain() != "" {
t.Errorf("a single machine read as a domain: %q", f.Domain())
}
}
+26 -3
View File
@@ -21,16 +21,36 @@ var smiQueryFields = []string{
"driver_version",
"temperature.gpu",
"ecc.errors.uncorrected.volatile.total",
// Last, and optional. An nvidia-smi too old to know this field returns a
// shorter row, and every column before it still lines up — which is why it
// goes on the end rather than anywhere else, and why the parser requires
// only the mandatory prefix.
"utilization.gpu",
}
// mandatorySMIFields is how many columns a row must have to be usable.
//
// Everything before utilization.gpu. A driver that does not know the last field
// gives a short row, and rejecting those would make an llmgw upgrade silently
// stop discovering GPUs on an older node — a far worse outcome than not knowing
// how busy a card is.
const mandatorySMIFields = 10
// DiscoverGPUsSMI enumerates GPUs by shelling out to nvidia-smi.
//
// This is the cgo-free path (plan §3.2): the NVML bindings need cgo, which
// would force the whole binary — server included — to be built with a C
// toolchain. nvidia-smi gives everything except NVLink topology, which the
// NVML build adds.
// toolchain. The query gives everything except topology, which Discover
// reads from `nvidia-smi topo -m` afterwards.
func DiscoverGPUsSMI(ctx context.Context, binary string) ([]GPU, error) {
if binary == "" {
// Not configured and not installed is a node without an NVIDIA card,
// not a failed discovery. Reported as an error it also cut discovery
// short before InfiniBand was looked at, on every CPU node that had
// not been told by hand to skip GPUs.
if _, err := exec.LookPath("nvidia-smi"); err != nil {
return nil, nil
}
binary = "nvidia-smi"
}
out, err := exec.CommandContext(ctx, binary,
@@ -55,7 +75,7 @@ func parseSMI(out string) ([]GPU, error) {
gpus := make([]GPU, 0, len(rows))
for _, row := range rows {
if len(row) < len(smiQueryFields) {
if len(row) < mandatorySMIFields {
continue
}
idx, err := strconv.Atoi(strings.TrimSpace(row[0]))
@@ -76,6 +96,9 @@ func parseSMI(out string) ([]GPU, error) {
g.MemoryUsedMB, _ = parseCSVInt(row[4])
g.Health.TemperatureC, _ = parseCSVInt(row[8])
g.Health.ECCErrors, _ = parseCSVInt(row[9])
if len(row) > 10 {
g.UtilizationPercent, _ = parseCSVInt(row[10])
}
assessHealth(&g)
gpus = append(gpus, g)
+35
View File
@@ -142,3 +142,38 @@ func TestDiscoverGPUsSMIMissingBinary(t *testing.T) {
t.Error("expected an error when nvidia-smi is missing")
}
}
func TestAnOlderDriverWithoutUtilisationStillDiscovers(t *testing.T) {
// An nvidia-smi too old for utilization.gpu returns a shorter row. Rejecting
// those would make an llmgw upgrade silently stop discovering GPUs on an
// older node, which is far worse than not knowing how busy a card is — and
// is what the first version of this change did.
old := "0, GPU-aaa, NVIDIA H100 80GB HBM3, 81559, 1024, 9.0, 0000:01:00.0, 550.54, 41, 0"
gpus, err := parseSMI(old)
if err != nil {
t.Fatal(err)
}
if len(gpus) != 1 {
t.Fatalf("a row without utilisation produced %d GPUs", len(gpus))
}
if gpus[0].MemoryTotalMB != 81559 || gpus[0].Health.TemperatureC != 41 {
t.Errorf("columns shifted: %+v", gpus[0])
}
if gpus[0].UtilizationPercent != 0 {
t.Errorf("utilisation = %d, want 0 when unreported", gpus[0].UtilizationPercent)
}
}
func TestUtilisationIsReadWhenPresent(t *testing.T) {
row := "0, GPU-aaa, NVIDIA H100 80GB HBM3, 81559, 1024, 9.0, 0000:01:00.0, 550.54, 41, 0, 87"
gpus, err := parseSMI(row)
if err != nil {
t.Fatal(err)
}
if len(gpus) != 1 {
t.Fatalf("got %d GPUs", len(gpus))
}
if gpus[0].UtilizationPercent != 87 {
t.Errorf("utilisation = %d, want 87", gpus[0].UtilizationPercent)
}
}
+174
View File
@@ -0,0 +1,174 @@
package discovery
import (
"bufio"
"os"
"runtime"
"strconv"
"strings"
)
// Host is what a node is, as far as installing software on it is concerned.
type Host struct {
// OSID, OSVersion and OSCodename come from /etc/os-release: "ubuntu",
// "24.04", "noble".
OSID string `json:"os_id,omitempty"`
OSVersion string `json:"os_version,omitempty"`
OSCodename string `json:"os_codename,omitempty"`
// OSLike is ID_LIKE: the families this system says it follows, "debian"
// for Ubuntu, "rhel centos fedora" for Rocky. It is what decides the
// package manager, which the name alone does not for every derivative.
OSLike []string `json:"os_like,omitempty"`
// Arch is Debian's spelling, "amd64" or "arm64", because that is what
// package names and release archives are keyed by.
Arch string `json:"arch,omitempty"`
// CPUFlags are the vector extensions inference cares about, of those this
// processor has.
CPUFlags []string `json:"cpu_flags,omitempty"`
// NVIDIADriver is the loaded driver's version, empty when there is none.
NVIDIADriver string `json:"nvidia_driver,omitempty"`
// MinComputeCapability is the oldest GPU's, 0 when there are none or it
// is not known. Not read here but from the GPU inventory, by the agent.
MinComputeCapability float64 `json:"min_compute_capability,omitempty"`
}
// Family is the packaging family: "deb", "rpm", or "" when neither is known.
func (h Host) Family() string {
for _, id := range append([]string{h.OSID}, h.OSLike...) {
switch id {
case "debian", "ubuntu":
return "deb"
case "rhel", "fedora", "centos", "rocky", "almalinux", "suse", "opensuse":
return "rpm"
}
}
return ""
}
// HasCPUFlag reports whether the processor has an extension.
func (h Host) HasCPUFlag(f string) bool {
for _, have := range h.CPUFlags {
if have == f {
return true
}
}
return false
}
// interestingFlags are the extensions that decide which build of an engine
// runs well. A VM on QEMU's default CPU model has none of them — and still
// runs, five times slower, with nothing saying why.
var interestingFlags = []string{"avx", "avx2", "fma", "f16c", "avx512f", "avx512_bf16", "amx_tile", "asimd", "sve"}
// DiscoverHost reads what the machine is.
func DiscoverHost() Host {
h := hostFromOSRelease("/etc/os-release")
h.Arch = debianArch(runtime.GOARCH)
h.CPUFlags = cpuFlagsFrom("/proc/cpuinfo")
h.NVIDIADriver = nvidiaDriverFrom("/proc/driver/nvidia/version")
return h
}
func hostFromOSRelease(path string) Host {
var h Host
f, err := os.Open(path) //nolint:gosec // a fixed system path
if err != nil {
return h
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
k, v, ok := strings.Cut(sc.Text(), "=")
if !ok {
continue
}
v = strings.Trim(v, `"'`)
switch k {
case "ID":
h.OSID = v
case "VERSION_ID":
h.OSVersion = v
case "VERSION_CODENAME":
h.OSCodename = v
case "ID_LIKE":
h.OSLike = strings.Fields(v)
}
}
return h
}
func debianArch(goarch string) string {
switch goarch {
case "amd64", "arm64":
return goarch
case "386":
return "i386"
case "arm":
return "armhf"
}
return goarch
}
func cpuFlagsFrom(path string) []string {
b, err := os.ReadFile(path) //nolint:gosec // a fixed system path
if err != nil {
return nil
}
have := map[string]bool{}
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
// "flags" on x86, "Features" on ARM. The first processor is enough:
// a machine does not mix instruction sets across its cores.
if k = strings.TrimSpace(k); k != "flags" && k != "Features" {
continue
}
for _, f := range strings.Fields(v) {
have[f] = true
}
break
}
var out []string
for _, f := range interestingFlags {
if have[f] {
out = append(out, f)
}
}
return out
}
// nvidiaDriverFrom reads the loaded kernel module's version, which exists
// exactly when a driver is loaded — unlike nvidia-smi, which can be installed
// on a machine whose module failed to load.
func nvidiaDriverFrom(path string) string {
b, err := os.ReadFile(path) //nolint:gosec // a fixed system path
if err != nil {
return ""
}
// "NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu ..."
fields := strings.Fields(string(b))
for i, f := range fields {
if f == "Module" && i+1 < len(fields) {
return fields[i+1]
}
}
return ""
}
// MinComputeCapability is the oldest GPU's compute capability, 0 when none
// is known: an engine built for newer GPUs is refused by the oldest.
func MinComputeCapability(gpus []GPU) float64 {
low := 0.0
for _, g := range gpus {
cc, err := strconv.ParseFloat(strings.TrimSpace(g.ComputeCapability), 64)
if err != nil || cc <= 0 {
continue
}
if low == 0 || cc < low {
low = cc
}
}
return low
}
+175
View File
@@ -5,6 +5,7 @@ package discovery
import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"slices"
@@ -42,8 +43,66 @@ type Port struct {
GIDPrefix string `json:"gid_prefix"`
LID int `json:"lid"`
SMLID int `json:"sm_lid"`
// Counters are the port's error and traffic counters, by their sysfs
// names, as far as the driver has them. The data counters are converted
// to bytes; sysfs counts them in four-byte words.
Counters map[string]uint64 `json:"counters,omitempty"`
// RoCEv2GID is the GID table index NCCL should use on a RoCE port: the
// first RoCE v2 entry holding an IPv4 address, else the first RoCE v2
// entry. -1 on InfiniBand, and on a RoCE port with no v2 entry. NCCL's
// default of 0 is a RoCE v1 link-local entry on most setups, which does
// not route, and a job on it hangs.
RoCEv2GID int `json:"rocev2_gid"`
// PortGUID is the port's own identifier, the interface half of its GID.
PortGUID string `json:"port_guid,omitempty"`
// SM is the subnet manager this port sees as master, from sminfo. Nil on
// RoCE, which has none, and when infiniband-diags is not installed.
SM *SMInfo `json:"sm,omitempty"`
// Network is, on RoCE, the IP network of the RoCE v2 address: the only
// thing that says two RoCE ports are on the same fabric.
Network string `json:"network,omitempty"`
}
// SMInfo is a subnet manager as a port sees it.
type SMInfo struct {
GUID string `json:"guid"`
LID int `json:"lid"`
Priority int `json:"priority"`
// State is SMINFO_MASTER, SMINFO_STANDBY, ...
State string `json:"state"`
}
// FabricKey is what identifies the physical fabric this port is on: the
// master subnet manager on InfiniBand, since every port on one fabric sees
// the same one and ports on separate fabrics cannot; the IP network on RoCE.
// Empty when neither is known, and for a port that is not up.
func (p Port) FabricKey() string {
if !p.Active() {
return ""
}
switch {
case p.LinkLayer == LinkEthernet && p.Network != "":
return "roce:" + p.Network
case p.SM != nil && p.SM.GUID != "":
return "sm:" + p.SM.GUID
}
return ""
}
// PortCounters are the counters worth watching, from each port's counters/
// directory. The errors are the ones that say a cable, a transceiver or a
// switch port is going bad before a job fails on it; the data counters say
// whether the fabric is carrying anything at all.
var PortCounters = []string{
"symbol_error", "link_error_recovery", "link_downed", "port_rcv_errors",
"port_rcv_remote_physical_errors", "port_xmit_discards",
"local_link_integrity_errors", "excessive_buffer_overrun_errors",
"port_xmit_data", "port_rcv_data",
}
// dataCounter reports whether a counter counts data in four-byte words.
func dataCounter(name string) bool { return name == "port_xmit_data" || name == "port_rcv_data" }
// Active reports whether the port can carry traffic.
func (p Port) Active() bool { return strings.EqualFold(p.State, "ACTIVE") }
@@ -56,6 +115,9 @@ type HCA struct {
// FirmwareVersion is reported by the driver; worth capturing because
// NCCL behaviour differs between firmware revisions.
FirmwareVersion string `json:"firmware_version,omitempty"`
// NodeGUID identifies the adapter on its fabric: it is the name a
// switch's view of the fabric, and every other tool, knows it by.
NodeGUID string `json:"node_guid,omitempty"`
}
// ActivePorts returns the ports that can carry traffic.
@@ -102,6 +164,7 @@ func DiscoverIB(root string) ([]HCA, error) {
func readHCA(dir, name string) (HCA, error) {
h := HCA{Name: name, NUMANode: -1}
h.FirmwareVersion = readTrimmed(filepath.Join(dir, "fw_ver"))
h.NodeGUID = normalizeGUID(readTrimmed(filepath.Join(dir, "node_guid")))
// device is a symlink into the PCI tree; its target's base name is the
// bus address, and numa_node lives beside it.
@@ -138,6 +201,30 @@ func readPort(dir string, num int) Port {
LID: parseMaybeHex(readTrimmed(filepath.Join(dir, "lid"))),
SMLID: parseMaybeHex(readTrimmed(filepath.Join(dir, "sm_lid"))),
}
p.RoCEv2GID = -1
p.PortGUID = portGUID(readTrimmed(filepath.Join(dir, "gids", "0")))
if p.LinkLayer == LinkEthernet {
p.RoCEv2GID = rocev2GID(dir)
if p.RoCEv2GID >= 0 {
idx := strconv.Itoa(p.RoCEv2GID)
p.Network = RoCENetwork(readTrimmed(filepath.Join(dir, "gid_attrs", "ndevs", idx)),
readTrimmed(filepath.Join(dir, "gids", idx)))
}
}
for _, name := range PortCounters {
raw := readTrimmed(filepath.Join(dir, "counters", name))
v, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
continue // not every driver has every counter
}
if dataCounter(name) {
v *= 4
}
if p.Counters == nil {
p.Counters = map[string]uint64{}
}
p.Counters[name] = v
}
return p
}
@@ -201,3 +288,91 @@ func readTrimmed(path string) string {
}
return strings.TrimSpace(string(b))
}
// rocev2GID finds the GID index a RoCE port's traffic should use.
func rocev2GID(dir string) int {
first := -1
for i := 0; i < 64; i++ {
typ := readTrimmed(filepath.Join(dir, "gid_attrs", "types", strconv.Itoa(i)))
if !strings.EqualFold(typ, "RoCE v2") {
continue
}
gid := strings.ToLower(readTrimmed(filepath.Join(dir, "gids", strconv.Itoa(i))))
if strings.HasPrefix(gid, "0000:0000:0000:0000:0000:ffff:") {
return i
}
if first < 0 {
first = i
}
}
return first
}
// normalizeGUID writes a GUID one way: sysfs says "0c42:a103:0045:bd6c",
// sminfo and ibnetdiscover say "0x0c42a1030045bd6c".
func normalizeGUID(g string) string {
g = strings.ToLower(strings.TrimSpace(g))
g = strings.TrimPrefix(g, "0x")
g = strings.ReplaceAll(g, ":", "")
if g == "" || strings.Trim(g, "0") == "" {
return ""
}
for len(g) < 16 {
g = "0" + g
}
return "0x" + g
}
// NormalizeGUID is normalizeGUID for other packages.
func NormalizeGUID(g string) string { return normalizeGUID(g) }
// portGUID is the interface identifier of a GID: its last four groups.
func portGUID(gid string) string {
parts := strings.Split(gid, ":")
if len(parts) != 8 {
return ""
}
return normalizeGUID(strings.Join(parts[4:], ""))
}
// RoCENetwork is the IP network an IPv4-mapped RoCE GID's address sits in,
// read from the interface that carries it. Replaced in tests.
var RoCENetwork = func(ndev, gid string) string {
ip := gidIPv4(gid)
if ip == nil || ndev == "" {
return ""
}
ifc, err := net.InterfaceByName(ndev)
if err != nil {
return ""
}
addrs, _ := ifc.Addrs()
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.Equal(ip) {
return (&net.IPNet{IP: ip.Mask(n.Mask), Mask: n.Mask}).String()
}
}
return ""
}
// gidIPv4 is the IPv4 address in an IPv4-mapped GID, nil otherwise.
func gidIPv4(gid string) net.IP {
g := strings.ToLower(gid)
const mapped = "0000:0000:0000:0000:0000:ffff:"
if !strings.HasPrefix(g, mapped) {
return nil
}
hex := strings.ReplaceAll(strings.TrimPrefix(g, mapped), ":", "")
if len(hex) != 8 {
return nil
}
b := make(net.IP, 4)
for i := range 4 {
v, err := strconv.ParseUint(hex[i*2:i*2+2], 16, 8)
if err != nil {
return nil
}
b[i] = byte(v)
}
return b
}
+138 -1
View File
@@ -31,6 +31,9 @@ func fakeSysfs(t *testing.T) string {
write("ib/mlx5_0/ports/1/gids/0", "FEC0:0000:0000:000A:0002:C903:00A0:1234")
write("ib/mlx5_0/ports/1/lid", "0x1a")
write("ib/mlx5_0/ports/1/sm_lid", "1")
write("ib/mlx5_0/ports/1/counters/symbol_error", "3")
write("ib/mlx5_0/ports/1/counters/link_downed", "1")
write("ib/mlx5_0/ports/1/counters/port_xmit_data", "1000")
// mlx5_1: two ports, the second one down.
write("ib/mlx5_1/ports/1/state", "4: ACTIVE")
@@ -46,7 +49,13 @@ func fakeSysfs(t *testing.T) string {
write("ib/mlx5_2/ports/1/state", "4: ACTIVE")
write("ib/mlx5_2/ports/1/rate", "100 Gb/sec (4X EDR)")
write("ib/mlx5_2/ports/1/link_layer", "Ethernet")
write("ib/mlx5_2/ports/1/gids/0", "0000:0000:0000:0000:0000:ffff:0a00:0001")
write("ib/mlx5_2/ports/1/gids/0", "fe80:0000:0000:0000:0000:0000:0000:0001")
write("ib/mlx5_2/ports/1/gid_attrs/types/0", "IB/RoCE v1")
write("ib/mlx5_2/ports/1/gids/1", "fe80:0000:0000:0000:0000:0000:0000:0001")
write("ib/mlx5_2/ports/1/gid_attrs/types/1", "RoCE v2")
write("ib/mlx5_2/ports/1/gids/3", "0000:0000:0000:0000:0000:ffff:0a00:0001")
write("ib/mlx5_2/ports/1/gid_attrs/types/3", "RoCE v2")
write("ib/mlx5_2/ports/1/gid_attrs/ndevs/3", "ens1f0")
// A PCI device with a NUMA node, linked the way the driver does it.
pci := filepath.Join(root, "pci", "0000:0b:00.0")
@@ -214,3 +223,131 @@ func TestParseHelpers(t *testing.T) {
}
})
}
func TestAPortsCountersAreRead(t *testing.T) {
hcas, err := DiscoverIB(fakeSysfs(t))
if err != nil {
t.Fatal(err)
}
c := hcas[0].Ports[0].Counters
// Data in bytes: sysfs counts four-byte words.
if c["symbol_error"] != 3 || c["link_downed"] != 1 || c["port_xmit_data"] != 4000 {
t.Errorf("counters = %v", c)
}
if _, ok := c["port_rcv_errors"]; ok {
t.Error("a counter the driver does not have should be absent, not zero")
}
}
func TestRoCEFindsItsRoutableGID(t *testing.T) {
// NCCL's default GID index is a v1 link-local entry on most hosts, which
// does not route: the job hangs. The v2 entry holding an IPv4 address is
// the one to use, over an earlier v2 link-local one.
hcas, err := DiscoverIB(fakeSysfs(t))
if err != nil {
t.Fatal(err)
}
for _, h := range hcas {
p := h.Ports[0]
switch h.Name {
case "mlx5_2":
if p.RoCEv2GID != 3 {
t.Errorf("RoCE GID index = %d, want 3", p.RoCEv2GID)
}
default:
if p.RoCEv2GID != -1 {
t.Errorf("%s is InfiniBand and has a GID index %d", h.Name, p.RoCEv2GID)
}
}
}
}
func TestTheSubnetManagerAPortSeesIsRead(t *testing.T) {
sm, err := ParseSMInfo("sminfo: sm lid 1 sm guid 0x98039B03009fcfd6, activity count 5452 priority 15 state 3 SMINFO_MASTER\n")
if err != nil {
t.Fatal(err)
}
if sm.GUID != "0x98039b03009fcfd6" || sm.LID != 1 || sm.Priority != 15 || sm.State != "SMINFO_MASTER" {
t.Errorf("sm = %+v", sm)
}
if _, err := ParseSMInfo("ibwarn: [1234] mad_rpc_open_port: can't open UMAD port"); err == nil {
t.Error("an error from sminfo should not parse")
}
}
func TestAPortsFabricIsItsSubnetManagerOrItsNetwork(t *testing.T) {
// Two ports on one InfiniBand fabric see one master SM, and on separate
// fabrics cannot; RoCE has no SM, and is its IP network.
hcas, err := DiscoverIB(fakeSysfs(t))
if err != nil {
t.Fatal(err)
}
if hcas[0].NodeGUID != "" {
t.Errorf("no node_guid file, yet %q", hcas[0].NodeGUID)
}
err = AttachSM(hcas, func(hca string, port int) (*SMInfo, error) {
return &SMInfo{GUID: "0x00000000000000aa", LID: 1, State: "SMINFO_MASTER"}, nil
})
if err != nil {
t.Fatal(err)
}
if got := hcas[0].Ports[0].FabricKey(); got != "sm:0x00000000000000aa" {
t.Errorf("IB key = %q", got)
}
if got := hcas[1].Ports[1].FabricKey(); got != "" {
t.Errorf("a port that is down is on no fabric, got %q", got)
}
if hcas[2].Ports[0].SM != nil {
t.Error("RoCE was asked for a subnet manager")
}
if got := hcas[0].Ports[0].PortGUID; got != "0x0002c90300a01234" {
t.Errorf("port GUID = %q", got)
}
}
func TestARoCEPortsNetworkIsItsFabric(t *testing.T) {
old := RoCENetwork
t.Cleanup(func() { RoCENetwork = old })
RoCENetwork = func(ndev, gid string) string {
if ip := gidIPv4(gid); ip != nil && ip.String() == "10.0.0.1" {
return "10.0.0.0/24"
}
return ""
}
hcas, err := DiscoverIB(fakeSysfs(t))
if err != nil {
t.Fatal(err)
}
roce := hcas[2].Ports[0]
if roce.Network != "10.0.0.0/24" || roce.FabricKey() != "roce:10.0.0.0/24" {
t.Errorf("RoCE network %q key %q", roce.Network, roce.FabricKey())
}
}
// smirDump is `saquery SMIR` on a fabric with a master and a standby, in the
// layout infiniband-diags prints.
const smirDump = "SMInfoRecord dump:\n\t\tRID\n\t\tLID...................1\n\t\tSMInfo dump:\n" +
"\t\tGUID..................0x248a07030030f0c0\n\t\tSM_Key................0x0000000000000000\n" +
"\t\tActCount..............386773\n\t\tPriority..............15\n\t\tSMState...............3\n" +
"SMInfoRecord dump:\n\t\tRID\n\t\tLID...................12\n\t\tSMInfo dump:\n" +
"\t\tGUID..................0x248a07030030f1d4\n\t\tSM_Key................0x0000000000000000\n" +
"\t\tActCount..............12\n\t\tPriority..............14\n\t\tSMState...............2\n"
func TestEverySubnetManagerOnAFabricIsListed(t *testing.T) {
recs, err := ParseSMIR(smirDump)
if err != nil {
t.Fatal(err)
}
if len(recs) != 2 {
t.Fatalf("records = %+v", recs)
}
if recs[0].GUID != "0x248a07030030f0c0" || recs[0].State != "master" || recs[0].Priority != 15 || recs[0].LID != 1 {
t.Errorf("master = %+v", recs[0])
}
if recs[1].State != "standby" || recs[1].LID != 12 {
t.Errorf("standby = %+v", recs[1])
}
if _, err := ParseSMIR("ibwarn: can't open UMAD port"); err == nil {
t.Error("an error should not parse")
}
}
+260
View File
@@ -0,0 +1,260 @@
package discovery
import (
"bufio"
"bytes"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// Load is how busy a node is: the machine as a whole, and each engine's share.
type Load struct {
CPUPercent float64
CPUCount int
Load1 float64
Load5 float64
Load15 float64
MemUsed int64
MemAvail int64
Shards []ShardLoad
}
// ShardLoad is one engine process group's use.
type ShardLoad struct {
ID string
CPUCores float64
RSSBytes int64
}
// userHZ is the unit /proc counts CPU time in. Fixed at 100 by the kernel's
// ABI on every architecture a node runs, whatever the internal tick rate.
const userHZ = 100
// LoadSampler measures load as the difference between two readings of /proc.
//
// CPU time is a counter, so a percentage needs two samples and the time
// between them. The sampler keeps the previous one; the first call has
// nothing to compare with and reports no load at all, rather than a zero that
// would read as an idle machine.
//
// Linux only, like every node. Elsewhere the files are missing and it reports
// nothing, as an older agent does.
type LoadSampler struct {
root string
now func() time.Time
mu sync.Mutex
prevAt time.Time
prevCPU cpuTimes
prevPG map[int]groupUse // process group -> what it has used
}
// groupUse is a process group's CPU time so far and its resident memory now.
type groupUse struct {
jiffies uint64
rssPages int64
}
type cpuTimes struct{ total, idle uint64 }
// NewLoadSampler reads the real /proc.
func NewLoadSampler() *LoadSampler { return newLoadSampler("/proc", time.Now) }
func newLoadSampler(root string, now func() time.Time) *LoadSampler {
return &LoadSampler{root: root, now: now}
}
// Sample reads the machine and the process groups led by the given shards'
// PIDs, keyed by shard ID. False when there is nothing to report yet.
func (s *LoadSampler) Sample(shards map[string]int) (Load, bool) {
cpu, ok := s.cpu()
if !ok {
return Load{}, false
}
groups := s.groups()
at := s.now()
s.mu.Lock()
prevAt, prevCPU, prevPG := s.prevAt, s.prevCPU, s.prevPG
s.prevAt, s.prevCPU, s.prevPG = at, cpu, groups
s.mu.Unlock()
if prevAt.IsZero() || cpu.total <= prevCPU.total {
return Load{}, false
}
l := Load{CPUCount: s.cpuCount()}
dTotal := float64(cpu.total - prevCPU.total)
dIdle := float64(cpu.idle - prevCPU.idle)
l.CPUPercent = clamp((dTotal-dIdle)/dTotal*100, 0, 100)
l.Load1, l.Load5, l.Load15 = s.loadavg()
total, avail := s.meminfo()
if total > 0 {
l.MemUsed, l.MemAvail = total-avail, avail
}
secs := at.Sub(prevAt).Seconds()
for id, pid := range shards {
if pid <= 0 {
continue
}
now := groups[pid]
sl := ShardLoad{ID: id, RSSBytes: now.rssPages * int64(os.Getpagesize())}
if before, ok := prevPG[pid]; ok && secs > 0 && now.jiffies >= before.jiffies {
sl.CPUCores = float64(now.jiffies-before.jiffies) / userHZ / secs
}
l.Shards = append(l.Shards, sl)
}
return l, true
}
func clamp(v, lo, hi float64) float64 { return max(lo, min(v, hi)) }
// cpu reads the aggregate line of /proc/stat. Idle includes iowait: a core
// waiting on the disk is not doing work.
func (s *LoadSampler) cpu() (cpuTimes, bool) {
b, err := os.ReadFile(filepath.Join(s.root, "stat"))
if err != nil {
return cpuTimes{}, false
}
line, _, _ := bytes.Cut(b, []byte("\n"))
f := strings.Fields(string(line))
if len(f) < 5 || f[0] != "cpu" {
return cpuTimes{}, false
}
var t cpuTimes
for i, v := range f[1:] {
// guest and guest_nice are already counted in user and nice.
if i >= 8 {
break
}
n, _ := strconv.ParseUint(v, 10, 64)
t.total += n
if i == 3 || i == 4 {
t.idle += n
}
}
return t, true
}
func (s *LoadSampler) cpuCount() int {
b, err := os.ReadFile(filepath.Join(s.root, "stat"))
if err != nil {
return 0
}
n := 0
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "cpu") && len(line) > 3 && line[3] >= '0' && line[3] <= '9' {
n++
}
}
return n
}
func (s *LoadSampler) loadavg() (l1, l5, l15 float64) {
b, err := os.ReadFile(filepath.Join(s.root, "loadavg"))
if err != nil {
return 0, 0, 0
}
f := strings.Fields(string(b))
if len(f) < 3 {
return 0, 0, 0
}
l1, _ = strconv.ParseFloat(f[0], 64)
l5, _ = strconv.ParseFloat(f[1], 64)
l15, _ = strconv.ParseFloat(f[2], 64)
return l1, l5, l15
}
// meminfo returns MemTotal and MemAvailable in bytes.
func (s *LoadSampler) meminfo() (total, avail int64) {
f, err := os.Open(filepath.Join(s.root, "meminfo"))
if err != nil {
return 0, 0
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
k, v, ok := strings.Cut(sc.Text(), ":")
if !ok {
continue
}
fields := strings.Fields(v)
if len(fields) == 0 {
continue
}
kb, _ := strconv.ParseInt(fields[0], 10, 64)
switch k {
case "MemTotal":
total = kb * 1024
case "MemAvailable":
avail = kb * 1024
}
}
return total, avail
}
// procStat is what one /proc/<pid>/stat says that load needs.
type procStat struct {
pgrp int
jiffies uint64
rssPages int64
}
// readProcStat parses /proc/<pid>/stat. The command name is in parentheses
// and may itself hold spaces and parentheses, so fields are counted from the
// last closing one.
func readProcStat(path string) (procStat, bool) {
b, err := os.ReadFile(path)
if err != nil {
return procStat{}, false
}
i := bytes.LastIndexByte(b, ')')
if i < 0 {
return procStat{}, false
}
f := strings.Fields(string(b[i+1:]))
// After the name: state(0) ppid(1) pgrp(2) ... utime(11) stime(12) ... rss(21)
if len(f) < 22 {
return procStat{}, false
}
var st procStat
st.pgrp, _ = strconv.Atoi(f[2])
ut, _ := strconv.ParseUint(f[11], 10, 64)
stt, _ := strconv.ParseUint(f[12], 10, 64)
st.jiffies = ut + stt
st.rssPages, _ = strconv.ParseInt(f[21], 10, 64)
return st, true
}
// groups totals CPU time and memory per process group, in one pass over
// /proc. An engine is started as the leader of its own group, so its workers
// — vLLM starts several — are counted with it.
func (s *LoadSampler) groups() map[int]groupUse {
out := map[int]groupUse{}
s.eachProc(func(st procStat) {
g := out[st.pgrp]
g.jiffies += st.jiffies
g.rssPages += st.rssPages
out[st.pgrp] = g
})
return out
}
func (s *LoadSampler) eachProc(fn func(procStat)) {
entries, err := os.ReadDir(s.root)
if err != nil {
return
}
for _, e := range entries {
if _, err := strconv.Atoi(e.Name()); err != nil {
continue
}
if st, ok := readProcStat(filepath.Join(s.root, e.Name(), "stat")); ok {
fn(st)
}
}
}
+96
View File
@@ -0,0 +1,96 @@
package discovery
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
)
// fakeProc is a /proc with two CPUs, a memory picture and processes.
type fakeProc struct {
t *testing.T
root string
}
func newFakeProc(t *testing.T) *fakeProc {
f := &fakeProc{t: t, root: t.TempDir()}
f.write("loadavg", "1.50 0.75 0.25 2/300 4242\n")
f.write("meminfo", "MemTotal: 8000000 kB\nMemFree: 1000000 kB\nMemAvailable: 3000000 kB\n")
return f
}
func (f *fakeProc) write(name, body string) {
f.t.Helper()
p := filepath.Join(f.root, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
f.t.Fatal(err)
}
}
// cpu sets the aggregate counters: user and idle, the rest zero.
func (f *fakeProc) cpu(user, idle uint64) {
f.write("stat", fmt.Sprintf("cpu %d 0 0 %d 0 0 0 0 0 0\ncpu0 0 0 0 0 0 0 0 0 0 0\ncpu1 0 0 0 0 0 0 0 0 0 0\nintr 1\n", user, idle))
}
// proc sets one process: its group, CPU jiffies (as utime) and RSS in pages.
func (f *fakeProc) proc(pid, pgrp int, jiffies uint64, rss int64) {
// The name holds a space and a parenthesis, as real ones can.
f.write(fmt.Sprintf("%d/stat", pid), fmt.Sprintf(
"%d (llama-server (x)) S 1 %d %d 0 -1 0 0 0 0 0 %d 0 0 0 20 0 1 0 100 1000 %d 0\n",
pid, pgrp, pgrp, jiffies, rss))
}
func TestLoadIsTheDifferenceBetweenTwoReadings(t *testing.T) {
p := newFakeProc(t)
at := time.Unix(1000, 0)
s := newLoadSampler(p.root, func() time.Time { return at })
p.cpu(100, 900)
p.proc(42, 42, 1000, 10)
p.proc(43, 42, 500, 5) // a worker in the engine's group
p.proc(7, 7, 99999, 1) // something else entirely
if _, ok := s.Sample(map[string]int{"shard-a": 42}); ok {
t.Fatal("the first reading has nothing to compare with and should report nothing")
}
// Two seconds later: 300 of the 400 jiffies that passed were busy, and
// the engine's group used 400 of them.
at = at.Add(2 * time.Second)
p.cpu(400, 1000)
p.proc(42, 42, 1300, 10)
p.proc(43, 42, 600, 6)
l, ok := s.Sample(map[string]int{"shard-a": 42})
if !ok {
t.Fatal("the second reading should report")
}
if l.CPUPercent != 75 || l.CPUCount != 2 {
t.Errorf("cpu %.1f%% of %d, want 75%% of 2", l.CPUPercent, l.CPUCount)
}
if l.Load1 != 1.5 || l.Load15 != 0.25 {
t.Errorf("load %v %v", l.Load1, l.Load15)
}
if l.MemUsed != 5000000*1024 || l.MemAvail != 3000000*1024 {
t.Errorf("memory used %d available %d", l.MemUsed, l.MemAvail)
}
if len(l.Shards) != 1 {
t.Fatalf("shards = %+v", l.Shards)
}
sh := l.Shards[0]
// 400 jiffies over 2 seconds at 100 a second is two cores.
if sh.ID != "shard-a" || sh.CPUCores != 2 || sh.RSSBytes != 16*int64(os.Getpagesize()) {
t.Errorf("shard = %+v", sh)
}
}
func TestNoProcMeansNoLoad(t *testing.T) {
s := newLoadSampler(filepath.Join(t.TempDir(), "missing"), time.Now)
s.Sample(nil)
if _, ok := s.Sample(nil); ok {
t.Error("a system without /proc should report nothing, not zeros")
}
}
+51
View File
@@ -0,0 +1,51 @@
package discovery
import (
"bufio"
"os"
"strconv"
"strings"
)
// MemoryTotalMB reports the machine's total RAM.
//
// It exists because placement had no idea how much a node could hold. A CPU
// profile has no card to be free, so every ready node looked like a candidate:
// a 14B model whose working set is 24 GB would have been placed just as
// happily on a node with 8 GB, which reports itself perfectly healthy right up
// until the OOM killer arrives. Labels were the stopgap; this is the fact they
// were standing in for.
//
// Read from /proc/meminfo, which is Linux-only — every node is. On anything
// else it returns 0, which callers treat as "unknown" and therefore no
// constraint, exactly as an older agent that does not report it at all.
func MemoryTotalMB() int { return memTotalMBFrom("/proc/meminfo") }
func memTotalMBFrom(path string) int {
f, err := os.Open(path)
if err != nil {
return 0
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
rest, ok := strings.CutPrefix(sc.Text(), "MemTotal:")
if !ok {
continue
}
fields := strings.Fields(rest)
if len(fields) == 0 {
return 0
}
kb, err := strconv.Atoi(fields[0])
if err != nil {
return 0
}
// MemTotal is what the kernel can hand out, already short of the
// installed total by the firmware's reservations. That is the right
// number for deciding what fits.
return kb / 1024
}
return 0
}
+39
View File
@@ -0,0 +1,39 @@
package discovery
import (
"os"
"path/filepath"
"testing"
)
func TestMemTotalIsReadFromMeminfo(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "meminfo")
// The real shape, including the fields before and after, so the parser is
// not tested against something tidier than it will see.
if err := os.WriteFile(p, []byte(
"MemTotal: 32876244 kB\nMemFree: 1234 kB\nMemAvailable: 5678 kB\n"), 0o600); err != nil {
t.Fatal(err)
}
if got, want := memTotalMBFrom(p), 32876244/1024; got != want {
t.Errorf("memTotalMBFrom = %d, want %d", got, want)
}
}
func TestUnreadableMeminfoReportsUnknownRatherThanZeroMemory(t *testing.T) {
// Zero means "did not say" everywhere downstream, and placement treats it
// as no constraint. A node with genuinely no memory is not a case.
if got := memTotalMBFrom(filepath.Join(t.TempDir(), "absent")); got != 0 {
t.Errorf("got %d for a missing file", got)
}
dir := t.TempDir()
p := filepath.Join(dir, "meminfo")
for _, junk := range []string{"", "MemTotal:\n", "MemTotal: not-a-number kB\n", "Other: 1 kB\n"} {
if err := os.WriteFile(p, []byte(junk), 0o600); err != nil {
t.Fatal(err)
}
if got := memTotalMBFrom(p); got != 0 {
t.Errorf("got %d for %q", got, junk)
}
}
}
+133
View File
@@ -0,0 +1,133 @@
package discovery
import (
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// WriteBW is one side of an ib_write_bw run, from perftest: an RDMA write
// bandwidth test between two adapters, which is what a collective spanning
// nodes rides on.
type WriteBW struct {
HCA string
Port int
// GIDIndex is the RoCE v2 GID to use; negative on InfiniBand, where the
// port's LID is used.
GIDIndex int
// Peer is the server's address, for the client; empty makes the server.
Peer string
TCPPort int
Seconds int
}
// Args are the command line: a duration rather than a count, so the test
// takes as long on a slow link as a fast one, and results in Gb/s.
func (w WriteBW) Args() []string {
args := []string{"-d", w.HCA, "-i", strconv.Itoa(max(w.Port, 1)),
"-p", strconv.Itoa(w.TCPPort), "-D", strconv.Itoa(max(w.Seconds, 1)),
"-F", "--report_gbits"}
if w.GIDIndex >= 0 {
args = append(args, "-x", strconv.Itoa(w.GIDIndex))
}
if w.Peer != "" {
args = append(args, w.Peer)
}
return args
}
// RunWriteBW runs one side and returns the average bandwidth the output
// reports, in Gb/s, and the output. The client retries its connection for a
// while: the server is started first, but a few seconds may pass before it
// listens.
func RunWriteBW(ctx context.Context, w WriteBW) (float64, string, error) {
bin, err := exec.LookPath("ib_write_bw")
if err != nil {
return 0, "", errors.New("ib_write_bw is not installed (the perftest package)")
}
limit := time.Duration(w.Seconds+60) * time.Second
var out []byte
for attempt := 0; ; attempt++ {
cctx, cancel := context.WithTimeout(ctx, limit)
out, err = exec.CommandContext(cctx, bin, w.Args()...).CombinedOutput() //nolint:gosec // fixed tool
cancel()
if err == nil || w.Peer == "" || !refused(string(out)) || attempt >= 10 {
break
}
select {
case <-ctx.Done():
return 0, string(out), ctx.Err()
case <-time.After(time.Second):
}
}
text := string(out)
if err != nil {
return 0, text, fmt.Errorf("ib_write_bw: %v: %s", err, lastNonEmpty(text))
}
gbps, perr := ParseWriteBW(text)
if perr != nil && w.Peer != "" {
// Only the client is sure to print the result; a server that does not
// is not a failure.
return 0, text, perr
}
return gbps, text, nil
}
func refused(out string) bool {
for _, s := range []string{"Couldn't connect", "Unable to init the socket connection", "Connection refused"} {
if strings.Contains(out, s) {
return true
}
}
return false
}
var columnSplit = regexp.MustCompile(`\s{2,}`)
// ParseWriteBW finds the average bandwidth in perftest's result table:
//
// #bytes #iterations BW peak[Gb/sec] BW average[Gb/sec] MsgRate[Mpps]
// 65536 887036 0.00 96.93 0.184877
//
// by the heading's position rather than a fixed column, since versions add
// columns and the peak reads 0.00 when the test runs for a duration.
func ParseWriteBW(out string) (float64, error) {
lines := strings.Split(out, "\n")
for i, l := range lines {
if !strings.Contains(l, "BW average") {
continue
}
heads := columnSplit.Split(strings.TrimSpace(l), -1)
col := -1
for j, h := range heads {
if strings.HasPrefix(h, "BW average") {
col = j
}
}
for _, d := range lines[i+1:] {
f := strings.Fields(d)
if len(f) == 0 || strings.HasPrefix(f[0], "-") {
continue
}
if len(f) != len(heads) || col < 0 {
return 0, fmt.Errorf("perftest's result table has a shape this does not know: %q / %q", l, d)
}
v, err := strconv.ParseFloat(f[col], 64)
if err != nil {
return 0, fmt.Errorf("reading the average bandwidth %q: %w", f[col], err)
}
return v, nil
}
}
return 0, errors.New("perftest printed no result")
}
func lastNonEmpty(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
return strings.TrimSpace(lines[len(lines)-1])
}
+49
View File
@@ -0,0 +1,49 @@
package discovery
import (
"slices"
"testing"
)
// The client's output of perftest 24.x, with a duration.
const writeBWClient = `---------------------------------------------------------------------------------------
RDMA_Write BW Test
Dual-port : OFF Device : mlx5_0
Number of qps : 1 Transport type : IB
Connection type : RC Using SRQ : OFF
TX depth : 128
CQ Moderation : 100
Mtu : 4096[B]
Link type : IB
Max inline data : 0[B]
rdma_cm QPs : OFF
Data ex. method : Ethernet
---------------------------------------------------------------------------------------
local address: LID 0x05 QPN 0x0112 PSN 0x7b1f02 RKey 0x1fffbd VAddr 0x007f1d2a5f0000
remote address: LID 0x09 QPN 0x0113 PSN 0x2c1e44 RKey 0x1ffeaf VAddr 0x007fa4e1a60000
---------------------------------------------------------------------------------------
#bytes #iterations BW peak[Gb/sec] BW average[Gb/sec] MsgRate[Mpps]
65536 1851214 0.00 388.21 0.740422
---------------------------------------------------------------------------------------
`
func TestTheAverageBandwidthIsReadFromPerftest(t *testing.T) {
got, err := ParseWriteBW(writeBWClient)
if err != nil || got != 388.21 {
t.Errorf("got %v, %v", got, err)
}
if _, err := ParseWriteBW(" Couldn't connect to 10.0.0.2:18515\n"); err == nil {
t.Error("output with no result table gave a number")
}
}
func TestWriteBWArguments(t *testing.T) {
ib := WriteBW{HCA: "mlx5_0", Port: 1, GIDIndex: -1, TCPPort: 18515, Seconds: 3}.Args()
if slices.Contains(ib, "-x") {
t.Errorf("InfiniBand was given a GID index: %v", ib)
}
roce := WriteBW{HCA: "mlx5_0", Port: 1, GIDIndex: 3, Peer: "10.0.0.2", TCPPort: 18515, Seconds: 3}.Args()
if !slices.Contains(roce, "-x") || roce[len(roce)-1] != "10.0.0.2" {
t.Errorf("RoCE client: %v", roce)
}
}
+242
View File
@@ -0,0 +1,242 @@
package discovery
import (
"context"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// Sensors is what a node's own hardware says about heat and power: the CPUs,
// the board where it has an inlet sensor, and each GPU. A zero is not
// measured, never a reading of zero — a VM has none of these, and must not
// look like a machine running at 0 °C on no power.
type Sensors struct {
// CPUTempC is the hottest CPU sensor: the package, or the hottest core
// where no package reading exists.
CPUTempC float64
// CPUPowerW is the CPU packages' draw, from RAPL: the change in their
// energy counters over the time between two samples.
CPUPowerW float64
// InletTempC is the air coming in, where the board has a sensor it calls
// that. Most do not, and a CPU's temperature is not a substitute: it
// follows the load, not the room.
InletTempC float64
GPUs []GPUSensor
}
// GPUSensor is one card's heat, power and whether it is slowing itself down.
type GPUSensor struct {
Index int
TempC float64
MemoryTempC float64
PowerW float64
PowerLimitW float64
// Throttle names what is holding the card's clocks down now:
// sw_power_cap, hw_slowdown, sw_thermal, hw_thermal, power_brake. Empty
// is running free. Idle and application clock settings are left out;
// they are not the card being held back.
Throttle []string
}
// sensorEvery bounds how often nvidia-smi is run. Heartbeats come more often
// than temperatures change, and each run costs a few hundred milliseconds.
const sensorEvery = 10 * time.Second
// SensorSampler reads Sensors. The first sample has no CPU power, which needs
// two readings of the energy counters.
type SensorSampler struct {
sys string // /sys, or a test's copy of it
smi func(ctx context.Context, fields string) ([]byte, error)
now func() time.Time
mu sync.Mutex
last Sensors
lastAt time.Time
prevEnergy map[string]uint64
prevAt time.Time
}
// NewSensorSampler reads this machine.
func NewSensorSampler() *SensorSampler {
return &SensorSampler{sys: "/sys", smi: runSMI, now: time.Now}
}
func runSMI(ctx context.Context, fields string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
//nolint:gosec // fixed binary, fields from this file
return exec.CommandContext(ctx, "nvidia-smi", "--query-gpu="+fields,
"--format=csv,noheader,nounits").Output()
}
// Sample reads the sensors, or returns the last reading when that is recent.
func (s *SensorSampler) Sample(ctx context.Context) Sensors {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
if !s.lastAt.IsZero() && now.Sub(s.lastAt) < sensorEvery {
return s.last
}
out := Sensors{}
out.CPUTempC, out.InletTempC = s.hwmon()
out.CPUPowerW = s.rapl(now)
out.GPUs = s.gpus(ctx)
s.last, s.lastAt = out, now
return out
}
// hwmon reads the hottest CPU sensor and an inlet sensor, from every hwmon
// device the kernel exposes — what lm_sensors reads, without it installed.
func (s *SensorSampler) hwmon() (cpu, inlet float64) {
devs, _ := filepath.Glob(filepath.Join(s.sys, "class/hwmon/hwmon*"))
var pkg, core float64
for _, d := range devs {
name := readTrim(filepath.Join(d, "name"))
inputs, _ := filepath.Glob(filepath.Join(d, "temp*_input"))
for _, in := range inputs {
milli, err := strconv.ParseFloat(readTrim(in), 64)
if err != nil || milli <= 0 {
continue
}
c := milli / 1000
label := strings.ToLower(readTrim(strings.TrimSuffix(in, "_input") + "_label"))
switch {
case isCPUSensor(name) && (strings.HasPrefix(label, "package") || label == "tctl" || label == "tdie"):
pkg = max(pkg, c)
case isCPUSensor(name):
core = max(core, c)
case label == "cpu":
// A board's own reading of the CPU, as Dell's SMM gives.
core = max(core, c)
case strings.Contains(label, "inlet") || strings.Contains(label, "ambient") ||
strings.Contains(label, "intake") || label == "systin":
inlet = max(inlet, c)
}
}
}
if pkg > 0 {
return pkg, inlet
}
return core, inlet
}
func isCPUSensor(name string) bool {
switch name {
case "coretemp", "k10temp", "zenpower", "cpu_thermal":
return true
}
return false
}
// rapl is the CPU packages' power from their energy counters. Package
// domains only: the dram and core subdomains are parts of a package, and
// adding them would count the same energy twice.
func (s *SensorSampler) rapl(now time.Time) float64 {
doms, _ := filepath.Glob(filepath.Join(s.sys, "class/powercap/intel-rapl:*"))
energy := map[string]uint64{}
wrap := map[string]uint64{}
for _, d := range doms {
if strings.Count(filepath.Base(d), ":") != 1 {
continue // intel-rapl:0:0 is a subdomain of intel-rapl:0
}
e, err := strconv.ParseUint(readTrim(filepath.Join(d, "energy_uj")), 10, 64)
if err != nil {
continue // unreadable without root on newer kernels
}
energy[d] = e
wrap[d], _ = strconv.ParseUint(readTrim(filepath.Join(d, "max_energy_range_uj")), 10, 64)
}
prev, prevAt := s.prevEnergy, s.prevAt
s.prevEnergy, s.prevAt = energy, now
dt := now.Sub(prevAt).Seconds()
if prev == nil || dt <= 0 || len(energy) == 0 {
return 0
}
var joules float64
for d, e := range energy {
p, ok := prev[d]
if !ok {
return 0 // a domain appeared: no fair difference this time
}
if e < p { // the counter wrapped
e += wrap[d]
}
joules += float64(e-p) / 1e6
}
return joules / dt
}
// The throttle reasons nvidia-smi reports, as bits. Only those that mean the
// card is being held back are named.
var throttleBits = []struct {
bit uint64
name string
}{
{0x4, "sw_power_cap"},
{0x8, "hw_slowdown"},
{0x20, "sw_thermal"},
{0x40, "hw_thermal"},
{0x80, "power_brake"},
}
// gpus asks nvidia-smi for each card's heat and power. The memory temperature
// is not known to every driver, and a field nvidia-smi does not know fails the
// whole query, so it is asked for first and dropped on failure.
func (s *SensorSampler) gpus(ctx context.Context) []GPUSensor {
withMem := "index,temperature.gpu,power.draw,enforced.power.limit,clocks_throttle_reasons.active,temperature.memory"
out, err := s.smi(ctx, withMem)
if err != nil {
if out, err = s.smi(ctx, strings.TrimSuffix(withMem, ",temperature.memory")); err != nil {
return nil
}
}
var gs []GPUSensor
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
f := strings.Split(line, ",")
if len(f) < 5 {
continue
}
for i := range f {
f[i] = strings.TrimSpace(f[i])
}
idx, err := strconv.Atoi(f[0])
if err != nil {
continue
}
g := GPUSensor{Index: idx, TempC: num(f[1]), PowerW: num(f[2]), PowerLimitW: num(f[3])}
if bits, err := strconv.ParseUint(strings.TrimPrefix(f[4], "0x"), 16, 64); err == nil {
for _, b := range throttleBits {
if bits&b.bit != 0 {
g.Throttle = append(g.Throttle, b.name)
}
}
}
if len(f) > 5 {
g.MemoryTempC = num(f[5])
}
gs = append(gs, g)
}
return gs
}
// num reads a figure nvidia-smi may give as "[N/A]" or "N/A".
func num(s string) float64 {
v, err := strconv.ParseFloat(s, 64)
if err != nil || v < 0 {
return 0
}
return v
}
func readTrim(path string) string {
b, err := os.ReadFile(path) //nolint:gosec // sysfs
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
+169
View File
@@ -0,0 +1,169 @@
package discovery
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func put(t *testing.T, root, rel, body string) {
t.Helper()
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
// dellHost is the layout of the Proxmox host these tests were written
// against: Dell's SMM with a CPU and SODIMM sensors, coretemp with a package
// and cores, and RAPL with a package and its dram subdomain.
func dellHost(t *testing.T) string {
root := t.TempDir()
put(t, root, "class/hwmon/hwmon0/name", "dell_smm")
put(t, root, "class/hwmon/hwmon0/temp1_input", "55000")
put(t, root, "class/hwmon/hwmon0/temp1_label", "CPU")
put(t, root, "class/hwmon/hwmon0/temp2_input", "47000")
put(t, root, "class/hwmon/hwmon0/temp2_label", "SODIMM")
put(t, root, "class/hwmon/hwmon1/name", "coretemp")
put(t, root, "class/hwmon/hwmon1/temp1_input", "58000")
put(t, root, "class/hwmon/hwmon1/temp1_label", "Package id 0")
put(t, root, "class/hwmon/hwmon1/temp10_input", "61000")
put(t, root, "class/hwmon/hwmon1/temp10_label", "Core 8")
put(t, root, "class/powercap/intel-rapl:0/energy_uj", "1000000000")
put(t, root, "class/powercap/intel-rapl:0/max_energy_range_uj", "262143328850")
put(t, root, "class/powercap/intel-rapl:0:0/energy_uj", "5000000")
return root
}
func sampler(sys string, smi func(context.Context, string) ([]byte, error), clock *time.Time) *SensorSampler {
return &SensorSampler{sys: sys, smi: smi, now: func() time.Time { return *clock }}
}
func noSMI(context.Context, string) ([]byte, error) { return nil, errors.New("not found") }
func TestAHostsCPUHeatAndPowerAreReadAsLmSensorsWould(t *testing.T) {
root := dellHost(t)
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s := sampler(root, noSMI, &now)
first := s.Sample(t.Context())
// The package reading, not the hotter core: the package is what the
// chip itself reports as its temperature.
if first.CPUTempC != 58 {
t.Errorf("CPU %.1f °C, expected the package's 58", first.CPUTempC)
}
if first.CPUPowerW != 0 {
t.Error("power from one reading of a counter is invented")
}
if first.InletTempC != 0 || first.GPUs != nil {
t.Errorf("sensors this host does not have were reported: %+v", first)
}
// 10 s later the package has used 650 J, and its dram subdomain some of
// that — counted once.
put(t, root, "class/powercap/intel-rapl:0/energy_uj", "1650000000")
put(t, root, "class/powercap/intel-rapl:0:0/energy_uj", "99000000")
now = now.Add(10 * time.Second)
if got := s.Sample(t.Context()).CPUPowerW; got < 64.9 || got > 65.1 {
t.Errorf("CPU power %.2f W, expected 65", got)
}
}
func TestACounterThatWrapsIsNotANegativeDraw(t *testing.T) {
root := dellHost(t)
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
put(t, root, "class/powercap/intel-rapl:0/energy_uj", "262143000000")
s := sampler(root, noSMI, &now)
s.Sample(t.Context())
// 328,850 µJ to the wrap, then 499,671,150 past it: 500 J in 10 s.
put(t, root, "class/powercap/intel-rapl:0/energy_uj", "499671150")
now = now.Add(10 * time.Second)
if got := s.Sample(t.Context()).CPUPowerW; got < 49.9 || got > 50.1 {
t.Errorf("across a wrap: %.2f W, expected 50", got)
}
}
func TestAVMHasNoSensorsAndSaysNothing(t *testing.T) {
root := t.TempDir()
put(t, root, "class/thermal/cooling_device0/type", "Processor")
now := time.Now()
got := sampler(root, noSMI, &now).Sample(t.Context())
if got.CPUTempC != 0 || got.CPUPowerW != 0 || got.InletTempC != 0 || got.GPUs != nil {
t.Errorf("a machine with no sensors reported %+v", got)
}
}
func TestABoardsInletSensorIsTheRoomNotTheCPU(t *testing.T) {
root := t.TempDir()
put(t, root, "class/hwmon/hwmon2/name", "nct6798")
put(t, root, "class/hwmon/hwmon2/temp1_input", "24500")
put(t, root, "class/hwmon/hwmon2/temp1_label", "SYSTIN")
put(t, root, "class/hwmon/hwmon3/name", "k10temp")
put(t, root, "class/hwmon/hwmon3/temp1_input", "71250")
put(t, root, "class/hwmon/hwmon3/temp1_label", "Tctl")
now := time.Now()
got := sampler(root, noSMI, &now).Sample(t.Context())
if got.InletTempC != 24.5 || got.CPUTempC != 71.25 {
t.Errorf("inlet %.2f, CPU %.2f", got.InletTempC, got.CPUTempC)
}
}
func TestEachGPUSaysHowHotHowHungryAndWhatHoldsItBack(t *testing.T) {
var asked []string
smi := func(_ context.Context, fields string) ([]byte, error) {
asked = append(asked, fields)
return []byte(
"0, 71, 612.45, 700.00, 0x0000000000000000, 84\n" +
"1, 88, 698.10, 700.00, 0x0000000000000024, 95\n" + // sw_power_cap + sw_thermal
"2, 45, [N/A], 700.00, 0x0000000000000001, [N/A]\n"), nil // idle only
}
now := time.Now()
ss := sampler(t.TempDir(), smi, &now)
got := ss.Sample(t.Context())
if len(got.GPUs) != 3 {
t.Fatalf("%+v", got.GPUs)
}
g0, g1, g2 := got.GPUs[0], got.GPUs[1], got.GPUs[2]
if g0.TempC != 71 || g0.PowerW != 612.45 || g0.PowerLimitW != 700 || g0.MemoryTempC != 84 || g0.Throttle != nil {
t.Errorf("card 0 %+v", g0)
}
if strings.Join(g1.Throttle, ",") != "sw_power_cap,sw_thermal" {
t.Errorf("card 1 throttled by %v", g1.Throttle)
}
if g2.PowerW != 0 || g2.MemoryTempC != 0 || g2.Throttle != nil {
t.Errorf("card 2: N/A is not a reading, and idle is not being held back: %+v", g2)
}
// Within ten seconds the last reading is reused rather than running
// nvidia-smi on every heartbeat; after, it is asked again.
now = now.Add(5 * time.Second)
ss.Sample(t.Context())
if len(asked) != 1 {
t.Errorf("nvidia-smi was asked %d times within ten seconds", len(asked))
}
now = now.Add(6 * time.Second)
ss.Sample(t.Context())
if len(asked) != 2 {
t.Errorf("nvidia-smi was asked %d times after eleven seconds, expected 2", len(asked))
}
}
func TestADriverThatDoesNotKnowMemoryTemperatureStillReportsTheRest(t *testing.T) {
smi := func(_ context.Context, fields string) ([]byte, error) {
if strings.Contains(fields, "temperature.memory") {
return nil, errors.New(`Field "temperature.memory" is not a valid field to query.`)
}
return []byte("0, 66, 250.0, 300.0, 0x0000000000000040\n"), nil
}
now := time.Now()
got := sampler(t.TempDir(), smi, &now).Sample(t.Context())
if len(got.GPUs) != 1 || got.GPUs[0].TempC != 66 || strings.Join(got.GPUs[0].Throttle, ",") != "hw_thermal" {
t.Errorf("%+v", got.GPUs)
}
}
+172
View File
@@ -0,0 +1,172 @@
package discovery
import (
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// ErrNoDiags means infiniband-diags is not installed, so the subnet manager
// cannot be asked who it is.
var ErrNoDiags = errors.New("sminfo not found: install infiniband-diags")
// smLine is sminfo's one line of answer:
//
// sminfo: sm lid 1 sm guid 0x98039b03009fcfd6, activity count 5452 priority 15 state 3 SMINFO_MASTER
var smLine = regexp.MustCompile(`sm lid (\d+) sm guid (0x[0-9a-fA-F]+), activity count \d+ priority (\d+) state \d+ (\S+)`)
// ParseSMInfo reads sminfo's output.
func ParseSMInfo(out string) (*SMInfo, error) {
m := smLine.FindStringSubmatch(out)
if m == nil {
return nil, fmt.Errorf("unrecognised sminfo output: %q", strings.TrimSpace(out))
}
lid, _ := strconv.Atoi(m[1])
prio, _ := strconv.Atoi(m[3])
return &SMInfo{GUID: normalizeGUID(m[2]), LID: lid, Priority: prio, State: m[4]}, nil
}
// QuerySM asks, through one port, which subnet manager is master. It is a
// management datagram to the SM, not a scan: cheap, but not free, which is
// why the agent asks once a minute rather than every heartbeat.
func QuerySM(ctx context.Context, hca string, port int) (*SMInfo, error) {
bin, err := exec.LookPath("sminfo")
if err != nil {
return nil, ErrNoDiags
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, bin, "-C", hca, "-P", strconv.Itoa(port)).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("sminfo on %s/%d: %w: %s", hca, port, err, strings.TrimSpace(string(out)))
}
return ParseSMInfo(string(out))
}
// AttachSM fills in the subnet manager each active InfiniBand port sees.
// RoCE has none. The first error is returned for reporting; the ports it
// could answer for keep their answer.
func AttachSM(hcas []HCA, query func(hca string, port int) (*SMInfo, error)) error {
var first error
for i := range hcas {
for j := range hcas[i].Ports {
p := &hcas[i].Ports[j]
if !p.Active() || p.LinkLayer == LinkEthernet {
continue
}
sm, err := query(hcas[i].Name, p.Num)
if err != nil {
if first == nil {
first = err
}
continue
}
p.SM = sm
}
}
return first
}
// SMRecord is one subnet manager on a fabric, as the subnet administrator
// lists them: master and standbys alike.
type SMRecord struct {
GUID string `json:"guid"`
LID int `json:"lid"`
Priority int `json:"priority"`
// State is master, standby, discovering or not active.
State string `json:"state"`
}
// smStates are SMInfo's state numbers.
var smStates = map[string]string{"0": "not active", "1": "discovering", "2": "standby", "3": "master"}
// smirField is one line of saquery's record dump: "LID.........1".
var smirField = regexp.MustCompile(`^\s*([A-Za-z_]+)\.{2,}\s*(\S+)`)
// ParseSMIR reads `saquery SMIR`: every subnet manager the fabric's subnet
// administrator knows of, one "SMInfoRecord dump:" block each.
func ParseSMIR(out string) ([]SMRecord, error) {
var recs []SMRecord
var cur *SMRecord
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "SMInfoRecord dump") {
recs = append(recs, SMRecord{})
cur = &recs[len(recs)-1]
continue
}
m := smirField.FindStringSubmatch(line)
if m == nil || cur == nil {
continue
}
switch strings.ToLower(m[1]) {
case "lid":
cur.LID, _ = strconv.Atoi(m[2])
case "guid":
cur.GUID = normalizeGUID(m[2])
case "priority":
cur.Priority, _ = strconv.Atoi(m[2])
case "smstate":
cur.State = smStates[m[2]]
if cur.State == "" {
cur.State = m[2]
}
}
}
out2 := recs[:0]
for _, r := range recs {
if r.GUID != "" {
out2 = append(out2, r)
}
}
if len(out2) == 0 {
return nil, fmt.Errorf("no subnet manager records in saquery output")
}
return out2, nil
}
// QuerySMs asks the subnet administrator, through one port, for every subnet
// manager on the fabric.
func QuerySMs(ctx context.Context, hca string, port int) ([]SMRecord, error) {
bin, err := exec.LookPath("saquery")
if err != nil {
return nil, ErrNoDiags
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, bin, "-C", hca, "-P", strconv.Itoa(port), "SMIR").CombinedOutput()
if err != nil {
return nil, fmt.Errorf("saquery on %s/%d: %w: %s", hca, port, err, strings.TrimSpace(string(out)))
}
return ParseSMIR(string(out))
}
// ScanFabric maps the fabric a port is on with ibnetdiscover: every switch,
// adapter and link. It can take a while on a large fabric, so it is bounded,
// and its output is capped: a fabric of thousands of ports is a few
// megabytes of text.
func ScanFabric(ctx context.Context, hca string, port int) (string, error) {
bin, err := exec.LookPath("ibnetdiscover")
if err != nil {
return "", ErrNoDiags
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
out, err := exec.CommandContext(ctx, bin, "-C", hca, "-P", strconv.Itoa(port)).Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return "", fmt.Errorf("ibnetdiscover on %s/%d: %w: %s", hca, port, err, strings.TrimSpace(string(ee.Stderr)))
}
return "", fmt.Errorf("ibnetdiscover on %s/%d: %w", hca, port, err)
}
const limit = 16 << 20
if len(out) > limit {
return "", fmt.Errorf("ibnetdiscover printed %d MB, more than the %d MB a scan may send", len(out)>>20, limit>>20)
}
return string(out), nil
}
+90
View File
@@ -0,0 +1,90 @@
package discovery
import (
"os"
"syscall"
)
// Storage is one filesystem a node keeps weights on.
//
// One entry per filesystem rather than per configured directory, because free
// space belongs to the disk and not to the path. Two model directories on the
// same disk are one pool of bytes, and reporting them separately would let the
// control plane add 24 GB to 24 GB and place a 40 GB model on a node with 24.
type Storage struct {
// Path is one directory on this filesystem — the first one configured that
// landed here, so the operator recognises it.
Path string `json:"path"`
// FreeBytes is what a non-root process can still write. Deliberately not
// the root reserve: the agent does not run as root, so the reserve is space
// it cannot have.
FreeBytes int64 `json:"free_bytes"`
// TotalBytes is the filesystem's size, so a reading of "8 GB free" can be
// told apart on a 10 TB filer and on a 10 GB root partition.
TotalBytes int64 `json:"total_bytes"`
// Shared marks a filesystem the node did not have to fill. Free space on
// one is shared with every other node using it, so two nodes reporting
// 500 GB free on the same filer have 500 GB between them, not a terabyte.
Shared bool `json:"shared,omitempty"`
}
// ScanStorage reports the filesystems behind the given model directories.
//
// Empty in, empty out, and a directory that cannot be read is skipped rather
// than reported as full: a mount that has not come back yet looks exactly like
// a missing directory, and claiming zero free bytes would take the node out of
// placement for a reason that is not true. Absent is not zero here, as
// everywhere else in this system.
func ScanStorage(dirs []string) []Storage {
var out []Storage
seen := map[uint64]int{} // filesystem id -> index in out
for _, dir := range dirs {
if dir == "" {
continue
}
dev, ok := filesystemID(dir)
if !ok {
continue
}
if i, dup := seen[dev]; dup {
// Same disk, already counted. Keep the first path: it is the one
// the operator wrote first and so the one they will recognise.
_ = i
continue
}
var st syscall.Statfs_t
if err := syscall.Statfs(dir, &st); err != nil {
continue
}
// Bsize is int32 on some platforms and int64 on others, which is why
// every field here is widened explicitly rather than assumed.
block := int64(st.Bsize)
seen[dev] = len(out)
out = append(out, Storage{
Path: dir,
FreeBytes: int64(st.Bavail) * block, //nolint:gosec,unconvert // widened on purpose
TotalBytes: int64(st.Blocks) * block, //nolint:gosec,unconvert // widened on purpose
Shared: isNetworkFS(dir),
})
}
return out
}
// filesystemID identifies the disk a path is on.
//
// By device number rather than by the statfs filesystem id, because the field
// holding that id is spelled differently on Linux and on macOS and this has to
// compile on both — the agent runs on one and is developed on the other.
func filesystemID(path string) (uint64, bool) {
fi, err := os.Stat(path)
if err != nil {
return 0, false
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(st.Dev), true //nolint:gosec,unconvert // int32 on macOS, uint64 on Linux
}
+63
View File
@@ -0,0 +1,63 @@
package discovery
import (
"os"
"path/filepath"
"testing"
)
func TestTwoDirectoriesOnOneDiskAreOnePoolOfBytes(t *testing.T) {
// The reason this matters: free space belongs to the disk, not to the path.
// Counting two directories separately lets the control plane add 24 GB to
// 24 GB and place a 40 GB model on a node that has 24.
base := t.TempDir()
a := filepath.Join(base, "models-a")
b := filepath.Join(base, "models-b")
for _, d := range []string{a, b} {
if err := os.Mkdir(d, 0o755); err != nil {
t.Fatal(err)
}
}
got := ScanStorage([]string{a, b})
if len(got) != 1 {
t.Fatalf("two directories on one filesystem reported as %d pools: %+v", len(got), got)
}
if got[0].Path != a {
t.Errorf("kept %q rather than the first directory configured", got[0].Path)
}
}
func TestStorageReportsSomethingPlausible(t *testing.T) {
got := ScanStorage([]string{t.TempDir()})
if len(got) != 1 {
t.Fatalf("a real directory reported %d filesystems", len(got))
}
s := got[0]
if s.TotalBytes <= 0 {
t.Error("a filesystem with no size at all")
}
if s.FreeBytes < 0 || s.FreeBytes > s.TotalBytes {
t.Errorf("free %d of total %d", s.FreeBytes, s.TotalBytes)
}
}
func TestAMissingDirectoryIsNotAFullDisk(t *testing.T) {
// A mount that has not come back yet looks exactly like a missing
// directory. Reporting it as zero free bytes would take the node out of
// placement for a reason that is not true; reporting nothing says only
// that nothing is known, which is the honest answer.
got := ScanStorage([]string{filepath.Join(t.TempDir(), "never-mounted")})
if len(got) != 0 {
t.Errorf("a missing directory was reported as a filesystem: %+v", got)
}
}
func TestNoDirectoriesConstrainNothing(t *testing.T) {
if got := ScanStorage(nil); len(got) != 0 {
t.Errorf("scanning nothing found %+v", got)
}
if got := ScanStorage([]string{""}); len(got) != 0 {
t.Errorf("an empty path found %+v", got)
}
}
+185
View File
@@ -0,0 +1,185 @@
package discovery
import (
"context"
"fmt"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
)
// Topology is what `nvidia-smi topo -m` says about how a node's GPUs and
// network adapters are wired together.
//
// Read from nvidia-smi rather than NVML so the portable, cgo-free binary knows
// it too. Without it that binary saw no NVLink at all: every GPU reported no
// peers, and a tensor-parallel group was chosen by index on machines where
// NVLink decides whether it runs at full speed.
type Topology struct {
// NVLink is, per GPU, the GPUs it reaches over NVLink and how many links
// are bonded to each.
NVLink map[int]map[int]int
// NICPath is, per GPU, how far each network adapter is, by its HCA name:
// PIX, PXB, PHB, NODE or SYS, nearest first. A GPU should talk to the
// fabric through the adapter nearest it; one across the socket costs a
// trip over the processor interconnect on every transfer.
NICPath map[int]map[string]string
// NUMA is the NUMA node each GPU is attached to, where reported.
NUMA map[int]int
}
// PathRank orders the relations nvidia-smi reports, nearest first. Lower is
// closer; an unknown relation ranks last.
func PathRank(rel string) int {
switch strings.ToUpper(rel) {
case "PIX":
return 0
case "PXB":
return 1
case "PHB":
return 2
case "NODE":
return 3
case "SYS":
return 4
}
return 5
}
// DiscoverTopology runs `nvidia-smi topo -m`. A node without nvidia-smi has
// no topology to report, which is not an error.
func DiscoverTopology(ctx context.Context, binary string) (Topology, error) {
if binary == "" {
if _, err := exec.LookPath("nvidia-smi"); err != nil {
return Topology{}, nil
}
binary = "nvidia-smi"
}
out, err := exec.CommandContext(ctx, binary, "topo", "-m").Output()
if err != nil {
return Topology{}, fmt.Errorf("running %s topo -m: %w", binary, err)
}
return parseTopo(string(out))
}
// ParseTopology reads `nvidia-smi topo -m` output.
func ParseTopology(out string) (Topology, error) { return parseTopo(out) }
var (
ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`)
nvLinks = regexp.MustCompile(`^NV(\d+)$`)
nicLegend = regexp.MustCompile(`^\s*(NIC\d+)\s*:\s*(\S+)\s*$`)
gpuColumn = regexp.MustCompile(`^GPU(\d+)$`)
numaColumn = "NUMA Affinity"
)
// parseTopo reads the matrix. Columns are tab-separated; the header names
// each one — GPUn, then the adapters, as NICn with a legend below on newer
// drivers or by HCA name on older ones — then CPU and NUMA affinity.
func parseTopo(out string) (Topology, error) {
out = ansi.ReplaceAllString(out, "")
lines := strings.Split(out, "\n")
legend := map[string]string{}
for _, l := range lines {
if m := nicLegend.FindStringSubmatch(l); m != nil {
legend[m[1]] = m[2]
}
}
var header []string
start := -1
for i, l := range lines {
cells := splitTabs(l)
if len(cells) > 1 && cells[0] == "" && gpuColumn.MatchString(cells[1]) {
header, start = cells, i
break
}
}
if start < 0 {
return Topology{}, fmt.Errorf("no GPU matrix in nvidia-smi topo output")
}
t := Topology{NVLink: map[int]map[int]int{}, NICPath: map[int]map[string]string{}, NUMA: map[int]int{}}
for _, l := range lines[start+1:] {
cells := splitTabs(l)
if len(cells) < 2 {
// The matrix ends at the first blank line, before the legend.
if strings.TrimSpace(l) == "" {
break
}
continue
}
m := gpuColumn.FindStringSubmatch(cells[0])
if m == nil {
continue // an adapter's row: its view of the GPUs is the same matrix
}
gpu, _ := strconv.Atoi(m[1])
for c := 1; c < len(cells) && c < len(header); c++ {
col, v := header[c], strings.TrimSpace(cells[c])
switch {
case gpuColumn.MatchString(col):
peer, _ := strconv.Atoi(gpuColumn.FindStringSubmatch(col)[1])
if n := nvLinks.FindStringSubmatch(v); n != nil && peer != gpu {
links, _ := strconv.Atoi(n[1])
if t.NVLink[gpu] == nil {
t.NVLink[gpu] = map[int]int{}
}
t.NVLink[gpu][peer] = links
}
case col == numaColumn:
if n, err := strconv.Atoi(v); err == nil {
t.NUMA[gpu] = n
}
case col == "CPU Affinity" || col == "GPU NUMA ID" || col == "":
default:
// An adapter: NICn named by the legend, or an HCA by name.
hca := col
if name, ok := legend[col]; ok {
hca = name
}
if PathRank(v) < 5 {
if t.NICPath[gpu] == nil {
t.NICPath[gpu] = map[string]string{}
}
t.NICPath[gpu][hca] = strings.ToUpper(v)
}
}
}
}
if len(t.NVLink) == 0 && len(t.NICPath) == 0 && len(t.NUMA) == 0 {
return t, fmt.Errorf("the nvidia-smi topo matrix had no rows")
}
return t, nil
}
func splitTabs(l string) []string {
l = strings.TrimRight(l, "\r")
cells := strings.Split(l, "\t")
for i := range cells {
cells[i] = strings.TrimSpace(cells[i])
}
return cells
}
// Apply puts the topology on the GPUs it describes. What a GPU already knows
// — NVLink peers from NVML, which is the more exact source — is kept.
func (t Topology) Apply(gpus []GPU) {
for i := range gpus {
g := &gpus[i]
if len(g.NVLinkPeers) == 0 {
for peer := range t.NVLink[g.Index] {
g.NVLinkPeers = append(g.NVLinkPeers, peer)
}
sort.Ints(g.NVLinkPeers)
}
if n, ok := t.NUMA[g.Index]; ok {
g.NUMANode = n
}
if paths := t.NICPath[g.Index]; len(paths) > 0 {
g.NICPaths = paths
}
}
}
+113
View File
@@ -0,0 +1,113 @@
package discovery
import (
"reflect"
"strings"
"testing"
)
// row joins cells the way nvidia-smi prints them: tab-separated, each padded.
func row(cells ...string) string { return strings.Join(cells, "\t") }
// dgxH100 is `nvidia-smi topo -m` on an eight-GPU NVSwitch machine with two
// adapters, as newer drivers print it: NICn columns and a legend naming them.
var dgxH100 = strings.Join([]string{
row("\x1b[4m", "GPU0", "GPU1", "GPU2", "GPU3", "NIC0", "NIC1", "CPU Affinity", "NUMA Affinity", "GPU NUMA ID\x1b[0m"),
row("GPU0", " X ", "NV18", "NV18", "NV18", "PXB", "SYS", "0-55,112-167", "0", "N/A"),
row("GPU1", "NV18", " X ", "NV18", "NV18", "PXB", "SYS", "0-55,112-167", "0", "N/A"),
row("GPU2", "NV18", "NV18", " X ", "NV18", "SYS", "PXB", "56-111,168-223", "1", "N/A"),
row("GPU3", "NV18", "NV18", "NV18", " X ", "SYS", "PIX", "56-111,168-223", "1", "N/A"),
row("NIC0", "PXB", "PXB", "SYS", "SYS", " X ", "SYS"),
row("NIC1", "SYS", "SYS", "PXB", "PIX", "SYS", " X "),
"",
"Legend:",
"",
" X = Self",
" SYS = Connection traversing PCIe as well as the SMP interconnect between NUMA nodes (e.g., QPI/UPI)",
" NV# = Connection traversing a bonded set of # NVLinks",
"",
"NIC Legend:",
"",
" NIC0: mlx5_0",
" NIC1: mlx5_1",
"",
}, "\n")
func TestNVLinkAndAdapterDistanceAreReadFromTopo(t *testing.T) {
topo, err := parseTopo(dgxH100)
if err != nil {
t.Fatal(err)
}
if got := topo.NVLink[0]; !reflect.DeepEqual(got, map[int]int{1: 18, 2: 18, 3: 18}) {
t.Errorf("GPU0's NVLink peers = %v", got)
}
// The legend turns NIC1 into the adapter's own name.
if got := topo.NICPath[3]; !reflect.DeepEqual(got, map[string]string{"mlx5_0": "SYS", "mlx5_1": "PIX"}) {
t.Errorf("GPU3's adapters = %v", got)
}
if topo.NUMA[2] != 1 || topo.NUMA[0] != 0 {
t.Errorf("NUMA = %v", topo.NUMA)
}
gpus := []GPU{{Index: 0}, {Index: 3}}
topo.Apply(gpus)
if !reflect.DeepEqual(gpus[0].NVLinkPeers, []int{1, 2, 3}) || gpus[1].NUMANode != 1 ||
gpus[1].NICPaths["mlx5_1"] != "PIX" {
t.Errorf("applied = %+v", gpus)
}
}
func TestOlderDriversNameAdaptersInTheHeader(t *testing.T) {
// No legend: the columns are the adapters' own names, and NVLink between
// pairs only, as on an A100 PCIe box with bridges.
out := strings.Join([]string{
row("", "GPU0", "GPU1", "GPU2", "GPU3", "mlx5_0", "CPU Affinity", "NUMA Affinity"),
row("GPU0", " X ", "NV12", "SYS", "SYS", "NODE", "0-31", "0"),
row("GPU1", "NV12", " X ", "SYS", "SYS", "NODE", "0-31", "0"),
row("GPU2", "SYS", "SYS", " X ", "NV12", "SYS", "32-63", "1"),
row("GPU3", "SYS", "SYS", "NV12", " X ", "SYS", "32-63", "1"),
row("mlx5_0", "NODE", "NODE", "SYS", "SYS", " X "),
"",
}, "\n")
topo, err := parseTopo(out)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(topo.NVLink[2], map[int]int{3: 12}) || len(topo.NVLink[0]) != 1 {
t.Errorf("NVLink = %v", topo.NVLink)
}
if topo.NICPath[0]["mlx5_0"] != "NODE" {
t.Errorf("adapters = %v", topo.NICPath)
}
}
func TestAPCIeMachineHasNoNVLink(t *testing.T) {
out := strings.Join([]string{
row("", "GPU0", "GPU1", "CPU Affinity", "NUMA Affinity"),
row("GPU0", " X ", "PHB", "0-15", "0"),
row("GPU1", "PHB", " X ", "0-15", "0"),
"",
}, "\n")
topo, err := parseTopo(out)
if err != nil {
t.Fatal(err)
}
if len(topo.NVLink) != 0 {
t.Errorf("NVLink on a PCIe machine: %v", topo.NVLink)
}
}
func TestNVMLPeersAreKeptOverTopo(t *testing.T) {
topo, _ := parseTopo(dgxH100)
gpus := []GPU{{Index: 0, NVLinkPeers: []int{1}}}
topo.Apply(gpus)
if !reflect.DeepEqual(gpus[0].NVLinkPeers, []int{1}) {
t.Errorf("NVML's answer was replaced: %v", gpus[0].NVLinkPeers)
}
}
func TestSomethingThatIsNotAMatrixIsAnError(t *testing.T) {
if _, err := parseTopo("NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver."); err == nil {
t.Error("expected an error")
}
}
+137
View File
@@ -0,0 +1,137 @@
package discovery
import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
)
// LocalWeights is one model file or directory a node can see.
type LocalWeights struct {
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
// Shared marks weights on a filesystem this node did not have to fill.
//
// Worth distinguishing: twenty nodes each holding their own copy and twenty
// nodes seeing one filer are the same list of paths and completely
// different situations — one has twenty copies of a 141 GB model and the
// other has one.
Shared bool `json:"shared,omitempty"`
}
// ScanWeights lists the model files under the given directories.
//
// Storage is the part of this system that will differ at every installation —
// a Lustre mount, an image exported over the fabric, local NVMe, an rsync from
// a laptop — so llmgw does not try to own it. It observes the result instead:
// whatever put the file there, the question placement needs answered is only
// whether it is present.
//
// Scanning nothing is the default and constrains nothing. A node with no
// directories configured behaves exactly as every node did before this existed,
// which is what makes the whole feature optional.
func ScanWeights(dirs []string) []LocalWeights {
var out []LocalWeights
seen := map[string]struct{}{}
for _, dir := range dirs {
if dir == "" {
continue
}
shared := isNetworkFS(dir)
entries, err := os.ReadDir(dir)
if err != nil {
// A directory that is not there is not an error to report upward:
// a mount that has not come back yet looks exactly like this, and
// the node is simply one without those weights until it does.
continue
}
for _, e := range entries {
path := filepath.Join(dir, e.Name())
if _, dup := seen[path]; dup {
continue
}
size, ok := weightSize(path, e)
if !ok {
continue
}
seen[path] = struct{}{}
out = append(out, LocalWeights{Path: path, SizeBytes: size, Shared: shared})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
// weightSize measures a candidate and says whether it is one at all.
//
// A model is either a single file — a .gguf, usually — or a directory of
// shards and a config, which is what every HuggingFace checkout looks like.
// Anything else in the directory is somebody's notes and is not reported.
func weightSize(path string, e fs.DirEntry) (int64, bool) {
if e.IsDir() {
// A directory counts only if it actually holds weights. An empty one
// is a half-finished download, and reporting it as present would have
// placement choose a node whose engine then fails to load.
var total int64
var found bool
_ = filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil //nolint:nilerr // an unreadable subtree is not fatal
}
if isWeightFile(p) {
found = true
}
if info, err := d.Info(); err == nil {
total += info.Size()
}
return nil
})
return total, found
}
if !isWeightFile(path) {
return 0, false
}
info, err := e.Info()
if err != nil {
return 0, false
}
return info.Size(), true
}
func isWeightFile(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".gguf", ".safetensors", ".bin", ".pt", ".pth":
return true
}
return false
}
// isNetworkFS reports whether a path is on a filesystem the node did not fill.
//
// By magic number rather than by parsing mounts, because the mount table says
// what was asked for and this says what the kernel actually has. The list is
// the network filesystems that turn up in front of GPUs: NFS, Lustre, GPFS,
// CephFS, GlusterFS and SMB. Anything unrecognised is treated as local, which
// is the conservative direction — a shared filer mistaken for local storage
// costs an unnecessary copy, while local storage mistaken for shared would have
// the cluster believe a model is everywhere when it is on one node.
func isNetworkFS(path string) bool {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return false
}
switch uint32(st.Type) { //nolint:unconvert // Type is int64 on some platforms
case 0x6969, // NFS
0x0BD00BD0, // Lustre
0x47504653, // GPFS
0x00C36400, // CephFS
0x65735546, // FUSE, which is how GlusterFS and many object mounts appear
0xFF534D42: // SMB/CIFS
return true
}
return false
}
+114
View File
@@ -0,0 +1,114 @@
package discovery
import (
"os"
"path/filepath"
"testing"
)
func write(t *testing.T, path string, size int) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, make([]byte, size), 0o644); err != nil {
t.Fatal(err)
}
}
func TestScanFindsFilesAndDirectoriesOfWeights(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "qwen-3b-q4.gguf"), 1024)
// A HuggingFace checkout: a directory of shards rather than one file.
write(t, filepath.Join(dir, "Llama-3.3-70B", "model-00001.safetensors"), 2048)
write(t, filepath.Join(dir, "Llama-3.3-70B", "config.json"), 64)
got := ScanWeights([]string{dir})
if len(got) != 2 {
t.Fatalf("found %d entries: %+v", len(got), got)
}
byName := map[string]int64{}
for _, w := range got {
byName[filepath.Base(w.Path)] = w.SizeBytes
}
if byName["qwen-3b-q4.gguf"] != 1024 {
t.Errorf("gguf size = %d", byName["qwen-3b-q4.gguf"])
}
// The directory's size is everything in it, config included: what matters
// is how much would have to be moved to put it somewhere else.
if byName["Llama-3.3-70B"] != 2048+64 {
t.Errorf("checkout size = %d, want %d", byName["Llama-3.3-70B"], 2048+64)
}
}
func TestAnEmptyDirectoryIsNotWeights(t *testing.T) {
// A half-finished download looks exactly like this, and reporting it as
// present would have placement choose a node whose engine then fails to
// load — the failure this whole feature exists to avoid.
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "downloading"), 0o755); err != nil {
t.Fatal(err)
}
write(t, filepath.Join(dir, "downloading", "README.md"), 10)
if got := ScanWeights([]string{dir}); len(got) != 0 {
t.Errorf("a directory with no weight files was reported: %+v", got)
}
}
func TestNotesAndScriptsAreNotReportedAsModels(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "notes.txt"), 10)
write(t, filepath.Join(dir, "fetch.sh"), 10)
write(t, filepath.Join(dir, "real.gguf"), 10)
got := ScanWeights([]string{dir})
if len(got) != 1 || filepath.Base(got[0].Path) != "real.gguf" {
t.Errorf("scan returned %+v", got)
}
}
func TestAMissingDirectoryIsNotAnError(t *testing.T) {
// A mount that has not come back yet looks exactly like a directory that is
// not there, and the node is simply one without those weights until it is.
got := ScanWeights([]string{filepath.Join(t.TempDir(), "never-existed")})
if len(got) != 0 {
t.Errorf("got %+v", got)
}
}
func TestScanningNothingIsTheDefaultAndConstrainsNothing(t *testing.T) {
// What makes the feature optional: an agent with no directories configured
// behaves exactly as every agent did before this existed.
if got := ScanWeights(nil); len(got) != 0 {
t.Errorf("got %+v", got)
}
if got := ScanWeights([]string{""}); len(got) != 0 {
t.Errorf("an empty directory name produced %+v", got)
}
}
func TestTheSamePathIsReportedOnce(t *testing.T) {
// Two configured directories can overlap, or be the same mount reached two
// ways. Counting a model twice would overstate what a fabric holds.
dir := t.TempDir()
write(t, filepath.Join(dir, "m.gguf"), 10)
if got := ScanWeights([]string{dir, dir}); len(got) != 1 {
t.Errorf("the same directory twice produced %+v", got)
}
}
func TestLocalStorageIsNeverMistakenForShared(t *testing.T) {
// The conservative direction. A shared filer read as local costs an
// unnecessary copy; local read as shared would have the cluster believe a
// model is everywhere when it is on one node.
dir := t.TempDir()
write(t, filepath.Join(dir, "m.gguf"), 10)
got := ScanWeights([]string{dir})
if len(got) != 1 {
t.Fatalf("got %+v", got)
}
if got[0].Shared {
t.Error("a temporary directory on local disk was reported as shared")
}
}
+60 -4
View File
@@ -63,15 +63,35 @@ type ShardSpec struct {
HCAs []string
// MgmtInterface is the ethernet interface used for bootstrap traffic.
MgmtInterface string
// GIDIndex is the RoCE v2 GID index for the HCAs, set by the agent when
// they are RoCE; nil on InfiniBand, where NCCL needs none.
GIDIndex *int
HTTPPort int
Profile Profile
// APIKey is what the engine demands of every request. Engines listen on
// all of a node's addresses; without a key anybody who can reach the port
// talks to the model directly, past the gateway's authentication, quotas
// and accounting. Given to the engine in its environment rather than on
// its command line, which every user on the node can read.
APIKey string `json:"api_key,omitempty"`
// Threads is the CPU cores placement reserved for a shard with no GPU,
// and so what its engine computes on. Zero leaves it to the engine's own
// configuration.
Threads int `json:"threads,omitempty"`
// Pool and Fabric are control plane concepts the agent cannot derive. They
// are carried so the agent can report them back after a restart, and are
// not used when building a command.
// are carried so the agent can report them back after a restart; Fabric
// also says, with an "nvl:" prefix, that the replica is inside a
// multi-node NVLink domain, which changes NCCL's environment.
Pool string
Fabric string
// ProfileFP identifies the version of the profile this shard was started
// from. Carried for the same reason as Pool and Fabric, and never read: it
// lets the control plane tell, after its own restart, whether a shard that
// is still running matches the profile the catalog holds now.
ProfileFP string
}
// Leader reports whether this shard serves HTTP for the replica.
@@ -141,9 +161,21 @@ func NewRegistry(cfg Config) *Registry {
r := &Registry{engines: map[string]Engine{}}
r.Add(&VLLM{cfg: cfg.VLLM})
r.Add(&SGLang{cfg: cfg.SGLang})
r.Add(&LlamaCpp{cfg: cfg.LlamaCpp})
r.Add(&Sim{cfg: cfg.Sim})
return r
}
// UseInstalled points an engine at a build the agent installed, and reports
// whether the engine takes one.
func (r *Registry) UseInstalled(name, path string) bool {
u, ok := r.engines[name].(interface{ UseInstalled(string) })
if ok {
u.UseInstalled(path)
}
return ok
}
// Add registers an engine, replacing any engine of the same name.
func (r *Registry) Add(e Engine) { r.engines[e.Name()] = e }
@@ -168,12 +200,18 @@ func (r *Registry) Names() []string {
// Config configures the engine adapters.
type Config struct {
VLLM VLLMConfig `koanf:"vllm"`
SGLang SGLangConfig `koanf:"sglang"`
VLLM VLLMConfig `koanf:"vllm"`
SGLang SGLangConfig `koanf:"sglang"`
LlamaCpp LlamaCppConfig `koanf:"llamacpp"`
Sim SimConfig `koanf:"sim"`
}
// --- shared helpers ---
// NCCLEnv is the environment a shard with this spec is given, for a tool that
// shows an operator what an engine would see.
func NCCLEnv(s ShardSpec) map[string]string { return baseEnv(s) }
// baseEnv builds the environment every engine shares.
//
// This is the part of §5.3 the plan marks critical. The rules encoded here:
@@ -196,11 +234,24 @@ func baseEnv(s ShardSpec) map[string]string {
if len(s.HCAs) > 0 {
env["NCCL_IB_HCA"] = strings.Join(s.HCAs, ",")
env["NCCL_IB_DISABLE"] = "0"
// RoCE: the index of the routable RoCE v2 entry. NCCL's default is a
// v1 link-local one on most hosts, and a job on it hangs.
if s.GIDIndex != nil {
env["NCCL_IB_GID_INDEX"] = strconv.Itoa(*s.GIDIndex)
}
} else {
// Single node, or a node with no fabric. Either way IB is not the path.
env["NCCL_IB_DISABLE"] = "1"
}
// Placed inside a multi-node NVLink domain: NCCL is to carry the
// replica's collectives over NVLink between its nodes, which it only
// does when told to, and needs IMEX for.
if s.NNodes > 1 && strings.HasPrefix(s.Fabric, "nvl:") {
env["NCCL_MNNVL_ENABLE"] = "1"
env["NCCL_CUMEM_ENABLE"] = "1"
}
if s.MgmtInterface != "" {
env["NCCL_SOCKET_IFNAME"] = s.MgmtInterface
env["GLOO_SOCKET_IFNAME"] = s.MgmtInterface
@@ -209,6 +260,11 @@ func baseEnv(s ShardSpec) map[string]string {
// Profile overrides win: they are the escape hatch for the per-cluster
// tuning no adapter can guess.
for k, v := range s.Profile.Env {
// Checked again here, not only where the model was defined: this is
// the process about to run as root, whatever sent the profile.
if bad, _ := UnsafeEnv(k); bad {
continue
}
env[k] = v
}
return env
+61 -8
View File
@@ -3,6 +3,7 @@ package engines
import (
"fmt"
"slices"
"strconv"
"strings"
"testing"
)
@@ -243,14 +244,41 @@ func TestVLLMRayWorkerRunsOnlyRay(t *testing.T) {
}
func TestVLLMMPMode(t *testing.T) {
// vLLM's multi-node launch: every node the same serve with its rank and
// the leader's address, workers headless. Workers used to be given the
// leader's address as their own VLLM_HOST_IP, and no rank at all.
v := &VLLM{cfg: VLLMConfig{LaunchMode: "mp"}}
cmd := buildOne(t, v, multiNodeSpec(0))
if argValue(cmd.Args, "--distributed-executor-backend") != "mp" {
t.Errorf("backend not set: %v", cmd.Args)
for _, rank := range []int{0, 1} {
cmd := buildOne(t, v, multiNodeSpec(rank))
for flag, want := range map[string]string{
"--distributed-executor-backend": "mp", "--nnodes": "2",
"--node-rank": strconv.Itoa(rank), "--master-addr": "10.0.0.10", "--master-port": "20001",
} {
if got := argValue(cmd.Args, flag); got != want {
t.Errorf("rank %d: %s = %q, want %q (%v)", rank, flag, got, want, cmd.Args)
}
}
if headless := slices.Contains(cmd.Args, "--headless"); headless != (rank > 0) {
t.Errorf("rank %d: headless = %v", rank, headless)
}
if ip, ok := envMap(cmd.Env)["VLLM_HOST_IP"]; ok && ip == "10.0.0.10" && rank > 0 {
t.Errorf("a worker was told the leader's address is its own")
}
}
env := envMap(cmd.Env)
if env["VLLM_HOST_IP"] != "10.0.0.10" || env["VLLM_PORT"] != "20001" {
t.Errorf("dist init not configured: %q %q", env["VLLM_HOST_IP"], env["VLLM_PORT"])
}
func TestRoCEGetsItsGIDIndex(t *testing.T) {
s := multiNodeSpec(0)
idx := 3
s.GIDIndex = &idx
env := envMap(buildOne(t, &VLLM{cfg: VLLMConfig{LaunchMode: "mp"}}, s).Env)
if env["NCCL_IB_GID_INDEX"] != "3" {
t.Errorf("NCCL_IB_GID_INDEX = %q", env["NCCL_IB_GID_INDEX"])
}
// InfiniBand needs none, and must not be given one.
env = envMap(buildOne(t, &VLLM{cfg: VLLMConfig{LaunchMode: "mp"}}, multiNodeSpec(0)).Env)
if _, ok := env["NCCL_IB_GID_INDEX"]; ok {
t.Error("an InfiniBand shard was given a GID index")
}
}
@@ -397,14 +425,14 @@ func TestValidateRequiresModelPath(t *testing.T) {
func TestRegistry(t *testing.T) {
r := NewRegistry(Config{})
if got := r.Names(); len(got) != 2 || got[0] != "sglang" || got[1] != "vllm" {
if got := r.Names(); !slices.Equal(got, []string{"llamacpp", "sglang", "sim", "vllm"}) {
t.Errorf("registry contents: %v", got)
}
if _, err := r.Get("vllm"); err != nil {
t.Errorf("vllm missing: %v", err)
}
err := func() error { _, err := r.Get("nope"); return err }()
if err == nil || !strings.Contains(err.Error(), "sglang, vllm") {
if err == nil || !strings.Contains(err.Error(), "llamacpp, sglang, sim, vllm") {
t.Errorf("an unknown engine should list what is available: %v", err)
}
}
@@ -423,3 +451,28 @@ func TestQuantizationOf(t *testing.T) {
t.Errorf("a dangling flag should yield nothing, got %q", got)
}
}
func TestAReplicaInAnNVLinkDomainIsToldToUseIt(t *testing.T) {
s := multiNodeSpec(0)
s.Fabric = "nvl:76b1ad76-7c24-4b6b-9f6c-2a1e0d8c11aa/7"
env := envMap(buildOne(t, &VLLM{cfg: VLLMConfig{LaunchMode: "mp"}}, s).Env)
if env["NCCL_MNNVL_ENABLE"] != "1" {
t.Errorf("NCCL_MNNVL_ENABLE = %q", env["NCCL_MNNVL_ENABLE"])
}
if env := envMap(buildOne(t, &VLLM{cfg: VLLMConfig{LaunchMode: "mp"}}, multiNodeSpec(0)).Env); env["NCCL_MNNVL_ENABLE"] != "" {
t.Error("a replica on InfiniBand was told to use multi-node NVLink")
}
}
func TestMultiNodeVLLMDefaultsToItsOwnLaunch(t *testing.T) {
// mp is what vLLM documents and needs nothing but vLLM; Ray was the
// default and was never checked against a real cluster.
cmds, err := (&VLLM{}).Commands(multiNodeSpec(1))
if err != nil {
t.Fatal(err)
}
if len(cmds) != 1 || cmds[0].Name != "vllm" || argValue(cmds[0].Args, "--distributed-executor-backend") != "mp" ||
!slices.Contains(cmds[0].Args, "--headless") {
t.Errorf("default worker = %+v", cmds)
}
}
+227
View File
@@ -0,0 +1,227 @@
package engines
import (
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync/atomic"
)
// LlamaCppConfig locates the llama.cpp installation.
type LlamaCppConfig struct {
// Bin is the llama-server binary. It is the server, not the CLI: the
// gateway talks OpenAI over HTTP and needs the one that serves it.
// Empty means the agent installs llama.cpp itself, at the version the
// control plane asks for. Setting it opts out of that.
Bin string `koanf:"bin"`
// Parallel is how many requests a server handles at once, when a profile
// does not say. llama.cpp defaults to one, which would make every second
// request wait behind the first no matter what the router decided.
Parallel int `koanf:"parallel"`
// Threads is how many CPU threads a shard with no GPU computes on. Zero
// takes half this machine's CPUs.
//
// Not all of them, which is llama.cpp's own default: two busy servers
// that each spin on every CPU fight for them, and on an 8-vCPU node
// that made 7B generation fall from 10 tokens a second to under 1 each.
// At half, one server alone is about a third slower and two together
// keep 5.5 each. A node that only ever runs one CPU replica does better
// with all of them; say so here.
Threads int `koanf:"threads"`
}
// numCPU is the CPUs this process may run on; a variable for tests.
var numCPU = runtime.NumCPU
// LlamaCpp builds llama.cpp launch commands.
//
// It is the engine that makes a cluster without GPUs useful: llama.cpp runs a
// small model on CPU at a speed that is fine for testing the whole path, and it
// serves the same OpenAI API as the others, so nothing downstream can tell the
// difference.
type LlamaCpp struct {
cfg LlamaCppConfig
installed atomic.Pointer[string]
}
// Name identifies the engine in the catalog.
func (*LlamaCpp) Name() string { return "llamacpp" }
// HealthPath is llama.cpp's readiness endpoint. It returns 503 while the model
// is still loading, which is exactly what the supervisor needs.
func (*LlamaCpp) HealthPath() string { return "/health" }
// MetricsPath is llama.cpp's Prometheus endpoint, which only exists when the
// server was started with --metrics. Commands always passes it.
func (*LlamaCpp) MetricsPath() string { return "/metrics" }
// ServesHTTP reports whether this rank answers the API. Every llama.cpp shard
// does, because a llama.cpp replica is always exactly one shard.
func (*LlamaCpp) ServesHTTP(ShardSpec) bool { return true }
// Validate checks the profile against the GPUs it would run on.
//
// The two refusals here are about not pretending: llama.cpp has no equivalent
// of a tensor-parallel group spanning machines, and its multi-GPU support is a
// different mechanism (--split-mode, --tensor-split) that this adapter does not
// drive. Accepting either would produce a replica that starts and then serves
// from one machine while the catalog believes it has several.
func (*LlamaCpp) Validate(p Profile, gpus []GPUInfo) error {
if err := validateCommon(p, gpus); err != nil {
return err
}
if p.Nodes > 1 {
return fmt.Errorf("profile %q: llamacpp does not span nodes; use vllm or sglang for a %d-node replica",
p.Name, p.Nodes)
}
if p.TP > 1 {
return fmt.Errorf("profile %q: llamacpp does not do tensor parallelism; "+
"it splits a model across local GPUs with --tensor-split, which this adapter does not drive",
p.Name)
}
return nil
}
// Commands returns the processes for one shard, which is always one process.
func (l *LlamaCpp) Commands(s ShardSpec) ([]Command, error) {
bin := l.bin()
p := s.Profile
model, err := ggufIn(p.ModelPath)
if err != nil {
return nil, err
}
args := []string{
"--model", model,
"--host", "0.0.0.0",
"--port", strconv.Itoa(s.HTTPPort),
// Without this there is no /metrics, and an endpoint the router cannot
// measure is one it has to route to blind.
"--metrics",
}
// The model's own chat template rather than a generic one, which is what
// renders tool definitions in the form the model was trained to read.
//
// Pinned rather than relied on: build 10991 has it on by default, older
// builds do not, and an engine upgrade should not be able to change how
// prompts are built underneath a running fleet. Passing it explicitly was
// a no-op on the build here — it was added while chasing tool calls that
// came back as text, and did not fix that, which turned out to be the
// model rather than the template.
if !hasFlag(p.Args, "--jinja", "--no-jinja") {
args = append(args, "--jinja")
}
if p.ServedModelName != "" {
args = append(args, "--alias", p.ServedModelName)
}
if !hasFlag(p.Args, "--parallel", "-np") {
n := l.cfg.Parallel
if n <= 0 {
n = 4
}
args = append(args, "--parallel", strconv.Itoa(n))
}
// With GPUs assigned, offload everything; llama.cpp keeps the model on the
// CPU otherwise, which is not what a GPU placement asked for. Without them
// the flag is omitted entirely rather than set to zero, so a build with no
// GPU support does not see an argument it rejects.
if len(s.GPUIndices) > 0 && !hasFlag(p.Args, "--n-gpu-layers", "-ngl") {
args = append(args, "--n-gpu-layers", "999")
}
if len(s.GPUIndices) == 0 && !hasFlag(p.Args, "--threads", "-t") {
// What placement reserved first: it is what the node's other
// replicas were placed around.
n := s.Threads
if n <= 0 {
n = l.cfg.Threads
}
if n <= 0 {
n = max(numCPU()/2, 1)
}
args = append(args, "--threads", strconv.Itoa(n))
}
args = append(args, p.Args...)
env := baseEnv(s)
if s.APIKey != "" {
env["LLAMA_API_KEY"] = s.APIKey
}
return []Command{{
Name: "llama-server", Path: bin, Args: args, Env: materializeEnv(env),
}}, nil
}
// hasFlag reports whether the profile already sets one of these flags, in
// either "--flag value" or "--flag=value" form.
func hasFlag(args []string, names ...string) bool {
for _, a := range args {
for _, n := range names {
if a == n || strings.HasPrefix(a, n+"=") {
return true
}
}
}
return false
}
// ggufIn resolves a model path that may be a directory.
//
// A multi-file version arrives as the directory it was downloaded into, because
// that is the only shape a set can take. llama.cpp wants a file: given the
// first part of a split GGUF it finds the rest beside it, so the first part is
// what it gets.
//
// A path that is already a file is returned untouched, which is every
// single-file model and every hand-staged one.
func ggufIn(path string) (string, error) {
st, err := os.Stat(path)
if err != nil || !st.IsDir() {
// Not a directory, or not there at all — the second is the engine's to
// report, with the message it would have given anyway.
return path, nil //nolint:nilerr // a missing path is the engine's error to give
}
entries, err := os.ReadDir(path)
if err != nil {
return "", err
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") {
names = append(names, e.Name())
}
}
if len(names) == 0 {
return "", fmt.Errorf("%s holds no .gguf file", path)
}
// Sorted, so a split model's first part comes first: the parts are named
// "-00001-of-00004" and lexical order is shard order.
sort.Strings(names)
return filepath.Join(path, names[0]), nil
}
// UseInstalled points the engine at a build the agent installed and checked.
// It takes precedence over everything else, because it is the only one that
// was seen to run.
func (l *LlamaCpp) UseInstalled(path string) { l.installed.Store(&path) }
// Configured reports whether the agent's configuration names a binary. A
// named binary is the operator's deliberate choice; one merely found on PATH
// is not, and is not reported as installed.
func (l *LlamaCpp) Configured() bool { return l.cfg.Bin != "" }
// bin is the llama-server to run: the installed build, else the configured
// one, else whatever PATH has — which is only ever reached by a node not
// provisioned by the agent and not told where its engine is.
func (l *LlamaCpp) bin() string {
if p := l.installed.Load(); p != nil {
return *p
}
if l.cfg.Bin != "" {
return l.cfg.Bin
}
return "llama-server"
}
+308
View File
@@ -0,0 +1,308 @@
package engines
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func llamaSpec(gpus []int) ShardSpec {
return ShardSpec{
ReplicaID: "rep-1", ShardID: "sh-1", Rank: 0, NNodes: 1,
GPUIndices: gpus, HTTPPort: 8000, MgmtInterface: "eth0",
Profile: Profile{
Name: "cpu", Engine: "llamacpp", TP: 1,
ModelPath: "/models/qwen.gguf", ServedModelName: "qwen-0.5b",
},
}
}
func TestLlamaCppCommand(t *testing.T) {
cmds, err := (&LlamaCpp{}).Commands(llamaSpec(nil))
if err != nil {
t.Fatal(err)
}
if len(cmds) != 1 {
t.Fatalf("a llama.cpp shard is one process, got %d", len(cmds))
}
c := cmds[0]
if c.Path != "llama-server" {
t.Errorf("path %q", c.Path)
}
// --metrics is not optional: llama-server serves no /metrics without it, and
// an endpoint the router cannot measure is one it routes to blind.
if !slices.Contains(c.Args, "--metrics") {
t.Error("the server was started without --metrics")
}
if v, _ := flagValue(c.Args, "--model"); v != "/models/qwen.gguf" {
t.Errorf("--model %q", v)
}
if v, _ := flagValue(c.Args, "--port"); v != "8000" {
t.Errorf("--port %q", v)
}
if v, _ := flagValue(c.Args, "--alias"); v != "qwen-0.5b" {
t.Errorf("--alias %q", v)
}
// llama.cpp serves one request at a time unless told otherwise, which would
// make every second request wait no matter what the router decided.
if v, ok := flagValue(c.Args, "--parallel"); !ok || v == "1" {
t.Errorf("--parallel %q", v)
}
if slices.Contains(c.Args, "--n-gpu-layers") {
t.Error("a CPU placement was given GPU layers")
}
}
func TestLlamaCppOffloadsWhenGivenGPUs(t *testing.T) {
cmds, _ := (&LlamaCpp{}).Commands(llamaSpec([]int{2}))
if v, ok := flagValue(cmds[0].Args, "--n-gpu-layers"); !ok || v != "999" {
t.Errorf("a GPU placement should offload the model, got %q", v)
}
// The shard must still be pinned to its own card.
if !slices.Contains(cmds[0].Env, "CUDA_VISIBLE_DEVICES=2") {
t.Error("the shard was not pinned to its GPU")
}
}
func TestProfileArgumentsWinOverDefaults(t *testing.T) {
// The escape hatch has to actually work, or an operator tuning slot count
// ends up with two --parallel flags and whichever llama.cpp happens to
// prefer.
s := llamaSpec(nil)
s.Profile.Args = []string{"--parallel", "16", "--ctx-size", "4096"}
cmds, _ := (&LlamaCpp{}).Commands(s)
n := 0
for _, a := range cmds[0].Args {
if a == "--parallel" {
n++
}
}
if n != 1 {
t.Errorf("--parallel appears %d times", n)
}
if v, _ := flagValue(cmds[0].Args, "--parallel"); v != "16" {
t.Errorf("the profile's value lost: %q", v)
}
}
func TestShortFormArgumentsAreRecognized(t *testing.T) {
s := llamaSpec([]int{0})
s.Profile.Args = []string{"-np", "8", "-ngl", "20"}
cmds, _ := (&LlamaCpp{}).Commands(s)
for _, flag := range []string{"--parallel", "--n-gpu-layers"} {
if slices.Contains(cmds[0].Args, flag) {
t.Errorf("%s was added although the profile set its short form", flag)
}
}
}
func TestLlamaCppRefusesWhatItCannotDo(t *testing.T) {
// Accepting either of these would produce a replica that starts and then
// serves from one machine while the catalog believes it has several.
multi := Profile{Name: "multi", Engine: "llamacpp", TP: 1, Nodes: 2, GPUsPerNode: 1, ModelPath: "/m"}
if err := (&LlamaCpp{}).Validate(multi, nil); err == nil {
t.Error("a multi-node llama.cpp profile was accepted")
}
tp := Profile{Name: "tp", Engine: "llamacpp", TP: 2, GPUsPerNode: 2, ModelPath: "/m"}
if err := (&LlamaCpp{}).Validate(tp, h100(2)); err == nil {
t.Error("a tensor-parallel llama.cpp profile was accepted")
}
}
func TestCPUProfileValidates(t *testing.T) {
p := Profile{Name: "cpu", Engine: "llamacpp", TP: 1, ModelPath: "/models/qwen.gguf"}
if err := (&LlamaCpp{}).Validate(p, nil); err != nil {
t.Errorf("a CPU profile on a node with no GPUs was rejected: %v", err)
}
}
func TestLlamaCppAlwaysServesHTTP(t *testing.T) {
// Unlike the others there is no worker rank to get this wrong for.
if !(&LlamaCpp{}).ServesHTTP(llamaSpec(nil)) {
t.Error("a llama.cpp shard must answer the API")
}
}
func TestBinaryIsConfigurable(t *testing.T) {
l := &LlamaCpp{cfg: LlamaCppConfig{Bin: "/opt/llama.cpp/bin/llama-server", Parallel: 2}}
cmds, _ := l.Commands(llamaSpec(nil))
if cmds[0].Path != "/opt/llama.cpp/bin/llama-server" {
t.Errorf("path %q", cmds[0].Path)
}
if v, _ := flagValue(cmds[0].Args, "--parallel"); v != "2" {
t.Errorf("--parallel %q", v)
}
}
func TestUnknownEngineNamesLlamaCpp(t *testing.T) {
_, err := NewRegistry(Config{}).Get("llama.cpp")
if err == nil || !strings.Contains(err.Error(), "llamacpp") {
t.Errorf("the suggestion should name the real engine: %v", err)
}
}
// flagValue reads a "--flag value" pair out of a command line.
func flagValue(args []string, flag string) (string, bool) {
for i, a := range args {
if a == flag && i+1 < len(args) {
return args[i+1], true
}
}
return "", false
}
func TestLlamaCppUsesTheModelsChatTemplate(t *testing.T) {
// Pinned so an engine upgrade cannot change how prompts are built under a
// running fleet: build 10991 defaults to jinja, older builds do not.
//
// Not a fix for tool calling, though it was added while chasing that. On
// this build the flag is a no-op and the behaviour is identical with and
// without it.
l := &LlamaCpp{}
cmds, err := l.Commands(ShardSpec{HTTPPort: 8080, Profile: Profile{ModelPath: "/m.gguf"}})
if err != nil {
t.Fatal(err)
}
if !slices.Contains(cmds[0].Args, "--jinja") {
t.Errorf("--jinja is missing; tool calls will come back as text: %v", cmds[0].Args)
}
}
func TestLlamaCppLetsAProfileTurnTheTemplateOff(t *testing.T) {
// A model whose bundled template is broken has to be serviceable without
// editing the adapter.
l := &LlamaCpp{}
cmds, err := l.Commands(ShardSpec{
HTTPPort: 8080,
Profile: Profile{ModelPath: "/m.gguf", Args: []string{"--no-jinja"}},
})
if err != nil {
t.Fatal(err)
}
if slices.Contains(cmds[0].Args, "--jinja") {
t.Errorf("the profile said --no-jinja and got both: %v", cmds[0].Args)
}
}
func TestASetDirectoryResolvesToItsFirstPart(t *testing.T) {
dir := t.TempDir()
// Deliberately out of order on disk, because readdir order is not shard
// order and the first part is the one llama.cpp must be given.
for _, n := range []string{
"qwen-q4_k_m-00003-of-00003.gguf",
"qwen-q4_k_m-00001-of-00003.gguf",
"qwen-q4_k_m-00002-of-00003.gguf",
} {
if err := os.WriteFile(filepath.Join(dir, n), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
got, err := ggufIn(dir)
if err != nil {
t.Fatal(err)
}
// Given the first part llama.cpp finds the rest beside it; given the third
// it loads a third of a model.
if filepath.Base(got) != "qwen-q4_k_m-00001-of-00003.gguf" {
t.Errorf("expected the first shard, got %s", filepath.Base(got))
}
}
func TestAPlainFilePathIsUntouched(t *testing.T) {
// Every single-file model and every hand-staged one takes this path.
p := filepath.Join(t.TempDir(), "model.gguf")
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if got, _ := ggufIn(p); got != p {
t.Errorf("expected the path unchanged, got %s", got)
}
}
func TestADirectoryWithNoWeightsSaysSo(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := ggufIn(dir); err == nil {
t.Error("a directory with no .gguf in it is not a model llama.cpp can load")
}
}
func TestAMissingPathIsLeftToTheEngine(t *testing.T) {
// The engine's own "no such file" names the path and the flag it came
// from, which is more use than anything this could say.
p := "/nonexistent/model.gguf"
got, err := ggufIn(p)
if err != nil || got != p {
t.Errorf("expected it passed through, got %q %v", got, err)
}
}
func TestLlamaCppIsGivenItsKeyOutOfSightOfTheProcessList(t *testing.T) {
// The command line is readable by every user on the node; the
// environment of a root process is not.
cmds, err := (&LlamaCpp{}).Commands(ShardSpec{HTTPPort: 8080, APIKey: "k1",
Profile: Profile{ModelPath: "/m.gguf"}})
if err != nil {
t.Fatal(err)
}
if slices.Contains(cmds[0].Args, "k1") || slices.Contains(cmds[0].Args, "--api-key") {
t.Errorf("the key is on the command line: %v", cmds[0].Args)
}
if !slices.Contains(cmds[0].Env, "LLAMA_API_KEY=k1") {
t.Error("llama-server was not given its key")
}
}
func TestACPUShardLeavesHalfTheMachineForAnother(t *testing.T) {
prev := numCPU
numCPU = func() int { return 8 }
t.Cleanup(func() { numCPU = prev })
threads := func(l *LlamaCpp, s ShardSpec) (string, bool) {
cmds, err := l.Commands(s)
if err != nil {
t.Fatal(err)
}
return flagValue(cmds[0].Args, "--threads")
}
if v, _ := threads(&LlamaCpp{}, llamaSpec(nil)); v != "4" {
t.Errorf("--threads %q on 8 CPUs, expected 4", v)
}
if v, _ := threads(&LlamaCpp{cfg: LlamaCppConfig{Threads: 8}}, llamaSpec(nil)); v != "8" {
t.Errorf("--threads %q with 8 configured", v)
}
if _, ok := threads(&LlamaCpp{}, llamaSpec([]int{0})); ok {
t.Error("a GPU shard was given a thread count")
}
s := llamaSpec(nil)
s.Profile.Args = []string{"-t", "2"}
cmds, _ := (&LlamaCpp{}).Commands(s)
if slices.Contains(cmds[0].Args, "--threads") {
t.Error("the profile's own -t was overridden")
}
numCPU = func() int { return 1 }
if v, _ := threads(&LlamaCpp{}, llamaSpec(nil)); v != "1" {
t.Errorf("--threads %q on one CPU", v)
}
}
func TestTheCoresPlacementReservedAreTheThreads(t *testing.T) {
s := llamaSpec(nil)
s.Threads = 3
cmds, err := (&LlamaCpp{cfg: LlamaCppConfig{Threads: 8}}).Commands(s)
if err != nil {
t.Fatal(err)
}
if v, _ := flagValue(cmds[0].Args, "--threads"); v != "3" {
t.Errorf("--threads %q; what placement reserved should win over the node's setting", v)
}
}
+216
View File
@@ -0,0 +1,216 @@
package engines
import (
"context"
"errors"
"os/exec"
"regexp"
"sort"
"strings"
"time"
)
// Unknown is the version reported for an engine that is installed and would
// not say which version it is.
//
// A value, not an absent key, because the two mean different things and
// placement reads the keys: absent is "this node cannot run it", Unknown is
// "it is here and the probe could not get a number out of it". Collapsing them
// would make a slow engine look like a missing one and take a node out of
// scheduling for being slow to answer --version.
const Unknown = "unknown"
// Prober is an engine that can say whether it is installed on this machine.
//
// Optional, and asserted for rather than part of Engine: an adapter that has no
// way to check should report nothing rather than be forced to invent an answer.
type Prober interface {
// Probe returns the engine's version, or an error when it is not installed.
Probe(ctx context.Context) (string, error)
}
// Probe asks every registered engine whether it is here.
//
// The result is what the node reports at registration, and its key set is what
// placement filters on. Until this existed nobody knew: the registry registers
// every adapter whether or not its binary exists, so a node with no vLLM
// accepted a vLLM shard, registered healthy, and failed the shard minutes later
// with "executable file not found" — in the engine's own words, on a machine
// nobody was watching.
//
// Bounded, because it runs on the path to registering and an agent that cannot
// reach the control plane is worse than one that reports an incomplete list.
func (r *Registry) Probe(ctx context.Context) map[string]string {
out := map[string]string{}
for name, e := range r.engines {
p, ok := e.(Prober)
if !ok {
continue
}
v, err := p.Probe(ctx)
if err != nil {
continue
}
if v == "" {
v = Unknown
}
out[name] = v
}
return out
}
// Names of the engines in a probe result, sorted.
func ProbedNames(m map[string]string) []string {
out := make([]string, 0, len(m))
for n := range m {
out = append(out, n)
}
sort.Strings(out)
return out
}
// probeTimeout bounds one engine's version check.
//
// Generous, because vLLM's --version imports torch and that is seconds on a
// cold page cache, and mean, because this is startup: an engine that has not
// answered in this long is reported as present-with-unknown-version rather
// than waited for.
const probeTimeout = 8 * time.Second
// run executes a version command, saying whether the program was even there.
//
// The two failures are different answers and the caller has to tell them
// apart. A binary that is absent is an engine this node cannot run. A binary
// that is present and exits non-zero is an engine it probably can — unless the
// command was a test of whether a module imports, where a non-zero exit is the
// whole answer.
func run(ctx context.Context, bin string, args ...string) (out string, found bool, ok bool) {
path, err := exec.LookPath(bin)
if err != nil {
return "", false, false
}
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
// Combined, not just stdout: engines print their version to either, and one
// that chose stderr would otherwise read as having no version at all.
b, err := exec.CommandContext(ctx, path, args...).CombinedOutput()
return string(b), true, err == nil
}
// installedBinary reports an engine that is its own executable.
//
// It resolves the path and runs nothing. The first version of this ran the
// engine with --version, which is fine for an engine and not fine for the
// wrapper scripts people put in front of one: a script that shifts past flags
// it does not recognise and then execs the server will start an inference
// server, at agent startup, on every node. The agent's own test suite installs
// exactly such a script, and caught it.
//
// So the version is not read here. Presence is what placement needs, and what
// the version is for — telling whether an engine still accepts the flags llmgw
// builds — is `llmgw ctl engine-check`, which is run deliberately by somebody
// who meant to run the engine.
func installedBinary(bin string) (string, error) {
if _, err := exec.LookPath(bin); err != nil {
return "", errNotInstalled
}
return Unknown, nil
}
// versionOfModule probes an engine that is a module inside an interpreter.
//
// This one does execute, and safely: the command is llmgw's own one-line
// import, not the engine's launcher, so there is no wrapper to shift past
// flags and start a server. There is also no alternative — a module has no
// path to look up.
//
// A non-zero exit is the answer: python3 exists on almost every machine, so
// presence of the interpreter says nothing. Treating a failed import as
// "installed" reported SGLang on every node in a fleet that had none, which
// would take nodes into scheduling for an engine they cannot run — the exact
// failure this whole change exists to stop.
func versionOfModule(ctx context.Context, python string, args ...string) (string, error) {
out, found, ok := run(ctx, python, args...)
if !found || !ok {
return "", errNotInstalled
}
return firstVersion(out), nil
}
var errNotInstalled = errors.New("not installed on this node")
var versionPattern = regexp.MustCompile(`\b\d+\.\d+(\.\d+)?\S*`)
// firstVersion is the first thing in the output that looks like a version.
//
// Pattern rather than parsing each engine's format: they print "vllm 0.6.3",
// "version: b4123 (…)", and a paragraph with the number in the middle, and all
// of those change between releases. What this is for is telling one build from
// another, which the first number does.
func firstVersion(s string) string {
for _, line := range strings.Split(s, "\n") {
if m := versionPattern.FindString(line); m != "" {
return strings.TrimRight(m, ",;)")
}
}
return Unknown
}
// Configured is an engine whose location the agent's configuration names.
type Configured interface {
Configured() bool
}
// ProbeConfigured probes only the engines the configuration names.
//
// Probe looks everywhere — PATH, the default interpreter — and reports what
// it finds, which is exactly the trust in whatever happens to be on a machine
// that installing engines replaces. A node the agent provisions reports what
// it installed, plus what the operator named on purpose; nothing else.
func (r *Registry) ProbeConfigured(ctx context.Context) map[string]string {
out := map[string]string{}
for name, e := range r.engines {
c, ok := e.(Configured)
if !ok || !c.Configured() {
continue
}
p, ok := e.(Prober)
if !ok {
continue
}
if v, err := p.Probe(ctx); err == nil {
if v == "" {
v = Unknown
}
out[name] = v
}
}
return out
}
// Probe reports whether the llama.cpp server is installed.
func (l *LlamaCpp) Probe(context.Context) (string, error) {
return installedBinary(l.bin())
}
// Configured reports whether the configuration names vLLM.
func (v *VLLM) Configured() bool { return v.cfg.Bin != "" }
// Configured reports whether the configuration names SGLang's interpreter.
func (g *SGLang) Configured() bool { return g.cfg.Python != "" }
// Probe reports whether vLLM is installed.
func (v *VLLM) Probe(context.Context) (string, error) {
return installedBinary(v.bin())
}
// Probe reports the SGLang version.
//
// Through the interpreter, because SGLang is a module rather than a binary:
// asking the shell for "sglang" finds nothing on a machine that can run it
// perfectly well.
func (g *SGLang) Probe(ctx context.Context) (string, error) {
return versionOfModule(ctx, g.python(), "-c",
"import sglang, sys; sys.stdout.write(getattr(sglang, '__version__', ''))")
}
+107
View File
@@ -0,0 +1,107 @@
package engines
import (
"context"
"os"
"path/filepath"
"testing"
)
// script writes an executable that stands in for an engine.
func script(t *testing.T, name, body string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
return p
}
func TestAnEngineThatIsNotInstalledIsAbsentFromTheReport(t *testing.T) {
r := NewRegistry(Config{})
r.engines = map[string]Engine{
"vllm": &VLLM{cfg: VLLMConfig{Bin: "/nonexistent/vllm"}},
}
// Absent, not present-with-unknown-version. Placement reads the keys, and
// a node listed for an engine it cannot run is the failure being fixed.
if got := r.Probe(t.Context()); len(got) != 0 {
t.Errorf("expected nothing, got %v", got)
}
}
func TestProbingNeverRunsTheEngine(t *testing.T) {
// A wrapper that shifts past flags it does not recognise and then starts
// the server. People put these in front of engines, and the agent's own
// tests install one. Probing with --version would launch an inference
// server at agent startup, on every node.
marker := filepath.Join(t.TempDir(), "ran")
bin := script(t, "fake-vllm", "touch "+marker+"; sleep 60")
r := NewRegistry(Config{})
r.engines = map[string]Engine{"vllm": &VLLM{cfg: VLLMConfig{Bin: bin}}}
got := r.Probe(t.Context())
if _, ok := got["vllm"]; !ok {
t.Error("a binary that is there should be reported present")
}
if _, err := os.Stat(marker); err == nil {
t.Fatal("the engine was executed by a probe")
}
}
func TestAModuleThatWillNotImportIsNotInstalled(t *testing.T) {
// python3 is on almost every machine, so the interpreter being there says
// nothing about SGLang. Reporting it present put every node in a fleet
// forward for an engine none of them had.
bin := script(t, "python3", "exit 1")
r := NewRegistry(Config{})
r.engines = map[string]Engine{"sglang": &SGLang{cfg: SGLangConfig{Python: bin}}}
if got := r.Probe(t.Context()); len(got) != 0 {
t.Errorf("a failed import means not installed, got %v", got)
}
}
func TestAModuleThatImportsIsReportedWithItsVersion(t *testing.T) {
bin := script(t, "python3", `echo "0.4.1"`)
r := NewRegistry(Config{})
r.engines = map[string]Engine{"sglang": &SGLang{cfg: SGLangConfig{Python: bin}}}
got := r.Probe(t.Context())
if got["sglang"] != "0.4.1" {
t.Errorf("expected the version, got %v", got)
}
}
func TestAVersionIsPickedOutOfWhateverIsPrinted(t *testing.T) {
// Only the module probe reaches this now, and a Python package version is
// dotted. A bare build number is deliberately not matched: it would turn a
// stray number in a line of prose into a version.
for in, want := range map[string]string{
"0.4.1": "0.4.1",
"sglang 0.4.post1": "0.4.post1",
"warning: noise\n0.4.1": "0.4.1",
"": Unknown,
"no numbers here": Unknown,
"just 4123": Unknown,
} {
if got := firstVersion(in); got != want {
t.Errorf("firstVersion(%q) = %q, want %q", in, got, want)
}
}
}
func TestProbingIsBoundedByTheContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
bin := script(t, "python3", "sleep 60")
r := NewRegistry(Config{})
r.engines = map[string]Engine{"sglang": &SGLang{cfg: SGLangConfig{Python: bin}}}
// Registration waits on this. An agent that cannot reach the control plane
// is worse than one reporting an incomplete list.
if got := r.Probe(ctx); len(got) != 0 {
t.Errorf("a cancelled probe should report nothing, got %v", got)
}
}
+29 -6
View File
@@ -1,6 +1,9 @@
package engines
import "strconv"
import (
"strconv"
"sync/atomic"
)
// SGLangConfig locates the SGLang installation.
type SGLangConfig struct {
@@ -11,7 +14,25 @@ type SGLangConfig struct {
}
// SGLang builds SGLang launch commands.
type SGLang struct{ cfg SGLangConfig }
type SGLang struct {
cfg SGLangConfig
installed atomic.Pointer[string]
}
// UseInstalled points the engine at the interpreter of the environment the
// agent installed and checked, ahead of the configuration.
func (g *SGLang) UseInstalled(path string) { g.installed.Store(&path) }
// python is the interpreter to launch SGLang with.
func (g *SGLang) python() string {
if p := g.installed.Load(); p != nil {
return *p
}
if g.cfg.Python != "" {
return g.cfg.Python
}
return "python3"
}
// Name identifies the engine in the catalog.
func (*SGLang) Name() string { return "sglang" }
@@ -55,10 +76,7 @@ func (g *SGLang) Commands(s ShardSpec) ([]Command, error) {
}
func (g *SGLang) serverCommand(s ShardSpec) (Command, error) {
python := g.cfg.Python
if python == "" {
python = "python3"
}
python := g.python()
p := s.Profile
args := []string{"-m", "sglang.launch_server",
@@ -78,6 +96,11 @@ func (g *SGLang) serverCommand(s ShardSpec) (Command, error) {
)
}
args = append(args, p.Args...)
// SGLang reads its key only from the command line, so on a node with
// other users it is visible to them in the process list.
if s.APIKey != "" && s.Leader() {
args = append(args, "--api-key", s.APIKey)
}
return Command{Name: "sglang", Path: python, Args: args, Env: materializeEnv(baseEnv(s))}, nil
}
+85
View File
@@ -0,0 +1,85 @@
package engines
import (
"fmt"
"os"
"strconv"
)
// SimConfig locates the simulated engine.
type SimConfig struct {
// Bin is the llmgw binary itself; the engine is one of its subcommands.
// Empty means whatever started this agent, which is right almost always.
Bin string `koanf:"bin"`
}
// Sim launches llmgw's own simulated engine.
//
// It is in the production registry on purpose. A cluster can be brought up and
// exercised end to end — placement, fabrics, multi-node sequencing, failure and
// rebuild — on machines that have no GPUs, which is the only way most of that
// path gets tested at all. The multi-node launch sequence went four phases
// without ever running, because the only things exercising it were stub
// processes that exercised the command line and nothing else.
//
// Nothing about it pretends to be real: the model name it serves is whatever it
// is told, the tokens are invented, and it says so in its own logs.
type Sim struct{ cfg SimConfig }
// Name identifies the engine in the catalog.
func (*Sim) Name() string { return "sim" }
// HealthPath is the simulated engine's readiness endpoint.
func (*Sim) HealthPath() string { return "/health" }
// MetricsPath is its Prometheus endpoint, in vLLM's vocabulary.
func (*Sim) MetricsPath() string { return "/metrics" }
// ServesHTTP reports whether this rank answers the API.
//
// Only the leader, exactly as vLLM and SGLang behave — which is the behaviour
// this engine exists to let us test.
func (*Sim) ServesHTTP(s ShardSpec) bool { return s.Leader() }
// Validate accepts anything the arithmetic allows.
//
// No compute-capability checks and no quantization rules: there is no hardware
// to be wrong about, and inventing refusals would make this a worse stand-in
// for the engines it stands in for.
func (*Sim) Validate(p Profile, gpus []GPUInfo) error {
return validateCommon(p, gpus)
}
// Commands returns the single process that makes up one shard.
func (s *Sim) Commands(spec ShardSpec) ([]Command, error) {
bin := s.cfg.Bin
if bin == "" {
exe, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("locating the llmgw binary for the sim engine: %w", err)
}
bin = exe
}
p := spec.Profile
args := []string{"sim-engine",
"--listen", "0.0.0.0:" + strconv.Itoa(spec.HTTPPort),
"--model", p.ServedModelName,
}
if spec.NNodes > 1 {
args = append(args,
"--nnodes", strconv.Itoa(spec.NNodes),
"--node-rank", strconv.Itoa(spec.Rank),
"--dist-init-addr", spec.LeaderAddr+":"+strconv.Itoa(spec.LeaderPort),
)
}
args = append(args, p.Args...)
return []Command{{
Name: "sim-engine", Path: bin, Args: args,
Env: materializeEnv(baseEnv(spec)),
// Workers dial the leader, so the leader has to be listening first.
// Both ranks express the same wait because both know the same address.
WaitForLeaderPort: spec.NNodes > 1 && !spec.Leader(),
}}, nil
}
+88
View File
@@ -0,0 +1,88 @@
package engines
import (
"slices"
"testing"
)
func simSpec(rank, nnodes int) ShardSpec {
return ShardSpec{
ReplicaID: "rep-1", ShardID: "sh-1", Rank: rank, NNodes: nnodes,
LeaderAddr: "10.0.0.5", LeaderPort: 20001,
GPUIndices: []int{0, 1}, HTTPPort: 20000, MgmtInterface: "eth0",
HCAs: []string{"mlx5_0", "mlx5_1"},
Profile: Profile{
Name: "tp4", Engine: "sim", TP: 4, GPUsPerNode: 2, Nodes: nnodes,
ModelPath: "/models/x", ServedModelName: "big-model",
},
}
}
func TestOnlyTheLeaderServes(t *testing.T) {
// The behaviour that made replicas fail while they were coming up perfectly
// well: health checking a worker's port, which never opens.
if !(&Sim{}).ServesHTTP(simSpec(0, 2)) {
t.Error("the leader does not serve")
}
if (&Sim{}).ServesHTTP(simSpec(1, 2)) {
t.Error("a worker claims to serve")
}
}
func TestAMultiNodeShardCarriesItsRendezvous(t *testing.T) {
cmds, err := (&Sim{cfg: SimConfig{Bin: "/usr/local/bin/llmgw"}}).Commands(simSpec(1, 2))
if err != nil {
t.Fatal(err)
}
if len(cmds) != 1 {
t.Fatalf("%d commands", len(cmds))
}
c := cmds[0]
if c.Args[0] != "sim-engine" {
t.Errorf("the engine is a subcommand of llmgw: %v", c.Args)
}
for flag, want := range map[string]string{
"--nnodes": "2", "--node-rank": "1", "--dist-init-addr": "10.0.0.5:20001",
} {
if got, _ := flagValue(c.Args, flag); got != want {
t.Errorf("%s = %q, want %q", flag, got, want)
}
}
// A worker must wait for the leader's port, or the launch is a race.
if !c.WaitForLeaderPort {
t.Error("a worker does not wait for the leader")
}
}
func TestTheLeaderDoesNotWaitForItself(t *testing.T) {
cmds, _ := (&Sim{cfg: SimConfig{Bin: "x"}}).Commands(simSpec(0, 2))
if cmds[0].WaitForLeaderPort {
t.Error("the leader waits for a port it is about to open")
}
}
func TestASingleNodeShardCarriesNoRendezvous(t *testing.T) {
cmds, _ := (&Sim{cfg: SimConfig{Bin: "x"}}).Commands(simSpec(0, 1))
for _, flag := range []string{"--nnodes", "--node-rank", "--dist-init-addr"} {
if slices.Contains(cmds[0].Args, flag) {
t.Errorf("a single-node shard was given %s", flag)
}
}
}
func TestTheShardIsStillPinnedToItsFabric(t *testing.T) {
// The simulated engine does not use NCCL, but the environment is generated
// by the same code that generates it for the ones that do — so a cluster
// exercised with this engine is exercising that too.
cmds, _ := (&Sim{cfg: SimConfig{Bin: "x"}}).Commands(simSpec(1, 2))
env := cmds[0].Env
for _, want := range []string{
"NCCL_IB_HCA=mlx5_0,mlx5_1", "NCCL_SOCKET_IFNAME=eth0",
"CUDA_VISIBLE_DEVICES=0,1", "NCCL_IB_DISABLE=0",
} {
if !slices.Contains(env, want) {
t.Errorf("the environment is missing %s", want)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package engines
import "strings"
// UnsafeEnv reports whether a profile may not set an environment variable,
// and why.
//
// A profile's env is the escape hatch for tuning no adapter can guess —
// CUDA_*, NCCL_*, VLLM_*, HF_* — and it reaches an engine the agent starts as
// root. Some variables are not tuning but a way to run other code in that
// process: the dynamic loader's LD_PRELOAD, Python's PYTHONSTARTUP, a shell's
// BASH_ENV. Set from a model definition, which an admin API or a config file
// supplies, they would turn "add a model" into "run this as root on every
// node". They are refused where a model is defined and again where an engine
// starts.
func UnsafeEnv(key string) (bool, string) {
k := strings.ToUpper(key)
switch {
case strings.HasPrefix(k, "LD_"), strings.HasPrefix(k, "DYLD_"):
return true, "it changes what the dynamic loader loads"
case k == "PATH":
return true, "it changes which binaries run"
case strings.HasPrefix(k, "PYTHON") && k != "PYTHONUNBUFFERED" && k != "PYTHONHASHSEED":
return true, "it changes what Python imports or runs at start"
case k == "BASH_ENV" || k == "ENV" || k == "PERL5OPT" || k == "PERL5LIB" ||
k == "NODE_OPTIONS" || k == "RUBYOPT" || k == "GCONV_PATH" || k == "OPENSSL_CONF":
return true, "it makes an interpreter or library load code"
}
return false, ""
}
+54 -16
View File
@@ -3,24 +3,48 @@ package engines
import (
"fmt"
"strconv"
"sync/atomic"
)
// VLLMConfig locates the vLLM installation and picks how it is launched.
type VLLMConfig struct {
// Bin is the vllm executable.
Bin string `koanf:"bin"`
// LaunchMode selects the multi-node mechanism: "ray" or "mp".
// LaunchMode selects the multi-node mechanism: "mp", the default, or
// "ray".
//
// Which one works depends on the vLLM version, and the flags have changed
// more than once (plan §14). Both paths exist so a cluster can pick the one
// its version supports instead of waiting for a code change.
// mp is what vLLM documents today — every node runs serve with its rank,
// the workers headless — and needs nothing but vLLM. ray is the older way,
// kept for a vLLM too old for mp's multi-node flags; it needs Ray on every
// node and has not been checked against a real cluster.
LaunchMode string `koanf:"launch_mode"`
// RayBin is used when LaunchMode is "ray".
RayBin string `koanf:"ray_bin"`
}
// VLLM builds vLLM launch commands.
type VLLM struct{ cfg VLLMConfig }
type VLLM struct {
cfg VLLMConfig
installed atomic.Pointer[string]
}
// UseInstalled points the engine at the environment the agent installed and
// checked. It takes precedence over the configuration, as llama.cpp's does:
// it is the one seen to run. An operator who wants their own sets the
// control plane's engines.vllm to "".
func (v *VLLM) UseInstalled(path string) { v.installed.Store(&path) }
// bin is the vllm to run: the installed one, else the configured one, else
// whatever PATH has.
func (v *VLLM) bin() string {
if p := v.installed.Load(); p != nil {
return *p
}
if v.cfg.Bin != "" {
return v.cfg.Bin
}
return "vllm"
}
// Name identifies the engine in the catalog.
func (*VLLM) Name() string { return "vllm" }
@@ -78,10 +102,7 @@ func (v *VLLM) Commands(s ShardSpec) ([]Command, error) {
// serverCommand produces the vLLM server process.
func (v *VLLM) serverCommand(s ShardSpec) (Command, error) {
bin := v.cfg.Bin
if bin == "" {
bin = "vllm"
}
bin := v.bin()
p := s.Profile
args := []string{"serve", p.ModelPath,
@@ -100,16 +121,27 @@ func (v *VLLM) serverCommand(s ShardSpec) (Command, error) {
if s.NNodes > 1 {
switch v.cfg.LaunchMode {
case "", "ray":
case "ray":
// Ray is started separately by the supervisor; vLLM then finds the
// cluster through the address in the environment.
env["VLLM_HOST_IP"] = s.LeaderAddr
env["RAY_ADDRESS"] = fmt.Sprintf("%s:%d", s.LeaderAddr, s.LeaderPort)
args = append(args, "--distributed-executor-backend", "ray")
case "mp":
env["VLLM_HOST_IP"] = s.LeaderAddr
env["VLLM_PORT"] = strconv.Itoa(s.LeaderPort)
args = append(args, "--distributed-executor-backend", "mp")
case "", "mp":
// vLLM's own multi-node launch: every node runs the same serve
// with its rank and the leader's address, and the workers run
// headless — no API server, only their GPUs. VLLM_HOST_IP is
// this node's address for vLLM to find itself, so it is left
// to vLLM; the leader's, which workers used to be given, made
// every worker announce itself as the leader.
args = append(args, "--distributed-executor-backend", "mp",
"--nnodes", strconv.Itoa(s.NNodes),
"--node-rank", strconv.Itoa(s.Rank),
"--master-addr", s.LeaderAddr,
"--master-port", strconv.Itoa(s.LeaderPort))
if !s.Leader() {
args = append(args, "--headless")
}
default:
return Command{}, fmt.Errorf("vllm: unknown launch_mode %q (want \"ray\" or \"mp\")", v.cfg.LaunchMode)
}
@@ -118,13 +150,16 @@ func (v *VLLM) serverCommand(s ShardSpec) (Command, error) {
// Profile arguments last, so an operator can override anything above.
args = append(args, p.Args...)
if s.APIKey != "" {
env["VLLM_API_KEY"] = s.APIKey
}
return Command{Name: "vllm", Path: bin, Args: args, Env: materializeEnv(env)}, nil
}
// rayCommand returns the command that starts or joins the Ray cluster, or
// ok=false when the launch mode does not use Ray.
func (v *VLLM) rayCommand(s ShardSpec) (Command, bool) {
if s.NNodes <= 1 || (v.cfg.LaunchMode != "" && v.cfg.LaunchMode != "ray") {
if s.NNodes <= 1 || v.cfg.LaunchMode != "ray" {
return Command{}, false
}
bin := v.cfg.RayBin
@@ -134,7 +169,10 @@ func (v *VLLM) rayCommand(s ShardSpec) (Command, bool) {
args := []string{"start", "--block"}
if s.Leader() {
args = append(args, "--head", "--port", strconv.Itoa(s.LeaderPort))
// Ray's head has no authentication: whoever reaches its port can run
// code on the node. Its dashboard, and the job API behind it, is the
// easiest way in, and nothing here uses it.
args = append(args, "--head", "--port", strconv.Itoa(s.LeaderPort), "--include-dashboard=false")
} else {
args = append(args, "--address", fmt.Sprintf("%s:%d", s.LeaderAddr, s.LeaderPort))
}
+345
View File
@@ -0,0 +1,345 @@
package agent
import (
"context"
"fmt"
"os/exec"
"slices"
"strconv"
"strings"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
)
// usableFabrics reports the fabrics this node can actually carry traffic on.
//
// Loud about what it drops. A fabric silently missing from a registration looks
// identical to one never configured, and the operator who typed the adapter
// name is the only person who can fix it — so the reason is logged here, on the
// machine where both halves are visible, rather than inferred later from a
// replica that will not converge.
func (a *Agent) usableFabrics(inv discovery.Inventory) []inventory.FabricAttachment {
out := a.checkFabrics(inv.HCAs, true)
a.mu.Lock()
a.fabricsSent = fabricsFingerprint(out)
a.mu.Unlock()
return out
}
// checkFabrics is the declared fabrics this node can carry traffic on, from
// the adapters as they are now. Loud logs every problem; the heartbeat checks
// quietly and speaks only when the answer changes.
func (a *Agent) checkFabrics(hcas []discovery.HCA, loud bool) []inventory.FabricAttachment {
declared := map[string][]string{}
for _, f := range a.cfg.Fabrics {
declared[f.Fabric] = f.HCAs
}
var out []inventory.FabricAttachment
for _, c := range discovery.CheckFabrics(declared, hcas) {
if loud {
for _, p := range c.Problems {
a.log.Error("a declared fabric adapter cannot carry traffic",
"fabric", c.Fabric, "problem", p)
}
}
if !c.OK() {
if loud {
a.log.Error("fabric not advertised; no declared adapter on it can carry traffic",
"fabric", c.Fabric,
"consequence", "no multi-node replica will be placed here")
}
continue
}
out = append(out, inventory.FabricAttachment{
Fabric: c.Fabric, HCAs: c.Usable, RateGbps: c.RateGbps,
})
}
return out
}
// fabricsFingerprint is enough to notice a fabric, an adapter or a rate
// changing.
func fabricsFingerprint(fs []inventory.FabricAttachment) string {
var b strings.Builder
for _, f := range fs {
fmt.Fprintf(&b, "%s:%s:%d;", f.Fabric, strings.Join(f.HCAs, ","), f.RateGbps)
}
return b.String()
}
// sampleIB re-reads the adapters for the heartbeat: every port's state and
// counters, and the fabrics again when they no longer match what the control
// plane was told.
//
// Every heartbeat, because it is a few small files per port, and because a
// port that went down used to be invisible until the agent restarted — the
// node went on being offered multi-node work that would hang on its first
// collective.
func (a *Agent) sampleIB(hb *llmgwv1.Heartbeat) {
a.mu.Lock()
had := len(a.inv.HCAs) > 0
a.mu.Unlock()
if !had && len(a.cfg.Fabrics) == 0 {
return
}
hcas, err := discovery.DiscoverIB(a.cfg.Discovery.IBRoot)
if err != nil {
return
}
a.attachSM(hcas)
for _, h := range hcas {
for _, p := range h.Ports {
ip := &llmgwv1.IBPort{
Hca: h.Name, Port: int32(p.Num), State: p.State, PhysState: p.PhysState,
RateGbps: int32(p.RateGbps), LinkLayer: string(p.LinkLayer), Counters: p.Counters,
NodeGuid: h.NodeGUID, PortGuid: p.PortGUID, Network: p.Network, GidPrefix: p.GIDPrefix,
}
a.mu.Lock()
if p.SM != nil {
ip.SmGuid, ip.SmLid, ip.SmPriority, ip.SmState = p.SM.GUID, int32(p.SM.LID), int32(p.SM.Priority), p.SM.State
} else {
ip.SmError = a.smErr[smKey(h.Name, p.Num)]
}
for _, r := range a.sms[smKey(h.Name, p.Num)] {
ip.SubnetManagers = append(ip.SubnetManagers, &llmgwv1.SubnetManager{
Guid: r.GUID, Lid: int32(r.LID), Priority: int32(r.Priority), State: r.State})
}
ip.SubnetManagersError = a.smsErr[smKey(h.Name, p.Num)]
a.mu.Unlock()
hb.IbPorts = append(hb.IbPorts, ip)
}
}
a.mu.Lock()
a.inv.HCAs = hcas // a reconnect registers what is true now
a.mu.Unlock()
if len(a.cfg.Fabrics) == 0 {
return
}
now := a.checkFabrics(hcas, false)
fp := fabricsFingerprint(now)
a.mu.Lock()
changed := fp != a.fabricsSent
a.fabricsSent = fp
a.mu.Unlock()
if !changed {
return
}
// Said once, with the reasons, the moment it happens.
a.checkFabrics(hcas, true)
a.log.Warn("the fabrics this node can carry traffic on changed", "now", fp)
hb.FabricsChanged = true
for _, f := range now {
hb.Fabrics = append(hb.Fabrics, &llmgwv1.FabricAttachment{
Fabric: f.Fabric, Hcas: f.HCAs, RateGbps: int32(f.RateGbps),
})
}
}
func smKey(hca string, port int) string { return fmt.Sprintf("%s/%d", hca, port) }
// scanFabric is discovery.ScanFabric, replaced in tests.
var scanFabric = discovery.ScanFabric
// querySMs is discovery.QuerySMs, replaced in tests.
var querySMs = func(hca string, port int) ([]discovery.SMRecord, error) {
return discovery.QuerySMs(context.Background(), hca, port)
}
func splitSMKey(k string) (string, int) {
i := strings.LastIndexByte(k, '/')
port, _ := strconv.Atoi(k[i+1:])
return k[:i], port
}
// querySM is discovery.QuerySM, replaced in tests.
var querySM = func(hca string, port int) (*discovery.SMInfo, error) {
return discovery.QuerySM(context.Background(), hca, port)
}
// attachSM puts on each active InfiniBand port the subnet manager it sees,
// asking again once a minute and otherwise reusing the last answer.
func (a *Agent) attachSM(hcas []discovery.HCA) {
a.mu.Lock()
stale := time.Since(a.smAsked) > time.Minute || a.sm == nil
a.mu.Unlock()
if stale {
sm, errs := map[string]*discovery.SMInfo{}, map[string]string{}
_ = discovery.AttachSM(hcas, func(hca string, port int) (*discovery.SMInfo, error) {
got, err := querySM(hca, port)
if err != nil {
errs[smKey(hca, port)] = err.Error()
return nil, err
}
sm[smKey(hca, port)] = got
return got, nil
})
// Every subnet manager on each fabric, through one port per master:
// the same list through every port of one fabric is one query too
// many for each.
sms, smsErr := map[string][]discovery.SMRecord{}, map[string]string{}
asked := map[string]bool{}
for k, v := range sm {
if asked[v.GUID] {
continue
}
asked[v.GUID] = true
hca, port := splitSMKey(k)
if recs, err := querySMs(hca, port); err != nil {
smsErr[k] = err.Error()
} else {
sms[k] = recs
}
}
a.mu.Lock()
for k, v := range sm {
if old := a.sm[k]; old != nil && old.GUID != v.GUID {
a.log.Warn("the master subnet manager changed", "port", k, "from", old.GUID, "to", v.GUID)
}
}
a.sm, a.smErr, a.smAsked = sm, errs, time.Now()
a.sms, a.smsErr = sms, smsErr
a.mu.Unlock()
return
}
a.mu.Lock()
defer a.mu.Unlock()
for i := range hcas {
for j := range hcas[i].Ports {
if sm := a.sm[smKey(hcas[i].Name, hcas[i].Ports[j].Num)]; sm != nil && hcas[i].Ports[j].Active() {
hcas[i].Ports[j].SM = sm
}
}
}
}
// ensureDiags installs infiniband-diags on a node with adapters and without
// it. sminfo is what tells which physical fabric a port is on; without it,
// two separate fabrics given one name cannot be caught. Installed the way
// engine dependencies are, from the machine's own package sources.
func (a *Agent) ensureDiags(ctx context.Context) {
a.mu.Lock()
hasIB := len(a.inv.HCAs) > 0
a.mu.Unlock()
if !hasIB || a.prov == nil {
return
}
if _, err := exec.LookPath("sminfo"); err == nil {
return
}
a.log.Info("installing infiniband-diags, to tell which fabric each port is on")
pkgs := map[string][]string{"deb": {"infiniband-diags"}, "rpm": {"infiniband-diags"}}
if err := a.prov.InstallPackages(ctx, pkgs); err != nil {
a.log.Warn("could not install infiniband-diags; fabrics cannot be told apart by their subnet manager",
"err", err)
}
}
// FeatureFabricTest is the agent answering FabricTestRequest.
const FeatureFabricTest = "fabric-test"
// runWriteBW is discovery.RunWriteBW, and haveWriteBW whether it is
// installed; both replaced in tests.
var (
runWriteBW = discovery.RunWriteBW
haveWriteBW = func() bool { _, err := exec.LookPath("ib_write_bw"); return err == nil }
)
// fabricTest runs this node's side of a bandwidth test with another.
func (a *Agent) fabricTest(ctx context.Context, req *llmgwv1.FabricTestRequest) *llmgwv1.FabricTestReply {
start := time.Now()
reply := &llmgwv1.FabricTestReply{RequestId: req.GetRequestId()}
defer func() { reply.TookMs = time.Since(start).Milliseconds() }()
w := discovery.WriteBW{HCA: req.GetHca(), Port: int(req.GetPort()), GIDIndex: -1,
TCPPort: int(req.GetTcpPort()), Seconds: int(req.GetSeconds())}
if req.GetRole() == "client" {
w.Peer = req.GetPeer()
}
a.mu.Lock()
found := false
for _, h := range a.inv.HCAs {
for _, p := range h.Ports {
if h.Name == w.HCA && p.Num == w.Port {
found = true
// RoCE needs its v2 GID named, as NCCL does; InfiniBand uses the LID.
if p.LinkLayer == discovery.LinkEthernet && p.RoCEv2GID >= 0 {
w.GIDIndex = p.RoCEv2GID
}
}
}
}
a.mu.Unlock()
if !found {
reply.Error = fmt.Sprintf("this node has no port %s/%d", w.HCA, w.Port)
return reply
}
if !haveWriteBW() && a.prov != nil {
a.log.Info("installing perftest, for a bandwidth test with another node")
pkgs := map[string][]string{"deb": {"perftest"}, "rpm": {"perftest"}}
if err := a.prov.InstallPackages(ctx, pkgs); err != nil {
reply.Error = "installing perftest: " + err.Error()
return reply
}
}
gbps, out, err := runWriteBW(ctx, w)
reply.Gbps = gbps
reply.Output = tailLines(out, 12)
if err != nil {
reply.Error = err.Error()
}
return reply
}
func tailLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
// discoverGPUFabric and discoverIMEX are replaced in tests.
var (
discoverGPUFabric = discovery.DiscoverGPUFabric
discoverIMEX = discovery.DiscoverIMEX
)
// sampleNVLink reads, once a minute, which NVLink domain each GPU is joined
// to and whether IMEX is up. nvidia-smi -q takes a second or two, too long
// for every heartbeat, and a fabric that was joined an hour ago rarely
// changes in five seconds — but when a fabric manager restarts it does, for
// every card at once.
func (a *Agent) sampleNVLink(hb *llmgwv1.Heartbeat) {
a.mu.Lock()
gpus := slices.Clone(a.inv.GPUs)
due := time.Since(a.nvlAsked) > time.Minute
if due {
a.nvlAsked = time.Now()
}
a.mu.Unlock()
if len(gpus) == 0 || !due {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
fab, err := discoverGPUFabric(ctx, a.cfg.Discovery.NvidiaSMI)
if err != nil || len(fab) == 0 {
return
}
discovery.ApplyFabric(gpus, fab)
for _, g := range gpus {
if g.Fabric == nil {
continue
}
hb.GpuFabric = append(hb.GpuFabric, &llmgwv1.GPUFabricState{
Index: int32(g.Index), State: g.Fabric.State, Status: g.Fabric.Status,
ClusterUuid: g.Fabric.ClusterUUID, CliqueId: g.Fabric.CliqueID, Bandwidth: g.Fabric.Bandwidth,
})
}
m := discoverIMEX(ctx)
hb.Imex = &llmgwv1.IMEX{State: m.State, Channels: int32(m.Channels)}
}
+107
View File
@@ -0,0 +1,107 @@
package agent
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
)
type standby bool
func (s standby) IsLeader() bool { return !bool(s) }
// startPlain runs an agent service over h2c, which is enough to test who
// accepts a connection and who refuses it.
//
// A nil leader means none was configured, which is every deployment that
// existed before leadership did and has to keep accepting agents.
func startPlain(t *testing.T, leader agentsvc.Leader) (*inventory.Store, string) {
t.Helper()
inv := inventory.NewStore()
svc := agentsvc.New(inv, newRecorder(), false, slog.New(slog.DiscardHandler))
if leader != nil {
svc.SetLeadership(leader)
}
path, handler := svc.Handler()
mux := http.NewServeMux()
mux.Handle(path, handler)
// h2c, because the agent speaks HTTP/2 with prior knowledge and a plain
// server negotiates HTTP/1.1 — which turns a clear refusal into a frame
// parsing error that says nothing about why.
srv := httptest.NewServer(h2c.NewHandler(mux, &http2.Server{}))
t.Cleanup(srv.Close)
return inv, srv.URL
}
func TestAStandbyRefusesAnAgent(t *testing.T) {
// Commands travel down the stream the agent opened, so whichever control
// plane holds it is the only one that can start anything on that node. A
// standby holding it means the leader's placements fail with "no agent
// connected" for a node that is plainly connected.
_, addr := startPlain(t, standby(true))
ag := runAgent(t, Config{NodeID: "n1", Server: addr, MgmtAddr: "127.0.0.1"})
err := ag.session(context.Background(), ag.cfg.Server)
if err == nil {
t.Fatal("a standby accepted an agent")
}
if !strings.Contains(err.Error(), "standby") {
t.Errorf("the refusal does not say why: %v", err)
}
}
func TestAnAgentReachesTheLeaderPastAStandby(t *testing.T) {
// The point of the list. The standby is first, so the agent only registers
// if it moved on rather than sitting on the refusal.
_, standbyAddr := startPlain(t, standby(true))
leaderInv, leaderAddr := startPlain(t, standby(false))
ag := runAgent(t, Config{
NodeID: "n1", MgmtAddr: "127.0.0.1",
Servers: []string{standbyAddr, leaderAddr},
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go ag.Run(ctx)
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
if _, ok := leaderInv.Get("n1"); ok {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("the agent never reached the leader past the standby")
}
func TestALeaderStillAcceptsWithNoLeadershipConfigured(t *testing.T) {
// Every deployment before this had no leadership at all, and must keep
// accepting agents with nothing configured.
inv, addr := startPlain(t, nil)
ag := runAgent(t, Config{NodeID: "n1", Server: addr, MgmtAddr: "127.0.0.1"})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go ag.Run(ctx)
deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) {
if _, ok := inv.Get("n1"); ok {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("a service with no leadership configured refused an agent")
}
+84
View File
@@ -0,0 +1,84 @@
package agent
import (
"context"
"log/slog"
"strings"
"sync"
)
// agentLogLines is how much of its own log an agent keeps for the admin page.
// Enough to cover an engine install or a failed start; the journal keeps the
// rest for anyone who can reach it.
const agentLogLines = 2000
// logRing keeps the most recent lines written to it.
type logRing struct {
mu sync.Mutex
lines []string
next int
full bool
}
func newLogRing(n int) *logRing { return &logRing{lines: make([]string, n)} }
// Write takes whole lines, as a text handler writes them.
func (r *logRing) Write(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
r.lines[r.next] = line
r.next = (r.next + 1) % len(r.lines)
if r.next == 0 {
r.full = true
}
}
return len(p), nil
}
// tail returns up to n of the most recent lines, oldest first.
func (r *logRing) tail(n int) []string {
r.mu.Lock()
defer r.mu.Unlock()
size := r.next
if r.full {
size = len(r.lines)
}
n = min(n, size)
out := make([]string, 0, n)
for i := size - n; i < size; i++ {
idx := i
if r.full {
idx = (r.next + i) % len(r.lines)
}
out = append(out, r.lines[idx])
}
return out
}
// teeHandler sends every record to two handlers: wherever the agent already
// logs, and the ring the admin page reads.
type teeHandler struct{ a, b slog.Handler }
func (t teeHandler) Enabled(ctx context.Context, l slog.Level) bool {
return t.a.Enabled(ctx, l) || t.b.Enabled(ctx, l)
}
func (t teeHandler) Handle(ctx context.Context, r slog.Record) error {
var err error
if t.a.Enabled(ctx, r.Level) {
err = t.a.Handle(ctx, r.Clone())
}
if t.b.Enabled(ctx, r.Level) {
_ = t.b.Handle(ctx, r.Clone())
}
return err
}
func (t teeHandler) WithAttrs(as []slog.Attr) slog.Handler {
return teeHandler{t.a.WithAttrs(as), t.b.WithAttrs(as)}
}
func (t teeHandler) WithGroup(name string) slog.Handler {
return teeHandler{t.a.WithGroup(name), t.b.WithGroup(name)}
}
+49
View File
@@ -0,0 +1,49 @@
package agent
import (
"fmt"
"slices"
"strings"
"testing"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
func TestTheLogRingKeepsTheNewestLinesInOrder(t *testing.T) {
r := newLogRing(3)
if got := r.tail(10); len(got) != 0 {
t.Fatalf("empty ring gave %v", got)
}
for i := 1; i <= 5; i++ {
fmt.Fprintf(r, "line %d\n", i)
}
if got := r.tail(10); !slices.Equal(got, []string{"line 3", "line 4", "line 5"}) {
t.Errorf("after wrapping: %v", got)
}
if got := r.tail(2); !slices.Equal(got, []string{"line 4", "line 5"}) {
t.Errorf("tail(2): %v", got)
}
}
func TestANodeAnswersForItsOwnLog(t *testing.T) {
cfg := Config{Server: "127.0.0.1:7443", NodeID: "n1", WeightsCache: t.TempDir(), EnginesDir: t.TempDir()}
cfg.Superv.WorkDir = t.TempDir()
a, err := New(cfg, nil, nil)
if err != nil {
t.Fatal(err)
}
a.log.Info("engine installation", "state", "installing")
r := a.logsReply(&llmgwv1.LogsRequest{RequestId: "x", Source: "agent", Lines: 50})
if r.GetRequestId() != "x" || !strings.Contains(strings.Join(r.GetLines(), "\n"), "engine installation") {
t.Errorf("the agent's own line is not in its log: %v", r.GetLines())
}
if !slices.Contains(r.GetSources(), "agent") {
t.Errorf("sources %v", r.GetSources())
}
r = a.logsReply(&llmgwv1.LogsRequest{RequestId: "y", Source: "no-such-shard"})
if r.GetError() == "" {
t.Error("an unknown engine was answered as if it existed")
}
}
+34
View File
@@ -0,0 +1,34 @@
package agent
import (
"slices"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
// logsReply answers a request for one of this node's logs.
func (a *Agent) logsReply(req *llmgwv1.LogsRequest) *llmgwv1.LogsReply {
n := int(req.GetLines())
if n <= 0 {
n = 200
}
n = min(n, agentLogLines)
shards := a.sup.IDs()
slices.Sort(shards)
out := &llmgwv1.LogsReply{
RequestId: req.GetRequestId(),
Sources: append([]string{"agent"}, shards...),
}
switch src := req.GetSource(); src {
case "", "agent":
out.Lines = a.logRing.tail(n)
default:
sh, err := a.sup.Get(src)
if err != nil {
out.Error = "no engine " + src + " runs on this node"
return out
}
out.Lines = sh.Logs(n)
}
return out
}
+317
View File
@@ -0,0 +1,317 @@
package agent
import (
"context"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// pki issues a cluster's certificates into a temporary directory and returns
// the configurations the control plane and one agent would load.
func pki(t *testing.T, nodeID string) (server, client tlsconf.Config) {
t.Helper()
dir := t.TempDir()
ca, err := tlsconf.NewCA("test CA")
if err != nil {
t.Fatal(err)
}
if err := ca.Write(dir); err != nil {
t.Fatal(err)
}
srv, err := ca.IssueServer("control-plane", []string{"127.0.0.1"})
if err != nil {
t.Fatal(err)
}
if err := srv.Write(dir, "server"); err != nil {
t.Fatal(err)
}
cli, err := ca.IssueClient(nodeID)
if err != nil {
t.Fatal(err)
}
if err := cli.Write(dir, "agent"); err != nil {
t.Fatal(err)
}
caPath := filepath.Join(dir, "ca.pem")
return tlsconf.Config{CA: caPath, Cert: filepath.Join(dir, "server.pem"), Key: filepath.Join(dir, "server-key.pem")},
tlsconf.Config{CA: caPath, Cert: filepath.Join(dir, "agent.pem"), Key: filepath.Join(dir, "agent-key.pem")}
}
// startTLS runs a real service behind a real TLS listener, with identity
// required — the deployed configuration rather than an approximation of it.
func startTLS(t *testing.T, serverTLS tlsconf.Config) (*agentsvc.Service, *inventory.Store, string) {
t.Helper()
inv := inventory.NewStore()
svc := agentsvc.New(inv, newRecorder(), true, slog.New(slog.DiscardHandler))
path, handler := svc.Handler()
mux := http.NewServeMux()
mux.Handle(path, handler)
tc, err := serverTLS.ServerConfig()
if err != nil {
t.Fatal(err)
}
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := httptest.NewUnstartedServer(mux)
srv.Listener = raw
srv.TLS = tc
srv.StartTLS()
t.Cleanup(srv.Close)
return svc, inv, "https://" + raw.Addr().String()
}
func runAgent(t *testing.T, cfg Config) *Agent {
t.Helper()
cfg.MgmtAddr = "127.0.0.1"
cfg.HeartbeatInterval = 50 * time.Millisecond
cfg.ReconnectBase = 20 * time.Millisecond
cfg.ReconnectMax = 100 * time.Millisecond
cfg.Discovery.SkipGPUs = true
cfg.Discovery.IBRoot = filepath.Join(t.TempDir(), "absent")
cfg.Superv = supervisor.Config{
WorkDir: t.TempDir(), LogLines: 64,
StopTimeout: 2 * time.Second, HealthInterval: 20 * time.Millisecond,
}
client, err := Client(cfg.TLS, 5*time.Second)
if err != nil {
t.Fatal(err)
}
ag, err := New(cfg, client, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
go func() { _ = ag.Run(ctx) }()
t.Cleanup(func() {
cancel()
ag.Supervisor().StopAll(context.Background())
})
return ag
}
func registered(svc *agentsvc.Service, inv *inventory.Store, nodeID string) func() bool {
return func() bool {
_, ok := inv.Get(nodeID)
return ok && len(svc.ConnectedNodes()) == 1
}
}
func eventually(t *testing.T, limit time.Duration, pred func() bool) bool {
t.Helper()
deadline := time.Now().Add(limit)
for time.Now().Before(deadline) {
if pred() {
return true
}
time.Sleep(10 * time.Millisecond)
}
return false
}
func TestAgentRegistersOverMutualTLS(t *testing.T) {
serverTLS, clientTLS := pki(t, "llmgw-node01")
svc, inv, url := startTLS(t, serverTLS)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node01"
cfg.Server = url
cfg.TLS = clientTLS
runAgent(t, cfg)
if !eventually(t, 10*time.Second, registered(svc, inv, "llmgw-node01")) {
t.Fatal("the agent never registered over mutual TLS")
}
}
func TestNodeCannotClaimAnotherNodesIdentity(t *testing.T) {
// The point of the whole exercise. node01 holds a valid certificate — it is
// a legitimate member of the cluster — and still cannot register as node02,
// because the id it claims is checked against the one it can prove.
serverTLS, clientTLS := pki(t, "llmgw-node01")
svc, inv, url := startTLS(t, serverTLS)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node02"
cfg.Server = url
cfg.TLS = clientTLS
runAgent(t, cfg)
// The agent reconnects on failure, so this has to stay false throughout
// rather than merely start false.
if eventually(t, 2*time.Second, func() bool {
_, ok := inv.Get("llmgw-node02")
return ok || len(svc.ConnectedNodes()) > 0
}) {
t.Fatal("an agent registered under a node id its certificate does not authorize")
}
}
func TestUnauthenticatedAgentIsRefused(t *testing.T) {
// The defensive half of the check: identity is required, but the connection
// carried none. In the shipped wiring TLS and this flag are tied together,
// so reaching here means someone untied them — and the answer must still be
// refusal rather than a silently trusted node id.
inv := inventory.NewStore()
svc := agentsvc.New(inv, newRecorder(), true, slog.New(slog.DiscardHandler))
path, handler := svc.Handler()
mux := http.NewServeMux()
mux.Handle(path, handler)
srv := httptest.NewServer(h2c.NewHandler(mux, &http2.Server{}))
t.Cleanup(srv.Close)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node01"
cfg.Server = srv.URL
runAgent(t, cfg)
if eventually(t, 2*time.Second, registered(svc, inv, "llmgw-node01")) {
t.Fatal("an agent with no proven identity reached the control plane")
}
}
func TestPlaintextAgentIsRejectedWhenTLSIsConfigured(t *testing.T) {
// A misconfiguration worth catching at startup rather than at runtime: an
// agent holding certificates but dialling http:// would connect in the
// clear and never learn that it had.
_, clientTLS := pki(t, "llmgw-node01")
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node01"
cfg.Server = "http://127.0.0.1:9443"
cfg.TLS = clientTLS
if _, err := New(cfg, nil, slog.New(slog.DiscardHandler)); err == nil {
t.Fatal("an agent with certificates was allowed to dial a plaintext address")
}
}
func TestBareAddressGainsTheRightScheme(t *testing.T) {
_, clientTLS := pki(t, "llmgw-node01")
secure := Config{NodeID: "n", Server: "10.0.0.140:9443", TLS: clientTLS}
if err := secure.normalize(); err != nil {
t.Fatal(err)
}
if secure.Server != "https://10.0.0.140:9443" {
t.Errorf("a configured agent dials %q", secure.Server)
}
plain := Config{NodeID: "n", Server: "10.0.0.140:9443"}
if err := plain.normalize(); err != nil {
t.Fatal(err)
}
if plain.Server != "http://10.0.0.140:9443" {
t.Errorf("an unconfigured agent dials %q", plain.Server)
}
}
func TestRefusedAgentLearnsWhy(t *testing.T) {
// An agent that cannot register must be able to say why. Reporting a
// refusal as a clean disconnect leaves an operator with a silent reconnect
// loop and nothing to act on — and resets the backoff, so the loop is a
// fast one.
serverTLS, clientTLS := pki(t, "llmgw-node01")
_, _, url := startTLS(t, serverTLS)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node02"
cfg.Server = url
cfg.TLS = clientTLS
cfg.Discovery.SkipGPUs = true
cfg.Discovery.IBRoot = filepath.Join(t.TempDir(), "absent")
cfg.Superv = supervisor.Config{WorkDir: t.TempDir(), LogLines: 8}
client, err := Client(cfg.TLS, 5*time.Second)
if err != nil {
t.Fatal(err)
}
ag, err := New(cfg, client, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
err = ag.session(context.Background(), ag.cfg.Server)
if err == nil {
t.Fatal("a refused session reported success")
}
if !strings.Contains(err.Error(), "llmgw-node01") || !strings.Contains(err.Error(), "llmgw-node02") {
t.Errorf("the error names neither certificate nor claim: %v", err)
}
}
// TestAnAgentWithNothingRunningStillTellsTheControlPlane covers the gap that
// made a reconciler fix dead code in production.
//
// "I am running nothing" is information: it is how the control plane learns
// that an engine died while its agent survived. The service used to return
// early on an empty list, so that message never reached the reconciler — while
// the reconciler's own tests, which call it directly, kept passing.
//
// This test goes through the real stream for that reason.
func TestAnAgentWithNothingRunningStillTellsTheControlPlane(t *testing.T) {
serverTLS, clientTLS := pki(t, "llmgw-node01")
inv := inventory.NewStore()
rec := newRecorder()
svc := agentsvc.New(inv, rec, true, slog.New(slog.DiscardHandler))
path, handler := svc.Handler()
mux := http.NewServeMux()
mux.Handle(path, handler)
tc, err := serverTLS.ServerConfig()
if err != nil {
t.Fatal(err)
}
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := httptest.NewUnstartedServer(mux)
srv.Listener = raw
srv.TLS = tc
srv.StartTLS()
t.Cleanup(srv.Close)
cfg := DefaultConfig()
cfg.NodeID = "llmgw-node01"
cfg.Server = "https://" + raw.Addr().String()
cfg.TLS = clientTLS
runAgent(t, cfg)
// The agent has no shards at all, which is the whole point.
if !eventually(t, 10*time.Second, func() bool {
return len(rec.adoptCalls()) > 0
}) {
t.Fatal("an agent running nothing never reached the reconciler")
}
for _, call := range rec.adoptCalls() {
if call.nodeID != "llmgw-node01" {
t.Errorf("adopt was told node %q", call.nodeID)
}
}
}
Loaded 100 of 558 files, more files were not shown because too many files have changed in this diff. Show more